@jantstack/adonis-authz 1.0.2 → 2.0.0-alpha.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.
Files changed (132) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +417 -46
  3. package/build/commands/authz_catalog_diff.d.ts +28 -0
  4. package/build/commands/authz_catalog_diff.d.ts.map +1 -0
  5. package/build/commands/authz_catalog_diff.js +67 -0
  6. package/build/commands/authz_catalog_diff.js.map +1 -0
  7. package/build/commands/authz_catalog_sync.d.ts +20 -0
  8. package/build/commands/authz_catalog_sync.d.ts.map +1 -0
  9. package/build/commands/authz_catalog_sync.js +58 -0
  10. package/build/commands/authz_catalog_sync.js.map +1 -0
  11. package/build/commands/main.d.ts +2 -0
  12. package/build/commands/main.d.ts.map +1 -1
  13. package/build/commands/main.js +2 -0
  14. package/build/commands/main.js.map +1 -1
  15. package/build/commands/openfga_import.d.ts +8 -2
  16. package/build/commands/openfga_import.d.ts.map +1 -1
  17. package/build/commands/openfga_import.js +29 -6
  18. package/build/commands/openfga_import.js.map +1 -1
  19. package/build/commands/openfga_provision.d.ts.map +1 -1
  20. package/build/commands/openfga_provision.js +2 -1
  21. package/build/commands/openfga_provision.js.map +1 -1
  22. package/build/index.d.ts +51 -5
  23. package/build/index.d.ts.map +1 -1
  24. package/build/index.js +43 -3
  25. package/build/index.js.map +1 -1
  26. package/build/src/catalog.d.ts +249 -2
  27. package/build/src/catalog.d.ts.map +1 -1
  28. package/build/src/catalog.js +709 -59
  29. package/build/src/catalog.js.map +1 -1
  30. package/build/src/catalog_cache.d.ts +300 -0
  31. package/build/src/catalog_cache.d.ts.map +1 -0
  32. package/build/src/catalog_cache.js +656 -0
  33. package/build/src/catalog_cache.js.map +1 -0
  34. package/build/src/clock.d.ts +24 -0
  35. package/build/src/clock.d.ts.map +1 -0
  36. package/build/src/clock.js +7 -0
  37. package/build/src/clock.js.map +1 -0
  38. package/build/src/define_config.d.ts +102 -6
  39. package/build/src/define_config.d.ts.map +1 -1
  40. package/build/src/define_config.js.map +1 -1
  41. package/build/src/drivers/backend_guard.d.ts +92 -0
  42. package/build/src/drivers/backend_guard.d.ts.map +1 -0
  43. package/build/src/drivers/backend_guard.js +221 -0
  44. package/build/src/drivers/backend_guard.js.map +1 -0
  45. package/build/src/drivers/database_driver.d.ts +242 -15
  46. package/build/src/drivers/database_driver.d.ts.map +1 -1
  47. package/build/src/drivers/database_driver.js +692 -127
  48. package/build/src/drivers/database_driver.js.map +1 -1
  49. package/build/src/drivers/openfga_driver.d.ts +341 -27
  50. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  51. package/build/src/drivers/openfga_driver.js +1052 -264
  52. package/build/src/drivers/openfga_driver.js.map +1 -1
  53. package/build/src/drivers/sql_expiry.d.ts +53 -0
  54. package/build/src/drivers/sql_expiry.d.ts.map +1 -0
  55. package/build/src/drivers/sql_expiry.js +66 -0
  56. package/build/src/drivers/sql_expiry.js.map +1 -0
  57. package/build/src/errors.d.ts +366 -0
  58. package/build/src/errors.d.ts.map +1 -0
  59. package/build/src/errors.js +387 -0
  60. package/build/src/errors.js.map +1 -0
  61. package/build/src/expiry.d.ts +27 -0
  62. package/build/src/expiry.d.ts.map +1 -0
  63. package/build/src/expiry.js +50 -0
  64. package/build/src/expiry.js.map +1 -0
  65. package/build/src/hierarchical_resolver.d.ts +56 -0
  66. package/build/src/hierarchical_resolver.d.ts.map +1 -0
  67. package/build/src/hierarchical_resolver.js +87 -0
  68. package/build/src/hierarchical_resolver.js.map +1 -0
  69. package/build/src/identity.d.ts +155 -0
  70. package/build/src/identity.d.ts.map +1 -0
  71. package/build/src/identity.js +359 -0
  72. package/build/src/identity.js.map +1 -0
  73. package/build/src/manager.d.ts +225 -7
  74. package/build/src/manager.d.ts.map +1 -1
  75. package/build/src/manager.js +1670 -23
  76. package/build/src/manager.js.map +1 -1
  77. package/build/src/memoize_ancestors.d.ts +23 -0
  78. package/build/src/memoize_ancestors.d.ts.map +1 -0
  79. package/build/src/memoize_ancestors.js +42 -0
  80. package/build/src/memoize_ancestors.js.map +1 -0
  81. package/build/src/middleware/app_access_middleware.d.ts +8 -6
  82. package/build/src/middleware/app_access_middleware.d.ts.map +1 -1
  83. package/build/src/middleware/app_access_middleware.js +10 -26
  84. package/build/src/middleware/app_access_middleware.js.map +1 -1
  85. package/build/src/models/authz_assignment.d.ts +5 -5
  86. package/build/src/models/authz_assignment.d.ts.map +1 -1
  87. package/build/src/models/authz_deny.d.ts +5 -5
  88. package/build/src/models/authz_deny.d.ts.map +1 -1
  89. package/build/src/models/authz_permission.d.ts +11 -5
  90. package/build/src/models/authz_permission.d.ts.map +1 -1
  91. package/build/src/models/authz_permission.js +4 -0
  92. package/build/src/models/authz_permission.js.map +1 -1
  93. package/build/src/models/authz_role.d.ts +13 -6
  94. package/build/src/models/authz_role.d.ts.map +1 -1
  95. package/build/src/models/authz_role.js +6 -1
  96. package/build/src/models/authz_role.js.map +1 -1
  97. package/build/src/models/authz_role_permission.d.ts +5 -5
  98. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  99. package/build/src/openfga.d.ts +13 -0
  100. package/build/src/openfga.d.ts.map +1 -0
  101. package/build/src/openfga.js +12 -0
  102. package/build/src/openfga.js.map +1 -0
  103. package/build/src/sql_descendants.d.ts +51 -0
  104. package/build/src/sql_descendants.d.ts.map +1 -0
  105. package/build/src/sql_descendants.js +129 -0
  106. package/build/src/sql_descendants.js.map +1 -0
  107. package/build/src/testing/contract.d.ts +138 -7
  108. package/build/src/testing/contract.d.ts.map +1 -1
  109. package/build/src/testing/contract.js +2946 -24
  110. package/build/src/testing/contract.js.map +1 -1
  111. package/build/src/testing/main.d.ts +4 -2
  112. package/build/src/testing/main.d.ts.map +1 -1
  113. package/build/src/testing/main.js +2 -1
  114. package/build/src/testing/main.js.map +1 -1
  115. package/build/src/testing/scope_tree.d.ts +65 -0
  116. package/build/src/testing/scope_tree.d.ts.map +1 -0
  117. package/build/src/testing/scope_tree.js +145 -0
  118. package/build/src/testing/scope_tree.js.map +1 -0
  119. package/build/src/traits/authz_scopes.d.ts +30 -6
  120. package/build/src/traits/authz_scopes.d.ts.map +1 -1
  121. package/build/src/traits/authz_scopes.js +30 -18
  122. package/build/src/traits/authz_scopes.js.map +1 -1
  123. package/build/src/traits/has_uuid.d.ts +5 -5
  124. package/build/src/traits/has_uuid.d.ts.map +1 -1
  125. package/build/src/types.d.ts +530 -28
  126. package/build/src/types.d.ts.map +1 -1
  127. package/build/src/types.js +10 -4
  128. package/build/src/types.js.map +1 -1
  129. package/build/stubs/config/app_acl.stub +4 -2
  130. package/build/stubs/config/authorization.stub +66 -12
  131. package/build/stubs/migration.stub +57 -13
  132. package/package.json +11 -6
@@ -1,80 +1,139 @@
1
- import { Exception } from '@adonisjs/core/exceptions';
2
1
  import db from '@adonisjs/lucid/services/db';
