@jantstack/adonis-authz 2.5.0-alpha.2 → 2.5.0-alpha.3

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.
Files changed (80) hide show
  1. package/README.md +343 -7
  2. package/build/commands/authz_relations_reconcile.d.ts +6 -0
  3. package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
  4. package/build/commands/authz_relations_reconcile.js +113 -3
  5. package/build/commands/authz_relations_reconcile.js.map +1 -1
  6. package/build/commands/openfga_provision.d.ts +62 -0
  7. package/build/commands/openfga_provision.d.ts.map +1 -1
  8. package/build/commands/openfga_provision.js +193 -1
  9. package/build/commands/openfga_provision.js.map +1 -1
  10. package/build/index.d.ts +6 -6
  11. package/build/index.d.ts.map +1 -1
  12. package/build/index.js +3 -3
  13. package/build/index.js.map +1 -1
  14. package/build/src/drivers/database_relations_driver.d.ts +36 -1
  15. package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
  16. package/build/src/drivers/database_relations_driver.js +211 -45
  17. package/build/src/drivers/database_relations_driver.js.map +1 -1
  18. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  19. package/build/src/drivers/openfga_driver.js +102 -36
  20. package/build/src/drivers/openfga_driver.js.map +1 -1
  21. package/build/src/drivers/openfga_facts.d.ts +21 -0
  22. package/build/src/drivers/openfga_facts.d.ts.map +1 -1
  23. package/build/src/drivers/openfga_facts.js +74 -8
  24. package/build/src/drivers/openfga_facts.js.map +1 -1
  25. package/build/src/drivers/openfga_relations_driver.d.ts +74 -1
  26. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
  27. package/build/src/drivers/openfga_relations_driver.js +599 -29
  28. package/build/src/drivers/openfga_relations_driver.js.map +1 -1
  29. package/build/src/errors.d.ts +112 -0
  30. package/build/src/errors.d.ts.map +1 -1
  31. package/build/src/errors.js +130 -0
  32. package/build/src/errors.js.map +1 -1
  33. package/build/src/manager.d.ts.map +1 -1
  34. package/build/src/manager.js +9 -4
  35. package/build/src/manager.js.map +1 -1
  36. package/build/src/openfga.d.ts +1 -1
  37. package/build/src/openfga.d.ts.map +1 -1
  38. package/build/src/openfga.js +1 -1
  39. package/build/src/openfga.js.map +1 -1
  40. package/build/src/relations/check_many.d.ts +1 -1
  41. package/build/src/relations/check_many.d.ts.map +1 -1
  42. package/build/src/relations/check_many.js +6 -4
  43. package/build/src/relations/check_many.js.map +1 -1
  44. package/build/src/relations/define_relations_config.d.ts +115 -2
  45. package/build/src/relations/define_relations_config.d.ts.map +1 -1
  46. package/build/src/relations/define_relations_config.js +197 -3
  47. package/build/src/relations/define_relations_config.js.map +1 -1
  48. package/build/src/relations/manager.d.ts +33 -2
  49. package/build/src/relations/manager.d.ts.map +1 -1
  50. package/build/src/relations/manager.js +120 -3
  51. package/build/src/relations/manager.js.map +1 -1
  52. package/build/src/relations/reconcile.d.ts +219 -1
  53. package/build/src/relations/reconcile.d.ts.map +1 -1
  54. package/build/src/relations/reconcile.js +745 -155
  55. package/build/src/relations/reconcile.js.map +1 -1
  56. package/build/src/relations/tuple_order.d.ts +126 -0
  57. package/build/src/relations/tuple_order.d.ts.map +1 -0
  58. package/build/src/relations/tuple_order.js +598 -0
  59. package/build/src/relations/tuple_order.js.map +1 -0
  60. package/build/src/shared/backend_guard.d.ts +8 -0
  61. package/build/src/shared/backend_guard.d.ts.map +1 -1
  62. package/build/src/shared/backend_guard.js +15 -0
  63. package/build/src/shared/backend_guard.js.map +1 -1
  64. package/build/src/testing/main.d.ts +1 -1
  65. package/build/src/testing/main.d.ts.map +1 -1
  66. package/build/src/testing/main.js +1 -1
  67. package/build/src/testing/main.js.map +1 -1
  68. package/build/src/testing/relations_contract.d.ts +18 -0
  69. package/build/src/testing/relations_contract.d.ts.map +1 -1
  70. package/build/src/testing/relations_contract.js +824 -44
  71. package/build/src/testing/relations_contract.js.map +1 -1
  72. package/build/src/testing/relations_reconcile_contract.d.ts +9 -0
  73. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
  74. package/build/src/testing/relations_reconcile_contract.js +207 -0
  75. package/build/src/testing/relations_reconcile_contract.js.map +1 -1
  76. package/build/src/types.d.ts +83 -3
  77. package/build/src/types.d.ts.map +1 -1
  78. package/build/src/types.js +2 -0
  79. package/build/src/types.js.map +1 -1
  80. package/package.json +1 -1
