@jantstack/adonis-authz 2.4.0-alpha.1 → 2.4.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 +248 -18
- package/build/commands/authz_relations_reconcile.d.ts +25 -1
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
- package/build/commands/authz_relations_reconcile.js +94 -6
- package/build/commands/authz_relations_reconcile.js.map +1 -1
- package/build/index.d.ts +12 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +10 -0
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +7 -0
- package/build/providers/authz_provider.js.map +1 -1
- package/build/src/define_config.d.ts +36 -0
- package/build/src/define_config.d.ts.map +1 -1
- package/build/src/define_config.js.map +1 -1
- package/build/src/drivers/database_driver.d.ts +53 -14
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +104 -32
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +47 -9
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/database_relations_driver.js +318 -89
- package/build/src/drivers/database_relations_driver.js.map +1 -1
- package/build/src/drivers/openfga_driver.d.ts +17 -4
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +38 -3
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_facts.d.ts +15 -0
- package/build/src/drivers/openfga_facts.d.ts.map +1 -1
- package/build/src/drivers/openfga_facts.js +25 -2
- package/build/src/drivers/openfga_facts.js.map +1 -1
- package/build/src/drivers/openfga_relations_driver.d.ts +27 -6
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_relations_driver.js +140 -16
- package/build/src/drivers/openfga_relations_driver.js.map +1 -1
- package/build/src/errors.d.ts +35 -3
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +61 -3
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +37 -16
- package/build/src/freeze.d.ts.map +1 -1
- package/build/src/freeze.js +53 -8
- package/build/src/freeze.js.map +1 -1
- package/build/src/manager.d.ts +16 -1
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +116 -25
- package/build/src/manager.js.map +1 -1
- package/build/src/models/authz_assignment.d.ts +2 -2
- package/build/src/models/authz_assignment.d.ts.map +1 -1
- package/build/src/models/authz_deny.d.ts +2 -2
- package/build/src/models/authz_deny.d.ts.map +1 -1
- package/build/src/models/authz_permission.d.ts +2 -2
- package/build/src/models/authz_permission.d.ts.map +1 -1
- package/build/src/models/authz_role.d.ts +2 -2
- package/build/src/models/authz_role.d.ts.map +1 -1
- package/build/src/models/authz_role_permission.d.ts +2 -2
- package/build/src/models/authz_role_permission.d.ts.map +1 -1
- package/build/src/relation_partition_trigger.d.ts.map +1 -1
- package/build/src/relation_partition_trigger.js +11 -3
- package/build/src/relation_partition_trigger.js.map +1 -1
- package/build/src/relations/define_relations_config.d.ts +17 -0
- package/build/src/relations/define_relations_config.d.ts.map +1 -1
- package/build/src/relations/define_relations_config.js +30 -1
- package/build/src/relations/define_relations_config.js.map +1 -1
- package/build/src/relations/manager.d.ts +25 -3
- package/build/src/relations/manager.d.ts.map +1 -1
- package/build/src/relations/manager.js +76 -19
- package/build/src/relations/manager.js.map +1 -1
- package/build/src/relations/reconcile.d.ts +53 -4
- package/build/src/relations/reconcile.d.ts.map +1 -1
- package/build/src/relations/reconcile.js +92 -30
- package/build/src/relations/reconcile.js.map +1 -1
- package/build/src/scope_outbox.d.ts.map +1 -1
- package/build/src/scope_outbox.js +17 -10
- package/build/src/scope_outbox.js.map +1 -1
- package/build/src/shared/backend_guard.d.ts +28 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -1
- package/build/src/shared/backend_guard.js +52 -0
- package/build/src/shared/backend_guard.js.map +1 -1
- package/build/src/shared/transaction_guard.d.ts +50 -0
- package/build/src/shared/transaction_guard.d.ts.map +1 -0
- package/build/src/shared/transaction_guard.js +60 -0
- package/build/src/shared/transaction_guard.js.map +1 -0
- package/build/src/testing/contract.d.ts +55 -4
- package/build/src/testing/contract.d.ts.map +1 -1
- package/build/src/testing/contract.js +240 -8
- package/build/src/testing/contract.js.map +1 -1
- package/build/src/testing/main.d.ts +4 -4
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +2 -2
- package/build/src/testing/main.js.map +1 -1
- package/build/src/testing/relations_contract.d.ts +23 -1
- package/build/src/testing/relations_contract.d.ts.map +1 -1
- package/build/src/testing/relations_contract.js +432 -17
- package/build/src/testing/relations_contract.js.map +1 -1
- package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
- package/build/src/testing/relations_reconcile_contract.js +49 -1
- package/build/src/testing/relations_reconcile_contract.js.map +1 -1
- package/build/src/traits/has_uuid.d.ts +2 -2
- package/build/src/traits/has_uuid.d.ts.map +1 -1
- package/build/src/types.d.ts +197 -10
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js.map +1 -1
- package/build/stubs/migration.stub +20 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -126,6 +126,8 @@ node ace authz:scopes:relay --dry-run # list what is still unpropagated
|
|
|
126
126
|
|
|
127
127
|
The package **does not impose a table**: the contract is the `ScopeOutbox` port (`enqueue`, `pending`, `markApplied`, `markFailed`, plus two optional ones: `dead` for parked entries and `acquire` for the single-writer lease). `sqlScopeOutbox` is the published implementation over Lucid and `stubs/scopes_outbox_migration.stub` is its migration — **copy it into your migrations yourself**; `node ace configure` does not publish it, because the outbox is opt-in. The only thing an implementation must do is write `enqueue` inside the transaction it is handed.
|
|
128
128
|
|
|
129
|
+
**Two rules about that transaction (2.4.0-alpha.2, lot L-1).** First, **`{ transaction }` carries the write, never the authority**: the freeze barrier that precedes every write (row `id = 2` of `authz_catalog_version`, see [the freeze](#migrating-and-verifying-authzreconcile-23)) is read through the engine's own connection, **never through your transaction** — a transaction opened *before* a freeze does not see it in its snapshot (InnoDB's REPEATABLE READ, PostgreSQL under that level, SQLite in WAL), and a barrier decided by the caller's snapshot is not a barrier (the suite reproduces the bypass on all three engines and pins the 503). The price is declared: **`{ transaction }` requires a connection pool of at least 2**. With a pool of 1 (SQLite `:memory:`) the barrier cannot get a connection while you hold the only one, and the notification is **503 `E_AUTHZ_BACKEND_TIMEOUT` at `freezeTimeoutMs`** (config, default 5000 ms) — fail-closed, never a hang, never a bypass. Second, `sqlScopeOutbox` **checks whose transaction it is**: it must be an *open* Lucid transaction (`isTransaction === true`) of the **outbox's own connection** (`connection`, or the primary one). A transaction of another connection, a `QueryClient` (`db.connection()`) or the whole `db` service are **500 `E_AUTHZ_CONFIG`** naming both connections, before any statement — until L-1 a transaction of another connection silently enqueued in *its* database (where no relay reads and `dead()` never shows it), and `db` passed the duck-check and wrote outside any transaction, in silence. The same check (`assertCallerTransaction`) is the rule for every `{ transaction }` of the package. The whole story — what `{ transaction }` means on each call, what the engines do, the `resolveChain` limit and what `openfga` offers instead — is in [Writing inside your transaction](#writing-inside-your-transaction-240-alpha2).
|
|
130
|
+
|
|
129
131
|
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.
|
|
130
132
|
|
|
131
133
|
**The relay is a single writer.** `pending()` reserves nothing, so two passes at once (a Kubernetes `CronJob` with the default `concurrencyPolicy: Allow`, two replicas, a pass that lasts longer than its interval) work on the same batch: the straggler re-applies an old `attached` after the other applied the new `moved`, and the store is left with the **old parent and a single edge, so nothing denounces it** — the old tenant keeps access to a subtree that is no longer theirs (measured). `sqlScopeOutbox.acquire()` takes a lease for the pass — a server-side lock on PostgreSQL and MySQL, a process-wide one on SQLite — and a second simultaneous pass does nothing and says so (`busy`). If you implement the port yourself and skip `acquire`, run the relay one pass at a time.
|
|
@@ -150,6 +152,70 @@ Two consequences you have to plan for:
|
|
|
150
152
|
- **Notify `attached` for every node.** A consumer that materialises paths and only notifies some of its nodes used to get *more* than it asked for; now it gets less. Diagnose it with `authz:reconcile --dry-run`, which lists the scopes that are not reachable from `app`.
|
|
151
153
|
- **Publishing the model is not enough: the store needs its root marker.** The reachability of the root is anchored by one tuple per holder type (`scope:app#rooted@<holder>:*`) — **zero per scope**, so the outbox and the relay carry nothing new. `syncAuthzCatalog` writes it (idempotently, and that is also how a holder type added to your config gets one), and `authz:reconcile` reports it as drift if it is missing. **Without it the whole store denies** — fail-closed and loud on the first question, but total; it is the same class of accident as "the model was never published".
|
|
152
154
|
|
|
155
|
+
### Writing inside your transaction (2.4.0-alpha.2)
|
|
156
|
+
|
|
157
|
+
`{ transaction: trx }` — an open Lucid transaction of yours — means **three different things** depending on the call, and the difference is the whole story of this section:
|
|
158
|
+
|
|
159
|
+
| Call | What `{ transaction }` does | `database` | `openfga` |
|
|
160
|
+
|---|---|---|---|
|
|
161
|
+
| `grant` / `revoke` / `deny` / `removeDeny` | **writes the fact inside your transaction** — commit and the fact is there with your rows, roll back and neither is | yes (`transactionalWrites: true`, lot L-3) | **500 `E_AUTHZ_UNSUPPORTED`**, nothing called (lot L-5) — see [the recipe by direction](#openfga-the-recipe-by-direction) |
|
|
162
|
+
| `relate` / `unrelate` / `purgeObject` / `purgeSubject` | **writes the tuple inside your transaction** | yes (lot L-4) | the same 500 |
|
|
163
|
+
| `scopes.attached` / `moved` / `detached` | **enqueues** the tree change in the outbox, inside your transaction (`scopes.outbox`, see [the tree outbox](#the-tree-outbox-and-the-relay-lag-you-are-accepting)). Enqueueing ≠ writing: this never passes the capability gate | both | both |
|
|
164
|
+
| `defineScopedRole` / `updateScopedRole` / `deleteScopedRole` | **refused**, 500 `E_AUTHZ_UNSUPPORTED`: the catalog is written through `withAuthzCatalogWrite`, the cross-process serializer (invariant 14), which your transaction would defeat | — | — |
|
|
165
|
+
|
|
166
|
+
The capability behind the first two rows is **`transactionalWrites`** (same name on both ports), and it means **exactly** *both or neither with your transaction* — never *"not lost"*; there is no intermediate value. `database` declares `true`; `openfga` declares `false` and cannot declare anything else: a tuple lives in another service and there is no two-phase commit between it and your database. `requireTransactionalWrites: true` in the config (root, or `relations.requireTransactionalWrites`) turns a wrong deployment into one that **does not start** — 500 `E_AUTHZ_CONFIG` when the driver is resolved, reads included — instead of a 500 on a rarely travelled route. It is the first capability of this package that is **not portable between drivers**: the same call is 200 on `database` and 500 on `openfga`, and the suite pins that asymmetry on purpose (each driver declares what it can do; you read the capability and adjust).
|
|
167
|
+
|
|
168
|
+
#### The rule: the write goes through your transaction, the authority never does
|
|
169
|
+
|
|
170
|
+
**Through your transaction: the write, and only the write** — the `INSERT`/`UPDATE`/`DELETE`, plus the "does it already exist?" read that belongs to it (a `grant` and its re-grant, a `relate` and its renewal, inside the same transaction see each other). **Through the engine's own connection, always: the authority** — the freeze barrier (row `id = 2` of `authz_catalog_version`), the catalog memo, `resolveChain`, F-05 and `assertWrite`. They are decided **before the first statement on your transaction** (the suite spies on the transaction and counts zero), and a transaction opened *before* a freeze does not get to decide it is not frozen (its snapshot would say so under InnoDB's REPEATABLE READ, PostgreSQL at that level and SQLite in WAL — the suite reproduces the bypass on the three engines and pins the 503). Until 2.4.0-alpha.2 the barrier *was* read through the caller's transaction "so a pool of 1 would not deadlock"; that traded authority for availability and it is gone.
|
|
171
|
+
|
|
172
|
+
Hence the deployment rule, said out loud: **`{ transaction }` requires a pool of at least 2.** With a pool of 1 (SQLite `:memory:`, the suite's default mode) the authority cannot get a connection while you hold the only one, and the write is **503 `E_AUTHZ_BACKEND_TIMEOUT` at `freezeTimeoutMs`** (config, default 5000 ms — the barrier's own total deadline, pool wait included; knex's own `acquireConnectionTimeout` would have made it 60 s) with **zero statements on your transaction**: fail-closed, never a hang, never a bypass. A pool-of-1 deployment declares it — `new DatabaseAuthorizationDriver({ transactionalWrites: false })`, `new DatabaseRelationsDriver(config, { transactionalWrites: false })` — and gets gate 1's immediate 500 instead; the driver declares what its deployment can do, the package does not guess the pool. This also applies to `scopes.*` with `{ transaction }` (the outbox): a single-connection deployment that used it has to raise the pool (breaking of deployment, lot L-1). While the engine is **frozen**, `{ transaction }` is the retryable 503 `E_AUTHZ_FROZEN` before the first statement on your transaction.
|
|
173
|
+
|
|
174
|
+
The transaction must be **yours and the driver's**: an open Lucid transaction (`isTransaction === true`) of the driver's connection (the primary one for facts; `connection` or the primary one for relations and the outbox). Another connection, a `QueryClient` (`db.connection()`) or the whole `db` service are 500 `E_AUTHZ_CONFIG` naming both connections, before any statement (`assertCallerTransaction`, exported: the same check for every `{ transaction }` of the package — until lot L-1 a foreign transaction silently enqueued in *its* database and `db` passed the duck-check and wrote outside any transaction). "Both or neither" is only true on the same connection.
|
|
175
|
+
|
|
176
|
+
What the published suite pins, **by census and not by the answer**: after a rollback `authz_assignments`/`authz_denies` hold zero rows for that holder and `authz_relations` zero new tuples in that partition, for the four writes of each port, on SQLite (file), PostgreSQL and MySQL; `purgeObject`/`purgeSubject` delete and revert *together*; while the transaction is open the authority (reading through the engine's connection) does not see the write; on commit census and answer change together (`runAuthorizationDriverContract` / `runRelationsDriverContract`, the `true` face of the pair). `onWrite`/`onRelationWrite` fire when the driver returns, with **`transactional: true`** on the event: at that instant the row exists only inside your transaction and it is a fact if and only if you commit — the package never sees that; a sink that wants the last word hangs itself on `trx.after('commit')`.
|
|
177
|
+
|
|
178
|
+
#### What the engines do when the write lives as long as your transaction (measured)
|
|
179
|
+
|
|
180
|
+
Outside a transaction the driver absorbs a clash: it re-reads and keeps the winner's row (2.5-B · K4). Inside yours it must not — PostgreSQL has already aborted your transaction, and under REPEATABLE READ the re-read would not see the winner (a mutant that re-reads fails differently on each engine) — so **a clash poisons your transaction**: the driver never issues a second statement on a transaction that may be aborted, the choice between roll back and retry is yours, and `onWrite` publishes nothing for the loser (a clash is not a deadline). Measured on the three engines, facts and tuples alike:
|
|
181
|
+
|
|
182
|
+
| Situation | PostgreSQL | MySQL | SQLite (file, WAL) |
|
|
183
|
+
|---|---|---|---|
|
|
184
|
+
| **Two open transactions write the same fact/tuple** (unique index) | the second `INSERT` waits until the first ends; on its commit, `23505` ⇒ **409 `E_AUTHZ_WRITE_CONFLICT`** ("roll back and retry"); your transaction is **aborted** (`25P02` on every later statement until you roll back) | waits (shared lock on the duplicate); `1062` ⇒ **409 `E_AUTHZ_WRITE_CONFLICT`**; your transaction **lives on**, only the statement was undone | does not wait: a reader cannot become the writer while another holds the reserved lock ⇒ `SQLITE_BUSY` at once, *before* the first commits ⇒ 503 `E_AUTHZ_BACKEND_UNAVAILABLE`; alive |
|
|
185
|
+
| **The driver's `timeoutMs` elapses while your statement waits** | knex cancels the query (`pg_cancel_backend`) ⇒ 503 `E_AUTHZ_BACKEND_TIMEOUT`; **aborted** | knex kills it (`KILL QUERY`) ⇒ 503 `E_AUTHZ_BACKEND_TIMEOUT`; lives on | the synchronous driver cannot elapse a deadline: `SQLITE_BUSY` above |
|
|
186
|
+
| **Two transactions write two tuples in crossed order** (A→B / B→A) | detected after `deadlock_timeout` (1 s by default); the victim gets **409 `E_AUTHZ_WRITE_CONFLICT`** (`40P01`) and stays **aborted** until its rollback; the winner waits for that rollback | InnoDB detects it at once; the victim gets **409** (`1213`) and InnoDB **rolls back its whole transaction** — its earlier rows are gone too; the winner may resolve first | cannot deadlock: the second writer is `SQLITE_BUSY` (503) at its first write |
|
|
187
|
+
| **A cross-partition userset row your own code inserts on that transaction** (the partition trigger fires inside it, lot L-4) | refused by the trigger; your transaction is **aborted** and the next `relate` of the package on it is a classified 503 (`25P02`), never the raw error | refused; lives on | refused; lives on |
|
|
188
|
+
| **A transaction that read before another writer committed** (a freeze put and lifted after your first `SELECT`, say) | — | — | cannot write afterwards: `SQLITE_BUSY` snapshot ⇒ 503, the engine's rule |
|
|
189
|
+
|
|
190
|
+
Two more things that follow from "as long as your transaction": for the 503 of an elapsed deadline `onWrite` publishes **`indeterminate: true` *and* `transactional: true`** — your rollback does determine the outcome, but the rollback is yours and the package never sees it (invariant 13 stands); and **renewing a relation expiry (delete+insert, R-15) rolls back to the previous expiry** — the census after the rollback holds the old row with its old `expires_at` and its old uuid.
|
|
191
|
+
|
|
192
|
+
#### The decision is not in your transaction: `resolveChain`
|
|
193
|
+
|
|
194
|
+
The *row* is inside your transaction; the *decision* that authorises writing it is not. `resolveChain` is your function and it reads your tree through your `db` — the engine's connection, not your transaction — so **the tree is judged against the state before your transaction** (`within` too): creating a scope and granting on it inside the same transaction is 422 `E_AUTHZ_UNKNOWN_SCOPE`, fail-closed, and that is the correct answer, pinned. It is the one limit of this port, and it is not fixed by handing the driver your transaction (that would put the authority back inside the caller's snapshot). Two recipes:
|
|
195
|
+
|
|
196
|
+
- **To grant on a scope you create in the same transaction**, use a `ScopeChainResolver` that closes over your transaction — `scopes.resolveChain` is a plain callback of yours and nothing stops it from reading your tree through `trx` (no case of this package exercises that form: it is your code). You are then telling the authority to trust your uncommitted tree — your call, said out loud. Otherwise commit the scope first and grant after.
|
|
197
|
+
- **If you move the tree *and* write facts in the same transaction, notify `authorization.scopes.*` first**, then write the facts. The notification validates the move against the tree as it stands (anti-cycles, existence), and with the outbox it **enqueues** in your transaction, so the tree change and your rows commit or vanish together; the facts you write after it are still judged against the pre-transaction tree, which is the fail-closed side.
|
|
198
|
+
|
|
199
|
+
#### `openfga`: the recipe by direction
|
|
200
|
+
|
|
201
|
+
`openfga` cannot put a tuple in your transaction and says so — 500 `E_AUTHZ_UNSUPPORTED` on every write that carries `{ transaction }`, through the manager **and through the driver itself** (`manager.driver()` re-validates: the message names the driver and the operation, nothing was called; the way out is the `database` driver or `requireTransactionalWrites`, and the driver's own letter adds that entering through `manager.driver()` is not a way out — for the **tree** the way out that exists is `scopes.outbox`; **for facts and relations there is no outbox**, on purpose: an outbox of facts was measured as fail-open during its lag and discarded by the panel, the numbers are in the changelog). What you *can* do with `openfga` is order your writes by **direction**, so that whichever of the two fails, the failure lands on the closed side:
|
|
202
|
+
|
|
203
|
+
| Direction | With `openfga` | If your transaction rolls back |
|
|
204
|
+
|---|---|---|
|
|
205
|
+
| **Permissive** — `grant`, `relate`, `removeDeny` | write **after the commit** (`trx.after('commit', …)`, or after `db.transaction()` returns) | nothing was granted ⇒ **fail-closed** |
|
|
206
|
+
| **Restrictive** — `revoke`, `deny`, `unrelate` | write **before, or inside, in line** (no `{ transaction }`) | something was revoked that your SQL still grants ⇒ **fail-closed**, and repairable (grant again) |
|
|
207
|
+
|
|
208
|
+
**This is not "both or neither"**: it is a write *order* that makes the failure fall on the closed side, and its limits are measured against a real server:
|
|
209
|
+
|
|
210
|
+
- **Its limit: it does not compose.** A `grant` and a `revoke` of the *same* fact in the same transaction are applied in inverted order — the revoke in line, on a fact that does not exist yet (a safe no-op), the grant after the commit — and **end up granting**. If your unit of work both grants and revokes the same fact, the recipe is not for it.
|
|
211
|
+
- **The package does not automate it.** `grant` with `{ transaction }` on `openfga` is the 500 above: the driver does not hook `trx.after('commit')` to write later, does not write in line "pretending", and nothing lands when you commit (pinned: zero hooks registered on the transaction, zero client calls, `onWrite` empty). The order is yours to write.
|
|
212
|
+
- **Lucid swallows what an `after('commit')` handler throws** (`TransactionClient.commit()` runs the hooks and catches). A `grant` that fails there — 503, 422 — fails in silence: still fail-closed, but unreported. Await the write after `db.transaction()` returns, or catch inside the handler and report it yourself.
|
|
213
|
+
- **"Inside" needs a pool of at least 2 even with `openfga`.** The write goes to the store, but the freeze barrier before it is read through the engine's SQL connection: a `revoke` in line *inside* your open transaction on a pool of 1 is the 503 of the barrier at `freezeTimeoutMs`, not a revoke. On a pool of 1 the restrictive write goes **before** you open the transaction.
|
|
214
|
+
|
|
215
|
+
#### Third-party drivers, and what the suite can and cannot prove
|
|
216
|
+
|
|
217
|
+
"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.
|
|
218
|
+
|
|
153
219
|
### Identity is validated, once and everywhere
|
|
154
220
|
|
|
155
221
|
`SubjectRef.type`/`uuid`, `ScopeRef.type`/`uuid`, role and permission slugs and `expiresAt` are checked by the manager on every call and again by each driver (the contract suite and third-party drivers bypass the manager). Lowercase letters, digits, `.`, `_`, `-` — **types and uuids alike**: types since 2.0 (a `*_ci` MySQL collation would merge `Users` and `users` into one row while FGA keeps them apart), uuids since 2.1 (the tree of a consumer merges `BBBB…` with `bbbb…` on PostgreSQL's `uuid` type and on MySQL's default collation, and the alias evaded a deny — *"la identidad es una cadena validada por la gramática … un uuid con MAYÚSCULAS … es 422"*; lower-case your ids at your edge: a UUID is the same id in any case); permissions may carry one `:` (`resource:action`); slugs are lowercase and at most **42** characters; `parent`, `binding`, `ancestor`, `rooted`, `role`, `assignee`, `denied` and the prefixes `can_`, `denied_`, `permits_` are reserved; `{ type: 'app', uuid: X }` and the root sentinel uuid outside `app` are rejected — even when your tree knows that sentinel (*"uuid centinela en un scope que el árbol SÍ conoce ⇒ 422"*); `grant`, `revoke` and `listSubjects` take a slug, and a `{ slug, scopeType }` object there is 422 (*"un RoleQuery objeto donde el contrato pide un slug ⇒ 422"*); `expiresAt` is `undefined`, `null` or a valid `Date` (*"expiresAt que no es Date válida, null ni omitido ⇒ 422"*). Violations are **422** (`E_AUTHZ_INVALID_IDENTITY`, `E_AUTHZ_INVALID_SLUG`) before any catalog, tree or backend call — zero queries, spied (*"identidad inválida ⇒ 422"*, *"slug mal formado o reservado ⇒ 422"*, *"una identidad inválida se rechaza con 0 llamadas al backend"*). `assertIdentity`, `assertValidSlug` and `assertExpiresAt` are exported so you can validate at your own edge with the same rule.
|
|
@@ -176,6 +242,8 @@ A seeder or an onboarding that calls `grant` "to make sure they have the role" n
|
|
|
176
242
|
|
|
177
243
|
One exception, on purpose: when a write **times out** (503 `E_AUTHZ_BACKEND_TIMEOUT`) the outcome is *unknown* — the request may still land on the backend after you received the error. Before propagating, the manager notifies the same event with **`indeterminate: true`**, so your audit records "may have happened" instead of nothing (*"una escritura que vence el deadline notifica onWrite con indeterminate: true ANTES de propagar el 503"*). A 503 that is not a timeout (connection refused) means the write did not happen and emits nothing. The `openfga` driver also stops the SDK from retrying on its own (`retryParams.maxRetry: 0` by default): a background retry after your 503 is exactly the phantom write this is about; enabling retries is opting into it. If you retry a timed-out write yourself, remember it is idempotent.
|
|
178
244
|
|
|
245
|
+
**Inside your transaction (2.4.0-alpha.2, lot L-3) the event also carries `transactional: true`**, and a deadline that elapses there is *still* `indeterminate: true`. The `{trx}` panel's auditor objected that inside a transaction "the rollback determines it" — it does, but the rollback is *yours* and the package never sees it: when the 503 reaches you the statement may or may not have landed inside your transaction (SQLite cannot cancel; MySQL kills the statement and your transaction lives on; PostgreSQL leaves your transaction aborted), and whether you commit is yours. `transactional: true` is on **every** event of a write inscribed in your transaction (`granted`/`extended`/`revoked`/`denied`/`deny_removed`): at that instant the row exists only inside your transaction and is a fact if and only if you commit. A sink that wants the last word hooks `trx.after('commit', …)`. Enqueueing through `scopes.*` is not writing and does not carry it.
|
|
246
|
+
|
|
179
247
|
`grant` always returns a `GrantOutcome`; a third-party driver that still returns `void` is normalized to `{ existed: false, expiresAt: options?.expiresAt ?? null }` (*"un driver de terceros cuyo grant no devuelve GrantOutcome sigue notificando granted"*). `revoke`/`removeDeny` require the role/permission to exist in the catalog for that level (422, like `grant`/`deny`); the safe no-op is for a *missing assignment* of a valid role (*"revoke/removeDeny con rol o permiso fuera del catálogo ⇒ 422"*, *"revoke/removeDeny inexistentes son no-ops seguros"*).
|
|
180
248
|
|
|
181
249
|
## Queries
|
|
@@ -427,9 +495,32 @@ const relations = defineRelationsConfig({
|
|
|
427
495
|
|
|
428
496
|
await rel.relate(user, 'viewer', { type: 'document', id }, tenant) // share with a user
|
|
429
497
|
await rel.relate({ object: team, relation: 'member' }, 'editor', doc, tenant) // share with a TEAM (userset)
|
|
498
|
+
await rel.relate(guest, 'viewer', doc, tenant, { expiresAt: new Date(Date.now() + 7 * 86_400_000) }) // time-boxed share
|
|
430
499
|
await rel.check(user, 'viewer', doc, tenant) // one Check; editor⊆viewer resolves server-side
|
|
431
500
|
```
|
|
432
501
|
|
|
502
|
+
**Relation expiry (2.4.0-alpha.2, R-15).** A relation tuple may carry an `expiresAt`, with the
|
|
503
|
+
**same three states as `grant`** (invariant 10): omitted preserves a live expiry (an expired one
|
|
504
|
+
revives without expiry — it is a new share), `null` removes it, a `Date` sets it. Expiry is
|
|
505
|
+
**strict** — a share that expires *now* no longer grants — and is honoured by `check`,
|
|
506
|
+
`listObjects`, `listSubjects` and `membersOf` in both drivers, including a membership that expires
|
|
507
|
+
(`relate(u, 'member', group, tenant, { expiresAt })` stops granting through the userset at that
|
|
508
|
+
instant). Enforced in SQL by `database` (`expires_at > now`, the same `DATETIME(3)` column and codec
|
|
509
|
+
as assignments) and by the `not_expired` condition on every relation subject in the fused model for
|
|
510
|
+
`openfga` (`current_time` travels in every `Check`/`ListObjects`). The `RelationsManager` takes the
|
|
511
|
+
same `clock` as the roles manager (the provider passes `config.clock`), and both drivers implement
|
|
512
|
+
`withClock`. In `database`, **renewing an expiry is delete+insert, never an `UPDATE`** — the table
|
|
513
|
+
stays insert/delete-only, and the case that observes it is in the suite (the row changes its uuid).
|
|
514
|
+
A bad `expiresAt` (not a valid `Date`/`null`/omitted) is 422 `E_AUTHZ_INVALID_IDENTITY` before the
|
|
515
|
+
driver. `enumerateRelations` does **not** filter expired tuples: they reach `authz:relations:reconcile`
|
|
516
|
+
with their `expiresAt` and are counted in `skipped.expired`, never silently dropped. And a *live*
|
|
517
|
+
tuple whose object type or relation the **destination** does not declare is **not written** and is
|
|
518
|
+
counted in `skipped.undeclared` (L-0): with the destination config at hand the pass discards it
|
|
519
|
+
before the driver (so `--dry-run` and the real pass report the same numbers, and the type also shows
|
|
520
|
+
in `modelDrift`); without it, the destination driver's own F-05 422 funnels it and it is counted the
|
|
521
|
+
same. Unlike `expired`, that one is a live relation that did not arrive, so the command exits ≠ 0:
|
|
522
|
+
declare the type in the destination, republish its model, and run the pass again.
|
|
523
|
+
|
|
433
524
|
`group` is a **built-in** object type (the userset carrier: `group#member`, nesting allowed), so
|
|
434
525
|
teams work without declaring anything. Every operation takes a **`partition: ScopeRef`** — the
|
|
435
526
|
tenant — and it is **mandatory**: a relation in tenant A never resolves in tenant B (`APP_SCOPE` is
|
|
@@ -439,9 +530,14 @@ in the model.
|
|
|
439
530
|
**The model is shared with the catalog, and so is its byte budget.** In the `openfga` driver,
|
|
440
531
|
relations fuse into the same `facts` model and the same store, so a single `Check` still answers
|
|
441
532
|
each question. The price is one budget: the 262,144-byte model holds **both** your permissions and
|
|
442
|
-
your object types. Measured
|
|
443
|
-
and
|
|
444
|
-
|
|
533
|
+
your object types. Measured (3 holder types, realistic permission names): the ceiling is **~450–470
|
|
534
|
+
permissions**, and since relation expiry (2.4.0-alpha.2) a three-relation object type costs about
|
|
535
|
+
**1.0 of a permission** (≈ 579 B vs ≈ 557 B; before the `not_expired` condition on relation subjects
|
|
536
|
+
it was ≈ 0.5) and `group` ≈ 0.34 (191 B; was 86 B). The condition adds `(holders + 1) × (type name +
|
|
537
|
+
"not_expired")` bytes per declared relation — ≈ 103 B per relation with three holders. So a catalog
|
|
538
|
+
of 447 realistic permissions has room for **24** three-relation object types (was 52), and one object
|
|
539
|
+
type costs the permission ceiling a single permission (472 → 471); a small catalog has room to
|
|
540
|
+
spare. The gate
|
|
445
541
|
watches the **fused** model — `defineRelationsConfig` that would push it over is 500
|
|
446
542
|
`E_AUTHZ_MODEL_TOO_LARGE` before anything is published (80 % warns), the same protection
|
|
447
543
|
`syncAuthzCatalog` already gives the permissions. A consumer that needs *many* object types **and**
|
|
@@ -457,8 +553,17 @@ declared (422 `E_AUTHZ_RELATION_TYPE_UNKNOWN` / `E_AUTHZ_RELATION_UNKNOWN`) **be
|
|
|
457
553
|
driver** — so the id of a `role_binding` is never composed by the relations driver, the collision
|
|
458
554
|
does not exist rather than being watched. This is F-05, and it is a **chokepoint**: every write path
|
|
459
555
|
funnels through it, and the published contract plants the exploit so a third-party relations driver
|
|
460
|
-
that does not enforce it **does not pass**.
|
|
461
|
-
|
|
556
|
+
that does not enforce it **does not pass**. **It lives in the manager AND in both drivers** (L-0,
|
|
557
|
+
2.4.0-alpha.2): `relate`/`unrelate` of `database` and `openfga` apply the very same function
|
|
558
|
+
(`assertRelationDeclared` — same class, same `code`, same message) **before touching the backend**
|
|
559
|
+
(zero `Write`, zero `INSERT`; the suite spies on the client and on the connection). Until L-0 the
|
|
560
|
+
port's docblock claimed the drivers re-validated and it was false in both: `manager.driver()` (the
|
|
561
|
+
platform escape hatch) and `authz:relations:reconcile` — which writes through the destination
|
|
562
|
+
*driver* — went straight past F-05, and in the shared store `driver.relate(evil, 'assignee',
|
|
563
|
+
{ type: 'role_binding', id: <roleUuid> }, S)` turned `roles.authorize(evil)` from `false` to `true`
|
|
564
|
+
(measured against a real server; the bridge spec keeps that case, and removing the driver guard puts
|
|
565
|
+
it back in red). `manager.driver()` still skips the manager's *other* barriers (`actor`,
|
|
566
|
+
`assertWrite`, `onRelationWrite`), as documented — F-05 is no longer one of them.
|
|
462
567
|
|
|
463
568
|
**`membersOf` is `database`-only.** `membersOf(group, 'member', partition)` returns the **transitive**
|
|
464
569
|
membership (through nested groups). Only the `database` driver has it (a recursive CTE); in `openfga`
|
|
@@ -481,13 +586,41 @@ analog of `authz:reconcile`, idempotent, bidirectional and never silent (it repo
|
|
|
481
586
|
`--dry-run` is the read-only verifier and also flags **model drift** (an object type in the source the
|
|
482
587
|
destination does not declare); `--prune` deletes what the source no longer backs. It migrates **facts
|
|
483
588
|
only** — there is no tree or catalog in `relations/` — and works **per partition** (`--partition-type`
|
|
484
|
-
/`--partition-uuid`; default `app`).
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
589
|
+
/`--partition-uuid`; default `app`). **The pass that writes runs under the durable freeze** (2.4.0-alpha.2,
|
|
590
|
+
lot L-1), exactly like `authz:reconcile`: every write of the package — roles *and* relations — answers a
|
|
591
|
+
retryable 503 `E_AUTHZ_FROZEN` fleet-wide while it runs; a live **operator** window (`authz:freeze`) is its
|
|
592
|
+
own context (it runs inside, does not renew or lift it); a live freeze of another pass is 423; and the
|
|
593
|
+
report carries `frozen: { durable, lapsed, leaseMs, fence }` — **`lapsed: true` means the lease was lost
|
|
594
|
+
mid-pass and the pass is not certified** (exit ≠ 0). `--dry-run` does not freeze: the verifier is
|
|
595
|
+
read-only and is meant for a cron.
|
|
596
|
+
|
|
597
|
+
**Writing relations inside your transaction (2.4.0-alpha.2, lot L-4).** `relate`/`unrelate`/`purgeObject`/
|
|
598
|
+
`purgeSubject` with **`{ transaction: trx }`** on the `database` relations driver write the tuple **inside
|
|
599
|
+
your open Lucid transaction** of the driver's connection — the same rule, the same guard, the same pool ≥ 2
|
|
600
|
+
requirement, the same census in the published suite and the same per-engine behaviour as facts, all in one
|
|
601
|
+
place: [Writing inside your transaction](#writing-inside-your-transaction-240-alpha2). What is specific to
|
|
602
|
+
tuples is there too: with a transaction present `relate` no longer opens its own, so the partition trigger
|
|
603
|
+
fires inside yours; renewing an expiry rolls back to the previous one; two crossed writes deadlock into a
|
|
604
|
+
409. A pool-of-1 deployment declares `new DatabaseRelationsDriver(config, { transactionalWrites: false })`;
|
|
605
|
+
on `openfga` it is the 500 and [the recipe by direction](#openfga-the-recipe-by-direction). **And the unique
|
|
606
|
+
index guards holder tuples too (lot L-4b).** Until L-4b `subject_relation` was `NULL` for a holder and every engine treats two
|
|
607
|
+
`NULL`s as distinct in a unique index, so `authz_rel_tuple_uq` only guarded **userset** tuples: two concurrent
|
|
608
|
+
`relate`s of the *same holder tuple* in two open transactions both went in and committed **two identical
|
|
609
|
+
rows** (no over-grant, but `listSubjects`/`enumerateRelations` listed the subject twice and the reconcile
|
|
610
|
+
census counted it twice). Since L-4b the column is **`NOT NULL DEFAULT ''`** (`''` = holder; the only option
|
|
611
|
+
with parity on the three engines — MySQL has no partial index, `NULLS NOT DISTINCT` is PostgreSQL 15+) and the
|
|
612
|
+
driver writes `''`, so the same holder tuple in two transactions ends in **exactly one row**: the loser gets
|
|
613
|
+
the same **409 `E_AUTHZ_WRITE_CONFLICT`** as a userset (PostgreSQL/MySQL) or `SQLITE_BUSY` (SQLite file), pinned
|
|
614
|
+
on the three engines, with the mutant (column back to nullable ⇒ two rows) red. An installation that migrated
|
|
615
|
+
before L-4b runs [the recipe](#upgrading-from-240-alpha2-before-l-4b-authz_relationssubject_relation): the
|
|
616
|
+
driver tolerates old `NULL` rows on read meanwhile.
|
|
617
|
+
|
|
618
|
+
**Still not in 2.4.** `includes` with `from` (cross-object inheritance like `viewer from parent`),
|
|
619
|
+
which would add a TTU between object types and force re-measuring depth. Relation expiry (R-15)
|
|
620
|
+
**landed in 2.4.0-alpha.2** (see above); an installation that migrated 2.4.0-alpha.1 adds the column with
|
|
621
|
+
the recipe in [Upgrading](#upgrading-from-240-alpha1-authz_relationsexpires_at). `{ transaction }` on
|
|
622
|
+
`relate`/`unrelate`/`purge*` **landed with lot L-4** (above); on `openfga` it is the honest 500 and
|
|
623
|
+
[the recipe by direction](#openfga-the-recipe-by-direction).
|
|
491
624
|
|
|
492
625
|
## Errors
|
|
493
626
|
|
|
@@ -519,8 +652,8 @@ Every error the package raises carries `status` and `code`. A standard AdonisJS
|
|
|
519
652
|
| `E_AUTHZ_FROZEN` | 503 | the engine's writes are frozen by a platform operation (`authz:reconcile`, or the cutover window of `authz:freeze`) — **durably, fleet-wide** (row `id = 2` of `authz_catalog_version`, 2.3): reads keep working and the error is **retryable** (`error.retryable === true`) — reissue the write when the window ends (the message says how it lifts) |
|
|
520
653
|
| `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 |
|
|
521
654
|
| `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 |
|
|
522
|
-
| `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 |
|
|
523
|
-
| `E_AUTHZ_CONFIG` | 500 | 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 |
|
|
655
|
+
| `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) |
|
|
656
|
+
| `E_AUTHZ_CONFIG` | 500 | 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); `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) |
|
|
524
657
|
| `E_AUTHZ_ROLE_IS_NOT_ACCESS` | 500 | `appAccess({ role })` |
|
|
525
658
|
| `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) |
|
|
526
659
|
| `E_AUTHZ_PURGE_INCOMPLETE` | 500 | `purgeScope` could not prove zero |
|
|
@@ -528,7 +661,7 @@ Every error the package raises carries `status` and `code`. A standard AdonisJS
|
|
|
528
661
|
| `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 |
|
|
529
662
|
| `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 |
|
|
530
663
|
| `E_AUTHZ_TOO_MANY_LOCAL_ROLES` | 500 | more local roles than `maxLocalRoles` (10 000) in a `prune-orphans` pass; never a partial list |
|
|
531
|
-
| `E_AUTHZ_UNSUPPORTED` | 500 | a primitive needs an optional port method the active driver lacks: `listDenies` (2.1; also behind `defineScopedRole`), `purgeRole` (2.2 — behind `deleteScopedRole`, `authz:catalog:prune-orphans` and, before writing anything, `defineScopedRole`; the `openfga` driver until 2.3); `reconcile` (2.3 — `authz:reconcile --to=<driver>`); `enumerateFacts` (2.3 — being the *source* of `authz:reconcile`; the `database` driver does not implement it: its facts are `authz_*`. Also raised when `--to` is the **active** driver and declares `hierarchyFacts`: its facts are its own, so the pass has to be able to read them instead of rebuilding it from `authz_*`) |
|
|
664
|
+
| `E_AUTHZ_UNSUPPORTED` | 500 | a primitive needs an optional port method the active driver lacks: `listDenies` (2.1; also behind `defineScopedRole`), `purgeRole` (2.2 — behind `deleteScopedRole`, `authz:catalog:prune-orphans` and, before writing anything, `defineScopedRole`; the `openfga` driver until 2.3); `reconcile` (2.3 — `authz:reconcile --to=<driver>`); `enumerateFacts` (2.3 — being the *source* of `authz:reconcile`; the `database` driver does not implement it: its facts are `authz_*`. Also raised when `--to` is the **active** driver and declares `hierarchyFacts`: its facts are its own, so the pass has to be able to read them instead of rebuilding it from `authz_*`). Also `{ transaction }` on `grant`/`revoke`/`deny`/`removeDeny` or `relate`/`unrelate`/`purge*` when the active driver declares `transactionalWrites: false` (2.4 — the message names the driver and the operation, nothing was called; the way out is the `database` driver or `requireTransactionalWrites`; the `openfga` drivers raise it themselves too, so `manager.driver()` is not a way out — see [the recipe by direction](#openfga-the-recipe-by-direction)), and `{ transaction }` on the delegation API (`defineScopedRole`/`updateScopedRole`/`deleteScopedRole`: the catalog is written through `withAuthzCatalogWrite`, the cross-process serializer, which a caller's transaction would defeat) |
|
|
532
665
|
| `E_AUTHZ_MODEL_TOO_LARGE` | 500 | the catalog does not fit in an OpenFGA authorization model (262,144 **bytes** — around **450 permissions with realistic slugs**; see [the model ceiling](#the-model-ceiling-is-bytes-not-permissions)): checked in `syncAuthzCatalog` **before** writing, with a warning past 80 % |
|
|
533
666
|
| `E_AUTHZ_RELATION_CONFIG` | 422 | a `relations/` object type or relation cannot be **fused** into the shared `facts` model: it duplicates a reserved `facts` type (`scope`/`role`/`role_binding`/`deny_binding`/`group`) or relation family (`can_<P>`/`denied_<P>`/`permits_<P>`/`parent`/`rooted`/`assignee`…), or a relation name collides with a catalog permission (F-04). The generator raises it — not OpenFGA's opaque 400 — because in the shared store the id-spaces overlap |
|
|
534
667
|
| `E_AUTHZ_SCOPE_DRIFT_UNGUARDED` | 500 | the `openfga` driver was constructed without `scopes.outbox` and without `acceptScopeDriftRisk: true`. Thrown at construction: a rollback of your transaction would otherwise leave the store's tree ahead of yours, and that escalation is invisible from your database |
|
|
@@ -572,6 +705,7 @@ Every driver **declares** this through `driver.capabilities`, and each declared
|
|
|
572
705
|
| `countRoleAssignments` | `true` | **`true`** | how many live facts a role has — what makes `stillGranting` true instead of a guess (2.3; same enumeration `purgeRole` needs) |
|
|
573
706
|
| `canonicalScopeReads` | `true` | **`false`** | whether a read canonicalises the caller's scope spelling against your tree before looking for facts (see below) |
|
|
574
707
|
| `enumerateFacts` | `false` | **`true`** | the driver can be the **source** of a migration: `enumerateFacts` hands its live facts back page by page, unfiltered and with their expiry (2.3). `database` declares `false` on purpose — its facts *are* `authz_assignments`/`authz_denies`, this package's published schema, so the destination reads them straight from there |
|
|
708
|
+
| `transactionalWrites` | **`true`** (default; declare `transactionalWrites: false` in the driver options on a pool of 1) | **`false`** | `grant`/`revoke`/`deny`/`removeDeny` with **`{ transaction }`** write the fact **inside the caller's transaction** — *both or neither*, never *"not lost"* (2.4, the `{trx}` panel). `database` does it since 2.4.0-alpha.2 (lot L-3): the write goes through your open Lucid transaction of the driver's connection (`assertCallerTransaction`: another connection, a `QueryClient` or the whole `db` are 500 `E_AUTHZ_CONFIG` before any statement), the authority never does, and the published suite pins **rollback ⇒ zero rows by census** for the four writes — see [writing inside your transaction](#writing-inside-your-transaction-240-alpha2). `openfga` cannot be anything but `false`: a tuple does not enter a SQL transaction and there is no 2PC; what it offers instead is [the recipe by direction](#openfga-the-recipe-by-direction). With `false`, `{ transaction }` is **500 `E_AUTHZ_UNSUPPORTED`** naming driver and operation, with **zero driver calls** (the manager's gate *and* the driver's own) — never ignored, never a warning; `requireTransactionalWrites: true` in the config makes it **500 `E_AUTHZ_CONFIG` when the driver is resolved** (the deployment does not start). Same name and same two gates on `RelationsDriverCapabilities` (`relate`/`unrelate`/`purgeObject`/`purgeSubject`; `relations.requireTransactionalWrites`, inherited from the root) — and since lot L-4 the `database` **relations** driver declares **`true`** too (`new DatabaseRelationsDriver(config, { transactionalWrites: false })` on a pool of 1; see [writing relations inside your transaction](#relations-rebac-24)). The only pair whose **two** faces are mandatory in the suite. Not to be confused with `scopes.*`'s `{ transaction }`, which **enqueues** in the outbox — enqueueing ≠ writing |
|
|
575
709
|
|
|
576
710
|
**Two answers `facts` does not share with `database`, declared — not scheduled fixes (2.3).** `authorize` decides with the tree that lives in the store; it never calls your `resolveChain`. That is the property you are buying: a PDP that answers when your database does not. It has two consequences, and each one is a **capability pair with a negative case** in the published contract suite — the package does not skip them, it judges both faces.
|
|
577
711
|
|
|
@@ -632,7 +766,7 @@ runAuthorizationDriverContract({
|
|
|
632
766
|
level: '2.2', // '2.1' = up to Phase 2, '2.0' = up to Phase 1; omit for the 1.x cases only
|
|
633
767
|
capabilities: { // what the driver declares; each one has its own cases
|
|
634
768
|
hierarchyFacts: false,
|
|
635
|
-
|
|
769
|
+
transactionalWrites: false, // can the driver write grant/revoke/deny/removeDeny INSIDE the caller's transaction (2.4)? false ⇒ { transaction } is 500 E_AUTHZ_UNSUPPORTED, with zero driver calls; true ⇒ the suite opens a transaction and pins rollback ⇒ ZERO rows by census (the `transactions` hook below; default Lucid's db.transaction() + authz_assignments/authz_denies)
|
|
636
770
|
truncationSignal: false,
|
|
637
771
|
singleCheckAuthorize: false,
|
|
638
772
|
injectableClock: false,
|
|
@@ -649,6 +783,9 @@ runAuthorizationDriverContract({
|
|
|
649
783
|
// process would be). Default: a prototype view of the driver with a fresh `CatalogCache` when it exposes `catalog`.
|
|
650
784
|
makeTwin: (driver, tree) => new MyDriver({ resolveChain: resolveChainFrom(tree), sameBackendAs: driver }),
|
|
651
785
|
seedCatalog: (catalog) => syncAuthzCatalog(catalog),
|
|
786
|
+
// Only with `transactionalWrites: true` (2.4): how the suite opens a transaction of YOUR driver's connection
|
|
787
|
+
// and counts a holder's facts in YOUR backend (the census). Omit it for Lucid's `db.transaction()` + `authz_*`.
|
|
788
|
+
transactions: { begin: async () => openMyTransaction(), census: async (subject) => countMyFacts(subject) },
|
|
652
789
|
cleanup: () => wipeEverything(),
|
|
653
790
|
})
|
|
654
791
|
```
|
|
@@ -755,7 +892,11 @@ What one pass does, in order: the **root marker** (`scope:app#rooted`, without w
|
|
|
755
892
|
|
|
756
893
|
The freeze has an **owner and a lease**. `manager.freeze(reason?, { leaseMs?, kind? })` takes the row (a live freeze held by someone else is 423 `E_AUTHZ_FREEZE_HELD`, never two owners) and returns a **token** (`{ fence, holder }`); `unfreeze(token)` only lifts the freeze whose token matches, so a nested or stale window can never lift somebody else's barrier — a `reconcile` running *inside* a frozen window runs inside it and leaves it standing. The lease (default 15 s) is renewed conditionally every `leaseMs / 3` while the freezing process lives; if that process dies (`SIGKILL`, OOM, pod eviction) **the fleet resumes writing on its own within `leaseMs`** — nobody cleans a row by hand. And the guarantee is **demonstrated, not assumed**: the writing pass reports `frozen: { durable, lapsed, leaseMs, fence }`, and `lapsed: true` — the lease was lost mid-pass (an event-loop stall longer than the lease, the database down, someone lifting the window) — means the pass is **not certified** and the command exits non-zero.
|
|
757
894
|
|
|
758
|
-
**What the freeze does NOT freeze, by name**: `syncAuthzCatalog` (a free function that never sees the manager — a sync during the window changes what the catalog grants), `manager.driver()` (the documented way out of *all* the manager's barriers), and your own scope-tree tables (your SQL never passes through this package). While the window is open the relay cannot drain either, so the queued-tree window *grows* with the freeze — the report counts it.
|
|
895
|
+
**What the freeze does NOT freeze, by name**: `syncAuthzCatalog` (a free function that never sees the manager — a sync during the window changes what the catalog grants), `manager.driver()` (the documented way out of *all* the manager's barriers), and your own scope-tree tables (your SQL never passes through this package). While the window is open the relay cannot drain either, so the queued-tree window *grows* with the freeze — the report counts it.
|
|
896
|
+
|
|
897
|
+
**What it DOES freeze since 2.4.0-alpha.2 (lot L-1): the relations engine too.** `RelationsManager.relate`/`unrelate`/`purgeObject`/`purgeSubject` pass **the same barrier** (the same row, the same function, the same retryable 503 `E_AUTHZ_FROZEN`) before validating anything; `check`/`listObjects`/`listSubjects`/`membersOf` keep answering. Until L-1 nothing in `relations/` looked at the freeze: during a cutover role writes got 503 and relation writes went in. And **the barrier is read through the engine's own connection, never through a caller's `transaction`** — that reading, introduced in 2.3 "so a pool of 1 would not deadlock", let the caller's snapshot decide whether the engine was frozen; it is gone, and `{ transaction }` now requires a pool of at least 2 (see [the tree outbox](#the-tree-outbox-and-the-relay-lag-you-are-accepting)). Two more honest boundaries: the guarantee holds only between processes that **share the `authz_*` tables** (that is invariant 14's deployment shape; a process pointed at another database sees nothing), and it is a guarantee of **this package's manager** — a third-party driver inherits the wording but the published contract suite never checks it (`MigrationContractHarness` has no manager, no second writer and no second process).
|
|
898
|
+
|
|
899
|
+
**And `{ transaction }` neither shortens nor skips the window (2.4.0-alpha.2, lot L-6).** The authority is read through the engine's own connection, so **a snapshot taken before the freeze does not skip it** — a transaction of yours opened before the freeze gets the same retryable 503 as everyone else (pinned on the three engines, with the barrier's own deadline on a pool of 1). What changes is the *other* arm of the sentence above: a write that had passed its barrier before the freeze landed still lands, and with `{ transaction }` it lands **when your transaction commits** — the window between barrier and commit is now **as long as your transaction**, minutes if you keep it open, at your discretion. The freeze still promises "another process gets a retryable 503", never "no write enters the window", and what it does NOT freeze does not change: `syncAuthzCatalog`, `manager.driver()` and your own scope-tree tables.
|
|
759
900
|
|
|
760
901
|
### The cutover window: `authz:freeze` / `authz:unfreeze` (2.3)
|
|
761
902
|
|
|
@@ -857,7 +998,8 @@ changes:
|
|
|
857
998
|
- **`authz_relations` and `authz_relations_config`** — the ReBAC tables of 2.4. A fresh install gets
|
|
858
999
|
them from the published forward migration (`node ace configure` publishes all eight tables); the
|
|
859
1000
|
ALTER recipe below does **not** create them, because they are new tables, not a transformation of
|
|
860
|
-
1.x ones.
|
|
1001
|
+
1.x ones. `authz_relations.expires_at` (`DATETIME(3)`, nullable) arrived in **2.4.0-alpha.2**: see
|
|
1002
|
+
[the alpha.1 → alpha.2 recipe](#upgrading-from-240-alpha1-authz_relationsexpires_at).
|
|
861
1003
|
- Identity columns become **`varchar(64)` `collate utf8mb4_bin`** (not `uuid`), so a non-UUID id
|
|
862
1004
|
(`user-42`, a ULID) is valid and case is compared byte-wise; `expires_at` becomes **`DATETIME(3)`**
|
|
863
1005
|
(millisecond-exact, valid past 2038). Each was a red test first — see [Operational notes for the SQL
|
|
@@ -940,6 +1082,94 @@ ALTER TABLE authz_roles
|
|
|
940
1082
|
ALTER TABLE authz_permissions ADD COLUMN assignable_at varchar(500) NULL;
|
|
941
1083
|
```
|
|
942
1084
|
|
|
1085
|
+
### Upgrading from 2.4.0-alpha.1: `authz_relations.expires_at`
|
|
1086
|
+
|
|
1087
|
+
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.
|
|
1088
|
+
|
|
1089
|
+
Relation expiry (R-15) adds **one nullable column** to `authz_relations`; nothing else changes and
|
|
1090
|
+
existing rows keep granting (NULL = no expiry). It is the same type decision as
|
|
1091
|
+
`authz_assignments.expires_at` (millisecond precision, valid past 2038 — 2.5 · J3). A Lucid
|
|
1092
|
+
migration does it engine-agnostically:
|
|
1093
|
+
|
|
1094
|
+
```ts
|
|
1095
|
+
this.schema.alterTable('authz_relations', (table) => {
|
|
1096
|
+
table.datetime('expires_at', { precision: 3 }).nullable()
|
|
1097
|
+
})
|
|
1098
|
+
```
|
|
1099
|
+
|
|
1100
|
+
Or by hand:
|
|
1101
|
+
|
|
1102
|
+
```sql
|
|
1103
|
+
-- PostgreSQL
|
|
1104
|
+
ALTER TABLE authz_relations ADD COLUMN expires_at timestamptz(3) NULL;
|
|
1105
|
+
-- MySQL
|
|
1106
|
+
ALTER TABLE authz_relations ADD COLUMN expires_at datetime(3) NULL;
|
|
1107
|
+
-- SQLite
|
|
1108
|
+
ALTER TABLE authz_relations ADD COLUMN expires_at datetime NULL;
|
|
1109
|
+
```
|
|
1110
|
+
|
|
1111
|
+
The `openfga` driver needs **no store migration**: the fused model gains `with not_expired` on every
|
|
1112
|
+
relation subject, so republish it (`node ace openfga:provision --store-id <id>` with your
|
|
1113
|
+
`relations.config` loaded, or any `saveRelationsConfig`/catalog sync that republishes the fused model)
|
|
1114
|
+
— existing tuples have no condition and keep granting without expiry.
|
|
1115
|
+
|
|
1116
|
+
### Upgrading from 2.4.0-alpha.2 (before L-4b): `authz_relations.subject_relation`
|
|
1117
|
+
|
|
1118
|
+
Lot L-4b makes `subject_relation` **`NOT NULL DEFAULT ''`** (`''` = holder, a value = userset) so the unique
|
|
1119
|
+
index `authz_rel_tuple_uq` guards holder tuples too — with `NULL` it only guarded usersets, because every
|
|
1120
|
+
engine treats two `NULL`s as distinct, and two concurrent `relate`s of the same holder committed two identical
|
|
1121
|
+
rows (see [writing relations inside your transaction](#relations-rebac-24)). The `database` driver now writes
|
|
1122
|
+
`''` and **tolerates an old `NULL` row on read** (it is a holder: `check`/`list*`/`membersOf` count it, `relate`
|
|
1123
|
+
of the same holder finds it and is a no-op, `unrelate`/`purgeSubject` delete it). **But the recipe belongs to the
|
|
1124
|
+
same deploy as the package upgrade, not "later"**: until step 4 the *old* partition trigger (`IS NOT NULL`)
|
|
1125
|
+
fires on every holder the new driver inserts (`''` with `subject_partition` NULL), so **every holder `relate`
|
|
1126
|
+
is refused** — 503 `E_AUTHZ_BACKEND_UNAVAILABLE` carrying the trigger's message, fail-closed, the driver's
|
|
1127
|
+
internal transaction undone (measured on the three engines). Reads and deletes keep working; the census counts
|
|
1128
|
+
the old duplicates until you run it. The recipe is **idempotent** (the suite runs it twice). Four steps, **in
|
|
1129
|
+
this order**: **re-create the partition trigger first** — its "is a userset" test becomes
|
|
1130
|
+
`subject_relation <> ''`; the old `IS NOT NULL` fires on `''`, so with it in place *the backfill itself is
|
|
1131
|
+
refused* by the `BEFORE UPDATE` trigger (measured on the three engines) —, then **de-duplicate** the holder
|
|
1132
|
+
rows the old index let through (keeps the oldest `uuid` per tuple; without it the backfill hits the unique
|
|
1133
|
+
index), then **backfill** `NULL` → `''`, then **alter** the column. One statement per line; the trigger bodies
|
|
1134
|
+
carry `;` inside, so run each line **whole** (never through a runner that splits on `;`). The suite executes
|
|
1135
|
+
each block verbatim on its engine (`upgrade_recipe.spec`).
|
|
1136
|
+
|
|
1137
|
+
```sql
|
|
1138
|
+
-- L-4b · PostgreSQL
|
|
1139
|
+
CREATE OR REPLACE FUNCTION authz_relations_partition_guard() RETURNS trigger LANGUAGE plpgsql AS $authz$ BEGIN IF NEW.subject_relation <> '' AND NEW.subject_partition IS DISTINCT FROM NEW.partition_key THEN RAISE EXCEPTION 'authz_relations: un userset no puede pertenecer a otra partición que su tupla (subject_partition=%, partition_key=%)', NEW.subject_partition, NEW.partition_key; END IF; RETURN NEW; END; $authz$;
|
|
1140
|
+
DELETE FROM authz_relations WHERE subject_relation IS NULL AND CAST(uuid AS CHAR(36)) NOT IN (SELECT keep FROM (SELECT MIN(CAST(uuid AS CHAR(36))) AS keep FROM authz_relations WHERE subject_relation IS NULL GROUP BY partition_key, object_type, object_uuid, relation, subject_type, subject_uuid) AS k);
|
|
1141
|
+
UPDATE authz_relations SET subject_relation = '' WHERE subject_relation IS NULL;
|
|
1142
|
+
ALTER TABLE authz_relations ALTER COLUMN subject_relation SET DEFAULT '', ALTER COLUMN subject_relation SET NOT NULL;
|
|
1143
|
+
```
|
|
1144
|
+
|
|
1145
|
+
```sql
|
|
1146
|
+
-- L-4b · MySQL
|
|
1147
|
+
DROP TRIGGER IF EXISTS authz_relations_partition_bi;
|
|
1148
|
+
DROP TRIGGER IF EXISTS authz_relations_partition_bu;
|
|
1149
|
+
CREATE TRIGGER authz_relations_partition_bi BEFORE INSERT ON authz_relations FOR EACH ROW BEGIN IF NEW.subject_relation <> '' AND NOT (NEW.subject_partition <=> NEW.partition_key) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'authz_relations: un userset no puede pertenecer a otra partición que su tupla'; END IF; END;
|
|
1150
|
+
CREATE TRIGGER authz_relations_partition_bu BEFORE UPDATE ON authz_relations FOR EACH ROW BEGIN IF NEW.subject_relation <> '' AND NOT (NEW.subject_partition <=> NEW.partition_key) THEN SIGNAL SQLSTATE '45000' SET MESSAGE_TEXT = 'authz_relations: un userset no puede pertenecer a otra partición que su tupla'; END IF; END;
|
|
1151
|
+
DELETE FROM authz_relations WHERE subject_relation IS NULL AND CAST(uuid AS CHAR(36)) NOT IN (SELECT keep FROM (SELECT MIN(CAST(uuid AS CHAR(36))) AS keep FROM authz_relations WHERE subject_relation IS NULL GROUP BY partition_key, object_type, object_uuid, relation, subject_type, subject_uuid) AS k);
|
|
1152
|
+
UPDATE authz_relations SET subject_relation = '' WHERE subject_relation IS NULL;
|
|
1153
|
+
ALTER TABLE authz_relations MODIFY subject_relation varchar(50) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL DEFAULT '';
|
|
1154
|
+
```
|
|
1155
|
+
|
|
1156
|
+
```sql
|
|
1157
|
+
-- L-4b · SQLite
|
|
1158
|
+
DROP TRIGGER IF EXISTS authz_relations_partition_bi;
|
|
1159
|
+
DROP TRIGGER IF EXISTS authz_relations_partition_bu;
|
|
1160
|
+
CREATE TRIGGER authz_relations_partition_bi BEFORE INSERT ON authz_relations FOR EACH ROW WHEN NEW.subject_relation <> '' AND NEW.subject_partition IS NOT NEW.partition_key BEGIN SELECT RAISE(ABORT, 'authz_relations: un userset no puede pertenecer a otra partición que su tupla'); END;
|
|
1161
|
+
CREATE TRIGGER authz_relations_partition_bu BEFORE UPDATE ON authz_relations FOR EACH ROW WHEN NEW.subject_relation <> '' AND NEW.subject_partition IS NOT NEW.partition_key BEGIN SELECT RAISE(ABORT, 'authz_relations: un userset no puede pertenecer a otra partición que su tupla'); END;
|
|
1162
|
+
DELETE FROM authz_relations WHERE subject_relation IS NULL AND CAST(uuid AS CHAR(36)) NOT IN (SELECT keep FROM (SELECT MIN(CAST(uuid AS CHAR(36))) AS keep FROM authz_relations WHERE subject_relation IS NULL GROUP BY partition_key, object_type, object_uuid, relation, subject_type, subject_uuid) AS k);
|
|
1163
|
+
UPDATE authz_relations SET subject_relation = '' WHERE subject_relation IS NULL;
|
|
1164
|
+
```
|
|
1165
|
+
|
|
1166
|
+
**SQLite has no `ALTER COLUMN`**: after the backfill the column stays nullable there (the suite measures that
|
|
1167
|
+
this is the *only* difference from the published schema). The driver never writes `NULL` and the unique index
|
|
1168
|
+
already guards `'' = ''`, so the guarantee holds; if you want the constraint itself, recreate the table with the
|
|
1169
|
+
DDL of the published migration (`stubs/migration.stub`) and copy the rows. The de-duplication keeps the oldest
|
|
1170
|
+
row per tuple: if the two rows raced with different expiries, the survivor is the first written. The `openfga`
|
|
1171
|
+
driver needs nothing — a tuple is unique by construction.
|
|
1172
|
+
|
|
943
1173
|
## Compatibility
|
|
944
1174
|
|
|
945
1175
|
| | |
|
|
@@ -1,6 +1,30 @@
|
|
|
1
1
|
import { BaseCommand } from '@adonisjs/core/ace';
|
|
2
2
|
import { CommandOptions } from '@adonisjs/core/types/ace';
|
|
3
|
-
import type { RelationsReconcileReport } from '../src/relations/reconcile.js';
|
|
3
|
+
import type { RelationsReconcileOptions, RelationsReconcileReport } from '../src/relations/reconcile.js';
|
|
4
|
+
import type { AuthorizationManager } from '../src/manager.js';
|
|
5
|
+
/**
|
|
6
|
+
* **La pasada de relaciones bajo la ventana DURABLE** (L-1 · J1, juez del
|
|
7
|
+
* panel `{trx}`). `reconcileRelations` (`src/relations/`) no puede ver al
|
|
8
|
+
* manager de roles (regla 2 de pureza), así que la ventana la abre el
|
|
9
|
+
* comando, aquí, con la misma forma que `authz:reconcile` de roles:
|
|
10
|
+
*
|
|
11
|
+
* - `--dry-run` **NO congela** (el verificador es read-only y está hecho
|
|
12
|
+
* para un cron: un mecanismo de indisponibilidad no se dispara desde ahí);
|
|
13
|
+
* - la pasada que ESCRIBE corre bajo `withFrozenWrites` con `kind:
|
|
14
|
+
* 'reconcile'` y la ventana del OPERADOR como contexto propio (el cutover:
|
|
15
|
+
* corre dentro, no la toma, no la renueva, no la levanta); un freeze vivo
|
|
16
|
+
* de OTRA pasada es 423;
|
|
17
|
+
* - el reporte publica `frozen` —fence, lease y **`lapsed`**— y un lease
|
|
18
|
+
* perdido a mitad significa que la pasada NO se certifica (exit ≠ 0).
|
|
19
|
+
*
|
|
20
|
+
* Durante la ventana TODA escritura del paquete responde 503 `E_AUTHZ_FROZEN`
|
|
21
|
+
* en la flota: las de roles y, desde L-1, las de relaciones
|
|
22
|
+
* (`RelationsManager` pasa la misma barrera). Función exportada para tener su
|
|
23
|
+
* caso sin montar un ace.
|
|
24
|
+
*/
|
|
25
|
+
export declare function runRelationsReconcile(authorization: Pick<AuthorizationManager, 'withFrozenWrites'>, options: RelationsReconcileOptions & {
|
|
26
|
+
toKey?: string;
|
|
27
|
+
}): Promise<RelationsReconcileReport>;
|
|
4
28
|
/**
|
|
5
29
|
* Las líneas del reporte de `authz:relations:reconcile` y si la pasada es
|
|
6
30
|
* «limpia», en una función PURA (mismo patrón que `reconcileLines` de roles):
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"authz_relations_reconcile.d.ts","sourceRoot":"","sources":["../../commands/authz_relations_reconcile.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAS,MAAM,oBAAoB,CAAA;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAA;AACzD,OAAO,KAAK,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAA;
|
|
1
|
+
{"version":3,"file":"authz_relations_reconcile.d.ts","sourceRoot":"","sources":["../../commands/authz_relations_reconcile.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAS,MAAM,oBAAoB,CAAA;AACvD,OAAO,EAAE,cAAc,EAAE,MAAM,0BAA0B,CAAA;AACzD,OAAO,KAAK,EAAE,yBAAyB,EAAE,wBAAwB,EAAE,MAAM,+BAA+B,CAAA;AACxG,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,mBAAmB,CAAA;AAE7D;;;;;;;;;;;;;;;;;;;GAmBG;AACH,wBAAsB,qBAAqB,CACzC,aAAa,EAAE,IAAI,CAAC,oBAAoB,EAAE,kBAAkB,CAAC,EAC7D,OAAO,EAAE,yBAAyB,GAAG;IAAE,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GACtD,OAAO,CAAC,wBAAwB,CAAC,CAanC;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,wBAAgB,uBAAuB,CAAC,MAAM,EAAE,wBAAwB,GAAG;IACzE,KAAK,EAAE,KAAK,CAAC;QAAE,KAAK,EAAE,KAAK,GAAG,SAAS,GAAG,OAAO,GAAG,SAAS,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAA;IACjF,KAAK,EAAE,OAAO,CAAA;CACf,CAiGA;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AACH,MAAM,CAAC,OAAO,OAAO,uBAAwB,SAAQ,WAAW;IAC9D,MAAM,CAAC,WAAW,SAA8B;IAChD,MAAM,CAAC,WAAW,SAAgG;IAElH,MAAM,CAAC,OAAO,EAAE,cAAc,CAE7B;IAGO,EAAE,EAAE,MAAM,GAAG,SAAS,CAAA;IAGtB,IAAI,EAAE,MAAM,GAAG,SAAS,CAAA;IAGxB,MAAM,EAAE,OAAO,GAAG,SAAS,CAAA;IAG3B,KAAK,EAAE,OAAO,GAAG,SAAS,CAAA;IAM1B,eAAe,EAAE,OAAO,GAAG,SAAS,CAAA;IAGpC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAA;IAGjC,aAAa,EAAE,MAAM,GAAG,SAAS,CAAA;IAEnC,GAAG;CAqHV"}
|