super_auth 0.8.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -1
  3. data/Gemfile.lock +1 -1
  4. data/README.md +409 -28
  5. data/USAGE.md +66 -25
  6. data/db/migrate/12_add_resource_indexes.rb +116 -0
  7. data/db/migrate/13_add_resource_tree_guard.rb +28 -0
  8. data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
  9. data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
  10. data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
  11. data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
  12. data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
  13. data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
  14. data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
  15. data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
  16. data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
  17. data/lib/generators/super_auth/install/templates/README +4 -2
  18. data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
  19. data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
  20. data/lib/super_auth/active_record/authorization.rb +11 -7
  21. data/lib/super_auth/active_record/by_current_user.rb +136 -29
  22. data/lib/super_auth/active_record/group.rb +3 -0
  23. data/lib/super_auth/active_record/nested.rb +43 -0
  24. data/lib/super_auth/active_record/resource.rb +21 -1
  25. data/lib/super_auth/active_record/role.rb +3 -0
  26. data/lib/super_auth/active_record.rb +30 -2
  27. data/lib/super_auth/authorization.rb +62 -21
  28. data/lib/super_auth/edge.rb +26 -7
  29. data/lib/super_auth/editor/index.html +3 -4
  30. data/lib/super_auth/editor.rb +12 -3
  31. data/lib/super_auth/nestable.rb +89 -1
  32. data/lib/super_auth/railtie.rb +0 -9
  33. data/lib/super_auth/reach.rb +88 -0
  34. data/lib/super_auth/resource.rb +41 -27
  35. data/lib/super_auth/rls.rb +576 -44
  36. data/lib/super_auth/tree_guard.rb +115 -0
  37. data/lib/super_auth/version.rb +1 -1
  38. data/lib/super_auth.rb +55 -33
  39. metadata +8 -1
data/README.md CHANGED
@@ -85,14 +85,31 @@ the editor only creates edges of the eight kinds the path strategies read.
85
85
  ## Postgres Row-Level Security (optional)
86
86
 
87
87
  The `ByCurrentUser` scope enforces authorization at the ORM layer. On Postgres you can
88
- additionally enforce the same rules inside the database itself, so raw SQL, `unscoped`,
89
- 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 —
90
90
  unauthorized rows become invisible at the connection level. Enforcement is pure SQL:
91
91
  participating apps don't load this gem, or Ruby, at all. The gem's role is
92
92
  administrative — define the graph, compile authorizations, enable the policies — which
93
93
  is what makes super_auth usable as a central authorization service for apps in any
94
94
  language.
95
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
+
96
113
  ### The contract (any language)
97
114
 
98
115
  Identity is asserted per transaction by calling the `super_auth_become` function that
@@ -133,11 +150,149 @@ Misuse fails closed, and the scheme works unchanged behind transaction-pooling p
133
150
  like pgbouncer, because a transaction is exactly what they keep on one server
134
151
  connection.
135
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
+
136
290
  ### Setup (Rails)
137
291
 
138
292
  **1. Match column types to your primary keys — before your first migration.**
139
293
  The policies compare `super_auth_authorizations.resource_external_id` directly
140
- 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:
141
296
 
142
297
  ```ruby
143
298
  # config/initializers/super_auth.rb
@@ -149,19 +304,35 @@ end
149
304
  If super_auth is already migrated with the wrong type, alter the four external id
150
305
  columns (`super_auth_users.external_id`, `super_auth_resources.external_id`,
151
306
  `super_auth_authorizations.user_external_id`, `super_auth_authorizations.resource_external_id`)
152
- 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.
153
310
 
154
311
  **2. Enable RLS on the tables you want protected:**
155
312
 
156
313
  ```bash
157
- rails generate super_auth:rls Document Invoice
314
+ rails generate super_auth:rls Claim Invoice
158
315
  rails db:migrate
