@jantstack/adonis-authz 1.1.0 → 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 -58
  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 +45 -6
  23. package/build/index.d.ts.map +1 -1
  24. package/build/index.js +37 -4
  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 +1020 -271
  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 +333 -0
  58. package/build/src/errors.d.ts.map +1 -1
  59. package/build/src/errors.js +352 -0
  60. package/build/src/errors.js.map +1 -1
  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,81 +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 { AuthorizationBackendError } from '../errors.js';
5
- import { APP_SCOPE, APP_SCOPE_TYPE } from '../types.js';
6
- import { APP_SCOPE_DB_UUID } from './database_driver.js';
7
- /** `:` no es válido en ids de FGA se encodea (`audit:read` → `audit~read`). */
8
- function encodeSlug(slug) {
9
- return slug.replaceAll(':', '~');
10
- }
11
- function decodeSlug(encoded) {
12
- return encoded.replaceAll('~', ':');
13
- }
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}$/;
14
14
  /**
15
- * Caracteres admitidos en un tipo de scope y en un uuid al construir la clave
16
- * del binding. `|` es el separador y `~` el escape de los slugs: si alguno
17
- * apareciera dentro de un componente, dos scopes DISTINTOS podrían producir
18
- * la misma clave —p. ej. `{org, 'anization|X'}` y `{'org|anization', 'X'}`—
19
- * y un grant en uno autorizaría en el otro (confusión de privilegios).
20
- *
21
- * El driver `database` es inmune por construcción (guarda tipo y uuid en
22
- * columnas separadas, sin codificar); esta validación protege la única ruta
23
- * 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.
24
21
  */
25
- const SCOPE_COMPONENT_FORMAT = /^[a-zA-Z0-9_.:-]+$/;
26
- function assertScopeComponent(kind, value) {
27
- if (!SCOPE_COMPONENT_FORMAT.test(value)) {
28
- throw new Exception(`${kind} inválido para el driver openfga: '${value}'. ` +
29
- `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.`);
30
39
  }
31
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).
32
46
  /**
33
- * Clave de scope dentro del id del binding: `app` para la raíz,
34
- * `<tipo>|<uuid>` para el resto. Genérico: sirve para cualquier nivel que
35
- * 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.
36
62
  */
37
- function scopeKey(scope) {
38
- assertScopeComponent('Tipo de scope', scope.type);
39
- if (scope.type === APP_SCOPE_TYPE)
40
- return APP_SCOPE_TYPE;
41
- assertScopeComponent('UUID de scope', String(scope.uuid ?? ''));
42
- return `${scope.type}|${scope.uuid}`;
43
- }
44
- function parseBindingId(id) {
45
- const parts = id.split('|');
46
- if (parts.length === 2 && parts[0] === APP_SCOPE_TYPE) {
47
- 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 };
48
72
  }
49
- if (parts.length === 3) {
50
- 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] };
51
75
  }
52
- return null;
76
+ else {
77
+ return null;
78
+ }
79
+ if (!isValidScope(scope) || !isCatalogUuid(uuid))
80
+ return null;
81
+ return { scope, uuid };
53
82
  }
54
83
  /** `<tipoFga>:<uuid>` a partir del morph name del holder. */
