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.
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
@@ -10,7 +10,15 @@ module CurrentScope
10
10
  # present and future.
11
11
  # 3. org-wide role — the role's permission set includes this permission.
12
12
  # 4. scoped role — a role held on THIS record grants the permission.
13
- # 5. default-denynothing granted means denied.
13
+ # 5. scoped role,the target is record-less (nil for a collection action,
14
+ # record-less a Class for a class-form check), so no specific record
15
+ # can be named. An action in config.collection_read_actions
16
+ # asks scope_for — the id-narrowed list query — so the gate
17
+ # opens exactly when the list would show records
18
+ # (full_access included, #65); any other action needs a
19
+ # scoped grant EXPLICITLY ticking the key. scope_for then
20
+ # narrows the list to those records.
21
+ # 6. default-deny — nothing granted means denied.
14
22
  class Resolver
15
23
  INITIATOR_METHOD = :current_scope_initiator
16
24
  # Host-defined per-record opt-in for break-glass. Absent ⇒ never bypassed
@@ -21,8 +29,8 @@ module CurrentScope
21
29
  # Public contract: boolean. `actor` is the REAL principal behind the
22
30
  # request (defaults to the subject — no impersonation); it only widens the
23
31
  # SoD veto under config.sod_identity == :either.
24
- def allow?(subject:, permission:, record: nil, actor: nil)
25
- decide(subject: subject, permission: permission, record: record, actor: actor).first
32
+ def allow?(subject:, permission:, record: nil, actor: nil, model: nil)
33
+ decide(subject: subject, permission: permission, record: record, actor: actor, model: model).first
26
34
  end
27
35
 
28
36
  # Internal decision: returns [allowed_bool, reason_or_nil]. The reason is a
@@ -30,7 +38,13 @@ module CurrentScope
30
38
  # denial, and :sod_bypassed on the one AUDITED allow (break-glass). Ordinary
31
39
  # allows carry nil. The resolver — shared across threads — holds no
32
40
  # per-decision state; the reason rides in the return tuple, not on self.
33
- def decide(subject:, permission:, record: nil, actor: nil)
41
+ #
42
+ # `model:` (#50) is the type the controller's current_scope_model hook
43
+ # declared for its collection actions, or nil when unknown. Only the
44
+ # record-less branch reads it, to bind that otherwise type-unbound grant
45
+ # check; every other branch ignores it. It stays a parameter, never state,
46
+ # per the purity rule above.
47
+ def decide(subject:, permission:, record: nil, actor: nil, model: nil)
34
48
  return [ false, :no_grant ] if subject.nil?
35
49
 
36
50
  case sod_decision(subject: subject, actor: actor, permission: permission, record: record)
@@ -42,6 +56,21 @@ module CurrentScope
42
56
  return [ true, nil ] if role&.full_access?
43
57
  return [ true, nil ] if role&.grants?(permission)
44
58
  return [ true, nil ] if scoped_grant?(subject: subject, permission: permission, record: record)
59
+ return [ true, nil ] if record_less_scoped_grant?(subject: subject, permission: permission, record: record, model: model)
60
+
61
+ # A LABEL, not a decision (R7a): this deny would have been an ALLOW had
62
+ # the controller declared current_scope_model and the grant ticked the
63
+ # key of that type. Without the distinct reason it is indistinguishable
64
+ # from an ordinary :no_grant, and the dev nudge that explains it is
65
+ # dev/test-only — so the production host who most needs the cause
66
+ # (403s after an upgrade) would be the one who cannot see it. Two labels
67
+ # for the two ways the declaration can be wrong: absent
68
+ # (:model_undeclared) vs present-but-unusable (:model_invalid — a
69
+ # String, PORO, or abstract class the shape guard refused). Same cell,
70
+ # different fix, so the label says which.
71
+ if record_less_denied_for_unknown_type?(subject: subject, permission: permission, record: record, model: model)
72
+ return [ false, model.nil? ? :model_undeclared : :model_invalid ]
73
+ end
45
74
 
46
75
  [ false, :no_grant ]
47
76
  end
@@ -65,6 +94,10 @@ module CurrentScope
65
94
  # host list can never drift from the per-record decision. Fail-closed (nil
66
95
  # subject / no grant → none) and flat — no parent/child cascade. SoD does
67
96
  # NOT apply: it vetoes record-targeted actions, not list membership.
97
+ #
98
+ # Since #65 the record-less gate asks this same query (.exists?) for
99
+ # actions in config.collection_read_actions, so gate and list cannot
100
+ # drift for listed reads — they are one query.
68
101
  def scope_for(subject:, model:, permission:)
69
102
  return model.none if subject.nil?
70
103
 
@@ -85,14 +118,110 @@ module CurrentScope
85
118
  )
