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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: cb3cfaf17408e822d2ce269223bed45714e97dad48d68ad68826cb398c39434f
4
- data.tar.gz: 20729feebd795748b0898e62d4934b1ca3ed490ee585fcf911ad37d5d500e2a5
3
+ metadata.gz: 12a83f4dca4237516e79cd7b518eb943b6ba5ba8cd296b49e287859d5d7cc62d
4
+ data.tar.gz: 1a1d2cfcefbdee379195f6fd37140b7df0037fa578f9452a11043e71719f2df3
5
5
  SHA512:
6
- metadata.gz: 7b6ede764a4924731f2a92c118dd0a2c018de8a808ddbd8d9010eacd862e0e57e972dcf3fe56a4337b239e052039dc0e022a1ef0bd7113c1fa1835e5d4c55f7a
7
- data.tar.gz: 9b8823b93863c93d01eecb46a2773ad7fa3dd7fe0b9908f9a5f50137492ffb7ad4a10cbddc8207aaab0f4ed4ba27f572d5624b8687da6069878abff6465838e3
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;
@@ -659,6 +683,8 @@ input[type="checkbox"], input[type="radio"] {
659
683
  host might place in the sidebar or topbar. */
660
684
  .cs-main form p { margin: 0 0 1rem; max-width: 30rem; }
661
685
 
686
+ /* Non-control captions (bulk subjects line) share form-label type. */
687
+ .cs-main form p > .cs-label,
662
688
  .cs-main form p > label {
663
689
  display: block;
664
690
  font-size: 0.7rem;
@@ -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
 
@@ -16,16 +16,35 @@ module CurrentScope
16
16
  # only the subjects that ACTUALLY changed so the notice can't over-report
17
17
  # (a re-set to the same role, or a clear on a subject with no role, is a
18
18
  # no-op and shouldn't be counted).
19
+ #
20
+ # Lock full-access holders inside the same transaction as the writes so
21
+ # concurrent clears cannot both observe "another holder remains" and then
22
+ # both proceed to zero holders.
19
23
  changed = 0
24
+ refused = false
20
25
  RoleAssignment.transaction do
21
- subjects.each do |subject|
22
- assignment = RoleAssignment.find_or_initialize_by(subject: subject)
23
- prior_role = assignment.role # nil for a brand-new assignment
24
- did = clearing ? clear_org_role(subject, assignment, prior_role) : set_org_role(subject, assignment, prior_role)
25
- changed += 1 if did
26
+ lock_full_access_org_holders!
27
+
28
+ if would_remove_last_full_access_holders?(subjects, clearing: clearing)
29
+ refused = true
30
+ else
31
+ subjects.each do |subject|
32
+ assignment = RoleAssignment.find_or_initialize_by(subject: subject)
33
+ prior_role = assignment.role # nil for a brand-new assignment
34
+ did = clearing ? clear_org_role(subject, assignment, prior_role) : set_org_role(subject, assignment, prior_role)
35
+ changed += 1 if did
36
+ end
26
37
  end
27
38
  end
28
39
 
40
+ if refused
41
+ redirect_back_or_to subjects_path,
42
+ alert: "Refusing to remove the last full-access org-wide assignment — " \
43
+ "it would lock everyone out of this UI. Grant full access to " \
44
+ "another subject first, then retry."
45
+ return
46
+ end
47
+
29
48
  # Return to wherever the action was invoked (the subjects page or a role's
30
49
  # members page); falls back to subjects when there's no referrer.
31
50
  redirect_back_or_to subjects_path, notice: org_notice(clearing, changed)
@@ -37,14 +56,32 @@ module CurrentScope
37
56
  # clean up an orphaned assignment whose subject was deleted (the subject-keyed
38
57
  # clear on `create` can't target a subject that no longer resolves).
39
58
  def destroy
40
- assignment = RoleAssignment.find(params[:id])
41
- subject = resolve_subject(assignment)
42
- role_name = assignment.role.name
43
-
59
+ refused = false
44
60
  RoleAssignment.transaction do
45
- assignment.destroy!
46
- Event.record!(event: "org_role.removed", target: subject || assignment, details: { role: role_name })
61
+ # Lock FA state BEFORE the target assignment so order matches create/
62
+ # role demote/delete (FA roles FA holders target row). Locking the
63
+ # assignment first inverted that order and could deadlock.
64
+ lock_full_access_org_holders!
65
+ assignment = RoleAssignment.lock.find(params[:id])
66
+ subject = resolve_subject(assignment)
67
+ role_name = assignment.role.name
68
+
69
+ if last_full_access_org_assignment?(assignment)
70
+ refused = true
71
+ else
72
+ assignment.destroy!
73
+ Event.record!(event: "org_role.removed", target: subject || assignment, details: { role: role_name })
74
+ end
47
75
  end
76
+
77
+ if refused
78
+ redirect_back_or_to subjects_path,
79
+ alert: "Refusing to remove the last full-access org-wide assignment — " \
80
+ "it would lock everyone out of this UI. Grant full access to " \
81
+ "another subject first, then retry."
82
+ return
83
+ end
84
+
48
85
  redirect_back_or_to subjects_path, notice: "Org-wide role removed."
49
86
  rescue ActiveRecord::RecordNotFound
50
87
  redirect_back_or_to subjects_path, notice: "That org-wide role was already removed."
@@ -68,6 +105,54 @@ module CurrentScope
68
105
  count == 1 ? "Org-wide role #{verb}." : "Org-wide role #{verb} for #{count} subjects."
69
106
  end
70
107
 
108
+ # True when this assignment is a live full_access org holder and no other
109
+ # full_access org assignment exists. Orphan rows (deleted subject → nil)
110
+ # must not permanently block cleanup of the last FA assignment.
111
+ def last_full_access_org_assignment?(assignment)
112
+ return false unless assignment.role&.full_access?
113
+ return false if resolve_subject(assignment).nil?
114
+
115
+ !full_access_org_assignments.where.not(id: assignment.id).exists?
116
+ end
117
+
118
+ # True when applying clear (or reassign to a non-full_access role) to these
119
+ # subjects would leave zero full_access org holders.
120
+ def would_remove_last_full_access_holders?(subjects, clearing:)
121
+ holders = full_access_org_assignments.to_a
122
+ return false if holders.empty?
123
+
124
+ affected_ids = holders.select { |a| subjects.any? { |s| same_subject?(a, s) } }.map(&:id)
125
+ return false if affected_ids.empty?
126
+
127
+ unless clearing
128
+ new_role = Role.find_by(id: params[:role_id])
129
+ return false if new_role&.full_access?
130
+ end
131
+
132
+ remaining = holders.reject { |a| affected_ids.include?(a.id) }
133
+ remaining.empty?
134
+ end
135
+
136
+ def full_access_org_assignments
137
+ RoleAssignment.joins(:role).where(current_scope_roles: { full_access: true })
138
+ end
139
+
140
+ # Lock full-access holder rows (and their roles) so concurrent remove/demote
141
+ # paths serialize on the same set the precheck reads. Call only inside a
142
+ # transaction. Prefer locking by id after a join pluck — FOR UPDATE with
143
+ # joins is adapter-fragile.
144
+ def lock_full_access_org_holders!
145
+ Role.where(full_access: true).lock.load
146
+ ids = full_access_org_assignments.pluck(:id)
147
+ RoleAssignment.where(id: ids).lock.load if ids.any?
148
+ end
149
+
150
+ def same_subject?(assignment, subject)
151
+ # Match the polymorphic storage name the subjects page uses (not only
152
+ # base_class.name — hosts can customize polymorphic_name).
153
+ assignment.subject_type == subject.class.polymorphic_name && assignment.subject_id == subject.id
154
+ end
155
+
71
156
  # Returns true when a role was actually cleared, false when there was nothing
72
157
  # to clear (so the caller's count stays accurate). Atomicity comes from
73
158
  # create's outer bulk transaction — only called from inside it. (No inner
@@ -1,7 +1,8 @@
1
1
  module CurrentScope
2
2
  class RolesController < ApplicationController
3
3
  def index
4
- @roles = Role.order(:name)
4
+ # Includes for delete-confirm holder counts (cascade warning).
5
+ @roles = Role.order(:name).includes(:role_assignments, :scoped_role_assignments)
5
6
  end
6
7
 
7
8
  def new
@@ -40,11 +41,12 @@ module CurrentScope
40
41
 
41
42
  def members
42
43
  @role = Role.find(params[:id])
43
- # No polymorphic includes: eager-loading a stale/renamed subject_type or
44
- # resource_type raises NameError. Lazy-load per row and label defensively
45
- # 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).
46
47
  @org_holders = RoleAssignment.where(role: @role).to_a
47
- @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)
48
50
 
49
51
  # Exclude via a subquery, not a plucked Ruby array, so a role with many
50
52
  # holders doesn't build a huge NOT IN bind list.
@@ -55,12 +57,36 @@ module CurrentScope
55
57
  end
56
58
 
57
59
  def update
58
- @role = Role.find(params[:id])
59
- previous_name = @role.name
60
+ permitted = role_params
61
+ previous_name = nil
60
62
  saved = false
63
+ refused = false
64
+
65
+ # Lock full-access roles + holders inside the write transaction so two
66
+ # concurrent demotions of the last held full-access roles cannot both pass
67
+ # a pre-transaction check and then both commit.
61
68
  Role.transaction do
62
- saved = @role.update(role_params)
63
- record_role_update(@role, previous_name) if saved
69
+ # Lock FA console state before the target role so concurrent demote/delete
70
+ # of two FA roles cannot invert lock order (roles first, then assignments).
71
+ lock_full_access_console_state!
72
+ @role = Role.lock.find(params[:id])
73
+
74
+ if demoting_would_lock_console?(@role, permitted)
75
+ refused = true
76
+ else
77
+ previous_name = @role.name
78
+ previous_full_access = @role.full_access?
79
+ saved = @role.update(permitted)
80
+ record_role_update(@role, previous_name, previous_full_access) if saved
81
+ end
82
+ end
83
+
84
+ if refused
85
+ redirect_to edit_role_path(@role),
86
+ alert: "Refusing to remove full access — this is the last full-access role " \
87
+ "any subject holds and would lock everyone out of this UI. Grant " \
88
+ "full access to another subject first, then retry."
89
+ return
64
90
  end
65
91
 
66
92
  if saved
@@ -71,44 +97,81 @@ module CurrentScope
71
97
  end
72
98
 
73
99
  def destroy
74
- role = Role.find(params[:id])
100
+ refused = false
101
+
102
+ Role.transaction do
103
+ lock_full_access_console_state!
104
+ role = Role.lock.find(params[:id])
105
+
106
+ if would_lock_console_by_removing_role?(role)
107
+ refused = true
108
+ else
109
+ # Snapshot WITHOUT polymorphic includes — includes(:subject)/:resource
110
+ # can raise NameError for stale types at preload (members page avoids
111
+ # this for the same reason). Resolve each row inside the helpers.
112
+ org_removed = role.role_assignments.to_a
113
+ scoped_revoked = role.scoped_role_assignments.to_a
114
+
115
+ role.destroy!
116
+ Event.record!(event: "role.deleted", target: role, details: { name: role.name })
117
+ org_removed.each do |a|
118
+ Event.record!(event: "org_role.removed", target: cascade_subject(a), details: { role: role.name })
119
+ end
120
+ scoped_revoked.each do |a|
121
+ Event.record!(event: "scoped_role.revoked", target: cascade_subject(a),
122
+ details: { role: role.name, resource: cascade_resource_label(a) })
123
+ end
124
+ end
125
+ end
75
126
 
76
- if last_full_access?(role)
127
+ if refused
77
128
  redirect_to roles_path,
78
- alert: "Refusing to delete the last full-access role — it would lock everyone out of this UI."
129
+ alert: "Refusing to delete this full-access role — it is the last one held by any " \
130
+ "subject and would lock everyone out of this UI. Grant full access to " \
131
+ "another subject first, then retry."
79
132
  return
80
133
  end
81
134
 
82
- # Snapshot the cascade BEFORE destroy! — dependent: :destroy takes the
83
- # assignments with the role, so they can't be read afterwards.
84
- org_removed = role.role_assignments.includes(:subject).to_a
85
- scoped_revoked = role.scoped_role_assignments.includes(:subject, :resource).to_a
86
-
87
- Role.transaction do
88
- role.destroy!
89
- Event.record!(event: "role.deleted", target: role, details: { name: role.name })
90
- org_removed.each do |a|
91
- Event.record!(event: "org_role.removed", target: a.subject, details: { role: role.name })
92
- end
93
- scoped_revoked.each do |a|
94
- Event.record!(event: "scoped_role.revoked", target: a.subject,
95
- details: { role: role.name, resource: helpers.current_scope_label(a.resource) })
96
- end
97
- end
98
135
  redirect_to roles_path, notice: "Role deleted."
99
136
  end
100
137
 
101
138
  private
102
139
 
140
+ # Polymorphic subject/resource may be deleted or unresolvable — never 500
141
+ # the cascade audit. Deleted records return nil without raising (especially
142
+ # after includes preload), so use || assignment, not rescue-only.
143
+ def cascade_subject(assignment)
144
+ assignment.subject || assignment
145
+ rescue ActiveRecord::RecordNotFound, NameError
146
+ assignment
147
+ end
148
+
149
+ def cascade_resource_label(assignment)
150
+ resource = begin
151
+ assignment.resource
152
+ rescue ActiveRecord::RecordNotFound, NameError
153
+ nil
154
+ end
155
+ return "#{assignment.resource_type}##{assignment.resource_id}" if resource.nil?
156
+
157
+ helpers.current_scope_label(resource)
158
+ rescue StandardError
159
+ "#{assignment.resource_type}##{assignment.resource_id}"
160
+ end
161
+
103
162
  # One event per save: role.renamed when the name changed (carries old/new
104
- # name AND the grid diff), else role.updated for a pure grid change. Emits
105
- # nothing when neither the name nor the grid moved.
106
- def record_role_update(role, previous_name)
163
+ # name AND the grid/full_access diff), else role.updated. Emits nothing on
164
+ # a pure no-op (same name, same grid, same full_access).
165
+ def record_role_update(role, previous_name, previous_full_access)
107
166
  diff = role.permission_keys_change || { added: [], removed: [], rejected: [] }
108
167
  renamed = previous_name != role.name
109
- return unless renamed || diff[:added].any? || diff[:removed].any?
168
+ full_access_changed = previous_full_access != role.full_access?
169
+ return unless renamed || full_access_changed || diff[:added].any? || diff[:removed].any?
110
170
 
111
171
  details = { added: diff[:added], removed: diff[:removed] }
172
+ if full_access_changed
173
+ details.merge!(full_access_from: previous_full_access, full_access_to: role.full_access?)
174
+ end
112
175
  event = "role.updated"
113
176
  if renamed
114
177
  event = "role.renamed"
@@ -117,8 +180,40 @@ module CurrentScope
117
180
  Event.record!(event: event, target: role, details: details)
118
181
  end
119
182
 
120
- def last_full_access?(role)
121
- role.full_access? && !Role.where(full_access: true).where.not(id: role.id).exists?
183
+ # True when removing/demoting this full_access role would leave zero
184
+ # full_access org holders. An unassigned full_access role is always safe
185
+ # to delete/demote (cubic). An empty spare full_access role must NOT
186
+ # authorize demoting the held Owner (CE) — check holders, not role rows.
187
+ def would_lock_console_by_removing_role?(role)
188
+ return false unless role.full_access?
189
+ return false unless RoleAssignment.where(role: role).exists?
190
+
191
+ !RoleAssignment.joins(:role)
192
+ .where(current_scope_roles: { full_access: true })
193
+ .where.not(role_id: role.id)
194
+ .exists?
195
+ end
196
+
197
+ # True when the update would turn off full_access and lock the console.
198
+ # Only treats an EXPLICIT full_access=false as demotion — a missing key
199
+ # would not change the column and must not false-positive refuse.
200
+ def demoting_would_lock_console?(role, permitted)
201
+ return false unless role.full_access?
202
+ return false unless permitted.key?(:full_access)
203
+ return false if ActiveModel::Type::Boolean.new.cast(permitted[:full_access])
204
+
205
+ would_lock_console_by_removing_role?(role)
206
+ end
207
+
208
+ # Serialize demote/delete against concurrent last-holder removal. Lock FA
209
+ # role rows and their org-wide holder assignments (by id — FOR UPDATE + join
210
+ # is adapter-fragile). Call only inside a transaction.
211
+ def lock_full_access_console_state!
212
+ Role.where(full_access: true).lock.load
213
+ ids = RoleAssignment.joins(:role)
214
+ .where(current_scope_roles: { full_access: true })
215
+ .pluck(:id)
216
+ RoleAssignment.where(id: ids).lock.load if ids.any?
122
217
  end
123
218
 
124
219
  def role_params
@@ -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