@jantstack/adonis-authz 2.4.0-alpha.1 → 2.4.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 +299 -19
- package/build/commands/authz_relations_reconcile.d.ts +25 -1
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
- package/build/commands/authz_relations_reconcile.js +94 -6
- package/build/commands/authz_relations_reconcile.js.map +1 -1
- package/build/index.d.ts +13 -2
- package/build/index.d.ts.map +1 -1
- package/build/index.js +15 -1
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts +6 -1
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +18 -2
- package/build/providers/authz_provider.js.map +1 -1
- package/build/src/define_config.d.ts +73 -1
- package/build/src/define_config.d.ts.map +1 -1
- package/build/src/define_config.js.map +1 -1
- package/build/src/drivers/database_driver.d.ts +53 -14
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +104 -32
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +57 -9
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/database_relations_driver.js +344 -89
- package/build/src/drivers/database_relations_driver.js.map +1 -1
- package/build/src/drivers/openfga_driver.d.ts +17 -4
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +38 -3
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_facts.d.ts +15 -0
- package/build/src/drivers/openfga_facts.d.ts.map +1 -1
- package/build/src/drivers/openfga_facts.js +25 -2
- package/build/src/drivers/openfga_facts.js.map +1 -1
- package/build/src/drivers/openfga_relations_driver.d.ts +40 -6
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_relations_driver.js +244 -44
- package/build/src/drivers/openfga_relations_driver.js.map +1 -1
- package/build/src/errors.d.ts +83 -4
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +139 -3
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +37 -16
- package/build/src/freeze.d.ts.map +1 -1
- package/build/src/freeze.js +53 -8
- package/build/src/freeze.js.map +1 -1
- package/build/src/manager.d.ts +16 -1
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +116 -25
- package/build/src/manager.js.map +1 -1
- package/build/src/models/authz_assignment.d.ts +2 -2
- package/build/src/models/authz_assignment.d.ts.map +1 -1
- package/build/src/models/authz_deny.d.ts +2 -2
- package/build/src/models/authz_deny.d.ts.map +1 -1
- package/build/src/models/authz_permission.d.ts +2 -2
- package/build/src/models/authz_permission.d.ts.map +1 -1
- package/build/src/models/authz_role.d.ts +2 -2
- package/build/src/models/authz_role.d.ts.map +1 -1
- package/build/src/models/authz_role_permission.d.ts +2 -2
- package/build/src/models/authz_role_permission.d.ts.map +1 -1
- package/build/src/relation_partition_trigger.d.ts.map +1 -1
- package/build/src/relation_partition_trigger.js +11 -3
- package/build/src/relation_partition_trigger.js.map +1 -1
- package/build/src/relations/define_relations_config.d.ts +51 -0
- package/build/src/relations/define_relations_config.d.ts.map +1 -1
- package/build/src/relations/define_relations_config.js +68 -1
- package/build/src/relations/define_relations_config.js.map +1 -1
- package/build/src/relations/manager.d.ts +71 -6
- package/build/src/relations/manager.d.ts.map +1 -1
- package/build/src/relations/manager.js +301 -29
- package/build/src/relations/manager.js.map +1 -1
- package/build/src/relations/reconcile.d.ts +53 -4
- package/build/src/relations/reconcile.d.ts.map +1 -1
- package/build/src/relations/reconcile.js +92 -30
- package/build/src/relations/reconcile.js.map +1 -1
- package/build/src/scope_outbox.d.ts.map +1 -1
- package/build/src/scope_outbox.js +17 -10
- package/build/src/scope_outbox.js.map +1 -1
- package/build/src/shared/backend_guard.d.ts +28 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -1
- package/build/src/shared/backend_guard.js +52 -0
- package/build/src/shared/backend_guard.js.map +1 -1
- package/build/src/shared/transaction_guard.d.ts +50 -0
- package/build/src/shared/transaction_guard.d.ts.map +1 -0
- package/build/src/shared/transaction_guard.js +60 -0
- package/build/src/shared/transaction_guard.js.map +1 -0
- package/build/src/testing/contract.d.ts +55 -4
- package/build/src/testing/contract.d.ts.map +1 -1
- package/build/src/testing/contract.js +240 -8
- package/build/src/testing/contract.js.map +1 -1
- package/build/src/testing/main.d.ts +4 -4
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +2 -2
- package/build/src/testing/main.js.map +1 -1
- package/build/src/testing/relations_contract.d.ts +23 -1
- package/build/src/testing/relations_contract.d.ts.map +1 -1
- package/build/src/testing/relations_contract.js +603 -19
- package/build/src/testing/relations_contract.js.map +1 -1
- package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
- package/build/src/testing/relations_reconcile_contract.js +49 -1
- package/build/src/testing/relations_reconcile_contract.js.map +1 -1
- package/build/src/traits/has_uuid.d.ts +2 -2
- package/build/src/traits/has_uuid.d.ts.map +1 -1
- package/build/src/types.d.ts +284 -16
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js +4 -0
- package/build/src/types.js.map +1 -1
- package/build/stubs/config/authorization.stub +45 -1
- package/build/stubs/migration.stub +20 -10
- package/package.json +1 -1
|
@@ -3,14 +3,20 @@
|
|
|
3
3
|
* (Fase 4, lote 4-3).
|
|
4
4
|
*
|
|
5
5
|
* Los hechos de relación viven en la tabla `authz_relations` (una fila por
|
|
6
|
-
* tupla, INSERT/DELETE-ONLY, decisión (c) del dueño)
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* `
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
6
|
+
* tupla, INSERT/DELETE-ONLY, decisión (c) del dueño). **La caducidad (R-15,
|
|
7
|
+
* 2.4.0-alpha.2)** es la columna `expires_at` (NULL = no caduca), con los
|
|
8
|
+
* MISMOS tres estados de `expiresAt` que `grant` (invariante 10), caducidad
|
|
9
|
+
* ESTRICTA (`expires_at > now`, la que vence ahora ya no cuenta), el MISMO
|
|
10
|
+
* codec por dialecto que `authz_assignments` (`sqlExpiryCodec`, 2.5-B · K2) y
|
|
11
|
+
* el reloj inyectable `withClock` (2.5 · J1). **Renovar la caducidad es
|
|
12
|
+
* delete+insert, nunca UPDATE** (decisión (c) del juez: un solo trigger por
|
|
13
|
+
* evento, menos superficie de divergencia de motor; observable: la fila cambia
|
|
14
|
+
* de `uuid`). La resolución de `check`/`membersOf`/`listObjects` —includes de
|
|
15
|
+
* un nivel y usersets de grupos anidados— es una **CTE recursiva por dialecto**
|
|
16
|
+
* (mismo patrón que `sql_descendants.ts`) que solo recorre hechos VIGENTES, y
|
|
17
|
+
* el cruce de particiones lo defiende, además, el trigger
|
|
18
|
+
* `relationPartitionTrigger` (defensa en profundidad para el escritor «a mano»;
|
|
19
|
+
* el corte primario es la columna `partition_key` en cada consulta).
|
|
14
20
|
*
|
|
15
21
|
* Pureza: este módulo vive en `drivers/` y NO importa `openfga` (regla 3 de
|
|
16
22
|
* `check_purity.mjs`) ni el `manager`. Consume solo el puerto (`../types.js`),
|
|
@@ -19,12 +25,16 @@
|
|
|
19
25
|
*/
|
|
20
26
|
import db from '@adonisjs/lucid/services/db';
|
|
21
27
|
import { v7 as uuidv7 } from 'uuid';
|
|
22
|
-
import { InvalidIdentityError, UnsupportedDialectError } from '../errors.js';
|
|
23
|
-
import { assertScope, assertSubject, scopeKey, scopeSpellings } from '../identity.js';
|
|
28
|
+
import { AuthorizationConfigError, InvalidIdentityError, UnsupportedDialectError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
|
|
29
|
+
import { assertScope, assertSubject, assertExpiresAt, scopeKey, scopeSpellings } from '../identity.js';
|
|
24
30
|
import { relationPartitionTrigger, relationPartitionTriggerDrops } from '../relation_partition_trigger.js';
|
|
25
|
-
import { systemClock } from '../clock.js';
|
|
26
|
-
import {
|
|
31
|
+
import { isClock, systemClock } from '../clock.js';
|
|
32
|
+
import { resolveGrantExpiry, sameInstant } from '../expiry.js';
|
|
33
|
+
import { sqlExpiryCodec } from '../shared/sql_expiry.js';
|
|
34
|
+
import { guardSql, isDeadlock, isUniqueViolation } from '../shared/backend_guard.js';
|
|
35
|
+
import { assertCallerTransaction } from '../shared/transaction_guard.js';
|
|
27
36
|
import { isRelUserset } from '../types.js';
|
|
37
|
+
import { assertRelationDeclared, assertObjectTypeDeclared } from '../relations/define_relations_config.js';
|
|
28
38
|
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
29
39
|
const RELATIONS_TABLE = 'authz_relations';
|
|
30
40
|
/** Longitud máxima del id de un objeto/holder de relación (columna `varchar(64)`). */
|
|
@@ -32,6 +42,15 @@ const RELATION_ID_MAX = 64;
|
|
|
32
42
|
/** El tipo BUILT-IN portador de usersets (declarado por el generador, no por el consumidor). */
|
|
33
43
|
const GROUP_TYPE = 'group';
|
|
34
44
|
const GROUP_MEMBER = 'member';
|
|
45
|
+
/**
|
|
46
|
+
* L-4b: lo que lleva `subject_relation` un HOLDER. Cadena vacía y no NULL
|
|
47
|
+
* porque el UNIQUE `authz_rel_tuple_uq` incluye la columna y NULL ≠ NULL en
|
|
48
|
+
* los tres motores (con NULL, dos `relate` concurrentes del mismo holder
|
|
49
|
+
* confirmaban DOS filas). Cadena vacía y no un centinela (`'-'`) porque la
|
|
50
|
+
* gramática de una relación (`[a-z0-9._-]{1,50}`) no admite la vacía: ningún
|
|
51
|
+
* userset puede colisionar con ella.
|
|
52
|
+
*/
|
|
53
|
+
const HOLDER_RELATION = '';
|
|
35
54
|
/** Tamaño de página por defecto y tope de `enumerateRelations` (origen de reconcile). */
|
|
36
55
|
const DEFAULT_ENUMERATE_LIMIT = 100;
|
|
37
56
|
const MAX_ENUMERATE_LIMIT = 1_000;
|
|
@@ -67,32 +86,81 @@ export class DatabaseRelationsDriver {
|
|
|
67
86
|
* paginados por la PK, sin filtrar ni derivar (invariante 7 + higiene de
|
|
68
87
|
* reconcile): la caña la ve el destino tal cual la escribió el origen.
|
|
69
88
|
* - `listObjectsTruncation: false`: sin tope de servidor, `listObjects` es exhaustiva.
|
|
89
|
+
* - `injectableClock: true` (R-15): `withClock(now)` fija el reloj que decide
|
|
90
|
+
* la caducidad (`expires_at > now`), como el driver `database` de roles.
|
|
91
|
+
* - `transactionalWrites: true` (L-4, default): las cuatro escrituras van
|
|
92
|
+
* por la transacción ABIERTA del llamante (`#writer()`,
|
|
93
|
+
* `assertCallerTransaction` contra la conexión de este driver). Lo que
|
|
94
|
+
* NO viaja por ella: la barrera del freeze y F-05 (autoridad). Un
|
|
95
|
+
* despliegue con pool 1 lo declara `false` (ver las opciones).
|
|
70
96
|
*/
|
|
71
|
-
capabilities
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
});
|
|
97
|
+
capabilities;
|
|
98
|
+
/**
|
|
99
|
+
* Con `transactionalWrites: false` declarado por el despliegue, un
|
|
100
|
+
* `{ transaction }` que llegara igual (un llamante que se salta el manager)
|
|
101
|
+
* se rechaza aquí también: no se ignora en silencio.
|
|
102
|
+
*/
|
|
103
|
+
#transactionalWrites;
|
|
79
104
|
#config;
|
|
80
105
|
#connectionName;
|
|
81
106
|
#timeoutMs;
|
|
82
107
|
#now;
|
|
83
108
|
/** Inyectable para probar el dialecto ajeno sin servidor. */
|
|
84
109
|
#database;
|
|
110
|
+
#expiryCodec;
|
|
85
111
|
constructor(config, options = {}, database = db) {
|
|
86
112
|
this.#config = config;
|
|
87
113
|
this.#connectionName = options.connection;
|
|
88
114
|
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
89
115
|
this.#now = options.now ?? systemClock;
|
|
90
116
|
this.#database = database;
|
|
117
|
+
this.#transactionalWrites = options.transactionalWrites ?? true;
|
|
118
|
+
this.capabilities = Object.freeze({
|
|
119
|
+
singleCheckRelations: true,
|
|
120
|
+
listObjectsInherited: false,
|
|
121
|
+
usersetSubjects: true,
|
|
122
|
+
membersOfNative: true,
|
|
123
|
+
enumerateRelations: true,
|
|
124
|
+
listObjectsTruncation: false,
|
|
125
|
+
injectableClock: true,
|
|
126
|
+
transactionalWrites: this.#transactionalWrites,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* Vista de este driver con OTRO reloj de pared (R-15, paridad con
|
|
131
|
+
* `AuthorizationDriver.withClock`): misma conexión, config y deadline; solo
|
|
132
|
+
* cambia el `now` que decide la caducidad. El driver no tiene estado propio
|
|
133
|
+
* (la conexión es del servicio `db`), así que la vista es una instancia nueva.
|
|
134
|
+
*/
|
|
135
|
+
withClock(now) {
|
|
136
|
+
if (!isClock(now)) {
|
|
137
|
+
throw new AuthorizationConfigError(`withClock: now debe ser una función () => Date (llegó ${typeof now})`);
|
|
138
|
+
}
|
|
139
|
+
return new DatabaseRelationsDriver(this.#config, { connection: this.#connectionName, timeoutMs: this.#timeoutMs, now, transactionalWrites: this.#transactionalWrites }, this.#database);
|
|
91
140
|
}
|
|
92
141
|
/* ── Infraestructura ──────────────────────────────────────────────────── */
|
|
93
142
|
#connection() {
|
|
94
143
|
return this.#database.connection(this.#connectionName);
|
|
95
144
|
}
|
|
145
|
+
/** El codec de `expires_at` por dialecto (K2): se decide una vez por driver, sin consulta. */
|
|
146
|
+
get #expiry() {
|
|
147
|
+
this.#expiryCodec ??= sqlExpiryCodec(this.#connection());
|
|
148
|
+
return this.#expiryCodec;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* El predicado SQL de VIGENCIA (R-15, caducidad ESTRICTA): sin caducidad o
|
|
152
|
+
* con caducidad FUTURA — la que vence en `now` ya no cuenta. Para el SQL
|
|
153
|
+
* crudo de las CTEs (`q` cita el identificador; la binding es `bind(now)`
|
|
154
|
+
* del codec, con UN solo `now` por operación, 2.5-B · K9).
|
|
155
|
+
*/
|
|
156
|
+
#activeSql(q, alias) {
|
|
157
|
+
return `(${alias}.${q('expires_at')} IS NULL OR ${alias}.${q('expires_at')} > ?)`;
|
|
158
|
+
}
|
|
159
|
+
/** El mismo predicado, para el query builder. */
|
|
160
|
+
#whereActive(query, at) {
|
|
161
|
+
const bound = this.#expiry.bind(at);
|
|
162
|
+
return query.where((b) => b.whereNull('expires_at').orWhere('expires_at', '>', bound));
|
|
163
|
+
}
|
|
96
164
|
#dialectMeta() {
|
|
97
165
|
const connection = this.#connection();
|
|
98
166
|
const name = connection?.dialect?.name ?? connection?.client?.driverName ?? 'desconocido';
|
|
@@ -110,8 +178,67 @@ export class DatabaseRelationsDriver {
|
|
|
110
178
|
#raw(operation, text, bindings) {
|
|
111
179
|
return this.#sql(operation, () => this.#connection().rawQuery(text, bindings));
|
|
112
180
|
}
|
|
181
|
+
/** La conexión de quien ESCRIBE: la declarada, o la primaria de Lucid (`undefined` con un `db` doble sin ella). */
|
|
182
|
+
#ownerConnection() {
|
|
183
|
+
return this.#connectionName ?? this.#database.primaryConnectionName;
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Por dónde escribe `operation` (L-4, el MISMO patrón que `writer()` del
|
|
187
|
+
* driver de roles, L-3): la transacción ABIERTA del llamante si llegó en
|
|
188
|
+
* `options.transaction` —validada contra la conexión de ESTE driver por
|
|
189
|
+
* `assertCallerTransaction`: otra conexión, un `QueryClient` o el `db`
|
|
190
|
+
* entero son 500 `E_AUTHZ_CONFIG` ANTES de tocar nada—, o la conexión del
|
|
191
|
+
* driver. `external` dice cuál de los dos.
|
|
192
|
+
*
|
|
193
|
+
* Solo la ESCRITURA va por ella (y la lectura «¿ya existe?» de `relate`,
|
|
194
|
+
* que forma parte de la misma escritura: un `relate` y su renovación en la
|
|
195
|
+
* misma transacción tienen que verse). La AUTORIDAD no: la barrera del
|
|
196
|
+
* freeze la lee el `RelationsManager` por la conexión del motor, y F-05 y la
|
|
197
|
+
* gramática cortan antes de llegar aquí.
|
|
198
|
+
*/
|
|
199
|
+
#writer(operation, options) {
|
|
200
|
+
if (options?.transaction === undefined || options.transaction === null) {
|
|
201
|
+
return { client: this.#connection(), external: false };
|
|
202
|
+
}
|
|
203
|
+
if (!this.#transactionalWrites) {
|
|
204
|
+
throw new UnsupportedOperationError('transactionalWrites', operation, 'database', 'Este despliegue declara transactionalWrites: false en las opciones del driver de relaciones (pool 1): ' +
|
|
205
|
+
'{ transaction } no se admite. Quita la opción (exige pool ≥ 2) o no pases transaction.');
|
|
206
|
+
}
|
|
207
|
+
const trx = assertCallerTransaction(`database-relations.${operation}`, options.transaction, {
|
|
208
|
+
connection: this.#ownerConnection(),
|
|
209
|
+
});
|
|
210
|
+
return { client: trx, external: true };
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Una sentencia que falla DENTRO de la transacción del llamante no se
|
|
214
|
+
* absorbe (L-4, paridad con `poisoned()` de roles): en PostgreSQL la
|
|
215
|
+
* transacción ya está ABORTADA (toda sentencia posterior es `25P02` hasta
|
|
216
|
+
* el rollback) y en REPEATABLE READ una relectura no vería al ganador. Así
|
|
217
|
+
* que un choque del UNIQUE de `authz_relations` (dos `relate` del mismo
|
|
218
|
+
* hecho en dos transacciones abiertas) y un **deadlock** (dos transacciones
|
|
219
|
+
* que escriben dos relaciones en orden cruzado: el motor elige una víctima y
|
|
220
|
+
* la deshace —PG `40P01`, MySQL `1213`—) salen como **409
|
|
221
|
+
* `E_AUTHZ_WRITE_CONFLICT`** —«envenena tu transacción: haz rollback y
|
|
222
|
+
* reintenta»—; un deadline vencido sigue siendo el 503
|
|
223
|
+
* `E_AUTHZ_BACKEND_TIMEOUT` que ya clasificó `#sql`, y cualquier otro fallo,
|
|
224
|
+
* su 503. Nunca una segunda sentencia sobre una transacción que puede estar
|
|
225
|
+
* abortada. (Medido por motor en `tests/relations_database.spec.ts`.)
|
|
226
|
+
*/
|
|
227
|
+
#poisoned(operation, error) {
|
|
228
|
+
if (isUniqueViolation(error)) {
|
|
229
|
+
throw new WriteConflictError(`database-relations.${operation}: la tupla la escribió otra transacción mientras la tuya estaba abierta ` +
|
|
230
|
+
'(choque del UNIQUE dentro de tu transacción). No se puede absorber ahí dentro: la transacción queda ' +
|
|
231
|
+
'envenenada (en PostgreSQL, abortada). Haz rollback y reintenta.', { cause: error });
|
|
232
|
+
}
|
|
233
|
+
if (isDeadlock(error)) {
|
|
234
|
+
throw new WriteConflictError(`database-relations.${operation}: el motor detectó un DEADLOCK con otra transacción y eligió la tuya ` +
|
|
235
|
+
'como víctima (dos transacciones escribiendo las mismas tuplas en orden cruzado). La transacción queda ' +
|
|
236
|
+
'envenenada (PostgreSQL la aborta; MySQL la deshace entera). Haz rollback y reintenta.', { cause: error });
|
|
237
|
+
}
|
|
238
|
+
throw error;
|
|
239
|
+
}
|
|
113
240
|
/* ── Validación de identidad (defensa en profundidad) ─────────────────── */
|
|
114
|
-
/** El objeto es `{ type, id }` bien formado (
|
|
241
|
+
/** El objeto es `{ type, id }` bien formado (F-05 —tipo/relación declarados— la aplica `assertRelationDeclared` en las SIETE operaciones: L-0 en `relate`/`unrelate`, alpha.3 en purgas y lecturas). */
|
|
115
242
|
#assertObject(object) {
|
|
116
243
|
if (!object || typeof object !== 'object') {
|
|
117
244
|
throw new InvalidIdentityError(`Objeto de relación inválido: llegó ${typeof object}`);
|
|
@@ -125,9 +252,17 @@ export class DatabaseRelationsDriver {
|
|
|
125
252
|
`(llegó ${JSON.stringify(value)})`);
|
|
126
253
|
}
|
|
127
254
|
}
|
|
128
|
-
/**
|
|
255
|
+
/**
|
|
256
|
+
* Valida el sujeto (holder o userset) y devuelve sus columnas. L-4b: el
|
|
257
|
+
* holder lleva `relation: ''` (NUNCA NULL): es lo que hace que el UNIQUE
|
|
258
|
+
* `authz_rel_tuple_uq` lo defienda (`'' = ''`, mientras que NULL ≠ NULL).
|
|
259
|
+
*/
|
|
129
260
|
#subjectColumns(subject, partitionKey) {
|
|
130
261
|
if (isRelUserset(subject)) {
|
|
262
|
+
// alpha.3 · F-05 también en el USERSET del sujeto (la misma función que
|
|
263
|
+
// el manager): `{ object: { type: 'role_binding' }, relation: 'role' }`
|
|
264
|
+
// como sujeto de una purga/lectura no toca ni nombra un binding real.
|
|
265
|
+
assertRelationDeclared(this.#config, subject.object, subject.relation);
|
|
131
266
|
this.#assertObject(subject.object);
|
|
132
267
|
this.#assertId(`la relación del userset '${subject.object.type}'`, subject.relation, RELATION_ID_MAX);
|
|
133
268
|
// El userset comparte la partición de la tupla (el manager así lo exige);
|
|
@@ -135,7 +270,21 @@ export class DatabaseRelationsDriver {
|
|
|
135
270
|
return { type: subject.object.type, uuid: subject.object.id, relation: subject.relation, partition: partitionKey };
|
|
136
271
|
}
|
|
137
272
|
assertSubject(subject);
|
|
138
|
-
return { type: subject.type, uuid: subject.uuid, relation:
|
|
273
|
+
return { type: subject.type, uuid: subject.uuid, relation: HOLDER_RELATION, partition: null };
|
|
274
|
+
}
|
|
275
|
+
/**
|
|
276
|
+
* El WHERE por `subject_relation`. Para un userset, su relación exacta. Para
|
|
277
|
+
* un HOLDER (`''`), **tolerante a NULL**: una fila vieja con NULL (alpha.1
|
|
278
|
+
* sin el backfill de L-4b) sigue siendo el mismo hecho, y si `unrelate`/
|
|
279
|
+
* `purgeSubject` no la vieran seguiría concediendo tras retirarla
|
|
280
|
+
* (fail-open); el «¿ya existe?» de `relate` también la encuentra, así que
|
|
281
|
+
* renovar su caducidad la sustituye por una fila con `''`.
|
|
282
|
+
*/
|
|
283
|
+
#whereSubjectRelation(b, relation) {
|
|
284
|
+
if (relation === HOLDER_RELATION)
|
|
285
|
+
b.where('subject_relation', HOLDER_RELATION).orWhereNull('subject_relation');
|
|
286
|
+
else
|
|
287
|
+
b.where('subject_relation', relation);
|
|
139
288
|
}
|
|
140
289
|
/* ── Expansión de includes (un nivel, hacia ABAJO desde la relación) ──── */
|
|
141
290
|
#expandDown(objectType, relation) {
|
|
@@ -155,17 +304,33 @@ export class DatabaseRelationsDriver {
|
|
|
155
304
|
return [...out];
|
|
156
305
|
}
|
|
157
306
|
/* ── Escrituras ───────────────────────────────────────────────────────── */
|
|
158
|
-
async relate(subject, relation, object, partition,
|
|
307
|
+
async relate(subject, relation, object, partition, options) {
|
|
308
|
+
// L-0 · F-05 en el DRIVER (la MISMA función que el manager): la red para
|
|
309
|
+
// quien entra por `manager.driver()` o por `reconcileRelations`. Corta
|
|
310
|
+
// ANTES de pedir la conexión. En `database` la tabla es propia (inocuo),
|
|
311
|
+
// pero el 422 es el MISMO que en `openfga`, donde el store es compartido.
|
|
312
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
159
313
|
assertScope(partition);
|
|
160
314
|
this.#assertObject(object);
|
|
161
315
|
this.#assertId(`la relación de '${object.type}'`, relation, RELATION_ID_MAX);
|
|
316
|
+
// R-15 (defensa en profundidad; el manager ya lo validó): los tres estados.
|
|
317
|
+
assertExpiresAt(options?.expiresAt);
|
|
318
|
+
const requested = options?.expiresAt;
|
|
162
319
|
const partitionKey = scopeKey(partition);
|
|
163
320
|
const s = this.#subjectColumns(subject, partitionKey);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
321
|
+
const codec = this.#expiry;
|
|
322
|
+
const now = this.#now();
|
|
323
|
+
// L-4: por la transacción del llamante si llegó (`#writer`, juzgada AQUÍ,
|
|
324
|
+
// después de F-05 y la gramática y antes de la primera sentencia).
|
|
325
|
+
const { client, external } = this.#writer('relate', options);
|
|
326
|
+
// El check-then-delete-insert idempotente. Con transacción EXTERNA corre
|
|
327
|
+
// tal cual sobre ella (el trigger de partición dispara en ESE INSERT,
|
|
328
|
+
// dentro de la trx del consumidor; NUNCA se abre una interna: la fila
|
|
329
|
+
// confirmaría sola y sobreviviría al rollback del llamante). Sin ella, en
|
|
330
|
+
// una transacción INTERNA de la conexión del driver (la atomicidad
|
|
331
|
+
// trigger+insert de 4-3). Cada sentencia lleva su deadline (`#sql`).
|
|
332
|
+
const write = async (trx) => {
|
|
333
|
+
const existing = await this.#sql('relate.select', () => trx
|
|
169
334
|
.from(RELATIONS_TABLE)
|
|
170
335
|
.where('partition_key', partitionKey)
|
|
171
336
|
.where('object_type', object.type)
|
|
@@ -173,11 +338,25 @@ export class DatabaseRelationsDriver {
|
|
|
173
338
|
.where('relation', relation)
|
|
174
339
|
.where('subject_type', s.type)
|
|
175
340
|
.where('subject_uuid', s.uuid)
|
|
176
|
-
.where((b) => (
|
|
177
|
-
.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
341
|
+
.where((b) => this.#whereSubjectRelation(b, s.relation))
|
|
342
|
+
.select('uuid', codec.select('expires_at'))
|
|
343
|
+
.limit(1));
|
|
344
|
+
const current = existing[0];
|
|
345
|
+
const previous = current ? codec.fromDb(current.expires_at) : null;
|
|
346
|
+
// Los tres estados (invariante 10): omitido preserva la VIGENTE (una
|
|
347
|
+
// caducada revive sin caducidad), null la quita, Date la fija.
|
|
348
|
+
const expiresAt = resolveGrantExpiry(previous, requested, now);
|
|
349
|
+
if (current) {
|
|
350
|
+
// Idempotente (invariante 6): la misma caducidad no reescribe nada.
|
|
351
|
+
if (sameInstant(previous, expiresAt))
|
|
352
|
+
return;
|
|
353
|
+
// **INSERT/DELETE-ONLY (decisión (c))**: cambiar la caducidad es
|
|
354
|
+
// BORRAR la fila e INSERTAR otra —nunca un UPDATE—; la fila nueva
|
|
355
|
+
// tiene otro `uuid`, que es lo que lo hace observable. Dentro de la
|
|
356
|
+
// trx del llamante, el rollback devuelve la fila (y la caducidad) VIEJA.
|
|
357
|
+
await this.#sql('relate.delete', () => trx.from(RELATIONS_TABLE).where('uuid', current.uuid).delete());
|
|
358
|
+
}
|
|
359
|
+
await this.#sql('relate.insert', () => trx.table(RELATIONS_TABLE).insert({
|
|
181
360
|
uuid: uuidv7(),
|
|
182
361
|
partition_key: partitionKey,
|
|
183
362
|
object_type: object.type,
|
|
@@ -187,29 +366,63 @@ export class DatabaseRelationsDriver {
|
|
|
187
366
|
subject_uuid: s.uuid,
|
|
188
367
|
subject_relation: s.relation,
|
|
189
368
|
subject_partition: s.partition,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
369
|
+
expires_at: codec.toDb(expiresAt),
|
|
370
|
+
// Sello de auditoría, no decisión (2.5-B · K5): reloj del SISTEMA
|
|
371
|
+
// (con el reloj inyectado en 2099 un TIMESTAMP de MySQL reventaría).
|
|
372
|
+
created_at: systemClock(),
|
|
373
|
+
}));
|
|
374
|
+
};
|
|
375
|
+
if (external) {
|
|
376
|
+
try {
|
|
377
|
+
await write(client);
|
|
378
|
+
}
|
|
379
|
+
catch (error) {
|
|
380
|
+
this.#poisoned('relate', error);
|
|
381
|
+
}
|
|
382
|
+
return;
|
|
383
|
+
}
|
|
384
|
+
await this.#sql('relate', () => client.transaction(write));
|
|
193
385
|
}
|
|
194
|
-
async unrelate(subject, relation, object, partition,
|
|
386
|
+
async unrelate(subject, relation, object, partition, options) {
|
|
387
|
+
// L-0 · F-05 también al RETIRAR (paridad con `openfga`).
|
|
388
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
195
389
|
assertScope(partition);
|
|
196
390
|
this.#assertObject(object);
|
|
197
391
|
this.#assertId(`la relación de '${object.type}'`, relation, RELATION_ID_MAX);
|
|
198
392
|
const partitionKey = scopeKey(partition);
|
|
199
393
|
const s = this.#subjectColumns(subject, partitionKey);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
394
|
+
const { client, external } = this.#writer('unrelate', options);
|
|
395
|
+
try {
|
|
396
|
+
await this.#sql('unrelate', () => client
|
|
397
|
+
.from(RELATIONS_TABLE)
|
|
398
|
+
.where('partition_key', partitionKey)
|
|
399
|
+
.where('object_type', object.type)
|
|
400
|
+
.where('object_uuid', object.id)
|
|
401
|
+
.where('relation', relation)
|
|
402
|
+
.where('subject_type', s.type)
|
|
403
|
+
.where('subject_uuid', s.uuid)
|
|
404
|
+
.where((b) => this.#whereSubjectRelation(b, s.relation))
|
|
405
|
+
.delete());
|
|
406
|
+
}
|
|
407
|
+
catch (error) {
|
|
408
|
+
if (external)
|
|
409
|
+
this.#poisoned('unrelate', error);
|
|
410
|
+
throw error;
|
|
411
|
+
}
|
|
210
412
|
}
|
|
211
413
|
/* ── Lecturas ─────────────────────────────────────────────────────────── */
|
|
414
|
+
/**
|
|
415
|
+
* **F-05 en las LECTURAS y las PURGAS del driver** (2.4.0-alpha.3, cierre
|
|
416
|
+
* del 🔴 1 / 🟠 2 del auditor; la MISMA función y el mismo orden que L-0 en
|
|
417
|
+
* `relate`/`unrelate`): PRIMERA línea, antes de pedir la conexión. En
|
|
418
|
+
* `database` la tabla es propia (inocuo), pero el 422 es el MISMO que en
|
|
419
|
+
* `openfga`, donde `purgeObject(role_binding)` borraba el binding real y
|
|
420
|
+
* `listSubjects(assignee, role_binding)` enumeraba sus asignados por el
|
|
421
|
+
* camino de `manager.driver()`. `check`/`listSubjects` validan el par;
|
|
422
|
+
* `listObjects`/`purgeObject` el tipo; `purgeSubject` el userset.
|
|
423
|
+
*/
|
|
212
424
|
async check(subject, relation, object, partition) {
|
|
425
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
213
426
|
assertScope(partition);
|
|
214
427
|
this.#assertObject(object);
|
|
215
428
|
const partitionKey = scopeKey(partition);
|
|
@@ -217,19 +430,21 @@ export class DatabaseRelationsDriver {
|
|
|
217
430
|
const relations = this.#expandDown(object.type, relation);
|
|
218
431
|
const meta = this.#dialectMeta();
|
|
219
432
|
const q = (name) => `${meta.quote}${name}${meta.quote}`;
|
|
220
|
-
const
|
|
433
|
+
const at = this.#expiry.bind(this.#now());
|
|
434
|
+
const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
|
|
221
435
|
const relPlaceholders = relations.map(() => '?').join(', ');
|
|
222
436
|
const sql = cte +
|
|
223
437
|
` SELECT ${meta.hint}1 FROM ${q(RELATIONS_TABLE)} r ` +
|
|
224
438
|
`JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
|
|
225
439
|
`AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
|
|
226
440
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
|
|
227
|
-
`AND r.${q('relation')} IN (${relPlaceholders}) LIMIT 1`;
|
|
228
|
-
const bindings = [...cteBindings, partitionKey, object.type, object.id, ...relations];
|
|
441
|
+
`AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')} LIMIT 1`;
|
|
442
|
+
const bindings = [...cteBindings, partitionKey, object.type, object.id, ...relations, at];
|
|
229
443
|
const rows = rowsOf(await this.#raw('check', sql, bindings));
|
|
230
444
|
return rows.length > 0;
|
|
231
445
|
}
|
|
232
446
|
async listObjects(subject, relation, objectType, partition, _page) {
|
|
447
|
+
assertRelationDeclared(this.#config, { type: objectType }, relation);
|
|
233
448
|
assertScope(partition);
|
|
234
449
|
this.#assertId('el tipo de objeto', objectType, RELATION_ID_MAX);
|
|
235
450
|
const partitionKey = scopeKey(partition);
|
|
@@ -237,15 +452,16 @@ export class DatabaseRelationsDriver {
|
|
|
237
452
|
const relations = this.#expandDown(objectType, relation);
|
|
238
453
|
const meta = this.#dialectMeta();
|
|
239
454
|
const q = (name) => `${meta.quote}${name}${meta.quote}`;
|
|
240
|
-
const
|
|
455
|
+
const at = this.#expiry.bind(this.#now());
|
|
456
|
+
const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
|
|
241
457
|
const relPlaceholders = relations.map(() => '?').join(', ');
|
|
242
458
|
const sql = cte +
|
|
243
459
|
` SELECT ${meta.hint}DISTINCT r.${q('object_uuid')} AS ${q('object_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
244
460
|
`JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
|
|
245
461
|
`AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
|
|
246
462
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? ` +
|
|
247
|
-
`AND r.${q('relation')} IN (${relPlaceholders})`;
|
|
248
|
-
const bindings = [...cteBindings, partitionKey, objectType, ...relations];
|
|
463
|
+
`AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')}`;
|
|
464
|
+
const bindings = [...cteBindings, partitionKey, objectType, ...relations, at];
|
|
249
465
|
const rows = rowsOf(await this.#raw('listObjects', sql, bindings));
|
|
250
466
|
const objects = rows
|
|
251
467
|
.map((row) => ({ type: objectType, id: String(row.object_uuid) }))
|
|
@@ -253,19 +469,20 @@ export class DatabaseRelationsDriver {
|
|
|
253
469
|
return { objects };
|
|
254
470
|
}
|
|
255
471
|
async listSubjects(relation, object, partition, _page) {
|
|
472
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
256
473
|
assertScope(partition);
|
|
257
474
|
this.#assertObject(object);
|
|
258
475
|
const partitionKey = scopeKey(partition);
|
|
259
476
|
// DIRECTOS del relation EXACTO (invariante 7): ni transitivo (eso es
|
|
260
477
|
// `membersOf`) ni derivado por includes. `usersetSubjects: true` ⇒ los
|
|
261
|
-
// usersets (`group#member`) salen junto con los holders.
|
|
262
|
-
const
|
|
478
|
+
// usersets (`group#member`) salen junto con los holders. Solo VIGENTES (R-15).
|
|
479
|
+
const now = this.#now();
|
|
480
|
+
const rows = await this.#sql('listSubjects', () => this.#whereActive(this.#connection()
|
|
263
481
|
.from(RELATIONS_TABLE)
|
|
264
482
|
.where('partition_key', partitionKey)
|
|
265
483
|
.where('object_type', object.type)
|
|
266
484
|
.where('object_uuid', object.id)
|
|
267
|
-
.where('relation', relation)
|
|
268
|
-
.select('subject_type', 'subject_uuid', 'subject_relation'));
|
|
485
|
+
.where('relation', relation), now).select('subject_type', 'subject_uuid', 'subject_relation'));
|
|
269
486
|
const subjects = rows.map((row) => this.#rowToSubject(row));
|
|
270
487
|
return { subjects };
|
|
271
488
|
}
|
|
@@ -277,6 +494,9 @@ export class DatabaseRelationsDriver {
|
|
|
277
494
|
* los holders de g1 y `listSubjects(member, g2)` solo el userset `g1#member`.
|
|
278
495
|
*/
|
|
279
496
|
async membersOf(object, relation, partition, _page) {
|
|
497
|
+
// Cierre-2 · 🟡 3: F-05 (par, ESTRICTA) también en `membersOf`, la octava
|
|
498
|
+
// operación del puerto; antes solo `#assertObject` (gramática).
|
|
499
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
280
500
|
assertScope(partition);
|
|
281
501
|
this.#assertObject(object);
|
|
282
502
|
const partitionKey = scopeKey(partition);
|
|
@@ -284,68 +504,96 @@ export class DatabaseRelationsDriver {
|
|
|
284
504
|
const meta = this.#dialectMeta();
|
|
285
505
|
const q = (name) => `${meta.quote}${name}${meta.quote}`;
|
|
286
506
|
const relPlaceholders = relations.map(() => '?').join(', ');
|
|
507
|
+
const at = this.#expiry.bind(this.#now());
|
|
508
|
+
const active = this.#activeSql(q, 'r');
|
|
287
509
|
// `grp`: los grupos alcanzables por usersets desde (object, RS) —directos y
|
|
288
510
|
// anidados—. Ancla de UN solo SELECT (MySQL no mezcla UNION ALL con la
|
|
289
511
|
// recursión): los usersets directos del objeto para cualquier rel de RS.
|
|
512
|
+
// Solo hechos VIGENTES en los cuatro tramos (R-15): una membresía caducada
|
|
513
|
+
// no lleva a nadie dentro.
|
|
290
514
|
const sql = `WITH RECURSIVE grp(g_uuid) AS ( ` +
|
|
291
515
|
`SELECT r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
292
516
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
|
|
293
517
|
`AND r.${q('relation')} IN (${relPlaceholders}) AND r.${q('subject_type')} = '${GROUP_TYPE}' ` +
|
|
294
|
-
`AND r.${q('subject_relation')} = '${GROUP_MEMBER}' ` +
|
|
518
|
+
`AND r.${q('subject_relation')} = '${GROUP_MEMBER}' AND ${active} ` +
|
|
295
519
|
`UNION ` +
|
|
296
520
|
`SELECT r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
297
521
|
`JOIN grp ON r.${q('object_uuid')} = grp.g_uuid ` +
|
|
298
522
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = '${GROUP_TYPE}' ` +
|
|
299
523
|
`AND r.${q('relation')} = '${GROUP_MEMBER}' AND r.${q('subject_type')} = '${GROUP_TYPE}' ` +
|
|
300
|
-
`AND r.${q('subject_relation')} = '${GROUP_MEMBER}' ` +
|
|
524
|
+
`AND r.${q('subject_relation')} = '${GROUP_MEMBER}' AND ${active} ` +
|
|
301
525
|
`) ` +
|
|
302
526
|
// Holders directos del objeto para RS, UNION holders directos de cualquier grupo de `grp`.
|
|
303
527
|
`SELECT ${meta.hint}${q('subject_type')} AS ${q('subject_type')}, ${q('subject_uuid')} AS ${q('subject_uuid')} ` +
|
|
304
528
|
`FROM ${q(RELATIONS_TABLE)} r ` +
|
|
305
529
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
|
|
306
|
-
`AND r.${q('relation')} IN (${relPlaceholders}) AND r.${q('subject_relation')}
|
|
530
|
+
`AND r.${q('relation')} IN (${relPlaceholders}) AND COALESCE(r.${q('subject_relation')}, '') = '' AND ${active} ` +
|
|
307
531
|
`UNION ` +
|
|
308
532
|
`SELECT r.${q('subject_type')}, r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
309
533
|
`JOIN grp ON r.${q('object_uuid')} = grp.g_uuid ` +
|
|
310
534
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = '${GROUP_TYPE}' ` +
|
|
311
|
-
`AND r.${q('relation')} = '${GROUP_MEMBER}' AND r.${q('subject_relation')}
|
|
535
|
+
`AND r.${q('relation')} = '${GROUP_MEMBER}' AND COALESCE(r.${q('subject_relation')}, '') = '' AND ${active}`;
|
|
312
536
|
const bindings = [
|
|
313
537
|
// grp ancla
|
|
314
|
-
partitionKey, object.type, object.id, ...relations,
|
|
538
|
+
partitionKey, object.type, object.id, ...relations, at,
|
|
315
539
|
// grp recursiva
|
|
316
|
-
partitionKey,
|
|
540
|
+
partitionKey, at,
|
|
317
541
|
// holders directos del objeto
|
|
318
|
-
partitionKey, object.type, object.id, ...relations,
|
|
542
|
+
partitionKey, object.type, object.id, ...relations, at,
|
|
319
543
|
// holders de los grupos
|
|
320
|
-
partitionKey,
|
|
544
|
+
partitionKey, at,
|
|
321
545
|
];
|
|
322
546
|
const rows = rowsOf(await this.#raw('membersOf', sql, bindings));
|
|
323
547
|
const subjects = rows.map((row) => ({ type: String(row.subject_type), uuid: String(row.subject_uuid) }));
|
|
324
548
|
return { subjects };
|
|
325
549
|
}
|
|
326
550
|
/* ── Purga (invariante 11): el DELETE demuestra el cero ───────────────── */
|
|
327
|
-
async purgeObject(object, partition) {
|
|
551
|
+
async purgeObject(object, partition, options) {
|
|
552
|
+
// alpha.3 · F-05 (solo el TIPO: una purga no nombra relación), PRIMERA línea.
|
|
553
|
+
// Cierre-2: la función de TIPO, no la estricta con relación opcional.
|
|
554
|
+
assertObjectTypeDeclared(this.#config, object);
|
|
328
555
|
assertScope(partition);
|
|
329
556
|
this.#assertObject(object);
|
|
330
557
|
const partitionKeys = this.#partitionSpellings(partition);
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
558
|
+
// L-4: por la transacción del llamante si llegó — la purga borra y
|
|
559
|
+
// REVIERTE con ella (tras un rollback todo lo purgado está de vuelta).
|
|
560
|
+
const { client, external } = this.#writer('purgeObject', options);
|
|
561
|
+
try {
|
|
562
|
+
await this.#sql('purgeObject', () => client
|
|
563
|
+
.from(RELATIONS_TABLE)
|
|
564
|
+
.whereIn('partition_key', partitionKeys)
|
|
565
|
+
.where('object_type', object.type)
|
|
566
|
+
.where('object_uuid', object.id)
|
|
567
|
+
.delete());
|
|
568
|
+
}
|
|
569
|
+
catch (error) {
|
|
570
|
+
if (external)
|
|
571
|
+
this.#poisoned('purgeObject', error);
|
|
572
|
+
throw error;
|
|
573
|
+
}
|
|
337
574
|
}
|
|
338
|
-
async purgeSubject(subject, partition) {
|
|
575
|
+
async purgeSubject(subject, partition, options) {
|
|
576
|
+
// alpha.3 · F-05 del USERSET (una purga por sujeto no nombra objeto), PRIMERA línea.
|
|
577
|
+
if (isRelUserset(subject))
|
|
578
|
+
assertRelationDeclared(this.#config, subject.object, subject.relation);
|
|
339
579
|
assertScope(partition);
|
|
340
580
|
const partitionKeys = this.#partitionSpellings(partition);
|
|
341
581
|
const s = this.#subjectColumns(subject, scopeKey(partition));
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
582
|
+
const { client, external } = this.#writer('purgeSubject', options);
|
|
583
|
+
try {
|
|
584
|
+
await this.#sql('purgeSubject', () => client
|
|
585
|
+
.from(RELATIONS_TABLE)
|
|
586
|
+
.whereIn('partition_key', partitionKeys)
|
|
587
|
+
.where('subject_type', s.type)
|
|
588
|
+
.where('subject_uuid', s.uuid)
|
|
589
|
+
.where((b) => this.#whereSubjectRelation(b, s.relation))
|
|
590
|
+
.delete());
|
|
591
|
+
}
|
|
592
|
+
catch (error) {
|
|
593
|
+
if (external)
|
|
594
|
+
this.#poisoned('purgeSubject', error);
|
|
595
|
+
throw error;
|
|
596
|
+
}
|
|
349
597
|
}
|
|
350
598
|
/* ── ORIGEN de reconcile (4-5): enumera los hechos directos ───────────── */
|
|
351
599
|
/**
|
|
@@ -354,8 +602,9 @@ export class DatabaseRelationsDriver {
|
|
|
354
602
|
* paginados por la PK (`uuid`, cursor que AVANZA), sin derivar por
|
|
355
603
|
* includes/usersets (el modelo del destino lo recompone): el destino recibe
|
|
356
604
|
* los hechos tal cual y decide qué escribe (invariante 7 + la higiene de
|
|
357
|
-
* reconcile —
|
|
358
|
-
*
|
|
605
|
+
* reconcile — **la caducada LLEGA con su `expiresAt`**, R-15, para contarse
|
|
606
|
+
* en `skipped`; filtrarla aquí la haría desaparecer sin rastro). Barre las
|
|
607
|
+
* DOS ortografías del uuid de partición (🟡2,
|
|
359
608
|
* coherente con `purge*`): un origen que las funde no puede dejar hechos del
|
|
360
609
|
* alias sin migrar.
|
|
361
610
|
*
|
|
@@ -383,7 +632,7 @@ export class DatabaseRelationsDriver {
|
|
|
383
632
|
.limit(limit + 1);
|
|
384
633
|
if (after)
|
|
385
634
|
q = q.where('uuid', '>', after);
|
|
386
|
-
return q.select('uuid', 'object_type', 'object_uuid', 'relation', 'subject_type', 'subject_uuid', 'subject_relation');
|
|
635
|
+
return q.select('uuid', 'object_type', 'object_uuid', 'relation', 'subject_type', 'subject_uuid', 'subject_relation', this.#expiry.select('expires_at'));
|
|
387
636
|
});
|
|
388
637
|
const hasMore = rows.length > limit;
|
|
389
638
|
const pageRows = hasMore ? rows.slice(0, limit) : rows;
|
|
@@ -393,6 +642,7 @@ export class DatabaseRelationsDriver {
|
|
|
393
642
|
object: { type: String(row.object_type), id: String(row.object_uuid) },
|
|
394
643
|
// La partición canónica pedida: el barrido de ortografías ya la unificó.
|
|
395
644
|
partition,
|
|
645
|
+
expiresAt: this.#expiry.fromDb(row.expires_at),
|
|
396
646
|
}));
|
|
397
647
|
const cursor = hasMore ? String(pageRows[pageRows.length - 1].uuid) : undefined;
|
|
398
648
|
return cursor ? { tuples, cursor } : { tuples };
|
|
@@ -417,8 +667,12 @@ export class DatabaseRelationsDriver {
|
|
|
417
667
|
* de esas identidades. Devuelve el `WITH RECURSIVE …` (sin el SELECT final) y
|
|
418
668
|
* sus bindings.
|
|
419
669
|
*/
|
|
420
|
-
#principalCte(meta, q, s, partitionKey) {
|
|
421
|
-
|
|
670
|
+
#principalCte(meta, q, s, partitionKey, at) {
|
|
671
|
+
// L-4b: el holder ya viene con `''`; el `COALESCE(subject_relation, '')`
|
|
672
|
+
// del JOIN es la tolerancia a una fila vieja con NULL.
|
|
673
|
+
const baseRel = s.relation;
|
|
674
|
+
// Solo membresías VIGENTES suben por la recursión (R-15): una `member`
|
|
675
|
+
// caducada no convierte al sujeto en miembro de nada.
|
|
422
676
|
const cte = `WITH RECURSIVE principal(p_type, p_uuid, p_rel) AS ( ` +
|
|
423
677
|
`SELECT ${meta.text('?')}, ${meta.text('?')}, ${meta.text('?')} ` +
|
|
424
678
|
`UNION ` +
|
|
@@ -427,11 +681,12 @@ export class DatabaseRelationsDriver {
|
|
|
427
681
|
`JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
|
|
428
682
|
`AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
|
|
429
683
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = '${GROUP_TYPE}' ` +
|
|
430
|
-
`AND r.${q('relation')} = '${GROUP_MEMBER}' )`;
|
|
431
|
-
return { cte, bindings: [s.type, s.uuid, baseRel, partitionKey] };
|
|
684
|
+
`AND r.${q('relation')} = '${GROUP_MEMBER}' AND ${this.#activeSql(q, 'r')} )`;
|
|
685
|
+
return { cte, bindings: [s.type, s.uuid, baseRel, partitionKey, at] };
|
|
432
686
|
}
|
|
687
|
+
/** `''` (L-4b) o NULL (fila vieja sin backfill) ⇒ holder; con valor ⇒ userset. */
|
|
433
688
|
#rowToSubject(row) {
|
|
434
|
-
if (row.subject_relation === null || row.subject_relation === undefined) {
|
|
689
|
+
if (row.subject_relation === null || row.subject_relation === undefined || row.subject_relation === HOLDER_RELATION) {
|
|
435
690
|
return { type: String(row.subject_type), uuid: String(row.subject_uuid) };
|
|
436
691
|
}
|
|
437
692
|
return { object: { type: String(row.subject_type), id: String(row.subject_uuid) }, relation: String(row.subject_relation) };
|