petergate 3.1.1 → 4.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.
@@ -14,59 +14,285 @@ module Petergate
14
14
  end
15
15
  end
16
16
 
17
+ # Every action on this controller, and nothing else.
18
+ #
19
+ # Rails' own `action_methods` is not that list. `internal_methods` stops
20
+ # at the first abstract controller and then adds back
21
+ # `public_instance_methods(false)` for each concrete class below it, so
22
+ # a public helper defined on a concrete superclass -- `current_user`
23
+ # overridden in ApplicationController, say, or anything `devise_group`
24
+ # generates -- comes back as an action and gets swept into `:all` and
25
+ # `except:` rules.
26
+ #
27
+ # Rather than blocklisting names, ask whether a method could be an
28
+ # action at all: Rails dispatches actions by name with no arguments, and
29
+ # a helper_method is by definition not one.
30
+ #
31
+ # Memoized against the identity of Rails' own action_methods Set. Rails
32
+ # replaces that Set whenever a method is added to the controller, so
33
+ # this inherits Rails' invalidation instead of hooking method_added,
34
+ # which would put a method on every controller in the application for an
35
+ # app to override without noticing.
17
36
  def all_actions
18
- ->{self.action_methods.to_a.map(&:to_sym) - [:check_access, :title]}.call
37
+ methods = action_methods
38
+ return @_petergate_all_actions if @_petergate_action_methods.equal?(methods)
39
+
40
+ # Frozen: it is handed straight to callers, and a caller mutating it
41
+ # would corrupt every later :all and except: expansion.
42
+ actions = petergate_action_names(methods).freeze
43
+
44
+ # The value before the guard. Assigning the guard first would let
45
+ # another thread see it set, take the early return, and get the nil
46
+ # value -- which reaches parse_permission_rules on the authorization
47
+ # path, where `:all` silently expands to nothing and `except:` raises
48
+ # NoMethodError on nil.
49
+ @_petergate_all_actions = actions
50
+ @_petergate_action_methods = methods
51
+ actions
19
52
  end
20
53
 
21
54
  def except_actions(arr = [])
22
55
  all_actions - arr
23
56
  end
24
57
 
25
- def access(rules = {}, &block)
58
+ # The auth model this controller's rules are about, inherited by
59
+ # subclasses. Called with no argument it reads the current value.
60
+ #
61
+ # Takes a Class -- `petergate_scope Employee` -- or a Symbol naming a
62
+ # Devise mapping. A Class works for both an STI subclass sharing one
63
+ # login and a separately mapped model; see Petergate.devise_scope_for.
64
+ def petergate_scope(scope = nil)
65
+ return petergate_default_scope if scope.nil?
66
+
67
+ petergate_validate_scope!(scope)
68
+ self.petergate_default_scope = scope.is_a?(Class) ? scope : scope.to_sym
69
+ end
70
+
71
+ # access [scope], [denial message], role => actions, ...
72
+ #
73
+ # The scope and the message are positional, not keys in the rules hash.
74
+ # Neither names a role, and burying them among the roles made them look
75
+ # like ones -- and made those two names unusable as roles.
76
+ #
77
+ # access all: [:index], admin: :all # current_user, as always
78
+ # access Employee, support: :all # an STI subclass
79
+ # access Vendor, supplier: :all # a separately mapped model
80
+ # access :member, admin: :all # a Devise mapping by name
81
+ # access "Staff only", admin: :all # a denial message
82
+ # access Employee, "Staff only", support: :all
83
+ #
84
+ # A scope is a Class or a Symbol and a message is a String, so they are
85
+ # told apart by type and may be given in either order.
86
+ def access(*args, **rules, &block)
87
+ # A braced hash arrives positionally: access({[:all, :user] => [...]}).
88
+ rules = args.pop.merge(rules) if args.last.is_a?(::Hash)
89
+
90
+ scope = args.find { |arg| arg.is_a?(::Class) || arg.is_a?(::Symbol) }
91
+
92
+ petergate_validate_scope!(scope)
93
+ message = args.find { |arg| arg.is_a?(::String) }
94
+
95
+ # delete_at rather than Array#-, which removes every equal element and
96
+ # so let `access :staff, :staff, ...` through as though the repeat had
97
+ # been asked for.
98
+ unexpected = args.dup
99
+ [scope, message].compact.each { |arg| unexpected.delete_at(unexpected.index(arg)) }
100
+ # empty? rather than any?: `any?` without a block skips falsy
101
+ # elements, so `access false, user: [:index]` was accepted silently.
102
+ unless unexpected.empty?
103
+ raise ArgumentError, "access takes a scope (a class or symbol) and a denial " \
104
+ "message (a string) before its rules, got #{unexpected.inspect}"
105
+ end
106
+
26
107
  if block
