@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
@@ -1,43 +1,23 @@
1
1
  import db from '@adonisjs/lucid/services/db';
2
2
  import { OpenFgaClient, ClientWriteRequestOnDuplicateWrites, ClientWriteRequestOnMissingDeletes, ConsistencyPreference, } from '@openfga/sdk';
3
- import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, PurgeIncompleteError, StoreNotEmptyError, UnknownPermissionError, } from '../errors.js';
3
+ import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, MassReconcileRefusedError, ReconcileTooLargeError, PurgeIncompleteError, ScopeCycleError, ScopeDriftUnguardedError, ScopeTreeDriftError, UnknownPermissionError, UnknownRoleError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
4
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';
5
+ import { AUTHZ_TABLES_ORIGIN, RECONCILE_MAX_DETAILS, emptyReconcilePhases as emptyPhases, reconcileBatchSize, reconcileMaxTuples, sumReconcilePhases as sumPhases, } from '../reconcile.js';
6
+ import { assertRoleAssignableAt, declaredRoleAt, fromDbScopeUuid, hasRoleTargets, resolveRoleQuery, rolesToRevoke, visibleRoleFor, } from './database_driver.js';
7
+ import { assertCatalogUuid, assertIdentity, assertScope, chainKeysFrom, isCatalogUuid, isValidScope, normalizeRoleQuery, scopeFromKey, scopeKey, } from '../identity.js';
7
8
  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';
9
+ import { assertKnownScope, canonicalScope, canonicalScopeTargets, guardSql, isAuthzError, isTimeoutLike, resolveChain, rootOnlyResolver, withDeadline, } from '../shared/backend_guard.js';
10
+ import { CatalogCache, GLOBAL_OWNER_KEY, assertCatalogOptions, isRoleVisibleWith, withAuthzCatalogWrite } from '../catalog/catalog_cache.js';
11
+ import { FACTS_ASSIGNEE_RELATION, FACTS_BINDING_RELATION, FACTS_BINDING_TYPE, FACTS_DENIED_PREFIX, FACTS_GROUP_TYPE, FACTS_MAX_RESOLVE_DEPTH, FACTS_PARENT_RELATION, FACTS_PERMITS_PREFIX, FACTS_ROLE_RELATION, FACTS_ROLE_TYPE, FACTS_ROOTED_RELATION, FACTS_SCOPE_TYPE, assertFactsModelPublishable, assertHolderTypes, factsBindingObject, factsBindingTuples, factsCatalogTuples, factsDenyTuple, factsParentTuple, factsRelationsOf, factsRootTuples, factsScopeBindingTuple, factsScopeObject, factsTupleId, openFgaFactsModel, } from './openfga_facts.js';
12
+ import { readCatalogProjectionSnapshot } from '../catalog/catalog.js';
13
+ import { isSqliteDialect, sqlExpiryCodec } from '../shared/sql_expiry.js';
10
14
  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
15
  /**
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.
16
+ * La inyectividad de `holderTypes` la comprueba el módulo del modelo
17
+ * (`openfga_facts.ts`, compartido por los dos generadores). Se re-exporta
18
+ * desde aquí porque el subpath `/openfga` es la puerta publicada.
21
19
  */
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.`);
39
- }
40
- }
20
+ export { assertHolderTypes };
41
21
  // La clave de scope del id del binding (`app` | `<tipo>|<uuid>`) es la
42
22
  // misma `scopeKey` del paquete (`identity.ts`; desde 3B también el owner de
43
23
  // un rol local): `|` es el separador y `assertScope` impide que un
@@ -45,8 +25,9 @@ export function assertHolderTypes(holderTypes) {
45
25
  // rechaza `{app, uuid}` (L0.10).
46
26
  /**
47
27
  * 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
28
+ * → scope + uuid del ROL (3b-2k · K2: el `deny_binding`, que era el otro
29
+ * consumidor de esta gramática, se fue con el modo `resolver`; el deny es hoy
30
+ * una relación del scope). Se parsea DESDE LA DERECHA (3A · A1): el último componente
50
31
  * es el uuid y el resto la clave del scope, que tiene 1 parte (`app`) o 2
51
32
  * (`<tipo>|<uuid>`). Antes el último componente era el slug codificado
52
33
  * (`docs~read`) y el parseo contaba partes: ambiguo en cuanto la clave del
@@ -57,7 +38,7 @@ export function assertHolderTypes(holderTypes) {
57
38
  * gramática —el scope, la de identidad; el uuid, la de UUID canónico del
58
39
  * catálogo—: un id que el driver no escribiría no es un hecho del motor
59
40
  * 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`.
41
+ * 2.2 no los lee, y `authz:reconcile` (3b-3) los reportará como deriva.
61
42
  * Exportada para probarla sin servidor.
62
43
  */
