current_scope 0.2.0 → 0.3.1
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.
Potentially problematic release.
This version of current_scope might be problematic. Click here for more details.
- checksums.yaml +4 -4
- data/README.md +317 -18
- data/app/assets/javascripts/current_scope/application.js +4 -0
- data/app/assets/stylesheets/current_scope/application.css +101 -0
- data/app/controllers/current_scope/application_controller.rb +39 -1
- data/app/controllers/current_scope/role_assignments_controller.rb +110 -25
- data/app/controllers/current_scope/roles_controller.rb +130 -32
- data/app/helpers/current_scope/application_helper.rb +168 -12
- data/app/models/current_scope/current.rb +24 -0
- data/app/models/current_scope/event.rb +10 -6
- data/app/models/current_scope/role.rb +61 -10
- data/app/views/current_scope/roles/edit.html.erb +92 -4
- data/app/views/current_scope/roles/index.html.erb +16 -2
- data/app/views/current_scope/roles/members.html.erb +3 -3
- data/app/views/current_scope/roles/new.html.erb +1 -1
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +19 -10
- data/app/views/current_scope/shared/access_denied.html.erb +30 -0
- data/app/views/current_scope/subjects/index.html.erb +21 -7
- data/app/views/layouts/current_scope/application.html.erb +4 -1
- data/config/routes.rb +3 -4
- data/lib/current_scope/configuration.rb +411 -18
- data/lib/current_scope/engine.rb +7 -0
- data/lib/current_scope/gating_reflection.rb +62 -0
- data/lib/current_scope/gating_tripwire.rb +36 -5
- data/lib/current_scope/guard.rb +410 -10
- data/lib/current_scope/mutation_guard.rb +30 -5
- data/lib/current_scope/permission_catalog.rb +116 -3
- data/lib/current_scope/permission_grid.rb +34 -4
- data/lib/current_scope/permissions.rb +45 -8
- data/lib/current_scope/resolver.rb +317 -13
- data/lib/current_scope/version.rb +1 -1
- data/lib/current_scope.rb +113 -5
- data/lib/generators/current_scope/install/install_generator.rb +64 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +94 -5
- data/lib/tasks/current_scope_tasks.rake +153 -0
- metadata +6 -2
data/lib/current_scope/guard.rb
CHANGED
|
@@ -3,9 +3,9 @@ module CurrentScope
|
|
|
3
3
|
# its own permission: the current controller#action IS the permission key,
|
|
4
4
|
# so new controllers are gated (fail-closed) the moment they exist.
|
|
5
5
|
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
# rules for the hook:
|
|
6
|
+
# Any controller whose actions take part in record-level decisions (scoped
|
|
7
|
+
# roles, SoD) declares a private current_scope_record method returning the
|
|
8
|
+
# record. Three rules for the hook:
|
|
9
9
|
# - it runs for EVERY gated action, collection actions included — return
|
|
10
10
|
# nil when there is no record
|
|
11
11
|
# - it runs BEFORE the controller's own before_actions, so it must load
|
|
@@ -17,6 +17,41 @@ module CurrentScope
|
|
|
17
17
|
# set_report if request.path_parameters[:id]
|
|
18
18
|
# end
|
|
19
19
|
#
|
|
20
|
+
# The hook is a DECLARATION, and the gate reads it as one. Returning nil says
|
|
21
|
+
# "this action has no record" — that is what lets a subject holding only
|
|
22
|
+
# scoped grants through a collection gate, with scope_for narrowing the list
|
|
23
|
+
# (#19). Declaring no hook at all says nothing, so the gate assumes nothing
|
|
24
|
+
# and scoped grants cannot open it (NO_RECORD below) — otherwise a controller
|
|
25
|
+
# that simply forgot the hook would hand a scoped subject every record of its
|
|
26
|
+
# type. Nothing is lost by silence: without a hook, scoped grants could never
|
|
27
|
+
# open a collection gate anyway. A collection-only controller that wants them
|
|
28
|
+
# to says so in one line:
|
|
29
|
+
#
|
|
30
|
+
# def current_scope_record = nil
|
|
31
|
+
#
|
|
32
|
+
# A controller may ALSO declare a private current_scope_model naming the type
|
|
33
|
+
# its collection actions deal in:
|
|
34
|
+
#
|
|
35
|
+
# def current_scope_model = Report
|
|
36
|
+
#
|
|
37
|
+
# Same discovery rules as current_scope_record (private, fixed name,
|
|
38
|
+
# optional). The Guard threads it to the resolver so the record-less scoped
|
|
39
|
+
# branch can bind to that type instead of matching a scoped grant on ANY
|
|
40
|
+
# type (#50); absent means the type is unknown. A plain method, so a host
|
|
41
|
+
# may branch on action_name for a per-action answer.
|
|
42
|
+
#
|
|
43
|
+
# The declaration is TRUSTED, like current_scope_record: since #65 a listed
|
|
44
|
+
# collection-read gate derives its answer from the declared type's scoped
|
|
45
|
+
# list — full_access included — so declaring the WRONG type opens this
|
|
46
|
+
# controller's reads to subjects holding scoped full_access grants of that
|
|
47
|
+
# type. Review the declaration the way you review the record hook.
|
|
48
|
+
#
|
|
49
|
+
# The two hooks PAIR, they don't substitute: current_scope_model WITHOUT
|
|
50
|
+
# current_scope_record is inert, because declaring no record hook passes
|
|
51
|
+
# NO_RECORD (below) and the record-less branch never runs — the declared
|
|
52
|
+
# type is never consulted. A collection controller opting scoped grants in
|
|
53
|
+
# declares BOTH: `def current_scope_record = nil` plus the model.
|
|
54
|
+
#
|
|
20
55
|
# Skip the gate for public endpoints with skip_before_action :current_scope_check!.
|
|
21
56
|
# MutationGuard (included here) adds the read-only-while-impersonating gate as
|
|
22
57
|
# its OWN before_action, so it runs first and survives that skip.
|
|
@@ -24,6 +59,44 @@ module CurrentScope
|
|
|
24
59
|
extend ActiveSupport::Concern
|
|
25
60
|
include MutationGuard
|
|
26
61
|
|
|
62
|
+
# "This controller never said whether there is a record here." Passed to the
|
|
63
|
+
# resolver instead of nil when the controller declares no
|
|
64
|
+
# current_scope_record hook at all.
|
|
65
|
+
#
|
|
66
|
+
# The distinction matters because the resolver honors a scoped grant on a
|
|
67
|
+
# record-less target — that is how a scoped-only subject reaches their index
|
|
68
|
+
# (#19). A declared hook returning nil is the host stating "there is no
|
|
69
|
+
# record here", which is exactly what the contract above asks for, and the
|
|
70
|
+
# resolver can trust it. No hook is not that statement: it is silence, and
|
|
71
|
+
# reading silence as "collection action" lets a controller with member
|
|
72
|
+
# actions hand a scoped subject every record of its type — strictly worse
|
|
73
|
+
# than the 403 it gave before this path existed.
|
|
74
|
+
#
|
|
75
|
+
# Neither nil nor a Class, so the resolver's record-less branch skips it and
|
|
76
|
+
# the decision falls to deny. Org-wide and full_access are unaffected — they
|
|
77
|
+
# never read the record — so silence costs a host nothing it had before:
|
|
78
|
+
# scoped grants could never open a collection gate anyway. Declaring the
|
|
79
|
+
# hook is how you opt in.
|
|
80
|
+
NO_RECORD = Object.new.freeze
|
|
81
|
+
|
|
82
|
+
class << self
|
|
83
|
+
# Warn-once latch for a failed would-be-denial recording, mirroring
|
|
84
|
+
# Event.warn_missing_events_table_once. Lives on the module, not the
|
|
85
|
+
# controller: the failure is per-process (a missing table, a dead
|
|
86
|
+
# connection), so per-instance state would warn once per request and
|
|
87
|
+
# defeat the point.
|
|
88
|
+
#
|
|
89
|
+
# ponytail: a plain ivar, not a Mutex. Worst case under a race is a second
|
|
90
|
+
# warning line — the thing being prevented is a flood, not a duplicate.
|
|
91
|
+
def ledger_warning_emitted? = @ledger_warning_emitted
|
|
92
|
+
def ledger_warning_emitted! = @ledger_warning_emitted = true
|
|
93
|
+
|
|
94
|
+
# Test seam: the latch would otherwise leak across examples, silently
|
|
95
|
+
# disarming the warning for every test after the first and making the
|
|
96
|
+
# suite order-dependent.
|
|
97
|
+
def reset_ledger_warning! = @ledger_warning_emitted = false
|
|
98
|
+
end
|
|
99
|
+
|
|
27
100
|
included do
|
|
28
101
|
before_action :current_scope_check!
|
|
29
102
|
end
|
|
@@ -45,21 +118,226 @@ module CurrentScope
|
|
|
45
118
|
"skip_before_action :current_scope_check!."
|
|
46
119
|
end
|
|
47
120
|
|
|
48
|
-
record =
|
|
121
|
+
record = resolve_current_scope_record
|
|
122
|
+
model = resolve_current_scope_model
|
|
49
123
|
|
|
50
|
-
#
|
|
51
|
-
#
|
|
124
|
+
# Stash the declared type for the advisory path (allowed_to? in a view),
|
|
125
|
+
# keyed to THIS controller so a cross-controller question can't borrow it
|
|
126
|
+
# (#50, KTD-6). Additive — the gate decision below reads `model` directly,
|
|
127
|
+
# not the ambient copy.
|
|
128
|
+
#
|
|
129
|
+
# NOT when the record hook is absent (NO_RECORD): the gate skips the
|
|
130
|
+
# record-less branch for NO_RECORD (the R9 inert case), so it DENIES a
|
|
131
|
+
# scoped subject — and the advisory path must agree, not show a link the
|
|
132
|
+
# gate 403s. Stashing the model here without a declared record is the one
|
|
133
|
+
# place the view could diverge from the gate. (#50 review, cubic)
|
|
134
|
+
CurrentScope::Current.collection_model = record.equal?(NO_RECORD) ? nil : model
|
|
135
|
+
CurrentScope::Current.collection_model_path = controller_path
|
|
136
|
+
|
|
137
|
+
# Decision *inputs* (subject, permission, record, model, actor) are
|
|
138
|
+
# explicit — the resolver does not read ambient identity for the allow/
|
|
139
|
+
# deny answer. Org-role *lookup* may use Current.memoized_org_role (a
|
|
140
|
+
# per-request cache, not a decision input). Actor only widens SoD under
|
|
52
141
|
# :either while impersonating; otherwise actor == subject.
|
|
53
142
|
allowed, reason = CurrentScope.resolver.decide(
|
|
54
143
|
subject: CurrentScope::Current.user, permission: permission,
|
|
55
|
-
record: record, actor: CurrentScope::Current.actor
|
|
144
|
+
record: record, model: model, actor: CurrentScope::Current.actor
|
|
56
145
|
)
|
|
57
|
-
|
|
146
|
+
unless allowed
|
|
147
|
+
# The nudge runs BEFORE the report-mode branch, and that ordering is the
|
|
148
|
+
# whole point of it in a retrofit. Report mode downgrades a :no_grant to
|
|
149
|
+
# an observation and lets the request through — so a nudge placed after
|
|
150
|
+
# the early return would go silent for exactly the host report mode
|
|
151
|
+
# exists for. And a missing record hook is the one gap report mode CANNOT
|
|
152
|
+
# explain on its own: the would_deny row for that action never clears, no
|
|
153
|
+
# matter what you grant, because the gate has no record to match a scoped
|
|
154
|
+
# grant against. This is the line that says why. Log-only either way, so
|
|
155
|
+
# it cannot affect the branch below. (#37/#41 interaction)
|
|
156
|
+
nudge_on_inert_scoped_grant(permission, record, reason)
|
|
157
|
+
nudge_on_undeclared_collection_model(permission, record, reason)
|
|
158
|
+
|
|
159
|
+
return report_would_deny(permission, record) if report_only_denial?(reason, permission, record)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
raise CurrentScope::AccessDenied.new(permission, reason: reason)
|
|
163
|
+
end
|
|
58
164
|
|
|
59
165
|
record_sod_bypass(permission, record) if reason == :sod_bypassed
|
|
60
166
|
nudge_on_nil_sod_record(permission, record)
|
|
61
167
|
end
|
|
62
168
|
|
|
169
|
+
# Report mode lifts EXACTLY ONE wall: :no_grant — "nobody has granted this
|
|
170
|
+
# subject this permission yet", which is the entire state of a host that has
|
|
171
|
+
# mounted the gate and not yet seeded its grants. That is the thing report
|
|
172
|
+
# mode exists to survey.
|
|
173
|
+
#
|
|
174
|
+
# Matched POSITIVELY, on one reason, and that is the whole design. Every
|
|
175
|
+
# other denial is a real refusal about a real rule and must still 403:
|
|
176
|
+
# :sod_veto (relaxing it lets an initiator actually self-approve — a fraud
|
|
177
|
+
# action executed, not a role gap surfaced), :impersonation_gate, and
|
|
178
|
+
# :not_full_access (the management console — report mode must never hand out
|
|
179
|
+
# the UI where grants are made).
|
|
180
|
+
#
|
|
181
|
+
# An "everything except the vetoes I know about" rule would have been correct
|
|
182
|
+
# the day it was written and wrong by the next release: :not_full_access did
|
|
183
|
+
# not exist when this was designed, and it is excluded here by construction
|
|
184
|
+
# rather than by anyone remembering to add it. New reasons are refusals until
|
|
185
|
+
# someone deliberately says otherwise — fail-closed, applied to the mode
|
|
186
|
+
# itself.
|
|
187
|
+
#
|
|
188
|
+
# ...but :no_grant is not always the innocent reason it looks like. See below.
|
|
189
|
+
def report_only_denial?(reason, permission, record)
|
|
190
|
+
CurrentScope.config.report_only? &&
|
|
191
|
+
reason == :no_grant &&
|
|
192
|
+
!sod_veto_blind_spot?(permission, record)
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# The SoD blind spot: a :no_grant that is NOT evidence the veto approved.
|
|
196
|
+
#
|
|
197
|
+
# The veto has nothing to measure without a record, so the resolver skips it
|
|
198
|
+
# and the decision falls through to the ordinary grant check. What comes back
|
|
199
|
+
# is :no_grant — indistinguishable from an ordinary missing grant, but meaning
|
|
200
|
+
# "nobody asked the veto", not "the veto passed".
|
|
201
|
+
#
|
|
202
|
+
# In :enforce that costs nothing; :no_grant is a 403 either way, so the
|
|
203
|
+
# skipped veto never decides anything (config.warn_on_nil_sod_record exists to
|
|
204
|
+
# surface it on the ALLOW path). Report mode is what turns it into a hole:
|
|
205
|
+
# :no_grant is exactly what it downgrades, so a host that mis-declares
|
|
206
|
+
# current_scope_record on an SoD action gets the action EXECUTED with the
|
|
207
|
+
# four-eyes rule never consulted. The subject could be the initiator. Nobody
|
|
208
|
+
# checked.
|
|
209
|
+
#
|
|
210
|
+
# So report mode declines to speak where the veto couldn't, and downgrades
|
|
211
|
+
# only a denial the veto actually saw and passed. This costs a retrofitting
|
|
212
|
+
# host nothing real: an SoD action reached without a record is a
|
|
213
|
+
# misconfiguration they must fix regardless, and it still 403s as it does
|
|
214
|
+
# today.
|
|
215
|
+
#
|
|
216
|
+
# ASKS the resolver rather than re-deriving "did the veto run" — the resolver
|
|
217
|
+
# owns that condition and a second copy would drift, with the drifting copy
|
|
218
|
+
# being the one guarding the fraud control. An earlier draft of this did
|
|
219
|
+
# enumerate its own "record-less" set (nil, NO_RECORD, Class) and missed the
|
|
220
|
+
# commonest mistake of all: a hook returning `params[:id]`, a String, which
|
|
221
|
+
# the resolver skips the veto for but that guess would have waved through.
|
|
222
|
+
def sod_veto_blind_spot?(permission, record)
|
|
223
|
+
CurrentScope.resolver.sod_veto_skipped?(permission: permission, record: record)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
# Observe and proceed.
|
|
227
|
+
def report_would_deny(permission, record)
|
|
228
|
+
Rails.logger&.warn(
|
|
229
|
+
"[CurrentScope] report-only: would DENY #{permission.inspect} " \
|
|
230
|
+
"(reason: no_grant) — grant it before setting config.enforcement = :enforce"
|
|
231
|
+
)
|
|
232
|
+
response.set_header("X-Current-Scope-Reason", "would_deny")
|
|
233
|
+
record_would_deny_event(permission, record)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# R3: report mode NEVER raises — that is its whole promise, and it has to hold
|
|
237
|
+
# regardless of audit posture or the state of the ledger.
|
|
238
|
+
#
|
|
239
|
+
# Every other caller of Event.record! is a mutation being performed, where
|
|
240
|
+
# :strict re-raising to roll back an unaudited change is exactly right. This
|
|
241
|
+
# is not a mutation: it observes a request that is being let through anyway.
|
|
242
|
+
# Inheriting that raise would mean a host running audit = :strict who hasn't
|
|
243
|
+
# run the events migration 500s on every ungranted request — the opposite of
|
|
244
|
+
# what report mode promises, landing on the exact host it exists for.
|
|
245
|
+
#
|
|
246
|
+
# The rescue wraps ONLY this call. Event.record! is the one thing here with a
|
|
247
|
+
# documented raise contract, so it is the one thing worth catching; a broad
|
|
248
|
+
# rescue over the whole observation would also swallow a broken logger or
|
|
249
|
+
# response, which are app-fatal anyway and shouldn't be hidden. (#59 review)
|
|
250
|
+
def record_would_deny_event(permission, record)
|
|
251
|
+
subject = CurrentScope::Current.user
|
|
252
|
+
# No ambient subject ⇒ nothing to attribute the row to, and Event.record!
|
|
253
|
+
# raises on a nil actor. Guard on the SUBJECT, not on `target` — a record
|
|
254
|
+
# can be non-nil while the subject is nil.
|
|
255
|
+
return if subject.nil?
|
|
256
|
+
|
|
257
|
+
# NO_RECORD (the controller declared no hook) and nil (it declared "no
|
|
258
|
+
# record here") both mean there is nothing to attribute the row to but the
|
|
259
|
+
# subject. Compared by identity — NO_RECORD is an Object instance, so
|
|
260
|
+
# `is_a?` would match every record there is.
|
|
261
|
+
target = record.equal?(NO_RECORD) ? nil : record
|
|
262
|
+
|
|
263
|
+
CurrentScope::Event.record!(
|
|
264
|
+
event: "access.would_deny", target: target || subject,
|
|
265
|
+
details: { permission: permission, reason: "no_grant" }
|
|
266
|
+
)
|
|
267
|
+
rescue StandardError => e
|
|
268
|
+
# ponytail: swallow and warn ONCE. An unrecordable observation is a lost
|
|
269
|
+
# log line; a raise here is a 500 on a request report mode promised to pass.
|
|
270
|
+
warn_ledger_failure_once(e)
|
|
271
|
+
nil
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
# The failure this catches is PERSISTENT, not incidental: :report + audit
|
|
275
|
+
# :strict + an un-migrated events table fails identically on every request.
|
|
276
|
+
# Warning per-request floods the log with one repeated line and buries the
|
|
277
|
+
# thing the operator actually needs — that the ledger is empty because the
|
|
278
|
+
# table is missing, and what to do about it. And it is the exact situation
|
|
279
|
+
# report mode exists for, so it is the one a host is most likely to be in.
|
|
280
|
+
#
|
|
281
|
+
# Warn-once per process, mirroring Event.warn_missing_events_table_once —
|
|
282
|
+
# the same failure, the same treatment. (#59 review) The message names the
|
|
283
|
+
# fix for a missing table and otherwise reports the real error, because
|
|
284
|
+
# telling someone with a dead connection to run migrations sends them after
|
|
285
|
+
# the wrong problem.
|
|
286
|
+
def warn_ledger_failure_once(error)
|
|
287
|
+
return if CurrentScope::Guard.ledger_warning_emitted?
|
|
288
|
+
|
|
289
|
+
CurrentScope::Guard.ledger_warning_emitted!
|
|
290
|
+
Rails.logger&.warn("[CurrentScope] report-only: #{ledger_failure_hint(error)} " \
|
|
291
|
+
"The request WAS allowed through — only the access.would_deny " \
|
|
292
|
+
"row is missing. This warns once per process.")
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# ASKS Event whether this is the un-migrated-table case rather than pattern-
|
|
296
|
+
# matching the message here. Event's signature already excludes missing-COLUMN
|
|
297
|
+
# errors — a partial migration is not an absent table, and its own comment
|
|
298
|
+
# says why: it "would point operators at the wrong fix". A looser test here
|
|
299
|
+
# reintroduced exactly that, telling someone their table was missing while
|
|
300
|
+
# they were looking right at it. (#59 review)
|
|
301
|
+
def ledger_failure_hint(error)
|
|
302
|
+
if CurrentScope::Event.missing_events_table?(error)
|
|
303
|
+
"the current_scope_events table is missing, so would-be denials are not being " \
|
|
304
|
+
"recorded and `rails current_scope:report` will be empty. Run " \
|
|
305
|
+
"`rails current_scope:install:migrations && rails db:migrate`, or set " \
|
|
306
|
+
"config.audit = false if you don't want the ledger."
|
|
307
|
+
else
|
|
308
|
+
"could not record a would-be denial (#{error.class}: #{error.message.to_s.truncate(120)})."
|
|
309
|
+
end
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
# The record this gate decides against, or NO_RECORD when the controller
|
|
313
|
+
# never declared the hook (see NO_RECORD). A declared hook's answer — record
|
|
314
|
+
# or nil — is passed through exactly as given.
|
|
315
|
+
#
|
|
316
|
+
# Deliberately reads the DECLARATION, not the route. Guessing member-vs-
|
|
317
|
+
# collection from path parameters cannot be made correct: `:id` misses
|
|
318
|
+
# `param: :slug`; "any key not suffixed _id" misses `param: :external_id`
|
|
319
|
+
# and falsely accuses a nested parent with a custom param. Each rule fails
|
|
320
|
+
# on the next routing DSL option, because the route simply does not encode
|
|
321
|
+
# what the host means. The hook does, and the contract above already asks
|
|
322
|
+
# every gated controller to declare it.
|
|
323
|
+
def resolve_current_scope_record
|
|
324
|
+
return NO_RECORD unless respond_to?(:current_scope_record, true)
|
|
325
|
+
|
|
326
|
+
send(:current_scope_record)
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# The type this controller's collection actions deal in, or nil when the
|
|
330
|
+
# host never declared current_scope_model. Mirrors
|
|
331
|
+
# resolve_current_scope_record, minus the sentinel: for the RECORD,
|
|
332
|
+
# "declared nil" and "declared nothing" are different statements and
|
|
333
|
+
# NO_RECORD keeps them apart; for the TYPE both collapse to the same fact —
|
|
334
|
+
# unknown — so a plain nil carries it.
|
|
335
|
+
def resolve_current_scope_model
|
|
336
|
+
return nil unless respond_to?(:current_scope_model, true)
|
|
337
|
+
|
|
338
|
+
send(:current_scope_model)
|
|
339
|
+
end
|
|
340
|
+
|
|
63
341
|
# Break-glass audit (KTD-1): the resolver stays pure and only reports
|
|
64
342
|
# :sod_bypassed; the Guard — which runs once per REAL gated action, never on
|
|
65
343
|
# advisory allowed_to?/scope_for — records the override exactly once and
|
|
@@ -77,14 +355,136 @@ module CurrentScope
|
|
|
77
355
|
response.set_header("X-Current-Scope-Reason", "sod_bypassed")
|
|
78
356
|
end
|
|
79
357
|
|
|
80
|
-
# A5 dev/test aid (
|
|
358
|
+
# A5 dev/test aid (on by default in dev/test, #41): the request was ALLOWED, but if it's an SoD
|
|
81
359
|
# action gated with a nil record, the SoD veto was silently skipped — a sign
|
|
82
360
|
# current_scope_record returned nil on a member action. Lives here (the gate
|
|
83
361
|
# seam), not in the shared resolver, so it never fires on advisory
|
|
84
362
|
# allowed_to?/scope_for calls. Prod behavior is unchanged either way.
|
|
363
|
+
# The denial-side mirror of nudge_on_nil_sod_record (#41): this controller
|
|
364
|
+
# declared NO current_scope_record hook, and the subject holds a scoped grant
|
|
365
|
+
# that would have applied if it had.
|
|
366
|
+
#
|
|
367
|
+
# That is a controller with member actions that forgot the hook. It fails
|
|
368
|
+
# closed — correctly — but the resulting 403 is byte-identical to "you were
|
|
369
|
+
# never granted this", so whoever debugs it goes and stares at the grants,
|
|
370
|
+
# which are fine, instead of the controller, which isn't.
|
|
371
|
+
#
|
|
372
|
+
# Keyed on NO_RECORD, NOT on nil, and the difference is the whole nudge:
|
|
373
|
+
#
|
|
374
|
+
# - NO_RECORD = "this controller never said whether there's a record here."
|
|
375
|
+
# Silence. Scoped grants can't open the gate, so a genuinely-granted
|
|
376
|
+
# subject is refused and nothing says why. THIS is the bug.
|
|
377
|
+
# - nil = "there is no record here", stated deliberately by the host.
|
|
378
|
+
# Since #49 a scoped role ticking the key OPENS that gate, so a subject
|
|
379
|
+
# with a matching grant isn't denied at all and there is nothing to nudge
|
|
380
|
+
# about. Nudging here would fire on every legitimate collection request.
|
|
381
|
+
#
|
|
382
|
+
# (Plan 023 predates #49 and guards on `record.nil?` — which can no longer
|
|
383
|
+
# fire for the case it was written for, and excludes the case that can. Pinned
|
|
384
|
+
# by tests below rather than left to the next reader to rediscover.)
|
|
385
|
+
def nudge_on_inert_scoped_grant(permission, record, reason)
|
|
386
|
+
return unless CurrentScope.config.warn_on_inert_scoped_grant
|
|
387
|
+
return unless reason == :no_grant
|
|
388
|
+
return unless record.equal?(NO_RECORD)
|
|
389
|
+
return unless CurrentScope.resolver.scoped_grant_exists?(
|
|
390
|
+
subject: CurrentScope::Current.user, permission: permission
|
|
391
|
+
)
|
|
392
|
+
|
|
393
|
+
# Says only what the predicate proves. scoped_grant_exists? has no resource
|
|
394
|
+
# filter — it can't have one, the missing record IS the bug — so it
|
|
395
|
+
# establishes that a matching scoped grant exists on SOME record, not that
|
|
396
|
+
# it would have applied to whichever record this action meant. Claiming
|
|
397
|
+
# "would satisfy it" overstates that, and a diagnostic that overstates is
|
|
398
|
+
# how a diagnostic starts being ignored. (#61 review, qodo)
|
|
399
|
+
message =
|
|
400
|
+
"[CurrentScope] denied \"#{permission}\" (no_grant) — this subject holds a scoped grant " \
|
|
401
|
+
"for it on some record, and #{controller_path} declares no current_scope_record, so the " \
|
|
402
|
+
"gate had no record to match it against. If this is a member action, declare the hook " \
|
|
403
|
+
"(`def current_scope_record = set_thing`) — if the subject holds the grant on the record " \
|
|
404
|
+
"in question, that fixes this. If the controller is collection-only, " \
|
|
405
|
+
"`def current_scope_record = nil` says so and lets scoped grants through."
|
|
406
|
+
|
|
407
|
+
# R9 (#50): this controller declared current_scope_model WITHOUT
|
|
408
|
+
# current_scope_record — the model hook is INERT, because no record hook
|
|
409
|
+
# means NO_RECORD and the record-less branch (the only reader of the
|
|
410
|
+
# declared type) never runs. That trigger is byte-for-byte this nudge's
|
|
411
|
+
# own, so it is one clause on this line, not a second nudge: two log
|
|
412
|
+
# lines saying the same thing on the same request is the noise the
|
|
413
|
+
# diagnostics contract exists to avoid.
|
|
414
|
+
if respond_to?(:current_scope_model, true)
|
|
415
|
+
message += " NOTE: this controller declares current_scope_model, but that hook is inert " \
|
|
416
|
+
"without current_scope_record — the record hook is what's missing here."
|
|
417
|
+
end
|
|
418
|
+
|
|
419
|
+
Rails.logger&.warn(message)
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# #50's adoption gap, named at the moment it bites: the resolver denied
|
|
423
|
+
# :model_undeclared — a declared collection action (record nil) with no
|
|
424
|
+
# current_scope_model, while a scoped grant explicitly ticks the key. The
|
|
425
|
+
# reason ALREADY proves that whole condition (the resolver derived it), so
|
|
426
|
+
# the predicate here is the reason and nothing else — re-deriving it from
|
|
427
|
+
# record/grants would be the drifting second copy KTD-5 warns about.
|
|
428
|
+
# `record` is taken to mirror its sibling's call shape, not consulted.
|
|
429
|
+
# Log-only; the reason rides X-Current-Scope-Reason with or without this.
|
|
430
|
+
def nudge_on_undeclared_collection_model(permission, _record, reason)
|
|
431
|
+
return unless CurrentScope.config.warn_on_undeclared_collection_model
|
|
432
|
+
|
|
433
|
+
# Both labels are the same cell — a scoped grant satisfies the key but
|
|
434
|
+
# the gate had no usable type to bind it to — with different fixes, so
|
|
435
|
+
# each gets its own sentence. :model_invalid names the actual value the
|
|
436
|
+
# hook returned, because "declares no current_scope_model" would be a
|
|
437
|
+
# lie to the host that DID declare one and typo'd it (the release-gate
|
|
438
|
+
# finding this branch exists for).
|
|
439
|
+
case reason
|
|
440
|
+
when :model_undeclared
|
|
441
|
+
# The grant is known to satisfy the key on SOME record of SOME type — a
|
|
442
|
+
# tick, or (on a listed read, #65) a full_access role, which ticks
|
|
443
|
+
# nothing. The missing declaration is exactly why the gate couldn't
|
|
444
|
+
# check which, and it also can't check record liveness — so the fix is
|
|
445
|
+
# named as "may fix", never promised. (#61 wording precedent; the
|
|
446
|
+
# resolver's label-predicate comment relies on this hedge.)
|
|
447
|
+
Rails.logger&.warn(
|
|
448
|
+
"[CurrentScope] denied \"#{permission}\" (model_undeclared) — this is a declared " \
|
|
449
|
+
"collection action (current_scope_record returned nil) and the subject holds a scoped " \
|
|
450
|
+
"grant that satisfies the key, but #{controller_path} declares no current_scope_model, " \
|
|
451
|
+
"so the gate had no type to bind it to and failed closed. Declaring the type this " \
|
|
452
|
+
"collection deals in (`def current_scope_model = TheType`) may fix this — the grant " \
|
|
453
|
+
"must be of that type, and for a listed read its record must still be in the model's " \
|
|
454
|
+
"default scope."
|
|
455
|
+
)
|
|
456
|
+
when :model_invalid
|
|
457
|
+
# Re-read the hook rather than thread the value through the call —
|
|
458
|
+
# keeping the call site at its siblings' 3-arg shape, so a host that
|
|
459
|
+
# collides with this (private, but un-namespaced) method name at the
|
|
460
|
+
# old arity gets its own method called, not an ArgumentError-500 on a
|
|
461
|
+
# denied request (PR #93 review, qodo + cubic). Display-only re-read:
|
|
462
|
+
# the hook is a plain method and the decision is already made.
|
|
463
|
+
model = resolve_current_scope_model
|
|
464
|
+
# A diagnostic must never alter the denial path — an object whose
|
|
465
|
+
# inspect raises would turn this 403 into a 500 (PR #93 review, cubic).
|
|
466
|
+
described = begin
|
|
467
|
+
model.inspect
|
|
468
|
+
rescue StandardError
|
|
469
|
+
"(uninspectable object)"
|
|
470
|
+
end
|
|
471
|
+
Rails.logger&.warn(
|
|
472
|
+
"[CurrentScope] denied \"#{permission}\" (model_invalid) — #{controller_path}'s " \
|
|
473
|
+
"current_scope_model returned #{described}, which is not a concrete " \
|
|
474
|
+
"ActiveRecord model class, so the gate could not bind the record-less check to it " \
|
|
475
|
+
"and failed closed. Return the AR class this collection lists " \
|
|
476
|
+
"(`def current_scope_model = TheType`) — a String, instance, or abstract class " \
|
|
477
|
+
"cannot be matched against scoped grants."
|
|
478
|
+
)
|
|
479
|
+
end
|
|
480
|
+
end
|
|
481
|
+
|
|
85
482
|
def nudge_on_nil_sod_record(permission, record)
|
|
86
483
|
return unless CurrentScope.config.warn_on_nil_sod_record
|
|
87
|
-
|
|
484
|
+
# NO_RECORD counts: it IS the member-action-with-no-record case this nudge
|
|
485
|
+
# exists to catch, so it must not go quiet just because the Guard now
|
|
486
|
+
# labels that case instead of passing a bare nil.
|
|
487
|
+
return unless record.nil? || record.equal?(NO_RECORD)
|
|
88
488
|
return unless CurrentScope.config.sod_actions.include?(permission.split("#").last)
|
|
89
489
|
|
|
90
490
|
Rails.logger&.warn(
|
|
@@ -36,19 +36,44 @@ module CurrentScope
|
|
|
36
36
|
)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
# A real actor stands behind a distinct effective subject (act-as).
|
|
40
|
-
#
|
|
41
|
-
#
|
|
39
|
+
# A real actor stands behind a distinct effective subject (act-as).
|
|
40
|
+
# Delegates to the one definition on Current (not the Permissions mixin,
|
|
41
|
+
# which isn't mixed in everywhere this gate runs) so the gate and the
|
|
42
|
+
# view-level read-only signal share a single authoritative predicate.
|
|
42
43
|
def current_scope_impersonating?
|
|
43
|
-
|
|
44
|
-
user.present? && CurrentScope::Current.actor != user
|
|
44
|
+
CurrentScope::Current.impersonating?
|
|
45
45
|
end
|
|
46
46
|
|
|
47
47
|
# Denials render forbidden and surface the machine-readable reason on the
|
|
48
48
|
# response so refusals are diagnosable by clients and tests.
|
|
49
|
+
#
|
|
50
|
+
# The ONLY place the reason header is written. Every denial in the gem —
|
|
51
|
+
# the Guard's, this gate's, and the engine's own front door — raises
|
|
52
|
+
# AccessDenied and lands here, so a denial cannot exist that forgets the
|
|
53
|
+
# header. (The engine's front door used to render its own `head :forbidden`
|
|
54
|
+
# and was exactly that denial: no header, no body. #23.)
|
|
49
55
|
def current_scope_denied(exception = nil)
|
|
50
56
|
reason = exception.respond_to?(:reason) ? exception.reason : nil
|
|
51
57
|
response.headers["X-Current-Scope-Reason"] = reason.to_s if reason
|
|
58
|
+
current_scope_render_denied(reason)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# How a denial renders — the one part a controller may vary. Default: a
|
|
62
|
+
# bodyless 403, which is what a host app's denial has always been and must
|
|
63
|
+
# stay. This runs inside HOST controllers (Guard mixes it in), so rendering
|
|
64
|
+
# a body here would push an engine-shaped response into an app that never
|
|
65
|
+
# asked for one, with no layout or view to render it in. The engine's own
|
|
66
|
+
# controller overrides this to explain itself; nothing else should.
|
|
67
|
+
#
|
|
68
|
+
# `current_scope_`-prefixed like every other method this concern mixes into
|
|
69
|
+
# a host controller (current_scope_check!, current_scope_denied,
|
|
70
|
+
# current_scope_mutation_guard!, current_scope_record). A bare
|
|
71
|
+
# `render_access_denied` is a name a host app could plausibly already have,
|
|
72
|
+
# and we would silently call theirs.
|
|
73
|
+
#
|
|
74
|
+
# Takes the reason: an overrider that renders a body needs to know WHICH
|
|
75
|
+
# denial it is answering, or it will explain the wrong one.
|
|
76
|
+
def current_scope_render_denied(_reason = nil)
|
|
52
77
|
head :forbidden
|
|
53
78
|
end
|
|
54
79
|
end
|
|
@@ -1,3 +1,8 @@
|
|
|
1
|
+
# Enumerable#to_set (key_set, the routed-key cache) is stdlib `set`, which a
|
|
2
|
+
# full Rails boot happens to load — a host on active_support.bare would
|
|
3
|
+
# NoMethodError at catalog construction without the explicit require.
|
|
4
|
+
require "set"
|
|
5
|
+
|
|
1
6
|
module CurrentScope
|
|
2
7
|
# Derives the permission set from the host's routes: one permission per
|
|
3
8
|
# controller#action pair. There is no table to maintain — add a controller
|
|
@@ -13,21 +18,129 @@ module CurrentScope
|
|
|
13
18
|
.transform_values { |ks| ks.map { |k| k.split("#").last } }
|
|
14
19
|
end
|
|
15
20
|
|
|
21
|
+
# Hot: the Guard asks this on EVERY gated request, and a role save asks it
|
|
22
|
+
# once per staged key. Set lookup, not Array#include? — the array is sorted
|
|
23
|
+
# for display, and scanning it linearly made every request pay for the size
|
|
24
|
+
# of the host's route table. Memoized alongside `keys` and dropped with it
|
|
25
|
+
# on reset!.
|
|
16
26
|
def include?(key)
|
|
17
|
-
|
|
27
|
+
key_set.include?(key)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The ONE parse of config.sod_bypass_permission's action segment — the grid
|
|
31
|
+
# view and the ungated task read it here rather than re-splitting the
|
|
32
|
+
# string themselves (a looser split("#").last silently turns a malformed
|
|
33
|
+
# "reports#" into "reports" and mislabels the break-glass cell; #79 review).
|
|
34
|
+
#
|
|
35
|
+
# `split("#", -1)` keeps the trailing empty field, so a malformed "reports#"
|
|
36
|
+
# yields "" and is caught here rather than silently becoming "reports" and
|
|
37
|
+
# injecting "reports#reports". Blank raises instead of skipping: the host
|
|
38
|
+
# turned break-glass ON, so a permission nobody can hold means the veto can
|
|
39
|
+
# never be lifted and the feature is inert — an undiagnosable deny, which is
|
|
40
|
+
# exactly what this engine promises not to do. (A boot-time check for this
|
|
41
|
+
# config belongs with #40.)
|
|
42
|
+
def bypass_action
|
|
43
|
+
segments = CurrentScope.config.sod_bypass_permission.to_s.split("#", -1)
|
|
44
|
+
# Exactly a bare action or one controller#action. More hashes would pass
|
|
45
|
+
# a last-segment check while the resolver reads the ORIGINAL full string —
|
|
46
|
+
# the catalog would inject a key nobody can be granted under, and the
|
|
47
|
+
# veto could never be lifted. (#79 review)
|
|
48
|
+
if segments.empty? || segments.size > 2 || segments.any?(&:blank?)
|
|
49
|
+
raise ConfigurationError,
|
|
50
|
+
"config.allow_sod_bypass is on, but config.sod_bypass_permission " \
|
|
51
|
+
"(#{CurrentScope.config.sod_bypass_permission.inspect}) is not a bare action or a " \
|
|
52
|
+
"single controller#action. Name the permission the record's initiator must hold " \
|
|
53
|
+
"to break glass (the default is \"bypass_sod\"), or set config.allow_sod_bypass = false."
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
segments.last
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# Is this key derived from a real route (as opposed to the injected
|
|
60
|
+
# break-glass key)? The distinction matters to anything making an
|
|
61
|
+
# inertness claim: a ROUTED action named like the bypass permission is an
|
|
62
|
+
# ordinary action and gets no exemption, while the injected key is live on
|
|
63
|
+
# any row. (#79 review)
|
|
64
|
+
def routed?(key)
|
|
65
|
+
keys # derive memoizes @routed_key_set as a side effect
|
|
66
|
+
@routed_key_set.include?(key)
|
|
18
67
|
end
|
|
19
68
|
|
|
20
69
|
private
|
|
21
70
|
|
|
71
|
+
def key_set
|
|
72
|
+
@key_set ||= keys.to_set
|
|
73
|
+
end
|
|
74
|
+
|
|
22
75
|
def derive
|
|
23
|
-
Rails.application.routes.routes.filter_map { |route|
|
|
76
|
+
routed = Rails.application.routes.routes.filter_map { |route|
|
|
24
77
|
controller = route.defaults[:controller]
|
|
25
78
|
action = route.defaults[:action]
|
|
26
79
|
next unless controller && action
|
|
27
80
|
next if CurrentScope.config.excluded_controllers.any? { |re| controller.match?(re) }
|
|
28
81
|
|
|
29
82
|
"#{controller}##{action}"
|
|
30
|
-
}.uniq
|
|
83
|
+
}.uniq
|
|
84
|
+
|
|
85
|
+
@routed_key_set = routed.to_set
|
|
86
|
+
(routed + bypass_keys(routed)).uniq.sort
|
|
31
87
|
end
|
|
88
|
+
|
|
89
|
+
# Break-glass is the one permission that isn't an action you can route: it
|
|
90
|
+
# gates the SoD veto rather than a request. So a purely route-derived catalog
|
|
91
|
+
# can never contain it — which left the shipped "grantable, editable in the
|
|
92
|
+
# role grid" claim false, and break-glass reachable only through full_access
|
|
93
|
+
# (defeating the point of a *scoped* trusted-approver role) or a console
|
|
94
|
+
# insert. The catalog is the single definition of what is grantable — the
|
|
95
|
+
# grid reads `grouped`, the role setter and the Guard read `include?` — so
|
|
96
|
+
# injecting the virtual key here makes the cell render AND the save stick,
|
|
97
|
+
# with no special case in either. (#21)
|
|
98
|
+
#
|
|
99
|
+
# Emitted only where it could mean something: break-glass on, and a
|
|
100
|
+
# controller that actually routes an SoD action. Off by default, so the
|
|
101
|
+
# catalog is byte-for-byte the routed set unless a host opts in.
|
|
102
|
+
#
|
|
103
|
+
# Route- and config-derived only — deliberately no model introspection. The
|
|
104
|
+
# precise set would be "controllers whose SoD-gated model defines an
|
|
105
|
+
# initiator", but discovering that means loading application models, which
|
|
106
|
+
# is expensive, boot-order fragile, and against the catalog's whole design.
|
|
107
|
+
#
|
|
108
|
+
# The key is built from the controller's LAST path segment, because that is
|
|
109
|
+
# what the resolver will ask for: it derives the bypass key from the
|
|
110
|
+
# RECORD's route_key (permission_key → record.model_name.route_key), never
|
|
111
|
+
# from the controller path. Under Rails' resource conventions those agree —
|
|
112
|
+
# Admin::ReportsController's last segment "reports" IS Report's route_key —
|
|
113
|
+
# so keying off the whole path would inject "admin/reports#bypass_sod" while
|
|
114
|
+
# the resolver looks up "reports#bypass_sod", leaving break-glass ungrantable
|
|
115
|
+
# for every namespaced SoD controller and handing the admin a cell that
|
|
116
|
+
# silently does nothing. Namespaced admin controllers are common enough that
|
|
117
|
+
# this is the difference between the fix working and not.
|
|
118
|
+
#
|
|
119
|
+
# A namespace-only resource therefore gets its bypass cell on a "reports"
|
|
120
|
+
# row that no controller routes — the grid renders it aligned, blank
|
|
121
|
+
# everywhere else. Slightly odd to look at, and correct: it is the key the
|
|
122
|
+
# resolver actually reads.
|
|
123
|
+
#
|
|
124
|
+
# The irreducible limit: a controller named differently from the records it
|
|
125
|
+
# acts on (an `approvals` controller approving `Invoice`s) still injects
|
|
126
|
+
# `approvals#bypass_sod` while the live key is `invoices#bypass_sod`.
|
|
127
|
+
# Closing that needs to know the SoD-gated model, i.e. introspection.
|
|
128
|
+
# Tracked at OQ-2.
|
|
129
|
+
def bypass_keys(routed)
|
|
130
|
+
return [] unless CurrentScope.config.allow_sod_bypass
|
|
131
|
+
|
|
132
|
+
sod_actions = CurrentScope.config.sod_actions
|
|
133
|
+
return [] if sod_actions.empty?
|
|
134
|
+
|
|
135
|
+
routed.group_by { |key| key.split("#").first }
|
|
136
|
+
.filter_map { |controller, keys|
|
|
137
|
+
actions = keys.map { |k| k.split("#").last }
|
|
138
|
+
"#{controller.split('/').last}##{bypass_action}" if actions.intersect?(sod_actions)
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# The action segment of config.sod_bypass_permission — tolerating either a
|
|
143
|
+
# bare action ("bypass_sod") or a full key ("reports#bypass_sod").
|
|
144
|
+
#
|
|
32
145
|
end
|
|
33
146
|
end
|