86
119
  end
87
120
 
121
+ # Whether the separation-of-duties veto is in a position to decide about this
122
+ # pair at all: the action must be SoD-listed AND there must be an actual
123
+ # record instance to name an initiator from. The veto is defined in terms of
124
+ # who raised a record, so with no record it has nothing to measure and is
125
+ # skipped — that covers a collection action's nil, a class-form check like
126
+ # allowed_to?(:approve, Report), and equally a host hook that handed back
127
+ # something that isn't a record at all (`params[:id]`, a String).
128
+ #
129
+ # PUBLIC because "the veto did not run" and "the veto passed" are different
130
+ # facts, and a caller that acts on the difference must be able to ASK rather
131
+ # than infer it. It is not inferable from the decision: a skipped veto falls
132
+ # through to the ordinary grant check, so it surfaces as :no_grant or an
133
+ # ordinary allow — never as anything that says "SoD abstained". Report mode
134
+ # (#37) has to know, because :no_grant is the one reason it downgrades.
135
+ #
136
+ # This is the single definition of the condition, deliberately: sod_decision
137
+ # reads it too. Two copies of "did the veto run" is two copies that drift,
138
+ # and the copy that drifts is the one guarding the fraud control. (#59 review)
139
+ def sod_veto_applies?(permission:, record:)
140
+ sod_action?(permission) && record.respond_to?(:new_record?)
141
+ end
142
+
143
+ # The blind spot: an SoD-listed action whose veto could NOT run. The decision
144
+ # that comes back for one of these is :no_grant or an ordinary allow — the
145
+ # veto contributed nothing to it. Anyone treating that :no_grant as "SoD was
146
+ # fine with this" is reading a silence as an answer.
147
+ #
148
+ # The same hazard the record-less scoped branch already refuses (see
149
+ # sod_action? below) and that config.warn_on_nil_sod_record surfaces on the
150
+ # allow path: a host mis-gating a member SoD action. In :enforce this costs
151
+ # nothing — :no_grant is a 403 regardless, so the skipped veto never decides
152
+ # anything. Report mode is what makes it matter, because :no_grant is exactly
153
+ # what it downgrades. (#37, #59 review)
154
+ def sod_veto_skipped?(permission:, record:)
155
+ sod_action?(permission) && !sod_veto_applies?(permission: permission, record: record)
156
+ end
157
+
158
+ # Does this subject hold a scoped grant that satisfies `permission` on ANY
159
+ # record? Read-only, and deliberately unfiltered by resource — that absence
160
+ # IS the question: the grant exists, but the gate never got a record for it
161
+ # to apply to.
162
+ #
163
+ # DIAGNOSTICS ONLY (#41). It answers a COUNTERFACTUAL — "had the controller
164
+ # declared its record hook, would a scoped grant have allowed this?" — and
165
+ # must never decide anything. The record it would need in order to BE a
166
+ # decision is exactly what's missing; that's the bug it reports.
167
+ #
168
+ # Reads roles_granting (full_access ∪ ticking), and that is right BECAUSE
169
+ # the counterfactual binds to a record: with a real record, scoped_grant?
170
+ # honors a scoped full_access role, so an honest "would it have been
171
+ # allowed?" must honor it too. The same reuse in a branch that binds to NO
172
+ # record was #49's P0 escalation — one scoped grant passing every
173
+ # collection gate in the app. The binding is the entire difference, which is
174
+ # why this is a question and not a gate.
175
+ def scoped_grant_exists?(subject:, permission:)
176
+ return false if subject.nil?
177
+
178
+ ScopedRoleAssignment.where(subject: subject, role_id: roles_granting(permission)).exists?
179
+ end
180
+
88
181
  private
89
182
 
90
183
  # Role ids that satisfy `permission`: full_access (grants everything) or an
91
184
  # explicit grant of the key. The one place "does this role grant it?" is
92
- # expressed for scoped grants shared by the gate and scope_for.
185
+ # expressed for scoped grants. Including full_access is only safe while no
186
+ # caller turns an unbound match into a PERMIT. Four callers, each safe for
187
+ # its own reason: scoped_grant? binds `resource:` to the exact record;
188
+ # scope_for binds `resource_type:` and answers in record ids
189
+ # (.select(:resource_id)) for the caller to narrow, never a boolean permit
190
+ # — which is also why the record-less read gate (#65) is safe: it asks
191
+ # scope_for and takes .exists? of the id-narrowed relation, so its answer
192
+ # is still derived from the records the subject holds, not from the grant
193
+ # row alone; scoped_grant_exists? binds nothing and is therefore
194
+ # diagnostics-only — its own comment says why it must never decide;
195
+ # record_less_denied_for_unknown_type?'s listed-read arm (#65) is the same
196
+ # diagnostics-only shape — it labels a deny, it never decides. A new
197
+ # caller must fit one of those shapes or use roles_ticking. (#65 cites
198
+ # this comment as the safety condition; it is load-bearing, keep it true.)
93
199
  def roles_granting(permission)
