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