current_scope 0.2.0 → 0.3.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 +317 -18
- data/app/assets/javascripts/current_scope/application.js +4 -0
- data/app/assets/stylesheets/current_scope/application.css +99 -0
- data/app/controllers/current_scope/application_controller.rb +39 -1
- data/app/controllers/current_scope/role_assignments_controller.rb +14 -14
- data/app/controllers/current_scope/roles_controller.rb +6 -2
- data/app/helpers/current_scope/application_helper.rb +168 -12
- data/app/models/current_scope/current.rb +24 -0
- data/app/models/current_scope/event.rb +10 -6
- data/app/models/current_scope/role.rb +61 -10
- data/app/views/current_scope/roles/edit.html.erb +92 -4
- data/app/views/current_scope/roles/members.html.erb +3 -3
- data/app/views/current_scope/roles/new.html.erb +1 -1
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +12 -3
- data/app/views/current_scope/shared/access_denied.html.erb +30 -0
- data/app/views/current_scope/subjects/index.html.erb +17 -5
- data/app/views/layouts/current_scope/application.html.erb +4 -1
- data/config/routes.rb +3 -4
- data/lib/current_scope/configuration.rb +378 -16
- data/lib/current_scope/engine.rb +7 -0
- data/lib/current_scope/gating_reflection.rb +62 -0
- data/lib/current_scope/gating_tripwire.rb +36 -5
- data/lib/current_scope/guard.rb +406 -8
- data/lib/current_scope/mutation_guard.rb +30 -5
- data/lib/current_scope/permission_catalog.rb +116 -3
- data/lib/current_scope/permission_grid.rb +34 -4
- data/lib/current_scope/permissions.rb +42 -8
- data/lib/current_scope/resolver.rb +317 -13
- data/lib/current_scope/version.rb +1 -1
- data/lib/current_scope.rb +113 -5
- data/lib/generators/current_scope/install/install_generator.rb +64 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +94 -5
- data/lib/tasks/current_scope_tasks.rake +153 -0
- metadata +6 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb3cfaf17408e822d2ce269223bed45714e97dad48d68ad68826cb398c39434f
|
|
4
|
+
data.tar.gz: 20729feebd795748b0898e62d4934b1ca3ed490ee585fcf911ad37d5d500e2a5
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7b6ede764a4924731f2a92c118dd0a2c018de8a808ddbd8d9010eacd862e0e57e972dcf3fe56a4337b239e052039dc0e022a1ef0bd7113c1fa1835e5d4c55f7a
|
|
7
|
+
data.tar.gz: 9b8823b93863c93d01eecb46a2773ad7fa3dd7fe0b9908f9a5f50137492ffb7ad4a10cbddc8207aaab0f4ed4ba27f572d5624b8687da6069878abff6465838e3
|
data/README.md
CHANGED
|
@@ -3,6 +3,27 @@
|
|
|
3
3
|
[](https://rubygems.org/gems/current_scope)
|
|
4
4
|
[](https://github.com/davidteren/current_scope/actions/workflows/ci.yml)
|
|
5
5
|
[](MIT-LICENSE)
|
|
6
|
+
[](https://davidteren.github.io/current_scope/)
|
|
7
|
+
[](https://github.com/davidteren/current_scope/issues)
|
|
8
|
+
|
|
9
|
+
> ## ⚠️ Not production-ready
|
|
10
|
+
>
|
|
11
|
+
> There are some known issues which are currently being worked on. **This is not
|
|
12
|
+
> production-ready**, but it is ready for experimentation and spiking, or
|
|
13
|
+
> whatever people want to do with it — just not yet for production.
|
|
14
|
+
>
|
|
15
|
+
> This is an **authorization** library, so the bar is different: a bug here is a
|
|
16
|
+
> user seeing or doing something they shouldn't. The open work is tracked in the
|
|
17
|
+
> [issue tracker](https://github.com/davidteren/current_scope/issues), and
|
|
18
|
+
> several items are security-relevant — permission keys that can be dropped
|
|
19
|
+
> silently, advisory checks that don't consult the catalog, and gaps in the
|
|
20
|
+
> separation-of-duties veto. Each is being worked through with a written plan and
|
|
21
|
+
> an adversarial review pass.
|
|
22
|
+
>
|
|
23
|
+
> Kick the tyres, build a spike, tell us what breaks. Don't put it in front of
|
|
24
|
+
> real users yet.
|
|
25
|
+
|
|
26
|
+
**Website:** [davidteren.github.io/current_scope](https://davidteren.github.io/current_scope/) — overview, the resolver, and quickstart at a glance.
|
|
6
27
|
|
|
7
28
|
**Authorization as data you edit in a UI, not rules you hardcode and redeploy —
|
|
8
29
|
with one ambient context that makes `allowed_to?` work identically in
|
|
@@ -92,14 +113,91 @@ class SessionsController < ApplicationController
|
|
|
92
113
|
end
|
|
93
114
|
```
|
|
94
115
|
|
|
116
|
+
### Retrofitting an app that already has users
|
|
117
|
+
|
|
118
|
+
> **Retrofitting a real app?** There's a full guide:
|
|
119
|
+
> [Adopting CurrentScope in an existing app](docs/guides/adopting-in-an-existing-app.md)
|
|
120
|
+
> — callback ordering vs. your authentication, the Devise recipe, the
|
|
121
|
+
> `skip_before_action` fail-open trap, hybrid HTML+API grants, and a rollout
|
|
122
|
+
> ladder. The short version is below.
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
The gate is fail-closed, so the line you just added denies **everything** until
|
|
126
|
+
grants exist. On a greenfield app that's invisible — you seed the Owner role and
|
|
127
|
+
move on. On an app that already has controllers and traffic, it means your suite
|
|
128
|
+
goes red and your users get 403s the moment you deploy, and the only way to
|
|
129
|
+
discover what you should have granted is to break it and read the wreckage.
|
|
130
|
+
|
|
131
|
+
Don't cut over blind. Run in report mode first:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
CurrentScope.configure do |config|
|
|
135
|
+
config.enforcement = :report # :enforce (default) | :report
|
|
136
|
+
end
|
|
137
|
+
```
|
|
138
|
+
|
|
139
|
+
The gate now logs what it *would* have denied and lets the request through,
|
|
140
|
+
recording each one to the ledger. Exercise the app, or just run your suite —
|
|
141
|
+
then read the gaps back out:
|
|
142
|
+
|
|
143
|
+
```bash
|
|
144
|
+
bin/rails current_scope:report
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
```
|
|
148
|
+
Would-be denials — grant these to stop them (most-denied first):
|
|
149
|
+
|
|
150
|
+
Ada Lovelace — currently Member
|
|
151
|
+
412x reports#index
|
|
152
|
+
38x reports#export
|
|
153
|
+
Grace Hopper
|
|
154
|
+
7x reports#approve
|
|
155
|
+
|
|
156
|
+
Total: 457 would-be denials across 2 subject(s).
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
That *is* your grant-seeding work, in the shape of the role grid you need to
|
|
160
|
+
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.
|
|
163
|
+
|
|
164
|
+
The rows are ordinary ledger events, so query them directly if you want
|
|
165
|
+
something the task doesn't show:
|
|
166
|
+
|
|
167
|
+
```ruby
|
|
168
|
+
CurrentScope::Event.where(event: "access.would_deny").pluck(:subject, :details)
|
|
169
|
+
# => [["gid://app/User/7", {"permission" => "reports#index", "reason" => "no_grant"}], ...]
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
**Report mode is an adoption ramp, not an off switch — don't run production on
|
|
173
|
+
it.** It relaxes exactly one denial: *nobody has granted this yet*. Everything
|
|
174
|
+
else still refuses:
|
|
175
|
+
|
|
176
|
+
| Still enforced in `:report` | Why it can't be relaxed |
|
|
177
|
+
|---|---|
|
|
178
|
+
| 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.) |
|
|
180
|
+
| The management console | It's where grants are made. An observation flag that opened it would be a privilege escalation. |
|
|
181
|
+
| Impersonation read-only gate | Runs before the permission check and answers to its own rule. |
|
|
182
|
+
|
|
183
|
+
The response carries `X-Current-Scope-Reason: would_deny` on anything report mode
|
|
184
|
+
let through, so you can spot them in an integration test or a proxy log without
|
|
185
|
+
reading the ledger.
|
|
186
|
+
|
|
95
187
|
**Assumption #1: every controller descends from a `Guard`'d base.** An action on
|
|
96
188
|
a controller that never includes `Guard` (an API base, a hand-rolled
|
|
97
|
-
`ActionController::Base`) is silently ungated
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
189
|
+
`ActionController::Base`) is silently ungated — though no longer invisibly: the
|
|
190
|
+
permission grid badges any controller **provably** ungated ("gate not run"),
|
|
191
|
+
and `bin/rails current_scope:ungated` prints the same inventory as a command.
|
|
192
|
+
To catch it at runtime, include the optional `CurrentScope::GatingTripwire` on
|
|
193
|
+
the base you want verified — it fires after any action that didn't run the
|
|
194
|
+
gate: **raising in dev/test, or logging once per `controller#action` under
|
|
195
|
+
`config.gating_tripwire = :warn` (the default outside dev/test; once per
|
|
196
|
+
process per site — a concurrent first hit can rarely emit a duplicate line)**, so a
|
|
197
|
+
production host can inventory its ungated surface without 500ing. It carries
|
|
198
|
+
its own `current_scope_skip_tripwire!` marker for genuinely-public actions (you
|
|
199
|
+
can't use `skip_before_action :current_scope_check!` on a controller that never
|
|
200
|
+
defined that callback — it raises at class load):
|
|
103
201
|
|
|
104
202
|
```ruby
|
|
105
203
|
class ApiController < ActionController::Base
|
|
@@ -109,7 +207,10 @@ end
|
|
|
109
207
|
```
|
|
110
208
|
|
|
111
209
|
It's an `after_action`, so it can't see an action that renders from a
|
|
112
|
-
`before_action` (halted chain) — a strong aid, not total coverage.
|
|
210
|
+
`before_action` (halted chain) — a strong aid, not total coverage. The grid
|
|
211
|
+
badge and the `ungated` task mark only what the callback chain *proves*: a
|
|
212
|
+
conditional skip (`only:`/`except:`) renders unmarked and is exactly what
|
|
213
|
+
`:warn` exists to catch.
|
|
113
214
|
|
|
114
215
|
Bootstrap the first admin (the management UI needs a full-access subject to
|
|
115
216
|
enter, so the first grant can't happen in the UI). One command:
|
|
@@ -180,12 +281,74 @@ query that drifts:
|
|
|
180
281
|
def index
|
|
181
282
|
@projects = scope_for(Project).order(created_at: :desc).page(params[:page])
|
|
182
283
|
end
|
|
284
|
+
|
|
285
|
+
private
|
|
286
|
+
|
|
287
|
+
# A collection-only controller declares BOTH hooks. current_scope_record = nil
|
|
288
|
+
# is what tells the gate "this action has no record" (a scoped grant can then
|
|
289
|
+
# open it); WITHOUT it the gate assumes nothing and current_scope_model is
|
|
290
|
+
# inert — the grant never opens the gate. current_scope_model then names the
|
|
291
|
+
# TYPE, so the grant opens the record-less gate only for Projects. (A
|
|
292
|
+
# controller with member actions already has current_scope_record; it just
|
|
293
|
+
# adds current_scope_model.)
|
|
294
|
+
def current_scope_record = nil
|
|
295
|
+
def current_scope_model = Project
|
|
183
296
|
```
|
|
184
297
|
|
|
185
298
|
- **full-access or an org-wide grant** of the key → every record (`Project.all`).
|
|
186
299
|
- **scoped grants** → only the specific records that role was granted on.
|
|
187
300
|
- **no grant** (or no subject) → empty, fail-closed like the gate.
|
|
188
301
|
|
|
302
|
+
The gate agrees. A collection action like `#index` has no record to name, so it
|
|
303
|
+
asks a record-less question, bound to the type the controller declares
|
|
304
|
+
(`current_scope_model`, above). For a **collection read**
|
|
305
|
+
(`config.collection_read_actions`, `index` by default) the gate asks
|
|
306
|
+
`scope_for` itself: the subject reaches the list exactly when it would show
|
|
307
|
+
them records — scoped `full_access` grants included — and the two halves
|
|
308
|
+
cannot disagree, because they are one query. Any other record-less key needs a
|
|
309
|
+
scoped grant whose role ticks it explicitly; `scope_for` then narrows the list
|
|
310
|
+
to the records they were actually granted. A grant on a `Report` never opens a
|
|
311
|
+
`Projects` gate — the type is what binds them. **No org-wide grant is needed to
|
|
312
|
+
reach a scoped index** (and reaching for one would defeat the purpose — an
|
|
313
|
+
org-wide grant means "see everything", so `scope_for` would return
|
|
314
|
+
`Project.all`). The same holds for the class form,
|
|
315
|
+
`allowed_to?(:index, Project)`, which carries the type as its argument, so a
|
|
316
|
+
view helper and the gate never disagree. A controller that does **not** declare
|
|
317
|
+
`current_scope_model` fails the record-less gate closed for scoped grants (the
|
|
318
|
+
denial carries `X-Current-Scope-Reason: model_undeclared`, and a dev nudge
|
|
319
|
+
names the one-line fix). A declaration that returns something other than a
|
|
320
|
+
concrete ActiveRecord class — `"Report"` for `Report`, say — also fails
|
|
321
|
+
closed, labelled `model_invalid`, with a nudge naming the value the hook
|
|
322
|
+
returned.
|
|
323
|
+
|
|
324
|
+
> **The gate admits; `scope_for` narrows. Both halves are yours to wire.** The
|
|
325
|
+
> gate only decides *whether* `#index` runs — it cannot filter a list you build
|
|
326
|
+
> with `Project.all`. If a scoped role ticks a collection key and that action
|
|
327
|
+
> doesn't call `scope_for`, the subject reaches the action and sees everything
|
|
328
|
+
> it queries. Gate a collection action for scoped roles only alongside a
|
|
329
|
+
> `scope_for` list.
|
|
330
|
+
|
|
331
|
+
Off the read list the rule is uniform: a scoped role that ticks `create` or a
|
|
332
|
+
bulk key opens *those* collection gates too, exactly as an org-wide grant of
|
|
333
|
+
the same key already does. Tick a collection key on a scoped role only when
|
|
334
|
+
you mean it — there is no record filter on `create`.
|
|
335
|
+
|
|
336
|
+
A scoped **`full_access`** role follows the read/write split: it opens the
|
|
337
|
+
listed reads of its record's type — the gate derives from which records the
|
|
338
|
+
grant actually holds, so "Owner of Project #7" reaches the project index and
|
|
339
|
+
sees Project #7 — and nothing else record-less. A full_access role satisfies
|
|
340
|
+
*every* key, so honoring it in a record-less check that answers with a bare
|
|
341
|
+
boolean would make one scoped grant a pass on every `#create` in the app; the
|
|
342
|
+
read gates are safe precisely because their answer comes from the list. Two
|
|
343
|
+
consequences worth knowing: a grant whose record is absent from the model's
|
|
344
|
+
default scope — destroyed, soft-deleted, or scoped out by a tenant
|
|
345
|
+
`default_scope` — opens nothing (an empty list is a 403, not an empty page),
|
|
346
|
+
and the declared
|
|
347
|
+
`current_scope_model` is **trusted like the record hook** — a wrong
|
|
348
|
+
declaration opens that controller's listed reads to full_access holders of the
|
|
349
|
+
declared type, so review the declaration the way you review
|
|
350
|
+
`current_scope_record`.
|
|
351
|
+
|
|
189
352
|
It returns a chainable `ActiveRecord::Relation`, so `.where`/`.order`/`.page`
|
|
190
353
|
compose normally. `permission:` defaults to the model's `index` key and accepts
|
|
191
354
|
a bare action or a full key (`scope_for(Report, permission: :approve)`).
|
|
@@ -264,9 +427,10 @@ to exempt a record type, or trim `config.sod_actions`.
|
|
|
264
427
|
> on an SoD member action, the veto is *skipped* — an org-wide-granted subject
|
|
265
428
|
> (including the initiator) passes. `nil` is legitimate for collection actions,
|
|
266
429
|
> so the resolver can't tell the two apart and won't raise. Returning the record
|
|
267
|
-
> on member actions is therefore the load-bearing control.
|
|
268
|
-
>
|
|
269
|
-
>
|
|
430
|
+
> on member actions is therefore the load-bearing control. **In development and
|
|
431
|
+
> test the gate logs a nudge** whenever an allowed SoD action was gated with no
|
|
432
|
+
> record (`config.warn_on_nil_sod_record`, on by default there, off in
|
|
433
|
+
> production) — see [Dev diagnostics](#dev-diagnostics).
|
|
270
434
|
|
|
271
435
|
With `sod_actions` empty (the default), the veto step is a no-op and the
|
|
272
436
|
resolver is simply `full_access → org-wide role → scoped role → deny`. No model
|
|
@@ -308,6 +472,17 @@ re-checked live at decision time:
|
|
|
308
472
|
2. the record's host hook `current_scope_sod_bypassed?` returns true, **and**
|
|
309
473
|
3. the record's **initiator** holds the bypass permission (`bypass_sod`).
|
|
310
474
|
|
|
475
|
+
**Where the cell is.** Break-glass is the one permission that isn't an action
|
|
476
|
+
you can route, so the role grid gets it injected rather than derived: with
|
|
477
|
+
`allow_sod_bypass` on, every controller that routes an action listed in
|
|
478
|
+
`sod_actions` grows a `bypass_sod` column, blank elsewhere. Tick it on the row
|
|
479
|
+
for that resource and the role can break the glass — the supported way to build
|
|
480
|
+
a "trusted admin may self-approve" role **without** `full_access`, which would
|
|
481
|
+
grant it implicitly along with everything else and defeat the point of a scoped
|
|
482
|
+
trusted approver. Turn `allow_sod_bypass` off and the column disappears and the
|
|
483
|
+
key stops being grantable: grantability follows the catalog, and the catalog
|
|
484
|
+
follows the flag.
|
|
485
|
+
|
|
311
486
|
Holding `bypass_sod` on a flagged, self-initiated record **is** the
|
|
312
487
|
authorization for the SoD action — the bypass grants the action, it doesn't
|
|
313
488
|
merely lift the veto and then re-check for a separate `approve` grant.
|
|
@@ -354,19 +529,128 @@ and `sod_identity` — are grouped in their own block and covered under
|
|
|
354
529
|
`sod_identity` is only observable once a mutation is allowed past the read-only
|
|
355
530
|
gate.
|
|
356
531
|
|
|
532
|
+
**`config.enforcement`** — `:enforce` (default) | `:report`. What the gate does
|
|
533
|
+
with a denial. `:enforce` means a denial is a 403; it is the only production
|
|
534
|
+
posture. `:report` logs a *missing grant* and lets the request through instead,
|
|
535
|
+
recording it as `access.would_deny` — the adoption ramp for retrofitting an
|
|
536
|
+
existing app, covered in [Retrofitting an app that already has
|
|
537
|
+
users](#retrofitting-an-app-that-already-has-users). It relaxes nothing else: the
|
|
538
|
+
SoD veto and the management console are untouched by it. An unknown value raises
|
|
539
|
+
at boot rather than being silently treated as one of the two — believing you're
|
|
540
|
+
enforcing when you aren't is the worst way to be wrong about this setting.
|
|
541
|
+
|
|
542
|
+
**`config.collection_read_actions`** — `["index"]` by default. The record-less
|
|
543
|
+
actions whose gate derives its answer from the scoped list, so a scoped
|
|
544
|
+
`full_access` grant opens exactly the collections that would show its records
|
|
545
|
+
(gate and list agree by construction — the #65 fix). Set `[]` to restore the
|
|
546
|
+
pre-#65 behavior, where explicit ticks still open type-bound record-less gates
|
|
547
|
+
but scoped `full_access` opens none (the whole record-less family is new in
|
|
548
|
+
this release — no released version had either posture). A full key
|
|
549
|
+
(`"reports#index"`) raises at assignment (the list is action-segment matched,
|
|
550
|
+
app-wide), and a canonical mutating name (`create`/`update`/`destroy`) logs a
|
|
551
|
+
loud warning.
|
|
552
|
+
**List-narrowing reads only:** never name a mutating action here — that would
|
|
553
|
+
hand a scoped full_access holder the action on every record of the type off a
|
|
554
|
+
grant on one record. Custom read actions (`export`, `search`) are the intended
|
|
555
|
+
additions. Members normalize to strings on assignment, so `%i[index]` works.
|
|
556
|
+
|
|
357
557
|
The **audit ledger** is controlled by `config.audit` — tri-state
|
|
358
558
|
`false | true | :strict`. `false` records nothing; `true` (the default) records
|
|
359
559
|
every authorization change and degrades gracefully (skip + warn once) if the
|
|
360
560
|
events table isn't migrated; `:strict` **raises** on a missing events table so
|
|
361
561
|
an audit-mandatory app never commits an unaudited change (the mutation rolls
|
|
362
|
-
back).
|
|
363
|
-
|
|
562
|
+
back).
|
|
563
|
+
|
|
564
|
+
> **Note on the `!`:** despite the bang, `Event.record!` only guarantees
|
|
565
|
+
> raise-on-failure under `:strict` (and for a missing actor). In the default
|
|
566
|
+
> `true` mode a missing events table is a warn-once no-op, and under `false`
|
|
567
|
+
> every call silently returns `nil` — so a mutation-wrapping transaction does
|
|
568
|
+
> **not** roll back on a failed audit write unless you opt into `:strict`.
|
|
569
|
+
|
|
570
|
+
### Dev diagnostics
|
|
571
|
+
|
|
572
|
+
Three things this engine gets wrong **silently**, and silently in the bad
|
|
573
|
+
direction: what went wrong looks exactly like what going right looks like. Each
|
|
574
|
+
one now says so in the log.
|
|
575
|
+
|
|
576
|
+
| Flag | Fires when | Why you'd never notice otherwise |
|
|
577
|
+
|---|---|---|
|
|
578
|
+
| `warn_on_nil_sod_record` | An SoD action was **allowed** while the gate had no record, so the veto was skipped | A veto that never ran looks identical to a veto that passed |
|
|
579
|
+
| `warn_on_inert_scoped_grant` | Denied `no_grant`, the subject **holds a scoped grant** that would satisfy it, and the controller declares no `current_scope_record` | The 403 is byte-identical to "never granted", so you go audit the grants — which are fine — instead of the controller, which isn't |
|
|
580
|
+
| `warn_on_cross_controller_derivation` | Short-form `allowed_to?(:show, record)` derived a **different key** than the gate on this controller enforces | If you meant this controller's gate, the view and the gate disagree — and the symptom (a link that 403s, or a hidden one that works) shows up nowhere near the cause |
|
|
581
|
+
|
|
582
|
+
All three are **log-only** — no decision, exception, header, or audit row changes
|
|
583
|
+
because of them, in any environment — and all three default **on in development
|
|
584
|
+
and test, off in production**:
|
|
585
|
+
|
|
586
|
+
```ruby
|
|
587
|
+
config.warn_on_nil_sod_record = Rails.env.local? # the defaults;
|
|
588
|
+
config.warn_on_inert_scoped_grant = Rails.env.local? # override either
|
|
589
|
+
config.warn_on_cross_controller_derivation = Rails.env.local? # way
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
The last one is a **hint, not an accusation**, and says so: asking about a
|
|
593
|
+
different resource than the current controller handles derives a different key
|
|
594
|
+
too, and that is correct and common. Nothing at the call site distinguishes the
|
|
595
|
+
two, so it warns **once per site** and names both readings. The first two are
|
|
596
|
+
unambiguous.
|
|
597
|
+
|
|
598
|
+
The default is the point. These catch mistakes you make while *writing* the app,
|
|
599
|
+
which is exactly when dev/test is where you are — and a diagnostic that ships off
|
|
600
|
+
is one the people who need it never find. `warn_on_nil_sod_record` has worked
|
|
601
|
+
since v0.1 and defaulted off, which is how it helped nobody.
|
|
602
|
+
|
|
603
|
+
A fourth setting is a **mode, not a flag** — the opt-in `GatingTripwire`
|
|
604
|
+
already speaks; the question is how:
|
|
364
605
|
|
|
365
|
-
|
|
606
|
+
```ruby
|
|
607
|
+
config.gating_tripwire = Rails.env.local? ? :raise : :warn # the default
|
|
608
|
+
```
|
|
609
|
+
|
|
610
|
+
`:raise` (dev/test) makes CI go red on an ungated action; `:warn` (elsewhere)
|
|
611
|
+
logs each ungated `controller#action` once, so a production host that included
|
|
612
|
+
the mixin gets an inventory instead of 500s. There is no `:off` — not including
|
|
613
|
+
the mixin is off.
|
|
614
|
+
|
|
615
|
+
Three loud-by-design behaviors. A controller excluded from the catalog can't be
|
|
366
616
|
granted, so gating it is a misconfiguration — Guard raises and tells you to
|
|
367
|
-
either stop excluding it or `skip_before_action :current_scope_check!`.
|
|
617
|
+
either stop excluding it or `skip_before_action :current_scope_check!`. A
|
|
368
618
|
`user_method` that the controller doesn't respond to raises instead of
|
|
369
|
-
silently turning every request into a 403.
|
|
619
|
+
silently turning every request into a 403. And **granting a permission key that
|
|
620
|
+
isn't in the catalog makes the role invalid**, naming the key:
|
|
621
|
+
|
|
622
|
+
```ruby
|
|
623
|
+
role.permission_keys = %w[reports#aprove] # typo
|
|
624
|
+
role.save # => false
|
|
625
|
+
role.errors[:permission_keys]
|
|
626
|
+
# => ["not in the permission catalog: reports#aprove — check for typos, or use
|
|
627
|
+
# assign_permission_keys(..., scrub: true) to drop stale keys deliberately"]
|
|
628
|
+
```
|
|
629
|
+
|
|
630
|
+
A grant that vanishes is the worst kind of bug this library can have: the role
|
|
631
|
+
looks right in the UI, the save succeeds, and the denial arrives later as an
|
|
632
|
+
unexplained 403. So a key the app doesn't route is an error, not a shrug — that
|
|
633
|
+
covers typos, programmatic grants of unrouted keys, and the never-routed
|
|
634
|
+
break-glass permission (which stays ungrantable; see #21).
|
|
635
|
+
|
|
636
|
+
There is one legitimate reason to drop a key silently: a controller was removed,
|
|
637
|
+
so a role still holds keys that no longer route. That is named at the call site
|
|
638
|
+
rather than assumed:
|
|
639
|
+
|
|
640
|
+
```ruby
|
|
641
|
+
role.assign_permission_keys(keys, scrub: true) # stale keys dropped, no error
|
|
642
|
+
role.save!
|
|
643
|
+
role.permission_keys_change[:rejected] # => ["gone#index"] — log it if you want
|
|
644
|
+
```
|
|
645
|
+
|
|
646
|
+
The diff is computed on save, so read it after. `scrub:` takes literal `true`
|
|
647
|
+
and nothing else — a stray truthy value must not be able to turn the strict
|
|
648
|
+
path off.
|
|
649
|
+
|
|
650
|
+
`scrub:` is deliberately not reachable from `permission_keys=`, so form params
|
|
651
|
+
and strong-params flows always take the strict path. The role editor is
|
|
652
|
+
unaffected: its grid is built from routed actions, so everything it submits is
|
|
653
|
+
already in the catalog, and a stale key is cleaned up transparently on save.
|
|
370
654
|
|
|
371
655
|
### Impersonation (act-as)
|
|
372
656
|
|
|
@@ -480,9 +764,24 @@ class ImpersonationsController < ApplicationController
|
|
|
480
764
|
end
|
|
481
765
|
```
|
|
482
766
|
|
|
483
|
-
Denials carry a machine-readable reason
|
|
484
|
-
|
|
485
|
-
|
|
767
|
+
Denials carry a machine-readable reason on `AccessDenied#reason`, surfaced on
|
|
768
|
+
the response as the `X-Current-Scope-Reason` header:
|
|
769
|
+
|
|
770
|
+
| Reason | Means |
|
|
771
|
+
|---|---|
|
|
772
|
+
| `sod_veto` | the record's initiator can't perform a separation-of-duties action on it |
|
|
773
|
+
| `no_grant` | nothing granted the permission — the default deny |
|
|
774
|
+
| `model_undeclared` | a record-less deny that a scoped grant would have opened, had the controller declared `current_scope_model` |
|
|
775
|
+
| `model_invalid` | `current_scope_model` was declared but returned something other than a concrete ActiveRecord class |
|
|
776
|
+
| `impersonation_gate` | a mutation while impersonating, which is read-only |
|
|
777
|
+
| `not_full_access` | the management UI, which only full-access subjects enter |
|
|
778
|
+
|
|
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.
|
|
486
785
|
|
|
487
786
|
**View/gate disagreement is by design.** `allowed_to?` is HTTP-ignorant: it
|
|
488
787
|
still returns `true` for a permission the subject genuinely holds, even though
|
|
@@ -79,6 +79,10 @@ document.addEventListener("DOMContentLoaded", function () {
|
|
|
79
79
|
// hint and disable the preserve inputs so they don't force the old subset back.
|
|
80
80
|
function releasePartial(box) {
|
|
81
81
|
box.indeterminate = false;
|
|
82
|
+
// The attribute is what CSS keys on (dashed outline; the marked-row hatch)
|
|
83
|
+
// — leaving it set keeps partial styling on a cell the user just cleared,
|
|
84
|
+
// even though no keys will be submitted. (#79 review)
|
|
85
|
+
box.removeAttribute("data-cs-partial");
|
|
82
86
|
var cell = box.closest("td");
|
|
83
87
|
if (!cell) return;
|
|
84
88
|
cell.querySelectorAll("[data-cs-preserve]").forEach(function (h) { h.disabled = true; });
|
|
@@ -132,6 +132,27 @@ a:hover { color: var(--cs-accent-strong); }
|
|
|
132
132
|
|
|
133
133
|
:focus-visible { outline: 2px solid var(--cs-accent); outline-offset: 2px; border-radius: 3px; }
|
|
134
134
|
|
|
135
|
+
/* Bypass block (WCAG 2.4.1): off-canvas until keyboard focus lands on it,
|
|
136
|
+
* then a small accent pill in the top-left, above the sticky sidebar. */
|
|
137
|
+
.cs-skip-link {
|
|
138
|
+
position: absolute;
|
|
139
|
+
top: 0.5rem;
|
|
140
|
+
left: 0.5rem;
|
|
141
|
+
z-index: 10;
|
|
142
|
+
padding: 0.5rem 0.875rem;
|
|
143
|
+
background: var(--cs-accent);
|
|
144
|
+
color: var(--cs-on-accent);
|
|
145
|
+
border-radius: 6px;
|
|
146
|
+
font-weight: 600;
|
|
147
|
+
text-decoration: none;
|
|
148
|
+
transform: translateY(calc(-100% - 1rem));
|
|
149
|
+
}
|
|
150
|
+
.cs-skip-link:focus { transform: none; }
|
|
151
|
+
|
|
152
|
+
/* The skip-link target: focusable for the jump, but never shows a ring of
|
|
153
|
+
* its own — the content it reveals is the feedback. */
|
|
154
|
+
#cs-main-content:focus-visible { outline: none; }
|
|
155
|
+
|
|
135
156
|
/* ---- Shell ------------------------------------------------------------ *
|
|
136
157
|
* A left sidebar (wordmark + section nav) and a scrollable main column with
|
|
137
158
|
* a sticky topbar. The dense grid lives inside main.
|
|
@@ -357,6 +378,17 @@ a:hover { color: var(--cs-accent-strong); }
|
|
|
357
378
|
box-shadow: var(--cs-shadow);
|
|
358
379
|
}
|
|
359
380
|
.cs-card:not(.cs-card--flush) > * { padding-inline: 1.125rem; }
|
|
381
|
+
|
|
382
|
+
/* The engine's front-door 403 — rendered standalone, without the console shell
|
|
383
|
+
(a sidebar of links this subject can't open would read as "you're in" and
|
|
384
|
+
then refuse every click). Only the centring is new; the surface, type and
|
|
385
|
+
theme tokens come from .cs-card and the base rules above. */
|
|
386
|
+
.cs-denied {
|
|
387
|
+
max-width: 46rem;
|
|
388
|
+
margin: 12vh auto 0;
|
|
389
|
+
padding-block: 1.5rem;
|
|
390
|
+
}
|
|
391
|
+
.cs-denied p { margin: 0 0 0.75rem; max-width: 68ch; }
|
|
360
392
|
/* Flush cards hold data tables. Let them scroll horizontally inside the card so
|
|
361
393
|
a wide table never overflows the page. On a wide screen the table fills the
|
|
362
394
|
card (base width:100%) and scoped-role chips wrap in their own cell — all
|
|
@@ -498,6 +530,73 @@ a:hover { color: var(--cs-accent-strong); }
|
|
|
498
530
|
.cs-grid td:has(input[data-cs-partial="true"]) { background: var(--cs-accent-wash); }
|
|
499
531
|
.cs-grid input[data-cs-partial="true"] { outline: 2px dashed var(--cs-accent); outline-offset: 1px; }
|
|
500
532
|
|
|
533
|
+
/* UNGATED (provable): the gate never runs on this controller, so its granted
|
|
534
|
+
routed actions are inert. The badge is advisory — marking is not disabling
|
|
535
|
+
(KTD-8/R5): nothing grayed, nothing disabled. It sits under the controller
|
|
536
|
+
name in the sticky header, wrapping normally inside the nowrap column. */
|
|
537
|
+
.cs-ungated-badge {
|
|
538
|
+
display: block;
|
|
539
|
+
max-width: 19rem;
|
|
540
|
+
margin-top: 0.25rem;
|
|
541
|
+
padding: 0.25rem 0.4375rem;
|
|
542
|
+
border: 1px solid var(--cs-danger-line);
|
|
543
|
+
border-radius: var(--cs-radius-sm);
|
|
544
|
+
background: var(--cs-danger-wash);
|
|
545
|
+
color: var(--cs-ink);
|
|
546
|
+
font-family: var(--cs-font);
|
|
547
|
+
font-size: 0.6875rem;
|
|
548
|
+
font-weight: 400;
|
|
549
|
+
line-height: 1.45;
|
|
550
|
+
white-space: normal;
|
|
551
|
+
}
|
|
552
|
+
.cs-ungated-badge strong {
|
|
553
|
+
color: var(--cs-danger);
|
|
554
|
+
font-weight: 650;
|
|
555
|
+
letter-spacing: 0.03em;
|
|
556
|
+
text-transform: uppercase;
|
|
557
|
+
}
|
|
558
|
+
.cs-ungated-badge code { font-family: var(--cs-mono); }
|
|
559
|
+
|
|
560
|
+
/* A checked (or partial) cell in a marked row must not read as a live grant:
|
|
561
|
+
swap the cobalt wash for a muted hatch. CSS-only, keyed on the badge — the
|
|
562
|
+
cell markup is untouched. The break-glass cell (.cs-cell-bypass) is excluded
|
|
563
|
+
on purpose (KTD-9): that grant IS live on a marked row, so it keeps the real
|
|
564
|
+
granted wash from the rule above. */
|
|
565
|
+
.cs-grid tbody tr:has(.cs-ungated-badge) td:not(.cs-cell-bypass):has(input:checked),
|
|
566
|
+
.cs-grid tbody tr:has(.cs-ungated-badge) td:not(.cs-cell-bypass):has(input[data-cs-partial="true"]) {
|
|
567
|
+
background: repeating-linear-gradient(-45deg,
|
|
568
|
+
var(--cs-surface-2) 0 4px, var(--cs-bg) 4px 8px);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/* UNINSPECTABLE: the controller raised while loading, so the gate state is
|
|
572
|
+
unknown — a distinct third voice, deliberately not the danger badge (no
|
|
573
|
+
claim about gating is being made) and not silence (unmarked reads as
|
|
574
|
+
"checked, and gated" under the hint's rules). */
|
|
575
|
+
.cs-uninspectable-note {
|
|
576
|
+
display: block;
|
|
577
|
+
max-width: 19rem;
|
|
578
|
+
margin-top: 0.25rem;
|
|
579
|
+
padding: 0.25rem 0.4375rem;
|
|
580
|
+
border: 1px dashed var(--cs-line-strong);
|
|
581
|
+
border-radius: var(--cs-radius-sm);
|
|
582
|
+
background: var(--cs-surface-2);
|
|
583
|
+
color: var(--cs-muted);
|
|
584
|
+
font-family: var(--cs-font);
|
|
585
|
+
font-size: 0.6875rem;
|
|
586
|
+
line-height: 1.45;
|
|
587
|
+
white-space: normal;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/* The per-cell exemption note on a marked row's break-glass cell. */
|
|
591
|
+
.cs-bypass-exempt {
|
|
592
|
+
display: block;
|
|
593
|
+
margin-top: 0.125rem;
|
|
594
|
+
font-size: 0.625rem;
|
|
595
|
+
line-height: 1.3;
|
|
596
|
+
color: var(--cs-muted);
|
|
597
|
+
white-space: nowrap;
|
|
598
|
+
}
|
|
599
|
+
|
|
501
600
|
/* Checkboxes / radios use the accent. */
|
|
502
601
|
input[type="checkbox"], input[type="radio"] {
|
|
503
602
|
accent-color: var(--cs-accent);
|
|
@@ -21,8 +21,46 @@ module CurrentScope
|
|
|
21
21
|
|
|
22
22
|
private
|
|
23
23
|
|
|
24
|
+
# Raises rather than rendering, so the engine's front door lands in the same
|
|
25
|
+
# current_scope_denied path as every other denial and gets the reason header
|
|
26
|
+
# for free. It used to `head :forbidden` here — the one denial in the gem
|
|
27
|
+
# that sat outside that machinery, and so the one with no reason and no body
|
|
28
|
+
# (#23). MutationGuard's rescue_from catches this from a before_action.
|
|
29
|
+
#
|
|
30
|
+
# Who is denied is unchanged: the full_access? check is byte-for-byte what
|
|
31
|
+
# it was. Only how the refusal is surfaced changed.
|
|
24
32
|
def require_full_access!
|
|
25
|
-
|
|
33
|
+
return if CurrentScope.resolver.full_access?(CurrentScope::Current.user)
|
|
34
|
+
|
|
35
|
+
raise CurrentScope::AccessDenied.new(
|
|
36
|
+
"#{controller_path}##{action_name}", reason: :not_full_access
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# The engine's UI is the one place a rendered denial belongs: the admin is
|
|
41
|
+
# looking at a browser, and "blank page" is not an answer to "why can't I get
|
|
42
|
+
# in?". Overrides ONLY the body — the reason header is still written by
|
|
43
|
+
# current_scope_denied, which stays the single place that knows about it.
|
|
44
|
+
#
|
|
45
|
+
# ONLY for :not_full_access. This concern rescues EVERY AccessDenied raised
|
|
46
|
+
# in this controller, and the other one that fires here is the impersonation
|
|
47
|
+
# gate — whose subject usually DOES have full access and is refused for an
|
|
48
|
+
# entirely different reason. Telling them they need a full-access role is a
|
|
49
|
+
# confidently wrong answer, which is worse than the blank page this fix
|
|
50
|
+
# replaces. Every other reason falls through to the bodyless default, i.e.
|
|
51
|
+
# exactly what it did before this change.
|
|
52
|
+
#
|
|
53
|
+
# HTML only: the page is a full HTML document, so a client that asked for
|
|
54
|
+
# anything else gets the bodyless 403 rather than markup under a content
|
|
55
|
+
# type it did not ask for.
|
|
56
|
+
#
|
|
57
|
+
# layout: false — the console layout is a sidebar of links to areas this
|
|
58
|
+
# subject cannot open. Offering them reads as "you're in" and then refuses
|
|
59
|
+
# every click.
|
|
60
|
+
def current_scope_render_denied(reason = nil)
|
|
61
|
+
return super unless reason == :not_full_access && request.format.html?
|
|
62
|
+
|
|
63
|
+
render "current_scope/shared/access_denied", status: :forbidden, layout: false
|
|
26
64
|
end
|
|
27
65
|
|
|
28
66
|
def subject_class
|
|
@@ -69,14 +69,15 @@ module CurrentScope
|
|
|
69
69
|
end
|
|
70
70
|
|
|
71
71
|
# Returns true when a role was actually cleared, false when there was nothing
|
|
72
|
-
# to clear (so the caller's count stays accurate).
|
|
72
|
+
# to clear (so the caller's count stays accurate). Atomicity comes from
|
|
73
|
+
# create's outer bulk transaction — only called from inside it. (No inner
|
|
74
|
+
# transaction: without requires_new it would be a bare yield, and it isn't
|
|
75
|
+
# wanted — a failure anywhere rolls back the whole batch by design.)
|
|
73
76
|
def clear_org_role(subject, assignment, prior_role)
|
|
74
77
|
return false unless assignment.persisted? # nothing to clear ⇒ no event
|
|
75
78
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
Event.record!(event: "org_role.removed", target: subject, details: { role: prior_role.name })
|
|
79
|
-
end
|
|
79
|
+
assignment.destroy!
|
|
80
|
+
Event.record!(event: "org_role.removed", target: subject, details: { role: prior_role.name })
|
|
80
81
|
true
|
|
81
82
|
end
|
|
82
83
|
|
|
@@ -88,16 +89,15 @@ module CurrentScope
|
|
|
88
89
|
new_role = Role.find(params.expect(:role_id))
|
|
89
90
|
changed = prior_role.nil? || prior_role.id != new_role.id
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
end
|
|
99
|
-
# same role re-set ⇒ no change ⇒ no event
|
|
92
|
+
# Atomicity comes from create's outer bulk transaction (see clear_org_role).
|
|
93
|
+
assignment.update!(role: new_role)
|
|
94
|
+
if prior_role.nil?
|
|
95
|
+
Event.record!(event: "org_role.assigned", target: subject, details: { role: new_role.name })
|
|
96
|
+
elsif prior_role.id != new_role.id
|
|
97
|
+
Event.record!(event: "org_role.changed", target: subject,
|
|
98
|
+
details: { from: prior_role.name, to: new_role.name })
|
|
100
99
|
end
|
|
100
|
+
# same role re-set ⇒ no change ⇒ no event
|
|
101
101
|
changed
|
|
102
102
|
end
|
|
103
103
|
end
|