@jantstack/adonis-authz 2.0.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 +693 -36
- package/build/commands/authz_catalog_diff.js +1 -1
- package/build/commands/authz_catalog_diff.js.map +1 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
- package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
- package/build/commands/authz_catalog_prune_orphans.js +136 -0
- package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
- package/build/commands/authz_catalog_sync.d.ts +17 -0
- package/build/commands/authz_catalog_sync.d.ts.map +1 -1
- package/build/commands/authz_catalog_sync.js +27 -4
- package/build/commands/authz_catalog_sync.js.map +1 -1
- package/build/commands/authz_freeze.d.ts +44 -0
- package/build/commands/authz_freeze.d.ts.map +1 -0
- package/build/commands/authz_freeze.js +95 -0
- package/build/commands/authz_freeze.js.map +1 -0
- package/build/commands/authz_reconcile.d.ts +102 -0
- package/build/commands/authz_reconcile.d.ts.map +1 -0
- package/build/commands/authz_reconcile.js +294 -0
- package/build/commands/authz_reconcile.js.map +1 -0
- package/build/commands/authz_relations_reconcile.d.ts +97 -0
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
- package/build/commands/authz_relations_reconcile.js +313 -0
- package/build/commands/authz_relations_reconcile.js.map +1 -0
- package/build/commands/authz_scopes_relay.d.ts +47 -0
- package/build/commands/authz_scopes_relay.d.ts.map +1 -0
- package/build/commands/authz_scopes_relay.js +141 -0
- package/build/commands/authz_scopes_relay.js.map +1 -0
- package/build/commands/authz_unfreeze.d.ts +37 -0
- package/build/commands/authz_unfreeze.d.ts.map +1 -0
- package/build/commands/authz_unfreeze.js +92 -0
- package/build/commands/authz_unfreeze.js.map +1 -0
- package/build/commands/main.d.ts +6 -1
- package/build/commands/main.d.ts.map +1 -1
- package/build/commands/main.js +6 -1
- package/build/commands/main.js.map +1 -1
- package/build/commands/openfga_provision.d.ts +46 -4
- package/build/commands/openfga_provision.d.ts.map +1 -1
- package/build/commands/openfga_provision.js +90 -7
- package/build/commands/openfga_provision.js.map +1 -1
- package/build/configure.d.ts +11 -0
- package/build/configure.d.ts.map +1 -1
- package/build/configure.js +37 -1
- package/build/configure.js.map +1 -1
- package/build/index.d.ts +50 -10
- package/build/index.d.ts.map +1 -1
- package/build/index.js +45 -6
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts +26 -2
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +55 -2
- package/build/providers/authz_provider.js.map +1 -1
- package/build/services/relations.d.ts +14 -0
- package/build/services/relations.d.ts.map +1 -0
- package/build/services/relations.js +17 -0
- package/build/services/relations.js.map +1 -0
- package/build/src/{catalog.d.ts → catalog/catalog.d.ts} +41 -2
- package/build/src/catalog/catalog.d.ts.map +1 -0
- package/build/src/{catalog.js → catalog/catalog.js} +120 -14
- package/build/src/catalog/catalog.js.map +1 -0
- package/build/src/{catalog_cache.d.ts → catalog/catalog_cache.d.ts} +46 -22
- package/build/src/catalog/catalog_cache.d.ts.map +1 -0
- package/build/src/{catalog_cache.js → catalog/catalog_cache.js} +53 -43
- package/build/src/catalog/catalog_cache.js.map +1 -0
- package/build/src/define_config.d.ts +137 -3
- 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 +128 -7
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +510 -24
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +113 -0
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
- package/build/src/drivers/database_relations_driver.js +679 -0
- package/build/src/drivers/database_relations_driver.js.map +1 -0
- package/build/src/drivers/openfga_driver.d.ts +729 -122
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +2083 -476
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_facts.d.ts +384 -0
- package/build/src/drivers/openfga_facts.d.ts.map +1 -0
- package/build/src/drivers/openfga_facts.js +836 -0
- package/build/src/drivers/openfga_facts.js.map +1 -0
- package/build/src/drivers/openfga_relations_driver.d.ts +141 -0
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
- package/build/src/drivers/openfga_relations_driver.js +590 -0
- package/build/src/drivers/openfga_relations_driver.js.map +1 -0
- package/build/src/errors.d.ts +290 -5
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +296 -7
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +141 -0
- package/build/src/freeze.d.ts.map +1 -0
- package/build/src/freeze.js +217 -0
- package/build/src/freeze.js.map +1 -0
- package/build/src/http/app_access_middleware.d.ts.map +1 -0
- package/build/src/http/app_access_middleware.js.map +1 -0
- package/build/src/http/resource_access_middleware.d.ts +105 -0
- package/build/src/http/resource_access_middleware.d.ts.map +1 -0
- package/build/src/http/resource_access_middleware.js +81 -0
- package/build/src/http/resource_access_middleware.js.map +1 -0
- package/build/src/identity.d.ts +74 -1
- package/build/src/identity.d.ts.map +1 -1
- package/build/src/identity.js +100 -2
- package/build/src/identity.js.map +1 -1
- package/build/src/manager.d.ts +330 -4
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +1285 -188
- package/build/src/manager.js.map +1 -1
- package/build/src/models/authz_assignment.d.ts +7 -7
- package/build/src/models/authz_assignment.d.ts.map +1 -1
- package/build/src/models/authz_deny.d.ts +7 -7
- package/build/src/models/authz_deny.d.ts.map +1 -1
- package/build/src/models/authz_permission.d.ts +7 -7
- package/build/src/models/authz_permission.d.ts.map +1 -1
- package/build/src/models/authz_role.d.ts +7 -7
- package/build/src/models/authz_role.d.ts.map +1 -1
- package/build/src/models/authz_role_permission.d.ts +7 -7
- package/build/src/models/authz_role_permission.d.ts.map +1 -1
- package/build/src/openfga.d.ts +18 -2
- package/build/src/openfga.d.ts.map +1 -1
- package/build/src/openfga.js +15 -1
- package/build/src/openfga.js.map +1 -1
- package/build/src/reconcile.d.ts +37 -0
- package/build/src/reconcile.d.ts.map +1 -0
- package/build/src/reconcile.js +69 -0
- package/build/src/reconcile.js.map +1 -0
- package/build/src/relation_partition_trigger.d.ts +8 -0
- package/build/src/relation_partition_trigger.d.ts.map +1 -0
- package/build/src/relation_partition_trigger.js +93 -0
- package/build/src/relation_partition_trigger.js.map +1 -0
- package/build/src/relations/define_relations_config.d.ts +75 -0
- package/build/src/relations/define_relations_config.d.ts.map +1 -0
- package/build/src/relations/define_relations_config.js +173 -0
- package/build/src/relations/define_relations_config.js.map +1 -0
- package/build/src/relations/manager.d.ts +60 -0
- package/build/src/relations/manager.d.ts.map +1 -0
- package/build/src/relations/manager.js +213 -0
- package/build/src/relations/manager.js.map +1 -0
- package/build/src/relations/reconcile.d.ts +111 -0
- package/build/src/relations/reconcile.d.ts.map +1 -0
- package/build/src/relations/reconcile.js +200 -0
- package/build/src/relations/reconcile.js.map +1 -0
- package/build/src/relations_config_store.d.ts +22 -0
- package/build/src/relations_config_store.d.ts.map +1 -0
- package/build/src/relations_config_store.js +74 -0
- package/build/src/relations_config_store.js.map +1 -0
- package/build/src/scope_outbox.d.ts +69 -0
- package/build/src/scope_outbox.d.ts.map +1 -0
- package/build/src/scope_outbox.js +298 -0
- package/build/src/scope_outbox.js.map +1 -0
- package/build/src/{drivers → shared}/backend_guard.d.ts +42 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -0
- package/build/src/{drivers → shared}/backend_guard.js +78 -1
- package/build/src/shared/backend_guard.js.map +1 -0
- package/build/src/shared/sql_expiry.d.ts.map +1 -0
- package/build/src/shared/sql_expiry.js.map +1 -0
- 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/sql_descendants.d.ts +47 -1
- package/build/src/sql_descendants.d.ts.map +1 -1
- package/build/src/sql_descendants.js +75 -1
- package/build/src/sql_descendants.js.map +1 -1
- package/build/src/testing/contract.d.ts +129 -4
- package/build/src/testing/contract.d.ts.map +1 -1
- package/build/src/testing/contract.js +912 -177
- package/build/src/testing/contract.js.map +1 -1
- package/build/src/testing/main.d.ts +8 -2
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +4 -1
- package/build/src/testing/main.js.map +1 -1
- package/build/src/testing/migration_contract.d.ts +284 -0
- package/build/src/testing/migration_contract.d.ts.map +1 -0
- package/build/src/testing/migration_contract.js +586 -0
- package/build/src/testing/migration_contract.js.map +1 -0
- package/build/src/testing/relations_contract.d.ts +73 -0
- package/build/src/testing/relations_contract.d.ts.map +1 -0
- package/build/src/testing/relations_contract.js +1069 -0
- package/build/src/testing/relations_contract.js.map +1 -0
- package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
- package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
- package/build/src/testing/relations_reconcile_contract.js +220 -0
- package/build/src/testing/relations_reconcile_contract.js.map +1 -0
- package/build/src/traits/authz_scopes.js +1 -1
- package/build/src/traits/authz_scopes.js.map +1 -1
- package/build/src/traits/has_uuid.d.ts +8 -8
- package/build/src/traits/has_uuid.d.ts.map +1 -1
- package/build/src/types.d.ts +1053 -83
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js +10 -0
- package/build/src/types.js.map +1 -1
- package/build/stubs/config/authorization.stub +104 -4
- package/build/stubs/migration.stub +136 -0
- package/build/stubs/scopes_outbox_migration.stub +57 -0
- package/package.json +4 -2
- package/build/commands/openfga_import.d.ts +0 -34
- package/build/commands/openfga_import.d.ts.map +0 -1
- package/build/commands/openfga_import.js +0 -97
- package/build/commands/openfga_import.js.map +0 -1
- package/build/src/catalog.d.ts.map +0 -1
- package/build/src/catalog.js.map +0 -1
- package/build/src/catalog_cache.d.ts.map +0 -1
- package/build/src/catalog_cache.js.map +0 -1
- package/build/src/drivers/backend_guard.d.ts.map +0 -1
- package/build/src/drivers/backend_guard.js.map +0 -1
- package/build/src/drivers/sql_expiry.d.ts.map +0 -1
- package/build/src/drivers/sql_expiry.js.map +0 -1
- package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
- package/build/src/middleware/app_access_middleware.js.map +0 -1
- /package/build/src/{middleware → http}/app_access_middleware.d.ts +0 -0
- /package/build/src/{middleware → http}/app_access_middleware.js +0 -0
- /package/build/src/{drivers → shared}/sql_expiry.d.ts +0 -0
- /package/build/src/{drivers → shared}/sql_expiry.js +0 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Driver `database` del puerto `RelationsDriver` — ReBAC sobre SQL propio
|
|
3
|
+
* (Fase 4, lote 4-3).
|
|
4
|
+
*
|
|
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). **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).
|
|
20
|
+
*
|
|
21
|
+
* Pureza: este módulo vive en `drivers/` y NO importa `openfga` (regla 3 de
|
|
22
|
+
* `check_purity.mjs`) ni el `manager`. Consume solo el puerto (`../types.js`),
|
|
23
|
+
* los errores, la gramática compartida (`../identity.js`), el guard SQL y el
|
|
24
|
+
* TIPO de la config de relaciones (`../relations/define_relations_config.js`).
|
|
25
|
+
*/
|
|
26
|
+
import db from '@adonisjs/lucid/services/db';
|
|
27
|
+
import { v7 as uuidv7 } from 'uuid';
|
|
28
|
+
import { AuthorizationConfigError, InvalidIdentityError, UnsupportedDialectError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
|
|
29
|
+
import { assertScope, assertSubject, assertExpiresAt, scopeKey, scopeSpellings } from '../identity.js';
|
|
30
|
+
import { relationPartitionTrigger, relationPartitionTriggerDrops } from '../relation_partition_trigger.js';
|
|
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';
|
|
36
|
+
import { isRelUserset } from '../types.js';
|
|
37
|
+
import { assertRelationDeclared } from '../relations/define_relations_config.js';
|
|
38
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
39
|
+
const RELATIONS_TABLE = 'authz_relations';
|
|
40
|
+
/** Longitud máxima del id de un objeto/holder de relación (columna `varchar(64)`). */
|
|
41
|
+
const RELATION_ID_MAX = 64;
|
|
42
|
+
/** El tipo BUILT-IN portador de usersets (declarado por el generador, no por el consumidor). */
|
|
43
|
+
const GROUP_TYPE = 'group';
|
|
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 = '';
|
|
54
|
+
/** Tamaño de página por defecto y tope de `enumerateRelations` (origen de reconcile). */
|
|
55
|
+
const DEFAULT_ENUMERATE_LIMIT = 100;
|
|
56
|
+
const MAX_ENUMERATE_LIMIT = 1_000;
|
|
57
|
+
/** Formato de id de relación: minúsculas, dígitos y `._-`; sin separadores del store. */
|
|
58
|
+
const RELATION_ID_FORMAT = /^[a-z0-9_.-]+$/;
|
|
59
|
+
const DIALECTS = {
|
|
60
|
+
postgres: { quote: '"', text: (e) => `CAST(${e} AS varchar(64))`, hint: '' },
|
|
61
|
+
sqlite3: { quote: '"', text: (e) => `CAST(${e} AS text)`, hint: '' },
|
|
62
|
+
'better-sqlite3': { quote: '"', text: (e) => `CAST(${e} AS text)`, hint: '' },
|
|
63
|
+
mysql: { quote: '`', text: (e) => `CAST(${e} AS CHAR(64))`, hint: '/*+ SET_VAR(cte_max_recursion_depth = 50000) */ ' },
|
|
64
|
+
mysql2: { quote: '`', text: (e) => `CAST(${e} AS CHAR(64))`, hint: '/*+ SET_VAR(cte_max_recursion_depth = 50000) */ ' },
|
|
65
|
+
};
|
|
66
|
+
// La DDL del trigger de partición vive en el módulo PURO
|
|
67
|
+
// `../relation_partition_trigger.js` (sin dependencia del servicio `db`, para
|
|
68
|
+
// que lo importe también el espejo del esquema, cargado antes de bootear la
|
|
69
|
+
// app). Se re-exporta aquí por comodidad del consumidor del driver.
|
|
70
|
+
export { relationPartitionTrigger, relationPartitionTriggerDrops };
|
|
71
|
+
/**
|
|
72
|
+
* El driver `database` de relaciones. Toda consulta pasa por `guardSql`: un
|
|
73
|
+
* fallo del cliente SQL sale como `AuthorizationBackendError` (503), nunca como
|
|
74
|
+
* el error crudo de knex (invariante 5).
|
|
75
|
+
*/
|
|
76
|
+
export class DatabaseRelationsDriver {
|
|
77
|
+
/**
|
|
78
|
+
* Lo que declara (cada valor tiene su cara en `runRelationsDriverContract`):
|
|
79
|
+
* - `singleCheckRelations`: `check` es UNA consulta (la CTE resuelve includes
|
|
80
|
+
* + usersets anidados en un round-trip).
|
|
81
|
+
* - `listObjectsInherited: false`: los `list*` no abren un subárbol (invariante 7).
|
|
82
|
+
* - `usersetSubjects`: `listSubjects` incluye los usersets directos (`group#member`).
|
|
83
|
+
* - `membersOfNative`: la membresía TRANSITIVA la resuelve la CTE recursiva.
|
|
84
|
+
* - `enumerateRelations: true` (4-5): es ORIGEN de `authz:reconcile` de
|
|
85
|
+
* relaciones — enumera los hechos DIRECTOS de la partición (`authz_relations`),
|
|
86
|
+
* paginados por la PK, sin filtrar ni derivar (invariante 7 + higiene de
|
|
87
|
+
* reconcile): la caña la ve el destino tal cual la escribió el origen.
|
|
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).
|
|
96
|
+
*/
|
|
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;
|
|
104
|
+
#config;
|
|
105
|
+
#connectionName;
|
|
106
|
+
#timeoutMs;
|
|
107
|
+
#now;
|
|
108
|
+
/** Inyectable para probar el dialecto ajeno sin servidor. */
|
|
109
|
+
#database;
|
|
110
|
+
#expiryCodec;
|
|
111
|
+
constructor(config, options = {}, database = db) {
|
|
112
|
+
this.#config = config;
|
|
113
|
+
this.#connectionName = options.connection;
|
|
114
|
+
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
115
|
+
this.#now = options.now ?? systemClock;
|
|
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);
|
|
140
|
+
}
|
|
141
|
+
/* ── Infraestructura ──────────────────────────────────────────────────── */
|
|
142
|
+
#connection() {
|
|
143
|
+
return this.#database.connection(this.#connectionName);
|
|
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
|
+
}
|
|
164
|
+
#dialectMeta() {
|
|
165
|
+
const connection = this.#connection();
|
|
166
|
+
const name = connection?.dialect?.name ?? connection?.client?.driverName ?? 'desconocido';
|
|
167
|
+
const meta = DIALECTS[name];
|
|
168
|
+
if (!meta) {
|
|
169
|
+
throw new UnsupportedDialectError(`DatabaseRelationsDriver: dialecto '${name}' sin observación en la suite ` +
|
|
170
|
+
`(hoy: PostgreSQL, MySQL 8 y SQLite). La CTE recursiva del check no se declara igual en dos ` +
|
|
171
|
+
`motores; impleméntala a mano para este motor.`);
|
|
172
|
+
}
|
|
173
|
+
return meta;
|
|
174
|
+
}
|
|
175
|
+
#sql(operation, fn) {
|
|
176
|
+
return guardSql('database-relations', operation, this.#timeoutMs, fn);
|
|
177
|
+
}
|
|
178
|
+
#raw(operation, text, bindings) {
|
|
179
|
+
return this.#sql(operation, () => this.#connection().rawQuery(text, bindings));
|
|
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
|
+
}
|
|
240
|
+
/* ── Validación de identidad (defensa en profundidad) ─────────────────── */
|
|
241
|
+
/** El objeto es `{ type, id }` bien formado (F-05 —tipo/relación declarados— la aplica `assertRelationDeclared` en `relate`/`unrelate`, L-0). */
|
|
242
|
+
#assertObject(object) {
|
|
243
|
+
if (!object || typeof object !== 'object') {
|
|
244
|
+
throw new InvalidIdentityError(`Objeto de relación inválido: llegó ${typeof object}`);
|
|
245
|
+
}
|
|
246
|
+
this.#assertId('el tipo de objeto', object.type, RELATION_ID_MAX);
|
|
247
|
+
this.#assertId(`el id del objeto '${object.type}'`, object.id, RELATION_ID_MAX);
|
|
248
|
+
}
|
|
249
|
+
#assertId(kind, value, max) {
|
|
250
|
+
if (typeof value !== 'string' || value.length === 0 || value.length > max || !RELATION_ID_FORMAT.test(value)) {
|
|
251
|
+
throw new InvalidIdentityError(`${kind} inválido: se esperaba una cadena de 1-${max} caracteres en minúsculas, dígitos y '._-' ` +
|
|
252
|
+
`(llegó ${JSON.stringify(value)})`);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
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
|
+
*/
|
|
260
|
+
#subjectColumns(subject, partitionKey) {
|
|
261
|
+
if (isRelUserset(subject)) {
|
|
262
|
+
this.#assertObject(subject.object);
|
|
263
|
+
this.#assertId(`la relación del userset '${subject.object.type}'`, subject.relation, RELATION_ID_MAX);
|
|
264
|
+
// El userset comparte la partición de la tupla (el manager así lo exige);
|
|
265
|
+
// se materializa en `subject_partition` para que el trigger lo defienda.
|
|
266
|
+
return { type: subject.object.type, uuid: subject.object.id, relation: subject.relation, partition: partitionKey };
|
|
267
|
+
}
|
|
268
|
+
assertSubject(subject);
|
|
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);
|
|
284
|
+
}
|
|
285
|
+
/* ── Expansión de includes (un nivel, hacia ABAJO desde la relación) ──── */
|
|
286
|
+
#expandDown(objectType, relation) {
|
|
287
|
+
const out = new Set([relation]);
|
|
288
|
+
const type = this.#config.objectTypes.find((t) => t.type === objectType);
|
|
289
|
+
const queue = [relation];
|
|
290
|
+
while (queue.length) {
|
|
291
|
+
const current = queue.pop();
|
|
292
|
+
const def = type?.relations.find((r) => r.name === current);
|
|
293
|
+
for (const inc of def?.includes ?? []) {
|
|
294
|
+
if (!out.has(inc)) {
|
|
295
|
+
out.add(inc);
|
|
296
|
+
queue.push(inc);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
return [...out];
|
|
301
|
+
}
|
|
302
|
+
/* ── Escrituras ───────────────────────────────────────────────────────── */
|
|
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);
|
|
309
|
+
assertScope(partition);
|
|
310
|
+
this.#assertObject(object);
|
|
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;
|
|
315
|
+
const partitionKey = scopeKey(partition);
|
|
316
|
+
const s = this.#subjectColumns(subject, partitionKey);
|
|
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
|
|
330
|
+
.from(RELATIONS_TABLE)
|
|
331
|
+
.where('partition_key', partitionKey)
|
|
332
|
+
.where('object_type', object.type)
|
|
333
|
+
.where('object_uuid', object.id)
|
|
334
|
+
.where('relation', relation)
|
|
335
|
+
.where('subject_type', s.type)
|
|
336
|
+
.where('subject_uuid', s.uuid)
|
|
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({
|
|
356
|
+
uuid: uuidv7(),
|
|
357
|
+
partition_key: partitionKey,
|
|
358
|
+
object_type: object.type,
|
|
359
|
+
object_uuid: object.id,
|
|
360
|
+
relation,
|
|
361
|
+
subject_type: s.type,
|
|
362
|
+
subject_uuid: s.uuid,
|
|
363
|
+
subject_relation: s.relation,
|
|
364
|
+
subject_partition: s.partition,
|
|
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));
|
|
381
|
+
}
|
|
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);
|
|
385
|
+
assertScope(partition);
|
|
386
|
+
this.#assertObject(object);
|
|
387
|
+
this.#assertId(`la relación de '${object.type}'`, relation, RELATION_ID_MAX);
|
|
388
|
+
const partitionKey = scopeKey(partition);
|
|
389
|
+
const s = this.#subjectColumns(subject, partitionKey);
|
|
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
|
+
}
|
|
408
|
+
}
|
|
409
|
+
/* ── Lecturas ─────────────────────────────────────────────────────────── */
|
|
410
|
+
async check(subject, relation, object, partition) {
|
|
411
|
+
assertScope(partition);
|
|
412
|
+
this.#assertObject(object);
|
|
413
|
+
const partitionKey = scopeKey(partition);
|
|
414
|
+
const s = this.#subjectColumns(subject, partitionKey);
|
|
415
|
+
const relations = this.#expandDown(object.type, relation);
|
|
416
|
+
const meta = this.#dialectMeta();
|
|
417
|
+
const q = (name) => `${meta.quote}${name}${meta.quote}`;
|
|
418
|
+
const at = this.#expiry.bind(this.#now());
|
|
419
|
+
const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
|
|
420
|
+
const relPlaceholders = relations.map(() => '?').join(', ');
|
|
421
|
+
const sql = cte +
|
|
422
|
+
` SELECT ${meta.hint}1 FROM ${q(RELATIONS_TABLE)} r ` +
|
|
423
|
+
`JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
|
|
424
|
+
`AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
|
|
425
|
+
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
|
|
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];
|
|
428
|
+
const rows = rowsOf(await this.#raw('check', sql, bindings));
|
|
429
|
+
return rows.length > 0;
|
|
430
|
+
}
|
|
431
|
+
async listObjects(subject, relation, objectType, partition, _page) {
|
|
432
|
+
assertScope(partition);
|
|
433
|
+
this.#assertId('el tipo de objeto', objectType, RELATION_ID_MAX);
|
|
434
|
+
const partitionKey = scopeKey(partition);
|
|
435
|
+
const s = this.#subjectColumns(subject, partitionKey);
|
|
436
|
+
const relations = this.#expandDown(objectType, relation);
|
|
437
|
+
const meta = this.#dialectMeta();
|
|
438
|
+
const q = (name) => `${meta.quote}${name}${meta.quote}`;
|
|
439
|
+
const at = this.#expiry.bind(this.#now());
|
|
440
|
+
const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
|
|
441
|
+
const relPlaceholders = relations.map(() => '?').join(', ');
|
|
442
|
+
const sql = cte +
|
|
443
|
+
` SELECT ${meta.hint}DISTINCT r.${q('object_uuid')} AS ${q('object_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
444
|
+
`JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
|
|
445
|
+
`AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
|
|
446
|
+
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? ` +
|
|
447
|
+
`AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')}`;
|
|
448
|
+
const bindings = [...cteBindings, partitionKey, objectType, ...relations, at];
|
|
449
|
+
const rows = rowsOf(await this.#raw('listObjects', sql, bindings));
|
|
450
|
+
const objects = rows
|
|
451
|
+
.map((row) => ({ type: objectType, id: String(row.object_uuid) }))
|
|
452
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
453
|
+
return { objects };
|
|
454
|
+
}
|
|
455
|
+
async listSubjects(relation, object, partition, _page) {
|
|
456
|
+
assertScope(partition);
|
|
457
|
+
this.#assertObject(object);
|
|
458
|
+
const partitionKey = scopeKey(partition);
|
|
459
|
+
// DIRECTOS del relation EXACTO (invariante 7): ni transitivo (eso es
|
|
460
|
+
// `membersOf`) ni derivado por includes. `usersetSubjects: true` ⇒ los
|
|
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()
|
|
464
|
+
.from(RELATIONS_TABLE)
|
|
465
|
+
.where('partition_key', partitionKey)
|
|
466
|
+
.where('object_type', object.type)
|
|
467
|
+
.where('object_uuid', object.id)
|
|
468
|
+
.where('relation', relation), now).select('subject_type', 'subject_uuid', 'subject_relation'));
|
|
469
|
+
const subjects = rows.map((row) => this.#rowToSubject(row));
|
|
470
|
+
return { subjects };
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Membresía TRANSITIVA de un objeto-grupo: todos los HOLDERS que son `member`
|
|
474
|
+
* directa o a través de grupos anidados (usersets `group#member`). CTE
|
|
475
|
+
* recursiva hacia ABAJO por los usersets. DISTINTA de `listSubjects` (hechos
|
|
476
|
+
* directos): en un grupo con `member(g1#member, g2)`, `membersOf(g2)` trae a
|
|
477
|
+
* los holders de g1 y `listSubjects(member, g2)` solo el userset `g1#member`.
|
|
478
|
+
*/
|
|
479
|
+
async membersOf(object, relation, partition, _page) {
|
|
480
|
+
assertScope(partition);
|
|
481
|
+
this.#assertObject(object);
|
|
482
|
+
const partitionKey = scopeKey(partition);
|
|
483
|
+
const relations = this.#expandDown(object.type, relation);
|
|
484
|
+
const meta = this.#dialectMeta();
|
|
485
|
+
const q = (name) => `${meta.quote}${name}${meta.quote}`;
|
|
486
|
+
const relPlaceholders = relations.map(() => '?').join(', ');
|
|
487
|
+
const at = this.#expiry.bind(this.#now());
|
|
488
|
+
const active = this.#activeSql(q, 'r');
|
|
489
|
+
// `grp`: los grupos alcanzables por usersets desde (object, RS) —directos y
|
|
490
|
+
// anidados—. Ancla de UN solo SELECT (MySQL no mezcla UNION ALL con la
|
|
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.
|
|
494
|
+
const sql = `WITH RECURSIVE grp(g_uuid) AS ( ` +
|
|
495
|
+
`SELECT r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
496
|
+
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
|
|
497
|
+
`AND r.${q('relation')} IN (${relPlaceholders}) AND r.${q('subject_type')} = '${GROUP_TYPE}' ` +
|
|
498
|
+
`AND r.${q('subject_relation')} = '${GROUP_MEMBER}' AND ${active} ` +
|
|
499
|
+
`UNION ` +
|
|
500
|
+
`SELECT r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
501
|
+
`JOIN grp ON r.${q('object_uuid')} = grp.g_uuid ` +
|
|
502
|
+
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = '${GROUP_TYPE}' ` +
|
|
503
|
+
`AND r.${q('relation')} = '${GROUP_MEMBER}' AND r.${q('subject_type')} = '${GROUP_TYPE}' ` +
|
|
504
|
+
`AND r.${q('subject_relation')} = '${GROUP_MEMBER}' AND ${active} ` +
|
|
505
|
+
`) ` +
|
|
506
|
+
// Holders directos del objeto para RS, UNION holders directos de cualquier grupo de `grp`.
|
|
507
|
+
`SELECT ${meta.hint}${q('subject_type')} AS ${q('subject_type')}, ${q('subject_uuid')} AS ${q('subject_uuid')} ` +
|
|
508
|
+
`FROM ${q(RELATIONS_TABLE)} r ` +
|
|
509
|
+
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
|
|
510
|
+
`AND r.${q('relation')} IN (${relPlaceholders}) AND COALESCE(r.${q('subject_relation')}, '') = '' AND ${active} ` +
|
|
511
|
+
`UNION ` +
|
|
512
|
+
`SELECT r.${q('subject_type')}, r.${q('subject_uuid')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
513
|
+
`JOIN grp ON r.${q('object_uuid')} = grp.g_uuid ` +
|
|
514
|
+
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = '${GROUP_TYPE}' ` +
|
|
515
|
+
`AND r.${q('relation')} = '${GROUP_MEMBER}' AND COALESCE(r.${q('subject_relation')}, '') = '' AND ${active}`;
|
|
516
|
+
const bindings = [
|
|
517
|
+
// grp ancla
|
|
518
|
+
partitionKey, object.type, object.id, ...relations, at,
|
|
519
|
+
// grp recursiva
|
|
520
|
+
partitionKey, at,
|
|
521
|
+
// holders directos del objeto
|
|
522
|
+
partitionKey, object.type, object.id, ...relations, at,
|
|
523
|
+
// holders de los grupos
|
|
524
|
+
partitionKey, at,
|
|
525
|
+
];
|
|
526
|
+
const rows = rowsOf(await this.#raw('membersOf', sql, bindings));
|
|
527
|
+
const subjects = rows.map((row) => ({ type: String(row.subject_type), uuid: String(row.subject_uuid) }));
|
|
528
|
+
return { subjects };
|
|
529
|
+
}
|
|
530
|
+
/* ── Purga (invariante 11): el DELETE demuestra el cero ───────────────── */
|
|
531
|
+
async purgeObject(object, partition, options) {
|
|
532
|
+
assertScope(partition);
|
|
533
|
+
this.#assertObject(object);
|
|
534
|
+
const partitionKeys = this.#partitionSpellings(partition);
|
|
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
|
+
}
|
|
551
|
+
}
|
|
552
|
+
async purgeSubject(subject, partition, options) {
|
|
553
|
+
assertScope(partition);
|
|
554
|
+
const partitionKeys = this.#partitionSpellings(partition);
|
|
555
|
+
const s = this.#subjectColumns(subject, scopeKey(partition));
|
|
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
|
+
}
|
|
571
|
+
}
|
|
572
|
+
/* ── ORIGEN de reconcile (4-5): enumera los hechos directos ───────────── */
|
|
573
|
+
/**
|
|
574
|
+
* **`enumerateRelations` — el ORIGEN de `authz:reconcile` de relaciones**
|
|
575
|
+
* (4-5). Devuelve los hechos DIRECTOS de la partición (`authz_relations`),
|
|
576
|
+
* paginados por la PK (`uuid`, cursor que AVANZA), sin derivar por
|
|
577
|
+
* includes/usersets (el modelo del destino lo recompone): el destino recibe
|
|
578
|
+
* los hechos tal cual y decide qué escribe (invariante 7 + la higiene de
|
|
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,
|
|
582
|
+
* coherente con `purge*`): un origen que las funde no puede dejar hechos del
|
|
583
|
+
* alias sin migrar.
|
|
584
|
+
*
|
|
585
|
+
* **Filtra por TIPOS DECLARADOS** (⚪3, paridad con `openfga`): el driver
|
|
586
|
+
* `openfga` descarta de su enumeración todo `object_type` que no sea `group`
|
|
587
|
+
* ni un tipo de `defineRelationsConfig`; el de `database` NO lo hacía y
|
|
588
|
+
* devolvía TODA fila. Como `reconcileRelations` escribe `to.relate(...)`
|
|
589
|
+
* DIRECTO (salta el manager/F-05), una fila `object_type='role_binding'`
|
|
590
|
+
* sembrada a mano habría migrado al store compartido y reabierto la escalada.
|
|
591
|
+
* Con el filtro ambos drivers censan lo mismo y el chokepoint no deja residuo
|
|
592
|
+
* por el camino de reconcile.
|
|
593
|
+
*/
|
|
594
|
+
async enumerateRelations(partition, page) {
|
|
595
|
+
assertScope(partition);
|
|
596
|
+
const partitionKeys = this.#partitionSpellings(partition);
|
|
597
|
+
const declaredTypes = [GROUP_TYPE, ...this.#config.objectTypes.map((t) => t.type)];
|
|
598
|
+
const limit = Math.max(1, Math.min(page?.limit ?? DEFAULT_ENUMERATE_LIMIT, MAX_ENUMERATE_LIMIT));
|
|
599
|
+
const after = page?.after;
|
|
600
|
+
const rows = await this.#sql('enumerateRelations', () => {
|
|
601
|
+
let q = this.#connection()
|
|
602
|
+
.from(RELATIONS_TABLE)
|
|
603
|
+
.whereIn('partition_key', partitionKeys)
|
|
604
|
+
.whereIn('object_type', declaredTypes)
|
|
605
|
+
.orderBy('uuid', 'asc')
|
|
606
|
+
.limit(limit + 1);
|
|
607
|
+
if (after)
|
|
608
|
+
q = q.where('uuid', '>', after);
|
|
609
|
+
return q.select('uuid', 'object_type', 'object_uuid', 'relation', 'subject_type', 'subject_uuid', 'subject_relation', this.#expiry.select('expires_at'));
|
|
610
|
+
});
|
|
611
|
+
const hasMore = rows.length > limit;
|
|
612
|
+
const pageRows = hasMore ? rows.slice(0, limit) : rows;
|
|
613
|
+
const tuples = pageRows.map((row) => ({
|
|
614
|
+
subject: this.#rowToSubject(row),
|
|
615
|
+
relation: String(row.relation),
|
|
616
|
+
object: { type: String(row.object_type), id: String(row.object_uuid) },
|
|
617
|
+
// La partición canónica pedida: el barrido de ortografías ya la unificó.
|
|
618
|
+
partition,
|
|
619
|
+
expiresAt: this.#expiry.fromDb(row.expires_at),
|
|
620
|
+
}));
|
|
621
|
+
const cursor = hasMore ? String(pageRows[pageRows.length - 1].uuid) : undefined;
|
|
622
|
+
return cursor ? { tuples, cursor } : { tuples };
|
|
623
|
+
}
|
|
624
|
+
/* ── Helpers privados ─────────────────────────────────────────────────── */
|
|
625
|
+
/**
|
|
626
|
+
* **Barre AMBAS ortografías del uuid de partición** (🟡2 del auditor,
|
|
627
|
+
* paridad con el fix del lote 3b-2h): un motor que funde `aaa…` con `aaa-…`
|
|
628
|
+
* (tipo `uuid` de PG, collation `*_ci`) haría del `DELETE` un no-op sobre el
|
|
629
|
+
* alias y la fila real seguiría concediendo. `scopeSpellings` da la del
|
|
630
|
+
* llamante Y la canónica de la que puede ser alias; se convierten a clave.
|
|
631
|
+
*/
|
|
632
|
+
#partitionSpellings(partition) {
|
|
633
|
+
const keys = scopeSpellings(partition).map((s) => scopeKey(s));
|
|
634
|
+
return [...new Set(keys)];
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* La CTE `principal`: el conjunto de identidades `(p_type, p_uuid, p_rel)` que
|
|
638
|
+
* el SUJETO «es» —él mismo (holder ⇒ `p_rel = ''`; userset ⇒ su relación) más
|
|
639
|
+
* cada `group#member` del que es miembro, transitivamente (grupos anidados)—.
|
|
640
|
+
* Un `check`/`listObjects` casa un hecho directo del objeto contra cualquiera
|
|
641
|
+
* de esas identidades. Devuelve el `WITH RECURSIVE …` (sin el SELECT final) y
|
|
642
|
+
* sus bindings.
|
|
643
|
+
*/
|
|
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.
|
|
650
|
+
const cte = `WITH RECURSIVE principal(p_type, p_uuid, p_rel) AS ( ` +
|
|
651
|
+
`SELECT ${meta.text('?')}, ${meta.text('?')}, ${meta.text('?')} ` +
|
|
652
|
+
`UNION ` +
|
|
653
|
+
`SELECT ${meta.text(`'${GROUP_TYPE}'`)}, r.${q('object_uuid')}, ${meta.text(`'${GROUP_MEMBER}'`)} ` +
|
|
654
|
+
`FROM ${q(RELATIONS_TABLE)} r ` +
|
|
655
|
+
`JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
|
|
656
|
+
`AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
|
|
657
|
+
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = '${GROUP_TYPE}' ` +
|
|
658
|
+
`AND r.${q('relation')} = '${GROUP_MEMBER}' AND ${this.#activeSql(q, 'r')} )`;
|
|
659
|
+
return { cte, bindings: [s.type, s.uuid, baseRel, partitionKey, at] };
|
|
660
|
+
}
|
|
661
|
+
/** `''` (L-4b) o NULL (fila vieja sin backfill) ⇒ holder; con valor ⇒ userset. */
|
|
662
|
+
#rowToSubject(row) {
|
|
663
|
+
if (row.subject_relation === null || row.subject_relation === undefined || row.subject_relation === HOLDER_RELATION) {
|
|
664
|
+
return { type: String(row.subject_type), uuid: String(row.subject_uuid) };
|
|
665
|
+
}
|
|
666
|
+
return { object: { type: String(row.subject_type), id: String(row.subject_uuid) }, relation: String(row.subject_relation) };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
/**
|
|
670
|
+
* Forma del resultado crudo por cliente: pg `{ rows }`, sqlite `rows[]`,
|
|
671
|
+
* mysql2 `[rows[], fields[]]` (2.5 · J3).
|
|
672
|
+
*/
|
|
673
|
+
function rowsOf(result) {
|
|
674
|
+
if (Array.isArray(result)) {
|
|
675
|
+
return result.length === 2 && Array.isArray(result[0]) && Array.isArray(result[1]) ? result[0] : result;
|
|
676
|
+
}
|
|
677
|
+
return result?.rows ?? [];
|
|
678
|
+
}
|
|
679
|
+
//# sourceMappingURL=database_relations_driver.js.map
|