@jantstack/adonis-authz 2.4.0-alpha.9 → 2.5.0-alpha.2
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.
- package/README.md +468 -30
- package/build/commands/authz_freeze.js +1 -1
- package/build/commands/authz_freeze.js.map +1 -1
- package/build/commands/authz_reconcile.d.ts +6 -2
- package/build/commands/authz_reconcile.d.ts.map +1 -1
- package/build/commands/authz_reconcile.js +129 -13
- package/build/commands/authz_reconcile.js.map +1 -1
- package/build/commands/authz_relations_reconcile.d.ts +16 -24
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
- package/build/commands/authz_relations_reconcile.js +198 -49
- package/build/commands/authz_relations_reconcile.js.map +1 -1
- package/build/commands/authz_scopes_relay.d.ts.map +1 -1
- package/build/commands/authz_scopes_relay.js +81 -1
- package/build/commands/authz_scopes_relay.js.map +1 -1
- package/build/commands/authz_unfreeze.d.ts +12 -4
- package/build/commands/authz_unfreeze.d.ts.map +1 -1
- package/build/commands/authz_unfreeze.js +74 -9
- package/build/commands/authz_unfreeze.js.map +1 -1
- package/build/index.d.ts +6 -4
- package/build/index.d.ts.map +1 -1
- package/build/index.js +5 -3
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts +1 -1
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +5 -1
- package/build/providers/authz_provider.js.map +1 -1
- package/build/src/define_config.d.ts +41 -0
- package/build/src/define_config.d.ts.map +1 -1
- package/build/src/define_config.js +120 -0
- package/build/src/define_config.js.map +1 -1
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +1 -0
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +18 -6
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/database_relations_driver.js +94 -46
- package/build/src/drivers/database_relations_driver.js.map +1 -1
- package/build/src/drivers/openfga_driver.d.ts +46 -5
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +115 -29
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_relations_driver.d.ts +82 -13
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_relations_driver.js +216 -55
- package/build/src/drivers/openfga_relations_driver.js.map +1 -1
- package/build/src/errors.d.ts +86 -0
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +86 -0
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +63 -4
- package/build/src/freeze.d.ts.map +1 -1
- package/build/src/freeze.js +107 -18
- package/build/src/freeze.js.map +1 -1
- package/build/src/identity.d.ts +120 -9
- package/build/src/identity.d.ts.map +1 -1
- package/build/src/identity.js +164 -12
- package/build/src/identity.js.map +1 -1
- package/build/src/manager.d.ts +64 -2
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +267 -35
- package/build/src/manager.js.map +1 -1
- package/build/src/openfga.d.ts +2 -2
- package/build/src/openfga.d.ts.map +1 -1
- package/build/src/openfga.js +2 -2
- package/build/src/openfga.js.map +1 -1
- package/build/src/relations/check_many.d.ts +17 -0
- package/build/src/relations/check_many.d.ts.map +1 -0
- package/build/src/relations/check_many.js +57 -0
- package/build/src/relations/check_many.js.map +1 -0
- package/build/src/relations/define_relations_config.d.ts +74 -1
- package/build/src/relations/define_relations_config.d.ts.map +1 -1
- package/build/src/relations/define_relations_config.js +201 -7
- package/build/src/relations/define_relations_config.js.map +1 -1
- package/build/src/relations/manager.d.ts +20 -0
- package/build/src/relations/manager.d.ts.map +1 -1
- package/build/src/relations/manager.js +88 -15
- package/build/src/relations/manager.js.map +1 -1
- package/build/src/relations/reconcile.d.ts +134 -3
- package/build/src/relations/reconcile.d.ts.map +1 -1
- package/build/src/relations/reconcile.js +340 -36
- package/build/src/relations/reconcile.js.map +1 -1
- package/build/src/relations/tuple_key.d.ts +25 -0
- package/build/src/relations/tuple_key.d.ts.map +1 -0
- package/build/src/relations/tuple_key.js +60 -0
- package/build/src/relations/tuple_key.js.map +1 -0
- package/build/src/relations_config_store.d.ts.map +1 -1
- package/build/src/relations_config_store.js +29 -7
- package/build/src/relations_config_store.js.map +1 -1
- package/build/src/relations_reconcile.d.ts +79 -0
- package/build/src/relations_reconcile.d.ts.map +1 -0
- package/build/src/relations_reconcile.js +85 -0
- package/build/src/relations_reconcile.js.map +1 -0
- package/build/src/scope_outbox.d.ts +9 -5
- package/build/src/scope_outbox.d.ts.map +1 -1
- package/build/src/scope_outbox.js +7 -4
- package/build/src/scope_outbox.js.map +1 -1
- package/build/src/shared/backend_guard.d.ts +30 -11
- package/build/src/shared/backend_guard.d.ts.map +1 -1
- package/build/src/shared/backend_guard.js +45 -15
- package/build/src/shared/backend_guard.js.map +1 -1
- package/build/src/shared/sql_expiry.d.ts +2 -0
- package/build/src/shared/sql_expiry.d.ts.map +1 -1
- package/build/src/shared/sql_expiry.js +4 -0
- package/build/src/shared/sql_expiry.js.map +1 -1
- package/build/src/testing/main.d.ts +3 -1
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +2 -1
- package/build/src/testing/main.js.map +1 -1
- package/build/src/testing/migration_contract.d.ts +9 -0
- package/build/src/testing/migration_contract.d.ts.map +1 -1
- package/build/src/testing/migration_contract.js +5 -2
- package/build/src/testing/migration_contract.js.map +1 -1
- package/build/src/testing/relations_contract.d.ts +14 -0
- package/build/src/testing/relations_contract.d.ts.map +1 -1
- package/build/src/testing/relations_contract.js +367 -21
- package/build/src/testing/relations_contract.js.map +1 -1
- package/build/src/testing/scope_outbox_contract.d.ts +14 -1
- package/build/src/testing/scope_outbox_contract.d.ts.map +1 -1
- package/build/src/testing/scope_outbox_contract.js +126 -48
- package/build/src/testing/scope_outbox_contract.js.map +1 -1
- package/build/src/testing/test_clock.d.ts +15 -0
- package/build/src/testing/test_clock.d.ts.map +1 -0
- package/build/src/testing/test_clock.js +65 -0
- package/build/src/testing/test_clock.js.map +1 -0
- package/build/src/types.d.ts +115 -12
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js.map +1 -1
- package/package.json +6 -1
package/README.md
CHANGED
|
@@ -35,7 +35,7 @@ node ace authz:catalog:sync
|
|
|
35
35
|
|
|
36
36
|
> **Upgrading to 2.4.0-alpha.4 — how the commands are registered.** `@jantstack/adonis-authz/commands` is now a real ace loader (it exports `getMetaData()`/`getCommand()`, built with `ListLoader` over the nine command classes, next to the named class exports). Until alpha.4 it only re-exported the classes, so the line `configure` writes — `commands: [() => import('@jantstack/adonis-authz/commands')]` — made **the whole `node ace` crash** at boot (`TypeError: loaderInstance.getMetaData is not a function`). If you registered the commands by wrapping the barrel in a `ListLoader(Object.values(m))` (the only way it worked before alpha.4), remove the wrapper and register the module directly: `() => import('@jantstack/adonis-authz/commands')`, which is what `node ace configure` writes. The wrapper now fails with `TypeError: command.serialize is not a function`, because the module also exports the loader functions: `Object.values(m)` includes them and `ListLoader.getMetaData()` calls `serialize()` on each value. The module has no `default` export on purpose — ace calls `getMetaData()` on the module namespace and never unwraps `default` (same shape as `@adonisjs/core/commands`).
|
|
37
37
|
|
|
38
|
-
For the OpenFGA driver, also install its SDK (optional peer): `npm i @openfga/sdk`. Everything that touches the SDK lives behind the **`@jantstack/adonis-authz/openfga` subpath** (`OpenFgaAuthorizationDriver`, `
|
|
38
|
+
For the OpenFGA driver, also install its SDK (optional peer): `npm i @openfga/sdk`. Everything that touches the SDK lives behind the **`@jantstack/adonis-authz/openfga` subpath** (`OpenFgaAuthorizationDriver`, `OpenFgaRelationsDriver`, `provisionOpenFgaStore`, `openFgaFactsModel`, `assertHolderTypes`); the main entry never imports it, so a database-only install boots without the SDK (the suite loads `index.ts` in a child process with `@openfga/sdk` blocked to prove it: *"index.ts (la entrada principal) carga con el SDK bloqueado"*). The published config imports the driver from that subpath *inside* the factory.
|
|
39
39
|
|
|
40
40
|
## Semantics (what every driver guarantees)
|
|
41
41
|
|
|
@@ -139,7 +139,7 @@ The package **does not impose a table**: the contract is the `ScopeOutbox` port
|
|
|
139
139
|
|
|
140
140
|
The relay is resumable and never silent: the report says *which* changes were applied, not a count. A change that cannot be applied **defers what depends on it and lets the rest through**: the failure poisons the scopes that change names, every later change naming one of them is reported as `deferred` and not attempted (transitively), and everything else is applied. That is what keeps the tree order — `attached(P, org)` before `attached(C, P)`, a `moved` before the `detached` of the same node — without letting one tenant freeze the queue for all of them. Until 2.0 the pass stopped at the first failure, and a single change that can never be applied (its parent scope was deleted before the pass) then blocked every later change of every tenant, indefinitely. `sqlScopeOutbox` also **parks** an entry after `maxAttempts` failures (default 5): it stops being retried, it is reported in `dead` on every pass, and the command exits non-zero while any exists — a parked entry is a permanent divergence of the backend's tree, not a resolved incident. The command exits non-zero on any failure too, so a supervisor notices. Applying a queued `detached` runs `purgeScope` and only then removes the edge, and it emits the `scope_purged` audit event at that point, carrying the actor that ordered it.
|
|
141
141
|
|
|
142
|
-
**"Was mine applied?" — the receipt and `only` (2.4.0-alpha.5).** `scopes.attached/moved/detached` return a receipt, **`{ queued: [id] }`** — the outbox ids of what they just enqueued (plural on purpose: a `detached` whose row is already gone enqueues one entry per spelling of the uuid). Empty means there is nothing to relay: without an outbox the change was applied inline; with a third-party `ScopeOutbox` whose `enqueue` still returns `void` there is no id, and then there is no `only` either. Hand the receipt to the relay on the interactive path, right after your commit: **`await authorization.relayScopeChanges({ only: queued })`**. That is the fix for the cost a consumer measured: with 500 entries of another tenant ahead in the queue, the inline pass cost **4,507 ms and 2,004 OpenFGA calls** to land one change; with `only` it costs **19 ms and 4 calls**, because the filter lives in the port (`pending(limit, after, { ids })` — the primary key) and the pass never reads the other 500 rows (the suite spies on `pending` to pin that; a port implementation that ignores the filter is 500 `E_AUTHZ_CONFIG` before anything is applied, never a silent drain of the backlog inside your request). **The order by scope key is not relaxed — deferred, never out of order.** Before applying each requested entry the relay probes the *whole* queue (`earlierPending`: one existence query against the `(applied_at, id)` index, 0.69 ms measured; a port without it is paged through `pending()`): if an earlier unapplied entry names one of the same scopes and was not requested, yours is reported `deferred` with `depends-on:<id>` and not applied. Why, measured: with `#1 moved(unit → orgB)` pending and `only: [#2 attached(child, unit)]`, a filter without the probe wrote `child → unit` while the store still said `unit → orgA`, and the editor of the *old* organization authorised over the child (`authorize(A@orgA, docs:read, child) = true`, report all green). One level is enough — the last link of any chain of pending dependencies shares a scope with yours by definition of the key contamination — so the probe costs one query, not the queue. The report is **per entry**: `entries` names every entry the pass looked at, `applied` / `deferred` (reason `depends-on:<id>` or `parked:<id>`) / `failed` / `dead`, with or without `only`; and `missing` names the ids of `only` that
|
|
142
|
+
**"Was mine applied?" — the receipt and `only` (2.4.0-alpha.5).** `scopes.attached/moved/detached` return a receipt, **`{ queued: [id] }`** — the outbox ids of what they just enqueued (plural on purpose: a `detached` whose row is already gone enqueues one entry per spelling of the uuid). Empty means there is nothing to relay: without an outbox the change was applied inline; with a third-party `ScopeOutbox` whose `enqueue` still returns `void` there is no id, and then there is no `only` either. Hand the receipt to the relay on the interactive path, right after your commit: **`await authorization.relayScopeChanges({ only: queued })`**. That is the fix for the cost a consumer measured: with 500 entries of another tenant ahead in the queue, the inline pass cost **4,507 ms and 2,004 OpenFGA calls** to land one change; with `only` it costs **19 ms and 4 calls**, because the filter lives in the port (`pending(limit, after, { ids })` — the primary key) and the pass never reads the other 500 rows (the suite spies on `pending` to pin that; a port implementation that ignores the filter is 500 `E_AUTHZ_CONFIG` before anything is applied, never a silent drain of the backlog inside your request). **The order by scope key is not relaxed — deferred, never out of order.** Before applying each requested entry the relay probes the *whole* queue (`earlierPending`: one existence query against the `(applied_at, id)` index, 0.69 ms measured; a port without it is paged through `pending()`): if an earlier unapplied entry names one of the same scopes and was not requested, yours is reported `deferred` with `depends-on:<id>` and not applied. Why, measured: with `#1 moved(unit → orgB)` pending and `only: [#2 attached(child, unit)]`, a filter without the probe wrote `child → unit` while the store still said `unit → orgA`, and the editor of the *old* organization authorised over the child (`authorize(A@orgA, docs:read, child) = true`, report all green). One level is enough — the last link of any chain of pending dependencies shares a scope with yours by definition of the key contamination — so the probe costs one query, not the queue. The report is **per entry**: `entries` names every entry the pass looked at, `applied` / `deferred` (reason `depends-on:<id>` or `parked:<id>`) / `failed` / `dead`, with or without `only`; and `missing` names the ids of `only` that are not in the queue at all — neither pending nor parked: already applied by an earlier pass, or unknown; the relay cannot tell — never an empty report in green. **`notEvaluated` names the ids of `only` that are still pending and the pass never looked at** because it reached `limit` first (2.4.0-alpha.10): until then `{ only: [a, b], limit: 1 }` put the pending `b` in `missing` — "already applied, or unknown" — and the applying pass exited 0. Telling them apart costs one read of the queue filtered by those ids, only when some requested id was not seen. `authz:scopes:relay --only … --limit …` names each of them ("is PENDING and this pass did NOT evaluate it", saying whether it was `--dry-run` or the applying pass and which `--limit`) and exits ≠ 0, in both. `only: []` is 500 `E_AUTHZ_CONFIG`: an empty `filter` on your side, or a receipt from an outbox without ids, cannot end up draining the whole queue under the lease of an HTTP request. **`remaining` and `dead` are always global**, with or without `only`: a filtered report that kept quiet about a queue that does not converge would be a green report over a broken tree. Three things this is **not**. `only` takes the ids from the receipt your own process just got, **never from the request body** — an endpoint that accepts `only` from a client is policy in an API that has no policy (the relay skips `requireActor`/`requireWithin` on purpose). **There is no filter by tenant or organization**: a `detached` whose row is already gone belongs to no organization (excluding it always is a permanent fail-open — its facts are never purged; including it always is "anyone relays anyone's `detached`"), and a `moved` would be attributed to its *destination*, which is the bias that keeps accesses. And `only` does not pull its dependencies along — that is transitive and ends up being the whole queue; if what you want is "the queue landed up to here", run the relay without `only`. **Do not return the relay report over HTTP**: it enumerates the tree activity of every tenant (ids, scope uuids, actors, the backend's error text); read it, log it, act on `entries`, and hand the client your own answer. Finally, **the inline pass returns a report, it does not throw**: a `deferred` or a `failures` does not abort your request unless you look at it — if `entries` says your change is `deferred`, it has not landed, and that is the fail-closed window described below.
|
|
143
143
|
|
|
144
144
|
**A 503 does not consume an attempt (2.4.0-alpha.5).** `maxAttempts` exists to converge on the *irreparable* — the parent of an enqueued `attached` was deleted and will not come back — and until alpha.4 a backend outage spent the same budget: measured, with the store down, **five requests of another tenant** running the inline relay parked an entry that was not theirs (one attempt per request: the entry at the head of the queue pays for everyone's pass), and from then on its dependants were applied without it. Now the manager tells the two apart by the nature of the error: an `AuthorizationBackendError` — the store did not answer, the deadline expired, any 503 — is reported in `failures` with `transient: true` and passed to `markFailed(id, error, { transient: true })`, and `sqlScopeOutbox` writes `last_error` **without** counting the attempt; a 422, a 500, `E_AUTHZ_SCOPE_TREE_DRIFT`, a missing parent or a bare error from a third-party driver count as before. A port implementation that ignores the third parameter counts everything — exactly the alpha.4 behaviour, degrading towards safe. For the inline recipe, with the numbers: an outage of the store lasting `N` requests leaves `N−1` entries deferred behind the failing head on request `N`, and none of them parked.
|
|
145
145
|
|
|
@@ -161,7 +161,7 @@ The relay is resumable and never silent: the report says *which* changes were ap
|
|
|
161
161
|
|
|
162
162
|
**How old is the queue? `report.queue` and `--max-age` (2.4.0-alpha.8).** Measured in a consumer (panel relay 2, auditor B2): with the store down, ten relay passes in a row reported `attempts = 0` on every entry (a 503 does not consume an attempt, since alpha.5), `dead = 0` and **no notion of time anywhere** — the cron was red because of `failures`, but nobody could tell since when, and nothing distinguished a queue that has been stuck for an hour from one that was enqueued a second ago. Every relay report now carries **`queue: { oldestPendingAt, pendingAgeMs }`**: the `createdAt` of the **oldest pending entry of the whole queue** — the `pending(1)` the pass already ran at the end for `remaining`, so it costs no extra query — and its age, `max(0, now − createdAt)`, measured with the manager's injectable clock (`config.clock`; no `Date.now()` in the package sources, the suite greps for it). It is **always global, also under `only`**, like `remaining` and `dead`: it names nobody, it only says "there is backlog". Both fields are `null` when nothing is *pending* (an empty queue, or only parked entries — those have their own alarm, `deadCount`, below) and when the port does not provide `createdAt`: a third-party `ScopeOutbox` whose entries carry no `createdAt` reads as `null`, **never as `0` or "now"** (the same rule as `stillGranting`: "I do not know" does not degrade to a number). `sqlScopeOutbox` returns it from the `created_at` column it already had (`createdAt?: Date` is an optional field of `PendingScopeTreeChange`; the outbox contract has a `createdAt` capability pair). Read the number for what it is: **the age is the clock of whoever enqueued** — another replica with a skewed clock stamps its own `created_at`, so the age is clamped at zero and never negative, and it is not a decision the package takes (audit stamps are not decisions). The command prints it on every pass; **`--max-age=<ms>`** makes the pass exit ≠ 0 when the age is above the threshold — **only with a threshold**: without it the age is reported and never fails the run, because a cron that goes red for ever over a queue that simply has backlog is a cron nobody reads. Parking by age (`parkAfterMs`) is *not* offered, on purpose — see the changelog: it reintroduces, with another clock, the mass parking alpha.5 removed, and it was measured to make every pass a 500 once the census fills.
|
|
163
163
|
|
|
164
|
-
**Enqueueing outside your transaction: `sqlScopeOutbox({ requireTransaction: true })` (2.4.0-alpha.8).** The mitigation above is *entirely* the `{ transaction: trx }` you hand to `scopes.attached/moved/detached`. Without it the call still enqueues — the row commits on its own, outside your unit of work — and if your transaction rolls back afterwards **the change stays in the queue and the relay applies it to the store**: your database says one parent, the PDP another, and nothing on your side can show it (the S5 leak, on the *correct-use* path; measured by the tester of panel relay 2, P13). With `requireTransaction: true` that call is **500 `E_AUTHZ_CONFIG` before the `INSERT`**, naming exactly that leak, through the manager and through the outbox alone; with an open transaction of the outbox's connection it enqueues as always. The default is **`false` in 2.4
|
|
164
|
+
**Enqueueing outside your transaction: `sqlScopeOutbox({ requireTransaction: true })` (2.4.0-alpha.8).** The mitigation above is *entirely* the `{ transaction: trx }` you hand to `scopes.attached/moved/detached`. Without it the call still enqueues — the row commits on its own, outside your unit of work — and if your transaction rolls back afterwards **the change stays in the queue and the relay applies it to the store**: your database says one parent, the PDP another, and nothing on your side can show it (the S5 leak, on the *correct-use* path; measured by the tester of panel relay 2, P13). With `requireTransaction: true` that call is **500 `E_AUTHZ_CONFIG` before the `INSERT`**, naming exactly that leak, through the manager and through the outbox alone; with an open transaction of the outbox's connection it enqueues as always. **The default is **`true` since 2.5.0** (lot B · B6, owner's decision; it was `false` in 2.4 because the published outbox contract enqueued without a transaction and a `true` default would have turned every third-party outbox red). `requireTransaction: false` is the explicit opt-out — the signature of code that enqueues outside any unit of work on purpose (a seeder, a harness) —, and the published contract now carries the capability pair **`requiresTransaction`** (optional, `false` when absent): with `true` the judge enqueues inside a transaction of your connection in every case, direct and through `scopes.*`, and pins the refusal (500 naming `requireTransaction`, zero rows, by the outbox alone and by the manager); with `false` it pins that enqueueing without one enters. Mind the pool: `{ transaction }` **through the manager** needs a pool of at least 2 (the freeze barrier reads through another connection; with a single one the notification is 503 `E_AUTHZ_BACKEND_TIMEOUT` at `freezeTimeoutMs`), so a deployment with **pool max 1 — knex's default for every SQLite connection, file or `:memory:`** — cannot use the default through `scopes.*`: declare `requireTransaction: false` there and say so (the 500 names the pool and that way out; following its first advice alone on a pool of 1 leads to the 503). A transaction that is **already committed or rolled back** is a different 500, below.
|
|
165
165
|
|
|
166
166
|
**What the report says about the parked set — `deadCount`, `deadDetail`, and the `origin` of a deferral (2.4.0-alpha.8).** Three small corrections to the report, each measured in a consumer. **`deadCount`** is always present and always global: `dead[]` is capped at `batchSize` (100), so `dead.length` *lies* once more than that is parked; the alarm "the queue does not converge" now rests on the count, which comes from the census the pass already reads for the key plug (no extra query). **Under `only`, `dead[]` carries `id`, `change` and `attempts` and — by default — no `error`**: the inline recipe runs inside a tenant's request, and the error text is what the backend said about *another* tenant's tree; pass `deadDetail: 'full'` to get it back (without `only` nothing changes, and the entries you *asked for* keep their `reason` in `entries`, they are yours). Read this as the safe-fields rule for a report you might be tempted to surface: `deadCount` and the ids say "there is a parked backlog" and nothing else; `change` still names scopes of other tenants (the actor never travels in `dead[]`), so the rule above stands — do not return the report over HTTP. And every `deferred` entry now carries **`origin`**: `'parked'` (held, directly or transitively, by a parked entry — the only origin that lets a `detached` through, invariant 11), `'failed'` (held by a real failure of this pass), or `'pending'` (under `only`: held by an earlier entry that is genuinely pending and was not requested — it will land on the next full pass). Until alpha.8 `depends-on:<id>` did not distinguish the last two and the origin lived only in the Spanish prose of `deferred[].error`; it is derived from the block itself, never parsed back from the text of `reason`.
|
|
167
167
|
|
|
@@ -249,7 +249,7 @@ The *row* is inside your transaction; the *decision* that authorises writing it
|
|
|
249
249
|
|
|
250
250
|
#### Third-party drivers, and what the suite can and cannot prove
|
|
251
251
|
|
|
252
|
-
"Both or neither" is **a guarantee of this package's `database` driver**, measured on its own schema on three engines. For a driver of yours declaring `transactionalWrites: true`, the runner requires the census (the `transactions` hook: how to open a transaction of *your* connection and how to count *your* rows) and judges rollback ⇒ zero, commit ⇒ applied, the uncommitted write invisible to the authority and three foreign transactions ⇒ 500 with zero statements — but it **cannot prove that a third-party driver's write really lives and dies with the transaction it is handed**: the census it counts is the one your hook reports, and a driver that opens its own transaction underneath passes the answer checks. Two more honest boundaries: the `true` face only runs where a second connection exists (`sqlite-file`, PostgreSQL, MySQL — the engine jobs of CI are not optional for this capability), so a driver judged only on `:memory:` never sees those cases; and the 409/503 classification of the table above is the `database` driver's — a third-party driver that returns the raw engine error is not caught by the suite.
|
|
252
|
+
"Both or neither" is **a guarantee of this package's `database` driver**, measured on its own schema on three engines. For a driver of yours declaring `transactionalWrites: true`, the runner requires the census (the `transactions` hook: how to open a transaction of *your* connection and how to count *your* rows) and judges rollback ⇒ zero, commit ⇒ applied, the uncommitted write invisible to the authority and three foreign transactions ⇒ 500 with zero statements — but it **cannot prove that a third-party driver's write really lives and dies with the transaction it is handed**: the census it counts is the one your hook reports, and a driver that opens its own transaction underneath passes the answer checks. Two more honest boundaries: the `true` face only runs where a second connection exists (`sqlite-file`, PostgreSQL, MySQL — the engine jobs of CI are not optional for this capability), so a driver judged only on `:memory:` never sees those cases; and the 409/503 classification of the table above is the `database` driver's — a third-party driver that returns the raw engine error is not caught by the suite. The same honesty for relations: the `listObjectsTruncation` pair of `runRelationsDriverContract` judges the **cap** only (`limits.listMaxResults`); **the runner does not judge the deadline** signal of a third-party `listObjects` (there is no way to observe it without sleeping), so a driver that does not implement it passes — the deadline cause is a property of this package's `openfga` driver, pinned by its own cases and by the surface guard. And the B3 case (a userset other than `group#member` as subject) observes your backend **through the port** — `listSubjects` and, under `enumerateRelations: true`, the whole partition — so a driver that stores the inert row and then throws the 422 goes red only through a port it declares: the minimal harness (`usersetSubjects: false`, `enumerateRelations: false`) has no port through which the runner can see that row, and such a driver passes (measured with that mutant on the double).
|
|
253
253
|
|
|
254
254
|
### Identity is validated, once and everywhere
|
|
255
255
|
|
|
@@ -317,7 +317,7 @@ await authorization.isWithin(unit, currentOrg) // the same question on its own
|
|
|
317
317
|
|
|
318
318
|
**Actor.** Every write (`grant`, `revoke`, `deny`, `removeDeny`, `scopes.*`) takes `{ actor }` — a `SubjectRef`, validated like any identity — which `onWrite` receives as `event.actor`. `requireActor: true` ⇒ a write without it is 422 `E_AUTHZ_ACTOR_REQUIRED` before the driver and before the hook. No `AsyncLocalStorage`: the actor is an explicit argument. For these six writes the engine never evaluates it (who may grant what is your policy) — **but the delegation API of 2.2 does**: there the `actor` is the whole policy, so it must come from the session and never from the request body. See [Scoped roles](#scoped-roles-22).
|
|
319
319
|
|
|
320
|
-
**`manager.driver()` is the documented way out of all of that.** It returns the active driver as-is: writes through it skip `actor`/`requireActor`, `within`/`requireWithin` **and `onWrite`**; reads skip the per-request memo. It exists for platform code (seeders, commands, writing at the root under `'non-root'`) and for tests — a tenant call-site should never call it, and a code review can grep for it. Nothing else is offered through it on purpose.
|
|
320
|
+
**`manager.driver()` is the documented way out of all of that.** It returns the active driver as-is: writes through it skip `actor`/`requireActor`, `within`/`requireWithin` **and `onWrite`**; reads skip the per-request memo. It exists for platform code (seeders, commands, writing at the root under `'non-root'`) and for tests — a tenant call-site should never call it, and a code review can grep for it. Nothing else is offered through it on purpose. **Grep for `relationsDriverFactory` and `relationsDriverKeys` too** (2.4.0-alpha.10): `manager.relationsDriverFactory(key)` returns the factory registered under `relations.drivers[key]` and `manager.relationsDriverKeys()` lists the keys — platform API next to `driver()`, used by `runRelationsReconcile` to resolve source and destination from the manager's own config. With the factory, any code holding the manager (a `forRequest()` view included) can build a relations driver that is **not** the active one and write to it with no `assertWrite`, no `requireActor`, no freeze and no `onRelationWrite`. It adds no real capability — `app.config` already hands out the factories — but it is one more thing to look for in a review. And `runRelationsReconcile(authorization, …)` takes the manager **structurally** (`Pick<AuthorizationManager, 'withFrozenWrites' | 'relationsDriverKey' | 'relationsDriverFactory'>`): an object that fakes `relationsDriverKey` or `relationsDriverFactory` decides which driver is active and which factories are compared, so pass it the service manager, never a hand-built or wrapped one.
|
|
321
321
|
|
|
322
322
|
**Decisions in bulk.** `authorizeMany(subject, permission, scopes)` → `boolean[]` by position, identical to N `authorize` (duplicates, unknown scopes, denies); empty ⇒ `[]` without touching anything; a position that cannot be answered rejects the whole call. `openfga` answers with one `batchCheck` for all chains (a repeated scope shares one slot); `database` composes N `authorize` over a memoised view (one tree call per distinct scope). A third-party driver's `authorizeMany` is validated: a result that is not a `boolean[]` with exactly one position per scope is 500 `E_AUTHZ_INTERNAL` naming the driver (*"authorizeMany valida la respuesta de un driver de terceros"*).
|
|
323
323
|
|
|
@@ -374,7 +374,7 @@ router
|
|
|
374
374
|
|
|
375
375
|
The middleware resolves the authenticated holder from its morph name and asks `authorize` at the **`app` scope**. It accepts **`{ permission }` only**: `appAccess({ role })` was removed in 2.0 — a gate over membership could not be denied — and passing `role` is a 500 `E_AUTHZ_ROLE_IS_NOT_ACCESS` with the recipe (create a permission, link it to the role, gate on the permission), thrown before authentication is checked (*"appAccess({ role }) es 500 E_AUTHZ_ROLE_IS_NOT_ACCESS con la receta"*). Per-organization or per-unit enforcement is your controller's or your own middleware's job: only your domain knows which scope a route belongs to. The holder must expose `uuid`; a numeric-PK model is rejected with an explicit error.
|
|
376
376
|
|
|
377
|
-
### Per-resource enforcement: `resourceAccess` (2.
|
|
377
|
+
### Per-resource enforcement: `resourceAccess` (2.4.0-alpha.1)
|
|
378
378
|
|
|
379
379
|
`appAccess` gates a route at the `app` scope. To gate one **resource** — *this document, in that
|
|
380
380
|
organization* — the `resourceAccess` middleware composes the same `authorize`: your code says how to
|
|
@@ -563,6 +563,48 @@ tenant — and it is **mandatory**: a relation in tenant A never resolves in ten
|
|
|
563
563
|
the mono-tenant value). The partition lives in the object id (`document:<partitionKey>|<uuid>`), not
|
|
564
564
|
in the model.
|
|
565
565
|
|
|
566
|
+
**Spelling is identity (2.5.0, D4).** Relations have no resolver chain to canonicalise against (roles do,
|
|
567
|
+
invariant 17), so the partition key is a string compared byte for byte — and until 2.5.0 the same tenant
|
|
568
|
+
uuid written **without dashes** (32 hex, what PostgreSQL's `uuid` type or a `*_ci` collation fold into the
|
|
569
|
+
dashed row elsewhere) was *another* partition that the canonical purge never saw. Since 2.5.0 **a partition
|
|
570
|
+
uuid that is 32 hex once the dashes are removed and is not exactly `8-4-4-4-12` is 422 `E_AUTHZ_INVALID_IDENTITY`
|
|
571
|
+
in every operation that takes a partition** — the manager, both drivers, the in-memory double,
|
|
572
|
+
`authz:relations:reconcile --partition-uuid` — with zero backend calls, the message naming the canonical form.
|
|
573
|
+
The rule is "every spelling an engine folds", not only "no dashes": PostgreSQL also reads `8-4-4-16`, four groups
|
|
574
|
+
of eight or the canonical form minus one dash as the same `uuid` (`'a0eebc99-9c0b4ef8-bb6d6bb9-bd380a11'::uuid`
|
|
575
|
+
is the canonical row), and each of those was a partition of its own that an offboarding by the canonical spelling
|
|
576
|
+
left alive (the lot A audit, A2; pinned by a truth table of forms and, on PostgreSQL, by measuring the cast). A
|
|
577
|
+
partition id that is *not* a uuid at all — 32 characters that are not all hex, 31 or 33 hex, `md5`-style ids with
|
|
578
|
+
an underscore — is a legal id and is untouched. Rows written with a non-canonical spelling before 2.5.0 are
|
|
579
|
+
**detected, listed and deleted** by [the upgrade recipe](#upgrading-to-250-non-canonical-partition-spellings)
|
|
580
|
+
(fail-closed by default; re-keying them is the explicit `--rekey` step). **Object and
|
|
581
|
+
subject ids are compared byte for byte; use one spelling.** They are *not* rejected: a 32-hex object id may
|
|
582
|
+
be a legitimate non-uuid id, so `{ type: 'document', id: '0192…aa' }` and `{ id: '0192…-…-aa' }` are two
|
|
583
|
+
documents in both directions (pinned by a case), exactly as a holder `uuid` is for roles.
|
|
584
|
+
|
|
585
|
+
**Deciding in bulk: `checkMany` (2.5.0).** `checkMany(subject, relation, objects, partition)` → `boolean[]`
|
|
586
|
+
by position, with the **same semantics as N `check`** (includes, usersets, strict expiry) for the ancestor
|
|
587
|
+
chain or the list you already hold: `database` answers in **one query** (one `now` for the whole batch),
|
|
588
|
+
`openfga` in **one `batchCheck`** (one `current_time` in every item, results aligned by `correlationId`,
|
|
589
|
+
never by position), and a driver that does not implement it is composed by the manager as N `check`s
|
|
590
|
+
(correct, N round trips, each with its own instant). Batches are **1..50** objects (`RELATIONS_CHECK_MANY_MAX`,
|
|
591
|
+
the server's `batchCheck` cap): 51 and `[]` are 422 `E_AUTHZ_CHECK_MANY_BATCH` with zero calls; duplicates
|
|
592
|
+
are allowed and answered by position (`[a, a, b]` ⇒ `[x, x, y]`); object types may be mixed as long as the
|
|
593
|
+
relation is declared for each (F-05 runs **per item**, so a `role_binding` in position 21 of 21 is 422 with
|
|
594
|
+
nothing asked); a per-item `error` inside a 200 `batchCheck` is a **503 for the whole batch**, never a
|
|
595
|
+
`false`. **`checkMany` returns one boolean per object: `false` does not distinguish "denied here" from
|
|
596
|
+
"nothing here", so it cannot implement deny-wins along a chain of objects that you walk yourself** — that is
|
|
597
|
+
`decideMany` (2.5.0, lot D). A third-party driver's answer is validated: anything but a `boolean[]` with one
|
|
598
|
+
position per object is 500 `E_AUTHZ_INTERNAL` naming the driver.
|
|
599
|
+
|
|
600
|
+
**A pure, stable key for a tuple (2.5.0).** `relationTupleKey({ partition, object, relation, subject, kind? })`
|
|
601
|
+
(root export) is deterministic and injective over exactly those five components — the expiry is not part of it
|
|
602
|
+
(renewing a share keeps its key, even though `database` rewrites the row with a new uuid) and neither is any
|
|
603
|
+
backend id. It is what `authz:relations:reconcile` matches source and destination with, and what you can index
|
|
604
|
+
your own metadata by, without the package storing it. `kind` is `'allow'` by default and travels in the key from
|
|
605
|
+
day one (`allow|unit|<uuid>|document:<id>#viewer@user:<uuid>`), so the key of an allow you already hold **does not
|
|
606
|
+
change** when denies (`kind: 'deny'`) arrive in 2.5.0.
|
|
607
|
+
|
|
566
608
|
**The model is shared with the catalog, and so is its byte budget.** In the `openfga` driver,
|
|
567
609
|
relations fuse into the same `facts` model and the same store, so a single `Check` still answers
|
|
568
610
|
each question. The price is one budget: the 262,144-byte model holds **both** your permissions and
|
|
@@ -599,10 +641,14 @@ platform escape hatch) and `authz:relations:reconcile` — which writes through
|
|
|
599
641
|
{ type: 'role_binding', id: <roleUuid> }, S)` turned `roles.authorize(evil)` from `false` to `true`
|
|
600
642
|
(measured against a real server; the bridge spec keeps that case, and removing the driver guard puts
|
|
601
643
|
it back in red). `manager.driver()` still skips the manager's *other* barriers (`actor`,
|
|
602
|
-
`assertWrite`, `onRelationWrite`), as documented — F-05 is no longer one of them.
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
644
|
+
`assertWrite`, `onRelationWrite`), as documented — F-05 is no longer one of them. **The userset subject is part of
|
|
645
|
+
that grammar (2.5.0 · B3)**: the only userset the fused model admits as a subject is `group#member`, so
|
|
646
|
+
`{ object: document, relation: viewer }` as subject is 422 `E_AUTHZ_RELATION_USERSET_UNSUPPORTED` on every operation
|
|
647
|
+
that takes a subject, through the same single function in the manager, both drivers and the published double — until
|
|
648
|
+
2.5.0 `database` stored that row inert and `openfga` surfaced the server's 400 as a 503.
|
|
649
|
+
|
|
650
|
+
**And since `2.4.0-alpha.3` F-05 covers **all nine operations of the port** — the eight of alpha.3 plus
|
|
651
|
+
`checkMany` (per item) since 2.5.0 —, not only the two writes that compose a tuple.** The alpha.3 audit found the chokepoint one call-site short (pre-existing in alpha.2, in
|
|
606
652
|
code alpha.3 had just rewritten): `purgeObject`/`purgeSubject`/`check`/`listObjects`/`listSubjects`/`membersOf`
|
|
607
653
|
never called `assertRelationDeclared`, so in the shared store `purgeObject({ type: 'role_binding', id: R }, S)`
|
|
608
654
|
composed — byte for byte — the id of a real binding and **deleted the real binding** with proof of zero
|
|
@@ -632,24 +678,77 @@ membership (through nested groups). Only the `database` driver has it (a recursi
|
|
|
632
678
|
it is 500 `E_AUTHZ_UNSUPPORTED` naming it — the transitive form is `ListUsers`, which truncates
|
|
633
679
|
without a reliable signal, and we never return a silent partial. `listSubjects` (direct facts,
|
|
634
680
|
invariant 7) works in both. `listObjects` in `openfga` signals `truncated: true` when the server's
|
|
635
|
-
`ListObjects` cap cuts the page
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
681
|
+
`ListObjects` cap cuts the page **and** when the call took **90 % or more of the server's `ListObjects`
|
|
682
|
+
deadline** (`listObjectsDeadlineMs`, default 3,000 ms, OpenFGA's default `--listObjects-deadline`; set it to
|
|
683
|
+
your server's **effective** cut, below) — past its deadline the server answers 200 with whatever it had resolved and **no signal**
|
|
684
|
+
(measured, 2.5.0 · B4; until then the README promised no mute partial list at all, and that was only true of the
|
|
685
|
+
cap). Both causes are one signal, through one envelope (`#listObjectsPage`, measured with a monotonic clock);
|
|
686
|
+
the signal is conservative on purpose: a slow-but-complete call may be flagged, a cut one never passes as
|
|
687
|
+
complete — the price is in [Limits](#limits). `listObjectsDeadlineMs` is the server's **effective** cut, whichever is
|
|
688
|
+
lower of the three — `--listObjects-deadline` (when it is not 0), `--request-timeout` (3 s by default) and, over HTTP,
|
|
689
|
+
the gateway's `--http-upstream-timeout` (5 s by default) — declare that lowest one: a server running with
|
|
690
|
+
`--listObjects-deadline=0` has **no** "no deadline" — its `--request-timeout` still cuts `ListObjects` with a mute 200
|
|
691
|
+
(measured on OpenFGA v1.19.0 by the audit of 2.5.0-alpha.2: `--request-timeout=30ms` answered `200` with 0 of 40,000
|
|
692
|
+
objects; `list_objects.go` only sets its own timeout when the deadline is not 0 and inherits the request-timeout
|
|
693
|
+
context otherwise) — so declare that value instead. `Infinity` is refused (500 `E_AUTHZ_CONFIG`): it would make the
|
|
694
|
+
driver pass that mute list as complete. Both at 0 is not supported (what the HTTP gateway's `--http-upstream-timeout`
|
|
695
|
+
does then is not measured for `ListObjects`; the package promises no "no deadline" it has not measured). And the
|
|
696
|
+
driver's own `timeoutMs` (5 s by default) cannot be lower than `listObjectsDeadlineMs`: the signal has to be able to
|
|
697
|
+
fire before the client cuts, so a deadline above the client timeout is 500 `E_AUTHZ_CONFIG` at construction
|
|
698
|
+
(`Number.MAX_SAFE_INTEGER` was "no deadline" under another name, 2.5.0-alpha.2 · closure 3). A 200 **without
|
|
699
|
+
`objects`** is 503, not an empty list. The `Read`-based enumerations — `listSubjects`, `enumerateRelations(Many)`,
|
|
700
|
+
the purges — are **exhaustive or 503**: they page until the continuation token is empty and throw on a response
|
|
701
|
+
without `tuples`, a repeating token or more than 10,000 pages (`E_AUTHZ_BACKEND_UNAVAILABLE`), never a short page.
|
|
702
|
+
The roles driver's `Read`s (`readAllTuples`, the reconcile dump, `enumerateFacts`) hold the same rule since
|
|
703
|
+
`2.5.0-alpha.2`.
|
|
704
|
+
|
|
705
|
+
**The config can be persisted, and nothing republishes the model behind your back.** `saveRelationsConfig(spec)`
|
|
706
|
+
stores the relations config in `authz_relations_config` under the shared version gate (invariant 14), so a
|
|
707
|
+
process without `defineRelationsConfig` in memory can read the declared types (`readRelationsConfig()`,
|
|
708
|
+
`null` when nothing was ever saved). **The fused model is published by one thing only: `node ace
|
|
709
|
+
openfga:provision`**, from the *static* config (`relations.config` and the `catalogs`) — the command boots
|
|
710
|
+
without a database and cannot read the table —, and re-publishing it is a manual operator step (single writer:
|
|
711
|
+
the `modelId` goes to every pod by hand, see [the cutover window](#the-cutover-window)). Neither
|
|
712
|
+
`syncAuthzCatalog` nor a config save republishes anything. The persisted copy has one reader today:
|
|
713
|
+
`authz:relations:reconcile`, which uses it as the **destination's** config to flag model drift — and since
|
|
714
|
+
2.5.0 a *failed* read of that table stops the command before any write (an unreadable config is not "no
|
|
715
|
+
config"; `null` is).
|
|
644
716
|
|
|
645
717
|
**Migrating tuples between drivers** is `node ace authz:relations:reconcile --to=<key>` — the relations
|
|
646
718
|
analog of `authz:reconcile`, idempotent, bidirectional and never silent (it reports written / deleted
|
|
647
719
|
/ unchanged / extra). `--to`/`--from` are keys of `relations.drivers` in `config/authorization.ts`;
|
|
648
720
|
`--dry-run` is the read-only verifier and also flags **model drift** (an object type in the source the
|
|
649
|
-
destination does not declare); `--prune` deletes what the source no longer backs
|
|
721
|
+
destination does not declare); `--prune` deletes what the source no longer backs — except a destination row the API
|
|
722
|
+
does not admit (`details`: `invalid-identity`, `userset-unsupported`, `undeclared` — a relation or type the
|
|
723
|
+
destination no longer declares), which no flag touches: listed, exit ≠ 0 in every mode, deleted by hand or, for
|
|
724
|
+
`undeclared`, declared again ([Upgrading, 6](#upgrading-to-250)); a 422 of the relations family from the destination's
|
|
725
|
+
`unrelate` is classified the same way and never aborts the pass. **Never towards the
|
|
726
|
+
ACTIVE relations driver** (2.4.0-alpha.10): once `relations.default ?? config.default` is `--to`, any
|
|
727
|
+
other source — with `--from` or picked by elimination — is 500 `E_AUTHZ_STALE_SOURCE_REFUSED` (it
|
|
728
|
+
would resurrect shares revoked since the cutover and, with `--prune`, delete the new ones); `--dry-run`
|
|
729
|
+
flags it, and `--allow-stale-source` is the human decision. It migrates **facts
|
|
650
730
|
only** — there is no tree or catalog in `relations/` — and works **per partition** (`--partition-type`
|
|
651
|
-
/`--partition-uuid`; default `app
|
|
652
|
-
|
|
731
|
+
/`--partition-uuid`; default `app`). **From code, the one public way is `runRelationsReconcile`** —
|
|
732
|
+
`import { runRelationsReconcile } from '@jantstack/adonis-authz/relations/reconcile'` (its own subpath since 2.5.0; until
|
|
733
|
+
`2.5.0-alpha.1` it was `…/commands/authz_relations_reconcile`, which no longer exports it) and
|
|
734
|
+
`runRelationsReconcile(authorization, { toKey, fromKey, partitions: [...] })` migrates several partitions in one
|
|
735
|
+
pass (see the sweep below), with the same guard and the same durable window as the command. Source and
|
|
736
|
+
destination are **keys of `relations.drivers`, resolved from the manager's own config**, and the guard compares
|
|
737
|
+
the *resolved* driver, not the label: two keys that point at the same factory are the same driver (an alias of the
|
|
738
|
+
active one is refused too; source = destination is 500 `E_AUTHZ_CONFIG`), and the active driver is always the
|
|
739
|
+
manager's — there is no `activeKey` to contradict it. **Declared limit: identity is the factory reference, not the
|
|
740
|
+
backend.** Two *different* factories that build the same store — `openfga: () => new OpenFgaRelationsDriver(…)` and an
|
|
741
|
+
alias `fga: () => new OpenFgaRelationsDriver(…)` with the same `OPENFGA_STORE_ID`, which is how an alias is naturally
|
|
742
|
+
written in a config — are **not** detected: `--to=fga --from=database` passes the guard and resurrects what was revoked.
|
|
743
|
+
Nothing is built before the guard, so there is no backend to compare. Register an alias with the *same* reference
|
|
744
|
+
(`const fga = () => …` and `{ openfga: fga, alias: fga }`) or do not register it; a backend identity declared by the
|
|
745
|
+
driver is on the 2.5 list. Until the closing lot of 2.4.0-alpha.10 the primitive
|
|
746
|
+
`reconcileRelations` was exported from the package root and this paragraph recommended it for K partitions: it
|
|
747
|
+
never saw the active driver nor took the window, so a loop written after the cutover resurrected revoked shares
|
|
748
|
+
with the fleet still writing. It is no longer public. **The guard reads the config of the process that runs it**:
|
|
749
|
+
`relations.default ?? config.default` *there* — a job or pod started with another environment (say, without
|
|
750
|
+
`AUTHZ_DRIVER`) believes another driver is active and the guard is off. Run it with the fleet's config; a durable
|
|
751
|
+
seal of the active driver is on the 2.5 list. **The pass that writes runs under the durable freeze** (2.4.0-alpha.2,
|
|
653
752
|
lot L-1), exactly like `authz:reconcile`: every write of the package — roles *and* relations — answers a
|
|
654
753
|
retryable 503 `E_AUTHZ_FROZEN` fleet-wide while it runs; a live **operator** window (`authz:freeze`) is its
|
|
655
754
|
own context (it runs inside, does not renew or lift it); a live freeze of another pass is 423; and the
|
|
@@ -667,7 +766,7 @@ means "this partition is empty" and in a purge "proved zero" without deleting an
|
|
|
667
766
|
that way; the suite spies the filter of every `read` and it is always `{}`. What *is* cheaper is doing the
|
|
668
767
|
sweep **once for K partitions**: `enumerateRelationsMany(partitions)` (optional port method, implemented by
|
|
669
768
|
`openfga`; `RelationsManager` exposes it and falls back to K calls for a driver without it) and
|
|
670
|
-
`
|
|
769
|
+
`runRelationsReconcile(authorization, { toKey, fromKey, partitions })`, which enumerates source and destination once, decides the K diffs
|
|
671
770
|
first (the mass-delete guard R-17 is evaluated for every partition **before** writing anything) and then
|
|
672
771
|
applies them; the report is the sum. Same output per partition, byte for byte, as K separate calls; `app`
|
|
673
772
|
does not pull `appliance|…`, `org|U` does not pull `organization|U`, and each spelling of a uuid is its own
|
|
@@ -768,11 +867,13 @@ Every error the package raises carries `status` and `code`. A standard AdonisJS
|
|
|
768
867
|
| `E_AUTHZ_FREEZE_HELD` | 423 | `freeze()` (or a second `authz:reconcile`) found a live freeze owned by someone else: two windows never interleave, and only the owner's token — or `authz:unfreeze` — lifts one. The message names the holder, the reason and the fence |
|
|
769
868
|
| `E_AUTHZ_RESOLVER_FAILED` | 503 | your `resolveChain`, `parentOf` or `descendantsOf` threw or answered a malformed scope; `descendantsOf` and `resolveChain` disagree in `authorizedScopes`; a subtree to exclude cannot be enumerated |
|
|
770
869
|
| `E_AUTHZ_WRITE_CONFLICT` | 409 | an `openfga` write kept clashing with another transaction over the same tuples (FGA answers `Aborted`/409, or 400 "cannot write a tuple which already exists"): the driver re-reads and re-applies, and only gives up after three rounds. The backend answered, so this is never a 503 — retry the write. Also (2.4.0-alpha.2, lot L-3) a `database` `grant`/`deny` **inside your transaction** (`{ transaction }`) that hits the UNIQUE because another transaction committed the same fact while yours was open: outside a transaction the driver re-reads and keeps the winner's row; inside yours it cannot (PostgreSQL has already aborted your transaction — every later statement is `25P02` until you roll back — and under REPEATABLE READ the re-read would not see the winner), so it **poisons your transaction**: roll back and retry. Measured on all three engines, see [writing inside your transaction](#writing-inside-your-transaction-240-alpha2). The same for a `database` `relate`/`unrelate`/`purge*` inside your transaction (lot L-4) — on a UNIQUE clash of a tuple (userset, and since lot L-4b holder too: `subject_relation` is `''`, never `NULL`), and on a **deadlock** (two transactions writing two tuples in crossed order: PostgreSQL `40P01`, MySQL `1213`; the engine's victim gets this 409, never a hang nor the raw error) |
|
|
771
|
-
| `E_AUTHZ_CONFIG` | 500 | `relayScopeChanges({ only: [] })`, a `ScopeOutbox` that ignores `pending(limit, after, { ids })` and returns an entry that was not asked for, `requeueScopeChanges` with an id that is not parked (or an empty list), or a `scopes.maxDepth` that is not an integer ≥ 1 (2.4.0-alpha.5); contradictory config (`holderTypes` not injective or a holder type not declared in it, `scopes.*` without resolver, `appAccess` without `permission`, `catalog` together with `catalogRevalidate`, an invalid `maxAgeMs`); `bumpAuthzCatalogVersion` called without the writing transaction's client; a `{ transaction }` handed to `sqlScopeOutbox` that is not an open Lucid transaction of the outbox's own connection (another connection, a `QueryClient`, or the whole `db` — the message names both connections), **or one that is already committed or rolled back** (`isCompleted`, 2.4.0-alpha.8 — the same check in the roles and relations drivers; it used to surface as a 503); `sqlScopeOutbox(
|
|
870
|
+
| `E_AUTHZ_CONFIG` | 500 | `relayScopeChanges({ only: [] })`, a `ScopeOutbox` that ignores `pending(limit, after, { ids })` and returns an entry that was not asked for, `requeueScopeChanges` with an id that is not parked (or an empty list), or a `scopes.maxDepth` that is not an integer ≥ 1 (2.4.0-alpha.5); contradictory config (`holderTypes` not injective or a holder type not declared in it, `scopes.*` without resolver, `appAccess` without `permission`, `catalog` together with `catalogRevalidate`, an invalid `maxAgeMs`); `bumpAuthzCatalogVersion` called without the writing transaction's client; a `{ transaction }` handed to `sqlScopeOutbox` that is not an open Lucid transaction of the outbox's own connection (another connection, a `QueryClient`, or the whole `db` — the message names both connections), **or one that is already committed or rolled back** (`isCompleted`, 2.4.0-alpha.8 — the same check in the roles and relations drivers; it used to surface as a 503); `sqlScopeOutbox()` enqueueing without `{ transaction }` (`requireTransaction`, 2.4.0-alpha.8; `true` by default since 2.5.0 · B6, `false` is the explicit opt-out); `relayScopeChanges({ deadDetail })` with a value other than `'full'`; `requireTransactionalWrites: true` (root, or `relations.requireTransactionalWrites`) with an active driver that does not declare `transactionalWrites: true` — raised **when the driver is resolved**, so reads fail too and the deployment does not start (2.4); a `relations.assertWrite` that returns a promise — or any value at all: `false`, `true`, a thenable function (it must be synchronous and it does not return verdicts: it throws or it does not; the write is refused before the driver, 2.4.0-alpha.3) |
|
|
772
871
|
| `E_AUTHZ_ROLE_IS_NOT_ACCESS` | 500 | `appAccess({ role })` |
|
|
773
872
|
| `E_AUTHZ_INTERNAL` | 500 | package invariant violated (empty scope set on a write, misaligned batch, a third-party `authorizeMany` answering the wrong shape, a `Read` continuation token that never advances or more than 10,000 pages, a corrupt `assignable_at`/`owner_scope_key` row) |
|
|
774
873
|
| `E_AUTHZ_PURGE_INCOMPLETE` | 500 | `purgeScope` could not prove zero; on the relations port, `purgeObject`/`purgeSubject` of `openfga` could not prove zero after deleting — the error carries the `markPartialWrite` mark and the event was emitted with `indeterminate: true` (2.4.0-alpha.3) |
|
|
775
874
|
| `E_AUTHZ_RECONCILE_TOO_LARGE` | 500 | the destination's dump does not fit the declared `--max-tuples` cap (default 1 000 000) and reconciling needs the whole snapshot to know what is left over. Nothing was written; raise the cap if your process can take it |
|
|
875
|
+
| `E_AUTHZ_STALE_SOURCE_REFUSED` | 500 | `authz:reconcile --to=<config.default>` (or `authz:relations:reconcile --to=<relations.default ?? default>`) with any resolved source other than the destination itself — `--from`, picked by elimination, or `authz_*` (2.4.0-alpha.10): the stale direction — the source stopped seeing the active driver's facts at the cutover, so the pass would resurrect every revocation made since. Nothing was read, frozen or written; for roles the maintenance pass is `--to=<active>` without `--from`, and `--allow-stale-source` is the human decision (`--dry-run` flags `report.staleSource` instead of throwing) |
|
|
876
|
+
| `E_AUTHZ_OPERATOR_WINDOW_EXPIRED` | 503 | `freeze()` — and with it `authz:reconcile`, `authz:relations:reconcile`, `withFrozenWrites`, `authz:freeze` — found the OPERATOR window expired (2.4.0-alpha.10): taking a freeze over it would erase the trace that writers resumed mid-cutover. Acknowledge it with `authz:unfreeze`, reopen with `authz:freeze` |
|
|
776
877
|
| `E_AUTHZ_MASS_RECONCILE_REFUSED` | 500 | `authz:reconcile --prune` would delete facts from the destination while the source has not contributed a single **usable** one — empty, or every fact it returned was skipped (expired, on scopes your tree no longer resolves): the signature of a wrong connection, of the wrong source, or of a blind resolver. Nothing was deleted; pass `--allow-mass-delete` if you really mean to empty it (`--dry-run` flags it instead of throwing). A source that is merely **stale** but still usable is covered by reading the facts from whoever owns them |
|
|
777
878
|
| `E_AUTHZ_MASS_PURGE_REFUSED` | 500 | `pruneOrphanRoles({ force: true })` would purge every distinct owner (or more than half the local roles): the signature of a blind `resolveChain`. Nothing was deleted; pass `allowMassPurge: true` (`--allow-mass-purge`) if the prune is real |
|
|
778
879
|
| `E_AUTHZ_TOO_MANY_LOCAL_ROLES` | 500 | more local roles than `maxLocalRoles` (10 000) in a `prune-orphans` pass; never a partial list |
|
|
@@ -795,6 +896,42 @@ Both also take **`catalogRevalidate`** (`'always'`, the default, or `{ everyMs }
|
|
|
795
896
|
|
|
796
897
|
Both take **`now`** (default `() => new Date()`): the wall clock every time-based *decision* uses — `expires_at > now()` in SQL, the `current_time` of every FGA check (one instant per operation: every check of a `batchCheck` carries the same `current_time`, and the two reads of `listScopes` filter with the same `now`), the client-side expiry filter of the enumerations and the three states of a re-grant. The audit stamps (`created_at`) are **not** decisions and use the system clock: with MySQL's `TIMESTAMP` an injected clock in 2040 made every write fail (*"… se escribe estando el reloj en 2040"*). Every driver of the package also implements `withClock(now)` on the port (a view bound to another clock, like `withChainResolver`), and the manager applies **`clock`** from the config to the driver it resolves — all `forRequest()` views share it; a config `clock` over a driver without `withClock` is 500 `E_AUTHZ_CONFIG`, never a clock silently ignored. It exists so that expiry is observable *without sleeping* (the contract fixes the exact instant: one millisecond before `expiresAt` grants, at `expiresAt` it does not — *"caducidad exacta con el reloj inyectado"*) and so that your own tests can freeze time; in production leave it alone and keep NTP running. It is not the monotonic clock of `forRequest({ maxAgeMs })`, which measures a window and must not move with NTP. Nothing else in `src/` reads the wall clock (a grep test pins it) — except the model trait `withAuthzScopes` (`whereRoles`/`wherePermissions`), which cannot see the manager: it decides "live" with the system clock unless you compose it with the same clock, `compose(BaseModel, withAuthzScopes({ clock }))` (*"withAuthzScopes({ clock }) decide la vigencia con ESE reloj"*). Its primary-key comparison is dialect-aware (on PostgreSQL a `uuid` primary key is cast to text against the `varchar` subquery: *"… con la clave primaria uuid nativa del modelo"*).
|
|
797
898
|
|
|
899
|
+
### Testing expiry from your app (2.5.0)
|
|
900
|
+
|
|
901
|
+
Expiry, renewal and "it expires right now" are decided by that one injectable clock, so your own tests can fix
|
|
902
|
+
the instant instead of sleeping. `createTestClock(start?)` (from **`@jantstack/adonis-authz/testing/clock`** — a
|
|
903
|
+
light module with no test-framework or Lucid import, so it is safe in a config file that production loads; `/testing`
|
|
904
|
+
re-exports it but pulls `@japa/runner` and `@adonisjs/lucid/services/db`, which an `--omit=dev` deploy does not have) returns a
|
|
905
|
+
**stable function over a mutable instant** — the same reference lives in your config for the whole test
|
|
906
|
+
process; `set(at)`, `advance(ms)`, `now()` and `at(offsetMs)` move or read it — and it mutates nothing global
|
|
907
|
+
(`systemClock`, the audit stamps and whatever does not receive the clock stay on wall time):
|
|
908
|
+
|
|
909
|
+
```ts
|
|
910
|
+
// config/authorization.ts
|
|
911
|
+
import { createTestClock } from '@jantstack/adonis-authz/testing/clock' // never from '/testing' here: that module imports Japa and Lucid
|
|
912
|
+
export const testClock = createTestClock('2030-01-01T00:00:00.000Z')
|
|
913
|
+
export default defineConfig({ …, clock: app.inTest ? testClock : undefined })
|
|
914
|
+
|
|
915
|
+
// tests/sharing.spec.ts
|
|
916
|
+
await relations.relate(user, 'viewer', doc, tenant, { expiresAt: testClock.at(+3_600_000) })
|
|
917
|
+
assert.isTrue(await relations.check(user, 'viewer', doc, tenant))
|
|
918
|
+
testClock.advance(3_600_000) // the share expires right now (strict)
|
|
919
|
+
assert.isFalse(await relations.check(user, 'viewer', doc, tenant))
|
|
920
|
+
```
|
|
921
|
+
|
|
922
|
+
The provider hands `config.clock` to **both** managers (roles and relations), and each applies it to its driver
|
|
923
|
+
with `withClock`, so one clock decides every expiry in the process — the suite pins that wiring with a case that
|
|
924
|
+
turns red if the relations manager stopped receiving it (P5-1). **Two things to know.** First, **the same clock
|
|
925
|
+
decides the freeze barrier and the reconcile lease**: a frozen window with a lease is "expired" for a process
|
|
926
|
+
whose clock has moved past `untilMs` (advance it 16 s and a pass sees its 15 s lease lapsed; freeze it and an
|
|
927
|
+
operator window with a lease never expires for that process). That is also what it means in production: **the
|
|
928
|
+
lease is judged by the clock of the process that reads the freeze row**, against an `untilMs` written with the
|
|
929
|
+
clock of the one that froze — a writer whose wall clock runs ahead by more than the lease writes inside the window
|
|
930
|
+
of a running pass (pre-existing, not introduced by the injectable clock; windows without a lease are not affected).
|
|
931
|
+
Keep NTP on every process that writes. Second, a `relate` that reads a *ticking* clock
|
|
932
|
+
(one advancing per read) is fine, but it is your test's clock: production leaves `clock` alone and keeps NTP
|
|
933
|
+
running.
|
|
934
|
+
|
|
798
935
|
`openfga` additionally takes `holderTypes` (required, injective; a holder whose morph name is not in it is 500 `E_AUTHZ_CONFIG`), `modelId`, a `logger` (default `console`), **`retryParams`** (default `{ maxRetry: 0 }`, see `indeterminate` above) and **`consistency`**: `'higher_consistency'` (default) or `'minimize_latency'`. The default protects the "removing the deny restores" promise against a server started with `--check-query-cache-enabled`, where a fresh revoke or deny would keep granting for up to the cache TTL; `minimize_latency` is the explicit opt-out (*"todo check lleva context.current_time; toda llamada HIGHER_CONSISTENCY"*). `driver.diagnostics.unparseableBindings` counts store tuples the engine cannot interpret — binding ids it does not understand and malformed tuples alike; each one is logged, never skipped in silence.
|
|
799
936
|
|
|
800
937
|
**The `openfga` driver *is* the `facts` mode, and there is no other (2.3, breaking).** The scope tree is materialised in the store as one `scope:<child>#parent@scope:<parent>` edge per node, which is what the model needs to inherit downwards without asking your database. `authorization.scopes.attached/moved/detached` maintain those edges: `moved` is one `Read` plus one atomic `Write` carrying the delete of the old parent and the write of the new one, and `detached` removes the edge **after** `purgeScope` has proved the facts of that scope are gone. Finding more than one parent for a scope is 500 `E_AUTHZ_SCOPE_TREE_DRIFT`: the package writes one edge per node, so two means something else writes to your store, and it is reported rather than silently "fixed".
|
|
@@ -1030,8 +1167,8 @@ This is the **only** migration and verification primitive of the package, and th
|
|
|
1030
1167
|
|
|
1031
1168
|
**Whoever owns the facts is where they are read from (2.3, and it is the whole safety of this command).** `--to=openfga` used to read `authz_assignments` / `authz_denies` *always*. In a deployment that has already cut over to `facts` those tables are **not** the source of truth of the facts — the store is, and nothing keeps them in sync after the cutover — so a pass that rebuilt from them re-wrote grants you had revoked (no flag needed) and, with `--prune`, deleted the denies that only lived in the store. So the pass now asks first, and **says the answer in its first line** (`report.factsFrom`):
|
|
1032
1169
|
|
|
1033
|
-
- **`--to` is the *active* driver and its facts live in its own backend** (`capabilities.hierarchyFacts`, which is what `openfga` declares): then its facts are its own and it is read from *itself*. This is the **maintenance pass**: it rebuilds everything **derived** — root marker, catalog projection, tree — and applies the visibility sweep of invariant 18 with the tree and the catalog of *today*, and it
|
|
1034
|
-
- **`--from=<driver>` is given**: you decide. If that driver's facts are `authz_*` (the package's `database`), `--to=openfga` is the **one-way migration** it always was, and it will happily overwrite the destination's facts with what those tables hold. That is what you want while migrating — and it is a loaded gun pointed at a store that is already live, which is why it now takes an explicit `--from` to fire it.
|
|
1170
|
+
- **`--to` is the *active* driver and its facts live in its own backend** (`capabilities.hierarchyFacts`, which is what `openfga` declares): then its facts are its own and it is read from *itself*. This is the **maintenance pass**: it rebuilds everything **derived** — root marker, catalog projection, tree — and applies the visibility sweep of invariant 18 with the tree and the catalog of *today*, and **it writes no fact; with `--prune` it deletes the facts of scopes that no longer resolve**, the expired ones and those of roles or permissions the catalog or the model no longer has (and the leftovers) — without `--prune` it deletes no assignment or deny (only the role-visibility edges invariant 18 forbids) and reports them as `extra-fact`. (Until 2.4.0-alpha.9 this line, and the command's first line, claimed the pass deleted nothing while `--prune` deleted them — measured by a consumer.) It is also what makes `--dry-run` usable as the CI verifier of a `facts` deployment: a correct store now comes out **clean** instead of reporting every live fact as `extra-fact`. (A driver in that position that cannot enumerate its own facts is 500 `E_AUTHZ_UNSUPPORTED` naming `enumerateFacts` — reading `authz_*` for it would be the bug.)
|
|
1171
|
+
- **`--from=<driver>` is given**: you decide. If that driver's facts are `authz_*` (the package's `database`), `--to=openfga` is the **one-way migration** it always was, and it will happily overwrite the destination's facts with what those tables hold. That is what you want while migrating — and it is a loaded gun pointed at a store that is already live, which is why it now takes an explicit `--from` to fire it. **Except towards the active driver (2.4.0-alpha.10, breaking):** if `--to` is `config.default`, the only legitimate source is `--to` itself (the maintenance pass above); any other **resolved** source — named with `--from`, picked by elimination without `--from` (with `database` active, `--to=database` alone picks `openfga`), or the `authz_*` tables — makes the manager refuse with 500 `E_AUTHZ_STALE_SOURCE_REFUSED` before resolving the source, reading or freezing anything. That is the **stale direction**: once the engine serves from `--to`, its facts are written there and the source stopped seeing them, so the pass would re-write what was revoked since the cutover (with or without `--prune`; and the mass-deletion guard does not fire, because the source is stale, not empty). The guard looks at `--to` and at the source it resolves, never at the flag — and at the *resolved* driver, not the key: the same factory registered under two keys is the same driver, so an alias of `config.default` is refused too (**declared limit**: identity is the factory *reference*, so two different factories that build the same backend are not detected and such an alias passes; register an alias with the same reference, and a backend identity declared by the driver is on the 2.5 list) — so the four rows are: maintenance pass — runs; initial cutover (`--to=<new>` while the engine still serves from the old one) — runs; going back **before** switching `default` — runs; re-running either of the two **after** switching `default` — refused. `authz:relations:reconcile` applies the same rule with the active relations driver (below). `--allow-stale-source` (`allowStaleSource: true`) is the human decision; `--dry-run` never throws, it flags `report.staleSource = { to, from, allowed }`, prints a warning and exits non-zero. **The guard is only as reliable as the `config.default` of the process that runs the command**: it has no durable record of what the fleet serves from, so a migration job or pod started with a different environment (`default: env.get('AUTHZ_DRIVER', 'database')` without that variable) believes another driver is active and the guard is silently off. Run it with the fleet's config; a durable seal of the active driver is on the 2.5 list.
|
|
1035
1172
|
- **Otherwise** (the destination is not the active driver): the migration of always, facts read from `authz_*`.
|
|
1036
1173
|
|
|
1037
1174
|
**It needs `scopes.enumerateEdges`** in your config: the whole tree, paginated with a cursor. `sqlScopeEdges({ table, uuidColumn, parentColumn, typeColumn })` implements it over a table with a parent column, exactly like `sqlDescendantsOf`. Without it the command refuses (500 `E_AUTHZ_CONFIG`) instead of assuming a flat tree — a flat tree would be an invented hierarchy, and an invented hierarchy grants.
|
|
@@ -1071,19 +1208,40 @@ The dangerous interval is not the pass: it is **[end of the last pass → the la
|
|
|
1071
1208
|
```bash
|
|
1072
1209
|
node ace authz:freeze --reason="cutover to openfga" # open the window: fleet-wide 503 on writes
|
|
1073
1210
|
node ace authz:reconcile --to=openfga --from=database # the pass RECOGNISES the operator window
|
|
1074
|
-
#
|
|
1211
|
+
# if you use relations/: one pass PER PARTITION, inside the same window
|
|
1212
|
+
node ace authz:relations:reconcile --to=openfga --from=database [--partition-type=<t> --partition-uuid=<id>]
|
|
1213
|
+
# VERIFY HERE, before switching config.default: openfga is not active yet, so this is a plain verifier
|
|
1214
|
+
node ace authz:reconcile --to=openfga --from=database --dry-run
|
|
1215
|
+
node ace authz:relations:reconcile --to=openfga --from=database --dry-run [--partition-type=<t> --partition-uuid=<id>]
|
|
1216
|
+
# … only when both come out clean: switch config.default (and relations.default, if you set it), redeploy …
|
|
1217
|
+
node ace authz:reconcile --to=openfga --dry-run # after the switch: the MAINTENANCE verifier, no --from
|
|
1075
1218
|
node ace authz:unfreeze # close the window
|
|
1076
1219
|
```
|
|
1077
1220
|
|
|
1221
|
+
**Verify before the switch, never with `--from` after it (2.4.0-alpha.10).** Once `config.default` is `openfga`,
|
|
1222
|
+
`--to=openfga --from=database` is the stale direction by definition — even inside the same live operator window,
|
|
1223
|
+
where nobody has written since the migration — so its `--dry-run` comes out as drift (exit 1) and the real pass is
|
|
1224
|
+
500 `E_AUTHZ_STALE_SOURCE_REFUSED`. There is deliberately **no exception for a live window**: a check that is only
|
|
1225
|
+
green in one narrow moment teaches operators to reach for `--allow-stale-source`, which is the one flag that must stay
|
|
1226
|
+
rare. So the runbook verifies while the destination is not active yet, and after the switch the roles verifier is the
|
|
1227
|
+
maintenance pass (`--to=openfga --dry-run`, no `--from`: it reads the store itself). Relations have no maintenance
|
|
1228
|
+
pass: their verification is the pre-switch `--dry-run`.
|
|
1229
|
+
|
|
1230
|
+
**Relations move with `config.default` (2.4.0-alpha.10).** The relations service resolves its driver as **`relations.default ?? config.default`**: if you did not set `relations.default`, switching `config.default` switches `relations/` too — and a store with zero relation tuples denies every `check` (fail-closed, total), while `authz:reconcile --to=openfga --dry-run` comes out clean (it does not govern relation tuples). So the relations pass belongs to the cutover window, **one partition per pass** (`authz:relations:reconcile` is per partition; a multi-tenant migration is a loop you write), before the switch. If you set `relations.default`, it is that key — not `config.default` — that decides which relations driver serves, and the stale-direction guard of `authz:relations:reconcile` uses the same resolution: once `relations.default ?? config.default` is the destination, no other source is accepted (500 `E_AUTHZ_STALE_SOURCE_REFUSED`, with or without `--from`, unless `--allow-stale-source`; `--dry-run` flags it). There is no maintenance pass for relations: after the cutover the active relations driver *is* the source of truth.
|
|
1231
|
+
|
|
1078
1232
|
`authz:reconcile` treats a live **operator** freeze as its own context: it runs inside it, does not take a second freeze, does not renew it and does not lift it when the pass ends — and its report's `frozen.fence` names the window it ran in (with `lapsed: true` if the window did not survive the whole pass). A live freeze of *another* pass is 423 instead: two migrations never interleave. **The operator window does not expire by default** — a cutover has no known duration in advance (window length and outage tolerance are independent magnitudes, the same argument that killed a fixed TTL), and a window that expires mid-cutover silently hands back exactly the fail-open this mechanism exists to close. The declared price: forget `authz:unfreeze` and the fleet cannot write until someone runs it — a *loud* incident (every 503 names the reason and the command that lifts it), not an invisible loss. `--lease-ms` is the opposite opt-in: the window lifts itself after that many milliseconds with nobody renewing it (the command has exited), and *its* declared price is that a cutover slower than the lease resumes the writers silently, mid-cutover. `authz:unfreeze` lifts an operator window; it refuses to lift a live pass's freeze unless you pass `--fence=<n>` (the explicit human decision for a process that died without a lease — and a stale fence lifts nothing).
|
|
1079
1233
|
|
|
1080
|
-
|
|
1234
|
+
**An expired operator window is evidence, not free space (2.4.0-alpha.10).** A `reconcile` (roles or relations) — or any `freeze()` — that finds the operator window **expired** no longer takes a freeze of its own over it: until 2.4.0-alpha.9 it certified itself with its own 15 s window and, on release, cleared the row, erasing the only trace that writers had resumed mid-cutover. It is now 503 `E_AUTHZ_OPERATOR_WINDOW_EXPIRED` naming when the window expired; acknowledge it with `authz:unfreeze` and reopen it with `authz:freeze`. (An expired freeze of a *pass* is still taken over — that is the lease after a `SIGKILL`.) And both reconcile commands now always say **which window** the pass ran in: inside the operator window (fence N), or a window of its own because no operator window was live — until alpha.9 those two outputs were identical.
|
|
1235
|
+
|
|
1236
|
+
**`authz:unfreeze` on a window that already EXPIRED is an error, not a no-op (2.4.0-alpha.10).** A window opened with `--lease-ms` that expired before `authz:unfreeze` used to look exactly like "there was no window" and the command said success — the last step of the cutover hid that the writers had resumed halfway, i.e. writes the new engine may not have seen. The row tells them apart (lifting clears the reason; an expired window keeps it, with its expiry in the past), so `authz:unfreeze` now prints an error naming when the window expired (ISO) and for how long the fleet has been writing, **exits non-zero**, and clears the row — a second `authz:unfreeze` is a plain success, and "there never was a window / already lifted" is still success. `manager.lastFreezeStatus()` is the reader that also returns the expired row (`expired: true`); `freezeStatus()` still returns only a live freeze. Don't call the cutover done: repeat the migration inside a window without expiry. And a pass that runs inside an operator window with an expiry now says so: `report.frozen.untilMs` is the window's expiry and `frozen.leaseMs` what it had left when the pass adopted it (`null` only for a window that does not expire).
|
|
1237
|
+
|
|
1238
|
+
**`--prune` refuses to run blind.** If it would delete facts while the source has not contributed a single **usable** one, it stops with 500 `E_AUTHZ_MASS_RECONCILE_REFUSED` before writing anything: that is the signature of a wrong connection, of the wrong source, or of a resolver that no longer resolves any of the source's scopes. Until 2.3 the guard looked at the *raw* count — incremented before each skip — so a source whose facts were **all discarded** (every one expired, or on scopes your tree no longer knows) disarmed it and `--prune` emptied the destination with a green report; now a fact only counts if it actually backs something the pass would keep, and the error says how many were read and discarded. `--allow-mass-delete` is the human decision; `--dry-run` never throws, it flags it. A source that is merely **stale** but still partially usable is not this guard's job: what protects you there is the rule above (the facts are read from whoever owns them) and, when `--from` points at a stale source towards the active driver, `E_AUTHZ_STALE_SOURCE_REFUSED`.
|
|
1081
1239
|
|
|
1082
1240
|
**The declared cap on the dump (2.3).** Reconciling means comparing against the **whole** state of the destination — without that snapshot there is no way to know what is left over, and "what is left over" is half the job. The *source* is read in batches with a cursor; the destination snapshot is not, and it is held in memory. That is declared rather than hidden: above `--max-tuples` (default 1 000 000) the pass refuses with 500 `E_AUTHZ_RECONCILE_TOO_LARGE` **before writing anything**, naming the cap. There is no partitioned migration in this version, and "resumable" means *idempotent and repeatable* — a pass that was interrupted is resumed by running it again; the second one converges and what was already written comes back as `unchanged` — not "a cursor persisted between runs".
|
|
1083
1241
|
|
|
1084
1242
|
**Migration is not lossless, and the losses are declared.** Run `runMigrationContract` (below) against your own pair: it seeds a fixed fixture, asks **448 identical questions** on both ends and fails if a single answer changes without a declared loss, if the pass counted a skip you did not declare, or if a seeded fact is simply **missing from the destination** with nothing declaring it — that last one is the census, and it does not depend on any of the 448 moving.
|
|
1085
1243
|
|
|
1086
|
-
**The way out of a store written by the previous version.** After 2.3 "a store written by the previous version is not read by this one": the old importer wrote assignments without the two (c2) edges and denies as `deny_binding` objects, a type the current model does not declare. `authz:reconcile --to=openfga --from=database --prune` rebuilds the store from `authz_*` — the source of truth *of that migration*, named out loud
|
|
1244
|
+
**The way out of a store written by the previous version.** After 2.3 "a store written by the previous version is not read by this one": the old importer wrote assignments without the two (c2) edges and denies as `deny_binding` objects, a type the current model does not declare. `authz:reconcile --to=openfga --from=database --prune` rebuilds the store from `authz_*` — the source of truth *of that migration*, named out loud — and clears the leftovers — a tuple whose type the model no longer declares can still be read and deleted (measured against the server). That store grants again after one pass. **Run it while `database` is still `config.default`** (2.4.0-alpha.10): if `openfga` is already the active driver, that command is the stale direction and the manager refuses it with 500 `E_AUTHZ_STALE_SOURCE_REFUSED` before reading or freezing anything — `authz_*` stopped seeing the store's writes at the cutover, so rebuilding from them would resurrect every revocation since (and `--prune` would delete what was granted since). If the store is already live *and* you know for a fact that nothing has been written to it since — a store written by the previous version that nobody served from — `--allow-stale-source` is the explicit human decision; run the same command with `--dry-run` first, which flags `report.staleSource` instead of throwing.
|
|
1087
1245
|
|
|
1088
1246
|
### Operational notes for this driver
|
|
1089
1247
|
|
|
@@ -1138,8 +1296,10 @@ Every bound the package enforces or measured, in one place, each with the consta
|
|
|
1138
1296
|
| Depth gate on writes (opt-in, 2.4.0-alpha.5) | `scopes.maxDepth` | none by default; set it to 22 for `facts` | the manager, `scopes.attached`/`scopes.moved`, every driver | 422 `E_AUTHZ_SCOPE_DEPTH_LIMIT` **before** writing or enqueueing; see below |
|
|
1139
1297
|
| Authorization-model size | `FACTS_MODEL_MAX_BYTES` | 262,144 | `openfga`, `syncAuthzCatalog` | 500 `E_AUTHZ_MODEL_TOO_LARGE` before writing, warning past 80 % (`FACTS_MODEL_WARN_RATIO`); how many permissions those bytes are is [not a property of the model](#the-model-ceiling-is-bytes-not-permissions) |
|
|
1140
1298
|
| Checks per `batchCheck` | `FGA_MAX_BATCH_CHECK` | 50 | `openfga`, `authorizeMany` | the driver splits into batches of 50 (the SDK's cap), nothing to do |
|
|
1299
|
+
| `ListObjects` effective cut of the server (relations `listObjects`) | `listObjectsDeadlineMs` (`DEFAULT_LIST_OBJECTS_DEADLINE_MS`) | 3,000 ms | the `openfga` relations driver (2.5.0 · B4) | a call that takes ≥ `LIST_OBJECTS_DEADLINE_RATIO` (0.9) × this value returns `truncated: true` — the server cuts at its deadline with a 200 and no signal; set it to the **lowest** of your server's `--listObjects-deadline` (when not 0), `--request-timeout` (3 s by default) and, over HTTP, `--http-upstream-timeout` (5 s by default) — declare that lowest one: with `--listObjects-deadline=0` the request timeout still cuts with a mute 200 (measured on OpenFGA v1.19.0), there is no "no deadline", `Infinity` is 500, both at 0 is not supported (the gateway's cut is not measured for `ListObjects`), and the driver's `timeoutMs` (5 s by default) cannot be lower than `listObjectsDeadlineMs` (500 at construction: the signal has to fire before the client cuts; `Number.MAX_SAFE_INTEGER` is caught by that). **The price of the conservative signal**: with the default, every `ListObjects` that takes 2,700 ms or more comes back `truncated` even when complete; a consumer that treats `truncated` as a 503 pays that p99 as user-facing 503s — raise the server's deadline (and this value) or handle `truncated` as a partial list, not as an outage |
|
|
1300
|
+
| Objects per `checkMany` | `RELATIONS_CHECK_MANY_MAX` | 50 | the `RelationsManager` and both relations drivers (2.5.0) | 422 `E_AUTHZ_CHECK_MANY_BATCH` with zero backend calls — split the list yourself; `[]` is 422 too. One batch is one `batchCheck` in `openfga` and one query in `database` |
|
|
1141
1301
|
| `Read` page size (enumerations, `enumerateFacts`, `enumerateRelations`) | — | 100 | `openfga` | the server's hard cap: `pageSize: 1000` is a 400; the driver pages until the continuation token is empty — a page with zero tuples **and** a token is not the end; a token that repeats, more than 10,000 pages, or a response without `tuples` is 503 `E_AUTHZ_BACKEND_UNAVAILABLE`, never a shorter enumeration |
|
|
1142
|
-
| `enumerateRelations(partition)` / `enumerateRelationsMany(partitions)` cost | — | O(tuples in the store) **per sweep** — one sweep per `enumerateRelations` call, **one sweep for K partitions** with `enumerateRelationsMany` / `
|
|
1302
|
+
| `enumerateRelations(partition)` / `enumerateRelationsMany(partitions)` cost | — | O(tuples in the store) **per sweep** — one sweep per `enumerateRelations` call, **one sweep for K partitions** with `enumerateRelationsMany` / `runRelationsReconcile(…, { partitions })` (2.4.0-alpha.6) | `openfga`, the *source* of `authz:relations:reconcile` | there is no prefix `Read` in OpenFGA (a prefix object answers 200 with **zero** tuples, measured: never "optimise" it that way — the suite spies the filter of every `read`, it is always `{}`); measured 15,000 tuples ⇒ 151 pages ≈ 0.5 s per sweep whatever the partition size; 3 partitions in a 5,000-tuple store: **150 reads / 502–558 ms** as three sweeps → **50 reads / 170–209 ms** as one ([the table](#the-source-sweep-measured)). Run it from a job or a cron, **never from a tenant request** |
|
|
1143
1303
|
| Hops the `only` probe walks back to inherit a block's origin | `RELAY_ORIGIN_MAX_HOPS` | 100 | the manager, `relayScopeChanges({ only })` | the origin is taken as a real block and the entry stays `deferred` (the purge waits for a full pass); one `earlierPending` query per hop |
|
|
1144
1304
|
| Interval between `acquire()` retries under `waitForLeaseMs` | `RELAY_LEASE_POLL_MS` | 25 ms | the manager, `relayScopeChanges({ waitForLeaseMs })` and `requeueScopeChanges({ apply, waitForLeaseMs })` | the budget is spent in polls of this size; past it the pass reports `busy` (default `waitForLeaseMs` is 0: one attempt) |
|
|
1145
1305
|
| Scopes a `pending()` pass reads per round trip | `DEFAULT_RELAY_BATCH` | 100 | `authz:scopes:relay` (`--batch-size`) | the pass keeps paging |
|
|
@@ -1274,6 +1434,284 @@ ALTER TABLE authz_roles
|
|
|
1274
1434
|
ALTER TABLE authz_permissions ADD COLUMN assignable_at varchar(500) NULL;
|
|
1275
1435
|
```
|
|
1276
1436
|
|
|
1437
|
+
### Upgrading to 2.5.0: non-canonical partition spellings
|
|
1438
|
+
|
|
1439
|
+
There is **no `2.4.0` stable**: the 2.4 line ends at `2.4.0-alpha.10` and the next stable is **2.5.0** (the
|
|
1440
|
+
plan 2.5: this lot is `2.5.0-alpha.1`; the tenancy work — deny, `decideMany`, `listObjectsForSubject` — follows
|
|
1441
|
+
in the same line). Three things change for an installation on `2.4.0-alpha.x`:
|
|
1442
|
+
|
|
1443
|
+
1. **A relation partition whose uuid is 32 hex without its dashes and is not exactly `8-4-4-4-12` is 422** (D4,
|
|
1444
|
+
above): the dashless spelling *and* every other placement of the dashes that PostgreSQL folds into the same
|
|
1445
|
+
`uuid`. Those spellings were accepted until now, so any tuple written that way is unreachable from the API
|
|
1446
|
+
after the upgrade — not granting, not purgeable, not enumerable. **It is also a grant the canonical purge never
|
|
1447
|
+
saw**: a share written through the id of the URL (no dashes) survived `purgeSubject(user, <canonical tenant>)`
|
|
1448
|
+
on 2.4, and an `unrelate` or a shorter `expiresAt` written by the canonical spelling never reached it. That is
|
|
1449
|
+
why the recipe has **three branches and the default one writes nothing**: the default is the detector, the
|
|
1450
|
+
list and two counts (rows *with* a canonical twin / rows *without* one) — read-only, run it as many times as
|
|
1451
|
+
you like. The two branches that write are explicit: **`--delete`** (fail-closed: the rows go away — a grant
|
|
1452
|
+
the canonical purge never saw does not survive the upgrade) and **`--rekey`** (the rows are re-keyed to the
|
|
1453
|
+
canonical spelling; a non-canonical row whose canonical twin exists is dropped and the twin is left exactly
|
|
1454
|
+
as it is — an expiry is **never extended**, `NULL` does not win — the rest are re-keyed with their own expiry).
|
|
1455
|
+
**Which branch is yours depends on which population you are** — the detector lists the same rows for both,
|
|
1456
|
+
so look at your ids, not at the count:
|
|
1457
|
+
- *Population A — your tenant ids are uuids, written somewhere without their dashes (the id of a URL, a
|
|
1458
|
+
`uuid` column read as hex, …).* The detector lists **some** of your partitions. These rows are aliases of
|
|
1459
|
+
the canonical partition, invisible to its purge: **`--delete`** is your branch. `--rekey` cannot tell
|
|
1460
|
+
"written dashless and never revoked" from "revoked by the canonical spelling and surviving through the
|
|
1461
|
+
alias", so it is a decision taken after reading the list, not a default (the audit's scenario — purge by
|
|
1462
|
+
the canonical spelling, then the recipe — is pinned as "still revoked" after `--delete` and "granted again,
|
|
1463
|
+
as reviewed" after `--rekey`).
|
|
1464
|
+
- *Population B — your tenant ids were never uuids: 32 hex characters by construction
|
|
1465
|
+
(`randomBytes(16).toString('hex')`, md5, …), with or without dashes at non-uuid positions.* The detector lists
|
|
1466
|
+
**every one of your partitions** (that is the signature) and nothing is an alias of anything: **`--rekey`** is
|
|
1467
|
+
your branch — it keeps every relation and moves it under the `8-4-4-4-12` spelling of the same 32 hex
|
|
1468
|
+
(pinned: a population-B store keeps all its tuples and answers `check` by the dashed id) — and from 2.5.0 on
|
|
1469
|
+
you pass that spelling to the API (`dashedUuid(hex)` is exported). **Running `--delete` on this population
|
|
1470
|
+
empties `authz_relations`**, which is why neither branch runs without its explicit statements.
|
|
1471
|
+
All three branches are executed verbatim by the suite on PostgreSQL, MySQL and SQLite (`upgrade_recipe.spec`),
|
|
1472
|
+
twice (idempotent), the default one pinned as zero writes. The detector uses the same rule as `assertPartition`:
|
|
1473
|
+
what the API rejects, the detector lists (pinned). One statement per line, run each line whole. A partition id
|
|
1474
|
+
that is not a uuid once the dashes are removed (31 or 33 hex, 32 characters that are not all hex) is untouched:
|
|
1475
|
+
it is a legal id.
|
|
1476
|
+
2. **F-05 is demanded of the driver called directly** (D13): `runRelationsDriverContract` now calls the harness
|
|
1477
|
+
driver without the manager and expects the same 422s — on `membersOf` too when the driver declares
|
|
1478
|
+
`membersOfNative`, and `checkMany` called directly must refuse 51 objects and `[]`. A third-party harness
|
|
1479
|
+
that seeded undeclared types straight through its driver breaks on purpose; the in-memory double already
|
|
1480
|
+
behaved this way since lot 0. **Since `2.5.0-alpha.2` the same is demanded for B3** (closure 1 of lot B): a
|
|
1481
|
+
userset subject that is not `group#member` must be 422 `E_AUTHZ_RELATION_USERSET_UNSUPPORTED` through the
|
|
1482
|
+
manager *and* through the driver called directly, on `relate`/`unrelate`/`check`/`checkMany`/`listObjects`/
|
|
1483
|
+
`purgeSubject` — through the manager with zero driver calls, spied — (29 core cases per harness). A third-party
|
|
1484
|
+
driver that stored such a row inert, or answered 503, passed the contract until then.
|
|
1485
|
+
3. **`checkMany` and `relationTupleKey` are additive** (`relationTupleKey` demands `kind`: there is no default);
|
|
1486
|
+
`contractRelationsConfig()` declares a second object type (`workspace`, same relations as `document`) so the
|
|
1487
|
+
mixed-types case is observable — a harness that asserted on the published model of that config sees one type
|
|
1488
|
+
more. `createTestClock` lives at **`@jantstack/adonis-authz/testing/clock`** (no Japa or Lucid import):
|
|
1489
|
+
import it from there in a config file production loads.
|
|
1490
|
+
|
|
1491
|
+
**Lot B (`2.5.0-alpha.2`) — the breaking changes decided for before the first 2.x stable**, each with how to adapt:
|
|
1492
|
+
|
|
1493
|
+
4. **A cycle in `includes` is 422 at `defineRelationsConfig`** (B1): `editor ⊇ viewer ⊇ editor` (direct, two or more
|
|
1494
|
+
relations) used to be accepted — `database` served it, `openfga:provision` could not publish it. The message names
|
|
1495
|
+
the cycle (`document#editor → document#viewer → document#editor`). *Adapt*: remove the edge that closes it; a diamond
|
|
1496
|
+
(`viewer ⊇ editor ⊇ owner` and `viewer ⊇ owner`) is fine.
|
|
1497
|
+
5. **An unknown key anywhere in the relations spec is 422 `E_AUTHZ_RELATION_CONFIG_UNKNOWN_KEY`** (B2), naming the path
|
|
1498
|
+
(`objectTypes[1].deny`, `objectTypes[0].relations[1].denny`, `database.foo`) and the allowed keys of that level — until
|
|
1499
|
+
now every unknown key was dropped in silence. **The roles `defineConfig` rejects unknown keys too** (500
|
|
1500
|
+
`E_AUTHZ_CONFIG`, root and the `openfga`/`scopes`/`relations`/`hooks` blocks — and since `2.5.0-alpha.2` the roles
|
|
1501
|
+
check also runs in the `AuthorizationManager` constructor and in the relations service, so a config exported without
|
|
1502
|
+
`defineConfig` is judged too; a block that is not an object, `scopes: true`, is 500 as well). *Adapt*: fix the typo or
|
|
1503
|
+
remove the key; if you were parking a key for a future version, it does not belong in the spec yet. **The persisted
|
|
1504
|
+
copy is revalidated too**: `saveRelationsConfig` before 2.5.0 stored the spec *as given*, unknown keys included, so a
|
|
1505
|
+
row written then that carries one (or an `includes` cycle, B1) makes `readRelationsConfig()` 422 and
|
|
1506
|
+
`authz:relations:reconcile` stops with its own line — *"la config persistida en authz_relations_config NO valida en
|
|
1507
|
+
2.5: <motivo>"* — distinct from the unreadable-table line (point 8). *Adapt*: re-save it once from a process with
|
|
1508
|
+
the corrected static config, `saveRelationsConfig(<corrected spec>)` (the spec, not the frozen config: passing the
|
|
1509
|
+
output of `defineRelationsConfig` is 422 with that hint), and run the pass again.
|
|
1510
|
+
6. **A userset subject that is not `group#member` is 422 `E_AUTHZ_RELATION_USERSET_UNSUPPORTED`** (B3) on `relate`/
|
|
1511
|
+
`unrelate`/`check`/`checkMany`/`listObjects`/`purgeSubject`, in both drivers and the manager, before any backend
|
|
1512
|
+
call. Until now `database` stored such a row inert and `openfga` answered a misclassified 503. *Adapt*: relate the
|
|
1513
|
+
holder or the group, never the relation of another object. **Rows written before 2.5.0 with such a subject are a
|
|
1514
|
+
residue the package lists but never deletes**: `listSubjects` still returns them (output is not validated),
|
|
1515
|
+
`purgeSubject(userset)` no longer reaches them (422), and `purgeObject(<the tuple's own object>)` removes them only
|
|
1516
|
+
together with every legitimate share of that object (`purgeObject` on the userset's object does not touch them).
|
|
1517
|
+
`authz:relations:reconcile` classifies them **in the plan, before any driver call**, on both sides and with or
|
|
1518
|
+
without the persisted config: one enumerated from the *source* counts in `skipped.usersetUnsupported` and is listed
|
|
1519
|
+
in `details` (`side: 'origen'`, `reason: 'userset-unsupported'`); one found in the *destination* counts in `extra`
|
|
1520
|
+
and is listed (`side: 'destino'`, `pruned: false` under `--prune`) — **`--prune` never deletes it** (no platform
|
|
1521
|
+
`DELETE` on a row the API does not admit), the pass writes and prunes everything else, `--dry-run` and the real pass
|
|
1522
|
+
give the same numbers (declared limit: if the persisted config and the destination driver's config differ —
|
|
1523
|
+
`saveRelationsConfig` is manual — the dry-run classifies with the persisted one and the real pass with the driver's,
|
|
1524
|
+
so the numbers can differ until you re-save the config), and the pass exits ≠ 0 with the residue, its side and the recipe on the line until you run
|
|
1525
|
+
it. The **form** of the userset is judged before the pair's F-05, with or without the persisted config: a residue
|
|
1526
|
+
whose userset names a type or a relation the destination does not declare is still this residue (with the recipe),
|
|
1527
|
+
never `skipped.undeclared` with the false remedy "declare it in the destination". **One rule for every destination
|
|
1528
|
+
row the API does not admit**: a tuple whose identity is outside the grammar (`reason: 'invalid-identity'` — an
|
|
1529
|
+
`object_uuid` written by hand in upper case, say) and — since closure 3 of this release — a row whose **relation or
|
|
1530
|
+
object type the destination no longer declares** (`reason: 'undeclared'`: a relation retired from the config while
|
|
1531
|
+
its rows were still there; `enumerateRelations` filters by type, not by relation, and `unrelate` refuses the row by
|
|
1532
|
+
F-05) have exactly the same semantics — `extra`, listed with `pruned: false` under `--prune`, never asked of the
|
|
1533
|
+
driver when the persisted config is at hand (both package drivers would refuse it in `unrelate`: R-16 for the
|
|
1534
|
+
grammar, F-05 for `undeclared`), exit ≠ 0 with or without `--prune`, the remedy on the line ("delete it by hand
|
|
1535
|
+
(SQL/SDK)"; for `undeclared`, "or declare the relation again in the destination and re-save its persisted config"),
|
|
1536
|
+
and never "repeat with `--prune`" (that advice is printed only for what `--prune` really deletes, and only when
|
|
1537
|
+
`--prune` was not passed; the report carries `prune`). Without the persisted config (`reconcileRelations` called
|
|
1538
|
+
with no `toConfig`) the plan cannot anticipate F-05 — `--dry-run` counts that row in `deleted`, the declared limit
|
|
1539
|
+
of L-0 for `written` — and the real pass **catches only the 422s of the relations family** from `unrelate`
|
|
1540
|
+
(`E_AUTHZ_RELATION_TYPE_UNKNOWN`, `E_AUTHZ_RELATION_UNKNOWN`, `E_AUTHZ_RELATION_USERSET_UNSUPPORTED`,
|
|
1541
|
+
`E_AUTHZ_INVALID_IDENTITY`), classifies each by code, goes on with the other rows and lists it the same way; any
|
|
1542
|
+
other error still propagates (that is the driver contradicting the plan for a reason the report cannot count).
|
|
1543
|
+
Until `2.5.0-alpha.2` such a tuple was asked of the driver, a destination residue under `--prune` aborted the pass
|
|
1544
|
+
mid-way (after writing), a source residue was counted as `skipped.undeclared` with a false remedy, and — until
|
|
1545
|
+
closure 3 — a retired relation's row made `--prune` throw `E_AUTHZ_RELATION_UNKNOWN` mid-pass, after writing, on
|
|
1546
|
+
every run (🟠 N9 of the final audit: `--prune` was unusable for that partition). The recipe — one detector and one `DELETE`, the same
|
|
1547
|
+
statements on PostgreSQL, MySQL and SQLite, executed verbatim by the suite on the three (`upgrade_recipe.spec`) and
|
|
1548
|
+
printed on the reconcile line:
|
|
1549
|
+
|
|
1550
|
+
```sql
|
|
1551
|
+
-- B3 · userset residue (PostgreSQL, MySQL and SQLite: the same two statements)
|
|
1552
|
+
-- 1 · detector: every row whose subject is a userset other than group#member (zero rows = nothing to do)
|
|
1553
|
+
SELECT uuid, partition_key, object_type, object_uuid, relation, subject_type, subject_uuid, subject_relation FROM authz_relations WHERE subject_relation <> '' AND NOT (subject_type = 'group' AND subject_relation = 'member');
|
|
1554
|
+
-- 2 · delete them (holders have subject_relation = ''; a pre-L-4b NULL holder is excluded too: NULL <> '' is not true)
|
|
1555
|
+
DELETE FROM authz_relations WHERE subject_relation <> '' AND NOT (subject_type = 'group' AND subject_relation = 'member');
|
|
1556
|
+
```
|
|
1557
|
+
|
|
1558
|
+
(on `openfga` such tuples never existed: the server refused them).
|
|
1559
|
+
7. **`listObjects` of `openfga` signals `truncated: true` on the server's deadline too** (B4): a call that takes ≥ 90 % of
|
|
1560
|
+
`listObjectsDeadlineMs` (default 3,000 ms, OpenFGA's default `--listObjects-deadline`). *Adapt*: set `listObjectsDeadlineMs`
|
|
1561
|
+
to your server's **effective** cut in `OpenFgaRelationsDriver`; code that already handled `truncated` needs nothing else; code
|
|
1562
|
+
that assumed the list complete on a slow server was wrong before and is told now. The value is the server's
|
|
1563
|
+
**effective** cut — whichever is lower of the three: `--listObjects-deadline` (when it is not 0), `--request-timeout`
|
|
1564
|
+
(3 s by default) and, over HTTP, the gateway's `--http-upstream-timeout` (5 s by default); declare that lowest one. A server that runs with **`--listObjects-deadline=0`** does **not** run without a deadline:
|
|
1565
|
+
its `--request-timeout` still cuts `ListObjects` with a mute 200 (measured on OpenFGA v1.19.0 by the audit of this
|
|
1566
|
+
release, 40,000 tuples: `--request-timeout=30ms` ⇒ `200`, 0 objects of 40,000, three runs; control with 30 s ⇒
|
|
1567
|
+
40,000 of 40,000; in the source, `list_objects.go` sets its own timeout only when the deadline is not 0 and the
|
|
1568
|
+
request-timeout interceptor returns the partial result without a signal) — declare *that* value. The earlier advice
|
|
1569
|
+
of this release (`Infinity` on the driver for a deadline of 0) is withdrawn: it was measured against a
|
|
1570
|
+
whole list that returned in 5 ms, which cannot tell "no deadline" from "deadline = request timeout", and it made
|
|
1571
|
+
the driver pass the mute partial list as complete — strictly worse than the default; `Infinity` is now 500
|
|
1572
|
+
`E_AUTHZ_CONFIG` ("the server always cuts at one of the two; declare the lower one"), like `NaN`, `0` and
|
|
1573
|
+
negatives (`0` on the driver would mean "everything truncated"). Both at 0 is not supported: the HTTP gateway's
|
|
1574
|
+
`--http-upstream-timeout` (5 s) is not measured for `ListObjects`, and the package promises no "no deadline" it
|
|
1575
|
+
has not measured. And the driver's `timeoutMs` (5 s by default) cannot be lower than `listObjectsDeadlineMs`
|
|
1576
|
+
(closure 3, ⚪ N10): a deadline the client cuts before the 0.9 threshold is a signal that can never fire — and if
|
|
1577
|
+
the server cuts earlier, a mute list passes as complete — so `listObjectsDeadlineMs > timeoutMs` is 500
|
|
1578
|
+
`E_AUTHZ_CONFIG` at construction ("the signal has to be able to fire before the client cuts"), which also closes
|
|
1579
|
+
`Number.MAX_SAFE_INTEGER`, "no deadline" under another name. *Adapt*: a driver built with a `timeoutMs` below
|
|
1580
|
+
3,000 ms must now declare a `listObjectsDeadlineMs` at or below it. And since `2.5.0-alpha.2` a `ListObjects` that answers 200 **without `objects`** is
|
|
1581
|
+
503 `E_AUTHZ_BACKEND_UNAVAILABLE`, like a `Read` without `tuples` — the same for the three `Read`s of the roles
|
|
1582
|
+
driver (`readAllTuples`, the reconcile dump, `enumerateFacts`), where such a response used to read as an empty
|
|
1583
|
+
enumeration and `purgeScope` "proved" zero on it.
|
|
1584
|
+
8. **`authz:relations:reconcile` stops (exit 1) when `authz_relations_config` cannot be read** (B5) — before building,
|
|
1585
|
+
freezing or writing anything; "no config persisted" (`null`) still runs and says so. A persisted config that **does
|
|
1586
|
+
not validate in 2.5** (a pre-2.5 row with a key 2.4 dropped in silence, an `includes` cycle, corrupt JSON) is a
|
|
1587
|
+
fourth branch with its own line, *"NO valida en 2.5: <motivo> … saveRelationsConfig(<spec corregida>)"* — never
|
|
1588
|
+
sold as "the read failed" nor as "no config" (point 5). *Adapt*: nothing, unless a cron relied on the silent
|
|
1589
|
+
continuation: give the command a readable database; for the fourth branch, re-save the corrected spec once.
|
|
1590
|
+
9. **`sqlScopeOutbox()` requires `{ transaction }` by default** (B6): `scopes.attached/moved/detached` (and
|
|
1591
|
+
`outbox.enqueue`) without the open transaction of the outbox's connection are **500 `E_AUTHZ_CONFIG` before the
|
|
1592
|
+
`INSERT`**. *Adapt*: pass `{ transaction: trx }` from `db.transaction()` as the recipe above shows; if you really
|
|
1593
|
+
enqueue outside any unit of work (a seeder, a platform job), say so: `sqlScopeOutbox({ requireTransaction: false })`.
|
|
1594
|
+
`{ transaction }` through the manager needs a pool ≥ 2 (the freeze barrier reads through another connection; with
|
|
1595
|
+
one connection it is 503 `E_AUTHZ_BACKEND_TIMEOUT` at `freezeTimeoutMs`), so with **pool max 1 — knex's default for
|
|
1596
|
+
every SQLite connection, file or `:memory:`** — keep the opt-out; the 500 says so too. A third-party outbox
|
|
1597
|
+
judged by `runScopeOutboxContract` declares the new optional capability `requiresTransaction` (absent = `false`) and,
|
|
1598
|
+
with `true`, hands the judge `transactions.begin()`.
|
|
1599
|
+
10. **`runRelationsReconcile` is imported from `@jantstack/adonis-authz/relations/reconcile`** (B7); the command module
|
|
1600
|
+
`./commands/authz_relations_reconcile` no longer exports it (nor `RunRelationsReconcileOptions`). *Adapt*: change the
|
|
1601
|
+
import path; the signature is the same.
|
|
1602
|
+
11. **The roles reconcile verifier reads the pinned model** (B8): with `modelId` set on `OpenFgaAuthorizationDriver`, the
|
|
1603
|
+
pass checks the catalog against *that* model, not the latest one in the store. *Adapt*: nothing; a pass that used to
|
|
1604
|
+
report `permission-not-in-model` for a permission only present in a newer model now projects it under the pinned one
|
|
1605
|
+
— and one that used to die with a 503 mid-pass (permission in the latest model but not in the pinned one) now counts
|
|
1606
|
+
it. The runbook rule stands: publish and roll out the model **before** the window, never inside it.
|
|
1607
|
+
|
|
1608
|
+
**Default (read-only): detect, list, count.** Nothing is written. Statement 3 says, per branch, what `--rekey`
|
|
1609
|
+
would do: a row *with* a canonical twin is dropped by it, a row *without* one is re-keyed.
|
|
1610
|
+
|
|
1611
|
+
```sql
|
|
1612
|
+
-- D4 · PostgreSQL
|
|
1613
|
+
-- 1 · detector: non-canonical partitions and how many tuples each one holds (zero rows = nothing to do)
|
|
1614
|
+
SELECT partition_key, COUNT(*) AS tuples FROM authz_relations WHERE replace(split_part(partition_key, '|', 2), '-', '') ~ '^[0-9a-f]{32}$' AND split_part(partition_key, '|', 2) !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' GROUP BY partition_key;
|
|
1615
|
+
-- 2 · the list: every tuple that is about to be deleted — review it (an offboarded user in it is the reason this is the default)
|
|
1616
|
+
SELECT uuid, partition_key, object_type, object_uuid, relation, subject_type, subject_uuid, subject_relation, subject_partition, expires_at FROM authz_relations WHERE replace(split_part(partition_key, '|', 2), '-', '') ~ '^[0-9a-f]{32}$' AND split_part(partition_key, '|', 2) !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' ORDER BY partition_key, object_type, object_uuid;
|
|
1617
|
+
-- 3 · the counts: rows WITH a canonical twin (--rekey drops them) and rows WITHOUT one (--rekey re-keys them); --delete removes both
|
|
1618
|
+
SELECT CASE WHEN EXISTS (SELECT 1 FROM authz_relations c WHERE c.partition_key = split_part(d.partition_key, '|', 1) || '|' || substr(replace(split_part(d.partition_key, '|', 2), '-', ''), 1, 8) || '-' || substr(replace(split_part(d.partition_key, '|', 2), '-', ''), 9, 4) || '-' || substr(replace(split_part(d.partition_key, '|', 2), '-', ''), 13, 4) || '-' || substr(replace(split_part(d.partition_key, '|', 2), '-', ''), 17, 4) || '-' || substr(replace(split_part(d.partition_key, '|', 2), '-', ''), 21, 12) AND c.object_type = d.object_type AND c.object_uuid = d.object_uuid AND c.relation = d.relation AND c.subject_type = d.subject_type AND c.subject_uuid = d.subject_uuid AND c.subject_relation = d.subject_relation) THEN 'with-canonical-twin' ELSE 'without-twin' END AS twin, COUNT(*) AS tuples FROM authz_relations d WHERE replace(split_part(d.partition_key, '|', 2), '-', '') ~ '^[0-9a-f]{32}$' AND split_part(d.partition_key, '|', 2) !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' GROUP BY 1;
|
|
1619
|
+
```
|
|
1620
|
+
|
|
1621
|
+
```sql
|
|
1622
|
+
-- D4 · MySQL
|
|
1623
|
+
-- 1 · detector: non-canonical partitions and how many tuples each one holds (zero rows = nothing to do)
|
|
1624
|
+
SELECT partition_key, COUNT(*) AS tuples FROM authz_relations WHERE partition_key LIKE '%|%' AND REPLACE(SUBSTRING_INDEX(partition_key, '|', -1), '-', '') REGEXP '^[0-9a-f]{32}$' AND SUBSTRING_INDEX(partition_key, '|', -1) NOT REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' GROUP BY partition_key;
|
|
1625
|
+
-- 2 · the list: every tuple that is about to be deleted — review it (an offboarded user in it is the reason this is the default)
|
|
1626
|
+
SELECT uuid, partition_key, object_type, object_uuid, relation, subject_type, subject_uuid, subject_relation, subject_partition, expires_at FROM authz_relations WHERE partition_key LIKE '%|%' AND REPLACE(SUBSTRING_INDEX(partition_key, '|', -1), '-', '') REGEXP '^[0-9a-f]{32}$' AND SUBSTRING_INDEX(partition_key, '|', -1) NOT REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' ORDER BY partition_key, object_type, object_uuid;
|
|
1627
|
+
-- 3 · the counts: rows WITH a canonical twin (--rekey drops them) and rows WITHOUT one (--rekey re-keys them); --delete removes both
|
|
1628
|
+
SELECT CASE WHEN EXISTS (SELECT 1 FROM authz_relations c WHERE c.partition_key = CONCAT(SUBSTRING_INDEX(d.partition_key, '|', 1), '|', SUBSTRING(REPLACE(SUBSTRING_INDEX(d.partition_key, '|', -1), '-', ''), 1, 8), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(d.partition_key, '|', -1), '-', ''), 9, 4), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(d.partition_key, '|', -1), '-', ''), 13, 4), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(d.partition_key, '|', -1), '-', ''), 17, 4), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(d.partition_key, '|', -1), '-', ''), 21, 12)) AND c.object_type = d.object_type AND c.object_uuid = d.object_uuid AND c.relation = d.relation AND c.subject_type = d.subject_type AND c.subject_uuid = d.subject_uuid AND c.subject_relation = d.subject_relation) THEN 'with-canonical-twin' ELSE 'without-twin' END AS twin, COUNT(*) AS tuples FROM authz_relations d WHERE d.partition_key LIKE '%|%' AND REPLACE(SUBSTRING_INDEX(d.partition_key, '|', -1), '-', '') REGEXP '^[0-9a-f]{32}$' AND SUBSTRING_INDEX(d.partition_key, '|', -1) NOT REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' GROUP BY 1;
|
|
1629
|
+
```
|
|
1630
|
+
|
|
1631
|
+
```sql
|
|
1632
|
+
-- D4 · SQLite
|
|
1633
|
+
-- 1 · detector: non-canonical partitions and how many tuples each one holds (zero rows = nothing to do)
|
|
1634
|
+
SELECT partition_key, COUNT(*) AS tuples FROM authz_relations WHERE instr(partition_key, '|') > 0 AND replace(substr(partition_key, instr(partition_key, '|') + 1), '-', '') GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' AND substr(partition_key, instr(partition_key, '|') + 1) NOT GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' GROUP BY partition_key;
|
|
1635
|
+
-- 2 · the list: every tuple that is about to be deleted — review it (an offboarded user in it is the reason this is the default)
|
|
1636
|
+
SELECT uuid, partition_key, object_type, object_uuid, relation, subject_type, subject_uuid, subject_relation, subject_partition, expires_at FROM authz_relations WHERE instr(partition_key, '|') > 0 AND replace(substr(partition_key, instr(partition_key, '|') + 1), '-', '') GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' AND substr(partition_key, instr(partition_key, '|') + 1) NOT GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' ORDER BY partition_key, object_type, object_uuid;
|
|
1637
|
+
-- 3 · the counts: rows WITH a canonical twin (--rekey drops them) and rows WITHOUT one (--rekey re-keys them); --delete removes both
|
|
1638
|
+
SELECT CASE WHEN EXISTS (SELECT 1 FROM authz_relations c WHERE c.partition_key = substr(d.partition_key, 1, instr(d.partition_key, '|')) || substr(replace(substr(d.partition_key, instr(d.partition_key, '|') + 1), '-', ''), 1, 8) || '-' || substr(replace(substr(d.partition_key, instr(d.partition_key, '|') + 1), '-', ''), 9, 4) || '-' || substr(replace(substr(d.partition_key, instr(d.partition_key, '|') + 1), '-', ''), 13, 4) || '-' || substr(replace(substr(d.partition_key, instr(d.partition_key, '|') + 1), '-', ''), 17, 4) || '-' || substr(replace(substr(d.partition_key, instr(d.partition_key, '|') + 1), '-', ''), 21, 12) AND c.object_type = d.object_type AND c.object_uuid = d.object_uuid AND c.relation = d.relation AND c.subject_type = d.subject_type AND c.subject_uuid = d.subject_uuid AND c.subject_relation = d.subject_relation) THEN 'with-canonical-twin' ELSE 'without-twin' END AS twin, COUNT(*) AS tuples FROM authz_relations d WHERE instr(d.partition_key, '|') > 0 AND replace(substr(d.partition_key, instr(d.partition_key, '|') + 1), '-', '') GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' AND substr(d.partition_key, instr(d.partition_key, '|') + 1) NOT GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' GROUP BY 1;
|
|
1639
|
+
```
|
|
1640
|
+
|
|
1641
|
+
**`--delete` (population A, after reviewing the list): fail-closed, one statement per engine, run *instead of*
|
|
1642
|
+
`--rekey`.**
|
|
1643
|
+
|
|
1644
|
+
```sql
|
|
1645
|
+
-- D4 · PostgreSQL · --delete
|
|
1646
|
+
-- 1 · fail-closed: delete every non-canonical row (grants invisible to the canonical purge do not survive the upgrade)
|
|
1647
|
+
DELETE FROM authz_relations WHERE replace(split_part(partition_key, '|', 2), '-', '') ~ '^[0-9a-f]{32}$' AND split_part(partition_key, '|', 2) !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
|
|
1648
|
+
```
|
|
1649
|
+
|
|
1650
|
+
```sql
|
|
1651
|
+
-- D4 · MySQL · --delete
|
|
1652
|
+
-- 1 · fail-closed: delete every non-canonical row (grants invisible to the canonical purge do not survive the upgrade)
|
|
1653
|
+
DELETE FROM authz_relations WHERE partition_key LIKE '%|%' AND REPLACE(SUBSTRING_INDEX(partition_key, '|', -1), '-', '') REGEXP '^[0-9a-f]{32}$' AND SUBSTRING_INDEX(partition_key, '|', -1) NOT REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
|
|
1654
|
+
```
|
|
1655
|
+
|
|
1656
|
+
```sql
|
|
1657
|
+
-- D4 · SQLite · --delete
|
|
1658
|
+
-- 1 · fail-closed: delete every non-canonical row (grants invisible to the canonical purge do not survive the upgrade)
|
|
1659
|
+
DELETE FROM authz_relations WHERE instr(partition_key, '|') > 0 AND replace(substr(partition_key, instr(partition_key, '|') + 1), '-', '') GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' AND substr(partition_key, instr(partition_key, '|') + 1) NOT GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]';
|
|
1660
|
+
```
|
|
1661
|
+
|
|
1662
|
+
**`--rekey` (population B, or population A as a reviewed decision): run these two statements *instead of*
|
|
1663
|
+
`--delete`.** Two non-canonical spellings of the *same*
|
|
1664
|
+
tenant without a canonical twin collide on the unique index of `authz_relations` and the `UPDATE` fails loudly
|
|
1665
|
+
(atomically: nothing is re-keyed, for any tenant, until the statement succeeds). Delete one of the two by hand
|
|
1666
|
+
first — **keep the row with the nearest expiry (a `NULL` expiry is the farthest) and delete the other one**, so the
|
|
1667
|
+
re-keyed tuple never outlives what either row already granted.
|
|
1668
|
+
|
|
1669
|
+
```sql
|
|
1670
|
+
-- D4 · PostgreSQL · --rekey
|
|
1671
|
+
-- 1 · a non-canonical row whose canonical twin exists goes away; the twin is NOT touched (its expiry is never extended)
|
|
1672
|
+
DELETE FROM authz_relations d WHERE replace(split_part(d.partition_key, '|', 2), '-', '') ~ '^[0-9a-f]{32}$' AND split_part(d.partition_key, '|', 2) !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' AND EXISTS (SELECT 1 FROM authz_relations c WHERE c.partition_key = split_part(d.partition_key, '|', 1) || '|' || substr(replace(split_part(d.partition_key, '|', 2), '-', ''), 1, 8) || '-' || substr(replace(split_part(d.partition_key, '|', 2), '-', ''), 9, 4) || '-' || substr(replace(split_part(d.partition_key, '|', 2), '-', ''), 13, 4) || '-' || substr(replace(split_part(d.partition_key, '|', 2), '-', ''), 17, 4) || '-' || substr(replace(split_part(d.partition_key, '|', 2), '-', ''), 21, 12) AND c.object_type = d.object_type AND c.object_uuid = d.object_uuid AND c.relation = d.relation AND c.subject_type = d.subject_type AND c.subject_uuid = d.subject_uuid AND c.subject_relation = d.subject_relation);
|
|
1673
|
+
-- 2 · the rest are re-keyed to the canonical spelling (and the subject_partition of their usersets with them)
|
|
1674
|
+
UPDATE authz_relations SET subject_partition = CASE WHEN subject_partition = partition_key THEN split_part(partition_key, '|', 1) || '|' || substr(replace(split_part(partition_key, '|', 2), '-', ''), 1, 8) || '-' || substr(replace(split_part(partition_key, '|', 2), '-', ''), 9, 4) || '-' || substr(replace(split_part(partition_key, '|', 2), '-', ''), 13, 4) || '-' || substr(replace(split_part(partition_key, '|', 2), '-', ''), 17, 4) || '-' || substr(replace(split_part(partition_key, '|', 2), '-', ''), 21, 12) ELSE subject_partition END, partition_key = split_part(partition_key, '|', 1) || '|' || substr(replace(split_part(partition_key, '|', 2), '-', ''), 1, 8) || '-' || substr(replace(split_part(partition_key, '|', 2), '-', ''), 9, 4) || '-' || substr(replace(split_part(partition_key, '|', 2), '-', ''), 13, 4) || '-' || substr(replace(split_part(partition_key, '|', 2), '-', ''), 17, 4) || '-' || substr(replace(split_part(partition_key, '|', 2), '-', ''), 21, 12) WHERE replace(split_part(partition_key, '|', 2), '-', '') ~ '^[0-9a-f]{32}$' AND split_part(partition_key, '|', 2) !~ '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
|
|
1675
|
+
```
|
|
1676
|
+
|
|
1677
|
+
```sql
|
|
1678
|
+
-- D4 · MySQL · --rekey
|
|
1679
|
+
-- 1 · a non-canonical row whose canonical twin exists goes away; the twin is NOT touched (its expiry is never extended)
|
|
1680
|
+
DELETE d FROM authz_relations d JOIN authz_relations c ON c.partition_key = CONCAT(SUBSTRING_INDEX(d.partition_key, '|', 1), '|', SUBSTRING(REPLACE(SUBSTRING_INDEX(d.partition_key, '|', -1), '-', ''), 1, 8), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(d.partition_key, '|', -1), '-', ''), 9, 4), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(d.partition_key, '|', -1), '-', ''), 13, 4), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(d.partition_key, '|', -1), '-', ''), 17, 4), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(d.partition_key, '|', -1), '-', ''), 21, 12)) AND c.object_type = d.object_type AND c.object_uuid = d.object_uuid AND c.relation = d.relation AND c.subject_type = d.subject_type AND c.subject_uuid = d.subject_uuid AND c.subject_relation = d.subject_relation WHERE d.partition_key LIKE '%|%' AND REPLACE(SUBSTRING_INDEX(d.partition_key, '|', -1), '-', '') REGEXP '^[0-9a-f]{32}$' AND SUBSTRING_INDEX(d.partition_key, '|', -1) NOT REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
|
|
1681
|
+
-- 2 · the rest are re-keyed to the canonical spelling (and the subject_partition of their usersets with them)
|
|
1682
|
+
UPDATE authz_relations SET subject_partition = CASE WHEN subject_partition = partition_key THEN CONCAT(SUBSTRING_INDEX(partition_key, '|', 1), '|', SUBSTRING(REPLACE(SUBSTRING_INDEX(partition_key, '|', -1), '-', ''), 1, 8), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(partition_key, '|', -1), '-', ''), 9, 4), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(partition_key, '|', -1), '-', ''), 13, 4), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(partition_key, '|', -1), '-', ''), 17, 4), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(partition_key, '|', -1), '-', ''), 21, 12)) ELSE subject_partition END, partition_key = CONCAT(SUBSTRING_INDEX(partition_key, '|', 1), '|', SUBSTRING(REPLACE(SUBSTRING_INDEX(partition_key, '|', -1), '-', ''), 1, 8), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(partition_key, '|', -1), '-', ''), 9, 4), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(partition_key, '|', -1), '-', ''), 13, 4), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(partition_key, '|', -1), '-', ''), 17, 4), '-', SUBSTRING(REPLACE(SUBSTRING_INDEX(partition_key, '|', -1), '-', ''), 21, 12)) WHERE partition_key LIKE '%|%' AND REPLACE(SUBSTRING_INDEX(partition_key, '|', -1), '-', '') REGEXP '^[0-9a-f]{32}$' AND SUBSTRING_INDEX(partition_key, '|', -1) NOT REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$';
|
|
1683
|
+
```
|
|
1684
|
+
|
|
1685
|
+
```sql
|
|
1686
|
+
-- D4 · SQLite · --rekey
|
|
1687
|
+
-- 1 · a non-canonical row whose canonical twin exists goes away; the twin is NOT touched (its expiry is never extended)
|
|
1688
|
+
DELETE FROM authz_relations WHERE instr(authz_relations.partition_key, '|') > 0 AND replace(substr(authz_relations.partition_key, instr(authz_relations.partition_key, '|') + 1), '-', '') GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' AND substr(authz_relations.partition_key, instr(authz_relations.partition_key, '|') + 1) NOT GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' AND EXISTS (SELECT 1 FROM authz_relations c WHERE c.partition_key = substr(authz_relations.partition_key, 1, instr(authz_relations.partition_key, '|')) || substr(replace(substr(authz_relations.partition_key, instr(authz_relations.partition_key, '|') + 1), '-', ''), 1, 8) || '-' || substr(replace(substr(authz_relations.partition_key, instr(authz_relations.partition_key, '|') + 1), '-', ''), 9, 4) || '-' || substr(replace(substr(authz_relations.partition_key, instr(authz_relations.partition_key, '|') + 1), '-', ''), 13, 4) || '-' || substr(replace(substr(authz_relations.partition_key, instr(authz_relations.partition_key, '|') + 1), '-', ''), 17, 4) || '-' || substr(replace(substr(authz_relations.partition_key, instr(authz_relations.partition_key, '|') + 1), '-', ''), 21, 12) AND c.object_type = authz_relations.object_type AND c.object_uuid = authz_relations.object_uuid AND c.relation = authz_relations.relation AND c.subject_type = authz_relations.subject_type AND c.subject_uuid = authz_relations.subject_uuid AND c.subject_relation = authz_relations.subject_relation);
|
|
1689
|
+
-- 2 · the rest are re-keyed to the canonical spelling (and the subject_partition of their usersets with them)
|
|
1690
|
+
UPDATE authz_relations SET subject_partition = CASE WHEN subject_partition = partition_key THEN substr(partition_key, 1, instr(partition_key, '|')) || substr(replace(substr(partition_key, instr(partition_key, '|') + 1), '-', ''), 1, 8) || '-' || substr(replace(substr(partition_key, instr(partition_key, '|') + 1), '-', ''), 9, 4) || '-' || substr(replace(substr(partition_key, instr(partition_key, '|') + 1), '-', ''), 13, 4) || '-' || substr(replace(substr(partition_key, instr(partition_key, '|') + 1), '-', ''), 17, 4) || '-' || substr(replace(substr(partition_key, instr(partition_key, '|') + 1), '-', ''), 21, 12) ELSE subject_partition END, partition_key = substr(partition_key, 1, instr(partition_key, '|')) || substr(replace(substr(partition_key, instr(partition_key, '|') + 1), '-', ''), 1, 8) || '-' || substr(replace(substr(partition_key, instr(partition_key, '|') + 1), '-', ''), 9, 4) || '-' || substr(replace(substr(partition_key, instr(partition_key, '|') + 1), '-', ''), 13, 4) || '-' || substr(replace(substr(partition_key, instr(partition_key, '|') + 1), '-', ''), 17, 4) || '-' || substr(replace(substr(partition_key, instr(partition_key, '|') + 1), '-', ''), 21, 12) WHERE instr(partition_key, '|') > 0 AND replace(substr(partition_key, instr(partition_key, '|') + 1), '-', '') GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]' AND substr(partition_key, instr(partition_key, '|') + 1) NOT GLOB '[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f]-[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]';
|
|
1691
|
+
```
|
|
1692
|
+
|
|
1693
|
+
**On `openfga` the store is the tuples themselves**, and the package cannot address a non-canonical partition
|
|
1694
|
+
any more (the driver refuses to compose its id), so there is no `--partition-uuid` to reconcile *from* it. The
|
|
1695
|
+
non-canonical tuples left in the store grant nothing from the API on 2.5.0. The same three branches, with the SDK:
|
|
1696
|
+
the default is to **list** them (`client.read({})` and keep the objects whose partition segment is 32 hex but not
|
|
1697
|
+
`8-4-4-4-12`); `--delete` is `client.write({ deletes })` of that list; the `--rekey` equivalent is fail-closed
|
|
1698
|
+
while there are no denies (write the canonical tuple first, then delete the old one): if `database` holds the same
|
|
1699
|
+
facts, run the SQL `--rekey` there and **`authz:relations:reconcile --to=openfga --from=database
|
|
1700
|
+
--partition-type=<type> --partition-uuid=<canonical>`** writes the canonical tuples, then delete the old ones with
|
|
1701
|
+
the SDK — which is what the suite executes against a real server (`relations_openfga.spec`, "D4 recipe"). If the
|
|
1702
|
+
store is the only copy, read the old tuples with the SDK, write them under the canonical object id and delete the
|
|
1703
|
+
old ones in that order. **Roles have nothing to migrate but do have the same rule**: with the consumer's row alive
|
|
1704
|
+
the resolver chain canonicalises the scope (invariant 17); where there is no chain — `scopes.detached` after the
|
|
1705
|
+
row is gone, `revoke`/`removeDeny` on a scope the tree no longer knows — the purge covers the caller's spelling
|
|
1706
|
+
*and* the canonical one it can be an alias of, under `pgFusedSpellings`: **exactly the spellings PostgreSQL's
|
|
1707
|
+
`uuid` folds** (hyphens only after groups of four hex digits — dashless, `8-4-4-16`, `8-8-8-8`, `12-4-4-12`…;
|
|
1708
|
+
measured against PostgreSQL 18), which is deliberately *narrower* than the partition rule `isNonCanonicalUuid`
|
|
1709
|
+
(`-<32hex>`, `<32hex>-`, `a0e-…`, `--` are legal ids PostgreSQL rejects: a 422 too many is harmless, a purge too
|
|
1710
|
+
many is not). And a spelling the resolver still knows as a **live** scope is never purged by that fan-out: with
|
|
1711
|
+
text scope ids a tenant could otherwise create and delete `<32 hex of another tenant's unit>` and have
|
|
1712
|
+
`detached`/`removeDeny` reach the live unit (cierre-3 of lot A). If the resolver fails while checking, the write is
|
|
1713
|
+
a 503 — never "purge just in case".
|
|
1714
|
+
|
|
1277
1715
|
### Upgrading from 2.4.0-alpha.1: `authz_relations.expires_at`
|
|
1278
1716
|
|
|
1279
1717
|
An installation that migrated 2.4.0-alpha.1 runs **both** recipes below, **in this order** — `expires_at` first (additive, R-15), then `subject_relation` (lot L-4b) — in the **same deploy** as the package upgrade: until the second one is applied, every holder `relate` is refused by the old partition trigger (measured, below). Both are executed verbatim by the suite on the three engines.
|