@jantstack/adonis-authz 2.5.0-rc.1 → 2.5.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -44
- package/build/commands/authz_upgrade_1x.d.ts +216 -0
- package/build/commands/authz_upgrade_1x.js +903 -0
- package/build/commands/main.d.ts +2 -1
- package/build/commands/main.js +5 -3
- package/build/src/shared/sql_expiry.d.ts +7 -3
- package/build/src/shared/sql_expiry.js +41 -7
- package/build/src/testing/contract.d.ts +20 -4
- package/build/src/testing/contract.js +99 -3
- package/build/src/testing/relations_contract.d.ts +11 -1
- package/build/src/testing/relations_contract.js +107 -18
- package/package.json +1 -1
package/build/commands/main.d.ts
CHANGED
|
@@ -9,7 +9,8 @@ import AuthzReconcile from './authz_reconcile.js';
|
|
|
9
9
|
import AuthzRelationsReconcile from './authz_relations_reconcile.js';
|
|
10
10
|
import AuthzFreeze from './authz_freeze.js';
|
|
11
11
|
import AuthzUnfreeze from './authz_unfreeze.js';
|
|
12
|
-
|
|
12
|
+
import AuthzUpgrade1x from './authz_upgrade_1x.js';
|
|
13
|
+
export { OpenFgaProvision, AuthzCatalogDiff, AuthzCatalogSync, AuthzCatalogPruneOrphans, AuthzScopesRelay, AuthzScopesRequeue, AuthzReconcile, AuthzRelationsReconcile, AuthzFreeze, AuthzUnfreeze, AuthzUpgrade1x, };
|
|
13
14
|
/** Contrato de loader de ace (`LoadersContract.getMetaData`). */
|
|
14
15
|
export declare function getMetaData(): Promise<CommandMetaData[]>;
|
|
15
16
|
/** Contrato de loader de ace (`LoadersContract.getCommand`): la clase, o `null` si el nombre no es nuestro. */
|
package/build/commands/main.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* desenvolver `default` (mismo contrato que `@adonisjs/core/commands` y
|
|
11
11
|
* `@adonisjs/lucid/commands`, que leen un `commands.json` generado por el
|
|
12
12
|
* assembler; aquí no hay assembler, así que el loader es un `ListLoader`
|
|
13
|
-
* sobre las
|
|
13
|
+
* sobre las once clases (nueve hasta alpha.4, diez hasta la rotación 3 de 2.5) y no hay índice que generar).
|
|
14
14
|
*
|
|
15
15
|
* Hasta alpha.3 este fichero re-exportaba las nueve clases y NADA más, de
|
|
16
16
|
* modo que en un consumidor configurado con `node ace configure` **`node ace`
|
|
@@ -37,8 +37,9 @@ import AuthzReconcile from './authz_reconcile.js';
|
|
|
37
37
|
import AuthzRelationsReconcile from './authz_relations_reconcile.js';
|
|
38
38
|
import AuthzFreeze from './authz_freeze.js';
|
|
39
39
|
import AuthzUnfreeze from './authz_unfreeze.js';
|
|
40
|
-
|
|
41
|
-
|
|
40
|
+
import AuthzUpgrade1x from './authz_upgrade_1x.js';
|
|
41
|
+
export { OpenFgaProvision, AuthzCatalogDiff, AuthzCatalogSync, AuthzCatalogPruneOrphans, AuthzScopesRelay, AuthzScopesRequeue, AuthzReconcile, AuthzRelationsReconcile, AuthzFreeze, AuthzUnfreeze, AuthzUpgrade1x, };
|
|
42
|
+
/** Los once (nueve hasta alpha.4; `authz:scopes:requeue` desde alpha.5; `authz:upgrade-1x` desde la rotación 3 de 2.5), en el orden en que `node ace list` los enseña. */
|
|
42
43
|
const loader = new ListLoader([
|
|
43
44
|
OpenFgaProvision,
|
|
44
45
|
AuthzCatalogDiff,
|
|
@@ -50,6 +51,7 @@ const loader = new ListLoader([
|
|
|
50
51
|
AuthzRelationsReconcile,
|
|
51
52
|
AuthzFreeze,
|
|
52
53
|
AuthzUnfreeze,
|
|
54
|
+
AuthzUpgrade1x,
|
|
53
55
|
]);
|
|
54
56
|
/** Contrato de loader de ace (`LoadersContract.getMetaData`). */
|
|
55
57
|
export function getMetaData() {
|
|
@@ -26,7 +26,7 @@ export interface ExpiryCodec {
|
|
|
26
26
|
bind(at: Date): unknown;
|
|
27
27
|
/** Expresión de SELECT que devuelve la columna de forma que `fromDb` la lea sin depender del cliente. */
|
|
28
28
|
select(column: string, alias?: string): unknown;
|
|
29
|
-
/** Lo que devuelve el SELECT de `select(...)` → `Date | null
|
|
29
|
+
/** Lo que devuelve el SELECT de `select(...)` → `Date | null` (`null` = columna NULL); presente e ilegible LANZA (RR-2). */
|
|
30
30
|
fromDb(value: unknown): Date | null;
|
|
31
31
|
}
|
|
32
32
|
/** ¿Es MySQL? (el mismo conjunto que decide el codec). */
|
|
@@ -44,8 +44,12 @@ export declare function dialectOf(connection: any): string;
|
|
|
44
44
|
export declare function isSqliteDialect(connection: any): boolean;
|
|
45
45
|
/** `2030-06-15T12:00:00.000Z` → `2030-06-15 12:00:00.000` (UTC, con milisegundos). */
|
|
46
46
|
export declare function formatUtcDatetime(at: Date): string;
|
|
47
|
-
/**
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* `2030-06-15 12:00:00.000000` (UTC, hasta microsegundos) → `Date`. Lo que no es una fecha REAL lanza
|
|
49
|
+
* `unreadableExpiry` (RR-2): la fecha cero, un mes 13, un 31 de febrero o un `24:00:00` (que `Date` desbordaría en
|
|
50
|
+
* silencio al día o al mes siguiente) no se leen como nada.
|
|
51
|
+
*/
|
|
52
|
+
export declare function parseUtcDatetime(value: string): Date;
|
|
49
53
|
/**
|
|
50
54
|
* El codec para una conexión de Lucid (default: la primaria). Se decide por
|
|
51
55
|
* dialecto una vez por driver; MySQL es el único que necesita algo distinto
|
|
@@ -26,20 +26,54 @@ export function isSqliteDialect(connection) {
|
|
|
26
26
|
export function formatUtcDatetime(at) {
|
|
27
27
|
return at.toISOString().slice(0, 23).replace('T', ' ');
|
|
28
28
|
}
|
|
29
|
-
/**
|
|
29
|
+
/**
|
|
30
|
+
* Una caducidad PRESENTE que no se puede leer (rotación 3 · cierre 2, RR-2 del re-ataque): es un error, nunca «sin
|
|
31
|
+
* caducidad». Hasta aquí `parseUtcDatetime` y el codec identidad devolvían `null` para una fecha que no lo es —la fecha
|
|
32
|
+
* cero `0000-00-00` que MySQL deja con un `sql_mode` no estricto, un `-infinity` de PostgreSQL, un texto en SQLite—, y
|
|
33
|
+
* `null` es PERMANENTE para quien lo consume: el re-grant la conservaba como «sin caducidad», `reconcile --to=openfga`
|
|
34
|
+
* y el export de `relations:reconcile` escribían una tupla permanente para una fila que en SQL ya no concede. Ahora es
|
|
35
|
+
* 500 `E_AUTHZ_INTERNAL` en la lectura: lo que lee la caducidad para reescribirla o migrarla se para antes de escribir
|
|
36
|
+
* (`reconcile --to=database` incluido, que antes la reescribía con la del origen: E1, declarado en «Limits»). La DECISIÓN
|
|
37
|
+
* de SQL (`expires_at > now`) NO pasa por aquí y no es fail-closed para todo valor (re-ataque 2 · C1, corregido): no
|
|
38
|
+
* concede con la fecha cero de MySQL ni con `-infinity`, pero SÍ concede —para siempre— con `+infinity` en PostgreSQL y
|
|
39
|
+
* con un TEXTO en SQLite (`TEXT > INTEGER` es verdadero), aunque sea una fecha pasada. El paquete nunca escribe esos
|
|
40
|
+
* valores; está declarado en «Limits» con su receta (revoke + grant) y el arreglo de fondo va a 2.6. `null`/`undefined` siguen siendo «sin
|
|
41
|
+
* caducidad»: es lo que guarda una columna NULL.
|
|
42
|
+
*/
|
|
43
|
+
function unreadableExpiry(value) {
|
|
44
|
+
const shown = value instanceof Date ? 'Invalid Date' : JSON.stringify(typeof value === 'string' ? value.slice(0, 40) : String(value));
|
|
45
|
+
return new AuthorizationInternalError(`expires_at ilegible (${shown}): una caducidad presente que no es un instante es un error, nunca «sin caducidad». ` +
|
|
46
|
+
'Corrige la fila (o bórrala) antes de releerla, migrarla o reescribirla.');
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* `2030-06-15 12:00:00.000000` (UTC, hasta microsegundos) → `Date`. Lo que no es una fecha REAL lanza
|
|
50
|
+
* `unreadableExpiry` (RR-2): la fecha cero, un mes 13, un 31 de febrero o un `24:00:00` (que `Date` desbordaría en
|
|
51
|
+
* silencio al día o al mes siguiente) no se leen como nada.
|
|
52
|
+
*/
|
|
30
53
|
export function parseUtcDatetime(value) {
|
|
31
|
-
const match = /^(\d{4}
|
|
54
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})[ T](\d{2}):(\d{2}):(\d{2})(?:\.(\d{1,6}))?$/.exec(value.trim());
|
|
32
55
|
if (!match)
|
|
33
|
-
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
|
|
56
|
+
throw unreadableExpiry(value);
|
|
57
|
+
const [, year, month, day, hour, minute, second, fraction] = match;
|
|
58
|
+
const wall = `${year}-${month}-${day}T${hour}:${minute}:${second}`;
|
|
59
|
+
const date = new Date(`${wall}.${(fraction ?? '').padEnd(3, '0').slice(0, 3)}Z`);
|
|
60
|
+
// Ida y vuelta: `Date` acepta `2030-02-31` (⇒ 3 de marzo) y `24:00:00` (⇒ día siguiente); una fecha real vuelve igual.
|
|
61
|
+
if (Number.isNaN(date.getTime()) || date.toISOString().slice(0, 19) !== wall)
|
|
62
|
+
throw unreadableExpiry(value);
|
|
63
|
+
return date;
|
|
37
64
|
}
|
|
38
65
|
const identityCodec = {
|
|
39
66
|
toDb: (expiresAt) => expiresAt,
|
|
40
67
|
bind: (at) => at,
|
|
41
68
|
select: (column, alias) => (alias && alias !== column ? `${column} as ${alias}` : column),
|
|
42
|
-
|
|
69
|
+
// RR-2: `toExpiryDate` da `null` para lo que no es fecha (y deja pasar un `Date` inválido); aquí eso es un error.
|
|
70
|
+
fromDb: (value) => {
|
|
71
|
+
const date = toExpiryDate(value);
|
|
72
|
+
if (date === null ? !(value === null || value === undefined || value === '') : Number.isNaN(date.getTime())) {
|
|
73
|
+
throw unreadableExpiry(value);
|
|
74
|
+
}
|
|
75
|
+
return date;
|
|
76
|
+
},
|
|
43
77
|
};
|
|
44
78
|
function mysqlCodec(connection) {
|
|
45
79
|
const raw = (sql, bindings) => typeof connection?.raw === 'function' ? connection.raw(sql, bindings) : db.raw(sql, bindings);
|
|
@@ -37,6 +37,10 @@ import type { ContractScopeTree } from './scope_tree.js';
|
|
|
37
37
|
* `truncationSignal: false` (con `exhaustiveLists`) y `hierarchyFacts: false`;
|
|
38
38
|
* los demás llegan con su fase, y mientras tanto declararlos `true` hace que
|
|
39
39
|
* la suite lance al registrarse: una promesa sin juez no pasa.
|
|
40
|
+
*
|
|
41
|
+
* Las claves OPCIONALES (`?:`, regla D15) se pueden omitir: entonces la cara
|
|
42
|
+
* la elige la declaración normalizada del DRIVER (`isOmittedOptionalCapability`);
|
|
43
|
+
* declararlas fija la cara y el juez exige que el driver diga lo mismo.
|
|
40
44
|
*/
|
|
41
45
|
export interface DriverCapabilities {
|
|
42
46
|
/** El driver materializa el árbol como hechos propios (Fase 3b). */
|
|
@@ -204,15 +208,18 @@ export interface DriverCapabilities {
|
|
|
204
208
|
enumerateFacts: boolean;
|
|
205
209
|
/**
|
|
206
210
|
* **El driver es EXPERIMENTAL** (estrategia 2.5.0, firma 4; `AuthorizationDriverCapabilities.experimental`). Opcional
|
|
207
|
-
* (regla D15:
|
|
208
|
-
*
|
|
211
|
+
* (regla D15): si el harness la OMITE, el juez juzga la cara que el DRIVER declara (su declaración normalizada; ensayo
|
|
212
|
+
* rc.1 de COGNITIV) y un harness publicado sin la clave sigue valiendo; si la declara, manda el harness. Con `true` el
|
|
213
|
+
* juez fija que el driver lo declara y que `authorization.capabilities()` lo publica; con `false`, que el driver NO lo
|
|
209
214
|
* declara y la copia normalizada dice `false`. No apaga ningún caso. `openfga` = `true` en 2.5.x; `database` = `false`.
|
|
210
215
|
*/
|
|
211
216
|
experimental?: boolean;
|
|
212
217
|
/**
|
|
213
218
|
* **Toda decisión que combina concesión y deny se lee en UNA foto** (rotación 2 · cierre 3, L12;
|
|
214
|
-
* `AuthorizationDriverCapabilities.snapshotDecisions`). Opcional (regla D15:
|
|
215
|
-
*
|
|
219
|
+
* `AuthorizationDriverCapabilities.snapshotDecisions`). Opcional (regla D15): si el harness la OMITE, el juez juzga la
|
|
220
|
+
* cara que el DRIVER declara —el `database` del paquete declara `true`, así que un harness de alpha.4 que lo registra
|
|
221
|
+
* sin la clave se juzga en la cara `true` (ensayo rc.1 de COGNITIV)—; si la declara, manda el harness y contradecir al
|
|
222
|
+
* driver es rojo. Solo tiene casos en `level: '2.1'` o superior (necesita `listDenies`). Con `true`
|
|
216
223
|
* el juez fija que el driver trae `readSnapshot`, que cada lectura pedida es EXACTAMENTE la del método homónimo (y lo
|
|
217
224
|
* no pedido no viene), que una pregunta mal formada es 422, y que el manager compone `effectivePermissions`,
|
|
218
225
|
* `authorizedScopes` y la policy de `defineScopedRole` con UNA llamada a `readSnapshot` y ninguna a
|
|
@@ -225,6 +232,15 @@ export interface DriverCapabilities {
|
|
|
225
232
|
*/
|
|
226
233
|
snapshotDecisions?: boolean;
|
|
227
234
|
}
|
|
235
|
+
/**
|
|
236
|
+
* **Clave D15 OMITIDA ⇒ la declaración del driver** (ensayo rc.1 de COGNITIV, día 1, hallazgo 1; firma del dueño
|
|
237
|
+
* 2026-10-23). Una clave opcional que el harness no declara (`undefined`) no es `false`: el juez la toma de la
|
|
238
|
+
* declaración NORMALIZADA del driver (`normalizeAuthorizationCapabilities`) y juzga ESA cara. Si el harness la declara,
|
|
239
|
+
* manda el harness (y contradecir al driver sigue siendo rojo). Un harness publicado antes de que la clave existiera no
|
|
240
|
+
* puede declararla: tratarla como `false` ponía rojo a un harness verde contra un driver que la declara `true` (el propio
|
|
241
|
+
* `database` con `snapshotDecisions`), que es lo que D15 prohíbe.
|
|
242
|
+
*/
|
|
243
|
+
export declare function isOmittedOptionalCapability(capabilities: DriverCapabilities, capability: keyof DriverCapabilities): boolean;
|
|
228
244
|
export type ContractLevel = 'core' | '2.0' | '2.1' | '2.2';
|
|
229
245
|
export interface DriverContractHarness {
|
|
230
246
|
name: string;
|
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import '@japa/assert';
|
|
2
2
|
import { test } from '@japa/runner';
|
|
3
3
|
import { v7 as uuidv7 } from 'uuid';
|
|
4
|
-
import { APP_SCOPE } from '../types.js';
|
|
4
|
+
import { APP_SCOPE, normalizeAuthorizationCapabilities } from '../types.js';
|
|
5
5
|
import { AuthorizationManager } from '../manager.js';
|
|
6
6
|
import db from '@adonisjs/lucid/services/db';
|
|
7
7
|
import { CatalogCache, GLOBAL_OWNER_KEY, withAuthzCatalogWrite } from '../catalog/catalog_cache.js';
|
|
8
8
|
import { scopeKey } from '../identity.js';
|
|
9
9
|
import { descendantsFrom, memoryScopeTree, resolveChainFrom } from './scope_tree.js';
|
|
10
|
+
const OPTIONAL_DRIVER_CAPABILITIES = Object.freeze({
|
|
11
|
+
experimental: true,
|
|
12
|
+
snapshotDecisions: true,
|
|
13
|
+
});
|
|
14
|
+
/**
|
|
15
|
+
* **Clave D15 OMITIDA ⇒ la declaración del driver** (ensayo rc.1 de COGNITIV, día 1, hallazgo 1; firma del dueño
|
|
16
|
+
* 2026-10-23). Una clave opcional que el harness no declara (`undefined`) no es `false`: el juez la toma de la
|
|
17
|
+
* declaración NORMALIZADA del driver (`normalizeAuthorizationCapabilities`) y juzga ESA cara. Si el harness la declara,
|
|
18
|
+
* manda el harness (y contradecir al driver sigue siendo rojo). Un harness publicado antes de que la clave existiera no
|
|
19
|
+
* puede declararla: tratarla como `false` ponía rojo a un harness verde contra un driver que la declara `true` (el propio
|
|
20
|
+
* `database` con `snapshotDecisions`), que es lo que D15 prohíbe.
|
|
21
|
+
*/
|
|
22
|
+
export function isOmittedOptionalCapability(capabilities, capability) {
|
|
23
|
+
return Object.prototype.hasOwnProperty.call(OPTIONAL_DRIVER_CAPABILITIES, capability) && capabilities[capability] === undefined;
|
|
24
|
+
}
|
|
10
25
|
/** El default de `DriverContractHarness.transactions`: Lucid + `authz_*`. */
|
|
11
26
|
export function lucidContractTransactions() {
|
|
12
27
|
const count = async (table, subject) => {
|
|
@@ -243,6 +258,11 @@ async function rejectsWith(assert, fn, expected) {
|
|
|
243
258
|
}
|
|
244
259
|
assert.fail('debería haber rechazado');
|
|
245
260
|
}
|
|
261
|
+
/**
|
|
262
|
+
* Presupuesto de un caso interno sin `timeout` propio cuando una cara se juzga dentro de UN caso (clave D15 omitida):
|
|
263
|
+
* el caso que la envuelve suma los de su cara. Solo acota un cuelgue; no es una medida.
|
|
264
|
+
*/
|
|
265
|
+
const OMITTED_CASE_TIMEOUT_MS = 30_000;
|
|
246
266
|
export function runAuthorizationDriverContract(harness) {
|
|
247
267
|
return registerAuthorizationDriverContract(harness, {
|
|
248
268
|
group: (title, define) => test.group(title, define),
|
|
@@ -259,7 +279,21 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
259
279
|
let tree;
|
|
260
280
|
/** Registra el caso solo si el harness pidió ese nivel o uno superior. */
|
|
261
281
|
let registered = 0;
|
|
282
|
+
/**
|
|
283
|
+
* Mientras no es `null`, `test` NO registra: guarda el caso aquí (las caras de una clave D15 omitida, que se juzgan
|
|
284
|
+
* dentro de un único caso cuando el driver ya existe; ver `caseFromDriver`).
|
|
285
|
+
*/
|
|
286
|
+
let capturing = null;
|
|
262
287
|
const test = (title, fn) => {
|
|
288
|
+
if (capturing) {
|
|
289
|
+
const entry = { title, fn };
|
|
290
|
+
capturing.push(entry);
|
|
291
|
+
return {
|
|
292
|
+
timeout(ms) {
|
|
293
|
+
entry.timeout = ms;
|
|
294
|
+
},
|
|
295
|
+
};
|
|
296
|
+
}
|
|
263
297
|
registered += 1;
|
|
264
298
|
return api.test(title, fn);
|
|
265
299
|
};
|
|
@@ -273,6 +307,8 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
273
307
|
* cara `whenTrue` queda sin cubrir y la suite lanza al cerrar el grupo.
|
|
274
308
|
*/
|
|
275
309
|
function caseFor(capability, pair) {
|
|
310
|
+
if (isOmittedOptionalCapability(harness.capabilities, capability))
|
|
311
|
+
return caseFromDriver(capability, pair);
|
|
276
312
|
const side = harness.capabilities[capability] ? pair.whenTrue : pair.whenFalse;
|
|
277
313
|
if (!side)
|
|
278
314
|
return;
|
|
@@ -284,7 +320,66 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
284
320
|
if (registered > before)
|
|
285
321
|
covered.add(capability);
|
|
286
322
|
}
|
|
287
|
-
|
|
323
|
+
/** Los casos de una cara, sin registrarlos. */
|
|
324
|
+
function captureFace(side) {
|
|
325
|
+
if (!side)
|
|
326
|
+
return [];
|
|
327
|
+
const previous = capturing;
|
|
328
|
+
const into = [];
|
|
329
|
+
capturing = into;
|
|
330
|
+
try {
|
|
331
|
+
side();
|
|
332
|
+
}
|
|
333
|
+
finally {
|
|
334
|
+
capturing = previous;
|
|
335
|
+
}
|
|
336
|
+
return into;
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* **La cara de una clave D15 que el harness OMITE la elige el DRIVER** (ver `isOmittedOptionalCapability`). Al
|
|
340
|
+
* registrar no hay driver (lo construye `makeDriver` en cada caso, y puede ser asíncrono o tener efectos: crear un
|
|
341
|
+
* store), así que las DOS caras se capturan sin registrarse y se registra UN caso que, ya con el driver del caso,
|
|
342
|
+
* lee su declaración normalizada y corre EN SERIE todos los casos de esa cara, cada uno sobre un estado fresco
|
|
343
|
+
* (`prepareCase`, lo mismo que el `setup` de Japa). Un rojo dice la clave, la declaración del driver y el título del
|
|
344
|
+
* caso interno. Nada se relaja: con `true` se juzga la cara `true` entera, y si esa cara no tiene caso en este nivel
|
|
345
|
+
* es rojo como lo sería declararla (`uncoveredCapabilities`, la misma regla pura). Si ninguna cara tiene caso en este
|
|
346
|
+
* nivel no hay nada observable y no se registra nada (como antes).
|
|
347
|
+
*/
|
|
348
|
+
function caseFromDriver(capability, pair) {
|
|
349
|
+
const faces = { true: captureFace(pair.whenTrue), false: captureFace(pair.whenFalse) };
|
|
350
|
+
if (!faces.true.length && !faces.false.length)
|
|
351
|
+
return;
|
|
352
|
+
const budget = (cases) => cases.reduce((ms, c) => ms + (c.timeout ?? OMITTED_CASE_TIMEOUT_MS), 0);
|
|
353
|
+
const title = `${capability}:(omitida en el harness ⇒ la declaración del driver) · se juzga la cara que el driver declara ` +
|
|
354
|
+
`(true: ${faces.true.length} caso(s) · false: ${faces.false.length}); un harness que la declare explícita manda (D15)`;
|
|
355
|
+
test(title, async ({ assert }) => {
|
|
356
|
+
const declared = normalizeAuthorizationCapabilities(driver.capabilities)[capability] === true;
|
|
357
|
+
const cases = declared ? faces.true : faces.false;
|
|
358
|
+
const uncovered = uncoveredCapabilities({ [capability]: declared }, new Set(cases.length ? [capability] : []));
|
|
359
|
+
if (uncovered.length) {
|
|
360
|
+
throw new Error(`[contrato ${harness.name}] el harness omite '${capability}' y el driver declara '${capability}: ${declared}', pero el ` +
|
|
361
|
+
`contrato no tiene caso para ese valor en el nivel '${level}'. Declara la clave en el harness con lo observable hoy.`);
|
|
362
|
+
}
|
|
363
|
+
for (const [index, entry] of cases.entries()) {
|
|
364
|
+
if (index > 0)
|
|
365
|
+
await prepareCase();
|
|
366
|
+
try {
|
|
367
|
+
await entry.fn({ assert });
|
|
368
|
+
}
|
|
369
|
+
catch (error) {
|
|
370
|
+
const prefix = `[${capability}: ${declared} — declaración del driver; el harness la omite] ${entry.title}\n`;
|
|
371
|
+
if (error && typeof error === 'object' && !Object.isFrozen(error)) {
|
|
372
|
+
error.message = prefix + String(error.message ?? '');
|
|
373
|
+
throw error;
|
|
374
|
+
}
|
|
375
|
+
throw new Error(prefix + String(error?.message ?? error), { cause: error });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
})?.timeout(Math.max(budget(faces.true), budget(faces.false)));
|
|
379
|
+
covered.add(capability);
|
|
380
|
+
}
|
|
381
|
+
/** El estado fresco de cada caso (el `setup` del grupo; `caseFromDriver` lo repite entre sus casos internos). */
|
|
382
|
+
async function prepareCase() {
|
|
288
383
|
await harness.cleanup();
|
|
289
384
|
await harness.seedCatalog(CONTRACT_CATALOG);
|
|
290
385
|
tree = harness.makeTree ? await harness.makeTree() : memoryScopeTree();
|
|
@@ -341,7 +436,8 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
341
436
|
await mirror(() => driver.onScopeMoved?.(child, parent));
|
|
342
437
|
};
|
|
343
438
|
}
|
|
344
|
-
}
|
|
439
|
+
}
|
|
440
|
+
group.each.setup(prepareCase);
|
|
345
441
|
group.teardown(async () => {
|
|
346
442
|
await harness.cleanup();
|
|
347
443
|
});
|
|
@@ -47,9 +47,19 @@ export declare const RELATIONS_CONTRACT_DENY_TYPES: {
|
|
|
47
47
|
readonly note: undefined;
|
|
48
48
|
};
|
|
49
49
|
export declare function contractDenyRelationsConfig(): RelationsConfig;
|
|
50
|
+
/**
|
|
51
|
+
* **Clave D15 OMITIDA ⇒ la declaración del driver** (gemelo de `isOmittedOptionalCapability` del runner de roles; ensayo
|
|
52
|
+
* rc.1 de COGNITIV, día 1). Una clave opcional que el harness no declara se juzga con la declaración NORMALIZADA del
|
|
53
|
+
* driver del caso (`normalizeRelationsCapabilities`); si el harness la declara, manda el harness.
|
|
54
|
+
*/
|
|
55
|
+
export declare function isOmittedOptionalRelationsCapability(capabilities: RelationsDriverCapabilities, capability: keyof RelationsDriverCapabilities): boolean;
|
|
50
56
|
export interface RelationsDriverContractHarness {
|
|
51
57
|
name: string;
|
|
52
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Lo que este driver DECLARA. Cada valor se juzga con su cara. Una clave OPCIONAL (`?:`, regla D15: `relationDenies`,
|
|
60
|
+
* `atomicRenewal`, `experimental`) que el harness omite se juzga con la declaración normalizada del DRIVER
|
|
61
|
+
* (`isOmittedOptionalRelationsCapability`); declararla fija la cara y el juez exige que el driver diga lo mismo.
|
|
62
|
+
*/
|
|
53
63
|
capabilities: RelationsDriverCapabilities;
|
|
54
64
|
/** Construye el driver sobre la config del contrato (fresco por caso). */
|
|
55
65
|
makeDriver(config: RelationsConfig): RelationsDriver | Promise<RelationsDriver>;
|
|
@@ -28,7 +28,7 @@ import { assertCheckManyObjects, distinctObjects, expandByPosition, RELATIONS_CH
|
|
|
28
28
|
import { assertEnumeratePage, assertNoListPage } from '../relations/page.js';
|
|
29
29
|
import { systemClock } from '../clock.js';
|
|
30
30
|
import { resolveGrantExpiry, sameInstant } from '../expiry.js';
|
|
31
|
-
import { isRelUserset } from '../types.js';
|
|
31
|
+
import { isRelUserset, normalizeRelationsCapabilities } from '../types.js';
|
|
32
32
|
/* ── El vocabulario del contrato ─────────────────────────────────────────── */
|
|
33
33
|
/** Los holder types del contrato de relaciones. */
|
|
34
34
|
export const RELATIONS_HOLDER_TYPES = ['user', 'admin', 'integration'];
|
|
@@ -101,6 +101,21 @@ export function contractDenyRelationsConfig() {
|
|
|
101
101
|
database: { membersOf: true },
|
|
102
102
|
});
|
|
103
103
|
}
|
|
104
|
+
const OPTIONAL_RELATIONS_CAPABILITIES = Object.freeze({
|
|
105
|
+
relationDenies: true,
|
|
106
|
+
atomicRenewal: true,
|
|
107
|
+
experimental: true,
|
|
108
|
+
});
|
|
109
|
+
/**
|
|
110
|
+
* **Clave D15 OMITIDA ⇒ la declaración del driver** (gemelo de `isOmittedOptionalCapability` del runner de roles; ensayo
|
|
111
|
+
* rc.1 de COGNITIV, día 1). Una clave opcional que el harness no declara se juzga con la declaración NORMALIZADA del
|
|
112
|
+
* driver del caso (`normalizeRelationsCapabilities`); si el harness la declara, manda el harness.
|
|
113
|
+
*/
|
|
114
|
+
export function isOmittedOptionalRelationsCapability(capabilities, capability) {
|
|
115
|
+
return Object.prototype.hasOwnProperty.call(OPTIONAL_RELATIONS_CAPABILITIES, capability) && capabilities[capability] === undefined;
|
|
116
|
+
}
|
|
117
|
+
/** Presupuesto de un caso interno sin `timeout` propio cuando una cara se juzga dentro de UN caso (clave D15 omitida). */
|
|
118
|
+
const OMITTED_CASE_TIMEOUT_MS = 30_000;
|
|
104
119
|
/** El default de `RelationsDriverContractHarness.transactions`: Lucid + `authz_relations`. */
|
|
105
120
|
export function lucidRelationsContractTransactions() {
|
|
106
121
|
return {
|
|
@@ -668,22 +683,94 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
668
683
|
base,
|
|
669
684
|
};
|
|
670
685
|
}
|
|
686
|
+
/** El estado fresco de cada caso (el `setup` del grupo; `caseFromDriver` lo repite entre sus casos internos). */
|
|
687
|
+
async function prepareCase() {
|
|
688
|
+
// alpha.3: el manager de cada caso captura `onRelationWrite` — el par
|
|
689
|
+
// `transactionalWrites` juzga también el EVENTO (la otra cara observable
|
|
690
|
+
// de la capacidad) y R-07/R-08 juzgan el evento de la purga.
|
|
691
|
+
state.events = [];
|
|
692
|
+
const f = await freshManager({ onRelationWrite: (e) => void state.events.push(e) });
|
|
693
|
+
state.manager = f.manager;
|
|
694
|
+
state.base = f.base;
|
|
695
|
+
state.writes = f.writes;
|
|
696
|
+
state.calls = f.calls;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Lo que se JUZGA de una capacidad dentro de un caso: lo que declara el harness, o —si omite una clave opcional (D15)—
|
|
700
|
+
* la declaración normalizada del driver del caso. Lo leen los casos que dependen de otra capacidad (p. ej. los de
|
|
701
|
+
* `atomicRenewal` siembran el deny si `relationDenies`): con la clave omitida no pueden leer `undefined` como `false`.
|
|
702
|
+
*/
|
|
703
|
+
function judged(capability) {
|
|
704
|
+
if (isOmittedOptionalRelationsCapability(harness.capabilities, capability)) {
|
|
705
|
+
return normalizeRelationsCapabilities(state.base?.capabilities)[capability] === true;
|
|
706
|
+
}
|
|
707
|
+
return harness.capabilities[capability] === true;
|
|
708
|
+
}
|
|
671
709
|
api.group(`relations contract [${harness.name}]`, (group) => {
|
|
672
|
-
group.each.setup(
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
// de la capacidad) y R-07/R-08 juzgan el evento de la purga.
|
|
676
|
-
state.events = [];
|
|
677
|
-
const f = await freshManager({ onRelationWrite: (e) => void state.events.push(e) });
|
|
678
|
-
state.manager = f.manager;
|
|
679
|
-
state.base = f.base;
|
|
680
|
-
state.writes = f.writes;
|
|
681
|
-
state.calls = f.calls;
|
|
682
|
-
});
|
|
710
|
+
group.each.setup(prepareCase);
|
|
711
|
+
/** Mientras no es `null`, `test` guarda el caso aquí en vez de registrarlo (ver `caseFromDriver`). */
|
|
712
|
+
let capturing = null;
|
|
683
713
|
const test = (title, fn) => {
|
|
714
|
+
if (capturing) {
|
|
715
|
+
const entry = { title, fn };
|
|
716
|
+
capturing.push(entry);
|
|
717
|
+
return {
|
|
718
|
+
timeout(ms) {
|
|
719
|
+
entry.timeout = ms;
|
|
720
|
+
},
|
|
721
|
+
};
|
|
722
|
+
}
|
|
684
723
|
registered++;
|
|
685
724
|
return api.test(title, fn);
|
|
686
725
|
};
|
|
726
|
+
function captureFace(side) {
|
|
727
|
+
const previous = capturing;
|
|
728
|
+
const into = [];
|
|
729
|
+
capturing = into;
|
|
730
|
+
try {
|
|
731
|
+
side();
|
|
732
|
+
}
|
|
733
|
+
finally {
|
|
734
|
+
capturing = previous;
|
|
735
|
+
}
|
|
736
|
+
return into;
|
|
737
|
+
}
|
|
738
|
+
/**
|
|
739
|
+
* **La cara de una clave D15 que el harness OMITE la elige el DRIVER** (gemelo de `caseFromDriver` del runner de
|
|
740
|
+
* roles). Las dos caras se capturan sin registrarse y se registra UN caso que, con el driver del caso, lee su
|
|
741
|
+
* declaración normalizada y corre en serie todos los casos de esa cara, cada uno sobre un estado fresco
|
|
742
|
+
* (`prepareCase`). Nada se relaja: si el driver declara `true` se juzga la cara `true` entera.
|
|
743
|
+
*/
|
|
744
|
+
function caseFromDriver(capability, pair) {
|
|
745
|
+
const faces = { true: captureFace(pair.whenTrue), false: captureFace(pair.whenFalse) };
|
|
746
|
+
const budget = (cases) => cases.reduce((ms, c) => ms + (c.timeout ?? OMITTED_CASE_TIMEOUT_MS), 0);
|
|
747
|
+
const title = `${capability}:(omitida en el harness ⇒ la declaración del driver) · se juzga la cara que el driver declara ` +
|
|
748
|
+
`(true: ${faces.true.length} caso(s) · false: ${faces.false.length}); un harness que la declare explícita manda (D15)`;
|
|
749
|
+
test(title, async ({ assert }) => {
|
|
750
|
+
const declared = normalizeRelationsCapabilities(state.base?.capabilities)[capability] === true;
|
|
751
|
+
const cases = declared ? faces.true : faces.false;
|
|
752
|
+
if (!cases.length) {
|
|
753
|
+
throw new Error(`[contrato ${harness.name}] el harness omite '${capability}' y el driver declara '${capability}: ${declared}', pero ` +
|
|
754
|
+
`esa cara no registró ningún caso: una cara vacía es un skip disfrazado.`);
|
|
755
|
+
}
|
|
756
|
+
for (const [index, entry] of cases.entries()) {
|
|
757
|
+
if (index > 0)
|
|
758
|
+
await prepareCase();
|
|
759
|
+
try {
|
|
760
|
+
await entry.fn({ assert });
|
|
761
|
+
}
|
|
762
|
+
catch (error) {
|
|
763
|
+
const prefix = `[${capability}: ${declared} — declaración del driver; el harness la omite] ${entry.title}\n`;
|
|
764
|
+
if (error && typeof error === 'object' && !Object.isFrozen(error)) {
|
|
765
|
+
error.message = prefix + String(error.message ?? '');
|
|
766
|
+
throw error;
|
|
767
|
+
}
|
|
768
|
+
throw new Error(prefix + String(error?.message ?? error), { cause: error });
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
})?.timeout(Math.max(budget(faces.true), budget(faces.false)));
|
|
772
|
+
covered.add(capability);
|
|
773
|
+
}
|
|
687
774
|
function caseFor(capability, pair) {
|
|
688
775
|
// MÁS ESTRICTO que el guard de roles: las DOS caras tienen que existir
|
|
689
776
|
// en el código del runner, o es un skip disfrazado (exigencia del tester).
|
|
@@ -691,6 +778,8 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
691
778
|
throw new Error(`[contrato ${harness.name}] la capacidad '${capability}' no declara sus dos caras ` +
|
|
692
779
|
`(whenTrue Y whenFalse): una cara ausente es un skip disfrazado.`);
|
|
693
780
|
}
|
|
781
|
+
if (isOmittedOptionalRelationsCapability(harness.capabilities, capability))
|
|
782
|
+
return caseFromDriver(capability, pair);
|
|
694
783
|
const before = registered;
|
|
695
784
|
(harness.capabilities[capability] ? pair.whenTrue : pair.whenFalse)();
|
|
696
785
|
if (registered > before)
|
|
@@ -860,7 +949,7 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
860
949
|
assert.notProperty(members, 'cursor', 'membersOf sin page no devuelve cursor');
|
|
861
950
|
assert.deepEqual(ids(members.subjects), expected, `membersOf devuelve los ${N}`);
|
|
862
951
|
}
|
|
863
|
-
if (
|
|
952
|
+
if (judged('relationDenies')) {
|
|
864
953
|
const { manager } = await denyManager();
|
|
865
954
|
const denied = { type: 'document', id: docId() };
|
|
866
955
|
for (const u of users)
|
|
@@ -1984,20 +2073,20 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
1984
2073
|
}
|
|
1985
2074
|
caseFor('atomicRenewal', {
|
|
1986
2075
|
whenTrue: () => test('atomicRenewal:true · renovar (otra caducidad) una membresía que TRANSPORTA un deny —directa y ANIDADA— entra en UNA escritura: la caducidad cambia y el deny sigue negando', async ({ assert }) => {
|
|
1987
|
-
const { manager, base } =
|
|
1988
|
-
const { u, p, x, g, h, k } = await seedRenewal(manager,
|
|
2076
|
+
const { manager, base } = judged('relationDenies') ? await denyManager() : { manager: state.manager, base: state.base };
|
|
2077
|
+
const { u, p, x, g, h, k } = await seedRenewal(manager, judged('relationDenies'));
|
|
1989
2078
|
for (const group of [g, h, k]) {
|
|
1990
2079
|
await manager.relate(u, 'member', group, p, { expiresAt: null });
|
|
1991
2080
|
const expiry = await membershipExpiry(base, u, group, p);
|
|
1992
2081
|
if (expiry !== undefined)
|
|
1993
2082
|
assert.isNull(expiry, `la membresía de ${group.id} quedó SIN caducidad`);
|
|
1994
2083
|
}
|
|
1995
|
-
const carried =
|
|
2084
|
+
const carried = judged('relationDenies') && harness.capabilities.usersetSubjects === true;
|
|
1996
2085
|
assert.equal(await manager.check(u, 'viewer', x, p), !carried, 'el deny transportado sigue negando tras renovar');
|
|
1997
2086
|
}),
|
|
1998
2087
|
whenFalse: () => test('atomicRenewal:false · renovar una membresía que TRANSPORTA un deny vivo —directa o por un grupo que la contiene— ⇒ 409 E_AUTHZ_RENEWAL_UNSAFE con la receta y CERO escrituras (la caducidad no cambia, el deny sigue negando); una membresía sin deny se renueva', async ({ assert }) => {
|
|
1999
|
-
const denies =
|
|
2000
|
-
const { manager, base } =
|
|
2088
|
+
const denies = judged('relationDenies') && harness.capabilities.usersetSubjects === true;
|
|
2089
|
+
const { manager, base } = judged('relationDenies') ? await denyManager() : { manager: state.manager, base: state.base };
|
|
2001
2090
|
const { u, p, x, g, h, k, hour } = await seedRenewal(manager, denies);
|
|
2002
2091
|
if (denies) {
|
|
2003
2092
|
for (const group of [g, h]) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jantstack/adonis-authz",
|
|
3
|
-
"version": "2.5.0-rc.
|
|
3
|
+
"version": "2.5.0-rc.2",
|
|
4
4
|
"description": "Driver-based authorization engine for AdonisJS + Lucid: hierarchical scopes with downward inheritance, explicit denies, expiring assignments and polymorphic holders. Ships a self-contained database driver and an OpenFGA driver, both judged by the same executable contract suite.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|