@@ -1,12 +1,12 @@
1
1
  import db from '@adonisjs/lucid/services/db';
2
2
  import { OpenFgaClient, ClientWriteRequestOnDuplicateWrites, ClientWriteRequestOnMissingDeletes, ConsistencyPreference, } from '@openfga/sdk';
3
- import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, MassReconcileRefusedError, ReconcileTooLargeError, PurgeIncompleteError, ScopeCycleError, ScopeDriftUnguardedError, ScopeTreeDriftError, UnknownPermissionError, UnknownRoleError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
3
+ import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, MassReconcileRefusedError, ReconcileTooLargeError, PurgeIncompleteError, ScopeCycleError, ScopeDriftUnguardedError, ScopeTreeDriftError, UnknownPermissionError, UnknownRoleError, UnsupportedOperationError, WriteConflictError, markPartialWrite, } from '../errors.js';
4
4
  import { APP_SCOPE_TYPE } from '../types.js';
5
5
  import { AUTHZ_TABLES_ORIGIN, RECONCILE_MAX_DETAILS, emptyReconcilePhases as emptyPhases, reconcileBatchSize, reconcileMaxTuples, sumReconcilePhases as sumPhases, } from '../reconcile.js';
6
6
  import { assertRoleAssignableAt, declaredRoleAt, fromDbScopeUuid, hasRoleTargets, resolveRoleQuery, rolesToRevoke, visibleRoleFor, } from './database_driver.js';
7
7
  import { assertCatalogUuid, assertIdentity, assertScope, chainKeysFrom, isCatalogUuid, isValidScope, normalizeRoleQuery, scopeFromKey, scopeKey, } from '../identity.js';
8
8
  import { resolveGrantExpiry, sameInstant, toExpiryDate } from '../expiry.js';
9
- import { assertKnownScope, canonicalScope, canonicalScopeTargets, guardSql, isAuthzError, isTimeoutLike, resolveChain, rootOnlyResolver, withDeadline, } from '../shared/backend_guard.js';
9
+ import { assertKnownScope, canonicalScope, canonicalScopeTargets, guardSql, isAuthzError, isServerWriteLimit, isTimeoutLike, resolveChain, rootOnlyResolver, withDeadline, } from '../shared/backend_guard.js';
10
10
  import { CatalogCache, GLOBAL_OWNER_KEY, assertCatalogOptions, isRoleVisibleWith, withAuthzCatalogWrite } from '../catalog/catalog_cache.js';
11
11
  import { FACTS_ASSIGNEE_RELATION, FACTS_BINDING_RELATION, FACTS_BINDING_TYPE, FACTS_DENIED_PREFIX, FACTS_GROUP_TYPE, FACTS_MAX_RESOLVE_DEPTH, FACTS_PARENT_RELATION, FACTS_PERMITS_PREFIX, FACTS_ROLE_RELATION, FACTS_ROLE_TYPE, FACTS_ROOTED_RELATION, FACTS_SCOPE_TYPE, assertFactsModelPublishable, assertHolderTypes, factsBindingObject, factsBindingTuples, factsCatalogTuples, factsDenyTuple, factsParentTuple, factsRelationsOf, factsRootTuples, factsScopeBindingTuple, factsScopeObject, factsTupleId, openFgaFactsModel, } from './openfga_facts.js';
12
12
  import { readCatalogProjectionSnapshot } from '../catalog/catalog.js';
@@ -239,6 +239,22 @@ function withPreserveRecipe(error) {
239
239
  `escribe a ciegas: si la intención es "permanente", pasa { expiresAt: null }; si es temporal, una Date.`;
240
240
  return error;
241
241
  }
