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,613 @@
1
+ module CurrentScope
2
+ class Configuration
3
+ # Host controller method that returns the authenticated subject.
4
+ attr_accessor :user_method
5
+
6
+ # Host controller method returning the REAL actor while impersonating
7
+ # (the pretender's true_user). nil means actor == user (no impersonation),
8
+ # so the actor is never resolved and falls back to the subject.
9
+ attr_accessor :actor_method
10
+
11
+ # Action names subject to the separation-of-duties veto: whoever initiated
12
+ # a record can never perform these actions on it. Not editable in the UI
13
+ # by design — SoD is a structural guarantee, not a preference. Records hit
14
+ # by these actions must define current_scope_initiator (return nil to
15
+ # exempt a record type).
16
+ #
17
+ # EMPTY BY DEFAULT — SoD is opt-in. The engine's baseline is scoped RBAC;
18
+ # many hosts want nothing to do with four-eyes. Enable it by listing the
19
+ # actions to gate, e.g. `config.sod_actions = %w[approve]`.
20
+ #
21
+ # Matched as STRINGS against the key's action segment (see Resolver#sod_action?).
22
+ # Use the writer below — a plain Symbol list ([:approve]) used to silently
23
+ # disable the veto (#91). Same shape as collection_read_actions=.
24
+ attr_reader :sod_actions
25
+
26
+ # Which identities the separation-of-duties veto weighs:
27
+ # :either (default) — veto if the effective subject OR (while
28
+ # impersonating) the REAL actor initiated the record,
29
+ # so impersonation can never approve the actor's own
30
+ # record.
31
+ # :subject — veto only on the effective subject.
32
+ # The two are identical when not impersonating (actor == subject).
33
+ # See the validating writer below — an unknown value must not silently
34
+ # narrow the veto.
35
+ attr_reader :sod_identity
36
+
37
+ SOD_IDENTITY_MODES = %i[either subject].freeze
38
+
39
+ # Normalizing writer for sod_actions (#91). The resolver matches with
40
+ # include?(permission.split("#").last) — a String — so symbols never match
41
+ # and silently turn the fraud control off. Normalize symbols to strings,
42
+ # reject non-String/Symbol elements and full keys (same honesty as
43
+ # collection_read_actions=), freeze the list.
44
+ def sod_actions=(value)
45
+ elements = Array(value)
46
+
47
+ if (bad = elements.reject { |e| e.is_a?(String) || e.is_a?(Symbol) }).any?
48
+ raise ConfigurationError,
49
+ "config.sod_actions takes action NAMES (strings or symbols); got " \
50
+ "#{bad.map(&:inspect).join(', ')}. Write e.g. %w[approve] or [:approve]."
51
+ end
52
+
53
+ actions = elements.map(&:to_s)
54
+
55
+ if (keyed = actions.grep(/#/)).any?
56
+ raise ConfigurationError,
57
+ "config.sod_actions is matched on the ACTION segment of a key — " \
58
+ "#{keyed.map(&:inspect).join(', ')} can never match. Write \"approve\", " \
59
+ "not \"reports#approve\"."
60
+ end
61
+
62
+ @sod_actions = actions.freeze
63
+ end
64
+
65
+ # Validating writer, same contract as enforcement=: the resolver compares
66
+ # `== :either`, so a typo (`:both`, `:actor`) would otherwise silently
67
+ # behave as :subject — narrowing the fraud control with no signal. Raise at
68
+ # assignment naming the closed set; the previous mode stands.
69
+ def sod_identity=(value)
70
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
71
+
72
+ unless SOD_IDENTITY_MODES.include?(mode)
73
+ raise ConfigurationError,
74
+ "config.sod_identity = #{value.inspect} is not a mode. " \
75
+ "Use :either (default — the veto binds the effective subject AND, " \
76
+ "while impersonating, the real actor, so impersonation can never " \
77
+ "approve the actor's own record) or :subject (weigh only the " \
78
+ "effective subject)."
79
+ end
80
+
81
+ @sod_identity = mode
82
+ end
83
+
84
+ # When false (the default), an impersonated session is read-only: any
85
+ # non-GET/HEAD request is denied while a real actor acts as a different
86
+ # subject — INCLUDING the engine's own management UI. The host's
87
+ # stop-impersonation, sign-out, and sign-in endpoints must opt out with
88
+ # skip_before_action :current_scope_mutation_guard!, or impersonation can
89
+ # never end. The gate runs BEFORE the permission check, so sod_identity is
90
+ # only observable once mutations are allowed (or on a GET-listed sod_action).
91
+ #
92
+ # Setting this true is refused in production unless the env opt-in below is
93
+ # set — letting a real actor write as someone else is a privilege-escalation
94
+ # and audit-integrity risk, so a prod deploy must acknowledge it explicitly.
95
+ # See the custom writer.
96
+ attr_reader :allow_mutations_while_impersonating
97
+
98
+ # Env var that opts a PRODUCTION deploy into impersonated writes. The VALUE
99
+ # is what counts: a truthy value ("1", "true", "yes"…) lifts the production
100
+ # refusal; unset, "", "false", "0", "off" all mean "not opted in" — an
101
+ # operator writing `…=false` in a deploy manifest gets what they said, not
102
+ # the opposite. development/test/staging never consult it.
103
+ PROD_MUTATIONS_ENV = "CURRENT_SCOPE_ALLOW_PROD_IMPERSONATION_MUTATIONS"
104
+
105
+ # Regexps matched against controller paths to keep infrastructure
106
+ # controllers out of the permission grid. An excluded controller cannot be
107
+ # granted, so it must also skip the gate
108
+ # (skip_before_action :current_scope_check!) — Guard raises otherwise.
109
+ # Skipping the gate leaves the controller ungated by CurrentScope — protect
110
+ # it with your own authorization (e.g. require_admin!). See docs/SECURITY-CHECKLIST.md.
111
+ attr_accessor :excluded_controllers
112
+
113
+ # Class the management UI's controllers inherit from, so they pick up the
114
+ # host's authentication and layout.
115
+ attr_accessor :parent_controller
116
+
117
+ # Host class acting as the subject, used by the management UI to list
118
+ # assignable subjects.
119
+ attr_accessor :subject_class
120
+
121
+ # How a subject is identified in the management UI (subjects table, picker,
122
+ # bulk bar). A subject id is meaningless with UUID keys, so pick something
123
+ # human. Accepts:
124
+ # - a Symbol — a method on the subject, e.g. :email or :name
125
+ # - a Proc — subject -> String, e.g. ->(u) { "#{u.first_name} #{u.last_name}" }
126
+ # - nil (default) — best-effort, people-first: email, else name, else
127
+ # first+last, else the generic current_scope_label / "Class #id".
128
+ #
129
+ # A Proc should be TOTAL — it runs for every subject the admin can see,
130
+ # including ones with nil or blank attributes, so `->(u) { u.email.upcase }`
131
+ # is a trap the first time someone is invited but hasn't filled in an email.
132
+ # Nothing here is load-bearing enough to break a page over, so a label that
133
+ # raises (or a Symbol the subject can't answer) degrades to the default chain
134
+ # for that subject and logs once — it never errors the page and never affects
135
+ # an authorization decision. If subject labels look wrong, check the log:
136
+ # a silent fallback is exactly what a broken label looks like from the UI.
137
+ attr_accessor :subject_label
138
+
139
+ # Break-glass override for the SoD veto. Default false — OFF preserves v0.1
140
+ # exactly: the separation-of-duties veto is absolute and this hook is never
141
+ # consulted. When true, the veto is lifted for a record ONLY when all three
142
+ # hold, re-checked live at decision time: this flag is on, the record's
143
+ # `current_scope_sod_bypassed?` hook returns true, AND the record's initiator
144
+ # holds the bypass permission (sod_bypass_permission). Every lifted veto is
145
+ # recorded by the engine (`sod.bypassed`) and surfaced on
146
+ # X-Current-Scope-Reason.
147
+ #
148
+ # HONEST FRAMING: this converts SoD from a *structural* guarantee into an
149
+ # *audited policy override* — it's break-glass, not SoD. Its legitimacy rests
150
+ # on being default-off, privilege-gated, and always audited. Unlike
151
+ # allow_mutations_while_impersonating there is NO production env-gate: the
152
+ # feature is per-record, privilege-scoped, and audited-by-construction, so
153
+ # production is its intended home.
154
+ attr_accessor :allow_sod_bypass
155
+
156
+ # The grantable permission the record's initiator must hold for a break-glass
157
+ # bypass to lift the veto (default "bypass_sod"). Resolved against the
158
+ # record's route key like any permission, so it's editable in the role grid —
159
+ # never a hardcoded role. Must NOT be listed in sod_actions (it isn't an SoD
160
+ # action; keeping it out also bounds the bypass re-entrancy).
161
+ #
162
+ # It isn't a routable action, so the catalog injects it rather than deriving
163
+ # it: the grid shows a column for it ONLY when allow_sod_bypass is on, and
164
+ # only on controllers that route an action listed in sod_actions. With the
165
+ # flag off it isn't grantable at all — nothing to tick, and the key is
166
+ # rejected if assigned. (#21)
167
+ attr_accessor :sod_bypass_permission
168
+
169
+ # Action names whose RECORD-LESS gate derives its answer from the scoped
170
+ # list (#65): for these, the check asks scope_for — the same id-narrowed
171
+ # query the list renders from — so a scoped full_access grant opens
172
+ # exactly the collections that would show it records, and gate and list
173
+ # agree by construction. Matched like sod_actions, on the action segment
174
+ # of the key ("index" in "reports#index").
175
+ #
176
+ # DEFAULT ["index"] — the #65 fix is on out of the box. Set [] to restore
177
+ # the pre-#65 record-less semantics (never a released posture on their
178
+ # own — #19/#50/#65 ship together): explicit ticks still open type-bound
179
+ # record-less gates, a scoped full_access role opens none, and the
180
+ # gate/list disagreement #65 describes comes back with it.
181
+ #
182
+ # LIST-NARROWING READS ONLY. The full_access safety argument is that the
183
+ # answer is derived from record ids — WHICH records the subject holds —
184
+ # so it is only sound for actions with a list side. Naming a mutating or
185
+ # non-idempotent action here ("create", "destroy_all") hands scoped
186
+ # full_access holders that action on the whole TYPE off a grant on one
187
+ # record: the #49 escalation, reintroduced by config. Custom read-shaped
188
+ # actions (export, search) are the intended additions.
189
+ #
190
+ # The declared current_scope_model is trusted here the way
191
+ # current_scope_record already is — a wrong declaration plus a scoped
192
+ # full_access grant of the declared type opens that controller's listed
193
+ # reads, so review the declaration like the record hook.
194
+ attr_reader :collection_read_actions
195
+
196
+ # Normalizing writer: the list is matched as STRINGS, so [:index] matching
197
+ # nothing would silently un-fix #65. Normalize rather than raise for shape
198
+ # (nil ⇒ [], symbols ⇒ strings — unambiguous), but two inputs get a voice,
199
+ # because a silently-inert or silently-widened security knob is the failure
200
+ # this writer exists to prevent:
201
+ #
202
+ # - a full KEY ("reports#index") RAISES — the list is matched on the
203
+ # action segment and applies to every controller, so a keyed member
204
+ # can never match (silently inert) and stripping it to "index" would
205
+ # silently widen a deliberately controller-scoped intent. Neither
206
+ # reading is honest; say so at assignment.
207
+ # - a canonical mutating action name (create/update/destroy) WARNS —
208
+ # the #49 escalation shape, reintroduced by config. A warning, not a
209
+ # raise: custom action names make any blocklist partial, and the
210
+ # report-mode precedent (warn_report_mode_in_production) is the
211
+ # house style for "legal but almost certainly not what you meant".
212
+ # - an element that is not a String/Symbol RAISES — Array({ index: true })
213
+ # is [[:index, true]], and .to_s on that pair is "[:index, true]": a
214
+ # member that can never match, silently REPLACING the default and
215
+ # un-fixing #65 with no signal (0.3.0 release-gate finding). Only
216
+ # action names have an unambiguous string form; anything else says so
217
+ # at assignment.
218
+ def collection_read_actions=(value)
219
+ elements = Array(value)
220
+
221
+ if (bad = elements.reject { |e| e.is_a?(String) || e.is_a?(Symbol) }).any?
222
+ raise ConfigurationError,
223
+ "config.collection_read_actions takes action NAMES (strings or symbols); got " \
224
+ "#{bad.map(&:inspect).join(', ')}. A Hash or nested array would coerce to a " \
225
+ "member that can never match an action, silently restoring the pre-#65 " \
226
+ "record-less semantics. Write e.g. %w[index export]."
227
+ end
228
+
229
+ actions = elements.map(&:to_s)
230
+
231
+ if (keyed = actions.grep(/#/)).any?
232
+ raise ConfigurationError,
233
+ "config.collection_read_actions is matched on the ACTION segment of a key and " \
234
+ "applies to every controller that routes it — #{keyed.map(&:inspect).join(', ')} " \
235
+ "can never match. Write \"index\", not \"reports#index\"; there is no " \
236
+ "per-controller form of this list."
237
+ end
238
+
239
+ warn_on_mutating_collection_reads(actions)
240
+ # Frozen so in-place mutation (config.collection_read_actions << :export)
241
+ # cannot bypass the writer — a symbol appended in place would silently
242
+ # never match, a "#" key would dodge the raise, and a mutating name would
243
+ # dodge the warning. FrozenError is loud; assign a new list instead.
244
+ @collection_read_actions = actions.freeze
245
+ end
246
+
247
+ # Canonical mutating / bulk-write action names the collection_read writer
248
+ # warns about. Includes destroy_all/update_all — the docs' escalation
249
+ # examples — so those cannot reintroduce #49 via config with no signal.
250
+ # Custom names still evade any partial blocklist; that ceiling is deliberate.
251
+ MUTATING_ACTION_NAMES = %w[create update destroy destroy_all update_all].freeze
252
+
253
+ # Tri-state: false | true (default) | :strict — controls
254
+ # CurrentScope::Event.record!.
255
+ # false — record! is a silent no-op; hosts that don't want the ledger
256
+ # set this and skip the events migration.
257
+ # true — append a row for every event; if the current_scope_events
258
+ # table hasn't been migrated yet, degrade gracefully (skip +
259
+ # warn once), so an existing host never breaks on first mutation.
260
+ # :strict — an audit-mandatory host: a missing events table RAISES instead
261
+ # of degrading, so a mutation-wrapping transaction rolls back
262
+ # rather than committing an unaudited grant. (Impersonation-
263
+ # boundary events have no mutation to roll back — a raise there
264
+ # is a loud 500 on a mis-migrated host.)
265
+ # Read as `== :strict`, never `== true` — don't flatten the tri-state.
266
+ # See the validating writer below — a misspelled :strict must not silently
267
+ # downgrade an audit-mandatory host to best-effort.
268
+ attr_reader :audit
269
+
270
+ AUDIT_MODES = [ false, true, :strict ].freeze
271
+
272
+ # Validating writer, same contract as enforcement=: because record! checks
273
+ # `== :strict`, a typo (`:strixt`) is truthy and would silently behave as
274
+ # plain true — the host believes unaudited mutations roll back while they
275
+ # commit. Raise at assignment naming the closed set; the previous mode
276
+ # stands.
277
+ def audit=(value)
278
+ # ENV can only carry strings, and two of the three modes are booleans —
279
+ # normalize the boolean spellings before symbolizing ("strict" → :strict).
280
+ mode = case value
281
+ when "true" then true
282
+ when "false" then false
283
+ else value.respond_to?(:to_sym) ? value.to_sym : value
284
+ end
285
+
286
+ unless AUDIT_MODES.include?(mode)
287
+ raise ConfigurationError,
288
+ "config.audit = #{value.inspect} is not a mode. " \
289
+ "Use false (record! is a silent no-op — no ledger), " \
290
+ "true (append a row per event, degrade gracefully if the events " \
291
+ "table is missing), or :strict (a missing events table RAISES so " \
292
+ "a mutation-wrapping transaction rolls back rather than " \
293
+ "committing an unaudited grant)."
294
+ end
295
+
296
+ @audit = mode
297
+ end
298
+
299
+ # --- Dev diagnostics (#41) ----------------------------------------------
300
+ #
301
+ # Four failure modes this engine has that are SILENT and silent in the bad
302
+ # direction: the thing that went wrong looks exactly like the thing going
303
+ # right. Each is a cheap log line in dev/test and costs nothing in prod.
304
+ #
305
+ # All four default ON in development and test, OFF in production — and off
306
+ # entirely when Rails isn't loaded. The default is the point: a diagnostic
307
+ # nobody knows about is a diagnostic nobody benefits from, and these
308
+ # protect against mistakes you make while WRITING the app, which is exactly
309
+ # when dev/test is where you are. A host can force any of them either way.
310
+ #
311
+ # Every one is LOG-ONLY. No decision, exception, header, or audit row
312
+ # changes because of them, in any environment.
313
+
314
+ # The gate logs a nudge when an SoD action is gated with no record and the
315
+ # request is ALLOWED — i.e. the veto was skipped because current_scope_record
316
+ # returned nil (or was never declared) on a member action. The gem's #1
317
+ # foot-gun: the veto silently not running looks identical to the veto passing.
318
+ #
319
+ # Emitted from the Guard seam (not the shared resolver), so it never fires on
320
+ # advisory allowed_to?/scope_for calls.
321
+ attr_accessor :warn_on_nil_sod_record
322
+
323
+ # The gate logs a nudge when a request is DENIED :no_grant, the controller
324
+ # declared no current_scope_record hook at all, and the subject holds a
325
+ # scoped grant that WOULD have applied had the hook returned the record.
326
+ #
327
+ # That combination is a controller with member actions that forgot the hook.
328
+ # It fails closed — correctly — but the 403 is indistinguishable from "you
329
+ # were never granted this", so the person debugging it goes looking at their
330
+ # grants, which are fine, instead of at their controller, which isn't.
331
+ attr_accessor :warn_on_inert_scoped_grant
332
+
333
+ # CurrentScope.permission_key logs a nudge when short-form derivation
334
+ # (`allowed_to?(:show, report)`) resolves to a DIFFERENT key than the one the
335
+ # current controller's gate enforces — the documented namespaced/custom-named
336
+ # controller foot-gun. The view then shows a link that 403s, or hides one that
337
+ # would have worked: the gate and the view disagree, silently, and the symptom
338
+ # shows up nowhere near the cause.
339
+ attr_accessor :warn_on_cross_controller_derivation
340
+
341
+ # The gate logs a nudge when a request is DENIED :model_undeclared — a
342
+ # declared collection action (current_scope_record returned nil) whose
343
+ # controller names no current_scope_model, while the subject holds a scoped
344
+ # grant ticking the key. The record-less branch had no type to bind that
345
+ # grant to, so it failed closed (#50) — correctly, but the 403 looks like
346
+ # "never granted" while the fix is one line in the controller. The same
347
+ # flag gates the :model_invalid nudge — the hook was declared but returned
348
+ # something unusable ("Report" for Report); one failure family, one knob.
349
+ attr_accessor :warn_on_undeclared_collection_model
350
+
351
+ # How the role-editor grid folds RESTful actions into columns. An ordered
352
+ # Hash of { column_label => [action names] }: ticking a group column grants
353
+ # every routed action in it. The default collapses the seven RESTful verbs
354
+ # into CRUD — new/create and edit/update pair up (the "new"/"edit" actions
355
+ # just render the form for their mutation), index+show read as one. Actions
356
+ # not in any group (e.g. "approve") get their own column. Set to nil (or {})
357
+ # to show every raw action as its own column instead. Either way the grid
358
+ # renders ALIGNED columns — a controller that doesn't route a column's
359
+ # actions shows a blank cell, never a shifted one.
360
+ attr_accessor :permission_grid_groups
361
+
362
+ # :enforce (default) | :report — what the gate DOES with a denial. (#37)
363
+ #
364
+ # The adoption ramp. A host retrofitting this engine onto an existing app
365
+ # has a controller suite that goes red the moment the gate is mounted: it is
366
+ # fail-closed, and nothing is granted yet. Report mode lets them mount the
367
+ # gate, run their app, and read what WOULD have been denied out of the
368
+ # ledger — turning a big-bang cutover into a list of grants to seed.
369
+ #
370
+ # :enforce — deny means 403. The only production posture.
371
+ # :report — a MISSING GRANT is logged and allowed through instead. Every
372
+ # other denial still refuses.
373
+ #
374
+ # Report mode is NOT an off switch and never reaches the things that would
375
+ # make it one: the separation-of-duties veto still refuses, and the
376
+ # management console answers to its own full-access check rather than this
377
+ # gate, so no enforcement setting can hand out the UI where grants are made.
378
+ attr_reader :enforcement
379
+
380
+ ENFORCEMENT_MODES = %i[enforce report].freeze
381
+
382
+ # Validating writer: an unknown value here is the worst kind of config
383
+ # mistake — the host believes it is enforcing while it is not. Fail at boot,
384
+ # naming what's allowed. Accepts a String so ENV["..."] works; anything that
385
+ # can't be a mode (nil from an unset ENV var, a number, a collection) raises
386
+ # ConfigurationError rather than NoMethodError, and the previous mode stands.
387
+ def enforcement=(value)
388
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
389
+
390
+ unless ENFORCEMENT_MODES.include?(mode)
391
+ raise ConfigurationError,
392
+ "config.enforcement = #{value.inspect} is not a mode. " \
393
+ "Use :enforce (deny means 403 — the production posture) or " \
394
+ ":report (log what WOULD be denied and allow it through, to seed " \
395
+ "grants before cutting over). Report mode is for adoption only: " \
396
+ "it never lifts the separation-of-duties veto and never opens the " \
397
+ "management console."
398
+ end
399
+
400
+ warn_report_mode_in_production if mode == :report && production?
401
+ @enforcement = mode
402
+ end
403
+
404
+ # True only in report mode. A predicate, so callers can't express "not
405
+ # enforcing" — the modes are a closed set, not a boolean.
406
+ def report_only? = @enforcement == :report
407
+
408
+ # :raise | :warn — what the opt-in GatingTripwire mixin (A4) does when it
409
+ # catches an action that completed without running the gate.
410
+ #
411
+ # :raise — fail loudly. Default in development/test: an ungated action is
412
+ # a hole you want CI to go red on, not one to discover in an audit.
413
+ # :warn — log once per controller#action and let the response through, so
414
+ # a real app can inventory its ungated surface without 500ing.
415
+ #
416
+ # A closed two-mode set, like enforcement — not a boolean, and no :off:
417
+ # not including the mixin is off.
418
+ attr_reader :gating_tripwire
419
+
420
+ GATING_TRIPWIRE_MODES = %i[raise warn].freeze
421
+
422
+ # Validating writer, same contract as enforcement=: an unknown value raises
423
+ # at assignment naming both modes, and the previous mode stands. Accepts a
424
+ # String so ENV["..."] works.
425
+ def gating_tripwire=(value)
426
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
427
+
428
+ unless GATING_TRIPWIRE_MODES.include?(mode)
429
+ raise ConfigurationError,
430
+ "config.gating_tripwire = #{value.inspect} is not a mode. " \
431
+ "Use :raise (an ungated action fails loudly — the dev/test posture) " \
432
+ "or :warn (log it once per controller#action and let the response " \
433
+ "through, to inventory an app's ungated surface). There is no :off — " \
434
+ "not including CurrentScope::GatingTripwire is off."
435
+ end
436
+
437
+ @gating_tripwire = mode
438
+ end
439
+
440
+ def initialize
441
+ @user_method = :current_user
442
+ @actor_method = nil
443
+ @sod_actions = [].freeze
444
+ @sod_identity = :either
445
+ @allow_sod_bypass = false
446
+ @sod_bypass_permission = "bypass_sod"
447
+ @collection_read_actions = [ "index" ].freeze
448
+ @allow_mutations_while_impersonating = false
449
+ @excluded_controllers = [
450
+ %r{\Arails/}, %r{\Aactive_storage/}, %r{\Aaction_mailbox/},
451
+ %r{\Aturbo/}, %r{\Acurrent_scope/}
452
+ ]
453
+ @parent_controller = "::ApplicationController"
454
+ @subject_class = "User"
455
+ @audit = true
456
+ @enforcement = :enforce
457
+ # Reuses diagnostics_default_on? for its env split and bare-Ruby safety
458
+ # ONLY — do NOT carry over the diagnostics flags' emit/silence reading.
459
+ # There, false means stay quiet; here, false means :warn, which EMITS.
460
+ # The inversion is deliberate: the tripwire mixin is opt-in, so a
461
+ # production host that included it is asking for the ungated inventory —
462
+ # the env decides only whether a hit 500s (dev/test) or logs (elsewhere).
463
+ @gating_tripwire = diagnostics_default_on? ? :raise : :warn
464
+ @warn_on_nil_sod_record = diagnostics_default_on?
465
+ @warn_on_inert_scoped_grant = diagnostics_default_on?
466
+ @warn_on_cross_controller_derivation = diagnostics_default_on?
467
+ @warn_on_undeclared_collection_model = diagnostics_default_on?
468
+ @permission_grid_groups = {
469
+ "read" => %w[index show],
470
+ "create" => %w[new create],
471
+ "update" => %w[edit update],
472
+ "destroy" => %w[destroy]
473
+ }
474
+ end
475
+
476
+ # Guarded writer: enabling impersonated writes is fine in
477
+ # development/test/staging, but refused in production unless PROD_MUTATIONS_ENV
478
+ # is set — so an unsafe flag fails the deploy loudly at boot instead of
479
+ # silently letting a real actor mutate data as someone else. Assigning false
480
+ # (or leaving the default) is always allowed.
481
+ def allow_mutations_while_impersonating=(value)
482
+ if value && production? && !prod_mutations_opt_in?
483
+ raise ConfigurationError,
484
+ "config.allow_mutations_while_impersonating = true is refused in " \
485
+ "production: letting a real actor write as the subject they " \
486
+ "impersonate is a privilege-escalation and audit-integrity risk. " \
487
+ "Keep it false so impersonated sessions stay read-only in " \
488
+ "production, or — only if writes under act-as are genuinely " \
489
+ "required (e.g. a live public showcase) — set ENV[\"#{PROD_MUTATIONS_ENV}\"] " \
490
+ "to a truthy value (\"1\"/\"true\") to opt in explicitly; \"false\", " \
491
+ "\"0\", and empty mean not opted in. development, test, and staging " \
492
+ "are unaffected."
493
+ end
494
+ @allow_mutations_while_impersonating = value
495
+ end
496
+
497
+ # Action segment of sod_bypass_permission — bare name or "controller#action".
498
+ # Shared by boot validation and the resolver's recursion guard so the two
499
+ # never normalize differently (#40). Uses split("#", -1) like
500
+ # PermissionCatalog#bypass_action: plain split("#").last turns "reports#"
501
+ # into "reports" (trailing empty dropped) and multi-hash values into the
502
+ # wrong last segment — false conflicts or missed recursion guards.
503
+ # Malformed shapes return nil (no false conflict); the catalog still raises
504
+ # loudly when allow_sod_bypass is on and the key is malformed.
505
+ def sod_bypass_action
506
+ segments = sod_bypass_permission.to_s.split("#", -1)
507
+ return if segments.empty? || segments.size > 2 || segments.any?(&:blank?)
508
+
509
+ segments.last
510
+ end
511
+
512
+ # True when the break-glass bypass permission is also listed in sod_actions.
513
+ # That pairing would re-enter the SoD step on every bypass check and stack
514
+ # overflow. Never valid in any environment (#40).
515
+ def sod_bypass_permission_conflicts_with_sod_actions?
516
+ action = sod_bypass_action
517
+ action.present? && sod_actions.include?(action)
518
+ end
519
+
520
+ # Boot-time config invariants. Wired from Engine#after_initialize after the
521
+ # host initializer has finalized both fields. Extensible seam for future
522
+ # multi-field checks; today only the bypass-in-sod_actions recursion rule.
523
+ def validate!
524
+ return unless sod_bypass_permission_conflicts_with_sod_actions?
525
+
526
+ action = sod_bypass_action
527
+ raise ConfigurationError,
528
+ "config.sod_bypass_permission (#{sod_bypass_permission.inspect}) is the " \
529
+ "action #{action.inspect}, which is also in config.sod_actions. The bypass " \
530
+ "permission must not be an SoD action — it would recurse. Remove " \
531
+ "#{action.inspect} from sod_actions."
532
+ end
533
+
534
+ private
535
+
536
+ # The env var's VALUE means what it says — presence alone is not consent.
537
+ # ActiveModel's boolean cast maps "false"/"0"/"f"/"off" to false and "" to
538
+ # nil; anything else present is truthy. Only reached when production? is
539
+ # already true, so Rails (and ActiveModel) are loaded — a bare-Ruby
540
+ # Configuration.new never gets here.
541
+ def prod_mutations_opt_in?
542
+ !!ActiveModel::Type::Boolean.new.cast(ENV[PROD_MUTATIONS_ENV])
543
+ end
544
+
545
+ # Report mode in production is DELIBERATELY allowed — surveying real traffic
546
+ # is the most honest way to find out what to grant, and a staging run won't
547
+ # show you the flows your actual users take. Refusing it here would break the
548
+ # feature's best use case.
549
+ #
550
+ # But "we are not enforcing authorization" is not a state a production app
551
+ # should be in silently, or for long. The failure mode is quiet: nothing
552
+ # breaks, no one is refused, and the temporary survey becomes the permanent
553
+ # posture because nothing ever reminded anyone. So it says so at boot, once,
554
+ # where a deploy log will keep it.
555
+ #
556
+ # ponytail: a warn, not a raise. Unlike allow_mutations_while_impersonating
557
+ # (which has an env-gate refusal) this has a legitimate production use and a
558
+ # deliberate exit — the host is mid-migration, and the loud reminder is the
559
+ # right amount of friction.
560
+ def warn_report_mode_in_production
561
+ message = "[CurrentScope] config.enforcement = :report in PRODUCTION — " \
562
+ "authorization is NOT being enforced. Missing grants are logged " \
563
+ "as access.would_deny and the request is ALLOWED THROUGH. This is " \
564
+ "an adoption ramp, not a production posture: seed the grants the " \
565
+ "ledger names, then set config.enforcement = :enforce."
566
+
567
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
568
+ Rails.logger.warn(message)
569
+ else
570
+ # Boot order: an initializer can run before the logger exists. Say it
571
+ # anyway — a warning nobody sees is the thing this method exists to
572
+ # prevent.
573
+ warn(message)
574
+ end
575
+ end
576
+
577
+ # Mirrors warn_report_mode_in_production: a legal posture that is almost
578
+ # never what the host meant says so once, loudly, where a boot log keeps
579
+ # it. Warn in every env — unlike report mode this misconfiguration is as
580
+ # wrong in development as in production.
581
+ def warn_on_mutating_collection_reads(actions)
582
+ mutating = actions & MUTATING_ACTION_NAMES
583
+ return if mutating.empty?
584
+
585
+ message = "[CurrentScope] config.collection_read_actions includes " \
586
+ "#{mutating.map(&:inspect).join(', ')} — this list is for LIST-NARROWING READS. " \
587
+ "A mutating action here hands scoped full_access holders that action on every " \
588
+ "record of its type off a grant on ONE record (the #49 escalation shape). " \
589
+ "Remove it unless that is genuinely intended."
590
+
591
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
592
+ Rails.logger.warn(message)
593
+ else
594
+ warn(message)
595
+ end
596
+ end
597
+
598
+ # Diagnostics are on where you are while writing the app, off where they'd
599
+ # be noise on someone else's dime. `local?` is Rails' own name for
600
+ # "development or test", so a host with a staging env that reports itself as
601
+ # neither gets prod behaviour — the conservative side for a log line.
602
+ #
603
+ # Mirrors the production? guard below: a bare-Ruby Configuration.new with no
604
+ # Rails must not raise, and gets false (no logger to warn to anyway).
605
+ def diagnostics_default_on?
606
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.local?
607
+ end
608
+
609
+ def production?
610
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
611
+ end
612
+ end
613
+ end
@@ -0,0 +1,41 @@
1
+ module CurrentScope
2
+ # Include in ApplicationController (before Guard). Populates the ambient
3
+ # context from the host's authentication once per request and exposes
4
+ # allowed_to? to actions and views. CurrentAttributes resets itself around
5
+ # every request and job, so nothing leaks between executions.
6
+ module Context
7
+ extend ActiveSupport::Concern
8
+ include Permissions
9
+
10
+ included do
11
+ before_action :set_current_scope_user
12
+ if respond_to?(:helper_method)
13
+ helper_method :allowed_to?, :scope_for, :current_scope_user, :current_scope_actor, :impersonating?
14
+ end
15
+ end
16
+
17
+ private
18
+
19
+ def set_current_scope_user
20
+ CurrentScope::Current.user = resolve_current_scope_subject(CurrentScope.config.user_method)
21
+
22
+ # nil actor_method means no impersonation: leave actor unset so Current
23
+ # falls back to the subject. Only resolve when the host opts in.
24
+ actor_method = CurrentScope.config.actor_method
25
+ CurrentScope::Current.actor = resolve_current_scope_subject(actor_method) if actor_method
26
+
27
+ # Correlation for the audit ledger (#30). ActionDispatch::RequestId runs
28
+ # ahead of app before_actions; job/console contexts never enter this hook
29
+ # and leave request_id nil by design.
30
+ CurrentScope::Current.request_id = request.request_id
31
+ end
32
+
33
+ def resolve_current_scope_subject(method)
34
+ return send(method) if respond_to?(method, true)
35
+
36
+ raise CurrentScope::ConfigurationError,
37
+ "#{self.class.name} does not respond to ##{method}. Define it, or point the " \
38
+ "matching CurrentScope.config.*_method at your method."
39
+ end
40
+ end
41
+ end