super_auth 0.7.0 → 0.9.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 (42) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -0
  3. data/Gemfile.lock +1 -1
  4. data/README.md +440 -42
  5. data/USAGE.md +127 -21
  6. data/db/migrate/11_add_parent_id_to_resources.rb +32 -0
  7. data/db/migrate/12_add_resource_indexes.rb +116 -0
  8. data/db/migrate/13_add_resource_tree_guard.rb +28 -0
  9. data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
  10. data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
  11. data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
  12. data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
  13. data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
  14. data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
  15. data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
  16. data/db/migrate_activerecord/20250101000011_add_parent_id_to_super_auth_resources.rb +9 -0
  17. data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
  18. data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
  19. data/db/seeds/sample_data.rb +1 -0
  20. data/lib/generators/super_auth/install/templates/README +6 -2
  21. data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
  22. data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
  23. data/lib/super_auth/active_record/authorization.rb +14 -3
  24. data/lib/super_auth/active_record/by_current_user.rb +136 -29
  25. data/lib/super_auth/active_record/group.rb +3 -0
  26. data/lib/super_auth/active_record/nested.rb +43 -0
  27. data/lib/super_auth/active_record/resource.rb +28 -4
  28. data/lib/super_auth/active_record/role.rb +3 -0
  29. data/lib/super_auth/active_record.rb +30 -2
  30. data/lib/super_auth/authorization.rb +63 -10
  31. data/lib/super_auth/edge.rb +73 -18
  32. data/lib/super_auth/editor/index.html +16 -10
  33. data/lib/super_auth/editor/seed.rb +11 -5
  34. data/lib/super_auth/editor.rb +35 -11
  35. data/lib/super_auth/nestable.rb +105 -4
  36. data/lib/super_auth/reach.rb +88 -0
  37. data/lib/super_auth/resource.rb +71 -0
  38. data/lib/super_auth/rls.rb +576 -44
  39. data/lib/super_auth/tree_guard.rb +115 -0
  40. data/lib/super_auth/version.rb +1 -1
  41. data/lib/super_auth.rb +55 -2
  42. metadata +10 -1
data/README.md CHANGED
@@ -16,8 +16,8 @@ SuperAuth enforces authorization in the database, so any language can participat
16
16
 
17
17
  ## Supported databases
18
18
 
19
- PostgreSQL 13+, MySQL 8.0+, and SQLite 3.44+. The group and role trees are recursive
20
- CTEs and the path columns use `concat()`, which sets those floors. CI runs the full
19
+ PostgreSQL 13+, MySQL 8.0+, and SQLite 3.44+. The group, role and resource trees are
20
+ recursive CTEs and the path columns use `concat()`, which sets those floors. CI runs the full
21
21
  suite against each of the three. Row-level security is Postgres only.
22
22
 
23
23
  ## Docs
@@ -28,9 +28,13 @@ How `super_auth` stacks up against other authentication strategies:
28
28
  ## Graph editor
29
29
 
30
30
  A Rails-free editor for the authorization graph: five boxes (groups, roles, users,
31
- permissions, resources); click any record to trace what it can reach and what reaches
32
- it; connect two records to draw an edge; delete records and edges; recompile. It ships
33
- in the gem as a Rack app and a command.
31
+ permissions, resources), of which groups, roles and resources are drawn as trees; click
32
+ any record to trace what it can reach and what reaches it; connect two records to draw
33
+ an edge; create records, including a resource container under a chosen parent; delete
34
+ records and edges; recompile. The editor makes containers, not application records:
35
+ your code registers a record under a container by saving a resource node with
36
+ `parent_id`, and a grant on the container reaches everything under it. It ships in the
37
+ gem as a Rack app and a command.
34
38
 