55
84
  function fgaSubjectWith(subject, holderTypes) {
56
85
  const fgaType = holderTypes[subject.type];
57
86
  if (!fgaType) {
58
- 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 ` +
59
89
  `(declarados: ${Object.keys(holderTypes).join(', ') || 'ninguno'}). ` +
60
- `Añádelo a holderTypes y regenera el authorization model.`, { status: 500 });
90
+ `Añádelo a holderTypes y regenera el authorization model.`);
61
91
  }
62
92
  return `${fgaType}:${subject.uuid}`;
63
93
  }
64
- function checkContext() {
65
- 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() };
66
106
  }
67
107
  /**
68
- * ¿La expiración almacenada y la pedida son la misma? Compara el instante,
69
- * no la cadena: `2026-01-01T00:00:00Z` y `2026-01-01T00:00:00.000Z` son el
70
- * 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ó.
71
114
  */
72
- function sameExpiry(storedValidUntil, requested) {
73
- if (!storedValidUntil && !requested)
74
- return true;
75
- if (!storedValidUntil || !requested)
76
- return false;
77
- const stored = Date.parse(storedValidUntil);
78
- 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;
79
137
  }
80
138
  /**
81
139
  * El authorization model en formato JSON del API de FGA, generado a partir
@@ -83,7 +141,8 @@ function sameExpiry(storedValidUntil, requested) {
83
141
  * driver: si difieren, los checks no encuentran las tuplas.
84
142
  */
85
143
  export function openFgaAuthorizationModel(holderTypeMap) {
86
- const holderTypes = [...new Set(Object.values(holderTypeMap))];
144
+ assertHolderTypes(holderTypeMap);
145
+ const holderTypes = Object.values(holderTypeMap);
87
146
  const direct = holderTypes.map((type) => ({ type }));
88
147
  const directWithExpiry = [
89
148
  ...direct,
@@ -133,6 +192,41 @@ export async function provisionOpenFgaStore(apiUrl, name, holderTypeMap) {
133
192
  const model = await scoped.writeAuthorizationModel(openFgaAuthorizationModel(holderTypeMap));
134
193
  return { storeId: store.id, modelId: model.authorization_model_id };
135
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
+ }
136
230
  /**
137
231
  * Devuelve el cliente con TODOS sus métodos envueltos: un fallo de red o un
138
232
  * 5xx sale como `AuthorizationBackendError` (503) y no como el `FgaError` del
@@ -148,21 +242,28 @@ export async function provisionOpenFgaStore(apiUrl, name, holderTypeMap) {
148
242
  * ahí el error del SDK es la información más útil y no rompe ninguna
149
243
  * abstracción.
150
244
  */
151
- function guardBackendErrors(client) {
245
+ function guardBackendErrors(client, timeoutMs) {
152
246
  return new Proxy(client, {
153
247
  get(target, prop, receiver) {
154
248
  const value = Reflect.get(target, prop, receiver);
155
249
  if (typeof value !== 'function')
156
250
  return value;
157
251
  return (...args) => {
158
- const fail = (cause) => new AuthorizationBackendError('openfga', String(prop), cause);
252
+ const operation = String(prop);
253
+ const fail = (cause) => isTimeoutLike(cause)
254
+ ? new AuthorizationBackendTimeoutError('openfga', operation, timeoutMs, cause)
255
+ : new AuthorizationBackendError('openfga', operation, cause);
159
256
  try {
160
257
  const result = value.apply(target, args);
161
- return result instanceof Promise
162
- ? result.catch((error) => {
163
- throw fail(error);
164
- })
165
- : result;
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));
166
267
  }
167
268
  catch (error) {
168
269
  throw fail(error);
@@ -171,6 +272,62 @@ function guardBackendErrors(client) {
171
272
  },
172
273
  });
173
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;
174
331
  /**
175
332
  * Migración de hechos database → openfga: copia las asignaciones vigentes y
176
333
  * los denies de las tablas `authz_*` como tuples del store FGA.
@@ -179,225 +336,528 @@ function guardBackendErrors(client) {
179
336
  * volver a AUTHZ_DRIVER=database (solo se pierde lo escrito mientras se
180
337
  * operó con openfga). El catálogo y la jerarquía nunca migran: son
181
338
  * metadata local para ambos drivers.
182
- * - Idempotente: re-ejecutar no duplica (onDuplicateWrites: Ignore).
183
339
  * - Las asignaciones ya expiradas se saltan (no tiene sentido copiarlas);
184
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.
185
350
  */
186
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
+ }
187
356
  const client = new OpenFgaClient({
188
357
  apiUrl: options.apiUrl,
189
358
  storeId: options.storeId,
190
359
  authorizationModelId: options.modelId,
191
360
  });
192
- const now = new Date();
361
+ const now = (options.now ?? systemClock)();
193
362
  const result = {
194
- assignments: 0,
195
- denies: 0,
363
+ written: 0,
364
+ updated: 0,
365
+ unchanged: 0,
366
+ extra: 0,
367
+ deleted: 0,
196
368
  skippedExpired: 0,
197
369
  dryRun: options.dryRun ?? false,
198
370
  };
199
- const tuples = [];
200
- const assignments = await db
201
- .from('authz_assignments as a')
202
- .join('authz_roles as r', 'r.uuid', 'a.role_uuid')
203
- .select('a.holder_type', 'a.holder_uuid', 'a.scope_type', 'a.scope_uuid', 'a.expires_at')
204
- .select('r.slug as role_slug');
205
371
  const rowScope = (row) => ({
206
372
  type: row.scope_type,
207
373
  uuid: row.scope_uuid === APP_SCOPE_DB_UUID ? null : row.scope_uuid,
208
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');
209
384
  for (const row of assignments) {
210
- const expiresAt = row.expires_at ? new Date(row.expires_at) : null;
385
+ const expiresAt = expiry.fromDb(row.expires_at);
211
386
  if (expiresAt && expiresAt <= now) {
212
387
  result.skippedExpired++;
213
388
  continue;
214
389
  }
215
- const scope = rowScope(row);
216
- const key = {
217
- user: fgaSubjectWith({ type: row.holder_type, uuid: row.holder_uuid }, options.holderTypes),
218
- relation: 'assignee',
219
- object: `role_binding:${scopeKey(scope)}|${encodeSlug(row.role_slug)}`,
220
- };
221
- tuples.push(expiresAt
222
- ? {
223
- ...key,
224
- condition: { name: 'not_expired', context: { valid_until: expiresAt.toISOString() } },
225
- }
226
- : key);
227
- 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
+ });
228
398
  }
229
399
  const denies = await db
230
400
  .from('authz_denies as d')
231
401
  .join('authz_permissions as p', 'p.uuid', 'd.permission_uuid')
232
402
  .select('d.holder_type', 'd.holder_uuid', 'd.scope_type', 'd.scope_uuid')
233
- .select('p.slug as permission_slug');
403
+ .select('p.uuid as permission_uuid');
234
404
  for (const row of denies) {
235
- const scope = rowScope(row);
236
- tuples.push({
237
- user: fgaSubjectWith({ type: row.holder_type, uuid: row.holder_uuid }, options.holderTypes),
238
- relation: 'denied',
239
- 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,
240
412
  });
241
- result.denies++;
242
413
  }
243
- if (!result.dryRun && tuples.length > 0) {
244
- // Chunks: el write transaccional de FGA tiene límite de tuples por request.
245
- for (let i = 0; i < tuples.length; i += 50) {
246
- await client.writeTuples(tuples.slice(i, i + 50), {
247
- conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore },
248
- });
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));
249
476
  }
250
477
  }
251
478
  return result;
252
479
  }
253
480
  export class OpenFgaAuthorizationDriver {
254
481
  client;
255
- resolveAncestors;
482
+ chainResolver;
256
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;
257
505
  constructor(options) {
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 });
258
515
  this.client = guardBackendErrors(new OpenFgaClient({
259
516
  apiUrl: options.apiUrl,
260
517
  storeId: options.storeId,
261
518
  authorizationModelId: options.modelId,
262
- }));
263
- this.resolveAncestors =
264
- options.resolveAncestors ??
265
- (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;
266
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;
564
+ }
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());
267
587
  }
268
- async chain(scope) {
269
- return [scope, ...(await this.resolveAncestors(scope))];
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);
270
595
  }
271
596
  fgaSubject(subject) {
272
597
  return fgaSubjectWith(subject, this.holderTypes);
273
598
  }
274
599
  /**
275
- * batchCheck troceado al límite del servidor FGA (50 checks/request) y con
276
- * 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.
277
603
  */
278
604
  async batchCheckAll(checks) {
279
- const results = [];
280
- for (let i = 0; i < checks.length; i += 50) {
281
- const slice = checks.slice(i, i + 50);
282
- const response = await this.client.batchCheck({ checks: slice });
283
- results.push(...response.result);
284
- }
285
- if (results.length !== checks.length) {
286
- throw new Exception('OpenFGA batchCheck devolvió menos resultados que checks', {
287
- status: 500,
288
- });
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);
289
620
  }
290
621
  return results;
291
622
  }
292
- // ── 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.
293
627
  async findPermission(slug) {
294
- return db.from('authz_permissions').where('slug', slug).select('uuid').first();
295
- }
296
- async findRoleOrFail(slug, scopeType) {
297
- const role = await db
298
- .from('authz_roles')
299
- .where('slug', slug)
300
- .where('scope_type', scopeType)
301
- .select('uuid')
302
- .first();
303
- if (!role) {
304
- throw new Exception(`Rol '${slug}' no existe en el catálogo para el nivel '${scopeType}'`, {
305
- status: 422,
306
- });
307
- }
628
+ return (await this.catalog.view()).permission(slug);
308
629
  }
309
- /** Roles del catálogo que conceden el permiso, agrupados por scope_type. */
310
- async rolesGranting(permissionUuid) {
311
- const rows = await db
312
- .from('authz_role_permissions as rp')
313
- .join('authz_roles as r', 'r.uuid', 'rp.role_uuid')
314
- .where('rp.permission_uuid', permissionUuid)
315
- .select('r.slug', 'r.scope_type');
316
- const byScopeType = new Map();
317
- for (const row of rows) {
318
- const list = byScopeType.get(row.scope_type) ?? [];
319
- list.push(row.slug);
320
- byScopeType.set(row.scope_type, list);
321
- }
322
- 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);
323
642
  }
324
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
+ }
325
673
  async authorize(subject, permission, scope) {
326
- 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);
327
679
  if (!perm)
328
680
  return false;
329
681
  const user = this.fgaSubject(subject);
330
- const chain = await this.chain(scope);
331
- // 1. Denies en la cadena. FAIL-CLOSED: un check de deny con error se
332
- // trata como denegado (jamás se ignora un deny por un fallo puntual).
333
- const denyChecks = chain.map((s) => ({
334
- user,
335
- relation: 'denied',
336
- object: `deny_binding:${scopeKey(s)}|${encodeSlug(permission)}`,
337
- }));
338
- const denyResults = await this.batchCheckAll(denyChecks);
339
- if (denyResults.some((r) => r.allowed || r.error))
682
+ const chain = await this.chain(scope, 'authorize');
683
+ if (!chain)
340
684
  return false;
341
- // 2. Alguna asignación vigente en la cadena cuyo rol concede el permiso.
342
- // Aquí un error individual falla cerrado por solo (no concede).
343
- const granting = await this.rolesGranting(perm.uuid);
344
- const checks = chain.flatMap((s) => (granting.get(s.type) ?? []).map((roleSlug) => ({
345
- user,
346
- relation: 'assignee',
347
- object: `role_binding:${scopeKey(s)}|${encodeSlug(roleSlug)}`,
348
- context: checkContext(),
349
- })));
350
- 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)
351
689
  return false;
352
- const results = await this.batchCheckAll(checks);
353
- 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
+ });
354
753
  }
355
754
  async grant(subject, role, scope, options = {}) {
356
- 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;
357
766
  const key = {
358
767
  user: this.fgaSubject(subject),
359
768
  relation: 'assignee',
360
- object: `role_binding:${scopeKey(scope)}|${encodeSlug(role)}`,
769
+ object: `role_binding:${scopeKey(target)}|${roleUuid}`,
361
770
  };
362
- const write = options.expiresAt
771
+ const tupleFor = (expiresAt) => expiresAt
363
772
  ? {
364
773
  ...key,
365
774
  condition: {
366
775
  name: 'not_expired',
367
- context: { valid_until: options.expiresAt.toISOString() },
776
+ context: { valid_until: expiresAt.toISOString() },
368
777
  },
369
778
  }
370
779
  : key;
371
780
  // FGA no admite delete+write de la misma tuple key en una transacción, así
372
- // 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
373
782
  // instante en el que authorize() responde false.
374
783
  //
375
784
  // Se mira primero qué hay, para NO pagar esa ventana cuando no hace falta:
376
785
  // - si no existe la tuple → solo write (el caso del primer grant);
377
- // - si existe idéntica → no-op (re-ejecutar un seeder no toca nada);
378
- // - solo si la expiración CAMBIA de verdad se hace delete+write.
379
- // 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).
380
789
  const current = await this.readAssignment(key);
381
- // Solo se salta la escritura si SABEMOS que lo almacenado ya es esto.
382
- if (current.kind === 'present' && sameExpiry(current.validUntil, options.expiresAt))
383
- return;
384
- if (current.kind === 'absent') {
385
- // No había nada: un write basta y no hay ventana de denegación. Si entre
386
- // el read y el write otro proceso escribió la misma key, este write
387
- // choca y entonces sí toca el camino largo, para que gane el último
388
- // escritor y no se pierda esta expiración en silencio.
389
- try {
390
- await this.client.writeTuples([write]);
391
- 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 };
392
804
  }
393
- catch {
394
- // cae al delete+write de abajo
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 };
395
827
  }
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));
834
+ }
835
+ const existed = await this.writeAssignment(key, tupleFor(options.expiresAt));
836
+ return { existed, expiresAt: options.expiresAt };
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;
396
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) {
397
857
  await this.client.deleteTuples([key], {
398
858
  conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
399
859
  });
400
- await this.client.writeTuples([write], {
860
+ await this.client.writeTuples([tuple], {
401
861
  conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore },
402
862
  });
403
863
  }
@@ -406,144 +866,433 @@ export class OpenFgaAuthorizationDriver {
406
866
  *
407
867
  * Distinguir `unknown` de `present` sin condición es lo que impide un bug
408
868
  * feo: si un fallo de lectura se pareciera a "existe y sin expiración", un
409
- * grant sin expiración saldría por el atajo del no-op y se perdería
410
- * 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.
411
872
  */
412
873
  async readAssignment(key) {
413
874
  try {
414
- const response = await this.client.read(key);
875
+ const response = await this.client.read(key, { consistency: this.consistency });
415
876
  const tuple = response.tuples?.[0];
416
877
  if (!tuple)
417
878
  return { kind: 'absent' };
418
879
  const validUntil = tuple.key?.condition?.context?.valid_until;
419
- return { kind: 'present', validUntil: validUntil ? String(validUntil) : undefined };
880
+ return { kind: 'present', validUntil: toExpiryDate(validUntil) };
420
881
  }
421
- catch {
422
- // No se pudo leer: se asume lo peor y se toma el camino largo, que
423
- // funciona exista o no la tuple.
424
- return { kind: 'unknown' };
882
+ catch (error) {
883
+ return { kind: 'unknown', error };
425
884
  }
426
885
  }
427
886
  async revoke(subject, role, scope) {
428
- await this.client.deleteTuples([
429
- {
430
- user: this.fgaSubject(subject),
431
- relation: 'assignee',
432
- object: `role_binding:${scopeKey(scope)}|${encodeSlug(role)}`,
433
- },
434
- ], { 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 } });
435
895
  }
436
896
  async hasRole(subject, role, scope) {
897
+ assertIdentity({ subject, role, scope });
437
898
  const user = this.fgaSubject(subject);
438
- const chain = await this.chain(scope);
439
- 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 }) => ({
440
914
  user,
441
915
  relation: 'assignee',
442
- object: `role_binding:${scopeKey(s)}|${encodeSlug(role)}`,
443
- context: checkContext(),
916
+ object: `role_binding:${scopeKey(s)}|${roleUuid}`,
917
+ context,
444
918
  })));
445
- return results.some((r) => r.allowed && !r.error);
919
+ return results.some((r) => r.allowed);
446
920
  }
447
921
  async deny(subject, permission, scope) {
922
+ assertIdentity({ subject, permission, scope });
448
923
  const perm = await this.findPermission(permission);
449
- if (!perm) {
450
- throw new Exception(`Permiso '${permission}' no existe en el catálogo`, { status: 422 });
451
- }
924
+ if (!perm)
925
+ throw new UnknownPermissionError(permission);
926
+ const [target] = await this.knownScope(scope, 'deny');
452
927
  await this.client.writeTuples([
453
928
  {
454
929
  user: this.fgaSubject(subject),
455
930
  relation: 'denied',
456
- object: `deny_binding:${scopeKey(scope)}|${encodeSlug(permission)}`,
931
+ object: `deny_binding:${scopeKey(target)}|${perm.uuid}`,
457
932
  },
458
933
  ], { conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore } });
459
934
  }
460
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');
461
941
  await this.client.deleteTuples([
462
942
  {
463
943
  user: this.fgaSubject(subject),
464
944
  relation: 'denied',
465
- object: `deny_binding:${scopeKey(scope)}|${encodeSlug(permission)}`,
945
+ object: `deny_binding:${scopeKey(target)}|${perm.uuid}`,
466
946
  },
467
947
  ], { conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore } });
468
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
+ */
469
954
  async listSubjects(role, scope) {
470
- // ListUsers exige EXACTAMENTE un user_filter → una consulta por tipo.
471
- 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 [];
472
972
  const fgaToMorph = Object.fromEntries(Object.entries(this.holderTypes).map(([morph, fga]) => [fga, morph]));
473
- for (const fgaType of [...new Set(Object.values(this.holderTypes))]) {
474
- const response = await this.client.listUsers({
475
- object: { type: 'role_binding', id: `${scopeKey(scope)}|${encodeSlug(role)}` },
476
- relation: 'assignee',
477
- user_filters: [{ type: fgaType }],
478
- context: checkContext(),
479
- });
480
- for (const u of response.users ?? []) {
481
- if (u.object) {
482
- results.push({ type: fgaToMorph[u.object.type] ?? u.object.type, uuid: u.object.id });
483
- }
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;
484
989
  }
990
+ results.push({ type: morph, uuid });
485
991
  }
486
992
  return results;
487
993
  }
488
- /** role_binding ids del subject (asignaciones directas vigentes). */
489
- async listBindings(subject) {
490
- 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({
491
1000
  user: this.fgaSubject(subject),
492
1001
  relation: 'assignee',
493
- type: 'role_binding',
494
- context: checkContext(),
495
- });
496
- 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);
497
1034
  }
498
1035
  async listRoles(subject, scope) {
499
- 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();
500
1044
  const roles = new Set();
501
- for (const id of await this.listBindings(subject)) {
502
- const parsed = parseBindingId(id);
503
- if (parsed && scopeKey(parsed.scope) === prefix)
504
- 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);
505
1051
  }
506
1052
  return [...roles];
507
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
+ }
508
1089
  async listRoleScopes(subject, scopeType) {
509
- const seen = new Map();
510
- for (const id of await this.listBindings(subject)) {
511
- const parsed = parseBindingId(id);
512
- if (parsed && parsed.scope.type === scopeType) {
513
- seen.set(scopeKey(parsed.scope), parsed.scope);
514
- }
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);
515
1100
  }
516
- return [...seen.values()];
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);
1112
+ }
1113
+ return known;
517
1114
  }
518
1115
  async listScopes(subject, permission) {
519
- const perm = await this.findPermission(permission);
1116
+ assertIdentity({ subject, permission });
1117
+ const catalog = await this.catalog.view();
1118
+ const perm = catalog.permission(permission);
520
1119
  if (!perm)
521
1120
  return [];
522
- const granting = await this.rolesGranting(perm.uuid);
523
- // Denies del subject para este permiso (una sola consulta).
524
- const denyResponse = await this.client.listObjects({
525
- user: this.fgaSubject(subject),
526
- relation: 'denied',
527
- type: 'deny_binding',
528
- });
529
- const deniedKeys = new Set((denyResponse.objects ?? [])
530
- .map((obj) => obj.replace(/^deny_binding:/, ''))
531
- .map((id) => parseBindingId(id))
532
- .filter((p) => Boolean(p && p.slug === permission))
533
- .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);
534
1128
  const result = new Map();
535
- for (const id of await this.listBindings(subject)) {
536
- const parsed = parseBindingId(id);
537
- 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)
538
1132
  continue;
539
- if (!(granting.get(parsed.scope.type) ?? []).includes(parsed.slug))
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)
1137
+ continue;
1138
+ if (!isRoleVisibleWith(role, chainKeysFrom(chain)[0]))
540
1139
  continue;
541
- const chain = await this.chain(parsed.scope);
542
1140
  const blocked = chain.some((s) => deniedKeys.has(scopeKey(s)));
543
1141
  if (!blocked)
544
- result.set(scopeKey(parsed.scope), parsed.scope);
1142
+ result.set(scopeKey(binding.scope), binding.scope);
545
1143
  }
546
1144
  return [...result.values()];
547
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
+ }
548
1297
  }
549
1298
  //# sourceMappingURL=openfga_driver.js.map