@jantstack/adonis-authz 2.5.0-alpha.3 → 2.5.0-rc.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 +437 -48
- package/build/commands/authz_catalog_diff.d.ts +0 -1
- package/build/commands/authz_catalog_diff.js +0 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts +17 -3
- package/build/commands/authz_catalog_prune_orphans.js +28 -6
- package/build/commands/authz_catalog_sync.d.ts +0 -1
- package/build/commands/authz_catalog_sync.js +0 -1
- package/build/commands/authz_freeze.d.ts +0 -1
- package/build/commands/authz_freeze.js +0 -1
- package/build/commands/authz_reconcile.d.ts +0 -1
- package/build/commands/authz_reconcile.js +0 -1
- package/build/commands/authz_relations_reconcile.d.ts +0 -1
- package/build/commands/authz_relations_reconcile.js +56 -8
- package/build/commands/authz_scopes_relay.d.ts +0 -1
- package/build/commands/authz_scopes_relay.js +54 -6
- package/build/commands/authz_scopes_requeue.d.ts +0 -1
- package/build/commands/authz_scopes_requeue.js +16 -3
- package/build/commands/authz_unfreeze.d.ts +0 -1
- package/build/commands/authz_unfreeze.js +0 -1
- package/build/commands/main.d.ts +0 -1
- package/build/commands/main.js +0 -1
- package/build/commands/openfga_provision.d.ts +10 -1
- package/build/commands/openfga_provision.js +43 -3
- package/build/configure.d.ts +0 -1
- package/build/configure.js +0 -1
- package/build/index.d.ts +9 -7
- package/build/index.js +10 -3
- package/build/providers/authz_provider.d.ts +0 -1
- package/build/providers/authz_provider.js +0 -1
- package/build/services/main.d.ts +0 -1
- package/build/services/main.js +0 -1
- package/build/services/relations.d.ts +0 -1
- package/build/services/relations.js +0 -1
- package/build/src/catalog/catalog.d.ts +0 -1
- package/build/src/catalog/catalog.js +0 -1
- package/build/src/catalog/catalog_cache.d.ts +13 -1
- package/build/src/catalog/catalog_cache.js +27 -2
- package/build/src/clock.d.ts +0 -1
- package/build/src/clock.js +0 -1
- package/build/src/define_config.d.ts +0 -1
- package/build/src/define_config.js +24 -1
- package/build/src/drivers/database_driver.d.ts +60 -3
- package/build/src/drivers/database_driver.js +160 -25
- package/build/src/drivers/database_relations_driver.d.ts +11 -5
- package/build/src/drivers/database_relations_driver.js +115 -33
- package/build/src/drivers/openfga_driver.d.ts +159 -26
- package/build/src/drivers/openfga_driver.js +882 -199
- package/build/src/drivers/openfga_facts.d.ts +91 -1
- package/build/src/drivers/openfga_facts.js +277 -3
- package/build/src/drivers/openfga_relations_driver.d.ts +41 -13
- package/build/src/drivers/openfga_relations_driver.js +422 -228
- package/build/src/errors.d.ts +160 -1
- package/build/src/errors.js +161 -1
- package/build/src/expiry.d.ts +51 -2
- package/build/src/expiry.js +96 -2
- package/build/src/freeze.d.ts +0 -1
- package/build/src/freeze.js +0 -1
- package/build/src/hierarchical_resolver.d.ts +0 -1
- package/build/src/hierarchical_resolver.js +0 -1
- package/build/src/http/app_access_middleware.d.ts +0 -1
- package/build/src/http/app_access_middleware.js +0 -1
- package/build/src/http/resource_access_middleware.d.ts +0 -1
- package/build/src/http/resource_access_middleware.js +0 -1
- package/build/src/identity.d.ts +9 -2
- package/build/src/identity.js +33 -1
- package/build/src/manager.d.ts +24 -1
- package/build/src/manager.js +381 -85
- package/build/src/memoize_ancestors.d.ts +0 -1
- package/build/src/memoize_ancestors.js +0 -1
- package/build/src/models/authz_assignment.d.ts +5 -6
- package/build/src/models/authz_assignment.js +0 -1
- package/build/src/models/authz_deny.d.ts +5 -6
- package/build/src/models/authz_deny.js +0 -1
- package/build/src/models/authz_permission.d.ts +5 -6
- package/build/src/models/authz_permission.js +0 -1
- package/build/src/models/authz_role.d.ts +5 -6
- package/build/src/models/authz_role.js +0 -1
- package/build/src/models/authz_role_permission.d.ts +5 -6
- package/build/src/models/authz_role_permission.js +0 -1
- package/build/src/openfga.d.ts +2 -3
- package/build/src/openfga.js +4 -2
- package/build/src/reconcile.d.ts +0 -1
- package/build/src/reconcile.js +0 -1
- package/build/src/relation_partition_trigger.d.ts +0 -1
- package/build/src/relation_partition_trigger.js +0 -1
- package/build/src/relations/check_many.d.ts +0 -1
- package/build/src/relations/check_many.js +0 -1
- package/build/src/relations/define_relations_config.d.ts +25 -3
- package/build/src/relations/define_relations_config.js +8 -3
- package/build/src/relations/deny_reach.d.ts +102 -0
- package/build/src/relations/deny_reach.js +230 -0
- package/build/src/relations/group_depth.d.ts +215 -0
- package/build/src/relations/group_depth.js +453 -0
- package/build/src/relations/manager.d.ts +14 -7
- package/build/src/relations/manager.js +60 -15
- package/build/src/relations/page.d.ts +19 -0
- package/build/src/relations/page.js +74 -0
- package/build/src/relations/reconcile.d.ts +58 -4
- package/build/src/relations/reconcile.js +266 -12
- package/build/src/relations/tuple_key.d.ts +0 -1
- package/build/src/relations/tuple_key.js +0 -1
- package/build/src/relations/tuple_order.d.ts +5 -4
- package/build/src/relations/tuple_order.js +5 -4
- package/build/src/relations_config_store.d.ts +57 -2
- package/build/src/relations_config_store.js +155 -5
- package/build/src/relations_reconcile.d.ts +0 -1
- package/build/src/relations_reconcile.js +0 -1
- package/build/src/scope_outbox.d.ts +36 -3
- package/build/src/scope_outbox.js +78 -18
- package/build/src/shared/backend_guard.d.ts +52 -1
- package/build/src/shared/backend_guard.js +164 -2
- package/build/src/shared/sql_expiry.d.ts +0 -1
- package/build/src/shared/sql_expiry.js +0 -1
- package/build/src/shared/transaction_guard.d.ts +0 -1
- package/build/src/shared/transaction_guard.js +0 -1
- package/build/src/sql_descendants.d.ts +0 -1
- package/build/src/sql_descendants.js +0 -1
- package/build/src/testing/contract.d.ts +26 -3
- package/build/src/testing/contract.js +258 -20
- package/build/src/testing/main.d.ts +0 -1
- package/build/src/testing/main.js +0 -1
- package/build/src/testing/migration_contract.d.ts +0 -1
- package/build/src/testing/migration_contract.js +0 -1
- package/build/src/testing/relations_contract.d.ts +7 -1
- package/build/src/testing/relations_contract.js +174 -7
- package/build/src/testing/relations_reconcile_contract.d.ts +0 -1
- package/build/src/testing/relations_reconcile_contract.js +0 -1
- package/build/src/testing/scope_outbox_contract.d.ts +0 -1
- package/build/src/testing/scope_outbox_contract.js +0 -1
- package/build/src/testing/scope_tree.d.ts +0 -1
- package/build/src/testing/scope_tree.js +0 -1
- package/build/src/testing/test_clock.d.ts +0 -1
- package/build/src/testing/test_clock.js +0 -1
- package/build/src/traits/authz_scopes.d.ts +0 -1
- package/build/src/traits/authz_scopes.js +0 -1
- package/build/src/traits/has_uuid.d.ts +5 -6
- package/build/src/traits/has_uuid.js +0 -1
- package/build/src/types.d.ts +138 -14
- package/build/src/types.js +6 -1
- package/build/stubs/config/authorization.stub +22 -6
- package/build/stubs/main.d.ts +0 -1
- package/build/stubs/main.js +0 -1
- package/package.json +4 -3
- package/build/commands/authz_catalog_diff.d.ts.map +0 -1
- package/build/commands/authz_catalog_diff.js.map +0 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts.map +0 -1
- package/build/commands/authz_catalog_prune_orphans.js.map +0 -1
- package/build/commands/authz_catalog_sync.d.ts.map +0 -1
- package/build/commands/authz_catalog_sync.js.map +0 -1
- package/build/commands/authz_freeze.d.ts.map +0 -1
- package/build/commands/authz_freeze.js.map +0 -1
- package/build/commands/authz_reconcile.d.ts.map +0 -1
- package/build/commands/authz_reconcile.js.map +0 -1
- package/build/commands/authz_relations_reconcile.d.ts.map +0 -1
- package/build/commands/authz_relations_reconcile.js.map +0 -1
- package/build/commands/authz_scopes_relay.d.ts.map +0 -1
- package/build/commands/authz_scopes_relay.js.map +0 -1
- package/build/commands/authz_scopes_requeue.d.ts.map +0 -1
- package/build/commands/authz_scopes_requeue.js.map +0 -1
- package/build/commands/authz_unfreeze.d.ts.map +0 -1
- package/build/commands/authz_unfreeze.js.map +0 -1
- package/build/commands/main.d.ts.map +0 -1
- package/build/commands/main.js.map +0 -1
- package/build/commands/openfga_provision.d.ts.map +0 -1
- package/build/commands/openfga_provision.js.map +0 -1
- package/build/configure.d.ts.map +0 -1
- package/build/configure.js.map +0 -1
- package/build/index.d.ts.map +0 -1
- package/build/index.js.map +0 -1
- package/build/providers/authz_provider.d.ts.map +0 -1
- package/build/providers/authz_provider.js.map +0 -1
- package/build/services/main.d.ts.map +0 -1
- package/build/services/main.js.map +0 -1
- package/build/services/relations.d.ts.map +0 -1
- package/build/services/relations.js.map +0 -1
- package/build/src/catalog/catalog.d.ts.map +0 -1
- package/build/src/catalog/catalog.js.map +0 -1
- package/build/src/catalog/catalog_cache.d.ts.map +0 -1
- package/build/src/catalog/catalog_cache.js.map +0 -1
- package/build/src/clock.d.ts.map +0 -1
- package/build/src/clock.js.map +0 -1
- package/build/src/define_config.d.ts.map +0 -1
- package/build/src/define_config.js.map +0 -1
- package/build/src/drivers/database_driver.d.ts.map +0 -1
- package/build/src/drivers/database_driver.js.map +0 -1
- package/build/src/drivers/database_relations_driver.d.ts.map +0 -1
- package/build/src/drivers/database_relations_driver.js.map +0 -1
- package/build/src/drivers/openfga_driver.d.ts.map +0 -1
- package/build/src/drivers/openfga_driver.js.map +0 -1
- package/build/src/drivers/openfga_facts.d.ts.map +0 -1
- package/build/src/drivers/openfga_facts.js.map +0 -1
- package/build/src/drivers/openfga_relations_driver.d.ts.map +0 -1
- package/build/src/drivers/openfga_relations_driver.js.map +0 -1
- package/build/src/errors.d.ts.map +0 -1
- package/build/src/errors.js.map +0 -1
- package/build/src/expiry.d.ts.map +0 -1
- package/build/src/expiry.js.map +0 -1
- package/build/src/freeze.d.ts.map +0 -1
- package/build/src/freeze.js.map +0 -1
- package/build/src/hierarchical_resolver.d.ts.map +0 -1
- package/build/src/hierarchical_resolver.js.map +0 -1
- package/build/src/http/app_access_middleware.d.ts.map +0 -1
- package/build/src/http/app_access_middleware.js.map +0 -1
- package/build/src/http/resource_access_middleware.d.ts.map +0 -1
- package/build/src/http/resource_access_middleware.js.map +0 -1
- package/build/src/identity.d.ts.map +0 -1
- package/build/src/identity.js.map +0 -1
- package/build/src/manager.d.ts.map +0 -1
- package/build/src/manager.js.map +0 -1
- package/build/src/memoize_ancestors.d.ts.map +0 -1
- package/build/src/memoize_ancestors.js.map +0 -1
- package/build/src/models/authz_assignment.d.ts.map +0 -1
- package/build/src/models/authz_assignment.js.map +0 -1
- package/build/src/models/authz_deny.d.ts.map +0 -1
- package/build/src/models/authz_deny.js.map +0 -1
- package/build/src/models/authz_permission.d.ts.map +0 -1
- package/build/src/models/authz_permission.js.map +0 -1
- package/build/src/models/authz_role.d.ts.map +0 -1
- package/build/src/models/authz_role.js.map +0 -1
- package/build/src/models/authz_role_permission.d.ts.map +0 -1
- package/build/src/models/authz_role_permission.js.map +0 -1
- package/build/src/openfga.d.ts.map +0 -1
- package/build/src/openfga.js.map +0 -1
- package/build/src/reconcile.d.ts.map +0 -1
- package/build/src/reconcile.js.map +0 -1
- package/build/src/relation_partition_trigger.d.ts.map +0 -1
- package/build/src/relation_partition_trigger.js.map +0 -1
- package/build/src/relations/check_many.d.ts.map +0 -1
- package/build/src/relations/check_many.js.map +0 -1
- package/build/src/relations/define_relations_config.d.ts.map +0 -1
- package/build/src/relations/define_relations_config.js.map +0 -1
- package/build/src/relations/manager.d.ts.map +0 -1
- package/build/src/relations/manager.js.map +0 -1
- package/build/src/relations/reconcile.d.ts.map +0 -1
- package/build/src/relations/reconcile.js.map +0 -1
- package/build/src/relations/tuple_key.d.ts.map +0 -1
- package/build/src/relations/tuple_key.js.map +0 -1
- package/build/src/relations/tuple_order.d.ts.map +0 -1
- package/build/src/relations/tuple_order.js.map +0 -1
- package/build/src/relations_config_store.d.ts.map +0 -1
- package/build/src/relations_config_store.js.map +0 -1
- package/build/src/relations_reconcile.d.ts.map +0 -1
- package/build/src/relations_reconcile.js.map +0 -1
- package/build/src/scope_outbox.d.ts.map +0 -1
- package/build/src/scope_outbox.js.map +0 -1
- package/build/src/shared/backend_guard.d.ts.map +0 -1
- package/build/src/shared/backend_guard.js.map +0 -1
- package/build/src/shared/sql_expiry.d.ts.map +0 -1
- package/build/src/shared/sql_expiry.js.map +0 -1
- package/build/src/shared/transaction_guard.d.ts.map +0 -1
- package/build/src/shared/transaction_guard.js.map +0 -1
- package/build/src/sql_descendants.d.ts.map +0 -1
- package/build/src/sql_descendants.js.map +0 -1
- package/build/src/testing/contract.d.ts.map +0 -1
- package/build/src/testing/contract.js.map +0 -1
- package/build/src/testing/main.d.ts.map +0 -1
- package/build/src/testing/main.js.map +0 -1
- package/build/src/testing/migration_contract.d.ts.map +0 -1
- package/build/src/testing/migration_contract.js.map +0 -1
- package/build/src/testing/relations_contract.d.ts.map +0 -1
- package/build/src/testing/relations_contract.js.map +0 -1
- package/build/src/testing/relations_reconcile_contract.d.ts.map +0 -1
- package/build/src/testing/relations_reconcile_contract.js.map +0 -1
- package/build/src/testing/scope_outbox_contract.d.ts.map +0 -1
- package/build/src/testing/scope_outbox_contract.js.map +0 -1
- package/build/src/testing/scope_tree.d.ts.map +0 -1
- package/build/src/testing/scope_tree.js.map +0 -1
- package/build/src/testing/test_clock.d.ts.map +0 -1
- package/build/src/testing/test_clock.js.map +0 -1
- package/build/src/traits/authz_scopes.d.ts.map +0 -1
- package/build/src/traits/authz_scopes.js.map +0 -1
- package/build/src/traits/has_uuid.d.ts.map +0 -1
- package/build/src/traits/has_uuid.js.map +0 -1
- package/build/src/types.d.ts.map +0 -1
- package/build/src/types.js.map +0 -1
- package/build/stubs/main.d.ts.map +0 -1
- package/build/stubs/main.js.map +0 -1
package/build/src/manager.js
CHANGED
|
@@ -10,7 +10,7 @@ import { assertKnownScope, canonicalScopeTargets, isAuthzError, resolveChain, ro
|
|
|
10
10
|
import { CatalogCache, GLOBAL_OWNER_KEY, invalidateAuthzCatalog, isRoleVisibleWith, readLocalRoles, withAuthzCatalogWrite } from './catalog/catalog_cache.js';
|
|
11
11
|
import { assertAssignableAt } from './catalog/catalog.js';
|
|
12
12
|
import { systemClock } from './clock.js';
|
|
13
|
-
import { ActorRequiredError,
|
|
13
|
+
import { ActorRequiredError, AuthorizationBackendTimeoutError, isPartialWrite, AuthorizationFrozenError, AuthorizationConfigError, AuthorizationInternalError, CatalogConflictError, InvalidIdentityError, MassPurgeRefusedError, PruneInterruptedError, NoDescendantsResolverError, NotWithinError, PermissionNotDelegableError, RankExceededError, RoleImmutableError, RoleLevelAboveOwnerError, ScopeCycleError, ScopeResolverError, ScopeTreeDriftError, TooManyScopesError, UnknownPermissionError, UnknownRoleError, UnknownScopeError, FreezeHeldError, UnsupportedOperationError, ViewExpiredError, WithinRequiredError, WithinRootForbiddenError, ScopeDriftUnguardedError, ScopeDepthLimitError, ScopeRequeueRefusedError, StaleSourceRefusedError, OperatorWindowExpiredError, isDeploymentFailure, isTransientRelayFailure, markDeploymentFailure, } from './errors.js';
|
|
14
14
|
import { APP_SCOPE_TYPE, normalizeAuthorizationCapabilities } from './types.js';
|
|
15
15
|
import { memoizeAncestors } from './memoize_ancestors.js';
|
|
16
16
|
import { AUTHZ_TABLES_ORIGIN } from './reconcile.js';
|
|
@@ -77,6 +77,14 @@ export const DEFAULT_RELAY_BATCH = 100;
|
|
|
77
77
|
export const DEFAULT_RELAY_LIMIT = 10_000;
|
|
78
78
|
/** Páginas que `authz:reconcile` pasea para MEDIR la ventana del relay (3b-3a). */
|
|
79
79
|
const RELAY_WINDOW_MAX_PAGES = 1_000;
|
|
80
|
+
/**
|
|
81
|
+
* **Fallos del relay que solo dicen algo de la posición del HIJO** (asentar · cierre 2, O-2 del re-ataque): el
|
|
82
|
+
* `E_AUTHZ_SCOPE_TREE_DRIFT` del respaldo (el consumidor no pone al hijo bajo el padre ENCOLADO). La pasada contamina
|
|
83
|
+
* solo la clave del hijo, no la del padre encolado: un `attached` de OTRO hijo bajo ese padre escribe otra arista y
|
|
84
|
+
* conmuta (la misma lectura que la variante B del censo de aparcadas, alpha.8). Hasta aquí, mientras la entrada fallaba
|
|
85
|
+
* —hasta `maxAttempts` pasadas, y una más tras cada `requeue`— taponaba la org entera para los hijos nuevos.
|
|
86
|
+
*/
|
|
87
|
+
const CHILD_ONLY_FAILURES = new WeakSet();
|
|
80
88
|
/**
|
|
81
89
|
* **Saltos que la sonda de `only` sigue hacia atrás para heredar el ORIGEN de
|
|
82
90
|
* un bloqueo** (2.4.0-alpha.6). Cada salto es una consulta `earlierPending`
|
|
@@ -767,11 +775,15 @@ export class AuthorizationManager {
|
|
|
767
775
|
// se contrasta es la de origen, resuelta en fresco.
|
|
768
776
|
attached: async (child, parent, options) => {
|
|
769
777
|
const actor = await this.#writeOptions(options, 'scopes.attached');
|
|
770
|
-
|
|
778
|
+
// RC2-B (rotación 2 · cierre 2, firmado: REORDENAR): `within` —destino Y origen, en fresco— se decide ANTES que el
|
|
779
|
+
// anti-ciclos y el techo de profundidad. Al revés, `SCOPE_CYCLE`/`SCOPE_DEPTH_LIMIT` sobre un scope AJENO decían
|
|
780
|
+
// DÓNDE está (de qué otro scope ajeno desciende, a cuántos saltos de la raíz) antes del `NOT_WITHIN`.
|
|
781
|
+
const edge = await this.#resolveEdge(child, parent, 'scopes.attached');
|
|
771
782
|
this.#assertWithinChain(parent, edge.chain, options, 'scopes.attached');
|
|
772
783
|
// Un hijo que el árbol ya conoce se está MOVIENDO (el `attach` de un
|
|
773
784
|
// nodo existente es un `move`): su origen también tiene que estar dentro.
|
|
774
785
|
await this.#assertWithinOrigin(child, options, 'scopes.attached', 'if-known');
|
|
786
|
+
this.#judgeEdge(edge, parent, 'scopes.attached');
|
|
775
787
|
const outbox = this.#outbox();
|
|
776
788
|
if (outbox) {
|
|
777
789
|
const receipt = await outbox.enqueue({ op: 'attached', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
|
|
@@ -795,9 +807,11 @@ export class AuthorizationManager {
|
|
|
795
807
|
*/
|
|
796
808
|
moved: async (child, newParent, options) => {
|
|
797
809
|
const actor = await this.#writeOptions(options, 'scopes.moved');
|
|
798
|
-
|
|
810
|
+
// RC2-B: `within` (destino y origen) antes que el anti-ciclos y el techo (ver `attached`).
|
|
811
|
+
const edge = await this.#resolveEdge(child, newParent, 'scopes.moved');
|
|
799
812
|
this.#assertWithinChain(newParent, edge.chain, options, 'scopes.moved');
|
|
800
813
|
await this.#assertWithinOrigin(child, options, 'scopes.moved', 'required');
|
|
814
|
+
this.#judgeEdge(edge, newParent, 'scopes.moved');
|
|
801
815
|
const outbox = this.#outbox();
|
|
802
816
|
if (outbox) {
|
|
803
817
|
const receipt = await outbox.enqueue({ op: 'moved', child: edge.child, parent: edge.chain[0] }, { transaction: options?.transaction, ...actor });
|
|
@@ -1214,6 +1228,7 @@ export class AuthorizationManager {
|
|
|
1214
1228
|
const parked = new Set();
|
|
1215
1229
|
/** El último id visto: la outbox pagina desde ahí (lo saltado se queda). */
|
|
1216
1230
|
let after;
|
|
1231
|
+
const passContext = { outbox, batchSize, appliedThisPass: [] };
|
|
1217
1232
|
const defer = (item, keys, reason, error, origin) => {
|
|
1218
1233
|
for (const key of keys)
|
|
1219
1234
|
block(key, item.id, origin);
|
|
@@ -1295,24 +1310,42 @@ export class AuthorizationManager {
|
|
|
1295
1310
|
}
|
|
1296
1311
|
}
|
|
1297
1312
|
}
|
|
1313
|
+
let appliedHow;
|
|
1298
1314
|
try {
|
|
1299
|
-
await this.#applyScopeChange(driver, item);
|
|
1315
|
+
appliedHow = await this.#applyScopeChange(driver, item, passContext);
|
|
1300
1316
|
}
|
|
1301
1317
|
catch (error) {
|
|
1302
1318
|
const message = error instanceof Error ? error.message : String(error);
|
|
1303
|
-
const transient =
|
|
1319
|
+
const transient = isTransientRelayFailure(error);
|
|
1320
|
+
const code = typeof error?.code === 'string' ? error.code : undefined;
|
|
1304
1321
|
await outbox.markFailed(item.id, message, { transient });
|
|
1305
|
-
|
|
1322
|
+
const failedKeys = typeof error === 'object' && error !== null && CHILD_ONLY_FAILURES.has(error) ? [scopeKey(item.change.child)] : keys;
|
|
1323
|
+
for (const key of failedKeys)
|
|
1306
1324
|
block(key, item.id, 'failed');
|
|
1307
1325
|
parked.add(id);
|
|
1308
|
-
failures.push({
|
|
1326
|
+
failures.push({
|
|
1327
|
+
id: item.id,
|
|
1328
|
+
change: item.change,
|
|
1329
|
+
error: message,
|
|
1330
|
+
transient,
|
|
1331
|
+
...(code !== undefined ? { code } : {}),
|
|
1332
|
+
...(isDeploymentFailure(error) ? { deployment: true } : {}),
|
|
1333
|
+
});
|
|
1309
1334
|
entries.push({ id: item.id, change: item.change, status: 'failed', reason: message, attempts: item.attempts });
|
|
1310
1335
|
continue;
|
|
1311
1336
|
}
|
|
1312
1337
|
await outbox.markApplied(item.id);
|
|
1313
1338
|
done.add(id);
|
|
1314
1339
|
applied.push({ id: item.id, change: item.change, attempts: item.attempts });
|
|
1315
|
-
|
|
1340
|
+
passContext.appliedThisPass.push(item.change);
|
|
1341
|
+
entries.push({
|
|
1342
|
+
id: item.id,
|
|
1343
|
+
change: item.change,
|
|
1344
|
+
status: 'applied',
|
|
1345
|
+
attempts: item.attempts,
|
|
1346
|
+
...(appliedHow?.reason !== undefined ? { reason: appliedHow.reason } : {}),
|
|
1347
|
+
...(appliedHow?.appliedParent !== undefined ? { appliedParent: appliedHow.appliedParent } : {}),
|
|
1348
|
+
});
|
|
1316
1349
|
}
|
|
1317
1350
|
// Una outbox que ignora `after` devuelve el mismo lote atascado: la
|
|
1318
1351
|
// pasada termina aquí en vez de dar vueltas (lo que quede, y lo que
|
|
@@ -1380,18 +1413,6 @@ export class AuthorizationManager {
|
|
|
1380
1413
|
'lo que no se pidió (filtra en el puerto, no en memoria).');
|
|
1381
1414
|
}
|
|
1382
1415
|
}
|
|
1383
|
-
/**
|
|
1384
|
-
* La caída del BACKEND no dice nada de la entrada (alpha.5 · R2a): un
|
|
1385
|
-
* `AuthorizationBackendError` (503; el timeout es uno), y en general
|
|
1386
|
-
* cualquier 503 (el freeze, el resolutor caído). Todo lo demás —422, 500,
|
|
1387
|
-
* `E_AUTHZ_SCOPE_TREE_DRIFT`, el padre inexistente, un error sin status de
|
|
1388
|
-
* un driver de terceros— es de la entrada y cuenta hacia el aparcado.
|
|
1389
|
-
*/
|
|
1390
|
-
static #isTransientRelayFailure(error) {
|
|
1391
|
-
if (error instanceof AuthorizationBackendError)
|
|
1392
|
-
return true;
|
|
1393
|
-
return typeof error === 'object' && error !== null && error.status === 503;
|
|
1394
|
-
}
|
|
1395
1416
|
/**
|
|
1396
1417
|
* Las claves de scope de lo APARCADO ⇒ el id aparcado que las nombra
|
|
1397
1418
|
* (alpha.5 · R2a). El censo lo lee `#relayInputs`: el `dead` ya leído si
|
|
@@ -2060,7 +2081,7 @@ export class AuthorizationManager {
|
|
|
2060
2081
|
*
|
|
2061
2082
|
* **La raíz `app` NO es clave** (2.4.0-alpha.8; panel relay 2, cuestión 1;
|
|
2062
2083
|
* decisión del dueño 2026-09-10 (4)). La raíz no se mueve, no se purga y
|
|
2063
|
-
* nunca es hijo (los tres 422 de `#
|
|
2084
|
+
* nunca es hijo (los tres 422 de `#resolveEdge`/`#judgeEdge`/`scopes.detached`), así que
|
|
2064
2085
|
* dos cambios cuyo ÚNICO cruce sea `app` conmutan: ningún orden que
|
|
2065
2086
|
* conservar (el auditor lo buscó y no lo encontró; `m2-orden.ts`). Con
|
|
2066
2087
|
* `[hijo, app]` toda alta de tenant compartía clave con todas las demás:
|
|
@@ -2112,17 +2133,105 @@ export class AuthorizationManager {
|
|
|
2112
2133
|
* —el driver demuestra cero o lanza—, arista al final** (S6). Al revés,
|
|
2113
2134
|
* una purga muerta a medias dejaría grants vivos en un scope sin ancestro,
|
|
2114
2135
|
* los denies heredados dejarían de aplicar y esos permisos serían
|
|
2115
|
-
* INDENEGABLES (invariante 2).
|
|
2136
|
+
* INDENEGABLES (invariante 2). **Salvo el PADRE** (lote C · TREE): un
|
|
2137
|
+
* `attached`/`moved` se aplica con el padre FRESCO del consumidor, no con
|
|
2138
|
+
* el encolado (ver el cuerpo); devuelve `'superseded'` si el nodo ya no
|
|
2139
|
+
* existe en el consumidor (aplicada sin arista).
|
|
2116
2140
|
*/
|
|
2117
|
-
async #applyScopeChange(driver, item) {
|
|
2141
|
+
async #applyScopeChange(driver, item, context) {
|
|
2118
2142
|
const change = item.change;
|
|
2119
|
-
if (change.op === 'attached') {
|
|
2120
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
2125
|
-
|
|
2143
|
+
if (change.op === 'attached' || change.op === 'moved') {
|
|
2144
|
+
// **Asentar · cierre 1 (B4 del auditor del panel de estrategia)**: el padre FRESCO es SOLO para un driver que
|
|
2145
|
+
// mantiene el árbol como HECHOS en su store (`capabilities.hierarchyFacts`: `openfga`). En `database` no hay árbol
|
|
2146
|
+
// que pueda divergir —decide `resolveChain` en cada pregunta—, así que su relay se comporta EXACTAMENTE como en
|
|
2147
|
+
// `f19fd37`: el padre ENCOLADO, sin resolutor ni modos de fallo nuevos (caso de paridad en `scope_outbox.spec`).
|
|
2148
|
+
if (driver.capabilities?.hierarchyFacts !== true) {
|
|
2149
|
+
if (change.op === 'attached')
|
|
2150
|
+
await driver.onScopeAttached?.(change.child, change.parent);
|
|
2151
|
+
else
|
|
2152
|
+
await driver.onScopeMoved?.(change.child, change.parent);
|
|
2153
|
+
return undefined;
|
|
2154
|
+
}
|
|
2155
|
+
// **El relay aplica el padre FRESCO del consumidor** (lote C · TREE, decisión del dueño 2026-10-20 (1), (d)
|
|
2156
|
+
// corregida del juez; 🟠 «TREE con outbox»). El padre ENCOLADO es el que el consumidor quería al notificar, y la
|
|
2157
|
+
// receta publicada notifica ANTES de confirmar la fila: dos `moved` del MISMO nodo pueden confirmar en un orden y
|
|
2158
|
+
// encolarse en el otro, y relevar el padre encolado dejaba el store, para siempre y en silencio, con OTRO padre que
|
|
2159
|
+
// la base del consumidor (medido: 1000/1000 forzado en PG y 200/200 en MySQL). Convergencia (juez TREE §2): la
|
|
2160
|
+
// entrada del ÚLTIMO commit de un nodo se aplica después de ese commit y lee el estado final.
|
|
2161
|
+
//
|
|
2162
|
+
// **Pero el resolutor no es fiable por construcción** (🔴 1 del auditor del paso 4; firma del dueño 2026-10-21): uno
|
|
2163
|
+
// filtrado por tenant responde `null` de todo en un comando (README «the realistic accident») y uno con caché/réplica
|
|
2164
|
+
// responde el padre VIEJO. Aplicar a ciegas convertía un fallo ruidoso en un TREE persistente y MUDO. Regla mínima:
|
|
2165
|
+
// el padre fresco se aplica cuando lo RESPALDA la propia cola —otra entrada del mismo hijo que lo dice (pendiente,
|
|
2166
|
+
// aplicada en esta pasada o aplicada después)—; si no, la entrada FALLA con un error que CONSUME intento (y se aparca
|
|
2167
|
+
// con su motivo tras `maxAttempts`, exit ≠ 0 en cada pasada):
|
|
2168
|
+
// - cadena con padre = encolado ⇒ se aplica (el caso normal);
|
|
2169
|
+
// - padre ≠ encolado RESPALDADO (otra entrada del hijo con ese padre) ⇒ se aplica el fresco, `reason:
|
|
2170
|
+
// 'consumer-parent'` y `appliedParent` en el reporte (la carrera S1: el store converge);
|
|
2171
|
+
// - padre ≠ encolado SIN respaldo ⇒ 500 `E_AUTHZ_SCOPE_TREE_DRIFT` (resolutor rancio, o un cambio no notificado);
|
|
2172
|
+
// con un resolutor que se refresca (TTL), un reintento posterior ve el padre encolado y lo aplica;
|
|
2173
|
+
// - `null` RESPALDADO por un `detached` del hijo en la cola ⇒ aplicada SIN arista, `reason: 'superseded'` (D×D no
|
|
2174
|
+
// resucita un nodo borrado);
|
|
2175
|
+
// - `null` sin respaldo ⇒ 422 `E_AUTHZ_UNKNOWN_SCOPE` (resolutor filtrado por tenant, o hijo huérfano en la base);
|
|
2176
|
+
// - cadena TRUNCADA (`[hijo]` de un nodo que no es la raíz: le falta `app`) ⇒ 503 `E_AUTHZ_RESOLVER_FAILED`
|
|
2177
|
+
// (auditor ⚪ 7: jamás inventar la raíz);
|
|
2178
|
+
// - el resolutor LANZA ⇒ 503 transitorio, no consume intento (regla de alpha.5);
|
|
2179
|
+
// - sin `scopes.resolveChain` ⇒ 500 `E_AUTHZ_CONFIG` (desviación 6 del paso 4).
|
|
2180
|
+
// Residuo declarado: con un resolutor rancio Y otra entrada del mismo hijo que respalde justo el padre rancio (dos
|
|
2181
|
+
// movimientos seguidos del nodo dentro del TTL de la caché) el relay no puede distinguirlo de la carrera S1 y aplica;
|
|
2182
|
+
// el reporte lo dice (`consumer-parent`, exit 1 del relay) y la red (`authz:reconcile --dry-run`, su fase `tree`) lo
|
|
2183
|
+
// ve; el campo `drift.treeMismatch` NO existe todavía (llega en 2.6). Límite declarado `openfga.relay.stale-resolver`.
|
|
2184
|
+
let resolver;
|
|
2185
|
+
try {
|
|
2186
|
+
resolver = this.#resolver('authz:scopes:relay');
|
|
2187
|
+
}
|
|
2188
|
+
catch (error) {
|
|
2189
|
+
// Desviación 6 del paso 4, con su caso (asentar · cierre 1, 🟡-3 del tester): un proceso relay sin
|
|
2190
|
+
// `scopes.resolveChain` es un DESPLIEGUE mal configurado ⇒ transitorio para el relay (inv. 5): no consume intento
|
|
2191
|
+
// ni aparca la cola entera. Hasta el caso, el 500 contaba intento y la aparcaba en `maxAttempts` pasadas.
|
|
2192
|
+
throw markDeploymentFailure(error);
|
|
2193
|
+
}
|
|
2194
|
+
const chain = await resolveChain(resolver, change.child, 'authz:scopes:relay');
|
|
2195
|
+
const childKey = scopeKey(change.child);
|
|
2196
|
+
if (chain === null) {
|
|
2197
|
+
if (await this.#relayBacking(context, item, (other) => other.op === 'detached' && scopeKey(other.child) === childKey)) {
|
|
2198
|
+
return { reason: 'superseded', appliedParent: null };
|
|
2199
|
+
}
|
|
2200
|
+
throw new UnknownScopeError(`authz:scopes:relay: el resolutor del consumidor no conoce ${childKey} (devolvió null) y ninguna entrada ` +
|
|
2201
|
+
`'detached' de ese nodo en la cola lo respalda. No se aplica nada: un resolutor filtrado por tenant (sin ` +
|
|
2202
|
+
`petición en el comando) o con caché, o una fila huérfana en tu árbol. El resolutor del relay tiene que leer ` +
|
|
2203
|
+
`lo CONFIRMADO, sin contexto de tenant. La entrada consume intento y se aparca si persiste.`);
|
|
2204
|
+
}
|
|
2205
|
+
if (chain.length < 2) {
|
|
2206
|
+
throw new ScopeResolverError('authz:scopes:relay', new TypeError(`resolveChain devolvió una cadena de un solo elemento para ${childKey}: le falta la raíz 'app'`));
|
|
2207
|
+
}
|
|
2208
|
+
const parent = chain[1];
|
|
2209
|
+
const parentKey = scopeKey(parent);
|
|
2210
|
+
let reason;
|
|
2211
|
+
if (parentKey !== scopeKey(change.parent)) {
|
|
2212
|
+
const backed = await this.#relayBacking(context, item, (other) => other.op !== 'detached' && scopeKey(other.child) === childKey && scopeKey(other.parent) === parentKey);
|
|
2213
|
+
if (!backed) {
|
|
2214
|
+
const drift = new ScopeTreeDriftError(`authz:scopes:relay: el padre de ${childKey} que da el resolutor del consumidor (${parentKey}) no es el ` +
|
|
2215
|
+
`encolado (${scopeKey(change.parent)}) y ninguna otra entrada de ese nodo en la cola lo respalda. No se ` +
|
|
2216
|
+
`aplica nada: un resolutor con caché o réplica atrasada, o un cambio de árbol no notificado. El resolutor del ` +
|
|
2217
|
+
`relay tiene que leer lo CONFIRMADO, sin TTL. La entrada consume intento y se aparca si persiste. Si tu base ` +
|
|
2218
|
+
`ya tiene a ${childKey} bajo ${parentKey} y esta entrada es una intención VIEJA (otra entrada del nodo se ` +
|
|
2219
|
+
`aplicó antes en otra pasada), la salida es volver a notificar scopes.moved(${childKey}, ${parentKey}) —la ` +
|
|
2220
|
+
`entrada nueva la respalda— y reencolar #${String(item.id)} con authz:scopes:requeue ` +
|
|
2221
|
+
`--id=${String(item.id)} --apply. Si ese requeue se niega (E_AUTHZ_REQUEUE_REFUSED) solo por cambios ` +
|
|
2222
|
+
`aplicados de OTROS hijos bajo ${scopeKey(change.parent)}, el conflicto es falso: authz:scopes:requeue ` +
|
|
2223
|
+
`--id=${String(item.id)} --force y después authz:scopes:relay (el relay vuelve a resolver el padre en ` +
|
|
2224
|
+
`fresco y exige respaldo: no revierte nada).`);
|
|
2225
|
+
CHILD_ONLY_FAILURES.add(drift);
|
|
2226
|
+
throw drift;
|
|
2227
|
+
}
|
|
2228
|
+
reason = 'consumer-parent';
|
|
2229
|
+
}
|
|
2230
|
+
if (change.op === 'attached')
|
|
2231
|
+
await driver.onScopeAttached?.(change.child, parent);
|
|
2232
|
+
else
|
|
2233
|
+
await driver.onScopeMoved?.(change.child, parent);
|
|
2234
|
+
return { ...(reason !== undefined ? { reason } : {}), appliedParent: parent };
|
|
2126
2235
|
}
|
|
2127
2236
|
// La auditoría no pierde al autor por haber pasado por una cola.
|
|
2128
2237
|
const event = {
|
|
@@ -2134,6 +2243,32 @@ export class AuthorizationManager {
|
|
|
2134
2243
|
await driver.onScopeDetached?.(change.child);
|
|
2135
2244
|
await this.#notify(event);
|
|
2136
2245
|
}
|
|
2246
|
+
/**
|
|
2247
|
+
* **¿La cola RESPALDA el padre fresco?** (asentar · cierre 1, 🔴 1 del auditor). Otra entrada del mismo hijo que lo
|
|
2248
|
+
* dice: aplicada en ESTA pasada, PENDIENTE (cualquier id; se pagina la cola entera, solo cuando el fresco no coincide
|
|
2249
|
+
* con el encolado: raro) o APLICADA con un id posterior (`laterApplied`, si la outbox lo trae). Nunca una aplicada
|
|
2250
|
+
* ANTERIOR de otra pasada: una caché rancia diría justo el padre de esa entrada vieja.
|
|
2251
|
+
*/
|
|
2252
|
+
async #relayBacking(context, item, matches) {
|
|
2253
|
+
if (context.appliedThisPass.some(matches))
|
|
2254
|
+
return true;
|
|
2255
|
+
const mine = String(item.id);
|
|
2256
|
+
let after;
|
|
2257
|
+
for (let page = 0; page < RELAY_WINDOW_MAX_PAGES; page++) {
|
|
2258
|
+
const batch = await context.outbox.pending(context.batchSize, after);
|
|
2259
|
+
if (batch.some((other) => String(other.id) !== mine && matches(other.change)))
|
|
2260
|
+
return true;
|
|
2261
|
+
if (batch.length < context.batchSize)
|
|
2262
|
+
break;
|
|
2263
|
+
after = batch[batch.length - 1].id;
|
|
2264
|
+
}
|
|
2265
|
+
if (typeof context.outbox.laterApplied === 'function') {
|
|
2266
|
+
const later = await context.outbox.laterApplied(item.id, [item.change.child], RELAY_WINDOW_MAX_PAGES);
|
|
2267
|
+
if (later.some((other) => matches(other.change)))
|
|
2268
|
+
return true;
|
|
2269
|
+
}
|
|
2270
|
+
return false;
|
|
2271
|
+
}
|
|
2137
2272
|
/** Cota entera positiva de las opciones del relay (500 si llega otra cosa). */
|
|
2138
2273
|
/**
|
|
2139
2274
|
* **Una opción desconocida es 500, nunca silencio** (2.4.0-alpha.8 · X-1;
|
|
@@ -2269,16 +2404,35 @@ export class AuthorizationManager {
|
|
|
2269
2404
|
return;
|
|
2270
2405
|
this.#assertWithinChain(child, chain, options, operation);
|
|
2271
2406
|
}
|
|
2272
|
-
/**
|
|
2273
|
-
|
|
2407
|
+
/**
|
|
2408
|
+
* Lo mismo con una cadena ya resuelta (en fresco) por el llamante. `asked` es cómo nombra el `message` lo que el
|
|
2409
|
+
* llamante PASÓ cuando el scope contrastado NO lo pasó él (RC2-A, rotación 2 · cierre 2: en `updateScopedRole`/
|
|
2410
|
+
* `deleteScopedRole` se contrasta el OWNER del rol, que sale del catálogo — el llamante solo pasó el uuid del rol).
|
|
2411
|
+
*/
|
|
2412
|
+
#assertWithinChain(scope, chain, options, operation, asked) {
|
|
2274
2413
|
const within = this.#requiredWithin(scope, options, operation);
|
|
2275
2414
|
if (!within)
|
|
2276
2415
|
return;
|
|
2277
2416
|
if (!chain.some((s) => _a.#sameScope(s, within))) {
|
|
2278
|
-
|
|
2279
|
-
|
|
2280
|
-
|
|
2417
|
+
// R2-03: el `message` llega al tenant tal cual — solo nombra lo que el llamante PASÓ (el scope, o el rol, y su
|
|
2418
|
+
// `within`); la cadena (identificadores de OTRO tenant) va a `error.details`, no enumerable, para el log del servidor.
|
|
2419
|
+
throw new NotWithinError(`${operation}: ${asked ?? `${scope.type}:${scope.uuid ?? ''}`} no está dentro de ${within.type}:${within.uuid ?? ''}; ` +
|
|
2420
|
+
`no se escribe fuera del scope declarado.`, { chain: chain.map((s) => ({ type: s.type, uuid: s.uuid ?? null })) });
|
|
2421
|
+
}
|
|
2422
|
+
}
|
|
2423
|
+
/**
|
|
2424
|
+
* La cadena (fresca) del OWNER de un rol local para `updateScopedRole`/`deleteScopedRole`, o 422
|
|
2425
|
+
* `E_AUTHZ_UNKNOWN_SCOPE` si el owner ya no resuelve (rol DORMIDO). El `message` nombra el ROL tal como lo pasó el
|
|
2426
|
+
* llamante, nunca el owner: el owner es un identificador que el llamante no aportó y puede ser de otro tenant (RC2-A).
|
|
2427
|
+
* El owner va a `error.details.owner`, no enumerable. El CÓDIGO no cambia.
|
|
2428
|
+
*/
|
|
2429
|
+
async #ownerChainOf(owner, roleUuid, operation) {
|
|
2430
|
+
const chain = await resolveChain(this.#freshResolver(), owner, operation);
|
|
2431
|
+
if (!chain) {
|
|
2432
|
+
throw new UnknownScopeError(`El owner del rol ${roleUuid} no existe para el resolutor de la cadena (${operation}): el rol está dormido. ` +
|
|
2433
|
+
`No se escribe sobre un scope que el árbol no reconoce; lo retira la plataforma (authz:catalog:prune-orphans).`, { owner: { type: owner.type, uuid: owner.uuid ?? null } });
|
|
2281
2434
|
}
|
|
2435
|
+
return chain;
|
|
2282
2436
|
}
|
|
2283
2437
|
/** El `within` a contrastar, validado y exigido según `requireWithin`; `null` si no hay que contrastar nada. */
|
|
2284
2438
|
#requiredWithin(scope, options, operation) {
|
|
@@ -2327,13 +2481,13 @@ export class AuthorizationManager {
|
|
|
2327
2481
|
return resolver;
|
|
2328
2482
|
}
|
|
2329
2483
|
/**
|
|
2330
|
-
*
|
|
2331
|
-
* CANÓNICO (invariante 17). El hijo canónico se devuelve desde 3b-2d
|
|
2484
|
+
* Resuelve la arista —identidad, padre conocido— y devuelve la cadena (fresca) del padre y el HIJO
|
|
2485
|
+
* CANÓNICO (invariante 17); el anti-ciclos y el techo los juzga `#judgeEdge`, DESPUÉS de `within` (RC2-B). El hijo canónico se devuelve desde 3b-2d
|
|
2332
2486
|
* porque la outbox lo encola: lo que se guarda en la cola es la fila del
|
|
2333
2487
|
* árbol, no lo que escribió el llamante — si no, el relay abriría días
|
|
2334
2488
|
* después una rama nueva en el store por un alias del uuid.
|
|
2335
2489
|
*/
|
|
2336
|
-
async #
|
|
2490
|
+
async #resolveEdge(child, parent, operation) {
|
|
2337
2491
|
const resolver = this.#resolver(operation);
|
|
2338
2492
|
assertScope(child);
|
|
2339
2493
|
assertScope(parent);
|
|
@@ -2345,14 +2499,22 @@ export class AuthorizationManager {
|
|
|
2345
2499
|
// El hijo, si el árbol ya lo conoce, con su identidad canónica (K1): un
|
|
2346
2500
|
// alias del uuid no puede colarse por debajo de la comprobación de ciclo.
|
|
2347
2501
|
const known = await resolveChain(resolver, child, operation);
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2502
|
+
return { chain, child: known ? known[0] : child };
|
|
2503
|
+
}
|
|
2504
|
+
/**
|
|
2505
|
+
* La otra mitad de la arista: anti-ciclos y techo de profundidad sobre lo que `#resolveEdge` resolvió. **Va DESPUÉS de
|
|
2506
|
+
* `within`** (rotación 2 · cierre 2 · RC2-B, firmado: REORDENAR): los dos errores hablan de la cadena del PADRE —«desciende
|
|
2507
|
+
* de …», «lo dejaría a N saltos»— y con un padre de otro tenant eran un oráculo de ESTRUCTURA ajena (de qué org cuelga
|
|
2508
|
+
* una unit ajena, a qué profundidad está) que salía antes del `NOT_WITHIN`. Sin `within`, o con uno que pasa, el orden
|
|
2509
|
+
* de los demás errores no cambia (identidad → padre desconocido → ciclo → profundidad).
|
|
2510
|
+
*/
|
|
2511
|
+
#judgeEdge(edge, parent, operation) {
|
|
2512
|
+
const childKey = _a.#scopeKey(edge.child);
|
|
2513
|
+
if (edge.chain.some((s) => _a.#scopeKey(s) === childKey)) {
|
|
2351
2514
|
throw new ScopeCycleError(`${operation}: ${parent.type}:${parent.uuid} desciende de ${childKey.replace('\u001f', ':')} (o es él mismo); ` +
|
|
2352
2515
|
`colgarlo cerraría un ciclo y la herencia dejaría de ser solo hacia abajo.`);
|
|
2353
2516
|
}
|
|
2354
|
-
this.#assertDepth(chain,
|
|
2355
|
-
return { chain, child: canonicalChild };
|
|
2517
|
+
this.#assertDepth(edge.chain, edge.child, parent, operation);
|
|
2356
2518
|
}
|
|
2357
2519
|
/**
|
|
2358
2520
|
* **El gate de profundidad, opt-in** (2.4.0-alpha.5 · R0; `scopes.maxDepth`).
|
|
@@ -2467,8 +2629,10 @@ export class AuthorizationManager {
|
|
|
2467
2629
|
if (!chain)
|
|
2468
2630
|
return [];
|
|
2469
2631
|
const catalog = await this.#catalogFor(driver).view();
|
|
2470
|
-
|
|
2471
|
-
const
|
|
2632
|
+
// Roles y denies en UNA foto (rotación 2 · cierre 3, L12): ver `#decisionFacts`.
|
|
2633
|
+
const facts = await this.#decisionFacts(driver, subject, { chain, catalog }, 'effectivePermissions');
|
|
2634
|
+
const { granted } = this.#grantedFrom(facts.roles, chain, catalog);
|
|
2635
|
+
const denied = this.#deniedIn(facts.denies, chain);
|
|
2472
2636
|
return [...granted].filter((permission) => !denied.has(permission));
|
|
2473
2637
|
}
|
|
2474
2638
|
/**
|
|
@@ -2492,11 +2656,18 @@ export class AuthorizationManager {
|
|
|
2492
2656
|
* `E_AUTHZ_AMBIGUOUS_ROLE`) si hay homónimos visibles. Nunca elige uno.
|
|
2493
2657
|
*/
|
|
2494
2658
|
async #rolesAlong(driver, subject, chain, catalog) {
|
|
2659
|
+
return this.#grantedFrom(await this.#readRoles(driver, subject, chain, catalog), chain, catalog);
|
|
2660
|
+
}
|
|
2661
|
+
/** Los roles del holder en la cadena por el PUERTO: `rolesInChain` si el driver lo trae; si no, la composición por slug. */
|
|
2662
|
+
async #readRoles(driver, subject, chain, catalog) {
|
|
2663
|
+
return typeof driver.rolesInChain === 'function'
|
|
2664
|
+
? await driver.rolesInChain(subject, chain)
|
|
2665
|
+
: await this.#rolesFromSlugs(driver, subject, chain, chainKeysFrom(chain), catalog);
|
|
2666
|
+
}
|
|
2667
|
+
/** Lo que conceden unos roles YA LEÍDOS a lo largo de la cadena, y el rank más alto (puro: el catálogo es el memo). */
|
|
2668
|
+
#grantedFrom(roles, chain, catalog) {
|
|
2495
2669
|
const keysFrom = chainKeysFrom(chain);
|
|
2496
2670
|
const levelIndex = new Map(chain.map((s, i) => [scopeKey(s), i]));
|
|
2497
|
-
const roles = typeof driver.rolesInChain === 'function'
|
|
2498
|
-
? await driver.rolesInChain(subject, chain)
|
|
2499
|
-
: await this.#rolesFromSlugs(driver, subject, chain, keysFrom, catalog);
|
|
2500
2671
|
const granted = new Set();
|
|
2501
2672
|
let rank = 0;
|
|
2502
2673
|
for (const { scope: level, role } of roles) {
|
|
@@ -2595,18 +2766,91 @@ export class AuthorizationManager {
|
|
|
2595
2766
|
return undefined;
|
|
2596
2767
|
}
|
|
2597
2768
|
}
|
|
2598
|
-
/** Permisos DENEGADOS al holder en algún scope de la cadena
|
|
2599
|
-
|
|
2600
|
-
const listDenies = this.#optional(driver, 'listDenies', primitive);
|
|
2769
|
+
/** Permisos DENEGADOS al holder en algún scope de la cadena, sobre denies YA LEÍDOS (los de `listDenies(subject)`). */
|
|
2770
|
+
#deniedIn(denies, chain) {
|
|
2601
2771
|
const key = _a.#scopeKey;
|
|
2602
2772
|
const chainKeys = new Set(chain.map(key));
|
|
2603
2773
|
const denied = new Set();
|
|
2604
|
-
for (const deny of
|
|
2774
|
+
for (const deny of denies) {
|
|
2605
2775
|
if (chainKeys.has(key(deny.scope)))
|
|
2606
2776
|
denied.add(deny.permission);
|
|
2607
2777
|
}
|
|
2608
2778
|
return denied;
|
|
2609
2779
|
}
|
|
2780
|
+
/**
|
|
2781
|
+
* **El punto de paso ÚNICO de las decisiones que el manager COMPONE con concesión y deny** (rotación 2 · cierre 3;
|
|
2782
|
+
* 4.ª aparición del linaje L12 «lector rasgado»). `effectivePermissions`, la policy de delegación de
|
|
2783
|
+
* `defineScopedRole`/`updateScopedRole` (`#assertDelegable`) y `authorizedScopes` combinan lo que el holder TIENE
|
|
2784
|
+
* (`rolesInChain`, o `listScopes` de un permiso) con lo que tiene DENEGADO (`listDenies`). Hasta aquí eran dos métodos
|
|
2785
|
+
* del puerto, dos lecturas sin foto común: un offboarding suelto (revoke y luego removeDeny) entre las dos hacía que
|
|
2786
|
+
* `effectivePermissions` listara un permiso, `authorizedScopes` una unit y `defineScopedRole` delegara algo que el
|
|
2787
|
+
* holder no tenía en NINGÚN estado real (repro `.claude/reproducciones/database-authorize-rasgado.spec.ts`, caso 5).
|
|
2788
|
+
*
|
|
2789
|
+
* Regla: con un driver que DECLARA la capacidad `snapshotDecisions: true` (opcional por D15) las DOS mitades salen de
|
|
2790
|
+
* UNA llamada a `readSnapshot`, que el driver responde desde una sola foto de sus hechos; la respuesta se contrasta
|
|
2791
|
+
* con lo pedido (una clave que falta o que no es un array es un bug del driver: 500 nombrándolo, como
|
|
2792
|
+
* `authorizeMany`). Decide la CAPACIDAD, no la presencia del método (rotación 2 · cierre 3b, H3 del auditor; el
|
|
2793
|
+
* patrón de `membersOfNative`): declarar `true` sin traer `readSnapshot` es 500 `E_AUTHZ_CONFIG` antes de leer nada,
|
|
2794
|
+
* y un driver que declara `false` (o nada) compone como siempre aunque tenga un método con ese nombre —el hueco
|
|
2795
|
+
* DECLARADO de un driver sin la capacidad (`openfga`, terceros)—. En los dos caminos, sin `listDenies` es 500
|
|
2796
|
+
* `E_AUTHZ_UNSUPPORTED` antes de leer nada: nunca un «sin denies».
|
|
2797
|
+
*
|
|
2798
|
+
* `catalog` va con `chain` porque la composición por slug lo necesita; el catálogo y la cadena se leen FUERA de la
|
|
2799
|
+
* foto (el memo contrasta la versión compartida; la cadena es del consumidor: «cadena frente a hechos», declarado).
|
|
2800
|
+
*
|
|
2801
|
+
* **Cuándo sale el 500 de «`true` sin `readSnapshot`»** (rotación 2 · cierre 3c, J3 del auditor): en la PRIMERA
|
|
2802
|
+
* decisión compuesta (`effectivePermissions`, `authorizedScopes`, la policy de `defineScopedRole`/`updateScopedRole`),
|
|
2803
|
+
* no al resolver el driver — `authorize` y los `list*` no lo necesitan y siguen respondiendo. Fail-closed y ruidoso,
|
|
2804
|
+
* pero tarde; el patrón es el de `#optional` (una operación que falta se dice cuando se pide). El mensaje nombra el
|
|
2805
|
+
* driver que lo EMITIÓ —la clave de `config.drivers` y la clase del objeto que la factory devolvió—, no solo
|
|
2806
|
+
* `config.default`.
|
|
2807
|
+
*/
|
|
2808
|
+
async #decisionFacts(driver, subject, request, primitive) {
|
|
2809
|
+
const listDenies = this.#optional(driver, 'listDenies', primitive);
|
|
2810
|
+
if (driver.capabilities?.snapshotDecisions === true) {
|
|
2811
|
+
if (typeof driver.readSnapshot !== 'function') {
|
|
2812
|
+
throw new AuthorizationConfigError(`${primitive}: el driver ${this.#emitter(driver)} declara snapshotDecisions: true y no trae readSnapshot. ` +
|
|
2813
|
+
'La capacidad promete leer en UNA foto lo que el manager compone; sin el método no hay foto que leer. ' +
|
|
2814
|
+
'Implementa readSnapshot o declara snapshotDecisions: false (el manager compone con dos lecturas, el hueco declarado).');
|
|
2815
|
+
}
|
|
2816
|
+
const answer = await driver.readSnapshot(subject, {
|
|
2817
|
+
...(request.chain ? { rolesInChain: request.chain } : {}),
|
|
2818
|
+
...(request.permission !== undefined ? { listScopes: request.permission } : {}),
|
|
2819
|
+
listDenies: true,
|
|
2820
|
+
});
|
|
2821
|
+
const got = (answer ?? {});
|
|
2822
|
+
const missing = [
|
|
2823
|
+
...(request.chain ? ['rolesInChain'] : []),
|
|
2824
|
+
...(request.permission !== undefined ? ['listScopes'] : []),
|
|
2825
|
+
'listDenies',
|
|
2826
|
+
].filter((key) => !Array.isArray(got[key]));
|
|
2827
|
+
if (answer === null || typeof answer !== 'object' || missing.length) {
|
|
2828
|
+
throw new AuthorizationInternalError(`${primitive}: el driver ${this.#emitter(driver)} respondió a readSnapshot sin ${missing.join(', ') || 'un objeto'}; ` +
|
|
2829
|
+
'el puerto exige un array por cada lectura pedida, todas de la misma foto.');
|
|
2830
|
+
}
|
|
2831
|
+
return {
|
|
2832
|
+
roles: got.rolesInChain,
|
|
2833
|
+
scopes: got.listScopes,
|
|
2834
|
+
denies: got.listDenies,
|
|
2835
|
+
};
|
|
2836
|
+
}
|
|
2837
|
+
// Fallback DECLARADO (driver sin `snapshotDecisions: true`): dos lecturas del puerto sin foto común. Un método
|
|
2838
|
+
// `readSnapshot` de un driver que no declara la capacidad NO se llama (puede ser otra cosa con el mismo nombre).
|
|
2839
|
+
const roles = request.chain ? await this.#readRoles(driver, subject, request.chain, request.catalog) : undefined;
|
|
2840
|
+
const scopes = request.permission !== undefined ? await driver.listScopes(subject, request.permission) : undefined;
|
|
2841
|
+
return { roles, scopes, denies: await listDenies(subject) };
|
|
2842
|
+
}
|
|
2843
|
+
/**
|
|
2844
|
+
* Cómo se nombra en un error el driver que lo EMITIÓ (cierre 3c, J3): la clave de `config.drivers` que lo resolvió y la
|
|
2845
|
+
* clase del objeto que devolvió la factory (una vista `Object.create`/`withClock` hereda la del driver). La clave sola
|
|
2846
|
+
* puede no decir qué objeto respondió (una factory que elige entre varios, una vista).
|
|
2847
|
+
*/
|
|
2848
|
+
#emitter(driver) {
|
|
2849
|
+
const kind = driver?.constructor?.name;
|
|
2850
|
+
return typeof kind === 'string' && kind !== '' && kind !== 'Object'
|
|
2851
|
+
? `'${this.#config.default}' (${kind})`
|
|
2852
|
+
: `'${this.#config.default}'`;
|
|
2853
|
+
}
|
|
2610
2854
|
// ── Roles locales a un scope (3B · B3): la API de DELEGACIÓN ─────────
|
|
2611
2855
|
// Un administrador de un scope (el actor) define roles que solo existen
|
|
2612
2856
|
// dentro de ese scope (owner) con permisos que él mismo tiene efectivos
|
|
@@ -2658,8 +2902,10 @@ export class AuthorizationManager {
|
|
|
2658
2902
|
// Composición y lista blanca antes de leer hechos: lo barato primero.
|
|
2659
2903
|
this.#assertComposable(parsed.permissions, parsed, catalog);
|
|
2660
2904
|
await this.#assertLevelUnderOwner(parsed.scopeType, chain, 'defineScopedRole');
|
|
2661
|
-
|
|
2662
|
-
const
|
|
2905
|
+
// Lo que el actor tiene y lo que tiene denegado, en UNA foto (rotación 2 · cierre 3, L12: `#decisionFacts`).
|
|
2906
|
+
const facts = await this.#decisionFacts(driver, who, { chain, catalog }, 'defineScopedRole');
|
|
2907
|
+
const { granted, rank: actorRank } = this.#grantedFrom(facts.roles, chain, catalog);
|
|
2908
|
+
const denied = this.#deniedIn(facts.denies, chain);
|
|
2663
2909
|
this.#assertDelegable(parsed.permissions, granted, denied, owner);
|
|
2664
2910
|
this.#assertRank(parsed.rank, actorRank, catalog.topGlobalRank);
|
|
2665
2911
|
const shadowedByAncestor = await this.#assertNoRoleCollision(parsed.slug, parsed.scopeType, owner, chain, catalog.rolesNamed(parsed.slug, parsed.scopeType));
|
|
@@ -2679,18 +2925,20 @@ export class AuthorizationManager {
|
|
|
2679
2925
|
const known = new Set(catalog.rolesNamed(parsed.slug, parsed.scopeType).map((r) => r.owner));
|
|
2680
2926
|
const fresh = [...new Set(rows.map((r) => String(r.owner_scope_key)).filter((o) => !known.has(o)))];
|
|
2681
2927
|
if (fresh.length) {
|
|
2682
|
-
// Los homónimos que el memo no tenía son de una escritura confirmada
|
|
2683
|
-
//
|
|
2684
|
-
// transacción
|
|
2685
|
-
//
|
|
2686
|
-
//
|
|
2687
|
-
//
|
|
2688
|
-
//
|
|
2689
|
-
//
|
|
2928
|
+
// Los homónimos que el memo no tenía son de una escritura confirmada mientras validábamos. Se juzgan con la
|
|
2929
|
+
// MISMA regla de colisión por AUTORIDAD que la comprobación previa (3F · S3, invariante 18), en lo que se puede
|
|
2930
|
+
// juzgar SIN salir de la transacción —la cadena del owner ya está resuelta—: global o ancestro-o-igual ⇒ 422
|
|
2931
|
+
// preciso (eso mantiene M2: dos `define` del mismo owner acaban en uno). **Cualquier otro owner NO es colisión**
|
|
2932
|
+
// (rotación 2 · R2-05, decisión del dueño 2026-10-22): un hermano —otro tenant— puede tener el mismo slug, y
|
|
2933
|
+
// un DESCENDIENTE queda ensombrecido, que es el segundo final legal de la carrera ancestro↔descendiente
|
|
2934
|
+
// (3G · Y1). Hasta este cierre los dos se rechazaban con un 422 que NOMBRABA el owner ajeno («owner
|
|
2935
|
+
// organization|<uuid de A>»): un oráculo de qué tenants definen qué slug, y una colisión falsa (reintentar
|
|
2936
|
+
// entraba). Lo que NO se hace aquí, a propósito: resolver la cadena del owner nuevo dentro de la transacción
|
|
2937
|
+
// (pediría otra conexión con el cerrojo del catálogo sostenido: abrazo mortal con pool 1), así que el rango
|
|
2938
|
+
// sobre un descendiente confirmado EN la ventana no se mide (W3 protege a los roles que YA existían al validar;
|
|
2939
|
+
// el mismo actor obtiene lo mismo yendo primero) y no sale en `shadowedByAncestor` del evento — sí en
|
|
2940
|
+
// `authz:catalog:diff`. Declarado en el CHANGELOG.
|
|
2690
2941
|
await this.#assertNoRoleCollision(parsed.slug, parsed.scopeType, owner, chain, fresh.map((o) => ({ owner: o })), 'sin-árbol');
|
|
2691
|
-
throw new CatalogConflictError(`El catálogo cambió mientras se validaba '${parsed.slug}@${parsed.scopeType}': apareció un rol con ese nombre ` +
|
|
2692
|
-
`(owner ${fresh.join(', ')}) que no estaba en la foto con la que se comprobaron las colisiones. ` +
|
|
2693
|
-
`No se escribe a ciegas; reintenta la operación.`);
|
|
2694
2942
|
}
|
|
2695
2943
|
const now = systemClock();
|
|
2696
2944
|
await trx.table('authz_roles').insert({
|
|
@@ -2742,10 +2990,10 @@ export class AuthorizationManager {
|
|
|
2742
2990
|
const catalog = await this.#catalogFor(driver).view();
|
|
2743
2991
|
const role = this.#localRoleOrFail(catalog, roleUuid);
|
|
2744
2992
|
const owner = this.#ownerOf(role);
|
|
2745
|
-
const chain = await
|
|
2993
|
+
const chain = await this.#ownerChainOf(owner, role.uuid, 'updateScopedRole');
|
|
2746
2994
|
// El scope contrastado es el OWNER del rol (3D · M3): editar un rol es
|
|
2747
2995
|
// escribir dentro de su contenedor.
|
|
2748
|
-
this.#assertWithinChain(owner, chain, options, 'updateScopedRole');
|
|
2996
|
+
this.#assertWithinChain(owner, chain, options, 'updateScopedRole', `el rol ${role.uuid}`);
|
|
2749
2997
|
const current = [...catalog.rolePermissionsOf(role.uuid)].sort();
|
|
2750
2998
|
const next = {
|
|
2751
2999
|
name: parsed.name ?? null,
|
|
@@ -2759,15 +3007,24 @@ export class AuthorizationManager {
|
|
|
2759
3007
|
// POR ENCIMA de su owner (creado antes de 3E o a mano) no se perpetúa:
|
|
2760
3008
|
// es una mina de slug y lo que toca es purgarlo, no editarlo.
|
|
2761
3009
|
await this.#assertLevelUnderOwner(role.scopeType, chain, 'updateScopedRole');
|
|
2762
|
-
|
|
3010
|
+
// Con permisos nuevos la policy combina lo que el actor tiene con lo que tiene denegado: UNA foto (rotación 2 ·
|
|
3011
|
+
// cierre 3, L12: `#decisionFacts`). Sin permisos solo hace falta el rank (una mitad: no rasga). Un driver SIN
|
|
3012
|
+
// `listDenies` no tiene denies que leer: se leen solo los roles y el 500 `E_AUTHZ_UNSUPPORTED` sale DESPUÉS del 422
|
|
3013
|
+
// de rango, en el orden de siempre (rotación 2 · cierre 3b, H4 del auditor).
|
|
3014
|
+
const withDenies = Boolean(parsed.permissions) && typeof driver.listDenies === 'function';
|
|
3015
|
+
const facts = withDenies
|
|
3016
|
+
? await this.#decisionFacts(driver, who, { chain, catalog }, 'updateScopedRole')
|
|
3017
|
+
: { roles: await this.#readRoles(driver, who, chain, catalog), denies: [] };
|
|
3018
|
+
const { granted, rank: actorRank } = this.#grantedFrom(facts.roles, chain, catalog);
|
|
2763
3019
|
this.#assertAboveRole(actorRank, role);
|
|
2764
3020
|
// 3G · W3: este rol puede estar ENSOMBRECIENDO al homónimo de un
|
|
2765
3021
|
// descendiente (3F · S3). Cambiarle rank o permisos es seguir ejerciendo
|
|
2766
3022
|
// esa autoridad, así que exige lo mismo que crearlo: superarlo en rango.
|
|
2767
3023
|
this.#assertAboveShadowed(actorRank, await this.#shadowedBelow(scopeKey(owner), new Set(chain.map(scopeKey)), catalog.rolesNamed(role.slug, role.scopeType).filter((other) => other.uuid !== role.uuid), 'updateScopedRole'), 'updateScopedRole');
|
|
2768
3024
|
if (parsed.permissions) {
|
|
2769
|
-
|
|
2770
|
-
|
|
3025
|
+
if (!withDenies)
|
|
3026
|
+
this.#optional(driver, 'listDenies', 'updateScopedRole');
|
|
3027
|
+
this.#assertDelegable(parsed.permissions, granted, this.#deniedIn(facts.denies, chain), owner);
|
|
2771
3028
|
}
|
|
2772
3029
|
if (parsed.rank !== undefined)
|
|
2773
3030
|
this.#assertRank(parsed.rank, actorRank, catalog.topGlobalRank);
|
|
@@ -2836,8 +3093,8 @@ export class AuthorizationManager {
|
|
|
2836
3093
|
const catalog = await this.#catalogFor(driver).view();
|
|
2837
3094
|
const role = this.#localRoleOrFail(catalog, roleUuid);
|
|
2838
3095
|
const owner = this.#ownerOf(role);
|
|
2839
|
-
const chain = await
|
|
2840
|
-
this.#assertWithinChain(owner, chain, options, 'deleteScopedRole');
|
|
3096
|
+
const chain = await this.#ownerChainOf(owner, role.uuid, 'deleteScopedRole');
|
|
3097
|
+
this.#assertWithinChain(owner, chain, options, 'deleteScopedRole', `el rol ${role.uuid}`);
|
|
2841
3098
|
const { rank: actorRank } = await this.#rolesAlong(driver, who, chain, catalog);
|
|
2842
3099
|
this.#assertAboveRole(actorRank, role);
|
|
2843
3100
|
const permissions = [...catalog.rolePermissionsOf(role.uuid)].sort();
|
|
@@ -2934,6 +3191,21 @@ export class AuthorizationManager {
|
|
|
2934
3191
|
* antes de su `purgeRole`; si ha vuelto, el rol se salta y se cuenta en
|
|
2935
3192
|
* `skipped` con `reason: 'owner-came-back'`.
|
|
2936
3193
|
*
|
|
3194
|
+
* **Tercer disparador del seguro** (rotación 2 de M4 · R2-02, decisión del dueño 2026-10-22 (2)): la
|
|
3195
|
+
* proporción la controla un TENANT —con la API de delegación legítima infla su catálogo local hasta que los
|
|
3196
|
+
* huérfanos de los demás bajan del 50 %—, y la variante realista del resolutor ciego no es «`null` para todo»
|
|
3197
|
+
* sino un contexto de tenant COLADO en el job de plataforma, que solo deja vivo a ese tenant. Así que, con
|
|
3198
|
+
* `force`, si los huérfanos son de MÁS DE UN owner y ALGUNO todavía concede (`stillGranting === true`) o no se
|
|
3199
|
+
* sabe (`undefined`: el driver no cuenta), también es 500 `E_AUTHZ_MASS_PURGE_REFUSED` antes de borrar nada,
|
|
3200
|
+
* salvo `allowMassPurge`. Un solo owner huérfano con asignaciones vivas sigue pasando (es el caso normal de un
|
|
3201
|
+
* tenant que se va, y el listado lo marca aparte). El `--dry-run` no lanza: lo marca en `massPurge` y dice por
|
|
3202
|
+
* qué en `massPurgeReasons` (`'all-owners' | 'proportion' | 'granting-across-owners'`).
|
|
3203
|
+
* - **Cota de lectura** (R2-04): `maxLocalRoles` (default `DEFAULT_MAX_LOCAL_ROLES`, `--max-local-roles` en el
|
|
3204
|
+
* comando) acota la lectura del catálogo local; pasarla es 500 `E_AUTHZ_TOO_MANY_LOCAL_ROLES`, nunca una lista
|
|
3205
|
+
* parcial. Hasta este cierre la opción no existía y el mensaje pedía «subir la cota» sin mando: un tenant con
|
|
3206
|
+
* 10 001 roles locales dejaba `prune-orphans` muerto para todos. Un valor que no es un entero ≥ 1 es 500
|
|
3207
|
+
* `E_AUTHZ_CONFIG` antes de leer nada.
|
|
3208
|
+
*
|
|
2937
3209
|
* Coste: una lectura del catálogo local + un `resolveChain` por OWNER
|
|
2938
3210
|
* DISTINTO (memoizado) + UNA llamada a `countRoleAssignments` con los
|
|
2939
3211
|
* uuids de los huérfanos (ninguna si no hay) + un `resolveChain` más por
|
|
@@ -2942,6 +3214,12 @@ export class AuthorizationManager {
|
|
|
2942
3214
|
* una petición.
|
|
2943
3215
|
*/
|
|
2944
3216
|
async pruneOrphanRoles(options = {}) {
|
|
3217
|
+
// R2-04: la cota es una entrada del operador y se valida antes de leer nada (L5: entrada inerte o rechazada).
|
|
3218
|
+
const maxLocalRoles = options.maxLocalRoles;
|
|
3219
|
+
if (maxLocalRoles !== undefined && !(Number.isInteger(maxLocalRoles) && maxLocalRoles >= 1)) {
|
|
3220
|
+
throw new AuthorizationConfigError(`pruneOrphanRoles: maxLocalRoles (--max-local-roles) debe ser un entero >= 1 (llegó ${String(maxLocalRoles)}). ` +
|
|
3221
|
+
`No se ha leído nada.`);
|
|
3222
|
+
}
|
|
2945
3223
|
const force = options.force === true;
|
|
2946
3224
|
this.#resolver('authz:catalog:prune-orphans');
|
|
2947
3225
|
const driver = await this.driver();
|
|
@@ -2953,7 +3231,7 @@ export class AuthorizationManager {
|
|
|
2953
3231
|
if (force)
|
|
2954
3232
|
await this.#assertNotFrozen('authz:catalog:prune-orphans');
|
|
2955
3233
|
const resolver = this.#freshResolver();
|
|
2956
|
-
const locals = await readLocalRoles({ driver: this.#config.default });
|
|
3234
|
+
const locals = await readLocalRoles({ driver: this.#config.default, maxLocalRoles });
|
|
2957
3235
|
const resolved = new Map();
|
|
2958
3236
|
const orphans = [];
|
|
2959
3237
|
for (const { role, permissions } of locals) {
|
|
@@ -2986,15 +3264,33 @@ export class AuthorizationManager {
|
|
|
2986
3264
|
}
|
|
2987
3265
|
const owners = new Set(locals.map(({ role }) => role.owner));
|
|
2988
3266
|
const orphanOwners = new Set(orphans.map(({ role }) => role.owner));
|
|
2989
|
-
|
|
3267
|
+
// Los TRES disparadores del seguro (AA2 + R2-02). Cada uno se reporta: el `--dry-run` dice POR QUÉ lo marca.
|
|
3268
|
+
const granting = orphans.filter(({ stillGranting }) => stillGranting !== false);
|
|
3269
|
+
const massPurgeReasons = [];
|
|
3270
|
+
if (orphans.length > 0) {
|
|
3271
|
+
if (orphanOwners.size === owners.size)
|
|
3272
|
+
massPurgeReasons.push('all-owners');
|
|
3273
|
+
if (orphans.length * 2 > locals.length)
|
|
3274
|
+
massPurgeReasons.push('proportion');
|
|
3275
|
+
if (orphanOwners.size > 1 && granting.length > 0)
|
|
3276
|
+
massPurgeReasons.push('granting-across-owners');
|
|
3277
|
+
}
|
|
3278
|
+
const massPurge = massPurgeReasons.length > 0;
|
|
2990
3279
|
if (!force)
|
|
2991
|
-
return { orphans, purged: [], skipped: [], massPurge, dryRun: true };
|
|
3280
|
+
return { orphans, purged: [], skipped: [], massPurge, massPurgeReasons, dryRun: true };
|
|
2992
3281
|
if (massPurge && options.allowMassPurge !== true) {
|
|
3282
|
+
// Solo CUENTAS: ni uuids ni slugs de owners (la lista ya la ve el operador en el `--dry-run`).
|
|
3283
|
+
const motivos = massPurgeReasons.map((reason) => reason === 'all-owners'
|
|
3284
|
+
? `todos los owners distintos (${orphanOwners.size} de ${owners.size}) salen huérfanos`
|
|
3285
|
+
: reason === 'proportion'
|
|
3286
|
+
? `los huérfanos son más del 50 % de los roles locales (${orphans.length} de ${locals.length})`
|
|
3287
|
+
: `los huérfanos son de ${orphanOwners.size} owners distintos y ${granting.length} de ellos TODAVÍA conceden ` +
|
|
3288
|
+
`o no se sabe (stillGranting true/undefined)`);
|
|
2993
3289
|
throw new MassPurgeRefusedError(`pruneOrphanRoles: ${orphans.length} de ${locals.length} roles locales (${orphanOwners.size} de ${owners.size} ` +
|
|
2994
|
-
`owners distintos) tienen el owner fuera del árbol. Esa es la firma de un
|
|
2995
|
-
`por el tenant de la petición,
|
|
2996
|
-
`
|
|
2997
|
-
`allowMassPurge: true (--allow-mass-purge).`);
|
|
3290
|
+
`owners distintos) tienen el owner fuera del árbol, y ${motivos.join('; ')}. Esa es la firma de un ` +
|
|
3291
|
+
`'scopes.resolveChain' ciego —filtrado por el tenant de la petición, con el contexto de UN tenant colado en el ` +
|
|
3292
|
+
`job, o sin contexto—: una pasada así borra el catálogo local, y las asignaciones vivas, de otros tenants. No se ` +
|
|
3293
|
+
`ha borrado nada. Comprueba el resolutor y, si la poda es real, repite con allowMassPurge: true (--allow-mass-purge).`);
|
|
2998
3294
|
}
|
|
2999
3295
|
const purged = [];
|
|
3000
3296
|
const skipped = [];
|
|
@@ -3032,7 +3328,7 @@ export class AuthorizationManager {
|
|
|
3032
3328
|
purged.push(role);
|
|
3033
3329
|
await this.#notifyCatalog({ action: 'role_purged', role, owner, permissions });
|
|
3034
3330
|
}
|
|
3035
|
-
return { orphans, purged, skipped, massPurge, dryRun: false };
|
|
3331
|
+
return { orphans, purged, skipped, massPurge, massPurgeReasons, dryRun: false };
|
|
3036
3332
|
}
|
|
3037
3333
|
/** El actor de la API de delegación: obligatorio SIEMPRE (sin él no hay policy que evaluar) y bien formado. */
|
|
3038
3334
|
async #requireActor(actor, operation) {
|
|
@@ -3557,12 +3853,13 @@ export class AuthorizationManager {
|
|
|
3557
3853
|
// resuelven una vez.
|
|
3558
3854
|
const view = this.#readResolver ? this : this.forRequest();
|
|
3559
3855
|
const driver = await view.#reader();
|
|
3560
|
-
const listDenies = this.#optional(driver, 'listDenies', 'authorizedScopes');
|
|
3561
3856
|
const key = _a.#scopeKey;
|
|
3562
|
-
|
|
3857
|
+
// Los scopes directos que conceden y los denies, en UNA foto (rotación 2 · cierre 3, L12: `#decisionFacts`).
|
|
3858
|
+
const facts = await view.#decisionFacts(driver, subject, { permission }, 'authorizedScopes');
|
|
3859
|
+
const direct = facts.scopes;
|
|
3563
3860
|
if (direct.length === 0)
|
|
3564
3861
|
return { kind: 'none' };
|
|
3565
|
-
const denied =
|
|
3862
|
+
const denied = facts.denies.filter((d) => d.permission === permission).map((d) => d.scope);
|
|
3566
3863
|
if (direct.some((s) => s.type === APP_SCOPE_TYPE)) {
|
|
3567
3864
|
if (denied.length > maxScopes) {
|
|
3568
3865
|
throw new TooManyScopesError(`authorizedScopes: ${denied.length} subárboles excluidos superan maxScopes=${maxScopes}; no se devuelve una lista parcial.`);
|
|
@@ -3642,7 +3939,7 @@ export class AuthorizationManager {
|
|
|
3642
3939
|
}
|
|
3643
3940
|
/**
|
|
3644
3941
|
* Clave LAXA de un scope, solo para agrupar/deduplicar candidatos dentro
|
|
3645
|
-
* de una operación (`authorizedScopes`, el anti-ciclo de `#
|
|
3942
|
+
* de una operación (`authorizedScopes`, el anti-ciclo de `#judgeEdge`).
|
|
3646
3943
|
* NO es `scopeKey` de `identity.ts` —esa valida la gramática y es la que
|
|
3647
3944
|
* identifica hechos, owners e ids de binding, y es la que usa
|
|
3648
3945
|
* `#rolesAlong` (3D · N5)—: aquí los scopes vienen del `descendantsOf` del
|
|
@@ -3826,4 +4123,3 @@ export class AuthorizationManager {
|
|
|
3826
4123
|
}
|
|
3827
4124
|
}
|
|
3828
4125
|
_a = AuthorizationManager;
|
|
3829
|
-
//# sourceMappingURL=manager.js.map
|