63
44
  export function parseBindingId(id) {
@@ -136,61 +117,45 @@ export function correlateBatchResults(checks, results) {
136
117
  return aligned;
137
118
  }
138
119
  /**
139
- * El authorization model en formato JSON del API de FGA, generado a partir
140
- * de los holders del consumidor. El mismo mapa debe usarse al construir el
141
- * driver: si difieren, los checks no encuentran las tuplas.
142
- */
143
- export function openFgaAuthorizationModel(holderTypeMap) {
144
- assertHolderTypes(holderTypeMap);
145
- const holderTypes = Object.values(holderTypeMap);
146
- const direct = holderTypes.map((type) => ({ type }));
147
- const directWithExpiry = [
148
- ...direct,
149
- ...holderTypes.map((type) => ({ type, condition: 'not_expired' })),
150
- ];
151
- return {
152
- schema_version: '1.1',
153
- type_definitions: [
154
- ...holderTypes.map((type) => ({ type, relations: {}, metadata: null })),
155
- {
156
- type: 'role_binding',
157
- relations: { assignee: { this: {} } },
158
- metadata: {
159
- relations: { assignee: { directly_related_user_types: directWithExpiry } },
160
- },
161
- },
162
- {
163
- type: 'deny_binding',
164
- relations: { denied: { this: {} } },
165
- metadata: {
166
- relations: { denied: { directly_related_user_types: direct } },
167
- },
168
- },
169
- ],
170
- conditions: {
171
- not_expired: {
172
- name: 'not_expired',
173
- expression: 'current_time < valid_until',
174
- parameters: {
175
- current_time: { type_name: 'TYPE_NAME_TIMESTAMP' },
176
- valid_until: { type_name: 'TYPE_NAME_TIMESTAMP' },
177
- },
178
- },
179
- },
180
- };
181
- }
182
- /**
183
- * Crea un store nuevo + escribe el authorization model derivado de los
184
- * holders del consumidor. Para bootstrap de un appliance o del harness de
185
- * tests. El `name` lo decide el caller (el comando openfga:provision
186
- * resuelve APP_NAME del entorno — el motor no lee env).
120
+ * Crea un store nuevo + escribe el authorization model **`facts` (c2r)**
121
+ * derivado de los holders y de los PERMISOS del consumidor (3b-2k · K2:
122
+ * antes escribía el modelo del modo `resolver`, que ya no existe). Para
123
+ * bootstrap de un appliance o del harness de tests. El `name` lo decide el
124
+ * caller (el comando `openfga:provision` resuelve APP_NAME del entorno — el
125
+ * motor no lee env).
126
+ *
127
+ * Los permisos entran aquí porque el modelo (c2r) declara CUATRO relaciones
128
+ * por permiso: un store provisionado sin ellos no puede responder a ninguna
129
+ * pregunta. `assertFactsModelPublishable` comprueba las cotas (nombre de
130
+ * relación y techo de 262.144 bytes ⇒ 500 `E_AUTHZ_MODEL_TOO_LARGE`) **antes
131
+ * de crear el store** (2.4.0-alpha.4): un catálogo impublicable no deja un
132
+ * store huérfano ni sale como error crudo del SDK.
133
+ * Añadir un permiso al catálogo obliga a republicar el modelo: es lo que
134
+ * hace `syncAuthzCatalog` con la proyección inyectada, y por eso el modelo
135
+ * versionado del store se escribe con `--store-id`.
136
+ *
137
+ * **Y los tipos de RELACIÓN (ReBAC) van FUSIONADOS** (Fase 4-8): si el
138
+ * consumidor declaró `relations.config`, `openfga:provision` los pasa aquí y
139
+ * el modelo publicado los incluye, de modo que un store recién aprovisionado
140
+ * ya acepta tuplas de relación (`document#viewer`…) SIN un
141
+ * `authz:catalog:sync` previo. Sin ellos el modelo es facts-only y una tupla
142
+ * de relación es un `validation_error` del servidor («type 'document' not
143
+ * found»). El gate de bytes mide el modelo FUSIONADO.
187
144
  */
188
- export async function provisionOpenFgaStore(apiUrl, name, holderTypeMap) {
145
+ export async function provisionOpenFgaStore(apiUrl, name, holderTypeMap, permissions, relations) {
146
+ // El modelo se construye y se VALIDA antes de crear el store (2.4.0-alpha.4,
147
+ // ⚪ 7 del auditor): hasta alpha.3 el `createStore` iba primero, así que un
148
+ // catálogo inválido (slugs que colisionan, `holderTypes` no inyectivo) o
149
+ // que desborda el techo de bytes dejaba un store HUÉRFANO sin modelo en el
150
+ // servidor por cada intento, y el techo llegaba como error crudo del SDK
151
+ // en vez de 500 `E_AUTHZ_MODEL_TOO_LARGE`. Cero requests si no es publicable.
152
+ assertFactsModelPublishable(holderTypeMap, permissions, undefined, relations);
153
+ const model = openFgaFactsModel(holderTypeMap, permissions, relations);
189
154
  const client = new OpenFgaClient({ apiUrl });
190
155
  const store = await client.createStore({ name });
191
156
  const scoped = new OpenFgaClient({ apiUrl, storeId: store.id });
192
- const model = await scoped.writeAuthorizationModel(openFgaAuthorizationModel(holderTypeMap));
193
- return { storeId: store.id, modelId: model.authorization_model_id };
157
+ const written = await scoped.writeAuthorizationModel(model);
158
+ return { storeId: store.id, modelId: written.authorization_model_id };
194
159
  }
195
160
  /**
196
161
  * ¿El error (o su cadena de causas) es el rechazo de FGA a escribir una tuple
@@ -199,8 +164,15 @@ export async function provisionOpenFgaStore(apiUrl, name, holderTypeMap) {
199
164
  * "alguien escribió antes" y se propagan clasificados, con el error del SDK
200
165
  * como causa (D6). Verificado contra OpenFGA v1.19: el duplicado llega como
201
166
  * 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í.
167
+ * mensaje "cannot write a tuple which already exists".
168
+ *
169
+ * **Y el 409 tiene nombre propio** (3b-2f · R3, medido contra el servidor):
170
+ * es el `Aborted` de un `Write` transaccional cuyas tuplas escribió otra
171
+ * transacción a la vez ("transactional write failed due to conflict: one or
172
+ * more tuples to write were inserted by another transaction"). Dice lo mismo
173
+ * —otro escritor llegó antes— y se trata igual: releer y re-aplicar. Lo que
174
+ * NO dice es QUÉ tupla chocó, así que quién existía lo decide la relectura y
175
+ * nunca el mensaje.
204
176
  */
205
177
  export function isDuplicateWrite(error) {
206
178
  let current = error;
@@ -215,6 +187,27 @@ export function isDuplicateWrite(error) {
215
187
  }
216
188
  return false;
217
189
  }
190
+ /**
191
+ * ¿El `Write` de una arista del árbol chocó con OTRO escritor? (3b-2h · 🟠 4)
192
+ * Son los dos lados del mismo choque: la tupla que se escribe ya está
193
+ * (`isDuplicateWrite`) o la que se borra ya no está —la acaba de borrar el
194
+ * otro—. En `reparent` las dos vienen SIEMPRE de una carrera: la lista de
195
+ * deletes se acaba de leer del store. Cualquier otro fallo del write se
196
+ * propaga clasificado, como hasta ahora.
197
+ */
198
+ function isTreeWriteRace(error) {
199
+ if (isDuplicateWrite(error))
200
+ return true;
201
+ let current = error;
202
+ for (let depth = 0; current && depth < 6; depth++) {
203
+ if (current.apiErrorCode === 'write_failed_due_to_invalid_input' &&
204
+ /does not exist/i.test(String(current.apiErrorMessage ?? current.message ?? ''))) {
205
+ return true;
206
+ }
207
+ current = current.cause;
208
+ }
209
+ return false;
210
+ }
218
211
  /**
219
212
  * Receta que acompaña al 503 de un `grant` SIN `expiresAt` cuando no se pudo
220
213
  * leer la caducidad vigente: preservar exige saber qué hay, y asumir
@@ -272,11 +265,65 @@ function guardBackendErrors(client, timeoutMs) {
272
265
  },
273
266
  });
274
267
  }
268
+ /**
269
+ * **El gate de construcción de `facts`** (3b-2d, panel 2 cruce 4 · S5).
270
+ *
271
+ * En `hierarchy: 'facts'` el árbol vive en el store de FGA y FGA es el PDP.
272
+ * El consumidor notifica `scopes.moved` dentro de su transacción, el paquete
273
+ * escribe la arista en FGA… y si esa transacción hace `rollback` —una
274
+ * constraint, una validación, un timeout de pool: no hace falta un crash—
275
+ * la escritura de FGA NO se deshace. SQL sigue diciendo que la unit es del
276
+ * tenant A y FGA que es del B: todos los holders con rol en B tienen acceso
277
+ * a una unidad de A, y la aplicación, que lista y audita contra SQL, no
278
+ * puede verlo. La ventana no es un hueco entre dos operaciones: dura hasta
279
+ * que alguien lo descubra.
280
+ *
281
+ * Por eso `scopes.outbox` no puede ser una recomendación: una recomendación
282
+ * no es un mecanismo. O está el puerto, o está la firma del dueño.
283
+ */
284
+ export function assertScopeDriftGuarded(options) {
285
+ if (options.outbox)
286
+ return;
287
+ if (options.acceptScopeDriftRisk === true)
288
+ return;
289
+ throw new ScopeDriftUnguardedError('OpenFgaAuthorizationDriver necesita la outbox del árbol: pasa la misma ' +
290
+ "'scopes.outbox' del config (el manager encola ahí los cambios del árbol dentro de TU transacción y " +
291
+ "'authz:scopes:relay' los aplica). Sin ella, un rollback de tu transacción deja el árbol de FGA " +
292
+ 'adelantado al tuyo y esa escalada no se ve desde tu base. Si mueves el árbol solo desde la ' +
293
+ "plataforma y lo asumes, dilo por escrito con acceptScopeDriftRisk: true.");
294
+ }
275
295
  export const DEFAULT_TIMEOUT_MS = 5_000;
296
+ /**
297
+ * Opciones de un `Write` que da por buena una tupla que ya estaba. Solo se
298
+ * usa donde el duplicado es IDÉNTICO por construcción (las aristas de (c2),
299
+ * que no llevan condición) o donde ya se sabe que la asignación no existe:
300
+ * en FGA la condición NO es parte de la clave, así que ignorar duplicados
301
+ * sobre una tupla con caducidad se quedaría la vieja (S7).
302
+ */
303
+ const IGNORE_DUPLICATE_WRITES = {
304
+ conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore },
305
+ };
306
+ /**
307
+ * Vueltas que da el `grant` ante un choque de escritura antes de rendirse con
308
+ * un 409 (3b-2f · R3). Dos bastan para el caso real —la primera descubre que
309
+ * la estructura ya estaba, la segunda la da por buena—; la tercera es el
310
+ * margen para una contención de verdad.
311
+ */
312
+ const GRANT_WRITE_ATTEMPTS = 3;
313
+ /** Vueltas de relectura de una arista del árbol antes de decir 409 (3b-2h · 🟠 4). */
314
+ const TREE_WRITE_ATTEMPTS = 3;
276
315
  /** Tope de operaciones por `Write` en FGA (verificado: `exceeded_entity_limit` a partir de 100). */
277
316
  const PURGE_BATCH_SIZE = 100;
278
317
  /** Tamaño de página de `Read` (máximo del servidor). */
279
318
  const READ_PAGE_SIZE = 100;
319
+ /**
320
+ * El deny del modo `resolver` (≤ 2.2), que el modelo de hoy ni declara pero
321
+ * un store de aquella versión sí tiene: `enumerateFacts` lo EMITE como deny
322
+ * (3b-8 · A2) porque `reconcile` es el sustituto documentado del importador
323
+ * y perderlo en silencio rompía el invariante 2 con el verificador en verde.
324
+ */
325
+ const LEGACY_DENY_BINDING_TYPE = 'deny_binding';
326
+ const LEGACY_DENIED_RELATION = 'denied';
280
327
  /**
281
328
  * Cota de páginas de una enumeración (1.000.000 de tuplas a 100 por página).
282
329
  * Un `continuation_token` que no avanza —un servidor roto, un proxy o una
@@ -285,199 +332,60 @@ const READ_PAGE_SIZE = 100;
285
332
  */
286
333
  export const MAX_READ_PAGES = 10_000;
287
334
  /**
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;
331
- /**
332
- * Migración de hechos database → openfga: copia las asignaciones vigentes y
333
- * los denies de las tablas `authz_*` como tuples del store FGA.
334
- *
335
- * - COPIA, no mueve: las tablas locales quedan intactas → el rollback es
336
- * volver a AUTHZ_DRIVER=database (solo se pierde lo escrito mientras se
337
- * operó con openfga). El catálogo y la jerarquía nunca migran: son
338
- * metadata local para ambos drivers.
339
- * - Las asignaciones ya expiradas se saltan (no tiene sentido copiarlas);
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.
335
+ * Tope de saltos al subir el árbol DEL STORE (3b-2e · E1). No es el techo de
336
+ * decisión —ese lo pone el servidor al evaluar (c2) y está medido en
337
+ * `FACTS_MAX_RESOLVE_DEPTH`—: es la red del recorrido, para que un árbol con
338
+ * una deriva que el anti-ciclos no vio no deje el proceso dando vueltas.
350
339
  */
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
- }
356
- const client = new OpenFgaClient({
357
- apiUrl: options.apiUrl,
358
- storeId: options.storeId,
359
- authorizationModelId: options.modelId,
360
- });
361
- const now = (options.now ?? systemClock)();
362
- const result = {
363
- written: 0,
364
- updated: 0,
365
- unchanged: 0,
366
- extra: 0,
367
- deleted: 0,
368
- skippedExpired: 0,
369
- dryRun: options.dryRun ?? false,
370
- };
371
- const rowScope = (row) => ({
372
- type: row.scope_type,
373
- uuid: row.scope_uuid === APP_SCOPE_DB_UUID ? null : row.scope_uuid,
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');
384
- for (const row of assignments) {
385
- const expiresAt = expiry.fromDb(row.expires_at);
386
- if (expiresAt && expiresAt <= now) {
387
- result.skippedExpired++;
388
- continue;
389
- }
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
- });
398
- }
399
- const denies = await db
400
- .from('authz_denies as d')
401
- .join('authz_permissions as p', 'p.uuid', 'd.permission_uuid')
402
- .select('d.holder_type', 'd.holder_uuid', 'd.scope_type', 'd.scope_uuid')
403
- .select('p.uuid as permission_uuid');
404
- for (const row of denies) {
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,
340
+ export const MAX_SCOPE_CHAIN_HOPS = 1_000;
341
+ export class OpenFgaAuthorizationDriver {
342
+ /**
343
+ * Lo que este driver declara (3b-2e · E2). Depende del MODO, así que es un
344
+ * getter y no un campo: una vista por prototipo (`withChainResolver`,
345
+ * `withClock`) declara lo mismo que su original.
346
+ *
347
+ * `roleInheritanceNative` y `listObjectsInherited` son `false` **también en
348
+ * `facts`**, y eso es el cruce 6 del panel: `hasRole`/`listRoles`/
349
+ * `listRoleScopes`/`listSubjects`/`listScopes` siguen usando `resolveChain`
350
+ * (en (c2) no hay alternativa, y está medido), y los `list*` enumeran con
351
+ * `Read` paginado, nunca con `ListObjects` (que trunca al tope del servidor
352
+ * sin señal). Lo único que `facts` cambia es `authorize`.
353
+ */
354
+ get capabilities() {
355
+ return Object.freeze({
356
+ hierarchyFacts: true,
357
+ singleCheckAuthorize: true,
358
+ roleInheritanceNative: false,
359
+ listObjectsInherited: false,
360
+ // 3b-2e · E4 / 3b-2j: purgar un rol y contar sus hechos necesitan
361
+ // enumerar sus bindings, y eso lo permiten las aristas de (c2)
362
+ // (`role_binding#role` y `scope#binding`).
363
+ purgeRole: true,
364
+ countRoleAssignments: true,
365
+ // 3b-2k · K1 · R2 (c): la decisión no pasa por el árbol, así que el
366
+ // objeto del store se compone con la ortografía del LLAMANTE y un alias
367
+ // del uuid no encuentra sus hechos (fail-CLOSED).
368
+ canonicalScopeReads: false,
369
+ // 3b-3b: sabe ser el ORIGEN de una migración — sus hechos son tuplas
370
+ // del store, y solo él sabe volverlas a `(holder, rol, scope)`.
371
+ enumerateFacts: true,
372
+ // L-2/L-5 (panel `{trx}`, (C)): `false` EXPLÍCITO, y no puede ser otra
373
+ // cosa — una tupla no entra en una transacción SQL: el store es otro
374
+ // servicio y no hay 2PC, así que «los dos o ninguno» con la transacción
375
+ // del consumidor es una promesa que este driver no puede hacer, y no la
376
+ // hace. `{ transaction }` es 500 `E_AUTHZ_UNSUPPORTED` por llamada, en
377
+ // el manager (puerta 1) Y en el driver (`rejectTransaction`, para quien
378
+ // entre por `manager.driver()`), con CERO llamadas al store (L-5 lo
379
+ // fija contra el `:8101` con espía sobre el cliente). La alternativa
380
+ // que SÍ existe es para el ÁRBOL: `scopes.outbox` encola el cambio en
381
+ // tu transacción y `authz:scopes:relay` lo aplica al store. Para HECHOS
382
+ // (grant/deny) no hay outbox: el panel la descartó (fail-open medido
383
+ // durante el lag); el consumidor lee esta capacidad y ajusta (usa
384
+ // `database` para lo que tenga que ir en su transacción, o
385
+ // `requireTransactionalWrites` para fallar al arrancar).
386
+ transactionalWrites: false,
412
387
  });
413
388
  }
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));
476
- }
477
- }
478
- return result;
479
- }
480
- export class OpenFgaAuthorizationDriver {
481
389
  client;
482
390
  chainResolver;
483
391
  holderTypes;
@@ -525,6 +433,7 @@ export class OpenFgaAuthorizationDriver {
525
433
  // Sin resolutor solo existe la raíz (L0.3: el default plano desapareció).
526
434
  this.chainResolver = options.resolveChain ?? rootOnlyResolver;
527
435
  this.holderTypes = options.holderTypes;
436
+ assertScopeDriftGuarded(options);
528
437
  this.logger = options.logger ?? console;
529
438
  this.timeoutMs = timeoutMs;
530
439
  this.consistency =
@@ -546,10 +455,14 @@ export class OpenFgaAuthorizationDriver {
546
455
  knownScope(scope, operation) {
547
456
  return assertKnownScope(this.chainResolver, scope, operation);
548
457
  }
549
- /** El scope canónico para `revoke`/`removeDeny`/`purgeScope` (ver `canonicalScope`). */
458
+ /** El scope canónico para `scopes.detached`/`purgeScope` (ver `canonicalScope`). */
550
459
  canonicalOrSelf(scope, operation) {
551
460
  return canonicalScope(this.chainResolver, scope, operation);
552
461
  }
462
+ /** Los destinos de un delete de hechos (`revoke`/`removeDeny`): canónico, o el fan-out de alias (3b-8 · A4). */
463
+ canonicalTargets(scope, operation) {
464
+ return canonicalScopeTargets(this.chainResolver, scope, operation);
465
+ }
553
466
  /**
554
467
  * Vista de este driver con OTRO resolutor de ancestros y el mismo estado
555
468
  * (cliente, memo del catálogo, deadline, diagnósticos). Es lo que usa
@@ -641,62 +554,50 @@ export class OpenFgaAuthorizationDriver {
641
554
  return declaredRoleAt(catalog, binding.uuid, binding.scope.type, chainKeys);
642
555
  }
643
556
  // ── 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
- }
673
557
  async authorize(subject, permission, scope) {
674
558
  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).
559
+ // Una foto del catálogo por pregunta: el permiso sale de la misma versión
560
+ // (y se paga una sola revalidación). Es lo ÚNICO local que queda.
677
561
  const catalog = await this.catalog.view();
678
562
  const perm = catalog.permission(permission);
679
563
  if (!perm)
680
564
  return false;
681
- const user = this.fgaSubject(subject);
682
- const chain = await this.chain(scope, 'authorize');
683
- if (!chain)
684
- return false;
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)
689
- return false;
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);
565
+ // La jerarquía, el catálogo y los denies ya están en el store, así que la
566
+ // pregunta entera cabe en UN `Check` (3b-2c). Hasta 3b-2k había además un
567
+ // modo `resolver` que expandía la cadena del consumidor a un `batchCheck`
568
+ // de N×M: se borró con él.
569
+ return this.factsAuthorize(this.fgaSubject(subject), permission, scope);
570
+ }
571
+ /**
572
+ * **`authorize` del modo `facts` (3b-2c): UN solo `Check`.**
573
+ *
574
+ * `can_<P>` sobre `scope:<key>` con el subject como user. El modelo (c2)
575
+ * ya lleva dentro las tres cosas que el modo `resolver` compone aquí:
576
+ * la herencia hacia abajo (`<P> from parent`), el deny explícito heredado
577
+ * (`denied_<P> from parent`) y la resta que hace ganar al deny
578
+ * (`can_<P> = <P> but not denied_<P>`). No hay `batchCheck`, no se expande
579
+ * la cadena y **no se llama al resolutor del consumidor** (cruce 6 del
580
+ * panel 2, que es también el literal que el README puede prometer).
581
+ *
582
+ * Lo único local que queda es el MEMO del catálogo, y es OBLIGATORIO: lo
583
+ * comprueba el llamante antes de llegar aquí. Sin esa guardia un permiso
584
+ * desconocido sería un `Check` de una relación que el modelo no declara —
585
+ * un 400 del servidor que saldría como 503— en vez del `false` que exige el
586
+ * invariante 5.
587
+ *
588
+ * Un scope que el árbol del consumidor no conoce no tiene tuplas en el
589
+ * store: responde `false` sin preguntar por él (invariante 9), pero aquí
590
+ * eso lo decide el propio store, no `resolveChain`. Cualquier fallo del
591
+ * backend sale como 503 desde el cliente envuelto; jamás un `false` mudo.
592
+ */
593
+ async factsAuthorize(user, permission, scope) {
594
+ const response = await this.client.check({
595
+ user,
596
+ relation: factsRelationsOf(permission).can,
597
+ object: factsScopeObject(scopeKey(scope)),
598
+ context: this.checkContext(),
599
+ }, { consistency: this.consistency });
600
+ return response.allowed === true;
700
601
  }
701
602
  /**
702
603
  * `authorize` sobre N scopes con UN batchCheck (2.1, B6): los checks de
@@ -716,42 +617,60 @@ export class OpenFgaAuthorizationDriver {
716
617
  const perm = catalog.permission(permission);
717
618
  if (!perm)
718
619
  return scopes.map(() => false);
719
- const user = this.fgaSubject(subject);
720
- const granting = catalog.rolesGranting(perm.uuid);
620
+ // UN `batchCheck` con UN item por scope —no el N×M de la cadena por el
621
+ // catálogo del modo `resolver`, borrado en 3b-2k—, y un scope repetido
622
+ // comparte item.
623
+ return this.factsAuthorizeMany(this.fgaSubject(subject), permission, scopes);
624
+ }
625
+ /**
626
+ * `authorizeMany` del modo `facts` (3b-2c): **UN `batchCheck` de N items**,
627
+ * uno por scope DISTINTO. En el modo `resolver` cada scope aporta los
628
+ * denies de su cadena más un check por (nivel, rol que concede): el lote
629
+ * crecía como N×M. Aquí cada scope es exactamente una pregunta,
630
+ * `can_<P>@scope:<key>`, y un scope repetido comparte item y respuesta
631
+ * (G2, CR9) en vez de duplicar el lote.
632
+ *
633
+ * Un `error` en cualquier check sigue siendo 503 entero (invariante 5, D1):
634
+ * lo lanza `batchCheckAll` antes de mirar un solo `allowed`.
635
+ */
636
+ async factsAuthorizeMany(user, permission, scopes) {
637
+ const relation = factsRelationsOf(permission).can;
721
638
  // Un instante para todo el lote (K9): N scopes, una pregunta.
722
639
  const context = this.checkContext();
723
640
  const batch = [];
724
- /** Por posición: `null` = false sin preguntar; si no, [inicio, nºDenies, nºRoles] dentro del lote. */
641
+ const itemByScope = new Map();
725
642
  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
643
  for (const scope of scopes) {
731
- const scopeId = scopeKey(scope);
732
- if (slotByScope.has(scopeId)) {
733
- slots.push(slotByScope.get(scopeId));
734
- continue;
644
+ const id = scopeKey(scope);
645
+ let item = itemByScope.get(id);
646
+ if (item === undefined) {
647
+ item = batch.length;
648
+ batch.push({ user, relation, object: factsScopeObject(id), context });
649
+ itemByScope.set(id, item);
735
650
  }
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);
651
+ slots.push(item);
743
652
  }
744
653
  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
- });
654
+ return slots.map((item) => results[item].allowed === true);
655
+ }
656
+ /**
657
+ * **`{ transaction }` se rechaza también AQUÍ** (L-5, defensa en
658
+ * profundidad como F-05 en L-0): la puerta 1 vive en el manager, pero
659
+ * `manager.driver()` es la salida documentada de las barreras y por ahí un
660
+ * `{ transaction }` llegaría al driver; sin esta guarda el driver
661
+ * escribiría la tupla en el store IGNORANDO la transacción — una escritura
662
+ * que finge ir en tu transacción y no se deshace con tu rollback, que es
663
+ * exactamente el fail-open que `transactionalWrites: false` declara no
664
+ * poder evitar. Primera línea de las cuatro escrituras, antes de la
665
+ * identidad, del catálogo y del store: CERO llamadas al cliente.
666
+ */
667
+ rejectTransaction(operation, options) {
668
+ if (options?.transaction === undefined || options.transaction === null)
669
+ return;
670
+ throw UnsupportedOperationError.transactionalDriver(operation, 'openfga', 'roles');
753
671
  }
754
672
  async grant(subject, role, scope, options = {}) {
673
+ this.rejectTransaction('grant', options);
755
674
  assertIdentity({ subject, role, scope, expiresAt: options.expiresAt });
756
675
  // El binding lleva la identidad canónica del árbol (K1), nunca la forma
757
676
  // del llamante, y el uuid del rol, nunca su slug (3A · A1). El rol tiene
@@ -768,6 +687,22 @@ export class OpenFgaAuthorizationDriver {
768
687
  relation: 'assignee',
769
688
  object: `role_binding:${scopeKey(target)}|${roleUuid}`,
770
689
  };
690
+ // **La forma nueva de los objetos (3b-2c), en UNA sola escritura
691
+ // (3b-2f · R3).** En (c2) la asignación no es alcanzable con el
692
+ // `assignee` a secas: el binding tiene que colgar del scope
693
+ // (`scope#binding`) y apuntar a su rol (`role_binding#role`). Son
694
+ // ESTRUCTURA —no llevan caducidad ni dicen quién está asignado, así que
695
+ // sin `assignee` no conceden nada—, pero mandarlas en un `Write` aparte
696
+ // (como hacía el 2c) hacía del `grant` una escritura NO atómica: contra
697
+ // un `purgeScope` concurrente quedaba el `assignee` sin su arista, y
698
+ // entonces `listRoles`/`hasRole` decían que la asignación existe y
699
+ // `authorize` que no concede — dos lecturas contradiciéndose sobre el
700
+ // mismo hecho, que es peor que perder la escritura. Ahora las TRES van
701
+ // en el mismo `Write` (transaccional en FGA): o están las tres o no está
702
+ // ninguna.
703
+ const structure = factsBindingTuples(scopeKey(target), roleUuid);
704
+ /** El write COMPLETO de una asignación: su estructura y ella. */
705
+ const writeSet = (tuple) => [...structure, tuple];
771
706
  const tupleFor = (expiresAt) => expiresAt
772
707
  ? {
773
708
  ...key,
@@ -794,7 +729,7 @@ export class OpenFgaAuthorizationDriver {
794
729
  if (options.expiresAt === undefined)
795
730
  throw withPreserveRecipe(current.error);
796
731
  const expiresAt = options.expiresAt;
797
- const existed = await this.writeAssignment(key, tupleFor(expiresAt));
732
+ const existed = await this.writeAssignment(key, tupleFor(expiresAt), structure);
798
733
  return { existed, expiresAt };
799
734
  }
800
735
  if (current.kind === 'present') {
@@ -802,64 +737,105 @@ export class OpenFgaAuthorizationDriver {
802
737
  if (sameInstant(current.validUntil, expiresAt)) {
803
738
  return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
804
739
  }
805
- await this.replaceAssignment(key, tupleFor(expiresAt));
740
+ await this.replaceAssignment(key, tupleFor(expiresAt), structure);
806
741
  return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
807
742
  }
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).
743
+ // No había nada: un write basta y no hay ventana de denegación. Ese write
744
+ // puede chocar por DOS motivos distintos, y la diferencia importa
745
+ // (D6 + 3b-2f · R3):
746
+ //
747
+ // - la ASIGNACIÓN ya existe — otro proceso la escribió entre el read y
748
+ // el write, o dos `grant` simultáneos —: se relee y se re-aplica sobre
749
+ // lo que quedó, para que gane el último escritor y no se pierda esta
750
+ // caducidad;
751
+ // - la ESTRUCTURA ya estaba — otro holder con el mismo rol en el mismo
752
+ // scope, que es el caso más común que hay — o la escribió otro `grant`
753
+ // a la vez: la asignación sigue sin existir, así que se repite el
754
+ // MISMO write ignorando duplicados (las aristas no llevan condición,
755
+ // así que un duplicado suyo es idéntico por construcción). Sigue
756
+ // siendo UNA escritura y sigue siendo atómica.
757
+ //
758
+ // Cuál de los dos fue lo dice la RELECTURA, no el error: el conflicto
759
+ // transaccional de FGA (`Aborted`, HTTP 409) no nombra ninguna tupla.
760
+ // Cualquier otro fallo del write no es una carrera y se propaga (D6); y
761
+ // una contención que no cede en `GRANT_WRITE_ATTEMPTS` vueltas sale como
762
+ // 409 —el estado del destino no es el que esta escritura esperaba—,
763
+ // jamás como un 503 "el backend no respondió", porque respondió.
764
+ //
765
+ // El límite honesto: si entre la relectura y el write con `Ignore` un
766
+ // tercero escribe la asignación, la suya se queda y esta se pierde en
767
+ // silencio (FGA no tiene un compare-and-set). Es la misma ventana que ya
768
+ // tenía el `replace`, estrechada a dos llamadas seguidas.
813
769
  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 };
770
+ for (let attempt = 0;; attempt++) {
771
+ try {
772
+ await this.client.writeTuples(writeSet(tupleFor(expiresAt)), attempt === 0 ? undefined : IGNORE_DUPLICATE_WRITES);
773
+ return { existed: false, expiresAt };
827
774
  }
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));
775
+ catch (error) {
776
+ if (!isDuplicateWrite(error))
777
+ throw error;
778
+ if (attempt >= GRANT_WRITE_ATTEMPTS - 1) {
779
+ throw new WriteConflictError(`grant: ${GRANT_WRITE_ATTEMPTS} intentos y el store sigue en conflicto sobre ` +
780
+ `${key.object}. Reintenta la escritura.`, { cause: error });
781
+ }
782
+ const raced = await this.readAssignment(key);
783
+ if (raced.kind === 'present') {
784
+ const target = resolveGrantExpiry(raced.validUntil, options.expiresAt, this.now());
785
+ if (!sameInstant(raced.validUntil, target)) {
786
+ await this.replaceAssignment(key, tupleFor(target), structure);
787
+ }
788
+ return { existed: true, previousExpiresAt: raced.validUntil, expiresAt: target };
789
+ }
790
+ if (raced.kind === 'unknown') {
791
+ // La relectura FALLÓ: sin objetivo explícito no se sabe qué
792
+ // preservar, y el error de lectura va como causa.
793
+ if (options.expiresAt === undefined)
794
+ throw withPreserveRecipe(raced.error);
795
+ const existed = await this.writeAssignment(key, tupleFor(options.expiresAt), structure);
796
+ return { existed, expiresAt: options.expiresAt };
797
+ }
798
+ // `absent` con estructura: el choque fue de las aristas, que las
799
+ // comparten todos los holders del mismo rol en el mismo scope. Otra
800
+ // vuelta, esta vez dándolas por buenas.
834
801
  }
835
- const existed = await this.writeAssignment(key, tupleFor(options.expiresAt));
836
- return { existed, expiresAt: options.expiresAt };
837
802
  }
838
803
  }
839
804
  /**
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).
805
+ * Write directo de la asignación CON su estructura (3b-2f · R3); si algo ya
806
+ * estaba, camino largo. Devuelve si existía la ASIGNACIÓN —lo dice la
807
+ * relectura, no el error: el choque puede ser de las aristas, que en (c2)
808
+ * las comparten todos los holders del mismo rol en el mismo scope—.
809
+ * Cualquier otro fallo se propaga tal cual (ya clasificado).
842
810
  */