27
108
  b_rules = block.call
28
109
  rules = rules.merge(b_rules) if b_rules.is_a?(Hash)
29
110
  end
30
111
 
31
- instance_eval do
32
- @_controller_rules = rules
33
- @_controller_message = rules.delete(:message)
112
+ # dup before deleting: the hash belongs to the caller, and the old
113
+ # code mutated it as a side effect of reading :message out.
114
+ rules = rules.dup
34
115
 
35
- def controller_rules
36
- @_controller_rules
37
- end
116
+ # `message:` in the hash is deprecated: it names nothing in the role
117
+ # vocabulary, and having it there is what made :message unusable as a
118
+ # role name. A positional string wins when both are given.
119
+ #
120
+ # Kernel#warn rather than ActiveSupport::Deprecation, matching the
121
+ # AllRest deprecation above -- and because a gem-owned deprecator
122
+ # registered with the app would raise under the suite's
123
+ # `config.active_support.deprecation = :raise`.
124
+ if rules.key?(:message)
125
+ source = caller_locations(1, 1)&.first
126
+ warn "petergate: passing `message:` to `access` is deprecated. Give the message " \
127
+ "as a string before the rules instead -- `access \"...\", user: [:index]`." \
128
+ "#{" Called from #{source.path}:#{source.lineno}." if source}"
129
+ end
38
130
 
39
- def controller_message
40
- @_controller_message || "Permission Denied"
41
- end
131
+ message ||= rules.delete(:message)
132
+ rules.delete(:message)
133
+
134
+ # A rule that named no scope follows the reading controller's
135
+ # petergate_scope, so it is filed under a sentinel and resolved when
136
+ # read. Rewriting keys at declaration time cannot see subclasses yet.
137
+ #
138
+ # No normalization: the find above admits only a Class or a Symbol,
139
+ # and a String is refused outright, so there is nothing left to coerce.
140
+ defaulted = scope.nil?
141
+ key = defaulted ? Petergate::DEFAULT_SCOPE : scope
142
+
143
+ # A subclass declaring `access` replaces everything it inherited, the
144
+ # way it always has. Only calls within one class body accumulate.
145
+ #
146
+ # Merging across the hierarchy instead would silently widen access on
147
+ # upgrade: a subclass that narrows its parent -- the documented reason
148
+ # to declare `access` again -- would keep the parent's wider rule live
149
+ # beside its own, and OR them.
150
+ petergate_claim_rules!
151
+
152
+ # Keyed by the scope *as declared*, not by the Devise scope it
153
+ # resolves to. Under STI `Employee` and `Manager` both resolve to the
154
+ # same Devise scope, and keying on that would make the second
155
+ # declaration silently replace the first instead of widening access.
156
+ #
157
+ # Re-declaring the same scope replaces it, which is what subclasses
158
+ # have always done to narrow a parent's rules. Declaring a different
159
+ # scope adds to them.
160
+ self.petergate_rules = petergate_rules
161
+ .merge(key => Petergate::Rule.new(
162
+ scope: scope, rules: rules.freeze, message: message, defaulted: defaulted
163
+ ))
164
+ .freeze
165
+
166
+ install_petergate_callback!
167
+ end
42
168
 
