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.
@@ -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
- # The real actor (Current.actor) enters here explicitly the resolver
138
- # never reads Current itself (PDP purity). It only matters under SoD
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
- raise CurrentScope::AccessDenied.new(permission, reason: reason)
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(e)
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
- def warn_ledger_failure_once(error)
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("[CurrentScope] report-only: #{ledger_failure_hint(error)} " \
289
- "The request WAS allowed through — only the access.would_deny " \
290
- "row is missing. This warns once per process.")
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 would-be denials are not being " \
302
- "recorded and `rails current_scope:report` will be empty. Run " \
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 a would-be denial (#{error.class}: #{error.message.to_s.truncate(120)})."
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
- return unless CurrentScope.resolver.scoped_grant_exists?(
388
- subject: CurrentScope::Current.user, permission: permission
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
- # NO_RECORD counts: it IS the member-action-with-no-record case this nudge
483
- # exists to catch, so it must not go quiet just because the Guard now
484
- # labels that case instead of passing a bare nil.
485
- return unless record.nil? || record.equal?(NO_RECORD)
486
- return unless CurrentScope.config.sod_actions.include?(permission.split("#").last)
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 a " \
490
- "nil record, so the SoD veto was skipped. If this is a member action, current_scope_record " \
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
- "#{controller_path}##{action_name}", reason: :impersonation_gate
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. Enforce that invariant loudly rather than trusting the
270
- # host to honor the doc comment: a bypass action in sod_actions would
271
- # recurse to a SystemStackError.
272
- bypass_action = CurrentScope.config.sod_bypass_permission.to_s.split("#").last
273
- if CurrentScope.config.sod_actions.include?(bypass_action)
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).
@@ -1,3 +1,3 @@
1
1
  module CurrentScope
2
- VERSION = "0.3.0"
2
+ VERSION = "0.4.0"
3
3
  end
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). Carries an optional
18
- # machine-readable reason, surfaced on the response as X-Current-Scope-Reason
19
- # by current_scope_denied:
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
- RoleAssignment.find_or_initialize_by(subject: subject).tap { |a| a.update!(role: role) }
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 every authorization change; if the events table hasn't
114
- # been migrated yet, degrade gracefully (skip + warn once).
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
- grouped = rows.group_by { |subject, _label, _details| subject }
65
-
66
- puts "Would-be denials grant these to stop them (most-denied first):"
67
- puts
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
- puts "Total: #{rows.count} would-be denials across #{grouped.size} subject(s)."
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 " \
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: current_scope
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren