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
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6d1016c7d88c21731beb99dbb486b560b2f519c6b43778002a170d71c4656ed2
|
|
4
|
+
data.tar.gz: 18de8cfad615fb401a015fcdd3492dad085f4b51e0717d0590f2569d508a4a2c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5dff8e7b037749e7ca6c8f3d1e1135cd107d183148a03eddad357831707341a6b96117cdef4c58b20b0381ee3b3c6fe7ebe0637e13450482022d3127afd33054
|
|
7
|
+
data.tar.gz: edc20a1aa232ca1c7f374f1c2024cdc1e03f529a1b7673680270fcbb72d52f8f9edaf6812bd9acb77cdc483de26ad03362d30d9542e7536d144282fa4ee079f1
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,71 @@
|
|
|
1
1
|
## [Unreleased]
|
|
2
2
|
|
|
3
|
+
## [0.9.0] - 2026-09-09
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
|
|
7
|
+
- `SuperAuth.as` no longer requires row-level security, so the ORM layer is deployable on its own and RLS is a later migration rather than a different call. On a database carrying no identity functions — every SQLite and MySQL host, and any Postgres host that has not run `rails generate super_auth:rls` — it sets `SuperAuth.current_user` for the block, runs it, and restores the previous user on the way out, raising, and does not open a transaction; before, it went straight to `SuperAuth::RLS.as`, which raises `SuperAuth::Error, "requires Postgres"` off Postgres and fails in `super_auth_become` on a Postgres host with no functions, which left the documented entry point unusable for exactly the hosts that had not turned the second layer on. Once `enable` has run, the same call opens the transaction and asserts the database identity as before, so turning RLS on costs no application change. `SuperAuth.rls?` is the predicate, and reports which of the two modes a database is in; it memoises `SuperAuth::RLS.installed?` for `SuperAuth.db`, since `as` asks on every block and only a migration changes the answer — `RLS.enable`, which creates the functions, clears it, as does `SuperAuth.db=`, and `SuperAuth.rls!` clears it by hand for a process that installs them some other way. A database passed explicitly is asked each time and never memoised.
|
|
8
|
+
|
|
9
|
+
- Parent-record grants. `SuperAuth::RLS.enable(:claims, resource_type: "Claim", parent: { column: :organization_id, resource_type: ["Organization::Member", "Organization::Admin"] })` admits a row of `claims` when the asserted identity holds a compiled row for the record whose id the row carries in `organization_id` — its tenancy, read off the row itself — with no resource node per claim and nothing to recompile when a claim is created or moves. Both keywords are normalised by `SuperAuth::Reach.normalize(resource_type:, parent:)` into a reach map, an ordered Hash from column to the types whose rows admit through it, `:id` always first: `{ id: ["Claim"], organization_id: ["Organization::Member", "Organization::Admin"] }`. `resource_type:` is a String or an Array of them; `parent:` is nil, one `{ column:, resource_type: }` Hash, or an Array of them; columns become Symbols, types Strings, duplicates are dropped and order is kept. Malformed input raises `SuperAuth::Error` naming the fault: an empty or non-String type list, a Hash with any other key, a missing or blank column, a parent named `:id` ("the per-record step, which resource_type: already declares; a parent is a column holding another record's id"), a column declared twice ("list every type it admits under one entry"). The ORM macro goes through the same call, so the same arguments produce the same map in both layers; whether the arguments agree is what `current?` checks. The lists are the point rather than a convenience: the policy must never be narrower than any tier's ORM scope over the same table, and a host whose readers key on `Organization::Member` and whose writers on `Organization::CaseWriter` has holders of one without the other, so every type a column admits is named under it, once. The steps are OR'd and never collapsed into the parent step: a per-record grant still admits a row whose parent column is NULL, and a user holding one read edge on one claim and no organization keeps it. Parents do not chain — a grant on the organization reaches the claim through `claims.organization_id` and stops; a medium that belongs to a claim is reached through a column of its own. The signature is the one the 0.8.0 CHANGELOG published. What a host gets to delete is the interesting part: under the column predicate there is no per-claim graph to keep in step with the tenancy column, so an "organization_id changed" hook that re-points per-record edges, and its whole failure mode, goes away; the column update is the transfer.
|
|
10
|
+
|
|
11
|
+
- The policy text is a contract, since clients in other languages read the same table under it, and it is written out in the README under "The contract (any language)". Per column step, one uncorrelated `IN`: `"claims"."organization_id" IN (SELECT a.resource_external_id FROM super_auth_authorizations a WHERE a.resource_external_type IN ('Organization::Member', 'Organization::Admin') AND a.resource_external_id IS NOT NULL AND <holder>)`, and the same for `"claims"."id"` against the table's own types; the type-level step, one bare `EXISTS` on `resource_external_id IS NULL`; each subquery written as the `UNION ALL` of its internal-user half (`a.user_id::text = NULLIF(current_setting('super_auth.user_id', true), '')`) and its external-user half (`user_external_id` and `user_external_type` against their settings), so each half can walk an index of its own — `holdings` emits both halves for every step whatever kind of identity is asserted, so every install reads through the `user_id` half too, which is why an index on it is not optional on Postgres; all of it under the transaction stamp and beside the system clause, `FOR ALL`, no `WITH CHECK`. Nothing in the expression is correlated with the outer row, so Postgres plans each step once per query — an InitPlan and hashed SubPlans — instead of once per row. Measured on 8,007 claims against a heavy holder: 2.6 ms, where a tuple-OR form took 54 s and the 0.8.0 policy 4.7–19 s, its one `EXISTS` on `resource_external_id IS NULL OR resource_external_id = t.id` re-run for every row of the table. The type-level step is always emitted unless `enable` is given `wildcard: false`, an explicit opt-out for a table whose types are never granted type-level; a `(type, NULL)` row then admits nothing there. A spec asserts the plan under a 2,000-row holder shows exactly one InitPlan and two hashed SubPlans and no correlated one, and a second, under `SET LOCAL enable_seqscan = off`, that every reference to the identity settings in that plan sits on an `Index Cond` line and none on a `Filter` line — a seek, not a scan — which fails with migration 12's expression indexes dropped.
|
|
12
|
+
|
|
13
|
+
- `enable` runs its DDL in one transaction under `SET LOCAL lock_timeout` (`lock_timeout: "5s"` by default; `disable` takes the same keyword). `DROP POLICY` and `CREATE POLICY` take `ACCESS EXCLUSIVE`, and as separate statements they left a window with no policy on a live table; now the whole sequence holds one lock in one transaction, and inside a migration's transaction it joins that one, the `SET LOCAL` lasting until it ends. Before any DDL, a preflight against `pg_attribute` refuses a reach column the table lacks, or one outside the type family of `super_auth_authorizations.resource_external_id` (identical, both integer types, or both text types), naming the table, the column, both SQL types and `SuperAuth.external_id_type`: "documents.id is integer and super_auth_authorizations.resource_external_id is text; the policy compares them with no cast, so they must be the same type, both integer types or both text types. SuperAuth.external_id_type is :string: set it to the type of your tables' ids before running the super_auth migrations, or alter the four external id columns to match" — where `CREATE POLICY` said "operator does not exist: integer = character varying" and nothing about which setting decides the other side (audit C4, closed). The ORM does the same for its parent columns on the first scoped query, because MySQL coerces a mismatched comparison silently and admits whatever rows the cast happens to match (audit B8): "Document declares parent column folder_id, which table documents does not have", or "MistypedDocument.organization_id is varchar(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". Checked once per model, ahead of the type-level probe and not for the system user, so a process boots before its migrations run; `reset_column_information` clears it. Only parent columns are checked at the ORM: the primary key against `external_id_type` stays the install-time rule the README states, since checking it would newly refuse hosts that coerce today.
|
|
14
|
+
|
|
15
|
+
- Policy versioning. `SuperAuth::RLS::POLICY_VERSION` (2, bumped only when the policy template changes) and the reach are written as canonical JSON in a `COMMENT ON POLICY` after every `CREATE POLICY` — `{"super_auth":2,"reach":{"id":["Claim"],"organization_id":["Organization::Member","Organization::Admin"]},"wildcard":true}`. The gem never `ALTER`s a policy, and `enable` drops every name it has ever given one (`SuperAuth::RLS::POLICY_NAMES`) before creating `super_auth`: Postgres ORs permissive policies, so one left behind under an old name would keep admitting rows beside the new one. `SuperAuth::RLS.current?(table, resource_type:, parent:, wildcard:)` is true when the table's policy has row security enabled and forced, a comment equal to the one these arguments would write, and an expression free of the 0.8.0 shape — for a test helper or a health check after a deploy that changed `parent:` or upgraded the gem. It answers `false` for a table with no policy of the gem's, one whose row security is off or unforced, and one whose reach genuinely disagrees; a policy an *earlier* version of `enable` built makes it raise instead, with the message `reach` and `coverage` already give ("the super_auth policy on claims was not built by this version of enable (policy version 2); re-run `SuperAuth::RLS.enable`"). There is nothing on such a table to compare the arguments against, and a bare `false` sends the host hunting through `parent:` and `wildcard:` over a database whose only fault is that nobody re-ran `enable` after the upgrade — the state `db:migrate` alone leaves it in, since no migration re-runs it, and the expensive one to sit in unawares, the 0.8.0 policy still installed and still correlated per row. Found by the first consumer, whose dev database had the 0.9.0 gem loaded against a 0.8.0 policy: `coverage` diagnosed it exactly and `current?` said `false` at the same moment on the same database. `stale` asks the question across every table and never raises, so a health check that wants a bare list still has one. `SuperAuth::RLS.stale` lists, as sorted Symbols, the tables carrying a policy of the gem's whose comment is missing or records another version. `SuperAuth::RLS.reach(table)` reads the map back from the comment, which is what lets `coverage` and `explain` work with no model loaded. `installed?` is untouched and keeps meaning only that the identity functions exist, since applications memoise it to decide whether to assert at all. `RENAME COLUMN` rewrites the stored expression and leaves the comment naming the old column, so `current?` goes false after one: re-run `enable`.
|
|
16
|
+
|
|
17
|
+
- `SuperAuth::RLS.explain(table, id)`: the compiled rows that admit one record for the identity currently asserted on the connection, each row's columns plus `step:` — `:type_level`, `:id`, or the parent column — in reach order; `[]` with no identity asserted, under system context, for a missing record, or when nothing admits it. `Model.super_auth_explain(record_or_id)` is the ORM twin, tagged the same way (`[:type_level, :id, :organization_id]` for a row all three admit), `[{ step: :system }]` under the system user, and reads the row `unscoped`, since the question is usually asked about one the user cannot see. Once a parent column takes part, the compiled table alone no longer answers "who can see X": a row is admitted by a compiled row naming a different record, so the answer is a join through the protected table, and these two are that join.
|
|
18
|
+
|
|
19
|
+
- `SuperAuth::ActiveRecord::Resource.dead_type_level_nodes`: the type-level nodes whose `external_type` names no loaded ActiveRecord class carrying the `ByCurrentUser` scope — the constant does not exist, or the scope sits only on a nested subclass, as with a `User` whose scope lives on `User::Directory` and `User::Writable`. Such a node compiles rows that match no model's query and admits nobody, and nothing in the gem notices; the first consumer found seven, all left behind by moving models to a read-only base with a `Writable` subclass without pruning the type list that mints the nodes. Rails-side by necessity, since only a process with the models loaded can say what a type string resolves to, which is why it is not a bucket of `coverage`.
|
|
20
|
+
- `SuperAuth::RLS.coverage(table)`: a diagnostic for a host moving tenancy from per-record rows to a parent column, or checking a production dump before it does. Not a gate — it refuses nothing — and it raises on a table with no policy, which is what a schema-loaded staging database has (`schema.rb` carries no policy), rather than reporting an empty table as covered. Five buckets in this order, each an Array of small hashes with a `count` and up to 20 example `ids`, only the entries whose count is above zero: `loss`, per holder of a type-level row on one of the table's own types, the records that holder reaches through that row and nothing else (no per-record row, no parent row for the value in any parent column, NULL parents included) — what deleting the type-level row takes away; `null_parent`, per parent column, the records whose column is NULL, which no parent grant can reach; `orphaned_rows`, compiled rows with no node behind them, the node deleted or no longer naming the row's type and id, grouped by type and whether the rows are type-level, since a `(type, NULL)` row whose node was deleted keeps admitting every record of the type until the next compile; `widening`, per holder of a parent-type row, the records the parent step admits that the holder holds no per-record row for, so the parent grant reaches them for the first time, a holder a type-level row already admits everywhere left out; `deletable_nodes`, per own type, the per-record nodes no user->resource edge points at — on the node or on any ancestor of it, since the compile copies a granted container's edges down to every node in its subtree — and no child sits under. That last rule is the migration rule written into the gem: access granted through a permission edge travels with the permission and can be replaced by the parent column, but access granted straight to a user — an owner, a reader with one grant on one record and no organization — has no other path, and deleting per-record nodes wholesale revokes it silently. `ids` in `loss`, `null_parent` and `widening` are the table's; in `orphaned_rows` and `deletable_nodes` they are `super_auth_resources` ids, `nil` for a compiled row with no node at all. The holder match is the policy's own SQL with the identity settings pointed at each holder in turn, so what coverage counts as reached is exactly what the policy admits, and a type-level row on a *parent* type is never counted: a column holds an id, and NULL equals none. `loss` is empty under `wildcard: false`, where the type-level step is not in the policy and a `(type, NULL)` row admits nothing, so deleting it takes nothing away. `coverage` and `explain` read in system context when the role may assert it (`super_auth_system()` granted, and neither superuser nor `BYPASSRLS`), otherwise as the caller's own identity, seeing only its rows; `coverage` then needs `SELECT` on the table, `super_auth_resources` and `super_auth_edges`, which `enable` grants to nobody, and counts `null_parent` before the buckets that impersonate each holder in turn, so that one runs under the caller. `explain` reads only the table and `super_auth_authorizations` and needs neither of the other two.
|
|
21
|
+
|
|
22
|
+
- `super_auth(parent:, wildcard:)` on an ActiveRecord model takes the same `parent:` shape, normalises it through the same `SuperAuth::Reach.normalize` — a malformed one raises at declaration, before the class is touched — and stores the frozen map on a class attribute; the table is not read at declaration. `wildcard: false` drops the type-level step and its probe. The scope is one `IN`-subquery per step, OR'd, on one inherited default scope with `all_queries: true`, so an instance's `update`, `destroy` and `reload` carry the same OR. For one parent, one type and internal user 1:
|
|
23
|
+
|
|
24
|
+
```sql
|
|
25
|
+
SELECT "claims".* FROM "claims"
|
|
26
|
+
WHERE ("claims"."id" IN (SELECT "super_auth_authorizations"."resource_external_id" FROM "super_auth_authorizations" WHERE "super_auth_authorizations"."user_id" = 1 AND "super_auth_authorizations"."resource_external_type" = 'Claim' AND "super_auth_authorizations"."resource_external_id" IS NOT NULL)
|
|
27
|
+
OR "claims"."organization_id" IN (SELECT "super_auth_authorizations"."resource_external_id" FROM "super_auth_authorizations" WHERE "super_auth_authorizations"."user_id" = 1 AND "super_auth_authorizations"."resource_external_type" = 'Organization::Member' AND "super_auth_authorizations"."resource_external_id" IS NOT NULL))
|
|
28
|
+
AND "claims"."id" = 7
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
preceded by one type-level probe (`SELECT 1 ... WHERE user_id = 1 AND resource_external_type = 'Claim' AND resource_external_id IS NULL LIMIT 1`, omitted under `wildcard: false`), so the statement count is constant in the number of rows. With a type list the parent step reads `IN ('Organization::Member', 'Organization::Admin')`; an application user matches on `user_external_id` and `user_external_type` instead of `user_id`. The per-record step stays keyed on the querying class's own name, so the permission-gated subclass trick is untouched, and a subclass inherits the parents; re-declaring on a subclass replaces its parents alone and adds no second scope, because two default scopes AND together and would silently deny every row the parent step admits. `ByCurrentUser` included directly, without the macro, behaves as in 0.8.0: no parents, type-level step on. Under the scope a row the user is not admitted to is simply absent — `update!` and `destroy` on one affect 0 rows without an error, `reload` raises `RecordNotFound` — the same answer the database gives.
|
|
32
|
+
|
|
33
|
+
- Migration 12 in both flavours: `idx_sa_auth_by_resource` on `super_auth_authorizations (resource_external_id, resource_external_type)`, `idx_sa_resources_by_external` on `super_auth_resources (external_type, external_id)`, and on Postgres the two expression indexes the policy's identity halves need, built `CONCURRENTLY` from a migration that runs outside a transaction. The first serves the per-resource work a host does around a record — the purge of its compiled rows when its node goes, a compile sliced to its ids — 164 ms to 2.5 ms for a per-resource delete. It leads on the id because that work is keyed by record, and one record carries several nodes of different types (a claim has `Claim` and `Claim::Writable`), so the hot slice is every row for these ids whatever their type: id-only by construction, a shape a type-leading index can only scan and filter (measured at 1.44M rows: 16 ms scanning against 0.04 ms seeking). The second serves the eight lookups that find a record's node, every one of them a sequence scan before. The other two are the policy's own and are Postgres-only, since the policy is: it compares `a.user_id::text` and `a.user_external_id::text`, casts on the column so that a malformed identity is no rows rather than an error, and a cast on a column defeats a plain btree unless it is a no-op. `idx_sa_auth_by_internal_user_text` on `((user_id::text), resource_external_type, resource_external_id)` is built on every Postgres host, because `user_id` is `int4` in every install and no migration has ever indexed it; `idx_sa_auth_by_current_user_text` on `((user_external_id::text), user_external_type, resource_external_type, resource_external_id)` is built only where that column's catalogue type is neither `varchar` nor `text`, since `varchar`->`text` is a no-op cast that migration 9's plain btree already answers as a seek and the index would be a 47 MB duplicate. Measured against the four configurations on 999,973 compiled rows: with the two expression indexes a pk lookup, a `count(*)` and a 50-row page are 1.09 / 2.73 / 2.94 ms, and without them 382.8 / 503.8 / 465.8 ms — they are a hard dependency of the uncorrelated policy, not an optimisation. Their write cost, approximate on a uuid host: a 200,000-row compile insert goes from 1158 ms to 2538 ms, about +7 s and +25 MB per 1,000,000 rows compiled. `CONCURRENTLY` because `super_auth_authorizations` sits on the read path of every statement on every protected table and a plain `CREATE INDEX` would hold `ACCESS EXCLUSIVE` on it for the length of the build; a failed build leaves an `INVALID` index holding the name, and re-running the migration drops that one and builds it again. Each index is skipped when an index of that name already exists, so the migration never fails against a schema a host already shaped.
|
|
34
|
+
|
|
35
|
+
- A `parent_id` cycle is refused at every layer, because once resources carry tenancy it is a security defect and not untidiness. The pair walks terminate on a cycle (`UNION`, since 0.8.0), and every node in one is an ancestor of every other, so a grant on any of them silently reaches all of their subtrees: a container pointed at one of its own children turned a user's single per-record read into a sibling claim plus an organization membership, with nothing raised anywhere. `SuperAuth::Nestable#validate` (groups, roles, resources) and the ActiveRecord twins, through the new `SuperAuth::ActiveRecord::Nested`, refuse a node that is its own parent ("parent_id cannot be the node itself") or whose new parent is inside its own subtree ("parent_id is inside the node's own subtree, which would close a cycle"; "Parent ..." through `ActiveRecord::RecordInvalid`), checked only when `parent_id` changes, by walking up from the new parent — one row per level however large the subtree, and unlike the descendant walk it does not stop at a type-level node, so a cycle through one is caught too. `Group`, `Role` and `Resource.assert_acyclic!` name the nodes no root reaches — "super_auth_resources has a parent_id cycle: node(s) 79, 80 cannot be reached from any root. Point one of them at a root, or at no parent, and recompile." — and `compile!` (both twins) runs all three before the flatness guard and before the delete, so a refused compile leaves the previous rows in place; a cycle written raw comes back from the editor's `POST /api/compile` as a 422 naming the nodes. Migration 13 adds, on Postgres only, a `BEFORE INSERT OR UPDATE OF parent_id` trigger (`super_auth_resources_tree_guard`) refusing both shapes at the row with `check_violation` — for a raw `UPDATE`, a data migration, another language — and does nothing on SQLite and MySQL, which each have a trigger dialect of their own, where the model and compile guards already hold; the file says so rather than leaving a gap in the numbering. A node already inside a raw cycle that is merely renamed still saves, since the check runs only when `parent_id` changes; `assert_acyclic!` is the backstop. Audit A1 is closed at write time and at compile time; A2, A3 and A5 stay open.
|
|
36
|
+
- `SuperAuth::TreeGuard.install`, `remove` and `installed?`: the trigger's SQL as a module a host calls itself, because migration 13 alone does not deliver a trigger to where hosts test. Rails' default `schema_format` is `:ruby`, `db/schema.rb` cannot carry a function or a trigger, and `db:test:prepare` loads `schema.rb` — so on such a host the guard exists in development and production and is absent from the test database, where a test asserting "cycles cannot happen" passes for the wrong reason. Call `SuperAuth::TreeGuard.install` from the test setup beside `SuperAuth::RLS.enable`, which `schema.rb` loses the same way; `installed?` is the health check. Postgres only; on SQLite and MySQL every method answers false and does nothing. Found by the first consumer's pre-release run: one trigger in development, none in test.
|
|
37
|
+
- The Sequel models are rebound to `SuperAuth.db` whenever it is set or the schemas are refreshed. A host requires the models before it has connected anything, so Sequel binds them to whatever `Sequel::Model.db` is at that moment — a mock, in a Rails boot — and a class left on that binding answers `db.database_type` wrong and runs its queries nowhere. The first consumer had carried an initializer rebinding six of the seven models for months; `Authorization` was not in the list, and 0.9.0's `compile_source` branched on its `db.database_type`, skipped the Postgres timestamp cast, and the first compile on a real Postgres schema failed with "column user_created_at is of type timestamp without time zone but expression is of type text". `compile!` and `compile_source` now name `SuperAuth.db` outright as well, and a spec binds a model to a mock, refreshes, and compiles. That initializer workaround can go.
|
|
38
|
+
- Migration 12's `down` is a no-op. Its `up` skips an index a host already owns under the same name, and a rollback cannot tell afterwards which of the two it created, so dropping by name would take the host's own index with it — which is how the first consumer had to un-record the migration by hand rather than revert it. An index left behind costs nothing; the tables' own drop removes them on a full uninstall.
|
|
39
|
+
|
|
40
|
+
- The subtree walk refuses both wildcard directions. 0.8.0's join predicate dropped a type-level node reached as a *descendant*; a per-record node nested *under* a type-level node still received its grants, so one accidental `parent_id` fanned a grant on "every Claim" out to a compiled row for every claim node beneath it, on every path that reads the walk — including a host that reads `SuperAuth::Edge.authorizations` directly and never calls `compile!`, where the flatness guard does not run. `SuperAuth::Resource.descendant_pairs` now stops at a type-level node (the recursive step joins the parent row and requires `external_type IS NULL OR external_id IS NOT NULL`), so a granted type-level node yields its own `(type, NULL)` row and nothing beneath it, on every compile path; the join predicate keeps the other direction, and `assert_compilable!` stays as the loud error in `compile!`. Groups and roles emit the old step unchanged.
|
|
41
|
+
|
|
42
|
+
- `SuperAuth::Resource.record(type, id)`, the finder for the node registered for one record, which refuses a nil id with `SuperAuth::Error` rather than answering: `where(external_type: type, external_id: nil)` is not "no node" — it *is* the type-level node for that type, and a helper called with an unset foreign key would otherwise grant, revoke or label the grant that covers every record of it. `wildcards` remains the finder for that node.
|
|
43
|
+
|
|
44
|
+
- Destroying a `SuperAuth::Resource`, `Group` or `Role`, or an ActiveRecord twin, deletes the node's compiled rows and its edges in the transaction that deletes the row: runtime reads only the compiled table, and a row naming a node that no longer exists would keep granting until the next compile. Children are not touched — the foreign key refuses to orphan them, and re-rooting or deleting them is the caller's decision — and rows compiled *through* the node for its descendants stay until the next compile, as after any other revocation. The editor's delete purges compiled rows for every node type, users and permissions included, and re-roots the children deliberately, which its confirm says: not the grandparent's children, whose grants would then reach them, and not deleted with the node, which is not what "delete this container" asks.
|
|
45
|
+
|
|
46
|
+
### Changed
|
|
47
|
+
|
|
48
|
+
- The 0.8.0 deprecation of type-level (wildcard) resource nodes is retracted. It was right about two nodes and wrong about thirty-seven. In the first consumer's graph, 37 of its 39 type-level nodes say "this principal may act on every record of this type" for a platform admin tier — a permanent concept with no better primitive, since the only replacement is a container with a node per record for every one of those types and a compiled row per record per holder, and nothing gained for it. Two carried the job the parent grant now does: authorizing INSERT under a policy with no per-record row to match. The only shape that was ever a hazard is a type-level node *inside* the tree, and that is refused rather than warned about — by `assert_compilable!` before every compile, by the walk and the join predicate on every path, and by the editor at the door, whose 422 now reads "type-level (wildcard) resources are flat and cannot contain other resources; make a container (a resource with no external type) instead" and whose tooltip says "type-level grant" where it said "type-level (deprecated)". A warning on a flat one had no action a host could take but silencing it, and every host with an admin tier did, which is exactly how to miss the one warning that would have mattered. The vocabulary is "type-level grant": a supported, permanent primitive, emitted by every policy and every scope unless the host opts out with `wildcard: false`. The 0.8.0 sentence that the next release "is when the wildcard stops being needed" is withdrawn with it: what this release stops needing is the wildcard *for INSERT on a table that has a parent column*, and nothing else.
|
|
49
|
+
|
|
50
|
+
- `compile!` (both twins) is one `INSERT ... SELECT` of `SuperAuth::Edge.authorizations` under the column list `SuperAuth::Edge::AUTHORIZATION_COLUMNS`, the follow-up 0.8.0's upgrade notes tracked, and returns the count from inside the transaction. Row by row through the model the same graph loaded at 574 rows/s against 5,277 through a bulk insert; at 1.44M rows that was about 42 minutes in one held transaction with 3.9 GB of rows resident in Ruby, exposed on the editor's Compile button. On Postgres the eight timestamp columns, which travel through the union as text for MySQL's collation rule, are cast back in the SELECT; `SET LOCAL jit = off` stays.
|
|
51
|
+
|
|
52
|
+
### Fixed
|
|
53
|
+
|
|
54
|
+
- The ActiveRecord migration chain runs on MySQL 8. It could not start at all: `20250101000001` declared `t.timestamps default: -> { "CURRENT_TIMESTAMP" }`, and since Rails 7 `t.timestamps` emits `datetime(6)`, where MySQL 8 refuses a default that does not name the same precision — `Mysql2::Error: Invalid default value for 'created_at'` on the first migration, so a Rails host on MySQL could not install the gem. The seven `create_table` migrations now pass `precision: nil`, so MySQL emits `datetime`, which is also what the Sequel twin's `DateTime` emits there; on Postgres and SQLite the column is unchanged, `timestamp` being `timestamp(6)` already. The spec that migrates the whole ActiveRecord chain up and back down no longer skips on MySQL. Pre-existing, and not caused by 0.8.0 or 0.9.0.
|
|
55
|
+
|
|
56
|
+
### Removed
|
|
57
|
+
|
|
58
|
+
- `SuperAuth::Deprecator`, `SuperAuth.deprecator`, `SuperAuth.deprecator=` and the `super_auth.deprecator` railtie initializer, so `app.deprecators[:super_auth]` is no longer registered and `compile!` prints nothing for flat type-level grants. Nothing else used them. A host's `SuperAuth.deprecator.silenced = true` line now raises `NoMethodError`: delete it, or guard it with `SuperAuth.respond_to?(:deprecator)` while both versions are in play.
|
|
59
|
+
|
|
60
|
+
### Upgrade notes
|
|
61
|
+
|
|
62
|
+
- Re-run `SuperAuth::RLS.enable` for every protected table, in a migration, with the `parent:` each should carry. **No migration does this for you**, so a host that runs `db:migrate` and nothing else is on 0.9.0's library with a 0.8.0 policy still installed, still correlated per row, and nothing anywhere reports it — check with `SuperAuth::RLS.stale` after deploying, which names exactly those tables. **On Rails, make sure that migration sorts after the copied migration 12**, because `db:migrate` runs in timestamp order and `rails super_auth:install:migrations` stamps its copy of 12 with the moment you ran the installer — so a hand-written `enable` migration you started first carries the earlier timestamp and runs before the indexes exist. That ordering is not cosmetic: without the expression indexes 0.9.0's policy is a regression against 0.8.0 on single-row reads, since its uncorrelated subqueries pay their full cost to read one row where the correlated `EXISTS` stopped at the first match. Run the installer first, then write the `enable` migration, and check the two filenames sort the way you meant. A gem upgrade changes nothing already in the database: the 0.8.0 policy stays as it was, re-running its `EXISTS` per row, and a parent grant is invisible to it — a holder of only `Organization::Member` sees nothing, fail closed, and it reads as a permissions bug. `SuperAuth::RLS.stale` lists the tables behind, `current?` in a test helper or health check catches the next drift. `enable` is idempotent, re-runnable on a live table, replaces the old policy in place under one lock, and accepts the signature the 0.8.0 CHANGELOG published unchanged.
|
|
63
|
+
- If you are staying on 0.7.x or 0.8.0 for now, read this before deciding you are unaffected. The policy those releases installed is one `EXISTS` correlated on the outer row (`(a.resource_external_id IS NULL OR a.resource_external_id = t.id) AND (internal OR external)`), so its cost is paid once per row a statement touches and scales with the number of *type-level* grants (`resource_external_id IS NULL`) on the types you protect, not with your record count. Measure yours: `SELECT count(*) AS total, count(*) FILTER (WHERE resource_external_id IS NULL) AS type_level FROM super_auth_authorizations`, then the breakdown that actually decides it, `SELECT resource_external_type, count(*) FROM super_auth_authorizations WHERE resource_external_id IS NULL GROUP BY 1 ORDER BY 2 DESC` — the type-level step is type-scoped, so a table protected as `Claim` pays for the `Claim` rows and not for 50,000 rows of an admin type no policy reads; take the count for each type you passed to `enable`. That count is the number of principals holding a type-level grant on a protected type, an admin population in most designs and so bounded by staff rather than customers; it grows without a ceiling only where a host hands type-level grants on a protected type to ordinary users. The curve, on synthetic data — a uuid install, an 8,000-row protected table, one `SELECT count(*)`: ~1,000 type-level rows, 7.9–9.5 s; 50,000, 121–141 s; 150,000, 348–350 s, which is 5.8 minutes for one `count(*)` over 8,000 rows. A second rig at 1,056,000 compiled rows brackets the turn: 12 type-level rows, 0.59 ms for a single-row read and 563 ms for `count(*)`; 1,000 rows, 1.03 ms and 622 ms; 50,000 rows, 56.0 ms and over 30 s. Somewhere between 1,000 and 50,000 the planner abandons the `BitmapOr` over `idx_sa_auth_by_resource` and falls back to scanning `super_auth_authorizations` once per outer row; below about 1,000 you are in the good plan and there is nothing to do. Every figure here is from a synthetic rig, not from any production install, and the variable they are in is type-level rows on a protected type — not your record count and not your compiled-row count, so take your own two numbers from the queries above before deciding you have a problem; most installs sit far below the knee, where none of this is worth doing. Re-time anything you believe is fine with `SET LOCAL synchronize_seqscans = off` inside the transaction: without it the same statement measured 20,463 ms and 8.495 ms minutes apart, because each sequence scan starts where the last one stopped, and warm numbers taken with it on are not reproducible and are the likeliest reason a host believes it has no problem. Your only lever short of upgrading is an index, and it might do nothing: migration 12's two expression indexes can be built on a 0.8.0 database (they are `CONCURRENTLY`, and they index columns the 0.8.0 predicate names too), and the two measurements disagree for a reason — at ~165,000 compiled rows with a holder of a handful of grants, 368,216 ms became 67.9 ms, while at 1,056,000 rows with a holder of 8,000 per-record grants nothing changed at all, the planner declining an identity bitmap it estimates at 6,415 rows when it would be re-read once per outer row. Build them `CONCURRENTLY`, `EXPLAIN` your worst statement with `synchronize_seqscans` off, and believe the plan rather than either number: if `Seq Scan on super_auth_authorizations` is still in it, drop them again — on a uuid host they cost about 25 MB and roughly halve compile insert throughput (~173k rows/s to ~79k). Upgrading is the fix and it is a different order of magnitude from any index, because it changes the shape and not the access path: 0.9.0's steps are uncorrelated with the outer row, so the type-level step plans once per query instead of once per row — 350,173 ms to 86 ms on the same data with no index change at all, then 86 ms to about 1 ms once migration 12's expression indexes land. Build the indexes before re-running `enable`, not after: without them 0.9.0's policy is a regression against 0.8.0 on single-row reads (512.9 ms against 70.9 ms on the uuid rig), because uncorrelated subqueries pay their full cost to read one row where the correlated `EXISTS` stopped at the first match.
|
|
64
|
+
- Run migrations 12 and 13: `rails super_auth:install:migrations` then `rails db:migrate`; `SuperAuth.install_migrations` or `super_auth-editor --migrate` without Rails. Both are safe on a schema that already carries the indexes, and 13 does nothing off Postgres. Migration 12 now runs outside a transaction so that it can build its indexes `CONCURRENTLY` on Postgres, which is what lets a host large enough to need them migrate without holding `ACCESS EXCLUSIVE` on `super_auth_authorizations` for the length of the build; a `CONCURRENTLY` build that fails leaves an `INVALID` index behind, and re-running the migration drops that one and builds it again.
|
|
65
|
+
- Nothing to recompile: the compiled table has the same columns, and the walk change only removes rows that a type-level node nested in the tree would have produced, a shape the flatness guard has refused since 0.8.0.
|
|
66
|
+
- With type-level grants in the graph, `compile!` is now silent. Delete any `SuperAuth.deprecator` line, or guard it (see Removed).
|
|
67
|
+
- The contract for clients in other languages — what the policy admits and what it deliberately does not — is in the README under "The contract (any language)". In one breath: the policy is the tenancy boundary and the application the capability boundary, so a read-tier holder passes it for `UPDATE` and `DELETE`; a parent type must be a capability type nobody else is granted, with `Claim::Admin` (platform-only, no parent, ever) and `Organization::Admin` (a per-organization node, a legitimate parent) side by side as the example of two names that collide; a column lists every tier's parent type, because the policy must never be narrower than any tier's ORM scope; parents do not chain; no row can be created through ActiveRecord that its creator cannot immediately read, which is why a node minted in `after_create_commit` can never authorize its own record's INSERT; a create with a NULL parent value needs system context around exactly that branch; a per-record holder may set the parent column, and the client gates it; a wrong tenancy column fails open; the compiled table alone no longer answers "who can see X"; `pg_current_xact_id()` cannot run on a hot standby, as before.
|
|
68
|
+
|
|
3
69
|
## [0.8.0] - 2026-09-09
|
|
4
70
|
|
|
5
71
|
### Added
|
|
@@ -28,8 +94,9 @@
|
|
|
28
94
|
|
|
29
95
|
### Upgrade notes
|
|
30
96
|
|
|
31
|
-
- Run migration 11 before deploying code that saves resource nodes or compiles: `SuperAuth::Resource` now reads `parent_id`, so `compile!` on an un-migrated schema fails with a missing-column error. In Rails, `rails
|
|
97
|
+
- Run migration 11 before deploying code that saves resource nodes or compiles: `SuperAuth::Resource` now reads `parent_id`, so `compile!` on an un-migrated schema fails with a missing-column error. In Rails, `rails super_auth:install:migrations` copies the new migration into the app and `rails db:migrate` runs it — the engine-scoped task; `railties:install:migrations` also works but sweeps in every mounted engine's pending migrations at once — the engine points the install task at `db/migrate_activerecord` but does not run its migrations on its own, which the install generator's notes used to claim and now do not. Without Rails, `SuperAuth.install_migrations` or `super_auth-editor --migrate`; `rake super_auth:migrate` exists only inside a Rails app (its tasks depend on `:environment`).
|
|
32
98
|
- No recompile: the compiled table has the same columns and, on a flat graph, the same rows.
|
|
99
|
+
- On a database that has been compiling incrementally — a per-user recompute that reinserts rows without a full clear — the first full `compile!` on 0.8.0 can legitimately drop rows: stale duplicates the incremental path accumulated, not grants. The check that tells the two apart is `compile!` on 0.7.0 against `compile!` on 0.8.0 over the same graph, which give the same count; a row count taken before the upgrade and after the first compile does not, and reads as 0.8.0 having eaten grants.
|
|
33
100
|
- With wildcard nodes in the graph, every compile prints one deprecation line naming them. Silence it with the Rails deprecation config — the deprecator is `app.deprecators[:super_auth]`, so `config.active_support.deprecation = :silence` and `config.active_support.report_deprecations = false` both apply — or with `SuperAuth.deprecator.silenced = true`.
|
|
34
101
|
- Compiled-table growth: a grant on a container compiles one row per descendant per path, and `compile!` inserts row by row, so a container over many records lengthens every compile in proportion. A single `INSERT ... SELECT` is the tracked follow-up.
|
|
35
102
|
|