sinatra-bouncer 2.0.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f07c9c87e55563ac21610aecc8813d32ee3322d291382bb1bd5cca0ce0d55a12
4
- data.tar.gz: fa55400409722c0f0f4ea83c6ee552654d0d8aee128d484f3a42c2c888e9b46b
3
+ metadata.gz: 849fed52904df15b1cc036685383f197e47aae52bef8283a81decc281fc95660
4
+ data.tar.gz: 3a3ba41d0d3c2ad70b1c4e48b81558213111d86a750717e2b8f44dea9072ca1f
5
5
  SHA512:
6
- metadata.gz: 4fc6e73dcb5867a418dea6b555f74fee57f41b58d75ab76657925a71b1b2ff059295135c30646f4de83fe82a00bc816a6d668d54ecd64f035b63f27d68b80ef1
7
- data.tar.gz: ca808e79a1f59743c17afc6e7475dd03a9a3186d8ed1ec3c6b4412ec96af2c38c0e454a0fada15c513e83c1070eb7b6f72c2a116be9e2ed7135592ce7f04ce70
6
+ metadata.gz: d63e6a5437ef4d71e7183dce458241a8efaebace1c6f95e77766412f2faf2d5d490107a4e925588509dfb8e73b0c2e3a21064e73aae4cb3f98ae0c8465f19dd5
7
+ data.tar.gz: ca6d627ae51d9ecf6c88ffa21621117adb3af2eee6c83d14846f8ac7a5aa314f9ce4d2d10c30baf192e4a7c435f750bfb5b9bc3c37ed308ee4a5255a8df0eacf
data/Gemfile.lock CHANGED
@@ -1,7 +1,7 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- sinatra-bouncer (2.0.0)
4
+ sinatra-bouncer (3.0.0)
5
5
  sinatra (>= 2.2)
6
6
 
7
7
  GEM
data/README.md CHANGED
@@ -5,35 +5,38 @@ routes are permitted based on your own logic.
5
5
 
6
6
  ## Big Picture
7
7
 
8
- Bouncer rules look like:
8
+ Bouncer's syntax looks like:
9
9
 
10
10
  ```ruby
11
- rules do
11
+ # You can define roles to collect permissions together
12
+ bouncer.role :admins do
13
+ current_user&.admin?
14
+ end
15
+
16
+ bouncer.rules do
12
17
  # Routes match based on one or more strings.
13
- can get: '/',
14
- post: %w[/user/sign-in
15
- /user/sign-out]
18
+ anyone.can get: '/',
19
+ post: ['/user/sign-in',
20
+ '/user/sign-out']
16
21
 
17
22
  # Wildcards match anything directly under that path
18
- can get: '/lib/js/*'
23
+ anyone.can get: '/lib/js/*'
19
24
 
20
- # Use a conditional rule block for additional logic
21
- can_sometimes get: '/admin/*',
22
- post: '/admin/actions/*' do
23
- current_user.admin?
24
- end
25
+ admins.can get: '/admin/*',
26
+ post: '/admin/actions/*'
25
27
 
26
28
  # ... etc ...
27
29
  end
28
-
29
30
  ```
30
31
 
31
- ## Features
32
+ ### Features
32
33
 
33
34
  Here's what this Gem provides:
34
35
 
35
36
  * **Block-by-default**
36
37
  * Any route must be explicitly allowed
