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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/README.md +462 -35
  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 +73 -0
  21. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  22. package/build/commands/authz_relations_reconcile.js +225 -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 +39 -10
  45. package/build/index.d.ts.map +1 -1
  46. package/build/index.js +35 -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 +48 -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 +101 -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 +86 -4
  68. package/build/src/drivers/database_driver.d.ts.map +1 -1
  69. package/build/src/drivers/database_driver.js +425 -11
  70. package/build/src/drivers/database_driver.js.map +1 -1
  71. package/build/src/drivers/database_relations_driver.d.ts +75 -0
  72. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  73. package/build/src/drivers/database_relations_driver.js +450 -0
  74. package/build/src/drivers/database_relations_driver.js.map +1 -0
  75. package/build/src/drivers/openfga_driver.d.ts +713 -119
  76. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  77. package/build/src/drivers/openfga_driver.js +2048 -476
  78. package/build/src/drivers/openfga_driver.js.map +1 -1
  79. package/build/src/drivers/openfga_facts.d.ts +369 -0
  80. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  81. package/build/src/drivers/openfga_facts.js +813 -0
  82. package/build/src/drivers/openfga_facts.js.map +1 -0
  83. package/build/src/drivers/openfga_relations_driver.d.ts +120 -0
  84. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  85. package/build/src/drivers/openfga_relations_driver.js +466 -0
  86. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  87. package/build/src/errors.d.ts +258 -5
  88. package/build/src/errors.d.ts.map +1 -1
  89. package/build/src/errors.js +238 -7
  90. package/build/src/errors.js.map +1 -1
  91. package/build/src/freeze.d.ts +120 -0
  92. package/build/src/freeze.d.ts.map +1 -0
  93. package/build/src/freeze.js +172 -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 +315 -4
  106. package/build/src/manager.d.ts.map +1 -1
  107. package/build/src/manager.js +1187 -181
  108. package/build/src/manager.js.map +1 -1
  109. package/build/src/models/authz_assignment.d.ts +6 -6
  110. package/build/src/models/authz_assignment.d.ts.map +1 -1
  111. package/build/src/models/authz_deny.d.ts +6 -6
  112. package/build/src/models/authz_deny.d.ts.map +1 -1
  113. package/build/src/models/authz_permission.d.ts +6 -6
  114. package/build/src/models/authz_permission.d.ts.map +1 -1
  115. package/build/src/models/authz_role.d.ts +6 -6
  116. package/build/src/models/authz_role.d.ts.map +1 -1
  117. package/build/src/models/authz_role_permission.d.ts +6 -6
  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 +85 -0
  130. package/build/src/relation_partition_trigger.js.map +1 -0
  131. package/build/src/relations/define_relations_config.d.ts +58 -0
  132. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  133. package/build/src/relations/define_relations_config.js +144 -0
  134. package/build/src/relations/define_relations_config.js.map +1 -0
  135. package/build/src/relations/manager.d.ts +38 -0
  136. package/build/src/relations/manager.d.ts.map +1 -0
  137. package/build/src/relations/manager.js +156 -0
  138. package/build/src/relations/manager.js.map +1 -0
  139. package/build/src/relations/reconcile.d.ts +62 -0
  140. package/build/src/relations/reconcile.d.ts.map +1 -0
  141. package/build/src/relations/reconcile.js +138 -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 +291 -0
  150. package/build/src/scope_outbox.js.map +1 -0
  151. package/build/src/{drivers → shared}/backend_guard.d.ts +14 -0
  152. package/build/src/shared/backend_guard.d.ts.map +1 -0
  153. package/build/src/{drivers → shared}/backend_guard.js +26 -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/sql_descendants.d.ts +47 -1
  158. package/build/src/sql_descendants.d.ts.map +1 -1
  159. package/build/src/sql_descendants.js +75 -1
  160. package/build/src/sql_descendants.js.map +1 -1
  161. package/build/src/testing/contract.d.ts +74 -0
  162. package/build/src/testing/contract.d.ts.map +1 -1
  163. package/build/src/testing/contract.js +672 -169
  164. package/build/src/testing/contract.js.map +1 -1
  165. package/build/src/testing/main.d.ts +6 -0
  166. package/build/src/testing/main.d.ts.map +1 -1
  167. package/build/src/testing/main.js +3 -0
  168. package/build/src/testing/main.js.map +1 -1
  169. package/build/src/testing/migration_contract.d.ts +284 -0
  170. package/build/src/testing/migration_contract.d.ts.map +1 -0
  171. package/build/src/testing/migration_contract.js +586 -0
  172. package/build/src/testing/migration_contract.js.map +1 -0
  173. package/build/src/testing/relations_contract.d.ts +51 -0
  174. package/build/src/testing/relations_contract.d.ts.map +1 -0
  175. package/build/src/testing/relations_contract.js +654 -0
  176. package/build/src/testing/relations_contract.js.map +1 -0
  177. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  178. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  179. package/build/src/testing/relations_reconcile_contract.js +172 -0
  180. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  181. package/build/src/traits/authz_scopes.js +1 -1
  182. package/build/src/traits/authz_scopes.js.map +1 -1
  183. package/build/src/traits/has_uuid.d.ts +7 -7
  184. package/build/src/traits/has_uuid.d.ts.map +1 -1
  185. package/build/src/types.d.ts +865 -82
  186. package/build/src/types.d.ts.map +1 -1
  187. package/build/src/types.js +10 -0
  188. package/build/src/types.js.map +1 -1
  189. package/build/stubs/config/authorization.stub +104 -4
  190. package/build/stubs/migration.stub +126 -0
  191. package/build/stubs/scopes_outbox_migration.stub +57 -0
  192. package/package.json +4 -2
  193. package/build/commands/openfga_import.d.ts +0 -34
  194. package/build/commands/openfga_import.d.ts.map +0 -1
  195. package/build/commands/openfga_import.js +0 -97
  196. package/build/commands/openfga_import.js.map +0 -1
  197. package/build/src/catalog.d.ts.map +0 -1
  198. package/build/src/catalog.js.map +0 -1
  199. package/build/src/catalog_cache.d.ts.map +0 -1
  200. package/build/src/catalog_cache.js.map +0 -1
  201. package/build/src/drivers/backend_guard.d.ts.map +0 -1
  202. package/build/src/drivers/backend_guard.js.map +0 -1
  203. package/build/src/drivers/sql_expiry.d.ts.map +0 -1
  204. package/build/src/drivers/sql_expiry.js.map +0 -1
  205. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  206. package/build/src/middleware/app_access_middleware.js.map +0 -1
  207. /package/build/src/{middleware → http}/app_access_middleware.d.ts +0 -0
  208. /package/build/src/{middleware → http}/app_access_middleware.js +0 -0
  209. /package/build/src/{drivers → shared}/sql_expiry.d.ts +0 -0
  210. /package/build/src/{drivers → shared}/sql_expiry.js +0 -0