843
- async writeAssignment(key, tuple) {
811
+ async writeAssignment(key, tuple, structure = []) {
844
812
  try {
845
- await this.client.writeTuples([tuple]);
813
+ await this.client.writeTuples([...structure, tuple]);
846
814
  return false;
847
815
  }
848
816
  catch (error) {
849
817
  if (!isDuplicateWrite(error))
850
818
  throw error;
851
- await this.replaceAssignment(key, tuple);
852
- return true;
819
+ // Sin estructura el duplicado solo puede ser la asignación; con ella
820
+ // puede ser una arista compartida, y entonces quién existía lo dice la
821
+ // relectura y no el error.
822
+ const existed = structure.length ? (await this.readAssignment(key)).kind !== 'absent' : true;
823
+ await this.replaceAssignment(key, tuple, structure);
824
+ return existed;
853
825
  }
854
826
  }
855
- /** delete + write (dos llamadas: FGA no admite ambas sobre la misma key en una). */
856
- async replaceAssignment(key, tuple) {
827
+ /**
828
+ * delete + write (dos llamadas: FGA no admite ambas sobre la misma key en
829
+ * una). El write repone la ESTRUCTURA junto a la asignación: si un
830
+ * `purgeScope` concurrente se llevó la arista `scope#binding` entre medias,
831
+ * lo que queda vuelve a ser coherente en vez de una asignación inerte que
832
+ * `listRoles` ve y `authorize` no (3b-2f · R3).
833
+ */
834
+ async replaceAssignment(key, tuple, structure = []) {
857
835
  await this.client.deleteTuples([key], {
858
836
  conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
859
837
  });
860
- await this.client.writeTuples([tuple], {
861
- conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore },
862
- });
838
+ await this.client.writeTuples([...structure, tuple], IGNORE_DUPLICATE_WRITES);
863
839
  }
864
840
  /**
865
841
  * Estado actual de una asignación, con TRES resultados posibles y no dos.
@@ -883,15 +859,19 @@ export class OpenFgaAuthorizationDriver {
883
859
  return { kind: 'unknown', error };
884
860
  }
885
861
  }
886
- async revoke(subject, role, scope) {
862
+ async revoke(subject, role, scope, options) {
863
+ this.rejectTransaction('revoke', options);
887
864
  assertIdentity({ subject, role, scope });
888
865
  // Rol fuera del catálogo para ese nivel ⇒ 422, como en `grant` (D10). Se
889
866
  // quitan los bindings de TODOS los roles con ese nombre en el scope
890
867
  // exacto (3B): a lo sumo uno es visible ahí; quitar nunca concede.
891
868
  const named = rolesToRevoke(await this.catalog.view(), role, scope);
892
- const target = await this.canonicalOrSelf(scope, 'revoke');
869
+ // Sin cadena, TODAS las ortografías de las que el uuid puede ser alias
870
+ // (3b-8 · A4): con una sola, el alias hacía del delete un no-op
871
+ // silencioso (`onMissingDeletes: Ignore`) y el hecho canónico seguía ahí.
872
+ const targets = await this.canonicalTargets(scope, 'revoke');
893
873
  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 } });
874
+ await this.client.deleteTuples(targets.flatMap((target) => named.map((r) => ({ user, relation: 'assignee', object: `role_binding:${scopeKey(target)}|${r.uuid}` }))), { conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore } });
895
875
  }
896
876
  async hasRole(subject, role, scope) {
897
877
  assertIdentity({ subject, role, scope });
@@ -918,33 +898,44 @@ export class OpenFgaAuthorizationDriver {
918
898
  })));
919
899
  return results.some((r) => r.allowed);
920
900
  }
921
- async deny(subject, permission, scope) {
901
+ async deny(subject, permission, scope, options) {
902
+ this.rejectTransaction('deny', options);
922
903
  assertIdentity({ subject, permission, scope });
923
904
  const perm = await this.findPermission(permission);
924
905
  if (!perm)
925
906
  throw new UnknownPermissionError(permission);
926
907
  const [target] = await this.knownScope(scope, 'deny');
927
- await this.client.writeTuples([
928
- {
929
- user: this.fgaSubject(subject),
930
- relation: 'denied',
931
- object: `deny_binding:${scopeKey(target)}|${perm.uuid}`,
932
- },
933
- ], { conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore } });
908
+ await this.client.writeTuples([this.denyTuple(subject, permission, target)], {
909
+ conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore },
910
+ });
934
911
  }
935
- async removeDeny(subject, permission, scope) {
912
+ async removeDeny(subject, permission, scope, options) {
913
+ this.rejectTransaction('removeDeny', options);
936
914
  assertIdentity({ subject, permission, scope });
937
915
  const perm = await this.findPermission(permission);
938
916
  if (!perm)
939
917
  throw new UnknownPermissionError(permission);
940
- const target = await this.canonicalOrSelf(scope, 'removeDeny');
941
- await this.client.deleteTuples([
942
- {
943
- user: this.fgaSubject(subject),
944
- relation: 'denied',
945
- object: `deny_binding:${scopeKey(target)}|${perm.uuid}`,
946
- },
947
- ], { conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore } });
918
+ // Mismo fan-out que `revoke` (3b-8 · A4): quitar un deny de menos por un
919
+ // alias sería un deny fantasma que nadie puede levantar.
920
+ const targets = await this.canonicalTargets(scope, 'removeDeny');
921
+ await this.client.deleteTuples(targets.map((target) => this.denyTuple(subject, permission, target)), { conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore } });
922
+ }
923
+ /**
924
+ * El hecho de un deny (3b-2c): una relación DEL SCOPE,
925
+ * `scope:<key>#denied_<P>@<holder>`. Así el modelo lo hereda hacia abajo
926
+ * por `parent` y `can_<P>` puede restarlo dentro del mismo `Check`
927
+ * (invariante 2) sin que el paquete pasee la cadena. Hasta 3b-2k · K2 el
928
+ * modo `resolver` lo guardaba en un objeto propio
929
+ * (`deny_binding:<scopeKey>|<permissionUuid>`) y el paquete expandía la
930
+ * cadena a un check por nivel; ese tipo se borró con el modo.
931
+ *
932
+ * La relación lleva el SLUG proyectado (no el uuid) porque el modelo la
933
+ * declara por nombre. El slug que llega aquí es el del catálogo: el
934
+ * llamante ya pasó por `findPermission`, que es quien decide qué existe.
935
+ */
936
+ denyTuple(subject, permission, target) {
937
+ const user = this.fgaSubject(subject);
938
+ return factsDenyTuple(scopeKey(target), permission, user);
948
939
  }
949
940
  /**
950
941
  * Holders con asignación vigente del rol en el scope exacto: `Read` por
@@ -1005,14 +996,52 @@ export class OpenFgaAuthorizationDriver {
1005
996
  }
1006
997
  /** Scopes (por clave) donde el subject tiene un deny directo del permiso (por su uuid). */
1007
998
  async deniedScopeKeys(subject, permissionUuid, at) {
999
+ // El deny es una relación DEL SCOPE: se pide por esa relación exacta y la
1000
+ // lectura devuelve ya solo los denies de ESTE permiso (una request, sin
1001
+ // filtrar en cliente). Un permiso que el catálogo ya no declara no tiene
1002
+ // relación que leer: sin denies (D5).
1003
+ const slug = (await this.catalog.view()).permissionSlug(permissionUuid);
1004
+ if (!slug)
1005
+ return new Set();
1008
1006
  const tuples = await this.readAllTuples({
1009
1007
  user: this.fgaSubject(subject),
1010
- relation: 'denied',
1011
- object: 'deny_binding:',
1008
+ relation: factsRelationsOf(slug).denied,
1009
+ object: `${FACTS_SCOPE_TYPE}:`,
1012
1010
  }, { 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)));
1011
+ return new Set(tuples.map((t) => t.object.slice(FACTS_SCOPE_TYPE.length + 1)));
1012
+ }
1013
+ /**
1014
+ * Los denies DIRECTOS del holder en el modo `facts`, ya traducidos a
1015
+ * `(scope, permiso)`. No hay `deny_binding` que enumerar: se leen de una
1016
+ * pasada las tuplas del holder sobre objetos `scope:` y se quedan las de la
1017
+ * familia `denied_<P>`. La vuelta de relación a slug la da el CATÁLOGO —una
1018
+ * relación que ya no declara ningún permiso no es un deny (D5), igual que
1019
+ * un `deny_binding` de un permiso retirado—, y una clave de scope que el
1020
+ * motor no entiende se cuenta y se registra, nunca se descarta en silencio
1021
+ * (L0.16).
1022
+ */
1023
+ async factsDenies(subject, catalog, at) {
1024
+ const permissionByRelation = new Map();
1025
+ for (const slug of catalog.permissionSlugs) {
1026
+ permissionByRelation.set(factsRelationsOf(slug).denied, slug);
1027
+ }
1028
+ const tuples = await this.readAllTuples({ user: this.fgaSubject(subject), object: `${FACTS_SCOPE_TYPE}:` }, { at });
1029
+ const denies = [];
1030
+ for (const tuple of tuples) {
1031
+ if (!tuple.relation.startsWith(FACTS_DENIED_PREFIX))
1032
+ continue;
1033
+ const permission = permissionByRelation.get(tuple.relation);
1034
+ if (!permission)
1035
+ continue;
1036
+ const scope = scopeFromKey(tuple.object.slice(FACTS_SCOPE_TYPE.length + 1));
1037
+ if (!scope) {
1038
+ this.diagnostics.unparseableBindings += 1;
1039
+ this.warn(`authz(openfga): el objeto '${tuple.object}' de un deny no tiene la forma de un scope del motor; se ignora en la enumeración (total: ${this.diagnostics.unparseableBindings})`);
1040
+ continue;
1041
+ }
1042
+ denies.push({ scope, permission });
1043
+ }
1044
+ return denies;
1016
1045
  }
