@jantstack/adonis-authz 1.1.0 → 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 (236) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +837 -51
  3. package/build/commands/authz_catalog_diff.d.ts +28 -0
  4. package/build/commands/authz_catalog_diff.d.ts.map +1 -0
  5. package/build/commands/authz_catalog_diff.js +67 -0
  6. package/build/commands/authz_catalog_diff.js.map +1 -0
  7. package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
  8. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
  9. package/build/commands/authz_catalog_prune_orphans.js +136 -0
  10. package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
  11. package/build/commands/authz_catalog_sync.d.ts +37 -0
  12. package/build/commands/authz_catalog_sync.d.ts.map +1 -0
  13. package/build/commands/authz_catalog_sync.js +81 -0
  14. package/build/commands/authz_catalog_sync.js.map +1 -0
  15. package/build/commands/authz_freeze.d.ts +44 -0
  16. package/build/commands/authz_freeze.d.ts.map +1 -0
  17. package/build/commands/authz_freeze.js +95 -0
  18. package/build/commands/authz_freeze.js.map +1 -0
  19. package/build/commands/authz_reconcile.d.ts +102 -0
  20. package/build/commands/authz_reconcile.d.ts.map +1 -0
  21. package/build/commands/authz_reconcile.js +294 -0
  22. package/build/commands/authz_reconcile.js.map +1 -0
  23. package/build/commands/authz_relations_reconcile.d.ts +73 -0
  24. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  25. package/build/commands/authz_relations_reconcile.js +225 -0
  26. package/build/commands/authz_relations_reconcile.js.map +1 -0
  27. package/build/commands/authz_scopes_relay.d.ts +47 -0
  28. package/build/commands/authz_scopes_relay.d.ts.map +1 -0
  29. package/build/commands/authz_scopes_relay.js +141 -0
  30. package/build/commands/authz_scopes_relay.js.map +1 -0
  31. package/build/commands/authz_unfreeze.d.ts +37 -0
  32. package/build/commands/authz_unfreeze.d.ts.map +1 -0
  33. package/build/commands/authz_unfreeze.js +92 -0
  34. package/build/commands/authz_unfreeze.js.map +1 -0
  35. package/build/commands/main.d.ts +8 -1
  36. package/build/commands/main.d.ts.map +1 -1
  37. package/build/commands/main.js +8 -1
  38. package/build/commands/main.js.map +1 -1
  39. package/build/commands/openfga_provision.d.ts +46 -4
  40. package/build/commands/openfga_provision.d.ts.map +1 -1
  41. package/build/commands/openfga_provision.js +91 -7
  42. package/build/commands/openfga_provision.js.map +1 -1
  43. package/build/configure.d.ts +11 -0
  44. package/build/configure.d.ts.map +1 -1
  45. package/build/configure.js +37 -1
  46. package/build/configure.js.map +1 -1
  47. package/build/index.d.ts +75 -7
  48. package/build/index.d.ts.map +1 -1
  49. package/build/index.js +67 -5
  50. package/build/index.js.map +1 -1
  51. package/build/providers/authz_provider.d.ts +26 -2
  52. package/build/providers/authz_provider.d.ts.map +1 -1
  53. package/build/providers/authz_provider.js +48 -2
  54. package/build/providers/authz_provider.js.map +1 -1
  55. package/build/services/relations.d.ts +14 -0
  56. package/build/services/relations.d.ts.map +1 -0
  57. package/build/services/relations.js +17 -0
  58. package/build/services/relations.js.map +1 -0
  59. package/build/src/catalog/catalog.d.ts +289 -0
  60. package/build/src/catalog/catalog.d.ts.map +1 -0
  61. package/build/src/catalog/catalog.js +859 -0
  62. package/build/src/catalog/catalog.js.map +1 -0
  63. package/build/src/catalog/catalog_cache.d.ts +324 -0
  64. package/build/src/catalog/catalog_cache.d.ts.map +1 -0
  65. package/build/src/catalog/catalog_cache.js +666 -0
  66. package/build/src/catalog/catalog_cache.js.map +1 -0
  67. package/build/src/clock.d.ts +24 -0
  68. package/build/src/clock.d.ts.map +1 -0
  69. package/build/src/clock.js +7 -0
  70. package/build/src/clock.js.map +1 -0
  71. package/build/src/define_config.d.ts +201 -7
  72. package/build/src/define_config.d.ts.map +1 -1
  73. package/build/src/define_config.js.map +1 -1
  74. package/build/src/drivers/database_driver.d.ts +324 -15
  75. package/build/src/drivers/database_driver.d.ts.map +1 -1
  76. package/build/src/drivers/database_driver.js +1107 -128
  77. package/build/src/drivers/database_driver.js.map +1 -1
  78. package/build/src/drivers/database_relations_driver.d.ts +75 -0
  79. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  80. package/build/src/drivers/database_relations_driver.js +450 -0
  81. package/build/src/drivers/database_relations_driver.js.map +1 -0
  82. package/build/src/drivers/openfga_driver.d.ts +963 -55
  83. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  84. package/build/src/drivers/openfga_driver.js +2693 -372
  85. package/build/src/drivers/openfga_driver.js.map +1 -1
  86. package/build/src/drivers/openfga_facts.d.ts +369 -0
  87. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  88. package/build/src/drivers/openfga_facts.js +813 -0
  89. package/build/src/drivers/openfga_facts.js.map +1 -0
  90. package/build/src/drivers/openfga_relations_driver.d.ts +120 -0
  91. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  92. package/build/src/drivers/openfga_relations_driver.js +466 -0
  93. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  94. package/build/src/errors.d.ts +586 -0
  95. package/build/src/errors.d.ts.map +1 -1
  96. package/build/src/errors.js +583 -0
  97. package/build/src/errors.js.map +1 -1
  98. package/build/src/expiry.d.ts +27 -0
  99. package/build/src/expiry.d.ts.map +1 -0
  100. package/build/src/expiry.js +50 -0
  101. package/build/src/expiry.js.map +1 -0
  102. package/build/src/freeze.d.ts +120 -0
  103. package/build/src/freeze.d.ts.map +1 -0
  104. package/build/src/freeze.js +172 -0
  105. package/build/src/freeze.js.map +1 -0
  106. package/build/src/hierarchical_resolver.d.ts +56 -0
  107. package/build/src/hierarchical_resolver.d.ts.map +1 -0
  108. package/build/src/hierarchical_resolver.js +87 -0
  109. package/build/src/hierarchical_resolver.js.map +1 -0
  110. package/build/src/{middleware → http}/app_access_middleware.d.ts +8 -6
  111. package/build/src/http/app_access_middleware.d.ts.map +1 -0
  112. package/build/src/{middleware → http}/app_access_middleware.js +10 -26
  113. package/build/src/http/app_access_middleware.js.map +1 -0
  114. package/build/src/http/resource_access_middleware.d.ts +105 -0
  115. package/build/src/http/resource_access_middleware.d.ts.map +1 -0
  116. package/build/src/http/resource_access_middleware.js +81 -0
  117. package/build/src/http/resource_access_middleware.js.map +1 -0
  118. package/build/src/identity.d.ts +228 -0
  119. package/build/src/identity.d.ts.map +1 -0
  120. package/build/src/identity.js +457 -0
  121. package/build/src/identity.js.map +1 -0
  122. package/build/src/manager.d.ts +536 -7
  123. package/build/src/manager.d.ts.map +1 -1
  124. package/build/src/manager.js +2676 -23
  125. package/build/src/manager.js.map +1 -1
  126. package/build/src/memoize_ancestors.d.ts +23 -0
  127. package/build/src/memoize_ancestors.d.ts.map +1 -0
  128. package/build/src/memoize_ancestors.js +42 -0
  129. package/build/src/memoize_ancestors.js.map +1 -0
  130. package/build/src/models/authz_assignment.d.ts +11 -11
  131. package/build/src/models/authz_assignment.d.ts.map +1 -1
  132. package/build/src/models/authz_deny.d.ts +11 -11
  133. package/build/src/models/authz_deny.d.ts.map +1 -1
  134. package/build/src/models/authz_permission.d.ts +17 -11
  135. package/build/src/models/authz_permission.d.ts.map +1 -1
  136. package/build/src/models/authz_permission.js +4 -0
  137. package/build/src/models/authz_permission.js.map +1 -1
  138. package/build/src/models/authz_role.d.ts +19 -12
  139. package/build/src/models/authz_role.d.ts.map +1 -1
  140. package/build/src/models/authz_role.js +6 -1
  141. package/build/src/models/authz_role.js.map +1 -1
  142. package/build/src/models/authz_role_permission.d.ts +11 -11
  143. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  144. package/build/src/openfga.d.ts +29 -0
  145. package/build/src/openfga.d.ts.map +1 -0
  146. package/build/src/openfga.js +26 -0
  147. package/build/src/openfga.js.map +1 -0
  148. package/build/src/reconcile.d.ts +37 -0
  149. package/build/src/reconcile.d.ts.map +1 -0
  150. package/build/src/reconcile.js +69 -0
  151. package/build/src/reconcile.js.map +1 -0
  152. package/build/src/relation_partition_trigger.d.ts +8 -0
  153. package/build/src/relation_partition_trigger.d.ts.map +1 -0
  154. package/build/src/relation_partition_trigger.js +85 -0
  155. package/build/src/relation_partition_trigger.js.map +1 -0
  156. package/build/src/relations/define_relations_config.d.ts +58 -0
  157. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  158. package/build/src/relations/define_relations_config.js +144 -0
  159. package/build/src/relations/define_relations_config.js.map +1 -0
  160. package/build/src/relations/manager.d.ts +38 -0
  161. package/build/src/relations/manager.d.ts.map +1 -0
  162. package/build/src/relations/manager.js +156 -0
  163. package/build/src/relations/manager.js.map +1 -0
  164. package/build/src/relations/reconcile.d.ts +62 -0
  165. package/build/src/relations/reconcile.d.ts.map +1 -0
  166. package/build/src/relations/reconcile.js +138 -0
  167. package/build/src/relations/reconcile.js.map +1 -0
  168. package/build/src/relations_config_store.d.ts +22 -0
  169. package/build/src/relations_config_store.d.ts.map +1 -0
  170. package/build/src/relations_config_store.js +74 -0
  171. package/build/src/relations_config_store.js.map +1 -0
  172. package/build/src/scope_outbox.d.ts +69 -0
  173. package/build/src/scope_outbox.d.ts.map +1 -0
  174. package/build/src/scope_outbox.js +291 -0
  175. package/build/src/scope_outbox.js.map +1 -0
  176. package/build/src/shared/backend_guard.d.ts +106 -0
  177. package/build/src/shared/backend_guard.d.ts.map +1 -0
  178. package/build/src/shared/backend_guard.js +246 -0
  179. package/build/src/shared/backend_guard.js.map +1 -0
  180. package/build/src/shared/sql_expiry.d.ts +53 -0
  181. package/build/src/shared/sql_expiry.d.ts.map +1 -0
  182. package/build/src/shared/sql_expiry.js +66 -0
  183. package/build/src/shared/sql_expiry.js.map +1 -0
  184. package/build/src/sql_descendants.d.ts +97 -0
  185. package/build/src/sql_descendants.d.ts.map +1 -0
  186. package/build/src/sql_descendants.js +203 -0
  187. package/build/src/sql_descendants.js.map +1 -0
  188. package/build/src/testing/contract.d.ts +212 -7
  189. package/build/src/testing/contract.d.ts.map +1 -1
  190. package/build/src/testing/contract.js +3449 -24
  191. package/build/src/testing/contract.js.map +1 -1
  192. package/build/src/testing/main.d.ts +10 -2
  193. package/build/src/testing/main.d.ts.map +1 -1
  194. package/build/src/testing/main.js +5 -1
  195. package/build/src/testing/main.js.map +1 -1
  196. package/build/src/testing/migration_contract.d.ts +284 -0
  197. package/build/src/testing/migration_contract.d.ts.map +1 -0
  198. package/build/src/testing/migration_contract.js +586 -0
  199. package/build/src/testing/migration_contract.js.map +1 -0
  200. package/build/src/testing/relations_contract.d.ts +51 -0
  201. package/build/src/testing/relations_contract.d.ts.map +1 -0
  202. package/build/src/testing/relations_contract.js +654 -0
  203. package/build/src/testing/relations_contract.js.map +1 -0
  204. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  205. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  206. package/build/src/testing/relations_reconcile_contract.js +172 -0
  207. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  208. package/build/src/testing/scope_tree.d.ts +65 -0
  209. package/build/src/testing/scope_tree.d.ts.map +1 -0
  210. package/build/src/testing/scope_tree.js +145 -0
  211. package/build/src/testing/scope_tree.js.map +1 -0
  212. package/build/src/traits/authz_scopes.d.ts +30 -6
  213. package/build/src/traits/authz_scopes.d.ts.map +1 -1
  214. package/build/src/traits/authz_scopes.js +30 -18
  215. package/build/src/traits/authz_scopes.js.map +1 -1
  216. package/build/src/traits/has_uuid.d.ts +12 -12
  217. package/build/src/traits/has_uuid.d.ts.map +1 -1
  218. package/build/src/types.d.ts +1313 -28
  219. package/build/src/types.d.ts.map +1 -1
  220. package/build/src/types.js +20 -4
  221. package/build/src/types.js.map +1 -1
  222. package/build/stubs/config/app_acl.stub +4 -2
  223. package/build/stubs/config/authorization.stub +168 -14
  224. package/build/stubs/migration.stub +183 -13
  225. package/build/stubs/scopes_outbox_migration.stub +57 -0
  226. package/package.json +14 -7
  227. package/build/commands/openfga_import.d.ts +0 -28
  228. package/build/commands/openfga_import.d.ts.map +0 -1
  229. package/build/commands/openfga_import.js +0 -74
  230. package/build/commands/openfga_import.js.map +0 -1
  231. package/build/src/catalog.d.ts +0 -3
  232. package/build/src/catalog.d.ts.map +0 -1
  233. package/build/src/catalog.js +0 -103
  234. package/build/src/catalog.js.map +0 -1
  235. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  236. package/build/src/middleware/app_access_middleware.js.map +0 -1
