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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (236) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +837 -51
  3. package/build/commands/authz_catalog_diff.d.ts +28 -0
  4. package/build/commands/authz_catalog_diff.d.ts.map +1 -0
  5. package/build/commands/authz_catalog_diff.js +67 -0
  6. package/build/commands/authz_catalog_diff.js.map +1 -0
  7. package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
  8. package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
  9. package/build/commands/authz_catalog_prune_orphans.js +136 -0
  10. package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
  11. package/build/commands/authz_catalog_sync.d.ts +37 -0
  12. package/build/commands/authz_catalog_sync.d.ts.map +1 -0
  13. package/build/commands/authz_catalog_sync.js +81 -0
  14. package/build/commands/authz_catalog_sync.js.map +1 -0
  15. package/build/commands/authz_freeze.d.ts +44 -0
  16. package/build/commands/authz_freeze.d.ts.map +1 -0
  17. package/build/commands/authz_freeze.js +95 -0
  18. package/build/commands/authz_freeze.js.map +1 -0
  19. package/build/commands/authz_reconcile.d.ts +102 -0
  20. package/build/commands/authz_reconcile.d.ts.map +1 -0
  21. package/build/commands/authz_reconcile.js +294 -0
  22. package/build/commands/authz_reconcile.js.map +1 -0
  23. package/build/commands/authz_relations_reconcile.d.ts +73 -0
  24. package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
  25. package/build/commands/authz_relations_reconcile.js +225 -0
  26. package/build/commands/authz_relations_reconcile.js.map +1 -0
  27. package/build/commands/authz_scopes_relay.d.ts +47 -0
  28. package/build/commands/authz_scopes_relay.d.ts.map +1 -0
  29. package/build/commands/authz_scopes_relay.js +141 -0
  30. package/build/commands/authz_scopes_relay.js.map +1 -0
  31. package/build/commands/authz_unfreeze.d.ts +37 -0
  32. package/build/commands/authz_unfreeze.d.ts.map +1 -0
  33. package/build/commands/authz_unfreeze.js +92 -0
  34. package/build/commands/authz_unfreeze.js.map +1 -0
  35. package/build/commands/main.d.ts +8 -1
  36. package/build/commands/main.d.ts.map +1 -1
  37. package/build/commands/main.js +8 -1
  38. package/build/commands/main.js.map +1 -1
  39. package/build/commands/openfga_provision.d.ts +46 -4
  40. package/build/commands/openfga_provision.d.ts.map +1 -1
  41. package/build/commands/openfga_provision.js +91 -7
  42. package/build/commands/openfga_provision.js.map +1 -1
  43. package/build/configure.d.ts +11 -0
  44. package/build/configure.d.ts.map +1 -1
  45. package/build/configure.js +37 -1
  46. package/build/configure.js.map +1 -1
  47. package/build/index.d.ts +75 -7
  48. package/build/index.d.ts.map +1 -1
  49. package/build/index.js +67 -5
  50. package/build/index.js.map +1 -1
  51. package/build/providers/authz_provider.d.ts +26 -2
  52. package/build/providers/authz_provider.d.ts.map +1 -1
  53. package/build/providers/authz_provider.js +48 -2
  54. package/build/providers/authz_provider.js.map +1 -1
  55. package/build/services/relations.d.ts +14 -0
  56. package/build/services/relations.d.ts.map +1 -0
  57. package/build/services/relations.js +17 -0
  58. package/build/services/relations.js.map +1 -0
  59. package/build/src/catalog/catalog.d.ts +289 -0
  60. package/build/src/catalog/catalog.d.ts.map +1 -0
  61. package/build/src/catalog/catalog.js +859 -0
  62. package/build/src/catalog/catalog.js.map +1 -0
  63. package/build/src/catalog/catalog_cache.d.ts +324 -0
  64. package/build/src/catalog/catalog_cache.d.ts.map +1 -0
  65. package/build/src/catalog/catalog_cache.js +666 -0
  66. package/build/src/catalog/catalog_cache.js.map +1 -0
  67. package/build/src/clock.d.ts +24 -0
  68. package/build/src/clock.d.ts.map +1 -0
  69. package/build/src/clock.js +7 -0
  70. package/build/src/clock.js.map +1 -0
  71. package/build/src/define_config.d.ts +201 -7
  72. package/build/src/define_config.d.ts.map +1 -1
  73. package/build/src/define_config.js.map +1 -1
  74. package/build/src/drivers/database_driver.d.ts +324 -15
  75. package/build/src/drivers/database_driver.d.ts.map +1 -1
  76. package/build/src/drivers/database_driver.js +1107 -128
  77. package/build/src/drivers/database_driver.js.map +1 -1
  78. package/build/src/drivers/database_relations_driver.d.ts +75 -0
  79. package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
  80. package/build/src/drivers/database_relations_driver.js +450 -0
  81. package/build/src/drivers/database_relations_driver.js.map +1 -0
  82. package/build/src/drivers/openfga_driver.d.ts +963 -55
  83. package/build/src/drivers/openfga_driver.d.ts.map +1 -1
  84. package/build/src/drivers/openfga_driver.js +2693 -372
  85. package/build/src/drivers/openfga_driver.js.map +1 -1
  86. package/build/src/drivers/openfga_facts.d.ts +369 -0
  87. package/build/src/drivers/openfga_facts.d.ts.map +1 -0
  88. package/build/src/drivers/openfga_facts.js +813 -0
  89. package/build/src/drivers/openfga_facts.js.map +1 -0
  90. package/build/src/drivers/openfga_relations_driver.d.ts +120 -0
  91. package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
  92. package/build/src/drivers/openfga_relations_driver.js +466 -0
  93. package/build/src/drivers/openfga_relations_driver.js.map +1 -0
  94. package/build/src/errors.d.ts +586 -0
  95. package/build/src/errors.d.ts.map +1 -1
  96. package/build/src/errors.js +583 -0
  97. package/build/src/errors.js.map +1 -1
  98. package/build/src/expiry.d.ts +27 -0
  99. package/build/src/expiry.d.ts.map +1 -0
  100. package/build/src/expiry.js +50 -0
  101. package/build/src/expiry.js.map +1 -0
  102. package/build/src/freeze.d.ts +120 -0
  103. package/build/src/freeze.d.ts.map +1 -0
  104. package/build/src/freeze.js +172 -0
  105. package/build/src/freeze.js.map +1 -0
  106. package/build/src/hierarchical_resolver.d.ts +56 -0
  107. package/build/src/hierarchical_resolver.d.ts.map +1 -0
  108. package/build/src/hierarchical_resolver.js +87 -0
  109. package/build/src/hierarchical_resolver.js.map +1 -0
  110. package/build/src/{middleware → http}/app_access_middleware.d.ts +8 -6
  111. package/build/src/http/app_access_middleware.d.ts.map +1 -0
  112. package/build/src/{middleware → http}/app_access_middleware.js +10 -26
  113. package/build/src/http/app_access_middleware.js.map +1 -0
  114. package/build/src/http/resource_access_middleware.d.ts +105 -0
  115. package/build/src/http/resource_access_middleware.d.ts.map +1 -0
  116. package/build/src/http/resource_access_middleware.js +81 -0
  117. package/build/src/http/resource_access_middleware.js.map +1 -0
  118. package/build/src/identity.d.ts +228 -0
  119. package/build/src/identity.d.ts.map +1 -0
  120. package/build/src/identity.js +457 -0
  121. package/build/src/identity.js.map +1 -0
  122. package/build/src/manager.d.ts +536 -7
  123. package/build/src/manager.d.ts.map +1 -1
  124. package/build/src/manager.js +2676 -23
  125. package/build/src/manager.js.map +1 -1
  126. package/build/src/memoize_ancestors.d.ts +23 -0
  127. package/build/src/memoize_ancestors.d.ts.map +1 -0
  128. package/build/src/memoize_ancestors.js +42 -0
  129. package/build/src/memoize_ancestors.js.map +1 -0
  130. package/build/src/models/authz_assignment.d.ts +11 -11
  131. package/build/src/models/authz_assignment.d.ts.map +1 -1
  132. package/build/src/models/authz_deny.d.ts +11 -11
  133. package/build/src/models/authz_deny.d.ts.map +1 -1
  134. package/build/src/models/authz_permission.d.ts +17 -11
  135. package/build/src/models/authz_permission.d.ts.map +1 -1
  136. package/build/src/models/authz_permission.js +4 -0
  137. package/build/src/models/authz_permission.js.map +1 -1
  138. package/build/src/models/authz_role.d.ts +19 -12
  139. package/build/src/models/authz_role.d.ts.map +1 -1
  140. package/build/src/models/authz_role.js +6 -1
  141. package/build/src/models/authz_role.js.map +1 -1
  142. package/build/src/models/authz_role_permission.d.ts +11 -11
  143. package/build/src/models/authz_role_permission.d.ts.map +1 -1
  144. package/build/src/openfga.d.ts +29 -0
  145. package/build/src/openfga.d.ts.map +1 -0
  146. package/build/src/openfga.js +26 -0
  147. package/build/src/openfga.js.map +1 -0
  148. package/build/src/reconcile.d.ts +37 -0
  149. package/build/src/reconcile.d.ts.map +1 -0
  150. package/build/src/reconcile.js +69 -0
  151. package/build/src/reconcile.js.map +1 -0
  152. package/build/src/relation_partition_trigger.d.ts +8 -0
  153. package/build/src/relation_partition_trigger.d.ts.map +1 -0
  154. package/build/src/relation_partition_trigger.js +85 -0
  155. package/build/src/relation_partition_trigger.js.map +1 -0
  156. package/build/src/relations/define_relations_config.d.ts +58 -0
  157. package/build/src/relations/define_relations_config.d.ts.map +1 -0
  158. package/build/src/relations/define_relations_config.js +144 -0
  159. package/build/src/relations/define_relations_config.js.map +1 -0
  160. package/build/src/relations/manager.d.ts +38 -0
  161. package/build/src/relations/manager.d.ts.map +1 -0
  162. package/build/src/relations/manager.js +156 -0
  163. package/build/src/relations/manager.js.map +1 -0
  164. package/build/src/relations/reconcile.d.ts +62 -0
  165. package/build/src/relations/reconcile.d.ts.map +1 -0
  166. package/build/src/relations/reconcile.js +138 -0
  167. package/build/src/relations/reconcile.js.map +1 -0
  168. package/build/src/relations_config_store.d.ts +22 -0
  169. package/build/src/relations_config_store.d.ts.map +1 -0
  170. package/build/src/relations_config_store.js +74 -0
  171. package/build/src/relations_config_store.js.map +1 -0
  172. package/build/src/scope_outbox.d.ts +69 -0
  173. package/build/src/scope_outbox.d.ts.map +1 -0
  174. package/build/src/scope_outbox.js +291 -0
  175. package/build/src/scope_outbox.js.map +1 -0
  176. package/build/src/shared/backend_guard.d.ts +106 -0
  177. package/build/src/shared/backend_guard.d.ts.map +1 -0
  178. package/build/src/shared/backend_guard.js +246 -0
  179. package/build/src/shared/backend_guard.js.map +1 -0
  180. package/build/src/shared/sql_expiry.d.ts +53 -0
  181. package/build/src/shared/sql_expiry.d.ts.map +1 -0
  182. package/build/src/shared/sql_expiry.js +66 -0
  183. package/build/src/shared/sql_expiry.js.map +1 -0
  184. package/build/src/sql_descendants.d.ts +97 -0
  185. package/build/src/sql_descendants.d.ts.map +1 -0
  186. package/build/src/sql_descendants.js +203 -0
  187. package/build/src/sql_descendants.js.map +1 -0
  188. package/build/src/testing/contract.d.ts +212 -7
  189. package/build/src/testing/contract.d.ts.map +1 -1
  190. package/build/src/testing/contract.js +3449 -24
  191. package/build/src/testing/contract.js.map +1 -1
  192. package/build/src/testing/main.d.ts +10 -2
  193. package/build/src/testing/main.d.ts.map +1 -1
  194. package/build/src/testing/main.js +5 -1
  195. package/build/src/testing/main.js.map +1 -1
  196. package/build/src/testing/migration_contract.d.ts +284 -0
  197. package/build/src/testing/migration_contract.d.ts.map +1 -0
  198. package/build/src/testing/migration_contract.js +586 -0
  199. package/build/src/testing/migration_contract.js.map +1 -0
  200. package/build/src/testing/relations_contract.d.ts +51 -0
  201. package/build/src/testing/relations_contract.d.ts.map +1 -0
  202. package/build/src/testing/relations_contract.js +654 -0
  203. package/build/src/testing/relations_contract.js.map +1 -0
  204. package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
  205. package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
  206. package/build/src/testing/relations_reconcile_contract.js +172 -0
  207. package/build/src/testing/relations_reconcile_contract.js.map +1 -0
  208. package/build/src/testing/scope_tree.d.ts +65 -0
  209. package/build/src/testing/scope_tree.d.ts.map +1 -0
  210. package/build/src/testing/scope_tree.js +145 -0
  211. package/build/src/testing/scope_tree.js.map +1 -0
  212. package/build/src/traits/authz_scopes.d.ts +30 -6
  213. package/build/src/traits/authz_scopes.d.ts.map +1 -1
  214. package/build/src/traits/authz_scopes.js +30 -18
  215. package/build/src/traits/authz_scopes.js.map +1 -1
  216. package/build/src/traits/has_uuid.d.ts +12 -12
  217. package/build/src/traits/has_uuid.d.ts.map +1 -1
  218. package/build/src/types.d.ts +1313 -28
  219. package/build/src/types.d.ts.map +1 -1
  220. package/build/src/types.js +20 -4
  221. package/build/src/types.js.map +1 -1
  222. package/build/stubs/config/app_acl.stub +4 -2
  223. package/build/stubs/config/authorization.stub +168 -14
  224. package/build/stubs/migration.stub +183 -13
  225. package/build/stubs/scopes_outbox_migration.stub +57 -0
  226. package/package.json +14 -7
  227. package/build/commands/openfga_import.d.ts +0 -28
  228. package/build/commands/openfga_import.d.ts.map +0 -1
  229. package/build/commands/openfga_import.js +0 -74
  230. package/build/commands/openfga_import.js.map +0 -1
  231. package/build/src/catalog.d.ts +0 -3
  232. package/build/src/catalog.d.ts.map +0 -1
  233. package/build/src/catalog.js +0 -103
  234. package/build/src/catalog.js.map +0 -1
  235. package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
  236. package/build/src/middleware/app_access_middleware.js.map +0 -1
@@ -2,32 +2,400 @@ import '@japa/assert';
2
2
  import { test } from '@japa/runner';
3
3
  import { v7 as uuidv7 } from 'uuid';
4
4
  import { APP_SCOPE } from '../types.js';
5
- /** Catálogo de prueba: roles a nivel app y a nivel organization. */
5
+ import { AuthorizationManager } from '../manager.js';
6
+ import db from '@adonisjs/lucid/services/db';
7
+ import { CatalogCache, GLOBAL_OWNER_KEY, withAuthzCatalogWrite } from '../catalog/catalog_cache.js';
8
+ import { scopeKey } from '../identity.js';
9
+ import { descendantsFrom, memoryScopeTree, resolveChainFrom } from './scope_tree.js';
10
+ /**
11
+ * Catálogo de prueba: roles a nivel app y a nivel organization. Los dos
12
+ * últimos existen solo para el invariante 8: `rank` es metadata y el motor no
13
+ * lo evalúa (un rol de rango alto no concede lo que no tiene).
14
+ */
6
15
  const CONTRACT_CATALOG = {
7
- permissions: [{ slug: 'docs:read' }, { slug: 'docs:write' }, { slug: 'billing:read' }],
16
+ permissions: [
17
+ { slug: 'docs:read' },
18
+ { slug: 'docs:write' },
19
+ { slug: 'billing:read' },
20
+ // 3B · B5: solo los roles de app y organization pueden llevarlo (composición).
21
+ { slug: 'org:settings', assignableAt: ['app', 'organization'] },
22
+ ],
8
23
  roles: [
9
24
  { slug: 'editor', scopeType: 'app', permissions: ['docs:read', 'docs:write'] },
25
+ // 3B: el administrador de una organization (rank 50) es quien delega roles locales.
26
+ { slug: 'org-admin', scopeType: 'organization', rank: 50, permissions: ['docs:read', 'docs:write', 'billing:read', 'org:settings'] },
10
27
  { slug: 'viewer', scopeType: 'app', permissions: ['docs:read'] },
11
28
  { slug: 'org-editor', scopeType: 'organization', permissions: ['docs:read', 'docs:write'] },
29
+ { slug: 'unit-editor', scopeType: 'unit', permissions: ['docs:write'] },
30
+ { slug: 'auditor-senior', scopeType: 'app', rank: 100, permissions: ['billing:read'] },
31
+ { slug: 'scribe', scopeType: 'app', rank: 0, permissions: ['docs:write'] },
32
+ // Mismo slug en dos niveles: `hasRole` con string no debe confundirlos (L0.6).
33
+ { slug: 'owner', scopeType: 'app', permissions: ['billing:read'] },
34
+ { slug: 'owner', scopeType: 'organization', permissions: ['docs:read'] },
12
35
  ],
13
36
  };
37
+ /**
38
+ * El mismo driver con OTRO memo del catálogo (2D · F1): una vista por
39
+ * prototipo cuyo `catalog` es un `CatalogCache` nuevo. Todo lo demás
40
+ * (cliente, conexión, resolutor) es el del original. Para un driver sin
41
+ * `catalog` no hay memo que separar: se devuelve tal cual.
42
+ */
43
+ function twinOf(driver) {
44
+ const catalog = driver.catalog;
45
+ if (!(catalog instanceof CatalogCache))
46
+ return driver;
47
+ const twin = Object.create(driver);
48
+ Object.defineProperty(twin, 'catalog', { value: new CatalogCache(), enumerable: true });
49
+ return twin;
50
+ }
51
+ /**
52
+ * Lo que un sync de otro proceso deja en la base al quitar un permiso de un
53
+ * rol: el vínculo ya no está y la versión compartida subió, en UNA
54
+ * transacción con el bump como última sentencia (`withAuthzCatalogWrite`, 2E
55
+ * · H2). Ninguna señal en memoria: es exactamente lo que cruza procesos.
56
+ */
57
+ async function removeLinkRow(role, scopeType, permission) {
58
+ let roleUuid = '';
59
+ await withAuthzCatalogWrite(async (trx) => {
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];
61
+ const permRow = (await trx.from('authz_permissions').where('slug', permission).select('uuid'))[0];
62
+ roleUuid = String(roleRow.uuid);
63
+ await trx.from('authz_role_permissions').where('role_uuid', roleRow.uuid).where('permission_uuid', permRow.uuid).delete();
64
+ });
65
+ return roleUuid;
66
+ }
67
+ /**
68
+ * Un rol LOCAL a `owner` escrito directamente en `authz_*` (3B · B2), como
69
+ * lo dejaría `defineScopedRole` en otro proceso: fila con
70
+ * `owner_scope_key = scopeKey(owner)` y sus vínculos, en una transacción con
71
+ * la versión subida al final. Sirve para juzgar al DRIVER (visibilidad por
72
+ * owner) sin pasar por la policy del manager (que se juzga aparte, con
73
+ * `defineScopedRole`). Devuelve el uuid del rol.
74
+ */
75
+ async function insertLocalRole(owner, spec) {
76
+ const uuid = uuidv7();
77
+ await withAuthzCatalogWrite(async (trx) => {
78
+ const now = new Date();
79
+ await trx.table('authz_roles').insert({
80
+ uuid,
81
+ slug: spec.slug,
82
+ name: spec.slug,
83
+ scope_type: spec.scopeType,
84
+ rank: spec.rank ?? 0,
85
+ owner_scope_key: scopeKey(owner),
86
+ created_at: now,
87
+ updated_at: now,
88
+ });
89
+ for (const permission of spec.permissions) {
90
+ const permRow = (await trx.from('authz_permissions').where('slug', permission).select('uuid'))[0];
91
+ await trx.table('authz_role_permissions').insert({ uuid: uuidv7(), role_uuid: uuid, permission_uuid: permRow.uuid, created_at: now });
92
+ }
93
+ });
94
+ return uuid;
95
+ }
96
+ /**
97
+ * Cambia a mano el NIVEL declarado de un rol (`authz_roles.scope_type`), con
98
+ * la versión subida (3D · N1). Es la forma de observar en LOS DOS drivers la
99
+ * paridad «una asignación cuenta si su rol está declarado para el nivel de
100
+ * ESE scope»: el catálogo siempre es SQL, así que una asignación creada
101
+ * legalmente queda apuntando a un rol de otro nivel sin escribir hechos a
102
+ * mano (que en `openfga` no serían escribibles por el puerto).
103
+ */
104
+ async function retypeRoleRow(roleUuid, scopeType) {
105
+ await withAuthzCatalogWrite(async (trx) => {
106
+ await trx.from('authz_roles').where('uuid', roleUuid).update({ scope_type: scopeType, updated_at: new Date() });
107
+ });
108
+ }
109
+ /**
110
+ * Los vínculos rol→permiso que quedan en `authz_role_permissions` para ese
111
+ * rol. El catálogo siempre es SQL, en los dos drivers, así que mirarlo aquí
112
+ * no acopla a ninguno.
113
+ *
114
+ * **Aviso honesto (3F · U2, tester 3E · R7)**: esta cuenta DOCUMENTA la
115
+ * promesa, no la falsa. El «todo o nada» del borrado de vínculos lo garantiza
116
+ * el ESQUEMA —`authz_role_permissions.role_uuid ON DELETE CASCADE`, y
117
+ * `authz_assignments.role_uuid ON DELETE RESTRICT` para las asignaciones—,
118
+ * vigilado por el guard stub↔espejo (K11, `delete_rule` a `delete_rule`). Un
119
+ * driver que borrase la fila del rol y se olvidara de los vínculos pasa este
120
+ * caso igual en SQLite, PostgreSQL y MySQL: el motor los borra por él
121
+ * (mutante EQUIVALENTE bajo este esquema, medido). Un driver de terceros
122
+ * sobre un esquema sin esas acciones —o con las FK desactivadas— tiene que
123
+ * borrarlos ÉL, y ningún caso de esta suite se lo va a decir.
124
+ */
125
+ async function linksOf(roleUuid) {
126
+ const rows = await db.from('authz_role_permissions').where('role_uuid', roleUuid).select('uuid');
127
+ return rows.length;
128
+ }
129
+ /**
130
+ * Un rol local escrito SIN subir la versión del catálogo (3E · R2): es lo que
131
+ * ve un escritor cuya foto del memo se tomó antes de que otro proceso
132
+ * confirmara el suyo. Solo la relectura de la BASE dentro de la transacción
133
+ * serializada (3D · M2 b) puede pararlo; el chequeo barato contra el memo no.
134
+ */
135
+ async function insertRoleUnseen(spec) {
136
+ const uuid = uuidv7();
137
+ const now = new Date();
138
+ await db.table('authz_roles').insert({
139
+ uuid,
140
+ slug: spec.slug,
141
+ name: spec.slug,
142
+ scope_type: spec.scopeType,
143
+ rank: 1,
144
+ owner_scope_key: spec.owner,
145
+ created_at: now,
146
+ updated_at: now,
147
+ });
148
+ return uuid;
149
+ }
150
+ /** Las filas de `authz_roles` con ese `(slug, nivel)`, leídas de la base (sin pasar por el memo). */
151
+ async function rowsNamed(slug, scopeType) {
152
+ return db.from('authz_roles').where('slug', slug).where('scope_type', scopeType).select('uuid');
153
+ }
154
+ /** El rol local `roleUuid` borrado A MANO (lo que la plataforma tiene que hacer sin `purgeRole`), con la versión subida. */
155
+ async function forgetRoleByHand(roleUuid) {
156
+ await withAuthzCatalogWrite(async (trx) => {
157
+ await trx.from('authz_role_permissions').where('role_uuid', roleUuid).delete();
158
+ await trx.from('authz_roles').where('uuid', roleUuid).delete();
159
+ });
160
+ }
161
+ /** Un vínculo rol→permiso escrito a mano (lo que un sync rechazaría), con la versión subida. */
162
+ async function insertLinkRow(roleUuid, permission) {
163
+ await withAuthzCatalogWrite(async (trx) => {
164
+ const permRow = (await trx.from('authz_permissions').where('slug', permission).select('uuid'))[0];
165
+ await trx.table('authz_role_permissions').insert({ uuid: uuidv7(), role_uuid: roleUuid, permission_uuid: permRow.uuid, created_at: new Date() });
166
+ });
167
+ }
14
168
  function subject(type = 'users') {
15
169
  return { type, uuid: uuidv7() };
16
170
  }
171
+ /**
172
+ * Reloj controlado del juez (2.5 · J1): un instante fijo que solo se mueve
173
+ * cuando el caso lo dice. Es lo que se inyecta con `driver.withClock(now)` y
174
+ * en `config.clock`: cada pregunta ve exactamente este `Date`.
175
+ */
176
+ function fixedClock(start) {
177
+ let current = new Date(start.getTime());
178
+ return {
179
+ now: () => new Date(current.getTime()),
180
+ set: (at) => {
181
+ current = new Date(at.getTime());
182
+ },
183
+ advance: (ms) => {
184
+ current = new Date(current.getTime() + ms);
185
+ },
186
+ };
187
+ }
17
188
  function orgScope(uuid = uuidv7()) {
18
189
  return { type: 'organization', uuid };
19
190
  }
