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,90 @@
1
+ module CurrentScope
2
+ # The read-only-while-impersonating gate. A SEPARATE before_action from the
3
+ # permission check, on purpose: the engine's management UI does
4
+ # skip_before_action :current_scope_check!, and a gate folded into that check
5
+ # would exempt the highest-value surface (grid/role/grant mutations). Guard
6
+ # includes this so the gate runs BEFORE the permission check; the engine
7
+ # ApplicationController includes it directly so skipping the permission check
8
+ # never skips the gate.
9
+ #
10
+ # An impersonated session is read-only by default: any non-GET/HEAD request is
11
+ # denied while a real actor stands behind a different effective subject. The
12
+ # endpoints that MUST run while impersonating — a host's stop-impersonation,
13
+ # sign-out, and sign-in actions — opt out with:
14
+ #
15
+ # skip_before_action :current_scope_mutation_guard!
16
+ #
17
+ # (Method spoofing is not a concern: Rack::MethodOverride only upgrades POST,
18
+ # and mutating actions are verb-pinned by routing.)
19
+ module MutationGuard
20
+ extend ActiveSupport::Concern
21
+
22
+ included do
23
+ before_action :current_scope_mutation_guard!
24
+ rescue_from CurrentScope::AccessDenied, with: :current_scope_denied
25
+ end
26
+
27
+ private
28
+
29
+ def current_scope_mutation_guard!
30
+ return if request.get? || request.head?
31
+ return if CurrentScope.config.allow_mutations_while_impersonating
32
+ return unless current_scope_impersonating?
33
+
34
+ key = "#{controller_path}##{action_name}"
35
+ raise CurrentScope::AccessDenied.new(
36
+ key,
37
+ reason: :impersonation_gate,
38
+ permission: key,
39
+ subject: CurrentScope::Current.user
40
+ )
41
+ end
42
+
43
+ # A real actor stands behind a distinct effective subject (act-as).
44
+ # Delegates to the one definition on Current (not the Permissions mixin,
45
+ # which isn't mixed in everywhere this gate runs) so the gate and the
46
+ # view-level read-only signal share a single authoritative predicate.
47
+ def current_scope_impersonating?
48
+ CurrentScope::Current.impersonating?
49
+ end
50
+
51
+ # Denials render forbidden and surface the machine-readable reason on the
52
+ # response so refusals are diagnosable by clients and tests.
53
+ #
54
+ # The ONLY place the reason header is written. Every denial in the gem —
55
+ # the Guard's, this gate's, and the engine's own front door — raises
56
+ # AccessDenied and lands here, so a denial cannot exist that forgets the
57
+ # header. (The engine's front door used to render its own `head :forbidden`
58
+ # and was exactly that denial: no header, no body. #23.)
59
+ def current_scope_denied(exception = nil)
60
+ reason = exception.respond_to?(:reason) ? exception.reason : nil
61
+ response.headers["X-Current-Scope-Reason"] = reason.to_s if reason
62
+ # Mirror the header into the server log so log-based triage can distinguish
63
+ # no_grant from sod_veto without reading response headers (#39).
64
+ permission = exception.respond_to?(:permission) ? exception.permission : nil
65
+ Rails.logger&.info(
66
+ "[CurrentScope] denied #{permission || '(unknown)'} (#{reason || 'unknown'}) → 403"
67
+ )
68
+ current_scope_render_denied(reason)
69
+ end
70
+
71
+ # How a denial renders — the one part a controller may vary. Default: a
72
+ # bodyless 403, which is what a host app's denial has always been and must
73
+ # stay. This runs inside HOST controllers (Guard mixes it in), so rendering
74
+ # a body here would push an engine-shaped response into an app that never
75
+ # asked for one, with no layout or view to render it in. The engine's own
76
+ # controller overrides this to explain itself; nothing else should.
77
+ #
78
+ # `current_scope_`-prefixed like every other method this concern mixes into
79
+ # a host controller (current_scope_check!, current_scope_denied,
80
+ # current_scope_mutation_guard!, current_scope_record). A bare
81
+ # `render_access_denied` is a name a host app could plausibly already have,
82
+ # and we would silently call theirs.
83
+ #
84
+ # Takes the reason: an overrider that renders a body needs to know WHICH
85
+ # denial it is answering, or it will explain the wrong one.
86
+ def current_scope_render_denied(_reason = nil)
87
+ head :forbidden
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,397 @@
1
+ require "set"
2
+
3
+ module CurrentScope
4
+ # The ONE answer to "what are this record's declared ancestors?" (#108).
5
+ #
6
+ # A model opts in with `current_scope_parent :project`. The resolver, failing a
7
+ # direct scoped-grant match, walks that chain and matches grants against the
8
+ # ancestors instead. Flat stays the default: a model that declares nothing gets
9
+ # an empty chain and a byte-identical decision.
10
+ #
11
+ # WHY A MACRO, when every other host hook (current_scope_record,
12
+ # current_scope_model, current_scope_initiator, current_scope_sod_bypassed?) is
13
+ # a plain method: those answer a per-INSTANCE question, so returning a value is
14
+ # enough. This one must also produce a QUERYABLE key — Resolver#scope_for builds
15
+ # `where(<foreign_key> => granted_ancestor_ids)`, and a method handing back a
16
+ # parent instance cannot yield a foreign key without loading every candidate
17
+ # row. Naming an association gives both derivations (the walk and the query)
18
+ # from one declaration, so the gate and the list cannot drift. The departure is
19
+ # deliberate and documented in the README rather than left to surprise a reader.
20
+ #
21
+ # DECLARATION ERRORS RAISE. DATA NEVER DOES. That split is load-bearing and was
22
+ # learned the hard way in review: the first cut raised ConfigurationError when a
23
+ # record's chain was too deep or looped, which meant two UPDATEs on a parent_id
24
+ # column — no code change at all — could 500 a live request, escape report
25
+ # mode's "never breaks a request" promise, and print a fix ("remove one of the
26
+ # current_scope_parent declarations") pointing at code that was correct. So:
27
+ # a bad DECLARATION raises when the host writes it (at the macro for most
28
+ # shapes; from validate_declarations! after load for a custom association
29
+ # primary key, which needs reflection.klass); bad or over-deep DATA truncates
30
+ # the walk, denies (fail-closed), and warns once.
31
+ module ParentChain
32
+ # A private ceiling, not a config knob: nobody can pick a default for a knob
33
+ # before a host declares a chain deep enough to need it. Raise it when one
34
+ # asks.
35
+ #
36
+ # Truncating past it is FAIL-CLOSED — fewer ancestors means fewer grants
37
+ # match, so the answer is a denial, never an escalation. It is also the only
38
+ # option that keeps the gate and the list agreeing: Resolver#ancestor_scope_for
39
+ # walks CLASSES, and a legitimate self-referential chain (Project belongs_to
40
+ # :parent, class_name: "Project") never terminates at the class level however
41
+ # shallow the data is. A ceiling that raised on one side and truncated on the
42
+ # other is exactly the divergence this constant exists to prevent.
43
+ MAX_PARENT_DEPTH = 5
44
+
45
+ # Installed on ActiveRecord::Base via ActiveSupport.on_load — the standard
46
+ # engine idiom for an acts_as_*-style declaration. Deliberately NOT hung off
47
+ # CurrentScope::Scopeable, whose contract is "BROWSE-ONLY — it does NOT gate
48
+ # access" (scopeable.rb:3) and whose `included` hook would also register the
49
+ # model in the scoped-role picker as a side effect of declaring a parent.
50
+ module Declaration
51
+ def current_scope_parent(association_name)
52
+ reflection = reflect_on_association(association_name)
53
+
54
+ if reflection.nil?
55
+ raise ConfigurationError,
56
+ "#{name}.current_scope_parent(#{association_name.inspect}) names an " \
57
+ "association that does not exist. Declare `belongs_to " \
58
+ "#{association_name.inspect}` first, or name the association that " \
59
+ "reaches the record scoped grants are held on."
60
+ end
61
+
62
+ unless reflection.belongs_to?
63
+ raise ConfigurationError,
64
+ "#{name}.current_scope_parent(#{association_name.inspect}) must name a " \
65
+ "belongs_to association — #{association_name.inspect} is a " \
66
+ "#{reflection.macro}. A scoped grant is held on ONE parent record, so " \
67
+ "the chain walks upward one owner at a time. Declare it on the CHILD " \
68
+ "instead — `current_scope_parent` in #{reflection.klass.name} — so a " \
69
+ "grant held here reaches its #{association_name}."
70
+ end
71
+
72
+ if reflection.polymorphic?
73
+ raise ConfigurationError,
74
+ "#{name}.current_scope_parent(#{association_name.inspect}) names a " \
75
+ "polymorphic belongs_to, which is not supported: the parent's class is " \
76
+ "not knowable without loading every candidate row, so scope_for could " \
77
+ "not build its query. Either name a concrete belongs_to if this model " \
78
+ "has one, or keep this model flat and grant on it directly."
79
+ end
80
+
81
+ # The walk loads the parent THROUGH the association, so a scope on it is
82
+ # applied; ancestor_scope_for rebuilds the join from the foreign key
83
+ # alone, so the scope never reaches that SQL. The two then disagree in
84
+ # the fail-OPEN direction — the gate denies while the collection-read
85
+ # gate (which is scope_for(...).exists?) allows and the list renders the
86
+ # rows. Refusing the shape is total; supporting it would mean building
87
+ # the arm with joins(reflection.name) so one definition feeds both.
88
+ if reflection.scope
89
+ raise ConfigurationError,
90
+ "#{name}.current_scope_parent(#{association_name.inspect}) names a SCOPED " \
91
+ "belongs_to. The per-record walk would apply that scope and the collection " \
92
+ "query would not, so the gate and the list would disagree about the same " \
93
+ "record. Declare an unscoped belongs_to for the chain."
94
+ end
95
+
96
+ # Resolver#scope_for is handed the COLLECTION type a controller names,
97
+ # which for STI is the base class, while the per-record gate reads the
98
+ # declaration off the instance's own class. A chain declared on a
99
+ # subclass would therefore be walked by the gate and invisible to the
100
+ # list — the two disagreeing about the same record. Refusing the shape
101
+ # removes that entire class of drift instead of documenting it.
102
+ if self != base_class
103
+ raise ConfigurationError,
104
+ "#{name}.current_scope_parent(#{association_name.inspect}) is declared on " \
105
+ "an STI subclass. Declare it on #{base_class.name} instead: scoped grants " \
106
+ "store the base class, and the collection query is built from it, so a " \
107
+ "subclass-only chain would open records the list could never show."
108
+ end
109
+
110
+ self.current_scope_parent_association = association_name.to_sym
111
+ CurrentScope::ParentChain.register(self)
112
+ end
113
+
114
+ def current_scope_parent_declared?
115
+ !current_scope_parent_association.nil?
116
+ end
117
+ end
118
+
119
+ class << self
120
+ # Declared classes, by NAME so dev-mode reloading never pins a stale
121
+ # constant (same reason CurrentScope::Scopeable stores strings).
122
+ def register(klass)
123
+ declared_names << klass.name if klass.name
124
+ end
125
+
126
+ def declared_names
127
+ @declared_names ||= Set.new
128
+ end
129
+
130
+ # Run once at boot (and on reload) rather than on the request path.
131
+ # validate_key! needs reflection.klass, which cannot resolve inside the
132
+ # macro without breaking an ordinary forward reference between two models
133
+ # that name each other — so the check has to happen later. Later must not
134
+ # mean "on the first gated request", because that is a deploy that boots
135
+ # green and 500s on real traffic. (cubic P2, ie-predictability P1)
136
+ #
137
+ # A single call sees only the classes loaded so far — in EVERY environment,
138
+ # not just development. That is why the engine calls it twice; see below.
139
+ #
140
+ # WORKS IN WAVES, and both halves of that are load-bearing.
141
+ #
142
+ # It cannot iterate the Set directly: validate_key! resolves
143
+ # reflection.klass, which AUTOLOADS the parent model, and a parent that
144
+ # declares a chain of its own registers itself from its class body —
145
+ # mutating the very Set being iterated. Ruby answers that with "can't add
146
+ # a new key into hash during iteration", a RuntimeError out of to_prepare
147
+ # for any host whose declared chain points at another declaring model. The
148
+ # dummy's Report -> Project is exactly that shape.
149
+ #
150
+ # It cannot iterate ONE snapshot either: a model that registers mid-pass
151
+ # would then wait for a later call of this method. Within one call there
152
+ # is no second chance, so it keeps taking snapshots until no new name
153
+ # appears — the walk is what loads those models, so it is also what must
154
+ # finish checking them. Terminates because the registry is finite and
155
+ # `validated` only grows. (#133 review — cubic)
156
+ #
157
+ # CALLED TWICE, on purpose (#139). From to_prepare, which railties runs
158
+ # BEFORE :eager_load! ("This needs to happen before eager load so it
159
+ # happens in exactly the same point regardless of config.eager_load") — so
160
+ # that pass sees only what was already loaded, and earns its keep in
161
+ # development by re-running on every reload. And from after_initialize
162
+ # where eager loading is on, which is the authoritative pass: every
163
+ # declaring model that was eager-loaded (and thus registered) is
164
+ # validated. Models outside eager_load_paths or marked do_not_eager_load
165
+ # still register only when first loaded, and nothing on the request path
166
+ # re-checks them. Idempotent, so running twice costs a second walk over a
167
+ # set that raises on the first bad entry either way.
168
+ def validate_declarations!
169
+ validated = Set.new
170
+
171
+ while (pending = declared_names.to_a.reject { |name| validated.include?(name) }).any?
172
+ pending.each do |name|
173
+ validated << name
174
+ klass = name.safe_constantize
175
+ next if klass.nil?
176
+
177
+ reflection = klass.reflect_on_association(klass.current_scope_parent_association)
178
+ validate_key!(klass, reflection) if reflection
179
+ end
180
+ end
181
+ end
182
+
183
+ # The ancestors a scoped grant may be matched against, nearest parent
184
+ # first, root last. Empty for an unopted model, a class, or an unsaved
185
+ # record — all three are "nothing to walk", not an error.
186
+ def ancestors_for(record)
187
+ return [] unless record.respond_to?(:new_record?) && record.persisted?
188
+
189
+ unless declared?(record.class)
190
+ reject_method_form!(record.class)
191
+ return []
192
+ end
193
+
194
+ walk(record)
195
+ end
196
+
197
+ # Whether this class (or an STI ancestor it inherits from) opted in.
198
+ # `klass < ActiveRecord::Base` rather than a bare respond_to? duck-type:
199
+ # the macro is installed on ActiveRecord::Base, so anything else answering
200
+ # this trio is a coincidence, and honouring it would let a non-AR class
201
+ # steer grant matching. Mirrors the record-less branch's collection_type?
202
+ # guard.
203
+ def declared?(klass)
204
+ klass.is_a?(Class) && klass < ActiveRecord::Base &&
205
+ klass.respond_to?(:current_scope_parent_declared?) &&
206
+ klass.current_scope_parent_declared?
207
+ end
208
+
209
+ # The declared association reflection, or nil. scope_for reads this for the
210
+ # foreign key; it must never re-derive the name itself.
211
+ # Resolved from base_class, ALWAYS. The declaration is refused on an STI
212
+ # subclass, but a subclass can still OVERRIDE the association it inherits
213
+ # (a different class_name or foreign key). The per-record gate reads the
214
+ # instance's class and the collection query reads the base, so resolving
215
+ # per-class would let those two walk different associations for the same
216
+ # record — the drift the STI refusal exists to prevent, re-entering by the
217
+ # back door. One declared base reflection feeds both. (cubic P1)
218
+ def reflection_for(klass)
219
+ unless declared?(klass)
220
+ # Both paths funnel through here, so this is where the method-form
221
+ # mistake has to be caught. Checking it only in ancestors_for meant the
222
+ # member gate raised while scope_for silently omitted parent grants —
223
+ # the collection answering :no_grant with no diagnosis. (qodo 3)
224
+ reject_method_form!(klass)
225
+ return nil
226
+ end
227
+
228
+ base = klass.base_class
229
+ base.reflect_on_association(base.current_scope_parent_association)
230
+ end
231
+
232
+ # Reset between reloads/tests so a truncation warning is not latched by a
233
+ # run that has since been fixed (same reason the gating tripwire resets).
234
+ def reset_warnings!
235
+ @warned = nil
236
+ end
237
+
238
+ private
239
+
240
+ # Checked HERE, not at declaration time, because it needs reflection.klass
241
+ # and forcing that constant to resolve inside the macro breaks a perfectly
242
+ # ordinary forward reference between two models that name each other. It is
243
+ # still a DECLARATION error, so it raises: no row value can reach it.
244
+ #
245
+ # Rebuilding the join (ancestor_scope_for) assumes the parent is matched on
246
+ # its own primary key. With `primary_key:` set, the child's foreign key
247
+ # holds some other column's values, so joining on the primary key would
248
+ # match a DIFFERENT parent row — a grant on X acting on Y, in the arm that
249
+ # IS the collection-read gate.
250
+ def validate_key!(klass, reflection)
251
+ return if reflection.polymorphic?
252
+ return if reflection.association_primary_key.to_s == reflection.klass.primary_key.to_s
253
+
254
+ raise ConfigurationError,
255
+ "#{klass.name}.current_scope_parent(#{reflection.name.inspect}) names a " \
256
+ "belongs_to with a custom association primary key " \
257
+ "(#{reflection.association_primary_key}). The collection query joins on " \
258
+ "#{reflection.klass.name}'s primary key, so it would match the wrong rows. " \
259
+ "Declare a chain that keys on the primary key."
260
+ end
261
+
262
+ def warned
263
+ @warned ||= Set.new
264
+ end
265
+
266
+ def walk(record)
267
+ ancestors = []
268
+ seen = [ identity(record) ]
269
+ current = record
270
+
271
+ while (reflection = reflection_for(current.class))
272
+ parent = load_parent(current, reflection)
273
+
274
+ # Normal data, all three: an optional belongs_to with no owner, a
275
+ # parent built in memory but never saved (it can hold no scoped grant),
276
+ # and one destroyed earlier in this request — the association cache
277
+ # would otherwise keep handing back the destroyed object, and matching
278
+ # a grant against it would open access to a record that is gone, which
279
+ # is the one-hop-up form of the AE4 rule.
280
+ break unless walkable?(parent)
281
+
282
+ key = identity(parent)
283
+ if seen.include?(key)
284
+ warn_truncated(record, :loop, seen + [ key ])
285
+ break
286
+ end
287
+
288
+ ancestors << parent
289
+ seen << key
290
+ current = parent
291
+
292
+ if ancestors.size >= MAX_PARENT_DEPTH
293
+ # Only a chain that actually CONTINUES past the ceiling was
294
+ # truncated. A chain of exactly five hops is valid and lost nothing,
295
+ # and warning here would latch [class, :depth] and swallow a later
296
+ # genuine over-depth warning for the same model. (qodo 2 / cubic P3)
297
+ warn_truncated(record, :depth, seen) if next_parent(current)
298
+ break
299
+ end
300
+ end
301
+
302
+ ancestors
303
+ end
304
+
305
+ # Whether the walk would have continued — used only to decide whether a
306
+ # ceiling stop actually TRUNCATED anything.
307
+ def next_parent(record)
308
+ reflection = reflection_for(record.class)
309
+ return nil unless reflection
310
+
311
+ parent = load_parent(record, reflection)
312
+ walkable?(parent) ? parent : nil
313
+ end
314
+
315
+ # The three terminal shapes, in ONE place so the walk and the
316
+ # would-it-have-continued check cannot disagree about what counts as a
317
+ # stop. A terminal parent means the walk ended normally and truncated
318
+ # nothing, so it must not warn — and must not latch [class, :depth] and
319
+ # swallow the next real over-depth warning. (cubic P3, second round)
320
+ def walkable?(parent)
321
+ !parent.nil? && !parent.new_record? && !parent.destroyed?
322
+ end
323
+
324
+ # F. Read the parent WITHOUT triggering a lazy load. A host running
325
+ # `strict_loading` would otherwise get ActiveRecord::StrictLoadingViolationError
326
+ # from inside the gate — a 500 on ordinary data, which is the failure this
327
+ # module exists to avoid. Uses the already-loaded target when there is one,
328
+ # so `includes(:project)` still pays off. (cubic P2)
329
+ def load_parent(record, reflection)
330
+ # A preloaded target is only trustworthy when the record's own class
331
+ # resolves the SAME association object the base declared. An STI subclass
332
+ # that overrides the inherited association would otherwise make the gate
333
+ # walk a different parent than scope_for queries — resolving the
334
+ # reflection from base_class is not enough on its own, because
335
+ # `record.association(name)` still resolves on the runtime subclass.
336
+ # (cubic P1, second round)
337
+ if record.class.reflect_on_association(reflection.name).equal?(reflection)
338
+ association = record.association(reflection.name)
339
+ return association.target if association.loaded?
340
+ end
341
+
342
+ foreign_key = record[reflection.foreign_key]
343
+ return nil if foreign_key.nil?
344
+
345
+ reflection.klass.find_by(reflection.klass.primary_key => foreign_key)
346
+ end
347
+
348
+ # Grants store the polymorphic base_class, so identity must too — otherwise
349
+ # an STI parent and its base would read as different records and a loop
350
+ # through them would not be seen.
351
+ def identity(record)
352
+ [ record.class.base_class.name, record.id ]
353
+ end
354
+
355
+ # Truncation denies rather than allows, so it can never escalate — but a
356
+ # denial nobody can explain is its own failure, hence the warning.
357
+ #
358
+ # The latch keys on the KIND (:loop / :depth), never on the rendered chain:
359
+ # the chain text carries record ids, so keying on it would mean a fresh
360
+ # entry per record — a warning on every row and a Set that grows without
361
+ # bound in a long-running process. At most two entries per class.
362
+ def warn_truncated(record, kind, chain)
363
+ key = [ record.class.base_class.name, kind ]
364
+ return if warned.include?(key)
365
+
366
+ warned << key
367
+ reason = kind == :loop ? "a loop in the data" : "a chain longer than #{MAX_PARENT_DEPTH}"
368
+ Rails.logger&.warn(
369
+ "[CurrentScope] current_scope_parent stopped walking #{record.class.name} " \
370
+ "early because of #{reason} (#{chain_text(chain)}). Grants held above that " \
371
+ "point will not match, so this can only DENY, never allow. Fix the data (or " \
372
+ "shorten the chain) if a subject is missing access they should have."
373
+ )
374
+ end
375
+
376
+ def chain_text(keys)
377
+ keys.map { |type, id| "#{type}##{id}" }.join(" -> ")
378
+ end
379
+
380
+ # A host that writes `def current_scope_parent = project` has written a
381
+ # method that nothing calls: the declaration is a class macro. Every other
382
+ # current_scope_* hook IS an instance method, so this is the mistake the
383
+ # name invites, and silence would be indistinguishable from "flat by
384
+ # design". This one DOES raise: it is a declaration error, reachable only
385
+ # by writing it, and no row value can trigger it.
386
+ def reject_method_form!(klass)
387
+ return unless klass.method_defined?(:current_scope_parent)
388
+
389
+ raise ConfigurationError,
390
+ "#{klass.name} defines an INSTANCE method " \
391
+ "`current_scope_parent`, but the declaration is a class-level macro " \
392
+ "and nothing reads that method. Replace it with " \
393
+ "`current_scope_parent :the_association` in the class body."
394
+ end
395
+ end
396
+ end
397
+ end
@@ -0,0 +1,146 @@
1
+ # Enumerable#to_set (key_set, the routed-key cache) is stdlib `set`, which a
2
+ # full Rails boot happens to load — a host on active_support.bare would
3
+ # NoMethodError at catalog construction without the explicit require.
4
+ require "set"
5
+
6
+ module CurrentScope
7
+ # Derives the permission set from the host's routes: one permission per
8
+ # controller#action pair. There is no table to maintain — add a controller
9
+ # and its actions appear in the grid on next boot/reload.
10
+ class PermissionCatalog
11
+ def keys
12
+ @keys ||= derive
13
+ end
14
+
15
+ # { "reports" => ["approve", "create", ...], ... } for the role-editor grid.
16
+ def grouped
17
+ keys.group_by { |key| key.split("#").first }
18
+ .transform_values { |ks| ks.map { |k| k.split("#").last } }
19
+ end
20
+
21
+ # Hot: the Guard asks this on EVERY gated request, and a role save asks it
22
+ # once per staged key. Set lookup, not Array#include? — the array is sorted
23
+ # for display, and scanning it linearly made every request pay for the size
24
+ # of the host's route table. Memoized alongside `keys` and dropped with it
25
+ # on reset!.
26
+ def include?(key)
27
+ key_set.include?(key)
28
+ end
29
+
30
+ # The ONE parse of config.sod_bypass_permission's action segment — the grid
31
+ # view and the ungated task read it here rather than re-splitting the
32
+ # string themselves (a looser split("#").last silently turns a malformed
33
+ # "reports#" into "reports" and mislabels the break-glass cell; #79 review).
34
+ #
35
+ # `split("#", -1)` keeps the trailing empty field, so a malformed "reports#"
36
+ # yields "" and is caught here rather than silently becoming "reports" and
37
+ # injecting "reports#reports". Blank raises instead of skipping: the host
38
+ # turned break-glass ON, so a permission nobody can hold means the veto can
39
+ # never be lifted and the feature is inert — an undiagnosable deny, which is
40
+ # exactly what this engine promises not to do. (A boot-time check for this
41
+ # config belongs with #40.)
42
+ def bypass_action
43
+ segments = CurrentScope.config.sod_bypass_permission.to_s.split("#", -1)
44
+ # Exactly a bare action or one controller#action. More hashes would pass
45
+ # a last-segment check while the resolver reads the ORIGINAL full string —
46
+ # the catalog would inject a key nobody can be granted under, and the
47
+ # veto could never be lifted. (#79 review)
48
+ if segments.empty? || segments.size > 2 || segments.any?(&:blank?)
49
+ raise ConfigurationError,
50
+ "config.allow_sod_bypass is on, but config.sod_bypass_permission " \
51
+ "(#{CurrentScope.config.sod_bypass_permission.inspect}) is not a bare action or a " \
52
+ "single controller#action. Name the permission the record's initiator must hold " \
53
+ "to break glass (the default is \"bypass_sod\"), or set config.allow_sod_bypass = false."
54
+ end
55
+
56
+ segments.last
57
+ end
58
+
59
+ # Is this key derived from a real route (as opposed to the injected
60
+ # break-glass key)? The distinction matters to anything making an
61
+ # inertness claim: a ROUTED action named like the bypass permission is an
62
+ # ordinary action and gets no exemption, while the injected key is live on
63
+ # any row. (#79 review)
64
+ def routed?(key)
65
+ keys # derive memoizes @routed_key_set as a side effect
66
+ @routed_key_set.include?(key)
67
+ end
68
+
69
+ private
70
+
71
+ def key_set
72
+ @key_set ||= keys.to_set
73
+ end
74
+
75
+ def derive
76
+ routed = Rails.application.routes.routes.filter_map { |route|
77
+ controller = route.defaults[:controller]
78
+ action = route.defaults[:action]
79
+ next unless controller && action
80
+ next if CurrentScope.config.excluded_controllers.any? { |re| controller.match?(re) }
81
+
82
+ "#{controller}##{action}"
83
+ }.uniq
84
+
85
+ @routed_key_set = routed.to_set
86
+ (routed + bypass_keys(routed)).uniq.sort
87
+ end
88
+
89
+ # Break-glass is the one permission that isn't an action you can route: it
90
+ # gates the SoD veto rather than a request. So a purely route-derived catalog
91
+ # can never contain it — which left the shipped "grantable, editable in the
92
+ # role grid" claim false, and break-glass reachable only through full_access
93
+ # (defeating the point of a *scoped* trusted-approver role) or a console
94
+ # insert. The catalog is the single definition of what is grantable — the
95
+ # grid reads `grouped`, the role setter and the Guard read `include?` — so
96
+ # injecting the virtual key here makes the cell render AND the save stick,
97
+ # with no special case in either. (#21)
98
+ #
99
+ # Emitted only where it could mean something: break-glass on, and a
100
+ # controller that actually routes an SoD action. Off by default, so the
101
+ # catalog is byte-for-byte the routed set unless a host opts in.
102
+ #
103
+ # Route- and config-derived only — deliberately no model introspection. The
104
+ # precise set would be "controllers whose SoD-gated model defines an
105
+ # initiator", but discovering that means loading application models, which
106
+ # is expensive, boot-order fragile, and against the catalog's whole design.
107
+ #
108
+ # The key is built from the controller's LAST path segment, because that is
109
+ # what the resolver will ask for: it derives the bypass key from the
110
+ # RECORD's route_key (permission_key → record.model_name.route_key), never
111
+ # from the controller path. Under Rails' resource conventions those agree —
112
+ # Admin::ReportsController's last segment "reports" IS Report's route_key —
113
+ # so keying off the whole path would inject "admin/reports#bypass_sod" while
114
+ # the resolver looks up "reports#bypass_sod", leaving break-glass ungrantable
115
+ # for every namespaced SoD controller and handing the admin a cell that
116
+ # silently does nothing. Namespaced admin controllers are common enough that
117
+ # this is the difference between the fix working and not.
118
+ #
119
+ # A namespace-only resource therefore gets its bypass cell on a "reports"
120
+ # row that no controller routes — the grid renders it aligned, blank
121
+ # everywhere else. Slightly odd to look at, and correct: it is the key the
122
+ # resolver actually reads.
123
+ #
124
+ # The irreducible limit: a controller named differently from the records it
125
+ # acts on (an `approvals` controller approving `Invoice`s) still injects
126
+ # `approvals#bypass_sod` while the live key is `invoices#bypass_sod`.
127
+ # Closing that needs to know the SoD-gated model, i.e. introspection.
128
+ # Tracked at OQ-2.
129
+ def bypass_keys(routed)
130
+ return [] unless CurrentScope.config.allow_sod_bypass
131
+
132
+ sod_actions = CurrentScope.config.sod_actions
133
+ return [] if sod_actions.empty?
134
+
135
+ routed.group_by { |key| key.split("#").first }
136
+ .filter_map { |controller, keys|
137
+ actions = keys.map { |k| k.split("#").last }
138
+ "#{controller.split('/').last}##{bypass_action}" if actions.intersect?(sod_actions)
139
+ }
140
+ end
141
+
142
+ # The action segment of config.sod_bypass_permission — tolerating either a
143
+ # bare action ("bypass_sod") or a full key ("reports#bypass_sod").
144
+ #
145
+ end
146
+ end