43
- def inherited(subclass)
44
- super
45
- subclass.instance_variable_set("@_controller_rules", instance_variable_get("@_controller_rules"))
46
- subclass.instance_variable_set("@_controller_message", instance_variable_get("@_controller_message"))
169
+ # Deprecated. petergate's own callback no longer calls these, but
170
+ # applications may. Kept quiet deliberately: the suite raises on any
171
+ # ActiveSupport deprecation.
172
+ def controller_rules
173
+ rule = petergate_rules[Petergate::DEFAULT_SCOPE] ||
174
+ petergate_rules[petergate_default_scope] ||
175
+ petergate_rules.values.first
176
+ rule&.rules || {}
177
+ end
178
+
179
+ def controller_message
180
+ petergate_rules.each_value.map(&:message).compact.first || "Permission Denied"
181
+ end
182
+
183
+ private
184
+ # A class that is not a model can never be a scope: there is no name to
185
+ # derive a helper from, so it resolves to :user, the exact type check
186
+ # then refuses every role rule, and `all:` rules keep granting -- all
187
+ # silently. Refuse it where it is declared instead.
188
+ def petergate_validate_scope!(scope)
189
+ # To `access` a String is the denial message. Accepting one here as
190
+ # a scope name would make the same literal mean two different things
191
+ # depending on which declaration it landed in, so a typo would be
192
+ # silently absorbed by whichever it hit. Symbols name scopes.
193
+ if scope.is_a?(::String)
194
+ raise ArgumentError, "petergate_scope takes a model class or a symbol, not the " \
195
+ "string #{scope.inspect}. Write `petergate_scope " \
196
+ ":#{scope}` -- to `access`, a string is the denial message."
47
197
  end
198
+
199
+ return unless scope.is_a?(::Class)
200
+ return if scope.respond_to?(:model_name)
201
+
202
+ raise ArgumentError, "#{scope} cannot be a petergate scope: it is not a model, " \
203
+ "so there is no `current_...` to authorize against."
48
204
  end
49
205
 
50
- before_action do
51
- unless logged_in?(:root_admin)
52
- message = permissions(self.class.controller_rules)
53
- if message == false || message.is_a?(String)
54
- if current_user || @user
55
- forbidden! message
56
- else
57
- unauthorized!
58
- end
59
- end
60
- end
206
+ # Rules belong to the class that declared them. The first `access` in
207
+ # a class discards whatever it inherited; later ones in the same class
208
+ # add to it.
209
+ def petergate_claim_rules!
210
+ return if petergate_rules_owner == self
211
+
212
+ self.petergate_rules_owner = self
213
+ self.petergate_rules = {}.freeze
214
+ end
215
+
216
+ def petergate_action_names(methods)
217
+ own = public_instance_methods(false).map(&:to_sym)
218
+ view_helper = respond_to?(:_helper_methods) ? _helper_methods.map(&:to_sym) : []
219
+
220
+ methods.to_a.map(&:to_sym).select { |name|
221
+ # What cannot be an action, whoever declared it. Rails dispatches
222
+ # actions by name with no arguments, and predicates and bang
223
+ # methods -- `authenticate_<scope>!` among them -- are not actions
224
+ # by convention.
225
+ next false if name.to_s.end_with?("?", "!")
226
+
227
+ method = begin
228
+ instance_method(name)
229
+ rescue NameError
230
+ nil
231
+ end
232
+ next false if method.nil?
233
+ next false if method.parameters.any? { |type, _| type == :req || type == :keyreq }
234
+
235
+ # Past those, a method the controller declares itself is an action
236
+ # even when it is also registered as a helper, so an action may
237
+ # share a name with one.
238
+ next true if own.include?(name)
239
+
240
+ !view_helper.include?(name)
241
+ } - [:check_access, :title]
242
+ end
243
+
244
+ # Exactly one callback, however many `access` calls a controller makes.
245
+ #
246
+ # One per call would AND them together -- each denying on its own --
247
+ # which is the opposite of what several scopes on one controller mean.
248
+ # The flag is a class_attribute, so a subclass that calls `access`
249
+ # again does not install a second copy; the inherited callback reads
250
+ # the subclass's own rules at request time.
251
+ #
252
+ # Registered here rather than at include time so it lands at the
253
+ # position of the `access` call, keeping the ordering apps rely on
254
+ # when an earlier before_action sets up what petergate reads. A named
255
+ # method rather than a block, so `skip_before_action` can reach it.
256
+ # Registered at the position of the `access` call that declared the
257
+ # rules, for every class that declares them -- not just the first in a
258
+ # hierarchy. A subclass that sets up its own authentication and then
259
+ # declares `access` wants petergate to run after that setup:
260
+ #
261
+ # class Api::BaseController < ApplicationController
262
+ # before_action :authenticate_from_token
263
+ # access admin: :all
264
+ # end
265
+ #
266
+ # The declaring class is tracked rather than the chain inspected: the
267
+ # filter is inherited, so it is always already present and inspecting
268
+ # the chain can only ever leave it at the ancestor's position.
269
+ # Skipping first keeps it to a single registration, and `raise: false`
270
+ # because a class that never inherited it has nothing to skip.
271
+ def install_petergate_callback!
272
+ return if petergate_callback_owner == self
273
+
274
+ self.petergate_callback_owner = self
275
+ skip_before_action :petergate_check_access!, raise: false
276
+ before_action :petergate_check_access!
61
277
  end