191
+ function unitScope(uuid = uuidv7()) {
192
+ return { type: 'unit', uuid };
193
+ }
194
+ /** Una organization nueva, ya colgada del padre en el árbol del harness. */
195
+ async function orgUnder(tree, parent) {
196
+ const org = orgScope();
197
+ await tree.attach(org, parent);
198
+ return org;
199
+ }
200
+ /** Una unit nueva, ya colgada del padre en el árbol del harness. */
201
+ async function unitUnder(tree, parent) {
202
+ const unit = unitScope();
203
+ await tree.attach(unit, parent);
204
+ return unit;
205
+ }
206
+ function scopeKeys(scopes) {
207
+ return scopes.map((s) => `${s.type}:${s.uuid ?? ''}`).sort();
208
+ }
209
+ const LEVEL_RANK = { core: 0, '2.0': 1, '2.1': 2, '2.2': 3 };
210
+ /**
211
+ * La llamada rechaza con el `status` y el `code` esperados. `assert.rejects`
212
+ * a secas aceptaría cualquier error —un `SqliteError` sin status, un 503 por
213
+ * un 422— y el contrato distingue precisamente esos tres estados.
214
+ */
215
+ async function rejectsWith(assert, fn, expected) {
216
+ try {
217
+ await fn();
218
+ }
219
+ catch (error) {
220
+ assert.equal(error?.status, expected.status, `status de ${error?.message ?? error}`);
221
+ assert.equal(error?.code, expected.code, `code de ${error?.message ?? error}`);
222
+ return error;
223
+ }
224
+ assert.fail('debería haber rechazado');
225
+ }
20
226
  export function runAuthorizationDriverContract(harness) {
21
- test.group(`authorization contract [${harness.name}]`, (group) => {
227
+ return registerAuthorizationDriverContract(harness, {
228
+ group: (title, define) => test.group(title, define),
229
+ test: (title, fn) => test(title, fn),
230
+ });
231
+ }
232
+ export function registerAuthorizationDriverContract(harness, api) {
233
+ const { test } = api;
234
+ const level = harness.level ?? 'core';
235
+ /** Capacidades con par registrado en esta ejecución (ver `caseFor`). */
236
+ const covered = new Set();
237
+ api.group(`authorization contract [${harness.name} · ${level}]`, (group) => {
22
238
  let driver;
239
+ let tree;
240
+ /** Registra el caso solo si el harness pidió ese nivel o uno superior. */
241
+ let registered = 0;
242
+ const test = (title, fn) => {
243
+ registered += 1;
244
+ return api.test(title, fn);
245
+ };
246
+ function since(minLevel, title, fn) {
247
+ if (LEVEL_RANK[level] >= LEVEL_RANK[minLevel])
248
+ return test(title, fn) ?? undefined;
249
+ }
250
+ /**
251
+ * Par de casos de una capacidad: se registra la cara que el harness
252
+ * declara y se anota como cubierta. Una capacidad declarada `true` sin
253
+ * cara `whenTrue` queda sin cubrir y la suite lanza al cerrar el grupo.
254
+ */
255
+ function caseFor(capability, pair) {
256
+ const side = harness.capabilities[capability] ? pair.whenTrue : pair.whenFalse;
257
+ if (!side)
258
+ return;
259
+ // Una capacidad solo cuenta como juzgada si su lado registró al menos
260
+ // un caso: un par que se registra bajo un `since` de nivel superior no
261
+ // cubre nada en un harness `core`, y no debe parecer que sí.
262
+ const before = registered;
263
+ side();
264
+ if (registered > before)
265
+ covered.add(capability);
266
+ }
23
267
  group.each.setup(async () => {
24
268
  await harness.cleanup();
25
269
  await harness.seedCatalog(CONTRACT_CATALOG);
26
- driver = await harness.makeDriver();
270
+ tree = harness.makeTree ? await harness.makeTree() : memoryScopeTree();
271
+ driver = await harness.makeDriver(tree);
272
+ // `tree.detach` es lo que el consumidor hace al borrar un scope, y el
273
+ // contrato exige que los hechos se purguen (N7/N8): el harness purga
274
+ // PRIMERO (el driver demuestra cero) y quita la arista DESPUÉS (S6).
275
+ // Se parchea el objeto en sitio (no se envuelve) para que el driver,
276
+ // que ya lo tiene, y los casos que parchean `chainOf` vean el mismo.
277
+ const detachEdge = tree.detach.bind(tree);
278
+ tree.detach = async (child) => {
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);
284
+ await detachEdge(child);
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
+ }
27
324
  });
28
325
  group.teardown(async () => {
29
326
  await harness.cleanup();
30
327
  });
328
+ /**
329
+ * Las primitivas de 2.1 (`within`, `authorizedScopes`, `effectivePermissions`,
330
+ * `authorizeMany`) son COMPOSICIÓN del manager sobre el puerto: el juez
331
+ * las observa a través de un manager construido sobre el driver del
332
+ * harness y el árbol del juez. Un driver que solo implemente el puerto
333
+ * 2.0 pasa por aquí igual; lo que le falte (`listDenies`) se ve como
334
+ * 500 `E_AUTHZ_UNSUPPORTED` en el caso que lo necesita, nunca como skip.
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
+ }
379
+ function managerOver(overrides = {}, over = driver) {
380
+ return new AuthorizationManager({
381
+ default: harness.name,
382
+ drivers: { [harness.name]: () => over },
383
+ warnOnOptInSecurity: false,
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
+ },
397
+ });
398
+ }
31
399
  test('grant de un rol concede sus permisos (authorize true)', async ({ assert }) => {
32
400
  const alice = subject();
33
401
  await driver.grant(alice, 'editor', APP_SCOPE);
@@ -82,22 +450,24 @@ export function runAuthorizationDriverContract(harness) {
82
450
  });
83
451
  test('scope aislado: grant en una org NO autoriza en app ni en otra org', async ({ assert, }) => {
84
452
  const alice = subject();
85
- const orgA = orgScope();
86
- const orgB = orgScope();
453
+ const orgA = await orgUnder(tree, APP_SCOPE);
454
+ const orgB = await orgUnder(tree, APP_SCOPE);
87
455
  await driver.grant(alice, 'org-editor', orgA);
88
456
  assert.isTrue(await driver.authorize(alice, 'docs:write', orgA));
457
+ // orgB EXISTE en el árbol: el false es por aislamiento, no por scope desconocido.
89
458
  assert.isFalse(await driver.authorize(alice, 'docs:write', orgB));
90
459
  assert.isFalse(await driver.authorize(alice, 'docs:write', APP_SCOPE));
91
460
  });
92
461
  test('herencia hacia abajo: grant a nivel app autoriza dentro de una org', async ({ assert, }) => {
93
462
  const alice = subject();
463
+ const org = await orgUnder(tree, APP_SCOPE);
94
464
  await driver.grant(alice, 'editor', APP_SCOPE);
95
- assert.isTrue(await driver.authorize(alice, 'docs:write', orgScope()));
465
+ assert.isTrue(await driver.authorize(alice, 'docs:write', org));
96
466
  });
97
467
  test('deny en el scope cercano bloquea el grant heredado, solo ahí', async ({ assert }) => {
98
468
  const alice = subject();
99
- const orgA = orgScope();
100
- const orgB = orgScope();
469
+ const orgA = await orgUnder(tree, APP_SCOPE);
470
+ const orgB = await orgUnder(tree, APP_SCOPE);
101
471
  await driver.grant(alice, 'editor', APP_SCOPE);
102
472
  await driver.deny(alice, 'docs:write', orgA);
103
473
  assert.isFalse(await driver.authorize(alice, 'docs:write', orgA));
@@ -112,9 +482,9 @@ export function runAuthorizationDriverContract(harness) {
112
482
  assert.isTrue(await driver.authorize(asUser, 'docs:read', APP_SCOPE));
113
483
  assert.isFalse(await driver.authorize(asAdmin, 'docs:read', APP_SCOPE));
114
484
  });
115
- test('grant duplicado es idempotente (y refresca expiresAt)', async ({ assert }) => {
485
+ test('grant duplicado es idempotente (y revive una asignación expirada)', async ({ assert }) => {
116
486
  const alice = subject();
117
- // Primera vez expirada, re-grant sin expiración: debe quedar vigente y única.
487
+ // Primera vez expirada, re-grant sin opciones: debe quedar vigente y única.
118
488
  await driver.grant(alice, 'editor', APP_SCOPE, {
119
489
  expiresAt: new Date(Date.now() - 60_000),
120
490
  });
@@ -124,12 +494,235 @@ export function runAuthorizationDriverContract(harness) {
124
494
  const holders = await driver.listSubjects('editor', APP_SCOPE);
125
495
  assert.lengthOf(holders.filter((h) => h.uuid === alice.uuid), 1);
126
496
  });
497
+ // ── Par de capacidad `injectableClock` (2.5 · J1) ────────────────────
498
+ // L0.4, los tres estados de `expiresAt`: "asegúrate de que tiene el rol"
499
+ // (un seeder, un onboarding) es un grant SIN opciones, y convertía un
500
+ // acceso temporal en permanente: `expiresAt` omitido se guardaba como
501
+ // NULL. Ahora omitido = no tocar una caducidad vigente; `null` = quitarla
502
+ // explícitamente; y sobre una asignación YA expirada el re-grant revive
503
+ // sin caducidad (es un grant nuevo a todos los efectos). El driver
504
+ // devuelve lo que hizo (`GrantOutcome`), pero lo que se juzga es el
505
+ // hecho: la caducidad preservada vence, la quitada no. La cara `true`
506
+ // lo observa con el reloj inyectado (sin dormir y con igualdad EXACTA de
507
+ // instantes); la cara `false` solo puede observarlo en tiempo real.
508
+ caseFor('injectableClock', {
509
+ whenTrue: () => {
510
+ test('expiresAt en tres estados con el reloj inyectado: omitido preserva la caducidad vigente, null la quita, expirada revive; el instante que vence es exacto', async ({ assert, }) => {
511
+ assert.typeOf(driver.withClock, 'function', 'injectableClock: true exige withClock en el puerto');
512
+ // Relativo a hoy (2.5-B · K7): al final se observa el driver SIN
513
+ // reloj, para el que `soon` tiene que seguir siendo futuro — un
514
+ // instante fijo (2030) sería una bomba de relojería en el juez.
515
+ const clock = fixedClock(new Date(Date.now() + 365 * 24 * 3_600_000));
516
+ const clocked = driver.withClock(clock.now);
517
+ const keep = subject();
518
+ const lift = subject();
519
+ const revive = subject();
520
+ const soon = new Date(clock.now().getTime() + 1_500);
521
+ await clocked.grant(keep, 'editor', APP_SCOPE, { expiresAt: soon });
522
+ const kept = await clocked.grant(keep, 'editor', APP_SCOPE);
523
+ await clocked.grant(lift, 'editor', APP_SCOPE, { expiresAt: soon });
524
+ const lifted = await clocked.grant(lift, 'editor', APP_SCOPE, { expiresAt: null });
525
+ const past = new Date(clock.now().getTime() - 60_000);
526
+ await clocked.grant(revive, 'editor', APP_SCOPE, { expiresAt: past });
527
+ assert.isFalse(await clocked.authorize(revive, 'docs:read', APP_SCOPE));
528
+ const revived = await clocked.grant(revive, 'editor', APP_SCOPE);
529
+ assert.isTrue(kept.existed);
530
+ assert.equal(kept.expiresAt.getTime(), soon.getTime());
531
+ assert.isTrue(lifted.existed);
532
+ assert.isNull(lifted.expiresAt);
533
+ assert.equal(lifted.previousExpiresAt.getTime(), soon.getTime());
534
+ assert.isTrue(revived.existed);
535
+ assert.isNull(revived.expiresAt);
536
+ assert.equal(revived.previousExpiresAt.getTime(), past.getTime());
537
+ assert.isTrue(await clocked.authorize(revive, 'docs:read', APP_SCOPE));
538
+ assert.isTrue(await clocked.authorize(keep, 'docs:read', APP_SCOPE));
539
+ assert.isTrue(await clocked.authorize(lift, 'docs:read', APP_SCOPE));
540
+ // Un milisegundo antes de vencer sigue vigente; en el instante exacto, no.
541
+ clock.set(new Date(soon.getTime() - 1));
542
+ assert.isTrue(await clocked.authorize(keep, 'docs:read', APP_SCOPE));
543
+ clock.set(soon);
544
+ assert.isFalse(await clocked.authorize(keep, 'docs:read', APP_SCOPE));
545
+ assert.isTrue(await clocked.authorize(lift, 'docs:read', APP_SCOPE));
546
+ assert.isTrue(await clocked.authorize(revive, 'docs:read', APP_SCOPE));
547
+ // El driver sin reloj inyectado no se ha tocado: para él (hoy) `soon` —dentro de un año— es futuro.
548
+ assert.isTrue(await driver.authorize(keep, 'docs:read', APP_SCOPE));
549
+ });
550
+ since('2.1', 'caducidad exacta con el reloj inyectado: en T−1 ms concede, en T no, en T+1 ms no —authorize, hasRole y los list*—; la renovación y el revivir se observan sin dormir', async ({ assert, }) => {
551
+ // 2.5 · J1. Antes `whereActive` (SQL) y `checkContext` (FGA) leían
552
+ // `new Date()`: la frontera exacta de la caducidad no era observable
553
+ // sin una ventana de tiempo real. `T` lleva milisegundos a propósito
554
+ // (J3): un motor que trunque a segundos falla aquí.
555
+ const T = new Date('2030-06-15T12:34:56.789Z');
556
+ const clock = fixedClock(new Date(T.getTime() - 60_000));
557
+ const clocked = driver.withClock(clock.now);
558
+ const alice = subject();
559
+ const org = await orgUnder(tree, APP_SCOPE);
560
+ await clocked.grant(alice, 'editor', APP_SCOPE, { expiresAt: T });
561
+ const observe = async () => ({
562
+ authorize: await clocked.authorize(alice, 'docs:read', org),
563
+ hasRole: await clocked.hasRole(alice, 'editor', org),
564
+ listSubjects: (await clocked.listSubjects('editor', APP_SCOPE)).some((h) => h.uuid === alice.uuid),
565
+ listRoles: await clocked.listRoles(alice, APP_SCOPE),
566
+ listRoleScopes: (await clocked.listRoleScopes(alice, 'app')).length,
567
+ listScopes: (await clocked.listScopes(alice, 'docs:read')).length,
568
+ });
569
+ const alive = { authorize: true, hasRole: true, listSubjects: true, listRoles: ['editor'], listRoleScopes: 1, listScopes: 1 };
570
+ const gone = { authorize: false, hasRole: false, listSubjects: false, listRoles: [], listRoleScopes: 0, listScopes: 0 };
571
+ clock.set(new Date(T.getTime() - 1));
572
+ assert.deepEqual(await observe(), alive, 'T−1 ms');
573
+ clock.set(T);
574
+ assert.deepEqual(await observe(), gone, 'T: el que expira ahora ya no cuenta');
575
+ clock.set(new Date(T.getTime() + 1));
576
+ assert.deepEqual(await observe(), gone, 'T+1 ms');
577
+ // Renovación: un re-grant con caducidad posterior vuelve a conceder hasta ESE instante.
578
+ const T2 = new Date(T.getTime() + 1_000);
579
+ const renewed = await clocked.grant(alice, 'editor', APP_SCOPE, { expiresAt: T2 });
580
+ assert.isTrue(renewed.existed);
581
+ assert.equal(renewed.previousExpiresAt.getTime(), T.getTime());
582
+ assert.equal(renewed.expiresAt.getTime(), T2.getTime());
583
+ assert.deepEqual(await observe(), alive, 'renovada en T+1 ms');
584
+ clock.set(new Date(T2.getTime() - 1));
585
+ assert.deepEqual(await observe(), alive, 'T2−1 ms');
586
+ clock.set(T2);
587
+ assert.deepEqual(await observe(), gone, 'T2');
588
+ // Revivir: el re-grant sin opciones sobre una asignación expirada queda sin caducidad.
589
+ const revived = await clocked.grant(alice, 'editor', APP_SCOPE);
590
+ assert.isTrue(revived.existed);
591
+ assert.isNull(revived.expiresAt);
592
+ clock.set(new Date('2099-12-31T23:59:59.999Z'));
593
+ assert.deepEqual(await observe(), alive, 'revivida, sin caducidad');
594
+ });
595
+ since('2.1', 'la caducidad guarda milisegundos: una que vence dentro de 600 ms no se redondea al segundo y lo que se lee es lo que se escribió, al milisegundo', async ({ assert, }) => {
596
+ // 2.5 · J3 (`subSecondExpiry`). En MySQL la columna `timestamp` de
597
+ // knex es TIMESTAMP(0): REDONDEA al segundo (una caducidad de
598
+ // +600 ms se guardaba como +1 s y concedía medio segundo de más).
599
+ // `DATETIME(3)` en el stub y el espejo. Un caso, una afirmación
600
+ // (2.5-B · K15): la de 2040 va aparte para que un motor que falle
601
+ // aquí no la enmascare.
602
+ const T0 = new Date('2030-01-01T00:00:00.000Z');
603
+ const clock = fixedClock(T0);
604
+ const clocked = driver.withClock(clock.now);
605
+ const alice = subject();
606
+ const soon = new Date(T0.getTime() + 600);
607
+ const first = await clocked.grant(alice, 'editor', APP_SCOPE, { expiresAt: soon });
608
+ assert.equal(first.expiresAt.getTime(), soon.getTime());
609
+ const reread = await clocked.grant(alice, 'editor', APP_SCOPE);
610
+ assert.equal(reread.previousExpiresAt.getTime(), soon.getTime(), 'lo que se lee es lo que se escribió, al milisegundo');
611
+ assert.equal(reread.expiresAt.getTime(), soon.getTime());
612
+ clock.set(new Date(soon.getTime() - 1));
613
+ assert.isTrue(await clocked.authorize(alice, 'docs:read', APP_SCOPE));
614
+ clock.set(soon);
615
+ assert.isFalse(await clocked.authorize(alice, 'docs:read', APP_SCOPE), 'vence a los 600 ms, no al segundo');
616
+ clock.set(new Date(T0.getTime() + 999));
617
+ assert.isFalse(await clocked.authorize(alice, 'docs:read', APP_SCOPE));
618
+ assert.deepEqual(await clocked.listSubjects('editor', APP_SCOPE), []);
619
+ });
620
+ since('2.1', 'la caducidad admite fechas más allá de 2038 (2040) y se escribe estando el reloj en 2040: los sellos de auditoría no son decisiones y no llevan el reloj inyectado', async ({ assert, }) => {
621
+ // 2.5 · J3: MySQL `TIMESTAMP` no admite fechas más allá de
622
+ // 2038-01-19 (un grant hasta 2040 era un 503) ⇒ `expires_at` es
623
+ // `DATETIME(3)`. Y 2.5-B · K5 (CR#4): `created_at` se sellaba con
624
+ // el reloj INYECTADO, así que un `grant` con el reloj en 2040
625
+ // reventaba igual (`TIMESTAMP` sigue siendo `TIMESTAMP` para los
626
+ // sellos). Los sellos son auditoría, no decisiones: llevan el reloj
627
+ // del sistema; lo que decide (`expires_at`) lleva el inyectado.
628
+ const T0 = new Date('2030-01-01T00:00:00.000Z');
629
+ const clock = fixedClock(T0);
630
+ const clocked = driver.withClock(clock.now);
631
+ const bob = subject();
632
+ const carol = subject();
633
+ const far = new Date('2040-01-01T00:00:00.000Z');
634
+ const long = await clocked.grant(bob, 'editor', APP_SCOPE, { expiresAt: far });
635
+ assert.equal(long.expiresAt.getTime(), far.getTime());
636
+ assert.isTrue(await clocked.authorize(bob, 'docs:read', APP_SCOPE));
637
+ assert.equal((await clocked.grant(bob, 'editor', APP_SCOPE)).previousExpiresAt.getTime(), far.getTime());
638
+ clock.set(new Date('2039-12-31T23:59:59.999Z'));
639
+ assert.isTrue(await clocked.authorize(bob, 'docs:read', APP_SCOPE));
640
+ clock.set(far);
641
+ assert.isFalse(await clocked.authorize(bob, 'docs:read', APP_SCOPE));
642
+ // Escribir con el reloj más allá de 2038: grant y deny se sellan igual.
643
+ const later = new Date('2041-06-01T00:00:00.000Z');
644
+ const written = await clocked.grant(carol, 'editor', APP_SCOPE, { expiresAt: later });
645
+ assert.equal(written.expiresAt.getTime(), later.getTime());
646
+ await clocked.deny(carol, 'docs:write', APP_SCOPE);
647
+ assert.isTrue(await clocked.authorize(carol, 'docs:read', APP_SCOPE));
648
+ assert.isFalse(await clocked.authorize(carol, 'docs:write', APP_SCOPE));
649
+ assert.deepEqual(await clocked.listRoles(carol, APP_SCOPE), ['editor']);
650
+ clock.set(later);
651
+ assert.isFalse(await clocked.authorize(carol, 'docs:read', APP_SCOPE));
652
+ });
653
+ since('2.1', 'el manager expone el reloj (config.clock) y lo comparten sus vistas de forRequest: la misma pregunta cambia de respuesta al mover el reloj, sin escribir nada', async ({ assert, }) => {
654
+ // 2.5 · J1. El consumidor fija el reloj UNA vez (config) y todo lo
655
+ // que pasa por el manager —lecturas directas, vistas por request,
656
+ // `effectivePermissions`, `authorizeMany`— lo usa. El memo de la
657
+ // vista es de ANCESTROS, nunca de decisiones: mover el reloj cambia
658
+ // la respuesta de la misma vista.
659
+ // Relativo a hoy (K7): al final se observa el driver del harness (reloj real), para el que T es futuro.
660
+ const T = new Date(Date.now() + 400 * 24 * 3_600_000 + 3_003);
661
+ const clock = fixedClock(new Date(T.getTime() - 60_000));
662
+ const authz = managerOver({ clock: clock.now });
663
+ const alice = subject();
664
+ const org = await orgUnder(tree, APP_SCOPE);
665
+ await authz.grant(alice, 'editor', APP_SCOPE, { expiresAt: T });
666
+ const view = authz.forRequest();
667
+ clock.set(new Date(T.getTime() - 1));
668
+ assert.isTrue(await authz.authorize(alice, 'docs:read', org));
669
+ assert.isTrue(await view.authorize(alice, 'docs:read', org));
670
+ assert.isTrue(await view.hasRole(alice, 'editor', org));
671
+ assert.deepEqual(await view.listRoles(alice, APP_SCOPE), ['editor']);
672
+ assert.deepEqual(await view.authorizeMany(alice, 'docs:read', [org, APP_SCOPE]), [true, true]);
673
+ assert.lengthOf(await view.listSubjects('editor', APP_SCOPE), 1);
674
+ clock.set(T);
675
+ assert.isFalse(await authz.authorize(alice, 'docs:read', org));
676
+ assert.isFalse(await view.authorize(alice, 'docs:read', org));
677
+ assert.isFalse(await view.hasRole(alice, 'editor', org));
678
+ assert.deepEqual(await view.listRoles(alice, APP_SCOPE), []);
679
+ assert.deepEqual(await view.authorizeMany(alice, 'docs:read', [org, APP_SCOPE]), [false, false]);
680
+ assert.deepEqual(await view.listSubjects('editor', APP_SCOPE), []);
681
+ // El driver del harness (reloj real) no se ve afectado: para él T es futuro.
682
+ assert.isTrue(await driver.authorize(alice, 'docs:read', org));
683
+ });
684
+ },
685
+ whenFalse: () => {
686
+ test('expiresAt en tres estados: omitido preserva la caducidad vigente, null la quita, expirada revive (observado en tiempo real: sin reloj inyectable)', async ({ assert, }) => {
687
+ assert.notTypeOf(driver.withClock, 'function', 'el harness declara injectableClock: false y el driver trae withClock: declara lo observable');
688
+ const keep = subject();
689
+ const lift = subject();
690
+ const revive = subject();
691
+ const soon = new Date(Date.now() + 1_500);
692
+ await driver.grant(keep, 'editor', APP_SCOPE, { expiresAt: soon });
693
+ const kept = await driver.grant(keep, 'editor', APP_SCOPE);
694
+ await driver.grant(lift, 'editor', APP_SCOPE, { expiresAt: soon });
695
+ const lifted = await driver.grant(lift, 'editor', APP_SCOPE, { expiresAt: null });
696
+ const past = new Date(Date.now() - 60_000);
697
+ await driver.grant(revive, 'editor', APP_SCOPE, { expiresAt: past });
698
+ assert.isFalse(await driver.authorize(revive, 'docs:read', APP_SCOPE));
699
+ const revived = await driver.grant(revive, 'editor', APP_SCOPE);
700
+ assert.isTrue(kept.existed);
701
+ assert.closeTo(kept.expiresAt.getTime(), soon.getTime(), 1_000);
702
+ assert.isTrue(lifted.existed);
703
+ assert.isNull(lifted.expiresAt);
704
+ assert.closeTo(lifted.previousExpiresAt.getTime(), soon.getTime(), 1_000);
705
+ assert.isTrue(revived.existed);
706
+ assert.isNull(revived.expiresAt);
707
+ assert.closeTo(revived.previousExpiresAt.getTime(), past.getTime(), 1_000);
708
+ assert.isTrue(await driver.authorize(revive, 'docs:read', APP_SCOPE));
709
+ assert.isTrue(await driver.authorize(keep, 'docs:read', APP_SCOPE));
710
+ assert.isTrue(await driver.authorize(lift, 'docs:read', APP_SCOPE));
711
+ await new Promise((resolve) => setTimeout(resolve, Math.max(0, soon.getTime() - Date.now()) + 300));
712
+ // La caducidad preservada venció; la quitada ya no existe.
713
+ assert.isFalse(await driver.authorize(keep, 'docs:read', APP_SCOPE));
714
+ assert.isTrue(await driver.authorize(lift, 'docs:read', APP_SCOPE));
715
+ assert.isTrue(await driver.authorize(revive, 'docs:read', APP_SCOPE));
716
+ })?.timeout(15_000);
717
+ },
718
+ });
127
719
  test('hasRole respeta scope, herencia y expiración', async ({ assert }) => {
128
720
  const alice = subject();
721
+ const org = await orgUnder(tree, APP_SCOPE);
129
722
  await driver.grant(alice, 'editor', APP_SCOPE);
130
723
  assert.isTrue(await driver.hasRole(alice, 'editor', APP_SCOPE));
131
724
  // Herencia hacia abajo: el rol de app "vale" dentro de una org.
132
- assert.isTrue(await driver.hasRole(alice, 'editor', orgScope()));
725
+ assert.isTrue(await driver.hasRole(alice, 'editor', org));
133
726
  assert.isFalse(await driver.hasRole(alice, 'viewer', APP_SCOPE));
134
727
  await driver.revoke(alice, 'editor', APP_SCOPE);
135
728
  assert.isFalse(await driver.hasRole(alice, 'editor', APP_SCOPE));
@@ -139,34 +732,65 @@ export function runAuthorizationDriverContract(harness) {
139
732
  });
140
733
  assert.isFalse(await driver.hasRole(expired, 'editor', APP_SCOPE));
141
734
  });
735
+ test('hasRole con el mismo slug en dos niveles: string hereda por cadena; { slug, scopeType } acota el nivel', async ({ assert, }) => {
736
+ // L0.6. `owner` existe a nivel app y a nivel organization. Con string,
737
+ // en cada nivel de la cadena solo cuenta el rol de ESE nivel: el owner
738
+ // de app casa en app y hereda hacia abajo; un owner de organization
739
+ // jamás casa en app. Con `{ slug, scopeType }` se pregunta por el rol
740
+ // de un nivel concreto, y el heredado de otro nivel no vale.
741
+ const bob = subject();
742
+ const carol = subject();
743
+ const org = await orgUnder(tree, APP_SCOPE);
744
+ await driver.grant(bob, 'owner', APP_SCOPE);
745
+ await driver.grant(carol, 'owner', org);
746
+ assert.isTrue(await driver.hasRole(bob, 'owner', org));
747
+ assert.isTrue(await driver.hasRole(bob, { slug: 'owner', scopeType: 'app' }, org));
748
+ assert.isFalse(await driver.hasRole(bob, { slug: 'owner', scopeType: 'organization' }, org));
749
+ assert.isTrue(await driver.hasRole(carol, 'owner', org));
750
+ assert.isTrue(await driver.hasRole(carol, { slug: 'owner', scopeType: 'organization' }, org));
751
+ assert.isFalse(await driver.hasRole(carol, { slug: 'owner', scopeType: 'app' }, org));
752
+ assert.isFalse(await driver.hasRole(carol, 'owner', APP_SCOPE));
753
+ assert.isFalse(await driver.hasRole(carol, { slug: 'owner', scopeType: 'organization' }, APP_SCOPE));
754
+ });
755
+ test('un deny NO afecta a hasRole: es membresía, no acceso', async ({ assert }) => {
756
+ // L0.6, fijado a propósito. `hasRole` responde "¿tiene el rol?", un hecho;
757
+ // el deny gobierna `authorize`, la decisión. Por eso ningún PEP del
758
+ // paquete acepta `role`: un middleware sobre `hasRole` sería indenegable.
759
+ const alice = subject();
760
+ await driver.grant(alice, 'editor', APP_SCOPE);
761
+ await driver.deny(alice, 'docs:read', APP_SCOPE);
762
+ await driver.deny(alice, 'docs:write', APP_SCOPE);
763
+ assert.isFalse(await driver.authorize(alice, 'docs:read', APP_SCOPE));
764
+ assert.isFalse(await driver.authorize(alice, 'docs:write', APP_SCOPE));
765
+ assert.isTrue(await driver.hasRole(alice, 'editor', APP_SCOPE));
766
+ });
142
767
  test('listSubjects: holders vigentes del rol en el scope exacto', async ({ assert }) => {
143
768
  const alice = subject();
144
769
  const bob = subject();
145
770
  const expired = subject();
146
- const otherScope = orgScope();
771
+ const otherScope = await orgUnder(tree, APP_SCOPE);
147
772
  await driver.grant(alice, 'editor', APP_SCOPE);
148
773
  await driver.grant(bob, 'editor', APP_SCOPE);
149
774
  await driver.grant(expired, 'editor', APP_SCOPE, {
150
775
  expiresAt: new Date(Date.now() - 60_000),
151
776
  });
152
777
  await driver.grant(subject(), 'org-editor', otherScope);
778
+ // Igualdad de CONJUNTO, no inclusión: un driver que devolviera de más
779
+ // (el expirado, el holder de la otra org) pasaría con `includeMembers`.
153
780
  const holders = await driver.listSubjects('editor', APP_SCOPE);
154
- const uuids = holders.map((h) => h.uuid);
155
- assert.includeMembers(uuids, [alice.uuid, bob.uuid]);
156
- assert.notInclude(uuids, expired.uuid);
781
+ assert.deepEqual(holders.map((h) => h.uuid).sort(), [alice.uuid, bob.uuid].sort());
157
782
  });
158
783
  test('listScopes: scopes directos que conceden el permiso, sin los denegados', async ({ assert, }) => {
159
784
  const alice = subject();
160
- const orgA = orgScope();
161
- const orgB = orgScope();
785
+ const orgA = await orgUnder(tree, APP_SCOPE);
786
+ const orgB = await orgUnder(tree, APP_SCOPE);
162
787
  await driver.grant(alice, 'editor', APP_SCOPE);
163
788
  await driver.grant(alice, 'org-editor', orgA);
164
789
  await driver.grant(alice, 'org-editor', orgB);
165
790
  await driver.deny(alice, 'docs:write', orgB);
791
+ // Conjunto exacto: ni orgB (denegada) ni nada heredado.
166
792
  const scopes = await driver.listScopes(alice, 'docs:write');
167
- const keys = scopes.map((s) => `${s.type}:${s.uuid ?? ''}`);
168
- assert.includeMembers(keys, ['app:', `organization:${orgA.uuid}`]);
169
- assert.notInclude(keys, `organization:${orgB.uuid}`);
793
+ assert.deepEqual(scopeKeys(scopes), scopeKeys([APP_SCOPE, orgA]));
170
794
  });
171
795
  test('revoke/removeDeny inexistentes son no-ops seguros', async ({ assert }) => {
172
796
  const ghost = subject();
@@ -174,9 +798,37 @@ export function runAuthorizationDriverContract(harness) {
174
798
  await driver.removeDeny(ghost, 'docs:read', APP_SCOPE);
175
799
  assert.isFalse(await driver.authorize(ghost, 'docs:read', APP_SCOPE));
176
800
  });
801
+ test('revoke/removeDeny con rol o permiso fuera del catálogo ⇒ 422, como grant/deny', async ({ assert, }) => {
802
+ // D10 (auditor H9/N4). Un rol que no existe para ese nivel no es "nada
803
+ // que quitar": es una pregunta mal formada, igual que en `grant`. El
804
+ // no-op silencioso escondía el caso real (el `scope_type` de un rol
805
+ // cambió en el catálogo y el revoke no quitaba nada) y además
806
+ // divergía entre drivers. El no-op sigue siendo para una ASIGNACIÓN
807
+ // inexistente de un rol válido ("revoke/removeDeny inexistentes son
808
+ // no-ops seguros").
809
+ const alice = subject();
810
+ await driver.grant(alice, 'editor', APP_SCOPE);
811
+ await driver.deny(alice, 'docs:read', APP_SCOPE);
812
+ await rejectsWith(assert, () => driver.revoke(alice, 'no-existe', APP_SCOPE), {
813
+ status: 422,
814
+ code: 'E_AUTHZ_UNKNOWN_ROLE',
815
+ });
816
+ // `org-editor` existe, pero no a nivel app.
817
+ await rejectsWith(assert, () => driver.revoke(alice, 'org-editor', APP_SCOPE), {
818
+ status: 422,
819
+ code: 'E_AUTHZ_UNKNOWN_ROLE',
820
+ });
821
+ await rejectsWith(assert, () => driver.removeDeny(alice, 'no:existe', APP_SCOPE), {
822
+ status: 422,
823
+ code: 'E_AUTHZ_UNKNOWN_PERMISSION',
824
+ });
825
+ // Nada de lo anterior tocó los hechos.
826
+ assert.isTrue(await driver.hasRole(alice, 'editor', APP_SCOPE));
827
+ assert.isFalse(await driver.authorize(alice, 'docs:read', APP_SCOPE));
828
+ });
177
829
  test('listRoles: roles directos vigentes en el scope exacto', async ({ assert }) => {
178
830
  const alice = subject();
179
- const orgA = orgScope();
831
+ const orgA = await orgUnder(tree, APP_SCOPE);
180
832
  await driver.grant(alice, 'editor', APP_SCOPE);
181
833
  await driver.grant(alice, 'viewer', APP_SCOPE, {
182
834
  expiresAt: new Date(Date.now() - 60_000), // expirado: no cuenta
@@ -192,8 +844,8 @@ export function runAuthorizationDriverContract(harness) {
192
844
  });
193
845
  test('listRoleScopes: scopes del tipo con algún rol directo vigente', async ({ assert }) => {
194
846
  const alice = subject();
195
- const orgA = orgScope();
196
- const orgB = orgScope();
847
+ const orgA = await orgUnder(tree, APP_SCOPE);
848
+ const orgB = await orgUnder(tree, APP_SCOPE);
197
849
  await driver.grant(alice, 'org-editor', orgA);
198
850
  await driver.grant(alice, 'org-editor', orgB, {
199
851
  expiresAt: new Date(Date.now() - 60_000), // expirado: fuera
@@ -206,6 +858,2779 @@ export function runAuthorizationDriverContract(harness) {
206
858
  assert.lengthOf(apps, 1);
207
859
  assert.isNull(apps[0].uuid);
208
860
  });
861
+ test('rank es metadata: un rol de rango alto no concede lo que no tiene', async ({ assert, }) => {
862
+ // Invariante 8 (core). La policy "no puedes dar un rol de rango ≥ al
863
+ // tuyo" es del consumidor; el motor jamás usa `rank` para decidir.
864
+ const senior = subject();
865
+ await driver.grant(senior, 'auditor-senior', APP_SCOPE); // rank 100, sin docs:write
866
+ assert.isFalse(await driver.authorize(senior, 'docs:write', APP_SCOPE));
867
+ const junior = subject();
868
+ await driver.grant(junior, 'scribe', APP_SCOPE); // rank 0, con docs:write
869
+ assert.isTrue(await driver.authorize(junior, 'docs:write', APP_SCOPE));
870
+ });
871
+ test('deny repetido no se duplica: un solo removeDeny lo levanta', async ({ assert }) => {
872
+ // Invariante 6 (core). Un deny duplicado que necesitara dos removeDeny
873
+ // dejaría al holder bloqueado "sin motivo" tras levantarlo una vez.
874
+ const alice = subject();
875
+ await driver.grant(alice, 'editor', APP_SCOPE);
876
+ await driver.deny(alice, 'docs:write', APP_SCOPE);
877
+ await driver.deny(alice, 'docs:write', APP_SCOPE);
878
+ assert.isFalse(await driver.authorize(alice, 'docs:write', APP_SCOPE));
879
+ await driver.removeDeny(alice, 'docs:write', APP_SCOPE);
880
+ assert.isTrue(await driver.authorize(alice, 'docs:write', APP_SCOPE));
881
+ });
882
+ test('deny antes del grant también bloquea: el orden de escritura no importa', async ({ assert, }) => {
883
+ const alice = subject();
884
+ await driver.deny(alice, 'docs:write', APP_SCOPE);
885
+ await driver.grant(alice, 'editor', APP_SCOPE);
886
+ assert.isFalse(await driver.authorize(alice, 'docs:write', APP_SCOPE));
887
+ assert.isTrue(await driver.authorize(alice, 'docs:read', APP_SCOPE));
888
+ });
889
+ test('identidad inválida ⇒ 422 E_AUTHZ_INVALID_IDENTITY, en lecturas y escrituras', async ({ assert, }) => {
890
+ // Invariante 5 (core, L0.5). Un uuid ausente, vacío o con sintaxis de
891
+ // otro sistema (`#` fabrica un userset en FGA; la comilla es SQL) no es
892
+ // "un holder sin permisos": es una pregunta mal formada. Se rechaza
893
+ // ANTES de tocar catálogo o backend, con el mismo status en todo driver
894
+ // — hoy openfga escribía `user:undefined` y database persistía la comilla.
895
+ const invalidUuids = ['', undefined, 'x#y', "x' OR '1'='1", 'a b', 'u|v', 'w*'];
896
+ for (const uuid of invalidUuids) {
897
+ const holder = { type: 'users', uuid };
898
+ const expected = { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' };
899
+ await rejectsWith(assert, () => driver.grant(holder, 'editor', APP_SCOPE), expected);
900
+ await rejectsWith(assert, () => driver.authorize(holder, 'docs:read', APP_SCOPE), expected);
901
+ await rejectsWith(assert, () => driver.deny(holder, 'docs:read', APP_SCOPE), expected);
902
+ }
903
+ // El tipo también es identidad, y va en minúsculas: en un motor SQL con
904
+ // collation `*_ci` `Users` y `users` serían la misma fila y en FGA dos
905
+ // holders distintos (E4, auditor H14).
906
+ for (const type of ['users#x', 'Users', 'USERS']) {
907
+ await rejectsWith(assert, () => driver.grant({ type, uuid: uuidv7() }, 'editor', APP_SCOPE), { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' });
908
+ }
909
+ await rejectsWith(assert, () => driver.authorize(subject(), 'docs:read', { type: 'Organization', uuid: uuidv7() }), { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' });
910
+ // Caso negativo: el ':' de un permiso es gramática válida, no identidad rota.
911
+ assert.isFalse(await driver.authorize(subject(), 'docs:read', APP_SCOPE));
912
+ });
913
+ test('expiresAt que no es Date válida, null ni omitido ⇒ 422 E_AUTHZ_INVALID_IDENTITY, sin escribir', async ({ assert, }) => {
914
+ // D7 (CR7). Una cadena ('2026-12-31'), un `Invalid Date` o un número no
915
+ // son una caducidad: un driver lanzaba un TypeError crudo al serializar
916
+ // y el otro persistía basura (una fecha inválida que nunca "vence" o que
917
+ // vence siempre). Es una pregunta mal formada: 422 antes de tocar nada.
918
+ const alice = subject();
919
+ const expected = { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' };
920
+ for (const expiresAt of ['2026-12-31', new Date('x'), 123, {}]) {
921
+ await rejectsWith(assert, () => driver.grant(alice, 'editor', APP_SCOPE, { expiresAt: expiresAt }), expected);
922
+ }
923
+ assert.deepEqual(await driver.listRoles(alice, APP_SCOPE), []);
924
+ assert.isFalse(await driver.authorize(alice, 'docs:read', APP_SCOPE));
925
+ // Los tres estados legales siguen siéndolo.
926
+ await driver.grant(alice, 'editor', APP_SCOPE, { expiresAt: new Date(Date.now() + 60_000) });
927
+ await driver.grant(alice, 'editor', APP_SCOPE, { expiresAt: null });
928
+ await driver.grant(alice, 'editor', APP_SCOPE);
929
+ assert.isTrue(await driver.authorize(alice, 'docs:read', APP_SCOPE));
930
+ });
931
+ test('un RoleQuery mal formado ⇒ 422 antes de tocar nada; las tres formas legales (slug, {slug,scopeType}, {uuid}) valen en grant/revoke/hasRole/listSubjects', async ({ assert, }) => {
932
+ // D11 (auditor H4) reescrito en 3D · M1: desde que `RoleQuery` admite
933
+ // `{ uuid }` en las cuatro rutas, el objeto ya no es «lo que no va
934
+ // aquí» — lo que sigue sin ir es un objeto MAL FORMADO (un body sin
935
+ // tipar, un job). Antes acababa en un 503 en un driver y en un
936
+ // TypeError crudo en el otro; sigue siendo 422, antes de tocar nada.
937
+ const alice = subject();
938
+ const slugError = { status: 422, code: 'E_AUTHZ_INVALID_SLUG' };
939
+ const identityError = { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' };
940
+ const malformed = [
941
+ [{}, slugError],
942
+ [{ slug: 'Editor', scopeType: 'app' }, slugError],
943
+ [{ slug: 'editor' }, identityError],
944
+ [{ uuid: 'no-es-un-uuid' }, identityError],
945
+ [{ uuid: '01a04dd8-095b-7208-827b-cb90aeee2936', slug: 'editor' }, identityError],
946
+ [42, identityError],
947
+ [null, identityError],
948
+ ];
949
+ for (const [query, expected] of malformed) {
950
+ await rejectsWith(assert, () => driver.grant(alice, query, APP_SCOPE), expected);
951
+ await rejectsWith(assert, () => driver.revoke(alice, query, APP_SCOPE), expected);
952
+ await rejectsWith(assert, () => driver.listSubjects(query, APP_SCOPE), expected);
953
+ await rejectsWith(assert, () => driver.hasRole(alice, query, APP_SCOPE), expected);
954
+ }
955
+ assert.deepEqual(await driver.listRoles(alice, APP_SCOPE), []);
956
+ // Y las tres formas legales apuntan al MISMO rol.
957
+ const editor = (await new CatalogCache().view()).role('editor', 'app');
958
+ await driver.grant(alice, { uuid: editor.uuid }, APP_SCOPE);
959
+ assert.isTrue(await driver.hasRole(alice, 'editor', APP_SCOPE));
960
+ assert.isTrue(await driver.hasRole(alice, { slug: 'editor', scopeType: 'app' }, APP_SCOPE));
961
+ assert.isTrue(await driver.hasRole(alice, { uuid: editor.uuid }, APP_SCOPE));
962
+ assert.deepEqual((await driver.listSubjects({ uuid: editor.uuid }, APP_SCOPE)).map((h) => h.uuid), [alice.uuid]);
963
+ assert.deepEqual((await driver.listSubjects({ slug: 'editor', scopeType: 'app' }, APP_SCOPE)).map((h) => h.uuid), [alice.uuid]);
964
+ // Un uuid del catálogo que no es de un rol de ESE nivel no existe ahí.
965
+ const orgEditor = (await new CatalogCache().view()).role('org-editor', 'organization');
966
+ await rejectsWith(assert, () => driver.grant(alice, { uuid: orgEditor.uuid }, APP_SCOPE), {
967
+ status: 422,
968
+ code: 'E_AUTHZ_ROLE_NOT_VISIBLE',
969
+ });
970
+ assert.deepEqual(await driver.listSubjects({ uuid: orgEditor.uuid }, APP_SCOPE), []);
971
+ await driver.revoke(alice, { uuid: editor.uuid }, APP_SCOPE);
972
+ assert.isFalse(await driver.hasRole(alice, 'editor', APP_SCOPE));
973
+ });
974
+ test('scope app con uuid ⇒ 422; no concede nada ni en la raíz', async ({ assert }) => {
975
+ // L0.10. `app` es la raíz: `{ app, 'X' }` no es "otro app". Un driver lo
976
+ // colapsaba a la raíz global (escalada de tenant a plataforma) y el otro
977
+ // respondía false: la divergencia es peor que cualquiera de las dos.
978
+ const alice = subject();
979
+ const fakeApp = { type: 'app', uuid: uuidv7() };
980
+ const expected = { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' };
981
+ await rejectsWith(assert, () => driver.grant(alice, 'editor', fakeApp), expected);
982
+ await rejectsWith(assert, () => driver.authorize(alice, 'docs:read', fakeApp), expected);
983
+ await rejectsWith(assert, () => driver.deny(alice, 'docs:read', fakeApp), expected);
984
+ await rejectsWith(assert, () => driver.listRoles(alice, fakeApp), expected);
985
+ assert.isFalse(await driver.authorize(alice, 'docs:read', APP_SCOPE));
986
+ assert.deepEqual(await driver.listRoles(alice, APP_SCOPE), []);
987
+ });
988
+ test('uuid centinela de la raíz en un scope que no es app ⇒ 422', async ({ assert }) => {
989
+ // L0.15. `00000000-…` es cómo el driver database almacena la raíz; como
990
+ // identidad de una organization colisionaría con ella.
991
+ const alice = subject();
992
+ const sentinel = { type: 'organization', uuid: '00000000-0000-0000-0000-000000000000' };
993
+ const expected = { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' };
994
+ await rejectsWith(assert, () => driver.grant(alice, 'org-editor', sentinel), expected);
995
+ await rejectsWith(assert, () => driver.authorize(alice, 'docs:read', sentinel), expected);
996
+ });
997
+ test('uuid centinela en un scope que el árbol SÍ conoce ⇒ 422 E_AUTHZ_INVALID_IDENTITY, y la raíz sigue limpia', async ({ assert, }) => {
998
+ // Tester H1 (E3). El caso anterior no discrimina por sí solo: sin la
999
+ // regla, el árbol del harness no conoce ese scope y B1 lo rechazaría
1000
+ // igual con `E_AUTHZ_UNKNOWN_SCOPE`. El peligro real es un centinela
1001
+ // que el árbol conoce: en `database` colisionaría con la fila de la
1002
+ // raíz (un grant ahí concedería en `app`). Aquí se cuelga del árbol y
1003
+ // debe seguir siendo identidad inválida, no un scope válido.
1004
+ const alice = subject();
1005
+ const sentinel = { type: 'organization', uuid: '00000000-0000-0000-0000-000000000000' };
1006
+ await tree.attach(sentinel, APP_SCOPE);
1007
+ const expected = { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' };
1008
+ await rejectsWith(assert, () => driver.grant(alice, 'org-editor', sentinel), expected);
1009
+ await rejectsWith(assert, () => driver.deny(alice, 'docs:read', sentinel), expected);
1010
+ await rejectsWith(assert, () => driver.authorize(alice, 'docs:read', sentinel), expected);
1011
+ await rejectsWith(assert, () => driver.listRoles(alice, sentinel), expected);
1012
+ assert.isFalse(await driver.authorize(alice, 'docs:read', APP_SCOPE));
1013
+ assert.deepEqual(await driver.listRoles(alice, APP_SCOPE), []);
1014
+ assert.deepEqual(await driver.listRoleScopes(alice, 'app'), []);
1015
+ });
1016
+ test('slug mal formado o reservado ⇒ 422 E_AUTHZ_INVALID_SLUG; nunca alcanza otro permiso', async ({ assert, }) => {
1017
+ // L0.8a. Hasta 2.1 openfga codificaba `docs:read` como `docs~read` en el
1018
+ // id del binding: un slug que llegara YA codificado apuntaba al binding
1019
+ // del permiso real, y `removeDeny(…, 'docs~read')` levantaba el deny de
1020
+ // `docs:read`. Desde 3A (2.2) los ids llevan el uuid del catálogo y no
1021
+ // hay escape, pero la gramática sigue rechazando `~`: el caso fija que
1022
+ // un slug así nunca alcanza otro permiso, en ningún driver. Los
1023
+ // reservados (`parent`…) y las familias (`can_`…) son nombres del modelo
1024
+ // FGA del modo facts: un permiso así invalidaría el modelo entero.
1025
+ const alice = subject();
1026
+ await driver.grant(alice, 'editor', APP_SCOPE);
1027
+ await driver.deny(alice, 'docs:read', APP_SCOPE);
1028
+ const expected = { status: 422, code: 'E_AUTHZ_INVALID_SLUG' };
1029
+ await rejectsWith(assert, () => driver.removeDeny(alice, 'docs~read', APP_SCOPE), expected);
1030
+ await rejectsWith(assert, () => driver.deny(alice, 'docs~read', APP_SCOPE), expected);
1031
+ // El deny real sigue en pie: nada lo tocó.
1032
+ assert.isFalse(await driver.authorize(alice, 'docs:read', APP_SCOPE));
1033
+ for (const slug of ['parent', 'assignee', 'can_docs', 'denied_docs', 'permits_x', 'a'.repeat(101), 'Docs', 'x|y', 'x y', '']) {
1034
+ await rejectsWith(assert, () => driver.grant(alice, slug, APP_SCOPE), expected);
1035
+ await rejectsWith(assert, () => driver.deny(alice, slug, APP_SCOPE), expected);
1036
+ await rejectsWith(assert, () => driver.authorize(alice, slug, APP_SCOPE), expected);
1037
+ }
1038
+ // Un rol no lleva ':'; un permiso sí (una vez).
1039
+ await rejectsWith(assert, () => driver.grant(alice, 'org:editor', APP_SCOPE), expected);
1040
+ await rejectsWith(assert, () => driver.authorize(alice, 'a:b:c', APP_SCOPE), expected);
1041
+ });
1042
+ since('2.1', 'la identidad es una cadena validada por la gramática, no un UUID del motor: ids que no son UUID se aceptan; un uuid con MAYÚSCULAS (holder o scope) es 422 E_AUTHZ_INVALID_IDENTITY antes de tocar nada', async ({ assert, }) => {
1043
+ // 2.5 · J3 + 2.5-B · K1. La gramática admite `[a-z0-9._-]{1,36}` y el
1044
+ // uuid es del consumidor. PostgreSQL con columnas `uuid` rechazaba
1045
+ // 'user-42' con un error de tipo (503) ⇒ columnas `varchar(64)`. MySQL
1046
+ // con la collation por defecto (`*_ci`) fundía 'abc' y 'ABC' en la
1047
+ // MISMA fila ⇒ `utf8mb4_bin`; y el árbol del consumidor (tipo `uuid`,
1048
+ // `*_ci`) seguía fundiéndolos (auditor 🔴 1) ⇒ las mayúsculas en un
1049
+ // uuid ya no son identidad: 422 en la puerta, en lecturas y escrituras,
1050
+ // para holders y scopes. Un alias por mayúsculas no llega al árbol.
1051
+ const lower = { type: 'users', uuid: 'abc.def_42' };
1052
+ const upper = { type: 'users', uuid: 'ABC.DEF_42' };
1053
+ const longest = { type: 'users', uuid: 'x'.repeat(36) };
1054
+ const org = await orgUnder(tree, APP_SCOPE);
1055
+ const orgUpper = { type: 'organization', uuid: org.uuid.toUpperCase() };
1056
+ const invalid = { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' };
1057
+ await driver.grant(lower, 'viewer', APP_SCOPE);
1058
+ await driver.grant(longest, 'editor', APP_SCOPE);
1059
+ await driver.grant(lower, 'org-editor', org);
1060
+ await driver.deny(lower, 'docs:read', org);
1061
+ assert.isTrue(await driver.authorize(lower, 'docs:read', APP_SCOPE));
1062
+ assert.isFalse(await driver.authorize(lower, 'docs:read', org), 'el deny en la org gana');
1063
+ assert.isTrue(await driver.authorize(lower, 'docs:write', org));
1064
+ assert.isTrue(await driver.authorize(longest, 'docs:write', APP_SCOPE));
1065
+ assert.deepEqual((await driver.listSubjects('viewer', APP_SCOPE)).map((h) => h.uuid), [lower.uuid], 'el holder, con su uuid exacto');
1066
+ assert.deepEqual(await driver.listRoles(lower, org), ['org-editor']);
1067
+ assert.deepEqual(scopeKeys(await driver.listRoleScopes(lower, 'organization')), scopeKeys([org]));
1068
+ // Mayúsculas en el uuid del HOLDER: 422 en lecturas y escrituras, y nada escrito.
1069
+ await rejectsWith(assert, () => driver.grant(upper, 'viewer', APP_SCOPE), invalid);
1070
+ await rejectsWith(assert, () => driver.authorize(upper, 'docs:read', APP_SCOPE), invalid);
1071
+ await rejectsWith(assert, () => driver.deny(upper, 'docs:read', APP_SCOPE), invalid);
1072
+ await rejectsWith(assert, () => driver.hasRole(upper, 'viewer', APP_SCOPE), invalid);
1073
+ await rejectsWith(assert, () => driver.listRoles(upper, APP_SCOPE), invalid);
1074
+ await rejectsWith(assert, () => driver.revoke(upper, 'viewer', APP_SCOPE), invalid);
1075
+ // Mayúsculas en el uuid del SCOPE: lo mismo, aunque el árbol fundiera el alias con la fila.
1076
+ await rejectsWith(assert, () => driver.authorize(lower, 'docs:write', orgUpper), invalid);
1077
+ await rejectsWith(assert, () => driver.grant(lower, 'org-editor', orgUpper), invalid);
1078
+ await rejectsWith(assert, () => driver.deny(lower, 'docs:write', orgUpper), invalid);
1079
+ await rejectsWith(assert, () => driver.removeDeny(lower, 'docs:read', orgUpper), invalid);
1080
+ await rejectsWith(assert, () => driver.listRoles(lower, orgUpper), invalid);
1081
+ await rejectsWith(assert, () => driver.listSubjects('org-editor', orgUpper), invalid);
1082
+ await rejectsWith(assert, () => driver.purgeScope(orgUpper), invalid);
1083
+ // Nada de lo anterior tocó los hechos.
1084
+ assert.isFalse(await driver.authorize(lower, 'docs:read', org));
1085
+ assert.deepEqual(await driver.listRoles(lower, org), ['org-editor']);
1086
+ assert.deepEqual((await driver.listSubjects('viewer', APP_SCOPE)).map((h) => h.uuid), [lower.uuid]);
1087
+ });
1088
+ since('2.1', 'un alias del uuid del scope (mayúsculas, guiones quitados) jamás evade un deny: o el árbol no lo conoce (false, nada, 422 al escribir) o resuelve al scope canónico (el deny casa y los hechos se escriben bajo la forma canónica)', async ({ assert, }) => {
1089
+ // 2.5-B · K1 (auditor 🔴 1). El árbol del consumidor puede fundir formas
1090
+ // distintas del mismo id (tipo `uuid` de PG: `BBBB…` = `bbbb…` =
1091
+ // `bbbb…` sin guiones; `char(36) *_ci` de MySQL: mayúsculas), pero
1092
+ // `authz_*` compara por bytes (J3): la cadena resolvía con el alias, el
1093
+ // grant del ancestro aplicaba y el deny —escrito canónico— no casaba.
1094
+ // Con el árbol en memoria el alias es simplemente desconocido; con el
1095
+ // árbol SQL del harness (`sqlScopeTree`, PG/MySQL) es donde estaba el
1096
+ // agujero. Lo que NUNCA puede pasar, con cualquier árbol: `true`.
1097
+ const mallory = subject();
1098
+ const eve = subject();
1099
+ const org = await orgUnder(tree, APP_SCOPE);
1100
+ const unit = await unitUnder(tree, org);
1101
+ await driver.grant(mallory, 'org-editor', org);
1102
+ await driver.deny(mallory, 'docs:read', unit);
1103
+ assert.isFalse(await driver.authorize(mallory, 'docs:read', unit), 'precondición: el deny gana con la forma canónica');
1104
+ assert.isTrue(await driver.authorize(mallory, 'docs:write', unit));
1105
+ // Mayúsculas: identidad inválida en la puerta (K1, defensa en profundidad): ni árbol ni hechos.
1106
+ const invalid = { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' };
1107
+ for (const uuid of [unit.uuid.toUpperCase(), unit.uuid.replaceAll('-', '').toUpperCase()]) {
1108
+ const alias = { type: 'unit', uuid };
1109
+ await rejectsWith(assert, () => driver.authorize(mallory, 'docs:read', alias), invalid);
1110
+ await rejectsWith(assert, () => driver.grant(eve, 'unit-editor', alias), invalid);
1111
+ await rejectsWith(assert, () => driver.deny(eve, 'docs:read', alias), invalid);
1112
+ }
1113
+ // Sin guiones: gramática válida. O el árbol no lo conoce, o resuelve a
1114
+ // la fila canónica (tipo `uuid` de PG); nunca una cadena con el alias.
1115
+ const alias = { type: 'unit', uuid: unit.uuid.replaceAll('-', '') };
1116
+ assert.isFalse(await driver.authorize(mallory, 'docs:read', alias), 'sin guiones: el deny se evade');
1117
+ const known = (await tree.chainOf(alias)) !== null;
1118
+ if (known) {
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`.
1125
+ assert.deepEqual(await driver.listRoles(mallory, alias), [], 'sin guiones: los roles directos se leen bajo la forma canónica');
1126
+ await driver.grant(eve, 'unit-editor', alias);
1127
+ assert.deepEqual(await driver.listRoles(eve, unit), ['unit-editor'], 'sin guiones: el grant sobre el alias queda bajo la forma canónica');
1128
+ assert.deepEqual(scopeKeys(await driver.listRoleScopes(eve, 'unit')), scopeKeys([unit]), 'sin guiones: ningún hecho con la forma del alias');
1129
+ await driver.revoke(eve, 'unit-editor', alias);
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)');
1139
+ }
1140
+ else {
1141
+ assert.isFalse(await driver.authorize(mallory, 'docs:write', alias), 'sin guiones: desconocido: nada concede');
1142
+ assert.deepEqual(await driver.listRoles(mallory, alias), [], 'sin guiones: desconocido');
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');
1150
+ }
1151
+ });
1152
+ test('scope que el árbol no conoce: authorize/hasRole false; grant/deny 422 E_AUTHZ_UNKNOWN_SCOPE', async ({ assert, }) => {
1153
+ // L0.3. Un scope que el consumidor no reconoce (borrado, inventado, o
1154
+ // no encontrado por un fallo) no es "un scope que cuelga de app": el
1155
+ // fallback plano `[APP_SCOPE]` hacía que un grant de app concediera en
1156
+ // un scope inventado y que borrar la unit tirase el deny de su org. Se
1157
+ // deniega por defecto y se rechaza escribir sobre él; las operaciones
1158
+ // idempotentes (revoke/removeDeny) no lanzan: no hay nada que quitar.
1159
+ const alice = subject();
1160
+ await driver.grant(alice, 'editor', APP_SCOPE);
1161
+ const ghost = orgScope(); // nunca colgado del árbol
1162
+ assert.isFalse(await driver.authorize(alice, 'docs:read', ghost));
1163
+ assert.isFalse(await driver.hasRole(alice, 'editor', ghost));
1164
+ const expected = { status: 422, code: 'E_AUTHZ_UNKNOWN_SCOPE' };
1165
+ await rejectsWith(assert, () => driver.grant(alice, 'org-editor', ghost), expected);
1166
+ await rejectsWith(assert, () => driver.deny(alice, 'docs:read', ghost), expected);
1167
+ await driver.revoke(alice, 'org-editor', ghost);
1168
+ await driver.removeDeny(alice, 'docs:read', ghost);
1169
+ assert.deepEqual(await driver.listRoles(alice, ghost), []);
1170
+ // Nada de lo anterior tocó la raíz.
1171
+ assert.isTrue(await driver.authorize(alice, 'docs:read', APP_SCOPE));
1172
+ });
1173
+ // ── Nivel 2.0 (Fase 0) ──────────────────────────────────────────────
1174
+ // Los casos que siguen se registran solo con `level: '2.0'`. Un harness
1175
+ // de tercero escrito para 1.x sigue corriendo los 19 de arriba tal cual.
1176
+ //
1177
+ // N7/N8 (`detach` purga) están abajo; N9 (un ciclo lanza 422 EN EL
1178
+ // PAQUETE) vive en el manager (`scopes.attached/moved`) y se prueba en
1179
+ // `tests/manager.spec.ts`: el juez habla con el driver, que no ve aristas.
1180
+ // Diferido: A1–A6 / B1–B2 — anexo de `hierarchyFacts: true` (Fase 3b).
1181
+ since('2.0', 'herencia de dos niveles: grant en app autoriza en una unit bajo una org', async ({ assert, }) => {
1182
+ // N1. El resolutor plano de 1.x solo producía cadenas de longitud 2;
1183
+ // con el árbol del juez la cadena es unit → org → app.
1184
+ const alice = subject();
1185
+ const org = await orgUnder(tree, APP_SCOPE);
1186
+ const unit = await unitUnder(tree, org);
1187
+ await driver.grant(alice, 'editor', APP_SCOPE);
1188
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit));
1189
+ });
1190
+ since('2.0', 'grant en una org vale en sus units, no en app ni en la org hermana', async ({ assert, }) => {
1191
+ // N1b
1192
+ const alice = subject();
1193
+ const orgA = await orgUnder(tree, APP_SCOPE);
1194
+ const orgB = await orgUnder(tree, APP_SCOPE);
1195
+ const unit = await unitUnder(tree, orgA);
1196
+ await driver.grant(alice, 'org-editor', orgA);
1197
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit));
1198
+ assert.isFalse(await driver.authorize(alice, 'docs:write', APP_SCOPE));
1199
+ assert.isFalse(await driver.authorize(alice, 'docs:write', orgB));
1200
+ });
1201
+ since('2.0', 'deny en una org hereda a sus units y solo hacia abajo; no toca otros permisos', async ({ assert, }) => {
1202
+ // N2
1203
+ const alice = subject();
1204
+ const orgA = await orgUnder(tree, APP_SCOPE);
1205
+ const orgB = await orgUnder(tree, APP_SCOPE);
1206
+ const unit = await unitUnder(tree, orgA);
1207
+ await driver.grant(alice, 'editor', APP_SCOPE);
1208
+ await driver.deny(alice, 'docs:write', orgA);
1209
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unit));
1210
+ assert.isTrue(await driver.authorize(alice, 'docs:write', orgB));
1211
+ assert.isTrue(await driver.authorize(alice, 'docs:write', APP_SCOPE));
1212
+ assert.isTrue(await driver.authorize(alice, 'docs:read', unit));
1213
+ });
1214
+ since('2.0', 'mover una unit fuera de la org donde hay grant le quita el permiso, sin otra escritura', async ({ assert, }) => {
1215
+ // N4. Lo único que cambia entre las dos preguntas es el árbol: no hay
1216
+ // revoke, ni deny, ni re-grant. Si el driver cacheara la cadena, o
1217
+ // dependiera de hechos que `move` no toca, el segundo authorize seguiría
1218
+ // en true.
1219
+ const alice = subject();
1220
+ const orgA = await orgUnder(tree, APP_SCOPE);
1221
+ const orgB = await orgUnder(tree, APP_SCOPE);
1222
+ const unit = await unitUnder(tree, orgA);
1223
+ await driver.grant(alice, 'org-editor', orgA);
1224
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit));
1225
+ await tree.move(unit, orgB);
1226
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unit));
1227
+ assert.isTrue(await driver.authorize(alice, 'docs:write', orgA));
1228
+ });
1229
+ since('2.0', 'mover una unit bajo la org donde hay grant le da el permiso', async ({ assert }) => {
1230
+ // N5. Sin esta cara positiva, N4 se satisfaría borrándolo todo.
1231
+ const alice = subject();
1232
+ const orgA = await orgUnder(tree, APP_SCOPE);
1233
+ const orgB = await orgUnder(tree, APP_SCOPE);
1234
+ const unit = await unitUnder(tree, orgA);
1235
+ await driver.grant(alice, 'org-editor', orgB);
1236
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unit));
1237
+ await tree.move(unit, orgB);
1238
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit));
1239
+ });
1240
+ since('2.0', 'mover una unit no afecta a lo heredado de app', async ({ assert }) => {
1241
+ // N6
1242
+ const alice = subject();
1243
+ const orgA = await orgUnder(tree, APP_SCOPE);
1244
+ const orgB = await orgUnder(tree, APP_SCOPE);
1245
+ const unit = await unitUnder(tree, orgA);
1246
+ await driver.grant(alice, 'editor', APP_SCOPE);
1247
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit));
1248
+ await tree.move(unit, orgB);
1249
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit));
1250
+ });
1251
+ since('2.0', 'con un árbol real los list* siguen sin enumerar descendientes heredados', async ({ assert, }) => {
1252
+ // I7set (invariante 7). Con el resolutor plano no había descendientes
1253
+ // que enumerar por error; con app → org → unit sí. Igualdad de conjunto:
1254
+ // un driver que "ayudara" devolviendo la unit heredada pasaría con
1255
+ // `includeMembers`.
1256
+ const alice = subject();
1257
+ const org = await orgUnder(tree, APP_SCOPE);
1258
+ const unit = await unitUnder(tree, org);
1259
+ await driver.grant(alice, 'editor', APP_SCOPE);
1260
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit));
1261
+ assert.deepEqual(scopeKeys(await driver.listScopes(alice, 'docs:write')), scopeKeys([APP_SCOPE]));
1262
+ assert.deepEqual(await driver.listRoles(alice, unit), []);
1263
+ assert.deepEqual(await driver.listRoleScopes(alice, 'unit'), []);
1264
+ assert.deepEqual(await driver.listRoleScopes(alice, 'organization'), []);
1265
+ });
1266
+ since('2.0', 'listScopes resta el deny aunque el sujeto tenga más denies de OTROS permisos que el tope del backend (L0.7)', async ({ assert, }) => {
1267
+ // L0.7, el único fail-open de L0. El driver openfga pedía TODOS los
1268
+ // denies del sujeto con `ListObjects` (tope del servidor, sin señal de
1269
+ // corte) y filtraba por permiso en cliente: cuatro denies irrelevantes
1270
+ // desplazaban al relevante fuera de la página y `listScopes` devolvía
1271
+ // como concedido un scope donde `authorize` responde false. Con el
1272
+ // servidor de tope 3 de CI el rojo se ve con cinco denies; contra un
1273
+ // servidor por defecto harían falta mil. Los denies de ruido van
1274
+ // PRIMERO y en scopes sin grant, para que lo único que pueda fallar
1275
+ // sea la resta del deny (no el recuento de bindings). Son 150 (D16):
1276
+ // más de una página de `Read` (100), para que un driver que no siga
1277
+ // el `continuation_token` también caiga aquí, y no solo en el unitario.
1278
+ const alice = subject();
1279
+ for (let i = 0; i < 75; i++) {
1280
+ const noise = await orgUnder(tree, APP_SCOPE);
1281
+ await driver.deny(alice, 'docs:read', noise);
1282
+ await driver.deny(alice, 'billing:read', noise);
1283
+ }
1284
+ const orgA = await orgUnder(tree, APP_SCOPE);
1285
+ const orgB = await orgUnder(tree, APP_SCOPE);
1286
+ await driver.grant(alice, 'org-editor', orgA);
1287
+ await driver.grant(alice, 'org-editor', orgB);
1288
+ await driver.deny(alice, 'docs:write', orgB);
1289
+ assert.isFalse(await driver.authorize(alice, 'docs:write', orgB));
1290
+ const scopes = await driver.listScopes(alice, 'docs:write');
1291
+ assert.notInclude(scopeKeys(scopes), scopeKeys([orgB])[0], 'orgB está denegada y se listó como concedida');
1292
+ assert.deepEqual(scopeKeys(scopes), scopeKeys([orgA]));
1293
+ })?.timeout(60_000);
1294
+ since('2.0', 'un scope retirado del árbol deja de responder: deny por defecto, sin herencia implícita de app', async ({ assert, }) => {
1295
+ // L0.3, cara del ciclo de vida. Lo único que cambia es el árbol: el
1296
+ // grant directo en la org sigue escrito (o purgado, si el harness
1297
+ // purga al desconectar — da igual), pero un scope que el árbol ya no
1298
+ // conoce no puede resolverse a "cuelga de app" ni a "cuelga de nada":
1299
+ // se deniega, y lo heredado de la raíz tampoco llega.
1300
+ const alice = subject();
1301
+ const bob = subject();
1302
+ const org = await orgUnder(tree, APP_SCOPE);
1303
+ await driver.grant(alice, 'org-editor', org);
1304
+ await driver.grant(bob, 'editor', APP_SCOPE);
1305
+ assert.isTrue(await driver.authorize(alice, 'docs:write', org));
1306
+ assert.isTrue(await driver.authorize(bob, 'docs:write', org));
1307
+ await tree.detach(org);
1308
+ assert.isFalse(await driver.authorize(alice, 'docs:write', org));
1309
+ assert.isFalse(await driver.hasRole(alice, 'org-editor', org));
1310
+ assert.isFalse(await driver.authorize(bob, 'docs:write', org));
1311
+ assert.isTrue(await driver.authorize(bob, 'docs:write', APP_SCOPE));
1312
+ });
1313
+ since('2.0', 'listRoles y listRoleScopes tampoco responden por un scope que el árbol no conoce', async ({ assert, }) => {
1314
+ // D8 (CR8). `authorize`/`hasRole` ya aplicaban "scope desconocido ⇒
1315
+ // nada" (L0.3); las dos enumeraciones de membresía no: un scope que el
1316
+ // consumidor borró sin avisar (sin `scopes.detached`) seguía listando
1317
+ // sus roles y apareciendo como scope con membresía. Aquí el árbol deja
1318
+ // de conocer la org SIN purgar (el hecho sigue escrito), que es
1319
+ // exactamente la situación que L0.3 cierra: lo que el árbol no conoce
1320
+ // no existe para el motor.
1321
+ const alice = subject();
1322
+ const orgA = await orgUnder(tree, APP_SCOPE);
1323
+ const orgB = await orgUnder(tree, APP_SCOPE);
1324
+ await driver.grant(alice, 'org-editor', orgA);
1325
+ await driver.grant(alice, 'org-editor', orgB);
1326
+ assert.deepEqual(await driver.listRoles(alice, orgA), ['org-editor']);
1327
+ assert.deepEqual(scopeKeys(await driver.listRoleScopes(alice, 'organization')), scopeKeys([orgA, orgB]));
1328
+ const original = tree.chainOf;
1329
+ const forgotten = `${orgA.type}:${orgA.uuid}`;
1330
+ tree.chainOf = async (scope) => `${scope.type}:${scope.uuid}` === forgotten ? null : original.call(tree, scope);
1331
+ try {
1332
+ assert.deepEqual(await driver.listRoles(alice, orgA), []);
1333
+ assert.deepEqual(scopeKeys(await driver.listRoleScopes(alice, 'organization')), scopeKeys([orgB]));
1334
+ assert.deepEqual(await driver.listRoles(alice, orgB), ['org-editor']);
1335
+ }
1336
+ finally {
1337
+ tree.chainOf = original;
1338
+ }
1339
+ // El hecho seguía escrito: al volver a conocer el scope, vuelve.
1340
+ assert.deepEqual(await driver.listRoles(alice, orgA), ['org-editor']);
1341
+ });
1342
+ since('2.0', 'quitar un permiso de un rol y re-sincronizar el catálogo lo retira: sin privilegios zombi', async ({ assert, }) => {
1343
+ // L0.9 (N1). El sync era solo aditivo: quitar `docs:write` de `editor`
1344
+ // en el config no lo quitaba de ningún entorno, para siempre. Ahora el
1345
+ // sync poda los vínculos que el spec ya no lista (solo de los roles
1346
+ // del spec; nunca borra roles ni permisos). Es un caso del juez porque
1347
+ // un driver que proyecte el catálogo (modo facts) tiene que reflejarlo.
1348
+ const alice = subject();
1349
+ await driver.grant(alice, 'editor', APP_SCOPE);
1350
+ assert.isTrue(await driver.authorize(alice, 'docs:write', APP_SCOPE));
1351
+ await harness.seedCatalog({
1352
+ ...CONTRACT_CATALOG,
1353
+ roles: CONTRACT_CATALOG.roles.map((role) => role.slug === 'editor' ? { ...role, permissions: ['docs:read'] } : role),
1354
+ });
1355
+ assert.isFalse(await driver.authorize(alice, 'docs:write', APP_SCOPE));
1356
+ assert.isTrue(await driver.authorize(alice, 'docs:read', APP_SCOPE));
1357
+ assert.deepEqual(await driver.listScopes(alice, 'docs:write'), []);
1358
+ // El rol sigue asignado: lo que cambió es lo que concede.
1359
+ assert.isTrue(await driver.hasRole(alice, 'editor', APP_SCOPE));
1360
+ });
1361
+ since('2.0', 'detach purga los hechos del scope: nada resucita al volver a colgarlo', async ({ assert, }) => {
1362
+ // N7 (N5 del auditor). Borrar un tenant dejaba sus grants y denies
1363
+ // vivos para siempre: sin FK (el scope es polimórfico), sin nada en
1364
+ // FGA. Con reutilización de uuid, o al re-colgar el nodo, resucitaban.
1365
+ // La segunda mitad es la que importa: sin ella "purga" podría ser
1366
+ // "desconecta".
1367
+ const alice = subject();
1368
+ const orgA = await orgUnder(tree, APP_SCOPE);
1369
+ const orgB = await orgUnder(tree, APP_SCOPE);
1370
+ const unit = await unitUnder(tree, orgA);
1371
+ await driver.grant(alice, 'editor', APP_SCOPE);
1372
+ await driver.grant(alice, 'unit-editor', unit);
1373
+ await driver.deny(alice, 'docs:read', unit);
1374
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit));
1375
+ assert.isFalse(await driver.authorize(alice, 'docs:read', unit));
1376
+ await tree.detach(unit);
1377
+ assert.deepEqual(await driver.listRoles(alice, unit), []);
1378
+ assert.deepEqual(await driver.listRoleScopes(alice, 'unit'), []);
1379
+ assert.notInclude(scopeKeys(await driver.listScopes(alice, 'docs:write')), scopeKeys([unit])[0]);
1380
+ await tree.attach(unit, orgB);
1381
+ // Ni el grant ni el deny resucitan: solo queda lo heredado de app.
1382
+ assert.deepEqual(await driver.listRoles(alice, unit), []);
1383
+ assert.isFalse(await driver.hasRole(alice, 'unit-editor', unit));
1384
+ assert.isTrue(await driver.authorize(alice, 'docs:read', unit));
1385
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit));
1386
+ });
1387
+ since('2.0', 'detach es quirúrgico: los hermanos y el padre conservan sus hechos', async ({ assert, }) => {
1388
+ // N8
1389
+ const alice = subject();
1390
+ const orgA = await orgUnder(tree, APP_SCOPE);
1391
+ const unit1 = await unitUnder(tree, orgA);
1392
+ const unit2 = await unitUnder(tree, orgA);
1393
+ await driver.grant(alice, 'org-editor', orgA);
1394
+ await driver.grant(alice, 'unit-editor', unit1);
1395
+ await driver.grant(alice, 'unit-editor', unit2);
1396
+ await driver.deny(alice, 'docs:read', unit2);
1397
+ await tree.detach(unit1);
1398
+ assert.deepEqual(await driver.listRoles(alice, unit2), ['unit-editor']);
1399
+ assert.deepEqual(await driver.listRoles(alice, orgA), ['org-editor']);
1400
+ assert.deepEqual(scopeKeys(await driver.listRoleScopes(alice, 'unit')), scopeKeys([unit2]));
1401
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unit2));
1402
+ assert.isFalse(await driver.authorize(alice, 'docs:read', unit2));
1403
+ assert.isTrue(await driver.authorize(alice, 'docs:write', orgA));
1404
+ });
1405
+ // ── Nivel 2.1 (Fase 2 · Lote 2B: primitivas) ────────────────────────
1406
+ // Composición del manager sobre el puerto (ver `managerOver`). Los
1407
+ // drivers no cambian de firma; lo que se juzga es que la primitiva
1408
+ // responda igual sobre cualquier driver que pase 2.0.
1409
+ since('2.1', 'within: grant/deny dentro de la cadena escriben; fuera ⇒ 422 E_AUTHZ_NOT_WITHIN sin escribir; app y el propio scope contienen', async ({ assert, }) => {
1410
+ // B1 (tester §5 E · within 1-4). El administrador de la org A concede
1411
+ // en una unit: el call-site declara `within: orgA` y el motor comprueba
1412
+ // `orgA ∈ chain(unit)` contra el árbol en fresco. Con la unit de B, el
1413
+ // uuid es válido y existe: sin `within` el grant habría pasado.
1414
+ const authz = managerOver();
1415
+ const alice = subject();
1416
+ const orgA = await orgUnder(tree, APP_SCOPE);
1417
+ const orgB = await orgUnder(tree, APP_SCOPE);
1418
+ const unitA1 = await unitUnder(tree, orgA);
1419
+ const unitB1 = await unitUnder(tree, orgB);
1420
+ await authz.grant(alice, 'unit-editor', unitA1, { within: orgA });
1421
+ await authz.grant(alice, 'unit-editor', unitA1, { within: APP_SCOPE });
1422
+ await authz.grant(alice, 'unit-editor', unitA1, { within: unitA1 });
1423
+ await authz.deny(alice, 'docs:read', unitA1, { within: orgA });
1424
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1));
1425
+ assert.isFalse(await driver.authorize(alice, 'docs:read', unitA1));
1426
+ const expected = { status: 422, code: 'E_AUTHZ_NOT_WITHIN' };
1427
+ await rejectsWith(assert, () => authz.grant(alice, 'unit-editor', unitB1, { within: orgA }), expected);
1428
+ await rejectsWith(assert, () => authz.deny(alice, 'docs:write', unitB1, { within: orgA }), expected);
1429
+ // Un hermano ni un descendiente contienen: la contención es hacia arriba.
1430
+ await rejectsWith(assert, () => authz.grant(alice, 'org-editor', orgA, { within: orgB }), expected);
1431
+ await rejectsWith(assert, () => authz.grant(alice, 'org-editor', orgA, { within: unitA1 }), expected);
1432
+ await rejectsWith(assert, () => authz.grant(alice, 'editor', APP_SCOPE, { within: orgA }), expected);
1433
+ // Nada de lo anterior escribió.
1434
+ assert.deepEqual(await driver.listRoles(alice, unitB1), []);
1435
+ assert.deepEqual(await driver.listRoles(alice, orgA), []);
1436
+ assert.deepEqual(await driver.listRoles(alice, APP_SCOPE), []);
1437
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitB1));
1438
+ // Un `within` que el árbol no conoce no contiene nada (422, no false).
1439
+ await rejectsWith(assert, () => authz.grant(alice, 'unit-editor', unitA1, { within: orgScope() }), expected);
1440
+ // Un scope desconocido sigue siendo E_AUTHZ_UNKNOWN_SCOPE, antes que la contención.
1441
+ await rejectsWith(assert, () => authz.grant(alice, 'unit-editor', unitScope(), { within: orgA }), {
1442
+ status: 422,
1443
+ code: 'E_AUTHZ_UNKNOWN_SCOPE',
1444
+ });
1445
+ // `isWithin` es la misma pregunta, suelta.
1446
+ assert.isTrue(await authz.isWithin(unitA1, orgA));
1447
+ assert.isTrue(await authz.isWithin(unitA1, APP_SCOPE));
1448
+ assert.isTrue(await authz.isWithin(unitA1, unitA1));
1449
+ assert.isTrue(await authz.isWithin(APP_SCOPE, APP_SCOPE));
1450
+ assert.isFalse(await authz.isWithin(unitB1, orgA));
1451
+ assert.isFalse(await authz.isWithin(orgA, unitA1));
1452
+ assert.isFalse(await authz.isWithin(APP_SCOPE, orgA));
1453
+ assert.isFalse(await authz.isWithin(unitScope(), orgA));
1454
+ });
1455
+ since('2.1', 'within en las otras cuatro escrituras: revoke/removeDeny contra el scope, scopes.attached/moved contra el padre, scopes.detached contra el hijo; fuera ⇒ 422 sin escribir', async ({ assert, }) => {
1456
+ // 2D · F2 (auditor 2, CR1). `within` solo cubría grant/deny; las demás
1457
+ // lo aceptaban en JS y lo ignoraban. Quitar un deny ajeno es conceder;
1458
+ // revocar un rol ajeno es sabotear; purgar o recolgar un scope ajeno,
1459
+ // lo mismo. Las seis escrituras pasan por la misma barrera.
1460
+ const authz = managerOver();
1461
+ const alice = subject();
1462
+ const orgA = await orgUnder(tree, APP_SCOPE);
1463
+ const orgB = await orgUnder(tree, APP_SCOPE);
1464
+ const unitB1 = await unitUnder(tree, orgB);
1465
+ await driver.grant(alice, 'unit-editor', unitB1);
1466
+ await driver.deny(alice, 'docs:read', unitB1);
1467
+ const expected = { status: 422, code: 'E_AUTHZ_NOT_WITHIN' };
1468
+ // El "admin de A" apunta a hechos de B.
1469
+ await rejectsWith(assert, () => authz.removeDeny(alice, 'docs:read', unitB1, { within: orgA }), expected);
1470
+ await rejectsWith(assert, () => authz.revoke(alice, 'unit-editor', unitB1, { within: orgA }), expected);
1471
+ assert.isFalse(await driver.authorize(alice, 'docs:read', unitB1), 'el deny sigue');
1472
+ assert.deepEqual(await driver.listRoles(alice, unitB1), ['unit-editor'], 'el rol sigue');
1473
+ // Dentro de B (o de la raíz, o del propio scope) sí.
1474
+ await authz.removeDeny(alice, 'docs:read', unitB1, { within: orgB });
1475
+ await authz.revoke(alice, 'unit-editor', unitB1, { within: unitB1 });
1476
+ assert.deepEqual(await driver.listRoles(alice, unitB1), []);
1477
+ await driver.grant(alice, 'unit-editor', unitB1);
1478
+ await authz.revoke(alice, 'unit-editor', unitB1, { within: APP_SCOPE });
1479
+ assert.deepEqual(await driver.listRoles(alice, unitB1), []);
1480
+ // scopes.attached / moved: el PADRE (nuevo) tiene que estar dentro.
1481
+ const unitNew = unitScope();
1482
+ const unitB2 = await unitUnder(tree, orgB);
1483
+ const unitA1 = await unitUnder(tree, orgA);
1484
+ await rejectsWith(assert, () => authz.scopes.attached(unitNew, orgB, { within: orgA }), expected);
1485
+ await rejectsWith(assert, () => authz.scopes.attached(unitNew, unitB2, { within: orgA }), expected);
1486
+ await rejectsWith(assert, () => authz.scopes.moved(unitB2, orgA, { within: orgB }), expected);
1487
+ await authz.scopes.attached(unitNew, unitB2, { within: orgB });
1488
+ await authz.scopes.moved(unitA1, orgA, { within: orgA });
1489
+ await authz.scopes.moved(unitB2, orgA, { within: APP_SCOPE });
1490
+ // scopes.detached: el HIJO tiene que estar dentro; y purga solo si lo está.
1491
+ await driver.grant(alice, 'unit-editor', unitB1);
1492
+ await rejectsWith(assert, () => authz.scopes.detached(unitB1, { within: orgA }), expected);
1493
+ assert.deepEqual(await driver.listRoles(alice, unitB1), ['unit-editor'], 'nada purgado');
1494
+ await authz.scopes.detached(unitB1, { within: orgB });
1495
+ assert.deepEqual(await driver.listRoles(alice, unitB1), []);
1496
+ // Un `within` desconocido no contiene nada; un scope que ya no está en el árbol no se puede contrastar.
1497
+ await rejectsWith(assert, () => authz.revoke(alice, 'unit-editor', unitB2, { within: orgScope() }), expected);
1498
+ await rejectsWith(assert, () => authz.scopes.detached(unitScope(), { within: orgB }), { status: 422, code: 'E_AUTHZ_UNKNOWN_SCOPE' });
1499
+ });
1500
+ since('2.1', 'within contrasta también el ORIGEN de scopes.moved/attached: una unit de B no se lleva a A con within: orgA ⇒ 422 E_AUTHZ_NOT_WITHIN sin llamar al driver, y la herencia no cruza', async ({ assert, }) => {
1501
+ // 2E · H1 (auditor 1). Solo se contrastaba el DESTINO: el admin de A
1502
+ // podía anexionarse `unit:B1` con el `within` de su sesión y, movida,
1503
+ // `authorize` pasaba de false a true (heredaba todo el subárbol robado;
1504
+ // peor que purgarlo). Ahora la cadena ACTUAL del hijo, resuelta en
1505
+ // fresco, también tiene que contener `within` — por eso el consumidor
1506
+ // notifica ANTES de recolgar su fila. Espía: cero llamadas al driver.
1507
+ const alice = subject();
1508
+ const orgA = await orgUnder(tree, APP_SCOPE);
1509
+ const orgB = await orgUnder(tree, APP_SCOPE);
1510
+ const unitA1 = await unitUnder(tree, orgA);
1511
+ const unitA2 = await unitUnder(tree, orgA);
1512
+ const unitB1 = await unitUnder(tree, orgB);
1513
+ await driver.grant(alice, 'org-editor', orgA);
1514
+ assert.isFalse(await driver.authorize(alice, 'docs:read', unitB1), 'precondición: la unit de B no hereda de A');
1515
+ const touched = [];
1516
+ const spied = new Proxy(driver, {
1517
+ get: (target, prop, receiver) => {
1518
+ const value = Reflect.get(target, prop, receiver);
1519
+ if (typeof value === 'function' && typeof prop === 'string') {
1520
+ return (...args) => {
1521
+ touched.push(prop);
1522
+ return value.apply(receiver, args);
1523
+ };
1524
+ }
1525
+ return value;
1526
+ },
1527
+ });
1528
+ const authz = managerOver({ requireWithin: 'non-root' }, spied);
1529
+ const expected = { status: 422, code: 'E_AUTHZ_NOT_WITHIN' };
1530
+ await rejectsWith(assert, () => authz.scopes.moved(unitB1, orgA, { within: orgA }), expected);
1531
+ await rejectsWith(assert, () => authz.scopes.attached(unitB1, orgA, { within: orgA }), expected);
1532
+ await rejectsWith(assert, () => authz.scopes.attached(unitB1, unitA1, { within: orgA }), expected);
1533
+ // Origen dentro y destino fuera sigue siendo 422 (F2): las dos caras.
1534
+ await rejectsWith(assert, () => authz.scopes.moved(unitA1, orgB, { within: orgA }), expected);
1535
+ // Un hijo que el árbol no conoce no tiene origen que contrastar.
1536
+ await rejectsWith(assert, () => authz.scopes.moved(unitScope(), orgA, { within: orgA }), { status: 422, code: 'E_AUTHZ_UNKNOWN_SCOPE' });
1537
+ assert.deepEqual(touched, [], 'el driver no se toca cuando la contención falla');
1538
+ // El consumidor no movió nada (el paquete rechazó): la herencia sigue sin cruzar.
1539
+ assert.isFalse(await driver.authorize(alice, 'docs:read', unitB1));
1540
+ assert.deepEqual(await tree.chainOf(unitB1), [unitB1, orgB, APP_SCOPE]);
1541
+ // Dentro del mismo tenant sí: nodo nuevo bajo A, y una unit de A bajo otra unit de A.
1542
+ const unitA3 = unitScope();
1543
+ await authz.scopes.attached(unitA3, orgA, { within: orgA });
1544
+ await tree.attach(unitA3, orgA);
1545
+ await authz.scopes.moved(unitA1, unitA2, { within: orgA });
1546
+ await tree.move(unitA1, unitA2);
1547
+ assert.isTrue(await driver.authorize(alice, 'docs:read', unitA1));
1548
+ // Y la plataforma (sin 'non-root') cruza tenants declarando la raíz: es un movimiento legítimo.
1549
+ await managerOver({}, spied).scopes.moved(unitB1, orgA, { within: APP_SCOPE });
1550
+ await tree.move(unitB1, orgA);
1551
+ assert.isTrue(await driver.authorize(alice, 'docs:read', unitB1));
1552
+ });
1553
+ since('2.1', "requireWithin: true ⇒ las seis escrituras sin within ⇒ 422 E_AUTHZ_WITHIN_REQUIRED sin escribir; 'non-root' rechaza además within: app; con el default (false) siguen escribiendo", async ({ assert, }) => {
1554
+ // B1 (tester 5-6, auditor E2) + 2D · F2 (auditor 2 y 9). La cara
1555
+ // negativa prueba que el flag hace algo: con `false` la escritura sin
1556
+ // `within` pasa, y ESO es lo que el aviso del manager nombra como
1557
+ // opt-in. `'non-root'` cierra el comodín `within: APP_SCOPE`, que
1558
+ // satisfacía la regla sin acotar nada.
1559
+ const alice = subject();
1560
+ const orgA = await orgUnder(tree, APP_SCOPE);
1561
+ const unitA1 = await unitUnder(tree, orgA);
1562
+ const strict = managerOver({ requireWithin: true });
1563
+ const expected = { status: 422, code: 'E_AUTHZ_WITHIN_REQUIRED' };
1564
+ await rejectsWith(assert, () => strict.grant(alice, 'org-editor', orgA), expected);
1565
+ await rejectsWith(assert, () => strict.deny(alice, 'docs:read', orgA), expected);
1566
+ await rejectsWith(assert, () => strict.grant(alice, 'editor', APP_SCOPE), expected);
1567
+ await rejectsWith(assert, () => strict.revoke(alice, 'org-editor', orgA), expected);
1568
+ await rejectsWith(assert, () => strict.removeDeny(alice, 'docs:read', orgA), expected);
1569
+ await rejectsWith(assert, () => strict.scopes.attached(unitScope(), orgA), expected);
1570
+ await rejectsWith(assert, () => strict.scopes.moved(unitA1, orgA), expected);
1571
+ await rejectsWith(assert, () => strict.scopes.detached(unitA1), expected);
1572
+ assert.deepEqual(await driver.listRoles(alice, orgA), []);
1573
+ assert.isFalse(await driver.authorize(alice, 'docs:read', orgA));
1574
+ // Con `within` las seis escriben (`APP_SCOPE` vale con `true`).
1575
+ await strict.grant(alice, 'org-editor', orgA, { within: orgA });
1576
+ await strict.deny(alice, 'docs:write', orgA, { within: APP_SCOPE });
1577
+ assert.isTrue(await driver.authorize(alice, 'docs:read', orgA));
1578
+ assert.isFalse(await driver.authorize(alice, 'docs:write', orgA));
1579
+ await strict.removeDeny(alice, 'docs:write', orgA, { within: orgA });
1580
+ assert.isTrue(await driver.authorize(alice, 'docs:write', orgA));
1581
+ await strict.revoke(alice, 'org-editor', orgA, { within: orgA });
1582
+ assert.isFalse(await driver.authorize(alice, 'docs:read', orgA));
1583
+ const unitA2 = unitScope();
1584
+ await strict.scopes.attached(unitA2, orgA, { within: orgA });
1585
+ await strict.scopes.moved(unitA1, orgA, { within: orgA });
1586
+ await driver.grant(alice, 'unit-editor', unitA1);
1587
+ await strict.scopes.detached(unitA1, { within: orgA });
1588
+ assert.deepEqual(await driver.listRoles(alice, unitA1), []);
1589
+ // 'non-root': la raíz como `within` no acota nada ⇒ 422 en las seis.
1590
+ const nonRoot = managerOver({ requireWithin: 'non-root' });
1591
+ const root = { status: 422, code: 'E_AUTHZ_WITHIN_ROOT_FORBIDDEN' };
1592
+ await rejectsWith(assert, () => nonRoot.grant(alice, 'org-editor', orgA, { within: APP_SCOPE }), root);
1593
+ await rejectsWith(assert, () => nonRoot.deny(alice, 'docs:read', orgA, { within: APP_SCOPE }), root);
1594
+ await rejectsWith(assert, () => nonRoot.revoke(alice, 'org-editor', orgA, { within: APP_SCOPE }), root);
1595
+ await rejectsWith(assert, () => nonRoot.removeDeny(alice, 'docs:read', orgA, { within: APP_SCOPE }), root);
1596
+ await rejectsWith(assert, () => nonRoot.scopes.attached(unitScope(), orgA, { within: APP_SCOPE }), root);
1597
+ await rejectsWith(assert, () => nonRoot.scopes.moved(unitA2, orgA, { within: APP_SCOPE }), root);
1598
+ await rejectsWith(assert, () => nonRoot.scopes.detached(unitA2, { within: APP_SCOPE }), root);
1599
+ await rejectsWith(assert, () => nonRoot.grant(alice, 'org-editor', orgA), expected);
1600
+ assert.deepEqual(await driver.listRoles(alice, orgA), []);
1601
+ // Con un tenant declarado, escribe; y en la raíz no se puede escribir por aquí (el driver o una config sin flag).
1602
+ await nonRoot.grant(alice, 'org-editor', orgA, { within: orgA });
1603
+ assert.isTrue(await driver.authorize(alice, 'docs:read', orgA));
1604
+ await rejectsWith(assert, () => nonRoot.grant(alice, 'editor', APP_SCOPE, { within: APP_SCOPE }), root);
1605
+ await rejectsWith(assert, () => nonRoot.grant(alice, 'editor', APP_SCOPE, { within: orgA }), { status: 422, code: 'E_AUTHZ_NOT_WITHIN' });
1606
+ assert.deepEqual(await driver.listRoles(alice, APP_SCOPE), []);
1607
+ const lax = managerOver();
1608
+ await lax.revoke(alice, 'org-editor', orgA);
1609
+ await lax.grant(alice, 'org-editor', orgA);
1610
+ assert.isTrue(await driver.authorize(alice, 'docs:read', orgA));
1611
+ });
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, }) => {
1613
+ // B6 (tester §5 E · authorizeMany 1-4). Composición por defecto
1614
+ // (`Promise.all` de `authorize` sobre una vista memoizada) o el método
1615
+ // opcional del driver (openfga: un solo batchCheck correlacionado por
1616
+ // id, L0.14): la respuesta tiene que ser la misma. Nunca parcial: si
1617
+ // una posición no se puede responder, no se responde ninguna.
1618
+ const authz = managerOver();
1619
+ const alice = subject();
1620
+ const orgA = await orgUnder(tree, APP_SCOPE);
1621
+ const orgB = await orgUnder(tree, APP_SCOPE);
1622
+ const unitA1 = await unitUnder(tree, orgA);
1623
+ const unitB1 = await unitUnder(tree, orgB);
1624
+ const ghost = orgScope();
1625
+ await driver.grant(alice, 'org-editor', orgA);
1626
+ await driver.grant(alice, 'unit-editor', unitB1);
1627
+ await driver.deny(alice, 'docs:write', unitA1);
1628
+ const scopes = [APP_SCOPE, orgA, unitA1, orgB, unitB1, ghost, orgA, unitB1];
1629
+ const expected = await Promise.all(scopes.map((s) => driver.authorize(alice, 'docs:write', s)));
1630
+ assert.deepEqual(expected, [false, true, false, false, true, false, true, true], 'precondición del caso');
1631
+ assert.deepEqual(await authz.authorizeMany(alice, 'docs:write', scopes), expected);
1632
+ assert.deepEqual(await authz.authorizeMany(alice, 'docs:read', scopes), await Promise.all(scopes.map((s) => driver.authorize(alice, 'docs:read', s))));
1633
+ assert.deepEqual(await authz.authorizeMany(alice, 'no:existe', [orgA, unitB1]), [false, false]);
1634
+ assert.deepEqual(await authz.authorizeMany(subject(), 'docs:write', [orgA, unitB1]), [false, false]);
1635
+ // Vacío: [] sin tocar el driver ni el árbol.
1636
+ const original = tree.chainOf;
1637
+ let asked = 0;
1638
+ tree.chainOf = async (scope) => {
1639
+ asked += 1;
1640
+ return original.call(tree, scope);
1641
+ };
1642
+ // Espía que sobrevive a `withChainResolver` (2D · F7, CR5): con
1643
+ // `value.apply(target)` la vista `Object.create(this)` heredaba del
1644
+ // driver real, no del Proxy, y la aserción "0 llamadas" era vacía.
1645
+ // Con `receiver` la vista hereda del Proxy y sus llamadas se ven.
1646
+ const touched = [];
1647
+ const spied = new Proxy(driver, {
1648
+ get: (target, prop, receiver) => {
1649
+ const value = Reflect.get(target, prop, receiver);
1650
+ if (typeof value === 'function' && typeof prop === 'string') {
1651
+ return (...args) => {
1652
+ touched.push(prop);
1653
+ return value.apply(receiver, args);
1654
+ };
1655
+ }
1656
+ return value;
1657
+ },
1658
+ });
1659
+ const watched = new AuthorizationManager({
1660
+ default: harness.name,
1661
+ drivers: { [harness.name]: () => spied },
1662
+ scopes: { resolveChain: resolveChainFrom(tree), descendantsOf: descendantsFrom(tree), acceptScopeDriftRisk: true },
1663
+ warnOnOptInSecurity: false,
1664
+ });
1665
+ try {
1666
+ assert.deepEqual(await watched.authorizeMany(alice, 'docs:write', []), []);
1667
+ assert.deepEqual(touched, []);
1668
+ assert.equal(asked, 0);
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
+ //
1675
+ // Identidad inválida en cualquier posición: 422 antes de tocar nada.
1676
+ touched.length = 0;
1677
+ await rejectsWith(assert, () => watched.authorizeMany(alice, 'docs:write', [orgA, { type: 'app', uuid: 'X' }]), {
1678
+ status: 422,
1679
+ code: 'E_AUTHZ_INVALID_IDENTITY',
1680
+ });
1681
+ assert.deepEqual(touched, [], 'ni withChainResolver ni authorize antes del 422');
1682
+ // Y el espía VE lo que pasa por la vista: una llamada válida se cuenta.
1683
+ touched.length = 0;
1684
+ assert.deepEqual(await watched.authorizeMany(alice, 'docs:write', [orgA]), [true]);
1685
+ assert.isTrue(touched.includes('authorize') || touched.includes('authorizeMany'), `el espía no vio nada: ${touched.join(',')}`);
1686
+ }
1687
+ finally {
1688
+ tree.chainOf = original;
1689
+ }
1690
+ });
1691
+ // ── Concurrencia (2.5 · J4) ──────────────────────────────────────────
1692
+ // Sin `{trx}` en el puerto (`transactions: false` sigue; es el diferido
1693
+ // 2.6): lo que se fija es que dos escrituras solapadas nunca dejan un
1694
+ // estado que ninguna de las dos habría dejado sola. Con el harness en
1695
+ // memoria (pool 1) se solapan a nivel de consulta; con `sqlite-file`, PG
1696
+ // y MySQL (pool ≥ 2) a nivel de conexión.
1697
+ since('2.1', 'dos grant concurrentes con caducidades distintas ⇒ UNA sola asignación y la caducidad final es una de las dos; ninguno falla (o falla con 409, nunca con 500/503)', async ({ assert, }) => {
1698
+ // J4 (a). Carrera check-then-insert: ambos ven «no hay nada» y ambos
1699
+ // insertan; el unique (centinela incluido) o el «tuple already exists»
1700
+ // de FGA detecta al perdedor, que degrada a re-grant sobre lo del
1701
+ // ganador. Un duplicado (dos filas), una caducidad inventada o un 503
1702
+ // por la carrera romperían el invariante 6.
1703
+ const base = Date.now();
1704
+ const e1 = new Date(base + 3_600_000);
1705
+ const e2 = new Date(base + 7_200_000);
1706
+ for (let round = 0; round < 6; round++) {
1707
+ const holder = subject();
1708
+ const [first, second] = round % 2 === 0 ? [e1, e2] : [e2, e1];
1709
+ const settled = await Promise.allSettled([
1710
+ driver.grant(holder, 'editor', APP_SCOPE, { expiresAt: first }),
1711
+ driver.grant(holder, 'editor', APP_SCOPE, { expiresAt: second }),
1712
+ ]);
1713
+ for (const outcome of settled) {
1714
+ if (outcome.status === 'rejected') {
1715
+ const error = outcome.reason;
1716
+ assert.equal(error?.status, 409, `ronda ${round}: ${error?.message}`);
1717
+ assert.match(String(error?.code), /^E_AUTHZ_/);
1718
+ }
1719
+ }
1720
+ assert.isTrue(settled.some((o) => o.status === 'fulfilled'), `ronda ${round}: al menos un grant escribió`);
1721
+ const holders = await driver.listSubjects('editor', APP_SCOPE);
1722
+ assert.lengthOf(holders.filter((h) => h.uuid === holder.uuid), 1, `ronda ${round}: una sola asignación`);
1723
+ // La caducidad que quedó es una de las dos pedidas, nunca otra ni ninguna.
1724
+ const state = await driver.grant(holder, 'editor', APP_SCOPE);
1725
+ assert.isTrue(state.existed);
1726
+ assert.oneOf(state.previousExpiresAt?.getTime(), [e1.getTime(), e2.getTime()], `ronda ${round}: caducidad final`);
1727
+ assert.equal(state.expiresAt?.getTime(), state.previousExpiresAt?.getTime(), 'el re-grant sin opciones no la toca');
1728
+ assert.isTrue(await driver.authorize(holder, 'docs:read', APP_SCOPE));
1729
+ }
1730
+ })?.timeout(60_000);
1731
+ since('2.1', 'purgeScope concurrente con grant en el mismo scope ⇒ nunca un estado a medias: o la asignación está entera y concede, o no está; y la purga siguiente demuestra cero', async ({ assert, }) => {
1732
+ // J4 (b). La purga borra en transacción (SQL) o borra y relee (FGA);
1733
+ // un grant que aterriza en medio queda ENTERO (y el scope sigue en el
1734
+ // árbol: es legítimo) o no queda. Lo que no puede pasar: una fila que
1735
+ // `listRoles` ve y `authorize` no (o al revés), dos filas, o un error
1736
+ // que no sea el «no pude demostrar cero» (500 E_AUTHZ_PURGE_INCOMPLETE)
1737
+ // que la purga tiene derecho a lanzar cuando alguien escribe debajo.
1738
+ const alice = subject();
1739
+ const later = new Date(Date.now() + 3_600_000);
1740
+ for (let round = 0; round < 6; round++) {
1741
+ const org = await orgUnder(tree, APP_SCOPE);
1742
+ await driver.grant(alice, 'org-editor', org);
1743
+ await driver.deny(alice, 'docs:read', org);
1744
+ const settled = await Promise.allSettled([
1745
+ driver.purgeScope(org),
1746
+ driver.grant(alice, 'org-editor', org, { expiresAt: later }),
1747
+ ]);
1748
+ const [purge, grant] = settled;
1749
+ if (purge.status === 'rejected') {
1750
+ const error = purge.reason;
1751
+ assert.equal(error?.code, 'E_AUTHZ_PURGE_INCOMPLETE', `ronda ${round}: ${error?.message}`);
1752
+ }
1753
+ assert.equal(grant.status, 'fulfilled', `ronda ${round}: el grant no falla por la purga (${grant.reason?.message ?? ''})`);
1754
+ // Lo que el grant DICE que dejó (K4): la caducidad pedida, siempre;
1755
+ // `existed: true` solo si de verdad refrescó una fila que seguía ahí.
1756
+ const outcome = grant.value;
1757
+ assert.equal(outcome.expiresAt?.getTime(), later.getTime(), `ronda ${round}: el outcome lleva la caducidad pedida`);
1758
+ const roles = await driver.listRoles(alice, org);
1759
+ assert.include([0, 1], roles.length, `ronda ${round}: cero o una asignación`);
1760
+ const present = roles.length === 1;
1761
+ assert.equal(await driver.hasRole(alice, 'org-editor', org), present, `ronda ${round}: hasRole coherente`);
1762
+ assert.equal(await driver.authorize(alice, 'docs:write', org), present, `ronda ${round}: authorize coherente`);
1763
+ assert.lengthOf((await driver.listSubjects('org-editor', org)).filter((h) => h.uuid === alice.uuid), roles.length);
1764
+ if (present) {
1765
+ const state = await driver.grant(alice, 'org-editor', org);
1766
+ assert.equal(state.previousExpiresAt?.getTime(), later.getTime(), `ronda ${round}: la que quedó es la del grant concurrente, entera`);
1767
+ }
1768
+ // Demostrar cero: una purga sin nadie escribiendo debajo deja el scope vacío.
1769
+ await driver.purgeScope(org);
1770
+ assert.deepEqual(await driver.listRoles(alice, org), []);
1771
+ assert.isFalse(await driver.authorize(alice, 'docs:write', org));
1772
+ // El deny purgado ya no bloquea, pero tampoco queda grant: denegación por defecto.
1773
+ assert.isFalse(await driver.authorize(alice, 'docs:read', org));
1774
+ }
1775
+ })?.timeout(60_000);
1776
+ since('2.1', 'syncAuthzCatalog concurrente con authorize ⇒ nunca una foto mixta permisiva: en cuanto una respuesta ve el permiso retirado, ninguna posterior lo concede (aquí y en otro memo)', async ({ assert, }) => {
1777
+ // J4 (c). El memo captura la versión ANTES de leer las tres tablas; un
1778
+ // sync que aterriza a mitad de carga deja la foto marcada como vieja y
1779
+ // la siguiente pregunta recarga. Una foto mixta solo puede ser MÁS
1780
+ // restrictiva (permisos → roles → vínculos), nunca conceder lo que el
1781
+ // catálogo nuevo retira. Lo observable: la secuencia de respuestas es
1782
+ // monótona (true… false…), la última es false, y el otro memo (otro
1783
+ // proceso) también deniega en su siguiente pregunta.
1784
+ const twin = harness.makeTwin ? await harness.makeTwin(driver, tree) : twinOf(driver);
1785
+ const alice = subject();
1786
+ const orgA = await orgUnder(tree, APP_SCOPE);
1787
+ await driver.grant(alice, 'org-editor', orgA);
1788
+ assert.isTrue(await driver.authorize(alice, 'docs:write', orgA));
1789
+ assert.isTrue(await twin.authorize(alice, 'docs:write', orgA));
1790
+ const answers = [];
1791
+ let synced = false;
1792
+ const sync = harness
1793
+ .seedCatalog({
1794
+ ...CONTRACT_CATALOG,
1795
+ roles: CONTRACT_CATALOG.roles.map((role) => role.slug === 'org-editor' ? { ...role, permissions: ['docs:read'] } : role),
1796
+ })
1797
+ .finally(() => {
1798
+ synced = true;
1799
+ });
1800
+ while (!synced)
1801
+ answers.push(await driver.authorize(alice, 'docs:write', orgA));
1802
+ await sync;
1803
+ answers.push(await driver.authorize(alice, 'docs:write', orgA));
1804
+ assert.isFalse(answers[answers.length - 1], 'tras el sync, denegado');
1805
+ const firstDenied = answers.indexOf(false);
1806
+ assert.isTrue(answers.slice(firstDenied).every((a) => a === false), `no vuelve a conceder tras denegar: ${answers.join(',')}`);
1807
+ assert.isFalse(await twin.authorize(alice, 'docs:write', orgA), 'el otro memo lo ve en su siguiente pregunta');
1808
+ assert.isTrue(await driver.authorize(alice, 'docs:read', orgA), 'lo que sigue en el catálogo sigue concediendo');
1809
+ // Y el catálogo del juez se restaura para el resto (el setup lo re-siembra igualmente).
1810
+ await harness.seedCatalog(CONTRACT_CATALOG);
1811
+ assert.isTrue(await driver.authorize(alice, 'docs:write', orgA));
1812
+ assert.isTrue(await twin.authorize(alice, 'docs:write', orgA));
1813
+ })?.timeout(60_000);
1814
+ // ── Nivel 2.2 (Fase 3 · Lote 3B: roles locales a un scope) ───────────
1815
+ // Un rol tiene un OWNER (`authz_roles.owner_scope_key`): `global` (el
1816
+ // catálogo del config) o la clave del scope que lo define. Regla única:
1817
+ // una asignación en el scope S de un rol R cuenta si y solo si R es
1818
+ // global o su owner está en chain(S) (S inclusive). `assignableAt`,
1819
+ // `rank` y `delegablePermissions` son controles de COMPOSICIÓN y de
1820
+ // ESCRITURA; `authorize` sigue siendo «¿hay un grant vigente en mi
1821
+ // cadena sin deny?» (invariantes 1, 2 y 8 intactos). Los casos de aquí
1822
+ // juzgan al DRIVER con roles locales escritos como los dejaría otro
1823
+ // proceso (`localRole`); la policy de `defineScopedRole` se juzga bajo
1824
+ // el par `listDenies` (necesita `effectivePermissions`).
1825
+ since('2.2', 'un rol local de la organization A concede en A y sus units (también anidadas), no en B ni en app; hasRole/listRoles/listSubjects/listScopes/listRoleScopes respetan el owner; grant fuera del owner ⇒ 422 E_AUTHZ_ROLE_NOT_VISIBLE sin escribir; mover la unit fuera de A retira lo concedido, y volverla lo restaura', async ({ assert, }) => {
1826
+ // 3B · B2. El owner es el CONTENEDOR del rol: fuera de él el rol no
1827
+ // existe (ni concede, ni es membresía, ni se puede asignar). Un rol
1828
+ // global con el mismo slug NO existe aquí (`lead`, `org-lead`): lo que
1829
+ // se observa es el owner, no una colisión.
1830
+ const alice = subject();
1831
+ const bob = subject();
1832
+ const orgA = await orgUnder(tree, APP_SCOPE);
1833
+ const orgB = await orgUnder(tree, APP_SCOPE);
1834
+ const unitA1 = await unitUnder(tree, orgA);
1835
+ const unitA1x = await unitUnder(tree, unitA1);
1836
+ const unitB1 = await unitUnder(tree, orgB);
1837
+ const leadA = await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'] });
1838
+ await localRole(orgA, { slug: 'org-lead', scopeType: 'organization', permissions: ['billing:read'] });
1839
+ // Un rol local de nivel `app` no es asignable en ningún sitio: la raíz
1840
+ // no cuelga de ningún owner. Existe (es una fila) pero no es visible.
1841
+ await localRole(orgA, { slug: 'phantom', scopeType: 'app', permissions: ['docs:read'] });
1842
+ await driver.grant(alice, 'lead', unitA1);
1843
+ await driver.grant(alice, 'org-lead', orgA);
1844
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1));
1845
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1x), 'hereda hacia abajo dentro del owner');
1846
+ assert.isTrue(await driver.authorize(alice, 'billing:read', unitA1));
1847
+ assert.isTrue(await driver.authorize(alice, 'billing:read', orgA));
1848
+ assert.isFalse(await driver.authorize(alice, 'docs:write', orgA), 'el rol de unit no vale en la org (no es herencia hacia arriba)');
1849
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitB1));
1850
+ assert.isFalse(await driver.authorize(alice, 'billing:read', orgB));
1851
+ assert.isFalse(await driver.authorize(alice, 'billing:read', APP_SCOPE));
1852
+ assert.isFalse(await driver.authorize(alice, 'docs:read', APP_SCOPE));
1853
+ const notVisible = { status: 422, code: 'E_AUTHZ_ROLE_NOT_VISIBLE' };
1854
+ await rejectsWith(assert, () => driver.grant(bob, 'lead', unitB1), notVisible);
1855
+ await rejectsWith(assert, () => driver.grant(bob, 'org-lead', orgB), notVisible);
1856
+ await rejectsWith(assert, () => driver.grant(bob, 'phantom', APP_SCOPE), notVisible);
1857
+ // Un rol que no existe en ningún owner sigue siendo E_AUTHZ_UNKNOWN_ROLE.
1858
+ await rejectsWith(assert, () => driver.grant(bob, 'no-existe', unitB1), { status: 422, code: 'E_AUTHZ_UNKNOWN_ROLE' });
1859
+ assert.deepEqual(await driver.listRoles(bob, unitB1), []);
1860
+ assert.deepEqual(await driver.listRoles(bob, orgB), []);
1861
+ assert.isFalse(await driver.authorize(bob, 'docs:write', unitB1));
1862
+ // Membresía y enumeraciones: solo dentro del owner.
1863
+ assert.isTrue(await driver.hasRole(alice, 'lead', unitA1));
1864
+ assert.isTrue(await driver.hasRole(alice, 'lead', unitA1x));
1865
+ assert.isTrue(await driver.hasRole(alice, { slug: 'lead', scopeType: 'unit' }, unitA1x));
1866
+ assert.isFalse(await driver.hasRole(alice, 'lead', unitB1));
1867
+ assert.isTrue(await driver.hasRole(alice, 'org-lead', unitA1));
1868
+ assert.isFalse(await driver.hasRole(alice, 'org-lead', orgB));
1869
+ assert.deepEqual(await driver.listRoles(alice, unitA1), ['lead']);
1870
+ assert.deepEqual(await driver.listRoles(alice, orgA), ['org-lead']);
1871
+ assert.deepEqual((await driver.listSubjects('lead', unitA1)).map((h) => h.uuid), [alice.uuid]);
1872
+ assert.deepEqual(await driver.listSubjects('lead', unitB1), [], 'el rol no existe en B: nadie lo tiene');
1873
+ // Y lo mismo preguntando por `{ uuid }` (3D · M1 c, tester): la
1874
+ // resolución exacta no exime de la regla de owner — dentro responde…
1875
+ assert.isTrue(await driver.hasRole(alice, { uuid: leadA }, unitA1x));
1876
+ assert.deepEqual((await driver.listSubjects({ uuid: leadA }, unitA1)).map((h) => h.uuid), [alice.uuid]);
1877
+ assert.deepEqual(scopeKeys(await driver.listScopes(alice, 'docs:write')), scopeKeys([unitA1]));
1878
+ assert.deepEqual(scopeKeys(await driver.listScopes(alice, 'billing:read')), scopeKeys([orgA]));
1879
+ assert.deepEqual(scopeKeys(await driver.listRoleScopes(alice, 'unit')), scopeKeys([unitA1]));
1880
+ assert.deepEqual(scopeKeys(await driver.listRoleScopes(alice, 'organization')), scopeKeys([orgA]));
1881
+ // La visibilidad se decide con el árbol de HOY: la unit sale de A y el
1882
+ // rol de A deja de existir para ella (sin escribir nada); vuelve, y vuelve.
1883
+ await tree.move(unitA1, orgB);
1884
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitA1));
1885
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitA1x));
1886
+ assert.isFalse(await driver.hasRole(alice, 'lead', unitA1));
1887
+ assert.deepEqual(await driver.listRoles(alice, unitA1), [], 'una asignación cuyo rol ya no es visible no es membresía');
1888
+ assert.deepEqual(await driver.listSubjects('lead', unitA1), []);
1889
+ // …y fuera, no: el uuid del rol de A no resucita la asignación que el
1890
+ // árbol de HOY dejó fuera de su owner (misma regla que por slug).
1891
+ assert.isFalse(await driver.hasRole(alice, { uuid: leadA }, unitA1));
1892
+ assert.deepEqual(await driver.listSubjects({ uuid: leadA }, unitA1), []);
1893
+ assert.deepEqual(await driver.listScopes(alice, 'docs:write'), []);
1894
+ assert.deepEqual(await driver.listRoleScopes(alice, 'unit'), []);
1895
+ await tree.move(unitA1, orgA);
1896
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1x));
1897
+ assert.deepEqual(await driver.listRoles(alice, unitA1), ['lead']);
1898
+ // revoke fuera del owner es un no-op (no hay nada que quitar); dentro, quita.
1899
+ await driver.revoke(alice, 'lead', unitB1);
1900
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1));
1901
+ await driver.revoke(alice, 'lead', unitA1);
1902
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitA1));
1903
+ });
1904
+ since('2.2', 'la visibilidad se decide POR NIVEL, no por el conjunto de la cadena: si el árbol se recuelga y el owner queda POR DEBAJO del scope de la asignación, esa asignación deja de contar aunque el owner siga en la cadena de la pregunta', async ({ assert, }) => {
1905
+ // 3B · B2 (desviación 2 del lote, sin caso hasta aquí). La regla es
1906
+ // «una asignación en el scope S cuenta si su rol EXISTE EN S»: el owner
1907
+ // tiene que ser ancestro-o-igual de S, no de la pregunta. Con la lectura
1908
+ // por CONJUNTO («owner ∈ chain(scope preguntado)») este caso concedería:
1909
+ // orgA sigue estando en la cadena de unitA2. El mutante que sustituye
1910
+ // `keys.slice(i)` por `keys` en `chainKeysFrom` pasa toda la suite sin
1911
+ // este caso, en los dos drivers y en los tres motores.
1912
+ const alice = subject();
1913
+ const orgA = await orgUnder(tree, APP_SCOPE);
1914
+ const unitA1 = await unitUnder(tree, orgA);
1915
+ await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'] });
1916
+ await driver.grant(alice, 'lead', unitA1);
1917
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1));
1918
+ // El consumidor recuelga su árbol: unitA1 pasa a la raíz y orgA cuelga
1919
+ // de ella. Ninguna escritura de hechos: solo cambia el árbol.
1920
+ await tree.move(unitA1, APP_SCOPE);
1921
+ await tree.move(orgA, unitA1);
1922
+ const unitA2 = await unitUnder(tree, orgA);
1923
+ // chain(unitA2) = [unitA2, orgA, unitA1, app]: el owner está en la
1924
+ // cadena, pero POR DEBAJO de la asignación (unitA1) — no cuenta.
1925
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitA2), 'por conjunto concedería; por nivel no');
1926
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitA1));
1927
+ assert.isFalse(await driver.hasRole(alice, 'lead', unitA2));
1928
+ assert.isFalse(await driver.hasRole(alice, 'lead', unitA1));
1929
+ assert.deepEqual(await driver.listRoles(alice, unitA1), []);
1930
+ assert.deepEqual(await driver.listRoles(alice, unitA2), []);
1931
+ assert.deepEqual(await driver.listScopes(alice, 'docs:write'), []);
1932
+ assert.deepEqual(await driver.listRoleScopes(alice, 'unit'), []);
1933
+ // El inverso, que es lo que hace significativo al caso: con el árbol
1934
+ // nuevo, una asignación DENTRO del owner sigue concediendo hacia abajo.
1935
+ const unitA2x = await unitUnder(tree, unitA2);
1936
+ await driver.grant(alice, 'lead', unitA2);
1937
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA2));
1938
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA2x));
1939
+ assert.deepEqual(await driver.listRoles(alice, unitA2), ['lead']);
1940
+ // Y la de unitA1 sigue sin contar: no ha revivido por el camino.
1941
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitA1));
1942
+ });
1943
+ since('2.2', 'dos tenants definen el mismo slug (lead@unit) con permisos distintos: cada uno concede lo suyo y listRoles/listSubjects/hasRole/revoke no cruzan tenants — el slug ya no identifica al rol, el uuid sí', async ({ assert, }) => {
1944
+ // 3B · B2 (tester §4: el pin de regresión de `listSubjects` promovido).
1945
+ // Con `unique(slug, scope_type, owner_scope_key)` hay DOS roles `lead@unit`
1946
+ // con uuids distintos; todo resuelve por uuid dentro del owner.
1947
+ const alice = subject();
1948
+ const bob = subject();
1949
+ const orgA = await orgUnder(tree, APP_SCOPE);
1950
+ const orgB = await orgUnder(tree, APP_SCOPE);
1951
+ const unitA1 = await unitUnder(tree, orgA);
1952
+ const unitB1 = await unitUnder(tree, orgB);
1953
+ const leadA = await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'] });
1954
+ const leadB = await localRole(orgB, { slug: 'lead', scopeType: 'unit', permissions: ['docs:read'] });
1955
+ await driver.grant(alice, 'lead', unitA1);
1956
+ await driver.grant(bob, 'lead', unitB1);
1957
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1));
1958
+ assert.isFalse(await driver.authorize(alice, 'docs:read', unitA1), 'el lead de A no lleva docs:read');
1959
+ assert.isTrue(await driver.authorize(bob, 'docs:read', unitB1));
1960
+ assert.isFalse(await driver.authorize(bob, 'docs:write', unitB1), 'el lead de B no lleva docs:write');
1961
+ assert.deepEqual((await driver.listSubjects('lead', unitA1)).map((h) => h.uuid), [alice.uuid]);
1962
+ assert.deepEqual((await driver.listSubjects('lead', unitB1)).map((h) => h.uuid), [bob.uuid]);
1963
+ assert.deepEqual(await driver.listRoles(alice, unitA1), ['lead']);
1964
+ assert.deepEqual(await driver.listRoles(alice, unitB1), []);
1965
+ assert.isTrue(await driver.hasRole(bob, 'lead', unitB1));
1966
+ assert.isFalse(await driver.hasRole(alice, 'lead', unitB1));
1967
+ assert.deepEqual(scopeKeys(await driver.listScopes(bob, 'docs:read')), scopeKeys([unitB1]));
1968
+ assert.deepEqual(await driver.listScopes(bob, 'docs:write'), []);
1969
+ // Y el uuid tampoco cruza tenants (3D · M1 c, tester): `{ uuid }` es
1970
+ // resolución EXACTA, no un permiso para saltarse al owner. El uuid del
1971
+ // rol de B es público (sale en `rolesInChain`, en el evento
1972
+ // `role_defined` y en el 422 de ambigüedad), así que si la ruta por
1973
+ // uuid no comprobara la visibilidad bastaría con pasarlo para asignar
1974
+ // dentro de A el rol de otro tenant. Hasta aquí solo había caso para
1975
+ // «el uuid es de otro NIVEL»; esta es la otra mitad de la regla.
1976
+ await rejectsWith(assert, () => driver.grant(alice, { uuid: leadB }, unitA1), {
1977
+ status: 422,
1978
+ code: 'E_AUTHZ_ROLE_NOT_VISIBLE',
1979
+ });
1980
+ assert.deepEqual(await driver.listRoles(alice, unitA1), ['lead'], 'y no escribió nada');
1981
+ assert.isFalse(await driver.authorize(alice, 'docs:read', unitA1));
1982
+ // Membresía: fuera del owner el rol no existe (false y [], nunca throw).
1983
+ assert.isFalse(await driver.hasRole(bob, { uuid: leadB }, unitA1));
1984
+ assert.isFalse(await driver.hasRole(alice, { uuid: leadA }, unitB1));
1985
+ assert.deepEqual(await driver.listSubjects({ uuid: leadB }, unitA1), []);
1986
+ assert.deepEqual(await driver.listSubjects({ uuid: leadA }, unitB1), []);
1987
+ // Donde el owner SÍ está en la cadena, el mismo uuid responde.
1988
+ assert.isTrue(await driver.hasRole(bob, { uuid: leadB }, unitB1));
1989
+ assert.deepEqual((await driver.listSubjects({ uuid: leadB }, unitB1)).map((h) => h.uuid), [bob.uuid]);
1990
+ assert.deepEqual((await driver.listSubjects({ uuid: leadA }, unitA1)).map((h) => h.uuid), [alice.uuid]);
1991
+ // Revocar «lead» en B no toca al lead de A.
1992
+ await driver.revoke(alice, 'lead', unitB1);
1993
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1));
1994
+ await driver.revoke(bob, 'lead', unitB1);
1995
+ assert.isFalse(await driver.authorize(bob, 'docs:read', unitB1));
1996
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1));
1997
+ });
1998
+ since('2.2', 'un scopes.moved legítimo junta dos homónimos en la misma cadena: TODA ruta por slug falla cerrada con 422 E_AUTHZ_AMBIGUOUS_ROLE (grant, revoke, hasRole, listSubjects), { uuid } resuelve el correcto y authorize —que no direcciona por slug— sigue respondiendo', async ({ assert, }) => {
1999
+ // 3D · M1 (auditor V3, reproducido en PG). U1 nace en el tenant B, que
2000
+ // define ahí su `lead@unit`; A define el suyo en su organization
2001
+ // (subárboles disjuntos: ninguna colisión). La plataforma transfiere U1
2002
+ // a A —operación legítima— y de repente los dos son visibles en la
2003
+ // misma cadena. Hasta 3C ganaba «el owner más cercano» y el admin de A
2004
+ // concedía SIN SABERLO el rol de B (`billing`/`docs:write` de otro
2005
+ // tenant). Ahora la ambigüedad es un error: nadie elige por él.
2006
+ const victima = subject();
2007
+ const orgA = await orgUnder(tree, APP_SCOPE);
2008
+ const orgB = await orgUnder(tree, APP_SCOPE);
2009
+ const unitU1 = await unitUnder(tree, orgB);
2010
+ const leadB = await localRole(unitU1, { slug: 'lead', scopeType: 'unit', permissions: ['docs:read'] });
2011
+ const leadA = await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'] });
2012
+ // Antes del move cada uno es el único visible en su subárbol.
2013
+ await driver.grant(victima, 'lead', unitU1);
2014
+ assert.isTrue(await driver.authorize(victima, 'docs:read', unitU1), 'el de B, que es el único visible ahí');
2015
+ await driver.revoke(victima, 'lead', unitU1);
2016
+ await tree.move(unitU1, orgA);
2017
+ const ambiguo = { status: 422, code: 'E_AUTHZ_AMBIGUOUS_ROLE' };
2018
+ await rejectsWith(assert, () => driver.grant(victima, 'lead', unitU1), ambiguo);
2019
+ await rejectsWith(assert, () => driver.grant(victima, { slug: 'lead', scopeType: 'unit' }, unitU1), ambiguo);
2020
+ await rejectsWith(assert, () => driver.hasRole(victima, 'lead', unitU1), ambiguo);
2021
+ await rejectsWith(assert, () => driver.listSubjects('lead', unitU1), ambiguo);
2022
+ assert.deepEqual(await driver.listRoles(victima, unitU1), [], 'nada escrito: el 422 fue antes del backend');
2023
+ // `{ uuid }` es la forma exacta: concede EL rol que se pidió.
2024
+ await driver.grant(victima, { uuid: leadA }, unitU1);
2025
+ assert.isTrue(await driver.authorize(victima, 'docs:write', unitU1), 'authorize no direcciona por slug: responde');
2026
+ assert.isFalse(await driver.authorize(victima, 'docs:read', unitU1), 'el rol de B no se le ha dado');
2027
+ assert.isTrue(await driver.hasRole(victima, { uuid: leadA }, unitU1));
2028
+ assert.isFalse(await driver.hasRole(victima, { uuid: leadB }, unitU1));
2029
+ assert.deepEqual((await driver.listSubjects({ uuid: leadA }, unitU1)).map((h) => h.uuid), [victima.uuid]);
2030
+ assert.deepEqual(await driver.listSubjects({ uuid: leadB }, unitU1), []);
2031
+ // La membresía habla en slugs y por eso puede tener homónimos: el
2032
+ // puerto lo documenta y la forma sin ambigüedad es `{ uuid }`.
2033
+ assert.deepEqual(await driver.listRoles(victima, unitU1), ['lead']);
2034
+ // Y el slug sigue siendo 422 aunque haya una asignación de por medio.
2035
+ await rejectsWith(assert, () => driver.hasRole(victima, 'lead', unitU1), ambiguo);
2036
+ // `revoke` por slug NO elige: quita los hechos de todos los homónimos
2037
+ // del scope exacto (quitar nunca concede). Por uuid, solo el suyo.
2038
+ await driver.grant(victima, { uuid: leadB }, unitU1);
2039
+ assert.isTrue(await driver.authorize(victima, 'docs:read', unitU1));
2040
+ await driver.revoke(victima, { uuid: leadB }, unitU1);
2041
+ assert.isFalse(await driver.authorize(victima, 'docs:read', unitU1));
2042
+ assert.isTrue(await driver.authorize(victima, 'docs:write', unitU1));
2043
+ await driver.grant(victima, { uuid: leadB }, unitU1);
2044
+ await driver.revoke(victima, 'lead', unitU1);
2045
+ assert.isFalse(await driver.authorize(victima, 'docs:write', unitU1));
2046
+ assert.isFalse(await driver.authorize(victima, 'docs:read', unitU1));
2047
+ // Deshecho el cruce, el slug vuelve a responder — y responde al de B.
2048
+ await tree.move(unitU1, orgB);
2049
+ await driver.grant(victima, 'lead', unitU1);
2050
+ assert.isTrue(await driver.authorize(victima, 'docs:read', unitU1));
2051
+ assert.isFalse(await driver.authorize(victima, 'docs:write', unitU1), 'el rol de A ya no existe aquí');
2052
+ assert.isTrue(await driver.hasRole(victima, 'lead', unitU1));
2053
+ });
2054
+ since('2.2', 'paridad entre drivers (3D · N1): una asignación cuyo rol está declarado para OTRO nivel no cuenta en ninguno de los dos — ni concede, ni es membresía, ni se enumera — y vuelve a contar en cuanto el nivel del rol coincide otra vez', async ({ assert, }) => {
2055
+ // Desviación 3 del lote 3B («`database.authorize` exige además
2056
+ // `r.scope_type = a.scope_type`, como ya hacía `openfga`») era una
2057
+ // promesa de PARIDAD sin juez: el mutante que la revertía pasaba las
2058
+ // suites enteras (tester H4). Solo es alcanzable retocando el catálogo
2059
+ // —`grant` lo impide—, y el catálogo es SQL en los dos drivers.
2060
+ const alice = subject();
2061
+ const orgA = await orgUnder(tree, APP_SCOPE);
2062
+ const unitA1 = await unitUnder(tree, orgA);
2063
+ const lead = await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'] });
2064
+ await driver.grant(alice, 'lead', unitA1);
2065
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1));
2066
+ // El rol pasa a estar declarado para `organization`: la asignación
2067
+ // sigue en un scope `unit` y deja de contar.
2068
+ await retypeRole(lead, 'organization');
2069
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitA1), 'el rol ya no existe en ese nivel');
2070
+ assert.isFalse(await driver.hasRole(alice, 'lead', unitA1));
2071
+ assert.isFalse(await driver.hasRole(alice, { slug: 'lead', scopeType: 'unit' }, unitA1));
2072
+ assert.deepEqual(await driver.listRoles(alice, unitA1), []);
2073
+ assert.deepEqual(await driver.listSubjects('lead', unitA1), []);
2074
+ assert.deepEqual(await driver.listScopes(alice, 'docs:write'), []);
2075
+ assert.deepEqual(await driver.listRoleScopes(alice, 'unit'), []);
2076
+ // Y tampoco cuenta «hacia arriba»: no hay asignación en orgA.
2077
+ assert.isFalse(await driver.authorize(alice, 'docs:write', orgA));
2078
+ await rejectsWith(assert, () => driver.grant(alice, 'lead', unitA1), { status: 422, code: 'E_AUTHZ_UNKNOWN_ROLE' });
2079
+ await retypeRole(lead, 'unit');
2080
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1), 'vuelve a coincidir el nivel: vuelve a contar');
2081
+ assert.deepEqual(await driver.listRoles(alice, unitA1), ['lead']);
2082
+ });
2083
+ since('2.2', "la clave de owner 'global' está reservada: un scope de TIPO 'global' produce la clave 'global|<uuid>' y sus roles son locales a él, nunca globales", async ({ assert, }) => {
2084
+ // 3B · B1/B2 (tester §4, mismo patrón que el centinela de la raíz). Un
2085
+ // tenant cuyo tipo de scope se llame `global` no se hace pasar por el
2086
+ // catálogo global: la clave de owner lleva siempre `<tipo>|<uuid>` (la
2087
+ // raíz no es owner), así que `global` a secas no la produce ningún scope.
2088
+ const alice = subject();
2089
+ const bob = subject();
2090
+ const weird = { type: 'global', uuid: uuidv7() };
2091
+ await tree.attach(weird, APP_SCOPE);
2092
+ const unitW = await unitUnder(tree, weird);
2093
+ const orgA = await orgUnder(tree, APP_SCOPE);
2094
+ const unitA1 = await unitUnder(tree, orgA);
2095
+ await localRole(weird, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'] });
2096
+ await driver.grant(alice, 'lead', unitW);
2097
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitW));
2098
+ await rejectsWith(assert, () => driver.grant(bob, 'lead', unitA1), { status: 422, code: 'E_AUTHZ_ROLE_NOT_VISIBLE' });
2099
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitA1));
2100
+ assert.deepEqual(await driver.listSubjects('lead', unitA1), []);
2101
+ });
2102
+ since('2.2', 'deny × rol local: un deny en cualquier punto de la cadena gana sobre un rol local; quitarlo restaura (invariante 2 en el módulo nuevo)', async ({ assert, }) => {
2103
+ // 3B · B2 (tester §4). El owner no es una excepción al deny: `authorize`
2104
+ // sigue siendo «sin deny en la cadena Y un grant vigente y visible».
2105
+ const alice = subject();
2106
+ const orgA = await orgUnder(tree, APP_SCOPE);
2107
+ const unitA1 = await unitUnder(tree, orgA);
2108
+ const unitA1x = await unitUnder(tree, unitA1);
2109
+ await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write', 'docs:read'] });
2110
+ await driver.grant(alice, 'lead', unitA1);
2111
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1x));
2112
+ await driver.deny(alice, 'docs:write', orgA); // por encima del grant
2113
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitA1));
2114
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitA1x));
2115
+ assert.isTrue(await driver.authorize(alice, 'docs:read', unitA1x), 'el deny es quirúrgico');
2116
+ assert.isTrue(await driver.hasRole(alice, 'lead', unitA1x), 'membresía intacta: el deny no la gobierna');
2117
+ await driver.deny(alice, 'docs:read', unitA1x); // por debajo del grant
2118
+ assert.isFalse(await driver.authorize(alice, 'docs:read', unitA1x));
2119
+ assert.isTrue(await driver.authorize(alice, 'docs:read', unitA1));
2120
+ assert.deepEqual(scopeKeys(await driver.listScopes(alice, 'docs:write')), [], 'listScopes resta el deny heredado');
2121
+ await driver.removeDeny(alice, 'docs:write', orgA);
2122
+ await driver.removeDeny(alice, 'docs:read', unitA1x);
2123
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1x));
2124
+ assert.isTrue(await driver.authorize(alice, 'docs:read', unitA1x));
2125
+ });
2126
+ since('2.2', 'assignableAt es control de COMPOSICIÓN, jamás de evaluación: un rol que lleva (a mano) un permiso no asignable en su nivel sigue concediendo lo ya asignado; un grant NUEVO de ese rol ⇒ 422 E_AUTHZ_ROLE_NOT_ASSIGNABLE_AT sin escribir', async ({ assert, }) => {
2127
+ // 3B · B5 (tester H). `org:settings` solo pueden llevarlo roles de
2128
+ // app/organization. `unit-editor@unit` no lo lleva (el sync lo
2129
+ // rechazaría); se le vincula A MANO después de conceder. Lo que fija
2130
+ // el caso: `authorize` NO mira `assignableAt` (invariante 1: un grant
2131
+ // vigente concede lo que su rol concede), y la barrera está en la
2132
+ // escritura (`grant`), donde el rol «no es asignable en ese nivel».
2133
+ const alice = subject();
2134
+ const bob = subject();
2135
+ const orgA = await orgUnder(tree, APP_SCOPE);
2136
+ const unitA1 = await unitUnder(tree, orgA);
2137
+ const unitA2 = await unitUnder(tree, orgA);
2138
+ await driver.grant(alice, 'unit-editor', unitA1);
2139
+ assert.isFalse(await driver.authorize(alice, 'org:settings', unitA1));
2140
+ const unitEditor = (await new CatalogCache().view()).role('unit-editor', 'unit').uuid;
2141
+ await linkByHand(unitEditor, 'org:settings');
2142
+ assert.isTrue(await driver.authorize(alice, 'org:settings', unitA1), 'lo ya asignado sigue concediendo: no se evalúa');
2143
+ assert.isTrue(await driver.hasRole(alice, 'unit-editor', unitA1));
2144
+ await rejectsWith(assert, () => driver.grant(bob, 'unit-editor', unitA2), { status: 422, code: 'E_AUTHZ_ROLE_NOT_ASSIGNABLE_AT' });
2145
+ assert.deepEqual(await driver.listRoles(bob, unitA2), []);
2146
+ assert.isFalse(await driver.authorize(bob, 'org:settings', unitA2));
2147
+ // Un rol de organization sí puede llevarlo: se asigna con normalidad.
2148
+ await driver.grant(bob, 'org-admin', orgA);
2149
+ assert.isTrue(await driver.authorize(bob, 'org:settings', unitA2));
2150
+ });
2151
+ // ── Par de capacidad `purgeRole` (3B · B4) ───────────────────────────
2152
+ caseFor('purgeRole', {
2153
+ whenTrue: () => {
2154
+ since('2.2', 'purgeRole(uuid) revoca todas las asignaciones del rol en TODOS los scopes (owner y descendientes), borra sus vínculos y el rol; recrear el slug (otro uuid) no revive nada; un uuid desconocido o mal formado ⇒ 422', async ({ assert, }) => {
2155
+ // 3B · B4 (tester §4: con asignaciones en descendientes y con denies
2156
+ // del permiso del rol). Es lo que `deleteScopedRole` necesita del
2157
+ // puerto: borrar el rol sin dejar asignaciones huérfanas que
2158
+ // resuciten si el slug vuelve a existir.
2159
+ assert.typeOf(driver.purgeRole, 'function', 'purgeRole: true exige purgeRole en el puerto');
2160
+ const alice = subject();
2161
+ const bob = subject();
2162
+ const orgA = await orgUnder(tree, APP_SCOPE);
2163
+ const unitA1 = await unitUnder(tree, orgA);
2164
+ const unitA1x = await unitUnder(tree, unitA1);
2165
+ const lead = await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'] });
2166
+ await driver.grant(alice, 'lead', unitA1);
2167
+ await driver.grant(bob, 'lead', unitA1x);
2168
+ await driver.grant(alice, 'unit-editor', unitA1); // otro rol: intacto
2169
+ await driver.deny(bob, 'docs:read', unitA1x); // un deny: intacto
2170
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1));
2171
+ assert.isTrue(await driver.authorize(bob, 'docs:write', unitA1x));
2172
+ assert.equal(await linksOf(lead), 1, 'el rol tenía su vínculo');
2173
+ await driver.purgeRole(lead);
2174
+ // El conteo de vínculos documenta la promesa; quien la GARANTIZA en
2175
+ // este esquema es el `CASCADE` (ver `linksOf`, 3F · U2).
2176
+ assert.equal(await linksOf(lead), 0, 'purgeRole borra también los vínculos rol→permiso');
2177
+ assert.isNull((await new CatalogCache().view()).roleByUuid(lead), 'y la fila del rol');
2178
+ assert.isFalse(await driver.hasRole(alice, 'lead', unitA1));
2179
+ assert.deepEqual(await driver.listRoles(alice, unitA1), ['unit-editor'], 'solo cae el rol purgado');
2180
+ assert.deepEqual(await driver.listRoles(bob, unitA1x), []);
2181
+ assert.isFalse(await driver.authorize(bob, 'docs:write', unitA1x));
2182
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1), 'unit-editor sigue concediendo');
2183
+ assert.isFalse(await driver.authorize(bob, 'docs:read', unitA1x), 'el deny sigue');
2184
+ await rejectsWith(assert, () => driver.grant(bob, 'lead', unitA1), { status: 422, code: 'E_AUTHZ_UNKNOWN_ROLE' });
2185
+ await rejectsWith(assert, () => driver.purgeRole(lead), { status: 422, code: 'E_AUTHZ_UNKNOWN_ROLE' });
2186
+ await rejectsWith(assert, () => driver.purgeRole('lead'), { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' });
2187
+ // El slug vuelve a existir (otro uuid): nada resucita.
2188
+ await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'] });
2189
+ assert.isFalse(await driver.hasRole(alice, 'lead', unitA1));
2190
+ assert.isFalse(await driver.authorize(bob, 'docs:write', unitA1x));
2191
+ assert.deepEqual(await driver.listSubjects('lead', unitA1), []);
2192
+ assert.deepEqual(await driver.listSubjects('lead', unitA1x), []);
2193
+ await driver.grant(bob, 'lead', unitA1x);
2194
+ assert.isTrue(await driver.authorize(bob, 'docs:write', unitA1x));
2195
+ assert.deepEqual((await driver.listSubjects('lead', unitA1x)).map((h) => h.uuid), [bob.uuid]);
2196
+ // El cero, por el PUERTO PÚBLICO y en CADA scope donde había una
2197
+ // asignación (3D · M6 c, tester H3): hasta aquí a los mutantes de
2198
+ // `purgeRole` los mataba una FK del espejo de tests que ni siquiera
2199
+ // coincidía con el stub publicado, no una aserción.
2200
+ const carol = subject();
2201
+ await driver.grant(carol, 'lead', unitA1);
2202
+ assert.deepEqual((await driver.listSubjects('lead', unitA1)).map((h) => h.uuid), [carol.uuid], 'alice no resucita con el slug');
2203
+ assert.isFalse(await driver.hasRole(alice, 'lead', unitA1));
2204
+ assert.isFalse(await driver.hasRole(alice, 'lead', unitA1x));
2205
+ });
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.
2216
+ const authz = managerOver();
2217
+ const alice = subject();
2218
+ const orgA = await orgUnder(tree, APP_SCOPE);
2219
+ const unitA1 = await unitUnder(tree, orgA);
2220
+ const unitA1x = await unitUnder(tree, unitA1);
2221
+ const propio = await localRole(unitA1, { slug: 'propio', scopeType: 'unit', permissions: ['docs:write'] });
2222
+ const delNieto = await localRole(unitA1x, { slug: 'nieto', scopeType: 'unit', permissions: ['docs:read'] });
2223
+ await driver.grant(alice, 'propio', unitA1);
2224
+ await driver.grant(alice, 'nieto', unitA1x);
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));
2229
+ const view = await new CatalogCache().view();
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');
2259
+ assert.equal(await linksOf(delNieto), 0);
2260
+ // Nada resucita por el slug: un rol nuevo con ese nombre en otro
2261
+ // árbol no hereda lo que concedía el purgado.
2262
+ const orgB = await orgUnder(tree, APP_SCOPE);
2263
+ const unitB1 = await unitUnder(tree, orgB);
2264
+ await localRole(orgB, { slug: 'propio', scopeType: 'unit', permissions: ['docs:read'] });
2265
+ await driver.grant(alice, 'propio', unitB1);
2266
+ assert.isTrue(await driver.authorize(alice, 'docs:read', unitB1));
2267
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitB1), 'el rol viejo no revive por el slug');
2268
+ });
2269
+ // ── Casos de COMPOSICIÓN (3G · W4) ────────────────────────────────
2270
+ // Las tres regresiones seguidas de 3E/3F/3G nacieron de componer
2271
+ // piezas correctas por separado: cada una tenía su caso, ninguna lo
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.
2283
+ },
2284
+ whenFalse: () => {
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, }) => {
2286
+ // 3B · B4 + 3E · P4. Hasta 3E este caso FIJABA UN CALLEJÓN SIN
2287
+ // SALIDA como comportamiento esperado: `defineScopedRole` creaba el
2288
+ // rol contra un driver que jamás podría borrarlo y aquí se afirmaba
2289
+ // que `scopes.detached` de ese scope respondía 500 «sin tocar
2290
+ // nada» — para siempre, hechos incluidos. Lo que se juzga ahora es
2291
+ // lo contrario: ese estado NO SE CREA (el 500 llega antes de
2292
+ // escribir) y, si llega por otra vía (catálogo escrito a mano, una
2293
+ // migración), la salida existe y está aquí demostrada.
2294
+ const authz = managerOver({ delegablePermissions: ['docs:read', 'docs:write'] });
2295
+ const alice = subject();
2296
+ const admin = subject();
2297
+ const orgA = await orgUnder(tree, APP_SCOPE);
2298
+ const unitA1 = await unitUnder(tree, orgA);
2299
+ assert.isUndefined(driver.purgeRole, 'purgeRole: false ⇒ el puerto no lo trae; un método que solo lanza al llamarlo no deja al manager protegerte');
2300
+ await driver.grant(admin, 'org-admin', orgA);
2301
+ // (1) La API de delegación se niega ANTES de escribir nada.
2302
+ const spec = { slug: 'lead', scopeType: 'unit', rank: 20, permissions: ['docs:write'] };
2303
+ await rejectsWith(assert, () => authz.defineScopedRole(admin, orgA, spec), { status: 500, code: 'E_AUTHZ_UNSUPPORTED' });
2304
+ assert.deepEqual((await new CatalogCache().view()).rolesNamed('lead', 'unit'), [], 'nada escrito');
2305
+ // (2) Los roles locales que existan por otra vía se LEEN con
2306
+ // normalidad (el catálogo es SQL en los dos drivers) y lo que
2307
+ // no se puede deshacer se dice, sin dejar nada a medias.
2308
+ const lead = await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'], rank: 20 });
2309
+ await driver.grant(alice, 'lead', unitA1);
2310
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1));
2311
+ await rejectsWith(assert, () => authz.deleteScopedRole(admin, lead), { status: 500, code: 'E_AUTHZ_UNSUPPORTED' });
2312
+ await rejectsWith(assert, () => authz.deleteScopedRole(admin, 'lead'), { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' });
2313
+ assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1), 'el rol sigue concediendo');
2314
+ assert.deepEqual(await driver.listRoles(alice, unitA1), ['lead']);
2315
+ assert.isNotNull((await new CatalogCache().view()).roleByUuid(lead), 'el rol sigue en el catálogo');
2316
+ assert.equal(await linksOf(lead), 1, 'y sus vínculos');
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.
2323
+ const owner = await unitUnder(tree, orgA);
2324
+ const propio = await localRole(owner, { slug: 'propio', scopeType: 'unit', permissions: ['docs:write'] });
2325
+ await driver.grant(alice, 'propio', owner);
2326
+ await authz.scopes.detached(owner);
2327
+ assert.deepEqual(await driver.listRoles(alice, owner), [], 'los hechos del scope, purgados');
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));
2337
+ // (5) Un scope SIN roles locales propios se purga siempre.
2338
+ const simple = await unitUnder(tree, orgA);
2339
+ await driver.grant(alice, 'unit-editor', simple);
2340
+ await authz.scopes.detached(simple);
2341
+ assert.deepEqual(await driver.listRoles(alice, simple), []);
2342
+ });
2343
+ },
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
+ });
2519
+ // ── Par de capacidad `listDenies` (2E · I5) ──────────────────────────
2520
+ // Todo lo que RESTA denies (`listDenies`, `effectivePermissions`,
2521
+ // `authorizedScopes`, y F1, que observa el catálogo a través de
2522
+ // `effectivePermissions`) necesita el método opcional del puerto. Un
2523
+ // driver que lo trae declara `listDenies: true` y se le juzgan estos
2524
+ // casos; uno que no lo trae declara `false` y se le juzga que las tres
2525
+ // primitivas lo DIGAN (500 `E_AUTHZ_UNSUPPORTED` nombrándolo) en vez de
2526
+ // simular «sin denies», que sería fail-open. Nunca un skip.
2527
+ caseFor('listDenies', {
2528
+ whenTrue: () => {
2529
+ since('2.1', 'el catálogo que decide es el de la base: un sync en otro proceso (otro memo) retira el permiso en la siguiente pregunta, en el driver y en effectivePermissions', async ({ assert, }) => {
2530
+ // 2D · F1 (auditor 1 y 4). Dos managers sobre dos instancias del
2531
+ // driver con memos DISTINTOS y sin señal en memoria entre ellas: lo
2532
+ // único que cruza procesos es la base (`authz_catalog_version`). Un
2533
+ // vínculo retirado por "otro proceso" tiene que dejar de conceder en
2534
+ // la siguiente pregunta de este, sin reiniciar y sin TTL.
2535
+ const twin = harness.makeTwin ? await harness.makeTwin(driver, tree) : twinOf(driver);
2536
+ const here = managerOver();
2537
+ const there = managerOver({}, twin);
2538
+ const alice = subject();
2539
+ const orgA = await orgUnder(tree, APP_SCOPE);
2540
+ await here.grant(alice, 'org-editor', orgA); // docs:read, docs:write
2541
+ assert.isTrue(await here.authorize(alice, 'docs:write', orgA));
2542
+ assert.isTrue(await there.authorize(alice, 'docs:write', orgA));
2543
+ assert.include(await there.effectivePermissions(alice, orgA), 'docs:write');
2544
+ // "Otro proceso" sincroniza `org-editor` sin `docs:write`: cambia la
2545
+ // base y sube la versión en su transacción. Ni `here` ni `there`
2546
+ // reciben llamada alguna.
2547
+ await withoutLink('org-editor', 'organization', 'docs:write');
2548
+ assert.isFalse(await there.authorize(alice, 'docs:write', orgA), 'el otro memo recarga por la versión');
2549
+ assert.isFalse(await here.authorize(alice, 'docs:write', orgA));
2550
+ assert.notInclude(await there.effectivePermissions(alice, orgA), 'docs:write');
2551
+ assert.notInclude(await here.effectivePermissions(alice, orgA), 'docs:write');
2552
+ assert.isTrue(await there.authorize(alice, 'docs:read', orgA), 'lo que sigue en el catálogo sigue concediendo');
2553
+ assert.deepEqual(await there.authorizeMany(alice, 'docs:write', [orgA, APP_SCOPE]), [false, false]);
2554
+ // Y un sync real (el del harness) lo devuelve en ambos.
2555
+ await harness.seedCatalog(CONTRACT_CATALOG);
2556
+ assert.isTrue(await there.authorize(alice, 'docs:write', orgA));
2557
+ assert.isTrue(await here.authorize(alice, 'docs:write', orgA));
2558
+ assert.include(await there.effectivePermissions(alice, orgA), 'docs:write');
2559
+ });
2560
+ since('2.1', 'listDenies: denies directos vigentes del scope exacto (sin herencia) o todos los del sujeto; fuera del catálogo no cuenta', async ({ assert, }) => {
2561
+ // B5 (tester §5 E · listDenies 1-3). Método OPCIONAL del puerto que
2562
+ // ambos drivers del paquete implementan; es lo que `effectivePermissions`
2563
+ // y `authorizedScopes` necesitan para restar. Invariante 7: directo y
2564
+ // exacto, como los demás `list*`.
2565
+ const alice = subject();
2566
+ const bob = subject();
2567
+ const orgA = await orgUnder(tree, APP_SCOPE);
2568
+ const unit = await unitUnder(tree, orgA);
2569
+ await driver.deny(alice, 'docs:read', orgA);
2570
+ await driver.deny(alice, 'docs:write', orgA);
2571
+ await driver.deny(alice, 'billing:read', unit);
2572
+ await driver.deny(alice, 'docs:read', APP_SCOPE);
2573
+ await driver.deny(bob, 'docs:read', orgA);
2574
+ const perms = (denies) => denies.map((d) => d.permission).sort();
2575
+ assert.deepEqual(perms(await driver.listDenies(alice, orgA)), ['docs:read', 'docs:write']);
2576
+ assert.deepEqual(perms(await driver.listDenies(alice, unit)), ['billing:read']);
2577
+ assert.deepEqual(perms(await driver.listDenies(alice, APP_SCOPE)), ['docs:read']);
2578
+ assert.deepEqual(await driver.listDenies(bob, unit), []);
2579
+ assert.deepEqual(await driver.listDenies(subject(), orgA), []);
2580
+ assert.deepEqual(await driver.listDenies(alice, orgScope()), [], 'scope desconocido: nada');
2581
+ // Sin scope: todos los denies directos del sujeto, con su scope.
2582
+ const all = await driver.listDenies(alice);
2583
+ assert.deepEqual(all.map((d) => `${d.permission}@${d.scope.type}:${d.scope.uuid ?? ''}`).sort(), [
2584
+ `billing:read@unit:${unit.uuid}`,
2585
+ `docs:read@app:`,
2586
+ `docs:read@organization:${orgA.uuid}`,
2587
+ `docs:write@organization:${orgA.uuid}`,
2588
+ ]);
2589
+ await driver.removeDeny(alice, 'docs:write', orgA);
2590
+ assert.deepEqual(perms(await driver.listDenies(alice, orgA)), ['docs:read']);
2591
+ assert.lengthOf(await driver.listDenies(alice), 3);
2592
+ });
2593
+ since('2.1', 'effectivePermissions: unión de los roles vigentes de la cadena menos lo denegado en ella; el orden de escritura no importa', async ({ assert, }) => {
2594
+ // B5 (tester §5 E · effectivePermissions 1-4). Composición del manager:
2595
+ // `listRoles` por nivel + catálogo memo, menos `listDenies` por nivel.
2596
+ // Prerrequisito de `catalog/` (3): es lo que un PEP por permiso ve.
2597
+ const authz = managerOver();
2598
+ const alice = subject();
2599
+ const bob = subject();
2600
+ const orgA = await orgUnder(tree, APP_SCOPE);
2601
+ const unit = await unitUnder(tree, orgA);
2602
+ await driver.grant(alice, 'owner', APP_SCOPE); // billing:read (app)
2603
+ await driver.grant(alice, 'viewer', APP_SCOPE, { expiresAt: new Date(Date.now() - 60_000) }); // expirado: nada
2604
+ await driver.grant(alice, 'org-editor', orgA); // docs:read, docs:write
2605
+ await driver.grant(alice, 'unit-editor', unit); // docs:write
2606
+ await driver.deny(alice, 'docs:write', orgA);
2607
+ assert.deepEqual((await authz.effectivePermissions(alice, APP_SCOPE)).sort(), ['billing:read']);
2608
+ assert.deepEqual((await authz.effectivePermissions(alice, orgA)).sort(), ['billing:read', 'docs:read']);
2609
+ assert.deepEqual((await authz.effectivePermissions(alice, unit)).sort(), ['billing:read', 'docs:read']);
2610
+ assert.deepEqual(await authz.effectivePermissions(subject(), unit), []);
2611
+ assert.deepEqual(await authz.effectivePermissions(alice, orgScope()), [], 'scope desconocido: nada');
2612
+ // Coherente con `authorize`, permiso a permiso.
2613
+ for (const permission of ['docs:read', 'docs:write', 'billing:read']) {
2614
+ assert.equal((await authz.effectivePermissions(alice, unit)).includes(permission), await driver.authorize(alice, permission, unit), permission);
2615
+ }
2616
+ // Mismo conjunto con las escrituras en orden inverso.
2617
+ await driver.deny(bob, 'docs:write', orgA);
2618
+ await driver.grant(bob, 'unit-editor', unit);
2619
+ await driver.grant(bob, 'org-editor', orgA);
2620
+ await driver.grant(bob, 'owner', APP_SCOPE);
2621
+ assert.deepEqual((await authz.effectivePermissions(bob, unit)).sort(), ['billing:read', 'docs:read']);
2622
+ // Quitar el deny restaura.
2623
+ await driver.removeDeny(alice, 'docs:write', orgA);
2624
+ assert.deepEqual((await authz.effectivePermissions(alice, unit)).sort(), ['billing:read', 'docs:read', 'docs:write']);
2625
+ });
2626
+ since('2.1', 'authorizedScopes: all SOLO con excludedSubtrees (un deny vivo nunca da un all silencioso); deny en app ⇒ none; sin grants ⇒ none', async ({ assert, }) => {
2627
+ // B3 (juez cruce 5, auditor E1; tester §5 E · authorizedScopes 1 y 3).
2628
+ // `all` = hay un grant vigente en la raíz (ancestro común de todo el
2629
+ // tipo). Los denies del permiso NO desaparecen dentro de `all`: van en
2630
+ // `excludedSubtrees` para que el consumidor los reste en su listado —
2631
+ // sin eso, la org B aparecería listada aunque `authorize` dijera false.
2632
+ const authz = managerOver();
2633
+ const alice = subject();
2634
+ const orgA = await orgUnder(tree, APP_SCOPE);
2635
+ const orgB = await orgUnder(tree, APP_SCOPE);
2636
+ const unitA1 = await unitUnder(tree, orgA);
2637
+ await driver.grant(alice, 'editor', APP_SCOPE);
2638
+ assert.deepEqual(await authz.authorizedScopes(alice, 'docs:write', 'organization'), { kind: 'all', excludedSubtrees: [] });
2639
+ assert.deepEqual(await authz.authorizedScopes(alice, 'docs:write', 'unit'), { kind: 'all', excludedSubtrees: [] });
2640
+ await driver.deny(alice, 'docs:write', orgB);
2641
+ await driver.deny(alice, 'docs:write', unitA1);
2642
+ await driver.deny(alice, 'docs:read', orgA); // otro permiso: no cuenta
2643
+ const result = await authz.authorizedScopes(alice, 'docs:write', 'organization');
2644
+ assert.equal(result.kind, 'all');
2645
+ const excluded = result.excludedSubtrees;
2646
+ assert.deepEqual(scopeKeys(excluded.map((e) => e.scope)), scopeKeys([orgB, unitA1]));
2647
+ assert.isTrue(excluded.every((e) => e.includesDescendants === true), 'un subárbol, no un scope (F10)');
2648
+ assert.isFalse(await driver.authorize(alice, 'docs:write', orgB));
2649
+ assert.isTrue(await driver.authorize(alice, 'docs:write', orgA));
2650
+ // Expandido (F10): lo que hay que restar es cada deny Y su subárbol —
2651
+ // exactamente los scopes donde `authorize` es false.
2652
+ const unitB1 = await unitUnder(tree, orgB);
2653
+ const unitB1x = await unitUnder(tree, unitB1);
2654
+ const unitA1y = await unitUnder(tree, unitA1);
2655
+ const expanded = await authz.expandExcludedSubtrees(excluded);
2656
+ assert.deepEqual(scopeKeys(expanded), scopeKeys([orgB, unitA1, unitB1, unitB1x, unitA1y]));
2657
+ for (const s of [orgA, orgB, unitA1, unitB1, unitB1x, unitA1y]) {
2658
+ assert.equal(!scopeKeys(expanded).includes(scopeKeys([s])[0]), await driver.authorize(alice, 'docs:write', s), scopeKeys([s])[0]);
2659
+ }
2660
+ // Y para el otro permiso, solo orgA está excluida.
2661
+ assert.deepEqual(await authz.authorizedScopes(alice, 'docs:read', 'organization'), {
2662
+ kind: 'all',
2663
+ excludedSubtrees: [{ scope: orgA, includesDescendants: true }],
2664
+ });
2665
+ // Deny en la raíz: el grant de app está bloqueado ⇒ nada, no `all`.
2666
+ await driver.deny(alice, 'docs:write', APP_SCOPE);
2667
+ assert.deepEqual(await authz.authorizedScopes(alice, 'docs:write', 'organization'), { kind: 'none' });
2668
+ await driver.removeDeny(alice, 'docs:write', APP_SCOPE);
2669
+ assert.equal((await authz.authorizedScopes(alice, 'docs:write', 'organization')).kind, 'all');
2670
+ assert.deepEqual(await authz.authorizedScopes(subject(), 'docs:write', 'organization'), { kind: 'none' });
2671
+ assert.deepEqual(await authz.authorizedScopes(alice, 'no:existe', 'organization'), { kind: 'none' });
2672
+ });
2673
+ since('2.1', 'authorizedScopes: some = directos del tipo ∪ descendientes vía descendantsOf, menos subárboles denegados; listScopes sigue sin enumerar (invariante 7)', async ({ assert, }) => {
2674
+ // B3 (tester §5 E · 2 y 4). La ÚNICA API del paquete que enumera
2675
+ // descendientes, y lo hace con el `descendantsOf` del consumidor —
2676
+ // nunca con N+1 llamadas a `resolveChain`. Un deny excluye su
2677
+ // subárbol entero, igual que `authorize` lo deniega.
2678
+ const authz = managerOver();
2679
+ const alice = subject();
2680
+ const orgA = await orgUnder(tree, APP_SCOPE);
2681
+ const orgB = await orgUnder(tree, APP_SCOPE);
2682
+ const orgC = await orgUnder(tree, APP_SCOPE);
2683
+ const unitA1 = await unitUnder(tree, orgA);
2684
+ const unitA2 = await unitUnder(tree, orgA);
2685
+ const unitA1x = await unitUnder(tree, unitA1); // unit bajo unit: profundidad 2
2686
+ const unitB1 = await unitUnder(tree, orgB);
2687
+ const unitC1 = await unitUnder(tree, orgC);
2688
+ await driver.grant(alice, 'org-editor', orgA);
2689
+ await driver.grant(alice, 'org-editor', orgB);
2690
+ await driver.grant(alice, 'unit-editor', unitC1); // directo de tipo unit, en una org sin grant
2691
+ const orgs = await authz.authorizedScopes(alice, 'docs:write', 'organization');
2692
+ assert.equal(orgs.kind, 'some');
2693
+ assert.deepEqual(scopeKeys(orgs.scopes), scopeKeys([orgA, orgB]));
2694
+ const units = await authz.authorizedScopes(alice, 'docs:write', 'unit');
2695
+ assert.equal(units.kind, 'some');
2696
+ assert.deepEqual(scopeKeys(units.scopes), scopeKeys([unitA1, unitA2, unitA1x, unitB1, unitC1]));
2697
+ // Invariante 7: los list* siguen siendo directos.
2698
+ assert.deepEqual(scopeKeys(await driver.listScopes(alice, 'docs:write')), scopeKeys([orgA, orgB, unitC1]));
2699
+ assert.deepEqual(await driver.listRoleScopes(alice, 'unit'), [unitC1]);
2700
+ // Denies: un subárbol entero fuera; un grant directo dentro de un
2701
+ // subárbol denegado tampoco cuenta (authorize lo deniega).
2702
+ await driver.deny(alice, 'docs:write', unitA1);
2703
+ await driver.deny(alice, 'docs:write', orgB);
2704
+ await driver.grant(alice, 'unit-editor', unitB1);
2705
+ assert.isFalse(await driver.authorize(alice, 'docs:write', unitB1));
2706
+ const afterDeny = await authz.authorizedScopes(alice, 'docs:write', 'unit');
2707
+ assert.equal(afterDeny.kind, 'some');
2708
+ assert.deepEqual(scopeKeys(afterDeny.scopes), scopeKeys([unitA2, unitC1]));
2709
+ assert.deepEqual(scopeKeys((await authz.authorizedScopes(alice, 'docs:write', 'organization')).scopes), scopeKeys([orgA]));
2710
+ // Coherente con authorize, scope a scope.
2711
+ for (const s of [unitA1, unitA2, unitA1x, unitB1, unitC1]) {
2712
+ assert.equal(scopeKeys(afterDeny.scopes).includes(scopeKeys([s])[0]), await driver.authorize(alice, 'docs:write', s), scopeKeys([s])[0]);
2713
+ }
2714
+ // Quitar los denies restaura; un tipo sin nodos ⇒ none.
2715
+ await driver.removeDeny(alice, 'docs:write', unitA1);
2716
+ await driver.removeDeny(alice, 'docs:write', orgB);
2717
+ assert.lengthOf((await authz.authorizedScopes(alice, 'docs:write', 'unit')).scopes, 5);
2718
+ assert.deepEqual(await authz.authorizedScopes(alice, 'docs:write', 'team'), { kind: 'none' });
2719
+ });
2720
+ since('2.1', 'authorizedScopes ≡ { s | authorize(s) } scope a scope, con deny intermedio y tres niveles; si descendantsOf y resolveChain discrepan (nodo ajeno, o subárbol denegado que no sabe enumerar) ⇒ 503, nunca una lista con cruces', async ({ assert, }) => {
2721
+ // 2D · F3 (auditor 3). Antes, `descendantsOf(deny) === null` valía `[]`
2722
+ // y el subárbol denegado se listaba como concedido; y un descendiente
2723
+ // ajeno devuelto por un `descendantsOf` roto se aceptaba (cruce de
2724
+ // tenant). Ahora cada candidato se contrasta con `resolveChain`:
2725
+ // su cadena decide (deny en la cadena ⇒ fuera, igual que `authorize`)
2726
+ // y si no cuelga del scope concedente se lanza.
2727
+ const authz = managerOver();
2728
+ const alice = subject();
2729
+ const orgA = await orgUnder(tree, APP_SCOPE);
2730
+ const orgB = await orgUnder(tree, APP_SCOPE);
2731
+ const unitA1 = await unitUnder(tree, orgA);
2732
+ const unitA2 = await unitUnder(tree, orgA);
2733
+ const teamA1a = await unitUnder(tree, unitA1);
2734
+ const teamA1b = await unitUnder(tree, unitA1);
2735
+ const teamA2a = await unitUnder(tree, unitA2);
2736
+ const unitB1 = await unitUnder(tree, orgB);
2737
+ await driver.grant(alice, 'org-editor', orgA);
2738
+ await driver.deny(alice, 'docs:write', unitA1); // deny intermedio: fuera teamA1a y teamA1b
2739
+ await driver.grant(alice, 'unit-editor', teamA1b); // un grant DENTRO del subárbol denegado no lo salva
2740
+ const all = [orgA, orgB, unitA1, unitA2, teamA1a, teamA1b, teamA2a, unitB1];
2741
+ const listed = (await authz.authorizedScopes(alice, 'docs:write', 'unit'));
2742
+ assert.equal(listed.kind, 'some');
2743
+ assert.deepEqual(scopeKeys(listed.scopes), scopeKeys([unitA2, teamA2a]));
2744
+ for (const s of all) {
2745
+ assert.equal(scopeKeys(listed.scopes).includes(scopeKeys([s])[0]), s.type === 'unit' && (await driver.authorize(alice, 'docs:write', s)), scopeKeys([s])[0]);
2746
+ }
2747
+ assert.deepEqual(scopeKeys((await authz.authorizedScopes(alice, 'docs:write', 'organization')).scopes), scopeKeys([orgA]));
2748
+ // Un `descendantsOf` que no sabe enumerar el subárbol denegado (null)
2749
+ // ya no importa: el deny se aplica por la cadena del candidato.
2750
+ const full = descendantsFrom(tree);
2751
+ const blind = managerOver({
2752
+ scopes: {
2753
+ resolveChain: resolveChainFrom(tree),
2754
+ descendantsOf: (scope, o) => (scopeKeys([scope])[0] === scopeKeys([unitA1])[0] ? Promise.resolve(null) : full(scope, o)),
2755
+ },
2756
+ });
2757
+ assert.deepEqual(scopeKeys((await blind.authorizedScopes(alice, 'docs:write', 'unit')).scopes), scopeKeys([unitA2, teamA2a]));
2758
+ // Un `descendantsOf` que devuelve un nodo de OTRO tenant: 503, no una lista con la unit de B.
2759
+ const expected = { status: 503, code: 'E_AUTHZ_RESOLVER_FAILED' };
2760
+ const crossed = managerOver({
2761
+ scopes: {
2762
+ resolveChain: resolveChainFrom(tree),
2763
+ descendantsOf: async (scope, o) => {
2764
+ const own = (await full(scope, o)) ?? [];
2765
+ return scopeKeys([scope])[0] === scopeKeys([orgA])[0] ? [...own, unitB1] : own;
2766
+ },
2767
+ },
2768
+ });
2769
+ await rejectsWith(assert, () => crossed.authorizedScopes(alice, 'docs:write', 'unit'), expected);
2770
+ // Y uno que devuelve un nodo que `resolveChain` no conoce, lo mismo.
2771
+ const ghost = managerOver({
2772
+ scopes: {
2773
+ resolveChain: resolveChainFrom(tree),
2774
+ descendantsOf: async (scope, o) => [...((await full(scope, o)) ?? []), unitScope()],
2775
+ },
2776
+ });
2777
+ await rejectsWith(assert, () => ghost.authorizedScopes(alice, 'docs:write', 'unit'), expected);
2778
+ // La pertenencia se contrasta con el memo por request: una llamada al árbol por candidato como mucho.
2779
+ let asked = 0;
2780
+ const original = tree.chainOf;
2781
+ tree.chainOf = async (scope) => {
2782
+ asked += 1;
2783
+ return original.call(tree, scope);
2784
+ };
2785
+ try {
2786
+ await authz.authorizedScopes(alice, 'docs:write', 'unit');
2787
+ assert.isAtMost(asked, all.length + 1);
2788
+ }
2789
+ finally {
2790
+ tree.chainOf = original;
2791
+ }
2792
+ });
2793
+ since('2.1', 'authorizedScopes: maxScopes superado ⇒ 422 E_AUTHZ_TOO_MANY_SCOPES (nunca parcial), la frontera exacta responde; sin descendantsOf ⇒ 500 aunque no haya grants; descendantsOf jamás se llama desde authorize/list*', async ({ assert, }) => {
2794
+ // B3/B2 (tester §5 E · 5, 6, 7 y el test de arquitectura). Una lista
2795
+ // truncada en silencio es el peor resultado posible: se lanza. Sin
2796
+ // `descendantsOf` no hay `none` (sería un fail-closed mentiroso: el
2797
+ // sujeto puede tener scopes). Y `descendantsOf` es abierto: prohibido
2798
+ // en el camino de decisión.
2799
+ let descendantsCalls = 0;
2800
+ const descendantsOf = descendantsFrom(tree);
2801
+ const authz = managerOver({
2802
+ scopes: {
2803
+ resolveChain: resolveChainFrom(tree),
2804
+ descendantsOf: (scope, options) => {
2805
+ descendantsCalls += 1;
2806
+ return descendantsOf(scope, options);
2807
+ },
2808
+ },
2809
+ });
2810
+ const alice = subject();
2811
+ const orgs = [];
2812
+ for (let i = 0; i < 3; i++) {
2813
+ const org = await orgUnder(tree, APP_SCOPE);
2814
+ await unitUnder(tree, org);
2815
+ orgs.push(org);
2816
+ await driver.grant(alice, 'org-editor', org);
2817
+ }
2818
+ await rejectsWith(assert, () => authz.authorizedScopes(alice, 'docs:write', 'organization', { maxScopes: 2 }), {
2819
+ status: 422,
2820
+ code: 'E_AUTHZ_TOO_MANY_SCOPES',
2821
+ });
2822
+ const exact = await authz.authorizedScopes(alice, 'docs:write', 'organization', { maxScopes: 3 });
2823
+ assert.equal(exact.kind, 'some');
2824
+ assert.lengthOf(exact.scopes, 3);
2825
+ // La cota es sobre la RESPUESTA: 3 units bajo esas orgs con maxScopes 3.
2826
+ assert.lengthOf((await authz.authorizedScopes(alice, 'docs:write', 'unit', { maxScopes: 3 })).scopes, 3);
2827
+ const capped = managerOver({ scopes: { resolveChain: resolveChainFrom(tree), descendantsOf, maxScopes: 2 } });
2828
+ await rejectsWith(assert, () => capped.authorizedScopes(alice, 'docs:write', 'organization'), { status: 422, code: 'E_AUTHZ_TOO_MANY_SCOPES' });
2829
+ // F8: la cota por llamada solo puede BAJAR la del config, nunca subirla.
2830
+ await rejectsWith(assert, () => capped.authorizedScopes(alice, 'docs:write', 'organization', { maxScopes: 100 }), { status: 422, code: 'E_AUTHZ_TOO_MANY_SCOPES' });
2831
+ // F8: se corta antes de bajar (3 orgs directas > 2: ni una llamada a descendantsOf).
2832
+ descendantsCalls = 0;
2833
+ await rejectsWith(assert, () => authz.authorizedScopes(alice, 'docs:write', 'organization', { maxScopes: 2 }), { status: 422, code: 'E_AUTHZ_TOO_MANY_SCOPES' });
2834
+ assert.equal(descendantsCalls, 0);
2835
+ const noDescendants = managerOver({ scopes: { resolveChain: resolveChainFrom(tree) } });
2836
+ const expected = { status: 500, code: 'E_AUTHZ_NO_DESCENDANTS_RESOLVER' };
2837
+ await rejectsWith(assert, () => noDescendants.authorizedScopes(alice, 'docs:write', 'organization'), expected);
2838
+ await rejectsWith(assert, () => noDescendants.authorizedScopes(subject(), 'docs:write', 'organization'), expected);
2839
+ // Arquitectura: el camino de decisión y los list* no tocan descendantsOf.
2840
+ descendantsCalls = 0;
2841
+ const unit = (await descendantsOf(orgs[0], { maxNodes: 10 }))[0];
2842
+ await authz.authorize(alice, 'docs:write', unit);
2843
+ await authz.authorizeMany(alice, 'docs:write', [unit, orgs[1]]);
2844
+ await authz.hasRole(alice, 'org-editor', unit);
2845
+ await authz.listScopes(alice, 'docs:write');
2846
+ await authz.listRoles(alice, orgs[0]);
2847
+ await authz.listRoleScopes(alice, 'organization');
2848
+ await authz.listSubjects('org-editor', orgs[0]);
2849
+ await authz.effectivePermissions(alice, unit);
2850
+ await authz.listDenies(alice);
2851
+ await authz.isWithin(unit, orgs[0]);
2852
+ await authz.grant(alice, 'unit-editor', unit, { within: orgs[0] });
2853
+ await authz.deny(alice, 'docs:read', unit);
2854
+ assert.equal(descendantsCalls, 0);
2855
+ await authz.authorizedScopes(alice, 'docs:write', 'unit');
2856
+ assert.isAbove(descendantsCalls, 0);
2857
+ });
2858
+ // 3E · P4: la API de DELEGACIÓN entera cuelga de `purgeRole`. Un rol
2859
+ // local en un driver que no sabe purgarlo es estado que nada puede
2860
+ // borrar: ni `deleteScopedRole` ni `scopes.detached` de ese scope
2861
+ // volverían a funcionar NUNCA. Por eso su policy solo se juzga con la
2862
+ // capacidad, y la cara `false` juzga que se diga ANTES de escribir.
2863
+ caseFor('purgeRole', {
2864
+ whenTrue: () => {
2865
+ since('2.2', 'defineScopedRole: el rol que el administrador de A delega concede en A y sus descendientes y no fuera; effectivePermissions y authorizedScopes respetan el owner; otro proceso (otro memo) lo ve en su siguiente pregunta; updateScopedRole cambia lo que concede; fuera de la policy ⇒ 422 sin escribir', async ({ assert, }) => {
2866
+ // 3B · B3 (+ B7: la versión compartida sube en la misma transacción).
2867
+ // La policy completa se juzga en `manager.spec`; aquí, que el rol
2868
+ // definido por la API es un rol del DRIVER como cualquier otro, en
2869
+ // ambos drivers, y que la delegación tiene barreras.
2870
+ const delegable = ['docs:read', 'docs:write', 'billing:read', 'org:settings'];
2871
+ const twin = harness.makeTwin ? await harness.makeTwin(driver, tree) : twinOf(driver);
2872
+ const here = managerOver({ delegablePermissions: delegable });
2873
+ const there = managerOver({ delegablePermissions: delegable }, twin);
2874
+ const admin = subject();
2875
+ const adminB = subject();
2876
+ const bob = subject();
2877
+ const orgA = await orgUnder(tree, APP_SCOPE);
2878
+ const orgB = await orgUnder(tree, APP_SCOPE);
2879
+ const unitA1 = await unitUnder(tree, orgA);
2880
+ const unitA1x = await unitUnder(tree, unitA1);
2881
+ const unitA2 = await unitUnder(tree, orgA);
2882
+ const unitB1 = await unitUnder(tree, orgB);
2883
+ await driver.grant(admin, 'org-admin', orgA);
2884
+ await driver.grant(adminB, 'org-admin', orgB);
2885
+ const lead = await here.defineScopedRole(admin, orgA, { slug: 'lead', scopeType: 'unit', rank: 20, permissions: ['docs:write'] });
2886
+ assert.deepEqual(lead, { uuid: lead.uuid, slug: 'lead', scopeType: 'unit', owner: `organization|${orgA.uuid}`, rank: 20 });
2887
+ await here.grant(bob, 'lead', unitA1, { within: orgA });
2888
+ assert.isTrue(await driver.authorize(bob, 'docs:write', unitA1));
2889
+ assert.isTrue(await driver.authorize(bob, 'docs:write', unitA1x));
2890
+ assert.isFalse(await driver.authorize(bob, 'docs:write', unitA2), 'la asignación es en unitA1: no en la hermana');
2891
+ assert.isFalse(await driver.authorize(bob, 'docs:write', unitB1));
2892
+ assert.isFalse(await driver.authorize(bob, 'docs:read', unitA1));
2893
+ // Otro proceso: sin señal en memoria, por la versión compartida.
2894
+ assert.isTrue(await there.authorize(bob, 'docs:write', unitA1x));
2895
+ assert.isTrue(await there.hasRole(bob, 'lead', unitA1x));
2896
+ assert.deepEqual(await there.listRoles(bob, unitA1), ['lead']);
2897
+ assert.deepEqual(await there.effectivePermissions(bob, unitA1x), ['docs:write']);
2898
+ assert.deepEqual(await here.effectivePermissions(bob, unitB1), []);
2899
+ const listed = await here.authorizedScopes(bob, 'docs:write', 'unit');
2900
+ assert.equal(listed.kind, 'some');
2901
+ assert.deepEqual(scopeKeys(listed.scopes), scopeKeys([unitA1, unitA1x]));
2902
+ await rejectsWith(assert, () => here.grant(bob, 'lead', unitB1, { within: orgB }), { status: 422, code: 'E_AUTHZ_ROLE_NOT_VISIBLE' });
2903
+ await rejectsWith(assert, () => there.grant(bob, 'lead', unitB1), { status: 422, code: 'E_AUTHZ_ROLE_NOT_VISIBLE' });
2904
+ // updateScopedRole: lo que concede cambia en el acto, en los dos memos.
2905
+ await here.updateScopedRole(admin, lead.uuid, { permissions: ['docs:read'] });
2906
+ assert.isFalse(await driver.authorize(bob, 'docs:write', unitA1x));
2907
+ assert.isTrue(await driver.authorize(bob, 'docs:read', unitA1x));
2908
+ assert.isFalse(await there.authorize(bob, 'docs:write', unitA1x));
2909
+ assert.deepEqual(await there.effectivePermissions(bob, unitA1x), ['docs:read']);
2910
+ // Barreras (422, nada escrito): permiso no delegable/no efectivo, deny del actor (C2),
2911
+ // rank ≥ actor, composición por nivel (B5), colisión con un global, owner raíz, otro tenant.
2912
+ const spec = { slug: 'lead2', scopeType: 'unit', rank: 20, permissions: ['docs:write'] };
2913
+ await rejectsWith(assert, () => managerOver().defineScopedRole(admin, orgA, spec), { status: 422, code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE' });
2914
+ await rejectsWith(assert, () => here.defineScopedRole(adminB, orgA, spec), { status: 422, code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE' });
2915
+ await driver.deny(admin, 'docs:write', orgA);
2916
+ await rejectsWith(assert, () => here.defineScopedRole(admin, orgA, spec), { status: 422, code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE' });
2917
+ await driver.removeDeny(admin, 'docs:write', orgA);
2918
+ await rejectsWith(assert, () => here.defineScopedRole(admin, orgA, { ...spec, rank: 50 }), { status: 422, code: 'E_AUTHZ_RANK_EXCEEDED' });
2919
+ await rejectsWith(assert, () => here.defineScopedRole(admin, orgA, { ...spec, permissions: ['org:settings'] }), { status: 422, code: 'E_AUTHZ_ROLE_NOT_ASSIGNABLE_AT' });
2920
+ await rejectsWith(assert, () => here.defineScopedRole(admin, orgA, { ...spec, slug: 'unit-editor' }), { status: 422, code: 'E_AUTHZ_CATALOG_CONFLICT' });
2921
+ await rejectsWith(assert, () => here.defineScopedRole(admin, APP_SCOPE, spec), { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' });
2922
+ await rejectsWith(assert, () => here.defineScopedRole(admin, orgB, spec), { status: 422, code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE' });
2923
+ await rejectsWith(assert, () => here.grant(bob, 'lead2', unitA1), { status: 422, code: 'E_AUTHZ_UNKNOWN_ROLE' });
2924
+ assert.isNull((await new CatalogCache().view()).rolesNamed('lead2', 'unit')[0] ?? null);
2925
+ // Y con la policy en regla, el mismo spec escribe.
2926
+ const lead2 = await here.defineScopedRole(admin, orgA, spec);
2927
+ await there.grant(bob, 'lead2', unitA2, { within: orgA });
2928
+ assert.isTrue(await driver.authorize(bob, 'docs:write', unitA2));
2929
+ assert.equal(lead2.owner, lead.owner);
2930
+ });
2931
+ // 3E · R2 (tester): la carrera de M2 es un PAR de capacidad. Con
2932
+ // escrituras de catálogo serializadas (PG/MySQL, cerrojo sobre la
2933
+ // fila de versión) el juez exige lo que el README promete —
2934
+ // exactamente un ganador y 422 para el perdedor—; sin ellas (SQLite
2935
+ // bloquea la base entera) solo lo innegociable: nunca dos, y el
2936
+ // perdedor no escribe. Aceptar `oneOf([422, 503])` en todas partes
2937
+ // dejaba vivo un mutante que convertía la colisión en un 503.
2938
+ const raceOfDefines = (serialized) => since('2.2', serialized
2939
+ ? 'dos defineScopedRole del MISMO (slug, nivel) con owners de la misma cadena, en paralelo y con memos distintos: EXACTAMENTE uno gana y el perdedor es 422 E_AUTHZ_CATALOG_CONFLICT — la unicidad no es una carrera'
2940
+ : 'dos defineScopedRole del MISMO (slug, nivel) con owners de la misma cadena, en paralelo y con memos distintos: nunca dos ganadores y el perdedor no escribe (sin escrituras de catálogo serializadas su transacción puede morir con 503)', async ({ assert }) => {
2941
+ // 3D · M2 (auditor V2 🔴 A, reproducido en PG: los dos se insertaban
2942
+ // porque el unique de la base es (slug, scope_type, owner_scope_key)
2943
+ // y cada uno comprobaba la colisión contra SU foto del memo). Ahora
2944
+ // las escrituras del catálogo van en serie (cerrojo sobre la fila de
2945
+ // `authz_catalog_version`) y la colisión se re-comprueba DENTRO de la
2946
+ // transacción, leyendo la base.
2947
+ const delegable = ['docs:read', 'docs:write', 'billing:read'];
2948
+ const twin = harness.makeTwin ? await harness.makeTwin(driver, tree) : twinOf(driver);
2949
+ const here = managerOver({ delegablePermissions: delegable });
2950
+ const there = managerOver({ delegablePermissions: delegable }, twin);
2951
+ const admin = subject();
2952
+ const orgA = await orgUnder(tree, APP_SCOPE);
2953
+ const unitA1 = await unitUnder(tree, orgA);
2954
+ await driver.grant(admin, 'org-admin', orgA);
2955
+ // Los dos memos calientes ANTES de escribir: cada uno con su foto.
2956
+ assert.deepEqual(await here.listRoles(admin, orgA), ['org-admin']);
2957
+ assert.deepEqual(await there.listRoles(admin, orgA), ['org-admin']);
2958
+ const spec = { slug: 'lead', scopeType: 'unit', rank: 20, permissions: ['docs:write'] };
2959
+ const results = await Promise.allSettled([
2960
+ here.defineScopedRole(admin, orgA, spec),
2961
+ there.defineScopedRole(admin, unitA1, { ...spec, permissions: ['docs:read'] }),
2962
+ ]);
2963
+ const ok = results.filter((r) => r.status === 'fulfilled');
2964
+ const estado = JSON.stringify(results.map((r) => (r.status === 'rejected' ? `${r.reason?.status} ${r.reason?.code}` : 'ok')));
2965
+ if (serialized) {
2966
+ // 3F · S3 (tester): desde que la colisión se decide por AUTORIDAD
2967
+ // esta carrera tiene DOS finales legales, y cuál sale depende de
2968
+ // quién CONFIRME primero — 2 ms de jitter bastan para cambiarlo,
2969
+ // medido en PostgreSQL y en MySQL—: si confirma el del ANCESTRO
2970
+ // (orgA), el del descendiente choca con 422; si confirma el del
2971
+ // DESCENDIENTE (unitA1), el del ancestro ya NO choca — entra y lo
2972
+ // ENSOMBRECE. Exigir aquí «exactamente un ganador» afirmaba una
2973
+ // unicidad que el paquete ya no promete en esa dirección y hacía
2974
+ // el caso FLAKY (pasaba porque el primero del array suele ganar).
2975
+ // Lo innegociable no cambia y se afirma abajo: en la base está
2976
+ // exactamente lo que se confirmó, nunca dos con el MISMO owner, y
2977
+ // quien no escribe lo sabe. La promesa fuerte de la capacidad —un
2978
+ // solo ganador y 422 para el perdedor— se juzga con el MISMO
2979
+ // owner, donde no hay asimetría de autoridad (`gemelo`, abajo).
2980
+ assert.oneOf(ok.length, [1, 2], `un ganador, o el del ancestro ensombreciendo al del descendiente (${estado})`);
2981
+ }
2982
+ else {
2983
+ // Sin serialización garantizada: nunca dos. Cuántos confirman
2984
+ // depende del motor — SQLite bloquea la base entera y el segundo
2985
+ // escritor puede recibir `SQLITE_BUSY`, un fallo de backend
2986
+ // legítimo (503) porque su transacción NO se aplicó.
2987
+ assert.isAtMost(ok.length, 1, `nunca dos ganadores (${estado})`);
2988
+ }
2989
+ for (const rejected of results.filter((r) => r.status === 'rejected')) {
2990
+ const error = rejected.reason;
2991
+ if (serialized) {
2992
+ assert.equal(error?.status, 422, `el perdedor choca, no se cae: ${error?.code} ${error?.message}`);
2993
+ assert.equal(error?.code, 'E_AUTHZ_CATALOG_CONFLICT');
2994
+ }
2995
+ else {
2996
+ assert.oneOf(error?.status, [422, 503], `el perdedor no escribe: ${error?.code} ${error?.message}`);
2997
+ if (error?.status === 422)
2998
+ assert.equal(error?.code, 'E_AUTHZ_CATALOG_CONFLICT');
2999
+ }
3000
+ }
3001
+ let named = (await new CatalogCache().view()).rolesNamed('lead', 'unit');
3002
+ assert.lengthOf(named, ok.length, 'en la base hay exactamente lo que se confirmó');
3003
+ assert.deepEqual(named.map((r) => r.uuid).sort(), ok.map((w) => w.value.uuid).sort(), 'y son exactamente los uuid que confirmaron: nadie escribe sin decirlo');
3004
+ assert.equal(new Set(named.map((r) => r.owner)).size, named.length, 'nunca dos roles con el MISMO owner');
3005
+ if (named.length === 2) {
3006
+ // El único que puede convivir con otro es el del ANCESTRO, y
3007
+ // dentro del subárbol del descendiente el slug pasa a ser AMBIGUO
3008
+ // (fail-closed, M1). Se purga el ensombrecido para que el resto
3009
+ // del caso siga desde un estado conocido, venga de donde venga.
3010
+ assert.isDefined(named.find((r) => r.owner === scopeKey(orgA)), 'el que puede convivir es el del ANCESTRO');
3011
+ await rejectsWith(assert, () => driver.grant(subject(), 'lead', unitA1), { status: 422, code: 'E_AUTHZ_AMBIGUOUS_ROLE' });
3012
+ await driver.purgeRole(named.find((r) => r.owner !== scopeKey(orgA)).uuid);
3013
+ named = (await new CatalogCache().view()).rolesNamed('lead', 'unit');
3014
+ }
3015
+ if (named.length === 0) {
3016
+ await here.defineScopedRole(admin, orgA, spec);
3017
+ named = (await new CatalogCache().view()).rolesNamed('lead', 'unit');
3018
+ }
3019
+ assert.lengthOf(named, 1);
3020
+ // La promesa FUERTE de la capacidad, sin la asimetría de S3: dos
3021
+ // `define` del MISMO owner, en paralelo y con memos distintos. Ahí
3022
+ // los dos tienen la MISMA autoridad, así que el segundo choca gane
3023
+ // quien gane — exactamente uno escribe y el perdedor es 422, que es
3024
+ // lo que dice el README de `serializedCatalogWrites`.
3025
+ assert.deepEqual(await here.listRoles(admin, orgA), ['org-admin']);
3026
+ assert.deepEqual(await there.listRoles(admin, orgA), ['org-admin']);
3027
+ const gemelos = await Promise.allSettled([
3028
+ here.defineScopedRole(admin, orgA, { ...spec, slug: 'gemelo' }),
3029
+ there.defineScopedRole(admin, orgA, { ...spec, slug: 'gemelo', permissions: ['docs:read'] }),
3030
+ ]);
3031
+ const ganan = gemelos.filter((r) => r.status === 'fulfilled');
3032
+ const cuento = JSON.stringify(gemelos.map((r) => (r.status === 'rejected' ? `${r.reason?.status} ${r.reason?.code}` : 'ok')));
3033
+ if (serialized) {
3034
+ assert.lengthOf(ganan, 1, `exactamente un ganador con el MISMO owner (${cuento})`);
3035
+ for (const perdedor of gemelos.filter((r) => r.status === 'rejected')) {
3036
+ const error = perdedor.reason;
3037
+ assert.equal(error?.status, 422, `el perdedor choca, no se cae: ${error?.code} ${error?.message}`);
3038
+ assert.equal(error?.code, 'E_AUTHZ_CATALOG_CONFLICT');
3039
+ }
3040
+ }
3041
+ else {
3042
+ assert.isAtMost(ganan.length, 1, `nunca dos ganadores (${cuento})`);
3043
+ }
3044
+ assert.lengthOf((await new CatalogCache().view()).rolesNamed('gemelo', 'unit'), ganan.length, 'en la base, exactamente lo confirmado — con el mismo owner no hay ensombrecimiento posible');
3045
+ // Y en SERIE —sin carrera que confunda— el segundo `define` choca
3046
+ // SIEMPRE y con el error preciso, desde el MISMO scope y desde
3047
+ // cualquier DESCENDIENTE del owner que ganó (da igual cuál de los
3048
+ // dos fuera: la carrera no decide qué se juzga aquí).
3049
+ const conflicto = { status: 422, code: 'E_AUTHZ_CATALOG_CONFLICT' };
3050
+ const dueno = named[0].owner === scopeKey(orgA) ? orgA : unitA1;
3051
+ const debajo = named[0].owner === scopeKey(orgA) ? unitA1 : await unitUnder(tree, unitA1);
3052
+ await rejectsWith(assert, () => there.defineScopedRole(admin, dueno, spec), conflicto);
3053
+ await rejectsWith(assert, () => here.defineScopedRole(admin, dueno, spec), conflicto);
3054
+ await rejectsWith(assert, () => there.defineScopedRole(admin, debajo, spec), conflicto);
3055
+ assert.lengthOf((await new CatalogCache().view()).rolesNamed('lead', 'unit'), 1);
3056
+ // 3F · S3: hacia ARRIBA no se choca, se ENSOMBRECE — la autoridad
3057
+ // manda (global > local de un ancestro > local de un descendiente),
3058
+ // así que el dueño del árbol siempre puede definir su rol aunque
3059
+ // alguien de abajo le haya ocupado el nombre (hasta 3E era 422 y
3060
+ // era la última forma de la mina de slug). Con otro slug, para no
3061
+ // tocar el estado de `lead`.
3062
+ const squat = await there.defineScopedRole(admin, debajo, { ...spec, slug: 'ocupado' });
3063
+ const delDueno = await here.defineScopedRole(admin, dueno, { ...spec, slug: 'ocupado' });
3064
+ assert.notEqual(delDueno.uuid, squat.uuid);
3065
+ assert.lengthOf((await new CatalogCache().view()).rolesNamed('ocupado', 'unit'), 2, 'el del ancestro entra y ensombrece al del descendiente');
3066
+ // Y dentro del subárbol del ocupante el slug es AMBIGUO, en los DOS
3067
+ // drivers: fail-closed, se opera por `{ uuid }`.
3068
+ await rejectsWith(assert, () => driver.grant(subject(), 'ocupado', debajo), { status: 422, code: 'E_AUTHZ_AMBIGUOUS_ROLE' });
3069
+ await driver.grant(subject(), { uuid: delDueno.uuid }, debajo);
3070
+ // Y por tanto el slug no es ambiguo en ninguna parte de la cadena.
3071
+ const bob = subject();
3072
+ await driver.grant(bob, 'lead', unitA1);
3073
+ assert.isTrue(await driver.hasRole(bob, 'lead', unitA1));
3074
+ // El RE-CHEQUEO dentro de la transacción (3D · M2 b), que hasta 3E
3075
+ // no ejercitaba ningún caso (tester R2: el mutante «sin
3076
+ // re-chequeo», forzado a serie, pasaba la suite entera): el rol
3077
+ // homónimo aparece SIN subir la versión del catálogo —exactamente
3078
+ // lo que ve un escritor cuya foto se tomó antes de que el otro
3079
+ // proceso confirmara—, así que el chequeo barato contra el memo lo
3080
+ // deja pasar y solo la relectura de la BASE puede pararlo.
3081
+ await insertRoleUnseen({ slug: 'sigiloso', scopeType: 'unit', owner: scopeKey(orgA) });
3082
+ await rejectsWith(assert, () => here.defineScopedRole(admin, unitA1, { ...spec, slug: 'sigiloso' }), conflicto);
3083
+ assert.lengthOf(await rowsNamed('sigiloso', 'unit'), 1, 'no se escribe a ciegas sobre lo que el memo no vio');
3084
+ // Y la rama del re-chequeo que NO se puede juzgar ahí dentro (3F ·
3085
+ // U3, tester 3E · §4.2): un homónimo cuyo owner no es global ni
3086
+ // ancestro-o-igual del owner pedido —una unit HERMANA— no se
3087
+ // clasifica sin resolver su cadena, y resolverla con el cerrojo del
3088
+ // catálogo sostenido sería un abrazo mortal con un pool de 1. Se
3089
+ // rechaza igual, con el 422 que pide REINTENTAR, y no escribe.
3090
+ // Hasta 3F esa rama solo la observaba la carrera de arriba, y solo
3091
+ // en el harness cuyo árbol SQL ensancha la ventana: aquí va EN
3092
+ // SERIE (el memo se recalienta primero, así que la fila nueva le es
3093
+ // invisible y solo la relectura de la BASE la ve).
3094
+ const unitA2 = await unitUnder(tree, orgA);
3095
+ assert.deepEqual(await here.listRoles(admin, orgA), ['org-admin'], 'memo recalentado tras el fallo anterior');
3096
+ await insertRoleUnseen({ slug: 'lateral', scopeType: 'unit', owner: scopeKey(unitA2) });
3097
+ const cambio = await rejectsWith(assert, () => here.defineScopedRole(admin, unitA1, { ...spec, slug: 'lateral' }), conflicto);
3098
+ assert.include(String(cambio?.message), 'El catálogo cambió mientras se validaba');
3099
+ assert.lengthOf(await rowsNamed('lateral', 'unit'), 1, 'el 422 genérico tampoco escribe a ciegas');
3100
+ });
3101
+ caseFor('serializedCatalogWrites', {
3102
+ whenTrue: () => raceOfDefines(true),
3103
+ whenFalse: () => raceOfDefines(false),
3104
+ });
3105
+ since('2.2', 'composición (3G · W4 iii): un defineScopedRole que ENSOMBRECE al homónimo de un descendiente Y a la vez tiene el subárbol por encima de la cota — la regla de nivel degrada, pero el rango sobre el ensombrecido se exige igual (422 sin escribir); con rango suficiente entra, lo reporta y el slug pasa a ser ambiguo solo ahí abajo', async ({ assert, }) => {
3106
+ // 3G · W3 × 3F · S2: la degradación de `descendantsOf` relaja la
3107
+ // regla de NIVEL (y el actor puede provocarla creando hijos), así
3108
+ // que lo que protege al descendiente en ese estado es el RANGO. Si
3109
+ // alguna vez las dos comprobaciones se funden en una, este caso lo
3110
+ // dice.
3111
+ const eventos = [];
3112
+ const admin = subject();
3113
+ const orgA = await orgUnder(tree, APP_SCOPE);
3114
+ const unitA1 = await unitUnder(tree, orgA);
3115
+ await unitUnder(tree, orgA);
3116
+ await unitUnder(tree, orgA);
3117
+ const authz = managerOver({
3118
+ delegablePermissions: ['docs:read', 'docs:write'],
3119
+ scopes: { resolveChain: resolveChainFrom(tree), descendantsOf: descendantsFrom(tree), maxDescendants: 1 },
3120
+ hooks: { onCatalogWrite: async (event) => void eventos.push(event) },
3121
+ });
3122
+ await driver.grant(admin, 'org-admin', orgA);
3123
+ const critico = await localRole(unitA1, { slug: 'critico', scopeType: 'unit', rank: 80, permissions: ['docs:write'] });
3124
+ // La regla de NIVEL degrada a la mínima: un tipo que no es de un
3125
+ // ancestro se acepta sin poder comprobar que cuelga del owner.
3126
+ const suelto = await authz.defineScopedRole(admin, orgA, { slug: 'suelto', scopeType: 'team', rank: 20, permissions: ['docs:read'] });
3127
+ assert.equal(suelto.scopeType, 'team', 'con el subárbol sin enumerar, la regla de nivel es la mínima');
3128
+ // Y aun así, ensombrecer exige superar en rango al ensombrecido.
3129
+ await rejectsWith(assert, () => authz.defineScopedRole(admin, orgA, { slug: 'critico', scopeType: 'unit', rank: 20, permissions: ['docs:read'] }), { status: 422, code: 'E_AUTHZ_RANK_EXCEEDED' });
3130
+ assert.lengthOf((await new CatalogCache().view()).rolesNamed('critico', 'unit'), 1, 'nada escrito');
3131
+ await driver.grant(subject(), 'critico', unitA1);
3132
+ // La otra cara: con rango de sobra sobre el homónimo, la autoridad
3133
+ // del ancestro sigue intacta (3F · S3) y se REPORTA a quién ensombrece.
3134
+ const flojo = await localRole(unitA1, { slug: 'flojo', scopeType: 'unit', rank: 10, permissions: ['docs:write'] });
3135
+ const sombra = await authz.defineScopedRole(admin, orgA, { slug: 'flojo', scopeType: 'unit', rank: 20, permissions: ['docs:read'] });
3136
+ assert.deepEqual(eventos.filter((e) => e.action === 'role_defined' && e.role.uuid === sombra.uuid).map((e) => e.shadowedByAncestor?.map((r) => r.uuid)), [[flojo]], 'el evento dice a quién acaba de ensombrecer');
3137
+ await rejectsWith(assert, () => driver.grant(subject(), 'flojo', unitA1), { status: 422, code: 'E_AUTHZ_AMBIGUOUS_ROLE' });
3138
+ assert.isNotNull((await new CatalogCache().view()).roleByUuid(critico), 'y el rol de rank 80 sigue intacto');
3139
+ });
3140
+ since('2.2', 'effectivePermissions es EXACTAMENTE {p | authorize(p)} aunque en la cadena haya un homónimo del rol del holder, y defineScopedRole no delega lo que solo tiene el homónimo (la policy mide por uuid, nunca del slug al catálogo)', async ({ assert, }) => {
3141
+ // 3D · M1 (auditor V1 🔴, escalada reproducida en PG): `rolesInChain`
3142
+ // devolvía el SLUG y el manager lo volvía a resolver con «el owner
3143
+ // más cercano gana», así que `effectivePermissions(pepe, U1)` decía
3144
+ // `docs:write` mientras `authorize` decía `false`, y con eso `pepe`
3145
+ // delegaba `docs:write` a un títere. Ahora el puerto habla por uuid.
3146
+ const delegable = ['docs:read', 'docs:write', 'billing:read'];
3147
+ const here = managerOver({ delegablePermissions: delegable });
3148
+ const pepe = subject();
3149
+ const titere = subject();
3150
+ const orgA = await orgUnder(tree, APP_SCOPE);
3151
+ const unitU1 = await unitUnder(tree, orgA);
3152
+ // El rol de pepe: `lead@unit` owner orgA, con SOLO docs:read.
3153
+ const suyo = await localRole(orgA, { slug: 'lead', scopeType: 'unit', rank: 20, permissions: ['docs:read'] });
3154
+ await driver.grant(pepe, { uuid: suyo }, unitU1);
3155
+ // Y el homónimo, owner de la propia unit, con docs:write. Pepe NO lo tiene.
3156
+ const ajeno = await localRole(unitU1, { slug: 'lead', scopeType: 'unit', rank: 20, permissions: ['docs:write'] });
3157
+ assert.notEqual(suyo, ajeno);
3158
+ assert.isTrue(await driver.authorize(pepe, 'docs:read', unitU1));
3159
+ assert.isFalse(await driver.authorize(pepe, 'docs:write', unitU1));
3160
+ assert.deepEqual(await here.effectivePermissions(pepe, unitU1), ['docs:read'], 'exactamente {p | authorize(p)}');
3161
+ assert.deepEqual(await here.effectivePermissions(pepe, orgA), []);
3162
+ // La policy de delegación mide lo mismo: docs:write no es suyo.
3163
+ await rejectsWith(assert, () => here.defineScopedRole(pepe, unitU1, { slug: 'jefe', scopeType: 'unit', rank: 10, permissions: ['docs:write'] }), { status: 422, code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE' });
3164
+ assert.deepEqual((await new CatalogCache().view()).rolesNamed('jefe', 'unit'), [], 'nada escrito');
3165
+ // Lo que SÍ es suyo se delega, y el rank del homónimo tampoco se hereda.
3166
+ const jefe = await here.defineScopedRole(pepe, unitU1, { slug: 'jefe', scopeType: 'unit', rank: 10, permissions: ['docs:read'] });
3167
+ await driver.grant(titere, { uuid: jefe.uuid }, unitU1);
3168
+ assert.isTrue(await driver.authorize(titere, 'docs:read', unitU1));
3169
+ assert.isFalse(await driver.authorize(titere, 'docs:write', unitU1), 'la escalada del auditor, cerrada');
3170
+ await rejectsWith(assert, () => here.defineScopedRole(pepe, unitU1, { slug: 'jefe2', scopeType: 'unit', rank: 20, permissions: ['docs:read'] }), { status: 422, code: 'E_AUTHZ_RANK_EXCEEDED' });
3171
+ });
3172
+ },
3173
+ // La cara `false` la juzga el par `purgeRole` de arriba (3E · P4):
3174
+ // `defineScopedRole` es 500 antes de escribir, así que esta policy
3175
+ // no existe para un driver que no sabe purgar.
3176
+ });
3177
+ },
3178
+ whenFalse: () => {
3179
+ since('2.1', 'sin listDenies en el puerto: listDenies, effectivePermissions y authorizedScopes son 500 E_AUTHZ_UNSUPPORTED nombrándolo (nunca un [] simulado); el puerto 2.0 sigue respondiendo', async ({ assert, }) => {
3180
+ // 2E · I5 (tester ⚪). Hasta aquí solo lo vigilaba `manager.spec`
3181
+ // con un driver falso; ahora lo juzga el contrato sobre el driver
3182
+ // real del harness. Un `[]` en su lugar diría «sin denies» y
3183
+ // `effectivePermissions` concedería lo denegado.
3184
+ assert.notTypeOf(driver.listDenies, 'function', 'el harness declara listDenies: false y el driver lo implementa: declara lo observable');
3185
+ const authz = managerOver();
3186
+ const alice = subject();
3187
+ const orgA = await orgUnder(tree, APP_SCOPE);
3188
+ await driver.grant(alice, 'org-editor', orgA);
3189
+ for (const [label, call] of [
3190
+ ['listDenies', () => authz.listDenies(alice, orgA)],
3191
+ ['listDenies (todos)', () => authz.listDenies(alice)],
3192
+ ['effectivePermissions', () => authz.effectivePermissions(alice, orgA)],
3193
+ ['authorizedScopes', () => authz.authorizedScopes(alice, 'docs:write', 'organization')],
3194
+ ]) {
3195
+ try {
3196
+ await call();
3197
+ assert.fail(`${label}: debería haber rechazado`);
3198
+ }
3199
+ catch (error) {
3200
+ assert.equal(error?.status, 500, `${label}: ${error?.message}`);
3201
+ assert.equal(error?.code, 'E_AUTHZ_UNSUPPORTED', label);
3202
+ assert.include(String(error?.message), 'listDenies', label);
3203
+ }
3204
+ }
3205
+ // El puerto 2.0 y las primitivas que no restan denies siguen respondiendo.
3206
+ assert.isTrue(await authz.authorize(alice, 'docs:write', orgA));
3207
+ assert.deepEqual(await authz.authorizeMany(alice, 'docs:write', [orgA, APP_SCOPE]), [true, false]);
3208
+ assert.deepEqual(await authz.listRoles(alice, orgA), ['org-editor']);
3209
+ assert.isTrue(await authz.isWithin(orgA, APP_SCOPE));
3210
+ });
3211
+ since('2.2', 'sin listDenies en el puerto: defineScopedRole y updateScopedRole son 500 E_AUTHZ_UNSUPPORTED nombrándolo (la policy resta denies del actor: sin ellos no se delega), nada escrito; deleteScopedRole no lo necesita', async ({ assert, }) => {
3212
+ // 3B · B3 + 2E · I5: `defineScopedRole` exige `effectivePermissions`
3213
+ // del actor (C2: resta denies). Sin `listDenies` no se puede saber
3214
+ // si el actor tiene denegado lo que delega ⇒ se DICE, nunca se
3215
+ // asume «sin denies» (sería el lavado del auditor).
3216
+ const authz = managerOver({ delegablePermissions: ['docs:read', 'docs:write'] });
3217
+ const admin = subject();
3218
+ const orgA = await orgUnder(tree, APP_SCOPE);
3219
+ // El nivel del rol tiene que colgar del owner (3E · P1): la unit
3220
+ // existe para que lo que se juzgue aquí sea `listDenies`.
3221
+ await unitUnder(tree, orgA);
3222
+ await driver.grant(admin, 'org-admin', orgA);
3223
+ const spec = { slug: 'lead', scopeType: 'unit', rank: 20, permissions: ['docs:write'] };
3224
+ for (const [label, call] of [
3225
+ ['defineScopedRole', () => authz.defineScopedRole(admin, orgA, spec)],
3226
+ ]) {
3227
+ try {
3228
+ await call();
3229
+ assert.fail(`${label}: debería haber rechazado`);
3230
+ }
3231
+ catch (error) {
3232
+ assert.equal(error?.status, 500, `${label}: ${error?.message}`);
3233
+ assert.equal(error?.code, 'E_AUTHZ_UNSUPPORTED', label);
3234
+ assert.include(String(error?.message), 'listDenies', label);
3235
+ }
3236
+ }
3237
+ assert.deepEqual((await new CatalogCache().view()).rolesNamed('lead', 'unit'), []);
3238
+ // Un rol local escrito por otro proceso: updateScopedRole con permisos también lo dice; deleteScopedRole no lo necesita.
3239
+ const lead = await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'], rank: 20 });
3240
+ try {
3241
+ await authz.updateScopedRole(admin, lead, { permissions: ['docs:read'] });
3242
+ assert.fail('updateScopedRole: debería haber rechazado');
3243
+ }
3244
+ catch (error) {
3245
+ assert.equal(error?.status, 500, error?.message);
3246
+ assert.equal(error?.code, 'E_AUTHZ_UNSUPPORTED');
3247
+ }
3248
+ assert.deepEqual([...(await new CatalogCache().view()).rolePermissionsOf(lead)], ['docs:write']);
3249
+ // Sin cambiar permisos no hace falta restar denies: el rank se cambia.
3250
+ assert.equal((await authz.updateScopedRole(admin, lead, { rank: 21 })).rank, 21);
3251
+ });
3252
+ },
3253
+ });
3254
+ // ── Pares de capacidad ──────────────────────────────────────────────
3255
+ // `hierarchyFacts: false` → lo observan N1b/N2/N4/N5 (arriba): el driver
3256
+ // responde según el árbol que le resuelve el consumidor, y `tree.move`
3257
+ // cambia la respuesta sin escritura. `true` llega en Fase 3b.
3258
+ // `injectableClock` tiene su par arriba (2.5 · J1). `transactions` (2.6)
3259
+ // y `singleCheckAuthorize` (Fase 3b): pares en su fase; hoy solo pueden
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
+ });
3294
+ caseFor('hierarchyFacts', {
3295
+ // Con el árbol en manos del consumidor (`resolveChain`), el árbol
3296
+ // es una dependencia más de cada pregunta: su caída se clasifica como la
3297
+ // del backend (503, código propio), nunca como un `false` ni como el
3298
+ // error crudo del consumidor. La cara `true` (árbol como hechos del
3299
+ // backend, donde el resolutor no participa) llega en Fase 3b.
3300
+ whenFalse: () => {
3301
+ test('un resolutor de ancestros que lanza ⇒ 503 E_AUTHZ_RESOLVER_FAILED, nunca false', async ({ assert, }) => {
3302
+ const alice = subject();
3303
+ const org = await orgUnder(tree, APP_SCOPE);
3304
+ await driver.grant(alice, 'editor', APP_SCOPE);
3305
+ assert.isTrue(await driver.authorize(alice, 'docs:read', org));
3306
+ const original = tree.chainOf;
3307
+ tree.chainOf = async () => {
3308
+ throw new Error('el árbol del consumidor está caído');
3309
+ };
3310
+ try {
3311
+ const expected = { status: 503, code: 'E_AUTHZ_RESOLVER_FAILED' };
3312
+ await rejectsWith(assert, () => driver.authorize(alice, 'docs:read', org), expected);
3313
+ await rejectsWith(assert, () => driver.hasRole(alice, 'editor', org), expected);
3314
+ }
3315
+ finally {
3316
+ tree.chainOf = original;
3317
+ }
3318
+ assert.isTrue(await driver.authorize(alice, 'docs:read', org));
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
+ });
3577
+ },
3578
+ });
3579
+ caseFor('truncationSignal', {
3580
+ // `whenTrue` (el driver señala el truncamiento) no tiene caso: ningún
3581
+ // driver del paquete trunca. Declarar `true` deja la capacidad sin
3582
+ // cubrir y la suite lanza al cerrar el grupo.
3583
+ whenFalse: () => caseFor('exhaustiveLists', {
3584
+ // Backend sin tope, o driver que enumera sin tope (`Read` paginado
3585
+ // en openfga): la única garantía honesta es que la lista es
3586
+ // completa por grande que sea. Contra el OpenFGA de tope 3 de CI
3587
+ // este caso es la prueba de que la enumeración no depende del tope.
3588
+ whenTrue: () => {
3589
+ test('listas exhaustivas: 1.200 asignaciones directas se devuelven enteras', async ({ assert, }) => {
3590
+ const alice = subject();
3591
+ const orgs = [];
3592
+ for (let i = 0; i < 1_200; i++) {
3593
+ const org = await orgUnder(tree, APP_SCOPE);
3594
+ orgs.push(org);
3595
+ await driver.grant(alice, 'org-editor', org);
3596
+ }
3597
+ const scopes = await driver.listScopes(alice, 'docs:write');
3598
+ assert.deepEqual(scopeKeys(scopes), scopeKeys(orgs));
3599
+ assert.lengthOf(await driver.listRoleScopes(alice, 'organization'), 1_200);
3600
+ })?.timeout(60_000);
3601
+ },
3602
+ // Backend con tope y driver que no lo señala (un driver de
3603
+ // terceros): lo único que se puede afirmar es la FRONTERA —
3604
+ // exactamente `listMaxResults` asignaciones se devuelven enteras.
3605
+ // Con una más el backend truncaría en silencio; eso no se codifica
3606
+ // como aceptado, solo se deja de afirmar.
3607
+ whenFalse: () => {
3608
+ const limit = harness.limits?.listMaxResults;
3609
+ if (!limit) {
3610
+ throw new Error(`[contrato ${harness.name}] declara 'exhaustiveLists: false' sin ` +
3611
+ `'limits.listMaxResults': el juez necesita el tope para probar la frontera.`);
3612
+ }
3613
+ test(`frontera del tope: ${limit} asignaciones directas se devuelven enteras`, async ({ assert, }) => {
3614
+ const alice = subject();
3615
+ const orgs = [];
3616
+ for (let i = 0; i < limit; i++) {
3617
+ const org = await orgUnder(tree, APP_SCOPE);
3618
+ orgs.push(org);
3619
+ await driver.grant(alice, 'org-editor', org);
3620
+ }
3621
+ const scopes = await driver.listScopes(alice, 'docs:write');
3622
+ assert.deepEqual(scopeKeys(scopes), scopeKeys(orgs));
3623
+ })?.timeout(120_000);
3624
+ },
3625
+ }),
3626
+ });
209
3627
  });
3628
+ // Al cerrar el grupo: toda capacidad declarada `true` tiene que haber
3629
+ // registrado su par. Es la regla "jamás un skip" hecha ejecutable.
3630
+ const uncovered = Object.keys(harness.capabilities).filter((capability) => harness.capabilities[capability] && !covered.has(capability));
3631
+ if (uncovered.length) {
3632
+ throw new Error(`[contrato ${harness.name}] declara ${uncovered.map((c) => `'${c}: true'`).join(', ')} ` +
3633
+ `pero el contrato no tiene caso para ese valor todavía. Declara lo observable hoy.`);
3634
+ }
210
3635
  }
211
3636
  //# sourceMappingURL=contract.js.map