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