62
- end
63
278
  end
64
279
 
65
280
  ALLRESTDEP = [:show, :index, :new, :edit, :update, :create, :destroy]
66
281
 
67
282
  def self.included(base)
68
283
  base.extend(ClassMethods)
69
- base.helper_method :logged_in?, :forbidden!, :unauthorized! if base.respond_to?(:helper_method)
284
+
285
+ # instance_accessor: false -- these are only ever read through
286
+ # self.class, and there is no reason to put three more public methods on
287
+ # every controller in the application.
288
+ base.class_attribute :petergate_default_scope, instance_accessor: false, default: :user
289
+ base.class_attribute :petergate_rules, instance_accessor: false, default: {}.freeze
290
+ base.class_attribute :petergate_rules_owner, instance_accessor: false, default: nil
291
+ base.class_attribute :petergate_callback_owner, instance_accessor: false, default: nil
292
+
293
+ # user_logged_in? is documented as a view helper but was never
294
+ # registered as one.
295
+ base.helper_method :logged_in?, :user_logged_in?, :forbidden!, :unauthorized! if base.respond_to?(:helper_method)
70
296
  end
71
297
 
72
298
  def parse_permission_rules(rules)
@@ -88,38 +314,63 @@ module Petergate
88
314
  rules = rules.inject({}){|h, (k, v)| k.class == Array ? h.merge(Hash[k.map{|kk| [kk, v]}]) : h.merge(k => v) }
89
315
  end
90
316
 
91
- def permissions(rules = {all: [:index, :show], customer: [], wiring: []})
317
+ def permissions(rules = {all: [:index, :show], customer: [], wiring: []}, scope: nil)
92
318
  rules = parse_permission_rules(rules)
93
319
  allowances = [rules[:all]]
94
- current_user.roles.each do |role|
320
+ resource = petergate_resource(scope || self.class.petergate_scope, strict: false)
321
+ resource.roles.each do |role|
95
322
  allowances << rules[role]
96
- end if user_logged_in?
323
+ end if resource
97
324
  allowances.flatten.compact.include?(action_name.to_sym)
98
325
  end
99
326
 
100
- def logged_in?(*roles)
101
- current_user && current_user.has_roles?(*roles)
327
+ # Roles are varargs, so the scope has to be a trailing keyword. Every
328
+ # documented call -- logged_in?(:admin, :editor) -- is unaffected.
329
+ #
330
+ # Defaults to the controller's own scope rather than :user, so a bare
331
+ # logged_in?(:manager) in a view under `petergate_scope Employee` asks the
332
+ # employee. Resolves leniently: a view should render false for an unknown
333
+ # scope, not raise. The strict resolution is on the authorization path,
334
+ # where silence would be a hole.
335
+ def logged_in?(*roles, scope: nil)
336
+ resource = petergate_resource(scope || self.class.petergate_scope, strict: false)
337
+ # `resource &&` rather than a boolean cast: this has always returned nil
338
+ # for a visitor, and views render that as "" where false renders "false".
339
+ resource && resource.has_roles?(*roles)
102
340
  end
103
341
 
104
- def user_logged_in?
105
- !!current_user
342
+ # Note this asks the type-exact question: under `petergate_scope Employee`
343
+ # a signed-in Manager is not an Employee, so this is false even though
344
+ # somebody is signed in. That is deliberate, and it is a different question
345
+ # from the one behind forbidden-vs-unauthorized, which asks whether the
346
+ # underlying login is occupied at all.
347
+ def user_logged_in?(scope: nil)
348
+ !!petergate_resource(scope || self.class.petergate_scope, strict: false)
106
349
  end
107
350
 
351
+ # First match wins: the message of the scope that actually refused a
352
+ # signed-in person, then the controller's own scope, then whatever was
353
+ # declared first.
108
354
  def custom_message
