@jantstack/adonis-authz 2.0.0-alpha.1 → 2.4.0-alpha.10

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 (224) hide show
  1. package/README.md +934 -40
  2. package/build/commands/authz_catalog_diff.d.ts.map +1 -1
  3. package/build/commands/authz_catalog_diff.js +1 -2
  4. package/build/commands/authz_catalog_diff.js.map +1 -1
  5. package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
  6. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
  7. package/build/commands/authz_catalog_prune_orphans.js +135 -0
  8. package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
  9. package/build/commands/authz_catalog_sync.d.ts +17 -0
  10. package/build/commands/authz_catalog_sync.d.ts.map +1 -1
  11. package/build/commands/authz_catalog_sync.js +27 -4
  12. package/build/commands/authz_catalog_sync.js.map +1 -1
  13. package/build/commands/authz_freeze.d.ts +44 -0
  14. package/build/commands/authz_freeze.d.ts.map +1 -0
  15. package/build/commands/authz_freeze.js +94 -0
  16. package/build/commands/authz_freeze.js.map +1 -0
  17. package/build/commands/authz_reconcile.d.ts +111 -0
  18. package/build/commands/authz_reconcile.d.ts.map +1 -0
  19. package/build/commands/authz_reconcile.js +417 -0
  20. package/build/commands/authz_reconcile.js.map +1 -0
  21. package/build/commands/authz_relations_reconcile.d.ts +144 -0
  22. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  23. package/build/commands/authz_relations_reconcile.js +438 -0
  24. package/build/commands/authz_relations_reconcile.js.map +1 -0
  25. package/build/commands/authz_scopes_relay.d.ts +97 -0
  26. package/build/commands/authz_scopes_relay.d.ts.map +1 -0
  27. package/build/commands/authz_scopes_relay.js +344 -0
  28. package/build/commands/authz_scopes_relay.js.map +1 -0
  29. package/build/commands/authz_scopes_requeue.d.ts +80 -0
  30. package/build/commands/authz_scopes_requeue.d.ts.map +1 -0
  31. package/build/commands/authz_scopes_requeue.js +230 -0
  32. package/build/commands/authz_scopes_requeue.js.map +1 -0
  33. package/build/commands/authz_unfreeze.d.ts +45 -0
  34. package/build/commands/authz_unfreeze.d.ts.map +1 -0
  35. package/build/commands/authz_unfreeze.js +157 -0
  36. package/build/commands/authz_unfreeze.js.map +1 -0
  37. package/build/commands/main.d.ts +16 -8
  38. package/build/commands/main.d.ts.map +1 -1
  39. package/build/commands/main.js +59 -6
  40. package/build/commands/main.js.map +1 -1
  41. package/build/commands/openfga_provision.d.ts +92 -4
  42. package/build/commands/openfga_provision.d.ts.map +1 -1
  43. package/build/commands/openfga_provision.js +155 -7
  44. package/build/commands/openfga_provision.js.map +1 -1
  45. package/build/configure.d.ts +11 -0
  46. package/build/configure.d.ts.map +1 -1
  47. package/build/configure.js +37 -1
  48. package/build/configure.js.map +1 -1
  49. package/build/index.d.ts +50 -11
  50. package/build/index.d.ts.map +1 -1
  51. package/build/index.js +49 -7
  52. package/build/index.js.map +1 -1
  53. package/build/providers/authz_provider.d.ts +31 -2
  54. package/build/providers/authz_provider.d.ts.map +1 -1
  55. package/build/providers/authz_provider.js +65 -2
  56. package/build/providers/authz_provider.js.map +1 -1
  57. package/build/services/relations.d.ts +14 -0
  58. package/build/services/relations.d.ts.map +1 -0
  59. package/build/services/relations.js +17 -0
  60. package/build/services/relations.js.map +1 -0
  61. package/build/src/{catalog.d.ts → catalog/catalog.d.ts} +41 -2
  62. package/build/src/catalog/catalog.d.ts.map +1 -0
  63. package/build/src/{catalog.js → catalog/catalog.js} +120 -14
  64. package/build/src/catalog/catalog.js.map +1 -0
  65. package/build/src/{catalog_cache.d.ts → catalog/catalog_cache.d.ts} +46 -22
  66. package/build/src/catalog/catalog_cache.d.ts.map +1 -0
  67. package/build/src/{catalog_cache.js → catalog/catalog_cache.js} +53 -43
  68. package/build/src/catalog/catalog_cache.js.map +1 -0
  69. package/build/src/define_config.d.ts +206 -3
  70. package/build/src/define_config.d.ts.map +1 -1
  71. package/build/src/define_config.js +11 -0
  72. package/build/src/define_config.js.map +1 -1
  73. package/build/src/drivers/database_driver.d.ts +128 -7
  74. package/build/src/drivers/database_driver.d.ts.map +1 -1
  75. package/build/src/drivers/database_driver.js +510 -24
  76. package/build/src/drivers/database_driver.js.map +1 -1
  77. package/build/src/drivers/database_relations_driver.d.ts +140 -0
  78. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  79. package/build/src/drivers/database_relations_driver.js +748 -0
  80. package/build/src/drivers/database_relations_driver.js.map +1 -0
  81. package/build/src/drivers/openfga_driver.d.ts +777 -122
  82. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  83. package/build/src/drivers/openfga_driver.js +2228 -477
  84. package/build/src/drivers/openfga_driver.js.map +1 -1
  85. package/build/src/drivers/openfga_facts.d.ts +384 -0
  86. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  87. package/build/src/drivers/openfga_facts.js +836 -0
  88. package/build/src/drivers/openfga_facts.js.map +1 -0
  89. package/build/src/drivers/openfga_relations_driver.d.ts +186 -0
  90. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  91. package/build/src/drivers/openfga_relations_driver.js +724 -0
  92. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  93. package/build/src/errors.d.ts +430 -6
  94. package/build/src/errors.d.ts.map +1 -1
  95. package/build/src/errors.js +453 -6
  96. package/build/src/errors.js.map +1 -1
  97. package/build/src/freeze.d.ts +200 -0
  98. package/build/src/freeze.d.ts.map +1 -0
  99. package/build/src/freeze.js +306 -0
  100. package/build/src/freeze.js.map +1 -0
  101. package/build/src/http/app_access_middleware.d.ts.map +1 -0
  102. package/build/src/http/app_access_middleware.js.map +1 -0
  103. package/build/src/http/resource_access_middleware.d.ts +105 -0
  104. package/build/src/http/resource_access_middleware.d.ts.map +1 -0
  105. package/build/src/http/resource_access_middleware.js +81 -0
  106. package/build/src/http/resource_access_middleware.js.map +1 -0
  107. package/build/src/identity.d.ts +74 -1
  108. package/build/src/identity.d.ts.map +1 -1
  109. package/build/src/identity.js +100 -2
  110. package/build/src/identity.js.map +1 -1
  111. package/build/src/manager.d.ts +549 -6
  112. package/build/src/manager.d.ts.map +1 -1
  113. package/build/src/manager.js +2243 -193
  114. package/build/src/manager.js.map +1 -1
  115. package/build/src/models/authz_assignment.d.ts +7 -7
  116. package/build/src/models/authz_assignment.d.ts.map +1 -1
  117. package/build/src/models/authz_deny.d.ts +7 -7
  118. package/build/src/models/authz_deny.d.ts.map +1 -1
  119. package/build/src/models/authz_permission.d.ts +7 -7
  120. package/build/src/models/authz_permission.d.ts.map +1 -1
  121. package/build/src/models/authz_role.d.ts +7 -7
  122. package/build/src/models/authz_role.d.ts.map +1 -1
  123. package/build/src/models/authz_role_permission.d.ts +7 -7
  124. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  125. package/build/src/openfga.d.ts +18 -2
  126. package/build/src/openfga.d.ts.map +1 -1
  127. package/build/src/openfga.js +15 -1
  128. package/build/src/openfga.js.map +1 -1
  129. package/build/src/reconcile.d.ts +37 -0
  130. package/build/src/reconcile.d.ts.map +1 -0
  131. package/build/src/reconcile.js +69 -0
  132. package/build/src/reconcile.js.map +1 -0
  133. package/build/src/relation_partition_trigger.d.ts +8 -0
  134. package/build/src/relation_partition_trigger.d.ts.map +1 -0
  135. package/build/src/relation_partition_trigger.js +93 -0
  136. package/build/src/relation_partition_trigger.js.map +1 -0
  137. package/build/src/relations/define_relations_config.d.ts +109 -0
  138. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  139. package/build/src/relations/define_relations_config.js +211 -0
  140. package/build/src/relations/define_relations_config.js.map +1 -0
  141. package/build/src/relations/manager.d.ts +138 -0
  142. package/build/src/relations/manager.d.ts.map +1 -0
  143. package/build/src/relations/manager.js +494 -0
  144. package/build/src/relations/manager.js.map +1 -0
  145. package/build/src/relations/reconcile.d.ts +137 -0
  146. package/build/src/relations/reconcile.d.ts.map +1 -0
  147. package/build/src/relations/reconcile.js +294 -0
  148. package/build/src/relations/reconcile.js.map +1 -0
  149. package/build/src/relations_config_store.d.ts +22 -0
  150. package/build/src/relations_config_store.d.ts.map +1 -0
  151. package/build/src/relations_config_store.js +74 -0
  152. package/build/src/relations_config_store.js.map +1 -0
  153. package/build/src/scope_outbox.d.ts +123 -0
  154. package/build/src/scope_outbox.d.ts.map +1 -0
  155. package/build/src/scope_outbox.js +532 -0
  156. package/build/src/scope_outbox.js.map +1 -0
  157. package/build/src/{drivers → shared}/backend_guard.d.ts +42 -0
  158. package/build/src/shared/backend_guard.d.ts.map +1 -0
  159. package/build/src/{drivers → shared}/backend_guard.js +78 -1
  160. package/build/src/shared/backend_guard.js.map +1 -0
  161. package/build/src/{drivers → shared}/sql_expiry.d.ts +2 -0
  162. package/build/src/shared/sql_expiry.d.ts.map +1 -0
  163. package/build/src/{drivers → shared}/sql_expiry.js +4 -0
  164. package/build/src/shared/sql_expiry.js.map +1 -0
  165. package/build/src/shared/transaction_guard.d.ts +53 -0
  166. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  167. package/build/src/shared/transaction_guard.js +74 -0
  168. package/build/src/shared/transaction_guard.js.map +1 -0
  169. package/build/src/sql_descendants.d.ts +55 -1
  170. package/build/src/sql_descendants.d.ts.map +1 -1
  171. package/build/src/sql_descendants.js +75 -1
  172. package/build/src/sql_descendants.js.map +1 -1
  173. package/build/src/testing/contract.d.ts +129 -4
  174. package/build/src/testing/contract.d.ts.map +1 -1
  175. package/build/src/testing/contract.js +915 -177
  176. package/build/src/testing/contract.js.map +1 -1
  177. package/build/src/testing/main.d.ts +10 -2
  178. package/build/src/testing/main.d.ts.map +1 -1
  179. package/build/src/testing/main.js +5 -1
  180. package/build/src/testing/main.js.map +1 -1
  181. package/build/src/testing/migration_contract.d.ts +293 -0
  182. package/build/src/testing/migration_contract.d.ts.map +1 -0
  183. package/build/src/testing/migration_contract.js +589 -0
  184. package/build/src/testing/migration_contract.js.map +1 -0
  185. package/build/src/testing/relations_contract.d.ts +73 -0
  186. package/build/src/testing/relations_contract.d.ts.map +1 -0
  187. package/build/src/testing/relations_contract.js +1238 -0
  188. package/build/src/testing/relations_contract.js.map +1 -0
  189. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  190. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  191. package/build/src/testing/relations_reconcile_contract.js +220 -0
  192. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  193. package/build/src/testing/scope_outbox_contract.d.ts +98 -0
  194. package/build/src/testing/scope_outbox_contract.d.ts.map +1 -0
  195. package/build/src/testing/scope_outbox_contract.js +677 -0
  196. package/build/src/testing/scope_outbox_contract.js.map +1 -0
  197. package/build/src/traits/authz_scopes.js +1 -1
  198. package/build/src/traits/authz_scopes.js.map +1 -1
  199. package/build/src/traits/has_uuid.d.ts +8 -8
  200. package/build/src/traits/has_uuid.d.ts.map +1 -1
  201. package/build/src/types.d.ts +1537 -84
  202. package/build/src/types.d.ts.map +1 -1
  203. package/build/src/types.js +65 -0
  204. package/build/src/types.js.map +1 -1
  205. package/build/stubs/config/authorization.stub +149 -5
  206. package/build/stubs/migration.stub +136 -0
  207. package/build/stubs/scopes_outbox_migration.stub +57 -0
  208. package/package.json +4 -2
  209. package/build/commands/openfga_import.d.ts +0 -34
  210. package/build/commands/openfga_import.d.ts.map +0 -1
  211. package/build/commands/openfga_import.js +0 -97
  212. package/build/commands/openfga_import.js.map +0 -1
  213. package/build/src/catalog.d.ts.map +0 -1
  214. package/build/src/catalog.js.map +0 -1
  215. package/build/src/catalog_cache.d.ts.map +0 -1
  216. package/build/src/catalog_cache.js.map +0 -1
  217. package/build/src/drivers/backend_guard.d.ts.map +0 -1
  218. package/build/src/drivers/backend_guard.js.map +0 -1
  219. package/build/src/drivers/sql_expiry.d.ts.map +0 -1
  220. package/build/src/drivers/sql_expiry.js.map +0 -1
  221. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  222. package/build/src/middleware/app_access_middleware.js.map +0 -1
  223. /package/build/src/{middleware → http}/app_access_middleware.d.ts +0 -0
  224. /package/build/src/{middleware → http}/app_access_middleware.js +0 -0
