@jantstack/adonis-authz 1.1.0 → 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/LICENSE +1 -1
- package/README.md +837 -51
- package/build/commands/authz_catalog_diff.d.ts +28 -0
- package/build/commands/authz_catalog_diff.d.ts.map +1 -0
- package/build/commands/authz_catalog_diff.js +67 -0
- package/build/commands/authz_catalog_diff.js.map +1 -0
- 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 +37 -0
- package/build/commands/authz_catalog_sync.d.ts.map +1 -0
- package/build/commands/authz_catalog_sync.js +81 -0
- package/build/commands/authz_catalog_sync.js.map +1 -0
- 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 +8 -1
- package/build/commands/main.d.ts.map +1 -1
- package/build/commands/main.js +8 -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 +91 -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 +75 -7
- package/build/index.d.ts.map +1 -1
- package/build/index.js +67 -5
- 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/catalog.d.ts +289 -0
- package/build/src/catalog/catalog.d.ts.map +1 -0
- package/build/src/catalog/catalog.js +859 -0
- package/build/src/catalog/catalog.js.map +1 -0
- package/build/src/catalog/catalog_cache.d.ts +324 -0
- package/build/src/catalog/catalog_cache.d.ts.map +1 -0
- package/build/src/catalog/catalog_cache.js +666 -0
- package/build/src/catalog/catalog_cache.js.map +1 -0
- package/build/src/clock.d.ts +24 -0
- package/build/src/clock.d.ts.map +1 -0
- package/build/src/clock.js +7 -0
- package/build/src/clock.js.map +1 -0
- package/build/src/define_config.d.ts +201 -7
- 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 +324 -15
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +1107 -128
- 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 +963 -55
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +2693 -372
- 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 +586 -0
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +583 -0
- package/build/src/errors.js.map +1 -1
- package/build/src/expiry.d.ts +27 -0
- package/build/src/expiry.d.ts.map +1 -0
- package/build/src/expiry.js +50 -0
- package/build/src/expiry.js.map +1 -0
- 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/hierarchical_resolver.d.ts +56 -0
- package/build/src/hierarchical_resolver.d.ts.map +1 -0
- package/build/src/hierarchical_resolver.js +87 -0
- package/build/src/hierarchical_resolver.js.map +1 -0
- package/build/src/{middleware → http}/app_access_middleware.d.ts +8 -6
- package/build/src/http/app_access_middleware.d.ts.map +1 -0
- package/build/src/{middleware → http}/app_access_middleware.js +10 -26
- 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 +228 -0
- package/build/src/identity.d.ts.map +1 -0
- package/build/src/identity.js +457 -0
- package/build/src/identity.js.map +1 -0
- package/build/src/manager.d.ts +536 -7
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +2676 -23
- package/build/src/manager.js.map +1 -1
- package/build/src/memoize_ancestors.d.ts +23 -0
- package/build/src/memoize_ancestors.d.ts.map +1 -0
- package/build/src/memoize_ancestors.js +42 -0
- package/build/src/memoize_ancestors.js.map +1 -0
- package/build/src/models/authz_assignment.d.ts +11 -11
- package/build/src/models/authz_assignment.d.ts.map +1 -1
- package/build/src/models/authz_deny.d.ts +11 -11
- package/build/src/models/authz_deny.d.ts.map +1 -1
- package/build/src/models/authz_permission.d.ts +17 -11
- package/build/src/models/authz_permission.d.ts.map +1 -1
- package/build/src/models/authz_permission.js +4 -0
- package/build/src/models/authz_permission.js.map +1 -1
- package/build/src/models/authz_role.d.ts +19 -12
- package/build/src/models/authz_role.d.ts.map +1 -1
- package/build/src/models/authz_role.js +6 -1
- package/build/src/models/authz_role.js.map +1 -1
- package/build/src/models/authz_role_permission.d.ts +11 -11
- package/build/src/models/authz_role_permission.d.ts.map +1 -1
- package/build/src/openfga.d.ts +29 -0
- package/build/src/openfga.d.ts.map +1 -0
- package/build/src/openfga.js +26 -0
- package/build/src/openfga.js.map +1 -0
- 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/shared/backend_guard.d.ts +106 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -0
- package/build/src/shared/backend_guard.js +246 -0
- package/build/src/shared/backend_guard.js.map +1 -0
- package/build/src/shared/sql_expiry.d.ts +53 -0
- package/build/src/shared/sql_expiry.d.ts.map +1 -0
- package/build/src/shared/sql_expiry.js +66 -0
- package/build/src/shared/sql_expiry.js.map +1 -0
- package/build/src/sql_descendants.d.ts +97 -0
- package/build/src/sql_descendants.d.ts.map +1 -0
- package/build/src/sql_descendants.js +203 -0
- package/build/src/sql_descendants.js.map +1 -0
- package/build/src/testing/contract.d.ts +212 -7
- package/build/src/testing/contract.d.ts.map +1 -1
- package/build/src/testing/contract.js +3449 -24
- package/build/src/testing/contract.js.map +1 -1
- package/build/src/testing/main.d.ts +10 -2
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +5 -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 +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/testing/scope_tree.d.ts +65 -0
- package/build/src/testing/scope_tree.d.ts.map +1 -0
- package/build/src/testing/scope_tree.js +145 -0
- package/build/src/testing/scope_tree.js.map +1 -0
- package/build/src/traits/authz_scopes.d.ts +30 -6
- package/build/src/traits/authz_scopes.d.ts.map +1 -1
- package/build/src/traits/authz_scopes.js +30 -18
- package/build/src/traits/authz_scopes.js.map +1 -1
- package/build/src/traits/has_uuid.d.ts +12 -12
- package/build/src/traits/has_uuid.d.ts.map +1 -1
- package/build/src/types.d.ts +1313 -28
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js +20 -4
- package/build/src/types.js.map +1 -1
- package/build/stubs/config/app_acl.stub +4 -2
- package/build/stubs/config/authorization.stub +168 -14
- package/build/stubs/migration.stub +183 -13
- package/build/stubs/scopes_outbox_migration.stub +57 -0
- package/package.json +14 -7
- package/build/commands/openfga_import.d.ts +0 -28
- package/build/commands/openfga_import.d.ts.map +0 -1
- package/build/commands/openfga_import.js +0 -74
- package/build/commands/openfga_import.js.map +0 -1
- package/build/src/catalog.d.ts +0 -3
- package/build/src/catalog.d.ts.map +0 -1
- package/build/src/catalog.js +0 -103
- package/build/src/catalog.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
|
@@ -1,7 +1,15 @@
|
|
|
1
1
|
import db from '@adonisjs/lucid/services/db';
|
|
2
|
-
import { Exception } from '@adonisjs/core/exceptions';
|
|
3
2
|
import { v7 as uuidv7 } from 'uuid';
|
|
4
|
-
import {
|
|
3
|
+
import { APP_SCOPE_TYPE } from '../types.js';
|
|
4
|
+
import { assertCatalogUuid, assertIdentity, assertScope, chainKeysFrom, normalizeRoleQuery } from '../identity.js';
|
|
5
|
+
import { resolveGrantExpiry, sameInstant } from '../expiry.js';
|
|
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';
|
|
11
|
+
import { isClock, systemClock } from '../clock.js';
|
|
12
|
+
import { sqlExpiryCodec } from '../shared/sql_expiry.js';
|
|
5
13
|
/**
|
|
6
14
|
* UUID centinela para el scope 'app' en las columnas scope_uuid (NOT NULL):
|
|
7
15
|
* permite que el unique de assignments/denies cubra también el nivel app
|
|
@@ -10,11 +18,231 @@ import { APP_SCOPE } from '../types.js';
|
|
|
10
18
|
*/
|
|
11
19
|
export const APP_SCOPE_DB_UUID = '00000000-0000-0000-0000-000000000000';
|
|
12
20
|
function toDbScopeUuid(scope) {
|
|
21
|
+
// Defensa en profundidad (L0.10/L0.15): es el único punto donde el scope
|
|
22
|
+
// se serializa a columnas, así que aquí se garantiza que `app` no lleve
|
|
23
|
+
// uuid y que ningún otro tipo use el centinela de la raíz.
|
|
24
|
+
assertScope(scope);
|
|
13
25
|
return scope.uuid ?? APP_SCOPE_DB_UUID;
|
|
14
26
|
}
|
|
15
|
-
|
|
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) {
|
|
16
36
|
return uuid === APP_SCOPE_DB_UUID ? null : uuid;
|
|
17
37
|
}
|
|
38
|
+
/**
|
|
39
|
+
* Clave de agrupación por scope de las filas de `authz_*` (3D · N5: UNA sola
|
|
40
|
+
* codificación en el driver). No es `scopeKey` de `identity.ts`: aquí el
|
|
41
|
+
* uuid es el de la COLUMNA (con el centinela de la raíz), porque es lo que
|
|
42
|
+
* devuelven las consultas. `\u001f` separa: ningún componente lo admite.
|
|
43
|
+
*/
|
|
44
|
+
function dbScopeKey(scope) {
|
|
45
|
+
return `${scope.type}\u001f${toDbScopeUuid(scope)}`;
|
|
46
|
+
}
|
|
47
|
+
function rowScopeKey(row) {
|
|
48
|
+
return `${row.scope_type}\u001f${row.scope_uuid}`;
|
|
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
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* El rol `(slug, scopeType)` que existe en el scope cuya cadena empieza por
|
|
81
|
+
* `chainKeys`, o 422: `E_AUTHZ_ROLE_NOT_VISIBLE` si hay roles con ese nombre
|
|
82
|
+
* pero todos son locales a otro contenedor; `E_AUTHZ_UNKNOWN_ROLE` si no hay
|
|
83
|
+
* ninguno. Compartido por ambos drivers (3B · B2).
|
|
84
|
+
*/
|
|
85
|
+
export function visibleRoleOrFail(catalog, slug, scope, chainKeys) {
|
|
86
|
+
const visible = catalog.roleVisible(slug, scope.type, chainKeys);
|
|
87
|
+
if (visible)
|
|
88
|
+
return visible;
|
|
89
|
+
const named = catalog.rolesNamed(slug, scope.type);
|
|
90
|
+
if (named.length === 0)
|
|
91
|
+
throw new UnknownRoleError(slug, scope.type);
|
|
92
|
+
// 3E · Q2 (auditor A6): NO se nombran los owners. Si ninguno es visible
|
|
93
|
+
// desde este scope, todos son locales a un contenedor que NO está en la
|
|
94
|
+
// cadena preguntada: imprimir sus claves regalaba a un tenant los
|
|
95
|
+
// identificadores de scope de otro (un 422 es lo que un framework devuelve
|
|
96
|
+
// tal cual al cliente). Lo que el llamante necesita saber —que el nombre
|
|
97
|
+
// existe pero no aquí— cabe sin ellos.
|
|
98
|
+
throw new RoleNotVisibleError(`El rol '${slug}' (nivel '${scope.type}') no existe en ${scope.type}:${scope.uuid ?? ''}: hay ${named.length} ` +
|
|
99
|
+
`${named.length === 1 ? 'rol' : 'roles'} con ese nombre en el catálogo, ${named.length === 1 ? 'local' : 'locales'} ` +
|
|
100
|
+
`a un scope que no está en la cadena de este. Un rol local solo se asigna en su owner o en sus descendientes.`);
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* EL rol al que apunta un `RoleQuery` en un scope concreto (3D · M1), para
|
|
104
|
+
* las rutas que direccionan un rol exacto (`grant`, `listSubjects`):
|
|
105
|
+
*
|
|
106
|
+
* - `{ uuid }`: resolución EXACTA, sin ambigüedad posible. Fuera del
|
|
107
|
+
* catálogo ⇒ 422 `E_AUTHZ_UNKNOWN_ROLE`; declarado para otro nivel o con
|
|
108
|
+
* el owner fuera de la cadena ⇒ 422 `E_AUTHZ_ROLE_NOT_VISIBLE` (no existe
|
|
109
|
+
* AQUÍ, que es lo mismo que decía la ruta por slug).
|
|
110
|
+
* - slug (con nivel opcional): `visibleRoleOrFail`, que ahora falla cerrado
|
|
111
|
+
* si hay más de un homónimo visible (422 `E_AUTHZ_AMBIGUOUS_ROLE`).
|
|
112
|
+
*
|
|
113
|
+
* Compartido por ambos drivers.
|
|
114
|
+
*/
|
|
115
|
+
export function resolveRoleQuery(catalog, role, scope, chainKeys) {
|
|
116
|
+
const query = normalizeRoleQuery(role);
|
|
117
|
+
if (query.uuid !== undefined) {
|
|
118
|
+
const declared = catalog.roleByUuid(query.uuid);
|
|
119
|
+
if (!declared)
|
|
120
|
+
throw new UnknownRoleError(query.uuid);
|
|
121
|
+
if (declared.scopeType !== scope.type || !isRoleVisibleWith(declared, chainKeys)) {
|
|
122
|
+
// 3E · Q2: el uuid de un rol de OTRO árbol no devuelve su slug ni su
|
|
123
|
+
// owner (sería una sonda: pruebo uuids y aprendo el catálogo ajeno).
|
|
124
|
+
// Cuando el owner SÍ está en la cadena, el rol es del llamante y
|
|
125
|
+
// nombrarlo le dice exactamente qué pasa (nivel equivocado).
|
|
126
|
+
throw new RoleNotVisibleError(isRoleVisibleWith(declared, chainKeys)
|
|
127
|
+
? `El rol '${declared.slug}' (${declared.uuid}, owner ${declared.owner}) está declarado para el nivel ` +
|
|
128
|
+
`'${declared.scopeType}' y no existe en ${scope.type}:${scope.uuid ?? ''}.`
|
|
129
|
+
: `El rol ${declared.uuid} no existe en ${scope.type}:${scope.uuid ?? ''}: es local a un scope que no está ` +
|
|
130
|
+
`en la cadena de ese scope.`);
|
|
131
|
+
}
|
|
132
|
+
return declared;
|
|
133
|
+
}
|
|
134
|
+
if (query.scopeType !== undefined && query.scopeType !== scope.type) {
|
|
135
|
+
// `{ slug, scopeType }` apunta a un rol de OTRO nivel: en un scope de tipo
|
|
136
|
+
// `scope.type` ese rol no existe (un rol vive en su nivel, D5/L0.6).
|
|
137
|
+
throw new UnknownRoleError(query.slug, scope.type);
|
|
138
|
+
}
|
|
139
|
+
return visibleRoleOrFail(catalog, query.slug, scope, chainKeys);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* EL rol al que apunta un `RoleQuery` en un scope, para el camino de LECTURA
|
|
143
|
+
* (`listSubjects`): `null` si el catálogo no declara ninguno que exista ahí
|
|
144
|
+
* (invariante 5: desconocido no lanza). La AMBIGÜEDAD sí lanza (3D · M1):
|
|
145
|
+
* elegir uno de dos homónimos era enumerar los holders del otro tenant.
|
|
146
|
+
*/
|
|
147
|
+
export function visibleRoleFor(catalog, query, scope, chainKeys) {
|
|
148
|
+
if (query.uuid !== undefined) {
|
|
149
|
+
const declared = catalog.roleByUuid(query.uuid);
|
|
150
|
+
if (!declared || declared.scopeType !== scope.type || !isRoleVisibleWith(declared, chainKeys))
|
|
151
|
+
return null;
|
|
152
|
+
return declared;
|
|
153
|
+
}
|
|
154
|
+
if (query.scopeType !== undefined && query.scopeType !== scope.type)
|
|
155
|
+
return null;
|
|
156
|
+
return catalog.roleVisible(query.slug, scope.type, chainKeys);
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Los roles a los que un `RoleQuery` puede referirse en un scope, para
|
|
160
|
+
* `revoke` (3D · M1): por uuid, ESE rol (422 si el catálogo no lo declara);
|
|
161
|
+
* por slug, TODOS los homónimos `(slug, nivel)` — quitar nunca concede y el
|
|
162
|
+
* scope puede no existir ya para el árbol (D8), así que no se resuelve la
|
|
163
|
+
* ambigüedad: se quitan todos. 422 `E_AUTHZ_UNKNOWN_ROLE` si no hay ninguno.
|
|
164
|
+
*/
|
|
165
|
+
export function rolesToRevoke(catalog, role, scope) {
|
|
166
|
+
const query = normalizeRoleQuery(role);
|
|
167
|
+
if (query.uuid !== undefined) {
|
|
168
|
+
const declared = catalog.roleByUuid(query.uuid);
|
|
169
|
+
if (!declared)
|
|
170
|
+
throw new UnknownRoleError(query.uuid);
|
|
171
|
+
return [declared];
|
|
172
|
+
}
|
|
173
|
+
const level = query.scopeType ?? scope.type;
|
|
174
|
+
const named = catalog.rolesNamed(query.slug, level);
|
|
175
|
+
if (named.length === 0)
|
|
176
|
+
throw new UnknownRoleError(query.slug, level);
|
|
177
|
+
return named;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Los niveles de la cadena donde una asignación de ESE rol contaría, para
|
|
181
|
+
* `hasRole` (3D · M1): los scopes del tipo del rol desde los que el rol es
|
|
182
|
+
* visible por owner. `null` = el catálogo no declara nada que responda (la
|
|
183
|
+
* membresía es `false`, nunca un throw: invariante 5).
|
|
184
|
+
*/
|
|
185
|
+
export function hasRoleTargets(catalog, role, chain) {
|
|
186
|
+
const query = normalizeRoleQuery(role);
|
|
187
|
+
const keysFrom = chainKeysFrom(chain);
|
|
188
|
+
if (query.uuid !== undefined) {
|
|
189
|
+
const declared = catalog.roleByUuid(query.uuid);
|
|
190
|
+
if (!declared)
|
|
191
|
+
return [];
|
|
192
|
+
return chain.flatMap((s, i) => s.type === declared.scopeType && isRoleVisibleWith(declared, keysFrom[i]) ? [{ scope: s, roleUuid: declared.uuid }] : []);
|
|
193
|
+
}
|
|
194
|
+
return chain.flatMap((s, i) => {
|
|
195
|
+
if (query.scopeType !== undefined && s.type !== query.scopeType)
|
|
196
|
+
return [];
|
|
197
|
+
const declared = catalog.roleVisible(query.slug, s.type, keysFrom[i]);
|
|
198
|
+
return declared ? [{ scope: s, roleUuid: declared.uuid }] : [];
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Control de COMPOSICIÓN en la escritura (3B · B5, defensa en profundidad):
|
|
203
|
+
* un rol de nivel L que lleve un permiso cuyo `assignableAt` no incluye L no
|
|
204
|
+
* se asigna (422 `E_AUTHZ_ROLE_NOT_ASSIGNABLE_AT`). El sync y
|
|
205
|
+
* `defineScopedRole` ya lo rechazan al componer; aquí se cierra el vínculo
|
|
206
|
+
* escrito a mano. `authorize` NUNCA lo mira: lo ya asignado sigue
|
|
207
|
+
* concediendo (invariante 1).
|
|
208
|
+
*/
|
|
209
|
+
export function assertRoleAssignableAt(catalog, role) {
|
|
210
|
+
// La regla es UNA (3D · N5): la misma que aplican `syncAuthzCatalog` y
|
|
211
|
+
// `defineScopedRole` al componer (`assertAssignableAt`, `catalog.ts`).
|
|
212
|
+
for (const permission of catalog.rolePermissionsOf(role.uuid)) {
|
|
213
|
+
assertAssignableAt(role, permission, catalog.permission(permission)?.assignableAt ?? null);
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
/**
|
|
217
|
+
* EL rol de un uuid, si EXISTE en un scope de nivel `scopeType` cuya cadena
|
|
218
|
+
* tiene esas claves: declarado para ese nivel (D5/L0.6) y global o con el
|
|
219
|
+
* owner en la cadena desde ahí (3B · B2). `null` si no. Es la regla de
|
|
220
|
+
* visibilidad por uuid, en UN sitio (3D · N5): la usan `listRoles`,
|
|
221
|
+
* `rolesInChain`, `listRoleScopes`/`listScopes` (`visibleAt`) en `database`
|
|
222
|
+
* y `declaredRole` en `openfga`.
|
|
223
|
+
*/
|
|
224
|
+
export function declaredRoleAt(catalog, roleUuid, scopeType, chainKeys) {
|
|
225
|
+
const declared = catalog.roleByUuid(roleUuid);
|
|
226
|
+
if (!declared || declared.scopeType !== scopeType)
|
|
227
|
+
return null;
|
|
228
|
+
return isRoleVisibleWith(declared, chainKeys) ? declared : null;
|
|
229
|
+
}
|
|
230
|
+
export function whereScopeIn(query, column, scopes, intent) {
|
|
231
|
+
if (scopes.length === 0) {
|
|
232
|
+
if (intent === 'write') {
|
|
233
|
+
throw new AuthorizationInternalError(`whereScopeIn: una escritura sin scopes no tiene destino (columna '${column}')`);
|
|
234
|
+
}
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
return query.where((outer) => {
|
|
238
|
+
for (const s of scopes) {
|
|
239
|
+
outer.orWhere((inner) => {
|
|
240
|
+
inner.where(`${column}_type`, s.type).where(`${column}_uuid`, toDbScopeUuid(s));
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
export const DEFAULT_TIMEOUT_MS = 5_000;
|
|
18
246
|
/**
|
|
19
247
|
* Driver `database`: motor de autorización propio sobre las tablas `authz_*`.
|
|
20
248
|
* Default autosuficiente del chasis (cero piezas extra en el appliance).
|
|
@@ -24,237 +252,988 @@ function fromDbScopeUuid(uuid) {
|
|
|
24
252
|
* permiso vía su rol. La cadena es `[scope, ...ancestros]` — herencia solo
|
|
25
253
|
* hacia abajo. Solo SQL estándar vía query builder (regla M6: nada
|
|
26
254
|
* específico de un motor).
|
|
255
|
+
*
|
|
256
|
+
* Toda consulta pasa por `sql()`: un fallo del cliente SQL (conexión, tabla,
|
|
257
|
+
* deadline) sale como `AuthorizationBackendError` 503, nunca como el error
|
|
258
|
+
* crudo de knex (L0.11).
|
|
27
259
|
*/
|
|
28
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
|
+
});
|
|
29
286
|
/**
|
|
30
287
|
* Resolutor de jerarquía inyectado por el consumidor (el chasis pasa el
|
|
31
288
|
* suyo, que conoce organizations/units, en `config/authorization.ts`).
|
|
32
|
-
*
|
|
289
|
+
* Sin él, el driver solo conoce la raíz (L0.3: ya no hay default plano).
|
|
33
290
|
*/
|
|
34
|
-
|
|
291
|
+
chainResolver;
|
|
292
|
+
timeoutMs;
|
|
293
|
+
/** Reloj de pared del driver (J1): el ÚNICO `now` de todas sus DECISIONES temporales (no de los sellos, K5). */
|
|
294
|
+
now;
|
|
295
|
+
/**
|
|
296
|
+
* Cómo viaja `expires_at` con este motor (2.5-B · K2): cadena UTC explícita
|
|
297
|
+
* en MySQL (sin depender de `timezone`/`TZ`), identidad en el resto. Se
|
|
298
|
+
* decide por dialecto en el primer uso (la conexión puede no estar lista al
|
|
299
|
+
* construir el driver).
|
|
300
|
+
*/
|
|
301
|
+
expiryCodec = null;
|
|
302
|
+
/**
|
|
303
|
+
* Memo del catálogo (2A): `findPermission`/`findRole` leen de aquí; los
|
|
304
|
+
* hechos (asignaciones, denies y el join con los vínculos) siguen en SQL en
|
|
305
|
+
* cada pregunta. Se revalida contra `authz_catalog_version` (2D · F1);
|
|
306
|
+
* `catalog.invalidate()` fuerza la recarga de ESTE memo.
|
|
307
|
+
*/
|
|
308
|
+
catalog;
|
|
35
309
|
constructor(options = {}) {
|
|
36
|
-
this.
|
|
37
|
-
|
|
310
|
+
this.chainResolver = options.resolveChain ?? rootOnlyResolver;
|
|
311
|
+
this.timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
312
|
+
if (options.now !== undefined && !isClock(options.now)) {
|
|
313
|
+
throw new AuthorizationConfigError(`DatabaseAuthorizationDriver: 'now' debe ser una función () => Date (llegó ${typeof options.now})`);
|
|
314
|
+
}
|
|
315
|
+
this.now = options.now ?? systemClock;
|
|
316
|
+
assertCatalogOptions('DatabaseAuthorizationDriver', options);
|
|
317
|
+
this.catalog =
|
|
318
|
+
options.catalog ??
|
|
319
|
+
new CatalogCache({ driver: 'database', timeoutMs: this.timeoutMs, revalidate: options.catalogRevalidate });
|
|
38
320
|
}
|
|
39
|
-
|
|
40
|
-
|
|
321
|
+
/**
|
|
322
|
+
* Vista de este driver con OTRO resolutor de la cadena y el mismo estado
|
|
323
|
+
* (conexión, memo del catálogo, deadline). Es lo que usa
|
|
324
|
+
* `AuthorizationManager.forRequest()` para leer con un resolutor memoizado
|
|
325
|
+
* sin tocar el driver compartido: el objeto devuelto hereda del original
|
|
326
|
+
* por prototipo y solo sobrescribe el resolutor.
|
|
327
|
+
*/
|
|
328
|
+
withChainResolver(resolveChain) {
|
|
329
|
+
const view = Object.create(this);
|
|
330
|
+
view.chainResolver = resolveChain;
|
|
331
|
+
return view;
|
|
41
332
|
}
|
|
42
|
-
/**
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
333
|
+
/**
|
|
334
|
+
* Vista de este driver con OTRO reloj de pared (2.5 · J1) y el mismo estado
|
|
335
|
+
* (conexión, memo, deadline, resolutor). Es lo que aplica el manager con
|
|
336
|
+
* `config.clock` y lo que el juez usa para fijar el instante. Misma
|
|
337
|
+
* técnica que `withChainResolver`: herencia por prototipo, un campo.
|
|
338
|
+
*/
|
|
339
|
+
withClock(now) {
|
|
340
|
+
if (!isClock(now)) {
|
|
341
|
+
throw new AuthorizationConfigError(`withClock: now debe ser una función () => Date (llegó ${typeof now})`);
|
|
342
|
+
}
|
|
343
|
+
const view = Object.create(this);
|
|
344
|
+
view.now = now;
|
|
345
|
+
return view;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* `[scope canónico, ...ancestros]`, o `null` si el scope no existe
|
|
349
|
+
* (lecturas: denegar). `chain[0]` —la fila del consumidor, no lo que
|
|
350
|
+
* escribió el llamante— es la identidad con la que se leen y escriben los
|
|
351
|
+
* hechos de ese scope (2.5-B · K1): un alias del uuid que el árbol funde
|
|
352
|
+
* con la fila real (tipo `uuid` de PG, collation `*_ci`) llega aquí ya
|
|
353
|
+
* canónico y el deny escrito canónico casa.
|
|
354
|
+
*/
|
|
355
|
+
chain(scope, operation) {
|
|
356
|
+
return resolveChain(this.chainResolver, scope, operation);
|
|
357
|
+
}
|
|
358
|
+
/** La cadena o 422: una escritura no puede ir a un scope que nadie reconoce. */
|
|
359
|
+
knownScope(scope, operation) {
|
|
360
|
+
return assertKnownScope(this.chainResolver, scope, operation);
|
|
361
|
+
}
|
|
362
|
+
/** El scope canónico para `purgeScope` (ver `canonicalScope`). */
|
|
363
|
+
canonicalOrSelf(scope, operation) {
|
|
364
|
+
return canonicalScope(this.chainResolver, scope, operation);
|
|
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);
|
|
51
369
|
}
|
|
52
|
-
/**
|
|
370
|
+
/**
|
|
371
|
+
* Ejecuta una consulta clasificando su fallo (503 con código propio). `fn`
|
|
372
|
+
* devuelve el BUILDER sin ejecutar: el guard le fija el deadline y luego lo
|
|
373
|
+
* ejecuta. Por eso no se usa `.first()` de Lucid (ejecuta al instante, sin
|
|
374
|
+
* dejar poner el timeout): `first()` de abajo hace `limit(1)` y toma la fila.
|
|
375
|
+
*/
|
|
376
|
+
sql(operation, fn) {
|
|
377
|
+
return guardSql('database', operation, this.timeoutMs, fn);
|
|
378
|
+
}
|
|
379
|
+
async first(operation, fn) {
|
|
380
|
+
const rows = await this.sql(operation, () => fn().limit(1));
|
|
381
|
+
return rows[0] ?? null;
|
|
382
|
+
}
|
|
383
|
+
get expiry() {
|
|
384
|
+
// Decidir el dialecto no cuesta ninguna consulta: una vista por
|
|
385
|
+
// prototipo (`withClock`/`withChainResolver`) hereda el codec del driver
|
|
386
|
+
// si ya lo tiene y, si no, lo decide una vez para sí.
|
|
387
|
+
this.expiryCodec ??= sqlExpiryCodec(db.connection());
|
|
388
|
+
return this.expiryCodec;
|
|
389
|
+
}
|
|
390
|
+
/** Asignación vigente: sin expiración o con expiración futura (estricta: la que vence AHORA ya no cuenta). */
|
|
53
391
|
whereActive(query, column = 'expires_at') {
|
|
392
|
+
const now = this.expiry.bind(this.now());
|
|
54
393
|
return query.where((builder) => {
|
|
55
|
-
builder.whereNull(column).orWhere(column, '>',
|
|
394
|
+
builder.whereNull(column).orWhere(column, '>', now);
|
|
56
395
|
});
|
|
57
396
|
}
|
|
397
|
+
// Catálogo desde el memo (2A): una carga por proceso/driver, no una
|
|
398
|
+
// consulta por pregunta. Un fallo de carga sale como 503, igual que antes.
|
|
58
399
|
async findPermission(slug) {
|
|
59
|
-
return
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
400
|
+
return (await this.catalog.view()).permission(slug);
|
|
401
|
+
}
|
|
402
|
+
/**
|
|
403
|
+
* Filtro «asignación VISIBLE en algún nivel de la cadena» (3B · B2), en
|
|
404
|
+
* SQL, sobre `authz_assignments as a` unida a `authz_roles as r`: por cada
|
|
405
|
+
* nivel `i`, el scope exacto Y el rol declarado para ese nivel Y (global O
|
|
406
|
+
* owner en `chain.slice(i)`). Sustituye a `whereScopeIn(chain)` en la
|
|
407
|
+
* consulta de `authorize`: mismo número de consultas, ahora con el owner
|
|
408
|
+
* decidido en la base (`owner_scope_key IN (...) OR 'global'`). La cadena
|
|
409
|
+
* viene validada (nunca vacía); L0.1 se conserva por si acaso.
|
|
410
|
+
*/
|
|
411
|
+
whereVisibleAssignmentIn(query, chain) {
|
|
412
|
+
if (chain.length === 0)
|
|
413
|
+
return null;
|
|
414
|
+
const keysFrom = chainKeysFrom(chain);
|
|
415
|
+
return query.where((outer) => {
|
|
416
|
+
chain.forEach((s, i) => {
|
|
417
|
+
outer.orWhere((level) => {
|
|
418
|
+
level
|
|
419
|
+
.where('a.scope_type', s.type)
|
|
420
|
+
.where('a.scope_uuid', toDbScopeUuid(s))
|
|
421
|
+
.where('r.scope_type', s.type)
|
|
422
|
+
.where((owner) => {
|
|
423
|
+
owner.where('r.owner_scope_key', GLOBAL_OWNER_KEY).orWhereIn('r.owner_scope_key', keysFrom[i]);
|
|
424
|
+
});
|
|
425
|
+
});
|
|
71
426
|
});
|
|
72
|
-
}
|
|
73
|
-
return role;
|
|
427
|
+
});
|
|
74
428
|
}
|
|
75
429
|
async authorize(subject, permission, scope) {
|
|
430
|
+
assertIdentity({ subject, permission, scope });
|
|
76
431
|
const perm = await this.findPermission(permission);
|
|
77
432
|
if (!perm)
|
|
78
433
|
return false;
|
|
79
|
-
const chain = await this.chain(scope);
|
|
80
|
-
|
|
434
|
+
const chain = await this.chain(scope, 'authorize');
|
|
435
|
+
if (!chain)
|
|
436
|
+
return false;
|
|
437
|
+
const deniedQuery = whereScopeIn(db
|
|
81
438
|
.from('authz_denies')
|
|
82
439
|
.where('holder_type', subject.type)
|
|
83
440
|
.where('holder_uuid', subject.uuid)
|
|
84
|
-
.where('permission_uuid', perm.uuid), 'scope', chain)
|
|
85
|
-
if (
|
|
441
|
+
.where('permission_uuid', perm.uuid), 'scope', chain, 'read');
|
|
442
|
+
if (!deniedQuery)
|
|
86
443
|
return false;
|
|
87
|
-
|
|
444
|
+
if (await this.first('authorize.denies', () => deniedQuery))
|
|
445
|
+
return false;
|
|
446
|
+
// Una asignación vigente, en la cadena, cuyo rol concede el permiso Y es
|
|
447
|
+
// visible en su nivel (global u owner ancestro-o-igual del scope de la
|
|
448
|
+
// asignación, 3B · B2). `assignableAt`/`rank` no entran: composición.
|
|
449
|
+
const grantedQuery = this.whereVisibleAssignmentIn(db
|
|
88
450
|
.from('authz_assignments as a')
|
|
89
451
|
.join('authz_role_permissions as rp', 'rp.role_uuid', 'a.role_uuid')
|
|
452
|
+
.join('authz_roles as r', 'r.uuid', 'a.role_uuid')
|
|
90
453
|
.where('rp.permission_uuid', perm.uuid)
|
|
91
454
|
.where('a.holder_type', subject.type)
|
|
92
|
-
.where('a.holder_uuid', subject.uuid),
|
|
455
|
+
.where('a.holder_uuid', subject.uuid), chain);
|
|
456
|
+
if (!grantedQuery)
|
|
457
|
+
return false;
|
|
458
|
+
const granted = await this.first('authorize.assignments', () => this.whereActive(grantedQuery, 'a.expires_at'));
|
|
93
459
|
return Boolean(granted);
|
|
94
460
|
}
|
|
95
461
|
async grant(subject, role, scope, options = {}) {
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
462
|
+
assertIdentity({ subject, role, scope, expiresAt: options.expiresAt });
|
|
463
|
+
// Se escribe bajo la identidad canónica del árbol (K1), nunca bajo la
|
|
464
|
+
// forma del llamante; y el rol tiene que EXISTIR en ese scope (3B · B2:
|
|
465
|
+
// global, o local a un ancestro-o-igual), con una composición legal (B5).
|
|
466
|
+
const chain = await this.knownScope(scope, 'grant');
|
|
467
|
+
const [target] = chain;
|
|
468
|
+
const catalog = await this.catalog.view();
|
|
469
|
+
const declared = resolveRoleQuery(catalog, role, target, chainKeysFrom(chain)[0]);
|
|
470
|
+
assertRoleAssignableAt(catalog, declared);
|
|
471
|
+
const roleUuid = declared.uuid;
|
|
472
|
+
const findExisting = () => whereScopeIn(db
|
|
99
473
|
.from('authz_assignments')
|
|
100
474
|
.where('holder_type', subject.type)
|
|
101
475
|
.where('holder_uuid', subject.uuid)
|
|
102
|
-
.where('role_uuid', roleUuid), 'scope', [
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
476
|
+
.where('role_uuid', roleUuid), 'scope', [target], 'write').select('uuid', this.expiry.select('expires_at'));
|
|
477
|
+
// Dos carreras posibles, ambas acotadas (2.5-B · K4): la fila que se
|
|
478
|
+
// leyó desaparece antes del UPDATE (una purga concurrente) ⇒ se vuelve a
|
|
479
|
+
// empezar como inserción; el INSERT choca con el unique (otro grant
|
|
480
|
+
// concurrente) ⇒ se relee y se refresca lo del ganador. Ninguna deja un
|
|
481
|
+
// «hecho» que no está escrito. Más de tres vueltas es contención
|
|
482
|
+
// patológica: 503, nunca un bucle.
|
|
483
|
+
for (let attempt = 0; attempt < 3; attempt++) {
|
|
484
|
+
const existing = await this.first('grant.find', findExisting);
|
|
485
|
+
if (existing) {
|
|
486
|
+
const refreshed = await this.refreshAssignment(existing, options.expiresAt);
|
|
487
|
+
if (refreshed)
|
|
488
|
+
return refreshed;
|
|
489
|
+
continue;
|
|
490
|
+
}
|
|
491
|
+
// No había nada: la caducidad es la pedida, o ninguna.
|
|
492
|
+
const expiresAt = options.expiresAt ?? null;
|
|
493
|
+
try {
|
|
494
|
+
await this.sql('grant.insert', () => db.table('authz_assignments').insert({
|
|
495
|
+
uuid: uuidv7(),
|
|
496
|
+
holder_type: subject.type,
|
|
497
|
+
holder_uuid: subject.uuid,
|
|
498
|
+
role_uuid: roleUuid,
|
|
499
|
+
scope_type: target.type,
|
|
500
|
+
scope_uuid: toDbScopeUuid(target),
|
|
501
|
+
expires_at: this.expiry.toDb(expiresAt),
|
|
502
|
+
// Sello de auditoría, no decisión (2.5-B · K5): reloj del sistema.
|
|
503
|
+
created_at: systemClock(),
|
|
504
|
+
}));
|
|
505
|
+
return { existed: false, expiresAt };
|
|
506
|
+
}
|
|
507
|
+
catch (error) {
|
|
508
|
+
// Carrera check-then-insert: el unique (que cubre también el nivel app
|
|
509
|
+
// vía centinela) la detecta — el perdedor degrada a re-grant sobre lo
|
|
510
|
+
// que escribió el ganador (con la misma semántica de tres estados).
|
|
511
|
+
// Si no era una carrera, el fallo del insert (ya clasificado) se propaga.
|
|
512
|
+
const raced = await this.first('grant.race', findExisting);
|
|
513
|
+
if (!raced)
|
|
514
|
+
throw error;
|
|
515
|
+
const refreshed = await this.refreshAssignment(raced, options.expiresAt);
|
|
516
|
+
if (refreshed)
|
|
517
|
+
return refreshed;
|
|
518
|
+
}
|
|
122
519
|
}
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
520
|
+
throw new AuthorizationBackendError('database', 'grant', new Error(`la asignación de ${subject.type}:${subject.uuid} aparece y desaparece entre lecturas (contención); no se pudo dejar escrita`));
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Re-grant sobre una asignación existente (L0.4): omitido preserva una
|
|
524
|
+
* caducidad vigente (o revive una expirada sin caducidad), `null` la quita,
|
|
525
|
+
* `Date` la fija. Solo se escribe si la caducidad cambia de verdad; si el
|
|
526
|
+
* UPDATE no toca ninguna fila (otro proceso la borró entre la lectura y la
|
|
527
|
+
* escritura, K4) devuelve `null`: no hay «existed: true» sobre nada.
|
|
528
|
+
*/
|
|
529
|
+
async refreshAssignment(row, requested) {
|
|
530
|
+
const previous = this.expiry.fromDb(row.expires_at);
|
|
531
|
+
const expiresAt = resolveGrantExpiry(previous, requested, this.now());
|
|
532
|
+
if (!sameInstant(previous, expiresAt)) {
|
|
533
|
+
const updated = await this.sql('grant.update', () => db.from('authz_assignments').where('uuid', row.uuid).update({ expires_at: this.expiry.toDb(expiresAt) }));
|
|
534
|
+
if (Number(Array.isArray(updated) ? updated[0] : updated) === 0)
|
|
535
|
+
return null;
|
|
136
536
|
}
|
|
537
|
+
return { existed: true, previousExpiresAt: previous, expiresAt };
|
|
137
538
|
}
|
|
138
539
|
async revoke(subject, role, scope) {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
540
|
+
assertIdentity({ subject, role, scope });
|
|
541
|
+
// Rol fuera del catálogo para ese nivel ⇒ 422, como en `grant` (D10). El
|
|
542
|
+
// no-op es para una asignación inexistente de un rol válido. Se quitan
|
|
543
|
+
// los hechos de TODOS los roles con ese nombre en el scope exacto (3B):
|
|
544
|
+
// a lo sumo uno es visible ahí y los demás serían filas huérfanas; el
|
|
545
|
+
// scope puede no existir ya para el árbol (D8), así que no se filtra por
|
|
546
|
+
// visibilidad — quitar nunca concede.
|
|
547
|
+
const named = rolesToRevoke(await this.catalog.view(), role, scope);
|
|
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');
|
|
553
|
+
await this.sql('revoke', () => whereScopeIn(db
|
|
148
554
|
.from('authz_assignments')
|
|
149
555
|
.where('holder_type', subject.type)
|
|
150
556
|
.where('holder_uuid', subject.uuid)
|
|
151
|
-
.
|
|
557
|
+
.whereIn('role_uuid', named.map((r) => r.uuid)), 'scope', targets, 'write').delete());
|
|
152
558
|
}
|
|
153
559
|
async hasRole(subject, role, scope) {
|
|
154
|
-
|
|
155
|
-
const
|
|
560
|
+
assertIdentity({ subject, role, scope });
|
|
561
|
+
const catalog = await this.catalog.view();
|
|
562
|
+
const chain = await this.chain(scope, 'hasRole');
|
|
563
|
+
if (!chain)
|
|
564
|
+
return false;
|
|
565
|
+
// Identidad por uuid (3A · A2): en cada nivel de la cadena cuenta SOLO el
|
|
566
|
+
// rol que el catálogo declara con ese slug para el tipo de ESE nivel
|
|
567
|
+
// (L0.6) —con `{ slug, scopeType }` se recortan los niveles a ese tipo—
|
|
568
|
+
// y la asignación se busca por `(scope, role_uuid)`, no por el slug de
|
|
569
|
+
// un join: dos roles con el mismo slug y owner distinto (3B) no se
|
|
570
|
+
// confunden. Sin rol declarado en ningún nivel no hay consulta que hacer.
|
|
571
|
+
const targets = hasRoleTargets(catalog, role, chain);
|
|
572
|
+
if (targets.length === 0)
|
|
573
|
+
return false;
|
|
574
|
+
const query = db
|
|
156
575
|
.from('authz_assignments as a')
|
|
157
|
-
.join('authz_roles as r', 'r.uuid', 'a.role_uuid')
|
|
158
|
-
.where('r.slug', role)
|
|
159
576
|
.where('a.holder_type', subject.type)
|
|
160
|
-
.where('a.holder_uuid', subject.uuid)
|
|
577
|
+
.where('a.holder_uuid', subject.uuid)
|
|
578
|
+
.where((outer) => {
|
|
579
|
+
for (const { scope: s, roleUuid } of targets) {
|
|
580
|
+
outer.orWhere((inner) => {
|
|
581
|
+
inner.where('a.scope_type', s.type).where('a.scope_uuid', toDbScopeUuid(s)).where('a.role_uuid', roleUuid);
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
const found = await this.first('hasRole', () => this.whereActive(query, 'a.expires_at'));
|
|
161
586
|
return Boolean(found);
|
|
162
587
|
}
|
|
163
588
|
async deny(subject, permission, scope) {
|
|
589
|
+
assertIdentity({ subject, permission, scope });
|
|
164
590
|
const perm = await this.findPermission(permission);
|
|
165
|
-
if (!perm)
|
|
166
|
-
throw new
|
|
167
|
-
|
|
168
|
-
const
|
|
591
|
+
if (!perm)
|
|
592
|
+
throw new UnknownPermissionError(permission);
|
|
593
|
+
const [target] = await this.knownScope(scope, 'deny');
|
|
594
|
+
const findExisting = () => whereScopeIn(db
|
|
169
595
|
.from('authz_denies')
|
|
170
596
|
.where('holder_type', subject.type)
|
|
171
597
|
.where('holder_uuid', subject.uuid)
|
|
172
|
-
.where('permission_uuid', perm.uuid), 'scope', [
|
|
598
|
+
.where('permission_uuid', perm.uuid), 'scope', [target], 'write');
|
|
599
|
+
const existing = await this.first('deny.find', findExisting);
|
|
173
600
|
if (existing)
|
|
174
601
|
return;
|
|
175
602
|
try {
|
|
176
|
-
await db.table('authz_denies').insert({
|
|
603
|
+
await this.sql('deny.insert', () => db.table('authz_denies').insert({
|
|
177
604
|
uuid: uuidv7(),
|
|
178
605
|
holder_type: subject.type,
|
|
179
606
|
holder_uuid: subject.uuid,
|
|
180
607
|
permission_uuid: perm.uuid,
|
|
181
|
-
scope_type:
|
|
182
|
-
scope_uuid: toDbScopeUuid(
|
|
183
|
-
created_at:
|
|
184
|
-
});
|
|
608
|
+
scope_type: target.type,
|
|
609
|
+
scope_uuid: toDbScopeUuid(target),
|
|
610
|
+
created_at: systemClock(),
|
|
611
|
+
}));
|
|
185
612
|
}
|
|
186
613
|
catch (error) {
|
|
187
614
|
// Carrera: si otro proceso insertó el mismo deny, el unique lo detecta
|
|
188
615
|
// y el resultado deseado ya existe.
|
|
189
|
-
const raced = await this.
|
|
190
|
-
.from('authz_denies')
|
|
191
|
-
.where('holder_type', subject.type)
|
|
192
|
-
.where('holder_uuid', subject.uuid)
|
|
193
|
-
.where('permission_uuid', perm.uuid), 'scope', [scope]).first();
|
|
616
|
+
const raced = await this.first('deny.race', findExisting);
|
|
194
617
|
if (!raced)
|
|
195
618
|
throw error;
|
|
196
619
|
}
|
|
197
620
|
}
|
|
198
621
|
async removeDeny(subject, permission, scope) {
|
|
622
|
+
assertIdentity({ subject, permission, scope });
|
|
199
623
|
const perm = await this.findPermission(permission);
|
|
200
624
|
if (!perm)
|
|
201
|
-
|
|
202
|
-
|
|
625
|
+
throw new UnknownPermissionError(permission);
|
|
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');
|
|
629
|
+
await this.sql('removeDeny', () => whereScopeIn(db
|
|
203
630
|
.from('authz_denies')
|
|
204
631
|
.where('holder_type', subject.type)
|
|
205
632
|
.where('holder_uuid', subject.uuid)
|
|
206
|
-
.where('permission_uuid', perm.uuid), 'scope',
|
|
633
|
+
.where('permission_uuid', perm.uuid), 'scope', targets, 'write').delete());
|
|
207
634
|
}
|
|
208
635
|
async listSubjects(role, scope) {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
636
|
+
assertIdentity({ role, scope });
|
|
637
|
+
// Un rol que el catálogo no declara para ese nivel (en ningún owner) no
|
|
638
|
+
// tiene holders (D5): nada que leer, ni árbol ni hechos. Un scope que el
|
|
639
|
+
// árbol no conoce no existe para el motor (D8, K1): nada; uno que conoce
|
|
640
|
+
// se lee bajo su identidad canónica, y el rol tiene que existir AHÍ (3B ·
|
|
641
|
+
// B2: global o local a un ancestro-o-igual); se busca por su uuid (3A).
|
|
642
|
+
const catalog = await this.catalog.view();
|
|
643
|
+
const asked = normalizeRoleQuery(role);
|
|
644
|
+
// Atajo D5: nada que leer (ni árbol ni hechos) si el catálogo no declara
|
|
645
|
+
// NINGÚN rol que pueda responder — por uuid, si no existe; por slug, si
|
|
646
|
+
// nadie lo declara para ese nivel.
|
|
647
|
+
if (asked.uuid !== undefined ? catalog.roleByUuid(asked.uuid) === null : catalog.rolesNamed(asked.slug, asked.scopeType ?? scope.type).length === 0) {
|
|
648
|
+
return [];
|
|
649
|
+
}
|
|
650
|
+
const chain = await this.chain(scope, 'listSubjects');
|
|
651
|
+
if (!chain)
|
|
652
|
+
return [];
|
|
653
|
+
const declared = visibleRoleFor(catalog, asked, scope, chainKeysFrom(chain)[0]);
|
|
654
|
+
if (!declared)
|
|
655
|
+
return [];
|
|
656
|
+
const query = whereScopeIn(db.from('authz_assignments as a').where('a.role_uuid', declared.uuid), 'a.scope', [chain[0]], 'read');
|
|
657
|
+
if (!query)
|
|
658
|
+
return [];
|
|
659
|
+
const rows = await this.sql('listSubjects', () => this.whereActive(query, 'a.expires_at').distinct('a.holder_type', 'a.holder_uuid'));
|
|
214
660
|
return rows.map((row) => ({ type: row.holder_type, uuid: row.holder_uuid }));
|
|
215
661
|
}
|
|
216
662
|
async listRoles(subject, scope) {
|
|
217
|
-
|
|
663
|
+
assertIdentity({ subject, scope });
|
|
664
|
+
// Un scope que el árbol no conoce no existe para el motor (D8): nada.
|
|
665
|
+
const chain = await this.chain(scope, 'listRoles');
|
|
666
|
+
if (!chain)
|
|
667
|
+
return [];
|
|
668
|
+
const query = whereScopeIn(db
|
|
669
|
+
.from('authz_assignments as a')
|
|
670
|
+
.where('a.holder_type', subject.type)
|
|
671
|
+
.where('a.holder_uuid', subject.uuid), 'a.scope', [chain[0]], 'read');
|
|
672
|
+
if (!query)
|
|
673
|
+
return [];
|
|
674
|
+
const rows = await this.sql('listRoles', () => this.whereActive(query, 'a.expires_at').distinct('a.role_uuid'));
|
|
675
|
+
// Del uuid al slug por el memo (3A · A2): un rol retirado del catálogo no
|
|
676
|
+
// es membresía (D5), uno declarado para OTRO nivel tampoco, ni uno local
|
|
677
|
+
// a un scope que ya no está en la cadena (3B · B2: la unit se movió).
|
|
678
|
+
const catalog = await this.catalog.view();
|
|
679
|
+
const keys = chainKeysFrom(chain)[0];
|
|
680
|
+
const slugs = new Set();
|
|
681
|
+
for (const row of rows) {
|
|
682
|
+
const declared = declaredRoleAt(catalog, row.role_uuid, chain[0].type, keys);
|
|
683
|
+
if (declared)
|
|
684
|
+
slugs.add(declared.slug);
|
|
685
|
+
}
|
|
686
|
+
return [...slugs];
|
|
687
|
+
}
|
|
688
|
+
/**
|
|
689
|
+
* Roles directos vigentes del holder en cada scope de la cadena (2D · G5):
|
|
690
|
+
* UNA consulta con `whereScopeIn(chain)` en vez de un `listRoles` por
|
|
691
|
+
* nivel. El filtro por catálogo (D5) es el memo, por uuid (3A · A2): el
|
|
692
|
+
* rol tiene que existir y estar declarado para el nivel de la asignación.
|
|
693
|
+
* La cadena viene ya resuelta por el manager.
|
|
694
|
+
*/
|
|
695
|
+
async rolesInChain(subject, chain) {
|
|
696
|
+
assertIdentity({ subject });
|
|
697
|
+
for (const scope of chain)
|
|
698
|
+
assertScope(scope);
|
|
699
|
+
const query = whereScopeIn(db
|
|
218
700
|
.from('authz_assignments as a')
|
|
219
|
-
.join('authz_roles as r', 'r.uuid', 'a.role_uuid')
|
|
220
701
|
.where('a.holder_type', subject.type)
|
|
221
|
-
.where('a.holder_uuid', subject.uuid), 'a.scope',
|
|
222
|
-
|
|
702
|
+
.where('a.holder_uuid', subject.uuid), 'a.scope', chain, 'read');
|
|
703
|
+
if (!query)
|
|
704
|
+
return [];
|
|
705
|
+
const rows = await this.sql('rolesInChain', () => this.whereActive(query, 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid', 'a.role_uuid'));
|
|
706
|
+
const catalog = await this.catalog.view();
|
|
707
|
+
const keysFrom = chainKeysFrom(chain);
|
|
708
|
+
const levelOf = new Map(chain.map((s, i) => [dbScopeKey(s), i]));
|
|
709
|
+
const result = [];
|
|
710
|
+
const seen = new Set();
|
|
711
|
+
for (const row of rows) {
|
|
712
|
+
// Visible desde el nivel de la ASIGNACIÓN (3B · B2): owner global o en la cadena desde ahí.
|
|
713
|
+
const level = levelOf.get(rowScopeKey(row));
|
|
714
|
+
if (level === undefined)
|
|
715
|
+
continue;
|
|
716
|
+
const declared = declaredRoleAt(catalog, row.role_uuid, row.scope_type, keysFrom[level]);
|
|
717
|
+
if (!declared)
|
|
718
|
+
continue;
|
|
719
|
+
// Dedupe por IDENTIDAD (3D · M1: el uuid, no el slug): dos homónimos
|
|
720
|
+
// asignados en el mismo scope son dos roles y cuentan los dos.
|
|
721
|
+
const dedupe = `${rowScopeKey(row)}\u001f${declared.uuid}`;
|
|
722
|
+
if (seen.has(dedupe))
|
|
723
|
+
continue;
|
|
724
|
+
seen.add(dedupe);
|
|
725
|
+
result.push({ scope: { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) }, role: declared });
|
|
726
|
+
}
|
|
727
|
+
return result;
|
|
223
728
|
}
|
|
224
729
|
async listRoleScopes(subject, scopeType) {
|
|
225
|
-
|
|
730
|
+
assertIdentity({ subject, scopeType });
|
|
731
|
+
const rows = await this.sql('listRoleScopes', () => this.whereActive(db
|
|
226
732
|
.from('authz_assignments as a')
|
|
227
733
|
.where('a.holder_type', subject.type)
|
|
228
734
|
.where('a.holder_uuid', subject.uuid)
|
|
229
|
-
.where('a.scope_type', scopeType), 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid');
|
|
230
|
-
|
|
735
|
+
.where('a.scope_type', scopeType), 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid', 'a.role_uuid'));
|
|
736
|
+
// Por scope: los roles asignados; cuenta si el árbol lo conoce (D8, una
|
|
737
|
+
// consulta al resolutor por scope, que el memo por request amortiza) Y
|
|
738
|
+
// alguno de sus roles existe ahí (D5; 3B · B2: declarado para ese nivel y
|
|
739
|
+
// global u owner en la cadena).
|
|
740
|
+
const catalog = await this.catalog.view();
|
|
741
|
+
const byScope = new Map();
|
|
742
|
+
for (const row of rows) {
|
|
743
|
+
const k = rowScopeKey(row);
|
|
744
|
+
if (!byScope.has(k))
|
|
745
|
+
byScope.set(k, { scope: { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) }, roles: [] });
|
|
746
|
+
byScope.get(k).roles.push(row.role_uuid);
|
|
747
|
+
}
|
|
748
|
+
const result = [];
|
|
749
|
+
for (const { scope, roles } of byScope.values()) {
|
|
750
|
+
const chain = await this.chain(scope, 'listRoleScopes');
|
|
751
|
+
if (!chain)
|
|
752
|
+
continue;
|
|
753
|
+
const keys = chainKeysFrom(chain)[0];
|
|
754
|
+
if (roles.some((uuid) => this.visibleAt(catalog, uuid, scope, keys)))
|
|
755
|
+
result.push(scope);
|
|
756
|
+
}
|
|
757
|
+
return result;
|
|
758
|
+
}
|
|
759
|
+
/** ¿El rol (por uuid) existe en el scope cuya cadena tiene esas claves? (3B · B2, `declaredRoleAt`) */
|
|
760
|
+
visibleAt(catalog, roleUuid, scope, chainKeys) {
|
|
761
|
+
return declaredRoleAt(catalog, roleUuid, scope.type, chainKeys) !== null;
|
|
231
762
|
}
|
|
232
763
|
async listScopes(subject, permission) {
|
|
764
|
+
assertIdentity({ subject, permission });
|
|
233
765
|
const perm = await this.findPermission(permission);
|
|
234
766
|
if (!perm)
|
|
235
767
|
return [];
|
|
236
|
-
const rows = await this.whereActive(db
|
|
768
|
+
const rows = await this.sql('listScopes.assignments', () => this.whereActive(db
|
|
237
769
|
.from('authz_assignments as a')
|
|
238
770
|
.join('authz_role_permissions as rp', 'rp.role_uuid', 'a.role_uuid')
|
|
239
771
|
.where('rp.permission_uuid', perm.uuid)
|
|
240
772
|
.where('a.holder_type', subject.type)
|
|
241
|
-
.where('a.holder_uuid', subject.uuid), 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid');
|
|
242
|
-
const denies = await db
|
|
773
|
+
.where('a.holder_uuid', subject.uuid), 'a.expires_at').distinct('a.scope_type', 'a.scope_uuid', 'a.role_uuid'));
|
|
774
|
+
const denies = await this.sql('listScopes.denies', () => db
|
|
243
775
|
.from('authz_denies')
|
|
244
776
|
.where('holder_type', subject.type)
|
|
245
777
|
.where('holder_uuid', subject.uuid)
|
|
246
778
|
.where('permission_uuid', perm.uuid)
|
|
247
|
-
.select('scope_type', 'scope_uuid');
|
|
779
|
+
.select('scope_type', 'scope_uuid'));
|
|
248
780
|
const deniedKeys = new Set(denies.map((d) => `${d.scope_type}:${fromDbScopeUuid(d.scope_uuid) ?? ''}`));
|
|
249
|
-
|
|
781
|
+
// Por scope: el conjunto de roles que conceden; el scope se lista si el
|
|
782
|
+
// árbol lo conoce, alguno de esos roles existe ahí (3B · B2) y ningún
|
|
783
|
+
// deny de la cadena lo bloquea — exactamente lo que `authorize` diría.
|
|
784
|
+
const catalog = await this.catalog.view();
|
|
785
|
+
const byScope = new Map();
|
|
250
786
|
for (const row of rows) {
|
|
251
|
-
const
|
|
252
|
-
|
|
787
|
+
const k = rowScopeKey(row);
|
|
788
|
+
if (!byScope.has(k))
|
|
789
|
+
byScope.set(k, { scope: { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) }, roles: [] });
|
|
790
|
+
byScope.get(k).roles.push(row.role_uuid);
|
|
791
|
+
}
|
|
792
|
+
const result = [];
|
|
793
|
+
for (const { scope: candidate, roles } of byScope.values()) {
|
|
794
|
+
// Un scope que el árbol ya no conoce no concede (authorize daría false):
|
|
795
|
+
// no se lista, igual que uno denegado.
|
|
796
|
+
const chain = await this.chain(candidate, 'listScopes');
|
|
797
|
+
if (!chain)
|
|
798
|
+
continue;
|
|
799
|
+
const keys = chainKeysFrom(chain)[0];
|
|
800
|
+
if (!roles.some((uuid) => this.visibleAt(catalog, uuid, candidate, keys)))
|
|
801
|
+
continue;
|
|
253
802
|
const blocked = chain.some((s) => deniedKeys.has(`${s.type}:${s.uuid ?? ''}`));
|
|
254
803
|
if (!blocked)
|
|
255
804
|
result.push(candidate);
|
|
256
805
|
}
|
|
257
806
|
return result;
|
|
258
807
|
}
|
|
808
|
+
/**
|
|
809
|
+
* Denies directos del holder (2.1, B5): del scope exacto o todos. Solo los
|
|
810
|
+
* de permisos que el catálogo declara (memo); los de scopes que el árbol
|
|
811
|
+
* no conoce no se listan (D8), como en `listRoleScopes`.
|
|
812
|
+
*/
|
|
813
|
+
async listDenies(subject, scope) {
|
|
814
|
+
assertIdentity(scope ? { subject, scope } : { subject });
|
|
815
|
+
const chain = scope ? await this.chain(scope, 'listDenies') : null;
|
|
816
|
+
if (scope && !chain)
|
|
817
|
+
return [];
|
|
818
|
+
const base = db
|
|
819
|
+
.from('authz_denies')
|
|
820
|
+
.where('holder_type', subject.type)
|
|
821
|
+
.where('holder_uuid', subject.uuid);
|
|
822
|
+
const query = chain ? whereScopeIn(base, 'scope', [chain[0]], 'read') : base;
|
|
823
|
+
if (!query)
|
|
824
|
+
return [];
|
|
825
|
+
const rows = await this.sql('listDenies', () => query.select('permission_uuid', 'scope_type', 'scope_uuid'));
|
|
826
|
+
const view = await this.catalog.view();
|
|
827
|
+
const result = [];
|
|
828
|
+
for (const row of rows) {
|
|
829
|
+
const permission = view.permissionSlug(row.permission_uuid);
|
|
830
|
+
if (!permission)
|
|
831
|
+
continue;
|
|
832
|
+
const denyScope = { type: row.scope_type, uuid: fromDbScopeUuid(row.scope_uuid) };
|
|
833
|
+
if (!scope && !(await this.chain(denyScope, 'listDenies')))
|
|
834
|
+
continue;
|
|
835
|
+
result.push({ permission, scope: denyScope });
|
|
836
|
+
}
|
|
837
|
+
return result;
|
|
838
|
+
}
|
|
839
|
+
/**
|
|
840
|
+
* Borra asignaciones y denies del scope exacto, en una transacción. Si el
|
|
841
|
+
* árbol aún lo conoce se purga su identidad canónica (K1); si ya no (el
|
|
842
|
+
* consumidor borró la fila antes de avisar) se purga tal cual llegó. Con
|
|
843
|
+
* `DELETE` en SQL la propia sentencia demuestra el cero: no hay residuo
|
|
844
|
+
* posible.
|
|
845
|
+
*/
|
|
846
|
+
async purgeScope(purged) {
|
|
847
|
+
assertScope(purged);
|
|
848
|
+
if (purged.type === APP_SCOPE_TYPE) {
|
|
849
|
+
throw new InvalidIdentityError('purgeScope: la raíz `app` no se purga');
|
|
850
|
+
}
|
|
851
|
+
const scope = await this.canonicalOrSelf(purged, 'purgeScope');
|
|
852
|
+
// La transacción no es un builder: `guardSql` no puede fijarle el
|
|
853
|
+
// deadline, así que cada DELETE pasa por él por separado (D4). El guard
|
|
854
|
+
// exterior clasifica lo que falle al abrir o confirmar la transacción.
|
|
855
|
+
await this.sql('purgeScope', () => db.transaction(async (trx) => {
|
|
856
|
+
await this.sql('purgeScope.assignments', () => trx
|
|
857
|
+
.from('authz_assignments')
|
|
858
|
+
.where('scope_type', scope.type)
|
|
859
|
+
.where('scope_uuid', toDbScopeUuid(scope))
|
|
860
|
+
.delete());
|
|
861
|
+
await this.sql('purgeScope.denies', () => trx
|
|
862
|
+
.from('authz_denies')
|
|
863
|
+
.where('scope_type', scope.type)
|
|
864
|
+
.where('scope_uuid', toDbScopeUuid(scope))
|
|
865
|
+
.delete());
|
|
866
|
+
}));
|
|
867
|
+
}
|
|
868
|
+
/**
|
|
869
|
+
* Purga un rol con sus hechos (3B · B4): sus asignaciones en TODOS los
|
|
870
|
+
* scopes, sus vínculos y la fila, en UNA transacción que sube la versión
|
|
871
|
+
* compartida del catálogo como última sentencia (`withAuthzCatalogWrite`):
|
|
872
|
+
* los demás procesos dejan de verlo en su siguiente pregunta. Se lee la
|
|
873
|
+
* fila en fresco (no el memo): purgar es escribir. Con `DELETE` la propia
|
|
874
|
+
* sentencia demuestra el cero. Global o local: la barrera «los globales son
|
|
875
|
+
* inmutables» es del manager (`deleteScopedRole`); por aquí (plataforma,
|
|
876
|
+
* `manager.driver()`) se purga lo que se pida.
|
|
877
|
+
*/
|
|
878
|
+
async purgeRole(roleUuid) {
|
|
879
|
+
assertCatalogUuid('rol', roleUuid);
|
|
880
|
+
const existing = await this.first('purgeRole.role', () => db.from('authz_roles').where('uuid', roleUuid).select('uuid'));
|
|
881
|
+
if (!existing)
|
|
882
|
+
throw new UnknownRoleError(roleUuid);
|
|
883
|
+
try {
|
|
884
|
+
await this.sql('purgeRole', () => withAuthzCatalogWrite(async (trx) => {
|
|
885
|
+
await this.sql('purgeRole.assignments', () => trx.from('authz_assignments').where('role_uuid', roleUuid).delete());
|
|
886
|
+
await this.sql('purgeRole.links', () => trx.from('authz_role_permissions').where('role_uuid', roleUuid).delete());
|
|
887
|
+
const deleted = await this.sql('purgeRole.role.delete', () => trx.from('authz_roles').where('uuid', roleUuid).delete());
|
|
888
|
+
if (Number(Array.isArray(deleted) ? deleted[0] : deleted) === 0)
|
|
889
|
+
throw new UnknownRoleError(roleUuid);
|
|
890
|
+
}, { driver: 'database', timeoutMs: this.timeoutMs }));
|
|
891
|
+
}
|
|
892
|
+
finally {
|
|
893
|
+
// Como el sync (2A): este proceso lo ve al instante también con `everyMs`.
|
|
894
|
+
this.catalog.invalidate();
|
|
895
|
+
}
|
|
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
|
+
}
|
|
259
1238
|
}
|
|
260
1239
|
//# sourceMappingURL=database_driver.js.map
|