94
- Role.where(full_access: true)
95
- .or(Role.where(id: RolePermission.where(permission_key: permission).select(:role_id)))
200
+ Role.where(full_access: true).or(Role.where(id: roles_ticking(permission)))
201
+ end
202
+
203
+ # Role ids that EXPLICITLY tick `permission` — full_access roles deliberately
204
+ # excluded, whether or not they also carry a RolePermission row. Only for the
205
+ # record-less branch, which binds the grant to no record at all: honoring
206
+ # full_access there would mean one scoped full_access grant on one record
207
+ # ("Owner of Report #7") opened EVERY record-less gate in the host app —
208
+ # every #index and #create on every controller — since a full_access role
209
+ # satisfies every key. That is the `resource:` bound scoped_grant? applies
210
+ # and the record-less branch cannot.
211
+ #
212
+ # The `where.not` is load-bearing, not belt-and-braces: a role can be
213
+ # full_access AND retain explicit rows (tick grid cells, then flip the
214
+ # full-access toggle), and matching on the leftover row alone would walk it
215
+ # straight back through the branch full_access is barred from.
216
+ #
217
+ # roles_granting's set is unchanged by this exclusion — it unions
218
+ # full_access back in, so full_access ∪ (ticking − full_access) == the same
219
+ # roles it always matched.
220
+ def roles_ticking(permission)
221
+ RolePermission
222
+ .where(permission_key: permission)
223
+ .where.not(role_id: Role.where(full_access: true).select(:id))
224
+ .select(:role_id)
96
225
  end
97
226
 
98
227
  # The separation-of-duties outcome for this decision: :none (no conflict, or
@@ -100,11 +229,7 @@ module CurrentScope
100
229
  # the veto stands), or :bypass (a conflict exists but break-glass lifts it).
101
230
  # Pure — reads only; the audit write for a :bypass happens at the Guard.
102
231
  def sod_decision(subject:, actor:, permission:, record:)
103
- action = permission.split("#").last
104
- return :none unless CurrentScope.config.sod_actions.include?(action)
105
- # No veto without an actual record instance (collection actions get nil,
106
- # class-form checks like allowed_to?(:approve, Report) get the class).
107
- return :none unless record.respond_to?(:new_record?)
232
+ return :none unless sod_veto_applies?(permission: permission, record: record)
108
233
 
109
234
  # SoD is a structural guarantee — "cannot determine the initiator" must
110
235
  # never mean "permit". A record type where SoD genuinely doesn't apply
@@ -114,7 +239,7 @@ module CurrentScope
114
239
  "#{record.class.name}##{INITIATOR_METHOD} is not defined, but " \
115
240
  "\"#{permission}\" is a separation-of-duties action (config.sod_actions). " \
116
241
  "Define #{INITIATOR_METHOD} on #{record.class.name} (return nil to exempt " \
117
- "a record), or remove \"#{action}\" from config.sod_actions."
242
+ "a record), or remove \"#{permission.split('#').last}\" from config.sod_actions."
118
243
  end
119
244
 
120
245
  initiator = record.send(INITIATOR_METHOD)
@@ -167,5 +292,184 @@ module CurrentScope
167
292
  .where(subject: subject, resource: record, role_id: roles_granting(permission))
168
293
  .exists?
169
294
  end
