current_scope 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +317 -18
  3. data/app/assets/javascripts/current_scope/application.js +4 -0
  4. data/app/assets/stylesheets/current_scope/application.css +99 -0
  5. data/app/controllers/current_scope/application_controller.rb +39 -1
  6. data/app/controllers/current_scope/role_assignments_controller.rb +14 -14
  7. data/app/controllers/current_scope/roles_controller.rb +6 -2
  8. data/app/helpers/current_scope/application_helper.rb +168 -12
  9. data/app/models/current_scope/current.rb +24 -0
  10. data/app/models/current_scope/event.rb +10 -6
  11. data/app/models/current_scope/role.rb +61 -10
  12. data/app/views/current_scope/roles/edit.html.erb +92 -4
  13. data/app/views/current_scope/roles/members.html.erb +3 -3
  14. data/app/views/current_scope/roles/new.html.erb +1 -1
  15. data/app/views/current_scope/scoped_role_assignments/new.html.erb +12 -3
  16. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  17. data/app/views/current_scope/subjects/index.html.erb +17 -5
  18. data/app/views/layouts/current_scope/application.html.erb +4 -1
  19. data/config/routes.rb +3 -4
  20. data/lib/current_scope/configuration.rb +378 -16
  21. data/lib/current_scope/engine.rb +7 -0
  22. data/lib/current_scope/gating_reflection.rb +62 -0
  23. data/lib/current_scope/gating_tripwire.rb +36 -5
  24. data/lib/current_scope/guard.rb +406 -8
  25. data/lib/current_scope/mutation_guard.rb +30 -5
  26. data/lib/current_scope/permission_catalog.rb +116 -3
  27. data/lib/current_scope/permission_grid.rb +34 -4
  28. data/lib/current_scope/permissions.rb +42 -8
  29. data/lib/current_scope/resolver.rb +317 -13
  30. data/lib/current_scope/version.rb +1 -1
  31. data/lib/current_scope.rb +113 -5
  32. data/lib/generators/current_scope/install/install_generator.rb +64 -0
  33. data/lib/generators/current_scope/install/templates/initializer.rb +94 -5
  34. data/lib/tasks/current_scope_tasks.rake +153 -0
  35. metadata +6 -2
@@ -14,7 +14,10 @@
14
14
  </div>
15
15
  <% if @query.present? %>
16
16
  <p class="cs-hint">Showing matches for “<%= @query %>” across all subjects. Narrow the list below per page; selection applies to the current page.</p>
17
- <% elsif @page > 1 || @has_next_page %>
17
+ <% else %>
18
+ <%# Always shown (not just on multi-page lists): the one input drives TWO
19
+ mechanisms — typing live-filters this page, Search queries every subject —
20
+ and nothing but this line says so. %>
18
21
  <p class="cs-hint">Type to filter this page, or Search to find anyone by email or name across every subject.</p>
19
22
  <% end %>
20
23
 
@@ -23,8 +26,11 @@
23
26
  <span class="cs-spacer"></span>
24
27
  <%# Org-wide role for the selection. JS injects the checked subjects as
25
28
  subject_gids[] on submit; blank role clears it for all. %>
26
- <%= form_with url: role_assignment_path, method: :post, class: "cs-bulk-org",
27
- data: { cs_bulk_org: true } do %>
29
+ <%# Setting REPLACES each subject's current org-wide role (blank clears it) —
30
+ as consequential as Remove, which confirms, so this does too. %>
31
+ <% bulk_prompt = "Change the org-wide role for the selected subjects? This replaces their current role." %>
32
+ <%= form_with url: role_assignments_path, method: :post, class: "cs-bulk-org",
33
+ data: { cs_bulk_org: true, cs_confirm: bulk_prompt, turbo_confirm: bulk_prompt } do %>
28
34
  <%= select_tag :role_id, options_from_collection_for_select(@roles, :id, :name),
29
35
  include_blank: "org role…", aria: { label: "Org-wide role for selected" } %>
30
36
  <%= submit_tag "Set for selected", name: nil, class: "cs-btn" %>
@@ -46,7 +52,11 @@
46
52
  </thead>
47
53
  <tbody>
48
54
  <% @subjects.each do |subject| %>
49
- <% key = [ subject.class.name, subject.id ] %>
55
+ <%# Key by what the polymorphic association STORES (the base_class name),
56
+ not subject.class.name — an STI subject (AdminUser < User) is saved as
57
+ "User", and keying on the subclass name would show its roles as
58
+ "— none —" while the resolver happily enforces them. %>
59
+ <% key = [ subject.class.polymorphic_name, subject.id ] %>
50
60
  <% label = current_scope_subject_label(subject) %>
