current_scope 0.5.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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +377 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/current_scope/application.js +229 -0
  6. data/app/assets/stylesheets/current_scope/application.css +917 -0
  7. data/app/controllers/current_scope/application_controller.rb +96 -0
  8. data/app/controllers/current_scope/events_controller.rb +14 -0
  9. data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
  10. data/app/controllers/current_scope/roles_controller.rb +256 -0
  11. data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
  12. data/app/controllers/current_scope/subjects_controller.rb +75 -0
  13. data/app/helpers/current_scope/application_helper.rb +261 -0
  14. data/app/models/concerns/current_scope/storable_keys.rb +101 -0
  15. data/app/models/current_scope/application_record.rb +5 -0
  16. data/app/models/current_scope/current.rb +74 -0
  17. data/app/models/current_scope/event.rb +136 -0
  18. data/app/models/current_scope/role.rb +106 -0
  19. data/app/models/current_scope/role_assignment.rb +42 -0
  20. data/app/models/current_scope/role_permission.rb +9 -0
  21. data/app/models/current_scope/scoped_role_assignment.rb +98 -0
  22. data/app/views/current_scope/events/index.html.erb +41 -0
  23. data/app/views/current_scope/roles/edit.html.erb +229 -0
  24. data/app/views/current_scope/roles/index.html.erb +55 -0
  25. data/app/views/current_scope/roles/members.html.erb +114 -0
  26. data/app/views/current_scope/roles/new.html.erb +19 -0
  27. data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
  28. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  29. data/app/views/current_scope/subjects/index.html.erb +124 -0
  30. data/app/views/layouts/current_scope/application.html.erb +56 -0
  31. data/config/routes.rb +13 -0
  32. data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
  33. data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
  34. data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
  35. data/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
  36. data/lib/current_scope/configuration.rb +613 -0
  37. data/lib/current_scope/context.rb +41 -0
  38. data/lib/current_scope/engine.rb +202 -0
  39. data/lib/current_scope/gating_reflection.rb +113 -0
  40. data/lib/current_scope/gating_tripwire.rb +84 -0
  41. data/lib/current_scope/grant_diagnosis.rb +216 -0
  42. data/lib/current_scope/guard.rb +750 -0
  43. data/lib/current_scope/mutation_guard.rb +90 -0
  44. data/lib/current_scope/parent_chain.rb +397 -0
  45. data/lib/current_scope/permission_catalog.rb +146 -0
  46. data/lib/current_scope/permission_grid.rb +132 -0
  47. data/lib/current_scope/permissions.rb +94 -0
  48. data/lib/current_scope/resolver.rb +669 -0
  49. data/lib/current_scope/schema_guard.rb +223 -0
  50. data/lib/current_scope/scopeable.rb +38 -0
  51. data/lib/current_scope/sod_preflight.rb +380 -0
  52. data/lib/current_scope/test_helpers.rb +53 -0
  53. data/lib/current_scope/version.rb +3 -0
  54. data/lib/current_scope.rb +432 -0
  55. data/lib/generators/current_scope/install/install_generator.rb +114 -0
  56. data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
  57. data/lib/tasks/current_scope_tasks.rake +454 -0
  58. metadata +123 -0