@@ -3,12 +3,13 @@ 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, } from '../errors.js';
7
+ import { RECONCILE_MAX_DETAILS, emptyReconcilePhases, reconcileBatchSize, reconcileMaxTuples, sumReconcilePhases, } from '../reconcile.js';
8
+ import { assertKnownScope, canonicalScope, canonicalScopeTargets, guardSql, resolveChain, rootOnlyResolver } from '../shared/backend_guard.js';
9
+ import { assertAssignableAt } from '../catalog/catalog.js';
10
+ import { CatalogCache, GLOBAL_OWNER_KEY, assertCatalogOptions, isRoleVisibleWith, withAuthzCatalogWrite } from '../catalog/catalog_cache.js';
10
11
  import { isClock, systemClock } from '../clock.js';
11
- import { sqlExpiryCodec } from './sql_expiry.js';
12
+ import { sqlExpiryCodec } from '../shared/sql_expiry.js';
12
13
  /**
13
14
  * UUID centinela para el scope 'app' en las columnas scope_uuid (NOT NULL):
14
15
  * permite que el unique de assignments/denies cubra también el nivel app
@@ -23,7 +24,15 @@ function toDbScopeUuid(scope) {
23
24
  assertScope(scope);
24
25
  return scope.uuid ?? APP_SCOPE_DB_UUID;
25
26
  }
26
- function fromDbScopeUuid(uuid) {
27
+ /**
28
+ * La vuelta de `toDbScopeUuid`: el centinela de la raíz vuelve a ser `null`.
29
+ * **Exportada desde 3b-3b**: `authz:reconcile --to=openfga` lee estas mismas
30
+ * columnas y hacía `row.scope_uuid ?? null`, que con la columna NOT NULL
31
+ * nunca es null — un grant o un deny en la RAÍZ reventaban la migración con
32
+ * un 422 a mitad de pasada (`el scope 'app' no admite uuid`). Lo cazó el
33
+ * contrato de migración, cuya siembra fija sí concede en `app`.
34
+ */
35
+ export function fromDbScopeUuid(uuid) {
27
36
  return uuid === APP_SCOPE_DB_UUID ? null : uuid;
28
37
  }