51
61
  <%# Filter against a precise text set — subject label, current org role, and
52
62
  scoped roles/records — NOT the row's textContent: every row embeds a
@@ -61,7 +71,9 @@
61
71
  </td>
62
72
  <td><%= label %></td>
63
73
  <td>
64
- <%= form_with url: role_assignment_path, method: :post do %>
74
+ <% set_prompt = "Change the org-wide role for #{label}? This replaces their current role." %>
75
+ <%= form_with url: role_assignments_path, method: :post,
76
+ data: { cs_confirm: set_prompt, turbo_confirm: set_prompt } do %>
65
77
  <%= hidden_field_tag :subject_gid, subject.to_gid %>
66
78
  <%= select_tag :role_id,
67
79
  options_from_collection_for_select(@roles, :id, :name, @assignments[key]&.role_id),
@@ -15,6 +15,9 @@
15
15
  <%= javascript_include_tag "current_scope/application", defer: true %>
16
16
  </head>
17
17
  <body>
18
+ <%# WCAG 2.4.1: every page repeats the sidebar + topbar before its content —
19
+ keyboard users get a bypass. Visually hidden until focused. %>
20
+ <a class="cs-skip-link" href="#cs-main-content">Skip to content</a>
18
21
  <div class="cs-shell">
19
22
  <aside class="cs-sidebar">
20
23
  <div class="cs-brand"><span class="cs-brand-mark" aria-hidden="true"></span> CurrentScope</div>
@@ -44,7 +47,7 @@
44
47
  <% if notice.present? %><p class="cs-flash cs-flash--notice" role="status"><%= notice %></p><% end %>
45
48
  <% if alert.present? %><p class="cs-flash cs-flash--alert" role="alert"><%= alert %></p><% end %>
46
49
 
47
- <main class="cs-content">
50
+ <main class="cs-content" id="cs-main-content" tabindex="-1">
48
51
  <%= yield %>
49
52
  </main>
50
53
  </div>
data/config/routes.rb CHANGED
@@ -6,9 +6,8 @@ CurrentScope::Engine.routes.draw do
6
6
  end
7
7
  resources :subjects, only: :index
8
8
  resources :events, only: :index
9
- resource :role_assignment, only: :create
10
- # Remove one org-wide assignment by id (members page: clean up an orphan whose
11
- # subject was deleted, which the subject-keyed clear on create can't target).
12
- delete "role_assignments/:id" => "role_assignments#destroy", as: :remove_role_assignment
9
+ # create is subject-keyed (no id); destroy removes one assignment by id (the
10
+ # members page's cleanup path for an orphan whose subject was deleted).
11
+ resources :role_assignments, only: [ :create, :destroy ]
13
12
  resources :scoped_role_assignments, only: [ :new, :create, :destroy ]
14
13
  end
@@ -26,7 +26,30 @@ module CurrentScope
26
26
  # record.
27
27
  # :subject — veto only on the effective subject.
28
28
  # The two are identical when not impersonating (actor == subject).
29
- attr_accessor :sod_identity
29
+ # See the validating writer below — an unknown value must not silently
30
+ # narrow the veto.
31
+ attr_reader :sod_identity
32
+
33
+ SOD_IDENTITY_MODES = %i[either subject].freeze
34
+
35
+ # Validating writer, same contract as enforcement=: the resolver compares
36
+ # `== :either`, so a typo (`:both`, `:actor`) would otherwise silently
37
+ # behave as :subject — narrowing the fraud control with no signal. Raise at
38
+ # assignment naming the closed set; the previous mode stands.
39
+ def sod_identity=(value)
40
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
41
+
42
+ unless SOD_IDENTITY_MODES.include?(mode)
43
+ raise ConfigurationError,
44
+ "config.sod_identity = #{value.inspect} is not a mode. " \
45
+ "Use :either (default — the veto binds the effective subject AND, " \
46
+ "while impersonating, the real actor, so impersonation can never " \
47
+ "approve the actor's own record) or :subject (weigh only the " \
48
+ "effective subject)."
49
+ end
50
+
51
+ @sod_identity = mode
52
+ end
30
53
 
31
54
  # When false (the default), an impersonated session is read-only: any
32
55
  # non-GET/HEAD request is denied while a real actor acts as a different
@@ -42,10 +65,11 @@ module CurrentScope
42
65
  # See the custom writer.
43
66
  attr_reader :allow_mutations_while_impersonating
44
67
 
