current_scope 0.3.1 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b43df7725417ffd6088533edb72032fd26a4bad9e8eefb6a56fa52d9a63a29e3
4
- data.tar.gz: b77bb0cdb38b19efd19ac754666d23abe14220ae3a0b70566f217eda26b016cf
3
+ metadata.gz: 12a83f4dca4237516e79cd7b518eb943b6ba5ba8cd296b49e287859d5d7cc62d
4
+ data.tar.gz: 1a1d2cfcefbdee379195f6fd37140b7df0037fa578f9452a11043e71719f2df3
5
5
  SHA512:
6
- metadata.gz: dedf447ca858342304bbd1dddeff2187291bd78780271f01328e9d328b090081f3c1bc293639dc4e2855b719ba9a61f4d49befac17673cb7f44dca10d2405037
7
- data.tar.gz: 56cd899f391607a79bf550fb416f961911b9c192e6a430bceedc3a92fabcab251bb0cad7aeabce20f4b10c9463939f0bd6afa6e55d7b535d6fbd047ea9fc426d
6
+ metadata.gz: 8f163e65876ed8a70eed33f4839b7d46827e73541c17543a67747ae4c35c6d3d9da281e023b83b8a733ca60d501f25c073d2bce3db7611cdfcc3f3d6b43e800b
7
+ data.tar.gz: 56fc9d5aab6a9babc3e8d9d0163274768c747a51e5378c625da34435a2e2ac6d00ba170cb2e6f50fa10f236b42c2a02fa68d23fc02a98e9bd9f2a40064083267
data/README.md CHANGED
@@ -23,7 +23,12 @@
23
23
  > Kick the tyres, build a spike, tell us what breaks. Don't put it in front of
24
24
  > real users yet.
25
25
 
