@jantstack/adonis-authz 1.0.2 → 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 -46
  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 +51 -5
  23. package/build/index.d.ts.map +1 -1
  24. package/build/index.js +43 -3
  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 +1052 -264
  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 +366 -0
  58. package/build/src/errors.d.ts.map +1 -0
  59. package/build/src/errors.js +387 -0
  60. package/build/src/errors.js.map +1 -0
  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,4 +1,58 @@
1
+ var _a;
1
2
  import { Exception } from '@adonisjs/core/exceptions';
3
+ import { v7 as uuidv7 } from 'uuid';
4
+ import { assertCatalogUuid, assertIdentity, assertScope, assertScopeType, assertSubject, assertValidSlug, chainKeysFrom, normalizeRoleQuery, scopeFromKey, scopeKey, } from './identity.js';
5
+ import { expiryChanged } from './expiry.js';
6
+ import { assertKnownScope, isAuthzError, resolveChain, rootOnlyResolver } from './drivers/backend_guard.js';
7
+ import { CatalogCache, GLOBAL_OWNER_KEY, invalidateAuthzCatalog, isRoleVisibleWith, readRolesOwnedBy, withAuthzCatalogWrite } from './catalog_cache.js';
8
+ import { assertAssignableAt } from './catalog.js';
9
+ import { systemClock } from './clock.js';
10
+ import { ActorRequiredError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, CatalogConflictError, InvalidIdentityError, NoDescendantsResolverError, NotWithinError, PermissionNotDelegableError, RankExceededError, RoleImmutableError, RoleLevelAboveOwnerError, ScopeCycleError, ScopeResolverError, TooManyScopesError, UnknownPermissionError, UnknownRoleError, UnsupportedOperationError, ViewExpiredError, WithinRequiredError, WithinRootForbiddenError, } from './errors.js';
11
+ import { APP_SCOPE_TYPE } from './types.js';
12
+ import { memoizeAncestors } from './memoize_ancestors.js';
13
+ /** Longitudes de `authz_roles.name`/`description` (el esquema publicado). */
14
+ const ROLE_NAME_MAX = 100;
15
+ const ROLE_DESCRIPTION_MAX = 500;
16
+ /** Descripción corta de una respuesta inválida de `authorizeMany`, para el mensaje del 500. */
17
+ function describeAnswer(answer) {
18
+ if (!Array.isArray(answer))
19
+ return `${answer === null ? 'null' : typeof answer} (no es un array)`;
20
+ const offending = answer.find((b) => typeof b !== 'boolean');
21
+ if (offending !== undefined || answer.some((b) => typeof b !== 'boolean')) {
22
+ return `un array de ${answer.length} con un elemento que no es boolean (${typeof offending})`;
23
+ }
24
+ return `un array de ${answer.length}`;
25
+ }
26
+ /**
27
+ * Configs que ya recibieron el aviso de seguridad opt-in (B7/B1): una vez
28
+ * por config, no por instancia — `forRequest()` construye managers hijos con
29
+ * la misma config y no debe repetirlo en cada request.
30
+ */
31
+ const warnedConfigs = new WeakSet();
32
+ /**
33
+ * Expande los `excludedSubtrees` de un `all` (2D · F10) a la lista plana de
34
+ * scopes que hay que restar: cada scope denegado y TODOS sus descendientes,
35
+ * con el `descendantsOf` del config. Es la forma correcta del `NOT IN`;
36
+ * restar solo los scopes con deny dejaría dentro sus subárboles.
37
+ */
38
+ export function expandExcludedSubtrees(view, excluded) {
39
+ return view.expandExcludedSubtrees(excluded);
40
+ }
41
+ /** Cotas por defecto de `authorizedScopes` (config `scopes.maxScopes` / `scopes.maxDescendants`). */
42
+ export const DEFAULT_MAX_SCOPES = 1_000;
43
+ export const DEFAULT_MAX_DESCENDANTS = 10_000;
44
+ /**
45
+ * Tope sano de `maxScopes`/`maxDescendants` (2.5-B, auditor ⚪6): por encima
46
+ * de ~4,29e9 el hint `SET_VAR(cte_max_recursion_depth)` de MySQL sale de
47
+ * rango y un ciclo en la tabla deja de ser el 422 «posible ciclo» del
48
+ * contrato (503); y ya con 1e6 un ciclo cuesta segundos de CPU por llamada.
49
+ * Una cota mayor es config rota (500), nunca una pregunta.
50
+ */
51
+ export const MAX_SCOPE_BOUND = 10_000_000;
52
+ /** Vida por defecto de una vista de `forRequest()` para LEER (F9): un request, no un módulo. */
53
+ export const DEFAULT_VIEW_MAX_AGE_MS = 30_000;
54
+ /** Reloj monótono del proceso: inmune a NTP, snapshots y `Date.now` parcheado. */
55
+ const monotonicNow = () => performance.now();
2
56
  /**
3
57
  * Manager de autorización — la fachada que usan middleware, services y
4
58
  * seeders. Resuelve el driver activo del config y notifica cada escritura
@@ -11,10 +65,98 @@ import { Exception } from '@adonisjs/core/exceptions';
11
65
  export class AuthorizationManager {
12
66
  #config;
13
67
  #driver = null;
68
+ /** Manager del que esta vista toma el driver (solo en vistas de `forRequest`). */
69
+ #parent = null;
70
+ /** Resolutor memoizado de ESTA vista; `null` = leer con el driver tal cual. */
71
+ #readResolver = null;
72
+ #readDriver = null;
73
+ /** Memo del catálogo propio, solo si el driver no expone el suyo (composición sin puerto). */
74
+ #ownCatalog = null;
75
+ /** Instante (reloj monótono, `#clock`) a partir del cual esta vista ya no puede leer; `null` = sin límite / no es vista. */
76
+ #readsUntil = null;
77
+ /** Reloj monótono con el que se mide `#readsUntil` (inyectable solo en tests). */
78
+ #clock = monotonicNow;
14
79
  constructor(config) {
15
80
  this.#config = config;
81
+ if (config.clock !== undefined && typeof config.clock !== 'function') {
82
+ throw new AuthorizationConfigError(`AuthorizationManager: config.clock debe ser una función () => Date (llegó ${typeof config.clock})`);
83
+ }
84
+ this.#warnOptInSecurity();
85
+ }
86
+ /**
87
+ * `requireWithin` y `requireActor` son opt-in en 2.1 (auditor E2, aceptado
88
+ * y nombrado): con los defaults, un call-site que no pase `within` concede
89
+ * donde le digan y una escritura sin `actor` se audita sin autor. Se avisa
90
+ * UNA vez por config al construir el manager; `warnOnOptInSecurity: false`
91
+ * es la forma explícita de asumirlo.
92
+ */
93
+ #warnOptInSecurity() {
94
+ const config = this.#config;
95
+ if (config.warnOnOptInSecurity === false)
96
+ return;
97
+ const missing = ['requireWithin', 'requireActor'].filter((flag) => !config[flag]);
98
+ if (missing.length === 0 || warnedConfigs.has(config))
99
+ return;
100
+ warnedConfigs.add(config);
101
+ const consequences = {
102
+ requireWithin: "las escrituras sin 'within' van al scope que les digan, sea de quien sea",
103
+ requireActor: 'las escrituras se auditan sin autor',
104
+ };
105
+ console.warn(`authz: seguridad opt-in sin activar — ${missing.join(', ')}: ` +
106
+ `${missing.map((flag) => consequences[flag]).join('; ')}. ` +
107
+ `Actívalo en config/authorization.ts o silencia este aviso con warnOnOptInSecurity: false.`);
16
108
  }
