current_scope 0.2.0 → 0.3.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.

Potentially problematic release.


This version of current_scope might be problematic. Click here for more details.

Files changed (35) 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 +99 -0
  5. data/app/controllers/current_scope/application_controller.rb +39 -1
  6. data/app/controllers/current_scope/role_assignments_controller.rb +14 -14
  7. data/app/controllers/current_scope/roles_controller.rb +6 -2
  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/members.html.erb +3 -3
  14. data/app/views/current_scope/roles/new.html.erb +1 -1
  15. data/app/views/current_scope/scoped_role_assignments/new.html.erb +12 -3
  16. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  17. data/app/views/current_scope/subjects/index.html.erb +17 -5
  18. data/app/views/layouts/current_scope/application.html.erb +4 -1
  19. data/config/routes.rb +3 -4
  20. data/lib/current_scope/configuration.rb +378 -16
  21. data/lib/current_scope/engine.rb +7 -0
  22. data/lib/current_scope/gating_reflection.rb +62 -0
  23. data/lib/current_scope/gating_tripwire.rb +36 -5
  24. data/lib/current_scope/guard.rb +406 -8
  25. data/lib/current_scope/mutation_guard.rb +30 -5
  26. data/lib/current_scope/permission_catalog.rb +116 -3
  27. data/lib/current_scope/permission_grid.rb +34 -4
  28. data/lib/current_scope/permissions.rb +42 -8
  29. data/lib/current_scope/resolver.rb +317 -13
  30. data/lib/current_scope/version.rb +1 -1
  31. data/lib/current_scope.rb +113 -5
  32. data/lib/generators/current_scope/install/install_generator.rb +64 -0
  33. data/lib/generators/current_scope/install/templates/initializer.rb +94 -5
  34. data/lib/tasks/current_scope_tasks.rake +153 -0
  35. metadata +6 -2
@@ -21,9 +21,30 @@ module CurrentScope
21
21
  # chain (render/redirect) — so an action that renders straight from a
22
22
  # before_action escapes the tripwire. It is a strong dev/test aid, not total
23
23
  # coverage (Grape/Rack endpoints are out of reach for the same reason).
24
+ # What a catch DOES is config.gating_tripwire (:raise in dev/test, :warn
25
+ # elsewhere) — :warn logs each ungated controller#action once instead of
26
+ # 500ing, so a real app can inventory its ungated surface.
24
27
  module GatingTripwire
25
28
  extend ActiveSupport::Concern
26
29
 
30
+ class << self
31
+ # The :warn latch. ponytail: a plain Set, not a Mutex — worst case under
32
+ # a race is one extra line, and a flood is the thing being prevented.
33
+ # Per-SITE, not per-process (unlike Guard.ledger_warning_emitted?): the
34
+ # point of :warn is an inventory, so every distinct controller#action
35
+ # must get its own line.
36
+ def warning_unseen?(site)
37
+ @warned ||= Set.new
38
+ @warned.add?(site) ? true : false
39
+ end
40
+
41
+ # Cleared on engine to_prepare — a reload can change whether a site is
42
+ # gated, so a stale latch is a false all-clear — and by tests.
43
+ def reset_warnings!
44
+ @warned = nil
45
+ end
46
+ end
47
+
27
48
  included do
28
49
  after_action :current_scope_verify_gated!
29
50
  end
@@ -43,11 +64,21 @@ module CurrentScope
43
64
  # controller carrying only this mixin (no Guard) never sets it.
44
65
  return if instance_variable_defined?(:@current_scope_checked) && @current_scope_checked
45
66
 
