@jantstack/adonis-authz 1.1.0 → 2.0.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 (132) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +417 -58
  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_sync.d.ts +20 -0
  8. package/build/commands/authz_catalog_sync.d.ts.map +1 -0
  9. package/build/commands/authz_catalog_sync.js +58 -0
  10. package/build/commands/authz_catalog_sync.js.map +1 -0
  11. package/build/commands/main.d.ts +2 -0
  12. package/build/commands/main.d.ts.map +1 -1
  13. package/build/commands/main.js +2 -0
  14. package/build/commands/main.js.map +1 -1
  15. package/build/commands/openfga_import.d.ts +8 -2
  16. package/build/commands/openfga_import.d.ts.map +1 -1
  17. package/build/commands/openfga_import.js +29 -6
  18. package/build/commands/openfga_import.js.map +1 -1
  19. package/build/commands/openfga_provision.d.ts.map +1 -1
  20. package/build/commands/openfga_provision.js +2 -1
  21. package/build/commands/openfga_provision.js.map +1 -1
  22. package/build/index.d.ts +45 -6
  23. package/build/index.d.ts.map +1 -1
  24. package/build/index.js +37 -4
  25. package/build/index.js.map +1 -1
  26. package/build/src/catalog.d.ts +249 -2
  27. package/build/src/catalog.d.ts.map +1 -1
  28. package/build/src/catalog.js +709 -59
  29. package/build/src/catalog.js.map +1 -1
  30. package/build/src/catalog_cache.d.ts +300 -0
  31. package/build/src/catalog_cache.d.ts.map +1 -0
  32. package/build/src/catalog_cache.js +656 -0
  33. package/build/src/catalog_cache.js.map +1 -0
  34. package/build/src/clock.d.ts +24 -0
  35. package/build/src/clock.d.ts.map +1 -0
  36. package/build/src/clock.js +7 -0
  37. package/build/src/clock.js.map +1 -0
  38. package/build/src/define_config.d.ts +102 -6
  39. package/build/src/define_config.d.ts.map +1 -1
  40. package/build/src/define_config.js.map +1 -1
  41. package/build/src/drivers/backend_guard.d.ts +92 -0
  42. package/build/src/drivers/backend_guard.d.ts.map +1 -0
  43. package/build/src/drivers/backend_guard.js +221 -0
  44. package/build/src/drivers/backend_guard.js.map +1 -0
  45. package/build/src/drivers/database_driver.d.ts +242 -15
  46. package/build/src/drivers/database_driver.d.ts.map +1 -1
  47. package/build/src/drivers/database_driver.js +692 -127
  48. package/build/src/drivers/database_driver.js.map +1 -1
  49. package/build/src/drivers/openfga_driver.d.ts +341 -27
  50. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  51. package/build/src/drivers/openfga_driver.js +1020 -271
  52. package/build/src/drivers/openfga_driver.js.map +1 -1
  53. package/build/src/drivers/sql_expiry.d.ts +53 -0
  54. package/build/src/drivers/sql_expiry.d.ts.map +1 -0
  55. package/build/src/drivers/sql_expiry.js +66 -0
  56. package/build/src/drivers/sql_expiry.js.map +1 -0
  57. package/build/src/errors.d.ts +333 -0
  58. package/build/src/errors.d.ts.map +1 -1
  59. package/build/src/errors.js +352 -0
  60. package/build/src/errors.js.map +1 -1
  61. package/build/src/expiry.d.ts +27 -0
  62. package/build/src/expiry.d.ts.map +1 -0
  63. package/build/src/expiry.js +50 -0
  64. package/build/src/expiry.js.map +1 -0
  65. package/build/src/hierarchical_resolver.d.ts +56 -0
  66. package/build/src/hierarchical_resolver.d.ts.map +1 -0
  67. package/build/src/hierarchical_resolver.js +87 -0
  68. package/build/src/hierarchical_resolver.js.map +1 -0
  69. package/build/src/identity.d.ts +155 -0
  70. package/build/src/identity.d.ts.map +1 -0
  71. package/build/src/identity.js +359 -0
  72. package/build/src/identity.js.map +1 -0
  73. package/build/src/manager.d.ts +225 -7
  74. package/build/src/manager.d.ts.map +1 -1
  75. package/build/src/manager.js +1670 -23
  76. package/build/src/manager.js.map +1 -1
  77. package/build/src/memoize_ancestors.d.ts +23 -0
  78. package/build/src/memoize_ancestors.d.ts.map +1 -0
  79. package/build/src/memoize_ancestors.js +42 -0
  80. package/build/src/memoize_ancestors.js.map +1 -0
  81. package/build/src/middleware/app_access_middleware.d.ts +8 -6
  82. package/build/src/middleware/app_access_middleware.d.ts.map +1 -1
  83. package/build/src/middleware/app_access_middleware.js +10 -26
  84. package/build/src/middleware/app_access_middleware.js.map +1 -1
  85. package/build/src/models/authz_assignment.d.ts +5 -5
  86. package/build/src/models/authz_assignment.d.ts.map +1 -1
  87. package/build/src/models/authz_deny.d.ts +5 -5
  88. package/build/src/models/authz_deny.d.ts.map +1 -1
  89. package/build/src/models/authz_permission.d.ts +11 -5
  90. package/build/src/models/authz_permission.d.ts.map +1 -1
  91. package/build/src/models/authz_permission.js +4 -0
  92. package/build/src/models/authz_permission.js.map +1 -1
  93. package/build/src/models/authz_role.d.ts +13 -6
  94. package/build/src/models/authz_role.d.ts.map +1 -1
  95. package/build/src/models/authz_role.js +6 -1
  96. package/build/src/models/authz_role.js.map +1 -1
  97. package/build/src/models/authz_role_permission.d.ts +5 -5
  98. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  99. package/build/src/openfga.d.ts +13 -0
  100. package/build/src/openfga.d.ts.map +1 -0
  101. package/build/src/openfga.js +12 -0
  102. package/build/src/openfga.js.map +1 -0
  103. package/build/src/sql_descendants.d.ts +51 -0
  104. package/build/src/sql_descendants.d.ts.map +1 -0
  105. package/build/src/sql_descendants.js +129 -0
  106. package/build/src/sql_descendants.js.map +1 -0
  107. package/build/src/testing/contract.d.ts +138 -7
  108. package/build/src/testing/contract.d.ts.map +1 -1
  109. package/build/src/testing/contract.js +2946 -24
  110. package/build/src/testing/contract.js.map +1 -1
  111. package/build/src/testing/main.d.ts +4 -2
  112. package/build/src/testing/main.d.ts.map +1 -1
  113. package/build/src/testing/main.js +2 -1
  114. package/build/src/testing/main.js.map +1 -1
  115. package/build/src/testing/scope_tree.d.ts +65 -0
  116. package/build/src/testing/scope_tree.d.ts.map +1 -0
  117. package/build/src/testing/scope_tree.js +145 -0
  118. package/build/src/testing/scope_tree.js.map +1 -0
  119. package/build/src/traits/authz_scopes.d.ts +30 -6
  120. package/build/src/traits/authz_scopes.d.ts.map +1 -1
  121. package/build/src/traits/authz_scopes.js +30 -18
  122. package/build/src/traits/authz_scopes.js.map +1 -1
  123. package/build/src/traits/has_uuid.d.ts +5 -5
  124. package/build/src/traits/has_uuid.d.ts.map +1 -1
  125. package/build/src/types.d.ts +530 -28
  126. package/build/src/types.d.ts.map +1 -1
  127. package/build/src/types.js +10 -4
  128. package/build/src/types.js.map +1 -1
  129. package/build/stubs/config/app_acl.stub +4 -2
  130. package/build/stubs/config/authorization.stub +66 -12
  131. package/build/stubs/migration.stub +57 -13
  132. package/package.json +11 -6
