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.

Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +317 -18
  3. data/app/assets/javascripts/current_scope/application.js +4 -0
  4. data/app/assets/stylesheets/current_scope/application.css +101 -0
  5. data/app/controllers/current_scope/application_controller.rb +39 -1
  6. data/app/controllers/current_scope/role_assignments_controller.rb +110 -25
  7. data/app/controllers/current_scope/roles_controller.rb +130 -32
  8. data/app/helpers/current_scope/application_helper.rb +168 -12
  9. data/app/models/current_scope/current.rb +24 -0
  10. data/app/models/current_scope/event.rb +10 -6
  11. data/app/models/current_scope/role.rb +61 -10
  12. data/app/views/current_scope/roles/edit.html.erb +92 -4
  13. data/app/views/current_scope/roles/index.html.erb +16 -2
  14. data/app/views/current_scope/roles/members.html.erb +3 -3
  15. data/app/views/current_scope/roles/new.html.erb +1 -1
  16. data/app/views/current_scope/scoped_role_assignments/new.html.erb +19 -10
  17. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  18. data/app/views/current_scope/subjects/index.html.erb +21 -7
  19. data/app/views/layouts/current_scope/application.html.erb +4 -1
  20. data/config/routes.rb +3 -4
  21. data/lib/current_scope/configuration.rb +411 -18
  22. data/lib/current_scope/engine.rb +7 -0
  23. data/lib/current_scope/gating_reflection.rb +62 -0
  24. data/lib/current_scope/gating_tripwire.rb +36 -5
  25. data/lib/current_scope/guard.rb +410 -10
  26. data/lib/current_scope/mutation_guard.rb +30 -5
  27. data/lib/current_scope/permission_catalog.rb +116 -3
  28. data/lib/current_scope/permission_grid.rb +34 -4
  29. data/lib/current_scope/permissions.rb +45 -8
  30. data/lib/current_scope/resolver.rb +317 -13
  31. data/lib/current_scope/version.rb +1 -1
  32. data/lib/current_scope.rb +113 -5
  33. data/lib/generators/current_scope/install/install_generator.rb +64 -0
  34. data/lib/generators/current_scope/install/templates/initializer.rb +94 -5
  35. data/lib/tasks/current_scope_tasks.rake +153 -0
  36. metadata +6 -2
@@ -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
- # Member actions that need record-level decisions (scoped roles, SoD)
7
- # declare a private current_scope_record method returning the record. Three
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 = respond_to?(:current_scope_record, true) ? send(:current_scope_record) : nil
121
+ record = resolve_current_scope_record
122
+ model = resolve_current_scope_model
49
123
 
50
- # The real actor (Current.actor) enters here explicitly the resolver
51
- # never reads Current itself (PDP purity). It only matters under SoD
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
- raise CurrentScope::AccessDenied.new(permission, reason: reason) unless allowed
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 (opt-in): the request was ALLOWED, but if it's an SoD
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
- return unless record.nil?
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). Read
40
- # from the ambient context directly, so the gate holds even where the
41
- # Permissions helpers are not mixed in.
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
- user = CurrentScope::Current.user
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
- keys.include?(key)
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.sort
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