@jantstack/adonis-authz 2.0.0-alpha.1 → 2.4.0-alpha.2
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 +693 -36
- 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 +97 -0
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
- package/build/commands/authz_relations_reconcile.js +313 -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 +50 -10
- package/build/index.d.ts.map +1 -1
- package/build/index.js +45 -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 +55 -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 +137 -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 +128 -7
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +510 -24
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +113 -0
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
- package/build/src/drivers/database_relations_driver.js +679 -0
- package/build/src/drivers/database_relations_driver.js.map +1 -0
- package/build/src/drivers/openfga_driver.d.ts +729 -122
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +2083 -476
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_facts.d.ts +384 -0
- package/build/src/drivers/openfga_facts.d.ts.map +1 -0
- package/build/src/drivers/openfga_facts.js +836 -0
- package/build/src/drivers/openfga_facts.js.map +1 -0
- package/build/src/drivers/openfga_relations_driver.d.ts +141 -0
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
- package/build/src/drivers/openfga_relations_driver.js +590 -0
- package/build/src/drivers/openfga_relations_driver.js.map +1 -0
- package/build/src/errors.d.ts +290 -5
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +296 -7
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +141 -0
- package/build/src/freeze.d.ts.map +1 -0
- package/build/src/freeze.js +217 -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 +330 -4
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +1285 -188
- package/build/src/manager.js.map +1 -1
- package/build/src/models/authz_assignment.d.ts +7 -7
- package/build/src/models/authz_assignment.d.ts.map +1 -1
- package/build/src/models/authz_deny.d.ts +7 -7
- package/build/src/models/authz_deny.d.ts.map +1 -1
- package/build/src/models/authz_permission.d.ts +7 -7
- package/build/src/models/authz_permission.d.ts.map +1 -1
- package/build/src/models/authz_role.d.ts +7 -7
- package/build/src/models/authz_role.d.ts.map +1 -1
- package/build/src/models/authz_role_permission.d.ts +7 -7
- 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 +93 -0
- package/build/src/relation_partition_trigger.js.map +1 -0
- package/build/src/relations/define_relations_config.d.ts +75 -0
- package/build/src/relations/define_relations_config.d.ts.map +1 -0
- package/build/src/relations/define_relations_config.js +173 -0
- package/build/src/relations/define_relations_config.js.map +1 -0
- package/build/src/relations/manager.d.ts +60 -0
- package/build/src/relations/manager.d.ts.map +1 -0
- package/build/src/relations/manager.js +213 -0
- package/build/src/relations/manager.js.map +1 -0
- package/build/src/relations/reconcile.d.ts +111 -0
- package/build/src/relations/reconcile.d.ts.map +1 -0
- package/build/src/relations/reconcile.js +200 -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 +298 -0
- package/build/src/scope_outbox.js.map +1 -0
- package/build/src/{drivers → shared}/backend_guard.d.ts +42 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -0
- package/build/src/{drivers → shared}/backend_guard.js +78 -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/shared/transaction_guard.d.ts +50 -0
- package/build/src/shared/transaction_guard.d.ts.map +1 -0
- package/build/src/shared/transaction_guard.js +60 -0
- package/build/src/shared/transaction_guard.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 +129 -4
- package/build/src/testing/contract.d.ts.map +1 -1
- package/build/src/testing/contract.js +912 -177
- package/build/src/testing/contract.js.map +1 -1
- package/build/src/testing/main.d.ts +8 -2
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +4 -1
- 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 +73 -0
- package/build/src/testing/relations_contract.d.ts.map +1 -0
- package/build/src/testing/relations_contract.js +1069 -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 +220 -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 +8 -8
- package/build/src/traits/has_uuid.d.ts.map +1 -1
- package/build/src/types.d.ts +1053 -83
- 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 +136 -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,9 +4,29 @@ 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
|
+
/** El default de `DriverContractHarness.transactions`: Lucid + `authz_*`. */
|
|
11
|
+
export function lucidContractTransactions() {
|
|
12
|
+
const count = async (table, subject) => {
|
|
13
|
+
const rows = await db
|
|
14
|
+
.from(table)
|
|
15
|
+
.where('holder_type', subject.type)
|
|
16
|
+
.where('holder_uuid', subject.uuid)
|
|
17
|
+
.count('* as n');
|
|
18
|
+
return Number(rows[0]?.n ?? 0);
|
|
19
|
+
};
|
|
20
|
+
return {
|
|
21
|
+
async begin() {
|
|
22
|
+
const trx = await db.transaction();
|
|
23
|
+
return { client: trx, commit: () => trx.commit(), rollback: () => trx.rollback() };
|
|
24
|
+
},
|
|
25
|
+
async census(subject) {
|
|
26
|
+
return { assignments: await count('authz_assignments', subject), denies: await count('authz_denies', subject) };
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
}
|
|
10
30
|
/**
|
|
11
31
|
* Catálogo de prueba: roles a nivel app y a nivel organization. Los dos
|
|
12
32
|
* últimos existen solo para el invariante 8: `rank` es metadata y el motor no
|
|
@@ -54,12 +74,15 @@ function twinOf(driver) {
|
|
|
54
74
|
* transacción con el bump como última sentencia (`withAuthzCatalogWrite`, 2E
|
|
55
75
|
* · H2). Ninguna señal en memoria: es exactamente lo que cruza procesos.
|
|
56
76
|
*/
|
|
57
|
-
async function
|
|
77
|
+
async function removeLinkRow(role, scopeType, permission) {
|
|
78
|
+
let roleUuid = '';
|
|
58
79
|
await withAuthzCatalogWrite(async (trx) => {
|
|
59
80
|
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
81
|
const permRow = (await trx.from('authz_permissions').where('slug', permission).select('uuid'))[0];
|
|
82
|
+
roleUuid = String(roleRow.uuid);
|
|
61
83
|
await trx.from('authz_role_permissions').where('role_uuid', roleRow.uuid).where('permission_uuid', permRow.uuid).delete();
|
|
62
84
|
});
|
|
85
|
+
return roleUuid;
|
|
63
86
|
}
|
|
64
87
|
/**
|
|
65
88
|
* Un rol LOCAL a `owner` escrito directamente en `authz_*` (3B · B2), como
|
|
@@ -69,7 +92,7 @@ async function withoutLink(role, scopeType, permission) {
|
|
|
69
92
|
* owner) sin pasar por la policy del manager (que se juzga aparte, con
|
|
70
93
|
* `defineScopedRole`). Devuelve el uuid del rol.
|
|
71
94
|
*/
|
|
72
|
-
async function
|
|
95
|
+
async function insertLocalRole(owner, spec) {
|
|
73
96
|
const uuid = uuidv7();
|
|
74
97
|
await withAuthzCatalogWrite(async (trx) => {
|
|
75
98
|
const now = new Date();
|
|
@@ -98,7 +121,7 @@ async function localRole(owner, spec) {
|
|
|
98
121
|
* legalmente queda apuntando a un rol de otro nivel sin escribir hechos a
|
|
99
122
|
* mano (que en `openfga` no serían escribibles por el puerto).
|
|
100
123
|
*/
|
|
101
|
-
async function
|
|
124
|
+
async function retypeRoleRow(roleUuid, scopeType) {
|
|
102
125
|
await withAuthzCatalogWrite(async (trx) => {
|
|
103
126
|
await trx.from('authz_roles').where('uuid', roleUuid).update({ scope_type: scopeType, updated_at: new Date() });
|
|
104
127
|
});
|
|
@@ -156,7 +179,7 @@ async function forgetRoleByHand(roleUuid) {
|
|
|
156
179
|
});
|
|
157
180
|
}
|
|
158
181
|
/** Un vínculo rol→permiso escrito a mano (lo que un sync rechazaría), con la versión subida. */
|
|
159
|
-
async function
|
|
182
|
+
async function insertLinkRow(roleUuid, permission) {
|
|
160
183
|
await withAuthzCatalogWrite(async (trx) => {
|
|
161
184
|
const permRow = (await trx.from('authz_permissions').where('slug', permission).select('uuid'))[0];
|
|
162
185
|
await trx.table('authz_role_permissions').insert({ uuid: uuidv7(), role_uuid: roleUuid, permission_uuid: permRow.uuid, created_at: new Date() });
|
|
@@ -274,8 +297,50 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
274
297
|
const detachEdge = tree.detach.bind(tree);
|
|
275
298
|
tree.detach = async (child) => {
|
|
276
299
|
await driver.purgeScope(child);
|
|
300
|
+
// El árbol del BACKEND, si el driver lo materializa (3b-2e · E6):
|
|
301
|
+
// la arista se va DESPUÉS de que la purga demuestre cero (S6), igual
|
|
302
|
+
// que hace `manager.scopes.detached`.
|
|
303
|
+
await driver.onScopeDetached?.(child);
|
|
277
304
|
await detachEdge(child);
|
|
278
305
|
};
|
|
306
|
+
// **El espejo del árbol** (3b-2e · E6). Un driver que declara
|
|
307
|
+
// `hierarchyFacts` NO lee el árbol del consumidor para decidir: lo tiene
|
|
308
|
+
// como hechos propios, y quien se los cuenta es el consumidor con
|
|
309
|
+
// `authorization.scopes.*`. La suite mueve el árbol con `tree.attach` /
|
|
310
|
+
// `tree.move` directamente (decenas de sitios), así que aquí se espeja:
|
|
311
|
+
// primero la fila del consumidor, después la notificación al driver —
|
|
312
|
+
// que es el orden en el que el paquete valida (el padre tiene que
|
|
313
|
+
// existir para el resolutor).
|
|
314
|
+
if (harness.capabilities.hierarchyFacts) {
|
|
315
|
+
/**
|
|
316
|
+
* El juez cuelga a propósito scopes que el PAQUETE considera
|
|
317
|
+
* identidad inválida (el uuid centinela de la raíz) para comprobar
|
|
318
|
+
* que ninguna escritura los acepta. El espejo no puede tumbar ese
|
|
319
|
+
* caso: si el driver rechaza el nodo por identidad, el store
|
|
320
|
+
* simplemente no se entera —y las escrituras siguen siendo 422, que
|
|
321
|
+
* es lo que el caso mide—. Cualquier otro error SÍ se propaga (un
|
|
322
|
+
* ciclo, un padre desconocido, el backend caído).
|
|
323
|
+
*/
|
|
324
|
+
const mirror = async (notify) => {
|
|
325
|
+
try {
|
|
326
|
+
await notify();
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
if (error?.code !== 'E_AUTHZ_INVALID_IDENTITY')
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
};
|
|
333
|
+
const attachEdge = tree.attach.bind(tree);
|
|
334
|
+
tree.attach = async (child, parent) => {
|
|
335
|
+
await attachEdge(child, parent);
|
|
336
|
+
await mirror(() => driver.onScopeAttached?.(child, parent));
|
|
337
|
+
};
|
|
338
|
+
const moveEdge = tree.move.bind(tree);
|
|
339
|
+
tree.move = async (child, parent) => {
|
|
340
|
+
await moveEdge(child, parent);
|
|
341
|
+
await mirror(() => driver.onScopeMoved?.(child, parent));
|
|
342
|
+
};
|
|
343
|
+
}
|
|
279
344
|
});
|
|
280
345
|
group.teardown(async () => {
|
|
281
346
|
await harness.cleanup();
|
|
@@ -288,17 +353,67 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
288
353
|
* 2.0 pasa por aquí igual; lo que le falte (`listDenies`) se ve como
|
|
289
354
|
* 500 `E_AUTHZ_UNSUPPORTED` en el caso que lo necesita, nunca como skip.
|
|
290
355
|
*/
|
|
356
|
+
/**
|
|
357
|
+
* Un rol LOCAL escrito directamente en `authz_*`, como haría otro proceso
|
|
358
|
+
* —y, desde 3b-2e · E6, con la **proyección derivada** del driver puesta
|
|
359
|
+
* al día: en un driver donde lo que un rol concede son tuplas (modo
|
|
360
|
+
* `facts`), un rol escrito sin proyectar no concedería nada y el caso
|
|
361
|
+
* mediría el espejo en vez del invariante.
|
|
362
|
+
*/
|
|
363
|
+
/**
|
|
364
|
+
* Los dos escritores «a mano» del catálogo, con la **proyección derivada**
|
|
365
|
+
* del driver al día (3b-2e · E6). Un proceso que escribe `authz_*` por
|
|
366
|
+
* fuera del sync ya tenía el deber de invalidar el memo (2A); en un driver
|
|
367
|
+
* que mantiene un espejo del catálogo en el backend (modo `facts`) también
|
|
368
|
+
* tiene que rehacer ese espejo — es lo que hace `syncAuthzCatalog` cuando
|
|
369
|
+
* se le pasa la proyección, y lo que `authz:reconcile` repara si se pierde.
|
|
370
|
+
*/
|
|
371
|
+
async function withoutLink(role, scopeType, permission) {
|
|
372
|
+
const uuid = await removeLinkRow(role, scopeType, permission);
|
|
373
|
+
await driver.projectCatalogRole?.(uuid);
|
|
374
|
+
}
|
|
375
|
+
async function linkByHand(roleUuid, permission) {
|
|
376
|
+
await insertLinkRow(roleUuid, permission);
|
|
377
|
+
await driver.projectCatalogRole?.(roleUuid);
|
|
378
|
+
}
|
|
379
|
+
async function localRole(owner, spec) {
|
|
380
|
+
const uuid = await insertLocalRole(owner, spec);
|
|
381
|
+
await driver.projectCatalogRole?.(uuid);
|
|
382
|
+
return uuid;
|
|
383
|
+
}
|
|
384
|
+
/**
|
|
385
|
+
* El TERCER escritor a mano: el NIVEL declarado de un rol (3b-2g · R1).
|
|
386
|
+
* Misma regla que los otros dos —quien escribe `authz_*` por fuera del
|
|
387
|
+
* sync rehace la proyección derivada del driver—, y aquí no es un detalle
|
|
388
|
+
* del harness: en `facts` la visibilidad de un rol también está
|
|
389
|
+
* materializada (la arista `scope#binding` significa «el rol es visible
|
|
390
|
+
* aquí»), así que un `scope_type` cambiado sin proyectar deja concediendo
|
|
391
|
+
* lo que el catálogo ya no declara en ese nivel. En `database`
|
|
392
|
+
* `projectCatalogRole` no existe y esto es un no-op: la regla se evalúa en
|
|
393
|
+
* cada pregunta.
|
|
394
|
+
*/
|
|
395
|
+
async function retypeRole(roleUuid, scopeType) {
|
|
396
|
+
await retypeRoleRow(roleUuid, scopeType);
|
|
397
|
+
await driver.projectCatalogRole?.(roleUuid);
|
|
398
|
+
}
|
|
291
399
|
function managerOver(overrides = {}, over = driver) {
|
|
292
400
|
return new AuthorizationManager({
|
|
293
401
|
default: harness.name,
|
|
294
402
|
drivers: { [harness.name]: () => over },
|
|
295
|
-
scopes: {
|
|
296
|
-
resolveChain: resolveChainFrom(tree),
|
|
297
|
-
descendantsOf: descendantsFrom(tree),
|
|
298
|
-
...overrides.scopes,
|
|
299
|
-
},
|
|
300
403
|
warnOnOptInSecurity: false,
|
|
301
404
|
...overrides,
|
|
405
|
+
// `scopes` va DESPUÉS de los overrides y los respeta enteros (hay
|
|
406
|
+
// casos que quitan `descendantsOf` a propósito): lo único que se
|
|
407
|
+
// añade siempre es la firma del riesgo de deriva. El juez juzga el
|
|
408
|
+
// DRIVER; la outbox es una mitigación del CONFIG (3b-2e · E3) y aquí
|
|
409
|
+
// el árbol lo mueve la propia suite, no una transacción de consumidor.
|
|
410
|
+
scopes: {
|
|
411
|
+
...(overrides.scopes ?? {
|
|
412
|
+
resolveChain: resolveChainFrom(tree),
|
|
413
|
+
descendantsOf: descendantsFrom(tree),
|
|
414
|
+
}),
|
|
415
|
+
acceptScopeDriftRisk: true,
|
|
416
|
+
},
|
|
302
417
|
});
|
|
303
418
|
}
|
|
304
419
|
test('grant de un rol concede sus permisos (authorize true)', async ({ assert }) => {
|
|
@@ -1021,21 +1136,37 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
1021
1136
|
assert.isFalse(await driver.authorize(mallory, 'docs:read', alias), 'sin guiones: el deny se evade');
|
|
1022
1137
|
const known = (await tree.chainOf(alias)) !== null;
|
|
1023
1138
|
if (known) {
|
|
1024
|
-
// El árbol lo funde con la fila real: la cadena es la canónica y
|
|
1025
|
-
//
|
|
1026
|
-
//
|
|
1027
|
-
|
|
1139
|
+
// El árbol lo funde con la fila real: la cadena es la canónica y las
|
|
1140
|
+
// ESCRITURAS caen sobre los hechos canónicos. Si además la DECISIÓN
|
|
1141
|
+
// sobre el alias encuentra esos hechos es la capacidad
|
|
1142
|
+
// `canonicalScopeReads`, con su par (3b-2k · K1 · R2 (c)): aquí no se
|
|
1143
|
+
// afirma, porque `openfga` en modo `facts` responde `false` —
|
|
1144
|
+
// fail-closed, pero no la misma respuesta que `database`.
|
|
1028
1145
|
assert.deepEqual(await driver.listRoles(mallory, alias), [], 'sin guiones: los roles directos se leen bajo la forma canónica');
|
|
1029
1146
|
await driver.grant(eve, 'unit-editor', alias);
|
|
1030
1147
|
assert.deepEqual(await driver.listRoles(eve, unit), ['unit-editor'], 'sin guiones: el grant sobre el alias queda bajo la forma canónica');
|
|
1031
1148
|
assert.deepEqual(scopeKeys(await driver.listRoleScopes(eve, 'unit')), scopeKeys([unit]), 'sin guiones: ningún hecho con la forma del alias');
|
|
1032
1149
|
await driver.revoke(eve, 'unit-editor', alias);
|
|
1033
1150
|
assert.deepEqual(await driver.listRoles(eve, unit), [], 'sin guiones: revoke sobre el alias quita el hecho canónico');
|
|
1151
|
+
// 3b-1 · M13: `purgeScope` canoniza igual que el resto (invariante
|
|
1152
|
+
// 17). Sin esto, la canonización que hace el DRIVER dentro de
|
|
1153
|
+
// `purgeScope` no la observaba ningún caso en ningún motor: un
|
|
1154
|
+
// `detached` notificado con el alias dejaría vivos los hechos.
|
|
1155
|
+
await driver.grant(eve, 'unit-editor', alias);
|
|
1156
|
+
await driver.purgeScope(alias);
|
|
1157
|
+
assert.deepEqual(await driver.listRoles(eve, unit), [], 'sin guiones: purgeScope sobre el alias purga los hechos CANÓNICOS');
|
|
1158
|
+
assert.isTrue(await driver.authorize(mallory, 'docs:read', unit), 'y también el deny canónico (era lo único que bloqueaba)');
|
|
1034
1159
|
}
|
|
1035
1160
|
else {
|
|
1036
1161
|
assert.isFalse(await driver.authorize(mallory, 'docs:write', alias), 'sin guiones: desconocido: nada concede');
|
|
1037
1162
|
assert.deepEqual(await driver.listRoles(mallory, alias), [], 'sin guiones: desconocido');
|
|
1038
1163
|
await rejectsWith(assert, () => driver.grant(eve, 'unit-editor', alias), { status: 422, code: 'E_AUTHZ_UNKNOWN_SCOPE' });
|
|
1164
|
+
// 3b-1 · M13, la otra cara: un scope que el árbol no conoce se purga
|
|
1165
|
+
// TAL CUAL (`canonicalScope`), así que un alias desconocido no puede
|
|
1166
|
+
// llevarse por delante los hechos de la forma canónica.
|
|
1167
|
+
await driver.purgeScope(alias);
|
|
1168
|
+
assert.isFalse(await driver.authorize(mallory, 'docs:read', unit), 'el deny canónico sigue en pie');
|
|
1169
|
+
assert.isTrue(await driver.authorize(mallory, 'docs:write', unit), 'y lo concedido sigue concedido');
|
|
1039
1170
|
}
|
|
1040
1171
|
});
|
|
1041
1172
|
test('scope que el árbol no conoce: authorize/hasRole false; grant/deny 422 E_AUTHZ_UNKNOWN_SCOPE', async ({ assert, }) => {
|
|
@@ -1498,7 +1629,7 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
1498
1629
|
await lax.grant(alice, 'org-editor', orgA);
|
|
1499
1630
|
assert.isTrue(await driver.authorize(alice, 'docs:read', orgA));
|
|
1500
1631
|
});
|
|
1501
|
-
since('2.1', 'authorizeMany: idéntico a N authorize por posición (duplicados, desconocidos, denegados); vacío ⇒ [] sin backend ni árbol;
|
|
1632
|
+
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
1633
|
// B6 (tester §5 E · authorizeMany 1-4). Composición por defecto
|
|
1503
1634
|
// (`Promise.all` de `authorize` sobre una vista memoizada) o el método
|
|
1504
1635
|
// opcional del driver (openfga: un solo batchCheck correlacionado por
|
|
@@ -1548,23 +1679,19 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
1548
1679
|
const watched = new AuthorizationManager({
|
|
1549
1680
|
default: harness.name,
|
|
1550
1681
|
drivers: { [harness.name]: () => spied },
|
|
1551
|
-
scopes: { resolveChain: resolveChainFrom(tree), descendantsOf: descendantsFrom(tree) },
|
|
1682
|
+
scopes: { resolveChain: resolveChainFrom(tree), descendantsOf: descendantsFrom(tree), acceptScopeDriftRisk: true },
|
|
1552
1683
|
warnOnOptInSecurity: false,
|
|
1553
1684
|
});
|
|
1554
1685
|
try {
|
|
1555
1686
|
assert.deepEqual(await watched.authorizeMany(alice, 'docs:write', []), []);
|
|
1556
1687
|
assert.deepEqual(touched, []);
|
|
1557
1688
|
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
|
-
});
|
|
1689
|
+
// Qué pasa cuando el ÁRBOL del consumidor falla en una posición no se
|
|
1690
|
+
// juzga aquí: es el par de capacidad `hierarchyFacts` (3b-2k · K1 ·
|
|
1691
|
+
// R2 (b)). Con el árbol en manos del consumidor lanza entero (503);
|
|
1692
|
+
// con el árbol como hechos del backend, `authorize` ya no lo necesita
|
|
1693
|
+
// y responde — que es justo la propiedad que este modo compra.
|
|
1694
|
+
//
|
|
1568
1695
|
// Identidad inválida en cualquier posición: 422 antes de tocar nada.
|
|
1569
1696
|
touched.length = 0;
|
|
1570
1697
|
await rejectsWith(assert, () => watched.authorizeMany(alice, 'docs:write', [orgA, { type: 'app', uuid: 'X' }]), {
|
|
@@ -1582,8 +1709,9 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
1582
1709
|
}
|
|
1583
1710
|
});
|
|
1584
1711
|
// ── Concurrencia (2.5 · J4) ──────────────────────────────────────────
|
|
1585
|
-
// Sin `{trx}` en
|
|
1586
|
-
//
|
|
1712
|
+
// Sin `{trx}` en estos casos (la escritura en la transacción del
|
|
1713
|
+
// llamante tiene su par, `transactionalWrites`, abajo): lo que se fija
|
|
1714
|
+
// es que dos escrituras solapadas nunca dejan un
|
|
1587
1715
|
// estado que ninguna de las dos habría dejado sola. Con el harness en
|
|
1588
1716
|
// memoria (pool 1) se solapan a nivel de consulta; con `sqlite-file`, PG
|
|
1589
1717
|
// y MySQL (pool ≥ 2) a nivel de conexión.
|
|
@@ -2096,158 +2224,86 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
2096
2224
|
assert.isFalse(await driver.hasRole(alice, 'lead', unitA1));
|
|
2097
2225
|
assert.isFalse(await driver.hasRole(alice, 'lead', unitA1x));
|
|
2098
2226
|
});
|
|
2099
|
-
since('2.2', 'scopes.detached purga
|
|
2100
|
-
//
|
|
2101
|
-
//
|
|
2102
|
-
//
|
|
2103
|
-
//
|
|
2104
|
-
//
|
|
2227
|
+
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, }) => {
|
|
2228
|
+
// 3b-0 · Z1. Entre 3D y 3G `scopes.detached` arrastraba además los
|
|
2229
|
+
// roles LOCALES del owner (y, con `descendantsOf`, los del
|
|
2230
|
+
// subárbol), con policy de rango, degradación y un retorno que
|
|
2231
|
+
// contaba lo purgado. Cinco lotes la tocaron y TRES de las cuatro
|
|
2232
|
+
// regresiones de la Fase 3 nacieron ahí, siempre por COMPOSICIÓN
|
|
2233
|
+
// (3E · P3 + 3F · S1/S2 ⇒ 3G · W1: un `detached` de un ancestro
|
|
2234
|
+
// desconocido destruía roles de descendientes VIVOS). Lo que este
|
|
2235
|
+
// caso fija es que esa superficie NO VUELVE: el catálogo no se
|
|
2236
|
+
// escribe desde el árbol.
|
|
2105
2237
|
const authz = managerOver();
|
|
2106
2238
|
const alice = subject();
|
|
2107
2239
|
const orgA = await orgUnder(tree, APP_SCOPE);
|
|
2108
2240
|
const unitA1 = await unitUnder(tree, orgA);
|
|
2109
2241
|
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.
|
|
2242
|
+
const propio = await localRole(unitA1, { slug: 'propio', scopeType: 'unit', permissions: ['docs:write'] });
|
|
2116
2243
|
const delNieto = await localRole(unitA1x, { slug: 'nieto', scopeType: 'unit', permissions: ['docs:read'] });
|
|
2117
|
-
await driver.grant(alice, '
|
|
2118
|
-
await driver.grant(alice, 'huerfano', unitA1x);
|
|
2244
|
+
await driver.grant(alice, 'propio', unitA1);
|
|
2119
2245
|
await driver.grant(alice, 'nieto', unitA1x);
|
|
2120
|
-
await driver.
|
|
2121
|
-
assert.
|
|
2122
|
-
await
|
|
2123
|
-
await
|
|
2246
|
+
assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1));
|
|
2247
|
+
assert.isUndefined(await authz.scopes.detached(unitA1), 'no devuelve nada que declarar a medias');
|
|
2248
|
+
assert.deepEqual(await driver.listRoles(alice, unitA1), [], 'los HECHOS del scope, purgados');
|
|
2249
|
+
assert.isFalse(await driver.authorize(alice, 'docs:write', unitA1));
|
|
2124
2250
|
const view = await new CatalogCache().view();
|
|
2125
|
-
assert.
|
|
2126
|
-
assert.
|
|
2127
|
-
assert.
|
|
2128
|
-
|
|
2129
|
-
|
|
2251
|
+
assert.isNotNull(view.roleByUuid(propio), 'la fila del rol NO se toca: el catálogo no se escribe desde el árbol');
|
|
2252
|
+
assert.isNotNull(view.roleByUuid(delNieto), 'ni la del descendiente');
|
|
2253
|
+
assert.equal(await linksOf(propio), 1, 'ni sus vínculos');
|
|
2254
|
+
// Dormido: con el owner fuera del árbol el rol no concede, no es
|
|
2255
|
+
// membresía y no se puede asignar (invariante 18, sin cambios).
|
|
2256
|
+
await tree.detach(unitA1);
|
|
2257
|
+
assert.isFalse(await driver.authorize(alice, 'docs:read', unitA1x), 'el nieto pierde su cadena y con ella todo');
|
|
2258
|
+
await rejectsWith(assert, () => authz.grant(alice, { uuid: propio }, unitA1x), { status: 422, code: 'E_AUTHZ_UNKNOWN_SCOPE' });
|
|
2259
|
+
// La salida es de PLATAFORMA y en dos tiempos: primero se mira.
|
|
2260
|
+
const seco = await authz.pruneOrphanRoles();
|
|
2261
|
+
assert.deepEqual(seco.orphans.map((o) => o.role.uuid).sort(), [delNieto, propio].sort());
|
|
2262
|
+
assert.deepEqual(seco.purged, [], 'el default no escribe');
|
|
2263
|
+
assert.isNotNull((await new CatalogCache().view()).roleByUuid(propio));
|
|
2264
|
+
// 3b-0b · AA1: los dos tenían hechos, y los hechos siguen ahí (el
|
|
2265
|
+
// `detached` purgó los del scope, no los del rol), así que el
|
|
2266
|
+
// barrido AVISA de que no está recogiendo basura inerte.
|
|
2267
|
+
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');
|
|
2268
|
+
// 3b-0b · AA2: aquí TODOS los owners locales están huérfanos, que
|
|
2269
|
+
// es la firma de un resolutor ciego. Se rechaza sin borrar nada.
|
|
2270
|
+
assert.isTrue(seco.massPurge);
|
|
2271
|
+
await rejectsWith(assert, () => authz.pruneOrphanRoles({ force: true }), { status: 500, code: 'E_AUTHZ_MASS_PURGE_REFUSED' });
|
|
2272
|
+
assert.isNotNull((await new CatalogCache().view()).roleByUuid(propio), 'el rechazo no borra nada');
|
|
2273
|
+
const purga = await authz.pruneOrphanRoles({ force: true, allowMassPurge: true });
|
|
2274
|
+
assert.deepEqual(purga.purged.map((r) => r.uuid).sort(), [delNieto, propio].sort());
|
|
2275
|
+
assert.deepEqual(purga.skipped, [], 'ningún owner volvió durante la pasada');
|
|
2276
|
+
const tras = await new CatalogCache().view();
|
|
2277
|
+
assert.isNull(tras.roleByUuid(propio), 'ahora sí: la fila se va');
|
|
2278
|
+
assert.deepEqual(tras.rolesNamed('propio', 'unit'), [], 'y ese (slug, nivel) vuelve a estar libre');
|
|
2279
|
+
assert.equal(await linksOf(propio), 0, 'sin vínculos huérfanos');
|
|
2130
2280
|
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.
|
|
2281
|
+
// Nada resucita por el slug: un rol nuevo con ese nombre en otro
|
|
2282
|
+
// árbol no hereda lo que concedía el purgado.
|
|
2135
2283
|
const orgB = await orgUnder(tree, APP_SCOPE);
|
|
2136
2284
|
const unitB1 = await unitUnder(tree, orgB);
|
|
2137
|
-
await localRole(orgB, { slug: '
|
|
2138
|
-
await driver.grant(alice, '
|
|
2285
|
+
await localRole(orgB, { slug: 'propio', scopeType: 'unit', permissions: ['docs:read'] });
|
|
2286
|
+
await driver.grant(alice, 'propio', unitB1);
|
|
2139
2287
|
assert.isTrue(await driver.authorize(alice, 'docs:read', unitB1));
|
|
2140
2288
|
assert.isFalse(await driver.authorize(alice, 'docs:write', unitB1), 'el rol viejo no revive por el slug');
|
|
2141
2289
|
});
|
|
2142
2290
|
// ── Casos de COMPOSICIÓN (3G · W4) ────────────────────────────────
|
|
2143
2291
|
// Las tres regresiones seguidas de 3E/3F/3G nacieron de componer
|
|
2144
2292
|
// 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
|
-
});
|
|
2293
|
+
// tenía JUNTO a las otras. De los CUATRO que se escribieron en 3G,
|
|
2294
|
+
// tres eran del `scopes.detached` que purgaba roles (ancestro
|
|
2295
|
+
// desconocido con descendientes vivos, cota superada con rango
|
|
2296
|
+
// insuficiente, `descendantsOf` que falla). Con 3b-0 · Z1 esa
|
|
2297
|
+
// composición ya no existe —la operación no toca el catálogo, no
|
|
2298
|
+
// mide rango y no enumera el subárbol—, así que los tres se
|
|
2299
|
+
// borraron: no hay piezas que componer. El cuarto sigue vivo, en el
|
|
2300
|
+
// par `listDenies` con el resto de la delegación (`defineScopedRole`
|
|
2301
|
+
// que ensombrece con el subárbol sin enumerar), porque ahí sí siguen
|
|
2302
|
+
// componiéndose la regla de nivel degradada y el rango del
|
|
2303
|
+
// ensombrecido.
|
|
2248
2304
|
},
|
|
2249
2305
|
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
|
|
2306
|
+
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
2307
|
// 3B · B4 + 3E · P4. Hasta 3E este caso FIJABA UN CALLEJÓN SIN
|
|
2252
2308
|
// SALIDA como comportamiento esperado: `defineScopedRole` creaba el
|
|
2253
2309
|
// rol contra un driver que jamás podría borrarlo y aquí se afirmaba
|
|
@@ -2279,22 +2335,26 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
2279
2335
|
assert.deepEqual(await driver.listRoles(alice, unitA1), ['lead']);
|
|
2280
2336
|
assert.isNotNull((await new CatalogCache().view()).roleByUuid(lead), 'el rol sigue en el catálogo');
|
|
2281
2337
|
assert.equal(await linksOf(lead), 1, 'y sus vínculos');
|
|
2282
|
-
// (3)
|
|
2283
|
-
//
|
|
2284
|
-
//
|
|
2338
|
+
// (3) 3b-0 · Z1: `scopes.detached` NO depende de `purgeRole`, ni
|
|
2339
|
+
// siquiera sobre un scope que ES owner de roles locales: purga
|
|
2340
|
+
// los HECHOS y no toca el catálogo (invariante 11). Hasta 3G
|
|
2341
|
+
// respondía 500 aquí, y el callejón —un scope cuyos hechos
|
|
2342
|
+
// nadie podía purgar— era real hasta que 3E · P4 lo cerró por
|
|
2343
|
+
// la otra punta. Ahora no hay callejón que cerrar.
|
|
2285
2344
|
const owner = await unitUnder(tree, orgA);
|
|
2286
2345
|
const propio = await localRole(owner, { slug: 'propio', scopeType: 'unit', permissions: ['docs:write'] });
|
|
2287
2346
|
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
2347
|
await authz.scopes.detached(owner);
|
|
2296
2348
|
assert.deepEqual(await driver.listRoles(alice, owner), [], 'los hechos del scope, purgados');
|
|
2297
2349
|
assert.isFalse(await driver.authorize(alice, 'docs:write', owner));
|
|
2350
|
+
assert.isNotNull((await new CatalogCache().view()).roleByUuid(propio), 'y el rol local sigue: purgarlo es otra operación');
|
|
2351
|
+
// (4) La que SÍ necesita `purgeRole` lo dice antes de leer nada:
|
|
2352
|
+
// `pruneOrphanRoles` (el motor de `authz:catalog:prune-orphans`).
|
|
2353
|
+
await rejectsWith(assert, () => authz.pruneOrphanRoles(), { status: 500, code: 'E_AUTHZ_UNSUPPORTED' });
|
|
2354
|
+
// Y la salida sigue siendo la de siempre: el catálogo es SQL y
|
|
2355
|
+
// es de la plataforma.
|
|
2356
|
+
await forgetRoleByHand(propio);
|
|
2357
|
+
assert.isNull((await new CatalogCache().view()).roleByUuid(propio));
|
|
2298
2358
|
// (5) Un scope SIN roles locales propios se purga siempre.
|
|
2299
2359
|
const simple = await unitUnder(tree, orgA);
|
|
2300
2360
|
await driver.grant(alice, 'unit-editor', simple);
|
|
@@ -2303,6 +2363,180 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
2303
2363
|
});
|
|
2304
2364
|
},
|
|
2305
2365
|
});
|
|
2366
|
+
// ── Par de capacidad `countRoleAssignments` (3b-2j) ───────────────────
|
|
2367
|
+
// `pruneOrphanRoles().stillGranting` avisa de que una purga puede estar
|
|
2368
|
+
// revocando permisos VIVOS (3b-0b · AA1) y su contrato publicado es
|
|
2369
|
+
// «falso ⇒ este rol seguro que no concede». Hasta 3b-2j lo calculaba el
|
|
2370
|
+
// barrido contando `authz_assignments` —la tabla del driver `database`—,
|
|
2371
|
+
// así que con un driver cuyos hechos viven en otro sitio (`openfga` en
|
|
2372
|
+
// modo `facts`, el store) era SIEMPRE `false`, justo antes de un borrado
|
|
2373
|
+
// destructivo. Contar los hechos de un rol pasa a ser una pregunta del
|
|
2374
|
+
// PUERTO. Un driver que la responda declara `countRoleAssignments: true`
|
|
2375
|
+
// y se le juzga el conteo; uno que no, declara `false` y se le juzga que
|
|
2376
|
+
// el barrido lo DIGA (`undefined`) en vez de degradarlo a «no concede».
|
|
2377
|
+
// Nunca un skip. **Es breaking para un driver de terceros de 2.2.**
|
|
2378
|
+
caseFor('countRoleAssignments', {
|
|
2379
|
+
whenTrue: () => {
|
|
2380
|
+
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, }) => {
|
|
2381
|
+
// 3b-2j (decisión del dueño del 2026-08-31 (3)). Es el método que
|
|
2382
|
+
// hace VERDADERO el `stillGranting` de `pruneOrphanRoles` en los dos
|
|
2383
|
+
// drivers; aquí se juzga en el puerto, que es donde vive.
|
|
2384
|
+
assert.typeOf(driver.countRoleAssignments, 'function', 'countRoleAssignments: true exige el método en el puerto');
|
|
2385
|
+
const cuenta = (uuids) => driver.countRoleAssignments(uuids);
|
|
2386
|
+
const alice = subject();
|
|
2387
|
+
const bob = subject();
|
|
2388
|
+
const orgA = await orgUnder(tree, APP_SCOPE);
|
|
2389
|
+
const unitA1 = await unitUnder(tree, orgA);
|
|
2390
|
+
const unitA2 = await unitUnder(tree, orgA);
|
|
2391
|
+
const lead = await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'] });
|
|
2392
|
+
const mudo = await localRole(orgA, { slug: 'mudo', scopeType: 'unit', permissions: ['docs:read'] });
|
|
2393
|
+
assert.deepEqual(await cuenta([lead, mudo]), [0, 0], 'un rol recién definido no tiene hechos');
|
|
2394
|
+
assert.deepEqual(await cuenta([]), [], 'sin uuids no hay nada que contar (ni que preguntarle al backend)');
|
|
2395
|
+
// Hechos en DOS scopes y de dos holders: se cuentan todos, sin
|
|
2396
|
+
// herencia y sin re-resolver el scope de cada uno (es conservador).
|
|
2397
|
+
await driver.grant(alice, { uuid: lead }, unitA1);
|
|
2398
|
+
await driver.grant(bob, { uuid: lead }, unitA1);
|
|
2399
|
+
await driver.grant(alice, { uuid: lead }, unitA2);
|
|
2400
|
+
// Y uno CADUCADO, que no cuenta: la caducidad es estricta y es la
|
|
2401
|
+
// misma con la que responde `authorize`.
|
|
2402
|
+
await driver.grant(bob, { uuid: lead }, unitA2, { expiresAt: new Date(Date.now() - 60_000) });
|
|
2403
|
+
assert.isFalse(await driver.authorize(bob, 'docs:write', unitA2), 'el caducado no concede…');
|
|
2404
|
+
assert.deepEqual(await cuenta([lead, mudo]), [3, 0], '…y tampoco cuenta');
|
|
2405
|
+
// El orden es el de la pregunta, no el del backend.
|
|
2406
|
+
assert.deepEqual(await cuenta([mudo, lead]), [0, 3], 'la respuesta va POR POSICIÓN');
|
|
2407
|
+
// Un uuid que el motor no conoce es 0 (es un conteo de hechos, no
|
|
2408
|
+
// una consulta al catálogo) y uno mal formado es 422.
|
|
2409
|
+
assert.deepEqual(await cuenta([uuidv7()]), [0], 'un rol que el motor no conoce no tiene hechos');
|
|
2410
|
+
await rejectsWith(assert, () => cuenta([lead, 'lead']), { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' });
|
|
2411
|
+
// Lo que baja el contador es que el hecho DEJE de existir.
|
|
2412
|
+
await driver.revoke(alice, { uuid: lead }, unitA2);
|
|
2413
|
+
assert.deepEqual(await cuenta([lead]), [2]);
|
|
2414
|
+
await driver.revoke(alice, { uuid: lead }, unitA1);
|
|
2415
|
+
await driver.revoke(bob, { uuid: lead }, unitA1);
|
|
2416
|
+
assert.deepEqual(await cuenta([lead]), [0], 'sin hechos vigentes: el barrido puede decir «no concede» con verdad');
|
|
2417
|
+
});
|
|
2418
|
+
},
|
|
2419
|
+
whenFalse: () => {
|
|
2420
|
+
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, }) => {
|
|
2421
|
+
// La cara que un driver de terceros escrito para 2.2 estrena con
|
|
2422
|
+
// este lote (breaking del puerto, decisión del dueño del
|
|
2423
|
+
// 2026-08-31 (3) · consecuencia 1). El `false` de antes no era
|
|
2424
|
+
// conservador: era falso, y se leía justo antes de un borrado.
|
|
2425
|
+
assert.isUndefined(driver.countRoleAssignments, 'countRoleAssignments: false ⇒ el puerto no lo trae; un método que solo lanza al llamarlo no deja al manager protegerte');
|
|
2426
|
+
const alice = subject();
|
|
2427
|
+
const orgA = await orgUnder(tree, APP_SCOPE);
|
|
2428
|
+
const unitA1 = await unitUnder(tree, orgA);
|
|
2429
|
+
const unitA1x = await unitUnder(tree, unitA1);
|
|
2430
|
+
const lead = await localRole(unitA1, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'] });
|
|
2431
|
+
await driver.grant(alice, { uuid: lead }, unitA1x);
|
|
2432
|
+
assert.isTrue(await driver.authorize(alice, 'docs:write', unitA1x), 'el rol concede HOY');
|
|
2433
|
+
// El driver de terceros COMPLETO: sabe purgar (si no,
|
|
2434
|
+
// `pruneOrphanRoles` es 500 antes de leer nada — par `purgeRole`) y
|
|
2435
|
+
// no sabe contar. Su `purgeRole` no debe llegar a llamarse: el
|
|
2436
|
+
// default es `--dry-run`.
|
|
2437
|
+
const tercero = Object.create(driver);
|
|
2438
|
+
Object.defineProperty(tercero, 'purgeRole', {
|
|
2439
|
+
value: async () => assert.fail('el dry-run no purga'),
|
|
2440
|
+
enumerable: false,
|
|
2441
|
+
});
|
|
2442
|
+
const authz = managerOver({}, tercero);
|
|
2443
|
+
// El owner sale del árbol; el NIETO conserva su ruta materializada
|
|
2444
|
+
// por él, así que el rol sigue concediendo (3b-0b · AA1).
|
|
2445
|
+
await tree.detach(unitA1);
|
|
2446
|
+
const seco = await authz.pruneOrphanRoles();
|
|
2447
|
+
assert.deepEqual(seco.orphans.map((o) => o.role.uuid), [lead]);
|
|
2448
|
+
assert.isUndefined(seco.orphans[0].stillGranting, 'sin el método del puerto: no se sabe');
|
|
2449
|
+
assert.notStrictEqual(seco.orphans[0].stillGranting, false, '«no lo sé» NUNCA es «no concede»');
|
|
2450
|
+
assert.isUndefined(seco.orphans[0].assignments, 'ni se inventa el contador que lo respaldaría');
|
|
2451
|
+
assert.deepEqual(seco.purged, [], 'y el dry-run no ha borrado nada');
|
|
2452
|
+
});
|
|
2453
|
+
},
|
|
2454
|
+
});
|
|
2455
|
+
// ── Par de capacidad `enumerateFacts` (3b-3b) ────────────────────────
|
|
2456
|
+
// Ser el ORIGEN de `authz:reconcile`. El 3a dejó `reconcile` con el patrón
|
|
2457
|
+
// de método opcional del puerto (`E_AUTHZ_UNSUPPORTED`) y sin capacidad
|
|
2458
|
+
// nueva; ésta es la entrada que faltaba, y tiene las DOS caras con caso
|
|
2459
|
+
// porque los dos drivers del paquete las ocupan: `openfga` sabe entregar
|
|
2460
|
+
// sus hechos (viven en el store y solo él sabe volverlos a
|
|
2461
|
+
// `(holder, rol, scope)`) y `database` no lo trae a propósito (sus hechos
|
|
2462
|
+
// SON `authz_assignments`/`authz_denies`, el esquema publicado).
|
|
2463
|
+
caseFor('enumerateFacts', {
|
|
2464
|
+
whenTrue: () => {
|
|
2465
|
+
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, }) => {
|
|
2466
|
+
const alice = subject();
|
|
2467
|
+
const bob = subject();
|
|
2468
|
+
const org = await orgUnder(tree, APP_SCOPE);
|
|
2469
|
+
const vencida = new Date(Date.now() - 60_000);
|
|
2470
|
+
await driver.grant(alice, 'org-editor', org);
|
|
2471
|
+
await driver.grant(bob, 'org-editor', org, { expiresAt: vencida });
|
|
2472
|
+
await driver.deny(alice, 'docs:write', org);
|
|
2473
|
+
const todos = [];
|
|
2474
|
+
let after;
|
|
2475
|
+
const cursores = new Set();
|
|
2476
|
+
for (let page = 0; page < 100; page++) {
|
|
2477
|
+
const got = await driver.enumerateFacts({ limit: 1, after });
|
|
2478
|
+
assert.isAtMost(got.facts.length, 1, 'nunca más de `limit` por página');
|
|
2479
|
+
todos.push(...got.facts);
|
|
2480
|
+
if (!got.cursor)
|
|
2481
|
+
break;
|
|
2482
|
+
assert.isFalse(cursores.has(got.cursor), 'el cursor tiene que AVANZAR, nunca repetirse');
|
|
2483
|
+
cursores.add(got.cursor);
|
|
2484
|
+
after = got.cursor;
|
|
2485
|
+
}
|
|
2486
|
+
const asignaciones = todos.filter((f) => f.kind === 'assignment');
|
|
2487
|
+
const denies = todos.filter((f) => f.kind === 'deny');
|
|
2488
|
+
assert.lengthOf(denies, 1, 'el deny es un hecho y sale');
|
|
2489
|
+
assert.equal(denies[0].permission, 'docs:write');
|
|
2490
|
+
assert.deepEqual(scopeKeys([denies[0].scope]), scopeKeys([org]));
|
|
2491
|
+
assert.lengthOf(asignaciones, 2, 'la CADUCADA también sale: filtrarla la haría desaparecer sin contarla');
|
|
2492
|
+
const deBob = asignaciones.find((f) => f.holder.uuid === bob.uuid);
|
|
2493
|
+
assert.equal(deBob.expiresAt?.getTime(), vencida.getTime(), 'y con su instante, para que el destino la cuente');
|
|
2494
|
+
assert.isString(deBob.roleUuid, 'la identidad del rol es el uuid, nunca el slug');
|
|
2495
|
+
assert.isString(deBob.detail, 'y viene nombrada: un motivo sin la fila no se arregla');
|
|
2496
|
+
// Y con una página grande sale exactamente el mismo conjunto (el
|
|
2497
|
+
// puerto promete «como mucho `limit`», no «exactamente `limit`»: un
|
|
2498
|
+
// backend con su propio tope de página lo respeta y da cursor).
|
|
2499
|
+
const grande = [];
|
|
2500
|
+
let siguiente;
|
|
2501
|
+
for (let page = 0; page < 100; page++) {
|
|
2502
|
+
const got = await driver.enumerateFacts({ limit: 1_000, after: siguiente });
|
|
2503
|
+
assert.isAtMost(got.facts.length, 1_000);
|
|
2504
|
+
grande.push(...got.facts);
|
|
2505
|
+
if (!got.cursor)
|
|
2506
|
+
break;
|
|
2507
|
+
siguiente = got.cursor;
|
|
2508
|
+
}
|
|
2509
|
+
assert.lengthOf(grande, todos.length);
|
|
2510
|
+
});
|
|
2511
|
+
},
|
|
2512
|
+
whenFalse: () => {
|
|
2513
|
+
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, }) => {
|
|
2514
|
+
assert.isUndefined(driver.enumerateFacts, 'enumerateFacts: false ⇒ el puerto no lo trae');
|
|
2515
|
+
// Un destino de laboratorio que SÍ sabe reconstruirse: lo que se
|
|
2516
|
+
// juzga es que la pasada muera nombrando al ORIGEN, no al destino.
|
|
2517
|
+
const destino = {
|
|
2518
|
+
capabilities: driver.capabilities,
|
|
2519
|
+
async reconcile(source) {
|
|
2520
|
+
await source.facts({ limit: 10 });
|
|
2521
|
+
assert.fail('no debería haber llegado a leer hechos');
|
|
2522
|
+
},
|
|
2523
|
+
};
|
|
2524
|
+
const authz = managerOver({
|
|
2525
|
+
drivers: { [harness.name]: () => driver, destino: () => destino },
|
|
2526
|
+
scopes: {
|
|
2527
|
+
resolveChain: resolveChainFrom(tree),
|
|
2528
|
+
descendantsOf: descendantsFrom(tree),
|
|
2529
|
+
enumerateEdges: async () => ({ edges: [] }),
|
|
2530
|
+
},
|
|
2531
|
+
});
|
|
2532
|
+
const error = await rejectsWith(assert, () => authz.reconcile({ to: 'destino' }), {
|
|
2533
|
+
status: 500,
|
|
2534
|
+
code: 'E_AUTHZ_UNSUPPORTED',
|
|
2535
|
+
});
|
|
2536
|
+
assert.include(String(error.message), 'enumerateFacts');
|
|
2537
|
+
});
|
|
2538
|
+
},
|
|
2539
|
+
});
|
|
2306
2540
|
// ── Par de capacidad `listDenies` (2E · I5) ──────────────────────────
|
|
2307
2541
|
// Todo lo que RESTA denies (`listDenies`, `effectivePermissions`,
|
|
2308
2542
|
// `authorizedScopes`, y F1, que observa el catálogo a través de
|
|
@@ -3042,9 +3276,234 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
3042
3276
|
// `hierarchyFacts: false` → lo observan N1b/N2/N4/N5 (arriba): el driver
|
|
3043
3277
|
// responde según el árbol que le resuelve el consumidor, y `tree.move`
|
|
3044
3278
|
// cambia la respuesta sin escritura. `true` llega en Fase 3b.
|
|
3045
|
-
// `injectableClock` tiene su par arriba (2.5 · J1). `
|
|
3046
|
-
//
|
|
3047
|
-
//
|
|
3279
|
+
// `injectableClock` tiene su par arriba (2.5 · J1). `transactionalWrites`
|
|
3280
|
+
// (L-2) tiene su par al final: la cara `false` hoy, la `true` con L-3.
|
|
3281
|
+
// ── L-2 · `transactionalWrites` (panel `{trx}`, (C)) ──────────────────
|
|
3282
|
+
// Las DOS caras son obligatorias en este par (`BOTH_FACES_REQUIRED`): una
|
|
3283
|
+
// `whenFalse` ausente se saltaba en silencio y «rechazo activo» sería
|
|
3284
|
+
// una etiqueta. Lo que la cara `false` fija es la PUERTA 1 (por llamada,
|
|
3285
|
+
// siempre activa) y la PUERTA 2 (por config, opt-in), las dos con el
|
|
3286
|
+
// manager construido sobre el driver del harness, en TODOS los niveles:
|
|
3287
|
+
// son composición del manager sobre `driver.capabilities`, así que un
|
|
3288
|
+
// driver de terceros de nivel `core` también las observa.
|
|
3289
|
+
caseFor('transactionalWrites', {
|
|
3290
|
+
// L-3: la cara `true`. Lo que se juzga es «los dos o ninguno con TU
|
|
3291
|
+
// transacción» POR CENSO: el rollback deja CERO filas nuevas (no solo
|
|
3292
|
+
// `authorize` false — una respuesta puede salir bien con la fila viva),
|
|
3293
|
+
// el commit las deja escritas, y mientras la transacción está abierta
|
|
3294
|
+
// la autoridad (que lee por la conexión del motor) no ve nada. Y la
|
|
3295
|
+
// otra mitad de la regla: una transacción que NO es la del driver no
|
|
3296
|
+
// recibe ni una sentencia. Cuatro escrituras, las dos caras cada una.
|
|
3297
|
+
whenTrue: () => {
|
|
3298
|
+
test('transactionalWrites: true · grant/revoke/deny/removeDeny con { transaction } + rollback ⇒ authorize sin cambio Y CERO filas (censo); + commit ⇒ aplicado; una transacción ajena, un cliente sin transacción o el `db` entero ⇒ 500 E_AUTHZ_CONFIG sin UNA sentencia por ella', async ({ assert, }) => {
|
|
3299
|
+
assert.equal(driver.capabilities?.transactionalWrites, true, 'el harness declara transactionalWrites: true y el driver no: declara lo observable');
|
|
3300
|
+
const tx = harness.transactions ?? lucidContractTransactions();
|
|
3301
|
+
const alice = subject();
|
|
3302
|
+
const org = await orgUnder(tree, APP_SCOPE);
|
|
3303
|
+
const authz = managerOver({});
|
|
3304
|
+
const can = () => driver.authorize(alice, 'docs:read', org);
|
|
3305
|
+
const census = () => tx.census(alice);
|
|
3306
|
+
// Una transacción que se queda abierta tras una aserción fallida
|
|
3307
|
+
// retiene una conexión del pool y arrastra a los casos siguientes:
|
|
3308
|
+
// el rollback va en `finally`, y el commit solo si nada falló.
|
|
3309
|
+
const rolledBack = async (fn) => {
|
|
3310
|
+
const t = await tx.begin();
|
|
3311
|
+
try {
|
|
3312
|
+
await fn(t.client);
|
|
3313
|
+
}
|
|
3314
|
+
finally {
|
|
3315
|
+
await t.rollback();
|
|
3316
|
+
}
|
|
3317
|
+
};
|
|
3318
|
+
const committed = async (fn) => {
|
|
3319
|
+
const t = await tx.begin();
|
|
3320
|
+
try {
|
|
3321
|
+
await fn(t.client);
|
|
3322
|
+
}
|
|
3323
|
+
catch (error) {
|
|
3324
|
+
await t.rollback();
|
|
3325
|
+
throw error;
|
|
3326
|
+
}
|
|
3327
|
+
await t.commit();
|
|
3328
|
+
};
|
|
3329
|
+
// grant: dentro no se ve desde fuera; rollback ⇒ nada; commit ⇒ concede.
|
|
3330
|
+
let seenInside;
|
|
3331
|
+
await rolledBack(async (transaction) => {
|
|
3332
|
+
await authz.grant(alice, 'org-editor', org, { transaction });
|
|
3333
|
+
seenInside = await can();
|
|
3334
|
+
});
|
|
3335
|
+
assert.deepEqual(await census(), { assignments: 0, denies: 0 }, 'ROJO: la asignación sobrevivió al rollback (la escritura NO fue por la transacción del llamante)');
|
|
3336
|
+
assert.isFalse(await can(), 'grant + rollback ⇒ no concede');
|
|
3337
|
+
assert.isFalse(seenInside, 'la autoridad lee por la conexión del motor: el grant sin confirmar no concede');
|
|
3338
|
+
await committed(async (transaction) => {
|
|
3339
|
+
await authz.grant(alice, 'org-editor', org, { transaction });
|
|
3340
|
+
});
|
|
3341
|
+
assert.isTrue(await can(), 'grant + commit ⇒ concede');
|
|
3342
|
+
assert.deepEqual(await census(), { assignments: 1, denies: 0 });
|
|
3343
|
+
// revoke: rollback ⇒ la asignación sigue; commit ⇒ se va.
|
|
3344
|
+
await rolledBack(async (transaction) => {
|
|
3345
|
+
await authz.revoke(alice, 'org-editor', org, { transaction });
|
|
3346
|
+
seenInside = await can();
|
|
3347
|
+
});
|
|
3348
|
+
assert.deepEqual(await census(), { assignments: 1, denies: 0 }, 'ROJO: el revoke sobrevivió al rollback');
|
|
3349
|
+
assert.isTrue(await can(), 'revoke + rollback ⇒ sigue concediendo');
|
|
3350
|
+
assert.isTrue(seenInside, 'el revoke sin confirmar no se ve desde la conexión del motor');
|
|
3351
|
+
await committed(async (transaction) => {
|
|
3352
|
+
await authz.revoke(alice, 'org-editor', org, { transaction });
|
|
3353
|
+
});
|
|
3354
|
+
assert.isFalse(await can(), 'revoke + commit ⇒ no concede');
|
|
3355
|
+
assert.deepEqual(await census(), { assignments: 0, denies: 0 });
|
|
3356
|
+
// deny (sobre un grant confirmado): rollback ⇒ sigue concediendo; commit ⇒ deniega.
|
|
3357
|
+
await authz.grant(alice, 'org-editor', org);
|
|
3358
|
+
await rolledBack(async (transaction) => {
|
|
3359
|
+
await authz.deny(alice, 'docs:read', org, { transaction });
|
|
3360
|
+
seenInside = await can();
|
|
3361
|
+
});
|
|
3362
|
+
assert.deepEqual(await census(), { assignments: 1, denies: 0 }, 'ROJO: el deny sobrevivió al rollback');
|
|
3363
|
+
assert.isTrue(await can(), 'deny + rollback ⇒ sigue concediendo');
|
|
3364
|
+
assert.isTrue(seenInside, 'el deny sin confirmar no se ve desde la conexión del motor');
|
|
3365
|
+
await committed(async (transaction) => {
|
|
3366
|
+
await authz.deny(alice, 'docs:read', org, { transaction });
|
|
3367
|
+
});
|
|
3368
|
+
assert.isFalse(await can(), 'deny + commit ⇒ deniega');
|
|
3369
|
+
assert.deepEqual(await census(), { assignments: 1, denies: 1 });
|
|
3370
|
+
// removeDeny: rollback ⇒ el deny sigue; commit ⇒ restaura.
|
|
3371
|
+
await rolledBack(async (transaction) => {
|
|
3372
|
+
await authz.removeDeny(alice, 'docs:read', org, { transaction });
|
|
3373
|
+
seenInside = await can();
|
|
3374
|
+
});
|
|
3375
|
+
assert.deepEqual(await census(), { assignments: 1, denies: 1 }, 'ROJO: el removeDeny sobrevivió al rollback');
|
|
3376
|
+
assert.isFalse(await can(), 'removeDeny + rollback ⇒ sigue denegando');
|
|
3377
|
+
assert.isFalse(seenInside, 'el removeDeny sin confirmar no se ve desde la conexión del motor');
|
|
3378
|
+
await committed(async (transaction) => {
|
|
3379
|
+
await authz.removeDeny(alice, 'docs:read', org, { transaction });
|
|
3380
|
+
});
|
|
3381
|
+
assert.isTrue(await can(), 'removeDeny + commit ⇒ restaura');
|
|
3382
|
+
assert.deepEqual(await census(), { assignments: 1, denies: 0 });
|
|
3383
|
+
// Una transacción que NO es la del driver (`assertCallerTransaction`):
|
|
3384
|
+
// 500 `E_AUTHZ_CONFIG` y ni una sentencia por ella (espía).
|
|
3385
|
+
const statements = [];
|
|
3386
|
+
const spy = (shape) => new Proxy(shape, {
|
|
3387
|
+
get(target, prop) {
|
|
3388
|
+
if (prop === 'from' || prop === 'table' || prop === 'raw' || prop === 'query' || prop === 'knexQuery') {
|
|
3389
|
+
return (...args) => {
|
|
3390
|
+
statements.push(`${String(prop)}(${String(args[0] ?? '')})`);
|
|
3391
|
+
return { where: () => ({}), select: async () => [], insert: async () => [], delete: async () => 0 };
|
|
3392
|
+
};
|
|
3393
|
+
}
|
|
3394
|
+
return Reflect.get(target, prop);
|
|
3395
|
+
},
|
|
3396
|
+
});
|
|
3397
|
+
const foreign = spy({ isTransaction: true, connectionName: `not-${harness.name}-${uuidv7()}` });
|
|
3398
|
+
const notATransaction = spy({ isTransaction: false, connectionName: 'primary' });
|
|
3399
|
+
const wholeDb = spy({ connection() { }, primaryConnectionName: 'primary' });
|
|
3400
|
+
const bob = subject();
|
|
3401
|
+
for (const [label, transaction] of [
|
|
3402
|
+
['otra conexión', foreign],
|
|
3403
|
+
['un cliente sin transacción', notATransaction],
|
|
3404
|
+
['el db entero', wholeDb],
|
|
3405
|
+
]) {
|
|
3406
|
+
for (const [operation, run] of [
|
|
3407
|
+
['grant', () => authz.grant(bob, 'org-editor', org, { transaction })],
|
|
3408
|
+
['revoke', () => authz.revoke(bob, 'org-editor', org, { transaction })],
|
|
3409
|
+
['deny', () => authz.deny(bob, 'docs:read', org, { transaction })],
|
|
3410
|
+
['removeDeny', () => authz.removeDeny(bob, 'docs:read', org, { transaction })],
|
|
3411
|
+
]) {
|
|
3412
|
+
const error = await rejectsWith(assert, run, { status: 500, code: 'E_AUTHZ_CONFIG' });
|
|
3413
|
+
assert.include(error.message, operation, `${label}/${operation}: nombra la operación`);
|
|
3414
|
+
}
|
|
3415
|
+
}
|
|
3416
|
+
assert.deepEqual(statements, [], 'ni una sentencia por una transacción que no es la del driver');
|
|
3417
|
+
assert.deepEqual(await tx.census(bob), { assignments: 0, denies: 0 });
|
|
3418
|
+
assert.isFalse(await driver.authorize(bob, 'docs:read', org));
|
|
3419
|
+
})?.timeout(30_000);
|
|
3420
|
+
},
|
|
3421
|
+
whenFalse: () => {
|
|
3422
|
+
test('transactionalWrites: false · { transaction } en grant/revoke/deny/removeDeny es 500 E_AUTHZ_UNSUPPORTED nombrando driver y operación, con CERO llamadas al driver (espía); y requireTransactionalWrites: true ⇒ 500 E_AUTHZ_CONFIG al RESOLVER el driver', async ({ assert, }) => {
|
|
3423
|
+
// El harness declara `false`: el driver tiene que decir lo mismo
|
|
3424
|
+
// (el manager lee `driver.capabilities`, no el harness).
|
|
3425
|
+
assert.notEqual(driver.capabilities?.transactionalWrites, true, 'el harness declara transactionalWrites: false y el driver declara true: declara lo observable');
|
|
3426
|
+
const touched = [];
|
|
3427
|
+
const spied = new Proxy(driver, {
|
|
3428
|
+
get(target, prop, receiver) {
|
|
3429
|
+
const value = Reflect.get(target, prop, receiver);
|
|
3430
|
+
if (typeof value === 'function' && typeof prop === 'string') {
|
|
3431
|
+
return (...args) => {
|
|
3432
|
+
touched.push(prop);
|
|
3433
|
+
return value.apply(receiver, args);
|
|
3434
|
+
};
|
|
3435
|
+
}
|
|
3436
|
+
return value;
|
|
3437
|
+
},
|
|
3438
|
+
});
|
|
3439
|
+
// La forma de un `TransactionClientContract` de Lucid: la puerta
|
|
3440
|
+
// decide por la CAPACIDAD, antes de mirar qué transacción es.
|
|
3441
|
+
const trx = { from() { }, table() { }, isTransaction: true, connectionName: 'primary' };
|
|
3442
|
+
const alice = subject();
|
|
3443
|
+
const org = await orgUnder(tree, APP_SCOPE);
|
|
3444
|
+
const authz = managerOver({}, spied);
|
|
3445
|
+
const writes = [
|
|
3446
|
+
['grant', () => authz.grant(alice, 'org-editor', org, { transaction: trx })],
|
|
3447
|
+
['revoke', () => authz.revoke(alice, 'org-editor', org, { transaction: trx })],
|
|
3448
|
+
['deny', () => authz.deny(alice, 'docs:read', org, { transaction: trx })],
|
|
3449
|
+
['removeDeny', () => authz.removeDeny(alice, 'docs:read', org, { transaction: trx })],
|
|
3450
|
+
];
|
|
3451
|
+
for (const [operation, run] of writes) {
|
|
3452
|
+
const error = await rejectsWith(assert, run, { status: 500, code: 'E_AUTHZ_UNSUPPORTED' });
|
|
3453
|
+
assert.include(error.message, `'${harness.name}'`, `${operation}: nombra el driver`);
|
|
3454
|
+
assert.include(error.message, operation, `${operation}: nombra la operación`);
|
|
3455
|
+
assert.include(error.message, 'requireTransactionalWrites', `${operation}: la letra lleva la salida`);
|
|
3456
|
+
}
|
|
3457
|
+
assert.deepEqual(touched, [], 'cero llamadas al driver: el rechazo va ANTES del backend');
|
|
3458
|
+
// Sin `{ transaction }` la MISMA escritura entra: la puerta es del
|
|
3459
|
+
// parámetro, no del driver.
|
|
3460
|
+
await authz.grant(alice, 'org-editor', org);
|
|
3461
|
+
assert.isTrue(await driver.authorize(alice, 'docs:read', org));
|
|
3462
|
+
assert.include(touched, 'grant');
|
|
3463
|
+
// Puerta 2, opt-in: quien quiera fallar al ARRANCAR lo pide, y
|
|
3464
|
+
// entonces el driver ni se resuelve (tampoco para leer).
|
|
3465
|
+
const strict = managerOver({ requireTransactionalWrites: true }, spied);
|
|
3466
|
+
const atResolve = await rejectsWith(assert, () => strict.driver(), { status: 500, code: 'E_AUTHZ_CONFIG' });
|
|
3467
|
+
assert.include(atResolve.message, `'${harness.name}'`);
|
|
3468
|
+
assert.include(atResolve.message, 'transactionalWrites');
|
|
3469
|
+
await rejectsWith(assert, () => strict.authorize(alice, 'docs:read', org), { status: 500, code: 'E_AUTHZ_CONFIG' });
|
|
3470
|
+
await rejectsWith(assert, () => strict.grant(alice, 'org-editor', org), { status: 500, code: 'E_AUTHZ_CONFIG' });
|
|
3471
|
+
});
|
|
3472
|
+
},
|
|
3473
|
+
});
|
|
3474
|
+
// ── 3b-2e · E6 · `singleCheckAuthorize` ──────────────────────────────
|
|
3475
|
+
caseFor('singleCheckAuthorize', {
|
|
3476
|
+
whenTrue: () => {
|
|
3477
|
+
test('`authorize` no consulta el árbol del consumidor (0 resolveChain) y aun así hereda hacia abajo', async ({ assert, }) => {
|
|
3478
|
+
// El literal aprobado (panel 2, cruce 6): en modo `facts` la
|
|
3479
|
+
// DECISIÓN no pasa por el árbol del consumidor. Lo que sigue
|
|
3480
|
+
// pasando por él es la MEMBRESÍA (`roleInheritanceNative: false`,
|
|
3481
|
+
// su propio par), y por eso el titular «sin SQL» está prohibido.
|
|
3482
|
+
const alice = subject();
|
|
3483
|
+
const org = await orgUnder(tree, APP_SCOPE);
|
|
3484
|
+
const unit = await unitUnder(tree, org);
|
|
3485
|
+
await driver.grant(alice, 'org-editor', org);
|
|
3486
|
+
const abajo = await withChainSpy(() => driver.authorize(alice, 'docs:read', unit));
|
|
3487
|
+
assert.isTrue(abajo.result, 'la herencia la resuelve el backend');
|
|
3488
|
+
assert.equal(abajo.calls, 0, 'y sin preguntarle al árbol del consumidor ni una vez');
|
|
3489
|
+
const hermana = await orgUnder(tree, APP_SCOPE);
|
|
3490
|
+
const fuera = await withChainSpy(() => driver.authorize(alice, 'docs:read', hermana));
|
|
3491
|
+
assert.isFalse(fuera.result);
|
|
3492
|
+
assert.equal(fuera.calls, 0);
|
|
3493
|
+
});
|
|
3494
|
+
},
|
|
3495
|
+
whenFalse: () => {
|
|
3496
|
+
test('`authorize` resuelve la cadena con el árbol del consumidor (lo consulta al menos una vez)', async ({ assert, }) => {
|
|
3497
|
+
const alice = subject();
|
|
3498
|
+
const org = await orgUnder(tree, APP_SCOPE);
|
|
3499
|
+
const unit = await unitUnder(tree, org);
|
|
3500
|
+
await driver.grant(alice, 'org-editor', org);
|
|
3501
|
+
const { result, calls } = await withChainSpy(() => driver.authorize(alice, 'docs:read', unit));
|
|
3502
|
+
assert.isTrue(result);
|
|
3503
|
+
assert.isAbove(calls, 0);
|
|
3504
|
+
});
|
|
3505
|
+
},
|
|
3506
|
+
});
|
|
3048
3507
|
caseFor('hierarchyFacts', {
|
|
3049
3508
|
// Con el árbol en manos del consumidor (`resolveChain`), el árbol
|
|
3050
3509
|
// es una dependencia más de cada pregunta: su caída se clasifica como la
|
|
@@ -3071,6 +3530,263 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
3071
3530
|
}
|
|
3072
3531
|
assert.isTrue(await driver.authorize(alice, 'docs:read', org));
|
|
3073
3532
|
});
|
|
3533
|
+
/**
|
|
3534
|
+
* La otra mitad de R2 (b), en el nivel del MANAGER (3b-2k · K1).
|
|
3535
|
+
* Salió del caso general de `authorizeMany`, que lo afirmaba para
|
|
3536
|
+
* todo driver: con el árbol en manos del consumidor una posición que
|
|
3537
|
+
* no se puede resolver tumba la llamada ENTERA (nunca un array
|
|
3538
|
+
* parcial, que sería un `false` disfrazado de respuesta).
|
|
3539
|
+
*/
|
|
3540
|
+
since('2.1', 'authorizeMany con un scope cuyo árbol lanza: 503 E_AUTHZ_RESOLVER_FAILED entero, jamás un array parcial', async ({ assert, }) => {
|
|
3541
|
+
const authz = managerOver();
|
|
3542
|
+
const alice = subject();
|
|
3543
|
+
const orgA = await orgUnder(tree, APP_SCOPE);
|
|
3544
|
+
const orgB = await orgUnder(tree, APP_SCOPE);
|
|
3545
|
+
const unitB1 = await unitUnder(tree, orgB);
|
|
3546
|
+
await driver.grant(alice, 'org-editor', orgA);
|
|
3547
|
+
const original = tree.chainOf;
|
|
3548
|
+
tree.chainOf = async (scope) => {
|
|
3549
|
+
if (`${scope.type}:${scope.uuid}` === `${unitB1.type}:${unitB1.uuid}`)
|
|
3550
|
+
throw new Error('árbol caído');
|
|
3551
|
+
return original.call(tree, scope);
|
|
3552
|
+
};
|
|
3553
|
+
try {
|
|
3554
|
+
await rejectsWith(assert, () => authz.authorizeMany(alice, 'docs:write', [orgA, unitB1, orgB]), {
|
|
3555
|
+
status: 503,
|
|
3556
|
+
code: 'E_AUTHZ_RESOLVER_FAILED',
|
|
3557
|
+
});
|
|
3558
|
+
}
|
|
3559
|
+
finally {
|
|
3560
|
+
tree.chainOf = original;
|
|
3561
|
+
}
|
|
3562
|
+
});
|
|
3563
|
+
},
|
|
3564
|
+
/**
|
|
3565
|
+
* Con el árbol como HECHOS del backend, la caída del resolutor del
|
|
3566
|
+
* consumidor deja de tumbar la DECISIÓN: `authorize` sigue respondiendo
|
|
3567
|
+
* (es la ganancia de disponibilidad de este modo). Lo que NO sobrevive
|
|
3568
|
+
* es la membresía, que sigue pasando por el árbol (cruce 6): ahí el
|
|
3569
|
+
* 503 se conserva, porque un `false` sería fail-open.
|
|
3570
|
+
*/
|
|
3571
|
+
whenTrue: () => {
|
|
3572
|
+
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, }) => {
|
|
3573
|
+
// 3b-2k · K1 · R2 (b), con la ampliación del 🟡 5 del auditor R2:
|
|
3574
|
+
// no basta decir «authorize sobrevive». Lo que hay que decir, y lo
|
|
3575
|
+
// que este caso fija, es que sobrevive **concediendo** mientras
|
|
3576
|
+
// todo lo que quitaría ese acceso está caído — el paquete no
|
|
3577
|
+
// ofrece, mientras dura, ninguna forma de retirarlo. Es la
|
|
3578
|
+
// propiedad que el dueño compró (PDP autónomo) con su precio
|
|
3579
|
+
// escrito, no un fallo: cerrarla sería volver a meter
|
|
3580
|
+
// `resolveChain` en el camino caliente de `authorize`.
|
|
3581
|
+
const alice = subject();
|
|
3582
|
+
const org = await orgUnder(tree, APP_SCOPE);
|
|
3583
|
+
const unit = await unitUnder(tree, org);
|
|
3584
|
+
await driver.grant(alice, 'org-editor', org);
|
|
3585
|
+
assert.isTrue(await driver.authorize(alice, 'docs:read', unit));
|
|
3586
|
+
const original = tree.chainOf;
|
|
3587
|
+
tree.chainOf = async () => {
|
|
3588
|
+
throw new Error('el árbol del consumidor está caído');
|
|
3589
|
+
};
|
|
3590
|
+
const down = { status: 503, code: 'E_AUTHZ_RESOLVER_FAILED' };
|
|
3591
|
+
try {
|
|
3592
|
+
assert.isTrue(await driver.authorize(alice, 'docs:read', unit), 'la decisión no lo necesita');
|
|
3593
|
+
assert.isTrue(await driver.authorize(alice, 'docs:write', org), 'ni en el scope exacto');
|
|
3594
|
+
// Y ninguna de las salidas para quitarlo responde.
|
|
3595
|
+
for (const [label, call] of [
|
|
3596
|
+
['hasRole', () => driver.hasRole(alice, 'org-editor', unit)],
|
|
3597
|
+
['listRoles', () => driver.listRoles(alice, org)],
|
|
3598
|
+
['listRoleScopes', () => driver.listRoleScopes(alice, 'organization')],
|
|
3599
|
+
['listSubjects', () => driver.listSubjects('org-editor', org)],
|
|
3600
|
+
['listScopes', () => driver.listScopes(alice, 'docs:read')],
|
|
3601
|
+
['revoke', () => driver.revoke(alice, 'org-editor', org)],
|
|
3602
|
+
['deny', () => driver.deny(alice, 'docs:read', unit)],
|
|
3603
|
+
['removeDeny', () => driver.removeDeny(alice, 'docs:read', unit)],
|
|
3604
|
+
['purgeScope', () => driver.purgeScope(org)],
|
|
3605
|
+
]) {
|
|
3606
|
+
const error = await rejectsWith(assert, call, down);
|
|
3607
|
+
assert.equal(error?.code, down.code, label);
|
|
3608
|
+
}
|
|
3609
|
+
// La consecuencia, dicha entera: sigue concediendo.
|
|
3610
|
+
assert.isTrue(await driver.authorize(alice, 'docs:read', unit), 'concede y no se puede revocar');
|
|
3611
|
+
}
|
|
3612
|
+
finally {
|
|
3613
|
+
tree.chainOf = original;
|
|
3614
|
+
}
|
|
3615
|
+
// Con el árbol de vuelta, revocar vuelve a funcionar: la ventana es
|
|
3616
|
+
// exactamente la caída, no un estado que quede pegado.
|
|
3617
|
+
await driver.revoke(alice, 'org-editor', org);
|
|
3618
|
+
assert.isFalse(await driver.authorize(alice, 'docs:read', unit));
|
|
3619
|
+
});
|
|
3620
|
+
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, }) => {
|
|
3621
|
+
// La cara `true` de lo que el caso general de `authorizeMany`
|
|
3622
|
+
// afirmaba para todos (3b-2k · K1 · R2 (b)). No es un caso más
|
|
3623
|
+
// laxo: exige la respuesta EXACTA, la misma que daría el árbol del
|
|
3624
|
+
// store con el resolutor en pie.
|
|
3625
|
+
const authz = managerOver();
|
|
3626
|
+
const alice = subject();
|
|
3627
|
+
const orgA = await orgUnder(tree, APP_SCOPE);
|
|
3628
|
+
const orgB = await orgUnder(tree, APP_SCOPE);
|
|
3629
|
+
const unitB1 = await unitUnder(tree, orgB);
|
|
3630
|
+
await driver.grant(alice, 'org-editor', orgA);
|
|
3631
|
+
const esperado = await authz.authorizeMany(alice, 'docs:write', [orgA, unitB1, orgB]);
|
|
3632
|
+
assert.deepEqual(esperado, [true, false, false], 'precondición del caso');
|
|
3633
|
+
const original = tree.chainOf;
|
|
3634
|
+
tree.chainOf = async (scope) => {
|
|
3635
|
+
if (`${scope.type}:${scope.uuid}` === `${unitB1.type}:${unitB1.uuid}`)
|
|
3636
|
+
throw new Error('árbol caído');
|
|
3637
|
+
return original.call(tree, scope);
|
|
3638
|
+
};
|
|
3639
|
+
try {
|
|
3640
|
+
assert.deepEqual(await authz.authorizeMany(alice, 'docs:write', [orgA, unitB1, orgB]), esperado, 'la misma respuesta que con el árbol en pie');
|
|
3641
|
+
}
|
|
3642
|
+
finally {
|
|
3643
|
+
tree.chainOf = original;
|
|
3644
|
+
}
|
|
3645
|
+
});
|
|
3646
|
+
},
|
|
3647
|
+
});
|
|
3648
|
+
// ── 3b-2k · K1 · R2 (c) · `canonicalScopeReads` ──────────────────────
|
|
3649
|
+
caseFor('canonicalScopeReads', {
|
|
3650
|
+
whenTrue: () => {
|
|
3651
|
+
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, }) => {
|
|
3652
|
+
const alice = subject();
|
|
3653
|
+
const org = await orgUnder(tree, APP_SCOPE);
|
|
3654
|
+
const unit = await unitUnder(tree, org);
|
|
3655
|
+
await driver.grant(alice, 'unit-editor', unit);
|
|
3656
|
+
assert.isTrue(await driver.authorize(alice, 'docs:write', unit), 'precondición del caso');
|
|
3657
|
+
const alias = { type: 'unit', uuid: unit.uuid.replaceAll('-', '') };
|
|
3658
|
+
await withAliasFused(alias, unit, async () => {
|
|
3659
|
+
assert.isTrue(await driver.authorize(alice, 'docs:write', alias), 'con canonicalScopeReads: true la lectura usa chain[0] (invariante 17)');
|
|
3660
|
+
});
|
|
3661
|
+
});
|
|
3662
|
+
},
|
|
3663
|
+
whenFalse: () => {
|
|
3664
|
+
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, }) => {
|
|
3665
|
+
// 3b-2k · K1 · R2 (c). La divergencia es de LECTURA y de una sola
|
|
3666
|
+
// dirección: nunca convierte un `false` en `true` (no evade denies
|
|
3667
|
+
// ni concede de más), pero no es la misma respuesta que
|
|
3668
|
+
// `database`, y quien pasa uuids con otra ortografía que la de su
|
|
3669
|
+
// tabla se queda fuera sin ningún aviso. La cara fail-OPEN, que sí
|
|
3670
|
+
// era un bug, la cerró 3b-2h (🟠 3 del auditor R2): la escritura
|
|
3671
|
+
// canoniza, y este caso lo fija aquí para que la capacidad no se
|
|
3672
|
+
// lea como «en `facts` la ortografía da igual».
|
|
3673
|
+
const alice = subject();
|
|
3674
|
+
const org = await orgUnder(tree, APP_SCOPE);
|
|
3675
|
+
const unit = await unitUnder(tree, org);
|
|
3676
|
+
await driver.grant(alice, 'unit-editor', unit);
|
|
3677
|
+
assert.isTrue(await driver.authorize(alice, 'docs:write', unit), 'precondición del caso');
|
|
3678
|
+
const eve = subject();
|
|
3679
|
+
const alias = { type: 'unit', uuid: unit.uuid.replaceAll('-', '') };
|
|
3680
|
+
await withAliasFused(alias, unit, async () => {
|
|
3681
|
+
assert.isFalse(await driver.authorize(alice, 'docs:write', alias), 'fail-CLOSED: no encuentra sus hechos');
|
|
3682
|
+
assert.isTrue(await driver.authorize(alice, 'docs:write', unit), 'y la forma canónica sigue concediendo');
|
|
3683
|
+
// La escritura sí canoniza (3b-2h · 🟠 3), en las cuatro puertas.
|
|
3684
|
+
await driver.grant(eve, 'unit-editor', alias);
|
|
3685
|
+
assert.deepEqual(await driver.listRoles(eve, unit), ['unit-editor'], 'el grant sobre el alias queda bajo la forma canónica');
|
|
3686
|
+
await driver.revoke(eve, 'unit-editor', alias);
|
|
3687
|
+
assert.deepEqual(await driver.listRoles(eve, unit), [], 'y el revoke sobre el alias quita el hecho canónico');
|
|
3688
|
+
await driver.deny(alice, 'docs:write', alias);
|
|
3689
|
+
assert.isFalse(await driver.authorize(alice, 'docs:write', unit), 'el deny sobre el alias bloquea la forma canónica');
|
|
3690
|
+
await driver.removeDeny(alice, 'docs:write', alias);
|
|
3691
|
+
assert.isTrue(await driver.authorize(alice, 'docs:write', unit));
|
|
3692
|
+
await driver.purgeScope(alias);
|
|
3693
|
+
assert.isFalse(await driver.authorize(alice, 'docs:write', unit), 'purgeScope sobre el alias purga los hechos CANÓNICOS');
|
|
3694
|
+
});
|
|
3695
|
+
});
|
|
3696
|
+
},
|
|
3697
|
+
});
|
|
3698
|
+
/**
|
|
3699
|
+
* El árbol del consumidor FUNDE dos ortografías del mismo uuid, que es lo
|
|
3700
|
+
* que hace de verdad una columna `uuid` de PostgreSQL o una collation
|
|
3701
|
+
* `*_ci` de MySQL. Se parchea `chainOf` en vez de depender del motor para
|
|
3702
|
+
* que el par `canonicalScopeReads` se juzgue igual en TODOS los harness,
|
|
3703
|
+
* también con el árbol en memoria (donde el alias sería, si no,
|
|
3704
|
+
* simplemente desconocido y el caso no probaría nada).
|
|
3705
|
+
*/
|
|
3706
|
+
async function withAliasFused(alias, canonical, fn) {
|
|
3707
|
+
const original = tree.chainOf;
|
|
3708
|
+
tree.chainOf = async (scope) => {
|
|
3709
|
+
const fused = scope.type === alias.type && scope.uuid === alias.uuid ? canonical : scope;
|
|
3710
|
+
return original.call(tree, fused);
|
|
3711
|
+
};
|
|
3712
|
+
try {
|
|
3713
|
+
await fn();
|
|
3714
|
+
}
|
|
3715
|
+
finally {
|
|
3716
|
+
tree.chainOf = original;
|
|
3717
|
+
}
|
|
3718
|
+
}
|
|
3719
|
+
/**
|
|
3720
|
+
* Un espía sobre el árbol del consumidor: cuántas veces lo consulta cada
|
|
3721
|
+
* primitiva. Es la única forma de juzgar «esto NO pasa por el árbol» sin
|
|
3722
|
+
* mirar dentro del driver.
|
|
3723
|
+
*/
|
|
3724
|
+
async function withChainSpy(fn) {
|
|
3725
|
+
const original = tree.chainOf;
|
|
3726
|
+
let calls = 0;
|
|
3727
|
+
tree.chainOf = async (scope) => {
|
|
3728
|
+
calls += 1;
|
|
3729
|
+
return original.call(tree, scope);
|
|
3730
|
+
};
|
|
3731
|
+
try {
|
|
3732
|
+
return { result: await fn(), calls };
|
|
3733
|
+
}
|
|
3734
|
+
finally {
|
|
3735
|
+
tree.chainOf = original;
|
|
3736
|
+
}
|
|
3737
|
+
}
|
|
3738
|
+
// ── 3b-2e · E2 · `roleInheritanceNative` ─────────────────────────────
|
|
3739
|
+
caseFor('roleInheritanceNative', {
|
|
3740
|
+
whenFalse: () => {
|
|
3741
|
+
test('la MEMBRESÍA la resuelve el paquete con el árbol del consumidor: hasRole/listRoles/listRoleScopes/listSubjects/listScopes lo consultan (cruce 6)', async ({ assert, }) => {
|
|
3742
|
+
// Es la declaración honesta que el README tiene que hacer: lo que
|
|
3743
|
+
// un modo `facts` puede prometer es «un solo Check en `authorize`»,
|
|
3744
|
+
// no «sin SQL». Estas cinco lecturas siguen pasando por el árbol en
|
|
3745
|
+
// LOS DOS drivers, y un driver que lo cambie tiene que declararlo.
|
|
3746
|
+
const alice = subject();
|
|
3747
|
+
const org = await orgUnder(tree, APP_SCOPE);
|
|
3748
|
+
const unit = await unitUnder(tree, org);
|
|
3749
|
+
await driver.grant(alice, 'org-editor', org);
|
|
3750
|
+
const primitives = [
|
|
3751
|
+
['hasRole', () => driver.hasRole(alice, 'org-editor', unit)],
|
|
3752
|
+
['listRoles', () => driver.listRoles(alice, org)],
|
|
3753
|
+
['listRoleScopes', () => driver.listRoleScopes(alice, 'organization')],
|
|
3754
|
+
['listSubjects', () => driver.listSubjects('org-editor', org)],
|
|
3755
|
+
['listScopes', () => driver.listScopes(alice, 'docs:read')],
|
|
3756
|
+
];
|
|
3757
|
+
for (const [name, run] of primitives) {
|
|
3758
|
+
const { calls } = await withChainSpy(run);
|
|
3759
|
+
assert.isAbove(calls, 0, `${name}: con roleInheritanceNative: false tiene que consultar el árbol`);
|
|
3760
|
+
}
|
|
3761
|
+
// Y la consecuencia observable: mover el nodo cambia la membresía
|
|
3762
|
+
// sin escribir un solo hecho.
|
|
3763
|
+
assert.isTrue(await driver.hasRole(alice, 'org-editor', unit));
|
|
3764
|
+
const otra = await orgUnder(tree, APP_SCOPE);
|
|
3765
|
+
await tree.move(unit, otra);
|
|
3766
|
+
assert.isFalse(await driver.hasRole(alice, 'org-editor', unit), 'la membresía es del árbol de HOY');
|
|
3767
|
+
});
|
|
3768
|
+
},
|
|
3769
|
+
});
|
|
3770
|
+
// ── 3b-2e · E2 · `listObjectsInherited` ──────────────────────────────
|
|
3771
|
+
caseFor('listObjectsInherited', {
|
|
3772
|
+
whenFalse: () => {
|
|
3773
|
+
test('los `list*` NO enumeran herencia (invariante 7): un grant en el ancestro no aparece abajo, aunque authorize diga true', async ({ assert, }) => {
|
|
3774
|
+
// El rol es del MISMO nivel que los dos scopes (`unit-editor@unit`)
|
|
3775
|
+
// a propósito: con un rol de otro nivel el filtro de nivel taparía
|
|
3776
|
+
// una enumeración que sí heredara, y el caso no probaría nada.
|
|
3777
|
+
const alice = subject();
|
|
3778
|
+
const org = await orgUnder(tree, APP_SCOPE);
|
|
3779
|
+
const unit = await unitUnder(tree, org);
|
|
3780
|
+
const sub = await unitUnder(tree, unit);
|
|
3781
|
+
await driver.grant(alice, 'unit-editor', unit);
|
|
3782
|
+
assert.isTrue(await driver.authorize(alice, 'docs:write', sub), 'la DECISIÓN sí hereda');
|
|
3783
|
+
assert.deepEqual(await driver.listRoles(alice, sub), [], 'la enumeración NO');
|
|
3784
|
+
assert.deepEqual(await driver.listSubjects('unit-editor', sub), [], 'ni por el otro lado');
|
|
3785
|
+
assert.deepEqual(scopeKeys(await driver.listScopes(alice, 'docs:write')), scopeKeys([unit]));
|
|
3786
|
+
assert.deepEqual(scopeKeys(await driver.listRoleScopes(alice, 'unit')), scopeKeys([unit]));
|
|
3787
|
+
// Y en el scope EXACTO sí está: lo que no se enumera es lo heredado.
|
|
3788
|
+
assert.deepEqual(await driver.listRoles(alice, unit), ['unit-editor']);
|
|
3789
|
+
});
|
|
3074
3790
|
},
|
|
3075
3791
|
});
|
|
3076
3792
|
caseFor('truncationSignal', {
|
|
@@ -3123,11 +3839,30 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
3123
3839
|
});
|
|
3124
3840
|
});
|
|
3125
3841
|
// Al cerrar el grupo: toda capacidad declarada `true` tiene que haber
|
|
3126
|
-
// registrado su par
|
|
3127
|
-
|
|
3842
|
+
// registrado su par — y las de `BOTH_FACES_REQUIRED`, también con `false`.
|
|
3843
|
+
// Es la regla "jamás un skip" hecha ejecutable.
|
|
3844
|
+
const uncovered = uncoveredCapabilities(harness.capabilities, covered);
|
|
3128
3845
|
if (uncovered.length) {
|
|
3129
|
-
throw new Error(`[contrato ${harness.name}] declara ${uncovered.map((c) => `'${c}:
|
|
3846
|
+
throw new Error(`[contrato ${harness.name}] declara ${uncovered.map((c) => `'${c}: ${String(harness.capabilities[c])}'`).join(', ')} ` +
|
|
3130
3847
|
`pero el contrato no tiene caso para ese valor todavía. Declara lo observable hoy.`);
|
|
3131
3848
|
}
|
|
3132
3849
|
}
|
|
3850
|
+
/**
|
|
3851
|
+
* La regla del guard, PURA (para que `contract_harness.spec.ts` la juzgue sin
|
|
3852
|
+
* borrar caras): una capacidad `true` sin caso registrado queda sin cubrir;
|
|
3853
|
+
* y una de `BOTH_FACES_REQUIRED` queda sin cubrir con CUALQUIER valor.
|
|
3854
|
+
*/
|
|
3855
|
+
export function uncoveredCapabilities(capabilities, covered) {
|
|
3856
|
+
return Object.keys(capabilities).filter((capability) => (capabilities[capability] || BOTH_FACES_REQUIRED.has(capability)) && !covered.has(capability));
|
|
3857
|
+
}
|
|
3858
|
+
/**
|
|
3859
|
+
* Capacidades cuyas DOS caras son obligatorias (L-2): declarar `false` sin
|
|
3860
|
+
* un caso `whenFalse` registrado lanza igual que declarar `true` sin
|
|
3861
|
+
* `whenTrue`. El guard general solo obliga a la cara `true` porque los pares
|
|
3862
|
+
* de nivel (`listDenies`, `purgeRole`…) no registran nada en un harness
|
|
3863
|
+
* `core` y `false` ahí es legítimo; `transactionalWrites` es composición del
|
|
3864
|
+
* manager y se observa en todos los niveles, así que su `false` sin caso es
|
|
3865
|
+
* un skip disfrazado — «rechazo activo» sería una etiqueta.
|
|
3866
|
+
*/
|
|
3867
|
+
const BOTH_FACES_REQUIRED = new Set(['transactionalWrites']);
|
|
3133
3868
|
//# sourceMappingURL=contract.js.map
|