@jantstack/adonis-authz 2.4.0-alpha.9 → 2.5.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +296 -15
- package/build/commands/authz_freeze.js +1 -1
- package/build/commands/authz_freeze.js.map +1 -1
- package/build/commands/authz_reconcile.d.ts +6 -2
- package/build/commands/authz_reconcile.d.ts.map +1 -1
- package/build/commands/authz_reconcile.js +129 -13
- package/build/commands/authz_reconcile.js.map +1 -1
- package/build/commands/authz_relations_reconcile.d.ts +66 -19
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -1
- package/build/commands/authz_relations_reconcile.js +212 -39
- package/build/commands/authz_relations_reconcile.js.map +1 -1
- package/build/commands/authz_scopes_relay.d.ts.map +1 -1
- package/build/commands/authz_scopes_relay.js +81 -1
- package/build/commands/authz_scopes_relay.js.map +1 -1
- 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/index.d.ts +6 -4
- package/build/index.d.ts.map +1 -1
- package/build/index.js +5 -3
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts +1 -1
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +2 -1
- package/build/providers/authz_provider.js.map +1 -1
- package/build/src/define_config.d.ts +9 -0
- 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.map +1 -1
- package/build/src/drivers/database_driver.js +1 -0
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +18 -6
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/database_relations_driver.js +91 -44
- package/build/src/drivers/database_relations_driver.js.map +1 -1
- package/build/src/drivers/openfga_driver.d.ts +16 -1
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +66 -20
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_relations_driver.d.ts +16 -4
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_relations_driver.js +71 -34
- package/build/src/drivers/openfga_relations_driver.js.map +1 -1
- package/build/src/errors.d.ts +50 -0
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +50 -0
- package/build/src/errors.js.map +1 -1
- package/build/src/freeze.d.ts +63 -4
- package/build/src/freeze.d.ts.map +1 -1
- package/build/src/freeze.js +107 -18
- package/build/src/freeze.js.map +1 -1
- package/build/src/identity.d.ts +120 -9
- package/build/src/identity.d.ts.map +1 -1
- package/build/src/identity.js +164 -12
- package/build/src/identity.js.map +1 -1
- package/build/src/manager.d.ts +64 -2
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +262 -35
- package/build/src/manager.js.map +1 -1
- package/build/src/openfga.d.ts +1 -1
- package/build/src/openfga.d.ts.map +1 -1
- package/build/src/openfga.js +1 -1
- package/build/src/openfga.js.map +1 -1
- package/build/src/relations/check_many.d.ts +17 -0
- package/build/src/relations/check_many.d.ts.map +1 -0
- package/build/src/relations/check_many.js +57 -0
- package/build/src/relations/check_many.js.map +1 -0
- package/build/src/relations/manager.d.ts +20 -0
- package/build/src/relations/manager.d.ts.map +1 -1
- package/build/src/relations/manager.js +84 -13
- package/build/src/relations/manager.js.map +1 -1
- package/build/src/relations/reconcile.d.ts +67 -0
- package/build/src/relations/reconcile.d.ts.map +1 -1
- package/build/src/relations/reconcile.js +123 -30
- package/build/src/relations/reconcile.js.map +1 -1
- package/build/src/relations/tuple_key.d.ts +25 -0
- package/build/src/relations/tuple_key.d.ts.map +1 -0
- package/build/src/relations/tuple_key.js +60 -0
- package/build/src/relations/tuple_key.js.map +1 -0
- package/build/src/shared/backend_guard.d.ts +30 -11
- package/build/src/shared/backend_guard.d.ts.map +1 -1
- package/build/src/shared/backend_guard.js +45 -15
- 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/testing/main.d.ts +3 -1
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +2 -1
- 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 +14 -0
- package/build/src/testing/relations_contract.d.ts.map +1 -1
- package/build/src/testing/relations_contract.js +297 -21
- package/build/src/testing/relations_contract.js.map +1 -1
- package/build/src/testing/test_clock.d.ts +15 -0
- package/build/src/testing/test_clock.d.ts.map +1 -0
- package/build/src/testing/test_clock.js +65 -0
- package/build/src/testing/test_clock.js.map +1 -0
- package/build/src/types.d.ts +108 -9
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js.map +1 -1
- package/package.json +5 -1
package/build/src/manager.js
CHANGED
|
@@ -1,15 +1,16 @@
|
|
|
1
1
|
var _a;
|
|
2
2
|
import { Exception } from '@adonisjs/core/exceptions';
|
|
3
3
|
import { v7 as uuidv7 } from 'uuid';
|
|
4
|
-
import {
|
|
4
|
+
import { activeRelationsDriverKey } from './define_config.js';
|
|
5
|
+
import { assertCatalogUuid, assertIdentity, assertScope, assertScopeType, assertSubject, assertValidSlug, chainKeysFrom, normalizeRoleQuery, scopeFromKey, scopeKey, } 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 { assertKnownScope, isAuthzError, resolveChain, rootOnlyResolver } from './shared/backend_guard.js';
|
|
8
|
+
import { DEFAULT_FREEZE_LEASE_MS, acquireFreezeOutcome, UNKNOWN_HOLDER, freezeIsLive, freezeKindOf, readFreezeRow, assertNotFrozenRow, releaseFreeze, renewFreeze, } from './freeze.js';
|
|
9
|
+
import { assertKnownScope, canonicalScopeTargets, 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, 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, } from './errors.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';
|
|
13
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';
|
|
@@ -20,12 +21,26 @@ const ROLE_DESCRIPTION_MAX = 500;
|
|
|
20
21
|
function describeAnswer(answer) {
|
|
21
22
|
if (!Array.isArray(answer))
|
|
22
23
|
return `${answer === null ? 'null' : typeof answer} (no es un array)`;
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
24
|
+
for (let i = 0; i < answer.length; i++) {
|
|
25
|
+
if (!(i in answer))
|
|
26
|
+
return `un array de ${answer.length} con un HUECO en la posición ${i}`;
|
|
27
|
+
if (typeof answer[i] !== 'boolean')
|
|
28
|
+
return `un array de ${answer.length} con un elemento que no es boolean en la posición ${i} (${typeof answer[i]})`;
|
|
26
29
|
}
|
|
27
30
|
return `un array de ${answer.length}`;
|
|
28
31
|
}
|
|
32
|
+
/**
|
|
33
|
+
* ¿Cada POSICIÓN del array es un boolean? Por índice, nunca con `some`/`every`
|
|
34
|
+
* (que saltan los huecos de un array disperso: 🟡 A3 del auditor del lote A;
|
|
35
|
+
* gemelo de `checkMany` en `src/relations/manager.ts`).
|
|
36
|
+
*/
|
|
37
|
+
function everyPositionBoolean(answers) {
|
|
38
|
+
for (let i = 0; i < answers.length; i++) {
|
|
39
|
+
if (typeof answers[i] !== 'boolean')
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
29
44
|
/**
|
|
30
45
|
* Configs que ya recibieron el aviso de seguridad opt-in (B7/B1): una vez
|
|
31
46
|
* por config, no por instancia — `forRequest()` construye managers hijos con
|
|
@@ -406,12 +421,30 @@ export class AuthorizationManager {
|
|
|
406
421
|
const finalReason = reason ?? 'una operación de plataforma';
|
|
407
422
|
const holder = `${kind}:${process.pid}:${randomBytes(4).toString('hex')}`;
|
|
408
423
|
const nowMs = root.#wallMs();
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
424
|
+
// alpha.10 · S2 (+ cierre del auditor · R2): una ventana de OPERADOR
|
|
425
|
+
// caducada no se pisa en silencio. Una fila con el lease vencido se toma
|
|
426
|
+
// encima (así la flota vuelve sola tras un SIGKILL de una pasada), pero la
|
|
427
|
+
// del operador es EVIDENCIA: pisarla y soltarla después la deja a NULL y
|
|
428
|
+
// borra el único rastro de que las escrituras volvieron a entrar a mitad
|
|
429
|
+
// del cutover. La exclusión vive DENTRO del UPDATE de `acquireFreezeOutcome`
|
|
430
|
+
// (atómica): aquí solo se lee el veredicto y la fila para redactarlo — un
|
|
431
|
+
// «compruebo y luego tomo» con dos lecturas del reloj era un TOCTOU.
|
|
432
|
+
const outcome = await acquireFreezeOutcome({ reason: finalReason, holder, untilMs: leaseMs === null ? null : nowMs + leaseMs, nowMs }, { driver: this.#config.default });
|
|
433
|
+
if (outcome.status === 'expired-operator') {
|
|
434
|
+
const row = outcome.row;
|
|
435
|
+
const when = row.untilMs === null ? '?' : new Date(row.untilMs).toISOString();
|
|
436
|
+
throw new OperatorWindowExpiredError(`freeze (${reason ?? kind}): la ventana del OPERADOR (${row.holder ?? '?'}, ${row.reason}, fence ` +
|
|
437
|
+
`${row.fence}) CADUCÓ el ${when} y nadie la cerró: desde entonces las escrituras volvieron a entrar en ` +
|
|
438
|
+
`toda la flota. No se toma una ventana propia encima —borraría ese rastro al soltarla y la pasada se ` +
|
|
439
|
+
`certificaría como si hubiera corrido dentro del cutover—. Reconócela con authz:unfreeze (dice cuánto ` +
|
|
440
|
+
`tiempo estuvo abierta y limpia la fila) y reabre la ventana con authz:freeze antes de repetir.`);
|
|
441
|
+
}
|
|
442
|
+
if (outcome.status === 'held') {
|
|
443
|
+
const row = outcome.row;
|
|
412
444
|
throw new FreezeHeldError(`freeze: ya hay un freeze VIVO de otro dueño (${row.holder ?? '?'}, fence ${row.fence}, motivo: ${row.reason ?? '?'}). ` +
|
|
413
445
|
`Espera a que termine, o levántalo con authz:unfreeze si su proceso murió sin lease.`);
|
|
414
446
|
}
|
|
447
|
+
const token = outcome.token;
|
|
415
448
|
const held = { token, reason: finalReason, kind, leaseMs, timer: null, lapsed: false };
|
|
416
449
|
if (leaseMs !== null) {
|
|
417
450
|
const interval = Math.max(250, Math.floor(leaseMs / 3));
|
|
@@ -472,6 +505,39 @@ export class AuthorizationManager {
|
|
|
472
505
|
get frozen() {
|
|
473
506
|
return this.#root().#heldFreeze !== null;
|
|
474
507
|
}
|
|
508
|
+
/**
|
|
509
|
+
* La clave (en `relations.drivers`) del driver de RELACIONES activo:
|
|
510
|
+
* `relations.default ?? default` (2.4.0-alpha.10). La lee el guard de la
|
|
511
|
+
* dirección rancia de `runRelationsReconcile`; desde el cierre del auditor
|
|
512
|
+
* (R4) el llamante ya no puede contradecirla con un `activeKey` propio.
|
|
513
|
+
*/
|
|
514
|
+
relationsDriverKey() {
|
|
515
|
+
return activeRelationsDriverKey(this.#root().#config);
|
|
516
|
+
}
|
|
517
|
+
/** Las claves registradas en `relations.drivers` del config de ESTE manager (vacío si no hay relaciones). */
|
|
518
|
+
relationsDriverKeys() {
|
|
519
|
+
return Object.keys(this.#root().#config.relations?.drivers ?? {});
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* La factory registrada bajo `key` en `relations.drivers` de ESTE manager, o
|
|
523
|
+
* `undefined` (cierre del auditor de alpha.10 · R4). `runRelationsReconcile`
|
|
524
|
+
* resuelve el origen y el destino por aquí —del MISMO config que decide el
|
|
525
|
+
* activo— y juzga la identidad por la factory resuelta, no por la etiqueta:
|
|
526
|
+
* dos claves con la misma factory son el mismo driver (y dos factories DISTINTAS
|
|
527
|
+
* sobre el mismo backend NO: límite declarado, cierre-2 · C1).
|
|
528
|
+
*
|
|
529
|
+
* **API de PLATAFORMA, junto a `driver()`** (cierre-2 · C6): con esta factory
|
|
530
|
+
* cualquier código que tenga el manager —vistas de `forRequest()` incluidas—
|
|
531
|
+
* construye un driver de relaciones NO activo y escribe en él sin `assertWrite`,
|
|
532
|
+
* `requireActor`, freeze ni `onRelationWrite`. No añade capacidad (el config ya
|
|
533
|
+
* da las factories), pero es otra cosa que buscar en una revisión.
|
|
534
|
+
*/
|
|
535
|
+
relationsDriverFactory(key) {
|
|
536
|
+
const drivers = this.#root().#config.relations?.drivers;
|
|
537
|
+
if (drivers === undefined || !Object.prototype.hasOwnProperty.call(drivers, key))
|
|
538
|
+
return undefined;
|
|
539
|
+
return drivers[key];
|
|
540
|
+
}
|
|
475
541
|
/** El freeze VIVO de la fila compartida, o `null`. Lo lee cualquiera; solo el token lo levanta. */
|
|
476
542
|
async freezeStatus() {
|
|
477
543
|
const row = await readFreezeRow({ driver: this.#config.default });
|
|
@@ -479,12 +545,42 @@ export class AuthorizationManager {
|
|
|
479
545
|
return null;
|
|
480
546
|
return {
|
|
481
547
|
reason: row.reason,
|
|
482
|
-
holder: row.holder ??
|
|
548
|
+
holder: row.holder ?? UNKNOWN_HOLDER,
|
|
483
549
|
kind: freezeKindOf(row.holder),
|
|
484
550
|
untilMs: row.untilMs,
|
|
485
551
|
fence: row.fence,
|
|
486
552
|
};
|
|
487
553
|
}
|
|
554
|
+
/**
|
|
555
|
+
* **La ÚLTIMA ventana de la fila, también la CADUCADA** (2.4.0-alpha.10, 🟠 2
|
|
556
|
+
* del sexto informe de COGNITIV). `freezeStatus()` responde solo lo VIVO —y
|
|
557
|
+
* así lo siguen usando la barrera y los comandos—, de modo que una ventana
|
|
558
|
+
* cuyo lease venció era indistinguible de «nunca hubo»: `authz:unfreeze`
|
|
559
|
+
* decía éxito y el último paso del cutover no delataba que las escrituras
|
|
560
|
+
* habían vuelto a entrar a mitad. Se distingue por la fila: `releaseFreeze`
|
|
561
|
+
* pone el motivo a `null` al levantar, así que motivo puesto + `untilMs` en
|
|
562
|
+
* el pasado es una ventana que CADUCÓ sin que nadie la cerrase.
|
|
563
|
+
*
|
|
564
|
+
* `null` = nunca hubo o ya se levantó; `expired: false` = viva (lo mismo que
|
|
565
|
+
* `freezeStatus()`); `expired: true` = caducada. `checkedAtMs` es el instante
|
|
566
|
+
* del reloj del config con el que se decidió.
|
|
567
|
+
*/
|
|
568
|
+
async lastFreezeStatus() {
|
|
569
|
+
const row = await readFreezeRow({ driver: this.#config.default });
|
|
570
|
+
if (row.reason === null)
|
|
571
|
+
return null;
|
|
572
|
+
const nowMs = this.#root().#wallMs();
|
|
573
|
+
return {
|
|
574
|
+
reason: row.reason,
|
|
575
|
+
holder: row.holder ?? UNKNOWN_HOLDER,
|
|
576
|
+
kind: freezeKindOf(row.holder),
|
|
577
|
+
untilMs: row.untilMs,
|
|
578
|
+
fence: row.fence,
|
|
579
|
+
expired: !freezeIsLive(row, nowMs),
|
|
580
|
+
// El instante (reloj del config) con el que se decidió `expired`.
|
|
581
|
+
checkedAtMs: nowMs,
|
|
582
|
+
};
|
|
583
|
+
}
|
|
488
584
|
/**
|
|
489
585
|
* `freeze()` + `finally unfreeze(token)`. El `finally` es la parte que
|
|
490
586
|
* importa: una migración que revienta a la mitad no puede dejar la
|
|
@@ -502,7 +598,13 @@ export class AuthorizationManager {
|
|
|
502
598
|
operatorAsContext: options.operatorAsContext === true,
|
|
503
599
|
});
|
|
504
600
|
try {
|
|
505
|
-
return await fn({
|
|
601
|
+
return await fn({
|
|
602
|
+
fence: context.fence,
|
|
603
|
+
leaseMs: context.leaseMs,
|
|
604
|
+
lapsed: context.lapsed,
|
|
605
|
+
...(context.untilMs === undefined ? {} : { untilMs: context.untilMs }),
|
|
606
|
+
...(context.window === undefined ? {} : { window: context.window }),
|
|
607
|
+
});
|
|
506
608
|
}
|
|
507
609
|
finally {
|
|
508
610
|
await context.release();
|
|
@@ -528,6 +630,7 @@ export class AuthorizationManager {
|
|
|
528
630
|
return {
|
|
529
631
|
fence: outer.token.fence,
|
|
530
632
|
leaseMs: outer.leaseMs,
|
|
633
|
+
window: 'enclosing',
|
|
531
634
|
release: async () => { },
|
|
532
635
|
lapsed: () => root.#tokenLapsed(outer.token, outer.lapsed),
|
|
533
636
|
};
|
|
@@ -536,9 +639,14 @@ export class AuthorizationManager {
|
|
|
536
639
|
const status = await this.freezeStatus();
|
|
537
640
|
if (status !== null && status.kind === 'operator') {
|
|
538
641
|
const token = { fence: status.fence, holder: status.holder };
|
|
642
|
+
// alpha.10 · 3: la caducidad REAL de la ventana adoptada. Hasta alpha.9
|
|
643
|
+
// `leaseMs: null` fijo — el reporte decía «sin caducidad» de una ventana
|
|
644
|
+
// abierta con `--lease-ms` (la certificación sí leía la fila).
|
|
539
645
|
return {
|
|
540
646
|
fence: status.fence,
|
|
541
|
-
leaseMs: null,
|
|
647
|
+
leaseMs: status.untilMs === null ? null : Math.max(0, status.untilMs - root.#wallMs()),
|
|
648
|
+
untilMs: status.untilMs,
|
|
649
|
+
window: 'operator',
|
|
542
650
|
release: async () => { },
|
|
543
651
|
lapsed: () => root.#tokenLapsed(token, false),
|
|
544
652
|
};
|
|
@@ -549,6 +657,7 @@ export class AuthorizationManager {
|
|
|
549
657
|
return {
|
|
550
658
|
fence: token.fence,
|
|
551
659
|
leaseMs: held.leaseMs,
|
|
660
|
+
window: 'own',
|
|
552
661
|
release: async () => {
|
|
553
662
|
try {
|
|
554
663
|
await this.unfreeze(token);
|
|
@@ -731,14 +840,19 @@ export class AuthorizationManager {
|
|
|
731
840
|
//
|
|
732
841
|
// Y cuando NO hay cadena —la fila ya no existe, que es el orden
|
|
733
842
|
// soportado de `detached` (3F · S1)— no hay con qué canonizar: se
|
|
734
|
-
// purgan
|
|
735
|
-
//
|
|
736
|
-
//
|
|
737
|
-
//
|
|
738
|
-
//
|
|
739
|
-
//
|
|
740
|
-
|
|
741
|
-
|
|
843
|
+
// purgan las ortografías de las que el uuid del llamante puede ser
|
|
844
|
+
// alias (`scopeSpellings`, 3b-2h · 🟠 3: lo que PG funde,
|
|
845
|
+
// `pgFusedSpellings`) MENOS las que el resolutor reconozca como un
|
|
846
|
+
// scope VIVO distinto del pedido (cierre-3 del lote A · 🟡 C2-2: con
|
|
847
|
+
// ids de texto, la canónica de un alias puede ser la unit viva de otro
|
|
848
|
+
// tenant, y `purgeScope` en `database` le borraría los denies). Con la
|
|
849
|
+
// fila viva esto es exactamente una, la de la tabla. El punto de paso
|
|
850
|
+
// es ÚNICO y compartido con `revoke`/`removeDeny` de los dos drivers
|
|
851
|
+
// (`canonicalScopeTargets`); un resolutor que falla al contrastar es
|
|
852
|
+
// 503, nunca «purgar por si acaso». Antes se usaba la del llamante a
|
|
853
|
+
// secas: `purgeScope` demostraba cero sobre un objeto que no existe,
|
|
854
|
+
// devolvía OK, y el scope real seguía concediendo para siempre.
|
|
855
|
+
const targets = await canonicalScopeTargets(this.#freshResolver(), child, 'scopes.detached');
|
|
742
856
|
const outbox = this.#outbox();
|
|
743
857
|
if (outbox) {
|
|
744
858
|
// La identidad se resuelve AQUÍ, con la fila del consumidor todavía
|
|
@@ -857,12 +971,13 @@ export class AuthorizationManager {
|
|
|
857
971
|
throw new AuthorizationConfigError(`authz:scopes:relay: deadDetail solo admite 'full' (llegó ${JSON.stringify(options.deadDetail)}); sin él, bajo only, dead[] va sin error.`);
|
|
858
972
|
}
|
|
859
973
|
const inputs = await _a.#relayInputs(outbox, options.only, batchSize, options.deadDetail);
|
|
860
|
-
const { only, filter, dead, deadCount, deadEntries,
|
|
974
|
+
const { only, filter, dead, deadCount, deadEntries, unseenOf } = inputs;
|
|
861
975
|
if (dryRun) {
|
|
862
976
|
const batch = await outbox.pending(limit, undefined, filter);
|
|
863
977
|
_a.#assertOnlyHonoured(batch, only);
|
|
864
978
|
const extra = batch.length >= limit ? true : false;
|
|
865
979
|
const seen = new Set([...batch.map((b) => String(b.id)), ...deadEntries.map((d) => String(d.id))]);
|
|
980
|
+
const unseen = await unseenOf(seen);
|
|
866
981
|
// La edad es GLOBAL: sin filtro la cabeza es `batch[0]`; con `only` hay que mirar la cola entera.
|
|
867
982
|
const queue = filter === undefined ? this.#queueOf(batch[0]) : (await this.#queueStatus(outbox)).queue;
|
|
868
983
|
return {
|
|
@@ -878,7 +993,7 @@ export class AuthorizationManager {
|
|
|
878
993
|
dryRun: true,
|
|
879
994
|
wouldApply: batch.map((item) => ({ id: item.id, change: item.change, attempts: item.attempts })),
|
|
880
995
|
entries: deadEntries,
|
|
881
|
-
|
|
996
|
+
...unseen,
|
|
882
997
|
};
|
|
883
998
|
}
|
|
884
999
|
// El lease del escritor ÚNICO. Una outbox que no sabe darlo se comporta
|
|
@@ -919,8 +1034,14 @@ export class AuthorizationManager {
|
|
|
919
1034
|
// llegar a `batchSize`). Una sola lectura de más, y solo si no cabía.
|
|
920
1035
|
const census = dead.length < batchSize ? dead : await _a.#deadLetters(outbox, PARKED_CENSUS_MAX);
|
|
921
1036
|
const deadCount = census.length;
|
|
922
|
-
/**
|
|
923
|
-
|
|
1037
|
+
/**
|
|
1038
|
+
* Lo aparcado que se pidió (con `only`) o todo lo aparcado, para `entries`.
|
|
1039
|
+
* Cierre-2 · C2 (gemelo): con `only` sale del CENSO completo si la lectura se
|
|
1040
|
+
* cortó en `batchSize` — si no, un id pedido aparcado más allá del primer lote
|
|
1041
|
+
* era `missing` («no estaba ni pendiente ni aparcado») estando aparcado.
|
|
1042
|
+
*/
|
|
1043
|
+
const deadSource = only !== undefined && read.length >= batchSize ? census : read;
|
|
1044
|
+
const deadEntries = deadSource
|
|
924
1045
|
.filter((item) => only === undefined || only.has(String(item.id)))
|
|
925
1046
|
.map((item) => ({
|
|
926
1047
|
id: item.id,
|
|
@@ -929,8 +1050,29 @@ export class AuthorizationManager {
|
|
|
929
1050
|
attempts: item.attempts,
|
|
930
1051
|
...(item.error === undefined ? {} : { reason: item.error }),
|
|
931
1052
|
}));
|
|
932
|
-
|
|
933
|
-
|
|
1053
|
+
/**
|
|
1054
|
+
* **Qué fue de los ids pedidos que la pasada NO vio** (cierre-2 de alpha.10 ·
|
|
1055
|
+
* C2). Hasta el cierre-2 todos eran `missing`; pero un id fuera del lote porque
|
|
1056
|
+
* la pasada alcanzó `limit` sigue PENDIENTE. Se pregunta a la cola —filtrada por
|
|
1057
|
+
* esos ids, sin límite propio, UNA lectura y solo si hay alguno sin ver—: lo que
|
|
1058
|
+
* sigue pendiente es `notEvaluated`; el resto, `missing` (no está en la cola: lo
|
|
1059
|
+
* aparcado ya está en `seen`).
|
|
1060
|
+
*/
|
|
1061
|
+
const unseenOf = async (seen) => {
|
|
1062
|
+
if (only === undefined)
|
|
1063
|
+
return { missing: [], notEvaluated: [] };
|
|
1064
|
+
const unseen = [...only.values()].filter((id) => !seen.has(id)).map((id) => _a.#rawId(onlyIds, id));
|
|
1065
|
+
if (unseen.length === 0)
|
|
1066
|
+
return { missing: [], notEvaluated: [] };
|
|
1067
|
+
const stillPending = await outbox.pending(unseen.length, undefined, { ids: unseen });
|
|
1068
|
+
_a.#assertOnlyHonoured(stillPending, new Set(unseen.map(String)));
|
|
1069
|
+
const pendingIds = new Set(stillPending.map((item) => String(item.id)));
|
|
1070
|
+
return {
|
|
1071
|
+
missing: unseen.filter((id) => !pendingIds.has(String(id))),
|
|
1072
|
+
notEvaluated: unseen.filter((id) => pendingIds.has(String(id))),
|
|
1073
|
+
};
|
|
1074
|
+
};
|
|
1075
|
+
return { only, filter, dead, census, deadCount, deadEntries, unseenOf };
|
|
934
1076
|
}
|
|
935
1077
|
/** El reporte de una pasada que NO entró: otra tiene el lease. Nada aplicado, nada tocado. */
|
|
936
1078
|
async #busyReport(outbox, inputs) {
|
|
@@ -949,6 +1091,7 @@ export class AuthorizationManager {
|
|
|
949
1091
|
wouldApply: [],
|
|
950
1092
|
entries: inputs.deadEntries,
|
|
951
1093
|
missing: [],
|
|
1094
|
+
notEvaluated: [],
|
|
952
1095
|
};
|
|
953
1096
|
}
|
|
954
1097
|
/**
|
|
@@ -1020,7 +1163,7 @@ export class AuthorizationManager {
|
|
|
1020
1163
|
* (auditor 🟠 5: tomar el lease dos veces era `busy` en verde).
|
|
1021
1164
|
*/
|
|
1022
1165
|
async #drainScopeChanges(outbox, input) {
|
|
1023
|
-
const { limit, batchSize, only, filter, dead, census, deadCount, deadEntries,
|
|
1166
|
+
const { limit, batchSize, only, filter, dead, census, deadCount, deadEntries, unseenOf } = input;
|
|
1024
1167
|
const driver = await this.driver();
|
|
1025
1168
|
const applied = [];
|
|
1026
1169
|
const deferred = [];
|
|
@@ -1188,7 +1331,7 @@ export class AuthorizationManager {
|
|
|
1188
1331
|
dryRun: false,
|
|
1189
1332
|
wouldApply: [],
|
|
1190
1333
|
entries,
|
|
1191
|
-
|
|
1334
|
+
...(await unseenOf(new Set(entries.map((e) => String(e.id))))),
|
|
1192
1335
|
};
|
|
1193
1336
|
}
|
|
1194
1337
|
/**
|
|
@@ -1586,6 +1729,49 @@ export class AuthorizationManager {
|
|
|
1586
1729
|
throw new UnsupportedOperationError('reconcile', `authz:reconcile --to=${name}`, name, `El driver '${name}' no sabe reconstruirse desde 'authz_*' + el árbol del consumidor. ` +
|
|
1587
1730
|
`El driver 'database' es ese caso a propósito: sus tablas son el ORIGEN.`);
|
|
1588
1731
|
}
|
|
1732
|
+
// **La dirección RANCIA** (2.4.0-alpha.10; 🔴 4 del sexto informe de
|
|
1733
|
+
// COGNITIV y cuarta iteración del LINAJE «origen rancio del reconcile»:
|
|
1734
|
+
// 3b-5 cerró la pasada por defecto, el primer guard de alpha.10 cerró
|
|
1735
|
+
// `--from`, y quedaba abierto el origen elegido POR DESCARTE). Regla del
|
|
1736
|
+
// dueño: el tercer defecto de un linaje se arregla estructuralmente, así
|
|
1737
|
+
// que el guard NO mira la bandera, mira el ORIGEN RESUELTO — con la MISMA
|
|
1738
|
+
// decisión que `#factsOrigin` (`#isMaintenancePass`), no con una copia:
|
|
1739
|
+
// si `to` es el driver ACTIVO, el único origen legítimo es `to` mismo (la
|
|
1740
|
+
// pasada de MANTENIMIENTO); cualquier otro —`--from`, el driver elegido por
|
|
1741
|
+
// descarte o las tablas `authz_*`— dejó de ver los hechos en el cutover y la
|
|
1742
|
+
// pasada re-escribiría lo revocado después (con o sin --prune; el seguro de
|
|
1743
|
+
// borrado masivo no salta: el origen no está vacío). Se decide por `to`,
|
|
1744
|
+
// nunca por `from`: el cutover inicial y la vuelta atrás ANTES de cambiar
|
|
1745
|
+
// `default` tienen `to` ≠ activo y pasan. Antes de resolver el origen y de
|
|
1746
|
+
// congelar. `--dry-run` no lanza (es el verificador): lo marca y
|
|
1747
|
+
// `reconcileLines` lo hace deriva. `from === to` sigue siendo el 500
|
|
1748
|
+
// E_AUTHZ_CONFIG de 3b-3b.
|
|
1749
|
+
const stale = this.#sameDriver(name, this.#config.default) &&
|
|
1750
|
+
(options.from === undefined || !this.#sameDriver(options.from, name)) &&
|
|
1751
|
+
!this.#isMaintenancePass(name, target, options.from);
|
|
1752
|
+
const allowStale = options.allowStaleSource === true;
|
|
1753
|
+
if (stale && !allowStale && options.dryRun !== true) {
|
|
1754
|
+
const how = options.from !== undefined ? `--from=${options.from}` : 'sin --from';
|
|
1755
|
+
// Cierre del auditor (⚪ 8): con UN solo driver registrado no hay descarte que
|
|
1756
|
+
// hacer; la letra no puede hablar de un origen que no existe.
|
|
1757
|
+
const others = Object.keys(this.#config.drivers ?? {}).filter((key) => !this.#sameDriver(key, name));
|
|
1758
|
+
const originLabel = options.from !== undefined
|
|
1759
|
+
? `el origen '${options.from}' no lo es`
|
|
1760
|
+
: others.length === 0
|
|
1761
|
+
? `no hay ningún otro driver registrado ni pasada de mantenimiento para él (no guarda sus hechos en su ` +
|
|
1762
|
+
`backend: no declara hierarchyFacts), así que no hay nada de dónde reconciliar hacia '${name}'`
|
|
1763
|
+
: `sin --from el origen se elegiría POR DESCARTE (las tablas authz_* u otro driver registrado: ` +
|
|
1764
|
+
`${others.join(', ')}), y no es '${name}'`;
|
|
1765
|
+
throw new StaleSourceRefusedError(`authz:reconcile --to=${name} ${how}: '${name}' es el driver ACTIVO (config.default` +
|
|
1766
|
+
`${name === this.#config.default ? '' : `: la clave resuelve la MISMA factory que '${this.#config.default}'`}) y ${originLabel}. ` +
|
|
1767
|
+
`Esa dirección re-escribiría en el driver activo hechos que el origen ya no refleja: resucitaría las ` +
|
|
1768
|
+
`revocaciones hechas desde el cutover (y con --prune borraría lo concedido después). No se ha leído, ` +
|
|
1769
|
+
`congelado ni escrito nada. Hacia el driver activo el único origen es él mismo: la pasada de mantenimiento ` +
|
|
1770
|
+
`(authz:reconcile --to=${name} [--dry-run], sin --from) si su driver guarda sus hechos; y si lo que quieres ` +
|
|
1771
|
+
`es volver atrás, migra en la otra dirección ANTES de cambiar config.default. Si de verdad el origen sigue ` +
|
|
1772
|
+
`siendo la fuente de verdad (el motor aún no ha escrito desde '${name}'), la decisión humana es ` +
|
|
1773
|
+
`--allow-stale-source (allowStaleSource: true).`);
|
|
1774
|
+
}
|
|
1589
1775
|
// **De dónde salen los HECHOS** (3b-5): la decisión que faltaba, y la que
|
|
1590
1776
|
// el destino no puede tomar por su cuenta. Ver `#factsOrigin`.
|
|
1591
1777
|
const origin = await this.#factsOrigin(name, target, options.from);
|
|
@@ -1596,7 +1782,7 @@ export class AuthorizationManager {
|
|
|
1596
1782
|
// `authz_*` no construye ningún driver de más. Y el origen se resuelve
|
|
1597
1783
|
// UNA vez.
|
|
1598
1784
|
facts: origin.enumerate,
|
|
1599
|
-
factsOrigin: { name: origin.name, authzTables: origin.authzTables },
|
|
1785
|
+
factsOrigin: { name: origin.name, authzTables: origin.authzTables, ...(origin.self ? { self: true } : {}) },
|
|
1600
1786
|
// El techo de profundidad del consumidor, si lo declaró (alpha.5 · R0):
|
|
1601
1787
|
// el mismo mando que el gate de `scopes.*`.
|
|
1602
1788
|
...(this.#config.scopes?.maxDepth === undefined ? {} : { maxDepth: this.#config.scopes.maxDepth }),
|
|
@@ -1618,6 +1804,8 @@ export class AuthorizationManager {
|
|
|
1618
1804
|
// Quién fue el origen se DICE, siempre: es la diferencia entre una
|
|
1619
1805
|
// migración y una pasada de mantenimiento contra el driver activo.
|
|
1620
1806
|
report.factsFrom = origin.resolved();
|
|
1807
|
+
if (stale)
|
|
1808
|
+
report.staleSource = { to: name, from: origin.resolved(), allowed: allowStale };
|
|
1621
1809
|
return report;
|
|
1622
1810
|
};
|
|
1623
1811
|
// La pasada que escribe congela; el verificador NO (ver el docblock).
|
|
@@ -1641,6 +1829,8 @@ export class AuthorizationManager {
|
|
|
1641
1829
|
lapsed: await window.lapsed(),
|
|
1642
1830
|
leaseMs: window.leaseMs,
|
|
1643
1831
|
fence: window.fence,
|
|
1832
|
+
...(window.untilMs === undefined ? {} : { untilMs: window.untilMs }),
|
|
1833
|
+
...(window.window === undefined ? {} : { window: window.window }),
|
|
1644
1834
|
};
|
|
1645
1835
|
return report;
|
|
1646
1836
|
}
|
|
@@ -1668,7 +1858,9 @@ export class AuthorizationManager {
|
|
|
1668
1858
|
* MANTENIMIENTO: los hechos se leen del propio destino por el puerto
|
|
1669
1859
|
* (`enumerateFacts`), se rehace lo DERIVADO (marcador, catálogo, árbol)
|
|
1670
1860
|
* y se aplica el barrido de visibilidad del invariante 18 con el árbol
|
|
1671
|
-
* y el catálogo de HOY. No se inventa
|
|
1861
|
+
* y el catálogo de HOY. No se inventa ningún hecho; con `--prune` se
|
|
1862
|
+
* borran los que ya no respaldan nada (scopes que ya no resuelven,
|
|
1863
|
+
* caducados, roles/permisos retirados, y los que sobran). Un
|
|
1672
1864
|
* destino activo con `hierarchyFacts` que no sepa enumerar sus hechos
|
|
1673
1865
|
* es 500 `E_AUTHZ_UNSUPPORTED`: leerle `authz_*` sería justo el defecto.
|
|
1674
1866
|
* 2. **`--from=<nombre>` manda**, y por eso se resuelve YA: de la
|
|
@@ -1682,7 +1874,7 @@ export class AuthorizationManager {
|
|
|
1682
1874
|
* de 3b-3b (`#factsEnumerator`).
|
|
1683
1875
|
*/
|
|
1684
1876
|
async #factsOrigin(to, target, from) {
|
|
1685
|
-
if (
|
|
1877
|
+
if (this.#isMaintenancePass(to, target, from)) {
|
|
1686
1878
|
if (typeof target.enumerateFacts !== 'function') {
|
|
1687
1879
|
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 ` +
|
|
1688
1880
|
`(hierarchyFacts), así que 'authz_assignments'/'authz_denies' NO son la fuente de verdad de sus ` +
|
|
@@ -1692,6 +1884,7 @@ export class AuthorizationManager {
|
|
|
1692
1884
|
return {
|
|
1693
1885
|
name: to,
|
|
1694
1886
|
authzTables: false,
|
|
1887
|
+
self: true,
|
|
1695
1888
|
enumerate: (page) => target.enumerateFacts(page),
|
|
1696
1889
|
resolved: () => to,
|
|
1697
1890
|
};
|
|
@@ -1717,10 +1910,44 @@ export class AuthorizationManager {
|
|
|
1717
1910
|
});
|
|
1718
1911
|
return { name: AUTHZ_TABLES_ORIGIN, authzTables: true, enumerate, resolved: () => resolvedName };
|
|
1719
1912
|
}
|
|
1913
|
+
/**
|
|
1914
|
+
* **¿Es la pasada de MANTENIMIENTO?** (3b-5; punto ÚNICO desde alpha.10): el
|
|
1915
|
+
* destino es el driver ACTIVO, sus hechos viven en su backend
|
|
1916
|
+
* (`hierarchyFacts`) y nadie nombró otro origen. Es la ÚNICA forma en la que
|
|
1917
|
+
* el origen resuelto de una pasada hacia el driver activo es el propio
|
|
1918
|
+
* destino, y la usan los dos que la necesitan: `#factsOrigin` (de dónde se
|
|
1919
|
+
* leen los hechos) y el guard de la dirección rancia de `reconcile` (si ese
|
|
1920
|
+
* origen es legítimo). Dos copias de esta condición eran dos respuestas.
|
|
1921
|
+
*/
|
|
1922
|
+
#isMaintenancePass(to, target, from) {
|
|
1923
|
+
return from === undefined && this.#sameDriver(to, this.#config.default) && target.capabilities?.hierarchyFacts === true;
|
|
1924
|
+
}
|
|
1925
|
+
/**
|
|
1926
|
+
* **¿Dos claves de `drivers` son el MISMO driver?** (cierre del auditor de
|
|
1927
|
+
* alpha.10 · R4, gemelo de roles): misma clave o la MISMA factory registrada
|
|
1928
|
+
* bajo dos claves. El guard de la dirección rancia, el mantenimiento, el
|
|
1929
|
+
* `from === to` y el descarte juzgan la identidad RESUELTA, no la etiqueta:
|
|
1930
|
+
* con `drivers: { openfga: f, fga: f }` y `default: 'openfga'`, `--to=fga
|
|
1931
|
+
* --from=database` es la misma dirección rancia que `--to=openfga`.
|
|
1932
|
+
*
|
|
1933
|
+
* **Límite DECLARADO (cierre-2 · C1)**: la identidad es la REFERENCIA de la
|
|
1934
|
+
* factory, no el backend. Dos factories distintas que construyen el mismo
|
|
1935
|
+
* store (`openfga: () => new OpenFga…(…)`, `fga: () => new OpenFga…(…)`) NO son
|
|
1936
|
+
* el mismo driver para este predicado y el alias pasa el guard. Identidad de
|
|
1937
|
+
* backend declarada por el driver ⇒ roadmap 2.5.
|
|
1938
|
+
*/
|
|
1939
|
+
#sameDriver(a, b) {
|
|
1940
|
+
if (a === b)
|
|
1941
|
+
return true;
|
|
1942
|
+
const drivers = this.#config.drivers ?? {};
|
|
1943
|
+
const own = (key) => (Object.prototype.hasOwnProperty.call(drivers, key) ? drivers[key] : undefined);
|
|
1944
|
+
const fa = own(a);
|
|
1945
|
+
return fa !== undefined && fa === own(b);
|
|
1946
|
+
}
|
|
1720
1947
|
/** El driver que `--from` nombra, con los dos errores de 3b-3b intactos. */
|
|
1721
1948
|
async #originDriver(from, to) {
|
|
1722
1949
|
const registered = Object.keys(this.#config.drivers ?? {});
|
|
1723
|
-
if (from
|
|
1950
|
+
if (this.#sameDriver(from, to)) {
|
|
1724
1951
|
throw new AuthorizationConfigError(`authz:reconcile --from=${from} --to=${to}: el origen y el destino son el mismo driver. ` +
|
|
1725
1952
|
`Si lo que quieres es VERIFICAR y reparar lo derivado del driver activo, no lo digas con --from: ` +
|
|
1726
1953
|
`la pasada ya lee sus hechos de él cuando es el driver por defecto.`);
|
|
@@ -1756,7 +1983,7 @@ export class AuthorizationManager {
|
|
|
1756
1983
|
const registered = Object.keys(this.#config.drivers ?? {});
|
|
1757
1984
|
const candidates = [];
|
|
1758
1985
|
for (const candidate of registered) {
|
|
1759
|
-
if (candidate
|
|
1986
|
+
if (this.#sameDriver(candidate, to))
|
|
1760
1987
|
continue;
|
|
1761
1988
|
const driver = await this.#config.drivers[candidate]();
|
|
1762
1989
|
if (typeof driver.enumerateFacts === 'function')
|
|
@@ -2188,7 +2415,7 @@ export class AuthorizationManager {
|
|
|
2188
2415
|
const answer = await driver.authorizeMany(subject, permission, scopes);
|
|
2189
2416
|
// Un `boolean[]` desalineado se leería por posición (F5, CR3): es un
|
|
2190
2417
|
// bug del driver, no una decisión. 500 nombrando al culpable.
|
|
2191
|
-
if (!Array.isArray(answer) || answer.length !== scopes.length || answer
|
|
2418
|
+
if (!Array.isArray(answer) || answer.length !== scopes.length || !everyPositionBoolean(answer)) {
|
|
2192
2419
|
throw new AuthorizationInternalError(`authorizeMany: el driver '${this.#config.default}' devolvió ${describeAnswer(answer)} para ${scopes.length} scopes; ` +
|
|
2193
2420
|
`el puerto exige un boolean[] con exactamente una posición por scope.`);
|
|
2194
2421
|
}
|