@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
|
@@ -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 } 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
|
/**
|
|
@@ -65,6 +80,40 @@ const MAX_READ_PAGES = 10_000;
|
|
|
65
80
|
* truncado FALSO (el servidor devolvió todo); por encima, se perdería el corte.
|
|
66
81
|
*/
|
|
67
82
|
const DEFAULT_LIST_OBJECTS_MAX = 1_000;
|
|
83
|
+
/**
|
|
84
|
+
* **B4 (plan 2.5 · lote B): el corte EFECTIVO de `ListObjects` del servidor**.
|
|
85
|
+
* Pasado, el servidor devuelve lo que llevaba resuelto con 200 y SIN señal
|
|
86
|
+
* (analista M4, medido): la segunda forma —además del tope— de una lista
|
|
87
|
+
* parcial muda. El driver mide cada llamada con un reloj MONÓTONO y, si duró
|
|
88
|
+
* ≥ `LIST_OBJECTS_DEADLINE_RATIO` × el corte configurado, señala
|
|
89
|
+
* `truncated: true` (la MISMA señal que el tope). **El corte efectivo es el
|
|
90
|
+
* MENOR de dos** (cierre 2 del lote B · 🟠 N1, medido por el auditor contra
|
|
91
|
+
* OpenFGA v1.19.0): `--listObjects-deadline` (3 s por defecto) si ≠ 0 y, si es
|
|
92
|
+
* 0, `--request-timeout` (3 s por defecto) — `list_objects.go` solo pone su
|
|
93
|
+
* propio timeout cuando el deadline ≠ 0 y, si no, hereda el ctx del
|
|
94
|
+
* interceptor de `--request-timeout`, que corta igual con un 200 mudo (con
|
|
95
|
+
* `--request-timeout=30ms`: `200 objects 0 of 40000`). «Sin deadline» no
|
|
96
|
+
* existe: el servidor siempre corta en alguno. **El corte efectivo es el MENOR
|
|
97
|
+
* de `--listObjects-deadline` (si ≠ 0), `--request-timeout` y, por HTTP,
|
|
98
|
+
* `--http-upstream-timeout` (5 s por defecto en el gateway)** (⚪ N10, cierre 3
|
|
99
|
+
* del lote B: el tercer corte, no medido para `ListObjects`, también cuenta);
|
|
100
|
+
* declara ESE menor en `listObjectsDeadlineMs` — y nunca por encima del
|
|
101
|
+
* `timeoutMs` del cliente: la señal tiene que poder saltar antes de que el
|
|
102
|
+
* cliente corte (500 al construir si no).
|
|
103
|
+
*/
|
|
104
|
+
export const DEFAULT_LIST_OBJECTS_DEADLINE_MS = 3_000;
|
|
105
|
+
export const LIST_OBJECTS_DEADLINE_RATIO = 0.9;
|
|
106
|
+
/**
|
|
107
|
+
* **SOLO TESTS** (⚪ 9 del cierre 1 del lote B): la clave —un símbolo— bajo la que
|
|
108
|
+
* un test inyecta la fuente del reloj MONÓTONO en ms con el que `#listObjectsPage`
|
|
109
|
+
* mide cada `ListObjects` (default `performance.now`). Fuera del tipo publicado
|
|
110
|
+
* `OpenFgaRelationsDriverOptions` y NO re-exportada por `/openfga`: un
|
|
111
|
+
* `monotonic: () => 0` en producción apagaría la señal del deadline, y una opción
|
|
112
|
+
* con ese poder no viaja en la superficie pública. No es el reloj de pared
|
|
113
|
+
* (`now`, que decide la caducidad): una ventana se mide con un reloj que no
|
|
114
|
+
* salta (2E · H3), y así el caso del deadline corre sin `sleep`.
|
|
115
|
+
*/
|
|
116
|
+
export const MONOTONIC_CLOCK = Symbol('OpenFgaRelationsDriver.monotonic');
|
|
68
117
|
/**
|
|
69
118
|
* Ignora duplicados en el `Write` de una tupla que NO estaba (idempotencia de
|
|
70
119
|
* dos `relate` concurrentes idénticos). Cambiar la caducidad de una que SÍ
|
|
@@ -98,7 +147,9 @@ export class OpenFgaRelationsDriver {
|
|
|
98
147
|
* y devuelve SOLO las tuplas de relación de la partición (tipo declarado o
|
|
99
148
|
* `group`), descartando las de `facts` (scope/role/role_binding).
|
|
100
149
|
* - `listObjectsTruncation: true`: `ListObjects` trunca al tope del servidor
|
|
101
|
-
* y este driver lo SEÑALA (medido contra el `:8103` de tope 3)
|
|
150
|
+
* y este driver lo SEÑALA (medido contra el `:8103` de tope 3) — y desde
|
|
151
|
+
* B4 (plan 2.5) también cuando la llamada roza el DEADLINE del servidor
|
|
152
|
+
* (≥ 0,9 × `listObjectsDeadlineMs`), la otra forma de lista parcial muda.
|
|
102
153
|
* - `injectableClock: true` (R-15): `withClock(now)` fija el reloj que viaja
|
|
103
154
|
* como `current_time` y filtra `listSubjects`, como el driver de roles.
|
|
104
155
|
*/
|
|
@@ -110,6 +161,13 @@ export class OpenFgaRelationsDriver {
|
|
|
110
161
|
enumerateRelations: true,
|
|
111
162
|
listObjectsTruncation: true,
|
|
112
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,
|
|
113
171
|
// L-2/L-5 (panel `{trx}`, (C)): `false` EXPLÍCITO, y no puede ser otra
|
|
114
172
|
// cosa — una tupla no entra en una transacción SQL: el store es otro
|
|
115
173
|
// servicio y no hay 2PC, así que «los dos o ninguno» con la transacción
|
|
@@ -135,7 +193,15 @@ export class OpenFgaRelationsDriver {
|
|
|
135
193
|
#timeoutMs;
|
|
136
194
|
#consistency;
|
|
137
195
|
#listObjectsMax;
|
|
196
|
+
#listObjectsDeadlineMs;
|
|
197
|
+
#monotonic;
|
|
138
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 = {};
|
|
139
205
|
diagnostics = { unparseableTuples: 0 };
|
|
140
206
|
constructor(config, options) {
|
|
141
207
|
this.#config = config;
|
|
@@ -149,7 +215,59 @@ export class OpenFgaRelationsDriver {
|
|
|
149
215
|
? ConsistencyPreference.MinimizeLatency
|
|
150
216
|
: ConsistencyPreference.HigherConsistency;
|
|
151
217
|
this.#listObjectsMax = options.listObjectsMaxResults ?? DEFAULT_LIST_OBJECTS_MAX;
|
|
218
|
+
const deadline = options.listObjectsDeadlineMs ?? DEFAULT_LIST_OBJECTS_DEADLINE_MS;
|
|
219
|
+
// 🟠 N1 (cierre 2 del lote B): `Infinity` se RETIRA. Con `--listObjects-deadline=0`
|
|
220
|
+
// el `--request-timeout` del servidor sigue cortando con un 200 mudo
|
|
221
|
+
// (medido, v1.19.0), así que «sin deadline» era una receta para la lista
|
|
222
|
+
// parcial muda que B4 impide. Solo un entero > 0: el corte efectivo.
|
|
223
|
+
if (!(Number.isInteger(deadline) && deadline > 0)) {
|
|
224
|
+
throw new AuthorizationConfigError(`OpenFgaRelationsDriver: listObjectsDeadlineMs debe ser un entero > 0 en ms: el corte EFECTIVO de ListObjects ` +
|
|
225
|
+
`de tu servidor, que es --listObjects-deadline si no es 0 y, si es 0, --request-timeout (3 s por defecto; ` +
|
|
226
|
+
`medido en OpenFGA v1.19.0: con el deadline a 0 el request-timeout corta igual con un 200 mudo). El servidor ` +
|
|
227
|
+
`siempre corta en alguno de los dos; declara el menor. No hay «sin deadline» (Infinity ya no se admite) ` +
|
|
228
|
+
`(llegó ${String(options.listObjectsDeadlineMs)}); default ${DEFAULT_LIST_OBJECTS_DEADLINE_MS}.`);
|
|
229
|
+
}
|
|
230
|
+
// ⚪ N10 (cierre 3 del lote B): la señal tiene que poder SALTAR antes de que
|
|
231
|
+
// el cliente corte. Un deadline declarado por encima del `timeoutMs` del
|
|
232
|
+
// cliente (5 s por defecto) nunca alcanza su umbral 0,9 × D —el cliente
|
|
233
|
+
// aborta con 503 antes— y, si el servidor corta ANTES que D, la lista muda
|
|
234
|
+
// pasa como completa (`Number.MAX_SAFE_INTEGER` era «sin deadline» con otro
|
|
235
|
+
// nombre y pasaba `Number.isInteger`). Guarda por construcción, sin tope
|
|
236
|
+
// arbitrario: D ≤ timeoutMs del cliente, o 500.
|
|
237
|
+
if (deadline > this.#timeoutMs) {
|
|
238
|
+
throw new AuthorizationConfigError(`OpenFgaRelationsDriver: listObjectsDeadlineMs (${deadline}) no puede ser mayor que el timeoutMs del cliente ` +
|
|
239
|
+
`(${this.#timeoutMs}): la señal tiene que poder saltar antes de que el cliente corte. Con un deadline por encima ` +
|
|
240
|
+
`del timeout el umbral 0,9 × listObjectsDeadlineMs nunca se alcanza (el cliente aborta con 503 antes) y, si el ` +
|
|
241
|
+
`servidor corta antes, la lista parcial muda pasa como completa. Declara el corte EFECTIVO del servidor —el MENOR ` +
|
|
242
|
+
`de --listObjects-deadline (si ≠ 0), --request-timeout y, por HTTP, --http-upstream-timeout— y un timeoutMs ≥ a él.`);
|
|
243
|
+
}
|
|
244
|
+
this.#listObjectsDeadlineMs = deadline;
|
|
245
|
+
// ⚪ 9 (cierre 1 del lote B): el reloj monótono inyectable SOLO entra por el
|
|
246
|
+
// símbolo (no está en el tipo publicado); la clave de texto `monotonic` de
|
|
247
|
+
// alpha.2 ya no gobierna nada y se rechaza (L5: una opción inerte no cae en
|
|
248
|
+
// silencio en el default).
|
|
249
|
+
if (options.monotonic !== undefined) {
|
|
250
|
+
throw new AuthorizationConfigError(`OpenFgaRelationsDriver: 'monotonic' no es una opción pública (era SOLO TESTS); el reloj monótono se inyecta ` +
|
|
251
|
+
`por el símbolo MONOTONIC_CLOCK del módulo del driver, que no se exporta por '/openfga'.`);
|
|
252
|
+
}
|
|
253
|
+
const monotonic = options[MONOTONIC_CLOCK];
|
|
254
|
+
if (monotonic !== undefined && typeof monotonic !== 'function') {
|
|
255
|
+
throw new AuthorizationConfigError(`OpenFgaRelationsDriver: [MONOTONIC_CLOCK] debe ser una función () => number (llegó ${typeof monotonic})`);
|
|
256
|
+
}
|
|
257
|
+
this.#monotonic = monotonic ?? (() => performance.now());
|
|
152
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
|
+
}
|
|
153
271
|
this.client = new OpenFgaClient({
|
|
154
272
|
apiUrl: options.apiUrl,
|
|
155
273
|
storeId: options.storeId,
|
|
@@ -171,6 +289,8 @@ export class OpenFgaRelationsDriver {
|
|
|
171
289
|
}
|
|
172
290
|
const view = new OpenFgaRelationsDriver(this.#config, { ...this.#options, now });
|
|
173
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;
|
|
174
294
|
return view;
|
|
175
295
|
}
|
|
176
296
|
/** El `context` de todo `Check`/`ListObjects`: la condición `not_expired` se evalúa contra este instante (un `now` por operación). */
|
|
@@ -216,6 +336,146 @@ export class OpenFgaRelationsDriver {
|
|
|
216
336
|
throw fail(error);
|
|
217
337
|
}), this.#timeoutMs, () => new AuthorizationBackendTimeoutError('openfga-relations', operation, this.#timeoutMs));
|
|
218
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
|
+
}
|
|
219
479
|
/* ── Composición y parseo de ids (⚪5: partición por string en el id) ────── */
|
|
220
480
|
/** `<type>:<scopeKey(partition)>|<objectUuid>`. */
|
|
221
481
|
#objectId(objectType, partitionKey, objectUuid) {
|
|
@@ -238,8 +498,9 @@ export class OpenFgaRelationsDriver {
|
|
|
238
498
|
if (isRelUserset(subject)) {
|
|
239
499
|
// alpha.3 · F-05 también en el USERSET del sujeto (la misma función que
|
|
240
500
|
// el manager): `role_binding:<key>|<R>#role` como `user` de un `Read`/
|
|
241
|
-
// `Check`/`Write` no se compone nunca.
|
|
242
|
-
|
|
501
|
+
// `Check`/`Write` no se compone nunca. B3 (lote B): y solo `group#member`
|
|
502
|
+
// — otro userset era un 400 del servidor (503 mal clasificado).
|
|
503
|
+
assertUsersetSubject(this.#config, subject);
|
|
243
504
|
const object = this.#objectId(subject.object.type, partitionKey, subject.object.id);
|
|
244
505
|
return `${object}#${subject.relation}`;
|
|
245
506
|
}
|
|
@@ -301,8 +562,12 @@ export class OpenFgaRelationsDriver {
|
|
|
301
562
|
* - presente con la caducidad que toca ⇒ no-op (idempotente, invariante 6);
|
|
302
563
|
* - presente con OTRA ⇒ delete + write en DOS llamadas (FGA no admite
|
|
303
564
|
* delete+write de la misma clave en un `Write`, ni reescribir su
|
|
304
|
-
* condición): entre ambas
|
|
305
|
-
*
|
|
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`).
|
|
306
571
|
*/
|
|
307
572
|
/**
|
|
308
573
|
* **`{ transaction }` se rechaza también AQUÍ** (L-5, defensa en
|
|
@@ -334,6 +599,7 @@ export class OpenFgaRelationsDriver {
|
|
|
334
599
|
const user = this.#subjectUser(subject, partitionKey);
|
|
335
600
|
const key = { user, relation, object: objectId };
|
|
336
601
|
const now = this.#now();
|
|
602
|
+
await this.#assertModelDeny([object.type]);
|
|
337
603
|
const current = await this.#readTuple('relate', key);
|
|
338
604
|
const previous = current ? current.validUntil : null;
|
|
339
605
|
const expiresAt = resolveGrantExpiry(previous, options?.expiresAt, now);
|
|
@@ -343,14 +609,111 @@ export class OpenFgaRelationsDriver {
|
|
|
343
609
|
? { ...key, condition: { name: FACTS_EXPIRY_CONDITION, context: { valid_until: expiresAt.toISOString() } } }
|
|
344
610
|
: key;
|
|
345
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
|
+
}
|
|
346
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;
|
|
347
654
|
}
|
|
348
655
|
await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES));
|
|
349
656
|
}
|
|
350
|
-
/**
|
|
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
|
+
}
|
|
705
|
+
/**
|
|
706
|
+
* La tupla EXACTA de una clave (un `Read` por clave completa), con su caducidad si la lleva.
|
|
707
|
+
* ⚪ N5 (cierre 2 del lote B): un 200 sin `tuples` NO es «no existe» — hasta el cierre `?? []` lo
|
|
708
|
+
* vendía así y `relate` escribía a ciegas (una caducidad vigente que no se ve no se preserva).
|
|
709
|
+
* Es 503, como `#readAll`: exhaustivo-o-ruidoso también en la lectura de UNA clave.
|
|
710
|
+
*/
|
|
351
711
|
async #readTuple(operation, key) {
|
|
352
712
|
const response = await this.#guard(operation, () => this.client.read(key, { consistency: this.#consistency }));
|
|
353
|
-
|
|
713
|
+
if (!Array.isArray(response?.tuples)) {
|
|
714
|
+
throw new AuthorizationBackendError('openfga-relations', operation, new Error(`la respuesta del Read de la clave no trae 'tuples'; no se toma por «no existe»`));
|
|
715
|
+
}
|
|
716
|
+
const found = response.tuples.map((t) => t.key).find((k) => k?.user === key.user && k?.relation === key.relation && k?.object === key.object);
|
|
354
717
|
if (!found)
|
|
355
718
|
return null;
|
|
356
719
|
return { ...key, validUntil: toExpiryDate(found.condition?.context?.valid_until) };
|
|
@@ -364,10 +727,67 @@ export class OpenFgaRelationsDriver {
|
|
|
364
727
|
const partitionKey = this.#keyOf(partition);
|
|
365
728
|
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
366
729
|
const user = this.#subjectUser(subject, partitionKey);
|
|
730
|
+
await this.#assertModelDeny([object.type]);
|
|
367
731
|
await this.#guard('unrelate', () =>
|
|
368
732
|
// Re-unrelate no-op seguro: borrar una tupla ausente se ignora.
|
|
369
733
|
this.client.write({ deletes: [{ user, relation, object: objectId }] }, IGNORE_MISSING_DELETES));
|
|
370
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
|
+
}
|
|
371
791
|
/* ── Lecturas ───────────────────────────────────────────────────────────── */
|
|
372
792
|
/**
|
|
373
793
|
* **F-05 en las LECTURAS y las PURGAS del driver** (2.4.0-alpha.3, cierre
|
|
@@ -390,6 +810,7 @@ export class OpenFgaRelationsDriver {
|
|
|
390
810
|
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
391
811
|
const user = this.#subjectUser(subject, partitionKey);
|
|
392
812
|
const context = this.#checkContext(this.#now());
|
|
813
|
+
await this.#assertModelDeny([object.type]);
|
|
393
814
|
const response = await this.#guard('check', () => this.client.check({ user, relation, object: objectId, context }, { consistency: this.#consistency }));
|
|
394
815
|
return response.allowed === true;
|
|
395
816
|
}
|
|
@@ -413,6 +834,7 @@ export class OpenFgaRelationsDriver {
|
|
|
413
834
|
const user = this.#subjectUser(subject, partitionKey);
|
|
414
835
|
const { distinct, index } = distinctObjects(objects);
|
|
415
836
|
const context = this.#checkContext(this.#now());
|
|
837
|
+
await this.#assertModelDeny(distinct.map((object) => object.type));
|
|
416
838
|
const checks = distinct.map((object, i) => ({
|
|
417
839
|
user,
|
|
418
840
|
relation,
|
|
@@ -432,16 +854,157 @@ export class OpenFgaRelationsDriver {
|
|
|
432
854
|
}
|
|
433
855
|
return expandByPosition(index, answers);
|
|
434
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
|
+
}
|
|
966
|
+
/**
|
|
967
|
+
* **El envoltorio ÚNICO de `ListObjects`** (plan 2.5 · lote B · B4; linaje L8,
|
|
968
|
+
* 5.ª iteración ⇒ M8: propiedad universal «toda enumeración que se puede
|
|
969
|
+
* cortar devuelve `truncated` o lanza»). `ListObjects` corta de DOS formas,
|
|
970
|
+
* las dos con 200 y sin `continuation_token` ni bandera: (1) al tope del
|
|
971
|
+
* servidor (`listObjectsMaxResults`): `>= tope` resultados CRUDOS ⇒ no se
|
|
972
|
+
* puede prometer la lista completa; (2) al DEADLINE del servidor
|
|
973
|
+
* (`--listObjects-deadline`, 3 s por defecto): pasado, devuelve lo que
|
|
974
|
+
* llevaba resuelto (analista M4, medido). La (2) se detecta midiendo la
|
|
975
|
+
* llamada con el reloj MONÓTONO (`monotonic`, `performance.now`): si duró
|
|
976
|
+
* ≥ `LIST_OBJECTS_DEADLINE_RATIO` × `listObjectsDeadlineMs`, `truncated`. Es
|
|
977
|
+
* una señal conservadora a propósito (una llamada lenta pero completa sale
|
|
978
|
+
* como truncada; una cortada JAMÁS como completa). Todo `ListObjects` del
|
|
979
|
+
* driver pasa por aquí — la guardia de superficie (columna `truncation`) lo
|
|
980
|
+
* sondea con las dos causas y un mutante por cada una. Las enumeraciones por
|
|
981
|
+
* `Read` (`listSubjects`, `enumerateRelations`, las purgas) no se cortan:
|
|
982
|
+
* `#readAll` agota el token o lanza 503 («exhaustivo o ruidoso»).
|
|
983
|
+
*/
|
|
984
|
+
async #listObjectsPage(request) {
|
|
985
|
+
const started = this.#monotonic();
|
|
986
|
+
const response = await this.#guard('listObjects', () => this.client.listObjects(request, { consistency: this.#consistency }));
|
|
987
|
+
const elapsedMs = this.#monotonic() - started;
|
|
988
|
+
// 🟡 4 (cierre 1 del lote B): un 200 sin `objects` (un proxy, una caché, un
|
|
989
|
+
// cuerpo vacío) NO es una lista completa vacía: es una enumeración que no
|
|
990
|
+
// se puede prometer ⇒ 503, como `#readAll` con una respuesta sin `tuples`.
|
|
991
|
+
// Hasta el cierre `response.objects ?? []` la vendía como `{ objects: [] }`
|
|
992
|
+
// sin `truncated` — la lista «completa» muda que M8 prohíbe.
|
|
993
|
+
if (!Array.isArray(response?.objects)) {
|
|
994
|
+
throw new AuthorizationBackendError('openfga-relations', 'listObjects', new Error(`la respuesta de ListObjects no trae 'objects'; no se toma por vacía (sería una lista completa muda)`));
|
|
995
|
+
}
|
|
996
|
+
const raw = response.objects;
|
|
997
|
+
const byCap = raw.length >= this.#listObjectsMax;
|
|
998
|
+
const byDeadline = elapsedMs >= LIST_OBJECTS_DEADLINE_RATIO * this.#listObjectsDeadlineMs;
|
|
999
|
+
return { raw, truncated: byCap || byDeadline, elapsedMs };
|
|
1000
|
+
}
|
|
435
1001
|
/**
|
|
436
1002
|
* `listObjects` = `ListObjects` (resuelve includes + usersets del lado del
|
|
437
|
-
* servidor) + FILTRO por partición + señal de TRUNCADO
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
442
|
-
* DESPUÉS (los ids llevan la partición), así que en un store con varias
|
|
443
|
-
* particiones el tope se consume entre todas — otra razón para señalar el
|
|
444
|
-
* truncado y no callar (documentado en el README).
|
|
1003
|
+
* servidor) + FILTRO por partición + señal de TRUNCADO (`#listObjectsPage`:
|
|
1004
|
+
* tope o deadline, B4). El filtro por partición se aplica DESPUÉS (los ids
|
|
1005
|
+
* llevan la partición), así que en un store con varias particiones el tope
|
|
1006
|
+
* se consume entre todas — otra razón para señalar el truncado y no callar
|
|
1007
|
+
* (documentado en el README).
|
|
445
1008
|
*/
|
|
446
1009
|
async listObjects(subject, relation, objectType, partition, _page) {
|
|
447
1010
|
assertRelationDeclared(this.#config, { type: objectType }, relation);
|
|
@@ -449,9 +1012,8 @@ export class OpenFgaRelationsDriver {
|
|
|
449
1012
|
const partitionKey = this.#keyOf(partition);
|
|
450
1013
|
const user = this.#subjectUser(subject, partitionKey);
|
|
451
1014
|
const context = this.#checkContext(this.#now());
|
|
452
|
-
|
|
453
|
-
const raw =
|
|
454
|
-
const truncated = raw.length >= this.#listObjectsMax;
|
|
1015
|
+
await this.#assertModelDeny([objectType]);
|
|
1016
|
+
const { raw, truncated } = await this.#listObjectsPage({ user, relation, type: objectType, context });
|
|
455
1017
|
const objects = [];
|
|
456
1018
|
for (const full of raw) {
|
|
457
1019
|
const parsed = this.#parseObjectId(full);
|
|
@@ -596,7 +1158,8 @@ export class OpenFgaRelationsDriver {
|
|
|
596
1158
|
}
|
|
597
1159
|
tuples.push({
|
|
598
1160
|
subject,
|
|
599
|
-
relation:
|
|
1161
|
+
// Lote D · D2: `denied_<rel>` ⇒ `{ relation: <rel>, kind: 'deny' }` (`storedRelationKind`, punto de paso único).
|
|
1162
|
+
...storedRelationKind(key.relation),
|
|
600
1163
|
object: { type: parsed.type, id: parsed.objectUuid },
|
|
601
1164
|
partition,
|
|
602
1165
|
expiresAt: key.validUntil,
|
|
@@ -621,7 +1184,7 @@ export class OpenFgaRelationsDriver {
|
|
|
621
1184
|
const objectId = this.#objectId(object.type, this.#keyOf(partition), object.id);
|
|
622
1185
|
const progress = { deleted: 0 };
|
|
623
1186
|
try {
|
|
624
|
-
await this.#
|
|
1187
|
+
await this.#purgeByFilters('purgeObject', [[{ object: objectId }, `objeto ${objectId}`]], progress);
|
|
625
1188
|
}
|
|
626
1189
|
catch (error) {
|
|
627
1190
|
throw this.#partialIfDeleted(error, progress);
|
|
@@ -646,9 +1209,7 @@ export class OpenFgaRelationsDriver {
|
|
|
646
1209
|
const types = [FACTS_GROUP_TYPE, ...this.#config.objectTypes.map((t) => t.type)];
|
|
647
1210
|
const progress = { deleted: 0 };
|
|
648
1211
|
try {
|
|
649
|
-
|
|
650
|
-
await this.#purgeByFilter('purgeSubject', { user, object: `${type}:` }, `sujeto ${user} en '${type}'`, progress);
|
|
651
|
-
}
|
|
1212
|
+
await this.#purgeByFilters('purgeSubject', types.map((type) => [{ user, object: `${type}:` }, `sujeto ${user} en '${type}'`]), progress);
|
|
652
1213
|
}
|
|
653
1214
|
catch (error) {
|
|
654
1215
|
throw this.#partialIfDeleted(error, progress);
|
|
@@ -658,7 +1219,7 @@ export class OpenFgaRelationsDriver {
|
|
|
658
1219
|
* **Una purga que muere a MEDIAS es indeterminada** (invariante 13,
|
|
659
1220
|
* 2.4.0-alpha.3, cierre del 🟡 6 del auditor). La purga NO es un `Write`:
|
|
660
1221
|
* son N `Read`+`deleteTuples` (ortografías × tipos, y lotes de
|
|
661
|
-
* `
|
|
1222
|
+
* `RELATIONS_PURGE_WRITE_MAX`), y un 503 que no es timeout —o el 500
|
|
662
1223
|
* `E_AUTHZ_PURGE_INCOMPLETE` de la demostración de cero— puede llegar
|
|
663
1224
|
* después de haber borrado parte. Si YA se borró al menos una tupla, el
|
|
664
1225
|
* error se marca (`markPartialWrite`, `src/errors.ts`: una propiedad, la
|
|
@@ -673,30 +1234,75 @@ export class OpenFgaRelationsDriver {
|
|
|
673
1234
|
return markPartialWrite(error);
|
|
674
1235
|
return error;
|
|
675
1236
|
}
|
|
676
|
-
/**
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
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
|
+
}
|
|
682
1279
|
progress.deleted += batch.length;
|
|
683
1280
|
}
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
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
|
+
}
|
|
692
1291
|
}
|
|
693
1292
|
}
|
|
694
1293
|
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
|
695
1294
|
/**
|
|
696
|
-
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
1295
|
+
* **El envoltorio ÚNICO de `Read`** (B4, la otra mitad de la propiedad
|
|
1296
|
+
* universal): TODAS las tuplas que casan con el filtro, paginando `Read`
|
|
1297
|
+
* hasta agotar el `continuation_token`. `Read` no tiene tope de resultados
|
|
1298
|
+
* (a diferencia de `ListObjects`), así que es exhaustivo — por eso las purgas,
|
|
1299
|
+
* `listSubjects` y `enumerateRelations(Many)` van por aquí, no por
|
|
1300
|
+
* `ListObjects` — y cuando NO puede serlo, lanza 503 en vez de devolver una
|
|
1301
|
+
* página corta: una respuesta sin `tuples`, un token que se repite (el
|
|
1302
|
+
* servidor no avanza; una página vacía con token cae aquí) o más de
|
|
1303
|
+
* `MAX_READ_PAGES` páginas. La guardia de superficie (columna `truncation`)
|
|
1304
|
+
* sondea las cinco operaciones que entran por aquí con un mutante que
|
|
1305
|
+
* «termina» en la página vacía.
|
|
700
1306
|
*/
|
|
701
1307
|
async #readAll(operation, filter) {
|
|
702
1308
|
const keys = [];
|
|
@@ -758,4 +1364,92 @@ export class OpenFgaRelationsDriver {
|
|
|
758
1364
|
// de modo que un 400 de validación de FGA o el cuerpo de un 502 que mencionara
|
|
759
1365
|
// la palabra salía como 503 `E_AUTHZ_BACKEND_TIMEOUT` con `indeterminate: true`
|
|
760
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
|
+
}
|
|
761
1455
|
//# sourceMappingURL=openfga_relations_driver.js.map
|