295
+
296
+ # A record-less target is allowed in one of two ways (#19, #65):
297
+ #
298
+ # - an action in config.collection_read_actions asks scope_for — the
299
+ # id-narrowed query the list renders from — so the gate opens exactly
300
+ # when the subject's list would show records, full_access included.
301
+ # Gate and list agree by construction; an empty list (no grant, or a
302
+ # grant whose record was destroyed) is a deny, fail-closed.
303
+ # - any other action needs a scoped grant whose role EXPLICITLY ticks
304
+ # the key. scope_for then narrows the collection to the granted records.
305
+ #
306
+ # Without this, the two halves of the per-record feature contradict each
307
+ # other: the gate turns a scoped-only subject away from their index, and
308
+ # the org-wide grant that gets them past it makes scope_for return every
309
+ # record.
310
+ #
311
+ # "Record-less" is a CLOSED set of exactly two shapes, tested positively:
312
+ #
313
+ # nil — a collection action; the Guard's hook returns nil when there is
314
+ # no record (guard.rb), which is the documented contract.
315
+ # a Class — the class form, allowed_to?(:index, Report).
316
+ #
317
+ # Positive, never `unless record.respond_to?(:new_record?)`: a negative test
318
+ # admits an OPEN set, so a host whose current_scope_record wrongly returns
319
+ # params[:id] (a String) or any other non-record would land here and be
320
+ # ALLOWED on the strength of a grant held over some *other* record — a
321
+ # fail-open in a fail-closed engine, and a breach of this branch's own
322
+ # invariant that a grant on X must not act on Y. Anything that is not
323
+ # literally nil-or-a-Class is not a record-less target and gets no say here.
324
+ #
325
+ # Consequence, deliberate: an UNPERSISTED instance (Report.new) is not
326
+ # record-less by this test, and scoped_grant? needs persisted? — so it is
327
+ # denied, while the class form is allowed. That asymmetry is only reachable
328
+ # by gating a collection action with Model.new instead of the documented nil,
329
+ # and it fails CLOSED (a 403 the host sees immediately), which is the safe
330
+ # direction to be wrong in.
331
+ #
332
+ # Binds by TYPE (#50). The target names no record, but the controller can
333
+ # name the type its collection lists via current_scope_model (record when
334
+ # it is a Class — the allowed_to?(:index, Report) form — else the model:
335
+ # kwarg the Guard threads). resource_type filters the grant to that type,
336
+ # normalized through base_class exactly as scope_for does, so "a Report
337
+ # grant" cannot open a Documents gate. UNKNOWN type ⇒ this branch does not
338
+ # fire (fail-closed): before #50 an unbound grant of any type opened every
339
+ # record-less gate, and for a key with no list side (#create) that let a
340
+ # Report-scoped subject create Documents — a live escalation, not a
341
+ # cosmetic empty list. A loud, documented deny is the safe direction.
342
+ #
343
+ # full_access and the two arms (#65): the read arm honors a scoped
344
+ # full_access grant because scope_for's answer is DERIVED FROM RECORD IDS —
345
+ # which records the subject holds, joined against the live table — the one
346
+ # shape roles_granting's safety condition names as safe. The non-read arm
347
+ # answers with a boolean off a type-bound match, strictly weaker (the id is
348
+ # discarded), so full_access stays barred there: one scoped grant on one
349
+ # Report must not create Reports. "Owner of Report #7" means full access to
350
+ # Report #7 — and, since #65, to the lists that would show it. (#65 records
351
+ # in full why a type-bound boolean over roles_granting is never the answer;
352
+ # 029's R4 was withdrawn over exactly that.)
353
+ #
354
+ # TRUST NOTE: the declared type is trusted the way current_scope_record is.
355
+ # A wrong current_scope_model plus a scoped full_access grant of the
356
+ # declared type opens that controller's listed reads — review the
357
+ # declaration like the record hook. Before #65 that misconfiguration
358
+ # failed closed; the trade is deliberate and documented (README, #65 plan
359
+ # KTD-5).
360
+ #
361
+ # Deliberately NOT memoized, unlike org_role. The memo there caches one
362
+ # lookup keyed by subject, invalidated by RoleAssignment writes alone. This
363
+ # predicate's answer derives from three tables (scoped assignments, roles,
364
+ # role permissions) — four on the read arm, whose scope_for joins the model
365
+ # table itself — so a memo would need invalidation hooks on all of them,
366
+ # and a stale entry here is a stale ALLOW. Only record-less checks by
367
+ # scoped-only subjects reach this line (org and full_access short-circuit
368
+ # above), so the cost is one query — on the read arm an id-subquery EXISTS
369
+ # against the model table — on a handful of nav-level checks per page, not
370
+ # the per-row gate. Revisit if that ever shows up in a profile.
371
+ def record_less_scoped_grant?(subject:, permission:, record:, model: nil)
372
+ # R3a: the record-less shape test stays FIRST — a declared model never
373
+ # rescues a non-record-less target. A host whose current_scope_record
374
+ # wrongly returns params[:id] (a String) must still fail closed here, not
375
+ # be allowed off a grant held over some other record.
376
+ return false unless record.nil? || record.is_a?(Class)
377
+ return false if sod_action?(permission)
378
+
379
+ # The class form (allowed_to?(:index, Report)) carries the type as the
380
+ # record; otherwise it comes from the model: hook. Unknown ⇒ deny (R3).
381
+ type = record.is_a?(Class) ? record : model
382
+ return false if type.nil?
383
+
384
+ # Shape guard, mirroring R3a's on the record: a type that is not an
385
+ # ActiveRecord model class (a String from a mis-declared
386
+ # current_scope_model, a non-record-storing PORO passed to the class form
387
+ # — the gem's own Scopeable Gadget is one) cannot name a base_class to
388
+ # normalize by. Require an actual AR subclass, not just anything answering
389
+ # base_class — a class whose base_class returns nil would still crash on
390
+ # .name. Deny (fail-closed) rather than raise NoMethodError: a garbage
391
+ # type is not a grant, and denying also preserves the pre-#50 boolean the
392
+ # class form returned for a non-AR argument. This guard must stay ABOVE
393
+ # the read arm — scope_for calls type.base_class and type.where, so a
394
+ # non-AR type would 500 instead of denying. (#50 review, #65)
395
+ #
396
+ # ABSTRACT classes are excluded too (#65 review): ApplicationRecord
397
+ # passes `< ActiveRecord::Base` but has no table, so the read arm's
398
+ # scope_for would raise TableNotSpecified where the ticking arm's
399
+ # resource_type match simply found nothing. An abstract class stores no
400
+ # rows, so no scoped grant can name it — deny, don't 500.
401
+ return false unless collection_type?(type)
402
+
403
+ if collection_read_action?(permission)
404
+ scope_for(subject: subject, model: type, permission: permission).exists?
405
+ else
406
+ ScopedRoleAssignment
407
+ .where(subject: subject, resource_type: type.base_class.name, role_id: roles_ticking(permission))
408
+ .exists?
409
+ end
410
+ end
411
+
412
+ # A usable collection type: an actual concrete ActiveRecord subclass —
413
+ # the one shape whose base_class/where the two record-less arms may call.
414
+ # The full rationale lives on the shape guard in record_less_scoped_grant?
415
+ # above; this predicate exists so the diagnostic labeler below refuses the
416
+ # exact same shapes the gate refused, and cannot drift from it.
417
+ def collection_type?(type)
418
+ type.is_a?(Class) && type < ActiveRecord::Base && !type.abstract_class?
419
+ end
420
+
421
+ # Would a (correct) current_scope_model declaration have given this
422
+ # record-less deny a chance? True only for the exact cell the #50
423
+ # fail-closed default created: a DECLARED collection action (record nil —
424
+ # the class form always carries its type, so it never lands here), no
425
+ # USABLE declared model — absent, or present but refused by the shape
426
+ # guard (a String, PORO, or abstract class) — not an SoD action (those
427
+ # are refused whatever the type), and a scoped grant EXPLICITLY ticking
428
+ # the key exists. Pure — reads only; decide labels the deny
429
+ # :model_undeclared / :model_invalid off this, changing no allow/deny.
430
+ #
431
+ # The role set follows the arm the deny came from (#65): a listed read
432
+ # matches a full_access-INCLUSIVE set, because a declared type would honor
433
+ # full_access there through scope_for — the label stays honest by saying
434
+ # so. Off the read list the set stays roles_ticking: naming a full_access
435
+ # grant :model_undeclared on #create would send the host to a fix that
436
+ # fixes nothing. Diagnostics-only either way — this labels a deny, it
437
+ # never decides (the scoped_grant_exists? shape, named in roles_granting's
438
+ # safety comment) — and it runs without a model, so it cannot check record
439
+ # liveness: a grant on a destroyed record makes "declare the model and
440
+ # this allows" false, which is why the nudge says "may fix", never
441
+ # promises.
442
+ def record_less_denied_for_unknown_type?(subject:, permission:, record:, model:)
443
+ return false unless record.nil? && !collection_type?(model)
444
+ return false if sod_action?(permission)
445
+
446
+ roles = collection_read_action?(permission) ? roles_granting(permission) : roles_ticking(permission)
447
+ ScopedRoleAssignment.where(subject: subject, role_id: roles).exists?
448
+ end
449
+
450
+ # An SoD action is record-targeted BY DEFINITION — "the subject who
451
+ # initiated a record can never approve THAT record". So a record-less SoD
452
+ # check is a contradiction in terms: there is no record for the veto to
453
+ # measure, and `sod_decision` returns :none for exactly that reason. Opening
454
+ # such a gate off a scoped grant would let a host that mis-gates a member
455
+ # SoD action (current_scope_record returning nil on `reports#approve` —
456
+ # precisely what warn_on_nil_sod_record exists to catch) hand out the action
457
+ # with the four-eyes veto silently skipped. The veto is a structural
458
+ # guarantee, so it must not rest on an opt-in dev warning; deny instead.
459
+ #
460
+ # This does NOT close the older org-grant asymmetry — a nil record on an SoD
461
+ # action still passes for an org-wide grant, which is characterized and
462
+ # pinned in test/sod_nil_record_test.rb. It only stops the record-less scoped
463
+ # branch from widening that hole to scoped grants too.
464
+ def sod_action?(permission)
465
+ CurrentScope.config.sod_actions.include?(permission.split("#").last)
466
+ end
467
+
468
+ # An action whose record-less gate derives from the scoped list (#65).
469
+ # Matched like sod_action?: the action segment of the key against a config
470
+ # list (config.collection_read_actions normalizes to strings on write).
471
+ def collection_read_action?(permission)
472
+ CurrentScope.config.collection_read_actions.include?(permission.split("#").last)
473
+ end
170
474
  end