46
- raise CurrentScope::ConfigurationError,
47
- "\"#{controller_path}##{action_name}\" completed without running current_scope_check! — " \
48
- "this controller is not gated by CurrentScope::Guard. Include CurrentScope::Guard on its " \
49
- "base controller, or mark this action public with " \
50
- "`current_scope_skip_tripwire! only: :#{action_name}`."
67
+ raise CurrentScope::ConfigurationError, current_scope_tripwire_message if CurrentScope.config.gating_tripwire == :raise
68
+
69
+ # :warn same remediation text, once per controller#action. The latch
70
+ # check comes first so an already-warned site (every request after the
71
+ # first, on a fail-open that keeps serving traffic) pays no string build.
72
+ return unless GatingTripwire.warning_unseen?("#{controller_path}##{action_name}")
73
+
74
+ Rails.logger&.warn("[CurrentScope] #{current_scope_tripwire_message}")
75
+ end
76
+
77
+ def current_scope_tripwire_message
78
+ "\"#{controller_path}##{action_name}\" completed without running current_scope_check! — " \
79
+ "this controller is not gated by CurrentScope::Guard. Include CurrentScope::Guard on its " \
80
+ "base controller, or mark this action public with " \
81
+ "`current_scope_skip_tripwire! only: :#{action_name}`."
51
82
  end
52
83
  end
53
84
  end
@@ -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,224 @@ 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
123
+
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
49
136
 
50
137
  # The real actor (Current.actor) enters here explicitly — the resolver
51
138
  # never reads Current itself (PDP purity). It only matters under SoD
52
139
  # :either while impersonating; otherwise actor == subject.
53
140
  allowed, reason = CurrentScope.resolver.decide(
54
141
  subject: CurrentScope::Current.user, permission: permission,
55
- record: record, actor: CurrentScope::Current.actor
142
+ record: record, model: model, actor: CurrentScope::Current.actor
56
143
  )
57
- raise CurrentScope::AccessDenied.new(permission, reason: reason) unless allowed
144
+ unless allowed
145
+ # The nudge runs BEFORE the report-mode branch, and that ordering is the
146
+ # whole point of it in a retrofit. Report mode downgrades a :no_grant to
147
+ # an observation and lets the request through — so a nudge placed after
148
+ # the early return would go silent for exactly the host report mode
149
+ # exists for. And a missing record hook is the one gap report mode CANNOT
150
+ # explain on its own: the would_deny row for that action never clears, no
151
+ # matter what you grant, because the gate has no record to match a scoped
152
+ # grant against. This is the line that says why. Log-only either way, so
153
+ # it cannot affect the branch below. (#37/#41 interaction)
154
+ nudge_on_inert_scoped_grant(permission, record, reason)
155
+ nudge_on_undeclared_collection_model(permission, record, reason)
156
+
157
+ return report_would_deny(permission, record) if report_only_denial?(reason, permission, record)
158
+
159
+
160
+ raise CurrentScope::AccessDenied.new(permission, reason: reason)
161
+ end
58
162
 
59
163
  record_sod_bypass(permission, record) if reason == :sod_bypassed
60
164
  nudge_on_nil_sod_record(permission, record)
61
165
  end
62
166
 