35
39
  ```bash
36
40
  gem install super_auth rackup webrick # any Rack server works; puma too
@@ -81,14 +85,31 @@ the editor only creates edges of the eight kinds the path strategies read.
81
85
  ## Postgres Row-Level Security (optional)
82
86
 
83
87
  The `ByCurrentUser` scope enforces authorization at the ORM layer. On Postgres you can
84
- additionally enforce the same rules inside the database itself, so raw SQL, `unscoped`,
85
- background jobs, and any other client on the same database are subject to them too —
88
+ additionally enforce the same reach inside the database itself, so raw SQL, `unscoped`,
89
+ background jobs, and any other client on the same database are subject to it too —
86
90
  unauthorized rows become invisible at the connection level. Enforcement is pure SQL:
87
91
  participating apps don't load this gem, or Ruby, at all. The gem's role is
88
92
  administrative — define the graph, compile authorizations, enable the policies — which
89
93
  is what makes super_auth usable as a central authorization service for apps in any
90
94
  language.
91
95
 
96
+ What the database enforces is *tenancy*, not capability. Authorization protection will
97
+ always be a combination of the language client ORM plus the super_auth database. The
98
+ RLS is just a portable way to start the transition to cover new languages and provide
99
+ some base authorization support to future apps. The policy decides which rows an
100
+ identity may touch at all; which of those it may write is the application's decision,
101
+ in code, in every language that writes.
102
+
103
+ **Optional means optional.** Nothing outside this section needs it. The tables,
104
+ `ByCurrentUser`, `permission_gated`, `parent:` grants and the graph editor all work on
105
+ SQLite and MySQL, and on Postgres with no policy enabled — `rails generate
106
+ super_auth:rls` is a separate generator you run when you want the second layer, and
107
+ until you do there is nothing to configure and nothing to keep current. `SuperAuth.as`
108
+ is the same call either way: on a database with no policies it sets `current_user` and
109
+ runs the block, and once `enable` has run it also opens the transaction and asserts the
110
+ database identity. So the ORM layer is deployable first and RLS is a later migration,
111
+ not a rewrite. `SuperAuth.rls?` reports which mode you are in.
112
+
92
113
  ### The contract (any language)
93
114
 
94
115
  Identity is asserted per transaction by calling the `super_auth_become` function that
@@ -129,11 +150,149 @@ Misuse fails closed, and the scheme works unchanged behind transaction-pooling p
129
150
  like pgbouncer, because a transaction is exactly what they keep on one server
130
151
  connection.
131
152
 
153
+ #### What the policy admits
154
+
155
+ Every protected table carries one policy, `super_auth`, `FOR ALL`. Its `USING` is the
156
+ transaction stamp AND (system context OR one step per entry of the table's *reach*):
157
+ the columns through which a compiled authorization reaches a row, each with the types
158
+ whose rows admit through it. The reach is declared on the policy and again on the model,
159
+ both through `SuperAuth::Reach.normalize`, and `current?` is what confirms the two agree
160
+ (see [Permission-Gated Models](#permission-gated-models)):
161
+
162
+ ```ruby
163
+ SuperAuth::RLS.enable(:claims,
164
+ resource_type: "Claim",
165
+ parent: { column: :organization_id, resource_type: ["Organization::Member", "Organization::Admin"] })
166
+ # reach: { id: ["Claim"], organization_id: ["Organization::Member", "Organization::Admin"] }
167
+ ```
168
+
169
+ installs this, verbatim except that `<holder>` is spelled out below:
170
+
171
+ ```sql
172
+ CREATE POLICY super_auth ON "claims"
173
+ USING (
174
+ current_setting('super_auth.xid', true) = pg_current_xact_id()::text
175
+ AND (
176
+ COALESCE(current_setting('super_auth.system', true), '') = 'true'
177
+ OR EXISTS (SELECT 1 FROM super_auth_authorizations a
178
+ WHERE a.resource_external_type IN ('Claim') AND a.resource_external_id IS NULL AND <holder>)
179
+ OR "claims"."id" IN (SELECT a.resource_external_id FROM super_auth_authorizations a
180
+ WHERE a.resource_external_type IN ('Claim') AND a.resource_external_id IS NOT NULL AND <holder>)
181
+ OR "claims"."organization_id" IN (SELECT a.resource_external_id FROM super_auth_authorizations a
182
+ WHERE a.resource_external_type IN ('Organization::Member', 'Organization::Admin')
183
+ AND a.resource_external_id IS NOT NULL AND <holder>)
184
+ )
185
+ )
186
+ ```
187
+
188
+ Each subquery is written out as the `UNION ALL` of its two `<holder>` halves —
189
+ `a.user_id::text = NULLIF(current_setting('super_auth.user_id', true), '')` for a user
190
+ managed inside super_auth, and `a.user_external_id::text =
191
+ NULLIF(current_setting('super_auth.user_external_id', true), '') AND a.user_external_type =
192
+ NULLIF(current_setting('super_auth.user_external_type', true), '')` for an application
193
+ user — so each half can walk an index of its own. The column is cast to text and never the
194
+ setting to the column's type, so a malformed identity is no rows rather than an error that
195
+ aborts the transaction, and a cast on a column defeats a plain btree: migration 12 indexes
196
+ the two expressions the policy actually writes, `(user_id::text)` on every Postgres host
197
+ and `(user_external_id::text)` on the hosts where `external_id_type` is not a text type —
198
+ where it is, `idx_sa_auth_by_current_user` already answers the cast as a seek. Both halves
199
+ are emitted whatever kind of identity you assert, so every install reads through the
200
+ `user_id` half too, which is why that index is not optional. Three kinds of step, in
201
+ this order:
202
+
203
+ - **Type-level.** A compiled row for one of the table's own types with
204
+ `resource_external_id` NULL admits every row of the table, present and future. A
205
+ supported, permanent primitive — "this principal may act on every record of this
206
+ type" has no cheaper spelling — and it is always emitted unless `enable` is given
207
+ `wildcard: false`, an explicit opt-out for a table whose types are never granted
208
+ type-level; a `(type, NULL)` row then admits nothing there.
209
+ - **Per record** (`id`). The row's own id is among the ids the holder's rows for the
210
+ table's own types name.
211
+ - **Parent** (one per `parent:` column, in the order declared). The value in the
212
+ column is among the ids the holder's rows for the column's types name: the row's
213
+ tenancy, read off the row itself, so there is no node per row and nothing to
214
+ recompile when a row is created or moves.
215
+
216
+ Nothing in the expression is correlated with the outer row, so Postgres evaluates each
217
+ step once per query — an InitPlan and hashed SubPlans — instead of once per row: 2.6 ms
218
+ on 8,007 claims against a holder with thousands of rows, where a per-row form is
219
+ seconds. `INSERT` and `UPDATE` are gated by the same expression: the policy has no
220
+ `WITH CHECK`, so Postgres reuses `USING` for the new row, and a create is admitted by a
221
+ type-level grant on the table's type, by a parent grant for the value the new row
222
+ carries in a parent column, or by system context.
223
+
224
+ The reach and a policy version are recorded as JSON in the policy's comment —
225
+ `{"super_auth":2,"reach":{"id":["Claim"],"organization_id":["Organization::Member","Organization::Admin"]},"wildcard":true}`
226
+ — which any client can read back with `obj_description(oid, 'pg_policy')`; the Ruby
227
+ readers are under [Keeping the policy current](#keeping-the-policy-current).
228
+
229
+ #### What the policy does not decide
230
+
231
+ 1. **Tenancy, not capability.** The policy is the tenancy boundary: organization A
232
+ never reads organization B's rows, enforced on the row's own column. Which of the
233
+ admitted tenants may write — viewer against writer inside one organization — is the
234
+ application's, in code. The policy is `FOR ALL` and gates no verb: a holder of a read
235
+ tier passes it for `UPDATE` and `DELETE` at the database. `DELETE` in particular is
236
+ gated by `USING` alone, so a read-tier holder's unfiltered `DELETE` removes every row
237
+ of their tenancy and reports 0 rows for everything else, without an error. Known,
238
+ accepted, and by design: RLS is the portable base, not the whole of authorization.
239
+ 2. **A parent type must be a capability type nobody else is granted.** Key the column on
240
+ `Organization::Member`, never on bare `Organization`: a per-record `Organization` node
241
+ is what a grant of *any* kind on the organization reaches, and it would admit every
242
+ claim to whoever holds it for whatever reason. Names do not say which kind a type is.
243
+ `Claim::Admin` is platform-only — force a status, wipe review data, actions even the
244
+ claim's owner may not take — granted type-level to the admin tier and per record to
245
+ nobody, and it declares **no parent, ever**; `Organization::Admin` is a per-organization
246
+ capability node organization admins are supposed to hold, and a legitimate parent
247
+ type. Same suffix, opposite meanings: pairing `Claim::Admin` with `Organization::Admin`
248
+ "for symmetry" would let every organization admin force status on their own
249
+ organization's claims. The Ruby side by side is under
250
+ [Permission-Gated Models](#permission-gated-models).
251
+ 3. **Every column lists every tier's parent type**, because the policy must never be
252
+ narrower than any tier's ORM scope over the same table. `Claim` keyed on
253
+ `Organization::Member` for readers and `Claim::Writable` on `Organization::CaseWriter`
254
+ in the ORM means the policy lists both under `organization_id` — a holder of one
255
+ without the other exists — or the ORM shows a row the database hides, which fails
256
+ closed and reads as a permissions bug. The gem cannot check this: the policy sees no
257
+ Ruby classes. It is yours.
258
+ 4. **Parents do not chain.** A grant on the organization reaches a claim through
259
+ `claims.organization_id` and stops. A medium that belongs to a claim is reached
260
+ through a column of its own on `media`, declared on `media`, not through `claims`.
261
+ 5. **No row can be created through ActiveRecord that its creator cannot immediately
262
+ read.** ActiveRecord always emits `INSERT ... RETURNING` on Postgres, and the returned
263
+ row must pass `USING`. A `WITH CHECK` could only narrow `USING`, never widen it. This is
264
+ why a node minted in `after_create_commit` can never authorize its own record's
265
+ `INSERT`: the row has to pass before the callback runs. What admits a create is listed
266
+ above; it is never "the node this create will make".
267
+ 6. **A create with no parent value cannot be authorized by the column step.** `col = NULL`
268
+ is never true. A row whose parent column is NULL is admitted by a type-level grant, a
269
+ per-record row, or system context, and a new row has no per-record row yet. The escape
270
+ hatch is system context around exactly that branch — `SuperAuth.as(SuperAuth::User.system) { ... }`,
271
+ or `SuperAuth::RLS.assert(system_user)` inside the transaction you already hold — never
272
+ SQL interpolated into a policy.
273
+ 7. **A per-record holder may set the parent column to any value.** `USING` is reused as
274
+ the check and a self-referencing check is not expressible, so a holder admitted by the
275
+ row's own id may move it to an organization they do not hold; the client gates that
276
+ column on write. Note the failure direction: with per-record nodes a wrong grant fails
277
+ closed, with a tenancy column a wrong *value in the column* fails **open** — guard
278
+ writes to it.
279
+ 8. **The compiled table alone no longer answers "who can see X".** A row is admitted by a
280
+ compiled row naming a *different* record, so the answer is a join through the
281
+ protected table: `SuperAuth::RLS.explain(:claims, id)` for any client (it reads the
282
+ comment, needs no model), `Claim.super_auth_explain(id)` in Ruby.
283
+ 9. **A type-level row on a parent type admits nothing.** `(Organization::Member, NULL)` is
284
+ not "every organization's claims": a column holds an id, and NULL equals none. Grant
285
+ the table's own type type-level for that.
286
+ 10. **`pg_current_xact_id()` cannot run on a hot standby** (pre-existing, unchanged): the
287
+ stamp assigns a transaction id, which a read replica cannot do, so neither the
288
+ assertion nor a protected query runs there.
289
+
132
290
  ### Setup (Rails)
133
291
 
134
292
  **1. Match column types to your primary keys — before your first migration.**
135
293
  The policies compare `super_auth_authorizations.resource_external_id` directly
136
- against your tables' pks with no casting, so the columns must share a type:
294
+ against your tables' pks, and against every parent column, with no casting, so the
295
+ columns must share a type:
137
296
 
138
297
  ```ruby