45
- # Env var that opts a PRODUCTION deploy into impersonated writes. Any value
46
- # (even "false" or "0" presence is what counts) lifts the production
47
- # refusal; unset in production means allow_mutations_while_impersonating=true
48
- # raises at boot. development/test/staging never consult it.
68
+ # Env var that opts a PRODUCTION deploy into impersonated writes. The VALUE
69
+ # is what counts: a truthy value ("1", "true", "yes"…) lifts the production
70
+ # refusal; unset, "", "false", "0", "off" all mean "not opted in" an
71
+ # operator writing `…=false` in a deploy manifest gets what they said, not
72
+ # the opposite. development/test/staging never consult it.
49
73
  PROD_MUTATIONS_ENV = "CURRENT_SCOPE_ALLOW_PROD_IMPERSONATION_MUTATIONS"
50
74
 
51
75
  # Regexps matched against controller paths to keep infrastructure
@@ -69,6 +93,15 @@ module CurrentScope
69
93
  # - a Proc — subject -> String, e.g. ->(u) { "#{u.first_name} #{u.last_name}" }
70
94
  # - nil (default) — best-effort, people-first: email, else name, else
71
95
  # first+last, else the generic current_scope_label / "Class #id".
96
+ #
97
+ # A Proc should be TOTAL — it runs for every subject the admin can see,
98
+ # including ones with nil or blank attributes, so `->(u) { u.email.upcase }`
99
+ # is a trap the first time someone is invited but hasn't filled in an email.
100
+ # Nothing here is load-bearing enough to break a page over, so a label that
101
+ # raises (or a Symbol the subject can't answer) degrades to the default chain
102
+ # for that subject and logs once — it never errors the page and never affects
103
+ # an authorization decision. If subject labels look wrong, check the log:
104
+ # a silent fallback is exactly what a broken label looks like from the UI.
72
105
  attr_accessor :subject_label
73
106
 
74
107
  # Break-glass override for the SoD veto. Default false — OFF preserves v0.1
@@ -93,8 +126,97 @@ module CurrentScope
93
126
  # record's route key like any permission, so it's editable in the role grid —
94
127
  # never a hardcoded role. Must NOT be listed in sod_actions (it isn't an SoD
95
128
  # action; keeping it out also bounds the bypass re-entrancy).
129
+ #
130
+ # It isn't a routable action, so the catalog injects it rather than deriving
131
+ # it: the grid shows a column for it ONLY when allow_sod_bypass is on, and
132
+ # only on controllers that route an action listed in sod_actions. With the
133
+ # flag off it isn't grantable at all — nothing to tick, and the key is
134
+ # rejected if assigned. (#21)
96
135
  attr_accessor :sod_bypass_permission
97
136
 
