@jantstack/adonis-authz 2.4.0-alpha.9 → 2.5.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +296 -15
- package/build/commands/authz_freeze.js +1 -1
- package/build/commands/authz_freeze.js.map +1 -1
- package/build/commands/authz_reconcile.d.ts +6 -2
- package/build/commands/authz_reconcile.d.ts.map +1 -1
- package/build/commands/authz_reconcile.js +129 -13
- package/build/commands/authz_reconcile.js.map +1 -1
- package/build/commands/authz_relations_reconcile.d.ts +66 -19
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
- package/build/commands/authz_relations_reconcile.js +212 -39
- package/build/commands/authz_relations_reconcile.js.map +1 -1
- package/build/commands/authz_scopes_relay.d.ts.map +1 -1
- package/build/commands/authz_scopes_relay.js +81 -1
- package/build/commands/authz_scopes_relay.js.map +1 -1
- package/build/commands/authz_unfreeze.d.ts +12 -4
- package/build/commands/authz_unfreeze.d.ts.map +1 -1
- package/build/commands/authz_unfreeze.js +74 -9
- package/build/commands/authz_unfreeze.js.map +1 -1
- package/build/index.d.ts +6 -4
- package/build/index.d.ts.map +1 -1
- package/build/index.js +5 -3
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts +1 -1
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +2 -1
- package/build/providers/authz_provider.js.map +1 -1
- package/build/src/define_config.d.ts +9 -0
- package/build/src/define_config.d.ts.map +1 -1
- package/build/src/define_config.js +11 -0
- package/build/src/define_config.js.map +1 -1
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +1 -0
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +18 -6
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/database_relations_driver.js +91 -44
- package/build/src/drivers/database_relations_driver.js.map +1 -1
- package/build/src/drivers/openfga_driver.d.ts +16 -1
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +66 -20
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_relations_driver.d.ts +16 -4
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_relations_driver.js +71 -34
- package/build/src/drivers/openfga_relations_driver.js.map +1 -1
- package/build/src/errors.d.ts +50 -0
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +50 -0
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +63 -4
- package/build/src/freeze.d.ts.map +1 -1
- package/build/src/freeze.js +107 -18
- package/build/src/freeze.js.map +1 -1
- package/build/src/identity.d.ts +120 -9
- package/build/src/identity.d.ts.map +1 -1
- package/build/src/identity.js +164 -12
- package/build/src/identity.js.map +1 -1
- package/build/src/manager.d.ts +64 -2
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +262 -35
- package/build/src/manager.js.map +1 -1
- package/build/src/openfga.d.ts +1 -1
- package/build/src/openfga.d.ts.map +1 -1
- package/build/src/openfga.js +1 -1
- package/build/src/openfga.js.map +1 -1
- package/build/src/relations/check_many.d.ts +17 -0
- package/build/src/relations/check_many.d.ts.map +1 -0
- package/build/src/relations/check_many.js +57 -0
- package/build/src/relations/check_many.js.map +1 -0
- package/build/src/relations/manager.d.ts +20 -0
- package/build/src/relations/manager.d.ts.map +1 -1
- package/build/src/relations/manager.js +84 -13
- package/build/src/relations/manager.js.map +1 -1
- package/build/src/relations/reconcile.d.ts +67 -0
- package/build/src/relations/reconcile.d.ts.map +1 -1
- package/build/src/relations/reconcile.js +123 -30
- package/build/src/relations/reconcile.js.map +1 -1
- package/build/src/relations/tuple_key.d.ts +25 -0
- package/build/src/relations/tuple_key.d.ts.map +1 -0
- package/build/src/relations/tuple_key.js +60 -0
- package/build/src/relations/tuple_key.js.map +1 -0
- package/build/src/shared/backend_guard.d.ts +30 -11
- package/build/src/shared/backend_guard.d.ts.map +1 -1
- package/build/src/shared/backend_guard.js +45 -15
- package/build/src/shared/backend_guard.js.map +1 -1
- package/build/src/shared/sql_expiry.d.ts +2 -0
- package/build/src/shared/sql_expiry.d.ts.map +1 -1
- package/build/src/shared/sql_expiry.js +4 -0
- package/build/src/shared/sql_expiry.js.map +1 -1
- package/build/src/testing/main.d.ts +3 -1
- 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/migration_contract.d.ts +9 -0
- package/build/src/testing/migration_contract.d.ts.map +1 -1
- package/build/src/testing/migration_contract.js +5 -2
- package/build/src/testing/migration_contract.js.map +1 -1
- package/build/src/testing/relations_contract.d.ts +14 -0
- package/build/src/testing/relations_contract.d.ts.map +1 -1
- package/build/src/testing/relations_contract.js +297 -21
- package/build/src/testing/relations_contract.js.map +1 -1
- package/build/src/testing/test_clock.d.ts +15 -0
- package/build/src/testing/test_clock.d.ts.map +1 -0
- package/build/src/testing/test_clock.js +65 -0
- package/build/src/testing/test_clock.js.map +1 -0
- package/build/src/types.d.ts +108 -9
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js.map +1 -1
- package/package.json +5 -1
|
@@ -20,9 +20,10 @@
|
|
|
20
20
|
import { v7 as uuidv7 } from 'uuid';
|
|
21
21
|
import { test as japaTest } from '@japa/runner';
|
|
22
22
|
import db from '@adonisjs/lucid/services/db';
|
|
23
|
-
import { scopeKey } from '../identity.js';
|
|
23
|
+
import { partitionKey, scopeKey } from '../identity.js';
|
|
24
24
|
import { RelationsManager } from '../relations/manager.js';
|
|
25
|
-
import { defineRelationsConfig } from '../relations/define_relations_config.js';
|
|
25
|
+
import { defineRelationsConfig, assertRelationDeclared, assertObjectTypeDeclared } from '../relations/define_relations_config.js';
|
|
26
|
+
import { assertCheckManyObjects, distinctObjects, expandByPosition, RELATIONS_CHECK_MANY_MAX } from '../relations/check_many.js';
|
|
26
27
|
import { systemClock } from '../clock.js';
|
|
27
28
|
import { resolveGrantExpiry } from '../expiry.js';
|
|
28
29
|
import { isRelUserset } from '../types.js';
|
|
@@ -33,18 +34,26 @@ export const RELATIONS_HOLDER_TYPES = ['user', 'admin', 'integration'];
|
|
|
33
34
|
* La config FIJA del contrato: un tipo `document` con `owner ⊆ editor ⊆
|
|
34
35
|
* viewer` (includes de un nivel) y `membersOf` habilitado en `database`.
|
|
35
36
|
*/
|
|
37
|
+
/**
|
|
38
|
+
* **Los dos tipos del contrato** (plan 2.5 · lote A, H10): `document` y un
|
|
39
|
+
* SEGUNDO tipo con las MISMAS relaciones, `workspace`, para que «tipos
|
|
40
|
+
* mezclados con la misma relación responden por ítem» (`checkMany`, P4-7)
|
|
41
|
+
* sea observable con la config fija —solo con `document` y el `group`
|
|
42
|
+
* built-in no comparten nombre de relación—. Cambio anunciado del export
|
|
43
|
+
* publicado: un harness de terceros que afirme sobre el modelo publicado de
|
|
44
|
+
* esta config ve un tipo más.
|
|
45
|
+
*/
|
|
46
|
+
export const RELATIONS_CONTRACT_OBJECT_TYPES = ['document', 'workspace'];
|
|
47
|
+
/**
|
|
48
|
+
* La config FIJA del contrato: `document` y `workspace`, cada uno con `owner ⊆
|
|
49
|
+
* editor ⊆ viewer` (includes de un nivel) y `membersOf` habilitado en `database`.
|
|
50
|
+
*/
|
|
36
51
|
export function contractRelationsConfig() {
|
|
37
52
|
return defineRelationsConfig({
|
|
38
|
-
objectTypes:
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
{ name: 'owner' },
|
|
43
|
-
{ name: 'editor', includes: ['owner'] },
|
|
44
|
-
{ name: 'viewer', includes: ['editor'] },
|
|
45
|
-
],
|
|
46
|
-
},
|
|
47
|
-
],
|
|
53
|
+
objectTypes: RELATIONS_CONTRACT_OBJECT_TYPES.map((type) => ({
|
|
54
|
+
type,
|
|
55
|
+
relations: [{ name: 'owner' }, { name: 'editor', includes: ['owner'] }, { name: 'viewer', includes: ['editor'] }],
|
|
56
|
+
})),
|
|
48
57
|
holderTypes: [...RELATIONS_HOLDER_TYPES],
|
|
49
58
|
database: { membersOf: true },
|
|
50
59
|
});
|
|
@@ -62,8 +71,15 @@ export function lucidRelationsContractTransactions() {
|
|
|
62
71
|
},
|
|
63
72
|
};
|
|
64
73
|
}
|
|
74
|
+
/**
|
|
75
|
+
* La clave de partición del doble pasa por `partitionKey` (`src/identity.ts`), la MISMA puerta que los dos
|
|
76
|
+
* drivers reales (plan 2.5 · lote 0, cierre ⚪ 11; lote A, D4): una partición mal formada (`A#B`, mayúsculas) es
|
|
77
|
+
* 422 `E_AUTHZ_INVALID_IDENTITY` también en el fixture, y un uuid de 32 hex SIN guiones también (D4). Hasta el
|
|
78
|
+
* cierre del lote 0 componía la clave por plantilla y aceptaba cualquier cosa, así que la columna `partition`
|
|
79
|
+
* de la guardia de superficie no podía decir nada del doble.
|
|
80
|
+
*/
|
|
65
81
|
function scopeKeyOf(scope) {
|
|
66
|
-
return scope
|
|
82
|
+
return partitionKey(scope);
|
|
67
83
|
}
|
|
68
84
|
function subjectKeyOf(subject) {
|
|
69
85
|
if (isRelUserset(subject))
|
|
@@ -85,9 +101,21 @@ export function makeRelationsDriver(options) {
|
|
|
85
101
|
*/
|
|
86
102
|
function buildRelationsDouble(options, tuples, now) {
|
|
87
103
|
const { config, capabilities, limits } = options;
|
|
88
|
-
/**
|
|
89
|
-
|
|
90
|
-
|
|
104
|
+
/**
|
|
105
|
+
* **F-05 en el DOBLE** (plan 2.5 · lote 0, D13; hallazgo H8 del tester): hasta el lote 0 el doble
|
|
106
|
+
* publicado no re-validaba tipo/relación —solo el manager cortaba— y un driver de terceros podía
|
|
107
|
+
* calcar esa forma y pasar el contrato. El doble aplica las MISMAS dos funciones que los dos drivers
|
|
108
|
+
* reales (`assertRelationDeclared` estricta y `assertObjectTypeDeclared` de tipo), en las ocho
|
|
109
|
+
* operaciones y en el userset del sujeto. La guardia de superficie (`tests/guard_surface.spec.ts`,
|
|
110
|
+
* filas `double`) lo sondea en el doble y en su vista `withClock`.
|
|
111
|
+
*/
|
|
112
|
+
function assertSubjectDeclared(subject) {
|
|
113
|
+
if (isRelUserset(subject))
|
|
114
|
+
assertRelationDeclared(config, subject.object, subject.relation);
|
|
115
|
+
}
|
|
116
|
+
/** Caducidad ESTRICTA (R-15): la que vence AHORA ya no cuenta. `at` fija el instante (un `now` por lote en `checkMany`). */
|
|
117
|
+
function active(t, at = now) {
|
|
118
|
+
return t.expiresAt === null || t.expiresAt.getTime() > at().getTime();
|
|
91
119
|
}
|
|
92
120
|
/**
|
|
93
121
|
* `viewer` ⊇ `{viewer, editor, owner}`: las relaciones cuyo hecho DIRECTO
|
|
@@ -113,27 +141,27 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
113
141
|
return out;
|
|
114
142
|
}
|
|
115
143
|
/** Los hechos DIRECTOS vigentes (los caducados no conceden ni se enumeran, R-15). */
|
|
116
|
-
function directSubjects(partition, objectType, objectId, relation) {
|
|
144
|
+
function directSubjects(partition, objectType, objectId, relation, at = now) {
|
|
117
145
|
return tuples.filter((t) => t.partition === partition &&
|
|
118
146
|
t.objectType === objectType &&
|
|
119
147
|
t.objectId === objectId &&
|
|
120
148
|
t.relation === relation &&
|
|
121
|
-
active(t));
|
|
149
|
+
active(t, at));
|
|
122
150
|
}
|
|
123
|
-
function resolves(subject, relation, object, partition, seen = new Set()) {
|
|
151
|
+
function resolves(subject, relation, object, partition, seen = new Set(), at = now) {
|
|
124
152
|
const guardKey = `${subjectKeyOf(subject)}|${object.type}:${object.id}#${relation}`;
|
|
125
153
|
if (seen.has(guardKey))
|
|
126
154
|
return false;
|
|
127
155
|
seen.add(guardKey);
|
|
128
156
|
const wantKey = subjectKeyOf(subject);
|
|
129
157
|
for (const rel of expandDown(object.type, relation)) {
|
|
130
|
-
for (const t of directSubjects(partition, object.type, object.id, rel)) {
|
|
158
|
+
for (const t of directSubjects(partition, object.type, object.id, rel, at)) {
|
|
131
159
|
if (t.subjectKey === wantKey)
|
|
132
160
|
return true;
|
|
133
161
|
// El sujeto directo es un userset (`group:g#member`): un HOLDER lo
|
|
134
162
|
// satisface si es MIEMBRO del userset (transitivo, grupos anidan).
|
|
135
163
|
if (isRelUserset(t.subject) && !isRelUserset(subject)) {
|
|
136
|
-
if (resolves(subject, t.subject.relation, t.subject.object, partition, seen))
|
|
164
|
+
if (resolves(subject, t.subject.relation, t.subject.object, partition, seen, at))
|
|
137
165
|
return true;
|
|
138
166
|
}
|
|
139
167
|
}
|
|
@@ -173,6 +201,8 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
173
201
|
const driver = {
|
|
174
202
|
capabilities,
|
|
175
203
|
async relate(subject, relation, object, partition, options) {
|
|
204
|
+
assertRelationDeclared(config, object, relation);
|
|
205
|
+
assertSubjectDeclared(subject);
|
|
176
206
|
const partitionKey = scopeKeyOf(partition);
|
|
177
207
|
const subjectKey = subjectKeyOf(subject);
|
|
178
208
|
const existing = tuples.find((t) => t.partition === partitionKey &&
|
|
@@ -198,6 +228,8 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
198
228
|
});
|
|
199
229
|
},
|
|
200
230
|
async unrelate(subject, relation, object, partition) {
|
|
231
|
+
assertRelationDeclared(config, object, relation);
|
|
232
|
+
assertSubjectDeclared(subject);
|
|
201
233
|
const partitionKey = scopeKeyOf(partition);
|
|
202
234
|
const subjectKey = subjectKeyOf(subject);
|
|
203
235
|
for (let i = tuples.length - 1; i >= 0; i--) {
|
|
@@ -212,9 +244,13 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
212
244
|
}
|
|
213
245
|
},
|
|
214
246
|
async check(subject, relation, object, partition) {
|
|
247
|
+
assertRelationDeclared(config, object, relation);
|
|
248
|
+
assertSubjectDeclared(subject);
|
|
215
249
|
return resolves(subject, relation, object, scopeKeyOf(partition));
|
|
216
250
|
},
|
|
217
251
|
async listObjects(subject, relation, objectType, partition, page) {
|
|
252
|
+
assertRelationDeclared(config, { type: objectType }, relation);
|
|
253
|
+
assertSubjectDeclared(subject);
|
|
218
254
|
const partitionKey = scopeKeyOf(partition);
|
|
219
255
|
const ids = new Set();
|
|
220
256
|
for (const t of tuples) {
|
|
@@ -237,6 +273,7 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
237
273
|
return { objects: matches };
|
|
238
274
|
},
|
|
239
275
|
async listSubjects(relation, object, partition, page) {
|
|
276
|
+
assertRelationDeclared(config, object, relation);
|
|
240
277
|
const partitionKey = scopeKeyOf(partition);
|
|
241
278
|
// DIRECTOS del relation EXACTO (invariante 7): ni transitivo (eso es
|
|
242
279
|
// `membersOf`) ni derivado. `usersetSubjects` decide si los usersets
|
|
@@ -248,6 +285,7 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
248
285
|
return { subjects: dedupeSubjects(subjects) };
|
|
249
286
|
},
|
|
250
287
|
async purgeObject(object, partition) {
|
|
288
|
+
assertObjectTypeDeclared(config, object);
|
|
251
289
|
const partitionKey = scopeKeyOf(partition);
|
|
252
290
|
for (let i = tuples.length - 1; i >= 0; i--) {
|
|
253
291
|
const t = tuples[i];
|
|
@@ -257,6 +295,7 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
257
295
|
}
|
|
258
296
|
},
|
|
259
297
|
async purgeSubject(subject, partition) {
|
|
298
|
+
assertSubjectDeclared(subject);
|
|
260
299
|
const partitionKey = scopeKeyOf(partition);
|
|
261
300
|
const subjectKey = subjectKeyOf(subject);
|
|
262
301
|
for (let i = tuples.length - 1; i >= 0; i--) {
|
|
@@ -266,8 +305,27 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
266
305
|
}
|
|
267
306
|
},
|
|
268
307
|
};
|
|
308
|
+
// `checkMany` (plan 2.5 · lote A): el doble lo trae solo cuando declara
|
|
309
|
+
// `singleCheckRelations` —el driver que resuelve en UNA llamada es el que
|
|
310
|
+
// tiene un lote nativo—; el MÍNIMO no lo trae y así el runner ejerce el
|
|
311
|
+
// respaldo del manager (N `check`, P4-9) sin una capacidad nueva.
|
|
312
|
+
if (capabilities.singleCheckRelations) {
|
|
313
|
+
driver.checkMany = async (subject, relation, objects, partition) => {
|
|
314
|
+
assertCheckManyObjects(objects);
|
|
315
|
+
for (const object of objects)
|
|
316
|
+
assertRelationDeclared(config, object, relation);
|
|
317
|
+
assertSubjectDeclared(subject);
|
|
318
|
+
const key = scopeKeyOf(partition);
|
|
319
|
+
const { distinct, index } = distinctObjects(objects);
|
|
320
|
+
// UN solo `now` para todo el lote (A-18): se fija aquí y `resolves` lo lee.
|
|
321
|
+
const at = now();
|
|
322
|
+
const answers = distinct.map((object) => resolves(subject, relation, object, key, new Set(), () => at));
|
|
323
|
+
return expandByPosition(index, answers);
|
|
324
|
+
};
|
|
325
|
+
}
|
|
269
326
|
if (capabilities.membersOfNative) {
|
|
270
327
|
driver.membersOf = async (object, relation, partition) => {
|
|
328
|
+
assertRelationDeclared(config, object, relation);
|
|
271
329
|
return { subjects: collectMembers(object, relation, scopeKeyOf(partition)) };
|
|
272
330
|
};
|
|
273
331
|
}
|
|
@@ -720,6 +778,198 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
720
778
|
assert.lengthOf(strictEvents, 1);
|
|
721
779
|
assert.deepEqual(strictEvents[0].actor, actor);
|
|
722
780
|
});
|
|
781
|
+
/* ── P4 · checkMany (plan 2.5 · lote A; A-9…A-14 del tester) ────────── */
|
|
782
|
+
/**
|
|
783
|
+
* **`checkMany` ≡ N `check`, posición a posición** (A-9, P4-1): allow directo,
|
|
784
|
+
* includes (`editor` ⊆ `viewer`), userset ANIDADO, caducado en el pasado y
|
|
785
|
+
* «nada». Y el orden de la RESPUESTA es el de la PETICIÓN (A-10, P4-2): los
|
|
786
|
+
* ids del lote van en orden inverso al de escritura y el patrón esperado
|
|
787
|
+
* es F,T,F,T — un driver que mapease por orden de fila de SQL o por el
|
|
788
|
+
* orden en que llegan los resultados del backend se pone rojo.
|
|
789
|
+
*/
|
|
790
|
+
test('P4-1/P4-2 · checkMany ≡ N check posición a posición (allow, includes, userset anidado, caducado, nada) y responde en el ORDEN de la petición (F,T,F,T con ids en orden inverso)', async ({ assert, }) => {
|
|
791
|
+
const u = holder();
|
|
792
|
+
const p = partition();
|
|
793
|
+
const direct = { type: 'document', id: docId() };
|
|
794
|
+
const viaInclude = { type: 'document', id: docId() };
|
|
795
|
+
const viaNested = { type: 'document', id: docId() };
|
|
796
|
+
const expired = { type: 'document', id: docId() };
|
|
797
|
+
const nothing = { type: 'document', id: docId() };
|
|
798
|
+
const g1 = { type: 'group', id: docId() };
|
|
799
|
+
const g2 = { type: 'group', id: docId() };
|
|
800
|
+
await state.manager.relate(u, 'viewer', direct, p);
|
|
801
|
+
await state.manager.relate(u, 'editor', viaInclude, p);
|
|
802
|
+
await state.manager.relate(u, 'member', g1, p);
|
|
803
|
+
await state.manager.relate({ object: g1, relation: 'member' }, 'member', g2, p);
|
|
804
|
+
await state.manager.relate({ object: g2, relation: 'member' }, 'viewer', viaNested, p);
|
|
805
|
+
await state.manager.relate(u, 'viewer', expired, p, { expiresAt: new Date(Date.now() - 60_000) });
|
|
806
|
+
const objects = [direct, viaInclude, viaNested, expired, nothing];
|
|
807
|
+
const one = [];
|
|
808
|
+
for (const object of objects)
|
|
809
|
+
one.push(await state.manager.check(u, 'viewer', object, p));
|
|
810
|
+
assert.deepEqual(one, [true, true, true, false, false], 'precondición: N check');
|
|
811
|
+
assert.deepEqual(await state.manager.checkMany(u, 'viewer', objects, p), one, 'checkMany ≡ N check, posición a posición');
|
|
812
|
+
// P4-2: ids en orden INVERSO al de escritura y respuesta F,T,F,T.
|
|
813
|
+
const a = { type: 'document', id: docId() };
|
|
814
|
+
const b = { type: 'document', id: docId() };
|
|
815
|
+
const c = { type: 'document', id: docId() };
|
|
816
|
+
const d = { type: 'document', id: docId() };
|
|
817
|
+
await state.manager.relate(u, 'viewer', c, p);
|
|
818
|
+
await state.manager.relate(u, 'viewer', a, p);
|
|
819
|
+
const sorted = [a, b, c, d].sort((x, y) => (x.id < y.id ? -1 : 1));
|
|
820
|
+
// [d', c', b', a'] con T solo en los que se escribieron (c y a): el patrón
|
|
821
|
+
// depende del orden de ids, así que se calcula con N check y se exige igual.
|
|
822
|
+
const request = [sorted[3], sorted[2], sorted[1], sorted[0]];
|
|
823
|
+
const expected = [];
|
|
824
|
+
for (const object of request)
|
|
825
|
+
expected.push(await state.manager.check(u, 'viewer', object, p));
|
|
826
|
+
assert.sameMembers(expected, [true, true, false, false]);
|
|
827
|
+
assert.deepEqual(await state.manager.checkMany(u, 'viewer', request, p), expected, 'el orden de la respuesta es el de la petición');
|
|
828
|
+
assert.deepEqual(await state.manager.checkMany(u, 'viewer', [...request].reverse(), p), [...expected].reverse());
|
|
829
|
+
});
|
|
830
|
+
/** A-11 (P4-4a): duplicados permitidos y respondidos por POSICIÓN. */
|
|
831
|
+
test('P4-4a · checkMany con duplicados: [a, a, b] ⇒ [x, x, y] (por posición, sin deduplicar la respuesta)', async ({ assert }) => {
|
|
832
|
+
const u = holder();
|
|
833
|
+
const p = partition();
|
|
834
|
+
const a = { type: 'document', id: docId() };
|
|
835
|
+
const b = { type: 'document', id: docId() };
|
|
836
|
+
await state.manager.relate(u, 'viewer', a, p);
|
|
837
|
+
assert.deepEqual(await state.manager.checkMany(u, 'viewer', [a, a, b], p), [true, true, false]);
|
|
838
|
+
assert.deepEqual(await state.manager.checkMany(u, 'viewer', [b, a, b, a], p), [false, true, false, true]);
|
|
839
|
+
});
|
|
840
|
+
/** A-12 (P4-5): el tope 50 responde; 51 y [] son 422 con CERO llamadas al driver. */
|
|
841
|
+
test(`P4-5 · checkMany con ${RELATIONS_CHECK_MANY_MAX} objetos responde; ${RELATIONS_CHECK_MANY_MAX + 1} ⇒ 422 E_AUTHZ_CHECK_MANY_BATCH y [] ⇒ 422, ambos con CERO llamadas al driver`, async ({ assert, }) => {
|
|
842
|
+
const u = holder();
|
|
843
|
+
const p = partition();
|
|
844
|
+
const fifty = [];
|
|
845
|
+
for (let i = 0; i < RELATIONS_CHECK_MANY_MAX; i++)
|
|
846
|
+
fifty.push({ type: 'document', id: docId() });
|
|
847
|
+
await state.manager.relate(u, 'viewer', fifty[RELATIONS_CHECK_MANY_MAX - 1], p);
|
|
848
|
+
const answers = await state.manager.checkMany(u, 'viewer', fifty, p);
|
|
849
|
+
assert.lengthOf(answers, RELATIONS_CHECK_MANY_MAX);
|
|
850
|
+
assert.isTrue(answers[RELATIONS_CHECK_MANY_MAX - 1]);
|
|
851
|
+
assert.isFalse(answers[0]);
|
|
852
|
+
const before = state.calls();
|
|
853
|
+
for (const [label, objects] of [
|
|
854
|
+
['51', [...fifty, { type: 'document', id: docId() }]],
|
|
855
|
+
['[]', []],
|
|
856
|
+
]) {
|
|
857
|
+
const caught = await state.manager.checkMany(u, 'viewer', objects, p).then(() => null, (e) => e);
|
|
858
|
+
assert.isNotNull(caught, `ROJO (${label}): checkMany respondió en vez de rechazar`);
|
|
859
|
+
assert.equal(caught.status, 422, `${label}: ${caught.message}`);
|
|
860
|
+
assert.equal(caught.code, 'E_AUTHZ_CHECK_MANY_BATCH', label);
|
|
861
|
+
}
|
|
862
|
+
assert.equal(state.calls(), before, 'CERO llamadas al driver: el tope y el lote vacío se juzgan antes');
|
|
863
|
+
});
|
|
864
|
+
/** A-13 (P4-6): F-05 por ÍTEM — un `role_binding` en la ÚLTIMA posición es 422 con cero llamadas. */
|
|
865
|
+
test('P4-6 · checkMany · F-05 por ítem: un role_binding en la posición 21 de 21 ⇒ 422 E_AUTHZ_RELATION_TYPE_UNKNOWN con CERO llamadas al driver; un id inválido en la última posición ⇒ 422 E_AUTHZ_INVALID_IDENTITY', async ({ assert, }) => {
|
|
866
|
+
const u = holder();
|
|
867
|
+
const p = partition();
|
|
868
|
+
const twenty = [];
|
|
869
|
+
for (let i = 0; i < 20; i++)
|
|
870
|
+
twenty.push({ type: 'document', id: docId() });
|
|
871
|
+
const before = state.calls();
|
|
872
|
+
const evil = await state.manager.checkMany(u, 'viewer', [...twenty, { type: 'role_binding', id: docId() }], p).then(() => null, (e) => e);
|
|
873
|
+
assert.isNotNull(evil, 'ROJO: un role_binding en la posición 21 entró');
|
|
874
|
+
assert.equal(evil.status, 422, String(evil?.message));
|
|
875
|
+
assert.equal(evil.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN');
|
|
876
|
+
const bad = await state.manager.checkMany(u, 'viewer', [...twenty, { type: 'document', id: 'A#B' }], p).then(() => null, (e) => e);
|
|
877
|
+
assert.equal(bad?.status, 422, String(bad?.message));
|
|
878
|
+
assert.equal(bad?.code, 'E_AUTHZ_INVALID_IDENTITY');
|
|
879
|
+
const undeclared = await state.manager.checkMany(u, 'assignee', twenty, p).then(() => null, (e) => e);
|
|
880
|
+
assert.equal(undeclared?.code, 'E_AUTHZ_RELATION_UNKNOWN', 'la relación no declarada también es 422');
|
|
881
|
+
assert.equal(state.calls(), before, 'CERO llamadas al driver');
|
|
882
|
+
});
|
|
883
|
+
/** A-14 (P4-7, H10): tipos MEZCLADOS con la misma relación responden por ítem (`document` y `workspace`). */
|
|
884
|
+
test('P4-7 · checkMany con tipos mezclados (document y workspace) y la misma relación responde por ítem, con los includes de CADA tipo', async ({ assert, }) => {
|
|
885
|
+
const u = holder();
|
|
886
|
+
const p = partition();
|
|
887
|
+
const doc = { type: 'document', id: docId() };
|
|
888
|
+
const ws = { type: 'workspace', id: docId() };
|
|
889
|
+
const otherWs = { type: 'workspace', id: docId() };
|
|
890
|
+
const otherDoc = { type: 'document', id: docId() };
|
|
891
|
+
await state.manager.relate(u, 'viewer', doc, p);
|
|
892
|
+
await state.manager.relate(u, 'editor', ws, p); // includes del tipo workspace: editor ⊆ viewer
|
|
893
|
+
// El mismo id en los DOS tipos: el tipo forma parte de la identidad (no se cruzan).
|
|
894
|
+
const twin = { type: 'workspace', id: doc.id };
|
|
895
|
+
assert.deepEqual(await state.manager.checkMany(u, 'viewer', [doc, ws, otherWs, otherDoc, twin, ws, doc], p), [true, true, false, false, false, true, true]);
|
|
896
|
+
assert.deepEqual(await state.manager.checkMany(u, 'owner', [doc, ws], p), [false, false], 'owner no se deriva de editor/viewer');
|
|
897
|
+
});
|
|
898
|
+
/* ── D13 · el DRIVER llamado directamente también corta F-05 (plan 2.5 · lote A) ── */
|
|
899
|
+
/**
|
|
900
|
+
* **F-05 vive también en el driver** (D13, decisión del dueño 2026-10-14;
|
|
901
|
+
* tester H8): hasta el lote A el runner solo exigía el corte en el MANAGER,
|
|
902
|
+
* así que un driver de terceros podía no re-validar y pasar el contrato —
|
|
903
|
+
* y por `manager.driver()` o por `reconcileRelations` (que escribe por el
|
|
904
|
+
* driver del destino) componer el id de un binding real en un store
|
|
905
|
+
* compartido. Ahora se llama al driver del harness EN DIRECTO, sin manager:
|
|
906
|
+
* las ocho operaciones (y `checkMany`, y `membersOf` cuando el driver lo
|
|
907
|
+
* trae nativo: 🟡 A7) rechazan un tipo no declarado con el MISMO 422 que
|
|
908
|
+
* el manager, y el `checkMany` directo corta el tope y el lote vacío (⚪ A12). **Ruptura declarada** para un harness de
|
|
909
|
+
* terceros que sembrara tipos no declarados por el driver.
|
|
910
|
+
*/
|
|
911
|
+
test('D13 · F-05 en el DRIVER llamado directamente (sin manager): relate/unrelate/check/checkMany/listObjects/listSubjects/purgeObject/purgeSubject(userset) —y membersOf si es nativo (A7)— contra role_binding ⇒ 422 E_AUTHZ_RELATION_TYPE_UNKNOWN, una relación no declarada ⇒ 422 E_AUTHZ_RELATION_UNKNOWN, y checkMany directo corta 51 y [] (A12)', async ({ assert, }) => {
|
|
912
|
+
const driver = state.base;
|
|
913
|
+
const u = holder();
|
|
914
|
+
const S = partition();
|
|
915
|
+
const binding = { type: 'role_binding', id: docId() };
|
|
916
|
+
const doc = { type: 'document', id: docId() };
|
|
917
|
+
const typeAttempts = [
|
|
918
|
+
['relate', () => driver.relate(u, 'assignee', binding, S)],
|
|
919
|
+
['unrelate', () => driver.unrelate(u, 'assignee', binding, S)],
|
|
920
|
+
['check', () => driver.check(u, 'assignee', binding, S)],
|
|
921
|
+
// `viewer` está declarada para `doc` ([0]): lo ÚNICO que falla es el role_binding de [1] (F-05 por ítem).
|
|
922
|
+
['checkMany', () => (typeof driver.checkMany === 'function' ? driver.checkMany(u, 'viewer', [doc, binding], S) : driver.check(u, 'assignee', binding, S))],
|
|
923
|
+
['listObjects', () => driver.listObjects(u, 'assignee', 'role_binding', S)],
|
|
924
|
+
['listSubjects', () => driver.listSubjects('assignee', binding, S)],
|
|
925
|
+
['purgeObject', () => driver.purgeObject(binding, S)],
|
|
926
|
+
['purgeSubject(userset)', () => driver.purgeSubject({ object: binding, relation: 'role' }, S)],
|
|
927
|
+
// 🟡 A7 (auditor del lote A): la NOVENA operación, cuando el driver la trae nativa — un `membersOf`
|
|
928
|
+
// sin F-05 enumera los asignados de un rol del store compartido (la clase del 🟠 2 de alpha.3).
|
|
929
|
+
...(harness.capabilities.membersOfNative && typeof driver.membersOf === 'function'
|
|
930
|
+
? [['membersOf', () => driver.membersOf(binding, 'assignee', S)]]
|
|
931
|
+
: []),
|
|
932
|
+
];
|
|
933
|
+
assert.equal(typeAttempts.length, harness.capabilities.membersOfNative && typeof driver.membersOf === 'function' ? 9 : 8, 'las nueve operaciones del puerto (ocho sin membersOf nativo)');
|
|
934
|
+
for (const [label, run] of typeAttempts) {
|
|
935
|
+
const caught = await run().then(() => null, (e) => e);
|
|
936
|
+
assert.isNotNull(caught, `ROJO (D13 · driver.${label}): el driver ENTRÓ contra role_binding sin el manager delante`);
|
|
937
|
+
assert.equal(caught.status, 422, `driver.${label}: ${caught.message}`);
|
|
938
|
+
assert.equal(caught.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN', `driver.${label}`);
|
|
939
|
+
}
|
|
940
|
+
for (const [label, run] of [
|
|
941
|
+
['relate', () => driver.relate(u, 'assignee', doc, S)],
|
|
942
|
+
['check', () => driver.check(u, 'assignee', doc, S)],
|
|
943
|
+
['listSubjects', () => driver.listSubjects('assignee', doc, S)],
|
|
944
|
+
]) {
|
|
945
|
+
const caught = await run().then(() => null, (e) => e);
|
|
946
|
+
assert.equal(caught?.status, 422, `driver.${label}: ${caught?.message}`);
|
|
947
|
+
assert.equal(caught?.code, 'E_AUTHZ_RELATION_UNKNOWN', `driver.${label}`);
|
|
948
|
+
}
|
|
949
|
+
// ⚪ A12 (auditor del lote A): el tope y el lote vacío también se cortan en el DRIVER directo.
|
|
950
|
+
if (typeof driver.checkMany === 'function') {
|
|
951
|
+
const many = [];
|
|
952
|
+
for (let i = 0; i <= RELATIONS_CHECK_MANY_MAX; i++)
|
|
953
|
+
many.push({ type: 'document', id: docId() });
|
|
954
|
+
for (const [label, objects] of [
|
|
955
|
+
[`${RELATIONS_CHECK_MANY_MAX + 1}`, many],
|
|
956
|
+
['[]', []],
|
|
957
|
+
]) {
|
|
958
|
+
const caught = await driver.checkMany(u, 'viewer', objects, S).then(() => null, (e) => e);
|
|
959
|
+
assert.isNotNull(caught, `ROJO (A12 · driver.checkMany ${label}): el driver directo no corta el lote`);
|
|
960
|
+
assert.equal(caught.status, 422, `driver.checkMany ${label}: ${caught.message}`);
|
|
961
|
+
assert.equal(caught.code, 'E_AUTHZ_CHECK_MANY_BATCH', `driver.checkMany ${label}`);
|
|
962
|
+
}
|
|
963
|
+
assert.lengthOf(await driver.checkMany(u, 'viewer', many.slice(0, RELATIONS_CHECK_MANY_MAX), S), RELATIONS_CHECK_MANY_MAX, 'el tope exacto responde');
|
|
964
|
+
}
|
|
965
|
+
// Control: lo declarado entra por el driver y el store no tiene ni rastro del binding.
|
|
966
|
+
await driver.relate(u, 'viewer', doc, S);
|
|
967
|
+
assert.isTrue(await driver.check(u, 'viewer', doc, S));
|
|
968
|
+
if (typeof driver.enumerateRelations === 'function' && harness.capabilities.enumerateRelations) {
|
|
969
|
+
const page = await driver.enumerateRelations(S);
|
|
970
|
+
assert.isFalse(page.tuples.some((t) => t.object.type === 'role_binding'), 'cero tuplas role_binding');
|
|
971
|
+
}
|
|
972
|
+
});
|
|
723
973
|
/* ── R-15 · caducidad de la tupla (núcleo, sin reloj): estricta y validada ── */
|
|
724
974
|
test('R-15 · relate con expiresAt pasado NO concede (check/listObjects/listSubjects); futuro sí; expiresAt inválido ⇒ 422 sin tocar el driver', async ({ assert, }) => {
|
|
725
975
|
const u = holder();
|
|
@@ -972,6 +1222,32 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
972
1222
|
// El driver del harness (sin reloj inyectado) no se ha tocado: para él
|
|
973
1223
|
// (hoy) T2 —2030— es futuro y la relación renovada concede.
|
|
974
1224
|
assert.isTrue(await state.manager.check(lift, 'viewer', doc, p));
|
|
1225
|
+
// **A-18 · un solo `now` por lote** (plan 2.5 · lote A): un reloj que
|
|
1226
|
+
// AVANZA 1 ms por lectura y tres tuplas que caducan en T3+1, T3+2 y
|
|
1227
|
+
// T3+3. `checkMany` lee el reloj UNA vez (T3) y las tres conceden;
|
|
1228
|
+
// un driver que leyera `now()` por ítem vería caducar la segunda o
|
|
1229
|
+
// la tercera entre ítems (mutante: `now()` por ítem ⇒ rojo). Solo
|
|
1230
|
+
// donde el driver trae `checkMany`: el respaldo del manager son N
|
|
1231
|
+
// `check`, cada uno con su instante (y así va escrito en el README).
|
|
1232
|
+
if (typeof state.base.checkMany === 'function') {
|
|
1233
|
+
const T3 = new Date('2031-01-01T00:00:00.000Z');
|
|
1234
|
+
const tickClock = fixedClock(T3);
|
|
1235
|
+
const batch = holder();
|
|
1236
|
+
const b1 = { type: 'document', id: docId() };
|
|
1237
|
+
const b2 = { type: 'document', id: docId() };
|
|
1238
|
+
const b3 = { type: 'document', id: docId() };
|
|
1239
|
+
const ticking = new RelationsManager(state.base.withClock(tickClock.now), contractRelationsConfig());
|
|
1240
|
+
await ticking.relate(batch, 'viewer', b1, p, { expiresAt: new Date(T3.getTime() + 1) });
|
|
1241
|
+
await ticking.relate(batch, 'viewer', b2, p, { expiresAt: new Date(T3.getTime() + 2) });
|
|
1242
|
+
await ticking.relate(batch, 'viewer', b3, p, { expiresAt: new Date(T3.getTime() + 3) });
|
|
1243
|
+
let reads = 0;
|
|
1244
|
+
const advancing = new RelationsManager(state.base.withClock(() => new Date(T3.getTime() + reads++)), contractRelationsConfig());
|
|
1245
|
+
assert.deepEqual(await advancing.checkMany(batch, 'viewer', [b1, b2, b3, b1, b2, b3], p), [true, true, true, true, true, true], 'un solo now por lote: las tres conceden en T3');
|
|
1246
|
+
assert.equal(reads, 1, 'checkMany leyó el reloj EXACTAMENTE una vez para todo el lote');
|
|
1247
|
+
// Y con el reloj ya en T3+3 ninguna concede (estricta): el lote decide con UN instante, no con el primero cacheado.
|
|
1248
|
+
reads = 3;
|
|
1249
|
+
assert.deepEqual(await advancing.checkMany(batch, 'viewer', [b1, b2, b3], p), [false, false, false]);
|
|
1250
|
+
}
|
|
975
1251
|
}),
|
|
976
1252
|
whenFalse: () => test('injectableClock:false · expiresAt en tres estados en tiempo real (1,5 s): omitido preserva, null quita, caducada revive; sin withClock', async ({ assert, }) => {
|
|
977
1253
|
assert.notTypeOf(state.base.withClock, 'function', 'el harness declara injectableClock: false y el driver trae withClock: declara lo observable');
|