171
475
  end
@@ -1,3 +1,3 @@
1
1
  module CurrentScope
2
- VERSION = "0.2.0"
2
+ VERSION = "0.3.0"
3
3
  end
data/lib/current_scope.rb CHANGED
@@ -9,12 +9,30 @@ require "current_scope/scopeable"
9
9
  require "current_scope/mutation_guard"
10
10
  require "current_scope/guard"
11
11
  require "current_scope/gating_tripwire"
12
+ require "current_scope/gating_reflection"
12
13
  require "current_scope/engine"
13
14
 
14
15
  module CurrentScope
15
16
  # Raised when the resolver denies an action gated by Guard (or when the
16
17
  # management UI is accessed without a full-access role). Carries an optional
17
- # machine-readable reason (:sod_veto, :no_grant, :impersonation_gate).
18
+ # machine-readable reason, surfaced on the response as X-Current-Scope-Reason
19
+ # by current_scope_denied:
20
+ #
21
+ # :sod_veto — the record's initiator can't perform an SoD action on it
22
+ # :no_grant — nothing granted the permission (the default deny)
23
+ # :model_undeclared — a record-less deny that a scoped grant would have
24
+ # opened, had the controller declared current_scope_model
25
+ # to bind it to a type (#50). Fail-closed, with the fix named.
26
+ # :model_invalid — its sibling: current_scope_model WAS declared but
27
+ # returned something the shape guard refuses (a String,
28
+ # an instance, an abstract class — not a concrete AR
29
+ # class). Same cell, different fix, so a different label.
30
+ # :impersonation_gate — a mutation while impersonating, which is read-only
31
+ # :not_full_access — the engine's management UI, which only full_access enters
32
+ #
33
+ # Every denial in the gem raises this and lands in current_scope_denied, so a
34
+ # denial cannot exist that forgets its reason. (:sod_bypassed is the one
35
+ # audited ALLOW, so it is set by the Guard rather than raised here.)
18
36
  class AccessDenied < StandardError
