@jantstack/adonis-authz 2.4.0-alpha.1 → 2.4.0-alpha.10
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 +496 -29
- package/build/commands/authz_catalog_diff.d.ts.map +1 -1
- package/build/commands/authz_catalog_diff.js +0 -1
- package/build/commands/authz_catalog_diff.js.map +1 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -1
- package/build/commands/authz_catalog_prune_orphans.js +0 -1
- package/build/commands/authz_catalog_prune_orphans.js.map +1 -1
- package/build/commands/authz_freeze.d.ts.map +1 -1
- package/build/commands/authz_freeze.js +1 -2
- package/build/commands/authz_freeze.js.map +1 -1
- package/build/commands/authz_reconcile.d.ts +15 -6
- package/build/commands/authz_reconcile.d.ts.map +1 -1
- package/build/commands/authz_reconcile.js +143 -20
- package/build/commands/authz_reconcile.js.map +1 -1
- package/build/commands/authz_relations_reconcile.d.ts +72 -1
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
- package/build/commands/authz_relations_reconcile.js +234 -21
- package/build/commands/authz_relations_reconcile.js.map +1 -1
- package/build/commands/authz_scopes_relay.d.ts +50 -0
- package/build/commands/authz_scopes_relay.d.ts.map +1 -1
- package/build/commands/authz_scopes_relay.js +213 -10
- package/build/commands/authz_scopes_relay.js.map +1 -1
- package/build/commands/authz_scopes_requeue.d.ts +80 -0
- package/build/commands/authz_scopes_requeue.d.ts.map +1 -0
- package/build/commands/authz_scopes_requeue.js +230 -0
- package/build/commands/authz_scopes_requeue.js.map +1 -0
- package/build/commands/authz_unfreeze.d.ts +12 -4
- package/build/commands/authz_unfreeze.d.ts.map +1 -1
- package/build/commands/authz_unfreeze.js +74 -9
- package/build/commands/authz_unfreeze.js.map +1 -1
- package/build/commands/main.d.ts +16 -13
- package/build/commands/main.d.ts.map +1 -1
- package/build/commands/main.js +59 -11
- package/build/commands/main.js.map +1 -1
- package/build/commands/openfga_provision.d.ts +49 -3
- package/build/commands/openfga_provision.d.ts.map +1 -1
- package/build/commands/openfga_provision.js +72 -7
- package/build/commands/openfga_provision.js.map +1 -1
- package/build/index.d.ts +15 -5
- package/build/index.d.ts.map +1 -1
- package/build/index.js +16 -3
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts +7 -2
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +20 -3
- package/build/providers/authz_provider.js.map +1 -1
- package/build/src/define_config.d.ts +106 -1
- package/build/src/define_config.d.ts.map +1 -1
- package/build/src/define_config.js +11 -0
- package/build/src/define_config.js.map +1 -1
- package/build/src/drivers/database_driver.d.ts +53 -14
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +104 -32
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +74 -9
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/database_relations_driver.js +391 -93
- package/build/src/drivers/database_relations_driver.js.map +1 -1
- package/build/src/drivers/openfga_driver.d.ts +71 -10
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +195 -16
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_facts.d.ts +15 -0
- package/build/src/drivers/openfga_facts.d.ts.map +1 -1
- package/build/src/drivers/openfga_facts.js +25 -2
- package/build/src/drivers/openfga_facts.js.map +1 -1
- package/build/src/drivers/openfga_relations_driver.d.ts +72 -6
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_relations_driver.js +308 -50
- package/build/src/drivers/openfga_relations_driver.js.map +1 -1
- package/build/src/errors.d.ts +175 -4
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +219 -3
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +100 -20
- package/build/src/freeze.d.ts.map +1 -1
- package/build/src/freeze.js +159 -25
- package/build/src/freeze.js.map +1 -1
- package/build/src/manager.d.ts +239 -7
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +1192 -148
- package/build/src/manager.js.map +1 -1
- package/build/src/models/authz_assignment.d.ts +2 -2
- package/build/src/models/authz_assignment.d.ts.map +1 -1
- package/build/src/models/authz_deny.d.ts +2 -2
- package/build/src/models/authz_deny.d.ts.map +1 -1
- package/build/src/models/authz_permission.d.ts +2 -2
- package/build/src/models/authz_permission.d.ts.map +1 -1
- package/build/src/models/authz_role.d.ts +2 -2
- package/build/src/models/authz_role.d.ts.map +1 -1
- package/build/src/models/authz_role_permission.d.ts +2 -2
- package/build/src/models/authz_role_permission.d.ts.map +1 -1
- package/build/src/relation_partition_trigger.d.ts.map +1 -1
- package/build/src/relation_partition_trigger.js +11 -3
- package/build/src/relation_partition_trigger.js.map +1 -1
- package/build/src/relations/define_relations_config.d.ts +51 -0
- package/build/src/relations/define_relations_config.d.ts.map +1 -1
- package/build/src/relations/define_relations_config.js +68 -1
- package/build/src/relations/define_relations_config.js.map +1 -1
- package/build/src/relations/manager.d.ts +106 -6
- package/build/src/relations/manager.d.ts.map +1 -1
- package/build/src/relations/manager.js +368 -30
- package/build/src/relations/manager.js.map +1 -1
- package/build/src/relations/reconcile.d.ts +81 -6
- package/build/src/relations/reconcile.d.ts.map +1 -1
- package/build/src/relations/reconcile.js +233 -77
- package/build/src/relations/reconcile.js.map +1 -1
- package/build/src/scope_outbox.d.ts +54 -0
- package/build/src/scope_outbox.d.ts.map +1 -1
- package/build/src/scope_outbox.js +274 -33
- package/build/src/scope_outbox.js.map +1 -1
- package/build/src/shared/backend_guard.d.ts +28 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -1
- package/build/src/shared/backend_guard.js +52 -0
- package/build/src/shared/backend_guard.js.map +1 -1
- package/build/src/shared/sql_expiry.d.ts +2 -0
- package/build/src/shared/sql_expiry.d.ts.map +1 -1
- package/build/src/shared/sql_expiry.js +4 -0
- package/build/src/shared/sql_expiry.js.map +1 -1
- package/build/src/shared/transaction_guard.d.ts +53 -0
- package/build/src/shared/transaction_guard.d.ts.map +1 -0
- package/build/src/shared/transaction_guard.js +74 -0
- package/build/src/shared/transaction_guard.js.map +1 -0
- package/build/src/sql_descendants.d.ts +8 -0
- package/build/src/sql_descendants.d.ts.map +1 -1
- package/build/src/sql_descendants.js.map +1 -1
- package/build/src/testing/contract.d.ts +55 -4
- package/build/src/testing/contract.d.ts.map +1 -1
- package/build/src/testing/contract.js +244 -9
- package/build/src/testing/contract.js.map +1 -1
- package/build/src/testing/main.d.ts +6 -4
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +3 -2
- package/build/src/testing/main.js.map +1 -1
- package/build/src/testing/migration_contract.d.ts +9 -0
- package/build/src/testing/migration_contract.d.ts.map +1 -1
- package/build/src/testing/migration_contract.js +5 -2
- package/build/src/testing/migration_contract.js.map +1 -1
- package/build/src/testing/relations_contract.d.ts +23 -1
- package/build/src/testing/relations_contract.d.ts.map +1 -1
- package/build/src/testing/relations_contract.js +603 -19
- package/build/src/testing/relations_contract.js.map +1 -1
- package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -1
- package/build/src/testing/relations_reconcile_contract.js +49 -1
- package/build/src/testing/relations_reconcile_contract.js.map +1 -1
- package/build/src/testing/scope_outbox_contract.d.ts +98 -0
- package/build/src/testing/scope_outbox_contract.d.ts.map +1 -0
- package/build/src/testing/scope_outbox_contract.js +677 -0
- package/build/src/testing/scope_outbox_contract.js.map +1 -0
- package/build/src/traits/has_uuid.d.ts +2 -2
- package/build/src/traits/has_uuid.d.ts.map +1 -1
- package/build/src/types.d.ts +708 -38
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js +55 -0
- package/build/src/types.js.map +1 -1
- package/build/stubs/config/authorization.stub +45 -1
- package/build/stubs/migration.stub +20 -10
- package/package.json +1 -1
package/build/src/manager.js
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
1
|
var _a;
|
|
2
2
|
import { Exception } from '@adonisjs/core/exceptions';
|
|
3
3
|
import { v7 as uuidv7 } from 'uuid';
|
|
4
|
+
import { activeRelationsDriverKey } from './define_config.js';
|
|
4
5
|
import { assertCatalogUuid, assertIdentity, assertScope, assertScopeType, assertSubject, assertValidSlug, chainKeysFrom, normalizeRoleQuery, scopeFromKey, scopeKey, scopeSpellings, } from './identity.js';
|
|
5
6
|
import { expiryChanged } from './expiry.js';
|
|
6
7
|
import { randomBytes } from 'node:crypto';
|
|
7
|
-
import { DEFAULT_FREEZE_LEASE_MS,
|
|
8
|
+
import { DEFAULT_FREEZE_LEASE_MS, acquireFreezeOutcome, UNKNOWN_HOLDER, freezeIsLive, freezeKindOf, readFreezeRow, assertNotFrozenRow, releaseFreeze, renewFreeze, } from './freeze.js';
|
|
8
9
|
import { assertKnownScope, isAuthzError, resolveChain, rootOnlyResolver } from './shared/backend_guard.js';
|
|
9
10
|
import { CatalogCache, GLOBAL_OWNER_KEY, invalidateAuthzCatalog, isRoleVisibleWith, readLocalRoles, withAuthzCatalogWrite } from './catalog/catalog_cache.js';
|
|
10
11
|
import { assertAssignableAt } from './catalog/catalog.js';
|
|
11
12
|
import { systemClock } from './clock.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';
|
|
13
|
-
import { APP_SCOPE_TYPE } from './types.js';
|
|
13
|
+
import { ActorRequiredError, AuthorizationBackendError, 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, ScopeDepthLimitError, ScopeRequeueRefusedError, StaleSourceRefusedError, OperatorWindowExpiredError, } from './errors.js';
|
|
14
|
+
import { APP_SCOPE_TYPE, normalizeAuthorizationCapabilities } from './types.js';
|
|
14
15
|
import { memoizeAncestors } from './memoize_ancestors.js';
|
|
15
16
|
import { AUTHZ_TABLES_ORIGIN } from './reconcile.js';
|
|
16
17
|
/** Longitudes de `authz_roles.name`/`description` (el esquema publicado). */
|
|
@@ -62,8 +63,49 @@ export const DEFAULT_RELAY_BATCH = 100;
|
|
|
62
63
|
export const DEFAULT_RELAY_LIMIT = 10_000;
|
|
63
64
|
/** Páginas que `authz:reconcile` pasea para MEDIR la ventana del relay (3b-3a). */
|
|
64
65
|
const RELAY_WINDOW_MAX_PAGES = 1_000;
|
|
66
|
+
/**
|
|
67
|
+
* **Saltos que la sonda de `only` sigue hacia atrás para heredar el ORIGEN de
|
|
68
|
+
* un bloqueo** (2.4.0-alpha.6). Cada salto es una consulta `earlierPending`
|
|
69
|
+
* (0,69 ms) sobre una entrada NO pedida; la cadena es de ids estrictamente
|
|
70
|
+
* decrecientes, así que termina sola — la cota solo acota el coste dentro de
|
|
71
|
+
* una petición inline. Pasada la cota el origen se toma por FALLO (la purga se
|
|
72
|
+
* aplaza a la pasada completa): degrada hacia lo seguro, nunca aplica sin saber.
|
|
73
|
+
*/
|
|
74
|
+
export const RELAY_ORIGIN_MAX_HOPS = 100;
|
|
75
|
+
/**
|
|
76
|
+
* **Cota PROPIA del censo de aparcadas** (alpha.5 · cierre, auditor 🟠 2). Las
|
|
77
|
+
* claves de `dead()` que taponan `attached`/`moved` se leen COMPLETAS en cada
|
|
78
|
+
* pasada, sean cuales sean `limit` y `batchSize` (con `--limit 50` o la receta
|
|
79
|
+
* inline `{ only, limit: 1 }` un censo acotado por `limit` dejaba a la aparcada
|
|
80
|
+
* 101 sin taponar y el `attached` colgaba del padre VIEJO — y la sonda de
|
|
81
|
+
* `only` no la ve porque `earlierPending` excluye lo aparcado). Si hay más
|
|
82
|
+
* aparcadas que esto, la pasada se NIEGA (500) en vez de aplicar con un tapón
|
|
83
|
+
* incompleto: una cola así es un incidente, no un lote.
|
|
84
|
+
*/
|
|
85
|
+
export const PARKED_CENSUS_MAX = 50_000;
|
|
65
86
|
/** Vida por defecto de una vista de `forRequest()` para LEER (F9): un request, no un módulo. */
|
|
66
87
|
export const DEFAULT_VIEW_MAX_AGE_MS = 30_000;
|
|
88
|
+
/**
|
|
89
|
+
* Cada cuánto reintenta `acquire()` una pasada con `waitForLeaseMs` (2.4.0-alpha.8;
|
|
90
|
+
* panel relay 2, cuestión 7). Medido en PG: 0,29 ms por sondeo ocupado, y el
|
|
91
|
+
* sondeo no retiene conexión entre intentos (la transacción del intento fallido
|
|
92
|
+
* se revierte). El default de `waitForLeaseMs` es 0: el `busy` inmediato de
|
|
93
|
+
* siempre.
|
|
94
|
+
*/
|
|
95
|
+
export const RELAY_LEASE_POLL_MS = 25;
|
|
96
|
+
/**
|
|
97
|
+
* Tope de `waitForLeaseMs` (cierre alpha.8, 🟡 1 del auditor): una espera sin tope
|
|
98
|
+
* cuelga la petición de un tenant; por encima es 500 `E_AUTHZ_CONFIG`. Y la espera
|
|
99
|
+
* se cuenta en SONDEOS (`waitForLeaseMs / RELAY_LEASE_POLL_MS`), no solo con el
|
|
100
|
+
* reloj de pared: con `config.clock` fijo el sondeo por reloj no terminaba nunca.
|
|
101
|
+
*/
|
|
102
|
+
export const RELAY_WAIT_LEASE_MAX_MS = 60_000;
|
|
103
|
+
/** Las opciones que `relayScopeChanges` conoce (2.4.0-alpha.8 · X-1: cualquier otra es 500). */
|
|
104
|
+
const RELAY_OPTIONS = ['limit', 'batchSize', 'dryRun', 'only', 'waitForLeaseMs', 'deadDetail'];
|
|
105
|
+
/** Precedencia de los orígenes de un bloqueo por clave (2.4.0-alpha.8): un fallo real pisa todo; una pendiente pisa a un aparcado. */
|
|
106
|
+
const BLOCK_RANK = { parked: 0, pending: 1, failed: 2 };
|
|
107
|
+
/** Las opciones que `requeueScopeChanges` conoce (2.4.0-alpha.8 · X-1: cualquier otra es 500). */
|
|
108
|
+
const REQUEUE_OPTIONS = ['force', 'apply', 'waitForLeaseMs'];
|
|
67
109
|
/** Reloj monótono del proceso: inmune a NTP, snapshots y `Date.now` parcheado. */
|
|
68
110
|
const monotonicNow = () => performance.now();
|
|
69
111
|
/**
|
|
@@ -196,10 +238,96 @@ export class AuthorizationManager {
|
|
|
196
238
|
}
|
|
197
239
|
driver = driver.withClock(clock);
|
|
198
240
|
}
|
|
241
|
+
this.#assertTransactionalWritesRequired(driver);
|
|
199
242
|
this.#assertScopeDriftGuarded(driver);
|
|
200
243
|
this.#driver = driver;
|
|
201
244
|
return driver;
|
|
202
245
|
}
|
|
246
|
+
/**
|
|
247
|
+
* **Lo que el driver ACTIVO declara, de solo lectura** (2.4.0-alpha.4;
|
|
248
|
+
* pregunta 1 de COGNITIV, panel alpha.4). Para que el código de tenant
|
|
249
|
+
* pueda ramificar —`if ((await authorization.capabilities()).transactionalWrites)
|
|
250
|
+
* grant(…, { transaction }) else grant(…)`— sin llamar a `driver()`, que
|
|
251
|
+
* sigue siendo API de PLATAFORMA (el grep del README sigue significando lo
|
|
252
|
+
* que significa).
|
|
253
|
+
*
|
|
254
|
+
* Es `(await this.driver()).capabilities` y NADA más, a propósito:
|
|
255
|
+
* - las capacidades son de la INSTANCIA (`database` decide
|
|
256
|
+
* `transactionalWrites` desde sus opciones), así que hay que resolver;
|
|
257
|
+
* - y se resuelve POR `driver()`, con sus dos puertas
|
|
258
|
+
* (`requireTransactionalWrites` y el gate de deriva del árbol) y su
|
|
259
|
+
* caché. Resolverlo por otro camino «para que la lectura no lance» y
|
|
260
|
+
* guardarlo en `#driver` dejaría `driver()` sirviendo un driver que
|
|
261
|
+
* nunca pasó por las puertas: un bypass permanente del gate de deriva
|
|
262
|
+
* (🔴 1 del auditor). Por eso hereda los 500 de las puertas
|
|
263
|
+
* (`E_AUTHZ_CONFIG`, `E_AUTHZ_SCOPE_DRIFT_UNGUARDED`) y la factory del
|
|
264
|
+
* consumidor: llámalo al arrancar (un hook `booted`/`ready`), no por
|
|
265
|
+
* request en un endpoint de metadatos.
|
|
266
|
+
*
|
|
267
|
+
* Devuelve una copia CONGELADA y NORMALIZADA: todas las claves de
|
|
268
|
+
* `AuthorizationDriverCapabilities` presentes, solo `true` es `true`
|
|
269
|
+
* (la misma lectura que la puerta 1), nunca `undefined`; mutarla no toca
|
|
270
|
+
* el objeto que leen las puertas. La vista de `forRequest()` delega en el
|
|
271
|
+
* padre (por `driver()`).
|
|
272
|
+
*/
|
|
273
|
+
async capabilities() {
|
|
274
|
+
const driver = await this.driver();
|
|
275
|
+
return normalizeAuthorizationCapabilities(driver.capabilities);
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* **Puerta 2 de `{ transaction }`** (L-2, opt-in): con
|
|
279
|
+
* `requireTransactionalWrites: true` el driver activo tiene que declarar
|
|
280
|
+
* `transactionalWrites: true` o el manager falla AL RESOLVERLO — antes de
|
|
281
|
+
* cachearlo, así que toda lectura, toda escritura y `driver()` fallan igual:
|
|
282
|
+
* el despliegue no arranca. Es la forma honesta de «fallar al construirse»:
|
|
283
|
+
* el roadmap lo pedía incondicional y eso haría `openfga` inconstruible en
|
|
284
|
+
* cualquier app que solo lo tenga registrado (y el driver se resuelve
|
|
285
|
+
* perezosamente y por nombre, así que al construir no se sabe si alguien
|
|
286
|
+
* pasará `{ transaction }`). Quien quiera fallar al arrancar, lo pide.
|
|
287
|
+
*/
|
|
288
|
+
#assertTransactionalWritesRequired(driver) {
|
|
289
|
+
if (this.#config.requireTransactionalWrites !== true)
|
|
290
|
+
return;
|
|
291
|
+
if (driver.capabilities?.transactionalWrites === true)
|
|
292
|
+
return;
|
|
293
|
+
throw new AuthorizationConfigError(`config.requireTransactionalWrites está en true y el driver '${this.#config.default}' declara ` +
|
|
294
|
+
`transactionalWrites: ${driver.capabilities ? String(driver.capabilities.transactionalWrites) : 'nada (sin capabilities)'}: ` +
|
|
295
|
+
`no puede inscribir grant/revoke/deny/removeDeny en la transacción del consumidor («los dos o ninguno»), así ` +
|
|
296
|
+
`que el manager no se resuelve. Usa un driver que la declare (database) o quita requireTransactionalWrites.`);
|
|
297
|
+
}
|
|
298
|
+
/**
|
|
299
|
+
* **Puerta 1 de `{ transaction }`** (L-2, siempre activa): una escritura de
|
|
300
|
+
* HECHOS con `{ transaction }` a un driver que no declara
|
|
301
|
+
* `transactionalWrites: true` es 500 `E_AUTHZ_UNSUPPORTED` nombrando driver
|
|
302
|
+
* y operación, ANTES de la barrera, de la identidad y del driver (cero
|
|
303
|
+
* llamadas). Nunca se ignora, nunca un `logger.warn`: aceptarla y no
|
|
304
|
+
* cumplirla sería publicar con nuestra firma la fuga del cruce 4 · S5.
|
|
305
|
+
* `scopes.*` NO pasa por aquí: su `transaction` ENCOLA (encolar ≠ escribir).
|
|
306
|
+
*/
|
|
307
|
+
async #assertTransactionalWrite(options, operation) {
|
|
308
|
+
if (options?.transaction === undefined || options.transaction === null)
|
|
309
|
+
return;
|
|
310
|
+
const driver = await this.driver();
|
|
311
|
+
if (driver.capabilities?.transactionalWrites === true)
|
|
312
|
+
return;
|
|
313
|
+
throw UnsupportedOperationError.transactional(operation, this.#config.default, 'roles');
|
|
314
|
+
}
|
|
315
|
+
/**
|
|
316
|
+
* La marca del evento de una escritura inscrita en la transacción del
|
|
317
|
+
* llamante (L-3): `onWrite` se dispara cuando el DRIVER vuelve, y en ese
|
|
318
|
+
* momento la fila existe solo dentro de esa transacción — es un hecho si y
|
|
319
|
+
* solo si el llamante confirma, cosa que el paquete nunca ve. Quien audita
|
|
320
|
+
* lo necesita para no registrar como firme lo que un rollback deshace.
|
|
321
|
+
*/
|
|
322
|
+
#transactional(options) {
|
|
323
|
+
return options?.transaction === undefined || options.transaction === null ? {} : { transactional: true };
|
|
324
|
+
}
|
|
325
|
+
/** La API de delegación no admite `{ transaction }` (§1.4 del veredicto `{trx}`): 500 antes de tocar nada. */
|
|
326
|
+
#assertNoTransaction(options, operation) {
|
|
327
|
+
if (options?.transaction === undefined || options.transaction === null)
|
|
328
|
+
return;
|
|
329
|
+
throw UnsupportedOperationError.transactionalCatalog(operation);
|
|
330
|
+
}
|
|
203
331
|
/**
|
|
204
332
|
* **El gate de deriva del árbol, en el MANAGER** (3b-2e · E3; cierra el
|
|
205
333
|
* agujero que declaró el 3b-2d).
|
|
@@ -279,12 +407,30 @@ export class AuthorizationManager {
|
|
|
279
407
|
const finalReason = reason ?? 'una operación de plataforma';
|
|
280
408
|
const holder = `${kind}:${process.pid}:${randomBytes(4).toString('hex')}`;
|
|
281
409
|
const nowMs = root.#wallMs();
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
410
|
+
// alpha.10 · S2 (+ cierre del auditor · R2): una ventana de OPERADOR
|
|
411
|
+
// caducada no se pisa en silencio. Una fila con el lease vencido se toma
|
|
412
|
+
// encima (así la flota vuelve sola tras un SIGKILL de una pasada), pero la
|
|
413
|
+
// del operador es EVIDENCIA: pisarla y soltarla después la deja a NULL y
|
|
414
|
+
// borra el único rastro de que las escrituras volvieron a entrar a mitad
|
|
415
|
+
// del cutover. La exclusión vive DENTRO del UPDATE de `acquireFreezeOutcome`
|
|
416
|
+
// (atómica): aquí solo se lee el veredicto y la fila para redactarlo — un
|
|
417
|
+
// «compruebo y luego tomo» con dos lecturas del reloj era un TOCTOU.
|
|
418
|
+
const outcome = await acquireFreezeOutcome({ reason: finalReason, holder, untilMs: leaseMs === null ? null : nowMs + leaseMs, nowMs }, { driver: this.#config.default });
|
|
419
|
+
if (outcome.status === 'expired-operator') {
|
|
420
|
+
const row = outcome.row;
|
|
421
|
+
const when = row.untilMs === null ? '?' : new Date(row.untilMs).toISOString();
|
|
422
|
+
throw new OperatorWindowExpiredError(`freeze (${reason ?? kind}): la ventana del OPERADOR (${row.holder ?? '?'}, ${row.reason}, fence ` +
|
|
423
|
+
`${row.fence}) CADUCÓ el ${when} y nadie la cerró: desde entonces las escrituras volvieron a entrar en ` +
|
|
424
|
+
`toda la flota. No se toma una ventana propia encima —borraría ese rastro al soltarla y la pasada se ` +
|
|
425
|
+
`certificaría como si hubiera corrido dentro del cutover—. Reconócela con authz:unfreeze (dice cuánto ` +
|
|
426
|
+
`tiempo estuvo abierta y limpia la fila) y reabre la ventana con authz:freeze antes de repetir.`);
|
|
427
|
+
}
|
|
428
|
+
if (outcome.status === 'held') {
|
|
429
|
+
const row = outcome.row;
|
|
285
430
|
throw new FreezeHeldError(`freeze: ya hay un freeze VIVO de otro dueño (${row.holder ?? '?'}, fence ${row.fence}, motivo: ${row.reason ?? '?'}). ` +
|
|
286
431
|
`Espera a que termine, o levántalo con authz:unfreeze si su proceso murió sin lease.`);
|
|
287
432
|
}
|
|
433
|
+
const token = outcome.token;
|
|
288
434
|
const held = { token, reason: finalReason, kind, leaseMs, timer: null, lapsed: false };
|
|
289
435
|
if (leaseMs !== null) {
|
|
290
436
|
const interval = Math.max(250, Math.floor(leaseMs / 3));
|
|
@@ -345,6 +491,39 @@ export class AuthorizationManager {
|
|
|
345
491
|
get frozen() {
|
|
346
492
|
return this.#root().#heldFreeze !== null;
|
|
347
493
|
}
|
|
494
|
+
/**
|
|
495
|
+
* La clave (en `relations.drivers`) del driver de RELACIONES activo:
|
|
496
|
+
* `relations.default ?? default` (2.4.0-alpha.10). La lee el guard de la
|
|
497
|
+
* dirección rancia de `runRelationsReconcile`; desde el cierre del auditor
|
|
498
|
+
* (R4) el llamante ya no puede contradecirla con un `activeKey` propio.
|
|
499
|
+
*/
|
|
500
|
+
relationsDriverKey() {
|
|
501
|
+
return activeRelationsDriverKey(this.#root().#config);
|
|
502
|
+
}
|
|
503
|
+
/** Las claves registradas en `relations.drivers` del config de ESTE manager (vacío si no hay relaciones). */
|
|
504
|
+
relationsDriverKeys() {
|
|
505
|
+
return Object.keys(this.#root().#config.relations?.drivers ?? {});
|
|
506
|
+
}
|
|
507
|
+
/**
|
|
508
|
+
* La factory registrada bajo `key` en `relations.drivers` de ESTE manager, o
|
|
509
|
+
* `undefined` (cierre del auditor de alpha.10 · R4). `runRelationsReconcile`
|
|
510
|
+
* resuelve el origen y el destino por aquí —del MISMO config que decide el
|
|
511
|
+
* activo— y juzga la identidad por la factory resuelta, no por la etiqueta:
|
|
512
|
+
* dos claves con la misma factory son el mismo driver (y dos factories DISTINTAS
|
|
513
|
+
* sobre el mismo backend NO: límite declarado, cierre-2 · C1).
|
|
514
|
+
*
|
|
515
|
+
* **API de PLATAFORMA, junto a `driver()`** (cierre-2 · C6): con esta factory
|
|
516
|
+
* cualquier código que tenga el manager —vistas de `forRequest()` incluidas—
|
|
517
|
+
* construye un driver de relaciones NO activo y escribe en él sin `assertWrite`,
|
|
518
|
+
* `requireActor`, freeze ni `onRelationWrite`. No añade capacidad (el config ya
|
|
519
|
+
* da las factories), pero es otra cosa que buscar en una revisión.
|
|
520
|
+
*/
|
|
521
|
+
relationsDriverFactory(key) {
|
|
522
|
+
const drivers = this.#root().#config.relations?.drivers;
|
|
523
|
+
if (drivers === undefined || !Object.prototype.hasOwnProperty.call(drivers, key))
|
|
524
|
+
return undefined;
|
|
525
|
+
return drivers[key];
|
|
526
|
+
}
|
|
348
527
|
/** El freeze VIVO de la fila compartida, o `null`. Lo lee cualquiera; solo el token lo levanta. */
|
|
349
528
|
async freezeStatus() {
|
|
350
529
|
const row = await readFreezeRow({ driver: this.#config.default });
|
|
@@ -352,12 +531,42 @@ export class AuthorizationManager {
|
|
|
352
531
|
return null;
|
|
353
532
|
return {
|
|
354
533
|
reason: row.reason,
|
|
355
|
-
holder: row.holder ??
|
|
534
|
+
holder: row.holder ?? UNKNOWN_HOLDER,
|
|
356
535
|
kind: freezeKindOf(row.holder),
|
|
357
536
|
untilMs: row.untilMs,
|
|
358
537
|
fence: row.fence,
|
|
359
538
|
};
|
|
360
539
|
}
|
|
540
|
+
/**
|
|
541
|
+
* **La ÚLTIMA ventana de la fila, también la CADUCADA** (2.4.0-alpha.10, 🟠 2
|
|
542
|
+
* del sexto informe de COGNITIV). `freezeStatus()` responde solo lo VIVO —y
|
|
543
|
+
* así lo siguen usando la barrera y los comandos—, de modo que una ventana
|
|
544
|
+
* cuyo lease venció era indistinguible de «nunca hubo»: `authz:unfreeze`
|
|
545
|
+
* decía éxito y el último paso del cutover no delataba que las escrituras
|
|
546
|
+
* habían vuelto a entrar a mitad. Se distingue por la fila: `releaseFreeze`
|
|
547
|
+
* pone el motivo a `null` al levantar, así que motivo puesto + `untilMs` en
|
|
548
|
+
* el pasado es una ventana que CADUCÓ sin que nadie la cerrase.
|
|
549
|
+
*
|
|
550
|
+
* `null` = nunca hubo o ya se levantó; `expired: false` = viva (lo mismo que
|
|
551
|
+
* `freezeStatus()`); `expired: true` = caducada. `checkedAtMs` es el instante
|
|
552
|
+
* del reloj del config con el que se decidió.
|
|
553
|
+
*/
|
|
554
|
+
async lastFreezeStatus() {
|
|
555
|
+
const row = await readFreezeRow({ driver: this.#config.default });
|
|
556
|
+
if (row.reason === null)
|
|
557
|
+
return null;
|
|
558
|
+
const nowMs = this.#root().#wallMs();
|
|
559
|
+
return {
|
|
560
|
+
reason: row.reason,
|
|
561
|
+
holder: row.holder ?? UNKNOWN_HOLDER,
|
|
562
|
+
kind: freezeKindOf(row.holder),
|
|
563
|
+
untilMs: row.untilMs,
|
|
564
|
+
fence: row.fence,
|
|
565
|
+
expired: !freezeIsLive(row, nowMs),
|
|
566
|
+
// El instante (reloj del config) con el que se decidió `expired`.
|
|
567
|
+
checkedAtMs: nowMs,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
361
570
|
/**
|
|
362
571
|
* `freeze()` + `finally unfreeze(token)`. El `finally` es la parte que
|
|
363
572
|
* importa: una migración que revienta a la mitad no puede dejar la
|
|
@@ -366,10 +575,22 @@ export class AuthorizationManager {
|
|
|
366
575
|
* A1.1/A1.3): si este manager ya sostiene el freeze, la ventana interior
|
|
367
576
|
* no toma otro ni lo levanta al salir — la exterior sigue en pie.
|
|
368
577
|
*/
|
|
369
|
-
async withFrozenWrites(reason, fn) {
|
|
370
|
-
|
|
578
|
+
async withFrozenWrites(reason, fn, options = {}) {
|
|
579
|
+
// L-1 · J1: una pasada que quiera correr DENTRO de la ventana del
|
|
580
|
+
// operador (el cutover) y publicar su `lapsed` —`authz:relations:reconcile`,
|
|
581
|
+
// que vive fuera de este manager— pide `kind: 'reconcile'` y
|
|
582
|
+
// `operatorAsContext: true`, lo mismo que hace `reconcile` de roles.
|
|
583
|
+
const context = await this.#durableFreezeContext(reason, options.kind ?? 'platform', {
|
|
584
|
+
operatorAsContext: options.operatorAsContext === true,
|
|
585
|
+
});
|
|
371
586
|
try {
|
|
372
|
-
return await fn(
|
|
587
|
+
return await fn({
|
|
588
|
+
fence: context.fence,
|
|
589
|
+
leaseMs: context.leaseMs,
|
|
590
|
+
lapsed: context.lapsed,
|
|
591
|
+
...(context.untilMs === undefined ? {} : { untilMs: context.untilMs }),
|
|
592
|
+
...(context.window === undefined ? {} : { window: context.window }),
|
|
593
|
+
});
|
|
373
594
|
}
|
|
374
595
|
finally {
|
|
375
596
|
await context.release();
|
|
@@ -395,6 +616,7 @@ export class AuthorizationManager {
|
|
|
395
616
|
return {
|
|
396
617
|
fence: outer.token.fence,
|
|
397
618
|
leaseMs: outer.leaseMs,
|
|
619
|
+
window: 'enclosing',
|
|
398
620
|
release: async () => { },
|
|
399
621
|
lapsed: () => root.#tokenLapsed(outer.token, outer.lapsed),
|
|
400
622
|
};
|
|
@@ -403,9 +625,14 @@ export class AuthorizationManager {
|
|
|
403
625
|
const status = await this.freezeStatus();
|
|
404
626
|
if (status !== null && status.kind === 'operator') {
|
|
405
627
|
const token = { fence: status.fence, holder: status.holder };
|
|
628
|
+
// alpha.10 · 3: la caducidad REAL de la ventana adoptada. Hasta alpha.9
|
|
629
|
+
// `leaseMs: null` fijo — el reporte decía «sin caducidad» de una ventana
|
|
630
|
+
// abierta con `--lease-ms` (la certificación sí leía la fila).
|
|
406
631
|
return {
|
|
407
632
|
fence: status.fence,
|
|
408
|
-
leaseMs: null,
|
|
633
|
+
leaseMs: status.untilMs === null ? null : Math.max(0, status.untilMs - root.#wallMs()),
|
|
634
|
+
untilMs: status.untilMs,
|
|
635
|
+
window: 'operator',
|
|
409
636
|
release: async () => { },
|
|
410
637
|
lapsed: () => root.#tokenLapsed(token, false),
|
|
411
638
|
};
|
|
@@ -416,6 +643,7 @@ export class AuthorizationManager {
|
|
|
416
643
|
return {
|
|
417
644
|
fence: token.fence,
|
|
418
645
|
leaseMs: held.leaseMs,
|
|
646
|
+
window: 'own',
|
|
419
647
|
release: async () => {
|
|
420
648
|
try {
|
|
421
649
|
await this.unfreeze(token);
|
|
@@ -454,25 +682,23 @@ export class AuthorizationManager {
|
|
|
454
682
|
* freeze, y con `catalogRevalidate: { everyMs }` la fila del memo ni se
|
|
455
683
|
* lee.
|
|
456
684
|
*/
|
|
457
|
-
async #assertNotFrozen(operation
|
|
685
|
+
async #assertNotFrozen(operation) {
|
|
458
686
|
const root = this.#root();
|
|
459
687
|
const held = root.#heldFreeze;
|
|
460
688
|
if (held) {
|
|
461
689
|
throw new AuthorizationFrozenError(`${operation}: el motor de autorización está congelado (${held.reason}) y no acepta escrituras. ` +
|
|
462
690
|
`Las lecturas siguen funcionando; reintenta esta escritura cuando la operación termine.`);
|
|
463
691
|
}
|
|
464
|
-
//
|
|
465
|
-
// por
|
|
466
|
-
// un pool de 1
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
throw new AuthorizationFrozenError(`${operation}: el motor de autorización está congelado (${row.reason})${lift} y no acepta escrituras. ` +
|
|
475
|
-
`Las lecturas siguen funcionando; reintenta esta escritura cuando la ventana termine.`);
|
|
692
|
+
// La fila se lee SIEMPRE por la conexión del motor (L-1 · 🟠 8). Hasta
|
|
693
|
+
// L-1 se leía por la transacción del consumidor si la escritura llegaba
|
|
694
|
+
// con una —«para no interbloquear un pool de 1»—, y eso era el agujero:
|
|
695
|
+
// la barrera la decidía el snapshot del llamante (medido en SQLite, MySQL
|
|
696
|
+
// y PG). Con pool 1 el precio es un 503 con deadline, no un bypass.
|
|
697
|
+
await assertNotFrozenRow(operation, {
|
|
698
|
+
driver: this.#config.default,
|
|
699
|
+
nowMs: root.#wallMs(),
|
|
700
|
+
timeoutMs: this.#config.freezeTimeoutMs,
|
|
701
|
+
});
|
|
476
702
|
}
|
|
477
703
|
/** Solo tests: fuerza re-resolución del driver. */
|
|
478
704
|
clearCachedDriver() {
|
|
@@ -529,10 +755,11 @@ export class AuthorizationManager {
|
|
|
529
755
|
await this.#assertWithinOrigin(child, options, 'scopes.attached', 'if-known');
|
|
530
756
|
const outbox = this.#outbox();
|
|
531
757
|
if (outbox) {
|
|
532
|
-
await outbox.enqueue({ op: 'attached', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
|
|
533
|
-
return;
|
|
758
|
+
const receipt = await outbox.enqueue({ op: 'attached', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
|
|
759
|
+
return _a.#receiptOf([receipt]);
|
|
534
760
|
}
|
|
535
761
|
await (await this.driver()).onScopeAttached?.(child, parent);
|
|
762
|
+
return { queued: [] };
|
|
536
763
|
},
|
|
537
764
|
/**
|
|
538
765
|
* `moved` NO vuelve a juzgar el catálogo, y no tiene por qué (3b-1 · D3,
|
|
@@ -554,10 +781,11 @@ export class AuthorizationManager {
|
|
|
554
781
|
await this.#assertWithinOrigin(child, options, 'scopes.moved', 'required');
|
|
555
782
|
const outbox = this.#outbox();
|
|
556
783
|
if (outbox) {
|
|
557
|
-
await outbox.enqueue({ op: 'moved', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
|
|
558
|
-
return;
|
|
784
|
+
const receipt = await outbox.enqueue({ op: 'moved', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
|
|
785
|
+
return _a.#receiptOf([receipt]);
|
|
559
786
|
}
|
|
560
787
|
await (await this.driver()).onScopeMoved?.(child, newParent);
|
|
788
|
+
return { queued: [] };
|
|
561
789
|
},
|
|
562
790
|
/**
|
|
563
791
|
* Hechos primero (el driver demuestra cero o lanza), arista después
|
|
@@ -611,10 +839,12 @@ export class AuthorizationManager {
|
|
|
611
839
|
// La identidad se resuelve AQUÍ, con la fila del consumidor todavía
|
|
612
840
|
// viva si la hay: al relevar el cambio ya no resolvería. Y no se
|
|
613
841
|
// audita `scope_purged` todavía, porque todavía no ha pasado nada.
|
|
842
|
+
// El recibo es PLURAL por esto mismo: una fila por ortografía.
|
|
843
|
+
const receipts = [];
|
|
614
844
|
for (const target of targets) {
|
|
615
|
-
await outbox.enqueue({ op: 'detached', child: target }, { transaction: options?.transaction, ...actor });
|
|
845
|
+
receipts.push(await outbox.enqueue({ op: 'detached', child: target }, { transaction: options?.transaction, ...actor }));
|
|
616
846
|
}
|
|
617
|
-
return;
|
|
847
|
+
return _a.#receiptOf(receipts);
|
|
618
848
|
}
|
|
619
849
|
const driver = await this.driver();
|
|
620
850
|
for (const purged of targets) {
|
|
@@ -623,8 +853,23 @@ export class AuthorizationManager {
|
|
|
623
853
|
await driver.onScopeDetached?.(purged);
|
|
624
854
|
await this.#notify(event);
|
|
625
855
|
}
|
|
856
|
+
return { queued: [] };
|
|
626
857
|
},
|
|
627
858
|
};
|
|
859
|
+
/**
|
|
860
|
+
* El recibo del encolado (alpha.5 · R1): los ids que la outbox devolvió,
|
|
861
|
+
* en orden. Una outbox que devuelve `void` (terceros anteriores a alpha.5)
|
|
862
|
+
* no deja id, y el recibo sale vacío: nunca se inventa uno.
|
|
863
|
+
*/
|
|
864
|
+
static #receiptOf(receipts) {
|
|
865
|
+
const queued = [];
|
|
866
|
+
for (const receipt of receipts) {
|
|
867
|
+
if (receipt && typeof receipt === 'object' && (typeof receipt.id === 'number' || typeof receipt.id === 'string')) {
|
|
868
|
+
queued.push(receipt.id);
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
return { queued };
|
|
872
|
+
}
|
|
628
873
|
/**
|
|
629
874
|
* **Drena la outbox del árbol y aplica los cambios al driver** (3b-2d).
|
|
630
875
|
* Es lo que hay detrás de `node ace authz:scopes:relay`.
|
|
@@ -668,8 +913,29 @@ export class AuthorizationManager {
|
|
|
668
913
|
* **fail-open temporal** —el tenant antiguo conserva acceso tras un
|
|
669
914
|
* `moved`, los denies heredados no aplican tras un `attached`—. No hay
|
|
670
915
|
* 2PC; es el precio de tener el árbol en dos sitios.
|
|
916
|
+
*
|
|
917
|
+
* **`only` (2.4.0-alpha.5 · R1)**: relevar SOLO estas entradas —los ids del
|
|
918
|
+
* recibo de `scopes.*` que ESTE proceso acaba de recibir, jamás del cuerpo
|
|
919
|
+
* de una petición— sin leer la cola de los demás (medido: 4 507 ms → 19 ms
|
|
920
|
+
* con 500 entradas ajenas por delante). El orden por clave NO se relaja:
|
|
921
|
+
* antes de aplicar cada entrada pedida se sondea la cola ENTERA
|
|
922
|
+
* (`earlierPending`, o paginando `pending()` si la outbox no lo tiene) y,
|
|
923
|
+
* si hay una pendiente ANTERIOR que comparte clave de scope con ella, sale
|
|
924
|
+
* `deferred` con `depends-on:<id>` — nunca aplicada fuera de orden (el 🔴 1
|
|
925
|
+
* del panel relay, medido: un `attached(child, unit)` aplicado antes que su
|
|
926
|
+
* `moved(unit → orgB)` dejaba al editor de la org VIEJA autorizando sobre el
|
|
927
|
+
* hijo). `remaining` y `dead` siguen siendo GLOBALES. `only: []` es 500
|
|
928
|
+
* (un `filter` vacío del consumidor no puede acabar en drenaje ni en un
|
|
929
|
+
* reporte vacío en verde), y una outbox que no honre el filtro también.
|
|
930
|
+
*
|
|
931
|
+
* **Lo aparcado tapona por clave (alpha.5 · R2a)**: las claves de `dead()`
|
|
932
|
+
* siembran el bloqueo al inicio de la pasada para lo que CONCEDE o recuelga
|
|
933
|
+
* (`attached`/`moved`, `deferred` con `parked:<id>`) y dejan pasar lo que
|
|
934
|
+
* RETIRA (`detached`: una purga nunca queda bloqueada, invariante 11). Y un
|
|
935
|
+
* 503 del backend NO consume intento (`markFailed(..., { transient })`).
|
|
671
936
|
*/
|
|
672
937
|
async relayScopeChanges(options = {}) {
|
|
938
|
+
_a.#assertKnownOptions('relayScopeChanges', options, RELAY_OPTIONS);
|
|
673
939
|
// El relay ESCRIBE el árbol en el driver: durante una migración se aplaza
|
|
674
940
|
// como cualquier otra escritura (lo que quede en la cola sigue ahí).
|
|
675
941
|
await this.#assertNotFrozen('authz:scopes:relay');
|
|
@@ -681,138 +947,708 @@ export class AuthorizationManager {
|
|
|
681
947
|
const limit = _a.#positive(options.limit, DEFAULT_RELAY_LIMIT, 'limit');
|
|
682
948
|
const batchSize = _a.#positive(options.batchSize, DEFAULT_RELAY_BATCH, 'batchSize');
|
|
683
949
|
const dryRun = options.dryRun === true;
|
|
684
|
-
|
|
685
|
-
|
|
950
|
+
const waitForLeaseMs = _a.#waitMs(options.waitForLeaseMs);
|
|
951
|
+
if (options.deadDetail !== undefined && options.deadDetail !== 'full') {
|
|
952
|
+
throw new AuthorizationConfigError(`authz:scopes:relay: deadDetail solo admite 'full' (llegó ${JSON.stringify(options.deadDetail)}); sin él, bajo only, dead[] va sin error.`);
|
|
953
|
+
}
|
|
954
|
+
const inputs = await _a.#relayInputs(outbox, options.only, batchSize, options.deadDetail);
|
|
955
|
+
const { only, filter, dead, deadCount, deadEntries, unseenOf } = inputs;
|
|
686
956
|
if (dryRun) {
|
|
687
|
-
const batch = await outbox.pending(limit);
|
|
957
|
+
const batch = await outbox.pending(limit, undefined, filter);
|
|
958
|
+
_a.#assertOnlyHonoured(batch, only);
|
|
688
959
|
const extra = batch.length >= limit ? true : false;
|
|
960
|
+
const seen = new Set([...batch.map((b) => String(b.id)), ...deadEntries.map((d) => String(d.id))]);
|
|
961
|
+
const unseen = await unseenOf(seen);
|
|
962
|
+
// La edad es GLOBAL: sin filtro la cabeza es `batch[0]`; con `only` hay que mirar la cola entera.
|
|
963
|
+
const queue = filter === undefined ? this.#queueOf(batch[0]) : (await this.#queueStatus(outbox)).queue;
|
|
689
964
|
return {
|
|
690
965
|
applied: [],
|
|
691
966
|
failed: null,
|
|
692
967
|
failures: [],
|
|
693
968
|
deferred: [],
|
|
694
969
|
dead,
|
|
970
|
+
deadCount,
|
|
971
|
+
queue,
|
|
695
972
|
busy: false,
|
|
696
973
|
remaining: batch.length > 0 || extra,
|
|
697
974
|
dryRun: true,
|
|
698
975
|
wouldApply: batch.map((item) => ({ id: item.id, change: item.change, attempts: item.attempts })),
|
|
976
|
+
entries: deadEntries,
|
|
977
|
+
...unseen,
|
|
699
978
|
};
|
|
700
979
|
}
|
|
701
980
|
// El lease del escritor ÚNICO. Una outbox que no sabe darlo se comporta
|
|
702
981
|
// como hasta ahora (y el README dice que entonces el relay tiene que
|
|
703
|
-
// correr de uno en uno).
|
|
704
|
-
const lease =
|
|
705
|
-
if (outbox.acquire && lease === null)
|
|
982
|
+
// correr de uno en uno). Con `waitForLeaseMs` se reintenta hasta el tope.
|
|
983
|
+
const lease = await this.#acquireLease(outbox, waitForLeaseMs);
|
|
984
|
+
if (outbox.acquire && lease === null)
|
|
985
|
+
return this.#busyReport(outbox, inputs);
|
|
986
|
+
try {
|
|
987
|
+
return await this.#drainScopeChanges(outbox, { limit, batchSize, ...inputs });
|
|
988
|
+
}
|
|
989
|
+
finally {
|
|
990
|
+
await lease?.release();
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
/**
|
|
994
|
+
* Lo que una pasada necesita saber de la cola antes de tomar el lease
|
|
995
|
+
* (`only` normalizado, el filtro para el puerto, lo APARCADO —siempre
|
|
996
|
+
* global— y sus entradas para el reporte). Compartido por `relayScopeChanges`
|
|
997
|
+
* y por `requeueScopeChanges({ apply })` (2.4.0-alpha.8), que releva bajo
|
|
998
|
+
* el MISMO lease y necesita releer lo aparcado DESPUÉS de reencolar.
|
|
999
|
+
*/
|
|
1000
|
+
static async #relayInputs(outbox, onlyIds, batchSize, deadDetail) {
|
|
1001
|
+
const only = _a.#onlyOf(onlyIds);
|
|
1002
|
+
// Al puerto van los ids TAL CUAL los dio el llamante (el recibo); el
|
|
1003
|
+
// conjunto de cadenas es solo para comparar sin depender del tipo.
|
|
1004
|
+
const filter = only === undefined ? undefined : { ids: [...onlyIds] };
|
|
1005
|
+
/** Lo aparcado por la outbox, si sabe aparcar: se reporta SIEMPRE, y GLOBAL. */
|
|
1006
|
+
const read = await _a.#deadLetters(outbox, batchSize);
|
|
1007
|
+
// Bajo `only` (2.4.0-alpha.8, coordinador): la lista sigue siendo global
|
|
1008
|
+
// —ids, `change`, `attempts`— pero SIN `error` salvo `deadDetail: 'full'`:
|
|
1009
|
+
// la causa es texto del backend sobre el árbol de otro tenant, y la receta
|
|
1010
|
+
// inline corre dentro de una petición. Lo pedido (`deadEntries`) sí la
|
|
1011
|
+
// lleva: es lo suyo.
|
|
1012
|
+
const dead = only === undefined || deadDetail === 'full' ? read : read.map(({ error: _error, ...safe }) => safe);
|
|
1013
|
+
// El censo COMPLETO (cierre alpha.5, 🟠 2): lo que `#parkedKeys` siembra y,
|
|
1014
|
+
// desde 2.4.0-alpha.8, lo que cuenta `deadCount` (`dead.length` miente al
|
|
1015
|
+
// llegar a `batchSize`). Una sola lectura de más, y solo si no cabía.
|
|
1016
|
+
const census = dead.length < batchSize ? dead : await _a.#deadLetters(outbox, PARKED_CENSUS_MAX);
|
|
1017
|
+
const deadCount = census.length;
|
|
1018
|
+
/**
|
|
1019
|
+
* Lo aparcado que se pidió (con `only`) o todo lo aparcado, para `entries`.
|
|
1020
|
+
* Cierre-2 · C2 (gemelo): con `only` sale del CENSO completo si la lectura se
|
|
1021
|
+
* cortó en `batchSize` — si no, un id pedido aparcado más allá del primer lote
|
|
1022
|
+
* era `missing` («no estaba ni pendiente ni aparcado») estando aparcado.
|
|
1023
|
+
*/
|
|
1024
|
+
const deadSource = only !== undefined && read.length >= batchSize ? census : read;
|
|
1025
|
+
const deadEntries = deadSource
|
|
1026
|
+
.filter((item) => only === undefined || only.has(String(item.id)))
|
|
1027
|
+
.map((item) => ({
|
|
1028
|
+
id: item.id,
|
|
1029
|
+
change: item.change,
|
|
1030
|
+
status: 'dead',
|
|
1031
|
+
attempts: item.attempts,
|
|
1032
|
+
...(item.error === undefined ? {} : { reason: item.error }),
|
|
1033
|
+
}));
|
|
1034
|
+
/**
|
|
1035
|
+
* **Qué fue de los ids pedidos que la pasada NO vio** (cierre-2 de alpha.10 ·
|
|
1036
|
+
* C2). Hasta el cierre-2 todos eran `missing`; pero un id fuera del lote porque
|
|
1037
|
+
* la pasada alcanzó `limit` sigue PENDIENTE. Se pregunta a la cola —filtrada por
|
|
1038
|
+
* esos ids, sin límite propio, UNA lectura y solo si hay alguno sin ver—: lo que
|
|
1039
|
+
* sigue pendiente es `notEvaluated`; el resto, `missing` (no está en la cola: lo
|
|
1040
|
+
* aparcado ya está en `seen`).
|
|
1041
|
+
*/
|
|
1042
|
+
const unseenOf = async (seen) => {
|
|
1043
|
+
if (only === undefined)
|
|
1044
|
+
return { missing: [], notEvaluated: [] };
|
|
1045
|
+
const unseen = [...only.values()].filter((id) => !seen.has(id)).map((id) => _a.#rawId(onlyIds, id));
|
|
1046
|
+
if (unseen.length === 0)
|
|
1047
|
+
return { missing: [], notEvaluated: [] };
|
|
1048
|
+
const stillPending = await outbox.pending(unseen.length, undefined, { ids: unseen });
|
|
1049
|
+
_a.#assertOnlyHonoured(stillPending, new Set(unseen.map(String)));
|
|
1050
|
+
const pendingIds = new Set(stillPending.map((item) => String(item.id)));
|
|
706
1051
|
return {
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
failures: [],
|
|
710
|
-
deferred: [],
|
|
711
|
-
dead,
|
|
712
|
-
busy: true,
|
|
713
|
-
remaining: (await outbox.pending(1)).length > 0,
|
|
714
|
-
dryRun: false,
|
|
715
|
-
wouldApply: [],
|
|
1052
|
+
missing: unseen.filter((id) => !pendingIds.has(String(id))),
|
|
1053
|
+
notEvaluated: unseen.filter((id) => pendingIds.has(String(id))),
|
|
716
1054
|
};
|
|
1055
|
+
};
|
|
1056
|
+
return { only, filter, dead, census, deadCount, deadEntries, unseenOf };
|
|
1057
|
+
}
|
|
1058
|
+
/** El reporte de una pasada que NO entró: otra tiene el lease. Nada aplicado, nada tocado. */
|
|
1059
|
+
async #busyReport(outbox, inputs) {
|
|
1060
|
+
const { remaining, queue } = await this.#queueStatus(outbox);
|
|
1061
|
+
return {
|
|
1062
|
+
applied: [],
|
|
1063
|
+
failed: null,
|
|
1064
|
+
failures: [],
|
|
1065
|
+
deferred: [],
|
|
1066
|
+
dead: inputs.dead,
|
|
1067
|
+
deadCount: inputs.deadCount,
|
|
1068
|
+
queue,
|
|
1069
|
+
busy: true,
|
|
1070
|
+
remaining,
|
|
1071
|
+
dryRun: false,
|
|
1072
|
+
wouldApply: [],
|
|
1073
|
+
entries: inputs.deadEntries,
|
|
1074
|
+
missing: [],
|
|
1075
|
+
notEvaluated: [],
|
|
1076
|
+
};
|
|
1077
|
+
}
|
|
1078
|
+
/**
|
|
1079
|
+
* **La cabeza GLOBAL de la cola y su edad** (2.4.0-alpha.8; panel relay 2,
|
|
1080
|
+
* cuestión 5). Es el `pending(1)` que la pasada ya hacía al final para
|
|
1081
|
+
* `remaining` —cero consultas nuevas—, leído SIN filtro aunque haya `only`:
|
|
1082
|
+
* la edad, como `remaining` y `dead`, es de la cola entera. La edad la mide
|
|
1083
|
+
* el reloj INYECTABLE del manager (`#wallMs`), nunca `Date.now()`.
|
|
1084
|
+
*/
|
|
1085
|
+
async #queueStatus(outbox) {
|
|
1086
|
+
const head = await outbox.pending(1);
|
|
1087
|
+
return { remaining: head.length > 0, queue: this.#queueOf(head[0]) };
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* La edad de una cabeza: `Math.max(0, ahora − createdAt)` —nunca negativa:
|
|
1091
|
+
* el sello lo puso el reloj de OTRA réplica, con su skew—; sin cabeza o sin
|
|
1092
|
+
* `createdAt` (una outbox de terceros que no lo trae) ⇒ `null` en los dos
|
|
1093
|
+
* campos, jamás `0` ni «ahora».
|
|
1094
|
+
*/
|
|
1095
|
+
#queueOf(head) {
|
|
1096
|
+
const createdAt = head?.createdAt;
|
|
1097
|
+
if (!(createdAt instanceof Date) || Number.isNaN(createdAt.getTime()))
|
|
1098
|
+
return { oldestPendingAt: null, pendingAgeMs: null };
|
|
1099
|
+
return { oldestPendingAt: createdAt, pendingAgeMs: Math.max(0, this.#root().#wallMs() - createdAt.getTime()) };
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* **El lease del escritor único, con espera acotada** (2.4.0-alpha.8;
|
|
1103
|
+
* `waitForLeaseMs`, panel relay 2, cuestión 7). Con `0` (el default) es
|
|
1104
|
+
* UN intento y `null` = `busy`, como siempre — el caso «dos pasadas a la vez
|
|
1105
|
+
* ⇒ busy» no cambia. Con un tope, reintenta `acquire()` cada
|
|
1106
|
+
* `RELAY_LEASE_POLL_MS` hasta vencerlo: `busy` al vencer, jamás un cuelgue.
|
|
1107
|
+
* El reloj es el inyectable del manager (nunca `Date.now()` en `src/`).
|
|
1108
|
+
* Exige pool ≥ 2 (README): con pool 1 el que espera puede estar reteniendo
|
|
1109
|
+
* la única conexión que necesita el que tiene el lease.
|
|
1110
|
+
*/
|
|
1111
|
+
async #acquireLease(outbox, waitForLeaseMs) {
|
|
1112
|
+
if (!outbox.acquire)
|
|
1113
|
+
return null;
|
|
1114
|
+
let lease = await outbox.acquire();
|
|
1115
|
+
if (lease !== null || waitForLeaseMs <= 0)
|
|
1116
|
+
return lease;
|
|
1117
|
+
const deadline = this.#root().#wallMs() + waitForLeaseMs;
|
|
1118
|
+
// Dos topes a la vez: el reloj de pared (inyectable) y el NÚMERO de sondeos —
|
|
1119
|
+
// con un reloj fijo el primero no vence jamás (medido por el auditor de alpha.8).
|
|
1120
|
+
const maxPolls = Math.ceil(waitForLeaseMs / RELAY_LEASE_POLL_MS);
|
|
1121
|
+
for (let polls = 0; lease === null && polls < maxPolls && this.#root().#wallMs() < deadline; polls++) {
|
|
1122
|
+
await new Promise((resolve) => setTimeout(resolve, RELAY_LEASE_POLL_MS));
|
|
1123
|
+
lease = await outbox.acquire();
|
|
1124
|
+
}
|
|
1125
|
+
return lease;
|
|
1126
|
+
}
|
|
1127
|
+
/** `waitForLeaseMs` normalizado: entero >= 0 (500 si no); ausente = 0. */
|
|
1128
|
+
static #waitMs(value) {
|
|
1129
|
+
if (value === undefined)
|
|
1130
|
+
return 0;
|
|
1131
|
+
if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
|
|
1132
|
+
throw new AuthorizationConfigError(`authz:scopes:relay: waitForLeaseMs debe ser un entero >= 0 en milisegundos (llegó ${String(value)})`);
|
|
717
1133
|
}
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1134
|
+
if (value > RELAY_WAIT_LEASE_MAX_MS) {
|
|
1135
|
+
throw new AuthorizationConfigError(`authz:scopes:relay: waitForLeaseMs = ${value} supera el tope RELAY_WAIT_LEASE_MAX_MS (${RELAY_WAIT_LEASE_MAX_MS} ms): ` +
|
|
1136
|
+
`una espera sin tope cuelga la petición del tenant; acorta la pasada (only/limit) en vez de esperar más.`);
|
|
1137
|
+
}
|
|
1138
|
+
return value;
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* **La pasada, ya con el lease tomado**: lo que hasta 2.4.0-alpha.7 era el
|
|
1142
|
+
* cuerpo de `relayScopeChanges`. Compartida con `requeueScopeChanges({
|
|
1143
|
+
* apply })`, que la llama bajo SU lease con `only` = los ids reencolados
|
|
1144
|
+
* (auditor 🟠 5: tomar el lease dos veces era `busy` en verde).
|
|
1145
|
+
*/
|
|
1146
|
+
async #drainScopeChanges(outbox, input) {
|
|
1147
|
+
const { limit, batchSize, only, filter, dead, census, deadCount, deadEntries, unseenOf } = input;
|
|
1148
|
+
const driver = await this.driver();
|
|
1149
|
+
const applied = [];
|
|
1150
|
+
const deferred = [];
|
|
1151
|
+
const failures = [];
|
|
1152
|
+
const entries = [];
|
|
1153
|
+
/**
|
|
1154
|
+
* Claves de scope contaminadas ⇒ el id que las contaminó y el ORIGEN del
|
|
1155
|
+
* bloqueo (alpha.5 · cierre, auditor 🟠 1). `parked: true` = la causa
|
|
1156
|
+
* raíz (transitiva) es una entrada APARCADA: tapona solo lo que concede
|
|
1157
|
+
* o recuelga (`attached`/`moved`), NUNCA un `detached` — si no, un
|
|
1158
|
+
* `attached` aplazado por aparcado congelaba la purga del nodo que el
|
|
1159
|
+
* tenant ya borró, para siempre (medido: el scope conservaba sus hechos
|
|
1160
|
+
* y su arista, y seguía concediendo; invariante 11). `parked: false` =
|
|
1161
|
+
* un FALLO real de esta pasada (o una pendiente anterior no pedida):
|
|
1162
|
+
* contamina todo, `detached` incluido — el orden entre un `attached`
|
|
1163
|
+
* que falló y la purga de ese mismo nodo sigue importando (3b-2h).
|
|
1164
|
+
*/
|
|
1165
|
+
const blocked = new Map();
|
|
1166
|
+
/** Origen ya sondeado de entradas NO pedidas (alpha.6): `parked` = retenida solo por un aparcado. Se olvida en cuanto la pasada registra un bloqueo que no es de aparcado (podría contaminar una clave ya sondeada). */
|
|
1167
|
+
const origins = new Map();
|
|
1168
|
+
const block = (key, id, origin) => {
|
|
1169
|
+
const current = blocked.get(key);
|
|
1170
|
+
// Precedencia `failed > pending > parked` (2.4.0-alpha.8; desde el
|
|
1171
|
+
// cierre de alpha.5: un fallo real pisa a un aparcado, nunca al revés).
|
|
1172
|
+
if (current === undefined || BLOCK_RANK[origin] > BLOCK_RANK[current.origin])
|
|
1173
|
+
blocked.set(key, { id, origin });
|
|
1174
|
+
if (origin !== 'parked')
|
|
1175
|
+
origins.clear();
|
|
1176
|
+
};
|
|
1177
|
+
/**
|
|
1178
|
+
* **Las claves de lo APARCADO** (alpha.5 · R2a): taponan lo que concede
|
|
1179
|
+
* o recuelga y dejan pasar lo que retira. El censo es COMPLETO —no
|
|
1180
|
+
* depende de `limit` ni de `batchSize` (cierre, 🟠 2)— con su propia
|
|
1181
|
+
* cota; el reporte sigue enseñando las primeras `batchSize`.
|
|
1182
|
+
*/
|
|
1183
|
+
const parkedKeys = _a.#parkedKeys(census);
|
|
1184
|
+
// Una outbox que no marca lo aplicado devolvería el mismo pendiente
|
|
1185
|
+
// para siempre: el relay no puede quedarse dando vueltas ni
|
|
1186
|
+
// "arreglarlo" por su cuenta, así que lo denuncia (500) en cuanto
|
|
1187
|
+
// vuelve a ver un id que YA aplicó.
|
|
1188
|
+
const done = new Set();
|
|
1189
|
+
/** Ids que esta pasada dejó a propósito (fallo o aplazo): reaparecen. */
|
|
1190
|
+
const parked = new Set();
|
|
1191
|
+
/** El último id visto: la outbox pagina desde ahí (lo saltado se queda). */
|
|
1192
|
+
let after;
|
|
1193
|
+
const defer = (item, keys, reason, error, origin) => {
|
|
1194
|
+
for (const key of keys)
|
|
1195
|
+
block(key, item.id, origin);
|
|
1196
|
+
parked.add(String(item.id));
|
|
1197
|
+
deferred.push({ id: item.id, change: item.change, attempts: item.attempts, error });
|
|
1198
|
+
// `origin` se DERIVA del bloqueo (2.4.0-alpha.8), jamás del prefijo de `reason`.
|
|
1199
|
+
entries.push({ id: item.id, change: item.change, status: 'deferred', reason, attempts: item.attempts, origin });
|
|
1200
|
+
};
|
|
1201
|
+
outer: while (applied.length < limit) {
|
|
1202
|
+
// **La barrera del freeze se RE-AFIRMA por lote** (3b-8 · B3). La
|
|
1203
|
+
// mirada única de la entrada dejaba hasta `DEFAULT_RELAY_LIMIT`
|
|
1204
|
+
// (10.000) escrituras de árbol colándose DESPUÉS de que otra pasada
|
|
1205
|
+
// adquiriera el freeze durable: escrituras que no salen en ningún
|
|
1206
|
+
// contador de la pasada certificada y que pueden invalidar su
|
|
1207
|
+
// resultado. El trade-off documentado en freeze.ts cubre «una
|
|
1208
|
+
// escritura que ya pasó su barrera», no una pasada entera. El coste
|
|
1209
|
+
// (una lectura de la fila `id=2` por lote; 0,14 ms/escritura ya
|
|
1210
|
+
// medidos y aceptados) va fuera del camino caliente. Un freeze
|
|
1211
|
+
// adquirido a mitad corta AQUÍ con el 503 reintentable de siempre:
|
|
1212
|
+
// lo ya aplicado está marcado en la outbox (la pasada es reanudable)
|
|
1213
|
+
// y el resto sigue pendiente para después de la ventana.
|
|
1214
|
+
await this.#assertNotFrozen('authz:scopes:relay');
|
|
1215
|
+
const batch = await outbox.pending(Math.min(batchSize, limit - applied.length), after, filter);
|
|
1216
|
+
if (batch.length === 0)
|
|
1217
|
+
break;
|
|
1218
|
+
_a.#assertOnlyHonoured(batch, only);
|
|
1219
|
+
let progress = false;
|
|
1220
|
+
for (const item of batch) {
|
|
1221
|
+
const id = String(item.id);
|
|
1222
|
+
if (done.has(id)) {
|
|
1223
|
+
throw new AuthorizationConfigError(`authz:scopes:relay: la outbox sigue devolviendo el cambio ${id} como pendiente después de markApplied. ` +
|
|
1224
|
+
'Tu implementación de ScopeOutbox no marca lo aplicado; el relay para antes de dar vueltas para siempre.');
|
|
1225
|
+
}
|
|
1226
|
+
if (parked.has(id))
|
|
1227
|
+
continue;
|
|
1228
|
+
progress = true;
|
|
1229
|
+
after = item.id;
|
|
1230
|
+
if (applied.length >= limit)
|
|
1231
|
+
break outer;
|
|
1232
|
+
const keys = _a.#changeKeys(item.change);
|
|
1233
|
+
// Una purga solo la frena un bloqueo cuyo origen sea un FALLO real;
|
|
1234
|
+
// el que nace de un aparcado la deja pasar (invariante 11).
|
|
1235
|
+
const collision = keys.find((key) => {
|
|
1236
|
+
const hit = blocked.get(key);
|
|
1237
|
+
return hit !== undefined && !(item.change.op === 'detached' && hit.origin === 'parked');
|
|
1238
|
+
});
|
|
1239
|
+
if (collision !== undefined) {
|
|
1240
|
+
const by = blocked.get(collision);
|
|
1241
|
+
defer(item, keys, `depends-on:${by.id}`, `aplazado: depende de ${collision}, que quedó sin aplicar en esta pasada (#${by.id})`, by.origin);
|
|
1242
|
+
continue;
|
|
1243
|
+
}
|
|
1244
|
+
// Lo aparcado tapona lo que CONCEDE o recuelga; una purga pasa.
|
|
1245
|
+
if (item.change.op !== 'detached') {
|
|
1246
|
+
const parkedKey = keys.find((key) => parkedKeys.has(key));
|
|
1247
|
+
if (parkedKey !== undefined) {
|
|
1248
|
+
const by = parkedKeys.get(parkedKey);
|
|
1249
|
+
defer(item, keys, `parked:${by}`, `aplazado: depende de ${parkedKey}, que nombra la entrada APARCADA #${by} (parked:${by}); no se aplica hasta que alguien la mire`, 'parked');
|
|
776
1250
|
continue;
|
|
777
1251
|
}
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1252
|
+
}
|
|
1253
|
+
// Con `only`, la sonda contra la cola ENTERA: una pendiente anterior
|
|
1254
|
+
// que comparta clave y no se pidió ⇒ aplazado, nunca fuera de orden.
|
|
1255
|
+
// **Y la sonda hereda el ORIGEN de esa anterior** (alpha.6, auditor 🟡 A
|
|
1256
|
+
// de alpha.5): si la anterior está —transitivamente— retenida solo por
|
|
1257
|
+
// un APARCADO, es el mismo bloqueo `parked:` que en la pasada completa
|
|
1258
|
+
// deja pasar la purga (invariante 11): el `detached` SE APLICA. Si la
|
|
1259
|
+
// anterior es un `attached`/`moved` pendiente de verdad (aplicable, o
|
|
1260
|
+
// retenido por un FALLO real), el `detached` sigue `deferred`: aplicarlo
|
|
1261
|
+
// antes que un `moved` pendiente del mismo scope dejaría deriva.
|
|
1262
|
+
if (only !== undefined) {
|
|
1263
|
+
const earlier = await this.#dependencyOf(outbox, item, batchSize);
|
|
1264
|
+
if (earlier !== null) {
|
|
1265
|
+
const origin = await this.#blockOriginOf(outbox, earlier, blocked, parkedKeys, batchSize, origins);
|
|
1266
|
+
if (!(item.change.op === 'detached' && origin === 'parked')) {
|
|
1267
|
+
const shared = _a.#changeKeys(earlier.change).find((key) => keys.includes(key)) ?? keys[0];
|
|
1268
|
+
defer(item, keys, `depends-on:${earlier.id}`, `aplazado: depende de ${shared}, que nombra la entrada anterior #${earlier.id} todavía sin aplicar (depends-on:${earlier.id}); ` +
|
|
1269
|
+
(origin === 'parked' ? 'espera a una entrada APARCADA; mira dead()' : 'corre el relay completo'), origin);
|
|
1270
|
+
continue;
|
|
1271
|
+
}
|
|
789
1272
|
}
|
|
790
|
-
await outbox.markApplied(item.id);
|
|
791
|
-
done.add(id);
|
|
792
|
-
applied.push({ id: item.id, change: item.change, attempts: item.attempts });
|
|
793
1273
|
}
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1274
|
+
try {
|
|
1275
|
+
await this.#applyScopeChange(driver, item);
|
|
1276
|
+
}
|
|
1277
|
+
catch (error) {
|
|
1278
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1279
|
+
const transient = _a.#isTransientRelayFailure(error);
|
|
1280
|
+
await outbox.markFailed(item.id, message, { transient });
|
|
1281
|
+
for (const key of keys)
|
|
1282
|
+
block(key, item.id, 'failed');
|
|
1283
|
+
parked.add(id);
|
|
1284
|
+
failures.push({ id: item.id, change: item.change, error: message, transient });
|
|
1285
|
+
entries.push({ id: item.id, change: item.change, status: 'failed', reason: message, attempts: item.attempts });
|
|
1286
|
+
continue;
|
|
1287
|
+
}
|
|
1288
|
+
await outbox.markApplied(item.id);
|
|
1289
|
+
done.add(id);
|
|
1290
|
+
applied.push({ id: item.id, change: item.change, attempts: item.attempts });
|
|
1291
|
+
entries.push({ id: item.id, change: item.change, status: 'applied', attempts: item.attempts });
|
|
799
1292
|
}
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1293
|
+
// Una outbox que ignora `after` devuelve el mismo lote atascado: la
|
|
1294
|
+
// pasada termina aquí en vez de dar vueltas (lo que quede, y lo que
|
|
1295
|
+
// haya detrás, sigue pendiente para la siguiente).
|
|
1296
|
+
if (!progress)
|
|
1297
|
+
break;
|
|
1298
|
+
}
|
|
1299
|
+
entries.push(...deadEntries);
|
|
1300
|
+
// La cabeza global de la cola: `remaining` y, desde alpha.8, su EDAD.
|
|
1301
|
+
const { remaining, queue } = await this.#queueStatus(outbox);
|
|
1302
|
+
return {
|
|
1303
|
+
applied,
|
|
1304
|
+
failed: failures[0] ?? null,
|
|
1305
|
+
failures,
|
|
1306
|
+
deferred,
|
|
1307
|
+
dead,
|
|
1308
|
+
deadCount,
|
|
1309
|
+
queue,
|
|
1310
|
+
busy: false,
|
|
1311
|
+
remaining,
|
|
1312
|
+
dryRun: false,
|
|
1313
|
+
wouldApply: [],
|
|
1314
|
+
entries,
|
|
1315
|
+
...(await unseenOf(new Set(entries.map((e) => String(e.id))))),
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1318
|
+
/**
|
|
1319
|
+
* `only` normalizado (alpha.5 · R1): un conjunto de ids como cadena (el id
|
|
1320
|
+
* de `sqlScopeOutbox` es un entero que según el motor y el driver llega
|
|
1321
|
+
* como `number` o como `string`; comparar con `===` sería un no-op
|
|
1322
|
+
* silencioso). `undefined` = sin filtro. **`[]` es 500**, nunca «sin
|
|
1323
|
+
* filtro»: un `filter` vacío del consumidor —o una outbox de terceros sin
|
|
1324
|
+
* recibo, que deja `queued: []`— no puede acabar drenando la cola entera
|
|
1325
|
+
* bajo el lease de una petición HTTP, ni en un reporte vacío en verde.
|
|
1326
|
+
*/
|
|
1327
|
+
static #onlyOf(only) {
|
|
1328
|
+
if (only === undefined)
|
|
1329
|
+
return undefined;
|
|
1330
|
+
if (!Array.isArray(only) || only.some((id) => typeof id !== 'number' && typeof id !== 'string')) {
|
|
1331
|
+
throw new AuthorizationConfigError(`authz:scopes:relay: 'only' debe ser una lista de ids (number | string); llegó ${JSON.stringify(only)}`);
|
|
1332
|
+
}
|
|
1333
|
+
if (only.length === 0) {
|
|
1334
|
+
throw new AuthorizationConfigError("authz:scopes:relay: 'only' está vacío. Un recibo vacío significa que no hay nada que relevar (sin outbox el cambio ya " +
|
|
1335
|
+
'se aplicó en línea; con una outbox de terceros sin recibo no hay `only`): relevar «nada» no es relevar «todo». ' +
|
|
1336
|
+
'Quita `only` para drenar la cola entera.');
|
|
1337
|
+
}
|
|
1338
|
+
return new Set(only.map(String));
|
|
1339
|
+
}
|
|
1340
|
+
/** El id tal como lo pasó el llamante en `only` (para `missing`). */
|
|
1341
|
+
static #rawId(only, key) {
|
|
1342
|
+
return only.find((id) => String(id) === key) ?? key;
|
|
1343
|
+
}
|
|
1344
|
+
/**
|
|
1345
|
+
* Una outbox que IGNORA `filter.ids` devuelve lo que no se pidió: el
|
|
1346
|
+
* backlog ajeno se aplicaría dentro de la petición del consumidor, en
|
|
1347
|
+
* silencio. Se detecta y se para (500), como el `markApplied` no honrado.
|
|
1348
|
+
*/
|
|
1349
|
+
static #assertOnlyHonoured(batch, only) {
|
|
1350
|
+
if (only === undefined)
|
|
1351
|
+
return;
|
|
1352
|
+
const stranger = batch.find((item) => !only.has(String(item.id)));
|
|
1353
|
+
if (stranger !== undefined) {
|
|
1354
|
+
throw new AuthorizationConfigError(`authz:scopes:relay: la outbox devolvió el cambio ${String(stranger.id)}, que no está en 'only'. ` +
|
|
1355
|
+
'Tu implementación de ScopeOutbox no honra `pending(limit, after, { ids })`: el relay para antes de aplicar ' +
|
|
1356
|
+
'lo que no se pidió (filtra en el puerto, no en memoria).');
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
/**
|
|
1360
|
+
* La caída del BACKEND no dice nada de la entrada (alpha.5 · R2a): un
|
|
1361
|
+
* `AuthorizationBackendError` (503; el timeout es uno), y en general
|
|
1362
|
+
* cualquier 503 (el freeze, el resolutor caído). Todo lo demás —422, 500,
|
|
1363
|
+
* `E_AUTHZ_SCOPE_TREE_DRIFT`, el padre inexistente, un error sin status de
|
|
1364
|
+
* un driver de terceros— es de la entrada y cuenta hacia el aparcado.
|
|
1365
|
+
*/
|
|
1366
|
+
static #isTransientRelayFailure(error) {
|
|
1367
|
+
if (error instanceof AuthorizationBackendError)
|
|
1368
|
+
return true;
|
|
1369
|
+
return typeof error === 'object' && error !== null && error.status === 503;
|
|
1370
|
+
}
|
|
1371
|
+
/**
|
|
1372
|
+
* Las claves de scope de lo APARCADO ⇒ el id aparcado que las nombra
|
|
1373
|
+
* (alpha.5 · R2a). El censo lo lee `#relayInputs`: el `dead` ya leído si
|
|
1374
|
+
* cabe entero en un lote (menos de `batchSize` filas = no hay más); si no,
|
|
1375
|
+
* la cola ENTERA hasta `PARKED_CENSUS_MAX` — nunca acotado por `limit`
|
|
1376
|
+
* (cierre, 🟠 2: con `--limit 50` o `{ only, limit: 1 }` la aparcada 101 no
|
|
1377
|
+
* taponaba y el `attached` colgaba del padre viejo)—, y si la cota se
|
|
1378
|
+
* alcanza la pasada se niega (500): un tapón incompleto no es un tapón.
|
|
1379
|
+
*/
|
|
1380
|
+
static #parkedKeys(census) {
|
|
1381
|
+
if (census.length >= PARKED_CENSUS_MAX) {
|
|
1382
|
+
throw new AuthorizationConfigError(`authz:scopes:relay: la outbox tiene ${PARKED_CENSUS_MAX} o más entradas APARCADAS y el censo de sus claves no cabe ` +
|
|
1383
|
+
`(PARKED_CENSUS_MAX): sin el censo entero el tapón por clave sería ciego y un attached podría colgarse del padre ` +
|
|
1384
|
+
`VIEJO. No se ha aplicado nada. Mira dead(), reencola o reconcilia antes de volver a relevar.`);
|
|
1385
|
+
}
|
|
1386
|
+
const keys = new Map();
|
|
1387
|
+
for (const item of census) {
|
|
1388
|
+
// **Solo la clave del HIJO** (2.4.0-alpha.8 · variante B; dueño: sí).
|
|
1389
|
+
// Una aparcada dice que la POSICIÓN DE SU HIJO en el store es
|
|
1390
|
+
// incorrecta; no dice nada de su padre nuevo: un `attached(otraUnit,
|
|
1391
|
+
// orgA)` detrás de un `moved(unit → orgA)` aparcado escribe otra arista
|
|
1392
|
+
// y conmuta (medido, `m7-tapon.ts`: se aplazaba sin motivo y el store
|
|
1393
|
+
// conservaba el padre VIEJO de todo lo que colgara de orgA). Lo que SÍ
|
|
1394
|
+
// nombra al hijo —`attached(child, unit)`, `moved(unit → orgC)`— sigue
|
|
1395
|
+
// taponado. Vive SOLO aquí, en la siembra: al COMPARAR (`#changeKeys`
|
|
1396
|
+
// en `blocked`, el respaldo de la sonda) van siempre las dos claves, o
|
|
1397
|
+
// el caso 1136 se pierde (trampa medida por el juez).
|
|
1398
|
+
const key = scopeKey(item.change.child);
|
|
1399
|
+
if (!keys.has(key))
|
|
1400
|
+
keys.set(key, item.id);
|
|
1401
|
+
}
|
|
1402
|
+
return keys;
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* **La sonda de dependencia de `only`** (alpha.5 · R1): la pendiente más
|
|
1406
|
+
* antigua ANTERIOR a `item` que comparta clave de scope con ella, o `null`.
|
|
1407
|
+
* Con `earlierPending` es una consulta de existencia (0,69 ms medidos);
|
|
1408
|
+
* sin ella se pagina `pending()` desde el principio hasta `item.id` —
|
|
1409
|
+
* correcto, O(cola)— y si la cola no se deja recorrer en
|
|
1410
|
+
* `RELAY_WINDOW_MAX_PAGES` lotes se para (500 nombrando el método), nunca
|
|
1411
|
+
* se aplica sin saber.
|
|
1412
|
+
*/
|
|
1413
|
+
async #dependencyOf(outbox, item, batchSize) {
|
|
1414
|
+
const keys = _a.#changeKeys(item.change);
|
|
1415
|
+
if (typeof outbox.earlierPending === 'function') {
|
|
1416
|
+
// La misma lista que `#changeKeys` (la raíz exenta, alpha.8): el SQL del
|
|
1417
|
+
// puerto sí sabe casar `parent_type = 'app'`; el manager no se lo pide.
|
|
1418
|
+
return outbox.earlierPending(item.id, _a.#changeScopes(item.change));
|
|
1419
|
+
}
|
|
1420
|
+
const mine = String(item.id);
|
|
1421
|
+
let after;
|
|
1422
|
+
for (let page = 0; page < RELAY_WINDOW_MAX_PAGES; page++) {
|
|
1423
|
+
const batch = await outbox.pending(batchSize, after);
|
|
1424
|
+
for (const earlier of batch) {
|
|
1425
|
+
if (String(earlier.id) === mine)
|
|
1426
|
+
return null;
|
|
1427
|
+
if (_a.#changeKeys(earlier.change).some((key) => keys.includes(key)))
|
|
1428
|
+
return earlier;
|
|
1429
|
+
}
|
|
1430
|
+
if (batch.length < batchSize)
|
|
1431
|
+
return null;
|
|
1432
|
+
after = batch[batch.length - 1].id;
|
|
1433
|
+
}
|
|
1434
|
+
throw new AuthorizationConfigError(`authz:scopes:relay: no se pudo sondear si el cambio ${mine} depende de una entrada anterior: la cola tiene más de ` +
|
|
1435
|
+
`${RELAY_WINDOW_MAX_PAGES * batchSize} pendientes por delante y tu ScopeOutbox no implementa earlierPending(). ` +
|
|
1436
|
+
'Implementa la sonda, o releva sin `only`.');
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* **El ORIGEN del bloqueo de una entrada NO pedida** (2.4.0-alpha.6): ¿en la
|
|
1440
|
+
* pasada COMPLETA saldría `deferred` por un APARCADO —directo (`parked:`) o
|
|
1441
|
+
* heredado (`depends-on:` de otra que lo está)— y no por un fallo real ni
|
|
1442
|
+
* porque simplemente se aplicaría? `true` = retenida solo por un aparcado.
|
|
1443
|
+
* Es la misma regla que aplica `block`/`defer` a lo que la pasada SÍ procesa,
|
|
1444
|
+
* simulada hacia atrás sobre lo que no procesa:
|
|
1445
|
+
* 1. una clave suya en `blocked` (esta pasada) con origen FALLO ⇒ fallo;
|
|
1446
|
+
* 2. `attached`/`moved` con una clave en `parkedKeys`, o en `blocked` con
|
|
1447
|
+
* origen aparcado ⇒ aparcado;
|
|
1448
|
+
* 3. si no, su propia anterior con clave compartida (`earlierPending`):
|
|
1449
|
+
* ninguna ⇒ se aplicaría ⇒ NO es aparcado (la pedida espera su turno);
|
|
1450
|
+
* alguna ⇒ el origen de ESA (recursivo, ids decrecientes, memoizado,
|
|
1451
|
+
* `RELAY_ORIGIN_MAX_HOPS`; pasada la cota ⇒ fallo, hacia lo seguro).
|
|
1452
|
+
* Un `detached` anterior nunca es «aparcado» (una purga pasa; si sigue
|
|
1453
|
+
* pendiente es porque se aplicará): la pedida espera detrás.
|
|
1454
|
+
*/
|
|
1455
|
+
async #blockOriginOf(outbox, entry, blocked, parkedKeys, batchSize, memo, hops = 0) {
|
|
1456
|
+
const id = String(entry.id);
|
|
1457
|
+
const known = memo.get(id);
|
|
1458
|
+
if (known !== undefined)
|
|
1459
|
+
return known;
|
|
1460
|
+
const decide = (origin) => {
|
|
1461
|
+
memo.set(id, origin);
|
|
1462
|
+
return origin;
|
|
1463
|
+
};
|
|
1464
|
+
// Pasada la cota, hacia lo seguro: como un fallo real.
|
|
1465
|
+
if (hops >= RELAY_ORIGIN_MAX_HOPS)
|
|
1466
|
+
return decide('failed');
|
|
1467
|
+
const keys = _a.#changeKeys(entry.change);
|
|
1468
|
+
const hits = keys.map((key) => blocked.get(key)).filter((hit) => hit !== undefined);
|
|
1469
|
+
// Desde 2.4.0-alpha.8 el origen es TRI-estado (`failed > pending > parked`):
|
|
1470
|
+
// la decisión aplicar/aplazar no cambia (solo `parked` exime a la purga);
|
|
1471
|
+
// lo que cambia es que el reporte distingue «espera a una pendiente de
|
|
1472
|
+
// verdad» de «bloqueada por un fallo».
|
|
1473
|
+
if (hits.some((hit) => hit.origin === 'failed'))
|
|
1474
|
+
return decide('failed');
|
|
1475
|
+
if (hits.some((hit) => hit.origin === 'pending'))
|
|
1476
|
+
return decide('pending');
|
|
1477
|
+
if (entry.change.op !== 'detached') {
|
|
1478
|
+
if (hits.length > 0 || keys.some((key) => parkedKeys.has(key)))
|
|
1479
|
+
return decide('parked');
|
|
1480
|
+
}
|
|
1481
|
+
const earlier = await this.#dependencyOf(outbox, entry, batchSize);
|
|
1482
|
+
// Ninguna anterior: se aplicaría en la pasada completa ⇒ la pedida espera a una PENDIENTE de verdad.
|
|
1483
|
+
if (earlier === null)
|
|
1484
|
+
return decide('pending');
|
|
1485
|
+
return decide(await this.#blockOriginOf(outbox, earlier, blocked, parkedKeys, batchSize, memo, hops + 1));
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* **Devuelve entradas APARCADAS a la cola** (2.4.0-alpha.5 · R2b; panel
|
|
1489
|
+
* relay, cuestión 2). Es lo que hay detrás de `node ace authz:scopes:requeue
|
|
1490
|
+
* --id`. Operación de PLATAFORMA como `relayScopeChanges` y `driver()`: sin
|
|
1491
|
+
* actor, sin `within`, **no se expone por HTTP**.
|
|
1492
|
+
*
|
|
1493
|
+
* **La sonda anti-reversión vive AQUÍ, no en la outbox**: reaplicar una
|
|
1494
|
+
* entrada ANTERIOR a otra ya APLICADA sobre la misma clave de scope
|
|
1495
|
+
* reescribe el árbol del store en un orden que nunca existió — el `moved`
|
|
1496
|
+
* viejo tras el nuevo deja el padre VIEJO con UN solo padre, así que
|
|
1497
|
+
* `assertOneParent` no lo delata y el tenant antiguo recupera el acceso
|
|
1498
|
+
* (auditor 🔴 2, `relay-auditor-5.ts`, medido). Por eso, antes de tocar la
|
|
1499
|
+
* cola, cada aparcada pedida se compara contra las aplicadas posteriores
|
|
1500
|
+
* que compartan clave (`outbox.laterApplied`, espejo de `earlierPending`)
|
|
1501
|
+
* y si hay alguna se NIEGA con 500 `E_AUTHZ_REQUEUE_REFUSED` nombrándola —
|
|
1502
|
+
* todo o nada, cero filas tocadas—. El remedio correcto es
|
|
1503
|
+
* `authz:reconcile --to=<driver>`, que recalcula con el árbol de HOY. La
|
|
1504
|
+
* sonda es del manager porque necesita las claves del cambio
|
|
1505
|
+
* (`#changeKeys`); `outbox.requeue` queda mecánico y una outbox de
|
|
1506
|
+
* terceros la hereda gratis.
|
|
1507
|
+
*
|
|
1508
|
+
* **`force`** salta la sonda y devuelve el diff en `overrides`. Existe para
|
|
1509
|
+
* el comando ace —que lo imprime y exige el flag explícito (decisión del
|
|
1510
|
+
* dueño del 2026-09-09 (2))— y para nada más: llamarlo con `force: true`
|
|
1511
|
+
* desde un endpoint es policy en una API sin policy.
|
|
1512
|
+
*
|
|
1513
|
+
* `attempts` queda en `maxAttempts − 1` (lo hace la outbox): si la causa
|
|
1514
|
+
* sigue rota la entrada vuelve a aparcarse en UNA pasada y contamina su
|
|
1515
|
+
* clave solo en esa pasada —un requeue automatizado de lo irreparable no
|
|
1516
|
+
* es un grifo de aplazamiento permanente (auditor 🔴 2, segunda mitad)—; si
|
|
1517
|
+
* el operador la arregló, entra a la primera. No hay bucle: lo aparcado no
|
|
1518
|
+
* vuelve solo a `pending()`.
|
|
1519
|
+
*
|
|
1520
|
+
* Exige `dead`, `requeue` y `laterApplied` en el puerto (500
|
|
1521
|
+
* `E_AUTHZ_UNSUPPORTED` nombrando el que falte): sin censo no se sabe qué
|
|
1522
|
+
* se reencola y sin sonda no hay requeue. Sin `apply` no pasa por la
|
|
1523
|
+
* barrera del freeze: no escribe en el backend; la pasada que aplique la
|
|
1524
|
+
* entrada sí la pasa.
|
|
1525
|
+
*
|
|
1526
|
+
* **`apply: true` — reencolar Y relevar en la misma llamada** (2.4.0-alpha.8;
|
|
1527
|
+
* panel relay 2, cuestión 4; decisión del dueño 2026-09-10 (4)). Medido
|
|
1528
|
+
* (tester P14): la reencolada vuelve con su id ORIGINAL (menor) y la
|
|
1529
|
+
* siguiente pasada `only` del tenant salía `deferred · depends-on:` — el
|
|
1530
|
+
* incidente no se cerraba con un comando. Con `apply`, tras reencolar se
|
|
1531
|
+
* releva SOLO eso (`only` = exactamente los ids reencolados: los
|
|
1532
|
+
* dependientes NO se arrastran, quedan `deferred`/pendientes y el reporte lo
|
|
1533
|
+
* dice —«`only` es mi recibo»—) y el reporte del relay viaja en
|
|
1534
|
+
* `report.relay`. Cuatro condiciones, todas fijadas con caso: (1) **un solo
|
|
1535
|
+
* lease** para toda la llamada (tomarlo dos veces era `busy` en verde,
|
|
1536
|
+
* auditor 🟠 5) — se toma PRIMERO, y si está ocupado es `relay.busy` con
|
|
1537
|
+
* CERO filas reencoladas (reencolar y decir `busy` dejaba `attempts = max −
|
|
1538
|
+
* 1` y la entrada se volvía a aparcar contaminando su clave); (2) **pasa la
|
|
1539
|
+
* barrera del freeze** ANTES de tocar nada (con `apply` escribe el árbol);
|
|
1540
|
+
* (3) **excluyente con `force`** (500 juntos: con las dos, revertir el árbol
|
|
1541
|
+
* pasaba de dos llamadas de plataforma a una, auditor 🟠 6); (4) la sonda
|
|
1542
|
+
* anti-reversión corre ANTES de reencolar, como siempre. `waitForLeaseMs`
|
|
1543
|
+
* (solo con `apply`) es la misma espera acotada que en el relay, para que
|
|
1544
|
+
* el comando que cierra el incidente no pierda siempre contra el cron.
|
|
1545
|
+
*/
|
|
1546
|
+
async requeueScopeChanges(ids, options = {}) {
|
|
1547
|
+
_a.#assertKnownOptions('requeueScopeChanges', options, REQUEUE_OPTIONS);
|
|
1548
|
+
const apply = options.apply === true;
|
|
1549
|
+
if (apply && options.force === true) {
|
|
1550
|
+
throw new AuthorizationConfigError('authz:scopes:requeue: apply y force no se combinan: con las dos, revertir el árbol del store por encima de lo ya ' +
|
|
1551
|
+
'aplicado sería UNA sola llamada. Reencola con --force y releva después a sabiendas, o usa --apply sin --force. ' +
|
|
1552
|
+
'No se ha hecho nada.');
|
|
1553
|
+
}
|
|
1554
|
+
if (!apply && options.waitForLeaseMs !== undefined) {
|
|
1555
|
+
throw new AuthorizationConfigError('authz:scopes:requeue: waitForLeaseMs solo tiene sentido con apply (sin apply no se toma ningún lease). No se ha hecho nada.');
|
|
1556
|
+
}
|
|
1557
|
+
const waitForLeaseMs = _a.#waitMs(options.waitForLeaseMs);
|
|
1558
|
+
// Con `apply` se ESCRIBE el árbol: la barrera del freeze va antes de nada.
|
|
1559
|
+
if (apply)
|
|
1560
|
+
await this.#assertNotFrozen('authz:scopes:requeue --apply');
|
|
1561
|
+
const outbox = this.#outbox();
|
|
1562
|
+
if (!outbox) {
|
|
1563
|
+
throw new AuthorizationConfigError("authz:scopes:requeue necesita 'scopes.outbox' en config/authorization.ts: sin cola no hay nada aparcado que reencolar.");
|
|
1564
|
+
}
|
|
1565
|
+
const wanted = _a.#requeueIds(ids);
|
|
1566
|
+
for (const method of ['dead', 'requeue', 'laterApplied']) {
|
|
1567
|
+
if (typeof outbox[method] !== 'function') {
|
|
1568
|
+
throw new UnsupportedOperationError(method, 'authz:scopes:requeue', 'scopes.outbox', method === 'laterApplied'
|
|
1569
|
+
? 'Sin la sonda anti-reversión (laterApplied: las entradas APLICADAS posteriores que comparten clave) no hay ' +
|
|
1570
|
+
'requeue: reaplicar una entrada vieja por encima de una nueva revierte el árbol del store en silencio.'
|
|
1571
|
+
: method === 'dead'
|
|
1572
|
+
? 'Sin dead() no se sabe qué está aparcado, y solo lo aparcado se reencola.'
|
|
1573
|
+
: 'Devuelve la entrada aparcada a pending() con attempts = maxAttempts − 1, todo o nada.');
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
// Con `apply`, el lease PRIMERO (una vez para toda la llamada): ocupado ⇒
|
|
1577
|
+
// `busy` con cero filas tocadas. Sin `apply` no hay lease que tomar.
|
|
1578
|
+
const lease = apply ? await this.#acquireLease(outbox, waitForLeaseMs) : null;
|
|
1579
|
+
if (apply && outbox.acquire && lease === null) {
|
|
1580
|
+
const inputs = await _a.#relayInputs(outbox, [...wanted], DEFAULT_RELAY_BATCH);
|
|
1581
|
+
return { requeued: [], overrides: [], forced: false, relay: await this.#busyReport(outbox, inputs) };
|
|
1582
|
+
}
|
|
1583
|
+
try {
|
|
1584
|
+
return await this.#requeueHeld(outbox, wanted, options.force === true, apply);
|
|
811
1585
|
}
|
|
812
1586
|
finally {
|
|
813
1587
|
await lease?.release();
|
|
814
1588
|
}
|
|
815
1589
|
}
|
|
1590
|
+
/** `requeueScopeChanges` con el lease (si `apply`) ya tomado: censo, sonda anti-reversión, requeue y —con `apply`— el relay de esos ids. */
|
|
1591
|
+
async #requeueHeld(outbox, wanted, force, apply) {
|
|
1592
|
+
// El censo de lo aparcado (acotado como el del relay): solo eso se reencola.
|
|
1593
|
+
const parked = await outbox.dead(DEFAULT_RELAY_LIMIT);
|
|
1594
|
+
const byId = new Map();
|
|
1595
|
+
for (const item of parked)
|
|
1596
|
+
byId.set(String(item.id), item);
|
|
1597
|
+
const unknown = wanted.filter((id) => !byId.has(String(id)));
|
|
1598
|
+
if (unknown.length > 0) {
|
|
1599
|
+
throw new AuthorizationConfigError(`authz:scopes:requeue: ${unknown.map(String).join(', ')} no está(n) aparcada(s) —no existe, ya se aplicó, o sigue ` +
|
|
1600
|
+
`pendiente con intentos por gastar—: no se ha reencolado NADA (todo o nada). Solo se reencola lo que sale por ` +
|
|
1601
|
+
`dead(); una entrada aplicada no revive jamás.`);
|
|
1602
|
+
}
|
|
1603
|
+
const conflicts = [];
|
|
1604
|
+
for (const id of wanted) {
|
|
1605
|
+
const item = byId.get(String(id));
|
|
1606
|
+
// La misma lista que `#changeKeys` (la raíz exenta, alpha.8): dos altas de
|
|
1607
|
+
// tenant no se revierten entre sí, y el conflicto que salía era falso.
|
|
1608
|
+
const scopes = _a.#changeScopes(item.change);
|
|
1609
|
+
// Se pide UNA de más para saber si el diff que va a leer el operador está cortado (cierre, ⚪ 6).
|
|
1610
|
+
const later = await outbox.laterApplied(item.id, scopes, DEFAULT_RELAY_BATCH + 1);
|
|
1611
|
+
const applied = later.slice(0, DEFAULT_RELAY_BATCH);
|
|
1612
|
+
if (applied.length > 0) {
|
|
1613
|
+
conflicts.push({ parked: item, applied, ...(later.length > DEFAULT_RELAY_BATCH ? { truncated: true } : {}) });
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
if (conflicts.length > 0 && !force) {
|
|
1617
|
+
const detail = conflicts
|
|
1618
|
+
.map((conflict) => {
|
|
1619
|
+
const keys = _a.#changeKeys(conflict.parked.change);
|
|
1620
|
+
const shared = conflict.applied.map((entry) => {
|
|
1621
|
+
const key = _a.#changeKeys(entry.change).find((k) => keys.includes(k)) ?? keys[0];
|
|
1622
|
+
return `#${entry.id} (${entry.change.op}, clave ${key})`;
|
|
1623
|
+
});
|
|
1624
|
+
return `#${conflict.parked.id} (${conflict.parked.change.op}) revertiría ${shared.join(', ')}`;
|
|
1625
|
+
})
|
|
1626
|
+
.join('; ');
|
|
1627
|
+
throw new ScopeRequeueRefusedError(`authz:scopes:requeue: reencolar reaplicaría un cambio ANTERIOR a otro ya APLICADO sobre la misma clave de scope, ` +
|
|
1628
|
+
`y el árbol del store quedaría en un orden que nunca existió: ${detail}. No se ha reencolado nada. ` +
|
|
1629
|
+
`Para recalcular con el árbol de HOY: authz:reconcile --to=<driver>. Si de verdad quieres revertir, el comando ` +
|
|
1630
|
+
`ace admite --force (imprime este diff y entra igual).`, conflicts);
|
|
1631
|
+
}
|
|
1632
|
+
await outbox.requeue([...wanted]);
|
|
1633
|
+
const report = { requeued: wanted.map((id) => byId.get(String(id))), overrides: conflicts, forced: force };
|
|
1634
|
+
if (!apply)
|
|
1635
|
+
return report;
|
|
1636
|
+
// Bajo el MISMO lease: solo los ids reencolados, con lo aparcado releído
|
|
1637
|
+
// (las reencoladas ya no salen por `dead()`); la barrera del freeze se
|
|
1638
|
+
// re-afirma por lote dentro, como en toda pasada.
|
|
1639
|
+
const inputs = await _a.#relayInputs(outbox, [...wanted], DEFAULT_RELAY_BATCH);
|
|
1640
|
+
report.relay = await this.#drainScopeChanges(outbox, { limit: DEFAULT_RELAY_LIMIT, batchSize: DEFAULT_RELAY_BATCH, ...inputs });
|
|
1641
|
+
return report;
|
|
1642
|
+
}
|
|
1643
|
+
/** Los ids de `requeue`: lista NO vacía de números o cadenas, sin repetidos (500 si no). */
|
|
1644
|
+
static #requeueIds(ids) {
|
|
1645
|
+
if (!Array.isArray(ids) || ids.length === 0 || ids.some((id) => typeof id !== 'number' && typeof id !== 'string')) {
|
|
1646
|
+
throw new AuthorizationConfigError(`authz:scopes:requeue: 'ids' debe ser una lista NO vacía de ids (number | string); llegó ${JSON.stringify(ids)}. ` +
|
|
1647
|
+
'Sin ids no se reencola nada: «nada» no es «todo».');
|
|
1648
|
+
}
|
|
1649
|
+
const seen = new Set();
|
|
1650
|
+
return ids.filter((id) => (seen.has(String(id)) ? false : (seen.add(String(id)), true)));
|
|
1651
|
+
}
|
|
816
1652
|
/**
|
|
817
1653
|
* **`authz:reconcile --to=<driver>`** (3b-3a): la ÚNICA primitiva de
|
|
818
1654
|
* migración y verificación del paquete, y el motivo de la fase entera —
|
|
@@ -874,6 +1710,49 @@ export class AuthorizationManager {
|
|
|
874
1710
|
throw new UnsupportedOperationError('reconcile', `authz:reconcile --to=${name}`, name, `El driver '${name}' no sabe reconstruirse desde 'authz_*' + el árbol del consumidor. ` +
|
|
875
1711
|
`El driver 'database' es ese caso a propósito: sus tablas son el ORIGEN.`);
|
|
876
1712
|
}
|
|
1713
|
+
// **La dirección RANCIA** (2.4.0-alpha.10; 🔴 4 del sexto informe de
|
|
1714
|
+
// COGNITIV y cuarta iteración del LINAJE «origen rancio del reconcile»:
|
|
1715
|
+
// 3b-5 cerró la pasada por defecto, el primer guard de alpha.10 cerró
|
|
1716
|
+
// `--from`, y quedaba abierto el origen elegido POR DESCARTE). Regla del
|
|
1717
|
+
// dueño: el tercer defecto de un linaje se arregla estructuralmente, así
|
|
1718
|
+
// que el guard NO mira la bandera, mira el ORIGEN RESUELTO — con la MISMA
|
|
1719
|
+
// decisión que `#factsOrigin` (`#isMaintenancePass`), no con una copia:
|
|
1720
|
+
// si `to` es el driver ACTIVO, el único origen legítimo es `to` mismo (la
|
|
1721
|
+
// pasada de MANTENIMIENTO); cualquier otro —`--from`, el driver elegido por
|
|
1722
|
+
// descarte o las tablas `authz_*`— dejó de ver los hechos en el cutover y la
|
|
1723
|
+
// pasada re-escribiría lo revocado después (con o sin --prune; el seguro de
|
|
1724
|
+
// borrado masivo no salta: el origen no está vacío). Se decide por `to`,
|
|
1725
|
+
// nunca por `from`: el cutover inicial y la vuelta atrás ANTES de cambiar
|
|
1726
|
+
// `default` tienen `to` ≠ activo y pasan. Antes de resolver el origen y de
|
|
1727
|
+
// congelar. `--dry-run` no lanza (es el verificador): lo marca y
|
|
1728
|
+
// `reconcileLines` lo hace deriva. `from === to` sigue siendo el 500
|
|
1729
|
+
// E_AUTHZ_CONFIG de 3b-3b.
|
|
1730
|
+
const stale = this.#sameDriver(name, this.#config.default) &&
|
|
1731
|
+
(options.from === undefined || !this.#sameDriver(options.from, name)) &&
|
|
1732
|
+
!this.#isMaintenancePass(name, target, options.from);
|
|
1733
|
+
const allowStale = options.allowStaleSource === true;
|
|
1734
|
+
if (stale && !allowStale && options.dryRun !== true) {
|
|
1735
|
+
const how = options.from !== undefined ? `--from=${options.from}` : 'sin --from';
|
|
1736
|
+
// Cierre del auditor (⚪ 8): con UN solo driver registrado no hay descarte que
|
|
1737
|
+
// hacer; la letra no puede hablar de un origen que no existe.
|
|
1738
|
+
const others = Object.keys(this.#config.drivers ?? {}).filter((key) => !this.#sameDriver(key, name));
|
|
1739
|
+
const originLabel = options.from !== undefined
|
|
1740
|
+
? `el origen '${options.from}' no lo es`
|
|
1741
|
+
: others.length === 0
|
|
1742
|
+
? `no hay ningún otro driver registrado ni pasada de mantenimiento para él (no guarda sus hechos en su ` +
|
|
1743
|
+
`backend: no declara hierarchyFacts), así que no hay nada de dónde reconciliar hacia '${name}'`
|
|
1744
|
+
: `sin --from el origen se elegiría POR DESCARTE (las tablas authz_* u otro driver registrado: ` +
|
|
1745
|
+
`${others.join(', ')}), y no es '${name}'`;
|
|
1746
|
+
throw new StaleSourceRefusedError(`authz:reconcile --to=${name} ${how}: '${name}' es el driver ACTIVO (config.default` +
|
|
1747
|
+
`${name === this.#config.default ? '' : `: la clave resuelve la MISMA factory que '${this.#config.default}'`}) y ${originLabel}. ` +
|
|
1748
|
+
`Esa dirección re-escribiría en el driver activo hechos que el origen ya no refleja: resucitaría las ` +
|
|
1749
|
+
`revocaciones hechas desde el cutover (y con --prune borraría lo concedido después). No se ha leído, ` +
|
|
1750
|
+
`congelado ni escrito nada. Hacia el driver activo el único origen es él mismo: la pasada de mantenimiento ` +
|
|
1751
|
+
`(authz:reconcile --to=${name} [--dry-run], sin --from) si su driver guarda sus hechos; y si lo que quieres ` +
|
|
1752
|
+
`es volver atrás, migra en la otra dirección ANTES de cambiar config.default. Si de verdad el origen sigue ` +
|
|
1753
|
+
`siendo la fuente de verdad (el motor aún no ha escrito desde '${name}'), la decisión humana es ` +
|
|
1754
|
+
`--allow-stale-source (allowStaleSource: true).`);
|
|
1755
|
+
}
|
|
877
1756
|
// **De dónde salen los HECHOS** (3b-5): la decisión que faltaba, y la que
|
|
878
1757
|
// el destino no puede tomar por su cuenta. Ver `#factsOrigin`.
|
|
879
1758
|
const origin = await this.#factsOrigin(name, target, options.from);
|
|
@@ -885,6 +1764,18 @@ export class AuthorizationManager {
|
|
|
885
1764
|
// UNA vez.
|
|
886
1765
|
facts: origin.enumerate,
|
|
887
1766
|
factsOrigin: { name: origin.name, authzTables: origin.authzTables },
|
|
1767
|
+
// El techo de profundidad del consumidor, si lo declaró (alpha.5 · R0):
|
|
1768
|
+
// el mismo mando que el gate de `scopes.*`.
|
|
1769
|
+
...(this.#config.scopes?.maxDepth === undefined ? {} : { maxDepth: this.#config.scopes.maxDepth }),
|
|
1770
|
+
// **Los tipos del OTRO subsistema del mismo backend** (alpha.9 · B · S4),
|
|
1771
|
+
// si el consumidor declaró relaciones. Es SOLO para el reporte: parte lo
|
|
1772
|
+
// ajeno en «declarado» y «no lo reclama nadie». La decisión de BORRAR no
|
|
1773
|
+
// lo mira —la toma la lista blanca congelada del destino—, porque el
|
|
1774
|
+
// proceso que corre un cutover puede no tener esta config y aun así
|
|
1775
|
+
// tiene que respetar las tuplas de quien sí la tiene.
|
|
1776
|
+
...(this.#config.relations?.config === undefined
|
|
1777
|
+
? {}
|
|
1778
|
+
: { relationTypes: this.#config.relations.config.objectTypes.map((type) => type.type) }),
|
|
888
1779
|
};
|
|
889
1780
|
const pass = async () => {
|
|
890
1781
|
const report = await target.reconcile(source, options);
|
|
@@ -894,6 +1785,8 @@ export class AuthorizationManager {
|
|
|
894
1785
|
// Quién fue el origen se DICE, siempre: es la diferencia entre una
|
|
895
1786
|
// migración y una pasada de mantenimiento contra el driver activo.
|
|
896
1787
|
report.factsFrom = origin.resolved();
|
|
1788
|
+
if (stale)
|
|
1789
|
+
report.staleSource = { to: name, from: origin.resolved(), allowed: allowStale };
|
|
897
1790
|
return report;
|
|
898
1791
|
};
|
|
899
1792
|
// La pasada que escribe congela; el verificador NO (ver el docblock).
|
|
@@ -917,6 +1810,8 @@ export class AuthorizationManager {
|
|
|
917
1810
|
lapsed: await window.lapsed(),
|
|
918
1811
|
leaseMs: window.leaseMs,
|
|
919
1812
|
fence: window.fence,
|
|
1813
|
+
...(window.untilMs === undefined ? {} : { untilMs: window.untilMs }),
|
|
1814
|
+
...(window.window === undefined ? {} : { window: window.window }),
|
|
920
1815
|
};
|
|
921
1816
|
return report;
|
|
922
1817
|
}
|
|
@@ -944,7 +1839,9 @@ export class AuthorizationManager {
|
|
|
944
1839
|
* MANTENIMIENTO: los hechos se leen del propio destino por el puerto
|
|
945
1840
|
* (`enumerateFacts`), se rehace lo DERIVADO (marcador, catálogo, árbol)
|
|
946
1841
|
* y se aplica el barrido de visibilidad del invariante 18 con el árbol
|
|
947
|
-
* y el catálogo de HOY. No se inventa
|
|
1842
|
+
* y el catálogo de HOY. No se inventa ningún hecho; con `--prune` se
|
|
1843
|
+
* borran los que ya no respaldan nada (scopes que ya no resuelven,
|
|
1844
|
+
* caducados, roles/permisos retirados, y los que sobran). Un
|
|
948
1845
|
* destino activo con `hierarchyFacts` que no sepa enumerar sus hechos
|
|
949
1846
|
* es 500 `E_AUTHZ_UNSUPPORTED`: leerle `authz_*` sería justo el defecto.
|
|
950
1847
|
* 2. **`--from=<nombre>` manda**, y por eso se resuelve YA: de la
|
|
@@ -958,7 +1855,7 @@ export class AuthorizationManager {
|
|
|
958
1855
|
* de 3b-3b (`#factsEnumerator`).
|
|
959
1856
|
*/
|
|
960
1857
|
async #factsOrigin(to, target, from) {
|
|
961
|
-
if (
|
|
1858
|
+
if (this.#isMaintenancePass(to, target, from)) {
|
|
962
1859
|
if (typeof target.enumerateFacts !== 'function') {
|
|
963
1860
|
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 ` +
|
|
964
1861
|
`(hierarchyFacts), así que 'authz_assignments'/'authz_denies' NO son la fuente de verdad de sus ` +
|
|
@@ -993,10 +1890,44 @@ export class AuthorizationManager {
|
|
|
993
1890
|
});
|
|
994
1891
|
return { name: AUTHZ_TABLES_ORIGIN, authzTables: true, enumerate, resolved: () => resolvedName };
|
|
995
1892
|
}
|
|
1893
|
+
/**
|
|
1894
|
+
* **¿Es la pasada de MANTENIMIENTO?** (3b-5; punto ÚNICO desde alpha.10): el
|
|
1895
|
+
* destino es el driver ACTIVO, sus hechos viven en su backend
|
|
1896
|
+
* (`hierarchyFacts`) y nadie nombró otro origen. Es la ÚNICA forma en la que
|
|
1897
|
+
* el origen resuelto de una pasada hacia el driver activo es el propio
|
|
1898
|
+
* destino, y la usan los dos que la necesitan: `#factsOrigin` (de dónde se
|
|
1899
|
+
* leen los hechos) y el guard de la dirección rancia de `reconcile` (si ese
|
|
1900
|
+
* origen es legítimo). Dos copias de esta condición eran dos respuestas.
|
|
1901
|
+
*/
|
|
1902
|
+
#isMaintenancePass(to, target, from) {
|
|
1903
|
+
return from === undefined && this.#sameDriver(to, this.#config.default) && target.capabilities?.hierarchyFacts === true;
|
|
1904
|
+
}
|
|
1905
|
+
/**
|
|
1906
|
+
* **¿Dos claves de `drivers` son el MISMO driver?** (cierre del auditor de
|
|
1907
|
+
* alpha.10 · R4, gemelo de roles): misma clave o la MISMA factory registrada
|
|
1908
|
+
* bajo dos claves. El guard de la dirección rancia, el mantenimiento, el
|
|
1909
|
+
* `from === to` y el descarte juzgan la identidad RESUELTA, no la etiqueta:
|
|
1910
|
+
* con `drivers: { openfga: f, fga: f }` y `default: 'openfga'`, `--to=fga
|
|
1911
|
+
* --from=database` es la misma dirección rancia que `--to=openfga`.
|
|
1912
|
+
*
|
|
1913
|
+
* **Límite DECLARADO (cierre-2 · C1)**: la identidad es la REFERENCIA de la
|
|
1914
|
+
* factory, no el backend. Dos factories distintas que construyen el mismo
|
|
1915
|
+
* store (`openfga: () => new OpenFga…(…)`, `fga: () => new OpenFga…(…)`) NO son
|
|
1916
|
+
* el mismo driver para este predicado y el alias pasa el guard. Identidad de
|
|
1917
|
+
* backend declarada por el driver ⇒ roadmap 2.5.
|
|
1918
|
+
*/
|
|
1919
|
+
#sameDriver(a, b) {
|
|
1920
|
+
if (a === b)
|
|
1921
|
+
return true;
|
|
1922
|
+
const drivers = this.#config.drivers ?? {};
|
|
1923
|
+
const own = (key) => (Object.prototype.hasOwnProperty.call(drivers, key) ? drivers[key] : undefined);
|
|
1924
|
+
const fa = own(a);
|
|
1925
|
+
return fa !== undefined && fa === own(b);
|
|
1926
|
+
}
|
|
996
1927
|
/** El driver que `--from` nombra, con los dos errores de 3b-3b intactos. */
|
|
997
1928
|
async #originDriver(from, to) {
|
|
998
1929
|
const registered = Object.keys(this.#config.drivers ?? {});
|
|
999
|
-
if (from
|
|
1930
|
+
if (this.#sameDriver(from, to)) {
|
|
1000
1931
|
throw new AuthorizationConfigError(`authz:reconcile --from=${from} --to=${to}: el origen y el destino son el mismo driver. ` +
|
|
1001
1932
|
`Si lo que quieres es VERIFICAR y reparar lo derivado del driver activo, no lo digas con --from: ` +
|
|
1002
1933
|
`la pasada ya lee sus hechos de él cuando es el driver por defecto.`);
|
|
@@ -1032,7 +1963,7 @@ export class AuthorizationManager {
|
|
|
1032
1963
|
const registered = Object.keys(this.#config.drivers ?? {});
|
|
1033
1964
|
const candidates = [];
|
|
1034
1965
|
for (const candidate of registered) {
|
|
1035
|
-
if (candidate
|
|
1966
|
+
if (this.#sameDriver(candidate, to))
|
|
1036
1967
|
continue;
|
|
1037
1968
|
const driver = await this.#config.drivers[candidate]();
|
|
1038
1969
|
if (typeof driver.enumerateFacts === 'function')
|
|
@@ -1101,11 +2032,42 @@ export class AuthorizationManager {
|
|
|
1101
2032
|
* comparten ninguna no pueden interactuar en el árbol —toda dependencia
|
|
1102
2033
|
* (recolgar, cerrar un ciclo, purgar) viaja por un nodo nombrado—, así que
|
|
1103
2034
|
* el orden RELATIVO que hay que conservar es exactamente este.
|
|
2035
|
+
*
|
|
2036
|
+
* **La raíz `app` NO es clave** (2.4.0-alpha.8; panel relay 2, cuestión 1;
|
|
2037
|
+
* decisión del dueño 2026-09-10 (4)). La raíz no se mueve, no se purga y
|
|
2038
|
+
* nunca es hijo (los tres 422 de `#assertEdge`/`scopes.detached`), así que
|
|
2039
|
+
* dos cambios cuyo ÚNICO cruce sea `app` conmutan: ningún orden que
|
|
2040
|
+
* conservar (el auditor lo buscó y no lo encontró; `m2-orden.ts`). Con
|
|
2041
|
+
* `[hijo, app]` toda alta de tenant compartía clave con todas las demás:
|
|
2042
|
+
* una aparcada bajo la raíz congelaba el registro entero (5 pasadas con
|
|
2043
|
+
* `applied = []`, medido en un consumidor), dos altas concurrentes con la
|
|
2044
|
+
* receta inline dejaban la segunda `deferred` SIN nada aparcado, y
|
|
2045
|
+
* `requeue` se negaba con un conflicto falso. Se decide por REF
|
|
2046
|
+
* (`#isRootRef`), nunca por la cadena `'app'`, y en los TRES sitios que
|
|
2047
|
+
* construyen la lista: aquí, `#dependencyOf` y `requeueScopeChanges` (los
|
|
2048
|
+
* dos por `#changeScopes`); también para el bloqueo por FALLO (una sola
|
|
2049
|
+
* regla). La lista nunca queda vacía: el hijo siempre va.
|
|
1104
2050
|
*/
|
|
1105
2051
|
static #changeKeys(change) {
|
|
1106
|
-
return change.
|
|
1107
|
-
|
|
1108
|
-
|
|
2052
|
+
return _a.#changeScopes(change).map((scope) => scopeKey(scope));
|
|
2053
|
+
}
|
|
2054
|
+
/**
|
|
2055
|
+
* Los scopes de `#changeKeys` como REFERENCIAS: la lista que va al puerto
|
|
2056
|
+
* (`earlierPending`/`laterApplied`). Es la misma decisión que
|
|
2057
|
+
* `#changeKeys` y vive en un solo sitio a propósito (auditor 🟠 3 del panel
|
|
2058
|
+
* relay 2: exentar la raíz solo en las claves dejaba el mutex vivo bajo
|
|
2059
|
+
* `only`, porque el SQL de la sonda sí casa `parent_type = 'app'`).
|
|
2060
|
+
*/
|
|
2061
|
+
static #changeScopes(change) {
|
|
2062
|
+
if (change.op === 'detached')
|
|
2063
|
+
return [change.child];
|
|
2064
|
+
if (_a.#isRootRef(change.parent))
|
|
2065
|
+
return [change.child];
|
|
2066
|
+
return [change.child, change.parent];
|
|
2067
|
+
}
|
|
2068
|
+
/** ¿Es la raíz? Por REF (`type === 'app' && uuid === null`), jamás comparando la cadena `'app'`. */
|
|
2069
|
+
static #isRootRef(scope) {
|
|
2070
|
+
return scope.type === APP_SCOPE_TYPE && (scope.uuid ?? null) === null;
|
|
1109
2071
|
}
|
|
1110
2072
|
/** Lo aparcado por la outbox (si sabe aparcar), listo para el reporte. */
|
|
1111
2073
|
static async #deadLetters(outbox, limit) {
|
|
@@ -1148,6 +2110,28 @@ export class AuthorizationManager {
|
|
|
1148
2110
|
await this.#notify(event);
|
|
1149
2111
|
}
|
|
1150
2112
|
/** Cota entera positiva de las opciones del relay (500 si llega otra cosa). */
|
|
2113
|
+
/**
|
|
2114
|
+
* **Una opción desconocida es 500, nunca silencio** (2.4.0-alpha.8 · X-1;
|
|
2115
|
+
* tester del panel relay 2, §10-d). Medido en alpha.7: `requeueScopeChanges(ids,
|
|
2116
|
+
* { apply: true })` y `relayScopeChanges({ waitForLeaseMs })` se IGNORABAN
|
|
2117
|
+
* —el objeto de opciones no se validaba— y un consumidor en JS (o con
|
|
2118
|
+
* `as any`) creía que aplicaba o esperaba cuando no; el síntoma era un
|
|
2119
|
+
* `busy` o un `deferred` achacado a otra cosa. Se comprueban las CLAVES
|
|
2120
|
+
* (un valor `undefined` en una clave conocida vale: es lo que pasan los
|
|
2121
|
+
* comandos ace), y el objeto tiene que serlo.
|
|
2122
|
+
*/
|
|
2123
|
+
static #assertKnownOptions(operation, options, known) {
|
|
2124
|
+
if (options === undefined)
|
|
2125
|
+
return;
|
|
2126
|
+
if (options === null || typeof options !== 'object' || Array.isArray(options)) {
|
|
2127
|
+
throw new AuthorizationConfigError(`${operation}: las opciones deben ser un objeto { ${known.join(', ')} }; llegó ${Array.isArray(options) ? 'una lista' : String(options)}.`);
|
|
2128
|
+
}
|
|
2129
|
+
const unknown = Object.keys(options).filter((key) => !known.includes(key));
|
|
2130
|
+
if (unknown.length > 0) {
|
|
2131
|
+
throw new AuthorizationConfigError(`${operation}: opción(es) desconocida(s): ${unknown.join(', ')}. Las conocidas son ${known.join(', ')}. ` +
|
|
2132
|
+
'Una opción que no existe no se ignora: en JS creerías que aplica (o espera) cuando no. No se ha hecho nada.');
|
|
2133
|
+
}
|
|
2134
|
+
}
|
|
1151
2135
|
static #positive(value, fallback, name) {
|
|
1152
2136
|
if (value === undefined)
|
|
1153
2137
|
return fallback;
|
|
@@ -1163,7 +2147,10 @@ export class AuthorizationManager {
|
|
|
1163
2147
|
* `{}` si no hay actor: el evento no inventa autores).
|
|
1164
2148
|
*/
|
|
1165
2149
|
async #writeOptions(options, operation) {
|
|
1166
|
-
|
|
2150
|
+
// Sin cast sobre `transaction` (L-1 · 🟠 8): la barrera no lee nada del
|
|
2151
|
+
// llamante, y `transaction` solo lo consumen los tipos que lo declaran
|
|
2152
|
+
// (`ScopeTreeWriteOptions`, para ENCOLAR — nunca para decidir).
|
|
2153
|
+
await this.#assertNotFrozen(operation);
|
|
1167
2154
|
if (options?.actor !== undefined)
|
|
1168
2155
|
assertSubject(options.actor);
|
|
1169
2156
|
if (this.#config.requireActor === true && !options?.actor) {
|
|
@@ -1339,8 +2326,36 @@ export class AuthorizationManager {
|
|
|
1339
2326
|
throw new ScopeCycleError(`${operation}: ${parent.type}:${parent.uuid} desciende de ${childKey.replace('\u001f', ':')} (o es él mismo); ` +
|
|
1340
2327
|
`colgarlo cerraría un ciclo y la herencia dejaría de ser solo hacia abajo.`);
|
|
1341
2328
|
}
|
|
2329
|
+
this.#assertDepth(chain, canonicalChild, parent, operation);
|
|
1342
2330
|
return { chain, child: canonicalChild };
|
|
1343
2331
|
}
|
|
2332
|
+
/**
|
|
2333
|
+
* **El gate de profundidad, opt-in** (2.4.0-alpha.5 · R0; `scopes.maxDepth`).
|
|
2334
|
+
* La cadena del padre incluye `app`, así que su longitud ES la profundidad
|
|
2335
|
+
* a la que quedaría el hijo (saltos `parent` hasta la raíz): cero I/O de
|
|
2336
|
+
* más. Se juzga ANTES de escribir o encolar. PARCIAL a propósito: no ve la
|
|
2337
|
+
* altura del subárbol que un `moved` arrastra (ver `define_config.ts`).
|
|
2338
|
+
*/
|
|
2339
|
+
#assertDepth(chain, child, parent, operation) {
|
|
2340
|
+
const maxDepth = this.#config.scopes?.maxDepth;
|
|
2341
|
+
if (maxDepth === undefined)
|
|
2342
|
+
return;
|
|
2343
|
+
if (typeof maxDepth !== 'number' || !Number.isInteger(maxDepth) || maxDepth < 1) {
|
|
2344
|
+
throw new AuthorizationConfigError(`${operation}: scopes.maxDepth debe ser un entero >= 1 (llegó ${String(maxDepth)}); quítalo si no quieres techo.`);
|
|
2345
|
+
}
|
|
2346
|
+
if (chain.length > maxDepth) {
|
|
2347
|
+
// El `message` es para el TENANT (2.4.0-alpha.8, cuestión 7.2): sin el
|
|
2348
|
+
// nombre del backend ni sus constantes; eso va en `details`, para el log.
|
|
2349
|
+
throw new ScopeDepthLimitError(`${operation}: colgar ${child.type}:${child.uuid} de ${parent.type}:${parent.uuid ?? ''} lo dejaría a ${chain.length} ` +
|
|
2350
|
+
`saltos de la raíz y el techo es scopes.maxDepth=${maxDepth}. No se ha escrito ni encolado nada. Aplana el árbol ` +
|
|
2351
|
+
`(o pide al operador que suba el techo).`, {
|
|
2352
|
+
depth: chain.length,
|
|
2353
|
+
maxDepth,
|
|
2354
|
+
why: 'el modelo facts del driver openfga no resuelve de forma fiable más allá de FACTS_MAX_RESOLVE_DEPTH: pasado él, authorize es 503 para todo ese subárbol',
|
|
2355
|
+
server: 'para subir el techo hay que subir también OPENFGA_RESOLVE_NODE_LIMIT en el servidor',
|
|
2356
|
+
});
|
|
2357
|
+
}
|
|
2358
|
+
}
|
|
1344
2359
|
// La identidad se valida AQUÍ, antes de resolver siquiera el driver: una
|
|
1345
2360
|
// pregunta mal formada (uuid ausente, `{app, uuid}`, slug con `~`) es 422
|
|
1346
2361
|
// sin tocar catálogo, árbol ni backend, y sin que el hook `onWrite` audite
|
|
@@ -1594,6 +2609,7 @@ export class AuthorizationManager {
|
|
|
1594
2609
|
* Devuelve el rol y notifica `role_defined`.
|
|
1595
2610
|
*/
|
|
1596
2611
|
async defineScopedRole(actor, ownerScope, spec, options) {
|
|
2612
|
+
this.#assertNoTransaction(options, 'defineScopedRole');
|
|
1597
2613
|
const who = await this.#requireActor(actor, 'defineScopedRole');
|
|
1598
2614
|
this.#assertOwnerScope(ownerScope, 'defineScopedRole');
|
|
1599
2615
|
const parsed = this.#parseScopedRoleSpec(spec);
|
|
@@ -1693,6 +2709,7 @@ export class AuthorizationManager {
|
|
|
1693
2709
|
* escribe ni notifica (idempotente). Notifica `role_updated`.
|
|
1694
2710
|
*/
|
|
1695
2711
|
async updateScopedRole(actor, roleUuid, changes, options) {
|
|
2712
|
+
this.#assertNoTransaction(options, 'updateScopedRole');
|
|
1696
2713
|
const who = await this.#requireActor(actor, 'updateScopedRole');
|
|
1697
2714
|
assertCatalogUuid('rol', roleUuid);
|
|
1698
2715
|
const parsed = this.#parseScopedRoleChanges(changes);
|
|
@@ -1786,6 +2803,7 @@ export class AuthorizationManager {
|
|
|
1786
2803
|
* su owner. Notifica `role_purged`. No necesita `listDenies`.
|
|
1787
2804
|
*/
|
|
1788
2805
|
async deleteScopedRole(actor, roleUuid, options) {
|
|
2806
|
+
this.#assertNoTransaction(options, 'deleteScopedRole');
|
|
1789
2807
|
const who = await this.#requireActor(actor, 'deleteScopedRole');
|
|
1790
2808
|
assertCatalogUuid('rol', roleUuid);
|
|
1791
2809
|
const driver = await this.driver();
|
|
@@ -2397,13 +3415,15 @@ export class AuthorizationManager {
|
|
|
2397
3415
|
* `E_AUTHZ_AMBIGUOUS_ROLE`, nunca «el más cercano gana»).
|
|
2398
3416
|
*/
|
|
2399
3417
|
async grant(subject, role, scope, options) {
|
|
3418
|
+
await this.#assertTransactionalWrite(options, 'grant');
|
|
2400
3419
|
const actor = await this.#writeOptions(options, 'grant');
|
|
2401
3420
|
assertIdentity({ subject, role, scope, expiresAt: options?.expiresAt });
|
|
2402
3421
|
await this.#assertWithin(scope, options, 'grant');
|
|
2403
3422
|
// 3E · Q7: el evento lleva el rol RESUELTO (uuid + slug + nivel + owner),
|
|
2404
3423
|
// no la pregunta cruda. Solo se resuelve si hay hook que lo vaya a leer.
|
|
2405
3424
|
const roles = await this.#resolvedRoles(role, scope, 'grant');
|
|
2406
|
-
const
|
|
3425
|
+
const transactional = this.#transactional(options);
|
|
3426
|
+
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))) ??
|
|
2407
3427
|
// Un driver de terceros que aún devuelva `void`: la firma promete un
|
|
2408
3428
|
// `GrantOutcome` y no miente (E1). Sin lectura previa no hay caducidad
|
|
2409
3429
|
// anterior que contar: es lo que pidió el llamante, y `existed: false`.
|
|
@@ -2419,6 +3439,7 @@ export class AuthorizationManager {
|
|
|
2419
3439
|
expiresAt: outcome.expiresAt,
|
|
2420
3440
|
previousExpiresAt: outcome.previousExpiresAt,
|
|
2421
3441
|
...actor,
|
|
3442
|
+
...transactional,
|
|
2422
3443
|
});
|
|
2423
3444
|
}
|
|
2424
3445
|
else {
|
|
@@ -2429,6 +3450,7 @@ export class AuthorizationManager {
|
|
|
2429
3450
|
roles,
|
|
2430
3451
|
expiresAt: outcome.expiresAt,
|
|
2431
3452
|
...actor,
|
|
3453
|
+
...transactional,
|
|
2432
3454
|
});
|
|
2433
3455
|
}
|
|
2434
3456
|
return outcome;
|
|
@@ -2440,27 +3462,38 @@ export class AuthorizationManager {
|
|
|
2440
3462
|
* rol.
|
|
2441
3463
|
*/
|
|
2442
3464
|
async revoke(subject, role, scope, options) {
|
|
3465
|
+
await this.#assertTransactionalWrite(options, 'revoke');
|
|
2443
3466
|
const actor = await this.#writeOptions(options, 'revoke');
|
|
2444
3467
|
assertIdentity({ subject, role, scope });
|
|
2445
3468
|
await this.#assertWithin(scope, options, 'revoke');
|
|
2446
|
-
const event = {
|
|
2447
|
-
|
|
3469
|
+
const event = {
|
|
3470
|
+
action: 'revoked',
|
|
3471
|
+
subject,
|
|
3472
|
+
scope,
|
|
3473
|
+
roles: await this.#resolvedRoles(role, scope, 'revoke'),
|
|
3474
|
+
...actor,
|
|
3475
|
+
...this.#transactional(options),
|
|
3476
|
+
};
|
|
3477
|
+
// L-2: las opciones viajan al driver (`transaction`, si la puerta la dejó pasar) — como en `grant`.
|
|
3478
|
+
await this.#write(event, async () => (await this.driver()).revoke(subject, role, scope, options));
|
|
2448
3479
|
await this.#notify(event);
|
|
2449
3480
|
}
|
|
2450
3481
|
async deny(subject, permission, scope, options) {
|
|
3482
|
+
await this.#assertTransactionalWrite(options, 'deny');
|
|
2451
3483
|
const actor = await this.#writeOptions(options, 'deny');
|
|
2452
3484
|
assertIdentity({ subject, permission, scope });
|
|
2453
3485
|
await this.#assertWithin(scope, options, 'deny');
|
|
2454
|
-
const event = { action: 'denied', subject, scope, permission, ...actor };
|
|
2455
|
-
await this.#write(event, async () => (await this.driver()).deny(subject, permission, scope));
|
|
3486
|
+
const event = { action: 'denied', subject, scope, permission, ...actor, ...this.#transactional(options) };
|
|
3487
|
+
await this.#write(event, async () => (await this.driver()).deny(subject, permission, scope, options));
|
|
2456
3488
|
await this.#notify(event);
|
|
2457
3489
|
}
|
|
2458
3490
|
async removeDeny(subject, permission, scope, options) {
|
|
3491
|
+
await this.#assertTransactionalWrite(options, 'removeDeny');
|
|
2459
3492
|
const actor = await this.#writeOptions(options, 'removeDeny');
|
|
2460
3493
|
assertIdentity({ subject, permission, scope });
|
|
2461
3494
|
await this.#assertWithin(scope, options, 'removeDeny');
|
|
2462
|
-
const event = { action: 'deny_removed', subject, scope, permission, ...actor };
|
|
2463
|
-
await this.#write(event, async () => (await this.driver()).removeDeny(subject, permission, scope));
|
|
3495
|
+
const event = { action: 'deny_removed', subject, scope, permission, ...actor, ...this.#transactional(options) };
|
|
3496
|
+
await this.#write(event, async () => (await this.driver()).removeDeny(subject, permission, scope, options));
|
|
2464
3497
|
await this.#notify(event);
|
|
2465
3498
|
}
|
|
2466
3499
|
/**
|
|
@@ -2711,6 +3744,17 @@ export class AuthorizationManager {
|
|
|
2711
3744
|
* `indeterminate: true`, para que quien audita registre "puede haber
|
|
2712
3745
|
* ocurrido" en vez de nada. Cualquier otro fallo (422, conexión rechazada)
|
|
2713
3746
|
* significa que la escritura no ocurrió y se propaga sin evento.
|
|
3747
|
+
*
|
|
3748
|
+
* **Dentro de la transacción del llamante (L-3) el deadline sigue siendo
|
|
3749
|
+
* `indeterminate: true`**, y el evento lleva además `transactional: true`.
|
|
3750
|
+
* El auditor del panel `{trx}` (🟡 12) objetó que ahí «el rollback la
|
|
3751
|
+
* determina»; pero lo que determina el rollback lo determina el LLAMANTE
|
|
3752
|
+
* y el paquete no lo ve: en el instante del 503 la sentencia puede haber
|
|
3753
|
+
* aterrizado en la transacción (SQLite no cancela; en PG la transacción
|
|
3754
|
+
* queda abortada y ya no puede confirmar; en MySQL la sentencia se mata y
|
|
3755
|
+
* la transacción sigue viva) y el llamante puede confirmar o no. Lo
|
|
3756
|
+
* honesto es el mismo «no lo sé» de siempre más la marca de que la última
|
|
3757
|
+
* palabra es su commit. Medido por motor en `database_transaction.spec.ts`.
|
|
2714
3758
|
*/
|
|
2715
3759
|
async #write(event, fn) {
|
|
2716
3760
|
try {
|