@jantstack/adonis-authz 2.5.0-alpha.3 → 2.5.0-rc.1
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 +437 -48
- package/build/commands/authz_catalog_diff.d.ts +0 -1
- package/build/commands/authz_catalog_diff.js +0 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts +17 -3
- package/build/commands/authz_catalog_prune_orphans.js +28 -6
- package/build/commands/authz_catalog_sync.d.ts +0 -1
- package/build/commands/authz_catalog_sync.js +0 -1
- package/build/commands/authz_freeze.d.ts +0 -1
- package/build/commands/authz_freeze.js +0 -1
- package/build/commands/authz_reconcile.d.ts +0 -1
- package/build/commands/authz_reconcile.js +0 -1
- package/build/commands/authz_relations_reconcile.d.ts +0 -1
- package/build/commands/authz_relations_reconcile.js +56 -8
- package/build/commands/authz_scopes_relay.d.ts +0 -1
- package/build/commands/authz_scopes_relay.js +54 -6
- package/build/commands/authz_scopes_requeue.d.ts +0 -1
- package/build/commands/authz_scopes_requeue.js +16 -3
- package/build/commands/authz_unfreeze.d.ts +0 -1
- package/build/commands/authz_unfreeze.js +0 -1
- package/build/commands/main.d.ts +0 -1
- package/build/commands/main.js +0 -1
- package/build/commands/openfga_provision.d.ts +10 -1
- package/build/commands/openfga_provision.js +43 -3
- package/build/configure.d.ts +0 -1
- package/build/configure.js +0 -1
- package/build/index.d.ts +9 -7
- package/build/index.js +10 -3
- package/build/providers/authz_provider.d.ts +0 -1
- package/build/providers/authz_provider.js +0 -1
- package/build/services/main.d.ts +0 -1
- package/build/services/main.js +0 -1
- package/build/services/relations.d.ts +0 -1
- package/build/services/relations.js +0 -1
- package/build/src/catalog/catalog.d.ts +0 -1
- package/build/src/catalog/catalog.js +0 -1
- package/build/src/catalog/catalog_cache.d.ts +13 -1
- package/build/src/catalog/catalog_cache.js +27 -2
- package/build/src/clock.d.ts +0 -1
- package/build/src/clock.js +0 -1
- package/build/src/define_config.d.ts +0 -1
- package/build/src/define_config.js +24 -1
- package/build/src/drivers/database_driver.d.ts +60 -3
- package/build/src/drivers/database_driver.js +160 -25
- package/build/src/drivers/database_relations_driver.d.ts +11 -5
- package/build/src/drivers/database_relations_driver.js +115 -33
- package/build/src/drivers/openfga_driver.d.ts +159 -26
- package/build/src/drivers/openfga_driver.js +882 -199
- package/build/src/drivers/openfga_facts.d.ts +91 -1
- package/build/src/drivers/openfga_facts.js +277 -3
- package/build/src/drivers/openfga_relations_driver.d.ts +41 -13
- package/build/src/drivers/openfga_relations_driver.js +422 -228
- package/build/src/errors.d.ts +160 -1
- package/build/src/errors.js +161 -1
- package/build/src/expiry.d.ts +51 -2
- package/build/src/expiry.js +96 -2
- package/build/src/freeze.d.ts +0 -1
- package/build/src/freeze.js +0 -1
- package/build/src/hierarchical_resolver.d.ts +0 -1
- package/build/src/hierarchical_resolver.js +0 -1
- package/build/src/http/app_access_middleware.d.ts +0 -1
- package/build/src/http/app_access_middleware.js +0 -1
- package/build/src/http/resource_access_middleware.d.ts +0 -1
- package/build/src/http/resource_access_middleware.js +0 -1
- package/build/src/identity.d.ts +9 -2
- package/build/src/identity.js +33 -1
- package/build/src/manager.d.ts +24 -1
- package/build/src/manager.js +381 -85
- package/build/src/memoize_ancestors.d.ts +0 -1
- package/build/src/memoize_ancestors.js +0 -1
- package/build/src/models/authz_assignment.d.ts +5 -6
- package/build/src/models/authz_assignment.js +0 -1
- package/build/src/models/authz_deny.d.ts +5 -6
- package/build/src/models/authz_deny.js +0 -1
- package/build/src/models/authz_permission.d.ts +5 -6
- package/build/src/models/authz_permission.js +0 -1
- package/build/src/models/authz_role.d.ts +5 -6
- package/build/src/models/authz_role.js +0 -1
- package/build/src/models/authz_role_permission.d.ts +5 -6
- package/build/src/models/authz_role_permission.js +0 -1
- package/build/src/openfga.d.ts +2 -3
- package/build/src/openfga.js +4 -2
- package/build/src/reconcile.d.ts +0 -1
- package/build/src/reconcile.js +0 -1
- package/build/src/relation_partition_trigger.d.ts +0 -1
- package/build/src/relation_partition_trigger.js +0 -1
- package/build/src/relations/check_many.d.ts +0 -1
- package/build/src/relations/check_many.js +0 -1
- package/build/src/relations/define_relations_config.d.ts +25 -3
- package/build/src/relations/define_relations_config.js +8 -3
- package/build/src/relations/deny_reach.d.ts +102 -0
- package/build/src/relations/deny_reach.js +230 -0
- package/build/src/relations/group_depth.d.ts +215 -0
- package/build/src/relations/group_depth.js +453 -0
- package/build/src/relations/manager.d.ts +14 -7
- package/build/src/relations/manager.js +60 -15
- package/build/src/relations/page.d.ts +19 -0
- package/build/src/relations/page.js +74 -0
- package/build/src/relations/reconcile.d.ts +58 -4
- package/build/src/relations/reconcile.js +266 -12
- package/build/src/relations/tuple_key.d.ts +0 -1
- package/build/src/relations/tuple_key.js +0 -1
- package/build/src/relations/tuple_order.d.ts +5 -4
- package/build/src/relations/tuple_order.js +5 -4
- package/build/src/relations_config_store.d.ts +57 -2
- package/build/src/relations_config_store.js +155 -5
- package/build/src/relations_reconcile.d.ts +0 -1
- package/build/src/relations_reconcile.js +0 -1
- package/build/src/scope_outbox.d.ts +36 -3
- package/build/src/scope_outbox.js +78 -18
- package/build/src/shared/backend_guard.d.ts +52 -1
- package/build/src/shared/backend_guard.js +164 -2
- package/build/src/shared/sql_expiry.d.ts +0 -1
- package/build/src/shared/sql_expiry.js +0 -1
- package/build/src/shared/transaction_guard.d.ts +0 -1
- package/build/src/shared/transaction_guard.js +0 -1
- package/build/src/sql_descendants.d.ts +0 -1
- package/build/src/sql_descendants.js +0 -1
- package/build/src/testing/contract.d.ts +26 -3
- package/build/src/testing/contract.js +258 -20
- package/build/src/testing/main.d.ts +0 -1
- package/build/src/testing/main.js +0 -1
- package/build/src/testing/migration_contract.d.ts +0 -1
- package/build/src/testing/migration_contract.js +0 -1
- package/build/src/testing/relations_contract.d.ts +7 -1
- package/build/src/testing/relations_contract.js +174 -7
- package/build/src/testing/relations_reconcile_contract.d.ts +0 -1
- package/build/src/testing/relations_reconcile_contract.js +0 -1
- package/build/src/testing/scope_outbox_contract.d.ts +0 -1
- package/build/src/testing/scope_outbox_contract.js +0 -1
- package/build/src/testing/scope_tree.d.ts +0 -1
- package/build/src/testing/scope_tree.js +0 -1
- package/build/src/testing/test_clock.d.ts +0 -1
- package/build/src/testing/test_clock.js +0 -1
- package/build/src/traits/authz_scopes.d.ts +0 -1
- package/build/src/traits/authz_scopes.js +0 -1
- package/build/src/traits/has_uuid.d.ts +5 -6
- package/build/src/traits/has_uuid.js +0 -1
- package/build/src/types.d.ts +138 -14
- package/build/src/types.js +6 -1
- package/build/stubs/config/authorization.stub +22 -6
- package/build/stubs/main.d.ts +0 -1
- package/build/stubs/main.js +0 -1
- package/package.json +4 -3
- package/build/commands/authz_catalog_diff.d.ts.map +0 -1
- package/build/commands/authz_catalog_diff.js.map +0 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts.map +0 -1
- package/build/commands/authz_catalog_prune_orphans.js.map +0 -1
- package/build/commands/authz_catalog_sync.d.ts.map +0 -1
- package/build/commands/authz_catalog_sync.js.map +0 -1
- package/build/commands/authz_freeze.d.ts.map +0 -1
- package/build/commands/authz_freeze.js.map +0 -1
- package/build/commands/authz_reconcile.d.ts.map +0 -1
- package/build/commands/authz_reconcile.js.map +0 -1
- package/build/commands/authz_relations_reconcile.d.ts.map +0 -1
- package/build/commands/authz_relations_reconcile.js.map +0 -1
- package/build/commands/authz_scopes_relay.d.ts.map +0 -1
- package/build/commands/authz_scopes_relay.js.map +0 -1
- package/build/commands/authz_scopes_requeue.d.ts.map +0 -1
- package/build/commands/authz_scopes_requeue.js.map +0 -1
- package/build/commands/authz_unfreeze.d.ts.map +0 -1
- package/build/commands/authz_unfreeze.js.map +0 -1
- package/build/commands/main.d.ts.map +0 -1
- package/build/commands/main.js.map +0 -1
- package/build/commands/openfga_provision.d.ts.map +0 -1
- package/build/commands/openfga_provision.js.map +0 -1
- package/build/configure.d.ts.map +0 -1
- package/build/configure.js.map +0 -1
- package/build/index.d.ts.map +0 -1
- package/build/index.js.map +0 -1
- package/build/providers/authz_provider.d.ts.map +0 -1
- package/build/providers/authz_provider.js.map +0 -1
- package/build/services/main.d.ts.map +0 -1
- package/build/services/main.js.map +0 -1
- package/build/services/relations.d.ts.map +0 -1
- package/build/services/relations.js.map +0 -1
- package/build/src/catalog/catalog.d.ts.map +0 -1
- package/build/src/catalog/catalog.js.map +0 -1
- package/build/src/catalog/catalog_cache.d.ts.map +0 -1
- package/build/src/catalog/catalog_cache.js.map +0 -1
- package/build/src/clock.d.ts.map +0 -1
- package/build/src/clock.js.map +0 -1
- package/build/src/define_config.d.ts.map +0 -1
- package/build/src/define_config.js.map +0 -1
- package/build/src/drivers/database_driver.d.ts.map +0 -1
- package/build/src/drivers/database_driver.js.map +0 -1
- package/build/src/drivers/database_relations_driver.d.ts.map +0 -1
- package/build/src/drivers/database_relations_driver.js.map +0 -1
- package/build/src/drivers/openfga_driver.d.ts.map +0 -1
- package/build/src/drivers/openfga_driver.js.map +0 -1
- package/build/src/drivers/openfga_facts.d.ts.map +0 -1
- package/build/src/drivers/openfga_facts.js.map +0 -1
- package/build/src/drivers/openfga_relations_driver.d.ts.map +0 -1
- package/build/src/drivers/openfga_relations_driver.js.map +0 -1
- package/build/src/errors.d.ts.map +0 -1
- package/build/src/errors.js.map +0 -1
- package/build/src/expiry.d.ts.map +0 -1
- package/build/src/expiry.js.map +0 -1
- package/build/src/freeze.d.ts.map +0 -1
- package/build/src/freeze.js.map +0 -1
- package/build/src/hierarchical_resolver.d.ts.map +0 -1
- package/build/src/hierarchical_resolver.js.map +0 -1
- package/build/src/http/app_access_middleware.d.ts.map +0 -1
- package/build/src/http/app_access_middleware.js.map +0 -1
- package/build/src/http/resource_access_middleware.d.ts.map +0 -1
- package/build/src/http/resource_access_middleware.js.map +0 -1
- package/build/src/identity.d.ts.map +0 -1
- package/build/src/identity.js.map +0 -1
- package/build/src/manager.d.ts.map +0 -1
- package/build/src/manager.js.map +0 -1
- package/build/src/memoize_ancestors.d.ts.map +0 -1
- package/build/src/memoize_ancestors.js.map +0 -1
- package/build/src/models/authz_assignment.d.ts.map +0 -1
- package/build/src/models/authz_assignment.js.map +0 -1
- package/build/src/models/authz_deny.d.ts.map +0 -1
- package/build/src/models/authz_deny.js.map +0 -1
- package/build/src/models/authz_permission.d.ts.map +0 -1
- package/build/src/models/authz_permission.js.map +0 -1
- package/build/src/models/authz_role.d.ts.map +0 -1
- package/build/src/models/authz_role.js.map +0 -1
- package/build/src/models/authz_role_permission.d.ts.map +0 -1
- package/build/src/models/authz_role_permission.js.map +0 -1
- package/build/src/openfga.d.ts.map +0 -1
- package/build/src/openfga.js.map +0 -1
- package/build/src/reconcile.d.ts.map +0 -1
- package/build/src/reconcile.js.map +0 -1
- package/build/src/relation_partition_trigger.d.ts.map +0 -1
- package/build/src/relation_partition_trigger.js.map +0 -1
- package/build/src/relations/check_many.d.ts.map +0 -1
- package/build/src/relations/check_many.js.map +0 -1
- package/build/src/relations/define_relations_config.d.ts.map +0 -1
- package/build/src/relations/define_relations_config.js.map +0 -1
- package/build/src/relations/manager.d.ts.map +0 -1
- package/build/src/relations/manager.js.map +0 -1
- package/build/src/relations/reconcile.d.ts.map +0 -1
- package/build/src/relations/reconcile.js.map +0 -1
- package/build/src/relations/tuple_key.d.ts.map +0 -1
- package/build/src/relations/tuple_key.js.map +0 -1
- package/build/src/relations/tuple_order.d.ts.map +0 -1
- package/build/src/relations/tuple_order.js.map +0 -1
- package/build/src/relations_config_store.d.ts.map +0 -1
- package/build/src/relations_config_store.js.map +0 -1
- package/build/src/relations_reconcile.d.ts.map +0 -1
- package/build/src/relations_reconcile.js.map +0 -1
- package/build/src/scope_outbox.d.ts.map +0 -1
- package/build/src/scope_outbox.js.map +0 -1
- package/build/src/shared/backend_guard.d.ts.map +0 -1
- package/build/src/shared/backend_guard.js.map +0 -1
- package/build/src/shared/sql_expiry.d.ts.map +0 -1
- package/build/src/shared/sql_expiry.js.map +0 -1
- package/build/src/shared/transaction_guard.d.ts.map +0 -1
- package/build/src/shared/transaction_guard.js.map +0 -1
- package/build/src/sql_descendants.d.ts.map +0 -1
- package/build/src/sql_descendants.js.map +0 -1
- package/build/src/testing/contract.d.ts.map +0 -1
- package/build/src/testing/contract.js.map +0 -1
- package/build/src/testing/main.d.ts.map +0 -1
- package/build/src/testing/main.js.map +0 -1
- package/build/src/testing/migration_contract.d.ts.map +0 -1
- package/build/src/testing/migration_contract.js.map +0 -1
- package/build/src/testing/relations_contract.d.ts.map +0 -1
- package/build/src/testing/relations_contract.js.map +0 -1
- package/build/src/testing/relations_reconcile_contract.d.ts.map +0 -1
- package/build/src/testing/relations_reconcile_contract.js.map +0 -1
- package/build/src/testing/scope_outbox_contract.d.ts.map +0 -1
- package/build/src/testing/scope_outbox_contract.js.map +0 -1
- package/build/src/testing/scope_tree.d.ts.map +0 -1
- package/build/src/testing/scope_tree.js.map +0 -1
- package/build/src/testing/test_clock.d.ts.map +0 -1
- package/build/src/testing/test_clock.js.map +0 -1
- package/build/src/traits/authz_scopes.d.ts.map +0 -1
- package/build/src/traits/authz_scopes.js.map +0 -1
- package/build/src/traits/has_uuid.d.ts.map +0 -1
- package/build/src/traits/has_uuid.js.map +0 -1
- package/build/src/types.d.ts.map +0 -1
- package/build/src/types.js.map +0 -1
- package/build/stubs/main.d.ts.map +0 -1
- package/build/stubs/main.js.map +0 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Driver-based **authorization engine for AdonisJS 7 + Lucid**: hierarchical scope
|
|
|
5
5
|
Ships two drivers that pass **the same executable contract suite**, case for case:
|
|
6
6
|
|
|
7
7
|
- **`database`** (default) — self-contained, engine-agnostic SQL over its own `authz_*` tables. Zero extra infrastructure.
|
|
8
|
-
- **`openfga`** — facts live in an [OpenFGA](https://openfga.dev) server (Zanzibar model); the catalog and your scope hierarchy stay local, so switching drivers is a facts migration, not a rewrite.
|
|
8
|
+
- **`openfga`** — facts live in an [OpenFGA](https://openfga.dev) server (Zanzibar model); the catalog and your scope hierarchy stay local, so switching drivers is a facts migration, not a rewrite. **Experimental in 2.5.x — not production-ready, and outside the semver guarantee** (only its experimental parts — the opt-in token and the drivers' behaviour — may change in 2.6, a minor, without a major; the rest of the package keeps semver): it refuses to be built without `experimental: 'openfga@2.5'` (500 `E_AUTHZ_CONFIG`), its open races are declared in [Limits](#limits) with figure, recipe and detector, and it closes in **2.6**. In production, use `database`.
|
|
9
9
|
|
|
10
10
|
```ts
|
|
11
11
|
import authorization from '@jantstack/adonis-authz/services/main'
|
|
@@ -45,7 +45,7 @@ These are the eight invariants of the contract. Every one of them is a case in t
|
|
|
45
45
|
2. **Explicit deny wins.** A deny anywhere in the scope chain blocks the permission even if a role grants it; removing the deny restores it; the order of writes does not matter (*"deny explícito gana sobre el rol"*, *"deny antes del grant también bloquea"*). A deny governs `authorize` only — **it does not affect `hasRole`**, which is a membership fact, not an access decision (*"un deny NO afecta a hasRole"*). That is why no middleware in this package accepts a role.
|
|
46
46
|
3. **Expiry is observable, without a scheduler.** An assignment past its `expiresAt` grants nothing — enforced in SQL by `database`, by an FGA *condition* in `openfga`, and filtered client-side in enumerations (*"asignación expirada no concede; expiración futura sí"*).
|
|
47
47
|
4. **Polymorphic holders.** `{ type: morphName, uuid }`. Two holders with the same uuid and a different type never cross (*"holder polimórfico"*). The `openfga` driver refuses a `holderTypes` map that would merge two morph names into one FGA type (*"holderTypes tiene que ser inyectivo"*).
|
|
48
|
-
5. **Deny by default, and three distinguishable outcomes.** No permission, unknown permission, no live assignment, unknown scope → `false`, never a throw. A malformed question (unknown role or permission in `grant`/`deny`/`revoke`/`removeDeny`, invalid identity, bad slug, a `RoleQuery` object where a slug is expected, an `expiresAt` that is not a valid `Date`/`null`, unknown scope on a write) → **422** with an `E_AUTHZ_*` code. A dependency that does not answer — the facts backend, the SQL catalog **in both drivers**, or your own resolver (throwing *or* answering a malformed ancestor) — → **503**, never a silent `false`; in `openfga` a per-check `error` inside a 200 `batchCheck` is a
|
|
48
|
+
5. **Deny by default, and three distinguishable outcomes.** No permission, unknown permission, no live assignment, unknown scope → `false`, never a throw. A malformed question (unknown role or permission in `grant`/`deny`/`revoke`/`removeDeny`, invalid identity, bad slug, a `RoleQuery` object where a slug is expected, an `expiresAt` that is not a valid `Date`/`null`, unknown scope on a write) → **422** with an `E_AUTHZ_*` code. A dependency that does not answer — the facts backend, the SQL catalog **in both drivers**, or your own resolver (throwing *or* answering a malformed ancestor) — → **503**, never a silent `false`; in `openfga` a per-check `error` inside a 200 `batchCheck` is classified exactly like the same failure thrown by a single `Check`, in the deny phase and in the role phase (*"un resolutor de ancestros que lanza ⇒ 503"*, *"authorize con el catálogo inaccesible lanza AuthorizationBackendError"*, *"authorize: error en un check de rol ⇒ 503"*). **Since 2.5.0-alpha.4 (R3) `openfga` tells the dependency that does not answer from the one that answers "no"**: a deployment the config does not match — store or model that does not exist, credentials (401/403), a type, relation or context the pinned model rejects — is **500 `E_AUTHZ_CONFIG` "not transient"** (retrying fails the same way until the deployment is fixed; the tree relay still treats it as transient, so it never parks an entry), an unknown 4xx is 500 with the server's error as `cause`, and a graph deeper than the engine resolves is **503 `E_AUTHZ_RESOLUTION_LIMIT`** (see [Limits](#limits)) — see [How OpenFGA failures are classified](#how-openfga-failures-are-classified-250-alpha4).
|
|
49
49
|
6. **Idempotent writes.** Re-grant does not duplicate; `expiresAt` has three states (below); re-revoke / re-deny / re-removeDeny are safe no-ops, and a repeated deny needs one `removeDeny` (*"grant duplicado es idempotente"*, *"deny repetido no se duplica"*).
|
|
50
50
|
7. **`list*` return direct facts, complete, from the catalog's point of view.** `listSubjects`, `listRoles`, `listRoleScopes`, `listScopes` return *direct*, live assignments of the exact scope — never inherited descendants (that set would be open-ended; ask `authorize` about a concrete scope) — and they return **all of them**: 1,200 direct assignments come back whole in both drivers, and a `listScopes` subtracts a deny even when the holder carries 150 denies of other permissions (more than one `Read` page, more than an OpenFGA server's `ListObjects` cap) (*"listas exhaustivas: 1.200 asignaciones directas"*, *"listScopes resta el deny aunque el sujeto tenga más denies de OTROS permisos que el tope del backend"*). CI runs the judge against a second OpenFGA whose cap is 3. Membership is what the **catalog** says it is: a role removed from `authz_roles` is no longer a membership in either driver, even if `openfga` still holds its tuple (*"rol borrado del catálogo: la tupla sigue en el store pero authorize deniega"*), and a scope the tree no longer knows lists nothing (*"listRoles y listRoleScopes tampoco responden por un scope que el árbol no conoce"*).
|
|
51
51
|
8. **`rank` is metadata.** The engine stores it and never evaluates it (*"rank es metadata"*). "Nobody grants a role at or above their own rank" is your assignment policy.
|
|
@@ -66,7 +66,9 @@ export default defineConfig({
|
|
|
66
66
|
|
|
67
67
|
drivers: {
|
|
68
68
|
database: () => new DatabaseAuthorizationDriver({ resolveChain: resolveScopeAncestors }),
|
|
69
|
-
|
|
69
|
+
// Experimental in 2.5.x: without `experimental: 'openfga@2.5'` it is 500 at construction. The opt-in is left commented
|
|
70
|
+
// on purpose — write it yourself once you have read «OpenFGA tooling» and Limits.
|
|
71
|
+
openfga: () => new OpenFgaAuthorizationDriver({ /* experimental: 'openfga@2.5', */ apiUrl, storeId, modelId, holderTypes, resolveChain: resolveScopeAncestors }),
|
|
70
72
|
},
|
|
71
73
|
|
|
72
74
|
// 3. Your catalogs (one per module), for authz:catalog:sync / diff
|
|
@@ -99,7 +101,7 @@ Your resolver's *answer* is validated too: an element that is not a well-formed
|
|
|
99
101
|
|
|
100
102
|
`ScopeType` is an open `string`, so define your own union for type safety. The engine never queries your tables.
|
|
101
103
|
|
|
102
|
-
**How deep can the tree go?** In `database`, as deep as your resolver walks. With the `openfga` driver the `facts` model resolves a chain reliably up to **22 `parent` hops** (`FACTS_MAX_RESOLVE_DEPTH`) and answers 503 — never `false` — past it, so design the tree with that number in front of you and, if you want the engine to refuse the 23rd level before it is written, declare `scopes.maxDepth` (422 `E_AUTHZ_SCOPE_DEPTH_LIMIT`, partial: it does not see the height of a moved subtree). Every bound the package enforces is in one table: [Limits](#limits).
|
|
104
|
+
**How deep can the tree go?** In `database`, as deep as your resolver walks. With the `openfga` driver the `facts` model resolves a chain reliably up to **22 `parent` hops** (`FACTS_MAX_RESOLVE_DEPTH`) and answers 503 `E_AUTHZ_RESOLUTION_LIMIT` — never `false` — past it, so design the tree with that number in front of you and, if you want the engine to refuse the 23rd level before it is written, declare `scopes.maxDepth` (422 `E_AUTHZ_SCOPE_DEPTH_LIMIT`, partial: it does not see the height of a moved subtree). Every bound the package enforces is in one table: [Limits](#limits).
|
|
103
105
|
|
|
104
106
|
#### The tree outbox, and the relay lag you are accepting
|
|
105
107
|
|
|
@@ -115,7 +117,7 @@ const outbox = sqlScopeOutbox() // or your own implementati
|
|
|
115
117
|
export default defineConfig({
|
|
116
118
|
scopes: { resolveChain, outbox },
|
|
117
119
|
drivers: {
|
|
118
|
-
openfga: () => new OpenFgaAuthorizationDriver({ /* … */ outbox }),
|
|
120
|
+
openfga: () => new OpenFgaAuthorizationDriver({ experimental: 'openfga@2.5', /* … */ outbox }),
|
|
119
121
|
},
|
|
120
122
|
})
|
|
121
123
|
|
|
@@ -155,7 +157,7 @@ The relay is resumable and never silent: the report says *which* changes were ap
|
|
|
155
157
|
|
|
156
158
|
**Closing the incident in one call: `requeueScopeChanges(ids, { apply: true })` / `node ace authz:scopes:requeue --id=<id> --apply` (2.4.0-alpha.8).** Measured in a consumer (panel relay 2, tester P14): after `requeue` the entry comes back with its *original* id — smaller than everything enqueued since — so the tenant's next inline `relayScopeChanges({ only })` found it *in front* and came out `deferred · depends-on:<id>`; the incident needed a full pass behind the command. With `apply: true` the entry is requeued **and relayed in the same call**, and the relay's report travels in `report.relay`. Four rules, each pinned by a case: **one lease** for the whole call, taken *first* — if another pass holds it the result is `relay.busy === true` with **zero rows requeued** and the command exits ≠ 0 (requeueing and then saying "busy" would leave the entry pending with `attempts = maxAttempts − 1`, to be parked again by the next pass and poison its key once more; `waitForLeaseMs` composes with it); it **passes the freeze barrier** before touching anything (with `apply` the command writes the tree; without `apply`, `requeue` still does not, it only touches the queue); it **does not compose with `force`** (500 `E_AUTHZ_CONFIG` — with both, reverting the tree over what was applied later would be a single call); and the internal `only` is **exactly the requeued ids**: what depended on the parked entry is *not* dragged along (it stays `deferred`/pending, the report says so — `remaining`, and the command prints it), because `only` is *your receipt* and nothing else. The anti-reversion probe still runs before anything is requeued (a refusal is 500 with zero driver calls, and the lease is released). If the cause is still broken, the same call parks the entry again (`attempts` goes from `maxAttempts − 1` to `maxAttempts`, `relay.failed` says why); a 503 from the store leaves it pending with `attempts = maxAttempts − 1`, as always. `--apply` is explicit, never the default: without it the command behaves exactly as before. Platform API, like the relay: never reachable over HTTP.
|
|
157
159
|
|
|
158
|
-
**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`). Since 2.4.0-alpha.5 that is **observed between real processes** (`tests/scope_outbox_multiprocess.spec.ts`: a second process relays the same queue while the first holds the lease ⇒ `busy`, zero writes, on PostgreSQL and MySQL), and the SQLite limitation is pinned the same way — the second process *does* apply over the first: **SQLite is not the engine for two replicas of the relay**. If you implement the port yourself and skip `acquire`, run the relay one pass at a time.
|
|
160
|
+
**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`). On MySQL `GET_LOCK` is per **server**, so since the rotation 2 closure the lease name carries the **database** the holding session reads with `DATABASE()`; during 2.5.x every pass also takes the previous name (without the database) first, so a rolling deploy from 2.5.0-alpha.4 keeps a single writer — and two deployments on different databases of one server still exclude each other until 2.6 (declared in [Limits](#limits)). PostgreSQL's advisory lock is per database and does not change. Since 2.4.0-alpha.5 that is **observed between real processes** (`tests/scope_outbox_multiprocess.spec.ts`: a second process relays the same queue while the first holds the lease ⇒ `busy`, zero writes, on PostgreSQL and MySQL), and the SQLite limitation is pinned the same way — the second process *does* apply over the first: **SQLite is not the engine for two replicas of the relay**. If you implement the port yourself and skip `acquire`, run the relay one pass at a time.
|
|
159
161
|
|
|
160
162
|
**Waiting for the lease instead of giving up: `relayScopeChanges({ waitForLeaseMs })` / `--wait-lease-ms` (2.4.0-alpha.8).** By default (`waitForLeaseMs: 0`) a pass that finds the lease taken reports `busy` at once — one `acquire()` attempt, the behaviour above, unchanged. With a positive value the pass retries `acquire()` every `RELAY_LEASE_POLL_MS` (25 ms; measured 0.29 ms per busy poll in PostgreSQL, and a failed poll holds no connection between attempts) until the lease is free or the budget is spent, and then reports `busy` — bounded, never a hang. It is opt-in and it **needs a pool of at least 2** connections, like `{ transaction }`: with a pool of 1 the waiting pass may be holding the very connection the pass that has the lease needs. Do not size it to a full backlog: a pass over 2,000 entries with real backend writes takes tens of seconds; the option exists so that an operator's `authz:scopes:requeue --apply --wait-lease-ms` does not lose systematically against the cron, not to queue relays behind each other.
|
|
161
163
|
|
|
@@ -168,7 +170,7 @@ The relay is resumable and never silent: the report says *which* changes were ap
|
|
|
168
170
|
**What the outbox does not fix, in plain words.** Between your commit and the relay pass there is a lag of **seconds during which FGA decides with the old tree**. What that costs you depends on the change, and the two directions have **opposite signs** (2.0, the `rooted` relation of the `facts` model — see [What a scope that is not attached grants](#what-a-scope-that-is-not-attached-grants)):
|
|
169
171
|
|
|
170
172
|
- after a `moved` or a `detached` it is a **temporary fail-open**: the **old tenant keeps access** to the moved subtree, and the scope you deleted is only purged when the relay runs;
|
|
171
|
-
- after an `attached` it is a **temporary fail-CLOSED**: **a newly created scope grants nothing at all until the relay runs.** Its chain does not reach the root in the store yet, so `can_<P>` is false there for everyone — including the tenant admin you just created it for — and `database` would answer `true`. **The recipe: drain the queue in the same request, right after your commit** (`await authorization.relayScopeChanges()`) on the interactive "create a tenant" path; that shrinks the window to one relay cycle for whatever failed, and nothing else. **Without an outbox the window is zero**: `authorization.scopes.*` calls the driver inline, in your transaction — which is the trade the outbox exists to make.
|
|
173
|
+
- after an `attached` it is a **temporary fail-CLOSED**: **a newly created scope grants nothing at all until the relay runs.** Its chain does not reach the root in the store yet, so `can_<P>` is false there for everyone — including the tenant admin you just created it for — and `database` would answer `true`. **The recipe: drain the queue in the same request, right after your commit** (`await authorization.relayScopeChanges()`) on the interactive "create a tenant" path; that shrinks the window to one relay cycle for whatever failed, and nothing else. **Without an outbox the window is zero**: `authorization.scopes.*` calls the driver inline, in your transaction — which is the trade the outbox exists to make. (Zero lag, not zero races: two inline `moved` of a parent and its child at the same time can leave a local role visible outside its owner in `openfga` — provisional row [races between writers](#limits).)
|
|
172
174
|
|
|
173
175
|
This is a **breaking change of observable behaviour in 2.0**: until then a not-yet-relayed `attached` *granted*, and **did not inherit the denies above it** — the fail-open the audit found, closing which is exactly what `rooted` buys. Denying for seconds is availability; granting for seconds is the defect this package spent two releases hunting.
|
|
174
176
|
|
|
@@ -202,7 +204,7 @@ The capability behind the first two rows is **`transactionalWrites`** (same name
|
|
|
202
204
|
|
|
203
205
|
**A transaction that is already committed or rolled back is refused before any statement (2.4.0-alpha.8).** Lucid keeps `isTransaction === true` on a finished transaction, so until alpha.7 `assertCallerTransaction` let it through and the statement blew up inside knex as **503 `E_AUTHZ_BACKEND_UNAVAILABLE`** ("the backend did not answer") — which is false: it is the caller's wiring (a `trx` reused after `await trx.commit()`). It is now the fourth check of the shared guard (`isCompleted === true` ⇒ **500 `E_AUTHZ_CONFIG`** naming the operation, "confirmada o revertida", zero statements), in the three places that take `{ transaction }`: `sqlScopeOutbox.enqueue`, the `database` roles driver and the `database` relations driver — pinned in the four engines.
|
|
204
206
|
|
|
205
|
-
**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.
|
|
207
|
+
**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). **One more read goes through it, and only one: the group nesting depth gate of `database`** (2.5.0-alpha.4, R1; only with `database.enforceGroupDepth`). It reads the nesting graph through your transaction because it is data of the write, like the "does it already exist?" — it decides no permission — and one transaction that writes `A ⊂ B` and then `B ⊂ C` must see its own first edge. The race it leaves is declared: two transactions that each add half of a chain both pass, and **under MySQL's REPEATABLE READ the window of that race is the whole life of your transaction** (its snapshot is taken at its first read), not just two writes at the same instant. A test pins the closed list of reads that go through your transaction — statement by statement and with its table, raw SQL included — and fails if the transaction's client is handed to anything it cannot follow. **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.
|
|
206
208
|
|
|
207
209
|
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.
|
|
208
210
|
|
|
@@ -281,7 +283,7 @@ A seeder or an onboarding that calls `grant` "to make sure they have the role" n
|
|
|
281
283
|
`onWrite` actions: `granted`, `extended`, `revoked`, `denied`, `deny_removed`, `scope_purged` (no `subject`). Since 2.2 the role-bearing events carry **`roles: CatalogRoleRef[]`** — the *resolved* role(s) (`uuid`, `slug`, `scopeType`, `owner`), not the `RoleQuery` that was asked: a sink that filtered by slug keeps working and now also has the uuid, which is what identifies a role since 2.2. It is a list because a `revoke` by slug removes the facts of every homonym visible in that scope; a `grant` resolves exactly one. It is absent when the role could not be resolved (a scope the tree does not know, a role outside the catalog) — the driver decides the outcome, the event never guesses. It runs *after* the write succeeded, so a hook that throws is logged and swallowed: propagating it would report a failure for an operation that did happen (*"un hook que lanza NO tumba la escritura"*). **It is not free**: resolving those roles costs a **fresh** `resolveChain` (not the `forRequest()` memo) plus a catalog view *per write* — a tree query per `grant`/`revoke` that did not exist before 2.2. Declare `hooks.onWrite` when you want the audit trail, not by default.
|
|
282
284
|
|
|
283
285
|
|
|
284
|
-
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 (
|
|
286
|
+
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 means the write did not happen and emits nothing — **with one family carved out on `openfga` (2.5.0-alpha.4, R3 closure)**: on a **write**, the server's own deadline (`deadline_exceeded`) and a **504** from a gateway or proxy in front of it are timeouts too (503 `E_AUTHZ_BACKEND_TIMEOUT`, `indeterminate: true`): the `Write` may have committed upstream after the response was cut. Every other 5xx on a write (500, 502, 503, 501) keeps "did not happen": the server answered that it failed. On a read, a 504 stays 503 `E_AUTHZ_BACKEND_UNAVAILABLE` (there is nothing to audit). On `openfga` a refused connection is not one of those since 2.5.0-alpha.4: the SDK hands it over as the same bare `FgaError` as a client timeout, without a code, and the package does not read messages — so it is classified as the conservative one, a timeout (`indeterminate: true`): "may have happened" said once too often, never once too few. 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.
|
|
285
287
|
|
|
286
288
|
**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.
|
|
287
289
|
|
|
@@ -307,7 +309,7 @@ await authorization.listDenies(subject, scope) // direct d
|
|
|
307
309
|
|
|
308
310
|
Everything below is composition in the manager over the driver port; a driver keeps its 2.0 shape. The port only gained two *optional* methods, `listDenies?` and `authorizeMany?` — a driver without them still passes `level: '2.0'`, and a primitive that needs one it lacks says so (500 `E_AUTHZ_UNSUPPORTED`, never a simulated `[]`).
|
|
309
311
|
|
|
310
|
-
**Containment.** **All nine writes** — `grant`, `revoke`, `deny`, `removeDeny`, `scopes.attached/moved/detached` and, since 2.2, `defineScopedRole`/`updateScopedRole`/`deleteScopedRole` — accept `within`: the scope being written must be inside it (`within ∈ chain(scope)`, inclusive; `APP_SCOPE` contains everything), checked against your tree *fresh* — the per-request memo is never used to decide a write. What is checked: the target scope for `grant`/`revoke`/`deny`/`removeDeny`; **both origin and destination** for `scopes.moved` — the new parent *and* the child's current chain — and the same for `scopes.attached` when the child already exists in the tree (attaching an existing node *is* a move; a new node only checks the parent); the child itself for `scopes.detached`; the role's **owner** for `defineScopedRole`/`updateScopedRole`/`deleteScopedRole`. Outside ⇒ 422 `E_AUTHZ_NOT_WITHIN`, nothing written, nothing purged, the driver not called. It is what stops "the admin of organization A grants in a unit of B by passing its uuid" — and, just as much, "removes B's deny" (removing a deny *is* granting), "revokes B's role", "purges B's unit" or **"moves B's unit under A"** (annexing a subtree inherits everything in it: worse than purging it) (*"within en las otras cuatro escrituras"*, *"within contrasta también el ORIGEN de scopes.moved/attached"*). Because the origin is read from your tree, notify `scopes.moved` **before** you re-parent the row, exactly as `scopes.detached` goes before the delete.
|
|
312
|
+
**Containment.** **All nine writes** — `grant`, `revoke`, `deny`, `removeDeny`, `scopes.attached/moved/detached` and, since 2.2, `defineScopedRole`/`updateScopedRole`/`deleteScopedRole` — accept `within`: the scope being written must be inside it (`within ∈ chain(scope)`, inclusive; `APP_SCOPE` contains everything), checked against your tree *fresh* — the per-request memo is never used to decide a write. What is checked: the target scope for `grant`/`revoke`/`deny`/`removeDeny`; **both origin and destination** for `scopes.moved` — the new parent *and* the child's current chain — and the same for `scopes.attached` when the child already exists in the tree (attaching an existing node *is* a move; a new node only checks the parent); the child itself for `scopes.detached`; the role's **owner** for `defineScopedRole`/`updateScopedRole`/`deleteScopedRole`. Outside ⇒ 422 `E_AUTHZ_NOT_WITHIN`, nothing written, nothing purged, the driver not called; its `message` names only the scope and the `within` you passed — the scope's chain, which is another tenant's tree, goes to `error.details.chain`, not enumerable (rotation 2 closure); for `updateScopedRole`/`deleteScopedRole`, where the scope checked is the role's owner and you only passed the role's uuid, the message names **the role**, never its owner (rotation 2 closure 2). And for `scopes.moved`/`scopes.attached` containment — destination **and** origin — is decided **before** the cycle check and `scopes.maxDepth`: a `within` that fails is `E_AUTHZ_NOT_WITHIN` whatever the edge would have been, so a cycle or a depth answer never tells you where another tenant's scope hangs (rotation 2 closure 2; without `within`, or with one that passes, the order of the other errors does not change). It is what stops "the admin of organization A grants in a unit of B by passing its uuid" — and, just as much, "removes B's deny" (removing a deny *is* granting), "revokes B's role", "purges B's unit" or **"moves B's unit under A"** (annexing a subtree inherits everything in it: worse than purging it) (*"within en las otras cuatro escrituras"*, *"within contrasta también el ORIGEN de scopes.moved/attached"*). Because the origin is read from your tree, notify `scopes.moved` **before** you re-parent the row, exactly as `scopes.detached` goes before the delete.
|
|
311
313
|
|
|
312
314
|
```ts
|
|
313
315
|
await authorization.grant(user, 'unit-editor', unit, { within: currentOrg })
|
|
@@ -319,7 +321,7 @@ await authorization.isWithin(unit, currentOrg) // the same question on its own
|
|
|
319
321
|
|
|
320
322
|
> **`within` must come from the session, never from the request body.** `within = scope` (or the scope's own parent) satisfies the rule *by definition* — the chain always contains the scope itself — so a `within` taken from the same input as the scope is no containment at all; `'non-root'` closes the `app` wildcard, not that one. Take it from the authenticated tenant (`currentOrg` above: the organization the session belongs to), and let the request only name *what* inside it to write.
|
|
321
323
|
|
|
322
|
-
`requireWithin: true` in the config makes any of the nine writes without `within` a 422 `E_AUTHZ_WITHIN_REQUIRED`. `requireWithin: 'non-root'` additionally rejects `within: APP_SCOPE` with 422 `E_AUTHZ_WITHIN_ROOT_FORBIDDEN`: the root contains everything, so as a containment it says nothing — it was the wildcard a tenant call-site could pass to satisfy the rule without naming its tenant. Platform code that really writes at the root uses `manager.driver()` (below) or a config without the flag. **The default is `false` — containment is opt-in in 2.1** and the manager warns once per config at construction (`warnOnOptInSecurity: false` silences it once you have decided). Same for `requireActor`.
|
|
324
|
+
`requireWithin: true` in the config makes any of the nine writes without `within` a 422 `E_AUTHZ_WITHIN_REQUIRED`. `requireWithin: 'non-root'` additionally rejects `within: APP_SCOPE` with 422 `E_AUTHZ_WITHIN_ROOT_FORBIDDEN`: the root contains everything, so as a containment it says nothing — it was the wildcard a tenant call-site could pass to satisfy the rule without naming its tenant. Platform code that really writes at the root uses `manager.driver()` (below) or a config without the flag. **A multi-tenant deployment must set `requireWithin`** (rotation 2 re-attack, owner's decision): containment is the only barrier that is judged *before* the delegation policy, so without `within` the 422 a tenant admin gets for touching another tenant's role (`updateScopedRole`/`deleteScopedRole` by uuid) can name that role's slug, rank or owner (`E_AUTHZ_ROLE_LEVEL_ABOVE_OWNER`, `E_AUTHZ_RANK_EXCEEDED`). Delegation without `within` is supported for a single tenant, not as a multi-tenant configuration. **The default is `false` — containment is opt-in in 2.1** and the manager warns once per config at construction (`warnOnOptInSecurity: false` silences it once you have decided). Same for `requireActor`.
|
|
323
325
|
|
|
324
326
|
**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).
|
|
325
327
|
|
|
@@ -456,7 +458,7 @@ node ace authz:reconcile --to=openfga --dry-run # verify a driver against auth
|
|
|
456
458
|
|
|
457
459
|
## Scoped roles (2.2)
|
|
458
460
|
|
|
459
|
-
A role has an **owner**: `global` — declared in config and synced with `syncAuthzCatalog` — or the scope that defined it with `defineScopedRole(actor, ownerScope, spec)`. One rule, in both drivers: *an assignment in scope S of role R counts if and only if R is global or R's owner is in chain(S)* (S inclusive). Outside its owner a local role does not exist: it grants nothing, is no membership (`hasRole`, `listRoles`, `listSubjects`, `listRoleScopes`, `listScopes`, `effectivePermissions`, `authorizedScopes` all apply the rule) and cannot be granted (422 `E_AUTHZ_ROLE_NOT_VISIBLE`, nothing written). Moving a unit out of the owner's subtree **retires** what the local role granted there, and moving it back restores it — the tree of *today* decides. **How that retirement happens depends on the driver (2.3, breaking):** in `database` it costs no write at all (the rule is evaluated on every question). In `openfga` the model has no `owner`, so a `role_binding` would keep granting while its scope is reachable — a fail-open — and the package therefore **writes**: `scopes.moved` sweeps the `scope#binding` edges of the local roles whose owner is no longer in the chain, across the **whole moved subtree**, and rewrites them when the owner is in the chain again. Global roles are never touched, and neither is a local role whose owner is still an ancestor. That write follows the same path as any other tree change
|
|
461
|
+
A role has an **owner**: `global` — declared in config and synced with `syncAuthzCatalog` — or the scope that defined it with `defineScopedRole(actor, ownerScope, spec)`. One rule, in both drivers: *an assignment in scope S of role R counts if and only if R is global or R's owner is in chain(S)* (S inclusive). Outside its owner a local role does not exist: it grants nothing, is no membership (`hasRole`, `listRoles`, `listSubjects`, `listRoleScopes`, `listScopes`, `effectivePermissions`, `authorizedScopes` all apply the rule) and cannot be granted (422 `E_AUTHZ_ROLE_NOT_VISIBLE`, nothing written). Moving a unit out of the owner's subtree **retires** what the local role granted there, and moving it back restores it — the tree of *today* decides. **How that retirement happens depends on the driver (2.3, breaking):** in `database` it costs no write at all (the rule is evaluated on every question). In `openfga` the model has no `owner`, so a `role_binding` would keep granting while its scope is reachable — a fail-open — and the package therefore **writes**: `scopes.moved` sweeps the `scope#binding` edges of the local roles whose owner is no longer in the chain, across the **whole moved subtree**, and rewrites them when the owner is in the chain again. Global roles are never touched, and neither is a local role whose owner is still an ancestor. That write follows the same path as any other tree change — with `scopes.outbox` it is applied by `authz:scopes:relay`, after the [relay lag](#the-tree-outbox-and-the-relay-lag-you-are-accepting), during which the store still has the old tree — but **not only the old tree decides**: a local role granted during the lag was checked for visibility against *your* (new) tree and written to the store's (old) one, so until 2.5.0-alpha.4 a `grant` made right after a unit ENTERS the owner granted past a deny of that owner until the relay landed; since the settle of 2.5.0-alpha.4 the `grant` first decides that visibility against the store's tree, so it does not grant there before the relay lands (fail-closed), and what is left of the races between writers is the declared limit `openfga.races.settle-window` in [Limits](#limits) — and **since 2.5.0-alpha.4 (F4) a `moved` cut between its own writes never leaves a state that grants** (the sweep alone, with no other writer running; concurrent writers are the declared limit just cited): `scopes.moved` (and a `scopes.attached` of a scope the tree already knows, which is a move) **deletes the `scope#binding` edges the NEW chain forbids before writing the tree edge**, and runs the full sweep — delete what the rule forbids, write what it requires — after it. Until then the order was edge first, sweep after, and the state in between (the moved unit under its new parent with the old owner's role still visible) was a real state of the store that granted past a deny of the old owner — measured on 15.9 % of concurrent checks; this paragraph called that "the temporary fail-open of the relay lag", which was false: that state gained access a deny had removed. A move cut between its writes now fails **closed** — the user loses the local role early, never gains it — and is completed by the relay's retry or by a reconcile. What F4 removes is that intermediate STATE; a single check that spans two writes can still read one before and one after (the [torn read](#limits) row). `authz:reconcile --to=<the driver you are serving from>` reconciles it if the relay was lost — that pass reads the facts from the store itself, recomputes the same rule with the tree and the catalog of *today*, deletes the `scope#binding` edges the rule forbids and writes the ones it requires, and counts them in `drift.roleVisibility` (see [whoever owns the facts](#migrating-and-verifying-authzreconcile-23); before 2.3's last cut it rebuilt from `authz_*`, which in a `facts` deployment hold no facts at all, so the sweep never ran and the report said `0`) (*"un rol local de la organization A concede en A y sus units, no en B ni en app"*). **The same is true of a role's level** (2.3): the (c2) model does not carry `scope_type` either, so changing the level of a role — an assignment stays where it is, but the role is no longer declared for that kind of scope — is retired in `database` by evaluating the rule again, and in `facts` by the same sweep, run from `projectCatalogRole` (the package calls it after `defineScopedRole`/`updateScopedRole`; a process that writes `authz_*` by hand owes it the same call it already owed for the permission mirror). **What this makes the `scope#binding` edge mean, plainly: "the role is visible here", not "this assignment exists".** The assignment itself is the `assignee` tuple, which no sweep ever touches — which is why `hasRole`, `listRoles` and `listSubjects` still enumerate assignments and filter them through the catalog, and answer exactly as they did before. Two tenants may each define `lead@unit` with different permissions: the slug no longer identifies a role, the uuid does, and the rule never lets a role cross tenants (*"dos tenants definen el mismo slug"*) — in `openfga` (experimental in 2.5.x), with the declared exception of the races between writers (`openfga.races.settle-window` and `openfga.catalog.permits-twin` in [Limits](#limits)), which can leave a local role visible in another tenant's subtree for the window of a writer's settle, or a removed permission granting until an `authz:reconcile`. A deny anywhere in the chain still wins over a local role (invariant 2; on `openfga`, with the torn-read exception of [Limits](#limits)).
|
|
460
462
|
|
|
461
463
|
**A local role never lives *above* its owner.** A role whose `scopeType` is the level of one of the owner's **ancestors** (`app` included, which is in every chain) is visible nowhere: it grants nothing, is nobody's membership and cannot be granted — all it does is occupy that `(slug, level)` for the owner of the tree and for the global catalog, which is squatting with the shape of a spec (like `permissions: []`). It is 422 `E_AUTHZ_ROLE_LEVEL_ABOVE_OWNER`, in `defineScopedRole` and in `updateScopedRole` — a row that already has an impossible level is not perpetuated either; purge it. The rule is decided with the owner's chain, which is already resolved, so it costs nothing and needs no extra configuration: the owner's own level is fine and **any other level is assumed to be below** — delegating downwards (`lead@unit` owned by an organization) keeps working with the published config stub. If you do declare `scopes.descendantsOf`, the check is **tightened**: the level must actually appear below the owner in today's tree — and if that subtree cannot be enumerated (more nodes than `maxDescendants`, or a `descendantsOf` that fails) the check **degrades to the minimal rule** instead of failing: *declaring `descendantsOf` must never leave you worse off than not declaring it* (audit N3, where a tenant with more units than the bound could no longer delegate downwards at all). **Be explicit about what that degradation costs** (audit P4): the strong check is a control the watched subject can switch off — creating scopes is a normal product feature, so an actor who creates more than `maxDescendants` children of their own scope gets the minimal rule back — and it also switches itself off when the resolver is down. That is deliberate and bounded: the minimal rule is the one every consumer runs with the published stub, it never grants anything, and the residual damage is squatting a `(slug, level)` that stays repairable by **authority plus rank** — an ancestor defines its own role and shadows it (below) **only if its rank is above the squatter's**. Ranks are your metadata (invariant 8) and nothing forces them to decrease with depth, so with a non‑monotonic layout — a rank‑60 role in a unit under a rank‑50 organization admin who owns that tree — the owner of the tree gets 422 `E_AUTHZ_RANK_EXCEEDED` from **both** doors (defining its own homonym and `deleteScopedRole`), and `scopes.detached` is not a third door either (since 2.3 it purges facts and never the catalog). The recourse is then the **platform**: whoever carries the highest‑rank global role outranks every local role by construction (`0 < rank < min(actor, highest global rank)`), and `manager.driver().purgeRole(uuid)` always works (audit D1). If that trade is not acceptable for you, keep `maxDescendants` above the size of your biggest subtree.
|
|
462
464
|
|
|
@@ -470,11 +472,12 @@ A role has an **owner**: `global` — declared in config and synced with `syncAu
|
|
|
470
472
|
node ace authz:catalog:prune-orphans # dry run: lists the local roles whose owner no longer resolves
|
|
471
473
|
node ace authz:catalog:prune-orphans --force # purges them (purgeRole each: assignments + links + row), notifies role_purged
|
|
472
474
|
node ace authz:catalog:prune-orphans --force --allow-mass-purge # ... even if that is *every* owner (read the next paragraph first)
|
|
475
|
+
node ace authz:catalog:prune-orphans --max-local-roles=50000 # a local catalog larger than the default cap (10 000)
|
|
473
476
|
```
|
|
474
477
|
|
|
475
|
-
It is a **platform** operation — a maintenance command with access to the catalog, like `authz:catalog:sync` — so it takes no actor and measures no rank, exactly like the `purgeRole` of last resort. `manager.pruneOrphanRoles({ force })` is, next to `manager.driver()`, **platform API**: it deliberately bypasses `requireActor` and `requireWithin`, so it belongs in a command or a maintenance job, never behind an HTTP controller. `--dry-run` is the default: nothing is written until a human has read the list. Because a dormant role may still be granting, every orphan is reported with `assignments` (live facts) and `stillGranting`, and the command lists those **apart, with a warning** — purging them revokes permissions that work today. The flag is conservative: it counts live facts and does not check whether each fact's scope still resolves, so `false` means "grants nothing, for sure" and `true` means "look before you force". **Those facts are counted by the driver** (`countRoleAssignments`, 2.3), because facts belong to the driver: counting rows of `authz_assignments` from the sweep meant that with `openfga` — where facts live in the store — `stillGranting` was *always* `false`, which is the one answer that must never be wrong here. A driver that does not implement it leaves both fields **`undefined`, never `false`**, and the command lists those orphans apart too, with their own warning: "I don't know" is not "it does not grant" when the next step is a delete. The roles are read **from the database**, not from the catalog memo (with `catalogRevalidate: { everyMs }` a role another process has just committed is not in your snapshot), in a stable order by uuid, so the list and the `role_purged` events reproduce identically on PostgreSQL, MySQL and SQLite, and no more than `maxLocalRoles` (10 000) of them (500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES`, never a partial list). A second pass is a no-op. A driver without `purgeRole` says 500 `E_AUTHZ_UNSUPPORTED` before reading anything.
|
|
478
|
+
It is a **platform** operation — a maintenance command with access to the catalog, like `authz:catalog:sync` — so it takes no actor and measures no rank, exactly like the `purgeRole` of last resort. `manager.pruneOrphanRoles({ force })` is, next to `manager.driver()`, **platform API**: it deliberately bypasses `requireActor` and `requireWithin`, so it belongs in a command or a maintenance job, never behind an HTTP controller. `--dry-run` is the default: nothing is written until a human has read the list. Because a dormant role may still be granting, every orphan is reported with `assignments` (live facts) and `stillGranting`, and the command lists those **apart, with a warning** — purging them revokes permissions that work today. The flag is conservative: it counts live facts and does not check whether each fact's scope still resolves, so `false` means "grants nothing, for sure" and `true` means "look before you force". **Those facts are counted by the driver** (`countRoleAssignments`, 2.3), because facts belong to the driver: counting rows of `authz_assignments` from the sweep meant that with `openfga` — where facts live in the store — `stillGranting` was *always* `false`, which is the one answer that must never be wrong here. A driver that does not implement it leaves both fields **`undefined`, never `false`**, and the command lists those orphans apart too, with their own warning: "I don't know" is not "it does not grant" when the next step is a delete. The roles are read **from the database**, not from the catalog memo (with `catalogRevalidate: { everyMs }` a role another process has just committed is not in your snapshot), in a stable order by uuid, so the list and the `role_purged` events reproduce identically on PostgreSQL, MySQL and SQLite, and no more than `maxLocalRoles` (10 000) of them (500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES`, never a partial list). **The cap is an option since the rotation 2 closure**: `pruneOrphanRoles({ maxLocalRoles })` / `--max-local-roles=<n>` (an integer ≥ 1, otherwise 500 `E_AUTHZ_CONFIG` before reading anything). Until then the error said "raise the cap" and nothing could: a tenant that defined 10 001 local roles left `prune-orphans` — the dry run included — dead for every tenant. A second pass is a no-op. A driver without `purgeRole` says 500 `E_AUTHZ_UNSUPPORTED` before reading anything.
|
|
476
479
|
|
|
477
|
-
**Two safeties, because the dangerous input is your own resolver.** The realistic accident is not someone calling the method by hand: it is a `scopes.resolveChain` **filtered by the request's tenant** — a normal multi-tenant pattern — or running with no context at all (a command, a lagging replica). It answers `null` for everything, so *every* local role looks orphaned and one `--force` pass deletes the local catalog of every tenant (measured: 2 of 2 live roles). So (a) if **all** distinct owners come out orphaned, or the orphans are more than **50 %** of the local roles, `force` is 500 `E_AUTHZ_MASS_PURGE_REFUSED` **before deleting anything**, naming the
|
|
480
|
+
**Two safeties, because the dangerous input is your own resolver.** The realistic accident is not someone calling the method by hand: it is a `scopes.resolveChain` **filtered by the request's tenant** — a normal multi-tenant pattern — or running with no context at all (a command, a lagging replica). It answers `null` for everything, so *every* local role looks orphaned and one `--force` pass deletes the local catalog of every tenant (measured: 2 of 2 live roles). So (a) if **all** distinct owners come out orphaned, or the orphans are more than **50 %** of the local roles, **or the orphans belong to more than one owner and any of them still grants (`stillGranting: true`) or nobody knows (`undefined`)**, `force` is 500 `E_AUTHZ_MASS_PURGE_REFUSED` **before deleting anything**, naming the counts (never another owner's identifiers); a real large prune passes `allowMassPurge: true` (`--allow-mass-purge`), which is a human decision, not a default (the dry run does not throw — it is the diagnostic you need to be able to read — it reports `massPurge: true` and why, in `massPurgeReasons`: `'all-owners'`, `'proportion'`, `'granting-across-owners'`). **Why the third trigger** (rotation 2 closure): the ratio is something a tenant controls — with the legitimate delegation API it inflates its own local catalog until the other tenants' orphans drop below 50 % — and the realistic blind resolver is not only "`null` for everything" but a job that inherited **one** tenant's context: that tenant stays alive and every other tenant's local roles, with their live assignments, come out orphaned. A single orphaned owner with live assignments still passes (a tenant that leaves is the normal case, and the listing marks it apart) — **so the third trigger does not cover a single victim**: a job whose resolver leaked tenant A's context, on a database where only one other owner has local roles, orphans that one owner and nothing fires (declared in [Limits](#limits)). The recipe: always read the `--dry-run` before `--force` (it marks those roles `stillGranting: true`), and run the pass with a resolver that has **no** tenant context. And (b) each owner is re-resolved **fresh immediately before its own `purgeRole`**: the window between reading and deleting is the whole pass, not an instant, so a concurrent `scopes.attached` or restore would otherwise delete a role whose owner is back. A role whose owner came back is skipped and reported (`skipped: [{ role, reason: 'owner-came-back' }]`). The set is not atomic — it does not need to be — so the report says **which** roles were purged (`purged: CatalogRoleRef[]`), not how many: if one `purgeRole` fails halfway, the previous ones are already gone, and with the above that can be a partial revocation of live permissions.
|
|
478
481
|
|
|
479
482
|
**Facts of live descendants survive `detached`, and wake up with the scope.** `scopes.detached` purges the facts of the **exact** scope; an assignment held in a *descendant* whose path went through it is not touched (invariant 11 — you purge each node of the branch you delete). While the branch is gone those facts grant nothing, because the descendant does not resolve either; but if the scope is **restored with the same uuid** (an undelete, a restore from the bin, re-creating the unit) the facts grant again, with no write of any kind. Between 2.2's first cut and 2.3 the role took its assignments with it, so this is a change of behaviour, and it is deliberate: the tree of *today* decides (invariant 18). If you want those facts gone, delete them by notifying `detached` for every node of the branch, or run `authz:reconcile --to=<driver> --prune` (2.3), which reports — and, with `--prune`, deletes — the facts whose scope no longer resolves.
|
|
480
483
|
|
|
@@ -569,6 +572,107 @@ tenant — and it is **mandatory**: a relation in tenant A never resolves in ten
|
|
|
569
572
|
the mono-tenant value). The partition lives in the object id (`document:<partitionKey>|<uuid>`), not
|
|
570
573
|
in the model.
|
|
571
574
|
|
|
575
|
+
**How deep groups may nest — each driver declares its ceiling (2.5.0-alpha.4, R1).** A nesting edge is
|
|
576
|
+
`relate({ object: { type: 'group', id: child }, relation: 'member' }, 'member', { type: 'group', id: parent }, tenant)`;
|
|
577
|
+
its depth is the number of `group ⊂ group` hops of the **longest** path that crosses it, upwards *and* downwards (a
|
|
578
|
+
short path and a long one to the same top group count as the long one: OpenFGA answers 503 to non-members along the
|
|
579
|
+
long one). **`openfga` has a ceiling and enforces it by default**: it **calibrates** it against your server on first
|
|
580
|
+
use — read-only, one `Check` per probe with the whole chain as contextual tuples on ids that do not exist in the store,
|
|
581
|
+
under `HIGHER_CONSISTENCY` — as the **minimum** of a member probe (answers `true`) and a non-member probe that has
|
|
582
|
+
memberships of its own (answers `false`; a probe without memberships is blind, it answers `false` at any depth). On
|
|
583
|
+
OpenFGA v1.19 with the default `--resolve-node-limit` that is **22** (a member resolves 24 hops, a non-member with
|
|
584
|
+
memberships 22; measured 200/200 at both edges, independent of `includes` and of the deny). Only success is
|
|
585
|
+
remembered, once per process: a calibration that fails (503, a timeout, a blind probe's 500) gives the same kind of error
|
|
586
|
+
to the nesting writes of the next `GROUP_DEPTH_CALIBRATION_BACKOFF_MS` (2,000 ms) without probing again — a new instance
|
|
587
|
+
per call, with the calibration's error as its `cause`; a calibration **timeout** reaches them as a plain 503
|
|
588
|
+
`E_AUTHZ_BACKEND_UNAVAILABLE`, because they did not touch the network and their write did not happen (no `indeterminate`
|
|
589
|
+
event) —, and the first one after that calibrates again. **Lowering the server's limit no longer needs a restart**: a read answered 503
|
|
590
|
+
`E_AUTHZ_RESOLUTION_LIMIT` forgets the remembered ceiling — at most once every `GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS`
|
|
591
|
+
(60,000 ms) per process — and the next nesting write measures it again. **Raising it does**: nothing answers 503 when the
|
|
592
|
+
server resolves more, so the process keeps the old (lower, safe) ceiling until it restarts. **`database` has no ceiling by default**
|
|
593
|
+
(its recursive query deduplicates and terminates, cycles included) and enforces one only when you opt in with a
|
|
594
|
+
separate switch, `database: { enforceGroupDepth: true }`, which then requires a number. The number is
|
|
595
|
+
**`maxGroupDepth`** in `defineRelationsConfig` — an integer from 1 to 97, or `false` to switch the `openfga` ceiling
|
|
596
|
+
off explicitly; anything else (`0`, `NaN`, `''`, `'24'`, `24.5`, `Infinity`, `null`) is 500 `E_AUTHZ_CONFIG` **when the
|
|
597
|
+
config is defined**, at boot. The package never reads environment variables: validate yours
|
|
598
|
+
(`Env.schema.number.optional()` in `start/env.ts`) and pass the number. In `openfga` the config can only **lower** the
|
|
599
|
+
calibrated ceiling — a larger value is 500 `E_AUTHZ_CONFIG` naming both numbers when the driver calibrates — and the
|
|
600
|
+
`maxGroupDepth` you set for an `openfga` CI does **not** switch the gate on in a `database` production (that is what the
|
|
601
|
+
separate switch is for). Both drivers re-check the pair when they are built (and on every use): a config extended with
|
|
602
|
+
a spread (`{ ...defineRelationsConfig(spec), maxGroupDepth: Number(env.get('X')) }`) that skipped the validation is the
|
|
603
|
+
same 500, never a gate switched off by `NaN`.
|
|
604
|
+
|
|
605
|
+
On a **write**, an edge that would leave a chain longer than the ceiling, that would close a cycle, or whose
|
|
606
|
+
neighbourhood exceeds `GROUP_DEPTH_MAX_NODES` groups is **422 `E_AUTHZ_GROUP_DEPTH_LIMIT`** with zero writes and no
|
|
607
|
+
`onRelationWrite` event — renewals and revivals of an edge included (`unrelate` is never gated; a `relate` identical to
|
|
608
|
+
an edge that already exists is still a no-op, even inside a chain that already exceeds the ceiling). In `openfga` the
|
|
609
|
+
check reads one `Read` per group of the walk, and the downward `Read` also brings the group's holders (the server cannot
|
|
610
|
+
filter a `Read` by user type), so it runs on a **budget** per edge — `GROUP_DEPTH_MAX_READS` requests,
|
|
611
|
+
`GROUP_DEPTH_MAX_TUPLES` tuples read and a total deadline of `GROUP_DEPTH_DEADLINE_MS` —; past the requests or the
|
|
612
|
+
tuples the edge is the same 422 (`details.reason: 'too-many-groups'`, `details.budget` says which bound), never "passes".
|
|
613
|
+
**Past the deadline it is a 503, not a 422**: running out of time says nothing about your data — the same graph passes
|
|
614
|
+
with the server unloaded, and on a shared store another tenant's load can be what slows it — so it is
|
|
615
|
+
`E_AUTHZ_BACKEND_UNAVAILABLE`, transient (retry), nothing written and **no `onRelationWrite` event** (it is not the
|
|
616
|
+
timeout subclass: the write did not happen); never the 422 that tells the tenant to flatten a nesting that is fine. Its `message` is for
|
|
617
|
+
the tenant (the chain, the ceiling, "nothing was written"); *why* the ceiling exists and the server knob
|
|
618
|
+
(`OPENFGA_RESOLVE_NODE_LIMIT`) travel in `error.details`, not enumerable — the same rule as `E_AUTHZ_SCOPE_DEPTH_LIMIT`.
|
|
619
|
+
On a **read**, a chain that is already deeper (written before 2.5.0-alpha.4, or through one of the gaps below) is 503
|
|
620
|
+
`E_AUTHZ_RESOLUTION_LIMIT`, never `false`. **The rule of the letter: the 422 is neutral, the 503 names the backend.** The
|
|
621
|
+
422 answers the tenant's question and both drivers share it, so its `message` names no backend (a `database` deployment
|
|
622
|
+
with `enforceGroupDepth` would otherwise read about an OpenFGA knob it does not have); the 503 of a read only ever comes
|
|
623
|
+
from `openfga` — and every 503 already names its driver — so its `message` names `maxGroupDepth` and the server knob,
|
|
624
|
+
`OPENFGA_RESOLVE_NODE_LIMIT`. In **`authz:relations:reconcile`** the plan computes the chain the pass
|
|
625
|
+
would leave in each partition — the source's live edges plus what the destination keeps (without `--prune`, every
|
|
626
|
+
live edge it already has) — against the destination's ceiling (`groupDepthLimit()`, calibrated, also in `--dry-run`),
|
|
627
|
+
and a partition that exceeds it is **refused** (`partitionsRefused` with cause `too-deep`, `report.tooDeep`, zero
|
|
628
|
+
writes in it, exit ≠ 0): skipping the edge would drop the denies it carries and grant in the destination what the
|
|
629
|
+
source denies. A cycle that already exists does not refuse a partition by itself (OpenFGA evaluates it); its *k*
|
|
630
|
+
groups count, conservatively, as *k − 1* hops, and the pass migrates it — the plan has already judged the partition, so
|
|
631
|
+
the pass does not ask the destination's write gate again (which would refuse the edge that closes the cycle halfway
|
|
632
|
+
through the pass, or an intermediate state the pass never certifies). **What the gate does not close, declared**: two writes running at the
|
|
633
|
+
same time that each add half of a chain both pass (each reads the graph without the other; in `database` the count
|
|
634
|
+
reads through the caller's transaction, so one transaction does see its own earlier edges, but two transactions do
|
|
635
|
+
not see each other — there is no lock); and an `openfga`-only deployment has no pass that scans chains already in the
|
|
636
|
+
store (no maintenance pass for relations; a read-only verifier is planned for 2.6). `listObjects` is not bound by the
|
|
637
|
+
ceiling: past it (measured up to 100 hops) it still lists the objects correctly while `check` of the same pair is 503.
|
|
638
|
+
|
|
639
|
+
**Calibrating at boot (optional) — log, never rethrow.** "At boot" in the package means *the config is validated* (the
|
|
640
|
+
500s above, when `defineRelationsConfig` runs); the calibration itself happens once per process, on the first nesting
|
|
641
|
+
write. If you want it earlier — to see a `maxGroupDepth` above what the server resolves in your boot log instead of on the
|
|
642
|
+
first write — call it from the `ready` hook of one of your providers and **record the error, do not throw it**:
|
|
643
|
+
|
|
644
|
+
```ts
|
|
645
|
+
// providers/app_provider.ts
|
|
646
|
+
export default class AppProvider {
|
|
647
|
+
constructor(protected app: ApplicationService) {}
|
|
648
|
+
|
|
649
|
+
async ready() {
|
|
650
|
+
if (this.app.getEnvironment() !== 'web') return
|
|
651
|
+
try {
|
|
652
|
+
const relations = await this.app.container.make('authz.relations')
|
|
653
|
+
const ceiling = await relations.driver().groupDepthLimit?.()
|
|
654
|
+
logger.info({ ceiling }, 'authz: group nesting ceiling')
|
|
655
|
+
} catch (error) {
|
|
656
|
+
logger.error({ err: error }, 'authz: could not calibrate the group nesting ceiling; the first nesting write will retry')
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
```
|
|
661
|
+
|
|
662
|
+
A `ready` that **throws** turns an OpenFGA hiccup at boot into an app that does not start — and with it
|
|
663
|
+
`node ace authz:relations:reconcile --to=database`, which boots the app and is the way back from `openfga`. Do not
|
|
664
|
+
rethrow. A failed calibration here costs nothing: the write path calibrates on its own (with the wait above).
|
|
665
|
+
|
|
666
|
+
**Planning to move from `database` to `openfga`? Switch the `database` ceiling on first.** With the default (no ceiling)
|
|
667
|
+
a tenant can build a chain deeper than what `openfga` resolves, and `authz:relations:reconcile --to=openfga` will then
|
|
668
|
+
**refuse that tenant's partition** (`too-deep`, exit ≠ 0). The runbook requires a green verification before the switch,
|
|
669
|
+
so one such partition **blocks the cutover of every tenant** until someone flattens that tenant's groups — and,
|
|
670
|
+
meanwhile, that partition keeps the destination's old snapshot (later revokes and denies are not migrated into it, the
|
|
671
|
+
same policy as `renewal-unsafe`). So, in every deployment that may migrate, set
|
|
672
|
+
`database: { enforceGroupDepth: true }` with `maxGroupDepth` at the `openfga` number (22 on v1.19 by default) **before**
|
|
673
|
+
tenants write nested groups; and run `authz:relations:reconcile --to=openfga --dry-run` early — it names each refused
|
|
674
|
+
partition with its chain.
|
|
675
|
+
|
|
572
676
|
**Spelling is identity (2.5.0, D4).** Relations have no resolver chain to canonicalise against (roles do,
|
|
573
677
|
invariant 17), so the partition key is a string compared byte for byte — and until 2.5.0 the same tenant
|
|
574
678
|
uuid written **without dashes** (32 hex, what PostgreSQL's `uuid` type or a `*_ci` collation fold into the
|
|
@@ -615,14 +719,13 @@ true` — that would be the single switch with no safe rollout order):
|
|
|
615
719
|
|
|
616
720
|
The rollout is two deployments: (1) the whole fleet on ≥ 2.5.0-alpha.3 with the type in `'read'`, the model
|
|
617
721
|
republished (`openfga:provision`) and **every pod pinned to the new `modelId`**; (2) verify; (3) a second deployment
|
|
618
|
-
moves the type to `'write'`.
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
would answer `none` where `check` answers `false`. A relation of a deniable type measures **at most 43 characters**
|
|
722
|
+
moves the type to `'write'`. Both `openfga` drivers **require a pinned `modelId`, always** (2.5.0-alpha.4, with or
|
|
723
|
+
without deny: 500 `E_AUTHZ_CONFIG` at construction otherwise) and check, once per process, that the pinned model **is
|
|
724
|
+
the one your config generates** — [the model gate](#the-pinned-model-is-the-one-your-config-generates-250-alpha4) —:
|
|
725
|
+
in particular that it declares `denied_<rel>` for every relation of those types **and that `<rel>` subtracts it**
|
|
726
|
+
(`<rel> … but not denied_<rel>`, with the closure of the path cut); a pinned model without it is 500 `E_AUTHZ_CONFIG`
|
|
727
|
+
on first use, never a grant over a denied object. **`decideMany` on `openfga` requires a pinned `modelId` always**, like
|
|
728
|
+
every operation since 2.5.0-alpha.4, and asks the `denied_*` of exactly the types the gate saw in the deny form. A relation of a deniable type measures **at most 43 characters**
|
|
626
729
|
(`DENIABLE_RELATION_NAME_MAX`: `denied_<rel>` has to fit FGA's 50); a type without `deny` keeps the 50.
|
|
627
730
|
|
|
628
731
|
**The semantics is a path cut** (decision D5, the 64-state table T1 in the suite): a deny on `Y` cuts every grant of
|
|
@@ -649,7 +752,7 @@ server does, so a page cut at the server's cap is a subset — never a denied ob
|
|
|
649
752
|
candidates, 1,000, past it 500 `E_AUTHZ_MEMBERS_DENY_LIMIT` and never a partial list, under one total deadline, 503);
|
|
650
753
|
the purges delete denies too. **On `openfga` a purge that fits in one `Write` is one `Write`**
|
|
651
754
|
(`RELATIONS_PURGE_WRITE_MAX`, 100 tuples — the server's `--max-tuples-per-write` default, measured: 101 is a 400 and
|
|
652
|
-
nothing applies), which FGA applies atomically; a larger one deletes the allows first, then **the subject's group
|
|
755
|
+
nothing applies), which FGA applies atomically for the writer — not for a concurrent reader: a check running during that `Write` can see part of it (the [torn read](#limits) row; what this paragraph guarantees is about a purge CUT, i.e. prefixes) —; a larger one deletes the allows first, then **the subject's group
|
|
653
756
|
memberships in a `Write` of their own** (a membership carries denies too), then the denies — so **a purge cut halfway
|
|
654
757
|
grants nothing that neither the state before nor the state after granted**
|
|
655
758
|
(*"purgeSubject cortada, deny por grupo (holder)"*, *"purgeSubject cortada, deny anidado (userset g#member)"*, and the
|
|
@@ -732,8 +835,8 @@ instead (not measured; e.g. a leftover deny on a relation the destination no lon
|
|
|
732
835
|
gives it and that deny cuts. **Declared window (TOCTOU of N1):** a NEW deny on a group written while ANOTHER membership of
|
|
733
836
|
that group is being renewed (its two `Write`s) does not reach that subject in between — the driver re-checks after the
|
|
734
837
|
renewal and says so (`E_AUTHZ_RENEWAL_RACED`), but it cannot close it. `database` renews in one transaction and has
|
|
735
|
-
none of this. **When OpenFGA offers an upsert, the renewal will be one `Write` and
|
|
736
|
-
on `openfga`, without touching the model. **A destination tuple
|
|
838
|
+
none of this. **When OpenFGA offers an upsert, the renewal will be one `Write` and a candidate for `atomicRenewal: true`**
|
|
839
|
+
on `openfga`, without touching the model, once a concurrent-reader measurement confirms it. **A destination tuple
|
|
737
840
|
that is expired at the pass's `now` is ABSENT for the plan** (closure 3 of D2): rewriting it is a plain write (never a
|
|
738
841
|
renewal, never `renewal-unsafe`), it is not part of the order's context (no carrier, no edge of the self-cut, no
|
|
739
842
|
"renewal" in phase 4), and with `--prune` the expired leftovers are deleted first and outside the order (they connect
|
|
@@ -879,7 +982,23 @@ the `modelId` goes to every pod by hand, see [the cutover window](#the-cutover-w
|
|
|
879
982
|
`syncAuthzCatalog` nor a config save republishes anything. The persisted copy has one reader today:
|
|
880
983
|
`authz:relations:reconcile`, which uses it as the **destination's** config to flag model drift — and since
|
|
881
984
|
2.5.0 a *failed* read of that table stops the command before any write (an unreadable config is not "no
|
|
882
|
-
config"; `null` is).
|
|
985
|
+
config"; `null` is). **Since 2.5.0-alpha.4 a save is also a guard (decision (8)):** `saveRelationsConfig` refuses a
|
|
986
|
+
config that shrinks the reach of a live deny of `authz_relations` — narrowing an `includes`, directly or transitively,
|
|
987
|
+
or withdrawing a relation or a type that has denies — and, while denies live, it refuses any save without a usable
|
|
988
|
+
baseline (no row, or a row it cannot type), with 500 `E_AUTHZ_DENY_REACH_REFUSED` and nothing written. Two named ways
|
|
989
|
+
out, apart: `{ establishBaseline: true }` adopts the baseline (only when there is none; it still judges orphans) and
|
|
990
|
+
`{ allowDenyReachShrink: ['note#editor'] }` exempts only the findings it names, and a name that exempts nothing in
|
|
991
|
+
that save is 500 `E_AUTHZ_CONFIG` — remove the list after the save that needs it (see the row in [Limits](#limits)).
|
|
992
|
+
**If you use deny, persisting the config is a mandatory release step**, in this order: save the config the release
|
|
993
|
+
serves (the guard) → `checkRelationsConfigDenyReach(served)` (it reads the row and the live denies, writes nothing,
|
|
994
|
+
and returns `ok: false` with one line per finding — fail the job on it) → serve. Run that check in CI after the save
|
|
995
|
+
and before serving, and in a cron; never as a hard failure in `ready` (an old pod restarting mid-deploy serves the
|
|
996
|
+
narrow config against the new row and would loop). Adopt the baseline once, with the spec the fleet serves today, in a
|
|
997
|
+
release that does **not** change `relations.config` (adopting in a release that narrows certifies the narrowing). Save
|
|
998
|
+
with the **same `connection`** as the `database` relations driver: the guard reads `authz_relations` through the
|
|
999
|
+
connection it is given. The persisted copy is also the **baseline** of the deny-reach detector of
|
|
1000
|
+
`authz:relations:reconcile --dry-run`, which needs **two** relations drivers (source ≠ destination); in a
|
|
1001
|
+
`database`-only deploy that command cannot run, so `checkRelationsConfigDenyReach` is your detector.
|
|
883
1002
|
|
|
884
1003
|
**Migrating tuples between drivers** is `node ace authz:relations:reconcile --to=<key>` — the relations
|
|
885
1004
|
analog of `authz:reconcile`, idempotent, bidirectional and never silent (it reports written / deleted
|
|
@@ -887,7 +1006,9 @@ analog of `authz:reconcile`, idempotent, bidirectional and never silent (it repo
|
|
|
887
1006
|
`--dry-run` is the read-only verifier and also flags **model drift** (an object type in the source the
|
|
888
1007
|
destination does not declare); `--prune` deletes what the source no longer backs — except a destination row the API
|
|
889
1008
|
does not admit (`details`: `invalid-identity`, `userset-unsupported`, `undeclared` — a relation or type the
|
|
890
|
-
destination no longer declares
|
|
1009
|
+
destination no longer declares —, `invalid-condition` — a tuple whose condition the package does not write or cannot
|
|
1010
|
+
read, 2.5.0-alpha.4: present and untouchable in the destination, never rewritten from the source, and never migrated
|
|
1011
|
+
from the source as "permanent"), which no flag touches: listed, exit ≠ 0 in every mode, deleted by hand or, for
|
|
891
1012
|
`undeclared`, declared again ([Upgrading, 6](#upgrading-to-250)); a 422 of the relations family from the destination's
|
|
892
1013
|
`unrelate` is classified the same way and never aborts the pass. **Denies migrate as denies (2.5.0-alpha.3):**
|
|
893
1014
|
`enumerateRelations` marks every tuple with `kind` (`denied_viewer` is enumerated as `viewer` + `kind: 'deny'`), the key
|
|
@@ -1094,7 +1215,7 @@ Every error the package raises carries `status` and `code`. A standard AdonisJS
|
|
|
1094
1215
|
| `E_AUTHZ_INVALID_IDENTITY` | 422 | malformed holder/scope, `{app, uuid}`, root sentinel outside `app` |
|
|
1095
1216
|
| `E_AUTHZ_INVALID_SLUG` | 422 | role/permission slug: grammar, length, reserved name or prefix, collision |
|
|
1096
1217
|
| `E_AUTHZ_UNKNOWN_ROLE` / `E_AUTHZ_UNKNOWN_PERMISSION` | 422 | not in the catalog (for that scope type), in `grant`/`deny`/`revoke`/`removeDeny`; by uuid in `purgeRole`/`updateScopedRole`/`deleteScopedRole` (2.2) |
|
|
1097
|
-
| `E_AUTHZ_CATALOG_CONFLICT` | 422 | two catalogs in `config.catalogs` declare the same role `(slug, scopeType)` or permission; a local role whose `(slug, scopeType)` is already visible from its owner through a **more authoritative** definition (a global, or a local of an ancestor)
|
|
1218
|
+
| `E_AUTHZ_CATALOG_CONFLICT` | 422 | two catalogs in `config.catalogs` declare the same role `(slug, scopeType)` or permission; a local role whose `(slug, scopeType)` is already visible from its owner through a **more authoritative** definition (a global, or a local of an ancestor) — also when that definition was committed by another process while this one was being validated (2.2; since the rotation 2 closure a homonym committed in that window by an owner that is neither global nor an ancestor-or-self — a sibling tenant, a descendant — is **not** a collision and the message never names another tenant's owner; a *descendant*'s homonym committed in that window is shadowed **without** the rank check of shadowing and is not in the event's `shadowedByAncestor` — `authz:catalog:diff` lists it: accepted and declared, rotation 2 closure 2). **Not** a homonym local to a *descendant* (the new one shadows it, `shadowedByAncestor`), and **not** a global role of the spec colliding with a local one: the sync writes the global and reports it as `shadowedByGlobal` |
|
|
1098
1219
|
| `E_AUTHZ_ROLE_NOT_VISIBLE` | 422 | `grant` of a local role outside its owner's subtree; a `{ uuid }` `RoleQuery` whose role is declared for another level or whose owner is not in the scope's chain (2.2) |
|
|
1099
1220
|
| `E_AUTHZ_AMBIGUOUS_ROLE` | 422 | `grant`/`revoke`/`hasRole`/`listSubjects` **by slug** where more than one role with that `(slug, level)` is visible in the chain — ask by `{ uuid }` (2.2) |
|
|
1100
1221
|
| `E_AUTHZ_ROLE_IMMUTABLE` | 422 | `updateScopedRole`/`deleteScopedRole` on a global role (2.2) |
|
|
@@ -1102,22 +1223,25 @@ Every error the package raises carries `status` and `code`. A standard AdonisJS
|
|
|
1102
1223
|
| `E_AUTHZ_ROLE_NOT_ASSIGNABLE_AT` | 422 | a role of level L carrying — or granted while carrying — a permission whose `assignableAt` excludes L: sync, `defineScopedRole`/`updateScopedRole`, `grant` (2.2) |
|
|
1103
1224
|
| `E_AUTHZ_PERMISSION_NOT_DELEGABLE` | 422 | `defineScopedRole`/`updateScopedRole`: a permission not in `delegablePermissions`, or not effective for the actor in the owner (not granted, or denied) (2.2) |
|
|
1104
1225
|
| `E_AUTHZ_RANK_EXCEEDED` | 422 | a local role's rank outside `0 < rank < min(actor's rank, highest global rank)`, or touching (or shadowing) a role of rank ≥ the actor's — `defineScopedRole`, `updateScopedRole`, `deleteScopedRole` |
|
|
1105
|
-
| `E_AUTHZ_UNKNOWN_SCOPE` | 422 | write on a scope the resolver does not know; unknown parent in `scopes
|
|
1226
|
+
| `E_AUTHZ_UNKNOWN_SCOPE` | 422 | write on a scope the resolver does not know; unknown parent in `scopes.*`; for `updateScopedRole`/`deleteScopedRole`, a role whose owner no longer resolves (dormant) — there the `message` names the role you passed, and the owner (which you did not pass, and may be another tenant's) is in `error.details.owner`, **not enumerable** (rotation 2 closure 2) |
|
|
1106
1227
|
| `E_AUTHZ_NO_SCOPE_RESOLVER` | 422 | driver without `resolveChain` asked about a non-`app` scope |
|
|
1107
1228
|
| `E_AUTHZ_SCOPE_CYCLE` | 422 | `scopes.attached/moved` would close a cycle; `hierarchicalScopeResolver` met a cycle |
|
|
1108
|
-
| `E_AUTHZ_NOT_WITHIN` | 422 | any of the nine writes with `within` not in the chain of the scope it writes to (the new parent **and** the child's current chain for `scopes.moved`, and for `scopes.attached` of an existing child; the role's owner for the delegation API since 2.2) (2.1) |
|
|
1229
|
+
| `E_AUTHZ_NOT_WITHIN` | 422 | any of the nine writes with `within` not in the chain of the scope it writes to (the new parent **and** the child's current chain for `scopes.moved`, and for `scopes.attached` of an existing child; the role's owner for the delegation API since 2.2) (2.1). The `message` names only what the caller passed — the scope and its `within` —; the scope's chain (another tenant's identifiers) is in `error.details.chain`, **not enumerable**, so `JSON.stringify(error)` does not carry it (rotation 2 closure; until then the message printed the whole chain). For `updateScopedRole`/`deleteScopedRole` the message names the role you passed, not its owner (rotation 2 closure 2). In `scopes.moved`/`scopes.attached` it comes **before** `E_AUTHZ_SCOPE_CYCLE` and `E_AUTHZ_SCOPE_DEPTH_LIMIT` (rotation 2 closure 2). The difference with `E_AUTHZ_UNKNOWN_SCOPE` is an existence oracle, declared in [Limits](#limits) |
|
|
1109
1230
|
| `E_AUTHZ_WITHIN_REQUIRED` | 422 | `requireWithin` set and a write without `within` (2.1) |
|
|
1110
1231
|
| `E_AUTHZ_WITHIN_ROOT_FORBIDDEN` | 422 | `requireWithin: 'non-root'` and `within: APP_SCOPE` (2.1) |
|
|
1111
1232
|
| `E_AUTHZ_ACTOR_REQUIRED` | 422 | `requireActor: true` and a write without `actor` (2.1); on the relations port (`relations.requireActor ?? requireActor`) the four writes, `purgeObject`/`purgeSubject` included since 2.4.0-alpha.3 |
|
|
1112
1233
|
| `E_AUTHZ_SCOPE_DEPTH_LIMIT` | 422 | `scopes.attached`/`scopes.moved` with `scopes.maxDepth` declared: the child would end up more than `maxDepth` `parent` hops from `app` (the parent's chain + 1). Refused **before** writing or enqueueing; the gate is opt-in and partial — see [Limits](#limits) (2.4.0-alpha.5). Since 2.4.0-alpha.8 the `message` is written for the tenant that made the request — the depth reached, `scopes.maxDepth`, "nothing written or enqueued" — and names no backend: *why* the ceiling exists (`FACTS_MAX_RESOLVE_DEPTH`, `OPENFGA_RESOLVE_NODE_LIMIT`) travels in `error.details` for the operator's log |
|
|
1234
|
+
| `E_AUTHZ_GROUP_DEPTH_LIMIT` | 422 | Relations (2.5.0-alpha.4, R1): a nesting `relate` (`group#member` into `group`) would leave a chain of nested groups longer than the driver's ceiling, would close a cycle, or walks more than `GROUP_DEPTH_MAX_NODES` groups — or, in `openfga`, exceeds the check's budget of `GROUP_DEPTH_MAX_READS` requests or `GROUP_DEPTH_MAX_TUPLES` tuples read (`details.reason`: `too-deep`/`cycle`/`too-many-groups`; `details.budget`: `nodes`/`reads`/`tuples`). Nothing written, no event. Running out of the check's total time (`GROUP_DEPTH_DEADLINE_MS`) is not this 422 but a transient 503 `E_AUTHZ_BACKEND_UNAVAILABLE`. The `message` is for the tenant and names no backend; `details` (not enumerable) carries the ceiling, why it exists and `OPENFGA_RESOLVE_NODE_LIMIT` — see [Relations](#relations-rebac-24) and [Limits](#limits) |
|
|
1113
1235
|
| `E_AUTHZ_TOO_MANY_SCOPES` | 422 | `authorizedScopes`/`expandExcludedSubtrees` over `maxScopes`, or `descendantsOf` over `maxNodes` (`sqlDescendantsOf`: also a possible cycle) — never a partial list (2.1) |
|
|
1114
|
-
| `
|
|
1115
|
-
| `
|
|
1236
|
+
| `E_AUTHZ_INVALID_PAGE` | 422 | `enumerateRelations`/`enumerateRelationsMany` (relations, 2.5.0-alpha.4 · 7.4) with a page that is not `{ limit?, after? }`: a `limit` that is not an integer ≥ 1, an `after` that is not a non-empty string or — on `database` — not the canonical uuid that driver emits as its cursor. Zero statements; a `limit` above `RELATIONS_ENUMERATE_MAX_LIMIT` is clipped, not this error. `InvalidRelationPageError` |
|
|
1237
|
+
| `E_AUTHZ_BACKEND_UNAVAILABLE` | 503 | facts backend or SQL catalog did not answer (both drivers, catalog sync/diff and the `authz_catalog_version` check included); the version row is missing or unreadable ("migración 2.0 no aplicada": fail-closed, never version 0); on `openfga` (since 2.5.0-alpha.4, R3) only the **transient** class: 5xx (501 included), 408, 429, `deadline_exceeded`, a per-check `internal_error` in a `batchCheck` — a 4xx is no longer a 503, see [How OpenFGA failures are classified](#how-openfga-failures-are-classified-250-alpha4); and a nesting `relate` whose depth check ran out of its total time (`GROUP_DEPTH_DEADLINE_MS`: a slow server, not your data — nothing written, no event; 2.5.0-alpha.4, third closure of R1) |
|
|
1238
|
+
| `E_AUTHZ_BACKEND_TIMEOUT` | 503 | `timeoutMs` elapsed (subclass of the above); on `openfga` also the SDK error that carries no response at all (a client timeout and a refused connection — but also a DNS failure, a TLS error or a reset connection — arrive as the same bare `FgaError`, and are not told apart without reading the message: the conservative class, `indeterminate`, 2.5.0-alpha.4), and a **write** answered 504 or `deadline_exceeded` (the deadline of the server or of a gateway in front of it: the write may have landed) |
|
|
1239
|
+
| `E_AUTHZ_RESOLUTION_LIMIT` | 503 | `openfga` (2.5.0-alpha.4, R3): the server answered `authorization_model_resolution_too_complex` — a scope chain or a group nesting deeper than it resolves —, from a single `Check` or as the per-check error of a `batchCheck`. Subclass of `E_AUTHZ_BACKEND_UNAVAILABLE` (a 503, transient for the relay) because at the edge it is probabilistic and a retry may succeed; past the bound it always fails. Never `false`. The bounds are in [Limits](#limits) |
|
|
1116
1240
|
| `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) |
|
|
1117
1241
|
| `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 |
|
|
1118
1242
|
| `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 |
|
|
1119
|
-
| `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) |
|
|
1120
|
-
| `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); an `openfga` relations purge against a server whose `--max-tuples-per-write` is below `RELATIONS_PURGE_WRITE_MAX` (100) — the server's 400 `exceeded_entity_limit`, marked as a partial write if an earlier `Write` already deleted (2.5.0) |
|
|
1243
|
+
| `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. Since 2.5.0-alpha.4 (R3) also any such race (409, or 400 `write_failed_due_to_invalid_input`) that escapes a write which does not re-read (until then a 503). 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) |
|
|
1244
|
+
| `E_AUTHZ_CONFIG` | 500 | on `openfga` (2.5.0-alpha.4, R3), a failure of the **deployment**: the store or the pinned model does not exist (`authorization_model_not_found`, `latest_authorization_model_not_found`, a 404 `store_id_not_found`), the credentials are refused (401/403), the server rejects a type, relation or context the pinned model does not admit (`validation_error`, also per check in a `batchCheck`), its `--max-tuples-per-write` is below the driver's batches (`exceeded_entity_limit`), or the client refuses a malformed id before calling; and a **4xx the package does not know** (the server's error in `cause`). "Not transient": the same call fails the same way until the deployment is fixed — but the tree relay does **not** consume an attempt on it (a parked `moved` would keep the old parent in the store) and `authz:scopes:relay` prints it on its own line, `fallo de DESPLIEGUE #…`. Also: `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); an `openfga` relations purge against a server whose `--max-tuples-per-write` is below `RELATIONS_PURGE_WRITE_MAX` (100) — the server's 400 `exceeded_entity_limit`, marked as a partial write if an earlier `Write` already deleted (2.5.0) |
|
|
1121
1245
|
| `E_AUTHZ_ROLE_IS_NOT_ACCESS` | 500 | `appAccess({ role })` |
|
|
1122
1246
|
| `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) |
|
|
1123
1247
|
| `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) |
|
|
@@ -1125,13 +1249,13 @@ Every error the package raises carries `status` and `code`. A standard AdonisJS
|
|
|
1125
1249
|
| `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) |
|
|
1126
1250
|
| `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` |
|
|
1127
1251
|
| `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 |
|
|
1128
|
-
| `E_AUTHZ_RENEWAL_UNSAFE` | 409 | `relate` on `openfga` (closure 2 of sublot D2) renewing — another expiry on an existing tuple — a `group:*#member` membership whose group, or a group containing it, is the subject of a live deny of the partition: the renewal is two `Write`s there and between them the subject would be granted. **Always zero writes**; the message carries the honest recipe (closure 5 of D2): there is no window-free way on `openfga` (openfga/openfga#2151); memberships without expiry in groups with a deny, or `unrelate` + `relate` accepting the window. A driver declaring `atomicRenewal: false` must answer it the same way. An already EXPIRED membership is not refused (closure 3 of D2) |
|
|
1252
|
+
| `E_AUTHZ_RENEWAL_UNSAFE` | 409 | `relate` on `openfga` (closure 2 of sublot D2) renewing — another expiry on an existing tuple — a `group:*#member` membership whose group, or a group containing it, is the subject of a live deny of the partition: the renewal is two `Write`s there and between them the subject would be granted. **Always zero writes**; the message carries the honest recipe (closure 5 of D2): there is no window-free way on `openfga` (openfga/openfga#2151); memberships without expiry in groups with a deny, or `unrelate` + `relate` accepting the window. A driver declaring `atomicRenewal: false` must answer it the same way. An already EXPIRED membership is not refused (closure 3 of D2). **Since 2.5.0-alpha.4 (L11) also** `relate` (relations) and `grant` (roles) on `openfga` when the key already exists with a condition the package does not write or cannot read (`foreign`/`unparseable`, see «The pinned model is the one your config generates»): zero writes, the message says "condición que este paquete no escribe"; its recipe is to inspect that tuple and delete it by hand, NOT `unrelate` + `relate` with a window |
|
|
1129
1253
|
| `E_AUTHZ_RENEWAL_RACED` | 409 | `RenewalRacedError` (closure 4 of D2; until then the same `E_AUTHZ_RENEWAL_UNSAFE`): an allowed two-`Write` renewal on `openfga` WENT THROUGH, and the re-check after it finds a deny that arrived concurrently and did not reach the subject between the two `Write`s. Marked partial (`indeterminate: true`), `renewalApplied: true`, `carriedDeny`. The final state is right: do NOT apply the `E_AUTHZ_RENEWAL_UNSAFE` recipe (it would reopen the window with the deny inside); review the subject's decisions in the window |
|
|
1130
1254
|
| `E_AUTHZ_DENY_MIGRATION_REFUSED` | 500 | `authz:relations:reconcile` / `runRelationsReconcile` (2.5.0-alpha.3): the source carries a live deny the **destination driver** cannot take — no `relationDenies`, no `denyPhase`, the type not in `deny: 'write'` in ITS config, the pair undeclared there, its pinned model without `denied_*`, or a source that does not mark `kind`. Decided in the plan phase: **zero writes** in every partition, never `skipped` (the allows alone would grant what the source denies). `error.refusals` lists them; `--dry-run` flags `report.denyRefused` instead of throwing (`deny NO migrable (<reason>)`, exit ≠ 0) |
|
|
1131
|
-
| `E_AUTHZ_MASS_PURGE_REFUSED` | 500 | `pruneOrphanRoles({ force: true })` would purge every distinct owner
|
|
1255
|
+
| `E_AUTHZ_MASS_PURGE_REFUSED` | 500 | `pruneOrphanRoles({ force: true })` would purge every distinct owner, or more than half the local roles, or orphans of more than one owner of which any still grants or cannot be counted (rotation 2 closure): the signature of a blind `resolveChain`. Nothing was deleted; pass `allowMassPurge: true` (`--allow-mass-purge`) if the prune is real |
|
|
1132
1256
|
| `E_AUTHZ_MEMBERS_DENY_LIMIT` | 500 | `membersOf` on `database` over an object with a live deny and more than `RELATIONS_MEMBERS_DENY_CHECK_MAX` (1,000) candidates; never a partial list. 500 and not 422: it is not an invalid question from the caller but a bound of the deployment (precedent `E_AUTHZ_TOO_MANY_LOCAL_ROLES`) (2.5.0) |
|
|
1133
|
-
| `E_AUTHZ_TOO_MANY_LOCAL_ROLES` | 500 | more local roles than `maxLocalRoles` (10 000) in a `prune-orphans` pass; never a partial list |
|
|
1134
|
-
| `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) |
|
|
1257
|
+
| `E_AUTHZ_TOO_MANY_LOCAL_ROLES` | 500 | more local roles than `maxLocalRoles` (10 000) in a `prune-orphans` pass; never a partial list. Raise it with `pruneOrphanRoles({ maxLocalRoles })` / `--max-local-roles` |
|
|
1258
|
+
| `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). And (2.5.0-alpha.4 · 7.4) a page argument passed to a relations listing — `listObjects`/`listSubjects`/`listDenies`/`membersOf` take none: anything but `undefined` is refused with zero backend calls, in the manager and in both drivers —, or an `after` handed to `enumerateRelations(Many)` of `openfga` (it never emits a cursor) |
|
|
1135
1259
|
| `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 % |
|
|
1136
1260
|
| `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 |
|
|
1137
1261
|
| `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 |
|
|
@@ -1186,9 +1310,9 @@ Keep NTP on every process that writes. Second, a `relate` that reads a *ticking*
|
|
|
1186
1310
|
(one advancing per read) is fine, but it is your test's clock: production leaves `clock` alone and keeps NTP
|
|
1187
1311
|
running.
|
|
1188
1312
|
|
|
1189
|
-
`openfga` additionally takes `holderTypes` (required, injective; a holder whose morph name is not in it is 500 `E_AUTHZ_CONFIG`), `
|
|
1313
|
+
`openfga` additionally takes `holderTypes` (required, injective; a holder whose morph name is not in it is 500 `E_AUTHZ_CONFIG`), **`modelId` (required since 2.5.0-alpha.4, in both `openfga` drivers: `openfga:provision` prints it as `OPENFGA_MODEL_ID`; see [the model gate](#the-pinned-model-is-the-one-your-config-generates-250-alpha4))**, 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.
|
|
1190
1314
|
|
|
1191
|
-
**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
|
|
1315
|
+
**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 `Write` carrying the delete of the old parent and the write of the new one — atomic for the writer (the store never holds both parents or none), **not for a concurrent reader**: a check that runs during that `Write` can read the old parent through one branch of the model and the new one through another (the torn-read row of [Limits](#limits)); with local roles in the catalog, the F4 sweep comes before and after it — 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".
|
|
1192
1316
|
|
|
1193
1317
|
Until 2.2 there was a second mode, `hierarchy: 'resolver'`, in which the tree stayed in your database and the package expanded the chain into a `batchCheck` of N×M on every question. **The option is gone**; passing it is a TypeScript error and is ignored at runtime. What that mode did is described in the changelog, together with what it cost to keep.
|
|
1194
1318
|
|
|
@@ -1212,6 +1336,8 @@ Every driver **declares** this through `driver.capabilities`, and each declared
|
|
|
1212
1336
|
| `countRoleAssignments` | `true` | **`true`** | how many live facts a role has — what makes `stillGranting` true instead of a guess (2.3; same enumeration `purgeRole` needs) |
|
|
1213
1337
|
| `canonicalScopeReads` | `true` | **`false`** | whether a read canonicalises the caller's scope spelling against your tree before looking for facts (see below) |
|
|
1214
1338
|
| `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 |
|
|
1339
|
+
| `experimental` (2.5.0-alpha.4; optional, normalised to `false`) | `false` | **`true`** in 2.5.x | the driver is outside the guarantee of the release: it is not built without `experimental: 'openfga@2.5'` and its open limits are in [Limits](#limits); the suite still runs whole against it (no case is skipped) |
|
|
1340
|
+
| `snapshotDecisions` (rotation 2 closure 3; optional, normalised to `false`) | **`true`** | `false` | every **decision** that combines grants and denies reads them from **one state** of the facts: `authorize` (and each position of `authorizeMany`) and `listScopes` in one SQL statement, and `readSnapshot` — the optional port method the manager uses for what it composes from two reads (`effectivePermissions`, `authorizedScopes`, the delegation policy of `defineScopedRole`/`updateScopedRole`). **The capability decides, not the method**: the manager calls `readSnapshot` only when the driver declares `true` (declaring `true` without the method is 500 `E_AUTHZ_CONFIG` before any read), and a method of that name on a driver that declares `false` is never called. Without it the manager composes as before — two reads with no common snapshot, declared in the [torn read](#limits) row. Your tree (`resolveChain`) and the catalog memo are read outside that snapshot. (The `database` **relations** driver has no such capability to declare: each of its decisions — `check`, `checkMany`, `decideMany`, `listObjects` — is one SQL statement, and so is `membersOf` while the object has no live deny (its candidates and its deny check together); with a deny on the object `membersOf` is 1 + N statements — the candidates, then one `check` per candidate, one snapshot per position like `authorizeMany` —, see the torn-read row) |
|
|
1215
1341
|
| `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 |
|
|
1216
1342
|
|
|
1217
1343
|
**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.
|
|
@@ -1254,7 +1380,7 @@ Two optimisations landed in 2.1, both measured and both **without changing a sin
|
|
|
1254
1380
|
- **A shared version in the database.** The migration ships `authz_catalog_version` (one row, `id = 1`). `syncAuthzCatalog` / `node ace authz:catalog:sync` increment it **as the last statement of the sync's transaction** — a sync that does not commit does not bump it. Before serving, each `CatalogCache` compares the version it loaded with that row (one primary-key `SELECT`, with the deadline, classified 503 like any other query; concurrent checks share one read) and reloads when the database is ahead. So a sync run by one worker, one container or a deploy job is seen by **every process on its next question** — no pub/sub, no restart, no TTL (*"el catálogo que decide es el de la base: un sync en otro proceso…"*, a contract case in both drivers, with two managers and two memos over the same database). If the version row cannot be read — the table is missing, **the row is missing or not a number** (a database without the 2.0 migration) — the question is 503 `E_AUTHZ_BACKEND_UNAVAILABLE` saying so: never version `0`, never an answer from a memo that might be stale (*"sin la fila de authz_catalog_version… 503"*).
|
|
1255
1381
|
- **`catalogRevalidate: 'always'`** (default) checks on every question. **`{ everyMs }`** checks at most once per window: it saves that `SELECT` at the price of a **bounded window in which another process's revocation is not yet seen** (a fail-open window you accept explicitly; a sync in the *same* process is still seen immediately). `{ everyMs: 30_000 }` is a reasonable trade for a read-heavy deployment whose catalog changes at deploy time. The window — like a view's `maxAgeMs` — is measured with a **monotonic clock** (`performance.now()`), so a wall clock stepped backwards by NTP or a snapshot restore neither stretches it nor revives an expired view (*"la ventana de { everyMs } se mide con reloj MONÓTONO"*).
|
|
1256
1382
|
- **Writing `authz_*` by hand** (a seeder, a data migration, a script) goes through **`withAuthzCatalogWrite(async (trx) => { … })`** — exported: it opens the transaction, runs your write with *that* client and bumps the version **as the last statement, inside**, so either both land or neither. Order matters and is enforced: `bumpAuthzCatalogVersion(trx)` requires the writing transaction's client (500 `E_AUTHZ_CONFIG` without it, or with the global `db`). A bump that commits *before* its write would make every other process reload the **old** rows tagged with the **new** version — and never revalidate again, a permanent fail-open (reproduced with two real processes; closed in 2.1). Until the write commits, the previous catalog stands, pinned as a negative case (*"un cambio en authz_* SIN subir la versión NO se ve"*). `withAuthzCatalogWrite` is the cross-process channel only: this process sees it on its next question under `'always'` and at the end of the window under `{ everyMs }` — call `invalidateAuthzCatalog()` after it if you use `everyMs` and need it at once (what `syncAuthzCatalog` does); `driver.catalog.invalidate()` reaches only that driver's memo (an invalidation that lands while a load is in flight is not lost).
|
|
1257
|
-
- Two drivers in one process can share one memo: `new DatabaseAuthorizationDriver({ catalog })` and `new OpenFgaAuthorizationDriver({ catalog })` with the same `new CatalogCache({ revalidate, timeoutMs })` — and without `catalogRevalidate` on the drivers (500 `E_AUTHZ_CONFIG`: the shared memo's `revalidate` is the policy).
|
|
1383
|
+
- Two drivers in one process can share one memo: `new DatabaseAuthorizationDriver({ catalog })` and `new OpenFgaAuthorizationDriver({ experimental: 'openfga@2.5', catalog, … })` with the same `new CatalogCache({ revalidate, timeoutMs })` — and without `catalogRevalidate` on the drivers (500 `E_AUTHZ_CONFIG`: the shared memo's `revalidate` is the policy).
|
|
1258
1384
|
|
|
1259
1385
|
**One `batchCheck` per `authorize` in `openfga`.** The denies of the chain and the roles that grant the permission travel in the same request (the SDK splits at 50 checks and parallelises); the rule is unchanged and evaluated in this order: any per-check `error` ⇒ 503, any deny `allowed` ⇒ `false`, any role `allowed` ⇒ `true`. When no role of the catalog grants the permission anywhere in the chain, the answer is `false` without a request — the denies cannot change it. Each operation takes one snapshot of the catalog, so one version check per question.
|
|
1260
1386
|
|
|
@@ -1299,6 +1425,8 @@ runAuthorizationDriverContract({
|
|
|
1299
1425
|
countRoleAssignments: true, // the port's optional countRoleAssignments (2.3); false ⇒ pruneOrphanRoles reports `undefined`, never `false`
|
|
1300
1426
|
canonicalScopeReads: true, // reads canonicalise the caller's scope spelling against your tree (2.3); false ⇒ an alias finds no facts (fail-closed)
|
|
1301
1427
|
enumerateFacts: false, // the port's optional enumerateFacts (2.3): can this driver be the SOURCE of authz:reconcile?
|
|
1428
|
+
experimental: false, // optional (2.5.0-alpha.4): true ⇒ the driver declares itself outside the release's guarantee (the suite still runs whole)
|
|
1429
|
+
snapshotDecisions: false, // optional (rotation 2 closure 3): true ⇒ the port's readSnapshot reads grants and denies from ONE state; judged at '2.1' and above
|
|
1302
1430
|
},
|
|
1303
1431
|
// The suite builds the scope tree case by case; hand it to your driver.
|
|
1304
1432
|
makeDriver: (tree) => new MyDriver({ resolveChain: resolveChainFrom(tree) }), // tree.chainOf(scope) = the canonical chain
|
|
@@ -1315,6 +1443,8 @@ runAuthorizationDriverContract({
|
|
|
1315
1443
|
|
|
1316
1444
|
Declaring a capability `true` that the suite has no case for makes registration throw — a promise without a judge does not pass. The port also has optional methods a driver may implement to do better than the manager's composition — `onScopeAttached/Moved/Detached` (tree as facts) and, since 2.1, `withChainResolver(resolver)` (a view of the driver bound to another resolver, what `forRequest()` uses to memoise reads), `listDenies(subject, scope?)` (direct denies; what `effectivePermissions` and `authorizedScopes` subtract), `authorizeMany(subject, permission, scopes)` (one round-trip for N decisions; its `boolean[]` is validated) and `rolesInChain(subject, chain)` (the holder's direct roles along a resolved chain in one read; what `effectivePermissions` uses); a driver without them keeps passing the same suite at `'2.0'`. At `'2.1'`, **`listDenies` is a capability pair**: `listDenies: true` judges `listDenies`, `effectivePermissions`, `authorizedScopes` and the shared catalog version through them; `listDenies: false` judges instead that those primitives *say so* — 500 `E_AUTHZ_UNSUPPORTED` naming the method, never a simulated `[]` (the package runs that face itself, over a `database` view without the method: *"sin listDenies en el puerto: … 500 E_AUTHZ_UNSUPPORTED"*). Declaring `listDenies: true` below `'2.1'` throws: nothing observes it there. `exhaustiveLists: false` asks for the backend's cap and proves only the exact boundary.
|
|
1317
1445
|
|
|
1446
|
+
Since the rotation 2 closure the port also has **`readSnapshot(subject, reads)`** (optional, with the capability **`snapshotDecisions`**, normalised to `false`): several port reads answered from **one state** of your facts — `reads` names any of `rolesInChain` (a resolved chain), `listScopes` (a permission) and `listDenies: true`, and each key comes back exactly as the method of the same name would answer it; what was not asked does not come back, and a malformed `reads` is 422 `E_AUTHZ_INVALID_IDENTITY`. The manager uses it for the decisions it *composes* from a grant read and a deny read — `effectivePermissions`, `authorizedScopes` and the delegation policy of `defineScopedRole`/`updateScopedRole` —, because two reads with no common snapshot can answer what no real state answers (a user offboarded with a `revoke` and then a `removeDeny` between the two reads came out of `effectivePermissions` with the permission the deny was there to remove). A driver without it keeps working exactly as before: the manager composes the two reads, and that gap is yours, declared in the [torn read](#limits) row. At `'2.1'` the pair judges either face: `true` — the method answers like its namesakes and the manager calls it **once** and never `rolesInChain`/`listScopes`/`listDenies` (*"snapshotDecisions:true · …"*; the judge counts only the manager's calls, so a `readSnapshot` that calls your own methods inside — a `REPEATABLE READ` transaction, say — passes); `false` — the manager never calls `readSnapshot`, even if your driver has a method with that name (the capability decides), and composes subtracting the denies (*"snapshotDecisions:false · …"*; without `listDenies` there is nothing composed to judge and that face only checks the declaration, as its title says). The delegation-policy part of each face (a `defineScopedRole` that must answer 422 because the actor has the permission denied) runs only when the harness declares `purgeRole: true` — a `'2.2'` pair: without `purgeRole` the manager does not delegate at all (500 before the policy) —, so a `'2.1'` driver with `listDenies` and without `purgeRole` passes the whole judge, and the title of each face says which variant was registered. The package runs the `false` face itself over a `database` view without the method (*"database (sin readSnapshot)"*), and both faces at `'2.1'` over `database` views without `purgeRole` (that one and *"database (2.1 sin purgeRole)"*). Whether your snapshot is really one is not something a generic judge can see from outside your backend: `database` pins it with forced interleavings and the surface guard `database.decision.snapshot`.
|
|
1447
|
+
|
|
1318
1448
|
Since 2.2 the port has **`purgeRole(roleUuid)`** (optional): revoke every assignment of the role in every scope, delete its links and the role row, atomically, bumping the shared catalog version (`withAuthzCatalogWrite`); a malformed uuid is 422 `E_AUTHZ_INVALID_IDENTITY`, an unknown one 422 `E_AUTHZ_UNKNOWN_ROLE`. **The atomicity of the link deletion is guaranteed by the SCHEMA, not by the driver code**: `authz_role_permissions.role_uuid` is `ON DELETE CASCADE` and `authz_assignments.role_uuid` is `ON DELETE RESTRICT` in the published migration (and in the test mirror, compared action by action by the stub-vs-mirror guard). The judge counts the links after the purge, but on this schema a driver that "forgot" to delete them would pass anyway — the engine deletes them: measured, and it is an equivalent mutant in SQLite, PostgreSQL and MySQL (tester 3E · R7). **If you run your own schema without those actions, or with foreign keys disabled, deleting the links is your driver's job and no test of this package will catch you.** It does not distinguish global from local (that barrier is the manager's). A driver that cannot purge declares **`purgeRole: false`** and must throw 500 `E_AUTHZ_UNSUPPORTED` without touching anything — the pair at `'2.2'` judges either the purge (*"purgeRole(uuid) revoca todas las asignaciones del rol en TODOS los scopes"*) or the refusal (*"sin purgeRole de verdad: el driver lo dice con 500"*); `true` below `'2.2'` throws. The `'2.2'` cases also judge local roles on the driver itself — visibility by owner in every read and write (*"un rol local de la organization A concede en A y sus units"*, *"dos tenants definen el mismo slug"*, the reserved `global` key, deny × local role, `assignableAt` not evaluated) — using roles written straight into `authz_*` as another process would, and, under `listDenies: true`, the delegation API through a manager over your driver (*"defineScopedRole: el rol que el administrador de A delega concede en A y sus descendientes"*); under `listDenies: false`, that `defineScopedRole` says so with 500. A driver reads a role's owner from the catalog memo (`CatalogView.roleVisible(slug, scopeType, chainKeys)` — which **throws** 422 `E_AUTHZ_AMBIGUOUS_ROLE` when more than one role is visible: never resolve the ambiguity yourself —, `rolesNamed`, `roleByUuid(...).owner`, `isRoleVisibleWith`, and the shared `declaredRoleAt(catalog, uuid, scopeType, chainKeys)`) and applies the rule per level of the chain: an assignment in scope S counts only if the role is global or its owner is in chain(S).
|
|
1319
1449
|
|
|
1320
1450
|
Since 2.3 the port also has **`countRoleAssignments(roleUuids)`** (optional, and **breaking if you wrote a 2.2 driver**): how many live facts each role has, across every scope, answered **by position** like `authorizeMany`. A live fact is an assignment that has not expired, judged with the driver's own clock; a role with none — or one the backend does not know — is `0`; a malformed uuid is 422 `E_AUTHZ_INVALID_IDENTITY`. It is what `pruneOrphanRoles` (`authz:catalog:prune-orphans`) asks before saying whether an orphan role is still granting, and it is a question for the *driver* because the facts are the driver's: until 2.3 the sweep counted rows of `authz_assignments` — the `database` driver's table — so with `openfga` it always answered "this role grants nothing", right before a destructive delete. It is deliberately conservative: it counts facts, it does not re-resolve each fact's scope. A driver that does not implement it declares **`countRoleAssignments: false`** and the sweep reports `assignments` and `stillGranting` as **`undefined`** — never `false` — and the command lists those roles apart; the pair at `'2.2'` judges either the counting (*"countRoleAssignments(uuids) cuenta los hechos VIGENTES"*) or that "I don't know" never degrades to "it does not grant" (*"sin countRoleAssignments: el puerto NO lo trae"*).
|
|
@@ -1390,6 +1520,24 @@ Seventeen cases per harness (2.4.0-alpha.8): nine core duties — including O-07
|
|
|
1390
1520
|
|
|
1391
1521
|
## OpenFGA tooling
|
|
1392
1522
|
|
|
1523
|
+
> **Experimental in 2.5.x — not production-ready, outside the semver guarantee, closes in 2.6.** "Outside the semver
|
|
1524
|
+
> guarantee" means exactly this: the experimental parts of `openfga` — the opt-in token and the drivers' behaviour — may
|
|
1525
|
+
> change in 2.6, a minor release, without a major; the subpath, its exports and the rest of the package keep semver. Both `openfga` drivers
|
|
1526
|
+
> (`OpenFgaAuthorizationDriver` and `OpenFgaRelationsDriver`) refuse to be built without the literal option
|
|
1527
|
+
> **`experimental: 'openfga@2.5'`**: absent, `true`, `'acknowledged'` or another version is **500 `E_AUTHZ_CONFIG`** as the
|
|
1528
|
+
> first line of the constructor — nothing else is validated, no client is created, the server is not called. The gate is
|
|
1529
|
+
> in the constructor, not in the config stub, so `manager.driver()` (and `authz:catalog:sync` with it),
|
|
1530
|
+
> `authz:reconcile --to=openfga`, `authz:relations:reconcile` and a direct `new` all go through it; a database-only config
|
|
1531
|
+
> that merely *registers* an `openfga` factory boots and answers (factories are lazy). `openfga:provision` builds no
|
|
1532
|
+
> driver — it creates the store and publishes the model — so it runs without the token; the first driver that serves
|
|
1533
|
+
> from that store does not. Both drivers declare `capabilities.experimental: true`
|
|
1534
|
+
> (`database`: `false`). Why: `openfga` still has open races and unmeasured fixes — each one is a row of [Limits](#limits)
|
|
1535
|
+
> marked *declared limit*, with its figure (or "not measured"), recipe, detector and a characterisation case — and they
|
|
1536
|
+
> close in **2.6**. The token carries the version on purpose (`true` is refused): an opt-in given for 2.5 is not an opt-in
|
|
1537
|
+
> for a later release with other limits, so an upgrade fails loudly instead of slipping in through `^2.5.0`. The subpath
|
|
1538
|
+
> keeps its name (`@jantstack/adonis-authz/openfga`). Use it for CI, rehearsals and development; in production, use
|
|
1539
|
+
> `database`.
|
|
1540
|
+
|
|
1393
1541
|
```bash
|
|
1394
1542
|
node ace openfga:provision # creates a store + writes the model for your holderTypes AND your permissions
|
|
1395
1543
|
node ace openfga:provision --store-id 01H… # writes a new model version into an existing store
|
|
@@ -1398,6 +1546,15 @@ node ace authz:catalog:sync # …and projects the catalog into
|
|
|
1398
1546
|
|
|
1399
1547
|
**`openfga:provision` writes the `facts` model, and that model carries your permissions** (four relations each: `<P>`, `can_<P>`, `denied_<P>`, `permits_<P>`), so the command resolves the `catalogs` you declared in `config/authorization.ts` — they are plain functions, no database needed. Without them there is nothing to publish and the command exits non-zero rather than leaving a store that denies everything. Adding or removing a permission changes the model: write a new version with `--store-id` and re-run `authz:catalog:sync`, which rewrites the derived projection (and re-places the root marker) through the driver.
|
|
1400
1548
|
|
|
1549
|
+
**`openfga:provision` re-reads what it published (2.5.0-alpha.4).** Right after `writeAuthorizationModel` it reads
|
|
1550
|
+
the model back by its id and compares it, whole, with the one the config generates (the same comparison the drivers
|
|
1551
|
+
run at start-up). Equal ⇒ it prints `OPENFGA_MODEL_ID=…`. Different, or the read fails ⇒ exit 1 with the stable
|
|
1552
|
+
prefix `provision RELECTURA FALLIDA:` — the model **is already published** in the store, and the line
|
|
1553
|
+
`OPENFGA_MODEL_ID=` is **not** printed: do not pin it (a server that serialises the model differently, e.g. another
|
|
1554
|
+
OpenFGA version — CI runs v1.19.0 — would make every driver answer 500 at its first use). Both clients of the command
|
|
1555
|
+
and `provisionOpenFgaStore` run with `retryParams: { maxRetry: 0 }`: since SDK 0.9.6 a client without them retries
|
|
1556
|
+
429/5xx three times, and a retried `createStore` can leave an orphan store.
|
|
1557
|
+
|
|
1401
1558
|
**`openfga:import` is gone (2.3, breaking).** It copied `authz_*` into the tuple shapes of the old `resolver` mode (`role_binding#assignee`, `deny_binding#denied`) — shapes the current model does not even declare, so a store filled by it would be rejected by the server and grant nothing. Its replacement is **`authz:reconcile`**, below. `E_AUTHZ_STORE_NOT_EMPTY` went with the command.
|
|
1402
1559
|
|
|
1403
1560
|
### Migrating and verifying: `authz:reconcile` (2.3)
|
|
@@ -1439,7 +1596,7 @@ What one pass does, in order: the **root marker** (`scope:app#rooted`, without w
|
|
|
1439
1596
|
|
|
1440
1597
|
**The store is SHARED, and this pass only governs its own four object types (2.4.0-alpha.9).** With `openfga`, the facts of this engine and the tuples of [`relations/`](#relations-rebac-24) live in the **same store**, and this pass has to read it whole (that is the only way to see what is left over). Until 2.4.0-alpha.8 everything it did not recognise was classified as a leftover *fact*, so **`--prune` deleted every relation tuple in the store** — 60 out of 60 measured by a consumer, the built-in `group` type included — and the destructive pass reported `clean = true` with exit 0, because it had just deleted the very thing that made it dirty. What this pass governs is now an explicit, frozen list of **object types**: `scope`, `role`, `role_binding` and the obsolete `deny_binding` (the deny of the ≤ 2.2 `resolver` mode, which `enumerateFacts` still reads and emits — so the promise that *one pass cleans a store written by the previous version* is kept whole). **Anything else is foreign**: it is not deleted, not even with `--prune`, it is not drift, and it does not enter `skipped` (which is why `clean` can be `true` again on a shared store). The classification **reads nothing at runtime** — not your relations config, not the published model, not a table: the process that runs a cutover is exactly the one that may not have that config, and a failed read must never turn somebody else's data into something deletable.
|
|
1441
1598
|
|
|
1442
|
-
The report carries them in a field of their own, `foreign: { tuples, types, relations?, unknown? }` (`phases` does not grow: it is a published type), and the command prints them with their type: `ajenas: 60 tupla(s) de tipos que esta pasada NO gobierna [document (40), group (10), space (10)]`, plus `--prune ha respetado 60 tupla(s) ajenas` on the destructive pass. When `relations.config` is wired, `foreign` is split into `relations` (your declared types, plus `group`) and `unknown` (nobody claims them) — **for the report only**. The way back has no symmetric defect and never had one: the relations reconcile has always enumerated by an allowlist of types and cannot see, let alone delete, a fact (measured). Two limits worth writing down: foreign tuples still count against `--max-tuples` (the snapshot is of the *store*, not of this pass), and **the tuples of a relation type you no longer declare are swept by nobody** — take `document` out of `objectTypes` and its tuples stay in the store (`enumerateRelations` filters by the allowlist, `unrelate`/`purgeObject` are 422 by F-05, and this pass now respects them), so they **grant again if you ever re-declare that type**. The way out today is to declare the type again and run `authz:relations:reconcile --prune` **for that partition** — and two caveats before you do (audit of alpha.9): the pass refuses with `E_AUTHZ_MASS_RECONCILE_REFUSED` when the source (`database`) holds none of those tuples, and the flag that lifts that refusal (`--allow-mass-delete`) will happily empty the **live** relations of the partition you point it at, so check `--partition-type`/`--partition-uuid` twice; and the command reconciles **one partition per pass**, so a multi-tenant cleanup is a loop you write. A `--prune-undeclared` is on the 2.
|
|
1599
|
+
The report carries them in a field of their own, `foreign: { tuples, types, relations?, unknown? }` (`phases` does not grow: it is a published type), and the command prints them with their type: `ajenas: 60 tupla(s) de tipos que esta pasada NO gobierna [document (40), group (10), space (10)]`, plus `--prune ha respetado 60 tupla(s) ajenas` on the destructive pass. When `relations.config` is wired, `foreign` is split into `relations` (your declared types, plus `group`) and `unknown` (nobody claims them) — **for the report only**. The way back has no symmetric defect and never had one: the relations reconcile has always enumerated by an allowlist of types and cannot see, let alone delete, a fact (measured). Two limits worth writing down: foreign tuples still count against `--max-tuples` (the snapshot is of the *store*, not of this pass), and **the tuples of a relation type you no longer declare are swept by nobody** — take `document` out of `objectTypes` and its tuples stay in the store (`enumerateRelations` filters by the allowlist, `unrelate`/`purgeObject` are 422 by F-05, and this pass now respects them), so they **grant again if you ever re-declare that type**. The way out today is to declare the type again and run `authz:relations:reconcile --prune` **for that partition** — and two caveats before you do (audit of alpha.9): the pass refuses with `E_AUTHZ_MASS_RECONCILE_REFUSED` when the source (`database`) holds none of those tuples, and the flag that lifts that refusal (`--allow-mass-delete`) will happily empty the **live** relations of the partition you point it at, so check `--partition-type`/`--partition-uuid` twice; and the command reconciles **one partition per pass**, so a multi-tenant cleanup is a loop you write. A `--prune-undeclared` is on the 2.6 list. There is deliberately **no `--prune-foreign`**: a mass deletion of another subsystem's data, triggerable from CI, is not a flag.
|
|
1443
1600
|
|
|
1444
1601
|
**`--dry-run` is the verifier, and it is read-only by contract**: same walk, same numbers, zero writes, exit 1 if there is anything to do. Run it in CI or in a cron. **`expired` does not fail the pass** (2.3): it is the migration's one *declared* loss — an expired assignment grants nothing in either driver, nothing sweeps expired rows out of the source (observable expiration without a scheduler, on purpose), and no write to the destination can "fix" it — so counting it as drift made the green verifier unreachable with real data: the first expiry pinned exit 1 forever. It is still reported (`N sin migrar por 'expired'`); it just is not drift. Expired facts *left over in the destination* are still drift (`extra-fact`), and their sweep exists: `--prune`. **There is no `--fix` and there will not be one** — a repair flag on a verifier is a grant mechanism. **And it does not freeze** (2.3): a pass that writes nothing has nothing to protect, so freezing there would buy zero and cost a write-outage mechanism fired by a cron job — which is precisely what "run it in a cron" invites. The pass that *does* write still freezes.
|
|
1445
1602
|
|
|
@@ -1472,6 +1629,14 @@ node ace authz:reconcile --to=openfga --dry-run # after the switch: the M
|
|
|
1472
1629
|
node ace authz:unfreeze # close the window
|
|
1473
1630
|
```
|
|
1474
1631
|
|
|
1632
|
+
**Retiring the old store: deleting it is not enough (measured on OpenFGA v1.19.0, memory engine).** A store removed
|
|
1633
|
+
with `DELETE /stores/{id}` (204) **keeps serving** in that process: `ReadAuthorizationModel` of its pinned model, `Read`,
|
|
1634
|
+
`Check` — and it **accepts `Write`**. The pinned-model gate (L11) sees it alive, so a process still configured with the
|
|
1635
|
+
old `OPENFGA_STORE_ID` keeps deciding and writing against it without any error. After the switch, remove the old store
|
|
1636
|
+
id from **every** process's configuration (and restart them) before you delete it; do not take the `204` (nor
|
|
1637
|
+
`scripts/openfga_prune_stores.mjs`) as proof that nothing can use it any more. With a persistent datastore
|
|
1638
|
+
(PostgreSQL/MySQL) this has not been measured.
|
|
1639
|
+
|
|
1475
1640
|
**Verify before the switch, never with `--from` after it (2.4.0-alpha.10).** Once `config.default` is `openfga`,
|
|
1476
1641
|
`--to=openfga --from=database` is the stale direction by definition — even inside the same live operator window,
|
|
1477
1642
|
where nobody has written since the migration — so its `--dry-run` comes out as drift (exit 1) and the real pass is
|
|
@@ -1509,6 +1674,60 @@ Three more properties worth knowing before putting it in front of production tra
|
|
|
1509
1674
|
- **Expiry follows the app server's clock.** The `not_expired` condition is evaluated against a `current_time` your process sends with each check, and enumerations filter with the same clock. Keep NTP running.
|
|
1510
1675
|
- **There is no distributed transaction with your database, and the catalog that *decides* is the projection in the store.** A `grant` validates the role against the local catalog and then writes the tuple. Delete that role from `authz_*` **by hand** afterwards and the two drivers stop agreeing, which is worth knowing before you write a migration script: membership (`hasRole`, `listRoles`, `listRoleScopes`, `listSubjects`) filters through the local catalog in both drivers and fails closed from the first instant, but `authorize` is decided by the store, where the permission→role mapping is the projection (`role:<uuid>#permits_<P>`) that your delete did not touch — **so it keeps granting**. Whoever writes `authz_*` by hand owes it a `driver.projectCatalogRole(uuid)`, exactly as it already owes a catalog-version bump; `syncAuthzCatalog` and the delegation API do it for you (*"en `facts` el catálogo que decide es la proyección del store, no la fila que acabas de borrar"*). `purgeScope`, meanwhile, cannot reach bindings of roles that are no longer in the catalog (it reads by exact object, built from the catalog; `Read` cannot enumerate by id prefix without a `user`). Reconciling both is the job of `authz:reconcile --to=openfga`.
|
|
1511
1676
|
|
|
1677
|
+
#### The pinned model is the one your config generates (2.5.0-alpha.4)
|
|
1678
|
+
|
|
1679
|
+
Both `openfga` drivers decide with a model **the server** holds, so the package checks it before trusting it. Once per
|
|
1680
|
+
process (one `ReadAuthorizationModel` of the pinned `modelId`, shared with every `withClock`/`withChainResolver`/
|
|
1681
|
+
`forRequest()` view), each driver compares the model it serves with the one its config generates, **per type**, in
|
|
1682
|
+
**rules, type restrictions (with their condition) and conditions**:
|
|
1683
|
+
|
|
1684
|
+
- **What is compared.** The roles driver: your holder types, `role`, `role_binding`, `scope` and the `conditions`
|
|
1685
|
+
map, modulo the catalog (the permissions are the ones the served model declares: `role#permits_*`). The relations
|
|
1686
|
+
driver: your holder types, `group` and the object types of its config — **per operation**: the types the operation
|
|
1687
|
+
touches, plus `group` (a different `group#member` changes who is a member, so what every type grants) and the
|
|
1688
|
+
`conditions` (they decide whether any tuple is alive), plus your holder types always (the subject of every operation).
|
|
1689
|
+
It is **per type**: a drift in `note` does not block a `check` on `document`. An operation on `group` (a membership)
|
|
1690
|
+
compares **every** type of the config — its renewal reads the `denied_*` of the types with deny —, which is wider than
|
|
1691
|
+
"the operation's types + `group`": a drift in any type blocks the memberships too (fail-closed, on purpose). A
|
|
1692
|
+
relation **more** than the config declares is drift too. A type without `deny` may be served in the deny form only as
|
|
1693
|
+
a **whole type** (the form a config with `deny` generates, D8); a mixed type is drift.
|
|
1694
|
+
- **Different ⇒ 500 `E_AUTHZ_CONFIG`** naming the differences by path (`document#denied_viewer (restricciones de
|
|
1695
|
+
tipo)`, `conditions.inverted (condición de más)`, `scope#can_docs_read (regla)`…) with **zero** calls that evaluate or
|
|
1696
|
+
write. Recipe: republish from the current config (`openfga:provision --store-id`) and pin the new `modelId` on the
|
|
1697
|
+
whole fleet before deploying. The comparison is memoised once it was *made* (with or without differences); a failure
|
|
1698
|
+
to **read** the model, whatever its class, is not memoised — the next operation reads again. Since R3 is not in yet, a
|
|
1699
|
+
`modelId` that does not exist in the store is a 503 for now.
|
|
1700
|
+
- **For the scope relay that 500 is a deployment failure, not the entry's**: `authz:scopes:relay` records it
|
|
1701
|
+
(`last_error`, `transient: true`) **without consuming an attempt and without parking** the `moved`/`detached` — parking
|
|
1702
|
+
them on a half-done deployment kept the old parent (and the old tenant's access) until a manual `requeue`. The next
|
|
1703
|
+
pass after the fleet runs the right model applies them.
|
|
1704
|
+
- **No operation is exempt.** Reads, purges and writes pass the whole gate (the order a purge deletes in was proven on
|
|
1705
|
+
the config's semantics). `enumerateRelations(Many)` and `enumerateFacts` — the *source* of a migration, which has to
|
|
1706
|
+
be able to rescue a drifted store — pass only the `conditions` part.
|
|
1707
|
+
- **Declared limit.** A type of the store that is neither yours nor the package's (another application's type in a
|
|
1708
|
+
shared store) is not compared: no rule the package generates references it (a case proves it structurally).
|
|
1709
|
+
|
|
1710
|
+
**A tuple's expiry comes from one function, and "unreadable" is never "permanent".** `fgaTupleExpiry` (in `src/expiry.ts`)
|
|
1711
|
+
reads `not_expired` only when its context is **exactly** `{ valid_until }` with a timestamp of the grammar the server
|
|
1712
|
+
accepts (fraction of any length truncated to nanoseconds, `.` or `,`, offsets up to `±24:mm`; measured on v1.19.0) and
|
|
1713
|
+
rounds **up** to the next millisecond — the server compares in nanoseconds and the package sends `current_time` in
|
|
1714
|
+
milliseconds, so this is the exact same answer (a `valid_until` of `…30.999999999Z` is alive at `.999`; `new Date()`
|
|
1715
|
+
used to truncate it and call it expired). Anything else is `foreign` (another condition, or a `not_expired` whose tuple
|
|
1716
|
+
freezes its own `current_time`) or `unparseable` (no `valid_until`: the server answers 400 when it evaluates it), and
|
|
1717
|
+
each reader decides, always failing closed:
|
|
1718
|
+
|
|
1719
|
+
| Who reads it | `foreign` / `unparseable` |
|
|
1720
|
+
|---|---|
|
|
1721
|
+
| `relate` (relations) / `grant` (roles) on that key (also when the key appears in a race) | 409 `E_AUTHZ_RENEWAL_UNSAFE`, nothing written, recipe: inspect it and delete it by hand |
|
|
1722
|
+
| the renewal check of a group membership that carries a deny | the deny (or the nesting edge) counts as **alive**: more 409s, never fewer |
|
|
1723
|
+
| the denies that decide `listScopes` (roles) | counted **alive**: fewer scopes listed, never more |
|
|
1724
|
+
| counts: `countRoleAssignments` (roles; `stillGranting` of `authz:catalog:prune-orphans`) | **counted**: "I don't know" never degrades to "does not grant" |
|
|
1725
|
+
| `listSubjects` / `listDenies` (relations), `listSubjects` / `listRoles` / `listDenies` / … (roles) | not listed; warned and counted (`diagnostics.unparseableTuples` / `unparseableBindings`) |
|
|
1726
|
+
| `enumerateRelations` / `enumerateFacts` (source of a migration) | `enumerateRelations`: emitted marked (`RelationTuple.invalidCondition`, with `expiresAt` = the epoch). **Check the mark before the date**: a reader that ignores it reads the tuple **expired**, which fails closed for an allow but **open** for a deny (or a `group` membership carrying one) — no `expiresAt` value is safe for both. `enumerateFacts`: an assignment is skipped with `invalid-condition` (never migrated); a **deny** is emitted marked (`ReconcileFact.invalidCondition`): `--to=database` migrates it as a deny (fails closed) and declares it (`invalid-condition`, drift) |
|
|
1727
|
+
| the source of `authz:relations:reconcile` | not migrated (`skipped.invalid`, drift); a **deny** like that refuses the pass (500 `E_AUTHZ_DENY_MIGRATION_REFUSED`); a **`group` membership** like that in a partition with **any** deny refuses that partition (`partitionsRefused`, `invalid-condition`: zero writes in it — the server may be evaluating it alive and carrying the deny) |
|
|
1728
|
+
| the destination of `authz:relations:reconcile` / `authz:reconcile` | present and untouchable: `extra`/`skipped` with `invalid-condition`, never deleted (not even with `--prune`), its key never rewritten from the source; drift (exit ≠ 0). A source deny whose key it blocks refuses the pass (500 `E_AUTHZ_DENY_MIGRATION_REFUSED`, both commands; `--dry-run` lists it) — except the roles **maintenance pass** (`--to=<active driver>`, source = the store itself): there the deny is reported (drift), neither written nor deleted, and the pass still rebuilds the root marker, the tree and the `roleVisibility` sweep; a `group` membership like that in a partition with any deny refuses the partition (relations) |
|
|
1729
|
+
| purges | deleted like any other tuple |
|
|
1730
|
+
|
|
1512
1731
|
#### The model ceiling is bytes, not permissions
|
|
1513
1732
|
|
|
1514
1733
|
The `facts` model publishes **four relations per permission**, so a big catalog can outgrow OpenFGA's authorization-model limit (262,144 bytes by default, `OPENFGA_MAX_AUTHORIZATION_MODEL_SIZE_IN_BYTES`). `syncAuthzCatalog` checks it **before writing anything** — 500 `E_AUTHZ_MODEL_TOO_LARGE`, with a warning past 80 % — and the check is exact: the package measures the **protobuf** size, which is what the server measures, and it matches the number the server reports byte for byte (four catalog shapes, verified against a real server).
|
|
@@ -1540,25 +1759,67 @@ legal for the `database` driver, which has no such ceiling — the same tree is
|
|
|
1540
1759
|
503 in the other, and it is a DoS within reach of whoever can nest sub-scopes. Raise
|
|
1541
1760
|
`OPENFGA_RESOLVE_NODE_LIMIT` on the server if your tree is deeper.
|
|
1542
1761
|
|
|
1762
|
+
### How OpenFGA failures are classified (2.5.0-alpha.4)
|
|
1763
|
+
|
|
1764
|
+
Until 2.5.0-alpha.4 every failure of the OpenFGA server that was not a timeout came out as 503 "the backend did not answer" — also a 401, a store that does not exist or a relation the pinned model does not declare, which answer the same way on every retry. Since R3 **one function** classifies them (`classifyFgaFailure`, owner's decisions 2026-10-17 (3) and (4)), called from the three places an OpenFGA error can come from in the two drivers (the client wrapper of each driver and the per-check error of a `batchCheck`), and it decides **by the HTTP status and the server's error code — an allowlist — or by the key of a per-check error (`input_error` / `internal_error`), never by the message** (measured: 41 shapes of SDK 0.9.6 against OpenFGA v1.19, pinned as fixtures in the suite):
|
|
1765
|
+
|
|
1766
|
+
| Class | What the server said | You get | The tree relay |
|
|
1767
|
+
|---|---|---|---|
|
|
1768
|
+
| transient | no answer, 5xx (501 included), 408, 429, `deadline_exceeded`, per-check `internal_error` | 503 `E_AUTHZ_BACKEND_UNAVAILABLE` (or `E_AUTHZ_BACKEND_TIMEOUT`, including the bare SDK error a client timeout and a refused connection share — and, **on a write**, a 504 or `deadline_exceeded`: indeterminate, see [Writes](#writes)) | does not consume an attempt |
|
|
1769
|
+
| graph too deep | `authorization_model_resolution_too_complex` (single `Check` or per check) | 503 `E_AUTHZ_RESOLUTION_LIMIT` — at the edge a retry may succeed; past it, never | does not consume an attempt |
|
|
1770
|
+
| deployment or data the pinned model rejects | 401/403/404; 400 `validation_error`, `authorization_model_not_found`, `latest_authorization_model_not_found`, `exceeded_entity_limit` (also per check) | 500 `E_AUTHZ_CONFIG`, "not transient", the server's error in `cause` | does **not** consume an attempt (it is the deployment, not the entry: parking a `moved` would leave the old parent in the store) |
|
|
1771
|
+
| race with another writer | 409; 400 `write_failed_due_to_invalid_input` | handled by the driver (re-read and re-apply); if it escapes, 409 `E_AUTHZ_WRITE_CONFLICT` | does **not** consume an attempt either (owner's decision (3), "race ⇒ as before": until R3 an escaped race was a 503; contention — a concurrent `grant` writing the same `scope#binding` edge the `moved` sweep rewrites — must never park a `moved`); `authz:scopes:relay` prints it as `CARRERA en #…` |
|
|
1772
|
+
| unknown 4xx | any other 4xx (a 422 included), or a 400 code outside the allowlist | 500 `E_AUTHZ_CONFIG` with `cause` | does not consume an attempt |
|
|
1773
|
+
|
|
1774
|
+
Three things the classes cannot see, said out loud. A relation missing from the pinned model, a tuple without its `valid_until` and a request without `current_time` are the same `validation_error`, and a store that does not exist answers the same `authorization_model_not_found` as a model that does not exist when the model id is pinned: each pair lands in the same class, so the message of the `500` names them all. Telling "already exists" from "does not exist" inside a race needs the message; the two functions that read it (`isDuplicateWrite`, `isTreeWriteRace`) are the only exemption, signed by the owner, and they sit on top of the race class. And the SQL twin (`guardSql`: a missing table or column is still a 503) is out of this release. A failure to **read the pinned model** is never memoised, whatever its class: a rotated token recovers on the next call without restarting the process.
|
|
1775
|
+
|
|
1776
|
+
**What "does not consume an attempt" costs you in monitoring.** A deployment failure, an unknown 4xx or a race that never clears is transient **forever**: the entry never parks, so `dead()` stays empty and `deadCount` stays 0 while a `moved` is not applied (the old parent still grants in the store). An alert that watches only `dead()` does not see it. Watch the relay's **exit code** (≠ 0 on every pass with a failure, with a line per branch: `fallo de DESPLIEGUE #…`, `CARRERA en #…`, `límite de RESOLUCIÓN en #…`, `falló #…`) and the **age of the queue** (`authz:scopes:relay --max-age=<ms>` exits ≠ 0 past it; `report.queue.pendingAgeMs`). And an entry whose failure repeats keeps contaminating its scope key on every pass, so a later `detached` of that key waits behind it too (invariant 11 is about parked entries, not failed ones) — fix the cause and the next pass applies both, in order.
|
|
1777
|
+
|
|
1543
1778
|
### Limits
|
|
1544
1779
|
|
|
1545
1780
|
Every bound the package enforces or measured, in one place, each with the constant or option that carries it — the suite imports those constants and asserts that **these numbers are the ones in the code** (a constant that changes without this table turns red). What lies past each bound is written next to it, because "past the limit" is never a silent `false` here.
|
|
1546
1781
|
|
|
1547
1782
|
| Limit | Constant / option | Value | Where | Past it |
|
|
1548
1783
|
|---|---|---|---|---|
|
|
1549
|
-
| Scope-chain depth the `facts` model resolves reliably (`parent` hops from a scope to `app`) | `FACTS_MAX_RESOLVE_DEPTH` | 22 | `openfga` only, `database` has no ceiling | `authorize` is 503, never `false`, for the whole subtree below (measured: 23 fails between 4 % and 26 % of the time, 24 always). Raise `OPENFGA_RESOLVE_NODE_LIMIT` on the server if your tree is deeper — and declare `scopes.maxDepth`, below |
|
|
1784
|
+
| Scope-chain depth the `facts` model resolves reliably (`parent` hops from a scope to `app`) | `FACTS_MAX_RESOLVE_DEPTH` | 22 | `openfga` only, `database` has no ceiling | `authorize` is 503 `E_AUTHZ_RESOLUTION_LIMIT` (2.5.0-alpha.4; until then the generic `E_AUTHZ_BACKEND_UNAVAILABLE`), never `false`, for the whole subtree below (measured: 23 fails between 4 % and 26 % of the time, 24 always). Raise `OPENFGA_RESOLVE_NODE_LIMIT` on the server if your tree is deeper — and declare `scopes.maxDepth`, below |
|
|
1785
|
+
| Group nesting depth (`group ⊂ group` hops of the LONGEST path through the edge; relations, 2.5.0-alpha.4 · R1) | `maxGroupDepth` (`RELATIONS_GROUP_DEPTH_MEASURED`) | 22 | `openfga`: calibrated against the server at first use (minimum of a member and a non-member with memberships; 22 on v1.19 with the default `--resolve-node-limit`, measured), on by default, `maxGroupDepth` in `defineRelationsConfig` may only lower it (1..97, or `false` to switch it off); `database`: none unless `database.enforceGroupDepth: true` | 422 `E_AUTHZ_GROUP_DEPTH_LIMIT` with zero writes on the edge that exceeds or closes a cycle (renewals included); 503 `E_AUTHZ_RESOLUTION_LIMIT` on reads of a deeper chain; `authz:relations:reconcile` refuses the partition (`too-deep`). Declared gaps: concurrent writes of two halves, and no scan of an `openfga`-only store (2.6) — see [Relations](#relations-rebac-24) |
|
|
1786
|
+
| Groups one nesting check walks (upwards from the parent plus downwards from the child) | `GROUP_DEPTH_MAX_NODES` | 10,000 | both relations drivers, on a nesting `relate` | 422 `E_AUTHZ_GROUP_DEPTH_LIMIT` (`details.reason: 'too-many-groups'`), never "passes"; the walk also stops as soon as its BFS levels exceed the ceiling. Cost in `openfga`: one `Read` per group walked (downwards, it reads the group's holders too), bounded by the three rows below |
|
|
1787
|
+
| `Read` requests one nesting check may send (`openfga`) | `GROUP_DEPTH_MAX_READS` | 2,000 | `openfga`, on a nesting `relate` | 422 `E_AUTHZ_GROUP_DEPTH_LIMIT` (`too-many-groups`, `details.budget: 'reads'`), nothing written |
|
|
1788
|
+
| Tuples one nesting check may read (`openfga`; holders included: the server cannot filter the downward `Read` by user type) | `GROUP_DEPTH_MAX_TUPLES` | 50,000 | `openfga`, on a nesting `relate` | 422 (`details.budget: 'tuples'`), nothing written |
|
|
1789
|
+
| Total time of one nesting check (`openfga`; checked before each request, a request in flight is bounded by `timeoutMs`) | `GROUP_DEPTH_DEADLINE_MS` | 10,000 ms | `openfga`, on a nesting `relate` | 503 `E_AUTHZ_BACKEND_UNAVAILABLE`, transient — the server is slow, not your data: retry —, nothing written and no `onRelationWrite` event (not the timeout subclass: the write did not happen). Until the third closure of R1 it was a 422 |
|
|
1790
|
+
| Direct holders of ONE group that is nested, renewed or sits below a nesting edge (`openfga`) | `GROUP_DEPTH_MAX_TUPLES` | 50,000 | `openfga` only; `database` reads the nesting rows alone (`subject_relation = 'member'`) and has no such bound | the downward `Read` of the check brings the group's holders too (the server cannot filter it by user type), so **a group with more than 50,000 direct holders cannot be nested into another group nor have that edge renewed, and neither can any group that contains it, directly or transitively** in `openfga`: always 422 (`details.budget: 'tuples'`), deterministic. Within a partition, whoever can add holders to a group can block every nesting that walks through it this way. An edge migrated by `authz:relations:reconcile` passes (the plan's mark skips the gate) but cannot be renewed afterwards. Keep large populations in leaf groups that are never nested, or stay on `database` for them |
|
|
1791
|
+
| Wait after a failed calibration of the nesting ceiling (`openfga`) | `GROUP_DEPTH_CALIBRATION_BACKOFF_MS` | 2,000 ms | `openfga`, per process, monotonic clock | nesting writes inside the wait get a new error of the calibration's kind (its `cause` is the calibration's error; a timeout becomes a non-timeout 503, no `indeterminate` event) without probing again; the first one after it calibrates again |
|
|
1792
|
+
| Minimum interval between two re-calibrations triggered by a read's 503 `E_AUTHZ_RESOLUTION_LIMIT` (`openfga`) | `GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS` | 60,000 ms | `openfga`, per process, monotonic clock | a 503 inside the interval does not forget the ceiling again; lowering the server's limit is seen without a restart, raising it needs one. Declared: any read's 503 `E_AUTHZ_RESOLUTION_LIMIT` triggers it — any tenant's, and not only a group chain's — and the new ceiling is whatever the probes measure then: a spurious `resolution_too_complex` under load would lower it for the whole process (fail-closed: nesting writes get the 500 «config > calibrated» or tighter 422s) until the next re-calibration (≥ 60 s later) or a restart |
|
|
1550
1793
|
| 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 |
|
|
1551
1794
|
| 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) |
|
|
1552
1795
|
| Checks per `batchCheck` | `FGA_MAX_BATCH_CHECK` | 50 | `openfga`, `authorizeMany` | the driver splits into batches of 50 (the SDK's cap), nothing to do |
|
|
1553
1796
|
| `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 |
|
|
1554
1797
|
| Objects per `checkMany` / `decideMany` | `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` |
|
|
1555
|
-
| `membersOf` candidates judged one by one when the object has a deny (`database`) | `RELATIONS_MEMBERS_DENY_CHECK_MAX` | 1,000 | the `database` relations driver (2.5.0) | 500 `E_AUTHZ_MEMBERS_DENY_LIMIT` before the first `check`, never a partial list; the whole loop also runs under one total deadline (`timeoutMs`), 503 `E_AUTHZ_BACKEND_TIMEOUT` past it. Without a deny row on the object there is no extra query and no bound |
|
|
1556
|
-
| Tuples a relations purge deletes in one
|
|
1798
|
+
| `membersOf` candidates judged one by one when the object has a deny (`database`) | `RELATIONS_MEMBERS_DENY_CHECK_MAX` | 1,000 | the `database` relations driver (2.5.0) | 500 `E_AUTHZ_MEMBERS_DENY_LIMIT` before the first `check`, never a partial list; the whole loop also runs under one total deadline (`timeoutMs`), 503 `E_AUTHZ_BACKEND_TIMEOUT` past it. Without a deny row on the object there is no extra query and no bound (whether the object has one is read in the same statement as the candidates) |
|
|
1799
|
+
| Tuples a relations purge deletes in one `Write` (atomic for the writer; see the torn-read row) | `RELATIONS_PURGE_WRITE_MAX` | 100 | the `openfga` relations driver (2.5.0) | the server's `--max-tuples-per-write` default (101 is a 400, measured on v1.19.0). Up to it a purge is one `Write`; past it, allows → the subject's group memberships in their own `Write` → denies. With more than 100 memberships a cut purge can grant until repeated (declared row, [Relations](#relations-rebac-24), «Deny on relations»). **Requires `--max-tuples-per-write` ≥ 100 on the server**: below it, the `Write` that passes the server's cap — not necessarily the first — is 500 `E_AUTHZ_CONFIG` naming the requirement (never a 503 that looks transient: repeated, the purge would fail the same way forever), marked as a partial write (`indeterminate: true`) if an earlier `Write` already deleted |
|
|
1800
|
+
| **Torn read**: a single `authorize`/`check` that runs while a write of the `openfga` drivers is in flight can read part of it — one relation of the model before the write, another after — and answer `true` where neither the state before nor the state after grants, past a deny (2.5.0-alpha.4, declared; invariant 2's declared exception) | — (no constant: a property of OpenFGA, which evaluates the branches of a `Check` as separate reads; it happens under `HIGHER_CONSISTENCY` too) | measured on OpenFGA v1.19 + postgres: **1.2–7.1 %** of the checks concurrent with a role-tree change (`scopes.moved`/`attached`/`detached` with roles; up to 7.1 % on a descendant of the moved scope), 1.4–1.8 % with a purge or a membership swap in one `Write`, 0.02 % across two consecutive `Write`s of the relations reconcile, ~10⁻⁵ for "two writes, any two (tree or facts), spanned by one check" on the scope they touch; the two contiguous `Write`s of F4 (the pre-sweep, then the tree edge) measured with the published model (c2r) and the real driver: **~1.8·10⁻³ on a DESCENDANT of the moved scope when it LEAVES the owner** (180 in 100,000; 49 in 60,000 in a second run, all of them leaving — the deny is evaluated one hop later there) and **~10⁻⁵ on the moved scope itself** (1 in 100,000) — OpenFGA v1.19 + postgres, `HIGHER_CONSISTENCY`, 1 writer + 7 readers, F4 audit 2026-10-19 (the 1 in 50,000 published before was a different model on the test harness) | `openfga` (roles and relations); **`database` — roles AND relations — reads the grants and denies of every decision in ONE SQL statement since the rotation 2 closure** — until then `authorize` read them in two, and a `deny` followed by a `grant` (or a transaction with both) committed between them answered `true` once, as did `listScopes`, `effectivePermissions`, `authorizedScopes` and the delegation policy across an offboarding (the manager composes them through `readSnapshot` now); and the relations driver's `membersOf` read the candidates and then, in a second statement, whether the object had any live deny — an offboarding (`unrelate`, then `removeDeny`) between the two listed a subject that was a member in no real state (the deny check rides in the candidates' statement now; `check`, `checkMany`, `decideMany` and `listObjects` already were one statement). With a deny on the object, `membersOf` judges each candidate with its own `check` — each one the answer of a real state, like each position of `authorizeMany`. What `database` still has is the **chain-vs-facts** shape: your `resolveChain` is read BEFORE the snapshot, so a `scopes.moved` followed by a fact change (e.g. a unit moved out of org A, then the `removeDeny` of a deny in A for a holder with a role in A) can be read as old chain + new facts and grant once in the unit, where no real state granted — it depends on your resolver, not on the package (not measured; deterministic with a forced interleaving). **Recipe**: when a tree change and a fact change go together, write the facts FIRST and move the scope after — then every answer is the one of a real state (the intermediate one, new facts on the old tree, is yours to choose knowingly). The catalog memo is outside the snapshot too — `authorize` and `listScopes` join the role→permission links inside the statement, but the composed decisions take them from the memo, contrasted with the shared version before each question —, so a catalog write and a fact write racing one composed read have the same shape. **It is caused by whoever writes — in a role-tree change, whoever moves scopes** | one answer of one check, **never persisted**: the next check sees a real state. What the package guarantees is about **prefixes** — a write CUT halfway never leaves a state that grants what neither end granted (the order properties of the surface guard: purges, reconcile, `moved` since F4) —, not about readers; "atomic" in this README means atomic for the writer. A PEP that caches a `true` makes it persistent: do not cache `authorize`. Removing it needs a model or a mechanism change (planned for evaluation in 2.6) |
|
|
1801
|
+
| **Races between writers of the `openfga` role driver (local roles)** — declared limit `openfga.races.settle-window` (experimental `openfga`, closes in 2.6) | — | persistent fail-open: 0 in ~38,800 interleavings of the in-memory double since the settle; against a real server not measured (M1); transient window: from a writer's `Write` to its own settle's correction | `openfga`, `hierarchy: 'facts'`, local roles only; `database` has none (it decides on every question) | the five interleavings found by the F4 audit (`updateScopedRole`/`projectCatalogRole` ∥ `scopes.moved`; a `grant` that resolved the OLD chain; a `grant` during the relay lag of a unit ENTERING the owner; two INLINE `moved` of a parent and its child) no longer leave a binding edge outside the owner in the double: every writer of a local-role `scope#binding` re-reads with `HIGHER_CONSISTENCY` and corrects to a fixed point (the settle, 2.5.0-alpha.4). What remains declared is the window before that correction — a check in it can grant outside the owner, across tenants and past a deny of the owner — and the closing measurement against a real server. **Recipe**: serialise catalog writes with scope moves, keep the outbox, and run `authz:reconcile --to=openfga` after reorganising the tree. **Detector**: a non-zero exit of `authz:reconcile --to=openfga --dry-run` on a cron (`drift.roleVisibility`). **Closes in 2.6**: M1 (zero persistent fail-opens against a real server, with a positive control that turns red) is the exit criterion of experimental. Case: «límite declarado openfga.races.settle-window: se voltea en 2.6». |
|
|
1802
|
+
| **Cost of a `moved`/`attached` of an existing scope with local roles (`openfga`)** — declared limit `openfga.roles.moved-cost` (experimental `openfga`, closes in 2.6) | — | 2 `Read`s per local role of the catalog per `moved`/`attached` (14 → 214 `Read`s with 100 local roles without a single binding, in the double; 2,008 with 1,000 in the F4 audit) | `openfga`, `hierarchy: 'facts'`; `database` has none | a tenant that defines many local roles slows the relay — one global queue — and widens the lag for everyone; zero `Read`s without local roles. **Recipe**: keep the number of local roles bounded and reorganise trees off-peak. **Detector**: the relay's lag: `authz:scopes:relay` reports `remaining: true` pass after pass. **Closes in 2.6**: read the bindings per scope of the moved subtree (`scope:X#binding`) instead of per local role of the catalog. Case: «límite declarado openfga.roles.moved-cost: se voltea en 2.6». |
|
|
1803
|
+
| **Two concurrent `scopes.moved` of the SAME scope applied inline (`acceptScopeDriftRisk: true`, no outbox)** — declared limit `openfga.tree.inline-same-node` (experimental `openfga`, closes in 2.6) | — | deterministic in the double (one preemption between the two movers); frequency against a real server not measured | `openfga`, `hierarchy: 'facts'` without `scopes.outbox`; `database` has none | the store keeps the parent of the writer that landed last, your tree the one that committed last: the store's tree diverges from yours and persists — a GLOBAL role skips the deny of the old tenant and an organisation role of the old tenant is inherited inside the new one. **Recipe**: use `scopes.outbox` (the relay applies your fresh parent), or lock the scope's row (`SELECT … FOR UPDATE`) before `scopes.moved`. **Detector**: a non-zero exit of `authz:reconcile --to=openfga --dry-run` (its tree phase counts the divergent edge). **Closes in 2.6**: the `drift.treeMismatch` report and the `FOR UPDATE` recipe measured on PostgreSQL and MySQL. Case: «límite declarado openfga.tree.inline-same-node: se voltea en 2.6». |
|
|
1804
|
+
| **The store's tree diverging from yours WITH the outbox (two movers of the same scope, commit order ≠ relay order)** — declared limit `openfga.tree.outbox` (experimental `openfga`, closes in 2.6) | — | closed in code since 2.5.0-alpha.4 and in the in-memory double — the relay applies the consumer's FRESH parent; the closing measurement (S1/S5/R at 0 disagreements on PostgreSQL and MySQL, the `relay.trust-enqueued-parent` mutant at 1000/1000) not measured; before the fix, 1000/1000 forced and 8.5–11.5 % with natural concurrency | `openfga`, `hierarchy: 'facts'` with `scopes.outbox`; SQLite does not suffer it (measured); `database` has none | if the fix does not hold under real concurrency, the same persistent divergence as the inline row, with every entry `applied`. **Recipe**: lock the scope's row (`SELECT … FOR UPDATE`) before `scopes.moved`, and give the relay a resolver without a TTL cache. **Detector**: a non-zero exit of `authz:reconcile --to=openfga --dry-run` on a cron. **Closes in 2.6**: S1/S5/R measured on PostgreSQL and MySQL, plus a property "everything through the outbox" (C1-6). Case: «límite declarado openfga.tree.outbox: se voltea en 2.6». |
|
|
1805
|
+
| **A cached resolver in the relay and two moves of the same scope within its TTL** — declared limit `openfga.relay.stale-resolver` (experimental `openfga`, closes in 2.6) | — | deterministic with one stale cache entry; frequency not measured | `openfga` with `scopes.outbox` (the fresh-parent relay is `hierarchyFacts` only); `database` has none | the relay applies the cached parent to both entries — the second as `consumer-parent`, "backed" by the first — and the store keeps the old parent while your tree has the new one, with nothing left in the queue to repair it. **Recipe**: give the relay's `scopes.resolveChain` no cache, or a TTL shorter than the time between two moves of one scope. **Detector**: `authz:scopes:relay` exits 1 with its `consumer-parent` line (`authz:scopes:requeue --apply` exits 0 with its own line). **Closes in 2.6**: the relay stops accepting a parent backed only by an entry of the same pass when the resolver may be cached. Case: «límite declarado openfga.relay.stale-resolver: se voltea en 2.6». |
|
|
1806
|
+
| **A scope restored with the same uuid after a `detached`, relayed by a resolver that answers `null`** — declared limit `openfga.relay.restored-without-edge` (experimental `openfga`, closes in 2.6) | — | deterministic; needs a blind resolver (filtered by tenant) and the reuse of a detached uuid; frequency not measured | `openfga` with `scopes.outbox`; `database` has none | the `attached` of the restored scope is applied as `superseded` with no edge: the scope exists in your tree and has no parent in the store, so it does not reach the root there (`rooted`) and denies everything on it, direct grants included (fail-closed) — while `database` grants. **Recipe**: do not reuse a detached scope's uuid, or notify `scopes.attached` again once the relay's resolver sees it. **Detector**: the `superseded` line of `authz:scopes:relay` (exit 0), and `authz:reconcile --to=openfga --dry-run` (the missing edge). **Closes in 2.6**: a `superseded` backed only by an EARLIER `detached` of the same pass is refused instead. Case: «límite declarado openfga.relay.restored-without-edge: se voltea en 2.6». |
|
|
1807
|
+
| **An orphan `assignee` after a `purgeRole` that ran out of rounds, resurrected by reusing the role's uuid** — declared limit `openfga.roles.purge-orphan-reuse` (experimental `openfga`, closes in 2.6) | — | needs a failed purge (500 `E_AUTHZ_PURGE_INCOMPLETE`) with a concurrent writer, a fixed `CatalogRoleSpec.uuid` and a re-sync; frequency not measured | `openfga` only; `database` purges in one transaction | the retry of the purge does not see the concurrent writer's `assignee` (it enumerates by the `role_binding#role` anchor, already gone); if the role is declared again with the same uuid, that holder is a member again without any grant, and the first `grant` of the role on that scope makes it grant. **Recipe**: before declaring a role again with the same uuid, run `authz:reconcile --to=openfga --dry-run` and, if it reports surplus facts, `--prune`. **Detector**: `authz:reconcile --to=openfga --dry-run` counts it as `extra-fact` and exits non-zero. **Closes in 2.6**: membership requires the `role_binding#role` anchor, and a `grant` that creates a binding's structure sweeps earlier `assignee`s. Case: «límite declarado openfga.roles.purge-orphan-reuse: se voltea en 2.6». |
|
|
1808
|
+
| **A `scope#binding` edge without `role_binding#role` after a failed purge racing a move (ORPHAN-edge)** — declared limit `openfga.roles.orphan-edge` (experimental `openfga`, closes in 2.6) | — | 2 in 34,420 fault∥race interleavings of the double; it grants nothing | `openfga` only; `database` purges in one transaction | garbage that fails closed: an edge of a purged role that the purge's retry does not see. **Recipe**: `authz:reconcile --to=openfga --prune`. **Detector**: `authz:reconcile --to=openfga --dry-run` counts it as `extra-fact` and exits non-zero. **Closes in 2.6**: the purge enumerates the role's edges by `scope#binding` too, not only by the `role_binding#role` anchor. Case: «límite declarado openfga.roles.orphan-edge: se voltea en 2.6». |
|
|
1809
|
+
| **Two concurrent projections of the same role (`updateScopedRole`/`projectCatalogRole`): a removed permission's `permits_*` survives** — declared limit `openfga.catalog.permits-twin` (experimental `openfga`, closes in 2.6) | — | deterministic in the double (one preemption before the projection's read of the store); frequency against a real server not measured | `openfga`, `hierarchy: 'facts'`; `database` has none (the catalog is its own table) | the projection that read the OLD catalog writes after the one that read the new one: the role keeps granting a permission the catalog no longer gives it, persistently — within the tenant that owns the role on this path (`updateScopedRole` is local roles only); the sync's projection is the same race on any role, global ones included, and is its own row (`openfga.catalog.permits-sync`). **Recipe**: serialise catalog writes of one role, and run `authz:reconcile --to=openfga` after a burst of catalog changes. **Detector**: a non-zero exit of `authz:reconcile --to=openfga --dry-run` (its catalog phase counts the surplus `permits_*`). **Closes in 2.6**: after its `Write` the projection re-reads the catalog version fresh and re-projects to a fixed point. Case: «límite declarado openfga.catalog.permits-twin: se voltea en 2.6». |
|
|
1810
|
+
| **The projection of `authz:catalog:sync` racing another catalog write: a removed permission's `permits_*` comes back** — declared limit `openfga.catalog.permits-sync` (experimental `openfga`, closes in 2.6) | — | deterministic in the double (one preemption before the sync's projection reads the store); frequency not measured — the window grows with the catalog (one paged `Read` of `role:` per holder type after the commit) | `openfga`, `hierarchy: 'facts'`; `database` has none (the catalog is its own table) | the sync takes its snapshot inside its transaction and projects after the commit without re-reading the version; if another write removes a permission in between (an `updateScopedRole`, or a second sync with another spec — a pod of the old release during a rolling deploy), the stale projection writes the `permits_*` back: the role keeps granting it, persistently, and with a GLOBAL role in every tenant where it is assigned; a real pass of `authz:reconcile --to=openfga` (the recipe itself) is the same race — it takes its catalog snapshot before it dumps the store, the freeze does not stop a sync, and a sync that removes a permission in between is reverted by the pass and reported as a write (it looks like a repair). **Recipe**: do not run two `authz:catalog:sync` of different specs at the same time (one sync job per deploy), avoid catalog changes during a sync, and run `authz:reconcile --to=openfga` after a deploy has finished — never a real pass while a sync can run; check with `--dry-run` afterwards. **Detector**: a non-zero exit of `authz:reconcile --to=openfga --dry-run` (its catalog phase counts the surplus `permits_*`). **Closes in 2.6**: the sync's projection re-reads the catalog version after its `Write` and re-projects to a fixed point, like the twin's. Case: «límite declarado openfga.catalog.permits-sync: se voltea en 2.6». |
|
|
1811
|
+
| **`openfga:provision` publishes a model that narrows the reach of a live deny (a relation withdrawn from an `includes`)** — declared limit `openfga.provision.deny-reach` (experimental `openfga`, closes in 2.6) | — | deterministic (model and interpreter, no server): with `editor: { includes: ['owner'] }` and a live `denied_editor` on a direct owner, the owner is denied; narrowed to `includes: []`, `openfga:provision` publishes without refusing and the same tuples grant `owner`; how often a config narrows under live denies is not measured | `openfga` (`openfga:provision`); `database` refuses the same change at `saveRelationsConfig` since sublot (8) (500 `E_AUTHZ_DENY_REACH_REFUSED`, forced way out by name: `allowDenyReachShrink: ['<type>#<relation>']`) | the provision guard only refuses a model that DROPS a `denied_*` relation (`--allow-drop-deny`); narrowing an `includes` keeps every `denied_*` but shrinks what the deny cuts (the up-closure of D5), so a deny that blocked a holder stops blocking them, persistently, once the new model is pinned. **Recipe**: before narrowing or withdrawing a relation of a type with live denies, list them (`authz:relations:reconcile --dry-run` over the partitions, or `listDenies`) and re-issue each deny on the relation that still covers the access you mean to cut. **Detector**: `authz:relations:reconcile --dry-run` (sublot (8)), which **requires a persisted baseline, saved on every provision** (`saveRelationsConfig` with the config you provision; without it the narrowing is not seen — the pass only says «alcance de denies: SIN línea base y N deny(s) vivos sin juzgar», exit 0): it compares the persisted config with the one the process serves and lists, per partition and in both stores, every live deny whose reach shrank («deny con alcance ENCOGIDO») or that was orphaned («deny HUÉRFANO»), and a baseline narrower than the served config («línea base RANCIA»), with its recipe, exit ≠ 0 — run it BEFORE saving the narrowed config: once saved, the baseline is the narrowed one; the `database` guard of `saveRelationsConfig` reads `authz_relations`, never the store. **Closes in 2.6**: the provision guard with the property «for every live deny, the new reach ⊇ the old reach» and an explicit forced way out (decision (8)). Case: «límite declarado openfga.provision.deny-reach: se voltea en 2.6». |
|
|
1812
|
+
| **A relations config that shrinks the reach of a live deny** — the `database` half of decision (8) (2.5.0-alpha.4, sublot (8)) | `allowDenyReachShrink` (option of `saveRelationsConfig`: the forced way out, by name) and `establishBaseline` (the adoption) | refused: for every live deny, new reach ⊇ old reach | `database`: `saveRelationsConfig`, inside its catalog transaction, over every `denied_*` row of `authz_relations`; detectors: `checkRelationsConfigDenyReach` (one driver) and `authz:relations:reconcile --dry-run` (two drivers) | 500 `E_AUTHZ_DENY_REACH_REFUSED` before anything is written (neither the row nor the catalog version), with one finding per type and relation and its recipe: «deny con alcance ENCOGIDO» — an `includes` narrowed, directly or transitively (the reach of a deny on `X` is `down*(X)`, what it cuts by the path cut of D5: with `editor: { includes: ['owner'] }` a deny on `editor` cuts a direct `owner`; narrowed to `includes: []` the same rows grant `owner`) —, «deny HUÉRFANO» — the relation or the type withdrawn (nobody reads the deny, no `removeDeny` reaches it, and it comes back if the relation is declared again), judged also when the baseline already lacked it — or «deny SIN línea base» — no usable baseline (no row, or a row whose `objectTypes[].relations[].{name, includes}` cannot be typed: it is read all-or-nothing, never filtered) while denies live, so whether the new config shrinks them cannot be judged. Widening is never refused, and the first row is created freely while no deny lives. **Two named ways out, apart**: `{ establishBaseline: true }` lifts only «deny SIN línea base» and only when there is no usable baseline (inert otherwise; orphans are still judged) — adopt once, with the spec the fleet serves today, in a release that does not change `relations.config`; `{ allowDenyReachShrink: ['note#editor'] }` exempts only the findings it names (an unnamed one is still refused; any other form, `true` included, is 500 `E_AUTHZ_CONFIG`; **a name that exempts no `shrunk`/orphan finding of that very save is also 500 `E_AUTHZ_CONFIG`**, nothing written, naming the surplus key — the list switches itself off, so a list left in the pipeline cannot silently exempt a later shrink of that relation: remove it after the forced save). **Persisting the config is a mandatory release step if you use deny**: save (the guard) → `checkRelationsConfigDenyReach(served)` (CI, and a cron; never a hard failure in `ready`) → serve. **What it does not cover**: the `database` driver evaluates with the config of the process that SERVES, not the persisted one, so a deploy that serves a narrowed config without saving it is not refused — `checkRelationsConfigDenyReach` catches it (and `authz:relations:reconcile --dry-run`, which needs two relations drivers: it cannot run in a `database`-only deploy); a stale baseline, the deploy window and re-declaring after forcing are declared below. Save with the same `connection` as the `database` relations driver (the guard reads `authz_relations` through the connection it is given). Withdrawing a type's `deny` opt-in does not change the reach in `database` (its reads honour the row) and is not judged here (in `openfga` the provision guard and `--allow-drop-deny` do). A withdrawn TYPE is not enumerated by `--dry-run`, so its denies are not seen there; the guard and `checkRelationsConfigDenyReach` read the whole table. |
|
|
1813
|
+
| **A stale baseline hides a narrowing (S-B)** — declared yellow of `database` (sublot (8), closes in 2.6) | — | deterministic: a row saved by release N-1 with `editor: { includes: [] }`, release N serving `includes: ['owner']` without saving it and a deny on `editor` written under it, release N+1 narrowing back and saving: saved, and the owner grants again | `database`: `saveRelationsConfig` (the guard compares with the row, not with what the fleet served) | when the persisted row is narrower than what the fleet served when the deny was written, a narrowing back to the row looks like no change and is saved: the deny stops cutting, persistently. **Recipe**: persisting the config is a mandatory release step if you use deny — save the config every release serves, before it serves. **Detector**: `checkRelationsConfigDenyReach(served)` run with the wider served config reports «línea base RANCIA» (`ok: false`) — in CI after the save and before serving, and in a cron; `authz:relations:reconcile --dry-run` prints the same line (exit 1) when there are two relations drivers. **In the order of this case, the CI step does not see it**: release N's CI runs before the deny exists, and N+1's runs after the narrowing is saved, when the row and the served config already agree; **what sees it is a cron that runs while release N serves, after the deny is written** — so the cron must run more often than a release lives. **Closes in 2.6**: checking at deny write (W) or recording the reach in the deny. Case: «límite declarado database (8) · S-B línea base RANCIA: la guarda no la ve y la delata checkRelationsConfigDenyReach; se voltea en 2.6». |
|
|
1814
|
+
| **The rolling-deploy window (S-C)** — declared yellow of `database` (sublot (8), closes in 2.6) | — | deterministic: the narrowed config saved with no live deny, then an old pod (wider config) writes a deny on `editor` over a direct owner: once the narrowed config serves, the owner grants | `database`: relation writes do not take the catalog lock | the window runs from the save of the narrowed config to the end of the deploy (the last old pod): a deny an old pod writes in it under the wider config cuts less once the narrowed config serves, and `shrunk` is not detected afterwards (the row and the served config agree). **Recipe**: do not write denies on the relations you are narrowing while old pods remain; after the deploy, re-issue the denies of those relations on the relation that still covers the access (`listDenies` enumerates them). **Detector**: during the window, `checkRelationsConfigDenyReach` run with an old pod's config reports «línea base RANCIA»; none after it. **Closes in 2.6**: checking at deny write (W) or recording the reach in the deny. Case: «límite declarado database (8) · S-C ventana del despliegue: un deny que escribe un pod viejo tras el guardado no se ve como encogido; se voltea en 2.6». |
|
|
1815
|
+
| **Forcing a withdrawal, then declaring it again, resurrects grants (S-E)** — declared yellow of `database` (sublot (8), closes in 2.6) | — | deterministic: a deny on `editor` with `editor ⊇ owner` over a direct owner; withdrawing `owner` forced with `allowDenyReachShrink: ['note#editor']`, then declaring `owner` again without `editor` including it: saved, and the owner grants | `database`: `saveRelationsConfig` | forcing a shrink moves the baseline: against the new row, declaring the withdrawn relation again without its old `includes` shrinks nothing, so the grants the deny used to cut grant again. **Recipe**: before forcing, remove or re-issue the denies the finding names (`removeDeny`, or `deny` on a relation that stays); never declare again a relation withdrawn by force without restoring the `includes` that put it under the deny. **Detector**: none automatic (the row agrees with what is served) — review every forced save; keeping the forcing list in the re-declaring save is 500 `E_AUTHZ_CONFIG` (the name exempts nothing there), but a save without it goes through. **Closes in 2.6** only by recording the reach in the deny: checking at deny write (W) does NOT close it — the deny is written while the row and the served config agree, and neither the forced save nor the re-declaration writes a deny. Case: «límite declarado database (8) · S-E forzar la retirada y re-declarar resucita los grants que el deny cortaba; se voltea en 2.6». |
|
|
1557
1816
|
| `openfga:provision --store-id` sweep for live denies of a dropped relation | — (no cap: streamed page by page) | — | `openfga:provision --store-id` (2.5.0-alpha.3), only when some model of the store declared a `denied_*` the new one does not | it reads the WHOLE store, page by page, keeping only a count and three examples (memory does not grow with the store, time does: O(store) per run); a server that does not advance (a repeated continuation token) or a page without `tuples` is exit 1 (`provision NEGADO:`) without writing the model — "zero live denies" is never assumed from a sweep that did not finish. Until closure 2 of sublot D2 the sweep stopped at 1,000,000 tuples, and since the store's model history keeps a retired opt-in forever, a bigger store could never be provisioned again |
|
|
1558
1817
|
| Models of the store `openfga:provision --store-id` reads to judge the store's state (every published model is one) | `PROVISION_MODEL_HISTORY_MAX` | 10,000 | `openfga:provision --store-id` (2.5.0-alpha.3) | exit 1 (`provision NEGADO:`) without writing the model — never judged on a truncated history |
|
|
1559
1818
|
| Relation name in a type with `deny` | `DENIABLE_RELATION_NAME_MAX` | 43 | `defineRelationsConfig` and the `openfga` model generator (2.5.0) | 422 `E_AUTHZ_RELATION_CONFIG` at definition: `denied_<rel>` has to fit FGA's 50. A type without `deny` keeps 50 |
|
|
1560
1819
|
| `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 |
|
|
1561
1820
|
| `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** |
|
|
1821
|
+
| Relation listings — `listObjects`, `listSubjects`, `listDenies`, `membersOf` — do not page (2.5.0-alpha.4, «before the RC» lot · 7.4) | — | no `page` parameter, no `cursor` in the answer | the `RelationsManager`, both relations drivers and the `/testing` double | the **whole** set, or `truncated: true` where the backend cuts (`listObjects` on `openfga`); never a cursor. A page argument that reaches them anyway (plain JS, `as any`) — anything but `undefined`, `null` and `{}` included — is 500 `E_AUTHZ_UNSUPPORTED` naming the operation, with zero backend calls. Paginated listings come in 2.6 as separate `*Page` methods behind their own capabilities |
|
|
1822
|
+
| Page size of `enumerateRelations(Many)` (`database`) | `RELATIONS_ENUMERATE_DEFAULT_LIMIT` / `RELATIONS_ENUMERATE_MAX_LIMIT` | 100 by default / 1,000 at most | the `database` relations driver; the `RelationsManager` and `openfga` validate the shape too (2.5.0-alpha.4 · 7.4) | a `limit` above 1,000 is **clipped** (the page carries a cursor: nothing is lost); a `limit` that is not an integer ≥ 1 (`NaN`, `0`, negative, decimal, a string) is 422 `E_AUTHZ_INVALID_PAGE` with zero statements (until 7.4 `NaN` produced a query without `LIMIT`); an `after` that is not the canonical uuid `database` emits is 422 `E_AUTHZ_INVALID_PAGE` (until 7.4: a 503 on PostgreSQL, a silent end on MySQL/SQLite). `openfga` (and the double) ignore a valid `limit` — one page, O(store), no cursor — and answer an `after` with 500 `E_AUTHZ_UNSUPPORTED`: they never emitted one. Declared: a cursor is not bound to its partition (a cursor of A reused on B skips rows) — 2.6 |
|
|
1562
1823
|
| 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 |
|
|
1563
1824
|
| 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) |
|
|
1564
1825
|
| Scopes a `pending()` pass reads per round trip | `DEFAULT_RELAY_BATCH` | 100 | `authz:scopes:relay` (`--batch-size`) | the pass keeps paging |
|
|
@@ -1568,9 +1829,22 @@ Every bound the package enforces or measured, in one place, each with the consta
|
|
|
1568
1829
|
| Parked outbox entries the relay can census per pass | `PARKED_CENSUS_MAX` | 50,000 | the manager, every relay pass (read in full, whatever `--limit`) | 500 `E_AUTHZ_CONFIG` before anything is applied — the relay of **every** tenant stops, loudly, never a blind plug. Measured cost of the census under the lease: 1,000 parked ⇒ 16 ms, 10,000 ⇒ 69 ms, 49,000 ⇒ 199 ms per pass. The way out is below (*When the parked set grows*) |
|
|
1569
1830
|
| Answer size of `authorizedScopes` / `expandExcludedSubtrees` | `maxScopes` | 1,000 | the manager | 422 `E_AUTHZ_TOO_MANY_SCOPES`, never a partial list (per call it can only be lowered) |
|
|
1570
1831
|
| Nodes one `descendantsOf` call may return | `maxDescendants` | 10,000 | the manager (`maxNodes` handed to your resolver) | 422 `E_AUTHZ_TOO_MANY_SCOPES`; both bounds are capped at `MAX_SCOPE_BOUND` (10,000,000) |
|
|
1571
|
-
| Local roles a `prune-orphans` pass may read | `maxLocalRoles` | 10,000 | `pruneOrphanRoles` | 500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES`, never a partial list |
|
|
1832
|
+
| Local roles a `prune-orphans` pass may read | `maxLocalRoles` | 10,000 | `pruneOrphanRoles({ maxLocalRoles })`, `authz:catalog:prune-orphans --max-local-roles` (rotation 2 closure; until then not settable) | 500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES`, never a partial list |
|
|
1833
|
+
| **Two deployments on different databases of the same MySQL server still share the relay lease during 2.5.x (R2-01)** — declared yellow of shared code (rotation 2, closes in 2.6) | `SCOPE_OUTBOX_TAKES_UNSCOPED_LOCK` | deterministic: two `sqlScopeOutbox()` with the default connection name and the stub's table, on two databases of one server | `sqlScopeOutbox` on MySQL (`GET_LOCK` is per server); PostgreSQL's advisory lock is per database and never had it; SQLite's lease is per process | the lease name carries the database since the rotation 2 closure (`DATABASE()` read by the session that holds it), but for a rolling deploy from 2.5.0-alpha.4 every 2.5.x pass also takes the old name without it, **first** — so a neighbour's pass (or a session holding that name) still leaves yours `busy`, which exits 0 with a line that says so. **Recipe**: give each deployment its own outbox table name or connection name (both are in the old name too), or run them on separate servers. **Detector**: `authz:scopes:relay --max-age=<ms>` exits ≠ 0 when the queue stops advancing; the `busy` line repeating on every pass. **Closes in 2.6**: the old name is retired (the suite turns red on the version bump until it is). Case: «límite declarado rotación 2 · R2-01 MySQL: el nombre de transición sigue excluyendo a dos bases del mismo servidor; se voltea en 2.6». |
|
|
1834
|
+
| **`E_AUTHZ_UNKNOWN_SCOPE` vs `E_AUTHZ_NOT_WITHIN` tells whether a scope exists in another tenant (R2-03)** — declared yellow of shared code (rotation 2, closes in 2.6) | — | not measured (deterministic: one write with a foreign uuid and one with a uuid that does not exist answer different codes) | the manager, the nine writes with `within` | the codes do not change (decision of the owner): a caller that knows or guesses a uuid learns that it exists *somewhere else*, not where — the message no longer names the chain, and since the rotation 2 closure 2 `scopes.moved`/`scopes.attached` decide containment before the cycle check and `scopes.maxDepth` (until then the cycle and depth answers told *which* foreign scope another foreign scope hangs from, and how deep: closed, not declared). The same holds for **roles** by uuid in `updateScopedRole`/`deleteScopedRole`: `E_AUTHZ_UNKNOWN_ROLE` (no such role), `E_AUTHZ_NOT_WITHIN` (a live role of another tenant) and `E_AUTHZ_UNKNOWN_SCOPE` (a dormant one) tell them apart; none of the three messages names the owner. **Recipe**: map both codes to the same answer at your HTTP boundary — a uniform 404 for "not yours or not there". **Detector**: count `E_AUTHZ_NOT_WITHIN` per actor in your logs — each one is a write aimed at a scope that exists outside the caller's tenant. **Closes in 2.6**: deciding containment before existence. Case: «límite declarado rotación 2 · R2-03 oráculo de existencia: UNKNOWN_SCOPE y NOT_WITHIN se distinguen por el code; se voltea en 2.6». |
|
|
1835
|
+
| **The third mass-purge trigger does not cover a single victim (RC2-C)** — declared yellow of shared code (rotation 2 closure 2, closes in 2.6) | `allowMassPurge` (`--allow-mass-purge`) | deterministic: a resolver with tenant A's context leaked and ONE other owner with local roles that still grant ⇒ no trigger fires | the manager, `pruneOrphanRoles({ force: true })` / `authz:catalog:prune-orphans --force` | the third trigger asks for orphans of **more than one** owner (owner's decision): with a leaked tenant context and one victim, nothing distinguishes it from a tenant that left, `--force` purges that owner's roles and its users lose live permissions. **Recipe**: read the `--dry-run` before every `--force` (it lists those roles apart, `stillGranting: true`), and run the pass with a resolver that has no tenant context (a platform job, not a request). **Detector**: the dry run's `stillGranting: true` rows; `role_purged` events of an owner your tree still has. **Closes in 2.6** (or earlier if a consumer runs platform jobs with tenant context: question open). Case: «límite declarado rotación 2 · RC2-C una sola víctima: el resolutor con el contexto de A colado y UN owner ajeno ⇒ ningún disparador y --force purga un rol que concede; se voltea si se cierra». |
|
|
1836
|
+
| **Without `within`, a delegation 422 can name another tenant's role (RC2R-1/RC2R-2, lineage L13)** — declared yellow of shared code (rotation 2 re-attack, closes in 2.6) | `requireWithin` | deterministic: `updateScopedRole`/`deleteScopedRole(adminOfA, <uuid of a role of B>)` with no `within` | the manager, delegation API | `E_AUTHZ_ROLE_LEVEL_ABOVE_OWNER` names B's owner and `E_AUTHZ_RANK_EXCEEDED` B's slug and rank; set `requireWithin` (multi-tenant needs it); 2.6 routes every tenant-facing message through one sanitiser |
|
|
1837
|
+
| **Any tenant's catalog write makes every process reload the WHOLE catalog (R2-06)** — declared yellow of shared code (rotation 2, closes in 2.6) | — | not measured | the catalog memo (`CatalogCache`: `authz_permissions`, `authz_roles`, `authz_role_permissions` read entire on a version change) | a tenant admin looping `updateScopedRole` (renaming is a real change ⇒ a version bump) makes the next question of every process, for every tenant, reload the full catalog — with a large local catalog, latency of `authorize` for everyone. **Recipe**: rate-limit the delegation API per owner in your app; `catalogRevalidate: { everyMs }` caps reloads to one per window per process, at the price of that window. **Detector**: the rate of `onCatalogWrite` events per owner (or of `authz_catalog_version` increments). **Closes in 2.6**: incremental reload by version, or a per-owner rate in the package. Case: «límite declarado rotación 2 · R2-06 recarga entera: una escritura de catálogo de un tenant recarga el catálogo de todos; se voltea en 2.6». |
|
|
1572
1838
|
| Ancestors `hierarchicalScopeResolver` walks | `maxDepth` (resolver option) | 64 in the README example | your `resolveChain` | 500 `E_AUTHZ_SCOPE_TOO_DEEP`, never a truncated chain |
|
|
1573
1839
|
|
|
1840
|
+
**The rows marked *declared limit* are the open limits of the experimental `openfga` driver (2.5.x).** Each one has an
|
|
1841
|
+
id, its figure — or "not measured" —, a recipe, a detector, the version that closes it (2.6) and a characterisation case
|
|
1842
|
+
in the suite titled «límite declarado <id>: se voltea en 2.6» that reproduces today's behaviour without a server: if
|
|
1843
|
+
someone fixes one without rewriting its row, the case turns red. The versioned list is
|
|
1844
|
+
`tests/fixtures/openfga_declared_limits.json`, and a case keeps the list, the cases and these rows equal. The torn read
|
|
1845
|
+
above is declared too (both `openfga` drivers) and is not repeated in that list. The detector most rows share is the
|
|
1846
|
+
maintenance pass on a cron: alert on a **non-zero exit** of `authz:reconcile --to=openfga --dry-run`.
|
|
1847
|
+
|
|
1574
1848
|
**`scopes.maxDepth` — the depth gate (2.4.0-alpha.5).** Declare it in `config.scopes` and `scopes.attached`/`scopes.moved` refuse, with **422 `E_AUTHZ_SCOPE_DEPTH_LIMIT`**, any child that would end up more than `maxDepth` `parent` hops from `app` — the parent's chain plus one — **before writing or enqueueing** (zero driver calls, zero outbox rows) and at zero extra I/O: the parent's chain is already resolved for the cycle check. Without the option there is no gate: a tree of any depth stays legal, and the `database` driver pays nothing. It lives in the config and not in `capabilities()` because a capability is a boolean by doctrine (`capabilities()` normalises with `=== true`); and it is the same knob `authz:reconcile` uses to report chains above the ceiling as drift (`drift.tooDeep`, exit ≠ 0 — a scope legal in `database` and a 503 in `openfga` belongs in CI, not in the first tenant that reaches it; without the option the verifier uses `FACTS_MAX_RESOLVE_DEPTH`). Set it to `22` for the `facts` model: then every question stays within the measured ceiling whichever ancestor carries the binding. **How to size it: `maxDepth` counts `parent` hops from the child to `app`, so a tree of N levels below `app` needs `scopes.maxDepth = N`** — not N + 1: the parent's chain includes `app`, but a child at exactly `maxDepth` hops *enters* (`chain.length > maxDepth` is the refusal). Pinned by an executable case (`scope_depth.spec.ts`): a tree of exactly three levels (organization → unit → sub-unit), `maxDepth: 2` refuses the third level with 422, `maxDepth: 3` lets it in. The 422's `message` is for the tenant (depth, `scopes.maxDepth`, nothing written or enqueued); the reason the ceiling exists goes to `error.details` (2.4.0-alpha.8). **The gate is **partial**, and sold as such**: it looks at the *destination* of the child, not at the height of the subtree a `moved` drags along — a subtree of height *h* moved under a parent at depth *d* leaves its leaves at *d + h + 1*, and the gate does not see them; measuring that would need `descendantsOf` on the write path, which is forbidden. The reconcile report is what catches those afterwards. Why a gate at all: past the ceiling the model answers 503 for the whole subtree (a DoS within reach of whoever can nest sub-scopes), and the cost of each relayed `attached` grows with the depth of its neighbour (measured ×5.8 from depth 1 to 34) on the queue every tenant shares.
|
|
1575
1849
|
|
|
1576
1850
|
### Operational notes for the SQL engines
|
|
@@ -2136,13 +2410,128 @@ both drivers, the count reaches 0 and stays 0, and only then the model is publis
|
|
|
2136
2410
|
Never run `authz:relations:reconcile` from a process < 2.5.0-alpha.3 once there are denies in a store: it counts them as
|
|
2137
2411
|
undeclared, and its `--prune` hands them to `unrelate`.
|
|
2138
2412
|
|
|
2413
|
+
### Upgrading to 2.5.0-alpha.4: pin `OPENFGA_MODEL_ID` everywhere (`openfga` only)
|
|
2414
|
+
|
|
2415
|
+
**Breaking for `openfga`**: both drivers refuse to construct without `modelId` (500 `E_AUTHZ_CONFIG` with the recipe),
|
|
2416
|
+
with or without deny types — until alpha.3 it was required only with a deny type (and by `decideMany`). Unpinned, the
|
|
2417
|
+
driver would check one model and the server would evaluate each request with the *latest* one of the store.
|
|
2418
|
+
|
|
2419
|
+
1. `node ace openfga:provision --store-id=$OPENFGA_STORE_ID` from the config you are deploying: it publishes, **re-reads
|
|
2420
|
+
and compares** the model, and only then prints `OPENFGA_MODEL_ID=…` (no such line ⇒ do not pin, see the error).
|
|
2421
|
+
2. Put that id in the `.env` of **every** process (web, workers, the `ace` commands) and deploy. The published
|
|
2422
|
+
`config/authorization.ts` stub already passes `env.get('OPENFGA_MODEL_ID')` to both drivers and now throws a plain
|
|
2423
|
+
error at resolution if it is missing.
|
|
2424
|
+
3. A pod whose config and pinned model differ answers 500 `E_AUTHZ_CONFIG` on first use naming the differences
|
|
2425
|
+
([the model gate](#the-pinned-model-is-the-one-your-config-generates-250-alpha4)) — fix the pair, never the gate.
|
|
2426
|
+
|
|
2427
|
+
`database` is not affected.
|
|
2428
|
+
|
|
2429
|
+
### Upgrading to 2.5.0-alpha.4: `openfga` is experimental — opt in with `experimental: 'openfga@2.5'` (`openfga` only)
|
|
2430
|
+
|
|
2431
|
+
**Breaking for every `openfga` user, by design.** Both `OpenFgaAuthorizationDriver` and `OpenFgaRelationsDriver` now
|
|
2432
|
+
throw **500 `E_AUTHZ_CONFIG`** at construction unless their options carry the literal `experimental: 'openfga@2.5'` —
|
|
2433
|
+
in the factory of `config/authorization.ts`, in the relations factory, and in any `new` of your own (scripts, tests,
|
|
2434
|
+
CI). Nothing else is validated before it and no client is created. `true`, `'acknowledged'` or another version are
|
|
2435
|
+
refused on purpose: the token says you read the [declared limits](#limits) of **this** release.
|
|
2436
|
+
|
|
2437
|
+
```ts
|
|
2438
|
+
openfga: async () => {
|
|
2439
|
+
const { OpenFgaAuthorizationDriver } = await import('@jantstack/adonis-authz/openfga')
|
|
2440
|
+
return new OpenFgaAuthorizationDriver({ experimental: 'openfga@2.5', apiUrl, storeId, modelId, resolveChain, holderTypes, outbox })
|
|
2441
|
+
},
|
|
2442
|
+
```
|
|
2443
|
+
|
|
2444
|
+
- **Not production-ready in 2.5.x.** The stable driver of 2.5.0 is `database`. `openfga` keeps its suite (the same
|
|
2445
|
+
judge runs whole against it), its runbook and its tooling, and its open limits are rows of [Limits](#limits) marked
|
|
2446
|
+
*declared limit*, each with figure, recipe, detector and a characterisation case. They close in **2.6**.
|
|
2447
|
+
- **The published config stub does not opt in for you.** Its two `openfga` factories carry the line commented out
|
|
2448
|
+
(`// experimental: 'openfga@2.5',`), so an app scaffolded with `AUTHZ_DRIVER=openfga` fails closed at the first use
|
|
2449
|
+
until someone writes it knowing what it accepts. With `AUTHZ_DRIVER=database` the factory is registered and never
|
|
2450
|
+
built: nothing changes.
|
|
2451
|
+
- **Reading it at runtime**: `(await authorization.capabilities()).experimental` and
|
|
2452
|
+
`relations.capabilities().experimental` are `true` on `openfga`, `false` on `database` (a third-party driver without
|
|
2453
|
+
the key reads `false`).
|
|
2454
|
+
- **Migrating off `openfga` needs the token too.** `authz:reconcile --to=database` reads the store through the `openfga`
|
|
2455
|
+
factory (it is the SOURCE), and building that driver is what the gate guards: keep `experimental: 'openfga@2.5'` in
|
|
2456
|
+
that factory until the migration is done, then remove the factory. Serving from `database` never builds it.
|
|
2457
|
+
- The subpath keeps its name (`@jantstack/adonis-authz/openfga`).
|
|
2458
|
+
|
|
2459
|
+
### Upgrading to 2.5.0-alpha.4: Node 22, the SDK range, no source maps, and relation listings without `page`
|
|
2460
|
+
|
|
2461
|
+
Four changes of the «before the RC» lot (7.1–7.4), all in `package.json`, the build or the type of the relations port:
|
|
2462
|
+
|
|
2463
|
+
1. **Node ≥ 22** (`engines.node`). It is what the suite and the first consumer run; on Node 20 npm warns (or refuses,
|
|
2464
|
+
with `engine-strict`).
|
|
2465
|
+
2. **`@openfga/sdk` is a declared optional peer: `>=0.9.6 <0.10.0`.** Until now only `peerDependenciesMeta` named it,
|
|
2466
|
+
so any version was "fine". 0.9.5 fixes `authorizationModelId` in `batchCheck` and 0.9.6 is the version the suite runs;
|
|
2467
|
+
a 0.10 will be a decision, not an accident. A `database`-only install still needs nothing (the peer is optional) —
|
|
2468
|
+
but if `@openfga/sdk` reaches your tree through ANOTHER package, npm validates the range anyway and a 0.10 fails the
|
|
2469
|
+
install with `ERESOLVE`.
|
|
2470
|
+
3. **No source maps in the tarball** (`sourceMap`/`declarationMap` off): smaller package, stack traces point at
|
|
2471
|
+
`build/*.js`. Nothing to do on your side.
|
|
2472
|
+
4. **🔴 BREAKING (type of the relations port) — `listObjects`, `listSubjects`, `listDenies` and `membersOf` take no
|
|
2473
|
+
`page`, and their answers have no `cursor`.** The parameter never did anything (designed in phase 4, never
|
|
2474
|
+
implemented), so a list that came back looked like a page and was the whole set; keeping it would have turned the
|
|
2475
|
+
real pagination of 2.6 into a silent change of meaning ("today everything, tomorrow 10 and a cursor nobody reads").
|
|
2476
|
+
- TypeScript: a fifth argument (fourth in `listSubjects`/`listDenies`/`membersOf`) no longer compiles, and
|
|
2477
|
+
`RelationObjectsPage.cursor` / `RelationSubjectsPage.cursor` are gone. Remove the argument; the answer is the whole
|
|
2478
|
+
set (or `truncated: true` where the backend cuts, `listObjects` on `openfga`).
|
|
2479
|
+
- At runtime (plain JS, `as any`): anything but `undefined` — `null` and `{}` included — is 500
|
|
2480
|
+
`E_AUTHZ_UNSUPPORTED` naming the operation, with zero backend calls, in the manager **and** in both drivers
|
|
2481
|
+
(`manager.driver()` is not a way around it). `undefined` passed explicitly is fine.
|
|
2482
|
+
- A **third-party driver** that still declares `page?` keeps compiling (an extra optional parameter is assignable);
|
|
2483
|
+
the published contract suite now pins at runtime that, without a page, the four listings return the whole set and
|
|
2484
|
+
no cursor (N = 150, above every internal page size).
|
|
2485
|
+
- **`enumerateRelations(Many)` keep their page**, now validated: a `limit` that is not an integer ≥ 1 is 422
|
|
2486
|
+
`E_AUTHZ_INVALID_PAGE`, above 1,000 it is clipped (with a cursor, nothing lost); on `database` an `after` that is not
|
|
2487
|
+
the canonical uuid it emitted is 422; on `openfga` any `after` is 500 (it never emits a cursor). The reconcile only
|
|
2488
|
+
ever passes the cursor it received, so it is unaffected.
|
|
2489
|
+
- Paginated listings arrive in 2.6 as **separate** methods (`listSubjectsPage`, …) behind their own capabilities —
|
|
2490
|
+
adding them will not break anything.
|
|
2491
|
+
|
|
2492
|
+
### Upgrading to 2.5.0-alpha.4: the release steps if you use the deny (`database`)
|
|
2493
|
+
|
|
2494
|
+
The deny-reach guard of [the persisted config](#relations-rebac-24) adds steps to every release. They are spread over
|
|
2495
|
+
that section and the [Limits](#limits) rows; this is the same thing in one place. If you do not use `deny`, nothing
|
|
2496
|
+
here is required.
|
|
2497
|
+
|
|
2498
|
+
```ts
|
|
2499
|
+
import { saveRelationsConfig, checkRelationsConfigDenyReach } from '@jantstack/adonis-authz'
|
|
2500
|
+
|
|
2501
|
+
// `spec` is the same object your config/authorization.ts passes to defineRelationsConfig.
|
|
2502
|
+
// Run this in the release job (an init container, a migrate step), BEFORE the new version serves traffic,
|
|
2503
|
+
// with the same `connection` as the `database` relations driver (if it has its own).
|
|
2504
|
+
|
|
2505
|
+
// 1 · Save the config this release serves. This IS the guard: if it shrinks the reach of a live deny it throws
|
|
2506
|
+
// 500 E_AUTHZ_DENY_REACH_REFUSED and writes nothing — the release stops here.
|
|
2507
|
+
const served = await saveRelationsConfig(spec)
|
|
2508
|
+
|
|
2509
|
+
// 2 · Check. Read-only; fail the job when `ok` is false (one line per finding in `lines`).
|
|
2510
|
+
const check = await checkRelationsConfigDenyReach(served)
|
|
2511
|
+
if (!check.ok) throw new Error(check.lines.join('\n'))
|
|
2512
|
+
|
|
2513
|
+
// 3 · Serve.
|
|
2514
|
+
```
|
|
2515
|
+
|
|
2516
|
+
- **Once, the first save with live denies and no saved config yet:** `saveRelationsConfig(spec, { establishBaseline:
|
|
2517
|
+
true })`, with the spec the fleet serves **today**, in a release that does **not** change `relations.config`. Remove
|
|
2518
|
+
the option afterwards — with a baseline it does nothing. If you already save the config before your first deny, you
|
|
2519
|
+
never need it.
|
|
2520
|
+
- **Only when you really mean to narrow:** `saveRelationsConfig(spec, { allowDenyReachShrink: ['note#editor'] })`,
|
|
2521
|
+
naming exactly what may shrink. A name that exempts nothing in that save is 500 `E_AUTHZ_CONFIG`, so the list cannot
|
|
2522
|
+
stay in the pipeline by accident: remove it after that release.
|
|
2523
|
+
- **Also run step 2 on a schedule** (a health job, a cron) while a release serves: it is what sees a saved config that
|
|
2524
|
+
fell behind the one being served. Never as a hard failure in `ready`.
|
|
2525
|
+
- On a `database`-only deploy, `authz:relations:reconcile --dry-run` cannot run (it needs two relations drivers):
|
|
2526
|
+
step 2 is your detector.
|
|
2527
|
+
|
|
2139
2528
|
## Compatibility
|
|
2140
2529
|
|
|
2141
2530
|
| | |
|
|
2142
2531
|
|---|---|
|
|
2143
|
-
| Node | ≥
|
|
2532
|
+
| Node | ≥ 22 (`engines.node`, since 2.5.0-alpha.4); CI is set up to run the suite on 22 and 24 (job `compat`). Note that AdonisJS 7 and Lucid 22 themselves declare Node ≥ 24 |
|
|
2144
2533
|
| AdonisJS | ^7 (peer) · Lucid ^22 (peer) |
|
|
2145
|
-
| OpenFGA SDK |
|
|
2534
|
+
| OpenFGA SDK | `>=0.9.6 <0.10.0` (optional peer, only for that driver; 0.9.5 fixes `authorizationModelId` in `batchCheck`, 0.9.6 is what the suite runs); server verified against `v1.19.0` |
|
|
2146
2535
|
| Databases | The full contract suite (`database` driver, and `openfga` with the catalog in SQL) runs on every engine in CI: **SQLite** (in memory, and as a file with a pool of 2–5), **PostgreSQL 18** and **MySQL 8.4**. See [Operational notes for the SQL engines](#operational-notes-for-the-sql-engines) for the three schema decisions those runs forced. |
|
|
2147
2536
|
| Module format | ESM only |
|
|
2148
2537
|
|