26
- **Website:** [davidteren.github.io/current_scope](https://davidteren.github.io/current_scope/) — overview, the resolver, and quickstart at a glance.
26
+ **Website:** [davidteren.github.io/current_scope](https://davidteren.github.io/current_scope/) —
27
+ overview, quickstart, the
28
+ [separation-of-duties guide](https://davidteren.github.io/current_scope/separation-of-duties.html),
29
+ the security checklist, and
30
+ [copy-paste prompts for AI agents](https://davidteren.github.io/current_scope/ai-agents.html).
31
+ Source lives in [`docs/site/`](docs/site/).
27
32
 
28
33
  **Authorization as data you edit in a UI, not rules you hardcode and redeploy —
29
34
  with one ambient context that makes `allowed_to?` work identically in
@@ -120,6 +125,9 @@ end
120
125
  > — callback ordering vs. your authentication, the Devise recipe, the
121
126
  > `skip_before_action` fail-open trap, hybrid HTML+API grants, and a rollout
122
127
  > ladder. The short version is below.
128
+ >
129
+ > **Shipping?** Read the [Security & production checklist](docs/SECURITY-CHECKLIST.md)
130
+ > first — excluded controllers, the 403/404 record oracle, and the pre-ship tick list.
123
131
 
124
132
 
125
133
  The gate is fail-closed, so the line you just added denies **everything** until
@@ -158,8 +166,9 @@ Total: 457 would-be denials across 2 subject(s).
158
166
 
159
167
  That *is* your grant-seeding work, in the shape of the role grid you need to
160
168
  build: every subject who'd have been refused, what they were missing, and how
161
- badly. Seed the roles it names, watch the list empty out, then flip to
162
- `:enforce`. Each step is one line back, and nobody gets a 403 while you learn.
169
+ badly. Seed the roles it names, re-exercise, and flip to `:enforce` once newly
170
+ exercised requests stop adding rows (the report reads the append-only
171
+ ledger, so historical rows do not clear). Each step is one line back, and nobody gets a 403 while you learn.
163
172
 
164
173
  The rows are ordinary ledger events, so query them directly if you want
165
174
  something the task doesn't show:
@@ -176,7 +185,7 @@ else still refuses:
176
185
  | Still enforced in `:report` | Why it can't be relaxed |
177
186
  |---|---|
178
187
  | Separation-of-duties veto | Lifting it lets an initiator really approve their own record — a fraud action executed, not a role gap surfaced. |
179
- | SoD actions the veto *couldn't* run on | If an SoD action is gated without a record, the veto has no initiator to measure and is skipped — so the refusal that comes back says "not granted", not "SoD approved". Report mode won't speak for a rule nobody asked, and still refuses. (`config.warn_on_nil_sod_record` surfaces the misconfiguration behind it.) |
188
+ | SoD actions the veto *couldn't* run on | If an SoD action is gated without a record, the veto has no initiator to measure and is skipped — so the refusal that comes back says "not granted", not "SoD approved". Report mode won't speak for a rule nobody asked, and still refuses — but it **logs the blind spot and records `access.sod_blind_spot`** (not `access.would_deny`; granting will not clear the 403). `rails current_scope:report` lists them separately. |
180
189
  | The management console | It's where grants are made. An observation flag that opened it would be a privilege escalation. |
181
190
  | Impersonation read-only gate | Runs before the permission check and answers to its own rule. |
182
191
 
@@ -380,6 +389,13 @@ class ReportsController < ApplicationController
380
389
  end
381
390
  ```
382
391
 
392
+ The same hook has two foot-guns worth knowing before you ship: returning **nil**
393
+ on an SoD member action silently skips the veto
394
+ ([§ Separation of duties](#separation-of-duties-opt-in)), and loading with
395
+ `Model.find` means an unauthorized caller sees **403** for an existing id vs
396
+ **404** for a missing one — a record-existence oracle
397
+ ([security checklist mitigation](docs/SECURITY-CHECKLIST.md#2-403-vs-404-leaks-which-records-exist)).
398
+
383
399
  ### Scopeable models
384
400
 
385
401
  `include CurrentScope::Scopeable` in a host model to list it in the scoped-role
@@ -487,7 +503,9 @@ Holding `bypass_sod` on a flagged, self-initiated record **is** the
487
503
  authorization for the SoD action — the bypass grants the action, it doesn't
488
504
  merely lift the veto and then re-check for a separate `approve` grant.
489
505
  `bypass_sod` must **not** appear in `sod_actions` (it isn't an SoD action); the
490
- engine raises if it does, to prevent a re-entrant loop.
506
+ engine raises **at boot** if it does (and again at decision time as defense in
507
+ depth), so a re-entrant pairing fails the deploy instead of 500ing on the first
508
+ real break-glass attempt.
491
509
 
492
510
  When a bypass lifts the veto, the engine records exactly one append-only
493
511
  `sod.bypassed` audit event at the enforcement gate (never on advisory
@@ -556,10 +574,16 @@ additions. Members normalize to strings on assignment, so `%i[index]` works.
556
574
 
557
575
  The **audit ledger** is controlled by `config.audit` — tri-state
558
576
  `false | true | :strict`. `false` records nothing; `true` (the default) records
559
- every authorization change and degrades gracefully (skip + warn once) if the
560
- events table isn't migrated; `:strict` **raises** on a missing events table so
561
- an audit-mandatory app never commits an unaudited change (the mutation rolls
562
- back).
577
+ authorization changes made through the **management UI**, the **impersonation
578
+ boundary**, and **`CurrentScope.grant!`** (including the rake task and seeds
579
+ bootstrap path self-attributed, `details.source = "bootstrap"`), and degrades
580
+ gracefully (skip + warn once) if the events table isn't migrated; `:strict`
581
+ **raises** on a missing events table so an audit-mandatory app never commits an
582
+ unaudited change (the mutation rolls back). Direct `RoleAssignment` /
583
+ `ScopedRoleAssignment` writes and the test helpers (`grant_role!` /
584
+ `grant_scoped_role!`) are **not** recorded — use `grant!` for bootstrap
585
+ paths that need a ledger trail. UI events stamp `request_id` from
586
+ `ActionDispatch::RequestId` via the Context hook.
563
587
 
564
588
  > **Note on the `!`:** despite the bang, `Event.record!` only guarantees
565
589
  > raise-on-failure under `:strict` (and for a missing actor). In the default
@@ -764,8 +788,16 @@ class ImpersonationsController < ApplicationController
764
788
  end
765
789
  ```
766
790
 
767
- Denials carry a machine-readable reason on `AccessDenied#reason`, surfaced on
768
- the response as the `X-Current-Scope-Reason` header:
791
+ Denials raise `CurrentScope::AccessDenied` with stable accessors for branded
792
+ 403 pages and error trackers (prefer `#permission` over parsing `#message`):
793
+
794
+ | Accessor | Meaning |
795
+ |---|---|
796
+ | `#permission` | denied `controller#action` key — the stable API. Defaults to the positional message when `permission:` is omitted |
797
+ | `#message` | `StandardError` message. Gem raise sites pass the key as both message and permission; they can diverge if a caller passes an explicit `permission:` keyword |
798
+ | `#reason` | machine-readable cause (also on `X-Current-Scope-Reason`) |
799
+ | `#record` | the record under decision when the gate had one; **nil** on collection / impersonation-gate denials |
800
+ | `#subject` | effective subject when known |
769
801
 
770
802
  | Reason | Means |
771
803
  |---|---|
@@ -776,12 +808,57 @@ the response as the `X-Current-Scope-Reason` header:
776
808
  | `impersonation_gate` | a mutation while impersonating, which is read-only |
777
809
  | `not_full_access` | the management UI, which only full-access subjects enter |
778
810
 
779
- Every denial routes through one method, so a refusal can't reach a client
780
- without its reason. A **host** denial is a bodyless `403` — the reason header is
781
- the signal, and the gem won't render into your app's response contract. The
782
- engine's own management UI is the exception: it renders a short page saying a
783
- full-access role is required, because the person reading that one is an admin
784
- looking at a browser.
811
+ Guard and MutationGuard denials route through one method
812
+ (`current_scope_denied`), so by default a refusal on a Guard-wrapped controller
813
+ gets its reason header (and the denial log line below). "By default" matters: a
814
+ host `rescue_from CurrentScope::AccessDenied` registered after the include
815
+ **replaces** that method, and with it the header and the log line (see the last
816
+ example in this section). A **host** denial is a
817
+ bodyless `403` by default — the reason header is the signal, and the gem won't
818
+ render into your app's response contract. The engine's own management UI is the
819
+ exception: it overrides the body seam to render a short page saying a full-access
820
+ role is required, because the person reading that one is an admin looking at a
821
+ browser.
822
+
823
+ The engine also registers `CurrentScope::AccessDenied → :forbidden` in
824
+ `ActionDispatch` rescue responses (only if the host has not already mapped that
825
+ class), so a denial that **escapes** Guard (PORO re-raise, Context-only
826
+ controller) is still HTTP **403**, not 500. That path is status-only — no
827
+ `X-Current-Scope-Reason` header and no denial log line unless something in your
828
+ stack writes them. On the Guard path, rescued denials log one INFO line
829
+ mirroring the header:
830
+
831
+ ```
832
+ [CurrentScope] denied reports#approve (no_grant) → 403
833
+ ```
834
+
835
+ INFO is intentional so production captures denials without raising the log
836
+ level; high-volume anonymous probes will grow the log — filter
837
+ `[CurrentScope] denied` if that is noise for your operators.
838
+
839
+ **Branded host 403 — prefer the body seam** so the header and log stay intact:
840
+
841
+ ```ruby
842
+ # Keeps X-Current-Scope-Reason + the denial log; only the body changes.
843
+ def current_scope_render_denied(reason)
844
+ render "errors/forbidden", status: :forbidden, locals: { reason: reason }
845
+ end
846
+ ```
847
+
848
+ Need `#permission` / `#record` / `#subject` on the page? A host `rescue_from`
849
+ registered **after** `include CurrentScope::Guard` wins and **replaces**
850
+ `current_scope_denied` — set the header yourself (or you lose it), and note
851
+ this example restores only the header; write your own log line if you need
852
+ the denial telemetry:
853
+
854
+ ```ruby
855
+ rescue_from CurrentScope::AccessDenied do |e|
856
+ response.headers["X-Current-Scope-Reason"] = e.reason.to_s if e.reason
857
+ render "errors/forbidden",
858
+ status: :forbidden,
859
+ locals: { permission: e.permission, reason: e.reason, record: e.record }
860
+ end
861
+ ```
785
862
 
786
863
  **View/gate disagreement is by design.** `allowed_to?` is HTTP-ignorant: it
787
864
  still returns `true` for a permission the subject genuinely holds, even though
@@ -619,6 +619,30 @@ input[type="checkbox"], input[type="radio"] {
619
619
  .cs-chip-label { padding: 0.125rem 0.25rem 0.125rem 0.6875rem; align-self: center; }
620
620
  .cs-chip form { display: flex; margin: 0; }
621
621
 
622
+ /* #90 — orphaned scoped grants (resource deleted) still list as chips/rows but
623
+ open nothing on collection reads since #65. Mark them so operators do not
624
+ trust the grant. Use .cs-inert-badge — NOT .cs-badge (topbar "full access"). */
625
+ .cs-chip--inert {
626
+ border-style: dashed;
627
+ opacity: 0.85;
628
+ background: var(--cs-surface-2);
629
+ }
630
+ .cs-row--inert td { color: var(--cs-muted); }
631
+ .cs-inert-badge {
632
+ display: inline-block;
633
+ font-size: 0.6875rem;
634
+ font-weight: 600;
635
+ letter-spacing: 0.02em;
636
+ text-transform: uppercase;
637
+ padding: 0.05rem 0.35rem;
638
+ border-radius: 0.25rem;
639
+ margin-left: 0.35rem;
640
+ vertical-align: middle;
641
+ color: var(--cs-danger, oklch(0.45 0.14 25));
642
+ background: color-mix(in oklch, var(--cs-danger, oklch(0.55 0.18 25)) 12%, transparent);
643
+ border: 1px solid color-mix(in oklch, var(--cs-danger, oklch(0.55 0.18 25)) 35%, transparent);
644
+ }
645
+
622
646
  /* Revoke control — a clear, labeled × with a divider so it reads as a button. */
623
647
  .cs-chip-remove {
624
648
  border: none;
@@ -32,8 +32,12 @@ module CurrentScope
32
32
  def require_full_access!
33
33
  return if CurrentScope.resolver.full_access?(CurrentScope::Current.user)
34
34
 
35
+ key = "#{controller_path}##{action_name}"
35
36
  raise CurrentScope::AccessDenied.new(
36
- "#{controller_path}##{action_name}", reason: :not_full_access
37
+ key,
38
+ reason: :not_full_access,
39
+ permission: key,
40
+ subject: CurrentScope::Current.user
37
41
  )
38
42
  end
39
43
 
@@ -41,11 +41,12 @@ module CurrentScope
41
41
 
42
42
  def members
43
43
  @role = Role.find(params[:id])
44
- # No polymorphic includes: eager-loading a stale/renamed subject_type or
45
- # resource_type raises NameError. Lazy-load per row and label defensively
46
- # in the view (current_scope_holder_* helpers), like the audit ledger does.
44
+ # No blanket polymorphic includes: stale subject_type/resource_type
45
+ # NameErrors at preload. Org holders stay lazy; scoped resources use the
46
+ # safe per-type preload (unresolvable types stay unloaded inert label).
47
47
  @org_holders = RoleAssignment.where(role: @role).to_a
48
- @scoped_holders = ScopedRoleAssignment.where(role: @role).to_a
48
+ @scoped_holders = ScopedRoleAssignment.where(role: @role).includes(:role).to_a
49
+ ScopedRoleAssignment.preload_resolvable_resources!(@scoped_holders)
49
50
 
50
51
  # Exclude via a subquery, not a plucked Ruby array, so a role with many
51
52
  # holders doesn't build a huge NOT IN bind list.
@@ -19,9 +19,12 @@ module CurrentScope
19
19
  @roles = Role.order(:name)
20
20
  @assignments = RoleAssignment.where(subject: @subjects)
21
21
  .index_by { |a| [ a.subject_type, a.subject_id ] }
22
- @scoped = ScopedRoleAssignment.where(subject: @subjects)
23
- .includes(:role, :resource)
24
- .group_by { |a| [ a.subject_type, a.subject_id ] }
22
+ # Safe polymorphic resource preload (resolvable types only) — full
23
+ # includes(:resource) NameErrors on a stale resource_type and 500s the
24
+ # page; skip-unresolvable + label as inert instead (#90 / PR #104).
25
+ scoped_rows = ScopedRoleAssignment.where(subject: @subjects).includes(:role).to_a
26
+ ScopedRoleAssignment.preload_resolvable_resources!(scoped_rows)
27
+ @scoped = scoped_rows.group_by { |a| [ a.subject_type, a.subject_id ] }
25
28
  end
26
29
 
27
30
  private
@@ -89,8 +89,14 @@ module CurrentScope
89
89
  end
90
90
 
91
91
  def current_scope_holder_resource_label(scoped_assignment)
92
+ if scoped_assignment.respond_to?(:orphaned_resource?) && scoped_assignment.orphaned_resource?
93
+ return "#{scoped_assignment.resource_type} ##{scoped_assignment.resource_id} (unavailable — inert)"
94
+ end
95
+
92
96
  current_scope_label(scoped_assignment.resource)
93
97
  rescue NameError, ActiveRecord::RecordNotFound
98
+ # Labeler failed — not proof the resource is orphaned. Raw type#id only
99
+ # (orphaned_resource? above owns the inert wording) — PR #104 review.
94
100
  "#{scoped_assignment.resource_type} ##{scoped_assignment.resource_id}"
95
101
  end
96
102
 
@@ -33,30 +33,45 @@ module CurrentScope
33
33
  # details: { name: "Owner" })
34
34
  #
35
35
  # Raises ConfigurationError (loud, matching the SoD posture) when there is
36
- # no ambient actor. Silent no-op (returns nil) when config.audit is false.
37
- def record!(event:, target:, details: nil)
36
+ # no ambient actor and no explicit actor: override. Silent no-op (returns
37
+ # nil) when config.audit is false.
38
+ #
39
+ # Optional actor:/subject: overrides (#30): when non-nil they replace the
40
+ # ambient reads so bootstrap paths (grant!, rake, seeds) can self-attribute
41
+ # without a controller. Omit both for byte-for-byte ambient behavior.
42
+ # Pin BOTH on a self-attributed grant so an ambient Current.user cannot
43
+ # leak into subject and mis-record the row as impersonation.
44
+ def record!(event:, target:, details: nil, actor: nil, subject: nil)
38
45
  return unless CurrentScope.config.audit
39
46
 
40
- actor = CurrentScope::Current.actor
47
+ actor ||= CurrentScope::Current.actor
41
48
  if actor.nil?
42
49
  raise CurrentScope::ConfigurationError,
43
50
  "CurrentScope::Event.record! has no actor — CurrentScope::Current.actor is nil. " \
44
51
  "Set the ambient context (the controller hook, or with_current_user in tests) before recording."
45
52
  end
46
53
 
47
- # Current.user is the effective subject; fall back to actor so subject
48
- # is never nil (it equals actor whenever not impersonating).
49
- subject = CurrentScope::Current.user || actor
54
+ # Explicit subject wins; else Current.user; else actor (never nil when
55
+ # actor is set, equals actor when not impersonating).
56
+ subject ||= CurrentScope::Current.user || actor
50
57
 
51
- create!(
52
- event: event.to_s,
53
- actor: actor.to_gid.to_s,
54
- subject: subject.to_gid.to_s,
55
- target: target.to_gid.to_s,
56
- target_label: label_for(target),
57
- details: details,
58
- request_id: CurrentScope::Current.request_id
59
- )
58
+ # requires_new: on PostgreSQL a StatementInvalid aborts the *whole*
59
+ # open transaction even if rescued — so a missing events table would
60
+ # poison grant!/controller mutation transactions that wrap record!.
61
+ # A savepoint isolates the audit write: default audit=true degrades
62
+ # and the assignment commits; :strict re-raises and rolls the outer
63
+ # mutation back (PR #102 review).
64
+ Event.transaction(requires_new: true) do
65
+ create!(
66
+ event: event.to_s,
67
+ actor: actor.to_gid.to_s,
68
+ subject: subject.to_gid.to_s,
69
+ target: target.to_gid.to_s,
70
+ target_label: label_for(target),
71
+ details: details,
72
+ request_id: CurrentScope::Current.request_id
73
+ )
74
+ end
60
75
  rescue ActiveRecord::StatementInvalid => e
61
76
  raise unless missing_events_table?(e)
62
77
 
@@ -2,6 +2,10 @@ module CurrentScope
2
2
  # A role held on ONE specific record: "Editor of Project #7" grants nothing
3
3
  # on Project #8. Never touches the subject's org-wide role — the two are
4
4
  # independent axes.
5
+ #
6
+ # Rows survive host resource destruction by design (polymorphic, no
7
+ # dependent:). Since #65 those orphan grants open nothing (empty list = 403)
8
+ # but still rendered like live access until labeled (#90).
5
9
  class ScopedRoleAssignment < ApplicationRecord
6
10
  belongs_to :role
7
11
  belongs_to :subject, polymorphic: true
@@ -10,5 +14,52 @@ module CurrentScope
10
14
  validates :role_id, uniqueness: {
11
15
  scope: [ :subject_type, :subject_id, :resource_type, :resource_id ]
12
16
  }
17
+
18
+ # Batch-load polymorphic resources for resolvable types only. A global
19
+ # includes(:resource) NameErrors when any resource_type is stale; this
20
+ # constantizes per type and skips unresolvable ones so they stay lazy
21
+ # and orphaned_resource? labels them inert (#90 / PR #104 review).
22
+ def self.preload_resolvable_resources!(assignments)
23
+ list = Array(assignments)
24
+ return list if list.empty?
25
+
26
+ list.group_by(&:resource_type).each do |type, rows|
27
+ next if type.blank?
28
+
29
+ klass =
30
+ begin
31
+ type.constantize
32
+ rescue NameError
33
+ next
34
+ end
35
+ next unless klass.respond_to?(:where)
36
+
37
+ records = klass.where(id: rows.map(&:resource_id).uniq).index_by { |r| r.id }
38
+ rows.each do |row|
39
+ assoc = row.association(:resource)
40
+ assoc.target = records[row.resource_id]
41
+ assoc.loaded!
42
+ end
43
+ end
44
+
45
+ list
46
+ end
47
+
48
+ # True when the pointed-at resource is gone (deleted row or unresolvable
49
+ # type). The grant is inert for authorization (#65) but still a console row.
50
+ # Memoized: views call this plus the label helper once each; a reset-every-
51
+ # call would re-query the resource twice per row (PR #104 cubic follow-up).
52
+ def orphaned_resource?
53
+ return @orphaned_resource if defined?(@orphaned_resource)
54
+
55
+ @orphaned_resource =
56
+ if resource_id.blank?
57
+ false
58
+ else
59
+ resource.nil?
60
+ end
61
+ rescue NameError, ActiveRecord::RecordNotFound
62
+ @orphaned_resource = true
63
+ end
13
64
  end
14
65
  end
@@ -77,12 +77,24 @@
77
77
  <% @scoped_holders.each do |sra| %>
78
78
  <% who = current_scope_holder_subject_label(sra) %>
79
79
  <% what = current_scope_holder_resource_label(sra) %>
80
- <tr>
80
+ <% orphaned = sra.orphaned_resource? %>
81
+ <tr id="scoped_holder_<%= sra.id %>"
82
+ class="cs-scoped-holder<%= ' cs-row--inert' if orphaned %>">
81
83
  <td><%= who %></td>
82
- <td><%= what %></td>
83
84
  <td>
84
- <% revoke_prompt = "Revoke #{@role.name} on #{what} from #{who}?" %>
85
- <%= button_to "Revoke", scoped_role_assignment_path(sra), method: :delete,
85
+ <%= what %>
86
+ <% if orphaned %>
87
+ <span class="cs-inert-badge"
88
+ title="This resource no longer resolves; the grant opens nothing on collection reads">inert</span>
89
+ <% end %>
90
+ </td>
91
+ <td>
92
+ <% revoke_prompt = orphaned ?
93
+ "Remove inert scoped grant (resource unavailable) for #{who}?" :
94
+ "Revoke #{@role.name} on #{what} from #{who}?" %>
95
+ <%= button_to (orphaned ? "Remove inert" : "Revoke"),
96
+ scoped_role_assignment_path(sra), method: :delete,
97
+ id: "scoped_revoke_#{sra.id}",
86
98
  class: "cs-btn", form: { data: { cs_confirm: revoke_prompt } },
87
99
  data: { turbo_confirm: revoke_prompt } %>
88
100
  </td>
@@ -63,7 +63,9 @@
63
63
  <select> listing all role names, which would match every query. %>
64
64
  <% org_role_name = @roles.find { |r| r.id == @assignments[key]&.role_id }&.name %>
65
65
  <% filter_text = [ label, org_role_name,
66
- *(@scoped[key] || []).map { |sra| "#{sra.role.name} #{current_scope_label(sra.resource)}" } ].compact.join(" ") %>
66
+ *(@scoped[key] || []).map { |sra|
67
+ "#{sra.role.name} #{current_scope_holder_resource_label(sra)}"
68
+ } ].compact.join(" ") %>
67
69
  <tr data-cs-row data-cs-filter-text="<%= filter_text %>">
68
70
  <td class="cs-select-cell">
69
71
  <input type="checkbox" data-cs-select value="<%= subject.to_gid %>"
@@ -85,14 +87,22 @@
85
87
  </td>
86
88
  <td>
87
89
  <% (@scoped[key] || []).each do |sra| %>
88
- <span class="cs-chip">
89
- <span class="cs-chip-label"><%= sra.role.name %> of <%= current_scope_label(sra.resource) %></span>
90
- <% revoke_prompt = "Revoke #{sra.role.name} on #{current_scope_label(sra.resource)}?" %>
90
+ <% resource_label = current_scope_holder_resource_label(sra) %>
91
+ <% orphaned = sra.orphaned_resource? %>
92
+ <span class="cs-chip cs-scoped-chip<%= ' cs-chip--inert' if orphaned %>" id="scoped_chip_<%= sra.id %>">
93
+ <span class="cs-chip-label">
94
+ <%= sra.role.name %> of <%= resource_label %>
95
+ <% if orphaned %><span class="cs-inert-badge">inert</span><% end %>
96
+ </span>
97
+ <% revoke_prompt = orphaned ?
98
+ "Remove inert scoped grant (#{resource_label})?" :
99
+ "Revoke #{sra.role.name} on #{resource_label}?" %>
91
100
  <%= button_to "×", scoped_role_assignment_path(sra), method: :delete,
92
101
  class: "cs-chip-remove",
93
- title: "Revoke this scoped role",
102
+ id: "scoped_chip_revoke_#{sra.id}",
103
+ title: (orphaned ? "Remove inert scoped grant" : "Revoke this scoped role"),
94
104
  form: { data: { cs_confirm: revoke_prompt } },
95
- aria: { label: "Revoke #{sra.role.name} on #{current_scope_label(sra.resource)}" },
105
+ aria: { label: (orphaned ? "Remove inert #{sra.role.name} grant on #{resource_label}" : "Revoke #{sra.role.name} on #{resource_label}") },
96
106
  data: { turbo_confirm: revoke_prompt } %>
97
107
  </span>
98
108
  <% end %>
@@ -106,6 +106,8 @@ module CurrentScope
106
106
  # controllers out of the permission grid. An excluded controller cannot be
107
107
  # granted, so it must also skip the gate
108
108
  # (skip_before_action :current_scope_check!) — Guard raises otherwise.
109
+ # Skipping the gate leaves the controller ungated by CurrentScope — protect
110
+ # it with your own authorization (e.g. require_admin!). See docs/SECURITY-CHECKLIST.md.
109
111
  attr_accessor :excluded_controllers
110
112
 
111
113
  # Class the management UI's controllers inherit from, so they pick up the
@@ -492,6 +494,43 @@ module CurrentScope
492
494
  @allow_mutations_while_impersonating = value
493
495
  end
494
496
 
497
+ # Action segment of sod_bypass_permission — bare name or "controller#action".
498
+ # Shared by boot validation and the resolver's recursion guard so the two
499
+ # never normalize differently (#40). Uses split("#", -1) like
500
+ # PermissionCatalog#bypass_action: plain split("#").last turns "reports#"
501
+ # into "reports" (trailing empty dropped) and multi-hash values into the
502
+ # wrong last segment — false conflicts or missed recursion guards.
503
+ # Malformed shapes return nil (no false conflict); the catalog still raises
504
+ # loudly when allow_sod_bypass is on and the key is malformed.
505
+ def sod_bypass_action
506
+ segments = sod_bypass_permission.to_s.split("#", -1)
507
+ return if segments.empty? || segments.size > 2 || segments.any?(&:blank?)
508
+
509
+ segments.last
510
+ end
511
+
512
+ # True when the break-glass bypass permission is also listed in sod_actions.
513
+ # That pairing would re-enter the SoD step on every bypass check and stack
514
+ # overflow. Never valid in any environment (#40).
515
+ def sod_bypass_permission_conflicts_with_sod_actions?
516
+ action = sod_bypass_action
517
+ action.present? && sod_actions.include?(action)
518
+ end
519
+
520
+ # Boot-time config invariants. Wired from Engine#after_initialize after the
521
+ # host initializer has finalized both fields. Extensible seam for future
522
+ # multi-field checks; today only the bypass-in-sod_actions recursion rule.
523
+ def validate!
524
+ return unless sod_bypass_permission_conflicts_with_sod_actions?
525
+
526
+ action = sod_bypass_action
527
+ raise ConfigurationError,
528
+ "config.sod_bypass_permission (#{sod_bypass_permission.inspect}) is the " \
529
+ "action #{action.inspect}, which is also in config.sod_actions. The bypass " \
530
+ "permission must not be an SoD action — it would recurse. Remove " \
531
+ "#{action.inspect} from sod_actions."
532
+ end
533
+
495
534
  private
496
535
 
497
536
  # The env var's VALUE means what it says — presence alone is not consent.
@@ -23,6 +23,11 @@ module CurrentScope
23
23
  # falls back to the subject. Only resolve when the host opts in.
24
24
  actor_method = CurrentScope.config.actor_method
25
25
  CurrentScope::Current.actor = resolve_current_scope_subject(actor_method) if actor_method
26
+
27
+ # Correlation for the audit ledger (#30). ActionDispatch::RequestId runs
28
+ # ahead of app before_actions; job/console contexts never enter this hook
29
+ # and leave request_id nil by design.
30
+ CurrentScope::Current.request_id = request.request_id
26
31
  end
27
32
 
28
33
  def resolve_current_scope_subject(method)
@@ -2,6 +2,35 @@ module CurrentScope
2
2
  class Engine < ::Rails::Engine
3
3
  isolate_namespace CurrentScope
4
4
 
5
+ # An AccessDenied that escapes any Guard rescue (PORO, Context-only
6
+ # controller, re-raise) must 403, not 500. Never turns a deny into an
7
+ # allow — the exception already blocked the action (#39).
8
+ #
9
+ # ||= so a host that already set this mapping (e.g. :not_found to hide
10
+ # existence) in config/application.rb is not clobbered. before:
11
+ # action_dispatch.configure so ExceptionWrapper.rescue_responses picks the
12
+ # entry up when it merge!s the config hash.
13
+ initializer "current_scope.rescue_responses", before: "action_dispatch.configure" do |app|
14
+ app.config.action_dispatch.rescue_responses["CurrentScope::AccessDenied"] ||= :forbidden
15
+ end
16
+
17
+ # Belt for Rails upgrades: if action_dispatch.configure is renamed/reordered
18
+ # and the config merge is missed, still pin the class map. Use key? — the
19
+ # ExceptionWrapper hash defaults missing keys to :internal_server_error
20
+ # (truthy), so ||= would never write.
21
+ initializer "current_scope.rescue_responses_apply", after: "action_dispatch.configure" do
22
+ map = ActionDispatch::ExceptionWrapper.rescue_responses
23
+ map["CurrentScope::AccessDenied"] = :forbidden unless map.key?("CurrentScope::AccessDenied")
24
+ end
25
+
26
+ # Cross-field config invariants (e.g. bypass permission ∉ sod_actions) must
27
+ # run AFTER the host initializer has assigned every field — a writer on
28
+ # either attr alone is order-dependent. once, not on to_prepare (config
29
+ # does not change on code reload). #40.
30
+ config.after_initialize do
31
+ CurrentScope.config.validate!
32
+ end
33
+
5
34
  # Routes (and therefore the derived permission catalog) can change on
6
35
  # every code reload in development, and reloaded host models must re-register
7
36
  # as scopeable rather than pile up stale/duplicate entries. Both reset here,
@@ -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
@@ -158,8 +160,19 @@ module CurrentScope
158
160
 
159
161
  return report_would_deny(permission, record) if report_only_denial?(reason, permission, record)
160
162
 
161
-
162
- 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
+ )
163
176
  end
164
177
 
165
178
  record_sod_bypass(permission, record) if reason == :sod_bypassed
@@ -233,6 +246,57 @@ module CurrentScope
233
246
  record_would_deny_event(permission, record)
234
247
  end
235
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
+
236
300
  # R3: report mode NEVER raises — that is its whole promise, and it has to hold
237
301
  # regardless of audit posture or the state of the ledger.
238
302
  #
@@ -267,7 +331,11 @@ module CurrentScope
267
331
  rescue StandardError => e
268
332
  # ponytail: swallow and warn ONCE. An unrecordable observation is a lost
269
333
  # log line; a raise here is a 500 on a request report mode promised to pass.
270
- 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
+ )
271
339
  nil
272
340
  end
273
341
 
@@ -283,13 +351,18 @@ module CurrentScope
283
351
  # fix for a missing table and otherwise reports the real error, because
284
352
  # telling someone with a dead connection to run migrations sends them after
285
353
  # the wrong problem.
286
- 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:)
287
359
  return if CurrentScope::Guard.ledger_warning_emitted?
288
360
 
289
361
  CurrentScope::Guard.ledger_warning_emitted!
290
- Rails.logger&.warn("[CurrentScope] report-only: #{ledger_failure_hint(error)} " \
291
- "The request WAS allowed through — only the access.would_deny " \
292
- "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
+ )
293
366
  end
294
367
 
295
368
  # ASKS Event whether this is the un-migrated-table case rather than pattern-
@@ -298,14 +371,14 @@ module CurrentScope
298
371
  # says why: it "would point operators at the wrong fix". A looser test here
299
372
  # reintroduced exactly that, telling someone their table was missing while
300
373
  # they were looking right at it. (#59 review)
301
- def ledger_failure_hint(error)
374
+ def ledger_failure_hint(error, event:)
302
375
  if CurrentScope::Event.missing_events_table?(error)
303
- "the current_scope_events table is missing, so would-be denials are not being " \
304
- "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 " \
305
378
  "`rails current_scope:install:migrations && rails db:migrate`, or set " \
306
379
  "config.audit = false if you don't want the ledger."
307
380
  else
308
- "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)})."
309
382
  end
310
383
  end
311
384
 
@@ -386,9 +459,22 @@ module CurrentScope
386
459
  return unless CurrentScope.config.warn_on_inert_scoped_grant
387
460
  return unless reason == :no_grant
388
461
  return unless record.equal?(NO_RECORD)
389
- return unless CurrentScope.resolver.scoped_grant_exists?(
390
- subject: CurrentScope::Current.user, permission: permission
391
- )
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
392
478
 
393
479
  # Says only what the predicate proves. scoped_grant_exists? has no resource
394
480
  # filter — it can't have one, the missing record IS the bug — so it
@@ -481,16 +567,23 @@ module CurrentScope
481
567
 
482
568
  def nudge_on_nil_sod_record(permission, record)
483
569
  return unless CurrentScope.config.warn_on_nil_sod_record
484
- # NO_RECORD counts: it IS the member-action-with-no-record case this nudge
485
- # exists to catch, so it must not go quiet just because the Guard now
486
- # labels that case instead of passing a bare nil.
487
- return unless record.nil? || record.equal?(NO_RECORD)
488
- 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
489
582
 
490
583
  Rails.logger&.warn(
491
- "[CurrentScope] \"#{permission}\" is a separation-of-duties action but was gated with a " \
492
- "nil record, so the SoD veto was skipped. If this is a member action, current_scope_record " \
493
- "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."
494
587
  )
495
588
  end
496
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
 
@@ -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.1"
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.1
4
+ version: 0.4.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - David Teren