@@ -1,7 +1,15 @@
1
1
  import db from '@adonisjs/lucid/services/db';
2
- import { Exception } from '@adonisjs/core/exceptions';
3
2
  import { v7 as uuidv7 } from 'uuid';
4
- import { APP_SCOPE } from '../types.js';
3
+ import { APP_SCOPE_TYPE } from '../types.js';
4
+ import { assertCatalogUuid, assertIdentity, assertScope, chainKeysFrom, normalizeRoleQuery } from '../identity.js';
5
+ import { resolveGrantExpiry, sameInstant } from '../expiry.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';
11
+ import { isClock, systemClock } from '../clock.js';
12
+ import { sqlExpiryCodec } from '../shared/sql_expiry.js';
5
13
  /**
6
14
  * UUID centinela para el scope 'app' en las columnas scope_uuid (NOT NULL):
7
15
  * permite que el unique de assignments/denies cubra también el nivel app
@@ -10,11 +18,231 @@ import { APP_SCOPE } from '../types.js';
10
18
  */
11
19
  export const APP_SCOPE_DB_UUID = '00000000-0000-0000-0000-000000000000';
12
20
  function toDbScopeUuid(scope) {
21
+ // Defensa en profundidad (L0.10/L0.15): es el único punto donde el scope
22
+ // se serializa a columnas, así que aquí se garantiza que `app` no lleve
23
+ // uuid y que ningún otro tipo use el centinela de la raíz.
24
+ assertScope(scope);
13
25
  return scope.uuid ?? APP_SCOPE_DB_UUID;
14
26
  }
15
- 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) {
16
36
  return uuid === APP_SCOPE_DB_UUID ? null : uuid;
17
37
  }