109
+ /**
110
+ * Vista por request (2A/A3): las LECTURAS (`authorize`, `hasRole`, `list*`)
111
+ * resuelven ancestros con `memoizeAncestors(config.scopes.resolveChain)`
112
+ * —una llamada al árbol por scope durante la vida de la vista—; las
113
+ * ESCRITURAS (`grant`, `revoke`, `deny`, `removeDeny`, `scopes.*`) siguen
114
+ * resolviendo en fresco, porque una lectura obsoleta caduca sola y un
115
+ * grant sobre una cadena que ya cambió queda escrito para siempre (auditor
116
+ * C3/E3). El memo es de ANCESTROS, nunca de decisiones: un deny escrito
117
+ * entre dos `authorize` de la misma vista cambia la segunda respuesta.
118
+ *
119
+ * Patrón en Adonis: un middleware hace `ctx.authz = authorization.forRequest()`
120
+ * y controladores y policies leen de `ctx.authz`. Sin `AsyncLocalStorage`:
121
+ * la vista es un objeto explícito con la vida que le des. Sin
122
+ * `config.scopes.resolveChain`, o con un driver de terceros sin
123
+ * `withChainResolver`, la vista lee con el driver tal cual (sin memo)
124
+ * y sigue siendo correcta.
125
+ */
126
+ forRequest(options = {}) {
127
+ const maxAgeMs = options.maxAgeMs ?? DEFAULT_VIEW_MAX_AGE_MS;
128
+ if (!Number.isInteger(maxAgeMs) || maxAgeMs < 0) {
129
+ throw new AuthorizationConfigError(`forRequest: maxAgeMs debe ser un entero >= 0 (0 = sin límite; llegó ${String(maxAgeMs)})`);
130
+ }
131
+ if (options.now !== undefined && typeof options.now !== 'function') {
132
+ throw new AuthorizationConfigError(`forRequest: now debe ser una función (llegó ${typeof options.now})`);
133
+ }
134
+ const view = new _a(this.#config);
135
+ view.#parent = this.#parent ?? this;
136
+ const resolver = this.#config.scopes?.resolveChain;
137
+ view.#readResolver = resolver ? memoizeAncestors(resolver) : null;
138
+ view.#clock = options.now ?? monotonicNow;
139
+ view.#readsUntil = maxAgeMs === 0 ? null : view.#clock() + maxAgeMs;
140
+ return view;
141
+ }
142
+ /**
143
+ * El driver activo, TAL CUAL. Es la salida explícita de las barreras del
144
+ * manager (2D · G4, auditor 8): lo que escribas por aquí no pasa por
145
+ * `actor`/`requireActor`, `within`/`requireWithin` ni `onWrite`, y lo que
146
+ * leas no pasa por el memo de ancestros. Está pensado para el código de
147
+ * PLATAFORMA (seeders, comandos, la escritura en la raíz con
148
+ * `requireWithin: 'non-root'`) y para tests; un call-site de tenant nunca
149
+ * debería llamarlo. No se ofrece nada más por aquí a propósito.
150
+ *
151
+ * Lo único que el manager le aplica al resolverlo es el reloj del config
152
+ * (`clock`, 2.5 · J1) vía `withClock`: no es una barrera, es la hora con
153
+ * la que el driver decide, y vale igual para la plataforma, los tests y
154
+ * cada vista de `forRequest()` (todas leen el driver del padre). Un driver
155
+ * sin `withClock` con `clock` declarado es 500 `E_AUTHZ_CONFIG`.
156
+ */
17
157
  async driver() {
158
+ if (this.#parent)
159
+ return this.#parent.driver();
18
160
  if (this.#driver)
19
161
  return this.#driver;
20
162
  const registry = this.#config.drivers;
@@ -23,53 +165,1557 @@ export class AuthorizationManager {
23
165
  throw new Exception(`Driver de autorización '${this.#config.default}' no registrado. ` +
24
166
  `Registrados: ${Object.keys(this.#config.drivers).join(', ')}`, { status: 500 });
25
167
  }
26
- const driver = await factory();
168
+ let driver = await factory();
169
+ const clock = this.#config.clock;
170
+ if (clock !== undefined) {
171
+ if (typeof driver.withClock !== 'function') {
172
+ throw new AuthorizationConfigError(`config.clock está declarado pero el driver '${this.#config.default}' no implementa withClock(now): ` +
173
+ `el reloj no llegaría a ninguna decisión. Implementa withClock en el driver o quita clock del config.`);
174
+ }
175
+ driver = driver.withClock(clock);
176
+ }
27
177
  this.#driver = driver;
28
178
  return driver;
29
179
  }
30
180
  /** Solo tests: fuerza re-resolución del driver. */
31
181
  clearCachedDriver() {
32
182
  this.#driver = null;
183
+ this.#readDriver = null;
184
+ }
185
+ /**
186
+ * El driver para LEER: en una vista de `forRequest`, el driver con el
187
+ * resolutor memoizado (si el driver sabe darlo); fuera de una vista, el
188
+ * driver tal cual. Las escrituras nunca pasan por aquí.
189
+ */
190
+ /**
191
+ * Una vista caducada no lee (F9): su memo de ancestros puede describir un
192
+ * árbol que ya cambió. Ruidoso a propósito. Lo mide el reloj monótono (H3).
193
+ * Pasan por aquí TODAS las lecturas, `expandExcludedSubtrees` incluida (I2).
194
+ */
195
+ #assertReadable() {
196
+ if (this.#readsUntil !== null && this.#clock() >= this.#readsUntil) {
197
+ throw new ViewExpiredError(`La vista de forRequest() superó su maxAgeMs y ya no puede leer: su memo de ancestros puede estar obsoleto. ` +
198
+ `Crea la vista por request (un middleware) o pasa forRequest({ maxAgeMs: 0 }) a sabiendas.`);
199
+ }
200
+ }
201
+ async #reader() {
202
+ this.#assertReadable();
203
+ const driver = await this.driver();
204
+ if (!this.#readResolver)
205
+ return driver;
206
+ if (this.#readDriver)
207
+ return this.#readDriver;
208
+ this.#readDriver = driver.withChainResolver?.(this.#readResolver) ?? driver;
209
+ return this.#readDriver;
210
+ }
211
+ /**
212
+ * El árbol de scopes es un hecho del contrato: el consumidor notifica sus
213
+ * cambios aquí, en TODOS los drivers, y el PAQUETE valida antes de tocar
214
+ * el driver — la raíz no cuelga de nada, el padre tiene que existir y no
215
+ * puede haber ciclos. FGA acepta un ciclo de `parent` y lo evalúa (un grant
216
+ * en cualquier nodo concede en la raíz, S2), así que la barrera es esta.
217
+ * Espía: si la validación falla, cero llamadas al driver.
218
+ */
219
+ scopes = {
220
+ // `within` (2D · F2; origen y destino desde 2E · H1) se contrasta con el
221
+ // PADRE —colgar o mover algo bajo un scope es escribir en ese scope— Y con
222
+ // la cadena ACTUAL del hijo cuando ya está en el árbol: llevarse un
223
+ // subárbol de otro tenant es peor que purgarlo (se hereda todo lo robado).
224
+ // Por eso el consumidor notifica ANTES de recolgar su fila: la cadena que
225
+ // se contrasta es la de origen, resuelta en fresco.
226
+ attached: async (child, parent, options) => {
227
+ this.#writeOptions(options, 'scopes.attached');
228
+ const chain = await this.#assertEdge(child, parent, 'scopes.attached');
229
+ this.#assertWithinChain(parent, chain, options, 'scopes.attached');
230
+ // Un hijo que el árbol ya conoce se está MOVIENDO (el `attach` de un
231
+ // nodo existente es un `move`): su origen también tiene que estar dentro.
232
+ await this.#assertWithinOrigin(child, options, 'scopes.attached', 'if-known');
233
+ await (await this.driver()).onScopeAttached?.(child, parent);
234
+ },
235
+ moved: async (child, newParent, options) => {
236
+ this.#writeOptions(options, 'scopes.moved');
237
+ const chain = await this.#assertEdge(child, newParent, 'scopes.moved');
238
+ this.#assertWithinChain(newParent, chain, options, 'scopes.moved');
239
+ await this.#assertWithinOrigin(child, options, 'scopes.moved', 'required');
240
+ await (await this.driver()).onScopeMoved?.(child, newParent);
241
+ },
242
+ /**
243
+ * Hechos primero (el driver demuestra cero o lanza), arista después
244
+ * (S6): si la purga muere a medias, el subárbol sigue colgado y los
245
+ * denies heredados siguen valiendo. Sin `within` no comprueba que el
246
+ * scope exista (el consumidor puede haber borrado ya su fila); con
247
+ * `within` (2D · F2) el hijo tiene que seguir en el árbol para
248
+ * contrastar su cadena: purga ANTES de borrar la fila.
249
+ */
250
+ detached: async (child, options) => {
251
+ const actor = this.#writeOptions(options, 'scopes.detached');
252
+ this.#resolver('scopes.detached');
253
+ assertScope(child);
254
+ if (child.type === APP_SCOPE_TYPE) {
255
+ throw new InvalidIdentityError('scopes.detached: la raíz `app` no se puede borrar ni purgar');
256
+ }
257
+ await this.#assertWithin(child, options, 'scopes.detached');
258
+ const driver = await this.driver();
259
+ // La identidad CANÓNICA, una sola vez y para TODO (3E · P2, auditor
260
+ // A2): hasta aquí los hechos se canonizaban dentro del driver y los
261
+ // roles no, así que un alias del uuid del scope —el mismo uuid sin
262
+ // guiones, que el tipo `uuid` de PostgreSQL resuelve a la misma fila y
263
+ // `assertScope` acepta— purgaba las asignaciones y dejaba VIVOS los
264
+ // roles: la mina de V5 volvía, en silencio y sin error.
265
+ // Una SOLA resolución para las dos cosas que dependen de ella: la
266
+ // identidad canónica de los hechos y si el árbol todavía conoce el
267
+ // scope (3G · W2, auditor P2: con `descendantsOf` declarado, un scope
268
+ // que ya no resuelve NO permite demostrar que la purga alcanzó al
269
+ // subárbol, y el resultado tiene que decirlo).
270
+ const chain = await resolveChain(this.#freshResolver(), child, 'scopes.detached');
271
+ const purged = chain ? chain[0] : child;
272
+ // Los roles LOCALES cuyo owner es este scope, PRIMERO (3D · M4, auditor
273
+ // V5): un rol sin owner no es visible en ninguna parte —no concede, no
274
+ // es membresía— pero su fila sobrevivía, `deleteScopedRole` respondía
275
+ // 422 `E_AUTHZ_UNKNOWN_SCOPE` (resuelve el owner en fresco) y ese
276
+ // `(slug, nivel)` quedaba bloqueado para el catálogo global PARA
277
+ // SIEMPRE. Antes que los hechos, para que un driver que no sabe purgar
278
+ // roles (openfga hasta 3b) lo diga con 500 `E_AUTHZ_UNSUPPORTED` sin
279
+ // haber tocado nada.
280
+ const outcome = await this.#purgeRolesOwnedBy(driver, purged, chain, actor.actor);
281
+ const event = {
282
+ action: 'scope_purged',
283
+ scope: purged,
284
+ ...actor,
285
+ ...(outcome.reason ? { reason: outcome.reason } : {}),
286
+ ...(outcome.truncated ? { truncated: true } : {}),
287
+ };
288
+ await this.#write(event, () => driver.purgeScope(purged));
289
+ await driver.onScopeDetached?.(purged);
290
+ await this.#notify(event);
291
+ return outcome;
292
+ },
293
+ };
294
+ /**
295
+ * Purga los roles LOCALES cuyo owner es `scope` —ya CANÓNICO— y, cuando el
296
+ * consumidor declara `scopes.descendantsOf`, los de todo su SUBÁRBOL (3D ·
297
+ * M4; 3E · P2, auditor A4: `detached(padre)` es lo que un consumidor
298
+ * notifica al borrar una rama, y los roles de los hijos quedaban huérfanos
299
+ * e indeleteables, bloqueando su `(slug, nivel)` global para siempre).
300
+ * Sin `descendantsOf` la promesa del invariante 18 se acota al scope
301
+ * exacto y así está escrito.
302
+ *
303
+ * Los roles se leen de la BASE (`readRolesOwnedBy`), no del memo: con una
304
+ * ventana `{ everyMs }` la foto puede no tener lo que otro proceso acaba
305
+ * de confirmar (auditor A2 bis).
306
+ *
307
+ * Policy de rango (3E · P3, auditor A3): `scopes.*` puede colgar de la
308
+ * sesión de un tenant —el invariante 15 lo invita—, así que esta purga de
309
+ * CATÁLOGO exige lo mismo que `deleteScopedRole`: rank del actor MAYOR que
310
+ * el de cada rol, comprobado sobre TODOS antes de tocar ninguno. Sin
311
+ * `actor` (plataforma) se comporta como hasta ahora, y el README lo dice.
312
+ * Si el árbol YA NO conoce el scope, la purga PROCEDE (3F · S1, auditor
313
+ * N2): `detached` es la operación que limpia DESPUÉS de borrar la fila y
314
+ * bloquearla dejaba vivos el rol, sus asignaciones y los denies de un
315
+ * scope que ya no existe —sin ninguna salida por el manager con
316
+ * `requireActor: true`—. Lo que se salta es la comprobación de rango **de
317
+ * los roles cuyo PROPIO owner tampoco resuelve**, no la de todos (3G · W1,
318
+ * auditor P1): medirla en la cadena del scope notificado y aplicarla a
319
+ * roles de otros owners destruía roles de descendientes VIVOS —de
320
+ * cualquier rango, concediendo en ese instante— que `deleteScopedRole` y
321
+ * el `detached` del propio scope niegan con 422. El evento y el valor de
322
+ * retorno lo dicen (`reason`).
323
+ *
324
+ * Cada `purgeRole` es atómico (asignaciones + vínculos + fila + versión) y
325
+ * se notifica `role_purged`; el conjunto no lo es, pero un rol cuyo owner
326
+ * ya no está en el árbol no es visible en ningún sitio, así que una purga
327
+ * a medias no cambia ninguna decisión — solo deja filas que la siguiente
328
+ * llamada recoge.
329
+ */
330
+ async #purgeRolesOwnedBy(driver, scope, chain, actor) {
331
+ const ownerKeys = [scopeKey(scope)];
332
+ const { below, declared, enumerated } = await this.#descendantsOrDegrade(scope, 'scopes.detached');
333
+ for (const node of below)
334
+ ownerKeys.push(scopeKey(node));
335
+ // 3G · W2 (auditor P2): con el scope FUERA del árbol, un `descendantsOf`
336
+ // que responde vacío (o `null`, que aquí es lo mismo) no demuestra que
337
+ // debajo no quede nada — el puerto no le exige responder por un scope
338
+ // que `resolveChain` ya no conoce (docblock de
339
+ // `ScopeDescendantsResolver`)—, así que el resultado no puede decir
340
+ // «completa»: `truncated: true`. Con una lista NO vacía sí se enumeró.
341
+ const unknownScope = chain === null;
342
+ const truncated = declared && (!enumerated || (unknownScope && below.length === 0));
343
+ const owned = await readRolesOwnedBy(ownerKeys, { driver: this.#config.default });
344
+ if (owned.length === 0) {
345
+ // El `reason` sale también con cero roles (3G · W2): lo que dice es que
346
+ // el árbol ya no conoce el scope, y eso vale igual para el consumidor
347
+ // que audita una purga que no encontró nada que purgar.
348
+ return { purgedRoles: 0, truncated, ...(unknownScope ? { reason: 'owner-detached-unknown' } : {}) };
349
+ }
350
+ const purgeRole = this.#optional(driver, 'purgeRole', 'scopes.detached');
351
+ const skipped = actor ? await this.#assertAboveOwnedRoles(driver, actor, owned.map((o) => o.role)) : false;
352
+ const reason = unknownScope || skipped ? 'owner-detached-unknown' : undefined;
353
+ for (const { role, permissions } of owned) {
354
+ const owner = scopeFromKey(role.owner) ?? scope;
355
+ try {
356
+ await purgeRole(role.uuid);
357
+ }
358
+ finally {
359
+ invalidateAuthzCatalog();
360
+ }
361
+ await this.#notifyCatalog({ action: 'role_purged', actor, role, owner, permissions });
362
+ }
363
+ return { purgedRoles: owned.length, truncated, ...(reason ? { reason } : {}) };
364
+ }
365
+ /**
366
+ * El actor de un `scopes.detached` solo tumba roles de rango MENOR que el
367
+ * suyo (3E · P3, auditor A3): un admin de unit con rank 5 no puede
368
+ * destruir por la vía del árbol el rol de rank 40 que `deleteScopedRole`
369
+ * le niega. Se comprueban TODOS antes de purgar ninguno (nada a medias).
370
+ *
371
+ * **El rango se mide POR ROL, en la cadena del OWNER de cada uno** — lo
372
+ * mismo que `deleteScopedRole`, que es la otra puerta a lo mismo (3G · W1,
373
+ * auditor P1). Medirlo en la cadena del scope NOTIFICADO y aplicarlo a
374
+ * roles de OTROS owners era un fail-open de manual: con `descendantsOf`
375
+ * declarado (S2) y la fila del padre ya borrada (S1), `detached(padre)`
376
+ * destruía los roles locales de descendientes VIVOS —de cualquier rango,
377
+ * concediendo en ese instante— porque la cadena del padre no resolvía y la
378
+ * policy no llegaba a correr. Las dos piezas eran correctas por separado.
379
+ *
380
+ * La comprobación se salta SOLO para los roles cuyo PROPIO owner tampoco
381
+ * resuelve: esos son los realmente inalcanzables (no conceden, no son
382
+ * membresía, no se pueden asignar ni borrar por `deleteScopedRole`) y son
383
+ * los que S1 vino a desbloquear. Devuelve `true` si se saltó alguno, para
384
+ * que el evento y el `ScopeDetachOutcome` lo digan (`reason`).
385
+ *
386
+ * Coste: un `resolveChain` y una lectura de roles del actor por OWNER
387
+ * distinto (memoizados por clave), no por rol.
388
+ */
389
+ async #assertAboveOwnedRoles(driver, actor, roles) {
390
+ const catalog = await this.#catalogFor(driver).view();
391
+ // `null` = el owner de ese rol tampoco está en el árbol: nada que medir.
392
+ const rankIn = new Map();
393
+ let skipped = false;
394
+ for (const role of roles) {
395
+ if (!rankIn.has(role.owner)) {
396
+ const owner = this.#ownerOf(role);
397
+ const ownerChain = await resolveChain(this.#freshResolver(), owner, 'scopes.detached');
398
+ rankIn.set(role.owner, ownerChain ? (await this.#rolesAlong(driver, actor, ownerChain, catalog)).rank : null);
399
+ }
400
+ const rank = rankIn.get(role.owner);
401
+ if (rank === null) {
402
+ skipped = true;
403
+ continue;
404
+ }
405
+ this.#assertAboveRole(rank, role);
406
+ }
407
+ return skipped;
408
+ }
409
+ /**
410
+ * Valida las opciones comunes de una escritura (B7) ANTES de identidad,
411
+ * catálogo, árbol y driver: `actor` bien formado si viene; obligatorio con
412
+ * `requireActor`. Devuelve `{ actor }` listo para fundir en el evento (o
413
+ * `{}` si no hay actor: el evento no inventa autores).
414
+ */
415
+ #writeOptions(options, operation) {
416
+ if (options?.actor !== undefined)
417
+ assertSubject(options.actor);
418
+ if (this.#config.requireActor === true && !options?.actor) {
419
+ throw new ActorRequiredError(`${operation}: el config exige 'actor' en toda escritura (requireActor: true) y no llegó ninguno.`);
420
+ }
421
+ return options?.actor ? { actor: options.actor } : {};
422
+ }
423
+ /** El resolutor con el que LEE este manager: el memo de la vista, o el fresco. */
424
+ #readResolverOrFresh() {
425
+ return this.#readResolver ?? this.#freshResolver();
426
+ }
427
+ /**
428
+ * El catálogo para las composiciones (B5/B3): el memo del driver si lo
429
+ * expone (`driver.catalog`, ambos drivers del paquete: una sola carga por
430
+ * proceso) y, si no, uno propio del manager. El catálogo es propiedad
431
+ * local siempre (`authz_*`), así que leerlo desde el manager no acopla a
432
+ * ningún driver.
433
+ */
434
+ #catalogFor(driver) {
435
+ const shared = driver.catalog;
436
+ if (shared instanceof CatalogCache)
437
+ return shared;
438
+ if (this.#parent)
439
+ return this.#parent.#catalogFor(driver);
440
+ this.#ownCatalog ??= new CatalogCache({ driver: this.#config.default });
441
+ return this.#ownCatalog;
442
+ }
443
+ /** Un método opcional del puerto, o 500 `E_AUTHZ_UNSUPPORTED` nombrándolo. */
444
+ #optional(driver, method, primitive, hint) {
445
+ const fn = driver[method];
446
+ if (typeof fn !== 'function') {
447
+ throw new UnsupportedOperationError(method, primitive, this.#config.default, hint);
448
+ }
449
+ return fn.bind(driver);
450
+ }
451
+ /** El resolutor FRESCO del config (o solo-raíz): el de las escrituras y de `isWithin`. */
452
+ #freshResolver() {
453
+ return this.#config.scopes?.resolveChain ?? rootOnlyResolver;
454
+ }
455
+ static #sameScope(a, b) {
456
+ return a.type === b.type && (a.uuid ?? null) === (b.uuid ?? null);
457
+ }
458
+ /**
459
+ * ¿`outer` contiene a `inner`? = `outer ∈ chain(inner)`, inclusive: un scope
460
+ * se contiene a sí mismo y `APP_SCOPE` contiene todo. Un `inner` que el
461
+ * árbol no conoce no está dentro de nada (`false`). Siempre con el resolutor
462
+ * fresco (nunca el memo por request): la contención decide escrituras.
463
+ */
464
+ async isWithin(inner, outer) {
465
+ assertScope(inner);
466
+ assertScope(outer);
467
+ const chain = await resolveChain(this.#freshResolver(), inner, 'isWithin');
468
+ if (!chain)
469
+ return false;
470
+ return chain.some((s) => _a.#sameScope(s, outer));
33
471
  }
472
+ /**
473
+ * Contención de una escritura (B1; las seis desde 2D · F2). El scope tiene
474
+ * que existir (422 `E_AUTHZ_UNKNOWN_SCOPE`, la misma regla que el driver
475
+ * aplicará después) y `within`, si viene, estar en su cadena (422
476
+ * `E_AUTHZ_NOT_WITHIN`). Con `requireWithin`, omitirlo es 422
477
+ * `E_AUTHZ_WITHIN_REQUIRED`; con `'non-root'`, `APP_SCOPE` como `within`
478
+ * es 422 `E_AUTHZ_WITHIN_ROOT_FORBIDDEN`. Todo antes del driver: nada se
479
+ * escribe. Siempre con el resolutor fresco (nunca el memo por request).
480
+ */
481
+ async #assertWithin(scope, options, operation) {
482
+ const within = this.#requiredWithin(scope, options, operation);
483
+ if (!within)
484
+ return;
485
+ const chain = await assertKnownScope(this.#freshResolver(), scope, operation);
486
+ this.#assertWithinChain(scope, chain, options, operation);
487
+ }
488
+ /**
489
+ * Contención del ORIGEN de un movimiento (2E · H1, auditor 1): la cadena
490
+ * ACTUAL del hijo, resuelta en fresco, también tiene que contener `within`.
491
+ * Con `'required'` (`scopes.moved`) el hijo tiene que existir en el árbol
492
+ * (422 `E_AUTHZ_UNKNOWN_SCOPE`: sin cadena no hay origen que contrastar);
493
+ * con `'if-known'` (`scopes.attached`) un hijo nuevo (`null`) no tiene
494
+ * origen y pasa, y uno ya colgado se trata como un `move`. Solo cuando hay
495
+ * `within` que contrastar: sin él no se consulta el árbol de más.
496
+ */
497
+ async #assertWithinOrigin(child, options, operation, presence) {
498
+ const within = this.#requiredWithin(child, options, operation);
499
+ if (!within)
500
+ return;
501
+ const resolver = this.#freshResolver();
502
+ const chain = presence === 'required'
503
+ ? await assertKnownScope(resolver, child, operation)
504
+ : await resolveChain(resolver, child, operation);
505
+ if (!chain)
506
+ return;
507
+ this.#assertWithinChain(child, chain, options, operation);
508
+ }
509
+ /** Lo mismo con una cadena ya resuelta (en fresco) por el llamante. */
510
+ #assertWithinChain(scope, chain, options, operation) {
511
+ const within = this.#requiredWithin(scope, options, operation);
512
+ if (!within)
513
+ return;
514
+ if (!chain.some((s) => _a.#sameScope(s, within))) {
515
+ throw new NotWithinError(`${operation}: ${scope.type}:${scope.uuid ?? ''} no está dentro de ` +
516
+ `${within.type}:${within.uuid ?? ''} (la cadena es ${chain.map((s) => `${s.type}:${s.uuid ?? ''}`).join(' → ')}); ` +
517
+ `no se escribe fuera del scope declarado.`);
518
+ }
519
+ }
520
+ /** El `within` a contrastar, validado y exigido según `requireWithin`; `null` si no hay que contrastar nada. */
521
+ #requiredWithin(scope, options, operation) {
522
+ const within = options?.within;
523
+ const policy = this.#config.requireWithin;
524
+ if (within === undefined) {
525
+ if (policy === true || policy === 'non-root') {
526
+ throw new WithinRequiredError(`${operation}: el config exige 'within' (requireWithin: ${JSON.stringify(policy)}) y la escritura sobre ` +
527
+ `${scope.type}:${scope.uuid ?? ''} no lo declara.`);
528
+ }
529
+ return null;
530
+ }
531
+ assertScope(within);
532
+ if (policy === 'non-root' && within.type === APP_SCOPE_TYPE) {
533
+ throw new WithinRootForbiddenError(`${operation}: el config exige un 'within' que acote (requireWithin: 'non-root') y llegó la raíz 'app', ` +
534
+ `que contiene todo. Declara el tenant; la plataforma escribe en la raíz con manager.driver() o con una config sin el flag.`);
535
+ }
536
+ return within;
537
+ }
538
+ #resolver(operation) {
539
+ const resolver = this.#config.scopes?.resolveChain;
540
+ if (!resolver) {
541
+ throw new AuthorizationConfigError(`${operation} necesita 'scopes.resolveChain' en config/authorization.ts: ` +
542
+ `sin el árbol del consumidor no se puede validar la arista.`);
543
+ }
544
+ return resolver;
545
+ }
546
+ /** Valida la arista y devuelve la cadena (fresca) del padre. */
547
+ async #assertEdge(child, parent, operation) {
548
+ const resolver = this.#resolver(operation);
549
+ assertScope(child);
550
+ assertScope(parent);
551
+ if (child.type === APP_SCOPE_TYPE) {
552
+ throw new InvalidIdentityError(`${operation}: la raíz \`app\` no puede colgar de nada`);
553
+ }
554
+ // 422 E_AUTHZ_UNKNOWN_SCOPE si el padre no existe.
555
+ const chain = await assertKnownScope(resolver, parent, operation);
556
+ // El hijo, si el árbol ya lo conoce, con su identidad canónica (K1): un
557
+ // alias del uuid no puede colarse por debajo de la comprobación de ciclo.
558
+ const known = await resolveChain(resolver, child, operation);
559
+ const childKey = _a.#scopeKey(known ? known[0] : child);
560
+ if (chain.some((s) => _a.#scopeKey(s) === childKey)) {
561
+ throw new ScopeCycleError(`${operation}: ${parent.type}:${parent.uuid} desciende de ${childKey.replace('\u001f', ':')} (o es él mismo); ` +
562
+ `colgarlo cerraría un ciclo y la herencia dejaría de ser solo hacia abajo.`);
563
+ }
564
+ return chain;
565
+ }
566
+ // La identidad se valida AQUÍ, antes de resolver siquiera el driver: una
567
+ // pregunta mal formada (uuid ausente, `{app, uuid}`, slug con `~`) es 422
568
+ // sin tocar catálogo, árbol ni backend, y sin que el hook `onWrite` audite
569
+ // una escritura que no ocurrió. Los drivers repiten la misma función por
570
+ // defensa en profundidad (el juez y un driver suelto no pasan por aquí).
34
571
  async authorize(subject, permission, scope) {
35
- return (await this.driver()).authorize(subject, permission, scope);
572
+ assertIdentity({ subject, permission, scope });
573
+ return (await this.#reader()).authorize(subject, permission, scope);
36
574
  }
37
575
  async hasRole(subject, role, scope) {
38
- return (await this.driver()).hasRole(subject, role, scope);
576
+ assertIdentity({ subject, role, scope });
577
+ return (await this.#reader()).hasRole(subject, role, scope);
39
578
  }
579
+ /**
580
+ * `authorize` sobre varios scopes, un booleano por posición (2.1, B6).
581
+ * Delegado al driver si trae `authorizeMany` (openfga: un batchCheck);
582
+ * si no, `Promise.all` de `authorize` sobre una vista con los ancestros
583
+ * memoizados (una llamada al árbol por scope distinto, aunque se repita).
584
+ * Idéntico a N `authorize`: duplicados por posición, desconocido ⇒ false,
585
+ * y si una posición no se puede responder, lanza entero. Vacío ⇒ `[]`
586
+ * sin tocar backend ni árbol.
587
+ */
588
+ async authorizeMany(subject, permission, scopes) {
589
+ assertIdentity({ subject, permission });
590
+ if (!Array.isArray(scopes)) {
591
+ throw new InvalidIdentityError(`authorizeMany: se esperaba un array de scopes y llegó ${typeof scopes}`);
592
+ }
593
+ for (const scope of scopes)
594
+ assertIdentity({ scope });
595
+ if (scopes.length === 0)
596
+ return [];
597
+ // Fuera de una vista, la composición abre una propia para que los N
598
+ // scopes compartan el memo de ancestros durante esta llamada.
599
+ const view = this.#readResolver ? this : this.forRequest();
600
+ const driver = await view.#reader();
601
+ if (typeof driver.authorizeMany === 'function') {
602
+ const answer = await driver.authorizeMany(subject, permission, scopes);
603
+ // Un `boolean[]` desalineado se leería por posición (F5, CR3): es un
604
+ // bug del driver, no una decisión. 500 nombrando al culpable.
605
+ if (!Array.isArray(answer) || answer.length !== scopes.length || answer.some((b) => typeof b !== 'boolean')) {
606
+ throw new AuthorizationInternalError(`authorizeMany: el driver '${this.#config.default}' devolvió ${describeAnswer(answer)} para ${scopes.length} scopes; ` +
607
+ `el puerto exige un boolean[] con exactamente una posición por scope.`);
608
+ }
609
+ return answer;
610
+ }
611
+ return Promise.all(scopes.map((scope) => driver.authorize(subject, permission, scope)));
612
+ }
613
+ /** Holders con asignación vigente del rol en ese scope exacto. `{ uuid }` es la forma exacta (3D · M1). */
40
614
  async listSubjects(role, scope) {
41
- return (await this.driver()).listSubjects(role, scope);
615
+ assertIdentity({ role, scope });
616
+ return (await this.#reader()).listSubjects(role, scope);
42
617
  }
43
618
  async listScopes(subject, permission) {
44
- return (await this.driver()).listScopes(subject, permission);
619
+ assertIdentity({ subject, permission });
620
+ return (await this.#reader()).listScopes(subject, permission);
45
621
  }
46
622
  async listRoles(subject, scope) {
47
- return (await this.driver()).listRoles(subject, scope);
623
+ assertIdentity({ subject, scope });
624
+ return (await this.#reader()).listRoles(subject, scope);
48
625
  }
49
626
  async listRoleScopes(subject, scopeType) {
50
- return (await this.driver()).listRoleScopes(subject, scopeType);
627
+ assertIdentity({ subject, scopeType });
628
+ return (await this.#reader()).listRoleScopes(subject, scopeType);
51
629
  }
52
- async grant(subject, role, scope, options) {
53
- await (await this.driver()).grant(subject, role, scope, options);
54
- await this.#notify({
55
- action: 'granted',
56
- subject,
57
- scope,
630
+ /** Denies directos del holder (scope exacto, o todos). 500 `E_AUTHZ_UNSUPPORTED` si el driver no lo implementa. */
631
+ async listDenies(subject, scope) {
632
+ assertIdentity(scope ? { subject, scope } : { subject });
633
+ const driver = await this.#reader();
634
+ return this.#optional(driver, 'listDenies', 'listDenies')(subject, scope);
635
+ }
636
+ /**
637
+ * Permisos efectivos del holder en un scope (2.1, B5): la unión de lo que
638
+ * conceden sus roles vigentes en toda la cadena (`listRoles` por nivel +
639
+ * catálogo) MENOS lo denegado en cualquier nivel de la cadena
640
+ * (`listDenies` por nivel). Es exactamente el conjunto `{ p | authorize(p) }`,
641
+ * calculado sin preguntar permiso a permiso. Scope desconocido ⇒ `[]`.
642
+ * Prerrequisito de `catalog/` (Fase 3).
643
+ */
644
+ async effectivePermissions(subject, scope) {
645
+ assertIdentity({ subject, scope });
646
+ const driver = await this.#reader();
647
+ this.#optional(driver, 'listDenies', 'effectivePermissions');
648
+ const chain = await resolveChain(this.#readResolverOrFresh(), scope, 'effectivePermissions');
649
+ if (!chain)
650
+ return [];
651
+ const catalog = await this.#catalogFor(driver).view();
652
+ const { granted } = await this.#rolesAlong(driver, subject, chain, catalog);
653
+ const denied = await this.#deniedAlong(driver, subject, chain, 'effectivePermissions');
654
+ return [...granted].filter((permission) => !denied.has(permission));
655
+ }
656
+ /**
657
+ * Lo que los roles VIGENTES del holder conceden a lo largo de una cadena
658
+ * (ya resuelta), y el rank más alto entre ellos (3B · B3). Roles de toda la
659
+ * cadena en UNA lectura (`rolesInChain`, G5) o, sin el método opcional, N
660
+ * `listRoles`.
661
+ *
662
+ * **Por UUID, nunca por slug (3D · M1).** `rolesInChain` devuelve
663
+ * `CatalogRoleRef`, así que aquí se lee el rol EXACTO que el holder tiene
664
+ * y sus permisos por uuid. El ida y vuelta por slug —resolver otra vez con
665
+ * `roleVisible`— atribuía al holder los permisos de un homónimo: el
666
+ * auditor lo llevó hasta una escalada completa (V1, `effectivePermissions`
667
+ * decía `billing:write` mientras `authorize` decía `false`, y
668
+ * `defineScopedRole` delegaba lo que el actor no tenía). Se conserva la
669
+ * defensa en profundidad: el rol tiene que seguir en el catálogo,
670
+ * declarado para el nivel de la asignación y visible desde ese nivel.
671
+ *
672
+ * Un driver de terceros sin `rolesInChain` solo sabe hablar en slugs: la
673
+ * composición pasa por `roleVisible`, que desde M1 falla CERRADA (422
674
+ * `E_AUTHZ_AMBIGUOUS_ROLE`) si hay homónimos visibles. Nunca elige uno.
675
+ */
676
+ async #rolesAlong(driver, subject, chain, catalog) {
677
+ const keysFrom = chainKeysFrom(chain);
678
+ const levelIndex = new Map(chain.map((s, i) => [scopeKey(s), i]));
679
+ const roles = typeof driver.rolesInChain === 'function'
680
+ ? await driver.rolesInChain(subject, chain)
681
+ : await this.#rolesFromSlugs(driver, subject, chain, keysFrom, catalog);
682
+ const granted = new Set();
683
+ let rank = 0;
684
+ for (const { scope: level, role } of roles) {
685
+ if (!role)
686
+ continue;
687
+ const index = levelIndex.get(scopeKey(level));
688
+ if (index === undefined)
689
+ continue;
690
+ const declared = catalog.roleByUuid(role.uuid);
691
+ if (!declared || declared.scopeType !== level.type)
692
+ continue;
693
+ if (!isRoleVisibleWith(declared, keysFrom[index]))
694
+ continue;
695
+ if (declared.rank > rank)
696
+ rank = declared.rank;
697
+ for (const permission of catalog.rolePermissionsOf(declared.uuid))
698
+ granted.add(permission);
699
+ }
700
+ return { granted, rank };
701
+ }
702
+ /**
703
+ * La composición por defecto de `#rolesAlong` cuando el driver NO trae
704
+ * `rolesInChain` (opcional en el puerto): `listRoles` devuelve slugs y hay
705
+ * que volver del slug al catálogo. Es el camino de un driver de terceros
706
+ * escrito para 2.0/2.1, y hasta 3E tenía dos defectos (tester 3D · R3):
707
+ *
708
+ * - usaba `roleVisible`, que desde M1 LANZA 422 `E_AUTHZ_AMBIGUOUS_ROLE`
709
+ * con dos homónimos visibles: `effectivePermissions` —una LECTURA que
710
+ * promete una lista— explotaba con un 422 en cuanto un `scopes.moved`
711
+ * legítimo juntaba dos roles del mismo nombre;
712
+ * - y elegir uno sería la escalada del auditor V1 (atribuir al holder los
713
+ * permisos del homónimo que NO tiene).
714
+ *
715
+ * La salida es no elegir NI adivinar: con homónimos visibles se pregunta
716
+ * al driver por `{ uuid }` —resolución exacta, parte del puerto desde 3D ·
717
+ * M1— cuál tiene de verdad. Cuesta una consulta más por slug ambiguo (que
718
+ * es deriva y el diff la reporta), y solo en drivers sin `rolesInChain`.
719
+ */
720
+ async #rolesFromSlugs(driver, subject, chain, keysFrom, catalog) {
721
+ const roles = [];
722
+ for (const [index, level] of chain.entries()) {
723
+ for (const slug of await driver.listRoles(subject, level)) {
724
+ const visible = catalog.rolesNamed(slug, level.type).filter((role) => isRoleVisibleWith(role, keysFrom[index]));
725
+ if (visible.length === 1) {
726
+ roles.push({ scope: level, role: visible[0] });
727
+ continue;
728
+ }
729
+ for (const role of visible) {
730
+ if (await driver.hasRole(subject, { uuid: role.uuid }, level))
731
+ roles.push({ scope: level, role });
732
+ }
733
+ }
734
+ }
735
+ return roles;
736
+ }
737
+ /**
738
+ * El/los rol(es) a los que apunta un `RoleQuery` en un scope, para el
739
+ * EVENTO de auditoría (3E · Q7). Best-effort a propósito: NUNCA cambia el
740
+ * resultado de la escritura —lo que decide es el driver, con su catálogo y
741
+ * su árbol—, solo enriquece lo que se notifica. Si algo no cuadra (scope
742
+ * que el árbol no conoce, rol fuera del catálogo, ambigüedad en un grant)
743
+ * el evento sale sin `roles` y el driver dirá lo que corresponda.
744
+ *
745
+ * Solo se calcula si hay un `onWrite` que lo vaya a leer: sin hook no
746
+ * cuesta ni una consulta.
747
+ */
748
+ async #resolvedRoles(role, scope, operation) {
749
+ if (!this.#config.hooks?.onWrite)
750
+ return undefined;
751
+ try {
752
+ const driver = await this.driver();
753
+ const chain = await resolveChain(this.#freshResolver(), scope, operation);
754
+ if (!chain)
755
+ return undefined;
756
+ const catalog = await this.#catalogFor(driver).view();
757
+ const target = chain[0];
758
+ const keys = chainKeysFrom(chain)[0];
759
+ const query = normalizeRoleQuery(role);
760
+ if (query.uuid !== undefined) {
761
+ const declared = catalog.roleByUuid(query.uuid);
762
+ if (!declared || declared.scopeType !== target.type || !isRoleVisibleWith(declared, keys))
763
+ return undefined;
764
+ return [declared];
765
+ }
766
+ if (query.scopeType !== undefined && query.scopeType !== target.type)
767
+ return undefined;
768
+ const visible = catalog.rolesNamed(query.slug, target.type).filter((r) => isRoleVisibleWith(r, keys));
769
+ if (visible.length === 0)
770
+ return undefined;
771
+ // Un `revoke` por slug quita los hechos de TODOS los homónimos del
772
+ // scope, así que el evento los lleva todos; un `grant` ambiguo no
773
+ // llega a escribir (422), y el evento no elige por él.
774
+ return operation === 'revoke' || visible.length === 1 ? visible : undefined;
775
+ }
776
+ catch {
777
+ return undefined;
778
+ }
779
+ }
780
+ /** Permisos DENEGADOS al holder en algún scope de la cadena: `listDenies` en UNA lectura (500 `E_AUTHZ_UNSUPPORTED` sin él). */
781
+ async #deniedAlong(driver, subject, chain, primitive) {
782
+ const listDenies = this.#optional(driver, 'listDenies', primitive);
783
+ const key = _a.#scopeKey;
784
+ const chainKeys = new Set(chain.map(key));
785
+ const denied = new Set();
786
+ for (const deny of await listDenies(subject)) {
787
+ if (chainKeys.has(key(deny.scope)))
788
+ denied.add(deny.permission);
789
+ }
790
+ return denied;
791
+ }
792
+ // ── Roles locales a un scope (3B · B3): la API de DELEGACIÓN ─────────
793
+ // Un administrador de un scope (el actor) define roles que solo existen
794
+ // dentro de ese scope (owner) con permisos que él mismo tiene efectivos
795
+ // ahí y que la plataforma declaró delegables, por debajo de su rank. Es
796
+ // policy de ESCRITURA (composición): `authorize` no cambia (invariantes
797
+ // 1, 2, 8). Todo se resuelve en FRESCO (auditor C3): una vista de
798
+ // `forRequest` con la cadena vieja no puede delegar en una unit que ya
799
+ // cambió de tenant. Escribe con `withAuthzCatalogWrite`: la versión
800
+ // compartida sube como última sentencia y los demás procesos ven el rol
801
+ // en su siguiente pregunta (B7).
802
+ /**
803
+ * Define un rol LOCAL a `ownerScope`. Policy, en este orden y antes de
804
+ * escribir nada: `actor` obligatorio y bien formado; `ownerScope` válido,
805
+ * no la raíz (los roles de la raíz son globales: config + sync) y conocido
806
+ * por el árbol (fresco); `spec` bien formado (slug, nivel ≠ `app`, rank
807
+ * entero, permisos); cada permiso en `config.delegablePermissions`, en el
808
+ * catálogo, componible en ese nivel (`assignableAt`, B5) y EFECTIVO para
809
+ * el actor en el owner (lo concede un rol suyo de la cadena y no lo tiene
810
+ * denegado en ella — C2); `0 < rank < min(rank del actor, rank máximo
811
+ * global)`; y ningún rol `(slug, scopeType)` visible en el owner (global,
812
+ * o local a un ancestro) ni local a un descendiente (colisión, 422
813
+ * `E_AUTHZ_CATALOG_CONFLICT`, re-comprobada dentro de la transacción
814
+ * serializada — 3D · M2). `options.within` contiene la escritura contra el
815
+ * OWNER y `requireWithin` la exige, como en las otras ocho (3D · M3).
816
+ * Devuelve el rol y notifica `role_defined`.
817
+ */
818
+ async defineScopedRole(actor, ownerScope, spec, options) {
819
+ const who = this.#requireActor(actor, 'defineScopedRole');
820
+ this.#assertOwnerScope(ownerScope, 'defineScopedRole');
821
+ const parsed = this.#parseScopedRoleSpec(spec);
822
+ const driver = await this.driver();
823
+ // 3E · P4 (code-review): un rol local que este driver no sabrá purgar es
824
+ // estado que NADA puede borrar — `deleteScopedRole` responde 500 y
825
+ // `scopes.detached` de ese scope (y de cualquier ancestro que lo
826
+ // arrastre) queda muerto para siempre, hechos incluidos. Se dice ANTES
827
+ // de crear nada, no al intentar deshacerlo.
828
+ this.#optional(driver, 'purgeRole', 'defineScopedRole', 'Los roles locales a un scope necesitan poder purgarse; en el driver openfga llegan con el modo `facts` (fase 3b).');
829
+ const chain = await assertKnownScope(this.#freshResolver(), ownerScope, 'defineScopedRole');
830
+ // Contención (3D · M3, auditor V4): es la SÉPTIMA escritura y hasta 3C no
831
+ // la cubría `requireWithin`, así que un holder con un rol en la RAÍZ
832
+ // creaba roles dentro de cualquier tenant (squatting de slugs incluido)
833
+ // con el `ownerScope` que le llegara en el cuerpo de la petición. La
834
+ // cadena ya está resuelta en fresco: se contrasta contra ella.
835
+ this.#assertWithinChain(ownerScope, chain, options, 'defineScopedRole');
836
+ const owner = chain[0];
837
+ const ownerKey = scopeKey(owner);
838
+ const catalog = await this.#catalogFor(driver).view();
839
+ // Composición y lista blanca antes de leer hechos: lo barato primero.
840
+ this.#assertComposable(parsed.permissions, parsed, catalog);
841
+ await this.#assertLevelUnderOwner(parsed.scopeType, chain, 'defineScopedRole');
842
+ const { granted, rank: actorRank } = await this.#rolesAlong(driver, who, chain, catalog);
843
+ const denied = await this.#deniedAlong(driver, who, chain, 'defineScopedRole');
844
+ this.#assertDelegable(parsed.permissions, granted, denied, owner);
845
+ this.#assertRank(parsed.rank, actorRank, catalog.topGlobalRank);
846
+ const shadowedByAncestor = await this.#assertNoRoleCollision(parsed.slug, parsed.scopeType, owner, chain, catalog.rolesNamed(parsed.slug, parsed.scopeType));
847
+ this.#assertAboveShadowed(actorRank, shadowedByAncestor, 'defineScopedRole');
848
+ const uuid = uuidv7();
849
+ const permissionUuids = parsed.permissions.map((slug) => catalog.permission(slug).uuid);
850
+ await this.#writeCatalog(async (trx) => {
851
+ // La colisión, OTRA VEZ, dentro de la transacción SERIALIZADA (M2) y
852
+ // contra la BASE: entre el chequeo de arriba y este hubo un
853
+ // `resolveChain` y dos lecturas al driver —cientos de ms con un árbol
854
+ // SQL— y el memo no ve lo que otro proceso confirmó en esa ventana.
855
+ const rows = await trx
856
+ .from('authz_roles')
857
+ .where('slug', parsed.slug)
858
+ .where('scope_type', parsed.scopeType)
859
+ .select('owner_scope_key');
860
+ const known = new Set(catalog.rolesNamed(parsed.slug, parsed.scopeType).map((r) => r.owner));
861
+ const fresh = [...new Set(rows.map((r) => String(r.owner_scope_key)).filter((o) => !known.has(o)))];
862
+ if (fresh.length) {
863
+ // Los homónimos que el memo no tenía son de una escritura confirmada
864
+ // mientras validábamos. Los que se pueden juzgar SIN salir de la
865
+ // transacción (global, o un ancestro-o-igual del owner: la cadena ya
866
+ // está resuelta) dan el 422 preciso; el resto se rechaza igual —
867
+ // resolver su cadena aquí dentro pediría otra conexión mientras se
868
+ // sostiene el cerrojo del catálogo (con un pool de 1, un abrazo
869
+ // mortal). Reintentar es correcto: el memo ya está invalidado y el
870
+ // segundo intento valida con la foto buena.
871
+ await this.#assertNoRoleCollision(parsed.slug, parsed.scopeType, owner, chain, fresh.map((o) => ({ owner: o })), 'sin-árbol');
872
+ throw new CatalogConflictError(`El catálogo cambió mientras se validaba '${parsed.slug}@${parsed.scopeType}': apareció un rol con ese nombre ` +
873
+ `(owner ${fresh.join(', ')}) que no estaba en la foto con la que se comprobaron las colisiones. ` +
874
+ `No se escribe a ciegas; reintenta la operación.`);
875
+ }
876
+ const now = systemClock();
877
+ await trx.table('authz_roles').insert({
878
+ uuid,
879
+ slug: parsed.slug,
880
+ name: parsed.name,
881
+ description: parsed.description,
882
+ scope_type: parsed.scopeType,
883
+ rank: parsed.rank,
884
+ owner_scope_key: ownerKey,
885
+ created_at: now,
886
+ updated_at: now,
887
+ });
888
+ for (const permissionUuid of permissionUuids) {
889
+ await trx.table('authz_role_permissions').insert({ uuid: uuidv7(), role_uuid: uuid, permission_uuid: permissionUuid, created_at: now });
890
+ }
891
+ });
892
+ const role = Object.freeze({ uuid, slug: parsed.slug, scopeType: parsed.scopeType, owner: ownerKey, rank: parsed.rank });
893
+ await this.#notifyCatalog({
894
+ action: 'role_defined',
895
+ actor: who,
58
896
  role,
59
- expiresAt: options?.expiresAt ?? null,
897
+ owner,
898
+ permissions: [...parsed.permissions].sort(),
899
+ ...(shadowedByAncestor.length ? { shadowedByAncestor } : {}),
60
900
  });
901
+ return role;
902
+ }
903
+ /**
904
+ * Cambia `name`/`description`/`rank`/`permissions` de un rol LOCAL (nunca
905
+ * su slug, nivel ni owner). Un global es 422 `E_AUTHZ_ROLE_IMMUTABLE`. El
906
+ * actor tiene que tener, en el owner del rol, rank MAYOR que el del rol
907
+ * (no se toca un rol de rango ≥ al propio) y la misma policy que al
908
+ * definir para lo que cambia: los permisos nuevos delegables/efectivos y
909
+ * componibles, el rank nuevo por debajo del suyo. Sin cambios reales no
910
+ * escribe ni notifica (idempotente). Notifica `role_updated`.
911
+ */
912
+ async updateScopedRole(actor, roleUuid, changes, options) {
913
+ const who = this.#requireActor(actor, 'updateScopedRole');
914
+ assertCatalogUuid('rol', roleUuid);
915
+ const parsed = this.#parseScopedRoleChanges(changes);
916
+ const driver = await this.driver();
917
+ const catalog = await this.#catalogFor(driver).view();
918
+ const role = this.#localRoleOrFail(catalog, roleUuid);
919
+ const owner = this.#ownerOf(role);
920
+ const chain = await assertKnownScope(this.#freshResolver(), owner, 'updateScopedRole');
921
+ // El scope contrastado es el OWNER del rol (3D · M3): editar un rol es
922
+ // escribir dentro de su contenedor.
923
+ this.#assertWithinChain(owner, chain, options, 'updateScopedRole');
924
+ const current = [...catalog.rolePermissionsOf(role.uuid)].sort();
925
+ const next = {
926
+ name: parsed.name ?? null,
927
+ description: parsed.description,
928
+ rank: parsed.rank ?? role.rank,
929
+ permissions: parsed.permissions ?? current,
930
+ };
931
+ if (parsed.permissions)
932
+ this.#assertComposable(parsed.permissions, role, catalog);
933
+ // 3E · P1: el nivel no cambia por esta API, pero un rol cuyo nivel está
934
+ // POR ENCIMA de su owner (creado antes de 3E o a mano) no se perpetúa:
935
+ // es una mina de slug y lo que toca es purgarlo, no editarlo.
936
+ await this.#assertLevelUnderOwner(role.scopeType, chain, 'updateScopedRole');
937
+ const { granted, rank: actorRank } = await this.#rolesAlong(driver, who, chain, catalog);
938
+ this.#assertAboveRole(actorRank, role);
939
+ // 3G · W3: este rol puede estar ENSOMBRECIENDO al homónimo de un
940
+ // descendiente (3F · S3). Cambiarle rank o permisos es seguir ejerciendo
941
+ // esa autoridad, así que exige lo mismo que crearlo: superarlo en rango.
942
+ this.#assertAboveShadowed(actorRank, await this.#shadowedBelow(scopeKey(owner), new Set(chain.map(scopeKey)), catalog.rolesNamed(role.slug, role.scopeType).filter((other) => other.uuid !== role.uuid), 'updateScopedRole'), 'updateScopedRole');
943
+ if (parsed.permissions) {
944
+ const denied = await this.#deniedAlong(driver, who, chain, 'updateScopedRole');
945
+ this.#assertDelegable(parsed.permissions, granted, denied, owner);
946
+ }
947
+ if (parsed.rank !== undefined)
948
+ this.#assertRank(parsed.rank, actorRank, catalog.topGlobalRank);
949
+ const nextPermissions = [...next.permissions].sort();
950
+ const permissionsChanged = nextPermissions.join('\u001f') !== current.join('\u001f');
951
+ const wanted = new Set(nextPermissions.map((slug) => catalog.permission(slug).uuid));
952
+ const changed = await this.#writeCatalog(async (trx) => {
953
+ const row = (await trx.from('authz_roles').where('uuid', role.uuid).select('name', 'description', 'rank'))[0];
954
+ if (!row)
955
+ throw new UnknownRoleError(role.uuid);
956
+ const patch = {};
957
+ if (next.name !== null && next.name !== row.name)
958
+ patch.name = next.name;
959
+ if (next.description !== undefined && (next.description ?? null) !== (row.description ?? null))
960
+ patch.description = next.description;
961
+ if (next.rank !== Number(row.rank))
962
+ patch.rank = next.rank;
963
+ let touched = false;
964
+ if (Object.keys(patch).length) {
965
+ await trx.from('authz_roles').where('uuid', role.uuid).update({ ...patch, updated_at: systemClock() });
966
+ touched = true;
967
+ }
968
+ if (permissionsChanged) {
969
+ const links = await trx.from('authz_role_permissions').where('role_uuid', role.uuid).select('uuid', 'permission_uuid');
970
+ const linked = new Set(links.map((l) => l.permission_uuid));
971
+ const stale = links.filter((l) => !wanted.has(l.permission_uuid));
972
+ if (stale.length) {
973
+ await trx
974
+ .from('authz_role_permissions')
975
+ .whereIn('uuid', stale.map((l) => l.uuid))
976
+ .delete();
977
+ }
978
+ for (const permissionUuid of wanted) {
979
+ if (linked.has(permissionUuid))
980
+ continue;
981
+ await trx.table('authz_role_permissions').insert({ uuid: uuidv7(), role_uuid: role.uuid, permission_uuid: permissionUuid, created_at: systemClock() });
982
+ }
983
+ touched = true;
984
+ }
985
+ return touched;
986
+ }, { skipIfNoop: true });
987
+ const updated = Object.freeze({ ...role, rank: next.rank });
988
+ if (changed)
989
+ await this.#notifyCatalog({ action: 'role_updated', actor: who, role: updated, owner, permissions: nextPermissions });
990
+ return updated;
991
+ }
992
+ /**
993
+ * Purga un rol LOCAL: sus asignaciones en todos los scopes, sus vínculos y
994
+ * el rol (`driver.purgeRole`, B4; 500 `E_AUTHZ_UNSUPPORTED` en un driver
995
+ * que no lo trae, sin tocar nada). Un global es 422
996
+ * `E_AUTHZ_ROLE_IMMUTABLE`; el actor necesita rank MAYOR que el del rol en
997
+ * su owner. Notifica `role_purged`. No necesita `listDenies`.
998
+ */
999
+ async deleteScopedRole(actor, roleUuid, options) {
1000
+ const who = this.#requireActor(actor, 'deleteScopedRole');
1001
+ assertCatalogUuid('rol', roleUuid);
1002
+ const driver = await this.driver();
1003
+ const purgeRole = this.#optional(driver, 'purgeRole', 'deleteScopedRole');
1004
+ const catalog = await this.#catalogFor(driver).view();
1005
+ const role = this.#localRoleOrFail(catalog, roleUuid);
1006
+ const owner = this.#ownerOf(role);
1007
+ const chain = await assertKnownScope(this.#freshResolver(), owner, 'deleteScopedRole');
1008
+ this.#assertWithinChain(owner, chain, options, 'deleteScopedRole');
1009
+ const { rank: actorRank } = await this.#rolesAlong(driver, who, chain, catalog);
1010
+ this.#assertAboveRole(actorRank, role);
1011
+ const permissions = [...catalog.rolePermissionsOf(role.uuid)].sort();
1012
+ try {
1013
+ await purgeRole(role.uuid);
1014
+ }
1015
+ finally {
1016
+ invalidateAuthzCatalog();
1017
+ }
1018
+ await this.#notifyCatalog({ action: 'role_purged', actor: who, role, owner, permissions });
61
1019
  }
62
- async revoke(subject, role, scope) {
63
- await (await this.driver()).revoke(subject, role, scope);
64
- await this.#notify({ action: 'revoked', subject, scope, role });
1020
+ /** El actor de la API de delegación: obligatorio SIEMPRE (sin él no hay policy que evaluar) y bien formado. */
1021
+ #requireActor(actor, operation) {
1022
+ if (actor === undefined || actor === null) {
1023
+ throw new ActorRequiredError(`${operation}: el actor es obligatorio (es quien delega; sin él no hay policy que evaluar).`);
1024
+ }
1025
+ assertSubject(actor);
1026
+ return actor;
1027
+ }
1028
+ /** El owner de un rol local: un scope válido que no sea la raíz (sus roles son globales y se declaran en el config). */
1029
+ #assertOwnerScope(ownerScope, operation) {
1030
+ assertScope(ownerScope);
1031
+ if (ownerScope.type === APP_SCOPE_TYPE) {
1032
+ throw new InvalidIdentityError(`${operation}: la raíz 'app' no puede ser owner de un rol local; los roles de la raíz son globales y se declaran ` +
1033
+ `en el catálogo del config (syncAuthzCatalog).`);
1034
+ }
1035
+ }
1036
+ #parseScopedRoleSpec(spec) {
1037
+ if (!spec || typeof spec !== 'object')
1038
+ throw new InvalidIdentityError(`Spec de rol local inválido: llegó ${spec === null ? 'null' : typeof spec}`);
1039
+ assertValidSlug('rol', spec.slug);
1040
+ assertScopeType(spec.scopeType);
1041
+ if (spec.scopeType === APP_SCOPE_TYPE) {
1042
+ throw new InvalidIdentityError(`Spec de rol local inválido: un rol local no puede ser de nivel 'app' (la raíz no está dentro de ningún owner).`);
1043
+ }
1044
+ const rank = this.#parseRank(spec.rank);
1045
+ if (rank === undefined)
1046
+ throw new InvalidIdentityError(`Spec de rol local inválido: 'rank' es obligatorio (entero).`);
1047
+ const name = this.#parseName(spec.name) ?? spec.slug;
1048
+ const description = this.#parseDescription(spec.description) ?? null;
1049
+ const permissions = this.#parsePermissions(spec.permissions);
1050
+ // 3D · N3 (auditor V7): un rol sin permisos no concede nada y ocupa el
1051
+ // `(slug, nivel)` del owner —y del subárbol— para siempre. Es squatting
1052
+ // con forma de spec: 422.
1053
+ if (permissions.length === 0) {
1054
+ throw new InvalidIdentityError(`Spec de rol local inválido: 'permissions' está vacío. Un rol que no concede nada solo ocupa el ` +
1055
+ `(slug, nivel) de su owner; si lo que quieres es reservarlo, hazlo con un permiso real.`);
1056
+ }
1057
+ return { slug: spec.slug, scopeType: spec.scopeType, name, description, rank, permissions };
1058
+ }
1059
+ #parseScopedRoleChanges(changes) {
1060
+ if (!changes || typeof changes !== 'object')
1061
+ throw new InvalidIdentityError(`Cambios de rol local inválidos: llegó ${changes === null ? 'null' : typeof changes}`);
1062
+ // 3D · N2 (tester H6): `slug`, `scopeType` y `owner` NO se cambian por
1063
+ // esta API —el README lo promete— y hasta aquí se ignoraban EN SILENCIO:
1064
+ // quien pasaba `{ slug: 'otro' }` creía haber renombrado el rol. Lo que
1065
+ // no se puede hacer se dice.
1066
+ const allowed = new Set(['name', 'description', 'rank', 'permissions']);
1067
+ const unknown = Object.keys(changes).filter((key) => !allowed.has(key));
1068
+ if (unknown.length) {
1069
+ throw new InvalidIdentityError(`Cambios de rol local inválidos: '${unknown.join("', '")}' no se puede${unknown.length > 1 ? 'n' : ''} cambiar ` +
1070
+ `(un rol local no cambia de slug, nivel ni owner: purga y define otro). Campos admitidos: ${[...allowed].join(', ')}.`);
1071
+ }
1072
+ return {
1073
+ name: this.#parseName(changes.name),
1074
+ description: this.#parseDescription(changes.description),
1075
+ rank: this.#parseRank(changes.rank),
1076
+ permissions: changes.permissions === undefined ? undefined : this.#parsePermissions(changes.permissions),
1077
+ };
1078
+ }
1079
+ #parseRank(rank) {
1080
+ if (rank === undefined)
1081
+ return undefined;
1082
+ if (typeof rank !== 'number' || !Number.isInteger(rank)) {
1083
+ throw new InvalidIdentityError(`rank inválido: se esperaba un entero y llegó ${typeof rank === 'number' ? rank : typeof rank}`);
1084
+ }
1085
+ return rank;
1086
+ }
1087
+ #parseName(name) {
1088
+ if (name === undefined)
1089
+ return undefined;
1090
+ if (typeof name !== 'string' || name.length === 0 || name.length > ROLE_NAME_MAX) {
1091
+ throw new InvalidIdentityError(`name inválido: se esperaba una cadena de 1 a ${ROLE_NAME_MAX} caracteres`);
1092
+ }
1093
+ return name;
1094
+ }
1095
+ #parseDescription(description) {
1096
+ if (description === undefined)
1097
+ return undefined;
1098
+ if (description === null)
1099
+ return null;
1100
+ if (typeof description !== 'string' || description.length > ROLE_DESCRIPTION_MAX) {
1101
+ throw new InvalidIdentityError(`description inválida: se esperaba una cadena de hasta ${ROLE_DESCRIPTION_MAX} caracteres, o null`);
1102
+ }
1103
+ return description;
1104
+ }
1105
+ #parsePermissions(permissions) {
1106
+ if (!Array.isArray(permissions))
1107
+ throw new InvalidIdentityError(`permissions inválido: se esperaba una lista de slugs y llegó ${typeof permissions}`);
1108
+ for (const slug of permissions)
1109
+ assertValidSlug('permiso', slug);
1110
+ return [...new Set(permissions)];
1111
+ }
1112
+ /** Lista blanca, existencia en el catálogo y composición por nivel (B5), en ese orden. */
1113
+ #assertComposable(permissions, role, catalog) {
1114
+ const delegable = new Set(this.#config.delegablePermissions ?? []);
1115
+ for (const slug of permissions) {
1116
+ if (!delegable.has(slug)) {
1117
+ throw new PermissionNotDelegableError(`'${slug}' no se puede delegar: no está en config.delegablePermissions ` +
1118
+ `(${delegable.size ? [...delegable].join(', ') : 'vacía: nadie delega nada hasta declararla'}).`);
1119
+ }
1120
+ const permission = catalog.permission(slug);
1121
+ if (!permission)
1122
+ throw new UnknownPermissionError(slug);
1123
+ assertAssignableAt(role, slug, permission.assignableAt);
1124
+ }
1125
+ }
1126
+ /** Cada permiso tiene que ser EFECTIVO para el actor en el owner: concedido por un rol suyo de la cadena y no denegado en ella (C2). */
1127
+ #assertDelegable(permissions, granted, denied, owner) {
1128
+ for (const slug of permissions) {
1129
+ if (denied.has(slug)) {
1130
+ throw new PermissionNotDelegableError(`'${slug}' no se puede delegar: el actor lo tiene DENEGADO en ${owner.type}:${owner.uuid ?? ''} (o en un ancestro); ` +
1131
+ `un deny no se lava componiendo un rol para otro.`);
1132
+ }
1133
+ if (!granted.has(slug)) {
1134
+ throw new PermissionNotDelegableError(`'${slug}' no se puede delegar: el actor no lo tiene efectivo en ${owner.type}:${owner.uuid ?? ''} ` +
1135
+ `(ningún rol vigente suyo en esa cadena lo concede).`);
1136
+ }
1137
+ }
1138
+ }
1139
+ /** `0 < rank < min(rank del actor, rank máximo global)`: policy de escritura, no de evaluación (invariante 8). */
1140
+ #assertRank(rank, actorRank, topGlobalRank) {
1141
+ const ceiling = Math.min(actorRank, topGlobalRank);
1142
+ if (!(rank > 0 && rank < ceiling)) {
1143
+ throw new RankExceededError(`rank ${rank} fuera de la policy: tiene que cumplir 0 < rank < ${ceiling} (min(rank del actor = ${actorRank}, rank máximo global = ${topGlobalRank})).`);
1144
+ }
1145
+ }
1146
+ /**
1147
+ * El nivel de un rol local nunca está POR ENCIMA de su owner (3E · P1,
1148
+ * auditor A1). La regla es mínima a propósito y se decide con la cadena
1149
+ * que ya está resuelta, sin pedirle nada más al consumidor:
1150
+ *
1151
+ * - `scopeType === owner.type` ⇒ vale (el caso propio).
1152
+ * - `scopeType` es el nivel de un ANCESTRO del owner (`app` incluida,
1153
+ * que está en toda cadena) ⇒ 422 `E_AUTHZ_ROLE_LEVEL_ABOVE_OWNER`. Es
1154
+ * la mina: un `operador@organization` cuyo owner es una unit jamás es
1155
+ * visible —no concede, no es membresía, nadie lo puede asignar— y lo
1156
+ * único que hace es OCUPAR ese `(slug, nivel)` para el dueño del árbol
1157
+ * y para el catálogo GLOBAL; el actor de menor privilegio del sistema
1158
+ * bloqueando a la plataforma (y, hasta 3E, su deploy entero).
1159
+ * - Cualquier otro tipo se presume DESCENDIENTE y vale: es el caso común
1160
+ * (`lead@unit` con owner una organization) y exigir `descendantsOf`
1161
+ * para él rompía a todo consumidor con el stub publicado, que no lo
1162
+ * declara.
1163
+ * - Con `scopes.descendantsOf` declarado se ENDURECE: el tipo tiene que
1164
+ * aparecer de verdad bajo el owner en el árbol de HOY; si no, 422.
1165
+ *
1166
+ * **Lo que cuesta la degradación, dicho** (3G · X1, auditor P4): si el
1167
+ * subárbol no se puede enumerar (cota superada o `descendantsOf` caído) la
1168
+ * regla vuelve a ser la MÍNIMA — y el propio actor puede provocarlo
1169
+ * creando más hijos de su scope que `maxDescendants`, porque crear scopes
1170
+ * es una función normal del producto. Es un control que el vigilado apaga.
1171
+ * Se acepta a sabiendas: la regla mínima no concede NADA (es la que corre
1172
+ * en todo consumidor con el stub publicado), y el daño residual —ocupar un
1173
+ * `(slug, nivel)`— es reparable por AUTORIDAD + RANGO: un ancestro con
1174
+ * rango por encima define el suyo y lo ensombrece (3F · S3 + 3G · W3), y
1175
+ * la plataforma siempre puede `purgeRole`. Quien no acepte ese trato deja
1176
+ * `maxDescendants` por encima de su subárbol mayor y vigila `truncated`.
1177
+ *
1178
+ * (Un `scopeType` de nivel `app` muere antes, en `#parseScopedRoleSpec`:
1179
+ * la raíz no cuelga de ningún owner. Si llegara aquí sería un ancestro.)
1180
+ */
1181
+ async #assertLevelUnderOwner(scopeType, chain, operation) {
1182
+ const owner = chain[0];
1183
+ if (scopeType === owner.type)
1184
+ return;
1185
+ const above = chain.slice(1).find((scope) => scope.type === scopeType);
1186
+ if (above) {
1187
+ throw new RoleLevelAboveOwnerError(`${operation}: un rol local de nivel '${scopeType}' está POR ENCIMA de su owner ${owner.type}:${owner.uuid ?? ''} ` +
1188
+ `('${scopeType}' es el nivel de ${above.type}:${above.uuid ?? ''}, un ancestro suyo en la cadena). Un rol así no ` +
1189
+ `sería visible en ninguna parte: no concedería nada y solo ocuparía ese (slug, nivel) para el resto del árbol y ` +
1190
+ `para el catálogo global. Defínelo en el nivel del owner o en uno por debajo.`);
1191
+ }
1192
+ // Lo demás se presume por debajo; con el árbol del consumidor a mano, se comprueba.
1193
+ const { below, enumerated } = await this.#descendantsOrDegrade(owner, operation);
1194
+ if (!enumerated)
1195
+ return;
1196
+ if (below.some((scope) => scope.type === scopeType))
1197
+ return;
1198
+ const levels = [...new Set(below.map((scope) => scope.type))].sort();
1199
+ throw new RoleLevelAboveOwnerError(`${operation}: un rol local de nivel '${scopeType}' no cuelga de ${owner.type}:${owner.uuid ?? ''} ` +
1200
+ `(bajo él hoy hay ${levels.length ? `niveles ${levels.join(', ')}` : 'ningún scope'}), así que no sería visible en ` +
1201
+ `ninguna parte: no concedería nada y solo ocuparía ese (slug, nivel) para el resto del árbol y para el catálogo global. ` +
1202
+ `Define el rol en el nivel del owner o en uno que cuelgue de él.`);
1203
+ }
1204
+ /**
1205
+ * Solo se toca un rol de rango MENOR que el propio.
1206
+ *
1207
+ * El mensaje solo nombra el rol cuando el actor tiene ALGO en esa cadena
1208
+ * (rank > 0): con rank 0 no tiene ningún rol vigente ahí, así que el rol
1209
+ * pertenece a un árbol que no es suyo y decirle su slug y su rank
1210
+ * convertía el 422 en una sonda de catálogo ajeno —`scopes.detached` de la
1211
+ * unit de otro tenant, sin `within`, enumeraba sus roles locales sin
1212
+ * escribir nada (3G · X5, auditor P7)—. Es la misma regla que ya se aplicó
1213
+ * a `E_AUTHZ_AMBIGUOUS_ROLE` (3E · Q2): se nombra lo que el llamante ya
1214
+ * puede ver, nada más.
1215
+ */
1216
+ #assertAboveRole(actorRank, role) {
1217
+ if (actorRank > role.rank)
1218
+ return;
1219
+ if (actorRank === 0) {
1220
+ throw new RankExceededError(`El actor no tiene ningún rol vigente en la cadena de ese scope (rank 0), así que no puede tocar los roles ` +
1221
+ `locales que hay ahí: hace falta rank mayor que el del rol.`);
1222
+ }
1223
+ throw new RankExceededError(`El actor (rank ${actorRank} en el owner del rol) no puede tocar '${role.slug}' (rank ${role.rank}): hace falta rank mayor que el del rol.`);
1224
+ }
1225
+ /** El rol por uuid, y LOCAL: un global es inmutable por esta API. */
1226
+ #localRoleOrFail(catalog, roleUuid) {
1227
+ const role = catalog.roleByUuid(roleUuid);
1228
+ if (!role)
1229
+ throw new UnknownRoleError(roleUuid);
1230
+ if (role.owner === GLOBAL_OWNER_KEY) {
1231
+ throw new RoleImmutableError(`El rol '${role.slug}@${role.scopeType}' es GLOBAL (catálogo del config): se cambia en el config y se sincroniza; ` +
1232
+ `por la API de delegación es inmutable.`);
1233
+ }
1234
+ return role;
1235
+ }
1236
+ /** El scope owner de un rol local (de su clave). Una clave que no es un scope es catálogo corrupto (500). */
1237
+ #ownerOf(role) {
1238
+ const owner = scopeFromKey(role.owner);
1239
+ if (!owner || owner.type === APP_SCOPE_TYPE) {
1240
+ throw new AuthorizationInternalError(`El owner del rol '${role.slug}' (${role.uuid}) no es una clave de scope: '${role.owner}'`);
1241
+ }
1242
+ return owner;
1243
+ }
1244
+ /**
1245
+ * La colisión se decide por AUTORIDAD (3F · S3, auditor N1): *una
1246
+ * definición más autorizada gana y ensombrece a la menos autorizada* —
1247
+ * global > local de un ancestro > local de un descendiente—, que es la
1248
+ * regla que 3E ya tomó para los globales frente a los locales.
1249
+ *
1250
+ * - Un homónimo GLOBAL, o local a un ancestro-o-igual del owner, es 422
1251
+ * `E_AUTHZ_CATALOG_CONFLICT`: hacia ARRIBA no se ensombrece a nadie.
1252
+ * - Un homónimo local a un DESCENDIENTE del owner ya NO colisiona: el
1253
+ * nuevo se crea —si el actor SUPERA EN RANGO al que va a ensombrecer
1254
+ * (3G · W3, `#assertAboveShadowed`: la autoridad no es solo posición)—
1255
+ * y el del descendiente queda ENSOMBRECIDO (se devuelve para el evento
1256
+ * `role_defined` y el diff lo lista como `shadowedByAncestor`). Hasta 3E era 422, y con eso el actor de menor
1257
+ * privilegio del sistema le ocupaba el nombre al DUEÑO del árbol —para
1258
+ * siempre, salvo purga rol a rol— y dejaba `authz:catalog:diff` en rojo,
1259
+ * que es el gate de CI del deploy. Ahora la mina solo se ensombrece a sí
1260
+ * misma: dentro de SU subárbol el slug pasa a 422 `E_AUTHZ_AMBIGUOUS_ROLE`
1261
+ * (M1, fail-closed) y se opera por `{ uuid }`, exactamente como con un
1262
+ * global. No concede nada de más: el hecho apunta al uuid del rol.
1263
+ *
1264
+ * Los owners de los homónimos se resuelven en fresco; uno que el árbol ya
1265
+ * no conoce no colisiona ni se ensombrece (no es visible en ningún sitio).
1266
+ *
1267
+ * `others` son los homónimos a contrastar: la foto del memo en el chequeo
1268
+ * BARATO (antes de abrir la transacción, para no pagar una transacción por
1269
+ * una colisión evidente) y las filas leídas de la BASE dentro de la
1270
+ * transacción serializada (3D · M2), que es el que manda. Con solo el
1271
+ * primero, dos `define` en paralelo —o un `define` contra un `sync`—
1272
+ * insertaban los dos homónimos y el estado era permanente (auditor V2).
1273
+ * En modo `sin-árbol` (dentro de la transacción, con el cerrojo sostenido)
1274
+ * no se resuelve ninguna cadena: el llamante rechaza igual lo que no puede
1275
+ * juzgar y pide reintentar.
1276
+ */
1277
+ async #assertNoRoleCollision(slug, scopeType, owner, ownerChain, others, mode = 'con-árbol') {
1278
+ const ownerKey = scopeKey(owner);
1279
+ const ancestors = new Set(ownerChain.map(scopeKey));
1280
+ // Lo que se juzga SIN tocar el árbol, primero: un 422 evidente no paga
1281
+ // un `resolveChain` por homónimo.
1282
+ for (const other of others) {
1283
+ let where = null;
1284
+ if (other.owner === GLOBAL_OWNER_KEY)
1285
+ where = 'global (catálogo del config)';
1286
+ else if (ancestors.has(other.owner))
1287
+ where = other.owner === ownerKey ? 'este mismo scope' : `un ancestro (${other.owner})`;
1288
+ if (where) {
1289
+ throw new CatalogConflictError(`Ya existe un rol '${slug}' de nivel '${scopeType}' visible desde ${owner.type}:${owner.uuid ?? ''}: es ${where}. ` +
1290
+ `Dentro de un scope un (slug, nivel) identifica un solo rol; elige otro slug.`);
1291
+ }
1292
+ }
1293
+ if (mode === 'sin-árbol')
1294
+ return [];
1295
+ return this.#shadowedBelow(ownerKey, ancestors, others, 'defineScopedRole');
65
1296
  }