19
37
  attr_reader :reason
20
38
 
@@ -50,6 +68,14 @@ module CurrentScope
50
68
  @catalog = nil
51
69
  end
52
70
 
71
+ # The cross-controller nudge warns once per site (see below). That latch is
72
+ # per-process, so it must be clearable: a leaked one silently disarms the
73
+ # warning for every later test and makes the suite order-dependent. Also
74
+ # cleared on engine to_prepare, since a reload can change what's routed.
75
+ def reset_cross_controller_warnings!
76
+ @cross_controller_warned = nil
77
+ end
78
+
53
79
  # Models that opted into the scoped-role picker via CurrentScope::Scopeable.
54
80
  # Stored as class-name strings and resolved lazily so dev-mode reloading
55
81
  # never pins a stale constant. Rebuilt from scratch on every engine
@@ -74,12 +100,13 @@ module CurrentScope
74
100
  # `action` is either a full permission key ("admin/reports#approve") or a
75
101
  # bare action name resolved against `record`'s route key, falling back to
76
102
  # `controller_path`.
77
- def allowed?(action, subject:, record: nil, controller_path: nil, actor: nil)
103
+ def allowed?(action, subject:, record: nil, controller_path: nil, actor: nil, model: nil)
78
104
  resolver.allow?(
79
105
  subject: subject,
80
106
  permission: permission_key(action, record: record, controller_path: controller_path),
81
107
  record: record,
82
- actor: actor
108
+ actor: actor,
109
+ model: model
83
110
  )
84
111
  end
85
112
 
@@ -91,6 +118,24 @@ module CurrentScope
91
118
  resolver.scope_for(subject: subject, model: model, permission: permission)
92
119
  end
93
120
 
121
+ # THE human-label fallback chain, shared by the UI helpers
122
+ # (ApplicationHelper#current_scope_label) and the audit ledger
123
+ # (Event.label_for) — one definition, so a record can never render as
124
+ # "Apollo" on screen while being frozen into the ledger as "Project #7".
125
+ # Chain: the record's own current_scope_label (Scopeable provides one) →
126
+ # human identifiers (name/email/title) → "Model #id" → to_s. Returns nil
127
+ # for nil; callers choose their own nil presentation ("(none)" in views).
128
+ def label_for(record)
129
+ return if record.nil?
130
+ return record.current_scope_label if record.respond_to?(:current_scope_label)
131
+
132
+ name = record.try(:name).presence || record.try(:email).presence || record.try(:title).presence
133
+ return name if name
134
+ return "#{record.model_name.human} ##{record.id}" if record.respond_to?(:model_name)
135
+
136
+ record.to_s
137
+ end
138
+
94
139
  def permission_key(action, record: nil, controller_path: nil)
