current_scope 0.2.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 (50) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +577 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/current_scope/application.js +225 -0
  6. data/app/assets/stylesheets/current_scope/application.css +707 -0
  7. data/app/controllers/current_scope/application_controller.rb +54 -0
  8. data/app/controllers/current_scope/events_controller.rb +14 -0
  9. data/app/controllers/current_scope/role_assignments_controller.rb +104 -0
  10. data/app/controllers/current_scope/roles_controller.rb +135 -0
  11. data/app/controllers/current_scope/scoped_role_assignments_controller.rb +159 -0
  12. data/app/controllers/current_scope/subjects_controller.rb +57 -0
  13. data/app/helpers/current_scope/application_helper.rb +72 -0
  14. data/app/models/current_scope/application_record.rb +5 -0
  15. data/app/models/current_scope/current.rb +50 -0
  16. data/app/models/current_scope/event.rb +117 -0
  17. data/app/models/current_scope/role.rb +55 -0
  18. data/app/models/current_scope/role_assignment.rb +21 -0
  19. data/app/models/current_scope/role_permission.rb +9 -0
  20. data/app/models/current_scope/scoped_role_assignment.rb +14 -0
  21. data/app/views/current_scope/events/index.html.erb +41 -0
  22. data/app/views/current_scope/roles/edit.html.erb +86 -0
  23. data/app/views/current_scope/roles/index.html.erb +41 -0
  24. data/app/views/current_scope/roles/members.html.erb +96 -0
  25. data/app/views/current_scope/roles/new.html.erb +19 -0
  26. data/app/views/current_scope/scoped_role_assignments/new.html.erb +135 -0
  27. data/app/views/current_scope/subjects/index.html.erb +100 -0
  28. data/app/views/layouts/current_scope/application.html.erb +53 -0
  29. data/config/routes.rb +14 -0
  30. data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
  31. data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
  32. data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
  33. data/lib/current_scope/configuration.rb +181 -0
  34. data/lib/current_scope/context.rb +36 -0
  35. data/lib/current_scope/engine.rb +14 -0
  36. data/lib/current_scope/gating_tripwire.rb +53 -0
  37. data/lib/current_scope/guard.rb +97 -0
  38. data/lib/current_scope/mutation_guard.rb +55 -0
  39. data/lib/current_scope/permission_catalog.rb +33 -0
  40. data/lib/current_scope/permission_grid.rb +90 -0
  41. data/lib/current_scope/permissions.rb +57 -0
  42. data/lib/current_scope/resolver.rb +171 -0
  43. data/lib/current_scope/scopeable.rb +38 -0
  44. data/lib/current_scope/test_helpers.rb +53 -0
  45. data/lib/current_scope/version.rb +3 -0
  46. data/lib/current_scope.rb +168 -0
  47. data/lib/generators/current_scope/install/install_generator.rb +33 -0
  48. data/lib/generators/current_scope/install/templates/initializer.rb +77 -0
  49. data/lib/tasks/current_scope_tasks.rake +15 -0
  50. metadata +113 -0
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: db5a9b846aa990e7a570fcecda8ed17155e1dda547d2378cae7f50c24222f600
4
+ data.tar.gz: 8c7a489b449e685e5690a764d154d7c9b9f2fcac014f6d11f2cef6456d1ecc26
5
+ SHA512:
6
+ metadata.gz: 2240d706b073404c057a1f28fbb0548ff1a8e9b596164d4ecf6fe2d787c3beee789537e4b5742e0034965a23cc7385eecc9eb604ed99cb3a73121d959a39087d
7
+ data.tar.gz: 6e1d6254c026ccce754eeae54ae160f94880ee4e410589d2f4ee6511a01aebf0a94e797b6b2eb075358f8f6b1681b10778c41f44488a2694349aace5e7f24dc7
data/MIT-LICENSE ADDED
@@ -0,0 +1,20 @@
1
+ Copyright TODO: Write your name
2
+
3
+ Permission is hereby granted, free of charge, to any person obtaining
4
+ a copy of this software and associated documentation files (the
5
+ "Software"), to deal in the Software without restriction, including
6
+ without limitation the rights to use, copy, modify, merge, publish,
7
+ distribute, sublicense, and/or sell copies of the Software, and to
8
+ permit persons to whom the Software is furnished to do so, subject to
9
+ the following conditions:
10
+
11
+ The above copyright notice and this permission notice shall be
12
+ included in all copies or substantial portions of the Software.
13
+
14
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
15
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
16
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
17
+ NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
18
+ LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
19
+ OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
20
+ WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,577 @@
1
+ # CurrentScope
2
+
3
+ [![Gem Version](https://img.shields.io/gem/v/current_scope)](https://rubygems.org/gems/current_scope)
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
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](MIT-LICENSE)
6
+
7
+ **Authorization as data you edit in a UI, not rules you hardcode and redeploy —
8
+ with one ambient context that makes `allowed_to?` work identically in
9
+ controllers, views, and components.**
10
+
11
+ CurrentScope is a mountable Rails engine. You add the gem, run the install
12
+ generator, and get:
13
+
14
+ - **Permissions auto-derived from your routes.** Every `controller#action`
15
+ pair *is* a permission. Add an `OrdersController` and its actions appear in
16
+ the permission grid with zero wiring.
17
+ - **Roles as rows, not classes.** A role is a named, editable bundle of
18
+ permissions — ticked cells on a controller × action grid. Change what
19
+ "Reviewer" means without a deploy.
20
+ - **Scoped roles.** The same role, attached to one specific record: "Editor of
21
+ Project #7" grants nothing on Project #8.
22
+ - **An optional separation-of-duties veto.** Off by default; opt in by listing
23
+ actions. Once on, whoever initiated a record can never approve it — not
24
+ grantable, not configurable in the UI, overrides even full access. A
25
+ structural guarantee, not a preference.
26
+ - **Fail-closed resolution.** No grant means denied. Everything is a
27
+ permission, even the baseline things every signed-in user can do.
28
+ - **An ambient authorization context.** The current subject flows through
29
+ `ActiveSupport::CurrentAttributes` from the controller gate down to the
30
+ smallest ViewComponent. The view can never disagree with the gate — they ask
31
+ the same resolver.
32
+
33
+ The decision order, fixed:
34
+
35
+ ```
36
+ 1. SoD veto → initiator? (opt-in, off by default) DENY (overrides all)
37
+ 2. full_access → role grants everything, forever ALLOW
38
+ 3. org-wide role → role's permission set includes it ALLOW
39
+ 4. scoped role → a role held on THIS record ALLOW
40
+ 5. otherwise → default deny
41
+ ```
42
+
43
+ ## Screenshots
44
+
45
+ The mounted management UI at `/current_scope` — self-contained (no web fonts, no
46
+ build step, CSP-safe), first-class light **and** dark themes.
47
+
48
+ **Permission grid** — one row per controller, CRUD action groups derived from
49
+ your routes; ticked cells glow, a partial group reads as indeterminate.
50
+
51
+ ![Permission grid](docs/screenshots/permission-grid.png)
52
+
53
+ **Subjects** — everyone who can hold a role, their one org-wide role, and any
54
+ per-record scoped roles; server-side search across all subjects.
55
+
56
+ ![Subjects](docs/screenshots/subjects.png)
57
+
58
+ | Roles | Members | Events |
59
+ |---|---|---|
60
+ | ![Roles](docs/screenshots/roles.png) | ![Members](docs/screenshots/members.png) | ![Events](docs/screenshots/events.png) |
61
+
62
+ > Regenerate: `CAPTURE_SCREENSHOTS=1 RAILS_ENV=test bin/rails test test/system/screenshots_test.rb`
63
+
64
+ ## Installation
65
+
66
+ ```ruby
67
+ # Gemfile
68
+ gem "current_scope"
69
+ ```
70
+
71
+ ```bash
72
+ bin/rails generate current_scope:install
73
+ bin/rails current_scope:install:migrations && bin/rails db:migrate
74
+ ```
75
+
76
+ Include the concerns in `ApplicationController` — `Context` populates the
77
+ ambient subject from your authentication, `Guard` gates every action behind
78
+ its own `controller#action` permission:
79
+
80
+ ```ruby
81
+ class ApplicationController < ActionController::Base
82
+ include CurrentScope::Context # sets CurrentScope::Current.user from current_user
83
+ include CurrentScope::Guard # fail-closed gate on every action
84
+ end
85
+ ```
86
+
87
+ Skip the gate where authorization doesn't apply (sign-in, webhooks):
88
+
89
+ ```ruby
90
+ class SessionsController < ApplicationController
91
+ skip_before_action :current_scope_check!
92
+ end
93
+ ```
94
+
95
+ **Assumption #1: every controller descends from a `Guard`'d base.** An action on
96
+ 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):
103
+
104
+ ```ruby
105
+ class ApiController < ActionController::Base
106
+ include CurrentScope::GatingTripwire
107
+ current_scope_skip_tripwire! only: :health
108
+ end
109
+ ```
110
+
111
+ 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.
113
+
114
+ Bootstrap the first admin (the management UI needs a full-access subject to
115
+ enter, so the first grant can't happen in the UI). One command:
116
+
117
+ ```bash
118
+ bin/rails current_scope:grant SUBJECT_ID=1 # grants the full-access Owner role
119
+ ```
120
+
121
+ or in `db/seeds.rb`:
122
+
123
+ ```ruby
124
+ CurrentScope.seed_defaults! # Owner (full_access) + Member
125
+ CurrentScope.grant!(User.first) # give the first user the Owner role
126
+ ```
127
+
128
+ Then manage everything at `/current_scope` (full-access subjects only): the
129
+ role grid, org-wide assignments, scoped grants.
130
+
131
+ ## Usage
132
+
133
+ ### Checking permissions — anywhere
134
+
135
+ `allowed_to?` is available in controllers and views via `Context`, and in any
136
+ PORO or ViewComponent by mixing in `CurrentScope::Permissions`. No
137
+ `current_user` threading, ever:
138
+
139
+ ```ruby
140
+ allowed_to?(:approve, report) # key derived from the record → reports#approve
141
+ allowed_to?(:create, Report) # class form for collection actions
142
+ allowed_to?("admin/reports#approve") # explicit key when you need it
143
+ ```
144
+
145
+ Key derivation agrees with the gate **when the current controller's path ends
146
+ in the record's route key**: inside `Admin::ReportsController` (path
147
+ `admin/reports`, route key `reports`), `allowed_to?(:approve, report)` resolves
148
+ to `admin/reports#approve` — exactly what the Guard enforces there — and a
149
+ cross-resource check from a projects view resolves to `reports#approve`.
150
+
151
+ > **Residual foot-gun — namespaced/custom-named controllers.** When a
152
+ > controller's path segment differs from the record's route key (e.g. a
153
+ > `DashboardController` that renders `Report`s: path `dashboard`, route key
154
+ > `reports`), the short-form `allowed_to?(:show, report)` derives
155
+ > `reports#show` while the Guard enforces `dashboard#show` — so a link may show
156
+ > that then 403s (or hide that would work). The Guard stays authoritative, so
157
+ > this is a display bug, not a bypass. **In such controllers, prefer the
158
+ > explicit full key** — `allowed_to?("dashboard#show")` — which removes the
159
+ > ambiguity. The short form is only guaranteed to match the gate when path
160
+ > segment == route key.
161
+
162
+ ```ruby
163
+ class ApproveButtonComponent < ViewComponent::Base
164
+ include CurrentScope::Permissions
165
+
166
+ def render? = !report.approved? && allowed_to?(:approve, report)
167
+ end
168
+ ```
169
+
170
+ ### Scoping a list (`scope_for`)
171
+
172
+ `allowed_to?` answers "may I act on **this** record?". `scope_for` answers the
173
+ list-side question — "**which** records may I act on?" — from the *same* roles,
174
+ permissions, and scoped grants the gate reads. Use it for index pages so the
175
+ list and the per-record gate stay one source of truth, never a hand-written
176
+ query that drifts:
177
+
178
+ ```ruby
179
+ # app/controllers/projects_controller.rb
180
+ def index
181
+ @projects = scope_for(Project).order(created_at: :desc).page(params[:page])
182
+ end
183
+ ```
184
+
185
+ - **full-access or an org-wide grant** of the key → every record (`Project.all`).
186
+ - **scoped grants** → only the specific records that role was granted on.
187
+ - **no grant** (or no subject) → empty, fail-closed like the gate.
188
+
189
+ It returns a chainable `ActiveRecord::Relation`, so `.where`/`.order`/`.page`
190
+ compose normally. `permission:` defaults to the model's `index` key and accepts
191
+ a bare action or a full key (`scope_for(Report, permission: :approve)`).
192
+
193
+ Every record `scope_for(Project)` returns passes `allowed_to?(:index, project)`,
194
+ and every record it omits fails it — by construction, not by convention. It
195
+ resolves against the **effective** subject, so acting-as changes what lists
196
+ show, and it is **flat**: a scoped grant lists that record only (parent/child
197
+ cascade is deferred). SoD does not apply — it vetoes record-targeted *actions*,
198
+ not list membership.
199
+
200
+ ### Record-level decisions
201
+
202
+ Member actions that need scoped roles or the SoD veto declare a hook. It runs
203
+ *before* your own `before_action`s (the gate comes first), so it loads the
204
+ record itself; memoize so your `set_*` callback reuses it. Key off
205
+ `request.path_parameters`, never `params` — a `?id=` query string must not
206
+ smuggle a record into collection actions:
207
+
208
+ ```ruby
209
+ class ReportsController < ApplicationController
210
+ private
211
+
212
+ def set_report = @report ||= Report.find(params.expect(:id))
213
+
214
+ def current_scope_record
215
+ set_report if request.path_parameters[:id]
216
+ end
217
+ end
218
+ ```
219
+
220
+ ### Scopeable models
221
+
222
+ `include CurrentScope::Scopeable` in a host model to list it in the scoped-role
223
+ picker's type dropdown, and give records a nice label with `current_scope_label`:
224
+
225
+ ```ruby
226
+ class Project < ApplicationRecord
227
+ include CurrentScope::Scopeable
228
+
229
+ def current_scope_label = "#{name} (##{id})" # optional; defaults to "Project ##{id}"
230
+ end
231
+ ```
232
+
233
+ This is **browse-only sugar** — it does *not* gate anything. The raw-GlobalID
234
+ path still accepts **any** model as a scoped-role target whether or not it opts
235
+ in; the mixin only decides what shows up in the dropdown. `current_scope_label`
236
+ is a plain instance method, so your own definition always wins over the default.
237
+
238
+ ### Separation of duties (opt-in)
239
+
240
+ Separation of duties is **off by default** — the engine's baseline is scoped
241
+ RBAC, and many apps want nothing to do with four-eyes. Turn it on by listing the
242
+ actions an initiator can never perform on their own record, and declare who
243
+ initiated each record:
244
+
245
+ ```ruby
246
+ # config/initializers/current_scope.rb
247
+ config.sod_actions = %w[approve] # empty by default → no SoD
248
+ ```
249
+
250
+ ```ruby
251
+ class Report < ApplicationRecord
252
+ def current_scope_initiator = requested_by
253
+ end
254
+ ```
255
+
256
+ Once enabled, the veto fails **loud, not open**: if an SoD action reaches a
257
+ record whose class doesn't define the hook, the resolver raises a
258
+ `ConfigurationError` instead of silently permitting. Return `nil` from the hook
259
+ to exempt a record type, or trim `config.sod_actions`.
260
+
261
+ > **An SoD-gated member action MUST return its record from `current_scope_record`.**
262
+ > This is the one asymmetry to know: a *present* record with a *missing*
263
+ > initiator hook raises (above), but if `current_scope_record` returns **nil**
264
+ > on an SoD member action, the veto is *skipped* — an org-wide-granted subject
265
+ > (including the initiator) passes. `nil` is legitimate for collection actions,
266
+ > 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.
270
+
271
+ With `sod_actions` empty (the default), the veto step is a no-op and the
272
+ resolver is simply `full_access → org-wide role → scoped role → deny`. No model
273
+ needs `current_scope_initiator` — the `ConfigurationError` above only fires for
274
+ actions that are *in* `sod_actions`. `sod_identity` is moot; roles, scoped
275
+ roles, `scope_for`, audit, and impersonation are unaffected.
276
+
277
+ By default (`config.sod_identity = :either`) the veto weighs **two**
278
+ identities: the effective subject *and* the real actor behind an impersonated
279
+ session. So an admin who initiated a report can't slip past the veto by
280
+ approving it while impersonating someone else — impersonation can never approve
281
+ your own record. Set `:subject` to weigh only the effective subject. The two
282
+ are identical when nobody is impersonating (`actor == subject`), so v0.1 hosts
283
+ see no change.
284
+
285
+ ### Break-glass override (`allow_sod_bypass`)
286
+
287
+ Sometimes a workflow needs a *conditional* self-approval — e.g. the owner or a
288
+ trusted admin may approve their own request. You can express that in your app
289
+ (a second `approve_own` permission plus a controller branch), but that pattern
290
+ has one forgettable, security-critical step: **recording the override in the
291
+ audit ledger**. Break-glass promotes the pattern into the engine so the audit
292
+ **cannot** be forgotten.
293
+
294
+ Be honest about what this is: it converts separation of duties from a
295
+ *structural guarantee* into an **audited policy override**. It's called
296
+ break-glass, not SoD. Its legitimacy rests on three things, all enforced: it is
297
+ **off by default**, **privilege-gated**, and **always audited**.
298
+
299
+ ```ruby
300
+ config.allow_sod_bypass = true # default false → the veto is absolute
301
+ config.sod_bypass_permission = "bypass_sod" # grantable, editable in the role grid
302
+ ```
303
+
304
+ With it on, the veto is lifted for a record **only when all three hold**,
305
+ re-checked live at decision time:
306
+
307
+ 1. `config.allow_sod_bypass` is on, **and**
308
+ 2. the record's host hook `current_scope_sod_bypassed?` returns true, **and**
309
+ 3. the record's **initiator** holds the bypass permission (`bypass_sod`).
310
+
311
+ Holding `bypass_sod` on a flagged, self-initiated record **is** the
312
+ authorization for the SoD action — the bypass grants the action, it doesn't
313
+ merely lift the veto and then re-check for a separate `approve` grant.
314
+ `bypass_sod` must **not** appear in `sod_actions` (it isn't an SoD action); the
315
+ engine raises if it does, to prevent a re-entrant loop.
316
+
317
+ When a bypass lifts the veto, the engine records exactly one append-only
318
+ `sod.bypassed` audit event at the enforcement gate (never on advisory
319
+ `allowed_to?` checks) and sets `X-Current-Scope-Reason: sod_bypassed` on the
320
+ response. A missing hook means "this type never breaks glass" — fail-closed, no
321
+ error. Under impersonation (`sod_identity = :either`) the bypass checks the
322
+ **initiator's** privilege, so impersonation can't launder it.
323
+
324
+ **Host recipe** (the engine ships the mechanism; these stay yours, exactly as
325
+ impersonation ships plumbing + recipe, not endpoints):
326
+
327
+ ```ruby
328
+ # 1. A per-record flag column: add_column :invoices, :sod_bypass_requested, :boolean, default: false
329
+ # 2. The hook, reading that column:
330
+ class Invoice < ApplicationRecord
331
+ def current_scope_initiator = requested_by
332
+ def current_scope_sod_bypassed? = sod_bypass_requested?
333
+ end
334
+ # 3. Gate WHO may set the flag on the same bypass_sod permission (a controller
335
+ # branch or a policy) — the engine deliberately does not own that decision.
336
+ ```
337
+
338
+ Prefer true SoD for genuine fraud control (contracts, pay runs) where no
339
+ override should exist. Reach for break-glass only when a *conditional,
340
+ privileged, audited* self-approval is the real requirement. Unlike
341
+ `allow_mutations_while_impersonating`, there is no production env-gate — the
342
+ feature is per-record, privilege-scoped, and audited-by-construction, so
343
+ production is its intended home.
344
+
345
+ ### Configuration
346
+
347
+ Everything lives in `config/initializers/current_scope.rb` (created by the
348
+ install generator): the `user_method`, the `subject_class`, `sod_actions`,
349
+ `excluded_controllers` (keep infrastructure out of the grid), and
350
+ `parent_controller` (what the management UI inherits from). The three
351
+ impersonation knobs — `actor_method`, `allow_mutations_while_impersonating`,
352
+ and `sod_identity` — are grouped in their own block and covered under
353
+ [Impersonation](#impersonation-act-as); they layer in that order, so
354
+ `sod_identity` is only observable once a mutation is allowed past the read-only
355
+ gate.
356
+
357
+ The **audit ledger** is controlled by `config.audit` — tri-state
358
+ `false | true | :strict`. `false` records nothing; `true` (the default) records
359
+ every authorization change and degrades gracefully (skip + warn once) if the
360
+ events table isn't migrated; `:strict` **raises** on a missing events table so
361
+ 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.
364
+
365
+ Two loud-by-design behaviors: a controller excluded from the catalog can't be
366
+ 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
368
+ `user_method` that the controller doesn't respond to raises instead of
369
+ silently turning every request into a 403.
370
+
371
+ ### Impersonation (act-as)
372
+
373
+ `Current` distinguishes the **effective subject** (`current_scope_user` — who
374
+ the request acts as) from the **real actor** (`current_scope_actor` — who is
375
+ actually behind it). They're the same person until an admin impersonates
376
+ someone; then permission checks read the subject while attribution reads the
377
+ actor. `current_scope_actor` falls back to the subject, so it's never nil and
378
+ you never write a nil branch. `impersonating?` is the read-only-state signal
379
+ for views (show a banner, disable destructive controls).
380
+
381
+ Point `actor_method` at the host method that returns the real actor:
382
+
383
+ ```ruby
384
+ # config/initializers/current_scope.rb
385
+ config.actor_method = :true_user
386
+ ```
387
+
388
+ > **`actor_method` is security-critical, not an optional extra.** The entire
389
+ > act-as security model keys off `actor != user`. If you impersonate but leave
390
+ > `actor_method` unset, `actor` falls back to `user`, so it all *looks* fine in
391
+ > manual testing while being silently inert: the read-only-while-impersonating
392
+ > `MutationGuard` never engages, the SoD `:either` veto can't fire, and every
393
+ > audit row is attributed to the impersonated subject instead of the real
394
+ > admin. The permission path can't detect this, but the boundary API can:
395
+ > calling `CurrentScope.record_impersonation_started!` with `actor_method` unset
396
+ > **raises** — that call is your declaration that impersonation is live, so a
397
+ > missing `actor_method` there is unambiguously a misconfiguration. (A host that
398
+ > impersonates without ever calling the boundary API gets no runtime signal —
399
+ > so set `actor_method` whenever you set up act-as.)
400
+
401
+ The host owns the act-as switch — CurrentScope only reads it. The recipe:
402
+
403
+ ```ruby
404
+ class ApplicationController < ActionController::Base
405
+ include CurrentScope::Context
406
+ include CurrentScope::Guard
407
+
408
+ private
409
+
410
+ # The real actor: always the signed-in account, never the impersonated one.
411
+ def true_user = current_user
412
+
413
+ # The effective subject: re-resolved from the session EVERY request, never
414
+ # cached in Current (which is per-request and must not be trusted across
415
+ # requests). Falls back to the real actor when not impersonating.
416
+ def current_scope_user
417
+ return true_user unless session[:impersonated_subject_id]
418
+
419
+ User.find_by(id: session[:impersonated_subject_id]) || true_user
420
+ end
421
+ end
422
+ ```
423
+
424
+ Wire `current_scope_user` in as your `user_method`, or override the reader as
425
+ above. Start and stop act-as through state-changing verbs (CSRF-protected),
426
+ and authorize **who** may impersonate — this is a privilege escalation surface:
427
+
428
+ ```ruby
429
+ class ImpersonationsController < ApplicationController
430
+ def create # POST /impersonation
431
+ head :forbidden and return unless allowed_to?(:create, controller: "impersonations")
432
+ session[:impersonated_subject_id] = params.expect(:subject_id)
433
+ redirect_to root_path
434
+ end
435
+
436
+ def destroy # DELETE /impersonation
437
+ session.delete(:impersonated_subject_id)
438
+ redirect_to root_path
439
+ end
440
+ end
441
+ ```
442
+
443
+ Clear the impersonation on **both** sign-in and sign-out
444
+ (`session.delete(:impersonated_subject_id)`) so an act-as session can never
445
+ outlive the login that started it or bleed into the next one.
446
+
447
+ #### Impersonated sessions are read-only by default
448
+
449
+ An impersonated session can look, but not touch: with `actor_method` set,
450
+ every non-`GET`/`HEAD` request is denied while a real actor stands behind a
451
+ different subject — **including the engine's own management UI** (editing roles
452
+ and grants is the highest-value surface to keep read-only). This gate is a
453
+ *separate* `before_action` from the permission check, so it survives
454
+ `skip_before_action :current_scope_check!` and runs *first*. Flip
455
+ `config.allow_mutations_while_impersonating = true` to allow writes (at which
456
+ point the SoD `:either` veto above becomes the observable line of defense).
457
+
458
+ **Production refuses this flag by default.** Letting a real actor write as the
459
+ subject they impersonate is a privilege-escalation and audit-integrity risk, so
460
+ `config.allow_mutations_while_impersonating = true` **raises at boot in
461
+ production** unless you set `CURRENT_SCOPE_ALLOW_PROD_IMPERSONATION_MUTATIONS` in
462
+ the environment. An unsafe deploy fails loudly instead of running silently
463
+ insecure. `development`, `test`, and `staging` are unaffected — the flag works
464
+ there with no env var. Assigning `false` (the default) never raises anywhere.
465
+ The escape hatch exists for cases like a live public showcase whose whole point
466
+ is demonstrating impersonated actions; a real production app should almost
467
+ always leave impersonated sessions read-only.
468
+
469
+ Because it runs first, the endpoints that **end** an impersonation must opt
470
+ out — your stop-impersonation, sign-out, **and** sign-in actions — or you could
471
+ never turn act-as off (and sign-in could never clear it):
472
+
473
+ ```ruby
474
+ class SessionsController < ApplicationController
475
+ skip_before_action :current_scope_mutation_guard! # sign-in/out ends act-as
476
+ end
477
+
478
+ class ImpersonationsController < ApplicationController
479
+ skip_before_action :current_scope_mutation_guard!, only: :destroy # stop act-as
480
+ end
481
+ ```
482
+
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.
486
+
487
+ **View/gate disagreement is by design.** `allowed_to?` is HTTP-ignorant: it
488
+ still returns `true` for a permission the subject genuinely holds, even though
489
+ the mutation gate will `403` the resulting non-GET click while impersonating.
490
+ Drive read-only affordances off `impersonating?` — render a banner, disable or
491
+ hide destructive controls — rather than expecting `allowed_to?` to hide them.
492
+
493
+ > The audit boundary events for act-as (recording who impersonated whom, and
494
+ > when it stopped) land in a later unit — this section is the resolution
495
+ > plumbing only.
496
+
497
+ `Current` is request-scoped and does **not** flow into Active Job. When a job
498
+ needs the subject or actor, pass GlobalIDs (or ids) as arguments and re-resolve
499
+ inside `perform` — never read `CurrentScope::Current` from a job.
500
+
501
+ ### Testing your app
502
+
503
+ ```ruby
504
+ require "current_scope/test_helpers"
505
+
506
+ class ApproveButtonComponentTest < ViewComponent::TestCase
507
+ include CurrentScope::TestHelpers
508
+
509
+ test "renders for a reviewer" do
510
+ with_current_user(users(:reviewer)) do
511
+ render_inline ApproveButtonComponent.new(report: reports(:pending))
512
+ assert_selector "button", text: "Approve"
513
+ end
514
+ end
515
+ end
516
+ ```
517
+
518
+ `with_current_user` is for in-process unit/view/component checks. To test your
519
+ own controllers **behind the gate** in a request or system spec, seed real
520
+ grants with `grant_role!` / `grant_scoped_role!` — they persist assignment rows
521
+ that survive the request cycle (which `with_current_user` cannot, since
522
+ `Context` re-resolves the subject on every real request). They seed grants only;
523
+ your app still signs the subject in through its own auth:
524
+
525
+ ```ruby
526
+ class ReportsAccessTest < ActionDispatch::IntegrationTest
527
+ include CurrentScope::TestHelpers
528
+
529
+ test "a reviewer can list but not destroy" do
530
+ reviewer = users(:reviewer)
531
+ grant_role!(reviewer, role: roles(:member)) # org-wide grant
532
+ grant_scoped_role!(reviewer, role: roles(:viewer), record: reports(:q3)) # one record
533
+
534
+ sign_in reviewer # your app's own auth
535
+ get reports_path
536
+ assert_response :success
537
+ end
538
+ end
539
+ ```
540
+
541
+ `CurrentAttributes` resets around every request, job, and test — the ambient
542
+ subject cannot leak between executions.
543
+
544
+ ## The showcase app
545
+
546
+ The engine has a full companion **showcase** — a standalone, deployable Rails
547
+ 8.1 host app (Hotwire, ViewComponent, built-in auth) that dramatizes every
548
+ mechanism end to end: a multi-domain anti-fraud gallery (payroll / contracts /
549
+ expenses), one-click "act as", a guided "try to commit fraud → refused"
550
+ walkthrough, the auto-derived permission grid, and the management UI. It lives
551
+ in its own repository:
552
+
553
+ **→ [davidteren/current_scope_showcase](https://github.com/davidteren/current_scope_showcase)**
554
+
555
+ Run it locally alongside this engine (checked out as a sibling directory):
556
+
557
+ ```bash
558
+ git clone https://github.com/davidteren/current_scope
559
+ git clone https://github.com/davidteren/current_scope_showcase
560
+ cd current_scope_showcase
561
+ bin/setup # bundle (resolves the engine at ../current_scope), seed the DB
562
+ bin/rails server # http://localhost:3000
563
+ ```
564
+
565
+ ## Design notes
566
+
567
+ - [`resources/DESIGN.md`](resources/DESIGN.md) — the original design-concept
568
+ capture (under the placeholder name "Grantwork").
569
+ - [`docs/RESEARCH.md`](docs/RESEARCH.md) — the research behind the ambient
570
+ context: Evil Martians / Vladimir Dementyev (palkan) on CurrentAttributes
571
+ vs dry-effects vs explicit passing, and what this gem borrows from Action
572
+ Policy.
573
+
574
+ ## License
575
+
576
+ The gem is available as open source under the terms of the
577
+ [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile ADDED
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+
3
+ APP_RAKEFILE = File.expand_path("test/dummy/Rakefile", __dir__)
4
+ load "rails/tasks/engine.rake"
5
+
6
+ require "bundler/gem_tasks"