current_scope 0.3.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/README.md +94 -17
- data/app/assets/stylesheets/current_scope/application.css +26 -0
- data/app/controllers/current_scope/application_controller.rb +5 -1
- data/app/controllers/current_scope/role_assignments_controller.rb +96 -11
- data/app/controllers/current_scope/roles_controller.rb +129 -34
- data/app/controllers/current_scope/subjects_controller.rb +6 -3
- data/app/helpers/current_scope/application_helper.rb +6 -0
- data/app/models/current_scope/event.rb +30 -15
- data/app/models/current_scope/scoped_role_assignment.rb +51 -0
- data/app/views/current_scope/roles/index.html.erb +16 -2
- data/app/views/current_scope/roles/members.html.erb +16 -4
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +7 -7
- data/app/views/current_scope/subjects/index.html.erb +20 -8
- data/lib/current_scope/configuration.rb +81 -11
- data/lib/current_scope/context.rb +5 -0
- data/lib/current_scope/engine.rb +29 -0
- data/lib/current_scope/guard.rb +120 -25
- data/lib/current_scope/mutation_guard.rb +11 -1
- data/lib/current_scope/permissions.rb +14 -11
- data/lib/current_scope/resolver.rb +5 -9
- data/lib/current_scope/version.rb +1 -1
- data/lib/current_scope.rb +53 -6
- data/lib/generators/current_scope/install/templates/initializer.rb +7 -2
- data/lib/tasks/current_scope_tasks.rake +41 -12
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 12a83f4dca4237516e79cd7b518eb943b6ba5ba8cd296b49e287859d5d7cc62d
|
|
4
|
+
data.tar.gz: 1a1d2cfcefbdee379195f6fd37140b7df0037fa578f9452a11043e71719f2df3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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/) —
|
|
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,
|
|
162
|
-
|
|
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. (`
|
|
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
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
|
768
|
-
|
|
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
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
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
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
41
|
-
subject = resolve_subject(assignment)
|
|
42
|
-
role_name = assignment.role.name
|
|
43
|
-
|
|
59
|
+
refused = false
|
|
44
60
|
RoleAssignment.transaction do
|
|
45
|
-
assignment
|
|
46
|
-
|
|
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
|
-
|
|
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:
|
|
44
|
-
#
|
|
45
|
-
#
|
|
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
|
-
|
|
59
|
-
previous_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
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
127
|
+
if refused
|
|
77
128
|
redirect_to roles_path,
|
|
78
|
-
alert: "Refusing to delete
|
|
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
|
|
105
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
|