159
316
  ```
160
317
 
161
- This creates one migration calling `SuperAuth::RLS.enable(:documents, resource_type: "Document")`
162
- per model you can also call that directly for tables outside Rails. `resource_type`
163
- must match the `resource_external_type` used in your authorization rows (the model's
164
- 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.
165
336
 
166
337
  **3. Connect as a role RLS applies to.** Superusers and `BYPASSRLS` roles skip
167
338
  policies entirely, so the app must not connect as one (owning the tables is fine —
@@ -173,7 +344,7 @@ nothing else:
173
344
 
174
345
  ```sql
175
346
  CREATE ROLE app_runtime LOGIN PASSWORD '...';
176
- GRANT SELECT, INSERT, UPDATE, DELETE ON documents, invoices TO app_runtime;
347
+ GRANT SELECT, INSERT, UPDATE, DELETE ON claims, invoices TO app_runtime;
177
348
  ```
178
349
 
179
350
  The right to bypass the policies is separate. Grant it, from a migration or a
@@ -218,32 +389,180 @@ the block to keep it. Where there is no block to wrap, a transaction you already
218
389
  manage or a change of user mid-request, `SuperAuth::RLS.assert(user)` asserts the
219
390
  identity in the current transaction and nothing else, and `SuperAuth::RLS.installed?`
220
391
  reports whether `enable` has run, so no application needs to know the SQL functions'
221
- signatures. Non-Ruby apps use the SQL contract directly. Each policy checks `super_auth_authorizations` with the same
222
- semantics as `ByCurrentUser`: type-level authorizations (`resource_external_id IS NULL`)
223
- act as a wildcard (deprecated, see CHANGELOG 0.8.0), per-record authorizations match
224
- 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`
225
396
  works as the user, including SuperAuth's own user records. For a user whose `system?`
226
397
  is true, `SuperAuth.as` calls `super_auth_system()` instead, so the connection's role
227
398
  must have been given the bypass with `SuperAuth::RLS.grant_system`.
228
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
+
229
543
  ### Notes
230
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.
231
552
  - Queries with no identity asserted see nothing, and writes are rejected — fail
232
553
  closed, by design. A client that has never heard of super_auth cannot accidentally
233
554
  reach protected rows.
234
- - Creating rows requires a type-level authorization for that resource type (or system
235
- context): the policy is `FOR ALL` with no `WITH CHECK`, so Postgres reuses its
236
- `USING` expression as the implicit `WITH CHECK` for INSERTs and UPDATEs. Type-level
237
- nodes are deprecated (see CHANGELOG 0.8.0) but remain the only way to authorize
238
- INSERT here until the parent-record grant planned for the next release; a resource
239
- container does not replace one on a protected table, because a per-record row can
240
- only match an id that already exists.
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.
241
560
  - The transaction stamp calls `pg_current_xact_id()`, which assigns a real transaction
242
561
  id even to read-only transactions — one extra xid per protected transaction.
243
562
  Negligible for almost everyone; revisit with a virtual-xid variant only if
244
563
  transaction id churn ever matters at extreme read volume.
245
564
  - One `external_id_type` covers the whole install, so every protected table across
246
- every participating app needs the same pk type.
565
+ every participating app needs the same pk type, and every parent column that type.
247
566
  - Postgres 13+ only (`pg_current_xact_id`). On other databases `SuperAuth::RLS`
248
567
  raises, and the ORM scope remains the enforcement layer.
249
568
 
@@ -465,31 +784,93 @@ Grants are per class in both directions: a `"Resource"` grant does not unlock th
465
784
 
466
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.
467
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
+
468
845
  ## Row-Level Security for permission-gated models
469
846
 
470
- 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:
471
848
 
472
849
  ```ruby
473
- 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"] })
474
854
  ```
475
855
 
476
- `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:
477
857
 
478
858
  ```ruby
479
859
  SuperAuth.as(current_user) do
480
- 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
481
861
  end
482
862
  # outside the block there is no asserted identity, so the policy matches nothing
483
863
  ```
484
864
 
485
- 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`, deprecated — see CHANGELOG 0.8.0) 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.
486
866
 
487
- 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.
488
868
 
489
869
  Notes:
490
870
 
491
871
  - With no `SuperAuth.as` assertion in effect, the policy matches nothing (deny by default) and writes are rejected — fail closed.
492
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)).
493
874
 
494
875
  ## Development
495
876