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.
Files changed (58) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +377 -0
  4. data/Rakefile +6 -0
  5. data/app/assets/javascripts/current_scope/application.js +229 -0
  6. data/app/assets/stylesheets/current_scope/application.css +917 -0
  7. data/app/controllers/current_scope/application_controller.rb +96 -0
  8. data/app/controllers/current_scope/events_controller.rb +14 -0
  9. data/app/controllers/current_scope/role_assignments_controller.rb +190 -0
  10. data/app/controllers/current_scope/roles_controller.rb +256 -0
  11. data/app/controllers/current_scope/scoped_role_assignments_controller.rb +165 -0
  12. data/app/controllers/current_scope/subjects_controller.rb +75 -0
  13. data/app/helpers/current_scope/application_helper.rb +261 -0
  14. data/app/models/concerns/current_scope/storable_keys.rb +101 -0
  15. data/app/models/current_scope/application_record.rb +5 -0
  16. data/app/models/current_scope/current.rb +74 -0
  17. data/app/models/current_scope/event.rb +136 -0
  18. data/app/models/current_scope/role.rb +106 -0
  19. data/app/models/current_scope/role_assignment.rb +42 -0
  20. data/app/models/current_scope/role_permission.rb +9 -0
  21. data/app/models/current_scope/scoped_role_assignment.rb +98 -0
  22. data/app/views/current_scope/events/index.html.erb +41 -0
  23. data/app/views/current_scope/roles/edit.html.erb +229 -0
  24. data/app/views/current_scope/roles/index.html.erb +55 -0
  25. data/app/views/current_scope/roles/members.html.erb +114 -0
  26. data/app/views/current_scope/roles/new.html.erb +19 -0
  27. data/app/views/current_scope/scoped_role_assignments/new.html.erb +144 -0
  28. data/app/views/current_scope/shared/access_denied.html.erb +30 -0
  29. data/app/views/current_scope/subjects/index.html.erb +124 -0
  30. data/app/views/layouts/current_scope/application.html.erb +56 -0
  31. data/config/routes.rb +13 -0
  32. data/db/migrate/20260710000001_create_current_scope_tables.rb +31 -0
  33. data/db/migrate/20260710000002_create_current_scope_events.rb +32 -0
  34. data/db/migrate/20260714000001_add_description_to_current_scope_roles.rb +5 -0
  35. data/db/migrate/20260805000001_widen_current_scope_polymorphic_ids.rb +165 -0
  36. data/lib/current_scope/configuration.rb +613 -0
  37. data/lib/current_scope/context.rb +41 -0
  38. data/lib/current_scope/engine.rb +202 -0
  39. data/lib/current_scope/gating_reflection.rb +113 -0
  40. data/lib/current_scope/gating_tripwire.rb +84 -0
  41. data/lib/current_scope/grant_diagnosis.rb +216 -0
  42. data/lib/current_scope/guard.rb +750 -0
  43. data/lib/current_scope/mutation_guard.rb +90 -0
  44. data/lib/current_scope/parent_chain.rb +397 -0
  45. data/lib/current_scope/permission_catalog.rb +146 -0
  46. data/lib/current_scope/permission_grid.rb +132 -0
  47. data/lib/current_scope/permissions.rb +94 -0
  48. data/lib/current_scope/resolver.rb +669 -0
  49. data/lib/current_scope/schema_guard.rb +223 -0
  50. data/lib/current_scope/scopeable.rb +38 -0
  51. data/lib/current_scope/sod_preflight.rb +380 -0
  52. data/lib/current_scope/test_helpers.rb +53 -0
  53. data/lib/current_scope/version.rb +3 -0
  54. data/lib/current_scope.rb +432 -0
  55. data/lib/generators/current_scope/install/install_generator.rb +114 -0
  56. data/lib/generators/current_scope/install/templates/initializer.rb +175 -0
  57. data/lib/tasks/current_scope_tasks.rake +454 -0
  58. 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
+ [![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
+ [![Website](https://img.shields.io/badge/website-davidteren.github.io%2Fcurrent__scope-4d7cfe)](https://davidteren.github.io/current_scope/)
7
+ [![Status: not production-ready](https://img.shields.io/badge/status-not%20production--ready-e8590c)](https://github.com/davidteren/current_scope/issues)
8
+
9
+ > ## ⚠️ Not production-ready
10
+ >
11
+ > There are some known issues which are currently being worked on. **This is not
12
+ > production-ready**, but it is ready for experimentation and spiking, or
13
+ > whatever people want to do with it — just not yet for production.
14
+ >
15
+ > This is an **authorization** library, so the bar is different: a bug here is a
16
+ > user seeing or doing something they shouldn't. The open work is tracked in the
17
+ > [issue tracker](https://github.com/davidteren/current_scope/issues), and
18
+ > several items are security-relevant — permission keys that can be dropped
19
+ > silently, advisory checks that don't consult the catalog, and gaps in the
20
+ > separation-of-duties veto. Each is being worked through with a written plan and
21
+ > an adversarial review pass.
22
+ >
23
+ > Kick the tyres, build a spike, tell us what breaks. Don't put it in front of
24
+ > real users yet.
25
+
26
+ **Website:** [davidteren.github.io/current_scope](https://davidteren.github.io/current_scope/) —
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
+ ![Permission grid](docs/screenshots/permission-grid.png)
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
+ ![Subjects](docs/screenshots/subjects.png)
93
+
94
+ | Roles | Members | Events |
95
+ |---|---|---|
96
+ | ![Roles](docs/screenshots/roles.png) | ![Members](docs/screenshots/members.png) | ![Events](docs/screenshots/events.png) |
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,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"
@@ -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
+ })();