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.
- checksums.yaml +4 -4
- data/.ruby-version +1 -1
- data/CHANGELOG.md +142 -0
- data/Gemfile +12 -0
- data/README.md +151 -10
- data/lib/generators/petergate/install_generator.rb +58 -9
- data/lib/generators/petergate/templates/migrations/add_roles_to_users.rb +2 -2
- data/lib/petergate/action_controller/base.rb +502 -40
- data/lib/petergate/active_record/base.rb +34 -8
- data/lib/petergate/rule.rb +34 -0
- data/lib/petergate/version.rb +1 -1
- data/lib/petergate.rb +101 -0
- data/petergate.gemspec +19 -1
- metadata +18 -5
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
323
|
+
end if resource
|
|
97
324
|
allowances.flatten.compact.include?(action_name.to_sym)
|
|
98
325
|
end
|
|
99
326
|
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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=
|