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

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 (214) hide show
  1. package/README.md +693 -36
  2. package/build/commands/authz_catalog_diff.js +1 -1
  3. package/build/commands/authz_catalog_diff.js.map +1 -1
  4. package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
  5. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
  6. package/build/commands/authz_catalog_prune_orphans.js +136 -0
  7. package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
  8. package/build/commands/authz_catalog_sync.d.ts +17 -0
  9. package/build/commands/authz_catalog_sync.d.ts.map +1 -1
  10. package/build/commands/authz_catalog_sync.js +27 -4
  11. package/build/commands/authz_catalog_sync.js.map +1 -1
  12. package/build/commands/authz_freeze.d.ts +44 -0
  13. package/build/commands/authz_freeze.d.ts.map +1 -0
  14. package/build/commands/authz_freeze.js +95 -0
  15. package/build/commands/authz_freeze.js.map +1 -0
  16. package/build/commands/authz_reconcile.d.ts +102 -0
  17. package/build/commands/authz_reconcile.d.ts.map +1 -0
  18. package/build/commands/authz_reconcile.js +294 -0
  19. package/build/commands/authz_reconcile.js.map +1 -0
  20. package/build/commands/authz_relations_reconcile.d.ts +97 -0
  21. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  22. package/build/commands/authz_relations_reconcile.js +313 -0
  23. package/build/commands/authz_relations_reconcile.js.map +1 -0
  24. package/build/commands/authz_scopes_relay.d.ts +47 -0
  25. package/build/commands/authz_scopes_relay.d.ts.map +1 -0
  26. package/build/commands/authz_scopes_relay.js +141 -0
  27. package/build/commands/authz_scopes_relay.js.map +1 -0
  28. package/build/commands/authz_unfreeze.d.ts +37 -0
  29. package/build/commands/authz_unfreeze.d.ts.map +1 -0
  30. package/build/commands/authz_unfreeze.js +92 -0
  31. package/build/commands/authz_unfreeze.js.map +1 -0
  32. package/build/commands/main.d.ts +6 -1
  33. package/build/commands/main.d.ts.map +1 -1
  34. package/build/commands/main.js +6 -1
  35. package/build/commands/main.js.map +1 -1
  36. package/build/commands/openfga_provision.d.ts +46 -4
  37. package/build/commands/openfga_provision.d.ts.map +1 -1
  38. package/build/commands/openfga_provision.js +90 -7
  39. package/build/commands/openfga_provision.js.map +1 -1
  40. package/build/configure.d.ts +11 -0
  41. package/build/configure.d.ts.map +1 -1
  42. package/build/configure.js +37 -1
  43. package/build/configure.js.map +1 -1
  44. package/build/index.d.ts +50 -10
  45. package/build/index.d.ts.map +1 -1
  46. package/build/index.js +45 -6
  47. package/build/index.js.map +1 -1
  48. package/build/providers/authz_provider.d.ts +26 -2
  49. package/build/providers/authz_provider.d.ts.map +1 -1
  50. package/build/providers/authz_provider.js +55 -2
  51. package/build/providers/authz_provider.js.map +1 -1
  52. package/build/services/relations.d.ts +14 -0
  53. package/build/services/relations.d.ts.map +1 -0
  54. package/build/services/relations.js +17 -0
  55. package/build/services/relations.js.map +1 -0
  56. package/build/src/{catalog.d.ts → catalog/catalog.d.ts} +41 -2
  57. package/build/src/catalog/catalog.d.ts.map +1 -0
  58. package/build/src/{catalog.js → catalog/catalog.js} +120 -14
  59. package/build/src/catalog/catalog.js.map +1 -0
  60. package/build/src/{catalog_cache.d.ts → catalog/catalog_cache.d.ts} +46 -22
  61. package/build/src/catalog/catalog_cache.d.ts.map +1 -0
  62. package/build/src/{catalog_cache.js → catalog/catalog_cache.js} +53 -43
  63. package/build/src/catalog/catalog_cache.js.map +1 -0
  64. package/build/src/define_config.d.ts +137 -3
  65. package/build/src/define_config.d.ts.map +1 -1
  66. package/build/src/define_config.js.map +1 -1
  67. package/build/src/drivers/database_driver.d.ts +128 -7
  68. package/build/src/drivers/database_driver.d.ts.map +1 -1
  69. package/build/src/drivers/database_driver.js +510 -24
  70. package/build/src/drivers/database_driver.js.map +1 -1
  71. package/build/src/drivers/database_relations_driver.d.ts +113 -0
  72. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  73. package/build/src/drivers/database_relations_driver.js +679 -0
  74. package/build/src/drivers/database_relations_driver.js.map +1 -0
  75. package/build/src/drivers/openfga_driver.d.ts +729 -122
  76. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  77. package/build/src/drivers/openfga_driver.js +2083 -476
  78. package/build/src/drivers/openfga_driver.js.map +1 -1
  79. package/build/src/drivers/openfga_facts.d.ts +384 -0
  80. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  81. package/build/src/drivers/openfga_facts.js +836 -0
  82. package/build/src/drivers/openfga_facts.js.map +1 -0
  83. package/build/src/drivers/openfga_relations_driver.d.ts +141 -0
  84. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  85. package/build/src/drivers/openfga_relations_driver.js +590 -0
  86. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  87. package/build/src/errors.d.ts +290 -5
  88. package/build/src/errors.d.ts.map +1 -1
  89. package/build/src/errors.js +296 -7
  90. package/build/src/errors.js.map +1 -1
  91. package/build/src/freeze.d.ts +141 -0
  92. package/build/src/freeze.d.ts.map +1 -0
  93. package/build/src/freeze.js +217 -0
  94. package/build/src/freeze.js.map +1 -0
  95. package/build/src/http/app_access_middleware.d.ts.map +1 -0
  96. package/build/src/http/app_access_middleware.js.map +1 -0
  97. package/build/src/http/resource_access_middleware.d.ts +105 -0
  98. package/build/src/http/resource_access_middleware.d.ts.map +1 -0
  99. package/build/src/http/resource_access_middleware.js +81 -0
  100. package/build/src/http/resource_access_middleware.js.map +1 -0
  101. package/build/src/identity.d.ts +74 -1
  102. package/build/src/identity.d.ts.map +1 -1
  103. package/build/src/identity.js +100 -2
  104. package/build/src/identity.js.map +1 -1
  105. package/build/src/manager.d.ts +330 -4
  106. package/build/src/manager.d.ts.map +1 -1
  107. package/build/src/manager.js +1285 -188
  108. package/build/src/manager.js.map +1 -1
  109. package/build/src/models/authz_assignment.d.ts +7 -7
  110. package/build/src/models/authz_assignment.d.ts.map +1 -1
  111. package/build/src/models/authz_deny.d.ts +7 -7
  112. package/build/src/models/authz_deny.d.ts.map +1 -1
  113. package/build/src/models/authz_permission.d.ts +7 -7
  114. package/build/src/models/authz_permission.d.ts.map +1 -1
  115. package/build/src/models/authz_role.d.ts +7 -7
  116. package/build/src/models/authz_role.d.ts.map +1 -1
  117. package/build/src/models/authz_role_permission.d.ts +7 -7
  118. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  119. package/build/src/openfga.d.ts +18 -2
  120. package/build/src/openfga.d.ts.map +1 -1
  121. package/build/src/openfga.js +15 -1
  122. package/build/src/openfga.js.map +1 -1
  123. package/build/src/reconcile.d.ts +37 -0
  124. package/build/src/reconcile.d.ts.map +1 -0
  125. package/build/src/reconcile.js +69 -0
  126. package/build/src/reconcile.js.map +1 -0
  127. package/build/src/relation_partition_trigger.d.ts +8 -0
  128. package/build/src/relation_partition_trigger.d.ts.map +1 -0
  129. package/build/src/relation_partition_trigger.js +93 -0
  130. package/build/src/relation_partition_trigger.js.map +1 -0
  131. package/build/src/relations/define_relations_config.d.ts +75 -0
  132. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  133. package/build/src/relations/define_relations_config.js +173 -0
  134. package/build/src/relations/define_relations_config.js.map +1 -0
  135. package/build/src/relations/manager.d.ts +60 -0
  136. package/build/src/relations/manager.d.ts.map +1 -0
  137. package/build/src/relations/manager.js +213 -0
  138. package/build/src/relations/manager.js.map +1 -0
  139. package/build/src/relations/reconcile.d.ts +111 -0
  140. package/build/src/relations/reconcile.d.ts.map +1 -0
  141. package/build/src/relations/reconcile.js +200 -0
  142. package/build/src/relations/reconcile.js.map +1 -0
  143. package/build/src/relations_config_store.d.ts +22 -0
  144. package/build/src/relations_config_store.d.ts.map +1 -0
  145. package/build/src/relations_config_store.js +74 -0
  146. package/build/src/relations_config_store.js.map +1 -0
  147. package/build/src/scope_outbox.d.ts +69 -0
  148. package/build/src/scope_outbox.d.ts.map +1 -0
  149. package/build/src/scope_outbox.js +298 -0
  150. package/build/src/scope_outbox.js.map +1 -0
  151. package/build/src/{drivers → shared}/backend_guard.d.ts +42 -0
  152. package/build/src/shared/backend_guard.d.ts.map +1 -0
  153. package/build/src/{drivers → shared}/backend_guard.js +78 -1
  154. package/build/src/shared/backend_guard.js.map +1 -0
  155. package/build/src/shared/sql_expiry.d.ts.map +1 -0
  156. package/build/src/shared/sql_expiry.js.map +1 -0
  157. package/build/src/shared/transaction_guard.d.ts +50 -0
  158. package/build/src/shared/transaction_guard.d.ts.map +1 -0
  159. package/build/src/shared/transaction_guard.js +60 -0
  160. package/build/src/shared/transaction_guard.js.map +1 -0
  161. package/build/src/sql_descendants.d.ts +47 -1
  162. package/build/src/sql_descendants.d.ts.map +1 -1
  163. package/build/src/sql_descendants.js +75 -1
  164. package/build/src/sql_descendants.js.map +1 -1
  165. package/build/src/testing/contract.d.ts +129 -4
  166. package/build/src/testing/contract.d.ts.map +1 -1
  167. package/build/src/testing/contract.js +912 -177
  168. package/build/src/testing/contract.js.map +1 -1
  169. package/build/src/testing/main.d.ts +8 -2
  170. package/build/src/testing/main.d.ts.map +1 -1
  171. package/build/src/testing/main.js +4 -1
  172. package/build/src/testing/main.js.map +1 -1
  173. package/build/src/testing/migration_contract.d.ts +284 -0
  174. package/build/src/testing/migration_contract.d.ts.map +1 -0
  175. package/build/src/testing/migration_contract.js +586 -0
  176. package/build/src/testing/migration_contract.js.map +1 -0
  177. package/build/src/testing/relations_contract.d.ts +73 -0
  178. package/build/src/testing/relations_contract.d.ts.map +1 -0
  179. package/build/src/testing/relations_contract.js +1069 -0
  180. package/build/src/testing/relations_contract.js.map +1 -0
  181. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  182. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  183. package/build/src/testing/relations_reconcile_contract.js +220 -0
  184. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  185. package/build/src/traits/authz_scopes.js +1 -1
  186. package/build/src/traits/authz_scopes.js.map +1 -1
  187. package/build/src/traits/has_uuid.d.ts +8 -8
  188. package/build/src/traits/has_uuid.d.ts.map +1 -1
  189. package/build/src/types.d.ts +1053 -83
  190. package/build/src/types.d.ts.map +1 -1
  191. package/build/src/types.js +10 -0
  192. package/build/src/types.js.map +1 -1
  193. package/build/stubs/config/authorization.stub +104 -4
  194. package/build/stubs/migration.stub +136 -0
  195. package/build/stubs/scopes_outbox_migration.stub +57 -0
  196. package/package.json +4 -2
  197. package/build/commands/openfga_import.d.ts +0 -34
  198. package/build/commands/openfga_import.d.ts.map +0 -1
  199. package/build/commands/openfga_import.js +0 -97
  200. package/build/commands/openfga_import.js.map +0 -1
  201. package/build/src/catalog.d.ts.map +0 -1
  202. package/build/src/catalog.js.map +0 -1
  203. package/build/src/catalog_cache.d.ts.map +0 -1
  204. package/build/src/catalog_cache.js.map +0 -1
  205. package/build/src/drivers/backend_guard.d.ts.map +0 -1
  206. package/build/src/drivers/backend_guard.js.map +0 -1
  207. package/build/src/drivers/sql_expiry.d.ts.map +0 -1
  208. package/build/src/drivers/sql_expiry.js.map +0 -1
  209. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  210. package/build/src/middleware/app_access_middleware.js.map +0 -1
  211. /package/build/src/{middleware → http}/app_access_middleware.d.ts +0 -0
  212. /package/build/src/{middleware → http}/app_access_middleware.js +0 -0
  213. /package/build/src/{drivers → shared}/sql_expiry.d.ts +0 -0
  214. /package/build/src/{drivers → shared}/sql_expiry.js +0 -0
