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 +4 -4
- data/README.md +94 -17
- data/app/assets/stylesheets/current_scope/application.css +24 -0
- data/app/controllers/current_scope/application_controller.rb +5 -1
- data/app/controllers/current_scope/roles_controller.rb +5 -4
- 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/members.html.erb +16 -4
- data/app/views/current_scope/subjects/index.html.erb +16 -6
- data/lib/current_scope/configuration.rb +39 -0
- data/lib/current_scope/context.rb +5 -0
- data/lib/current_scope/engine.rb +29 -0
- data/lib/current_scope/guard.rb +116 -23
- data/lib/current_scope/mutation_guard.rb +11 -1
- 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;
|
|
@@ -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
|
|
|
@@ -41,11 +41,12 @@ module CurrentScope
|
|
|
41
41
|
|
|
42
42
|
def members
|
|
43
43
|
@role = Role.find(params[:id])
|
|
44
|
-
# No polymorphic includes:
|
|
45
|
-
#
|
|
46
|
-
#
|
|
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
|
-
|
|
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
|
|
|
@@ -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
|
|
37
|
-
|
|
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
|
|
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
|
-
#
|
|
48
|
-
# is
|
|
49
|
-
subject
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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|
|
|
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
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
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: "
|
|
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)
|
data/lib/current_scope/engine.rb
CHANGED
|
@@ -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,
|
data/lib/current_scope/guard.rb
CHANGED
|
@@ -115,7 +115,9 @@ module CurrentScope
|
|
|
115
115
|
raise CurrentScope::ConfigurationError,
|
|
116
116
|
"\"#{permission}\" is not in the permission catalog (excluded_controllers " \
|
|
117
117
|
"or not routed). Either stop excluding it, or skip the gate here with " \
|
|
118
|
-
"skip_before_action :current_scope_check!."
|
|
118
|
+
"skip_before_action :current_scope_check!. Skipping the gate leaves this " \
|
|
119
|
+
"controller ungated by CurrentScope — protect it with your own " \
|
|
120
|
+
"authorization (e.g. require_admin!)."
|
|
119
121
|
end
|
|
120
122
|
|
|
121
123
|
record = resolve_current_scope_record
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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(
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
304
|
-
"recorded and `rails current_scope:report` will be
|
|
376
|
+
"the current_scope_events table is missing, so #{event} rows are not being " \
|
|
377
|
+
"recorded and `rails current_scope:report` will be incomplete. Run " \
|
|
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
|
|
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
|
-
|
|
390
|
-
|
|
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
|
-
#
|
|
485
|
-
#
|
|
486
|
-
#
|
|
487
|
-
|
|
488
|
-
return unless CurrentScope.
|
|
570
|
+
# Ask the resolver (#74) — do not re-derive "did the veto skip?". A private
|
|
571
|
+
# nil/NO_RECORD copy missed the commonest mistake: a hook returning
|
|
572
|
+
# params[:id] (a String). sod_veto_skipped? is the single definition.
|
|
573
|
+
gate_record = record.equal?(NO_RECORD) ? nil : record
|
|
574
|
+
return unless CurrentScope.resolver.sod_veto_skipped?(permission: permission, record: gate_record)
|
|
575
|
+
|
|
576
|
+
shape_hint =
|
|
577
|
+
if gate_record.nil?
|
|
578
|
+
"a nil record"
|
|
579
|
+
else
|
|
580
|
+
"a non-record (#{gate_record.class.name}) — often a hook returning params[:id]"
|
|
581
|
+
end
|
|
489
582
|
|
|
490
583
|
Rails.logger&.warn(
|
|
491
|
-
"[CurrentScope] \"#{permission}\" is a separation-of-duties action but was gated with
|
|
492
|
-
"
|
|
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
|
-
|
|
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.
|
|
270
|
-
#
|
|
271
|
-
#
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
raise ConfigurationError,
|
|
275
|
-
"config.sod_bypass_permission (#{CurrentScope.config.sod_bypass_permission.inspect}) is the " \
|
|
276
|
-
"action #{bypass_action.inspect}, which is also in config.sod_actions. The bypass permission " \
|
|
277
|
-
"must not be an SoD action — it would recurse. Remove #{bypass_action.inspect} from sod_actions."
|
|
269
|
+
# without recursing. Boot validate! is the primary gate (#40); this raise
|
|
270
|
+
# is defense in depth for runtime-mutated config (tests reassign live).
|
|
271
|
+
# The conflict test lives on Configuration so boot and decide cannot drift.
|
|
272
|
+
if CurrentScope.config.sod_bypass_permission_conflicts_with_sod_actions?
|
|
273
|
+
CurrentScope.config.validate!
|
|
278
274
|
end
|
|
279
275
|
|
|
280
276
|
# Absent hook ⇒ this type never breaks glass (fail-closed, no raise).
|
data/lib/current_scope.rb
CHANGED
|
@@ -14,9 +14,20 @@ require "current_scope/engine"
|
|
|
14
14
|
|
|
15
15
|
module CurrentScope
|
|
16
16
|
# Raised when the resolver denies an action gated by Guard (or when the
|
|
17
|
-
# management UI is accessed without a full-access role).
|
|
18
|
-
#
|
|
19
|
-
#
|
|
17
|
+
# management UI is accessed without a full-access role). Accessors:
|
|
18
|
+
#
|
|
19
|
+
# #permission — denied key (stable API for branded 403s). Defaults to the
|
|
20
|
+
# positional message when permission: is omitted so legacy
|
|
21
|
+
# raise sites still populate it. Prefer this over #message.
|
|
22
|
+
# #message — StandardError message (positional arg). Gem raise sites pass
|
|
23
|
+
# the key as both message and permission; they can diverge if
|
|
24
|
+
# a caller passes an explicit permission: keyword.
|
|
25
|
+
# #reason — machine-readable cause (also on X-Current-Scope-Reason)
|
|
26
|
+
# #record — the record under decision when the gate had one; nil for
|
|
27
|
+
# collection / impersonation-gate denials
|
|
28
|
+
# #subject — effective subject when known; nil if none was in scope
|
|
29
|
+
#
|
|
30
|
+
# Reasons surfaced by current_scope_denied:
|
|
20
31
|
#
|
|
21
32
|
# :sod_veto — the record's initiator can't perform an SoD action on it
|
|
22
33
|
# :no_grant — nothing granted the permission (the default deny)
|
|
@@ -34,11 +45,16 @@ module CurrentScope
|
|
|
34
45
|
# denial cannot exist that forgets its reason. (:sod_bypassed is the one
|
|
35
46
|
# audited ALLOW, so it is set by the Guard rather than raised here.)
|
|
36
47
|
class AccessDenied < StandardError
|
|
37
|
-
attr_reader :reason
|
|
48
|
+
attr_reader :reason, :permission, :record, :subject
|
|
38
49
|
|
|
39
|
-
def initialize(message = nil, reason: nil)
|
|
50
|
+
def initialize(message = nil, reason: nil, permission: nil, record: nil, subject: nil)
|
|
40
51
|
super(message)
|
|
41
52
|
@reason = reason
|
|
53
|
+
# permission defaults to message so older raise sites and branded-403
|
|
54
|
+
# recipes that only pass the key positionally still populate #permission.
|
|
55
|
+
@permission = permission || message
|
|
56
|
+
@record = record
|
|
57
|
+
@subject = subject
|
|
42
58
|
end
|
|
43
59
|
end
|
|
44
60
|
|
|
@@ -190,12 +206,43 @@ module CurrentScope
|
|
|
190
206
|
# Seeds the default Owner/Member roles ONLY on the default path — the name
|
|
191
207
|
# promises "assign a role", so a caller granting an explicit role must not
|
|
192
208
|
# get a full-access Owner row created in their roles table as a side effect.
|
|
209
|
+
#
|
|
210
|
+
# Audit (#30): when the org role actually changes, records one
|
|
211
|
+
# `org_role.assigned` / `org_role.changed` event, self-attributed to the
|
|
212
|
+
# grantee with `details.source = "bootstrap"`. Same-role re-grants are a
|
|
213
|
+
# no-op event-wise. Direct model writes and TestHelpers do not go through
|
|
214
|
+
# this path and are not recorded (documented intentionally).
|
|
193
215
|
def grant!(subject, role: nil)
|
|
194
216
|
role ||= begin
|
|
195
217
|
seed_defaults!
|
|
196
218
|
Role.find_by!(name: "Owner")
|
|
197
219
|
end
|
|
198
|
-
|
|
220
|
+
|
|
221
|
+
RoleAssignment.transaction do
|
|
222
|
+
assignment = RoleAssignment.find_or_initialize_by(subject: subject)
|
|
223
|
+
prior_role = assignment.persisted? ? assignment.role : nil
|
|
224
|
+
assignment.update!(role: role)
|
|
225
|
+
|
|
226
|
+
if prior_role.nil?
|
|
227
|
+
Event.record!(
|
|
228
|
+
event: "org_role.assigned",
|
|
229
|
+
target: subject,
|
|
230
|
+
details: { role: role.name, source: "bootstrap" },
|
|
231
|
+
actor: subject,
|
|
232
|
+
subject: subject
|
|
233
|
+
)
|
|
234
|
+
elsif prior_role.id != role.id
|
|
235
|
+
Event.record!(
|
|
236
|
+
event: "org_role.changed",
|
|
237
|
+
target: subject,
|
|
238
|
+
details: { from: prior_role.name, to: role.name, source: "bootstrap" },
|
|
239
|
+
actor: subject,
|
|
240
|
+
subject: subject
|
|
241
|
+
)
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
assignment
|
|
245
|
+
end
|
|
199
246
|
end
|
|
200
247
|
|
|
201
248
|
private
|
|
@@ -110,8 +110,10 @@ CurrentScope.configure do |config|
|
|
|
110
110
|
|
|
111
111
|
# Audit ledger — tri-state: false | true (default) | :strict.
|
|
112
112
|
# false — no audit rows recorded.
|
|
113
|
-
# true — record
|
|
114
|
-
#
|
|
113
|
+
# true — record management-UI mutations, impersonation boundary events,
|
|
114
|
+
# and grant!/rake/seeds bootstrap grants; if the events table
|
|
115
|
+
# hasn't been migrated yet, degrade gracefully (skip + warn once).
|
|
116
|
+
# Direct model writes and test helpers are not recorded.
|
|
115
117
|
# :strict — a missing events table RAISES (rolling the mutation back), so an
|
|
116
118
|
# audit-mandatory app never commits an unaudited change.
|
|
117
119
|
# config.audit = true
|
|
@@ -159,6 +161,9 @@ CurrentScope.configure do |config|
|
|
|
159
161
|
# Controller paths (regexps) excluded from the permission grid. Excluded
|
|
160
162
|
# controllers can't be granted, so they must also skip the gate with
|
|
161
163
|
# skip_before_action :current_scope_check! — Guard raises otherwise.
|
|
164
|
+
# Skipping the gate leaves the controller ungated by CurrentScope — protect
|
|
165
|
+
# it with your own authorization (e.g. require_admin!). See
|
|
166
|
+
# https://github.com/davidteren/current_scope/blob/main/docs/SECURITY-CHECKLIST.md
|
|
162
167
|
# config.excluded_controllers += [%r{\Awebhooks/}]
|
|
163
168
|
|
|
164
169
|
# Controller the management UI inherits from (for host auth + before_actions).
|
|
@@ -9,6 +9,14 @@ namespace :current_scope do
|
|
|
9
9
|
subject = klass.find_by(id: id)
|
|
10
10
|
abort "No #{klass} with id=#{id}" if subject.nil?
|
|
11
11
|
|
|
12
|
+
# grant! seeds Owner on the default path — warn on replacement even when
|
|
13
|
+
# the Owner row does not exist yet (first-time Owner creation).
|
|
14
|
+
prior = CurrentScope::RoleAssignment.find_by(subject: subject)&.role
|
|
15
|
+
if prior && prior.name != "Owner"
|
|
16
|
+
warn "WARNING: #{klass}##{subject.id} already held the #{prior.name.inspect} role — " \
|
|
17
|
+
"replacing it with full-access Owner."
|
|
18
|
+
end
|
|
19
|
+
|
|
12
20
|
CurrentScope.grant!(subject)
|
|
13
21
|
puts "Granted the full-access Owner role to #{klass}##{subject.id}."
|
|
14
22
|
end
|
|
@@ -32,6 +40,10 @@ namespace :current_scope do
|
|
|
32
40
|
begin
|
|
33
41
|
rows = CurrentScope::Event.where(event: "access.would_deny")
|
|
34
42
|
.pluck(:subject, :target_label, :details)
|
|
43
|
+
# #73: SoD blind-spot 403s are NOT would_deny (granting won't fix them).
|
|
44
|
+
# Surface them as a separate section so the survey is complete.
|
|
45
|
+
blind_rows = CurrentScope::Event.where(event: "access.sod_blind_spot")
|
|
46
|
+
.pluck(:subject, :target_label, :details)
|
|
35
47
|
rescue ActiveRecord::StatementInvalid => e
|
|
36
48
|
# Report mode without the migration records nothing (the ledger degrades and
|
|
37
49
|
# warns once). Reaching for this summary is exactly how a host discovers
|
|
@@ -42,7 +54,7 @@ namespace :current_scope do
|
|
|
42
54
|
"Run: bin/rails current_scope:install:migrations && bin/rails db:migrate"
|
|
43
55
|
end
|
|
44
56
|
|
|
45
|
-
if rows.empty?
|
|
57
|
+
if rows.empty? && blind_rows.empty?
|
|
46
58
|
# "No output" is indistinguishable from "the task is broken", and the two
|
|
47
59
|
# likeliest causes are both SILENT: report mode never on, or audit off.
|
|
48
60
|
# Name them — this is the first thing a host runs, and an unexplained blank
|
|
@@ -61,25 +73,42 @@ namespace :current_scope do
|
|
|
61
73
|
# ponytail: group in Ruby, not SQL. `details` is a JSON column and querying
|
|
62
74
|
# into it is adapter-specific; this is a rollout aid run by hand over a
|
|
63
75
|
# transitional table, so portability beats a smarter query.
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
grouped.each do |subject_gid, subject_rows|
|
|
70
|
-
label = subject_rows.first[1].presence || subject_gid
|
|
71
|
-
puts " #{label}#{org_role_suffix.call(subject_gid)}"
|
|
72
|
-
|
|
73
|
-
subject_rows
|
|
76
|
+
#
|
|
77
|
+
# Shared tally so would_deny and sod_blind_spot sections cannot drift on
|
|
78
|
+
# ordering / unknown-permission handling (PR #103 review).
|
|
79
|
+
print_permission_counts = lambda do |event_rows|
|
|
80
|
+
event_rows
|
|
74
81
|
.group_by { |_s, _l, details| details.is_a?(Hash) ? details["permission"] : nil }
|
|
75
82
|
.transform_values(&:count)
|
|
76
83
|
.sort_by { |permission, count| [ -count, permission.to_s ] }
|
|
77
84
|
.each { |permission, count| puts " #{count.to_s.rjust(5)}x #{permission || '(unknown)'}" }
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
unless rows.empty?
|
|
88
|
+
grouped = rows.group_by { |subject, _label, _details| subject }
|
|
78
89
|
|
|
90
|
+
puts "Would-be denials — grant these to stop them (most-denied first):"
|
|
79
91
|
puts
|
|
92
|
+
|
|
93
|
+
grouped.each do |subject_gid, subject_rows|
|
|
94
|
+
label = subject_rows.first[1].presence || subject_gid
|
|
95
|
+
puts " #{label}#{org_role_suffix.call(subject_gid)}"
|
|
96
|
+
print_permission_counts.call(subject_rows)
|
|
97
|
+
puts
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
puts "Total: #{rows.count} would-be denials across #{grouped.size} subject(s)."
|
|
80
101
|
end
|
|
81
102
|
|
|
82
|
-
|
|
103
|
+
unless blind_rows.empty?
|
|
104
|
+
puts if rows.any?
|
|
105
|
+
puts "SoD blind-spot denials — NOT fixed by granting (declare current_scope_record):"
|
|
106
|
+
puts
|
|
107
|
+
print_permission_counts.call(blind_rows)
|
|
108
|
+
puts
|
|
109
|
+
puts "Total: #{blind_rows.count} blind-spot 403(s). Granting these permissions will not " \
|
|
110
|
+
"clear them — fix the record hook (or remove the action from config.sod_actions)."
|
|
111
|
+
end
|
|
83
112
|
end
|
|
84
113
|
|
|
85
114
|
desc "Inventory the routed controllers that provably never run the gate — the static " \
|