current_scope 0.5.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 +377 -0
- data/Rakefile +6 -0
- data/app/assets/javascripts/current_scope/application.js +229 -0
- data/app/assets/stylesheets/current_scope/application.css +917 -0
- data/app/controllers/current_scope/application_controller.rb +96 -0
- data/app/controllers/current_scope/events_controller.rb +14 -0
- data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
- data/app/controllers/current_scope/roles_controller.rb +256 -0
- data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
- data/app/controllers/current_scope/subjects_controller.rb +75 -0
- data/app/helpers/current_scope/application_helper.rb +261 -0
- data/app/models/concerns/current_scope/storable_keys.rb +101 -0
- data/app/models/current_scope/application_record.rb +5 -0
- data/app/models/current_scope/current.rb +74 -0
- data/app/models/current_scope/event.rb +136 -0
- data/app/models/current_scope/role.rb +106 -0
- data/app/models/current_scope/role_assignment.rb +42 -0
- data/app/models/current_scope/role_permission.rb +9 -0
- data/app/models/current_scope/scoped_role_assignment.rb +98 -0
- data/app/views/current_scope/events/index.html.erb +41 -0
- data/app/views/current_scope/roles/edit.html.erb +229 -0
- data/app/views/current_scope/roles/index.html.erb +55 -0
- data/app/views/current_scope/roles/members.html.erb +114 -0
- data/app/views/current_scope/roles/new.html.erb +19 -0
- data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
- data/app/views/current_scope/shared/access_denied.html.erb +30 -0
- data/app/views/current_scope/subjects/index.html.erb +124 -0
- data/app/views/layouts/current_scope/application.html.erb +56 -0
- data/config/routes.rb +13 -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/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
- data/lib/current_scope/configuration.rb +613 -0
- data/lib/current_scope/context.rb +41 -0
- data/lib/current_scope/engine.rb +202 -0
- data/lib/current_scope/gating_reflection.rb +113 -0
- data/lib/current_scope/gating_tripwire.rb +84 -0
- data/lib/current_scope/grant_diagnosis.rb +216 -0
- data/lib/current_scope/guard.rb +750 -0
- data/lib/current_scope/mutation_guard.rb +90 -0
- data/lib/current_scope/parent_chain.rb +397 -0
- data/lib/current_scope/permission_catalog.rb +146 -0
- data/lib/current_scope/permission_grid.rb +132 -0
- data/lib/current_scope/permissions.rb +94 -0
- data/lib/current_scope/resolver.rb +669 -0
- data/lib/current_scope/schema_guard.rb +223 -0
- data/lib/current_scope/scopeable.rb +38 -0
- data/lib/current_scope/sod_preflight.rb +380 -0
- data/lib/current_scope/test_helpers.rb +53 -0
- data/lib/current_scope/version.rb +3 -0
- data/lib/current_scope.rb +432 -0
- data/lib/generators/current_scope/install/install_generator.rb +114 -0
- data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
- data/lib/tasks/current_scope_tasks.rake +454 -0
- metadata +123 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: e36971fa803dbe79c3e3171704f24cb69ccdca7ff8c3cf7ad04ea5c13409fc01
|
|
4
|
+
data.tar.gz: 208ffd02340988fa901b03f1f3ccc6a22684b5bd37da1f58c4dbabceb0016ae6
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 68203296603381de42f71070d6650663e56eb9e0ec9d10511544a43583e4fc10b7511677a26a888ead593295b8558f993b92ee8cde9443e4b215581b7bab02d5
|
|
7
|
+
data.tar.gz: dc93b8f64007cfc730b32453f968039d44eba0e04c9998957b1ee05c6bbf01de942f4a145eec02cb120b627f8b5d16896c8d676d626447934b23183d01899637
|
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,377 @@
|
|
|
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
|
+
[](https://davidteren.github.io/current_scope/)
|
|
7
|
+
[](https://github.com/davidteren/current_scope/issues)
|
|
8
|
+
|
|
9
|
+
> ## ⚠️ Not production-ready
|
|
10
|
+
>
|
|
11
|
+
> There are some known issues which are currently being worked on. **This is not
|
|
12
|
+
> production-ready**, but it is ready for experimentation and spiking, or
|
|
13
|
+
> whatever people want to do with it — just not yet for production.
|
|
14
|
+
>
|
|
15
|
+
> This is an **authorization** library, so the bar is different: a bug here is a
|
|
16
|
+
> user seeing or doing something they shouldn't. The open work is tracked in the
|
|
17
|
+
> [issue tracker](https://github.com/davidteren/current_scope/issues), and
|
|
18
|
+
> several items are security-relevant — permission keys that can be dropped
|
|
19
|
+
> silently, advisory checks that don't consult the catalog, and gaps in the
|
|
20
|
+
> separation-of-duties veto. Each is being worked through with a written plan and
|
|
21
|
+
> an adversarial review pass.
|
|
22
|
+
>
|
|
23
|
+
> Kick the tyres, build a spike, tell us what breaks. Don't put it in front of
|
|
24
|
+
> real users yet.
|
|
25
|
+
|
|
26
|
+
**Website:** [davidteren.github.io/current_scope](https://davidteren.github.io/current_scope/) —
|
|
27
|
+
overview, quickstart, the
|
|
28
|
+
[separation-of-duties guide](https://davidteren.github.io/current_scope/separation-of-duties.html),
|
|
29
|
+
the security checklist, and
|
|
30
|
+
[copy-paste prompts for AI agents](https://davidteren.github.io/current_scope/ai-agents.html).
|
|
31
|
+
Source lives in [`docs/site/`](docs/site/).
|
|
32
|
+
|
|
33
|
+
**Authorization as data you edit in a UI, not rules you hardcode and redeploy —
|
|
34
|
+
with one ambient context that makes `allowed_to?` work identically in
|
|
35
|
+
controllers, views, and components.**
|
|
36
|
+
|
|
37
|
+
CurrentScope is a mountable Rails engine. You add the gem, run the install
|
|
38
|
+
generator, and get:
|
|
39
|
+
|
|
40
|
+
- **Permissions auto-derived from your routes.** Every `controller#action`
|
|
41
|
+
pair *is* a permission. Add an `OrdersController` and its actions appear in
|
|
42
|
+
the permission grid with zero wiring.
|
|
43
|
+
- **Roles as rows, not classes.** A role is a named, editable bundle of
|
|
44
|
+
permissions — ticked cells on a controller × action grid. Change what
|
|
45
|
+
"Reviewer" means without a deploy.
|
|
46
|
+
- **Scoped roles.** The same role, attached to one specific record: "Editor of
|
|
47
|
+
Project #7" grants nothing on Project #8. A model can opt in to reaching down a
|
|
48
|
+
declared chain with `current_scope_parent :project`, so a role held on a
|
|
49
|
+
project covers that project's reports — including reports created after the
|
|
50
|
+
grant, and up to five hops of nesting.
|
|
51
|
+
Flat is still the default, a scoped `full_access` grant deliberately does not
|
|
52
|
+
cascade, and the four-eyes veto keeps reading the record you handed it. See
|
|
53
|
+
[Checking permissions](docs/guides/checking-permissions.md#a-grant-on-a-parent-record-108).
|
|
54
|
+
- **An optional separation-of-duties veto.** Off by default; opt in by listing
|
|
55
|
+
actions. Once on, whoever initiated a record can never approve it — not
|
|
56
|
+
grantable, not configurable in the UI, overrides even full access. A
|
|
57
|
+
structural guarantee, not a preference.
|
|
58
|
+
- **Fail-closed resolution.** No grant means denied. Everything is a
|
|
59
|
+
permission, even the baseline things every signed-in user can do.
|
|
60
|
+
- **An ambient authorization context.** The current subject flows through
|
|
61
|
+
`ActiveSupport::CurrentAttributes` from the controller gate down to the
|
|
62
|
+
smallest ViewComponent. The view can never disagree with the gate — they ask
|
|
63
|
+
the same resolver.
|
|
64
|
+
|
|
65
|
+
The decision order, fixed:
|
|
66
|
+
|
|
67
|
+
```
|
|
68
|
+
1. SoD veto → initiator? (opt-in, off by default) DENY (overrides all)
|
|
69
|
+
2. full_access → role grants everything, forever ALLOW
|
|
70
|
+
3. org-wide role → role's permission set includes it ALLOW
|
|
71
|
+
4. scoped role → a role held on THIS record ALLOW
|
|
72
|
+
5. otherwise → default deny
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Screenshots
|
|
76
|
+
|
|
77
|
+
The mounted management UI at `/current_scope` — self-contained (no web fonts, no
|
|
78
|
+
build step, CSP-safe), first-class light **and** dark themes.
|
|
79
|
+
|
|
80
|
+
**Permission grid** — one row per controller, CRUD action groups derived from
|
|
81
|
+
your routes; ticked cells glow, a partial group reads as indeterminate. A route
|
|
82
|
+
whose controller class is missing (stale or typo) still appears — the catalog
|
|
83
|
+
mirrors routes — but the row is badged **no controller** so you do not grant a
|
|
84
|
+
key that only 500s. Remove the route or add the class;
|
|
85
|
+
`excluded_controllers` can hide the row if you want it out of the grid.
|
|
86
|
+
|
|
87
|
+

|
|
88
|
+
|
|
89
|
+
**Subjects** — everyone who can hold a role, their one org-wide role, and any
|
|
90
|
+
per-record scoped roles; server-side search across all subjects.
|
|
91
|
+
|
|
92
|
+

|
|
93
|
+
|
|
94
|
+
| Roles | Members | Events |
|
|
95
|
+
|---|---|---|
|
|
96
|
+
|  |  |  |
|
|
97
|
+
|
|
98
|
+
Screenshot regenerate command: [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
99
|
+
|
|
100
|
+
## Installation
|
|
101
|
+
|
|
102
|
+
> **Upgrading from 0.4 or earlier? Run the migrations.** 0.5 widens the columns
|
|
103
|
+
> that store a grant's subject and resource id, so UUID and other string primary
|
|
104
|
+
> keys are stored whole instead of being truncated to an integer
|
|
105
|
+
> ([#151](https://github.com/davidteren/current_scope/issues/151) — two subjects
|
|
106
|
+
> could collapse into one identity, and one inherit the other's roles). Run
|
|
107
|
+
> `bin/rails current_scope:install:migrations && bin/rails db:migrate`; the engine
|
|
108
|
+
> refuses to boot until you do. If MySQL was loaded from `schema.rb`, also run
|
|
109
|
+
> `bin/rails current_scope:repair_schema` to apply the binary collation that
|
|
110
|
+
> `schema.rb` cannot represent. Integer, UUID and ULID keys all work, up to 64
|
|
111
|
+
> characters. See [UPGRADING.md](UPGRADING.md).
|
|
112
|
+
|
|
113
|
+
This is the **canonical greenfield quickstart** (new app, or install before
|
|
114
|
+
users hit gated controllers). The same numbered path lives on the
|
|
115
|
+
[docs site](https://davidteren.github.io/current_scope/quickstart.html) and in
|
|
116
|
+
the install generator's next-steps text (#25). **Existing apps with traffic
|
|
117
|
+
must use [report mode first](#retrofitting-an-app-that-already-has-users)**
|
|
118
|
+
before bootstrap — do not cut over blind.
|
|
119
|
+
|
|
120
|
+
```ruby
|
|
121
|
+
# Gemfile
|
|
122
|
+
gem "current_scope"
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
```bash
|
|
126
|
+
bin/rails generate current_scope:install
|
|
127
|
+
bin/rails current_scope:install:migrations && bin/rails db:migrate
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
**1. Include the concerns** in `ApplicationController` — `Context` populates
|
|
131
|
+
the ambient subject from your authentication, `Guard` gates every action:
|
|
132
|
+
|
|
133
|
+
```ruby
|
|
134
|
+
class ApplicationController < ActionController::Base
|
|
135
|
+
include CurrentScope::Context # sets CurrentScope::Current.user from current_user
|
|
136
|
+
include CurrentScope::Guard # fail-closed gate on every action
|
|
137
|
+
end
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
**2. Skip the gate on sign-in** (and other public endpoints). **Do not skip
|
|
141
|
+
this step** — the gate is fail-closed and covers *everything*, including login.
|
|
142
|
+
Prefer the declared form so the role grid shows **why** the gate is off:
|
|
143
|
+
|
|
144
|
+
```ruby
|
|
145
|
+
class SessionsController < ApplicationController
|
|
146
|
+
current_scope_skip_gate!(reason: "sign-in must run without a grant")
|
|
147
|
+
# While impersonating, sign-in/out must also clear the mutation guard or a
|
|
148
|
+
# POST that ends act-as is blocked (same as bare skip of the permission gate):
|
|
149
|
+
skip_before_action :current_scope_mutation_guard!
|
|
150
|
+
# bare skip_before_action :current_scope_check! still works, but the grid
|
|
151
|
+
# marks it as an unexplained "gate not run"
|
|
152
|
+
end
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
A skipped controller is unprotected by the permission gate — supply your own
|
|
156
|
+
auth where that matters ([security checklist](docs/SECURITY-CHECKLIST.md)).
|
|
157
|
+
|
|
158
|
+
**3. Bootstrap the first admin.** The management UI only admits full-access
|
|
159
|
+
subjects; the seeded **Member** role starts with **zero** permissions until
|
|
160
|
+
you edit it:
|
|
161
|
+
|
|
162
|
+
```bash
|
|
163
|
+
bin/rails current_scope:grant SUBJECT_ID=YOUR_USER_ID
|
|
164
|
+
# or: CurrentScope.grant!(User.first) # upserts Owner — not RoleAssignment.create!
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
`grant!` reuses an existing role named Owner without forcing `full_access`.
|
|
168
|
+
On a greenfield seed that is fine (seed_defaults! creates Owner as full_access).
|
|
169
|
+
If someone renamed/stripped Owner earlier, repair with
|
|
170
|
+
`CurrentScope::Role.find_by!(name: "Owner").update!(full_access: true)` before
|
|
171
|
+
expecting `/current_scope` to open.
|
|
172
|
+
|
|
173
|
+
**4. Manage roles** at `/current_scope`. A Guard denial is HTTP 403 with
|
|
174
|
+
`X-Current-Scope-Reason` (`no_grant`, `sod_veto`, …) when the default
|
|
175
|
+
engine rescue runs. Host `rescue_from` handlers can replace that response.
|
|
176
|
+
|
|
177
|
+
### Retrofitting an app that already has users
|
|
178
|
+
|
|
179
|
+
> **Retrofitting a real app?** There's a full guide:
|
|
180
|
+
> [Adopting CurrentScope in an existing app](docs/guides/adopting-in-an-existing-app.md)
|
|
181
|
+
> — callback ordering vs. your authentication, the Devise recipe, the
|
|
182
|
+
> `skip_before_action` fail-open trap, hybrid HTML+API grants, and a rollout
|
|
183
|
+
> ladder. The short version is below.
|
|
184
|
+
>
|
|
185
|
+
> **Shipping?** Read the [Security & production checklist](docs/SECURITY-CHECKLIST.md)
|
|
186
|
+
> first — excluded controllers, the 403/404 record oracle, and the pre-ship tick list.
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
The gate is fail-closed, so the line you just added denies **everything** until
|
|
190
|
+
grants exist. On a greenfield app that's invisible — you seed the Owner role and
|
|
191
|
+
move on. On an app that already has controllers and traffic, it means your suite
|
|
192
|
+
goes red and your users get 403s the moment you deploy, and the only way to
|
|
193
|
+
discover what you should have granted is to break it and read the wreckage.
|
|
194
|
+
|
|
195
|
+
Don't cut over blind. Run in report mode first:
|
|
196
|
+
|
|
197
|
+
```ruby
|
|
198
|
+
CurrentScope.configure do |config|
|
|
199
|
+
config.enforcement = :report # :enforce (default) | :report
|
|
200
|
+
end
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
The gate now logs what it *would* have denied and lets the request through,
|
|
204
|
+
recording each one to the ledger. Exercise the app, or just run your suite —
|
|
205
|
+
then read the gaps back out:
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
bin/rails current_scope:report
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
```
|
|
212
|
+
Would-be denials — grant these to stop them (most-denied first):
|
|
213
|
+
|
|
214
|
+
Ada Lovelace — currently Member
|
|
215
|
+
412x reports#index
|
|
216
|
+
38x reports#export
|
|
217
|
+
Grace Hopper
|
|
218
|
+
7x reports#approve
|
|
219
|
+
|
|
220
|
+
Total: 457 would-be denials across 2 subject(s).
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
That *is* your grant-seeding work, in the shape of the role grid you need to
|
|
224
|
+
build: every subject who'd have been refused, what they were missing, and how
|
|
225
|
+
badly. Seed the roles it names, re-exercise, and flip to `:enforce` once newly
|
|
226
|
+
exercised requests stop adding rows (the report reads the append-only
|
|
227
|
+
ledger, so historical rows do not clear). Each step is one line back, and nobody gets a 403 while you learn.
|
|
228
|
+
|
|
229
|
+
The rows are ordinary ledger events, so query them directly if you want
|
|
230
|
+
something the task doesn't show:
|
|
231
|
+
|
|
232
|
+
```ruby
|
|
233
|
+
CurrentScope::Event.where(event: "access.would_deny").pluck(:subject, :details)
|
|
234
|
+
# => [["gid://app/User/7", {"permission" => "reports#index", "reason" => "no_grant"}], ...]
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
**Report mode is an adoption ramp, not an off switch — don't run production on
|
|
238
|
+
it.** It relaxes exactly one denial: *nobody has granted this yet*. Everything
|
|
239
|
+
else still refuses:
|
|
240
|
+
|
|
241
|
+
| Still enforced in `:report` | Why it can't be relaxed |
|
|
242
|
+
|---|---|
|
|
243
|
+
| Separation-of-duties veto | Lifting it lets an initiator really approve their own record — a fraud action executed, not a role gap surfaced. |
|
|
244
|
+
| SoD actions the veto *couldn't* run on | If an SoD action is gated without a record, the veto has no initiator to measure and is skipped — so the refusal that comes back says "not granted", not "SoD approved". Report mode won't speak for a rule nobody asked, and still refuses — but it **logs the blind spot and records `access.sod_blind_spot`** (not `access.would_deny`; granting will not clear the 403). `rails current_scope:report` lists them separately. |
|
|
245
|
+
| SoD actions on a model with no `current_scope_initiator` | The veto cannot be measured at all, so the resolver raises `ConfigurationError` and the request **500s — under `:report` exactly as under `:enforce`**. Passing it through would run a four-eyes action unchecked; a 403 would make a wiring mistake read as an ordinary denial. The engine warns about these **when the routes load** (boot in production and staging; development's lazy route set defers it to the first request) where a controller declares `current_scope_model`, records `access.sod_initiator_missing` when traffic finds one, and `rails current_scope:report` lists both. |
|
|
246
|
+
| The management console | It's where grants are made. An observation flag that opened it would be a privilege escalation. |
|
|
247
|
+
| Impersonation read-only gate | Runs before the permission check and answers to its own rule. |
|
|
248
|
+
|
|
249
|
+
The response carries `X-Current-Scope-Reason: would_deny` on anything report mode
|
|
250
|
+
let through, so you can spot them in an integration test or a proxy log without
|
|
251
|
+
reading the ledger.
|
|
252
|
+
|
|
253
|
+
**Assumption #1: every controller descends from a `Guard`'d base.** An action on
|
|
254
|
+
a controller that never includes `Guard` (an API base, a hand-rolled
|
|
255
|
+
`ActionController::Base`) is silently ungated — though no longer invisibly: the
|
|
256
|
+
permission grid badges any controller **provably** ungated ("gate not run"),
|
|
257
|
+
and `bin/rails current_scope:ungated` prints the same inventory as a command.
|
|
258
|
+
To catch it at runtime, include the optional `CurrentScope::GatingTripwire` on
|
|
259
|
+
the base you want verified — it fires after any action that didn't run the
|
|
260
|
+
gate: **raising in dev/test, or logging once per `controller#action` under
|
|
261
|
+
`config.gating_tripwire = :warn` (the default outside dev/test; once per
|
|
262
|
+
process per site — a concurrent first hit can rarely emit a duplicate line)**, so a
|
|
263
|
+
production host can inventory its ungated surface without 500ing. It carries
|
|
264
|
+
its own `current_scope_skip_tripwire!` marker for genuinely-public actions (you
|
|
265
|
+
can't use `skip_before_action :current_scope_check!` on a controller that never
|
|
266
|
+
defined that callback — it raises at class load):
|
|
267
|
+
|
|
268
|
+
```ruby
|
|
269
|
+
class ApiController < ActionController::Base
|
|
270
|
+
include CurrentScope::GatingTripwire
|
|
271
|
+
current_scope_skip_tripwire! only: :health
|
|
272
|
+
end
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
It's an `after_action`, so it can't see an action that renders from a
|
|
276
|
+
`before_action` (halted chain) — a strong aid, not total coverage. The grid
|
|
277
|
+
badge and the `ungated` task mark only what the callback chain *proves*: a
|
|
278
|
+
conditional skip (`only:`/`except:`) renders unmarked and is exactly what
|
|
279
|
+
`:warn` exists to catch.
|
|
280
|
+
|
|
281
|
+
Bootstrap the first admin (the management UI needs a full-access subject to
|
|
282
|
+
enter, so the first grant can't happen in the UI). One command:
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
bin/rails current_scope:grant SUBJECT_ID=1 # grants the full-access Owner role
|
|
286
|
+
```
|
|
287
|
+
|
|
288
|
+
or in `db/seeds.rb`:
|
|
289
|
+
|
|
290
|
+
```ruby
|
|
291
|
+
CurrentScope.seed_defaults! # Owner (full_access) + Member
|
|
292
|
+
CurrentScope.grant!(User.first) # give the first user the Owner role
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Then manage everything at `/current_scope` (full-access subjects only): the
|
|
296
|
+
role grid, org-wide assignments, scoped grants.
|
|
297
|
+
|
|
298
|
+
## Documentation
|
|
299
|
+
|
|
300
|
+
| Guide | What it covers |
|
|
301
|
+
|---|---|
|
|
302
|
+
| [Concepts & glossary](docs/guides/concepts-and-glossary.md) | Decision order + core vocabulary — **read first** |
|
|
303
|
+
| [Checking permissions](docs/guides/checking-permissions.md) | `allowed_to?`, `scope_for`, record-level, scopeable models |
|
|
304
|
+
| [Separation of duties & break-glass](docs/guides/separation-of-duties-and-break-glass.md) | SoD veto, `allow_sod_bypass` |
|
|
305
|
+
| [Impersonation](docs/guides/impersonation.md) | Act-as, mutation guard, denial shape |
|
|
306
|
+
| [Configuration reference](docs/guides/configuration-reference.md) | Initializer knobs, enforcement, audit, diagnostics |
|
|
307
|
+
| [Testing](docs/guides/testing.md) | `TestHelpers`, grants in request specs |
|
|
308
|
+
| [Adopting in an existing app](docs/guides/adopting-in-an-existing-app.md) | Report-mode retrofit ladder |
|
|
309
|
+
| [Security & production checklist](docs/SECURITY-CHECKLIST.md) | Pre-ship tick list |
|
|
310
|
+
| [Docs site](https://davidteren.github.io/current_scope/) | Published quickstart, SoD story, AI-agent prompts |
|
|
311
|
+
|
|
312
|
+
Root [CONCEPTS.md](CONCEPTS.md) is the longer glossary narrative for maintainers.
|
|
313
|
+
|
|
314
|
+
## The showcase app
|
|
315
|
+
|
|
316
|
+
The engine has a full companion **showcase** — a standalone, deployable Rails
|
|
317
|
+
8.1 host app (Hotwire, ViewComponent, built-in auth) that dramatizes every
|
|
318
|
+
mechanism end to end: a multi-domain anti-fraud gallery (payroll / contracts /
|
|
319
|
+
expenses), one-click "act as", a guided "try to commit fraud → refused"
|
|
320
|
+
walkthrough, the auto-derived permission grid, and the management UI. It lives
|
|
321
|
+
in its own repository:
|
|
322
|
+
|
|
323
|
+
**→ [davidteren/current_scope_showcase](https://github.com/davidteren/current_scope_showcase)**
|
|
324
|
+
|
|
325
|
+
Run it locally alongside this engine (checked out as a sibling directory):
|
|
326
|
+
|
|
327
|
+
```bash
|
|
328
|
+
git clone https://github.com/davidteren/current_scope
|
|
329
|
+
git clone https://github.com/davidteren/current_scope_showcase
|
|
330
|
+
cd current_scope_showcase
|
|
331
|
+
bin/setup # bundle (resolves the engine at ../current_scope), seed the DB
|
|
332
|
+
bin/rails server # http://localhost:3000
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
## Limitations
|
|
336
|
+
|
|
337
|
+
**SSR-first.** CurrentScope is for server-rendered Rails (controllers, views,
|
|
338
|
+
ViewComponents, Turbo). Separate JS front-ends ([#96](https://github.com/davidteren/current_scope/issues/96))
|
|
339
|
+
and Inertia ([#97](https://github.com/davidteren/current_scope/issues/97)) have
|
|
340
|
+
no first-class client contract yet. API controllers that include Guard still
|
|
341
|
+
authorize on the server.
|
|
342
|
+
|
|
343
|
+
**Model limits** — deliberate shape of the v1 data model, not gaps:
|
|
344
|
+
|
|
345
|
+
| Limit | What it means |
|
|
346
|
+
|---|---|
|
|
347
|
+
| **Flat scoped grants** | A scoped role on a parent record does **not** cascade to children. Hierarchy is deferred — see [docs/ROADMAP.md](docs/ROADMAP.md) §2.3. |
|
|
348
|
+
| **One org-wide role** | At most one org-wide role per subject (DB-enforced). |
|
|
349
|
+
| **Scoped role = full bundle** | Scoping reuses the whole role; there is no per-record capability subset. |
|
|
350
|
+
|
|
351
|
+
**Intentional residuals** (not forgotten bugs) — full write-up on the
|
|
352
|
+
[Limitations page](https://davidteren.github.io/current_scope/limitations.html)
|
|
353
|
+
(source: [docs/site/limitations.md](docs/site/limitations.md)):
|
|
354
|
+
|
|
355
|
+
| Residual | What it means for you |
|
|
356
|
+
|---|---|
|
|
357
|
+
| A5 SoD + nil record | Member SoD actions must return the record or the veto is skipped |
|
|
358
|
+
| A2 `actor_method` | Set it when you impersonate; no false auto-detect |
|
|
359
|
+
| A6 audit degrade | Use `audit: :strict` when the ledger is mandatory |
|
|
360
|
+
| Trusted `current_scope_model` | Wrong type can open wrong listed reads — review like the record hook |
|
|
361
|
+
| Report × model_undeclared / model_invalid | Hard 403 (reason header + dev nudge) only when a scoped grant would otherwise satisfy; plain no_grant still report-mode observes |
|
|
362
|
+
| GatingTripwire opt-in | Never-included Guard stays open; include Guard + optional tripwire |
|
|
363
|
+
| Parent/child cascade is opt-in | Flat unless the child declares `current_scope_parent`; then bounded at 5 hops, and `full_access` does not cascade (#108) |
|
|
364
|
+
|
|
365
|
+
## Design notes
|
|
366
|
+
|
|
367
|
+
- [`resources/DESIGN.md`](resources/DESIGN.md) — the original design-concept
|
|
368
|
+
capture (under the placeholder name "Grantwork").
|
|
369
|
+
- [`docs/RESEARCH.md`](docs/RESEARCH.md) — the research behind the ambient
|
|
370
|
+
context: Evil Martians / Vladimir Dementyev (palkan) on CurrentAttributes
|
|
371
|
+
vs dry-effects vs explicit passing, and what this gem borrows from Action
|
|
372
|
+
Policy.
|
|
373
|
+
|
|
374
|
+
## License
|
|
375
|
+
|
|
376
|
+
The gem is available as open source under the terms of the
|
|
377
|
+
[MIT License](https://opensource.org/licenses/MIT).
|
data/Rakefile
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// The engine's only JavaScript, and pure progressive enhancement.
|
|
2
|
+
//
|
|
3
|
+
// CSP-safe by construction: it ships as a served asset under script-src 'self'
|
|
4
|
+
// (never an inline onchange= handler, which a baseline CSP blocks). One
|
|
5
|
+
// delegated change listener auto-submits the scoped-role cascade form when a
|
|
6
|
+
// marked control changes — pick a resource type, or type a search — so the next
|
|
7
|
+
// step renders without a manual click. Every such control still has a visible
|
|
8
|
+
// submit button, so the cascade works with this script disabled, and with no
|
|
9
|
+
// Turbo at all (the submit is a plain full-page GET).
|
|
10
|
+
document.addEventListener("change", function (event) {
|
|
11
|
+
var el = event.target;
|
|
12
|
+
if (!el || typeof el.matches !== "function") return;
|
|
13
|
+
if (!el.matches("[data-current-scope-autosubmit]")) return;
|
|
14
|
+
|
|
15
|
+
var form = el.form || el.closest("form");
|
|
16
|
+
if (form) form.requestSubmit();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// CSP-safe confirmation for destructive submits. `data-turbo-confirm` only fires
|
|
20
|
+
// when the host loads Turbo; this engine can't assume that, so a form carrying
|
|
21
|
+
// data-cs-confirm gets a native window.confirm regardless. Runs in the capture
|
|
22
|
+
// phase so it can veto before any other submit handler acts.
|
|
23
|
+
document.addEventListener("submit", function (event) {
|
|
24
|
+
var form = event.target;
|
|
25
|
+
if (!form || typeof form.matches !== "function") return;
|
|
26
|
+
if (!form.matches("[data-cs-confirm]")) return;
|
|
27
|
+
if (!window.confirm(form.getAttribute("data-cs-confirm"))) event.preventDefault();
|
|
28
|
+
}, true);
|
|
29
|
+
|
|
30
|
+
// Light/dark theme toggle. Progressive enhancement: the server already renders
|
|
31
|
+
// the chosen theme from the current_scope_theme cookie (so there's no flash), and defaults
|
|
32
|
+
// to the OS preference when no cookie is set. This just flips the choice live
|
|
33
|
+
// and persists it. CSP-safe (served asset, no inline handler).
|
|
34
|
+
document.addEventListener("click", function (event) {
|
|
35
|
+
var btn = event.target.closest && event.target.closest("[data-cs-theme-toggle]");
|
|
36
|
+
if (!btn) return;
|
|
37
|
+
|
|
38
|
+
var root = document.documentElement;
|
|
39
|
+
var current = root.getAttribute("data-cs-theme");
|
|
40
|
+
var effectiveDark = current
|
|
41
|
+
? current === "dark"
|
|
42
|
+
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
43
|
+
var next = effectiveDark ? "light" : "dark";
|
|
44
|
+
|
|
45
|
+
root.setAttribute("data-cs-theme", next);
|
|
46
|
+
// Namespaced cookie name so it can't collide with a host cookie; Secure on
|
|
47
|
+
// https so a UI preference isn't sent in the clear.
|
|
48
|
+
var secure = window.location.protocol === "https:" ? ";secure" : "";
|
|
49
|
+
document.cookie = "current_scope_theme=" + next + ";path=/;max-age=31536000;samesite=lax" + secure;
|
|
50
|
+
btn.setAttribute("aria-pressed", String(next === "dark"));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Sync the toggle's aria-pressed to the theme actually rendered. The server can
|
|
54
|
+
// only set it from the cookie; with no cookie + OS dark the page is dark but the
|
|
55
|
+
// server rendered aria-pressed="false". Correct it once we can read matchMedia.
|
|
56
|
+
document.addEventListener("DOMContentLoaded", function () {
|
|
57
|
+
var btn = document.querySelector("[data-cs-theme-toggle]");
|
|
58
|
+
if (!btn) return;
|
|
59
|
+
var current = document.documentElement.getAttribute("data-cs-theme");
|
|
60
|
+
var effectiveDark = current
|
|
61
|
+
? current === "dark"
|
|
62
|
+
: window.matchMedia("(prefers-color-scheme: dark)").matches;
|
|
63
|
+
btn.setAttribute("aria-pressed", String(effectiveDark));
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Permission grid: a per-row "enable all" master checkbox toggles every action in its
|
|
67
|
+
// controller row, and stays in sync (checked / indeterminate / unchecked) as individual
|
|
68
|
+
// actions change. Progressive enhancement — with JS off, each action checkbox still works.
|
|
69
|
+
(function () {
|
|
70
|
+
// Matches both channels: raw action checkboxes (role[permission_keys][]) and
|
|
71
|
+
// grouped CRUD checkboxes (role[permission_groups][]).
|
|
72
|
+
var ACTION = 'input[type="checkbox"][name^="role[permission"]';
|
|
73
|
+
|
|
74
|
+
function actionsIn(row) { return row.querySelectorAll(ACTION); }
|
|
75
|
+
|
|
76
|
+
// A partial group checkbox ships hidden [data-cs-preserve] inputs that keep its
|
|
77
|
+
// existing keys across a no-op save. Once the user (or the row master) drives
|
|
78
|
+
// the checkbox, the checkbox alone governs the group: clear the indeterminate
|
|
79
|
+
// hint and disable the preserve inputs so they don't force the old subset back.
|
|
80
|
+
function releasePartial(box) {
|
|
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");
|
|
86
|
+
var cell = box.closest("td");
|
|
87
|
+
if (!cell) return;
|
|
88
|
+
cell.querySelectorAll("[data-cs-preserve]").forEach(function (h) { h.disabled = true; });
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function syncMaster(row) {
|
|
92
|
+
var master = row.querySelector("[data-cs-row-all]");
|
|
93
|
+
if (!master) return;
|
|
94
|
+
var boxes = actionsIn(row), checked = 0;
|
|
95
|
+
boxes.forEach(function (b) { if (b.checked) checked++; });
|
|
96
|
+
master.checked = boxes.length > 0 && checked === boxes.length;
|
|
97
|
+
master.indeterminate = checked > 0 && checked < boxes.length;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
document.addEventListener("change", function (event) {
|
|
101
|
+
var el = event.target;
|
|
102
|
+
if (!el || typeof el.matches !== "function") return;
|
|
103
|
+
|
|
104
|
+
if (el.matches("[data-cs-row-all]")) {
|
|
105
|
+
var row = el.closest("tr");
|
|
106
|
+
if (row) actionsIn(row).forEach(function (b) {
|
|
107
|
+
b.checked = el.checked;
|
|
108
|
+
releasePartial(b); // keep displayed + submitted state consistent with the master
|
|
109
|
+
});
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
if (el.matches(ACTION)) {
|
|
113
|
+
releasePartial(el);
|
|
114
|
+
var r = el.closest("tr");
|
|
115
|
+
if (r) syncMaster(r);
|
|
116
|
+
}
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
document.addEventListener("DOMContentLoaded", function () {
|
|
120
|
+
document.querySelectorAll("[data-cs-row-all]").forEach(function (master) {
|
|
121
|
+
var row = master.closest("tr");
|
|
122
|
+
if (row) syncMaster(row);
|
|
123
|
+
});
|
|
124
|
+
// A grouped CRUD checkbox that's checked but only partially granted
|
|
125
|
+
// (e.g. read = index but not show) reads as indeterminate.
|
|
126
|
+
document.querySelectorAll('[data-cs-partial="true"]').forEach(function (cb) {
|
|
127
|
+
cb.indeterminate = true;
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
})();
|
|
131
|
+
|
|
132
|
+
// Subjects page: client-side filter, multi-select, and a bulk "grant scoped role
|
|
133
|
+
// to selected" action. Framework-free (no Stimulus dependency) so it works in
|
|
134
|
+
// any host; pure progressive enhancement — single-subject assignment still works
|
|
135
|
+
// with JS off via each row's "+ scoped role" link.
|
|
136
|
+
(function () {
|
|
137
|
+
function rows() {
|
|
138
|
+
var list = document.querySelector("[data-cs-filter-list]");
|
|
139
|
+
return list ? Array.prototype.slice.call(list.querySelectorAll("[data-cs-row]")) : [];
|
|
140
|
+
}
|
|
141
|
+
function visibleRows() { return rows().filter(function (r) { return !r.hidden; }); }
|
|
142
|
+
function selectOf(row) { return row.querySelector("[data-cs-select]"); }
|
|
143
|
+
// Scan ALL rows, not just visible ones: a subject checked before the operator
|
|
144
|
+
// typed a filter must stay in the bulk selection (select-all still works off
|
|
145
|
+
// visibleRows). Otherwise filtering would silently drop checked subjects.
|
|
146
|
+
function selectedRows() {
|
|
147
|
+
return rows().filter(function (r) { var cb = selectOf(r); return cb && cb.checked; });
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function syncBulk() {
|
|
151
|
+
var bar = document.querySelector("[data-cs-bulk]");
|
|
152
|
+
if (bar) {
|
|
153
|
+
var n = selectedRows().length;
|
|
154
|
+
bar.hidden = n === 0;
|
|
155
|
+
var count = bar.querySelector("[data-cs-bulk-count]");
|
|
156
|
+
if (count) count.textContent = String(n);
|
|
157
|
+
}
|
|
158
|
+
var all = document.querySelector("[data-cs-select-all]");
|
|
159
|
+
if (all) {
|
|
160
|
+
var vis = visibleRows();
|
|
161
|
+
var checked = vis.filter(function (r) { var cb = selectOf(r); return cb && cb.checked; });
|
|
162
|
+
all.checked = vis.length > 0 && checked.length === vis.length;
|
|
163
|
+
all.indeterminate = checked.length > 0 && checked.length < vis.length;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
document.addEventListener("input", function (event) {
|
|
168
|
+
if (!event.target.matches || !event.target.matches("[data-cs-filter]")) return;
|
|
169
|
+
var needle = event.target.value.trim().toLowerCase();
|
|
170
|
+
var anyVisible = false;
|
|
171
|
+
rows().forEach(function (row) {
|
|
172
|
+
// Prefer the row's explicit filter text (subject + roles + records); fall
|
|
173
|
+
// back to textContent only if a row didn't provide one.
|
|
174
|
+
var haystack = (row.getAttribute("data-cs-filter-text") || row.textContent).toLowerCase();
|
|
175
|
+
var match = !needle || haystack.indexOf(needle) !== -1;
|
|
176
|
+
// Keep a checked (selected) row visible even when it doesn't match, so a
|
|
177
|
+
// subject can never sit hidden-but-selected inside a bulk action — what
|
|
178
|
+
// you see stays what you'll act on. selectedRows() scans all rows.
|
|
179
|
+
var cb = selectOf(row);
|
|
180
|
+
row.hidden = !match && !(cb && cb.checked);
|
|
181
|
+
if (!row.hidden) anyVisible = true;
|
|
182
|
+
});
|
|
183
|
+
var empty = document.querySelector("[data-cs-filter-empty]");
|
|
184
|
+
if (empty) empty.hidden = anyVisible || rows().length === 0;
|
|
185
|
+
syncBulk();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
document.addEventListener("change", function (event) {
|
|
189
|
+
if (event.target.matches && event.target.matches("[data-cs-select-all]")) {
|
|
190
|
+
visibleRows().forEach(function (r) { var cb = selectOf(r); if (cb) cb.checked = event.target.checked; });
|
|
191
|
+
syncBulk();
|
|
192
|
+
} else if (event.target.matches && event.target.matches("[data-cs-select]")) {
|
|
193
|
+
syncBulk();
|
|
194
|
+
}
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
document.addEventListener("click", function (event) {
|
|
198
|
+
if (event.target.closest && event.target.closest("[data-cs-bulk-clear]")) {
|
|
199
|
+
rows().forEach(function (r) { var cb = selectOf(r); if (cb) cb.checked = false; });
|
|
200
|
+
syncBulk();
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
var go = event.target.closest && event.target.closest("[data-cs-bulk-scoped]");
|
|
204
|
+
if (!go) return;
|
|
205
|
+
event.preventDefault();
|
|
206
|
+
var gids = selectedRows().map(function (r) { return selectOf(r).value; });
|
|
207
|
+
if (!gids.length) return;
|
|
208
|
+
var base = go.getAttribute("data-cs-bulk-url");
|
|
209
|
+
var query = gids.map(function (g) { return "subject_gids[]=" + encodeURIComponent(g); }).join("&");
|
|
210
|
+
window.location = base + (base.indexOf("?") === -1 ? "?" : "&") + query;
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
// Bulk org-wide role: inject the checked subjects into the POST form on submit.
|
|
214
|
+
document.addEventListener("submit", function (event) {
|
|
215
|
+
var form = event.target.closest && event.target.closest("[data-cs-bulk-org]");
|
|
216
|
+
if (!form) return;
|
|
217
|
+
var gids = selectedRows().map(function (r) { return selectOf(r).value; });
|
|
218
|
+
if (!gids.length) { event.preventDefault(); return; }
|
|
219
|
+
form.querySelectorAll("[data-cs-injected]").forEach(function (n) { n.remove(); });
|
|
220
|
+
gids.forEach(function (g) {
|
|
221
|
+
var input = document.createElement("input");
|
|
222
|
+
input.type = "hidden";
|
|
223
|
+
input.name = "subject_gids[]";
|
|
224
|
+
input.value = g;
|
|
225
|
+
input.setAttribute("data-cs-injected", "");
|
|
226
|
+
form.appendChild(input);
|
|
227
|
+
});
|
|
228
|
+
});
|
|
229
|
+
})();
|