109
- defined?(self.class.controller_message) ? self.class.controller_message : 'Permission Denied'
355
+ rules = petergate_declared_rules
356
+
357
+ petergate_signed_in_rule&.message ||
358
+ rules.find { |rule| petergate_scope_of(rule) == self.class.petergate_scope }&.message ||
359
+ rules.map(&:message).compact.first ||
360
+ "Permission Denied"
110
361
  end
111
362
 
112
- def unauthorized!
363
+ def unauthorized!(scope: nil)
113
364
  # ActionController::API has no MimeResponds, so no respond_to; a bare
114
365
  # status is the right answer for an API caller regardless.
115
366
  return head(:unauthorized) if is_a?(::ActionController::API)
116
367
 
117
368
  respond_to do |format|
118
- format.any(:js, :json, :xml) do
369
+ format.any(:js, :json, :xml) do
119
370
  head(:unauthorized)
120
371
  end
121
372
  format.html do
122
- return authenticate_user!
373
+ return petergate_authenticate!(scope || petergate_denial_scope)
123
374
  end
124
375
  end
125
376
  end
@@ -141,11 +392,222 @@ module Petergate
141
392
  # destination. Honouring the real header now would change where
142
393
  # every existing app sends a refused user, and would hand the
143
394
  # redirect target to the caller.
144
- destination = current_user.present? ? after_sign_in_path_for(current_user) : root_path
395
+ resource = petergate_redirect_resource
396
+ destination = resource.present? ? after_sign_in_path_for(resource) : root_path
145
397
  redirect_to destination, notice: notice
146
398
  end
147
399
  end
148
400
  end