@@ -0,0 +1,656 @@
1
+ import db from '@adonisjs/lucid/services/db';
2
+ import { guardSql, isAuthzError, isSqlDriverError, isTimeoutLike } from './drivers/backend_guard.js';
3
+ import { AmbiguousRoleError, AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, } from './errors.js';
4
+ import { isValidScopeType, scopeFromKey } from './identity.js';
5
+ import { systemClock } from './clock.js';
6
+ import { isSqliteDialect } from './drivers/sql_expiry.js';
7
+ /**
8
+ * Memo del CATÁLOGO (roles, permisos y vínculos rol→permiso), y de nada más.
9
+ *
10
+ * El camino caliente de `authorize` consultaba `authz_*` en cada pregunta
11
+ * (`findPermission` en ambos drivers; `rolesGranting` además en openfga)
12
+ * para leer algo que solo cambia cuando el consumidor sincroniza su config.
13
+ * Este memo carga las tres tablas una vez, perezosamente, y las sirve desde
14
+ * memoria mientras la BASE diga que siguen vigentes. Lo que NUNCA cachea:
15
+ * hechos (asignaciones, denies) ni decisiones — un `authorize` siempre
16
+ * pregunta al backend de hechos; lo que se ahorra es el "¿qué uuid tiene
17
+ * `docs:read`?".
18
+ *
19
+ * Invariante (2D · F1): **el catálogo que decide es el de la BD.** La tabla
20
+ * `authz_catalog_version` (una fila, `id = 1`) lleva un entero que
21
+ * `syncAuthzCatalog` incrementa DENTRO de su transacción. Cada foto recuerda
22
+ * con qué versión se cargó y, antes de servirse, la contrasta con la fila
23
+ * (un SELECT por clave primaria, con deadline y clasificado 503): si difiere,
24
+ * recarga. Así un sync en OTRO proceso (otro worker, `node ace
25
+ * authz:catalog:sync` en un despliegue) se ve en la siguiente pregunta de
26
+ * todos los procesos, sin pub/sub y sin reiniciar. El memo nunca sirve una
27
+ * decisión con una versión distinta de la de la base.
28
+ *
29
+ * Contrato de invalidación (README, "Performance"):
30
+ * 1. `syncAuthzCatalog` (y por tanto `authz:catalog:sync`) sube la fila en
31
+ * su transacción (lo ven todos los procesos) y además el contador de
32
+ * este módulo (lo ve este proceso al instante, también con `everyMs`).
33
+ * 2. `invalidateAuthzCatalog()` invalida los memos de ESTE proceso; la fila
34
+ * la sube `bumpAuthzCatalogVersion(trx)` para TODOS, y SOLO como última
35
+ * sentencia de la transacción que escribe `authz_*` (2E · H2): un bump
36
+ * que se confirma antes que su escritura hace que otro proceso recargue
37
+ * los datos viejos etiquetados con la versión nueva y no vuelva a
38
+ * revalidar jamás. Quien escribe `authz_*` por fuera del sync (un seeder,
39
+ * una migración de datos) lo hace con `withAuthzCatalogWrite(async (trx)
40
+ * => …)`, que abre la transacción y sube la versión al final, dentro; sin
41
+ * eso los demás procesos no se enteran (caso negativo fijado por test).
42
+ * 3. `revalidate: 'always'` (default) contrasta la fila en cada `view()`.
43
+ * `{ everyMs }` (opt-in) la contrasta como mucho una vez por ventana: es
44
+ * una ventana ACOTADA de catálogo viejo —de revocación fail-open— que el
45
+ * consumidor acepta a sabiendas a cambio de ahorrarse ese SELECT.
46
+ *
47
+ * Es composición: cada driver del paquete construye el suyo (o recibe uno
48
+ * compartido en `catalog`); un driver de terceros no necesita saber que existe.
49
+ */
50
+ /** Tabla de la versión compartida del catálogo (una fila, `id = 1`). */
51
+ export const CATALOG_VERSION_TABLE = 'authz_catalog_version';
52
+ const CATALOG_VERSION_ROW_ID = 1;
53
+ /**
54
+ * Clave de owner de los roles GLOBALES (`authz_roles.owner_scope_key`, 3B ·
55
+ * B1): los del catálogo del config (`syncAuthzCatalog`). Reservada: ningún
56
+ * `scopeKey()` la produce (la raíz da `app`; el resto lleva `|`).
57
+ */
58
+ export const GLOBAL_OWNER_KEY = 'global';
59
+ /** ¿El rol cuenta en un scope cuya cadena tiene esas claves (owner global o en la cadena)? */
60
+ export function isRoleVisibleWith(role, chainKeys) {
61
+ if (role.owner === GLOBAL_OWNER_KEY)
62
+ return true;
63
+ return Array.isArray(chainKeys) ? chainKeys.includes(role.owner) : chainKeys.has(role.owner);
64
+ }
65
+ /**
66
+ * Versión del catálogo en ESTE proceso. La sube `syncAuthzCatalog` al
67
+ * terminar e `invalidateAuthzCatalog()`; cada memo recuerda con qué versión
68
+ * cargó y se recarga si difiere. Es la señal intra-proceso (inmediata, sin
69
+ * SQL); la señal entre procesos es la fila `authz_catalog_version`.
70
+ */
71
+ let catalogVersion = 0;
72
+ /**
73
+ * Invalida todos los memos del catálogo de este proceso. En otro proceso no
74
+ * hace nada: para eso está la fila compartida (`bumpAuthzCatalogVersion`).
75
+ */
76
+ export function invalidateAuthzCatalog() {
77
+ catalogVersion += 1;
78
+ }
79
+ /** Deadline de cada consulta de carga (el mismo default que el catálogo). */
80
+ export const DEFAULT_CATALOG_CACHE_TIMEOUT_MS = 5_000;
81
+ function describeClient(client) {
82
+ if (client === null)
83
+ return 'null';
84
+ if (client === undefined)
85
+ return 'nada';
86
+ if (typeof client !== 'object' && typeof client !== 'function')
87
+ return `un ${typeof client}`;
88
+ const c = client;
89
+ if (c.isTransaction === false)
90
+ return `un cliente que NO es una transacción (${c.constructor?.name ?? 'objeto'})`;
91
+ return `un ${c.constructor?.name ?? 'objeto'} sin isTransaction`;
92
+ }
93
+ /**
94
+ * `bumpAuthzCatalogVersion` exige la transacción que escribe `authz_*` (2E ·
95
+ * H2, auditor 2): con el `db` global la subida se confirmaba ANTES que la
96
+ * escritura (fuera de la transacción del consumidor) y el memo de otro
97
+ * proceso recargaba los datos viejos con la etiqueta nueva — un fail-open
98
+ * permanente. 500: es un error de programación, no una pregunta.
99
+ */
100
+ function assertTransactionClient(client, operation) {
101
+ const c = client;
102
+ if (!c || typeof c.from !== 'function' || typeof c.table !== 'function' || c.isTransaction !== true) {
103
+ throw new AuthorizationConfigError(`${operation} exige el cliente de la TRANSACCIÓN que escribe authz_* (el trx de db.transaction) y llegó ` +
104
+ `${describeClient(client)}. Un bump fuera de esa transacción se confirma antes que la escritura y deja a los ` +
105
+ `demás procesos con el catálogo viejo etiquetado como nuevo, para siempre. Escribe authz_* con ` +
106
+ `withAuthzCatalogWrite(async (trx) => { … }): abre la transacción, ejecuta tu escritura y sube la versión ` +
107
+ `como última sentencia, dentro.`);
108
+ }
109
+ return c;
110
+ }
111
+ /**
112
+ * `catalog` (memo compartido) y `catalogRevalidate` juntos se contradicen
113
+ * (2E · I3, auditor 11): la política de revalidación es la del memo y se fija
114
+ * al construir el `CatalogCache`; el `catalogRevalidate` del driver se
115
+ * ignoraba en silencio. 500 al construir: config rota, no una pregunta. Lo
116
+ * llaman los dos drivers del paquete desde su constructor.
117
+ */
118
+ export function assertCatalogOptions(driver, options) {
119
+ if (options.catalog !== undefined && options.catalogRevalidate !== undefined) {
120
+ throw new AuthorizationConfigError(`${driver}: 'catalog' (memo compartido) y 'catalogRevalidate' no pueden ir juntos: la política de ` +
121
+ `revalidación es la del memo que se comparte (new CatalogCache({ revalidate })) y la del driver se ` +
122
+ `ignoraría. Quita 'catalogRevalidate' o construye el driver sin 'catalog'.`);
123
+ }
124
+ }
125
+ /**
126
+ * La fila de la versión no está o no se puede leer como número (2E · I1,
127
+ * auditor 7): es una base sin la migración 2.0 (que la siembra) o con la fila
128
+ * borrada. Fail-closed: sin versión legible no se sirve ningún catálogo — ni
129
+ * el memo viejo ni una carga en frío etiquetada como «versión 0».
130
+ */
131
+ function unreadableVersionRow(driver, why) {
132
+ const error = new AuthorizationBackendError(driver, 'catalog.version', new Error(why));
133
+ error.message =
134
+ `El backend de autorización '${driver}' no tiene una versión legible del catálogo (${CATALOG_VERSION_TABLE}, ` +
135
+ `id = ${CATALOG_VERSION_ROW_ID}): ${why}. Probablemente la migración 2.0 no está aplicada (siembra la fila); ` +
136
+ `sin ella no se sirve ningún catálogo.`;
137
+ return error;
138
+ }
139
+ /**
140
+ * Versión compartida del catálogo: la fila `authz_catalog_version`. Sin fila
141
+ * legible (semilla ausente, fila borrada, valor no numérico) es 503
142
+ * `E_AUTHZ_BACKEND_UNAVAILABLE` con «migración 2.0 no aplicada» (I1); sin
143
+ * TABLA se clasifica como el resto de fallos SQL (503).
144
+ */
145
+ export async function readAuthzCatalogVersion(options = {}) {
146
+ const client = options.client ?? db;
147
+ const driver = options.driver ?? 'catalog';
148
+ const rows = await guardSql(driver, 'catalog.version', options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS, () => client.from(CATALOG_VERSION_TABLE).where('id', CATALOG_VERSION_ROW_ID).select('version'));
149
+ if (rows.length === 0)
150
+ throw unreadableVersionRow(driver, 'la fila no existe');
151
+ const raw = rows[0].version;
152
+ const version = typeof raw === 'number' ? raw : typeof raw === 'string' || typeof raw === 'bigint' ? Number(raw) : Number.NaN;
153
+ if (!Number.isFinite(version))
154
+ throw unreadableVersionRow(driver, `la columna version vale ${String(raw)}`);
155
+ return version;
156
+ }
157
+ /**
158
+ * Sube la versión compartida del catálogo: TODOS los memos de TODOS los
159
+ * procesos recargan en su siguiente pregunta (o al cerrar su ventana
160
+ * `everyMs`). Exige el cliente de la transacción que escribe `authz_*` (500
161
+ * `E_AUTHZ_CONFIG` sin él, 2E · H2) y tiene que ser su ÚLTIMA sentencia: lo
162
+ * garantiza `withAuthzCatalogWrite`, que es por donde escriben
163
+ * `syncAuthzCatalog` y cualquier seeder o migración de datos. Sin fila
164
+ * (semilla ausente) la crea; la carrera de dos procesos que la crean a la vez
165
+ * la resuelve la clave primaria: el perdedor vuelve a hacer UPDATE. Es SOLO el
166
+ * canal entre procesos: no toca el contador de este (con `everyMs`, hasta este
167
+ * proceso tarda una ventana en verlo; con `'always'`, la siguiente pregunta lo
168
+ * ve).
169
+ */
170
+ export async function bumpAuthzCatalogVersion(trx, options = {}) {
171
+ const client = assertTransactionClient(trx, 'bumpAuthzCatalogVersion');
172
+ const driver = options.driver ?? 'catalog';
173
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS;
174
+ const update = () => guardSql(driver, 'catalog.version.bump', timeoutMs, () => client
175
+ .from(CATALOG_VERSION_TABLE)
176
+ .where('id', CATALOG_VERSION_ROW_ID)
177
+ .increment('version', 1));
178
+ const updated = Number(await update());
179
+ if (updated > 0)
180
+ return;
181
+ try {
182
+ await guardSql(driver, 'catalog.version.seed', timeoutMs, () => client.table(CATALOG_VERSION_TABLE).insert({ id: CATALOG_VERSION_ROW_ID, version: 1, updated_at: systemClock() }));
183
+ }
184
+ catch (error) {
185
+ // Otro proceso sembró la fila entre el UPDATE y el INSERT: se sube la suya.
186
+ if (Number(await update()) === 0)
187
+ throw error;
188
+ }
189
+ }
190
+ /**
191
+ * Los roles LOCALES de esos owners leídos de la BASE, en fresco, con sus
192
+ * permisos (3E · P2, auditor A2 bis).
193
+ *
194
+ * `scopes.detached` decidía qué purgar con la foto del MEMO: con
195
+ * `catalogRevalidate: { everyMs }` —config legal y documentada— un rol que
196
+ * otro proceso acababa de confirmar no estaba en la foto y SOBREVIVÍA a la
197
+ * desaparición de su owner, bloqueando ese `(slug, nivel)` para el catálogo
198
+ * global para siempre. M2 ya había aprendido a releer la base dentro de la
199
+ * transacción; M4 no.
200
+ *
201
+ * Queda una ventana (un `defineScopedRole` confirmado entre este SELECT y la
202
+ * purga) que no cierra ningún cerrojo razonable: `purgeRole` abre su propia
203
+ * transacción con el cerrojo del catálogo y leer aquí dentro de otra sería
204
+ * un abrazo mortal con un pool de 1. Es una carrera que el tenant pierde de
205
+ * todas formas —define un rol en un scope que se está borrando— y la
206
+ * siguiente notificación (o `authz:reconcile`, 3b) lo recoge.
207
+ *
208
+ * El orden es ESTABLE por `uuid` (3F · U5, tester 3E · §4.3): sin `ORDER BY`
209
+ * lo ponía el motor, así que la secuencia de `role_purged` que ve el hook de
210
+ * auditoría —y el rol por el que empezaría una purga interrumpida a medias—
211
+ * cambiaba entre PostgreSQL, MySQL y SQLite. La policy de rango se comprueba
212
+ * sobre TODOS antes de tocar ninguno (3E · P3), así que el orden no decide
213
+ * QUÉ se purga; decide lo que se REPRODUCE. Con uuid v7 es además el orden
214
+ * de creación.
215
+ */
216
+ export async function readRolesOwnedBy(ownerKeys, options = {}) {
217
+ const owners = [...new Set(ownerKeys)].filter((key) => key !== GLOBAL_OWNER_KEY);
218
+ if (owners.length === 0)
219
+ return [];
220
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS;
221
+ const driver = options.driver ?? 'catalog';
222
+ const rows = [];
223
+ // Por lotes: `descendantsOf` puede traer miles de owners y un `IN` de
224
+ // 10 000 elementos es una consulta que algunos motores rechazan.
225
+ for (let i = 0; i < owners.length; i += 500) {
226
+ const chunk = owners.slice(i, i + 500);
227
+ rows.push(...(await guardSql(driver, 'catalog.rolesOwnedBy', timeoutMs, () => db
228
+ .from('authz_roles')
229
+ .whereIn('owner_scope_key', chunk)
230
+ .orderBy('uuid', 'asc')
231
+ .select('uuid', 'slug', 'scope_type', 'rank', 'owner_scope_key'))));
232
+ }
233
+ if (rows.length === 0)
234
+ return [];
235
+ // Los lotes se concatenan en el orden de `owners`, así que el orden estable
236
+ // del conjunto entero se fija aquí.
237
+ rows.sort((a, b) => String(a.uuid).localeCompare(String(b.uuid)));
238
+ const links = await guardSql(driver, 'catalog.rolePermissionsOwnedBy', timeoutMs, () => db
239
+ .from('authz_role_permissions')
240
+ .join('authz_permissions', 'authz_permissions.uuid', 'authz_role_permissions.permission_uuid')
241
+ .whereIn('authz_role_permissions.role_uuid', rows.map((row) => String(row.uuid)))
242
+ .select('authz_role_permissions.role_uuid as role_uuid', 'authz_permissions.slug as permission_slug'));
243
+ const permissionsOf = new Map();
244
+ for (const link of links) {
245
+ const list = permissionsOf.get(String(link.role_uuid)) ?? [];
246
+ list.push(String(link.permission_slug));
247
+ permissionsOf.set(String(link.role_uuid), list);
248
+ }
249
+ return rows.map((row) => ({
250
+ role: Object.freeze({
251
+ uuid: String(row.uuid),
252
+ slug: String(row.slug),
253
+ scopeType: String(row.scope_type),
254
+ owner: String(row.owner_scope_key),
255
+ rank: Number(row.rank),
256
+ }),
257
+ permissions: (permissionsOf.get(String(row.uuid)) ?? []).sort(),
258
+ }));
259
+ }
260
+ /**
261
+ * Serializa las escrituras del CATÁLOGO bloqueando la fila de
262
+ * `authz_catalog_version` (3D · M2, auditor V2 🔴).
263
+ *
264
+ * La unicidad de la que depende todo el modelo de roles locales —«dentro de
265
+ * una cadena un `(slug, nivel)` identifica un solo rol»— era un
266
+ * *read-then-write*: cada escritor comprobaba la colisión contra SU foto del
267
+ * memo y el unique de la base es `(slug, scope_type, owner_scope_key)`, así
268
+ * que dos `define` con owners distintos (o un `define` contra un `sync`)
269
+ * insertaban los dos y dejaban dos homónimos PERMANENTES. Un `SELECT … FOR
270
+ * UPDATE` sobre `authz_roles WHERE slug=? AND scope_type=?` no lo cierra: no
271
+ * hay filas que bloquear (en PostgreSQL no hay gap locks), que es justo el
272
+ * caso. La fila de la versión sí existe siempre y TODA escritura de `authz_*`
273
+ * pasa por aquí, así que bloquearla es la barrera real: los escritores del
274
+ * catálogo van en serie y el re-chequeo de colisión dentro de la transacción
275
+ * (leyendo la BASE, no el memo) ve lo que el anterior confirmó.
276
+ *
277
+ * Coste: las escrituras del catálogo son raras (un sync por despliegue, la
278
+ * API de delegación). Los HECHOS (`grant`/`deny`/…) no pasan por aquí.
279
+ *
280
+ * SQLite no tiene `FOR UPDATE` y no lo necesita: sus escrituras ya se
281
+ * serializan a nivel de base (una transacción de escritura a la vez).
282
+ */
283
+ async function lockCatalogForWrite(trx, options) {
284
+ // 3E · Q5: Lucid llama `better-sqlite3` a su dialecto de SQLite.
285
+ if (isSqliteDialect(trx))
286
+ return;
287
+ await guardSql(options.driver, 'catalog.lock', options.timeoutMs, () => trx.from(CATALOG_VERSION_TABLE).where('id', CATALOG_VERSION_ROW_ID).forUpdate().select('version'));
288
+ }
289
+ /**
290
+ * LA forma de escribir `authz_*` por fuera de `syncAuthzCatalog` (2E · H2):
291
+ * abre una transacción, ejecuta `fn(trx)` —tu escritura, con ESE cliente— y
292
+ * sube `authz_catalog_version` como última sentencia, dentro. O se confirma
293
+ * todo (datos nuevos + versión nueva) o nada: un memo de otro proceso nunca
294
+ * puede leer la versión nueva con los datos viejos. Devuelve lo que devuelva
295
+ * `fn`. Si `fn` lanza, la transacción se revierte, la versión no sube y su
296
+ * error sale tal cual (es tuyo) — salvo que sea un error del cliente SQL
297
+ * (2.5-B · K12), que se clasifica como 503 igual que un fallo al abrir o
298
+ * confirmar la transacción. NO te tragues errores de SQL dentro de `fn`: en
299
+ * PostgreSQL la transacción queda abortada y todo lo que sigue falla; en
300
+ * MySQL y SQLite el motor no la aborta y lo que sigue SE CONFIRMA.
301
+ *
302
+ * Es solo el canal entre procesos (la fila): en este proceso la siguiente
303
+ * pregunta lo ve con `'always'` y, con `{ everyMs }`, al cerrar la ventana —
304
+ * `syncAuthzCatalog` además invalida en memoria; hazlo tú con
305
+ * `invalidateAuthzCatalog()` si usas `everyMs` y lo necesitas al instante.
306
+ *
307
+ * await withAuthzCatalogWrite(async (trx) => {
308
+ * await trx.from('authz_role_permissions').where('role_uuid', role).delete()
309
+ * })
310
+ */
311
+ export async function withAuthzCatalogWrite(fn, options = {}) {
312
+ if (typeof fn !== 'function') {
313
+ throw new AuthorizationConfigError(`withAuthzCatalogWrite espera la función que escribe authz_* (async (trx) => …) y llegó ${typeof fn}`);
314
+ }
315
+ const driver = options.driver ?? 'catalog';
316
+ const timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS;
317
+ const connection = options.connection ? db.connection(options.connection) : db;
318
+ // Lo que lance `fn` es del consumidor y sale intacto; lo que falle al abrir
319
+ // o confirmar la transacción es la base y se clasifica (503).
320
+ let consumerError = null;
321
+ try {
322
+ return await connection.transaction(async (trx) => {
323
+ // Primero el cerrojo del catálogo (M2), después la escritura y, como
324
+ // última sentencia, el bump (H2).
325
+ await lockCatalogForWrite(trx, { driver, timeoutMs });
326
+ let result;
327
+ try {
328
+ result = await fn(trx);
329
+ }
330
+ catch (error) {
331
+ consumerError = { error };
332
+ throw error;
333
+ }
334
+ await bumpAuthzCatalogVersion(trx, { driver, timeoutMs });
335
+ return result;
336
+ });
337
+ }
338
+ catch (error) {
339
+ // Lo que `fn` lanzó y es SUYO (su `Error`, un 422 del paquete) sale
340
+ // intacto. Lo que `fn` dejó escapar del CLIENTE SQL (2.5-B · K12: en
341
+ // PostgreSQL, tras tragarse un fallo, la transacción está abortada y el
342
+ // siguiente UPDATE lanza `25P02` con el SQL dentro) se clasifica igual
343
+ // que un fallo al abrir o confirmar: 503, causa conservada, sin SQL en el
344
+ // mensaje. `fn` NO debe tragarse errores de SQL: en MySQL y SQLite el
345
+ // motor no aborta la transacción y lo que sigue SE CONFIRMA.
346
+ const fromConsumer = consumerError !== null && consumerError.error === error;
347
+ if (fromConsumer && !isSqlDriverError(error))
348
+ throw error;
349
+ if (isAuthzError(error))
350
+ throw error;
351
+ if (isTimeoutLike(error))
352
+ throw new AuthorizationBackendTimeoutError(driver, 'catalog.write', timeoutMs, error);
353
+ throw new AuthorizationBackendError(driver, 'catalog.write', error);
354
+ }
355
+ }
356
+ export class CatalogCache {
357
+ #view = null;
358
+ #version = -1;
359
+ /** Generación de ESTA instancia: `invalidate()` la sube; una carga captura la suya antes de leer (F4). */
360
+ #generation = 0;
361
+ #loadedGeneration = -1;
362
+ #loading = null;
363
+ #checking = null;
364
+ /** Último instante (reloj monótono) en que la foto se contrastó con la fila (base de `everyMs`). */
365
+ #checkedAt = 0;
366
+ #everyMs;
367
+ #timeoutMs;
368
+ #driver;
369
+ /** Reloj monótono: nunca `Date.now()` para medir una ventana (H3). */
370
+ #now;
371
+ constructor(options = {}) {
372
+ const revalidate = options.revalidate ?? 'always';
373
+ if (revalidate !== 'always') {
374
+ const everyMs = revalidate?.everyMs;
375
+ if (typeof everyMs !== 'number' || !(Number.isFinite(everyMs) && everyMs > 0)) {
376
+ throw new TypeError(`CatalogCache: revalidate debe ser 'always' o { everyMs: número > 0 } (llegó ${JSON.stringify(revalidate)})`);
377
+ }
378
+ this.#everyMs = everyMs;
379
+ }
380
+ else {
381
+ this.#everyMs = null;
382
+ }
383
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_CATALOG_CACHE_TIMEOUT_MS;
384
+ this.#driver = options.driver ?? 'catalog';
385
+ if (options.now !== undefined && typeof options.now !== 'function') {
386
+ throw new TypeError(`CatalogCache: now debe ser una función (llegó ${typeof options.now})`);
387
+ }
388
+ this.#now = options.now ?? (() => performance.now());
389
+ }
390
+ /**
391
+ * La foto vigente. Sin foto, o invalidada en este proceso ⇒ carga (las
392
+ * llamadas concurrentes comparten la misma promesa: un arranque con cien
393
+ * requests no dispara cien cargas). Con foto ⇒ se contrasta su versión
394
+ * con la fila compartida (según `revalidate`; las comprobaciones
395
+ * concurrentes también comparten promesa) y, si la base va por delante,
396
+ * recarga. Una carga o comprobación que falla (503, clasificado por
397
+ * `guardSql`) no deja nada cacheado ni servido: la siguiente pregunta
398
+ * vuelve a intentarlo.
399
+ */
400
+ async view() {
401
+ const current = this.#view;
402
+ if (current && this.#isFresh()) {
403
+ if (!this.#needsCheck())
404
+ return current;
405
+ if (!this.#checking) {
406
+ this.#checking = this.#revalidate(current).finally(() => {
407
+ this.#checking = null;
408
+ });
409
+ }
410
+ return this.#checking;
411
+ }
412
+ return this.#reload();
413
+ }
414
+ /** Una carga compartida; `knownVersion` es la fila recién leída por una revalidación (se ahorra releerla). */
415
+ #reload(knownVersion) {
416
+ if (!this.#loading) {
417
+ this.#loading = this.#load(knownVersion).finally(() => {
418
+ this.#loading = null;
419
+ });
420
+ }
421
+ return this.#loading;
422
+ }
423
+ /**
424
+ * Olvida la foto de ESTE memo. La siguiente pregunta recarga. Es un bump
425
+ * de generación, no un `#view = null`: una carga en vuelo capturó la
426
+ * generación anterior y su foto aterriza ya vieja (F4, CR2).
427
+ */
428
+ invalidate() {
429
+ this.#generation += 1;
430
+ }
431
+ /** ¿Hay una foto cargada y vigente para este proceso? (Observabilidad para tests y diagnóstico; no consulta la base.) */
432
+ get loaded() {
433
+ return this.#view !== null && this.#isFresh();
434
+ }
435
+ #isFresh() {
436
+ return this.#version === catalogVersion && this.#loadedGeneration === this.#generation;
437
+ }
438
+ #needsCheck() {
439
+ if (this.#everyMs === null)
440
+ return true;
441
+ return this.#now() - this.#checkedAt >= this.#everyMs;
442
+ }
443
+ #sql(operation, fn) {
444
+ return guardSql(this.#driver, operation, this.#timeoutMs, fn);
445
+ }
446
+ #readVersion() {
447
+ return readAuthzCatalogVersion({ driver: this.#driver, timeoutMs: this.#timeoutMs });
448
+ }
449
+ /**
450
+ * Contrasta la foto con la fila: misma versión ⇒ se sirve; distinta ⇒
451
+ * recarga. Si la foto cambió mientras se leía la fila (otra carga terminó
452
+ * antes) se sirve lo que haya ahora, que ya es más nuevo.
453
+ */
454
+ async #revalidate(current) {
455
+ const dbVersion = await this.#readVersion();
456
+ if (this.#view !== current)
457
+ return this.view();
458
+ if (dbVersion === current.version && this.#isFresh()) {
459
+ this.#checkedAt = this.#now();
460
+ return current;
461
+ }
462
+ return this.#reload(dbVersion);
463
+ }
464
+ async #load(knownVersion) {
465
+ // Las versiones (proceso y fila) se toman ANTES de leer las tablas: si un
466
+ // sync aterriza durante la carga, esta foto queda marcada como vieja y
467
+ // la siguiente pregunta recarga. Una foto mixta solo puede ser más
468
+ // restrictiva (permisos → roles → vínculos) y dura una pregunta.
469
+ const version = catalogVersion;
470
+ const generation = this.#generation;
471
+ const dbVersion = knownVersion ?? (await this.#readVersion());
472
+ const permissions = await this.#sql('catalog.permissions', () => db.from('authz_permissions').select('uuid', 'slug', 'assignable_at'));
473
+ const roles = await this.#sql('catalog.roles', () => db.from('authz_roles').select('uuid', 'slug', 'scope_type', 'owner_scope_key', 'rank'));
474
+ const links = await this.#sql('catalog.links', () => db.from('authz_role_permissions').select('role_uuid', 'permission_uuid'));
475
+ const view = buildCatalogView(permissions, roles, links, systemClock().getTime(), dbVersion);
476
+ this.#view = view;
477
+ this.#version = version;
478
+ this.#loadedGeneration = generation;
479
+ // La ventana se mide con el reloj monótono; `loadedAt` es de pared (informativo).
480
+ this.#checkedAt = this.#now();
481
+ return view;
482
+ }
483
+ }
484
+ /**
485
+ * Clave `(slug, scopeType)` de un rol con un separador NO imprimible
486
+ * (`\u001f`, escrito como escape a propósito: un carácter invisible en el
487
+ * código ya costó un bug): `a:b`+`c` y `a`+`b:c` no pueden colisionar.
488
+ */
489
+ function roleKey(slug, scopeType) {
490
+ return `${slug}\u001f${scopeType}`;
491
+ }
492
+ /**
493
+ * `assignable_at` tal como viene de la base: `NULL` = cualquier nivel; si
494
+ * no, un JSON con una lista no vacía de tipos de scope válidos. Otra cosa
495
+ * (una edición a mano) es catálogo corrupto: 500 `E_AUTHZ_INTERNAL`, nunca
496
+ * «cualquiera» (sería relajar una restricción en silencio) ni «ninguno»
497
+ * disfrazado de dato.
498
+ */
499
+ export function parseAssignableAt(slug, raw) {
500
+ if (raw === null || raw === undefined)
501
+ return null;
502
+ let parsed = raw;
503
+ if (typeof raw === 'string') {
504
+ try {
505
+ parsed = JSON.parse(raw);
506
+ }
507
+ catch {
508
+ parsed = undefined;
509
+ }
510
+ }
511
+ if (!Array.isArray(parsed) || parsed.length === 0 || !parsed.every((t) => typeof t === 'string' && isValidScopeType(t))) {
512
+ throw new AuthorizationInternalError(`authz_permissions.assignable_at del permiso '${slug}' no es una lista JSON no vacía de tipos de scope válidos ` +
513
+ `(llegó ${typeof raw === 'string' ? raw : typeof raw}); corrige la fila o vuelve a sincronizar el catálogo.`);
514
+ }
515
+ return Object.freeze([...new Set(parsed)]);
516
+ }
517
+ /**
518
+ * `authz_roles.owner_scope_key` tal como viene de la base: `global` o la
519
+ * clave de un scope que NO es la raíz. Cualquier otra cosa es catálogo
520
+ * corrupto (500 `E_AUTHZ_INTERNAL`), nunca un default silencioso.
521
+ *
522
+ * `'app'` en particular (3D · N3, auditor V7): la raíz está SIEMPRE en toda
523
+ * cadena, así que un rol con ese owner sería visible en todas partes — un
524
+ * global disfrazado que `syncAuthzCatalog` no gobierna. La API lo impide
525
+ * (`#assertOwnerScope`); aquí se cierra la fila escrita a mano.
526
+ */
527
+ function ownerOf(row) {
528
+ const owner = row.owner_scope_key;
529
+ const valid = typeof owner === 'string' && (owner === GLOBAL_OWNER_KEY || scopeFromKey(owner)?.uuid != null);
530
+ if (!valid) {
531
+ throw new AuthorizationInternalError(`authz_roles.owner_scope_key del rol '${row.slug}@${row.scope_type}' (${row.uuid}) no es una clave de owner ` +
532
+ `(llegó ${typeof owner === 'string' ? `'${owner}'` : owner === null ? 'null' : typeof owner}); se espera ` +
533
+ `'${GLOBAL_OWNER_KEY}' o '<tipo>|<uuid>' de un scope que no sea la raíz. Corrige la fila o aplica la migración 2.2.`);
534
+ }
535
+ return owner;
536
+ }
537
+ function buildCatalogView(permissions, roles, links, loadedAt, version) {
538
+ const permissionBySlug = new Map();
539
+ const slugByPermissionUuid = new Map();
540
+ for (const p of permissions) {
541
+ permissionBySlug.set(p.slug, Object.freeze({ uuid: p.uuid, assignableAt: parseAssignableAt(p.slug, p.assignable_at) }));
542
+ slugByPermissionUuid.set(p.uuid, p.slug);
543
+ }
544
+ // Por `(slug, scopeType)` puede haber VARIOS roles (owners distintos, 3B):
545
+ // la lista guarda primero el global (si lo hay) y luego los locales.
546
+ const rolesByKey = new Map();
547
+ const roleByUuid = new Map();
548
+ const rolesByLevel = new Map();
549
+ const rolesByOwner = new Map();
550
+ let topGlobalRank = 0;
551
+ for (const r of roles) {
552
+ const owner = ownerOf(r);
553
+ const rank = Number(r.rank ?? 0);
554
+ const role = Object.freeze({
555
+ uuid: r.uuid,
556
+ slug: r.slug,
557
+ scopeType: r.scope_type,
558
+ owner,
559
+ rank: Number.isFinite(rank) ? rank : 0,
560
+ });
561
+ const key = roleKey(r.slug, r.scope_type);
562
+ if (!rolesByKey.has(key))
563
+ rolesByKey.set(key, []);
564
+ if (owner === GLOBAL_OWNER_KEY) {
565
+ rolesByKey.get(key).unshift(role);
566
+ if (role.rank > topGlobalRank)
567
+ topGlobalRank = role.rank;
568
+ }
569
+ else {
570
+ rolesByKey.get(key).push(role);
571
+ }
572
+ roleByUuid.set(r.uuid, role);
573
+ if (!rolesByLevel.has(r.scope_type))
574
+ rolesByLevel.set(r.scope_type, []);
575
+ rolesByLevel.get(r.scope_type).push(role);
576
+ if (owner !== GLOBAL_OWNER_KEY) {
577
+ if (!rolesByOwner.has(owner))
578
+ rolesByOwner.set(owner, []);
579
+ rolesByOwner.get(owner).push(role);
580
+ }
581
+ }
582
+ // Un vínculo cuyo rol o permiso no existe (FK rota fuera del sync) no
583
+ // concede nada: se ignora, igual que lo ignoraría el join SQL.
584
+ const grantingByPermission = new Map();
585
+ const permissionsByRole = new Map();
586
+ for (const link of links) {
587
+ const role = roleByUuid.get(link.role_uuid);
588
+ if (!role)
589
+ continue;
590
+ const slug = slugByPermissionUuid.get(link.permission_uuid);
591
+ if (slug !== undefined) {
592
+ if (!permissionsByRole.has(link.role_uuid))
593
+ permissionsByRole.set(link.role_uuid, new Set());
594
+ permissionsByRole.get(link.role_uuid).add(slug);
595
+ }
596
+ let byLevel = grantingByPermission.get(link.permission_uuid);
597
+ if (!byLevel) {
598
+ byLevel = new Map();
599
+ grantingByPermission.set(link.permission_uuid, byLevel);
600
+ }
601
+ let list = byLevel.get(role.scopeType);
602
+ if (!list) {
603
+ list = [];
604
+ byLevel.set(role.scopeType, list);
605
+ }
606
+ list.push({ slug: role.slug, uuid: link.role_uuid, scopeType: role.scopeType, owner: role.owner });
607
+ }
608
+ const EMPTY_SET = new Set();
609
+ const permissionSlugs = Object.freeze([...permissionBySlug.keys()]);
610
+ return {
611
+ permission: (slug) => permissionBySlug.get(slug) ?? null,
612
+ role: (slug, scopeType) => (rolesByKey.get(roleKey(slug, scopeType)) ?? []).find((r) => r.owner === GLOBAL_OWNER_KEY) ?? null,
613
+ roleVisible: (slug, scopeType, chainKeys) => {
614
+ const named = rolesByKey.get(roleKey(slug, scopeType));
615
+ if (!named)
616
+ return null;
617
+ const owners = new Set(chainKeys);
618
+ const visible = named.filter((role) => isRoleVisibleWith(role, owners));
619
+ if (visible.length === 0)
620
+ return null;
621
+ if (visible.length > 1) {
622
+ // 3D · M1: fail-closed. Se nombran uuid y owner de los que SON
623
+ // visibles en esta cadena —y solo esos (3E · Q2): el llamante ya
624
+ // puede verlos, así que no hay fuga de otro árbol—, que es lo que le
625
+ // permite direccionar por `{ uuid }`.
626
+ //
627
+ // 3E · Q1 (auditor A5): el mensaje aconsejaba «renombra uno de
628
+ // ellos» y la API PROHÍBE renombrar (`updateScopedRole` solo cambia
629
+ // name/description/rank/permissions). La salida real es `{ uuid }`
630
+ // para seguir operando y purgar uno para deshacer la ambigüedad.
631
+ throw new AmbiguousRoleError(`'${slug}' (nivel '${scopeType}') es AMBIGUO aquí: hay ${visible.length} roles visibles en esta cadena ` +
632
+ `(${visible.map((r) => `${r.uuid} owner=${r.owner}`).join('; ')}). Un slug ya no identifica un rol: ` +
633
+ `pregunta por { uuid }, que sigue funcionando. Un rol local no se renombra: para deshacer la ambigüedad ` +
634
+ `hay que PURGAR uno (deleteScopedRole con rank suficiente, o la plataforma con driver().purgeRole). ` +
635
+ `authz:catalog:diff los lista (3F · S3: los ensombrecidos por autoridad NO son deriva y salen con exit 0).`);
636
+ }
637
+ return visible[0];
638
+ },
639
+ rolesNamed: (slug, scopeType) => [...(rolesByKey.get(roleKey(slug, scopeType)) ?? [])],
640
+ rolesOwnedBy: (ownerKey) => [...(rolesByOwner.get(ownerKey) ?? [])],
641
+ roleByUuid: (uuid) => roleByUuid.get(uuid) ?? null,
642
+ rolesFor: (scopeType, ownerKeys) => {
643
+ const owners = new Set(ownerKeys);
644
+ return (rolesByLevel.get(scopeType) ?? []).filter((r) => isRoleVisibleWith(r, owners));
645
+ },
646
+ // Copia por llamada: el llamante puede mutar lo que recibe sin tocar la foto.
647
+ rolesGranting: (permissionUuid) => new Map([...(grantingByPermission.get(permissionUuid) ?? new Map())].map(([k, v]) => [k, [...v]])),
648
+ rolePermissionsOf: (roleUuid) => new Set(permissionsByRole.get(roleUuid) ?? EMPTY_SET),
649
+ permissionSlug: (uuid) => slugByPermissionUuid.get(uuid) ?? null,
650
+ permissionSlugs,
651
+ topGlobalRank,
652
+ loadedAt,
653
+ version,
654
+ };
655
+ }
656
+ //# sourceMappingURL=catalog_cache.js.map