3
- import { OpenFgaClient, ClientWriteRequestOnDuplicateWrites, ClientWriteRequestOnMissingDeletes, } from '@openfga/sdk';
4
- import { APP_SCOPE, APP_SCOPE_TYPE } from '../types.js';
5
- import { APP_SCOPE_DB_UUID } from './database_driver.js';
6
- /** `:` no es válido en ids de FGA se encodea (`audit:read` → `audit~read`). */
7
- function encodeSlug(slug) {
8
- return slug.replaceAll(':', '~');
9
- }
10
- function decodeSlug(encoded) {
11
- return encoded.replaceAll('~', ':');
12
- }
2
+ import { OpenFgaClient, ClientWriteRequestOnDuplicateWrites, ClientWriteRequestOnMissingDeletes, ConsistencyPreference, } from '@openfga/sdk';
3
+ import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, PurgeIncompleteError, StoreNotEmptyError, UnknownPermissionError, } from '../errors.js';
4
+ import { APP_SCOPE_TYPE } from '../types.js';
5
+ import { APP_SCOPE_DB_UUID, assertRoleAssignableAt, declaredRoleAt, hasRoleTargets, resolveRoleQuery, rolesToRevoke, visibleRoleFor, } from './database_driver.js';
6
+ import { assertIdentity, assertScope, chainKeysFrom, isCatalogUuid, isValidScope, normalizeRoleQuery, scopeKey, } from '../identity.js';
7
+ import { resolveGrantExpiry, sameInstant, toExpiryDate } from '../expiry.js';
8
+ import { assertKnownScope, canonicalScope, guardSql, isTimeoutLike, resolveChain, rootOnlyResolver, withDeadline, } from './backend_guard.js';
9
+ import { CatalogCache, assertCatalogOptions, isRoleVisibleWith } from '../catalog_cache.js';
10
+ import { isClock, systemClock } from '../clock.js';
11
+ import { sqlExpiryCodec } from './sql_expiry.js';
12
+ /** Nombre de tipo admitido por FGA (`^[^:#@\s]{1,254}$`). */
13
+ const FGA_TYPE_FORMAT = /^[^:#@\s]{1,254}$/;
13
14
  /**
14
- * Caracteres admitidos en un tipo de scope y en un uuid al construir la clave
15
- * del binding. `|` es el separador y `~` el escape de los slugs: si alguno
16
- * apareciera dentro de un componente, dos scopes DISTINTOS podrían producir
17
- * la misma clave —p. ej. `{org, 'anization|X'}` y `{'org|anization', 'X'}`—
18
- * y un grant en uno autorizaría en el otro (confusión de privilegios).
19
- *
20
- * El driver `database` es inmune por construcción (guarda tipo y uuid en
21
- * columnas separadas, sin codificar); esta validación protege la única ruta
22
- * que serializa el scope a un string.
15
+ * `holderTypes` tiene que ser INYECTIVO. Si dos morph names caen en el mismo
16
+ * tipo FGA, para el store son un solo holder: un grant a `users:U` autoriza a
17
+ * `integrations:U`, `listSubjects` devuelve el morph equivocado y un revoke
18
+ * borra al otro (invariante 4, L0.2). El generador del modelo lo "sabía"
19
+ * (deduplicaba con un Set) y publicaba sin quejarse: ahora lanza aquí, al
20
+ * construir el driver y al generar el modelo, antes de tocar nada.
23
21
  */
24
- const SCOPE_COMPONENT_FORMAT = /^[a-zA-Z0-9_.:-]+$/;
25
- function assertScopeComponent(kind, value) {
26
- if (!SCOPE_COMPONENT_FORMAT.test(value)) {
27
- throw new Exception(`${kind} inválido para el driver openfga: '${value}'. ` +
28
- `Solo se admiten letras, dígitos y . _ - : (ni '|' ni '~').`, { status: 500 });
22
+ export function assertHolderTypes(holderTypes) {
23
+ if (!holderTypes || typeof holderTypes !== 'object' || Object.keys(holderTypes).length === 0) {
24
+ throw new AuthorizationConfigError('holderTypes vacío: el driver openfga necesita al menos un holder (morph name → tipo FGA)');
25
+ }
26
+ const morphsByFgaType = new Map();
27
+ for (const [morph, fgaType] of Object.entries(holderTypes)) {
28
+ if (typeof fgaType !== 'string' || !FGA_TYPE_FORMAT.test(fgaType)) {
29
+ throw new AuthorizationConfigError(`holderTypes['${morph}'] = ${JSON.stringify(fgaType)} no es un tipo FGA válido ` +
30
+ `(1-254 caracteres, sin ':', '#', '@' ni espacios)`);
31
+ }
32
+ morphsByFgaType.set(fgaType, [...(morphsByFgaType.get(fgaType) ?? []), morph]);
33
+ }
34
+ const collisions = [...morphsByFgaType.entries()].filter(([, morphs]) => morphs.length > 1);
35
+ if (collisions.length) {
36
+ throw new AuthorizationConfigError(`holderTypes no es inyectivo: ` +
37
+ collisions.map(([fga, morphs]) => `${morphs.join(' y ')} → '${fga}'`).join('; ') +
38
+ `. Dos holders con el mismo tipo FGA serían uno solo para el store.`);
29
39
  }
30
40
  }
41
+ // La clave de scope del id del binding (`app` | `<tipo>|<uuid>`) es la
42
+ // misma `scopeKey` del paquete (`identity.ts`; desde 3B también el owner de
43
+ // un rol local): `|` es el separador y `assertScope` impide que un
44
+ // componente lo lleve (dos scopes distintos no producen la misma clave) y
45
+ // rechaza `{app, uuid}` (L0.10).
31
46
  /**
32
- * Clave de scope dentro del id del binding: `app` para la raíz,
33
- * `<tipo>|<uuid>` para el resto. Genérico: sirve para cualquier nivel que
34
- * defina el consumidor sin tocar el driver.
47
+ * Id de binding (`<scopeKey>|<uuid>`: `app|<uuid>` o `<tipo>|<uuidScope>|<uuid>`)
48
+ * → scope + uuid del catálogo (del rol en `role_binding`, del permiso en
49
+ * `deny_binding`). Se parsea DESDE LA DERECHA (3A · A1): el último componente
50
+ * es el uuid y el resto la clave del scope, que tiene 1 parte (`app`) o 2
51
+ * (`<tipo>|<uuid>`). Antes el último componente era el slug codificado
52
+ * (`docs~read`) y el parseo contaba partes: ambiguo en cuanto la clave del
53
+ * scope variara de longitud (panel 2026-08-28, §2-C), y con un escape no
54
+ * inyectivo desde el llamante (L0.8a).
55
+ *
56
+ * `null` si no tiene la forma del motor O si alguna parte no pasa su
57
+ * gramática —el scope, la de identidad; el uuid, la de UUID canónico del
58
+ * catálogo—: un id que el driver no escribiría no es un hecho del motor
59
+ * aunque esté en el store. Los ids de 1.x/2.0–2.1 (con slug) caen aquí:
60
+ * 2.2 no los lee, y `openfga:import --reconcile` los cuenta como `extra`.
61
+ * Exportada para probarla sin servidor.
35
62
  */
36
- function scopeKey(scope) {
37
- assertScopeComponent('Tipo de scope', scope.type);
38
- if (scope.type === APP_SCOPE_TYPE)
39
- return APP_SCOPE_TYPE;
40
- assertScopeComponent('UUID de scope', String(scope.uuid ?? ''));
41
- return `${scope.type}|${scope.uuid}`;
42
- }
43
- function parseBindingId(id) {
44
- const parts = id.split('|');
45
- if (parts.length === 2 && parts[0] === APP_SCOPE_TYPE) {
46
- return { scope: { type: APP_SCOPE_TYPE, uuid: null }, slug: decodeSlug(parts[1]) };
63
+ export function parseBindingId(id) {
64
+ const cut = id.lastIndexOf('|');
65
+ if (cut < 0)
66
+ return null;
67
+ const uuid = id.slice(cut + 1);
68
+ const keyParts = id.slice(0, cut).split('|');
69
+ let scope;
70
+ if (keyParts.length === 1 && keyParts[0] === APP_SCOPE_TYPE) {
71
+ scope = { type: APP_SCOPE_TYPE, uuid: null };
47
72
  }
48
- if (parts.length === 3) {
49
- return { scope: { type: parts[0], uuid: parts[1] }, slug: decodeSlug(parts[2]) };
73
+ else if (keyParts.length === 2) {
74
+ scope = { type: keyParts[0], uuid: keyParts[1] };
50
75
  }
51
- return null;
76
+ else {
77
+ return null;
78
+ }
79
+ if (!isValidScope(scope) || !isCatalogUuid(uuid))
80
+ return null;
81
+ return { scope, uuid };
52
82
  }
53
83
  /** `<tipoFga>:<uuid>` a partir del morph name del holder. */
54
84
  function fgaSubjectWith(subject, holderTypes) {
55
85
  const fgaType = holderTypes[subject.type];
56
86
  if (!fgaType) {
57
- throw new Exception(`Holder type '${subject.type}' no está en el modelo FGA ` +
87
+ // Contradicción de config (D15): el modelo del store no tiene ese tipo.
88
+ throw new AuthorizationConfigError(`Holder type '${subject.type}' no está en el modelo FGA ` +
58
89
  `(declarados: ${Object.keys(holderTypes).join(', ') || 'ninguno'}). ` +
59
- `Añádelo a holderTypes y regenera el authorization model.`, { status: 500 });
90
+ `Añádelo a holderTypes y regenera el authorization model.`);
60
91
  }
61
92
  return `${fgaType}:${subject.uuid}`;
62
93
  }
63
- function checkContext() {
64
- return { current_time: new Date().toISOString() };
94
+ /**
95
+ * El `context` de TODA consulta que evalúe relaciones: checks de roles y de
96
+ * denies. Un único constructor a propósito (S17): en cuanto una tupla del
97
+ * camino lleva la condición `not_expired`, un check sin `current_time` falla
98
+ * entero (400 → 503). Hoy los denies no llevan condición; el modo facts (3b)
99
+ * evalúa deny y grant en un solo check, así que no hay margen. Las
100
+ * enumeraciones no evalúan nada (`Read` devuelve tuplas escritas): filtran
101
+ * la caducidad en cliente con el MISMO reloj del driver (`now()`, J1): el
102
+ * `current_time` que viaja en cada check es el instante que decide.
103
+ */
104
+ function checkContext(now) {
105
+ return { current_time: now.toISOString() };
65
106
  }
66
107
  /**
67
- * ¿La expiración almacenada y la pedida son la misma? Compara el instante,
68
- * no la cadena: `2026-01-01T00:00:00Z` y `2026-01-01T00:00:00.000Z` son el
69
- * mismo momento y no justifican reescribir la tuple.
108
+ * Alinea los resultados de un batchCheck con los checks pedidos por
109
+ * `correlationId`, no por posición (L0.14). El SDK reparte el lote en
110
+ * sub-lotes paralelos y concatena las respuestas según llegan: el orden no es
111
+ * el de los checks. Cardinalidad igual no basta —un id duplicado y otro
112
+ * ausente pasan el conteo—: cada check debe tener EXACTAMENTE un resultado y
113
+ * ningún resultado puede ser de un check que no se pidió.
70
114
  */
71
- function sameExpiry(storedValidUntil, requested) {
72
- if (!storedValidUntil && !requested)
73
- return true;
74
- if (!storedValidUntil || !requested)
75
- return false;
76
- const stored = Date.parse(storedValidUntil);
77
- return Number.isFinite(stored) && stored === requested.getTime();
115
+ export function correlateBatchResults(checks, results) {
116
+ const byId = new Map();
117
+ for (const result of results) {
118
+ const id = result.correlationId;
119
+ if (byId.has(id)) {
120
+ throw new AuthorizationInternalError(`OpenFGA batchCheck devolvió dos resultados para el correlationId '${id}'`);
121
+ }
122
+ byId.set(id, result);
123
+ }
124
+ const aligned = checks.map((check) => {
125
+ const result = byId.get(check.correlationId);
126
+ if (!result) {
127
+ throw new AuthorizationInternalError(`OpenFGA batchCheck no devolvió resultado para el check '${check.correlationId}' (${check.relation} ${check.object})`);
128
+ }
129
+ return result;
130
+ });
131
+ if (byId.size !== checks.length) {
132
+ const requested = new Set(checks.map((c) => c.correlationId));
133
+ const foreign = [...byId.keys()].filter((id) => !requested.has(id));
134
+ throw new AuthorizationInternalError(`OpenFGA batchCheck devolvió resultados de checks no pedidos: ${foreign.join(', ')}`);
135
+ }
136
+ return aligned;
78
137
  }
79
138
  /**
80
139
  * El authorization model en formato JSON del API de FGA, generado a partir
@@ -82,7 +141,8 @@ function sameExpiry(storedValidUntil, requested) {
82
141
  * driver: si difieren, los checks no encuentran las tuplas.
83
142
  */
84
143
  export function openFgaAuthorizationModel(holderTypeMap) {
85
- const holderTypes = [...new Set(Object.values(holderTypeMap))];
144
+ assertHolderTypes(holderTypeMap);
145
+ const holderTypes = Object.values(holderTypeMap);
86
146
  const direct = holderTypes.map((type) => ({ type }));
87
147
  const directWithExpiry = [
88
148
  ...direct,
@@ -132,6 +192,142 @@ export async function provisionOpenFgaStore(apiUrl, name, holderTypeMap) {
132
192
  const model = await scoped.writeAuthorizationModel(openFgaAuthorizationModel(holderTypeMap));
133
193
  return { storeId: store.id, modelId: model.authorization_model_id };
134
194
  }
195
+ /**
196
+ * ¿El error (o su cadena de causas) es el rechazo de FGA a escribir una tuple
197
+ * key que ya existe? Es la ÚNICA señal de carrera check-then-write que
198
+ * `grant` acepta: un 400 de validación (`validation_error`) o un 5xx no son
199
+ * "alguien escribió antes" y se propagan clasificados, con el error del SDK
200
+ * como causa (D6). Verificado contra OpenFGA v1.19: el duplicado llega como
201
+ * HTTP 400 con `apiErrorCode: 'write_failed_due_to_invalid_input'` y el
202
+ * mensaje "cannot write a tuple which already exists"; un 409 se acepta por
203
+ * si una versión del servidor lo devuelve así.
204
+ */
205
+ export function isDuplicateWrite(error) {
206
+ let current = error;
207
+ for (let depth = 0; current && depth < 6; depth++) {
208
+ if (current.statusCode === 409)
209
+ return true;
210
+ if (current.apiErrorCode === 'write_failed_due_to_invalid_input' &&
211
+ /already exists/i.test(String(current.apiErrorMessage ?? current.message ?? ''))) {
212
+ return true;
213
+ }
214
+ current = current.cause;
215
+ }
216
+ return false;
217
+ }
218
+ /**
219
+ * Receta que acompaña al 503 de un `grant` SIN `expiresAt` cuando no se pudo
220
+ * leer la caducidad vigente: preservar exige saber qué hay, y asumir
221
+ * "permanente" sería L0.4 en modo degradado. Se añade al mensaje del error
222
+ * ya clasificado (mismo tipo, mismo `code`, misma causa).
223
+ */
224
+ function withPreserveRecipe(error) {
225
+ error.message +=
226
+ `. 'grant' sin 'expiresAt' necesita leer la caducidad vigente para preservarla y no se ` +
227
+ `escribe a ciegas: si la intención es "permanente", pasa { expiresAt: null }; si es temporal, una Date.`;
228
+ return error;
229
+ }
230
+ /**
231
+ * Devuelve el cliente con TODOS sus métodos envueltos: un fallo de red o un
232
+ * 5xx sale como `AuthorizationBackendError` (503) y no como el `FgaError` del
233
+ * SDK, que acoplaría el call-site al backend que este paquete abstrae.
234
+ *
235
+ * Se envuelve el cliente entero, y no llamada por llamada, a propósito: con
236
+ * once puntos de invocación lo fácil es olvidar uno, y quien añada el número
237
+ * doce no tendría por qué saber que debe envolverlo. Así la garantía se
238
+ * cumple por construcción en vez de por disciplina.
239
+ *
240
+ * Solo lo usa el driver. `provisionOpenFgaStore` y el importador son
241
+ * herramientas explícitamente de OpenFGA —las invocas por su nombre—, así que
242
+ * ahí el error del SDK es la información más útil y no rompe ninguna
243
+ * abstracción.
244
+ */
245
+ function guardBackendErrors(client, timeoutMs) {
246
+ return new Proxy(client, {
247
+ get(target, prop, receiver) {
248
+ const value = Reflect.get(target, prop, receiver);
249
+ if (typeof value !== 'function')
250
+ return value;
251
+ return (...args) => {
252
+ const operation = String(prop);
253
+ const fail = (cause) => isTimeoutLike(cause)
254
+ ? new AuthorizationBackendTimeoutError('openfga', operation, timeoutMs, cause)
255
+ : new AuthorizationBackendError('openfga', operation, cause);
256
+ try {
257
+ const result = value.apply(target, args);
258
+ if (!(result instanceof Promise))
259
+ return result;
260
+ // Deadline TOTAL por llamada (reintentos del SDK incluidos): el
261
+ // `timeout` de axios corta cada intento, pero el SDK reintenta los
262
+ // errores de red con backoff y sin esto el llamante esperaría la
263
+ // suma de todos. Un deadline es un deadline.
264
+ return withDeadline(result.catch((error) => {
265
+ throw fail(error);
266
+ }), timeoutMs, () => new AuthorizationBackendTimeoutError('openfga', operation, timeoutMs));
267
+ }
268
+ catch (error) {
269
+ throw fail(error);
270
+ }
271
+ };
272
+ },
273
+ });
274
+ }
275
+ export const DEFAULT_TIMEOUT_MS = 5_000;
276
+ /** Tope de operaciones por `Write` en FGA (verificado: `exceeded_entity_limit` a partir de 100). */
277
+ const PURGE_BATCH_SIZE = 100;
278
+ /** Tamaño de página de `Read` (máximo del servidor). */
279
+ const READ_PAGE_SIZE = 100;
280
+ /**
281
+ * Cota de páginas de una enumeración (1.000.000 de tuplas a 100 por página).
282
+ * Un `continuation_token` que no avanza —un servidor roto, un proxy o una
283
+ * caché delante— era un bucle infinito que ningún deadline cortaba, porque
284
+ * el deadline es por llamada (D12, auditor H7).
285
+ */
286
+ export const MAX_READ_PAGES = 10_000;
287
+ /**
288
+ * Recorre TODAS las páginas de un `Read` con la misma cota que el driver
289
+ * (D12): token repetido o más de `MAX_READ_PAGES` páginas ⇒ 500. Lo usa el
290
+ * importador, que trabaja con un cliente crudo (herramienta FGA).
291
+ */
292
+ async function* readPages(client, filter) {
293
+ let continuationToken;
294
+ const seen = new Set();
295
+ let pages = 0;
296
+ do {
297
+ const response = await client.read(filter, {
298
+ pageSize: READ_PAGE_SIZE,
299
+ continuationToken,
300
+ consistency: ConsistencyPreference.HigherConsistency,
301
+ });
302
+ pages += 1;
303
+ for (const tuple of response.tuples ?? [])
304
+ yield tuple;
305
+ continuationToken = response.continuation_token || undefined;
306
+ if (continuationToken) {
307
+ if (seen.has(continuationToken)) {
308
+ throw new AuthorizationInternalError(`Read: el continuation_token se repite (página ${pages})`);
309
+ }
310
+ if (pages >= MAX_READ_PAGES) {
311
+ throw new AuthorizationInternalError(`Read: más de ${MAX_READ_PAGES} páginas sin agotar el continuation_token`);
312
+ }
313
+ seen.add(continuationToken);
314
+ }
315
+ } while (continuationToken);
316
+ }
317
+ /** Clave textual de una tupla, para comparar conjuntos. */
318
+ function tupleId(key) {
319
+ return `${key.user}#${key.relation}@${key.object}`;
320
+ }
321
+ function tupleOf(fact) {
322
+ return fact.expiresAt
323
+ ? {
324
+ ...fact.key,
325
+ condition: { name: 'not_expired', context: { valid_until: fact.expiresAt.toISOString() } },
326
+ }
327
+ : fact.key;
328
+ }
329
+ /** Tope de tuplas por `Write` transaccional de FGA. */
330
+ const IMPORT_BATCH_SIZE = 100;
135
331
  /**
136
332
  * Migración de hechos database → openfga: copia las asignaciones vigentes y
137
333
  * los denies de las tablas `authz_*` como tuples del store FGA.
@@ -140,225 +336,528 @@ export async function provisionOpenFgaStore(apiUrl, name, holderTypeMap) {
140
336
  * volver a AUTHZ_DRIVER=database (solo se pierde lo escrito mientras se
141
337
  * operó con openfga). El catálogo y la jerarquía nunca migran: son
142
338
  * metadata local para ambos drivers.
143
- * - Idempotente: re-ejecutar no duplica (onDuplicateWrites: Ignore).
144
339
  * - Las asignaciones ya expiradas se saltan (no tiene sentido copiarlas);
145
340
  * las de expiración futura viajan con la condition `not_expired`.
341
+ * - NUNCA `onDuplicateWrites: Ignore` (S7): en FGA la condición no es parte
342
+ * de la clave, así que "ignorar el duplicado" dejaba la caducidad vieja y
343
+ * reportaba éxito. Un store con tuplas exige `reconcile`, que compara
344
+ * tupla a tupla, reescribe las que difieren y cuenta las que SQL no tiene
345
+ * (`extra`); con `prune` las borra (`deleted`) y el reconcile converge
346
+ * (D14). Nunca silencioso: el reporte distingue written / updated /
347
+ * unchanged / extra / deleted / skippedExpired.
348
+ *
349
+ * Herramienta explícitamente de OpenFGA: los errores del SDK salen crudos.
146
350
  */
147
351
  export async function importAuthzFactsToOpenFga(options) {
352
+ assertHolderTypes(options.holderTypes);
353
+ if (options.prune && !options.reconcile) {
354
+ throw new AuthorizationConfigError('openfga:import: `prune` solo tiene sentido con `reconcile` (es lo que sobra respecto a SQL lo que se borra)');
355
+ }
148
356
  const client = new OpenFgaClient({
149
357
  apiUrl: options.apiUrl,
150
358
  storeId: options.storeId,
151
359
  authorizationModelId: options.modelId,
152
360
  });
153
- const now = new Date();
361
+ const now = (options.now ?? systemClock)();
154
362
  const result = {
155
- assignments: 0,
156
- denies: 0,
363
+ written: 0,
364
+ updated: 0,
365
+ unchanged: 0,
366
+ extra: 0,
367
+ deleted: 0,
157
368
  skippedExpired: 0,
158
369
  dryRun: options.dryRun ?? false,
159
370
  };
160
- const tuples = [];
161
- const assignments = await db
162
- .from('authz_assignments as a')
163
- .join('authz_roles as r', 'r.uuid', 'a.role_uuid')
164
- .select('a.holder_type', 'a.holder_uuid', 'a.scope_type', 'a.scope_uuid', 'a.expires_at')
165
- .select('r.slug as role_slug');
166
371
  const rowScope = (row) => ({
167
372
  type: row.scope_type,
168
373
  uuid: row.scope_uuid === APP_SCOPE_DB_UUID ? null : row.scope_uuid,
169
374
  });
375
+ const facts = [];
376
+ // `expires_at` se lee con el codec del motor (2.5-B · K2): en MySQL como
377
+ // cadena UTC vía DATE_FORMAT, sin depender de la zona del cliente.
378
+ const expiry = sqlExpiryCodec(db.connection());
379
+ const assignments = await db
380
+ .from('authz_assignments as a')
381
+ .join('authz_roles as r', 'r.uuid', 'a.role_uuid')
382
+ .select('a.holder_type', 'a.holder_uuid', 'a.scope_type', 'a.scope_uuid', expiry.select('a.expires_at', 'expires_at'))
383
+ .select('r.uuid as role_uuid');
170
384
  for (const row of assignments) {
171
- const expiresAt = row.expires_at ? new Date(row.expires_at) : null;
385
+ const expiresAt = expiry.fromDb(row.expires_at);
172
386
  if (expiresAt && expiresAt <= now) {
173
387
  result.skippedExpired++;
174
388
  continue;
175
389
  }
176
- const scope = rowScope(row);
177
- const key = {
178
- user: fgaSubjectWith({ type: row.holder_type, uuid: row.holder_uuid }, options.holderTypes),
179
- relation: 'assignee',
180
- object: `role_binding:${scopeKey(scope)}|${encodeSlug(row.role_slug)}`,
181
- };
182
- tuples.push(expiresAt
183
- ? {
184
- ...key,
185
- condition: { name: 'not_expired', context: { valid_until: expiresAt.toISOString() } },
186
- }
187
- : key);
188
- result.assignments++;
390
+ facts.push({
391
+ key: {
392
+ user: fgaSubjectWith({ type: row.holder_type, uuid: row.holder_uuid }, options.holderTypes),
393
+ relation: 'assignee',
394
+ object: `role_binding:${scopeKey(rowScope(row))}|${row.role_uuid}`,
395
+ },
396
+ expiresAt,
397
+ });
189
398
  }
190
399
  const denies = await db
191
400
  .from('authz_denies as d')
192
401
  .join('authz_permissions as p', 'p.uuid', 'd.permission_uuid')
193
402
  .select('d.holder_type', 'd.holder_uuid', 'd.scope_type', 'd.scope_uuid')
194
- .select('p.slug as permission_slug');
403
+ .select('p.uuid as permission_uuid');
195
404
  for (const row of denies) {
196
- const scope = rowScope(row);
197
- tuples.push({
198
- user: fgaSubjectWith({ type: row.holder_type, uuid: row.holder_uuid }, options.holderTypes),
199
- relation: 'denied',
200
- object: `deny_binding:${scopeKey(scope)}|${encodeSlug(row.permission_slug)}`,
405
+ facts.push({
406
+ key: {
407
+ user: fgaSubjectWith({ type: row.holder_type, uuid: row.holder_uuid }, options.holderTypes),
408
+ relation: 'denied',
409
+ object: `deny_binding:${scopeKey(rowScope(row))}|${row.permission_uuid}`,
410
+ },
411
+ expiresAt: null,
201
412
  });
202
- result.denies++;
203
413
  }
204
- if (!result.dryRun && tuples.length > 0) {
205
- // Chunks: el write transaccional de FGA tiene límite de tuples por request.
206
- for (let i = 0; i < tuples.length; i += 50) {
207
- await client.writeTuples(tuples.slice(i, i + 50), {
208
- conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore },
209
- });
414
+ // ¿Store vacío? Un Read sin filtro devuelve cualquier tupla que haya.
415
+ const probe = await client.read({}, { pageSize: 1 });
416
+ const storeIsEmpty = (probe.tuples ?? []).length === 0;
417
+ if (!storeIsEmpty && !options.reconcile) {
418
+ throw new StoreNotEmptyError(`El store ${options.storeId} ya tiene tuplas. Importar encima sin comparar dejaría ` +
419
+ `caducidades viejas en pie: usa --reconcile (compara tupla a tupla) o un store nuevo.`);
420
+ }
421
+ const toWrite = [];
422
+ const toReplace = [];
423
+ const toDelete = [];
424
+ if (storeIsEmpty) {
425
+ toWrite.push(...facts);
426
+ }
427
+ else {
428
+ for (const fact of facts) {
429
+ const stored = await client.read(fact.key);
430
+ const tuple = stored.tuples?.[0];
431
+ if (!tuple) {
432
+ toWrite.push(fact);
433
+ continue;
434
+ }
435
+ const storedExpiry = toExpiryDate(tuple.key?.condition?.context?.valid_until);
436
+ if (sameInstant(storedExpiry, fact.expiresAt))
437
+ result.unchanged++;
438
+ else
439
+ toReplace.push(fact);
440
+ }
441
+ // Lo que el store tiene de MÁS (D14): se lee entero y se resta el
442
+ // conjunto de SQL. Solo los objetos del motor; nada más vive en este
443
+ // modelo, pero si algo hubiera no es asunto del importador.
444
+ const wanted = new Set(facts.map((f) => tupleId(f.key)));
445
+ for await (const tuple of readPages(client, {})) {
446
+ const k = tuple?.key;
447
+ if (!k?.user || !k?.relation || !k?.object)
448
+ continue;
449
+ if (!/^(role_binding|deny_binding):/.test(k.object))
450
+ continue;
451
+ const key = { user: k.user, relation: k.relation, object: k.object };
452
+ if (!wanted.has(tupleId(key)))
453
+ toDelete.push(key);
454
+ }
455
+ }
456
+ result.written = toWrite.length;
457
+ result.updated = toReplace.length;
458
+ result.extra = toDelete.length;
459
+ result.deleted = options.prune ? toDelete.length : 0;
460
+ if (result.dryRun)
461
+ return result;
462
+ // Sin Ignore: en un store vacío un duplicado es un bug (dos filas de SQL
463
+ // con la misma clave, imposible por el unique) y debe verse.
464
+ for (let i = 0; i < toWrite.length; i += IMPORT_BATCH_SIZE) {
465
+ await client.writeTuples(toWrite.slice(i, i + IMPORT_BATCH_SIZE).map(tupleOf));
466
+ }
467
+ // delete + write no caben en una misma request para la misma clave.
468
+ for (let i = 0; i < toReplace.length; i += IMPORT_BATCH_SIZE) {
469
+ const batch = toReplace.slice(i, i + IMPORT_BATCH_SIZE);
470
+ await client.deleteTuples(batch.map((f) => f.key));
471
+ await client.writeTuples(batch.map(tupleOf));
472
+ }
473
+ if (options.prune) {
474
+ for (let i = 0; i < toDelete.length; i += IMPORT_BATCH_SIZE) {
475
+ await client.deleteTuples(toDelete.slice(i, i + IMPORT_BATCH_SIZE));
210
476
  }
211
477
  }
212
478
  return result;
213
479
  }
214
480
  export class OpenFgaAuthorizationDriver {
215
481
  client;
216
- resolveAncestors;
482
+ chainResolver;
217
483
  holderTypes;
484
+ /**
485
+ * Contadores observables del driver. `unparseableBindings`: ids del store
486
+ * que el motor no entiende (L0.16). Cada uno es un hecho que las
487
+ * enumeraciones NO muestran; se registra y se cuenta, jamás un `continue`
488
+ * mudo — quien opera el store tiene que poder verlo.
489
+ */
490
+ diagnostics = { unparseableBindings: 0 };
491
+ logger;
492
+ timeoutMs;
493
+ consistency;
494
+ /** Reloj de pared del driver (J1): el ÚNICO `now` de checks, filtros y re-grant. */
495
+ now;
496
+ /**
497
+ * Memo del catálogo (2A): permisos, roles por nivel y roles que conceden
498
+ * cada permiso se leen de aquí en el camino caliente; antes eran dos
499
+ * consultas SQL por `authorize`. Los hechos siguen en FGA en cada pregunta.
500
+ * Se revalida contra `authz_catalog_version` (2D · F1): cada operación
501
+ * toma la foto UNA vez (`view()`) y lee de ella todo lo que necesita.
502
+ * `catalog.invalidate()` fuerza la recarga de ESTE memo.
503
+ */
504
+ catalog;
218
505
  constructor(options) {
219
- this.client = new OpenFgaClient({
506
+ assertHolderTypes(options.holderTypes);
507
+ assertCatalogOptions('OpenFgaAuthorizationDriver', options);
508
+ if (options.now !== undefined && !isClock(options.now)) {
509
+ throw new AuthorizationConfigError(`OpenFgaAuthorizationDriver: 'now' debe ser una función () => Date (llegó ${typeof options.now})`);
510
+ }
511
+ this.now = options.now ?? systemClock;
512
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
513
+ this.catalog =
514
+ options.catalog ?? new CatalogCache({ driver: 'openfga', timeoutMs, revalidate: options.catalogRevalidate });
515
+ this.client = guardBackendErrors(new OpenFgaClient({
220
516
  apiUrl: options.apiUrl,
221
517
  storeId: options.storeId,
222
518
  authorizationModelId: options.modelId,
223
- });
224
- this.resolveAncestors =
225
- options.resolveAncestors ??
226
- (async (scope) => (scope.type === APP_SCOPE_TYPE ? [] : [APP_SCOPE]));
519
+ // `baseOptions` se funde en la config de axios de cada request: es la
520
+ // única vía del SDK (no tiene `timeoutMs` propio; su default es 10 s).
521
+ baseOptions: { timeout: timeoutMs },
522
+ // Sin reintentos a escondidas (ver `OpenFgaDriverOptions.retryParams`).
523
+ retryParams: { maxRetry: 0, ...options.retryParams },
524
+ }), timeoutMs);
525
+ // Sin resolutor solo existe la raíz (L0.3: el default plano desapareció).
526
+ this.chainResolver = options.resolveChain ?? rootOnlyResolver;
227
527
  this.holderTypes = options.holderTypes;
528
+ this.logger = options.logger ?? console;
529
+ this.timeoutMs = timeoutMs;
530
+ this.consistency =
531
+ options.consistency === 'minimize_latency'
532
+ ? ConsistencyPreference.MinimizeLatency
533
+ : ConsistencyPreference.HigherConsistency;
534
+ }
535
+ /**
536
+ * `[scope canónico, ...ancestros]`, o `null` si el scope no existe
537
+ * (lecturas: denegar). `chain[0]` —la fila del consumidor, no lo que
538
+ * escribió el llamante— es el scope que va en la clave de cada binding
539
+ * (2.5-B · K1): un alias del uuid que el árbol funde con la fila real
540
+ * llega aquí ya canónico y el `deny_binding` escrito canónico casa.
541
+ */
542
+ chain(scope, operation) {
543
+ return resolveChain(this.chainResolver, scope, operation);
544
+ }
545
+ /** La cadena o 422: una escritura no puede ir a un scope que nadie reconoce. */
546
+ knownScope(scope, operation) {
547
+ return assertKnownScope(this.chainResolver, scope, operation);
548
+ }
549
+ /** El scope canónico para `revoke`/`removeDeny`/`purgeScope` (ver `canonicalScope`). */
550
+ canonicalOrSelf(scope, operation) {
551
+ return canonicalScope(this.chainResolver, scope, operation);
552
+ }
553
+ /**
554
+ * Vista de este driver con OTRO resolutor de ancestros y el mismo estado
555
+ * (cliente, memo del catálogo, deadline, diagnósticos). Es lo que usa
556
+ * `AuthorizationManager.forRequest()` para leer con un resolutor memoizado
557
+ * sin tocar el driver compartido: hereda por prototipo y solo sobrescribe
558
+ * el resolutor.
559
+ */
560
+ withChainResolver(resolveChain) {
561
+ const view = Object.create(this);
562
+ view.chainResolver = resolveChain;
563
+ return view;
228
564
  }
229
- async chain(scope) {
230
- return [scope, ...(await this.resolveAncestors(scope))];
565
+ /**
566
+ * Vista de este driver con OTRO reloj de pared (2.5 · J1): mismo cliente,
567
+ * store, memo y resolutor; solo cambia el `now` que viaja como
568
+ * `current_time` y filtra las enumeraciones. Lo aplica el manager con
569
+ * `config.clock` y el juez para fijar el instante.
570
+ */
571
+ withClock(now) {
572
+ if (!isClock(now)) {
573
+ throw new AuthorizationConfigError(`withClock: now debe ser una función () => Date (llegó ${typeof now})`);
574
+ }
575
+ const view = Object.create(this);
576
+ view.now = now;
577
+ return view;
578
+ }
579
+ /**
580
+ * El `context` de los checks de UNA operación, con el reloj de ESTE driver
581
+ * (o vista): se construye una vez por operación y viaja en todos sus
582
+ * checks (2.5-B · K9). Antes se leía el reloj por check y un mismo
583
+ * `authorize` evaluaba el deny en un instante y el rol en otro.
584
+ */
585
+ checkContext() {
586
+ return checkContext(this.now());
587
+ }
588
+ /**
589
+ * Consulta al catálogo local clasificando su fallo. Con este driver el
590
+ * catálogo SQL sigue siendo una dependencia dura de cada pregunta: su caída
591
+ * era un error crudo de Lucid que se presentaba como bug de aplicación (N3).
592
+ */
593
+ sql(operation, fn) {
594
+ return guardSql('openfga', operation, this.timeoutMs, fn);
231
595
  }
232
596
  fgaSubject(subject) {
233
597
  return fgaSubjectWith(subject, this.holderTypes);
234
598
  }
235
599
  /**
236
- * batchCheck troceado al límite del servidor FGA (50 checks/request) y con
237
- * verificación de completitud: TODOS los checks deben volver respondidos.
600
+ * Un batchCheck con TODOS los checks (el SDK trocea a 50 por request y
601
+ * paraleliza), cada uno con un `correlationId` propio, y la respuesta
602
+ * alineada por ese id: un resultado por check, ni uno más ni uno menos.
238
603
  */
239
604
  async batchCheckAll(checks) {
240
- const results = [];
241
- for (let i = 0; i < checks.length; i += 50) {
242
- const slice = checks.slice(i, i + 50);
243
- const response = await this.client.batchCheck({ checks: slice });
244
- results.push(...response.result);
245
- }
246
- if (results.length !== checks.length) {
247
- throw new Exception('OpenFGA batchCheck devolvió menos resultados que checks', {
248
- status: 500,
249
- });
605
+ if (checks.length === 0)
606
+ return [];
607
+ const withIds = checks.map((check, index) => ({
608
+ ...check,
609
+ correlationId: String(index),
610
+ }));
611
+ const response = await this.client.batchCheck({ checks: withIds }, { consistency: this.consistency });
612
+ const results = correlateBatchResults(withIds, response.result);
613
+ // Un 200 con `error` en un check individual (`input_error`,
614
+ // `internal_error`…) es una caída parcial del backend, no un "sin
615
+ // permiso": se clasifica igual que un 5xx (invariante 5, D1). Antes la
616
+ // fase de roles y `hasRole` lo colapsaban en `false`.
617
+ const failed = results.find((r) => r.error);
618
+ if (failed) {
619
+ throw new AuthorizationBackendError('openfga', `batchCheck (${failed.request?.relation} ${failed.request?.object})`, failed.error);
250
620
  }
251
621
  return results;
252
622
  }
253
- // ── Catálogo local (compartido entre drivers) ─────────────────────────
623
+ // ── Catálogo local (compartido entre drivers), desde el memo (2A) ──────
624
+ // Una carga por driver/proceso en vez de una o dos consultas SQL por
625
+ // pregunta, más una revalidación por operación (2D · F1). Un fallo de
626
+ // carga sale como 503, igual que antes.
254
627
  async findPermission(slug) {
255
- return db.from('authz_permissions').where('slug', slug).select('uuid').first();
256
- }
257
- async findRoleOrFail(slug, scopeType) {
258
- const role = await db
259
- .from('authz_roles')
260
- .where('slug', slug)
261
- .where('scope_type', scopeType)
262
- .select('uuid')
263
- .first();
264
- if (!role) {
265
- throw new Exception(`Rol '${slug}' no existe en el catálogo para el nivel '${scopeType}'`, {
266
- status: 422,
267
- });
268
- }
628
+ return (await this.catalog.view()).permission(slug);
269
629
  }
270
- /** Roles del catálogo que conceden el permiso, agrupados por scope_type. */
271
- async rolesGranting(permissionUuid) {
272
- const rows = await db
273
- .from('authz_role_permissions as rp')
274
- .join('authz_roles as r', 'r.uuid', 'rp.role_uuid')
275
- .where('rp.permission_uuid', permissionUuid)
276
- .select('r.slug', 'r.scope_type');
277
- const byScopeType = new Map();
278
- for (const row of rows) {
279
- const list = byScopeType.get(row.scope_type) ?? [];
280
- list.push(row.slug);
281
- byScopeType.set(row.scope_type, list);
282
- }
283
- return byScopeType;
630
+ /**
631
+ * Filtro por catálogo de las lecturas de membresía (`listRoles`,
632
+ * `listRoleScopes`, `rolesInChain`, `listScopes`): un binding cuyo uuid ya
633
+ * no está en `authz_roles` —o está, pero declarado para OTRO nivel, o es
634
+ * local a un scope que no está en la cadena del binding (3B · B2)— es una
635
+ * tupla huérfana, no una membresía; igual que en `database`, donde el
636
+ * catálogo lo excluye (D5). La tupla la recoge `authz:reconcile`. Desde 3A
637
+ * la resolución es por uuid (`roleByUuid`), nunca por slug. `chainKeys` es
638
+ * la cadena del scope del BINDING (desde él hacia la raíz).
639
+ */
640
+ declaredRole(catalog, binding, chainKeys) {
641
+ return declaredRoleAt(catalog, binding.uuid, binding.scope.type, chainKeys);
284
642
  }
285
643
  // ── Contrato ──────────────────────────────────────────────────────────
644
+ /**
645
+ * Los checks de UNA pregunta (subject, permiso, cadena): los denies de cada
646
+ * nivel y los roles del catálogo que conceden el permiso en cada nivel.
647
+ * Sin rol que conceda ⇒ `null`: la respuesta es `false` digan lo que digan
648
+ * los denies y no se pregunta al backend (2A).
649
+ */
650
+ checksFor(user, permissionUuid, chain, granting, context) {
651
+ // Por nivel, solo los roles que EXISTEN ahí (3B · B2): globales, o locales
652
+ // a un scope de la cadena desde ese nivel. Un rol de otro tenant no
653
+ // cuesta un check (mismo número de checks que sin roles locales).
654
+ const keysFrom = chainKeysFrom(chain);
655
+ const roles = chain.flatMap((s, i) => (granting.get(s.type) ?? [])
656
+ .filter((role) => isRoleVisibleWith(role, keysFrom[i]))
657
+ .map((role) => ({
658
+ user,
659
+ relation: 'assignee',
660
+ object: `role_binding:${scopeKey(s)}|${role.uuid}`,
661
+ context,
662
+ })));
663
+ if (roles.length === 0)
664
+ return null;
665
+ const denies = chain.map((s) => ({
666
+ user,
667
+ relation: 'denied',
668
+ object: `deny_binding:${scopeKey(s)}|${permissionUuid}`,
669
+ context,
670
+ }));
671
+ return { denies, roles };
672
+ }
286
673
  async authorize(subject, permission, scope) {
287
- const perm = await this.findPermission(permission);
674
+ assertIdentity({ subject, permission, scope });
675
+ // Una foto del catálogo por pregunta: permiso y roles que conceden salen
676
+ // de la misma versión (y se paga una sola revalidación).
677
+ const catalog = await this.catalog.view();
678
+ const perm = catalog.permission(permission);
288
679
  if (!perm)
289
680
  return false;
290
681
  const user = this.fgaSubject(subject);
291
- const chain = await this.chain(scope);
292
- // 1. Denies en la cadena. FAIL-CLOSED: un check de deny con error se
293
- // trata como denegado (jamás se ignora un deny por un fallo puntual).
294
- const denyChecks = chain.map((s) => ({
295
- user,
296
- relation: 'denied',
297
- object: `deny_binding:${scopeKey(s)}|${encodeSlug(permission)}`,
298
- }));
299
- const denyResults = await this.batchCheckAll(denyChecks);
300
- if (denyResults.some((r) => r.allowed || r.error))
682
+ const chain = await this.chain(scope, 'authorize');
683
+ if (!chain)
301
684
  return false;
302
- // 2. Alguna asignación vigente en la cadena cuyo rol concede el permiso.
303
- // Aquí un error individual falla cerrado por solo (no concede).
304
- const granting = await this.rolesGranting(perm.uuid);
305
- const checks = chain.flatMap((s) => (granting.get(s.type) ?? []).map((roleSlug) => ({
306
- user,
307
- relation: 'assignee',
308
- object: `role_binding:${scopeKey(s)}|${encodeSlug(roleSlug)}`,
309
- context: checkContext(),
310
- })));
311
- if (checks.length === 0)
685
+ // Si ningún rol de la cadena concede el permiso, la respuesta es `false`
686
+ // digan lo que digan los denies: no se pregunta al backend.
687
+ const checks = this.checksFor(user, perm.uuid, chain, catalog.rolesGranting(perm.uuid), this.checkContext());
688
+ if (!checks)
312
689
  return false;
313
- const results = await this.batchCheckAll(checks);
314
- return results.some((r) => r.allowed && !r.error);
690
+ // UN solo batchCheck (2A): los denies de la cadena y los roles que
691
+ // conceden van en la misma request; el SDK trocea a 50 y paraleliza.
692
+ // Regla, en este orden: cualquier `error` ⇒ 503 (D1, dentro de
693
+ // `batchCheckAll`, antes de mirar nada); algún deny `allowed` ⇒ false;
694
+ // algún rol `allowed` ⇒ true. Antes eran dos requests secuenciales
695
+ // (denies, luego roles) con la misma regla.
696
+ const results = await this.batchCheckAll([...checks.denies, ...checks.roles]);
697
+ if (results.slice(0, checks.denies.length).some((r) => r.allowed))
698
+ return false;
699
+ return results.slice(checks.denies.length).some((r) => r.allowed);
700
+ }
701
+ /**
702
+ * `authorize` sobre N scopes con UN batchCheck (2.1, B6): los checks de
703
+ * todas las cadenas viajan juntos (el SDK trocea a 50 y paraleliza) y se
704
+ * atribuyen a su scope por posición dentro del lote correlacionado
705
+ * (L0.14). Misma regla que `authorize`, por scope: `error` en cualquier
706
+ * check ⇒ 503 entero (D1); deny `allowed` ⇒ false; rol `allowed` ⇒ true.
707
+ * Scope desconocido o sin rol que conceda ⇒ false sin checks.
708
+ */
709
+ async authorizeMany(subject, permission, scopes) {
710
+ assertIdentity({ subject, permission });
711
+ for (const scope of scopes)
712
+ assertIdentity({ scope });
713
+ if (scopes.length === 0)
714
+ return [];
715
+ const catalog = await this.catalog.view();
716
+ const perm = catalog.permission(permission);
717
+ if (!perm)
718
+ return scopes.map(() => false);
719
+ const user = this.fgaSubject(subject);
720
+ const granting = catalog.rolesGranting(perm.uuid);
721
+ // Un instante para todo el lote (K9): N scopes, una pregunta.
722
+ const context = this.checkContext();
723
+ const batch = [];
724
+ /** Por posición: `null` = false sin preguntar; si no, [inicio, nºDenies, nºRoles] dentro del lote. */
725
+ const slots = [];
726
+ // Un scope repetido comparte slot (y cadena) con su primera aparición
727
+ // (G2, CR9): mismos checks, misma respuesta por posición, sin duplicar
728
+ // el lote.
729
+ const slotByScope = new Map();
730
+ for (const scope of scopes) {
731
+ const scopeId = scopeKey(scope);
732
+ if (slotByScope.has(scopeId)) {
733
+ slots.push(slotByScope.get(scopeId));
734
+ continue;
735
+ }
736
+ const chain = await this.chain(scope, 'authorizeMany');
737
+ const checks = chain ? this.checksFor(user, perm.uuid, chain, granting, context) : null;
738
+ const slot = checks ? [batch.length, checks.denies.length, checks.roles.length] : null;
739
+ if (checks)
740
+ batch.push(...checks.denies, ...checks.roles);
741
+ slotByScope.set(scopeId, slot);
742
+ slots.push(slot);
743
+ }
744
+ const results = await this.batchCheckAll(batch);
745
+ return slots.map((slot) => {
746
+ if (!slot)
747
+ return false;
748
+ const [start, denies, roles] = slot;
749
+ if (results.slice(start, start + denies).some((r) => r.allowed))
750
+ return false;
751
+ return results.slice(start + denies, start + denies + roles).some((r) => r.allowed);
752
+ });
315
753
  }
316
754
  async grant(subject, role, scope, options = {}) {
317
- await this.findRoleOrFail(role, scope.type);
755
+ assertIdentity({ subject, role, scope, expiresAt: options.expiresAt });
756
+ // El binding lleva la identidad canónica del árbol (K1), nunca la forma
757
+ // del llamante, y el uuid del rol, nunca su slug (3A · A1). El rol tiene
758
+ // que EXISTIR en ese scope (3B · B2: global, o local a un ancestro-o-igual)
759
+ // con una composición legal (B5).
760
+ const chain = await this.knownScope(scope, 'grant');
761
+ const [target] = chain;
762
+ const catalog = await this.catalog.view();
763
+ const declared = resolveRoleQuery(catalog, role, target, chainKeysFrom(chain)[0]);
764
+ assertRoleAssignableAt(catalog, declared);
765
+ const roleUuid = declared.uuid;
318
766
  const key = {
319
767
  user: this.fgaSubject(subject),
320
768
  relation: 'assignee',
321
- object: `role_binding:${scopeKey(scope)}|${encodeSlug(role)}`,
769
+ object: `role_binding:${scopeKey(target)}|${roleUuid}`,
322
770
  };
323
- const write = options.expiresAt
771
+ const tupleFor = (expiresAt) => expiresAt
324
772
  ? {
325
773
  ...key,
326
774
  condition: {
327
775
  name: 'not_expired',
328
- context: { valid_until: options.expiresAt.toISOString() },
776
+ context: { valid_until: expiresAt.toISOString() },
329
777
  },
330
778
  }
331
779
  : key;
332
780
  // FGA no admite delete+write de la misma tuple key en una transacción, así
333
- // que refrescar la expiración obliga a dos llamadas — y entre ellas hay un
781
+ // que cambiar la expiración obliga a dos llamadas — y entre ellas hay un
334
782
  // instante en el que authorize() responde false.
335
783
  //
336
784
  // Se mira primero qué hay, para NO pagar esa ventana cuando no hace falta:
337
785
  // - si no existe la tuple → solo write (el caso del primer grant);
338
- // - si existe idéntica → no-op (re-ejecutar un seeder no toca nada);
339
- // - solo si la expiración CAMBIA de verdad se hace delete+write.
340
- // La ventana pasa de "cada re-grant" a "cuando el llamante quiso cambiarla".
786
+ // - si existe con la caducidad que toca → no-op (un seeder no toca nada);
787
+ // - solo si la caducidad CAMBIA de verdad se hace delete+write.
788
+ // Y la lectura es lo que hace posible "omitido = preservar" (L0.4).
341
789
  const current = await this.readAssignment(key);
342
- // Solo se salta la escritura si SABEMOS que lo almacenado ya es esto.
343
- if (current.kind === 'present' && sameExpiry(current.validUntil, options.expiresAt))
344
- return;
345
- if (current.kind === 'absent') {
346
- // No había nada: un write basta y no hay ventana de denegación. Si entre
347
- // el read y el write otro proceso escribió la misma key, este write
348
- // choca y entonces sí toca el camino largo, para que gane el último
349
- // escritor y no se pierda esta expiración en silencio.
350
- try {
351
- await this.client.writeTuples([write]);
352
- return;
790
+ if (current.kind === 'unknown') {
791
+ // Sin lectura no hay forma de preservar una caducidad vigente: asumir
792
+ // "permanente" sería exactamente el defecto en modo degradado. Con un
793
+ // objetivo explícito (`Date`/`null`) sí se puede escribir a ciegas.
794
+ if (options.expiresAt === undefined)
795
+ throw withPreserveRecipe(current.error);
796
+ const expiresAt = options.expiresAt;
797
+ const existed = await this.writeAssignment(key, tupleFor(expiresAt));
798
+ return { existed, expiresAt };
799
+ }
800
+ if (current.kind === 'present') {
801
+ const expiresAt = resolveGrantExpiry(current.validUntil, options.expiresAt, this.now());
802
+ if (sameInstant(current.validUntil, expiresAt)) {
803
+ return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
804
+ }
805
+ await this.replaceAssignment(key, tupleFor(expiresAt));
806
+ return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
807
+ }
808
+ // No había nada: un write basta y no hay ventana de denegación. Si entre
809
+ // el read y el write otro proceso escribió la misma key, este write
810
+ // choca con un 409 — entonces se relee y se aplica el re-grant sobre lo
811
+ // que quedó, para que gane el último escritor y no se pierda esta
812
+ // caducidad. Cualquier otro fallo del write no es una carrera (D6).
813
+ const expiresAt = options.expiresAt ?? null;
814
+ try {
815
+ await this.client.writeTuples([tupleFor(expiresAt)]);
816
+ return { existed: false, expiresAt };
817
+ }
818
+ catch (error) {
819
+ if (!isDuplicateWrite(error))
820
+ throw error;
821
+ const raced = await this.readAssignment(key);
822
+ if (raced.kind === 'present') {
823
+ const target = resolveGrantExpiry(raced.validUntil, options.expiresAt, this.now());
824
+ if (!sameInstant(raced.validUntil, target))
825
+ await this.replaceAssignment(key, tupleFor(target));
826
+ return { existed: true, previousExpiresAt: raced.validUntil, expiresAt: target };
353
827
  }
354
- catch {
355
- // cae al delete+write de abajo
828
+ if (options.expiresAt === undefined) {
829
+ // El write chocó y la relectura no ve la tupla (o falló): sin objetivo
830
+ // explícito no se sabe qué preservar. El 409 original va como causa.
831
+ throw withPreserveRecipe(raced.kind === 'unknown'
832
+ ? raced.error
833
+ : new AuthorizationBackendError('openfga', 'grant (el write chocó y la relectura no ve la tupla)', error));
356
834
  }
835
+ const existed = await this.writeAssignment(key, tupleFor(options.expiresAt));
836
+ return { existed, expiresAt: options.expiresAt };
357
837
  }
838
+ }
839
+ /**
840
+ * Write directo; si la key ya existía (409), camino largo. Devuelve si
841
+ * existía. Cualquier otro fallo se propaga tal cual (ya clasificado).
842
+ */
843
+ async writeAssignment(key, tuple) {
844
+ try {
845
+ await this.client.writeTuples([tuple]);
846
+ return false;
847
+ }
848
+ catch (error) {
849
+ if (!isDuplicateWrite(error))
850
+ throw error;
851
+ await this.replaceAssignment(key, tuple);
852
+ return true;
853
+ }
854
+ }
855
+ /** delete + write (dos llamadas: FGA no admite ambas sobre la misma key en una). */
856
+ async replaceAssignment(key, tuple) {
358
857
  await this.client.deleteTuples([key], {
359
858
  conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
360
859
  });
361
- await this.client.writeTuples([write], {
860
+ await this.client.writeTuples([tuple], {
362
861
  conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore },
363
862
  });
364
863
  }
@@ -367,144 +866,433 @@ export class OpenFgaAuthorizationDriver {
367
866
  *
368
867
  * Distinguir `unknown` de `present` sin condición es lo que impide un bug
369
868
  * feo: si un fallo de lectura se pareciera a "existe y sin expiración", un
370
- * grant sin expiración saldría por el atajo del no-op y se perdería
371
- * mientras el hook onWrite ya habría auditado que se concedió.
869
+ * grant sin expiración saldría por el atajo del no-op y se perdería. El
870
+ * error de lectura (ya clasificado como 503) viaja con el resultado para
871
+ * que quien no pueda seguir sin él lo propague.
372
872
  */
373
873
  async readAssignment(key) {
374
874
  try {
375
- const response = await this.client.read(key);
875
+ const response = await this.client.read(key, { consistency: this.consistency });
376
876
  const tuple = response.tuples?.[0];
377
877
  if (!tuple)
378
878
  return { kind: 'absent' };
379
879
  const validUntil = tuple.key?.condition?.context?.valid_until;
380
- return { kind: 'present', validUntil: validUntil ? String(validUntil) : undefined };
880
+ return { kind: 'present', validUntil: toExpiryDate(validUntil) };
381
881
  }
382
- catch {
383
- // No se pudo leer: se asume lo peor y se toma el camino largo, que
384
- // funciona exista o no la tuple.
385
- return { kind: 'unknown' };
882
+ catch (error) {
883
+ return { kind: 'unknown', error };
386
884
  }
387
885
  }
388
886
  async revoke(subject, role, scope) {
389
- await this.client.deleteTuples([
390
- {
391
- user: this.fgaSubject(subject),
392
- relation: 'assignee',
393
- object: `role_binding:${scopeKey(scope)}|${encodeSlug(role)}`,
394
- },
395
- ], { conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore } });
887
+ assertIdentity({ subject, role, scope });
888
+ // Rol fuera del catálogo para ese nivel ⇒ 422, como en `grant` (D10). Se
889
+ // quitan los bindings de TODOS los roles con ese nombre en el scope
890
+ // exacto (3B): a lo sumo uno es visible ahí; quitar nunca concede.
891
+ const named = rolesToRevoke(await this.catalog.view(), role, scope);
892
+ const target = await this.canonicalOrSelf(scope, 'revoke');
893
+ const user = this.fgaSubject(subject);
894
+ await this.client.deleteTuples(named.map((r) => ({ user, relation: 'assignee', object: `role_binding:${scopeKey(target)}|${r.uuid}` })), { conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore } });
396
895
  }
397
896
  async hasRole(subject, role, scope) {
897
+ assertIdentity({ subject, role, scope });
398
898
  const user = this.fgaSubject(subject);
399
- const chain = await this.chain(scope);
400
- const results = await this.batchCheckAll(chain.map((s) => ({
899
+ const chain = await this.chain(scope, 'hasRole');
900
+ if (!chain)
901
+ return false;
902
+ // El id del binding lleva el scope (y con él su tipo) y el UUID del rol
903
+ // que el catálogo declara con ese slug para el tipo de ESE nivel (3A):
904
+ // en cada nivel solo casa el rol de ese nivel. Con `{ slug, scopeType }`
905
+ // se recorta la cadena a los niveles de ese tipo (L0.6). Y solo se
906
+ // pregunta por los niveles para los que el catálogo declara el rol (D5):
907
+ // un rol retirado no es membresía aunque su tupla siga en el store.
908
+ const catalog = await this.catalog.view();
909
+ const targets = hasRoleTargets(catalog, role, chain);
910
+ if (targets.length === 0)
911
+ return false;
912
+ const context = this.checkContext();
913
+ const results = await this.batchCheckAll(targets.map(({ scope: s, roleUuid }) => ({
401
914
  user,
402
915
  relation: 'assignee',
403
- object: `role_binding:${scopeKey(s)}|${encodeSlug(role)}`,
404
- context: checkContext(),
916
+ object: `role_binding:${scopeKey(s)}|${roleUuid}`,
917
+ context,
405
918
  })));
406
- return results.some((r) => r.allowed && !r.error);
919
+ return results.some((r) => r.allowed);
407
920
  }
408
921
  async deny(subject, permission, scope) {
922
+ assertIdentity({ subject, permission, scope });
409
923
  const perm = await this.findPermission(permission);
410
- if (!perm) {
411
- throw new Exception(`Permiso '${permission}' no existe en el catálogo`, { status: 422 });
412
- }
924
+ if (!perm)
925
+ throw new UnknownPermissionError(permission);
926
+ const [target] = await this.knownScope(scope, 'deny');
413
927
  await this.client.writeTuples([
414
928
  {
415
929
  user: this.fgaSubject(subject),
416
930
  relation: 'denied',
417
- object: `deny_binding:${scopeKey(scope)}|${encodeSlug(permission)}`,
931
+ object: `deny_binding:${scopeKey(target)}|${perm.uuid}`,
418
932
  },
419
933
  ], { conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore } });
420
934
  }
421
935
  async removeDeny(subject, permission, scope) {
936
+ assertIdentity({ subject, permission, scope });
937
+ const perm = await this.findPermission(permission);
938
+ if (!perm)
939
+ throw new UnknownPermissionError(permission);
940
+ const target = await this.canonicalOrSelf(scope, 'removeDeny');
422
941
  await this.client.deleteTuples([
423
942
  {
424
943
  user: this.fgaSubject(subject),
425
944
  relation: 'denied',
426
- object: `deny_binding:${scopeKey(scope)}|${encodeSlug(permission)}`,
945
+ object: `deny_binding:${scopeKey(target)}|${perm.uuid}`,
427
946
  },
428
947
  ], { conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore } });
429
948
  }
949
+ /**
950
+ * Holders con asignación vigente del rol en el scope exacto: `Read` por
951
+ * objeto exacto, paginado, con la caducidad filtrada en cliente. Antes era
952
+ * `ListUsers`, que trunca al tope del servidor sin señal (L0.7).
953
+ */
430
954
  async listSubjects(role, scope) {
431
- // ListUsers exige EXACTAMENTE un user_filter → una consulta por tipo.
432
- const results = [];
955
+ assertIdentity({ role, scope });
956
+ // Un rol que el catálogo no declara para ese nivel (en ningún owner) no
957
+ // tiene holders (D5): nada que leer, ni árbol ni store. Un scope que el
958
+ // árbol no conoce no existe para el motor (D8, K1): nada; uno que conoce
959
+ // se lee bajo su identidad canónica, y el rol tiene que existir AHÍ (3B ·
960
+ // B2); el que existe se lee por su uuid (3A).
961
+ const catalog = await this.catalog.view();
962
+ const asked = normalizeRoleQuery(role);
963
+ if (asked.uuid !== undefined ? catalog.roleByUuid(asked.uuid) === null : catalog.rolesNamed(asked.slug, asked.scopeType ?? scope.type).length === 0) {
964
+ return [];
965
+ }
966
+ const chain = await this.chain(scope, 'listSubjects');
967
+ if (!chain)
968
+ return [];
969
+ const declared = visibleRoleFor(catalog, asked, scope, chainKeysFrom(chain)[0]);
970
+ if (!declared)
971
+ return [];
433
972
  const fgaToMorph = Object.fromEntries(Object.entries(this.holderTypes).map(([morph, fga]) => [fga, morph]));
434
- for (const fgaType of [...new Set(Object.values(this.holderTypes))]) {
435
- const response = await this.client.listUsers({
436
- object: { type: 'role_binding', id: `${scopeKey(scope)}|${encodeSlug(role)}` },
437
- relation: 'assignee',
438
- user_filters: [{ type: fgaType }],
439
- context: checkContext(),
440
- });
441
- for (const u of response.users ?? []) {
442
- if (u.object) {
443
- results.push({ type: fgaToMorph[u.object.type] ?? u.object.type, uuid: u.object.id });
444
- }
973
+ const tuples = await this.readAllTuples({
974
+ relation: 'assignee',
975
+ object: `role_binding:${scopeKey(chain[0])}|${declared.uuid}`,
976
+ });
977
+ const results = [];
978
+ for (const tuple of tuples) {
979
+ // `<tipoFga>:<uuid>`; un userset (`#`) o un tipo que no está en el mapa
980
+ // no es un holder que este driver haya escrito.
981
+ const separator = tuple.user.indexOf(':');
982
+ const fgaType = separator > 0 ? tuple.user.slice(0, separator) : '';
983
+ const uuid = separator > 0 ? tuple.user.slice(separator + 1) : '';
984
+ const morph = fgaToMorph[fgaType];
985
+ if (!morph || !uuid || uuid.includes('#')) {
986
+ this.diagnostics.unparseableBindings += 1;
987
+ this.warn(`authz(openfga): el user '${tuple.user}' de '${tuple.object}' no es un holder del motor; se ignora en la enumeración (total: ${this.diagnostics.unparseableBindings})`);
988
+ continue;
445
989
  }
990
+ results.push({ type: morph, uuid });
446
991
  }
447
992
  return results;
448
993
  }
449
- /** role_binding ids del subject (asignaciones directas vigentes). */
450
- async listBindings(subject) {
451
- const response = await this.client.listObjects({
994
+ /**
995
+ * Bindings del subject ya parseados (asignaciones directas vigentes). Los
996
+ * ids que no se entienden se registran y se cuentan, no se descartan.
997
+ */
998
+ async listBindings(subject, at) {
999
+ const tuples = await this.readAllTuples({
452
1000
  user: this.fgaSubject(subject),
453
1001
  relation: 'assignee',
454
- type: 'role_binding',
455
- context: checkContext(),
456
- });
457
- return (response.objects ?? []).map((obj) => obj.replace(/^role_binding:/, ''));
1002
+ object: 'role_binding:',
1003
+ }, { at });
1004
+ return this.parseBindings('role_binding', tuples.map((t) => t.object));
1005
+ }
1006
+ /** Scopes (por clave) donde el subject tiene un deny directo del permiso (por su uuid). */
1007
+ async deniedScopeKeys(subject, permissionUuid, at) {
1008
+ const tuples = await this.readAllTuples({
1009
+ user: this.fgaSubject(subject),
1010
+ relation: 'denied',
1011
+ object: 'deny_binding:',
1012
+ }, { at });
1013
+ return new Set(this.parseBindings('deny_binding', tuples.map((t) => t.object))
1014
+ .filter((p) => p.uuid === permissionUuid)
1015
+ .map((p) => scopeKey(p.scope)));
1016
+ }
1017
+ parseBindings(type, objects) {
1018
+ const parsed = [];
1019
+ for (const obj of objects) {
1020
+ const id = obj.replace(new RegExp(`^${type}:`), '');
1021
+ const binding = parseBindingId(id);
1022
+ if (binding) {
1023
+ parsed.push(binding);
1024
+ }
1025
+ else {
1026
+ this.diagnostics.unparseableBindings += 1;
1027
+ this.warn(`authz(openfga): binding '${type}:${id}' no tiene la forma del motor; se ignora en la enumeración (total: ${this.diagnostics.unparseableBindings})`);
1028
+ }
1029
+ }
1030
+ return parsed;
1031
+ }
1032
+ warn(message) {
1033
+ this.logger.warn(message);
458
1034
  }
459
1035
  async listRoles(subject, scope) {
460
- const prefix = scopeKey(scope);
1036
+ assertIdentity({ subject, scope });
1037
+ // Un scope que el árbol no conoce no existe para el motor (D8): nada.
1038
+ const chain = await this.chain(scope, 'listRoles');
1039
+ if (!chain)
1040
+ return [];
1041
+ const prefix = scopeKey(chain[0]);
1042
+ const keys = chainKeysFrom(chain)[0];
1043
+ const catalog = await this.catalog.view();
461
1044
  const roles = new Set();
462
- for (const id of await this.listBindings(subject)) {
463
- const parsed = parseBindingId(id);
464
- if (parsed && scopeKey(parsed.scope) === prefix)
465
- roles.add(parsed.slug);
1045
+ for (const binding of await this.listBindings(subject)) {
1046
+ if (scopeKey(binding.scope) !== prefix)
1047
+ continue;
1048
+ const declared = this.declaredRole(catalog, binding, keys);
1049
+ if (declared)
1050
+ roles.add(declared.slug);
466
1051
  }
467
1052
  return [...roles];
468
1053
  }
1054
+ /**
1055
+ * Roles directos vigentes del holder en cada scope de la cadena (2D · G5):
1056
+ * UNA lectura (`Read` paginado de sus bindings) agrupada por scope, en vez
1057
+ * de un `listRoles` por nivel. Solo roles que el catálogo declara para ese
1058
+ * nivel (D5). La cadena viene ya resuelta por el manager.
1059
+ */
1060
+ async rolesInChain(subject, chain) {
1061
+ assertIdentity({ subject });
1062
+ for (const scope of chain)
1063
+ assertScope(scope);
1064
+ if (chain.length === 0)
1065
+ return [];
1066
+ const catalog = await this.catalog.view();
1067
+ const keysFrom = chainKeysFrom(chain);
1068
+ const wanted = new Map(chain.map((s, i) => [scopeKey(s), { scope: s, keys: keysFrom[i] }]));
1069
+ const seen = new Set();
1070
+ const result = [];
1071
+ for (const binding of await this.listBindings(subject)) {
1072
+ const id = scopeKey(binding.scope);
1073
+ const level = wanted.get(id);
1074
+ if (!level)
1075
+ continue;
1076
+ const scope = level.scope;
1077
+ const declared = this.declaredRole(catalog, binding, level.keys);
1078
+ if (!declared)
1079
+ continue;
1080
+ // Dedupe por IDENTIDAD (3D · M1: el uuid, no el slug).
1081
+ const dedupe = `${id}\u001f${declared.uuid}`;
1082
+ if (seen.has(dedupe))
1083
+ continue;
1084
+ seen.add(dedupe);
1085
+ result.push({ scope, role: declared });
1086
+ }
1087
+ return result;
1088
+ }
469
1089
  async listRoleScopes(subject, scopeType) {
470
- const seen = new Map();
471
- for (const id of await this.listBindings(subject)) {
472
- const parsed = parseBindingId(id);
473
- if (parsed && parsed.scope.type === scopeType) {
474
- seen.set(scopeKey(parsed.scope), parsed.scope);
475
- }
1090
+ assertIdentity({ subject, scopeType });
1091
+ const catalog = await this.catalog.view();
1092
+ const byScope = new Map();
1093
+ for (const binding of await this.listBindings(subject)) {
1094
+ if (binding.scope.type !== scopeType)
1095
+ continue;
1096
+ const k = scopeKey(binding.scope);
1097
+ if (!byScope.has(k))
1098
+ byScope.set(k, { scope: binding.scope, bindings: [] });
1099
+ byScope.get(k).bindings.push(binding);
1100
+ }
1101
+ // Los scopes que el árbol ya no conoce no se listan (D8): una consulta
1102
+ // al resolutor por scope, el mismo coste que `listScopes`. Y con la
1103
+ // cadena se decide si alguno de sus roles existe ahí (D5, 3B · B2).
1104
+ const known = [];
1105
+ for (const { scope, bindings } of byScope.values()) {
1106
+ const chain = await this.chain(scope, 'listRoleScopes');
1107
+ if (!chain)
1108
+ continue;
1109
+ const keys = chainKeysFrom(chain)[0];
1110
+ if (bindings.some((b) => this.declaredRole(catalog, b, keys)))
1111
+ known.push(scope);
476
1112
  }
477
- return [...seen.values()];
1113
+ return known;
478
1114
  }
479
1115
  async listScopes(subject, permission) {
480
- const perm = await this.findPermission(permission);
1116
+ assertIdentity({ subject, permission });
1117
+ const catalog = await this.catalog.view();
1118
+ const perm = catalog.permission(permission);
481
1119
  if (!perm)
482
1120
  return [];
483
- const granting = await this.rolesGranting(perm.uuid);
484
- // Denies del subject para este permiso (una sola consulta).
485
- const denyResponse = await this.client.listObjects({
486
- user: this.fgaSubject(subject),
487
- relation: 'denied',
488
- type: 'deny_binding',
489
- });
490
- const deniedKeys = new Set((denyResponse.objects ?? [])
491
- .map((obj) => obj.replace(/^deny_binding:/, ''))
492
- .map((id) => parseBindingId(id))
493
- .filter((p) => Boolean(p && p.slug === permission))
494
- .map((p) => scopeKey(p.scope)));
1121
+ const granting = catalog.rolesGranting(perm.uuid);
1122
+ // Denies directos del subject para este permiso: TODOS, paginando. Con
1123
+ // `ListObjects` el tope del servidor se consumía con los denies de
1124
+ // cualquier permiso y el relevante podía quedar fuera: fail-open (L0.7).
1125
+ // Las dos lecturas filtran la caducidad con el MISMO instante (K9).
1126
+ const at = this.now();
1127
+ const deniedKeys = await this.deniedScopeKeys(subject, perm.uuid, at);
495
1128
  const result = new Map();
496
- for (const id of await this.listBindings(subject)) {
497
- const parsed = parseBindingId(id);
498
- if (!parsed)
1129
+ for (const binding of await this.listBindings(subject, at)) {
1130
+ const role = (granting.get(binding.scope.type) ?? []).find((r) => r.uuid === binding.uuid);
1131
+ if (!role)
1132
+ continue;
1133
+ // Un scope que el árbol ya no conoce no concede: no se lista. Y el rol
1134
+ // tiene que existir ahí (3B · B2: global u owner en la cadena).
1135
+ const chain = await this.chain(binding.scope, 'listScopes');
1136
+ if (!chain)
499
1137
  continue;
500
- if (!(granting.get(parsed.scope.type) ?? []).includes(parsed.slug))
1138
+ if (!isRoleVisibleWith(role, chainKeysFrom(chain)[0]))
501
1139
  continue;
502
- const chain = await this.chain(parsed.scope);
503
1140
  const blocked = chain.some((s) => deniedKeys.has(scopeKey(s)));
504
1141
  if (!blocked)
505
- result.set(scopeKey(parsed.scope), parsed.scope);
1142
+ result.set(scopeKey(binding.scope), binding.scope);
506
1143
  }
507
1144
  return [...result.values()];
508
1145
  }
1146
+ /**
1147
+ * Denies directos del holder (2.1, B5): `Read` paginado de sus
1148
+ * `deny_binding` (nunca ListObjects, L0.7), filtrados por el catálogo (un
1149
+ * permiso retirado no es un deny, D5), por scope exacto si se pide, y por
1150
+ * scopes que el árbol conoce (D8).
1151
+ */
1152
+ async listDenies(subject, scope) {
1153
+ assertIdentity(scope ? { subject, scope } : { subject });
1154
+ const chain = scope ? await this.chain(scope, 'listDenies') : null;
1155
+ if (scope && !chain)
1156
+ return [];
1157
+ const wanted = chain ? scopeKey(chain[0]) : null;
1158
+ const view = await this.catalog.view();
1159
+ const tuples = await this.readAllTuples({
1160
+ user: this.fgaSubject(subject),
1161
+ relation: 'denied',
1162
+ object: 'deny_binding:',
1163
+ });
1164
+ const result = [];
1165
+ for (const binding of this.parseBindings('deny_binding', tuples.map((t) => t.object))) {
1166
+ const permission = view.permissionSlug(binding.uuid);
1167
+ if (!permission)
1168
+ continue;
1169
+ if (wanted !== null) {
1170
+ if (scopeKey(binding.scope) !== wanted)
1171
+ continue;
1172
+ }
1173
+ else if (!(await this.chain(binding.scope, 'listDenies'))) {
1174
+ continue;
1175
+ }
1176
+ result.push({ permission, scope: binding.scope });
1177
+ }
1178
+ return result;
1179
+ }
1180
+ /**
1181
+ * Purga del scope exacto en FGA (N7, S6, B2). No hay "borrar todo lo de
1182
+ * este objeto": se leen por objeto EXACTO los bindings posibles — un
1183
+ * `role_binding` por cada rol del catálogo de ese `scope_type` y un
1184
+ * `deny_binding` por cada permiso — paginando `Read` (nunca ListObjects:
1185
+ * trunca sin avisar, L0.7), se borra en lotes ≤ 100 (límite del Write) y
1186
+ * se vuelve a leer cada objeto: si queda algo, se lanza. Un rol retirado
1187
+ * del catálogo deja bindings inalcanzables por esta vía; es el precio de no
1188
+ * tener un índice por objeto, y lo vigilará `authz:reconcile` (3b).
1189
+ */
1190
+ async purgeScope(purged) {
1191
+ assertScope(purged);
1192
+ if (purged.type === APP_SCOPE_TYPE) {
1193
+ throw new InvalidIdentityError('purgeScope: la raíz `app` no se purga');
1194
+ }
1195
+ // La identidad canónica si el árbol aún lo conoce (K1); tal cual si ya no.
1196
+ const scope = await this.canonicalOrSelf(purged, 'purgeScope');
1197
+ const key = scopeKey(scope);
1198
+ const roles = await this.sql('purgeScope.roles', () => db.from('authz_roles').where('scope_type', scope.type).select('uuid'));
1199
+ const permissions = await this.sql('purgeScope.permissions', () => db.from('authz_permissions').select('uuid'));
1200
+ const objects = [
1201
+ ...roles.map((r) => `role_binding:${key}|${r.uuid}`),
1202
+ ...permissions.map((p) => `deny_binding:${key}|${p.uuid}`),
1203
+ ];
1204
+ for (const object of objects) {
1205
+ const keys = await this.readAllTuples({ object }, { includeExpired: true });
1206
+ for (let i = 0; i < keys.length; i += PURGE_BATCH_SIZE) {
1207
+ await this.client.deleteTuples(keys.slice(i, i + PURGE_BATCH_SIZE), {
1208
+ conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
1209
+ });
1210
+ }
1211
+ }
1212
+ // Demostrar cero: lo que no se puede demostrar, se reporta.
1213
+ const residue = [];
1214
+ for (const object of objects) {
1215
+ const left = await this.readAllTuples({ object }, { includeExpired: true });
1216
+ if (left.length)
1217
+ residue.push(`${object} (${left.length})`);
1218
+ }
1219
+ if (residue.length) {
1220
+ throw new PurgeIncompleteError(`purgeScope ${key}: quedan tuplas tras el borrado — ${residue.join('; ')}. ` +
1221
+ `No confirmes el borrado del scope; reintenta la purga.`);
1222
+ }
1223
+ }
1224
+ // `purgeRole` NO existe en este driver (3B · B4, capacidad `purgeRole:
1225
+ // false`): los bindings de un rol viven en objetos
1226
+ // `role_binding:<scopeKey>|<roleUuid>` de scopes que el driver no puede
1227
+ // enumerar por rol sin leer el store entero (`Read` filtra por prefijo de
1228
+ // objeto, no por sufijo). Borrar la fila del catálogo sin sus tuplas
1229
+ // dejaría hechos huérfanos que resucitarían al recrear el slug.
1230
+ //
1231
+ // Hasta 3E el método existía y lanzaba 500 al LLAMARLO, y eso era el
1232
+ // callejón que encontró el code-review (3E · P4): `defineScopedRole`
1233
+ // escribía el rol tan tranquilo y después nada podía borrarlo — ni
1234
+ // `deleteScopedRole` ni `scopes.detached` de ese scope, para siempre.
1235
+ // Desde 3E el método es OPCIONAL en el puerto (Q4) y NO declararlo es la
1236
+ // forma de decir «no sé purgar»: el manager lo comprueba ANTES de escribir
1237
+ // (500 `E_AUTHZ_UNSUPPORTED` nombrándolo). Llega con 3b (`facts` +
1238
+ // `authz:reconcile`).
1239
+ /**
1240
+ * TODAS las tuplas que casan con el filtro, paginando `Read` hasta agotar
1241
+ * el `continuation_token`, sin las caducadas. Es la única primitiva de
1242
+ * enumeración del driver (L0.7): `Read` no tiene tope de resultados —a
1243
+ * diferencia de `ListObjects`/`ListUsers`, que cortan al máximo del
1244
+ * servidor sin ninguna señal— y devuelve la condición de cada tupla, así
1245
+ * que la caducidad se filtra aquí con el mismo reloj que `checkContext`.
1246
+ *
1247
+ * Contrapartida, documentada en el README: `Read` devuelve tuplas
1248
+ * ESCRITAS, no relaciones computadas. Con el modelo que genera este paquete
1249
+ * (`assignee`/`denied` directas) es exactamente lo mismo; un modelo
1250
+ * extendido con relaciones derivadas sobre `role_binding` no se enumeraría
1251
+ * por aquí.
1252
+ */
1253
+ async readAllTuples(filter, options = {}) {
1254
+ // El instante con el que se filtra: el de la operación si lo trae (K9), o
1255
+ // el de esta lectura.
1256
+ const now = options.at ?? this.now();
1257
+ const keys = [];
1258
+ let continuationToken;
1259
+ const seenTokens = new Set();
1260
+ let pages = 0;
1261
+ do {
1262
+ const response = await this.client.read(filter, {
1263
+ pageSize: READ_PAGE_SIZE,
1264
+ continuationToken,
1265
+ consistency: this.consistency,
1266
+ });
1267
+ pages += 1;
1268
+ for (const tuple of response.tuples ?? []) {
1269
+ const k = tuple?.key;
1270
+ if (!k?.user || !k?.relation || !k?.object) {
1271
+ // Una tupla que el motor no puede leer es un hecho que las
1272
+ // enumeraciones NO muestran: se cuenta y se registra (L0.16, H16).
1273
+ this.diagnostics.unparseableBindings += 1;
1274
+ this.warn(`authz(openfga): tupla malformada en Read ${JSON.stringify(filter)} (${JSON.stringify(k ?? null)}); se ignora en la enumeración (total: ${this.diagnostics.unparseableBindings})`);
1275
+ continue;
1276
+ }
1277
+ if (!options.includeExpired) {
1278
+ const validUntil = toExpiryDate(k.condition?.context?.valid_until);
1279
+ if (validUntil && validUntil <= now)
1280
+ continue;
1281
+ }
1282
+ keys.push({ user: k.user, relation: k.relation, object: k.object });
1283
+ }
1284
+ continuationToken = response.continuation_token || undefined;
1285
+ if (continuationToken) {
1286
+ if (seenTokens.has(continuationToken)) {
1287
+ throw new AuthorizationInternalError(`Read ${JSON.stringify(filter)}: el continuation_token se repite (página ${pages}); el servidor no avanza`);
1288
+ }
1289
+ if (pages >= MAX_READ_PAGES) {
1290
+ throw new AuthorizationInternalError(`Read ${JSON.stringify(filter)}: más de ${MAX_READ_PAGES} páginas sin agotar el continuation_token`);
1291
+ }
1292
+ seenTokens.add(continuationToken);
1293
+ }
1294
+ } while (continuationToken);
1295
+ return keys;
1296
+ }
509
1297
  }
510
1298
  //# sourceMappingURL=openfga_driver.js.map