401
+
402
+ private
403
+ # The single callback every `access` call feeds.
404
+ #
405
+ # Scopes are OR'd: each is evaluated against its own resource, and one
406
+ # passing is enough. Private, so it can never be mistaken for an action.
407
+ def petergate_check_access!
408
+ rules = petergate_declared_rules
409
+ return if rules.empty?
410
+
411
+ # The cache lives for this check and no longer. Within it, an empty
412
+ # scope is asked about many times -- the root_admin pass, the granting
413
+ # pass, the denial decision, the redirect target, the message -- and
414
+ # caching nil keeps that to one lookup. Outside it, a cached nil would
415
+ # outlive an action that signs someone in and then renders, leaving the
416
+ # view to draw itself for a stranger.
417
+ @_petergate_resources = {}
418
+
419
+ begin
420
+ petergate_evaluate_access!(rules)
421
+ ensure
422
+ @_petergate_resources = nil
423
+ end
424
+ end
425
+
426
+ def petergate_evaluate_access!(rules)
427
+ # Every declared scope must have a helper behind it, whoever happens
428
+ # to be signed in: the passes below short-circuit, so a typo would
429
+ # otherwise raise or stay silent depending on the request.
430
+ #
431
+ # Existence only. Actually reading current_<scope> would have Warden
432
+ # deserialize a session -- and possibly query -- for scopes the checks
433
+ # may never reach.
434
+ rules.each { |rule| petergate_assert_scope!(petergate_scope_of(rule)) }
435
+
436
+ # root_admin bypasses, but only in a scope this controller declares.
437
+ # Including the controller's default scope unconditionally would let a
438
+ # root_admin User walk into a controller whose rules are all about
439
+ # another model.
440
+ return if rules.any? { |rule| petergate_holds_role?(petergate_scope_of(rule), :root_admin) }
441
+
442
+ return if rules.any? { |rule| petergate_grants?(rule) }
443
+
444
+ # Nobody passed. Whether that is "wrong person" or "no person" decides
445
+ # between the two denials, and the answer falls out of the scope
446
+ # rather than needing configuration: if a declared scope holds someone
447
+ # -- signed in but the wrong type, under STI -- there is nothing
448
+ # further to authenticate as. If every declared scope is empty, a
449
+ # login genuinely is the answer.
450
+ #
451
+ # @user is honoured as a stand-in for a signed-in resource, as it
452
+ # always has been.
453
+ if petergate_signed_in_rule || @user
454
+ forbidden!
455
+ else
456
+ unauthorized!
457
+ end
458
+ end
459
+
460
+ # Every rule this controller authorizes by, and the scope each resolves
461
+ # to here -- a rule that named no scope follows this controller's
462
+ # petergate_scope, wherever it was declared.
463
+ def petergate_declared_rules
464
+ self.class.petergate_rules.values
465
+ end
466
+
467
+ def petergate_scope_of(rule)
468
+ rule.scope_for(self.class)
469
+ end
470
+
471
+ def petergate_grants?(rule)
472
+ rules = parse_permission_rules(rule.rules)
473
+ allowances = [rules[:all]]
474
+ resource = petergate_resource(petergate_scope_of(rule))
475
+
476
+ resource.roles.each { |role| allowances << rules[role] } if resource
477
+ allowances.flatten.compact.include?(action_name.to_sym)
478
+ end
479
+
480
+ def petergate_holds_role?(scope, *roles)
481
+ resource = petergate_resource(scope)
482
+ !!resource && resource.has_roles?(*roles)
483
+ end
484
+
485
+ # The resource for a scope, or nil when it is the wrong type.
486
+ #
487
+ # A Class scope matches exactly: under STI `current_user` may be any
488
+ # subclass, and `access Employee` means an Employee, not merely something
489
+ # descended from User. A subclass therefore has to name itself.
490
+ def petergate_resource(scope, strict: true)
491
+ resource = petergate_scope_resource(scope, strict: strict)
492
+ return resource unless scope.is_a?(Class)
493
+
494
+ resource if resource.instance_of?(scope)
495
+ end
496
+
497
+ # Checks that a scope resolves to a helper this controller actually has.
498
+ # Cheap and free of side effects, so it can run for every declared scope
499
+ # on every request.
500
+ def petergate_assert_scope!(scope)
501
+ method = "current_#{Petergate.devise_scope_for(scope)}"
502
+ return if respond_to?(method, true)
503
+
504
+ raise Petergate::MissingScopeError, petergate_missing_scope_message(method, scope)
505
+ end
506
+
507
+ def petergate_scope_resource(scope, strict: true)
508
+ # Never created here. The cache is set up by petergate_check_access!
509
+ # and torn down with it, so a lenient caller outside that window --
510
+ # a view helper, or an app filter on a controller with no `access` at
511
+ # all -- reads the login fresh instead of leaving a nil behind for the
512
+ # rest of the request.
513
+ cache = @_petergate_resources
514
+
515
+ # Keyed by the Devise scope, not the declared one. Several classes can
516
+ # share one login -- under STI they all do -- and they are the same
517
+ # resource; only the type check that follows differs. Keyed by the
518
+ # declared scope instead, `Employee` and `Staff` each cost their own
519
+ # session lookup.
520
+ devise_scope = Petergate.devise_scope_for(scope)
521
+ return cache[devise_scope] if cache&.key?(devise_scope)
522
+
523
+ method = "current_#{devise_scope}"
524
+
525
+ # A missing helper is never recorded, so a lenient caller cannot
526
+ # suppress the error for a later strict one.
527
+ unless respond_to?(method, true)
528
+ raise Petergate::MissingScopeError, petergate_missing_scope_message(method, scope) if strict
529
+ return nil
530
+ end
531
+
532
+ resource = send(method)
533
+ # nil is recorded too: within one check an empty scope is asked about
534
+ # by every pass, and each miss would otherwise be a fresh lookup.
535
+ cache[devise_scope] = resource if cache
536
+ resource
537
+ end
538
+
539
+ # The first declared rule whose scope holds anyone at all, whatever
540
+ # their type. This is what separates "wrong person" from "no person".
541
+ def petergate_signed_in_rule
542
+ petergate_declared_rules.find do |rule|
543
+ petergate_scope_resource(petergate_scope_of(rule), strict: false)
544
+ end
545
+ end
546
+
547
+ # Whom to redirect a refused person to their own home page as. Falls
548
+ # back to the controller's scope because forbidden! is public API and
549
+ # gets called from controllers that declare no rules at all.
550
+ def petergate_redirect_resource
551
+ rule = petergate_signed_in_rule
552
+ return petergate_scope_resource(petergate_scope_of(rule), strict: false) if rule
553
+
554
+ petergate_scope_resource(self.class.petergate_scope, strict: false)
555
+ end
556
+
557
+ # Which login a stranger is sent to. The controller's own scope when it
558
+ # is one of the declared ones -- that is what `petergate_scope` is for --
559
+ # otherwise the first scope declared, so a controller whose only rules
560
+ # are about Employee never hands visitors a :user login.
561
+ def petergate_denial_scope
562
+ rules = petergate_declared_rules
563
+ default = self.class.petergate_scope
564
+ scopes = rules.map { |rule| petergate_scope_of(rule) }
565
+ return default if scopes.empty? || scopes.include?(default)
566
+
567
+ scopes.first
568
+ end
569
+
570
+ def petergate_authenticate!(scope)
571
+ petergate_send("authenticate_#{Petergate.devise_scope_for(scope)}!", scope, strict: true)
572
+ end
573
+
574
+ # respond_to?/send rather than public_send: Devise's helpers are public,
575
+ # an application's hand-written ones are often private (and should be,
576
+ # or they look like actions). Both have to work.
577
+ def petergate_send(method, scope, strict:)
578
+ unless respond_to?(method, true)
579
+ return nil unless strict
580
+
581
+ raise Petergate::MissingScopeError, petergate_missing_scope_message(method, scope)
582
+ end
583
+
584
+ send(method)
585
+ end
586
+
587
+ # Says which of the two ways this went wrong, because they have
588
+ # different fixes and the resolved name alone does not distinguish them.
589
+ def petergate_missing_scope_message(method, scope)
590
+ devise_scope = Petergate.devise_scope_for(scope)
591
+
592
+ if scope.is_a?(Class) && devise_scope != Petergate.own_scope_name_for(scope)
593
+ <<~MESSAGE.squish
594
+ #{self.class.name} authorizes against #{scope}, which petergate resolved to the
595
+ #{devise_scope.inspect} authentication scope -- but there is no ##{method}.
596
+ If #{scope} signs in through its own login, add
597
+ `devise_for :#{devise_scope.to_s.pluralize}` to config/routes.rb. If it shares a
598
+ login with a parent class, that parent is what needs the login, and #{scope} only
599
+ needs its roles. If #{scope} is not an authenticatable model at all, it cannot be
600
+ a petergate scope.
601
+ MESSAGE
602
+ else
603
+ <<~MESSAGE.squish
604
+ #{self.class.name} authorizes against the #{scope.inspect} scope, but there is no
605
+ ##{method}. Add `devise_for :#{devise_scope.to_s.pluralize}` to config/routes.rb,
606
+ or define ##{method} yourself. A scope comes from `petergate_scope` or from
607
+ the first argument to `access`.
608
+ MESSAGE
609
+ end
610
+ end
149
611
  end
