sinatra-bouncer 2.0.0 → 3.0.0

Sign up to get free protection for your applications and to get access to all the features.
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