242
+ /**
243
+ * **El gemelo de roles de N3** (lote D · D2; cierre 2 de D1 · ⚪ N3 lo cerró en relaciones): un servidor arrancado con
244
+ * `--max-tuples-per-write` < 100 rechaza con un 400 `exceeded_entity_limit` los `Write` que este driver parte en
245
+ * `PURGE_BATCH_SIZE` (100) — `projectCatalog` (la proyección del catálogo), `reconcile` (`applyReconcileWrites`) y
246
+ * `purgeScope`. Hasta D2 salía como 503 `E_AUTHZ_BACKEND_UNAVAILABLE`, que parece transitorio: repetido, vuelve a
247
+ * fallar igual para siempre. Es configuración del DESPLIEGUE ⇒ 500 `E_AUTHZ_CONFIG` con la letra del requisito, en el
248
+ * PUNTO DE PASO ÚNICO de todas las llamadas del cliente (`guardBackendErrors`).
249
+ */
250
+ function serverWriteLimitError(operation, cause) {
251
+ const sdk = cause?.apiErrorMessage;
252
+ return new AuthorizationConfigError(`OpenFgaAuthorizationDriver: ${operation}: el servidor OpenFGA rechazó un Write por su tope de operaciones por Write ` +
253
+ `(exceeded_entity_limit${typeof sdk === 'string' ? `: «${sdk}»` : ''}). Este driver parte sus escrituras (proyección ` +
254
+ `del catálogo, reconcile, purgas) en lotes de ${PURGE_BATCH_SIZE} y exige --max-tuples-per-write ≥ ${PURGE_BATCH_SIZE} en ` +
255
+ `el servidor (OPENFGA_MAX_TUPLES_PER_WRITE; el default es ${PURGE_BATCH_SIZE}). No es transitorio: repetida, la operación ` +
256
+ `vuelve a fallar igual. Si ya se habían aplicado Write anteriores de la misma operación, quedó A MEDIAS.`, { cause });
257
+ }
242
258
  /**
243
259
  * Devuelve el cliente con TODOS sus métodos envueltos: un fallo de red o un
244
260
  * 5xx sale como `AuthorizationBackendError` (503) y no como el `FgaError` del
@@ -264,7 +280,9 @@ function guardBackendErrors(client, timeoutMs) {
264
280
  const operation = String(prop);
265
281
  const fail = (cause) => isTimeoutLike(cause)
266
282
  ? new AuthorizationBackendTimeoutError('openfga', operation, timeoutMs, cause)
267
- : new AuthorizationBackendError('openfga', operation, cause);
283
+ : isServerWriteLimit(cause)
284
+ ? serverWriteLimitError(operation, cause)
285
+ : new AuthorizationBackendError('openfga', operation, cause);
268
286
  try {
269
287
  const result = value.apply(target, args);
270
288
  if (!(result instanceof Promise))
@@ -857,7 +875,16 @@ export class OpenFgaAuthorizationDriver {
857
875
  await this.client.deleteTuples([key], {
858
876
  conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
859
877
  });
860
- await this.client.writeTuples([...structure, tuple], IGNORE_DUPLICATE_WRITES);
878
+ // Cierre 3 de D2 · ⚪ C2-5 (gemelo de roles del N1 (b) de relaciones): el delete YA entró — si el write falla, el
879
+ // `assignee` quedó BORRADO (el grant se perdió: fail-closed) y la escritura es PARCIAL, no «no escrita»: el error va
880
+ // marcado (`markPartialWrite`) y el manager notifica `indeterminate: true` (invariante 13). Hasta el cierre 3 era un
881
+ // 503 sin marca y sin evento, con el grant ya revocado.
882
+ try {
883
+ await this.client.writeTuples([...structure, tuple], IGNORE_DUPLICATE_WRITES);
884
+ }
885
+ catch (error) {
886
+ throw error !== null && typeof error === 'object' ? markPartialWrite(error) : error;
887
+ }
861
888
  }
862
889
  /**
863
890
  * Estado actual de una asignación, con TRES resultados posibles y no dos.
@@ -1633,12 +1660,32 @@ export class OpenFgaAuthorizationDriver {
1633
1660
  const keys = await this.readAllTuples({ object }, { includeExpired: true });
1634
1661
  return object === scopeObject ? keys.filter((k) => k.relation !== FACTS_PARENT_RELATION) : keys;
1635
1662
  };
1636
- /** Borra en lotes ≤ 100 (límite del `Write`), sin quejarse de lo que ya no está. */
1637
- const deleteKeys = async (keys) => {
1663
+ /**
1664
+ * Cierre 4 de D2 · ⚪ C3-4 (gemelo de `#partialIfDeleted` de relaciones): cuántos `Write` de borrado YA entraron. La
1665
+ * purga son varios `Write` (estructura, hechos por objeto, denies; lotes de 100): un fallo después del primero llega
1666
+ * con parte ya borrada y se marca `markPartialWrite` (el manager notifica `indeterminate: true`, invariante 13). Un
1667
+ * lote que falla no borró nada (FGA aplica cada `Write` entero).
1668
+ */
1669
+ const progress = { deleted: 0, unconfirmed: false };
1670
+ /**
1671
+ * Borra en lotes ≤ 100 (límite del `Write`), sin quejarse de lo que ya no está. Cierre 5 de D2 · ⚪ D4-5: el contador
1672
+ * suma lo que el lote BORRA DE VERDAD (`effective`, por defecto todo: las fases 2-4 borran lo que acaban de LEER), no
1673
+ * lo pedido — la fase 1 borra la estructura a CIEGAS con `Ignore` y hasta el cierre 5 contaba claves que no existían
1674
+ * (un fallo posterior salía `indeterminate: true` sin haber borrado nada).
1675
+ */
1676
+ const deleteKeys = async (keys, effective = () => true) => {
1638
1677
  for (let i = 0; i < keys.length; i += PURGE_BATCH_SIZE) {
1639
- await this.client.deleteTuples(keys.slice(i, i + PURGE_BATCH_SIZE), {
1678
+ const batch = keys.slice(i, i + PURGE_BATCH_SIZE);
1679
+ await this.client.deleteTuples(batch, {
1640
1680
  conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
1641
1681
  });
1682
+ progress.deleted += batch.filter(effective).length;
1683
+ // Cierre 6 de D2 · ⚪ D5-4: una clave pedida a ciegas que la lectura previa NO vio puede haberla escrito un
1684
+ // `grant` concurrente entre esa lectura y este borrado: entonces este lote SÍ borró algo que no se contó. Hasta
1685
+ // que la fase 2 termine (borra —y cuenta— el `assignee` de ese grant) el efecto es INCIERTO, y un fallo en ese
1686
+ // tramo sale marcado parcial: preferir sobre-notificar a sub-notificar.
1687
+ if (batch.some((k) => !effective(k)))
1688
+ progress.unconfirmed = true;
1642
1689
  }
1643
1690
  };
1644
1691
  /** Las dos aristas de (c2): estructura, no hechos (`#role` y `#binding`). */
@@ -1671,36 +1718,55 @@ export class OpenFgaAuthorizationDriver {
1671
1718
  // el residuo es justo lo que la demostración de cero reporta. La arista
1672
1719
  // `parent` sigue sin tocarse: la borra `scopes.detached` DESPUÉS de que
1673
1720
  // esta purga demuestre cero (S6, cruce 9).
1674
- await deleteKeys(roles.flatMap((r) => factsBindingTuples(key, r.uuid)));
1675
- for (const object of objects) {
1676
- if (object === scopeObject)
1677
- continue;
1678
- await deleteKeys((await purgeable(object)).filter((k) => !isStructure(k)));
1679
- }
1680
- // **Las aristas `#binding` de roles que el catálogo YA NO declara en este
1681
- // nivel también se purgan** (3b-8 · C2). El barrido no es atómico con el
1682
- // catálogo: un rol cuya fila se borró (o cambió de `scope_type`) entre el
1683
- // commit y esta purga dejaba una arista que el paso 1 no ve —solo mira
1684
- // `authz_roles WHERE scope_type = <este>`— y que la demostración de cero
1685
- // SÍ cuenta ⇒ `E_AUTHZ_PURGE_INCOMPLETE` en TODOS los intentos y
1686
- // `detached` bloqueado para siempre. Borrarla nunca concede (es la arista
1687
- // de visibilidad, y su rol ya no existe en este nivel), y la arista de un
1688
- // rol DEL catálogo que un `grant` concurrente reescribió no se toca
1689
- // (`known` la protege): esa sigue saliendo como residuo, que es lo
1690
- // correcto — se reintenta.
1691
- const known = new Set(roles.map((r) => `role_binding:${key}|${r.uuid}`));
1692
- await deleteKeys((await purgeable(scopeObject)).filter((k) => k.relation === FACTS_BINDING_RELATION && !known.has(k.user)));
1693
- await deleteKeys((await purgeable(scopeObject)).filter((k) => k.relation.startsWith(FACTS_DENIED_PREFIX)));
1694
- // Demostrar cero: lo que no se puede demostrar, se reporta.
1695
- const residue = [];
1696
- for (const object of objects) {
1697
- const left = await purgeable(object);
1698
- if (left.length)
1699
- residue.push(`${object} (${left.length})`);
1721
+ // Los pasos (estructura → hechos → denies → demostrar cero), en una función LOCAL (no un método `#`: el driver
1722
+ // puede llegar envuelto en un Proxy y un campo privado exige el receptor real), para marcar el corte a medias.
1723
+ const steps = async () => {
1724
+ // ⚪ D4-5 (cierre 5 de D2): qué estructura EXISTE antes del borrado a ciegas — UNA lectura del scope (sus aristas
1725
+ // `#binding`). Solo la arista `scope#binding` hace visible (y por tanto observable) un rol aquí: un `role_binding#role`
1726
+ // sin su `#binding` (lo que deja el barrido de roles locales) no cambia ninguna respuesta al borrarse, así que no
1727
+ // cuenta como escritura hecha. Ventana declarada: una arista que un `grant` concurrente escribe ENTRE esta lectura y
1728
+ // el borrado se borra sin contarse; la fase 2 borra después su `assignee`, que sí cuenta, y HASTA que la fase 2
1729
+ // termina el error va marcado parcial igual (`unconfirmed`, ⚪ D5-4 del cierre 6: sobre-notificar, nunca sub-).
1730
+ const visible = new Set((await purgeable(scopeObject)).filter((k) => k.relation === FACTS_BINDING_RELATION).map((k) => k.user));
1731
+ await deleteKeys(roles.flatMap((r) => factsBindingTuples(key, r.uuid)), (k) => visible.has(k.relation === FACTS_BINDING_RELATION ? k.user : k.object));
1732
+ for (const object of objects) {
1733
+ if (object === scopeObject)
1734
+ continue;
1735
+ await deleteKeys((await purgeable(object)).filter((k) => !isStructure(k)));
1736
+ }
1737
+ // ⚪ D5-4: la fase 2 terminó — el `assignee` de un grant que se coló antes de la fase 1 ya se borró y se contó.
1738
+ progress.unconfirmed = false;
1739
+ // **Las aristas `#binding` de roles que el catálogo YA NO declara en este
1740
+ // nivel también se purgan** (3b-8 · C2). El barrido no es atómico con el
1741
+ // catálogo: un rol cuya fila se borró (o cambió de `scope_type`) entre el
1742
+ // commit y esta purga dejaba una arista que el paso 1 no ve —solo mira
1743
+ // `authz_roles WHERE scope_type = <este>`— y que la demostración de cero
1744
+ // SÍ cuenta ⇒ `E_AUTHZ_PURGE_INCOMPLETE` en TODOS los intentos y
1745
+ // `detached` bloqueado para siempre. Borrarla nunca concede (es la arista
1746
+ // de visibilidad, y su rol ya no existe en este nivel), y la arista de un
1747
+ // rol DEL catálogo que un `grant` concurrente reescribió no se toca
1748
+ // (`known` la protege): esa sigue saliendo como residuo, que es lo
1749
+ // correcto — se reintenta.
1750
+ const known = new Set(roles.map((r) => `role_binding:${key}|${r.uuid}`));
1751
+ await deleteKeys((await purgeable(scopeObject)).filter((k) => k.relation === FACTS_BINDING_RELATION && !known.has(k.user)));
1752
+ await deleteKeys((await purgeable(scopeObject)).filter((k) => k.relation.startsWith(FACTS_DENIED_PREFIX)));
1753
+ // Demostrar cero: lo que no se puede demostrar, se reporta.
1754
+ const residue = [];
1755
+ for (const object of objects) {
1756
+ const left = await purgeable(object);
1757
+ if (left.length)
1758
+ residue.push(`${object} (${left.length})`);
1759
+ }
1760
+ if (residue.length) {
1761
+ throw new PurgeIncompleteError(`purgeScope ${key}: quedan tuplas tras el borrado — ${residue.join('; ')}. ` +
1762
+ `No confirmes el borrado del scope; reintenta la purga.`);
1763
+ }
1764
+ };
1765
+ try {
1766
+ await steps();
1700
1767
  }
1701
- if (residue.length) {
1702
- throw new PurgeIncompleteError(`purgeScope ${key}: quedan tuplas tras el borrado — ${residue.join('; ')}. ` +
1703
- `No confirmes el borrado del scope; reintenta la purga.`);
1768
+ catch (error) {
1769
+ throw (progress.deleted > 0 || progress.unconfirmed) && error !== null && typeof error === 'object' ? markPartialWrite(error) : error;
1704
1770
  }
1705
1771
  }
1706
1772
  /**