150
612
  end
151
613
  end
@@ -58,15 +58,41 @@ module Petergate
58
58
  end
59
59
  end
60
60
 
61
+ # Only roles this record's own class defines.
62
+ #
63
+ # `roles=` filters against available_roles, but the column outlives
64
+ # the class that wrote it. An STI `type` change is the sharp case:
65
+ # the row keeps the roles of the kind it used to be, and those roles
66
+ # would otherwise still satisfy `access`, granting a Customer what
67
+ # was written for an Employee. Removing a role from a `petergate`
68
+ # declaration leaves the same residue behind.
69
+ #
70
+ # A subclass with its own petergate call is checked against its own
71
+ # ROLES; one without inherits its parent's, which is the constant
72
+ # lookup doing the right thing.
61
73
  def roles
62
- case self[:roles].class.to_s
63
- when "String", "Symbol"
64
- [self[:roles].to_sym, :user].uniq
65
- when "Array"
66
- super
67
- else
68
- [:user]
69
- end
74
+ # Deliberately no to_sym on the Array branch. `roles=` has always
75
+ # written symbols there, so an array of strings can only have come
76
+ # from a raw write -- update_column, an import, a fixture -- and
77
+ # normalizing it would start granting a role that previously
78
+ # matched nothing. Rejecting it keeps this release unable to widen
79
+ # access, and the warning says the data is wrong.
80
+ #
81
+ # The single-role branch does symbolize, because it always has:
82
+ # `role = "editor"` from a form is the documented way to set it.
83
+ stored = case self[:roles].class.to_s
84
+ when "String", "Symbol"
85
+ [self[:roles].to_sym]
86
+ when "Array"
87
+ Array(self[:roles]).compact
88
+ else
89
+ []
90
+ end
91
+
92
+ permitted, rejected = stored.partition { |role| available_roles.include?(role) }
93
+ Petergate.warn_about_unavailable_roles(self.class, rejected) if rejected.any?
94
+
95
+ (permitted + [:user]).uniq
70
96
  end
71
97
 
72
98
  alias_method :role=, :roles=