38
+ * **Role-Oriented**
39
+ * The [DSL](https://en.wikipedia.org/wiki/Domain-specific_language) is constructed to be easily readable
37
40
  * **Declarative Syntax**
38
41
  * Straightforward syntax reduces complexity of layered permissions
39
42
  * Keeps permissions together for clarity
@@ -44,7 +47,7 @@ Here's what this Gem provides:
44
47
  * **Forced Boolean Affirmation**
45
48
  * Condition blocks must explicitly return `true`, avoiding accidental truthy values
46
49
 
47
- ## Anti-Features
50
+ ### Anti-Features
48
51
 
49
52
  Bouncer intentionally does not support some concepts.
50
53
 
@@ -80,7 +83,7 @@ require 'sinatra/bouncer'
80
83
  class MyApp < Sinatra::Base
81
84
  register Sinatra::Bouncer
82
85
 
83
- rules do
86
+ bouncer.rules do
84
87
  # ... can statements ...
85
88
  end
86
89
 
@@ -94,7 +97,7 @@ end
94
97
  require 'sinatra'
95
98
  require 'sinatra/bouncer'
96
99
 
97
- rules do
100
+ bouncer.rules do
98
101
  # ... can statements ...
99
102
  end
100
103
 
@@ -103,57 +106,77 @@ end
103
106
 
104
107
  ## Usage
105
108
 
106
- Call `rules` with a block that uses the `#can` and `#can_sometimes` DSL methods to declare rules for paths.
109
+ Define roles using the `role` method and provide each one with a condition block. Then call `rules` with a block that
110
+ uses your defined roles with the `#can` or `#can_sometimes` DSL methods to declare which
111
+ paths are allowed.
107
112
 
108
113
  The rules block is run once as part of the configuration phase but the condition blocks are evaluated in the context of
109
- the
110
- request, which means you will have access to Sinatra helpers,
111
- the `request` object, and `params`.
114
+ the request handler. This means they have access to Sinatra helpers, the `request` object, and `params`.
112
115
 
113
116
  ```ruby
114
117
  require 'sinatra'
115
118
  require 'sinatra/bouncer'
116
119
 
117
- rules do
118
- # example: always allow GET requests to root path or /sign-in
119
- can get: %w[/
120
- /sign-in]
120
+ bouncer.role :members do
121
+ !current_user.nil?
122
+ end
123
+
124
+ bouncer.role :bots do
125
+ !request.get_header('X-CUSTOM-BOT').nil?
126
+ end
127
+
128
+ bouncer.rules do
129
+ # example: always allow GET requests to '/' and '/about'; and POST requests to '/sign-in'
130
+ anyone.can get: ['/', '/about'],
131
+ post: '/sign-in'
121
132
 
122
133
  # example: logged in users can view (GET) member restricted paths and edit their account (POST)
123
- can_sometimes get: '/members/*',
124
- post: '/members/edit-account' do
125
- !current_user.nil?
134
+ members.can get: '/members/*'
135
+ members.can_sometimes post: '/members/edit-account' do
136
+ current_user && current_user.id == params[:id]
126
137
  end
127
138
 
128
- # example: check an arbitrary request header is present
129
- can_sometimes get: '/bots/*' do
130
- !request.get_header('X-CUSTOM_PROP').nil?
131
- end
139
+ # example: require presence of arbitrary request header in the role condition
140
+ bots.can get: '/bots/*'
132
141
  end
133
142
 
134
143
  # ... Sinatra route declarations as normal ...
135
144
  ```
136
145
 
146
+ ### Role Declarations
147
+
148
+ Roles are declared using the `#role` method in your Sinatra config. Each one must be provided a condition block that
149
+ must return exactly `true` when the role applies.
150
+
151
+ ```ruby
152
+ # let's pretend that current_user is a helper that returns the user from Warden
153
+ bouncer.role :admins do
154
+ current_user&.admin?
155
+ end
156
+ ```
157
+
158
+ > **Note:** There is a default role called `anyone` that is always declared for you.
159
+
137
160
  ### HTTP Method and Route Matching
138
161
 
139
- Both `#can` and `#can_sometimes` accept multiple
140
- [HTTP methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods) as symbols
162
+ Both `#can` and `#can_sometimes` accept symbol
163
+ [HTTP methods](https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods) as keys
141
164
  and each key is paired with one or more path strings.
142
165
 
143
166
  ```ruby
144
167
  # example: single method, single route
145
- can get: '/'
168
+ anyone.can get: '/'
146
169
 
147
170
  # example: multiple methods, single route each
148
- can get: '/',
149
- post: '/blog/action/save'
171
+ anyone.can get: '/',
172
+ post: '/blog/action/save'
150
173
 
151
174
  # example: multiple methods, multiple routes (using string array syntax)
152
- can get: %w[/
153
- /sign-in
154
- /blog/editor],
155
- post: %w[/blog/action/save
156
- /blog/action/delete]
175
+ anyone.can get: %w[/
176
+ /sign-in
177
+ /blog/editor],
178
+ post: %w[/blog/action/save
179
+ /blog/action/delete]
157
180
  ```
158
181
 
159
182
  > **Note** Allowing GET implies allowing HEAD, since HEAD is by spec a GET without a response body. The reverse is not
@@ -161,55 +184,49 @@ can get: %w[/
161
184
 
162
185
  #### Wildcards and Special Symbols
163
186
 
164
- > **Warning** Always be cautious when using wildcards and special symbols to not accidentally open up pathways that
165
- > should remain private.
166
-
167
187
  Provide a wildcard `*` to match any string excluding slash. There is intentionally no syntax for matching wildcards
168
188
  recursively, so nested paths will also need to be declared.
169
189
 
170
190
  ```ruby
171
191
  # example: match anything directly under the /members/ path
172
- can get: '/members/*'
192
+ members.can get: '/members/*'
173
193
  ```
174
194
 
175
- There are also 2 special symbols:
176
-
177
- 1. `:any` will match any HTTP method.
178
- 2. `:all` will match all paths.
195
+ There is also a special symbol, `:all` that matches all paths. It is intended for rare use
196
+ with superadmin-type accounts.
179
197
 
180
198
  ```ruby
181
- # this allows any method type on the / path
182
- can any: '/'
183
-
184
- # this allows GET on all paths
185
- can get: :all
199
+ # this allows GET on all paths to those in the admin group
200
+ admins.can get: :all
186
201
  ```
187
202
 
203
+ > **Warning** Always be cautious when using wildcards and special symbols to avoid accidentally opening up pathways that
204
+ > should remain private.
205
+
188
206
  ### Always Allow: `can`
189
207
 
190
208
  Any route declared with `#can` will be accepted without further challenge.
191
209
 
192
210
  ```ruby
193
- rules do
211
+ bouncer.rules do
194
212
  # Anyone can access this path over GET
195
- can get: '/login'
213
+ anyone.can get: '/login'
196
214
  end
197
215
  ```
198
216
 
199
217
  ### Conditionally Allow: `can_sometimes`
200
218
 
201
- `can_sometimes` is for occasions that you to check further, but want to defer that choice until the path is actually
202
- attempted.
203
- `can_sometimes` takes a block that will be run once the path is attempted. This block **must return an explicit boolean
204
- **
205
- (ie. `true` or `false`) to avoid any accidental truthy values creating unwanted access.
219
+ `can_sometimes` takes a condition block that will be run once the path is attempted. This block **must return an
220
+ explicit boolean** (ie. `true` or `false`) to avoid any accidental truthy values creating unwanted access.
206
221
 
207
222
  ```ruby
208
- rules do
209
- can_sometimes get: '/login' # Anyone can access this path over GET
223
+ bouncer.role :users do
224
+ !current_user.nil?
225
+ end
210
226
 
211
- can_sometimes post: '/user/blog/actions/save' do
212
- !current_user.nil?
227
+ bouncer.rules do
228
+ users.can_sometimes post: '/user/save' do
229
+ current_user.id == params[:id]
213
230
  end
214
231
  end
215
232
  ```
@@ -217,7 +234,7 @@ end
217
234
  ### Custom Bounce Behaviour
218
235
 
219
236
  The default bounce action is to `halt 403`. Call `bounce_with` with a block to specify your own behaviour. The block is
220
- also run in a sinatra request context, so you can use helpers here as well.
237
+ also run in a Sinatra request handler context, so you can use helpers here as well.
221
238
 
222
239
  ```ruby
223
240
  require 'sinatra'
data/RELEASE_NOTES.md CHANGED
@@ -19,6 +19,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
19
19
 
20
20
  * none
21
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
+
22
39
  ## [2.0.0] - 2023-11-13
23
40
 
24
41
  ### Major Changes
@@ -6,81 +6,60 @@ module Sinatra
6
6
  module Bouncer
7
7
  # Core implementation of Bouncer logic
8
8
  class BasicBouncer
9
- attr_accessor :bounce_with, :rules_initializer
10
-
11
- # Enumeration of HTTP method strings based on:
12
- # https://developer.mozilla.org/en-US/docs/Web/HTTP/Methods
13
- # Ignoring CONNECT and TRACE due to rarity
14
- HTTP_METHODS = %w[GET HEAD PUT POST DELETE OPTIONS PATCH].freeze
15
-
16
- # Symbol versions of HTTP_METHODS constant
17
- #
18
- # @see HTTP_METHODS
19
- HTTP_METHOD_SYMBOLS = HTTP_METHODS.collect do |http_method|
20
- http_method.downcase.to_sym
21
- end.freeze
22
-
23
- # Method symbol used to match any method
24
- WILDCARD_METHOD = :any
9
+ attr_accessor :rules_initializer
25
10
 
26
11
  def initialize
27
- @ruleset = Hash.new do
28
- []
29
- end
12
+ @rules = []
30
13
 
31
- @rules_initializer = proc {}
32
- end
33
-
34
- def can(**method_routes)
35
- if block_given?
36
- hint = 'If you wish to conditionally allow, use #can_sometimes instead.'
37
- raise BouncerError, "You cannot provide a block to #can. #{ hint }"
14
+ role :anyone do
15
+ true
38
16
  end
39
17
 
40
- can_sometimes(**method_routes) do
41
- true
18
+ @rules_initializer = proc {}
19
+ @bounce_strategy = proc do
20
+ halt 403
42
21
  end
43
22
  end
44
23
 
45
- def can_sometimes(**method_routes, &block)
46
- unless block
47
- hint = 'If you wish to always allow, use #can instead.'
48
- raise BouncerError, "You must provide a block to #can_sometimes. #{ hint }"
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?
49
28
  end
29
+ end
50
30
 
51
- method_routes.each do |method, paths|
52
- unless HTTP_METHOD_SYMBOLS.include?(method) || method == WILDCARD_METHOD
53
- hint = "Must be one of: #{ HTTP_METHOD_SYMBOLS } or :#{ WILDCARD_METHOD }"
54
- raise BouncerError, "'#{ method }' is not a known HTTP method key. #{ hint }"
55
- end
56
-
57
- paths = [paths] unless paths.respond_to? :collect
31
+ def can?(method, path, context)
32
+ method = method.downcase.to_sym unless method.is_a? Symbol
58
33
 
59
- @ruleset[method] += paths.collect { |path| Rule.new(path, &block) }
34
+ @rules.any? do |rule|
35
+ rule.allow? method, path, context
60
36
  end
61
37
  end
62
38
 
63
- def can?(method, path, context)
64
- rulesets = @ruleset[WILDCARD_METHOD] + @ruleset[method]
65
-
66
- # HEAD requests are equivalent to GET requests without response
67
- rulesets += @ruleset[:get] if method == :head
39
+ def bounce_with(&block)
40
+ @bounce_strategy = block
41
+ end
68
42
 
69
- rules = rulesets.select { |rule| rule.match_path?(path) }
43
+ def bounce(instance)
44
+ instance.instance_exec(&@bounce_strategy)
45
+ end
70
46
 
71
- rules.any? do |rule_block|
72
- ruling = rule_block.rule_passes? context
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
73
51
 
74
- ruling
52
+ define_singleton_method identifier do
53
+ add_rule(&block)
75
54
  end
76
55
  end
77
56
 
78
- def bounce(instance)
79
- if bounce_with
80
- instance.instance_exec(&bounce_with)
81
- else
82
- instance.halt 403
83
- end
57
+ private
58
+
59
+ def add_rule(&block)
60
+ rule = Rule.new(&block)
61
+ @rules << rule
62
+ rule
84
63
  end
85
64
  end
86
65
 
@@ -2,50 +2,122 @@
2
2
 
3
3
  module Sinatra
4
4
  module Bouncer
5
- # Defines a Rule to be evaluated with each request
5
+ # Defines a RuleSet to be evaluated with each request
6
6
  class Rule
7
- def initialize(path, &rule_block)
8
- if path == :all
9
- @path = :all
10
- else
11
- 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
18
+
19
+ def initialize(&block)
20
+ raise ArgumentError, 'must provide a block to Bouncer::Rule' unless block
21
+
22
+ @routes = Hash.new do
23
+ []
24
+ end
25
+
26
+ @conditions = [block]
27
+ end
28
+
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
34
+
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
39
+
40
+ method_routes.each do |method, paths|
41
+ validate_http_method! method
42
+
43
+ paths = [paths] unless paths.respond_to? :collect
44
+
45
+ @routes[method] += paths.collect { |path| normalize_path path }
46
+ end
47
+
48
+ @conditions << block
49
+ end
12
50
 
13
- @path = path.split('/')
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 }"
14
55
  end
15
56
 
16
- @rule = rule_block
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 }"
17
78
  end
18
79
 
19
80
  # Determines if the path matches the exact path or wildcard.
20
81
  #
21
82
  # @return `true` if the path matches
22
- def match_path?(path)
23
- return true if @path == :all
83
+ def match_path?(method, trial_path)
84
+ trial_path = normalize_path trial_path
24
85
 
25
- path = "/#{ path }" unless path.start_with?('/')
86
+ matchers_for(method).any? do |matcher|
87
+ return true if matcher == :all
26
88
 
27
- split_path = path.split('/')
28
- matches = @path.length == split_path.length
89
+ matcher_parts = matcher.split '/'
90
+ trial_parts = trial_path.split '/'
91
+ matches = matcher_parts.length == trial_parts.length
29
92
 
30
- @path.each_index do |i|
31
- allowed_segment = @path[i]
32
- given_segment = split_path[i]
93
+ matcher_parts.each_index do |i|
94
+ allowed_segment = matcher_parts[i]
95
+ given_segment = trial_parts[i]
33
96
 
34
- matches &= given_segment == allowed_segment || allowed_segment == '*'
97
+ matches &= given_segment == allowed_segment || allowed_segment == '*'
98
+ end
99
+
100
+ matches
35
101
  end
102
+ end
103
+
104
+ def matchers_for(method)
105
+ matchers = @routes[method]
36
106
 
37
- matches
107
+ matchers += @routes[:get] if method == :head
108
+
109
+ matchers
38
110
  end
39
111
 
40
112
  # Evaluates the rule's block. Defensively prevents truthy values from the block from allowing a route.
41
113
  #
42
114
  # @raise BouncerError when the rule block is a truthy value but not exactly `true`
43
115
  # @return Exactly `true` or `false`, depending on the result of the rule block
44
- def rule_passes?(context)
45
- ruling = context.instance_exec(&@rule)
116
+ def rule_passes?(context, &rule)
117
+ ruling = context.instance_exec(&rule)
46
118
 
47
119
  unless !ruling || ruling.is_a?(TrueClass)
48
- source = @rule.source_location.join(':')
120
+ source = rule.source_location.join(':')
49
121
  msg = <<~ERR
50
122
  Rule block at does not return explicit true/false.
51
123
  Rules must return explicit true or false to prevent accidental truthy values.
@@ -57,6 +129,18 @@ module Sinatra
57
129
 
58
130
  !!ruling
59
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
143
+ end
60
144
  end
61
145
  end
62
146
  end
@@ -3,6 +3,6 @@
3
3
  module Sinatra
4
4
  module Bouncer
5
5
  # Current version of the gem
6
- VERSION = '2.0.0'
6
+ VERSION = '3.0.0'
7
7
  end
8
8
  end
@@ -34,20 +34,9 @@ module Sinatra
34
34
  base_class.set :bouncer, BasicBouncer.new
35
35
 
36
36
  base_class.before do
37
- http_method = request.request_method.downcase.to_sym
38
- path = request.path.downcase
39
-
40
- settings.bouncer.bounce(self) unless settings.bouncer.can?(http_method, path, self)
37
+ settings.bouncer.bounce(self) unless settings.bouncer.can?(request.request_method, request.path, self)
41
38
  end
42
39
  end
43
-
44
- def bounce_with(&block)
45
- bouncer.bounce_with = block
46
- end
47
-
48
- def rules(&block)
49
- settings.bouncer.instance_exec(&block)
50
- end
51
40
  end
52
41
 
53
42
  register Sinatra::Bouncer
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: 2.0.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-11-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