current_scope 0.3.0 → 0.4.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.
- checksums.yaml +4 -4
- data/README.md +94 -17
- data/app/assets/stylesheets/current_scope/application.css +26 -0
- data/app/controllers/current_scope/application_controller.rb +5 -1
- data/app/controllers/current_scope/role_assignments_controller.rb +96 -11
- data/app/controllers/current_scope/roles_controller.rb +129 -34
- data/app/controllers/current_scope/subjects_controller.rb +6 -3
- data/app/helpers/current_scope/application_helper.rb +6 -0
- data/app/models/current_scope/event.rb +30 -15
- data/app/models/current_scope/scoped_role_assignment.rb +51 -0
- data/app/views/current_scope/roles/index.html.erb +16 -2
- data/app/views/current_scope/roles/members.html.erb +16 -4
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +7 -7
- data/app/views/current_scope/subjects/index.html.erb +20 -8
- data/lib/current_scope/configuration.rb +81 -11
- data/lib/current_scope/context.rb +5 -0
- data/lib/current_scope/engine.rb +29 -0
- data/lib/current_scope/guard.rb +120 -25
- data/lib/current_scope/mutation_guard.rb +11 -1
- data/lib/current_scope/permissions.rb +14 -11
- data/lib/current_scope/resolver.rb +5 -9
- data/lib/current_scope/version.rb +1 -1
- data/lib/current_scope.rb +53 -6
- data/lib/generators/current_scope/install/templates/initializer.rb +7 -2
- data/lib/tasks/current_scope_tasks.rake +41 -12
- metadata +1 -1
data/lib/current_scope/guard.rb
CHANGED
|
@@ -115,7 +115,9 @@ module CurrentScope
|
|
|
115
115
|
raise CurrentScope::ConfigurationError,
|
|
116
116
|
"\"#{permission}\" is not in the permission catalog (excluded_controllers " \
|
|
117
117
|
"or not routed). Either stop excluding it, or skip the gate here with " \
|
|
118
|
-
"skip_before_action :current_scope_check!."
|
|
118
|
+
"skip_before_action :current_scope_check!. Skipping the gate leaves this " \
|
|
119
|
+
"controller ungated by CurrentScope — protect it with your own " \
|
|
120
|
+
"authorization (e.g. require_admin!)."
|
|
119
121
|
end
|
|
120
122
|
|
|
121
123
|
record = resolve_current_scope_record
|
|
@@ -134,8 +136,10 @@ module CurrentScope
|
|
|
134
136
|
CurrentScope::Current.collection_model = record.equal?(NO_RECORD) ? nil : model
|
|
135
137
|
CurrentScope::Current.collection_model_path = controller_path
|
|
136
138
|
|
|
137
|
-
#
|
|
138
|
-
#
|
|
139
|
+
# Decision *inputs* (subject, permission, record, model, actor) are
|
|
140
|
+
# explicit — the resolver does not read ambient identity for the allow/
|
|
141
|
+
# deny answer. Org-role *lookup* may use Current.memoized_org_role (a
|
|
142
|
+
# per-request cache, not a decision input). Actor only widens SoD under
|
|
139
143
|
# :either while impersonating; otherwise actor == subject.
|
|
140
144
|
allowed, reason = CurrentScope.resolver.decide(
|
|
141
145
|
subject: CurrentScope::Current.user, permission: permission,
|
|
@@ -156,8 +160,19 @@ module CurrentScope
|
|
|
156
160
|
|
|
157
161
|
return report_would_deny(permission, record) if report_only_denial?(reason, permission, record)
|
|
158
162
|
|
|
159
|
-
|
|
160
|
-
|
|
163
|
+
# Report mode still 403s the SoD blind spot (correct, fail-closed) but
|
|
164
|
+
# used to do so silently — no log, no ledger, invisible to
|
|
165
|
+
# current_scope:report. Diagnose before the raise so a retrofit host
|
|
166
|
+
# sees the mis-declared record hook they must fix (#73).
|
|
167
|
+
diagnose_report_sod_blind_spot(permission, record, reason)
|
|
168
|
+
|
|
169
|
+
raise CurrentScope::AccessDenied.new(
|
|
170
|
+
permission,
|
|
171
|
+
reason: reason,
|
|
172
|
+
permission: permission,
|
|
173
|
+
record: (record.equal?(NO_RECORD) ? nil : record),
|
|
174
|
+
subject: CurrentScope::Current.user
|
|
175
|
+
)
|
|
161
176
|
end
|
|
162
177
|
|
|
163
178
|
record_sod_bypass(permission, record) if reason == :sod_bypassed
|
|
@@ -231,6 +246,57 @@ module CurrentScope
|
|
|
231
246
|
record_would_deny_event(permission, record)
|
|
232
247
|
end
|
|
233
248
|
|
|
249
|
+
# #73: report mode refuses to *downgrade* an SoD blind-spot denial (the
|
|
250
|
+
# veto never ran), but must not 403 *silently*. Log the cause/fix and
|
|
251
|
+
# record a distinct ledger row — never access.would_deny, because granting
|
|
252
|
+
# the permission will not clear this 403 (the hook is the fix).
|
|
253
|
+
def diagnose_report_sod_blind_spot(permission, record, reason)
|
|
254
|
+
return unless CurrentScope.config.report_only?
|
|
255
|
+
return unless reason == :no_grant
|
|
256
|
+
# Ask the resolver (#74) — no second copy of the skip condition.
|
|
257
|
+
return unless sod_veto_blind_spot?(permission, record)
|
|
258
|
+
|
|
259
|
+
Rails.logger&.warn(
|
|
260
|
+
"[CurrentScope] report-only: DENIED #{permission.inspect} because the " \
|
|
261
|
+
"separation-of-duties veto could not run (no usable record for this " \
|
|
262
|
+
"action). This is NOT a missing grant — granting will not clear the 403. " \
|
|
263
|
+
"Declare current_scope_record to return the AR record on this member " \
|
|
264
|
+
"action (or remove the action from config.sod_actions if it is not " \
|
|
265
|
+
"meant to be four-eyes gated). See also rails current_scope:report " \
|
|
266
|
+
"(access.sod_blind_spot events)."
|
|
267
|
+
)
|
|
268
|
+
record_sod_blind_spot_event(permission, record)
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def record_sod_blind_spot_event(permission, record)
|
|
272
|
+
subject = CurrentScope::Current.user
|
|
273
|
+
return if subject.nil?
|
|
274
|
+
|
|
275
|
+
target = record.equal?(NO_RECORD) ? nil : record
|
|
276
|
+
# Non-records (String params[:id], etc.) are not GlobalID targets.
|
|
277
|
+
target = nil unless target.respond_to?(:to_gid)
|
|
278
|
+
|
|
279
|
+
CurrentScope::Event.record!(
|
|
280
|
+
event: "access.sod_blind_spot",
|
|
281
|
+
target: target || subject,
|
|
282
|
+
details: {
|
|
283
|
+
permission: permission,
|
|
284
|
+
reason: "no_grant",
|
|
285
|
+
blind_spot: true,
|
|
286
|
+
fix: "declare current_scope_record for this SoD member action"
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
rescue StandardError => e
|
|
290
|
+
# Blind-spot path returns 403 next — do not claim the request was allowed
|
|
291
|
+
# or that a would_deny row was lost (PR #103 review).
|
|
292
|
+
warn_ledger_failure_once(
|
|
293
|
+
e,
|
|
294
|
+
event: "access.sod_blind_spot",
|
|
295
|
+
request_outcome: "The request was DENIED (403) — only the access.sod_blind_spot row is missing."
|
|
296
|
+
)
|
|
297
|
+
nil
|
|
298
|
+
end
|
|
299
|
+
|
|
234
300
|
# R3: report mode NEVER raises — that is its whole promise, and it has to hold
|
|
235
301
|
# regardless of audit posture or the state of the ledger.
|
|
236
302
|
#
|
|
@@ -265,7 +331,11 @@ module CurrentScope
|
|
|
265
331
|
rescue StandardError => e
|
|
266
332
|
# ponytail: swallow and warn ONCE. An unrecordable observation is a lost
|
|
267
333
|
# log line; a raise here is a 500 on a request report mode promised to pass.
|
|
268
|
-
warn_ledger_failure_once(
|
|
334
|
+
warn_ledger_failure_once(
|
|
335
|
+
e,
|
|
336
|
+
event: "access.would_deny",
|
|
337
|
+
request_outcome: "The request WAS allowed through — only the access.would_deny row is missing."
|
|
338
|
+
)
|
|
269
339
|
nil
|
|
270
340
|
end
|
|
271
341
|
|
|
@@ -281,13 +351,18 @@ module CurrentScope
|
|
|
281
351
|
# fix for a missing table and otherwise reports the real error, because
|
|
282
352
|
# telling someone with a dead connection to run migrations sends them after
|
|
283
353
|
# the wrong problem.
|
|
284
|
-
|
|
354
|
+
#
|
|
355
|
+
# `event` / `request_outcome` are path-specific: would_deny allows the
|
|
356
|
+
# request; sod_blind_spot still 403s — the operator-facing text must not
|
|
357
|
+
# claim the wrong outcome (PR #103).
|
|
358
|
+
def warn_ledger_failure_once(error, event:, request_outcome:)
|
|
285
359
|
return if CurrentScope::Guard.ledger_warning_emitted?
|
|
286
360
|
|
|
287
361
|
CurrentScope::Guard.ledger_warning_emitted!
|
|
288
|
-
Rails.logger&.warn(
|
|
289
|
-
|
|
290
|
-
|
|
362
|
+
Rails.logger&.warn(
|
|
363
|
+
"[CurrentScope] report-only: #{ledger_failure_hint(error, event: event)} " \
|
|
364
|
+
"#{request_outcome} This warns once per process."
|
|
365
|
+
)
|
|
291
366
|
end
|
|
292
367
|
|
|
293
368
|
# ASKS Event whether this is the un-migrated-table case rather than pattern-
|
|
@@ -296,14 +371,14 @@ module CurrentScope
|
|
|
296
371
|
# says why: it "would point operators at the wrong fix". A looser test here
|
|
297
372
|
# reintroduced exactly that, telling someone their table was missing while
|
|
298
373
|
# they were looking right at it. (#59 review)
|
|
299
|
-
def ledger_failure_hint(error)
|
|
374
|
+
def ledger_failure_hint(error, event:)
|
|
300
375
|
if CurrentScope::Event.missing_events_table?(error)
|
|
301
|
-
"the current_scope_events table is missing, so
|
|
302
|
-
"recorded and `rails current_scope:report` will be
|
|
376
|
+
"the current_scope_events table is missing, so #{event} rows are not being " \
|
|
377
|
+
"recorded and `rails current_scope:report` will be incomplete. Run " \
|
|
303
378
|
"`rails current_scope:install:migrations && rails db:migrate`, or set " \
|
|
304
379
|
"config.audit = false if you don't want the ledger."
|
|
305
380
|
else
|
|
306
|
-
"could not record
|
|
381
|
+
"could not record #{event} (#{error.class}: #{error.message.to_s.truncate(120)})."
|
|
307
382
|
end
|
|
308
383
|
end
|
|
309
384
|
|
|
@@ -384,9 +459,22 @@ module CurrentScope
|
|
|
384
459
|
return unless CurrentScope.config.warn_on_inert_scoped_grant
|
|
385
460
|
return unless reason == :no_grant
|
|
386
461
|
return unless record.equal?(NO_RECORD)
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
462
|
+
|
|
463
|
+
# Diagnostics must not 500 the request (#74): a transient DB error inside
|
|
464
|
+
# scoped_grant_exists? is log-only, matching record_would_deny_event.
|
|
465
|
+
has_grant =
|
|
466
|
+
begin
|
|
467
|
+
CurrentScope.resolver.scoped_grant_exists?(
|
|
468
|
+
subject: CurrentScope::Current.user, permission: permission
|
|
469
|
+
)
|
|
470
|
+
rescue StandardError => e
|
|
471
|
+
Rails.logger&.warn(
|
|
472
|
+
"[CurrentScope] warn_on_inert_scoped_grant could not check scoped grants " \
|
|
473
|
+
"(#{e.class}: #{e.message}); skipping diagnostic for \"#{permission}\"."
|
|
474
|
+
)
|
|
475
|
+
false
|
|
476
|
+
end
|
|
477
|
+
return unless has_grant
|
|
390
478
|
|
|
391
479
|
# Says only what the predicate proves. scoped_grant_exists? has no resource
|
|
392
480
|
# filter — it can't have one, the missing record IS the bug — so it
|
|
@@ -479,16 +567,23 @@ module CurrentScope
|
|
|
479
567
|
|
|
480
568
|
def nudge_on_nil_sod_record(permission, record)
|
|
481
569
|
return unless CurrentScope.config.warn_on_nil_sod_record
|
|
482
|
-
#
|
|
483
|
-
#
|
|
484
|
-
#
|
|
485
|
-
|
|
486
|
-
return unless CurrentScope.
|
|
570
|
+
# Ask the resolver (#74) — do not re-derive "did the veto skip?". A private
|
|
571
|
+
# nil/NO_RECORD copy missed the commonest mistake: a hook returning
|
|
572
|
+
# params[:id] (a String). sod_veto_skipped? is the single definition.
|
|
573
|
+
gate_record = record.equal?(NO_RECORD) ? nil : record
|
|
574
|
+
return unless CurrentScope.resolver.sod_veto_skipped?(permission: permission, record: gate_record)
|
|
575
|
+
|
|
576
|
+
shape_hint =
|
|
577
|
+
if gate_record.nil?
|
|
578
|
+
"a nil record"
|
|
579
|
+
else
|
|
580
|
+
"a non-record (#{gate_record.class.name}) — often a hook returning params[:id]"
|
|
581
|
+
end
|
|
487
582
|
|
|
488
583
|
Rails.logger&.warn(
|
|
489
|
-
"[CurrentScope] \"#{permission}\" is a separation-of-duties action but was gated with
|
|
490
|
-
"
|
|
491
|
-
"must return the record; if it's a collection action, this is expected."
|
|
584
|
+
"[CurrentScope] \"#{permission}\" is a separation-of-duties action but was gated with " \
|
|
585
|
+
"#{shape_hint}, so the SoD veto was skipped. If this is a member action, " \
|
|
586
|
+
"current_scope_record must return the AR record; if it's a collection action, this is expected."
|
|
492
587
|
)
|
|
493
588
|
end
|
|
494
589
|
end
|
|
@@ -31,8 +31,12 @@ module CurrentScope
|
|
|
31
31
|
return if CurrentScope.config.allow_mutations_while_impersonating
|
|
32
32
|
return unless current_scope_impersonating?
|
|
33
33
|
|
|
34
|
+
key = "#{controller_path}##{action_name}"
|
|
34
35
|
raise CurrentScope::AccessDenied.new(
|
|
35
|
-
|
|
36
|
+
key,
|
|
37
|
+
reason: :impersonation_gate,
|
|
38
|
+
permission: key,
|
|
39
|
+
subject: CurrentScope::Current.user
|
|
36
40
|
)
|
|
37
41
|
end
|
|
38
42
|
|
|
@@ -55,6 +59,12 @@ module CurrentScope
|
|
|
55
59
|
def current_scope_denied(exception = nil)
|
|
56
60
|
reason = exception.respond_to?(:reason) ? exception.reason : nil
|
|
57
61
|
response.headers["X-Current-Scope-Reason"] = reason.to_s if reason
|
|
62
|
+
# Mirror the header into the server log so log-based triage can distinguish
|
|
63
|
+
# no_grant from sod_veto without reading response headers (#39).
|
|
64
|
+
permission = exception.respond_to?(:permission) ? exception.permission : nil
|
|
65
|
+
Rails.logger&.info(
|
|
66
|
+
"[CurrentScope] denied #{permission || '(unknown)'} (#{reason || 'unknown'}) → 403"
|
|
67
|
+
)
|
|
58
68
|
current_scope_render_denied(reason)
|
|
59
69
|
end
|
|
60
70
|
|
|
@@ -60,17 +60,6 @@ module CurrentScope
|
|
|
60
60
|
# default — the class form allowed_to?(:index, Report) is how you ask about
|
|
61
61
|
# another controller, and it binds from its argument (R5). (KTD-6)
|
|
62
62
|
#
|
|
63
|
-
# The match keys on the KEY's controller, not just the controller: kwarg: a
|
|
64
|
-
# full "reports#index" key from a projects view names "reports" and must NOT
|
|
65
|
-
# borrow the projects ambient (a Project grant answering a reports key). A
|
|
66
|
-
# bare action uses the resolved controller. (#50 review, cubic)
|
|
67
|
-
def ambient_collection_model(action, controller)
|
|
68
|
-
key_controller = action.to_s.include?("#") ? action.to_s.split("#").first : controller
|
|
69
|
-
return nil unless key_controller && key_controller == CurrentScope::Current.collection_model_path
|
|
70
|
-
|
|
71
|
-
CurrentScope::Current.collection_model
|
|
72
|
-
end
|
|
73
|
-
|
|
74
63
|
def current_scope_user
|
|
75
64
|
CurrentScope::Current.user
|
|
76
65
|
end
|
|
@@ -87,5 +76,19 @@ module CurrentScope
|
|
|
87
76
|
def impersonating?
|
|
88
77
|
CurrentScope::Current.impersonating?
|
|
89
78
|
end
|
|
79
|
+
|
|
80
|
+
private
|
|
81
|
+
|
|
82
|
+
# Internal binding for gate/view agreement (#50 KTD-6) — not host API.
|
|
83
|
+
# Private so it does not appear in controller action_methods. Matches on
|
|
84
|
+
# the KEY's controller, not just the controller: kwarg: a full
|
|
85
|
+
# "reports#index" key from a projects view must not borrow the projects
|
|
86
|
+
# ambient. A bare action uses the resolved controller. (#50 review, cubic)
|
|
87
|
+
def ambient_collection_model(action, controller)
|
|
88
|
+
key_controller = action.to_s.include?("#") ? action.to_s.split("#").first : controller
|
|
89
|
+
return nil unless key_controller && key_controller == CurrentScope::Current.collection_model_path
|
|
90
|
+
|
|
91
|
+
CurrentScope::Current.collection_model
|
|
92
|
+
end
|
|
90
93
|
end
|
|
91
94
|
end
|
|
@@ -266,15 +266,11 @@ module CurrentScope
|
|
|
266
266
|
|
|
267
267
|
# Re-entrancy is bounded ONLY because the bypass permission isn't itself an
|
|
268
268
|
# SoD action (KTD-5) — the inner allowed? below returns at the SoD step
|
|
269
|
-
# without recursing.
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
raise ConfigurationError,
|
|
275
|
-
"config.sod_bypass_permission (#{CurrentScope.config.sod_bypass_permission.inspect}) is the " \
|
|
276
|
-
"action #{bypass_action.inspect}, which is also in config.sod_actions. The bypass permission " \
|
|
277
|
-
"must not be an SoD action — it would recurse. Remove #{bypass_action.inspect} from sod_actions."
|
|
269
|
+
# without recursing. Boot validate! is the primary gate (#40); this raise
|
|
270
|
+
# is defense in depth for runtime-mutated config (tests reassign live).
|
|
271
|
+
# The conflict test lives on Configuration so boot and decide cannot drift.
|
|
272
|
+
if CurrentScope.config.sod_bypass_permission_conflicts_with_sod_actions?
|
|
273
|
+
CurrentScope.config.validate!
|
|
278
274
|
end
|
|
279
275
|
|
|
280
276
|
# Absent hook ⇒ this type never breaks glass (fail-closed, no raise).
|
data/lib/current_scope.rb
CHANGED
|
@@ -14,9 +14,20 @@ require "current_scope/engine"
|
|
|
14
14
|
|
|
15
15
|
module CurrentScope
|
|
16
16
|
# Raised when the resolver denies an action gated by Guard (or when the
|
|
17
|
-
# management UI is accessed without a full-access role).
|
|
18
|
-
#
|
|
19
|
-
#
|
|
17
|
+
# management UI is accessed without a full-access role). Accessors:
|
|
18
|
+
#
|
|
19
|
+
# #permission — denied key (stable API for branded 403s). Defaults to the
|
|
20
|
+
# positional message when permission: is omitted so legacy
|
|
21
|
+
# raise sites still populate it. Prefer this over #message.
|
|
22
|
+
# #message — StandardError message (positional arg). Gem raise sites pass
|
|
23
|
+
# the key as both message and permission; they can diverge if
|
|
24
|
+
# a caller passes an explicit permission: keyword.
|
|
25
|
+
# #reason — machine-readable cause (also on X-Current-Scope-Reason)
|
|
26
|
+
# #record — the record under decision when the gate had one; nil for
|
|
27
|
+
# collection / impersonation-gate denials
|
|
28
|
+
# #subject — effective subject when known; nil if none was in scope
|
|
29
|
+
#
|
|
30
|
+
# Reasons surfaced by current_scope_denied:
|
|
20
31
|
#
|
|
21
32
|
# :sod_veto — the record's initiator can't perform an SoD action on it
|
|
22
33
|
# :no_grant — nothing granted the permission (the default deny)
|
|
@@ -34,11 +45,16 @@ module CurrentScope
|
|
|
34
45
|
# denial cannot exist that forgets its reason. (:sod_bypassed is the one
|
|
35
46
|
# audited ALLOW, so it is set by the Guard rather than raised here.)
|
|
36
47
|
class AccessDenied < StandardError
|
|
37
|
-
attr_reader :reason
|
|
48
|
+
attr_reader :reason, :permission, :record, :subject
|
|
38
49
|
|
|
39
|
-
def initialize(message = nil, reason: nil)
|
|
50
|
+
def initialize(message = nil, reason: nil, permission: nil, record: nil, subject: nil)
|
|
40
51
|
super(message)
|
|
41
52
|
@reason = reason
|
|
53
|
+
# permission defaults to message so older raise sites and branded-403
|
|
54
|
+
# recipes that only pass the key positionally still populate #permission.
|
|
55
|
+
@permission = permission || message
|
|
56
|
+
@record = record
|
|
57
|
+
@subject = subject
|
|
42
58
|
end
|
|
43
59
|
end
|
|
44
60
|
|
|
@@ -190,12 +206,43 @@ module CurrentScope
|
|
|
190
206
|
# Seeds the default Owner/Member roles ONLY on the default path — the name
|
|
191
207
|
# promises "assign a role", so a caller granting an explicit role must not
|
|
192
208
|
# get a full-access Owner row created in their roles table as a side effect.
|
|
209
|
+
#
|
|
210
|
+
# Audit (#30): when the org role actually changes, records one
|
|
211
|
+
# `org_role.assigned` / `org_role.changed` event, self-attributed to the
|
|
212
|
+
# grantee with `details.source = "bootstrap"`. Same-role re-grants are a
|
|
213
|
+
# no-op event-wise. Direct model writes and TestHelpers do not go through
|
|
214
|
+
# this path and are not recorded (documented intentionally).
|
|
193
215
|
def grant!(subject, role: nil)
|
|
194
216
|
role ||= begin
|
|
195
217
|
seed_defaults!
|
|
196
218
|
Role.find_by!(name: "Owner")
|
|
197
219
|
end
|
|
198
|
-
|
|
220
|
+
|
|
221
|
+
RoleAssignment.transaction do
|
|
222
|
+
assignment = RoleAssignment.find_or_initialize_by(subject: subject)
|
|
223
|
+
prior_role = assignment.persisted? ? assignment.role : nil
|
|
224
|
+
assignment.update!(role: role)
|
|
225
|
+
|
|
226
|
+
if prior_role.nil?
|
|
227
|
+
Event.record!(
|
|
228
|
+
event: "org_role.assigned",
|
|
229
|
+
target: subject,
|
|
230
|
+
details: { role: role.name, source: "bootstrap" },
|
|
231
|
+
actor: subject,
|
|
232
|
+
subject: subject
|
|
233
|
+
)
|
|
234
|
+
elsif prior_role.id != role.id
|
|
235
|
+
Event.record!(
|
|
236
|
+
event: "org_role.changed",
|
|
237
|
+
target: subject,
|
|
238
|
+
details: { from: prior_role.name, to: role.name, source: "bootstrap" },
|
|
239
|
+
actor: subject,
|
|
240
|
+
subject: subject
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
assignment
|
|
245
|
+
end
|
|
199
246
|
end
|
|
200
247
|
|
|
201
248
|
private
|
|
@@ -110,8 +110,10 @@ CurrentScope.configure do |config|
|
|
|
110
110
|
|
|
111
111
|
# Audit ledger — tri-state: false | true (default) | :strict.
|
|
112
112
|
# false — no audit rows recorded.
|
|
113
|
-
# true — record
|
|
114
|
-
#
|
|
113
|
+
# true — record management-UI mutations, impersonation boundary events,
|
|
114
|
+
# and grant!/rake/seeds bootstrap grants; if the events table
|
|
115
|
+
# hasn't been migrated yet, degrade gracefully (skip + warn once).
|
|
116
|
+
# Direct model writes and test helpers are not recorded.
|
|
115
117
|
# :strict — a missing events table RAISES (rolling the mutation back), so an
|
|
116
118
|
# audit-mandatory app never commits an unaudited change.
|
|
117
119
|
# config.audit = true
|
|
@@ -159,6 +161,9 @@ CurrentScope.configure do |config|
|
|
|
159
161
|
# Controller paths (regexps) excluded from the permission grid. Excluded
|
|
160
162
|
# controllers can't be granted, so they must also skip the gate with
|
|
161
163
|
# skip_before_action :current_scope_check! — Guard raises otherwise.
|
|
164
|
+
# Skipping the gate leaves the controller ungated by CurrentScope — protect
|
|
165
|
+
# it with your own authorization (e.g. require_admin!). See
|
|
166
|
+
# https://github.com/davidteren/current_scope/blob/main/docs/SECURITY-CHECKLIST.md
|
|
162
167
|
# config.excluded_controllers += [%r{\Awebhooks/}]
|
|
163
168
|
|
|
164
169
|
# Controller the management UI inherits from (for host auth + before_actions).
|
|
@@ -9,6 +9,14 @@ namespace :current_scope do
|
|
|
9
9
|
subject = klass.find_by(id: id)
|
|
10
10
|
abort "No #{klass} with id=#{id}" if subject.nil?
|
|
11
11
|
|
|
12
|
+
# grant! seeds Owner on the default path — warn on replacement even when
|
|
13
|
+
# the Owner row does not exist yet (first-time Owner creation).
|
|
14
|
+
prior = CurrentScope::RoleAssignment.find_by(subject: subject)&.role
|
|
15
|
+
if prior && prior.name != "Owner"
|
|
16
|
+
warn "WARNING: #{klass}##{subject.id} already held the #{prior.name.inspect} role — " \
|
|
17
|
+
"replacing it with full-access Owner."
|
|
18
|
+
end
|
|
19
|
+
|
|
12
20
|
CurrentScope.grant!(subject)
|
|
13
21
|
puts "Granted the full-access Owner role to #{klass}##{subject.id}."
|
|
14
22
|
end
|
|
@@ -32,6 +40,10 @@ namespace :current_scope do
|
|
|
32
40
|
begin
|
|
33
41
|
rows = CurrentScope::Event.where(event: "access.would_deny")
|
|
34
42
|
.pluck(:subject, :target_label, :details)
|
|
43
|
+
# #73: SoD blind-spot 403s are NOT would_deny (granting won't fix them).
|
|
44
|
+
# Surface them as a separate section so the survey is complete.
|
|
45
|
+
blind_rows = CurrentScope::Event.where(event: "access.sod_blind_spot")
|
|
46
|
+
.pluck(:subject, :target_label, :details)
|
|
35
47
|
rescue ActiveRecord::StatementInvalid => e
|
|
36
48
|
# Report mode without the migration records nothing (the ledger degrades and
|
|
37
49
|
# warns once). Reaching for this summary is exactly how a host discovers
|
|
@@ -42,7 +54,7 @@ namespace :current_scope do
|
|
|
42
54
|
"Run: bin/rails current_scope:install:migrations && bin/rails db:migrate"
|
|
43
55
|
end
|
|
44
56
|
|
|
45
|
-
if rows.empty?
|
|
57
|
+
if rows.empty? && blind_rows.empty?
|
|
46
58
|
# "No output" is indistinguishable from "the task is broken", and the two
|
|
47
59
|
# likeliest causes are both SILENT: report mode never on, or audit off.
|
|
48
60
|
# Name them — this is the first thing a host runs, and an unexplained blank
|
|
@@ -61,25 +73,42 @@ namespace :current_scope do
|
|
|
61
73
|
# ponytail: group in Ruby, not SQL. `details` is a JSON column and querying
|
|
62
74
|
# into it is adapter-specific; this is a rollout aid run by hand over a
|
|
63
75
|
# transitional table, so portability beats a smarter query.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
grouped.each do |subject_gid, subject_rows|
|
|
70
|
-
label = subject_rows.first[1].presence || subject_gid
|
|
71
|
-
puts " #{label}#{org_role_suffix.call(subject_gid)}"
|
|
72
|
-
|
|
73
|
-
subject_rows
|
|
76
|
+
#
|
|
77
|
+
# Shared tally so would_deny and sod_blind_spot sections cannot drift on
|
|
78
|
+
# ordering / unknown-permission handling (PR #103 review).
|
|
79
|
+
print_permission_counts = lambda do |event_rows|
|
|
80
|
+
event_rows
|
|
74
81
|
.group_by { |_s, _l, details| details.is_a?(Hash) ? details["permission"] : nil }
|
|
75
82
|
.transform_values(&:count)
|
|
76
83
|
.sort_by { |permission, count| [ -count, permission.to_s ] }
|
|
77
84
|
.each { |permission, count| puts " #{count.to_s.rjust(5)}x #{permission || '(unknown)'}" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
unless rows.empty?
|
|
88
|
+
grouped = rows.group_by { |subject, _label, _details| subject }
|
|
78
89
|
|
|
90
|
+
puts "Would-be denials — grant these to stop them (most-denied first):"
|
|
79
91
|
puts
|
|
92
|
+
|
|
93
|
+
grouped.each do |subject_gid, subject_rows|
|
|
94
|
+
label = subject_rows.first[1].presence || subject_gid
|
|
95
|
+
puts " #{label}#{org_role_suffix.call(subject_gid)}"
|
|
96
|
+
print_permission_counts.call(subject_rows)
|
|
97
|
+
puts
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
puts "Total: #{rows.count} would-be denials across #{grouped.size} subject(s)."
|
|
80
101
|
end
|
|
81
102
|
|
|
82
|
-
|
|
103
|
+
unless blind_rows.empty?
|
|
104
|
+
puts if rows.any?
|
|
105
|
+
puts "SoD blind-spot denials — NOT fixed by granting (declare current_scope_record):"
|
|
106
|
+
puts
|
|
107
|
+
print_permission_counts.call(blind_rows)
|
|
108
|
+
puts
|
|
109
|
+
puts "Total: #{blind_rows.count} blind-spot 403(s). Granting these permissions will not " \
|
|
110
|
+
"clear them — fix the record hook (or remove the action from config.sod_actions)."
|
|
111
|
+
end
|
|
83
112
|
end
|
|
84
113
|
|
|
85
114
|
desc "Inventory the routed controllers that provably never run the gate — the static " \
|