sinatra-bouncer 1.3.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/RELEASE_NOTES.md ADDED
@@ -0,0 +1,170 @@
1
+ # Release Notes
2
+
3
+ All notable changes to this project will be documented below.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) and this project loosely follows
6
+ [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Major Changes
11
+
12
+ * none
13
+
14
+ ### Minor Changes
15
+
16
+ * none
17
+
18
+ ### Bugfixes
19
+
20
+ * none
21
+
22
+ ## [3.0.0] - 2023-11-21
23
+
24
+ ### Major Changes
25
+
26
+ * Changed rules DSL to be role-oriented
27
+ * Removed the `:any` HTTP method wildcard
28
+ * Changed Sinatra API to require calling methods on bouncer object
29
+ * eg. `rules do ... end` should now be `bouncer.rules do ... end`
30
+
31
+ ### Minor Changes
32
+
33
+ * none
34
+
35
+ ### Bugfixes
36
+
37
+ * none
38
+
39
+ ## [2.0.0] - 2023-11-13
40
+
41
+ ### Major Changes
42
+
43
+ * Converted to hash syntax in `can` and `can_sometimes` statements
44
+
45
+ ### Minor Changes
46
+
47
+ * Converted Cucumber tests to Rspec integration tests for consistency
48
+ * HEAD requests are evaluated like GET requests due to semantic equivalence
49
+ * Falsey values are now acceptable rule block results
50
+
51
+ ### Bugfixes
52
+
53
+ * none
54
+
55
+ ## [1.3.0] - 2023-09-13
56
+
57
+ ### Major Changes
58
+
59
+ * none
60
+
61
+ ### Minor Changes
62
+
63
+ * Increased minimum Ruby to 3.1
64
+ * Cleaned up development dependencies
65
+ * Rubocop cleanups
66
+ * Installed Simplecov
67
+ * Created Rakefile
68
+
69
+ ### Bugfixes
70
+
71
+ * none
72
+
73
+ ## [1.2.0] - 2016-10-02
74
+
75
+ ### Major Changes
76
+
77
+ * none
78
+
79
+ ### Minor Changes
80
+
81
+ * Supports wildcard matches in route strings
82
+
83
+ ### Bugfixes
84
+
85
+ * none
86
+
87
+ ## [1.1.1] - 2015-06-02
88
+
89
+ ### Major Changes
90
+
91
+ * none
92
+
93
+ ### Minor Changes
94
+
95
+ * none
96
+
97
+ ### Bugfixes
98
+
99
+ * Runs `bounce_with` in context of web request
100
+
101
+ ## [1.1.0] - 2015-05-28
102
+
103
+ ### Major Changes
104
+
105
+ * none
106
+
107
+ ### Minor Changes
108
+
109
+ * none
110
+
111
+ ### Bugfixes
112
+
113
+ * Correctly forgets rules between routes
114
+
115
+ ## [1.0.2] - 2015-05-24
116
+
117
+ ### Major Changes
118
+
119
+ * none
120
+
121
+ ### Minor Changes
122
+
123
+ * Changed default halt to 403
124
+ * Application available in rule block
125
+
126
+ ### Bugfixes
127
+
128
+ * Fixed sinatra module registration
129
+
130
+ ## [1.0.1] - 2015-05-21
131
+
132
+ ### Major Changes
133
+
134
+ * none
135
+
136
+ ### Minor Changes
137
+
138
+ * none
139
+
140
+ ### Bugfixes
141
+
142
+ * none
143
+
144
+ ## [1.0.0] - Unreleased Prototype
145
+
146
+ ### Major Changes
147
+
148
+ * none
149
+
150
+ ### Minor Changes
151
+
152
+ * none
153
+
154
+ ### Bugfixes
155
+
156
+ * none
157
+
158
+ ## [0.1.0] - Unreleased Prototype
159
+
160
+ ### Major Changes
161
+
162
+ * Initial prototype
163
+
164
+ ### Minor Changes
165
+
166
+ * none
167
+
168
+ ### Bugfixes
169
+
170
+ * none
@@ -4,65 +4,62 @@ require_relative 'rule'
4
4
 
5
5
  module Sinatra
6
6
  module Bouncer
7
+ # Core implementation of Bouncer logic
7
8
  class BasicBouncer
8
- attr_accessor :bounce_with, :rules_initializer
9
+ attr_accessor :rules_initializer
9
10
 
10
11
  def initialize
11
- # @rules = Hash.new do |method_to_paths, method|
12
- # method_to_paths[method] = Hash.new do |path_to_rules, path|
13
- # path_to_rules[path] = []
14
- # end
15
- # end
12
+ @rules = []
16
13
 
17
- @ruleset = Hash.new do
18
- []
14
+ role :anyone do
15
+ true
19
16
  end
20
17
 
21
18
  @rules_initializer = proc {}
19
+ @bounce_strategy = proc do
20
+ halt 403
21
+ end
22
22
  end
23
23
 
24
- def reset!
25
- @ruleset.clear
24
+ def rules(&block)
25
+ instance_exec(&block)
26
+ @rules.each do |rule|
27
+ raise BouncerError, 'rules block error: missing #can or #can_sometimes call' if rule.incomplete?
28
+ end
26
29
  end
27
30
 
28
- def can(method, *paths)
29
- if block_given?
30
- hint = 'If you wish to conditionally allow, use #can_sometimes instead.'
31
- raise BouncerError, "You cannot provide a block to #can. #{ hint }"
32
- end
31
+ def can?(method, path, context)
32
+ method = method.downcase.to_sym unless method.is_a? Symbol
33
33
 
34
- can_sometimes(method, *paths) do
35
- true
34
+ @rules.any? do |rule|
35
+ rule.allow? method, path, context
36
36
  end
37
37
  end
38
38
 
39
- def can_sometimes(method, *paths, &block)
40
- unless block
41
- hint = 'If you wish to always allow, use #can instead.'
42
- raise BouncerError, "You must provide a block to #can_sometimes. #{ hint }"
43
- end
44
-
45
- paths.each do |path|
46
- @ruleset[method] += [Rule.new(path, &block)]
47
- end
39
+ def bounce_with(&block)
40
+ @bounce_strategy = block
48
41
  end
49
42
 
50
- def can?(method, path)
51
- rules = (@ruleset[:any_method] + @ruleset[method]).select { |rule| rule.match_path?(path) }
43
+ def bounce(instance)
44
+ instance.instance_exec(&@bounce_strategy)
45
+ end
52
46
 
53
- rules.any? do |rule_block|
54
- ruling = rule_block.rule_passes?
47
+ def role(identifier, &block)
48
+ raise ArgumentError, 'must provide a role identifier to #role' unless identifier
49
+ raise ArgumentError, 'must provide a role condition block to #role' unless block
50
+ raise ArgumentError, "role called '#{ identifier }' already defined" if respond_to? identifier
55
51
 
56
- ruling
52
+ define_singleton_method identifier do
53
+ add_rule(&block)
57
54
  end
58
55
  end
59
56
 
60
- def bounce(instance)
61
- if bounce_with
62
- instance.instance_exec(&bounce_with)
63
- else
64
- instance.halt 403
65
- end
57
+ private
58
+
59
+ def add_rule(&block)
60
+ rule = Rule.new(&block)
61
+ @rules << rule
62
+ rule
66
63
  end
67
64
  end
68
65
 
@@ -2,42 +2,122 @@
2
2
 
3
3
  module Sinatra
4
4
  module Bouncer
5
+ # Defines a RuleSet to be evaluated with each request
5
6
  class Rule
6
- def initialize(path, &rule_block)
7
- if path == :all
8
- @path = :all
9
- else
10
- path = "/#{ path }" unless path.start_with?('/')
7
+ # Enumeration of HTTP method strings based on:
8
+ # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
9
+ # Ignoring CONNECT and TRACE due to rarity
10
+ HTTP_METHODS = %w[GET HEAD PUT POST DELETE OPTIONS PATCH].freeze
11
+
12
+ # Symbol versions of HTTP_METHODS constant
13
+ #
14
+ # @see HTTP_METHODS
15
+ HTTP_METHOD_SYMBOLS = HTTP_METHODS.collect do |http_method|
16
+ http_method.downcase.to_sym
17
+ end.freeze
11
18
 
12
- @path = path.split('/')
19
+ def initialize(&block)
20
+ raise ArgumentError, 'must provide a block to Bouncer::Rule' unless block
21
+
22
+ @routes = Hash.new do
23
+ []
13
24
  end
14
25
 
15
- @rule = rule_block
26
+ @conditions = [block]
16
27
  end
17
28
 
18
- def match_path?(path)
19
- return true if @path == :all
29
+ def can_sometimes(**method_routes, &block)
30
+ if method_routes.empty?
31
+ raise ArgumentError,
32
+ 'must provide a hash where keys are HTTP method symbols and values are one or more path matchers'
33
+ end
20
34
 
21
- path = "/#{ path }" unless path.start_with?('/')
35
+ unless block
36
+ hint = 'If you wish to always allow, use #can instead.'
37
+ raise BouncerError, "You must provide a block to #can_sometimes. #{ hint }"
38
+ end
22
39
 
23
- split_path = path.split('/')
24
- matches = @path.length == split_path.length
40
+ method_routes.each do |method, paths|
41
+ validate_http_method! method
25
42
 
26
- @path.each_index do |i|
27
- allowed_segment = @path[i]
28
- given_segment = split_path[i]
43
+ paths = [paths] unless paths.respond_to? :collect
29
44
 
30
- matches &= given_segment == allowed_segment || allowed_segment == '*'
45
+ @routes[method] += paths.collect { |path| normalize_path path }
31
46
  end
32
47
 
33
- matches
48
+ @conditions << block
34
49
  end
35
50
 
36
- def rule_passes?
37
- ruling = @rule.call
51
+ def can(**method_routes)
52
+ if block_given?
53
+ hint = 'If you wish to conditionally allow, use #can_sometimes instead.'
54
+ raise BouncerError, "You cannot provide a block to #can. #{ hint }"
55
+ end
56
+
57
+ can_sometimes(**method_routes) do
58
+ true
59
+ end
60
+ end
61
+
62
+ def allow?(method, path, context)
63
+ match_path?(method, path) && @conditions.all? do |condition|
64
+ rule_passes?(context, &condition)
65
+ end
66
+ end
67
+
68
+ def incomplete?
69
+ @routes.empty?
70
+ end
71
+
72
+ private
73
+
74
+ def validate_http_method!(method)
75
+ return if HTTP_METHOD_SYMBOLS.include?(method)
76
+
77
+ raise BouncerError, "'#{ method }' is not a known HTTP method key. Must be one of: #{ HTTP_METHOD_SYMBOLS }"
78
+ end
38
79
 
39
- unless ruling.is_a?(TrueClass) || ruling.is_a?(FalseClass)
40
- source = @rule.source_location.join(':')
80
+ # Determines if the path matches the exact path or wildcard.
81
+ #
82
+ # @return `true` if the path matches
83
+ def match_path?(method, trial_path)
84
+ trial_path = normalize_path trial_path
85
+
86
+ matchers_for(method).any? do |matcher|
87
+ return true if matcher == :all
88
+
89
+ matcher_parts = matcher.split '/'
90
+ trial_parts = trial_path.split '/'
91
+ matches = matcher_parts.length == trial_parts.length
92
+
93
+ matcher_parts.each_index do |i|
94
+ allowed_segment = matcher_parts[i]
95
+ given_segment = trial_parts[i]
96
+
97
+ matches &= given_segment == allowed_segment || allowed_segment == '*'
98
+ end
99
+
100
+ matches
101
+ end
102
+ end
103
+
104
+ def matchers_for(method)
105
+ matchers = @routes[method]
106
+
107
+ matchers += @routes[:get] if method == :head
108
+
109
+ matchers
110
+ end
111
+
112
+ # Evaluates the rule's block. Defensively prevents truthy values from the block from allowing a route.
113
+ #
114
+ # @raise BouncerError when the rule block is a truthy value but not exactly `true`
115
+ # @return Exactly `true` or `false`, depending on the result of the rule block
116
+ def rule_passes?(context, &rule)
117
+ ruling = context.instance_exec(&rule)
118
+
119
+ unless !ruling || ruling.is_a?(TrueClass)
120
+ source = rule.source_location.join(':')
41
121
  msg = <<~ERR
42
122
  Rule block at does not return explicit true/false.
43
123
  Rules must return explicit true or false to prevent accidental truthy values.
@@ -47,7 +127,19 @@ module Sinatra
47
127
  raise BouncerError, msg
48
128
  end
49
129
 
50
- ruling
130
+ !!ruling
131
+ end
132
+
133
+ def normalize_path(path)
134
+ if path == :all
135
+ path
136
+ else
137
+ if path.start_with?('/')
138
+ path
139
+ else
140
+ "/#{ path }"
141
+ end.downcase
142
+ end
51
143
  end
52
144
  end
53
145
  end
@@ -3,6 +3,6 @@
3
3
  module Sinatra
4
4
  module Bouncer
5
5
  # Current version of the gem
6
- VERSION = '1.3.0'
6
+ VERSION = '3.0.0'
7
7
  end
8
8
  end
@@ -23,52 +23,21 @@
23
23
  # WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
24
24
  #++
25
25
 
26
+ require 'sinatra/base'
26
27
  require_relative 'bouncer/basic_bouncer'
27
28
 
29
+ # Namespace module
28
30
  module Sinatra
31
+ # Namespace module
29
32
  module Bouncer
30
33
  def self.registered(base_class)
31
- base_class.helpers HelperMethods
32
-
33
- bouncer = BasicBouncer.new
34
-
35
- # TODO: can we instead store it somehow on the actual temp request object?
36
- base_class.set :bouncer, bouncer
34
+ base_class.set :bouncer, BasicBouncer.new
37
35
 
38
36
  base_class.before do
39
- bouncer.reset! # must clear all rules otherwise will leave doors open
40
-
41
- instance_exec(&bouncer.rules_initializer)
42
-
43
- http_method = request.request_method.downcase.to_sym
44
- path = request.path.downcase
45
-
46
- bouncer.bounce(self) unless bouncer.can?(http_method, path)
47
- end
48
- end
49
-
50
- # Start ExtensionMethods
51
- def bounce_with(&block)
52
- bouncer.bounce_with = block
53
- end
54
-
55
- def rules(&block)
56
- bouncer.rules_initializer = block
57
- end
58
- # End ExtensionMethods
59
-
60
- module HelperMethods
61
- def can(*args)
62
- settings.bouncer.can(*args)
63
- end
64
-
65
- def can_sometimes(...)
66
- settings.bouncer.can_sometimes(...)
37
+ settings.bouncer.bounce(self) unless settings.bouncer.can?(request.request_method, request.path, self)
67
38
  end
68
39
  end
69
40
  end
70
41
 
71
- if defined? register
72
- register Bouncer
73
- end
42
+ register Sinatra::Bouncer
74
43
  end
@@ -19,7 +19,10 @@ Gem::Specification.new do |spec|
19
19
  'rubygems_mfa_required' => 'true'
20
20
  }
