@jantstack/adonis-authz 2.5.0-alpha.2 → 2.5.0-alpha.4
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 +743 -28
- 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 +0 -1
- package/build/commands/authz_catalog_prune_orphans.js +0 -1
- 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 +6 -1
- package/build/commands/authz_relations_reconcile.js +163 -5
- package/build/commands/authz_scopes_relay.d.ts +0 -1
- package/build/commands/authz_scopes_relay.js +49 -5
- package/build/commands/authz_scopes_requeue.d.ts +0 -1
- package/build/commands/authz_scopes_requeue.js +14 -2
- 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 +72 -1
- package/build/commands/openfga_provision.js +236 -4
- package/build/configure.d.ts +0 -1
- package/build/configure.js +0 -1
- package/build/index.d.ts +11 -9
- package/build/index.js +12 -5
- 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 +25 -1
- 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 +6 -1
- package/build/src/drivers/database_driver.js +22 -2
- package/build/src/drivers/database_relations_driver.d.ts +46 -5
- package/build/src/drivers/database_relations_driver.js +297 -59
- package/build/src/drivers/openfga_driver.d.ts +159 -26
- package/build/src/drivers/openfga_driver.js +980 -231
- package/build/src/drivers/openfga_facts.d.ts +112 -1
- package/build/src/drivers/openfga_facts.js +351 -11
- package/build/src/drivers/openfga_relations_driver.d.ts +109 -8
- package/build/src/drivers/openfga_relations_driver.js +817 -53
- package/build/src/errors.d.ts +233 -1
- package/build/src/errors.js +283 -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 +2 -1
- package/build/src/identity.js +4 -1
- package/build/src/manager.d.ts +0 -1
- package/build/src/manager.js +164 -31
- 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 +3 -4
- package/build/src/openfga.js +5 -3
- 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 +1 -2
- package/build/src/relations/check_many.js +6 -5
- package/build/src/relations/define_relations_config.d.ts +140 -5
- package/build/src/relations/define_relations_config.js +205 -6
- 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 +46 -8
- package/build/src/relations/manager.js +178 -16
- package/build/src/relations/page.d.ts +19 -0
- package/build/src/relations/page.js +74 -0
- package/build/src/relations/reconcile.d.ts +274 -2
- package/build/src/relations/reconcile.js +1006 -162
- 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 +127 -0
- package/build/src/relations/tuple_order.js +599 -0
- 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 +0 -1
- package/build/src/scope_outbox.js +0 -1
- package/build/src/shared/backend_guard.d.ts +60 -1
- package/build/src/shared/backend_guard.js +179 -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 +7 -1
- package/build/src/testing/contract.js +77 -1
- package/build/src/testing/main.d.ts +1 -2
- package/build/src/testing/main.js +1 -2
- 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 +25 -1
- package/build/src/testing/relations_contract.js +996 -49
- package/build/src/testing/relations_reconcile_contract.d.ts +9 -1
- package/build/src/testing/relations_reconcile_contract.js +207 -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 +170 -15
- 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_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
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
import db from '@adonisjs/lucid/services/db';
|
|
2
2
|
import { OpenFgaClient, ClientWriteRequestOnDuplicateWrites, ClientWriteRequestOnMissingDeletes, ConsistencyPreference, } from '@openfga/sdk';
|
|
3
|
-
import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, MassReconcileRefusedError, ReconcileTooLargeError, PurgeIncompleteError, ScopeCycleError, ScopeDriftUnguardedError, ScopeTreeDriftError, UnknownPermissionError, UnknownRoleError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
|
|
3
|
+
import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, ResolutionLimitError, MassReconcileRefusedError, DenyMigrationRefusedError, ReconcileTooLargeError, PurgeIncompleteError, ScopeCycleError, ScopeDriftUnguardedError, ScopeTreeDriftError, UnknownPermissionError, UnknownRoleError, UnsupportedOperationError, RenewalUnsafeError, WriteConflictError, markDeploymentFailure, markPartialWrite, } from '../errors.js';
|
|
4
4
|
import { APP_SCOPE_TYPE } from '../types.js';
|
|
5
5
|
import { AUTHZ_TABLES_ORIGIN, RECONCILE_MAX_DETAILS, emptyReconcilePhases as emptyPhases, reconcileBatchSize, reconcileMaxTuples, sumReconcilePhases as sumPhases, } from '../reconcile.js';
|
|
6
6
|
import { assertRoleAssignableAt, declaredRoleAt, fromDbScopeUuid, hasRoleTargets, resolveRoleQuery, rolesToRevoke, visibleRoleFor, } from './database_driver.js';
|
|
7
7
|
import { assertCatalogUuid, assertIdentity, assertScope, chainKeysFrom, isCatalogUuid, isValidScope, normalizeRoleQuery, scopeFromKey, scopeKey, } from '../identity.js';
|
|
8
|
-
import { resolveGrantExpiry, sameInstant
|
|
9
|
-
import { assertKnownScope, canonicalScope, canonicalScopeTargets, guardSql, isAuthzError, isTimeoutLike, resolveChain, rootOnlyResolver, withDeadline, } from '../shared/backend_guard.js';
|
|
8
|
+
import { fgaTupleExpiry, resolveGrantExpiry, sameInstant } from '../expiry.js';
|
|
9
|
+
import { assertKnownScope, canonicalScope, canonicalScopeTargets, classifyFgaFailure, fgaFailureError, guardSql, isAuthzError, isServerWriteLimit, isTimeoutLike, resolveChain, rootOnlyResolver, withDeadline, } from '../shared/backend_guard.js';
|
|
10
10
|
import { CatalogCache, GLOBAL_OWNER_KEY, assertCatalogOptions, isRoleVisibleWith, withAuthzCatalogWrite } from '../catalog/catalog_cache.js';
|
|
11
|
-
import { FACTS_ASSIGNEE_RELATION, FACTS_BINDING_RELATION, FACTS_BINDING_TYPE, FACTS_DENIED_PREFIX, FACTS_GROUP_TYPE, FACTS_MAX_RESOLVE_DEPTH, FACTS_PARENT_RELATION, FACTS_PERMITS_PREFIX, FACTS_ROLE_RELATION, FACTS_ROLE_TYPE, FACTS_ROOTED_RELATION, FACTS_SCOPE_TYPE, assertFactsModelPublishable, assertHolderTypes, factsBindingObject, factsBindingTuples, factsCatalogTuples, factsDenyTuple, factsParentTuple, factsRelationsOf, factsRootTuples, factsScopeBindingTuple, factsScopeObject, factsTupleId, openFgaFactsModel, } from './openfga_facts.js';
|
|
11
|
+
import { FACTS_ASSIGNEE_RELATION, FACTS_BINDING_RELATION, FACTS_BINDING_TYPE, FACTS_DENIED_PREFIX, FACTS_GROUP_TYPE, FACTS_MAX_RESOLVE_DEPTH, FACTS_PARENT_RELATION, FACTS_PERMITS_PREFIX, FACTS_ROLE_RELATION, FACTS_ROLE_TYPE, FACTS_ROOTED_RELATION, FACTS_SCOPE_TYPE, assertFactsModelPublishable, assertHolderTypes, assertPinnedModel, assertExperimentalOptIn, factsModelDifferences, factsModelDriftError, factsBindingObject, factsBindingTuples, factsCatalogTuples, factsDenyTuple, factsParentTuple, factsRelationsOf, factsRootTuples, factsScopeBindingTuple, factsScopeObject, factsTupleId, openFgaFactsModel, } from './openfga_facts.js';
|
|
12
12
|
import { readCatalogProjectionSnapshot } from '../catalog/catalog.js';
|
|
13
13
|
import { isSqliteDialect, sqlExpiryCodec } from '../shared/sql_expiry.js';
|
|
14
14
|
import { isClock, systemClock } from '../clock.js';
|
|
@@ -131,7 +131,11 @@ export function alignBatchResults(driver, operation, checks, results) {
|
|
|
131
131
|
const aligned = correlateBatchResults(checks, results);
|
|
132
132
|
const failed = aligned.find((r) => r.error);
|
|
133
133
|
if (failed) {
|
|
134
|
-
|
|
134
|
+
// R3 (injerto 10 del juez = H9 del auditor): el error POR ÍTEM pasa por el MISMO clasificador que la excepción —la
|
|
135
|
+
// clave del objeto `error` (`input_error`/`internal_error`) hace de `apiErrorCode`—, o `checkMany`/`decideMany`/
|
|
136
|
+
// `authorizeMany` divergirían de `check`. El primero en el orden de la PETICIÓN (alineado por `correlationId`: el
|
|
137
|
+
// servidor no devuelve en orden, F0 · M-d nota 2).
|
|
138
|
+
throw fgaFailureError(driver, `${operation} (${failed.request?.relation} ${failed.request?.object})`, failed.error);
|
|
135
139
|
}
|
|
136
140
|
return aligned;
|
|
137
141
|
}
|
|
@@ -170,9 +174,11 @@ export async function provisionOpenFgaStore(apiUrl, name, holderTypeMap, permiss
|
|
|
170
174
|
// en vez de 500 `E_AUTHZ_MODEL_TOO_LARGE`. Cero requests si no es publicable.
|
|
171
175
|
assertFactsModelPublishable(holderTypeMap, permissions, undefined, relations);
|
|
172
176
|
const model = openFgaFactsModel(holderTypeMap, permissions, relations);
|
|
173
|
-
|
|
177
|
+
// Injerto 15 del juez (H12 del auditor): sin reintentos a escondidas también aquí. Desde el SDK 0.9.6 un cliente sin
|
|
178
|
+
// `retryParams` reintenta 3 veces los 429/5xx, y un `createStore` reintentado tras un 5xx puede dejar un store huérfano.
|
|
179
|
+
const client = new OpenFgaClient({ apiUrl, retryParams: { maxRetry: 0 } });
|
|
174
180
|
const store = await client.createStore({ name });
|
|
175
|
-
const scoped = new OpenFgaClient({ apiUrl, storeId: store.id });
|
|
181
|
+
const scoped = new OpenFgaClient({ apiUrl, storeId: store.id, retryParams: { maxRetry: 0 } });
|
|
176
182
|
const written = await scoped.writeAuthorizationModel(model);
|
|
177
183
|
return { storeId: store.id, modelId: written.authorization_model_id };
|
|
178
184
|
}
|
|
@@ -239,6 +245,22 @@ function withPreserveRecipe(error) {
|
|
|
239
245
|
`escribe a ciegas: si la intención es "permanente", pasa { expiresAt: null }; si es temporal, una Date.`;
|
|
240
246
|
return error;
|
|
241
247
|
}
|
|
248
|
+
/**
|
|
249
|
+
* **El gemelo de roles de N3** (lote D · D2; cierre 2 de D1 · ⚪ N3 lo cerró en relaciones): un servidor arrancado con
|
|
250
|
+
* `--max-tuples-per-write` < 100 rechaza con un 400 `exceeded_entity_limit` los `Write` que este driver parte en
|
|
251
|
+
* `PURGE_BATCH_SIZE` (100) — `projectCatalog` (la proyección del catálogo), `reconcile` (`applyReconcileWrites`) y
|
|
252
|
+
* `purgeScope`. Hasta D2 salía como 503 `E_AUTHZ_BACKEND_UNAVAILABLE`, que parece transitorio: repetido, vuelve a
|
|
253
|
+
* fallar igual para siempre. Es configuración del DESPLIEGUE ⇒ 500 `E_AUTHZ_CONFIG` con la letra del requisito, en el
|
|
254
|
+
* PUNTO DE PASO ÚNICO de todas las llamadas del cliente (`guardBackendErrors`).
|
|
255
|
+
*/
|
|
256
|
+
function serverWriteLimitError(operation, cause) {
|
|
257
|
+
const sdk = cause?.apiErrorMessage;
|
|
258
|
+
return new AuthorizationConfigError(`OpenFgaAuthorizationDriver: ${operation}: el servidor OpenFGA rechazó un Write por su tope de operaciones por Write ` +
|
|
259
|
+
`(exceeded_entity_limit${typeof sdk === 'string' ? `: «${sdk}»` : ''}). Este driver parte sus escrituras (proyección ` +
|
|
260
|
+
`del catálogo, reconcile, purgas) en lotes de ${PURGE_BATCH_SIZE} y exige --max-tuples-per-write ≥ ${PURGE_BATCH_SIZE} en ` +
|
|
261
|
+
`el servidor (OPENFGA_MAX_TUPLES_PER_WRITE; el default es ${PURGE_BATCH_SIZE}). No es transitorio: repetida, la operación ` +
|
|
262
|
+
`vuelve a fallar igual. Si ya se habían aplicado Write anteriores de la misma operación, quedó A MEDIAS.`, { cause });
|
|
263
|
+
}
|
|
242
264
|
/**
|
|
243
265
|
* Devuelve el cliente con TODOS sus métodos envueltos: un fallo de red o un
|
|
244
266
|
* 5xx sale como `AuthorizationBackendError` (503) y no como el `FgaError` del
|
|
@@ -254,6 +276,8 @@ function withPreserveRecipe(error) {
|
|
|
254
276
|
* ahí el error del SDK es la información más útil y no rompe ninguna
|
|
255
277
|
* abstracción.
|
|
256
278
|
*/
|
|
279
|
+
/** Los métodos del cliente del SDK que ESCRIBEN en el store (cierre de R3, 🟡 R3-A2: su 504 es indeterminado). */
|
|
280
|
+
const FGA_WRITE_METHODS = new Set(['write', 'writeTuples', 'deleteTuples']);
|
|
257
281
|
function guardBackendErrors(client, timeoutMs) {
|
|
258
282
|
return new Proxy(client, {
|
|
259
283
|
get(target, prop, receiver) {
|
|
@@ -262,9 +286,15 @@ function guardBackendErrors(client, timeoutMs) {
|
|
|
262
286
|
return value;
|
|
263
287
|
return (...args) => {
|
|
264
288
|
const operation = String(prop);
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
289
|
+
// R3 (decisión del dueño 2026-10-17 (3)): la clase la decide `classifyFgaFailure` (por `statusCode` +
|
|
290
|
+
// `apiErrorCode`, jamás por el mensaje). El tope de `Write` del servidor es un despliegue con su letra propia.
|
|
291
|
+
const fail = (cause) => {
|
|
292
|
+
// Cierre de R3 (🟡 R3-A2): el 504 / `deadline_exceeded` de un método que ESCRIBE es indeterminado (timeout).
|
|
293
|
+
const failure = classifyFgaFailure(cause, { write: FGA_WRITE_METHODS.has(operation) });
|
|
294
|
+
if (failure.kind === 'deployment' && isServerWriteLimit(cause))
|
|
295
|
+
return markDeploymentFailure(serverWriteLimitError(operation, cause));
|
|
296
|
+
return fgaFailureError('openfga', operation, cause, { timeoutMs, failure });
|
|
297
|
+
};
|
|
268
298
|
try {
|
|
269
299
|
const result = value.apply(target, args);
|
|
270
300
|
if (!(result instanceof Promise))
|
|
@@ -335,6 +365,55 @@ const TREE_WRITE_ATTEMPTS = 3;
|
|
|
335
365
|
const PURGE_BATCH_SIZE = 100;
|
|
336
366
|
/** Tamaño de página de `Read` (máximo del servidor). */
|
|
337
367
|
const READ_PAGE_SIZE = 100;
|
|
368
|
+
/**
|
|
369
|
+
* **Vueltas del settle** (lote C · carreras, resolución 7 del juez; linaje L13): aplicaciones que
|
|
370
|
+
* `settleRoleVisibility` intenta antes de rendirse. Al agotarlas BORRA las aristas `scope#binding` de los pares (fail-CLOSED,
|
|
371
|
+
* intra-tenant: el churn exige `within` en los dos padres) y lanza 409 `E_AUTHZ_WRITE_CONFLICT` marcado PARCIAL
|
|
372
|
+
* (invariante 13: `indeterminate: true` en `onWrite`).
|
|
373
|
+
*/
|
|
374
|
+
export const SETTLE_MAX_ROUNDS = 5;
|
|
375
|
+
/** Vueltas de «borrar y releer» de `purgeRole` antes de rendirse con 500 `E_AUTHZ_PURGE_INCOMPLETE` (lote C, hallazgo 2). */
|
|
376
|
+
const PURGE_ROLE_ROUNDS = 3;
|
|
377
|
+
/**
|
|
378
|
+
* **La consistencia de TODA lectura que alimenta una escritura** (lote C · carreras, resolución 8 del juez; hallazgo 3 del
|
|
379
|
+
* panel TREE): `HIGHER_CONSISTENCY` SIEMPRE, también con `consistency: 'minimize_latency'`. El argumento del settle (tipo
|
|
380
|
+
* Dekker: «escribo y después leo») cae con UNA sola lectura servida desde una caché o una réplica que no ve la escritura
|
|
381
|
+
* propia, y el `minimize_latency` del driver es un opt-in para DECIDIR, no para dejar estado escrito (medido en el doble: el
|
|
382
|
+
* atajo idempotente de `reparent` con una foto atrasada deja el árbol del store con OTRO padre sin concurrencia, y el barrido
|
|
383
|
+
* de `moved` lee su propia arista rancia). Lo usan `readForWrite`, `checkForWrite` y `readAssignment`; una guardia de fuente
|
|
384
|
+
* (`tests/visibility_settle.spec.ts`) vigila que los métodos que escriben no lean con `this.consistency`.
|
|
385
|
+
*/
|
|
386
|
+
const WRITE_CONSISTENCY = ConsistencyPreference.HigherConsistency;
|
|
387
|
+
const settlePairId = (pair) => `${pair.scope}|${pair.role}`;
|
|
388
|
+
/** `scope#ancestor` = `parent or ancestor from parent` (NO reflexivo, K6): el `Check` del settle (resolución 9, M2: 26 saltos). */
|
|
389
|
+
const FACTS_ANCESTOR_RELATION = 'ancestor';
|
|
390
|
+
/**
|
|
391
|
+
* **El error ORIGINAL manda; el del settle va como causa** (resolución del juez, auditor 🟡 5 (iv)). El settle corre en un
|
|
392
|
+
* `finally` lógico de los tres escritores; si lanza, NO sustituye al error que ya llevaba la operación (un 500
|
|
393
|
+
* `E_AUTHZ_SCOPE_TREE_DRIFT` que aparca no puede salir como un 503 transitorio, ni perder su marca parcial). Se cuelga como
|
|
394
|
+
* `cause` si el original no tiene una; si ya la tiene, como `settleError` (no enumerable). Un error congelado se devuelve
|
|
395
|
+
* tal cual.
|
|
396
|
+
*/
|
|
397
|
+
/** `markPartialWrite` sobre un `unknown` (solo marca objetos). */
|
|
398
|
+
function markPartialObject(error) {
|
|
399
|
+
return error !== null && typeof error === 'object' ? markPartialWrite(error) : error;
|
|
400
|
+
}
|
|
401
|
+
/** ¿Es el 409 de la COTA (o cualquier `E_AUTHZ_WRITE_CONFLICT`)? Ese ya decidió (borró y avisa): no se reintenta. */
|
|
402
|
+
function isWriteConflictLike(error) {
|
|
403
|
+
return error instanceof WriteConflictError || error?.code === WriteConflictError.code;
|
|
404
|
+
}
|
|
405
|
+
function withSettleCause(original, settleError) {
|
|
406
|
+
if (original === null || typeof original !== 'object')
|
|
407
|
+
return original;
|
|
408
|
+
try {
|
|
409
|
+
const property = original.cause === undefined ? 'cause' : 'settleError';
|
|
410
|
+
Object.defineProperty(original, property, { value: settleError, enumerable: false, configurable: true, writable: true });
|
|
411
|
+
}
|
|
412
|
+
catch {
|
|
413
|
+
// congelado: se propaga el original sin la causa (nunca un TypeError en su lugar)
|
|
414
|
+
}
|
|
415
|
+
return original;
|
|
416
|
+
}
|
|
338
417
|
/**
|
|
339
418
|
* El deny del modo `resolver` (≤ 2.2), que el modelo de hoy ni declara pero
|
|
340
419
|
* un store de aquella versión sí tiene: `enumerateFacts` lo EMITE como deny
|
|
@@ -403,6 +482,9 @@ export class OpenFgaAuthorizationDriver {
|
|
|
403
482
|
// `database` para lo que tenga que ir en su transacción, o
|
|
404
483
|
// `requireTransactionalWrites` para fallar al arrancar).
|
|
405
484
|
transactionalWrites: false,
|
|
485
|
+
// Estrategia 2.5.0 · firma 4 (regla D15: clave nueva OPCIONAL, normalizada a `false`): este driver es EXPERIMENTAL en
|
|
486
|
+
// 2.5.x — no se construye sin `experimental: 'openfga@2.5'` y sus límites declarados están en README «Limits».
|
|
487
|
+
experimental: true,
|
|
406
488
|
});
|
|
407
489
|
}
|
|
408
490
|
client;
|
|
@@ -424,6 +506,12 @@ export class OpenFgaAuthorizationDriver {
|
|
|
424
506
|
* con el que este driver escribe y responde, no el último del store.
|
|
425
507
|
*/
|
|
426
508
|
modelId;
|
|
509
|
+
/**
|
|
510
|
+
* **La comparación modelo ↔ config de L11, UNA por proceso** (`modelGate`). Es un OBJETO que se MUTA y nunca se
|
|
511
|
+
* reasigna (H11 b): las vistas `withChainResolver`/`withClock` son `Object.create(this)` y lo heredan por prototipo,
|
|
512
|
+
* así que `forRequest()` no relee el modelo por petición.
|
|
513
|
+
*/
|
|
514
|
+
modelMemo = {};
|
|
427
515
|
consistency;
|
|
428
516
|
/** Reloj de pared del driver (J1): el ÚNICO `now` de checks, filtros y re-grant. */
|
|
429
517
|
now;
|
|
@@ -437,8 +525,13 @@ export class OpenFgaAuthorizationDriver {
|
|
|
437
525
|
*/
|
|
438
526
|
catalog;
|
|
439
527
|
constructor(options) {
|
|
528
|
+
// Estrategia 2.5.0 · firma 4: `openfga` es EXPERIMENTAL en 2.5.x. PRIMERA línea: sin el token, 500 sin crear el cliente.
|
|
529
|
+
assertExperimentalOptIn('OpenFgaAuthorizationDriver', options?.experimental);
|
|
440
530
|
assertHolderTypes(options.holderTypes);
|
|
441
531
|
assertCatalogOptions('OpenFgaAuthorizationDriver', options);
|
|
532
|
+
// L11 · `modelId` OBLIGATORIO, siempre (decisión del dueño 2026-10-17 (2)): sin pin, la puerta compararía el modelo
|
|
533
|
+
// del arranque y el servidor evaluaría con el último de AHORA.
|
|
534
|
+
assertPinnedModel('OpenFgaAuthorizationDriver', options.modelId);
|
|
442
535
|
if (options.now !== undefined && !isClock(options.now)) {
|
|
443
536
|
throw new AuthorizationConfigError(`OpenFgaAuthorizationDriver: 'now' debe ser una función () => Date (llegó ${typeof options.now})`);
|
|
444
537
|
}
|
|
@@ -525,6 +618,53 @@ export class OpenFgaAuthorizationDriver {
|
|
|
525
618
|
checkContext() {
|
|
526
619
|
return checkContext(this.now());
|
|
527
620
|
}
|
|
621
|
+
/**
|
|
622
|
+
* **La lectura del modelo, UNA por proceso** (L11): `readAuthorizationModel` del `modelId` FIJADO y
|
|
623
|
+
* `factsModelDifferences` con `slice: 'roles'` (los holders, `role`, `role_binding`, `scope` y las condiciones; la
|
|
624
|
+
* igualdad es «módulo el catálogo»: `P_M` sale del propio modelo). Se memoriza el ÉXITO —también con diferencias— y un
|
|
625
|
+
* generador que no puede generar (500); **un fallo al LEER el modelo NO se memoriza, sea 503 o 500** (cierre de L11).
|
|
626
|
+
*/
|
|
627
|
+
modelGate() {
|
|
628
|
+
const memo = this.modelMemo;
|
|
629
|
+
if (memo.gate)
|
|
630
|
+
return memo.gate;
|
|
631
|
+
// Cierre de L11 (🟡-6 del tester): se memoriza lo que sale de una COMPARACIÓN HECHA —igual, distinta, o el 500 del
|
|
632
|
+
// generador, que es determinista con ese modelo—, NUNCA un fallo al LEER el modelo, sea de la clase que sea: hoy un
|
|
633
|
+
// 503 (o un 500 que el cliente ya clasifica, p. ej. el tope de Write) y, con R3, el 401/403 o el modelo inexistente
|
|
634
|
+
// que se clasificarán como despliegue (500): un token rotado tiene que poder recuperarse sin reiniciar el proceso.
|
|
635
|
+
let compared = false;
|
|
636
|
+
const pending = (async () => {
|
|
637
|
+
const response = await this.client.readAuthorizationModel();
|
|
638
|
+
const served = response?.authorization_model;
|
|
639
|
+
if (!Array.isArray(served?.type_definitions)) {
|
|
640
|
+
throw new AuthorizationBackendError('openfga', 'readAuthorizationModel', new Error('la respuesta de ReadAuthorizationModel no trae type_definitions; no se toma por un modelo igual'));
|
|
641
|
+
}
|
|
642
|
+
compared = true;
|
|
643
|
+
return factsModelDifferences(served, { holderTypes: this.holderTypes, slice: 'roles' });
|
|
644
|
+
})().catch((error) => {
|
|
645
|
+
if (!compared && memo.gate === pending)
|
|
646
|
+
memo.gate = undefined;
|
|
647
|
+
throw error;
|
|
648
|
+
});
|
|
649
|
+
memo.gate = pending;
|
|
650
|
+
pending.catch(() => { });
|
|
651
|
+
return pending;
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* **La puerta L11 del driver de ROLES** (lote «antes de RC», decisión del dueño 2026-10-17 (1); hasta L11 este driver
|
|
655
|
+
* no comparaba NINGUNA regla —P7: solo el conjunto de `permits_*`, «no es una barrera de seguridad»—: un `can_<P>` sin
|
|
656
|
+
* `but not denied_<P>`, un `rooted` cambiado o un `assignee` sin `not_expired` concedían o negaban sin error). Toda
|
|
657
|
+
* operación que llega al store la pasa ENTERA, justo después de validar su entrada y antes de la primera llamada
|
|
658
|
+
* (catálogo, resolutor y cliente incluidos); `enumerateFacts` —el ORIGEN de una migración— solo la parte
|
|
659
|
+
* `conditions`. Distinta ⇒ 500 `E_AUTHZ_CONFIG` con las rutas y CERO llamadas que evalúen o escriban.
|
|
660
|
+
*/
|
|
661
|
+
async assertModel(operation, part = 'full') {
|
|
662
|
+
const comparison = await this.modelGate();
|
|
663
|
+
const hits = comparison.differences.filter((d) => (part === 'conditions' ? d.type === null && d.path.startsWith('conditions.') : true));
|
|
664
|
+
if (hits.length > 0)
|
|
665
|
+
throw factsModelDriftError('OpenFgaAuthorizationDriver', operation, this.modelId, hits);
|
|
666
|
+
return comparison;
|
|
667
|
+
}
|
|
528
668
|
/**
|
|
529
669
|
* Consulta al catálogo local clasificando su fallo. Con este driver el
|
|
530
670
|
* catálogo SQL sigue siendo una dependencia dura de cada pregunta: su caída
|
|
@@ -555,6 +695,257 @@ export class OpenFgaAuthorizationDriver {
|
|
|
555
695
|
// Antes la fase de roles y `hasRole` lo colapsaban en `false`.
|
|
556
696
|
return alignBatchResults('openfga', 'batchCheck', withIds, response.result);
|
|
557
697
|
}
|
|
698
|
+
/* ── L13 · el SETTLE de la visibilidad de los roles locales (lote C · carreras, opción C firmada 2026-10-20) ──────────
|
|
699
|
+
*
|
|
700
|
+
* En (c2) la visibilidad de un rol LOCAL es un dato DERIVADO materializado (la arista `scope#binding`, invariante 18)
|
|
701
|
+
* que se calcula leyendo OTRO dato mutable (las aristas `scope#parent`) y se escribe después: cualquier escritor que lea
|
|
702
|
+
* el árbol en t0 y escriba en t1 pierde frente a un cambio de árbol en (t0, t1) cuyo barrido ya pasó (TOCTOU entre
|
|
703
|
+
* escritores; R1/R2/R3/K2/K4 del panel, medidos en `loteC-asentar-fase1.md`). El remedio firmado es el SETTLE: tras toda
|
|
704
|
+
* escritura que puede dejar una arista equivocada, RELEER el store con HIGHER y corregir hasta un punto fijo. Por qué
|
|
705
|
+
* basta (tipo Dekker, sobre un store linealizable con HIGHER): cada escritor de visibilidad hace «escribo → leo el árbol»
|
|
706
|
+
* y cada escritor del árbol hace «escribo la arista → leo los bindings»; de dos escrituras concurrentes, al menos una de
|
|
707
|
+
* las dos lecturas posteriores ve la del otro, y quien la ve corrige. Lo que NO cierra (declarado, letra del paso 8): la
|
|
708
|
+
* ventana transitoria entre la escritura equivocada y su corrección, y una caída del proceso dentro de ella.
|
|
709
|
+
*
|
|
710
|
+
* **Punto de paso ÚNICO**: las ALTAS de `scope#binding` de un rol local solo salen de aquí (`writeBindingEdges`, que
|
|
711
|
+
* también usa la decisión previa del `grant`); los borrados pueden ir directos (borrar nunca concede). */
|
|
712
|
+
/**
|
|
713
|
+
* **El catálogo de una ESCRITURA** (K4, resolución 2 del juez): con la versión contrastada en fresco SIEMPRE
|
|
714
|
+
* (`CatalogCache.freshView`), ignore o no la ventana `{ everyMs }`. Un `catalog` inyectado que no lo traiga cae en
|
|
715
|
+
* `view()` (el contrato del memo es suyo).
|
|
716
|
+
*/
|
|
717
|
+
writeCatalog() {
|
|
718
|
+
const catalog = this.catalog;
|
|
719
|
+
return typeof catalog.freshView === 'function' ? catalog.freshView() : catalog.view();
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* K4 (segunda mitad): un uuid de rol que el STORE tiene y el catálogo de la escritura no conoce fuerza UNA recarga del
|
|
723
|
+
* memo (`invalidate` + foto en fresco). Si tras ella sigue ausente, el rol se borró: quien llama BORRA su arista.
|
|
724
|
+
*/
|
|
725
|
+
async writeCatalogKnowing(uuids) {
|
|
726
|
+
const view = await this.writeCatalog();
|
|
727
|
+
for (const uuid of uuids) {
|
|
728
|
+
if (view.roleByUuid(uuid))
|
|
729
|
+
continue;
|
|
730
|
+
this.catalog.invalidate();
|
|
731
|
+
return this.writeCatalog();
|
|
732
|
+
}
|
|
733
|
+
return view;
|
|
734
|
+
}
|
|
735
|
+
/**
|
|
736
|
+
* Un `batchCheck` con `HIGHER_CONSISTENCY` para el settle, SIN colapsar los errores por ítem: el llamante decide qué
|
|
737
|
+
* hacer con un `E_AUTHZ_RESOLUTION_LIMIT` de `ancestor` (caer a `storeChain`, condición 2 de la resolución 9) y propaga
|
|
738
|
+
* el resto (clasificados por el MISMO `fgaFailureError` que `alignBatchResults`).
|
|
739
|
+
*/
|
|
740
|
+
async checkForWrite(checks) {
|
|
741
|
+
if (checks.length === 0)
|
|
742
|
+
return [];
|
|
743
|
+
const withIds = checks.map((check, index) => ({ ...check, correlationId: String(index) }));
|
|
744
|
+
const response = await this.client.batchCheck({ checks: withIds }, { consistency: WRITE_CONSISTENCY });
|
|
745
|
+
return correlateBatchResults(withIds, response.result).map((r) => r.error
|
|
746
|
+
? { error: fgaFailureError('openfga', `settle (${r.request?.relation} ${r.request?.object})`, r.error) }
|
|
747
|
+
: { allowed: r.allowed === true });
|
|
748
|
+
}
|
|
749
|
+
/**
|
|
750
|
+
* **La decisión del settle**, con el STORE (todas las lecturas con HIGHER, en UN `batchCheck`): para cada par, si la
|
|
751
|
+
* arista TIENE que estar (`want`) y si ESTÁ (`has`). `want` exige, por este orden:
|
|
752
|
+
* - el rol en el catálogo de la escritura (K4: en fresco y, si falta, tras una recarga; si sigue faltando, `known: false`
|
|
753
|
+
* y la arista se borra — «rol ausente ⇒ borra»);
|
|
754
|
+
* - declarado para el NIVEL del scope (3b-2g · R1);
|
|
755
|
+
* - `role_binding#role` VIVO (auditor ⚪ 8: tras un `revoke`/`purgeScope`/`purgeRole` concurrente el settle no resucita
|
|
756
|
+
* la arista; `liveness: false` solo en la decisión PREVIA del `grant`, que escribe esa arista en el mismo `Write`);
|
|
757
|
+
* - y, si el rol es LOCAL, su owner ancestro-o-igual del scope EN EL STORE: atajo `scope == owner` (`ancestor` no es
|
|
758
|
+
* reflexivo, K6), `Check(scope:<owner> ancestor scope:<S>)` y, si ese `Check` excede la cota del servidor (medido en M2:
|
|
759
|
+
* solo cuando el owner SÍ es ancestro a > 26 saltos), la cadena del store salto a salto (`storeChain`) — nunca «borrar
|
|
760
|
+
* por si acaso» (fail-closed aleatorio cerca de la cota). La laxitud de `ancestor` ante DOS padres es deriva que ya
|
|
761
|
+
* denuncian `moved` y reconcile (riesgo declarado del juez).
|
|
762
|
+
*/
|
|
763
|
+
async decideRoleVisibility(pairs, options = {}) {
|
|
764
|
+
const decisions = new Map();
|
|
765
|
+
if (pairs.length === 0)
|
|
766
|
+
return decisions;
|
|
767
|
+
const catalog = await this.writeCatalogKnowing(pairs.map((p) => p.role));
|
|
768
|
+
const context = this.checkContext();
|
|
769
|
+
const checks = [];
|
|
770
|
+
const slots = [];
|
|
771
|
+
const seen = new Set();
|
|
772
|
+
for (const pair of pairs) {
|
|
773
|
+
const id = settlePairId(pair);
|
|
774
|
+
if (seen.has(id))
|
|
775
|
+
continue;
|
|
776
|
+
seen.add(id);
|
|
777
|
+
const role = catalog.roleByUuid(pair.role);
|
|
778
|
+
const binding = factsBindingObject(pair.scope, pair.role);
|
|
779
|
+
const levelOk = role !== null && role.scopeType === scopeFromKey(pair.scope)?.type;
|
|
780
|
+
const slot = {
|
|
781
|
+
id,
|
|
782
|
+
pair,
|
|
783
|
+
role,
|
|
784
|
+
levelOk,
|
|
785
|
+
edge: checks.push({ user: binding, relation: FACTS_BINDING_RELATION, object: factsScopeObject(pair.scope), context }) - 1,
|
|
786
|
+
};
|
|
787
|
+
if (role && levelOk) {
|
|
788
|
+
if (options.liveness !== false) {
|
|
789
|
+
slot.live = checks.push({ user: `${FACTS_ROLE_TYPE}:${pair.role}`, relation: FACTS_ROLE_RELATION, object: binding, context }) - 1;
|
|
790
|
+
}
|
|
791
|
+
if (role.owner !== GLOBAL_OWNER_KEY && role.owner !== pair.scope) {
|
|
792
|
+
slot.anc = checks.push({ user: factsScopeObject(role.owner), relation: FACTS_ANCESTOR_RELATION, object: factsScopeObject(pair.scope), context }) - 1;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
slots.push(slot);
|
|
796
|
+
}
|
|
797
|
+
const results = await this.checkForWrite(checks);
|
|
798
|
+
const verdict = (index) => {
|
|
799
|
+
const result = results[index];
|
|
800
|
+
if (result.error)
|
|
801
|
+
throw result.error;
|
|
802
|
+
return result.allowed === true;
|
|
803
|
+
};
|
|
804
|
+
for (const slot of slots) {
|
|
805
|
+
const has = verdict(slot.edge);
|
|
806
|
+
if (!slot.role || !slot.levelOk) {
|
|
807
|
+
decisions.set(slot.id, { want: false, has, known: slot.role !== null });
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
let want = slot.live === undefined ? true : verdict(slot.live);
|
|
811
|
+
if (want && slot.anc !== undefined) {
|
|
812
|
+
const result = results[slot.anc];
|
|
813
|
+
if (result.error) {
|
|
814
|
+
if (!(result.error instanceof ResolutionLimitError))
|
|
815
|
+
throw result.error;
|
|
816
|
+
want = (await this.storeChain(slot.pair.scope)).includes(slot.role.owner);
|
|
817
|
+
}
|
|
818
|
+
else
|
|
819
|
+
want = result.allowed === true;
|
|
820
|
+
}
|
|
821
|
+
decisions.set(slot.id, { want, has, known: true });
|
|
822
|
+
}
|
|
823
|
+
return decisions;
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* **El ÚNICO sitio que da de ALTA una arista `scope#binding` de rol local** (y borra las que se le pidan), en lotes ≤ 100
|
|
827
|
+
* (`Write` con `Ignore` en las dos direcciones: dice el estado que DEBE quedar, no el delta — invariante 6).
|
|
828
|
+
*/
|
|
829
|
+
async writeBindingEdges(writes, deletes, progress) {
|
|
830
|
+
if (!writes.length && !deletes.length)
|
|
831
|
+
return;
|
|
832
|
+
const operations = [...deletes.map((tuple) => ({ delete: tuple })), ...writes.map((tuple) => ({ write: tuple }))];
|
|
833
|
+
for (let i = 0; i < operations.length; i += PURGE_BATCH_SIZE) {
|
|
834
|
+
const chunk = operations.slice(i, i + PURGE_BATCH_SIZE);
|
|
835
|
+
await this.client.write({
|
|
836
|
+
writes: chunk.filter((o) => o.write).map((o) => o.write),
|
|
837
|
+
deletes: chunk.filter((o) => o.delete).map((o) => o.delete),
|
|
838
|
+
}, {
|
|
839
|
+
conflict: {
|
|
840
|
+
onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore,
|
|
841
|
+
onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore,
|
|
842
|
+
},
|
|
843
|
+
});
|
|
844
|
+
progress.writes += 1;
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
/**
|
|
848
|
+
* **`settleRoleVisibility`** — el punto de paso de L13. Decide con el store (`decideRoleVisibility`), y si alguna
|
|
849
|
+
* arista no está como tiene que estar, aplica la diferencia y VUELVE A LEER; termina cuando una lectura POSTERIOR a su
|
|
850
|
+
* última escritura ve el store coherente (punto fijo: «borro una vez» deja CLOSED persistente al entrar, R1-entrar).
|
|
851
|
+
*
|
|
852
|
+
* **Cota** (resolución 7): tras `SETTLE_MAX_ROUNDS` aplicaciones sin converger (un churn que mueve el árbol en cada
|
|
853
|
+
* relectura) se RINDE: borra las aristas de los pares que NO convergieron —y, en el mismo `Write`, `giveUpDeletes` (el `assignee` de un
|
|
854
|
+
* `grant` que CREÓ la asignación: vuelta a «no ocurrió»)— y lanza 409 `E_AUTHZ_WRITE_CONFLICT` marcado PARCIAL
|
|
855
|
+
* (`indeterminate: true`). Fail-CLOSED declarado e intra-tenant; en un re-grant el 409 NO significa «no asignado».
|
|
856
|
+
*
|
|
857
|
+
* Devuelve los pares cuyo rol el catálogo ya no conoce (`known: false`): el `grant` que los CREÓ deshace su asignación.
|
|
858
|
+
*/
|
|
859
|
+
async settleRoleVisibility(pairs, progress, options = {}) {
|
|
860
|
+
if (pairs.length === 0)
|
|
861
|
+
return { unknownRoles: [] };
|
|
862
|
+
try {
|
|
863
|
+
return await this.settleRounds(pairs, progress, options);
|
|
864
|
+
}
|
|
865
|
+
catch (first) {
|
|
866
|
+
// **Asentar · cierre 1 (🟡 4 del auditor; medido)**: un settle que FALLA tras una carrera dejaba la arista rancia
|
|
867
|
+
// —OPEN persistente— y la letra solo declaraba «si el proceso muere» (fallo ∥ carrera, campaña `racefault` de
|
|
868
|
+
// `loteC-asentar-cierre1.ts`: 188 corridas OPEN de 26 464 en línea, 139 con el relay). Ahora, ante un fallo que no
|
|
869
|
+
// sea la cota (409 que ya decidió) ni un error del propio catálogo/arnés: UNA segunda vuelta completa (relee y
|
|
870
|
+
// corrige: un 503 suelto no deja nada); si también falla, sale el error ORIGINAL con el segundo como causa. NO se
|
|
871
|
+
// borra «por si acaso»: un error no es «no visible» (inv. 5, 🟠-3 del tester) y un borrado en bloque cerraría el
|
|
872
|
+
// subárbol entero de un `moved` por un parpadeo. Lo que queda DECLARADO (letra del paso 8): un backend que falla
|
|
873
|
+
// DOS veces seguidas dentro de la ventana deja lo que hubiera —la clase de «una caída dentro de la ventana»—, y lo
|
|
874
|
+
// repara el relay (si era un cambio de árbol con outbox) o lo ve la red (`authz:reconcile --dry-run`, exit ≠ 0).
|
|
875
|
+
if (isWriteConflictLike(first))
|
|
876
|
+
throw first;
|
|
877
|
+
try {
|
|
878
|
+
return await this.settleRounds(pairs, progress, options);
|
|
879
|
+
}
|
|
880
|
+
catch (second) {
|
|
881
|
+
if (isWriteConflictLike(second))
|
|
882
|
+
throw second;
|
|
883
|
+
throw withSettleCause(first, second);
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
/** Las vueltas del settle (lo que hasta el cierre 1 era el cuerpo de `settleRoleVisibility`). */
|
|
888
|
+
async settleRounds(pairs, progress, options) {
|
|
889
|
+
/**
|
|
890
|
+
* El `finally` del PROPIO settle (auditor 🟡 5 (iii)): si su `Write` vence el deadline —pudo confirmarse
|
|
891
|
+
* (invariante 13)—, la vuelta siguiente relee y corrige igual; si TAMBIÉN falla, manda el error del `Write` (el de la
|
|
892
|
+
* relectura va como causa). Una vez convergido tras un timeout del `Write`, el store quedó asentado: no se lanza.
|
|
893
|
+
*/
|
|
894
|
+
let applyError;
|
|
895
|
+
let decisions;
|
|
896
|
+
for (let round = 0;; round++) {
|
|
897
|
+
try {
|
|
898
|
+
decisions = await this.decideRoleVisibility(pairs);
|
|
899
|
+
}
|
|
900
|
+
catch (error) {
|
|
901
|
+
throw applyError ? withSettleCause(applyError.error, error) : error;
|
|
902
|
+
}
|
|
903
|
+
const writes = [];
|
|
904
|
+
const deletes = [];
|
|
905
|
+
const unknownRoles = [];
|
|
906
|
+
for (const pair of pairs) {
|
|
907
|
+
const decision = decisions.get(settlePairId(pair));
|
|
908
|
+
if (!decision.known)
|
|
909
|
+
unknownRoles.push(pair);
|
|
910
|
+
if (decision.want === decision.has)
|
|
911
|
+
continue;
|
|
912
|
+
const edge = factsScopeBindingTuple(pair.scope, pair.role);
|
|
913
|
+
if (decision.want)
|
|
914
|
+
writes.push(edge);
|
|
915
|
+
else
|
|
916
|
+
deletes.push(edge);
|
|
917
|
+
}
|
|
918
|
+
if (writes.length === 0 && deletes.length === 0)
|
|
919
|
+
return { unknownRoles };
|
|
920
|
+
if (round >= SETTLE_MAX_ROUNDS)
|
|
921
|
+
break;
|
|
922
|
+
try {
|
|
923
|
+
await this.writeBindingEdges(writes, deletes, progress);
|
|
924
|
+
}
|
|
925
|
+
catch (error) {
|
|
926
|
+
// Solo un `Write` INDETERMINADO (deadline vencido: pudo confirmarse) se vuelve a verificar; cualquier otro fallo
|
|
927
|
+
// no escribió nada (FGA aplica cada `Write` entero) y se propaga tal cual.
|
|
928
|
+
if (applyError || !(error instanceof AuthorizationBackendTimeoutError))
|
|
929
|
+
throw applyError ? withSettleCause(applyError.error, error) : error;
|
|
930
|
+
applyError = { error };
|
|
931
|
+
}
|
|
932
|
+
}
|
|
933
|
+
// Asentar · cierre 1 (🟡 2 del auditor): la rendición borra SOLO las aristas de los pares que NO convergieron en la
|
|
934
|
+
// última lectura (`want ≠ has`). Con el subárbol de un `moved` alto (varias orgs) o un reconcile (toda la flota), borrar
|
|
935
|
+
// las de TODOS los pares dejaba CLOSED persistente a tenants que no hacían churn: el «intra-tenant» de la resolución 7
|
|
936
|
+
// no se cumplía. Un par con `want` y sin arista ya está cerrado; uno con arista y sin `want` se borra.
|
|
937
|
+
const edges = pairs
|
|
938
|
+
.filter((pair) => {
|
|
939
|
+
const decision = decisions.get(settlePairId(pair));
|
|
940
|
+
return decision.want !== decision.has;
|
|
941
|
+
})
|
|
942
|
+
.map((pair) => factsScopeBindingTuple(pair.scope, pair.role));
|
|
943
|
+
await this.writeBindingEdges([], [...edges, ...(options.giveUpDeletes ?? [])], progress);
|
|
944
|
+
throw markPartialWrite(new WriteConflictError(`La visibilidad de ${pairs.length} asignación(es) de rol local no convergió en ${SETTLE_MAX_ROUNDS} vueltas: el árbol ` +
|
|
945
|
+
`del store cambia en cada relectura (otro escritor mueve estos scopes en bucle). Se han BORRADO sus aristas ` +
|
|
946
|
+
`scope#binding (fail-closed): el rol no concede ahí hasta la siguiente escritura que lo asiente (un grant, un ` +
|
|
947
|
+
`moved del nodo o authz:reconcile). La escritura es PARCIAL: si era un re-grant, la asignación sigue existiendo.`));
|
|
948
|
+
}
|
|
558
949
|
// ── Catálogo local (compartido entre drivers), desde el memo (2A) ──────
|
|
559
950
|
// Una carga por driver/proceso en vez de una o dos consultas SQL por
|
|
560
951
|
// pregunta, más una revalidación por operación (2D · F1). Un fallo de
|
|
@@ -578,6 +969,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
578
969
|
// ── Contrato ──────────────────────────────────────────────────────────
|
|
579
970
|
async authorize(subject, permission, scope) {
|
|
580
971
|
assertIdentity({ subject, permission, scope });
|
|
972
|
+
await this.assertModel('authorize');
|
|
581
973
|
// Una foto del catálogo por pregunta: el permiso sale de la misma versión
|
|
582
974
|
// (y se paga una sola revalidación). Es lo ÚNICO local que queda.
|
|
583
975
|
const catalog = await this.catalog.view();
|
|
@@ -635,6 +1027,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
635
1027
|
assertIdentity({ scope });
|
|
636
1028
|
if (scopes.length === 0)
|
|
637
1029
|
return [];
|
|
1030
|
+
await this.assertModel('authorizeMany');
|
|
638
1031
|
const catalog = await this.catalog.view();
|
|
639
1032
|
const perm = catalog.permission(permission);
|
|
640
1033
|
if (!perm)
|
|
@@ -694,13 +1087,15 @@ export class OpenFgaAuthorizationDriver {
|
|
|
694
1087
|
async grant(subject, role, scope, options = {}) {
|
|
695
1088
|
this.rejectTransaction('grant', options);
|
|
696
1089
|
assertIdentity({ subject, role, scope, expiresAt: options.expiresAt });
|
|
1090
|
+
await this.assertModel('grant');
|
|
697
1091
|
// El binding lleva la identidad canónica del árbol (K1), nunca la forma
|
|
698
1092
|
// del llamante, y el uuid del rol, nunca su slug (3A · A1). El rol tiene
|
|
699
1093
|
// que EXISTIR en ese scope (3B · B2: global, o local a un ancestro-o-igual)
|
|
700
1094
|
// con una composición legal (B5).
|
|
701
1095
|
const chain = await this.knownScope(scope, 'grant');
|
|
702
1096
|
const [target] = chain;
|
|
703
|
-
|
|
1097
|
+
// K4 (lote C · carreras, resolución 2): el catálogo de una ESCRITURA se contrasta en fresco, con ventana o sin ella.
|
|
1098
|
+
const catalog = await this.writeCatalog();
|
|
704
1099
|
const declared = resolveRoleQuery(catalog, role, target, chainKeysFrom(chain)[0]);
|
|
705
1100
|
assertRoleAssignableAt(catalog, declared);
|
|
706
1101
|
const roleUuid = declared.uuid;
|
|
@@ -709,6 +1104,64 @@ export class OpenFgaAuthorizationDriver {
|
|
|
709
1104
|
relation: 'assignee',
|
|
710
1105
|
object: `role_binding:${scopeKey(target)}|${roleUuid}`,
|
|
711
1106
|
};
|
|
1107
|
+
const structure = factsBindingTuples(scopeKey(target), roleUuid);
|
|
1108
|
+
// Un rol GLOBAL no depende del árbol (su nivel es inmutable por uuid): sus tres tuplas, en UN `Write`, sin settle.
|
|
1109
|
+
if (declared.owner === GLOBAL_OWNER_KEY)
|
|
1110
|
+
return this.writeGrant(key, structure, options);
|
|
1111
|
+
// **L13 · un rol LOCAL** (lote C · carreras, opción C firmada; injerto del analista): la visibilidad se decide con el
|
|
1112
|
+
// STORE antes de escribir (`decideRoleVisibility`, HIGHER) — durante el lag del relay al ENTRAR en el owner el `grant`
|
|
1113
|
+
// ya no concede (🟡 4: fail-CLOSED, como `attached`) — y la arista solo va en el `Write` si el store la exige. Después,
|
|
1114
|
+
// pase lo que pase con el `Write` (un timeout pudo confirmarse, invariante 13), el SETTLE relee y corrige hasta un punto
|
|
1115
|
+
// fijo (R2: un `moved` que barrió antes de que este `grant` escribiera).
|
|
1116
|
+
const pair = { scope: scopeKey(target), role: roleUuid };
|
|
1117
|
+
const pre = (await this.decideRoleVisibility([pair], { liveness: false })).get(settlePairId(pair));
|
|
1118
|
+
const progress = { writes: 0, attempted: 0 };
|
|
1119
|
+
let outcome;
|
|
1120
|
+
let failure;
|
|
1121
|
+
try {
|
|
1122
|
+
outcome = await this.writeGrant(key, pre.want ? structure : structure.filter((t) => t.relation !== FACTS_BINDING_RELATION), options, progress);
|
|
1123
|
+
}
|
|
1124
|
+
catch (error) {
|
|
1125
|
+
failure = { error };
|
|
1126
|
+
}
|
|
1127
|
+
const created = outcome?.existed === false;
|
|
1128
|
+
let settled = { unknownRoles: [] };
|
|
1129
|
+
if (failure === undefined || progress.attempted > 0) {
|
|
1130
|
+
try {
|
|
1131
|
+
// Rendición de la cota con una asignación RECIÉN creada: el `assignee` se va en el MISMO `Write` que la arista.
|
|
1132
|
+
settled = await this.settleRoleVisibility([pair], progress, { giveUpDeletes: created ? [key] : [] });
|
|
1133
|
+
}
|
|
1134
|
+
catch (settleError) {
|
|
1135
|
+
// El error ORIGINAL manda (el del settle va como causa); sin original, el del settle —ya escribimos: PARCIAL—.
|
|
1136
|
+
if (failure)
|
|
1137
|
+
throw withSettleCause(progress.writes > 0 ? markPartialObject(failure.error) : failure.error, settleError);
|
|
1138
|
+
throw markPartialObject(settleError);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
if (settled.unknownRoles.length > 0) {
|
|
1142
|
+
// **Hallazgo 2 del panel TREE (ORPHAN)**: el rol se PURGÓ mientras se asignaba (`purgeRole` concurrente). Una
|
|
1143
|
+
// asignación de un rol que ya no existe no se deja: se borran sus TRES tuplas en UN `Write` (vuelta a «no ocurrió»;
|
|
1144
|
+
// con el rol purgado, `role_binding#role` no es de nadie más) y 409 marcado parcial. **Asentar · cierre 1 (🟡 3 del
|
|
1145
|
+
// auditor)**: también en un RE-grant (`existed: true`: el `replaceAssignment` repone `assignee` y `role_binding#role`
|
|
1146
|
+
// DESPUÉS del repaso de la purga) y también si el `Write` lanzó tras confirmarse (antes, `if (failure) throw` salía
|
|
1147
|
+
// primero): con el rol purgado no hay nada que preservar. El error original, si lo hay, manda.
|
|
1148
|
+
await this.client.write({ writes: [], deletes: [key, ...structure] }, { conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore, onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore } });
|
|
1149
|
+
if (failure)
|
|
1150
|
+
throw markPartialObject(failure.error);
|
|
1151
|
+
throw markPartialWrite(new WriteConflictError(`grant: el rol ${roleUuid} se purgó del catálogo mientras se asignaba en ${scopeKey(target)}; la asignación se ` +
|
|
1152
|
+
`ha deshecho (no ocurrió). Si el rol se vuelve a crear, repite el grant.`));
|
|
1153
|
+
}
|
|
1154
|
+
if (failure)
|
|
1155
|
+
throw progress.writes > 0 ? markPartialObject(failure.error) : failure.error;
|
|
1156
|
+
return outcome;
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* El cuerpo de ESCRITURA del `grant` (sin la decisión de visibilidad): lee la asignación, decide entre no-op, write o
|
|
1160
|
+
* delete+write, y re-aplica ante una carrera. `structure` son las aristas que van con el `assignee` en el MISMO `Write`
|
|
1161
|
+
* (las tres para un rol global; sin la `scope#binding` si el store dice que el rol local no es visible ahí). `progress`
|
|
1162
|
+
* cuenta los `Write` INTENTADOS (el settle del `grant` local corre si hubo alguno, también si falló: invariante 13).
|
|
1163
|
+
*/
|
|
1164
|
+
async writeGrant(key, structure, options, progress = { attempted: 0 }) {
|
|
712
1165
|
// **La forma nueva de los objetos (3b-2c), en UNA sola escritura
|
|
713
1166
|
// (3b-2f · R3).** En (c2) la asignación no es alcanzable con el
|
|
714
1167
|
// `assignee` a secas: el binding tiene que colgar del scope
|
|
@@ -722,7 +1175,6 @@ export class OpenFgaAuthorizationDriver {
|
|
|
722
1175
|
// mismo hecho, que es peor que perder la escritura. Ahora las TRES van
|
|
723
1176
|
// en el mismo `Write` (transaccional en FGA): o están las tres o no está
|
|
724
1177
|
// ninguna.
|
|
725
|
-
const structure = factsBindingTuples(scopeKey(target), roleUuid);
|
|
726
1178
|
/** El write COMPLETO de una asignación: su estructura y ella. */
|
|
727
1179
|
const writeSet = (tuple) => [...structure, tuple];
|
|
728
1180
|
const tupleFor = (expiresAt) => expiresAt
|
|
@@ -751,14 +1203,20 @@ export class OpenFgaAuthorizationDriver {
|
|
|
751
1203
|
if (options.expiresAt === undefined)
|
|
752
1204
|
throw withPreserveRecipe(current.error);
|
|
753
1205
|
const expiresAt = options.expiresAt;
|
|
1206
|
+
progress.attempted += 1;
|
|
754
1207
|
const existed = await this.writeAssignment(key, tupleFor(expiresAt), structure);
|
|
755
1208
|
return { existed, expiresAt };
|
|
756
1209
|
}
|
|
757
1210
|
if (current.kind === 'present') {
|
|
1211
|
+
// L11 · H8: la asignación YA existe con una condición que el paquete no escribe o no sabe leer: ni «sin
|
|
1212
|
+
// caducidad» (no-op mudo) ni «vencida» (reescritura a ciegas). 409, cero escrituras, receta.
|
|
1213
|
+
if (current.invalid !== undefined)
|
|
1214
|
+
throw RenewalUnsafeError.foreignCondition('grant', `${key.object}#${key.relation}@${key.user}`, current.invalid);
|
|
758
1215
|
const expiresAt = resolveGrantExpiry(current.validUntil, options.expiresAt, this.now());
|
|
759
1216
|
if (sameInstant(current.validUntil, expiresAt)) {
|
|
760
1217
|
return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
|
|
761
1218
|
}
|
|
1219
|
+
progress.attempted += 1;
|
|
762
1220
|
await this.replaceAssignment(key, tupleFor(expiresAt), structure);
|
|
763
1221
|
return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
|
|
764
1222
|
}
|
|
@@ -775,7 +1233,8 @@ export class OpenFgaAuthorizationDriver {
|
|
|
775
1233
|
// a la vez: la asignación sigue sin existir, así que se repite el
|
|
776
1234
|
// MISMO write ignorando duplicados (las aristas no llevan condición,
|
|
777
1235
|
// así que un duplicado suyo es idéntico por construcción). Sigue
|
|
778
|
-
// siendo UNA escritura y sigue siendo atómica
|
|
1236
|
+
// siendo UNA escritura y sigue siendo atómica PARA EL ESCRITOR (para
|
|
1237
|
+
// un lector, `can_<P>` exige las tres: una mezcla es fail-closed).
|
|
779
1238
|
//
|
|
780
1239
|
// Cuál de los dos fue lo dice la RELECTURA, no el error: el conflicto
|
|
781
1240
|
// transaccional de FGA (`Aborted`, HTTP 409) no nombra ninguna tupla.
|
|
@@ -791,6 +1250,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
791
1250
|
const expiresAt = options.expiresAt ?? null;
|
|
792
1251
|
for (let attempt = 0;; attempt++) {
|
|
793
1252
|
try {
|
|
1253
|
+
progress.attempted += 1;
|
|
794
1254
|
await this.client.writeTuples(writeSet(tupleFor(expiresAt)), attempt === 0 ? undefined : IGNORE_DUPLICATE_WRITES);
|
|
795
1255
|
return { existed: false, expiresAt };
|
|
796
1256
|
}
|
|
@@ -803,8 +1263,11 @@ export class OpenFgaAuthorizationDriver {
|
|
|
803
1263
|
}
|
|
804
1264
|
const raced = await this.readAssignment(key);
|
|
805
1265
|
if (raced.kind === 'present') {
|
|
1266
|
+
if (raced.invalid !== undefined)
|
|
1267
|
+
throw RenewalUnsafeError.foreignCondition('grant', `${key.object}#${key.relation}@${key.user}`, raced.invalid);
|
|
806
1268
|
const target = resolveGrantExpiry(raced.validUntil, options.expiresAt, this.now());
|
|
807
1269
|
if (!sameInstant(raced.validUntil, target)) {
|
|
1270
|
+
progress.attempted += 1;
|
|
808
1271
|
await this.replaceAssignment(key, tupleFor(target), structure);
|
|
809
1272
|
}
|
|
810
1273
|
return { existed: true, previousExpiresAt: raced.validUntil, expiresAt: target };
|
|
@@ -814,6 +1277,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
814
1277
|
// preservar, y el error de lectura va como causa.
|
|
815
1278
|
if (options.expiresAt === undefined)
|
|
816
1279
|
throw withPreserveRecipe(raced.error);
|
|
1280
|
+
progress.attempted += 1;
|
|
817
1281
|
const existed = await this.writeAssignment(key, tupleFor(options.expiresAt), structure);
|
|
818
1282
|
return { existed, expiresAt: options.expiresAt };
|
|
819
1283
|
}
|
|
@@ -857,7 +1321,16 @@ export class OpenFgaAuthorizationDriver {
|
|
|
857
1321
|
await this.client.deleteTuples([key], {
|
|
858
1322
|
conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
|
|
859
1323
|
});
|
|
860
|
-
|
|
1324
|
+
// Cierre 3 de D2 · ⚪ C2-5 (gemelo de roles del N1 (b) de relaciones): el delete YA entró — si el write falla, el
|
|
1325
|
+
// `assignee` quedó BORRADO (el grant se perdió: fail-closed) y la escritura es PARCIAL, no «no escrita»: el error va
|
|
1326
|
+
// marcado (`markPartialWrite`) y el manager notifica `indeterminate: true` (invariante 13). Hasta el cierre 3 era un
|
|
1327
|
+
// 503 sin marca y sin evento, con el grant ya revocado.
|
|
1328
|
+
try {
|
|
1329
|
+
await this.client.writeTuples([...structure, tuple], IGNORE_DUPLICATE_WRITES);
|
|
1330
|
+
}
|
|
1331
|
+
catch (error) {
|
|
1332
|
+
throw error !== null && typeof error === 'object' ? markPartialWrite(error) : error;
|
|
1333
|
+
}
|
|
861
1334
|
}
|
|
862
1335
|
/**
|
|
863
1336
|
* Estado actual de una asignación, con TRES resultados posibles y no dos.
|
|
@@ -870,15 +1343,15 @@ export class OpenFgaAuthorizationDriver {
|
|
|
870
1343
|
*/
|
|
871
1344
|
async readAssignment(key) {
|
|
872
1345
|
try {
|
|
873
|
-
|
|
1346
|
+
// Hallazgo 3 TREE: la lectura decide la ESCRITURA del `grant` (omitido = preservar) ⇒ HIGHER siempre.
|
|
1347
|
+
const response = await this.client.read(key, { consistency: WRITE_CONSISTENCY });
|
|
874
1348
|
// ⚪ N5 (cierre 2 del lote B): un 200 sin `tuples` no es «ausente», es
|
|
875
1349
|
// «no se sabe» (503 de `tuplesOrThrow` como causa): sin objetivo explícito
|
|
876
1350
|
// `grant` no escribe a ciegas sobre una caducidad que no ha podido ver.
|
|
877
1351
|
const tuple = this.tuplesOrThrow(response, 'readAssignment')[0];
|
|
878
1352
|
if (!tuple)
|
|
879
1353
|
return { kind: 'absent' };
|
|
880
|
-
|
|
881
|
-
return { kind: 'present', validUntil: toExpiryDate(validUntil) };
|
|
1354
|
+
return { kind: 'present', ...storedCondition(tuple.key?.condition) };
|
|
882
1355
|
}
|
|
883
1356
|
catch (error) {
|
|
884
1357
|
return { kind: 'unknown', error };
|
|
@@ -887,10 +1360,14 @@ export class OpenFgaAuthorizationDriver {
|
|
|
887
1360
|
async revoke(subject, role, scope, options) {
|
|
888
1361
|
this.rejectTransaction('revoke', options);
|
|
889
1362
|
assertIdentity({ subject, role, scope });
|
|
1363
|
+
await this.assertModel('revoke');
|
|
890
1364
|
// Rol fuera del catálogo para ese nivel ⇒ 422, como en `grant` (D10). Se
|
|
891
1365
|
// quitan los bindings de TODOS los roles con ese nombre en el scope
|
|
892
1366
|
// exacto (3B): a lo sumo uno es visible ahí; quitar nunca concede.
|
|
893
|
-
|
|
1367
|
+
// Asentar · cierre 1 (🟡 5 del auditor, K4): el catálogo de una ESCRITURA, con la versión en fresco. Con el memo de
|
|
1368
|
+
// `{ everyMs }` un local recién definido en otro proceso que ensombrece al global no salía en `rolesNamed`: el delete
|
|
1369
|
+
// con `Ignore` era un no-op y la asignación local seguía concediendo (un «revocado» falso, mudo).
|
|
1370
|
+
const named = rolesToRevoke(await this.writeCatalog(), role, scope);
|
|
894
1371
|
// Sin cadena, TODAS las ortografías de las que el uuid puede ser alias
|
|
895
1372
|
// (3b-8 · A4): con una sola, el alias hacía del delete un no-op
|
|
896
1373
|
// silencioso (`onMissingDeletes: Ignore`) y el hecho canónico seguía ahí.
|
|
@@ -900,6 +1377,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
900
1377
|
}
|
|
901
1378
|
async hasRole(subject, role, scope) {
|
|
902
1379
|
assertIdentity({ subject, role, scope });
|
|
1380
|
+
await this.assertModel('hasRole');
|
|
903
1381
|
const user = this.fgaSubject(subject);
|
|
904
1382
|
const chain = await this.chain(scope, 'hasRole');
|
|
905
1383
|
if (!chain)
|
|
@@ -926,6 +1404,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
926
1404
|
async deny(subject, permission, scope, options) {
|
|
927
1405
|
this.rejectTransaction('deny', options);
|
|
928
1406
|
assertIdentity({ subject, permission, scope });
|
|
1407
|
+
await this.assertModel('deny');
|
|
929
1408
|
const perm = await this.findPermission(permission);
|
|
930
1409
|
if (!perm)
|
|
931
1410
|
throw new UnknownPermissionError(permission);
|
|
@@ -937,6 +1416,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
937
1416
|
async removeDeny(subject, permission, scope, options) {
|
|
938
1417
|
this.rejectTransaction('removeDeny', options);
|
|
939
1418
|
assertIdentity({ subject, permission, scope });
|
|
1419
|
+
await this.assertModel('removeDeny');
|
|
940
1420
|
const perm = await this.findPermission(permission);
|
|
941
1421
|
if (!perm)
|
|
942
1422
|
throw new UnknownPermissionError(permission);
|
|
@@ -969,6 +1449,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
969
1449
|
*/
|
|
970
1450
|
async listSubjects(role, scope) {
|
|
971
1451
|
assertIdentity({ role, scope });
|
|
1452
|
+
await this.assertModel('listSubjects');
|
|
972
1453
|
// Un rol que el catálogo no declara para ese nivel (en ningún owner) no
|
|
973
1454
|
// tiene holders (D5): nada que leer, ni árbol ni store. Un scope que el
|
|
974
1455
|
// árbol no conoce no existe para el motor (D8, K1): nada; uno que conoce
|
|
@@ -1032,7 +1513,10 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1032
1513
|
user: this.fgaSubject(subject),
|
|
1033
1514
|
relation: factsRelationsOf(slug).denied,
|
|
1034
1515
|
object: `${FACTS_SCOPE_TYPE}:`,
|
|
1035
|
-
},
|
|
1516
|
+
},
|
|
1517
|
+
// Cierre de L11 (🟡-3 del tester, gemelo de `#carriedDeny`): el deny con una condición que el paquete no escribe
|
|
1518
|
+
// cuenta VIVO donde NIEGA — `listScopes` lista de menos, nunca de más.
|
|
1519
|
+
{ at, includeInvalid: true });
|
|
1036
1520
|
return new Set(tuples.map((t) => t.object.slice(FACTS_SCOPE_TYPE.length + 1)));
|
|
1037
1521
|
}
|
|
1038
1522
|
/**
|
|
@@ -1088,6 +1572,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1088
1572
|
}
|
|
1089
1573
|
async listRoles(subject, scope) {
|
|
1090
1574
|
assertIdentity({ subject, scope });
|
|
1575
|
+
await this.assertModel('listRoles');
|
|
1091
1576
|
// Un scope que el árbol no conoce no existe para el motor (D8): nada.
|
|
1092
1577
|
const chain = await this.chain(scope, 'listRoles');
|
|
1093
1578
|
if (!chain)
|
|
@@ -1117,6 +1602,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1117
1602
|
assertScope(scope);
|
|
1118
1603
|
if (chain.length === 0)
|
|
1119
1604
|
return [];
|
|
1605
|
+
await this.assertModel('rolesInChain');
|
|
1120
1606
|
const catalog = await this.catalog.view();
|
|
1121
1607
|
const keysFrom = chainKeysFrom(chain);
|
|
1122
1608
|
const wanted = new Map(chain.map((s, i) => [scopeKey(s), { scope: s, keys: keysFrom[i] }]));
|
|
@@ -1142,6 +1628,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1142
1628
|
}
|
|
1143
1629
|
async listRoleScopes(subject, scopeType) {
|
|
1144
1630
|
assertIdentity({ subject, scopeType });
|
|
1631
|
+
await this.assertModel('listRoleScopes');
|
|
1145
1632
|
const catalog = await this.catalog.view();
|
|
1146
1633
|
const byScope = new Map();
|
|
1147
1634
|
for (const binding of await this.listBindings(subject)) {
|
|
@@ -1168,6 +1655,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1168
1655
|
}
|
|
1169
1656
|
async listScopes(subject, permission) {
|
|
1170
1657
|
assertIdentity({ subject, permission });
|
|
1658
|
+
await this.assertModel('listScopes');
|
|
1171
1659
|
const catalog = await this.catalog.view();
|
|
1172
1660
|
const perm = catalog.permission(permission);
|
|
1173
1661
|
if (!perm)
|
|
@@ -1205,6 +1693,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1205
1693
|
*/
|
|
1206
1694
|
async listDenies(subject, scope) {
|
|
1207
1695
|
assertIdentity(scope ? { subject, scope } : { subject });
|
|
1696
|
+
await this.assertModel('listDenies');
|
|
1208
1697
|
const chain = scope ? await this.chain(scope, 'listDenies') : null;
|
|
1209
1698
|
if (scope && !chain)
|
|
1210
1699
|
return [];
|
|
@@ -1255,8 +1744,12 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1255
1744
|
* borrar una tupla inexistente—, la cadena del padre NUEVO en el store
|
|
1256
1745
|
* (3b-8 · A5: el anti-ciclos del consumidor no ve un store
|
|
1257
1746
|
* desincronizado) y **UN solo `Write`** con el delete del padre viejo y el
|
|
1258
|
-
* write del nuevo, que es atómico
|
|
1259
|
-
*
|
|
1747
|
+
* write del nuevo, que es atómico PARA EL ESCRITOR (el store nunca guarda
|
|
1748
|
+
* los dos padres ni ninguno) pero NO para un lector concurrente: un `Check`
|
|
1749
|
+
* que dura lo que el `Write` puede leer la arista vieja por una rama del
|
|
1750
|
+
* modelo y la nueva por otra (lector rasgado, fila «torn read» de Limits del
|
|
1751
|
+
* README; medido 1,2–7,1 %, decisión del dueño 6b). O(profundidad) requests;
|
|
1752
|
+
* con roles locales, además el barrido F4 (ver `reparent`).
|
|
1260
1753
|
*/
|
|
1261
1754
|
async onScopeMoved(child, newParent) {
|
|
1262
1755
|
await this.reparent(child, newParent, 'scopes.moved');
|
|
@@ -1326,9 +1819,10 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1326
1819
|
if (child.type === APP_SCOPE_TYPE) {
|
|
1327
1820
|
throw new InvalidIdentityError('scopes.detached: la raíz `app` no se puede borrar ni purgar');
|
|
1328
1821
|
}
|
|
1822
|
+
await this.assertModel('scopes.detached');
|
|
1329
1823
|
const scope = await this.canonicalOrSelf(child, 'scopes.detached');
|
|
1330
1824
|
const object = factsScopeObject(scopeKey(scope));
|
|
1331
|
-
const current = await this.
|
|
1825
|
+
const current = await this.readForWrite({ object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
|
|
1332
1826
|
// Aquí NO se exige un solo padre: el nodo se va entero y llevarse las dos
|
|
1333
1827
|
// aristas de una deriva es lo correcto (a diferencia de `moved`, donde
|
|
1334
1828
|
// habría que elegir cuál sobrevive).
|
|
@@ -1360,58 +1854,115 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1360
1854
|
* impide esa carrera es el ESCRITOR ÚNICO del relay (`ScopeOutbox.acquire`).
|
|
1361
1855
|
*/
|
|
1362
1856
|
async reparent(child, parent, operation) {
|
|
1857
|
+
// Cierre de L11 (⚪ A3.4 del auditor): la puerta ANTES de resolver la arista (`assertEdge` lee la cadena del
|
|
1858
|
+
// CONSUMIDOR), como en el resto de métodos — la desviación 3 del informe («antes del catálogo y del resolutor») era
|
|
1859
|
+
// falsa aquí. Consecuencia, fail-closed: con un modelo distinto un `attached`/`moved` es 500 aunque la arista fuera 422.
|
|
1860
|
+
await this.assertModel(operation);
|
|
1363
1861
|
const { childKey, parentKey } = await this.assertEdge(child, parent, operation);
|
|
1364
1862
|
const wanted = factsParentTuple(childKey, parentKey);
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1863
|
+
// Lote «antes de RC» · F4 (invariante 13): cuántos `Write` YA entraron. Un error tras ≥ 1 sale marcado PARCIAL (el
|
|
1864
|
+
// barrido previo o la arista ya cambiaron respuestas); antes del primero, no. Lote C (L13): `attempted` cuenta los
|
|
1865
|
+
// INTENTADOS —un `Write` que vence el deadline pudo confirmarse—, y con ≥ 1 el SETTLE corre también si la operación
|
|
1866
|
+
// lanza (`finally` lógico de la resolución del juez; el error ORIGINAL manda).
|
|
1867
|
+
const progress = { writes: 0, attempted: 0 };
|
|
1868
|
+
/** El settle ya corrió (o está corriendo): un error SUYO no lo vuelve a lanzar desde el `catch`. */
|
|
1869
|
+
let settling = false;
|
|
1870
|
+
const settle = async () => {
|
|
1871
|
+
settling = true;
|
|
1872
|
+
await this.settleSubtree(childKey, progress);
|
|
1873
|
+
};
|
|
1874
|
+
try {
|
|
1875
|
+
for (let attempt = 0;; attempt++) {
|
|
1876
|
+
// Hallazgo 3 del panel TREE: la lectura del padre ALIMENTA el `Write` de la arista (y su atajo idempotente) ⇒
|
|
1877
|
+
// HIGHER. Con una foto atrasada el atajo veía «ya está ahí» y volvía sin escribir: el store se quedaba con OTRO
|
|
1878
|
+
// padre que el consumidor, sin concurrencia (`churnC` solo, en `minimize`).
|
|
1879
|
+
const current = await this.readForWrite({ object: wanted.object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
|
|
1880
|
+
this.assertOneParent(wanted.object, current);
|
|
1881
|
+
if (current.length === 1 && current[0].user === wanted.user) {
|
|
1882
|
+
// **El atajo idempotente TAMBIÉN barre** (3b-8 · A3). El relay
|
|
1883
|
+
// reintenta: un `moved` cuyo primer intento escribió la arista y
|
|
1884
|
+
// murió ANTES del barrido vuelve a entregarse, cae aquí, y hasta
|
|
1885
|
+
// 3b-8 el `return` a secas se saltaba `sweepLocalRoleBindings` — el
|
|
1886
|
+
// rol local del tenant A seguía concediendo en el subárbol del B
|
|
1887
|
+
// para siempre, con el relay en verde (invariante 18 roto por
|
|
1888
|
+
// COMPOSICIÓN de dos piezas correctas: la idempotencia y el barrido).
|
|
1889
|
+
// Desde F4 es el barrido COMPLETO de después; desde el lote C, el SETTLE del subárbol.
|
|
1890
|
+
// Coste: cero requests sin roles locales, como el propio barrido.
|
|
1891
|
+
await settle();
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
// **El ciclo se mira también en el árbol DEL STORE** (3b-8 · A5).
|
|
1895
|
+
// `assertEdge` valida contra la cadena del CONSUMIDOR, pero quien
|
|
1896
|
+
// decide en `facts` es el store, y una outbox aplicada en desorden (un
|
|
1897
|
+
// `moved` aparcado + el move inverso posterior) lo deja desincronizado:
|
|
1898
|
+
// la arista nueva —legal para el consumidor— puede cerrar un ciclo
|
|
1899
|
+
// REAL, que FGA acepta y evalúa en los dos sentidos (cruce 3:
|
|
1900
|
+
// fail-open mudo). `storeChain` del barrido solo lanzaba DESPUÉS de
|
|
1901
|
+
// escribir y no deshacía nada. Se sube por la cadena del PADRE en el
|
|
1902
|
+
// store antes del `Write`; el coste es O(profundidad) lecturas por
|
|
1903
|
+
// cambio de árbol, fuera del camino caliente.
|
|
1904
|
+
const parentChain = await this.storeChain(parentKey);
|
|
1905
|
+
if (parentChain.includes(childKey)) {
|
|
1906
|
+
throw new ScopeCycleError(`${operation}: en el árbol del STORE ${factsScopeObject(parentKey)} desciende de ${childKey} ` +
|
|
1907
|
+
`(o es él mismo); escribir la arista cerraría un ciclo real que FGA evalúa en los dos ` +
|
|
1908
|
+
`sentidos. El árbol del store está desincronizado del consumidor (una outbox aplicada en ` +
|
|
1909
|
+
`desorden, una entrada aparcada): reconstrúyelo con authz:reconcile.`);
|
|
1910
|
+
}
|
|
1911
|
+
// **F4 — el barrido PREVIO** (lote «antes de RC», decisión del dueño 6b-estudio, opción E; linaje L10): ANTES de
|
|
1912
|
+
// escribir la arista se BORRAN las aristas `scope#binding` de los roles locales que la regla del invariante 18
|
|
1913
|
+
// prohíbe con la cadena NUEVA (la del padre en el store + el hijo). Hasta F4 el orden era arista → barrido, y el
|
|
1914
|
+
// estado intermedio «hijo bajo el padre nuevo con el binding del owner viejo visible» era un estado REAL del store
|
|
1915
|
+
// que concedía —saltándose un deny del owner— en el 15,9 % de los `Check` concurrentes (🟠 del auditor del
|
|
1916
|
+
// estudio 6b). Con el barrido previo ese prefijo falla CERRADO: el usuario pierde el rol local antes de tiempo,
|
|
1917
|
+
// nunca lo gana. Solo BORRA: escribir aquí lo que la cadena nueva exige concedería bajo el padre VIEJO (entrar en
|
|
1918
|
+
// el owner con un deny en el owner: mutante `writes-before-edge`). Las escrituras van en el SETTLE de después. Lo
|
|
1919
|
+
// que NO elimina: un `Check` que dura los dos `Write` (lee el binding antes del primero y la arista después del
|
|
1920
|
+
// segundo) — el residuo «dos escrituras en un Check» de la fila «torn read» (medido con c2r por el auditor de F4:
|
|
1921
|
+
// ~1,8·10⁻³ en un DESCENDIENTE al salir del owner, ~10⁻⁵ en el nodo movido). Las carreras entre ESCRITORES
|
|
1922
|
+
// (`projectCatalogRole`/`grant` concurrentes con el move) las cierra el settle (lote C, L13).
|
|
1923
|
+
await this.sweepLocalRoleBindings(childKey, progress, { phase: 'pre', chain: [childKey, ...parentChain] });
|
|
1924
|
+
try {
|
|
1925
|
+
progress.attempted += 1;
|
|
1926
|
+
await this.client.write({ writes: [wanted], deletes: current });
|
|
1927
|
+
progress.writes += 1;
|
|
1928
|
+
}
|
|
1929
|
+
catch (error) {
|
|
1930
|
+
if (!isTreeWriteRace(error))
|
|
1931
|
+
throw error;
|
|
1932
|
+
if (attempt >= TREE_WRITE_ATTEMPTS - 1) {
|
|
1933
|
+
throw new WriteConflictError(`${operation}: ${TREE_WRITE_ATTEMPTS} intentos y el árbol del store sigue en conflicto sobre ` +
|
|
1934
|
+
`${wanted.object}: otro escritor toca las mismas tuplas (otra escritura del árbol fuera del relay, o dos ` +
|
|
1935
|
+
`pasadas a la vez sin lease en tu outbox). Es contención, no una entrada irreparable: en el relay NO ` +
|
|
1936
|
+
`consume intento y la pasada siguiente relee y re-aplica.`, { cause: error });
|
|
1937
|
+
}
|
|
1938
|
+
continue;
|
|
1939
|
+
}
|
|
1940
|
+
// **El SETTLE del subárbol** (lote C · L13; sustituye al barrido COMPLETO de F4): con la arista ya escrita, relee
|
|
1941
|
+
// el store (HIGHER) y deja cada `scope#binding` de rol local del subárbol como dice la cadena DEL STORE, hasta un
|
|
1942
|
+
// punto fijo. Escribe lo que la cadena nueva exige y vuelve a borrar lo que prohíbe: borrar otra vez no es
|
|
1943
|
+
// redundante — un `grant` del rol local que aterrice ENTRE el barrido previo y la arista deja su `scope#binding`
|
|
1944
|
+
// visible fuera del owner, y solo esta pasada lo limpia (mutante `post-writes-only`) —; y un `projectCatalogRole` o
|
|
1945
|
+
// un `moved` del padre que corran a la vez se ven en la relectura (R1/R3/K2).
|
|
1946
|
+
await settle();
|
|
1378
1947
|
return;
|
|
1379
1948
|
}
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
// cambio de árbol, fuera del camino caliente.
|
|
1390
|
-
if ((await this.storeChain(parentKey)).includes(childKey)) {
|
|
1391
|
-
throw new ScopeCycleError(`${operation}: en el árbol del STORE ${factsScopeObject(parentKey)} desciende de ${childKey} ` +
|
|
1392
|
-
`(o es él mismo); escribir la arista cerraría un ciclo real que FGA evalúa en los dos ` +
|
|
1393
|
-
`sentidos. El árbol del store está desincronizado del consumidor (una outbox aplicada en ` +
|
|
1394
|
-
`desorden, una entrada aparcada): reconstrúyelo con authz:reconcile.`);
|
|
1395
|
-
}
|
|
1396
|
-
try {
|
|
1397
|
-
await this.client.write({ writes: [wanted], deletes: current });
|
|
1398
|
-
}
|
|
1399
|
-
catch (error) {
|
|
1400
|
-
if (!isTreeWriteRace(error))
|
|
1401
|
-
throw error;
|
|
1402
|
-
if (attempt >= TREE_WRITE_ATTEMPTS - 1) {
|
|
1403
|
-
throw new WriteConflictError(`${operation}: ${TREE_WRITE_ATTEMPTS} intentos y el árbol del store sigue en conflicto sobre ` +
|
|
1404
|
-
`${wanted.object}. El relay es escritor ÚNICO: si hay dos pasadas a la vez, dale un lease a tu outbox.`, { cause: error });
|
|
1949
|
+
}
|
|
1950
|
+
catch (error) {
|
|
1951
|
+
let final = error;
|
|
1952
|
+
if (!settling && progress.attempted > 0) {
|
|
1953
|
+
try {
|
|
1954
|
+
await settle();
|
|
1955
|
+
}
|
|
1956
|
+
catch (settleError) {
|
|
1957
|
+
final = withSettleCause(error, settleError);
|
|
1405
1958
|
}
|
|
1406
|
-
continue;
|
|
1407
1959
|
}
|
|
1408
|
-
|
|
1409
|
-
return;
|
|
1960
|
+
throw progress.writes > 0 && final !== null && typeof final === 'object' ? markPartialWrite(final) : final;
|
|
1410
1961
|
}
|
|
1411
1962
|
}
|
|
1412
1963
|
/**
|
|
1413
|
-
* **El barrido del rol local** (3b-2e · E1; decisión del dueño del
|
|
1414
|
-
* 2026-08-30, opción 1).
|
|
1964
|
+
* **El barrido PREVIO del rol local** (3b-2e · E1; decisión del dueño del
|
|
1965
|
+
* 2026-08-30, opción 1; F4: solo borra).
|
|
1415
1966
|
*
|
|
1416
1967
|
* En (c2) el modelo no tiene `owner`, así que `authorize` NO vuelve a
|
|
1417
1968
|
* decidir con el árbol de hoy si un rol LOCAL sigue siendo visible: un
|
|
@@ -1421,11 +1972,12 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1421
1972
|
* un **fail-open** — y encima uno que solo se ve comparando drivers.
|
|
1422
1973
|
*
|
|
1423
1974
|
* Lo que se toca es la arista `scope#binding`, que es lo que hace
|
|
1424
|
-
* ALCANZABLE la asignación:
|
|
1425
|
-
*
|
|
1426
|
-
*
|
|
1427
|
-
*
|
|
1428
|
-
* ni nada de un rol GLOBAL, cuya
|
|
1975
|
+
* ALCANZABLE la asignación: el barrido previo la BORRA donde el owner del
|
|
1976
|
+
* rol no estará en la cadena NUEVA; las ALTAS (volver a su sitio restaura)
|
|
1977
|
+
* son del settle de después (`settleSubtree`), el punto de paso único de
|
|
1978
|
+
* L13. No se toca el `assignee` —el hecho de la asignación no cambia porque
|
|
1979
|
+
* el árbol se mueva, igual que en `database`— ni nada de un rol GLOBAL, cuya
|
|
1980
|
+
* visibilidad no depende del árbol.
|
|
1429
1981
|
*
|
|
1430
1982
|
* **Por subárbol, no por nodo** (consecuencia 2): los descendientes del
|
|
1431
1983
|
* nodo movido también cambian de cadena.
|
|
@@ -1434,24 +1986,43 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1434
1986
|
* que no usa delegación— son **cero** requests y `moved` sigue siendo el
|
|
1435
1987
|
* `Read` + `Write` del cruce 8. Con roles locales: una lectura por rol local
|
|
1436
1988
|
* (sus bindings, por `role_binding#role`), la bajada del subárbol y un
|
|
1437
|
-
* `Write` por lote.
|
|
1989
|
+
* `Write` por lote (el coste O(roles locales) por `moved` es el 🟡 6, declarado hasta 2.6).
|
|
1438
1990
|
*/
|
|
1439
|
-
async sweepLocalRoleBindings(movedKey) {
|
|
1440
|
-
const catalog = await this.
|
|
1991
|
+
async sweepLocalRoleBindings(movedKey, progress, options) {
|
|
1992
|
+
const { catalog, pairs, subtree } = await this.localBindingsBelow(movedKey, options.chain);
|
|
1993
|
+
if (pairs.length === 0)
|
|
1994
|
+
return;
|
|
1995
|
+
const writes = [];
|
|
1996
|
+
const deletes = [];
|
|
1997
|
+
for (const pair of pairs) {
|
|
1998
|
+
this.classifyBindingEdge(catalog, { scope: scopeFromKey(pair.scope), uuid: pair.role }, subtree.get(pair.scope), writes, deletes);
|
|
1999
|
+
}
|
|
2000
|
+
// F4: el previo solo BORRA (lo que la cadena nueva prohíbe); las altas, del settle de después.
|
|
2001
|
+
await this.applyBindingSweep(deletes, progress);
|
|
2002
|
+
}
|
|
2003
|
+
/**
|
|
2004
|
+
* Los bindings de roles LOCALES que cuelgan del subárbol de `movedKey` en el STORE (HIGHER), con la cadena de cada
|
|
2005
|
+
* scope del subárbol. El catálogo es el de una ESCRITURA (K4: en fresco), y un uuid que el store tiene y el catálogo no
|
|
2006
|
+
* conoce fuerza una recarga (`writeCatalogKnowing`); los roles se enumeran por el catálogo (el amarillo 6 —enumerar
|
|
2007
|
+
* desde el store— es de 2.6). `chain` es la cadena que el nodo TENDRÁ (barrido previo) o, sin ella, la del store.
|
|
2008
|
+
*/
|
|
2009
|
+
async localBindingsBelow(movedKey, chain) {
|
|
2010
|
+
const catalog = await this.writeCatalog();
|
|
1441
2011
|
const locals = catalog.localRoles();
|
|
2012
|
+
const subtree = new Map();
|
|
1442
2013
|
if (locals.length === 0)
|
|
1443
|
-
return;
|
|
2014
|
+
return { catalog, pairs: [], subtree };
|
|
1444
2015
|
// La cadena del nodo movido y su subárbol se leen del STORE, no del
|
|
1445
2016
|
// consumidor: el árbol que decide en `facts` es el del store, y con la
|
|
1446
2017
|
// outbox el relay aplica esto MÁS TARDE, cuando el árbol del consumidor
|
|
1447
|
-
// puede haber seguido moviéndose.
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
2018
|
+
// puede haber seguido moviéndose. En el barrido PREVIO de F4 la arista
|
|
2019
|
+
// nueva aún no está escrita: la cadena es la que TENDRÁ (`chain`:
|
|
2020
|
+
// el hijo + la cadena del padre nuevo en el store), nunca la vieja.
|
|
2021
|
+
subtree.set(movedKey, chain ?? (await this.storeChain(movedKey)));
|
|
1451
2022
|
const pending = [movedKey];
|
|
1452
2023
|
while (pending.length) {
|
|
1453
2024
|
const key = pending.pop();
|
|
1454
|
-
const children = await this.
|
|
2025
|
+
const children = await this.readForWrite({ user: factsScopeObject(key), relation: FACTS_PARENT_RELATION, object: `${FACTS_SCOPE_TYPE}:` }, { includeExpired: true });
|
|
1455
2026
|
for (const edge of children) {
|
|
1456
2027
|
const childKey = edge.object.slice(FACTS_SCOPE_TYPE.length + 1);
|
|
1457
2028
|
if (subtree.has(childKey))
|
|
@@ -1460,21 +2031,28 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1460
2031
|
pending.push(childKey);
|
|
1461
2032
|
}
|
|
1462
2033
|
}
|
|
1463
|
-
const
|
|
1464
|
-
const deletes = [];
|
|
2034
|
+
const pairs = [];
|
|
1465
2035
|
for (const role of locals) {
|
|
1466
2036
|
// Los bindings de ESTE rol, esté donde esté: `role_binding:…#role@role:<uuid>`.
|
|
1467
2037
|
// El filtro lleva `user`, que es lo que el servidor real exige cuando el
|
|
1468
2038
|
// objeto es solo un tipo (lección del 2c).
|
|
1469
|
-
const bindings = await this.
|
|
2039
|
+
const bindings = await this.readForWrite({ user: `${FACTS_ROLE_TYPE}:${role.uuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
|
|
1470
2040
|
for (const binding of this.parseBindings(FACTS_BINDING_TYPE, bindings.map((t) => t.object))) {
|
|
1471
|
-
const
|
|
1472
|
-
if (
|
|
1473
|
-
|
|
1474
|
-
this.classifyBindingEdge(catalog, binding, chainKeys, writes, deletes);
|
|
2041
|
+
const key = scopeKey(binding.scope);
|
|
2042
|
+
if (subtree.has(key))
|
|
2043
|
+
pairs.push({ scope: key, role: binding.uuid });
|
|
1475
2044
|
}
|
|
1476
2045
|
}
|
|
1477
|
-
|
|
2046
|
+
return { catalog, pairs, subtree };
|
|
2047
|
+
}
|
|
2048
|
+
/**
|
|
2049
|
+
* **El settle del subárbol de un cambio de árbol** (lote C · L13): los bindings de roles locales del subárbol de
|
|
2050
|
+
* `movedKey` (en el store, HIGHER, catálogo en fresco) pasan por `settleRoleVisibility`. Lo llaman `reparent` tras la
|
|
2051
|
+
* arista, su atajo idempotente y su `finally`.
|
|
2052
|
+
*/
|
|
2053
|
+
async settleSubtree(movedKey, progress) {
|
|
2054
|
+
const { pairs } = await this.localBindingsBelow(movedKey);
|
|
2055
|
+
await this.settleRoleVisibility(pairs, progress);
|
|
1478
2056
|
}
|
|
1479
2057
|
/**
|
|
1480
2058
|
* **El barrido por NIVEL** (3b-2g · R1; decisión del dueño del 2026-08-30
|
|
@@ -1486,46 +2064,29 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1486
2064
|
* —donde `declaredRoleAt` se evalúa en cada pregunta— y **sigue
|
|
1487
2065
|
* concediendo** en `facts`, que es la divergencia R1 del lote 2e.
|
|
1488
2066
|
*
|
|
1489
|
-
* Se cierra igual que el owner:
|
|
1490
|
-
* bindings de ESE rol
|
|
2067
|
+
* Se cierra igual que el owner: dejando la arista `scope#binding` de los
|
|
2068
|
+
* bindings de ESE rol como dice la regla única de visibilidad. Lo llama
|
|
1491
2069
|
* `projectCatalogRole`, que es el hook de «una escritura de catálogo cambió
|
|
1492
2070
|
* este rol»: el manager lo dispara tras `defineScopedRole`/`updateScopedRole`
|
|
1493
2071
|
* y un escritor «a mano» de `authz_*` tiene el mismo deber que ya tenía con
|
|
1494
2072
|
* el espejo de permisos (sin él, en `facts` un rol recién definido no
|
|
1495
2073
|
* concedería nada y quitarle un permiso seguiría concediéndolo).
|
|
1496
2074
|
*
|
|
1497
|
-
*
|
|
1498
|
-
*
|
|
1499
|
-
*
|
|
1500
|
-
*
|
|
2075
|
+
* **Desde el lote C pasa ENTERO por el settle** (L13; 🟠 1 de F4-carreras: este barrido leía `storeChain` y escribía
|
|
2076
|
+
* DESPUÉS, y un `moved` que barriera entre medias quedaba pisado — fail-OPEN persistente entre tenants). El catálogo es
|
|
2077
|
+
* el de una escritura (K4, hallazgo 4 del panel TREE): con un memo rancio un rol recién definido en otro proceso se
|
|
2078
|
+
* ignoraba. Un rol que el catálogo ya no declara —tras la recarga— no tiene visibilidad que decidir: sus aristas se BORRAN
|
|
2079
|
+
* («rol ausente ⇒ borra», K4); el resto de sus hechos son de `purgeRole`/`authz:reconcile`.
|
|
2080
|
+
*
|
|
2081
|
+
* Coste: una lectura (los bindings del rol) y un `batchCheck` (HIGHER) por vuelta del settle; un `Write` por lote solo
|
|
2082
|
+
* si hay algo que corregir. Un rol sin bindings —todo `defineScopedRole`— son 0 escrituras.
|
|
1501
2083
|
*/
|
|
1502
|
-
async sweepRoleVisibility(roleUuid) {
|
|
1503
|
-
const
|
|
1504
|
-
// Un rol que el catálogo ya no declara no tiene visibilidad que decidir:
|
|
1505
|
-
// sus hechos son de `purgeRole` (que los borra enteros) y de
|
|
1506
|
-
// `authz:reconcile`, no de este barrido.
|
|
1507
|
-
const role = catalog.roleByUuid(roleUuid);
|
|
1508
|
-
if (!role)
|
|
1509
|
-
return;
|
|
1510
|
-
const bindings = await this.readAllTuples({ user: `${FACTS_ROLE_TYPE}:${roleUuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
|
|
2084
|
+
async sweepRoleVisibility(roleUuid, progress) {
|
|
2085
|
+
const bindings = await this.readForWrite({ user: `${FACTS_ROLE_TYPE}:${roleUuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
|
|
1511
2086
|
if (bindings.length === 0)
|
|
1512
2087
|
return;
|
|
1513
|
-
const
|
|
1514
|
-
|
|
1515
|
-
/** `scopeKey` → su cadena en el árbol del STORE, una vez por scope. */
|
|
1516
|
-
const chains = new Map();
|
|
1517
|
-
for (const binding of this.parseBindings(FACTS_BINDING_TYPE, bindings.map((t) => t.object))) {
|
|
1518
|
-
const key = scopeKey(binding.scope);
|
|
1519
|
-
let chainKeys = chains.get(key);
|
|
1520
|
-
if (!chainKeys) {
|
|
1521
|
-
// La cadena solo decide para un rol LOCAL: la visibilidad de un
|
|
1522
|
-
// global no depende del árbol, así que no se paga por leerlo.
|
|
1523
|
-
chainKeys = role.owner === GLOBAL_OWNER_KEY ? [key] : await this.storeChain(key);
|
|
1524
|
-
chains.set(key, chainKeys);
|
|
1525
|
-
}
|
|
1526
|
-
this.classifyBindingEdge(catalog, binding, chainKeys, writes, deletes);
|
|
1527
|
-
}
|
|
1528
|
-
await this.applyBindingSweep(writes, deletes);
|
|
2088
|
+
const pairs = this.parseBindings(FACTS_BINDING_TYPE, bindings.map((t) => t.object)).map((b) => ({ scope: scopeKey(b.scope), role: b.uuid }));
|
|
2089
|
+
await this.settleRoleVisibility(pairs, progress);
|
|
1529
2090
|
}
|
|
1530
2091
|
/**
|
|
1531
2092
|
* La arista `scope#binding` de un binding, a escribir o a borrar según la
|
|
@@ -1534,7 +2095,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1534
2095
|
* NIVEL de ese scope (3b-2g · R1) y ser global o tener a su owner en la
|
|
1535
2096
|
* cadena (3b-2e · E1). Visible ⇒ la arista se (re)escribe; no visible ⇒ se
|
|
1536
2097
|
* borra. El `assignee` no se toca: la asignación existe igual, lo que
|
|
1537
|
-
* cambia es dónde se la ve.
|
|
2098
|
+
* cambia es dónde se la ve. (Desde el lote C solo lo usa el barrido PREVIO, que solo borra.)
|
|
1538
2099
|
*/
|
|
1539
2100
|
classifyBindingEdge(catalog, binding, chainKeys, writes, deletes) {
|
|
1540
2101
|
const edge = factsScopeBindingTuple(scopeKey(binding.scope), binding.uuid);
|
|
@@ -1544,31 +2105,11 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1544
2105
|
deletes.push(edge);
|
|
1545
2106
|
}
|
|
1546
2107
|
/**
|
|
1547
|
-
* Aplica
|
|
1548
|
-
*
|
|
1549
|
-
* `Ignore` en las dos direcciones: el barrido dice el estado que DEBE
|
|
1550
|
-
* quedar, no el delta — borrar lo que ya no está y reescribir lo que ya
|
|
1551
|
-
* estaba son no-ops, no errores (invariante 6).
|
|
2108
|
+
* Aplica los BORRADOS de un barrido (lote C · L13: `applyBindingSweep` ya no da ALTAS; las altas de `scope#binding` de
|
|
2109
|
+
* rol local solo salen del settle). Borrar nunca concede, así que va directo, en lotes ≤ 100 con `Ignore`.
|
|
1552
2110
|
*/
|
|
1553
|
-
async applyBindingSweep(
|
|
1554
|
-
|
|
1555
|
-
return;
|
|
1556
|
-
const operations = [
|
|
1557
|
-
...deletes.map((tuple) => ({ delete: tuple })),
|
|
1558
|
-
...writes.map((tuple) => ({ write: tuple })),
|
|
1559
|
-
];
|
|
1560
|
-
for (let i = 0; i < operations.length; i += PURGE_BATCH_SIZE) {
|
|
1561
|
-
const chunk = operations.slice(i, i + PURGE_BATCH_SIZE);
|
|
1562
|
-
await this.client.write({
|
|
1563
|
-
writes: chunk.filter((o) => o.write).map((o) => o.write),
|
|
1564
|
-
deletes: chunk.filter((o) => o.delete).map((o) => o.delete),
|
|
1565
|
-
}, {
|
|
1566
|
-
conflict: {
|
|
1567
|
-
onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore,
|
|
1568
|
-
onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore,
|
|
1569
|
-
},
|
|
1570
|
-
});
|
|
1571
|
-
}
|
|
2111
|
+
async applyBindingSweep(deletes, progress = { writes: 0 }) {
|
|
2112
|
+
await this.writeBindingEdges([], deletes, progress);
|
|
1572
2113
|
}
|
|
1573
2114
|
/**
|
|
1574
2115
|
* `[key, ...ancestros]` según el ÁRBOL DEL STORE (3b-2e · E1), subiendo por
|
|
@@ -1576,14 +2117,15 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1576
2117
|
* tiene que gobernar el barrido; el resolutor del consumidor no participa.
|
|
1577
2118
|
* Un nodo con más de un padre es deriva y se dice (`assertOneParent`), y el
|
|
1578
2119
|
* recorrido está acotado por el mismo tope de páginas que las
|
|
1579
|
-
* enumeraciones: un ciclo escrito a mano no cuelga el proceso.
|
|
2120
|
+
* enumeraciones: un ciclo escrito a mano no cuelga el proceso. Alimenta
|
|
2121
|
+
* escrituras (anti-ciclos de `reparent`, barrido, caída del settle) ⇒ HIGHER (hallazgo 3 TREE).
|
|
1580
2122
|
*/
|
|
1581
2123
|
async storeChain(key) {
|
|
1582
2124
|
const chain = [key];
|
|
1583
2125
|
const seen = new Set([key]);
|
|
1584
2126
|
for (let hop = 0; hop < MAX_SCOPE_CHAIN_HOPS; hop++) {
|
|
1585
2127
|
const object = factsScopeObject(chain[chain.length - 1]);
|
|
1586
|
-
const parents = await this.
|
|
2128
|
+
const parents = await this.readForWrite({ object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
|
|
1587
2129
|
this.assertOneParent(object, parents);
|
|
1588
2130
|
if (parents.length === 0)
|
|
1589
2131
|
return chain;
|
|
@@ -1614,6 +2156,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1614
2156
|
if (purged.type === APP_SCOPE_TYPE) {
|
|
1615
2157
|
throw new InvalidIdentityError('purgeScope: la raíz `app` no se purga');
|
|
1616
2158
|
}
|
|
2159
|
+
await this.assertModel('purgeScope');
|
|
1617
2160
|
// La identidad canónica si el árbol aún lo conoce (K1); tal cual si ya no.
|
|
1618
2161
|
const scope = await this.canonicalOrSelf(purged, 'purgeScope');
|
|
1619
2162
|
const key = scopeKey(scope);
|
|
@@ -1630,15 +2173,35 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1630
2173
|
const objects = [...roles.map((r) => `role_binding:${key}|${r.uuid}`), scopeObject];
|
|
1631
2174
|
/** Lo purgable de un objeto: todo, salvo la arista `parent` del scope. */
|
|
1632
2175
|
const purgeable = async (object) => {
|
|
1633
|
-
const keys = await this.
|
|
2176
|
+
const keys = await this.readForWrite({ object }, { includeExpired: true });
|
|
1634
2177
|
return object === scopeObject ? keys.filter((k) => k.relation !== FACTS_PARENT_RELATION) : keys;
|
|
1635
2178
|
};
|
|
1636
|
-
/**
|
|
1637
|
-
|
|
2179
|
+
/**
|
|
2180
|
+
* Cierre 4 de D2 · ⚪ C3-4 (gemelo de `#partialIfDeleted` de relaciones): cuántos `Write` de borrado YA entraron. La
|
|
2181
|
+
* purga son varios `Write` (estructura, hechos por objeto, denies; lotes de 100): un fallo después del primero llega
|
|
2182
|
+
* con parte ya borrada y se marca `markPartialWrite` (el manager notifica `indeterminate: true`, invariante 13). Un
|
|
2183
|
+
* lote que falla no borró nada (FGA aplica cada `Write` entero).
|
|
2184
|
+
*/
|
|
2185
|
+
const progress = { deleted: 0, unconfirmed: false };
|
|
2186
|
+
/**
|
|
2187
|
+
* Borra en lotes ≤ 100 (límite del `Write`), sin quejarse de lo que ya no está. Cierre 5 de D2 · ⚪ D4-5: el contador
|
|
2188
|
+
* suma lo que el lote BORRA DE VERDAD (`effective`, por defecto todo: las fases 2-4 borran lo que acaban de LEER), no
|
|
2189
|
+
* lo pedido — la fase 1 borra la estructura a CIEGAS con `Ignore` y hasta el cierre 5 contaba claves que no existían
|
|
2190
|
+
* (un fallo posterior salía `indeterminate: true` sin haber borrado nada).
|
|
2191
|
+
*/
|
|
2192
|
+
const deleteKeys = async (keys, effective = () => true) => {
|
|
1638
2193
|
for (let i = 0; i < keys.length; i += PURGE_BATCH_SIZE) {
|
|
1639
|
-
|
|
2194
|
+
const batch = keys.slice(i, i + PURGE_BATCH_SIZE);
|
|
2195
|
+
await this.client.deleteTuples(batch, {
|
|
1640
2196
|
conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
|
|
1641
2197
|
});
|
|
2198
|
+
progress.deleted += batch.filter(effective).length;
|
|
2199
|
+
// Cierre 6 de D2 · ⚪ D5-4: una clave pedida a ciegas que la lectura previa NO vio puede haberla escrito un
|
|
2200
|
+
// `grant` concurrente entre esa lectura y este borrado: entonces este lote SÍ borró algo que no se contó. Hasta
|
|
2201
|
+
// que la fase 2 termine (borra —y cuenta— el `assignee` de ese grant) el efecto es INCIERTO, y un fallo en ese
|
|
2202
|
+
// tramo sale marcado parcial: preferir sobre-notificar a sub-notificar.
|
|
2203
|
+
if (batch.some((k) => !effective(k)))
|
|
2204
|
+
progress.unconfirmed = true;
|
|
1642
2205
|
}
|
|
1643
2206
|
};
|
|
1644
2207
|
/** Las dos aristas de (c2): estructura, no hechos (`#role` y `#binding`). */
|
|
@@ -1671,36 +2234,55 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1671
2234
|
// el residuo es justo lo que la demostración de cero reporta. La arista
|
|
1672
2235
|
// `parent` sigue sin tocarse: la borra `scopes.detached` DESPUÉS de que
|
|
1673
2236
|
// esta purga demuestre cero (S6, cruce 9).
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
2237
|
+
// Los pasos (estructura → hechos → denies → demostrar cero), en una función LOCAL (no un método `#`: el driver
|
|
2238
|
+
// puede llegar envuelto en un Proxy y un campo privado exige el receptor real), para marcar el corte a medias.
|
|
2239
|
+
const steps = async () => {
|
|
2240
|
+
// ⚪ D4-5 (cierre 5 de D2): qué estructura EXISTE antes del borrado a ciegas — UNA lectura del scope (sus aristas
|
|
2241
|
+
// `#binding`). Solo la arista `scope#binding` hace visible (y por tanto observable) un rol aquí: un `role_binding#role`
|
|
2242
|
+
// sin su `#binding` (lo que deja el barrido de roles locales) no cambia ninguna respuesta al borrarse, así que no
|
|
2243
|
+
// cuenta como escritura hecha. Ventana declarada: una arista que un `grant` concurrente escribe ENTRE esta lectura y
|
|
2244
|
+
// el borrado se borra sin contarse; la fase 2 borra después su `assignee`, que sí cuenta, y HASTA que la fase 2
|
|
2245
|
+
// termina el error va marcado parcial igual (`unconfirmed`, ⚪ D5-4 del cierre 6: sobre-notificar, nunca sub-).
|
|
2246
|
+
const visible = new Set((await purgeable(scopeObject)).filter((k) => k.relation === FACTS_BINDING_RELATION).map((k) => k.user));
|
|
2247
|
+
await deleteKeys(roles.flatMap((r) => factsBindingTuples(key, r.uuid)), (k) => visible.has(k.relation === FACTS_BINDING_RELATION ? k.user : k.object));
|
|
2248
|
+
for (const object of objects) {
|
|
2249
|
+
if (object === scopeObject)
|
|
2250
|
+
continue;
|
|
2251
|
+
await deleteKeys((await purgeable(object)).filter((k) => !isStructure(k)));
|
|
2252
|
+
}
|
|
2253
|
+
// ⚪ D5-4: la fase 2 terminó — el `assignee` de un grant que se coló antes de la fase 1 ya se borró y se contó.
|
|
2254
|
+
progress.unconfirmed = false;
|
|
2255
|
+
// **Las aristas `#binding` de roles que el catálogo YA NO declara en este
|
|
2256
|
+
// nivel también se purgan** (3b-8 · C2). El barrido no es atómico con el
|
|
2257
|
+
// catálogo: un rol cuya fila se borró (o cambió de `scope_type`) entre el
|
|
2258
|
+
// commit y esta purga dejaba una arista que el paso 1 no ve —solo mira
|
|
2259
|
+
// `authz_roles WHERE scope_type = <este>`— y que la demostración de cero
|
|
2260
|
+
// SÍ cuenta ⇒ `E_AUTHZ_PURGE_INCOMPLETE` en TODOS los intentos y
|
|
2261
|
+
// `detached` bloqueado para siempre. Borrarla nunca concede (es la arista
|
|
2262
|
+
// de visibilidad, y su rol ya no existe en este nivel), y la arista de un
|
|
2263
|
+
// rol DEL catálogo que un `grant` concurrente reescribió no se toca
|
|
2264
|
+
// (`known` la protege): esa sigue saliendo como residuo, que es lo
|
|
2265
|
+
// correcto — se reintenta.
|
|
2266
|
+
const known = new Set(roles.map((r) => `role_binding:${key}|${r.uuid}`));
|
|
2267
|
+
await deleteKeys((await purgeable(scopeObject)).filter((k) => k.relation === FACTS_BINDING_RELATION && !known.has(k.user)));
|
|
2268
|
+
await deleteKeys((await purgeable(scopeObject)).filter((k) => k.relation.startsWith(FACTS_DENIED_PREFIX)));
|
|
2269
|
+
// Demostrar cero: lo que no se puede demostrar, se reporta.
|
|
2270
|
+
const residue = [];
|
|
2271
|
+
for (const object of objects) {
|
|
2272
|
+
const left = await purgeable(object);
|
|
2273
|
+
if (left.length)
|
|
2274
|
+
residue.push(`${object} (${left.length})`);
|
|
2275
|
+
}
|
|
2276
|
+
if (residue.length) {
|
|
2277
|
+
throw new PurgeIncompleteError(`purgeScope ${key}: quedan tuplas tras el borrado — ${residue.join('; ')}. ` +
|
|
2278
|
+
`No confirmes el borrado del scope; reintenta la purga.`);
|
|
2279
|
+
}
|
|
2280
|
+
};
|
|
2281
|
+
try {
|
|
2282
|
+
await steps();
|
|
2283
|
+
}
|
|
2284
|
+
catch (error) {
|
|
2285
|
+
throw (progress.deleted > 0 || progress.unconfirmed) && error !== null && typeof error === 'object' ? markPartialWrite(error) : error;
|
|
1704
2286
|
}
|
|
1705
2287
|
}
|
|
1706
2288
|
/**
|
|
@@ -1744,7 +2326,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1744
2326
|
*/
|
|
1745
2327
|
async ensureFactsRoot() {
|
|
1746
2328
|
const wanted = factsRootTuples(this.holderTypes);
|
|
1747
|
-
const current = await this.
|
|
2329
|
+
const current = await this.readForWrite({ object: factsScopeObject(APP_SCOPE_TYPE), relation: FACTS_ROOTED_RELATION }, { includeExpired: true });
|
|
1748
2330
|
const have = new Set(current.map((tuple) => tuple.user));
|
|
1749
2331
|
const missing = wanted.filter((tuple) => !have.has(tuple.user));
|
|
1750
2332
|
if (missing.length)
|
|
@@ -1761,6 +2343,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1761
2343
|
* catálogo escribe CERO tuplas.
|
|
1762
2344
|
*/
|
|
1763
2345
|
async projectCatalog(snapshot) {
|
|
2346
|
+
await this.assertModel('catalogProjection.project');
|
|
1764
2347
|
await this.ensureFactsRoot();
|
|
1765
2348
|
const wanted = new Map();
|
|
1766
2349
|
for (const tuple of factsCatalogTuples(snapshot.roles, this.holderTypes)) {
|
|
@@ -1778,7 +2361,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1778
2361
|
// otro user sobre `role:` no es de esta proyección y no se toca.
|
|
1779
2362
|
const current = new Map();
|
|
1780
2363
|
for (const holderType of Object.values(this.holderTypes)) {
|
|
1781
|
-
for (const key of await this.
|
|
2364
|
+
for (const key of await this.readForWrite({ user: `${holderType}:*`, object: `${FACTS_ROLE_TYPE}:` }, { includeExpired: true })) {
|
|
1782
2365
|
// Solo la familia del catálogo: si el modelo crece con otras
|
|
1783
2366
|
// relaciones sobre `role`, no son de esta proyección.
|
|
1784
2367
|
if (!key.relation.startsWith(FACTS_PERMITS_PREFIX))
|
|
@@ -1825,14 +2408,24 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1825
2408
|
*/
|
|
1826
2409
|
async purgeRole(roleUuid) {
|
|
1827
2410
|
assertCatalogUuid('rol', roleUuid);
|
|
2411
|
+
await this.assertModel('purgeRole');
|
|
1828
2412
|
const existing = await this.sql('purgeRole.role', () => db.from('authz_roles').where('uuid', roleUuid).select('uuid'));
|
|
1829
2413
|
if (!existing.length)
|
|
1830
2414
|
throw new UnknownRoleError(roleUuid);
|
|
1831
2415
|
const roleObject = `${FACTS_ROLE_TYPE}:${roleUuid}`;
|
|
1832
|
-
/**
|
|
2416
|
+
/**
|
|
2417
|
+
* Los objetos `role_binding` de este rol, por su arista `#role` — **y todos los que ya se vieron** (lote C · hallazgo 2
|
|
2418
|
+
* del panel TREE, ORPHAN): el borrado se lleva la arista `#role`, así que la demostración de cero y el repaso que
|
|
2419
|
+
* enumeraban SOLO por ella no veían el `assignee` que un `grant` concurrente escribió en un binding ya enumerado
|
|
2420
|
+
* (estructura compartida: `Ignore` de duplicados) — la purga «demostraba» cero y ese hecho quedaba huérfano para
|
|
2421
|
+
* siempre. Con la unión, ese hecho sale como residuo (500 `E_AUTHZ_PURGE_INCOMPLETE`, se reintenta) o lo borra el repaso.
|
|
2422
|
+
*/
|
|
2423
|
+
const seenBindings = new Set();
|
|
1833
2424
|
const bindingsOf = async () => {
|
|
1834
|
-
const edges = await this.
|
|
1835
|
-
|
|
2425
|
+
const edges = await this.readForWrite({ user: roleObject, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
|
|
2426
|
+
for (const edge of edges)
|
|
2427
|
+
seenBindings.add(edge.object);
|
|
2428
|
+
return [...seenBindings];
|
|
1836
2429
|
};
|
|
1837
2430
|
/** El scope del que cuelga un binding, según su propio id (`<scopeKey>|<roleUuid>`). */
|
|
1838
2431
|
const scopeOf = (binding) => {
|
|
@@ -1840,37 +2433,63 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1840
2433
|
const separator = id.lastIndexOf('|');
|
|
1841
2434
|
return separator > 0 ? id.slice(0, separator) : null;
|
|
1842
2435
|
};
|
|
1843
|
-
/**
|
|
2436
|
+
/** Los HECHOS purgables del rol: sus bindings enteros y las aristas del scope (sin la proyección: ver `projection`). */
|
|
1844
2437
|
const purgeable = async () => {
|
|
1845
2438
|
const keys = [];
|
|
1846
2439
|
for (const binding of await bindingsOf()) {
|
|
1847
|
-
keys.push(...(await this.
|
|
2440
|
+
keys.push(...(await this.readForWrite({ object: binding }, { includeExpired: true })));
|
|
1848
2441
|
const key = scopeOf(binding);
|
|
1849
2442
|
if (!key)
|
|
1850
2443
|
continue;
|
|
1851
2444
|
// La arista vive en el objeto `scope`, así que se lee de ahí (y solo
|
|
1852
2445
|
// la que apunta a ESTE binding: el scope tiene las de otros roles).
|
|
1853
|
-
const scopeEdges = await this.
|
|
2446
|
+
const scopeEdges = await this.readForWrite({ user: binding, relation: FACTS_BINDING_RELATION, object: factsScopeObject(key) }, { includeExpired: true });
|
|
1854
2447
|
keys.push(...scopeEdges);
|
|
1855
2448
|
}
|
|
1856
|
-
|
|
2449
|
+
return keys;
|
|
2450
|
+
};
|
|
2451
|
+
/** La proyección del catálogo del rol: `role:<uuid>#permits_<P>@<holder>:*`. */
|
|
2452
|
+
const projection = async () => {
|
|
2453
|
+
const keys = [];
|
|
1857
2454
|
for (const holderType of Object.values(this.holderTypes)) {
|
|
1858
|
-
keys.push(...(await this.
|
|
2455
|
+
keys.push(...(await this.readForWrite({ user: `${holderType}:*`, object: roleObject }, { includeExpired: true })));
|
|
1859
2456
|
}
|
|
1860
2457
|
return keys;
|
|
1861
2458
|
};
|
|
1862
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
2459
|
+
/** Borrar y RELEER hasta cero, con cota; devuelve lo que sobrevive a la última relectura. */
|
|
2460
|
+
const deleteUntilZero = async (read) => {
|
|
2461
|
+
let residue = await read();
|
|
2462
|
+
for (let round = 0; residue.length > 0 && round < PURGE_ROLE_ROUNDS; round++) {
|
|
2463
|
+
for (let i = 0; i < residue.length; i += PURGE_BATCH_SIZE) {
|
|
2464
|
+
await this.client.deleteTuples(residue.slice(i, i + PURGE_BATCH_SIZE), {
|
|
2465
|
+
conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
|
|
2466
|
+
});
|
|
2467
|
+
}
|
|
2468
|
+
residue = await read();
|
|
2469
|
+
}
|
|
2470
|
+
return residue;
|
|
2471
|
+
};
|
|
2472
|
+
const incomplete = (residue, what) => new PurgeIncompleteError(`purgeRole ${roleUuid}: quedan ${residue.length} tuplas ${what} tras el borrado ` +
|
|
2473
|
+
`(${residue.slice(0, 3).map((k) => `${k.user}#${k.relation}@${k.object}`).join('; ')}…). ` +
|
|
2474
|
+
`El rol NO se borra del catálogo: reintenta la purga.`);
|
|
2475
|
+
// Lote C (hallazgo 2, ORPHAN): borrar y RELEER hasta cero, con cota. Un escritor concurrente (el settle de un `moved`
|
|
2476
|
+
// o de un `grant` que decidió con `role_binding#role` vivo) puede dejar una arista en un binding ya enumerado entre la
|
|
2477
|
+
// lectura y el borrado; la vuelta siguiente la ve (los bindings vistos se recuerdan) y la borra. Antes, una vuelta y la
|
|
2478
|
+
// demostración: ese residuo salía como `E_AUTHZ_PURGE_INCOMPLETE`, y el reintento ya NO lo veía (la arista `#role` se
|
|
2479
|
+
// había ido con la primera pasada): una `scope#binding` resucitada para siempre.
|
|
2480
|
+
//
|
|
2481
|
+
// **Asentar · cierre 1, desviación 2 = (b) (firma del dueño 2026-10-21): la proyección `permits_*` se borra DESPUÉS de
|
|
2482
|
+
// demostrar cero en los HECHOS.** Antes iban juntos, y una purga que fallaba (500 `E_AUTHZ_PURGE_INCOMPLETE` o un 503)
|
|
2483
|
+
// dejaba el rol en el catálogo SIN conceder lo que aún tenía asignado (CLOSED hasta el reintento; la propiedad lo
|
|
2484
|
+
// eximía con la atribución `purge-incomplete`, que tapaba síntomas reales del mutante `purgeRole.one-round`). Ahora
|
|
2485
|
+
// una purga que falla en los hechos deja el rol entero (lo que queda asignado sigue concediendo, reintentable); con
|
|
2486
|
+
// los hechos a cero ya no hay asignación a la que la proyección conceda, así que su borrado no cierra nada.
|
|
2487
|
+
const residue = await deleteUntilZero(purgeable);
|
|
2488
|
+
if (residue.length)
|
|
2489
|
+
throw incomplete(residue, 'de hechos');
|
|
2490
|
+
const projected = await deleteUntilZero(projection);
|
|
2491
|
+
if (projected.length)
|
|
2492
|
+
throw incomplete(projected, 'de la proyección permits_*');
|
|
1874
2493
|
try {
|
|
1875
2494
|
await this.sql('purgeRole', () => withAuthzCatalogWrite(async (trx) => {
|
|
1876
2495
|
// `authz_assignments` es del driver `database` y aquí está vacía,
|
|
@@ -1886,6 +2505,25 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1886
2505
|
finally {
|
|
1887
2506
|
this.catalog.invalidate();
|
|
1888
2507
|
}
|
|
2508
|
+
// **Hallazgo 2 del panel TREE (ORPHAN), la pata Dekker de `purgeRole`**: un `grant` del rol que se coló entre la
|
|
2509
|
+
// demostración de cero y el DELETE SQL (leyó el catálogo con el rol vivo) dejó sus tuplas —incluida la arista
|
|
2510
|
+
// `scope#binding`, que ya ninguna regla gobierna— DESPUÉS de la purga. Con el rol ya fuera de la base, se repasa lo
|
|
2511
|
+
// purgable y se BORRA lo reaparecido. No lanza: la purga del rol ya ocurrió (el catálogo lo dice), y lo que un `grant`
|
|
2512
|
+
// —nuevo o RE-grant: desde el cierre 1 del asentar, sin mirar `created`— escriba después de ESTA lectura lo deshace él
|
|
2513
|
+
// mismo: su settle ve el rol ausente tras recargar y el `grant` borra sus tres tuplas. Un fallo de esta pasada (503) tampoco revierte nada: lo que quede no concede (no hay `permits_*` ni rol en
|
|
2514
|
+
// el catálogo) y lo recoge `authz:reconcile --prune`.
|
|
2515
|
+
try {
|
|
2516
|
+
const reappeared = [...(await purgeable()), ...(await projection())];
|
|
2517
|
+
for (let i = 0; i < reappeared.length; i += PURGE_BATCH_SIZE) {
|
|
2518
|
+
await this.client.deleteTuples(reappeared.slice(i, i + PURGE_BATCH_SIZE), {
|
|
2519
|
+
conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
|
|
2520
|
+
});
|
|
2521
|
+
}
|
|
2522
|
+
}
|
|
2523
|
+
catch (error) {
|
|
2524
|
+
this.warn(`authz(openfga): purgeRole ${roleUuid}: el repaso tras borrar el rol del catálogo falló (${String(error?.message ?? error)}); ` +
|
|
2525
|
+
'lo que reapareció no concede y lo recoge authz:reconcile --prune.');
|
|
2526
|
+
}
|
|
1889
2527
|
}
|
|
1890
2528
|
/**
|
|
1891
2529
|
* Cuántos hechos VIGENTES tiene cada rol, en todos los scopes (3b-2j).
|
|
@@ -1919,12 +2557,15 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1919
2557
|
async countRoleAssignments(roleUuids) {
|
|
1920
2558
|
for (const uuid of roleUuids)
|
|
1921
2559
|
assertCatalogUuid('rol', uuid);
|
|
2560
|
+
await this.assertModel('countRoleAssignments');
|
|
1922
2561
|
const counts = [];
|
|
1923
2562
|
for (const roleUuid of roleUuids) {
|
|
1924
2563
|
const edges = await this.readAllTuples({ user: `${FACTS_ROLE_TYPE}:${roleUuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
|
|
1925
2564
|
let total = 0;
|
|
1926
2565
|
for (const binding of new Set(edges.map((edge) => edge.object))) {
|
|
1927
|
-
|
|
2566
|
+
// Cierre de L11 (🟡 A2(a) / 🟠-2 del tester, clase 3b-2j): un `assignee` `foreign`/`unparseable` CUENTA —puede estar
|
|
2567
|
+
// vivo en el servidor y contarlo de menos es decir «no concede» sobre algo que sí, justo antes de un purgeRole—.
|
|
2568
|
+
total += (await this.readAllTuples({ relation: FACTS_ASSIGNEE_RELATION, object: binding }, { includeInvalid: true })).length;
|
|
1928
2569
|
}
|
|
1929
2570
|
counts.push(total);
|
|
1930
2571
|
}
|
|
@@ -1949,8 +2590,25 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1949
2590
|
*/
|
|
1950
2591
|
async projectCatalogRole(roleUuid) {
|
|
1951
2592
|
assertCatalogUuid('rol', roleUuid);
|
|
1952
|
-
await this.
|
|
1953
|
-
|
|
2593
|
+
await this.assertModel('projectCatalogRole');
|
|
2594
|
+
// Lote C · L13: la visibilidad pasa por el settle (punto de paso único); un fallo tras ≥ 1 `Write` sale PARCIAL.
|
|
2595
|
+
const progress = { writes: 0 };
|
|
2596
|
+
try {
|
|
2597
|
+
await this.sweepRoleVisibility(roleUuid, progress);
|
|
2598
|
+
await this.projectRolePermits(roleUuid);
|
|
2599
|
+
}
|
|
2600
|
+
catch (error) {
|
|
2601
|
+
throw progress.writes > 0 ? markPartialObject(error) : error;
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
/**
|
|
2605
|
+
* La proyección `permits_<P>` de UN rol (la otra mitad de `projectCatalogRole`). **Hallazgo 4 del panel TREE (K4)**: el
|
|
2606
|
+
* catálogo es el de una ESCRITURA (`writeCatalog`, versión en fresco) — con un memo `{ everyMs }` que todavía no conoce
|
|
2607
|
+
* un rol recién definido en otro proceso, `rolePermissionsOf` devolvía `[]` y esto BORRABA sus `permits_*` (fail-closed
|
|
2608
|
+
* medido: `dave@C`). Mutante `project.memo-catalog`.
|
|
2609
|
+
*/
|
|
2610
|
+
async projectRolePermits(roleUuid) {
|
|
2611
|
+
const catalog = await this.writeCatalogKnowing([roleUuid]);
|
|
1954
2612
|
const permissions = [...catalog.rolePermissionsOf(roleUuid)].sort();
|
|
1955
2613
|
const object = `${FACTS_ROLE_TYPE}:${roleUuid}`;
|
|
1956
2614
|
const wanted = new Map();
|
|
@@ -1959,7 +2617,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1959
2617
|
}
|
|
1960
2618
|
const current = new Map();
|
|
1961
2619
|
for (const holderType of Object.values(this.holderTypes)) {
|
|
1962
|
-
for (const key of await this.
|
|
2620
|
+
for (const key of await this.readForWrite({ user: `${holderType}:*`, object }, { includeExpired: true })) {
|
|
1963
2621
|
if (!key.relation.startsWith(FACTS_PERMITS_PREFIX))
|
|
1964
2622
|
continue;
|
|
1965
2623
|
current.set(factsTupleId(key), key);
|
|
@@ -2019,7 +2677,9 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2019
2677
|
const prune = options.prune === true;
|
|
2020
2678
|
const batchSize = reconcileBatchSize(options.batchSize);
|
|
2021
2679
|
const maxTuples = reconcileMaxTuples(options.maxTuples);
|
|
2022
|
-
|
|
2680
|
+
await this.assertModel('reconcile');
|
|
2681
|
+
// K4 (lote C · carreras): la pasada que ESCRIBE decide con el catálogo contrastado en fresco.
|
|
2682
|
+
const catalog = await this.writeCatalog();
|
|
2023
2683
|
const now = this.now();
|
|
2024
2684
|
/** Lo que el destino DEBE tener, por id de tupla. */
|
|
2025
2685
|
const wanted = new Map();
|
|
@@ -2170,6 +2830,8 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2170
2830
|
*/
|
|
2171
2831
|
const foreignTypes = new Map();
|
|
2172
2832
|
let foreignTuples = 0;
|
|
2833
|
+
/** Cierre de L11: los denies que el origen quiere y el destino tiene con una condición ajena (niegan la pasada). */
|
|
2834
|
+
const blockedDenies = [];
|
|
2173
2835
|
for (const tuple of current) {
|
|
2174
2836
|
const id = factsTupleId(tuple);
|
|
2175
2837
|
const family = familyOfTuple(tuple);
|
|
@@ -2179,6 +2841,25 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2179
2841
|
foreignTypes.set(type, (foreignTypes.get(type) ?? 0) + 1);
|
|
2180
2842
|
continue;
|
|
2181
2843
|
}
|
|
2844
|
+
// L11 · H8: una tupla del DESTINO con una condición que el paquete no escribe es PRESENTE e INTOCABLE: ni se
|
|
2845
|
+
// borra (tampoco con `--prune`) ni se reescribe encima lo que el origen quiere en esa clave; se lista y es deriva.
|
|
2846
|
+
if (tuple.invalid !== undefined) {
|
|
2847
|
+
const target = wanted.get(id);
|
|
2848
|
+
if (target)
|
|
2849
|
+
seen.add(id);
|
|
2850
|
+
note('tuple', 'invalid-condition', `${id} (${tuple.invalid})`);
|
|
2851
|
+
// Cierre de L11 (⚪ A2(b) del auditor; gemelo de relaciones, donde el deny bloqueado por una clave ajena del destino
|
|
2852
|
+
// NIEGA la pasada): un DENY que el origen quiere y el destino tiene con una condición que el paquete no escribe NO
|
|
2853
|
+
// queda «presente» — el servidor puede ignorarlo (una restricción sin condición, M-a(1)) y los grants que la
|
|
2854
|
+
// pasada escribe concederían. Ante la duda, lo que nunca concede de más: la pasada que escribe se NIEGA (abajo).
|
|
2855
|
+
// Cierre-2 (C1): en MANTENIMIENTO el origen ES este store, así que ese deny lo emitió él mismo (marcado): la pasada
|
|
2856
|
+
// no escribe hechos alrededor, negarla solo bloquearía lo DERIVADO (marcador, árbol, barrido `roleVisibility` del
|
|
2857
|
+
// invariante 18). Se deja intocable y se reporta (la nota de arriba), sin negar.
|
|
2858
|
+
if (target && !originIsSelf && target.tuple.relation.startsWith(FACTS_DENIED_PREFIX)) {
|
|
2859
|
+
blockedDenies.push(`${id} (${tuple.invalid})`);
|
|
2860
|
+
}
|
|
2861
|
+
continue;
|
|
2862
|
+
}
|
|
2182
2863
|
if (tuple.relation === FACTS_ASSIGNEE_RELATION)
|
|
2183
2864
|
storeBindings.add(tuple.object);
|
|
2184
2865
|
if (tuple.relation === FACTS_PARENT_RELATION && tuple.object.startsWith(`${FACTS_SCOPE_TYPE}:`)) {
|
|
@@ -2237,6 +2918,13 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2237
2918
|
// que devuelve N filas y las N se descartan (caducadas, scopes que ya no
|
|
2238
2919
|
// resuelven) sigue sin respaldar NADA de lo que --prune va a borrar.
|
|
2239
2920
|
const massDelete = prune && factsDeleted > 0 && facts.usable === 0;
|
|
2921
|
+
if (blockedDenies.length > 0 && !dryRun) {
|
|
2922
|
+
throw new DenyMigrationRefusedError(`deny NO migrable: authz:reconcile --to=openfga se NIEGA — ${blockedDenies.length} deny(s) que el origen quiere y el ` +
|
|
2923
|
+
`DESTINO tiene con una condición que el paquete no escribe ni sabe leer (L11): ${blockedDenies.slice(0, 5).join('; ')}` +
|
|
2924
|
+
`${blockedDenies.length > 5 ? '; …' : ''}. El servidor puede ignorar ese deny (su restricción no admite condición) y ` +
|
|
2925
|
+
'escribir los grants alrededor concedería lo que el origen niega. No se ha escrito nada. Receta: borra esa tupla del ' +
|
|
2926
|
+
'destino a mano (SDK/CLI de OpenFGA) y repite; --dry-run la lista como invalid-condition.');
|
|
2927
|
+
}
|
|
2240
2928
|
if (massDelete && !dryRun && options.allowMassDelete !== true) {
|
|
2241
2929
|
throw new MassReconcileRefusedError(`authz:reconcile --to=openfga --prune borraría ${factsDeleted} tupla(s) de hechos del store y el ` +
|
|
2242
2930
|
`ORIGEN no ha aportado NI UN hecho utilizable (${facts.rows} leídos, todos descartados — mira ` +
|
|
@@ -2247,10 +2935,27 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2247
2935
|
`--allow-mass-delete.`);
|
|
2248
2936
|
}
|
|
2249
2937
|
if (!dryRun) {
|
|
2938
|
+
// **Lote C · L13 (resolución 10 del juez): las ALTAS de `scope#binding` de un rol LOCAL pasan por el settle.** La
|
|
2939
|
+
// pasada las decide con una foto de toda la pasada (segundos o minutos) sin relectura, y el freeze no para las
|
|
2940
|
+
// escrituras en vuelo ni `manager.driver()`: escritas a ciegas, un `moved` que barriera entre medias quedaba
|
|
2941
|
+
// pisado. Se sacan de `writes` y, con el árbol y la estructura ya escritos, el settle las relee y las asienta con el
|
|
2942
|
+
// STORE. Las de roles globales (su visibilidad no depende del árbol) y todo lo demás van como siempre.
|
|
2943
|
+
const settlePairs = [];
|
|
2944
|
+
for (let i = writes.length - 1; i >= 0; i--) {
|
|
2945
|
+
const tuple = writes[i];
|
|
2946
|
+
if (tuple?.relation !== FACTS_BINDING_RELATION || !String(tuple.object).startsWith(`${FACTS_SCOPE_TYPE}:`))
|
|
2947
|
+
continue;
|
|
2948
|
+
const binding = parseBindingId(String(tuple.user).slice(FACTS_BINDING_TYPE.length + 1));
|
|
2949
|
+
if (!binding || catalog.roleByUuid(binding.uuid)?.owner === GLOBAL_OWNER_KEY)
|
|
2950
|
+
continue;
|
|
2951
|
+
settlePairs.push({ scope: scopeKey(binding.scope), role: binding.uuid });
|
|
2952
|
+
writes.splice(i, 1);
|
|
2953
|
+
}
|
|
2250
2954
|
// Los DELETES primero: si la pasada muere entre medias, lo que queda es
|
|
2251
2955
|
// de MENOS (fail-closed), nunca una tupla vieja conviviendo con la nueva.
|
|
2252
2956
|
await this.applyReconcileWrites(deletes, []);
|
|
2253
2957
|
await this.applyReconcileWrites([], writes);
|
|
2958
|
+
await this.settleRoleVisibility(settlePairs.reverse(), { writes: 0 });
|
|
2254
2959
|
}
|
|
2255
2960
|
const totals = sumPhases(phases);
|
|
2256
2961
|
return {
|
|
@@ -2284,18 +2989,9 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2284
2989
|
* el modelo que sí sirve (fail-closed sobre un permiso vivo).
|
|
2285
2990
|
*/
|
|
2286
2991
|
async modelPermissions() {
|
|
2287
|
-
//
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
? await this.client.readAuthorizationModel()
|
|
2291
|
-
: await this.client.readLatestAuthorizationModel();
|
|
2292
|
-
const model = response?.authorization_model;
|
|
2293
|
-
if (!model?.type_definitions)
|
|
2294
|
-
return null;
|
|
2295
|
-
const role = model.type_definitions.find((definition) => definition.type === FACTS_ROLE_TYPE);
|
|
2296
|
-
if (!role?.relations)
|
|
2297
|
-
return null;
|
|
2298
|
-
return new Set(Object.keys(role.relations).filter((name) => name.startsWith(FACTS_PERMITS_PREFIX)));
|
|
2992
|
+
// L11: la MISMA lectura (memorizada) que la puerta; `P_M` son las bases de `role#permits_*` del modelo FIJADO.
|
|
2993
|
+
const { permissions } = await this.modelGate();
|
|
2994
|
+
return new Set(permissions.map((permission) => `${FACTS_PERMITS_PREFIX}${permission}`));
|
|
2299
2995
|
}
|
|
2300
2996
|
/**
|
|
2301
2997
|
* El árbol del ORIGEN, paginado (`scopes.enumerateEdges`), con los ciclos
|
|
@@ -2579,6 +3275,12 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2579
3275
|
ctx.discard('deny', 'unknown-permission', fact.detail);
|
|
2580
3276
|
continue;
|
|
2581
3277
|
}
|
|
3278
|
+
// Cierre-2 de L11 (C1): un deny MARCADO del origen se quiere igual (fail-closed: se escribe sin condición, niega);
|
|
3279
|
+
// si el origen es OTRO store, se declara aquí. En mantenimiento la clave es la misma tupla del destino y la cuenta
|
|
3280
|
+
// el bucle del destino (una sola nota por tupla).
|
|
3281
|
+
if (fact.invalidCondition !== undefined && source.factsOrigin?.self !== true) {
|
|
3282
|
+
ctx.note('deny', 'invalid-condition', `${fact.detail} (${fact.invalidCondition})`);
|
|
3283
|
+
}
|
|
2582
3284
|
if (this.wantDeny(ctx, {
|
|
2583
3285
|
holder: fact.holder,
|
|
2584
3286
|
scope: fact.scope,
|
|
@@ -2722,6 +3424,9 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2722
3424
|
if (!Number.isFinite(limit) || limit < 1) {
|
|
2723
3425
|
throw new AuthorizationConfigError(`enumerateFacts: limit debe ser un entero >= 1 (llegó ${String(page.limit)})`);
|
|
2724
3426
|
}
|
|
3427
|
+
// L11: el ORIGEN de una migración pasa SOLO la parte `conditions` (tiene que poder rescatar un store con deriva de
|
|
3428
|
+
// reglas; la vigencia de lo que emite sí depende de la condición del modelo).
|
|
3429
|
+
await this.assertModel('enumerateFacts', 'conditions');
|
|
2725
3430
|
const catalog = await this.catalog.view();
|
|
2726
3431
|
const permissionByRelation = new Map();
|
|
2727
3432
|
for (const slug of catalog.permissionSlugs) {
|
|
@@ -2766,12 +3471,19 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2766
3471
|
skipped.push({ kind: 'tuple', reason: 'unknown-holder-type', detail: id });
|
|
2767
3472
|
continue;
|
|
2768
3473
|
}
|
|
3474
|
+
// L11: una condición que el paquete no escribe o no sabe leer NO es «permanente»: no se migra (fail-closed: el
|
|
3475
|
+
// destino no concede lo que el origen quizá no concedía) y se cuenta.
|
|
3476
|
+
const condition = storedCondition(key.condition);
|
|
3477
|
+
if (condition.invalid !== undefined) {
|
|
3478
|
+
skipped.push({ kind: 'tuple', reason: 'invalid-condition', detail: `${id} (${condition.invalid})` });
|
|
3479
|
+
continue;
|
|
3480
|
+
}
|
|
2769
3481
|
facts.push({
|
|
2770
3482
|
kind: 'assignment',
|
|
2771
3483
|
holder,
|
|
2772
3484
|
scope: binding.scope,
|
|
2773
3485
|
roleUuid: binding.uuid,
|
|
2774
|
-
expiresAt:
|
|
3486
|
+
expiresAt: condition.validUntil,
|
|
2775
3487
|
detail: id,
|
|
2776
3488
|
});
|
|
2777
3489
|
continue;
|
|
@@ -2794,7 +3506,18 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2794
3506
|
skipped.push({ kind: 'tuple', reason: 'unknown-holder-type', detail: id });
|
|
2795
3507
|
continue;
|
|
2796
3508
|
}
|
|
2797
|
-
|
|
3509
|
+
// Cierre-2 de L11 (C1 del auditor; gemelo del `assignee` de arriba): un deny con una condición que el paquete no
|
|
3510
|
+
// escribe se MARCA en vez de salir como deny limpio. No se salta (hacia `database` perderlo sería fail-OPEN): el
|
|
3511
|
+
// destino lo migra como deny y lo declara, y la pasada de MANTENIMIENTO lo deja donde está y lo reporta.
|
|
3512
|
+
const condition = storedCondition(key.condition);
|
|
3513
|
+
facts.push({
|
|
3514
|
+
kind: 'deny',
|
|
3515
|
+
holder,
|
|
3516
|
+
scope,
|
|
3517
|
+
permission,
|
|
3518
|
+
detail: id,
|
|
3519
|
+
...(condition.invalid !== undefined ? { invalidCondition: condition.invalid } : {}),
|
|
3520
|
+
});
|
|
2798
3521
|
continue;
|
|
2799
3522
|
}
|
|
2800
3523
|
// **El deny del modo `resolver` de 2.2 ES un hecho y se emite** (3b-8 ·
|
|
@@ -2853,7 +3576,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2853
3576
|
const seenTokens = new Set();
|
|
2854
3577
|
let pages = 0;
|
|
2855
3578
|
do {
|
|
2856
|
-
const response = await this.client.read({}, { pageSize: READ_PAGE_SIZE, continuationToken, consistency:
|
|
3579
|
+
const response = await this.client.read({}, { pageSize: READ_PAGE_SIZE, continuationToken, consistency: WRITE_CONSISTENCY });
|
|
2857
3580
|
pages += 1;
|
|
2858
3581
|
for (const tuple of this.tuplesOrThrow(response, 'reconcile.readEveryTuple')) {
|
|
2859
3582
|
const key = tuple?.key;
|
|
@@ -2863,12 +3586,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2863
3586
|
`no se puede comparar (total: ${this.diagnostics.unparseableBindings})`);
|
|
2864
3587
|
continue;
|
|
2865
3588
|
}
|
|
2866
|
-
out.push({
|
|
2867
|
-
user: key.user,
|
|
2868
|
-
relation: key.relation,
|
|
2869
|
-
object: key.object,
|
|
2870
|
-
validUntil: toExpiryDate(key.condition?.context?.valid_until),
|
|
2871
|
-
});
|
|
3589
|
+
out.push({ user: key.user, relation: key.relation, object: key.object, ...storedCondition(key.condition) });
|
|
2872
3590
|
if (out.length > maxTuples) {
|
|
2873
3591
|
throw new ReconcileTooLargeError(`authz:reconcile --to=openfga: el store pasa de maxTuples (${maxTuples}) tuplas y la pasada ` +
|
|
2874
3592
|
`necesita la foto ENTERA del destino para saber qué sobra (que es la mitad del trabajo: ` +
|
|
@@ -2938,6 +3656,14 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2938
3656
|
}
|
|
2939
3657
|
return tuples;
|
|
2940
3658
|
}
|
|
3659
|
+
/**
|
|
3660
|
+
* **`readAllTuples` para una lectura que ALIMENTA UNA ESCRITURA** (lote C · carreras, resolución 8; hallazgo 3 TREE):
|
|
3661
|
+
* con `HIGHER_CONSISTENCY` siempre (`WRITE_CONSISTENCY`). Es el helper ÚNICO: la guardia de fuente de
|
|
3662
|
+
* `tests/visibility_settle.spec.ts` exige que los métodos que escriben lean por aquí (o por `checkForWrite`).
|
|
3663
|
+
*/
|
|
3664
|
+
readForWrite(filter, options = {}) {
|
|
3665
|
+
return this.readAllTuples(filter, { ...options, consistency: WRITE_CONSISTENCY });
|
|
3666
|
+
}
|
|
2941
3667
|
async readAllTuples(filter, options = {}) {
|
|
2942
3668
|
// El instante con el que se filtra: el de la operación si lo trae (K9), o
|
|
2943
3669
|
// el de esta lectura.
|
|
@@ -2950,7 +3676,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2950
3676
|
const response = await this.client.read(filter, {
|
|
2951
3677
|
pageSize: READ_PAGE_SIZE,
|
|
2952
3678
|
continuationToken,
|
|
2953
|
-
consistency: this.consistency,
|
|
3679
|
+
consistency: options.consistency ?? this.consistency,
|
|
2954
3680
|
});
|
|
2955
3681
|
pages += 1;
|
|
2956
3682
|
for (const tuple of this.tuplesOrThrow(response, `Read ${JSON.stringify(filter)}`)) {
|
|
@@ -2963,8 +3689,22 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2963
3689
|
continue;
|
|
2964
3690
|
}
|
|
2965
3691
|
if (!options.includeExpired) {
|
|
2966
|
-
const
|
|
2967
|
-
|
|
3692
|
+
const condition = storedCondition(k.condition);
|
|
3693
|
+
// L11: una condición que el paquete no escribe o no sabe leer no es un hecho vigente que enumerar (sería
|
|
3694
|
+
// «permanente» por defecto): se avisa y se cuenta, como una tupla malformada. Las purgas leen con
|
|
3695
|
+
// `includeExpired` y la borran igual.
|
|
3696
|
+
// Cierre de L11 (criterio: ante la duda, lo que nunca concede de más): donde la tupla NIEGA o CUENTA
|
|
3697
|
+
// (`includeInvalid`: los denies de `listScopes`, el conteo de `countRoleAssignments`) se toma VIVA —un
|
|
3698
|
+
// `not_expired` con `current_time` congelado lo está en el servidor, H2—, con el mismo aviso.
|
|
3699
|
+
if (condition.invalid !== undefined) {
|
|
3700
|
+
this.diagnostics.unparseableBindings += 1;
|
|
3701
|
+
this.warn(`authz(openfga): tupla con una condición que el paquete no escribe en Read ${JSON.stringify(filter)} (${k.user}#${k.relation}@${k.object}: ${condition.invalid}); ${options.includeInvalid ? 'se cuenta VIVA (niega o cuenta: lo conservador)' : 'se ignora en la enumeración'} (total: ${this.diagnostics.unparseableBindings})`);
|
|
3702
|
+
if (!options.includeInvalid)
|
|
3703
|
+
continue;
|
|
3704
|
+
keys.push({ user: k.user, relation: k.relation, object: k.object });
|
|
3705
|
+
continue;
|
|
3706
|
+
}
|
|
3707
|
+
if (condition.validUntil && condition.validUntil <= now)
|
|
2968
3708
|
continue;
|
|
2969
3709
|
}
|
|
2970
3710
|
keys.push({ user: k.user, relation: k.relation, object: k.object });
|
|
@@ -3088,6 +3828,16 @@ function foreignReport(tuples, types, relationTypes) {
|
|
|
3088
3828
|
relations += count;
|
|
3089
3829
|
return { tuples, types: byType, relations, unknown: tuples - relations };
|
|
3090
3830
|
}
|
|
3831
|
+
/** Una tupla tal como está HOY en el store (con su caducidad). */
|
|
3832
|
+
/** La condición de una tupla leída (L11): el ÚNICO paso por `fgaTupleExpiry` de este driver. */
|
|
3833
|
+
function storedCondition(condition) {
|
|
3834
|
+
const expiry = fgaTupleExpiry(condition);
|
|
3835
|
+
if (expiry.kind === 'expiry')
|
|
3836
|
+
return { validUntil: expiry.at };
|
|
3837
|
+
if (expiry.kind === 'none')
|
|
3838
|
+
return { validUntil: null };
|
|
3839
|
+
return { validUntil: null, invalid: `${expiry.kind}: ${expiry.detail}` };
|
|
3840
|
+
}
|
|
3091
3841
|
/** La clave sola: un `delete` de FGA no lleva condición. */
|
|
3092
3842
|
function bareTuple(tuple) {
|
|
3093
3843
|
return { user: tuple.user, relation: tuple.relation, object: tuple.object };
|
|
@@ -3129,4 +3879,3 @@ function walkUpTo(parentOf, from, target) {
|
|
|
3129
3879
|
}
|
|
3130
3880
|
return null;
|
|
3131
3881
|
}
|
|
3132
|
-
//# sourceMappingURL=openfga_driver.js.map
|