@@ -3,12 +3,14 @@ import { v7 as uuidv7 } from 'uuid';
3
3
  import { APP_SCOPE_TYPE } from '../types.js';
4
4
  import { assertCatalogUuid, assertIdentity, assertScope, chainKeysFrom, normalizeRoleQuery } from '../identity.js';
5
5
  import { resolveGrantExpiry, sameInstant } from '../expiry.js';
6
- import { AuthorizationBackendError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, RoleNotVisibleError, UnknownPermissionError, UnknownRoleError, } from '../errors.js';
7
- import { assertKnownScope, canonicalScope, guardSql, resolveChain, rootOnlyResolver } from './backend_guard.js';
8
- import { assertAssignableAt } from '../catalog.js';
9
- import { CatalogCache, GLOBAL_OWNER_KEY, assertCatalogOptions, isRoleVisibleWith, withAuthzCatalogWrite } from '../catalog_cache.js';
6
+ import { AuthorizationBackendError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, MassReconcileRefusedError, ReconcileTooLargeError, RoleNotVisibleError, UnknownPermissionError, UnknownRoleError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
7
+ import { RECONCILE_MAX_DETAILS, emptyReconcilePhases, reconcileBatchSize, reconcileMaxTuples, sumReconcilePhases, } from '../reconcile.js';
8
+ import { assertKnownScope, canonicalScope, canonicalScopeTargets, guardSql, isUniqueViolation, resolveChain, rootOnlyResolver, } from '../shared/backend_guard.js';
9
+ import { assertCallerTransaction } from '../shared/transaction_guard.js';
10
+ import { assertAssignableAt } from '../catalog/catalog.js';
11
+ import { CatalogCache, GLOBAL_OWNER_KEY, assertCatalogOptions, isRoleVisibleWith, withAuthzCatalogWrite } from '../catalog/catalog_cache.js';
10
12
  import { isClock, systemClock } from '../clock.js';
11
- import { sqlExpiryCodec } from './sql_expiry.js';
13
+ import { sqlExpiryCodec } from '../shared/sql_expiry.js';
12
14
  /**
13
15
  * UUID centinela para el scope 'app' en las columnas scope_uuid (NOT NULL):
14
16
  * permite que el unique de assignments/denies cubra también el nivel app
@@ -23,7 +25,15 @@ function toDbScopeUuid(scope) {
23
25
  assertScope(scope);
24
26
  return scope.uuid ?? APP_SCOPE_DB_UUID;
25
27
  }
26
- function fromDbScopeUuid(uuid) {
28
+ /**
29
+ * La vuelta de `toDbScopeUuid`: el centinela de la raíz vuelve a ser `null`.
30
+ * **Exportada desde 3b-3b**: `authz:reconcile --to=openfga` lee estas mismas
31
+ * columnas y hacía `row.scope_uuid ?? null`, que con la columna NOT NULL
32
+ * nunca es null — un grant o un deny en la RAÍZ reventaban la migración con
33
+ * un 422 a mitad de pasada (`el scope 'app' no admite uuid`). Lo cazó el
34
+ * contrato de migración, cuya siembra fija sí concede en `app`.
35
+ */
36
+ export function fromDbScopeUuid(uuid) {
27
37
  return uuid === APP_SCOPE_DB_UUID ? null : uuid;
28
38
  }
29
39
  /**
@@ -38,6 +48,35 @@ function dbScopeKey(scope) {
38
48
  function rowScopeKey(row) {
39
49
  return `${row.scope_type}\u001f${row.scope_uuid}`;
40
50
  }
51
+ /**
52
+ * Clave de comparación de una asignación. **Es exactamente el unique
53
+ * `authz_asg_holder_role_scope_uq`** y NO incluye `expires_at`: cambiar la
54
+ * caducidad es un UPDATE de la misma fila, no una fila nueva (invariante 6).
55
+ * Que la clave del diff sea la del índice es lo que hace que la migración no
56
+ * pueda dejar dos filas del mismo hecho ni chocar con el unique al insertar.
57
+ */
58
+ function assignmentKey(row) {
59
+ return [row.holder_type, row.holder_uuid, row.role_uuid, row.scope_type, row.scope_uuid].join('\u001f');
60
+ }
61
+ function denyKey(row) {
62
+ return [row.holder_type, row.holder_uuid, row.permission_uuid, row.scope_type, row.scope_uuid].join('\u001f');
63
+ }
64
+ /**
65
+ * La caducidad que dura MÁS (`null` = para siempre). Es la regla con la que
66
+ * se resuelve un colapso de dos hechos del origen en una sola fila: el
67
+ * origen concedía mientras CUALQUIERA de los dos siguiera vivo, así que la
68
+ * unión es lo que conserva la respuesta.
69
+ */
70
+ function longerExpiry(a, b) {
71
+ if (a === null || b === null)
72
+ return null;
73
+ return a >= b ? a : b;
74
+ }
75
+ /** Cómo se NOMBRA una fila del destino en `details`: sin esto un motivo no se arregla. */
76
+ function reconcileRowLabel(row) {
77
+ const what = row.role_uuid ? `→ ${row.role_uuid}` : `⊘ ${row.permission_uuid}`;
78
+ return `${row.holder_type}:${row.holder_uuid} ${what} @ ${row.scope_type}:${row.scope_uuid}`;
79
+ }
41
80
  /**
42
81
  * El rol `(slug, scopeType)` que existe en el scope cuya cadena empieza por
43
82
  * `chainKeys`, o 422: `E_AUTHZ_ROLE_NOT_VISIBLE` si hay roles con ese nombre
@@ -220,6 +259,21 @@ export const DEFAULT_TIMEOUT_MS = 5_000;
220
259
  * crudo de knex (L0.11).
221
260
  */
222
261
  export class DatabaseAuthorizationDriver {
262
+ /**
263
+ * Lo que este driver declara (3b-2e · E2). El árbol es del consumidor
264
+ * (`resolveChain`), así que no hay hechos de jerarquía ni deriva que
265
+ * mitigar; `authorize` son varias consultas SQL; la membresía la resuelve
266
+ * el propio SQL contra la cadena que le pasan; los `list*` no enumeran
267
+ * herencia (invariante 7); y `purgeRole` está implementado en una
268
+ * transacción.
269
+ */
270
+ capabilities;
271
+ /**
272
+ * Con `transactionalWrites: false` declarado por el despliegue, un
273
+ * `{ transaction }` que llegara igual (un llamante que se salta el manager)
274
+ * se rechaza aquí también: no se ignora en silencio.
275
+ */
276
+ transactionalWrites;
223
277
  /**
224
278
  * Resolutor de jerarquía inyectado por el consumidor (el chasis pasa el
225
279
  * suyo, que conoce organizations/units, en `config/authorization.ts`).
@@ -250,6 +304,30 @@ export class DatabaseAuthorizationDriver {
250
304
  throw new AuthorizationConfigError(`DatabaseAuthorizationDriver: 'now' debe ser una función () => Date (llegó ${typeof options.now})`);
251
305
  }
252
306
  this.now = options.now ?? systemClock;
307
+ this.transactionalWrites = options.transactionalWrites ?? true;
308
+ this.capabilities = Object.freeze({
309
+ hierarchyFacts: false,
310
+ singleCheckAuthorize: false,
311
+ roleInheritanceNative: false,
312
+ listObjectsInherited: false,
313
+ purgeRole: true,
314
+ countRoleAssignments: true,
315
+ // 3b-2k · K1: `authorize` resuelve la cadena y usa `chain[0]`, así que un
316
+ // alias del uuid que el árbol funde con la fila real encuentra sus hechos.
317
+ canonicalScopeReads: true,
318
+ // 3b-3b: sus hechos son `authz_assignments`/`authz_denies` —el esquema
319
+ // PUBLICADO del paquete—, así que el destino los lee de ahí y no por el
320
+ // puerto. No es «no sabe»: es que no hace falta un método para leer una
321
+ // tabla documentada. Un driver de terceros que quiera ser origen sí lo
322
+ // necesita, y por eso la capacidad tiene sus dos caras.
323
+ enumerateFacts: false,
324
+ // L-3: `grant`/`revoke`/`deny`/`removeDeny` escriben por la transacción
325
+ // ABIERTA del llamante (`writer()`: `assertCallerTransaction` contra la
326
+ // conexión primaria de Lucid). Lo que NO viaja por ella: el catálogo,
327
+ // `resolveChain` y la barrera del freeze (autoridad). Un despliegue con
328
+ // pool 1 lo declara `false` (ver `DatabaseDriverOptions`).
329
+ transactionalWrites: this.transactionalWrites,
330
+ });
253
331
  assertCatalogOptions('DatabaseAuthorizationDriver', options);
254
332
  this.catalog =
255
333
  options.catalog ??
@@ -296,10 +374,14 @@ export class DatabaseAuthorizationDriver {
296
374
  knownScope(scope, operation) {
297
375
  return assertKnownScope(this.chainResolver, scope, operation);
298
376
  }
299
- /** El scope canónico para `revoke`/`removeDeny`/`purgeScope` (ver `canonicalScope`). */
377
+ /** El scope canónico para `purgeScope` (ver `canonicalScope`). */
300
378
  canonicalOrSelf(scope, operation) {
301
379
  return canonicalScope(this.chainResolver, scope, operation);
302
380
  }
381
+ /** Los destinos de un delete de hechos (`revoke`/`removeDeny`): canónico, o el fan-out de alias (3b-8 · A4). */
382
+ canonicalTargets(scope, operation) {
383
+ return canonicalScopeTargets(this.chainResolver, scope, operation);
384
+ }
303
385
  /**
304
386
  * Ejecuta una consulta clasificando su fallo (503 con código propio). `fn`
305
387
  * devuelve el BUILDER sin ejecutar: el guard le fija el deadline y luego lo
@@ -313,6 +395,49 @@ export class DatabaseAuthorizationDriver {
313
395
  const rows = await this.sql(operation, () => fn().limit(1));
314
396
  return rows[0] ?? null;
315
397
  }
398
+ /**
399
+ * Por dónde escribe `operation` (L-3): la transacción ABIERTA del llamante
400
+ * si llegó en `options.transaction` —validada contra la conexión de este
401
+ * driver, la primaria de Lucid, por la que escribe todo lo demás: otra
402
+ * conexión, un `QueryClient` o el `db` entero son 500 `E_AUTHZ_CONFIG`
403
+ * ANTES de tocar nada—, o `db`. `external` dice cuál de los dos.
404
+ *
405
+ * Solo la ESCRITURA va por ella. La lectura de «¿ya existe?» de
406
+ * `grant`/`deny` también, porque forma parte de la misma escritura (tiene
407
+ * que ver lo que TU transacción ya escribió: un `grant` y su re-grant en la
408
+ * misma transacción); el catálogo y `resolveChain` no (autoridad, L-1).
409
+ */
410
+ writer(operation, options) {
411
+ if (options?.transaction === undefined || options.transaction === null)
412
+ return { client: db, external: false };
413
+ if (!this.transactionalWrites) {
414
+ throw new UnsupportedOperationError('transactionalWrites', operation, 'database', 'Este despliegue declara transactionalWrites: false en las opciones del driver (pool 1): { transaction } ' +
415
+ 'no se admite. Quita la opción (exige pool ≥ 2) o no pases transaction.');
416
+ }
417
+ const trx = assertCallerTransaction(`database.${operation}`, options.transaction, { connection: db.primaryConnectionName });
418
+ return { client: trx, external: true };
419
+ }
420
+ /**
421
+ * Un INSERT que falla DENTRO de la transacción del llamante no se absorbe
422
+ * (L-3). Fuera de ella `grant`/`deny` releen y se quedan con lo del ganador
423
+ * (K4); dentro no se puede: en PostgreSQL la transacción ya está ABORTADA
424
+ * (toda sentencia posterior es `25P02` hasta el rollback) y en REPEATABLE
425
+ * READ (MySQL por defecto) la relectura no vería al ganador. Así que un
426
+ * choque del UNIQUE sale como **409 `E_AUTHZ_WRITE_CONFLICT`** —«envenena
427
+ * tu transacción: haz rollback y reintenta»—, un deadline vencido sigue
428
+ * siendo el 503 `E_AUTHZ_BACKEND_TIMEOUT` que ya clasificó `sql()` (con
429
+ * `onWrite` `indeterminate: true` en el manager) y cualquier otro fallo, su
430
+ * 503. Nunca una segunda sentencia sobre una transacción que puede estar
431
+ * abortada.
432
+ */
433
+ poisoned(operation, error) {
434
+ if (isUniqueViolation(error)) {
435
+ throw new WriteConflictError(`database.${operation}: el hecho lo escribió otra transacción mientras la tuya estaba abierta (choque del ` +
436
+ 'UNIQUE dentro de tu transacción). No se puede absorber ahí dentro: la transacción queda envenenada ' +
437
+ '(en PostgreSQL, abortada). Haz rollback y reintenta.', { cause: error });
438
+ }
439
+ throw error;
440
+ }
316
441
  get expiry() {
317
442
  // Decidir el dialecto no cuesta ninguna consulta: una vista por
318
443
  // prototipo (`withClock`/`withChainResolver`) hereda el codec del driver
@@ -402,7 +527,11 @@ export class DatabaseAuthorizationDriver {
402
527
  const declared = resolveRoleQuery(catalog, role, target, chainKeysFrom(chain)[0]);
403
528
  assertRoleAssignableAt(catalog, declared);
404
529
  const roleUuid = declared.uuid;
405
- const findExisting = () => whereScopeIn(db
530
+ // L-3: la ESCRITURA (y la lectura «¿ya existe?» que forma parte de ella)
531
+ // va por la transacción del llamante si llegó; todo lo de arriba
532
+ // (árbol, catálogo) fue por la conexión del motor.
533
+ const { client, external } = this.writer('grant', options);
534
+ const findExisting = () => whereScopeIn(client
406
535
  .from('authz_assignments')
407
536
  .where('holder_type', subject.type)
408
537
  .where('holder_uuid', subject.uuid)
@@ -416,7 +545,7 @@ export class DatabaseAuthorizationDriver {
416
545
  for (let attempt = 0; attempt < 3; attempt++) {
417
546
  const existing = await this.first('grant.find', findExisting);
418
547
  if (existing) {
419
- const refreshed = await this.refreshAssignment(existing, options.expiresAt);
548
+ const refreshed = await this.refreshAssignment(existing, options.expiresAt, client);
420
549
  if (refreshed)
421
550
  return refreshed;
422
551
  continue;
@@ -424,7 +553,7 @@ export class DatabaseAuthorizationDriver {
424
553
  // No había nada: la caducidad es la pedida, o ninguna.
425
554
  const expiresAt = options.expiresAt ?? null;
426
555
  try {
427
- await this.sql('grant.insert', () => db.table('authz_assignments').insert({
556
+ await this.sql('grant.insert', () => client.table('authz_assignments').insert({
428
557
  uuid: uuidv7(),
429
558
  holder_type: subject.type,
430
559
  holder_uuid: subject.uuid,
@@ -438,6 +567,10 @@ export class DatabaseAuthorizationDriver {
438
567
  return { existed: false, expiresAt };
439
568
  }
440
569
  catch (error) {
570
+ // Dentro de la transacción del llamante no hay relectura que valga
571
+ // (L-3, `poisoned`): 409 si fue el UNIQUE, si no el error tal cual.
572
+ if (external)
573
+ this.poisoned('grant', error);
441
574
  // Carrera check-then-insert: el unique (que cubre también el nivel app
442
575
  // vía centinela) la detecta — el perdedor degrada a re-grant sobre lo
443
576
  // que escribió el ganador (con la misma semántica de tres estados).
@@ -445,7 +578,7 @@ export class DatabaseAuthorizationDriver {
445
578
  const raced = await this.first('grant.race', findExisting);
446
579
  if (!raced)
447
580
  throw error;
448
- const refreshed = await this.refreshAssignment(raced, options.expiresAt);
581
+ const refreshed = await this.refreshAssignment(raced, options.expiresAt, client);
449
582
  if (refreshed)
450
583
  return refreshed;
451
584
  }
@@ -459,17 +592,17 @@ export class DatabaseAuthorizationDriver {
459
592
  * UPDATE no toca ninguna fila (otro proceso la borró entre la lectura y la
460
593
  * escritura, K4) devuelve `null`: no hay «existed: true» sobre nada.
461
594
  */
462
- async refreshAssignment(row, requested) {
595
+ async refreshAssignment(row, requested, client) {
463
596
  const previous = this.expiry.fromDb(row.expires_at);
464
597
  const expiresAt = resolveGrantExpiry(previous, requested, this.now());
465
598
  if (!sameInstant(previous, expiresAt)) {
466
- const updated = await this.sql('grant.update', () => db.from('authz_assignments').where('uuid', row.uuid).update({ expires_at: this.expiry.toDb(expiresAt) }));
599
+ const updated = await this.sql('grant.update', () => client.from('authz_assignments').where('uuid', row.uuid).update({ expires_at: this.expiry.toDb(expiresAt) }));
467
600
  if (Number(Array.isArray(updated) ? updated[0] : updated) === 0)
468
601
  return null;
469
602
  }
470
603
  return { existed: true, previousExpiresAt: previous, expiresAt };
471
604
  }
472
- async revoke(subject, role, scope) {
605
+ async revoke(subject, role, scope, options) {
473
606
  assertIdentity({ subject, role, scope });
474
607
  // Rol fuera del catálogo para ese nivel ⇒ 422, como en `grant` (D10). El
475
608
  // no-op es para una asignación inexistente de un rol válido. Se quitan
@@ -478,12 +611,17 @@ export class DatabaseAuthorizationDriver {
478
611
  // scope puede no existir ya para el árbol (D8), así que no se filtra por
479
612
  // visibilidad — quitar nunca concede.
480
613
  const named = rolesToRevoke(await this.catalog.view(), role, scope);
481
- const target = await this.canonicalOrSelf(scope, 'revoke');
482
- await this.sql('revoke', () => whereScopeIn(db
614
+ // Sin cadena con la que canonizar, TODAS las ortografías de las que el
615
+ // uuid puede ser alias (3b-8 · A4, mismo fan-out que `scopes.detached`):
616
+ // con una sola, un alias hacía del DELETE un no-op silencioso y la fila
617
+ // canónica seguía concediendo si el scope se restauraba.
618
+ const targets = await this.canonicalTargets(scope, 'revoke');
619
+ const { client } = this.writer('revoke', options);
620
+ await this.sql('revoke', () => whereScopeIn(client
483
621
  .from('authz_assignments')
484
622
  .where('holder_type', subject.type)
485
623
  .where('holder_uuid', subject.uuid)
486
- .whereIn('role_uuid', named.map((r) => r.uuid)), 'scope', [target], 'write').delete());
624
+ .whereIn('role_uuid', named.map((r) => r.uuid)), 'scope', targets, 'write').delete());
487
625
  }
488
626
  async hasRole(subject, role, scope) {
489
627
  assertIdentity({ subject, role, scope });
@@ -514,13 +652,14 @@ export class DatabaseAuthorizationDriver {
514
652
  const found = await this.first('hasRole', () => this.whereActive(query, 'a.expires_at'));
515
653
  return Boolean(found);
516
654
  }
517
- async deny(subject, permission, scope) {
655
+ async deny(subject, permission, scope, options) {
518
656
  assertIdentity({ subject, permission, scope });
519
657
  const perm = await this.findPermission(permission);
520
658
  if (!perm)
521
659
  throw new UnknownPermissionError(permission);
522
660
  const [target] = await this.knownScope(scope, 'deny');
523
- const findExisting = () => whereScopeIn(db
661
+ const { client, external } = this.writer('deny', options);
662
+ const findExisting = () => whereScopeIn(client
524
663
  .from('authz_denies')
525
664
  .where('holder_type', subject.type)
526
665
  .where('holder_uuid', subject.uuid)
@@ -529,7 +668,7 @@ export class DatabaseAuthorizationDriver {
529
668
  if (existing)
530
669
  return;
531
670
  try {
532
- await this.sql('deny.insert', () => db.table('authz_denies').insert({
671
+ await this.sql('deny.insert', () => client.table('authz_denies').insert({
533
672
  uuid: uuidv7(),
534
673
  holder_type: subject.type,
535
674
  holder_uuid: subject.uuid,
@@ -540,6 +679,9 @@ export class DatabaseAuthorizationDriver {
540
679
  }));
541
680
  }
542
681
  catch (error) {
682
+ // Dentro de la transacción del llamante no se relee (L-3, `poisoned`).
683
+ if (external)
684
+ this.poisoned('deny', error);
543
685
  // Carrera: si otro proceso insertó el mismo deny, el unique lo detecta
544
686
  // y el resultado deseado ya existe.
545
687
  const raced = await this.first('deny.race', findExisting);
@@ -547,17 +689,20 @@ export class DatabaseAuthorizationDriver {
547
689
  throw error;
548
690
  }
549
691
  }
550
- async removeDeny(subject, permission, scope) {
692
+ async removeDeny(subject, permission, scope, options) {
551
693
  assertIdentity({ subject, permission, scope });
552
694
  const perm = await this.findPermission(permission);
553
695
  if (!perm)
554
696
  throw new UnknownPermissionError(permission);
555
- const target = await this.canonicalOrSelf(scope, 'removeDeny');
556
- await this.sql('removeDeny', () => whereScopeIn(db
697
+ // Mismo fan-out que `revoke` (3b-8 · A4): quitar un deny de menos por un
698
+ // alias sería un deny fantasma que nadie puede levantar.
699
+ const targets = await this.canonicalTargets(scope, 'removeDeny');
700
+ const { client } = this.writer('removeDeny', options);
701
+ await this.sql('removeDeny', () => whereScopeIn(client
557
702
  .from('authz_denies')
558
703
  .where('holder_type', subject.type)
559
704
  .where('holder_uuid', subject.uuid)
560
- .where('permission_uuid', perm.uuid), 'scope', [target], 'write').delete());
705
+ .where('permission_uuid', perm.uuid), 'scope', targets, 'write').delete());
561
706
  }
562
707
  async listSubjects(role, scope) {
563
708
  assertIdentity({ role, scope });
@@ -821,5 +966,346 @@ export class DatabaseAuthorizationDriver {
821
966
  this.catalog.invalidate();
822
967
  }
823
968
  }
969
+ /**
970
+ * Cuántos hechos VIGENTES tiene cada rol, en todos los scopes (3b-2j). Es
971
+ * lo que `pruneOrphanRoles` necesita para decir si un huérfano todavía
972
+ * concede, y es una pregunta del PUERTO porque los hechos son del driver:
973
+ * aquí son filas de `authz_assignments`, en `openfga` son tuplas del store.
974
+ *
975
+ * Una consulta agrupada para todos los uuids (no una por rol) y la misma
976
+ * caducidad ESTRICTA que el resto del driver, con SU reloj (`whereActive`,
977
+ * J1): la asignación que vence ahora ya no cuenta, como no cuenta en
978
+ * `authorize`. Un rol sin hechos —o que no está en la tabla— es `0`.
979
+ */
980
+ async countRoleAssignments(roleUuids) {
981
+ for (const uuid of roleUuids)
982
+ assertCatalogUuid('rol', uuid);
983
+ if (roleUuids.length === 0)
984
+ return [];
985
+ const rows = await this.sql('countRoleAssignments', () => this.whereActive(db.from('authz_assignments').whereIn('role_uuid', roleUuids))
986
+ .groupBy('role_uuid')
987
+ .select('role_uuid')
988
+ .count('* as total'));
989
+ const totals = new Map();
990
+ for (const row of rows)
991
+ totals.set(String(row.role_uuid), Number(row.total ?? row.count ?? 0));
992
+ return roleUuids.map((uuid) => totals.get(uuid) ?? 0);
993
+ }
994
+ /* ── `authz:reconcile --to=database` (3b-3b) ─────────────────────────── */
995
+ /**
996
+ * **Rehace `authz_assignments`/`authz_denies` desde los hechos de OTRO
997
+ * driver** — la vuelta de la migración, y con ella la simetría que el dueño
998
+ * compró: «todo en un driver o todo en otro».
999
+ *
1000
+ * Qué se migra y qué NO, y por qué:
1001
+ * - **los HECHOS, sí**: es lo único que vive en el otro driver. Llegan por
1002
+ * `source.facts` —el puerto `enumerateFacts`, paginado— y no por SQL:
1003
+ * aquí el origen es un store, no una tabla de este paquete.
1004
+ * - **el ÁRBOL, no**. En esta dirección el árbol NO se migra: el driver
1005
+ * `database` lo lee de las tablas del CONSUMIDOR en cada pregunta
1006
+ * (`resolveChain`), que son su fuente de verdad. Copiarlo a algún sitio
1007
+ * sería inventarse una segunda copia y con ella una deriva que hoy no
1008
+ * existe. Se usa, eso sí, para decidir qué hecho es migrable
1009
+ * (`unknown-scope`) y bajo qué identidad CANÓNICA se escribe
1010
+ * (invariante 17).
1011
+ * - **el CATÁLOGO, tampoco**: es propiedad local SIEMPRE (regla de higiene
1012
+ * del paquete), ya está en `authz_*` y ningún driver es su fuente. Aquí
1013
+ * solo se usa para traducir: un `roleUuid` que el catálogo no declara y
1014
+ * un permiso que no existe no son hechos migrables.
1015
+ *
1016
+ * Por eso `phases.root`, `phases.catalog` y `phases.tree` son CERO en esta
1017
+ * dirección, y todo el movimiento está en `phases.facts`. Es una respuesta,
1018
+ * no un hueco: los ceros dicen «aquí no hay nada derivado que rehacer».
1019
+ *
1020
+ * El resto del contrato es el mismo que en la ida (3b-3a): idempotente (la
1021
+ * segunda pasada escribe cero), `dryRun` es el VERIFICADOR y es read-only
1022
+ * por contrato —**un `--fix` está prohibido** (cruce 4 · S18) y no se
1023
+ * implementa ni se deja preparado—, nunca silenciosa (`skipped{motivo}` +
1024
+ * `details`), los hechos que sobran solo se borran con `prune`, y el
1025
+ * seguro del origen ciego (`E_AUTHZ_MASS_RECONCILE_REFUSED`) se aplica
1026
+ * igual: un origen que devuelve CERO hechos y un `--prune` detrás vacían
1027
+ * la base, y eso es casi siempre apuntar al store equivocado.
1028
+ */
1029
+ async reconcile(source, options = {}) {
1030
+ const dryRun = options.dryRun === true;
1031
+ const prune = options.prune === true;
1032
+ const batchSize = reconcileBatchSize(options.batchSize);
1033
+ const maxTuples = reconcileMaxTuples(options.maxTuples);
1034
+ const catalog = await this.catalog.view();
1035
+ const now = this.now();
1036
+ const skipped = {};
1037
+ const details = [];
1038
+ const note = (kind, reason, detail) => {
1039
+ skipped[reason] = (skipped[reason] ?? 0) + 1;
1040
+ if (details.length < RECONCILE_MAX_DETAILS)
1041
+ details.push({ kind, reason, detail });
1042
+ };
1043
+ /** `[scope canónico, ...]` memoizado: el árbol del consumidor manda. */
1044
+ const chains = new Map();
1045
+ const canonical = async (scope) => {
1046
+ const key = dbScopeKey(scope);
1047
+ if (!chains.has(key)) {
1048
+ chains.set(key, await resolveChain(source.resolveChain, scope, 'reconcile'));
1049
+ }
1050
+ const chain = chains.get(key);
1051
+ return chain === null ? null : chain[0];
1052
+ };
1053
+ /* 1. Los HECHOS del origen, paginados por el puerto. */
1054
+ const wantedAssignments = new Map();
1055
+ const wantedDenies = new Map();
1056
+ let sourceFacts = 0;
1057
+ /**
1058
+ * Hechos del origen que RESPALDAN algo en el destino (3b-8 · B1). El
1059
+ * seguro de borrado masivo miraba `sourceFacts`, el conteo CRUDO — se
1060
+ * incrementa ANTES de cada skip—, así que un origen cuyos hechos se
1061
+ * descartan TODOS (caducados, scopes que ya no resuelven: la firma de un
1062
+ * store equivocado o de un resolutor ciego) lo esquivaba y `--prune`
1063
+ * vaciaba `authz_assignments`/`authz_denies` sin una sola pregunta. Es el
1064
+ * primo del 🔴 del lote 5, un escalón abajo: el seguro miraba si el
1065
+ * origen estaba VACÍO, no si estaba VACÍO DE HECHOS UTILIZABLES.
1066
+ */
1067
+ let usableFacts = 0;
1068
+ const enumerate = source.facts;
1069
+ if (typeof enumerate !== 'function') {
1070
+ throw new UnsupportedOperationError('enumerateFacts', 'authz:reconcile --to=database', 'origen', `El ORIGEN de la migración tiene que saber enumerar sus hechos: sin eso, esta pasada leería ` +
1071
+ `cero hechos y con --prune vaciaría 'authz_assignments'/'authz_denies'. El driver 'database' ` +
1072
+ `no lo implementa a propósito (sus hechos SON esas tablas).`);
1073
+ }
1074
+ let after;
1075
+ const seenCursors = new Set();
1076
+ for (let page = 0;; page++) {
1077
+ const got = await enumerate({ limit: batchSize, after });
1078
+ if (got.facts.length > batchSize) {
1079
+ throw new AuthorizationInternalError(`authz:reconcile: el origen devolvió ${got.facts.length} hechos con limit=${batchSize}`);
1080
+ }
1081
+ for (const skip of got.skipped ?? [])
1082
+ note(skip.kind, skip.reason, skip.detail);
1083
+ for (const fact of got.facts) {
1084
+ sourceFacts += 1;
1085
+ if (sourceFacts > maxTuples) {
1086
+ throw new ReconcileTooLargeError(`authz:reconcile --to=database: el ORIGEN pasa de maxTuples (${maxTuples}) hechos y la pasada ` +
1087
+ `necesita comparar contra el estado ENTERO del destino, que entra en memoria. Sube maxTuples ` +
1088
+ `si tu proceso lo aguanta; no hay migración por particiones en esta versión.`);
1089
+ }
1090
+ const target = await canonical(fact.scope);
1091
+ if (target === null) {
1092
+ note(fact.kind, 'unknown-scope', fact.detail);
1093
+ continue;
1094
+ }
1095
+ if (fact.kind === 'assignment') {
1096
+ if (fact.expiresAt !== null && fact.expiresAt !== undefined && fact.expiresAt <= now) {
1097
+ note('assignment', 'expired', fact.detail);
1098
+ continue;
1099
+ }
1100
+ const role = fact.roleUuid ? catalog.roleByUuid(fact.roleUuid) : null;
1101
+ if (!role) {
1102
+ note('assignment', 'unknown-role', fact.detail);
1103
+ continue;
1104
+ }
1105
+ const row = {
1106
+ holder_type: fact.holder.type,
1107
+ holder_uuid: fact.holder.uuid,
1108
+ role_uuid: role.uuid,
1109
+ scope_type: target.type,
1110
+ scope_uuid: toDbScopeUuid(target),
1111
+ };
1112
+ // **Dos hechos del origen que colapsan en UNA fila** (S15, medido):
1113
+ // el store distingue `scope:unit|AAA` de `scope:unit|aaa` y la tabla
1114
+ // del consumidor no puede tener las dos (columna `uuid` en
1115
+ // PostgreSQL, collation `*_ci` en MySQL), así que la cadena
1116
+ // canónica las funde. No se pierde en silencio: se cuenta con su
1117
+ // motivo, y la fila se queda con la caducidad que MÁS dura —que es
1118
+ // lo que el origen respondía, donde bastaba con que UNA siguiera
1119
+ // viva—.
1120
+ usableFacts += 1;
1121
+ const key = assignmentKey(row);
1122
+ const before = wantedAssignments.get(key);
1123
+ if (before) {
1124
+ note('assignment', 'folded-scope', `${before.detail} ≡ ${fact.detail}`);
1125
+ }
1126
+ const expiresAt = fact.expiresAt ?? null;
1127
+ if (!before || longerExpiry(before.expiresAt, expiresAt) === expiresAt) {
1128
+ wantedAssignments.set(key, { row, expiresAt, detail: fact.detail });
1129
+ }
1130
+ continue;
1131
+ }
1132
+ const permission = fact.permission ? catalog.permission(fact.permission) : null;
1133
+ if (!permission) {
1134
+ note('deny', 'unknown-permission', fact.detail);
1135
+ continue;
1136
+ }
1137
+ const row = {
1138
+ holder_type: fact.holder.type,
1139
+ holder_uuid: fact.holder.uuid,
1140
+ permission_uuid: permission.uuid,
1141
+ scope_type: target.type,
1142
+ scope_uuid: toDbScopeUuid(target),
1143
+ };
1144
+ usableFacts += 1;
1145
+ const denyId = denyKey(row);
1146
+ if (wantedDenies.has(denyId))
1147
+ note('deny', 'folded-scope', fact.detail);
1148
+ wantedDenies.set(denyId, row);
1149
+ }
1150
+ const cursor = got.cursor;
1151
+ if (!cursor)
1152
+ break;
1153
+ if (seenCursors.has(cursor)) {
1154
+ throw new AuthorizationInternalError(`authz:reconcile: el cursor del origen se repite (página ${page + 1}); no avanza`);
1155
+ }
1156
+ seenCursors.add(cursor);
1157
+ after = cursor;
1158
+ }
1159
+ /* 2. El DESTINO entero, tal como está. */
1160
+ const phases = emptyReconcilePhases();
1161
+ const inserts = [];
1162
+ const updates = [];
1163
+ const deletes = { assignments: [], denies: [] };
1164
+ const seenAssignments = new Set();
1165
+ const seenDenies = new Set();
1166
+ let destinationRows = 0;
1167
+ const countDestination = () => {
1168
+ destinationRows += 1;
1169
+ if (destinationRows > maxTuples) {
1170
+ throw new ReconcileTooLargeError(`authz:reconcile --to=database: el DESTINO pasa de maxTuples (${maxTuples}) filas y la pasada ` +
1171
+ `necesita la foto entera para saber qué sobra. Sube maxTuples si tu proceso lo aguanta; ` +
1172
+ `no hay migración por particiones en esta versión.`);
1173
+ }
1174
+ };
1175
+ await this.eachReconcileRow('reconcile.assignments', batchSize, (query) => query
1176
+ .from('authz_assignments')
1177
+ .select('uuid', 'holder_type', 'holder_uuid', 'role_uuid', 'scope_type', 'scope_uuid')
1178
+ .select(this.expiry.select('expires_at')), (row) => {
1179
+ countDestination();
1180
+ const key = assignmentKey(row);
1181
+ const target = wantedAssignments.get(key);
1182
+ if (target) {
1183
+ seenAssignments.add(key);
1184
+ if (sameInstant(this.expiry.fromDb(row.expires_at), target.expiresAt)) {
1185
+ phases.facts.unchanged += 1;
1186
+ return;
1187
+ }
1188
+ phases.facts.updated += 1;
1189
+ updates.push({ uuid: String(row.uuid), expiresAt: target.expiresAt });
1190
+ return;
1191
+ }
1192
+ phases.facts.extra += 1;
1193
+ if (!prune) {
1194
+ note('assignment', 'extra-fact', reconcileRowLabel(row));
1195
+ return;
1196
+ }
1197
+ phases.facts.deleted += 1;
1198
+ deletes.assignments.push(String(row.uuid));
1199
+ });
1200
+ await this.eachReconcileRow('reconcile.denies', batchSize, (query) => query
1201
+ .from('authz_denies')
1202
+ .select('uuid', 'holder_type', 'holder_uuid', 'permission_uuid', 'scope_type', 'scope_uuid'), (row) => {
1203
+ countDestination();
1204
+ const key = denyKey(row);
1205
+ if (wantedDenies.has(key)) {
1206
+ seenDenies.add(key);
1207
+ phases.facts.unchanged += 1;
1208
+ return;
1209
+ }
1210
+ phases.facts.extra += 1;
1211
+ if (!prune) {
1212
+ note('deny', 'extra-fact', reconcileRowLabel(row));
1213
+ return;
1214
+ }
1215
+ phases.facts.deleted += 1;
1216
+ deletes.denies.push(String(row.uuid));
1217
+ });
1218
+ for (const [key, target] of wantedAssignments) {
1219
+ if (seenAssignments.has(key))
1220
+ continue;
1221
+ phases.facts.written += 1;
1222
+ inserts.push({
1223
+ table: 'authz_assignments',
1224
+ row: {
1225
+ uuid: uuidv7(),
1226
+ ...target.row,
1227
+ expires_at: this.expiry.toDb(target.expiresAt),
1228
+ created_at: systemClock(),
1229
+ },
1230
+ });
1231
+ }
1232
+ for (const [key, row] of wantedDenies) {
1233
+ if (seenDenies.has(key))
1234
+ continue;
1235
+ phases.facts.written += 1;
1236
+ inserts.push({ table: 'authz_denies', row: { uuid: uuidv7(), ...row, created_at: systemClock() } });
1237
+ }
1238
+ /* 3. El seguro del origen ciego (AA2 aplicado a la migración; 3b-8 · B1:
1239
+ * sobre los hechos UTILIZABLES, no el conteo crudo — un origen que
1240
+ * devuelve N hechos y los N se descartan sigue siendo un origen que
1241
+ * no respalda NADA de lo que `--prune` va a borrar). */
1242
+ const massDelete = prune && phases.facts.deleted > 0 && usableFacts === 0;
1243
+ if (massDelete && !dryRun && options.allowMassDelete !== true) {
1244
+ throw new MassReconcileRefusedError(`authz:reconcile --to=database --prune borraría ${phases.facts.deleted} fila(s) de hechos y el ORIGEN ` +
1245
+ `no ha devuelto NI UN hecho utilizable (${sourceFacts} leídos, todos descartados — mira 'skipped'). ` +
1246
+ `Eso es la firma de un store equivocado (o vacío, o de un árbol que ya no resuelve ninguno de sus ` +
1247
+ `scopes), no de una base que sobra: esta pasada dejaría 'authz_assignments'/'authz_denies' sin nada ` +
1248
+ `concedido. Comprueba el --from y el store; si de verdad quieres vaciarlas, --allow-mass-delete.`);
1249
+ }
1250
+ /* 4. Aplicar. Deletes primero: si muere a medias, el destino queda de
1251
+ * MENOS (fail-closed), nunca con dos filas del mismo hecho. */
1252
+ if (!dryRun) {
1253
+ for (let i = 0; i < deletes.assignments.length; i += batchSize) {
1254
+ const slice = deletes.assignments.slice(i, i + batchSize);
1255
+ await this.sql('reconcile.delete.assignments', () => db.from('authz_assignments').whereIn('uuid', slice).delete());
1256
+ }
1257
+ for (let i = 0; i < deletes.denies.length; i += batchSize) {
1258
+ const slice = deletes.denies.slice(i, i + batchSize);
1259
+ await this.sql('reconcile.delete.denies', () => db.from('authz_denies').whereIn('uuid', slice).delete());
1260
+ }
1261
+ for (const update of updates) {
1262
+ await this.sql('reconcile.update', () => db.from('authz_assignments').where('uuid', update.uuid).update({ expires_at: this.expiry.toDb(update.expiresAt) }));
1263
+ }
1264
+ for (const table of ['authz_assignments', 'authz_denies']) {
1265
+ const rows = inserts.filter((i) => i.table === table).map((i) => i.row);
1266
+ for (let i = 0; i < rows.length; i += batchSize) {
1267
+ const slice = rows.slice(i, i + batchSize);
1268
+ await this.sql('reconcile.insert', () => db.table(table).insert(slice));
1269
+ }
1270
+ }
1271
+ }
1272
+ const totals = sumReconcilePhases(phases);
1273
+ return {
1274
+ to: 'database',
1275
+ dryRun,
1276
+ prune,
1277
+ ...totals,
1278
+ phases,
1279
+ skipped,
1280
+ details,
1281
+ // El árbol NO se migra en esta dirección, así que no hay ciclos que
1282
+ // reportar ni aristas de más: los ciclos del árbol del consumidor los
1283
+ // ve `--to=openfga`, que es quien lo copia.
1284
+ cycles: [],
1285
+ drift: { rootMarker: false, multiParent: [], roleVisibility: 0, pendingRelay: 0, deadRelay: 0 },
1286
+ massDelete,
1287
+ };
1288
+ }
1289
+ /**
1290
+ * Pasea una tabla `authz_*` por lotes con cursor sobre `uuid` (PK: orden
1291
+ * total y estable). Lo mismo que hace `openfga.reconcile` con el origen,
1292
+ * por el mismo motivo: una base grande no entra entera de golpe.
1293
+ */
1294
+ async eachReconcileRow(operation, batchSize, build, handle) {
1295
+ let after;
1296
+ for (;;) {
1297
+ const rows = await this.sql(operation, () => {
1298
+ const query = build(db);
1299
+ if (after !== undefined)
1300
+ query.where('uuid', '>', after);
1301
+ return query.orderBy('uuid', 'asc').limit(batchSize);
1302
+ });
1303
+ for (const row of rows)
1304
+ handle(row);
1305
+ if (rows.length < batchSize)
1306
+ return;
1307
+ after = String(rows[rows.length - 1].uuid);
1308
+ }
1309
+ }
824
1310
  }
825
1311
  //# sourceMappingURL=database_driver.js.map