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,669 @@
1
+ module CurrentScope
2
+ # The decision point. Every allow/deny question in the system routes through
3
+ # here, in a fixed order:
4
+ #
5
+ # 1. SoD veto — the record's initiator can never perform an SoD action
6
+ # on it. Reads two identities: the effective subject and
7
+ # (under sod_identity :either) the real actor behind an
8
+ # impersonated session. Overrides everything, incl. full_access.
9
+ # 2. full_access — the subject's org-wide role grants all permissions,
10
+ # present and future.
11
+ # 3. org-wide role — the role's permission set includes this permission.
12
+ # 4. scoped role — a role held on THIS record grants the permission; or,
13
+ # when the record's model declared current_scope_parent
14
+ # (#108, opt-in), a role held on one of its declared
15
+ # ancestors and EXPLICITLY ticking the key. full_access
16
+ # does not cascade up a chain — see scoped_grant?.
17
+ # 5. scoped role, — the target is record-less (nil for a collection action,
18
+ # record-less a Class for a class-form check), so no specific record
19
+ # can be named. An action in config.collection_read_actions
20
+ # asks scope_for — the id-narrowed list query — so the gate
21
+ # opens exactly when the list would show records
22
+ # (full_access included, #65); any other action needs a
23
+ # scoped grant EXPLICITLY ticking the key. scope_for then
24
+ # narrows the list to those records.
25
+ # 6. default-deny — nothing granted means denied.
26
+ class Resolver
27
+ INITIATOR_METHOD = :current_scope_initiator
28
+ # Host-defined per-record opt-in for break-glass. Absent ⇒ never bypassed
29
+ # (fail-closed, no raise — unlike a missing initiator, absence here is
30
+ # unambiguous).
31
+ BYPASS_METHOD = :current_scope_sod_bypassed?
32
+
33
+ # Public contract: boolean. `actor` is the REAL principal behind the
34
+ # request (defaults to the subject — no impersonation); it only widens the
35
+ # SoD veto under config.sod_identity == :either.
36
+ # `cascade:` (default true) controls ONLY the #108 ancestor arm of the
37
+ # record-bound scoped-grant check. `false` matches grants on THIS record
38
+ # alone; it does not change org-wide, full_access, or record-less behaviour,
39
+ # and it does not reach scope_for (the record-less read arm always cascades).
40
+ # Break-glass is its one caller — see sod_bypassed?.
41
+ def allow?(subject:, permission:, record: nil, actor: nil, model: nil, cascade: true)
42
+ decide(subject: subject, permission: permission, record: record, actor: actor, model: model,
43
+ cascade: cascade).first
44
+ end
45
+
46
+ # Internal decision: returns [allowed_bool, reason_or_nil]. The reason is a
47
+ # machine-readable cause the Guard surfaces: :sod_veto / :no_grant on a
48
+ # denial, and :sod_bypassed on the one AUDITED allow (break-glass). Ordinary
49
+ # allows carry nil. The resolver — shared across threads — holds no
50
+ # per-decision state; the reason rides in the return tuple, not on self.
51
+ #
52
+ # `model:` (#50) is the type the controller's current_scope_model hook
53
+ # declared for its collection actions, or nil when unknown. Only the
54
+ # record-less branch reads it, to bind that otherwise type-unbound grant
55
+ # check; every other branch ignores it. It stays a parameter, never state,
56
+ # per the purity rule above.
57
+ def decide(subject:, permission:, record: nil, actor: nil, model: nil, cascade: true)
58
+ return [ false, :no_grant ] if subject.nil?
59
+
60
+ case sod_decision(subject: subject, actor: actor, permission: permission, record: record)
61
+ when :veto then return [ false, :sod_veto ]
62
+ when :bypass then return [ true, :sod_bypassed ] # break-glass: privileged, audited override
63
+ end
64
+
65
+ role = org_role(subject)
66
+ return [ true, nil ] if role&.full_access?
67
+ return [ true, nil ] if role&.grants?(permission)
68
+ return [ true, nil ] if scoped_grant?(subject: subject, permission: permission, record: record,
69
+ cascade: cascade)
70
+ return [ true, nil ] if record_less_scoped_grant?(subject: subject, permission: permission, record: record, model: model)
71
+
72
+ # A LABEL, not a decision (R7a): this deny would have been an ALLOW had
73
+ # the controller declared current_scope_model and the grant ticked the
74
+ # key of that type. Without the distinct reason it is indistinguishable
75
+ # from an ordinary :no_grant, and the dev nudge that explains it is
76
+ # dev/test-only — so the production host who most needs the cause
77
+ # (403s after an upgrade) would be the one who cannot see it. Two labels
78
+ # for the two ways the declaration can be wrong: absent
79
+ # (:model_undeclared) vs present-but-unusable (:model_invalid — a
80
+ # String, PORO, or abstract class the shape guard refused). Same cell,
81
+ # different fix, so the label says which.
82
+ if record_less_denied_for_unknown_type?(subject: subject, permission: permission, record: record, model: model)
83
+ return [ false, model.nil? ? :model_undeclared : :model_invalid ]
84
+ end
85
+
86
+ [ false, :no_grant ]
87
+ end
88
+
89
+ # The subject's one org-wide role. Memoized per request (via Current) so the
90
+ # many gate checks a single request makes don't each re-query — the decision
91
+ # is identical, only the lookup is cached, keeping the resolver a pure
92
+ # decision function over its inputs.
93
+ def org_role(subject)
94
+ CurrentScope::Current.memoized_org_role(subject) do
95
+ RoleAssignment.find_by(subject: subject)&.role
96
+ end
97
+ end
98
+
99
+ def full_access?(subject)
100
+ !!(subject && org_role(subject)&.full_access?)
101
+ end
102
+
103
+ # The list-side complement to allow?: "which records of `model` may this
104
+ # subject act on?". Reads the SAME org + scoped grants the gate reads, so a
105
+ # host list can never drift from the per-record decision. Fail-closed (nil
106
+ # subject / no grant → none). Flat UNLESS the model declared
107
+ # current_scope_parent (#108), in which case the ancestor arm unions in the
108
+ # children of granted parents under roles_ticking. SoD does
109
+ # NOT apply: it vetoes record-targeted actions, not list membership.
110
+ #
111
+ # Since #65 the record-less gate asks this same query (.exists?) for
112
+ # actions in config.collection_read_actions, so gate and list cannot
113
+ # drift for listed reads — they are one query.
114
+ def scope_for(subject:, model:, permission:)
115
+ return model.none if subject.nil?
116
+
117
+ role = org_role(subject)
118
+ return model.all if role&.full_access? || role&.grants?(permission)
119
+
120
+ # Records on which the subject holds a scoped role that grants the key.
121
+ # Query the polymorphic base_class (what scoped grants store), not the
122
+ # passed model's name — otherwise scope_for(STISubclass) returns nothing
123
+ # while the per-record gate (also keyed on base_class) would allow it. The
124
+ # `model.where` still applies STI's own type predicate, so a subclass query
125
+ # can't over-list sibling-subclass rows. An empty id list yields an empty
126
+ # (still chainable) relation.
127
+ #
128
+ # NOTE scope_for is EAGER since #151: granted_ids runs its query when the
129
+ # relation is BUILT, not when it is enumerated, so the result is a snapshot
130
+ # of the grants at call time and the method needs a connection. The returned
131
+ # relation is still lazy and chainable.
132
+ direct = model.where(model.primary_key => granted_ids(
133
+ subject: subject, type: model.base_class.name, model: model,
134
+ roles: roles_granting(permission)
135
+ ))
136
+
137
+ ancestor_scope_for(subject: subject, model: model, permission: permission)
138
+ .reduce(direct) { |relation, arm| relation.or(arm) }
139
+ end
140
+
141
+ # Whether the separation-of-duties veto is in a position to decide about this
142
+ # pair at all: the action must be SoD-listed AND there must be an actual
143
+ # record instance to name an initiator from. The veto is defined in terms of
144
+ # who raised a record, so with no record it has nothing to measure and is
145
+ # skipped — that covers a collection action's nil, a class-form check like
146
+ # allowed_to?(:approve, Report), and equally a host hook that handed back
147
+ # something that isn't a record at all (`params[:id]`, a String).
148
+ #
149
+ # PUBLIC because "the veto did not run" and "the veto passed" are different
150
+ # facts, and a caller that acts on the difference must be able to ASK rather
151
+ # than infer it. It is not inferable from the decision: a skipped veto falls
152
+ # through to the ordinary grant check, so it surfaces as :no_grant or an
153
+ # ordinary allow — never as anything that says "SoD abstained". Report mode
154
+ # (#37) has to know, because :no_grant is the one reason it downgrades.
155
+ #
156
+ # This is the single definition of the condition, deliberately: sod_decision
157
+ # reads it too. Two copies of "did the veto run" is two copies that drift,
158
+ # and the copy that drifts is the one guarding the fraud control. (#59 review)
159
+ def sod_veto_applies?(permission:, record:)
160
+ sod_action?(permission) && record.respond_to?(:new_record?)
161
+ end
162
+
163
+ # The blind spot: an SoD-listed action whose veto could NOT run. The decision
164
+ # that comes back for one of these is :no_grant or an ordinary allow — the
165
+ # veto contributed nothing to it. Anyone treating that :no_grant as "SoD was
166
+ # fine with this" is reading a silence as an answer.
167
+ #
168
+ # The same hazard the record-less scoped branch already refuses (see
169
+ # sod_action? below) and that config.warn_on_nil_sod_record surfaces on the
170
+ # allow path: a host mis-gating a member SoD action. In :enforce this costs
171
+ # nothing — :no_grant is a 403 regardless, so the skipped veto never decides
172
+ # anything. Report mode is what makes it matter, because :no_grant is exactly
173
+ # what it downgrades. (#37, #59 review)
174
+ def sod_veto_skipped?(permission:, record:)
175
+ sod_action?(permission) && !sod_veto_applies?(permission: permission, record: record)
176
+ end
177
+
178
+ # The misconfiguration behind the ConfigurationError below: the veto IS in a
179
+ # position to decide about this pair, and the record cannot name an
180
+ # initiator. Every request to such an action raises — in :report mode as
181
+ # well as :enforce (#133).
182
+ #
183
+ # PUBLIC for the same reason sod_veto_skipped? is: report mode's diagnosis
184
+ # must be able to ASK what the cause was. The alternative is inspecting the
185
+ # raised exception's message, which is the pattern-match-the-error-string
186
+ # mistake Event.missing_events_table? already exists to prevent — and here
187
+ # it would decide whether a host is sent after their record hook or their
188
+ # model. This is the single definition; sod_decision reads it too, so the
189
+ # diagnosis and the raise cannot drift apart.
190
+ def sod_initiator_missing?(permission:, record:)
191
+ sod_veto_applies?(permission: permission, record: record) &&
192
+ !record.respond_to?(INITIATOR_METHOD, true)
193
+ end
194
+
195
+ # Does this subject hold a scoped grant that satisfies `permission` on ANY
196
+ # record? Read-only, and deliberately unfiltered by resource — that absence
197
+ # IS the question: the grant exists, but the gate never got a record for it
198
+ # to apply to.
199
+ #
200
+ # DIAGNOSTICS ONLY (#41). It answers a COUNTERFACTUAL — "had the controller
201
+ # declared its record hook, would a scoped grant have allowed this?" — and
202
+ # must never decide anything. The record it would need in order to BE a
203
+ # decision is exactly what's missing; that's the bug it reports.
204
+ #
205
+ # Reads roles_granting (full_access ∪ ticking), and that is right BECAUSE
206
+ # the counterfactual binds to a record: with a real record, scoped_grant?
207
+ # honors a scoped full_access role, so an honest "would it have been
208
+ # allowed?" must honor it too. The same reuse in a branch that binds to NO
209
+ # record was #49's P0 escalation — one scoped grant passing every
210
+ # collection gate in the app. The binding is the entire difference, which is
211
+ # why this is a question and not a gate.
212
+ def scoped_grant_exists?(subject:, permission:)
213
+ return false if subject.nil?
214
+
215
+ ScopedRoleAssignment.where(subject: subject, role_id: roles_granting(permission)).exists?
216
+ end
217
+
218
+ private
219
+
220
+ # Role ids that satisfy `permission`: full_access (grants everything) or an
221
+ # explicit grant of the key. The one place "does this role grant it?" is
222
+ # expressed for scoped grants. Including full_access is only safe while no
223
+ # caller turns an unbound match into a PERMIT. Four callers, each safe for
224
+ # its own reason: scoped_grant? binds `resource:` to the exact record;
225
+ # scope_for binds `resource_type:` and answers in record ids
226
+ # (granted_ids) for the caller to narrow, never a boolean permit
227
+ # — which is also why the record-less read gate (#65) is safe: it asks
228
+ # scope_for and takes .exists? of the id-narrowed relation, so its answer
229
+ # is still derived from the records the subject holds, not from the grant
230
+ # row alone; scoped_grant_exists? binds nothing and is therefore
231
+ # diagnostics-only — its own comment says why it must never decide;
232
+ # record_less_denied_for_unknown_type?'s listed-read arm (#65) is the same
233
+ # diagnostics-only shape — it labels a deny, it never decides. A new
234
+ # caller must fit one of those shapes or use roles_ticking. (#65 cites
235
+ # this comment as the safety condition; it is load-bearing, keep it true.)
236
+ def roles_granting(permission)
237
+ Role.where(full_access: true).or(Role.where(id: roles_ticking(permission)))
238
+ end
239
+
240
+ # Role ids that EXPLICITLY tick `permission` — full_access roles deliberately
241
+ # excluded, whether or not they also carry a RolePermission row.
242
+ #
243
+ # FOUR callers now, for two different reasons. The record-less branch
244
+ # (record_less_scoped_grant?, record_less_denied_for_unknown_type?) binds the
245
+ # grant to no record at all: honoring
246
+ # full_access there would mean one scoped full_access grant on one record
247
+ # ("Owner of Report #7") opened EVERY record-less gate in the host app —
248
+ # every #index and #create on every controller — since a full_access role
249
+ # satisfies every key. That is the `resource:` bound scoped_grant? applies
250
+ # and the record-less branch cannot.
251
+ #
252
+ # The #108 ancestor arms (ancestor_scoped_grant?, ancestor_scope_for) DO bind
253
+ # to specific ancestor records, so they are here for the second reason: a
254
+ # scoped full_access grant on a root record must not open every permission on
255
+ # every descendant. Same escalation as above, one hop removed.
256
+ #
257
+ # The `where.not` is load-bearing, not belt-and-braces: a role can be
258
+ # full_access AND retain explicit rows (tick grid cells, then flip the
259
+ # full-access toggle), and matching on the leftover row alone would walk it
260
+ # straight back through the branch full_access is barred from.
261
+ #
262
+ # roles_granting's set is unchanged by this exclusion — it unions
263
+ # full_access back in, so full_access ∪ (ticking − full_access) == the same
264
+ # roles it always matched.
265
+ def roles_ticking(permission)
266
+ RolePermission
267
+ .where(permission_key: permission)
268
+ .where.not(role_id: Role.where(full_access: true).select(:id))
269
+ .select(:role_id)
270
+ end
271
+
272
+ # The separation-of-duties outcome for this decision: :none (no conflict, or
273
+ # not an SoD action), :veto (the initiator is acting on their own record and
274
+ # the veto stands), or :bypass (a conflict exists but break-glass lifts it).
275
+ # Pure — reads only; the audit write for a :bypass happens at the Guard.
276
+ def sod_decision(subject:, actor:, permission:, record:)
277
+ return :none unless sod_veto_applies?(permission: permission, record: record)
278
+
279
+ # SoD is a structural guarantee — "cannot determine the initiator" must
280
+ # never mean "permit". A record type where SoD genuinely doesn't apply
281
+ # declares the hook returning nil.
282
+ if sod_initiator_missing?(permission: permission, record: record)
283
+ # The third fix, and the warning, exist because this message used to
284
+ # name only the first two — and a host who reached it by declaring a
285
+ # PARENT as current_scope_record (to make a scoped grant match, before
286
+ # #108 existed) would take fix 1 and silently blind the veto: it would
287
+ # then measure the parent's initiator, never the child's. That is the
288
+ # trap; naming it here is cheaper than diagnosing it later.
289
+ raise ConfigurationError,
290
+ "#{record.class.name}##{INITIATOR_METHOD} is not defined, but " \
291
+ "\"#{permission}\" is a separation-of-duties action (config.sod_actions). " \
292
+ "Define #{INITIATOR_METHOD} on #{record.class.name} (return nil to exempt " \
293
+ "a record), or remove \"#{permission.split('#').last}\" from config.sod_actions. " \
294
+ "If #{record.class.name} is standing in for its children so a scoped grant " \
295
+ "matches, declare current_scope_parent on the CHILD instead and hand the " \
296
+ "child back from current_scope_record — defining #{INITIATOR_METHOD} here " \
297
+ "would make the veto measure this record's initiator, not the child's."
298
+ end
299
+
300
+ initiator = record.send(INITIATOR_METHOD)
301
+ return :none if initiator.blank?
302
+
303
+ # The subject can never approve their own record. Under :either, neither
304
+ # can a real actor who initiated it while impersonating a different
305
+ # subject — impersonation must not become a self-approval loophole. Not
306
+ # impersonating (actor == subject) collapses both checks to the same test.
307
+ actor ||= subject
308
+ conflict = initiator == subject ||
309
+ (CurrentScope.config.sod_identity == :either && actor != subject && initiator == actor)
310
+ return :none unless conflict
311
+
312
+ sod_bypassed?(record: record, initiator: initiator) ? :bypass : :veto
313
+ end
314
+
315
+ # Break-glass: does an audited, privileged override lift the veto for this
316
+ # record? All three must hold, live: the config switch is on, the record's
317
+ # host hook opts in, and the INITIATOR (the identity the veto fired on —
318
+ # KTD-2, so impersonation can't launder it) holds the bypass permission.
319
+ def sod_bypassed?(record:, initiator:)
320
+ return false unless CurrentScope.config.allow_sod_bypass
321
+
322
+ # Re-entrancy is bounded ONLY because the bypass permission isn't itself an
323
+ # SoD action (KTD-5) — the inner allowed? below returns at the SoD step
324
+ # without recursing. Boot validate! is the primary gate (#40); this raise
325
+ # is defense in depth for runtime-mutated config (tests reassign live).
326
+ # The conflict test lives on Configuration so boot and decide cannot drift.
327
+ if CurrentScope.config.sod_bypass_permission_conflicts_with_sod_actions?
328
+ CurrentScope.config.validate!
329
+ end
330
+
331
+ # Absent hook ⇒ this type never breaks glass (fail-closed, no raise).
332
+ return false unless record.respond_to?(BYPASS_METHOD, true) && record.send(BYPASS_METHOD)
333
+
334
+ # cascade: false is load-bearing (#108). This re-enters the resolver, so
335
+ # WITHOUT it the ancestor arm would satisfy the bypass permission too, and
336
+ # a bypass_sod grant held on a PARENT would lift the four-eyes veto on
337
+ # every descendant — off a grant never held on the record being approved.
338
+ # The veto itself was never the leg that widened; its escape hatch was.
339
+ # Break-glass stays where it has always been: org-wide, full_access, or a
340
+ # grant on THIS record.
341
+ #
342
+ # This is airtight only because `record` here is always a concrete record —
343
+ # it is the record the veto is deciding about — so record_less_scoped_grant?
344
+ # returns immediately and its always-cascading scope_for path is never
345
+ # reached. If break-glass is ever routed through a record-less or collection
346
+ # shape, `cascade: false` will NOT protect it, because the record-less read
347
+ # arm has no cascade toggle. Re-check this line before doing that. (devin)
348
+ allow?(
349
+ subject: initiator,
350
+ permission: CurrentScope.permission_key(CurrentScope.config.sod_bypass_permission, record: record),
351
+ record: record,
352
+ cascade: false
353
+ )
354
+ end
355
+
356
+ # A scoped grant held on THIS record, or — when the model opted in with
357
+ # `current_scope_parent` (#108) — on one of its declared ancestors.
358
+ #
359
+ # The two arms deliberately query DIFFERENT role sets. The direct arm keeps
360
+ # roles_granting (full_access-inclusive), safe because `resource:` binds to
361
+ # one exact record, so a scoped full_access grant opens exactly that record.
362
+ # The ancestor arm uses roles_ticking, which EXCLUDES full_access: reusing
363
+ # roles_granting there would let one scoped full_access grant on a root
364
+ # record open every descendant of it, for every permission — #49's P0
365
+ # escalation with a multiplier. Cascading reach and cascading privilege are
366
+ # separate decisions; a host wanting blanket authority over a subtree ticks
367
+ # the keys. The cost is that privilege stops being monotonic (a full_access
368
+ # role reaches FEWER records through a chain than one that merely ticks the
369
+ # key), which is why the role editor's label names the carve-out.
370
+ # The #108 list arm, one relation per hop of the declared chain.
371
+ #
372
+ # This is NOT list cosmetics: record_less_scoped_grant?'s read arm takes
373
+ # .exists? of scope_for's relation, so for config.collection_read_actions
374
+ # this query IS the gate. It therefore uses roles_ticking, matching
375
+ # scoped_grant?'s ancestor arm — the direct arm above keeps roles_granting,
376
+ # so full_access still opens a directly-granted record's collection reads and
377
+ # still does not cascade to children.
378
+ #
379
+ # Every hop normalizes through base_class for the same reason the direct arm
380
+ # does (see the comment above it): grants store the polymorphic base_class,
381
+ # and querying a subclass name instead would make the list return nothing
382
+ # where the per-record gate allows.
383
+ def ancestor_scope_for(subject:, model:, permission:)
384
+ arms = []
385
+ klass = model
386
+ # Foreign keys walked so far, innermost last: [[Report, :project_id], ...]
387
+ path = []
388
+
389
+ while (reflection = ParentChain.reflection_for(klass))
390
+ parent = reflection.klass
391
+ path << [ klass, reflection.foreign_key ]
392
+ break if path.size > ParentChain::MAX_PARENT_DEPTH
393
+
394
+ granted = granted_ids(
395
+ subject: subject, type: parent.base_class.name, model: parent,
396
+ roles: roles_ticking(permission)
397
+ )
398
+
399
+ arms << narrow_through(path, innermost: parent.where(parent.primary_key => granted))
400
+ klass = parent
401
+ end
402
+
403
+ arms
404
+ end
405
+
406
+ # The granted resource ids, READ OUT rather than left as a subquery.
407
+ #
408
+ # `resource_id` is a string column (#151) so that a UUID key survives, but a
409
+ # model's own primary key is usually a bigint. Comparing the two inside SQL is
410
+ # where adapters part company: SQLite coerces silently, MySQL coerces at the
411
+ # cost of the index, and PostgreSQL refuses outright with
412
+ # `operator does not exist: bigint = character varying`. Handing ActiveRecord a
413
+ # plain array instead lets it cast each value to the target column's own type,
414
+ # which is correct on all three and needs no adapter branching.
415
+ #
416
+ # The cost is one small extra query: the set is the grants THIS subject holds
417
+ # on THIS type, not a row per record. bin/db runs the suite against all three
418
+ # adapters so a regression here cannot hide behind SQLite again.
419
+ #
420
+ # NON-CANONICAL IDS ARE DROPPED, and that filter is load-bearing. Handing the
421
+ # raw strings to `model.where(primary_key => ids)` lets ActiveRecord cast each
422
+ # one INTO the model's key type — and for a bigint key that cast is
423
+ # String#to_i, so a grant holding "7f00aaaa-…" would come back as 7 and open
424
+ # record 7, which it never named. Widening the column closed that collapse on
425
+ # the write path and re-opened it here; the round-trip check closes it again.
426
+ # Dropping (rather than matching) keeps the fail-closed direction: an id that
427
+ # names no record grants nothing.
428
+ def granted_ids(subject:, type:, model:, roles:)
429
+ ScopedRoleAssignment
430
+ .where(subject: subject, resource_type: type, role_id: roles)
431
+ .pluck(:resource_id)
432
+ .select { |id| CurrentScope.canonical_key?(model, id) }
433
+ end
434
+
435
+ # Build `model.where(fk1 => Parent.where(fk2 => ...))` from the outside in, so
436
+ # a grant N hops up still narrows to the right rows of `model`.
437
+ def narrow_through(path, innermost:)
438
+ # The reduce already ENDS on `model.where(...)`, because the first path
439
+ # entry pushed by ancestor_scope_for is always [model, its own fk] and
440
+ # reversing puts it last. Wrapping the result in model.where(id: ...)
441
+ # again would add a redundant self-join to every arm at every depth.
442
+ path.reverse.reduce(innermost) do |inner, (klass, foreign_key)|
443
+ klass.where(foreign_key => inner.select(inner.klass.primary_key))
444
+ end
445
+ end
446
+
447
+ def scoped_grant?(subject:, permission:, record:, cascade: true)
448
+ # `record` may be a class (allowed_to?(:create, Report)) — classes can't
449
+ # hold scoped grants, only persisted records can.
450
+ return false unless record.respond_to?(:new_record?) && record.persisted?
451
+
452
+ return true if ScopedRoleAssignment
453
+ .where(subject: subject, resource: record, role_id: roles_granting(permission))
454
+ .exists?
455
+
456
+ return false unless cascade
457
+
458
+ ancestor_scoped_grant?(subject: subject, permission: permission, record: record)
459
+ end
460
+
461
+ # The #108 fallback. Empty ancestors (no declaration, the default) means one
462
+ # extra no-op call and the same answer as before.
463
+ def ancestor_scoped_grant?(subject:, permission:, record:)
464
+ ancestors = ParentChain.ancestors_for(record)
465
+ return false if ancestors.empty?
466
+
467
+ ScopedRoleAssignment
468
+ .where(subject: subject, resource: ancestors, role_id: roles_ticking(permission))
469
+ .exists?
470
+ end
471
+
472
+ # A record-less target is allowed in one of two ways (#19, #65):
473
+ #
474
+ # - an action in config.collection_read_actions asks scope_for — the
475
+ # id-narrowed query the list renders from — so the gate opens exactly
476
+ # when the subject's list would show records, full_access included.
477
+ # Gate and list agree by construction; an empty list (no grant, or a
478
+ # grant whose record was destroyed) is a deny, fail-closed.
479
+ # - any other action needs a scoped grant whose role EXPLICITLY ticks
480
+ # the key. scope_for then narrows the collection to the granted records.
481
+ #
482
+ # Without this, the two halves of the per-record feature contradict each
483
+ # other: the gate turns a scoped-only subject away from their index, and
484
+ # the org-wide grant that gets them past it makes scope_for return every
485
+ # record.
486
+ #
487
+ # "Record-less" is a CLOSED set of exactly two shapes, tested positively:
488
+ #
489
+ # nil — a collection action; the Guard's hook returns nil when there is
490
+ # no record (guard.rb), which is the documented contract.
491
+ # a Class — the class form, allowed_to?(:index, Report).
492
+ #
493
+ # Positive, never `unless record.respond_to?(:new_record?)`: a negative test
494
+ # admits an OPEN set, so a host whose current_scope_record wrongly returns
495
+ # params[:id] (a String) or any other non-record would land here and be
496
+ # ALLOWED on the strength of a grant held over some *other* record — a
497
+ # fail-open in a fail-closed engine, and a breach of this branch's own
498
+ # invariant that a grant on X must not act on Y. Anything that is not
499
+ # literally nil-or-a-Class is not a record-less target and gets no say here.
500
+ #
501
+ # Consequence, deliberate: an UNPERSISTED instance (Report.new) is not
502
+ # record-less by this test, and scoped_grant? needs persisted? — so it is
503
+ # denied, while the class form is allowed. That asymmetry is only reachable
504
+ # by gating a collection action with Model.new instead of the documented nil,
505
+ # and it fails CLOSED (a 403 the host sees immediately), which is the safe
506
+ # direction to be wrong in.
507
+ #
508
+ # Binds by TYPE (#50). The target names no record, but the controller can
509
+ # name the type its collection lists via current_scope_model (record when
510
+ # it is a Class — the allowed_to?(:index, Report) form — else the model:
511
+ # kwarg the Guard threads). resource_type filters the grant to that type,
512
+ # normalized through base_class exactly as scope_for does, so "a Report
513
+ # grant" cannot open a Documents gate. UNKNOWN type ⇒ this branch does not
514
+ # fire (fail-closed): before #50 an unbound grant of any type opened every
515
+ # record-less gate, and for a key with no list side (#create) that let a
516
+ # Report-scoped subject create Documents — a live escalation, not a
517
+ # cosmetic empty list. A loud, documented deny is the safe direction.
518
+ #
519
+ # full_access and the two arms (#65): the read arm honors a scoped
520
+ # full_access grant because scope_for's answer is DERIVED FROM RECORD IDS —
521
+ # which records the subject holds, joined against the live table — the one
522
+ # shape roles_granting's safety condition names as safe. The non-read arm
523
+ # answers with a boolean off a type-bound match, strictly weaker (the id is
524
+ # discarded), so full_access stays barred there: one scoped grant on one
525
+ # Report must not create Reports. "Owner of Report #7" means full access to
526
+ # Report #7 — and, since #65, to the lists that would show it. (#65 records
527
+ # in full why a type-bound boolean over roles_granting is never the answer;
528
+ # 029's R4 was withdrawn over exactly that.)
529
+ #
530
+ # TRUST NOTE: the declared type is trusted the way current_scope_record is.
531
+ # A wrong current_scope_model plus a scoped full_access grant of the
532
+ # declared type opens that controller's listed reads — review the
533
+ # declaration like the record hook. Before #65 that misconfiguration
534
+ # failed closed; the trade is deliberate and documented (README, #65 plan
535
+ # KTD-5).
536
+ #
537
+ # Deliberately NOT memoized, unlike org_role. The memo there caches one
538
+ # lookup keyed by subject, invalidated by RoleAssignment writes alone. This
539
+ # predicate's answer derives from three tables (scoped assignments, roles,
540
+ # role permissions) — four on the read arm, whose scope_for joins the model
541
+ # table itself — so a memo would need invalidation hooks on all of them,
542
+ # and a stale entry here is a stale ALLOW. Only record-less checks by
543
+ # scoped-only subjects reach this line (org and full_access short-circuit
544
+ # above), so the cost is one query — on the read arm an id-subquery EXISTS
545
+ # against the model table — on a handful of nav-level checks per page, not
546
+ # the per-row gate. Revisit if that ever shows up in a profile.
547
+ def record_less_scoped_grant?(subject:, permission:, record:, model: nil)
548
+ # R3a: the record-less shape test stays FIRST — a declared model never
549
+ # rescues a non-record-less target. A host whose current_scope_record
550
+ # wrongly returns params[:id] (a String) must still fail closed here, not
551
+ # be allowed off a grant held over some other record.
552
+ return false unless record.nil? || record.is_a?(Class)
553
+ return false if sod_action?(permission)
554
+
555
+ # The class form (allowed_to?(:index, Report)) carries the type as the
556
+ # record; otherwise it comes from the model: hook. Unknown ⇒ deny (R3).
557
+ type = record.is_a?(Class) ? record : model
558
+ return false if type.nil?
559
+
560
+ # Shape guard, mirroring R3a's on the record: a type that is not an
561
+ # ActiveRecord model class (a String from a mis-declared
562
+ # current_scope_model, a non-record-storing PORO passed to the class form
563
+ # — the gem's own Scopeable Gadget is one) cannot name a base_class to
564
+ # normalize by. Require an actual AR subclass, not just anything answering
565
+ # base_class — a class whose base_class returns nil would still crash on
566
+ # .name. Deny (fail-closed) rather than raise NoMethodError: a garbage
567
+ # type is not a grant, and denying also preserves the pre-#50 boolean the
568
+ # class form returned for a non-AR argument. This guard must stay ABOVE
569
+ # the read arm — scope_for calls type.base_class and type.where, so a
570
+ # non-AR type would 500 instead of denying. (#50 review, #65)
571
+ #
572
+ # ABSTRACT classes are excluded too (#65 review): ApplicationRecord
573
+ # passes `< ActiveRecord::Base` but has no table, so the read arm's
574
+ # scope_for would raise TableNotSpecified where the ticking arm's
575
+ # resource_type match simply found nothing. An abstract class stores no
576
+ # rows, so no scoped grant can name it — deny, don't 500.
577
+ return false unless collection_type?(type)
578
+
579
+ # A collection gate binds grants to a single-value key. A composite or absent
580
+ # primary key holds no canonical grant, and `where(pk => ids)` with an array
581
+ # pk would 500 — deny, fail-closed, before either arm builds a relation.
582
+ return false unless CurrentScope.storable_key?(type.base_class)
583
+
584
+ if collection_read_action?(permission)
585
+ scope_for(subject: subject, model: type, permission: permission).exists?
586
+ else
587
+ # A syntactically valid stored id is not enough: a destroyed/default-
588
+ # scoped-out record opens nothing. Keep the established R6a STI ceiling:
589
+ # non-read collection gates bind to the base class, not one subclass.
590
+ ids = granted_ids(subject: subject, type: type.base_class.name, model: type,
591
+ roles: roles_ticking(permission))
592
+ type.base_class.where(type.base_class.primary_key => ids).exists?
593
+ end
594
+ end
595
+
596
+ # A usable collection type: an actual concrete ActiveRecord subclass —
597
+ # the one shape whose base_class/where the two record-less arms may call.
598
+ # The full rationale lives on the shape guard in record_less_scoped_grant?
599
+ # above; this predicate exists so the diagnostic labeler below refuses the
600
+ # exact same shapes the gate refused, and cannot drift from it.
601
+ #
602
+ def collection_type?(type)
603
+ type.is_a?(Class) && type < ActiveRecord::Base && !type.abstract_class?
604
+ end
605
+ # PUBLIC since #133, for that same reason one hop further out: SodPreflight
606
+ # asks the identical question of a declared current_scope_model, and an
607
+ # inline copy of the three terms is precisely the re-derivation #74 keeps
608
+ # charging this codebase for. One definition, three consumers.
609
+ #
610
+ # Spelled as a standalone `public :`, matching Event.missing_events_table? —
611
+ # this repo's one other "expose a single method out of the private section"
612
+ # case — so there is one idiom for it rather than two.
613
+ public :collection_type?
614
+
615
+ # Would a (correct) current_scope_model declaration have given this
616
+ # record-less deny a chance? True only for the exact cell the #50
617
+ # fail-closed default created: a DECLARED collection action (record nil —
618
+ # the class form always carries its type, so it never lands here), no
619
+ # USABLE declared model — absent, or present but refused by the shape
620
+ # guard (a String, PORO, or abstract class) — not an SoD action (those
621
+ # are refused whatever the type), and a scoped grant EXPLICITLY ticking
622
+ # the key exists. Pure — reads only; decide labels the deny
623
+ # :model_undeclared / :model_invalid off this, changing no allow/deny.
624
+ #
625
+ # The role set follows the arm the deny came from (#65): a listed read
626
+ # matches a full_access-INCLUSIVE set, because a declared type would honor
627
+ # full_access there through scope_for — the label stays honest by saying
628
+ # so. Off the read list the set stays roles_ticking: naming a full_access
629
+ # grant :model_undeclared on #create would send the host to a fix that
630
+ # fixes nothing. Diagnostics-only either way — this labels a deny, it
631
+ # never decides (the scoped_grant_exists? shape, named in roles_granting's
632
+ # safety comment) — and it runs without a model, so it cannot check record
633
+ # liveness: a grant on a destroyed record makes "declare the model and
634
+ # this allows" false, which is why the nudge says "may fix", never
635
+ # promises.
636
+ def record_less_denied_for_unknown_type?(subject:, permission:, record:, model:)
637
+ return false unless record.nil? && !collection_type?(model)
638
+ return false if sod_action?(permission)
639
+
640
+ roles = collection_read_action?(permission) ? roles_granting(permission) : roles_ticking(permission)
641
+ ScopedRoleAssignment.where(subject: subject, role_id: roles).exists?
642
+ end
643
+
644
+ # An SoD action is record-targeted BY DEFINITION — "the subject who
645
+ # initiated a record can never approve THAT record". So a record-less SoD
646
+ # check is a contradiction in terms: there is no record for the veto to
647
+ # measure, and `sod_decision` returns :none for exactly that reason. Opening
648
+ # such a gate off a scoped grant would let a host that mis-gates a member
649
+ # SoD action (current_scope_record returning nil on `reports#approve` —
650
+ # precisely what warn_on_nil_sod_record exists to catch) hand out the action
651
+ # with the four-eyes veto silently skipped. The veto is a structural
652
+ # guarantee, so it must not rest on an opt-in dev warning; deny instead.
653
+ #
654
+ # This does NOT close the older org-grant asymmetry — a nil record on an SoD
655
+ # action still passes for an org-wide grant, which is characterized and
656
+ # pinned in test/sod_nil_record_test.rb. It only stops the record-less scoped
657
+ # branch from widening that hole to scoped grants too.
658
+ def sod_action?(permission)
659
+ CurrentScope.config.sod_actions.include?(permission.split("#").last)
660
+ end
661
+
662
+ # An action whose record-less gate derives from the scoped list (#65).
663
+ # Matched like sod_action?: the action segment of the key against a config
664
+ # list (config.collection_read_actions normalizes to strings on write).
665
+ def collection_read_action?(permission)
666
+ CurrentScope.config.collection_read_actions.include?(permission.split("#").last)
667
+ end
668
+ end
669
+ end