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