@jantstack/adonis-authz 2.5.0-alpha.1 → 2.5.0-alpha.3
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 +515 -22
- package/build/commands/authz_relations_reconcile.d.ts +13 -62
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
- package/build/commands/authz_relations_reconcile.js +202 -116
- package/build/commands/authz_relations_reconcile.js.map +1 -1
- package/build/commands/openfga_provision.d.ts +62 -0
- package/build/commands/openfga_provision.d.ts.map +1 -1
- package/build/commands/openfga_provision.js +193 -1
- package/build/commands/openfga_provision.js.map +1 -1
- package/build/index.d.ts +6 -6
- package/build/index.d.ts.map +1 -1
- package/build/index.js +3 -3
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +4 -1
- package/build/providers/authz_provider.js.map +1 -1
- package/build/src/define_config.d.ts +32 -0
- package/build/src/define_config.d.ts.map +1 -1
- package/build/src/define_config.js +109 -0
- package/build/src/define_config.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +36 -1
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/database_relations_driver.js +213 -46
- package/build/src/drivers/database_relations_driver.js.map +1 -1
- package/build/src/drivers/openfga_driver.d.ts +30 -4
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +151 -45
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_facts.d.ts +21 -0
- package/build/src/drivers/openfga_facts.d.ts.map +1 -1
- package/build/src/drivers/openfga_facts.js +74 -8
- package/build/src/drivers/openfga_facts.js.map +1 -1
- package/build/src/drivers/openfga_relations_driver.d.ts +140 -10
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_relations_driver.js +743 -49
- package/build/src/drivers/openfga_relations_driver.js.map +1 -1
- package/build/src/errors.d.ts +148 -0
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +166 -0
- package/build/src/errors.js.map +1 -1
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +15 -5
- 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 +1 -1
- package/build/src/relations/check_many.d.ts.map +1 -1
- package/build/src/relations/check_many.js +6 -4
- package/build/src/relations/check_many.js.map +1 -1
- package/build/src/relations/define_relations_config.d.ts +187 -1
- package/build/src/relations/define_relations_config.d.ts.map +1 -1
- package/build/src/relations/define_relations_config.js +395 -7
- package/build/src/relations/define_relations_config.js.map +1 -1
- package/build/src/relations/manager.d.ts +33 -2
- package/build/src/relations/manager.d.ts.map +1 -1
- package/build/src/relations/manager.js +123 -4
- package/build/src/relations/manager.js.map +1 -1
- package/build/src/relations/reconcile.d.ts +295 -13
- package/build/src/relations/reconcile.d.ts.map +1 -1
- package/build/src/relations/reconcile.js +886 -85
- package/build/src/relations/reconcile.js.map +1 -1
- package/build/src/relations/tuple_order.d.ts +126 -0
- package/build/src/relations/tuple_order.d.ts.map +1 -0
- package/build/src/relations/tuple_order.js +598 -0
- package/build/src/relations/tuple_order.js.map +1 -0
- package/build/src/relations_config_store.d.ts.map +1 -1
- package/build/src/relations_config_store.js +29 -7
- package/build/src/relations_config_store.js.map +1 -1
- package/build/src/relations_reconcile.d.ts +79 -0
- package/build/src/relations_reconcile.d.ts.map +1 -0
- package/build/src/relations_reconcile.js +85 -0
- package/build/src/relations_reconcile.js.map +1 -0
- package/build/src/scope_outbox.d.ts +9 -5
- package/build/src/scope_outbox.d.ts.map +1 -1
- package/build/src/scope_outbox.js +7 -4
- package/build/src/scope_outbox.js.map +1 -1
- package/build/src/shared/backend_guard.d.ts +8 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -1
- package/build/src/shared/backend_guard.js +15 -0
- package/build/src/shared/backend_guard.js.map +1 -1
- package/build/src/testing/main.d.ts +1 -1
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +1 -1
- package/build/src/testing/main.js.map +1 -1
- package/build/src/testing/relations_contract.d.ts +18 -0
- package/build/src/testing/relations_contract.d.ts.map +1 -1
- package/build/src/testing/relations_contract.js +895 -45
- package/build/src/testing/relations_contract.js.map +1 -1
- package/build/src/testing/relations_reconcile_contract.d.ts +9 -0
- package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
- package/build/src/testing/relations_reconcile_contract.js +207 -0
- package/build/src/testing/relations_reconcile_contract.js.map +1 -1
- package/build/src/testing/scope_outbox_contract.d.ts +14 -1
- package/build/src/testing/scope_outbox_contract.d.ts.map +1 -1
- package/build/src/testing/scope_outbox_contract.js +126 -48
- package/build/src/testing/scope_outbox_contract.js.map +1 -1
- package/build/src/types.d.ts +90 -6
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js +2 -0
- package/build/src/types.js.map +1 -1
- package/package.json +2 -1
|
@@ -21,11 +21,12 @@ 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
23
|
import { partitionKey, scopeKey } from '../identity.js';
|
|
24
|
+
import { RenewalUnsafeError } from '../errors.js';
|
|
24
25
|
import { RelationsManager } from '../relations/manager.js';
|
|
25
|
-
import { defineRelationsConfig, assertRelationDeclared, assertObjectTypeDeclared } from '../relations/define_relations_config.js';
|
|
26
|
+
import { defineRelationsConfig, assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject, assertRelationDenyable, assertDenySubject, assertDenyNoExpiry, cutPathAllows, decideFrom, deniedRelationOf, denyPhaseOf, storedRelationKind, relationsDown, RELATION_DENY_PREFIX, } from '../relations/define_relations_config.js';
|
|
26
27
|
import { assertCheckManyObjects, distinctObjects, expandByPosition, RELATIONS_CHECK_MANY_MAX } from '../relations/check_many.js';
|
|
27
28
|
import { systemClock } from '../clock.js';
|
|
28
|
-
import { resolveGrantExpiry } from '../expiry.js';
|
|
29
|
+
import { resolveGrantExpiry, sameInstant } from '../expiry.js';
|
|
29
30
|
import { isRelUserset } from '../types.js';
|
|
30
31
|
/* ── El vocabulario del contrato ─────────────────────────────────────────── */
|
|
31
32
|
/** Los holder types del contrato de relaciones. */
|
|
@@ -58,6 +59,30 @@ export function contractRelationsConfig() {
|
|
|
58
59
|
database: { membersOf: true },
|
|
59
60
|
});
|
|
60
61
|
}
|
|
62
|
+
/**
|
|
63
|
+
* **La config del contrato CON deny** (plan 2.5 · lote D · D1): los MISMOS dos tipos con el mismo retículo y, además,
|
|
64
|
+
* un tercero sin opt-in —para que «deny sobre un tipo sin deny ⇒ 422» y «D8: un tipo sin opt-in también honra»
|
|
65
|
+
* se juzguen con la config fija—:
|
|
66
|
+
*
|
|
67
|
+
* - `document`: `deny: 'write'` (fase 2: se escriben denies);
|
|
68
|
+
* - `workspace`: `deny: 'read'` (fase 1: el modelo y las lecturas honran; `deny()` ⇒ 422);
|
|
69
|
+
* - `note`: sin opt-in.
|
|
70
|
+
*
|
|
71
|
+
* Es una config APARTE (no se tocó `contractRelationsConfig`): un tipo sin `deny` genera el modelo de 2.4.0-alpha.10
|
|
72
|
+
* byte a byte (D-29) y los casos de antes no cambian de config.
|
|
73
|
+
*/
|
|
74
|
+
export const RELATIONS_CONTRACT_DENY_TYPES = { document: 'write', workspace: 'read', note: undefined };
|
|
75
|
+
export function contractDenyRelationsConfig() {
|
|
76
|
+
return defineRelationsConfig({
|
|
77
|
+
objectTypes: Object.keys(RELATIONS_CONTRACT_DENY_TYPES).map((type) => {
|
|
78
|
+
const relations = [{ name: 'owner' }, { name: 'editor', includes: ['owner'] }, { name: 'viewer', includes: ['editor'] }];
|
|
79
|
+
const deny = RELATIONS_CONTRACT_DENY_TYPES[type];
|
|
80
|
+
return deny === undefined ? { type, relations } : { type, relations, deny };
|
|
81
|
+
}),
|
|
82
|
+
holderTypes: [...RELATIONS_HOLDER_TYPES],
|
|
83
|
+
database: { membersOf: true },
|
|
84
|
+
});
|
|
85
|
+
}
|
|
61
86
|
/** El default de `RelationsDriverContractHarness.transactions`: Lucid + `authz_relations`. */
|
|
62
87
|
export function lucidRelationsContractTransactions() {
|
|
63
88
|
return {
|
|
@@ -110,35 +135,17 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
110
135
|
* filas `double`) lo sondea en el doble y en su vista `withClock`.
|
|
111
136
|
*/
|
|
112
137
|
function assertSubjectDeclared(subject) {
|
|
138
|
+
// B3 (lote B): F-05 del par y solo `group#member`, la MISMA función que las tres capas reales.
|
|
113
139
|
if (isRelUserset(subject))
|
|
114
|
-
|
|
140
|
+
assertUsersetSubject(config, subject);
|
|
115
141
|
}
|
|
116
142
|
/** Caducidad ESTRICTA (R-15): la que vence AHORA ya no cuenta. `at` fija el instante (un `now` por lote en `checkMany`). */
|
|
117
143
|
function active(t, at = now) {
|
|
118
144
|
return t.expiresAt === null || t.expiresAt.getTime() > at().getTime();
|
|
119
145
|
}
|
|
120
|
-
/**
|
|
121
|
-
* `viewer` ⊇ `{viewer, editor, owner}`: las relaciones cuyo hecho DIRECTO
|
|
122
|
-
* satisface R. `viewer includes editor` ⇒ un `editor` es también `viewer`,
|
|
123
|
-
* así que para resolver `viewer` se aceptan los sujetos de `viewer`, de lo
|
|
124
|
-
* que `viewer` incluye (`editor`) y transitivamente (`owner`). Se sigue la
|
|
125
|
-
* cadena de `includes` hacia ABAJO desde R.
|
|
126
|
-
*/
|
|
146
|
+
/** `down*(R)` — la función ÚNICA de la config (lote D), la misma que los dos drivers reales. */
|
|
127
147
|
function expandDown(objectType, relation) {
|
|
128
|
-
|
|
129
|
-
const type = config.objectTypes.find((t) => t.type === objectType);
|
|
130
|
-
const queue = [relation];
|
|
131
|
-
while (queue.length) {
|
|
132
|
-
const current = queue.pop();
|
|
133
|
-
const def = type?.relations.find((r) => r.name === current);
|
|
134
|
-
for (const inc of def?.includes ?? []) {
|
|
135
|
-
if (!out.has(inc)) {
|
|
136
|
-
out.add(inc);
|
|
137
|
-
queue.push(inc);
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
}
|
|
141
|
-
return out;
|
|
148
|
+
return new Set(relationsDown(config, objectType, relation));
|
|
142
149
|
}
|
|
143
150
|
/** Los hechos DIRECTOS vigentes (los caducados no conceden ni se enumeran, R-15). */
|
|
144
151
|
function directSubjects(partition, objectType, objectId, relation, at = now) {
|
|
@@ -148,25 +155,49 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
148
155
|
t.relation === relation &&
|
|
149
156
|
active(t, at));
|
|
150
157
|
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (
|
|
164
|
-
|
|
165
|
-
|
|
158
|
+
/**
|
|
159
|
+
* **El PRINCIPAL del sujeto** (lote D, la misma CTE `principal` del driver `database`): él mismo y cada
|
|
160
|
+
* `group:g#member` del que es miembro VIGENTE, transitivamente (grupos anidados). Un grant o un deny alcanzan al
|
|
161
|
+
* sujeto si su sujeto directo está aquí — por eso un deny a `g#member` lo transporta una membresía, y deja de
|
|
162
|
+
* transportarlo cuando esa membresía caduca (M6, caso C).
|
|
163
|
+
*/
|
|
164
|
+
function principalOf(subject, partition, at = now) {
|
|
165
|
+
const out = new Set([subjectKeyOf(subject)]);
|
|
166
|
+
let grew = true;
|
|
167
|
+
while (grew) {
|
|
168
|
+
grew = false;
|
|
169
|
+
for (const t of tuples) {
|
|
170
|
+
if (t.partition !== partition || t.objectType !== 'group' || t.relation !== 'member' || !active(t, at))
|
|
171
|
+
continue;
|
|
172
|
+
const key = `group:${t.objectId}#member`;
|
|
173
|
+
if (out.has(t.subjectKey) && !out.has(key)) {
|
|
174
|
+
out.add(key);
|
|
175
|
+
grew = true;
|
|
166
176
|
}
|
|
167
177
|
}
|
|
168
178
|
}
|
|
169
|
-
return
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
/** Las relaciones con grant (`allowed`) y con deny (`denied`, nombre PÚBLICO) que alcanzan al principal en el objeto. */
|
|
182
|
+
function factsOn(principal, object, partition, at = now) {
|
|
183
|
+
const allowed = new Set();
|
|
184
|
+
const denied = new Set();
|
|
185
|
+
for (const t of tuples) {
|
|
186
|
+
if (t.partition !== partition || t.objectType !== object.type || t.objectId !== object.id || !active(t, at))
|
|
187
|
+
continue;
|
|
188
|
+
if (!principal.has(t.subjectKey))
|
|
189
|
+
continue;
|
|
190
|
+
if (t.relation.startsWith(RELATION_DENY_PREFIX))
|
|
191
|
+
denied.add(t.relation.slice(RELATION_DENY_PREFIX.length));
|
|
192
|
+
else
|
|
193
|
+
allowed.add(t.relation);
|
|
194
|
+
}
|
|
195
|
+
return { allowed, denied };
|
|
196
|
+
}
|
|
197
|
+
/** `check` con CORTE DE CAMINO (D5): `cutPathAllows`, la regla ÚNICA (la misma que `database` y el oráculo). */
|
|
198
|
+
function resolves(subject, relation, object, partition, at = now) {
|
|
199
|
+
const { allowed, denied } = factsOn(principalOf(subject, partition, at), object, partition, at);
|
|
200
|
+
return cutPathAllows(config, object.type, relation, allowed, denied);
|
|
170
201
|
}
|
|
171
202
|
function collectMembers(object, relation, partition, seen = new Set()) {
|
|
172
203
|
const key = `${object.type}:${object.id}#${relation}`;
|
|
@@ -198,6 +229,26 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
198
229
|
}
|
|
199
230
|
return out;
|
|
200
231
|
}
|
|
232
|
+
/** ¿El grupo (o uno que lo contiene) es sujeto de un deny vivo de la partición? (cierre 2 de D2 · 🟠 N1). */
|
|
233
|
+
function carriedDeny(partition, group) {
|
|
234
|
+
const seen = new Set([group]);
|
|
235
|
+
const queue = [group];
|
|
236
|
+
while (queue.length > 0) {
|
|
237
|
+
const userset = `${queue.shift()}#member`;
|
|
238
|
+
for (const t of tuples) {
|
|
239
|
+
if (t.partition !== partition || t.subjectKey !== userset)
|
|
240
|
+
continue;
|
|
241
|
+
if (t.relation.startsWith(RELATION_DENY_PREFIX))
|
|
242
|
+
return `${t.objectType}:${t.objectId}#${t.relation}@${userset}`;
|
|
243
|
+
// Cierre 3 de D2 · ⚪ C2-4: una arista de anidación VENCIDA no transporta nada.
|
|
244
|
+
if (t.objectType === 'group' && t.relation === 'member' && active(t) && !seen.has(`group:${t.objectId}`)) {
|
|
245
|
+
seen.add(`group:${t.objectId}`);
|
|
246
|
+
queue.push(`group:${t.objectId}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return null;
|
|
251
|
+
}
|
|
201
252
|
const driver = {
|
|
202
253
|
capabilities,
|
|
203
254
|
async relate(subject, relation, object, partition, options) {
|
|
@@ -214,6 +265,15 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
214
265
|
// (una caducada revive sin caducidad), null la quita, Date la fija.
|
|
215
266
|
const expiresAt = resolveGrantExpiry(existing?.expiresAt ?? null, options?.expiresAt, now());
|
|
216
267
|
if (existing) {
|
|
268
|
+
// Cierre 2 de D2 · 🟠 N1: el doble DECLARA lo que hace — con `atomicRenewal` ausente/`false` se porta como un
|
|
269
|
+
// driver que renueva con dos escrituras y NIEGA renovar una membresía que transporta un deny vivo (409, cero
|
|
270
|
+
// escrituras); con `true` renueva en el sitio (atómico).
|
|
271
|
+
// Cierre 3 de D2 · ⚪ C2-4: una membresía YA VENCIDA no conecta nada: reescribirla no abre ninguna ventana.
|
|
272
|
+
if (capabilities.atomicRenewal !== true && object.type === 'group' && active(existing) && !sameInstant(existing.expiresAt, expiresAt)) {
|
|
273
|
+
const carried = carriedDeny(partitionKey, `${object.type}:${object.id}`);
|
|
274
|
+
if (carried !== null)
|
|
275
|
+
throw RenewalUnsafeError.carrier(`${object.type}:${object.id}#${relation}@${subjectKey}`, carried);
|
|
276
|
+
}
|
|
217
277
|
existing.expiresAt = expiresAt;
|
|
218
278
|
return;
|
|
219
279
|
}
|
|
@@ -319,14 +379,76 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
319
379
|
const { distinct, index } = distinctObjects(objects);
|
|
320
380
|
// UN solo `now` para todo el lote (A-18): se fija aquí y `resolves` lo lee.
|
|
321
381
|
const at = now();
|
|
322
|
-
const answers = distinct.map((object) => resolves(subject, relation, object, key,
|
|
382
|
+
const answers = distinct.map((object) => resolves(subject, relation, object, key, () => at));
|
|
323
383
|
return expandByPosition(index, answers);
|
|
324
384
|
};
|
|
325
385
|
}
|
|
386
|
+
// **El deny en el doble** (lote D · D1): solo con `relationDenies: true` —el MÍNIMO no lo trae y el runner juzga
|
|
387
|
+
// la cara `false` (D-28)—. Las MISMAS guardas que los drivers reales (F-05, opt-in en fase 'write', sin caducidad,
|
|
388
|
+
// ni `*` ni `group`) y la MISMA regla (`cutPathAllows`/`decideFrom`).
|
|
389
|
+
if (capabilities.relationDenies) {
|
|
390
|
+
driver.deny = async (subject, relation, object, partition, options) => {
|
|
391
|
+
assertRelationDeclared(config, object, relation);
|
|
392
|
+
assertRelationDenyable(config, object, relation);
|
|
393
|
+
assertDenyNoExpiry(options);
|
|
394
|
+
assertDenySubject(subject);
|
|
395
|
+
assertSubjectDeclared(subject);
|
|
396
|
+
const partitionKey = scopeKeyOf(partition);
|
|
397
|
+
const subjectKey = subjectKeyOf(subject);
|
|
398
|
+
const stored = deniedRelationOf(relation);
|
|
399
|
+
const existing = tuples.find((t) => t.partition === partitionKey && t.objectType === object.type && t.objectId === object.id && t.relation === stored && t.subjectKey === subjectKey);
|
|
400
|
+
if (existing) {
|
|
401
|
+
existing.expiresAt = null;
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
tuples.push({ partition: partitionKey, objectType: object.type, objectId: object.id, relation: stored, subject, subjectKey, expiresAt: null });
|
|
405
|
+
};
|
|
406
|
+
driver.removeDeny = async (subject, relation, object, partition, options) => {
|
|
407
|
+
assertRelationDeclared(config, object, relation);
|
|
408
|
+
assertDenyNoExpiry(options);
|
|
409
|
+
assertSubjectDeclared(subject);
|
|
410
|
+
const partitionKey = scopeKeyOf(partition);
|
|
411
|
+
const subjectKey = subjectKeyOf(subject);
|
|
412
|
+
const stored = deniedRelationOf(relation);
|
|
413
|
+
for (let i = tuples.length - 1; i >= 0; i--) {
|
|
414
|
+
const t = tuples[i];
|
|
415
|
+
if (t.partition === partitionKey && t.objectType === object.type && t.objectId === object.id && t.relation === stored && t.subjectKey === subjectKey) {
|
|
416
|
+
tuples.splice(i, 1);
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
};
|
|
420
|
+
// Lote D · D2: la fase del opt-in en la config de ESTE driver (R2-5), con F-05 del par; la pregunta del plan del reconcile.
|
|
421
|
+
driver.denyPhase = async (object, relation) => {
|
|
422
|
+
assertRelationDeclared(config, object, relation);
|
|
423
|
+
return denyPhaseOf(config, object.type) ?? null;
|
|
424
|
+
};
|
|
425
|
+
driver.listDenies = async (relation, object, partition) => {
|
|
426
|
+
assertRelationDeclared(config, object, relation);
|
|
427
|
+
const direct = directSubjects(scopeKeyOf(partition), object.type, object.id, deniedRelationOf(relation));
|
|
428
|
+
return { subjects: dedupeSubjects(direct.map((t) => t.subject)) };
|
|
429
|
+
};
|
|
430
|
+
driver.decideMany = async (subject, relation, objects, partition) => {
|
|
431
|
+
assertCheckManyObjects(objects, 'decideMany');
|
|
432
|
+
for (const object of objects)
|
|
433
|
+
assertRelationDeclared(config, object, relation);
|
|
434
|
+
assertSubjectDeclared(subject);
|
|
435
|
+
const key = scopeKeyOf(partition);
|
|
436
|
+
const { distinct, index } = distinctObjects(objects);
|
|
437
|
+
const at = now();
|
|
438
|
+
const principal = principalOf(subject, key, () => at);
|
|
439
|
+
const answers = distinct.map((object) => {
|
|
440
|
+
const { allowed, denied } = factsOn(principal, object, key, () => at);
|
|
441
|
+
return decideFrom(config, object.type, relation, allowed, denied);
|
|
442
|
+
});
|
|
443
|
+
return index.map((i) => answers[i]);
|
|
444
|
+
};
|
|
445
|
+
}
|
|
326
446
|
if (capabilities.membersOfNative) {
|
|
327
447
|
driver.membersOf = async (object, relation, partition) => {
|
|
328
448
|
assertRelationDeclared(config, object, relation);
|
|
329
|
-
|
|
449
|
+
// Lote D: `membersOf` RESTA (corte de camino por candidato, como `database`).
|
|
450
|
+
const key = scopeKeyOf(partition);
|
|
451
|
+
return { subjects: collectMembers(object, relation, key).filter((s) => resolves(s, relation, object, key)) };
|
|
330
452
|
};
|
|
331
453
|
}
|
|
332
454
|
if (capabilities.enumerateRelations) {
|
|
@@ -338,7 +460,8 @@ function buildRelationsDouble(options, tuples, now) {
|
|
|
338
460
|
.filter((t) => t.partition === partitionKey)
|
|
339
461
|
.map((t) => ({
|
|
340
462
|
subject: t.subject,
|
|
341
|
-
|
|
463
|
+
// Lote D · D2: el deny se enumera con su relación pública y `kind: 'deny'` (la misma función que los drivers).
|
|
464
|
+
...storedRelationKind(t.relation),
|
|
342
465
|
object: { type: t.objectType, id: t.objectId },
|
|
343
466
|
partition,
|
|
344
467
|
expiresAt: t.expiresAt,
|
|
@@ -365,6 +488,63 @@ function fixedClock(start) {
|
|
|
365
488
|
};
|
|
366
489
|
}
|
|
367
490
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
491
|
+
/* ── Lote D · T1: la tabla de 64 estados con CORTE DE CAMINO (tester ronda 2, §2.2) ─────────────────────────── */
|
|
492
|
+
/** Las tres preguntas de T1, en el orden de la tabla (v / e / o). */
|
|
493
|
+
const T1_QUESTIONS = ['viewer', 'editor', 'owner'];
|
|
494
|
+
/** Orden de los subconjuntos de la tabla del tester: ∅, {v}, {e}, {e,v}, {o}, {o,v}, {o,e}, {o,e,v} (bits v=1, e=2, o=4). */
|
|
495
|
+
function t1Subset(mask) {
|
|
496
|
+
const out = [];
|
|
497
|
+
if (mask & 4)
|
|
498
|
+
out.push('owner');
|
|
499
|
+
if (mask & 2)
|
|
500
|
+
out.push('editor');
|
|
501
|
+
if (mask & 1)
|
|
502
|
+
out.push('viewer');
|
|
503
|
+
return out;
|
|
504
|
+
}
|
|
505
|
+
/** La fila `row` (0..63) de T1: allows = subconjunto `row >> 3`, denies = subconjunto `row & 7`. */
|
|
506
|
+
function t1State(row) {
|
|
507
|
+
return { allows: t1Subset(row >> 3), denies: t1Subset(row & 7) };
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* **La tabla LITERAL del tester** (`panel-tenencia-2-tester.md` §2.2, 64 filas × v/e/o), copiada byte a byte: el
|
|
511
|
+
* oráculo de abajo tiene que reproducirla (si no, el caso es rojo antes de preguntar nada al driver), y el driver
|
|
512
|
+
* tiene que reproducir las dos. NO se calcula con `cutPathAllows` del paquete: un mutante de la regla cambiaría a la
|
|
513
|
+
* vez la respuesta y la expectativa.
|
|
514
|
+
*/
|
|
515
|
+
const T1_EXPECTED = ('FFF FFF FFF FFF FFF FFF FFF FFF ' +
|
|
516
|
+
'TFF FFF TFF FFF TFF FFF TFF FFF ' +
|
|
517
|
+
'TTF FFF FFF FFF TTF FFF FFF FFF ' +
|
|
518
|
+
'TTF FFF TFF FFF TTF FFF TFF FFF ' +
|
|
519
|
+
'TTT FFF FFF FFF FFF FFF FFF FFF ' +
|
|
520
|
+
'TTT FFF TFF FFF TFF FFF TFF FFF ' +
|
|
521
|
+
'TTT FFF FFF FFF TTF FFF FFF FFF ' +
|
|
522
|
+
'TTT FFF TFF FFF TTF FFF TFF FFF').split(' ');
|
|
523
|
+
/** Las 37 filas PAR del tester (alguna semántica plausible en UN solo driver da otra respuesta): las del BRIDGE. */
|
|
524
|
+
const T1_PAR = new Set([
|
|
525
|
+
11, 13, 15, 18, 19, 21, 22, 23, 26, 27, 29, 30, 31, 34, 35, 36, 37, 38, 39, 42, 43, 44, 45, 46, 47, 50, 51, 52, 53,
|
|
526
|
+
54, 55, 58, 59, 60, 61, 62, 63,
|
|
527
|
+
]);
|
|
528
|
+
/** El retículo del contrato, escrito a mano (owner ⇒ editor ⇒ viewer). */
|
|
529
|
+
const T1_DOWN = { viewer: ['viewer', 'editor', 'owner'], editor: ['editor', 'owner'], owner: ['owner'] };
|
|
530
|
+
const T1_UP = { owner: ['owner', 'editor', 'viewer'], editor: ['editor', 'viewer'], viewer: ['viewer'] };
|
|
531
|
+
/** CORTE (D5), la fórmula del tester sobre el retículo a mano: ∃ T ∈ A ∩ down*(Q) con up*(T) ∩ D = ∅. */
|
|
532
|
+
function t1Oracle(allows, denies, q) {
|
|
533
|
+
return T1_DOWN[q].some((t) => allows.includes(t) && T1_UP[t].every((above) => !denies.includes(above)));
|
|
534
|
+
}
|
|
535
|
+
/** D9 (AMPLIO): ¿algún deny en up*(down*(Q))? */
|
|
536
|
+
function t1DeniedAbove(denies, q) {
|
|
537
|
+
return T1_DOWN[q].some((t) => T1_UP[t].some((above) => denies.includes(above)));
|
|
538
|
+
}
|
|
539
|
+
/** Las seis filas con NOMBRE (tester §2.2): cada una se lee sola cuando falla. */
|
|
540
|
+
const T1_NAMED = [
|
|
541
|
+
['D-06 · un deny en viewer bloquea al owner: F/F/F', 34, 'FFF'],
|
|
542
|
+
['D-07 · un deny en editor anula el grant de owner ENTERO: F/F/F (la fila del juez: mata a las siete semánticas)', 35, 'FFF'],
|
|
543
|
+
['D-08 · …salvo un viewer directo: owner + viewer + deny(editor) ⇒ T/F/F', 43, 'TFF'],
|
|
544
|
+
['D-09 · deny en owner sin otro grant: F/F/F (no sobrevive ni el viewer derivado)', 37, 'FFF'],
|
|
545
|
+
['D-10 · owner + viewer + deny(owner) ⇒ T/F/F', 45, 'TFF'],
|
|
546
|
+
['D-11 · un deny en una relación MÁS FUERTE no toca un grant más débil: viewer + deny(editor) ⇒ T/F/F', 11, 'TFF'],
|
|
547
|
+
];
|
|
368
548
|
/* ── El runner ───────────────────────────────────────────────────────────── */
|
|
369
549
|
function holder() {
|
|
370
550
|
return { type: 'user', uuid: uuidv7() };
|
|
@@ -388,8 +568,13 @@ function spy(driver) {
|
|
|
388
568
|
let calls = 0;
|
|
389
569
|
const wrapped = new Proxy(driver, {
|
|
390
570
|
get(target, prop, receiver) {
|
|
571
|
+
// Un método OPCIONAL que el driver no trae se queda ausente (el manager decide por `typeof`: `checkMany`
|
|
572
|
+
// compone, `decideMany`/`deny` son 500): envolverlo lo haría aparecer.
|
|
573
|
+
if (typeof target[prop] !== 'function')
|
|
574
|
+
return Reflect.get(target, prop, receiver);
|
|
391
575
|
// L-2: las CUATRO escrituras (también `purge*`, que reciben `{ transaction }`).
|
|
392
|
-
|
|
576
|
+
// Lote D: también `deny`/`removeDeny` (escrituras del deny).
|
|
577
|
+
if (prop === 'relate' || prop === 'unrelate' || prop === 'purgeObject' || prop === 'purgeSubject' || prop === 'deny' || prop === 'removeDeny') {
|
|
393
578
|
return async (...args) => {
|
|
394
579
|
writes++;
|
|
395
580
|
calls++;
|
|
@@ -398,7 +583,14 @@ function spy(driver) {
|
|
|
398
583
|
}
|
|
399
584
|
// alpha.3 (cierre) · las TRES lecturas también cuentan: F-05 tiene que
|
|
400
585
|
// cortarlas ANTES del driver («cero llamadas» = ni una lectura).
|
|
401
|
-
|
|
586
|
+
// Lote D: y `checkMany`/`decideMany`/`listDenies`/`membersOf` (las lecturas del lote, «cero llamadas» = ninguna).
|
|
587
|
+
if (prop === 'check' ||
|
|
588
|
+
prop === 'listObjects' ||
|
|
589
|
+
prop === 'listSubjects' ||
|
|
590
|
+
prop === 'checkMany' ||
|
|
591
|
+
prop === 'decideMany' ||
|
|
592
|
+
prop === 'listDenies' ||
|
|
593
|
+
prop === 'membersOf') {
|
|
402
594
|
return async (...args) => {
|
|
403
595
|
calls++;
|
|
404
596
|
return target[prop](...args);
|
|
@@ -970,6 +1162,75 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
970
1162
|
assert.isFalse(page.tuples.some((t) => t.object.type === 'role_binding'), 'cero tuplas role_binding');
|
|
971
1163
|
}
|
|
972
1164
|
});
|
|
1165
|
+
/* ── B3 · el userset del SUJETO: `group#member` y solo `group#member` (plan 2.5 · lote B; cierre 1 · 🟡 5) ── */
|
|
1166
|
+
/**
|
|
1167
|
+
* **B3 en el runner PUBLICADO** (cierre 1 del lote B · 🟡 5 del auditor):
|
|
1168
|
+
* hasta el cierre la variante `usersetUnsupported` solo vivía en la guardia
|
|
1169
|
+
* de superficie del paquete, así que un driver de terceros que guardara la
|
|
1170
|
+
* fila inerte (`document#viewer` como sujeto) —o respondiera 503— pasaba
|
|
1171
|
+
* `runRelationsDriverContract` entero, y el reconcile hacia él y
|
|
1172
|
+
* `manager.driver()` repetían los 🟠 1/🟠 2. Es «variante de F-05» (D13):
|
|
1173
|
+
* se exige por el MANAGER y por el DRIVER llamado en directo, en las seis
|
|
1174
|
+
* operaciones con sujeto, con el `code` propio; por el manager con CERO
|
|
1175
|
+
* llamadas al driver (espía) y CERO eventos. El userset legítimo
|
|
1176
|
+
* (`group#member`) sigue entrando por los mismos caminos (control).
|
|
1177
|
+
*/
|
|
1178
|
+
test('B3 · un userset ≠ group#member como SUJETO (document#viewer, declarado) ⇒ 422 E_AUTHZ_RELATION_USERSET_UNSUPPORTED en relate/unrelate/check/checkMany/listObjects/purgeSubject, por el MANAGER (CERO llamadas al driver, CERO eventos) y por el DRIVER en directo; group#member entra (control)', async ({ assert, }) => {
|
|
1179
|
+
const S = partition();
|
|
1180
|
+
const doc = { type: 'document', id: docId() };
|
|
1181
|
+
const other = { type: 'document', id: docId() };
|
|
1182
|
+
const stale = { object: other, relation: 'viewer' };
|
|
1183
|
+
const attemptsOn = (driver) => [
|
|
1184
|
+
['relate', () => driver.relate(stale, 'viewer', doc, S)],
|
|
1185
|
+
['unrelate', () => driver.unrelate(stale, 'viewer', doc, S)],
|
|
1186
|
+
['check', () => driver.check(stale, 'viewer', doc, S)],
|
|
1187
|
+
['checkMany', () => (typeof driver.checkMany === 'function' ? driver.checkMany(stale, 'viewer', [doc], S) : driver.check(stale, 'viewer', doc, S))],
|
|
1188
|
+
['listObjects', () => driver.listObjects(stale, 'viewer', 'document', S)],
|
|
1189
|
+
['purgeSubject', () => driver.purgeSubject(stale, S)],
|
|
1190
|
+
];
|
|
1191
|
+
for (const [path, target] of [
|
|
1192
|
+
['manager', state.manager],
|
|
1193
|
+
['driver', state.base],
|
|
1194
|
+
]) {
|
|
1195
|
+
for (const [label, run] of attemptsOn(target)) {
|
|
1196
|
+
const caught = await run().then(() => null, (e) => e);
|
|
1197
|
+
assert.isNotNull(caught, `ROJO (B3 · ${path}.${label}): el userset document#viewer ENTRÓ como sujeto`);
|
|
1198
|
+
assert.equal(caught.status, 422, `${path}.${label}: ${caught.message}`);
|
|
1199
|
+
assert.equal(caught.code, 'E_AUTHZ_RELATION_USERSET_UNSUPPORTED', `${path}.${label}`);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
// Por el manager, «cero llamadas» se observa con el espía.
|
|
1203
|
+
assert.equal(state.calls(), 0, 'por el manager: CERO llamadas al driver');
|
|
1204
|
+
assert.lengthOf(state.events, 0, 'CERO eventos');
|
|
1205
|
+
// Cierre 2 del lote B · 🟡 N4: por el driver en directo el runner SÍ ve su backend — por el PUERTO. Un
|
|
1206
|
+
// tercero que valida DESPUÉS de escribir (`relate` guarda la fila inerte y luego lanza el 422) pasaba el
|
|
1207
|
+
// contrato entero (medido: 58/58 verdes con ese mutante sobre el doble). Se afirma por `listSubjects`
|
|
1208
|
+
// (invariante 7: hechos directos; un userset guardado saldría ahí con `usersetSubjects: true`) y, con la
|
|
1209
|
+
// capacidad, por `enumerateRelations` (la partición entera): la fila NO quedó.
|
|
1210
|
+
// **Límite declarado (cierre 3 · ⚪ N11)**: el runner solo ve el backend por los puertos que el harness
|
|
1211
|
+
// DECLARA. Con `usersetSubjects: false` (`listSubjects` no devuelve usersets) y `enumerateRelations: false`
|
|
1212
|
+
// no hay puerto por el que observar la fila inerte, así que un tercero con ese harness mínimo que guarde y
|
|
1213
|
+
// DESPUÉS lance pasa este caso (medido con el mutante «guarda y luego lanza» sobre el doble mínimo). Declara
|
|
1214
|
+
// las capacidades que tu driver tiene; el runner no puede probar lo que no se le deja ver (README «Third-party
|
|
1215
|
+
// drivers»).
|
|
1216
|
+
const staleKey = `${other.type}:${other.id}#viewer`;
|
|
1217
|
+
const describeSubject = (s) => (isRelUserset(s) ? `${s.object.type}:${s.object.id}#${s.relation}` : `${s.type}:${s.uuid}`);
|
|
1218
|
+
const listed = await state.base.listSubjects('viewer', doc, S);
|
|
1219
|
+
assert.notInclude(listed.subjects.map(describeSubject), staleKey, 'ROJO (N4): el driver GUARDÓ la fila inerte antes de lanzar el 422 — listSubjects la enumera');
|
|
1220
|
+
if (typeof state.base.enumerateRelations === 'function' && harness.capabilities.enumerateRelations) {
|
|
1221
|
+
const page = await state.base.enumerateRelations(S);
|
|
1222
|
+
assert.isFalse(page.tuples.some((t) => describeSubject(t.subject) === staleKey), 'ROJO (N4): la partición contiene la fila inerte que el 422 decía no haber escrito');
|
|
1223
|
+
}
|
|
1224
|
+
// Control: el userset legítimo entra por los dos caminos y concede.
|
|
1225
|
+
const u = holder();
|
|
1226
|
+
const g = { type: 'group', id: docId() };
|
|
1227
|
+
await state.manager.relate(u, 'member', g, S);
|
|
1228
|
+
await state.manager.relate({ object: g, relation: 'member' }, 'viewer', doc, S);
|
|
1229
|
+
assert.isTrue(await state.manager.check(u, 'viewer', doc, S));
|
|
1230
|
+
assert.isTrue(await state.base.check({ object: g, relation: 'member' }, 'viewer', doc, S));
|
|
1231
|
+
await state.base.purgeSubject({ object: g, relation: 'member' }, S);
|
|
1232
|
+
assert.isFalse(await state.manager.check(u, 'viewer', doc, S));
|
|
1233
|
+
});
|
|
973
1234
|
/* ── R-15 · caducidad de la tupla (núcleo, sin reloj): estricta y validada ── */
|
|
974
1235
|
test('R-15 · relate con expiresAt pasado NO concede (check/listObjects/listSubjects); futuro sí; expiresAt inválido ⇒ 422 sin tocar el driver', async ({ assert, }) => {
|
|
975
1236
|
const u = holder();
|
|
@@ -1501,6 +1762,595 @@ export function registerRelationsDriverContract(harness, api) {
|
|
|
1501
1762
|
assert.include(atResolve.message, 'transactionalWrites');
|
|
1502
1763
|
}),
|
|
1503
1764
|
});
|
|
1765
|
+
/* ── Lote D · D1 · el par `relationDenies` (D5 corte de camino, D8, D9, D10, D3, S8–S10, S12–S15, P-2) ── */
|
|
1766
|
+
/** Un manager sobre la config del contrato CON deny (`document` 'write', `workspace` 'read', `note` sin opt-in). */
|
|
1767
|
+
async function denyManager(options) {
|
|
1768
|
+
const config = contractDenyRelationsConfig();
|
|
1769
|
+
const base = await harness.makeDriver(config);
|
|
1770
|
+
const s = spy(base);
|
|
1771
|
+
return {
|
|
1772
|
+
manager: new RelationsManager(s.driver, config, { driverName: harness.name, ...options }),
|
|
1773
|
+
base,
|
|
1774
|
+
config,
|
|
1775
|
+
writes: s.writes,
|
|
1776
|
+
calls: s.calls,
|
|
1777
|
+
};
|
|
1778
|
+
}
|
|
1779
|
+
/** Siembra un estado de T1 sobre un objeto: allows `A` y denies `D` (subconjuntos de owner/editor/viewer) para `u`. */
|
|
1780
|
+
async function seedState(m, u, object, p, allows, denies) {
|
|
1781
|
+
for (const r of allows)
|
|
1782
|
+
await m.relate(u, r, object, p);
|
|
1783
|
+
for (const r of denies)
|
|
1784
|
+
await m.deny(u, r, object, p);
|
|
1785
|
+
}
|
|
1786
|
+
/* ── Cierre 2 de D2 · 🟠 N1 · el par `atomicRenewal` (renovar una membresía que TRANSPORTA un deny) ── */
|
|
1787
|
+
/**
|
|
1788
|
+
* Siembra: U ∈ g con caducidad (+1 h), un allow DIRECTO de U sobre X y —si el driver deniega— el deny de `g#member`
|
|
1789
|
+
* sobre X; además U ∈ h (+1 h) con h ⊂ g (portadora ANIDADA) y U ∈ k (+1 h) sin deny. Devuelve la caducidad sembrada.
|
|
1790
|
+
*/
|
|
1791
|
+
async function seedRenewal(m, denies) {
|
|
1792
|
+
const u = holder();
|
|
1793
|
+
const p = partition();
|
|
1794
|
+
const x = { type: 'document', id: docId() };
|
|
1795
|
+
const g = { type: 'group', id: docId() };
|
|
1796
|
+
const h = { type: 'group', id: docId() };
|
|
1797
|
+
const k = { type: 'group', id: docId() };
|
|
1798
|
+
const hour = new Date(Date.now() + 3_600_000);
|
|
1799
|
+
await m.relate(u, 'member', g, p, { expiresAt: hour });
|
|
1800
|
+
if (harness.capabilities.usersetSubjects)
|
|
1801
|
+
await m.relate({ object: h, relation: 'member' }, 'member', g, p);
|
|
1802
|
+
await m.relate(u, 'member', h, p, { expiresAt: hour });
|
|
1803
|
+
await m.relate(u, 'member', k, p, { expiresAt: hour });
|
|
1804
|
+
await m.relate(u, 'viewer', x, p);
|
|
1805
|
+
if (denies && harness.capabilities.usersetSubjects)
|
|
1806
|
+
await m.deny({ object: g, relation: 'member' }, 'viewer', x, p);
|
|
1807
|
+
return { u, p, x, g, h, k, hour };
|
|
1808
|
+
}
|
|
1809
|
+
/** La caducidad de la membresía de U en `group` según la enumeración del driver (si la trae); `undefined` si no. */
|
|
1810
|
+
async function membershipExpiry(base, u, group, p) {
|
|
1811
|
+
if (!harness.capabilities.enumerateRelations || typeof base.enumerateRelations !== 'function')
|
|
1812
|
+
return undefined;
|
|
1813
|
+
const page = await base.enumerateRelations(p);
|
|
1814
|
+
const tuple = page.tuples.find((t) => t.object.type === 'group' && t.object.id === group.id && !isRelUserset(t.subject) && t.subject.uuid === u.uuid);
|
|
1815
|
+
return tuple ? (tuple.expiresAt ?? null) : undefined;
|
|
1816
|
+
}
|
|
1817
|
+
caseFor('atomicRenewal', {
|
|
1818
|
+
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 }) => {
|
|
1819
|
+
const { manager, base } = harness.capabilities.relationDenies ? await denyManager() : { manager: state.manager, base: state.base };
|
|
1820
|
+
const { u, p, x, g, h, k } = await seedRenewal(manager, harness.capabilities.relationDenies === true);
|
|
1821
|
+
for (const group of [g, h, k]) {
|
|
1822
|
+
await manager.relate(u, 'member', group, p, { expiresAt: null });
|
|
1823
|
+
const expiry = await membershipExpiry(base, u, group, p);
|
|
1824
|
+
if (expiry !== undefined)
|
|
1825
|
+
assert.isNull(expiry, `la membresía de ${group.id} quedó SIN caducidad`);
|
|
1826
|
+
}
|
|
1827
|
+
const carried = harness.capabilities.relationDenies === true && harness.capabilities.usersetSubjects === true;
|
|
1828
|
+
assert.equal(await manager.check(u, 'viewer', x, p), !carried, 'el deny transportado sigue negando tras renovar');
|
|
1829
|
+
}),
|
|
1830
|
+
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 }) => {
|
|
1831
|
+
const denies = harness.capabilities.relationDenies === true && harness.capabilities.usersetSubjects === true;
|
|
1832
|
+
const { manager, base } = harness.capabilities.relationDenies ? await denyManager() : { manager: state.manager, base: state.base };
|
|
1833
|
+
const { u, p, x, g, h, k, hour } = await seedRenewal(manager, denies);
|
|
1834
|
+
if (denies) {
|
|
1835
|
+
for (const group of [g, h]) {
|
|
1836
|
+
let caught;
|
|
1837
|
+
try {
|
|
1838
|
+
await manager.relate(u, 'member', group, p, { expiresAt: null });
|
|
1839
|
+
}
|
|
1840
|
+
catch (error) {
|
|
1841
|
+
caught = error;
|
|
1842
|
+
}
|
|
1843
|
+
assert.equal(caught?.status, 409, `renovar ${group.id === g.id ? 'la portadora' : 'la portadora ANIDADA'}: ${caught?.message}`);
|
|
1844
|
+
assert.equal(caught?.code, 'E_AUTHZ_RENEWAL_UNSAFE');
|
|
1845
|
+
assert.include(caught?.message, 'unrelate + relate');
|
|
1846
|
+
assert.include(caught?.message, 'No se ha escrito nada');
|
|
1847
|
+
const expiry = await membershipExpiry(base, u, group, p);
|
|
1848
|
+
if (expiry !== undefined)
|
|
1849
|
+
assert.equal(expiry?.getTime(), hour.getTime(), 'CERO escrituras: la caducidad no cambió');
|
|
1850
|
+
assert.isFalse(await manager.check(u, 'viewer', x, p), 'el deny sigue negando');
|
|
1851
|
+
}
|
|
1852
|
+
// Cierre 3 de D2 · ⚪ C2-4: una membresía portadora YA VENCIDA no conecta nada (no hay ventana que proteger) ⇒
|
|
1853
|
+
// reescribirla NO es 409; tampoco la de un grupo cuyo ÚNICO camino al deny es una arista de anidación vencida.
|
|
1854
|
+
const past = new Date(Date.now() - 3_600_000);
|
|
1855
|
+
const late = holder();
|
|
1856
|
+
await manager.relate(late, 'viewer', x, p);
|
|
1857
|
+
await manager.relate(late, 'member', g, p, { expiresAt: past });
|
|
1858
|
+
assert.isTrue(await manager.check(late, 'viewer', x, p), 'la membresía vencida no lleva el deny hasta el sujeto');
|
|
1859
|
+
await manager.relate(late, 'member', g, p, { expiresAt: null });
|
|
1860
|
+
assert.isFalse(await manager.check(late, 'viewer', x, p), 'reescrita: el deny de g vuelve a alcanzarle');
|
|
1861
|
+
const viaExpired = { type: 'group', id: docId() };
|
|
1862
|
+
await manager.relate({ object: viaExpired, relation: 'member' }, 'member', g, p, { expiresAt: past });
|
|
1863
|
+
const nested = holder();
|
|
1864
|
+
await manager.relate(nested, 'member', viaExpired, p, { expiresAt: hour });
|
|
1865
|
+
await manager.relate(nested, 'member', viaExpired, p, { expiresAt: null });
|
|
1866
|
+
const nestedExpiry = await membershipExpiry(base, nested, viaExpired, p);
|
|
1867
|
+
if (nestedExpiry !== undefined)
|
|
1868
|
+
assert.isNull(nestedExpiry, 'la arista vencida hacia g no transporta su deny: se renueva');
|
|
1869
|
+
}
|
|
1870
|
+
await manager.relate(u, 'member', k, p, { expiresAt: null });
|
|
1871
|
+
const expiry = await membershipExpiry(base, u, k, p);
|
|
1872
|
+
if (expiry !== undefined)
|
|
1873
|
+
assert.isNull(expiry, 'la membresía SIN deny se renueva');
|
|
1874
|
+
}),
|
|
1875
|
+
});
|
|
1876
|
+
caseFor('relationDenies', {
|
|
1877
|
+
whenTrue: () => {
|
|
1878
|
+
test('D-05 · corte de camino: las 192 respuestas de T1 (64 estados × viewer/editor/owner) por check; checkMany, decideMany (D9) y listObjects (D-15) dicen lo MISMO sobre los 64 objetos; P1-M monotonía (D-12)', async ({ assert, }) => {
|
|
1879
|
+
// El oráculo es INDEPENDIENTE del código del paquete (la tabla literal del tester §2.2 y su fórmula con el
|
|
1880
|
+
// retículo escrito a mano): si fuera `cutPathAllows`, un mutante de la regla cambiaría a la vez el driver y
|
|
1881
|
+
// la expectativa y viviría.
|
|
1882
|
+
for (let row = 0; row < 64; row++) {
|
|
1883
|
+
const { allows, denies } = t1State(row);
|
|
1884
|
+
const oracle = T1_QUESTIONS.map((q) => (t1Oracle(allows, denies, q) ? 'T' : 'F')).join('');
|
|
1885
|
+
assert.equal(oracle, T1_EXPECTED[row], `T1 fila ${row + 1}: el oráculo y la tabla del tester divergen`);
|
|
1886
|
+
}
|
|
1887
|
+
const { manager } = await denyManager();
|
|
1888
|
+
const u = holder();
|
|
1889
|
+
const p = partition();
|
|
1890
|
+
const objects = [];
|
|
1891
|
+
for (let row = 0; row < 64; row++) {
|
|
1892
|
+
const object = { type: 'document', id: docId() };
|
|
1893
|
+
objects.push(object);
|
|
1894
|
+
const { allows, denies } = t1State(row);
|
|
1895
|
+
await seedState(manager, u, object, p, allows, denies);
|
|
1896
|
+
}
|
|
1897
|
+
const observed = [];
|
|
1898
|
+
for (let row = 0; row < 64; row++) {
|
|
1899
|
+
let answer = '';
|
|
1900
|
+
for (const q of T1_QUESTIONS)
|
|
1901
|
+
answer += (await manager.check(u, q, objects[row], p)) ? 'T' : 'F';
|
|
1902
|
+
observed.push(answer);
|
|
1903
|
+
}
|
|
1904
|
+
const wrong = observed
|
|
1905
|
+
.map((got, row) => [row, got])
|
|
1906
|
+
.filter(([row, got]) => got !== T1_EXPECTED[row])
|
|
1907
|
+
.map(([row, got]) => `fila ${row + 1} (A=${t1State(row).allows.join('+') || '∅'} D=${t1State(row).denies.join('+') || '∅'}${T1_PAR.has(row + 1) ? ', PAR' : ''}): esperado ${T1_EXPECTED[row]} (v/e/o), obtenido ${got}`);
|
|
1908
|
+
assert.deepEqual(wrong, [], 'T1 · check con corte de camino');
|
|
1909
|
+
// checkMany (sin cambio de contrato) y decideMany (D9 AMPLIO), por lotes de ≤ 50, y listObjects (resta).
|
|
1910
|
+
for (const [qi, q] of T1_QUESTIONS.entries()) {
|
|
1911
|
+
const many = [];
|
|
1912
|
+
const decided = [];
|
|
1913
|
+
for (let start = 0; start < 64; start += RELATIONS_CHECK_MANY_MAX) {
|
|
1914
|
+
const batch = objects.slice(start, start + RELATIONS_CHECK_MANY_MAX);
|
|
1915
|
+
many.push(...(await manager.checkMany(u, q, batch, p)));
|
|
1916
|
+
decided.push(...(await manager.decideMany(u, q, batch, p)));
|
|
1917
|
+
}
|
|
1918
|
+
assert.deepEqual(many.map((b) => (b ? 'T' : 'F')).join(''), T1_EXPECTED.map((x) => x[qi]).join(''), `checkMany(${q}) honra el deny igual que check (sin cambio de contrato)`);
|
|
1919
|
+
const expectedDecisions = T1_EXPECTED.map((x, row) => (x[qi] === 'T' ? 'allow' : t1DeniedAbove(t1State(row).denies, q) ? 'deny' : 'none'));
|
|
1920
|
+
assert.deepEqual(decided, expectedDecisions, `decideMany(${q}) · D9 amplio sobre los 64 estados`);
|
|
1921
|
+
const page = await manager.listObjects(u, q, 'document', p);
|
|
1922
|
+
const listed = page.objects.map((o) => o.id);
|
|
1923
|
+
const expectedIds = objects.filter((_, row) => T1_EXPECTED[row][qi] === 'T').map((o) => o.id);
|
|
1924
|
+
const intruders = listed.filter((id) => !expectedIds.includes(id));
|
|
1925
|
+
assert.deepEqual(intruders, [], `listObjects(${q}) RESTA (D-15): ni un objeto denegado (S16: tampoco con el tope)`);
|
|
1926
|
+
if (page.truncated === true) {
|
|
1927
|
+
// S16 (tope bajo, :8103 o el doble con tope): la página cortada es un SUBCONJUNTO de lo concedido, y el
|
|
1928
|
+
// truncado solo se señala si hay concedidos de más — nunca porque los denegados llenen el tope.
|
|
1929
|
+
assert.isAbove(expectedIds.length, listed.length, `listObjects(${q}) · truncated solo si hay allows de más`);
|
|
1930
|
+
}
|
|
1931
|
+
else {
|
|
1932
|
+
assert.sameMembers(listed, expectedIds, `listObjects(${q}) RESTA (D-15) y es exhaustiva sin truncated`);
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
// D-12 · P1-M: más allows o menos denies nunca quita acceso (sobre lo OBSERVADO).
|
|
1936
|
+
const nonMonotone = [];
|
|
1937
|
+
for (let a = 0; a < 64; a++) {
|
|
1938
|
+
for (let b = 0; b < 64; b++) {
|
|
1939
|
+
const A = t1State(a);
|
|
1940
|
+
const B = t1State(b);
|
|
1941
|
+
const moreOrEqual = A.allows.every((r) => B.allows.includes(r)) && B.denies.every((r) => A.denies.includes(r));
|
|
1942
|
+
if (!moreOrEqual)
|
|
1943
|
+
continue;
|
|
1944
|
+
for (let qi = 0; qi < 3; qi++) {
|
|
1945
|
+
if (observed[a][qi] === 'T' && observed[b][qi] === 'F')
|
|
1946
|
+
nonMonotone.push(`${a + 1}→${b + 1} (${T1_QUESTIONS[qi]})`);
|
|
1947
|
+
}
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
assert.deepEqual(nonMonotone, [], 'D-12 · P1-M: monotonía en los 64 estados');
|
|
1951
|
+
});
|
|
1952
|
+
for (const [name, row, expected] of T1_NAMED) {
|
|
1953
|
+
test(`${name} (T1 fila ${row})`, async ({ assert }) => {
|
|
1954
|
+
const { manager } = await denyManager();
|
|
1955
|
+
const u = holder();
|
|
1956
|
+
const p = partition();
|
|
1957
|
+
const object = { type: 'document', id: docId() };
|
|
1958
|
+
const { allows, denies } = t1State(row - 1);
|
|
1959
|
+
await seedState(manager, u, object, p, allows, denies);
|
|
1960
|
+
const got = [];
|
|
1961
|
+
for (const q of T1_QUESTIONS)
|
|
1962
|
+
got.push((await manager.check(u, q, object, p)) ? 'T' : 'F');
|
|
1963
|
+
assert.equal(got.join(''), expected, `${name}: v/e/o`);
|
|
1964
|
+
});
|
|
1965
|
+
}
|
|
1966
|
+
test('D-01/D-02/D-03 · deny gana sobre el allow directo y removeDeny lo restaura; el ORDEN de escritura no importa; deny y removeDeny idempotentes (re-deny no duplica, un removeDeny levanta un deny repetido, re-removeDeny es no-op)', async ({ assert }) => {
|
|
1967
|
+
const { manager } = await denyManager();
|
|
1968
|
+
const p = partition();
|
|
1969
|
+
const u = holder();
|
|
1970
|
+
const a = { type: 'document', id: docId() };
|
|
1971
|
+
await manager.relate(u, 'viewer', a, p);
|
|
1972
|
+
assert.isTrue(await manager.check(u, 'viewer', a, p));
|
|
1973
|
+
await manager.deny(u, 'viewer', a, p);
|
|
1974
|
+
assert.isFalse(await manager.check(u, 'viewer', a, p), 'D-01: deny gana');
|
|
1975
|
+
await manager.removeDeny(u, 'viewer', a, p);
|
|
1976
|
+
assert.isTrue(await manager.check(u, 'viewer', a, p), 'D-01: removeDeny restaura');
|
|
1977
|
+
// D-02: deny ANTES que el allow, mismo resultado.
|
|
1978
|
+
const b = { type: 'document', id: docId() };
|
|
1979
|
+
await manager.deny(u, 'viewer', b, p);
|
|
1980
|
+
await manager.relate(u, 'viewer', b, p);
|
|
1981
|
+
assert.isFalse(await manager.check(u, 'viewer', b, p), 'D-02: deny→allow = allow→deny');
|
|
1982
|
+
// D-03: idempotencia.
|
|
1983
|
+
await manager.deny(u, 'viewer', b, p);
|
|
1984
|
+
await manager.deny(u, 'viewer', b, p);
|
|
1985
|
+
assert.lengthOf((await manager.listDenies('viewer', b, p)).subjects, 1, 're-deny no duplica');
|
|
1986
|
+
await manager.removeDeny(u, 'viewer', b, p);
|
|
1987
|
+
assert.isTrue(await manager.check(u, 'viewer', b, p), 'un removeDeny levanta un deny repetido');
|
|
1988
|
+
await manager.removeDeny(u, 'viewer', b, p);
|
|
1989
|
+
assert.isTrue(await manager.check(u, 'viewer', b, p), 're-removeDeny es no-op');
|
|
1990
|
+
assert.deepEqual((await manager.listDenies('viewer', b, p)).subjects, []);
|
|
1991
|
+
});
|
|
1992
|
+
test('D-04 · allow y deny del MISMO hecho conviven: unrelate no levanta el deny, removeDeny no quita el allow; listSubjects NO resta (D-15) y listDenies da los hechos directos', async ({ assert }) => {
|
|
1993
|
+
const { manager } = await denyManager();
|
|
1994
|
+
const p = partition();
|
|
1995
|
+
const u = holder();
|
|
1996
|
+
const v = holder();
|
|
1997
|
+
const a = { type: 'document', id: docId() };
|
|
1998
|
+
await manager.relate(u, 'viewer', a, p);
|
|
1999
|
+
await manager.relate(v, 'viewer', a, p);
|
|
2000
|
+
await manager.deny(u, 'viewer', a, p);
|
|
2001
|
+
const subjects = (await manager.listSubjects('viewer', a, p)).subjects.map((s) => s.uuid);
|
|
2002
|
+
assert.sameMembers(subjects, [u.uuid, v.uuid], 'listSubjects da HECHOS: no resta el deny');
|
|
2003
|
+
assert.deepEqual((await manager.listDenies('viewer', a, p)).subjects.map((s) => s.uuid), [u.uuid]);
|
|
2004
|
+
await manager.unrelate(u, 'viewer', a, p);
|
|
2005
|
+
await manager.relate(u, 'viewer', a, p);
|
|
2006
|
+
assert.isFalse(await manager.check(u, 'viewer', a, p), 'unrelate no tocó el deny');
|
|
2007
|
+
assert.lengthOf((await manager.listDenies('viewer', a, p)).subjects, 1);
|
|
2008
|
+
await manager.removeDeny(u, 'viewer', a, p);
|
|
2009
|
+
assert.isTrue(await manager.check(u, 'viewer', a, p), 'removeDeny no quitó el allow');
|
|
2010
|
+
assert.isTrue(await manager.check(v, 'viewer', a, p), 'el deny de u nunca tocó a v');
|
|
2011
|
+
});
|
|
2012
|
+
test('D-13 · deny a g#member bloquea a sus miembros, también ANIDADOS (g1 ⊂ g2, deny a g2#member); un deny DIRECTO bloquea aunque el allow llegue por grupo', async ({ assert }) => {
|
|
2013
|
+
const { manager } = await denyManager();
|
|
2014
|
+
const p = partition();
|
|
2015
|
+
const u = holder();
|
|
2016
|
+
const g1 = { type: 'group', id: docId() };
|
|
2017
|
+
const g2 = { type: 'group', id: docId() };
|
|
2018
|
+
await manager.relate(u, 'member', g1, p);
|
|
2019
|
+
await manager.relate({ object: g1, relation: 'member' }, 'member', g2, p);
|
|
2020
|
+
const a = { type: 'document', id: docId() };
|
|
2021
|
+
await manager.relate(u, 'owner', a, p);
|
|
2022
|
+
await manager.deny({ object: g2, relation: 'member' }, 'editor', a, p);
|
|
2023
|
+
assert.isFalse(await manager.check(u, 'viewer', a, p), 'deny(editor) a g2#member alcanza a u por g1 ⊂ g2 (fila 35)');
|
|
2024
|
+
const b = { type: 'document', id: docId() };
|
|
2025
|
+
await manager.relate({ object: g1, relation: 'member' }, 'viewer', b, p);
|
|
2026
|
+
assert.isTrue(await manager.check(u, 'viewer', b, p));
|
|
2027
|
+
await manager.deny(u, 'viewer', b, p);
|
|
2028
|
+
assert.isFalse(await manager.check(u, 'viewer', b, p), 'deny directo gana al allow que llega por el grupo');
|
|
2029
|
+
});
|
|
2030
|
+
test('D-14 · check de un USERSET: el deny de un miembro NO sube al grupo (check(g#member) sigue siendo la fila de g)', async ({ assert }) => {
|
|
2031
|
+
const { manager } = await denyManager();
|
|
2032
|
+
const p = partition();
|
|
2033
|
+
const u = holder();
|
|
2034
|
+
const g = { type: 'group', id: docId() };
|
|
2035
|
+
const a = { type: 'document', id: docId() };
|
|
2036
|
+
await manager.relate(u, 'member', g, p);
|
|
2037
|
+
await manager.relate({ object: g, relation: 'member' }, 'viewer', a, p);
|
|
2038
|
+
await manager.deny(u, 'viewer', a, p);
|
|
2039
|
+
assert.isFalse(await manager.check(u, 'viewer', a, p));
|
|
2040
|
+
assert.isTrue(await manager.check({ object: g, relation: 'member' }, 'viewer', a, p), 'el grupo conserva su viewer');
|
|
2041
|
+
});
|
|
2042
|
+
test('D-16 · purgeObject y purgeSubject borran también los denies (después del objeto, nada del sujeto sobrevive; re-relate concede)', async ({ assert }) => {
|
|
2043
|
+
const { manager } = await denyManager();
|
|
2044
|
+
const p = partition();
|
|
2045
|
+
const u = holder();
|
|
2046
|
+
const a = { type: 'document', id: docId() };
|
|
2047
|
+
await manager.relate(u, 'viewer', a, p);
|
|
2048
|
+
await manager.deny(u, 'viewer', a, p);
|
|
2049
|
+
await manager.purgeObject(a, p);
|
|
2050
|
+
assert.deepEqual((await manager.listDenies('viewer', a, p)).subjects, [], 'purgeObject borró el deny');
|
|
2051
|
+
await manager.relate(u, 'viewer', a, p);
|
|
2052
|
+
assert.isTrue(await manager.check(u, 'viewer', a, p));
|
|
2053
|
+
await manager.deny(u, 'viewer', a, p);
|
|
2054
|
+
await manager.purgeSubject(u, p);
|
|
2055
|
+
assert.deepEqual((await manager.listDenies('viewer', a, p)).subjects, [], 'purgeSubject borró el deny');
|
|
2056
|
+
await manager.relate(u, 'viewer', a, p);
|
|
2057
|
+
assert.isTrue(await manager.check(u, 'viewer', a, p), 'ni allow ni deny viejos: el re-relate concede');
|
|
2058
|
+
});
|
|
2059
|
+
test('D-15b · membersOf RESTA (si el driver es nativo): un miembro TRANSITIVO con deny directo, o alcanzado por un deny al grupo, no sale; sin deny, la lista de siempre', async ({ assert }) => {
|
|
2060
|
+
if (!harness.capabilities.membersOfNative) {
|
|
2061
|
+
// Cara sin membersOf nativo: la operación es 500 E_AUTHZ_UNSUPPORTED (su par lo juzga); aquí se afirma que el deny no la abre.
|
|
2062
|
+
const { manager } = await denyManager();
|
|
2063
|
+
const caught = await manager.membersOf({ type: 'document', id: docId() }, 'viewer', partition()).then(() => null, (e) => e);
|
|
2064
|
+
assert.equal(caught?.code, 'E_AUTHZ_UNSUPPORTED');
|
|
2065
|
+
return;
|
|
2066
|
+
}
|
|
2067
|
+
const { manager } = await denyManager();
|
|
2068
|
+
const p = partition();
|
|
2069
|
+
const a = holder();
|
|
2070
|
+
const b = holder();
|
|
2071
|
+
const c = holder();
|
|
2072
|
+
const g = { type: 'group', id: docId() };
|
|
2073
|
+
const doc = { type: 'document', id: docId() };
|
|
2074
|
+
await manager.relate(a, 'member', g, p);
|
|
2075
|
+
await manager.relate(b, 'member', g, p);
|
|
2076
|
+
await manager.relate({ object: g, relation: 'member' }, 'viewer', doc, p);
|
|
2077
|
+
await manager.relate(c, 'owner', doc, p);
|
|
2078
|
+
const before = (await manager.membersOf(doc, 'viewer', p)).subjects.map((s) => s.uuid);
|
|
2079
|
+
assert.sameMembers(before, [a.uuid, b.uuid, c.uuid]);
|
|
2080
|
+
await manager.deny(b, 'viewer', doc, p);
|
|
2081
|
+
await manager.deny(c, 'editor', doc, p);
|
|
2082
|
+
const after = (await manager.membersOf(doc, 'viewer', p)).subjects.map((s) => s.uuid);
|
|
2083
|
+
assert.sameMembers(after, [a.uuid], 'b (deny directo) y c (owner cortado por deny(editor), fila 35) no salen');
|
|
2084
|
+
});
|
|
2085
|
+
test('S8–S10 · FILAS DECLARADAS (quitar acceso que DA acceso): unrelate(u, member, g) con deny a g#member ⇒ u recupera su allow directo (S8); purgeObject(group:g) ⇒ igual (S9); purgeSubject(g#member) borra los denies del grupo (S10)', async ({ assert }) => {
|
|
2086
|
+
const { manager } = await denyManager();
|
|
2087
|
+
const run = async (lift, label) => {
|
|
2088
|
+
const p = partition();
|
|
2089
|
+
const u = holder();
|
|
2090
|
+
const g = { type: 'group', id: docId() };
|
|
2091
|
+
const a = { type: 'document', id: docId() };
|
|
2092
|
+
await manager.relate(u, 'viewer', a, p);
|
|
2093
|
+
await manager.relate(u, 'member', g, p);
|
|
2094
|
+
await manager.deny({ object: g, relation: 'member' }, 'viewer', a, p);
|
|
2095
|
+
assert.isFalse(await manager.check(u, 'viewer', a, p), `${label}: antes, el deny del grupo bloquea`);
|
|
2096
|
+
await lift(u, g, p);
|
|
2097
|
+
assert.isTrue(await manager.check(u, 'viewer', a, p), `${label}: FILA DECLARADA — la escritura que «quita» devuelve el acceso`);
|
|
2098
|
+
};
|
|
2099
|
+
await run((u, g, p) => manager.unrelate(u, 'member', g, p), 'S8 · salir del grupo denegado');
|
|
2100
|
+
await run((_u, g, p) => manager.purgeObject(g, p), 'S9 · purgar el grupo');
|
|
2101
|
+
await run((_u, g, p) => manager.purgeSubject({ object: g, relation: 'member' }, p), 'S10 · purgar el userset del grupo');
|
|
2102
|
+
});
|
|
2103
|
+
test('D-17 · deny sobre un tipo SIN opt-in (note), en FASE 1 (workspace, deny: read), sobre group, con `*` o con un userset ≠ group#member ⇒ 422 con CERO llamadas al driver y CERO eventos; removeDeny y listDenies SÍ aceptan esos tipos declarados (D8: retirar el opt-in es reversible)', async ({ assert }) => {
|
|
2104
|
+
const events = [];
|
|
2105
|
+
const f = await denyManager({ onRelationWrite: (e) => void events.push(e) });
|
|
2106
|
+
const p = partition();
|
|
2107
|
+
const u = holder();
|
|
2108
|
+
const cases = [
|
|
2109
|
+
['tipo sin opt-in', () => f.manager.deny(u, 'viewer', { type: 'note', id: docId() }, p), 'E_AUTHZ_RELATION_NOT_DENYABLE'],
|
|
2110
|
+
["fase 1 (deny: 'read')", () => f.manager.deny(u, 'viewer', { type: 'workspace', id: docId() }, p), 'E_AUTHZ_RELATION_NOT_DENYABLE'],
|
|
2111
|
+
['group', () => f.manager.deny(u, 'member', { type: 'group', id: docId() }, p), 'E_AUTHZ_RELATION_NOT_DENYABLE'],
|
|
2112
|
+
["comodín '*'", () => f.manager.deny({ type: 'user', uuid: '*' }, 'viewer', { type: 'document', id: docId() }, p), 'E_AUTHZ_RELATION_NOT_DENYABLE'],
|
|
2113
|
+
['userset ≠ group#member', () => f.manager.deny({ object: { type: 'document', id: docId() }, relation: 'viewer' }, 'viewer', { type: 'document', id: docId() }, p), 'E_AUTHZ_RELATION_USERSET_UNSUPPORTED'],
|
|
2114
|
+
];
|
|
2115
|
+
for (const [label, run, code] of cases) {
|
|
2116
|
+
const caught = await run().then(() => null, (e) => e);
|
|
2117
|
+
assert.isNotNull(caught, `${label}: entró`);
|
|
2118
|
+
assert.equal(caught?.status, 422, `${label}: ${caught?.message}`);
|
|
2119
|
+
assert.equal(caught?.code, code, `${label}: ${caught?.message}`);
|
|
2120
|
+
}
|
|
2121
|
+
assert.equal(f.calls(), 0, 'CERO llamadas al driver');
|
|
2122
|
+
assert.lengthOf(events, 0, 'CERO eventos');
|
|
2123
|
+
for (const type of ['note', 'workspace']) {
|
|
2124
|
+
await f.manager.removeDeny(u, 'viewer', { type, id: docId() }, p);
|
|
2125
|
+
assert.deepEqual((await f.manager.listDenies('viewer', { type, id: docId() }, p)).subjects, [], `${type}: listDenies responde`);
|
|
2126
|
+
}
|
|
2127
|
+
});
|
|
2128
|
+
test('D-19 (reescrito por D3) · expiresAt en deny/removeDeny ⇒ 422 E_AUTHZ_DENY_EXPIRY_UNSUPPORTED con la razón medida (Date y null), CERO llamadas', async ({ assert }) => {
|
|
2129
|
+
const f = await denyManager();
|
|
2130
|
+
const p = partition();
|
|
2131
|
+
const a = { type: 'document', id: docId() };
|
|
2132
|
+
for (const expiresAt of [new Date(Date.now() + 60_000), null]) {
|
|
2133
|
+
for (const op of ['deny', 'removeDeny']) {
|
|
2134
|
+
const caught = await f.manager[op](holder(), 'viewer', a, p, { expiresAt }).then(() => null, (e) => e);
|
|
2135
|
+
assert.equal(caught?.status, 422, `${op}(${String(expiresAt)}): ${caught?.message}`);
|
|
2136
|
+
assert.equal(caught?.code, 'E_AUTHZ_DENY_EXPIRY_UNSUPPORTED');
|
|
2137
|
+
assert.include(String(caught?.message), 'M5');
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
assert.equal(f.calls(), 0);
|
|
2141
|
+
});
|
|
2142
|
+
test('D-20 · eventos: deny/removeDeny notifican con operation y el nombre PÚBLICO de la relación, el actor y sin expiresAt; requireActor los alcanza; assertWrite ve operation deny/removeDeny y si lanza no hay escritura ni evento', async ({ assert }) => {
|
|
2143
|
+
const events = [];
|
|
2144
|
+
const seen = [];
|
|
2145
|
+
const actor = holder();
|
|
2146
|
+
const f = await denyManager({
|
|
2147
|
+
onRelationWrite: (e) => void events.push(e),
|
|
2148
|
+
assertWrite: (ref) => {
|
|
2149
|
+
seen.push(ref.operation);
|
|
2150
|
+
if (ref.object.id === 'vetado')
|
|
2151
|
+
throw new Error('policy');
|
|
2152
|
+
},
|
|
2153
|
+
});
|
|
2154
|
+
const p = partition();
|
|
2155
|
+
const u = holder();
|
|
2156
|
+
const a = { type: 'document', id: docId() };
|
|
2157
|
+
await f.manager.deny(u, 'viewer', a, p, { actor });
|
|
2158
|
+
await f.manager.removeDeny(u, 'viewer', a, p, { actor });
|
|
2159
|
+
assert.deepEqual(events.map((e) => e.operation), ['deny', 'removeDeny']);
|
|
2160
|
+
assert.equal(events[0].relation, 'viewer', 'el nombre PÚBLICO, nunca denied_viewer');
|
|
2161
|
+
assert.deepEqual(events[0].actor, actor);
|
|
2162
|
+
assert.notProperty(events[0], 'expiresAt');
|
|
2163
|
+
assert.deepEqual(seen, ['deny', 'removeDeny']);
|
|
2164
|
+
const vetoed = await f.manager.deny(u, 'viewer', { type: 'document', id: 'vetado' }, p).then(() => null, (e) => e);
|
|
2165
|
+
assert.equal(vetoed?.message, 'policy');
|
|
2166
|
+
assert.lengthOf(events, 2, 'assertWrite que lanza: sin evento');
|
|
2167
|
+
const strict = await denyManager({ requireActor: true });
|
|
2168
|
+
const missing = await strict.manager.deny(u, 'viewer', a, p).then(() => null, (e) => e);
|
|
2169
|
+
assert.equal(missing?.code, 'E_AUTHZ_ACTOR_REQUIRED');
|
|
2170
|
+
const missing2 = await strict.manager.removeDeny(u, 'viewer', a, p).then(() => null, (e) => e);
|
|
2171
|
+
assert.equal(missing2?.code, 'E_AUTHZ_ACTOR_REQUIRED');
|
|
2172
|
+
assert.equal(strict.calls(), 0);
|
|
2173
|
+
});
|
|
2174
|
+
test('D9 · decideMany: fila 35 preguntada en viewer ⇒ deny (amplio); fila 43 ⇒ allow; nada ⇒ none; el caso Nóminas (deny en Nóminas con allow heredado de Finanzas ⇒ [none, deny, allow] recorriendo hoja → raíz); orden y duplicados por posición', async ({ assert }) => {
|
|
2175
|
+
const { manager } = await denyManager();
|
|
2176
|
+
const p = partition();
|
|
2177
|
+
const u = holder();
|
|
2178
|
+
const row35 = { type: 'document', id: docId() };
|
|
2179
|
+
const row43 = { type: 'document', id: docId() };
|
|
2180
|
+
const nothing = { type: 'document', id: docId() };
|
|
2181
|
+
await seedState(manager, u, row35, p, ['owner'], ['editor']);
|
|
2182
|
+
await seedState(manager, u, row43, p, ['owner', 'viewer'], ['editor']);
|
|
2183
|
+
assert.deepEqual(await manager.decideMany(u, 'viewer', [row35, row43, nothing, row35], p), ['deny', 'allow', 'none', 'deny']);
|
|
2184
|
+
// Nóminas (juez ronda 2 §4; auditor R2-1): la cadena la recorre el LLAMANTE.
|
|
2185
|
+
const informe = { type: 'document', id: docId() };
|
|
2186
|
+
const nominas = { type: 'document', id: docId() };
|
|
2187
|
+
const finanzas = { type: 'document', id: docId() };
|
|
2188
|
+
await manager.relate(u, 'viewer', finanzas, p);
|
|
2189
|
+
await manager.deny(u, 'viewer', nominas, p);
|
|
2190
|
+
assert.deepEqual(await manager.decideMany(u, 'viewer', [informe, nominas, finanzas], p), ['none', 'deny', 'allow'], 'el primer no-none desde la hoja es deny');
|
|
2191
|
+
assert.deepEqual(await manager.checkMany(u, 'viewer', [informe, nominas, finanzas], p), [false, false, true], 'con checkMany el mismo escenario es INDISTINGUIBLE (limitación declarada)');
|
|
2192
|
+
});
|
|
2193
|
+
test(`D9 · decideMany con las MISMAS garantías que checkMany: ${RELATIONS_CHECK_MANY_MAX} responde, ${RELATIONS_CHECK_MANY_MAX + 1} y [] ⇒ 422 E_AUTHZ_CHECK_MANY_BATCH; F-05 por ÍTEM (role_binding en la última posición) y un id inválido ⇒ 422; todo con CERO llamadas`, async ({ assert }) => {
|
|
2194
|
+
const f = await denyManager();
|
|
2195
|
+
const p = partition();
|
|
2196
|
+
const u = holder();
|
|
2197
|
+
const ids = Array.from({ length: RELATIONS_CHECK_MANY_MAX }, () => ({ type: 'document', id: docId() }));
|
|
2198
|
+
assert.lengthOf(await f.manager.decideMany(u, 'viewer', ids, p), RELATIONS_CHECK_MANY_MAX);
|
|
2199
|
+
const before = f.calls();
|
|
2200
|
+
for (const objects of [[...ids, { type: 'document', id: docId() }], []]) {
|
|
2201
|
+
const caught = await f.manager.decideMany(u, 'viewer', objects, p).then(() => null, (e) => e);
|
|
2202
|
+
assert.equal(caught?.code, 'E_AUTHZ_CHECK_MANY_BATCH');
|
|
2203
|
+
assert.include(String(caught?.message), 'decideMany');
|
|
2204
|
+
}
|
|
2205
|
+
const typed = await f.manager.decideMany(u, 'viewer', [...ids.slice(0, 20), { type: 'role_binding', id: docId() }], p).then(() => null, (e) => e);
|
|
2206
|
+
assert.equal(typed?.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN');
|
|
2207
|
+
const badId = await f.manager.decideMany(u, 'viewer', [...ids.slice(0, 3), { type: 'document', id: 'A#B' }], p).then(() => null, (e) => e);
|
|
2208
|
+
assert.equal(badId?.code, 'E_AUTHZ_INVALID_IDENTITY');
|
|
2209
|
+
assert.equal(f.calls(), before, 'CERO llamadas: el lote se juzga antes');
|
|
2210
|
+
});
|
|
2211
|
+
test('P-2 (COGNITIV) · decideMany para un holder de OTRA organización responde por la partición del OBJETO — y su gemelo en check/checkMany: el mismo holder con allow en A y deny en B sobre el mismo id', async ({ assert }) => {
|
|
2212
|
+
const { manager } = await denyManager();
|
|
2213
|
+
const orgA = partition();
|
|
2214
|
+
const orgB = partition();
|
|
2215
|
+
const guest = holder();
|
|
2216
|
+
const shared = { type: 'document', id: docId() };
|
|
2217
|
+
await manager.relate(guest, 'viewer', shared, orgA);
|
|
2218
|
+
await manager.relate(guest, 'viewer', shared, orgB);
|
|
2219
|
+
await manager.deny(guest, 'viewer', shared, orgB);
|
|
2220
|
+
assert.deepEqual(await manager.decideMany(guest, 'viewer', [shared], orgA), ['allow'], 'decideMany responde por la partición del objeto (A)');
|
|
2221
|
+
assert.deepEqual(await manager.decideMany(guest, 'viewer', [shared], orgB), ['deny'], 'y en B, por B');
|
|
2222
|
+
assert.isTrue(await manager.check(guest, 'viewer', shared, orgA), 'gemelo check en A');
|
|
2223
|
+
assert.isFalse(await manager.check(guest, 'viewer', shared, orgB), 'gemelo check en B');
|
|
2224
|
+
assert.deepEqual(await manager.checkMany(guest, 'viewer', [shared], orgA), [true], 'gemelo checkMany en A');
|
|
2225
|
+
assert.deepEqual(await manager.checkMany(guest, 'viewer', [shared], orgB), [false], 'gemelo checkMany en B');
|
|
2226
|
+
});
|
|
2227
|
+
test('D-30 · un alias de la partición jamás evade un deny: con el deny en la canónica, la ortografía sin guiones es 422 en check/checkMany/decideMany/listObjects/deny/removeDeny/listDenies (nunca true)', async ({ assert }) => {
|
|
2228
|
+
const { manager } = await denyManager();
|
|
2229
|
+
const canonical = { type: 'unit', uuid: uuidv7() };
|
|
2230
|
+
const alias = { type: 'unit', uuid: canonical.uuid.replace(/-/g, '') };
|
|
2231
|
+
const u = holder();
|
|
2232
|
+
const a = { type: 'document', id: docId() };
|
|
2233
|
+
await manager.relate(u, 'viewer', a, canonical);
|
|
2234
|
+
await manager.deny(u, 'viewer', a, canonical);
|
|
2235
|
+
const runs = [
|
|
2236
|
+
['check', () => manager.check(u, 'viewer', a, alias)],
|
|
2237
|
+
['checkMany', () => manager.checkMany(u, 'viewer', [a], alias)],
|
|
2238
|
+
['decideMany', () => manager.decideMany(u, 'viewer', [a], alias)],
|
|
2239
|
+
['listObjects', () => manager.listObjects(u, 'viewer', 'document', alias)],
|
|
2240
|
+
['deny', () => manager.deny(u, 'viewer', a, alias)],
|
|
2241
|
+
['removeDeny', () => manager.removeDeny(u, 'viewer', a, alias)],
|
|
2242
|
+
['listDenies', () => manager.listDenies('viewer', a, alias)],
|
|
2243
|
+
];
|
|
2244
|
+
for (const [op, run] of runs) {
|
|
2245
|
+
const caught = await run().then((r) => ({ answered: r }), (e) => e);
|
|
2246
|
+
assert.equal(caught?.code, 'E_AUTHZ_INVALID_IDENTITY', `${op} por el alias: ${JSON.stringify(caught?.answered ?? caught?.message)}`);
|
|
2247
|
+
}
|
|
2248
|
+
assert.isFalse(await manager.check(u, 'viewer', a, canonical), 'el deny sigue en la canónica');
|
|
2249
|
+
});
|
|
2250
|
+
test("denyPhase · (cierre 1 de D2 · ⚪ 9) el DRIVER responde la fase de SU config por tipo (document 'write', workspace 'read', note null) y es la verdad sobre deny(): 'write' ⇒ entra y check F; 'read' y null ⇒ 422 NOT_DENYABLE sin escribir; F-05 (tipo/relación no declarados, relación ausente) ⇒ 422 — la puerta del DESTINO de authz:relations:reconcile", async ({ assert }) => {
|
|
2251
|
+
const config = contractDenyRelationsConfig();
|
|
2252
|
+
const driver = await harness.makeDriver(config);
|
|
2253
|
+
assert.isFunction(driver.denyPhase, 'un driver que declara relationDenies trae denyPhase (el reconcile lo exige: motivo no-deny-phase)');
|
|
2254
|
+
const p = partition();
|
|
2255
|
+
for (const [type, expected] of Object.entries(RELATIONS_CONTRACT_DENY_TYPES)) {
|
|
2256
|
+
const phase = await driver.denyPhase({ type }, 'viewer');
|
|
2257
|
+
assert.equal(phase, expected ?? null, `denyPhase(${type}) = la fase de la config del DRIVER`);
|
|
2258
|
+
const u = holder();
|
|
2259
|
+
const object = { type, id: docId() };
|
|
2260
|
+
await driver.relate(u, 'viewer', object, p);
|
|
2261
|
+
const wrote = await driver.deny(u, 'viewer', object, p).then(() => null, (e) => e);
|
|
2262
|
+
if (phase === 'write') {
|
|
2263
|
+
assert.isNull(wrote, `${type}: 'write' ⇒ deny() entra (${wrote?.message})`);
|
|
2264
|
+
assert.isFalse(await driver.check(u, 'viewer', object, p), `${type}: el deny escrito niega`);
|
|
2265
|
+
}
|
|
2266
|
+
else {
|
|
2267
|
+
assert.equal(wrote?.status, 422, `${type}: '${phase}' ⇒ deny() 422 (${wrote?.message})`);
|
|
2268
|
+
assert.equal(wrote?.code, 'E_AUTHZ_RELATION_NOT_DENYABLE');
|
|
2269
|
+
assert.isTrue(await driver.check(u, 'viewer', object, p), `${type}: la negativa no escribió nada`);
|
|
2270
|
+
}
|
|
2271
|
+
}
|
|
2272
|
+
for (const [label, run, code] of [
|
|
2273
|
+
['tipo no declarado', () => driver.denyPhase({ type: 'role_binding' }, 'assignee'), 'E_AUTHZ_RELATION_TYPE_UNKNOWN'],
|
|
2274
|
+
['relación no declarada', () => driver.denyPhase({ type: 'document' }, 'approver'), 'E_AUTHZ_RELATION_UNKNOWN'],
|
|
2275
|
+
['relación ausente', () => driver.denyPhase({ type: 'document' }, undefined), 'E_AUTHZ_RELATION_UNKNOWN'],
|
|
2276
|
+
]) {
|
|
2277
|
+
const caught = await run().then((answer) => ({ answer }), (e) => e);
|
|
2278
|
+
assert.equal(caught?.code, code, `F-05 · ${label}: ${JSON.stringify(caught?.answer ?? caught?.message)}`);
|
|
2279
|
+
}
|
|
2280
|
+
});
|
|
2281
|
+
test('M6 · el deny TRANSPORTADO por una membresía de grupo con caducidad: viva ⇒ deny (check F, decideMany deny); caducada ⇒ el allow vuelve (check T, decideMany allow); S13: un solo now por lote en decideMany', async ({ assert }) => {
|
|
2282
|
+
const config = contractDenyRelationsConfig();
|
|
2283
|
+
const base = await harness.makeDriver(config);
|
|
2284
|
+
const u = holder();
|
|
2285
|
+
const g = { type: 'group', id: docId() };
|
|
2286
|
+
const a = { type: 'document', id: docId() };
|
|
2287
|
+
const p = partition();
|
|
2288
|
+
if (harness.capabilities.injectableClock) {
|
|
2289
|
+
const T = new Date('2031-03-03T03:03:03.303Z');
|
|
2290
|
+
const clock = fixedClock(new Date(T.getTime() - 60_000));
|
|
2291
|
+
const m = new RelationsManager(base.withClock(clock.now), config);
|
|
2292
|
+
await m.relate(u, 'viewer', a, p);
|
|
2293
|
+
await m.relate(u, 'member', g, p, { expiresAt: T });
|
|
2294
|
+
await m.deny({ object: g, relation: 'member' }, 'viewer', a, p);
|
|
2295
|
+
clock.set(new Date(T.getTime() - 1));
|
|
2296
|
+
assert.isFalse(await m.check(u, 'viewer', a, p), 'T−1 ms: la membresía viva transporta el deny');
|
|
2297
|
+
assert.deepEqual(await m.decideMany(u, 'viewer', [a], p), ['deny']);
|
|
2298
|
+
clock.set(T);
|
|
2299
|
+
assert.isTrue(await m.check(u, 'viewer', a, p), 'T: la membresía caducó y el allow vuelve');
|
|
2300
|
+
assert.deepEqual(await m.decideMany(u, 'viewer', [a], p), ['allow']);
|
|
2301
|
+
// S13 · UN solo `now` por lote también en decideMany (el patrón A-18): un reloj que AVANZA 1 ms por lectura
|
|
2302
|
+
// y tres allows que caducan en T3+1, T3+2, T3+3 ⇒ las tres `allow` y el reloj se lee UNA vez.
|
|
2303
|
+
const T3 = new Date('2031-04-04T00:00:00.000Z');
|
|
2304
|
+
const setup = new RelationsManager(base.withClock(fixedClock(T3).now), config);
|
|
2305
|
+
const batch = holder();
|
|
2306
|
+
const docs = [docId(), docId(), docId()].map((id) => ({ type: 'document', id }));
|
|
2307
|
+
for (const [i, d] of docs.entries())
|
|
2308
|
+
await setup.relate(batch, 'viewer', d, p, { expiresAt: new Date(T3.getTime() + i + 1) });
|
|
2309
|
+
let reads = 0;
|
|
2310
|
+
const ticking = new RelationsManager(base.withClock(() => new Date(T3.getTime() + reads++)), config);
|
|
2311
|
+
assert.deepEqual(await ticking.decideMany(batch, 'viewer', [...docs, ...docs], p), ['allow', 'allow', 'allow', 'allow', 'allow', 'allow']);
|
|
2312
|
+
assert.equal(reads, 1, 'S13: decideMany leyó el reloj EXACTAMENTE una vez para todo el lote');
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
2315
|
+
const m = new RelationsManager(base, config);
|
|
2316
|
+
await m.relate(u, 'viewer', a, p);
|
|
2317
|
+
await m.relate(u, 'member', g, p, { expiresAt: new Date(Date.now() + 1_500) });
|
|
2318
|
+
await m.deny({ object: g, relation: 'member' }, 'viewer', a, p);
|
|
2319
|
+
assert.isFalse(await m.check(u, 'viewer', a, p), 'viva: el deny viaja por la membresía');
|
|
2320
|
+
await sleep(1_700);
|
|
2321
|
+
assert.isTrue(await m.check(u, 'viewer', a, p), 'caducada: el allow vuelve');
|
|
2322
|
+
});
|
|
2323
|
+
},
|
|
2324
|
+
whenFalse: () => test('relationDenies:false · D-28 · deny/removeDeny/listDenies/decideMany ⇒ 500 E_AUTHZ_UNSUPPORTED nombrándolos, con CERO llamadas al driver y CERO eventos; F-05 va antes (un role_binding sigue siendo 422)', async ({ assert }) => {
|
|
2325
|
+
const p = partition();
|
|
2326
|
+
const u = holder();
|
|
2327
|
+
const a = { type: 'document', id: docId() };
|
|
2328
|
+
const runs = [
|
|
2329
|
+
['deny', () => state.manager.deny(u, 'viewer', a, p)],
|
|
2330
|
+
['removeDeny', () => state.manager.removeDeny(u, 'viewer', a, p)],
|
|
2331
|
+
['listDenies', () => state.manager.listDenies('viewer', a, p)],
|
|
2332
|
+
['decideMany', () => state.manager.decideMany(u, 'viewer', [a], p)],
|
|
2333
|
+
];
|
|
2334
|
+
for (const [op, run] of runs) {
|
|
2335
|
+
const caught = await run().then(() => null, (e) => e);
|
|
2336
|
+
assert.equal(caught?.status, 500, `${op}: ${caught?.message}`);
|
|
2337
|
+
assert.equal(caught?.code, 'E_AUTHZ_UNSUPPORTED');
|
|
2338
|
+
assert.include(String(caught?.message), op);
|
|
2339
|
+
}
|
|
2340
|
+
const typed = await state.manager.deny(u, 'assignee', { type: 'role_binding', id: docId() }, p).then(() => null, (e) => e);
|
|
2341
|
+
assert.equal(typed?.code, 'E_AUTHZ_RELATION_TYPE_UNKNOWN', 'F-05 antes que la capacidad');
|
|
2342
|
+
assert.equal(state.calls(), 0, 'CERO llamadas al driver');
|
|
2343
|
+
assert.lengthOf(state.events, 0, 'CERO eventos');
|
|
2344
|
+
// Cierre 1 de D2 · ⚪ 9 (el par de denyPhase): sin relationDenies el driver no AFIRMA una fase — o no trae
|
|
2345
|
+
// denyPhase, o responde null para todo tipo (la puerta del reconcile lo trata como no-capability igualmente).
|
|
2346
|
+
const bare = await harness.makeDriver(contractDenyRelationsConfig());
|
|
2347
|
+
if (typeof bare.denyPhase === 'function') {
|
|
2348
|
+
for (const type of Object.keys(RELATIONS_CONTRACT_DENY_TYPES)) {
|
|
2349
|
+
assert.isNull(await bare.denyPhase({ type }, 'viewer'), `relationDenies:false · denyPhase(${type}) no afirma una fase`);
|
|
2350
|
+
}
|
|
2351
|
+
}
|
|
2352
|
+
}),
|
|
2353
|
+
});
|
|
1504
2354
|
// Al cerrar: toda capacidad declarada tiene que haber registrado su cara.
|
|
1505
2355
|
group.teardown(async () => {
|
|
1506
2356
|
const uncovered = Object.keys(harness.capabilities).filter((c) => !covered.has(c));
|