38
+ /**
39
+ * Clave de agrupación por scope de las filas de `authz_*` (3D · N5: UNA sola
40
+ * codificación en el driver). No es `scopeKey` de `identity.ts`: aquí el
41
+ * uuid es el de la COLUMNA (con el centinela de la raíz), porque es lo que
42
+ * devuelven las consultas. `\u001f` separa: ningún componente lo admite.
43
+ */
44
+ function dbScopeKey(scope) {
45
+ return `${scope.type}\u001f${toDbScopeUuid(scope)}`;
46
+ }
47
+ function rowScopeKey(row) {
48
+ return `${row.scope_type}\u001f${row.scope_uuid}`;
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
+ }
79
+ /**
80
+ * El rol `(slug, scopeType)` que existe en el scope cuya cadena empieza por
81
+ * `chainKeys`, o 422: `E_AUTHZ_ROLE_NOT_VISIBLE` si hay roles con ese nombre
82
+ * pero todos son locales a otro contenedor; `E_AUTHZ_UNKNOWN_ROLE` si no hay
83
+ * ninguno. Compartido por ambos drivers (3B · B2).
84
+ */
85
+ export function visibleRoleOrFail(catalog, slug, scope, chainKeys) {
86
+ const visible = catalog.roleVisible(slug, scope.type, chainKeys);
87
+ if (visible)
88
+ return visible;
89
+ const named = catalog.rolesNamed(slug, scope.type);
90
+ if (named.length === 0)
91
+ throw new UnknownRoleError(slug, scope.type);
92
+ // 3E · Q2 (auditor A6): NO se nombran los owners. Si ninguno es visible
93
+ // desde este scope, todos son locales a un contenedor que NO está en la
94
+ // cadena preguntada: imprimir sus claves regalaba a un tenant los
95
+ // identificadores de scope de otro (un 422 es lo que un framework devuelve
96
+ // tal cual al cliente). Lo que el llamante necesita saber —que el nombre
97
+ // existe pero no aquí— cabe sin ellos.
98
+ throw new RoleNotVisibleError(`El rol '${slug}' (nivel '${scope.type}') no existe en ${scope.type}:${scope.uuid ?? ''}: hay ${named.length} ` +
99
+ `${named.length === 1 ? 'rol' : 'roles'} con ese nombre en el catálogo, ${named.length === 1 ? 'local' : 'locales'} ` +
100
+ `a un scope que no está en la cadena de este. Un rol local solo se asigna en su owner o en sus descendientes.`);
101
+ }
102
+ /**
103
+ * EL rol al que apunta un `RoleQuery` en un scope concreto (3D · M1), para
104
+ * las rutas que direccionan un rol exacto (`grant`, `listSubjects`):
105
+ *
106
+ * - `{ uuid }`: resolución EXACTA, sin ambigüedad posible. Fuera del
107
+ * catálogo ⇒ 422 `E_AUTHZ_UNKNOWN_ROLE`; declarado para otro nivel o con
108
+ * el owner fuera de la cadena ⇒ 422 `E_AUTHZ_ROLE_NOT_VISIBLE` (no existe
109
+ * AQUÍ, que es lo mismo que decía la ruta por slug).
110
+ * - slug (con nivel opcional): `visibleRoleOrFail`, que ahora falla cerrado
111
+ * si hay más de un homónimo visible (422 `E_AUTHZ_AMBIGUOUS_ROLE`).
112
+ *
113
+ * Compartido por ambos drivers.
114
+ */
115
+ export function resolveRoleQuery(catalog, role, scope, chainKeys) {
116
+ const query = normalizeRoleQuery(role);
117
+ if (query.uuid !== undefined) {
118
+ const declared = catalog.roleByUuid(query.uuid);
119
+ if (!declared)
120
+ throw new UnknownRoleError(query.uuid);
121
+ if (declared.scopeType !== scope.type || !isRoleVisibleWith(declared, chainKeys)) {
122
+ // 3E · Q2: el uuid de un rol de OTRO árbol no devuelve su slug ni su
123
+ // owner (sería una sonda: pruebo uuids y aprendo el catálogo ajeno).
124
+ // Cuando el owner SÍ está en la cadena, el rol es del llamante y
125
+ // nombrarlo le dice exactamente qué pasa (nivel equivocado).
126
+ throw new RoleNotVisibleError(isRoleVisibleWith(declared, chainKeys)
127
+ ? `El rol '${declared.slug}' (${declared.uuid}, owner ${declared.owner}) está declarado para el nivel ` +
128
+ `'${declared.scopeType}' y no existe en ${scope.type}:${scope.uuid ?? ''}.`
129
+ : `El rol ${declared.uuid} no existe en ${scope.type}:${scope.uuid ?? ''}: es local a un scope que no está ` +
130
+ `en la cadena de ese scope.`);
131
+ }
132
+ return declared;
133
+ }
134
+ if (query.scopeType !== undefined && query.scopeType !== scope.type) {
135
+ // `{ slug, scopeType }` apunta a un rol de OTRO nivel: en un scope de tipo
136
+ // `scope.type` ese rol no existe (un rol vive en su nivel, D5/L0.6).
137
+ throw new UnknownRoleError(query.slug, scope.type);
138
+ }
139
+ return visibleRoleOrFail(catalog, query.slug, scope, chainKeys);
140
+ }
141
+ /**
142
+ * EL rol al que apunta un `RoleQuery` en un scope, para el camino de LECTURA
143
+ * (`listSubjects`): `null` si el catálogo no declara ninguno que exista ahí
144
+ * (invariante 5: desconocido no lanza). La AMBIGÜEDAD sí lanza (3D · M1):
145
+ * elegir uno de dos homónimos era enumerar los holders del otro tenant.
146
+ */
147
+ export function visibleRoleFor(catalog, query, scope, chainKeys) {
148
+ if (query.uuid !== undefined) {
149
+ const declared = catalog.roleByUuid(query.uuid);
150
+ if (!declared || declared.scopeType !== scope.type || !isRoleVisibleWith(declared, chainKeys))
151
+ return null;
152
+ return declared;
153
+ }
154
+ if (query.scopeType !== undefined && query.scopeType !== scope.type)
155
+ return null;
156
+ return catalog.roleVisible(query.slug, scope.type, chainKeys);
157
+ }
158
+ /**
159
+ * Los roles a los que un `RoleQuery` puede referirse en un scope, para
160
+ * `revoke` (3D · M1): por uuid, ESE rol (422 si el catálogo no lo declara);
161
+ * por slug, TODOS los homónimos `(slug, nivel)` — quitar nunca concede y el
162
+ * scope puede no existir ya para el árbol (D8), así que no se resuelve la
163
+ * ambigüedad: se quitan todos. 422 `E_AUTHZ_UNKNOWN_ROLE` si no hay ninguno.
164
+ */
165
+ export function rolesToRevoke(catalog, role, scope) {
166
+ const query = normalizeRoleQuery(role);
167
+ if (query.uuid !== undefined) {
168
+ const declared = catalog.roleByUuid(query.uuid);
169
+ if (!declared)
170
+ throw new UnknownRoleError(query.uuid);
171
+ return [declared];
172
+ }
173
+ const level = query.scopeType ?? scope.type;
174
+ const named = catalog.rolesNamed(query.slug, level);
175
+ if (named.length === 0)
176
+ throw new UnknownRoleError(query.slug, level);
177
+ return named;
178
+ }
179
+ /**
180
+ * Los niveles de la cadena donde una asignación de ESE rol contaría, para
181
+ * `hasRole` (3D · M1): los scopes del tipo del rol desde los que el rol es
182
+ * visible por owner. `null` = el catálogo no declara nada que responda (la
183
+ * membresía es `false`, nunca un throw: invariante 5).
184
+ */
185
+ export function hasRoleTargets(catalog, role, chain) {
186
+ const query = normalizeRoleQuery(role);
187
+ const keysFrom = chainKeysFrom(chain);
188
+ if (query.uuid !== undefined) {
189
+ const declared = catalog.roleByUuid(query.uuid);
190
+ if (!declared)
191
+ return [];
192
+ return chain.flatMap((s, i) => s.type === declared.scopeType && isRoleVisibleWith(declared, keysFrom[i]) ? [{ scope: s, roleUuid: declared.uuid }] : []);
193
+ }
194
+ return chain.flatMap((s, i) => {
195
+ if (query.scopeType !== undefined && s.type !== query.scopeType)
196
+ return [];
197
+ const declared = catalog.roleVisible(query.slug, s.type, keysFrom[i]);
198
+ return declared ? [{ scope: s, roleUuid: declared.uuid }] : [];
199
+ });
200
+ }
201
+ /**
202
+ * Control de COMPOSICIÓN en la escritura (3B · B5, defensa en profundidad):
203
+ * un rol de nivel L que lleve un permiso cuyo `assignableAt` no incluye L no
204
+ * se asigna (422 `E_AUTHZ_ROLE_NOT_ASSIGNABLE_AT`). El sync y
205
+ * `defineScopedRole` ya lo rechazan al componer; aquí se cierra el vínculo
206
+ * escrito a mano. `authorize` NUNCA lo mira: lo ya asignado sigue
207
+ * concediendo (invariante 1).
208
+ */
209
+ export function assertRoleAssignableAt(catalog, role) {
210
+ // La regla es UNA (3D · N5): la misma que aplican `syncAuthzCatalog` y
211
+ // `defineScopedRole` al componer (`assertAssignableAt`, `catalog.ts`).
212
+ for (const permission of catalog.rolePermissionsOf(role.uuid)) {
213
+ assertAssignableAt(role, permission, catalog.permission(permission)?.assignableAt ?? null);
214
+ }
215
+ }
216
+ /**
217
+ * EL rol de un uuid, si EXISTE en un scope de nivel `scopeType` cuya cadena
218
+ * tiene esas claves: declarado para ese nivel (D5/L0.6) y global o con el
219
+ * owner en la cadena desde ahí (3B · B2). `null` si no. Es la regla de
220
+ * visibilidad por uuid, en UN sitio (3D · N5): la usan `listRoles`,
221
+ * `rolesInChain`, `listRoleScopes`/`listScopes` (`visibleAt`) en `database`
222
+ * y `declaredRole` en `openfga`.
223
+ */
224
+ export function declaredRoleAt(catalog, roleUuid, scopeType, chainKeys) {
225
+ const declared = catalog.roleByUuid(roleUuid);
226
+ if (!declared || declared.scopeType !== scopeType)
227
+ return null;
228
+ return isRoleVisibleWith(declared, chainKeys) ? declared : null;
229
+ }
230
+ export function whereScopeIn(query, column, scopes, intent) {
231
+ if (scopes.length === 0) {
232
+ if (intent === 'write') {
233
+ throw new AuthorizationInternalError(`whereScopeIn: una escritura sin scopes no tiene destino (columna '${column}')`);
234
+ }
235
+ return null;
236
+ }
237
+ return query.where((outer) => {
238
+ for (const s of scopes) {
239
+ outer.orWhere((inner) => {
240
+ inner.where(`${column}_type`, s.type).where(`${column}_uuid`, toDbScopeUuid(s));
241
+ });
242
+ }
243
+ });
244
+ }
245
+ export const DEFAULT_TIMEOUT_MS = 5_000;
18
246
  /**
19
247
  * Driver `database`: motor de autorización propio sobre las tablas `authz_*`.
20
248
  * Default autosuficiente del chasis (cero piezas extra en el appliance).
@@ -24,237 +252,988 @@ function fromDbScopeUuid(uuid) {
24
252
  * permiso vía su rol. La cadena es `[scope, ...ancestros]` — herencia solo
25
253
  * hacia abajo. Solo SQL estándar vía query builder (regla M6: nada
26
254
  * específico de un motor).
255
+ *
256
+ * Toda consulta pasa por `sql()`: un fallo del cliente SQL (conexión, tabla,
257
+ * deadline) sale como `AuthorizationBackendError` 503, nunca como el error
258
+ * crudo de knex (L0.11).
27
259
  */
