@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.
- package/README.md +343 -7
- package/build/commands/authz_relations_reconcile.d.ts +6 -0
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
- package/build/commands/authz_relations_reconcile.js +113 -3
- package/build/commands/authz_relations_reconcile.js.map +1 -1
- package/build/commands/openfga_provision.d.ts +62 -0
- package/build/commands/openfga_provision.d.ts.map +1 -1
- package/build/commands/openfga_provision.js +193 -1
- package/build/commands/openfga_provision.js.map +1 -1
- package/build/index.d.ts +6 -6
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -3
- package/build/index.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +36 -1
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/database_relations_driver.js +211 -45
- package/build/src/drivers/database_relations_driver.js.map +1 -1
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +102 -36
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_facts.d.ts +21 -0
- package/build/src/drivers/openfga_facts.d.ts.map +1 -1
- package/build/src/drivers/openfga_facts.js +74 -8
- package/build/src/drivers/openfga_facts.js.map +1 -1
- package/build/src/drivers/openfga_relations_driver.d.ts +74 -1
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_relations_driver.js +599 -29
- package/build/src/drivers/openfga_relations_driver.js.map +1 -1
- package/build/src/errors.d.ts +112 -0
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +130 -0
- package/build/src/errors.js.map +1 -1
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +9 -4
- package/build/src/manager.js.map +1 -1
- package/build/src/openfga.d.ts +1 -1
- package/build/src/openfga.d.ts.map +1 -1
- package/build/src/openfga.js +1 -1
- package/build/src/openfga.js.map +1 -1
- package/build/src/relations/check_many.d.ts +1 -1
- package/build/src/relations/check_many.d.ts.map +1 -1
- package/build/src/relations/check_many.js +6 -4
- package/build/src/relations/check_many.js.map +1 -1
- package/build/src/relations/define_relations_config.d.ts +115 -2
- package/build/src/relations/define_relations_config.d.ts.map +1 -1
- package/build/src/relations/define_relations_config.js +197 -3
- package/build/src/relations/define_relations_config.js.map +1 -1
- package/build/src/relations/manager.d.ts +33 -2
- package/build/src/relations/manager.d.ts.map +1 -1
- package/build/src/relations/manager.js +120 -3
- package/build/src/relations/manager.js.map +1 -1
- package/build/src/relations/reconcile.d.ts +219 -1
- package/build/src/relations/reconcile.d.ts.map +1 -1
- package/build/src/relations/reconcile.js +745 -155
- package/build/src/relations/reconcile.js.map +1 -1
- package/build/src/relations/tuple_order.d.ts +126 -0
- package/build/src/relations/tuple_order.d.ts.map +1 -0
- package/build/src/relations/tuple_order.js +598 -0
- package/build/src/relations/tuple_order.js.map +1 -0
- package/build/src/shared/backend_guard.d.ts +8 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -1
- package/build/src/shared/backend_guard.js +15 -0
- package/build/src/shared/backend_guard.js.map +1 -1
- package/build/src/testing/main.d.ts +1 -1
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +1 -1
- package/build/src/testing/main.js.map +1 -1
- package/build/src/testing/relations_contract.d.ts +18 -0
- package/build/src/testing/relations_contract.d.ts.map +1 -1
- package/build/src/testing/relations_contract.js +824 -44
- package/build/src/testing/relations_contract.js.map +1 -1
- package/build/src/testing/relations_reconcile_contract.d.ts +9 -0
- package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
- package/build/src/testing/relations_reconcile_contract.js +207 -0
- package/build/src/testing/relations_reconcile_contract.js.map +1 -1
- package/build/src/types.d.ts +83 -3
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js +2 -0
- package/build/src/types.js.map +1 -1
- 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
|
-
:
|
|
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
|
-
|
|
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
|
-
/**
|
|
1637
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
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
|
-
|
|
1702
|
-
throw
|
|
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
|
/**
|