@@ -0,0 +1,750 @@
1
+ module CurrentScope
2
+ # The enforcement point. Include after Context to gate every action behind
3
+ # its own permission: the current controller#action IS the permission key,
4
+ # so new controllers are gated (fail-closed) the moment they exist.
5
+ #
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
+ # - it runs for EVERY gated action, collection actions included — return
10
+ # nil when there is no record
11
+ # - it runs BEFORE the controller's own before_actions, so it must load
12
+ # the record itself (memoize so set_* callbacks reuse it)
13
+ # - key off request.path_parameters, NEVER params: a query-string ?id=
14
+ # must not let a scoped role on one record unlock a collection action
15
+ #
16
+ # def current_scope_record
17
+ # set_report if request.path_parameters[:id]
18
+ # end
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
+ #
55
+ # Skip the gate for public endpoints with skip_before_action :current_scope_check!
56
+ # or, preferably, current_scope_skip_gate!(reason: "…") so the role grid can
57
+ # show declared intent instead of an unexplained "gate not run" badge (#76).
58
+ # MutationGuard (included here) adds the read-only-while-impersonating gate as
59
+ # its OWN before_action, so it runs first and survives that skip.
60
+ module Guard
61
+ extend ActiveSupport::Concern
62
+ include MutationGuard
63
+
64
+ # Inheritable whole-controller skip reason from current_scope_skip_gate!.
65
+ # nil means no declared reason (bare skip_before_action or never included).
66
+ class_methods do
67
+ # Prefer the macro over bare skip_before_action so the grid can show
68
+ # "skipped: …" instead of the alarming unexplained badge (#76 / plan 030).
69
+ # only:/except: still perform the skip; the stored reason is the
70
+ # whole-controller annotation when those are absent (row-level grid today).
71
+ def current_scope_skip_gate!(reason:, **options)
72
+ text = reason.to_s.strip
73
+ raise ArgumentError, "current_scope_skip_gate! requires a non-blank reason:" if text.empty?
74
+
75
+ # Whole-controller only: only:/except: stays unprovable at row level
76
+ # (KTD-3), so we do not claim the entire controller was deliberately
77
+ # opened. The skip still runs.
78
+ if options[:only].nil? && options[:except].nil?
79
+ self.current_scope_gate_skip_reason = text
80
+ end
81
+
82
+ skip_before_action :current_scope_check!, raise: false, **options
83
+ end
84
+ end
85
+
86
+ # "This controller never said whether there is a record here." Passed to the
87
+ # resolver instead of nil when the controller declares no
88
+ # current_scope_record hook at all.
89
+ #
90
+ # The distinction matters because the resolver honors a scoped grant on a
91
+ # record-less target — that is how a scoped-only subject reaches their index
92
+ # (#19). A declared hook returning nil is the host stating "there is no
93
+ # record here", which is exactly what the contract above asks for, and the
94
+ # resolver can trust it. No hook is not that statement: it is silence, and
95
+ # reading silence as "collection action" lets a controller with member
96
+ # actions hand a scoped subject every record of its type — strictly worse
97
+ # than the 403 it gave before this path existed.
98
+ #
99
+ # Neither nil nor a Class, so the resolver's record-less branch skips it and
100
+ # the decision falls to deny. Org-wide and full_access are unaffected — they
101
+ # never read the record — so silence costs a host nothing it had before:
102
+ # scoped grants could never open a collection gate anyway. Declaring the
103
+ # hook is how you opt in.
104
+ NO_RECORD = Object.new.freeze
105
+
106
+ class << self
107
+ # Warn-once latch for a failed would-be-denial recording, mirroring
108
+ # Event.warn_missing_events_table_once. Lives on the module, not the
109
+ # controller: the failure is per-process (a missing table, a dead
110
+ # connection), so per-instance state would warn once per request and
111
+ # defeat the point.
112
+ #
113
+ # ponytail: a plain ivar, not a Mutex. Worst case under a race is a second
114
+ # warning line — the thing being prevented is a flood, not a duplicate.
115
+ def ledger_warning_emitted? = @ledger_warning_emitted
116
+ def ledger_warning_emitted! = @ledger_warning_emitted = true
117
+
118
+ # Test seam: the latch would otherwise leak across examples, silently
119
+ # disarming the warning for every test after the first and making the
120
+ # suite order-dependent.
121
+ def reset_ledger_warning! = @ledger_warning_emitted = false
122
+ end
123
+
124
+ included do
125
+ # class_attribute so a child inherits a parent's declared reason (#62 shape).
126
+ class_attribute :current_scope_gate_skip_reason, instance_accessor: false, default: nil
127
+ before_action :current_scope_check!
128
+ end
129
+
130
+ private
131
+
132
+ def current_scope_check!
133
+ # Record that the gate ran, so an optional GatingTripwire (A4) can tell a
134
+ # gated action from one on a controller that never included Guard.
135
+ @current_scope_checked = true
136
+ permission = "#{controller_path}##{action_name}"
137
+
138
+ # An excluded controller can never be granted in the grid, so gating it
139
+ # would lock it to full_access forever — a misconfiguration, not a deny.
140
+ # Name excluded-vs-unrouted and the matching regex so the fix is obvious
141
+ # (#44); "not in the catalog" alone hid which of the two was true.
142
+ unless CurrentScope.catalog.include?(permission)
143
+ raise CurrentScope::ConfigurationError, catalog_miss_message(permission)
144
+ end
145
+
146
+ record = resolve_current_scope_record
147
+ model = resolve_current_scope_model
148
+
149
+ # Stash the declared type for the advisory path (allowed_to? in a view),
150
+ # keyed to THIS controller so a cross-controller question can't borrow it
151
+ # (#50, KTD-6). Additive — the gate decision below reads `model` directly,
152
+ # not the ambient copy.
153
+ #
154
+ # NOT when the record hook is absent (NO_RECORD): the gate skips the
155
+ # record-less branch for NO_RECORD (the R9 inert case), so it DENIES a
156
+ # scoped subject — and the advisory path must agree, not show a link the
157
+ # gate 403s. Stashing the model here without a declared record is the one
158
+ # place the view could diverge from the gate. (#50 review, cubic)
159
+ CurrentScope::Current.collection_model = record.equal?(NO_RECORD) ? nil : model
160
+ CurrentScope::Current.collection_model_path = controller_path
161
+
162
+ # Decision *inputs* (subject, permission, record, model, actor) are
163
+ # explicit — the resolver does not read ambient identity for the allow/
164
+ # deny answer. Org-role *lookup* may use Current.memoized_org_role (a
165
+ # per-request cache, not a decision input). Actor only widens SoD under
166
+ # :either while impersonating; otherwise actor == subject.
167
+ allowed, reason = decide_with_report_diagnosis(permission, record, model)
168
+ unless allowed
169
+ # The nudge runs BEFORE the report-mode branch, and that ordering is the
170
+ # whole point of it in a retrofit. Report mode downgrades a :no_grant to
171
+ # an observation and lets the request through — so a nudge placed after
172
+ # the early return would go silent for exactly the host report mode
173
+ # exists for. And a missing record hook is the one gap report mode CANNOT
174
+ # explain on its own: the would_deny row for that action never clears, no
175
+ # matter what you grant, because the gate has no record to match a scoped
176
+ # grant against. This is the line that says why. Log-only either way, so
177
+ # it cannot affect the branch below. (#37/#41 interaction)
178
+ nudge_on_inert_scoped_grant(permission, record, reason)
179
+ nudge_on_undeclared_collection_model(permission, record, reason)
180
+
181
+ return report_would_deny(permission, record) if report_only_denial?(reason, permission, record)
182
+
183
+ # Report mode still 403s the SoD blind spot (correct, fail-closed) but
184
+ # used to do so silently — no log, no ledger, invisible to
185
+ # current_scope:report. Diagnose before the raise so a retrofit host
186
+ # sees the mis-declared record hook they must fix (#73).
187
+ diagnose_report_sod_blind_spot(permission, record, reason)
188
+
189
+ raise CurrentScope::AccessDenied.new(
190
+ permission,
191
+ reason: reason,
192
+ permission: permission,
193
+ record: (record.equal?(NO_RECORD) ? nil : record),
194
+ subject: CurrentScope::Current.user
195
+ )
196
+ end
197
+
198
+ record_sod_bypass(permission, record) if reason == :sod_bypassed
199
+ nudge_on_nil_sod_record(permission, record)
200
+ end
201
+
202
+ # The gate decision, plus report mode's account of the one failure it cannot
203
+ # downgrade and cannot pass through: a model with no current_scope_initiator
204
+ # on an SoD action, which raises and 500s the request (#133).
205
+ #
206
+ # The raise STAYS. Report mode's promise is "nothing changes for users", and
207
+ # this breaks it — but the two candidate repairs are worse. Passing the
208
+ # request through executes an SoD action with the four-eyes veto never
209
+ # consulted, which is #73's escalation with the safety catch removed;
210
+ # downgrading to a 403 dresses a misconfiguration up as an ordinary denial,
211
+ # the silent-weakening pattern this engine keeps getting burned by. So the
212
+ # 500 stands, and what changes is that it stops being the loudest failure
213
+ # with the least reporting: it now names itself in the log and in the ledger,
214
+ # so `rails current_scope:report` accounts for it instead of leaving a host
215
+ # to correlate 500s by hand. SodPreflight is the other half — it finds most
216
+ # of these at boot, before any traffic arrives here at all.
217
+ #
218
+ # Rescues the CLASS and asks the resolver WHY, rather than matching the
219
+ # message: ConfigurationError is raised for more than one cause, and the
220
+ # cause decides which fix the host is sent after.
221
+ def decide_with_report_diagnosis(permission, record, model)
222
+ CurrentScope.resolver.decide(
223
+ subject: CurrentScope::Current.user, permission: permission,
224
+ record: record, model: model, actor: CurrentScope::Current.actor
225
+ )
226
+ rescue CurrentScope::ConfigurationError
227
+ diagnose_report_sod_initiator(permission, record)
228
+ raise
229
+ end
230
+
231
+ # #133: report mode only. In :enforce a host has committed to the engine and
232
+ # meets this in their error tracker; report mode is the survey, so the survey
233
+ # is where it has to appear. Mirrors diagnose_report_sod_blind_spot — same
234
+ # shape, same report-only scope, a distinct event because the fix is
235
+ # different (the MODEL's hook, not the controller's).
236
+ def diagnose_report_sod_initiator(permission, record)
237
+ return unless CurrentScope.config.report_only?
238
+
239
+ gate_record = record.equal?(NO_RECORD) ? nil : record
240
+ # Ask the resolver (#74) — no second copy of the condition, and no reading
241
+ # of the exception's message.
242
+ return unless CurrentScope.resolver.sod_initiator_missing?(
243
+ permission: permission, record: gate_record
244
+ )
245
+
246
+ Rails.logger&.warn(
247
+ "[CurrentScope] report-only: \"#{permission}\" RAISED rather than being reported — " \
248
+ "#{gate_record.class.name} defines no #{CurrentScope::Resolver::INITIATOR_METHOD}, and " \
249
+ "\"#{permission}\" is a separation-of-duties action (config.sod_actions), so the veto " \
250
+ "cannot run and the engine refuses to guess. Report mode does NOT downgrade this: the " \
251
+ "request 500s here exactly as it would under :enforce. Define " \
252
+ "#{CurrentScope::Resolver::INITIATOR_METHOD} on #{gate_record.class.name} (return nil to " \
253
+ "exempt a record), or remove \"#{permission.split('#').last}\" from config.sod_actions. " \
254
+ "See also rails current_scope:report (access.sod_initiator_missing events)."
255
+ )
256
+ record_sod_initiator_missing_event(permission, gate_record)
257
+ end
258
+
259
+ def record_sod_initiator_missing_event(permission, record)
260
+ subject = CurrentScope::Current.user
261
+ return if subject.nil?
262
+
263
+ # Building the row is rescued SEPARATELY from writing it, and the reason is
264
+ # the latch rather than the rescue. warn_ledger_failure_once is one
265
+ # per-PROCESS one-shot shared by all three report-mode recorders
266
+ # (would_deny, sod_blind_spot, and this one), so a failure that never
267
+ # reached the ledger would consume the single warning the OTHER two still
268
+ # need — and label itself "could not record", sending an operator after a
269
+ # ledger problem that does not exist. Only Event.record! may trip that
270
+ # latch. (#133 — qodo, PR #141)
271
+ begin
272
+ # An unsaved record has no GlobalID, so attribute the row to the subject
273
+ # instead — the model NAME is the fix-carrying detail here, and it rides
274
+ # in details either way.
275
+ target = record if record.respond_to?(:to_gid) && record.try(:persisted?)
276
+ details = {
277
+ permission: permission,
278
+ model: record.class.name,
279
+ fix: "define #{CurrentScope::Resolver::INITIATOR_METHOD} on #{record.class.name}"
280
+ }
281
+ rescue StandardError => e
282
+ Rails.logger&.warn(
283
+ "[CurrentScope] report-only: could not BUILD the access.sod_initiator_missing row " \
284
+ "(#{safe_error_description(e)}) — this is not a ledger failure, so the " \
285
+ "ledger warning is left armed. The request RAISED " \
286
+ "CurrentScope::ConfigurationError (500) either way."
287
+ )
288
+ return nil
289
+ end
290
+
291
+ CurrentScope::Event.record!(
292
+ event: "access.sod_initiator_missing", target: target || subject, details: details
293
+ )
294
+ rescue StandardError => e
295
+ # The request is about to 500 on the ConfigurationError being re-raised —
296
+ # say that, rather than claiming an outcome this path does not produce.
297
+ warn_ledger_failure_once(
298
+ e,
299
+ event: "access.sod_initiator_missing",
300
+ request_outcome: "The request RAISED CurrentScope::ConfigurationError (500) — only the " \
301
+ "access.sod_initiator_missing row is missing."
302
+ )
303
+ nil
304
+ end
305
+
306
+ # Report mode lifts EXACTLY ONE wall: :no_grant — "nobody has granted this
307
+ # subject this permission yet", which is the entire state of a host that has
308
+ # mounted the gate and not yet seeded its grants. That is the thing report
309
+ # mode exists to survey.
310
+ #
311
+ # Matched POSITIVELY, on one reason, and that is the whole design. Every
312
+ # other denial is a real refusal about a real rule and must still 403:
313
+ # :sod_veto (relaxing it lets an initiator actually self-approve — a fraud
314
+ # action executed, not a role gap surfaced), :impersonation_gate, and
315
+ # :not_full_access (the management console — report mode must never hand out
316
+ # the UI where grants are made).
317
+ #
318
+ # An "everything except the vetoes I know about" rule would have been correct
319
+ # the day it was written and wrong by the next release: :not_full_access did
320
+ # not exist when this was designed, and it is excluded here by construction
321
+ # rather than by anyone remembering to add it. New reasons are refusals until
322
+ # someone deliberately says otherwise — fail-closed, applied to the mode
323
+ # itself.
324
+ #
325
+ # ...but :no_grant is not always the innocent reason it looks like. See below.
326
+ def report_only_denial?(reason, permission, record)
327
+ CurrentScope.config.report_only? &&
328
+ reason == :no_grant &&
329
+ !sod_veto_blind_spot?(permission, record)
330
+ end
331
+
332
+ # The SoD blind spot: a :no_grant that is NOT evidence the veto approved.
333
+ #
334
+ # The veto has nothing to measure without a record, so the resolver skips it
335
+ # and the decision falls through to the ordinary grant check. What comes back
336
+ # is :no_grant — indistinguishable from an ordinary missing grant, but meaning
337
+ # "nobody asked the veto", not "the veto passed".
338
+ #
339
+ # In :enforce that costs nothing; :no_grant is a 403 either way, so the
340
+ # skipped veto never decides anything (config.warn_on_nil_sod_record exists to
341
+ # surface it on the ALLOW path). Report mode is what turns it into a hole:
342
+ # :no_grant is exactly what it downgrades, so a host that mis-declares
343
+ # current_scope_record on an SoD action gets the action EXECUTED with the
344
+ # four-eyes rule never consulted. The subject could be the initiator. Nobody
345
+ # checked.
346
+ #
347
+ # So report mode declines to speak where the veto couldn't, and downgrades
348
+ # only a denial the veto actually saw and passed. This costs a retrofitting
349
+ # host nothing real: an SoD action reached without a record is a
350
+ # misconfiguration they must fix regardless, and it still 403s as it does
351
+ # today.
352
+ #
353
+ # ASKS the resolver rather than re-deriving "did the veto run" — the resolver
354
+ # owns that condition and a second copy would drift, with the drifting copy
355
+ # being the one guarding the fraud control. An earlier draft of this did
356
+ # enumerate its own "record-less" set (nil, NO_RECORD, Class) and missed the
357
+ # commonest mistake of all: a hook returning `params[:id]`, a String, which
358
+ # the resolver skips the veto for but that guess would have waved through.
359
+ def sod_veto_blind_spot?(permission, record)
360
+ CurrentScope.resolver.sod_veto_skipped?(permission: permission, record: record)
361
+ end
362
+
363
+ # Observe and proceed.
364
+ def report_would_deny(permission, record)
365
+ Rails.logger&.warn(
366
+ "[CurrentScope] report-only: would DENY #{permission.inspect} " \
367
+ "(reason: no_grant) — grant it before setting config.enforcement = :enforce"
368
+ )
369
+ response.set_header("X-Current-Scope-Reason", "would_deny")
370
+ record_would_deny_event(permission, record)
371
+ end
372
+
373
+ # #73: report mode refuses to *downgrade* an SoD blind-spot denial (the
374
+ # veto never ran), but must not 403 *silently*. Log the cause/fix and
375
+ # record a distinct ledger row — never access.would_deny, because granting
376
+ # the permission will not clear this 403 (the hook is the fix).
377
+ def diagnose_report_sod_blind_spot(permission, record, reason)
378
+ return unless CurrentScope.config.report_only?
379
+ return unless reason == :no_grant
380
+ # Ask the resolver (#74) — no second copy of the skip condition.
381
+ return unless sod_veto_blind_spot?(permission, record)
382
+
383
+ Rails.logger&.warn(
384
+ "[CurrentScope] report-only: DENIED #{permission.inspect} because the " \
385
+ "separation-of-duties veto could not run (no usable record for this " \
386
+ "action). This is NOT a missing grant — granting will not clear the 403. " \
387
+ "Declare current_scope_record to return the AR record on this member " \
388
+ "action (or remove the action from config.sod_actions if it is not " \
389
+ "meant to be four-eyes gated). See also rails current_scope:report " \
390
+ "(access.sod_blind_spot events)."
391
+ )
392
+ record_sod_blind_spot_event(permission, record)
393
+ end
394
+
395
+ def record_sod_blind_spot_event(permission, record)
396
+ subject = CurrentScope::Current.user
397
+ return if subject.nil?
398
+
399
+ target = record.equal?(NO_RECORD) ? nil : record
400
+ # Non-records (String params[:id], etc.) are not GlobalID targets.
401
+ target = nil unless target.respond_to?(:to_gid)
402
+
403
+ CurrentScope::Event.record!(
404
+ event: "access.sod_blind_spot",
405
+ target: target || subject,
406
+ details: {
407
+ permission: permission,
408
+ reason: "no_grant",
409
+ blind_spot: true,
410
+ fix: "declare current_scope_record for this SoD member action"
411
+ }
412
+ )
413
+ rescue StandardError => e
414
+ # Blind-spot path returns 403 next — do not claim the request was allowed
415
+ # or that a would_deny row was lost (PR #103 review).
416
+ warn_ledger_failure_once(
417
+ e,
418
+ event: "access.sod_blind_spot",
419
+ request_outcome: "The request was DENIED (403) — only the access.sod_blind_spot row is missing."
420
+ )
421
+ nil
422
+ end
423
+
424
+ # R3: report mode NEVER raises — that is its whole promise, and it has to hold
425
+ # regardless of audit posture or the state of the ledger.
426
+ #
427
+ # Every other caller of Event.record! is a mutation being performed, where
428
+ # :strict re-raising to roll back an unaudited change is exactly right. This
429
+ # is not a mutation: it observes a request that is being let through anyway.
430
+ # Inheriting that raise would mean a host running audit = :strict who hasn't
431
+ # run the events migration 500s on every ungranted request — the opposite of
432
+ # what report mode promises, landing on the exact host it exists for.
433
+ #
434
+ # The rescue wraps ONLY this call. Event.record! is the one thing here with a
435
+ # documented raise contract, so it is the one thing worth catching; a broad
436
+ # rescue over the whole observation would also swallow a broken logger or
437
+ # response, which are app-fatal anyway and shouldn't be hidden. (#59 review)
438
+ def record_would_deny_event(permission, record)
439
+ subject = CurrentScope::Current.user
440
+ # No ambient subject ⇒ nothing to attribute the row to, and Event.record!
441
+ # raises on a nil actor. Guard on the SUBJECT, not on `target` — a record
442
+ # can be non-nil while the subject is nil.
443
+ return if subject.nil?
444
+
445
+ # NO_RECORD (the controller declared no hook) and nil (it declared "no
446
+ # record here") both mean there is nothing to attribute the row to but the
447
+ # subject. Compared by identity — NO_RECORD is an Object instance, so
448
+ # `is_a?` would match every record there is.
449
+ target = record.equal?(NO_RECORD) ? nil : record
450
+
451
+ CurrentScope::Event.record!(
452
+ event: "access.would_deny", target: target || subject,
453
+ details: { permission: permission, reason: "no_grant" }
454
+ )
455
+ rescue StandardError => e
456
+ # ponytail: swallow and warn ONCE. An unrecordable observation is a lost
457
+ # log line; a raise here is a 500 on a request report mode promised to pass.
458
+ warn_ledger_failure_once(
459
+ e,
460
+ event: "access.would_deny",
461
+ request_outcome: "The request WAS allowed through — only the access.would_deny row is missing."
462
+ )
463
+ nil
464
+ end
465
+
466
+ # The failure this catches is PERSISTENT, not incidental: :report + audit
467
+ # :strict + an un-migrated events table fails identically on every request.
468
+ # Warning per-request floods the log with one repeated line and buries the
469
+ # thing the operator actually needs — that the ledger is empty because the
470
+ # table is missing, and what to do about it. And it is the exact situation
471
+ # report mode exists for, so it is the one a host is most likely to be in.
472
+ #
473
+ # Warn-once per process, mirroring Event.warn_missing_events_table_once —
474
+ # the same failure, the same treatment. (#59 review) The message names the
475
+ # fix for a missing table and otherwise reports the real error, because
476
+ # telling someone with a dead connection to run migrations sends them after
477
+ # the wrong problem.
478
+ #
479
+ # `event` / `request_outcome` are path-specific: would_deny allows the
480
+ # request; sod_blind_spot still 403s — the operator-facing text must not
481
+ # claim the wrong outcome (PR #103).
482
+ def warn_ledger_failure_once(error, event:, request_outcome:)
483
+ return if CurrentScope::Guard.ledger_warning_emitted?
484
+
485
+ CurrentScope::Guard.ledger_warning_emitted!
486
+ Rails.logger&.warn(
487
+ "[CurrentScope] report-only: #{ledger_failure_hint(error, event: event)} " \
488
+ "#{request_outcome} This warns once per process."
489
+ )
490
+ end
491
+
492
+ # ASKS Event whether this is the un-migrated-table case rather than pattern-
493
+ # matching the message here. Event's signature already excludes missing-COLUMN
494
+ # errors — a partial migration is not an absent table, and its own comment
495
+ # says why: it "would point operators at the wrong fix". A looser test here
496
+ # reintroduced exactly that, telling someone their table was missing while
497
+ # they were looking right at it. (#59 review)
498
+ def ledger_failure_hint(error, event:)
499
+ if CurrentScope::Event.missing_events_table?(error)
500
+ "the current_scope_events table is missing, so #{event} rows are not being " \
501
+ "recorded and `rails current_scope:report` will be incomplete. Run " \
502
+ "`rails current_scope:install:migrations && rails db:migrate`, or set " \
503
+ "config.audit = false if you don't want the ledger."
504
+ else
505
+ "could not record #{event} (#{safe_error_description(error)})."
506
+ end
507
+ end
508
+
509
+ # "Class: message", with a fallback when the exception itself is hostile.
510
+ #
511
+ # `e.message` is host-overridable and can raise. Every use of it here is
512
+ # inside a rescue on a path that is ABOUT to re-raise something more
513
+ # important — the ConfigurationError that names the model and both fixes, or
514
+ # a 403 — so a second exception raised while formatting the first would
515
+ # replace it, and the host would lose the message that tells them what to do.
516
+ # This codebase already accepted that argument once for `model.inspect`
517
+ # (PR #93); the same reasoning covers `message`. (#133 — cubic, PR #141)
518
+ def safe_error_description(error)
519
+ "#{error.class}: #{error.message.to_s.truncate(120)}"
520
+ rescue StandardError
521
+ "#{error.class}: (message unavailable)"
522
+ end
523
+
524
+ # Why a gated permission is missing from the catalog: excluded by config
525
+ # (name the matching regex) vs never routed. Two different host fixes (#44).
526
+ def catalog_miss_message(permission)
527
+ matched = CurrentScope.config.excluded_controllers.select { |re| controller_path.match?(re) }
528
+ skip_clause =
529
+ "Either stop excluding it, or skip the gate here with " \
530
+ "skip_before_action :current_scope_check!. Skipping the gate leaves this " \
531
+ "controller ungated by CurrentScope — protect it with your own " \
532
+ "authorization (e.g. require_admin!)."
533
+
534
+ if matched.any?
535
+ patterns = matched.map(&:inspect).join(", ")
536
+ %("#{permission}" is excluded by config.excluded_controllers (matched #{patterns}). #{skip_clause})
537
+ else
538
+ %("#{permission}" is not in the permission catalog because it is not routed. ) +
539
+ "Add a route for this controller#action, or skip the gate with " \
540
+ "skip_before_action :current_scope_check! if the action is intentional " \
541
+ "(then protect it with your own authorization)."
542
+ end
543
+ end
544
+
545
+ # The record this gate decides against, or NO_RECORD when the controller
546
+ # never declared the hook (see NO_RECORD). A declared hook's answer — record
547
+ # or nil — is passed through exactly as given.
548
+ #
549
+ # Deliberately reads the DECLARATION, not the route. Guessing member-vs-
550
+ # collection from path parameters cannot be made correct: `:id` misses
551
+ # `param: :slug`; "any key not suffixed _id" misses `param: :external_id`
552
+ # and falsely accuses a nested parent with a custom param. Each rule fails
553
+ # on the next routing DSL option, because the route simply does not encode
554
+ # what the host means. The hook does, and the contract above already asks
555
+ # every gated controller to declare it.
556
+ def resolve_current_scope_record
557
+ return NO_RECORD unless respond_to?(:current_scope_record, true)
558
+
559
+ send(:current_scope_record)
560
+ end
561
+
562
+ # The type this controller's collection actions deal in, or nil when the
563
+ # host never declared current_scope_model. Mirrors
564
+ # resolve_current_scope_record, minus the sentinel: for the RECORD,
565
+ # "declared nil" and "declared nothing" are different statements and
566
+ # NO_RECORD keeps them apart; for the TYPE both collapse to the same fact —
567
+ # unknown — so a plain nil carries it.
568
+ def resolve_current_scope_model
569
+ return nil unless respond_to?(:current_scope_model, true)
570
+
571
+ send(:current_scope_model)
572
+ end
573
+
574
+ # Break-glass audit (KTD-1): the resolver stays pure and only reports
575
+ # :sod_bypassed; the Guard — which runs once per REAL gated action, never on
576
+ # advisory allowed_to?/scope_for — records the override exactly once and
577
+ # surfaces it on the response. Recorded for ANY verb: the guarantee is
578
+ # "every bypass is audited", so if a host ever routes an SoD action to GET,
579
+ # the bypass still leaves its trail rather than slipping through unlogged.
580
+ # Event.record! is a no-op when config.audit is false, so an audit-off host
581
+ # still permits, records nothing — consistent with the rest of the ledger.
582
+ def record_sod_bypass(permission, record)
583
+ initiator = record.send(CurrentScope::Resolver::INITIATOR_METHOD)
584
+ CurrentScope::Event.record!(
585
+ event: "sod.bypassed", target: record,
586
+ details: { permission: permission, initiator: initiator&.to_gid&.to_s }
587
+ )
588
+ response.set_header("X-Current-Scope-Reason", "sod_bypassed")
589
+ end
590
+
591
+ # A5 dev/test aid (on by default in dev/test, #41): the request was ALLOWED, but if it's an SoD
592
+ # action gated with a nil record, the SoD veto was silently skipped — a sign
593
+ # current_scope_record returned nil on a member action. Lives here (the gate
594
+ # seam), not in the shared resolver, so it never fires on advisory
595
+ # allowed_to?/scope_for calls. Prod behavior is unchanged either way.
596
+ # The denial-side mirror of nudge_on_nil_sod_record (#41): this controller
597
+ # declared NO current_scope_record hook, and the subject holds a scoped grant
598
+ # that would have applied if it had.
599
+ #
600
+ # That is a controller with member actions that forgot the hook. It fails
601
+ # closed — correctly — but the resulting 403 is byte-identical to "you were
602
+ # never granted this", so whoever debugs it goes and stares at the grants,
603
+ # which are fine, instead of the controller, which isn't.
604
+ #
605
+ # Keyed on NO_RECORD, NOT on nil, and the difference is the whole nudge:
606
+ #
607
+ # - NO_RECORD = "this controller never said whether there's a record here."
608
+ # Silence. Scoped grants can't open the gate, so a genuinely-granted
609
+ # subject is refused and nothing says why. THIS is the bug.
610
+ # - nil = "there is no record here", stated deliberately by the host.
611
+ # Since #49 a scoped role ticking the key OPENS that gate, so a subject
612
+ # with a matching grant isn't denied at all and there is nothing to nudge
613
+ # about. Nudging here would fire on every legitimate collection request.
614
+ #
615
+ # (Plan 023 predates #49 and guards on `record.nil?` — which can no longer
616
+ # fire for the case it was written for, and excludes the case that can. Pinned
617
+ # by tests below rather than left to the next reader to rediscover.)
618
+ def nudge_on_inert_scoped_grant(permission, record, reason)
619
+ return unless CurrentScope.config.warn_on_inert_scoped_grant
620
+ return unless reason == :no_grant
621
+ return unless record.equal?(NO_RECORD)
622
+
623
+ # Diagnostics must not 500 the request (#74): a transient DB error inside
624
+ # scoped_grant_exists? is log-only, matching record_would_deny_event.
625
+ has_grant =
626
+ begin
627
+ CurrentScope.resolver.scoped_grant_exists?(
628
+ subject: CurrentScope::Current.user, permission: permission
629
+ )
630
+ rescue StandardError => e
631
+ Rails.logger&.warn(
632
+ "[CurrentScope] warn_on_inert_scoped_grant could not check scoped grants " \
633
+ "(#{safe_error_description(e)}); skipping diagnostic for \"#{permission}\"."
634
+ )
635
+ false
636
+ end
637
+ return unless has_grant
638
+
639
+ # Says only what the predicate proves. scoped_grant_exists? has no resource
640
+ # filter — it can't have one, the missing record IS the bug — so it
641
+ # establishes that a matching scoped grant exists on SOME record, not that
642
+ # it would have applied to whichever record this action meant. Claiming
643
+ # "would satisfy it" overstates that, and a diagnostic that overstates is
644
+ # how a diagnostic starts being ignored. (#61 review, qodo)
645
+ message =
646
+ "[CurrentScope] denied \"#{permission}\" (no_grant) — this subject holds a scoped grant " \
647
+ "for it on some record, and #{controller_path} declares no current_scope_record, so the " \
648
+ "gate had no record to match it against. If this is a member action, declare the hook " \
649
+ "(`def current_scope_record = set_thing`) — if the subject holds the grant on the record " \
650
+ "in question, that fixes this. If the controller is collection-only, " \
651
+ "`def current_scope_record = nil` says so and lets scoped grants through."
652
+
653
+ # R9 (#50): this controller declared current_scope_model WITHOUT
654
+ # current_scope_record — the model hook is INERT, because no record hook
655
+ # means NO_RECORD and the record-less branch (the only reader of the
656
+ # declared type) never runs. That trigger is byte-for-byte this nudge's
657
+ # own, so it is one clause on this line, not a second nudge: two log
658
+ # lines saying the same thing on the same request is the noise the
659
+ # diagnostics contract exists to avoid.
660
+ if respond_to?(:current_scope_model, true)
661
+ message += " NOTE: this controller declares current_scope_model, but that hook is inert " \
662
+ "without current_scope_record — the record hook is what's missing here."
663
+ end
664
+
665
+ Rails.logger&.warn(message)
666
+ end
667
+
668
+ # #50's adoption gap, named at the moment it bites: the resolver denied
669
+ # :model_undeclared — a declared collection action (record nil) with no
670
+ # current_scope_model, while a scoped grant explicitly ticks the key. The
671
+ # reason ALREADY proves that whole condition (the resolver derived it), so
672
+ # the predicate here is the reason and nothing else — re-deriving it from
673
+ # record/grants would be the drifting second copy KTD-5 warns about.
674
+ # `record` is taken to mirror its sibling's call shape, not consulted.
675
+ # Log-only; the reason rides X-Current-Scope-Reason with or without this.
676
+ def nudge_on_undeclared_collection_model(permission, _record, reason)
677
+ return unless CurrentScope.config.warn_on_undeclared_collection_model
678
+
679
+ # Both labels are the same cell — a scoped grant satisfies the key but
680
+ # the gate had no usable type to bind it to — with different fixes, so
681
+ # each gets its own sentence. :model_invalid names the actual value the
682
+ # hook returned, because "declares no current_scope_model" would be a
683
+ # lie to the host that DID declare one and typo'd it (the release-gate
684
+ # finding this branch exists for).
685
+ case reason
686
+ when :model_undeclared
687
+ # The grant is known to satisfy the key on SOME record of SOME type — a
688
+ # tick, or (on a listed read, #65) a full_access role, which ticks
689
+ # nothing. The missing declaration is exactly why the gate couldn't
690
+ # check which, and it also can't check record liveness — so the fix is
691
+ # named as "may fix", never promised. (#61 wording precedent; the
692
+ # resolver's label-predicate comment relies on this hedge.)
693
+ Rails.logger&.warn(
694
+ "[CurrentScope] denied \"#{permission}\" (model_undeclared) — this is a declared " \
695
+ "collection action (current_scope_record returned nil) and the subject holds a scoped " \
696
+ "grant that satisfies the key, but #{controller_path} declares no current_scope_model, " \
697
+ "so the gate had no type to bind it to and failed closed. Declaring the type this " \
698
+ "collection deals in (`def current_scope_model = TheType`) may fix this — the grant " \
699
+ "must be of that type, and for a listed read its record must still be in the model's " \
700
+ "default scope."
701
+ )
702
+ when :model_invalid
703
+ # Re-read the hook rather than thread the value through the call —
704
+ # keeping the call site at its siblings' 3-arg shape, so a host that
705
+ # collides with this (private, but un-namespaced) method name at the
706
+ # old arity gets its own method called, not an ArgumentError-500 on a
707
+ # denied request (PR #93 review, qodo + cubic). Display-only re-read:
708
+ # the hook is a plain method and the decision is already made.
709
+ model = resolve_current_scope_model
710
+ # A diagnostic must never alter the denial path — an object whose
711
+ # inspect raises would turn this 403 into a 500 (PR #93 review, cubic).
712
+ described = begin
713
+ model.inspect
714
+ rescue StandardError
715
+ "(uninspectable object)"
716
+ end
717
+ Rails.logger&.warn(
718
+ "[CurrentScope] denied \"#{permission}\" (model_invalid) — #{controller_path}'s " \
719
+ "current_scope_model returned #{described}, which is not a concrete " \
720
+ "ActiveRecord model class, so the gate could not bind the record-less check to it " \
721
+ "and failed closed. Return the AR class this collection lists " \
722
+ "(`def current_scope_model = TheType`) — a String, instance, or abstract class " \
723
+ "cannot be matched against scoped grants."
724
+ )
725
+ end
726
+ end
727
+
728
+ def nudge_on_nil_sod_record(permission, record)
729
+ return unless CurrentScope.config.warn_on_nil_sod_record
730
+ # Ask the resolver (#74) — do not re-derive "did the veto skip?". A private
731
+ # nil/NO_RECORD copy missed the commonest mistake: a hook returning
732
+ # params[:id] (a String). sod_veto_skipped? is the single definition.
733
+ gate_record = record.equal?(NO_RECORD) ? nil : record
734
+ return unless CurrentScope.resolver.sod_veto_skipped?(permission: permission, record: gate_record)
735
+
736
+ shape_hint =
737
+ if gate_record.nil?
738
+ "a nil record"
739
+ else
740
+ "a non-record (#{gate_record.class.name}) — often a hook returning params[:id]"
741
+ end
742
+
743
+ Rails.logger&.warn(
744
+ "[CurrentScope] \"#{permission}\" is a separation-of-duties action but was gated with " \
745
+ "#{shape_hint}, so the SoD veto was skipped. If this is a member action, " \
746
+ "current_scope_record must return the AR record; if it's a collection action, this is expected."
747
+ )
748
+ end
749
+ end
750
+ end