@jantstack/adonis-authz 2.4.0-alpha.1 → 2.4.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +248 -18
- package/build/commands/authz_relations_reconcile.d.ts +25 -1
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
- package/build/commands/authz_relations_reconcile.js +94 -6
- package/build/commands/authz_relations_reconcile.js.map +1 -1
- package/build/index.d.ts +12 -1
- package/build/index.d.ts.map +1 -1
- package/build/index.js +10 -0
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +7 -0
- package/build/providers/authz_provider.js.map +1 -1
- package/build/src/define_config.d.ts +36 -0
- package/build/src/define_config.d.ts.map +1 -1
- package/build/src/define_config.js.map +1 -1
- package/build/src/drivers/database_driver.d.ts +53 -14
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +104 -32
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +47 -9
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/database_relations_driver.js +318 -89
- package/build/src/drivers/database_relations_driver.js.map +1 -1
- package/build/src/drivers/openfga_driver.d.ts +17 -4
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +38 -3
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_facts.d.ts +15 -0
- package/build/src/drivers/openfga_facts.d.ts.map +1 -1
- package/build/src/drivers/openfga_facts.js +25 -2
- package/build/src/drivers/openfga_facts.js.map +1 -1
- package/build/src/drivers/openfga_relations_driver.d.ts +27 -6
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_relations_driver.js +140 -16
- package/build/src/drivers/openfga_relations_driver.js.map +1 -1
- package/build/src/errors.d.ts +35 -3
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +61 -3
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +37 -16
- package/build/src/freeze.d.ts.map +1 -1
- package/build/src/freeze.js +53 -8
- package/build/src/freeze.js.map +1 -1
- package/build/src/manager.d.ts +16 -1
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +116 -25
- package/build/src/manager.js.map +1 -1
- package/build/src/models/authz_assignment.d.ts +2 -2
- package/build/src/models/authz_assignment.d.ts.map +1 -1
- package/build/src/models/authz_deny.d.ts +2 -2
- package/build/src/models/authz_deny.d.ts.map +1 -1
- package/build/src/models/authz_permission.d.ts +2 -2
- package/build/src/models/authz_permission.d.ts.map +1 -1
- package/build/src/models/authz_role.d.ts +2 -2
- package/build/src/models/authz_role.d.ts.map +1 -1
- package/build/src/models/authz_role_permission.d.ts +2 -2
- package/build/src/models/authz_role_permission.d.ts.map +1 -1
- package/build/src/relation_partition_trigger.d.ts.map +1 -1
- package/build/src/relation_partition_trigger.js +11 -3
- package/build/src/relation_partition_trigger.js.map +1 -1
- package/build/src/relations/define_relations_config.d.ts +17 -0
- package/build/src/relations/define_relations_config.d.ts.map +1 -1
- package/build/src/relations/define_relations_config.js +30 -1
- package/build/src/relations/define_relations_config.js.map +1 -1
- package/build/src/relations/manager.d.ts +25 -3
- package/build/src/relations/manager.d.ts.map +1 -1
- package/build/src/relations/manager.js +76 -19
- package/build/src/relations/manager.js.map +1 -1
- package/build/src/relations/reconcile.d.ts +53 -4
- package/build/src/relations/reconcile.d.ts.map +1 -1
- package/build/src/relations/reconcile.js +92 -30
- package/build/src/relations/reconcile.js.map +1 -1
- package/build/src/scope_outbox.d.ts.map +1 -1
- package/build/src/scope_outbox.js +17 -10
- package/build/src/scope_outbox.js.map +1 -1
- package/build/src/shared/backend_guard.d.ts +28 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -1
- package/build/src/shared/backend_guard.js +52 -0
- package/build/src/shared/backend_guard.js.map +1 -1
- package/build/src/shared/transaction_guard.d.ts +50 -0
- package/build/src/shared/transaction_guard.d.ts.map +1 -0
- package/build/src/shared/transaction_guard.js +60 -0
- package/build/src/shared/transaction_guard.js.map +1 -0
- package/build/src/testing/contract.d.ts +55 -4
- package/build/src/testing/contract.d.ts.map +1 -1
- package/build/src/testing/contract.js +240 -8
- package/build/src/testing/contract.js.map +1 -1
- package/build/src/testing/main.d.ts +4 -4
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +2 -2
- package/build/src/testing/main.js.map +1 -1
- package/build/src/testing/relations_contract.d.ts +23 -1
- package/build/src/testing/relations_contract.d.ts.map +1 -1
- package/build/src/testing/relations_contract.js +432 -17
- package/build/src/testing/relations_contract.js.map +1 -1
- package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
- package/build/src/testing/relations_reconcile_contract.js +49 -1
- package/build/src/testing/relations_reconcile_contract.js.map +1 -1
- package/build/src/traits/has_uuid.d.ts +2 -2
- package/build/src/traits/has_uuid.d.ts.map +1 -1
- package/build/src/types.d.ts +197 -10
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js.map +1 -1
- package/build/stubs/migration.stub +20 -10
- package/package.json +1 -1
|
@@ -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 } 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 `relate`/`unrelate`, L-0). */
|
|
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,7 +252,11 @@ 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)) {
|
|
131
262
|
this.#assertObject(subject.object);
|
|
@@ -135,7 +266,21 @@ export class DatabaseRelationsDriver {
|
|
|
135
266
|
return { type: subject.object.type, uuid: subject.object.id, relation: subject.relation, partition: partitionKey };
|
|
136
267
|
}
|
|
137
268
|
assertSubject(subject);
|
|
138
|
-
return { type: subject.type, uuid: subject.uuid, relation:
|
|
269
|
+
return { type: subject.type, uuid: subject.uuid, relation: HOLDER_RELATION, partition: null };
|
|
270
|
+
}
|
|
271
|
+
/**
|
|
272
|
+
* El WHERE por `subject_relation`. Para un userset, su relación exacta. Para
|
|
273
|
+
* un HOLDER (`''`), **tolerante a NULL**: una fila vieja con NULL (alpha.1
|
|
274
|
+
* sin el backfill de L-4b) sigue siendo el mismo hecho, y si `unrelate`/
|
|
275
|
+
* `purgeSubject` no la vieran seguiría concediendo tras retirarla
|
|
276
|
+
* (fail-open); el «¿ya existe?» de `relate` también la encuentra, así que
|
|
277
|
+
* renovar su caducidad la sustituye por una fila con `''`.
|
|
278
|
+
*/
|
|
279
|
+
#whereSubjectRelation(b, relation) {
|
|
280
|
+
if (relation === HOLDER_RELATION)
|
|
281
|
+
b.where('subject_relation', HOLDER_RELATION).orWhereNull('subject_relation');
|
|
282
|
+
else
|
|
283
|
+
b.where('subject_relation', relation);
|
|
139
284
|
}
|
|
140
285
|
/* ── Expansión de includes (un nivel, hacia ABAJO desde la relación) ──── */
|
|
141
286
|
#expandDown(objectType, relation) {
|
|
@@ -155,17 +300,33 @@ export class DatabaseRelationsDriver {
|
|
|
155
300
|
return [...out];
|
|
156
301
|
}
|
|
157
302
|
/* ── Escrituras ───────────────────────────────────────────────────────── */
|
|
158
|
-
async relate(subject, relation, object, partition,
|
|
303
|
+
async relate(subject, relation, object, partition, options) {
|
|
304
|
+
// L-0 · F-05 en el DRIVER (la MISMA función que el manager): la red para
|
|
305
|
+
// quien entra por `manager.driver()` o por `reconcileRelations`. Corta
|
|
306
|
+
// ANTES de pedir la conexión. En `database` la tabla es propia (inocuo),
|
|
307
|
+
// pero el 422 es el MISMO que en `openfga`, donde el store es compartido.
|
|
308
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
159
309
|
assertScope(partition);
|
|
160
310
|
this.#assertObject(object);
|
|
161
311
|
this.#assertId(`la relación de '${object.type}'`, relation, RELATION_ID_MAX);
|
|
312
|
+
// R-15 (defensa en profundidad; el manager ya lo validó): los tres estados.
|
|
313
|
+
assertExpiresAt(options?.expiresAt);
|
|
314
|
+
const requested = options?.expiresAt;
|
|
162
315
|
const partitionKey = scopeKey(partition);
|
|
163
316
|
const s = this.#subjectColumns(subject, partitionKey);
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
317
|
+
const codec = this.#expiry;
|
|
318
|
+
const now = this.#now();
|
|
319
|
+
// L-4: por la transacción del llamante si llegó (`#writer`, juzgada AQUÍ,
|
|
320
|
+
// después de F-05 y la gramática y antes de la primera sentencia).
|
|
321
|
+
const { client, external } = this.#writer('relate', options);
|
|
322
|
+
// El check-then-delete-insert idempotente. Con transacción EXTERNA corre
|
|
323
|
+
// tal cual sobre ella (el trigger de partición dispara en ESE INSERT,
|
|
324
|
+
// dentro de la trx del consumidor; NUNCA se abre una interna: la fila
|
|
325
|
+
// confirmaría sola y sobreviviría al rollback del llamante). Sin ella, en
|
|
326
|
+
// una transacción INTERNA de la conexión del driver (la atomicidad
|
|
327
|
+
// trigger+insert de 4-3). Cada sentencia lleva su deadline (`#sql`).
|
|
328
|
+
const write = async (trx) => {
|
|
329
|
+
const existing = await this.#sql('relate.select', () => trx
|
|
169
330
|
.from(RELATIONS_TABLE)
|
|
170
331
|
.where('partition_key', partitionKey)
|
|
171
332
|
.where('object_type', object.type)
|
|
@@ -173,11 +334,25 @@ export class DatabaseRelationsDriver {
|
|
|
173
334
|
.where('relation', relation)
|
|
174
335
|
.where('subject_type', s.type)
|
|
175
336
|
.where('subject_uuid', s.uuid)
|
|
176
|
-
.where((b) => (
|
|
177
|
-
.
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
337
|
+
.where((b) => this.#whereSubjectRelation(b, s.relation))
|
|
338
|
+
.select('uuid', codec.select('expires_at'))
|
|
339
|
+
.limit(1));
|
|
340
|
+
const current = existing[0];
|
|
341
|
+
const previous = current ? codec.fromDb(current.expires_at) : null;
|
|
342
|
+
// Los tres estados (invariante 10): omitido preserva la VIGENTE (una
|
|
343
|
+
// caducada revive sin caducidad), null la quita, Date la fija.
|
|
344
|
+
const expiresAt = resolveGrantExpiry(previous, requested, now);
|
|
345
|
+
if (current) {
|
|
346
|
+
// Idempotente (invariante 6): la misma caducidad no reescribe nada.
|
|
347
|
+
if (sameInstant(previous, expiresAt))
|
|
348
|
+
return;
|
|
349
|
+
// **INSERT/DELETE-ONLY (decisión (c))**: cambiar la caducidad es
|
|
350
|
+
// BORRAR la fila e INSERTAR otra —nunca un UPDATE—; la fila nueva
|
|
351
|
+
// tiene otro `uuid`, que es lo que lo hace observable. Dentro de la
|
|
352
|
+
// trx del llamante, el rollback devuelve la fila (y la caducidad) VIEJA.
|
|
353
|
+
await this.#sql('relate.delete', () => trx.from(RELATIONS_TABLE).where('uuid', current.uuid).delete());
|
|
354
|
+
}
|
|
355
|
+
await this.#sql('relate.insert', () => trx.table(RELATIONS_TABLE).insert({
|
|
181
356
|
uuid: uuidv7(),
|
|
182
357
|
partition_key: partitionKey,
|
|
183
358
|
object_type: object.type,
|
|
@@ -187,26 +362,49 @@ export class DatabaseRelationsDriver {
|
|
|
187
362
|
subject_uuid: s.uuid,
|
|
188
363
|
subject_relation: s.relation,
|
|
189
364
|
subject_partition: s.partition,
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
365
|
+
expires_at: codec.toDb(expiresAt),
|
|
366
|
+
// Sello de auditoría, no decisión (2.5-B · K5): reloj del SISTEMA
|
|
367
|
+
// (con el reloj inyectado en 2099 un TIMESTAMP de MySQL reventaría).
|
|
368
|
+
created_at: systemClock(),
|
|
369
|
+
}));
|
|
370
|
+
};
|
|
371
|
+
if (external) {
|
|
372
|
+
try {
|
|
373
|
+
await write(client);
|
|
374
|
+
}
|
|
375
|
+
catch (error) {
|
|
376
|
+
this.#poisoned('relate', error);
|
|
377
|
+
}
|
|
378
|
+
return;
|
|
379
|
+
}
|
|
380
|
+
await this.#sql('relate', () => client.transaction(write));
|
|
193
381
|
}
|
|
194
|
-
async unrelate(subject, relation, object, partition,
|
|
382
|
+
async unrelate(subject, relation, object, partition, options) {
|
|
383
|
+
// L-0 · F-05 también al RETIRAR (paridad con `openfga`).
|
|
384
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
195
385
|
assertScope(partition);
|
|
196
386
|
this.#assertObject(object);
|
|
197
387
|
this.#assertId(`la relación de '${object.type}'`, relation, RELATION_ID_MAX);
|
|
198
388
|
const partitionKey = scopeKey(partition);
|
|
199
389
|
const s = this.#subjectColumns(subject, partitionKey);
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
390
|
+
const { client, external } = this.#writer('unrelate', options);
|
|
391
|
+
try {
|
|
392
|
+
await this.#sql('unrelate', () => client
|
|
393
|
+
.from(RELATIONS_TABLE)
|
|
394
|
+
.where('partition_key', partitionKey)
|
|
395
|
+
.where('object_type', object.type)
|
|
396
|
+
.where('object_uuid', object.id)
|
|
397
|
+
.where('relation', relation)
|
|
398
|
+
.where('subject_type', s.type)
|
|
399
|
+
.where('subject_uuid', s.uuid)
|
|
400
|
+
.where((b) => this.#whereSubjectRelation(b, s.relation))
|
|
401
|
+
.delete());
|
|
402
|
+
}
|
|
403
|
+
catch (error) {
|
|
404
|
+
if (external)
|
|
405
|
+
this.#poisoned('unrelate', error);
|
|
406
|
+
throw error;
|
|
407
|
+
}
|
|
210
408
|
}
|
|
211
409
|
/* ── Lecturas ─────────────────────────────────────────────────────────── */
|
|
212
410
|
async check(subject, relation, object, partition) {
|
|
@@ -217,15 +415,16 @@ export class DatabaseRelationsDriver {
|
|
|
217
415
|
const relations = this.#expandDown(object.type, relation);
|
|
218
416
|
const meta = this.#dialectMeta();
|
|
219
417
|
const q = (name) => `${meta.quote}${name}${meta.quote}`;
|
|
220
|
-
const
|
|
418
|
+
const at = this.#expiry.bind(this.#now());
|
|
419
|
+
const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
|
|
221
420
|
const relPlaceholders = relations.map(() => '?').join(', ');
|
|
222
421
|
const sql = cte +
|
|
223
422
|
` SELECT ${meta.hint}1 FROM ${q(RELATIONS_TABLE)} r ` +
|
|
224
423
|
`JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
|
|
225
424
|
`AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
|
|
226
425
|
`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];
|
|
426
|
+
`AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')} LIMIT 1`;
|
|
427
|
+
const bindings = [...cteBindings, partitionKey, object.type, object.id, ...relations, at];
|
|
229
428
|
const rows = rowsOf(await this.#raw('check', sql, bindings));
|
|
230
429
|
return rows.length > 0;
|
|
231
430
|
}
|
|
@@ -237,15 +436,16 @@ export class DatabaseRelationsDriver {
|
|
|
237
436
|
const relations = this.#expandDown(objectType, relation);
|
|
238
437
|
const meta = this.#dialectMeta();
|
|
239
438
|
const q = (name) => `${meta.quote}${name}${meta.quote}`;
|
|
240
|
-
const
|
|
439
|
+
const at = this.#expiry.bind(this.#now());
|
|
440
|
+
const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
|
|
241
441
|
const relPlaceholders = relations.map(() => '?').join(', ');
|
|
242
442
|
const sql = cte +
|
|
243
443
|
` SELECT ${meta.hint}DISTINCT r.${q('object_uuid')} AS ${q('object_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
244
444
|
`JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
|
|
245
445
|
`AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
|
|
246
446
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? ` +
|
|
247
|
-
`AND r.${q('relation')} IN (${relPlaceholders})`;
|
|
248
|
-
const bindings = [...cteBindings, partitionKey, objectType, ...relations];
|
|
447
|
+
`AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')}`;
|
|
448
|
+
const bindings = [...cteBindings, partitionKey, objectType, ...relations, at];
|
|
249
449
|
const rows = rowsOf(await this.#raw('listObjects', sql, bindings));
|
|
250
450
|
const objects = rows
|
|
251
451
|
.map((row) => ({ type: objectType, id: String(row.object_uuid) }))
|
|
@@ -258,14 +458,14 @@ export class DatabaseRelationsDriver {
|
|
|
258
458
|
const partitionKey = scopeKey(partition);
|
|
259
459
|
// DIRECTOS del relation EXACTO (invariante 7): ni transitivo (eso es
|
|
260
460
|
// `membersOf`) ni derivado por includes. `usersetSubjects: true` ⇒ los
|
|
261
|
-
// usersets (`group#member`) salen junto con los holders.
|
|
262
|
-
const
|
|
461
|
+
// usersets (`group#member`) salen junto con los holders. Solo VIGENTES (R-15).
|
|
462
|
+
const now = this.#now();
|
|
463
|
+
const rows = await this.#sql('listSubjects', () => this.#whereActive(this.#connection()
|
|
263
464
|
.from(RELATIONS_TABLE)
|
|
264
465
|
.where('partition_key', partitionKey)
|
|
265
466
|
.where('object_type', object.type)
|
|
266
467
|
.where('object_uuid', object.id)
|
|
267
|
-
.where('relation', relation)
|
|
268
|
-
.select('subject_type', 'subject_uuid', 'subject_relation'));
|
|
468
|
+
.where('relation', relation), now).select('subject_type', 'subject_uuid', 'subject_relation'));
|
|
269
469
|
const subjects = rows.map((row) => this.#rowToSubject(row));
|
|
270
470
|
return { subjects };
|
|
271
471
|
}
|
|
@@ -284,68 +484,90 @@ export class DatabaseRelationsDriver {
|
|
|
284
484
|
const meta = this.#dialectMeta();
|
|
285
485
|
const q = (name) => `${meta.quote}${name}${meta.quote}`;
|
|
286
486
|
const relPlaceholders = relations.map(() => '?').join(', ');
|
|
487
|
+
const at = this.#expiry.bind(this.#now());
|
|
488
|
+
const active = this.#activeSql(q, 'r');
|
|
287
489
|
// `grp`: los grupos alcanzables por usersets desde (object, RS) —directos y
|
|
288
490
|
// anidados—. Ancla de UN solo SELECT (MySQL no mezcla UNION ALL con la
|
|
289
491
|
// recursión): los usersets directos del objeto para cualquier rel de RS.
|
|
492
|
+
// Solo hechos VIGENTES en los cuatro tramos (R-15): una membresía caducada
|
|
493
|
+
// no lleva a nadie dentro.
|
|
290
494
|
const sql = `WITH RECURSIVE grp(g_uuid) AS ( ` +
|
|
291
495
|
`SELECT r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
292
496
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
|
|
293
497
|
`AND r.${q('relation')} IN (${relPlaceholders}) AND r.${q('subject_type')} = '${GROUP_TYPE}' ` +
|
|
294
|
-
`AND r.${q('subject_relation')} = '${GROUP_MEMBER}' ` +
|
|
498
|
+
`AND r.${q('subject_relation')} = '${GROUP_MEMBER}' AND ${active} ` +
|
|
295
499
|
`UNION ` +
|
|
296
500
|
`SELECT r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
297
501
|
`JOIN grp ON r.${q('object_uuid')} = grp.g_uuid ` +
|
|
298
502
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = '${GROUP_TYPE}' ` +
|
|
299
503
|
`AND r.${q('relation')} = '${GROUP_MEMBER}' AND r.${q('subject_type')} = '${GROUP_TYPE}' ` +
|
|
300
|
-
`AND r.${q('subject_relation')} = '${GROUP_MEMBER}' ` +
|
|
504
|
+
`AND r.${q('subject_relation')} = '${GROUP_MEMBER}' AND ${active} ` +
|
|
301
505
|
`) ` +
|
|
302
506
|
// Holders directos del objeto para RS, UNION holders directos de cualquier grupo de `grp`.
|
|
303
507
|
`SELECT ${meta.hint}${q('subject_type')} AS ${q('subject_type')}, ${q('subject_uuid')} AS ${q('subject_uuid')} ` +
|
|
304
508
|
`FROM ${q(RELATIONS_TABLE)} r ` +
|
|
305
509
|
`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')}
|
|
510
|
+
`AND r.${q('relation')} IN (${relPlaceholders}) AND COALESCE(r.${q('subject_relation')}, '') = '' AND ${active} ` +
|
|
307
511
|
`UNION ` +
|
|
308
512
|
`SELECT r.${q('subject_type')}, r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
309
513
|
`JOIN grp ON r.${q('object_uuid')} = grp.g_uuid ` +
|
|
310
514
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = '${GROUP_TYPE}' ` +
|
|
311
|
-
`AND r.${q('relation')} = '${GROUP_MEMBER}' AND r.${q('subject_relation')}
|
|
515
|
+
`AND r.${q('relation')} = '${GROUP_MEMBER}' AND COALESCE(r.${q('subject_relation')}, '') = '' AND ${active}`;
|
|
312
516
|
const bindings = [
|
|
313
517
|
// grp ancla
|
|
314
|
-
partitionKey, object.type, object.id, ...relations,
|
|
518
|
+
partitionKey, object.type, object.id, ...relations, at,
|
|
315
519
|
// grp recursiva
|
|
316
|
-
partitionKey,
|
|
520
|
+
partitionKey, at,
|
|
317
521
|
// holders directos del objeto
|
|
318
|
-
partitionKey, object.type, object.id, ...relations,
|
|
522
|
+
partitionKey, object.type, object.id, ...relations, at,
|
|
319
523
|
// holders de los grupos
|
|
320
|
-
partitionKey,
|
|
524
|
+
partitionKey, at,
|
|
321
525
|
];
|
|
322
526
|
const rows = rowsOf(await this.#raw('membersOf', sql, bindings));
|
|
323
527
|
const subjects = rows.map((row) => ({ type: String(row.subject_type), uuid: String(row.subject_uuid) }));
|
|
324
528
|
return { subjects };
|
|
325
529
|
}
|
|
326
530
|
/* ── Purga (invariante 11): el DELETE demuestra el cero ───────────────── */
|
|
327
|
-
async purgeObject(object, partition) {
|
|
531
|
+
async purgeObject(object, partition, options) {
|
|
328
532
|
assertScope(partition);
|
|
329
533
|
this.#assertObject(object);
|
|
330
534
|
const partitionKeys = this.#partitionSpellings(partition);
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
535
|
+
// L-4: por la transacción del llamante si llegó — la purga borra y
|
|
536
|
+
// REVIERTE con ella (tras un rollback todo lo purgado está de vuelta).
|
|
537
|
+
const { client, external } = this.#writer('purgeObject', options);
|
|
538
|
+
try {
|
|
539
|
+
await this.#sql('purgeObject', () => client
|
|
540
|
+
.from(RELATIONS_TABLE)
|
|
541
|
+
.whereIn('partition_key', partitionKeys)
|
|
542
|
+
.where('object_type', object.type)
|
|
543
|
+
.where('object_uuid', object.id)
|
|
544
|
+
.delete());
|
|
545
|
+
}
|
|
546
|
+
catch (error) {
|
|
547
|
+
if (external)
|
|
548
|
+
this.#poisoned('purgeObject', error);
|
|
549
|
+
throw error;
|
|
550
|
+
}
|
|
337
551
|
}
|
|
338
|
-
async purgeSubject(subject, partition) {
|
|
552
|
+
async purgeSubject(subject, partition, options) {
|
|
339
553
|
assertScope(partition);
|
|
340
554
|
const partitionKeys = this.#partitionSpellings(partition);
|
|
341
555
|
const s = this.#subjectColumns(subject, scopeKey(partition));
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
556
|
+
const { client, external } = this.#writer('purgeSubject', options);
|
|
557
|
+
try {
|
|
558
|
+
await this.#sql('purgeSubject', () => client
|
|
559
|
+
.from(RELATIONS_TABLE)
|
|
560
|
+
.whereIn('partition_key', partitionKeys)
|
|
561
|
+
.where('subject_type', s.type)
|
|
562
|
+
.where('subject_uuid', s.uuid)
|
|
563
|
+
.where((b) => this.#whereSubjectRelation(b, s.relation))
|
|
564
|
+
.delete());
|
|
565
|
+
}
|
|
566
|
+
catch (error) {
|
|
567
|
+
if (external)
|
|
568
|
+
this.#poisoned('purgeSubject', error);
|
|
569
|
+
throw error;
|
|
570
|
+
}
|
|
349
571
|
}
|
|
350
572
|
/* ── ORIGEN de reconcile (4-5): enumera los hechos directos ───────────── */
|
|
351
573
|
/**
|
|
@@ -354,8 +576,9 @@ export class DatabaseRelationsDriver {
|
|
|
354
576
|
* paginados por la PK (`uuid`, cursor que AVANZA), sin derivar por
|
|
355
577
|
* includes/usersets (el modelo del destino lo recompone): el destino recibe
|
|
356
578
|
* los hechos tal cual y decide qué escribe (invariante 7 + la higiene de
|
|
357
|
-
* reconcile —
|
|
358
|
-
*
|
|
579
|
+
* reconcile — **la caducada LLEGA con su `expiresAt`**, R-15, para contarse
|
|
580
|
+
* en `skipped`; filtrarla aquí la haría desaparecer sin rastro). Barre las
|
|
581
|
+
* DOS ortografías del uuid de partición (🟡2,
|
|
359
582
|
* coherente con `purge*`): un origen que las funde no puede dejar hechos del
|
|
360
583
|
* alias sin migrar.
|
|
361
584
|
*
|
|
@@ -383,7 +606,7 @@ export class DatabaseRelationsDriver {
|
|
|
383
606
|
.limit(limit + 1);
|
|
384
607
|
if (after)
|
|
385
608
|
q = q.where('uuid', '>', after);
|
|
386
|
-
return q.select('uuid', 'object_type', 'object_uuid', 'relation', 'subject_type', 'subject_uuid', 'subject_relation');
|
|
609
|
+
return q.select('uuid', 'object_type', 'object_uuid', 'relation', 'subject_type', 'subject_uuid', 'subject_relation', this.#expiry.select('expires_at'));
|
|
387
610
|
});
|
|
388
611
|
const hasMore = rows.length > limit;
|
|
389
612
|
const pageRows = hasMore ? rows.slice(0, limit) : rows;
|
|
@@ -393,6 +616,7 @@ export class DatabaseRelationsDriver {
|
|
|
393
616
|
object: { type: String(row.object_type), id: String(row.object_uuid) },
|
|
394
617
|
// La partición canónica pedida: el barrido de ortografías ya la unificó.
|
|
395
618
|
partition,
|
|
619
|
+
expiresAt: this.#expiry.fromDb(row.expires_at),
|
|
396
620
|
}));
|
|
397
621
|
const cursor = hasMore ? String(pageRows[pageRows.length - 1].uuid) : undefined;
|
|
398
622
|
return cursor ? { tuples, cursor } : { tuples };
|
|
@@ -417,8 +641,12 @@ export class DatabaseRelationsDriver {
|
|
|
417
641
|
* de esas identidades. Devuelve el `WITH RECURSIVE …` (sin el SELECT final) y
|
|
418
642
|
* sus bindings.
|
|
419
643
|
*/
|
|
420
|
-
#principalCte(meta, q, s, partitionKey) {
|
|
421
|
-
|
|
644
|
+
#principalCte(meta, q, s, partitionKey, at) {
|
|
645
|
+
// L-4b: el holder ya viene con `''`; el `COALESCE(subject_relation, '')`
|
|
646
|
+
// del JOIN es la tolerancia a una fila vieja con NULL.
|
|
647
|
+
const baseRel = s.relation;
|
|
648
|
+
// Solo membresías VIGENTES suben por la recursión (R-15): una `member`
|
|
649
|
+
// caducada no convierte al sujeto en miembro de nada.
|
|
422
650
|
const cte = `WITH RECURSIVE principal(p_type, p_uuid, p_rel) AS ( ` +
|
|
423
651
|
`SELECT ${meta.text('?')}, ${meta.text('?')}, ${meta.text('?')} ` +
|
|
424
652
|
`UNION ` +
|
|
@@ -427,11 +655,12 @@ export class DatabaseRelationsDriver {
|
|
|
427
655
|
`JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
|
|
428
656
|
`AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
|
|
429
657
|
`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] };
|
|
658
|
+
`AND r.${q('relation')} = '${GROUP_MEMBER}' AND ${this.#activeSql(q, 'r')} )`;
|
|
659
|
+
return { cte, bindings: [s.type, s.uuid, baseRel, partitionKey, at] };
|
|
432
660
|
}
|
|
661
|
+
/** `''` (L-4b) o NULL (fila vieja sin backfill) ⇒ holder; con valor ⇒ userset. */
|
|
433
662
|
#rowToSubject(row) {
|
|
434
|
-
if (row.subject_relation === null || row.subject_relation === undefined) {
|
|
663
|
+
if (row.subject_relation === null || row.subject_relation === undefined || row.subject_relation === HOLDER_RELATION) {
|
|
435
664
|
return { type: String(row.subject_type), uuid: String(row.subject_uuid) };
|
|
436
665
|
}
|
|
437
666
|
return { object: { type: String(row.subject_type), id: String(row.subject_uuid) }, relation: String(row.subject_relation) };
|