@jantstack/adonis-authz 2.0.0-alpha.1 → 2.4.0-alpha.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +693 -36
- package/build/commands/authz_catalog_diff.js +1 -1
- package/build/commands/authz_catalog_diff.js.map +1 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
- package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
- package/build/commands/authz_catalog_prune_orphans.js +136 -0
- package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
- package/build/commands/authz_catalog_sync.d.ts +17 -0
- package/build/commands/authz_catalog_sync.d.ts.map +1 -1
- package/build/commands/authz_catalog_sync.js +27 -4
- package/build/commands/authz_catalog_sync.js.map +1 -1
- package/build/commands/authz_freeze.d.ts +44 -0
- package/build/commands/authz_freeze.d.ts.map +1 -0
- package/build/commands/authz_freeze.js +95 -0
- package/build/commands/authz_freeze.js.map +1 -0
- package/build/commands/authz_reconcile.d.ts +102 -0
- package/build/commands/authz_reconcile.d.ts.map +1 -0
- package/build/commands/authz_reconcile.js +294 -0
- package/build/commands/authz_reconcile.js.map +1 -0
- package/build/commands/authz_relations_reconcile.d.ts +97 -0
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
- package/build/commands/authz_relations_reconcile.js +313 -0
- package/build/commands/authz_relations_reconcile.js.map +1 -0
- package/build/commands/authz_scopes_relay.d.ts +47 -0
- package/build/commands/authz_scopes_relay.d.ts.map +1 -0
- package/build/commands/authz_scopes_relay.js +141 -0
- package/build/commands/authz_scopes_relay.js.map +1 -0
- package/build/commands/authz_unfreeze.d.ts +37 -0
- package/build/commands/authz_unfreeze.d.ts.map +1 -0
- package/build/commands/authz_unfreeze.js +92 -0
- package/build/commands/authz_unfreeze.js.map +1 -0
- package/build/commands/main.d.ts +6 -1
- package/build/commands/main.d.ts.map +1 -1
- package/build/commands/main.js +6 -1
- package/build/commands/main.js.map +1 -1
- package/build/commands/openfga_provision.d.ts +46 -4
- package/build/commands/openfga_provision.d.ts.map +1 -1
- package/build/commands/openfga_provision.js +90 -7
- package/build/commands/openfga_provision.js.map +1 -1
- package/build/configure.d.ts +11 -0
- package/build/configure.d.ts.map +1 -1
- package/build/configure.js +37 -1
- package/build/configure.js.map +1 -1
- package/build/index.d.ts +50 -10
- package/build/index.d.ts.map +1 -1
- package/build/index.js +45 -6
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts +26 -2
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +55 -2
- package/build/providers/authz_provider.js.map +1 -1
- package/build/services/relations.d.ts +14 -0
- package/build/services/relations.d.ts.map +1 -0
- package/build/services/relations.js +17 -0
- package/build/services/relations.js.map +1 -0
- package/build/src/{catalog.d.ts → catalog/catalog.d.ts} +41 -2
- package/build/src/catalog/catalog.d.ts.map +1 -0
- package/build/src/{catalog.js → catalog/catalog.js} +120 -14
- package/build/src/catalog/catalog.js.map +1 -0
- package/build/src/{catalog_cache.d.ts → catalog/catalog_cache.d.ts} +46 -22
- package/build/src/catalog/catalog_cache.d.ts.map +1 -0
- package/build/src/{catalog_cache.js → catalog/catalog_cache.js} +53 -43
- package/build/src/catalog/catalog_cache.js.map +1 -0
- package/build/src/define_config.d.ts +137 -3
- package/build/src/define_config.d.ts.map +1 -1
- package/build/src/define_config.js.map +1 -1
- package/build/src/drivers/database_driver.d.ts +128 -7
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +510 -24
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +113 -0
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
- package/build/src/drivers/database_relations_driver.js +679 -0
- package/build/src/drivers/database_relations_driver.js.map +1 -0
- package/build/src/drivers/openfga_driver.d.ts +729 -122
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +2083 -476
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_facts.d.ts +384 -0
- package/build/src/drivers/openfga_facts.d.ts.map +1 -0
- package/build/src/drivers/openfga_facts.js +836 -0
- package/build/src/drivers/openfga_facts.js.map +1 -0
- package/build/src/drivers/openfga_relations_driver.d.ts +141 -0
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
- package/build/src/drivers/openfga_relations_driver.js +590 -0
- package/build/src/drivers/openfga_relations_driver.js.map +1 -0
- package/build/src/errors.d.ts +290 -5
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +296 -7
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +141 -0
- package/build/src/freeze.d.ts.map +1 -0
- package/build/src/freeze.js +217 -0
- package/build/src/freeze.js.map +1 -0
- package/build/src/http/app_access_middleware.d.ts.map +1 -0
- package/build/src/http/app_access_middleware.js.map +1 -0
- package/build/src/http/resource_access_middleware.d.ts +105 -0
- package/build/src/http/resource_access_middleware.d.ts.map +1 -0
- package/build/src/http/resource_access_middleware.js +81 -0
- package/build/src/http/resource_access_middleware.js.map +1 -0
- package/build/src/identity.d.ts +74 -1
- package/build/src/identity.d.ts.map +1 -1
- package/build/src/identity.js +100 -2
- package/build/src/identity.js.map +1 -1
- package/build/src/manager.d.ts +330 -4
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +1285 -188
- package/build/src/manager.js.map +1 -1
- package/build/src/models/authz_assignment.d.ts +7 -7
- package/build/src/models/authz_assignment.d.ts.map +1 -1
- package/build/src/models/authz_deny.d.ts +7 -7
- package/build/src/models/authz_deny.d.ts.map +1 -1
- package/build/src/models/authz_permission.d.ts +7 -7
- package/build/src/models/authz_permission.d.ts.map +1 -1
- package/build/src/models/authz_role.d.ts +7 -7
- package/build/src/models/authz_role.d.ts.map +1 -1
- package/build/src/models/authz_role_permission.d.ts +7 -7
- package/build/src/models/authz_role_permission.d.ts.map +1 -1
- package/build/src/openfga.d.ts +18 -2
- package/build/src/openfga.d.ts.map +1 -1
- package/build/src/openfga.js +15 -1
- package/build/src/openfga.js.map +1 -1
- package/build/src/reconcile.d.ts +37 -0
- package/build/src/reconcile.d.ts.map +1 -0
- package/build/src/reconcile.js +69 -0
- package/build/src/reconcile.js.map +1 -0
- package/build/src/relation_partition_trigger.d.ts +8 -0
- package/build/src/relation_partition_trigger.d.ts.map +1 -0
- package/build/src/relation_partition_trigger.js +93 -0
- package/build/src/relation_partition_trigger.js.map +1 -0
- package/build/src/relations/define_relations_config.d.ts +75 -0
- package/build/src/relations/define_relations_config.d.ts.map +1 -0
- package/build/src/relations/define_relations_config.js +173 -0
- package/build/src/relations/define_relations_config.js.map +1 -0
- package/build/src/relations/manager.d.ts +60 -0
- package/build/src/relations/manager.d.ts.map +1 -0
- package/build/src/relations/manager.js +213 -0
- package/build/src/relations/manager.js.map +1 -0
- package/build/src/relations/reconcile.d.ts +111 -0
- package/build/src/relations/reconcile.d.ts.map +1 -0
- package/build/src/relations/reconcile.js +200 -0
- package/build/src/relations/reconcile.js.map +1 -0
- package/build/src/relations_config_store.d.ts +22 -0
- package/build/src/relations_config_store.d.ts.map +1 -0
- package/build/src/relations_config_store.js +74 -0
- package/build/src/relations_config_store.js.map +1 -0
- package/build/src/scope_outbox.d.ts +69 -0
- package/build/src/scope_outbox.d.ts.map +1 -0
- package/build/src/scope_outbox.js +298 -0
- package/build/src/scope_outbox.js.map +1 -0
- package/build/src/{drivers → shared}/backend_guard.d.ts +42 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -0
- package/build/src/{drivers → shared}/backend_guard.js +78 -1
- package/build/src/shared/backend_guard.js.map +1 -0
- package/build/src/shared/sql_expiry.d.ts.map +1 -0
- package/build/src/shared/sql_expiry.js.map +1 -0
- package/build/src/shared/transaction_guard.d.ts +50 -0
- package/build/src/shared/transaction_guard.d.ts.map +1 -0
- package/build/src/shared/transaction_guard.js +60 -0
- package/build/src/shared/transaction_guard.js.map +1 -0
- package/build/src/sql_descendants.d.ts +47 -1
- package/build/src/sql_descendants.d.ts.map +1 -1
- package/build/src/sql_descendants.js +75 -1
- package/build/src/sql_descendants.js.map +1 -1
- package/build/src/testing/contract.d.ts +129 -4
- package/build/src/testing/contract.d.ts.map +1 -1
- package/build/src/testing/contract.js +912 -177
- package/build/src/testing/contract.js.map +1 -1
- package/build/src/testing/main.d.ts +8 -2
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +4 -1
- package/build/src/testing/main.js.map +1 -1
- package/build/src/testing/migration_contract.d.ts +284 -0
- package/build/src/testing/migration_contract.d.ts.map +1 -0
- package/build/src/testing/migration_contract.js +586 -0
- package/build/src/testing/migration_contract.js.map +1 -0
- package/build/src/testing/relations_contract.d.ts +73 -0
- package/build/src/testing/relations_contract.d.ts.map +1 -0
- package/build/src/testing/relations_contract.js +1069 -0
- package/build/src/testing/relations_contract.js.map +1 -0
- package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
- package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
- package/build/src/testing/relations_reconcile_contract.js +220 -0
- package/build/src/testing/relations_reconcile_contract.js.map +1 -0
- package/build/src/traits/authz_scopes.js +1 -1
- package/build/src/traits/authz_scopes.js.map +1 -1
- package/build/src/traits/has_uuid.d.ts +8 -8
- package/build/src/traits/has_uuid.d.ts.map +1 -1
- package/build/src/types.d.ts +1053 -83
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js +10 -0
- package/build/src/types.js.map +1 -1
- package/build/stubs/config/authorization.stub +104 -4
- package/build/stubs/migration.stub +136 -0
- package/build/stubs/scopes_outbox_migration.stub +57 -0
- package/package.json +4 -2
- package/build/commands/openfga_import.d.ts +0 -34
- package/build/commands/openfga_import.d.ts.map +0 -1
- package/build/commands/openfga_import.js +0 -97
- package/build/commands/openfga_import.js.map +0 -1
- package/build/src/catalog.d.ts.map +0 -1
- package/build/src/catalog.js.map +0 -1
- package/build/src/catalog_cache.d.ts.map +0 -1
- package/build/src/catalog_cache.js.map +0 -1
- package/build/src/drivers/backend_guard.d.ts.map +0 -1
- package/build/src/drivers/backend_guard.js.map +0 -1
- package/build/src/drivers/sql_expiry.d.ts.map +0 -1
- package/build/src/drivers/sql_expiry.js.map +0 -1
- package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
- package/build/src/middleware/app_access_middleware.js.map +0 -1
- /package/build/src/{middleware → http}/app_access_middleware.d.ts +0 -0
- /package/build/src/{middleware → http}/app_access_middleware.js +0 -0
- /package/build/src/{drivers → shared}/sql_expiry.d.ts +0 -0
- /package/build/src/{drivers → shared}/sql_expiry.js +0 -0
package/build/src/manager.js
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
var _a;
|
|
2
2
|
import { Exception } from '@adonisjs/core/exceptions';
|
|
3
3
|
import { v7 as uuidv7 } from 'uuid';
|
|
4
|
-
import { assertCatalogUuid, assertIdentity, assertScope, assertScopeType, assertSubject, assertValidSlug, chainKeysFrom, normalizeRoleQuery, scopeFromKey, scopeKey, } from './identity.js';
|
|
4
|
+
import { assertCatalogUuid, assertIdentity, assertScope, assertScopeType, assertSubject, assertValidSlug, chainKeysFrom, normalizeRoleQuery, scopeFromKey, scopeKey, scopeSpellings, } from './identity.js';
|
|
5
5
|
import { expiryChanged } from './expiry.js';
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
6
|
+
import { randomBytes } from 'node:crypto';
|
|
7
|
+
import { DEFAULT_FREEZE_LEASE_MS, acquireFreeze, freezeIsLive, freezeKindOf, readFreezeRow, assertNotFrozenRow, releaseFreeze, renewFreeze, } from './freeze.js';
|
|
8
|
+
import { assertKnownScope, isAuthzError, resolveChain, rootOnlyResolver } from './shared/backend_guard.js';
|
|
9
|
+
import { CatalogCache, GLOBAL_OWNER_KEY, invalidateAuthzCatalog, isRoleVisibleWith, readLocalRoles, withAuthzCatalogWrite } from './catalog/catalog_cache.js';
|
|
10
|
+
import { assertAssignableAt } from './catalog/catalog.js';
|
|
9
11
|
import { systemClock } from './clock.js';
|
|
10
|
-
import { ActorRequiredError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, CatalogConflictError, InvalidIdentityError, NoDescendantsResolverError, NotWithinError, PermissionNotDelegableError, RankExceededError, RoleImmutableError, RoleLevelAboveOwnerError, ScopeCycleError, ScopeResolverError, TooManyScopesError, UnknownPermissionError, UnknownRoleError, UnsupportedOperationError, ViewExpiredError, WithinRequiredError, WithinRootForbiddenError, } from './errors.js';
|
|
12
|
+
import { ActorRequiredError, AuthorizationBackendTimeoutError, AuthorizationFrozenError, AuthorizationConfigError, AuthorizationInternalError, CatalogConflictError, InvalidIdentityError, MassPurgeRefusedError, PruneInterruptedError, NoDescendantsResolverError, NotWithinError, PermissionNotDelegableError, RankExceededError, RoleImmutableError, RoleLevelAboveOwnerError, ScopeCycleError, ScopeResolverError, TooManyScopesError, UnknownPermissionError, UnknownRoleError, FreezeHeldError, UnsupportedOperationError, ViewExpiredError, WithinRequiredError, WithinRootForbiddenError, ScopeDriftUnguardedError, } from './errors.js';
|
|
11
13
|
import { APP_SCOPE_TYPE } from './types.js';
|
|
12
14
|
import { memoizeAncestors } from './memoize_ancestors.js';
|
|
15
|
+
import { AUTHZ_TABLES_ORIGIN } from './reconcile.js';
|
|
13
16
|
/** Longitudes de `authz_roles.name`/`description` (el esquema publicado). */
|
|
14
17
|
const ROLE_NAME_MAX = 100;
|
|
15
18
|
const ROLE_DESCRIPTION_MAX = 500;
|
|
@@ -49,6 +52,16 @@ export const DEFAULT_MAX_DESCENDANTS = 10_000;
|
|
|
49
52
|
* Una cota mayor es config rota (500), nunca una pregunta.
|
|
50
53
|
*/
|
|
51
54
|
export const MAX_SCOPE_BOUND = 10_000_000;
|
|
55
|
+
/**
|
|
56
|
+
* Cotas por defecto de `authz:scopes:relay` (3b-2d). El lote es el tamaño de
|
|
57
|
+
* cada `pending()`; el límite, cuántos cambios aplica una pasada antes de
|
|
58
|
+
* volver (lo que quede sigue pendiente: drenar es reanudable por diseño y
|
|
59
|
+
* una pasada eterna no es reanudable).
|
|
60
|
+
*/
|
|
61
|
+
export const DEFAULT_RELAY_BATCH = 100;
|
|
62
|
+
export const DEFAULT_RELAY_LIMIT = 10_000;
|
|
63
|
+
/** Páginas que `authz:reconcile` pasea para MEDIR la ventana del relay (3b-3a). */
|
|
64
|
+
const RELAY_WINDOW_MAX_PAGES = 1_000;
|
|
52
65
|
/** Vida por defecto de una vista de `forRequest()` para LEER (F9): un request, no un módulo. */
|
|
53
66
|
export const DEFAULT_VIEW_MAX_AGE_MS = 30_000;
|
|
54
67
|
/** Reloj monótono del proceso: inmune a NTP, snapshots y `Date.now` parcheado. */
|
|
@@ -76,6 +89,15 @@ export class AuthorizationManager {
|
|
|
76
89
|
#readsUntil = null;
|
|
77
90
|
/** Reloj monótono con el que se mide `#readsUntil` (inyectable solo en tests). */
|
|
78
91
|
#clock = monotonicNow;
|
|
92
|
+
/**
|
|
93
|
+
* El freeze que ESTE manager sostiene (su token, su renovador), o `null`.
|
|
94
|
+
* Vive en el manager RAÍZ —una vista de `forRequest()` no es otro motor—
|
|
95
|
+
* pero desde 3b-7 el ESTADO del freeze es la fila `id = 2` de
|
|
96
|
+
* `authz_catalog_version`: esto es solo el lado del dueño (quién renueva y
|
|
97
|
+
* qué token puede levantarlo). Que la barrera alcance a las vistas y al
|
|
98
|
+
* resto de la FLOTA lo garantiza la fila, no esta referencia.
|
|
99
|
+
*/
|
|
100
|
+
#heldFreeze = null;
|
|
79
101
|
constructor(config) {
|
|
80
102
|
this.#config = config;
|
|
81
103
|
if (config.clock !== undefined && typeof config.clock !== 'function') {
|
|
@@ -174,9 +196,343 @@ export class AuthorizationManager {
|
|
|
174
196
|
}
|
|
175
197
|
driver = driver.withClock(clock);
|
|
176
198
|
}
|
|
199
|
+
this.#assertTransactionalWritesRequired(driver);
|
|
200
|
+
this.#assertScopeDriftGuarded(driver);
|
|
177
201
|
this.#driver = driver;
|
|
178
202
|
return driver;
|
|
179
203
|
}
|
|
204
|
+
/**
|
|
205
|
+
* **Puerta 2 de `{ transaction }`** (L-2, opt-in): con
|
|
206
|
+
* `requireTransactionalWrites: true` el driver activo tiene que declarar
|
|
207
|
+
* `transactionalWrites: true` o el manager falla AL RESOLVERLO — antes de
|
|
208
|
+
* cachearlo, así que toda lectura, toda escritura y `driver()` fallan igual:
|
|
209
|
+
* el despliegue no arranca. Es la forma honesta de «fallar al construirse»:
|
|
210
|
+
* el roadmap lo pedía incondicional y eso haría `openfga` inconstruible en
|
|
211
|
+
* cualquier app que solo lo tenga registrado (y el driver se resuelve
|
|
212
|
+
* perezosamente y por nombre, así que al construir no se sabe si alguien
|
|
213
|
+
* pasará `{ transaction }`). Quien quiera fallar al arrancar, lo pide.
|
|
214
|
+
*/
|
|
215
|
+
#assertTransactionalWritesRequired(driver) {
|
|
216
|
+
if (this.#config.requireTransactionalWrites !== true)
|
|
217
|
+
return;
|
|
218
|
+
if (driver.capabilities?.transactionalWrites === true)
|
|
219
|
+
return;
|
|
220
|
+
throw new AuthorizationConfigError(`config.requireTransactionalWrites está en true y el driver '${this.#config.default}' declara ` +
|
|
221
|
+
`transactionalWrites: ${driver.capabilities ? String(driver.capabilities.transactionalWrites) : 'nada (sin capabilities)'}: ` +
|
|
222
|
+
`no puede inscribir grant/revoke/deny/removeDeny en la transacción del consumidor («los dos o ninguno»), así ` +
|
|
223
|
+
`que el manager no se resuelve. Usa un driver que la declare (database) o quita requireTransactionalWrites.`);
|
|
224
|
+
}
|
|
225
|
+
/**
|
|
226
|
+
* **Puerta 1 de `{ transaction }`** (L-2, siempre activa): una escritura de
|
|
227
|
+
* HECHOS con `{ transaction }` a un driver que no declara
|
|
228
|
+
* `transactionalWrites: true` es 500 `E_AUTHZ_UNSUPPORTED` nombrando driver
|
|
229
|
+
* y operación, ANTES de la barrera, de la identidad y del driver (cero
|
|
230
|
+
* llamadas). Nunca se ignora, nunca un `logger.warn`: aceptarla y no
|
|
231
|
+
* cumplirla sería publicar con nuestra firma la fuga del cruce 4 · S5.
|
|
232
|
+
* `scopes.*` NO pasa por aquí: su `transaction` ENCOLA (encolar ≠ escribir).
|
|
233
|
+
*/
|
|
234
|
+
async #assertTransactionalWrite(options, operation) {
|
|
235
|
+
if (options?.transaction === undefined || options.transaction === null)
|
|
236
|
+
return;
|
|
237
|
+
const driver = await this.driver();
|
|
238
|
+
if (driver.capabilities?.transactionalWrites === true)
|
|
239
|
+
return;
|
|
240
|
+
throw UnsupportedOperationError.transactional(operation, this.#config.default, 'roles');
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* La marca del evento de una escritura inscrita en la transacción del
|
|
244
|
+
* llamante (L-3): `onWrite` se dispara cuando el DRIVER vuelve, y en ese
|
|
245
|
+
* momento la fila existe solo dentro de esa transacción — es un hecho si y
|
|
246
|
+
* solo si el llamante confirma, cosa que el paquete nunca ve. Quien audita
|
|
247
|
+
* lo necesita para no registrar como firme lo que un rollback deshace.
|
|
248
|
+
*/
|
|
249
|
+
#transactional(options) {
|
|
250
|
+
return options?.transaction === undefined || options.transaction === null ? {} : { transactional: true };
|
|
251
|
+
}
|
|
252
|
+
/** La API de delegación no admite `{ transaction }` (§1.4 del veredicto `{trx}`): 500 antes de tocar nada. */
|
|
253
|
+
#assertNoTransaction(options, operation) {
|
|
254
|
+
if (options?.transaction === undefined || options.transaction === null)
|
|
255
|
+
return;
|
|
256
|
+
throw UnsupportedOperationError.transactionalCatalog(operation);
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* **El gate de deriva del árbol, en el MANAGER** (3b-2e · E3; cierra el
|
|
260
|
+
* agujero que declaró el 3b-2d).
|
|
261
|
+
*
|
|
262
|
+
* El driver `facts` ya se niega a construirse sin `outbox` ni firma, pero
|
|
263
|
+
* ese gate mira SU opción `outbox` — y quien ENCOLA es el manager, que lee
|
|
264
|
+
* `config.scopes.outbox`. Pasarle la instancia solo al driver dejaba el
|
|
265
|
+
* gate contento y la mitigación apagada: `manager.scopes.*` seguía
|
|
266
|
+
* escribiendo en el backend dentro de la transacción del consumidor, que
|
|
267
|
+
* es exactamente S5. Aquí se cierra, y se cierra porque el driver DECLARA
|
|
268
|
+
* su `hierarchy` (`capabilities.hierarchyFacts`, la pieza de capacidades de
|
|
269
|
+
* este lote).
|
|
270
|
+
*
|
|
271
|
+
* Un driver sin `capabilities` (2.x, o de terceros) se trata como
|
|
272
|
+
* `hierarchyFacts: false`: no hay dos árboles y no hay deriva que mitigar.
|
|
273
|
+
*/
|
|
274
|
+
#assertScopeDriftGuarded(driver) {
|
|
275
|
+
if (!driver.capabilities?.hierarchyFacts)
|
|
276
|
+
return;
|
|
277
|
+
if (this.#config.scopes?.outbox)
|
|
278
|
+
return;
|
|
279
|
+
if (this.#config.scopes?.acceptScopeDriftRisk === true)
|
|
280
|
+
return;
|
|
281
|
+
throw new ScopeDriftUnguardedError(`El driver '${this.#config.default}' declara el árbol como hechos propios (hierarchy: 'facts') y ` +
|
|
282
|
+
"config/authorization.ts no trae 'scopes.outbox': el manager escribiría el árbol en el backend DENTRO de tu " +
|
|
283
|
+
'transacción, y un rollback posterior no lo deshace (el backend queda adelantado a tu base y esa escalada no ' +
|
|
284
|
+
"se ve desde ella). Declarar la outbox solo en el driver NO basta: quien encola es el manager. Pon la MISMA " +
|
|
285
|
+
"instancia en scopes.outbox, o firma el riesgo con scopes.acceptScopeDriftRisk: true.");
|
|
286
|
+
}
|
|
287
|
+
/**
|
|
288
|
+
* **Congela las ESCRITURAS del motor, DURABLE** (3b-7; decisión del dueño
|
|
289
|
+
* del 2026-08-31 (3b): B + E-analista). Operación de PLATAFORMA, como
|
|
290
|
+
* `driver()`: no se expone por HTTP.
|
|
291
|
+
*
|
|
292
|
+
* El estado ya NO vive en el proceso: vive en la fila `id = 2` de
|
|
293
|
+
* `authz_catalog_version`, así que alcanza a **todos los procesos que
|
|
294
|
+
* comparten las tablas `authz_*`** (invariante 14: el comando ace y los
|
|
295
|
+
* workers hablan con la misma base). Mientras el freeze está vivo, toda
|
|
296
|
+
* escritura del manager —las cuatro de hechos, las tres de árbol, la API
|
|
297
|
+
* de delegación, `pruneOrphanRoles({force})` y `relayScopeChanges`—
|
|
298
|
+
* responde 503 `E_AUTHZ_FROZEN` **reintentable** y no llega al driver; las
|
|
299
|
+
* LECTURAS siguen respondiendo con normalidad (la asimetría deliberada:
|
|
300
|
+
* `authorize` no se congela ni un milisegundo).
|
|
301
|
+
*
|
|
302
|
+
* Devuelve el **token del dueño** (`{ fence, holder }`): `unfreeze(token)`
|
|
303
|
+
* solo levanta el freeze cuyo token coincide — el `finally` de una ventana
|
|
304
|
+
* ajena o rezagada no puede levantar la tuya (auditor A1.3). Un freeze
|
|
305
|
+
* VIVO de otro dueño ⇒ 423 `E_AUTHZ_FREEZE_HELD`, nunca dos dueños.
|
|
306
|
+
*
|
|
307
|
+
* El **lease** (default 15 s) se renueva solo (`leaseMs / 3`, `unref()`)
|
|
308
|
+
* mientras este proceso vive; si el proceso muere (`SIGKILL`, OOM), el
|
|
309
|
+
* lease vence y la flota vuelve a escribir SOLA en ≤ `leaseMs` — nadie
|
|
310
|
+
* limpia nada a mano. `leaseMs: null` = sin caducidad: la ventana del
|
|
311
|
+
* OPERADOR (`authz:freeze`), que dura hasta su `authz:unfreeze`.
|
|
312
|
+
*
|
|
313
|
+
* Lo que el freeze **NO congela**, a propósito y documentado (auditor
|
|
314
|
+
* 🟠 5): `syncAuthzCatalog` (función libre que no ve al manager),
|
|
315
|
+
* `manager.driver()` (la salida documentada de TODAS las barreras) y el
|
|
316
|
+
* árbol SQL del consumidor (sus tablas, su SQL). Y lo que no puede
|
|
317
|
+
* prometer: una escritura que ya pasó su barrera cuando el freeze aterriza
|
|
318
|
+
* ENTRA (no hay atomicidad entre una fila SQL y un backend externo) — la
|
|
319
|
+
* promesa publicada es «otro proceso recibe 503», jamás «ninguna escritura
|
|
320
|
+
* entra en la ventana».
|
|
321
|
+
*/
|
|
322
|
+
async freeze(reason, options = {}) {
|
|
323
|
+
const root = this.#root();
|
|
324
|
+
if (root.#heldFreeze) {
|
|
325
|
+
throw new FreezeHeldError(`freeze: este manager ya sostiene el freeze (fence ${root.#heldFreeze.token.fence}, ` +
|
|
326
|
+
`motivo: ${root.#heldFreeze.reason}). Una ventana dentro de otra corre DENTRO (withFrozenWrites/reconcile); ` +
|
|
327
|
+
`si quieres otra ventana, levanta antes la tuya con unfreeze(token).`);
|
|
328
|
+
}
|
|
329
|
+
const kind = options.kind ?? 'platform';
|
|
330
|
+
const leaseMs = options.leaseMs === undefined ? DEFAULT_FREEZE_LEASE_MS : options.leaseMs;
|
|
331
|
+
if (leaseMs !== null && (!Number.isInteger(leaseMs) || leaseMs < 1)) {
|
|
332
|
+
throw new AuthorizationConfigError(`freeze: leaseMs debe ser un entero >= 1 o null (llegó ${String(leaseMs)})`);
|
|
333
|
+
}
|
|
334
|
+
const finalReason = reason ?? 'una operación de plataforma';
|
|
335
|
+
const holder = `${kind}:${process.pid}:${randomBytes(4).toString('hex')}`;
|
|
336
|
+
const nowMs = root.#wallMs();
|
|
337
|
+
const token = await acquireFreeze({ reason: finalReason, holder, untilMs: leaseMs === null ? null : nowMs + leaseMs, nowMs }, { driver: this.#config.default });
|
|
338
|
+
if (token === null) {
|
|
339
|
+
const row = await readFreezeRow({ driver: this.#config.default });
|
|
340
|
+
throw new FreezeHeldError(`freeze: ya hay un freeze VIVO de otro dueño (${row.holder ?? '?'}, fence ${row.fence}, motivo: ${row.reason ?? '?'}). ` +
|
|
341
|
+
`Espera a que termine, o levántalo con authz:unfreeze si su proceso murió sin lease.`);
|
|
342
|
+
}
|
|
343
|
+
const held = { token, reason: finalReason, kind, leaseMs, timer: null, lapsed: false };
|
|
344
|
+
if (leaseMs !== null) {
|
|
345
|
+
const interval = Math.max(250, Math.floor(leaseMs / 3));
|
|
346
|
+
held.timer = setInterval(() => void root.#renewHeldFreeze(held), interval);
|
|
347
|
+
held.timer.unref?.();
|
|
348
|
+
}
|
|
349
|
+
root.#heldFreeze = held;
|
|
350
|
+
return token;
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Renovación CONDICIONAL del lease (fence + holder + «aún no venció»).
|
|
354
|
+
* 0 filas ⇒ el lease se PERDIÓ a mitad (pausa de GC más larga que el
|
|
355
|
+
* lease, base caída, otro dueño): se marca `lapsed`, se deja de renovar y
|
|
356
|
+
* NUNCA se «recupera» — la pasada que lo sostenía no se certifica.
|
|
357
|
+
*/
|
|
358
|
+
async #renewHeldFreeze(held) {
|
|
359
|
+
if (held.lapsed || held.leaseMs === null)
|
|
360
|
+
return;
|
|
361
|
+
const nowMs = this.#wallMs();
|
|
362
|
+
try {
|
|
363
|
+
const renewed = await renewFreeze(held.token, { untilMs: nowMs + held.leaseMs, nowMs }, { driver: this.#config.default });
|
|
364
|
+
if (!renewed) {
|
|
365
|
+
held.lapsed = true;
|
|
366
|
+
if (held.timer)
|
|
367
|
+
clearInterval(held.timer);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
catch {
|
|
371
|
+
// Base caída: transitorio. Los escritores tampoco pueden escribir (su
|
|
372
|
+
// barrera es la misma base, fail-closed); si la caída dura más que el
|
|
373
|
+
// lease, la SIGUIENTE renovación toca 0 filas y marca lapsed.
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
/**
|
|
377
|
+
* Levanta el freeze de ESTE token; uno ajeno o rezagado no toca nada (esa
|
|
378
|
+
* es toda la garantía del fence). Devuelve si de verdad lo levantó.
|
|
379
|
+
*/
|
|
380
|
+
async unfreeze(token) {
|
|
381
|
+
if (!token || typeof token.fence !== 'number' || typeof token.holder !== 'string') {
|
|
382
|
+
throw new AuthorizationConfigError('unfreeze: hace falta el token que devolvió freeze() ({ fence, holder }). Levantar el freeze de otro es authz:unfreeze.');
|
|
383
|
+
}
|
|
384
|
+
const root = this.#root();
|
|
385
|
+
const { released, lapsed } = await releaseFreeze(token, { nowMs: root.#wallMs() }, { driver: this.#config.default });
|
|
386
|
+
const held = root.#heldFreeze;
|
|
387
|
+
if (held && held.token.fence === token.fence && held.token.holder === token.holder) {
|
|
388
|
+
if (held.timer)
|
|
389
|
+
clearInterval(held.timer);
|
|
390
|
+
held.lapsed = held.lapsed || lapsed;
|
|
391
|
+
root.#heldFreeze = null;
|
|
392
|
+
}
|
|
393
|
+
return released;
|
|
394
|
+
}
|
|
395
|
+
/**
|
|
396
|
+
* ¿SOSTIENE este manager un freeze? (proceso-local: su token vive aquí.)
|
|
397
|
+
* Para saber si el MOTOR está congelado —por quien sea— pregunta
|
|
398
|
+
* `freezeStatus()`: eso es la fila, no la memoria.
|
|
399
|
+
*/
|
|
400
|
+
get frozen() {
|
|
401
|
+
return this.#root().#heldFreeze !== null;
|
|
402
|
+
}
|
|
403
|
+
/** El freeze VIVO de la fila compartida, o `null`. Lo lee cualquiera; solo el token lo levanta. */
|
|
404
|
+
async freezeStatus() {
|
|
405
|
+
const row = await readFreezeRow({ driver: this.#config.default });
|
|
406
|
+
if (!freezeIsLive(row, this.#root().#wallMs()))
|
|
407
|
+
return null;
|
|
408
|
+
return {
|
|
409
|
+
reason: row.reason,
|
|
410
|
+
holder: row.holder ?? '?',
|
|
411
|
+
kind: freezeKindOf(row.holder),
|
|
412
|
+
untilMs: row.untilMs,
|
|
413
|
+
fence: row.fence,
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
/**
|
|
417
|
+
* `freeze()` + `finally unfreeze(token)`. El `finally` es la parte que
|
|
418
|
+
* importa: una migración que revienta a la mitad no puede dejar la
|
|
419
|
+
* aplicación sin poder escribir (y si además el proceso muere sin
|
|
420
|
+
* `finally`, el lease vence solo). **El anidado corre DENTRO** (auditor
|
|
421
|
+
* A1.1/A1.3): si este manager ya sostiene el freeze, la ventana interior
|
|
422
|
+
* no toma otro ni lo levanta al salir — la exterior sigue en pie.
|
|
423
|
+
*/
|
|
424
|
+
async withFrozenWrites(reason, fn, options = {}) {
|
|
425
|
+
// L-1 · J1: una pasada que quiera correr DENTRO de la ventana del
|
|
426
|
+
// operador (el cutover) y publicar su `lapsed` —`authz:relations:reconcile`,
|
|
427
|
+
// que vive fuera de este manager— pide `kind: 'reconcile'` y
|
|
428
|
+
// `operatorAsContext: true`, lo mismo que hace `reconcile` de roles.
|
|
429
|
+
const context = await this.#durableFreezeContext(reason, options.kind ?? 'platform', {
|
|
430
|
+
operatorAsContext: options.operatorAsContext === true,
|
|
431
|
+
});
|
|
432
|
+
try {
|
|
433
|
+
return await fn({ fence: context.fence, leaseMs: context.leaseMs, lapsed: context.lapsed });
|
|
434
|
+
}
|
|
435
|
+
finally {
|
|
436
|
+
await context.release();
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* El contexto de una ventana congelada: quién la sostiene, cómo se cierra
|
|
441
|
+
* y cómo se sabe si el lease se perdió a mitad (`lapsed`). Tres formas:
|
|
442
|
+
*
|
|
443
|
+
* 1. Este manager YA sostiene un freeze ⇒ la ventana corre DENTRO y el
|
|
444
|
+
* `release` es un no-op (la exterior manda).
|
|
445
|
+
* 2. Hay un freeze de OPERADOR vivo y `operatorAsContext` ⇒ el cutover:
|
|
446
|
+
* `reconcile` corre dentro de la ventana del operador, no la renueva
|
|
447
|
+
* ni la levanta, y su `lapsed` es «¿seguía la MISMA ventana viva al
|
|
448
|
+
* terminar?».
|
|
449
|
+
* 3. Nadie ⇒ se toma uno propio (lease renovado) y se suelta al salir.
|
|
450
|
+
* Un freeze vivo de otro dueño ⇒ 423 (lo lanza `freeze()`).
|
|
451
|
+
*/
|
|
452
|
+
async #durableFreezeContext(reason, kind, options) {
|
|
453
|
+
const root = this.#root();
|
|
454
|
+
const outer = root.#heldFreeze;
|
|
455
|
+
if (outer) {
|
|
456
|
+
return {
|
|
457
|
+
fence: outer.token.fence,
|
|
458
|
+
leaseMs: outer.leaseMs,
|
|
459
|
+
release: async () => { },
|
|
460
|
+
lapsed: () => root.#tokenLapsed(outer.token, outer.lapsed),
|
|
461
|
+
};
|
|
462
|
+
}
|
|
463
|
+
if (options.operatorAsContext) {
|
|
464
|
+
const status = await this.freezeStatus();
|
|
465
|
+
if (status !== null && status.kind === 'operator') {
|
|
466
|
+
const token = { fence: status.fence, holder: status.holder };
|
|
467
|
+
return {
|
|
468
|
+
fence: status.fence,
|
|
469
|
+
leaseMs: null,
|
|
470
|
+
release: async () => { },
|
|
471
|
+
lapsed: () => root.#tokenLapsed(token, false),
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const token = await this.freeze(reason, { kind });
|
|
476
|
+
const held = root.#heldFreeze;
|
|
477
|
+
return {
|
|
478
|
+
fence: token.fence,
|
|
479
|
+
leaseMs: held.leaseMs,
|
|
480
|
+
release: async () => {
|
|
481
|
+
try {
|
|
482
|
+
await this.unfreeze(token);
|
|
483
|
+
}
|
|
484
|
+
catch (error) {
|
|
485
|
+
// La base no respondió al soltar: el lease vence solo en <= leaseMs
|
|
486
|
+
// y los escritores ya están recibiendo 503 de esa misma base.
|
|
487
|
+
console.warn('authz: no se pudo soltar el freeze al cerrar la ventana (el lease vencerá solo)', error);
|
|
488
|
+
}
|
|
489
|
+
},
|
|
490
|
+
lapsed: () => root.#tokenLapsed(token, held.lapsed),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
/** ¿Se perdió la ventana de ESTE token en algún momento? (la renovación fallida, o la fila ya no es suya / venció). */
|
|
494
|
+
async #tokenLapsed(token, alreadyLapsed) {
|
|
495
|
+
if (alreadyLapsed)
|
|
496
|
+
return true;
|
|
497
|
+
const row = await readFreezeRow({ driver: this.#config.default });
|
|
498
|
+
const mine = row.fence === token.fence && row.holder === token.holder;
|
|
499
|
+
return !mine || (row.untilMs !== null && row.untilMs <= this.#wallMs());
|
|
500
|
+
}
|
|
501
|
+
/** Milisegundos de PARED con el reloj del config (el mismo que decide caducidades). */
|
|
502
|
+
#wallMs() {
|
|
503
|
+
return (this.#config.clock ?? systemClock)().getTime();
|
|
504
|
+
}
|
|
505
|
+
/** El manager raíz: el de una vista de `forRequest()` es su padre. */
|
|
506
|
+
#root() {
|
|
507
|
+
return this.#parent ?? this;
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* La barrera del freeze, delante de TODA escritura del manager. Va antes
|
|
511
|
+
* de validar identidades y de tocar el árbol: durante la migración una
|
|
512
|
+
* escritura no se valida a medias, se rechaza entera. Desde 3b-7 es la
|
|
513
|
+
* FILA compartida (consulta propia por PK, sin memo: +0,14 ms p50 por
|
|
514
|
+
* escritura, medidos; 0 en `authorize`) — un freeze cacheado 30 s no es un
|
|
515
|
+
* freeze, y con `catalogRevalidate: { everyMs }` la fila del memo ni se
|
|
516
|
+
* lee.
|
|
517
|
+
*/
|
|
518
|
+
async #assertNotFrozen(operation) {
|
|
519
|
+
const root = this.#root();
|
|
520
|
+
const held = root.#heldFreeze;
|
|
521
|
+
if (held) {
|
|
522
|
+
throw new AuthorizationFrozenError(`${operation}: el motor de autorización está congelado (${held.reason}) y no acepta escrituras. ` +
|
|
523
|
+
`Las lecturas siguen funcionando; reintenta esta escritura cuando la operación termine.`);
|
|
524
|
+
}
|
|
525
|
+
// La fila se lee SIEMPRE por la conexión del motor (L-1 · 🟠 8). Hasta
|
|
526
|
+
// L-1 se leía por la transacción del consumidor si la escritura llegaba
|
|
527
|
+
// con una —«para no interbloquear un pool de 1»—, y eso era el agujero:
|
|
528
|
+
// la barrera la decidía el snapshot del llamante (medido en SQLite, MySQL
|
|
529
|
+
// y PG). Con pool 1 el precio es un 503 con deadline, no un bypass.
|
|
530
|
+
await assertNotFrozenRow(operation, {
|
|
531
|
+
driver: this.#config.default,
|
|
532
|
+
nowMs: root.#wallMs(),
|
|
533
|
+
timeoutMs: this.#config.freezeTimeoutMs,
|
|
534
|
+
});
|
|
535
|
+
}
|
|
180
536
|
/** Solo tests: fuerza re-resolución del driver. */
|
|
181
537
|
clearCachedDriver() {
|
|
182
538
|
this.#driver = null;
|
|
@@ -224,19 +580,42 @@ export class AuthorizationManager {
|
|
|
224
580
|
// Por eso el consumidor notifica ANTES de recolgar su fila: la cadena que
|
|
225
581
|
// se contrasta es la de origen, resuelta en fresco.
|
|
226
582
|
attached: async (child, parent, options) => {
|
|
227
|
-
this.#writeOptions(options, 'scopes.attached');
|
|
228
|
-
const
|
|
229
|
-
this.#assertWithinChain(parent, chain, options, 'scopes.attached');
|
|
583
|
+
const actor = await this.#writeOptions(options, 'scopes.attached');
|
|
584
|
+
const edge = await this.#assertEdge(child, parent, 'scopes.attached');
|
|
585
|
+
this.#assertWithinChain(parent, edge.chain, options, 'scopes.attached');
|
|
230
586
|
// Un hijo que el árbol ya conoce se está MOVIENDO (el `attach` de un
|
|
231
587
|
// nodo existente es un `move`): su origen también tiene que estar dentro.
|
|
232
588
|
await this.#assertWithinOrigin(child, options, 'scopes.attached', 'if-known');
|
|
589
|
+
const outbox = this.#outbox();
|
|
590
|
+
if (outbox) {
|
|
591
|
+
await outbox.enqueue({ op: 'attached', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
233
594
|
await (await this.driver()).onScopeAttached?.(child, parent);
|
|
234
595
|
},
|
|
596
|
+
/**
|
|
597
|
+
* `moved` NO vuelve a juzgar el catálogo, y no tiene por qué (3b-1 · D3,
|
|
598
|
+
* auditor 3G): mover un scope es un hecho del árbol, no una escritura de
|
|
599
|
+
* catálogo. Lo que hay que tener escrito es la consecuencia: la relación
|
|
600
|
+
* «A ensombrece a B» es función del árbol de HOY, así que un `moved` que
|
|
601
|
+
* mete un subárbol bajo un scope que ya tiene el homónimo **crea la
|
|
602
|
+
* sombra sin que se juzgue ningún rango en ninguna parte** — y el dueño
|
|
603
|
+
* del subárbol movido puede no poder repararla (su rango se mide en la
|
|
604
|
+
* cadena del owner de la sombra). Por eso «sobre un rol solo actúa quien
|
|
605
|
+
* lo supera en rango» (3G · W3) es una comprobación de ESCRITURA y no un
|
|
606
|
+
* invariante del sistema. Es ruidosa: `authz:catalog:diff` la lista como
|
|
607
|
+
* `shadowedByAncestor` (y `--fail-on-shadows` la cuenta como deriva).
|
|
608
|
+
*/
|
|
235
609
|
moved: async (child, newParent, options) => {
|
|
236
|
-
this.#writeOptions(options, 'scopes.moved');
|
|
237
|
-
const
|
|
238
|
-
this.#assertWithinChain(newParent, chain, options, 'scopes.moved');
|
|
610
|
+
const actor = await this.#writeOptions(options, 'scopes.moved');
|
|
611
|
+
const edge = await this.#assertEdge(child, newParent, 'scopes.moved');
|
|
612
|
+
this.#assertWithinChain(newParent, edge.chain, options, 'scopes.moved');
|
|
239
613
|
await this.#assertWithinOrigin(child, options, 'scopes.moved', 'required');
|
|
614
|
+
const outbox = this.#outbox();
|
|
615
|
+
if (outbox) {
|
|
616
|
+
await outbox.enqueue({ op: 'moved', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
|
|
617
|
+
return;
|
|
618
|
+
}
|
|
240
619
|
await (await this.driver()).onScopeMoved?.(child, newParent);
|
|
241
620
|
},
|
|
242
621
|
/**
|
|
@@ -246,165 +625,595 @@ export class AuthorizationManager {
|
|
|
246
625
|
* scope exista (el consumidor puede haber borrado ya su fila); con
|
|
247
626
|
* `within` (2D · F2) el hijo tiene que seguir en el árbol para
|
|
248
627
|
* contrastar su cadena: purga ANTES de borrar la fila.
|
|
628
|
+
*
|
|
629
|
+
* **Purga HECHOS y solo hechos** (invariante 11; 3b-0 · Z1). Entre 3D y
|
|
630
|
+
* 3G esta operación arrastraba además los roles LOCALES cuyo owner era
|
|
631
|
+
* ese scope (y, con `descendantsOf`, los de todo el subárbol), con su
|
|
632
|
+
* propia policy de rango, su degradación y un valor de retorno que
|
|
633
|
+
* contaba lo purgado. Cinco lotes la tocaron y TRES de las cuatro
|
|
634
|
+
* regresiones de la Fase 3 nacieron ahí, siempre por COMPOSICIÓN de
|
|
635
|
+
* piezas correctas por separado (3E · P3 + 3F · S1/S2 ⇒ 3G · W1). El
|
|
636
|
+
* requisito que lo pedía —un rol cuyo owner desaparece queda
|
|
637
|
+
* indeleteable y ocupa su `(slug, nivel)`— se resuelve más simple y
|
|
638
|
+
* fuera del camino de un tenant: el rol queda DORMIDO (no concede, no es
|
|
639
|
+
* membresía, no se asigna) y la PLATAFORMA lo retira con
|
|
640
|
+
* `authz:catalog:prune-orphans` (Z2). Así `scopes.detached` vuelve a ser
|
|
641
|
+
* O(1), sin rango que medir, sin árbol que enumerar y sin nada que
|
|
642
|
+
* declarar a medias.
|
|
249
643
|
*/
|
|
250
644
|
detached: async (child, options) => {
|
|
251
|
-
const actor = this.#writeOptions(options, 'scopes.detached');
|
|
645
|
+
const actor = await this.#writeOptions(options, 'scopes.detached');
|
|
252
646
|
this.#resolver('scopes.detached');
|
|
253
647
|
assertScope(child);
|
|
254
648
|
if (child.type === APP_SCOPE_TYPE) {
|
|
255
649
|
throw new InvalidIdentityError('scopes.detached: la raíz `app` no se puede borrar ni purgar');
|
|
256
650
|
}
|
|
257
651
|
await this.#assertWithin(child, options, 'scopes.detached');
|
|
258
|
-
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
//
|
|
267
|
-
//
|
|
268
|
-
//
|
|
269
|
-
//
|
|
652
|
+
// La identidad CANÓNICA (3E · P2, auditor A2): hasta 3D los hechos se
|
|
653
|
+
// canonizaban dentro del driver, así que un alias del uuid del scope
|
|
654
|
+
// —el mismo uuid sin guiones, que el tipo `uuid` de PostgreSQL
|
|
655
|
+
// resuelve a la misma fila y `assertScope` acepta— purgaba unas cosas
|
|
656
|
+
// y dejaba otras. Se resuelve UNA vez, aquí, y vale para todo.
|
|
657
|
+
//
|
|
658
|
+
// Y cuando NO hay cadena —la fila ya no existe, que es el orden
|
|
659
|
+
// soportado de `detached` (3F · S1)— no hay con qué canonizar: se
|
|
660
|
+
// purgan TODAS las ortografías de las que el uuid del llamante puede
|
|
661
|
+
// ser alias (`scopeSpellings`, 3b-2h · 🟠 3). Con la fila viva esto es
|
|
662
|
+
// exactamente una, la de la tabla; sin ella, la del llamante y la
|
|
663
|
+
// canónica que un motor pudo fundir con la suya. Antes se usaba la del
|
|
664
|
+
// llamante a secas: `purgeScope` demostraba cero sobre un objeto que no
|
|
665
|
+
// existe, devolvía OK, y el scope real seguía concediendo para siempre.
|
|
270
666
|
const chain = await resolveChain(this.#freshResolver(), child, 'scopes.detached');
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
...actor
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
await driver.onScopeDetached?.(purged);
|
|
290
|
-
await this.#notify(event);
|
|
291
|
-
return outcome;
|
|
667
|
+
const targets = chain ? [chain[0]] : scopeSpellings(child);
|
|
668
|
+
const outbox = this.#outbox();
|
|
669
|
+
if (outbox) {
|
|
670
|
+
// La identidad se resuelve AQUÍ, con la fila del consumidor todavía
|
|
671
|
+
// viva si la hay: al relevar el cambio ya no resolvería. Y no se
|
|
672
|
+
// audita `scope_purged` todavía, porque todavía no ha pasado nada.
|
|
673
|
+
for (const target of targets) {
|
|
674
|
+
await outbox.enqueue({ op: 'detached', child: target }, { transaction: options?.transaction, ...actor });
|
|
675
|
+
}
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
const driver = await this.driver();
|
|
679
|
+
for (const purged of targets) {
|
|
680
|
+
const event = { action: 'scope_purged', scope: purged, ...actor };
|
|
681
|
+
await this.#write(event, () => driver.purgeScope(purged));
|
|
682
|
+
await driver.onScopeDetached?.(purged);
|
|
683
|
+
await this.#notify(event);
|
|
684
|
+
}
|
|
292
685
|
},
|
|
293
686
|
};
|
|
294
687
|
/**
|
|
295
|
-
*
|
|
296
|
-
*
|
|
297
|
-
*
|
|
298
|
-
*
|
|
299
|
-
*
|
|
300
|
-
*
|
|
301
|
-
*
|
|
688
|
+
* **Drena la outbox del árbol y aplica los cambios al driver** (3b-2d).
|
|
689
|
+
* Es lo que hay detrás de `node ace authz:scopes:relay`.
|
|
690
|
+
*
|
|
691
|
+
* Operación de PLATAFORMA, como `pruneOrphanRoles`: se salta `requireActor`
|
|
692
|
+
* y `requireWithin` a propósito —la policy ya se juzgó al ENCOLAR, con el
|
|
693
|
+
* árbol y la sesión de aquel momento— así que **no se expone por HTTP**.
|
|
694
|
+
* Aquí solo se propaga lo que ya se validó.
|
|
302
695
|
*
|
|
303
|
-
*
|
|
304
|
-
*
|
|
305
|
-
*
|
|
696
|
+
* Reanudable y nunca silenciosa: el reporte dice QUÉ se aplicó (no un
|
|
697
|
+
* contador: la pasada no es atómica), qué falló, qué se aplazó y si queda
|
|
698
|
+
* trabajo.
|
|
306
699
|
*
|
|
307
|
-
*
|
|
308
|
-
*
|
|
309
|
-
*
|
|
310
|
-
*
|
|
311
|
-
*
|
|
312
|
-
*
|
|
313
|
-
*
|
|
314
|
-
*
|
|
315
|
-
*
|
|
316
|
-
*
|
|
317
|
-
*
|
|
318
|
-
*
|
|
319
|
-
*
|
|
320
|
-
*
|
|
321
|
-
*
|
|
322
|
-
*
|
|
700
|
+
* **El orden del árbol importa, pero solo entre cambios que se tocan**
|
|
701
|
+
* (3b-2h · 🔴 2, auditor R2). Hasta el 2h la pasada PARABA en el primer
|
|
702
|
+
* fallo, y eso convertía una entrada que ya no se puede aplicar —el padre
|
|
703
|
+
* del `attached` encolado se borró antes del relevo, la arista cerraría
|
|
704
|
+
* ahora un ciclo, el nodo acabó con dos padres— en un **tapón permanente
|
|
705
|
+
* para todos los tenants**: `pending()` devuelve lo no aplicado ordenado
|
|
706
|
+
* por id, así que la envenenada era la cabecera de la cola en TODAS las
|
|
707
|
+
* pasadas siguientes y ningún cambio del árbol volvía a llegar al store
|
|
708
|
+
* (medido: una unit nueva nunca recibía su arista `parent`, el deny de su
|
|
709
|
+
* organization nunca la alcanzaba y un `detached` posterior nunca purgaba).
|
|
710
|
+
* Ahora un fallo **contamina los scopes que nombra**: los cambios
|
|
711
|
+
* posteriores que tocan alguno de ellos se APLAZAN sin intentarse (y
|
|
712
|
+
* contaminan a su vez, así que la dependencia es transitiva), y los demás
|
|
713
|
+
* se aplican. El par ordenado que importaba —`attached(P, org)` antes que
|
|
714
|
+
* `attached(C, P)`, `moved` antes que `detached`— sigue respetado porque
|
|
715
|
+
* comparten scope; lo que ya no pasa es que el tenant A congele el árbol
|
|
716
|
+
* del tenant B.
|
|
323
717
|
*
|
|
324
|
-
*
|
|
325
|
-
*
|
|
326
|
-
*
|
|
327
|
-
*
|
|
328
|
-
*
|
|
718
|
+
* **Escritor ÚNICO** (3b-2h · 🟠 4): si la outbox sabe dar un lease
|
|
719
|
+
* (`acquire`), la pasada lo toma y una segunda pasada simultánea no hace
|
|
720
|
+
* nada y lo dice (`busy`). Sin lease, dos pasadas trabajan sobre el mismo
|
|
721
|
+
* lote —`pending()` no reserva y el lote no se relee— y la rezagada
|
|
722
|
+
* re-aplica cambios viejos sobre el árbol nuevo.
|
|
723
|
+
*
|
|
724
|
+
* Lo que esta pieza NO arregla, y va escrito en el README con estas
|
|
725
|
+
* palabras: entre el commit del consumidor y esta pasada hay un lag
|
|
726
|
+
* (segundos) durante el cual el backend decide con el árbol VIEJO. Es un
|
|
727
|
+
* **fail-open temporal** —el tenant antiguo conserva acceso tras un
|
|
728
|
+
* `moved`, los denies heredados no aplican tras un `attached`—. No hay
|
|
729
|
+
* 2PC; es el precio de tener el árbol en dos sitios.
|
|
329
730
|
*/
|
|
330
|
-
async
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
const
|
|
344
|
-
if (
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
731
|
+
async relayScopeChanges(options = {}) {
|
|
732
|
+
// El relay ESCRIBE el árbol en el driver: durante una migración se aplaza
|
|
733
|
+
// como cualquier otra escritura (lo que quede en la cola sigue ahí).
|
|
734
|
+
await this.#assertNotFrozen('authz:scopes:relay');
|
|
735
|
+
const outbox = this.#outbox();
|
|
736
|
+
if (!outbox) {
|
|
737
|
+
throw new AuthorizationConfigError("authz:scopes:relay necesita 'scopes.outbox' en config/authorization.ts: sin cola no hay nada que drenar " +
|
|
738
|
+
'(y sin cola tampoco hay mitigación: el manager estaría escribiendo en el backend dentro de tu transacción).');
|
|
739
|
+
}
|
|
740
|
+
const limit = _a.#positive(options.limit, DEFAULT_RELAY_LIMIT, 'limit');
|
|
741
|
+
const batchSize = _a.#positive(options.batchSize, DEFAULT_RELAY_BATCH, 'batchSize');
|
|
742
|
+
const dryRun = options.dryRun === true;
|
|
743
|
+
/** Lo aparcado por la outbox, si sabe aparcar: se reporta SIEMPRE. */
|
|
744
|
+
const dead = await _a.#deadLetters(outbox, batchSize);
|
|
745
|
+
if (dryRun) {
|
|
746
|
+
const batch = await outbox.pending(limit);
|
|
747
|
+
const extra = batch.length >= limit ? true : false;
|
|
748
|
+
return {
|
|
749
|
+
applied: [],
|
|
750
|
+
failed: null,
|
|
751
|
+
failures: [],
|
|
752
|
+
deferred: [],
|
|
753
|
+
dead,
|
|
754
|
+
busy: false,
|
|
755
|
+
remaining: batch.length > 0 || extra,
|
|
756
|
+
dryRun: true,
|
|
757
|
+
wouldApply: batch.map((item) => ({ id: item.id, change: item.change, attempts: item.attempts })),
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
// El lease del escritor ÚNICO. Una outbox que no sabe darlo se comporta
|
|
761
|
+
// como hasta ahora (y el README dice que entonces el relay tiene que
|
|
762
|
+
// correr de uno en uno).
|
|
763
|
+
const lease = outbox.acquire ? await outbox.acquire() : null;
|
|
764
|
+
if (outbox.acquire && lease === null) {
|
|
765
|
+
return {
|
|
766
|
+
applied: [],
|
|
767
|
+
failed: null,
|
|
768
|
+
failures: [],
|
|
769
|
+
deferred: [],
|
|
770
|
+
dead,
|
|
771
|
+
busy: true,
|
|
772
|
+
remaining: (await outbox.pending(1)).length > 0,
|
|
773
|
+
dryRun: false,
|
|
774
|
+
wouldApply: [],
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
try {
|
|
778
|
+
const driver = await this.driver();
|
|
779
|
+
const applied = [];
|
|
780
|
+
const deferred = [];
|
|
781
|
+
const failures = [];
|
|
782
|
+
/** Claves de scope contaminadas: lo que las toque se aplaza. */
|
|
783
|
+
const blocked = new Set();
|
|
784
|
+
// Una outbox que no marca lo aplicado devolvería el mismo pendiente
|
|
785
|
+
// para siempre: el relay no puede quedarse dando vueltas ni
|
|
786
|
+
// "arreglarlo" por su cuenta, así que lo denuncia (500) en cuanto
|
|
787
|
+
// vuelve a ver un id que YA aplicó.
|
|
788
|
+
const done = new Set();
|
|
789
|
+
/** Ids que esta pasada dejó a propósito (fallo o aplazo): reaparecen. */
|
|
790
|
+
const parked = new Set();
|
|
791
|
+
/** El último id visto: la outbox pagina desde ahí (lo saltado se queda). */
|
|
792
|
+
let after;
|
|
793
|
+
outer: while (applied.length < limit) {
|
|
794
|
+
// **La barrera del freeze se RE-AFIRMA por lote** (3b-8 · B3). La
|
|
795
|
+
// mirada única de la entrada dejaba hasta `DEFAULT_RELAY_LIMIT`
|
|
796
|
+
// (10.000) escrituras de árbol colándose DESPUÉS de que otra pasada
|
|
797
|
+
// adquiriera el freeze durable: escrituras que no salen en ningún
|
|
798
|
+
// contador de la pasada certificada y que pueden invalidar su
|
|
799
|
+
// resultado. El trade-off documentado en freeze.ts cubre «una
|
|
800
|
+
// escritura que ya pasó su barrera», no una pasada entera. El coste
|
|
801
|
+
// (una lectura de la fila `id=2` por lote; 0,14 ms/escritura ya
|
|
802
|
+
// medidos y aceptados) va fuera del camino caliente. Un freeze
|
|
803
|
+
// adquirido a mitad corta AQUÍ con el 503 reintentable de siempre:
|
|
804
|
+
// lo ya aplicado está marcado en la outbox (la pasada es reanudable)
|
|
805
|
+
// y el resto sigue pendiente para después de la ventana.
|
|
806
|
+
await this.#assertNotFrozen('authz:scopes:relay');
|
|
807
|
+
const batch = await outbox.pending(Math.min(batchSize, limit - applied.length), after);
|
|
808
|
+
if (batch.length === 0)
|
|
809
|
+
break;
|
|
810
|
+
let progress = false;
|
|
811
|
+
for (const item of batch) {
|
|
812
|
+
const id = String(item.id);
|
|
813
|
+
if (done.has(id)) {
|
|
814
|
+
throw new AuthorizationConfigError(`authz:scopes:relay: la outbox sigue devolviendo el cambio ${id} como pendiente después de markApplied. ` +
|
|
815
|
+
'Tu implementación de ScopeOutbox no marca lo aplicado; el relay para antes de dar vueltas para siempre.');
|
|
816
|
+
}
|
|
817
|
+
if (parked.has(id))
|
|
818
|
+
continue;
|
|
819
|
+
progress = true;
|
|
820
|
+
after = item.id;
|
|
821
|
+
if (applied.length >= limit)
|
|
822
|
+
break outer;
|
|
823
|
+
const keys = _a.#changeKeys(item.change);
|
|
824
|
+
const collision = keys.find((key) => blocked.has(key));
|
|
825
|
+
if (collision !== undefined) {
|
|
826
|
+
for (const key of keys)
|
|
827
|
+
blocked.add(key);
|
|
828
|
+
parked.add(id);
|
|
829
|
+
deferred.push({
|
|
830
|
+
id: item.id,
|
|
831
|
+
change: item.change,
|
|
832
|
+
attempts: item.attempts,
|
|
833
|
+
error: `aplazado: depende de ${collision}, que quedó sin aplicar en esta pasada`,
|
|
834
|
+
});
|
|
835
|
+
continue;
|
|
836
|
+
}
|
|
837
|
+
try {
|
|
838
|
+
await this.#applyScopeChange(driver, item);
|
|
839
|
+
}
|
|
840
|
+
catch (error) {
|
|
841
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
842
|
+
await outbox.markFailed(item.id, message);
|
|
843
|
+
for (const key of keys)
|
|
844
|
+
blocked.add(key);
|
|
845
|
+
parked.add(id);
|
|
846
|
+
failures.push({ id: item.id, change: item.change, error: message });
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
await outbox.markApplied(item.id);
|
|
850
|
+
done.add(id);
|
|
851
|
+
applied.push({ id: item.id, change: item.change, attempts: item.attempts });
|
|
852
|
+
}
|
|
853
|
+
// Una outbox que ignora `after` devuelve el mismo lote atascado: la
|
|
854
|
+
// pasada termina aquí en vez de dar vueltas (lo que quede, y lo que
|
|
855
|
+
// haya detrás, sigue pendiente para la siguiente).
|
|
856
|
+
if (!progress)
|
|
857
|
+
break;
|
|
357
858
|
}
|
|
358
|
-
|
|
359
|
-
|
|
859
|
+
return {
|
|
860
|
+
applied,
|
|
861
|
+
failed: failures[0] ?? null,
|
|
862
|
+
failures,
|
|
863
|
+
deferred,
|
|
864
|
+
dead,
|
|
865
|
+
busy: false,
|
|
866
|
+
remaining: (await outbox.pending(1)).length > 0,
|
|
867
|
+
dryRun: false,
|
|
868
|
+
wouldApply: [],
|
|
869
|
+
};
|
|
870
|
+
}
|
|
871
|
+
finally {
|
|
872
|
+
await lease?.release();
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
/**
|
|
876
|
+
* **`authz:reconcile --to=<driver>`** (3b-3a): la ÚNICA primitiva de
|
|
877
|
+
* migración y verificación del paquete, y el motivo de la fase entera —
|
|
878
|
+
* «todo en un driver o todo en otro, con una migración idempotente y
|
|
879
|
+
* bidireccional». Sustituye a `openfga:import`, que el 2k borró.
|
|
880
|
+
*
|
|
881
|
+
* Operación de PLATAFORMA, como `driver()` y `relayScopeChanges`: no lleva
|
|
882
|
+
* actor, no mide rangos y **no se expone por HTTP**.
|
|
883
|
+
*
|
|
884
|
+
* El driver de destino se resuelve **por nombre del registro**
|
|
885
|
+
* (`config.drivers[to]`), no por `config.default`: la migración de verdad
|
|
886
|
+
* es «el motor sigue corriendo con `database` mientras se llena el store de
|
|
887
|
+
* `openfga`», y con el default no habría forma de nombrar al destino. Un
|
|
888
|
+
* driver que no sabe reconstruirse lo dice (500 `E_AUTHZ_UNSUPPORTED`
|
|
889
|
+
* nombrando `reconcile`); el driver `database` es ese caso: sus tablas SON
|
|
890
|
+
* el origen y llenarlas desde un store es la otra dirección (3b-3b).
|
|
891
|
+
*
|
|
892
|
+
* Durante la pasada que ESCRIBE, las escrituras del motor están CONGELADAS
|
|
893
|
+
* (`withFrozenWrites`): un `grant` que aterrizara entre la lectura del
|
|
894
|
+
* origen y la escritura del destino no llegaría al destino y no aparecería
|
|
895
|
+
* en ningún contador. Las lecturas siguen. El `finally` descongela pase lo
|
|
896
|
+
* que pase.
|
|
897
|
+
*
|
|
898
|
+
* **`--dry-run` NO congela** (3b-6, panel 3 · juez §3). El verificador es
|
|
899
|
+
* read-only por contrato: no escribe nada, así que no tiene NADA que
|
|
900
|
+
* proteger, y congelar ahí sería apagar las escrituras a cambio de cero.
|
|
901
|
+
* Está publicado para correrlo en CI y en un cron, o sea justo el sitio
|
|
902
|
+
* desde el que un mecanismo de indisponibilidad se dispara solo — hoy
|
|
903
|
+
* contra el proceso del job, y contra la flota entera el día que el freeze
|
|
904
|
+
* sea durable. El único contraargumento posible —que congelar estabiliza
|
|
905
|
+
* sus números— no vale: los números de un verificador read-only no son una
|
|
906
|
+
* garantía de nada.
|
|
907
|
+
*
|
|
908
|
+
* Lo que añade el manager al reporte del driver es lo único que el driver
|
|
909
|
+
* no puede ver: **la ventana del relay** —los cambios del árbol encolados y
|
|
910
|
+
* sin aplicar, que son la deriva que el store todavía no conoce (decisión
|
|
911
|
+
* del dueño del 2026-08-30, consecuencia 4)— y las entradas APARCADAS, que
|
|
912
|
+
* no son una ventana sino una divergencia permanente.
|
|
913
|
+
*/
|
|
914
|
+
async reconcile(options) {
|
|
915
|
+
const name = options.to;
|
|
916
|
+
const factory = this.#config.drivers?.[name];
|
|
917
|
+
if (!factory) {
|
|
918
|
+
throw new AuthorizationConfigError(`authz:reconcile --to=${name}: ese driver no está registrado en config/authorization.ts ` +
|
|
919
|
+
`(registrados: ${Object.keys(this.#config.drivers ?? {}).join(', ') || 'ninguno'}). ` +
|
|
920
|
+
`El destino se nombra por su clave en 'drivers', no por el driver activo: migrar es llenar el ` +
|
|
921
|
+
`destino mientras el motor sigue corriendo con el otro.`);
|
|
922
|
+
}
|
|
923
|
+
let target = await factory();
|
|
924
|
+
const clock = this.#config.clock;
|
|
925
|
+
if (clock !== undefined) {
|
|
926
|
+
if (typeof target.withClock !== 'function') {
|
|
927
|
+
throw new AuthorizationConfigError(`config.clock está declarado pero el driver '${name}' no implementa withClock(now): la migración ` +
|
|
928
|
+
`escribiría caducidades decididas con otro reloj que el motor.`);
|
|
360
929
|
}
|
|
361
|
-
|
|
930
|
+
target = target.withClock(clock);
|
|
931
|
+
}
|
|
932
|
+
if (typeof target.reconcile !== 'function') {
|
|
933
|
+
throw new UnsupportedOperationError('reconcile', `authz:reconcile --to=${name}`, name, `El driver '${name}' no sabe reconstruirse desde 'authz_*' + el árbol del consumidor. ` +
|
|
934
|
+
`El driver 'database' es ese caso a propósito: sus tablas son el ORIGEN.`);
|
|
935
|
+
}
|
|
936
|
+
// **De dónde salen los HECHOS** (3b-5): la decisión que faltaba, y la que
|
|
937
|
+
// el destino no puede tomar por su cuenta. Ver `#factsOrigin`.
|
|
938
|
+
const origin = await this.#factsOrigin(name, target, options.from);
|
|
939
|
+
const source = {
|
|
940
|
+
enumerateEdges: this.#edgesEnumerator(),
|
|
941
|
+
resolveChain: this.#freshResolver(),
|
|
942
|
+
// Los hechos del ORIGEN, **perezosos** (3b-3b): la dirección que lee
|
|
943
|
+
// `authz_*` no construye ningún driver de más. Y el origen se resuelve
|
|
944
|
+
// UNA vez.
|
|
945
|
+
facts: origin.enumerate,
|
|
946
|
+
factsOrigin: { name: origin.name, authzTables: origin.authzTables },
|
|
947
|
+
};
|
|
948
|
+
const pass = async () => {
|
|
949
|
+
const report = await target.reconcile(source, options);
|
|
950
|
+
const { pending, dead } = await this.#relayWindow();
|
|
951
|
+
report.drift.pendingRelay = pending;
|
|
952
|
+
report.drift.deadRelay = dead;
|
|
953
|
+
// Quién fue el origen se DICE, siempre: es la diferencia entre una
|
|
954
|
+
// migración y una pasada de mantenimiento contra el driver activo.
|
|
955
|
+
report.factsFrom = origin.resolved();
|
|
956
|
+
return report;
|
|
957
|
+
};
|
|
958
|
+
// La pasada que escribe congela; el verificador NO (ver el docblock).
|
|
959
|
+
if (options.dryRun === true)
|
|
960
|
+
return pass();
|
|
961
|
+
// El freeze de la pasada es DURABLE y con dueño (3b-7): si este manager
|
|
962
|
+
// ya sostiene uno, la pasada corre DENTRO; si hay una ventana de
|
|
963
|
+
// OPERADOR viva (`authz:freeze`, el cutover), la pasada la reconoce como
|
|
964
|
+
// contexto propio —no la toma, no la renueva, no la levanta—; si el
|
|
965
|
+
// freeze vivo es de otro `reconcile`, 423: dos pasadas no se pisan. Y el
|
|
966
|
+
// reporte publica la garantía en vez de suponerla: `frozen.lapsed=true`
|
|
967
|
+
// significa que el lease se perdió a mitad y la pasada NO se certifica
|
|
968
|
+
// (el comando sale distinto de cero).
|
|
969
|
+
const window = await this.#durableFreezeContext(`authz:reconcile --to=${name}`, 'reconcile', {
|
|
970
|
+
operatorAsContext: true,
|
|
971
|
+
});
|
|
972
|
+
try {
|
|
973
|
+
const report = await pass();
|
|
974
|
+
report.frozen = {
|
|
975
|
+
durable: true,
|
|
976
|
+
lapsed: await window.lapsed(),
|
|
977
|
+
leaseMs: window.leaseMs,
|
|
978
|
+
fence: window.fence,
|
|
979
|
+
};
|
|
980
|
+
return report;
|
|
981
|
+
}
|
|
982
|
+
finally {
|
|
983
|
+
await window.release();
|
|
362
984
|
}
|
|
363
|
-
return { purgedRoles: owned.length, truncated, ...(reason ? { reason } : {}) };
|
|
364
985
|
}
|
|
365
986
|
/**
|
|
366
|
-
*
|
|
367
|
-
*
|
|
368
|
-
*
|
|
369
|
-
*
|
|
987
|
+
* **Quién es la FUENTE DE VERDAD de los hechos de esta pasada** (3b-5, los
|
|
988
|
+
* dos 🔴 del auditor final de la Fase 3b). Es la pregunta que
|
|
989
|
+
* `authz:reconcile --to=openfga` no se hacía: leía `authz_assignments`/
|
|
990
|
+
* `authz_denies` SIEMPRE, y en un despliegue `hierarchy: 'facts'` esas
|
|
991
|
+
* tablas no son la fuente de verdad de los hechos —lo son las tuplas del
|
|
992
|
+
* store—, así que la pasada resucitaba lo revocado después del cutover,
|
|
993
|
+
* `--prune` borraba los denies vivos y el barrido de visibilidad del
|
|
994
|
+
* invariante 18 no se aplicaba nunca (`forbidden` salía vacío porque
|
|
995
|
+
* `wanted.facts` salía vacío).
|
|
996
|
+
*
|
|
997
|
+
* Las tres respuestas, en este orden:
|
|
370
998
|
*
|
|
371
|
-
* **El
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
*
|
|
378
|
-
*
|
|
999
|
+
* 1. **El destino es el driver ACTIVO y sus hechos son SUYOS**
|
|
1000
|
+
* (`to === config.default` y `capabilities.hierarchyFacts`): entonces
|
|
1001
|
+
* `authz_*` no puede ser su origen —el motor lleva desde el cutover
|
|
1002
|
+
* escribiendo los hechos en el destino— y la pasada es de
|
|
1003
|
+
* MANTENIMIENTO: los hechos se leen del propio destino por el puerto
|
|
1004
|
+
* (`enumerateFacts`), se rehace lo DERIVADO (marcador, catálogo, árbol)
|
|
1005
|
+
* y se aplica el barrido de visibilidad del invariante 18 con el árbol
|
|
1006
|
+
* y el catálogo de HOY. No se inventa ni se borra un solo hecho. Un
|
|
1007
|
+
* destino activo con `hierarchyFacts` que no sepa enumerar sus hechos
|
|
1008
|
+
* es 500 `E_AUTHZ_UNSUPPORTED`: leerle `authz_*` sería justo el defecto.
|
|
1009
|
+
* 2. **`--from=<nombre>` manda**, y por eso se resuelve YA: de la
|
|
1010
|
+
* naturaleza de ese driver depende de dónde salen los hechos (si sabe
|
|
1011
|
+
* `enumerateFacts`, del puerto; si no, es un driver cuyos hechos son
|
|
1012
|
+
* `authz_*` —el `database` del paquete— y los lee el destino).
|
|
1013
|
+
* 3. **Sin `--from` y sin ser el activo**: la MIGRACIÓN de siempre. Los
|
|
1014
|
+
* hechos son `authz_*`, el esquema PUBLICADO del paquete, y el destino
|
|
1015
|
+
* los lee él mismo; si el destino los pide por el puerto (`--to=database`)
|
|
1016
|
+
* el origen se resuelve entonces, perezosamente y con la regla ruidosa
|
|
1017
|
+
* de 3b-3b (`#factsEnumerator`).
|
|
1018
|
+
*/
|
|
1019
|
+
async #factsOrigin(to, target, from) {
|
|
1020
|
+
if (from === undefined && to === this.#config.default && target.capabilities?.hierarchyFacts === true) {
|
|
1021
|
+
if (typeof target.enumerateFacts !== 'function') {
|
|
1022
|
+
throw new UnsupportedOperationError('enumerateFacts', `authz:reconcile --to=${to}`, to, `El motor SIRVE desde '${to}' y ese driver declara que el árbol y los hechos viven en su backend ` +
|
|
1023
|
+
`(hierarchyFacts), así que 'authz_assignments'/'authz_denies' NO son la fuente de verdad de sus ` +
|
|
1024
|
+
`hechos: reconstruirlo desde ellas reescribiría lo que hayas revocado desde el cutover. Para poder ` +
|
|
1025
|
+
`verificarlo y repararlo hace falta que sepa entregar sus hechos (enumerateFacts).`);
|
|
1026
|
+
}
|
|
1027
|
+
return {
|
|
1028
|
+
name: to,
|
|
1029
|
+
authzTables: false,
|
|
1030
|
+
enumerate: (page) => target.enumerateFacts(page),
|
|
1031
|
+
resolved: () => to,
|
|
1032
|
+
};
|
|
1033
|
+
}
|
|
1034
|
+
if (from !== undefined) {
|
|
1035
|
+
const driver = await this.#originDriver(from, to);
|
|
1036
|
+
return {
|
|
1037
|
+
name: from,
|
|
1038
|
+
authzTables: typeof driver.enumerateFacts !== 'function',
|
|
1039
|
+
enumerate: async (page) => {
|
|
1040
|
+
if (typeof driver.enumerateFacts !== 'function') {
|
|
1041
|
+
throw new UnsupportedOperationError('enumerateFacts', `authz:reconcile --from=${from}`, from, `El driver '${from}' no sabe entregar sus hechos. El driver 'database' es ese caso a propósito: ` +
|
|
1042
|
+
`sus hechos son 'authz_assignments'/'authz_denies' y el destino los lee de ahí.`);
|
|
1043
|
+
}
|
|
1044
|
+
return driver.enumerateFacts(page);
|
|
1045
|
+
},
|
|
1046
|
+
resolved: () => from,
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
let resolvedName = AUTHZ_TABLES_ORIGIN;
|
|
1050
|
+
const enumerate = await this.#factsEnumerator(to, (name) => {
|
|
1051
|
+
resolvedName = name;
|
|
1052
|
+
});
|
|
1053
|
+
return { name: AUTHZ_TABLES_ORIGIN, authzTables: true, enumerate, resolved: () => resolvedName };
|
|
1054
|
+
}
|
|
1055
|
+
/** El driver que `--from` nombra, con los dos errores de 3b-3b intactos. */
|
|
1056
|
+
async #originDriver(from, to) {
|
|
1057
|
+
const registered = Object.keys(this.#config.drivers ?? {});
|
|
1058
|
+
if (from === to) {
|
|
1059
|
+
throw new AuthorizationConfigError(`authz:reconcile --from=${from} --to=${to}: el origen y el destino son el mismo driver. ` +
|
|
1060
|
+
`Si lo que quieres es VERIFICAR y reparar lo derivado del driver activo, no lo digas con --from: ` +
|
|
1061
|
+
`la pasada ya lee sus hechos de él cuando es el driver por defecto.`);
|
|
1062
|
+
}
|
|
1063
|
+
const factory = this.#config.drivers?.[from];
|
|
1064
|
+
if (!factory) {
|
|
1065
|
+
throw new AuthorizationConfigError(`authz:reconcile --from=${from}: ese driver no está registrado en config/authorization.ts ` +
|
|
1066
|
+
`(registrados: ${registered.join(', ') || 'ninguno'}).`);
|
|
1067
|
+
}
|
|
1068
|
+
return factory();
|
|
1069
|
+
}
|
|
1070
|
+
/**
|
|
1071
|
+
* **Quién es el ORIGEN de `authz:reconcile --to=<destino>`** (3b-3b), y su
|
|
1072
|
+
* enumerador de hechos — perezoso: se resuelve la PRIMERA vez que el
|
|
1073
|
+
* destino lo pide, así que la dirección que lee `authz_*` (`--to=openfga`)
|
|
1074
|
+
* no construye ningún driver de más.
|
|
379
1075
|
*
|
|
380
|
-
* La
|
|
381
|
-
* resuelve
|
|
382
|
-
*
|
|
383
|
-
* los que
|
|
384
|
-
*
|
|
1076
|
+
* La regla es determinista y RUIDOSA, nunca «el que haya» (`--from` lo
|
|
1077
|
+
* resuelve antes `#factsOrigin`, 3b-5):
|
|
1078
|
+
* - se busca entre los drivers registrados distintos del
|
|
1079
|
+
* destino los que sepan ser origen (`capabilities.enumerateFacts` o el
|
|
1080
|
+
* método): **exactamente uno** ⇒ ése; **ninguno** ⇒ 500
|
|
1081
|
+
* `E_AUTHZ_UNSUPPORTED` nombrando `enumerateFacts`; **más de uno** ⇒ 500
|
|
1082
|
+
* pidiendo `--from`, porque elegir por ti es elegir de dónde sale lo que
|
|
1083
|
+
* va a quedar escrito.
|
|
385
1084
|
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
1085
|
+
* Nunca «cero hechos» en silencio: un origen que no responde y un `--prune`
|
|
1086
|
+
* detrás vacían el destino, y eso no puede depender de adivinar.
|
|
388
1087
|
*/
|
|
389
|
-
async #
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
const
|
|
398
|
-
|
|
1088
|
+
async #factsEnumerator(to, onResolved) {
|
|
1089
|
+
let resolved = null;
|
|
1090
|
+
const build = async () => {
|
|
1091
|
+
const registered = Object.keys(this.#config.drivers ?? {});
|
|
1092
|
+
const candidates = [];
|
|
1093
|
+
for (const candidate of registered) {
|
|
1094
|
+
if (candidate === to)
|
|
1095
|
+
continue;
|
|
1096
|
+
const driver = await this.#config.drivers[candidate]();
|
|
1097
|
+
if (typeof driver.enumerateFacts === 'function')
|
|
1098
|
+
candidates.push({ name: candidate, driver });
|
|
399
1099
|
}
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
1100
|
+
if (candidates.length === 1) {
|
|
1101
|
+
onResolved?.(candidates[0].name);
|
|
1102
|
+
return candidates[0].driver;
|
|
1103
|
+
}
|
|
1104
|
+
if (candidates.length === 0) {
|
|
1105
|
+
throw new UnsupportedOperationError('enumerateFacts', `authz:reconcile --to=${to}`, to, `Ningún driver registrado (${registered.join(', ') || 'ninguno'}) sabe ser el ORIGEN de esta ` +
|
|
1106
|
+
`migración. Sin hechos que leer, la pasada escribiría cero y con --prune vaciaría el destino.`);
|
|
404
1107
|
}
|
|
405
|
-
|
|
1108
|
+
throw new AuthorizationConfigError(`authz:reconcile --to=${to}: hay más de un origen posible ` +
|
|
1109
|
+
`(${candidates.map((c) => c.name).join(', ')}). Dilo con --from=<driver>: de dónde salen los ` +
|
|
1110
|
+
`hechos decide lo que va a quedar escrito, y eso no se adivina.`);
|
|
1111
|
+
};
|
|
1112
|
+
return async (page) => {
|
|
1113
|
+
resolved ??= await build();
|
|
1114
|
+
return resolved.enumerateFacts(page);
|
|
1115
|
+
};
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* `scopes.enumerateEdges` o 500: sin el árbol del consumidor no se puede
|
|
1119
|
+
* reconstruir el del backend, y suponerlo plano sería inventar una
|
|
1120
|
+
* jerarquía (y con ella una concesión).
|
|
1121
|
+
*/
|
|
1122
|
+
#edgesEnumerator() {
|
|
1123
|
+
const enumerate = this.#config.scopes?.enumerateEdges;
|
|
1124
|
+
if (typeof enumerate !== 'function') {
|
|
1125
|
+
throw new AuthorizationConfigError("authz:reconcile necesita 'scopes.enumerateEdges' en config/authorization.ts: es el árbol ENTERO, " +
|
|
1126
|
+
'paginado, y es lo que se migra (y lo que dice qué aristas del backend ya no respalda nadie). ' +
|
|
1127
|
+
'sqlScopeEdges(...) lo implementa sobre una tabla con columna padre.');
|
|
1128
|
+
}
|
|
1129
|
+
return enumerate;
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* **La ventana del relay, medida** (decisión del dueño del 2026-08-30,
|
|
1133
|
+
* consecuencia 4): cuántos cambios del árbol están encolados sin aplicar
|
|
1134
|
+
* —el backend decide con el árbol viejo mientras tanto— y cuántos están
|
|
1135
|
+
* APARCADOS, que ya no es una ventana sino una divergencia permanente.
|
|
1136
|
+
*
|
|
1137
|
+
* Se mide con las escrituras congeladas, así que la cola no crece durante
|
|
1138
|
+
* la cuenta. Sin outbox no hay ventana (el manager escribe en línea) y los
|
|
1139
|
+
* dos números son cero.
|
|
1140
|
+
*/
|
|
1141
|
+
async #relayWindow() {
|
|
1142
|
+
const outbox = this.#outbox();
|
|
1143
|
+
if (!outbox)
|
|
1144
|
+
return { pending: 0, dead: 0 };
|
|
1145
|
+
let pending = 0;
|
|
1146
|
+
let after;
|
|
1147
|
+
for (let page = 0; page < RELAY_WINDOW_MAX_PAGES; page++) {
|
|
1148
|
+
const batch = await outbox.pending(DEFAULT_RELAY_BATCH, after);
|
|
1149
|
+
pending += batch.length;
|
|
1150
|
+
if (batch.length < DEFAULT_RELAY_BATCH)
|
|
1151
|
+
break;
|
|
1152
|
+
after = batch[batch.length - 1].id;
|
|
1153
|
+
}
|
|
1154
|
+
const dead = typeof outbox.dead === 'function' ? (await outbox.dead(DEFAULT_RELAY_BATCH)).length : 0;
|
|
1155
|
+
return { pending, dead };
|
|
1156
|
+
}
|
|
1157
|
+
/**
|
|
1158
|
+
* Los scopes que un cambio del árbol NOMBRA: son las claves con las que se
|
|
1159
|
+
* decide si otro cambio depende de él (3b-2h · 🔴 2). Dos cambios que no
|
|
1160
|
+
* comparten ninguna no pueden interactuar en el árbol —toda dependencia
|
|
1161
|
+
* (recolgar, cerrar un ciclo, purgar) viaja por un nodo nombrado—, así que
|
|
1162
|
+
* el orden RELATIVO que hay que conservar es exactamente este.
|
|
1163
|
+
*/
|
|
1164
|
+
static #changeKeys(change) {
|
|
1165
|
+
return change.op === 'detached'
|
|
1166
|
+
? [scopeKey(change.child)]
|
|
1167
|
+
: [scopeKey(change.child), scopeKey(change.parent)];
|
|
1168
|
+
}
|
|
1169
|
+
/** Lo aparcado por la outbox (si sabe aparcar), listo para el reporte. */
|
|
1170
|
+
static async #deadLetters(outbox, limit) {
|
|
1171
|
+
if (typeof outbox.dead !== 'function')
|
|
1172
|
+
return [];
|
|
1173
|
+
const rows = await outbox.dead(limit);
|
|
1174
|
+
return rows.map((item) => ({
|
|
1175
|
+
id: item.id,
|
|
1176
|
+
change: item.change,
|
|
1177
|
+
attempts: item.attempts,
|
|
1178
|
+
...(item.lastError === undefined ? {} : { error: item.lastError }),
|
|
1179
|
+
}));
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Aplica UN cambio del árbol al driver. Es el mismo camino que
|
|
1183
|
+
* `scopes.*` sin outbox, incluido el orden de `detached`: **hechos primero
|
|
1184
|
+
* —el driver demuestra cero o lanza—, arista al final** (S6). Al revés,
|
|
1185
|
+
* una purga muerta a medias dejaría grants vivos en un scope sin ancestro,
|
|
1186
|
+
* los denies heredados dejarían de aplicar y esos permisos serían
|
|
1187
|
+
* INDENEGABLES (invariante 2).
|
|
1188
|
+
*/
|
|
1189
|
+
async #applyScopeChange(driver, item) {
|
|
1190
|
+
const change = item.change;
|
|
1191
|
+
if (change.op === 'attached') {
|
|
1192
|
+
await driver.onScopeAttached?.(change.child, change.parent);
|
|
1193
|
+
return;
|
|
406
1194
|
}
|
|
407
|
-
|
|
1195
|
+
if (change.op === 'moved') {
|
|
1196
|
+
await driver.onScopeMoved?.(change.child, change.parent);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
// La auditoría no pierde al autor por haber pasado por una cola.
|
|
1200
|
+
const event = {
|
|
1201
|
+
action: 'scope_purged',
|
|
1202
|
+
scope: change.child,
|
|
1203
|
+
...(item.actor ? { actor: item.actor } : {}),
|
|
1204
|
+
};
|
|
1205
|
+
await this.#write(event, () => driver.purgeScope(change.child));
|
|
1206
|
+
await driver.onScopeDetached?.(change.child);
|
|
1207
|
+
await this.#notify(event);
|
|
1208
|
+
}
|
|
1209
|
+
/** Cota entera positiva de las opciones del relay (500 si llega otra cosa). */
|
|
1210
|
+
static #positive(value, fallback, name) {
|
|
1211
|
+
if (value === undefined)
|
|
1212
|
+
return fallback;
|
|
1213
|
+
if (!Number.isInteger(value) || value < 1) {
|
|
1214
|
+
throw new AuthorizationConfigError(`authz:scopes:relay: ${name} debe ser un entero >= 1 (llegó ${String(value)})`);
|
|
1215
|
+
}
|
|
1216
|
+
return value;
|
|
408
1217
|
}
|
|
409
1218
|
/**
|
|
410
1219
|
* Valida las opciones comunes de una escritura (B7) ANTES de identidad,
|
|
@@ -412,7 +1221,11 @@ export class AuthorizationManager {
|
|
|
412
1221
|
* `requireActor`. Devuelve `{ actor }` listo para fundir en el evento (o
|
|
413
1222
|
* `{}` si no hay actor: el evento no inventa autores).
|
|
414
1223
|
*/
|
|
415
|
-
#writeOptions(options, operation) {
|
|
1224
|
+
async #writeOptions(options, operation) {
|
|
1225
|
+
// Sin cast sobre `transaction` (L-1 · 🟠 8): la barrera no lee nada del
|
|
1226
|
+
// llamante, y `transaction` solo lo consumen los tipos que lo declaran
|
|
1227
|
+
// (`ScopeTreeWriteOptions`, para ENCOLAR — nunca para decidir).
|
|
1228
|
+
await this.#assertNotFrozen(operation);
|
|
416
1229
|
if (options?.actor !== undefined)
|
|
417
1230
|
assertSubject(options.actor);
|
|
418
1231
|
if (this.#config.requireActor === true && !options?.actor) {
|
|
@@ -535,6 +1348,26 @@ export class AuthorizationManager {
|
|
|
535
1348
|
}
|
|
536
1349
|
return within;
|
|
537
1350
|
}
|
|
1351
|
+
/**
|
|
1352
|
+
* La outbox del árbol, si el consumidor la declaró (3b-2d). Con ella,
|
|
1353
|
+
* `scopes.attached/moved/detached` NO tocan el driver: encolan el cambio
|
|
1354
|
+
* en la transacción del consumidor y lo aplica `authz:scopes:relay`.
|
|
1355
|
+
*
|
|
1356
|
+
* Por qué no es una recomendación sino un mecanismo (panel 2, cruce 4 ·
|
|
1357
|
+
* S5): sin outbox, el paquete escribe en el backend DENTRO de la
|
|
1358
|
+
* transacción del consumidor y un `rollback` posterior no lo deshace. El
|
|
1359
|
+
* árbol del backend queda adelantado al de la base del consumidor y en
|
|
1360
|
+
* modo `facts` eso es una escalada persistente e invisible —el backend es
|
|
1361
|
+
* el PDP, y la aplicación lista y audita contra su propia base—.
|
|
1362
|
+
*
|
|
1363
|
+
* Lo que la outbox NO arregla: el lag del relay. Durante esos segundos el
|
|
1364
|
+
* backend decide con el árbol VIEJO, y eso es un **fail-open temporal**
|
|
1365
|
+
* (el tenant antiguo conserva acceso tras un `moved`; los denies heredados
|
|
1366
|
+
* no aplican tras un `attached`). No hay 2PC.
|
|
1367
|
+
*/
|
|
1368
|
+
#outbox() {
|
|
1369
|
+
return this.#config.scopes?.outbox;
|
|
1370
|
+
}
|
|
538
1371
|
#resolver(operation) {
|
|
539
1372
|
const resolver = this.#config.scopes?.resolveChain;
|
|
540
1373
|
if (!resolver) {
|
|
@@ -543,7 +1376,13 @@ export class AuthorizationManager {
|
|
|
543
1376
|
}
|
|
544
1377
|
return resolver;
|
|
545
1378
|
}
|
|
546
|
-
/**
|
|
1379
|
+
/**
|
|
1380
|
+
* Valida la arista y devuelve la cadena (fresca) del padre y el HIJO
|
|
1381
|
+
* CANÓNICO (invariante 17). El hijo canónico se devuelve desde 3b-2d
|
|
1382
|
+
* porque la outbox lo encola: lo que se guarda en la cola es la fila del
|
|
1383
|
+
* árbol, no lo que escribió el llamante — si no, el relay abriría días
|
|
1384
|
+
* después una rama nueva en el store por un alias del uuid.
|
|
1385
|
+
*/
|
|
547
1386
|
async #assertEdge(child, parent, operation) {
|
|
548
1387
|
const resolver = this.#resolver(operation);
|
|
549
1388
|
assertScope(child);
|
|
@@ -556,12 +1395,13 @@ export class AuthorizationManager {
|
|
|
556
1395
|
// El hijo, si el árbol ya lo conoce, con su identidad canónica (K1): un
|
|
557
1396
|
// alias del uuid no puede colarse por debajo de la comprobación de ciclo.
|
|
558
1397
|
const known = await resolveChain(resolver, child, operation);
|
|
559
|
-
const
|
|
1398
|
+
const canonicalChild = known ? known[0] : child;
|
|
1399
|
+
const childKey = _a.#scopeKey(canonicalChild);
|
|
560
1400
|
if (chain.some((s) => _a.#scopeKey(s) === childKey)) {
|
|
561
1401
|
throw new ScopeCycleError(`${operation}: ${parent.type}:${parent.uuid} desciende de ${childKey.replace('\u001f', ':')} (o es él mismo); ` +
|
|
562
1402
|
`colgarlo cerraría un ciclo y la herencia dejaría de ser solo hacia abajo.`);
|
|
563
1403
|
}
|
|
564
|
-
return chain;
|
|
1404
|
+
return { chain, child: canonicalChild };
|
|
565
1405
|
}
|
|
566
1406
|
// La identidad se valida AQUÍ, antes de resolver siquiera el driver: una
|
|
567
1407
|
// pregunta mal formada (uuid ausente, `{app, uuid}`, slug con `~`) es 422
|
|
@@ -816,7 +1656,8 @@ export class AuthorizationManager {
|
|
|
816
1656
|
* Devuelve el rol y notifica `role_defined`.
|
|
817
1657
|
*/
|
|
818
1658
|
async defineScopedRole(actor, ownerScope, spec, options) {
|
|
819
|
-
|
|
1659
|
+
this.#assertNoTransaction(options, 'defineScopedRole');
|
|
1660
|
+
const who = await this.#requireActor(actor, 'defineScopedRole');
|
|
820
1661
|
this.#assertOwnerScope(ownerScope, 'defineScopedRole');
|
|
821
1662
|
const parsed = this.#parseScopedRoleSpec(spec);
|
|
822
1663
|
const driver = await this.driver();
|
|
@@ -890,6 +1731,11 @@ export class AuthorizationManager {
|
|
|
890
1731
|
}
|
|
891
1732
|
});
|
|
892
1733
|
const role = Object.freeze({ uuid, slug: parsed.slug, scopeType: parsed.scopeType, owner: ownerKey, rank: parsed.rank });
|
|
1734
|
+
// La proyección derivada del driver, si la tiene (3b-2e · E4): en el modo
|
|
1735
|
+
// `facts` lo que un rol concede son TUPLAS, así que un rol definido sin
|
|
1736
|
+
// proyectar no concedería nada — un no-op silencioso. Va después del
|
|
1737
|
+
// commit del catálogo y antes de notificar.
|
|
1738
|
+
await driver.projectCatalogRole?.(uuid);
|
|
893
1739
|
await this.#notifyCatalog({
|
|
894
1740
|
action: 'role_defined',
|
|
895
1741
|
actor: who,
|
|
@@ -910,7 +1756,8 @@ export class AuthorizationManager {
|
|
|
910
1756
|
* escribe ni notifica (idempotente). Notifica `role_updated`.
|
|
911
1757
|
*/
|
|
912
1758
|
async updateScopedRole(actor, roleUuid, changes, options) {
|
|
913
|
-
|
|
1759
|
+
this.#assertNoTransaction(options, 'updateScopedRole');
|
|
1760
|
+
const who = await this.#requireActor(actor, 'updateScopedRole');
|
|
914
1761
|
assertCatalogUuid('rol', roleUuid);
|
|
915
1762
|
const parsed = this.#parseScopedRoleChanges(changes);
|
|
916
1763
|
const driver = await this.driver();
|
|
@@ -985,6 +1832,12 @@ export class AuthorizationManager {
|
|
|
985
1832
|
return touched;
|
|
986
1833
|
}, { skipIfNoop: true });
|
|
987
1834
|
const updated = Object.freeze({ ...role, rank: next.rank });
|
|
1835
|
+
// 3b-2e · E4: quitarle un permiso a un rol tiene que dejar de conceder
|
|
1836
|
+
// también en el driver que proyecta el catálogo como tuplas. Sin esto la
|
|
1837
|
+
// tupla `permits_<P>` sobrevive al vínculo y el rol sigue concediendo lo
|
|
1838
|
+
// que ya no vincula: fail-open.
|
|
1839
|
+
if (changed && permissionsChanged)
|
|
1840
|
+
await driver.projectCatalogRole?.(role.uuid);
|
|
988
1841
|
if (changed)
|
|
989
1842
|
await this.#notifyCatalog({ action: 'role_updated', actor: who, role: updated, owner, permissions: nextPermissions });
|
|
990
1843
|
return updated;
|
|
@@ -997,7 +1850,8 @@ export class AuthorizationManager {
|
|
|
997
1850
|
* su owner. Notifica `role_purged`. No necesita `listDenies`.
|
|
998
1851
|
*/
|
|
999
1852
|
async deleteScopedRole(actor, roleUuid, options) {
|
|
1000
|
-
|
|
1853
|
+
this.#assertNoTransaction(options, 'deleteScopedRole');
|
|
1854
|
+
const who = await this.#requireActor(actor, 'deleteScopedRole');
|
|
1001
1855
|
assertCatalogUuid('rol', roleUuid);
|
|
1002
1856
|
const driver = await this.driver();
|
|
1003
1857
|
const purgeRole = this.#optional(driver, 'purgeRole', 'deleteScopedRole');
|
|
@@ -1017,8 +1871,194 @@ export class AuthorizationManager {
|
|
|
1017
1871
|
}
|
|
1018
1872
|
await this.#notifyCatalog({ action: 'role_purged', actor: who, role, owner, permissions });
|
|
1019
1873
|
}
|
|
1874
|
+
/**
|
|
1875
|
+
* Los roles LOCALES cuyo owner el árbol YA NO conoce, y —con `force`— su
|
|
1876
|
+
* purga. Es el motor de `authz:catalog:prune-orphans` (3b-0 · Z2).
|
|
1877
|
+
*
|
|
1878
|
+
* Un rol así está DORMIDO, y «dormido» significa **exactamente** esto
|
|
1879
|
+
* (3b-0b · AA1, auditor 3b-0): no es visible desde ningún scope vivo cuya
|
|
1880
|
+
* cadena NO pase por su owner. No significa que no conceda. La regla única
|
|
1881
|
+
* de visibilidad (invariante 18) pide que el owner esté en la cadena del
|
|
1882
|
+
* scope preguntado, y **un descendiente vivo cuya ruta materializada sigue
|
|
1883
|
+
* pasando por el owner la cumple**: ahí el rol concede, es membresía por
|
|
1884
|
+
* los seis caminos de lectura y se puede ASIGNAR, por slug y por uuid.
|
|
1885
|
+
* Ocurre en cuanto el consumidor borra la fila del owner sin borrar (o sin
|
|
1886
|
+
* notificar) la de sus descendientes — el borrado en dos pasos y las rutas
|
|
1887
|
+
* materializadas son lo normal. Por eso este barrido es destructivo de
|
|
1888
|
+
* verdad y por eso `--dry-run` es el default: puede estar revocando
|
|
1889
|
+
* permisos VIVOS, no recogiendo basura inerte. Lo que sí es seguro decir:
|
|
1890
|
+
* un rol huérfano SIN asignaciones vigentes no concede nada, y ninguno
|
|
1891
|
+
* concede en un scope cuya cadena no pase por el owner.
|
|
1892
|
+
*
|
|
1893
|
+
* Cada huérfano viene con `assignments` (hechos vigentes) y
|
|
1894
|
+
* `stillGranting` (`assignments > 0`), que es la marca CONSERVADORA de
|
|
1895
|
+
* «esto no es basura inerte»: cuenta hechos, no comprueba si el scope de
|
|
1896
|
+
* cada uno sigue resolviendo. Falso ⇒ no concede seguro; verdadero ⇒
|
|
1897
|
+
* míralo antes de `--force`.
|
|
1898
|
+
*
|
|
1899
|
+
* **Y esos hechos se los cuenta el DRIVER** (3b-2j, decisión del dueño del
|
|
1900
|
+
* 2026-08-31 (3)), con `countRoleAssignments` del puerto. Hasta aquí los
|
|
1901
|
+
* contaba el propio barrido en `authz_assignments` —la tabla del driver
|
|
1902
|
+
* `database`—, así que con `openfga` en modo `facts`, donde los hechos
|
|
1903
|
+
* viven en el store, `stillGranting` era SIEMPRE `false`: el barrido
|
|
1904
|
+
* declaraba basura inerte, justo antes de un borrado destructivo, un rol
|
|
1905
|
+
* que estaba concediendo (medido en el lote 2i). Un driver que no traiga
|
|
1906
|
+
* el método deja los DOS campos en **`undefined`**, nunca en `false`: «no
|
|
1907
|
+
* lo sé» no puede degradar a «no concede», que es exactamente el bug. Con
|
|
1908
|
+
* `undefined` el rol no es demostrablemente inerte y el comando lo lista
|
|
1909
|
+
* APARTE, igual que a los que sí conceden.
|
|
1910
|
+
*
|
|
1911
|
+
* Lo que el rol dormido sí hace en todo caso es ocupar su `(slug, nivel)`
|
|
1912
|
+
* dentro del subárbol donde todavía se le vea, y `deleteScopedRole` no lo
|
|
1913
|
+
* alcanza (resuelve el owner en fresco y responde 422
|
|
1914
|
+
* `E_AUTHZ_UNKNOWN_SCOPE`). Hasta 3G esa limpieza la arrastraba
|
|
1915
|
+
* `scopes.detached`, y ahí es donde nacieron tres de las cuatro
|
|
1916
|
+
* regresiones de la Fase 3: la operación la dispara un TENANT, sobre un
|
|
1917
|
+
* scope que ya no resuelve, así que hubo que inventarle una policy de
|
|
1918
|
+
* rango sin cadena donde medirla, una enumeración del subárbol y una
|
|
1919
|
+
* degradación — tres piezas que compuestas destruían roles de
|
|
1920
|
+
* descendientes VIVOS. Aquí no hay nada de eso: es una operación de
|
|
1921
|
+
* PLATAFORMA (una tarea de mantenimiento con acceso al catálogo, como
|
|
1922
|
+
* `authz:catalog:sync`), no lleva actor y no mide rangos, exactamente como
|
|
1923
|
+
* el `purgeRole` de último recurso que el README ya prometía. Es, junto a
|
|
1924
|
+
* `driver()`, **API de plataforma**: se salta `requireActor` y
|
|
1925
|
+
* `requireWithin` a propósito, así que no se expone a un controlador.
|
|
1926
|
+
*
|
|
1927
|
+
* `force: false` (el default, y el del comando: `--dry-run`) NO escribe:
|
|
1928
|
+
* devuelve la lista para que un humano la mire. Con `force: true` cada rol
|
|
1929
|
+
* se purga con `purgeRole` —atómico: asignaciones + vínculos + fila +
|
|
1930
|
+
* versión del catálogo— y se notifica `role_purged` (sin `actor`). El
|
|
1931
|
+
* conjunto no es atómico, y por eso el reporte dice QUÉ se purgó
|
|
1932
|
+
* (`purged: CatalogRoleRef[]`, 3b-0b · AB3) y no cuántos: si un
|
|
1933
|
+
* `purgeRole` falla a mitad, lo anterior ya está borrado —con el hallazgo
|
|
1934
|
+
* de AA1 eso puede ser revocación parcial de permisos vivos— y quien
|
|
1935
|
+
* recoge el 503 necesita la lista, no un contador. Una pasada
|
|
1936
|
+
* interrumpida la recoge la siguiente (el orden es estable por uuid).
|
|
1937
|
+
*
|
|
1938
|
+
* **Dos seguros contra el barrido a ciegas**, que es el riesgo real
|
|
1939
|
+
* (auditor 3b-0):
|
|
1940
|
+
*
|
|
1941
|
+
* - **Cota de purga masiva** (AA2): si TODOS los owners distintos
|
|
1942
|
+
* resultan huérfanos, o si los huérfanos superan el 50 % de los roles
|
|
1943
|
+
* locales, `force` es 500 `E_AUTHZ_MASS_PURGE_REFUSED` **antes de
|
|
1944
|
+
* borrar nada**. Esa es la firma de un `resolveChain` filtrado por el
|
|
1945
|
+
* tenant de la petición o corriendo sin contexto (comando, réplica
|
|
1946
|
+
* atrasada): devuelve `null` para todo y la pasada se lleva el catálogo
|
|
1947
|
+
* local de TODOS los tenants (medido: 2 de 2 roles vivos). Una poda
|
|
1948
|
+
* grande de verdad pasa con `allowMassPurge: true`
|
|
1949
|
+
* (`--allow-mass-purge`), que es una decisión humana. El `--dry-run` no
|
|
1950
|
+
* lanza —es justo el diagnóstico que hay que poder mirar— pero lo
|
|
1951
|
+
* marca en `massPurge`.
|
|
1952
|
+
* - **Re-resolución justo antes de cada purga** (AA3): entre la lectura y
|
|
1953
|
+
* el borrado cabe un `scopes.attached`/restore concurrente, y la
|
|
1954
|
+
* ventana es TODA la pasada (N roles + N `resolveChain`), no un
|
|
1955
|
+
* instante. Cada owner se vuelve a resolver en FRESCO inmediatamente
|
|
1956
|
+
* antes de su `purgeRole`; si ha vuelto, el rol se salta y se cuenta en
|
|
1957
|
+
* `skipped` con `reason: 'owner-came-back'`.
|
|
1958
|
+
*
|
|
1959
|
+
* Coste: una lectura del catálogo local + un `resolveChain` por OWNER
|
|
1960
|
+
* DISTINTO (memoizado) + UNA llamada a `countRoleAssignments` con los
|
|
1961
|
+
* uuids de los huérfanos (ninguna si no hay) + un `resolveChain` más por
|
|
1962
|
+
* rol purgado (el de AA3). Es O(owners con roles locales) para mirar y
|
|
1963
|
+
* O(roles purgados) para borrar, y corre en un comando, no en el camino de
|
|
1964
|
+
* una petición.
|
|
1965
|
+
*/
|
|
1966
|
+
async pruneOrphanRoles(options = {}) {
|
|
1967
|
+
const force = options.force === true;
|
|
1968
|
+
this.#resolver('authz:catalog:prune-orphans');
|
|
1969
|
+
const driver = await this.driver();
|
|
1970
|
+
// Antes de leer nada: un driver que no sabe purgar lo dice, no se
|
|
1971
|
+
// descubre a mitad de la pasada (3E · P4). Y antes que la barrera del
|
|
1972
|
+
// freeze (3b-7): «no sé purgar» es permanente y se dice sin consultar
|
|
1973
|
+
// NADA (la promesa medida en 3b-1); «estás congelado» es transitorio.
|
|
1974
|
+
const purgeRole = this.#optional(driver, 'purgeRole', 'pruneOrphanRoles');
|
|
1975
|
+
if (force)
|
|
1976
|
+
await this.#assertNotFrozen('authz:catalog:prune-orphans');
|
|
1977
|
+
const resolver = this.#freshResolver();
|
|
1978
|
+
const locals = await readLocalRoles({ driver: this.#config.default });
|
|
1979
|
+
const resolved = new Map();
|
|
1980
|
+
const orphans = [];
|
|
1981
|
+
for (const { role, permissions } of locals) {
|
|
1982
|
+
const owner = this.#ownerOf(role);
|
|
1983
|
+
if (!resolved.has(role.owner)) {
|
|
1984
|
+
resolved.set(role.owner, (await resolveChain(resolver, owner, 'pruneOrphanRoles')) !== null);
|
|
1985
|
+
}
|
|
1986
|
+
if (resolved.get(role.owner))
|
|
1987
|
+
continue;
|
|
1988
|
+
orphans.push({ role, owner, permissions, assignments: undefined, stillGranting: undefined });
|
|
1989
|
+
}
|
|
1990
|
+
// Los hechos son del DRIVER, no de una tabla (3b-2j). Sin el método del
|
|
1991
|
+
// puerto los dos campos se quedan en `undefined`: el barrido no lo sabe y
|
|
1992
|
+
// lo dice, en vez de degradar a «no concede».
|
|
1993
|
+
if (orphans.length > 0 && typeof driver.countRoleAssignments === 'function') {
|
|
1994
|
+
const counts = await driver.countRoleAssignments(orphans.map(({ role }) => role.uuid));
|
|
1995
|
+
if (!Array.isArray(counts) || counts.length !== orphans.length) {
|
|
1996
|
+
throw new AuthorizationInternalError(`countRoleAssignments: el driver '${this.#config.default}' respondió ${Array.isArray(counts) ? counts.length : typeof counts} ` +
|
|
1997
|
+
`valor(es) para ${orphans.length} rol(es). La respuesta es POR POSICIÓN y esto se lee antes de borrar: no se ` +
|
|
1998
|
+
`adivina cuál era de quién.`);
|
|
1999
|
+
}
|
|
2000
|
+
counts.forEach((total, i) => {
|
|
2001
|
+
if (!Number.isInteger(total) || total < 0) {
|
|
2002
|
+
throw new AuthorizationInternalError(`countRoleAssignments: el driver '${this.#config.default}' respondió '${total}' para el rol ` +
|
|
2003
|
+
`'${orphans[i].role.slug}' (${orphans[i].role.uuid}); se espera un entero ≥ 0.`);
|
|
2004
|
+
}
|
|
2005
|
+
orphans[i].assignments = total;
|
|
2006
|
+
orphans[i].stillGranting = total > 0;
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
2009
|
+
const owners = new Set(locals.map(({ role }) => role.owner));
|
|
2010
|
+
const orphanOwners = new Set(orphans.map(({ role }) => role.owner));
|
|
2011
|
+
const massPurge = orphans.length > 0 && (orphanOwners.size === owners.size || orphans.length * 2 > locals.length);
|
|
2012
|
+
if (!force)
|
|
2013
|
+
return { orphans, purged: [], skipped: [], massPurge, dryRun: true };
|
|
2014
|
+
if (massPurge && options.allowMassPurge !== true) {
|
|
2015
|
+
throw new MassPurgeRefusedError(`pruneOrphanRoles: ${orphans.length} de ${locals.length} roles locales (${orphanOwners.size} de ${owners.size} ` +
|
|
2016
|
+
`owners distintos) tienen el owner fuera del árbol. Esa es la firma de un 'scopes.resolveChain' ciego —filtrado ` +
|
|
2017
|
+
`por el tenant de la petición, o sin contexto— que devuelve null para todo: una pasada así borra el catálogo ` +
|
|
2018
|
+
`local de todos los tenants. No se ha borrado nada. Comprueba el resolutor y, si la poda es real, repite con ` +
|
|
2019
|
+
`allowMassPurge: true (--allow-mass-purge).`);
|
|
2020
|
+
}
|
|
2021
|
+
const purged = [];
|
|
2022
|
+
const skipped = [];
|
|
2023
|
+
for (const { role, owner, permissions } of orphans) {
|
|
2024
|
+
// AA3: la ventana entre leer y borrar es toda la pasada. El owner se
|
|
2025
|
+
// vuelve a resolver EN FRESCO aquí mismo; si ha vuelto (un
|
|
2026
|
+
// `scopes.attached`, un restore, una réplica que se pone al día) este
|
|
2027
|
+
// rol ya no es huérfano y no se toca.
|
|
2028
|
+
if ((await resolveChain(this.#freshResolver(), owner, 'pruneOrphanRoles')) !== null) {
|
|
2029
|
+
skipped.push({ role, reason: 'owner-came-back' });
|
|
2030
|
+
continue;
|
|
2031
|
+
}
|
|
2032
|
+
try {
|
|
2033
|
+
// 3b-8 · B3 (mismo patrón que el relay): la ventana de la pasada es
|
|
2034
|
+
// larga (N roles × resolveChain) y la mirada única de la entrada
|
|
2035
|
+
// dejaba purgas destructivas DESPUÉS de un freeze adquirido a mitad.
|
|
2036
|
+
// Se re-afirma por rol, ANTES de cada borrado; el 503 sale envuelto
|
|
2037
|
+
// en PruneInterruptedError para que viaje la lista de lo YA purgado.
|
|
2038
|
+
await this.#assertNotFrozen('authz:catalog:prune-orphans');
|
|
2039
|
+
await purgeRole(role.uuid);
|
|
2040
|
+
}
|
|
2041
|
+
catch (error) {
|
|
2042
|
+
// La purga no es transaccional ENTRE roles: lo ya borrado está
|
|
2043
|
+
// borrado. El valor de retorno no llega a producirse, así que la
|
|
2044
|
+
// lista viaja en el error (tester 3b-1 §6.2) y el del driver va como
|
|
2045
|
+
// `cause`: la abstracción no filtra.
|
|
2046
|
+
throw new PruneInterruptedError(`pruneOrphanRoles: '${role.slug}' (nivel '${role.scopeType}') no se pudo purgar. ` +
|
|
2047
|
+
`Los ${purged.length} rol(es) anteriores YA están borrados y no se deshacen; el resto sigue vivo. ` +
|
|
2048
|
+
`La lista de lo purgado va en 'error.purged' y también en los eventos 'role_purged' ya emitidos; ` +
|
|
2049
|
+
`la siguiente pasada recoge lo que queda.`, purged, skipped, { cause: error });
|
|
2050
|
+
}
|
|
2051
|
+
finally {
|
|
2052
|
+
invalidateAuthzCatalog();
|
|
2053
|
+
}
|
|
2054
|
+
purged.push(role);
|
|
2055
|
+
await this.#notifyCatalog({ action: 'role_purged', role, owner, permissions });
|
|
2056
|
+
}
|
|
2057
|
+
return { orphans, purged, skipped, massPurge, dryRun: false };
|
|
2058
|
+
}
|
|
1020
2059
|
/** El actor de la API de delegación: obligatorio SIEMPRE (sin él no hay policy que evaluar) y bien formado. */
|
|
1021
|
-
#requireActor(actor, operation) {
|
|
2060
|
+
async #requireActor(actor, operation) {
|
|
2061
|
+
await this.#assertNotFrozen(operation);
|
|
1022
2062
|
if (actor === undefined || actor === null) {
|
|
1023
2063
|
throw new ActorRequiredError(`${operation}: el actor es obligatorio (es quien delega; sin él no hay policy que evaluar).`);
|
|
1024
2064
|
}
|
|
@@ -1170,10 +2210,18 @@ export class AuthorizationManager {
|
|
|
1170
2210
|
* es una función normal del producto. Es un control que el vigilado apaga.
|
|
1171
2211
|
* Se acepta a sabiendas: la regla mínima no concede NADA (es la que corre
|
|
1172
2212
|
* en todo consumidor con el stub publicado), y el daño residual —ocupar un
|
|
1173
|
-
* `(slug, nivel)`— es reparable por AUTORIDAD + RANGO: un ancestro
|
|
1174
|
-
*
|
|
1175
|
-
*
|
|
1176
|
-
*
|
|
2213
|
+
* `(slug, nivel)`— es reparable por AUTORIDAD + RANGO: un ancestro define
|
|
2214
|
+
* el suyo y lo ensombrece (3F · S3 + 3G · W3) **si supera en rango al
|
|
2215
|
+
* squatter** — `rank` es metadata del consumidor (invariante 8) y nada
|
|
2216
|
+
* obliga a que decrezca con la profundidad, así que con un reparto no
|
|
2217
|
+
* monótono (rank 60 en una unit bajo el org-admin rank 50 que es dueño de
|
|
2218
|
+
* ese árbol) el dueño se lleva 422 por las dos puertas y el recurso es la
|
|
2219
|
+
* PLATAFORMA (3b-1 · D1): el techo global acota todo rank local, y
|
|
2220
|
+
* `purgeRole` no mide rango. Quien no acepte ese trato deja
|
|
2221
|
+
* `maxDescendants` por encima de su subárbol mayor (3b-0b · AB1: la
|
|
2222
|
+
* degradación ya no se anuncia en ningún retorno —`truncated` se borró con
|
|
2223
|
+
* `ScopeDetachOutcome` en 3b-0 · Z1—, así que la cota es lo único que hay
|
|
2224
|
+
* que vigilar; `authz:catalog:diff --fail-on-shadows` es el gate de CI).
|
|
1177
2225
|
*
|
|
1178
2226
|
* (Un `scopeType` de nivel `app` muere antes, en `#parseScopedRoleSpec`:
|
|
1179
2227
|
* la raíz no cuelga de ningún owner. Si llegara aquí sería un ancestro.)
|
|
@@ -1297,10 +2345,28 @@ export class AuthorizationManager {
|
|
|
1297
2345
|
/**
|
|
1298
2346
|
* Los homónimos LOCALES a un DESCENDIENTE del owner: los que una
|
|
1299
2347
|
* definición en `ownerKey` ENSOMBRECE (3F · S3). Los owners se resuelven
|
|
1300
|
-
* en fresco; uno que el árbol ya no conoce no ensombrece a nadie
|
|
1301
|
-
*
|
|
1302
|
-
*
|
|
1303
|
-
*
|
|
2348
|
+
* en fresco; uno que el árbol ya no conoce no ensombrece a nadie. Lo usan
|
|
2349
|
+
* `defineScopedRole` (la colisión) y `updateScopedRole` (que no crea
|
|
2350
|
+
* sombras nuevas, pero tampoco deja tocar un rol que ya ensombrece a otro
|
|
2351
|
+
* de más rango — 3G · W3).
|
|
2352
|
+
*
|
|
2353
|
+
* **La VENTANA, dicha** (3b-1 · D2, auditor 3G): `chain === null` es «no
|
|
2354
|
+
* demostrable», y aquí se trata como «no hay sombra». Mientras el árbol no
|
|
2355
|
+
* responda por el owner de la víctima —soft-delete, réplica atrasada, un
|
|
2356
|
+
* scope en «pending»: los mismos estados que el resto del paquete admite
|
|
2357
|
+
* como normales— un actor de rank bajo en un ancestro crea el homónimo sin
|
|
2358
|
+
* pasar por `#assertAboveShadowed`, y al volver el árbol la sombra es real
|
|
2359
|
+
* y permanente. **No se rechaza, a propósito**: desde 3b-0 · Z1 un rol cuyo
|
|
2360
|
+
* owner no resuelve está DORMIDO y la salida es `prune-orphans`, así que
|
|
2361
|
+
* rechazar aquí convertiría un rol dormido en un BLOQUEO de `(slug, nivel)`
|
|
2362
|
+
* —exactamente la mina que Z1 quitó— y lo haría por una condición que el
|
|
2363
|
+
* llamante no puede ni ver ni corregir. Lo que acota el daño: (a) el mismo
|
|
2364
|
+
* atacante consigue la misma denegación **yendo primero**, sin trampa
|
|
2365
|
+
* ninguna (W3 solo protege a los roles que YA existen; ocupar el nombre
|
|
2366
|
+
* antes siempre fue gratis); (b) nadie pierde permisos —`authorize` no
|
|
2367
|
+
* direcciona por slug— y la sombra sale en `authz:catalog:diff` como
|
|
2368
|
+
* `shadowedByAncestor`; (c) el dueño del árbol con rango la borra, y la
|
|
2369
|
+
* plataforma siempre (3b-1 · D1).
|
|
1304
2370
|
*/
|
|
1305
2371
|
async #shadowedBelow(ownerKey, ancestors, others, operation) {
|
|
1306
2372
|
const shadowed = [];
|
|
@@ -1318,7 +2384,13 @@ export class AuthorizationManager {
|
|
|
1318
2384
|
}
|
|
1319
2385
|
/**
|
|
1320
2386
|
* Sobre un rol solo actúa quien lo SUPERA EN RANGO — también para
|
|
1321
|
-
* ensombrecerlo (3G · W3, auditor P3′).
|
|
2387
|
+
* ensombrecerlo (3G · W3, auditor P3′). **Es una comprobación de
|
|
2388
|
+
* ESCRITURA, no un invariante** (3b-1 · D3): quién ensombrece a quién es
|
|
2389
|
+
* función del árbol de HOY y el árbol se mueve sin preguntar aquí
|
|
2390
|
+
* (`scopes.moved` crea sombras sin juzgar ningún rango), y el propio
|
|
2391
|
+
* chequeo tiene su ventana (`#shadowedBelow` con `chain === null`, D2) y
|
|
2392
|
+
* su límite honesto: solo protege a los roles que YA existen —ocupar el
|
|
2393
|
+
* nombre primero siempre fue gratis—. Ensombrecer es tan destructivo
|
|
1322
2394
|
* como borrar: dentro del subárbol del ensombrecido toda ruta por slug
|
|
1323
2395
|
* pasa a 422 `E_AUTHZ_AMBIGUOUS_ROLE` para TODOS, y la víctima no puede
|
|
1324
2396
|
* repararlo (su rango se mide en la cadena del owner del rol que
|
|
@@ -1390,13 +2462,15 @@ export class AuthorizationManager {
|
|
|
1390
2462
|
* `E_AUTHZ_AMBIGUOUS_ROLE`, nunca «el más cercano gana»).
|
|
1391
2463
|
*/
|
|
1392
2464
|
async grant(subject, role, scope, options) {
|
|
1393
|
-
|
|
2465
|
+
await this.#assertTransactionalWrite(options, 'grant');
|
|
2466
|
+
const actor = await this.#writeOptions(options, 'grant');
|
|
1394
2467
|
assertIdentity({ subject, role, scope, expiresAt: options?.expiresAt });
|
|
1395
2468
|
await this.#assertWithin(scope, options, 'grant');
|
|
1396
2469
|
// 3E · Q7: el evento lleva el rol RESUELTO (uuid + slug + nivel + owner),
|
|
1397
2470
|
// no la pregunta cruda. Solo se resuelve si hay hook que lo vaya a leer.
|
|
1398
2471
|
const roles = await this.#resolvedRoles(role, scope, 'grant');
|
|
1399
|
-
const
|
|
2472
|
+
const transactional = this.#transactional(options);
|
|
2473
|
+
const outcome = (await this.#write({ action: 'granted', subject, scope, roles, expiresAt: options?.expiresAt ?? null, ...actor, ...transactional }, async () => (await this.driver()).grant(subject, role, scope, options))) ??
|
|
1400
2474
|
// Un driver de terceros que aún devuelva `void`: la firma promete un
|
|
1401
2475
|
// `GrantOutcome` y no miente (E1). Sin lectura previa no hay caducidad
|
|
1402
2476
|
// anterior que contar: es lo que pidió el llamante, y `existed: false`.
|
|
@@ -1412,6 +2486,7 @@ export class AuthorizationManager {
|
|
|
1412
2486
|
expiresAt: outcome.expiresAt,
|
|
1413
2487
|
previousExpiresAt: outcome.previousExpiresAt,
|
|
1414
2488
|
...actor,
|
|
2489
|
+
...transactional,
|
|
1415
2490
|
});
|
|
1416
2491
|
}
|
|
1417
2492
|
else {
|
|
@@ -1422,6 +2497,7 @@ export class AuthorizationManager {
|
|
|
1422
2497
|
roles,
|
|
1423
2498
|
expiresAt: outcome.expiresAt,
|
|
1424
2499
|
...actor,
|
|
2500
|
+
...transactional,
|
|
1425
2501
|
});
|
|
1426
2502
|
}
|
|
1427
2503
|
return outcome;
|
|
@@ -1433,27 +2509,38 @@ export class AuthorizationManager {
|
|
|
1433
2509
|
* rol.
|
|
1434
2510
|
*/
|
|
1435
2511
|
async revoke(subject, role, scope, options) {
|
|
1436
|
-
|
|
2512
|
+
await this.#assertTransactionalWrite(options, 'revoke');
|
|
2513
|
+
const actor = await this.#writeOptions(options, 'revoke');
|
|
1437
2514
|
assertIdentity({ subject, role, scope });
|
|
1438
2515
|
await this.#assertWithin(scope, options, 'revoke');
|
|
1439
|
-
const event = {
|
|
1440
|
-
|
|
2516
|
+
const event = {
|
|
2517
|
+
action: 'revoked',
|
|
2518
|
+
subject,
|
|
2519
|
+
scope,
|
|
2520
|
+
roles: await this.#resolvedRoles(role, scope, 'revoke'),
|
|
2521
|
+
...actor,
|
|
2522
|
+
...this.#transactional(options),
|
|
2523
|
+
};
|
|
2524
|
+
// L-2: las opciones viajan al driver (`transaction`, si la puerta la dejó pasar) — como en `grant`.
|
|
2525
|
+
await this.#write(event, async () => (await this.driver()).revoke(subject, role, scope, options));
|
|
1441
2526
|
await this.#notify(event);
|
|
1442
2527
|
}
|
|
1443
2528
|
async deny(subject, permission, scope, options) {
|
|
1444
|
-
|
|
2529
|
+
await this.#assertTransactionalWrite(options, 'deny');
|
|
2530
|
+
const actor = await this.#writeOptions(options, 'deny');
|
|
1445
2531
|
assertIdentity({ subject, permission, scope });
|
|
1446
2532
|
await this.#assertWithin(scope, options, 'deny');
|
|
1447
|
-
const event = { action: 'denied', subject, scope, permission, ...actor };
|
|
1448
|
-
await this.#write(event, async () => (await this.driver()).deny(subject, permission, scope));
|
|
2533
|
+
const event = { action: 'denied', subject, scope, permission, ...actor, ...this.#transactional(options) };
|
|
2534
|
+
await this.#write(event, async () => (await this.driver()).deny(subject, permission, scope, options));
|
|
1449
2535
|
await this.#notify(event);
|
|
1450
2536
|
}
|
|
1451
2537
|
async removeDeny(subject, permission, scope, options) {
|
|
1452
|
-
|
|
2538
|
+
await this.#assertTransactionalWrite(options, 'removeDeny');
|
|
2539
|
+
const actor = await this.#writeOptions(options, 'removeDeny');
|
|
1453
2540
|
assertIdentity({ subject, permission, scope });
|
|
1454
2541
|
await this.#assertWithin(scope, options, 'removeDeny');
|
|
1455
|
-
const event = { action: 'deny_removed', subject, scope, permission, ...actor };
|
|
1456
|
-
await this.#write(event, async () => (await this.driver()).removeDeny(subject, permission, scope));
|
|
2542
|
+
const event = { action: 'deny_removed', subject, scope, permission, ...actor, ...this.#transactional(options) };
|
|
2543
|
+
await this.#write(event, async () => (await this.driver()).removeDeny(subject, permission, scope, options));
|
|
1457
2544
|
await this.#notify(event);
|
|
1458
2545
|
}
|
|
1459
2546
|
/**
|
|
@@ -1614,29 +2701,28 @@ export class AuthorizationManager {
|
|
|
1614
2701
|
return { maxScopes: Math.min(options.maxScopes ?? configured, configured), maxNodes };
|
|
1615
2702
|
}
|
|
1616
2703
|
/**
|
|
1617
|
-
* El subárbol del consumidor para
|
|
1618
|
-
*
|
|
1619
|
-
* `
|
|
1620
|
-
*
|
|
2704
|
+
* El subárbol del consumidor para la pieza que lo camina por SEGURIDAD y
|
|
2705
|
+
* no por enumeración —la regla de nivel de `defineScopedRole`/
|
|
2706
|
+
* `updateScopedRole`—, DEGRADANDO en vez de tumbar la operación (3F · S2,
|
|
2707
|
+
* auditor N3).
|
|
1621
2708
|
*
|
|
1622
2709
|
* Regla: *declarar `scopes.descendantsOf` nunca puede dejarte peor que no
|
|
1623
2710
|
* declararlo*. Hasta 3E, una org con más units que `maxDescendants` —la
|
|
1624
|
-
* cota sale del config y una llamada no la puede subir (F8)— dejaba
|
|
1625
|
-
* `detached` entero en 503 **sin purgar ni los roles ni los hechos** y al
|
|
2711
|
+
* cota sale del config y una llamada no la puede subir (F8)— dejaba al
|
|
1626
2712
|
* tenant grande sin poder delegar hacia abajo: la configuración que el
|
|
1627
2713
|
* invariante 18 recomienda EMPEORABA el caso grande. Ahora, si el subárbol
|
|
1628
2714
|
* no se puede enumerar (más nodos que la cota, o un `descendantsOf` que
|
|
1629
|
-
* falla), se sigue con `enumerated: false
|
|
1630
|
-
*
|
|
1631
|
-
*
|
|
1632
|
-
* tipos de un ancestro). Ninguna de las dos degradaciones concede nada:
|
|
1633
|
-
* purgar menos deja roles que ya no son visibles en ninguna parte, y la
|
|
1634
|
-
* regla mínima es la que corre en todo consumidor con el stub publicado.
|
|
2715
|
+
* falla), se sigue con `enumerated: false` y la regla de nivel cae a la
|
|
2716
|
+
* MÍNIMA (rechazar solo los tipos de un ancestro), que es la que corre en
|
|
2717
|
+
* todo consumidor con el stub publicado y no concede nada.
|
|
1635
2718
|
* Pero no es gratis y está escrito donde toca (3G · X1, auditor P4): es un
|
|
1636
2719
|
* control que el propio vigilado puede apagar creando hijos. Lo que NO
|
|
1637
|
-
* degrada
|
|
1638
|
-
*
|
|
1639
|
-
*
|
|
2720
|
+
* degrada es ensombrecer, que sigue pidiendo rango aunque la regla de
|
|
2721
|
+
* nivel haya caído a la mínima (3G · W3).
|
|
2722
|
+
*
|
|
2723
|
+
* (Desde 3b-0 · Z1 `scopes.detached` ya no llama aquí: purga los hechos
|
|
2724
|
+
* del scope EXACTO y no toca el catálogo, así que no tiene subárbol que
|
|
2725
|
+
* enumerar ni degradación que declarar.)
|
|
1640
2726
|
*
|
|
1641
2727
|
* Lo que NO se degrada es un error de CONFIG (`maxDescendants` fuera de
|
|
1642
2728
|
* rango): eso es un bug del consumidor y sigue siendo 500.
|
|
@@ -1644,14 +2730,14 @@ export class AuthorizationManager {
|
|
|
1644
2730
|
async #descendantsOrDegrade(scope, operation) {
|
|
1645
2731
|
const descendantsOf = this.#config.scopes?.descendantsOf;
|
|
1646
2732
|
if (!descendantsOf)
|
|
1647
|
-
return { below: [],
|
|
2733
|
+
return { below: [], enumerated: false };
|
|
1648
2734
|
const { maxNodes } = this.#scopeBounds(operation, {});
|
|
1649
2735
|
try {
|
|
1650
|
-
return { below: await this.#descendants(descendantsOf, scope, maxNodes),
|
|
2736
|
+
return { below: await this.#descendants(descendantsOf, scope, maxNodes), enumerated: true };
|
|
1651
2737
|
}
|
|
1652
2738
|
catch (error) {
|
|
1653
2739
|
if (error instanceof TooManyScopesError || error instanceof ScopeResolverError) {
|
|
1654
|
-
return { below: [],
|
|
2740
|
+
return { below: [], enumerated: false };
|
|
1655
2741
|
}
|
|
1656
2742
|
throw error;
|
|
1657
2743
|
}
|
|
@@ -1705,6 +2791,17 @@ export class AuthorizationManager {
|
|
|
1705
2791
|
* `indeterminate: true`, para que quien audita registre "puede haber
|
|
1706
2792
|
* ocurrido" en vez de nada. Cualquier otro fallo (422, conexión rechazada)
|
|
1707
2793
|
* significa que la escritura no ocurrió y se propaga sin evento.
|
|
2794
|
+
*
|
|
2795
|
+
* **Dentro de la transacción del llamante (L-3) el deadline sigue siendo
|
|
2796
|
+
* `indeterminate: true`**, y el evento lleva además `transactional: true`.
|
|
2797
|
+
* El auditor del panel `{trx}` (🟡 12) objetó que ahí «el rollback la
|
|
2798
|
+
* determina»; pero lo que determina el rollback lo determina el LLAMANTE
|
|
2799
|
+
* y el paquete no lo ve: en el instante del 503 la sentencia puede haber
|
|
2800
|
+
* aterrizado en la transacción (SQLite no cancela; en PG la transacción
|
|
2801
|
+
* queda abortada y ya no puede confirmar; en MySQL la sentencia se mata y
|
|
2802
|
+
* la transacción sigue viva) y el llamante puede confirmar o no. Lo
|
|
2803
|
+
* honesto es el mismo «no lo sé» de siempre más la marca de que la última
|
|
2804
|
+
* palabra es su commit. Medido por motor en `database_transaction.spec.ts`.
|
|
1708
2805
|
*/
|
|
1709
2806
|
async #write(event, fn) {
|
|
1710
2807
|
try {
|