@jantstack/adonis-authz 2.5.0-alpha.2 → 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 +343 -7
- package/build/commands/authz_relations_reconcile.d.ts +6 -0
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
- package/build/commands/authz_relations_reconcile.js +113 -3
- 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/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 +211 -45
- package/build/src/drivers/database_relations_driver.js.map +1 -1
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +102 -36
- 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 +74 -1
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_relations_driver.js +599 -29
- package/build/src/drivers/openfga_relations_driver.js.map +1 -1
- package/build/src/errors.d.ts +112 -0
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +130 -0
- package/build/src/errors.js.map +1 -1
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +9 -4
- 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 +115 -2
- package/build/src/relations/define_relations_config.d.ts.map +1 -1
- package/build/src/relations/define_relations_config.js +197 -3
- 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 +120 -3
- package/build/src/relations/manager.js.map +1 -1
- package/build/src/relations/reconcile.d.ts +219 -1
- package/build/src/relations/reconcile.d.ts.map +1 -1
- package/build/src/relations/reconcile.js +745 -155
- 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/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 +824 -44
- 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/types.d.ts +83 -3
- 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 +1 -1
|
@@ -32,20 +32,35 @@
|
|
|
32
32
|
* del SDK escapa (todo va por `#guard`, que clasifica a 503/timeout).
|
|
33
33
|
*/
|
|
34
34
|
import { OpenFgaClient, ConsistencyPreference, ClientWriteRequestOnMissingDeletes, ClientWriteRequestOnDuplicateWrites, } from '@openfga/sdk';
|
|
35
|
-
import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, InvalidIdentityError, PurgeIncompleteError, UnsupportedOperationError, markPartialWrite, } from '../errors.js';
|
|
35
|
+
import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, InvalidIdentityError, PurgeIncompleteError, RenewalRacedError, RenewalUnsafeError, UnsupportedOperationError, markPartialWrite, } from '../errors.js';
|
|
36
36
|
import { assertPartition, assertSubject, assertRelationId, assertExpiresAt, partitionKey } from '../identity.js';
|
|
37
37
|
import { isClock, systemClock } from '../clock.js';
|
|
38
|
-
import { resolveGrantExpiry, sameInstant, toExpiryDate } from '../expiry.js';
|
|
38
|
+
import { isActiveExpiry, resolveGrantExpiry, sameInstant, toExpiryDate } from '../expiry.js';
|
|
39
39
|
import { isRelUserset } from '../types.js';
|
|
40
40
|
import { alignBatchResults } from './openfga_driver.js';
|
|
41
|
-
import { FGA_MAX_BATCH_CHECK } from './openfga_facts.js';
|
|
41
|
+
import { FGA_MAX_BATCH_CHECK, canonicalRewrite, factsRelationRewrites } from './openfga_facts.js';
|
|
42
42
|
import { assertCheckManyObjects, distinctObjects, expandByPosition } from '../relations/check_many.js';
|
|
43
43
|
import { assertFgaObjectId, FACTS_EXPIRY_CONDITION, FACTS_GROUP_TYPE, } from './openfga_facts.js';
|
|
44
|
-
import { assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject } from '../relations/define_relations_config.js';
|
|
45
|
-
import {
|
|
44
|
+
import { assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject, assertRelationDenyable, assertDenySubject, assertDenyNoExpiry, denyPhaseOf, deniedRelationOf, storedRelationKind, hasDenyTypes, relationsDown, RELATION_DENY_PREFIX, RELATION_USERSET_RELATION, RELATION_USERSET_TYPE, } from '../relations/define_relations_config.js';
|
|
45
|
+
import { relationDeleteOrder, relationOrderClass } from '../relations/tuple_order.js';
|
|
46
|
+
import { isServerWriteLimit, isTimeoutLike, withDeadline } from '../shared/backend_guard.js';
|
|
46
47
|
export const DEFAULT_TIMEOUT_MS = 5_000;
|
|
47
|
-
/**
|
|
48
|
-
|
|
48
|
+
/**
|
|
49
|
+
* **Tope de operaciones por `Write` en FGA** — `--max-tuples-per-write` del servidor, default 100. MEDIDO en v1.19.0
|
|
50
|
+
* (`openfga-dev`, lote D · cierre 1 de D1): 100 deletes en un `Write` entran; 101 —o 50 writes + 51 deletes— es 400
|
|
51
|
+
* «The number of write operations exceeds the allowed limit of 100» y NO se aplica nada (el `Write` es transaccional).
|
|
52
|
+
* Es también la frontera de la purga atómica (`#purgeByFilters`): una purga que cabe aquí es UN `Write`, sin estado
|
|
53
|
+
* intermedio; con más membresías de grupo que esto, el residuo es una fila DECLARADA (README, «Deny on relations»).
|
|
54
|
+
* **Requisito del despliegue: `--max-tuples-per-write` ≥ 100** (README, «Limits»). Un servidor con el tope MÁS BAJO
|
|
55
|
+
* responde 400 `exceeded_entity_limit` al primer `Write` que lo pase, y ése puede no ser el primero de la purga (con
|
|
56
|
+
* tope 50 y `[3 allows][60 membresías][40 denies]` el `Write` de 3 ya se aplicó): el driver lo dice como **500
|
|
57
|
+
* `E_AUTHZ_CONFIG`** nombrando el requisito —no como un 503 que parece transitorio: repetida, la purga vuelve a pedir
|
|
58
|
+
* un `Write` de más de lo que el servidor admite y no termina nunca— y, si ya borró algo, **marcado como escritura
|
|
59
|
+
* parcial** (`indeterminate: true`, invariante 13). Cierre 2 de D1 · ⚪ N3 del auditor (la letra anterior, «503 sin
|
|
60
|
+
* haber borrado nada», era falsa con varios `Write`). Uno con el tope más alto no cambia nada (el driver sigue partiendo
|
|
61
|
+
* en 100). Sin opción para bajarlo: la cota es del servidor y el remedio es su arranque.
|
|
62
|
+
*/
|
|
63
|
+
export const RELATIONS_PURGE_WRITE_MAX = 100;
|
|
49
64
|
/** Tamaño de página de `Read` (máximo del servidor). */
|
|
50
65
|
const READ_PAGE_SIZE = 100;
|
|
51
66
|
/**
|
|
@@ -146,6 +161,13 @@ export class OpenFgaRelationsDriver {
|
|
|
146
161
|
enumerateRelations: true,
|
|
147
162
|
listObjectsTruncation: true,
|
|
148
163
|
injectableClock: true,
|
|
164
|
+
// Lote D · D1: el deny es la relación `denied_<rel>` del modelo fusionado (`X = … but not denied_X`); el corte lo
|
|
165
|
+
// evalúa el SERVIDOR en `check`/`batchCheck`/`ListObjects` (una lista cortada es un subconjunto: fail-closed).
|
|
166
|
+
relationDenies: true,
|
|
167
|
+
// Cierre 2 de D2 · 🟠 N1: `false` EXPLÍCITO — FGA no reemplaza la misma clave en un `Write` (M5): `relate` renueva
|
|
168
|
+
// con DOS (delete, write). Por eso NIEGA renovar una membresía que transporta un deny (409
|
|
169
|
+
// `E_AUTHZ_RENEWAL_UNSAFE`, cero escrituras) y marca parcial la renovación permitida cuyo 2.º `Write` falla.
|
|
170
|
+
atomicRenewal: false,
|
|
149
171
|
// L-2/L-5 (panel `{trx}`, (C)): `false` EXPLÍCITO, y no puede ser otra
|
|
150
172
|
// cosa — una tupla no entra en una transacción SQL: el store es otro
|
|
151
173
|
// servicio y no hay 2PC, así que «los dos o ninguno» con la transacción
|
|
@@ -174,6 +196,12 @@ export class OpenFgaRelationsDriver {
|
|
|
174
196
|
#listObjectsDeadlineMs;
|
|
175
197
|
#monotonic;
|
|
176
198
|
#logger;
|
|
199
|
+
/**
|
|
200
|
+
* **Las relaciones `denied_*` que declara el modelo que sirve este driver** (lote D · D-31): se lee UNA vez por
|
|
201
|
+
* proceso (`readAuthorizationModel` del `modelId` fijado —D14 lo exige con un tipo con deny—) y se comparte con
|
|
202
|
+
* las vistas `withClock`. Un fallo al leerla no se memoriza (503 y se reintenta en la siguiente).
|
|
203
|
+
*/
|
|
204
|
+
#modelMemo = {};
|
|
177
205
|
diagnostics = { unparseableTuples: 0 };
|
|
178
206
|
constructor(config, options) {
|
|
179
207
|
this.#config = config;
|
|
@@ -228,6 +256,18 @@ export class OpenFgaRelationsDriver {
|
|
|
228
256
|
}
|
|
229
257
|
this.#monotonic = monotonic ?? (() => performance.now());
|
|
230
258
|
this.#logger = options.logger ?? console;
|
|
259
|
+
// **D14** (decisión del dueño 2026-10-14; auditor R2-2): con algún tipo con opt-in del deny (en cualquier fase), el
|
|
260
|
+
// `modelId` FIJADO es obligatorio. Sin él cada `Check` evalúa contra el ÚLTIMO modelo del store, y un
|
|
261
|
+
// `openfga:provision` desde un proceso viejo (sin `denied_*`) haría que este pod concediera sobre objetos denegados
|
|
262
|
+
// sin enterarse; con él, la guarda D-31 lee ESE modelo una vez y es 500 si no declara el deny.
|
|
263
|
+
if (hasDenyTypes(config) && !options.modelId) {
|
|
264
|
+
throw new AuthorizationConfigError(`OpenFgaRelationsDriver: la config de relaciones declara deny en ${config.objectTypes
|
|
265
|
+
.filter((t) => t.deny !== undefined)
|
|
266
|
+
.map((t) => `'${t.type}' (${t.deny})`)
|
|
267
|
+
.join(', ')} y el driver no tiene modelId fijado (D14). Con deny activo cada pod tiene que evaluar contra un modelo ` +
|
|
268
|
+
`CONOCIDO que declare denied_<rel>: publica el modelo (openfga:provision), fija OPENFGA_MODEL_ID en TODA la flota ` +
|
|
269
|
+
`y despliega. Sin el pin, un modelo republicado sin denied_* haría conceder sobre objetos denegados.`);
|
|
270
|
+
}
|
|
231
271
|
this.client = new OpenFgaClient({
|
|
232
272
|
apiUrl: options.apiUrl,
|
|
233
273
|
storeId: options.storeId,
|
|
@@ -249,6 +289,8 @@ export class OpenFgaRelationsDriver {
|
|
|
249
289
|
}
|
|
250
290
|
const view = new OpenFgaRelationsDriver(this.#config, { ...this.#options, now });
|
|
251
291
|
view.client = this.client;
|
|
292
|
+
// La guarda D-31 es del PROCESO: la vista comparte la lectura del modelo.
|
|
293
|
+
view.#modelMemo = this.#modelMemo;
|
|
252
294
|
return view;
|
|
253
295
|
}
|
|
254
296
|
/** El `context` de todo `Check`/`ListObjects`: la condición `not_expired` se evalúa contra este instante (un `now` por operación). */
|
|
@@ -294,6 +336,146 @@ export class OpenFgaRelationsDriver {
|
|
|
294
336
|
throw fail(error);
|
|
295
337
|
}), this.#timeoutMs, () => new AuthorizationBackendTimeoutError('openfga-relations', operation, this.#timeoutMs));
|
|
296
338
|
}
|
|
339
|
+
/* ── D-31: el modelo FIJADO tiene que declarar el deny de los tipos con opt-in ─────────────────────────── */
|
|
340
|
+
/** Las relaciones `<tipo>#denied_<rel>` que declara el modelo que sirve este driver (memo por proceso). */
|
|
341
|
+
#modelDenied() {
|
|
342
|
+
this.#readModel();
|
|
343
|
+
return this.#modelMemo.denied;
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* **R2 del auditor del panel «óptimo» (cierre 5 de D2): la DERIVA modelo ↔ config en TODOS los tipos**, no solo en los
|
|
347
|
+
* que tienen deny: por tipo declarado en la config (y `group`), las relaciones cuya regla en el modelo FIJADO no es la
|
|
348
|
+
* que genera el paquete (`factsRelationRewrites`, por igualdad tras `canonicalRewrite`) o que el modelo no trae. La
|
|
349
|
+
* MISMA lectura del modelo (memo por proceso) que D-31.
|
|
350
|
+
*/
|
|
351
|
+
#modelDrift() {
|
|
352
|
+
this.#readModel();
|
|
353
|
+
return this.#modelMemo.drift;
|
|
354
|
+
}
|
|
355
|
+
/** UNA lectura del modelo por proceso (memo compartido con las vistas `withClock`); un fallo no se memoriza. */
|
|
356
|
+
#readModel() {
|
|
357
|
+
if (this.#modelMemo.denied)
|
|
358
|
+
return;
|
|
359
|
+
const memo = this.#modelMemo;
|
|
360
|
+
const served = this.#guard('readAuthorizationModel', async () => {
|
|
361
|
+
const response = this.#options.modelId
|
|
362
|
+
? await this.client.readAuthorizationModel()
|
|
363
|
+
: await this.client.readLatestAuthorizationModel();
|
|
364
|
+
const definitions = response?.authorization_model?.type_definitions;
|
|
365
|
+
if (!Array.isArray(definitions)) {
|
|
366
|
+
throw new Error('la respuesta de ReadAuthorizationModel no trae type_definitions; no se toma por «sin deny»');
|
|
367
|
+
}
|
|
368
|
+
// R2 (cierre 5 de D2): la deriva de TODOS los tipos de la config (y `group`) contra lo que genera el paquete.
|
|
369
|
+
// En un tipo SIN opt-in también vale la forma CON deny (`<rel> = base but not denied_<rel>`): es D8 —el opt-in
|
|
370
|
+
// retirado con el modelo aún publicado—, más restrictivo, nunca una concesión por una regla vieja.
|
|
371
|
+
const drift = new Map();
|
|
372
|
+
const expectedTypes = [
|
|
373
|
+
{ type: RELATION_USERSET_TYPE, relations: { member: { this: {} } } },
|
|
374
|
+
...this.#config.objectTypes.map((spec) => ({
|
|
375
|
+
type: spec.type,
|
|
376
|
+
relations: factsRelationRewrites(spec),
|
|
377
|
+
alternative: spec.deny === undefined ? factsRelationRewrites({ ...spec, deny: 'read' }) : undefined,
|
|
378
|
+
})),
|
|
379
|
+
];
|
|
380
|
+
for (const { type, relations: expectedRelations, alternative } of expectedTypes) {
|
|
381
|
+
const servedRelations = definitions.find((definition) => definition?.type === type)?.relations ?? {};
|
|
382
|
+
// Cierre 6 de D2 · 🟡 D5-2: la excepción D8 vale solo si el modelo trae la forma CON deny ENTERA para esa
|
|
383
|
+
// relación: `<rel>` Y `denied_<rel>` (el cierre del corte de camino). Con `denied_editor = this` (sin `or
|
|
384
|
+
// denied_viewer`) un modelo a mano concedía `editor` a quien tiene deny en `viewer`, donde `database` niega.
|
|
385
|
+
const denyFormMatches = (name) => {
|
|
386
|
+
if (!alternative)
|
|
387
|
+
return false;
|
|
388
|
+
const denied = deniedRelationOf(name);
|
|
389
|
+
return sameRewrite(servedRelations[name], alternative[name]) && sameRewrite(servedRelations[denied], alternative[denied]);
|
|
390
|
+
};
|
|
391
|
+
const differing = Object.keys(expectedRelations).filter((name) => !sameRewrite(servedRelations[name], expectedRelations[name]) && !denyFormMatches(name));
|
|
392
|
+
if (differing.length > 0)
|
|
393
|
+
drift.set(type, differing);
|
|
394
|
+
}
|
|
395
|
+
// Cierre 1 de D1 · ⚪ 7: no basta con que EXISTA `denied_<rel>`: `<rel>` tiene que RESTARLO.
|
|
396
|
+
// Cierre 2 de D1 · ⚪ N4: tampoco basta con la FORMA. En un tipo CON opt-in, `<rel>` y `denied_<rel>` tienen que
|
|
397
|
+
// ser EXACTAMENTE las reglas que genera el paquete para ese tipo (`factsRelationRewrites`, la fuente única del
|
|
398
|
+
// generador), por igualdad estructural tras `canonicalRewrite`: un modelo a mano con `denied_editor: [user]` (sin
|
|
399
|
+
// `or denied_viewer`, el cierre del corte de camino) restaba bien y CONCEDÍA `editor` a un owner con deny en
|
|
400
|
+
// `viewer`, donde `database` niega. En un tipo SIN opt-in (D8: opt-in retirado con el modelo aún publicado) no hay
|
|
401
|
+
// regla del paquete con la que comparar: cuenta la forma de ⚪ 7 (`difference` con `subtract` = `denied_<rel>`),
|
|
402
|
+
// que es lo que `decideMany` necesita para saber qué deny preguntar.
|
|
403
|
+
const out = new Set();
|
|
404
|
+
for (const definition of definitions) {
|
|
405
|
+
const relations = definition?.relations ?? {};
|
|
406
|
+
const spec = this.#config.objectTypes.find((t) => t.type === definition?.type);
|
|
407
|
+
const expected = spec && spec.deny !== undefined ? factsRelationRewrites(spec) : undefined;
|
|
408
|
+
for (const name of Object.keys(relations)) {
|
|
409
|
+
if (!name.startsWith(RELATION_DENY_PREFIX))
|
|
410
|
+
continue;
|
|
411
|
+
const relation = name.slice(RELATION_DENY_PREFIX.length);
|
|
412
|
+
const base = relations[relation];
|
|
413
|
+
const ok = expected
|
|
414
|
+
? sameRewrite(base, expected[relation]) && sameRewrite(relations[name], expected[name])
|
|
415
|
+
: base?.difference?.subtract?.computedUserset?.relation === name;
|
|
416
|
+
if (ok)
|
|
417
|
+
out.add(`${definition.type}#${name}`);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return { denied: out, drift };
|
|
421
|
+
}).catch((error) => {
|
|
422
|
+
// Un fallo (503) NO se memoriza: la siguiente lo vuelve a intentar.
|
|
423
|
+
if (memo.denied === denied) {
|
|
424
|
+
memo.denied = undefined;
|
|
425
|
+
memo.drift = undefined;
|
|
426
|
+
}
|
|
427
|
+
throw error;
|
|
428
|
+
});
|
|
429
|
+
const denied = served.then((x) => x.denied);
|
|
430
|
+
memo.denied = denied;
|
|
431
|
+
memo.drift = served.then((x) => x.drift);
|
|
432
|
+
// Las dos promesas derivan de UNA: si nadie espera una de ellas, su rechazo no puede quedar sin capturar.
|
|
433
|
+
memo.denied.catch(() => { });
|
|
434
|
+
memo.drift.catch(() => { });
|
|
435
|
+
}
|
|
436
|
+
/**
|
|
437
|
+
* **D-31 · un tipo con opt-in y un modelo FIJADO sin `denied_<rel>` ⇒ 500 al USARLO** (juez ronda 2 §2, H3 b):
|
|
438
|
+
* un pod con la config nueva y el pin a un modelo viejo (o republicado por un proceso viejo) concedería sobre un
|
|
439
|
+
* objeto denegado —la resta la hace el servidor con el modelo que el pod fija—. Antes de la primera request de
|
|
440
|
+
* cualquier operación sobre esos tipos se comprueba (una lectura del modelo por proceso) y, si falta, 500
|
|
441
|
+
* `E_AUTHZ_CONFIG` nombrando las relaciones: ruidoso en vez de conceder. Tipos sin opt-in: sin coste.
|
|
442
|
+
*/
|
|
443
|
+
async #assertModelDeny(types) {
|
|
444
|
+
const wanted = [];
|
|
445
|
+
const distinct = [...new Set(types)];
|
|
446
|
+
for (const type of distinct) {
|
|
447
|
+
if (denyPhaseOf(this.#config, type) === undefined)
|
|
448
|
+
continue;
|
|
449
|
+
const spec = this.#config.objectTypes.find((t) => t.type === type);
|
|
450
|
+
for (const relation of spec?.relations ?? [])
|
|
451
|
+
wanted.push(`${type}#${deniedRelationOf(relation.name)}`);
|
|
452
|
+
}
|
|
453
|
+
if (wanted.length > 0) {
|
|
454
|
+
const declared = await this.#modelDenied();
|
|
455
|
+
const missing = wanted.filter((name) => !declared.has(name));
|
|
456
|
+
if (missing.length > 0) {
|
|
457
|
+
throw new AuthorizationConfigError(`OpenFgaRelationsDriver: la config declara deny y el modelo que sirve este driver (modelId ` +
|
|
458
|
+
`${this.#options.modelId ?? '(último)'}) NO declara ${missing.join(', ')} —o la declara pero <rel> o denied_<rel> no son las reglas que genera el paquete (but not y el cierre del corte de camino)— (D-31). Con ese modelo el servidor no ` +
|
|
459
|
+
`resta el deny: un pod así concedería sobre objetos denegados. Republica el modelo desde la config actual ` +
|
|
460
|
+
`(openfga:provision) y fija el modelId nuevo en TODA la flota antes de desplegar.`);
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
// R2 del auditor del panel «óptimo» (cierre 5 de D2): la MISMA comparación en los tipos SIN deny. Un pod con una config
|
|
464
|
+
// que ESTRECHA una regla (`viewer` deja de incluir `editor`) y el pin a un modelo viejo concedía por la regla vieja,
|
|
465
|
+
// en silencio, donde `database` con la misma config niega. Misma lectura del modelo, ya memorizada.
|
|
466
|
+
// Cierre 6 de D2 · ⚪ (D5-2): `group` se compara en TODA operación, no solo en las suyas — un `group#member` del
|
|
467
|
+
// modelo que no es `this` cambia quién es miembro y, con ello, qué concede un `check` de `document`.
|
|
468
|
+
const drift = await this.#modelDrift();
|
|
469
|
+
const compared = [...new Set([...distinct, RELATION_USERSET_TYPE])];
|
|
470
|
+
const drifted = compared.flatMap((type) => (drift.get(type) ?? []).map((relation) => `${type}#${relation}`));
|
|
471
|
+
if (drifted.length > 0) {
|
|
472
|
+
throw new AuthorizationConfigError(`OpenFgaRelationsDriver: el modelo que sirve este driver (modelId ${this.#options.modelId ?? '(último)'}) declara ` +
|
|
473
|
+
`${drifted.join(', ')} con una regla DISTINTA de la que genera la config de este proceso (includes / deny), o no la ` +
|
|
474
|
+
`declara (D-31 en todos los tipos, R2). Con ese modelo el servidor decide por la regla del modelo: un pod así ` +
|
|
475
|
+
`concedería lo que la config —y database con la misma config— niega. Republica el modelo desde la config actual ` +
|
|
476
|
+
`(openfga:provision) y fija el modelId nuevo en TODA la flota antes de desplegar.`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
297
479
|
/* ── Composición y parseo de ids (⚪5: partición por string en el id) ────── */
|
|
298
480
|
/** `<type>:<scopeKey(partition)>|<objectUuid>`. */
|
|
299
481
|
#objectId(objectType, partitionKey, objectUuid) {
|
|
@@ -380,8 +562,12 @@ export class OpenFgaRelationsDriver {
|
|
|
380
562
|
* - presente con la caducidad que toca ⇒ no-op (idempotente, invariante 6);
|
|
381
563
|
* - presente con OTRA ⇒ delete + write en DOS llamadas (FGA no admite
|
|
382
564
|
* delete+write de la misma clave en un `Write`, ni reescribir su
|
|
383
|
-
* condición): entre ambas
|
|
384
|
-
*
|
|
565
|
+
* condición): entre ambas la tupla NO está. Para un allow eso solo
|
|
566
|
+
* quita (fail-closed, la misma ventana que el `grant` de roles); para una
|
|
567
|
+
* membresía `group:*#member` que TRANSPORTA un deny, CONCEDERÍA — desde el
|
|
568
|
+
* cierre 2 de D2 (🟠 N1) esa renovación es 409 `E_AUTHZ_RENEWAL_UNSAFE`
|
|
569
|
+
* con cero escrituras (`#carriedDeny`), y si el 2.º `Write` de una
|
|
570
|
+
* renovación permitida falla el error va marcado PARCIAL (`markPartialWrite`).
|
|
385
571
|
*/
|
|
386
572
|
/**
|
|
387
573
|
* **`{ transaction }` se rechaza también AQUÍ** (L-5, defensa en
|
|
@@ -413,6 +599,7 @@ export class OpenFgaRelationsDriver {
|
|
|
413
599
|
const user = this.#subjectUser(subject, partitionKey);
|
|
414
600
|
const key = { user, relation, object: objectId };
|
|
415
601
|
const now = this.#now();
|
|
602
|
+
await this.#assertModelDeny([object.type]);
|
|
416
603
|
const current = await this.#readTuple('relate', key);
|
|
417
604
|
const previous = current ? current.validUntil : null;
|
|
418
605
|
const expiresAt = resolveGrantExpiry(previous, options?.expiresAt, now);
|
|
@@ -422,10 +609,99 @@ export class OpenFgaRelationsDriver {
|
|
|
422
609
|
? { ...key, condition: { name: FACTS_EXPIRY_CONDITION, context: { valid_until: expiresAt.toISOString() } } }
|
|
423
610
|
: key;
|
|
424
611
|
if (current) {
|
|
612
|
+
// Cierre 2 de D2 · 🟠 N1 (decisión del dueño 2026-10-15 (9)): una membresía que TRANSPORTA un deny no se renueva
|
|
613
|
+
// con dos `Write` — entre el delete y el write el sujeto quedaría sin el deny y concedería. 409, CERO escrituras.
|
|
614
|
+
// Cierre 3 de D2 · ⚪ C2-4: una membresía YA VENCIDA no conecta nada (el deny ya no alcanza al sujeto por ella), así
|
|
615
|
+
// que no hay ventana que proteger: se reescribe sin comprobar portadora (hasta el cierre 3 era un 409 espurio).
|
|
616
|
+
const guarded = object.type === RELATION_USERSET_TYPE && isActiveExpiry(previous, now);
|
|
617
|
+
const membership = `${objectId}#${relation}@${user}`;
|
|
618
|
+
if (guarded) {
|
|
619
|
+
const carried = await this.#carriedDeny(objectId, partitionKey, now);
|
|
620
|
+
if (carried !== null)
|
|
621
|
+
throw RenewalUnsafeError.carrier(membership, carried);
|
|
622
|
+
}
|
|
425
623
|
await this.#guard('relate', () => this.client.write({ deletes: [key] }, IGNORE_MISSING_DELETES));
|
|
624
|
+
// Cierre 2 de D2 · 🟠 N1 (b): el delete YA entró — si el write falla, la escritura es PARCIAL (invariante 13: el
|
|
625
|
+
// manager notifica `indeterminate: true`); hasta el cierre era un 503 «sin escribir» con la tupla borrada.
|
|
626
|
+
try {
|
|
627
|
+
await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES));
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
// Cierre 3 de D2 · 🟡 C2-3: la membresía quedó BORRADA. Si entre la comprobación y el delete entró un deny en el
|
|
631
|
+
// grupo (TOCTOU), ese deny ya no alcanza al sujeto —y no lo alcanzará hasta que la membresía se reescriba—: el
|
|
632
|
+
// error parcial lo NOMBRA (`carriedDeny`) con la receta. Mejor esfuerzo: si la re-lectura también falla, se dice.
|
|
633
|
+
if (guarded && error !== null && typeof error === 'object') {
|
|
634
|
+
annotateRacedDeny(error, membership, await this.#recheckCarried(objectId, partitionKey, now));
|
|
635
|
+
}
|
|
636
|
+
throw error !== null && typeof error === 'object' ? markPartialWrite(error) : error;
|
|
637
|
+
}
|
|
638
|
+
// Cierre 3 de D2 · 🟡 C2-3: la RE-COMPROBACIÓN. Un deny que entró entre `#carriedDeny` y los dos `Write` no alcanzó
|
|
639
|
+
// al sujeto durante la ventana; la renovación se aplicó (el estado final es correcto) y el error lo dice, marcado
|
|
640
|
+
// parcial (el manager notifica `indeterminate: true`). La ventana NO se cierra (FGA no tiene una primitiva para
|
|
641
|
+
// serializar `deny` contra la renovación): deja de ser muda. README «Every other renewal stays two `Write`s».
|
|
642
|
+
if (guarded) {
|
|
643
|
+
const recheck = await this.#recheckCarried(objectId, partitionKey, now);
|
|
644
|
+
if (recheck.deny !== null)
|
|
645
|
+
throw markPartialWrite(RenewalRacedError.raced(membership, recheck.deny));
|
|
646
|
+
if (recheck.error !== undefined) {
|
|
647
|
+
const error = recheck.error;
|
|
648
|
+
throw error !== null && typeof error === 'object'
|
|
649
|
+
? markPartialWrite(annotateRacedDeny(error, membership, recheck, true))
|
|
650
|
+
: error;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return;
|
|
426
654
|
}
|
|
427
655
|
await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES));
|
|
428
656
|
}
|
|
657
|
+
/**
|
|
658
|
+
* **¿Transporta el grupo un deny vivo?** (cierre 2 de D2 · 🟠 N1): el `#member` de `group` —o el de un grupo que lo
|
|
659
|
+
* contiene, transitivamente (`group:G#member@group:H#member`: los miembros de H heredan los denies de G)— es sujeto de
|
|
660
|
+
* una tupla `denied_*` de la partición, en algún tipo con opt-in del deny. Devuelve la primera que encuentra (para la
|
|
661
|
+
* letra) o `null`. Coste: por cada grupo del cierre hacia arriba, un `Read` por `user` + tipo (el filtro que FGA admite:
|
|
662
|
+
* `user` y `object` tipo) por cada tipo con deny, y uno de sus padres. Solo corre en una RENOVACIÓN de membresía.
|
|
663
|
+
*/
|
|
664
|
+
async #carriedDeny(groupObjectId, partitionKey, now) {
|
|
665
|
+
const denyTypes = this.#config.objectTypes.filter((t) => denyPhaseOf(this.#config, t.type) !== undefined).map((t) => t.type);
|
|
666
|
+
if (denyTypes.length === 0)
|
|
667
|
+
return null;
|
|
668
|
+
const seen = new Set([groupObjectId]);
|
|
669
|
+
const queue = [groupObjectId];
|
|
670
|
+
while (queue.length > 0) {
|
|
671
|
+
const userset = `${queue.shift()}#${RELATION_USERSET_RELATION}`;
|
|
672
|
+
for (const type of denyTypes) {
|
|
673
|
+
for (const t of await this.#readAll('relate', { user: userset, object: `${type}:` })) {
|
|
674
|
+
// Cierre 6 de D2 · 🟡 D5-1: un deny VENCIDO no transporta nada (la MISMA regla que C2-4 para la arista y que el
|
|
675
|
+
// plan para toda tupla del destino: vencida = ausente). Contarlo hacía de la pasada REAL un 409 que el dry-run
|
|
676
|
+
// no anunciaba, y las particiones siguientes no migraban.
|
|
677
|
+
if (t.relation.startsWith(RELATION_DENY_PREFIX) &&
|
|
678
|
+
t.object.startsWith(`${type}:${partitionKey}|`) &&
|
|
679
|
+
isActiveExpiry(t.validUntil ?? null, now)) {
|
|
680
|
+
return `${t.object}#${t.relation}@${t.user}`;
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
for (const parent of await this.#readAll('relate', { user: userset, relation: RELATION_USERSET_RELATION, object: `${RELATION_USERSET_TYPE}:` })) {
|
|
685
|
+
// Cierre 3 de D2 · ⚪ C2-4: una arista de anidación VENCIDA no transporta nada (no conecta en FGA).
|
|
686
|
+
if (!isActiveExpiry(parent.validUntil ?? null, now))
|
|
687
|
+
continue;
|
|
688
|
+
if (!seen.has(parent.object)) {
|
|
689
|
+
seen.add(parent.object);
|
|
690
|
+
queue.push(parent.object);
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return null;
|
|
695
|
+
}
|
|
696
|
+
/** `#carriedDeny` tras los dos `Write` (🟡 C2-3): nunca lanza; un fallo de la re-lectura viaja en `error`. */
|
|
697
|
+
async #recheckCarried(groupObjectId, partitionKey, now) {
|
|
698
|
+
try {
|
|
699
|
+
return { deny: await this.#carriedDeny(groupObjectId, partitionKey, now) };
|
|
700
|
+
}
|
|
701
|
+
catch (error) {
|
|
702
|
+
return { deny: null, error };
|
|
703
|
+
}
|
|
704
|
+
}
|
|
429
705
|
/**
|
|
430
706
|
* La tupla EXACTA de una clave (un `Read` por clave completa), con su caducidad si la lleva.
|
|
431
707
|
* ⚪ N5 (cierre 2 del lote B): un 200 sin `tuples` NO es «no existe» — hasta el cierre `?? []` lo
|
|
@@ -451,10 +727,67 @@ export class OpenFgaRelationsDriver {
|
|
|
451
727
|
const partitionKey = this.#keyOf(partition);
|
|
452
728
|
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
453
729
|
const user = this.#subjectUser(subject, partitionKey);
|
|
730
|
+
await this.#assertModelDeny([object.type]);
|
|
454
731
|
await this.#guard('unrelate', () =>
|
|
455
732
|
// Re-unrelate no-op seguro: borrar una tupla ausente se ignora.
|
|
456
733
|
this.client.write({ deletes: [{ user, relation, object: objectId }] }, IGNORE_MISSING_DELETES));
|
|
457
734
|
}
|
|
735
|
+
/**
|
|
736
|
+
* **`deny` = UN `Write` de la tupla `denied_<rel>`** (lote D · D1): sin caducidad (D3), así que no hay renovación
|
|
737
|
+
* ni ventana —un re-deny idéntico lo ignora el servidor (`on_duplicate: ignore`)—; una tupla de deny escrita a mano
|
|
738
|
+
* CON condición devuelve 409 y, desde el cierre 1 de D2 (🟡 4), sale como 500 `E_AUTHZ_CONFIG` con la receta
|
|
739
|
+
* (`AuthorizationConfigError.conditionalDeny`; hasta entonces era un 503 que se repetía para siempre). Antes del `Write`: la
|
|
740
|
+
* puerta de `{ transaction }` (L-5), F-05 sobre el nombre PÚBLICO, la puerta del opt-in (fase `'write'`), sin
|
|
741
|
+
* caducidad, la partición, `*`, el sujeto y la guarda del modelo (D-31). CERO llamadas si algo de eso falla.
|
|
742
|
+
*/
|
|
743
|
+
async deny(subject, relation, object, partition, options) {
|
|
744
|
+
this.#rejectTransaction('deny', options);
|
|
745
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
746
|
+
assertRelationDenyable(this.#config, object, relation);
|
|
747
|
+
assertDenyNoExpiry(options);
|
|
748
|
+
assertPartition(partition);
|
|
749
|
+
assertDenySubject(subject);
|
|
750
|
+
const partitionKey = this.#keyOf(partition);
|
|
751
|
+
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
752
|
+
const user = this.#subjectUser(subject, partitionKey);
|
|
753
|
+
await this.#assertModelDeny([object.type]);
|
|
754
|
+
const key = { user, relation: deniedRelationOf(relation), object: objectId };
|
|
755
|
+
try {
|
|
756
|
+
await this.#guard('deny', () => this.client.write({ writes: [key] }, IGNORE_DUPLICATE_WRITES));
|
|
757
|
+
}
|
|
758
|
+
catch (error) {
|
|
759
|
+
// Cierre 1 de D2 · 🟡 4: el 409 de una clave que ya existe CON condición (medido en v1.19.0: «already exists with
|
|
760
|
+
// a different condition») no es una caída. Se confirma RELEYENDO la tupla (no por el texto del mensaje): si está y
|
|
761
|
+
// lleva condición, 500 con la receta; si no, el 503 de siempre.
|
|
762
|
+
if (error?.status === 503 && error?.cause?.statusCode === 409) {
|
|
763
|
+
const found = await this.#guard('deny', () => this.client.read(key, { consistency: this.#consistency }));
|
|
764
|
+
if (Array.isArray(found?.tuples) && found.tuples.some((t) => t?.key?.condition)) {
|
|
765
|
+
throw AuthorizationConfigError.conditionalDeny('deny', `${key.object}#${key.relation}@${key.user}`);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
throw error;
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* **`removeDeny` = UN delete de `denied_<rel>`** (no-op si no está). Sobre CUALQUIER tipo declarado (D8/R2-4 b):
|
|
773
|
+
* `Write` con delete no valida la relación contra el modelo (medido en v1.19.0), así que un deny vivo de un tipo
|
|
774
|
+
* que retiró el opt-in se puede borrar. Ojo: quitar un deny CONCEDE (filas declaradas S8–S10).
|
|
775
|
+
* **La guarda D-31 SÍ corre aquí para los tipos CON opt-in** (letra corregida en el cierre 1 de D1, ⚪ 5 del auditor:
|
|
776
|
+
* la decisión 4 del informe decía que no): con el opt-in vivo y un modelo fijado sin `denied_*` ⇒ 500 antes del
|
|
777
|
+
* delete. Con el opt-in RETIRADO no hay guarda (el tipo no está en la config con `deny`) y el delete borra de verdad
|
|
778
|
+
* (medido por el auditor: `listDenies` de 1 a 0 y `check` vuelve a `true` con el modelo viejo) — retirar es reversible.
|
|
779
|
+
*/
|
|
780
|
+
async removeDeny(subject, relation, object, partition, options) {
|
|
781
|
+
this.#rejectTransaction('removeDeny', options);
|
|
782
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
783
|
+
assertDenyNoExpiry(options);
|
|
784
|
+
assertPartition(partition);
|
|
785
|
+
const partitionKey = this.#keyOf(partition);
|
|
786
|
+
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
787
|
+
const user = this.#subjectUser(subject, partitionKey);
|
|
788
|
+
await this.#assertModelDeny([object.type]);
|
|
789
|
+
await this.#guard('removeDeny', () => this.client.write({ deletes: [{ user, relation: deniedRelationOf(relation), object: objectId }] }, IGNORE_MISSING_DELETES));
|
|
790
|
+
}
|
|
458
791
|
/* ── Lecturas ───────────────────────────────────────────────────────────── */
|
|
459
792
|
/**
|
|
460
793
|
* **F-05 en las LECTURAS y las PURGAS del driver** (2.4.0-alpha.3, cierre
|
|
@@ -477,6 +810,7 @@ export class OpenFgaRelationsDriver {
|
|
|
477
810
|
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
478
811
|
const user = this.#subjectUser(subject, partitionKey);
|
|
479
812
|
const context = this.#checkContext(this.#now());
|
|
813
|
+
await this.#assertModelDeny([object.type]);
|
|
480
814
|
const response = await this.#guard('check', () => this.client.check({ user, relation, object: objectId, context }, { consistency: this.#consistency }));
|
|
481
815
|
return response.allowed === true;
|
|
482
816
|
}
|
|
@@ -500,6 +834,7 @@ export class OpenFgaRelationsDriver {
|
|
|
500
834
|
const user = this.#subjectUser(subject, partitionKey);
|
|
501
835
|
const { distinct, index } = distinctObjects(objects);
|
|
502
836
|
const context = this.#checkContext(this.#now());
|
|
837
|
+
await this.#assertModelDeny(distinct.map((object) => object.type));
|
|
503
838
|
const checks = distinct.map((object, i) => ({
|
|
504
839
|
user,
|
|
505
840
|
relation,
|
|
@@ -519,6 +854,115 @@ export class OpenFgaRelationsDriver {
|
|
|
519
854
|
}
|
|
520
855
|
return expandByPosition(index, answers);
|
|
521
856
|
}
|
|
857
|
+
/**
|
|
858
|
+
* **`decideMany` = un `batchCheck` con `R` y los `denied_<m>` de las relaciones FUENTE de `down*(R)`** (lote D ·
|
|
859
|
+
* D9, juez ronda 2 §4): por el cierre ascendente del modelo (`denied_X = this ∨ denied_P`), `denied_<fuente>`
|
|
860
|
+
* cubre todo `up*(down*(R))` — en el retículo del contrato, 2 ítems por objeto (`R` y `denied_owner`). Mismo
|
|
861
|
+
* `current_time` en TODOS los ítems (un `now` por lote), alineado por `correlationId` (`alignBatchResults`, la
|
|
862
|
+
* misma función que `checkMany` y roles), troceado por `FGA_MAX_BATCH_CHECK`, y un `error` en UN ítem ⇒ 503 para
|
|
863
|
+
* todo el lote (nunca `none`: S15). La decisión sale de `decideFrom`, la regla ÚNICA (la misma que `database` y el
|
|
864
|
+
* doble). En un tipo sin opt-in se pregunta el deny solo si el modelo que sirve el driver lo declara (opt-in
|
|
865
|
+
* retirado con el modelo aún publicado, D8); si no, cada posición es `allow` o `none` (no hay deny posible).
|
|
866
|
+
*/
|
|
867
|
+
async decideMany(subject, relation, objects, partition) {
|
|
868
|
+
assertCheckManyObjects(objects, 'decideMany');
|
|
869
|
+
for (const object of objects)
|
|
870
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
871
|
+
assertPartition(partition);
|
|
872
|
+
this.#assertPinnedForDecideMany();
|
|
873
|
+
const partitionKey = this.#keyOf(partition);
|
|
874
|
+
const user = this.#subjectUser(subject, partitionKey);
|
|
875
|
+
const { distinct, index } = distinctObjects(objects);
|
|
876
|
+
const context = this.#checkContext(this.#now());
|
|
877
|
+
await this.#assertModelDeny(distinct.map((object) => object.type));
|
|
878
|
+
const declared = await this.#modelDenied();
|
|
879
|
+
const checks = [];
|
|
880
|
+
const plan = [];
|
|
881
|
+
distinct.forEach((object, i) => {
|
|
882
|
+
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
883
|
+
const down = relationsDown(this.#config, object.type, relation);
|
|
884
|
+
const type = this.#config.objectTypes.find((t) => t.type === object.type);
|
|
885
|
+
const sources = down.filter((name) => {
|
|
886
|
+
const includes = type?.relations.find((r) => r.name === name)?.includes ?? [];
|
|
887
|
+
return includes.length === 0 && declared.has(`${object.type}#${deniedRelationOf(name)}`);
|
|
888
|
+
});
|
|
889
|
+
plan.push({ object, sources });
|
|
890
|
+
// `correlationId` del servidor: `^[\w\d-]{1,36}$` (sin `:`): `a<i>` para R, `d<i>-<k>` para la fuente k.
|
|
891
|
+
checks.push({ user, relation, object: objectId, context, correlationId: `a${i}` });
|
|
892
|
+
sources.forEach((source, k) => {
|
|
893
|
+
checks.push({ user, relation: deniedRelationOf(source), object: objectId, context, correlationId: `d${i}-${k}` });
|
|
894
|
+
});
|
|
895
|
+
});
|
|
896
|
+
const allowed = new Map();
|
|
897
|
+
for (let start = 0; start < checks.length; start += FGA_MAX_BATCH_CHECK) {
|
|
898
|
+
const chunk = checks.slice(start, start + FGA_MAX_BATCH_CHECK);
|
|
899
|
+
const response = await this.#guard('decideMany', () => this.client.batchCheck({ checks: chunk }, { consistency: this.#consistency }));
|
|
900
|
+
const results = alignBatchResults('openfga-relations', 'decideMany', chunk, response.result);
|
|
901
|
+
results.forEach((r, i) => allowed.set(chunk[i].correlationId, r.allowed === true));
|
|
902
|
+
}
|
|
903
|
+
// D9, por posición: `allow` si el `Check` de R concede (el servidor ya aplicó el corte); si no, `deny` si alguno
|
|
904
|
+
// de los `denied_<fuente>` alcanza al sujeto (su cierre cubre up*(down*(R))); si no, `none`. Es `decideFrom` con el
|
|
905
|
+
// corte ya evaluado en el servidor (los dos drivers y el doble responden la misma tabla: casos D9 de la COMÚN).
|
|
906
|
+
const answers = plan.map(({ sources }, i) => {
|
|
907
|
+
if (allowed.get(`a${i}`))
|
|
908
|
+
return 'allow';
|
|
909
|
+
return sources.some((_, k) => allowed.get(`d${i}-${k}`)) ? 'deny' : 'none';
|
|
910
|
+
});
|
|
911
|
+
return index.map((at) => answers[at]);
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* **`decideMany` exige `modelId` FIJADO, con o sin tipos con deny** (lote D · cierre 1 de D1, 🟡 3 del auditor; D14
|
|
915
|
+
* extendido a la primitiva de cadena). `decideMany` decide qué `denied_*` preguntar con el memo del modelo
|
|
916
|
+
* (`#modelDenied`), y `check` lo resta el SERVIDOR con el modelo que evalúa: sin pin, el memo es el «último» del
|
|
917
|
+
* momento de la primera lectura y el servidor evalúa con el último de AHORA. Republicado un modelo con deny (flota con
|
|
918
|
+
* dos configs, reactivación del opt-in), `check` daba `false` por el deny y `decideMany` respondía `none` —la cadena
|
|
919
|
+
* del llamante seguía subiendo y concedía por el padre: fail-open en la primitiva—. Con el pin, memo y `Check` son
|
|
920
|
+
* del MISMO modelo. 500 `E_AUTHZ_CONFIG` con cero llamadas, ANTES de leer nada.
|
|
921
|
+
*/
|
|
922
|
+
#assertPinnedForDecideMany() {
|
|
923
|
+
if (this.#options.modelId)
|
|
924
|
+
return;
|
|
925
|
+
throw new AuthorizationConfigError(`OpenFgaRelationsDriver.decideMany: exige modelId FIJADO (D14, también sin tipos con deny). decideMany decide qué ` +
|
|
926
|
+
`denied_<rel> preguntar con el modelo que leyó una vez, y sin pin el servidor evalúa check con el ÚLTIMO modelo del ` +
|
|
927
|
+
`store: tras republicar un modelo con deny respondería 'none' donde check responde false, y la cadena del llamante ` +
|
|
928
|
+
`concedería por el padre. Receta: provision (openfga:provision) → fijar modelId (OPENFGA_MODEL_ID) en TODA la flota ` +
|
|
929
|
+
`→ desplegar. No se ha llamado al servidor.`);
|
|
930
|
+
}
|
|
931
|
+
/**
|
|
932
|
+
* **`denyPhase` — ¿admite este driver escrituras de deny en el tipo?** (lote D · D2, R2-5). La fase del opt-in EN
|
|
933
|
+
* LA CONFIG DE ESTE DRIVER y, con opt-in, el modelo FIJADO comprobado con la MISMA guarda D-31 que `deny` (500 si
|
|
934
|
+
* no declara `denied_<rel>`). F-05 estricto del par, antes de nada (422, cero llamadas). La llama
|
|
935
|
+
* `reconcileRelations` en la fase de plan cuando el origen trae denies: un destino `openfga` sin `'write'` en el
|
|
936
|
+
* tipo, o con un pin a un modelo sin el deny, NIEGA la pasada antes de la primera escritura (jamás `skipped`).
|
|
937
|
+
*/
|
|
938
|
+
async denyPhase(object, relation) {
|
|
939
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
940
|
+
const phase = denyPhaseOf(this.#config, object.type);
|
|
941
|
+
if (phase === undefined)
|
|
942
|
+
return null;
|
|
943
|
+
await this.#assertModelDeny([object.type]);
|
|
944
|
+
return phase;
|
|
945
|
+
}
|
|
946
|
+
/** `listDenies` = `Read` por `(object, denied_<rel>)`: los denies DIRECTOS (hechos). Cualquier tipo declarado (D8). */
|
|
947
|
+
async listDenies(relation, object, partition, _page) {
|
|
948
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
949
|
+
assertPartition(partition);
|
|
950
|
+
const partitionKey = this.#keyOf(partition);
|
|
951
|
+
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
952
|
+
const now = this.#now();
|
|
953
|
+
const tuples = await this.#readAll('listDenies', { object: objectId, relation: deniedRelationOf(relation) });
|
|
954
|
+
const subjects = [];
|
|
955
|
+
for (const t of tuples) {
|
|
956
|
+
if (t.validUntil && t.validUntil.getTime() <= now.getTime())
|
|
957
|
+
continue;
|
|
958
|
+
const subject = this.#userToSubject(t.user);
|
|
959
|
+
if (subject)
|
|
960
|
+
subjects.push(subject);
|
|
961
|
+
else
|
|
962
|
+
this.#warnUnparseable('listDenies', t.user);
|
|
963
|
+
}
|
|
964
|
+
return { subjects };
|
|
965
|
+
}
|
|
522
966
|
/**
|
|
523
967
|
* **El envoltorio ÚNICO de `ListObjects`** (plan 2.5 · lote B · B4; linaje L8,
|
|
524
968
|
* 5.ª iteración ⇒ M8: propiedad universal «toda enumeración que se puede
|
|
@@ -568,6 +1012,7 @@ export class OpenFgaRelationsDriver {
|
|
|
568
1012
|
const partitionKey = this.#keyOf(partition);
|
|
569
1013
|
const user = this.#subjectUser(subject, partitionKey);
|
|
570
1014
|
const context = this.#checkContext(this.#now());
|
|
1015
|
+
await this.#assertModelDeny([objectType]);
|
|
571
1016
|
const { raw, truncated } = await this.#listObjectsPage({ user, relation, type: objectType, context });
|
|
572
1017
|
const objects = [];
|
|
573
1018
|
for (const full of raw) {
|
|
@@ -713,7 +1158,8 @@ export class OpenFgaRelationsDriver {
|
|
|
713
1158
|
}
|
|
714
1159
|
tuples.push({
|
|
715
1160
|
subject,
|
|
716
|
-
relation:
|
|
1161
|
+
// Lote D · D2: `denied_<rel>` ⇒ `{ relation: <rel>, kind: 'deny' }` (`storedRelationKind`, punto de paso único).
|
|
1162
|
+
...storedRelationKind(key.relation),
|
|
717
1163
|
object: { type: parsed.type, id: parsed.objectUuid },
|
|
718
1164
|
partition,
|
|
719
1165
|
expiresAt: key.validUntil,
|
|
@@ -738,7 +1184,7 @@ export class OpenFgaRelationsDriver {
|
|
|
738
1184
|
const objectId = this.#objectId(object.type, this.#keyOf(partition), object.id);
|
|
739
1185
|
const progress = { deleted: 0 };
|
|
740
1186
|
try {
|
|
741
|
-
await this.#
|
|
1187
|
+
await this.#purgeByFilters('purgeObject', [[{ object: objectId }, `objeto ${objectId}`]], progress);
|
|
742
1188
|
}
|
|
743
1189
|
catch (error) {
|
|
744
1190
|
throw this.#partialIfDeleted(error, progress);
|
|
@@ -763,9 +1209,7 @@ export class OpenFgaRelationsDriver {
|
|
|
763
1209
|
const types = [FACTS_GROUP_TYPE, ...this.#config.objectTypes.map((t) => t.type)];
|
|
764
1210
|
const progress = { deleted: 0 };
|
|
765
1211
|
try {
|
|
766
|
-
|
|
767
|
-
await this.#purgeByFilter('purgeSubject', { user, object: `${type}:` }, `sujeto ${user} en '${type}'`, progress);
|
|
768
|
-
}
|
|
1212
|
+
await this.#purgeByFilters('purgeSubject', types.map((type) => [{ user, object: `${type}:` }, `sujeto ${user} en '${type}'`]), progress);
|
|
769
1213
|
}
|
|
770
1214
|
catch (error) {
|
|
771
1215
|
throw this.#partialIfDeleted(error, progress);
|
|
@@ -775,7 +1219,7 @@ export class OpenFgaRelationsDriver {
|
|
|
775
1219
|
* **Una purga que muere a MEDIAS es indeterminada** (invariante 13,
|
|
776
1220
|
* 2.4.0-alpha.3, cierre del 🟡 6 del auditor). La purga NO es un `Write`:
|
|
777
1221
|
* son N `Read`+`deleteTuples` (ortografías × tipos, y lotes de
|
|
778
|
-
* `
|
|
1222
|
+
* `RELATIONS_PURGE_WRITE_MAX`), y un 503 que no es timeout —o el 500
|
|
779
1223
|
* `E_AUTHZ_PURGE_INCOMPLETE` de la demostración de cero— puede llegar
|
|
780
1224
|
* después de haber borrado parte. Si YA se borró al menos una tupla, el
|
|
781
1225
|
* error se marca (`markPartialWrite`, `src/errors.ts`: una propiedad, la
|
|
@@ -790,22 +1234,60 @@ export class OpenFgaRelationsDriver {
|
|
|
790
1234
|
return markPartialWrite(error);
|
|
791
1235
|
return error;
|
|
792
1236
|
}
|
|
793
|
-
/**
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1237
|
+
/**
|
|
1238
|
+
* **Lee TODOS los filtros, borra en un `Write` si cabe —y si no, en ORDEN GLOBAL: allows → membresías → denies— y
|
|
1239
|
+
* RE-LEE cada filtro para demostrar cero (o lanza)** (lote D · D-16/D-26/S3; cierre 1 de D1, 🟠 1 del auditor; el
|
|
1240
|
+
* orden S6 de roles, invariante 6). La propiedad: **una purga cortada a medias no concede nada que no concedieran el
|
|
1241
|
+
* estado inicial o el final** —con la única excepción DECLARADA de abajo—.
|
|
1242
|
+
*
|
|
1243
|
+
* - **Cabe en `RELATIONS_PURGE_WRITE_MAX` (100) ⇒ UN solo `Write`**: FGA lo aplica de forma transaccional (medido:
|
|
1244
|
+
* 101 es 400 y no se aplica nada), así que NO hay estado intermedio que cortar.
|
|
1245
|
+
* - **No cabe ⇒ tres clases, cada una en sus propios `Write`, en el orden de BORRADO del punto de paso ÚNICO
|
|
1246
|
+
* `relationDeleteOrder`** (`src/relations/tuple_order.ts`; cierre 1 de D2 · 🟠 1: el mismo que el reconcile): (1) los allows sobre objetos que no son
|
|
1247
|
+
* `group` —quitar un allow solo resta (D-12, monotonía)—; (2) las MEMBRESÍAS `group:*#member@<sujeto>` —una membresía
|
|
1248
|
+
* TRANSPORTA allows y denies a la vez: borrarla antes que un allow directo deja ese allow sin el deny que lo cortaba
|
|
1249
|
+
* (🟠 1: con el orden «solo prefijo» de alpha.3-dev, `group:g#member@U` caía en el lote 1 y `doc:V#viewer@U` en el 2;
|
|
1250
|
+
* cortada entre los dos, U CONCEDÍA sobre V; la gemela `purgeSubject(g#member)` con la arista anidada
|
|
1251
|
+
* `group:h#member@group:g#member`)—, en UN `Write` PROPIO si caben (ordenadas por objeto, las portadoras de deny al
|
|
1252
|
+
* final: el reparto no depende del `Read`); (3) los `denied_*`, al final —quedan denies de MÁS, nunca de menos—.
|
|
1253
|
+
* - **Residuo DECLARADO**: con MÁS de 100 membresías del sujeto no caben en un `Write`, y una que transporta un deny
|
|
1254
|
+
* puede caer en un lote anterior a otra que transporta un allow (fila con caso en `relations_openfga.spec`, frase en
|
|
1255
|
+
* el README). No se niega la purga antes de borrar (invariante 11: una purga no espera; un 500 dejaría al sujeto de
|
|
1256
|
+
* baja con TODO su acceso): sale marcada como parcial (`indeterminate: true`) y se repite.
|
|
1257
|
+
*
|
|
1258
|
+
* `purgeObject` pasa por aquí igual: sobre un objeto que no es `group` no hay membresías (allows → denies); sobre
|
|
1259
|
+
* `group:g` todo son membresías de g, y cada miembro queda dentro o fuera de g —su estado inicial o el final—.
|
|
1260
|
+
* `progress.deleted` cuenta lo que YA se borró (un lote que falla no borró nada) para la marca de escritura parcial
|
|
1261
|
+
* (invariante 13).
|
|
1262
|
+
*/
|
|
1263
|
+
async #purgeByFilters(operation, filters, progress) {
|
|
1264
|
+
const read = [];
|
|
1265
|
+
for (const [filter, what] of filters)
|
|
1266
|
+
read.push({ filter, what, keys: await this.#readAll(operation, filter) });
|
|
1267
|
+
for (const batch of purgeWrites(read.flatMap((r) => r.keys))) {
|
|
1268
|
+
try {
|
|
1269
|
+
await this.#guard(operation, () => this.client.deleteTuples(batch.map((k) => ({ user: k.user, relation: k.relation, object: k.object })), IGNORE_MISSING_DELETES));
|
|
1270
|
+
}
|
|
1271
|
+
catch (error) {
|
|
1272
|
+
// Cierre 2 de D1 · ⚪ N3: el 400 del tope de operaciones por `Write` del SERVIDOR no es una caída (503
|
|
1273
|
+
// reintentable): es un despliegue con `--max-tuples-per-write` < `RELATIONS_PURGE_WRITE_MAX`. 500 con la letra;
|
|
1274
|
+
// la marca de escritura parcial la pone `#partialIfDeleted` si ya se aplicó un `Write` anterior.
|
|
1275
|
+
if (isServerWriteLimit(error))
|
|
1276
|
+
throw purgeWriteLimitError(operation, batch.length, progress.deleted, error);
|
|
1277
|
+
throw error;
|
|
1278
|
+
}
|
|
799
1279
|
progress.deleted += batch.length;
|
|
800
1280
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1281
|
+
// Demostración de cero: re-lee tras borrar (invariante 11), solo los filtros que tenían algo.
|
|
1282
|
+
for (const { filter, what, keys } of read) {
|
|
1283
|
+
if (keys.length === 0)
|
|
1284
|
+
continue;
|
|
1285
|
+
const residue = await this.#readAll(operation, filter);
|
|
1286
|
+
if (residue.length > 0) {
|
|
1287
|
+
throw new PurgeIncompleteError(`${operation}: quedan ${residue.length} tuplas de ${what} tras el borrado ` +
|
|
1288
|
+
`(${residue.slice(0, 3).map((k) => `${k.user}#${k.relation}@${k.object}`).join('; ')}). ` +
|
|
1289
|
+
`No se confirma la purga.`);
|
|
1290
|
+
}
|
|
809
1291
|
}
|
|
810
1292
|
}
|
|
811
1293
|
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
|
@@ -882,4 +1364,92 @@ export class OpenFgaRelationsDriver {
|
|
|
882
1364
|
// de modo que un 400 de validación de FGA o el cuerpo de un 502 que mencionara
|
|
883
1365
|
// la palabra salía como 503 `E_AUTHZ_BACKEND_TIMEOUT` con `indeterminate: true`
|
|
884
1366
|
// sin haber borrado nada (medido por el auditor: `alpha3-cierre-parcial.ts` B).
|
|
1367
|
+
/** Igualdad ESTRUCTURAL de dos reglas de reescritura (D-31, cierre 2 de D1 · ⚪ N4); una ausente nunca es igual. */
|
|
1368
|
+
function sameRewrite(served, generated) {
|
|
1369
|
+
if (served === undefined || generated === undefined)
|
|
1370
|
+
return false;
|
|
1371
|
+
return JSON.stringify(canonicalRewrite(served)) === JSON.stringify(canonicalRewrite(generated));
|
|
1372
|
+
}
|
|
1373
|
+
/** La LETRA del 500 de N3 (fijada por `relations_openfga.spec`, «⚪ N3 · …»). */
|
|
1374
|
+
function purgeWriteLimitError(operation, size, deleted, cause) {
|
|
1375
|
+
const sdk = cause?.cause?.apiErrorMessage;
|
|
1376
|
+
return new AuthorizationConfigError(`OpenFgaRelationsDriver: ${operation}: el servidor OpenFGA rechazó un Write de ${size} tuplas por su tope de ` +
|
|
1377
|
+
`operaciones por Write (exceeded_entity_limit${typeof sdk === 'string' ? `: «${sdk}»` : ''}). El driver parte las ` +
|
|
1378
|
+
`purgas en RELATIONS_PURGE_WRITE_MAX = ${RELATIONS_PURGE_WRITE_MAX} y exige --max-tuples-per-write ≥ ` +
|
|
1379
|
+
`${RELATIONS_PURGE_WRITE_MAX} en el servidor (OPENFGA_MAX_TUPLES_PER_WRITE; el default es ${RELATIONS_PURGE_WRITE_MAX}). ` +
|
|
1380
|
+
`No es transitorio: repetida, la purga vuelve a fallar igual. ` +
|
|
1381
|
+
(deleted > 0
|
|
1382
|
+
? `Ya se habían borrado ${deleted} tuplas en Write anteriores: la purga quedó A MEDIAS (escritura parcial).`
|
|
1383
|
+
: 'No se borró nada.'), { cause });
|
|
1384
|
+
}
|
|
1385
|
+
/**
|
|
1386
|
+
* **Los `Write` de una purga, en orden** (lote D · cierre 1 de D1, 🟠 1; ver `#purgeByFilters`): uno solo si todo cabe
|
|
1387
|
+
* en `RELATIONS_PURGE_WRITE_MAX`; si no, en el orden de BORRADO del punto de paso ÚNICO `relationDeleteOrder`
|
|
1388
|
+
* (`src/relations/tuple_order.ts`, cierre 1 de D2 · 🟠 1: el MISMO que usa el reconcile) — allows sobre objetos que no
|
|
1389
|
+
* son `group`, luego las membresías `group:*#member` (las portadoras de deny al final, ordenadas por objeto y sujeto) y
|
|
1390
|
+
* al final los `denied_*` —, con CADA CLASE en sus PROPIOS `Write` (con ≤ 100 membresías, uno). Pura: la tabla de verdad
|
|
1391
|
+
* del caso la juzga contra el servidor, y la PROPIEDAD la juzga sin servidor la guardia de superficie
|
|
1392
|
+
* (`GS · P · openfga.purge.order`, cierre 2 de D1 · ⚪ N2), con los mutantes de orden en el corredor publicado
|
|
1393
|
+
* (`npm run check:guard-mutants`). Exportada solo para eso: no sale por `/openfga`.
|
|
1394
|
+
*/
|
|
1395
|
+
export function purgeWrites(keys) {
|
|
1396
|
+
if (keys.length === 0)
|
|
1397
|
+
return [];
|
|
1398
|
+
if (keys.length <= RELATIONS_PURGE_WRITE_MAX)
|
|
1399
|
+
return [[...keys]];
|
|
1400
|
+
const view = (k) => ({ deny: k.relation.startsWith(RELATION_DENY_PREFIX), object: k.object, user: k.user });
|
|
1401
|
+
const ordered = relationDeleteOrder(keys, view);
|
|
1402
|
+
const chunks = (list) => {
|
|
1403
|
+
const out = [];
|
|
1404
|
+
for (let i = 0; i < list.length; i += RELATIONS_PURGE_WRITE_MAX)
|
|
1405
|
+
out.push(list.slice(i, i + RELATIONS_PURGE_WRITE_MAX));
|
|
1406
|
+
return out;
|
|
1407
|
+
};
|
|
1408
|
+
// Cada CLASE en sus propios `Write`: una membresía nunca comparte lote con el último allow ni con el primer deny.
|
|
1409
|
+
const runs = [];
|
|
1410
|
+
let previous;
|
|
1411
|
+
for (const k of ordered) {
|
|
1412
|
+
const cls = relationOrderClass(view(k));
|
|
1413
|
+
if (cls !== previous)
|
|
1414
|
+
runs.push([]);
|
|
1415
|
+
runs[runs.length - 1].push(k);
|
|
1416
|
+
previous = cls;
|
|
1417
|
+
}
|
|
1418
|
+
return runs.flatMap(chunks);
|
|
1419
|
+
}
|
|
1420
|
+
/**
|
|
1421
|
+
* **El error de una renovación en dos `Write` NOMBRA el deny que entró en la ventana** (cierre 3 de D2 · 🟡 C2-3). Sobre
|
|
1422
|
+
* el error que se propaga (su clase y su `code` no cambian): `carriedDeny` = el deny que la re-comprobación encontró, y la
|
|
1423
|
+
* letra con la receta; si la re-comprobación también falló, lo dice (`carriedDenyUnknown: true`). `applied` = la
|
|
1424
|
+
* renovación SÍ se aplicó y lo que falló es solo la re-comprobación. Un error congelado se propaga tal cual.
|
|
1425
|
+
*/
|
|
1426
|
+
function annotateRacedDeny(error, membership, recheck, applied = false) {
|
|
1427
|
+
let note = null;
|
|
1428
|
+
const target = error;
|
|
1429
|
+
try {
|
|
1430
|
+
if (recheck.deny !== null) {
|
|
1431
|
+
target.carriedDeny = recheck.deny;
|
|
1432
|
+
note =
|
|
1433
|
+
` — Cierre 3 de D2 (C2-3): la membresía ${membership} quedó BORRADA y un deny concurrente (${recheck.deny}) que ` +
|
|
1434
|
+
'entró en el grupo durante la renovación ya NO alcanza al sujeto por ella, y no lo alcanzará hasta que la ' +
|
|
1435
|
+
'membresía se reescriba. Receta: vuelve a escribirla (relate: ya no existe, así que es UN Write) o retira y ' +
|
|
1436
|
+
'reescribe ese deny después.';
|
|
1437
|
+
}
|
|
1438
|
+
else if (recheck.error !== undefined) {
|
|
1439
|
+
target.carriedDenyUnknown = true;
|
|
1440
|
+
note = applied
|
|
1441
|
+
? ` — Cierre 3 de D2 (C2-3): la renovación de ${membership} SE APLICÓ, pero la re-comprobación de un deny ` +
|
|
1442
|
+
'concurrente en su grupo falló: no se sabe si un deny entró en la ventana entre los dos Write. Receta: lista los ' +
|
|
1443
|
+
'denies del grupo (listDenies) y revisa las decisiones de ese sujeto en esa ventana.'
|
|
1444
|
+
: ` — Cierre 3 de D2 (C2-3): la membresía ${membership} quedó BORRADA y la re-comprobación de un deny concurrente ` +
|
|
1445
|
+
'en su grupo también falló. Receta: vuelve a escribir la membresía (relate) y lista los denies del grupo.';
|
|
1446
|
+
}
|
|
1447
|
+
if (note !== null && typeof target.message === 'string')
|
|
1448
|
+
target.message += note;
|
|
1449
|
+
}
|
|
1450
|
+
catch {
|
|
1451
|
+
// Un error congelado no admite la nota: se propaga el original (la clase y el `code` no cambian).
|
|
1452
|
+
}
|
|
1453
|
+
return error;
|
|
1454
|
+
}
|
|
885
1455
|
//# sourceMappingURL=openfga_relations_driver.js.map
|