@@ -0,0 +1,724 @@
1
+ /**
2
+ * Driver `openfga` del puerto `RelationsDriver` — ReBAC sobre el MISMO store y
3
+ * `modelId` que el driver `facts` de roles (Fase 4, lote 4-4).
4
+ *
5
+ * Store COMPARTIDO (decisión del dueño / juez §2): las relaciones ReBAC viven
6
+ * en el modelo FUSIONADO (`facts` + `group` + los tipos de objeto del
7
+ * consumidor), así que `relate`/`unrelate` son `Write`/delete de tuplas del
8
+ * mismo store, `check` es UN solo `Check` (el modelo resuelve includes +
9
+ * usersets del lado del servidor) y `list*` van por `ListObjects`/`Read`.
10
+ *
11
+ * **La partición va en el ID del objeto y del userset**, no en el modelo:
12
+ * `document:<scopeKey(partition)>|<uuid>`, `group:<scopeKey(partition)>|<uuid>#member`.
13
+ * El cruce de particiones se corta por comparación de string —aquí NO hay
14
+ * trigger como en `database`, así que F-05 (en el manager) + la comparación de
15
+ * string son la ÚNICA defensa de tenant—. El id lleva DOS `|` cuando la
16
+ * partición es un scope no-raíz (`document:unit|<partUuid>|<objUuid>`), así que
17
+ * el parseo es CANÓNICO desde la DERECHA (⚪5, el mismo cuidado que
18
+ * `parseBindingId`): partición = todo menos el ÚLTIMO segmento; el id del
19
+ * objeto es el último y no contiene `|`.
20
+ *
21
+ * **La frontera del 🔴 (F-05) NO vive aquí**: la cierra el `RelationsManager`
22
+ * ANTES de tocar el driver (un `object.type` no declarado ⇒ 422) junto con ⚪4
23
+ * (un tipo reservado no se puede declarar). Si a este driver se le llama en
24
+ * DIRECTO con `object.type: 'role_binding'` (la salida `manager.driver()`, de
25
+ * plataforma), compondría el id de un binding real y escalaría — por eso el
26
+ * caso-exploit del auditor se cierra en el manager, y el runner publica el
27
+ * caso para que un driver de terceros que no lo cumpla NO pase el contrato.
28
+ *
29
+ * Pureza: vive detrás del subpath `@jantstack/adonis-authz/openfga`
30
+ * (`src/openfga.ts`), como el driver `facts` de roles: es lo único que puede
31
+ * tocar `@openfga/sdk` (peer opcional). La abstracción no filtra: ningún error
32
+ * del SDK escapa (todo va por `#guard`, que clasifica a 503/timeout).
33
+ */
34
+ import { OpenFgaClient, ConsistencyPreference, ClientWriteRequestOnMissingDeletes, ClientWriteRequestOnDuplicateWrites, } from '@openfga/sdk';
35
+ import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, InvalidIdentityError, PurgeIncompleteError, UnsupportedOperationError, markPartialWrite, } from '../errors.js';
36
+ import { assertScope, assertSubject, assertRelationId, assertExpiresAt, scopeKey, scopeSpellings } from '../identity.js';
37
+ import { isClock, systemClock } from '../clock.js';
38
+ import { resolveGrantExpiry, sameInstant, toExpiryDate } from '../expiry.js';
39
+ import { isRelUserset } from '../types.js';
40
+ import { assertFgaObjectId, FACTS_EXPIRY_CONDITION, FACTS_GROUP_TYPE, } from './openfga_facts.js';
41
+ import { assertRelationDeclared, assertObjectTypeDeclared } from '../relations/define_relations_config.js';
42
+ import { isTimeoutLike, withDeadline } from '../shared/backend_guard.js';
43
+ export const DEFAULT_TIMEOUT_MS = 5_000;
44
+ /** Tope de operaciones por `Write` en FGA (`exceeded_entity_limit` a partir de 100). */
45
+ const WRITE_BATCH_SIZE = 100;
46
+ /** Tamaño de página de `Read` (máximo del servidor). */
47
+ const READ_PAGE_SIZE = 100;
48
+ /**
49
+ * Cota de páginas de una enumeración por `Read`. Un `continuation_token` que
50
+ * no avanza (servidor roto, proxy, caché) sería un bucle que ningún deadline
51
+ * corta (el deadline es por llamada). Mismo patrón que el driver de roles.
52
+ */
53
+ const MAX_READ_PAGES = 10_000;
54
+ /**
55
+ * El tope de `ListObjects` del servidor por defecto (`OPENFGA_LIST_OBJECTS_MAX_RESULTS`).
56
+ * `ListObjects` NO devuelve señal de truncado ni `continuation_token`: corta al
57
+ * tope y calla. La única forma honesta de detectar el corte es comparar el
58
+ * número de resultados con el tope CONFIGURADO del servidor —de ahí la opción
59
+ * `listObjectsMaxResults`—: si vuelven `>= tope`, no se puede prometer que la
60
+ * lista esté completa ⇒ `truncated: true` (lección S16/L0.7: nunca una lista
61
+ * parcial MUDA). Ponerlo por debajo del tope real del servidor daría un
62
+ * truncado FALSO (el servidor devolvió todo); por encima, se perdería el corte.
63
+ */
64
+ const DEFAULT_LIST_OBJECTS_MAX = 1_000;
65
+ /**
66
+ * Ignora duplicados en el `Write` de una tupla que NO estaba (idempotencia de
67
+ * dos `relate` concurrentes idénticos). Cambiar la caducidad de una que SÍ
68
+ * está es delete+write (R-15): FGA no admite reescribir la condición de una
69
+ * tupla existente y un `Ignore` la dejaría con la caducidad vieja en silencio.
70
+ */
71
+ const IGNORE_DUPLICATE_WRITES = {
72
+ conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore },
73
+ };
74
+ /** Ignora deletes de tuplas ya ausentes (re-unrelate no-op seguro, invariante 6). */
75
+ const IGNORE_MISSING_DELETES = {
76
+ conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
77
+ };
78
+ export class OpenFgaRelationsDriver {
79
+ /**
80
+ * Lo que declara (cada valor tiene su cara en `runRelationsDriverContract`):
81
+ * - `singleCheckRelations: true`: `check` es UN solo `Check` (el modelo
82
+ * resuelve includes + usersets del lado del servidor).
83
+ * - `listObjectsInherited: false`: `list*` no abren el subárbol de
84
+ * particiones (invariante 7); `ListObjects` sí resuelve la derivación del
85
+ * modelo (includes/usersets), que es «directos + derivados», no herencia
86
+ * de scopes.
87
+ * - `usersetSubjects: true`: `listSubjects` (`Read`) trae los usersets
88
+ * directos (`group#member`) junto con los holders.
89
+ * - `membersOfNative: false`: la membresía TRANSITIVA sería `ListUsers`, que
90
+ * trunca al tope del servidor; se declara 500 `E_AUTHZ_UNSUPPORTED`, jamás
91
+ * un userset enumerado a medias (par de `purgeRole` de la 3b).
92
+ * - `enumerateRelations: true` (4-5): es ORIGEN de `authz:reconcile` de
93
+ * relaciones — lee el store ENTERO (`Read({})`, la única forma de ver todo
94
+ * sin filtro por user u objeto: un `Read` por tipo-solo sin user es un 400)
95
+ * y devuelve SOLO las tuplas de relación de la partición (tipo declarado o
96
+ * `group`), descartando las de `facts` (scope/role/role_binding).
97
+ * - `listObjectsTruncation: true`: `ListObjects` trunca al tope del servidor
98
+ * y este driver lo SEÑALA (medido contra el `:8103` de tope 3).
99
+ * - `injectableClock: true` (R-15): `withClock(now)` fija el reloj que viaja
100
+ * como `current_time` y filtra `listSubjects`, como el driver de roles.
101
+ */
102
+ capabilities = Object.freeze({
103
+ singleCheckRelations: true,
104
+ listObjectsInherited: false,
105
+ usersetSubjects: true,
106
+ membersOfNative: false,
107
+ enumerateRelations: true,
108
+ listObjectsTruncation: true,
109
+ injectableClock: true,
110
+ // L-2/L-5 (panel `{trx}`, (C)): `false` EXPLÍCITO, y no puede ser otra
111
+ // cosa — una tupla no entra en una transacción SQL: el store es otro
112
+ // servicio y no hay 2PC, así que «los dos o ninguno» con la transacción
113
+ // del consumidor es una promesa que este driver no puede hacer, y no la
114
+ // hace. `{ transaction }` es 500 `E_AUTHZ_UNSUPPORTED` por llamada, en el
115
+ // `RelationsManager` (puerta 1) Y en el driver (`#rejectTransaction`,
116
+ // para quien entre por `manager.driver()` o por `reconcileRelations`),
117
+ // con CERO llamadas al store (L-5 lo fija contra el `:8101` con espía
118
+ // sobre el cliente). La alternativa que SÍ existe es para el ÁRBOL de
119
+ // scopes: `scopes.outbox` encola en tu transacción y `authz:scopes:relay`
120
+ // aplica. Para las RELACIONES no hay outbox (el panel la descartó:
121
+ // fail-open medido durante el lag); el consumidor lee esta capacidad y
122
+ // ajusta (usa `database` para lo que tenga que ir en su transacción, o
123
+ // `requireTransactionalWrites` para fallar al arrancar).
124
+ transactionalWrites: false,
125
+ });
126
+ client;
127
+ #config;
128
+ #options;
129
+ #now;
130
+ #holderTypes;
131
+ #fgaToMorph;
132
+ #timeoutMs;
133
+ #consistency;
134
+ #listObjectsMax;
135
+ #logger;
136
+ diagnostics = { unparseableTuples: 0 };
137
+ constructor(config, options) {
138
+ this.#config = config;
139
+ this.#options = options;
140
+ this.#now = options.now ?? systemClock;
141
+ this.#holderTypes = options.holderTypes;
142
+ this.#fgaToMorph = Object.fromEntries(Object.entries(options.holderTypes).map(([morph, fga]) => [fga, morph]));
143
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
144
+ this.#consistency =
145
+ options.consistency === 'minimize_latency'
146
+ ? ConsistencyPreference.MinimizeLatency
147
+ : ConsistencyPreference.HigherConsistency;
148
+ this.#listObjectsMax = options.listObjectsMaxResults ?? DEFAULT_LIST_OBJECTS_MAX;
149
+ this.#logger = options.logger ?? console;
150
+ this.client = new OpenFgaClient({
151
+ apiUrl: options.apiUrl,
152
+ storeId: options.storeId,
153
+ authorizationModelId: options.modelId,
154
+ baseOptions: { timeout: this.#timeoutMs },
155
+ retryParams: { maxRetry: 0 },
156
+ });
157
+ }
158
+ /**
159
+ * Vista de este driver con OTRO reloj de pared (R-15, paridad con
160
+ * `AuthorizationDriver.withClock`): MISMO cliente (store, modelo, deadline),
161
+ * solo cambia el `now` que decide la caducidad. Se construye una instancia
162
+ * nueva y se le presta el cliente (el espía de «1 Check» envuelve
163
+ * `client.check` de la instancia base y tiene que seguir viéndolo).
164
+ */
165
+ withClock(now) {
166
+ if (!isClock(now)) {
167
+ throw new AuthorizationConfigError(`withClock: now debe ser una función () => Date (llegó ${typeof now})`);
168
+ }
169
+ const view = new OpenFgaRelationsDriver(this.#config, { ...this.#options, now });
170
+ view.client = this.client;
171
+ return view;
172
+ }
173
+ /** El `context` de todo `Check`/`ListObjects`: la condición `not_expired` se evalúa contra este instante (un `now` por operación). */
174
+ #checkContext(at) {
175
+ return { current_time: at.toISOString() };
176
+ }
177
+ /* ── Clasificación de fallos (la abstracción no filtra) ─────────────────── */
178
+ /**
179
+ * **El MISMO criterio que el driver de roles** (`guardBackendErrors`,
180
+ * `openfga_driver.ts`; cierre-2 de alpha.3, ⚪ 5 del re-ataque): un
181
+ * deadline TOTAL PROPIO por llamada (`withDeadline`, `timeoutMs`) que es lo
182
+ * que dice «venció el deadline» —503 `E_AUTHZ_BACKEND_TIMEOUT`, indeterminado—,
183
+ * y para lo que el cliente lanza por su cuenta el `isTimeoutLike` ESTRICTO
184
+ * de `backend_guard.ts` (`ECONNABORTED`/`ETIMEDOUT`/`.timeout` numérico/
185
+ * `KnexTimeoutError`, por la cadena de `cause`). Hasta el cierre-2 este
186
+ * driver duplicaba la función con una rama por SUBSTRING del mensaje
187
+ * (`/timeout/i`): un error que decía «no es timeout» salía como timeout con
188
+ * `indeterminate: true` sin haber borrado nada, y el docblock afirmaba en
189
+ * falso «mismo criterio que roles».
190
+ */
191
+ async #guard(operation, fn) {
192
+ const fail = (error) => {
193
+ // Un error del paquete (422/500 ya clasificado) se propaga tal cual.
194
+ if (error instanceof InvalidIdentityError || error instanceof PurgeIncompleteError)
195
+ return error;
196
+ if (isTimeoutLike(error)) {
197
+ return new AuthorizationBackendTimeoutError('openfga-relations', operation, this.#timeoutMs, error);
198
+ }
199
+ return new AuthorizationBackendError('openfga-relations', operation, error);
200
+ };
201
+ let pending;
202
+ try {
203
+ pending = fn();
204
+ }
205
+ catch (error) {
206
+ throw fail(error);
207
+ }
208
+ return withDeadline(pending.catch((error) => {
209
+ throw fail(error);
210
+ }), this.#timeoutMs, () => new AuthorizationBackendTimeoutError('openfga-relations', operation, this.#timeoutMs));
211
+ }
212
+ /* ── Composición y parseo de ids (⚪5: partición por string en el id) ────── */
213
+ /** `<type>:<scopeKey(partition)>|<objectUuid>`. */
214
+ #objectId(objectType, partitionKey, objectUuid) {
215
+ // R-16 (defensa en profundidad, paridad con `database`): el `object.id` con
216
+ // la gramática estricta ANTES de componer. Un `|`/`#`/`:`/espacio dentro se
217
+ // escribía (solo se medía la longitud) y luego se perdía en el parseo por
218
+ // el último `|` —invisible en enumeración y `reconcile`—. `manager.driver()`
219
+ // salta el manager, así que el driver aplica la MISMA regla que él.
220
+ assertRelationId(`el id del objeto '${objectType}'`, objectUuid);
221
+ const id = `${objectType}:${partitionKey}|${objectUuid}`;
222
+ assertFgaObjectId(objectType, id);
223
+ return id;
224
+ }
225
+ /**
226
+ * El `user` FGA de un sujeto: holder ⇒ `<fgaType>:<uuid>`; userset ⇒
227
+ * `<type>:<scopeKey(partition)>|<objUuid>#<relation>`. El userset comparte la
228
+ * partición de la tupla (el manager lo exige).
229
+ */
230
+ #subjectUser(subject, partitionKey) {
231
+ if (isRelUserset(subject)) {
232
+ // alpha.3 · F-05 también en el USERSET del sujeto (la misma función que
233
+ // el manager): `role_binding:<key>|<R>#role` como `user` de un `Read`/
234
+ // `Check`/`Write` no se compone nunca.
235
+ assertRelationDeclared(this.#config, subject.object, subject.relation);
236
+ const object = this.#objectId(subject.object.type, partitionKey, subject.object.id);
237
+ return `${object}#${subject.relation}`;
238
+ }
239
+ const fgaType = this.#holderTypes[subject.type];
240
+ if (!fgaType) {
241
+ throw new InvalidIdentityError(`Holder type '${subject.type}' no está en holderTypes del driver openfga de relaciones ` +
242
+ `(declarados: ${Object.keys(this.#holderTypes).join(', ') || 'ninguno'}).`);
243
+ }
244
+ return `${fgaType}:${subject.uuid}`;
245
+ }
246
+ /**
247
+ * **Parseo CANÓNICO del id de un objeto de relación** (⚪5): a partir de
248
+ * `<type>:<scopeKey>|<objUuid>`, separa `type` (antes del primer `:`),
249
+ * `partitionKey` (todo menos el ÚLTIMO segmento tras el `:`) y `objectUuid`
250
+ * (el último segmento, que no contiene `|`). Es el MISMO parseo-desde-la-
251
+ * derecha que `parseBindingId`: `lastIndexOf('|')` corta el objeto de la
252
+ * clave de partición (que tiene 1 parte `app` o 2 `<tipo>|<uuid>`). `null` si
253
+ * el id no tiene la forma que este driver escribe.
254
+ */
255
+ #parseObjectId(fullId) {
256
+ const colon = fullId.indexOf(':');
257
+ if (colon <= 0)
258
+ return null;
259
+ const type = fullId.slice(0, colon);
260
+ const rest = fullId.slice(colon + 1);
261
+ const cut = rest.lastIndexOf('|');
262
+ if (cut < 0)
263
+ return null;
264
+ const partitionKey = rest.slice(0, cut);
265
+ const objectUuid = rest.slice(cut + 1);
266
+ if (!partitionKey || !objectUuid)
267
+ return null;
268
+ return { type, partitionKey, objectUuid };
269
+ }
270
+ /** Un `user` del store de vuelta a `RelSubject`: userset (`…#rel`) o holder (`<fga>:<uuid>`). */
271
+ #userToSubject(user) {
272
+ const hash = user.indexOf('#');
273
+ if (hash >= 0) {
274
+ const parsed = this.#parseObjectId(user.slice(0, hash));
275
+ const relation = user.slice(hash + 1);
276
+ if (!parsed || !relation)
277
+ return null;
278
+ return { object: { type: parsed.type, id: parsed.objectUuid }, relation };
279
+ }
280
+ const colon = user.indexOf(':');
281
+ if (colon <= 0)
282
+ return null;
283
+ const morph = this.#fgaToMorph[user.slice(0, colon)];
284
+ const uuid = user.slice(colon + 1);
285
+ if (!morph || !uuid || uuid === '*' || uuid.includes('|'))
286
+ return null;
287
+ return { type: morph, uuid };
288
+ }
289
+ /* ── Escrituras ─────────────────────────────────────────────────────────── */
290
+ /**
291
+ * `relate` con los tres estados de `expiresAt` (R-15, invariante 10). Se LEE
292
+ * primero la tupla exacta (un `Read` por clave, como `grant` de roles):
293
+ * - ausente ⇒ un `Write` (con la condición `not_expired` si caduca);
294
+ * - presente con la caducidad que toca ⇒ no-op (idempotente, invariante 6);
295
+ * - presente con OTRA ⇒ delete + write en DOS llamadas (FGA no admite
296
+ * delete+write de la misma clave en un `Write`, ni reescribir su
297
+ * condición): entre ambas hay un instante en el que `check` responde
298
+ * `false` —fail-closed, la misma ventana que el `grant` de roles—.
299
+ */
300
+ /**
301
+ * **`{ transaction }` se rechaza también AQUÍ** (L-5, defensa en
302
+ * profundidad como F-05 en L-0): la puerta 1 vive en el `RelationsManager`,
303
+ * pero `manager.driver()` y `reconcileRelations` entran por el driver; sin
304
+ * esta guarda un `{ transaction }` por ahí escribiría la tupla en el store
305
+ * IGNORANDO la transacción — una escritura que finge ir en tu transacción y
306
+ * que tu rollback no deshace, que es exactamente el fail-open que
307
+ * `transactionalWrites: false` declara no poder evitar. Primera línea de
308
+ * las cuatro escrituras, antes de F-05, del `Read` y del `Write`: CERO
309
+ * llamadas al cliente.
310
+ */
311
+ #rejectTransaction(operation, options) {
312
+ if (options?.transaction === undefined || options.transaction === null)
313
+ return;
314
+ throw UnsupportedOperationError.transactionalDriver(`relations.${operation}`, 'openfga', 'relations');
315
+ }
316
+ async relate(subject, relation, object, partition, options) {
317
+ this.#rejectTransaction('relate', options);
318
+ // L-0 · F-05 en el DRIVER (defensa en profundidad, la MISMA función que el
319
+ // manager): `manager.driver()` y `reconcileRelations` entran por aquí, y en
320
+ // el store COMPARTIDO un tipo no declarado (`role_binding`) compondría el
321
+ // id exacto de un binding de roles. Corta ANTES del `Read` y del `Write`.
322
+ assertRelationDeclared(this.#config, object, relation);
323
+ assertScope(partition);
324
+ assertExpiresAt(options?.expiresAt);
325
+ const partitionKey = scopeKey(partition);
326
+ const objectId = this.#objectId(object.type, partitionKey, object.id);
327
+ const user = this.#subjectUser(subject, partitionKey);
328
+ const key = { user, relation, object: objectId };
329
+ const now = this.#now();
330
+ const current = await this.#readTuple('relate', key);
331
+ const previous = current ? current.validUntil : null;
332
+ const expiresAt = resolveGrantExpiry(previous, options?.expiresAt, now);
333
+ if (current && sameInstant(previous, expiresAt))
334
+ return;
335
+ const tuple = expiresAt
336
+ ? { ...key, condition: { name: FACTS_EXPIRY_CONDITION, context: { valid_until: expiresAt.toISOString() } } }
337
+ : key;
338
+ if (current) {
339
+ await this.#guard('relate', () => this.client.write({ deletes: [key] }, IGNORE_MISSING_DELETES));
340
+ }
341
+ await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES));
342
+ }
343
+ /** La tupla EXACTA de una clave (un `Read` por clave completa), con su caducidad si la lleva. */
344
+ async #readTuple(operation, key) {
345
+ const response = await this.#guard(operation, () => this.client.read(key, { consistency: this.#consistency }));
346
+ const found = (response.tuples ?? []).map((t) => t.key).find((k) => k?.user === key.user && k?.relation === key.relation && k?.object === key.object);
347
+ if (!found)
348
+ return null;
349
+ return { ...key, validUntil: toExpiryDate(found.condition?.context?.valid_until) };
350
+ }
351
+ async unrelate(subject, relation, object, partition, options) {
352
+ this.#rejectTransaction('unrelate', options);
353
+ // L-0 · F-05 también al RETIRAR: un `unrelate(alice, assignee, role_binding)`
354
+ // sería un revoke de roles por la puerta de relaciones.
355
+ assertRelationDeclared(this.#config, object, relation);
356
+ assertScope(partition);
357
+ const partitionKey = scopeKey(partition);
358
+ const objectId = this.#objectId(object.type, partitionKey, object.id);
359
+ const user = this.#subjectUser(subject, partitionKey);
360
+ await this.#guard('unrelate', () =>
361
+ // Re-unrelate no-op seguro: borrar una tupla ausente se ignora.
362
+ this.client.write({ deletes: [{ user, relation, object: objectId }] }, IGNORE_MISSING_DELETES));
363
+ }
364
+ /* ── Lecturas ───────────────────────────────────────────────────────────── */
365
+ /**
366
+ * **F-05 en las LECTURAS y las PURGAS del driver** (2.4.0-alpha.3, cierre
367
+ * del 🔴 1 / 🟠 2 del auditor; la MISMA función y la misma posición que
368
+ * L-0 en `relate`/`unrelate` y que L-5 con `{ transaction }`): PRIMERA
369
+ * línea (tras `#rejectTransaction` en las purgas), CERO llamadas al
370
+ * cliente. Reproducido contra el `:8101` en el store compartido:
371
+ * `purgeObject({ type: 'role_binding', id: R }, S)` componía
372
+ * `role_binding:<scopeKey>|<R>` —byte a byte el binding real— y
373
+ * `#purgeByFilter` lo borraba con demostración de cero (`authorize` de
374
+ * `true` a `false`); `listSubjects('assignee', role_binding)` enumeraba sus
375
+ * asignados. `check`/`listSubjects` validan el par; `listObjects`/
376
+ * `purgeObject` el tipo; `purgeSubject` el userset.
377
+ */
378
+ /** `check` = UN solo `Check` (el modelo resuelve includes + usersets). */
379
+ async check(subject, relation, object, partition) {
380
+ assertRelationDeclared(this.#config, object, relation);
381
+ assertScope(partition);
382
+ const partitionKey = scopeKey(partition);
383
+ const objectId = this.#objectId(object.type, partitionKey, object.id);
384
+ const user = this.#subjectUser(subject, partitionKey);
385
+ const context = this.#checkContext(this.#now());
386
+ const response = await this.#guard('check', () => this.client.check({ user, relation, object: objectId, context }, { consistency: this.#consistency }));
387
+ return response.allowed === true;
388
+ }
389
+ /**
390
+ * `listObjects` = `ListObjects` (resuelve includes + usersets del lado del
391
+ * servidor) + FILTRO por partición + señal de TRUNCADO. `ListObjects` corta
392
+ * al tope del servidor sin `continuation_token` ni bandera, así que el
393
+ * truncado se detecta comparando el número de resultados CRUDOS con el tope
394
+ * configurado (`listObjectsMaxResults`): `>= tope` ⇒ no se puede prometer la
395
+ * lista completa ⇒ `truncated: true`. El filtro por partición se aplica
396
+ * DESPUÉS (los ids llevan la partición), así que en un store con varias
397
+ * particiones el tope se consume entre todas — otra razón para señalar el
398
+ * truncado y no callar (documentado en el README).
399
+ */
400
+ async listObjects(subject, relation, objectType, partition, _page) {
401
+ assertRelationDeclared(this.#config, { type: objectType }, relation);
402
+ assertScope(partition);
403
+ const partitionKey = scopeKey(partition);
404
+ const user = this.#subjectUser(subject, partitionKey);
405
+ const context = this.#checkContext(this.#now());
406
+ const response = await this.#guard('listObjects', () => this.client.listObjects({ user, relation, type: objectType, context }, { consistency: this.#consistency }));
407
+ const raw = response.objects ?? [];
408
+ const truncated = raw.length >= this.#listObjectsMax;
409
+ const objects = [];
410
+ for (const full of raw) {
411
+ const parsed = this.#parseObjectId(full);
412
+ if (!parsed || parsed.type !== objectType) {
413
+ this.#warnUnparseable('listObjects', full);
414
+ continue;
415
+ }
416
+ if (parsed.partitionKey !== partitionKey)
417
+ continue; // otra partición: no cruza
418
+ objects.push({ type: objectType, id: parsed.objectUuid });
419
+ }
420
+ objects.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
421
+ return truncated ? { objects, truncated: true } : { objects };
422
+ }
423
+ /**
424
+ * `listSubjects` = `Read` por `(object, relation)` — hechos DIRECTOS del
425
+ * relation EXACTO (invariante 7): ni transitivo (eso es `membersOf`) ni
426
+ * derivado por includes. `usersetSubjects: true` ⇒ los usersets
427
+ * (`group#member`) salen junto con los holders.
428
+ */
429
+ async listSubjects(relation, object, partition, _page) {
430
+ assertRelationDeclared(this.#config, object, relation);
431
+ assertScope(partition);
432
+ const partitionKey = scopeKey(partition);
433
+ const objectId = this.#objectId(object.type, partitionKey, object.id);
434
+ // `Read` devuelve tuplas ESCRITAS, no evaluadas: la caducidad se filtra en
435
+ // cliente con el MISMO reloj del driver (R-15, estricta: la que vence en
436
+ // `now` ya no cuenta), como las enumeraciones del driver de roles.
437
+ const now = this.#now();
438
+ const tuples = await this.#readAll('listSubjects', { object: objectId, relation });
439
+ const subjects = [];
440
+ for (const t of tuples) {
441
+ if (t.validUntil && t.validUntil.getTime() <= now.getTime())
442
+ continue;
443
+ const subject = this.#userToSubject(t.user);
444
+ if (subject)
445
+ subjects.push(subject);
446
+ else
447
+ this.#warnUnparseable('listSubjects', t.user);
448
+ }
449
+ return { subjects };
450
+ }
451
+ /**
452
+ * **`membersOf` ⇒ 500 `E_AUTHZ_UNSUPPORTED`** (cara `whenFalse`): la
453
+ * membresía TRANSITIVA de un grupo sería `ListUsers`, que trunca al tope del
454
+ * servidor sin señal fiable. No hay userset transitivo enumerable aquí; se
455
+ * dice con 500 nombrándolo, jamás un userset a medias. Es el análogo del par
456
+ * `purgeRole` de la 3b. (El método existe en el prototipo para que el manager
457
+ * lo despache, pero la capacidad `membersOfNative: false` lo corta antes.)
458
+ */
459
+ async membersOf() {
460
+ throw new UnsupportedOperationError('membersOf', 'la membresía transitiva de un grupo', 'openfga', `En 'openfga' la transitiva sería 'ListUsers', que trunca al tope del servidor sin señal fiable. ` +
461
+ `Usa el driver 'database' (CTE recursiva) o 'listSubjects' para los hechos directos.`);
462
+ }
463
+ /* ── ORIGEN de reconcile (4-5): las tuplas de relación de la partición ──── */
464
+ /**
465
+ * **`enumerateRelations` — el ORIGEN de `authz:reconcile` de relaciones**
466
+ * (4-5). Lee el store ENTERO (`Read({})`) —la única forma de ver todo sin un
467
+ * filtro por `user` u `object`, que un `Read` por tipo-solo sin user
468
+ * rechazaría con 400— y devuelve SOLO las tuplas de relación de la partición
469
+ * pedida: tipo `group` o un tipo declarado en la config, DESCARTANDO las de
470
+ * `facts` (`scope`/`role`/`role_binding`, que no llevan la partición en el id
471
+ * con la forma `<key>|<uuid>` de relaciones —o cuyo tipo no está declarado—).
472
+ *
473
+ * `truncated`/paginación: `enumerateRelations` agota el `Read` (exhaustivo,
474
+ * como `listSubjects`/`purge*`), así que devuelve TODO en una página sin
475
+ * cursor —el reconcile itera hasta que no hay cursor—. Es el ORIGEN de una
476
+ * migración: una tupla que no llegara aquí desaparecería sin rastro — por eso
477
+ * la CADUCADA también llega, con su `expiresAt` (R-15), para que el destino
478
+ * la cuente en `skipped`.
479
+ *
480
+ * **Coste: O(tuplas del store) por PASADA** (2.4.0-alpha.6): es un barrido
481
+ * entero por llamada, sea cual sea el tamaño de la partición (medido: 5 000
482
+ * tuplas ⇒ 50 páginas ≈ 170 ms; 15 000 ⇒ 151 páginas ≈ 0,5 s). Para K
483
+ * particiones usa `enumerateRelationsMany`: UN barrido en vez de K.
484
+ */
485
+ async enumerateRelations(partition, _page) {
486
+ assertScope(partition);
487
+ return this.#sweep([partition], 'enumerateRelations');
488
+ }
489
+ /**
490
+ * **`enumerateRelationsMany` — K particiones en UN barrido** (2.4.0-alpha.6;
491
+ * panel relay, analista §3: con 3 particiones 1,4 s → 0,46 s). MISMA
492
+ * semántica que `enumerateRelations` por cada partición pedida —exhaustiva,
493
+ * sin filtrar la caducada, todo en una página sin cursor— y cada tupla
494
+ * viene con SU `partition` (la referencia que pasó el llamante, casada por
495
+ * `scopeKey` ENTERA), de modo que el llamante reparte por `partition`.
496
+ * Reparte un solo `Read({})` paginado del store entero.
497
+ *
498
+ * **Lo que este método NO hace, a propósito: un `Read` por PREFIJO de id.**
499
+ * OpenFGA no tiene prefix-match: `Read({ object: 'document:unit|<uuid>|' })`
500
+ * responde **200 con cero tuplas** (medido por el panel, auditor 🔴 3) — en
501
+ * el ORIGEN de una migración eso es «esta partición está vacía» en silencio,
502
+ * y en una purga, «demostrado cero» sin haber borrado nada. El único filtro
503
+ * legal es `{}`; la partición se decide comparando la CLAVE entera del id
504
+ * parseado desde la derecha (`#parseObjectId`), así que `app` no arrastra a
505
+ * `appliance|…`, `org|U` no arrastra a `organization|U` y cada ortografía del
506
+ * uuid es su propia partición. La suite espía el FILTRO de cada `read`.
507
+ *
508
+ * **Exhaustivo o ruidoso**: una página con cero tuplas y token de
509
+ * continuación NO es el fin; un token que se repite, más de
510
+ * `MAX_READ_PAGES` páginas o una respuesta sin `tuples` son 503
511
+ * `E_AUTHZ_BACKEND_UNAVAILABLE` (`#readAll`); una tupla que no se entiende
512
+ * se cuenta en `diagnostics.unparseableTuples` y se avisa. Sin particiones
513
+ * es 500 `E_AUTHZ_CONFIG` con cero lecturas: no hay «éxito vacío».
514
+ */
515
+ async enumerateRelationsMany(partitions, _page) {
516
+ if (!Array.isArray(partitions) || partitions.length === 0) {
517
+ throw new AuthorizationConfigError('enumerateRelationsMany: sin particiones no hay nada que enumerar (y un barrido del store para nadie no es un éxito vacío). ' +
518
+ 'Pasa al menos una partición.');
519
+ }
520
+ for (const partition of partitions)
521
+ assertScope(partition);
522
+ return this.#sweep(partitions, 'enumerateRelationsMany');
523
+ }
524
+ /** El barrido: UN `Read({})` paginado del store entero, repartido por la clave ENTERA de partición. */
525
+ async #sweep(partitions, operation) {
526
+ // La primera referencia de cada clave es la que viaja en `tuple.partition`
527
+ // (dos ortografías del mismo uuid son dos claves, como en `enumerateRelations`).
528
+ const wanted = new Map();
529
+ for (const partition of partitions) {
530
+ const key = scopeKey(partition);
531
+ if (!wanted.has(key))
532
+ wanted.set(key, partition);
533
+ }
534
+ const relationTypes = new Set([FACTS_GROUP_TYPE, ...this.#config.objectTypes.map((t) => t.type)]);
535
+ const all = await this.#readAll(operation, {});
536
+ const tuples = [];
537
+ for (const key of all) {
538
+ const parsed = this.#parseObjectId(key.object);
539
+ // No es una tupla de relación (facts: scope/role/role_binding, o un tipo
540
+ // no declarado): no la migra este puerto.
541
+ if (!parsed || !relationTypes.has(parsed.type))
542
+ continue;
543
+ const partition = wanted.get(parsed.partitionKey);
544
+ if (partition === undefined)
545
+ continue; // otra partición: no cruza (clave ENTERA, nunca prefijo)
546
+ const subject = this.#userToSubject(key.user);
547
+ if (!subject) {
548
+ this.#warnUnparseable(operation, key.user);
549
+ continue;
550
+ }
551
+ tuples.push({
552
+ subject,
553
+ relation: key.relation,
554
+ object: { type: parsed.type, id: parsed.objectUuid },
555
+ partition,
556
+ expiresAt: key.validUntil,
557
+ });
558
+ }
559
+ return { tuples };
560
+ }
561
+ /* ── Purga (invariante 11): borra y DEMUESTRA cero, o lanza ─────────────── */
562
+ /**
563
+ * Borra TODAS las tuplas del objeto (cualquier relación) y demuestra cero.
564
+ * Barre las DOS ortografías del uuid de partición (🟡2, paridad con
565
+ * `database`): un motor que funda `aaa…` con `aaa-…` dejaría la fila del
566
+ * alias concediendo tras una purga «exitosa». Si tras borrar quedan tuplas
567
+ * ⇒ 500 `E_AUTHZ_PURGE_INCOMPLETE`.
568
+ */
569
+ async purgeObject(object, partition, options) {
570
+ this.#rejectTransaction('purgeObject', options);
571
+ // alpha.3 · F-05 (solo el TIPO: una purga no nombra relación), antes de componer el id.
572
+ // Cierre-2: la función de TIPO, no la estricta con relación opcional.
573
+ assertObjectTypeDeclared(this.#config, object);
574
+ assertScope(partition);
575
+ const objectIds = this.#partitionSpellings(partition).map((key) => this.#objectId(object.type, key, object.id));
576
+ const progress = { deleted: 0 };
577
+ try {
578
+ for (const objectId of objectIds) {
579
+ await this.#purgeByFilter('purgeObject', { object: objectId }, `objeto ${objectId}`, progress);
580
+ }
581
+ }
582
+ catch (error) {
583
+ throw this.#partialIfDeleted(error, progress);
584
+ }
585
+ }
586
+ /**
587
+ * Borra TODAS las tuplas cuyo SUJETO es este subject en la partición, en
588
+ * cualquier tipo de objeto declarado, y demuestra cero. `Read` exige un
589
+ * filtro de objeto (un `Read` solo por `user` es un 400 del servidor), así
590
+ * que se recorre cada tipo declarado (`group` + los del consumidor) con
591
+ * `Read({ user, object: '<type>:' })`.
592
+ */
593
+ async purgeSubject(subject, partition, options) {
594
+ this.#rejectTransaction('purgeSubject', options);
595
+ // alpha.3 · F-05 del USERSET (una purga por sujeto no nombra objeto), antes de nada.
596
+ if (isRelUserset(subject))
597
+ assertRelationDeclared(this.#config, subject.object, subject.relation);
598
+ assertScope(partition);
599
+ if (!isRelUserset(subject))
600
+ assertSubject(subject);
601
+ const partitionKeys = this.#partitionSpellings(partition);
602
+ const types = [FACTS_GROUP_TYPE, ...this.#config.objectTypes.map((t) => t.type)];
603
+ const progress = { deleted: 0 };
604
+ try {
605
+ for (const partitionKey of partitionKeys) {
606
+ const user = this.#subjectUser(subject, partitionKey);
607
+ for (const type of types) {
608
+ await this.#purgeByFilter('purgeSubject', { user, object: `${type}:` }, `sujeto ${user} en '${type}'`, progress);
609
+ }
610
+ }
611
+ }
612
+ catch (error) {
613
+ throw this.#partialIfDeleted(error, progress);
614
+ }
615
+ }
616
+ /**
617
+ * **Una purga que muere a MEDIAS es indeterminada** (invariante 13,
618
+ * 2.4.0-alpha.3, cierre del 🟡 6 del auditor). La purga NO es un `Write`:
619
+ * son N `Read`+`deleteTuples` (ortografías × tipos, y lotes de
620
+ * `WRITE_BATCH_SIZE`), y un 503 que no es timeout —o el 500
621
+ * `E_AUTHZ_PURGE_INCOMPLETE` de la demostración de cero— puede llegar
622
+ * después de haber borrado parte. Si YA se borró al menos una tupla, el
623
+ * error se marca (`markPartialWrite`, `src/errors.ts`: una propiedad, la
624
+ * clase y el `code` no cambian) y el `RelationsManager` notifica
625
+ * `onRelationWrite` con `indeterminate: true` ANTES de propagarlo — «puede
626
+ * haber ocurrido», como con el deadline. Si no se borró nada, el error va
627
+ * tal cual: «esa escritura no ocurrió» sigue siendo verdad. En `database`
628
+ * la purga es UN `DELETE` y no necesita la marca.
629
+ */
630
+ #partialIfDeleted(error, progress) {
631
+ if (progress.deleted > 0 && error !== null && typeof error === 'object')
632
+ return markPartialWrite(error);
633
+ return error;
634
+ }
635
+ /** Lee por filtro, borra en lotes, y RE-LEE para demostrar cero (o lanza). `progress.deleted` cuenta lo que YA se borró (un lote que falla no borró nada: el `Write` de FGA es transaccional). */
636
+ async #purgeByFilter(operation, filter, what, progress) {
637
+ const keys = await this.#readAll(operation, filter);
638
+ for (let i = 0; i < keys.length; i += WRITE_BATCH_SIZE) {
639
+ const batch = keys.slice(i, i + WRITE_BATCH_SIZE);
640
+ await this.#guard(operation, () => this.client.deleteTuples(batch.map((k) => ({ user: k.user, relation: k.relation, object: k.object })), IGNORE_MISSING_DELETES));
641
+ progress.deleted += batch.length;
642
+ }
643
+ if (keys.length === 0)
644
+ return;
645
+ // Demostración de cero: re-lee tras borrar (invariante 11).
646
+ const residue = await this.#readAll(operation, filter);
647
+ if (residue.length > 0) {
648
+ throw new PurgeIncompleteError(`${operation}: quedan ${residue.length} tuplas de ${what} tras el borrado ` +
649
+ `(${residue.slice(0, 3).map((k) => `${k.user}#${k.relation}@${k.object}`).join('; ')}). ` +
650
+ `No se confirma la purga.`);
651
+ }
652
+ }
653
+ /* ── Helpers ────────────────────────────────────────────────────────────── */
654
+ /** Las dos ortografías del uuid de partición, como claves de scope (🟡2). */
655
+ #partitionSpellings(partition) {
656
+ return [...new Set(scopeSpellings(partition).map((s) => scopeKey(s)))];
657
+ }
658
+ /**
659
+ * TODAS las tuplas que casan con el filtro, paginando `Read` hasta agotar el
660
+ * `continuation_token`. `Read` no tiene tope de resultados (a diferencia de
661
+ * `ListObjects`), así que es exhaustivo — por eso las purgas y `listSubjects`
662
+ * van por aquí, no por `ListObjects`.
663
+ */
664
+ async #readAll(operation, filter) {
665
+ const keys = [];
666
+ let continuationToken;
667
+ const seenTokens = new Set();
668
+ let pages = 0;
669
+ do {
670
+ const response = await this.#guard(operation, () => this.client.read(filter, {
671
+ pageSize: READ_PAGE_SIZE,
672
+ continuationToken,
673
+ consistency: this.#consistency,
674
+ }));
675
+ pages += 1;
676
+ // «Exhaustivo o ruidoso» (alpha.6): una respuesta que no trae `tuples`
677
+ // no se entiende — no es una página vacía. Un 200 sin forma en el ORIGEN
678
+ // de una migración sería «no hay nada» en silencio.
679
+ if (!Array.isArray(response?.tuples)) {
680
+ throw new AuthorizationBackendError('openfga-relations', operation, new Error(`la respuesta del Read (página ${pages}) no trae 'tuples'; no se toma por vacía`));
681
+ }
682
+ for (const tuple of response.tuples) {
683
+ const key = tuple?.key;
684
+ if (!key?.user || !key?.relation || !key?.object) {
685
+ this.#warnUnparseable(operation, JSON.stringify(key ?? null));
686
+ continue;
687
+ }
688
+ // Con su caducidad (R-15): quien la lee decide si filtra (`listSubjects`),
689
+ // la emite (`enumerateRelations`) o la ignora (`purge*` borra TODO).
690
+ keys.push({
691
+ user: key.user,
692
+ relation: key.relation,
693
+ object: key.object,
694
+ validUntil: toExpiryDate(key.condition?.context?.valid_until),
695
+ });
696
+ }
697
+ continuationToken = response.continuation_token || undefined;
698
+ if (continuationToken) {
699
+ if (seenTokens.has(continuationToken)) {
700
+ throw new AuthorizationBackendError('openfga-relations', operation, new Error(`el continuation_token se repite (página ${pages}); el servidor no avanza`));
701
+ }
702
+ if (pages >= MAX_READ_PAGES) {
703
+ throw new AuthorizationBackendError('openfga-relations', operation, new Error(`más de ${MAX_READ_PAGES} páginas sin agotar el Read`));
704
+ }
705
+ seenTokens.add(continuationToken);
706
+ }
707
+ } while (continuationToken);
708
+ return keys;
709
+ }
710
+ #warnUnparseable(operation, detail) {
711
+ this.diagnostics.unparseableTuples += 1;
712
+ this.#logger.warn(`authz(openfga-relations): tupla no parseable en ${operation} (${detail}); ` +
713
+ `se ignora en la enumeración (total: ${this.diagnostics.unparseableTuples})`);
714
+ }
715
+ }
716
+ // Cierre-2 de alpha.3 (⚪ 5 del re-ataque): la clasificación de timeout es la
717
+ // de `src/shared/backend_guard.ts` —`isTimeoutLike`: `KnexTimeoutError`,
718
+ // `.timeout` numérico, `ECONNABORTED`/`ETIMEDOUT`, por la cadena de `cause`—,
719
+ // la MISMA que importa el driver de roles. Hasta el cierre-2 este fichero
720
+ // duplicaba la función con una rama por SUBSTRING del mensaje (`/timeout/i`),
721
+ // de modo que un 400 de validación de FGA o el cuerpo de un 502 que mencionara
722
+ // la palabra salía como 503 `E_AUTHZ_BACKEND_TIMEOUT` con `indeterminate: true`
723
+ // sin haber borrado nada (medido por el auditor: `alpha3-cierre-parcial.ts` B).
724
+ //# sourceMappingURL=openfga_relations_driver.js.map