petergate 3.1.0 → 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.
@@ -1,3 +1,6 @@
1
+ # Included into ActionController::Base *and* ActionController::API: Rails runs
2
+ # the :action_controller load hook for both. See the hook at the bottom of this
3
+ # file for why that hook rather than the Base/API-specific pair.
1
4
  module Petergate
2
5
  module ActionController
3
6
  module Base
@@ -11,59 +14,285 @@ module Petergate
11
14
  end
12
15
  end
13
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.
14
36
  def all_actions
15
- ->{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
16
52
  end
17
53
 
18
54
  def except_actions(arr = [])
19
55
  all_actions - arr
20
56
  end
21
57
 
22
- 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
+
23
107
  if block
24
108
  b_rules = block.call
25
109
  rules = rules.merge(b_rules) if b_rules.is_a?(Hash)
26
110
  end
27
111
 
28
- instance_eval do
29
- @_controller_rules = rules
30
- @_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
31
115
 
32
- def controller_rules
33
- @_controller_rules
34
- 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
35
130
 
36
- def controller_message
37
- @_controller_message || "Permission Denied"
38
- 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
39
168
 
40
- def inherited(subclass)
41
- super
42
- subclass.instance_variable_set("@_controller_rules", instance_variable_get("@_controller_rules"))
43
- 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."
44
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."
45
204
  end
46
205
 
47
- before_action do
48
- unless logged_in?(:root_admin)
49
- message = permissions(self.class.controller_rules)
50
- if message == false || message.is_a?(String)
51
- if current_user || @user
52
- forbidden! message
53
- else
54
- unauthorized!
55
- end
56
- end
57
- 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!
58
277
  end
59
- end
60
278
  end
61
279
 
62
280
  ALLRESTDEP = [:show, :index, :new, :edit, :update, :create, :destroy]
63
281
 
64
282
  def self.included(base)
65
283
  base.extend(ClassMethods)
66
- 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)
67
296
  end
68
297
 
69
298
  def parse_permission_rules(rules)
@@ -85,49 +314,70 @@ module Petergate
85
314
  rules = rules.inject({}){|h, (k, v)| k.class == Array ? h.merge(Hash[k.map{|kk| [kk, v]}]) : h.merge(k => v) }
86
315
  end
87
316
 
88
- def permissions(rules = {all: [:index, :show], customer: [], wiring: []})
317
+ def permissions(rules = {all: [:index, :show], customer: [], wiring: []}, scope: nil)
89
318
  rules = parse_permission_rules(rules)
90
319
  allowances = [rules[:all]]
91
- current_user.roles.each do |role|
320
+ resource = petergate_resource(scope || self.class.petergate_scope, strict: false)
321
+ resource.roles.each do |role|
92
322
  allowances << rules[role]
93
- end if user_logged_in?
323
+ end if resource
94
324
  allowances.flatten.compact.include?(action_name.to_sym)
95
325
  end
96
326
 
97
- def logged_in?(*roles)
98
- 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)
99
340
  end
100
341
 
101
- def user_logged_in?
102
- !!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)
103
349
  end
104
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.
105
354
  def custom_message
106
- defined?(self.class.controller_message) ? self.class.controller_message : 'Permission Denied'
107
- end
355
+ rules = petergate_declared_rules
108
356
 
109
- # ActionController::API does not include ActionController::MimeResponds,
110
- # so `respond_to` is unavailable there. API controllers get a bare status
111
- # code instead of an HTML redirect, which is what an API caller expects.
112
- def negotiates_formats?
113
- respond_to?(:respond_to)
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"
114
361
  end
115
362
 
116
- def unauthorized!
117
- return head(:unauthorized) unless negotiates_formats?
363
+ def unauthorized!(scope: nil)
364
+ # ActionController::API has no MimeResponds, so no respond_to; a bare
365
+ # status is the right answer for an API caller regardless.
366
+ return head(:unauthorized) if is_a?(::ActionController::API)
118
367
 
119
368
  respond_to do |format|
120
- format.any(:js, :json, :xml) do
369
+ format.any(:js, :json, :xml) do
121
370
  head(:unauthorized)
122
371
  end
123
372
  format.html do
124
- return authenticate_user!
373
+ return petergate_authenticate!(scope || petergate_denial_scope)
125
374
  end
126
375
  end
127
376
  end
128
377
 
129
378
  def forbidden!(msg = nil)
130
- return head(:forbidden) unless negotiates_formats?
379
+ # See unauthorized! -- API controllers cannot respond_to.
380
+ return head(:forbidden) if is_a?(::ActionController::API)
131
381
 
132
382
  respond_to do |format|
133
383
  format.any(:js, :json, :xml) do
@@ -142,11 +392,222 @@ module Petergate
142
392
  # destination. Honouring the real header now would change where
143
393
  # every existing app sends a refused user, and would hand the
144
394
  # redirect target to the caller.
145
- 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
146
397
  redirect_to destination, notice: notice
147
398
  end
148
399
  end
149
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
150
611
  end
151
612
  end
152
613
  end