28
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
+ });
29
286
  /**
30
287
  * Resolutor de jerarquía inyectado por el consumidor (el chasis pasa el
31
288
  * suyo, que conoce organizations/units, en `config/authorization.ts`).
32
- * Default puro sin dominio: todo cuelga directamente de `app`.
289
+ * Sin él, el driver solo conoce la raíz (L0.3: ya no hay default plano).
33
290
  */
34
- resolveAncestors;
291
+ chainResolver;
292
+ timeoutMs;
293
+ /** Reloj de pared del driver (J1): el ÚNICO `now` de todas sus DECISIONES temporales (no de los sellos, K5). */
294
+ now;
295
+ /**
296
+ * Cómo viaja `expires_at` con este motor (2.5-B · K2): cadena UTC explícita
297
+ * en MySQL (sin depender de `timezone`/`TZ`), identidad en el resto. Se
298
+ * decide por dialecto en el primer uso (la conexión puede no estar lista al
299
+ * construir el driver).
300
+ */
301
+ expiryCodec = null;
302
+ /**
303
+ * Memo del catálogo (2A): `findPermission`/`findRole` leen de aquí; los
304
+ * hechos (asignaciones, denies y el join con los vínculos) siguen en SQL en
305
+ * cada pregunta. Se revalida contra `authz_catalog_version` (2D · F1);
306
+ * `catalog.invalidate()` fuerza la recarga de ESTE memo.
307
+ */
308
+ catalog;
35
309
  constructor(options = {}) {
36
- this.resolveAncestors =
37
- options.resolveAncestors ?? (async (scope) => (scope.type === 'app' ? [] : [APP_SCOPE]));
310
+ this.chainResolver = options.resolveChain ?? rootOnlyResolver;
311
+ this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
312
+ if (options.now !== undefined && !isClock(options.now)) {
313
+ throw new AuthorizationConfigError(`DatabaseAuthorizationDriver: 'now' debe ser una función () => Date (llegó ${typeof options.now})`);
314
+ }
315
+ this.now = options.now ?? systemClock;
316
+ assertCatalogOptions('DatabaseAuthorizationDriver', options);
317
+ this.catalog =
318
+ options.catalog ??
319
+ new CatalogCache({ driver: 'database', timeoutMs: this.timeoutMs, revalidate: options.catalogRevalidate });
38
320
  }