21
21
 
22
- spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features|integrations)/}) }
22
+ spec.files = `git ls-files -z`.split("\x0").reject do |f|
23
+ f.match(%r{^(test|spec|features|integrations|benchmarks)/})
24
+ end
25
+
23
26
  spec.require_paths = ['lib']
24
27
 
25
28
  spec.required_ruby_version = '>= 3.1'
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sinatra-bouncer
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.3.0
4
+ version: 3.0.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tenjin Inc
@@ -9,7 +9,7 @@ authors:
9
9
  autorequire:
10
10
  bindir: bin
11
11
  cert_chain: []
12
- date: 2023-09-13 00:00:00.000000000 Z
12
+ date: 2023-11-22 00:00:00.000000000 Z
13
13
  dependencies:
14
14
  - !ruby/object:Gem::Dependency
15
15
  name: sinatra
@@ -37,9 +37,12 @@ files:
37
37
  - ".rubocop.yml"
38
38
  - ".ruby-version"
39
39
  - ".simplecov"
40
+ - CODE_OF_CONDUCT.md
40
41
  - Gemfile
42
+ - Gemfile.lock
41
43
  - MIT-LICENSE
42
44
  - README.md
45
+ - RELEASE_NOTES.md
43
46
  - Rakefile
44
47
  - cucumber.yml