139
298
  # config/initializers/super_auth.rb
@@ -145,19 +304,35 @@ end
145
304
  If super_auth is already migrated with the wrong type, alter the four external id
146
305
  columns (`super_auth_users.external_id`, `super_auth_resources.external_id`,
147
306
  `super_auth_authorizations.user_external_id`, `super_auth_authorizations.resource_external_id`)
148
- in a migration of your own.
307
+ in a migration of your own. `enable` checks, before any DDL, that every column the
308
+ policy will compare exists and shares the type family (both integer types, or both text
309
+ types), and names the table, the column, both types and this setting when one does not.
149
310
 
150
311
  **2. Enable RLS on the tables you want protected:**
151
312
 
152
313
  ```bash
153
- rails generate super_auth:rls Document Invoice
314
+ rails generate super_auth:rls Claim Invoice
154
315
  rails db:migrate
155
316
  ```
156
317
 
157
- This creates one migration calling `SuperAuth::RLS.enable(:documents, resource_type: "Document")`
158
- per model you can also call that directly for tables outside Rails. `resource_type`
159
- must match the `resource_external_type` used in your authorization rows (the model's
160
- class name when you use the AR integration).
318
+ This creates one migration calling `enable` per model, each followed by a commented
319
+ `parent:` line to fill in where the table carries a tenancy column:
320
+
321
+ ```ruby
322
+ SuperAuth::RLS.enable(:claims, resource_type: "Claim")
323
+ # Tenancy, not capability: a parent grant admits every row whose column holds a granted record's id, so list every type that may touch the row at all and let the ORM decide who writes.
324
+ # parent: { column: :organization_id, resource_type: ["Organization::Member"] }
325
+ ```
326
+
327
+ You can also call `enable` directly for tables outside Rails. `resource_type` must match
328
+ the `resource_external_type` used in your authorization rows (the model's class name when
329
+ you use the AR integration, one entry per class that scopes the table); `parent:` is a
330
+ `{ column:, resource_type: }` Hash or an Array of them, the same shape the model's
331
+ `super_auth parent:` takes. The DDL runs in one transaction under `lock_timeout:` (default
332
+ `"5s"`) — `DROP` and `CREATE POLICY` take `ACCESS EXCLUSIVE`, and separately they left a
333
+ window with no policy on a live table — and joins the migration's transaction when there
334
+ is one. `enable` is idempotent and re-runnable on a protected table, which is how a
335
+ policy is changed.
161
336
 