29
38
  /**
@@ -38,6 +47,35 @@ function dbScopeKey(scope) {
38
47
  function rowScopeKey(row) {
39
48
  return `${row.scope_type}\u001f${row.scope_uuid}`;
40
49
  }
50
+ /**
51
+ * Clave de comparación de una asignación. **Es exactamente el unique
52
+ * `authz_asg_holder_role_scope_uq`** y NO incluye `expires_at`: cambiar la
53
+ * caducidad es un UPDATE de la misma fila, no una fila nueva (invariante 6).
54
+ * Que la clave del diff sea la del índice es lo que hace que la migración no
55
+ * pueda dejar dos filas del mismo hecho ni chocar con el unique al insertar.
56
+ */
57
+ function assignmentKey(row) {
58
+ return [row.holder_type, row.holder_uuid, row.role_uuid, row.scope_type, row.scope_uuid].join('\u001f');
59
+ }
60
+ function denyKey(row) {
61
+ return [row.holder_type, row.holder_uuid, row.permission_uuid, row.scope_type, row.scope_uuid].join('\u001f');
62
+ }
63
+ /**
64
+ * La caducidad que dura MÁS (`null` = para siempre). Es la regla con la que
65
+ * se resuelve un colapso de dos hechos del origen en una sola fila: el
66
+ * origen concedía mientras CUALQUIERA de los dos siguiera vivo, así que la
67
+ * unión es lo que conserva la respuesta.
68
+ */
69
+ function longerExpiry(a, b) {
70
+ if (a === null || b === null)
71
+ return null;
72
+ return a >= b ? a : b;
73
+ }
74
+ /** Cómo se NOMBRA una fila del destino en `details`: sin esto un motivo no se arregla. */
75
+ function reconcileRowLabel(row) {
76
+ const what = row.role_uuid ? `→ ${row.role_uuid}` : `⊘ ${row.permission_uuid}`;
77
+ return `${row.holder_type}:${row.holder_uuid} ${what} @ ${row.scope_type}:${row.scope_uuid}`;
78
+ }
41
79
  /**
42
80
  * El rol `(slug, scopeType)` que existe en el scope cuya cadena empieza por
43
81
  * `chainKeys`, o 422: `E_AUTHZ_ROLE_NOT_VISIBLE` si hay roles con ese nombre
@@ -220,6 +258,31 @@ export const DEFAULT_TIMEOUT_MS = 5_000;
220
258
  * crudo de knex (L0.11).
221
259
  */