66
- async deny(subject, permission, scope) {
67
- await (await this.driver()).deny(subject, permission, scope);
68
- await this.#notify({ action: 'denied', subject, scope, permission });
1297
+ /**
1298
+ * Los homónimos LOCALES a un DESCENDIENTE del owner: los que una
1299
+ * definición en `ownerKey` ENSOMBRECE (3F · S3). Los owners se resuelven
1300
+ * en fresco; uno que el árbol ya no conoce no ensombrece a nadie (no es
1301
+ * visible en ninguna parte). Lo usan `defineScopedRole` (la colisión) y
1302
+ * `updateScopedRole` (que no crea sombras nuevas, pero tampoco deja tocar
1303
+ * un rol que ya ensombrece a otro de más rango — 3G · W3).
1304
+ */
1305
+ async #shadowedBelow(ownerKey, ancestors, others, operation) {
1306
+ const shadowed = [];
1307
+ for (const other of others) {
1308
+ if (other.owner === GLOBAL_OWNER_KEY || ancestors.has(other.owner))
1309
+ continue;
1310
+ if (!('uuid' in other) || !('rank' in other))
1311
+ continue;
1312
+ const otherOwner = scopeFromKey(other.owner);
1313
+ const chain = otherOwner ? await resolveChain(this.#freshResolver(), otherOwner, operation) : null;
1314
+ if (chain && chain.some((s) => scopeKey(s) === ownerKey))
1315
+ shadowed.push(other);
1316
+ }
1317
+ return shadowed;
69
1318
  }
70
- async removeDeny(subject, permission, scope) {
71
- await (await this.driver()).removeDeny(subject, permission, scope);
72
- await this.#notify({ action: 'deny_removed', subject, scope, permission });
1319
+ /**
1320
+ * Sobre un rol solo actúa quien lo SUPERA EN RANGO — también para
1321
+ * ensombrecerlo (3G · W3, auditor P3′). Ensombrecer es tan destructivo
1322
+ * como borrar: dentro del subárbol del ensombrecido toda ruta por slug
1323
+ * pasa a 422 `E_AUTHZ_AMBIGUOUS_ROLE` para TODOS, y la víctima no puede
1324
+ * repararlo (su rango se mide en la cadena del owner del rol que
1325
+ * ensombrece, donde no vale nada). Hasta 3F la autoridad era solo POSICIÓN
1326
+ * y un actor de rank 3 en la organization inutilizaba por slug un rol de
1327
+ * rank 40 de una unit, en toda su cadena y para siempre.
1328
+ *
1329
+ * El mensaje NO nombra el rank ni el owner del ensombrecido: un ancestro
1330
+ * no ve los roles de sus descendientes (la visibilidad solo baja), así que
1331
+ * el 422 no puede ser una sonda del catálogo de abajo (misma regla que
1332
+ * `E_AUTHZ_AMBIGUOUS_ROLE`, 3E · Q2).
1333
+ */
1334
+ #assertAboveShadowed(actorRank, shadowed, operation) {
1335
+ for (const role of shadowed) {
1336
+ if (actorRank > role.rank)
1337
+ continue;
1338
+ throw new RankExceededError(`${operation}: por debajo de este owner ya hay un rol local '${role.slug}' de nivel '${role.scopeType}' con rank ` +
1339
+ `MAYOR O IGUAL al tuyo (tu rank aquí es ${actorRank}). Definir el tuyo lo ensombrecería —dentro de su subárbol ` +
1340
+ `ese slug pasaría a ser ambiguo para todos y su dueño no podría repararlo—, y sobre un rol solo actúa quien lo ` +
1341
+ `supera en rango, igual que en deleteScopedRole. Elige otro slug.`);
1342
+ }
1343
+ }
1344
+ /**
1345
+ * LA escritura del catálogo por el manager: `withAuthzCatalogWrite` (la
1346
+ * versión compartida sube como última sentencia, dentro) y, al salir
1347
+ * —bien o mal—, se invalidan los memos de este proceso (como el sync). Con
1348
+ * `skipIfNoop`, un `fn` que devuelve `false` no ha escrito nada y la
1349
+ * versión no se toca (se revierte la transacción vacía).
1350
+ */
1351
+ async #writeCatalog(fn, options = {}) {
1352
+ const noop = Symbol('noop');
1353
+ try {
1354
+ return await withAuthzCatalogWrite(async (trx) => {
1355
+ const result = await fn(trx);
1356
+ if (options.skipIfNoop && result === false)
1357
+ throw noop;
1358
+ return result;
1359
+ }, { driver: this.#config.default });
1360
+ }
1361
+ catch (error) {
1362
+ if (error === noop)
1363
+ return false;
1364
+ throw error;
1365
+ }
1366
+ finally {
1367
+ invalidateAuthzCatalog();
1368
+ }
1369
+ }
1370
+ async #notifyCatalog(event) {
1371
+ try {
1372
+ await this.#config.hooks?.onCatalogWrite?.(event);
1373
+ }
1374
+ catch (error) {
1375
+ const context = `authz: el hook onCatalogWrite falló tras '${event.action}' (la escritura sí se aplicó)`;
1376
+ try {
1377
+ const { default: logger } = await import('@adonisjs/core/services/logger');
1378
+ logger.error({ err: error, event }, context);
1379
+ }
1380
+ catch {
1381
+ console.error(context, error);
1382
+ }
1383
+ }
1384
+ }
1385
+ /**
1386
+ * Asigna un rol al holder en un scope. `role` es un `RoleQuery` (3D · M1):
1387
+ * un slug, `{ slug, scopeType }` o `{ uuid }` — esta última es la forma
1388
+ * exacta, la única que responde cuando dos roles locales homónimos son
1389
+ * visibles en la misma cadena (las otras dos son 422
1390
+ * `E_AUTHZ_AMBIGUOUS_ROLE`, nunca «el más cercano gana»).
1391
+ */
1392
+ async grant(subject, role, scope, options) {
1393
+ const actor = this.#writeOptions(options, 'grant');
1394
+ assertIdentity({ subject, role, scope, expiresAt: options?.expiresAt });
1395
+ await this.#assertWithin(scope, options, 'grant');
1396
+ // 3E · Q7: el evento lleva el rol RESUELTO (uuid + slug + nivel + owner),
1397
+ // no la pregunta cruda. Solo se resuelve si hay hook que lo vaya a leer.
1398
+ const roles = await this.#resolvedRoles(role, scope, 'grant');
1399
+ const outcome = (await this.#write({ action: 'granted', subject, scope, roles, expiresAt: options?.expiresAt ?? null, ...actor }, async () => (await this.driver()).grant(subject, role, scope, options))) ??
1400
+ // Un driver de terceros que aún devuelva `void`: la firma promete un
1401
+ // `GrantOutcome` y no miente (E1). Sin lectura previa no hay caducidad
1402
+ // anterior que contar: es lo que pidió el llamante, y `existed: false`.
1403
+ { existed: false, expiresAt: options?.expiresAt ?? null };
1404
+ // Un re-grant que cambia la caducidad de una asignación existente es un
1405
+ // evento distinto (L0.4): quien audita necesita ver de cuál a cuál.
1406
+ if (expiryChanged(outcome)) {
1407
+ await this.#notify({
1408
+ action: 'extended',
1409
+ subject,
1410
+ scope,
1411
+ roles,
1412
+ expiresAt: outcome.expiresAt,
1413
+ previousExpiresAt: outcome.previousExpiresAt,
1414
+ ...actor,
1415
+ });
1416
+ }
1417
+ else {
1418
+ await this.#notify({
1419
+ action: 'granted',
1420
+ subject,
1421
+ scope,
1422
+ roles,
1423
+ expiresAt: outcome.expiresAt,
1424
+ ...actor,
1425
+ });
1426
+ }
1427
+ return outcome;
1428
+ }
1429
+ /**
1430
+ * Quita la asignación del rol en ese scope exacto. Por slug se quitan las
1431
+ * de TODOS los homónimos `(slug, nivel)` (3B; quitar nunca concede, y el
1432
+ * scope puede no existir ya para el árbol); por `{ uuid }`, solo la de ese
1433
+ * rol.
1434
+ */
1435
+ async revoke(subject, role, scope, options) {
1436
+ const actor = this.#writeOptions(options, 'revoke');
1437
+ assertIdentity({ subject, role, scope });
1438
+ await this.#assertWithin(scope, options, 'revoke');
1439
+ const event = { action: 'revoked', subject, scope, roles: await this.#resolvedRoles(role, scope, 'revoke'), ...actor };
1440
+ await this.#write(event, async () => (await this.driver()).revoke(subject, role, scope));
1441
+ await this.#notify(event);
1442
+ }
1443
+ async deny(subject, permission, scope, options) {
1444
+ const actor = this.#writeOptions(options, 'deny');
1445
+ assertIdentity({ subject, permission, scope });
1446
+ await this.#assertWithin(scope, options, 'deny');
1447
+ const event = { action: 'denied', subject, scope, permission, ...actor };
1448
+ await this.#write(event, async () => (await this.driver()).deny(subject, permission, scope));
1449
+ await this.#notify(event);
1450
+ }
1451
+ async removeDeny(subject, permission, scope, options) {
1452
+ const actor = this.#writeOptions(options, 'removeDeny');
1453
+ assertIdentity({ subject, permission, scope });
1454
+ await this.#assertWithin(scope, options, 'removeDeny');
1455
+ const event = { action: 'deny_removed', subject, scope, permission, ...actor };
1456
+ await this.#write(event, async () => (await this.driver()).removeDeny(subject, permission, scope));
1457
+ await this.#notify(event);
1458
+ }
1459
+ /**
1460
+ * Scopes de un tipo donde el holder tiene el permiso (2.1, B3). La ÚNICA
1461
+ * API del paquete que enumera descendientes — excepción explícita al
1462
+ * invariante 7 (`list*` siguen siendo directos) — y lo hace con el
1463
+ * `descendantsOf` del consumidor, nunca con N+1 `resolveChain` a ciegas.
1464
+ *
1465
+ * Regla:
1466
+ * 1. `listScopes(subject, permission)`: los scopes DIRECTOS que conceden,
1467
+ * ya sin los bloqueados por un deny en su cadena y sin los que el árbol
1468
+ * no conoce. Vacío ⇒ `none`.
1469
+ * 2. Si la raíz está entre ellos ⇒ `all`, con `excludedSubtrees` = todos
1470
+ * los scopes con deny vivo del permiso (`listDenies`), como subárboles
1471
+ * (F10). Nunca `all` sin esa lista (juez cruce 5): un deny vivo tiene
1472
+ * que verse.
1473
+ * 3. Si no: candidatos = directos ∪ sus descendientes (`descendantsOf`).
1474
+ * Cada candidato se contrasta con `resolveChain` (memoizado por
1475
+ * request, F3): su cadena tiene que contener el scope concedente —si
1476
+ * no, los dos resolutores del consumidor describen árboles distintos y
1477
+ * se lanza 503 `E_AUTHZ_RESOLVER_FAILED`, nunca una lista con cruces—
1478
+ * y no puede contener un scope denegado (es EXACTAMENTE la regla de
1479
+ * `authorize`: deny en la cadena ⇒ false). Se filtran por `scopeType`.
1480
+ * 4. Más de `maxScopes` ⇒ 422 `E_AUTHZ_TOO_MANY_SCOPES`, nunca parcial, y
1481
+ * se corta en cuanto se sabe (F8): los directos del tipo antes de bajar
1482
+ * y el conteo del tipo dentro del bucle. `options.maxScopes` solo puede
1483
+ * BAJAR la cota del config.
1484
+ * Sin `scopes.descendantsOf` ⇒ 500 `E_AUTHZ_NO_DESCENDANTS_RESOLVER`
1485
+ * (antes de mirar nada: un `none` sin árbol sería mentira).
1486
+ */
1487
+ async authorizedScopes(subject, permission, scopeType, options = {}) {
1488
+ assertIdentity({ subject, permission, scopeType });
1489
+ const descendantsOf = this.#descendantsResolver('authorizedScopes');
1490
+ const { maxScopes, maxNodes } = this.#scopeBounds('authorizedScopes', options);
1491
+ // Una vista propia para la llamada: los scopes que se resuelvan se
1492
+ // resuelven una vez.
1493
+ const view = this.#readResolver ? this : this.forRequest();
1494
+ const driver = await view.#reader();
1495
+ const listDenies = this.#optional(driver, 'listDenies', 'authorizedScopes');
1496
+ const key = _a.#scopeKey;
1497
+ const direct = await driver.listScopes(subject, permission);
1498
+ if (direct.length === 0)
1499
+ return { kind: 'none' };
1500
+ const denied = (await listDenies(subject)).filter((d) => d.permission === permission).map((d) => d.scope);
1501
+ if (direct.some((s) => s.type === APP_SCOPE_TYPE)) {
1502
+ if (denied.length > maxScopes) {
1503
+ throw new TooManyScopesError(`authorizedScopes: ${denied.length} subárboles excluidos superan maxScopes=${maxScopes}; no se devuelve una lista parcial.`);
1504
+ }
1505
+ return { kind: 'all', excludedSubtrees: denied.map((scope) => ({ scope, includesDescendants: true })) };
1506
+ }
1507
+ const tooMany = (count) => new TooManyScopesError(`authorizedScopes: más de ${maxScopes} scopes de tipo '${scopeType}' (${count} ya contados, maxScopes); ` +
1508
+ `acota la pregunta o sube la cota. No se devuelve una lista parcial.`);
1509
+ const deniedKeys = new Set(denied.map(key));
1510
+ const result = new Map();
1511
+ // Los directos del tipo (ya sin denies por encima: `listScopes`) cuentan
1512
+ // antes de bajar a ningún subárbol (F8).
1513
+ for (const granted of direct) {
1514
+ if (granted.type === scopeType)
1515
+ result.set(key(granted), granted);
1516
+ }
1517
+ if (result.size > maxScopes)
1518
+ throw tooMany(result.size);
1519
+ const resolver = view.#readResolverOrFresh();
1520
+ for (const granted of direct) {
1521
+ const grantedKey = key(granted);
1522
+ for (const candidate of await view.#descendants(descendantsOf, granted, maxNodes)) {
1523
+ const candidateKey = key(candidate);
1524
+ if (result.has(candidateKey))
1525
+ continue;
1526
+ // Pertenencia (F3): la cadena del candidato, según `resolveChain`,
1527
+ // tiene que pasar por el scope concedente. Si no (o si el árbol de
1528
+ // ancestros no lo conoce), los dos resolutores discrepan: 503.
1529
+ const chain = await resolveChain(resolver, candidate, 'authorizedScopes');
1530
+ if (!chain || !chain.some((s) => key(s) === grantedKey)) {
1531
+ throw new ScopeResolverError('authorizedScopes', new Error(`descendantsOf(${grantedKey.replace('\u001f', ':')}) devolvió ${candidateKey.replace('\u001f', ':')} pero ` +
1532
+ `resolveChain no lo cuelga de ahí: los dos resolutores describen árboles distintos y no se puede responder.`));
1533
+ }
1534
+ // Deny en la cadena ⇒ no concede, como en `authorize`.
1535
+ if (chain.some((s) => deniedKeys.has(key(s))))
1536
+ continue;
1537
+ if (candidate.type !== scopeType)
1538
+ continue;
1539
+ result.set(candidateKey, candidate);
1540
+ if (result.size > maxScopes)
1541
+ throw tooMany(result.size);
1542
+ }
1543
+ }
1544
+ const scopes = [...result.values()];
1545
+ return scopes.length ? { kind: 'some', scopes } : { kind: 'none' };
1546
+ }
1547
+ /**
1548
+ * Los `excludedSubtrees` de un `all` (F10) expandidos: cada scope denegado
1549
+ * y todos sus descendientes, con el `descendantsOf` del config. Un scope
1550
+ * que `descendantsOf` no conoce (`null`) es 503: restarlo a medias
1551
+ * dejaría su subárbol dentro (fail-open). Cotas: `maxDescendants` por
1552
+ * subárbol y `maxScopes` (config o por llamada, nunca por encima del
1553
+ * config) sobre el total ⇒ 422, nunca parcial.
1554
+ */
1555
+ async expandExcludedSubtrees(excluded, options = {}) {
1556
+ if (!Array.isArray(excluded)) {
1557
+ throw new InvalidIdentityError(`expandExcludedSubtrees: se esperaba un array y llegó ${typeof excluded}`);
1558
+ }
1559
+ for (const item of excluded)
1560
+ assertScope(item?.scope);
1561
+ // Es una lectura de la vista como las demás (I2, auditor 10): caduca con ella.
1562
+ this.#assertReadable();
1563
+ const descendantsOf = this.#descendantsResolver('expandExcludedSubtrees');
1564
+ const { maxScopes, maxNodes } = this.#scopeBounds('expandExcludedSubtrees', options);
1565
+ const key = _a.#scopeKey;
1566
+ const result = new Map();
1567
+ for (const { scope } of excluded) {
1568
+ result.set(key(scope), scope);
1569
+ const below = await this.#descendants(descendantsOf, scope, maxNodes, 'strict');
1570
+ for (const d of below)
1571
+ result.set(key(d), d);
1572
+ if (result.size > maxScopes) {
1573
+ throw new TooManyScopesError(`expandExcludedSubtrees: más de ${maxScopes} scopes excluidos (maxScopes); no se devuelve una lista parcial.`);
1574
+ }
1575
+ }
1576
+ return [...result.values()];
1577
+ }
1578
+ /**
1579
+ * Clave LAXA de un scope, solo para agrupar/deduplicar candidatos dentro
1580
+ * de una operación (`authorizedScopes`, el anti-ciclo de `#assertEdge`).
1581
+ * NO es `scopeKey` de `identity.ts` —esa valida la gramática y es la que
1582
+ * identifica hechos, owners e ids de binding, y es la que usa
1583
+ * `#rolesAlong` (3D · N5)—: aquí los scopes vienen del `descendantsOf` del
1584
+ * consumidor y no se les exige gramática para compararlos entre sí.
1585
+ */
1586
+ static #scopeKey(s) {
1587
+ return `${s.type}\u001f${s.uuid ?? ''}`;
1588
+ }
1589
+ #descendantsResolver(operation) {
1590
+ const descendantsOf = this.#config.scopes?.descendantsOf;
1591
+ if (!descendantsOf) {
1592
+ throw new NoDescendantsResolverError(`${operation} necesita 'scopes.descendantsOf' en config/authorization.ts (p. ej. sqlDescendantsOf(...)): ` +
1593
+ `sin el árbol de descendientes no se puede enumerar sin mentir.`);
1594
+ }
1595
+ return descendantsOf;
1596
+ }
1597
+ /**
1598
+ * Cotas de una enumeración: `maxScopes` del config (default 1000), que
1599
+ * una llamada solo puede BAJAR (F8: subirla por llamada era una escalada
1600
+ * silenciosa de la cota global), y `maxDescendants` del config.
1601
+ */
1602
+ #scopeBounds(operation, options) {
1603
+ const configured = this.#config.scopes?.maxScopes ?? DEFAULT_MAX_SCOPES;
1604
+ const maxNodes = this.#config.scopes?.maxDescendants ?? DEFAULT_MAX_DESCENDANTS;
1605
+ for (const [name, value] of [
1606
+ ['maxScopes', configured],
1607
+ ['maxDescendants', maxNodes],
1608
+ ['maxScopes (por llamada)', options.maxScopes ?? configured],
1609
+ ]) {
1610
+ if (!Number.isInteger(value) || value < 1 || value > MAX_SCOPE_BOUND) {
1611
+ throw new AuthorizationConfigError(`${operation}: ${name} debe ser un entero entre 1 y ${MAX_SCOPE_BOUND} (llegó ${String(value)})`);
1612
+ }
1613
+ }
1614
+ return { maxScopes: Math.min(options.maxScopes ?? configured, configured), maxNodes };
1615
+ }
1616
+ /**
1617
+ * El subárbol del consumidor para las dos piezas que lo caminan por
1618
+ * SEGURIDAD y no por enumeración —`scopes.detached` y la regla de nivel de
1619
+ * `defineScopedRole`/`updateScopedRole`—, DEGRADANDO en vez de tumbar la
1620
+ * operación (3F · S2, auditor N3).
1621
+ *
1622
+ * Regla: *declarar `scopes.descendantsOf` nunca puede dejarte peor que no
1623
+ * declararlo*. Hasta 3E, una org con más units que `maxDescendants` —la
1624
+ * cota sale del config y una llamada no la puede subir (F8)— dejaba el
1625
+ * `detached` entero en 503 **sin purgar ni los roles ni los hechos** y al
1626
+ * tenant grande sin poder delegar hacia abajo: la configuración que el
1627
+ * invariante 18 recomienda EMPEORABA el caso grande. Ahora, si el subárbol
1628
+ * no se puede enumerar (más nodos que la cota, o un `descendantsOf` que
1629
+ * falla), se sigue con `enumerated: false`: la purga se acota al scope
1630
+ * exacto —lo mismo que sin declararlo, y el resultado lo dice con
1631
+ * `truncated`— y la regla de nivel cae a la MÍNIMA (rechazar solo los
1632
+ * tipos de un ancestro). Ninguna de las dos degradaciones concede nada:
1633
+ * purgar menos deja roles que ya no son visibles en ninguna parte, y la
1634
+ * regla mínima es la que corre en todo consumidor con el stub publicado.
1635
+ * Pero no es gratis y está escrito donde toca (3G · X1, auditor P4): es un
1636
+ * control que el propio vigilado puede apagar creando hijos. Lo que NO
1637
+ * degrada nunca es la policy de RANGO: con `below = []` sigue corriendo
1638
+ * sobre los roles del scope exacto (3G · X2), y ensombrecer sigue pidiendo
1639
+ * rango aunque la regla de nivel haya caído a la mínima (3G · W3).
1640
+ *
1641
+ * Lo que NO se degrada es un error de CONFIG (`maxDescendants` fuera de
1642
+ * rango): eso es un bug del consumidor y sigue siendo 500.
1643
+ */
1644
+ async #descendantsOrDegrade(scope, operation) {
1645
+ const descendantsOf = this.#config.scopes?.descendantsOf;
1646
+ if (!descendantsOf)
1647
+ return { below: [], declared: false, enumerated: false };
1648
+ const { maxNodes } = this.#scopeBounds(operation, {});
1649
+ try {
1650
+ return { below: await this.#descendants(descendantsOf, scope, maxNodes), declared: true, enumerated: true };
1651
+ }
1652
+ catch (error) {
1653
+ if (error instanceof TooManyScopesError || error instanceof ScopeResolverError) {
1654
+ return { below: [], declared: true, enumerated: false };
1655
+ }
1656
+ throw error;
1657
+ }
1658
+ }
1659
+ /**
1660
+ * `descendantsOf` del consumidor, clasificado como `resolveChain` clasifica
1661
+ * `resolveChain`: lanza ⇒ 503 `E_AUTHZ_RESOLVER_FAILED`; no-array o
1662
+ * scope mal formado ⇒ 503; más de `maxNodes` ⇒ 422 `E_AUTHZ_TOO_MANY_SCOPES`;
1663
+ * `null` (desconocido para ese árbol) ⇒ nada debajo para un scope
1664
+ * CONCEDENTE (conservador: no se lista lo que no se puede enumerar) y 503
1665
+ * en modo `strict` (un subárbol EXCLUIDO que no se puede enumerar no se
1666
+ * puede restar: fail-open, F3/F10).
1667
+ */
1668
+ async #descendants(descendantsOf, scope, maxNodes, unknown = 'empty') {
1669
+ let result;
1670
+ try {
1671
+ result = await descendantsOf(scope, { maxNodes });
1672
+ }
1673
+ catch (error) {
1674
+ if (isAuthzError(error))
1675
+ throw error;
1676
+ throw new ScopeResolverError('descendantsOf', error);
1677
+ }
1678
+ if (result === null || result === undefined) {
1679
+ if (unknown === 'strict') {
1680
+ throw new ScopeResolverError('descendantsOf', new Error(`descendantsOf no conoce ${scope.type}:${scope.uuid ?? ''}: su subárbol no se puede restar.`));
1681
+ }
1682
+ return [];
1683
+ }
1684
+ if (!Array.isArray(result)) {
1685
+ throw new ScopeResolverError('descendantsOf', new TypeError(`descendantsOf devolvió ${typeof result} en vez de ScopeRef[] | null`));
1686
+ }
1687
+ for (const s of result) {
1688
+ try {
1689
+ assertScope(s);
1690
+ }
1691
+ catch (error) {
1692
+ throw new ScopeResolverError('descendantsOf', error);
1693
+ }
1694
+ }
1695
+ if (result.length > maxNodes) {
1696
+ throw new TooManyScopesError(`descendantsOf(${scope.type}:${scope.uuid ?? ''}) devolvió ${result.length} nodos, más que maxDescendants=${maxNodes}.`);
1697
+ }
1698
+ return result;
1699
+ }
1700
+ /**
1701
+ * Ejecuta una escritura del driver. Si vence el deadline (503
1702
+ * `E_AUTHZ_BACKEND_TIMEOUT`) el resultado es DESCONOCIDO: el SDK o el
1703
+ * servidor pueden aplicarla después de que el llamante reciba el error
1704
+ * (D2, auditor H1). Antes de propagar se notifica el mismo evento con
1705
+ * `indeterminate: true`, para que quien audita registre "puede haber
1706
+ * ocurrido" en vez de nada. Cualquier otro fallo (422, conexión rechazada)
1707
+ * significa que la escritura no ocurrió y se propaga sin evento.
1708
+ */
1709
+ async #write(event, fn) {
1710
+ try {
1711
+ return await fn();
1712
+ }
1713
+ catch (error) {
1714
+ if (error instanceof AuthorizationBackendTimeoutError) {
1715
+ await this.#notify({ ...event, indeterminate: true });
1716
+ }
1717
+ throw error;
1718
+ }
73
1719
  }
74
1720
  /**
75
1721
  * Notifica al consumidor. El hook es un side-effect (auditar, emitir un
@@ -99,4 +1745,5 @@ export class AuthorizationManager {
99
1745
  }
100
1746
  }
101
1747
  }
1748
+ _a = AuthorizationManager;
102
1749
  //# sourceMappingURL=manager.js.map