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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +577 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/current_scope/application.js +225 -0
- data/app/assets/stylesheets/current_scope/application.css +707 -0
- data/app/controllers/current_scope/application_controller.rb +54 -0
- data/app/controllers/current_scope/events_controller.rb +14 -0
- data/app/controllers/current_scope/role_assignments_controller.rb +104 -0
- data/app/controllers/current_scope/roles_controller.rb +135 -0
- data/app/controllers/current_scope/scoped_role_assignments_controller.rb +159 -0
- data/app/controllers/current_scope/subjects_controller.rb +57 -0
- data/app/helpers/current_scope/application_helper.rb +72 -0
- data/app/models/current_scope/application_record.rb +5 -0
- data/app/models/current_scope/current.rb +50 -0
- data/app/models/current_scope/event.rb +117 -0
- data/app/models/current_scope/role.rb +55 -0
- data/app/models/current_scope/role_assignment.rb +21 -0
- data/app/models/current_scope/role_permission.rb +9 -0
- data/app/models/current_scope/scoped_role_assignment.rb +14 -0
- data/app/views/current_scope/events/index.html.erb +41 -0
- data/app/views/current_scope/roles/edit.html.erb +86 -0
- data/app/views/current_scope/roles/index.html.erb +41 -0
- data/app/views/current_scope/roles/members.html.erb +96 -0
- data/app/views/current_scope/roles/new.html.erb +19 -0
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +135 -0
- data/app/views/current_scope/subjects/index.html.erb +100 -0
- data/app/views/layouts/current_scope/application.html.erb +53 -0
- data/config/routes.rb +14 -0
- data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
- data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
- data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
- data/lib/current_scope/configuration.rb +181 -0
- data/lib/current_scope/context.rb +36 -0
- data/lib/current_scope/engine.rb +14 -0
- data/lib/current_scope/gating_tripwire.rb +53 -0
- data/lib/current_scope/guard.rb +97 -0
- data/lib/current_scope/mutation_guard.rb +55 -0
- data/lib/current_scope/permission_catalog.rb +33 -0
- data/lib/current_scope/permission_grid.rb +90 -0
- data/lib/current_scope/permissions.rb +57 -0
- data/lib/current_scope/resolver.rb +171 -0
- data/lib/current_scope/scopeable.rb +38 -0
- data/lib/current_scope/test_helpers.rb +53 -0
- data/lib/current_scope/version.rb +3 -0
- data/lib/current_scope.rb +168 -0
- data/lib/generators/current_scope/install/install_generator.rb +33 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +77 -0
- data/lib/tasks/current_scope_tasks.rake +15 -0
- 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
|
+
[](https://rubygems.org/gems/current_scope)
|
|
4
|
+
[](https://github.com/davidteren/current_scope/actions/workflows/ci.yml)
|
|
5
|
+
[](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
|
+

|
|
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
|
+

|
|
57
|
+
|
|
58
|
+
| Roles | Members | Events |
|
|
59
|
+
|---|---|---|
|
|
60
|
+
|  |  |  |
|
|
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).
|