167
+ # Report mode lifts EXACTLY ONE wall: :no_grant — "nobody has granted this
168
+ # subject this permission yet", which is the entire state of a host that has
169
+ # mounted the gate and not yet seeded its grants. That is the thing report
170
+ # mode exists to survey.
171
+ #
172
+ # Matched POSITIVELY, on one reason, and that is the whole design. Every
173
+ # other denial is a real refusal about a real rule and must still 403:
174
+ # :sod_veto (relaxing it lets an initiator actually self-approve — a fraud
175
+ # action executed, not a role gap surfaced), :impersonation_gate, and
176
+ # :not_full_access (the management console — report mode must never hand out
177
+ # the UI where grants are made).
178
+ #
179
+ # An "everything except the vetoes I know about" rule would have been correct
180
+ # the day it was written and wrong by the next release: :not_full_access did
181
+ # not exist when this was designed, and it is excluded here by construction
182
+ # rather than by anyone remembering to add it. New reasons are refusals until
183
+ # someone deliberately says otherwise — fail-closed, applied to the mode
184
+ # itself.
185
+ #
186
+ # ...but :no_grant is not always the innocent reason it looks like. See below.
187
+ def report_only_denial?(reason, permission, record)
188
+ CurrentScope.config.report_only? &&
189
+ reason == :no_grant &&
190
+ !sod_veto_blind_spot?(permission, record)
191
+ end
192
+
193
+ # The SoD blind spot: a :no_grant that is NOT evidence the veto approved.
194
+ #
195
+ # The veto has nothing to measure without a record, so the resolver skips it
196
+ # and the decision falls through to the ordinary grant check. What comes back
197
+ # is :no_grant — indistinguishable from an ordinary missing grant, but meaning
198
+ # "nobody asked the veto", not "the veto passed".
199
+ #
200
+ # In :enforce that costs nothing; :no_grant is a 403 either way, so the
201
+ # skipped veto never decides anything (config.warn_on_nil_sod_record exists to
202
+ # surface it on the ALLOW path). Report mode is what turns it into a hole:
203
+ # :no_grant is exactly what it downgrades, so a host that mis-declares
204
+ # current_scope_record on an SoD action gets the action EXECUTED with the
205
+ # four-eyes rule never consulted. The subject could be the initiator. Nobody
206
+ # checked.
207
+ #
208
+ # So report mode declines to speak where the veto couldn't, and downgrades
209
+ # only a denial the veto actually saw and passed. This costs a retrofitting
210
+ # host nothing real: an SoD action reached without a record is a
211
+ # misconfiguration they must fix regardless, and it still 403s as it does
212
+ # today.
213
+ #
214
+ # ASKS the resolver rather than re-deriving "did the veto run" — the resolver
215
+ # owns that condition and a second copy would drift, with the drifting copy
216
+ # being the one guarding the fraud control. An earlier draft of this did
217
+ # enumerate its own "record-less" set (nil, NO_RECORD, Class) and missed the
218
+ # commonest mistake of all: a hook returning `params[:id]`, a String, which
219
+ # the resolver skips the veto for but that guess would have waved through.
220
+ def sod_veto_blind_spot?(permission, record)
221
+ CurrentScope.resolver.sod_veto_skipped?(permission: permission, record: record)
222
+ end
223
+
224
+ # Observe and proceed.
225
+ def report_would_deny(permission, record)
226
+ Rails.logger&.warn(
227
+ "[CurrentScope] report-only: would DENY #{permission.inspect} " \
228
+ "(reason: no_grant) — grant it before setting config.enforcement = :enforce"
229
+ )
230
+ response.set_header("X-Current-Scope-Reason", "would_deny")
231
+ record_would_deny_event(permission, record)
232
+ end
233
+
234
+ # R3: report mode NEVER raises — that is its whole promise, and it has to hold
235
+ # regardless of audit posture or the state of the ledger.
236
+ #
237
+ # Every other caller of Event.record! is a mutation being performed, where
238
+ # :strict re-raising to roll back an unaudited change is exactly right. This
239
+ # is not a mutation: it observes a request that is being let through anyway.
240
+ # Inheriting that raise would mean a host running audit = :strict who hasn't
241
+ # run the events migration 500s on every ungranted request — the opposite of
242
+ # what report mode promises, landing on the exact host it exists for.
243
+ #
244
+ # The rescue wraps ONLY this call. Event.record! is the one thing here with a
245
+ # documented raise contract, so it is the one thing worth catching; a broad
246
+ # rescue over the whole observation would also swallow a broken logger or
247
+ # response, which are app-fatal anyway and shouldn't be hidden. (#59 review)
248
+ def record_would_deny_event(permission, record)
249
+ subject = CurrentScope::Current.user
250
+ # No ambient subject ⇒ nothing to attribute the row to, and Event.record!
251
+ # raises on a nil actor. Guard on the SUBJECT, not on `target` — a record
252
+ # can be non-nil while the subject is nil.
253
+ return if subject.nil?
254
+
255
+ # NO_RECORD (the controller declared no hook) and nil (it declared "no
256
+ # record here") both mean there is nothing to attribute the row to but the
257
+ # subject. Compared by identity — NO_RECORD is an Object instance, so
258
+ # `is_a?` would match every record there is.
259
+ target = record.equal?(NO_RECORD) ? nil : record
260
+
261
+ CurrentScope::Event.record!(
262
+ event: "access.would_deny", target: target || subject,
263
+ details: { permission: permission, reason: "no_grant" }
264
+ )
265
+ rescue StandardError => e
266
+ # ponytail: swallow and warn ONCE. An unrecordable observation is a lost
267
+ # log line; a raise here is a 500 on a request report mode promised to pass.
268
+ warn_ledger_failure_once(e)
269
+ nil
270
+ end
271
+
272
+ # The failure this catches is PERSISTENT, not incidental: :report + audit
273
+ # :strict + an un-migrated events table fails identically on every request.
274
+ # Warning per-request floods the log with one repeated line and buries the
275
+ # thing the operator actually needs — that the ledger is empty because the
276
+ # table is missing, and what to do about it. And it is the exact situation
277
+ # report mode exists for, so it is the one a host is most likely to be in.
278
+ #
279
+ # Warn-once per process, mirroring Event.warn_missing_events_table_once —
280
+ # the same failure, the same treatment. (#59 review) The message names the
281
+ # fix for a missing table and otherwise reports the real error, because
282
+ # telling someone with a dead connection to run migrations sends them after
283
+ # the wrong problem.
284
+ def warn_ledger_failure_once(error)
285
+ return if CurrentScope::Guard.ledger_warning_emitted?
286
+
287
+ CurrentScope::Guard.ledger_warning_emitted!
288
+ Rails.logger&.warn("[CurrentScope] report-only: #{ledger_failure_hint(error)} " \
289
+ "The request WAS allowed through — only the access.would_deny " \
290
+ "row is missing. This warns once per process.")
291
+ end
292
+
293
+ # ASKS Event whether this is the un-migrated-table case rather than pattern-
294
+ # matching the message here. Event's signature already excludes missing-COLUMN
295
+ # errors — a partial migration is not an absent table, and its own comment
296
+ # says why: it "would point operators at the wrong fix". A looser test here
297
+ # reintroduced exactly that, telling someone their table was missing while
298
+ # they were looking right at it. (#59 review)
299
+ def ledger_failure_hint(error)
300
+ if CurrentScope::Event.missing_events_table?(error)
301
+ "the current_scope_events table is missing, so would-be denials are not being " \
302
+ "recorded and `rails current_scope:report` will be empty. Run " \
303
+ "`rails current_scope:install:migrations && rails db:migrate`, or set " \
304
+ "config.audit = false if you don't want the ledger."
305
+ else
306
+ "could not record a would-be denial (#{error.class}: #{error.message.to_s.truncate(120)})."
307
+ end
308
+ end
309
+
310
+ # The record this gate decides against, or NO_RECORD when the controller
311
+ # never declared the hook (see NO_RECORD). A declared hook's answer — record
312
+ # or nil — is passed through exactly as given.
313
+ #
314
+ # Deliberately reads the DECLARATION, not the route. Guessing member-vs-
315
+ # collection from path parameters cannot be made correct: `:id` misses
316
+ # `param: :slug`; "any key not suffixed _id" misses `param: :external_id`
317
+ # and falsely accuses a nested parent with a custom param. Each rule fails
318
+ # on the next routing DSL option, because the route simply does not encode
319
+ # what the host means. The hook does, and the contract above already asks
320
+ # every gated controller to declare it.
321
+ def resolve_current_scope_record
322
+ return NO_RECORD unless respond_to?(:current_scope_record, true)
323
+
324
+ send(:current_scope_record)
325
+ end
326
+
327
+ # The type this controller's collection actions deal in, or nil when the
328
+ # host never declared current_scope_model. Mirrors
329
+ # resolve_current_scope_record, minus the sentinel: for the RECORD,
330
+ # "declared nil" and "declared nothing" are different statements and
331
+ # NO_RECORD keeps them apart; for the TYPE both collapse to the same fact —
332
+ # unknown — so a plain nil carries it.
333
+ def resolve_current_scope_model
334
+ return nil unless respond_to?(:current_scope_model, true)
335
+
336
+ send(:current_scope_model)
337
+ end
338
+
63
339
  # Break-glass audit (KTD-1): the resolver stays pure and only reports
