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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/README.md +462 -35
  2. package/build/commands/authz_catalog_diff.js +1 -1
  3. package/build/commands/authz_catalog_diff.js.map +1 -1
  4. package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
  5. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
  6. package/build/commands/authz_catalog_prune_orphans.js +136 -0
  7. package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
  8. package/build/commands/authz_catalog_sync.d.ts +17 -0
  9. package/build/commands/authz_catalog_sync.d.ts.map +1 -1
  10. package/build/commands/authz_catalog_sync.js +27 -4
  11. package/build/commands/authz_catalog_sync.js.map +1 -1
  12. package/build/commands/authz_freeze.d.ts +44 -0
  13. package/build/commands/authz_freeze.d.ts.map +1 -0
  14. package/build/commands/authz_freeze.js +95 -0
  15. package/build/commands/authz_freeze.js.map +1 -0
  16. package/build/commands/authz_reconcile.d.ts +102 -0
  17. package/build/commands/authz_reconcile.d.ts.map +1 -0
  18. package/build/commands/authz_reconcile.js +294 -0
  19. package/build/commands/authz_reconcile.js.map +1 -0
  20. package/build/commands/authz_relations_reconcile.d.ts +73 -0
  21. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  22. package/build/commands/authz_relations_reconcile.js +225 -0
  23. package/build/commands/authz_relations_reconcile.js.map +1 -0
  24. package/build/commands/authz_scopes_relay.d.ts +47 -0
  25. package/build/commands/authz_scopes_relay.d.ts.map +1 -0
  26. package/build/commands/authz_scopes_relay.js +141 -0
  27. package/build/commands/authz_scopes_relay.js.map +1 -0
  28. package/build/commands/authz_unfreeze.d.ts +37 -0
  29. package/build/commands/authz_unfreeze.d.ts.map +1 -0
  30. package/build/commands/authz_unfreeze.js +92 -0
  31. package/build/commands/authz_unfreeze.js.map +1 -0
  32. package/build/commands/main.d.ts +6 -1
  33. package/build/commands/main.d.ts.map +1 -1
  34. package/build/commands/main.js +6 -1
  35. package/build/commands/main.js.map +1 -1
  36. package/build/commands/openfga_provision.d.ts +46 -4
  37. package/build/commands/openfga_provision.d.ts.map +1 -1
  38. package/build/commands/openfga_provision.js +90 -7
  39. package/build/commands/openfga_provision.js.map +1 -1
  40. package/build/configure.d.ts +11 -0
  41. package/build/configure.d.ts.map +1 -1
  42. package/build/configure.js +37 -1
  43. package/build/configure.js.map +1 -1
  44. package/build/index.d.ts +39 -10
  45. package/build/index.d.ts.map +1 -1
  46. package/build/index.js +35 -6
  47. package/build/index.js.map +1 -1
  48. package/build/providers/authz_provider.d.ts +26 -2
  49. package/build/providers/authz_provider.d.ts.map +1 -1
  50. package/build/providers/authz_provider.js +48 -2
  51. package/build/providers/authz_provider.js.map +1 -1
  52. package/build/services/relations.d.ts +14 -0
  53. package/build/services/relations.d.ts.map +1 -0
  54. package/build/services/relations.js +17 -0
  55. package/build/services/relations.js.map +1 -0
  56. package/build/src/{catalog.d.ts → catalog/catalog.d.ts} +41 -2
  57. package/build/src/catalog/catalog.d.ts.map +1 -0
  58. package/build/src/{catalog.js → catalog/catalog.js} +120 -14
  59. package/build/src/catalog/catalog.js.map +1 -0
  60. package/build/src/{catalog_cache.d.ts → catalog/catalog_cache.d.ts} +46 -22
  61. package/build/src/catalog/catalog_cache.d.ts.map +1 -0
  62. package/build/src/{catalog_cache.js → catalog/catalog_cache.js} +53 -43
  63. package/build/src/catalog/catalog_cache.js.map +1 -0
  64. package/build/src/define_config.d.ts +101 -3
  65. package/build/src/define_config.d.ts.map +1 -1
  66. package/build/src/define_config.js.map +1 -1
  67. package/build/src/drivers/database_driver.d.ts +86 -4
  68. package/build/src/drivers/database_driver.d.ts.map +1 -1
  69. package/build/src/drivers/database_driver.js +425 -11
  70. package/build/src/drivers/database_driver.js.map +1 -1
  71. package/build/src/drivers/database_relations_driver.d.ts +75 -0
  72. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  73. package/build/src/drivers/database_relations_driver.js +450 -0
  74. package/build/src/drivers/database_relations_driver.js.map +1 -0
  75. package/build/src/drivers/openfga_driver.d.ts +713 -119
  76. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  77. package/build/src/drivers/openfga_driver.js +2048 -476
  78. package/build/src/drivers/openfga_driver.js.map +1 -1
  79. package/build/src/drivers/openfga_facts.d.ts +369 -0
  80. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  81. package/build/src/drivers/openfga_facts.js +813 -0
  82. package/build/src/drivers/openfga_facts.js.map +1 -0
  83. package/build/src/drivers/openfga_relations_driver.d.ts +120 -0
  84. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  85. package/build/src/drivers/openfga_relations_driver.js +466 -0
  86. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  87. package/build/src/errors.d.ts +258 -5
  88. package/build/src/errors.d.ts.map +1 -1
  89. package/build/src/errors.js +238 -7
  90. package/build/src/errors.js.map +1 -1
  91. package/build/src/freeze.d.ts +120 -0
  92. package/build/src/freeze.d.ts.map +1 -0
  93. package/build/src/freeze.js +172 -0
  94. package/build/src/freeze.js.map +1 -0
  95. package/build/src/http/app_access_middleware.d.ts.map +1 -0
  96. package/build/src/http/app_access_middleware.js.map +1 -0
  97. package/build/src/http/resource_access_middleware.d.ts +105 -0
  98. package/build/src/http/resource_access_middleware.d.ts.map +1 -0
  99. package/build/src/http/resource_access_middleware.js +81 -0
  100. package/build/src/http/resource_access_middleware.js.map +1 -0
  101. package/build/src/identity.d.ts +74 -1
  102. package/build/src/identity.d.ts.map +1 -1
  103. package/build/src/identity.js +100 -2
  104. package/build/src/identity.js.map +1 -1
  105. package/build/src/manager.d.ts +315 -4
  106. package/build/src/manager.d.ts.map +1 -1
  107. package/build/src/manager.js +1187 -181
  108. package/build/src/manager.js.map +1 -1
  109. package/build/src/models/authz_assignment.d.ts +6 -6
  110. package/build/src/models/authz_assignment.d.ts.map +1 -1
  111. package/build/src/models/authz_deny.d.ts +6 -6
  112. package/build/src/models/authz_deny.d.ts.map +1 -1
  113. package/build/src/models/authz_permission.d.ts +6 -6
  114. package/build/src/models/authz_permission.d.ts.map +1 -1
  115. package/build/src/models/authz_role.d.ts +6 -6
  116. package/build/src/models/authz_role.d.ts.map +1 -1
  117. package/build/src/models/authz_role_permission.d.ts +6 -6
  118. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  119. package/build/src/openfga.d.ts +18 -2
  120. package/build/src/openfga.d.ts.map +1 -1
  121. package/build/src/openfga.js +15 -1
  122. package/build/src/openfga.js.map +1 -1
  123. package/build/src/reconcile.d.ts +37 -0
  124. package/build/src/reconcile.d.ts.map +1 -0
  125. package/build/src/reconcile.js +69 -0
  126. package/build/src/reconcile.js.map +1 -0
  127. package/build/src/relation_partition_trigger.d.ts +8 -0
  128. package/build/src/relation_partition_trigger.d.ts.map +1 -0
  129. package/build/src/relation_partition_trigger.js +85 -0
  130. package/build/src/relation_partition_trigger.js.map +1 -0
  131. package/build/src/relations/define_relations_config.d.ts +58 -0
  132. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  133. package/build/src/relations/define_relations_config.js +144 -0
  134. package/build/src/relations/define_relations_config.js.map +1 -0
  135. package/build/src/relations/manager.d.ts +38 -0
  136. package/build/src/relations/manager.d.ts.map +1 -0
  137. package/build/src/relations/manager.js +156 -0
  138. package/build/src/relations/manager.js.map +1 -0
  139. package/build/src/relations/reconcile.d.ts +62 -0
  140. package/build/src/relations/reconcile.d.ts.map +1 -0
  141. package/build/src/relations/reconcile.js +138 -0
  142. package/build/src/relations/reconcile.js.map +1 -0
  143. package/build/src/relations_config_store.d.ts +22 -0
  144. package/build/src/relations_config_store.d.ts.map +1 -0
  145. package/build/src/relations_config_store.js +74 -0
  146. package/build/src/relations_config_store.js.map +1 -0
  147. package/build/src/scope_outbox.d.ts +69 -0
  148. package/build/src/scope_outbox.d.ts.map +1 -0
  149. package/build/src/scope_outbox.js +291 -0
  150. package/build/src/scope_outbox.js.map +1 -0
  151. package/build/src/{drivers → shared}/backend_guard.d.ts +14 -0
  152. package/build/src/shared/backend_guard.d.ts.map +1 -0
  153. package/build/src/{drivers → shared}/backend_guard.js +26 -1
  154. package/build/src/shared/backend_guard.js.map +1 -0
  155. package/build/src/shared/sql_expiry.d.ts.map +1 -0
  156. package/build/src/shared/sql_expiry.js.map +1 -0
  157. package/build/src/sql_descendants.d.ts +47 -1
  158. package/build/src/sql_descendants.d.ts.map +1 -1
  159. package/build/src/sql_descendants.js +75 -1
  160. package/build/src/sql_descendants.js.map +1 -1
  161. package/build/src/testing/contract.d.ts +74 -0
  162. package/build/src/testing/contract.d.ts.map +1 -1
  163. package/build/src/testing/contract.js +672 -169
  164. package/build/src/testing/contract.js.map +1 -1
  165. package/build/src/testing/main.d.ts +6 -0
  166. package/build/src/testing/main.d.ts.map +1 -1
  167. package/build/src/testing/main.js +3 -0
  168. package/build/src/testing/main.js.map +1 -1
  169. package/build/src/testing/migration_contract.d.ts +284 -0
  170. package/build/src/testing/migration_contract.d.ts.map +1 -0
  171. package/build/src/testing/migration_contract.js +586 -0
  172. package/build/src/testing/migration_contract.js.map +1 -0
  173. package/build/src/testing/relations_contract.d.ts +51 -0
  174. package/build/src/testing/relations_contract.d.ts.map +1 -0
  175. package/build/src/testing/relations_contract.js +654 -0
  176. package/build/src/testing/relations_contract.js.map +1 -0
  177. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  178. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  179. package/build/src/testing/relations_reconcile_contract.js +172 -0
  180. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  181. package/build/src/traits/authz_scopes.js +1 -1
  182. package/build/src/traits/authz_scopes.js.map +1 -1
  183. package/build/src/traits/has_uuid.d.ts +7 -7
  184. package/build/src/traits/has_uuid.d.ts.map +1 -1
  185. package/build/src/types.d.ts +865 -82
  186. package/build/src/types.d.ts.map +1 -1
  187. package/build/src/types.js +10 -0
  188. package/build/src/types.js.map +1 -1
  189. package/build/stubs/config/authorization.stub +104 -4
  190. package/build/stubs/migration.stub +126 -0
  191. package/build/stubs/scopes_outbox_migration.stub +57 -0
  192. package/package.json +4 -2
  193. package/build/commands/openfga_import.d.ts +0 -34
  194. package/build/commands/openfga_import.d.ts.map +0 -1
  195. package/build/commands/openfga_import.js +0 -97
  196. package/build/commands/openfga_import.js.map +0 -1
  197. package/build/src/catalog.d.ts.map +0 -1
  198. package/build/src/catalog.js.map +0 -1
  199. package/build/src/catalog_cache.d.ts.map +0 -1
  200. package/build/src/catalog_cache.js.map +0 -1
  201. package/build/src/drivers/backend_guard.d.ts.map +0 -1
  202. package/build/src/drivers/backend_guard.js.map +0 -1
  203. package/build/src/drivers/sql_expiry.d.ts.map +0 -1
  204. package/build/src/drivers/sql_expiry.js.map +0 -1
  205. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  206. package/build/src/middleware/app_access_middleware.js.map +0 -1
  207. /package/build/src/{middleware → http}/app_access_middleware.d.ts +0 -0
  208. /package/build/src/{middleware → http}/app_access_middleware.js +0 -0
  209. /package/build/src/{drivers → shared}/sql_expiry.d.ts +0 -0
  210. /package/build/src/{drivers → shared}/sql_expiry.js +0 -0