95
140
  action = action.to_s
96
141
  return action if action.include?("#")
@@ -102,6 +147,7 @@ module CurrentScope
102
147
  # Guard enforces, so prefer it: the view must agree with the gate.
103
148
  return "#{controller_path}##{action}" if controller_path&.split("/")&.last == route_key
104
149
 
150
+ warn_on_cross_controller_derivation(action, route_key, controller_path)
105
151
  return "#{route_key}##{action}"
106
152
  end
107
153
  return "#{controller_path}##{action}" if controller_path
@@ -111,6 +157,7 @@ module CurrentScope
111
157
  "a full \"controller#action\" string, or call from a controller/view"
112
158
  end
113
159
 
160
+
114
161
  # Impersonation boundary events. The impersonated identity is an EXPLICIT
115
162
  # argument (not read from the ambient pair): at act-as START the ambient
116
163
  # actor still equals the effective user — Current re-resolves next request —
@@ -139,14 +186,75 @@ module CurrentScope
139
186
  # same subject's org role to `role` rather than creating a duplicate (which
140
187
  # the one-role-per-subject uniqueness would reject anyway). Backs the
141
188
  # `current_scope:grant` rake task, so a fresh install doesn't need a console.
189
+ #
190
+ # Seeds the default Owner/Member roles ONLY on the default path — the name
191
+ # promises "assign a role", so a caller granting an explicit role must not
192
+ # get a full-access Owner row created in their roles table as a side effect.
142
193
  def grant!(subject, role: nil)
143
- seed_defaults!
144
- role ||= Role.find_by!(name: "Owner")
194
+ role ||= begin
195
+ seed_defaults!
196
+ Role.find_by!(name: "Owner")
197
+ end
145
198
  RoleAssignment.find_or_initialize_by(subject: subject).tap { |a| a.update!(role: role) }
146
199
  end
147
200
 
148
201
  private
149
202
 
203
+ # The documented namespaced/custom-named controller foot-gun (#41): the short
204
+ # form derived a DIFFERENT key than the gate on this controller enforces, so a
205
+ # view can show a link that 403s (or hide one that works). Silent, and the
206
+ # symptom appears nowhere near the cause.
207
+ #
208
+ # THIS SIGNAL IS AMBIGUOUS AND CANNOT BE MADE PRECISE. Two callers produce
209
+ # byte-identical inputs here:
210
+ #
211
+ # DashboardController renders Reports; allowed_to?(:show, report) is meant
212
+ # to mirror THIS controller's gate (dashboard#show) -> foot-gun.
213
+ # DocumentsController lists documents with links to reports;
214
+ # allowed_to?(:show, report) genuinely means reports#show -> correct.
215
+ #
216
+ # Both have a controller path that doesn't end in the record's route_key, and
217
+ # both route "{controller_path}##{action}". Nothing at the call site
218
+ # distinguishes intent. An earlier draft treated the catalog hit as proof of
219
+ # the foot-gun and warned "they disagree" — which is a false positive on every
220
+ # row of the second case. (#59/#61 review, cubic)
221
+ #
222
+ # So: warn ONCE per (controller_path, action, route_key), and say plainly that
223
+ # either reading may be right. One line per distinct site is a hint; one line
224
+ # per row is noise people learn to filter — and a diagnostic that cries wolf is
225
+ # worse than none, which is the whole thesis of this PR.
226
+ #
227
+ # ponytail: derivation is a hot path (every view helper call), so the flag is
228
+ # checked FIRST — off costs one boolean and never touches the catalog.
229
+ def warn_on_cross_controller_derivation(action, route_key, controller_path)
230
+ return unless config.warn_on_cross_controller_derivation
231
+ return if controller_path.nil? || controller_path.empty?
232
+ # A "log-only" diagnostic that raises isn't log-only. catalog reads
233
+ # Rails.application.routes, so a host that forces the flag on outside a
234
+ # booted Rails must get silence, not a NameError out of key derivation.
235
+ # (#61 review, qodo)
236
+ return unless defined?(Rails) && Rails.respond_to?(:application) && Rails.application
237
+
238
+ gate_key = "#{controller_path}##{action}"
239
+ return unless catalog.include?(gate_key)
240
+ return unless cross_controller_warning_unseen?(gate_key, route_key)
241
+
242
+ Rails.logger&.warn(
243
+ "[CurrentScope] allowed_to?(#{action.to_sym.inspect}, <#{route_key.singularize.camelize}>) on " \
244
+ "#{controller_path} derived \"#{route_key}##{action}\", but the gate here enforces " \
245
+ "\"#{gate_key}\". If you meant this controller's own gate, they disagree — pass the explicit " \
246
+ "key: allowed_to?(\"#{gate_key}\"). If you're asking about a different resource than this " \
247
+ "controller handles, the derived key is correct and this is expected. Warned once per site."
248
+ )
249
+ end
250
+
251
+ # ponytail: a plain Set, not a Mutex — worst case under a race is one extra
252
+ # line, and a flood is the thing being prevented. Dev/test only by default.
253
+ def cross_controller_warning_unseen?(gate_key, route_key)
254
+ @cross_controller_warned ||= Set.new
255
+ @cross_controller_warned.add?("#{gate_key}|#{route_key}") ? true : false
256
+ end
257
+
150
258
  # A2: the boundary events are the one place a host declares it is actually