1017
1046
  parseBindings(type, objects) {
1018
1047
  const parsed = [];
@@ -1144,10 +1173,10 @@ export class OpenFgaAuthorizationDriver {
1144
1173
  return [...result.values()];
1145
1174
  }
1146
1175
  /**
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).
1176
+ * Denies directos del holder (2.1, B5): `Read` paginado de sus relaciones
1177
+ * `denied_<P>` sobre objetos `scope:` (nunca ListObjects, L0.7), filtrados
1178
+ * por el catálogo (un permiso retirado no es un deny, D5), por scope exacto
1179
+ * si se pide, y por scopes que el árbol conoce (D8).
1151
1180
  */
1152
1181
  async listDenies(subject, scope) {
1153
1182
  assertIdentity(scope ? { subject, scope } : { subject });
@@ -1156,36 +1185,404 @@ export class OpenFgaAuthorizationDriver {
1156
1185
  return [];
1157
1186
  const wanted = chain ? scopeKey(chain[0]) : null;
1158
1187
  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
- });
1188
+ const denies = await this.factsDenies(subject, view);
1164
1189
  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;
1190
+ for (const deny of denies) {
1169
1191
  if (wanted !== null) {
1170
- if (scopeKey(binding.scope) !== wanted)
1192
+ if (scopeKey(deny.scope) !== wanted)
1171
1193
  continue;
1172
1194
  }
1173
- else if (!(await this.chain(binding.scope, 'listDenies'))) {
1195
+ else if (!(await this.chain(deny.scope, 'listDenies'))) {
1174
1196
  continue;
1175
1197
  }
1176
- result.push({ permission, scope: binding.scope });
1198
+ result.push({ permission: deny.permission, scope: deny.scope });
1177
1199
  }
1178
1200
  return result;
1179
1201
  }
1202
+ /* ── El ÁRBOL como hechos (3b-2b) ──────────────────────────────────── */
1203
+ /**
1204
+ * Un nodo tiene como mucho UN padre. El paquete nunca escribe dos (cada
1205
+ * `moved` sustituye la arista entera dentro de un `Write`), así que dos
1206
+ * padres son DERIVA: alguien más escribe en el store, y mientras tanto la
1207
+ * herencia está trayendo hechos de dos ramas. Se lanza; no se "arregla",
1208
+ * porque elegir cuál sobrevive sería adivinar cuál de las dos concesiones
1209
+ * vivas es la buena (cruce 8: «si devuelve >1 ⇒ drift ⇒ lanza»).
1210
+ */
1211
+ assertOneParent(object, current) {
1212
+ if (current.length <= 1)
1213
+ return;
1214
+ throw new ScopeTreeDriftError(`El árbol del store tiene ${current.length} padres para ${object} ` +
1215
+ `(${current.map((t) => t.user).join(', ')}); el paquete solo escribe uno. ` +
1216
+ `No se elige por ti: reconstruye el árbol con authz:reconcile.`);
1217
+ }
1218
+ /**
1219
+ * El consumidor colgó un scope nuevo (o recolgó uno que ya existía: un
1220
+ * `attach` sobre un nodo conocido ES un move). En modo `facts` eso es UNA
1221
+ * tupla `scope:<hijo>#parent@scope:<padre>`; en modo `resolver` no es nada
1222
+ * (la jerarquía la resuelve el paquete en cada pregunta).
1223
+ */
1224
+ async onScopeAttached(child, parent) {
1225
+ await this.reparent(child, parent, 'scopes.attached');
1226
+ }
1227
+ /**
1228
+ * El consumidor movió un scope. Procedimiento fijado en el cruce 8 del
1229
+ * panel 2: un `Read` del padre actual —obligatorio, porque FGA rechaza
1230
+ * borrar una tupla inexistente—, la cadena del padre NUEVO en el store
1231
+ * (3b-8 · A5: el anti-ciclos del consumidor no ve un store
1232
+ * desincronizado) y **UN solo `Write`** con el delete del padre viejo y el
1233
+ * write del nuevo, que es atómico dentro de la request. O(profundidad)
1234
+ * requests, UNA mutación.
1235
+ */
1236
+ async onScopeMoved(child, newParent) {
1237
+ await this.reparent(child, newParent, 'scopes.moved');
1238
+ }
1239
+ /**
1240
+ * **Anti-ciclos, en el PAQUETE y antes de escribir** (cruce 3 del panel 2,
1241
+ * bloqueante S2). Medido contra OpenFGA v1.19: el servidor ACEPTA una
1242
+ * arista que cierra un ciclo, no se cuelga, responde en 2-7 ms y la
1243
+ * herencia se vuelve bidireccional —un grant en un descendiente concede en
1244
+ * el ancestro, y con la raíz dentro del ciclo concede en todo el store—.
1245
+ * Fail-open mudo: no hay nada que capturar. La suite lo reproduce contra el
1246
+ * `:8101` para que nadie proponga delegar esto en el backend.
1247
+ *
1248
+ * Las tres validaciones del cruce 8, en orden y sin escribir nada si
1249
+ * fallan: (i) la raíz no cuelga de nadie; (ii) el padre EXISTE según el
1250
+ * árbol del consumidor; (iii) el hijo no es ancestro-o-igual del padre.
1251
+ * Las mismas que hace `AuthorizationManager.#assertEdge`: aquí se repiten
1252
+ * por defensa en profundidad, porque `manager.driver()` es la salida
1253
+ * documentada de todas las barreras del paquete.
1254
+ *
1255
+ * Devuelve las dos claves CANÓNICAS (invariante 17): un alias del uuid ni
1256
+ * evade la comprobación de ciclo ni abre una segunda rama en el store.
1257
+ */
1258
+ async assertEdge(child, parent, operation) {
1259
+ assertScope(child);
1260
+ assertScope(parent);
1261
+ if (child.type === APP_SCOPE_TYPE) {
1262
+ throw new InvalidIdentityError(`${operation}: la raíz \`app\` no puede colgar de nada`);
1263
+ }
1264
+ // (ii) 422 E_AUTHZ_UNKNOWN_SCOPE si el padre no existe.
1265
+ const parentChain = await this.knownScope(parent, operation);
1266
+ // El hijo, si el árbol ya lo conoce, con su identidad canónica.
1267
+ const childChain = await this.chain(child, operation);
1268
+ const childKey = scopeKey(childChain ? childChain[0] : child);
1269
+ if (parentChain.some((s) => scopeKey(s) === childKey)) {
1270
+ throw new ScopeCycleError(`${operation}: ${parent.type}:${parent.uuid ?? ''} desciende de ${childKey} (o es él mismo); ` +
1271
+ `colgarlo cerraría un ciclo y FGA lo evaluaría en los dos sentidos: un grant en el ` +
1272
+ `descendiente concedería en el ancestro.`);
1273
+ }
1274
+ return { childKey, parentKey: scopeKey(parentChain[0]) };
1275
+ }
1276
+ /**
1277
+ * El consumidor sacó un scope del árbol. En modo `facts` se borra su
1278
+ * arista `#parent` — y **la arista es lo ÚLTIMO** (S6, cruce 9): el
1279
+ * manager llama primero a `purgeScope`, que borra los hechos del scope y
1280
+ * DEMUESTRA cero o lanza (invariante 11).
1281
+ *
1282
+ * **El motivo del orden cambió con (c2r) y el orden NO** (3b-2i). La razón
1283
+ * que se escribió en 3b-2b —«un scope sin ancestro dejaría de heredar los
1284
+ * denies del padre y sus permisos serían INDENEGABLES»— ya no es cierta:
1285
+ * ése era exactamente el 🔴 1 del auditor R2 y hoy un scope que no alcanza
1286
+ * `app` no concede nada (`can_<P>` exige `rooted`). Lo que sigue justificando
1287
+ * el orden es lo otro: una purga que muere a medias tiene que dejar denies
1288
+ * de MÁS, nunca de menos, y borrar la arista antes convertiría el fallo en
1289
+ * «se quedaron hechos vivos en un nodo que ya nadie purga» (los recoge
1290
+ * `authz:reconcile`, pero mientras tanto el nodo es invisible para el
1291
+ * árbol). Con la arista al final, una purga fallida se reintenta.
1292
+ *
1293
+ * No se tocan las aristas de los HIJOS (`scope:<hijo>#parent@scope:<este>`):
1294
+ * el consumidor notifica un `detached` por nodo, o un `moved` para
1295
+ * recolgarlos — y **desde (c2r) esos hijos, mientras tanto, DENIEGAN** (su
1296
+ * cadena ya no llega a la raíz) en vez de conceder de más. Lo que quede sin
1297
+ * nodo arriba lo ve `authz:reconcile` (3b-3).
1298
+ */
1299
+ async onScopeDetached(child) {
1300
+ assertScope(child);
1301
+ if (child.type === APP_SCOPE_TYPE) {
1302
+ throw new InvalidIdentityError('scopes.detached: la raíz `app` no se puede borrar ni purgar');
1303
+ }
1304
+ const scope = await this.canonicalOrSelf(child, 'scopes.detached');
1305
+ const object = factsScopeObject(scopeKey(scope));
1306
+ const current = await this.readAllTuples({ object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
1307
+ // Aquí NO se exige un solo padre: el nodo se va entero y llevarse las dos
1308
+ // aristas de una deriva es lo correcto (a diferencia de `moved`, donde
1309
+ // habría que elegir cuál sobrevive).
1310
+ if (current.length === 0)
1311
+ return;
1312
+ await this.client.write({ writes: [], deletes: current });
1313
+ }
1314
+ /**
1315
+ * Escribe la arista del árbol dejando UNA sola: se lee la que hay y se
1316
+ * sustituye en el mismo `Write`. Sin diferencia no se llama al servidor
1317
+ * (invariante 6: re-anexar al mismo padre es un no-op seguro, y además
1318
+ * escribir una tupla que ya está sería un conflicto con los defaults
1319
+ * estrictos del SDK).
1320
+ *
1321
+ * **Y el choque con otro escritor del árbol no es una caída** (3b-2h ·
1322
+ * 🟠 4, invariante 6). Medido contra el `:8101`: dos `attached` del mismo
1323
+ * nodo al mismo padre a la vez —lo que hacen dos pasadas del relay sobre el
1324
+ * mismo lote, porque `pending()` no reserva nada— y el perdedor se llevaba
1325
+ * un **503 «el backend no respondió»**, cuando el backend respondió
1326
+ * perfectamente («cannot write a tuple which already exists»). Aquí se hace
1327
+ * lo que el invariante 6 manda desde `grant`: releer y re-aplicar sobre lo
1328
+ * que quedó —así el re-intento ve la arista del otro y sale por el no-op—,
1329
+ * y una contención que no cede en `TREE_WRITE_ATTEMPTS` vueltas sale como
1330
+ * 409 `E_AUTHZ_WRITE_CONFLICT`, nunca como un 503.
1331
+ *
1332
+ * Esto NO convierte dos escritores en uno: dos `attached` del mismo nodo a
1333
+ * padres DISTINTOS siguen pudiendo dejar dos aristas (FGA no tiene
1334
+ * compare-and-set y el `Read` de arriba es un check-then-write). Lo que
1335
+ * impide esa carrera es el ESCRITOR ÚNICO del relay (`ScopeOutbox.acquire`).
1336
+ */
1337
+ async reparent(child, parent, operation) {
1338
+ const { childKey, parentKey } = await this.assertEdge(child, parent, operation);
1339
+ const wanted = factsParentTuple(childKey, parentKey);
1340
+ for (let attempt = 0;; attempt++) {
1341
+ const current = await this.readAllTuples({ object: wanted.object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
1342
+ this.assertOneParent(wanted.object, current);
1343
+ if (current.length === 1 && current[0].user === wanted.user) {
1344
+ // **El atajo idempotente TAMBIÉN barre** (3b-8 · A3). El relay
1345
+ // reintenta: un `moved` cuyo primer intento escribió la arista y
1346
+ // murió ANTES del barrido vuelve a entregarse, cae aquí, y hasta
1347
+ // 3b-8 el `return` a secas se saltaba `sweepLocalRoleBindings` — el
1348
+ // rol local del tenant A seguía concediendo en el subárbol del B
1349
+ // para siempre, con el relay en verde (invariante 18 roto por
1350
+ // COMPOSICIÓN de dos piezas correctas: la idempotencia y el barrido).
1351
+ // Coste: cero requests sin roles locales, como el propio barrido.
1352
+ await this.sweepLocalRoleBindings(childKey);
1353
+ return;
1354
+ }
1355
+ // **El ciclo se mira también en el árbol DEL STORE** (3b-8 · A5).
1356
+ // `assertEdge` valida contra la cadena del CONSUMIDOR, pero quien
1357
+ // decide en `facts` es el store, y una outbox aplicada en desorden (un
1358
+ // `moved` aparcado + el move inverso posterior) lo deja desincronizado:
1359
+ // la arista nueva —legal para el consumidor— puede cerrar un ciclo
1360
+ // REAL, que FGA acepta y evalúa en los dos sentidos (cruce 3:
1361
+ // fail-open mudo). `storeChain` del barrido solo lanzaba DESPUÉS de
1362
+ // escribir y no deshacía nada. Se sube por la cadena del PADRE en el
1363
+ // store antes del `Write`; el coste es O(profundidad) lecturas por
1364
+ // cambio de árbol, fuera del camino caliente.
1365
+ if ((await this.storeChain(parentKey)).includes(childKey)) {
1366
+ throw new ScopeCycleError(`${operation}: en el árbol del STORE ${factsScopeObject(parentKey)} desciende de ${childKey} ` +
1367
+ `(o es él mismo); escribir la arista cerraría un ciclo real que FGA evalúa en los dos ` +
1368
+ `sentidos. El árbol del store está desincronizado del consumidor (una outbox aplicada en ` +
1369
+ `desorden, una entrada aparcada): reconstrúyelo con authz:reconcile.`);
1370
+ }
1371
+ try {
1372
+ await this.client.write({ writes: [wanted], deletes: current });
1373
+ }
1374
+ catch (error) {
1375
+ if (!isTreeWriteRace(error))
1376
+ throw error;
1377
+ if (attempt >= TREE_WRITE_ATTEMPTS - 1) {
1378
+ throw new WriteConflictError(`${operation}: ${TREE_WRITE_ATTEMPTS} intentos y el árbol del store sigue en conflicto sobre ` +
1379
+ `${wanted.object}. El relay es escritor ÚNICO: si hay dos pasadas a la vez, dale un lease a tu outbox.`, { cause: error });
1380
+ }
1381
+ continue;
1382
+ }
1383
+ await this.sweepLocalRoleBindings(childKey);
1384
+ return;
1385
+ }
1386
+ }
1387
+ /**
1388
+ * **El barrido del rol local** (3b-2e · E1; decisión del dueño del
1389
+ * 2026-08-30, opción 1).
1390
+ *
1391
+ * En (c2) el modelo no tiene `owner`, así que `authorize` NO vuelve a
1392
+ * decidir con el árbol de hoy si un rol LOCAL sigue siendo visible: un
1393
+ * `role_binding` concede mientras su scope alcance al que pregunta. Sin
1394
+ * esto, mover una unit fuera de la organización dueña de un rol local
1395
+ * dejaría de retirar lo concedido (el invariante 18 en `database`), que es
1396
+ * un **fail-open** — y encima uno que solo se ve comparando drivers.
1397
+ *
1398
+ * Lo que se toca es la arista `scope#binding`, que es lo que hace
1399
+ * ALCANZABLE la asignación: se BORRA donde el owner del rol ya no está en
1400
+ * la cadena y se REESCRIBE donde vuelve a estarlo (invariante 18: volver la
1401
+ * unit a su sitio restaura). No se toca el `assignee` —el hecho de la
1402
+ * asignación no cambia porque el árbol se mueva, igual que en `database`—
1403
+ * ni nada de un rol GLOBAL, cuya visibilidad no depende del árbol.
1404
+ *
1405
+ * **Por subárbol, no por nodo** (consecuencia 2): los descendientes del
1406
+ * nodo movido también cambian de cadena.
1407
+ *
1408
+ * Coste: si el catálogo no tiene NI UN rol local —el caso de todo consumidor
1409
+ * que no usa delegación— son **cero** requests y `moved` sigue siendo el
1410
+ * `Read` + `Write` del cruce 8. Con roles locales: una lectura por rol local
1411
+ * (sus bindings, por `role_binding#role`), la bajada del subárbol y un
1412
+ * `Write` por lote.
1413
+ */
1414
+ async sweepLocalRoleBindings(movedKey) {
1415
+ const catalog = await this.catalog.view();
1416
+ const locals = catalog.localRoles();
1417
+ if (locals.length === 0)
1418
+ return;
1419
+ // La cadena del nodo movido y su subárbol se leen del STORE, no del
1420
+ // consumidor: el árbol que decide en `facts` es el del store, y con la
1421
+ // outbox el relay aplica esto MÁS TARDE, cuando el árbol del consumidor
1422
+ // puede haber seguido moviéndose.
1423
+ const chainOfMoved = await this.storeChain(movedKey);
1424
+ /** `scopeKey` → claves de su cadena (él incluido), con el árbol de AHORA. */
1425
+ const subtree = new Map([[movedKey, chainOfMoved]]);
1426
+ const pending = [movedKey];
1427
+ while (pending.length) {
1428
+ const key = pending.pop();
1429
+ const children = await this.readAllTuples({ user: factsScopeObject(key), relation: FACTS_PARENT_RELATION, object: `${FACTS_SCOPE_TYPE}:` }, { includeExpired: true });
1430
+ for (const edge of children) {
1431
+ const childKey = edge.object.slice(FACTS_SCOPE_TYPE.length + 1);
1432
+ if (subtree.has(childKey))
1433
+ continue;
1434
+ subtree.set(childKey, [childKey, ...subtree.get(key)]);
1435
+ pending.push(childKey);
1436
+ }
1437
+ }
1438
+ const writes = [];
1439
+ const deletes = [];
1440
+ for (const role of locals) {
1441
+ // Los bindings de ESTE rol, esté donde esté: `role_binding:…#role@role:<uuid>`.
1442
+ // El filtro lleva `user`, que es lo que el servidor real exige cuando el
1443
+ // objeto es solo un tipo (lección del 2c).
1444
+ const bindings = await this.readAllTuples({ user: `${FACTS_ROLE_TYPE}:${role.uuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
1445
+ for (const binding of this.parseBindings(FACTS_BINDING_TYPE, bindings.map((t) => t.object))) {
1446
+ const chainKeys = subtree.get(scopeKey(binding.scope));
1447
+ if (!chainKeys)
1448
+ continue;
1449
+ this.classifyBindingEdge(catalog, binding, chainKeys, writes, deletes);
1450
+ }
1451
+ }
1452
+ await this.applyBindingSweep(writes, deletes);
1453
+ }
1454
+ /**
1455
+ * **El barrido por NIVEL** (3b-2g · R1; decisión del dueño del 2026-08-30
1456
+ * (2), mismo mecanismo que E1).
1457
+ *
1458
+ * El modelo (c2) tampoco lleva el NIVEL (`scope_type`) del rol: la
1459
+ * proyección dice qué permisos vincula, no en qué nivel se declara. Sin
1460
+ * esto, cambiar el `scope_type` de un rol retira lo concedido en `database`
1461
+ * —donde `declaredRoleAt` se evalúa en cada pregunta— y **sigue
1462
+ * concediendo** en `facts`, que es la divergencia R1 del lote 2e.
1463
+ *
1464
+ * Se cierra igual que el owner: barriendo la arista `scope#binding` de los
1465
+ * bindings de ESE rol con la regla única de visibilidad. Lo llama
1466
+ * `projectCatalogRole`, que es el hook de «una escritura de catálogo cambió
1467
+ * este rol»: el manager lo dispara tras `defineScopedRole`/`updateScopedRole`
1468
+ * y un escritor «a mano» de `authz_*` tiene el mismo deber que ya tenía con
1469
+ * el espejo de permisos (sin él, en `facts` un rol recién definido no
1470
+ * concedería nada y quitarle un permiso seguiría concediéndolo).
1471
+ *
1472
+ * Coste: una lectura (los bindings del rol) y, **solo si el rol es LOCAL y
1473
+ * tiene bindings**, la cadena del store de cada scope distinto donde cuelga
1474
+ * uno; un `Write` por lote si hay algo que barrer. Un rol sin bindings —el
1475
+ * caso de todo `defineScopedRole`— son 0 escrituras.
1476
+ */
1477
+ async sweepRoleVisibility(roleUuid) {
1478
+ const catalog = await this.catalog.view();
1479
+ // Un rol que el catálogo ya no declara no tiene visibilidad que decidir:
1480
+ // sus hechos son de `purgeRole` (que los borra enteros) y de
1481
+ // `authz:reconcile`, no de este barrido.
1482
+ const role = catalog.roleByUuid(roleUuid);
1483
+ if (!role)
1484
+ return;
1485
+ const bindings = await this.readAllTuples({ user: `${FACTS_ROLE_TYPE}:${roleUuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
1486
+ if (bindings.length === 0)
1487
+ return;
1488
+ const writes = [];
1489
+ const deletes = [];
1490
+ /** `scopeKey` → su cadena en el árbol del STORE, una vez por scope. */
1491
+ const chains = new Map();
1492
+ for (const binding of this.parseBindings(FACTS_BINDING_TYPE, bindings.map((t) => t.object))) {
1493
+ const key = scopeKey(binding.scope);
1494
+ let chainKeys = chains.get(key);
1495
+ if (!chainKeys) {
1496
+ // La cadena solo decide para un rol LOCAL: la visibilidad de un
1497
+ // global no depende del árbol, así que no se paga por leerlo.
1498
+ chainKeys = role.owner === GLOBAL_OWNER_KEY ? [key] : await this.storeChain(key);
1499
+ chains.set(key, chainKeys);
1500
+ }
1501
+ this.classifyBindingEdge(catalog, binding, chainKeys, writes, deletes);
1502
+ }
1503
+ await this.applyBindingSweep(writes, deletes);
1504
+ }
1505
+ /**
1506
+ * La arista `scope#binding` de un binding, a escribir o a borrar según la
1507
+ * **regla única de visibilidad** (`declaredRoleAt`, la misma que evalúa
1508
+ * `database` en cada pregunta): el rol tiene que estar declarado para el
1509
+ * NIVEL de ese scope (3b-2g · R1) y ser global o tener a su owner en la
1510
+ * cadena (3b-2e · E1). Visible ⇒ la arista se (re)escribe; no visible ⇒ se
1511
+ * borra. El `assignee` no se toca: la asignación existe igual, lo que
1512
+ * cambia es dónde se la ve.
1513
+ */
1514
+ classifyBindingEdge(catalog, binding, chainKeys, writes, deletes) {
1515
+ const edge = factsScopeBindingTuple(scopeKey(binding.scope), binding.uuid);
1516
+ if (declaredRoleAt(catalog, binding.uuid, binding.scope.type, chainKeys))
1517
+ writes.push(edge);
1518
+ else
1519
+ deletes.push(edge);
1520
+ }
1521
+ /**
1522
+ * Aplica el barrido en lotes ≤ 100 (el límite del `Write`).
1523
+ *
1524
+ * `Ignore` en las dos direcciones: el barrido dice el estado que DEBE
1525
+ * quedar, no el delta — borrar lo que ya no está y reescribir lo que ya
1526
+ * estaba son no-ops, no errores (invariante 6).
1527
+ */
1528
+ async applyBindingSweep(writes, deletes) {
1529
+ if (!writes.length && !deletes.length)
1530
+ return;
1531
+ const operations = [
1532
+ ...deletes.map((tuple) => ({ delete: tuple })),
1533
+ ...writes.map((tuple) => ({ write: tuple })),
1534
+ ];
1535
+ for (let i = 0; i < operations.length; i += PURGE_BATCH_SIZE) {
1536
+ const chunk = operations.slice(i, i + PURGE_BATCH_SIZE);
1537
+ await this.client.write({
1538
+ writes: chunk.filter((o) => o.write).map((o) => o.write),
1539
+ deletes: chunk.filter((o) => o.delete).map((o) => o.delete),
1540
+ }, {
1541
+ conflict: {
1542
+ onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore,
1543
+ onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore,
1544
+ },
1545
+ });
1546
+ }
1547
+ }
1548
+ /**
1549
+ * `[key, ...ancestros]` según el ÁRBOL DEL STORE (3b-2e · E1), subiendo por
1550
+ * `scope#parent`. Es la cadena con la que FGA va a decidir, que es la que
1551
+ * tiene que gobernar el barrido; el resolutor del consumidor no participa.
1552
+ * Un nodo con más de un padre es deriva y se dice (`assertOneParent`), y el
1553
+ * recorrido está acotado por el mismo tope de páginas que las
1554
+ * enumeraciones: un ciclo escrito a mano no cuelga el proceso.
1555
+ */
1556
+ async storeChain(key) {
1557
+ const chain = [key];
1558
+ const seen = new Set([key]);
1559
+ for (let hop = 0; hop < MAX_SCOPE_CHAIN_HOPS; hop++) {
1560
+ const object = factsScopeObject(chain[chain.length - 1]);
1561
+ const parents = await this.readAllTuples({ object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
1562
+ this.assertOneParent(object, parents);
1563
+ if (parents.length === 0)
1564
+ return chain;
1565
+ const parentKey = parents[0].user.slice(FACTS_SCOPE_TYPE.length + 1);
1566
+ if (seen.has(parentKey)) {
1567
+ throw new ScopeCycleError(`El árbol del store tiene un ciclo en ${factsScopeObject(parentKey)}: no se puede decidir la ` +
1568
+ `visibilidad de un rol local. Reconstruye el árbol con authz:reconcile.`);
1569
+ }
1570
+ seen.add(parentKey);
1571
+ chain.push(parentKey);
1572
+ }
1573
+ throw new AuthorizationInternalError(`El árbol del store pasa de ${MAX_SCOPE_CHAIN_HOPS} niveles desde ${factsScopeObject(key)}; ` +
1574
+ `no se puede resolver la cadena para el barrido de roles locales.`);
1575
+ }
1180
1576
  /**
1181
1577
  * Purga del scope exacto en FGA (N7, S6, B2). No hay "borrar todo lo de
1182
1578
  * 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).
1579
+ * `role_binding` por cada rol del catálogo de ese `scope_type` — más el
1580
+ * objeto `scope:<key>` (donde viven los `denied_<P>` y el `#binding`),
1581
+ * paginando `Read` (nunca ListObjects: trunca sin avisar, L0.7), se borra
1582
+ * en lotes ≤ 100 (límite del Write) y se vuelve a leer cada objeto: si
1583
+ * queda algo, se lanza. Un rol retirado del catálogo deja bindings
1584
+ * inalcanzables por esta vía; es el precio de no tener un índice por
1585
+ * objeto, y lo vigilará `authz:reconcile` (3b).
1189
1586
  */
1190
1587
  async purgeScope(purged) {
1191
1588
  assertScope(purged);
@@ -1196,23 +1593,83 @@ export class OpenFgaAuthorizationDriver {
1196
1593
  const scope = await this.canonicalOrSelf(purged, 'purgeScope');
1197
1594
  const key = scopeKey(scope);
1198
1595
  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) {
1596
+ // **Los denies no son objetos propios** (3b-2c): son relaciones
1597
+ // `denied_<P>` DEL SCOPE, así que se purgan leyendo el objeto
1598
+ // `scope:<key>` —una lectura en vez de O(permisos)—, y con ellos se va el
1599
+ // enlace `#binding` de los bindings que colgaban de aquí. Lo que NO se
1600
+ // toca es la arista `parent`: la borra `scopes.detached` DESPUÉS de que
1601
+ // esta purga demuestre cero (S6, cruce 9). Al revés, una purga que muriera
1602
+ // a medias dejaría el scope sin ancestro y, desde (c2r), sin conceder
1603
+ // nada — pero también sin nadie que volviera a purgarlo.
1604
+ const scopeObject = factsScopeObject(key);
1605
+ const objects = [...roles.map((r) => `role_binding:${key}|${r.uuid}`), scopeObject];
1606
+ /** Lo purgable de un objeto: todo, salvo la arista `parent` del scope. */
1607
+ const purgeable = async (object) => {
1205
1608
  const keys = await this.readAllTuples({ object }, { includeExpired: true });
1609
+ return object === scopeObject ? keys.filter((k) => k.relation !== FACTS_PARENT_RELATION) : keys;
1610
+ };
1611
+ /** Borra en lotes ≤ 100 (límite del `Write`), sin quejarse de lo que ya no está. */
1612
+ const deleteKeys = async (keys) => {
1206
1613
  for (let i = 0; i < keys.length; i += PURGE_BATCH_SIZE) {
1207
1614
  await this.client.deleteTuples(keys.slice(i, i + PURGE_BATCH_SIZE), {
1208
1615
  conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
1209
1616
  });
1210
1617
  }
1618
+ };
1619
+ /** Las dos aristas de (c2): estructura, no hechos (`#role` y `#binding`). */
1620
+ const isStructure = (k) => k.relation === FACTS_BINDING_RELATION || k.relation === FACTS_ROLE_RELATION;
1621
+ // **El ORDEN de la purga es parte del contrato** (3b-2f · R3).
1622
+ // No hay transacción que abarque los N objetos, así que un `grant`
1623
+ // concurrente aterriza en algún hueco; lo que se elige es que NINGÚN
1624
+ // hueco deje una asignación que `listRoles`/`hasRole` ven y `authorize`
1625
+ // no honra —enumerada y sin conceder, que es peor que perder la
1626
+ // escritura—:
1627
+ //
1628
+ // 1. **la ESTRUCTURA primero** (`role_binding#role` y `scope#binding`).
1629
+ // Son deterministas —`factsBindingTuples(key, rol)`—, así que se
1630
+ // borran a ciegas y esta fase no cuesta ni una lectura.
1631
+ // 2. **los HECHOS después** (`assignee`), releyendo cada objeto: lo que
1632
+ // alguien escribió después de (1) también se borra.
1633
+ // 3. **los `denied_<P>` del scope, los últimos.**
1634
+ //
1635
+ // Con el `grant` escribiendo sus TRES tuplas en un solo `Write`, un
1636
+ // grant que aterriza antes de (2) pierde su asignación ahí (cero,
1637
+ // coherente) y uno que aterriza después se queda ENTERO con las aristas
1638
+ // que él mismo escribió (concede, coherente). Al revés —las aristas al
1639
+ // final, como hasta 3b-2e— el grant que caía en medio se quedaba con el
1640
+ // `assignee` y sin arista.
1641
+ // Los denies van los ÚLTIMOS porque una purga que muere a medias tiene
1642
+ // que dejar denies de MÁS, nunca de menos (invariante 2): entre (1) y
1643
+ // (3) lo que queda del scope es inerte, no permisivo. Y (2) y (3) borran
1644
+ // SOLO lo suyo: una arista que un grant concurrente reescribió no se
1645
+ // toca —sería volver a huerfanar su asignación—; queda como residuo, y
1646
+ // el residuo es justo lo que la demostración de cero reporta. La arista
1647
+ // `parent` sigue sin tocarse: la borra `scopes.detached` DESPUÉS de que
1648
+ // esta purga demuestre cero (S6, cruce 9).
1649
+ await deleteKeys(roles.flatMap((r) => factsBindingTuples(key, r.uuid)));
1650
+ for (const object of objects) {
1651
+ if (object === scopeObject)
1652
+ continue;
1653
+ await deleteKeys((await purgeable(object)).filter((k) => !isStructure(k)));
1211
1654
  }
1655
+ // **Las aristas `#binding` de roles que el catálogo YA NO declara en este
1656
+ // nivel también se purgan** (3b-8 · C2). El barrido no es atómico con el
1657
+ // catálogo: un rol cuya fila se borró (o cambió de `scope_type`) entre el
1658
+ // commit y esta purga dejaba una arista que el paso 1 no ve —solo mira
1659
+ // `authz_roles WHERE scope_type = <este>`— y que la demostración de cero
1660
+ // SÍ cuenta ⇒ `E_AUTHZ_PURGE_INCOMPLETE` en TODOS los intentos y
1661
+ // `detached` bloqueado para siempre. Borrarla nunca concede (es la arista
1662
+ // de visibilidad, y su rol ya no existe en este nivel), y la arista de un
1663
+ // rol DEL catálogo que un `grant` concurrente reescribió no se toca
1664
+ // (`known` la protege): esa sigue saliendo como residuo, que es lo
1665
+ // correcto — se reintenta.
1666
+ const known = new Set(roles.map((r) => `role_binding:${key}|${r.uuid}`));
1667
+ await deleteKeys((await purgeable(scopeObject)).filter((k) => k.relation === FACTS_BINDING_RELATION && !known.has(k.user)));
1668
+ await deleteKeys((await purgeable(scopeObject)).filter((k) => k.relation.startsWith(FACTS_DENIED_PREFIX)));
1212
1669
  // Demostrar cero: lo que no se puede demostrar, se reporta.
1213
1670
  const residue = [];
1214
1671
  for (const object of objects) {
1215
- const left = await this.readAllTuples({ object }, { includeExpired: true });
1672
+ const left = await purgeable(object);
1216
1673
  if (left.length)
1217
1674
  residue.push(`${object} (${left.length})`);
1218
1675
  }
@@ -1221,37 +1678,1185 @@ export class OpenFgaAuthorizationDriver {
1221
1678
  `No confirmes el borrado del scope; reintenta la purga.`);
1222
1679
  }
1223
1680
  }
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
1681
  /**
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`.
1682
+ * La **proyección derivada del catálogo** en el store (3b-2a · A5; regla
1683
+ * del catálogo reescrita, panel 2 cruce 7). Se pasa a `syncAuthzCatalog`,
1684
+ * que la usa en dos momentos: comprueba que el catálogo que va a quedar es
1685
+ * publicable (cotas de nombre y techo del modelo) ANTES de escribir, y
1686
+ * rehace las tuplas `role:<uuid>#permits_<P>@<holder>:*` con el catálogo ya
1687
+ * confirmado.
1246
1688
  *
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í.
1689
+ * Sigue sin ser el catálogo: es un espejo reconstruible que ningún camino
1690
+ * de LECTURA de este driver consulta para responder qué permisos tiene un
1691
+ * rol (A6). Quien decide es `authz_*` a través del memo.
1252
1692
  */
1253
- async readAllTuples(filter, options = {}) {
1254
- // El instante con el que se filtra: el de la operación si lo trae (K9), o
1693
+ catalogProjection() {
1694
+ return {
1695
+ assertPublishable: (permissions) => {
1696
+ assertFactsModelPublishable(this.holderTypes, permissions, (message) => this.warn(message));
1697
+ },
1698
+ project: (snapshot) => this.projectCatalog(snapshot),
1699
+ };
1700
+ }
1701
+ /**
1702
+ * **El marcador de raíz de (c2r)** (3b-2i): `scope:app#rooted@<holder>:*`,
1703
+ * una tupla por holder type en todo el store y CERO por scope.
1704
+ *
1705
+ * Va aquí —en la proyección del catálogo, o sea en cada `syncAuthzCatalog`—
1706
+ * y no en `attached`, porque el evento que hace falta cubrir es **añadir un
1707
+ * holderType al `config`**: sin esto ese holder denegaría en TODO el store
1708
+ * aunque el modelo se haya republicado, que es la única forma realista de
1709
+ * quedarse sin marcador en un store vivo. Es idempotente: un `Read` y, solo
1710
+ * si falta algo, un `Write` con lo que falta (0 escrituras en el caso
1711
+ * normal).
1712
+ *
1713
+ * Solo en modo `facts`: el modelo del modo `resolver` no declara `rooted` y
1714
+ * escribirlo sería un 400 del servidor.
1715
+ *
1716
+ * ⚠️ Sin marcador el store entero DENIEGA (fail-closed, medido). Por eso se
1717
+ * repone en cada sync y `authz:reconcile` (3b-3) tiene el deber escrito de
1718
+ * reportarlo como deriva cuando falte.
1719
+ */
1720
+ async ensureFactsRoot() {
1721
+ const wanted = factsRootTuples(this.holderTypes);
1722
+ const current = await this.readAllTuples({ object: factsScopeObject(APP_SCOPE_TYPE), relation: FACTS_ROOTED_RELATION }, { includeExpired: true });
1723
+ const have = new Set(current.map((tuple) => tuple.user));
1724
+ const missing = wanted.filter((tuple) => !have.has(tuple.user));
1725
+ if (missing.length)
1726
+ await this.client.write({ writes: missing, deletes: [] });
1727
+ }
1728
+ /**
1729
+ * Espeja los vínculos rol→permiso: escribe lo que falta y BORRA lo que
1730
+ * sobra, en UN `Write` por lote con deletes y writes juntos (cruce 8:
1731
+ * queda prohibido el patrón `deleteTuples()` + `writeTuples()`, que no es
1732
+ * atómico). Con (c2) quitar un permiso de un rol son tantos deletes como
1733
+ * holders y ninguna reescritura del modelo.
1734
+ *
1735
+ * Sin diferencias no se llama al servidor: un `sync` que no cambió el
1736
+ * catálogo escribe CERO tuplas.
1737
+ */
1738
+ async projectCatalog(snapshot) {
1739
+ await this.ensureFactsRoot();
1740
+ const wanted = new Map();
1741
+ for (const tuple of factsCatalogTuples(snapshot.roles, this.holderTypes)) {
1742
+ wanted.set(factsTupleId(tuple), tuple);
1743
+ }
1744
+ // Lo que la proyección tiene HOY. `Read` por prefijo de tipo, paginado y
1745
+ // acotado como todas las enumeraciones del driver (L0.7) — y UNA lectura
1746
+ // por holder, porque el servidor REAL rechaza con un 400 un `Read` cuyo
1747
+ // objeto es solo el tipo y no trae user («the object type field is
1748
+ // required and both the object id and user cannot be empty»). Con el
1749
+ // comodín de usuario de (c2) eso no cuesta nada: la proyección son
1750
+ // exactamente las tuplas `<holder>:*`, así que preguntando por cada
1751
+ // comodín se ve el espejo ENTERO —incluidas las tuplas de roles que el
1752
+ // catálogo ya no lista, que es lo que hay que borrar—. Lo escrito con
1753
+ // otro user sobre `role:` no es de esta proyección y no se toca.
1754
+ const current = new Map();
1755
+ for (const holderType of Object.values(this.holderTypes)) {
1756
+ for (const key of await this.readAllTuples({ user: `${holderType}:*`, object: `${FACTS_ROLE_TYPE}:` }, { includeExpired: true })) {
1757
+ // Solo la familia del catálogo: si el modelo crece con otras
1758
+ // relaciones sobre `role`, no son de esta proyección.
1759
+ if (!key.relation.startsWith(FACTS_PERMITS_PREFIX))
1760
+ continue;
1761
+ current.set(factsTupleId(key), key);
1762
+ }
1763
+ }
1764
+ const writes = [...wanted.values()].filter((tuple) => !current.has(factsTupleId(tuple)));
1765
+ const deletes = [...current.values()].filter((tuple) => !wanted.has(factsTupleId(tuple)));
1766
+ // Los deletes van primero dentro del lote: quitar un permiso tiene que
1767
+ // caber en la misma request que lo que lo sustituye.
1768
+ const operations = [
1769
+ ...deletes.map((tuple) => ({ delete: tuple })),
1770
+ ...writes.map((tuple) => ({ write: tuple })),
1771
+ ];
1772
+ for (let i = 0; i < operations.length; i += PURGE_BATCH_SIZE) {
1773
+ const chunk = operations.slice(i, i + PURGE_BATCH_SIZE);
1774
+ await this.client.write({
1775
+ writes: chunk.filter((o) => o.write).map((o) => o.write),
1776
+ deletes: chunk.filter((o) => o.delete).map((o) => o.delete),
1777
+ });
1778
+ }
1779
+ return { written: writes.length, deleted: deletes.length, unchanged: wanted.size - writes.length };
1780
+ }
1781
+ /**
1782
+ * **Purga un ROL con sus hechos** (3b-2e · E4; hasta aquí este driver no lo
1783
+ * traía y por eso `defineScopedRole` era 500 `E_AUTHZ_UNSUPPORTED` antes de
1784
+ * escribir, 3E · P4).
1785
+ *
1786
+ * Lo que lo hace posible es (c2): el binding APUNTA A SU ROL
1787
+ * (`role_binding:…#role@role:<uuid>`), así que los bindings de un rol se
1788
+ * enumeran filtrando por `user` — y con la arista `scope#binding` se sabe de
1789
+ * qué scope cuelga cada uno. En el modo `resolver` esas dos aristas no
1790
+ * existen y el método TAMPOCO: el constructor lo retira (el manager lo lee
1791
+ * como «no sé purgar» y se niega antes de escribir).
1792
+ *
1793
+ * Orden: **hechos primero, catálogo después** (el mismo de `detached`, S6).
1794
+ * No hay transacción que abarque FGA y SQL, así que lo que se garantiza es
1795
+ * la dirección segura: mientras la fila del rol siga viva, lo que quede en
1796
+ * el store es visible y reintentable; al revés quedarían hechos huérfanos
1797
+ * que resucitarían al recrear el slug. Y se DEMUESTRA cero (invariante 11):
1798
+ * si algo sobrevive, 500 `E_AUTHZ_PURGE_INCOMPLETE` y el catálogo no se
1799
+ * toca.
1800
+ */
1801
+ async purgeRole(roleUuid) {
1802
+ assertCatalogUuid('rol', roleUuid);
1803
+ const existing = await this.sql('purgeRole.role', () => db.from('authz_roles').where('uuid', roleUuid).select('uuid'));
1804
+ if (!existing.length)
1805
+ throw new UnknownRoleError(roleUuid);
1806
+ const roleObject = `${FACTS_ROLE_TYPE}:${roleUuid}`;
1807
+ /** Los objetos `role_binding` de este rol, por su arista `#role`. */
1808
+ const bindingsOf = async () => {
1809
+ const edges = await this.readAllTuples({ user: roleObject, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
1810
+ return [...new Set(edges.map((edge) => edge.object))];
1811
+ };
1812
+ /** El scope del que cuelga un binding, según su propio id (`<scopeKey>|<roleUuid>`). */
1813
+ const scopeOf = (binding) => {
1814
+ const id = binding.slice(FACTS_BINDING_TYPE.length + 1);
1815
+ const separator = id.lastIndexOf('|');
1816
+ return separator > 0 ? id.slice(0, separator) : null;
1817
+ };
1818
+ /** Todo lo purgable del rol: sus bindings enteros, las aristas del scope y su proyección. */
1819
+ const purgeable = async () => {
1820
+ const keys = [];
1821
+ for (const binding of await bindingsOf()) {
1822
+ keys.push(...(await this.readAllTuples({ object: binding }, { includeExpired: true })));
1823
+ const key = scopeOf(binding);
1824
+ if (!key)
1825
+ continue;
1826
+ // La arista vive en el objeto `scope`, así que se lee de ahí (y solo
1827
+ // la que apunta a ESTE binding: el scope tiene las de otros roles).
1828
+ const scopeEdges = await this.readAllTuples({ user: binding, relation: FACTS_BINDING_RELATION, object: factsScopeObject(key) }, { includeExpired: true });
1829
+ keys.push(...scopeEdges);
1830
+ }
1831
+ // La proyección del catálogo del rol: `role:<uuid>#permits_<P>@<holder>:*`.
1832
+ for (const holderType of Object.values(this.holderTypes)) {
1833
+ keys.push(...(await this.readAllTuples({ user: `${holderType}:*`, object: roleObject }, { includeExpired: true })));
1834
+ }
1835
+ return keys;
1836
+ };
1837
+ const keys = await purgeable();
1838
+ for (let i = 0; i < keys.length; i += PURGE_BATCH_SIZE) {
1839
+ await this.client.deleteTuples(keys.slice(i, i + PURGE_BATCH_SIZE), {
1840
+ conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
1841
+ });
1842
+ }
1843
+ const residue = await purgeable();
1844
+ if (residue.length) {
1845
+ throw new PurgeIncompleteError(`purgeRole ${roleUuid}: quedan ${residue.length} tuplas tras el borrado ` +
1846
+ `(${residue.slice(0, 3).map((k) => `${k.user}#${k.relation}@${k.object}`).join('; ')}…). ` +
1847
+ `El rol NO se borra del catálogo: reintenta la purga.`);
1848
+ }
1849
+ try {
1850
+ await this.sql('purgeRole', () => withAuthzCatalogWrite(async (trx) => {
1851
+ // `authz_assignments` es del driver `database` y aquí está vacía,
1852
+ // pero se limpia igual: un store que viene de una migración entre
1853
+ // drivers no puede dejar filas apuntando a un rol que ya no existe.
1854
+ await this.sql('purgeRole.assignments', () => trx.from('authz_assignments').where('role_uuid', roleUuid).delete());
1855
+ await this.sql('purgeRole.links', () => trx.from('authz_role_permissions').where('role_uuid', roleUuid).delete());
1856
+ const deleted = await this.sql('purgeRole.role.delete', () => trx.from('authz_roles').where('uuid', roleUuid).delete());
1857
+ if (Number(Array.isArray(deleted) ? deleted[0] : deleted) === 0)
1858
+ throw new UnknownRoleError(roleUuid);
1859
+ }, { driver: 'openfga', timeoutMs: this.timeoutMs }));
1860
+ }
1861
+ finally {
1862
+ this.catalog.invalidate();
1863
+ }
1864
+ }
1865
+ /**
1866
+ * Cuántos hechos VIGENTES tiene cada rol, en todos los scopes (3b-2j).
1867
+ *
1868
+ * Aquí los hechos son TUPLAS, no filas: `role_binding:<scope>|<rol>#assignee@<holder>`,
1869
+ * con la caducidad en su *condition*. Por eso esta pregunta es del PUERTO
1870
+ * y no del barrido: hasta 3b-2j `pruneOrphanRoles` contaba
1871
+ * `authz_assignments` —la tabla del driver `database`, vacía con este— y
1872
+ * el `stillGranting` que se lee justo antes de purgar decía SIEMPRE «este
1873
+ * rol no concede», sobre roles que concedían (medido en el lote 2i).
1874
+ *
1875
+ * Lo hace posible lo mismo que hace posible `purgeRole`: el binding apunta
1876
+ * a su rol (`role_binding:…#role@role:<uuid>`, (c2)), así que los bindings
1877
+ * de un rol se enumeran filtrando por `user`. En modo `resolver` esa arista
1878
+ * no existe y el método TAMPOCO (el constructor lo retira, y el manager lo
1879
+ * lee como «no lo sé»).
1880
+ *
1881
+ * La arista estructural se lee con `includeExpired` —no caduca, la
1882
+ * caducidad está en el `assignee`— y los assignees sin él: la caducidad es
1883
+ * ESTRICTA y con el reloj del driver, igual que en `authorize`. Un `user`
1884
+ * que no se entiende como holder se cuenta igual (a diferencia de
1885
+ * `listSubjects`, que lo descarta): aquí contar de MÁS es el lado seguro —
1886
+ * marca el rol para que un humano lo mire— y contar de menos es decir «no
1887
+ * concede» sobre algo que sí.
1888
+ *
1889
+ * Coste: por rol preguntado, una lectura de sus bindings más una por
1890
+ * binding. Lo llama `pruneOrphanRoles` con los HUÉRFANOS de la pasada (no
1891
+ * con el catálogo entero) y corre en un comando de plataforma, no en el
1892
+ * camino de una petición.
1893
+ */
1894
+ async countRoleAssignments(roleUuids) {
1895
+ for (const uuid of roleUuids)
1896
+ assertCatalogUuid('rol', uuid);
1897
+ const counts = [];
1898
+ for (const roleUuid of roleUuids) {
1899
+ const edges = await this.readAllTuples({ user: `${FACTS_ROLE_TYPE}:${roleUuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
1900
+ let total = 0;
1901
+ for (const binding of new Set(edges.map((edge) => edge.object))) {
1902
+ total += (await this.readAllTuples({ relation: FACTS_ASSIGNEE_RELATION, object: binding })).length;
1903
+ }
1904
+ counts.push(total);
1905
+ }
1906
+ return counts;
1907
+ }
1908
+ /**
1909
+ * **Rehace la proyección de UN rol** (3b-2e · E4). En (c2) lo que un rol
1910
+ * concede son tuplas (`role:<uuid>#permits_<P>@<holder>:*`), no el catálogo
1911
+ * local: una escritura de catálogo que no las toque deja un rol que no
1912
+ * concede nada (`defineScopedRole`) o que sigue concediendo lo que ya no
1913
+ * vincula (`updateScopedRole`) — lo segundo es un fail-open. `syncAuthzCatalog`
1914
+ * ya lo hace para el catálogo entero cuando el consumidor le pasa la
1915
+ * proyección; esto es lo mismo para las escrituras de la API de delegación,
1916
+ * y cuesta una lectura por holder.
1917
+ *
1918
+ * **Y son DOS proyecciones, no una** (3b-2g · R1): lo que el rol concede
1919
+ * (`permits_<P>`) y **dónde es visible** (`scope#binding`, `sweepRoleVisibility`).
1920
+ * El modelo (c2) no lleva el NIVEL del rol, así que un `scope_type` que
1921
+ * cambia sin barrer deja la asignación concediendo en un nivel que el
1922
+ * catálogo ya no declara — retirado en `database` y vivo aquí, que es la
1923
+ * divergencia R1 del lote 2e.
1924
+ */
1925
+ async projectCatalogRole(roleUuid) {
1926
+ assertCatalogUuid('rol', roleUuid);
1927
+ await this.sweepRoleVisibility(roleUuid);
1928
+ const catalog = await this.catalog.view();
1929
+ const permissions = [...catalog.rolePermissionsOf(roleUuid)].sort();
1930
+ const object = `${FACTS_ROLE_TYPE}:${roleUuid}`;
1931
+ const wanted = new Map();
1932
+ for (const tuple of factsCatalogTuples([{ uuid: roleUuid, permissions }], this.holderTypes)) {
1933
+ wanted.set(factsTupleId(tuple), tuple);
1934
+ }
1935
+ const current = new Map();
1936
+ for (const holderType of Object.values(this.holderTypes)) {
1937
+ for (const key of await this.readAllTuples({ user: `${holderType}:*`, object }, { includeExpired: true })) {
1938
+ if (!key.relation.startsWith(FACTS_PERMITS_PREFIX))
1939
+ continue;
1940
+ current.set(factsTupleId(key), key);
1941
+ }
1942
+ }
1943
+ const writes = [...wanted.values()].filter((tuple) => !current.has(factsTupleId(tuple)));
1944
+ const deletes = [...current.values()].filter((tuple) => !wanted.has(factsTupleId(tuple)));
1945
+ if (!writes.length && !deletes.length)
1946
+ return;
1947
+ await this.client.write({ writes, deletes });
1948
+ }
1949
+ /* ── `authz:reconcile --to=openfga` (3b-3a) ───────────────────────────── */
1950
+ /**
1951
+ * **Reconstruye el store desde `authz_*` + el árbol del consumidor.**
1952
+ *
1953
+ * Es la razón de ser de la fase («todo en un driver o todo en otro, con una
1954
+ * migración idempotente y bidireccional») y la ÚNICA primitiva de migración
1955
+ * del paquete: `openfga:import` se borró en 3b-2k · K2 porque escribía las
1956
+ * tuplas de un modelo que ya no existe.
1957
+ *
1958
+ * Migra las TRES cosas que hacen completo a este driver:
1959
+ * 1. el **marcador de raíz** (`scope:app#rooted@<holder>:*`, 3b-2i) — sin
1960
+ * él el store entero DENIEGA, así que va primero;
1961
+ * 2. la **proyección del catálogo** (`role:<uuid>#permits_<P>`), leída con
1962
+ * la MISMA función que usa `syncAuthzCatalog` (`readCatalogProjectionSnapshot`),
1963
+ * para que reconcile no "arregle" en cada pasada lo que el sync deja bien;
1964
+ * 3. el **árbol** (`scope#parent`) desde `scopes.enumerateEdges`, y
1965
+ * 4. los **hechos**: `authz_assignments` (assignee + las dos aristas de
1966
+ * (c2)) y `authz_denies` (`scope#denied_<P>`).
1967
+ *
1968
+ * **Qué borra y qué no.** Lo DERIVADO —marcador, catálogo y árbol— es un
1969
+ * espejo de datos locales que nadie más escribe: lo que sobra se borra
1970
+ * siempre (cruce 9 · S7 lo exige para las aristas que `enumerateEdges` no
1971
+ * respalda, y es lo que repara un nodo con DOS padres, 3b-2h · 🟠 4). Los
1972
+ * HECHOS solo se borran con `prune`: son irreversibles y su origen depende
1973
+ * de qué driver esté vivo. La excepción, a propósito, es la arista
1974
+ * `scope#binding` de una asignación que el origen SÍ respalda pero cuya
1975
+ * regla de visibilidad dice que NO (invariante 18): dejarla es fail-OPEN
1976
+ * —es justo la escritura que `scopes.moved`/`projectCatalogRole` pudieron
1977
+ * perder si el relay no pasó—, así que se borra siempre y se cuenta en
1978
+ * `drift.roleVisibility`.
1979
+ *
1980
+ * **Nada de `Ignore` a ciegas** (cruce 9 · S7): el importador viejo escribía
1981
+ * con `onDuplicateWrites: Ignore` y por eso una tupla que ya estaba con OTRA
1982
+ * caducidad se quedaba como estaba y encima se contaba como escrita —rompía
1983
+ * los invariantes 3 y 6—. Aquí el estado del destino se LEE entero antes de
1984
+ * decidir, la diferencia de caducidad se resuelve con delete + write, y los
1985
+ * contadores salen del diff, no del write. `Ignore` se conserva solo como
1986
+ * red contra una carrera (y por eso la migración va con `manager.freeze()`).
1987
+ *
1988
+ * `dryRun` es el VERIFICADOR: mismo recorrido, cero escrituras, mismos
1989
+ * números. Read-only por contrato (cruce 4 · S18): **un `--fix` está
1990
+ * prohibido** y no se implementa ni se deja preparado.
1991
+ */
1992
+ async reconcile(source, options = {}) {
1993
+ const dryRun = options.dryRun === true;
1994
+ const prune = options.prune === true;
1995
+ const batchSize = reconcileBatchSize(options.batchSize);
1996
+ const maxTuples = reconcileMaxTuples(options.maxTuples);
1997
+ const catalog = await this.catalog.view();
1998
+ const now = this.now();
1999
+ /** Lo que el destino DEBE tener, por id de tupla. */
2000
+ const wanted = new Map();
2001
+ /** Aristas de visibilidad que NO pueden existir (invariante 18). */
2002
+ const forbidden = new Set();
2003
+ const skipped = {};
2004
+ const details = [];
2005
+ const note = (kind, reason, detail) => {
2006
+ skipped[reason] = (skipped[reason] ?? 0) + 1;
2007
+ if (details.length < RECONCILE_MAX_DETAILS)
2008
+ details.push({ kind, reason, detail });
2009
+ };
2010
+ const want = (tuple, family, validUntil = null) => {
2011
+ wanted.set(factsTupleId(tuple), { tuple, family, validUntil });
2012
+ };
2013
+ // 1. El marcador de raíz. Va el primero porque sin él `can_<P>` es `false`
2014
+ // en TODO el store (fail-closed total, medido en 3b-2i).
2015
+ for (const tuple of factsRootTuples(this.holderTypes))
2016
+ want(tuple, 'root');
2017
+ // 2. La proyección del catálogo, con la MISMA foto que el sync.
2018
+ const snapshot = await readCatalogProjectionSnapshot(db, (operation, fn) => this.sql(`reconcile.catalog.${operation}`, fn));
2019
+ // Lo que el MODELO publicado declara: un permiso que el catálogo tiene y
2020
+ // el modelo no (el store se quedó en una versión anterior) haría que el
2021
+ // `Write` de su `permits_<P>` fuese un 400 del servidor a mitad de la
2022
+ // migración. Se detecta con una lectura y se cuenta, en vez de reventar.
2023
+ const publishable = await this.modelPermissions();
2024
+ const usable = (permission) => publishable === null || publishable.has(factsRelationsOf(permission).permits);
2025
+ for (const role of snapshot.roles) {
2026
+ const permissions = role.permissions.filter((permission) => {
2027
+ if (usable(permission))
2028
+ return true;
2029
+ note('tuple', 'permission-not-in-model', `role:${role.uuid} permits_${permission}`);
2030
+ return false;
2031
+ });
2032
+ for (const tuple of factsCatalogTuples([{ uuid: role.uuid, permissions }], this.holderTypes)) {
2033
+ want(tuple, 'catalog');
2034
+ }
2035
+ }
2036
+ // 3. El ÁRBOL, paginado por cursor y con los ciclos DETECTADOS: FGA acepta
2037
+ // un ciclo de `parent` y lo evalúa —un grant en cualquier nodo concede
2038
+ // en la raíz (cruce 3, reproducido dos veces)—, así que ninguna arista
2039
+ // de un ciclo se escribe y el ciclo se reporta.
2040
+ const { parentOf, cycles } = await this.readSourceTree(source, batchSize, note);
2041
+ for (const [child, parent] of parentOf)
2042
+ want(factsParentTuple(child, parent), 'tree');
2043
+ /** `[key, ...ancestros]` según el árbol del ORIGEN, o `null` si no llega a la raíz. */
2044
+ const chains = new Map();
2045
+ const chainOf = (key) => {
2046
+ const memo = chains.get(key);
2047
+ if (memo !== undefined)
2048
+ return memo;
2049
+ const chain = [];
2050
+ let current = key;
2051
+ const seen = new Set();
2052
+ while (current !== undefined && !seen.has(current)) {
2053
+ chain.push(current);
2054
+ if (current === APP_SCOPE_TYPE) {
2055
+ chains.set(key, chain);
2056
+ return chain;
2057
+ }
2058
+ seen.add(current);
2059
+ current = parentOf.get(current);
2060
+ }
2061
+ chains.set(key, null);
2062
+ return null;
2063
+ };
2064
+ // **Las cadenas que superan el techo** (alpha.5 · R0, tester 4-6): el
2065
+ // verificador ya camina el árbol, así que lo dice aquí y no en el primer
2066
+ // tenant que lo alcance. Un solo mando: `scopes.maxDepth` del config si
2067
+ // llega, y si no la cota MEDIDA del modelo (22 saltos).
2068
+ const maxDepth = reconcileMaxDepth(source.maxDepth);
2069
+ const tooDeep = [];
2070
+ for (const key of parentOf.keys()) {
2071
+ const chain = chainOf(key);
2072
+ if (chain !== null && chain.length - 1 > maxDepth)
2073
+ tooDeep.push(key);
2074
+ }
2075
+ tooDeep.sort();
2076
+ // 4. Los HECHOS, **de quien sea su fuente de verdad** (3b-5). `authz_*`
2077
+ // lo es en la MIGRACIÓN `database` → `openfga`, y por eso esa
2078
+ // dirección es la vía de salida de un store escrito por otra versión.
2079
+ // Pero cuando el motor ya SIRVE desde este driver los hechos son las
2080
+ // tuplas del store, no esas tablas: leerlas ahí resucitaba lo revocado
2081
+ // tras el cutover y dejaba el barrido de visibilidad sin aplicar (los
2082
+ // dos 🔴 del auditor final). Quién es el origen lo decide el manager y
2083
+ // llega en `source.factsOrigin`; aquí solo se obedece.
2084
+ const origin = source.factsOrigin ?? {
2085
+ name: AUTHZ_TABLES_ORIGIN,
2086
+ authzTables: true,
2087
+ };
2088
+ const factsCtx = { note, usable, chainOf, want, forbidden };
2089
+ const facts = origin.authzTables
2090
+ ? await this.readSourceFacts(catalog, batchSize, now, factsCtx)
2091
+ : await this.readOriginFacts(source, batchSize, maxTuples, catalog, now, factsCtx);
2092
+ // 5. El destino ENTERO, tal como está. **La cota es DECLARADA** (3b-3b ·
2093
+ // B5): este volcado entra en memoria —el ORIGEN se lee por lotes con
2094
+ // cursor, el destino no—, así que pasar de `maxTuples` es un 500 con
2095
+ // nombre antes de escribir nada, no un OOM a mitad de migración.
2096
+ const current = await this.readEveryTuple(maxTuples);
2097
+ const phases = emptyPhases();
2098
+ const drift = {
2099
+ rootMarker: false,
2100
+ multiParent: [],
2101
+ roleVisibility: 0,
2102
+ pendingRelay: 0,
2103
+ deadRelay: 0,
2104
+ tooDeep,
2105
+ tooDeepLimit: maxDepth,
2106
+ };
2107
+ const deletes = [];
2108
+ const writes = [];
2109
+ const seen = new Set();
2110
+ const parents = new Map();
2111
+ /** Bindings que el store YA conoce (tienen `assignee`): ver `drift.roleVisibility`. */
2112
+ const storeBindings = new Set();
2113
+ /**
2114
+ * **Lo AJENO del store COMPARTIDO** (alpha.9 · B): tuplas cuyo tipo de
2115
+ * objeto no gobierna esta pasada. Se cuentan aparte —fuera de `skipped`,
2116
+ * fuera de `details` y fuera de `phases`— y salen del bucle ANTES de
2117
+ * cualquier `deletes.push` o `phases[...]`.
2118
+ */
2119
+ const foreignTypes = new Map();
2120
+ let foreignTuples = 0;
2121
+ for (const tuple of current) {
2122
+ const id = factsTupleId(tuple);
2123
+ const family = familyOfTuple(tuple);
2124
+ if (family === 'foreign') {
2125
+ foreignTuples += 1;
2126
+ const type = objectTypeOf(tuple.object);
2127
+ foreignTypes.set(type, (foreignTypes.get(type) ?? 0) + 1);
2128
+ continue;
2129
+ }
2130
+ if (tuple.relation === FACTS_ASSIGNEE_RELATION)
2131
+ storeBindings.add(tuple.object);
2132
+ if (tuple.relation === FACTS_PARENT_RELATION && tuple.object.startsWith(`${FACTS_SCOPE_TYPE}:`)) {
2133
+ parents.set(tuple.object, (parents.get(tuple.object) ?? 0) + 1);
2134
+ }
2135
+ const target = wanted.get(id);
2136
+ if (target) {
2137
+ seen.add(id);
2138
+ if (sameInstant(tuple.validUntil, target.validUntil)) {
2139
+ phases[target.family].unchanged += 1;
2140
+ continue;
2141
+ }
2142
+ // La caducidad NO es parte de la clave en FGA: cambiarla es borrar y
2143
+ // volver a escribir (por eso `Ignore` se queda la vieja — S7).
2144
+ phases[target.family].updated += 1;
2145
+ deletes.push(bareTuple(tuple));
2146
+ writes.push(reconcileWriteForm(target));
2147
+ continue;
2148
+ }
2149
+ phases[family].extra += 1;
2150
+ const visibilityDrift = forbidden.has(id);
2151
+ if (visibilityDrift)
2152
+ drift.roleVisibility += 1;
2153
+ // Lo derivado se rehace entero; los hechos solo con `--prune`.
2154
+ if (family === 'facts' && !prune && !visibilityDrift) {
2155
+ note('tuple', 'extra-fact', id);
2156
+ continue;
2157
+ }
2158
+ phases[family].deleted += 1;
2159
+ deletes.push(bareTuple(tuple));
2160
+ }
2161
+ for (const [id, target] of wanted) {
2162
+ if (seen.has(id))
2163
+ continue;
2164
+ if (target.family === 'root')
2165
+ drift.rootMarker = true;
2166
+ // Una arista de visibilidad que falta con el binding YA en el store es
2167
+ // la escritura del invariante 18 que el relay pudo perder; en una
2168
+ // migración a un store vacío no falta nada, sobra todo.
2169
+ if (target.tuple.relation === FACTS_BINDING_RELATION && storeBindings.has(target.tuple.user)) {
2170
+ drift.roleVisibility += 1;
2171
+ }
2172
+ phases[target.family].written += 1;
2173
+ writes.push(reconcileWriteForm(target));
2174
+ }
2175
+ for (const [object, count] of parents) {
2176
+ if (count > 1)
2177
+ drift.multiParent.push(object);
2178
+ }
2179
+ drift.multiParent.sort();
2180
+ // El seguro contra el origen ciego (AA2 aplicado a la migración): borrar
2181
+ // hechos con `authz_*` VACÍO es casi siempre apuntar a la base equivocada
2182
+ // o estar mirando el driver que NO escribe ahí.
2183
+ const factsDeleted = phases.facts.deleted;
2184
+ // 3b-8 · B1: sobre los hechos UTILIZABLES, no el conteo crudo — un origen
2185
+ // que devuelve N filas y las N se descartan (caducadas, scopes que ya no
2186
+ // resuelven) sigue sin respaldar NADA de lo que --prune va a borrar.
2187
+ const massDelete = prune && factsDeleted > 0 && facts.usable === 0;
2188
+ if (massDelete && !dryRun && options.allowMassDelete !== true) {
2189
+ throw new MassReconcileRefusedError(`authz:reconcile --to=openfga --prune borraría ${factsDeleted} tupla(s) de hechos del store y el ` +
2190
+ `ORIGEN no ha aportado NI UN hecho utilizable (${facts.rows} leídos, todos descartados — mira ` +
2191
+ `'skipped'). Eso es la firma de una base equivocada, de un árbol que ya no resuelve esos scopes, ` +
2192
+ `o de que los hechos los está escribiendo el driver 'openfga' (que los guarda en el store, no en ` +
2193
+ `'authz_assignments'/'authz_denies'): esta pasada dejaría el store sin nada concedido y no hay desde ` +
2194
+ `dónde reconstruirlo. Comprueba la conexión y el driver activo; si de verdad quieres vaciarlo, ` +
2195
+ `--allow-mass-delete.`);
2196
+ }
2197
+ if (!dryRun) {
2198
+ // Los DELETES primero: si la pasada muere entre medias, lo que queda es
2199
+ // de MENOS (fail-closed), nunca una tupla vieja conviviendo con la nueva.
2200
+ await this.applyReconcileWrites(deletes, []);
2201
+ await this.applyReconcileWrites([], writes);
2202
+ }
2203
+ const totals = sumPhases(phases);
2204
+ return {
2205
+ to: 'openfga',
2206
+ factsFrom: origin.name,
2207
+ dryRun,
2208
+ prune,
2209
+ ...totals,
2210
+ phases,
2211
+ skipped,
2212
+ details,
2213
+ cycles,
2214
+ drift,
2215
+ massDelete,
2216
+ foreign: foreignReport(foreignTuples, foreignTypes, source.relationTypes),
2217
+ };
2218
+ }
2219
+ /**
2220
+ * Las relaciones `permits_<P>` que DECLARA el modelo publicado del store, o
2221
+ * `null` si no se puede saber (un store sin modelo). No es una barrera de
2222
+ * seguridad: es la diferencia entre contar un permiso que este store no
2223
+ * puede llevar y morirse con un 400 a mitad de la migración.
2224
+ */
2225
+ async modelPermissions() {
2226
+ const response = await this.client.readLatestAuthorizationModel();
2227
+ const model = response?.authorization_model;
2228
+ if (!model?.type_definitions)
2229
+ return null;
2230
+ const role = model.type_definitions.find((definition) => definition.type === FACTS_ROLE_TYPE);
2231
+ if (!role?.relations)
2232
+ return null;
2233
+ return new Set(Object.keys(role.relations).filter((name) => name.startsWith(FACTS_PERMITS_PREFIX)));
2234
+ }
2235
+ /**
2236
+ * El árbol del ORIGEN, paginado (`scopes.enumerateEdges`), con los ciclos
2237
+ * apartados. Un ciclo no se escribe NUNCA: FGA lo evalúa y la herencia pasa
2238
+ * a ser bidireccional (un grant en un descendiente concede en el ancestro,
2239
+ * cruce 3), así que aquí sale como reporte y sus nodos se quedan sin arista
2240
+ * —o sea, sin `rooted`, o sea denegando (fail-closed)—.
2241
+ */
2242
+ async readSourceTree(source, batchSize, note) {
2243
+ if (typeof source.enumerateEdges !== 'function') {
2244
+ throw new AuthorizationConfigError("authz:reconcile necesita 'scopes.enumerateEdges' en config/authorization.ts: sin el árbol del consumidor " +
2245
+ 'no se puede reconstruir el del store (y suponerlo plano sería inventar una jerarquía).');
2246
+ }
2247
+ const parentOf = new Map();
2248
+ const cycles = [];
2249
+ const cycleNodes = new Set();
2250
+ const seenCursors = new Set();
2251
+ let after;
2252
+ for (let page = 0;; page++) {
2253
+ const result = await source.enumerateEdges({ limit: batchSize, after });
2254
+ if (!result || !Array.isArray(result.edges)) {
2255
+ throw new AuthorizationConfigError(`scopes.enumerateEdges devolvió ${typeof result} en vez de { edges, cursor? }`);
2256
+ }
2257
+ if (result.edges.length > batchSize) {
2258
+ throw new AuthorizationConfigError(`scopes.enumerateEdges devolvió ${result.edges.length} aristas con limit=${batchSize}: ` +
2259
+ `no se puede paginar lo que no cabe en el lote.`);
2260
+ }
2261
+ for (const edge of result.edges) {
2262
+ if (!edge || !isValidScope(edge.child) || !isValidScope(edge.parent)) {
2263
+ note('edge', 'invalid-scope', JSON.stringify(edge ?? null));
2264
+ continue;
2265
+ }
2266
+ if (edge.child.type === APP_SCOPE_TYPE) {
2267
+ note('edge', 'root-child', `la raíz 'app' no cuelga de nada`);
2268
+ continue;
2269
+ }
2270
+ const child = scopeKey(edge.child);
2271
+ const parent = scopeKey(edge.parent);
2272
+ const previous = parentOf.get(child);
2273
+ if (previous !== undefined) {
2274
+ note('edge', 'two-parents-in-source', `${child} → ${previous} y ${parent}`);
2275
+ continue;
2276
+ }
2277
+ const cycle = walkUpTo(parentOf, parent, child);
2278
+ if (cycle) {
2279
+ // `cycle` ya viene con TODOS los nodos del ciclo (`[padre … hijo]`).
2280
+ cycles.push(cycle);
2281
+ for (const node of cycle)
2282
+ cycleNodes.add(node);
2283
+ note('edge', 'cycle', `${child} → ${parent} cierra un ciclo`);
2284
+ continue;
2285
+ }
2286
+ parentOf.set(child, parent);
2287
+ }
2288
+ if (result.cursor === undefined || result.cursor === null)
2289
+ break;
2290
+ if (seenCursors.has(result.cursor)) {
2291
+ throw new AuthorizationConfigError(`scopes.enumerateEdges: el cursor '${result.cursor}' se repite (página ${page + 1}); no avanza.`);
2292
+ }
2293
+ seenCursors.add(result.cursor);
2294
+ after = result.cursor;
2295
+ if (page >= RECONCILE_MAX_PAGES) {
2296
+ throw new AuthorizationInternalError(`scopes.enumerateEdges: más de ${RECONCILE_MAX_PAGES} páginas sin agotar el árbol.`);
2297
+ }
2298
+ }
2299
+ // Un ciclo deja a TODOS sus nodos sin arista, no solo al que la cerró: si
2300
+ // no, cuál se queda fuera dependería del orden de enumeración.
2301
+ for (const node of cycleNodes) {
2302
+ if (parentOf.delete(node))
2303
+ note('edge', 'cycle', `${node} pertenece a un ciclo: su arista no se escribe`);
2304
+ }
2305
+ return { parentOf, cycles };
2306
+ }
2307
+ /**
2308
+ * Los HECHOS del origen (`authz_assignments` y `authz_denies`), leídos **por
2309
+ * lotes con cursor** sobre la clave primaria: una pasada interrumpida se
2310
+ * repite y converge (es idempotente), y una base grande no entra entera en
2311
+ * memoria de golpe. Los DOS barridos van en la MISMA transacción de lectura
2312
+ * repetible (3b-6, `withSourceSnapshot`): sueltos, componían dos mitades de
2313
+ * dos operaciones distintas y FABRICABAN un permiso.
2314
+ *
2315
+ * Cada fila que NO se migra sale contada y con su motivo:
2316
+ * - `unknown-scope`: el árbol del consumidor ya no resuelve ese scope
2317
+ * (`detached` de un ancestro). Sus tuplas del store son las de la
2318
+ * «resurrección» (3b-0b · AA4) y las borra `--prune`.
2319
+ * - `unknown-role` / `unknown-permission`: el catálogo ya no lo declara
2320
+ * (un rol retirado; invariante 11 dice que los recoge este comando).
2321
+ * - `expired`: la asignación ya no concede; migrarla sería escribir una
2322
+ * caducidad pasada.
2323
+ * - `role-not-visible`: la asignación existe (y `listRoles`/`hasRole` la
2324
+ * enumeran), pero el rol NO es visible en ese scope con el árbol y el
2325
+ * catálogo de HOY (invariante 18), así que su arista `scope#binding` no
2326
+ * se escribe — y si el store la tiene, se borra.
2327
+ * - `unknown-holder-type`: el `holderTypes` del config no declara ese
2328
+ * morph name, así que no hay usuario FGA que escribir.
2329
+ */
2330
+ async readSourceFacts(catalog, batchSize, now, ctx) {
2331
+ const expiry = sqlExpiryCodec(db.connection());
2332
+ let rows = 0;
2333
+ // Hechos que RESPALDAN algo en el destino (3b-8 · B1): el seguro de
2334
+ // borrado masivo mira esto, no el conteo crudo.
2335
+ let usable = 0;
2336
+ // Los DOS barridos, en UNA sola foto (3b-6). Ver `withSourceSnapshot`.
2337
+ return this.withSourceSnapshot(async (client) => {
2338
+ await this.eachRow(client, 'reconcile.assignments', batchSize, (query) => query
2339
+ .from('authz_assignments')
2340
+ .select('uuid', 'holder_type', 'holder_uuid', 'role_uuid', 'scope_type', 'scope_uuid')
2341
+ .select(expiry.select('expires_at')), (row) => {
2342
+ rows += 1;
2343
+ if (this.wantAssignment(catalog, now, ctx, {
2344
+ holder: { type: row.holder_type, uuid: String(row.holder_uuid) },
2345
+ // `scope_uuid` es NOT NULL y la RAÍZ va con el centinela (K1/L0.10):
2346
+ // un `?? null` nunca es null y `scopeKey` rechazaba `app` con uuid,
2347
+ // así que un grant en la raíz reventaba la migración entera (3b-3b).
2348
+ scope: { type: row.scope_type, uuid: fromDbScopeUuid(String(row.scope_uuid)) },
2349
+ roleUuid: String(row.role_uuid),
2350
+ expiresAt: expiry.fromDb(row.expires_at),
2351
+ detail: `${row.holder_type}:${row.holder_uuid} → ${row.role_uuid} @ ${row.scope_type}:${row.scope_uuid ?? ''}`,
2352
+ })) {
2353
+ usable += 1;
2354
+ }
2355
+ });
2356
+ await this.eachRow(client, 'reconcile.denies', batchSize, (query) => query.from('authz_denies').select('uuid', 'holder_type', 'holder_uuid', 'permission_uuid', 'scope_type', 'scope_uuid'), (row) => {
2357
+ rows += 1;
2358
+ const label = `${row.holder_type}:${row.holder_uuid} ⊘ ${row.permission_uuid} @ ${row.scope_type}:${row.scope_uuid ?? ''}`;
2359
+ // En `authz_denies` el permiso es un uuid; por el puerto es un slug
2360
+ // (`ReconcileFact.permission`), que es lo que el catálogo local sabe
2361
+ // traducir. Aquí se traduce una vez y el resto es común.
2362
+ const permission = catalog.permissionSlug(String(row.permission_uuid));
2363
+ if (!permission) {
2364
+ ctx.note('deny', 'unknown-permission', label);
2365
+ return;
2366
+ }
2367
+ if (this.wantDeny(ctx, {
2368
+ holder: { type: row.holder_type, uuid: String(row.holder_uuid) },
2369
+ scope: { type: row.scope_type, uuid: fromDbScopeUuid(String(row.scope_uuid)) },
2370
+ permission,
2371
+ detail: label,
2372
+ })) {
2373
+ usable += 1;
2374
+ }
2375
+ });
2376
+ return { rows, usable };
2377
+ });
2378
+ }
2379
+ /**
2380
+ * **La foto CONSISTENTE del origen** (3b-6; 🔴 3 del panel 3, verificado en
2381
+ * el código por el juez).
2382
+ *
2383
+ * `readSourceFacts` recorre `authz_assignments` y DESPUÉS `authz_denies`.
2384
+ * Cada barrido se construía sobre la conexión global, así que entre los dos
2385
+ * había un hueco —el tiempo de pasear la primera tabla entera por lotes de
2386
+ * 100: segundos o minutos en una base real— por el que se colaban
2387
+ * operaciones de negocio COMPUESTAS. Un offboarding es `revoke` +
2388
+ * `removeDeny`: si cae ahí, la pasada se queda con la mitad de cada una y
2389
+ * escribe en el destino **el rol sin su deny**, o sea un permiso que NI el
2390
+ * estado anterior NI el posterior concedían, con el reporte diciendo
2391
+ * `written=13 extra=0 skipped={} clean=true`. No es una pérdida: es una
2392
+ * ESCALADA fabricada, y el operador no tiene ni un motivo para desconfiar
2393
+ * del verde.
2394
+ *
2395
+ * Con los dos barridos dentro de UNA transacción de lectura repetible, el
2396
+ * peor resultado de la ventana deja de ser «un estado que nunca existió» y
2397
+ * pasa a ser «el estado consistente de `t0`»: la deriva recuperable que
2398
+ * repite la pasada siguiente.
2399
+ *
2400
+ * **Qué garantiza cada motor, porque no es lo mismo** (Fase 2.5 dixit):
2401
+ * - **PostgreSQL**: `BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ`.
2402
+ * La foto se toma en la primera sentencia de la transacción y no se
2403
+ * mueve; garantía del motor.
2404
+ * - **MySQL/InnoDB**: `SET TRANSACTION ISOLATION LEVEL REPEATABLE READ` +
2405
+ * `BEGIN`, y la lectura consistente queda fijada en la primera consulta.
2406
+ * REPEATABLE READ es su default, pero se DECLARA a propósito: el default
2407
+ * es config del servidor y no una promesa del paquete.
2408
+ * - **SQLite**: no acepta nivel de aislamiento —knex avisa y lo ignora—,
2409
+ * así que no se le pide: su transacción de LECTURA ya es una foto (en
2410
+ * WAL el lector conserva su snapshot mientras un escritor confirma; sin
2411
+ * WAL el escritor espera al lector). Por eso aquí se pregunta el
2412
+ * dialecto en vez de mandar el nivel a ciegas.
2413
+ *
2414
+ * **Lo que esto NO cubre y queda declarado**: cubre la dirección cuyo
2415
+ * origen es `authz_*` (`--to=openfga`). Cuando la fuente de verdad de los
2416
+ * hechos es el STORE (`readOriginFacts` → `enumerateFacts`: `--to=database`
2417
+ * y la pasada de mantenimiento) las páginas de `Read` **tampoco son una
2418
+ * foto consistente** y no hay REPEATABLE READ que valga: ahí la misma
2419
+ * composición de media transacción sigue siendo posible. La instrumentación
2420
+ * posible en esa dirección es `readChanges({ startTime })` —detectar y
2421
+ * nombrar la tupla, no prevenirla—, y no está hecha.
2422
+ */
2423
+ async withSourceSnapshot(fn) {
2424
+ const options = isSqliteDialect(db.connection())
2425
+ ? undefined
2426
+ : { isolationLevel: 'repeatable read' };
2427
+ // Lo que lance `fn` ya viene clasificado (cada consulta pasa por
2428
+ // `this.sql`); lo que falle al ABRIR o cerrar la transacción es la base y
2429
+ // se clasifica aquí, que un error crudo de knex no cruza la frontera.
2430
+ let inner = null;
2431
+ try {
2432
+ return await db.transaction(async (trx) => {
2433
+ try {
2434
+ return await fn(trx);
2435
+ }
2436
+ catch (error) {
2437
+ inner = { error };
2438
+ throw error;
2439
+ }
2440
+ }, options);
2441
+ }
2442
+ catch (error) {
2443
+ if (inner !== null && inner.error === error)
2444
+ throw error;
2445
+ if (isAuthzError(error))
2446
+ throw error;
2447
+ if (isTimeoutLike(error)) {
2448
+ throw new AuthorizationBackendTimeoutError('openfga', 'reconcile.snapshot', this.timeoutMs, error);
2449
+ }
2450
+ throw new AuthorizationBackendError('openfga', 'reconcile.snapshot', error);
2451
+ }
2452
+ }
2453
+ /**
2454
+ * **Los hechos por el PUERTO** (3b-5): la otra fuente de verdad posible.
2455
+ *
2456
+ * Se usa cuando `authz_*` NO manda —el caso que faltaba: el motor ya sirve
2457
+ * desde este driver y sus hechos son las tuplas del store—, y también sirve
2458
+ * para migrar desde otro driver que sepa `enumerateFacts`. El recorrido y
2459
+ * los motivos son **los mismos** que los de `readSourceFacts` (`expired`,
2460
+ * `unknown-scope`, `unknown-role`, `unknown-holder-type`,
2461
+ * `role-not-visible`): lo único que cambia es de dónde salen las filas, que
2462
+ * es justo la decisión que no se estaba tomando.
2463
+ *
2464
+ * Consecuencia buscada: con el store como origen, `wanted` describe lo que
2465
+ * el store YA tiene, así que la pasada no escribe hechos —con `--prune` sí
2466
+ * BORRA los que `wanted` ya no respalda: los de scopes que ya no resuelven,
2467
+ * los caducados, los de roles/permisos retirados y los que sobran; alpha.10—
2468
+ * y
2469
+ * sí rehace lo DERIVADO (marcador, catálogo, árbol) y aplica el barrido de
2470
+ * visibilidad del invariante 18 con el árbol y el catálogo de HOY, que es
2471
+ * la reparación que el invariante promete y no existía.
2472
+ *
2473
+ * Disciplina del cursor idéntica a la de `--to=database`: como mucho
2474
+ * `limit` por página, el cursor tiene que AVANZAR y la cota `maxTuples` se
2475
+ * aplica también al origen.
2476
+ */
2477
+ async readOriginFacts(source, batchSize, maxTuples, catalog, now, ctx) {
2478
+ const enumerate = source.facts;
2479
+ if (typeof enumerate !== 'function') {
2480
+ throw new UnsupportedOperationError('enumerateFacts', 'authz:reconcile --to=openfga', 'origen', `El ORIGEN de esta pasada no son 'authz_assignments'/'authz_denies', así que tiene que saber ` +
2481
+ `enumerar sus hechos: sin eso la pasada leería cero hechos y con --prune vaciaría el store.`);
2482
+ }
2483
+ let rows = 0;
2484
+ let usable = 0;
2485
+ let after;
2486
+ const seenCursors = new Set();
2487
+ for (let page = 0;; page++) {
2488
+ const got = await enumerate({ limit: batchSize, after });
2489
+ if (got.facts.length > batchSize) {
2490
+ throw new AuthorizationInternalError(`authz:reconcile: el origen devolvió ${got.facts.length} hechos con limit=${batchSize}`);
2491
+ }
2492
+ for (const skip of got.skipped ?? [])
2493
+ ctx.note(skip.kind, skip.reason, skip.detail);
2494
+ for (const fact of got.facts) {
2495
+ rows += 1;
2496
+ if (rows > maxTuples) {
2497
+ throw new ReconcileTooLargeError(`authz:reconcile --to=openfga: el ORIGEN pasa de maxTuples (${maxTuples}) hechos y la pasada ` +
2498
+ `necesita compararlos contra el estado ENTERO del destino, que entra en memoria. Sube ` +
2499
+ `maxTuples si tu proceso lo aguanta; no hay migración por particiones en esta versión.`);
2500
+ }
2501
+ if (fact.kind === 'assignment') {
2502
+ if (this.wantAssignment(catalog, now, ctx, {
2503
+ holder: fact.holder,
2504
+ scope: fact.scope,
2505
+ roleUuid: String(fact.roleUuid ?? ''),
2506
+ expiresAt: fact.expiresAt ?? null,
2507
+ detail: fact.detail,
2508
+ })) {
2509
+ usable += 1;
2510
+ }
2511
+ continue;
2512
+ }
2513
+ if (!fact.permission) {
2514
+ ctx.note('deny', 'unknown-permission', fact.detail);
2515
+ continue;
2516
+ }
2517
+ if (this.wantDeny(ctx, {
2518
+ holder: fact.holder,
2519
+ scope: fact.scope,
2520
+ permission: fact.permission,
2521
+ detail: fact.detail,
2522
+ })) {
2523
+ usable += 1;
2524
+ }
2525
+ }
2526
+ const cursor = got.cursor;
2527
+ if (!cursor)
2528
+ break;
2529
+ if (seenCursors.has(cursor)) {
2530
+ throw new AuthorizationInternalError(`authz:reconcile: el cursor del origen se repite (página ${page + 1}); no avanza`);
2531
+ }
2532
+ seenCursors.add(cursor);
2533
+ after = cursor;
2534
+ }
2535
+ return { rows, usable };
2536
+ }
2537
+ /**
2538
+ * Una ASIGNACIÓN del origen (venga de `authz_*` o del puerto) traducida a
2539
+ * lo que el store debe tener: el `assignee` con su caducidad, la arista
2540
+ * `role_binding#role` y —solo si el rol es VISIBLE ahí— la `scope#binding`.
2541
+ * Es la única implementación de esa regla en esta dirección: tenerla dos
2542
+ * veces era tenerla distinta según de dónde salieran los hechos.
2543
+ */
2544
+ wantAssignment(catalog, now, ctx, fact) {
2545
+ if (fact.expiresAt !== null && fact.expiresAt <= now) {
2546
+ ctx.note('assignment', 'expired', fact.detail);
2547
+ return false;
2548
+ }
2549
+ const key = scopeKey(fact.scope);
2550
+ const chain = ctx.chainOf(key);
2551
+ if (chain === null) {
2552
+ ctx.note('assignment', 'unknown-scope', fact.detail);
2553
+ return false;
2554
+ }
2555
+ const role = catalog.roleByUuid(fact.roleUuid);
2556
+ if (!role) {
2557
+ ctx.note('assignment', 'unknown-role', fact.detail);
2558
+ return false;
2559
+ }
2560
+ let user;
2561
+ try {
2562
+ user = this.fgaSubject(fact.holder);
2563
+ }
2564
+ catch {
2565
+ ctx.note('assignment', 'unknown-holder-type', fact.detail);
2566
+ return false;
2567
+ }
2568
+ const object = factsBindingObject(key, role.uuid);
2569
+ ctx.want({ user, relation: FACTS_ASSIGNEE_RELATION, object }, 'facts', fact.expiresAt);
2570
+ ctx.want({ user: `${FACTS_ROLE_TYPE}:${role.uuid}`, relation: FACTS_ROLE_RELATION, object }, 'facts');
2571
+ // La arista `scope#binding` significa «el rol es VISIBLE aquí»
2572
+ // (3b-2g · R1), y la regla es la misma que evalúa `database` en cada
2573
+ // pregunta: nivel declarado + owner en la cadena.
2574
+ const edge = factsScopeBindingTuple(key, role.uuid);
2575
+ if (declaredRoleAt(catalog, role.uuid, fact.scope.type, chain)) {
2576
+ ctx.want(edge, 'facts');
2577
+ }
2578
+ else {
2579
+ ctx.forbidden.add(factsTupleId(edge));
2580
+ ctx.note('assignment', 'role-not-visible', fact.detail);
2581
+ }
2582
+ return true;
2583
+ }
2584
+ /** Un DENY del origen (permiso ya como slug) traducido a `scope#denied_<P>`. */
2585
+ wantDeny(ctx, fact) {
2586
+ if (!ctx.usable(fact.permission)) {
2587
+ ctx.note('deny', 'permission-not-in-model', fact.detail);
2588
+ return false;
2589
+ }
2590
+ const key = scopeKey(fact.scope);
2591
+ if (ctx.chainOf(key) === null) {
2592
+ ctx.note('deny', 'unknown-scope', fact.detail);
2593
+ return false;
2594
+ }
2595
+ let user;
2596
+ try {
2597
+ user = this.fgaSubject(fact.holder);
2598
+ }
2599
+ catch {
2600
+ ctx.note('deny', 'unknown-holder-type', fact.detail);
2601
+ return false;
2602
+ }
2603
+ ctx.want(factsDenyTuple(key, fact.permission, user), 'facts');
2604
+ return true;
2605
+ }
2606
+ /**
2607
+ * Pasea una tabla `authz_*` por lotes de `batchSize` con cursor sobre
2608
+ * `uuid` (clave primaria: orden total y estable). Es lo que hace la pasada
2609
+ * REANUDABLE y lo que impide que una base grande entre entera de golpe.
2610
+ *
2611
+ * **El cliente llega por parámetro** (3b-6): antes cada página se construía
2612
+ * sobre el `db` global, así que dos barridos consecutivos eran dos fotos
2613
+ * distintas. Quien decide la foto es `withSourceSnapshot`, y aquí solo se
2614
+ * obedece — un call-site nuevo que pase `db` vuelve a abrir el hueco, y por
2615
+ * eso el parámetro es obligatorio y no tiene default.
2616
+ */
2617
+ async eachRow(client, operation, batchSize, build, handle) {
2618
+ let after;
2619
+ for (;;) {
2620
+ const rows = await this.sql(operation, () => {
2621
+ const query = build(client);
2622
+ if (after !== undefined)
2623
+ query.where('uuid', '>', after);
2624
+ return query.orderBy('uuid', 'asc').limit(batchSize);
2625
+ });
2626
+ for (const row of rows)
2627
+ handle(row);
2628
+ if (rows.length < batchSize)
2629
+ return;
2630
+ after = String(rows[rows.length - 1].uuid);
2631
+ }
2632
+ }
2633
+ /**
2634
+ * **Los hechos de este store, paginados** (3b-3b): la mitad ORIGEN de la
2635
+ * migración, la que hace posible `authz:reconcile --to=database`.
2636
+ *
2637
+ * Se lee con `Read` y su `continuation_token`, no con `ListObjects`: el
2638
+ * plan lo dice con esas palabras y el motivo es que `ListObjects` **no
2639
+ * tiene `continuation_token`** —corta al tope del servidor sin señal
2640
+ * (S16)—, así que no hay forma de pasear un store entero con él. `Read`
2641
+ * sin filtro es además lo único que ve la basura de una versión anterior,
2642
+ * que es lo que sale en `skipped`.
2643
+ *
2644
+ * **Nada se filtra**: una asignación caducada sale con su `expiresAt` para
2645
+ * que el DESTINO la cuente con su motivo. Si se filtrara aquí desaparecería
2646
+ * sin dejar rastro en ningún contador, que es exactamente lo que una
2647
+ * migración no puede hacer. Por lo mismo NO interviene el reloj del driver.
2648
+ *
2649
+ * Lo que no es un hecho —la estructura de (c2) (`parent`, `binding`,
2650
+ * `role`, `rooted`) y la proyección del catálogo (`permits_<P>`)— no se
2651
+ * emite ni se cuenta: es DERIVADO, se rehace desde el catálogo y el árbol
2652
+ * del consumidor, y en esta dirección ni siquiera se migra. Lo que sí se
2653
+ * cuenta es lo que TENDRÍA que ser un hecho y no se entiende.
2654
+ */
2655
+ async enumerateFacts(page) {
2656
+ const limit = Math.trunc(page.limit);
2657
+ if (!Number.isFinite(limit) || limit < 1) {
2658
+ throw new AuthorizationConfigError(`enumerateFacts: limit debe ser un entero >= 1 (llegó ${String(page.limit)})`);
2659
+ }
2660
+ const catalog = await this.catalog.view();
2661
+ const permissionByRelation = new Map();
2662
+ for (const slug of catalog.permissionSlugs) {
2663
+ permissionByRelation.set(factsRelationsOf(slug).denied, slug);
2664
+ }
2665
+ const fgaToMorph = Object.fromEntries(Object.entries(this.holderTypes).map(([morph, fga]) => [fga, morph]));
2666
+ const holderOf = (user) => {
2667
+ const separator = user.indexOf(':');
2668
+ if (separator <= 0)
2669
+ return null;
2670
+ const morph = fgaToMorph[user.slice(0, separator)];
2671
+ const uuid = user.slice(separator + 1);
2672
+ if (!morph || !uuid || uuid.includes('#') || uuid === '*')
2673
+ return null;
2674
+ return { type: morph, uuid };
2675
+ };
2676
+ const facts = [];
2677
+ const skipped = [];
2678
+ // El `pageSize` que se le pide al servidor va acotado por el tope de
2679
+ // página del driver: pedir 1 000 es un 400 del servidor, y el contrato del
2680
+ // puerto es «como mucho `limit`», no «exactamente `limit`».
2681
+ const response = await this.client.read({}, { pageSize: Math.min(limit, READ_PAGE_SIZE), continuationToken: page.after, consistency: this.consistency });
2682
+ for (const tuple of response.tuples ?? []) {
2683
+ const key = tuple?.key;
2684
+ if (!key?.user || !key?.relation || !key?.object) {
2685
+ skipped.push({ kind: 'tuple', reason: 'unparseable-tuple', detail: JSON.stringify(key ?? null) });
2686
+ continue;
2687
+ }
2688
+ const id = `${key.user}#${key.relation}@${key.object}`;
2689
+ if (key.relation === FACTS_ASSIGNEE_RELATION) {
2690
+ const binding = key.object.startsWith(`${FACTS_BINDING_TYPE}:`)
2691
+ ? parseBindingId(key.object.slice(FACTS_BINDING_TYPE.length + 1))
2692
+ : null;
2693
+ if (!binding) {
2694
+ // Un id de binding que este motor no escribiría (los de 1.x/2.1
2695
+ // llevaban el slug): es un hecho que NADIE puede migrar.
2696
+ skipped.push({ kind: 'tuple', reason: 'unparseable-binding', detail: id });
2697
+ continue;
2698
+ }
2699
+ const holder = holderOf(key.user);
2700
+ if (!holder) {
2701
+ skipped.push({ kind: 'tuple', reason: 'unknown-holder-type', detail: id });
2702
+ continue;
2703
+ }
2704
+ facts.push({
2705
+ kind: 'assignment',
2706
+ holder,
2707
+ scope: binding.scope,
2708
+ roleUuid: binding.uuid,
2709
+ expiresAt: toExpiryDate(key.condition?.context?.valid_until),
2710
+ detail: id,
2711
+ });
2712
+ continue;
2713
+ }
2714
+ if (key.relation.startsWith(FACTS_DENIED_PREFIX) && key.object.startsWith(`${FACTS_SCOPE_TYPE}:`)) {
2715
+ const permission = permissionByRelation.get(key.relation);
2716
+ if (!permission) {
2717
+ // La relación existe en el modelo pero el catálogo ya no declara
2718
+ // ese permiso (D5): no es un deny, y quien lo recoge es `--prune`.
2719
+ skipped.push({ kind: 'tuple', reason: 'unknown-permission', detail: id });
2720
+ continue;
2721
+ }
2722
+ const scope = scopeFromKey(key.object.slice(FACTS_SCOPE_TYPE.length + 1));
2723
+ if (!scope) {
2724
+ skipped.push({ kind: 'tuple', reason: 'invalid-scope', detail: id });
2725
+ continue;
2726
+ }
2727
+ const holder = holderOf(key.user);
2728
+ if (!holder) {
2729
+ skipped.push({ kind: 'tuple', reason: 'unknown-holder-type', detail: id });
2730
+ continue;
2731
+ }
2732
+ facts.push({ kind: 'deny', holder, scope, permission, detail: id });
2733
+ continue;
2734
+ }
2735
+ // **El deny del modo `resolver` de 2.2 ES un hecho y se emite** (3b-8 ·
2736
+ // A2): `deny_binding:<scopeKey>|<permissionUuid>#denied@<holder>`. El
2737
+ // modelo (c2r) ni declara ese tipo, pero un store escrito por la
2738
+ // versión anterior lo tiene, y `reconcile` es el sustituto documentado
2739
+ // del importador borrado en 3b-2k · K2: descartarlo en silencio perdía
2740
+ // los denies explícitos de la migración con el verificador en VERDE
2741
+ // (invariante 2 roto sin una línea en ningún contador). El permiso va
2742
+ // por uuid en el id (así los escribía 2.2); el catálogo local lo
2743
+ // traduce a slug, y lo que no traduce sale contado, jamás mudo.
2744
+ if (key.relation === LEGACY_DENIED_RELATION && key.object.startsWith(`${LEGACY_DENY_BINDING_TYPE}:`)) {
2745
+ const binding = parseBindingId(key.object.slice(LEGACY_DENY_BINDING_TYPE.length + 1));
2746
+ if (!binding) {
2747
+ skipped.push({ kind: 'tuple', reason: 'unparseable-binding', detail: id });
2748
+ continue;
2749
+ }
2750
+ const permission = catalog.permissionSlug(binding.uuid);
2751
+ if (!permission) {
2752
+ skipped.push({ kind: 'tuple', reason: 'unknown-permission', detail: id });
2753
+ continue;
2754
+ }
2755
+ const holder = holderOf(key.user);
2756
+ if (!holder) {
2757
+ skipped.push({ kind: 'tuple', reason: 'unknown-holder-type', detail: id });
2758
+ continue;
2759
+ }
2760
+ facts.push({ kind: 'deny', holder, scope: binding.scope, permission, detail: id });
2761
+ continue;
2762
+ }
2763
+ // Estructura y proyección del catálogo: derivado, no es un hecho.
2764
+ }
2765
+ const cursor = response.continuation_token || undefined;
2766
+ return { facts, ...(skipped.length ? { skipped } : {}), ...(cursor ? { cursor } : {}) };
2767
+ }
2768
+ /**
2769
+ * El store ENTERO, con la caducidad de cada tupla. Un `Read` sin filtro es
2770
+ * la única forma de ver lo que SOBRA —incluida la basura de una versión
2771
+ * anterior de ESTE subsistema, cuyos tipos el modelo de hoy ni declara
2772
+ * (`deny_binding`: se lee y se borra, comprobado contra el servidor)—:
2773
+ * filtrando por objeto solo se ve lo que ya se sabe que existe, y filtrar
2774
+ * por TIPO es un 400 del servidor si no se nombra también el `user`
2775
+ * (medido, v1.19.0), que es justo lo que esta pasada busca.
2776
+ *
2777
+ * Leerlo entero **no** significa que sea suyo (2.4.0-alpha.9 · B): con
2778
+ * `openfga` el store es COMPARTIDO con `relations/`, así que lo que sale de
2779
+ * aquí se clasifica por TIPO de objeto (`familyOfTuple` /
2780
+ * `OWN_FACTS_OBJECT_TYPES`) y lo ajeno se cuenta en `report.foreign` sin
2781
+ * tocarlo, ni siquiera con `--prune`. Sí ocupa la cota `maxTuples`: la foto
2782
+ * es del store, no de esta pasada. Paginado y acotado como todas las
2783
+ * enumeraciones.
2784
+ */
2785
+ async readEveryTuple(maxTuples) {
2786
+ const out = [];
2787
+ let continuationToken;
2788
+ const seenTokens = new Set();
2789
+ let pages = 0;
2790
+ do {
2791
+ const response = await this.client.read({}, { pageSize: READ_PAGE_SIZE, continuationToken, consistency: this.consistency });
2792
+ pages += 1;
2793
+ for (const tuple of response.tuples ?? []) {
2794
+ const key = tuple?.key;
2795
+ if (!key?.user || !key?.relation || !key?.object) {
2796
+ this.diagnostics.unparseableBindings += 1;
2797
+ this.warn(`authz(openfga): tupla malformada en el Read de reconcile (${JSON.stringify(key ?? null)}); ` +
2798
+ `no se puede comparar (total: ${this.diagnostics.unparseableBindings})`);
2799
+ continue;
2800
+ }
2801
+ out.push({
2802
+ user: key.user,
2803
+ relation: key.relation,
2804
+ object: key.object,
2805
+ validUntil: toExpiryDate(key.condition?.context?.valid_until),
2806
+ });
2807
+ if (out.length > maxTuples) {
2808
+ throw new ReconcileTooLargeError(`authz:reconcile --to=openfga: el store pasa de maxTuples (${maxTuples}) tuplas y la pasada ` +
2809
+ `necesita la foto ENTERA del destino para saber qué sobra (que es la mitad del trabajo: ` +
2810
+ `las aristas que ya nadie respalda, el nodo con dos padres, la basura de otra versión). ` +
2811
+ `Sube maxTuples si tu proceso lo aguanta; no hay migración por particiones en esta versión.`);
2812
+ }
2813
+ }
2814
+ continuationToken = response.continuation_token || undefined;
2815
+ if (continuationToken) {
2816
+ if (seenTokens.has(continuationToken)) {
2817
+ throw new AuthorizationInternalError(`reconcile: el continuation_token se repite (página ${pages}); el servidor no avanza`);
2818
+ }
2819
+ if (pages >= MAX_READ_PAGES) {
2820
+ throw new AuthorizationInternalError(`reconcile: más de ${MAX_READ_PAGES} páginas sin agotar el store`);
2821
+ }
2822
+ seenTokens.add(continuationToken);
2823
+ }
2824
+ } while (continuationToken);
2825
+ return out;
2826
+ }
2827
+ /**
2828
+ * Aplica el plan en lotes ≤ 100 (el límite del `Write`). `Ignore` en las dos
2829
+ * direcciones es RED, no política: el plan sale de un diff sobre el estado
2830
+ * leído y la migración corre con las escrituras congeladas, así que un
2831
+ * duplicado o un borrado que ya no está solo puede venir de una carrera —y
2832
+ * en una carrera es preferible seguir a abortar la migración entera—.
2833
+ */
2834
+ async applyReconcileWrites(deletes, writes) {
2835
+ for (let i = 0; i < deletes.length; i += PURGE_BATCH_SIZE) {
2836
+ await this.client.deleteTuples(deletes.slice(i, i + PURGE_BATCH_SIZE), {
2837
+ conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
2838
+ });
2839
+ }
2840
+ for (let i = 0; i < writes.length; i += PURGE_BATCH_SIZE) {
2841
+ await this.client.writeTuples(writes.slice(i, i + PURGE_BATCH_SIZE), IGNORE_DUPLICATE_WRITES);
2842
+ }
2843
+ }
2844
+ /**
2845
+ * TODAS las tuplas que casan con el filtro, paginando `Read` hasta agotar
2846
+ * el `continuation_token`, sin las caducadas. Es la única primitiva de
2847
+ * enumeración del driver (L0.7): `Read` no tiene tope de resultados —a
2848
+ * diferencia de `ListObjects`/`ListUsers`, que cortan al máximo del
2849
+ * servidor sin ninguna señal— y devuelve la condición de cada tupla, así
2850
+ * que la caducidad se filtra aquí con el mismo reloj que `checkContext`.
2851
+ *
2852
+ * Contrapartida, documentada en el README: `Read` devuelve tuplas
2853
+ * ESCRITAS, no relaciones computadas. Con el modelo que genera este paquete
2854
+ * (`assignee`/`denied` directas) es exactamente lo mismo; un modelo
2855
+ * extendido con relaciones derivadas sobre `role_binding` no se enumeraría
2856
+ * por aquí.
2857
+ */
2858
+ async readAllTuples(filter, options = {}) {
2859
+ // El instante con el que se filtra: el de la operación si lo trae (K9), o
1255
2860
  // el de esta lectura.
1256
2861
  const now = options.at ?? this.now();
1257
2862
  const keys = [];
@@ -1295,4 +2900,150 @@ export class OpenFgaAuthorizationDriver {
1295
2900
  return keys;
1296
2901
  }
1297
2902
  }
2903
+ /* ── `authz:reconcile` (3b-3a): piezas puras ────────────────────────────── */
2904
+ /** Tope de páginas de `scopes.enumerateEdges` (10 000 000 de nodos a 100). */
2905
+ const RECONCILE_MAX_PAGES = 100_000;
2906
+ /**
2907
+ * **Los CUATRO tipos de objeto que el reconcile de HECHOS gobierna**
2908
+ * (2.4.0-alpha.9 · B; 🔴 3 del quinto informe de COGNITIV, panel del store
2909
+ * compartido, decisión del dueño del 2026-09-11 (2)).
2910
+ *
2911
+ * Con `openfga` los dos subsistemas del paquete —hechos y `relations/`—
2912
+ * comparten store, y esta pasada tiene que leerlo ENTERO (`Read({})` es la
2913
+ * única forma de ver lo que sobra). Mientras `familyOfTuple` tuvo un
2914
+ * catch-all (`return 'facts'`), toda tupla de relaciones caía en `extra` y
2915
+ * `--prune` la BORRABA: 60 de 60 medidas por el consumidor —`group`
2916
+ * incluido, que es el tipo de usersets que el generador emite SIEMPRE— y la
2917
+ * pasada destructiva reportaba `clean = true` con exit 0, porque borraba
2918
+ * justo lo que causaba el `extra-fact`.
2919
+ *
2920
+ * Por eso la lista es EXPLÍCITA y congelada, y no un «lo demás es mío»:
2921
+ * - `scope`, `role` y `role_binding` son los tipos vivos del modelo (c2r);
2922
+ * - `deny_binding` es el deny del modo `resolver` de 2.2: el modelo de hoy
2923
+ * ni lo declara, pero `enumerateFacts` lo LEE y lo emite como deny
2924
+ * (3b-8 · A2), así que es PROPIO aunque ya no se pueda escribir — y su
2925
+ * barrido es la promesa publicada de que un store de la versión anterior
2926
+ * se limpia en una pasada (medido: escribirla es 400, borrarla es 200).
2927
+ *
2928
+ * **NO es `RESERVED_FACTS_TYPES`** (`src/identity.ts`), que incluye `group`:
2929
+ * son dos conjuntos con nombres parecidos y propósitos distintos —«nadie
2930
+ * puede declarar esto como tipo de relaciones» vs. «esto lo gobierna esta
2931
+ * pasada»— y reutilizar el primero reproduce el defecto para los usersets.
2932
+ *
2933
+ * **Regla**: un tipo NUEVO del modelo `facts` entra aquí en el MISMO commit
2934
+ * en que se declara (y en `RESERVED_FACTS_TYPES`, para que
2935
+ * `defineRelationsConfig` grite al arrancar en vez de que esta pasada borre
2936
+ * en silencio los datos de quien ya usaba ese nombre).
2937
+ */
2938
+ export const OWN_FACTS_OBJECT_TYPES = Object.freeze(new Set([FACTS_SCOPE_TYPE, FACTS_ROLE_TYPE, FACTS_BINDING_TYPE, LEGACY_DENY_BINDING_TYPE]));
2939
+ /**
2940
+ * El TIPO de objeto de una tupla (`scope:app` ⇒ `scope`). Con `indexOf`, no
2941
+ * `split`: el id lleva `|` y lo elige el consumidor en las tuplas de
2942
+ * relaciones, y esto decide un `delete`. Un objeto sin `:` no es de nadie.
2943
+ */
2944
+ function objectTypeOf(object) {
2945
+ const cut = object.indexOf(':');
2946
+ return cut < 0 ? '' : object.slice(0, cut);
2947
+ }
2948
+ /**
2949
+ * A qué FAMILIA pertenece una tupla del store, que es lo que decide si esta
2950
+ * pasada puede borrarla por su cuenta:
2951
+ * - `foreign` = **de otro subsistema** (store COMPARTIDO): su tipo de objeto
2952
+ * no está en `OWN_FACTS_OBJECT_TYPES`. Ni se borra, ni es `extra`, ni
2953
+ * entra en `skipped`; se cuenta aparte y se reporta. **Se decide SOLO con
2954
+ * la lista congelada**: nada de `relations.config`, del modelo publicado
2955
+ * ni de una tabla — una lectura que falle no puede volver a convertir una
2956
+ * tupla ajena en borrable (y el proceso que corre el reconcile es justo
2957
+ * el que puede no tener esa config);
2958
+ * - `root`, `catalog` y `tree` son ESPEJOS de datos locales (el `holderTypes`
2959
+ * del config, `authz_roles`/`authz_permissions`, `scopes.enumerateEdges`)
2960
+ * que nadie más escribe: lo que sobra se borra siempre;
2961
+ * - el resto son HECHOS —`assignee`, las dos aristas de (c2), los
2962
+ * `denied_<P>` y la basura de un modelo anterior del PROPIO paquete— y
2963
+ * solo los borra `--prune`.
2964
+ *
2965
+ * El tipo de objeto se mira el PRIMERO, antes que la relación: una tupla
2966
+ * ajena que se llamara `rooted` no puede caer en una familia derivada, que
2967
+ * son las que se borran sin `--prune`.
2968
+ */
2969
+ function familyOfTuple(tuple) {
2970
+ if (!OWN_FACTS_OBJECT_TYPES.has(objectTypeOf(tuple.object)))
2971
+ return 'foreign';
2972
+ if (tuple.relation === FACTS_ROOTED_RELATION)
2973
+ return 'root';
2974
+ if (tuple.object.startsWith(`${FACTS_ROLE_TYPE}:`) && tuple.relation.startsWith(FACTS_PERMITS_PREFIX))
2975
+ return 'catalog';
2976
+ if (tuple.object.startsWith(`${FACTS_SCOPE_TYPE}:`) && tuple.relation === FACTS_PARENT_RELATION)
2977
+ return 'tree';
2978
+ return 'facts';
2979
+ }
2980
+ /**
2981
+ * **El censo de lo AJENO** (alpha.9 · B · S3/S4), campo PROPIO del reporte:
2982
+ * fuera de `skipped` —ahí dentro `clean` era `false` para siempre en
2983
+ * cualquier despliegue con relaciones, que es el corolario del 🔴 3— y fuera
2984
+ * de `details` (tope 200: miles de ajenas expulsarían los `extra-fact`
2985
+ * reales, que son los que conceden). `phases` NO crece: es tipo publicado.
2986
+ *
2987
+ * `relationTypes` —los tipos declarados en `relations.config`, que pone el
2988
+ * MANAGER— solo parte el reporte en «ajena DECLARADA» y «no la reclama
2989
+ * nadie»: **la decisión de borrar ya está tomada** por la lista blanca
2990
+ * congelada y no mira esto. Sin esa lista todo va a `unknown`, que es la
2991
+ * verdad (este proceso no sabe de quién son) y nunca un permiso para borrar.
2992
+ * `group` cuenta como declarado: lo emite SIEMPRE el generador de relaciones,
2993
+ * el consumidor no lo declara.
2994
+ */
2995
+ function foreignReport(tuples, types, relationTypes) {
2996
+ const byType = {};
2997
+ for (const type of [...types.keys()].sort())
2998
+ byType[type] = types.get(type);
2999
+ if (relationTypes === undefined)
3000
+ return { tuples, types: byType, unknown: tuples };
3001
+ const declared = new Set([FACTS_GROUP_TYPE, ...relationTypes]);
3002
+ let relations = 0;
3003
+ for (const [type, count] of types)
3004
+ if (declared.has(type))
3005
+ relations += count;
3006
+ return { tuples, types: byType, relations, unknown: tuples - relations };
3007
+ }
3008
+ /** La clave sola: un `delete` de FGA no lleva condición. */
3009
+ function bareTuple(tuple) {
3010
+ return { user: tuple.user, relation: tuple.relation, object: tuple.object };
3011
+ }
3012
+ /** La tupla tal como se ESCRIBE: con la condición `not_expired` si caduca. */
3013
+ function reconcileWriteForm(target) {
3014
+ if (target.validUntil === null)
3015
+ return target.tuple;
3016
+ return {
3017
+ ...target.tuple,
3018
+ condition: { name: 'not_expired', context: { valid_until: target.validUntil.toISOString() } },
3019
+ };
3020
+ }
3021
+ /**
3022
+ * ¿Se llega a `target` subiendo desde `from`? Devuelve el camino recorrido
3023
+ * (`[from, ..., target]`) o `null`. Es el anti-ciclos del árbol del ORIGEN: la
3024
+ * arista que cierra el ciclo es la que se está clasificando, así que el mapa
3025
+ * por el que se sube NUNCA tiene uno (el cinturón de `seen` es por si acaso).
3026
+ */
3027
+ /** El techo de profundidad del verificador: el del config, o la cota medida del modelo (500 si no es un entero ≥ 1). */
3028
+ function reconcileMaxDepth(declared) {
3029
+ if (declared === undefined)
3030
+ return FACTS_MAX_RESOLVE_DEPTH;
3031
+ if (!Number.isInteger(declared) || declared < 1) {
3032
+ throw new AuthorizationConfigError(`authz:reconcile: scopes.maxDepth debe ser un entero >= 1 (llegó ${String(declared)})`);
3033
+ }
3034
+ return declared;
3035
+ }
3036
+ function walkUpTo(parentOf, from, target) {
3037
+ const path = [];
3038
+ const seen = new Set();
3039
+ let current = from;
3040
+ while (current !== undefined && !seen.has(current)) {
3041
+ path.push(current);
3042
+ if (current === target)
3043
+ return path;
3044
+ seen.add(current);
3045
+ current = parentOf.get(current);
3046
+ }
3047
+ return null;
3048
+ }
1298
3049
  //# sourceMappingURL=openfga_driver.js.map