@@ -4,7 +4,7 @@ import { v7 as uuidv7 } from 'uuid';
4
4
  import { APP_SCOPE } from '../types.js';
5
5
  import { AuthorizationManager } from '../manager.js';
6
6
  import db from '@adonisjs/lucid/services/db';
7
- import { CatalogCache, GLOBAL_OWNER_KEY, withAuthzCatalogWrite } from '../catalog_cache.js';
7
+ import { CatalogCache, GLOBAL_OWNER_KEY, withAuthzCatalogWrite } from '../catalog/catalog_cache.js';
8
8
  import { scopeKey } from '../identity.js';
9
9
  import { descendantsFrom, memoryScopeTree, resolveChainFrom } from './scope_tree.js';
10
10
  /**
@@ -54,12 +54,15 @@ function twinOf(driver) {
54
54
  * transacción con el bump como última sentencia (`withAuthzCatalogWrite`, 2E
55
55
  * · H2). Ninguna señal en memoria: es exactamente lo que cruza procesos.
56
56
  */
57
- async function withoutLink(role, scopeType, permission) {
57
+ async function removeLinkRow(role, scopeType, permission) {
58
+ let roleUuid = '';
58
59
  await withAuthzCatalogWrite(async (trx) => {
59
60
  const roleRow = (await trx.from('authz_roles').where('slug', role).where('scope_type', scopeType).where('owner_scope_key', GLOBAL_OWNER_KEY).select('uuid'))[0];
60
61
  const permRow = (await trx.from('authz_permissions').where('slug', permission).select('uuid'))[0];
62
+ roleUuid = String(roleRow.uuid);
61
63
  await trx.from('authz_role_permissions').where('role_uuid', roleRow.uuid).where('permission_uuid', permRow.uuid).delete();
62
64
  });
65
+ return roleUuid;
63
66
  }
64
67
  /**
65
68
  * Un rol LOCAL a `owner` escrito directamente en `authz_*` (3B · B2), como
@@ -69,7 +72,7 @@ async function withoutLink(role, scopeType, permission) {
69
72
  * owner) sin pasar por la policy del manager (que se juzga aparte, con
70
73
  * `defineScopedRole`). Devuelve el uuid del rol.
71
74
  */
72
- async function localRole(owner, spec) {
75
+ async function insertLocalRole(owner, spec) {
73
76
  const uuid = uuidv7();
74
77
  await withAuthzCatalogWrite(async (trx) => {
75
78
  const now = new Date();
@@ -98,7 +101,7 @@ async function localRole(owner, spec) {
98
101
  * legalmente queda apuntando a un rol de otro nivel sin escribir hechos a
99
102
  * mano (que en `openfga` no serían escribibles por el puerto).
100
103
  */
101
- async function retypeRole(roleUuid, scopeType) {
104
+ async function retypeRoleRow(roleUuid, scopeType) {
102
105
  await withAuthzCatalogWrite(async (trx) => {
103
106
  await trx.from('authz_roles').where('uuid', roleUuid).update({ scope_type: scopeType, updated_at: new Date() });
104
107
  });
@@ -156,7 +159,7 @@ async function forgetRoleByHand(roleUuid) {
156
159
  });
157
160
  }
158
161
  /** Un vínculo rol→permiso escrito a mano (lo que un sync rechazaría), con la versión subida. */
159
- async function linkByHand(roleUuid, permission) {
162
+ async function insertLinkRow(roleUuid, permission) {
160
163
  await withAuthzCatalogWrite(async (trx) => {
161
164
  const permRow = (await trx.from('authz_permissions').where('slug', permission).select('uuid'))[0];
162
165
  await trx.table('authz_role_permissions').insert({ uuid: uuidv7(), role_uuid: roleUuid, permission_uuid: permRow.uuid, created_at: new Date() });
@@ -274,8 +277,50 @@ export function registerAuthorizationDriverContract(harness, api) {
274
277
  const detachEdge = tree.detach.bind(tree);
275
278
  tree.detach = async (child) => {
276
279
  await driver.purgeScope(child);
280
+ // El árbol del BACKEND, si el driver lo materializa (3b-2e · E6):
281
+ // la arista se va DESPUÉS de que la purga demuestre cero (S6), igual
282
+ // que hace `manager.scopes.detached`.
283
+ await driver.onScopeDetached?.(child);
277
284
  await detachEdge(child);
278
285
  };
286
+ // **El espejo del árbol** (3b-2e · E6). Un driver que declara
287
+ // `hierarchyFacts` NO lee el árbol del consumidor para decidir: lo tiene
288
+ // como hechos propios, y quien se los cuenta es el consumidor con
289
+ // `authorization.scopes.*`. La suite mueve el árbol con `tree.attach` /
290
+ // `tree.move` directamente (decenas de sitios), así que aquí se espeja:
291
+ // primero la fila del consumidor, después la notificación al driver —
292
+ // que es el orden en el que el paquete valida (el padre tiene que
293
+ // existir para el resolutor).
294
+ if (harness.capabilities.hierarchyFacts) {
295
+ /**
296
+ * El juez cuelga a propósito scopes que el PAQUETE considera
297
+ * identidad inválida (el uuid centinela de la raíz) para comprobar
298
+ * que ninguna escritura los acepta. El espejo no puede tumbar ese
299
+ * caso: si el driver rechaza el nodo por identidad, el store
300
+ * simplemente no se entera —y las escrituras siguen siendo 422, que
301
+ * es lo que el caso mide—. Cualquier otro error SÍ se propaga (un
302
+ * ciclo, un padre desconocido, el backend caído).
303
+ */
304
+ const mirror = async (notify) => {
305
+ try {
306
+ await notify();
307
+ }
308
+ catch (error) {
309
+ if (error?.code !== 'E_AUTHZ_INVALID_IDENTITY')
310
+ throw error;
311
+ }
312
+ };
313
+ const attachEdge = tree.attach.bind(tree);
314
+ tree.attach = async (child, parent) => {
315
+ await attachEdge(child, parent);
316
+ await mirror(() => driver.onScopeAttached?.(child, parent));
317
+ };
318
+ const moveEdge = tree.move.bind(tree);
319
+ tree.move = async (child, parent) => {
320
+ await moveEdge(child, parent);
321
+ await mirror(() => driver.onScopeMoved?.(child, parent));
322
+ };
323
+ }
279
324
  });
280
325
  group.teardown(async () => {
281
326
  await harness.cleanup();
@@ -288,17 +333,67 @@ export function registerAuthorizationDriverContract(harness, api) {
288
333
  * 2.0 pasa por aquí igual; lo que le falte (`listDenies`) se ve como
289
334
  * 500 `E_AUTHZ_UNSUPPORTED` en el caso que lo necesita, nunca como skip.
290
335
  */
336
+ /**
337
+ * Un rol LOCAL escrito directamente en `authz_*`, como haría otro proceso
338
+ * —y, desde 3b-2e · E6, con la **proyección derivada** del driver puesta
339
+ * al día: en un driver donde lo que un rol concede son tuplas (modo
340
+ * `facts`), un rol escrito sin proyectar no concedería nada y el caso
341
+ * mediría el espejo en vez del invariante.
342
+ */
343
+ /**
344
+ * Los dos escritores «a mano» del catálogo, con la **proyección derivada**
345
+ * del driver al día (3b-2e · E6). Un proceso que escribe `authz_*` por
346
+ * fuera del sync ya tenía el deber de invalidar el memo (2A); en un driver
347
+ * que mantiene un espejo del catálogo en el backend (modo `facts`) también
348
+ * tiene que rehacer ese espejo — es lo que hace `syncAuthzCatalog` cuando
349
+ * se le pasa la proyección, y lo que `authz:reconcile` repara si se pierde.
350
+ */
351
+ async function withoutLink(role, scopeType, permission) {
352
+ const uuid = await removeLinkRow(role, scopeType, permission);
353
+ await driver.projectCatalogRole?.(uuid);
354
+ }
355
+ async function linkByHand(roleUuid, permission) {
356
+ await insertLinkRow(roleUuid, permission);
357
+ await driver.projectCatalogRole?.(roleUuid);
358
+ }
359
+ async function localRole(owner, spec) {
360
+ const uuid = await insertLocalRole(owner, spec);
361
+ await driver.projectCatalogRole?.(uuid);
362
+ return uuid;
363
+ }
364
+ /**
365
+ * El TERCER escritor a mano: el NIVEL declarado de un rol (3b-2g · R1).
366
+ * Misma regla que los otros dos —quien escribe `authz_*` por fuera del
367
+ * sync rehace la proyección derivada del driver—, y aquí no es un detalle
368
+ * del harness: en `facts` la visibilidad de un rol también está
369
+ * materializada (la arista `scope#binding` significa «el rol es visible
370
+ * aquí»), así que un `scope_type` cambiado sin proyectar deja concediendo
371
+ * lo que el catálogo ya no declara en ese nivel. En `database`
372
+ * `projectCatalogRole` no existe y esto es un no-op: la regla se evalúa en
373
+ * cada pregunta.
374
+ */
375
+ async function retypeRole(roleUuid, scopeType) {
376
+ await retypeRoleRow(roleUuid, scopeType);
377
+ await driver.projectCatalogRole?.(roleUuid);
378
+ }
291
379
  function managerOver(overrides = {}, over = driver) {
292
380
  return new AuthorizationManager({
293
381
  default: harness.name,
294
382
  drivers: { [harness.name]: () => over },
295
- scopes: {
296
- resolveChain: resolveChainFrom(tree),
297
- descendantsOf: descendantsFrom(tree),
298
- ...overrides.scopes,
299
- },
300
383
  warnOnOptInSecurity: false,
301
384
  ...overrides,
385
+ // `scopes` va DESPUÉS de los overrides y los respeta enteros (hay
386
+ // casos que quitan `descendantsOf` a propósito): lo único que se
387
+ // añade siempre es la firma del riesgo de deriva. El juez juzga el
388
+ // DRIVER; la outbox es una mitigación del CONFIG (3b-2e · E3) y aquí
389
+ // el árbol lo mueve la propia suite, no una transacción de consumidor.
390
+ scopes: {
391
+ ...(overrides.scopes ?? {
392
+ resolveChain: resolveChainFrom(tree),
393
+ descendantsOf: descendantsFrom(tree),
394
+ }),
395
+ acceptScopeDriftRisk: true,
396
+ },
302
397
  });
303
398
  }
304
399
  test('grant de un rol concede sus permisos (authorize true)', async ({ assert }) => {
@@ -1021,21 +1116,37 @@ export function registerAuthorizationDriverContract(harness, api) {
1021
1116
  assert.isFalse(await driver.authorize(mallory, 'docs:read', alias), 'sin guiones: el deny se evade');
1022
1117
  const known = (await tree.chainOf(alias)) !== null;
1023
1118
  if (known) {
1024
- // El árbol lo funde con la fila real: la cadena es la canónica y los
1025
- // hechos casan (`docs:write` concede el `false` de arriba es el
1026
- // deny canónico, no un scope desconocido).
1027
- assert.isTrue(await driver.authorize(mallory, 'docs:write', alias), 'sin guiones: lo concedido sigue concedido');
1119
+ // El árbol lo funde con la fila real: la cadena es la canónica y las
1120
+ // ESCRITURAS caen sobre los hechos canónicos. Si además la DECISIÓN
1121
+ // sobre el alias encuentra esos hechos es la capacidad
1122
+ // `canonicalScopeReads`, con su par (3b-2k · K1 · R2 (c)): aquí no se
1123
+ // afirma, porque `openfga` en modo `facts` responde `false` —
1124
+ // fail-closed, pero no la misma respuesta que `database`.
1028
1125
  assert.deepEqual(await driver.listRoles(mallory, alias), [], 'sin guiones: los roles directos se leen bajo la forma canónica');
1029
1126
  await driver.grant(eve, 'unit-editor', alias);
1030
1127
  assert.deepEqual(await driver.listRoles(eve, unit), ['unit-editor'], 'sin guiones: el grant sobre el alias queda bajo la forma canónica');
1031
1128
  assert.deepEqual(scopeKeys(await driver.listRoleScopes(eve, 'unit')), scopeKeys([unit]), 'sin guiones: ningún hecho con la forma del alias');
1032
1129
  await driver.revoke(eve, 'unit-editor', alias);
1033
1130
  assert.deepEqual(await driver.listRoles(eve, unit), [], 'sin guiones: revoke sobre el alias quita el hecho canónico');
1131
+ // 3b-1 · M13: `purgeScope` canoniza igual que el resto (invariante
1132
+ // 17). Sin esto, la canonización que hace el DRIVER dentro de
1133
+ // `purgeScope` no la observaba ningún caso en ningún motor: un
1134
+ // `detached` notificado con el alias dejaría vivos los hechos.
1135
+ await driver.grant(eve, 'unit-editor', alias);
1136
+ await driver.purgeScope(alias);
1137
+ assert.deepEqual(await driver.listRoles(eve, unit), [], 'sin guiones: purgeScope sobre el alias purga los hechos CANÓNICOS');
1138
+ assert.isTrue(await driver.authorize(mallory, 'docs:read', unit), 'y también el deny canónico (era lo único que bloqueaba)');
1034
1139
  }
1035
1140
  else {
1036
1141
  assert.isFalse(await driver.authorize(mallory, 'docs:write', alias), 'sin guiones: desconocido: nada concede');
1037
1142
  assert.deepEqual(await driver.listRoles(mallory, alias), [], 'sin guiones: desconocido');
1038
1143
  await rejectsWith(assert, () => driver.grant(eve, 'unit-editor', alias), { status: 422, code: 'E_AUTHZ_UNKNOWN_SCOPE' });
1144
+ // 3b-1 · M13, la otra cara: un scope que el árbol no conoce se purga
1145
+ // TAL CUAL (`canonicalScope`), así que un alias desconocido no puede
1146
+ // llevarse por delante los hechos de la forma canónica.
1147
+ await driver.purgeScope(alias);
1148
+ assert.isFalse(await driver.authorize(mallory, 'docs:read', unit), 'el deny canónico sigue en pie');
1149
+ assert.isTrue(await driver.authorize(mallory, 'docs:write', unit), 'y lo concedido sigue concedido');
1039
1150
  }
1040
1151
  });
1041
1152
  test('scope que el árbol no conoce: authorize/hasRole false; grant/deny 422 E_AUTHZ_UNKNOWN_SCOPE', async ({ assert, }) => {
@@ -1498,7 +1609,7 @@ export function registerAuthorizationDriverContract(harness, api) {
1498
1609
  await lax.grant(alice, 'org-editor', orgA);
1499
1610
  assert.isTrue(await driver.authorize(alice, 'docs:read', orgA));
1500
1611
  });
1501
- since('2.1', 'authorizeMany: idéntico a N authorize por posición (duplicados, desconocidos, denegados); vacío ⇒ [] sin backend ni árbol; un scope que lanza lanza entero', async ({ assert, }) => {
1612
+ since('2.1', 'authorizeMany: idéntico a N authorize por posición (duplicados, desconocidos, denegados); vacío ⇒ [] sin backend ni árbol; identidad inválida 422 antes de tocar nada', async ({ assert, }) => {
1502
1613
  // B6 (tester §5 E · authorizeMany 1-4). Composición por defecto
1503
1614
  // (`Promise.all` de `authorize` sobre una vista memoizada) o el método
1504
1615
  // opcional del driver (openfga: un solo batchCheck correlacionado por
@@ -1548,23 +1659,19 @@ export function registerAuthorizationDriverContract(harness, api) {
1548
1659
  const watched = new AuthorizationManager({
1549
1660
  default: harness.name,
1550
1661
  drivers: { [harness.name]: () => spied },
1551
- scopes: { resolveChain: resolveChainFrom(tree), descendantsOf: descendantsFrom(tree) },
1662
+ scopes: { resolveChain: resolveChainFrom(tree), descendantsOf: descendantsFrom(tree), acceptScopeDriftRisk: true },
1552
1663
  warnOnOptInSecurity: false,
1553
1664
  });
1554
1665
  try {
1555
1666
  assert.deepEqual(await watched.authorizeMany(alice, 'docs:write', []), []);
1556
1667
  assert.deepEqual(touched, []);
1557
1668
  assert.equal(asked, 0);
1558
- // Un scope cuyo árbol falla: lanza entero (503), no un array parcial.
1559
- tree.chainOf = async (scope) => {
1560
- if (`${scope.type}:${scope.uuid}` === `${unitB1.type}:${unitB1.uuid}`)
1561
- throw new Error('árbol caído');
1562
- return original.call(tree, scope);
1563
- };
1564
- await rejectsWith(assert, () => watched.authorizeMany(alice, 'docs:write', [orgA, unitB1, orgB]), {
1565
- status: 503,
1566
- code: 'E_AUTHZ_RESOLVER_FAILED',
1567
- });
1669
+ // Qué pasa cuando el ÁRBOL del consumidor falla en una posición no se
1670
+ // juzga aquí: es el par de capacidad `hierarchyFacts` (3b-2k · K1 ·
1671
+ // R2 (b)). Con el árbol en manos del consumidor lanza entero (503);
1672
+ // con el árbol como hechos del backend, `authorize` ya no lo necesita
1673
+ // y responde — que es justo la propiedad que este modo compra.
1674
+ //
1568
1675
  // Identidad inválida en cualquier posición: 422 antes de tocar nada.
1569
1676
  touched.length = 0;
1570
1677
  await rejectsWith(assert, () => watched.authorizeMany(alice, 'docs:write', [orgA, { type: 'app', uuid: 'X' }]), {
@@ -2096,158 +2203,86 @@ export function registerAuthorizationDriverContract(harness, api) {
2096
2203
  assert.isFalse(await driver.hasRole(alice, 'lead', unitA1));
2097
2204
  assert.isFalse(await driver.hasRole(alice, 'lead', unitA1x));
2098
2205
  });
2099
- since('2.2', 'scopes.detached purga también los roles LOCALES cuyo owner es ese scope (3D · M4): la fila no sobrevive, ese (slug, nivel) vuelve a estar libre para el catálogo global y nada de lo que concedía queda en pie', async ({ assert, }) => {
2100
- // Auditor V5 (reproducido en PG): `deleteScopedRole` respondía 422
2101
- // `E_AUTHZ_UNKNOWN_SCOPE` en cuanto el owner salía del árbol (lo
2102
- // resuelve en fresco) y la fila del rol sobrevivía bloqueando ese
2103
- // `(slug, nivel)` para `syncAuthzCatalog` PARA SIEMPRE. Un rol sin
2104
- // owner no es visible en ninguna parte: purgarlo no pierde nada.
2206
+ since('2.2', 'scopes.detached purga los HECHOS del scope y SOLO los hechos (invariante 11): los roles locales de ese owner —y los del subárbol— sobreviven DORMIDOS (no conceden, no son membresía, no se asignan) y ningún actor los destruye por esta vía; la salida es pruneOrphanRoles, que es de plataforma', async ({ assert, }) => {
2207
+ // 3b-0 · Z1. Entre 3D y 3G `scopes.detached` arrastraba además los
2208
+ // roles LOCALES del owner (y, con `descendantsOf`, los del
2209
+ // subárbol), con policy de rango, degradación y un retorno que
2210
+ // contaba lo purgado. Cinco lotes la tocaron y TRES de las cuatro
2211
+ // regresiones de la Fase 3 nacieron ahí, siempre por COMPOSICIÓN
2212
+ // (3E · P3 + 3F · S1/S2 ⇒ 3G · W1: un `detached` de un ancestro
2213
+ // desconocido destruía roles de descendientes VIVOS). Lo que este
2214
+ // caso fija es que esa superficie NO VUELVE: el catálogo no se
2215
+ // escribe desde el árbol.
2105
2216
  const authz = managerOver();
2106
2217
  const alice = subject();
2107
2218
  const orgA = await orgUnder(tree, APP_SCOPE);
2108
2219
  const unitA1 = await unitUnder(tree, orgA);
2109
2220
  const unitA1x = await unitUnder(tree, unitA1);
2110
- const huerfano = await localRole(unitA1, { slug: 'huerfano', scopeType: 'unit', permissions: ['docs:write'] });
2111
- const deOrg = await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:read'] });
2112
- // 3E · P2 (auditor A4) / R7: un rol cuyo owner es un DESCENDIENTE
2113
- // del scope notificado. El consumidor notifica el nodo que borró —
2114
- // no uno por hoja—, y estos quedaban huérfanos e indeleteables,
2115
- // bloqueando su (slug, nivel) global para siempre.
2221
+ const propio = await localRole(unitA1, { slug: 'propio', scopeType: 'unit', permissions: ['docs:write'] });
2116
2222
  const delNieto = await localRole(unitA1x, { slug: 'nieto', scopeType: 'unit', permissions: ['docs:read'] });
2117
- await driver.grant(alice, 'huerfano', unitA1);
2118
- await driver.grant(alice, 'huerfano', unitA1x);
2223
+ await driver.grant(alice, 'propio', unitA1);
2119
2224
  await driver.grant(alice, 'nieto', unitA1x);
2120
- await driver.grant(alice, 'lead', unitA1);
2121
- assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1x));
2122
- await authz.scopes.detached(unitA1);
2123
- await tree.detach(unitA1);
2225
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1));
2226
+ assert.isUndefined(await authz.scopes.detached(unitA1), 'no devuelve nada que declarar a medias');
2227
+ assert.deepEqual(await driver.listRoles(alice, unitA1), [], 'los HECHOS del scope, purgados');
2228
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitA1));
2124
2229
  const view = await new CatalogCache().view();
2125
- assert.isNull(view.roleByUuid(huerfano), 'la fila del rol cuyo owner desapareció no sobrevive');
2126
- assert.deepEqual(view.rolesNamed('huerfano', 'unit'), [], 'el (slug, nivel) vuelve a estar libre');
2127
- assert.isNull(view.roleByUuid(delNieto), 'ni la del rol de un descendiente del scope notificado');
2128
- assert.deepEqual(view.rolesNamed('nieto', 'unit'), []);
2129
- assert.equal(await linksOf(huerfano), 0, 'sin vínculos huérfanos');
2230
+ assert.isNotNull(view.roleByUuid(propio), 'la fila del rol NO se toca: el catálogo no se escribe desde el árbol');
2231
+ assert.isNotNull(view.roleByUuid(delNieto), 'ni la del descendiente');
2232
+ assert.equal(await linksOf(propio), 1, 'ni sus vínculos');
2233
+ // Dormido: con el owner fuera del árbol el rol no concede, no es
2234
+ // membresía y no se puede asignar (invariante 18, sin cambios).
2235
+ await tree.detach(unitA1);
2236
+ assert.isFalse(await driver.authorize(alice, 'docs:read', unitA1x), 'el nieto pierde su cadena y con ella todo');
2237
+ await rejectsWith(assert, () => authz.grant(alice, { uuid: propio }, unitA1x), { status: 422, code: 'E_AUTHZ_UNKNOWN_SCOPE' });
2238
+ // La salida es de PLATAFORMA y en dos tiempos: primero se mira.
2239
+ const seco = await authz.pruneOrphanRoles();
2240
+ assert.deepEqual(seco.orphans.map((o) => o.role.uuid).sort(), [delNieto, propio].sort());
2241
+ assert.deepEqual(seco.purged, [], 'el default no escribe');
2242
+ assert.isNotNull((await new CatalogCache().view()).roleByUuid(propio));
2243
+ // 3b-0b · AA1: los dos tenían hechos, y los hechos siguen ahí (el
2244
+ // `detached` purgó los del scope, no los del rol), así que el
2245
+ // barrido AVISA de que no está recogiendo basura inerte.
2246
+ assert.deepEqual(seco.orphans.filter((o) => o.stillGranting).map((o) => o.role.uuid).sort(), [delNieto].sort(), 'el del nieto conserva su hecho; el del scope purgado, no');
2247
+ // 3b-0b · AA2: aquí TODOS los owners locales están huérfanos, que
2248
+ // es la firma de un resolutor ciego. Se rechaza sin borrar nada.
2249
+ assert.isTrue(seco.massPurge);
2250
+ await rejectsWith(assert, () => authz.pruneOrphanRoles({ force: true }), { status: 500, code: 'E_AUTHZ_MASS_PURGE_REFUSED' });
2251
+ assert.isNotNull((await new CatalogCache().view()).roleByUuid(propio), 'el rechazo no borra nada');
2252
+ const purga = await authz.pruneOrphanRoles({ force: true, allowMassPurge: true });
2253
+ assert.deepEqual(purga.purged.map((r) => r.uuid).sort(), [delNieto, propio].sort());
2254
+ assert.deepEqual(purga.skipped, [], 'ningún owner volvió durante la pasada');
2255
+ const tras = await new CatalogCache().view();
2256
+ assert.isNull(tras.roleByUuid(propio), 'ahora sí: la fila se va');
2257
+ assert.deepEqual(tras.rolesNamed('propio', 'unit'), [], 'y ese (slug, nivel) vuelve a estar libre');
2258
+ assert.equal(await linksOf(propio), 0, 'sin vínculos huérfanos');
2130
2259
  assert.equal(await linksOf(delNieto), 0);
2131
- assert.isNotNull(view.roleByUuid(deOrg), 'el rol de la organization, que sigue en el árbol, no se toca');
2132
- assert.deepEqual(await driver.listSubjects({ uuid: huerfano }, unitA1x), []);
2133
- // El subárbol lo purga el consumidor nodo a nodo (invariante 7): lo
2134
- // que aquí se fija es que el ROL no deja rastro que resucite.
2260
+ // Nada resucita por el slug: un rol nuevo con ese nombre en otro
2261
+ // árbol no hereda lo que concedía el purgado.
2135
2262
  const orgB = await orgUnder(tree, APP_SCOPE);
2136
2263
  const unitB1 = await unitUnder(tree, orgB);
2137
- await localRole(orgB, { slug: 'huerfano', scopeType: 'unit', permissions: ['docs:read'] });
2138
- await driver.grant(alice, 'huerfano', unitB1);
2264
+ await localRole(orgB, { slug: 'propio', scopeType: 'unit', permissions: ['docs:read'] });
2265
+ await driver.grant(alice, 'propio', unitB1);
2139
2266
  assert.isTrue(await driver.authorize(alice, 'docs:read', unitB1));
2140
2267
  assert.isFalse(await driver.authorize(alice, 'docs:write', unitB1), 'el rol viejo no revive por el slug');
2141
2268
  });
2142
2269
  // ── Casos de COMPOSICIÓN (3G · W4) ────────────────────────────────
2143
2270
  // Las tres regresiones seguidas de 3E/3F/3G nacieron de componer
2144
2271
  // piezas correctas por separado: cada una tenía su caso, ninguna lo
2145
- // tenía JUNTO a las otras. Estos son los que las habrían cazado.
2146
- since('2.2', 'composición (3G · W4 i): detached de un ANCESTRO que el árbol ya no conoce, con requireActor y descendantsOf declarado, NO destruye los roles locales de descendientes VIVOS — el rango se mide en la cadena del OWNER de cada rol (422 y no se purga ninguno); cuando el owner de cada rol tampoco resuelve, la purga procede y lo dice', async ({ assert, }) => {
2147
- // Auditor P1 (🟠 3G · W1): S1 (sin cadena donde medir, la purga
2148
- // procede) + S2 (con `descendantsOf` la purga baja al subárbol) =
2149
- // un rol de rank 80 de una unit VIVA destruido por un actor de
2150
- // rank 50, mientras `deleteScopedRole` se lo negaba.
2151
- const alice = subject();
2152
- const admin = subject();
2153
- const orgA = await orgUnder(tree, APP_SCOPE);
2154
- const unitA1 = await unitUnder(tree, orgA);
2155
- const oculto = new Set();
2156
- const cadena = resolveChainFrom(tree);
2157
- const authz = managerOver({
2158
- requireActor: true,
2159
- scopes: {
2160
- // La fila del scope se borró; los hijos conservan su ruta.
2161
- resolveChain: (scope) => (oculto.has(scopeKey(scope)) ? Promise.resolve(null) : cadena(scope)),
2162
- // El consumidor es la autoridad sobre su tabla: sigue
2163
- // respondiendo por los hijos del padre borrado.
2164
- descendantsOf: descendantsFrom(tree),
2165
- },
2166
- });
2167
- await driver.grant(admin, 'org-admin', orgA);
2168
- const caro = await localRole(unitA1, { slug: 'caro', scopeType: 'unit', rank: 80, permissions: ['docs:write'] });
2169
- const barato = await localRole(unitA1, { slug: 'barato', scopeType: 'unit', rank: 10, permissions: ['docs:read'] });
2170
- await driver.grant(alice, { uuid: caro }, unitA1);
2171
- assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1), 'el rol de rank 80 CONCEDE ahora mismo');
2172
- oculto.add(scopeKey(orgA));
2173
- await rejectsWith(assert, () => authz.scopes.detached(orgA, { actor: admin }), { status: 422, code: 'E_AUTHZ_RANK_EXCEEDED' });
2174
- const vista = await new CatalogCache().view();
2175
- assert.isNotNull(vista.roleByUuid(caro), 'el rol de la unit viva sobrevive');
2176
- assert.isNotNull(vista.roleByUuid(barato), 'y el de rango bajo tampoco cae: se comprueban TODOS antes de tocar ninguno');
2177
- assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1), 'y sigue concediendo');
2178
- // La otra cara (S1 intacta): con el owner de cada rol también fuera
2179
- // del árbol —invisibles en todas partes— la purga procede saltándose
2180
- // el rango, y el retorno lo dice.
2181
- oculto.add(scopeKey(unitA1));
2182
- const salida = await authz.scopes.detached(orgA, { actor: admin });
2183
- assert.equal(salida.purgedRoles, 2);
2184
- assert.equal(salida.reason, 'owner-detached-unknown');
2185
- const tras = await new CatalogCache().view();
2186
- assert.isNull(tras.roleByUuid(caro));
2187
- assert.isNull(tras.roleByUuid(barato));
2188
- });
2189
- since('2.2', 'composición (3G · W4 ii): con el subárbol POR ENCIMA de la cota el detached DEGRADA, pero la policy de rango corre igual sobre los roles del scope EXACTO: rango insuficiente ⇒ 422 y no se purga nada (tampoco los hechos); con rango suficiente ⇒ purga el scope exacto y marca el resultado como parcial', async ({ assert, }) => {
2190
- // Auditor P4 (efecto lateral) + 3G · X2: con `below = []` por
2191
- // degradación la policy se saltaba entera si nadie la invocaba.
2192
- const alice = subject();
2193
- const admin = subject();
2194
- const plataforma = subject();
2195
- const orgA = await orgUnder(tree, APP_SCOPE);
2196
- await unitUnder(tree, orgA);
2197
- await unitUnder(tree, orgA);
2198
- await unitUnder(tree, orgA);
2199
- const authz = managerOver({
2200
- requireActor: true,
2201
- scopes: { resolveChain: resolveChainFrom(tree), descendantsOf: descendantsFrom(tree), maxDescendants: 1 },
2202
- });
2203
- await driver.grant(admin, 'org-admin', orgA);
2204
- const jefazo = await localRole(orgA, { slug: 'jefazo', scopeType: 'organization', rank: 80, permissions: ['docs:write'] });
2205
- await driver.grant(alice, { uuid: jefazo }, orgA);
2206
- assert.isTrue(await driver.authorize(alice, 'docs:write', orgA));
2207
- await rejectsWith(assert, () => authz.scopes.detached(orgA, { actor: admin }), { status: 422, code: 'E_AUTHZ_RANK_EXCEEDED' });
2208
- assert.isNotNull((await new CatalogCache().view()).roleByUuid(jefazo), 'el rol sigue');
2209
- assert.isTrue(await driver.authorize(alice, 'docs:write', orgA), 'y los HECHOS también: la purga no llegó a correr');
2210
- await driver.grant(plataforma, 'auditor-senior', APP_SCOPE);
2211
- const salida = await authz.scopes.detached(orgA, { actor: plataforma });
2212
- assert.deepEqual(salida, { purgedRoles: 1, truncated: true }, 'purga el scope exacto y dice que es parcial');
2213
- assert.isNull((await new CatalogCache().view()).roleByUuid(jefazo));
2214
- assert.isFalse(await driver.authorize(alice, 'docs:write', orgA));
2215
- });
2216
- since('2.2', 'composición (3G · W4 iv): detached de un scope desconocido cuyo descendantsOf FALLA purga solo lo que puede demostrar (los roles de ESE owner, sin rango que medir), jamás los de un descendiente vivo —que ni se leen—, y el resultado es parcial; con el resolutor sano ese mismo descendiente exige rango: degradar nunca da MÁS poder que enumerar', async ({ assert, }) => {
2217
- const alice = subject();
2218
- const forastero = subject();
2219
- const orgA = await orgUnder(tree, APP_SCOPE);
2220
- const unitA1 = await unitUnder(tree, orgA);
2221
- const cadena = resolveChainFrom(tree);
2222
- const abajo = descendantsFrom(tree);
2223
- let roto = true;
2224
- const authz = managerOver({
2225
- requireActor: true,
2226
- scopes: {
2227
- resolveChain: (scope) => (scopeKey(scope) === scopeKey(orgA) ? Promise.resolve(null) : cadena(scope)),
2228
- descendantsOf: (scope, options) => {
2229
- if (roto)
2230
- throw new Error('la réplica del árbol no responde');
2231
- return abajo(scope, options);
2232
- },
2233
- },
2234
- });
2235
- const propio = await localRole(orgA, { slug: 'propio', scopeType: 'organization', rank: 80, permissions: ['docs:write'] });
2236
- const hijo = await localRole(unitA1, { slug: 'hijo', scopeType: 'unit', rank: 80, permissions: ['docs:write'] });
2237
- await driver.grant(alice, { uuid: hijo }, unitA1);
2238
- const salida = await authz.scopes.detached(orgA, { actor: forastero });
2239
- assert.deepEqual(salida, { purgedRoles: 1, truncated: true, reason: 'owner-detached-unknown' });
2240
- const vista = await new CatalogCache().view();
2241
- assert.isNull(vista.roleByUuid(propio), 'el rol del scope que ya no existe se va: no era visible en ninguna parte');
2242
- assert.isNotNull(vista.roleByUuid(hijo), 'el de la unit VIVA no lo toca nadie con rank 0');
2243
- assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1), 'y sigue concediendo');
2244
- roto = false;
2245
- await rejectsWith(assert, () => authz.scopes.detached(orgA, { actor: forastero }), { status: 422, code: 'E_AUTHZ_RANK_EXCEEDED' });
2246
- assert.isNotNull((await new CatalogCache().view()).roleByUuid(hijo));
2247
- });
2272
+ // tenía JUNTO a las otras. De los CUATRO que se escribieron en 3G,
2273
+ // tres eran del `scopes.detached` que purgaba roles (ancestro
2274
+ // desconocido con descendientes vivos, cota superada con rango
2275
+ // insuficiente, `descendantsOf` que falla). Con 3b-0 · Z1 esa
2276
+ // composición ya no existe —la operación no toca el catálogo, no
2277
+ // mide rango y no enumera el subárbol—, así que los tres se
2278
+ // borraron: no hay piezas que componer. El cuarto sigue vivo, en el
2279
+ // par `listDenies` con el resto de la delegación (`defineScopedRole`
2280
+ // que ensombrece con el subárbol sin enumerar), porque ahí sí siguen
2281
+ // componiéndose la regla de nivel degradada y el rango del
2282
+ // ensombrecido.
2248
2283
  },
2249
2284
  whenFalse: () => {
2250
- since('2.2', 'sin purgeRole: el puerto NO lo trae (opcional desde 3E · Q4) y el manager lo dice con 500 E_AUTHZ_UNSUPPORTED ANTES de escribir — defineScopedRole no crea el rol, deleteScopedRole y scopes.detached de un scope con roles locales no tocan nada; y el callejón tiene salida: borrada la fila del rol, el scope se purga con normalidad', async ({ assert, }) => {
2285
+ since('2.2', 'sin purgeRole: el puerto NO lo trae (opcional desde 3E · Q4) y el manager lo dice con 500 E_AUTHZ_UNSUPPORTED ANTES de escribir — defineScopedRole no crea el rol, deleteScopedRole y pruneOrphanRoles lo dicen sin tocar nada; scopes.detached, que solo purga HECHOS, no lo necesita', async ({ assert, }) => {
2251
2286
  // 3B · B4 + 3E · P4. Hasta 3E este caso FIJABA UN CALLEJÓN SIN
2252
2287
  // SALIDA como comportamiento esperado: `defineScopedRole` creaba el
2253
2288
  // rol contra un driver que jamás podría borrarlo y aquí se afirmaba
@@ -2279,22 +2314,26 @@ export function registerAuthorizationDriverContract(harness, api) {
2279
2314
  assert.deepEqual(await driver.listRoles(alice, unitA1), ['lead']);
2280
2315
  assert.isNotNull((await new CatalogCache().view()).roleByUuid(lead), 'el rol sigue en el catálogo');
2281
2316
  assert.equal(await linksOf(lead), 1, 'y sus vínculos');
2282
- // (3) 3D · M4: `scopes.detached` de un scope que ES owner de roles
2283
- // locales necesita purgarlos; sin `purgeRole` lo dice con 500
2284
- // ANTES de tocar los hechos (nada purgado a medias).
2317
+ // (3) 3b-0 · Z1: `scopes.detached` NO depende de `purgeRole`, ni
2318
+ // siquiera sobre un scope que ES owner de roles locales: purga
2319
+ // los HECHOS y no toca el catálogo (invariante 11). Hasta 3G
2320
+ // respondía 500 aquí, y el callejón —un scope cuyos hechos
2321
+ // nadie podía purgar— era real hasta que 3E · P4 lo cerró por
2322
+ // la otra punta. Ahora no hay callejón que cerrar.
2285
2323
  const owner = await unitUnder(tree, orgA);
2286
2324
  const propio = await localRole(owner, { slug: 'propio', scopeType: 'unit', permissions: ['docs:write'] });
2287
2325
  await driver.grant(alice, 'propio', owner);
2288
- await rejectsWith(assert, () => authz.scopes.detached(owner), { status: 500, code: 'E_AUTHZ_UNSUPPORTED' });
2289
- assert.isTrue(await driver.authorize(alice, 'docs:write', owner), 'los hechos del scope siguen: no se purgó nada');
2290
- assert.deepEqual(await driver.listRoles(alice, owner), ['propio']);
2291
- // (4) LA SALIDA: la plataforma borra la fila del rol (el catálogo
2292
- // es suyo y es SQL) y el scope se purga con normalidad. Nadie
2293
- // queda encerrado.
2294
- await forgetRoleByHand(propio);
2295
2326
  await authz.scopes.detached(owner);
2296
2327
  assert.deepEqual(await driver.listRoles(alice, owner), [], 'los hechos del scope, purgados');
2297
2328
  assert.isFalse(await driver.authorize(alice, 'docs:write', owner));
2329
+ assert.isNotNull((await new CatalogCache().view()).roleByUuid(propio), 'y el rol local sigue: purgarlo es otra operación');
2330
+ // (4) La que SÍ necesita `purgeRole` lo dice antes de leer nada:
2331
+ // `pruneOrphanRoles` (el motor de `authz:catalog:prune-orphans`).
2332
+ await rejectsWith(assert, () => authz.pruneOrphanRoles(), { status: 500, code: 'E_AUTHZ_UNSUPPORTED' });
2333
+ // Y la salida sigue siendo la de siempre: el catálogo es SQL y
2334
+ // es de la plataforma.
2335
+ await forgetRoleByHand(propio);
2336
+ assert.isNull((await new CatalogCache().view()).roleByUuid(propio));
2298
2337
  // (5) Un scope SIN roles locales propios se purga siempre.
2299
2338
  const simple = await unitUnder(tree, orgA);
2300
2339
  await driver.grant(alice, 'unit-editor', simple);
@@ -2303,6 +2342,180 @@ export function registerAuthorizationDriverContract(harness, api) {
2303
2342
  });
2304
2343
  },
2305
2344
  });
2345
+ // ── Par de capacidad `countRoleAssignments` (3b-2j) ───────────────────
2346
+ // `pruneOrphanRoles().stillGranting` avisa de que una purga puede estar
2347
+ // revocando permisos VIVOS (3b-0b · AA1) y su contrato publicado es
2348
+ // «falso ⇒ este rol seguro que no concede». Hasta 3b-2j lo calculaba el
2349
+ // barrido contando `authz_assignments` —la tabla del driver `database`—,
2350
+ // así que con un driver cuyos hechos viven en otro sitio (`openfga` en
2351
+ // modo `facts`, el store) era SIEMPRE `false`, justo antes de un borrado
2352
+ // destructivo. Contar los hechos de un rol pasa a ser una pregunta del
2353
+ // PUERTO. Un driver que la responda declara `countRoleAssignments: true`
2354
+ // y se le juzga el conteo; uno que no, declara `false` y se le juzga que
2355
+ // el barrido lo DIGA (`undefined`) en vez de degradarlo a «no concede».
2356
+ // Nunca un skip. **Es breaking para un driver de terceros de 2.2.**
2357
+ caseFor('countRoleAssignments', {
2358
+ whenTrue: () => {
2359
+ since('2.2', 'countRoleAssignments(uuids) cuenta los hechos VIGENTES de cada rol en TODOS los scopes y responde POR POSICIÓN: la asignación caducada no cuenta, un revoke lo baja, un rol sin hechos —o que el motor no conoce— es 0 y un uuid mal formado es 422', async ({ assert, }) => {
2360
+ // 3b-2j (decisión del dueño del 2026-08-31 (3)). Es el método que
2361
+ // hace VERDADERO el `stillGranting` de `pruneOrphanRoles` en los dos
2362
+ // drivers; aquí se juzga en el puerto, que es donde vive.
2363
+ assert.typeOf(driver.countRoleAssignments, 'function', 'countRoleAssignments: true exige el método en el puerto');
2364
+ const cuenta = (uuids) => driver.countRoleAssignments(uuids);
2365
+ const alice = subject();
2366
+ const bob = subject();
2367
+ const orgA = await orgUnder(tree, APP_SCOPE);
2368
+ const unitA1 = await unitUnder(tree, orgA);
2369
+ const unitA2 = await unitUnder(tree, orgA);
2370
+ const lead = await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'] });
2371
+ const mudo = await localRole(orgA, { slug: 'mudo', scopeType: 'unit', permissions: ['docs:read'] });
2372
+ assert.deepEqual(await cuenta([lead, mudo]), [0, 0], 'un rol recién definido no tiene hechos');
2373
+ assert.deepEqual(await cuenta([]), [], 'sin uuids no hay nada que contar (ni que preguntarle al backend)');
2374
+ // Hechos en DOS scopes y de dos holders: se cuentan todos, sin
2375
+ // herencia y sin re-resolver el scope de cada uno (es conservador).
2376
+ await driver.grant(alice, { uuid: lead }, unitA1);
2377
+ await driver.grant(bob, { uuid: lead }, unitA1);
2378
+ await driver.grant(alice, { uuid: lead }, unitA2);
2379
+ // Y uno CADUCADO, que no cuenta: la caducidad es estricta y es la
2380
+ // misma con la que responde `authorize`.
2381
+ await driver.grant(bob, { uuid: lead }, unitA2, { expiresAt: new Date(Date.now() - 60_000) });
2382
+ assert.isFalse(await driver.authorize(bob, 'docs:write', unitA2), 'el caducado no concede…');
2383
+ assert.deepEqual(await cuenta([lead, mudo]), [3, 0], '…y tampoco cuenta');
2384
+ // El orden es el de la pregunta, no el del backend.
2385
+ assert.deepEqual(await cuenta([mudo, lead]), [0, 3], 'la respuesta va POR POSICIÓN');
2386
+ // Un uuid que el motor no conoce es 0 (es un conteo de hechos, no
2387
+ // una consulta al catálogo) y uno mal formado es 422.
2388
+ assert.deepEqual(await cuenta([uuidv7()]), [0], 'un rol que el motor no conoce no tiene hechos');
2389
+ await rejectsWith(assert, () => cuenta([lead, 'lead']), { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' });
2390
+ // Lo que baja el contador es que el hecho DEJE de existir.
2391
+ await driver.revoke(alice, { uuid: lead }, unitA2);
2392
+ assert.deepEqual(await cuenta([lead]), [2]);
2393
+ await driver.revoke(alice, { uuid: lead }, unitA1);
2394
+ await driver.revoke(bob, { uuid: lead }, unitA1);
2395
+ assert.deepEqual(await cuenta([lead]), [0], 'sin hechos vigentes: el barrido puede decir «no concede» con verdad');
2396
+ });
2397
+ },
2398
+ whenFalse: () => {
2399
+ since('2.2', 'sin countRoleAssignments: el puerto NO lo trae y pruneOrphanRoles deja stillGranting (y assignments) en `undefined` —JAMÁS en `false`—, y el comando lista ese rol aparte: «no lo sé» no puede degradar a «no concede» justo antes de purgar', async ({ assert, }) => {
2400
+ // La cara que un driver de terceros escrito para 2.2 estrena con
2401
+ // este lote (breaking del puerto, decisión del dueño del
2402
+ // 2026-08-31 (3) · consecuencia 1). El `false` de antes no era
2403
+ // conservador: era falso, y se leía justo antes de un borrado.
2404
+ assert.isUndefined(driver.countRoleAssignments, 'countRoleAssignments: false ⇒ el puerto no lo trae; un método que solo lanza al llamarlo no deja al manager protegerte');
2405
+ const alice = subject();
2406
+ const orgA = await orgUnder(tree, APP_SCOPE);
2407
+ const unitA1 = await unitUnder(tree, orgA);
2408
+ const unitA1x = await unitUnder(tree, unitA1);
2409
+ const lead = await localRole(unitA1, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'] });
2410
+ await driver.grant(alice, { uuid: lead }, unitA1x);
2411
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1x), 'el rol concede HOY');
2412
+ // El driver de terceros COMPLETO: sabe purgar (si no,
2413
+ // `pruneOrphanRoles` es 500 antes de leer nada — par `purgeRole`) y
2414
+ // no sabe contar. Su `purgeRole` no debe llegar a llamarse: el
2415
+ // default es `--dry-run`.
2416
+ const tercero = Object.create(driver);
2417
+ Object.defineProperty(tercero, 'purgeRole', {
2418
+ value: async () => assert.fail('el dry-run no purga'),
2419
+ enumerable: false,
2420
+ });
2421
+ const authz = managerOver({}, tercero);
2422
+ // El owner sale del árbol; el NIETO conserva su ruta materializada
2423
+ // por él, así que el rol sigue concediendo (3b-0b · AA1).
2424
+ await tree.detach(unitA1);
2425
+ const seco = await authz.pruneOrphanRoles();
2426
+ assert.deepEqual(seco.orphans.map((o) => o.role.uuid), [lead]);
2427
+ assert.isUndefined(seco.orphans[0].stillGranting, 'sin el método del puerto: no se sabe');
2428
+ assert.notStrictEqual(seco.orphans[0].stillGranting, false, '«no lo sé» NUNCA es «no concede»');
2429
+ assert.isUndefined(seco.orphans[0].assignments, 'ni se inventa el contador que lo respaldaría');
2430
+ assert.deepEqual(seco.purged, [], 'y el dry-run no ha borrado nada');
2431
+ });
2432
+ },
2433
+ });
2434
+ // ── Par de capacidad `enumerateFacts` (3b-3b) ────────────────────────
2435
+ // Ser el ORIGEN de `authz:reconcile`. El 3a dejó `reconcile` con el patrón
2436
+ // de método opcional del puerto (`E_AUTHZ_UNSUPPORTED`) y sin capacidad
2437
+ // nueva; ésta es la entrada que faltaba, y tiene las DOS caras con caso
2438
+ // porque los dos drivers del paquete las ocupan: `openfga` sabe entregar
2439
+ // sus hechos (viven en el store y solo él sabe volverlos a
2440
+ // `(holder, rol, scope)`) y `database` no lo trae a propósito (sus hechos
2441
+ // SON `authz_assignments`/`authz_denies`, el esquema publicado).
2442
+ caseFor('enumerateFacts', {
2443
+ whenTrue: () => {
2444
+ since('2.2', 'enumerateFacts: los hechos del driver salen paginados, con su caducidad SIN filtrar y con un cursor que avanza — es lo que `authz:reconcile --to=<otro>` lee como origen', async ({ assert, }) => {
2445
+ const alice = subject();
2446
+ const bob = subject();
2447
+ const org = await orgUnder(tree, APP_SCOPE);
2448
+ const vencida = new Date(Date.now() - 60_000);
2449
+ await driver.grant(alice, 'org-editor', org);
2450
+ await driver.grant(bob, 'org-editor', org, { expiresAt: vencida });
2451
+ await driver.deny(alice, 'docs:write', org);
2452
+ const todos = [];
2453
+ let after;
2454
+ const cursores = new Set();
2455
+ for (let page = 0; page < 100; page++) {
2456
+ const got = await driver.enumerateFacts({ limit: 1, after });
2457
+ assert.isAtMost(got.facts.length, 1, 'nunca más de `limit` por página');
2458
+ todos.push(...got.facts);
2459
+ if (!got.cursor)
2460
+ break;
2461
+ assert.isFalse(cursores.has(got.cursor), 'el cursor tiene que AVANZAR, nunca repetirse');
2462
+ cursores.add(got.cursor);
2463
+ after = got.cursor;
2464
+ }
2465
+ const asignaciones = todos.filter((f) => f.kind === 'assignment');
2466
+ const denies = todos.filter((f) => f.kind === 'deny');
2467
+ assert.lengthOf(denies, 1, 'el deny es un hecho y sale');
2468
+ assert.equal(denies[0].permission, 'docs:write');
2469
+ assert.deepEqual(scopeKeys([denies[0].scope]), scopeKeys([org]));
2470
+ assert.lengthOf(asignaciones, 2, 'la CADUCADA también sale: filtrarla la haría desaparecer sin contarla');
2471
+ const deBob = asignaciones.find((f) => f.holder.uuid === bob.uuid);
2472
+ assert.equal(deBob.expiresAt?.getTime(), vencida.getTime(), 'y con su instante, para que el destino la cuente');
2473
+ assert.isString(deBob.roleUuid, 'la identidad del rol es el uuid, nunca el slug');
2474
+ assert.isString(deBob.detail, 'y viene nombrada: un motivo sin la fila no se arregla');
2475
+ // Y con una página grande sale exactamente el mismo conjunto (el
2476
+ // puerto promete «como mucho `limit`», no «exactamente `limit`»: un
2477
+ // backend con su propio tope de página lo respeta y da cursor).
2478
+ const grande = [];
2479
+ let siguiente;
2480
+ for (let page = 0; page < 100; page++) {
2481
+ const got = await driver.enumerateFacts({ limit: 1_000, after: siguiente });
2482
+ assert.isAtMost(got.facts.length, 1_000);
2483
+ grande.push(...got.facts);
2484
+ if (!got.cursor)
2485
+ break;
2486
+ siguiente = got.cursor;
2487
+ }
2488
+ assert.lengthOf(grande, todos.length);
2489
+ });
2490
+ },
2491
+ whenFalse: () => {
2492
+ since('2.2', 'sin enumerateFacts el driver NO puede ser el origen de una migración, y authz:reconcile lo DICE (500 E_AUTHZ_UNSUPPORTED nombrándolo) en vez de leer cero hechos y vaciar el destino con --prune', async ({ assert, }) => {
2493
+ assert.isUndefined(driver.enumerateFacts, 'enumerateFacts: false ⇒ el puerto no lo trae');
2494
+ // Un destino de laboratorio que SÍ sabe reconstruirse: lo que se
2495
+ // juzga es que la pasada muera nombrando al ORIGEN, no al destino.
2496
+ const destino = {
2497
+ capabilities: driver.capabilities,
2498
+ async reconcile(source) {
2499
+ await source.facts({ limit: 10 });
2500
+ assert.fail('no debería haber llegado a leer hechos');
2501
+ },
2502
+ };
2503
+ const authz = managerOver({
2504
+ drivers: { [harness.name]: () => driver, destino: () => destino },
2505
+ scopes: {
2506
+ resolveChain: resolveChainFrom(tree),
2507
+ descendantsOf: descendantsFrom(tree),
2508
+ enumerateEdges: async () => ({ edges: [] }),
2509
+ },
2510
+ });
2511
+ const error = await rejectsWith(assert, () => authz.reconcile({ to: 'destino' }), {
2512
+ status: 500,
2513
+ code: 'E_AUTHZ_UNSUPPORTED',
2514
+ });
2515
+ assert.include(String(error.message), 'enumerateFacts');
2516
+ });
2517
+ },
2518
+ });
2306
2519
  // ── Par de capacidad `listDenies` (2E · I5) ──────────────────────────
2307
2520
  // Todo lo que RESTA denies (`listDenies`, `effectivePermissions`,
2308
2521
  // `authorizedScopes`, y F1, que observa el catálogo a través de
@@ -3045,6 +3258,39 @@ export function registerAuthorizationDriverContract(harness, api) {
3045
3258
  // `injectableClock` tiene su par arriba (2.5 · J1). `transactions` (2.6)
3046
3259
  // y `singleCheckAuthorize` (Fase 3b): pares en su fase; hoy solo pueden
3047
3260
  // declararse `false`.
3261
+ // ── 3b-2e · E6 · `singleCheckAuthorize` ──────────────────────────────
3262
+ caseFor('singleCheckAuthorize', {
3263
+ whenTrue: () => {
3264
+ test('`authorize` no consulta el árbol del consumidor (0 resolveChain) y aun así hereda hacia abajo', async ({ assert, }) => {
3265
+ // El literal aprobado (panel 2, cruce 6): en modo `facts` la
3266
+ // DECISIÓN no pasa por el árbol del consumidor. Lo que sigue
3267
+ // pasando por él es la MEMBRESÍA (`roleInheritanceNative: false`,
3268
+ // su propio par), y por eso el titular «sin SQL» está prohibido.
3269
+ const alice = subject();
3270
+ const org = await orgUnder(tree, APP_SCOPE);
3271
+ const unit = await unitUnder(tree, org);
3272
+ await driver.grant(alice, 'org-editor', org);
3273
+ const abajo = await withChainSpy(() => driver.authorize(alice, 'docs:read', unit));
3274
+ assert.isTrue(abajo.result, 'la herencia la resuelve el backend');
3275
+ assert.equal(abajo.calls, 0, 'y sin preguntarle al árbol del consumidor ni una vez');
3276
+ const hermana = await orgUnder(tree, APP_SCOPE);
3277
+ const fuera = await withChainSpy(() => driver.authorize(alice, 'docs:read', hermana));
3278
+ assert.isFalse(fuera.result);
3279
+ assert.equal(fuera.calls, 0);
3280
+ });
3281
+ },
3282
+ whenFalse: () => {
3283
+ test('`authorize` resuelve la cadena con el árbol del consumidor (lo consulta al menos una vez)', async ({ assert, }) => {
3284
+ const alice = subject();
3285
+ const org = await orgUnder(tree, APP_SCOPE);
3286
+ const unit = await unitUnder(tree, org);
3287
+ await driver.grant(alice, 'org-editor', org);
3288
+ const { result, calls } = await withChainSpy(() => driver.authorize(alice, 'docs:read', unit));
3289
+ assert.isTrue(result);
3290
+ assert.isAbove(calls, 0);
3291
+ });
3292
+ },
3293
+ });
3048
3294
  caseFor('hierarchyFacts', {
3049
3295
  // Con el árbol en manos del consumidor (`resolveChain`), el árbol
3050
3296
  // es una dependencia más de cada pregunta: su caída se clasifica como la
@@ -3071,6 +3317,263 @@ export function registerAuthorizationDriverContract(harness, api) {
3071
3317
  }
3072
3318
  assert.isTrue(await driver.authorize(alice, 'docs:read', org));
3073
3319
  });
3320
+ /**
3321
+ * La otra mitad de R2 (b), en el nivel del MANAGER (3b-2k · K1).
3322
+ * Salió del caso general de `authorizeMany`, que lo afirmaba para
3323
+ * todo driver: con el árbol en manos del consumidor una posición que
3324
+ * no se puede resolver tumba la llamada ENTERA (nunca un array
3325
+ * parcial, que sería un `false` disfrazado de respuesta).
3326
+ */
3327
+ since('2.1', 'authorizeMany con un scope cuyo árbol lanza: 503 E_AUTHZ_RESOLVER_FAILED entero, jamás un array parcial', async ({ assert, }) => {
3328
+ const authz = managerOver();
3329
+ const alice = subject();
3330
+ const orgA = await orgUnder(tree, APP_SCOPE);
3331
+ const orgB = await orgUnder(tree, APP_SCOPE);
3332
+ const unitB1 = await unitUnder(tree, orgB);
3333
+ await driver.grant(alice, 'org-editor', orgA);
3334
+ const original = tree.chainOf;
3335
+ tree.chainOf = async (scope) => {
3336
+ if (`${scope.type}:${scope.uuid}` === `${unitB1.type}:${unitB1.uuid}`)
3337
+ throw new Error('árbol caído');
3338
+ return original.call(tree, scope);
3339
+ };
3340
+ try {
3341
+ await rejectsWith(assert, () => authz.authorizeMany(alice, 'docs:write', [orgA, unitB1, orgB]), {
3342
+ status: 503,
3343
+ code: 'E_AUTHZ_RESOLVER_FAILED',
3344
+ });
3345
+ }
3346
+ finally {
3347
+ tree.chainOf = original;
3348
+ }
3349
+ });
3350
+ },
3351
+ /**
3352
+ * Con el árbol como HECHOS del backend, la caída del resolutor del
3353
+ * consumidor deja de tumbar la DECISIÓN: `authorize` sigue respondiendo
3354
+ * (es la ganancia de disponibilidad de este modo). Lo que NO sobrevive
3355
+ * es la membresía, que sigue pasando por el árbol (cruce 6): ahí el
3356
+ * 503 se conserva, porque un `false` sería fail-open.
3357
+ */
3358
+ whenTrue: () => {
3359
+ test('el árbol vive en el backend: con el resolutor del consumidor caído el modo es *grant-only* — `authorize` responde `true` y NADA se puede revocar (hasRole/list*/revoke/deny/removeDeny/purgeScope son 503)', async ({ assert, }) => {
3360
+ // 3b-2k · K1 · R2 (b), con la ampliación del 🟡 5 del auditor R2:
3361
+ // no basta decir «authorize sobrevive». Lo que hay que decir, y lo
3362
+ // que este caso fija, es que sobrevive **concediendo** mientras
3363
+ // todo lo que quitaría ese acceso está caído — el paquete no
3364
+ // ofrece, mientras dura, ninguna forma de retirarlo. Es la
3365
+ // propiedad que el dueño compró (PDP autónomo) con su precio
3366
+ // escrito, no un fallo: cerrarla sería volver a meter
3367
+ // `resolveChain` en el camino caliente de `authorize`.
3368
+ const alice = subject();
3369
+ const org = await orgUnder(tree, APP_SCOPE);
3370
+ const unit = await unitUnder(tree, org);
3371
+ await driver.grant(alice, 'org-editor', org);
3372
+ assert.isTrue(await driver.authorize(alice, 'docs:read', unit));
3373
+ const original = tree.chainOf;
3374
+ tree.chainOf = async () => {
3375
+ throw new Error('el árbol del consumidor está caído');
3376
+ };
3377
+ const down = { status: 503, code: 'E_AUTHZ_RESOLVER_FAILED' };
3378
+ try {
3379
+ assert.isTrue(await driver.authorize(alice, 'docs:read', unit), 'la decisión no lo necesita');
3380
+ assert.isTrue(await driver.authorize(alice, 'docs:write', org), 'ni en el scope exacto');
3381
+ // Y ninguna de las salidas para quitarlo responde.
3382
+ for (const [label, call] of [
3383
+ ['hasRole', () => driver.hasRole(alice, 'org-editor', unit)],
3384
+ ['listRoles', () => driver.listRoles(alice, org)],
3385
+ ['listRoleScopes', () => driver.listRoleScopes(alice, 'organization')],
3386
+ ['listSubjects', () => driver.listSubjects('org-editor', org)],
3387
+ ['listScopes', () => driver.listScopes(alice, 'docs:read')],
3388
+ ['revoke', () => driver.revoke(alice, 'org-editor', org)],
3389
+ ['deny', () => driver.deny(alice, 'docs:read', unit)],
3390
+ ['removeDeny', () => driver.removeDeny(alice, 'docs:read', unit)],
3391
+ ['purgeScope', () => driver.purgeScope(org)],
3392
+ ]) {
3393
+ const error = await rejectsWith(assert, call, down);
3394
+ assert.equal(error?.code, down.code, label);
3395
+ }
3396
+ // La consecuencia, dicha entera: sigue concediendo.
3397
+ assert.isTrue(await driver.authorize(alice, 'docs:read', unit), 'concede y no se puede revocar');
3398
+ }
3399
+ finally {
3400
+ tree.chainOf = original;
3401
+ }
3402
+ // Con el árbol de vuelta, revocar vuelve a funcionar: la ventana es
3403
+ // exactamente la caída, no un estado que quede pegado.
3404
+ await driver.revoke(alice, 'org-editor', org);
3405
+ assert.isFalse(await driver.authorize(alice, 'docs:read', unit));
3406
+ });
3407
+ since('2.1', 'authorizeMany con un scope cuyo árbol lanza: RESPONDE con el árbol del store (no hay 503), porque la decisión no pasa por el resolutor', async ({ assert, }) => {
3408
+ // La cara `true` de lo que el caso general de `authorizeMany`
3409
+ // afirmaba para todos (3b-2k · K1 · R2 (b)). No es un caso más
3410
+ // laxo: exige la respuesta EXACTA, la misma que daría el árbol del
3411
+ // store con el resolutor en pie.
3412
+ const authz = managerOver();
3413
+ const alice = subject();
3414
+ const orgA = await orgUnder(tree, APP_SCOPE);
3415
+ const orgB = await orgUnder(tree, APP_SCOPE);
3416
+ const unitB1 = await unitUnder(tree, orgB);
3417
+ await driver.grant(alice, 'org-editor', orgA);
3418
+ const esperado = await authz.authorizeMany(alice, 'docs:write', [orgA, unitB1, orgB]);
3419
+ assert.deepEqual(esperado, [true, false, false], 'precondición del caso');
3420
+ const original = tree.chainOf;
3421
+ tree.chainOf = async (scope) => {
3422
+ if (`${scope.type}:${scope.uuid}` === `${unitB1.type}:${unitB1.uuid}`)
3423
+ throw new Error('árbol caído');
3424
+ return original.call(tree, scope);
3425
+ };
3426
+ try {
3427
+ assert.deepEqual(await authz.authorizeMany(alice, 'docs:write', [orgA, unitB1, orgB]), esperado, 'la misma respuesta que con el árbol en pie');
3428
+ }
3429
+ finally {
3430
+ tree.chainOf = original;
3431
+ }
3432
+ });
3433
+ },
3434
+ });
3435
+ // ── 3b-2k · K1 · R2 (c) · `canonicalScopeReads` ──────────────────────
3436
+ caseFor('canonicalScopeReads', {
3437
+ whenTrue: () => {
3438
+ test('un alias del uuid que el árbol funde con la fila canónica encuentra sus hechos: `authorize` responde lo mismo que sobre la forma canónica', async ({ assert, }) => {
3439
+ const alice = subject();
3440
+ const org = await orgUnder(tree, APP_SCOPE);
3441
+ const unit = await unitUnder(tree, org);
3442
+ await driver.grant(alice, 'unit-editor', unit);
3443
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit), 'precondición del caso');
3444
+ const alias = { type: 'unit', uuid: unit.uuid.replaceAll('-', '') };
3445
+ await withAliasFused(alias, unit, async () => {
3446
+ assert.isTrue(await driver.authorize(alice, 'docs:write', alias), 'con canonicalScopeReads: true la lectura usa chain[0] (invariante 17)');
3447
+ });
3448
+ });
3449
+ },
3450
+ whenFalse: () => {
3451
+ test('un alias del uuid que el árbol funde con la fila canónica NO encuentra sus hechos: `authorize` es false (fail-CLOSED) donde la forma canónica concede — pero las ESCRITURAS sí canonizan', async ({ assert, }) => {
3452
+ // 3b-2k · K1 · R2 (c). La divergencia es de LECTURA y de una sola
3453
+ // dirección: nunca convierte un `false` en `true` (no evade denies
3454
+ // ni concede de más), pero no es la misma respuesta que
3455
+ // `database`, y quien pasa uuids con otra ortografía que la de su
3456
+ // tabla se queda fuera sin ningún aviso. La cara fail-OPEN, que sí
3457
+ // era un bug, la cerró 3b-2h (🟠 3 del auditor R2): la escritura
3458
+ // canoniza, y este caso lo fija aquí para que la capacidad no se
3459
+ // lea como «en `facts` la ortografía da igual».
3460
+ const alice = subject();
3461
+ const org = await orgUnder(tree, APP_SCOPE);
3462
+ const unit = await unitUnder(tree, org);
3463
+ await driver.grant(alice, 'unit-editor', unit);
3464
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit), 'precondición del caso');
3465
+ const eve = subject();
3466
+ const alias = { type: 'unit', uuid: unit.uuid.replaceAll('-', '') };
3467
+ await withAliasFused(alias, unit, async () => {
3468
+ assert.isFalse(await driver.authorize(alice, 'docs:write', alias), 'fail-CLOSED: no encuentra sus hechos');
3469
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit), 'y la forma canónica sigue concediendo');
3470
+ // La escritura sí canoniza (3b-2h · 🟠 3), en las cuatro puertas.
3471
+ await driver.grant(eve, 'unit-editor', alias);
3472
+ assert.deepEqual(await driver.listRoles(eve, unit), ['unit-editor'], 'el grant sobre el alias queda bajo la forma canónica');
3473
+ await driver.revoke(eve, 'unit-editor', alias);
3474
+ assert.deepEqual(await driver.listRoles(eve, unit), [], 'y el revoke sobre el alias quita el hecho canónico');
3475
+ await driver.deny(alice, 'docs:write', alias);
3476
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unit), 'el deny sobre el alias bloquea la forma canónica');
3477
+ await driver.removeDeny(alice, 'docs:write', alias);
3478
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit));
3479
+ await driver.purgeScope(alias);
3480
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unit), 'purgeScope sobre el alias purga los hechos CANÓNICOS');
3481
+ });
3482
+ });
3483
+ },
3484
+ });
3485
+ /**
3486
+ * El árbol del consumidor FUNDE dos ortografías del mismo uuid, que es lo
3487
+ * que hace de verdad una columna `uuid` de PostgreSQL o una collation
3488
+ * `*_ci` de MySQL. Se parchea `chainOf` en vez de depender del motor para
3489
+ * que el par `canonicalScopeReads` se juzgue igual en TODOS los harness,
3490
+ * también con el árbol en memoria (donde el alias sería, si no,
3491
+ * simplemente desconocido y el caso no probaría nada).
3492
+ */
3493
+ async function withAliasFused(alias, canonical, fn) {
3494
+ const original = tree.chainOf;
3495
+ tree.chainOf = async (scope) => {
3496
+ const fused = scope.type === alias.type && scope.uuid === alias.uuid ? canonical : scope;
3497
+ return original.call(tree, fused);
3498
+ };
3499
+ try {
3500
+ await fn();
3501
+ }
3502
+ finally {
3503
+ tree.chainOf = original;
3504
+ }
3505
+ }
3506
+ /**
3507
+ * Un espía sobre el árbol del consumidor: cuántas veces lo consulta cada
3508
+ * primitiva. Es la única forma de juzgar «esto NO pasa por el árbol» sin
3509
+ * mirar dentro del driver.
3510
+ */
3511
+ async function withChainSpy(fn) {
3512
+ const original = tree.chainOf;
3513
+ let calls = 0;
3514
+ tree.chainOf = async (scope) => {
3515
+ calls += 1;
3516
+ return original.call(tree, scope);
3517
+ };
3518
+ try {
3519
+ return { result: await fn(), calls };
3520
+ }
3521
+ finally {
3522
+ tree.chainOf = original;
3523
+ }
3524
+ }
3525
+ // ── 3b-2e · E2 · `roleInheritanceNative` ─────────────────────────────
3526
+ caseFor('roleInheritanceNative', {
3527
+ whenFalse: () => {
3528
+ test('la MEMBRESÍA la resuelve el paquete con el árbol del consumidor: hasRole/listRoles/listRoleScopes/listSubjects/listScopes lo consultan (cruce 6)', async ({ assert, }) => {
3529
+ // Es la declaración honesta que el README tiene que hacer: lo que
3530
+ // un modo `facts` puede prometer es «un solo Check en `authorize`»,
3531
+ // no «sin SQL». Estas cinco lecturas siguen pasando por el árbol en
3532
+ // LOS DOS drivers, y un driver que lo cambie tiene que declararlo.
3533
+ const alice = subject();
3534
+ const org = await orgUnder(tree, APP_SCOPE);
3535
+ const unit = await unitUnder(tree, org);
3536
+ await driver.grant(alice, 'org-editor', org);
3537
+ const primitives = [
3538
+ ['hasRole', () => driver.hasRole(alice, 'org-editor', unit)],
3539
+ ['listRoles', () => driver.listRoles(alice, org)],
3540
+ ['listRoleScopes', () => driver.listRoleScopes(alice, 'organization')],
3541
+ ['listSubjects', () => driver.listSubjects('org-editor', org)],
3542
+ ['listScopes', () => driver.listScopes(alice, 'docs:read')],
3543
+ ];
3544
+ for (const [name, run] of primitives) {
3545
+ const { calls } = await withChainSpy(run);
3546
+ assert.isAbove(calls, 0, `${name}: con roleInheritanceNative: false tiene que consultar el árbol`);
3547
+ }
3548
+ // Y la consecuencia observable: mover el nodo cambia la membresía
3549
+ // sin escribir un solo hecho.
3550
+ assert.isTrue(await driver.hasRole(alice, 'org-editor', unit));
3551
+ const otra = await orgUnder(tree, APP_SCOPE);
3552
+ await tree.move(unit, otra);
3553
+ assert.isFalse(await driver.hasRole(alice, 'org-editor', unit), 'la membresía es del árbol de HOY');
3554
+ });
3555
+ },
3556
+ });
3557
+ // ── 3b-2e · E2 · `listObjectsInherited` ──────────────────────────────
3558
+ caseFor('listObjectsInherited', {
3559
+ whenFalse: () => {
3560
+ test('los `list*` NO enumeran herencia (invariante 7): un grant en el ancestro no aparece abajo, aunque authorize diga true', async ({ assert, }) => {
3561
+ // El rol es del MISMO nivel que los dos scopes (`unit-editor@unit`)
3562
+ // a propósito: con un rol de otro nivel el filtro de nivel taparía
3563
+ // una enumeración que sí heredara, y el caso no probaría nada.
3564
+ const alice = subject();
3565
+ const org = await orgUnder(tree, APP_SCOPE);
3566
+ const unit = await unitUnder(tree, org);
3567
+ const sub = await unitUnder(tree, unit);
3568
+ await driver.grant(alice, 'unit-editor', unit);
3569
+ assert.isTrue(await driver.authorize(alice, 'docs:write', sub), 'la DECISIÓN sí hereda');
3570
+ assert.deepEqual(await driver.listRoles(alice, sub), [], 'la enumeración NO');
3571
+ assert.deepEqual(await driver.listSubjects('unit-editor', sub), [], 'ni por el otro lado');
3572
+ assert.deepEqual(scopeKeys(await driver.listScopes(alice, 'docs:write')), scopeKeys([unit]));
3573
+ assert.deepEqual(scopeKeys(await driver.listRoleScopes(alice, 'unit')), scopeKeys([unit]));
3574
+ // Y en el scope EXACTO sí está: lo que no se enumera es lo heredado.
3575
+ assert.deepEqual(await driver.listRoles(alice, unit), ['unit-editor']);
3576
+ });
3074
3577
  },
3075
3578
  });
3076
3579
  caseFor('truncationSignal', {