@jantstack/adonis-authz 2.0.0-alpha.1 → 2.4.0-alpha.1
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 +462 -35
- 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 +73 -0
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
- package/build/commands/authz_relations_reconcile.js +225 -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 +39 -10
- package/build/index.d.ts.map +1 -1
- package/build/index.js +35 -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 +48 -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 +101 -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 +86 -4
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +425 -11
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +75 -0
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
- package/build/src/drivers/database_relations_driver.js +450 -0
- package/build/src/drivers/database_relations_driver.js.map +1 -0
- package/build/src/drivers/openfga_driver.d.ts +713 -119
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +2048 -476
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_facts.d.ts +369 -0
- package/build/src/drivers/openfga_facts.d.ts.map +1 -0
- package/build/src/drivers/openfga_facts.js +813 -0
- package/build/src/drivers/openfga_facts.js.map +1 -0
- package/build/src/drivers/openfga_relations_driver.d.ts +120 -0
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
- package/build/src/drivers/openfga_relations_driver.js +466 -0
- package/build/src/drivers/openfga_relations_driver.js.map +1 -0
- package/build/src/errors.d.ts +258 -5
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +238 -7
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +120 -0
- package/build/src/freeze.d.ts.map +1 -0
- package/build/src/freeze.js +172 -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 +315 -4
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +1187 -181
- package/build/src/manager.js.map +1 -1
- package/build/src/models/authz_assignment.d.ts +6 -6
- package/build/src/models/authz_assignment.d.ts.map +1 -1
- package/build/src/models/authz_deny.d.ts +6 -6
- package/build/src/models/authz_deny.d.ts.map +1 -1
- package/build/src/models/authz_permission.d.ts +6 -6
- package/build/src/models/authz_permission.d.ts.map +1 -1
- package/build/src/models/authz_role.d.ts +6 -6
- package/build/src/models/authz_role.d.ts.map +1 -1
- package/build/src/models/authz_role_permission.d.ts +6 -6
- 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 +85 -0
- package/build/src/relation_partition_trigger.js.map +1 -0
- package/build/src/relations/define_relations_config.d.ts +58 -0
- package/build/src/relations/define_relations_config.d.ts.map +1 -0
- package/build/src/relations/define_relations_config.js +144 -0
- package/build/src/relations/define_relations_config.js.map +1 -0
- package/build/src/relations/manager.d.ts +38 -0
- package/build/src/relations/manager.d.ts.map +1 -0
- package/build/src/relations/manager.js +156 -0
- package/build/src/relations/manager.js.map +1 -0
- package/build/src/relations/reconcile.d.ts +62 -0
- package/build/src/relations/reconcile.d.ts.map +1 -0
- package/build/src/relations/reconcile.js +138 -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 +291 -0
- package/build/src/scope_outbox.js.map +1 -0
- package/build/src/{drivers → shared}/backend_guard.d.ts +14 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -0
- package/build/src/{drivers → shared}/backend_guard.js +26 -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/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 +74 -0
- package/build/src/testing/contract.d.ts.map +1 -1
- package/build/src/testing/contract.js +672 -169
- package/build/src/testing/contract.js.map +1 -1
- package/build/src/testing/main.d.ts +6 -0
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +3 -0
- 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 +51 -0
- package/build/src/testing/relations_contract.d.ts.map +1 -0
- package/build/src/testing/relations_contract.js +654 -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 +172 -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 +7 -7
- package/build/src/traits/has_uuid.d.ts.map +1 -1
- package/build/src/types.d.ts +865 -82
- 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 +126 -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,13 @@ 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, } from '../errors.js';
|
|
7
|
+
import { RECONCILE_MAX_DETAILS, emptyReconcilePhases, reconcileBatchSize, reconcileMaxTuples, sumReconcilePhases, } from '../reconcile.js';
|
|
8
|
+
import { assertKnownScope, canonicalScope, canonicalScopeTargets, guardSql, resolveChain, rootOnlyResolver } from '../shared/backend_guard.js';
|
|
9
|
+
import { assertAssignableAt } from '../catalog/catalog.js';
|
|
10
|
+
import { CatalogCache, GLOBAL_OWNER_KEY, assertCatalogOptions, isRoleVisibleWith, withAuthzCatalogWrite } from '../catalog/catalog_cache.js';
|
|
10
11
|
import { isClock, systemClock } from '../clock.js';
|
|
11
|
-
import { sqlExpiryCodec } from '
|
|
12
|
+
import { sqlExpiryCodec } from '../shared/sql_expiry.js';
|
|
12
13
|
/**
|
|
13
14
|
* UUID centinela para el scope 'app' en las columnas scope_uuid (NOT NULL):
|
|
14
15
|
* permite que el unique de assignments/denies cubra también el nivel app
|
|
@@ -23,7 +24,15 @@ function toDbScopeUuid(scope) {
|
|
|
23
24
|
assertScope(scope);
|
|
24
25
|
return scope.uuid ?? APP_SCOPE_DB_UUID;
|
|
25
26
|
}
|
|
26
|
-
|
|
27
|
+
/**
|
|
28
|
+
* La vuelta de `toDbScopeUuid`: el centinela de la raíz vuelve a ser `null`.
|
|
29
|
+
* **Exportada desde 3b-3b**: `authz:reconcile --to=openfga` lee estas mismas
|
|
30
|
+
* columnas y hacía `row.scope_uuid ?? null`, que con la columna NOT NULL
|
|
31
|
+
* nunca es null — un grant o un deny en la RAÍZ reventaban la migración con
|
|
32
|
+
* un 422 a mitad de pasada (`el scope 'app' no admite uuid`). Lo cazó el
|
|
33
|
+
* contrato de migración, cuya siembra fija sí concede en `app`.
|
|
34
|
+
*/
|
|
35
|
+
export function fromDbScopeUuid(uuid) {
|
|
27
36
|
return uuid === APP_SCOPE_DB_UUID ? null : uuid;
|
|
28
37
|
}
|
|
29
38
|
/**
|
|
@@ -38,6 +47,35 @@ function dbScopeKey(scope) {
|
|
|
38
47
|
function rowScopeKey(row) {
|
|
39
48
|
return `${row.scope_type}\u001f${row.scope_uuid}`;
|
|
40
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Clave de comparación de una asignación. **Es exactamente el unique
|
|
52
|
+
* `authz_asg_holder_role_scope_uq`** y NO incluye `expires_at`: cambiar la
|
|
53
|
+
* caducidad es un UPDATE de la misma fila, no una fila nueva (invariante 6).
|
|
54
|
+
* Que la clave del diff sea la del índice es lo que hace que la migración no
|
|
55
|
+
* pueda dejar dos filas del mismo hecho ni chocar con el unique al insertar.
|
|
56
|
+
*/
|
|
57
|
+
function assignmentKey(row) {
|
|
58
|
+
return [row.holder_type, row.holder_uuid, row.role_uuid, row.scope_type, row.scope_uuid].join('\u001f');
|
|
59
|
+
}
|
|
60
|
+
function denyKey(row) {
|
|
61
|
+
return [row.holder_type, row.holder_uuid, row.permission_uuid, row.scope_type, row.scope_uuid].join('\u001f');
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* La caducidad que dura MÁS (`null` = para siempre). Es la regla con la que
|
|
65
|
+
* se resuelve un colapso de dos hechos del origen en una sola fila: el
|
|
66
|
+
* origen concedía mientras CUALQUIERA de los dos siguiera vivo, así que la
|
|
67
|
+
* unión es lo que conserva la respuesta.
|
|
68
|
+
*/
|
|
69
|
+
function longerExpiry(a, b) {
|
|
70
|
+
if (a === null || b === null)
|
|
71
|
+
return null;
|
|
72
|
+
return a >= b ? a : b;
|
|
73
|
+
}
|
|
74
|
+
/** Cómo se NOMBRA una fila del destino en `details`: sin esto un motivo no se arregla. */
|
|
75
|
+
function reconcileRowLabel(row) {
|
|
76
|
+
const what = row.role_uuid ? `→ ${row.role_uuid}` : `⊘ ${row.permission_uuid}`;
|
|
77
|
+
return `${row.holder_type}:${row.holder_uuid} ${what} @ ${row.scope_type}:${row.scope_uuid}`;
|
|
78
|
+
}
|
|
41
79
|
/**
|
|
42
80
|
* El rol `(slug, scopeType)` que existe en el scope cuya cadena empieza por
|
|
43
81
|
* `chainKeys`, o 422: `E_AUTHZ_ROLE_NOT_VISIBLE` si hay roles con ese nombre
|
|
@@ -220,6 +258,31 @@ export const DEFAULT_TIMEOUT_MS = 5_000;
|
|
|
220
258
|
* crudo de knex (L0.11).
|
|
221
259
|
*/
|
|
222
260
|
export class DatabaseAuthorizationDriver {
|
|
261
|
+
/**
|
|
262
|
+
* Lo que este driver declara (3b-2e · E2). El árbol es del consumidor
|
|
263
|
+
* (`resolveChain`), así que no hay hechos de jerarquía ni deriva que
|
|
264
|
+
* mitigar; `authorize` son varias consultas SQL; la membresía la resuelve
|
|
265
|
+
* el propio SQL contra la cadena que le pasan; los `list*` no enumeran
|
|
266
|
+
* herencia (invariante 7); y `purgeRole` está implementado en una
|
|
267
|
+
* transacción.
|
|
268
|
+
*/
|
|
269
|
+
capabilities = Object.freeze({
|
|
270
|
+
hierarchyFacts: false,
|
|
271
|
+
singleCheckAuthorize: false,
|
|
272
|
+
roleInheritanceNative: false,
|
|
273
|
+
listObjectsInherited: false,
|
|
274
|
+
purgeRole: true,
|
|
275
|
+
countRoleAssignments: true,
|
|
276
|
+
// 3b-2k · K1: `authorize` resuelve la cadena y usa `chain[0]`, así que un
|
|
277
|
+
// alias del uuid que el árbol funde con la fila real encuentra sus hechos.
|
|
278
|
+
canonicalScopeReads: true,
|
|
279
|
+
// 3b-3b: sus hechos son `authz_assignments`/`authz_denies` —el esquema
|
|
280
|
+
// PUBLICADO del paquete—, así que el destino los lee de ahí y no por el
|
|
281
|
+
// puerto. No es «no sabe»: es que no hace falta un método para leer una
|
|
282
|
+
// tabla documentada. Un driver de terceros que quiera ser origen sí lo
|
|
283
|
+
// necesita, y por eso la capacidad tiene sus dos caras.
|
|
284
|
+
enumerateFacts: false,
|
|
285
|
+
});
|
|
223
286
|
/**
|
|
224
287
|
* Resolutor de jerarquía inyectado por el consumidor (el chasis pasa el
|
|
225
288
|
* suyo, que conoce organizations/units, en `config/authorization.ts`).
|
|
@@ -296,10 +359,14 @@ export class DatabaseAuthorizationDriver {
|
|
|
296
359
|
knownScope(scope, operation) {
|
|
297
360
|
return assertKnownScope(this.chainResolver, scope, operation);
|
|
298
361
|
}
|
|
299
|
-
/** El scope canónico para `
|
|
362
|
+
/** El scope canónico para `purgeScope` (ver `canonicalScope`). */
|
|
300
363
|
canonicalOrSelf(scope, operation) {
|
|
301
364
|
return canonicalScope(this.chainResolver, scope, operation);
|
|
302
365
|
}
|
|
366
|
+
/** Los destinos de un delete de hechos (`revoke`/`removeDeny`): canónico, o el fan-out de alias (3b-8 · A4). */
|
|
367
|
+
canonicalTargets(scope, operation) {
|
|
368
|
+
return canonicalScopeTargets(this.chainResolver, scope, operation);
|
|
369
|
+
}
|
|
303
370
|
/**
|
|
304
371
|
* Ejecuta una consulta clasificando su fallo (503 con código propio). `fn`
|
|
305
372
|
* devuelve el BUILDER sin ejecutar: el guard le fija el deadline y luego lo
|
|
@@ -478,12 +545,16 @@ export class DatabaseAuthorizationDriver {
|
|
|
478
545
|
// scope puede no existir ya para el árbol (D8), así que no se filtra por
|
|
479
546
|
// visibilidad — quitar nunca concede.
|
|
480
547
|
const named = rolesToRevoke(await this.catalog.view(), role, scope);
|
|
481
|
-
|
|
548
|
+
// Sin cadena con la que canonizar, TODAS las ortografías de las que el
|
|
549
|
+
// uuid puede ser alias (3b-8 · A4, mismo fan-out que `scopes.detached`):
|
|
550
|
+
// con una sola, un alias hacía del DELETE un no-op silencioso y la fila
|
|
551
|
+
// canónica seguía concediendo si el scope se restauraba.
|
|
552
|
+
const targets = await this.canonicalTargets(scope, 'revoke');
|
|
482
553
|
await this.sql('revoke', () => whereScopeIn(db
|
|
483
554
|
.from('authz_assignments')
|
|
484
555
|
.where('holder_type', subject.type)
|
|
485
556
|
.where('holder_uuid', subject.uuid)
|
|
486
|
-
.whereIn('role_uuid', named.map((r) => r.uuid)), 'scope',
|
|
557
|
+
.whereIn('role_uuid', named.map((r) => r.uuid)), 'scope', targets, 'write').delete());
|
|
487
558
|
}
|
|
488
559
|
async hasRole(subject, role, scope) {
|
|
489
560
|
assertIdentity({ subject, role, scope });
|
|
@@ -552,12 +623,14 @@ export class DatabaseAuthorizationDriver {
|
|
|
552
623
|
const perm = await this.findPermission(permission);
|
|
553
624
|
if (!perm)
|
|
554
625
|
throw new UnknownPermissionError(permission);
|
|
555
|
-
|
|
626
|
+
// Mismo fan-out que `revoke` (3b-8 · A4): quitar un deny de menos por un
|
|
627
|
+
// alias sería un deny fantasma que nadie puede levantar.
|
|
628
|
+
const targets = await this.canonicalTargets(scope, 'removeDeny');
|
|
556
629
|
await this.sql('removeDeny', () => whereScopeIn(db
|
|
557
630
|
.from('authz_denies')
|
|
558
631
|
.where('holder_type', subject.type)
|
|
559
632
|
.where('holder_uuid', subject.uuid)
|
|
560
|
-
.where('permission_uuid', perm.uuid), 'scope',
|
|
633
|
+
.where('permission_uuid', perm.uuid), 'scope', targets, 'write').delete());
|
|
561
634
|
}
|
|
562
635
|
async listSubjects(role, scope) {
|
|
563
636
|
assertIdentity({ role, scope });
|
|
@@ -821,5 +894,346 @@ export class DatabaseAuthorizationDriver {
|
|
|
821
894
|
this.catalog.invalidate();
|
|
822
895
|
}
|
|
823
896
|
}
|
|
897
|
+
/**
|
|
898
|
+
* Cuántos hechos VIGENTES tiene cada rol, en todos los scopes (3b-2j). Es
|
|
899
|
+
* lo que `pruneOrphanRoles` necesita para decir si un huérfano todavía
|
|
900
|
+
* concede, y es una pregunta del PUERTO porque los hechos son del driver:
|
|
901
|
+
* aquí son filas de `authz_assignments`, en `openfga` son tuplas del store.
|
|
902
|
+
*
|
|
903
|
+
* Una consulta agrupada para todos los uuids (no una por rol) y la misma
|
|
904
|
+
* caducidad ESTRICTA que el resto del driver, con SU reloj (`whereActive`,
|
|
905
|
+
* J1): la asignación que vence ahora ya no cuenta, como no cuenta en
|
|
906
|
+
* `authorize`. Un rol sin hechos —o que no está en la tabla— es `0`.
|
|
907
|
+
*/
|
|
908
|
+
async countRoleAssignments(roleUuids) {
|
|
909
|
+
for (const uuid of roleUuids)
|
|
910
|
+
assertCatalogUuid('rol', uuid);
|
|
911
|
+
if (roleUuids.length === 0)
|
|
912
|
+
return [];
|
|
913
|
+
const rows = await this.sql('countRoleAssignments', () => this.whereActive(db.from('authz_assignments').whereIn('role_uuid', roleUuids))
|
|
914
|
+
.groupBy('role_uuid')
|
|
915
|
+
.select('role_uuid')
|
|
916
|
+
.count('* as total'));
|
|
917
|
+
const totals = new Map();
|
|
918
|
+
for (const row of rows)
|
|
919
|
+
totals.set(String(row.role_uuid), Number(row.total ?? row.count ?? 0));
|
|
920
|
+
return roleUuids.map((uuid) => totals.get(uuid) ?? 0);
|
|
921
|
+
}
|
|
922
|
+
/* ── `authz:reconcile --to=database` (3b-3b) ─────────────────────────── */
|
|
923
|
+
/**
|
|
924
|
+
* **Rehace `authz_assignments`/`authz_denies` desde los hechos de OTRO
|
|
925
|
+
* driver** — la vuelta de la migración, y con ella la simetría que el dueño
|
|
926
|
+
* compró: «todo en un driver o todo en otro».
|
|
927
|
+
*
|
|
928
|
+
* Qué se migra y qué NO, y por qué:
|
|
929
|
+
* - **los HECHOS, sí**: es lo único que vive en el otro driver. Llegan por
|
|
930
|
+
* `source.facts` —el puerto `enumerateFacts`, paginado— y no por SQL:
|
|
931
|
+
* aquí el origen es un store, no una tabla de este paquete.
|
|
932
|
+
* - **el ÁRBOL, no**. En esta dirección el árbol NO se migra: el driver
|
|
933
|
+
* `database` lo lee de las tablas del CONSUMIDOR en cada pregunta
|
|
934
|
+
* (`resolveChain`), que son su fuente de verdad. Copiarlo a algún sitio
|
|
935
|
+
* sería inventarse una segunda copia y con ella una deriva que hoy no
|
|
936
|
+
* existe. Se usa, eso sí, para decidir qué hecho es migrable
|
|
937
|
+
* (`unknown-scope`) y bajo qué identidad CANÓNICA se escribe
|
|
938
|
+
* (invariante 17).
|
|
939
|
+
* - **el CATÁLOGO, tampoco**: es propiedad local SIEMPRE (regla de higiene
|
|
940
|
+
* del paquete), ya está en `authz_*` y ningún driver es su fuente. Aquí
|
|
941
|
+
* solo se usa para traducir: un `roleUuid` que el catálogo no declara y
|
|
942
|
+
* un permiso que no existe no son hechos migrables.
|
|
943
|
+
*
|
|
944
|
+
* Por eso `phases.root`, `phases.catalog` y `phases.tree` son CERO en esta
|
|
945
|
+
* dirección, y todo el movimiento está en `phases.facts`. Es una respuesta,
|
|
946
|
+
* no un hueco: los ceros dicen «aquí no hay nada derivado que rehacer».
|
|
947
|
+
*
|
|
948
|
+
* El resto del contrato es el mismo que en la ida (3b-3a): idempotente (la
|
|
949
|
+
* segunda pasada escribe cero), `dryRun` es el VERIFICADOR y es read-only
|
|
950
|
+
* por contrato —**un `--fix` está prohibido** (cruce 4 · S18) y no se
|
|
951
|
+
* implementa ni se deja preparado—, nunca silenciosa (`skipped{motivo}` +
|
|
952
|
+
* `details`), los hechos que sobran solo se borran con `prune`, y el
|
|
953
|
+
* seguro del origen ciego (`E_AUTHZ_MASS_RECONCILE_REFUSED`) se aplica
|
|
954
|
+
* igual: un origen que devuelve CERO hechos y un `--prune` detrás vacían
|
|
955
|
+
* la base, y eso es casi siempre apuntar al store equivocado.
|
|
956
|
+
*/
|
|
957
|
+
async reconcile(source, options = {}) {
|
|
958
|
+
const dryRun = options.dryRun === true;
|
|
959
|
+
const prune = options.prune === true;
|
|
960
|
+
const batchSize = reconcileBatchSize(options.batchSize);
|
|
961
|
+
const maxTuples = reconcileMaxTuples(options.maxTuples);
|
|
962
|
+
const catalog = await this.catalog.view();
|
|
963
|
+
const now = this.now();
|
|
964
|
+
const skipped = {};
|
|
965
|
+
const details = [];
|
|
966
|
+
const note = (kind, reason, detail) => {
|
|
967
|
+
skipped[reason] = (skipped[reason] ?? 0) + 1;
|
|
968
|
+
if (details.length < RECONCILE_MAX_DETAILS)
|
|
969
|
+
details.push({ kind, reason, detail });
|
|
970
|
+
};
|
|
971
|
+
/** `[scope canónico, ...]` memoizado: el árbol del consumidor manda. */
|
|
972
|
+
const chains = new Map();
|
|
973
|
+
const canonical = async (scope) => {
|
|
974
|
+
const key = dbScopeKey(scope);
|
|
975
|
+
if (!chains.has(key)) {
|
|
976
|
+
chains.set(key, await resolveChain(source.resolveChain, scope, 'reconcile'));
|
|
977
|
+
}
|
|
978
|
+
const chain = chains.get(key);
|
|
979
|
+
return chain === null ? null : chain[0];
|
|
980
|
+
};
|
|
981
|
+
/* 1. Los HECHOS del origen, paginados por el puerto. */
|
|
982
|
+
const wantedAssignments = new Map();
|
|
983
|
+
const wantedDenies = new Map();
|
|
984
|
+
let sourceFacts = 0;
|
|
985
|
+
/**
|
|
986
|
+
* Hechos del origen que RESPALDAN algo en el destino (3b-8 · B1). El
|
|
987
|
+
* seguro de borrado masivo miraba `sourceFacts`, el conteo CRUDO — se
|
|
988
|
+
* incrementa ANTES de cada skip—, así que un origen cuyos hechos se
|
|
989
|
+
* descartan TODOS (caducados, scopes que ya no resuelven: la firma de un
|
|
990
|
+
* store equivocado o de un resolutor ciego) lo esquivaba y `--prune`
|
|
991
|
+
* vaciaba `authz_assignments`/`authz_denies` sin una sola pregunta. Es el
|
|
992
|
+
* primo del 🔴 del lote 5, un escalón abajo: el seguro miraba si el
|
|
993
|
+
* origen estaba VACÍO, no si estaba VACÍO DE HECHOS UTILIZABLES.
|
|
994
|
+
*/
|
|
995
|
+
let usableFacts = 0;
|
|
996
|
+
const enumerate = source.facts;
|
|
997
|
+
if (typeof enumerate !== 'function') {
|
|
998
|
+
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 ` +
|
|
999
|
+
`cero hechos y con --prune vaciaría 'authz_assignments'/'authz_denies'. El driver 'database' ` +
|
|
1000
|
+
`no lo implementa a propósito (sus hechos SON esas tablas).`);
|
|
1001
|
+
}
|
|
1002
|
+
let after;
|
|
1003
|
+
const seenCursors = new Set();
|
|
1004
|
+
for (let page = 0;; page++) {
|
|
1005
|
+
const got = await enumerate({ limit: batchSize, after });
|
|
1006
|
+
if (got.facts.length > batchSize) {
|
|
1007
|
+
throw new AuthorizationInternalError(`authz:reconcile: el origen devolvió ${got.facts.length} hechos con limit=${batchSize}`);
|
|
1008
|
+
}
|
|
1009
|
+
for (const skip of got.skipped ?? [])
|
|
1010
|
+
note(skip.kind, skip.reason, skip.detail);
|
|
1011
|
+
for (const fact of got.facts) {
|
|
1012
|
+
sourceFacts += 1;
|
|
1013
|
+
if (sourceFacts > maxTuples) {
|
|
1014
|
+
throw new ReconcileTooLargeError(`authz:reconcile --to=database: el ORIGEN pasa de maxTuples (${maxTuples}) hechos y la pasada ` +
|
|
1015
|
+
`necesita comparar contra el estado ENTERO del destino, que entra en memoria. Sube maxTuples ` +
|
|
1016
|
+
`si tu proceso lo aguanta; no hay migración por particiones en esta versión.`);
|
|
1017
|
+
}
|
|
1018
|
+
const target = await canonical(fact.scope);
|
|
1019
|
+
if (target === null) {
|
|
1020
|
+
note(fact.kind, 'unknown-scope', fact.detail);
|
|
1021
|
+
continue;
|
|
1022
|
+
}
|
|
1023
|
+
if (fact.kind === 'assignment') {
|
|
1024
|
+
if (fact.expiresAt !== null && fact.expiresAt !== undefined && fact.expiresAt <= now) {
|
|
1025
|
+
note('assignment', 'expired', fact.detail);
|
|
1026
|
+
continue;
|
|
1027
|
+
}
|
|
1028
|
+
const role = fact.roleUuid ? catalog.roleByUuid(fact.roleUuid) : null;
|
|
1029
|
+
if (!role) {
|
|
1030
|
+
note('assignment', 'unknown-role', fact.detail);
|
|
1031
|
+
continue;
|
|
1032
|
+
}
|
|
1033
|
+
const row = {
|
|
1034
|
+
holder_type: fact.holder.type,
|
|
1035
|
+
holder_uuid: fact.holder.uuid,
|
|
1036
|
+
role_uuid: role.uuid,
|
|
1037
|
+
scope_type: target.type,
|
|
1038
|
+
scope_uuid: toDbScopeUuid(target),
|
|
1039
|
+
};
|
|
1040
|
+
// **Dos hechos del origen que colapsan en UNA fila** (S15, medido):
|
|
1041
|
+
// el store distingue `scope:unit|AAA` de `scope:unit|aaa` y la tabla
|
|
1042
|
+
// del consumidor no puede tener las dos (columna `uuid` en
|
|
1043
|
+
// PostgreSQL, collation `*_ci` en MySQL), así que la cadena
|
|
1044
|
+
// canónica las funde. No se pierde en silencio: se cuenta con su
|
|
1045
|
+
// motivo, y la fila se queda con la caducidad que MÁS dura —que es
|
|
1046
|
+
// lo que el origen respondía, donde bastaba con que UNA siguiera
|
|
1047
|
+
// viva—.
|
|
1048
|
+
usableFacts += 1;
|
|
1049
|
+
const key = assignmentKey(row);
|
|
1050
|
+
const before = wantedAssignments.get(key);
|
|
1051
|
+
if (before) {
|
|
1052
|
+
note('assignment', 'folded-scope', `${before.detail} ≡ ${fact.detail}`);
|
|
1053
|
+
}
|
|
1054
|
+
const expiresAt = fact.expiresAt ?? null;
|
|
1055
|
+
if (!before || longerExpiry(before.expiresAt, expiresAt) === expiresAt) {
|
|
1056
|
+
wantedAssignments.set(key, { row, expiresAt, detail: fact.detail });
|
|
1057
|
+
}
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
const permission = fact.permission ? catalog.permission(fact.permission) : null;
|
|
1061
|
+
if (!permission) {
|
|
1062
|
+
note('deny', 'unknown-permission', fact.detail);
|
|
1063
|
+
continue;
|
|
1064
|
+
}
|
|
1065
|
+
const row = {
|
|
1066
|
+
holder_type: fact.holder.type,
|
|
1067
|
+
holder_uuid: fact.holder.uuid,
|
|
1068
|
+
permission_uuid: permission.uuid,
|
|
1069
|
+
scope_type: target.type,
|
|
1070
|
+
scope_uuid: toDbScopeUuid(target),
|
|
1071
|
+
};
|
|
1072
|
+
usableFacts += 1;
|
|
1073
|
+
const denyId = denyKey(row);
|
|
1074
|
+
if (wantedDenies.has(denyId))
|
|
1075
|
+
note('deny', 'folded-scope', fact.detail);
|
|
1076
|
+
wantedDenies.set(denyId, row);
|
|
1077
|
+
}
|
|
1078
|
+
const cursor = got.cursor;
|
|
1079
|
+
if (!cursor)
|
|
1080
|
+
break;
|
|
1081
|
+
if (seenCursors.has(cursor)) {
|
|
1082
|
+
throw new AuthorizationInternalError(`authz:reconcile: el cursor del origen se repite (página ${page + 1}); no avanza`);
|
|
1083
|
+
}
|
|
1084
|
+
seenCursors.add(cursor);
|
|
1085
|
+
after = cursor;
|
|
1086
|
+
}
|
|
1087
|
+
/* 2. El DESTINO entero, tal como está. */
|
|
1088
|
+
const phases = emptyReconcilePhases();
|
|
1089
|
+
const inserts = [];
|
|
1090
|
+
const updates = [];
|
|
1091
|
+
const deletes = { assignments: [], denies: [] };
|
|
1092
|
+
const seenAssignments = new Set();
|
|
1093
|
+
const seenDenies = new Set();
|
|
1094
|
+
let destinationRows = 0;
|
|
1095
|
+
const countDestination = () => {
|
|
1096
|
+
destinationRows += 1;
|
|
1097
|
+
if (destinationRows > maxTuples) {
|
|
1098
|
+
throw new ReconcileTooLargeError(`authz:reconcile --to=database: el DESTINO pasa de maxTuples (${maxTuples}) filas y la pasada ` +
|
|
1099
|
+
`necesita la foto entera para saber qué sobra. Sube maxTuples si tu proceso lo aguanta; ` +
|
|
1100
|
+
`no hay migración por particiones en esta versión.`);
|
|
1101
|
+
}
|
|
1102
|
+
};
|
|
1103
|
+
await this.eachReconcileRow('reconcile.assignments', batchSize, (query) => query
|
|
1104
|
+
.from('authz_assignments')
|
|
1105
|
+
.select('uuid', 'holder_type', 'holder_uuid', 'role_uuid', 'scope_type', 'scope_uuid')
|
|
1106
|
+
.select(this.expiry.select('expires_at')), (row) => {
|
|
1107
|
+
countDestination();
|
|
1108
|
+
const key = assignmentKey(row);
|
|
1109
|
+
const target = wantedAssignments.get(key);
|
|
1110
|
+
if (target) {
|
|
1111
|
+
seenAssignments.add(key);
|
|
1112
|
+
if (sameInstant(this.expiry.fromDb(row.expires_at), target.expiresAt)) {
|
|
1113
|
+
phases.facts.unchanged += 1;
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
phases.facts.updated += 1;
|
|
1117
|
+
updates.push({ uuid: String(row.uuid), expiresAt: target.expiresAt });
|
|
1118
|
+
return;
|
|
1119
|
+
}
|
|
1120
|
+
phases.facts.extra += 1;
|
|
1121
|
+
if (!prune) {
|
|
1122
|
+
note('assignment', 'extra-fact', reconcileRowLabel(row));
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
phases.facts.deleted += 1;
|
|
1126
|
+
deletes.assignments.push(String(row.uuid));
|
|
1127
|
+
});
|
|
1128
|
+
await this.eachReconcileRow('reconcile.denies', batchSize, (query) => query
|
|
1129
|
+
.from('authz_denies')
|
|
1130
|
+
.select('uuid', 'holder_type', 'holder_uuid', 'permission_uuid', 'scope_type', 'scope_uuid'), (row) => {
|
|
1131
|
+
countDestination();
|
|
1132
|
+
const key = denyKey(row);
|
|
1133
|
+
if (wantedDenies.has(key)) {
|
|
1134
|
+
seenDenies.add(key);
|
|
1135
|
+
phases.facts.unchanged += 1;
|
|
1136
|
+
return;
|
|
1137
|
+
}
|
|
1138
|
+
phases.facts.extra += 1;
|
|
1139
|
+
if (!prune) {
|
|
1140
|
+
note('deny', 'extra-fact', reconcileRowLabel(row));
|
|
1141
|
+
return;
|
|
1142
|
+
}
|
|
1143
|
+
phases.facts.deleted += 1;
|
|
1144
|
+
deletes.denies.push(String(row.uuid));
|
|
1145
|
+
});
|
|
1146
|
+
for (const [key, target] of wantedAssignments) {
|
|
1147
|
+
if (seenAssignments.has(key))
|
|
1148
|
+
continue;
|
|
1149
|
+
phases.facts.written += 1;
|
|
1150
|
+
inserts.push({
|
|
1151
|
+
table: 'authz_assignments',
|
|
1152
|
+
row: {
|
|
1153
|
+
uuid: uuidv7(),
|
|
1154
|
+
...target.row,
|
|
1155
|
+
expires_at: this.expiry.toDb(target.expiresAt),
|
|
1156
|
+
created_at: systemClock(),
|
|
1157
|
+
},
|
|
1158
|
+
});
|
|
1159
|
+
}
|
|
1160
|
+
for (const [key, row] of wantedDenies) {
|
|
1161
|
+
if (seenDenies.has(key))
|
|
1162
|
+
continue;
|
|
1163
|
+
phases.facts.written += 1;
|
|
1164
|
+
inserts.push({ table: 'authz_denies', row: { uuid: uuidv7(), ...row, created_at: systemClock() } });
|
|
1165
|
+
}
|
|
1166
|
+
/* 3. El seguro del origen ciego (AA2 aplicado a la migración; 3b-8 · B1:
|
|
1167
|
+
* sobre los hechos UTILIZABLES, no el conteo crudo — un origen que
|
|
1168
|
+
* devuelve N hechos y los N se descartan sigue siendo un origen que
|
|
1169
|
+
* no respalda NADA de lo que `--prune` va a borrar). */
|
|
1170
|
+
const massDelete = prune && phases.facts.deleted > 0 && usableFacts === 0;
|
|
1171
|
+
if (massDelete && !dryRun && options.allowMassDelete !== true) {
|
|
1172
|
+
throw new MassReconcileRefusedError(`authz:reconcile --to=database --prune borraría ${phases.facts.deleted} fila(s) de hechos y el ORIGEN ` +
|
|
1173
|
+
`no ha devuelto NI UN hecho utilizable (${sourceFacts} leídos, todos descartados — mira 'skipped'). ` +
|
|
1174
|
+
`Eso es la firma de un store equivocado (o vacío, o de un árbol que ya no resuelve ninguno de sus ` +
|
|
1175
|
+
`scopes), no de una base que sobra: esta pasada dejaría 'authz_assignments'/'authz_denies' sin nada ` +
|
|
1176
|
+
`concedido. Comprueba el --from y el store; si de verdad quieres vaciarlas, --allow-mass-delete.`);
|
|
1177
|
+
}
|
|
1178
|
+
/* 4. Aplicar. Deletes primero: si muere a medias, el destino queda de
|
|
1179
|
+
* MENOS (fail-closed), nunca con dos filas del mismo hecho. */
|
|
1180
|
+
if (!dryRun) {
|
|
1181
|
+
for (let i = 0; i < deletes.assignments.length; i += batchSize) {
|
|
1182
|
+
const slice = deletes.assignments.slice(i, i + batchSize);
|
|
1183
|
+
await this.sql('reconcile.delete.assignments', () => db.from('authz_assignments').whereIn('uuid', slice).delete());
|
|
1184
|
+
}
|
|
1185
|
+
for (let i = 0; i < deletes.denies.length; i += batchSize) {
|
|
1186
|
+
const slice = deletes.denies.slice(i, i + batchSize);
|
|
1187
|
+
await this.sql('reconcile.delete.denies', () => db.from('authz_denies').whereIn('uuid', slice).delete());
|
|
1188
|
+
}
|
|
1189
|
+
for (const update of updates) {
|
|
1190
|
+
await this.sql('reconcile.update', () => db.from('authz_assignments').where('uuid', update.uuid).update({ expires_at: this.expiry.toDb(update.expiresAt) }));
|
|
1191
|
+
}
|
|
1192
|
+
for (const table of ['authz_assignments', 'authz_denies']) {
|
|
1193
|
+
const rows = inserts.filter((i) => i.table === table).map((i) => i.row);
|
|
1194
|
+
for (let i = 0; i < rows.length; i += batchSize) {
|
|
1195
|
+
const slice = rows.slice(i, i + batchSize);
|
|
1196
|
+
await this.sql('reconcile.insert', () => db.table(table).insert(slice));
|
|
1197
|
+
}
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
const totals = sumReconcilePhases(phases);
|
|
1201
|
+
return {
|
|
1202
|
+
to: 'database',
|
|
1203
|
+
dryRun,
|
|
1204
|
+
prune,
|
|
1205
|
+
...totals,
|
|
1206
|
+
phases,
|
|
1207
|
+
skipped,
|
|
1208
|
+
details,
|
|
1209
|
+
// El árbol NO se migra en esta dirección, así que no hay ciclos que
|
|
1210
|
+
// reportar ni aristas de más: los ciclos del árbol del consumidor los
|
|
1211
|
+
// ve `--to=openfga`, que es quien lo copia.
|
|
1212
|
+
cycles: [],
|
|
1213
|
+
drift: { rootMarker: false, multiParent: [], roleVisibility: 0, pendingRelay: 0, deadRelay: 0 },
|
|
1214
|
+
massDelete,
|
|
1215
|
+
};
|
|
1216
|
+
}
|
|
1217
|
+
/**
|
|
1218
|
+
* Pasea una tabla `authz_*` por lotes con cursor sobre `uuid` (PK: orden
|
|
1219
|
+
* total y estable). Lo mismo que hace `openfga.reconcile` con el origen,
|
|
1220
|
+
* por el mismo motivo: una base grande no entra entera de golpe.
|
|
1221
|
+
*/
|
|
1222
|
+
async eachReconcileRow(operation, batchSize, build, handle) {
|
|
1223
|
+
let after;
|
|
1224
|
+
for (;;) {
|
|
1225
|
+
const rows = await this.sql(operation, () => {
|
|
1226
|
+
const query = build(db);
|
|
1227
|
+
if (after !== undefined)
|
|
1228
|
+
query.where('uuid', '>', after);
|
|
1229
|
+
return query.orderBy('uuid', 'asc').limit(batchSize);
|
|
1230
|
+
});
|
|
1231
|
+
for (const row of rows)
|
|
1232
|
+
handle(row);
|
|
1233
|
+
if (rows.length < batchSize)
|
|
1234
|
+
return;
|
|
1235
|
+
after = String(rows[rows.length - 1].uuid);
|
|
1236
|
+
}
|
|
1237
|
+
}
|
|
824
1238
|
}
|
|
825
1239
|
//# sourceMappingURL=database_driver.js.map
|