222
260
  export class DatabaseAuthorizationDriver {
261
+ /**
262
+ * Lo que este driver declara (3b-2e · E2). El árbol es del consumidor
263
+ * (`resolveChain`), así que no hay hechos de jerarquía ni deriva que
264
+ * mitigar; `authorize` son varias consultas SQL; la membresía la resuelve
265
+ * el propio SQL contra la cadena que le pasan; los `list*` no enumeran
266
+ * herencia (invariante 7); y `purgeRole` está implementado en una
267
+ * transacción.
268
+ */
269
+ capabilities = Object.freeze({
270
+ hierarchyFacts: false,
271
+ singleCheckAuthorize: false,
272
+ roleInheritanceNative: false,
273
+ listObjectsInherited: false,
274
+ purgeRole: true,
275
+ countRoleAssignments: true,
276
+ // 3b-2k · K1: `authorize` resuelve la cadena y usa `chain[0]`, así que un
277
+ // alias del uuid que el árbol funde con la fila real encuentra sus hechos.
278
+ canonicalScopeReads: true,
279
+ // 3b-3b: sus hechos son `authz_assignments`/`authz_denies` —el esquema
280
+ // PUBLICADO del paquete—, así que el destino los lee de ahí y no por el
281
+ // puerto. No es «no sabe»: es que no hace falta un método para leer una
282
+ // tabla documentada. Un driver de terceros que quiera ser origen sí lo
283
+ // necesita, y por eso la capacidad tiene sus dos caras.
284
+ enumerateFacts: false,
285
+ });
223
286
  /**
224
287
  * Resolutor de jerarquía inyectado por el consumidor (el chasis pasa el
225
288
  * suyo, que conoce organizations/units, en `config/authorization.ts`).
@@ -296,10 +359,14 @@ export class DatabaseAuthorizationDriver {
296
359
  knownScope(scope, operation) {
297
360
  return assertKnownScope(this.chainResolver, scope, operation);
298
361
  }
299
- /** El scope canónico para `revoke`/`removeDeny`/`purgeScope` (ver `canonicalScope`). */
362
+ /** El scope canónico para `purgeScope` (ver `canonicalScope`). */
300
363
  canonicalOrSelf(scope, operation) {
301
364
  return canonicalScope(this.chainResolver, scope, operation);
302
365
  }
366
+ /** Los destinos de un delete de hechos (`revoke`/`removeDeny`): canónico, o el fan-out de alias (3b-8 · A4). */
367
+ canonicalTargets(scope, operation) {
368
+ return canonicalScopeTargets(this.chainResolver, scope, operation);
369
+ }
303
370
  /**
304
371
  * Ejecuta una consulta clasificando su fallo (503 con código propio). `fn`
305
372
  * devuelve el BUILDER sin ejecutar: el guard le fija el deadline y luego lo
@@ -478,12 +545,16 @@ export class DatabaseAuthorizationDriver {
478
545
  // scope puede no existir ya para el árbol (D8), así que no se filtra por
479
546
  // visibilidad — quitar nunca concede.
480
547
  const named = rolesToRevoke(await this.catalog.view(), role, scope);
481
- const target = await this.canonicalOrSelf(scope, 'revoke');
548
+ // Sin cadena con la que canonizar, TODAS las ortografías de las que el
549
+ // uuid puede ser alias (3b-8 · A4, mismo fan-out que `scopes.detached`):
550
+ // con una sola, un alias hacía del DELETE un no-op silencioso y la fila
551
+ // canónica seguía concediendo si el scope se restauraba.
552
+ const targets = await this.canonicalTargets(scope, 'revoke');
482
553
  await this.sql('revoke', () => whereScopeIn(db
483
554
  .from('authz_assignments')
484
555
  .where('holder_type', subject.type)
485
556
  .where('holder_uuid', subject.uuid)
486
- .whereIn('role_uuid', named.map((r) => r.uuid)), 'scope', [target], 'write').delete());
557
+ .whereIn('role_uuid', named.map((r) => r.uuid)), 'scope', targets, 'write').delete());
487
558
  }
488
559
  async hasRole(subject, role, scope) {
489
560
  assertIdentity({ subject, role, scope });
@@ -552,12 +623,14 @@ export class DatabaseAuthorizationDriver {
552
623
  const perm = await this.findPermission(permission);
553
624
  if (!perm)
554
625
  throw new UnknownPermissionError(permission);
555
- const target = await this.canonicalOrSelf(scope, 'removeDeny');
626
+ // Mismo fan-out que `revoke` (3b-8 · A4): quitar un deny de menos por un
627
+ // alias sería un deny fantasma que nadie puede levantar.
628
+ const targets = await this.canonicalTargets(scope, 'removeDeny');
556
629
  await this.sql('removeDeny', () => whereScopeIn(db
557
630
  .from('authz_denies')
558
631
  .where('holder_type', subject.type)
559
632
  .where('holder_uuid', subject.uuid)
560
- .where('permission_uuid', perm.uuid), 'scope', [target], 'write').delete());
633
+ .where('permission_uuid', perm.uuid), 'scope', targets, 'write').delete());
561
634
  }
562
635
  async listSubjects(role, scope) {
563
636
  assertIdentity({ role, scope });
@@ -821,5 +894,346 @@ export class DatabaseAuthorizationDriver {
821
894
  this.catalog.invalidate();
822
895
  }
823
896
  }
897
+ /**
898
+ * Cuántos hechos VIGENTES tiene cada rol, en todos los scopes (3b-2j). Es
899
+ * lo que `pruneOrphanRoles` necesita para decir si un huérfano todavía
900
+ * concede, y es una pregunta del PUERTO porque los hechos son del driver:
901
+ * aquí son filas de `authz_assignments`, en `openfga` son tuplas del store.
902
+ *
903
+ * Una consulta agrupada para todos los uuids (no una por rol) y la misma
904
+ * caducidad ESTRICTA que el resto del driver, con SU reloj (`whereActive`,
905
+ * J1): la asignación que vence ahora ya no cuenta, como no cuenta en
906
+ * `authorize`. Un rol sin hechos —o que no está en la tabla— es `0`.
907
+ */
908
+ async countRoleAssignments(roleUuids) {
909
+ for (const uuid of roleUuids)
910
+ assertCatalogUuid('rol', uuid);
911
+ if (roleUuids.length === 0)
912
+ return [];
913
+ const rows = await this.sql('countRoleAssignments', () => this.whereActive(db.from('authz_assignments').whereIn('role_uuid', roleUuids))
914
+ .groupBy('role_uuid')
915
+ .select('role_uuid')
916
+ .count('* as total'));
917
+ const totals = new Map();
918
+ for (const row of rows)
919
+ totals.set(String(row.role_uuid), Number(row.total ?? row.count ?? 0));
920
+ return roleUuids.map((uuid) => totals.get(uuid) ?? 0);
921
+ }
922
+ /* ── `authz:reconcile --to=database` (3b-3b) ─────────────────────────── */
923
+ /**
924
+ * **Rehace `authz_assignments`/`authz_denies` desde los hechos de OTRO
925
+ * driver** — la vuelta de la migración, y con ella la simetría que el dueño
926
+ * compró: «todo en un driver o todo en otro».
927
+ *
928
+ * Qué se migra y qué NO, y por qué:
929
+ * - **los HECHOS, sí**: es lo único que vive en el otro driver. Llegan por
930
+ * `source.facts` —el puerto `enumerateFacts`, paginado— y no por SQL:
931
+ * aquí el origen es un store, no una tabla de este paquete.
932
+ * - **el ÁRBOL, no**. En esta dirección el árbol NO se migra: el driver
933
+ * `database` lo lee de las tablas del CONSUMIDOR en cada pregunta
934
+ * (`resolveChain`), que son su fuente de verdad. Copiarlo a algún sitio
935
+ * sería inventarse una segunda copia y con ella una deriva que hoy no
936
+ * existe. Se usa, eso sí, para decidir qué hecho es migrable
937
+ * (`unknown-scope`) y bajo qué identidad CANÓNICA se escribe
938
+ * (invariante 17).
939
+ * - **el CATÁLOGO, tampoco**: es propiedad local SIEMPRE (regla de higiene
940
+ * del paquete), ya está en `authz_*` y ningún driver es su fuente. Aquí
941
+ * solo se usa para traducir: un `roleUuid` que el catálogo no declara y
942
+ * un permiso que no existe no son hechos migrables.
943
+ *
944
+ * Por eso `phases.root`, `phases.catalog` y `phases.tree` son CERO en esta
945
+ * dirección, y todo el movimiento está en `phases.facts`. Es una respuesta,
946
+ * no un hueco: los ceros dicen «aquí no hay nada derivado que rehacer».
947
+ *
948
+ * El resto del contrato es el mismo que en la ida (3b-3a): idempotente (la
949
+ * segunda pasada escribe cero), `dryRun` es el VERIFICADOR y es read-only
950
+ * por contrato —**un `--fix` está prohibido** (cruce 4 · S18) y no se
951
+ * implementa ni se deja preparado—, nunca silenciosa (`skipped{motivo}` +
952
+ * `details`), los hechos que sobran solo se borran con `prune`, y el
953
+ * seguro del origen ciego (`E_AUTHZ_MASS_RECONCILE_REFUSED`) se aplica
954
+ * igual: un origen que devuelve CERO hechos y un `--prune` detrás vacían
955
+ * la base, y eso es casi siempre apuntar al store equivocado.
956
+ */
957
+ async reconcile(source, options = {}) {
958
+ const dryRun = options.dryRun === true;
959
+ const prune = options.prune === true;
960
+ const batchSize = reconcileBatchSize(options.batchSize);
961
+ const maxTuples = reconcileMaxTuples(options.maxTuples);
962
+ const catalog = await this.catalog.view();
963
+ const now = this.now();
964
+ const skipped = {};
965
+ const details = [];
966
+ const note = (kind, reason, detail) => {
967
+ skipped[reason] = (skipped[reason] ?? 0) + 1;
968
+ if (details.length < RECONCILE_MAX_DETAILS)
969
+ details.push({ kind, reason, detail });
970
+ };
971
+ /** `[scope canónico, ...]` memoizado: el árbol del consumidor manda. */
972
+ const chains = new Map();
973
+ const canonical = async (scope) => {
974
+ const key = dbScopeKey(scope);
975
+ if (!chains.has(key)) {
976
+ chains.set(key, await resolveChain(source.resolveChain, scope, 'reconcile'));
977
+ }
978
+ const chain = chains.get(key);
979
+ return chain === null ? null : chain[0];
980
+ };
981
+ /* 1. Los HECHOS del origen, paginados por el puerto. */
982
+ const wantedAssignments = new Map();
983
+ const wantedDenies = new Map();
984
+ let sourceFacts = 0;
985
+ /**
986
+ * Hechos del origen que RESPALDAN algo en el destino (3b-8 · B1). El
987
+ * seguro de borrado masivo miraba `sourceFacts`, el conteo CRUDO — se
988
+ * incrementa ANTES de cada skip—, así que un origen cuyos hechos se
989
+ * descartan TODOS (caducados, scopes que ya no resuelven: la firma de un
990
+ * store equivocado o de un resolutor ciego) lo esquivaba y `--prune`
991
+ * vaciaba `authz_assignments`/`authz_denies` sin una sola pregunta. Es el
992
+ * primo del 🔴 del lote 5, un escalón abajo: el seguro miraba si el
993
+ * origen estaba VACÍO, no si estaba VACÍO DE HECHOS UTILIZABLES.
994
+ */
995
+ let usableFacts = 0;
996
+ const enumerate = source.facts;
997
+ if (typeof enumerate !== 'function') {
998
+ 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 ` +
999
+ `cero hechos y con --prune vaciaría 'authz_assignments'/'authz_denies'. El driver 'database' ` +
1000
+ `no lo implementa a propósito (sus hechos SON esas tablas).`);
1001
+ }
1002
+ let after;
1003
+ const seenCursors = new Set();
1004
+ for (let page = 0;; page++) {
1005
+ const got = await enumerate({ limit: batchSize, after });
1006
+ if (got.facts.length > batchSize) {
1007
+ throw new AuthorizationInternalError(`authz:reconcile: el origen devolvió ${got.facts.length} hechos con limit=${batchSize}`);
1008
+ }
1009
+ for (const skip of got.skipped ?? [])
1010
+ note(skip.kind, skip.reason, skip.detail);
1011
+ for (const fact of got.facts) {
1012
+ sourceFacts += 1;
1013
+ if (sourceFacts > maxTuples) {
1014
+ throw new ReconcileTooLargeError(`authz:reconcile --to=database: el ORIGEN pasa de maxTuples (${maxTuples}) hechos y la pasada ` +
1015
+ `necesita comparar contra el estado ENTERO del destino, que entra en memoria. Sube maxTuples ` +
1016
+ `si tu proceso lo aguanta; no hay migración por particiones en esta versión.`);
1017
+ }
1018
+ const target = await canonical(fact.scope);
1019
+ if (target === null) {
1020
+ note(fact.kind, 'unknown-scope', fact.detail);
1021
+ continue;
1022
+ }
1023
+ if (fact.kind === 'assignment') {
1024
+ if (fact.expiresAt !== null && fact.expiresAt !== undefined && fact.expiresAt <= now) {
1025
+ note('assignment', 'expired', fact.detail);
1026
+ continue;
1027
+ }
1028
+ const role = fact.roleUuid ? catalog.roleByUuid(fact.roleUuid) : null;
1029
+ if (!role) {
1030
+ note('assignment', 'unknown-role', fact.detail);
1031
+ continue;
1032
+ }
1033
+ const row = {
1034
+ holder_type: fact.holder.type,
1035
+ holder_uuid: fact.holder.uuid,
1036
+ role_uuid: role.uuid,
1037
+ scope_type: target.type,
1038
+ scope_uuid: toDbScopeUuid(target),
1039
+ };
1040
+ // **Dos hechos del origen que colapsan en UNA fila** (S15, medido):
1041
+ // el store distingue `scope:unit|AAA` de `scope:unit|aaa` y la tabla
1042
+ // del consumidor no puede tener las dos (columna `uuid` en
1043
+ // PostgreSQL, collation `*_ci` en MySQL), así que la cadena
1044
+ // canónica las funde. No se pierde en silencio: se cuenta con su
1045
+ // motivo, y la fila se queda con la caducidad que MÁS dura —que es
1046
+ // lo que el origen respondía, donde bastaba con que UNA siguiera
1047
+ // viva—.
1048
+ usableFacts += 1;
1049
+ const key = assignmentKey(row);
1050
+ const before = wantedAssignments.get(key);
1051
+ if (before) {
1052
+ note('assignment', 'folded-scope', `${before.detail} ≡ ${fact.detail}`);
1053
+ }
1054
+ const expiresAt = fact.expiresAt ?? null;
1055
+ if (!before || longerExpiry(before.expiresAt, expiresAt) === expiresAt) {
1056
+ wantedAssignments.set(key, { row, expiresAt, detail: fact.detail });
1057
+ }
1058
+ continue;
1059
+ }
1060
+ const permission = fact.permission ? catalog.permission(fact.permission) : null;
1061
+ if (!permission) {
1062
+ note('deny', 'unknown-permission', fact.detail);
1063
+ continue;
1064
+ }
1065
+ const row = {
1066
+ holder_type: fact.holder.type,
1067
+ holder_uuid: fact.holder.uuid,
1068
+ permission_uuid: permission.uuid,
1069
+ scope_type: target.type,
1070
+ scope_uuid: toDbScopeUuid(target),
1071
+ };
1072
+ usableFacts += 1;
1073
+ const denyId = denyKey(row);
1074
+ if (wantedDenies.has(denyId))
1075
+ note('deny', 'folded-scope', fact.detail);
1076
+ wantedDenies.set(denyId, row);
1077
+ }
1078
+ const cursor = got.cursor;
1079
+ if (!cursor)
1080
+ break;
1081
+ if (seenCursors.has(cursor)) {
1082
+ throw new AuthorizationInternalError(`authz:reconcile: el cursor del origen se repite (página ${page + 1}); no avanza`);
1083
+ }
1084
+ seenCursors.add(cursor);
1085
+ after = cursor;
1086
+ }
1087
+ /* 2. El DESTINO entero, tal como está. */
1088
+ const phases = emptyReconcilePhases();
1089
+ const inserts = [];
1090
+ const updates = [];
1091
+ const deletes = { assignments: [], denies: [] };
1092
+ const seenAssignments = new Set();
1093
+ const seenDenies = new Set();
1094
+ let destinationRows = 0;
1095
+ const countDestination = () => {
1096
+ destinationRows += 1;
1097
+ if (destinationRows > maxTuples) {
1098
+ throw new ReconcileTooLargeError(`authz:reconcile --to=database: el DESTINO pasa de maxTuples (${maxTuples}) filas y la pasada ` +
1099
+ `necesita la foto entera para saber qué sobra. Sube maxTuples si tu proceso lo aguanta; ` +
1100
+ `no hay migración por particiones en esta versión.`);
1101
+ }
1102
+ };
1103
+ await this.eachReconcileRow('reconcile.assignments', batchSize, (query) => query
1104
+ .from('authz_assignments')
1105
+ .select('uuid', 'holder_type', 'holder_uuid', 'role_uuid', 'scope_type', 'scope_uuid')
1106
+ .select(this.expiry.select('expires_at')), (row) => {
1107
+ countDestination();
1108
+ const key = assignmentKey(row);
1109
+ const target = wantedAssignments.get(key);
1110
+ if (target) {
1111
+ seenAssignments.add(key);
1112
+ if (sameInstant(this.expiry.fromDb(row.expires_at), target.expiresAt)) {
1113
+ phases.facts.unchanged += 1;
1114
+ return;
1115
+ }
1116
+ phases.facts.updated += 1;
1117
+ updates.push({ uuid: String(row.uuid), expiresAt: target.expiresAt });
1118
+ return;
1119
+ }
1120
+ phases.facts.extra += 1;
1121
+ if (!prune) {
1122
+ note('assignment', 'extra-fact', reconcileRowLabel(row));
1123
+ return;
1124
+ }
1125
+ phases.facts.deleted += 1;
1126
+ deletes.assignments.push(String(row.uuid));
1127
+ });
1128
+ await this.eachReconcileRow('reconcile.denies', batchSize, (query) => query
1129
+ .from('authz_denies')
1130
+ .select('uuid', 'holder_type', 'holder_uuid', 'permission_uuid', 'scope_type', 'scope_uuid'), (row) => {
1131
+ countDestination();
1132
+ const key = denyKey(row);
1133
+ if (wantedDenies.has(key)) {
1134
+ seenDenies.add(key);
1135
+ phases.facts.unchanged += 1;
1136
+ return;
1137
+ }
1138
+ phases.facts.extra += 1;
1139
+ if (!prune) {
1140
+ note('deny', 'extra-fact', reconcileRowLabel(row));
1141
+ return;
1142
+ }
1143
+ phases.facts.deleted += 1;
1144
+ deletes.denies.push(String(row.uuid));
1145
+ });
1146
+ for (const [key, target] of wantedAssignments) {
1147
+ if (seenAssignments.has(key))
1148
+ continue;
1149
+ phases.facts.written += 1;
1150
+ inserts.push({
1151
+ table: 'authz_assignments',
1152
+ row: {
1153
+ uuid: uuidv7(),
1154
+ ...target.row,
1155
+ expires_at: this.expiry.toDb(target.expiresAt),
1156
+ created_at: systemClock(),
1157
+ },
1158
+ });
1159
+ }
1160
+ for (const [key, row] of wantedDenies) {
1161
+ if (seenDenies.has(key))
1162
+ continue;
1163
+ phases.facts.written += 1;
1164
+ inserts.push({ table: 'authz_denies', row: { uuid: uuidv7(), ...row, created_at: systemClock() } });
1165
+ }
1166
+ /* 3. El seguro del origen ciego (AA2 aplicado a la migración; 3b-8 · B1:
1167
+ * sobre los hechos UTILIZABLES, no el conteo crudo — un origen que
1168
+ * devuelve N hechos y los N se descartan sigue siendo un origen que
1169
+ * no respalda NADA de lo que `--prune` va a borrar). */
1170
+ const massDelete = prune && phases.facts.deleted > 0 && usableFacts === 0;
1171
+ if (massDelete && !dryRun && options.allowMassDelete !== true) {
1172
+ throw new MassReconcileRefusedError(`authz:reconcile --to=database --prune borraría ${phases.facts.deleted} fila(s) de hechos y el ORIGEN ` +
1173
+ `no ha devuelto NI UN hecho utilizable (${sourceFacts} leídos, todos descartados — mira 'skipped'). ` +
1174
+ `Eso es la firma de un store equivocado (o vacío, o de un árbol que ya no resuelve ninguno de sus ` +
1175
+ `scopes), no de una base que sobra: esta pasada dejaría 'authz_assignments'/'authz_denies' sin nada ` +
1176
+ `concedido. Comprueba el --from y el store; si de verdad quieres vaciarlas, --allow-mass-delete.`);
1177
+ }
1178
+ /* 4. Aplicar. Deletes primero: si muere a medias, el destino queda de
1179
+ * MENOS (fail-closed), nunca con dos filas del mismo hecho. */
1180
+ if (!dryRun) {
1181
+ for (let i = 0; i < deletes.assignments.length; i += batchSize) {
1182
+ const slice = deletes.assignments.slice(i, i + batchSize);
1183
+ await this.sql('reconcile.delete.assignments', () => db.from('authz_assignments').whereIn('uuid', slice).delete());
1184
+ }
1185
+ for (let i = 0; i < deletes.denies.length; i += batchSize) {
1186
+ const slice = deletes.denies.slice(i, i + batchSize);
1187
+ await this.sql('reconcile.delete.denies', () => db.from('authz_denies').whereIn('uuid', slice).delete());
1188
+ }
1189
+ for (const update of updates) {
1190
+ await this.sql('reconcile.update', () => db.from('authz_assignments').where('uuid', update.uuid).update({ expires_at: this.expiry.toDb(update.expiresAt) }));
1191
+ }
1192
+ for (const table of ['authz_assignments', 'authz_denies']) {
1193
+ const rows = inserts.filter((i) => i.table === table).map((i) => i.row);
1194
+ for (let i = 0; i < rows.length; i += batchSize) {
1195
+ const slice = rows.slice(i, i + batchSize);
1196
+ await this.sql('reconcile.insert', () => db.table(table).insert(slice));
1197
+ }
1198
+ }
1199
+ }
1200
+ const totals = sumReconcilePhases(phases);
1201
+ return {
1202
+ to: 'database',
1203
+ dryRun,
1204
+ prune,
1205
+ ...totals,
1206
+ phases,
1207
+ skipped,
1208
+ details,
1209
+ // El árbol NO se migra en esta dirección, así que no hay ciclos que
1210
+ // reportar ni aristas de más: los ciclos del árbol del consumidor los
1211
+ // ve `--to=openfga`, que es quien lo copia.
1212
+ cycles: [],
1213
+ drift: { rootMarker: false, multiParent: [], roleVisibility: 0, pendingRelay: 0, deadRelay: 0 },
1214
+ massDelete,
1215
+ };
1216
+ }
1217
+ /**
1218
+ * Pasea una tabla `authz_*` por lotes con cursor sobre `uuid` (PK: orden
1219
+ * total y estable). Lo mismo que hace `openfga.reconcile` con el origen,
1220
+ * por el mismo motivo: una base grande no entra entera de golpe.
1221
+ */
1222
+ async eachReconcileRow(operation, batchSize, build, handle) {
1223
+ let after;
1224
+ for (;;) {
1225
+ const rows = await this.sql(operation, () => {
1226
+ const query = build(db);
1227
+ if (after !== undefined)
1228
+ query.where('uuid', '>', after);
1229
+ return query.orderBy('uuid', 'asc').limit(batchSize);
1230
+ });
1231
+ for (const row of rows)
1232
+ handle(row);
1233
+ if (rows.length < batchSize)
1234
+ return;
1235
+ after = String(rows[rows.length - 1].uuid);
1236
+ }
1237
+ }
824
1238
  }
825
1239
  //# sourceMappingURL=database_driver.js.map