45
48
  - lib/sinatra/bouncer.rb
@@ -47,18 +50,6 @@ files:
47
50
  - lib/sinatra/bouncer/rule.rb
48
51
  - lib/sinatra/bouncer/version.rb
49
52
  - sinatra_bouncer.gemspec
50
- - tests/integrations/dev_defines_legal_routes.feature
51
- - tests/integrations/dev_installs_bouncer.feature
52
- - tests/integrations/step_definitions/given.rb
53
- - tests/integrations/step_definitions/then.rb
54
- - tests/integrations/step_definitions/when.rb
55
- - tests/integrations/support/env.rb
56
- - tests/integrations/support/helpers.rb
57
- - tests/integrations/support/types.rb
58
- - tests/spec/basic_bouncer_spec.rb
59
- - tests/spec/rule_spec.rb
60
- - tests/spec/spec_helper.rb
61
- - tests/test_app.rb
62
53
  homepage: https://github.com/TenjinInc/Sinatra-Bouncer
63
54
  licenses:
64
55
  - MIT
@@ -1,57 +0,0 @@
1
- Feature: Developer defines legal routes
2
- As a developer
3
- So that clients can safely access my server
4
- I will allow specific routes
5
-
6
- Scenario Outline: it should allows access to whitelist routes
7
- Given a sinatra server with bouncer and routes:
8
- | method | path | allowed |
9
- | get | <path> | yes |
10
- When I visit /<path>
11
- Then it should be at /<path>
12
- And it should have status code 200
13
- Examples:
14
- | path |
15
- | some_path |
16
- | another_path |
17
-
18
- Scenario: it should NOT allow access to other routes
19
- Given a sinatra server with bouncer and routes:
20
- | method | path | allowed |
21
- | get | some_path | yes |
22
- | get | illegal_path | no |
23
- When I visit /illegal_path
24
- Then it should have status code 403
25
-
26
- Scenario Outline: it should allow multiple routes with a splat
27
- Given a sinatra server with bouncer and routes:
28
- | method | path | allowed |
29
- | get | admin/* | yes |
30
- When I visit /admin/<sub_path>
31
- Then it should be at /admin/<sub_path>
32
- And it should have status code 200
33
- Examples:
34
- | sub_path |
35
- | dashboard |
36
- | users |
37
-
38
- Scenario Outline: it should allow splat to be in the middle of the route
39
- Given a sinatra server with bouncer and routes:
40
- | method | path | allowed |
41
- | get | admin/*/create | yes |
42
- When I visit /admin/<sub_path>/create
43
- Then it should be at /admin/<sub_path>/create
44
- And it should have status code 200
45
- Examples:
46
- | sub_path |
47
- | tasks |
48
- | users |
49
-
50
- Scenario: it should forget rules between requests
51
- Given a sinatra server with bouncer and routes:
52
- | method | path | allowed |
53
- | get | some_path | once |
54
- When I double visit /some_path
55
- Then it should be at /some_path
56
- And it should have status code 403
57
-
@@ -1,12 +0,0 @@
1
- Feature: Developer installs Bouncer
2
- As a developer
3
- So that I can secure my sinatra server
4
- I will install bouncer
5
-
6
- Scenario: it should auto protect all routes
7
- Given a sinatra server with bouncer and routes:
8
- | method | path |
9
- | get | some_path |
10
- When I visit /some_path
11
- Then it should have status code 403
12
-
@@ -1,36 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Given 'a sinatra server with bouncer and routes:' do |table|
4
- app = Capybara.app
5
-
6
- allowed_paths = []
7
-
8
- table.hashes.each do |row|
9
- path = row[:path]
10
- path = "/#{ path }" if path[0] != '/' # ensure leading slash
11
-
12
- method = row[:method].to_sym
13
- # build the routes
14
- app.send(method, path) do
15
- 'The result of path'
16
- end
17
-
18
- is_once = row[:allowed] =~ /once/i
19
-
20
- next unless is_once || parse_bool(row[:allowed])
21
-
22
- allowed_paths << path
23
-
24
- @allowed_once_paths << path if is_once
25
- end
26
-
27
- onces = @allowed_once_paths
28
-
29
- app.rules do
30
- allowed_paths.each do |path|
31
- can_sometimes(:any_method, path) do
32
- !onces.include?(path)
33
- end
34
- end
35
- end
36
- end
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- Then 'it should have status code {int}' do |status|
4
- expect(page.driver.status_code).to eq status
5
- end
6
-
7
- Then 'it should be at {path}' do |path|
8
- expect(page).to have_current_path path.to_s
9
- end
@@ -1,11 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- When 'he/she/they/I double visit(s) {path}' do |path|
4
- visit path
5
- visit '/'
6
- visit path
7
- end
8
-
9
- When 'he/she/they/I visit(s) {path}' do |path|
10
- visit path
11
- end