137
+ # Action names whose RECORD-LESS gate derives its answer from the scoped
138
+ # list (#65): for these, the check asks scope_for — the same id-narrowed
139
+ # query the list renders from — so a scoped full_access grant opens
140
+ # exactly the collections that would show it records, and gate and list
141
+ # agree by construction. Matched like sod_actions, on the action segment
142
+ # of the key ("index" in "reports#index").
143
+ #
144
+ # DEFAULT ["index"] — the #65 fix is on out of the box. Set [] to restore
145
+ # the pre-#65 record-less semantics (never a released posture on their
146
+ # own — #19/#50/#65 ship together): explicit ticks still open type-bound
147
+ # record-less gates, a scoped full_access role opens none, and the
148
+ # gate/list disagreement #65 describes comes back with it.
149
+ #
150
+ # LIST-NARROWING READS ONLY. The full_access safety argument is that the
151
+ # answer is derived from record ids — WHICH records the subject holds —
152
+ # so it is only sound for actions with a list side. Naming a mutating or
153
+ # non-idempotent action here ("create", "destroy_all") hands scoped
154
+ # full_access holders that action on the whole TYPE off a grant on one
155
+ # record: the #49 escalation, reintroduced by config. Custom read-shaped
156
+ # actions (export, search) are the intended additions.
157
+ #
158
+ # The declared current_scope_model is trusted here the way
159
+ # current_scope_record already is — a wrong declaration plus a scoped
160
+ # full_access grant of the declared type opens that controller's listed
161
+ # reads, so review the declaration like the record hook.
162
+ attr_reader :collection_read_actions
163
+
164
+ # Normalizing writer, unlike sod_actions' plain accessor (that footgun is
165
+ # grandfathered, not precedent): the list is matched as STRINGS, so
166
+ # [:index] matching nothing would silently un-fix #65. Normalize rather
167
+ # than raise for shape (nil ⇒ [], symbols ⇒ strings — unambiguous), but
168
+ # two inputs get a voice, because a silently-inert or silently-widened
169
+ # security knob is the failure this writer exists to prevent:
170
+ #
171
+ # - a full KEY ("reports#index") RAISES — the list is matched on the
172
+ # action segment and applies to every controller, so a keyed member
173
+ # can never match (silently inert) and stripping it to "index" would
174
+ # silently widen a deliberately controller-scoped intent. Neither
175
+ # reading is honest; say so at assignment.
176
+ # - a canonical mutating action name (create/update/destroy) WARNS —
177
+ # the #49 escalation shape, reintroduced by config. A warning, not a
178
+ # raise: custom action names make any blocklist partial, and the
179
+ # report-mode precedent (warn_report_mode_in_production) is the
180
+ # house style for "legal but almost certainly not what you meant".
181
+ # - an element that is not a String/Symbol RAISES — Array({ index: true })
182
+ # is [[:index, true]], and .to_s on that pair is "[:index, true]": a
183
+ # member that can never match, silently REPLACING the default and
184
+ # un-fixing #65 with no signal (0.3.0 release-gate finding). Only
185
+ # action names have an unambiguous string form; anything else says so
186
+ # at assignment.
187
+ def collection_read_actions=(value)
188
+ elements = Array(value)
189
+
190
+ if (bad = elements.reject { |e| e.is_a?(String) || e.is_a?(Symbol) }).any?
191
+ raise ConfigurationError,
192
+ "config.collection_read_actions takes action NAMES (strings or symbols); got " \
193
+ "#{bad.map(&:inspect).join(', ')}. A Hash or nested array would coerce to a " \
194
+ "member that can never match an action, silently restoring the pre-#65 " \
195
+ "record-less semantics. Write e.g. %w[index export]."
196
+ end
197
+
198
+ actions = elements.map(&:to_s)
199
+
200
+ if (keyed = actions.grep(/#/)).any?
201
+ raise ConfigurationError,
202
+ "config.collection_read_actions is matched on the ACTION segment of a key and " \
203
+ "applies to every controller that routes it — #{keyed.map(&:inspect).join(', ')} " \
204
+ "can never match. Write \"index\", not \"reports#index\"; there is no " \
205
+ "per-controller form of this list."
206
+ end
207
+
208
+ warn_on_mutating_collection_reads(actions)
209
+ # Frozen so in-place mutation (config.collection_read_actions << :export)
210
+ # cannot bypass the writer — a symbol appended in place would silently
211
+ # never match, a "#" key would dodge the raise, and a mutating name would
212
+ # dodge the warning. FrozenError is loud; assign a new list instead.
213
+ @collection_read_actions = actions.freeze
214
+ end
215
+
216
+ # The three canonical Rails write actions — the unambiguous slice of the
217
+ # mutating-action space the writer above warns about.
218
+ MUTATING_ACTION_NAMES = %w[create update destroy].freeze
219
+
98
220
  # Tri-state: false | true (default) | :strict — controls
99
221
  # CurrentScope::Event.record!.
100
222
  # false — record! is a silent no-op; hosts that don't want the ledger
@@ -108,16 +230,91 @@ module CurrentScope
108
230
  # boundary events have no mutation to roll back — a raise there
109
231
  # is a loud 500 on a mis-migrated host.)
110
232
  # Read as `== :strict`, never `== true` — don't flatten the tri-state.
111
- attr_accessor :audit
112
-
113
- # A5 (opt-in, dev/test aid): when true, the gate logs a nudge if an SoD
114
- # action is gated with a nil record and the request is allowed — i.e. the
115
- # SoD veto was silently skipped because current_scope_record returned nil on
116
- # a member action. Off by default; prod behavior never changes. Emitted from
117
- # the Guard seam (not the shared resolver), so it doesn't fire on advisory
118
- # allowed_to?/scope_for calls.
233
+ # See the validating writer below — a misspelled :strict must not silently
234
+ # downgrade an audit-mandatory host to best-effort.
235
+ attr_reader :audit
236
+
237
+ AUDIT_MODES = [ false, true, :strict ].freeze
238
+
239
+ # Validating writer, same contract as enforcement=: because record! checks
240
+ # `== :strict`, a typo (`:strixt`) is truthy and would silently behave as
241
+ # plain true — the host believes unaudited mutations roll back while they
242
+ # commit. Raise at assignment naming the closed set; the previous mode
243
+ # stands.
244
+ def audit=(value)
245
+ # ENV can only carry strings, and two of the three modes are booleans —
246
+ # normalize the boolean spellings before symbolizing ("strict" → :strict).
247
+ mode = case value
248
+ when "true" then true
249
+ when "false" then false
250
+ else value.respond_to?(:to_sym) ? value.to_sym : value
251
+ end
252
+
253
+ unless AUDIT_MODES.include?(mode)
254
+ raise ConfigurationError,
255
+ "config.audit = #{value.inspect} is not a mode. " \
256
+ "Use false (record! is a silent no-op — no ledger), " \
257
+ "true (append a row per event, degrade gracefully if the events " \
258
+ "table is missing), or :strict (a missing events table RAISES so " \
259
+ "a mutation-wrapping transaction rolls back rather than " \
260
+ "committing an unaudited grant)."
261
+ end
262
+
263
+ @audit = mode
264
+ end
265
+
266
+ # --- Dev diagnostics (#41) ----------------------------------------------
267
+ #
268
+ # Four failure modes this engine has that are SILENT and silent in the bad
269
+ # direction: the thing that went wrong looks exactly like the thing going
270
+ # right. Each is a cheap log line in dev/test and costs nothing in prod.
271
+ #
272
+ # All four default ON in development and test, OFF in production — and off
273
+ # entirely when Rails isn't loaded. The default is the point: a diagnostic
274
+ # nobody knows about is a diagnostic nobody benefits from, and these
275
+ # protect against mistakes you make while WRITING the app, which is exactly
276
+ # when dev/test is where you are. A host can force any of them either way.
277
+ #
278
+ # Every one is LOG-ONLY. No decision, exception, header, or audit row
279
+ # changes because of them, in any environment.
280
+
281
+ # The gate logs a nudge when an SoD action is gated with no record and the
282
+ # request is ALLOWED — i.e. the veto was skipped because current_scope_record
283
+ # returned nil (or was never declared) on a member action. The gem's #1
284
+ # foot-gun: the veto silently not running looks identical to the veto passing.
285
+ #
286
+ # Emitted from the Guard seam (not the shared resolver), so it never fires on
287
+ # advisory allowed_to?/scope_for calls.
119
288
  attr_accessor :warn_on_nil_sod_record
120
289
 
290
+ # The gate logs a nudge when a request is DENIED :no_grant, the controller
291
+ # declared no current_scope_record hook at all, and the subject holds a
292
+ # scoped grant that WOULD have applied had the hook returned the record.
293
+ #
294
+ # That combination is a controller with member actions that forgot the hook.
295
+ # It fails closed — correctly — but the 403 is indistinguishable from "you
296
+ # were never granted this", so the person debugging it goes looking at their
297
+ # grants, which are fine, instead of at their controller, which isn't.
298
+ attr_accessor :warn_on_inert_scoped_grant
299
+
300
+ # CurrentScope.permission_key logs a nudge when short-form derivation
301
+ # (`allowed_to?(:show, report)`) resolves to a DIFFERENT key than the one the
302
+ # current controller's gate enforces — the documented namespaced/custom-named
303
+ # controller foot-gun. The view then shows a link that 403s, or hides one that
304
+ # would have worked: the gate and the view disagree, silently, and the symptom
305
+ # shows up nowhere near the cause.
306
+ attr_accessor :warn_on_cross_controller_derivation
307
+
308
+ # The gate logs a nudge when a request is DENIED :model_undeclared — a
309
+ # declared collection action (current_scope_record returned nil) whose
310
+ # controller names no current_scope_model, while the subject holds a scoped
311
+ # grant ticking the key. The record-less branch had no type to bind that
312
+ # grant to, so it failed closed (#50) — correctly, but the 403 looks like
313
+ # "never granted" while the fix is one line in the controller. The same
314
+ # flag gates the :model_invalid nudge — the hook was declared but returned
315
+ # something unusable ("Report" for Report); one failure family, one knob.
316
+ attr_accessor :warn_on_undeclared_collection_model
317
+
121
318
  # How the role-editor grid folds RESTful actions into columns. An ordered
122
319
  # Hash of { column_label => [action names] }: ticking a group column grants
123
320
  # every routed action in it. The default collapses the seven RESTful verbs
@@ -129,6 +326,84 @@ module CurrentScope
129
326
  # actions shows a blank cell, never a shifted one.
130
327
  attr_accessor :permission_grid_groups
131
328
 
329
+ # :enforce (default) | :report — what the gate DOES with a denial. (#37)
330
+ #
331
+ # The adoption ramp. A host retrofitting this engine onto an existing app
332
+ # has a controller suite that goes red the moment the gate is mounted: it is
333
+ # fail-closed, and nothing is granted yet. Report mode lets them mount the
334
+ # gate, run their app, and read what WOULD have been denied out of the
335
+ # ledger — turning a big-bang cutover into a list of grants to seed.
336
+ #
337
+ # :enforce — deny means 403. The only production posture.
338
+ # :report — a MISSING GRANT is logged and allowed through instead. Every
339
+ # other denial still refuses.
340
+ #
341
+ # Report mode is NOT an off switch and never reaches the things that would
342
+ # make it one: the separation-of-duties veto still refuses, and the
343
+ # management console answers to its own full-access check rather than this
344
+ # gate, so no enforcement setting can hand out the UI where grants are made.
345
+ attr_reader :enforcement
346
+
347
+ ENFORCEMENT_MODES = %i[enforce report].freeze
348
+
349
+ # Validating writer: an unknown value here is the worst kind of config
350
+ # mistake — the host believes it is enforcing while it is not. Fail at boot,
351
+ # naming what's allowed. Accepts a String so ENV["..."] works; anything that
352
+ # can't be a mode (nil from an unset ENV var, a number, a collection) raises
353
+ # ConfigurationError rather than NoMethodError, and the previous mode stands.
354
+ def enforcement=(value)
355
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
356
+
357
+ unless ENFORCEMENT_MODES.include?(mode)
358
+ raise ConfigurationError,
359
+ "config.enforcement = #{value.inspect} is not a mode. " \
360
+ "Use :enforce (deny means 403 — the production posture) or " \
361
+ ":report (log what WOULD be denied and allow it through, to seed " \
362
+ "grants before cutting over). Report mode is for adoption only: " \
363
+ "it never lifts the separation-of-duties veto and never opens the " \
364
+ "management console."
365
+ end
366
+
367
+ warn_report_mode_in_production if mode == :report && production?
368
+ @enforcement = mode
369
+ end
370
+
371
+ # True only in report mode. A predicate, so callers can't express "not
372
+ # enforcing" — the modes are a closed set, not a boolean.
373
+ def report_only? = @enforcement == :report
374
+
375
+ # :raise | :warn — what the opt-in GatingTripwire mixin (A4) does when it
376
+ # catches an action that completed without running the gate.
377
+ #
378
+ # :raise — fail loudly. Default in development/test: an ungated action is
379
+ # a hole you want CI to go red on, not one to discover in an audit.
380
+ # :warn — log once per controller#action and let the response through, so
381
+ # a real app can inventory its ungated surface without 500ing.
382
+ #
383
+ # A closed two-mode set, like enforcement — not a boolean, and no :off:
384
+ # not including the mixin is off.
385
+ attr_reader :gating_tripwire
386
+
387
+ GATING_TRIPWIRE_MODES = %i[raise warn].freeze
388
+
389
+ # Validating writer, same contract as enforcement=: an unknown value raises
390
+ # at assignment naming both modes, and the previous mode stands. Accepts a
391
+ # String so ENV["..."] works.
392
+ def gating_tripwire=(value)
393
+ mode = value.respond_to?(:to_sym) ? value.to_sym : value
394
+
395
+ unless GATING_TRIPWIRE_MODES.include?(mode)
396
+ raise ConfigurationError,
397
+ "config.gating_tripwire = #{value.inspect} is not a mode. " \
398
+ "Use :raise (an ungated action fails loudly — the dev/test posture) " \
399
+ "or :warn (log it once per controller#action and let the response " \
400
+ "through, to inventory an app's ungated surface). There is no :off — " \
401
+ "not including CurrentScope::GatingTripwire is off."
402
+ end
403
+
404
+ @gating_tripwire = mode
405
+ end
406
+
132
407
  def initialize
133
408
  @user_method = :current_user
134
409
  @actor_method = nil
@@ -136,6 +411,7 @@ module CurrentScope
136
411
  @sod_identity = :either
137
412
  @allow_sod_bypass = false
138
413
  @sod_bypass_permission = "bypass_sod"
414
+ @collection_read_actions = [ "index" ].freeze
139
415
  @allow_mutations_while_impersonating = false
140
416
  @excluded_controllers = [
141
417
  %r{\Arails/}, %r{\Aactive_storage/}, %r{\Aaction_mailbox/},
@@ -144,7 +420,18 @@ module CurrentScope
144
420
  @parent_controller = "::ApplicationController"
145
421
  @subject_class = "User"
146
422
  @audit = true
147
- @warn_on_nil_sod_record = false
423
+ @enforcement = :enforce
424
+ # Reuses diagnostics_default_on? for its env split and bare-Ruby safety
425
+ # ONLY — do NOT carry over the diagnostics flags' emit/silence reading.
426
+ # There, false means stay quiet; here, false means :warn, which EMITS.
427
+ # The inversion is deliberate: the tripwire mixin is opt-in, so a
428
+ # production host that included it is asking for the ungated inventory —
429
+ # the env decides only whether a hit 500s (dev/test) or logs (elsewhere).
430
+ @gating_tripwire = diagnostics_default_on? ? :raise : :warn
431
+ @warn_on_nil_sod_record = diagnostics_default_on?
432
+ @warn_on_inert_scoped_grant = diagnostics_default_on?
433
+ @warn_on_cross_controller_derivation = diagnostics_default_on?
434
+ @warn_on_undeclared_collection_model = diagnostics_default_on?
148
435
  @permission_grid_groups = {
149
436
  "read" => %w[index show],
150
437
  "create" => %w[new create],
@@ -159,7 +446,7 @@ module CurrentScope
159
446
  # silently letting a real actor mutate data as someone else. Assigning false
160
447
  # (or leaving the default) is always allowed.
161
448
  def allow_mutations_while_impersonating=(value)
162
- if value && production? && !ENV.key?(PROD_MUTATIONS_ENV)
449
+ if value && production? && !prod_mutations_opt_in?
163
450
  raise ConfigurationError,
164
451
  "config.allow_mutations_while_impersonating = true is refused in " \
165
452
  "production: letting a real actor write as the subject they " \
@@ -167,13 +454,88 @@ module CurrentScope
167
454
  "Keep it false so impersonated sessions stay read-only in " \
168
455
  "production, or — only if writes under act-as are genuinely " \
169
456
  "required (e.g. a live public showcase) — set ENV[\"#{PROD_MUTATIONS_ENV}\"] " \
170
- "to opt in explicitly. development, test, and staging are unaffected."
457
+ "to a truthy value (\"1\"/\"true\") to opt in explicitly; \"false\", " \
458
+ "\"0\", and empty mean not opted in. development, test, and staging " \
459
+ "are unaffected."
171
460
  end
172
461
  @allow_mutations_while_impersonating = value
173
462
  end
174
463
 
175
464
  private
176
465
 
466
+ # The env var's VALUE means what it says — presence alone is not consent.
467
+ # ActiveModel's boolean cast maps "false"/"0"/"f"/"off" to false and "" to
468
+ # nil; anything else present is truthy. Only reached when production? is
469
+ # already true, so Rails (and ActiveModel) are loaded — a bare-Ruby
470
+ # Configuration.new never gets here.
471
+ def prod_mutations_opt_in?
472
+ !!ActiveModel::Type::Boolean.new.cast(ENV[PROD_MUTATIONS_ENV])
473
+ end
474
+
475
+ # Report mode in production is DELIBERATELY allowed — surveying real traffic
476
+ # is the most honest way to find out what to grant, and a staging run won't
477
+ # show you the flows your actual users take. Refusing it here would break the
478
+ # feature's best use case.
479
+ #
480
+ # But "we are not enforcing authorization" is not a state a production app
481
+ # should be in silently, or for long. The failure mode is quiet: nothing
482
+ # breaks, no one is refused, and the temporary survey becomes the permanent
483
+ # posture because nothing ever reminded anyone. So it says so at boot, once,
484
+ # where a deploy log will keep it.
485
+ #
486
+ # ponytail: a warn, not a raise. Unlike allow_mutations_while_impersonating
487
+ # (which has an env-gate refusal) this has a legitimate production use and a
488
+ # deliberate exit — the host is mid-migration, and the loud reminder is the
489
+ # right amount of friction.
490
+ def warn_report_mode_in_production
491
+ message = "[CurrentScope] config.enforcement = :report in PRODUCTION — " \
492
+ "authorization is NOT being enforced. Missing grants are logged " \
493
+ "as access.would_deny and the request is ALLOWED THROUGH. This is " \
494
+ "an adoption ramp, not a production posture: seed the grants the " \
495
+ "ledger names, then set config.enforcement = :enforce."
496
+
497
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
498
+ Rails.logger.warn(message)
499
+ else
500
+ # Boot order: an initializer can run before the logger exists. Say it
501
+ # anyway — a warning nobody sees is the thing this method exists to
502
+ # prevent.
503
+ warn(message)
504
+ end
505
+ end
506
+
507
+ # Mirrors warn_report_mode_in_production: a legal posture that is almost
508
+ # never what the host meant says so once, loudly, where a boot log keeps
509
+ # it. Warn in every env — unlike report mode this misconfiguration is as
510
+ # wrong in development as in production.
511
+ def warn_on_mutating_collection_reads(actions)
512
+ mutating = actions & MUTATING_ACTION_NAMES
513
+ return if mutating.empty?
514
+
515
+ message = "[CurrentScope] config.collection_read_actions includes " \
516
+ "#{mutating.map(&:inspect).join(', ')} — this list is for LIST-NARROWING READS. " \
517
+ "A mutating action here hands scoped full_access holders that action on every " \
518
+ "record of its type off a grant on ONE record (the #49 escalation shape). " \
519
+ "Remove it unless that is genuinely intended."
520
+
521
+ if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
522
+ Rails.logger.warn(message)
523
+ else
524
+ warn(message)
525
+ end
526
+ end
527
+
528
+ # Diagnostics are on where you are while writing the app, off where they'd
529
+ # be noise on someone else's dime. `local?` is Rails' own name for
530
+ # "development or test", so a host with a staging env that reports itself as
531
+ # neither gets prod behaviour — the conservative side for a log line.
532
+ #
533
+ # Mirrors the production? guard below: a bare-Ruby Configuration.new with no
534
+ # Rails must not raise, and gets false (no logger to warn to anyway).
535
+ def diagnostics_default_on?
536
+ defined?(Rails) && Rails.respond_to?(:env) && Rails.env.local?
537
+ end
538
+
177
539
  def production?
178
540
  defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
179
541
  end
@@ -9,6 +9,13 @@ module CurrentScope
9
9
  config.to_prepare do
10
10
  CurrentScope.reset_catalog!
11
11
  CurrentScope.reset_scopeable_registry!
12
+ # The cross-controller nudge warns once per site; a reload can change what's
13
+ # routed, so a stale latch would hide a divergence the edit just created.
14
+ CurrentScope.reset_cross_controller_warnings!
15
+ # Same reason for the tripwire's :warn latch: a reload can change whether a
16
+ # controller#action is gated, and a stale latch would hand a dev running
17
+ # :warn a false all-clear right after the edit.
18
+ CurrentScope::GatingTripwire.reset_warnings!
12
19
  end
13
20
  end
14
21
  end
@@ -0,0 +1,62 @@
1
+ module CurrentScope
2
+ # Answers one question about a controller path: is it PROVEN that
3
+ # current_scope_check! never runs there? True only when the callback is
4
+ # ABSENT from the class's callback chain — the one thing the chain states
5
+ # unconditionally. Everything else is false:
6
+ #
7
+ # - callback present and unconditional → gated → false
8
+ # - callback present wearing a condition (skip_before_action only:) →
9
+ # UNPROVABLE without evaluating ActionFilter internals, which this class
10
+ # must never do (KTD-3) → false. The naive any?/none? presence check
11
+ # inverts exactly here — see the ProblemFrame characterization in
12
+ # test/gating_reflection_test.rb — and per-action guessing is how a
13
+ # fail-open gets reported as gated.
14
+ # - no controller class routed at that path → nothing to prove → false
15
+ #
16
+ # Absence is an EFFECT, not a cause: a bare skip_before_action, an inherited
17
+ # bare skip (#62), and never including Guard at all are the same verdict,
18
+ # because the chain looks the same. That is deliberate — the question is
19
+ # "does the gate run?", not "why not?".
20
+ #
21
+ # Named beside GatingTripwire: both interrogate the same subject (is this
22
+ # action gated?), the tripwire at request time, this one by reflection.
23
+ class GatingReflection
24
+ def ungated?(controller_path)
25
+ klass = controller_class_for(controller_path)
26
+ # A controller without the callbacks API at all (a bare ActionController::
27
+ # Metal without AbstractController::Callbacks) cannot run a before_action
28
+ # — the gate PROVABLY never runs there. Without this check the reflection
29
+ # would raise NoMethodError instead of answering. (#79 review)
30
+ return true unless klass.respond_to?(:_process_action_callbacks)
31
+
32
+ klass
33
+ ._process_action_callbacks
34
+ .none? { |callback| callback.kind == :before && callback.filter == :current_scope_check! }
35
+ rescue ActionDispatch::MissingController
36
+ # A routed path with no controller class (a scaffolding leftover, a
37
+ # not-yet-written controller) proves nothing about gating. Silent false,
38
+ # matching the prove-or-stay-silent discipline of
39
+ # warn_on_cross_controller_derivation.
40
+ false
41
+ end
42
+
43
+ private
44
+
45
+ # Rails owns the path→class rule — camelize, namespacing, the Controller
46
+ # suffix, and crucially the NameError triage: a missing CONTROLLER constant
47
+ # becomes MissingController (rescued above), while a NameError raised from
48
+ # inside the controller's own broken body re-raises as-is and must
49
+ # PROPAGATE out of ungated? (a blanket rescue NameError would silently
50
+ # report a broken controller as gated). Hand-rolling camelize+constantize
51
+ # would have to reimplement that triage and would drift. (KTD-2)
52
+ #
53
+ # controller_class_for lives on Request, so a throwaway request object is
54
+ # the price of asking. Built lazily and memoized here, NEVER in initialize:
55
+ # the role-save path constructs a GatingReflection it may never ask — any
56
+ # failure in construction would 500 role saves. (KTD-8)
57
+ def controller_class_for(controller_path)
58
+ @request ||= ActionDispatch::Request.new({})
59
+ @request.controller_class_for(controller_path)
60
+ end
61
+ end
62
+ end