151
259
  # impersonating. If actor_method is unset there, the entire act-as security
152
260
  # model is silently inert — so fail LOUD instead of recording an
@@ -27,7 +27,71 @@ module CurrentScope
27
27
  4. Manage roles at /current_scope (full-access subjects only).
28
28
 
29
29
  NEXT
30
+
31
+ say_retrofit_warning if existing_app?
30
32
  end
33
+
34
+ # An ABSOLUTE URL, not a repo-relative path. This prints in the HOST's
35
+ # terminal, in the HOST's app directory: "docs/guides/..." would resolve
36
+ # against their app, where it does not exist — and the gemspec ships only
37
+ # {app,config,db,lib} + README, so it is not in the installed gem either.
38
+ # Shipping docs/ wouldn't fix it (nobody reads docs out of a gem's install
39
+ # dir); a URL is the only form that resolves from where the reader is
40
+ # standing, and terminals make it clickable. (#64 review, qodo)
41
+ #
42
+ # Points at blob/main deliberately: the guide does not exist at the last
43
+ # release tag (it landed after v0.2.0), so a version-pinned URL would 404
44
+ # today. Revisit pinning to "blob/v#{VERSION}" at the next release, when a
45
+ # tag containing the guide exists. (#71 review, qodo)
46
+ GUIDE_PATH = "docs/guides/adopting-in-an-existing-app.md".freeze
47
+ GUIDE_URL = "https://github.com/davidteren/current_scope/blob/main/#{GUIDE_PATH}".freeze
48
+
49
+ private
50
+
51
+ # Adding a fail-closed gate to an app that already has controllers means
52
+ # every one of them starts denying: nothing is granted yet. That is the
53
+ # engine working, but it reads as "the gem broke my app" — and it lands
54
+ # AFTER step 2 above, when the suite is already red and the person is
55
+ # already reaching for git revert. Say it before that happens.
56
+ def say_retrofit_warning
57
+ say <<~RETROFIT, :yellow
58
+ Heads up — this app already has controllers.
59
+
60
+ Step 2 mounts a FAIL-CLOSED gate: anything not granted is denied. No
61
+ grants exist yet, so your controller specs will go red and your users
62
+ will get 403s. Nothing is misconfigured when that happens; it is the
63
+ gate doing its job on an app that hasn't been granted anything.
64
+
65
+ To retrofit incrementally instead, set this before step 2:
66
+
67
+ config.enforcement = :report # config/initializers/current_scope.rb
68
+
69
+ The gate then logs what it WOULD deny and lets it through. Run your
70
+ suite, then read the gaps back as a starter role grid:
71
+
72
+ bin/rails current_scope:report
73
+
74
+ Seed the roles it names, watch it empty out, then flip to :enforce.
75
+ One line back at any point.
76
+
77
+ The full retrofit guide — callback ordering vs. your authentication,
78
+ the Devise recipe, the skip_before_action fail-open trap, and a
79
+ rollout ladder:
80
+
81
+ #{GUIDE_URL}
82
+
83
+ RETROFIT
84
+ end
85
+
86
+ # ponytail: "does this app have controllers of its own" — the closed set
87
+ # is app/controllers/*.rb minus the one Rails generates for every app.
88
+ # A fresh `rails new` has only application_controller.rb, so it gets the
89
+ # clean install message; anything more means a real app is being
90
+ # retrofitted. Wrong guess costs a paragraph of advice, not correctness.
91
+ def existing_app?
92
+ controllers = Dir.glob(File.join(destination_root, "app/controllers/**/*_controller.rb"))
93
+ controllers.reject { |f| File.basename(f) == "application_controller.rb" }.any?
94
+ end
31
95
  end
32
96
  end
33
97
  end