64
340
  # :sod_bypassed; the Guard — which runs once per REAL gated action, never on
65
341
  # advisory allowed_to?/scope_for — records the override exactly once and
@@ -77,14 +353,136 @@ module CurrentScope
77
353
  response.set_header("X-Current-Scope-Reason", "sod_bypassed")
78
354
  end
79
355
 
80
- # A5 dev/test aid (opt-in): the request was ALLOWED, but if it's an SoD
356
+ # A5 dev/test aid (on by default in dev/test, #41): the request was ALLOWED, but if it's an SoD
81
357
  # action gated with a nil record, the SoD veto was silently skipped — a sign
82
358
  # current_scope_record returned nil on a member action. Lives here (the gate
83
359
  # seam), not in the shared resolver, so it never fires on advisory
84
360
  # allowed_to?/scope_for calls. Prod behavior is unchanged either way.
361
+ # The denial-side mirror of nudge_on_nil_sod_record (#41): this controller
362
+ # declared NO current_scope_record hook, and the subject holds a scoped grant
363
+ # that would have applied if it had.
364
+ #
365
+ # That is a controller with member actions that forgot the hook. It fails
366
+ # closed — correctly — but the resulting 403 is byte-identical to "you were
367
+ # never granted this", so whoever debugs it goes and stares at the grants,
368
+ # which are fine, instead of the controller, which isn't.
369
+ #
370
+ # Keyed on NO_RECORD, NOT on nil, and the difference is the whole nudge:
371
+ #
372
+ # - NO_RECORD = "this controller never said whether there's a record here."
373
+ # Silence. Scoped grants can't open the gate, so a genuinely-granted
374
+ # subject is refused and nothing says why. THIS is the bug.
375
+ # - nil = "there is no record here", stated deliberately by the host.
376
+ # Since #49 a scoped role ticking the key OPENS that gate, so a subject
377
+ # with a matching grant isn't denied at all and there is nothing to nudge
378
+ # about. Nudging here would fire on every legitimate collection request.
379
+ #
380
+ # (Plan 023 predates #49 and guards on `record.nil?` — which can no longer
381
+ # fire for the case it was written for, and excludes the case that can. Pinned
382
+ # by tests below rather than left to the next reader to rediscover.)
383
+ def nudge_on_inert_scoped_grant(permission, record, reason)
384
+ return unless CurrentScope.config.warn_on_inert_scoped_grant
385
+ return unless reason == :no_grant
386
+ return unless record.equal?(NO_RECORD)
387
+ return unless CurrentScope.resolver.scoped_grant_exists?(
388
+ subject: CurrentScope::Current.user, permission: permission
389
+ )
390
+
391
+ # Says only what the predicate proves. scoped_grant_exists? has no resource
392
+ # filter — it can't have one, the missing record IS the bug — so it
393
+ # establishes that a matching scoped grant exists on SOME record, not that
394
+ # it would have applied to whichever record this action meant. Claiming
395
+ # "would satisfy it" overstates that, and a diagnostic that overstates is
396
+ # how a diagnostic starts being ignored. (#61 review, qodo)
397
+ message =
398
+ "[CurrentScope] denied \"#{permission}\" (no_grant) — this subject holds a scoped grant " \
399
+ "for it on some record, and #{controller_path} declares no current_scope_record, so the " \
400
+ "gate had no record to match it against. If this is a member action, declare the hook " \
401
+ "(`def current_scope_record = set_thing`) — if the subject holds the grant on the record " \
402
+ "in question, that fixes this. If the controller is collection-only, " \
403
+ "`def current_scope_record = nil` says so and lets scoped grants through."
404
+
405
+ # R9 (#50): this controller declared current_scope_model WITHOUT
406
+ # current_scope_record — the model hook is INERT, because no record hook
407
+ # means NO_RECORD and the record-less branch (the only reader of the
408
+ # declared type) never runs. That trigger is byte-for-byte this nudge's
409
+ # own, so it is one clause on this line, not a second nudge: two log
410
+ # lines saying the same thing on the same request is the noise the
411
+ # diagnostics contract exists to avoid.
412
+ if respond_to?(:current_scope_model, true)
413
+ message += " NOTE: this controller declares current_scope_model, but that hook is inert " \
414
+ "without current_scope_record — the record hook is what's missing here."
415
+ end
416
+
417
+ Rails.logger&.warn(message)
418
+ end
419
+
420
+ # #50's adoption gap, named at the moment it bites: the resolver denied
421
+ # :model_undeclared — a declared collection action (record nil) with no
422
+ # current_scope_model, while a scoped grant explicitly ticks the key. The
423
+ # reason ALREADY proves that whole condition (the resolver derived it), so
424
+ # the predicate here is the reason and nothing else — re-deriving it from
425
+ # record/grants would be the drifting second copy KTD-5 warns about.
426
+ # `record` is taken to mirror its sibling's call shape, not consulted.
427
+ # Log-only; the reason rides X-Current-Scope-Reason with or without this.
428
+ def nudge_on_undeclared_collection_model(permission, _record, reason)
429
+ return unless CurrentScope.config.warn_on_undeclared_collection_model
430
+
431
+ # Both labels are the same cell — a scoped grant satisfies the key but
432
+ # the gate had no usable type to bind it to — with different fixes, so
433
+ # each gets its own sentence. :model_invalid names the actual value the
434
+ # hook returned, because "declares no current_scope_model" would be a
435
+ # lie to the host that DID declare one and typo'd it (the release-gate
436
+ # finding this branch exists for).
437
+ case reason
438
+ when :model_undeclared
439
+ # The grant is known to satisfy the key on SOME record of SOME type — a
440
+ # tick, or (on a listed read, #65) a full_access role, which ticks
441
+ # nothing. The missing declaration is exactly why the gate couldn't
442
+ # check which, and it also can't check record liveness — so the fix is
443
+ # named as "may fix", never promised. (#61 wording precedent; the
444
+ # resolver's label-predicate comment relies on this hedge.)
445
+ Rails.logger&.warn(
446
+ "[CurrentScope] denied \"#{permission}\" (model_undeclared) — this is a declared " \
447
+ "collection action (current_scope_record returned nil) and the subject holds a scoped " \
448
+ "grant that satisfies the key, but #{controller_path} declares no current_scope_model, " \
449
+ "so the gate had no type to bind it to and failed closed. Declaring the type this " \
450
+ "collection deals in (`def current_scope_model = TheType`) may fix this — the grant " \
451
+ "must be of that type, and for a listed read its record must still be in the model's " \
452
+ "default scope."
453
+ )
454
+ when :model_invalid
455
+ # Re-read the hook rather than thread the value through the call —
456
+ # keeping the call site at its siblings' 3-arg shape, so a host that
457
+ # collides with this (private, but un-namespaced) method name at the
458
+ # old arity gets its own method called, not an ArgumentError-500 on a
459
+ # denied request (PR #93 review, qodo + cubic). Display-only re-read:
460
+ # the hook is a plain method and the decision is already made.
461
+ model = resolve_current_scope_model
462
+ # A diagnostic must never alter the denial path — an object whose
463
+ # inspect raises would turn this 403 into a 500 (PR #93 review, cubic).
464
+ described = begin
465
+ model.inspect
466
+ rescue StandardError
467
+ "(uninspectable object)"
468
+ end
469
+ Rails.logger&.warn(
470
+ "[CurrentScope] denied \"#{permission}\" (model_invalid) — #{controller_path}'s " \
471
+ "current_scope_model returned #{described}, which is not a concrete " \
472
+ "ActiveRecord model class, so the gate could not bind the record-less check to it " \
473
+ "and failed closed. Return the AR class this collection lists " \
474
+ "(`def current_scope_model = TheType`) — a String, instance, or abstract class " \
475
+ "cannot be matched against scoped grants."
476
+ )
477
+ end
478
+ end
479
+
85
480
  def nudge_on_nil_sod_record(permission, record)
86
481
  return unless CurrentScope.config.warn_on_nil_sod_record
87
- return unless record.nil?
482
+ # NO_RECORD counts: it IS the member-action-with-no-record case this nudge
483
+ # exists to catch, so it must not go quiet just because the Guard now
484
+ # labels that case instead of passing a bare nil.
485
+ return unless record.nil? || record.equal?(NO_RECORD)
88
486
  return unless CurrentScope.config.sod_actions.include?(permission.split("#").last)
89
487
 
90
488
  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