current_scope 0.2.0 → 0.3.1
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.
- checksums.yaml +4 -4
- data/README.md +317 -18
- data/app/assets/javascripts/current_scope/application.js +4 -0
- data/app/assets/stylesheets/current_scope/application.css +101 -0
- data/app/controllers/current_scope/application_controller.rb +39 -1
- data/app/controllers/current_scope/role_assignments_controller.rb +110 -25
- data/app/controllers/current_scope/roles_controller.rb +130 -32
- data/app/helpers/current_scope/application_helper.rb +168 -12
- data/app/models/current_scope/current.rb +24 -0
- data/app/models/current_scope/event.rb +10 -6
- data/app/models/current_scope/role.rb +61 -10
- data/app/views/current_scope/roles/edit.html.erb +92 -4
- data/app/views/current_scope/roles/index.html.erb +16 -2
- data/app/views/current_scope/roles/members.html.erb +3 -3
- data/app/views/current_scope/roles/new.html.erb +1 -1
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +19 -10
- data/app/views/current_scope/shared/access_denied.html.erb +30 -0
- data/app/views/current_scope/subjects/index.html.erb +21 -7
- data/app/views/layouts/current_scope/application.html.erb +4 -1
- data/config/routes.rb +3 -4
- data/lib/current_scope/configuration.rb +411 -18
- data/lib/current_scope/engine.rb +7 -0
- data/lib/current_scope/gating_reflection.rb +62 -0
- data/lib/current_scope/gating_tripwire.rb +36 -5
- data/lib/current_scope/guard.rb +410 -10
- data/lib/current_scope/mutation_guard.rb +30 -5
- data/lib/current_scope/permission_catalog.rb +116 -3
- data/lib/current_scope/permission_grid.rb +34 -4
- data/lib/current_scope/permissions.rb +45 -8
- data/lib/current_scope/resolver.rb +317 -13
- data/lib/current_scope/version.rb +1 -1
- data/lib/current_scope.rb +113 -5
- data/lib/generators/current_scope/install/install_generator.rb +64 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +94 -5
- data/lib/tasks/current_scope_tasks.rake +153 -0
- metadata +6 -2
|
@@ -17,7 +17,11 @@ module CurrentScope
|
|
|
17
17
|
# EMPTY BY DEFAULT — SoD is opt-in. The engine's baseline is scoped RBAC;
|
|
18
18
|
# many hosts want nothing to do with four-eyes. Enable it by listing the
|
|
19
19
|
# actions to gate, e.g. `config.sod_actions = %w[approve]`.
|
|
20
|
-
|
|
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
|
|
21
25
|
|
|
22
26
|
# Which identities the separation-of-duties veto weighs:
|
|
23
27
|
# :either (default) — veto if the effective subject OR (while
|
|
@@ -26,7 +30,56 @@ module CurrentScope
|
|
|
26
30
|
# record.
|
|
27
31
|
# :subject — veto only on the effective subject.
|
|
28
32
|
# The two are identical when not impersonating (actor == subject).
|
|
29
|
-
|
|
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
|
|
30
83
|
|
|
31
84
|
# When false (the default), an impersonated session is read-only: any
|
|
32
85
|
# non-GET/HEAD request is denied while a real actor acts as a different
|
|
@@ -42,10 +95,11 @@ module CurrentScope
|
|
|
42
95
|
# See the custom writer.
|
|
43
96
|
attr_reader :allow_mutations_while_impersonating
|
|
44
97
|
|
|
45
|
-
# Env var that opts a PRODUCTION deploy into impersonated writes.
|
|
46
|
-
#
|
|
47
|
-
# refusal; unset in
|
|
48
|
-
#
|
|
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.
|
|
49
103
|
PROD_MUTATIONS_ENV = "CURRENT_SCOPE_ALLOW_PROD_IMPERSONATION_MUTATIONS"
|
|
50
104
|
|
|
51
105
|
# Regexps matched against controller paths to keep infrastructure
|
|
@@ -69,6 +123,15 @@ module CurrentScope
|
|
|
69
123
|
# - a Proc — subject -> String, e.g. ->(u) { "#{u.first_name} #{u.last_name}" }
|
|
70
124
|
# - nil (default) — best-effort, people-first: email, else name, else
|
|
71
125
|
# first+last, else the generic current_scope_label / "Class #id".
|
|
126
|
+
#
|
|
127
|
+
# A Proc should be TOTAL — it runs for every subject the admin can see,
|
|
128
|
+
# including ones with nil or blank attributes, so `->(u) { u.email.upcase }`
|
|
129
|
+
# is a trap the first time someone is invited but hasn't filled in an email.
|
|
130
|
+
# Nothing here is load-bearing enough to break a page over, so a label that
|
|
131
|
+
# raises (or a Symbol the subject can't answer) degrades to the default chain
|
|
132
|
+
# for that subject and logs once — it never errors the page and never affects
|
|
133
|
+
# an authorization decision. If subject labels look wrong, check the log:
|
|
134
|
+
# a silent fallback is exactly what a broken label looks like from the UI.
|
|
72
135
|
attr_accessor :subject_label
|
|
73
136
|
|
|
74
137
|
# Break-glass override for the SoD veto. Default false — OFF preserves v0.1
|
|
@@ -93,8 +156,98 @@ module CurrentScope
|
|
|
93
156
|
# record's route key like any permission, so it's editable in the role grid —
|
|
94
157
|
# never a hardcoded role. Must NOT be listed in sod_actions (it isn't an SoD
|
|
95
158
|
# action; keeping it out also bounds the bypass re-entrancy).
|
|
159
|
+
#
|
|
160
|
+
# It isn't a routable action, so the catalog injects it rather than deriving
|
|
161
|
+
# it: the grid shows a column for it ONLY when allow_sod_bypass is on, and
|
|
162
|
+
# only on controllers that route an action listed in sod_actions. With the
|
|
163
|
+
# flag off it isn't grantable at all — nothing to tick, and the key is
|
|
164
|
+
# rejected if assigned. (#21)
|
|
96
165
|
attr_accessor :sod_bypass_permission
|
|
97
166
|
|
|
167
|
+
# Action names whose RECORD-LESS gate derives its answer from the scoped
|
|
168
|
+
# list (#65): for these, the check asks scope_for — the same id-narrowed
|
|
169
|
+
# query the list renders from — so a scoped full_access grant opens
|
|
170
|
+
# exactly the collections that would show it records, and gate and list
|
|
171
|
+
# agree by construction. Matched like sod_actions, on the action segment
|
|
172
|
+
# of the key ("index" in "reports#index").
|
|
173
|
+
#
|
|
174
|
+
# DEFAULT ["index"] — the #65 fix is on out of the box. Set [] to restore
|
|
175
|
+
# the pre-#65 record-less semantics (never a released posture on their
|
|
176
|
+
# own — #19/#50/#65 ship together): explicit ticks still open type-bound
|
|
177
|
+
# record-less gates, a scoped full_access role opens none, and the
|
|
178
|
+
# gate/list disagreement #65 describes comes back with it.
|
|
179
|
+
#
|
|
180
|
+
# LIST-NARROWING READS ONLY. The full_access safety argument is that the
|
|
181
|
+
# answer is derived from record ids — WHICH records the subject holds —
|
|
182
|
+
# so it is only sound for actions with a list side. Naming a mutating or
|
|
183
|
+
# non-idempotent action here ("create", "destroy_all") hands scoped
|
|
184
|
+
# full_access holders that action on the whole TYPE off a grant on one
|
|
185
|
+
# record: the #49 escalation, reintroduced by config. Custom read-shaped
|
|
186
|
+
# actions (export, search) are the intended additions.
|
|
187
|
+
#
|
|
188
|
+
# The declared current_scope_model is trusted here the way
|
|
189
|
+
# current_scope_record already is — a wrong declaration plus a scoped
|
|
190
|
+
# full_access grant of the declared type opens that controller's listed
|
|
191
|
+
# reads, so review the declaration like the record hook.
|
|
192
|
+
attr_reader :collection_read_actions
|
|
193
|
+
|
|
194
|
+
# Normalizing writer: the list is matched as STRINGS, so [:index] matching
|
|
195
|
+
# nothing would silently un-fix #65. Normalize rather than raise for shape
|
|
196
|
+
# (nil ⇒ [], symbols ⇒ strings — unambiguous), but two inputs get a voice,
|
|
197
|
+
# because a silently-inert or silently-widened security knob is the failure
|
|
198
|
+
# this writer exists to prevent:
|
|
199
|
+
#
|
|
200
|
+
# - a full KEY ("reports#index") RAISES — the list is matched on the
|
|
201
|
+
# action segment and applies to every controller, so a keyed member
|
|
202
|
+
# can never match (silently inert) and stripping it to "index" would
|
|
203
|
+
# silently widen a deliberately controller-scoped intent. Neither
|
|
204
|
+
# reading is honest; say so at assignment.
|
|
205
|
+
# - a canonical mutating action name (create/update/destroy) WARNS —
|
|
206
|
+
# the #49 escalation shape, reintroduced by config. A warning, not a
|
|
207
|
+
# raise: custom action names make any blocklist partial, and the
|
|
208
|
+
# report-mode precedent (warn_report_mode_in_production) is the
|
|
209
|
+
# house style for "legal but almost certainly not what you meant".
|
|
210
|
+
# - an element that is not a String/Symbol RAISES — Array({ index: true })
|
|
211
|
+
# is [[:index, true]], and .to_s on that pair is "[:index, true]": a
|
|
212
|
+
# member that can never match, silently REPLACING the default and
|
|
213
|
+
# un-fixing #65 with no signal (0.3.0 release-gate finding). Only
|
|
214
|
+
# action names have an unambiguous string form; anything else says so
|
|
215
|
+
# at assignment.
|
|
216
|
+
def collection_read_actions=(value)
|
|
217
|
+
elements = Array(value)
|
|
218
|
+
|
|
219
|
+
if (bad = elements.reject { |e| e.is_a?(String) || e.is_a?(Symbol) }).any?
|
|
220
|
+
raise ConfigurationError,
|
|
221
|
+
"config.collection_read_actions takes action NAMES (strings or symbols); got " \
|
|
222
|
+
"#{bad.map(&:inspect).join(', ')}. A Hash or nested array would coerce to a " \
|
|
223
|
+
"member that can never match an action, silently restoring the pre-#65 " \
|
|
224
|
+
"record-less semantics. Write e.g. %w[index export]."
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
actions = elements.map(&:to_s)
|
|
228
|
+
|
|
229
|
+
if (keyed = actions.grep(/#/)).any?
|
|
230
|
+
raise ConfigurationError,
|
|
231
|
+
"config.collection_read_actions is matched on the ACTION segment of a key and " \
|
|
232
|
+
"applies to every controller that routes it — #{keyed.map(&:inspect).join(', ')} " \
|
|
233
|
+
"can never match. Write \"index\", not \"reports#index\"; there is no " \
|
|
234
|
+
"per-controller form of this list."
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
warn_on_mutating_collection_reads(actions)
|
|
238
|
+
# Frozen so in-place mutation (config.collection_read_actions << :export)
|
|
239
|
+
# cannot bypass the writer — a symbol appended in place would silently
|
|
240
|
+
# never match, a "#" key would dodge the raise, and a mutating name would
|
|
241
|
+
# dodge the warning. FrozenError is loud; assign a new list instead.
|
|
242
|
+
@collection_read_actions = actions.freeze
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Canonical mutating / bulk-write action names the collection_read writer
|
|
246
|
+
# warns about. Includes destroy_all/update_all — the docs' escalation
|
|
247
|
+
# examples — so those cannot reintroduce #49 via config with no signal.
|
|
248
|
+
# Custom names still evade any partial blocklist; that ceiling is deliberate.
|
|
249
|
+
MUTATING_ACTION_NAMES = %w[create update destroy destroy_all update_all].freeze
|
|
250
|
+
|
|
98
251
|
# Tri-state: false | true (default) | :strict — controls
|
|
99
252
|
# CurrentScope::Event.record!.
|
|
100
253
|
# false — record! is a silent no-op; hosts that don't want the ledger
|
|
@@ -108,16 +261,91 @@ module CurrentScope
|
|
|
108
261
|
# boundary events have no mutation to roll back — a raise there
|
|
109
262
|
# is a loud 500 on a mis-migrated host.)
|
|
110
263
|
# Read as `== :strict`, never `== true` — don't flatten the tri-state.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
#
|
|
118
|
-
#
|
|
264
|
+
# See the validating writer below — a misspelled :strict must not silently
|
|
265
|
+
# downgrade an audit-mandatory host to best-effort.
|
|
266
|
+
attr_reader :audit
|
|
267
|
+
|
|
268
|
+
AUDIT_MODES = [ false, true, :strict ].freeze
|
|
269
|
+
|
|
270
|
+
# Validating writer, same contract as enforcement=: because record! checks
|
|
271
|
+
# `== :strict`, a typo (`:strixt`) is truthy and would silently behave as
|
|
272
|
+
# plain true — the host believes unaudited mutations roll back while they
|
|
273
|
+
# commit. Raise at assignment naming the closed set; the previous mode
|
|
274
|
+
# stands.
|
|
275
|
+
def audit=(value)
|
|
276
|
+
# ENV can only carry strings, and two of the three modes are booleans —
|
|
277
|
+
# normalize the boolean spellings before symbolizing ("strict" → :strict).
|
|
278
|
+
mode = case value
|
|
279
|
+
when "true" then true
|
|
280
|
+
when "false" then false
|
|
281
|
+
else value.respond_to?(:to_sym) ? value.to_sym : value
|
|
282
|
+
end
|
|
283
|
+
|
|
284
|
+
unless AUDIT_MODES.include?(mode)
|
|
285
|
+
raise ConfigurationError,
|
|
286
|
+
"config.audit = #{value.inspect} is not a mode. " \
|
|
287
|
+
"Use false (record! is a silent no-op — no ledger), " \
|
|
288
|
+
"true (append a row per event, degrade gracefully if the events " \
|
|
289
|
+
"table is missing), or :strict (a missing events table RAISES so " \
|
|
290
|
+
"a mutation-wrapping transaction rolls back rather than " \
|
|
291
|
+
"committing an unaudited grant)."
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
@audit = mode
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
# --- Dev diagnostics (#41) ----------------------------------------------
|
|
298
|
+
#
|
|
299
|
+
# Four failure modes this engine has that are SILENT and silent in the bad
|
|
300
|
+
# direction: the thing that went wrong looks exactly like the thing going
|
|
301
|
+
# right. Each is a cheap log line in dev/test and costs nothing in prod.
|
|
302
|
+
#
|
|
303
|
+
# All four default ON in development and test, OFF in production — and off
|
|
304
|
+
# entirely when Rails isn't loaded. The default is the point: a diagnostic
|
|
305
|
+
# nobody knows about is a diagnostic nobody benefits from, and these
|
|
306
|
+
# protect against mistakes you make while WRITING the app, which is exactly
|
|
307
|
+
# when dev/test is where you are. A host can force any of them either way.
|
|
308
|
+
#
|
|
309
|
+
# Every one is LOG-ONLY. No decision, exception, header, or audit row
|
|
310
|
+
# changes because of them, in any environment.
|
|
311
|
+
|
|
312
|
+
# The gate logs a nudge when an SoD action is gated with no record and the
|
|
313
|
+
# request is ALLOWED — i.e. the veto was skipped because current_scope_record
|
|
314
|
+
# returned nil (or was never declared) on a member action. The gem's #1
|
|
315
|
+
# foot-gun: the veto silently not running looks identical to the veto passing.
|
|
316
|
+
#
|
|
317
|
+
# Emitted from the Guard seam (not the shared resolver), so it never fires on
|
|
318
|
+
# advisory allowed_to?/scope_for calls.
|
|
119
319
|
attr_accessor :warn_on_nil_sod_record
|
|
120
320
|
|
|
321
|
+
# The gate logs a nudge when a request is DENIED :no_grant, the controller
|
|
322
|
+
# declared no current_scope_record hook at all, and the subject holds a
|
|
323
|
+
# scoped grant that WOULD have applied had the hook returned the record.
|
|
324
|
+
#
|
|
325
|
+
# That combination is a controller with member actions that forgot the hook.
|
|
326
|
+
# It fails closed — correctly — but the 403 is indistinguishable from "you
|
|
327
|
+
# were never granted this", so the person debugging it goes looking at their
|
|
328
|
+
# grants, which are fine, instead of at their controller, which isn't.
|
|
329
|
+
attr_accessor :warn_on_inert_scoped_grant
|
|
330
|
+
|
|
331
|
+
# CurrentScope.permission_key logs a nudge when short-form derivation
|
|
332
|
+
# (`allowed_to?(:show, report)`) resolves to a DIFFERENT key than the one the
|
|
333
|
+
# current controller's gate enforces — the documented namespaced/custom-named
|
|
334
|
+
# controller foot-gun. The view then shows a link that 403s, or hides one that
|
|
335
|
+
# would have worked: the gate and the view disagree, silently, and the symptom
|
|
336
|
+
# shows up nowhere near the cause.
|
|
337
|
+
attr_accessor :warn_on_cross_controller_derivation
|
|
338
|
+
|
|
339
|
+
# The gate logs a nudge when a request is DENIED :model_undeclared — a
|
|
340
|
+
# declared collection action (current_scope_record returned nil) whose
|
|
341
|
+
# controller names no current_scope_model, while the subject holds a scoped
|
|
342
|
+
# grant ticking the key. The record-less branch had no type to bind that
|
|
343
|
+
# grant to, so it failed closed (#50) — correctly, but the 403 looks like
|
|
344
|
+
# "never granted" while the fix is one line in the controller. The same
|
|
345
|
+
# flag gates the :model_invalid nudge — the hook was declared but returned
|
|
346
|
+
# something unusable ("Report" for Report); one failure family, one knob.
|
|
347
|
+
attr_accessor :warn_on_undeclared_collection_model
|
|
348
|
+
|
|
121
349
|
# How the role-editor grid folds RESTful actions into columns. An ordered
|
|
122
350
|
# Hash of { column_label => [action names] }: ticking a group column grants
|
|
123
351
|
# every routed action in it. The default collapses the seven RESTful verbs
|
|
@@ -129,13 +357,92 @@ module CurrentScope
|
|
|
129
357
|
# actions shows a blank cell, never a shifted one.
|
|
130
358
|
attr_accessor :permission_grid_groups
|
|
131
359
|
|
|
360
|
+
# :enforce (default) | :report — what the gate DOES with a denial. (#37)
|
|
361
|
+
#
|
|
362
|
+
# The adoption ramp. A host retrofitting this engine onto an existing app
|
|
363
|
+
# has a controller suite that goes red the moment the gate is mounted: it is
|
|
364
|
+
# fail-closed, and nothing is granted yet. Report mode lets them mount the
|
|
365
|
+
# gate, run their app, and read what WOULD have been denied out of the
|
|
366
|
+
# ledger — turning a big-bang cutover into a list of grants to seed.
|
|
367
|
+
#
|
|
368
|
+
# :enforce — deny means 403. The only production posture.
|
|
369
|
+
# :report — a MISSING GRANT is logged and allowed through instead. Every
|
|
370
|
+
# other denial still refuses.
|
|
371
|
+
#
|
|
372
|
+
# Report mode is NOT an off switch and never reaches the things that would
|
|
373
|
+
# make it one: the separation-of-duties veto still refuses, and the
|
|
374
|
+
# management console answers to its own full-access check rather than this
|
|
375
|
+
# gate, so no enforcement setting can hand out the UI where grants are made.
|
|
376
|
+
attr_reader :enforcement
|
|
377
|
+
|
|
378
|
+
ENFORCEMENT_MODES = %i[enforce report].freeze
|
|
379
|
+
|
|
380
|
+
# Validating writer: an unknown value here is the worst kind of config
|
|
381
|
+
# mistake — the host believes it is enforcing while it is not. Fail at boot,
|
|
382
|
+
# naming what's allowed. Accepts a String so ENV["..."] works; anything that
|
|
383
|
+
# can't be a mode (nil from an unset ENV var, a number, a collection) raises
|
|
384
|
+
# ConfigurationError rather than NoMethodError, and the previous mode stands.
|
|
385
|
+
def enforcement=(value)
|
|
386
|
+
mode = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
387
|
+
|
|
388
|
+
unless ENFORCEMENT_MODES.include?(mode)
|
|
389
|
+
raise ConfigurationError,
|
|
390
|
+
"config.enforcement = #{value.inspect} is not a mode. " \
|
|
391
|
+
"Use :enforce (deny means 403 — the production posture) or " \
|
|
392
|
+
":report (log what WOULD be denied and allow it through, to seed " \
|
|
393
|
+
"grants before cutting over). Report mode is for adoption only: " \
|
|
394
|
+
"it never lifts the separation-of-duties veto and never opens the " \
|
|
395
|
+
"management console."
|
|
396
|
+
end
|
|
397
|
+
|
|
398
|
+
warn_report_mode_in_production if mode == :report && production?
|
|
399
|
+
@enforcement = mode
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
# True only in report mode. A predicate, so callers can't express "not
|
|
403
|
+
# enforcing" — the modes are a closed set, not a boolean.
|
|
404
|
+
def report_only? = @enforcement == :report
|
|
405
|
+
|
|
406
|
+
# :raise | :warn — what the opt-in GatingTripwire mixin (A4) does when it
|
|
407
|
+
# catches an action that completed without running the gate.
|
|
408
|
+
#
|
|
409
|
+
# :raise — fail loudly. Default in development/test: an ungated action is
|
|
410
|
+
# a hole you want CI to go red on, not one to discover in an audit.
|
|
411
|
+
# :warn — log once per controller#action and let the response through, so
|
|
412
|
+
# a real app can inventory its ungated surface without 500ing.
|
|
413
|
+
#
|
|
414
|
+
# A closed two-mode set, like enforcement — not a boolean, and no :off:
|
|
415
|
+
# not including the mixin is off.
|
|
416
|
+
attr_reader :gating_tripwire
|
|
417
|
+
|
|
418
|
+
GATING_TRIPWIRE_MODES = %i[raise warn].freeze
|
|
419
|
+
|
|
420
|
+
# Validating writer, same contract as enforcement=: an unknown value raises
|
|
421
|
+
# at assignment naming both modes, and the previous mode stands. Accepts a
|
|
422
|
+
# String so ENV["..."] works.
|
|
423
|
+
def gating_tripwire=(value)
|
|
424
|
+
mode = value.respond_to?(:to_sym) ? value.to_sym : value
|
|
425
|
+
|
|
426
|
+
unless GATING_TRIPWIRE_MODES.include?(mode)
|
|
427
|
+
raise ConfigurationError,
|
|
428
|
+
"config.gating_tripwire = #{value.inspect} is not a mode. " \
|
|
429
|
+
"Use :raise (an ungated action fails loudly — the dev/test posture) " \
|
|
430
|
+
"or :warn (log it once per controller#action and let the response " \
|
|
431
|
+
"through, to inventory an app's ungated surface). There is no :off — " \
|
|
432
|
+
"not including CurrentScope::GatingTripwire is off."
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
@gating_tripwire = mode
|
|
436
|
+
end
|
|
437
|
+
|
|
132
438
|
def initialize
|
|
133
439
|
@user_method = :current_user
|
|
134
440
|
@actor_method = nil
|
|
135
|
-
@sod_actions = []
|
|
441
|
+
@sod_actions = [].freeze
|
|
136
442
|
@sod_identity = :either
|
|
137
443
|
@allow_sod_bypass = false
|
|
138
444
|
@sod_bypass_permission = "bypass_sod"
|
|
445
|
+
@collection_read_actions = [ "index" ].freeze
|
|
139
446
|
@allow_mutations_while_impersonating = false
|
|
140
447
|
@excluded_controllers = [
|
|
141
448
|
%r{\Arails/}, %r{\Aactive_storage/}, %r{\Aaction_mailbox/},
|
|
@@ -144,7 +451,18 @@ module CurrentScope
|
|
|
144
451
|
@parent_controller = "::ApplicationController"
|
|
145
452
|
@subject_class = "User"
|
|
146
453
|
@audit = true
|
|
147
|
-
@
|
|
454
|
+
@enforcement = :enforce
|
|
455
|
+
# Reuses diagnostics_default_on? for its env split and bare-Ruby safety
|
|
456
|
+
# ONLY — do NOT carry over the diagnostics flags' emit/silence reading.
|
|
457
|
+
# There, false means stay quiet; here, false means :warn, which EMITS.
|
|
458
|
+
# The inversion is deliberate: the tripwire mixin is opt-in, so a
|
|
459
|
+
# production host that included it is asking for the ungated inventory —
|
|
460
|
+
# the env decides only whether a hit 500s (dev/test) or logs (elsewhere).
|
|
461
|
+
@gating_tripwire = diagnostics_default_on? ? :raise : :warn
|
|
462
|
+
@warn_on_nil_sod_record = diagnostics_default_on?
|
|
463
|
+
@warn_on_inert_scoped_grant = diagnostics_default_on?
|
|
464
|
+
@warn_on_cross_controller_derivation = diagnostics_default_on?
|
|
465
|
+
@warn_on_undeclared_collection_model = diagnostics_default_on?
|
|
148
466
|
@permission_grid_groups = {
|
|
149
467
|
"read" => %w[index show],
|
|
150
468
|
"create" => %w[new create],
|
|
@@ -159,7 +477,7 @@ module CurrentScope
|
|
|
159
477
|
# silently letting a real actor mutate data as someone else. Assigning false
|
|
160
478
|
# (or leaving the default) is always allowed.
|
|
161
479
|
def allow_mutations_while_impersonating=(value)
|
|
162
|
-
if value && production? && !
|
|
480
|
+
if value && production? && !prod_mutations_opt_in?
|
|
163
481
|
raise ConfigurationError,
|
|
164
482
|
"config.allow_mutations_while_impersonating = true is refused in " \
|
|
165
483
|
"production: letting a real actor write as the subject they " \
|
|
@@ -167,13 +485,88 @@ module CurrentScope
|
|
|
167
485
|
"Keep it false so impersonated sessions stay read-only in " \
|
|
168
486
|
"production, or — only if writes under act-as are genuinely " \
|
|
169
487
|
"required (e.g. a live public showcase) — set ENV[\"#{PROD_MUTATIONS_ENV}\"] " \
|
|
170
|
-
"to opt in explicitly
|
|
488
|
+
"to a truthy value (\"1\"/\"true\") to opt in explicitly; \"false\", " \
|
|
489
|
+
"\"0\", and empty mean not opted in. development, test, and staging " \
|
|
490
|
+
"are unaffected."
|
|
171
491
|
end
|
|
172
492
|
@allow_mutations_while_impersonating = value
|
|
173
493
|
end
|
|
174
494
|
|
|
175
495
|
private
|
|
176
496
|
|
|
497
|
+
# The env var's VALUE means what it says — presence alone is not consent.
|
|
498
|
+
# ActiveModel's boolean cast maps "false"/"0"/"f"/"off" to false and "" to
|
|
499
|
+
# nil; anything else present is truthy. Only reached when production? is
|
|
500
|
+
# already true, so Rails (and ActiveModel) are loaded — a bare-Ruby
|
|
501
|
+
# Configuration.new never gets here.
|
|
502
|
+
def prod_mutations_opt_in?
|
|
503
|
+
!!ActiveModel::Type::Boolean.new.cast(ENV[PROD_MUTATIONS_ENV])
|
|
504
|
+
end
|
|
505
|
+
|
|
506
|
+
# Report mode in production is DELIBERATELY allowed — surveying real traffic
|
|
507
|
+
# is the most honest way to find out what to grant, and a staging run won't
|
|
508
|
+
# show you the flows your actual users take. Refusing it here would break the
|
|
509
|
+
# feature's best use case.
|
|
510
|
+
#
|
|
511
|
+
# But "we are not enforcing authorization" is not a state a production app
|
|
512
|
+
# should be in silently, or for long. The failure mode is quiet: nothing
|
|
513
|
+
# breaks, no one is refused, and the temporary survey becomes the permanent
|
|
514
|
+
# posture because nothing ever reminded anyone. So it says so at boot, once,
|
|
515
|
+
# where a deploy log will keep it.
|
|
516
|
+
#
|
|
517
|
+
# ponytail: a warn, not a raise. Unlike allow_mutations_while_impersonating
|
|
518
|
+
# (which has an env-gate refusal) this has a legitimate production use and a
|
|
519
|
+
# deliberate exit — the host is mid-migration, and the loud reminder is the
|
|
520
|
+
# right amount of friction.
|
|
521
|
+
def warn_report_mode_in_production
|
|
522
|
+
message = "[CurrentScope] config.enforcement = :report in PRODUCTION — " \
|
|
523
|
+
"authorization is NOT being enforced. Missing grants are logged " \
|
|
524
|
+
"as access.would_deny and the request is ALLOWED THROUGH. This is " \
|
|
525
|
+
"an adoption ramp, not a production posture: seed the grants the " \
|
|
526
|
+
"ledger names, then set config.enforcement = :enforce."
|
|
527
|
+
|
|
528
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
529
|
+
Rails.logger.warn(message)
|
|
530
|
+
else
|
|
531
|
+
# Boot order: an initializer can run before the logger exists. Say it
|
|
532
|
+
# anyway — a warning nobody sees is the thing this method exists to
|
|
533
|
+
# prevent.
|
|
534
|
+
warn(message)
|
|
535
|
+
end
|
|
536
|
+
end
|
|
537
|
+
|
|
538
|
+
# Mirrors warn_report_mode_in_production: a legal posture that is almost
|
|
539
|
+
# never what the host meant says so once, loudly, where a boot log keeps
|
|
540
|
+
# it. Warn in every env — unlike report mode this misconfiguration is as
|
|
541
|
+
# wrong in development as in production.
|
|
542
|
+
def warn_on_mutating_collection_reads(actions)
|
|
543
|
+
mutating = actions & MUTATING_ACTION_NAMES
|
|
544
|
+
return if mutating.empty?
|
|
545
|
+
|
|
546
|
+
message = "[CurrentScope] config.collection_read_actions includes " \
|
|
547
|
+
"#{mutating.map(&:inspect).join(', ')} — this list is for LIST-NARROWING READS. " \
|
|
548
|
+
"A mutating action here hands scoped full_access holders that action on every " \
|
|
549
|
+
"record of its type off a grant on ONE record (the #49 escalation shape). " \
|
|
550
|
+
"Remove it unless that is genuinely intended."
|
|
551
|
+
|
|
552
|
+
if defined?(Rails) && Rails.respond_to?(:logger) && Rails.logger
|
|
553
|
+
Rails.logger.warn(message)
|
|
554
|
+
else
|
|
555
|
+
warn(message)
|
|
556
|
+
end
|
|
557
|
+
end
|
|
558
|
+
|
|
559
|
+
# Diagnostics are on where you are while writing the app, off where they'd
|
|
560
|
+
# be noise on someone else's dime. `local?` is Rails' own name for
|
|
561
|
+
# "development or test", so a host with a staging env that reports itself as
|
|
562
|
+
# neither gets prod behaviour — the conservative side for a log line.
|
|
563
|
+
#
|
|
564
|
+
# Mirrors the production? guard below: a bare-Ruby Configuration.new with no
|
|
565
|
+
# Rails must not raise, and gets false (no logger to warn to anyway).
|
|
566
|
+
def diagnostics_default_on?
|
|
567
|
+
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.local?
|
|
568
|
+
end
|
|
569
|
+
|
|
177
570
|
def production?
|
|
178
571
|
defined?(Rails) && Rails.respond_to?(:env) && Rails.env.production?
|
|
179
572
|
end
|
data/lib/current_scope/engine.rb
CHANGED
|
@@ -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
|
|
@@ -21,9 +21,30 @@ module CurrentScope
|
|
|
21
21
|
# chain (render/redirect) — so an action that renders straight from a
|
|
22
22
|
# before_action escapes the tripwire. It is a strong dev/test aid, not total
|
|
23
23
|
# coverage (Grape/Rack endpoints are out of reach for the same reason).
|
|
24
|
+
# What a catch DOES is config.gating_tripwire (:raise in dev/test, :warn
|
|
25
|
+
# elsewhere) — :warn logs each ungated controller#action once instead of
|
|
26
|
+
# 500ing, so a real app can inventory its ungated surface.
|
|
24
27
|
module GatingTripwire
|
|
25
28
|
extend ActiveSupport::Concern
|
|
26
29
|
|
|
30
|
+
class << self
|
|
31
|
+
# The :warn latch. ponytail: a plain Set, not a Mutex — worst case under
|
|
32
|
+
# a race is one extra line, and a flood is the thing being prevented.
|
|
33
|
+
# Per-SITE, not per-process (unlike Guard.ledger_warning_emitted?): the
|
|
34
|
+
# point of :warn is an inventory, so every distinct controller#action
|
|
35
|
+
# must get its own line.
|
|
36
|
+
def warning_unseen?(site)
|
|
37
|
+
@warned ||= Set.new
|
|
38
|
+
@warned.add?(site) ? true : false
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Cleared on engine to_prepare — a reload can change whether a site is
|
|
42
|
+
# gated, so a stale latch is a false all-clear — and by tests.
|
|
43
|
+
def reset_warnings!
|
|
44
|
+
@warned = nil
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
27
48
|
included do
|
|
28
49
|
after_action :current_scope_verify_gated!
|
|
29
50
|
end
|
|
@@ -43,11 +64,21 @@ module CurrentScope
|
|
|
43
64
|
# controller carrying only this mixin (no Guard) never sets it.
|
|
44
65
|
return if instance_variable_defined?(:@current_scope_checked) && @current_scope_checked
|
|
45
66
|
|
|
46
|
-
raise CurrentScope::ConfigurationError,
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
67
|
+
raise CurrentScope::ConfigurationError, current_scope_tripwire_message if CurrentScope.config.gating_tripwire == :raise
|
|
68
|
+
|
|
69
|
+
# :warn — same remediation text, once per controller#action. The latch
|
|
70
|
+
# check comes first so an already-warned site (every request after the
|
|
71
|
+
# first, on a fail-open that keeps serving traffic) pays no string build.
|
|
72
|
+
return unless GatingTripwire.warning_unseen?("#{controller_path}##{action_name}")
|
|
73
|
+
|
|
74
|
+
Rails.logger&.warn("[CurrentScope] #{current_scope_tripwire_message}")
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def current_scope_tripwire_message
|
|
78
|
+
"\"#{controller_path}##{action_name}\" completed without running current_scope_check! — " \
|
|
79
|
+
"this controller is not gated by CurrentScope::Guard. Include CurrentScope::Guard on its " \
|
|
80
|
+
"base controller, or mark this action public with " \
|
|
81
|
+
"`current_scope_skip_tripwire! only: :#{action_name}`."
|
|
51
82
|
end
|
|
52
83
|
end
|
|
53
84
|
end
|