162
337
  **3. Connect as a role RLS applies to.** Superusers and `BYPASSRLS` roles skip
163
338
  policies entirely, so the app must not connect as one (owning the tables is fine —
@@ -169,7 +344,7 @@ nothing else:
169
344
 
170
345
  ```sql
171
346
  CREATE ROLE app_runtime LOGIN PASSWORD '...';
172
- GRANT SELECT, INSERT, UPDATE, DELETE ON documents, invoices TO app_runtime;
347
+ GRANT SELECT, INSERT, UPDATE, DELETE ON claims, invoices TO app_runtime;
173
348
  ```
174
349
 
175
350
  The right to bypass the policies is separate. Grant it, from a migration or a
@@ -214,27 +389,180 @@ the block to keep it. Where there is no block to wrap, a transaction you already
214
389
  manage or a change of user mid-request, `SuperAuth::RLS.assert(user)` asserts the
215
390
  identity in the current transaction and nothing else, and `SuperAuth::RLS.installed?`
216
391
  reports whether `enable` has run, so no application needs to know the SQL functions'
217
- signatures. Non-Ruby apps use the SQL contract directly. Each policy checks `super_auth_authorizations` with the same
218
- semantics as `ByCurrentUser`: type-level authorizations (`resource_external_id IS NULL`)
219
- act as a wildcard, per-record authorizations match on id. Any object with an `id`
392
+ signatures. Non-Ruby apps use the SQL contract directly. Each policy checks
393
+ `super_auth_authorizations` with the same semantics as `ByCurrentUser`: a type-level
394
+ grant (`resource_external_id IS NULL`) admits every row of the type, a per-record row
395
+ matches on id, a parent row matches on the declared column. Any object with an `id`
220
396
  works as the user, including SuperAuth's own user records. For a user whose `system?`
221
397
  is true, `SuperAuth.as` calls `super_auth_system()` instead, so the connection's role
222
398
  must have been given the bypass with `SuperAuth::RLS.grant_system`.
223
399
 
400
+ ### Keeping the policy current
401
+
402
+ A gem upgrade changes nothing already in the database. The policy `enable` wrote stays
403
+ exactly as written until `enable` runs again, so a release that changes the policy
404
+ template — 0.9.0 did — or a model that gains a `parent:` needs `enable` re-run for that
405
+ table, in a migration, with the arguments it should now carry. A parent grant is
406
+ invisible to a policy that predates it: the holder sees nothing, fail closed, and it
407
+ reads as a permissions bug.
408
+
409
+ `enable` records `SuperAuth::RLS::POLICY_VERSION` and the reach in the policy's comment,
410
+ never `ALTER`s a policy, and drops every name it has ever given one before creating
411
+ `super_auth` (Postgres ORs permissive policies, so one left behind under an old name would
412
+ keep admitting rows). Two readers:
413
+
414
+ ```ruby
415
+ SuperAuth::RLS.stale # => [:claims] tables whose policy predates this gem
416
+ SuperAuth::RLS.current?(:claims, resource_type: "Claim",
417
+ parent: { column: :organization_id, resource_type: ["Organization::Member", "Organization::Admin"] })
418
+ # => true only if row security is enabled and forced, the comment matches these
419
+ # arguments exactly, and the expression is not the 0.8.0 shape
420
+ SuperAuth::RLS.reach(:claims) # => { id: ["Claim"], organization_id: [...] }
421
+ ```
422
+
423
+ Put `current?` in a test helper or a health check: it is what catches a deploy that
424
+ changed `parent:` in the model and not in the database, and `RENAME COLUMN`, which
425
+ rewrites the stored expression while the comment keeps the old column name. `installed?`
426
+ is unchanged and means only that the identity functions exist.
427
+
428
+ **Run `stale` first after any upgrade.** `current?` answers `false` for a table with no
429
+ policy of the gem's, or one whose row security is off or whose reach genuinely
430
+ disagrees — but a policy an *earlier* version of `enable` built makes it **raise**, with
431
+ the same message `reach` and `coverage` give: "the super_auth policy on claims was not
432
+ built by this version of enable (policy version 2); re-run `SuperAuth::RLS.enable`".
433
+ There is nothing to compare these arguments against on such a table, and a bare `false`
434
+ would say "your `parent:`/`wildcard:` arguments are wrong" about a database whose only
435
+ fault is that nobody re-ran `enable` — which is exactly the state `db:migrate` alone
436
+ leaves you in, since no migration re-runs it. It is also the expensive state to be in
437
+ unawares: the 0.8.0 policy is still installed and still correlated per row. `stale` asks
438
+ the same question across every table and never raises, so a health check that wants a
439
+ bare list has one.
440
+
441
+ ### If you are still on 0.7.x or 0.8.0
442
+
443
+ The policy those releases installed is one `EXISTS` correlated on the outer row —
444
+ `(a.resource_external_id IS NULL OR a.resource_external_id = t.id) AND (internal OR
445
+ external)` — so its cost is paid once per row the statement touches, and what it scales
446
+ with is the number of **type-level** grants (`resource_external_id IS NULL`) on the types
447
+ you protect, not your record count. You cannot change the predicate without upgrading, so
448
+ measure yours before deciding anything:
449
+
450
+ ```sql
451
+ SELECT count(*) AS total,
452
+ count(*) FILTER (WHERE resource_external_id IS NULL) AS type_level
453
+ FROM super_auth_authorizations;
454
+
455
+ SELECT resource_external_type, count(*)
456
+ FROM super_auth_authorizations
457
+ WHERE resource_external_id IS NULL
458
+ GROUP BY 1 ORDER BY 2 DESC;
459
+ ```
460
+
461
+ The breakdown is the number to read, not the total: the type-level step is type-scoped, so
462
+ a table protected as `Claim` pays for the `Claim` rows and not for 50,000 rows of an admin
463
+ type no policy reads. Take the count for each type you passed to `enable`. It counts
464
+ principals holding a type-level grant on a protected type, which for most designs is an
465
+ admin population and therefore bounded by staff rather than by customers; it is where a
466
+ host hands type-level grants on a protected type to ordinary users that it grows without a
467
+ ceiling.
468
+
469
+ The curve, measured on synthetic data — a uuid install, an 8,000-row protected table, one
470
+ `SELECT count(*)`: at ~1,000 type-level rows, 7.9–9.5 s; at 50,000, 121–141 s; at 150,000,
471
+ 348–350 s. That last is 5.8 minutes for one `count(*)` over 8,000 rows. A second rig, at
472
+ 1,056,000 compiled rows, brackets where it turns: at 12 type-level rows a single-row read
473
+ is 0.59 ms and `count(*)` 563 ms, at 1,000 rows 1.03 ms and 622 ms, at 50,000 rows 56.0 ms
474
+ and over 30 s. Somewhere between 1,000 and 50,000 the planner abandons the `BitmapOr` over
475
+ `idx_sa_auth_by_resource` and falls back to scanning `super_auth_authorizations` once per
476
+ outer row. Below about 1,000 you are in the good plan and there is nothing to do. Every
477
+ figure in this section is from a synthetic rig, not from any production install, and the
478
+ variable they are in is type-level rows on a protected type — not your record count and
479
+ not your compiled-row count. Take your own two numbers from the queries above before
480
+ deciding you have a problem: most installs sit far below the knee, where none of this is
481
+ worth doing.
482
+
483
+ Re-time any statement you believe is fine with `SET LOCAL synchronize_seqscans = off`
484
+ inside the transaction. Without it the same statement measured 20,463 ms and 8.495 ms
485
+ minutes apart, because each sequence scan starts where the last one stopped; warm numbers
486
+ taken with it on are not reproducible, and they are the likeliest reason a host believes it
487
+ has no problem.
488
+
489
+ Your only lever short of upgrading is an index, and the honest answer is that it might do
490
+ nothing. Migration 12's two expression indexes can be built on a 0.8.0 database — they are
491
+ `CONCURRENTLY`, and they index columns the 0.8.0 predicate names too — and the two
492
+ measurements of that disagree, for a reason: at ~165,000 compiled rows with a holder of a
493
+ handful of grants, 368,216 ms became 67.9 ms; at 1,056,000 rows with a holder of 8,000
494
+ per-record grants, nothing changed at all, because the planner declines an identity bitmap
495
+ it estimates at 6,415 rows when it would be re-read once per outer row. So build them
496
+ `CONCURRENTLY`, `EXPLAIN` your worst statement with `synchronize_seqscans` off, and believe
497
+ the plan rather than either number: if `Seq Scan on super_auth_authorizations` is still in
498
+ it, drop them again — on a uuid host they cost about 25 MB and roughly halve compile insert
499
+ throughput (~173k rows/s to ~79k).
500
+
501
+ Upgrading is the fix, and it is a different order of magnitude from any index, because it
502
+ changes the shape rather than the access path. 0.9.0's steps are uncorrelated with the
503
+ outer row, so the type-level step plans once per query instead of once per row: 350,173 ms
504
+ to 86 ms on the same data with no index change at all, then 86 ms to about 1 ms once
505
+ migration 12's expression indexes land. Build the indexes before you re-run `enable`, not
506
+ after: 0.9.0's policy without them is a regression against 0.8.0 on single-row reads
507
+ (512.9 ms against 70.9 ms on the uuid rig), because uncorrelated subqueries pay their full
508
+ cost to read one row where the correlated `EXISTS` stopped at the first match.
509
+
510
+ ### Explaining and measuring reach
511
+
512
+ ```ruby
513
+ SuperAuth.as(user) { SuperAuth::RLS.explain(:claims, claim.id) }
514
+ # => [{ step: :organization_id, user_id: 1, resource_external_type: "Organization::Member",
515
+ # resource_external_id: 3, ... every column of the compiled row }]
516
+ Claim.super_auth_explain(claim) # the ORM twin, for SuperAuth.current_user
517
+ # => [{ step: :type_level, ... }, { step: :id, ... }, { step: :organization_id, ... }]
518
+ ```
519
+
520
+ `explain` returns the compiled rows that admit one record for the asserted identity,
521
+ each tagged with the step that admitted it, in reach order; `[]` with no identity, under
522
+ system context, for a missing record, or when nothing admits it. The ORM twin answers
523
+ `[{ step: :system }]` for the system user and reads the row `unscoped`, since the
524
+ question is usually asked about a row the user cannot see.
525
+
526
+ `SuperAuth::RLS.coverage(:claims)` is the diagnostic for moving a table's tenancy from
527
+ per-record rows to a parent column, or for checking a production dump before doing so.
528
+ It refuses nothing. Five buckets, each an Array of `{ count:, ids: [up to 20] }` entries
529
+ with only the non-zero ones present: `loss` (per holder of a type-level row on the table's
530
+ type: the rows only that grant reaches — what deleting it takes away), `null_parent` (per
531
+ parent column: rows with NULL in it, which no parent grant can reach), `orphaned_rows`
532
+ (compiled rows whose node is gone or no longer names them, by type and whether type-level),
533
+ `widening` (per holder of a parent-type row: rows the parent step admits that no per-record
534
+ row did), `deletable_nodes` (per type: the per-record nodes no user->resource edge points
535
+ at, on themselves or on any ancestor, and no child sits under — the only ones a cleanup may
536
+ delete, because access granted straight to a user has no other path). `ids` are the table's
537
+ in the first, second and fourth and `super_auth_resources` ids in the other two. Both
538
+ readers run in system context when the role may assert it and as the caller's own identity
539
+ otherwise. `coverage` needs `SELECT` on the table, `super_auth_resources` and
540
+ `super_auth_edges`, which `enable` grants to nobody; `explain` reads only the table and
541
+ `super_auth_authorizations`, so it needs neither.
542
+
224
543
  ### Notes
225
544
 
545
+ - A test database built from `db/schema.rb` has neither the policies nor the `parent_id`
546
+ cycle trigger: Rails' default `schema_format` is `:ruby`, and `schema.rb` cannot carry a
547
+ policy, a `FORCE ROW LEVEL SECURITY` flag, a function or a trigger. Call
548
+ `SuperAuth::RLS.enable(...)` for each protected table and `SuperAuth::TreeGuard.install`
549
+ from the test setup, and assert `SuperAuth::RLS.current?(...)` and
550
+ `SuperAuth::TreeGuard.installed?` there, so a forgotten re-enable fails the suite rather
551
+ than silently testing an unprotected database.
226
552
  - Queries with no identity asserted see nothing, and writes are rejected — fail
227
553
  closed, by design. A client that has never heard of super_auth cannot accidentally
228
554
  reach protected rows.
229
- - Creating rows requires a type-level authorization for that resource type (or system
230
- context): the policy is `FOR ALL` with no `WITH CHECK`, so Postgres reuses its
231
- `USING` expression as the implicit `WITH CHECK` for INSERTs and UPDATEs.
555
+ - Creating rows needs a type-level grant on the table's type, a parent grant for the
556
+ value the new row carries in a parent column, or system context: the policy is
557
+ `FOR ALL` with no `WITH CHECK`, so Postgres reuses its `USING` expression for
558
+ INSERTs and UPDATEs, and a per-record row can only match an id that already exists. A
559
+ resource container does not replace either on a protected table, for the same reason.
232
560
  - The transaction stamp calls `pg_current_xact_id()`, which assigns a real transaction
233
561
  id even to read-only transactions — one extra xid per protected transaction.
234
562
  Negligible for almost everyone; revisit with a virtual-xid variant only if
235
563
  transaction id churn ever matters at extreme read volume.
236
564
  - One `external_id_type` covers the whole install, so every protected table across
237
- every participating app needs the same pk type.
565
+ every participating app needs the same pk type, and every parent column that type.
238
566
  - Postgres 13+ only (`pg_current_xact_id`). On other databases `SuperAuth::RLS`
239
567
  raises, and the ORM scope remains the enforcement layer.
240
568
 
@@ -273,16 +601,16 @@ The basis for how this works is that the rules engine is trying to match a user
273
601
  The engine determines if it can find an authorization route betewen a user and a resource. It does so by looking at users, groups, roles, permissions.
274
602
 
275
603
  +---+ +---+
276
- | | | | (Group nests within Group,
277
- | v | v Role nests within Role)
604
+ | | | | (Group, Role and Resource
605
+ | v | v each nest within themselves)
278
606
  +-------+ +------+
279
607
  | Group |<----->| Role |
280
608
  +-------+\ / +------+
281
609
  ^ \ / ^
282
610
  | \/ |
283
- | /\ |
284
- | / \ |
285
- V / \ V
611
+ | /\ | +---+
612
+ | / \ | | |
613
+ V / \ V | v
286
614
  +---------------+ +------+/ \+------------+ +----------+ +-------------------+
287
615
  | YourApp::User |<-->| User |<------>| Permission |<-->| Resource | <--> | YourApp::Resource |
288
616
  +---------------+ +------+ +------------+ +----------+ +-------------------+
@@ -292,9 +620,10 @@ The engine determines if it can find an authorization route betewen a user and a
292
620
 
293
621
 
294
622
  The lines between the boxes are called [edges](https://en.wikipedia.org/wiki/Glossary_of_graph_theory#edge).
295
- The self-loops on `Group` and `Role` mean each nests within itself: a `Group` can contain
296
- child `Group`s and a `Role` can contain child `Role`s, recursively. Grants on a parent
297
- flow to every descendant which is why `Group` and `Role` are described as *trees*.
623
+ The self-loops on `Group`, `Role` and `Resource` mean each nests within itself: a `Group`
624
+ can contain child `Group`s, a `Role` child `Role`s, and a `Resource` child `Resource`s (a
625
+ container with your records registered under it), recursively. Grants on a parent flow to
626
+ every descendant — which is why `Group`, `Role` and `Resource` are described as *trees*.
298
627
 
299
628
  In general the super_auth has 5 different pathing strategies to search for access.
300
629
 
@@ -305,7 +634,7 @@ In general the super_auth has 5 different pathing strategies to search for acces
305
634
  5. users <-> resource
306
635
 
307
636
  Edges can be drawn between any 2 objects, allowing super_auth can seamlessly scale in complexity with you.
308
- When `Group` and `Role` are used, the rules will apply to all descedants. If there are any edges
637
+ When `Group`, `Role` and `Resource` nodes are nested, the rules apply to all descendants. If there are any edges
309
638
  between the specified user and the resource, then access is granted.
310
639
 
311
640
 
@@ -430,13 +759,18 @@ class Resource < ApplicationRecord
430
759
  end
431
760
  ```
432
761
 
433
- Approve access to the subclass the same way as any other resource — register it by its class name and draw edges to it:
762
+ Approve access to the subclass the same way as any other resource — register it by its class name and draw edges to it. Here the nodes sit in a container, so one edge covers every server registered under it:
434
763
 
435
764
  ```ruby
436
- restartable = SuperAuth::Resource.create(
437
- name: "restartable servers",
438
- external_type: "Resource::ResourceRestartPermission"
439
- )
765
+ restartable = SuperAuth::Resource.create(name: "restartable servers") # a container
766
+ servers.each do |server|
767
+ SuperAuth::Resource.create(
768
+ name: server.name,
769
+ external_type: "Resource::ResourceRestartPermission",
770
+ external_id: server.id,
771
+ parent: restartable
772
+ )
773
+ end
440
774
  restart = SuperAuth::Permission.create(name: "restart")
441
775
  SuperAuth::Edge.create(user: sa_user, permission: restart)
442
776
  SuperAuth::Edge.create(permission: restart, resource: restartable)
@@ -448,31 +782,95 @@ Resource::ResourceRestartPermission.find(id) # needs its own explicit approval
448
782
 
449
783
  Grants are per class in both directions: a `"Resource"` grant does not unlock the subclass, and a `"Resource::ResourceRestartPermission"` grant does not unlock the base class.
450
784
 
785
+ The resource tree is containment, not inheritance. A row compiled through a container copies the descendant node's own `external_type`, so nesting does not weaken the rule above; what weakens it is the node's position. A `"Resource::ResourceRestartPermission"` node whose parent is the `"Resource"` node is a descendant of it and receives every grant drawn on `"Resource"`. Register capability nodes as siblings of their base-class nodes, or in a container beside them as above, never as their children.
786
+
787
+ ### Tenancy from a column: `parent:`
788
+
789
+ A record that belongs to something — a claim to an organization, a document to a folder — can be reached through the column that says so, instead of through a node per record. `super_auth parent:` declares the column and the types whose rows admit through it, and the scope becomes one `IN`-subquery per step, OR'd: the row's own id against the class's own name, then each parent column against its types. The tiers are capability subclasses of the parent, exactly as above:
790
+
791
+ ```ruby
792
+ class Organization < ApplicationRecord
793
+ super_auth
794
+ # Per-organization capability nodes, registered as siblings of the
795
+ # Organization node, never under it (containment is not inheritance):
796
+ class Member < Organization; end # every member holds one — the tenancy tier
797
+ class CaseWriter < Member; end # members who may write
798
+ class Admin < CaseWriter; end # organization admins; a legitimate parent type
799
+ end
800
+
801
+ class Claim < ApplicationRecord
802
+ # Readers: anyone the organization admits at all.
803
+ super_auth parent: { column: :organization_id,
804
+ resource_type: ["Organization::Member", "Organization::CaseWriter", "Organization::Admin"] }
805
+
806
+ class Writable < Claim
807
+ # Writers: a narrower tier, on the same column. Re-declaring on a subclass
808
+ # replaces its parents alone; the per-record step stays keyed on "Claim::Writable".
809
+ super_auth parent: { column: :organization_id,
810
+ resource_type: ["Organization::CaseWriter", "Organization::Admin"] }
811
+ end
812
+
813
+ class Admin < Writable
814
+ # Platform-only: force a status, wipe review data — actions even the
815
+ # claim's owner may not take. Granted type-level to the admin tier and
816
+ # per record to nobody, and it declares NO parent, ever. Pairing it with
817
+ # Organization::Admin "for symmetry" would hand every organization admin
818
+ # these actions on their own organization's claims.
819
+ super_auth
820
+ end
821
+ end
822
+ ```
823
+
824
+ `Claim::Admin` and `Organization::Admin` share a suffix and mean opposite things — one is the platform's, the other a per-organization node organization admins are supposed to hold — and a reader who has just learned that `Organization::Admin` is a parent type is one keystroke from the escalation. The rule the example follows: a parent type must be a capability type nobody else is granted (`Organization::Member`, never bare `Organization`, whose per-record node any grant on the organization reaches), and a subclass that exists to be *narrower* than the record's owner declares no parent.
825
+
826
+ For one parent and internal user 1 the scope is, on Postgres and SQLite (MySQL backticks):
827
+
828
+ ```sql
829
+ SELECT "claims".* FROM "claims"
830
+ WHERE ("claims"."id" IN (SELECT "super_auth_authorizations"."resource_external_id" FROM "super_auth_authorizations"
831
+ WHERE "super_auth_authorizations"."user_id" = 1
832
+ AND "super_auth_authorizations"."resource_external_type" = 'Claim'
833
+ AND "super_auth_authorizations"."resource_external_id" IS NOT NULL)
834
+ OR "claims"."organization_id" IN (SELECT "super_auth_authorizations"."resource_external_id" FROM "super_auth_authorizations"
835
+ WHERE "super_auth_authorizations"."user_id" = 1
836
+ AND "super_auth_authorizations"."resource_external_type" = 'Organization::Member'
837
+ AND "super_auth_authorizations"."resource_external_id" IS NOT NULL))
838
+ AND "claims"."id" = 7
839
+ ```
840
+
841
+ preceded by one probe for a type-level `Claim` row, which admits everything when found (`wildcard: false` on the macro drops the probe and the step). With a type list the parent step reads `IN ('Organization::Member', 'Organization::CaseWriter', 'Organization::Admin')`; an application user matches on `user_external_id` and `user_external_type`. Two statements per query, however many rows.
842
+
843
+ Rules the scope keeps: the steps are OR'd and never collapsed into the parent step, so a per-record grant admits a row whose parent column is NULL, and a user with one read edge on one claim and no organization keeps it; a subclass inherits the parents and is keyed on its own name; re-declaring on a subclass replaces its parents only, on the one inherited default scope — never a second one, which would AND with the first and deny every row the parent step admits; a type-level row on a *parent* type admits nothing (NULL equals no id); a row the user is not admitted to is absent, so `update!` and `destroy` on it affect 0 rows without an error and `reload` raises `RecordNotFound`; parents do not chain. The parent column must exist with the type of `SuperAuth.external_id_type`, checked on the first scoped query rather than at declaration, so a process boots before its migrations run, and named when wrong: "Claim declares parent column organization_id, which table claims does not have", or "Claim.organization_id is character varying(255) but super_auth_authorizations.resource_external_id is bigint; a parent column must have the type of SuperAuth.external_id_type, the type of the ids it holds". `Claim.super_auth_explain(claim)` lists the compiled rows admitting one record for the current user, each tagged `:type_level`, `:id` or the column, and `[{ step: :system }]` for the system user.
844
+
451
845
  ## Row-Level Security for permission-gated models
452
846
 
453
- For defense in depth on Postgres (13+), enable a policy on the table. It is keyed by a single resource type the base class's nameand enforces *row visibility* using the same [contract described above](#postgres-row-level-security-optional):
847
+ For defense in depth on Postgres (13+), enable a policy on the table with the same declaration. The policy sees only the table, not which Ruby class issued the query, so it lists every class that scopes the table under `resource_type:` and every tier's parent type under the column it must never be narrower than any tier's ORM scope, or the ORM shows a row the database hides:
454
848
 
455
849
  ```ruby
456
- SuperAuth::RLS.enable(:resources, resource_type: "Resource")
850
+ SuperAuth::RLS.enable(:claims,
851
+ resource_type: ["Claim", "Claim::Writable", "Claim::Admin"],
852
+ parent: { column: :organization_id,
853
+ resource_type: ["Organization::Member", "Organization::CaseWriter", "Organization::Admin"] })
457
854
  ```
458
855
 
459
- `enable` turns on `ROW LEVEL SECURITY` (with `FORCE`, so the table owner is covered too) and installs a policy that derives visibility from `super_auth_authorizations`. Identity is asserted **per transaction, not per connection**: wrap the work in `SuperAuth.as`, which opens a transaction and calls `super_auth_become` for you (see the contract above). Every query inside is filtered, and the identity dies with the transaction:
856
+ `enable` turns on `ROW LEVEL SECURITY` (with `FORCE`, so the table owner is covered too) and installs the policy under the [contract described above](#the-contract-any-language), which derives visibility from `super_auth_authorizations`. Identity is asserted **per transaction, not per connection**: wrap the work in `SuperAuth.as`, which opens a transaction and calls `super_auth_become` for you. Every query inside is filtered, and the identity dies with the transaction:
460
857
 
461
858
  ```ruby
462
859
  SuperAuth.as(current_user) do
463
- SuperAuth.db[:resources].all # only rows current_user holds a grant on
860
+ SuperAuth.db[:claims].all # only rows current_user reaches: per record, type-level, or through organization_id
464
861
  end
465
862
  # outside the block there is no asserted identity, so the policy matches nothing
466
863
  ```
467
864
 
468
- Works with `SuperAuth::User` records (matched by `user_id`) or your own user objects (matched by `user_external_id` / `user_external_type`); type-level wildcard grants (`resource_external_id IS NULL`) and the system user behave exactly as they do in the ActiveRecord scope. `SuperAuth::RLS.disable(:resources)` removes the policy.
865
+ Works with `SuperAuth::User` records (matched by `user_id`) or your own user objects (matched by `user_external_id` / `user_external_type`); type-level grants (`resource_external_id IS NULL`) and the system user behave exactly as they do in the ActiveRecord scope. `SuperAuth::RLS.disable(:claims)` removes the policy.
469
866
 
470
- Because a policy sees only the table, not which Ruby class issued the query, row-level security enforces access to the **base** resource type: a `"Resource"` grant makes the row visible in the database, but the policy cannot distinguish the `"Resource::ResourceRestartPermission"` subclass. Per-class (capability) enforcement therefore stays with the ORM scope the database is the row-visibility backstop, the client gates the capability.
867
+ Because the policy cannot distinguish `Claim::Writable` from `Claim`, capability enforcement — who among the admitted may write stays with the ORM scope, in every language that writes: the database is the tenancy boundary, the client gates the capability. A `Claim::Writable` per-record row admits its row at the database for every verb, and so does an `Organization::Member` row for every claim of the organization; the create guard and the `Writable` scope are what stop a viewer from writing, as they were before there was a policy.
471
868
 
472
869
  Notes:
473
870
 
474
871
  - With no `SuperAuth.as` assertion in effect, the policy matches nothing (deny by default) and writes are rejected — fail closed.
475
872
  - Postgres superusers and `BYPASSRLS` roles bypass row-level security entirely — run your application as a regular role (see the setup guide above).
873
+ - After a gem upgrade or a change to `parent:`, re-run `enable`: a policy already in the database does not change on its own. `SuperAuth::RLS.stale` lists the tables behind and `current?` checks one (see [Keeping the policy current](#keeping-the-policy-current)).
476
874
 
477
875
  ## Development
478
876