@jantstack/adonis-authz 2.4.0-alpha.1 → 2.4.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +248 -18
- package/build/commands/authz_relations_reconcile.d.ts +25 -1
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
- package/build/commands/authz_relations_reconcile.js +94 -6
- package/build/commands/authz_relations_reconcile.js.map +1 -1
- package/build/index.d.ts +12 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +10 -0
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +7 -0
- package/build/providers/authz_provider.js.map +1 -1
- package/build/src/define_config.d.ts +36 -0
- package/build/src/define_config.d.ts.map +1 -1
- package/build/src/define_config.js.map +1 -1
- package/build/src/drivers/database_driver.d.ts +53 -14
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +104 -32
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +47 -9
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/database_relations_driver.js +318 -89
- package/build/src/drivers/database_relations_driver.js.map +1 -1
- package/build/src/drivers/openfga_driver.d.ts +17 -4
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +38 -3
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_facts.d.ts +15 -0
- package/build/src/drivers/openfga_facts.d.ts.map +1 -1
- package/build/src/drivers/openfga_facts.js +25 -2
- package/build/src/drivers/openfga_facts.js.map +1 -1
- package/build/src/drivers/openfga_relations_driver.d.ts +27 -6
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_relations_driver.js +140 -16
- package/build/src/drivers/openfga_relations_driver.js.map +1 -1
- package/build/src/errors.d.ts +35 -3
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +61 -3
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +37 -16
- package/build/src/freeze.d.ts.map +1 -1
- package/build/src/freeze.js +53 -8
- package/build/src/freeze.js.map +1 -1
- package/build/src/manager.d.ts +16 -1
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +116 -25
- package/build/src/manager.js.map +1 -1
- package/build/src/models/authz_assignment.d.ts +2 -2
- package/build/src/models/authz_assignment.d.ts.map +1 -1
- package/build/src/models/authz_deny.d.ts +2 -2
- package/build/src/models/authz_deny.d.ts.map +1 -1
- package/build/src/models/authz_permission.d.ts +2 -2
- package/build/src/models/authz_permission.d.ts.map +1 -1
- package/build/src/models/authz_role.d.ts +2 -2
- package/build/src/models/authz_role.d.ts.map +1 -1
- package/build/src/models/authz_role_permission.d.ts +2 -2
- package/build/src/models/authz_role_permission.d.ts.map +1 -1
- package/build/src/relation_partition_trigger.d.ts.map +1 -1
- package/build/src/relation_partition_trigger.js +11 -3
- package/build/src/relation_partition_trigger.js.map +1 -1
- package/build/src/relations/define_relations_config.d.ts +17 -0
- package/build/src/relations/define_relations_config.d.ts.map +1 -1
- package/build/src/relations/define_relations_config.js +30 -1
- package/build/src/relations/define_relations_config.js.map +1 -1
- package/build/src/relations/manager.d.ts +25 -3
- package/build/src/relations/manager.d.ts.map +1 -1
- package/build/src/relations/manager.js +76 -19
- package/build/src/relations/manager.js.map +1 -1
- package/build/src/relations/reconcile.d.ts +53 -4
- package/build/src/relations/reconcile.d.ts.map +1 -1
- package/build/src/relations/reconcile.js +92 -30
- package/build/src/relations/reconcile.js.map +1 -1
- package/build/src/scope_outbox.d.ts.map +1 -1
- package/build/src/scope_outbox.js +17 -10
- package/build/src/scope_outbox.js.map +1 -1
- package/build/src/shared/backend_guard.d.ts +28 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -1
- package/build/src/shared/backend_guard.js +52 -0
- package/build/src/shared/backend_guard.js.map +1 -1
- package/build/src/shared/transaction_guard.d.ts +50 -0
- package/build/src/shared/transaction_guard.d.ts.map +1 -0
- package/build/src/shared/transaction_guard.js +60 -0
- package/build/src/shared/transaction_guard.js.map +1 -0
- package/build/src/testing/contract.d.ts +55 -4
- package/build/src/testing/contract.d.ts.map +1 -1
- package/build/src/testing/contract.js +240 -8
- package/build/src/testing/contract.js.map +1 -1
- package/build/src/testing/main.d.ts +4 -4
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +2 -2
- package/build/src/testing/main.js.map +1 -1
- package/build/src/testing/relations_contract.d.ts +23 -1
- package/build/src/testing/relations_contract.d.ts.map +1 -1
- package/build/src/testing/relations_contract.js +432 -17
- package/build/src/testing/relations_contract.js.map +1 -1
- package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
- package/build/src/testing/relations_reconcile_contract.js +49 -1
- package/build/src/testing/relations_reconcile_contract.js.map +1 -1
- package/build/src/traits/has_uuid.d.ts +2 -2
- package/build/src/traits/has_uuid.d.ts.map +1 -1
- package/build/src/types.d.ts +197 -10
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js.map +1 -1
- package/build/stubs/migration.stub +20 -10
- package/package.json +1 -1
|
@@ -19,8 +19,12 @@
|
|
|
19
19
|
*/
|
|
20
20
|
import { v7 as uuidv7 } from 'uuid';
|
|
21
21
|
import { test as japaTest } from '@japa/runner';
|
|
22
|
+
import db from '@adonisjs/lucid/services/db';
|
|
23
|
+
import { scopeKey } from '../identity.js';
|
|
22
24
|
import { RelationsManager } from '../relations/manager.js';
|
|
23
25
|
import { defineRelationsConfig } from '../relations/define_relations_config.js';
|
|
26
|
+
import { systemClock } from '../clock.js';
|
|
27
|
+
import { resolveGrantExpiry } from '../expiry.js';
|
|
24
28
|
import { isRelUserset } from '../types.js';
|
|
25
29
|
/* ── El vocabulario del contrato ─────────────────────────────────────────── */
|
|
26
30
|
/** Los holder types del contrato de relaciones. */
|
|
@@ -45,6 +49,19 @@ export function contractRelationsConfig() {
|
|
|
45
49
|
database: { membersOf: true },
|
|
46
50
|
});
|
|
47
51
|
}
|
|
52
|
+
/** El default de `RelationsDriverContractHarness.transactions`: Lucid + `authz_relations`. */
|
|
53
|
+
export function lucidRelationsContractTransactions() {
|
|
54
|
+
return {
|
|
55
|
+
async begin() {
|
|
56
|
+
const trx = await db.transaction();
|
|
57
|
+
return { client: trx, commit: () => trx.commit(), rollback: () => trx.rollback() };
|
|
58
|
+
},
|
|
59
|
+
async census(partition) {
|
|
60
|
+
const rows = await db.from('authz_relations').where('partition_key', scopeKey(partition)).count('* as n');
|
|
61
|
+
return Number(rows[0]?.n ?? 0);
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
48
65
|
function scopeKeyOf(scope) {
|
|
49
66
|
return scope.type === 'app' ? 'app' : `${scope.type}|${scope.uuid}`;
|
|
50
67
|
}
|
|
@@ -59,8 +76,19 @@ function subjectKeyOf(subject) {
|
|
|
59
76
|
* que prueba el CONTRATO —no el MODELO fusionado (eso es el `:8101` en 4-4)—.
|
|
60
77
|
*/
|
|
61
78
|
export function makeRelationsDriver(options) {
|
|
79
|
+
return buildRelationsDouble(options, [], systemClock);
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* El doble, sobre un conjunto de tuplas COMPARTIDO y un reloj: `withClock`
|
|
83
|
+
* (solo con `injectableClock: true`) devuelve otra vista sobre las MISMAS
|
|
84
|
+
* tuplas con otro `now`, como los drivers reales (R-15).
|
|
85
|
+
*/
|
|
86
|
+
function buildRelationsDouble(options, tuples, now) {
|
|
62
87
|
const { config, capabilities, limits } = options;
|
|
63
|
-
|
|
88
|
+
/** Caducidad ESTRICTA (R-15): la que vence AHORA ya no cuenta. */
|
|
89
|
+
function active(t) {
|
|
90
|
+
return t.expiresAt === null || t.expiresAt.getTime() > now().getTime();
|
|
91
|
+
}
|
|
64
92
|
/**
|
|
65
93
|
* `viewer` ⊇ `{viewer, editor, owner}`: las relaciones cuyo hecho DIRECTO
|
|
66
94
|
* satisface R. `viewer includes editor` ⇒ un `editor` es también `viewer`,
|
|
@@ -84,11 +112,13 @@ export function makeRelationsDriver(options) {
|
|
|
84
112
|
}
|
|
85
113
|
return out;
|
|
86
114
|
}
|
|
115
|
+
/** Los hechos DIRECTOS vigentes (los caducados no conceden ni se enumeran, R-15). */
|
|
87
116
|
function directSubjects(partition, objectType, objectId, relation) {
|
|
88
117
|
return tuples.filter((t) => t.partition === partition &&
|
|
89
118
|
t.objectType === objectType &&
|
|
90
119
|
t.objectId === objectId &&
|
|
91
|
-
t.relation === relation
|
|
120
|
+
t.relation === relation &&
|
|
121
|
+
active(t));
|
|
92
122
|
}
|
|
93
123
|
function resolves(subject, relation, object, partition, seen = new Set()) {
|
|
94
124
|
const guardKey = `${subjectKeyOf(subject)}|${object.type}:${object.id}#${relation}`;
|
|
@@ -142,24 +172,30 @@ export function makeRelationsDriver(options) {
|
|
|
142
172
|
}
|
|
143
173
|
const driver = {
|
|
144
174
|
capabilities,
|
|
145
|
-
async relate(subject, relation, object, partition) {
|
|
175
|
+
async relate(subject, relation, object, partition, options) {
|
|
146
176
|
const partitionKey = scopeKeyOf(partition);
|
|
147
177
|
const subjectKey = subjectKeyOf(subject);
|
|
148
|
-
const
|
|
178
|
+
const existing = tuples.find((t) => t.partition === partitionKey &&
|
|
149
179
|
t.objectType === object.type &&
|
|
150
180
|
t.objectId === object.id &&
|
|
151
181
|
t.relation === relation &&
|
|
152
182
|
t.subjectKey === subjectKey);
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
subject,
|
|
160
|
-
subjectKey,
|
|
161
|
-
});
|
|
183
|
+
// R-15, los tres estados (invariante 10): omitido preserva la vigente
|
|
184
|
+
// (una caducada revive sin caducidad), null la quita, Date la fija.
|
|
185
|
+
const expiresAt = resolveGrantExpiry(existing?.expiresAt ?? null, options?.expiresAt, now());
|
|
186
|
+
if (existing) {
|
|
187
|
+
existing.expiresAt = expiresAt;
|
|
188
|
+
return;
|
|
162
189
|
}
|
|
190
|
+
tuples.push({
|
|
191
|
+
partition: partitionKey,
|
|
192
|
+
objectType: object.type,
|
|
193
|
+
objectId: object.id,
|
|
194
|
+
relation,
|
|
195
|
+
subject,
|
|
196
|
+
subjectKey,
|
|
197
|
+
expiresAt,
|
|
198
|
+
});
|
|
163
199
|
},
|
|
164
200
|
async unrelate(subject, relation, object, partition) {
|
|
165
201
|
const partitionKey = scopeKeyOf(partition);
|
|
@@ -182,7 +218,7 @@ export function makeRelationsDriver(options) {
|
|
|
182
218
|
const partitionKey = scopeKeyOf(partition);
|
|
183
219
|
const ids = new Set();
|
|
184
220
|
for (const t of tuples) {
|
|
185
|
-
if (t.partition === partitionKey && t.objectType === objectType)
|
|
221
|
+
if (t.partition === partitionKey && t.objectType === objectType && active(t))
|
|
186
222
|
ids.add(t.objectId);
|
|
187
223
|
}
|
|
188
224
|
const matches = [];
|
|
@@ -238,6 +274,8 @@ export function makeRelationsDriver(options) {
|
|
|
238
274
|
if (capabilities.enumerateRelations) {
|
|
239
275
|
driver.enumerateRelations = async (partition) => {
|
|
240
276
|
const partitionKey = scopeKeyOf(partition);
|
|
277
|
+
// SIN filtrar la caducada (R-15): llega con su `expiresAt` para que el
|
|
278
|
+
// destino de reconcile la cuente en `skipped`, no para que desaparezca.
|
|
241
279
|
const out = tuples
|
|
242
280
|
.filter((t) => t.partition === partitionKey)
|
|
243
281
|
.map((t) => ({
|
|
@@ -245,12 +283,30 @@ export function makeRelationsDriver(options) {
|
|
|
245
283
|
relation: t.relation,
|
|
246
284
|
object: { type: t.objectType, id: t.objectId },
|
|
247
285
|
partition,
|
|
286
|
+
expiresAt: t.expiresAt,
|
|
248
287
|
}));
|
|
249
288
|
return { tuples: out };
|
|
250
289
|
};
|
|
251
290
|
}
|
|
291
|
+
if (capabilities.injectableClock) {
|
|
292
|
+
// La vista con otro reloj comparte las tuplas (como `Object.create(this)`
|
|
293
|
+
// en los drivers reales). Solo existe si se declara: la cara `false` del
|
|
294
|
+
// juez exige que un driver sin la capacidad NO traiga `withClock`.
|
|
295
|
+
driver.withClock = (next) => buildRelationsDouble(options, tuples, next);
|
|
296
|
+
}
|
|
252
297
|
return driver;
|
|
253
298
|
}
|
|
299
|
+
/** El reloj fijo del juez (el mismo patrón que `fixedClock` del runner de roles). */
|
|
300
|
+
function fixedClock(start) {
|
|
301
|
+
let current = new Date(start.getTime());
|
|
302
|
+
return {
|
|
303
|
+
now: () => new Date(current.getTime()),
|
|
304
|
+
set: (at) => {
|
|
305
|
+
current = new Date(at.getTime());
|
|
306
|
+
},
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
254
310
|
/* ── El runner ───────────────────────────────────────────────────────────── */
|
|
255
311
|
function holder() {
|
|
256
312
|
return { type: 'user', uuid: uuidv7() };
|
|
@@ -273,7 +329,8 @@ function spy(driver) {
|
|
|
273
329
|
let writes = 0;
|
|
274
330
|
const wrapped = new Proxy(driver, {
|
|
275
331
|
get(target, prop, receiver) {
|
|
276
|
-
|
|
332
|
+
// L-2: las CUATRO escrituras (también `purge*`, que reciben `{ transaction }`).
|
|
333
|
+
if (prop === 'relate' || prop === 'unrelate' || prop === 'purgeObject' || prop === 'purgeSubject') {
|
|
277
334
|
return async (...args) => {
|
|
278
335
|
writes++;
|
|
279
336
|
return target[prop](...args);
|
|
@@ -316,7 +373,13 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
316
373
|
async function freshManager(options) {
|
|
317
374
|
const base = await harness.makeDriver(contractRelationsConfig());
|
|
318
375
|
const s = spy(base);
|
|
319
|
-
|
|
376
|
+
// L-2: el manager nombra al driver en las dos puertas de `{ transaction }`
|
|
377
|
+
// como lo haría el provider (la clave en `relations.drivers`): aquí, el harness.
|
|
378
|
+
return {
|
|
379
|
+
manager: new RelationsManager(s.driver, contractRelationsConfig(), { driverName: harness.name, ...options }),
|
|
380
|
+
writes: s.writes,
|
|
381
|
+
base,
|
|
382
|
+
};
|
|
320
383
|
}
|
|
321
384
|
api.group(`relations contract [${harness.name}]`, (group) => {
|
|
322
385
|
group.each.setup(async () => {
|
|
@@ -327,7 +390,7 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
327
390
|
});
|
|
328
391
|
const test = (title, fn) => {
|
|
329
392
|
registered++;
|
|
330
|
-
api.test(title, fn);
|
|
393
|
+
return api.test(title, fn);
|
|
331
394
|
};
|
|
332
395
|
function caseFor(capability, pair) {
|
|
333
396
|
// MÁS ESTRICTO que el guard de roles: las DOS caras tienen que existir
|
|
@@ -503,6 +566,38 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
503
566
|
assert.deepEqual(events[0].actor, actor);
|
|
504
567
|
assert.equal(events[0].operation, 'relate');
|
|
505
568
|
});
|
|
569
|
+
/* ── R-15 · caducidad de la tupla (núcleo, sin reloj): estricta y validada ── */
|
|
570
|
+
test('R-15 · relate con expiresAt pasado NO concede (check/listObjects/listSubjects); futuro sí; expiresAt inválido ⇒ 422 sin tocar el driver', async ({ assert, }) => {
|
|
571
|
+
const u = holder();
|
|
572
|
+
const doc = { type: 'document', id: docId() };
|
|
573
|
+
const p = partition();
|
|
574
|
+
// Caducada al escribirse: no concede y no se enumera (caducidad
|
|
575
|
+
// estricta; sin reloj inyectado, un instante PASADO ya lo observa).
|
|
576
|
+
await state.manager.relate(u, 'viewer', doc, p, { expiresAt: new Date(Date.now() - 60_000) });
|
|
577
|
+
assert.isFalse(await state.manager.check(u, 'viewer', doc, p), 'caducada ⇒ check false');
|
|
578
|
+
const objects = await state.manager.listObjects(u, 'viewer', 'document', p);
|
|
579
|
+
assert.isFalse(objects.objects.some((o) => o.id === doc.id), 'caducada ⇒ listObjects no la lista');
|
|
580
|
+
const subjects = await state.manager.listSubjects('viewer', doc, p);
|
|
581
|
+
assert.isFalse(subjects.subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid), 'caducada ⇒ listSubjects no la lista');
|
|
582
|
+
// Con caducidad futura concede (y `Date` sobre la caducada la FIJA: tercer estado).
|
|
583
|
+
await state.manager.relate(u, 'viewer', doc, p, { expiresAt: new Date(Date.now() + 3_600_000) });
|
|
584
|
+
assert.isTrue(await state.manager.check(u, 'viewer', doc, p), 'futura ⇒ concede');
|
|
585
|
+
// Un `expiresAt` que no es Date válida/null/omitido es 422 ANTES del driver.
|
|
586
|
+
const writes = state.writes();
|
|
587
|
+
for (const bad of ['mañana', 12345, new Date('no-es-fecha')]) {
|
|
588
|
+
let caught;
|
|
589
|
+
try {
|
|
590
|
+
await state.manager.relate(u, 'viewer', doc, p, { expiresAt: bad });
|
|
591
|
+
assert.fail(`debería haber lanzado con ${String(bad)}`);
|
|
592
|
+
}
|
|
593
|
+
catch (error) {
|
|
594
|
+
caught = error;
|
|
595
|
+
}
|
|
596
|
+
assert.equal(caught.status, 422);
|
|
597
|
+
assert.equal(caught.code, 'E_AUTHZ_INVALID_IDENTITY');
|
|
598
|
+
}
|
|
599
|
+
assert.equal(state.writes(), writes, 'el driver no se tocó con un expiresAt inválido');
|
|
600
|
+
});
|
|
506
601
|
/* ── Capacidades, cada una con sus DOS caras ── */
|
|
507
602
|
caseFor('singleCheckRelations', {
|
|
508
603
|
// whenTrue: la derivación (includes + userset anidado) resuelve — la
|
|
@@ -641,6 +736,326 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
641
736
|
assert.isUndefined(page.truncated);
|
|
642
737
|
}),
|
|
643
738
|
});
|
|
739
|
+
/* ── R-15 · el par `injectableClock` (paridad con roles, 2.5 · J1) ── */
|
|
740
|
+
caseFor('injectableClock', {
|
|
741
|
+
whenTrue: () => test('injectableClock:true · expiresAt en tres estados con el reloj inyectado, y la caducidad EXACTA (T−1 ms concede, T no, T+1 no) en check/listObjects/listSubjects y a través de un userset; renovar y revivir sin dormir', async ({ assert, }) => {
|
|
742
|
+
assert.typeOf(state.base.withClock, 'function', 'injectableClock: true exige withClock en el puerto');
|
|
743
|
+
// `T` lleva milisegundos a propósito (2.5 · J3): un motor que trunque
|
|
744
|
+
// a segundos falla aquí (DATETIME(3) en MySQL).
|
|
745
|
+
const T = new Date('2030-06-15T12:34:56.789Z');
|
|
746
|
+
const clock = fixedClock(new Date(T.getTime() - 60_000));
|
|
747
|
+
const clocked = new RelationsManager(state.base.withClock(clock.now), contractRelationsConfig());
|
|
748
|
+
const u = holder();
|
|
749
|
+
const doc = { type: 'document', id: docId() };
|
|
750
|
+
const viaGroup = { type: 'document', id: docId() };
|
|
751
|
+
const g = { type: 'group', id: docId() };
|
|
752
|
+
const p = partition();
|
|
753
|
+
await clocked.relate(u, 'viewer', doc, p, { expiresAt: T });
|
|
754
|
+
// La MEMBRESÍA caduca: g#member es editor de `viaGroup` sin plazo, pero
|
|
755
|
+
// u es member de g solo hasta T ⇒ en T deja de ser viewer de viaGroup.
|
|
756
|
+
await clocked.relate(u, 'member', g, p, { expiresAt: T });
|
|
757
|
+
await clocked.relate({ object: g, relation: 'member' }, 'editor', viaGroup, p);
|
|
758
|
+
const observe = async () => ({
|
|
759
|
+
check: await clocked.check(u, 'viewer', doc, p),
|
|
760
|
+
listObjects: (await clocked.listObjects(u, 'viewer', 'document', p)).objects.some((o) => o.id === doc.id),
|
|
761
|
+
listSubjects: (await clocked.listSubjects('viewer', doc, p)).subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid),
|
|
762
|
+
viaGroup: await clocked.check(u, 'viewer', viaGroup, p),
|
|
763
|
+
...(harness.capabilities.membersOfNative
|
|
764
|
+
? {
|
|
765
|
+
membersOf: (await clocked.membersOf(g, 'member', p)).subjects.some((s) => !isRelUserset(s) && s.uuid === u.uuid),
|
|
766
|
+
}
|
|
767
|
+
: {}),
|
|
768
|
+
});
|
|
769
|
+
const alive = {
|
|
770
|
+
check: true,
|
|
771
|
+
listObjects: true,
|
|
772
|
+
listSubjects: true,
|
|
773
|
+
viaGroup: true,
|
|
774
|
+
...(harness.capabilities.membersOfNative ? { membersOf: true } : {}),
|
|
775
|
+
};
|
|
776
|
+
const gone = {
|
|
777
|
+
check: false,
|
|
778
|
+
listObjects: false,
|
|
779
|
+
listSubjects: false,
|
|
780
|
+
viaGroup: false,
|
|
781
|
+
...(harness.capabilities.membersOfNative ? { membersOf: false } : {}),
|
|
782
|
+
};
|
|
783
|
+
clock.set(new Date(T.getTime() - 1));
|
|
784
|
+
assert.deepEqual(await observe(), alive, 'T−1 ms');
|
|
785
|
+
clock.set(T);
|
|
786
|
+
assert.deepEqual(await observe(), gone, 'T: la que vence ahora ya no cuenta (estricta)');
|
|
787
|
+
clock.set(new Date(T.getTime() + 1));
|
|
788
|
+
assert.deepEqual(await observe(), gone, 'T+1 ms');
|
|
789
|
+
// Renovación: `Date` posterior vuelve a conceder hasta ESE instante.
|
|
790
|
+
const T2 = new Date(T.getTime() + 1_000);
|
|
791
|
+
await clocked.relate(u, 'viewer', doc, p, { expiresAt: T2 });
|
|
792
|
+
await clocked.relate(u, 'member', g, p, { expiresAt: T2 });
|
|
793
|
+
assert.deepEqual(await observe(), alive, 'renovada en T+1 ms');
|
|
794
|
+
clock.set(new Date(T2.getTime() - 1));
|
|
795
|
+
assert.deepEqual(await observe(), alive, 'T2−1 ms');
|
|
796
|
+
clock.set(T2);
|
|
797
|
+
assert.deepEqual(await observe(), gone, 'T2');
|
|
798
|
+
// Los tres estados (invariante 10), con el reloj en T2 (u ya caducado):
|
|
799
|
+
// omitido sobre una CADUCADA revive sin caducidad;
|
|
800
|
+
await clocked.relate(u, 'viewer', doc, p);
|
|
801
|
+
await clocked.relate(u, 'member', g, p);
|
|
802
|
+
clock.set(new Date('2099-12-31T23:59:59.999Z'));
|
|
803
|
+
assert.deepEqual(await observe(), alive, 'revivida sin caducidad: vigente en 2099');
|
|
804
|
+
// omitido sobre una VIGENTE la preserva (no la convierte en permanente);
|
|
805
|
+
const keep = holder();
|
|
806
|
+
const soon = new Date(clock.now().getTime() + 1_500);
|
|
807
|
+
await clocked.relate(keep, 'viewer', doc, p, { expiresAt: soon });
|
|
808
|
+
await clocked.relate(keep, 'viewer', doc, p);
|
|
809
|
+
// null la quita.
|
|
810
|
+
const lift = holder();
|
|
811
|
+
await clocked.relate(lift, 'viewer', doc, p, { expiresAt: soon });
|
|
812
|
+
await clocked.relate(lift, 'viewer', doc, p, { expiresAt: null });
|
|
813
|
+
clock.set(new Date(soon.getTime() - 1));
|
|
814
|
+
assert.isTrue(await clocked.check(keep, 'viewer', doc, p), 'preservada: vigente en soon−1');
|
|
815
|
+
clock.set(soon);
|
|
816
|
+
assert.isFalse(await clocked.check(keep, 'viewer', doc, p), 'preservada: vence en soon (omitido NO la hizo permanente)');
|
|
817
|
+
assert.isTrue(await clocked.check(lift, 'viewer', doc, p), 'null la quitó: sigue vigente');
|
|
818
|
+
// El driver del harness (sin reloj inyectado) no se ha tocado: para él
|
|
819
|
+
// (hoy) T2 —2030— es futuro y la relación renovada concede.
|
|
820
|
+
assert.isTrue(await state.manager.check(lift, 'viewer', doc, p));
|
|
821
|
+
}),
|
|
822
|
+
whenFalse: () => test('injectableClock:false · expiresAt en tres estados en tiempo real (1,5 s): omitido preserva, null quita, caducada revive; sin withClock', async ({ assert, }) => {
|
|
823
|
+
assert.notTypeOf(state.base.withClock, 'function', 'el harness declara injectableClock: false y el driver trae withClock: declara lo observable');
|
|
824
|
+
const doc = { type: 'document', id: docId() };
|
|
825
|
+
const p = partition();
|
|
826
|
+
const keep = holder();
|
|
827
|
+
const lift = holder();
|
|
828
|
+
const revive = holder();
|
|
829
|
+
const soon = new Date(Date.now() + 1_500);
|
|
830
|
+
await state.manager.relate(keep, 'viewer', doc, p, { expiresAt: soon });
|
|
831
|
+
await state.manager.relate(keep, 'viewer', doc, p); // omitido: preserva `soon`
|
|
832
|
+
await state.manager.relate(lift, 'viewer', doc, p, { expiresAt: soon });
|
|
833
|
+
await state.manager.relate(lift, 'viewer', doc, p, { expiresAt: null }); // la quita
|
|
834
|
+
await state.manager.relate(revive, 'viewer', doc, p, { expiresAt: new Date(Date.now() - 60_000) });
|
|
835
|
+
assert.isFalse(await state.manager.check(revive, 'viewer', doc, p), 'caducada no concede');
|
|
836
|
+
await state.manager.relate(revive, 'viewer', doc, p); // omitido sobre caducada: revive sin caducidad
|
|
837
|
+
assert.isTrue(await state.manager.check(keep, 'viewer', doc, p));
|
|
838
|
+
assert.isTrue(await state.manager.check(lift, 'viewer', doc, p));
|
|
839
|
+
assert.isTrue(await state.manager.check(revive, 'viewer', doc, p));
|
|
840
|
+
await sleep(1_700);
|
|
841
|
+
assert.isFalse(await state.manager.check(keep, 'viewer', doc, p), 'preservada: venció');
|
|
842
|
+
assert.isTrue(await state.manager.check(lift, 'viewer', doc, p), 'sin caducidad: sigue');
|
|
843
|
+
assert.isTrue(await state.manager.check(revive, 'viewer', doc, p), 'revivida sin caducidad: sigue');
|
|
844
|
+
}),
|
|
845
|
+
});
|
|
846
|
+
/* ── L-2 · el par `transactionalWrites` (panel `{trx}`, (C); mismo nombre que en roles) ── */
|
|
847
|
+
caseFor('transactionalWrites', {
|
|
848
|
+
// L-4: la cara `true`. Lo que se juzga es «los dos o ninguno con TU
|
|
849
|
+
// transacción» POR CENSO (paridad con la cara `true` de roles, L-3): el
|
|
850
|
+
// rollback deja CERO tuplas nuevas en la partición (no solo `check`
|
|
851
|
+
// false — una respuesta puede salir bien con la fila viva), el commit
|
|
852
|
+
// las deja escritas, mientras la transacción está abierta la autoridad
|
|
853
|
+
// (que lee por la conexión del motor) no ve nada, y `purgeObject`/
|
|
854
|
+
// `purgeSubject` borran y REVIERTEN juntos (tras el rollback todo lo
|
|
855
|
+
// purgado está de vuelta). Y la otra mitad: una transacción que NO es
|
|
856
|
+
// la del driver no recibe ni una sentencia. Necesita un motor con
|
|
857
|
+
// transacciones reales y pool ≥ 2: el harness `database` lo declara
|
|
858
|
+
// `true` en `sqlite-file`/PG/MySQL y `false` en `:memory:`.
|
|
859
|
+
whenTrue: () => test('transactionalWrites:true · relate/unrelate/purgeObject/purgeSubject con { transaction } + rollback ⇒ check sin cambio Y CERO tuplas nuevas (censo); + commit ⇒ aplicado; purge* borran y revierten JUNTOS; una transacción ajena, un cliente sin transacción o el `db` entero ⇒ 500 E_AUTHZ_CONFIG sin UNA sentencia por ella', async ({ assert, }) => {
|
|
860
|
+
assert.equal(state.base.capabilities?.transactionalWrites, true, 'el harness declara transactionalWrites: true y el driver no: declara lo observable');
|
|
861
|
+
const tx = harness.transactions ?? lucidRelationsContractTransactions();
|
|
862
|
+
const u = holder();
|
|
863
|
+
const v = holder();
|
|
864
|
+
const doc = { type: 'document', id: docId() };
|
|
865
|
+
const other = { type: 'document', id: docId() };
|
|
866
|
+
const p = partition();
|
|
867
|
+
const can = () => state.manager.check(u, 'viewer', doc, p);
|
|
868
|
+
const census = () => tx.census(p);
|
|
869
|
+
// Una transacción que se queda abierta tras una aserción fallida
|
|
870
|
+
// retiene una conexión del pool y arrastra a los casos siguientes:
|
|
871
|
+
// el rollback va en `finally`, y el commit solo si nada falló.
|
|
872
|
+
const rolledBack = async (fn) => {
|
|
873
|
+
const t = await tx.begin();
|
|
874
|
+
try {
|
|
875
|
+
await fn(t.client);
|
|
876
|
+
}
|
|
877
|
+
finally {
|
|
878
|
+
await t.rollback();
|
|
879
|
+
}
|
|
880
|
+
};
|
|
881
|
+
const committed = async (fn) => {
|
|
882
|
+
const t = await tx.begin();
|
|
883
|
+
try {
|
|
884
|
+
await fn(t.client);
|
|
885
|
+
}
|
|
886
|
+
catch (error) {
|
|
887
|
+
await t.rollback();
|
|
888
|
+
throw error;
|
|
889
|
+
}
|
|
890
|
+
await t.commit();
|
|
891
|
+
};
|
|
892
|
+
// relate: dentro no se ve desde fuera; rollback ⇒ nada; commit ⇒ concede.
|
|
893
|
+
let seenInside;
|
|
894
|
+
await rolledBack(async (transaction) => {
|
|
895
|
+
await state.manager.relate(u, 'viewer', doc, p, { transaction });
|
|
896
|
+
seenInside = await can();
|
|
897
|
+
});
|
|
898
|
+
assert.equal(await census(), 0, 'ROJO: la relación sobrevivió al rollback (la escritura NO fue por la transacción del llamante)');
|
|
899
|
+
assert.isFalse(await can(), 'relate + rollback ⇒ no concede');
|
|
900
|
+
assert.isFalse(seenInside, 'la autoridad lee por la conexión del motor: el relate sin confirmar no concede');
|
|
901
|
+
await committed(async (transaction) => {
|
|
902
|
+
await state.manager.relate(u, 'viewer', doc, p, { transaction });
|
|
903
|
+
});
|
|
904
|
+
assert.isTrue(await can(), 'relate + commit ⇒ concede');
|
|
905
|
+
assert.equal(await census(), 1);
|
|
906
|
+
// unrelate: rollback ⇒ la tupla sigue; commit ⇒ se va.
|
|
907
|
+
await rolledBack(async (transaction) => {
|
|
908
|
+
await state.manager.unrelate(u, 'viewer', doc, p, { transaction });
|
|
909
|
+
seenInside = await can();
|
|
910
|
+
});
|
|
911
|
+
assert.equal(await census(), 1, 'ROJO: el unrelate sobrevivió al rollback');
|
|
912
|
+
assert.isTrue(await can(), 'unrelate + rollback ⇒ sigue concediendo');
|
|
913
|
+
assert.isTrue(seenInside, 'el unrelate sin confirmar no se ve desde la conexión del motor');
|
|
914
|
+
await committed(async (transaction) => {
|
|
915
|
+
await state.manager.unrelate(u, 'viewer', doc, p, { transaction });
|
|
916
|
+
});
|
|
917
|
+
assert.isFalse(await can(), 'unrelate + commit ⇒ no concede');
|
|
918
|
+
assert.equal(await census(), 0);
|
|
919
|
+
// purgeObject: dos sujetos sobre `doc` + uno sobre `other`; rollback ⇒
|
|
920
|
+
// los tres siguen (borran y revierten JUNTOS); commit ⇒ quedan solo
|
|
921
|
+
// los de `other`.
|
|
922
|
+
await state.manager.relate(u, 'viewer', doc, p);
|
|
923
|
+
await state.manager.relate(v, 'editor', doc, p);
|
|
924
|
+
await state.manager.relate(u, 'owner', other, p);
|
|
925
|
+
assert.equal(await census(), 3);
|
|
926
|
+
await rolledBack(async (transaction) => {
|
|
927
|
+
await state.manager.purgeObject(doc, p, { transaction });
|
|
928
|
+
seenInside = await can();
|
|
929
|
+
});
|
|
930
|
+
assert.equal(await census(), 3, 'ROJO: purgeObject sobrevivió al rollback (algo de lo purgado no volvió)');
|
|
931
|
+
assert.isTrue(await can(), 'purgeObject + rollback ⇒ sigue concediendo');
|
|
932
|
+
assert.isTrue(await state.manager.check(v, 'viewer', doc, p));
|
|
933
|
+
assert.isTrue(seenInside, 'el purgeObject sin confirmar no se ve desde la conexión del motor');
|
|
934
|
+
await committed(async (transaction) => {
|
|
935
|
+
await state.manager.purgeObject(doc, p, { transaction });
|
|
936
|
+
});
|
|
937
|
+
assert.equal(await census(), 1, 'purgeObject + commit ⇒ solo queda la tupla de `other`');
|
|
938
|
+
assert.isFalse(await can());
|
|
939
|
+
assert.isFalse(await state.manager.check(v, 'viewer', doc, p));
|
|
940
|
+
assert.isTrue(await state.manager.check(u, 'owner', other, p));
|
|
941
|
+
// purgeSubject: `u` es owner de `other` y viewer de `doc` de nuevo;
|
|
942
|
+
// rollback ⇒ las dos siguen; commit ⇒ las dos se van y `v` no se toca.
|
|
943
|
+
await state.manager.relate(u, 'viewer', doc, p);
|
|
944
|
+
await state.manager.relate(v, 'viewer', doc, p);
|
|
945
|
+
assert.equal(await census(), 3);
|
|
946
|
+
await rolledBack(async (transaction) => {
|
|
947
|
+
await state.manager.purgeSubject(u, p, { transaction });
|
|
948
|
+
seenInside = await can();
|
|
949
|
+
});
|
|
950
|
+
assert.equal(await census(), 3, 'ROJO: purgeSubject sobrevivió al rollback');
|
|
951
|
+
assert.isTrue(await can(), 'purgeSubject + rollback ⇒ sigue concediendo');
|
|
952
|
+
assert.isTrue(await state.manager.check(u, 'owner', other, p));
|
|
953
|
+
assert.isTrue(seenInside, 'el purgeSubject sin confirmar no se ve desde la conexión del motor');
|
|
954
|
+
await committed(async (transaction) => {
|
|
955
|
+
await state.manager.purgeSubject(u, p, { transaction });
|
|
956
|
+
});
|
|
957
|
+
assert.equal(await census(), 1, 'purgeSubject + commit ⇒ solo queda la tupla de `v`');
|
|
958
|
+
assert.isFalse(await can());
|
|
959
|
+
assert.isFalse(await state.manager.check(u, 'owner', other, p));
|
|
960
|
+
assert.isTrue(await state.manager.check(v, 'viewer', doc, p));
|
|
961
|
+
// Una transacción que NO es la del driver (`assertCallerTransaction`):
|
|
962
|
+
// 500 `E_AUTHZ_CONFIG` y ni una sentencia por ella (espía).
|
|
963
|
+
const statements = [];
|
|
964
|
+
const spyTrx = (shape) => new Proxy(shape, {
|
|
965
|
+
get(target, prop) {
|
|
966
|
+
if (prop === 'from' || prop === 'table' || prop === 'raw' || prop === 'query' || prop === 'knexQuery' || prop === 'rawQuery' || prop === 'transaction') {
|
|
967
|
+
return (...args) => {
|
|
968
|
+
statements.push(`${String(prop)}(${String(args[0] ?? '')})`);
|
|
969
|
+
return { where: () => ({}), select: async () => [], insert: async () => [], delete: async () => 0 };
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
return Reflect.get(target, prop);
|
|
973
|
+
},
|
|
974
|
+
});
|
|
975
|
+
const foreign = spyTrx({ isTransaction: true, connectionName: `not-${harness.name}-${uuidv7()}` });
|
|
976
|
+
const notATransaction = spyTrx({ isTransaction: false, connectionName: 'primary' });
|
|
977
|
+
const wholeDb = spyTrx({ connection() { }, primaryConnectionName: 'primary' });
|
|
978
|
+
const w = holder();
|
|
979
|
+
const writesBefore = state.writes();
|
|
980
|
+
for (const [label, transaction] of [
|
|
981
|
+
['otra conexión', foreign],
|
|
982
|
+
['un cliente sin transacción', notATransaction],
|
|
983
|
+
['el db entero', wholeDb],
|
|
984
|
+
]) {
|
|
985
|
+
for (const [operation, run] of [
|
|
986
|
+
['relate', () => state.manager.relate(w, 'viewer', doc, p, { transaction })],
|
|
987
|
+
['unrelate', () => state.manager.unrelate(w, 'viewer', doc, p, { transaction })],
|
|
988
|
+
['purgeObject', () => state.manager.purgeObject(doc, p, { transaction })],
|
|
989
|
+
['purgeSubject', () => state.manager.purgeSubject(w, p, { transaction })],
|
|
990
|
+
]) {
|
|
991
|
+
let caught;
|
|
992
|
+
try {
|
|
993
|
+
await run();
|
|
994
|
+
assert.fail(`${label}/${operation}: debería haber lanzado`);
|
|
995
|
+
}
|
|
996
|
+
catch (error) {
|
|
997
|
+
caught = error;
|
|
998
|
+
}
|
|
999
|
+
assert.equal(caught.status, 500, `${label}/${operation}: ${caught.message}`);
|
|
1000
|
+
assert.equal(caught.code, 'E_AUTHZ_CONFIG', `${label}/${operation}`);
|
|
1001
|
+
assert.include(caught.message, operation, `${label}/${operation}: nombra la operación`);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
assert.deepEqual(statements, [], 'ni una sentencia por una transacción que no es la del driver');
|
|
1005
|
+
assert.isAbove(state.writes(), writesBefore, 'la trx ajena la juzga el DRIVER (assertCallerTransaction), no la puerta 1');
|
|
1006
|
+
assert.equal(await census(), 1, 'la trx ajena no escribió nada');
|
|
1007
|
+
assert.isFalse(await state.manager.check(w, 'viewer', doc, p));
|
|
1008
|
+
})?.timeout(30_000),
|
|
1009
|
+
whenFalse: () => test('transactionalWrites:false · { transaction } en relate/unrelate/purgeObject/purgeSubject ⇒ 500 E_AUTHZ_UNSUPPORTED nombrando driver y operación, con CERO llamadas al driver (espía); y requireTransactionalWrites: true ⇒ 500 E_AUTHZ_CONFIG al construir el manager (= al resolver el driver)', async ({ assert, }) => {
|
|
1010
|
+
assert.notEqual(state.base.capabilities?.transactionalWrites, true, 'el harness declara transactionalWrites: false y el driver declara true: declara lo observable');
|
|
1011
|
+
const trx = { from() { }, table() { }, isTransaction: true, connectionName: 'primary' };
|
|
1012
|
+
const u = holder();
|
|
1013
|
+
const doc = { type: 'document', id: docId() };
|
|
1014
|
+
const p = partition();
|
|
1015
|
+
const writes = [
|
|
1016
|
+
['relate', () => state.manager.relate(u, 'viewer', doc, p, { transaction: trx })],
|
|
1017
|
+
['unrelate', () => state.manager.unrelate(u, 'viewer', doc, p, { transaction: trx })],
|
|
1018
|
+
['purgeObject', () => state.manager.purgeObject(doc, p, { transaction: trx })],
|
|
1019
|
+
['purgeSubject', () => state.manager.purgeSubject(u, p, { transaction: trx })],
|
|
1020
|
+
];
|
|
1021
|
+
for (const [operation, run] of writes) {
|
|
1022
|
+
let caught;
|
|
1023
|
+
try {
|
|
1024
|
+
await run();
|
|
1025
|
+
assert.fail(`${operation}: debería haber lanzado`);
|
|
1026
|
+
}
|
|
1027
|
+
catch (error) {
|
|
1028
|
+
caught = error;
|
|
1029
|
+
}
|
|
1030
|
+
assert.equal(caught.status, 500, `${operation}: ${caught.message}`);
|
|
1031
|
+
assert.equal(caught.code, 'E_AUTHZ_UNSUPPORTED', operation);
|
|
1032
|
+
assert.include(caught.message, `'${harness.name}'`, `${operation}: nombra el driver`);
|
|
1033
|
+
assert.include(caught.message, operation, `${operation}: nombra la operación`);
|
|
1034
|
+
assert.include(caught.message, 'requireTransactionalWrites', `${operation}: la letra lleva la salida`);
|
|
1035
|
+
}
|
|
1036
|
+
assert.equal(state.writes(), 0, 'cero llamadas al driver: el rechazo va ANTES del backend');
|
|
1037
|
+
// Sin `{ transaction }` la MISMA escritura entra.
|
|
1038
|
+
await state.manager.relate(u, 'viewer', doc, p);
|
|
1039
|
+
assert.isTrue(await state.manager.check(u, 'viewer', doc, p));
|
|
1040
|
+
assert.equal(state.writes(), 1);
|
|
1041
|
+
// Puerta 2, opt-in: al construir el manager (= resolver el driver).
|
|
1042
|
+
let atResolve;
|
|
1043
|
+
try {
|
|
1044
|
+
new RelationsManager(state.base, contractRelationsConfig(), {
|
|
1045
|
+
requireTransactionalWrites: true,
|
|
1046
|
+
driverName: harness.name,
|
|
1047
|
+
});
|
|
1048
|
+
assert.fail('requireTransactionalWrites: true sobre un driver que declara false tenía que lanzar al construir');
|
|
1049
|
+
}
|
|
1050
|
+
catch (error) {
|
|
1051
|
+
atResolve = error;
|
|
1052
|
+
}
|
|
1053
|
+
assert.equal(atResolve.status, 500, String(atResolve?.message));
|
|
1054
|
+
assert.equal(atResolve.code, 'E_AUTHZ_CONFIG');
|
|
1055
|
+
assert.include(atResolve.message, `'${harness.name}'`);
|
|
1056
|
+
assert.include(atResolve.message, 'transactionalWrites');
|
|
1057
|
+
}),
|
|
1058
|
+
});
|
|
644
1059
|
// Al cerrar: toda capacidad declarada tiene que haber registrado su cara.
|
|
645
1060
|
group.teardown(async () => {
|
|
646
1061
|
const uncovered = Object.keys(harness.capabilities).filter((c) => !covered.has(c));
|