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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -1
- data/Gemfile.lock +1 -1
- data/README.md +409 -28
- data/USAGE.md +66 -25
- data/db/migrate/12_add_resource_indexes.rb +116 -0
- data/db/migrate/13_add_resource_tree_guard.rb +28 -0
- data/db/migrate_activerecord/20250101000001_create_super_auth_users.rb +7 -1
- data/db/migrate_activerecord/20250101000002_create_super_auth_groups.rb +7 -1
- data/db/migrate_activerecord/20250101000003_create_super_auth_permissions.rb +7 -1
- data/db/migrate_activerecord/20250101000004_create_super_auth_roles.rb +7 -1
- data/db/migrate_activerecord/20250101000005_create_super_auth_resources.rb +7 -1
- data/db/migrate_activerecord/20250101000006_create_super_auth_edges.rb +7 -1
- data/db/migrate_activerecord/20250101000007_create_super_auth_authorizations.rb +7 -1
- data/db/migrate_activerecord/20250101000012_add_super_auth_resource_indexes.rb +89 -0
- data/db/migrate_activerecord/20250101000013_add_resource_tree_guard_to_super_auth_resources.rb +15 -0
- data/lib/generators/super_auth/install/templates/README +4 -2
- data/lib/generators/super_auth/install/templates/super_auth.rb +6 -3
- data/lib/generators/super_auth/rls/templates/migration.rb.erb +2 -0
- data/lib/super_auth/active_record/authorization.rb +11 -7
- data/lib/super_auth/active_record/by_current_user.rb +136 -29
- data/lib/super_auth/active_record/group.rb +3 -0
- data/lib/super_auth/active_record/nested.rb +43 -0
- data/lib/super_auth/active_record/resource.rb +21 -1
- data/lib/super_auth/active_record/role.rb +3 -0
- data/lib/super_auth/active_record.rb +30 -2
- data/lib/super_auth/authorization.rb +62 -21
- data/lib/super_auth/edge.rb +26 -7
- data/lib/super_auth/editor/index.html +3 -4
- data/lib/super_auth/editor.rb +12 -3
- data/lib/super_auth/nestable.rb +89 -1
- data/lib/super_auth/railtie.rb +0 -9
- data/lib/super_auth/reach.rb +88 -0
- data/lib/super_auth/resource.rb +41 -27
- data/lib/super_auth/rls.rb +576 -44
- data/lib/super_auth/tree_guard.rb +115 -0
- data/lib/super_auth/version.rb +1 -1
- data/lib/super_auth.rb +55 -33
- 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
|
|
89
|
-
background jobs, and any other client on the same database are subject to
|
|
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
|
|
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
|
|
314
|
+
rails generate super_auth:rls Claim Invoice
|
|
158
315
|
rails db:migrate
|
|
159
316
|
```
|
|
160
317
|
|
|
161
|
-
This creates one migration calling `
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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
|
|
222
|
-
semantics as `ByCurrentUser`: type-level
|
|
223
|
-
|
|
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
|
|
235
|
-
|
|
236
|
-
`
|
|
237
|
-
|
|
238
|
-
|
|
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.
|
|
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(:
|
|
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
|
|
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[:
|
|
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
|
|
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
|
|
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
|
|