@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
|
@@ -3,12 +3,14 @@ import { v7 as uuidv7 } from 'uuid';
|
|
|
3
3
|
import { APP_SCOPE_TYPE } from '../types.js';
|
|
4
4
|
import { assertCatalogUuid, assertIdentity, assertScope, chainKeysFrom, normalizeRoleQuery } from '../identity.js';
|
|
5
5
|
import { resolveGrantExpiry, sameInstant } from '../expiry.js';
|
|
6
|
-
import { AuthorizationBackendError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, RoleNotVisibleError, UnknownPermissionError, UnknownRoleError, } from '../errors.js';
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
6
|
+
import { AuthorizationBackendError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, MassReconcileRefusedError, ReconcileTooLargeError, RoleNotVisibleError, UnknownPermissionError, UnknownRoleError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
|
|
7
|
+
import { RECONCILE_MAX_DETAILS, emptyReconcilePhases, reconcileBatchSize, reconcileMaxTuples, sumReconcilePhases, } from '../reconcile.js';
|
|
8
|
+
import { assertKnownScope, canonicalScope, canonicalScopeTargets, guardSql, isUniqueViolation, resolveChain, rootOnlyResolver, } from '../shared/backend_guard.js';
|
|
9
|
+
import { assertCallerTransaction } from '../shared/transaction_guard.js';
|
|
10
|
+
import { assertAssignableAt } from '../catalog/catalog.js';
|
|
11
|
+
import { CatalogCache, GLOBAL_OWNER_KEY, assertCatalogOptions, isRoleVisibleWith, withAuthzCatalogWrite } from '../catalog/catalog_cache.js';
|
|
10
12
|
import { isClock, systemClock } from '../clock.js';
|
|
11
|
-
import { sqlExpiryCodec } from '
|
|
13
|
+
import { sqlExpiryCodec } from '../shared/sql_expiry.js';
|
|
12
14
|
/**
|
|
13
15
|
* UUID centinela para el scope 'app' en las columnas scope_uuid (NOT NULL):
|
|
14
16
|
* permite que el unique de assignments/denies cubra también el nivel app
|
|
@@ -23,7 +25,15 @@ function toDbScopeUuid(scope) {
|
|
|
23
25
|
assertScope(scope);
|
|
24
26
|
return scope.uuid ?? APP_SCOPE_DB_UUID;
|
|
25
27
|
}
|
|
26
|
-
|
|
28
|
+
/**
|
|
29
|
+
* La vuelta de `toDbScopeUuid`: el centinela de la raíz vuelve a ser `null`.
|
|
30
|
+
* **Exportada desde 3b-3b**: `authz:reconcile --to=openfga` lee estas mismas
|
|
31
|
+
* columnas y hacía `row.scope_uuid ?? null`, que con la columna NOT NULL
|
|
32
|
+
* nunca es null — un grant o un deny en la RAÍZ reventaban la migración con
|
|
33
|
+
* un 422 a mitad de pasada (`el scope 'app' no admite uuid`). Lo cazó el
|
|
34
|
+
* contrato de migración, cuya siembra fija sí concede en `app`.
|
|
35
|
+
*/
|
|
36
|
+
export function fromDbScopeUuid(uuid) {
|
|
27
37
|
return uuid === APP_SCOPE_DB_UUID ? null : uuid;
|
|
28
38
|
}
|
|
29
39
|
/**
|
|
@@ -38,6 +48,35 @@ function dbScopeKey(scope) {
|
|
|
38
48
|
function rowScopeKey(row) {
|
|
39
49
|
return `${row.scope_type}\u001f${row.scope_uuid}`;
|
|
40
50
|
}
|
|
51
|
+
/**
|
|
52
|
+
* Clave de comparación de una asignación. **Es exactamente el unique
|
|
53
|
+
* `authz_asg_holder_role_scope_uq`** y NO incluye `expires_at`: cambiar la
|
|
54
|
+
* caducidad es un UPDATE de la misma fila, no una fila nueva (invariante 6).
|
|
55
|
+
* Que la clave del diff sea la del índice es lo que hace que la migración no
|
|
56
|
+
* pueda dejar dos filas del mismo hecho ni chocar con el unique al insertar.
|
|
57
|
+
*/
|
|
58
|
+
function assignmentKey(row) {
|
|
59
|
+
return [row.holder_type, row.holder_uuid, row.role_uuid, row.scope_type, row.scope_uuid].join('\u001f');
|
|
60
|
+
}
|
|
61
|
+
function denyKey(row) {
|
|
62
|
+
return [row.holder_type, row.holder_uuid, row.permission_uuid, row.scope_type, row.scope_uuid].join('\u001f');
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* La caducidad que dura MÁS (`null` = para siempre). Es la regla con la que
|
|
66
|
+
* se resuelve un colapso de dos hechos del origen en una sola fila: el
|
|
67
|
+
* origen concedía mientras CUALQUIERA de los dos siguiera vivo, así que la
|
|
68
|
+
* unión es lo que conserva la respuesta.
|
|
69
|
+
*/
|
|
70
|
+
function longerExpiry(a, b) {
|
|
71
|
+
if (a === null || b === null)
|
|
72
|
+
return null;
|
|
73
|
+
return a >= b ? a : b;
|
|
74
|
+
}
|
|
75
|
+
/** Cómo se NOMBRA una fila del destino en `details`: sin esto un motivo no se arregla. */
|
|
76
|
+
function reconcileRowLabel(row) {
|
|
77
|
+
const what = row.role_uuid ? `→ ${row.role_uuid}` : `⊘ ${row.permission_uuid}`;
|
|
78
|
+
return `${row.holder_type}:${row.holder_uuid} ${what} @ ${row.scope_type}:${row.scope_uuid}`;
|
|
79
|
+
}
|
|
41
80
|
/**
|
|
42
81
|
* El rol `(slug, scopeType)` que existe en el scope cuya cadena empieza por
|
|
43
82
|
* `chainKeys`, o 422: `E_AUTHZ_ROLE_NOT_VISIBLE` si hay roles con ese nombre
|
|
@@ -220,6 +259,21 @@ export const DEFAULT_TIMEOUT_MS = 5_000;
|
|
|
220
259
|
* crudo de knex (L0.11).
|
|
221
260
|
*/
|
|
222
261
|
export class DatabaseAuthorizationDriver {
|
|
262
|
+
/**
|
|
263
|
+
* Lo que este driver declara (3b-2e · E2). El árbol es del consumidor
|
|
264
|
+
* (`resolveChain`), así que no hay hechos de jerarquía ni deriva que
|
|
265
|
+
* mitigar; `authorize` son varias consultas SQL; la membresía la resuelve
|
|
266
|
+
* el propio SQL contra la cadena que le pasan; los `list*` no enumeran
|
|
267
|
+
* herencia (invariante 7); y `purgeRole` está implementado en una
|
|
268
|
+
* transacción.
|
|
269
|
+
*/
|
|
270
|
+
capabilities;
|
|
271
|
+
/**
|
|
272
|
+
* Con `transactionalWrites: false` declarado por el despliegue, un
|
|
273
|
+
* `{ transaction }` que llegara igual (un llamante que se salta el manager)
|
|
274
|
+
* se rechaza aquí también: no se ignora en silencio.
|
|
275
|
+
*/
|
|
276
|
+
transactionalWrites;
|
|
223
277
|
/**
|
|
224
278
|
* Resolutor de jerarquía inyectado por el consumidor (el chasis pasa el
|
|
225
279
|
* suyo, que conoce organizations/units, en `config/authorization.ts`).
|
|
@@ -250,6 +304,30 @@ export class DatabaseAuthorizationDriver {
|
|
|
250
304
|
throw new AuthorizationConfigError(`DatabaseAuthorizationDriver: 'now' debe ser una función () => Date (llegó ${typeof options.now})`);
|
|
251
305
|
}
|
|
252
306
|
this.now = options.now ?? systemClock;
|
|
307
|
+
this.transactionalWrites = options.transactionalWrites ?? true;
|
|
308
|
+
this.capabilities = Object.freeze({
|
|
309
|
+
hierarchyFacts: false,
|
|
310
|
+
singleCheckAuthorize: false,
|
|
311
|
+
roleInheritanceNative: false,
|
|
312
|
+
listObjectsInherited: false,
|
|
313
|
+
purgeRole: true,
|
|
314
|
+
countRoleAssignments: true,
|
|
315
|
+
// 3b-2k · K1: `authorize` resuelve la cadena y usa `chain[0]`, así que un
|
|
316
|
+
// alias del uuid que el árbol funde con la fila real encuentra sus hechos.
|
|
317
|
+
canonicalScopeReads: true,
|
|
318
|
+
// 3b-3b: sus hechos son `authz_assignments`/`authz_denies` —el esquema
|
|
319
|
+
// PUBLICADO del paquete—, así que el destino los lee de ahí y no por el
|
|
320
|
+
// puerto. No es «no sabe»: es que no hace falta un método para leer una
|
|
321
|
+
// tabla documentada. Un driver de terceros que quiera ser origen sí lo
|
|
322
|
+
// necesita, y por eso la capacidad tiene sus dos caras.
|
|
323
|
+
enumerateFacts: false,
|
|
324
|
+
// L-3: `grant`/`revoke`/`deny`/`removeDeny` escriben por la transacción
|
|
325
|
+
// ABIERTA del llamante (`writer()`: `assertCallerTransaction` contra la
|
|
326
|
+
// conexión primaria de Lucid). Lo que NO viaja por ella: el catálogo,
|
|
327
|
+
// `resolveChain` y la barrera del freeze (autoridad). Un despliegue con
|
|
328
|
+
// pool 1 lo declara `false` (ver `DatabaseDriverOptions`).
|
|
329
|
+
transactionalWrites: this.transactionalWrites,
|
|
330
|
+
});
|
|
253
331
|
assertCatalogOptions('DatabaseAuthorizationDriver', options);
|
|
254
332
|
this.catalog =
|
|
255
333
|
options.catalog ??
|
|
@@ -296,10 +374,14 @@ export class DatabaseAuthorizationDriver {
|
|
|
296
374
|
knownScope(scope, operation) {
|
|
297
375
|
return assertKnownScope(this.chainResolver, scope, operation);
|
|
298
376
|
}
|
|
299
|
-
/** El scope canónico para `
|
|
377
|
+
/** El scope canónico para `purgeScope` (ver `canonicalScope`). */
|
|
300
378
|
canonicalOrSelf(scope, operation) {
|
|
301
379
|
return canonicalScope(this.chainResolver, scope, operation);
|
|
302
380
|
}
|
|
381
|
+
/** Los destinos de un delete de hechos (`revoke`/`removeDeny`): canónico, o el fan-out de alias (3b-8 · A4). */
|
|
382
|
+
canonicalTargets(scope, operation) {
|
|
383
|
+
return canonicalScopeTargets(this.chainResolver, scope, operation);
|
|
384
|
+
}
|
|
303
385
|
/**
|
|
304
386
|
* Ejecuta una consulta clasificando su fallo (503 con código propio). `fn`
|
|
305
387
|
* devuelve el BUILDER sin ejecutar: el guard le fija el deadline y luego lo
|
|
@@ -313,6 +395,49 @@ export class DatabaseAuthorizationDriver {
|
|
|
313
395
|
const rows = await this.sql(operation, () => fn().limit(1));
|
|
314
396
|
return rows[0] ?? null;
|
|
315
397
|
}
|
|
398
|
+
/**
|
|
399
|
+
* Por dónde escribe `operation` (L-3): la transacción ABIERTA del llamante
|
|
400
|
+
* si llegó en `options.transaction` —validada contra la conexión de este
|
|
401
|
+
* driver, la primaria de Lucid, por la que escribe todo lo demás: otra
|
|
402
|
+
* conexión, un `QueryClient` o el `db` entero son 500 `E_AUTHZ_CONFIG`
|
|
403
|
+
* ANTES de tocar nada—, o `db`. `external` dice cuál de los dos.
|
|
404
|
+
*
|
|
405
|
+
* Solo la ESCRITURA va por ella. La lectura de «¿ya existe?» de
|
|
406
|
+
* `grant`/`deny` también, porque forma parte de la misma escritura (tiene
|
|
407
|
+
* que ver lo que TU transacción ya escribió: un `grant` y su re-grant en la
|
|
408
|
+
* misma transacción); el catálogo y `resolveChain` no (autoridad, L-1).
|
|
409
|
+
*/
|
|
410
|
+
writer(operation, options) {
|
|
411
|
+
if (options?.transaction === undefined || options.transaction === null)
|
|
412
|
+
return { client: db, external: false };
|
|
413
|
+
if (!this.transactionalWrites) {
|
|
414
|
+
throw new UnsupportedOperationError('transactionalWrites', operation, 'database', 'Este despliegue declara transactionalWrites: false en las opciones del driver (pool 1): { transaction } ' +
|
|
415
|
+
'no se admite. Quita la opción (exige pool ≥ 2) o no pases transaction.');
|
|
416
|
+
}
|
|
417
|
+
const trx = assertCallerTransaction(`database.${operation}`, options.transaction, { connection: db.primaryConnectionName });
|
|
418
|
+
return { client: trx, external: true };
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* Un INSERT que falla DENTRO de la transacción del llamante no se absorbe
|
|
422
|
+
* (L-3). Fuera de ella `grant`/`deny` releen y se quedan con lo del ganador
|
|
423
|
+
* (K4); dentro no se puede: en PostgreSQL la transacción ya está ABORTADA
|
|
424
|
+
* (toda sentencia posterior es `25P02` hasta el rollback) y en REPEATABLE
|
|
425
|
+
* READ (MySQL por defecto) la relectura no vería al ganador. Así que un
|
|
426
|
+
* choque del UNIQUE sale como **409 `E_AUTHZ_WRITE_CONFLICT`** —«envenena
|
|
427
|
+
* tu transacción: haz rollback y reintenta»—, un deadline vencido sigue
|
|
428
|
+
* siendo el 503 `E_AUTHZ_BACKEND_TIMEOUT` que ya clasificó `sql()` (con
|
|
429
|
+
* `onWrite` `indeterminate: true` en el manager) y cualquier otro fallo, su
|
|
430
|
+
* 503. Nunca una segunda sentencia sobre una transacción que puede estar
|
|
431
|
+
* abortada.
|
|
432
|
+
*/
|
|
433
|
+
poisoned(operation, error) {
|
|
434
|
+
if (isUniqueViolation(error)) {
|
|
435
|
+
throw new WriteConflictError(`database.${operation}: el hecho lo escribió otra transacción mientras la tuya estaba abierta (choque del ` +
|
|
436
|
+
'UNIQUE dentro de tu transacción). No se puede absorber ahí dentro: la transacción queda envenenada ' +
|
|
437
|
+
'(en PostgreSQL, abortada). Haz rollback y reintenta.', { cause: error });
|
|
438
|
+
}
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
316
441
|
get expiry() {
|
|
317
442
|
// Decidir el dialecto no cuesta ninguna consulta: una vista por
|
|
318
443
|
// prototipo (`withClock`/`withChainResolver`) hereda el codec del driver
|
|
@@ -402,7 +527,11 @@ export class DatabaseAuthorizationDriver {
|
|
|
402
527
|
const declared = resolveRoleQuery(catalog, role, target, chainKeysFrom(chain)[0]);
|
|
403
528
|
assertRoleAssignableAt(catalog, declared);
|
|
404
529
|
const roleUuid = declared.uuid;
|
|
405
|
-
|
|
530
|
+
// L-3: la ESCRITURA (y la lectura «¿ya existe?» que forma parte de ella)
|
|
531
|
+
// va por la transacción del llamante si llegó; todo lo de arriba
|
|
532
|
+
// (árbol, catálogo) fue por la conexión del motor.
|
|
533
|
+
const { client, external } = this.writer('grant', options);
|
|
534
|
+
const findExisting = () => whereScopeIn(client
|
|
406
535
|
.from('authz_assignments')
|
|
407
536
|
.where('holder_type', subject.type)
|
|
408
537
|
.where('holder_uuid', subject.uuid)
|
|
@@ -416,7 +545,7 @@ export class DatabaseAuthorizationDriver {
|
|
|
416
545
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
417
546
|
const existing = await this.first('grant.find', findExisting);
|
|
418
547
|
if (existing) {
|
|
419
|
-
const refreshed = await this.refreshAssignment(existing, options.expiresAt);
|
|
548
|
+
const refreshed = await this.refreshAssignment(existing, options.expiresAt, client);
|
|
420
549
|
if (refreshed)
|
|
421
550
|
return refreshed;
|
|
422
551
|
continue;
|
|
@@ -424,7 +553,7 @@ export class DatabaseAuthorizationDriver {
|
|
|
424
553
|
// No había nada: la caducidad es la pedida, o ninguna.
|
|
425
554
|
const expiresAt = options.expiresAt ?? null;
|
|
426
555
|
try {
|
|
427
|
-
await this.sql('grant.insert', () =>
|
|
556
|
+
await this.sql('grant.insert', () => client.table('authz_assignments').insert({
|
|
428
557
|
uuid: uuidv7(),
|
|
429
558
|
holder_type: subject.type,
|
|
430
559
|
holder_uuid: subject.uuid,
|
|
@@ -438,6 +567,10 @@ export class DatabaseAuthorizationDriver {
|
|
|
438
567
|
return { existed: false, expiresAt };
|
|
439
568
|
}
|
|
440
569
|
catch (error) {
|
|
570
|
+
// Dentro de la transacción del llamante no hay relectura que valga
|
|
571
|
+
// (L-3, `poisoned`): 409 si fue el UNIQUE, si no el error tal cual.
|
|
572
|
+
if (external)
|
|
573
|
+
this.poisoned('grant', error);
|
|
441
574
|
// Carrera check-then-insert: el unique (que cubre también el nivel app
|
|
442
575
|
// vía centinela) la detecta — el perdedor degrada a re-grant sobre lo
|
|
443
576
|
// que escribió el ganador (con la misma semántica de tres estados).
|
|
@@ -445,7 +578,7 @@ export class DatabaseAuthorizationDriver {
|
|
|
445
578
|
const raced = await this.first('grant.race', findExisting);
|
|
446
579
|
if (!raced)
|
|
447
580
|
throw error;
|
|
448
|
-
const refreshed = await this.refreshAssignment(raced, options.expiresAt);
|
|
581
|
+
const refreshed = await this.refreshAssignment(raced, options.expiresAt, client);
|
|
449
582
|
if (refreshed)
|
|
450
583
|
return refreshed;
|
|
451
584
|
}
|
|
@@ -459,17 +592,17 @@ export class DatabaseAuthorizationDriver {
|
|
|
459
592
|
* UPDATE no toca ninguna fila (otro proceso la borró entre la lectura y la
|
|
460
593
|
* escritura, K4) devuelve `null`: no hay «existed: true» sobre nada.
|
|
461
594
|
*/
|
|
462
|
-
async refreshAssignment(row, requested) {
|
|
595
|
+
async refreshAssignment(row, requested, client) {
|
|
463
596
|
const previous = this.expiry.fromDb(row.expires_at);
|
|
464
597
|
const expiresAt = resolveGrantExpiry(previous, requested, this.now());
|
|
465
598
|
if (!sameInstant(previous, expiresAt)) {
|
|
466
|
-
const updated = await this.sql('grant.update', () =>
|
|
599
|
+
const updated = await this.sql('grant.update', () => client.from('authz_assignments').where('uuid', row.uuid).update({ expires_at: this.expiry.toDb(expiresAt) }));
|
|
467
600
|
if (Number(Array.isArray(updated) ? updated[0] : updated) === 0)
|
|
468
601
|
return null;
|
|
469
602
|
}
|
|
470
603
|
return { existed: true, previousExpiresAt: previous, expiresAt };
|
|
471
604
|
}
|
|
472
|
-
async revoke(subject, role, scope) {
|
|
605
|
+
async revoke(subject, role, scope, options) {
|
|
473
606
|
assertIdentity({ subject, role, scope });
|
|
474
607
|
// Rol fuera del catálogo para ese nivel ⇒ 422, como en `grant` (D10). El
|
|
475
608
|
// no-op es para una asignación inexistente de un rol válido. Se quitan
|
|
@@ -478,12 +611,17 @@ export class DatabaseAuthorizationDriver {
|
|
|
478
611
|
// scope puede no existir ya para el árbol (D8), así que no se filtra por
|
|
479
612
|
// visibilidad — quitar nunca concede.
|
|
480
613
|
const named = rolesToRevoke(await this.catalog.view(), role, scope);
|
|
481
|
-
|
|
482
|
-
|
|
614
|
+
// Sin cadena con la que canonizar, TODAS las ortografías de las que el
|
|
615
|
+
// uuid puede ser alias (3b-8 · A4, mismo fan-out que `scopes.detached`):
|
|
616
|
+
// con una sola, un alias hacía del DELETE un no-op silencioso y la fila
|
|
617
|
+
// canónica seguía concediendo si el scope se restauraba.
|
|
618
|
+
const targets = await this.canonicalTargets(scope, 'revoke');
|
|
619
|
+
const { client } = this.writer('revoke', options);
|
|
620
|
+
await this.sql('revoke', () => whereScopeIn(client
|
|
483
621
|
.from('authz_assignments')
|
|
484
622
|
.where('holder_type', subject.type)
|
|
485
623
|
.where('holder_uuid', subject.uuid)
|
|
486
|
-
.whereIn('role_uuid', named.map((r) => r.uuid)), 'scope',
|
|
624
|
+
.whereIn('role_uuid', named.map((r) => r.uuid)), 'scope', targets, 'write').delete());
|
|
487
625
|
}
|
|
488
626
|
async hasRole(subject, role, scope) {
|
|
489
627
|
assertIdentity({ subject, role, scope });
|
|
@@ -514,13 +652,14 @@ export class DatabaseAuthorizationDriver {
|
|
|
514
652
|
const found = await this.first('hasRole', () => this.whereActive(query, 'a.expires_at'));
|
|
515
653
|
return Boolean(found);
|
|
516
654
|
}
|
|
517
|
-
async deny(subject, permission, scope) {
|
|
655
|
+
async deny(subject, permission, scope, options) {
|
|
518
656
|
assertIdentity({ subject, permission, scope });
|
|
519
657
|
const perm = await this.findPermission(permission);
|
|
520
658
|
if (!perm)
|
|
521
659
|
throw new UnknownPermissionError(permission);
|
|
522
660
|
const [target] = await this.knownScope(scope, 'deny');
|
|
523
|
-
const
|
|
661
|
+
const { client, external } = this.writer('deny', options);
|
|
662
|
+
const findExisting = () => whereScopeIn(client
|
|
524
663
|
.from('authz_denies')
|
|
525
664
|
.where('holder_type', subject.type)
|
|
526
665
|
.where('holder_uuid', subject.uuid)
|
|
@@ -529,7 +668,7 @@ export class DatabaseAuthorizationDriver {
|
|
|
529
668
|
if (existing)
|
|
530
669
|
return;
|
|
531
670
|
try {
|
|
532
|
-
await this.sql('deny.insert', () =>
|
|
671
|
+
await this.sql('deny.insert', () => client.table('authz_denies').insert({
|
|
533
672
|
uuid: uuidv7(),
|
|
534
673
|
holder_type: subject.type,
|
|
535
674
|
holder_uuid: subject.uuid,
|
|
@@ -540,6 +679,9 @@ export class DatabaseAuthorizationDriver {
|
|
|
540
679
|
}));
|
|
541
680
|
}
|
|
542
681
|
catch (error) {
|
|
682
|
+
// Dentro de la transacción del llamante no se relee (L-3, `poisoned`).
|
|
683
|
+
if (external)
|
|
684
|
+
this.poisoned('deny', error);
|
|
543
685
|
// Carrera: si otro proceso insertó el mismo deny, el unique lo detecta
|
|
544
686
|
// y el resultado deseado ya existe.
|
|
545
687
|
const raced = await this.first('deny.race', findExisting);
|
|
@@ -547,17 +689,20 @@ export class DatabaseAuthorizationDriver {
|
|
|
547
689
|
throw error;
|
|
548
690
|
}
|
|
549
691
|
}
|
|
550
|
-
async removeDeny(subject, permission, scope) {
|
|
692
|
+
async removeDeny(subject, permission, scope, options) {
|
|
551
693
|
assertIdentity({ subject, permission, scope });
|
|
552
694
|
const perm = await this.findPermission(permission);
|
|
553
695
|
if (!perm)
|
|
554
696
|
throw new UnknownPermissionError(permission);
|
|
555
|
-
|
|
556
|
-
|
|
697
|
+
// Mismo fan-out que `revoke` (3b-8 · A4): quitar un deny de menos por un
|
|
698
|
+
// alias sería un deny fantasma que nadie puede levantar.
|
|
699
|
+
const targets = await this.canonicalTargets(scope, 'removeDeny');
|
|
700
|
+
const { client } = this.writer('removeDeny', options);
|
|
701
|
+
await this.sql('removeDeny', () => whereScopeIn(client
|
|
557
702
|
.from('authz_denies')
|
|
558
703
|
.where('holder_type', subject.type)
|
|
559
704
|
.where('holder_uuid', subject.uuid)
|
|
560
|
-
.where('permission_uuid', perm.uuid), 'scope',
|
|
705
|
+
.where('permission_uuid', perm.uuid), 'scope', targets, 'write').delete());
|
|
561
706
|
}
|
|
562
707
|
async listSubjects(role, scope) {
|
|
563
708
|
assertIdentity({ role, scope });
|
|
@@ -821,5 +966,346 @@ export class DatabaseAuthorizationDriver {
|
|
|
821
966
|
this.catalog.invalidate();
|
|
822
967
|
}
|
|
823
968
|
}
|
|
969
|
+
/**
|
|
970
|
+
* Cuántos hechos VIGENTES tiene cada rol, en todos los scopes (3b-2j). Es
|
|
971
|
+
* lo que `pruneOrphanRoles` necesita para decir si un huérfano todavía
|
|
972
|
+
* concede, y es una pregunta del PUERTO porque los hechos son del driver:
|
|
973
|
+
* aquí son filas de `authz_assignments`, en `openfga` son tuplas del store.
|
|
974
|
+
*
|
|
975
|
+
* Una consulta agrupada para todos los uuids (no una por rol) y la misma
|
|
976
|
+
* caducidad ESTRICTA que el resto del driver, con SU reloj (`whereActive`,
|
|
977
|
+
* J1): la asignación que vence ahora ya no cuenta, como no cuenta en
|
|
978
|
+
* `authorize`. Un rol sin hechos —o que no está en la tabla— es `0`.
|
|
979
|
+
*/
|
|
980
|
+
async countRoleAssignments(roleUuids) {
|
|
981
|
+
for (const uuid of roleUuids)
|
|
982
|
+
assertCatalogUuid('rol', uuid);
|
|
983
|
+
if (roleUuids.length === 0)
|
|
984
|
+
return [];
|
|
985
|
+
const rows = await this.sql('countRoleAssignments', () => this.whereActive(db.from('authz_assignments').whereIn('role_uuid', roleUuids))
|
|
986
|
+
.groupBy('role_uuid')
|
|
987
|
+
.select('role_uuid')
|
|
988
|
+
.count('* as total'));
|
|
989
|
+
const totals = new Map();
|
|
990
|
+
for (const row of rows)
|
|
991
|
+
totals.set(String(row.role_uuid), Number(row.total ?? row.count ?? 0));
|
|
992
|
+
return roleUuids.map((uuid) => totals.get(uuid) ?? 0);
|
|
993
|
+
}
|
|
994
|
+
/* ── `authz:reconcile --to=database` (3b-3b) ─────────────────────────── */
|
|
995
|
+
/**
|
|
996
|
+
* **Rehace `authz_assignments`/`authz_denies` desde los hechos de OTRO
|
|
997
|
+
* driver** — la vuelta de la migración, y con ella la simetría que el dueño
|
|
998
|
+
* compró: «todo en un driver o todo en otro».
|
|
999
|
+
*
|
|
1000
|
+
* Qué se migra y qué NO, y por qué:
|
|
1001
|
+
* - **los HECHOS, sí**: es lo único que vive en el otro driver. Llegan por
|
|
1002
|
+
* `source.facts` —el puerto `enumerateFacts`, paginado— y no por SQL:
|
|
1003
|
+
* aquí el origen es un store, no una tabla de este paquete.
|
|
1004
|
+
* - **el ÁRBOL, no**. En esta dirección el árbol NO se migra: el driver
|
|
1005
|
+
* `database` lo lee de las tablas del CONSUMIDOR en cada pregunta
|
|
1006
|
+
* (`resolveChain`), que son su fuente de verdad. Copiarlo a algún sitio
|
|
1007
|
+
* sería inventarse una segunda copia y con ella una deriva que hoy no
|
|
1008
|
+
* existe. Se usa, eso sí, para decidir qué hecho es migrable
|
|
1009
|
+
* (`unknown-scope`) y bajo qué identidad CANÓNICA se escribe
|
|
1010
|
+
* (invariante 17).
|
|
1011
|
+
* - **el CATÁLOGO, tampoco**: es propiedad local SIEMPRE (regla de higiene
|
|
1012
|
+
* del paquete), ya está en `authz_*` y ningún driver es su fuente. Aquí
|
|
1013
|
+
* solo se usa para traducir: un `roleUuid` que el catálogo no declara y
|
|
1014
|
+
* un permiso que no existe no son hechos migrables.
|
|
1015
|
+
*
|
|
1016
|
+
* Por eso `phases.root`, `phases.catalog` y `phases.tree` son CERO en esta
|
|
1017
|
+
* dirección, y todo el movimiento está en `phases.facts`. Es una respuesta,
|
|
1018
|
+
* no un hueco: los ceros dicen «aquí no hay nada derivado que rehacer».
|
|
1019
|
+
*
|
|
1020
|
+
* El resto del contrato es el mismo que en la ida (3b-3a): idempotente (la
|
|
1021
|
+
* segunda pasada escribe cero), `dryRun` es el VERIFICADOR y es read-only
|
|
1022
|
+
* por contrato —**un `--fix` está prohibido** (cruce 4 · S18) y no se
|
|
1023
|
+
* implementa ni se deja preparado—, nunca silenciosa (`skipped{motivo}` +
|
|
1024
|
+
* `details`), los hechos que sobran solo se borran con `prune`, y el
|
|
1025
|
+
* seguro del origen ciego (`E_AUTHZ_MASS_RECONCILE_REFUSED`) se aplica
|
|
1026
|
+
* igual: un origen que devuelve CERO hechos y un `--prune` detrás vacían
|
|
1027
|
+
* la base, y eso es casi siempre apuntar al store equivocado.
|
|
1028
|
+
*/
|
|
1029
|
+
async reconcile(source, options = {}) {
|
|
1030
|
+
const dryRun = options.dryRun === true;
|
|
1031
|
+
const prune = options.prune === true;
|
|
1032
|
+
const batchSize = reconcileBatchSize(options.batchSize);
|
|
1033
|
+
const maxTuples = reconcileMaxTuples(options.maxTuples);
|
|
1034
|
+
const catalog = await this.catalog.view();
|
|
1035
|
+
const now = this.now();
|
|
1036
|
+
const skipped = {};
|
|
1037
|
+
const details = [];
|
|
1038
|
+
const note = (kind, reason, detail) => {
|
|
1039
|
+
skipped[reason] = (skipped[reason] ?? 0) + 1;
|
|
1040
|
+
if (details.length < RECONCILE_MAX_DETAILS)
|
|
1041
|
+
details.push({ kind, reason, detail });
|
|
1042
|
+
};
|
|
1043
|
+
/** `[scope canónico, ...]` memoizado: el árbol del consumidor manda. */
|
|
1044
|
+
const chains = new Map();
|
|
1045
|
+
const canonical = async (scope) => {
|
|
1046
|
+
const key = dbScopeKey(scope);
|
|
1047
|
+
if (!chains.has(key)) {
|
|
1048
|
+
chains.set(key, await resolveChain(source.resolveChain, scope, 'reconcile'));
|
|
1049
|
+
}
|
|
1050
|
+
const chain = chains.get(key);
|
|
1051
|
+
return chain === null ? null : chain[0];
|
|
1052
|
+
};
|
|
1053
|
+
/* 1. Los HECHOS del origen, paginados por el puerto. */
|
|
1054
|
+
const wantedAssignments = new Map();
|
|
1055
|
+
const wantedDenies = new Map();
|
|
1056
|
+
let sourceFacts = 0;
|
|
1057
|
+
/**
|
|
1058
|
+
* Hechos del origen que RESPALDAN algo en el destino (3b-8 · B1). El
|
|
1059
|
+
* seguro de borrado masivo miraba `sourceFacts`, el conteo CRUDO — se
|
|
1060
|
+
* incrementa ANTES de cada skip—, así que un origen cuyos hechos se
|
|
1061
|
+
* descartan TODOS (caducados, scopes que ya no resuelven: la firma de un
|
|
1062
|
+
* store equivocado o de un resolutor ciego) lo esquivaba y `--prune`
|
|
1063
|
+
* vaciaba `authz_assignments`/`authz_denies` sin una sola pregunta. Es el
|
|
1064
|
+
* primo del 🔴 del lote 5, un escalón abajo: el seguro miraba si el
|
|
1065
|
+
* origen estaba VACÍO, no si estaba VACÍO DE HECHOS UTILIZABLES.
|
|
1066
|
+
*/
|
|
1067
|
+
let usableFacts = 0;
|
|
1068
|
+
const enumerate = source.facts;
|
|
1069
|
+
if (typeof enumerate !== 'function') {
|
|
1070
|
+
throw new UnsupportedOperationError('enumerateFacts', 'authz:reconcile --to=database', 'origen', `El ORIGEN de la migración tiene que saber enumerar sus hechos: sin eso, esta pasada leería ` +
|
|
1071
|
+
`cero hechos y con --prune vaciaría 'authz_assignments'/'authz_denies'. El driver 'database' ` +
|
|
1072
|
+
`no lo implementa a propósito (sus hechos SON esas tablas).`);
|
|
1073
|
+
}
|
|
1074
|
+
let after;
|
|
1075
|
+
const seenCursors = new Set();
|
|
1076
|
+
for (let page = 0;; page++) {
|
|
1077
|
+
const got = await enumerate({ limit: batchSize, after });
|
|
1078
|
+
if (got.facts.length > batchSize) {
|
|
1079
|
+
throw new AuthorizationInternalError(`authz:reconcile: el origen devolvió ${got.facts.length} hechos con limit=${batchSize}`);
|
|
1080
|
+
}
|
|
1081
|
+
for (const skip of got.skipped ?? [])
|
|
1082
|
+
note(skip.kind, skip.reason, skip.detail);
|
|
1083
|
+
for (const fact of got.facts) {
|
|
1084
|
+
sourceFacts += 1;
|
|
1085
|
+
if (sourceFacts > maxTuples) {
|
|
1086
|
+
throw new ReconcileTooLargeError(`authz:reconcile --to=database: el ORIGEN pasa de maxTuples (${maxTuples}) hechos y la pasada ` +
|
|
1087
|
+
`necesita comparar contra el estado ENTERO del destino, que entra en memoria. Sube maxTuples ` +
|
|
1088
|
+
`si tu proceso lo aguanta; no hay migración por particiones en esta versión.`);
|
|
1089
|
+
}
|
|
1090
|
+
const target = await canonical(fact.scope);
|
|
1091
|
+
if (target === null) {
|
|
1092
|
+
note(fact.kind, 'unknown-scope', fact.detail);
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
if (fact.kind === 'assignment') {
|
|
1096
|
+
if (fact.expiresAt !== null && fact.expiresAt !== undefined && fact.expiresAt <= now) {
|
|
1097
|
+
note('assignment', 'expired', fact.detail);
|
|
1098
|
+
continue;
|
|
1099
|
+
}
|
|
1100
|
+
const role = fact.roleUuid ? catalog.roleByUuid(fact.roleUuid) : null;
|
|
1101
|
+
if (!role) {
|
|
1102
|
+
note('assignment', 'unknown-role', fact.detail);
|
|
1103
|
+
continue;
|
|
1104
|
+
}
|
|
1105
|
+
const row = {
|
|
1106
|
+
holder_type: fact.holder.type,
|
|
1107
|
+
holder_uuid: fact.holder.uuid,
|
|
1108
|
+
role_uuid: role.uuid,
|
|
1109
|
+
scope_type: target.type,
|
|
1110
|
+
scope_uuid: toDbScopeUuid(target),
|
|
1111
|
+
};
|
|
1112
|
+
// **Dos hechos del origen que colapsan en UNA fila** (S15, medido):
|
|
1113
|
+
// el store distingue `scope:unit|AAA` de `scope:unit|aaa` y la tabla
|
|
1114
|
+
// del consumidor no puede tener las dos (columna `uuid` en
|
|
1115
|
+
// PostgreSQL, collation `*_ci` en MySQL), así que la cadena
|
|
1116
|
+
// canónica las funde. No se pierde en silencio: se cuenta con su
|
|
1117
|
+
// motivo, y la fila se queda con la caducidad que MÁS dura —que es
|
|
1118
|
+
// lo que el origen respondía, donde bastaba con que UNA siguiera
|
|
1119
|
+
// viva—.
|
|
1120
|
+
usableFacts += 1;
|
|
1121
|
+
const key = assignmentKey(row);
|
|
1122
|
+
const before = wantedAssignments.get(key);
|
|
1123
|
+
if (before) {
|
|
1124
|
+
note('assignment', 'folded-scope', `${before.detail} ≡ ${fact.detail}`);
|
|
1125
|
+
}
|
|
1126
|
+
const expiresAt = fact.expiresAt ?? null;
|
|
1127
|
+
if (!before || longerExpiry(before.expiresAt, expiresAt) === expiresAt) {
|
|
1128
|
+
wantedAssignments.set(key, { row, expiresAt, detail: fact.detail });
|
|
1129
|
+
}
|
|
1130
|
+
continue;
|
|
1131
|
+
}
|
|
1132
|
+
const permission = fact.permission ? catalog.permission(fact.permission) : null;
|
|
1133
|
+
if (!permission) {
|
|
1134
|
+
note('deny', 'unknown-permission', fact.detail);
|
|
1135
|
+
continue;
|
|
1136
|
+
}
|
|
1137
|
+
const row = {
|
|
1138
|
+
holder_type: fact.holder.type,
|
|
1139
|
+
holder_uuid: fact.holder.uuid,
|
|
1140
|
+
permission_uuid: permission.uuid,
|
|
1141
|
+
scope_type: target.type,
|
|
1142
|
+
scope_uuid: toDbScopeUuid(target),
|
|
1143
|
+
};
|
|
1144
|
+
usableFacts += 1;
|
|
1145
|
+
const denyId = denyKey(row);
|
|
1146
|
+
if (wantedDenies.has(denyId))
|
|
1147
|
+
note('deny', 'folded-scope', fact.detail);
|
|
1148
|
+
wantedDenies.set(denyId, row);
|
|
1149
|
+
}
|
|
1150
|
+
const cursor = got.cursor;
|
|
1151
|
+
if (!cursor)
|
|
1152
|
+
break;
|
|
1153
|
+
if (seenCursors.has(cursor)) {
|
|
1154
|
+
throw new AuthorizationInternalError(`authz:reconcile: el cursor del origen se repite (página ${page + 1}); no avanza`);
|
|
1155
|
+
}
|
|
1156
|
+
seenCursors.add(cursor);
|
|
1157
|
+
after = cursor;
|
|
1158
|
+
}
|
|
1159
|
+
/* 2. El DESTINO entero, tal como está. */
|
|
1160
|
+
const phases = emptyReconcilePhases();
|
|
1161
|
+
const inserts = [];
|
|
1162
|
+
const updates = [];
|
|
1163
|
+
const deletes = { assignments: [], denies: [] };
|
|
1164
|
+
const seenAssignments = new Set();
|
|
1165
|
+
const seenDenies = new Set();
|
|
1166
|
+
let destinationRows = 0;
|
|
1167
|
+
const countDestination = () => {
|
|
1168
|
+
destinationRows += 1;
|
|
1169
|
+
if (destinationRows > maxTuples) {
|
|
1170
|
+
throw new ReconcileTooLargeError(`authz:reconcile --to=database: el DESTINO pasa de maxTuples (${maxTuples}) filas y la pasada ` +
|
|
1171
|
+
`necesita la foto entera para saber qué sobra. Sube maxTuples si tu proceso lo aguanta; ` +
|
|
1172
|
+
`no hay migración por particiones en esta versión.`);
|
|
1173
|
+
}
|
|
1174
|
+
};
|
|
1175
|
+
await this.eachReconcileRow('reconcile.assignments', batchSize, (query) => query
|
|
1176
|
+
.from('authz_assignments')
|
|
1177
|
+
.select('uuid', 'holder_type', 'holder_uuid', 'role_uuid', 'scope_type', 'scope_uuid')
|
|
1178
|
+
.select(this.expiry.select('expires_at')), (row) => {
|
|
1179
|
+
countDestination();
|
|
1180
|
+
const key = assignmentKey(row);
|
|
1181
|
+
const target = wantedAssignments.get(key);
|
|
1182
|
+
if (target) {
|
|
1183
|
+
seenAssignments.add(key);
|
|
1184
|
+
if (sameInstant(this.expiry.fromDb(row.expires_at), target.expiresAt)) {
|
|
1185
|
+
phases.facts.unchanged += 1;
|
|
1186
|
+
return;
|
|
1187
|
+
}
|
|
1188
|
+
phases.facts.updated += 1;
|
|
1189
|
+
updates.push({ uuid: String(row.uuid), expiresAt: target.expiresAt });
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
phases.facts.extra += 1;
|
|
1193
|
+
if (!prune) {
|
|
1194
|
+
note('assignment', 'extra-fact', reconcileRowLabel(row));
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
phases.facts.deleted += 1;
|
|
1198
|
+
deletes.assignments.push(String(row.uuid));
|
|
1199
|
+
});
|
|
1200
|
+
await this.eachReconcileRow('reconcile.denies', batchSize, (query) => query
|
|
1201
|
+
.from('authz_denies')
|
|
1202
|
+
.select('uuid', 'holder_type', 'holder_uuid', 'permission_uuid', 'scope_type', 'scope_uuid'), (row) => {
|
|
1203
|
+
countDestination();
|
|
1204
|
+
const key = denyKey(row);
|
|
1205
|
+
if (wantedDenies.has(key)) {
|
|
1206
|
+
seenDenies.add(key);
|
|
1207
|
+
phases.facts.unchanged += 1;
|
|
1208
|
+
return;
|
|
1209
|
+
}
|
|
1210
|
+
phases.facts.extra += 1;
|
|
1211
|
+
if (!prune) {
|
|
1212
|
+
note('deny', 'extra-fact', reconcileRowLabel(row));
|
|
1213
|
+
return;
|
|
1214
|
+
}
|
|
1215
|
+
phases.facts.deleted += 1;
|
|
1216
|
+
deletes.denies.push(String(row.uuid));
|
|
1217
|
+
});
|
|
1218
|
+
for (const [key, target] of wantedAssignments) {
|
|
1219
|
+
if (seenAssignments.has(key))
|
|
1220
|
+
continue;
|
|
1221
|
+
phases.facts.written += 1;
|
|
1222
|
+
inserts.push({
|
|
1223
|
+
table: 'authz_assignments',
|
|
1224
|
+
row: {
|
|
1225
|
+
uuid: uuidv7(),
|
|
1226
|
+
...target.row,
|
|
1227
|
+
expires_at: this.expiry.toDb(target.expiresAt),
|
|
1228
|
+
created_at: systemClock(),
|
|
1229
|
+
},
|
|
1230
|
+
});
|
|
1231
|
+
}
|
|
1232
|
+
for (const [key, row] of wantedDenies) {
|
|
1233
|
+
if (seenDenies.has(key))
|
|
1234
|
+
continue;
|
|
1235
|
+
phases.facts.written += 1;
|
|
1236
|
+
inserts.push({ table: 'authz_denies', row: { uuid: uuidv7(), ...row, created_at: systemClock() } });
|
|
1237
|
+
}
|
|
1238
|
+
/* 3. El seguro del origen ciego (AA2 aplicado a la migración; 3b-8 · B1:
|
|
1239
|
+
* sobre los hechos UTILIZABLES, no el conteo crudo — un origen que
|
|
1240
|
+
* devuelve N hechos y los N se descartan sigue siendo un origen que
|
|
1241
|
+
* no respalda NADA de lo que `--prune` va a borrar). */
|
|
1242
|
+
const massDelete = prune && phases.facts.deleted > 0 && usableFacts === 0;
|
|
1243
|
+
if (massDelete && !dryRun && options.allowMassDelete !== true) {
|
|
1244
|
+
throw new MassReconcileRefusedError(`authz:reconcile --to=database --prune borraría ${phases.facts.deleted} fila(s) de hechos y el ORIGEN ` +
|
|
1245
|
+
`no ha devuelto NI UN hecho utilizable (${sourceFacts} leídos, todos descartados — mira 'skipped'). ` +
|
|
1246
|
+
`Eso es la firma de un store equivocado (o vacío, o de un árbol que ya no resuelve ninguno de sus ` +
|
|
1247
|
+
`scopes), no de una base que sobra: esta pasada dejaría 'authz_assignments'/'authz_denies' sin nada ` +
|
|
1248
|
+
`concedido. Comprueba el --from y el store; si de verdad quieres vaciarlas, --allow-mass-delete.`);
|
|
1249
|
+
}
|
|
1250
|
+
/* 4. Aplicar. Deletes primero: si muere a medias, el destino queda de
|
|
1251
|
+
* MENOS (fail-closed), nunca con dos filas del mismo hecho. */
|
|
1252
|
+
if (!dryRun) {
|
|
1253
|
+
for (let i = 0; i < deletes.assignments.length; i += batchSize) {
|
|
1254
|
+
const slice = deletes.assignments.slice(i, i + batchSize);
|
|
1255
|
+
await this.sql('reconcile.delete.assignments', () => db.from('authz_assignments').whereIn('uuid', slice).delete());
|
|
1256
|
+
}
|
|
1257
|
+
for (let i = 0; i < deletes.denies.length; i += batchSize) {
|
|
1258
|
+
const slice = deletes.denies.slice(i, i + batchSize);
|
|
1259
|
+
await this.sql('reconcile.delete.denies', () => db.from('authz_denies').whereIn('uuid', slice).delete());
|
|
1260
|
+
}
|
|
1261
|
+
for (const update of updates) {
|
|
1262
|
+
await this.sql('reconcile.update', () => db.from('authz_assignments').where('uuid', update.uuid).update({ expires_at: this.expiry.toDb(update.expiresAt) }));
|
|
1263
|
+
}
|
|
1264
|
+
for (const table of ['authz_assignments', 'authz_denies']) {
|
|
1265
|
+
const rows = inserts.filter((i) => i.table === table).map((i) => i.row);
|
|
1266
|
+
for (let i = 0; i < rows.length; i += batchSize) {
|
|
1267
|
+
const slice = rows.slice(i, i + batchSize);
|
|
1268
|
+
await this.sql('reconcile.insert', () => db.table(table).insert(slice));
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
const totals = sumReconcilePhases(phases);
|
|
1273
|
+
return {
|
|
1274
|
+
to: 'database',
|
|
1275
|
+
dryRun,
|
|
1276
|
+
prune,
|
|
1277
|
+
...totals,
|
|
1278
|
+
phases,
|
|
1279
|
+
skipped,
|
|
1280
|
+
details,
|
|
1281
|
+
// El árbol NO se migra en esta dirección, así que no hay ciclos que
|
|
1282
|
+
// reportar ni aristas de más: los ciclos del árbol del consumidor los
|
|
1283
|
+
// ve `--to=openfga`, que es quien lo copia.
|
|
1284
|
+
cycles: [],
|
|
1285
|
+
drift: { rootMarker: false, multiParent: [], roleVisibility: 0, pendingRelay: 0, deadRelay: 0 },
|
|
1286
|
+
massDelete,
|
|
1287
|
+
};
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Pasea una tabla `authz_*` por lotes con cursor sobre `uuid` (PK: orden
|
|
1291
|
+
* total y estable). Lo mismo que hace `openfga.reconcile` con el origen,
|
|
1292
|
+
* por el mismo motivo: una base grande no entra entera de golpe.
|
|
1293
|
+
*/
|
|
1294
|
+
async eachReconcileRow(operation, batchSize, build, handle) {
|
|
1295
|
+
let after;
|
|
1296
|
+
for (;;) {
|
|
1297
|
+
const rows = await this.sql(operation, () => {
|
|
1298
|
+
const query = build(db);
|
|
1299
|
+
if (after !== undefined)
|
|
1300
|
+
query.where('uuid', '>', after);
|
|
1301
|
+
return query.orderBy('uuid', 'asc').limit(batchSize);
|
|
1302
|
+
});
|
|
1303
|
+
for (const row of rows)
|
|
1304
|
+
handle(row);
|
|
1305
|
+
if (rows.length < batchSize)
|
|
1306
|
+
return;
|
|
1307
|
+
after = String(rows[rows.length - 1].uuid);
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
824
1310
|
}
|
|
825
1311
|
//# sourceMappingURL=database_driver.js.map
|