39
- async chain(scope) {
40
- return [scope, ...(await this.resolveAncestors(scope))];
321
+ /**
322
+ * Vista de este driver con OTRO resolutor de la cadena y el mismo estado
323
+ * (conexión, memo del catálogo, deadline). Es lo que usa
324
+ * `AuthorizationManager.forRequest()` para leer con un resolutor memoizado
325
+ * sin tocar el driver compartido: el objeto devuelto hereda del original
326
+ * por prototipo y solo sobrescribe el resolutor.
327
+ */
328
+ withChainResolver(resolveChain) {
329
+ const view = Object.create(this);
330
+ view.chainResolver = resolveChain;
331
+ return view;
41
332
  }
42
- /** Filtro (scope_type, scope_uuid) ∈ scopes (centinela para el nivel app). */
43
- whereScopeIn(query, column, scopes) {
44
- return query.where((outer) => {
45
- for (const s of scopes) {
46
- outer.orWhere((inner) => {
47
- inner.where(`${column}_type`, s.type).where(`${column}_uuid`, toDbScopeUuid(s));
48
- });
49
- }
50
- });
333
+ /**
334
+ * Vista de este driver con OTRO reloj de pared (2.5 · J1) y el mismo estado
335
+ * (conexión, memo, deadline, resolutor). Es lo que aplica el manager con
336
+ * `config.clock` y lo que el juez usa para fijar el instante. Misma
337
+ * técnica que `withChainResolver`: herencia por prototipo, un campo.
338
+ */
339
+ withClock(now) {
340
+ if (!isClock(now)) {
341
+ throw new AuthorizationConfigError(`withClock: now debe ser una función () => Date (llegó ${typeof now})`);
342
+ }
343
+ const view = Object.create(this);
344
+ view.now = now;
345
+ return view;
346
+ }
347
+ /**
348
+ * `[scope canónico, ...ancestros]`, o `null` si el scope no existe
349
+ * (lecturas: denegar). `chain[0]` —la fila del consumidor, no lo que
350
+ * escribió el llamante— es la identidad con la que se leen y escriben los
351
+ * hechos de ese scope (2.5-B · K1): un alias del uuid que el árbol funde
352
+ * con la fila real (tipo `uuid` de PG, collation `*_ci`) llega aquí ya
353
+ * canónico y el deny escrito canónico casa.
354
+ */
355
+ chain(scope, operation) {
356
+ return resolveChain(this.chainResolver, scope, operation);
357
+ }
358
+ /** La cadena o 422: una escritura no puede ir a un scope que nadie reconoce. */
359
+ knownScope(scope, operation) {
360
+ return assertKnownScope(this.chainResolver, scope, operation);
361
+ }
362
+ /** El scope canónico para `purgeScope` (ver `canonicalScope`). */
363
+ canonicalOrSelf(scope, operation) {
364
+ return canonicalScope(this.chainResolver, scope, operation);
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);
51
369
  }
52
- /** Asignación vigente: sin expiración o con expiración futura. */
370
+ /**
371
+ * Ejecuta una consulta clasificando su fallo (503 con código propio). `fn`
372
+ * devuelve el BUILDER sin ejecutar: el guard le fija el deadline y luego lo
373
+ * ejecuta. Por eso no se usa `.first()` de Lucid (ejecuta al instante, sin
374
+ * dejar poner el timeout): `first()` de abajo hace `limit(1)` y toma la fila.
375
+ */
376
+ sql(operation, fn) {
377
+ return guardSql('database', operation, this.timeoutMs, fn);
378
+ }
379
+ async first(operation, fn) {
380
+ const rows = await this.sql(operation, () => fn().limit(1));
381
+ return rows[0] ?? null;
382
+ }
383
+ get expiry() {
384
+ // Decidir el dialecto no cuesta ninguna consulta: una vista por
385
+ // prototipo (`withClock`/`withChainResolver`) hereda el codec del driver
386
+ // si ya lo tiene y, si no, lo decide una vez para sí.
387
+ this.expiryCodec ??= sqlExpiryCodec(db.connection());
388
+ return this.expiryCodec;
389
+ }
390
+ /** Asignación vigente: sin expiración o con expiración futura (estricta: la que vence AHORA ya no cuenta). */
53
391
  whereActive(query, column = 'expires_at') {
392
+ const now = this.expiry.bind(this.now());
54
393
  return query.where((builder) => {
55
- builder.whereNull(column).orWhere(column, '>', new Date());
394
+ builder.whereNull(column).orWhere(column, '>', now);
56
395
  });
57
396
  }
397
+ // Catálogo desde el memo (2A): una carga por proceso/driver, no una
398
+ // consulta por pregunta. Un fallo de carga sale como 503, igual que antes.
58
399
  async findPermission(slug) {
59
- return db.from('authz_permissions').where('slug', slug).select('uuid').first();
60
- }
61
- async findRoleOrFail(slug, scopeType) {
62
- const role = await db
63
- .from('authz_roles')
64
- .where('slug', slug)
65
- .where('scope_type', scopeType)
66
- .select('uuid')
67
- .first();
68
- if (!role) {
69
- throw new Exception(`Rol '${slug}' no existe en el catálogo para el nivel '${scopeType}'`, {
70
- status: 422,
400
+ return (await this.catalog.view()).permission(slug);
401
+ }
402
+ /**
403
+ * Filtro «asignación VISIBLE en algún nivel de la cadena» (3B · B2), en
404
+ * SQL, sobre `authz_assignments as a` unida a `authz_roles as r`: por cada
405
+ * nivel `i`, el scope exacto Y el rol declarado para ese nivel Y (global O
406
+ * owner en `chain.slice(i)`). Sustituye a `whereScopeIn(chain)` en la
407
+ * consulta de `authorize`: mismo número de consultas, ahora con el owner
408
+ * decidido en la base (`owner_scope_key IN (...) OR 'global'`). La cadena
409
+ * viene validada (nunca vacía); L0.1 se conserva por si acaso.
410
+ */
411
+ whereVisibleAssignmentIn(query, chain) {
412
+ if (chain.length === 0)
413
+ return null;
414
+ const keysFrom = chainKeysFrom(chain);
415
+ return query.where((outer) => {
416
+ chain.forEach((s, i) => {
417
+ outer.orWhere((level) => {
418
+ level
419
+ .where('a.scope_type', s.type)
420
+ .where('a.scope_uuid', toDbScopeUuid(s))
421
+ .where('r.scope_type', s.type)
422
+ .where((owner) => {
423
+ owner.where('r.owner_scope_key', GLOBAL_OWNER_KEY).orWhereIn('r.owner_scope_key', keysFrom[i]);
424
+ });
425
+ });
71
426
  });
72
- }
73
- return role;
427
+ });
74
428
  }
75
429
  async authorize(subject, permission, scope) {
430
+ assertIdentity({ subject, permission, scope });
76
431
  const perm = await this.findPermission(permission);
77
432
  if (!perm)
78
433
  return false;
79
- const chain = await this.chain(scope);
80
- const denied = await this.whereScopeIn(db
434
+ const chain = await this.chain(scope, 'authorize');
435
+ if (!chain)
436
+ return false;
437
+ const deniedQuery = whereScopeIn(db
81
438
  .from('authz_denies')
82
439
  .where('holder_type', subject.type)
83
440
  .where('holder_uuid', subject.uuid)
84
- .where('permission_uuid', perm.uuid), 'scope', chain).first();
85
- if (denied)
441
+ .where('permission_uuid', perm.uuid), 'scope', chain, 'read');
442
+ if (!deniedQuery)
86
443
  return false;
87
- const granted = await this.whereActive(this.whereScopeIn(db
444
+ if (await this.first('authorize.denies', () => deniedQuery))
445
+ return false;
446
+ // Una asignación vigente, en la cadena, cuyo rol concede el permiso Y es
447
+ // visible en su nivel (global u owner ancestro-o-igual del scope de la
448
+ // asignación, 3B · B2). `assignableAt`/`rank` no entran: composición.
449
+ const grantedQuery = this.whereVisibleAssignmentIn(db
88
450
  .from('authz_assignments as a')
89
451
  .join('authz_role_permissions as rp', 'rp.role_uuid', 'a.role_uuid')
452
+ .join('authz_roles as r', 'r.uuid', 'a.role_uuid')
90
453
  .where('rp.permission_uuid', perm.uuid)
91
454
  .where('a.holder_type', subject.type)
92
- .where('a.holder_uuid', subject.uuid), 'a.scope', chain), 'a.expires_at').first();
455
+ .where('a.holder_uuid', subject.uuid), chain);
456
+ if (!grantedQuery)
457
+ return false;
458
+ const granted = await this.first('authorize.assignments', () => this.whereActive(grantedQuery, 'a.expires_at'));
93
459
  return Boolean(granted);
94
460
  }
95
461
  async grant(subject, role, scope, options = {}) {
96
- const { uuid: roleUuid } = await this.findRoleOrFail(role, scope.type);
97
- const expiresAt = options.expiresAt ?? null;
98
- const existing = await this.whereScopeIn(db
462
+ assertIdentity({ subject, role, scope, expiresAt: options.expiresAt });
463
+ // Se escribe bajo la identidad canónica del árbol (K1), nunca bajo la
464
+ // forma del llamante; y el rol tiene que EXISTIR en ese scope (3B · B2:
465
+ // global, o local a un ancestro-o-igual), con una composición legal (B5).
466
+ const chain = await this.knownScope(scope, 'grant');
467
+ const [target] = chain;
468
+ const catalog = await this.catalog.view();
469
+ const declared = resolveRoleQuery(catalog, role, target, chainKeysFrom(chain)[0]);
470
+ assertRoleAssignableAt(catalog, declared);
471
+ const roleUuid = declared.uuid;
472
+ const findExisting = () => whereScopeIn(db
99
473
  .from('authz_assignments')
100
474
  .where('holder_type', subject.type)
101
475
  .where('holder_uuid', subject.uuid)
102
- .where('role_uuid', roleUuid), 'scope', [scope]).first();
103
- if (existing) {
104
- // Idempotente: re-grant refresca la expiración (revivir una asignación
105
- // expirada es un grant nuevo a todos los efectos).
106
- await db.from('authz_assignments').where('uuid', existing.uuid).update({
107
- expires_at: expiresAt,
108
- });
109
- return;
110
- }
111
- try {
112
- await db.table('authz_assignments').insert({
113
- uuid: uuidv7(),
114
- holder_type: subject.type,
115
- holder_uuid: subject.uuid,
116
- role_uuid: roleUuid,
117
- scope_type: scope.type,
118
- scope_uuid: toDbScopeUuid(scope),
119
- expires_at: expiresAt,
120
- created_at: new Date(),
121
- });
476
+ .where('role_uuid', roleUuid), 'scope', [target], 'write').select('uuid', this.expiry.select('expires_at'));
477
+ // Dos carreras posibles, ambas acotadas (2.5-B · K4): la fila que se
478
+ // leyó desaparece antes del UPDATE (una purga concurrente) ⇒ se vuelve a
479
+ // empezar como inserción; el INSERT choca con el unique (otro grant
480
+ // concurrente) ⇒ se relee y se refresca lo del ganador. Ninguna deja un
481
+ // «hecho» que no está escrito. Más de tres vueltas es contención
482
+ // patológica: 503, nunca un bucle.
483
+ for (let attempt = 0; attempt < 3; attempt++) {
484
+ const existing = await this.first('grant.find', findExisting);
485
+ if (existing) {
486
+ const refreshed = await this.refreshAssignment(existing, options.expiresAt);
487
+ if (refreshed)
488
+ return refreshed;
489
+ continue;
490
+ }
491
+ // No había nada: la caducidad es la pedida, o ninguna.
492
+ const expiresAt = options.expiresAt ?? null;
493
+ try {
494
+ await this.sql('grant.insert', () => db.table('authz_assignments').insert({
495
+ uuid: uuidv7(),
496
+ holder_type: subject.type,
497
+ holder_uuid: subject.uuid,
498
+ role_uuid: roleUuid,
499
+ scope_type: target.type,
500
+ scope_uuid: toDbScopeUuid(target),
501
+ expires_at: this.expiry.toDb(expiresAt),
502
+ // Sello de auditoría, no decisión (2.5-B · K5): reloj del sistema.
503
+ created_at: systemClock(),
504
+ }));
505
+ return { existed: false, expiresAt };
506
+ }
507
+ catch (error) {
508
+ // Carrera check-then-insert: el unique (que cubre también el nivel app
509
+ // vía centinela) la detecta — el perdedor degrada a re-grant sobre lo
510
+ // que escribió el ganador (con la misma semántica de tres estados).
511
+ // Si no era una carrera, el fallo del insert (ya clasificado) se propaga.
512
+ const raced = await this.first('grant.race', findExisting);
513
+ if (!raced)
514
+ throw error;
515
+ const refreshed = await this.refreshAssignment(raced, options.expiresAt);
516
+ if (refreshed)
517
+ return refreshed;
518
+ }
122
519
  }
123
- catch (error) {
124
- // Carrera check-then-insert: el unique (que cubre también el nivel app
125
- // vía centinela) la detecta — el perdedor degrada a update (idempotente).
126
- const raced = await this.whereScopeIn(db
127
- .from('authz_assignments')
128
- .where('holder_type', subject.type)
129
- .where('holder_uuid', subject.uuid)
130
- .where('role_uuid', roleUuid), 'scope', [scope]).first();
131
- if (!raced)
132
- throw error;
133
- await db.from('authz_assignments').where('uuid', raced.uuid).update({
134
- expires_at: expiresAt,
135
- });
520
+ throw new AuthorizationBackendError('database', 'grant', new Error(`la asignación de ${subject.type}:${subject.uuid} aparece y desaparece entre lecturas (contención); no se pudo dejar escrita`));
521
+ }
522
+ /**
523
+ * Re-grant sobre una asignación existente (L0.4): omitido preserva una
524
+ * caducidad vigente (o revive una expirada sin caducidad), `null` la quita,
525
+ * `Date` la fija. Solo se escribe si la caducidad cambia de verdad; si el
526
+ * UPDATE no toca ninguna fila (otro proceso la borró entre la lectura y la
527
+ * escritura, K4) devuelve `null`: no hay «existed: true» sobre nada.
528
+ */
529
+ async refreshAssignment(row, requested) {
530
+ const previous = this.expiry.fromDb(row.expires_at);
531
+ const expiresAt = resolveGrantExpiry(previous, requested, this.now());
532
+ if (!sameInstant(previous, expiresAt)) {
533
+ const updated = await this.sql('grant.update', () => db.from('authz_assignments').where('uuid', row.uuid).update({ expires_at: this.expiry.toDb(expiresAt) }));
534
+ if (Number(Array.isArray(updated) ? updated[0] : updated) === 0)
535
+ return null;
136
536
  }
537
+ return { existed: true, previousExpiresAt: previous, expiresAt };
137
538
  }
138
539
  async revoke(subject, role, scope) {
139
- const roleRow = await db
140
- .from('authz_roles')
141
- .where('slug', role)
142
- .where('scope_type', scope.type)
143
- .select('uuid')
144
- .first();
145
- if (!roleRow)
146
- return;
147
- await this.whereScopeIn(db
540
+ assertIdentity({ subject, role, scope });
541
+ // Rol fuera del catálogo para ese nivel ⇒ 422, como en `grant` (D10). El
542
+ // no-op es para una asignación inexistente de un rol válido. Se quitan
543
+ // los hechos de TODOS los roles con ese nombre en el scope exacto (3B):
544
+ // a lo sumo uno es visible ahí y los demás serían filas huérfanas; el
545
+ // scope puede no existir ya para el árbol (D8), así que no se filtra por
546
+ // visibilidad — quitar nunca concede.
547
+ const named = rolesToRevoke(await this.catalog.view(), role, scope);
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');
553
+ await this.sql('revoke', () => whereScopeIn(db
148
554
  .from('authz_assignments')
149
555
  .where('holder_type', subject.type)
150
556
  .where('holder_uuid', subject.uuid)
151
- .where('role_uuid', roleRow.uuid), 'scope', [scope]).delete();
557
+ .whereIn('role_uuid', named.map((r) => r.uuid)), 'scope', targets, 'write').delete());
152
558
  }
153
559
  async hasRole(subject, role, scope) {
154
- const chain = await this.chain(scope);
155
- const found = await this.whereActive(this.whereScopeIn(db
560
+ assertIdentity({ subject, role, scope });
561
+ const catalog = await this.catalog.view();
562
+ const chain = await this.chain(scope, 'hasRole');
563
+ if (!chain)
564
+ return false;
565
+ // Identidad por uuid (3A · A2): en cada nivel de la cadena cuenta SOLO el
566
+ // rol que el catálogo declara con ese slug para el tipo de ESE nivel
567
+ // (L0.6) —con `{ slug, scopeType }` se recortan los niveles a ese tipo—
568
+ // y la asignación se busca por `(scope, role_uuid)`, no por el slug de
569
+ // un join: dos roles con el mismo slug y owner distinto (3B) no se
570
+ // confunden. Sin rol declarado en ningún nivel no hay consulta que hacer.
571
+ const targets = hasRoleTargets(catalog, role, chain);
572
+ if (targets.length === 0)
573
+ return false;
574
+ const query = db
156
575
  .from('authz_assignments as a')
157
- .join('authz_roles as r', 'r.uuid', 'a.role_uuid')
158
- .where('r.slug', role)
159
576
  .where('a.holder_type', subject.type)
160
- .where('a.holder_uuid', subject.uuid), 'a.scope', chain), 'a.expires_at').first();
577
+ .where('a.holder_uuid', subject.uuid)
578
+ .where((outer) => {
579
+ for (const { scope: s, roleUuid } of targets) {
580
+ outer.orWhere((inner) => {
581
+ inner.where('a.scope_type', s.type).where('a.scope_uuid', toDbScopeUuid(s)).where('a.role_uuid', roleUuid);
582
+ });
583
+ }
584
+ });
585
+ const found = await this.first('hasRole', () => this.whereActive(query, 'a.expires_at'));
161
586
  return Boolean(found);
162
587
  }
163
588
  async deny(subject, permission, scope) {
589
+ assertIdentity({ subject, permission, scope });
164
590
  const perm = await this.findPermission(permission);
165
- if (!perm) {
166
- throw new Exception(`Permiso '${permission}' no existe en el catálogo`, { status: 422 });
167
- }
168
- const existing = await this.whereScopeIn(db
591
+ if (!perm)
592
+ throw new UnknownPermissionError(permission);
593
+ const [target] = await this.knownScope(scope, 'deny');
594
+ const findExisting = () => whereScopeIn(db
169
595
  .from('authz_denies')
170
596
  .where('holder_type', subject.type)
171
597
  .where('holder_uuid', subject.uuid)
172
- .where('permission_uuid', perm.uuid), 'scope', [scope]).first();
598
+ .where('permission_uuid', perm.uuid), 'scope', [target], 'write');
599
+ const existing = await this.first('deny.find', findExisting);
173
600
  if (existing)
174
601
  return;
175
602
  try {
176
- await db.table('authz_denies').insert({
603
+ await this.sql('deny.insert', () => db.table('authz_denies').insert({
177
604
  uuid: uuidv7(),
178
605
  holder_type: subject.type,
179
606
  holder_uuid: subject.uuid,
180
607
  permission_uuid: perm.uuid,
181
- scope_type: scope.type,
182
- scope_uuid: toDbScopeUuid(scope),
183
- created_at: new Date(),
184
- });
608
+ scope_type: target.type,
609
+ scope_uuid: toDbScopeUuid(target),
610
+ created_at: systemClock(),
611
+ }));
185
612
  }
186
613
  catch (error) {
187
614
  // Carrera: si otro proceso insertó el mismo deny, el unique lo detecta
188
615
  // y el resultado deseado ya existe.
189
- const raced = await this.whereScopeIn(db
190
- .from('authz_denies')
191
- .where('holder_type', subject.type)
192
- .where('holder_uuid', subject.uuid)
193
- .where('permission_uuid', perm.uuid), 'scope', [scope]).first();
616
+ const raced = await this.first('deny.race', findExisting);
194
617
  if (!raced)
195
618
  throw error;
196
619
  }
197
620
  }
198
621
  async removeDeny(subject, permission, scope) {
622
+ assertIdentity({ subject, permission, scope });
199
623
  const perm = await this.findPermission(permission);
200
624
  if (!perm)
201
- return;
202
- await this.whereScopeIn(db
625
+ throw new UnknownPermissionError(permission);
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');
629
+ await this.sql('removeDeny', () => whereScopeIn(db
203
630
  .from('authz_denies')
204
631
  .where('holder_type', subject.type)
205
632
  .where('holder_uuid', subject.uuid)
206
- .where('permission_uuid', perm.uuid), 'scope', [scope]).delete();
633
+ .where('permission_uuid', perm.uuid), 'scope', targets, 'write').delete());
207
634
  }
208
635
  async listSubjects(role, scope) {
209
- const rows = await this.whereActive(this.whereScopeIn(db
210
- .from('authz_assignments as a')
211
- .join('authz_roles as r', 'r.uuid', 'a.role_uuid')
212
- .where('r.slug', role)
213
- .where('r.scope_type', scope.type), 'a.scope', [scope]), 'a.expires_at').distinct('a.holder_type', 'a.holder_uuid');
636
+ assertIdentity({ role, scope });
637
+ // Un rol que el catálogo no declara para ese nivel (en ningún owner) no
638
+ // tiene holders (D5): nada que leer, ni árbol ni hechos. Un scope que el
639
+ // árbol no conoce no existe para el motor (D8, K1): nada; uno que conoce
640
+ // se lee bajo su identidad canónica, y el rol tiene que existir AHÍ (3B ·
641
+ // B2: global o local a un ancestro-o-igual); se busca por su uuid (3A).
642
+ const catalog = await this.catalog.view();
643
+ const asked = normalizeRoleQuery(role);
644
+ // Atajo D5: nada que leer (ni árbol ni hechos) si el catálogo no declara
645
+ // NINGÚN rol que pueda responder — por uuid, si no existe; por slug, si
646
+ // nadie lo declara para ese nivel.
647
+ if (asked.uuid !== undefined ? catalog.roleByUuid(asked.uuid) === null : catalog.rolesNamed(asked.slug, asked.scopeType ?? scope.type).length === 0) {
648
+ return [];
649
+ }
650
+ const chain = await this.chain(scope, 'listSubjects');
651
+ if (!chain)
652
+ return [];
653
+ const declared = visibleRoleFor(catalog, asked, scope, chainKeysFrom(chain)[0]);
654
+ if (!declared)
655
+ return [];
656
+ const query = whereScopeIn(db.from('authz_assignments as a').where('a.role_uuid', declared.uuid), 'a.scope', [chain[0]], 'read');
657
+ if (!query)
658
+ return [];
659
+ const rows = await this.sql('listSubjects', () => this.whereActive(query, 'a.expires_at').distinct('a.holder_type', 'a.holder_uuid'));
214
660
  return rows.map((row) => ({ type: row.holder_type, uuid: row.holder_uuid }));
215
661
  }
216
662
  async listRoles(subject, scope) {
217
- const rows = await this.whereActive(this.whereScopeIn(db
663
+ assertIdentity({ subject, scope });
664
+ // Un scope que el árbol no conoce no existe para el motor (D8): nada.
665
+ const chain = await this.chain(scope, 'listRoles');
666
+ if (!chain)
667
+ return [];
668
+ const query = whereScopeIn(db
669
+ .from('authz_assignments as a')
670
+ .where('a.holder_type', subject.type)
671
+ .where('a.holder_uuid', subject.uuid), 'a.scope', [chain[0]], 'read');
672
+ if (!query)
673
+ return [];
674
+ const rows = await this.sql('listRoles', () => this.whereActive(query, 'a.expires_at').distinct('a.role_uuid'));
675
+ // Del uuid al slug por el memo (3A · A2): un rol retirado del catálogo no
676
+ // es membresía (D5), uno declarado para OTRO nivel tampoco, ni uno local
677
+ // a un scope que ya no está en la cadena (3B · B2: la unit se movió).
678
+ const catalog = await this.catalog.view();
679
+ const keys = chainKeysFrom(chain)[0];
680
+ const slugs = new Set();
681
+ for (const row of rows) {
682
+ const declared = declaredRoleAt(catalog, row.role_uuid, chain[0].type, keys);
683
+ if (declared)
684
+ slugs.add(declared.slug);
685
+ }
686
+ return [...slugs];
687
+ }
688
+ /**
689
+ * Roles directos vigentes del holder en cada scope de la cadena (2D · G5):
690
+ * UNA consulta con `whereScopeIn(chain)` en vez de un `listRoles` por
691
+ * nivel. El filtro por catálogo (D5) es el memo, por uuid (3A · A2): el
692
+ * rol tiene que existir y estar declarado para el nivel de la asignación.
693
+ * La cadena viene ya resuelta por el manager.
694
+ */
695
+ async rolesInChain(subject, chain) {
696
+ assertIdentity({ subject });
697
+ for (const scope of chain)
698
+ assertScope(scope);
699
+ const query = whereScopeIn(db
218
700
  .from('authz_assignments as a')
219
- .join('authz_roles as r', 'r.uuid', 'a.role_uuid')
220
701
  .where('a.holder_type', subject.type)
221
- .where('a.holder_uuid', subject.uuid), 'a.scope', [scope]), 'a.expires_at').distinct('r.slug');
222
- return rows.map((row) => row.slug);
702
+ .where('a.holder_uuid', subject.uuid), 'a.scope', chain, 'read');
703
+ if (!query)
704
+ return [];
705
+ const rows = await this.sql('rolesInChain', () => this.whereActive(query, 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid', 'a.role_uuid'));
706
+ const catalog = await this.catalog.view();
707
+ const keysFrom = chainKeysFrom(chain);
708
+ const levelOf = new Map(chain.map((s, i) => [dbScopeKey(s), i]));
709
+ const result = [];
710
+ const seen = new Set();
711
+ for (const row of rows) {
712
+ // Visible desde el nivel de la ASIGNACIÓN (3B · B2): owner global o en la cadena desde ahí.
713
+ const level = levelOf.get(rowScopeKey(row));
714
+ if (level === undefined)
715
+ continue;
716
+ const declared = declaredRoleAt(catalog, row.role_uuid, row.scope_type, keysFrom[level]);
717
+ if (!declared)
718
+ continue;
719
+ // Dedupe por IDENTIDAD (3D · M1: el uuid, no el slug): dos homónimos
720
+ // asignados en el mismo scope son dos roles y cuentan los dos.
721
+ const dedupe = `${rowScopeKey(row)}\u001f${declared.uuid}`;
722
+ if (seen.has(dedupe))
723
+ continue;
724
+ seen.add(dedupe);
725
+ result.push({ scope: { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) }, role: declared });
726
+ }
727
+ return result;
223
728
  }
224
729
  async listRoleScopes(subject, scopeType) {
225
- const rows = await this.whereActive(db
730
+ assertIdentity({ subject, scopeType });
731
+ const rows = await this.sql('listRoleScopes', () => this.whereActive(db
226
732
  .from('authz_assignments as a')
227
733
  .where('a.holder_type', subject.type)
228
734
  .where('a.holder_uuid', subject.uuid)
229
- .where('a.scope_type', scopeType), 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid');
230
- return rows.map((row) => ({ type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) }));
735
+ .where('a.scope_type', scopeType), 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid', 'a.role_uuid'));
736
+ // Por scope: los roles asignados; cuenta si el árbol lo conoce (D8, una
737
+ // consulta al resolutor por scope, que el memo por request amortiza) Y
738
+ // alguno de sus roles existe ahí (D5; 3B · B2: declarado para ese nivel y
739
+ // global u owner en la cadena).
740
+ const catalog = await this.catalog.view();
741
+ const byScope = new Map();
742
+ for (const row of rows) {
743
+ const k = rowScopeKey(row);
744
+ if (!byScope.has(k))
745
+ byScope.set(k, { scope: { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) }, roles: [] });
746
+ byScope.get(k).roles.push(row.role_uuid);
747
+ }
748
+ const result = [];
749
+ for (const { scope, roles } of byScope.values()) {
750
+ const chain = await this.chain(scope, 'listRoleScopes');
751
+ if (!chain)
752
+ continue;
753
+ const keys = chainKeysFrom(chain)[0];
754
+ if (roles.some((uuid) => this.visibleAt(catalog, uuid, scope, keys)))
755
+ result.push(scope);
756
+ }
757
+ return result;
758
+ }
759
+ /** ¿El rol (por uuid) existe en el scope cuya cadena tiene esas claves? (3B · B2, `declaredRoleAt`) */
760
+ visibleAt(catalog, roleUuid, scope, chainKeys) {
761
+ return declaredRoleAt(catalog, roleUuid, scope.type, chainKeys) !== null;
231
762
  }
232
763
  async listScopes(subject, permission) {
764
+ assertIdentity({ subject, permission });
233
765
  const perm = await this.findPermission(permission);
234
766
  if (!perm)
235
767
  return [];
236
- const rows = await this.whereActive(db
768
+ const rows = await this.sql('listScopes.assignments', () => this.whereActive(db
237
769
  .from('authz_assignments as a')
238
770
  .join('authz_role_permissions as rp', 'rp.role_uuid', 'a.role_uuid')
239
771
  .where('rp.permission_uuid', perm.uuid)
240
772
  .where('a.holder_type', subject.type)
241
- .where('a.holder_uuid', subject.uuid), 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid');
242
- const denies = await db
773
+ .where('a.holder_uuid', subject.uuid), 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid', 'a.role_uuid'));
774
+ const denies = await this.sql('listScopes.denies', () => db
243
775
  .from('authz_denies')
244
776
  .where('holder_type', subject.type)
245
777
  .where('holder_uuid', subject.uuid)
246
778
  .where('permission_uuid', perm.uuid)
247
- .select('scope_type', 'scope_uuid');
779
+ .select('scope_type', 'scope_uuid'));
248
780
  const deniedKeys = new Set(denies.map((d) => `${d.scope_type}:${fromDbScopeUuid(d.scope_uuid) ?? ''}`));
249
- const result = [];
781
+ // Por scope: el conjunto de roles que conceden; el scope se lista si el
782
+ // árbol lo conoce, alguno de esos roles existe ahí (3B · B2) y ningún
783
+ // deny de la cadena lo bloquea — exactamente lo que `authorize` diría.
784
+ const catalog = await this.catalog.view();
785
+ const byScope = new Map();
250
786
  for (const row of rows) {
251
- const candidate = { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) };
252
- const chain = await this.chain(candidate);
787
+ const k = rowScopeKey(row);
788
+ if (!byScope.has(k))
789
+ byScope.set(k, { scope: { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) }, roles: [] });
790
+ byScope.get(k).roles.push(row.role_uuid);
791
+ }
792
+ const result = [];
793
+ for (const { scope: candidate, roles } of byScope.values()) {
794
+ // Un scope que el árbol ya no conoce no concede (authorize daría false):
795
+ // no se lista, igual que uno denegado.
796
+ const chain = await this.chain(candidate, 'listScopes');
797
+ if (!chain)
798
+ continue;
799
+ const keys = chainKeysFrom(chain)[0];
800
+ if (!roles.some((uuid) => this.visibleAt(catalog, uuid, candidate, keys)))
801
+ continue;
253
802
  const blocked = chain.some((s) => deniedKeys.has(`${s.type}:${s.uuid ?? ''}`));
254
803
  if (!blocked)
255
804
  result.push(candidate);
256
805
  }
257
806
  return result;
258
807
  }
808
+ /**
809
+ * Denies directos del holder (2.1, B5): del scope exacto o todos. Solo los
810
+ * de permisos que el catálogo declara (memo); los de scopes que el árbol
811
+ * no conoce no se listan (D8), como en `listRoleScopes`.
812
+ */
813
+ async listDenies(subject, scope) {
814
+ assertIdentity(scope ? { subject, scope } : { subject });
815
+ const chain = scope ? await this.chain(scope, 'listDenies') : null;
816
+ if (scope && !chain)
817
+ return [];
818
+ const base = db
819
+ .from('authz_denies')
820
+ .where('holder_type', subject.type)
821
+ .where('holder_uuid', subject.uuid);
822
+ const query = chain ? whereScopeIn(base, 'scope', [chain[0]], 'read') : base;
823
+ if (!query)
824
+ return [];
825
+ const rows = await this.sql('listDenies', () => query.select('permission_uuid', 'scope_type', 'scope_uuid'));
826
+ const view = await this.catalog.view();
827
+ const result = [];
828
+ for (const row of rows) {
829
+ const permission = view.permissionSlug(row.permission_uuid);
830
+ if (!permission)
831
+ continue;
832
+ const denyScope = { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) };
833
+ if (!scope && !(await this.chain(denyScope, 'listDenies')))
834
+ continue;
835
+ result.push({ permission, scope: denyScope });
836
+ }
837
+ return result;
838
+ }
839
+ /**
840
+ * Borra asignaciones y denies del scope exacto, en una transacción. Si el
841
+ * árbol aún lo conoce se purga su identidad canónica (K1); si ya no (el
842
+ * consumidor borró la fila antes de avisar) se purga tal cual llegó. Con
843
+ * `DELETE` en SQL la propia sentencia demuestra el cero: no hay residuo
844
+ * posible.
845
+ */
846
+ async purgeScope(purged) {
847
+ assertScope(purged);
848
+ if (purged.type === APP_SCOPE_TYPE) {
849
+ throw new InvalidIdentityError('purgeScope: la raíz `app` no se purga');
850
+ }
851
+ const scope = await this.canonicalOrSelf(purged, 'purgeScope');
852
+ // La transacción no es un builder: `guardSql` no puede fijarle el
853
+ // deadline, así que cada DELETE pasa por él por separado (D4). El guard
854
+ // exterior clasifica lo que falle al abrir o confirmar la transacción.
855
+ await this.sql('purgeScope', () => db.transaction(async (trx) => {
856
+ await this.sql('purgeScope.assignments', () => trx
857
+ .from('authz_assignments')
858
+ .where('scope_type', scope.type)
859
+ .where('scope_uuid', toDbScopeUuid(scope))
860
+ .delete());
861
+ await this.sql('purgeScope.denies', () => trx
862
+ .from('authz_denies')
863
+ .where('scope_type', scope.type)
864
+ .where('scope_uuid', toDbScopeUuid(scope))
865
+ .delete());
866
+ }));
867
+ }
868
+ /**
869
+ * Purga un rol con sus hechos (3B · B4): sus asignaciones en TODOS los
870
+ * scopes, sus vínculos y la fila, en UNA transacción que sube la versión
871
+ * compartida del catálogo como última sentencia (`withAuthzCatalogWrite`):
872
+ * los demás procesos dejan de verlo en su siguiente pregunta. Se lee la
873
+ * fila en fresco (no el memo): purgar es escribir. Con `DELETE` la propia
874
+ * sentencia demuestra el cero. Global o local: la barrera «los globales son
875
+ * inmutables» es del manager (`deleteScopedRole`); por aquí (plataforma,
876
+ * `manager.driver()`) se purga lo que se pida.
877
+ */
878
+ async purgeRole(roleUuid) {
879
+ assertCatalogUuid('rol', roleUuid);
880
+ const existing = await this.first('purgeRole.role', () => db.from('authz_roles').where('uuid', roleUuid).select('uuid'));
881
+ if (!existing)
882
+ throw new UnknownRoleError(roleUuid);
883
+ try {
884
+ await this.sql('purgeRole', () => withAuthzCatalogWrite(async (trx) => {
885
+ await this.sql('purgeRole.assignments', () => trx.from('authz_assignments').where('role_uuid', roleUuid).delete());
886
+ await this.sql('purgeRole.links', () => trx.from('authz_role_permissions').where('role_uuid', roleUuid).delete());
887
+ const deleted = await this.sql('purgeRole.role.delete', () => trx.from('authz_roles').where('uuid', roleUuid).delete());
888
+ if (Number(Array.isArray(deleted) ? deleted[0] : deleted) === 0)
889
+ throw new UnknownRoleError(roleUuid);
890
+ }, { driver: 'database', timeoutMs: this.timeoutMs }));
891
+ }
892
+ finally {
893
+ // Como el sync (2A): este proceso lo ve al instante también con `everyMs`.
894
+ this.catalog.invalidate();
895
+ }
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
+ }
259
1238
  }
260
1239
  //# sourceMappingURL=database_driver.js.map