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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +317 -18
  3. data/app/assets/javascripts/current_scope/application.js +4 -0
  4. data/app/assets/stylesheets/current_scope/application.css +99 -0
  5. data/app/controllers/current_scope/application_controller.rb +39 -1
  6. data/app/controllers/current_scope/role_assignments_controller.rb +14 -14
  7. data/app/controllers/current_scope/roles_controller.rb +6 -2
  8. data/app/helpers/current_scope/application_helper.rb +168 -12
  9. data/app/models/current_scope/current.rb +24 -0
  10. data/app/models/current_scope/event.rb +10 -6
  11. data/app/models/current_scope/role.rb +61 -10
  12. data/app/views/current_scope/roles/edit.html.erb +92 -4
  13. data/app/views/current_scope/roles/members.html.erb +3 -3
  14. data/app/views/current_scope/roles/new.html.erb +1 -1
  15. data/app/views/current_scope/scoped_role_assignments/new.html.erb +12 -3
  16. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  17. data/app/views/current_scope/subjects/index.html.erb +17 -5
  18. data/app/views/layouts/current_scope/application.html.erb +4 -1
  19. data/config/routes.rb +3 -4
  20. data/lib/current_scope/configuration.rb +378 -16
  21. data/lib/current_scope/engine.rb +7 -0
  22. data/lib/current_scope/gating_reflection.rb +62 -0
  23. data/lib/current_scope/gating_tripwire.rb +36 -5
  24. data/lib/current_scope/guard.rb +406 -8
  25. data/lib/current_scope/mutation_guard.rb +30 -5
  26. data/lib/current_scope/permission_catalog.rb +116 -3
  27. data/lib/current_scope/permission_grid.rb +34 -4
  28. data/lib/current_scope/permissions.rb +42 -8
  29. data/lib/current_scope/resolver.rb +317 -13
  30. data/lib/current_scope/version.rb +1 -1
  31. data/lib/current_scope.rb +113 -5
  32. data/lib/generators/current_scope/install/install_generator.rb +64 -0
  33. data/lib/generators/current_scope/install/templates/initializer.rb +94 -5
  34. data/lib/tasks/current_scope_tasks.rake +153 -0
  35. metadata +6 -2
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: db5a9b846aa990e7a570fcecda8ed17155e1dda547d2378cae7f50c24222f600
4
- data.tar.gz: 8c7a489b449e685e5690a764d154d7c9b9f2fcac014f6d11f2cef6456d1ecc26
3
+ metadata.gz: cb3cfaf17408e822d2ce269223bed45714e97dad48d68ad68826cb398c39434f
4
+ data.tar.gz: 20729feebd795748b0898e62d4934b1ca3ed490ee585fcf911ad37d5d500e2a5
5
5
  SHA512:
6
- metadata.gz: 2240d706b073404c057a1f28fbb0548ff1a8e9b596164d4ecf6fe2d787c3beee789537e4b5742e0034965a23cc7385eecc9eb604ed99cb3a73121d959a39087d
7
- data.tar.gz: 6e1d6254c026ccce754eeae54ae160f94880ee4e410589d2f4ee6511a01aebf0a94e797b6b2eb075358f8f6b1681b10778c41f44488a2694349aace5e7f24dc7
6
+ metadata.gz: 7b6ede764a4924731f2a92c118dd0a2c018de8a808ddbd8d9010eacd862e0e57e972dcf3fe56a4337b239e052039dc0e022a1ef0bd7113c1fa1835e5d4c55f7a
7
+ data.tar.gz: 9b8823b93863c93d01eecb46a2773ad7fa3dd7fe0b9908f9a5f50137492ffb7ad4a10cbddc8207aaab0f4ed4ba27f572d5624b8687da6069878abff6465838e3
data/README.md CHANGED
@@ -3,6 +3,27 @@
3
3
  [![Gem Version](https://img.shields.io/gem/v/current_scope)](https://rubygems.org/gems/current_scope)
4
4
  [![CI](https://github.com/davidteren/current_scope/actions/workflows/ci.yml/badge.svg)](https://github.com/davidteren/current_scope/actions/workflows/ci.yml)
5
5
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](MIT-LICENSE)
6
+ [![Website](https://img.shields.io/badge/website-davidteren.github.io%2Fcurrent__scope-4d7cfe)](https://davidteren.github.io/current_scope/)
7
+ [![Status: not production-ready](https://img.shields.io/badge/status-not%20production--ready-e8590c)](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. To catch that in dev/test, include
98
- the optional `CurrentScope::GatingTripwire` on the base you want verified — it
99
- raises after any action that didn't run the gate, and carries its own
100
- `current_scope_skip_tripwire!` marker for genuinely-public actions (you can't use
101
- `skip_before_action :current_scope_check!` on a controller that never defined
102
- that callback it raises at class load):
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. As a dev/test aid,
268
- > set `config.warn_on_nil_sod_record = true` to log a nudge whenever an allowed
269
- > SoD action was gated with a nil record.
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). `config.warn_on_nil_sod_record` (default off) is a dev/test aid — see the
363
- [Separation of duties](#separation-of-duties-opt-in) note.
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
- Two loud-by-design behaviors: a controller excluded from the catalog can't be
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!`. And a
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 (`:sod_veto`, `:no_grant`,
484
- `:impersonation_gate`) on `AccessDenied#reason`, surfaced on the response as the
485
- `X-Current-Scope-Reason` header.
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
- head :forbidden unless CurrentScope.resolver.full_access?(CurrentScope::Current.user)
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
- RoleAssignment.transaction do
77
- assignment.destroy!
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
- RoleAssignment.transaction do
92
- assignment.update!(role: new_role)
93
- if prior_role.nil?
94
- Event.record!(event: "org_role.assigned", target: subject, details: { role: new_role.name })
95
- elsif prior_role.id != new_role.id
96
- Event.record!(event: "org_role.changed", target: subject,
97
- details: { from: prior_role.name, to: new_role.name })
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