@jantstack/adonis-authz 2.5.0-alpha.3 → 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 +414 -35
- 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 +0 -1
- package/build/commands/authz_relations_reconcile.js +56 -8
- package/build/commands/authz_scopes_relay.d.ts +0 -1
- package/build/commands/authz_scopes_relay.js +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 +10 -1
- package/build/commands/openfga_provision.js +43 -3
- package/build/configure.d.ts +0 -1
- package/build/configure.js +0 -1
- package/build/index.d.ts +7 -5
- package/build/index.js +10 -3
- package/build/providers/authz_provider.d.ts +0 -1
- package/build/providers/authz_provider.js +0 -1
- package/build/services/main.d.ts +0 -1
- package/build/services/main.js +0 -1
- package/build/services/relations.d.ts +0 -1
- package/build/services/relations.js +0 -1
- package/build/src/catalog/catalog.d.ts +0 -1
- package/build/src/catalog/catalog.js +0 -1
- package/build/src/catalog/catalog_cache.d.ts +13 -1
- package/build/src/catalog/catalog_cache.js +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 +11 -5
- package/build/src/drivers/database_relations_driver.js +90 -18
- package/build/src/drivers/openfga_driver.d.ts +159 -26
- package/build/src/drivers/openfga_driver.js +882 -199
- package/build/src/drivers/openfga_facts.d.ts +91 -1
- package/build/src/drivers/openfga_facts.js +277 -3
- package/build/src/drivers/openfga_relations_driver.d.ts +41 -13
- package/build/src/drivers/openfga_relations_driver.js +422 -228
- package/build/src/errors.d.ts +121 -1
- package/build/src/errors.js +153 -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 +156 -28
- package/build/src/memoize_ancestors.d.ts +0 -1
- package/build/src/memoize_ancestors.js +0 -1
- package/build/src/models/authz_assignment.d.ts +5 -6
- package/build/src/models/authz_assignment.js +0 -1
- package/build/src/models/authz_deny.d.ts +5 -6
- package/build/src/models/authz_deny.js +0 -1
- package/build/src/models/authz_permission.d.ts +5 -6
- package/build/src/models/authz_permission.js +0 -1
- package/build/src/models/authz_role.d.ts +5 -6
- package/build/src/models/authz_role.js +0 -1
- package/build/src/models/authz_role_permission.d.ts +5 -6
- package/build/src/models/authz_role_permission.js +0 -1
- package/build/src/openfga.d.ts +2 -3
- package/build/src/openfga.js +4 -2
- package/build/src/reconcile.d.ts +0 -1
- package/build/src/reconcile.js +0 -1
- package/build/src/relation_partition_trigger.d.ts +0 -1
- package/build/src/relation_partition_trigger.js +0 -1
- package/build/src/relations/check_many.d.ts +0 -1
- package/build/src/relations/check_many.js +0 -1
- package/build/src/relations/define_relations_config.d.ts +25 -3
- package/build/src/relations/define_relations_config.js +8 -3
- package/build/src/relations/deny_reach.d.ts +102 -0
- package/build/src/relations/deny_reach.js +230 -0
- package/build/src/relations/group_depth.d.ts +215 -0
- package/build/src/relations/group_depth.js +453 -0
- package/build/src/relations/manager.d.ts +14 -7
- package/build/src/relations/manager.js +60 -15
- package/build/src/relations/page.d.ts +19 -0
- package/build/src/relations/page.js +74 -0
- package/build/src/relations/reconcile.d.ts +58 -4
- package/build/src/relations/reconcile.js +266 -12
- package/build/src/relations/tuple_key.d.ts +0 -1
- package/build/src/relations/tuple_key.js +0 -1
- package/build/src/relations/tuple_order.d.ts +5 -4
- package/build/src/relations/tuple_order.js +5 -4
- package/build/src/relations_config_store.d.ts +57 -2
- package/build/src/relations_config_store.js +155 -5
- package/build/src/relations_reconcile.d.ts +0 -1
- package/build/src/relations_reconcile.js +0 -1
- package/build/src/scope_outbox.d.ts +0 -1
- package/build/src/scope_outbox.js +0 -1
- package/build/src/shared/backend_guard.d.ts +52 -1
- package/build/src/shared/backend_guard.js +164 -2
- package/build/src/shared/sql_expiry.d.ts +0 -1
- package/build/src/shared/sql_expiry.js +0 -1
- package/build/src/shared/transaction_guard.d.ts +0 -1
- package/build/src/shared/transaction_guard.js +0 -1
- package/build/src/sql_descendants.d.ts +0 -1
- package/build/src/sql_descendants.js +0 -1
- package/build/src/testing/contract.d.ts +7 -1
- package/build/src/testing/contract.js +77 -1
- package/build/src/testing/main.d.ts +0 -1
- package/build/src/testing/main.js +0 -1
- package/build/src/testing/migration_contract.d.ts +0 -1
- package/build/src/testing/migration_contract.js +0 -1
- package/build/src/testing/relations_contract.d.ts +7 -1
- package/build/src/testing/relations_contract.js +174 -7
- package/build/src/testing/relations_reconcile_contract.d.ts +0 -1
- package/build/src/testing/relations_reconcile_contract.js +0 -1
- package/build/src/testing/scope_outbox_contract.d.ts +0 -1
- package/build/src/testing/scope_outbox_contract.js +0 -1
- package/build/src/testing/scope_tree.d.ts +0 -1
- package/build/src/testing/scope_tree.js +0 -1
- package/build/src/testing/test_clock.d.ts +0 -1
- package/build/src/testing/test_clock.js +0 -1
- package/build/src/traits/authz_scopes.d.ts +0 -1
- package/build/src/traits/authz_scopes.js +0 -1
- package/build/src/traits/has_uuid.d.ts +5 -6
- package/build/src/traits/has_uuid.js +0 -1
- package/build/src/types.d.ts +89 -14
- package/build/src/types.js +4 -1
- package/build/stubs/config/authorization.stub +22 -6
- package/build/stubs/main.d.ts +0 -1
- package/build/stubs/main.js +0 -1
- package/package.json +4 -3
- package/build/commands/authz_catalog_diff.d.ts.map +0 -1
- package/build/commands/authz_catalog_diff.js.map +0 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts.map +0 -1
- package/build/commands/authz_catalog_prune_orphans.js.map +0 -1
- package/build/commands/authz_catalog_sync.d.ts.map +0 -1
- package/build/commands/authz_catalog_sync.js.map +0 -1
- package/build/commands/authz_freeze.d.ts.map +0 -1
- package/build/commands/authz_freeze.js.map +0 -1
- package/build/commands/authz_reconcile.d.ts.map +0 -1
- package/build/commands/authz_reconcile.js.map +0 -1
- package/build/commands/authz_relations_reconcile.d.ts.map +0 -1
- package/build/commands/authz_relations_reconcile.js.map +0 -1
- package/build/commands/authz_scopes_relay.d.ts.map +0 -1
- package/build/commands/authz_scopes_relay.js.map +0 -1
- package/build/commands/authz_scopes_requeue.d.ts.map +0 -1
- package/build/commands/authz_scopes_requeue.js.map +0 -1
- package/build/commands/authz_unfreeze.d.ts.map +0 -1
- package/build/commands/authz_unfreeze.js.map +0 -1
- package/build/commands/main.d.ts.map +0 -1
- package/build/commands/main.js.map +0 -1
- package/build/commands/openfga_provision.d.ts.map +0 -1
- package/build/commands/openfga_provision.js.map +0 -1
- package/build/configure.d.ts.map +0 -1
- package/build/configure.js.map +0 -1
- package/build/index.d.ts.map +0 -1
- package/build/index.js.map +0 -1
- package/build/providers/authz_provider.d.ts.map +0 -1
- package/build/providers/authz_provider.js.map +0 -1
- package/build/services/main.d.ts.map +0 -1
- package/build/services/main.js.map +0 -1
- package/build/services/relations.d.ts.map +0 -1
- package/build/services/relations.js.map +0 -1
- package/build/src/catalog/catalog.d.ts.map +0 -1
- package/build/src/catalog/catalog.js.map +0 -1
- package/build/src/catalog/catalog_cache.d.ts.map +0 -1
- package/build/src/catalog/catalog_cache.js.map +0 -1
- package/build/src/clock.d.ts.map +0 -1
- package/build/src/clock.js.map +0 -1
- package/build/src/define_config.d.ts.map +0 -1
- package/build/src/define_config.js.map +0 -1
- package/build/src/drivers/database_driver.d.ts.map +0 -1
- package/build/src/drivers/database_driver.js.map +0 -1
- package/build/src/drivers/database_relations_driver.d.ts.map +0 -1
- package/build/src/drivers/database_relations_driver.js.map +0 -1
- package/build/src/drivers/openfga_driver.d.ts.map +0 -1
- package/build/src/drivers/openfga_driver.js.map +0 -1
- package/build/src/drivers/openfga_facts.d.ts.map +0 -1
- package/build/src/drivers/openfga_facts.js.map +0 -1
- package/build/src/drivers/openfga_relations_driver.d.ts.map +0 -1
- package/build/src/drivers/openfga_relations_driver.js.map +0 -1
- package/build/src/errors.d.ts.map +0 -1
- package/build/src/errors.js.map +0 -1
- package/build/src/expiry.d.ts.map +0 -1
- package/build/src/expiry.js.map +0 -1
- package/build/src/freeze.d.ts.map +0 -1
- package/build/src/freeze.js.map +0 -1
- package/build/src/hierarchical_resolver.d.ts.map +0 -1
- package/build/src/hierarchical_resolver.js.map +0 -1
- package/build/src/http/app_access_middleware.d.ts.map +0 -1
- package/build/src/http/app_access_middleware.js.map +0 -1
- package/build/src/http/resource_access_middleware.d.ts.map +0 -1
- package/build/src/http/resource_access_middleware.js.map +0 -1
- package/build/src/identity.d.ts.map +0 -1
- package/build/src/identity.js.map +0 -1
- package/build/src/manager.d.ts.map +0 -1
- package/build/src/manager.js.map +0 -1
- package/build/src/memoize_ancestors.d.ts.map +0 -1
- package/build/src/memoize_ancestors.js.map +0 -1
- package/build/src/models/authz_assignment.d.ts.map +0 -1
- package/build/src/models/authz_assignment.js.map +0 -1
- package/build/src/models/authz_deny.d.ts.map +0 -1
- package/build/src/models/authz_deny.js.map +0 -1
- package/build/src/models/authz_permission.d.ts.map +0 -1
- package/build/src/models/authz_permission.js.map +0 -1
- package/build/src/models/authz_role.d.ts.map +0 -1
- package/build/src/models/authz_role.js.map +0 -1
- package/build/src/models/authz_role_permission.d.ts.map +0 -1
- package/build/src/models/authz_role_permission.js.map +0 -1
- package/build/src/openfga.d.ts.map +0 -1
- package/build/src/openfga.js.map +0 -1
- package/build/src/reconcile.d.ts.map +0 -1
- package/build/src/reconcile.js.map +0 -1
- package/build/src/relation_partition_trigger.d.ts.map +0 -1
- package/build/src/relation_partition_trigger.js.map +0 -1
- package/build/src/relations/check_many.d.ts.map +0 -1
- package/build/src/relations/check_many.js.map +0 -1
- package/build/src/relations/define_relations_config.d.ts.map +0 -1
- package/build/src/relations/define_relations_config.js.map +0 -1
- package/build/src/relations/manager.d.ts.map +0 -1
- package/build/src/relations/manager.js.map +0 -1
- package/build/src/relations/reconcile.d.ts.map +0 -1
- package/build/src/relations/reconcile.js.map +0 -1
- package/build/src/relations/tuple_key.d.ts.map +0 -1
- package/build/src/relations/tuple_key.js.map +0 -1
- package/build/src/relations/tuple_order.d.ts.map +0 -1
- package/build/src/relations/tuple_order.js.map +0 -1
- package/build/src/relations_config_store.d.ts.map +0 -1
- package/build/src/relations_config_store.js.map +0 -1
- package/build/src/relations_reconcile.d.ts.map +0 -1
- package/build/src/relations_reconcile.js.map +0 -1
- package/build/src/scope_outbox.d.ts.map +0 -1
- package/build/src/scope_outbox.js.map +0 -1
- package/build/src/shared/backend_guard.d.ts.map +0 -1
- package/build/src/shared/backend_guard.js.map +0 -1
- package/build/src/shared/sql_expiry.d.ts.map +0 -1
- package/build/src/shared/sql_expiry.js.map +0 -1
- package/build/src/shared/transaction_guard.d.ts.map +0 -1
- package/build/src/shared/transaction_guard.js.map +0 -1
- package/build/src/sql_descendants.d.ts.map +0 -1
- package/build/src/sql_descendants.js.map +0 -1
- package/build/src/testing/contract.d.ts.map +0 -1
- package/build/src/testing/contract.js.map +0 -1
- package/build/src/testing/main.d.ts.map +0 -1
- package/build/src/testing/main.js.map +0 -1
- package/build/src/testing/migration_contract.d.ts.map +0 -1
- package/build/src/testing/migration_contract.js.map +0 -1
- package/build/src/testing/relations_contract.d.ts.map +0 -1
- package/build/src/testing/relations_contract.js.map +0 -1
- package/build/src/testing/relations_reconcile_contract.d.ts.map +0 -1
- package/build/src/testing/relations_reconcile_contract.js.map +0 -1
- package/build/src/testing/scope_outbox_contract.d.ts.map +0 -1
- package/build/src/testing/scope_outbox_contract.js.map +0 -1
- package/build/src/testing/scope_tree.d.ts.map +0 -1
- package/build/src/testing/scope_tree.js.map +0 -1
- package/build/src/testing/test_clock.d.ts.map +0 -1
- package/build/src/testing/test_clock.js.map +0 -1
- package/build/src/traits/authz_scopes.d.ts.map +0 -1
- package/build/src/traits/authz_scopes.js.map +0 -1
- package/build/src/traits/has_uuid.d.ts.map +0 -1
- package/build/src/traits/has_uuid.js.map +0 -1
- package/build/src/types.d.ts.map +0 -1
- package/build/src/types.js.map +0 -1
- package/build/stubs/main.d.ts.map +0 -1
- package/build/stubs/main.js.map +0 -1
|
@@ -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, markPartialWrite, } 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, isServerWriteLimit, 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
|
}
|
|
@@ -270,6 +276,8 @@ function serverWriteLimitError(operation, cause) {
|
|
|
270
276
|
* ahí el error del SDK es la información más útil y no rompe ninguna
|
|
271
277
|
* abstracción.
|
|
272
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']);
|
|
273
281
|
function guardBackendErrors(client, timeoutMs) {
|
|
274
282
|
return new Proxy(client, {
|
|
275
283
|
get(target, prop, receiver) {
|
|
@@ -278,11 +286,15 @@ function guardBackendErrors(client, timeoutMs) {
|
|
|
278
286
|
return value;
|
|
279
287
|
return (...args) => {
|
|
280
288
|
const operation = String(prop);
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
+
};
|
|
286
298
|
try {
|
|
287
299
|
const result = value.apply(target, args);
|
|
288
300
|
if (!(result instanceof Promise))
|
|
@@ -353,6 +365,55 @@ const TREE_WRITE_ATTEMPTS = 3;
|
|
|
353
365
|
const PURGE_BATCH_SIZE = 100;
|
|
354
366
|
/** Tamaño de página de `Read` (máximo del servidor). */
|
|
355
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
|
+
}
|
|
356
417
|
/**
|
|
357
418
|
* El deny del modo `resolver` (≤ 2.2), que el modelo de hoy ni declara pero
|
|
358
419
|
* un store de aquella versión sí tiene: `enumerateFacts` lo EMITE como deny
|
|
@@ -421,6 +482,9 @@ export class OpenFgaAuthorizationDriver {
|
|
|
421
482
|
// `database` para lo que tenga que ir en su transacción, o
|
|
422
483
|
// `requireTransactionalWrites` para fallar al arrancar).
|
|
423
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,
|
|
424
488
|
});
|
|
425
489
|
}
|
|
426
490
|
client;
|
|
@@ -442,6 +506,12 @@ export class OpenFgaAuthorizationDriver {
|
|
|
442
506
|
* con el que este driver escribe y responde, no el último del store.
|
|
443
507
|
*/
|
|
444
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 = {};
|
|
445
515
|
consistency;
|
|
446
516
|
/** Reloj de pared del driver (J1): el ÚNICO `now` de checks, filtros y re-grant. */
|
|
447
517
|
now;
|
|
@@ -455,8 +525,13 @@ export class OpenFgaAuthorizationDriver {
|
|
|
455
525
|
*/
|
|
456
526
|
catalog;
|
|
457
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);
|
|
458
530
|
assertHolderTypes(options.holderTypes);
|
|
459
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);
|
|
460
535
|
if (options.now !== undefined && !isClock(options.now)) {
|
|
461
536
|
throw new AuthorizationConfigError(`OpenFgaAuthorizationDriver: 'now' debe ser una función () => Date (llegó ${typeof options.now})`);
|
|
462
537
|
}
|
|
@@ -543,6 +618,53 @@ export class OpenFgaAuthorizationDriver {
|
|
|
543
618
|
checkContext() {
|
|
544
619
|
return checkContext(this.now());
|
|
545
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
|
+
}
|
|
546
668
|
/**
|
|
547
669
|
* Consulta al catálogo local clasificando su fallo. Con este driver el
|
|
548
670
|
* catálogo SQL sigue siendo una dependencia dura de cada pregunta: su caída
|
|
@@ -573,6 +695,257 @@ export class OpenFgaAuthorizationDriver {
|
|
|
573
695
|
// Antes la fase de roles y `hasRole` lo colapsaban en `false`.
|
|
574
696
|
return alignBatchResults('openfga', 'batchCheck', withIds, response.result);
|
|
575
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
|
+
}
|
|
576
949
|
// ── Catálogo local (compartido entre drivers), desde el memo (2A) ──────
|
|
577
950
|
// Una carga por driver/proceso en vez de una o dos consultas SQL por
|
|
578
951
|
// pregunta, más una revalidación por operación (2D · F1). Un fallo de
|
|
@@ -596,6 +969,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
596
969
|
// ── Contrato ──────────────────────────────────────────────────────────
|
|
597
970
|
async authorize(subject, permission, scope) {
|
|
598
971
|
assertIdentity({ subject, permission, scope });
|
|
972
|
+
await this.assertModel('authorize');
|
|
599
973
|
// Una foto del catálogo por pregunta: el permiso sale de la misma versión
|
|
600
974
|
// (y se paga una sola revalidación). Es lo ÚNICO local que queda.
|
|
601
975
|
const catalog = await this.catalog.view();
|
|
@@ -653,6 +1027,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
653
1027
|
assertIdentity({ scope });
|
|
654
1028
|
if (scopes.length === 0)
|
|
655
1029
|
return [];
|
|
1030
|
+
await this.assertModel('authorizeMany');
|
|
656
1031
|
const catalog = await this.catalog.view();
|
|
657
1032
|
const perm = catalog.permission(permission);
|
|
658
1033
|
if (!perm)
|
|
@@ -712,13 +1087,15 @@ export class OpenFgaAuthorizationDriver {
|
|
|
712
1087
|
async grant(subject, role, scope, options = {}) {
|
|
713
1088
|
this.rejectTransaction('grant', options);
|
|
714
1089
|
assertIdentity({ subject, role, scope, expiresAt: options.expiresAt });
|
|
1090
|
+
await this.assertModel('grant');
|
|
715
1091
|
// El binding lleva la identidad canónica del árbol (K1), nunca la forma
|
|
716
1092
|
// del llamante, y el uuid del rol, nunca su slug (3A · A1). El rol tiene
|
|
717
1093
|
// que EXISTIR en ese scope (3B · B2: global, o local a un ancestro-o-igual)
|
|
718
1094
|
// con una composición legal (B5).
|
|
719
1095
|
const chain = await this.knownScope(scope, 'grant');
|
|
720
1096
|
const [target] = chain;
|
|
721
|
-
|
|
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();
|
|
722
1099
|
const declared = resolveRoleQuery(catalog, role, target, chainKeysFrom(chain)[0]);
|
|
723
1100
|
assertRoleAssignableAt(catalog, declared);
|
|
724
1101
|
const roleUuid = declared.uuid;
|
|
@@ -727,6 +1104,64 @@ export class OpenFgaAuthorizationDriver {
|
|
|
727
1104
|
relation: 'assignee',
|
|
728
1105
|
object: `role_binding:${scopeKey(target)}|${roleUuid}`,
|
|
729
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 }) {
|
|
730
1165
|
// **La forma nueva de los objetos (3b-2c), en UNA sola escritura
|
|
731
1166
|
// (3b-2f · R3).** En (c2) la asignación no es alcanzable con el
|
|
732
1167
|
// `assignee` a secas: el binding tiene que colgar del scope
|
|
@@ -740,7 +1175,6 @@ export class OpenFgaAuthorizationDriver {
|
|
|
740
1175
|
// mismo hecho, que es peor que perder la escritura. Ahora las TRES van
|
|
741
1176
|
// en el mismo `Write` (transaccional en FGA): o están las tres o no está
|
|
742
1177
|
// ninguna.
|
|
743
|
-
const structure = factsBindingTuples(scopeKey(target), roleUuid);
|
|
744
1178
|
/** El write COMPLETO de una asignación: su estructura y ella. */
|
|
745
1179
|
const writeSet = (tuple) => [...structure, tuple];
|
|
746
1180
|
const tupleFor = (expiresAt) => expiresAt
|
|
@@ -769,14 +1203,20 @@ export class OpenFgaAuthorizationDriver {
|
|
|
769
1203
|
if (options.expiresAt === undefined)
|
|
770
1204
|
throw withPreserveRecipe(current.error);
|
|
771
1205
|
const expiresAt = options.expiresAt;
|
|
1206
|
+
progress.attempted += 1;
|
|
772
1207
|
const existed = await this.writeAssignment(key, tupleFor(expiresAt), structure);
|
|
773
1208
|
return { existed, expiresAt };
|
|
774
1209
|
}
|
|
775
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);
|
|
776
1215
|
const expiresAt = resolveGrantExpiry(current.validUntil, options.expiresAt, this.now());
|
|
777
1216
|
if (sameInstant(current.validUntil, expiresAt)) {
|
|
778
1217
|
return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
|
|
779
1218
|
}
|
|
1219
|
+
progress.attempted += 1;
|
|
780
1220
|
await this.replaceAssignment(key, tupleFor(expiresAt), structure);
|
|
781
1221
|
return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
|
|
782
1222
|
}
|
|
@@ -793,7 +1233,8 @@ export class OpenFgaAuthorizationDriver {
|
|
|
793
1233
|
// a la vez: la asignación sigue sin existir, así que se repite el
|
|
794
1234
|
// MISMO write ignorando duplicados (las aristas no llevan condición,
|
|
795
1235
|
// así que un duplicado suyo es idéntico por construcción). Sigue
|
|
796
|
-
// 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).
|
|
797
1238
|
//
|
|
798
1239
|
// Cuál de los dos fue lo dice la RELECTURA, no el error: el conflicto
|
|
799
1240
|
// transaccional de FGA (`Aborted`, HTTP 409) no nombra ninguna tupla.
|
|
@@ -809,6 +1250,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
809
1250
|
const expiresAt = options.expiresAt ?? null;
|
|
810
1251
|
for (let attempt = 0;; attempt++) {
|
|
811
1252
|
try {
|
|
1253
|
+
progress.attempted += 1;
|
|
812
1254
|
await this.client.writeTuples(writeSet(tupleFor(expiresAt)), attempt === 0 ? undefined : IGNORE_DUPLICATE_WRITES);
|
|
813
1255
|
return { existed: false, expiresAt };
|
|
814
1256
|
}
|
|
@@ -821,8 +1263,11 @@ export class OpenFgaAuthorizationDriver {
|
|
|
821
1263
|
}
|
|
822
1264
|
const raced = await this.readAssignment(key);
|
|
823
1265
|
if (raced.kind === 'present') {
|
|
1266
|
+
if (raced.invalid !== undefined)
|
|
1267
|
+
throw RenewalUnsafeError.foreignCondition('grant', `${key.object}#${key.relation}@${key.user}`, raced.invalid);
|
|
824
1268
|
const target = resolveGrantExpiry(raced.validUntil, options.expiresAt, this.now());
|
|
825
1269
|
if (!sameInstant(raced.validUntil, target)) {
|
|
1270
|
+
progress.attempted += 1;
|
|
826
1271
|
await this.replaceAssignment(key, tupleFor(target), structure);
|
|
827
1272
|
}
|
|
828
1273
|
return { existed: true, previousExpiresAt: raced.validUntil, expiresAt: target };
|
|
@@ -832,6 +1277,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
832
1277
|
// preservar, y el error de lectura va como causa.
|
|
833
1278
|
if (options.expiresAt === undefined)
|
|
834
1279
|
throw withPreserveRecipe(raced.error);
|
|
1280
|
+
progress.attempted += 1;
|
|
835
1281
|
const existed = await this.writeAssignment(key, tupleFor(options.expiresAt), structure);
|
|
836
1282
|
return { existed, expiresAt: options.expiresAt };
|
|
837
1283
|
}
|
|
@@ -897,15 +1343,15 @@ export class OpenFgaAuthorizationDriver {
|
|
|
897
1343
|
*/
|
|
898
1344
|
async readAssignment(key) {
|
|
899
1345
|
try {
|
|
900
|
-
|
|
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 });
|
|
901
1348
|
// ⚪ N5 (cierre 2 del lote B): un 200 sin `tuples` no es «ausente», es
|
|
902
1349
|
// «no se sabe» (503 de `tuplesOrThrow` como causa): sin objetivo explícito
|
|
903
1350
|
// `grant` no escribe a ciegas sobre una caducidad que no ha podido ver.
|
|
904
1351
|
const tuple = this.tuplesOrThrow(response, 'readAssignment')[0];
|
|
905
1352
|
if (!tuple)
|
|
906
1353
|
return { kind: 'absent' };
|
|
907
|
-
|
|
908
|
-
return { kind: 'present', validUntil: toExpiryDate(validUntil) };
|
|
1354
|
+
return { kind: 'present', ...storedCondition(tuple.key?.condition) };
|
|
909
1355
|
}
|
|
910
1356
|
catch (error) {
|
|
911
1357
|
return { kind: 'unknown', error };
|
|
@@ -914,10 +1360,14 @@ export class OpenFgaAuthorizationDriver {
|
|
|
914
1360
|
async revoke(subject, role, scope, options) {
|
|
915
1361
|
this.rejectTransaction('revoke', options);
|
|
916
1362
|
assertIdentity({ subject, role, scope });
|
|
1363
|
+
await this.assertModel('revoke');
|
|
917
1364
|
// Rol fuera del catálogo para ese nivel ⇒ 422, como en `grant` (D10). Se
|
|
918
1365
|
// quitan los bindings de TODOS los roles con ese nombre en el scope
|
|
919
1366
|
// exacto (3B): a lo sumo uno es visible ahí; quitar nunca concede.
|
|
920
|
-
|
|
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);
|
|
921
1371
|
// Sin cadena, TODAS las ortografías de las que el uuid puede ser alias
|
|
922
1372
|
// (3b-8 · A4): con una sola, el alias hacía del delete un no-op
|
|
923
1373
|
// silencioso (`onMissingDeletes: Ignore`) y el hecho canónico seguía ahí.
|
|
@@ -927,6 +1377,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
927
1377
|
}
|
|
928
1378
|
async hasRole(subject, role, scope) {
|
|
929
1379
|
assertIdentity({ subject, role, scope });
|
|
1380
|
+
await this.assertModel('hasRole');
|
|
930
1381
|
const user = this.fgaSubject(subject);
|
|
931
1382
|
const chain = await this.chain(scope, 'hasRole');
|
|
932
1383
|
if (!chain)
|
|
@@ -953,6 +1404,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
953
1404
|
async deny(subject, permission, scope, options) {
|
|
954
1405
|
this.rejectTransaction('deny', options);
|
|
955
1406
|
assertIdentity({ subject, permission, scope });
|
|
1407
|
+
await this.assertModel('deny');
|
|
956
1408
|
const perm = await this.findPermission(permission);
|
|
957
1409
|
if (!perm)
|
|
958
1410
|
throw new UnknownPermissionError(permission);
|
|
@@ -964,6 +1416,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
964
1416
|
async removeDeny(subject, permission, scope, options) {
|
|
965
1417
|
this.rejectTransaction('removeDeny', options);
|
|
966
1418
|
assertIdentity({ subject, permission, scope });
|
|
1419
|
+
await this.assertModel('removeDeny');
|
|
967
1420
|
const perm = await this.findPermission(permission);
|
|
968
1421
|
if (!perm)
|
|
969
1422
|
throw new UnknownPermissionError(permission);
|
|
@@ -996,6 +1449,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
996
1449
|
*/
|
|
997
1450
|
async listSubjects(role, scope) {
|
|
998
1451
|
assertIdentity({ role, scope });
|
|
1452
|
+
await this.assertModel('listSubjects');
|
|
999
1453
|
// Un rol que el catálogo no declara para ese nivel (en ningún owner) no
|
|
1000
1454
|
// tiene holders (D5): nada que leer, ni árbol ni store. Un scope que el
|
|
1001
1455
|
// árbol no conoce no existe para el motor (D8, K1): nada; uno que conoce
|
|
@@ -1059,7 +1513,10 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1059
1513
|
user: this.fgaSubject(subject),
|
|
1060
1514
|
relation: factsRelationsOf(slug).denied,
|
|
1061
1515
|
object: `${FACTS_SCOPE_TYPE}:`,
|
|
1062
|
-
},
|
|
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 });
|
|
1063
1520
|
return new Set(tuples.map((t) => t.object.slice(FACTS_SCOPE_TYPE.length + 1)));
|
|
1064
1521
|
}
|
|
1065
1522
|
/**
|
|
@@ -1115,6 +1572,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1115
1572
|
}
|
|
1116
1573
|
async listRoles(subject, scope) {
|
|
1117
1574
|
assertIdentity({ subject, scope });
|
|
1575
|
+
await this.assertModel('listRoles');
|
|
1118
1576
|
// Un scope que el árbol no conoce no existe para el motor (D8): nada.
|
|
1119
1577
|
const chain = await this.chain(scope, 'listRoles');
|
|
1120
1578
|
if (!chain)
|
|
@@ -1144,6 +1602,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1144
1602
|
assertScope(scope);
|
|
1145
1603
|
if (chain.length === 0)
|
|
1146
1604
|
return [];
|
|
1605
|
+
await this.assertModel('rolesInChain');
|
|
1147
1606
|
const catalog = await this.catalog.view();
|
|
1148
1607
|
const keysFrom = chainKeysFrom(chain);
|
|
1149
1608
|
const wanted = new Map(chain.map((s, i) => [scopeKey(s), { scope: s, keys: keysFrom[i] }]));
|
|
@@ -1169,6 +1628,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1169
1628
|
}
|
|
1170
1629
|
async listRoleScopes(subject, scopeType) {
|
|
1171
1630
|
assertIdentity({ subject, scopeType });
|
|
1631
|
+
await this.assertModel('listRoleScopes');
|
|
1172
1632
|
const catalog = await this.catalog.view();
|
|
1173
1633
|
const byScope = new Map();
|
|
1174
1634
|
for (const binding of await this.listBindings(subject)) {
|
|
@@ -1195,6 +1655,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1195
1655
|
}
|
|
1196
1656
|
async listScopes(subject, permission) {
|
|
1197
1657
|
assertIdentity({ subject, permission });
|
|
1658
|
+
await this.assertModel('listScopes');
|
|
1198
1659
|
const catalog = await this.catalog.view();
|
|
1199
1660
|
const perm = catalog.permission(permission);
|
|
1200
1661
|
if (!perm)
|
|
@@ -1232,6 +1693,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1232
1693
|
*/
|
|
1233
1694
|
async listDenies(subject, scope) {
|
|
1234
1695
|
assertIdentity(scope ? { subject, scope } : { subject });
|
|
1696
|
+
await this.assertModel('listDenies');
|
|
1235
1697
|
const chain = scope ? await this.chain(scope, 'listDenies') : null;
|
|
1236
1698
|
if (scope && !chain)
|
|
1237
1699
|
return [];
|
|
@@ -1282,8 +1744,12 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1282
1744
|
* borrar una tupla inexistente—, la cadena del padre NUEVO en el store
|
|
1283
1745
|
* (3b-8 · A5: el anti-ciclos del consumidor no ve un store
|
|
1284
1746
|
* desincronizado) y **UN solo `Write`** con el delete del padre viejo y el
|
|
1285
|
-
* write del nuevo, que es atómico
|
|
1286
|
-
*
|
|
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`).
|
|
1287
1753
|
*/
|
|
1288
1754
|
async onScopeMoved(child, newParent) {
|
|
1289
1755
|
await this.reparent(child, newParent, 'scopes.moved');
|
|
@@ -1353,9 +1819,10 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1353
1819
|
if (child.type === APP_SCOPE_TYPE) {
|
|
1354
1820
|
throw new InvalidIdentityError('scopes.detached: la raíz `app` no se puede borrar ni purgar');
|
|
1355
1821
|
}
|
|
1822
|
+
await this.assertModel('scopes.detached');
|
|
1356
1823
|
const scope = await this.canonicalOrSelf(child, 'scopes.detached');
|
|
1357
1824
|
const object = factsScopeObject(scopeKey(scope));
|
|
1358
|
-
const current = await this.
|
|
1825
|
+
const current = await this.readForWrite({ object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
|
|
1359
1826
|
// Aquí NO se exige un solo padre: el nodo se va entero y llevarse las dos
|
|
1360
1827
|
// aristas de una deriva es lo correcto (a diferencia de `moved`, donde
|
|
1361
1828
|
// habría que elegir cuál sobrevive).
|
|
@@ -1387,58 +1854,115 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1387
1854
|
* impide esa carrera es el ESCRITOR ÚNICO del relay (`ScopeOutbox.acquire`).
|
|
1388
1855
|
*/
|
|
1389
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);
|
|
1390
1861
|
const { childKey, parentKey } = await this.assertEdge(child, parent, operation);
|
|
1391
1862
|
const wanted = factsParentTuple(childKey, parentKey);
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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();
|
|
1405
1947
|
return;
|
|
1406
1948
|
}
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
// cambio de árbol, fuera del camino caliente.
|
|
1417
|
-
if ((await this.storeChain(parentKey)).includes(childKey)) {
|
|
1418
|
-
throw new ScopeCycleError(`${operation}: en el árbol del STORE ${factsScopeObject(parentKey)} desciende de ${childKey} ` +
|
|
1419
|
-
`(o es él mismo); escribir la arista cerraría un ciclo real que FGA evalúa en los dos ` +
|
|
1420
|
-
`sentidos. El árbol del store está desincronizado del consumidor (una outbox aplicada en ` +
|
|
1421
|
-
`desorden, una entrada aparcada): reconstrúyelo con authz:reconcile.`);
|
|
1422
|
-
}
|
|
1423
|
-
try {
|
|
1424
|
-
await this.client.write({ writes: [wanted], deletes: current });
|
|
1425
|
-
}
|
|
1426
|
-
catch (error) {
|
|
1427
|
-
if (!isTreeWriteRace(error))
|
|
1428
|
-
throw error;
|
|
1429
|
-
if (attempt >= TREE_WRITE_ATTEMPTS - 1) {
|
|
1430
|
-
throw new WriteConflictError(`${operation}: ${TREE_WRITE_ATTEMPTS} intentos y el árbol del store sigue en conflicto sobre ` +
|
|
1431
|
-
`${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);
|
|
1432
1958
|
}
|
|
1433
|
-
continue;
|
|
1434
1959
|
}
|
|
1435
|
-
|
|
1436
|
-
return;
|
|
1960
|
+
throw progress.writes > 0 && final !== null && typeof final === 'object' ? markPartialWrite(final) : final;
|
|
1437
1961
|
}
|
|
1438
1962
|
}
|
|
1439
1963
|
/**
|
|
1440
|
-
* **El barrido del rol local** (3b-2e · E1; decisión del dueño del
|
|
1441
|
-
* 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).
|
|
1442
1966
|
*
|
|
1443
1967
|
* En (c2) el modelo no tiene `owner`, así que `authorize` NO vuelve a
|
|
1444
1968
|
* decidir con el árbol de hoy si un rol LOCAL sigue siendo visible: un
|
|
@@ -1448,11 +1972,12 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1448
1972
|
* un **fail-open** — y encima uno que solo se ve comparando drivers.
|
|
1449
1973
|
*
|
|
1450
1974
|
* Lo que se toca es la arista `scope#binding`, que es lo que hace
|
|
1451
|
-
* ALCANZABLE la asignación:
|
|
1452
|
-
*
|
|
1453
|
-
*
|
|
1454
|
-
*
|
|
1455
|
-
* 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.
|
|
1456
1981
|
*
|
|
1457
1982
|
* **Por subárbol, no por nodo** (consecuencia 2): los descendientes del
|
|
1458
1983
|
* nodo movido también cambian de cadena.
|
|
@@ -1461,24 +1986,43 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1461
1986
|
* que no usa delegación— son **cero** requests y `moved` sigue siendo el
|
|
1462
1987
|
* `Read` + `Write` del cruce 8. Con roles locales: una lectura por rol local
|
|
1463
1988
|
* (sus bindings, por `role_binding#role`), la bajada del subárbol y un
|
|
1464
|
-
* `Write` por lote.
|
|
1989
|
+
* `Write` por lote (el coste O(roles locales) por `moved` es el 🟡 6, declarado hasta 2.6).
|
|
1465
1990
|
*/
|
|
1466
|
-
async sweepLocalRoleBindings(movedKey) {
|
|
1467
|
-
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();
|
|
1468
2011
|
const locals = catalog.localRoles();
|
|
2012
|
+
const subtree = new Map();
|
|
1469
2013
|
if (locals.length === 0)
|
|
1470
|
-
return;
|
|
2014
|
+
return { catalog, pairs: [], subtree };
|
|
1471
2015
|
// La cadena del nodo movido y su subárbol se leen del STORE, no del
|
|
1472
2016
|
// consumidor: el árbol que decide en `facts` es el del store, y con la
|
|
1473
2017
|
// outbox el relay aplica esto MÁS TARDE, cuando el árbol del consumidor
|
|
1474
|
-
// puede haber seguido moviéndose.
|
|
1475
|
-
|
|
1476
|
-
|
|
1477
|
-
|
|
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)));
|
|
1478
2022
|
const pending = [movedKey];
|
|
1479
2023
|
while (pending.length) {
|
|
1480
2024
|
const key = pending.pop();
|
|
1481
|
-
const children = await this.
|
|
2025
|
+
const children = await this.readForWrite({ user: factsScopeObject(key), relation: FACTS_PARENT_RELATION, object: `${FACTS_SCOPE_TYPE}:` }, { includeExpired: true });
|
|
1482
2026
|
for (const edge of children) {
|
|
1483
2027
|
const childKey = edge.object.slice(FACTS_SCOPE_TYPE.length + 1);
|
|
1484
2028
|
if (subtree.has(childKey))
|
|
@@ -1487,21 +2031,28 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1487
2031
|
pending.push(childKey);
|
|
1488
2032
|
}
|
|
1489
2033
|
}
|
|
1490
|
-
const
|
|
1491
|
-
const deletes = [];
|
|
2034
|
+
const pairs = [];
|
|
1492
2035
|
for (const role of locals) {
|
|
1493
2036
|
// Los bindings de ESTE rol, esté donde esté: `role_binding:…#role@role:<uuid>`.
|
|
1494
2037
|
// El filtro lleva `user`, que es lo que el servidor real exige cuando el
|
|
1495
2038
|
// objeto es solo un tipo (lección del 2c).
|
|
1496
|
-
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 });
|
|
1497
2040
|
for (const binding of this.parseBindings(FACTS_BINDING_TYPE, bindings.map((t) => t.object))) {
|
|
1498
|
-
const
|
|
1499
|
-
if (
|
|
1500
|
-
|
|
1501
|
-
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 });
|
|
1502
2044
|
}
|
|
1503
2045
|
}
|
|
1504
|
-
|
|
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);
|
|
1505
2056
|
}
|
|
1506
2057
|
/**
|
|
1507
2058
|
* **El barrido por NIVEL** (3b-2g · R1; decisión del dueño del 2026-08-30
|
|
@@ -1513,46 +2064,29 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1513
2064
|
* —donde `declaredRoleAt` se evalúa en cada pregunta— y **sigue
|
|
1514
2065
|
* concediendo** en `facts`, que es la divergencia R1 del lote 2e.
|
|
1515
2066
|
*
|
|
1516
|
-
* Se cierra igual que el owner:
|
|
1517
|
-
* 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
|
|
1518
2069
|
* `projectCatalogRole`, que es el hook de «una escritura de catálogo cambió
|
|
1519
2070
|
* este rol»: el manager lo dispara tras `defineScopedRole`/`updateScopedRole`
|
|
1520
2071
|
* y un escritor «a mano» de `authz_*` tiene el mismo deber que ya tenía con
|
|
1521
2072
|
* el espejo de permisos (sin él, en `facts` un rol recién definido no
|
|
1522
2073
|
* concedería nada y quitarle un permiso seguiría concediéndolo).
|
|
1523
2074
|
*
|
|
1524
|
-
*
|
|
1525
|
-
*
|
|
1526
|
-
*
|
|
1527
|
-
*
|
|
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.
|
|
1528
2083
|
*/
|
|
1529
|
-
async sweepRoleVisibility(roleUuid) {
|
|
1530
|
-
const
|
|
1531
|
-
// Un rol que el catálogo ya no declara no tiene visibilidad que decidir:
|
|
1532
|
-
// sus hechos son de `purgeRole` (que los borra enteros) y de
|
|
1533
|
-
// `authz:reconcile`, no de este barrido.
|
|
1534
|
-
const role = catalog.roleByUuid(roleUuid);
|
|
1535
|
-
if (!role)
|
|
1536
|
-
return;
|
|
1537
|
-
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 });
|
|
1538
2086
|
if (bindings.length === 0)
|
|
1539
2087
|
return;
|
|
1540
|
-
const
|
|
1541
|
-
|
|
1542
|
-
/** `scopeKey` → su cadena en el árbol del STORE, una vez por scope. */
|
|
1543
|
-
const chains = new Map();
|
|
1544
|
-
for (const binding of this.parseBindings(FACTS_BINDING_TYPE, bindings.map((t) => t.object))) {
|
|
1545
|
-
const key = scopeKey(binding.scope);
|
|
1546
|
-
let chainKeys = chains.get(key);
|
|
1547
|
-
if (!chainKeys) {
|
|
1548
|
-
// La cadena solo decide para un rol LOCAL: la visibilidad de un
|
|
1549
|
-
// global no depende del árbol, así que no se paga por leerlo.
|
|
1550
|
-
chainKeys = role.owner === GLOBAL_OWNER_KEY ? [key] : await this.storeChain(key);
|
|
1551
|
-
chains.set(key, chainKeys);
|
|
1552
|
-
}
|
|
1553
|
-
this.classifyBindingEdge(catalog, binding, chainKeys, writes, deletes);
|
|
1554
|
-
}
|
|
1555
|
-
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);
|
|
1556
2090
|
}
|
|
1557
2091
|
/**
|
|
1558
2092
|
* La arista `scope#binding` de un binding, a escribir o a borrar según la
|
|
@@ -1561,7 +2095,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1561
2095
|
* NIVEL de ese scope (3b-2g · R1) y ser global o tener a su owner en la
|
|
1562
2096
|
* cadena (3b-2e · E1). Visible ⇒ la arista se (re)escribe; no visible ⇒ se
|
|
1563
2097
|
* borra. El `assignee` no se toca: la asignación existe igual, lo que
|
|
1564
|
-
* 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.)
|
|
1565
2099
|
*/
|
|
1566
2100
|
classifyBindingEdge(catalog, binding, chainKeys, writes, deletes) {
|
|
1567
2101
|
const edge = factsScopeBindingTuple(scopeKey(binding.scope), binding.uuid);
|
|
@@ -1571,31 +2105,11 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1571
2105
|
deletes.push(edge);
|
|
1572
2106
|
}
|
|
1573
2107
|
/**
|
|
1574
|
-
* Aplica
|
|
1575
|
-
*
|
|
1576
|
-
* `Ignore` en las dos direcciones: el barrido dice el estado que DEBE
|
|
1577
|
-
* quedar, no el delta — borrar lo que ya no está y reescribir lo que ya
|
|
1578
|
-
* 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`.
|
|
1579
2110
|
*/
|
|
1580
|
-
async applyBindingSweep(
|
|
1581
|
-
|
|
1582
|
-
return;
|
|
1583
|
-
const operations = [
|
|
1584
|
-
...deletes.map((tuple) => ({ delete: tuple })),
|
|
1585
|
-
...writes.map((tuple) => ({ write: tuple })),
|
|
1586
|
-
];
|
|
1587
|
-
for (let i = 0; i < operations.length; i += PURGE_BATCH_SIZE) {
|
|
1588
|
-
const chunk = operations.slice(i, i + PURGE_BATCH_SIZE);
|
|
1589
|
-
await this.client.write({
|
|
1590
|
-
writes: chunk.filter((o) => o.write).map((o) => o.write),
|
|
1591
|
-
deletes: chunk.filter((o) => o.delete).map((o) => o.delete),
|
|
1592
|
-
}, {
|
|
1593
|
-
conflict: {
|
|
1594
|
-
onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore,
|
|
1595
|
-
onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore,
|
|
1596
|
-
},
|
|
1597
|
-
});
|
|
1598
|
-
}
|
|
2111
|
+
async applyBindingSweep(deletes, progress = { writes: 0 }) {
|
|
2112
|
+
await this.writeBindingEdges([], deletes, progress);
|
|
1599
2113
|
}
|
|
1600
2114
|
/**
|
|
1601
2115
|
* `[key, ...ancestros]` según el ÁRBOL DEL STORE (3b-2e · E1), subiendo por
|
|
@@ -1603,14 +2117,15 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1603
2117
|
* tiene que gobernar el barrido; el resolutor del consumidor no participa.
|
|
1604
2118
|
* Un nodo con más de un padre es deriva y se dice (`assertOneParent`), y el
|
|
1605
2119
|
* recorrido está acotado por el mismo tope de páginas que las
|
|
1606
|
-
* 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).
|
|
1607
2122
|
*/
|
|
1608
2123
|
async storeChain(key) {
|
|
1609
2124
|
const chain = [key];
|
|
1610
2125
|
const seen = new Set([key]);
|
|
1611
2126
|
for (let hop = 0; hop < MAX_SCOPE_CHAIN_HOPS; hop++) {
|
|
1612
2127
|
const object = factsScopeObject(chain[chain.length - 1]);
|
|
1613
|
-
const parents = await this.
|
|
2128
|
+
const parents = await this.readForWrite({ object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
|
|
1614
2129
|
this.assertOneParent(object, parents);
|
|
1615
2130
|
if (parents.length === 0)
|
|
1616
2131
|
return chain;
|
|
@@ -1641,6 +2156,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1641
2156
|
if (purged.type === APP_SCOPE_TYPE) {
|
|
1642
2157
|
throw new InvalidIdentityError('purgeScope: la raíz `app` no se purga');
|
|
1643
2158
|
}
|
|
2159
|
+
await this.assertModel('purgeScope');
|
|
1644
2160
|
// La identidad canónica si el árbol aún lo conoce (K1); tal cual si ya no.
|
|
1645
2161
|
const scope = await this.canonicalOrSelf(purged, 'purgeScope');
|
|
1646
2162
|
const key = scopeKey(scope);
|
|
@@ -1657,7 +2173,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1657
2173
|
const objects = [...roles.map((r) => `role_binding:${key}|${r.uuid}`), scopeObject];
|
|
1658
2174
|
/** Lo purgable de un objeto: todo, salvo la arista `parent` del scope. */
|
|
1659
2175
|
const purgeable = async (object) => {
|
|
1660
|
-
const keys = await this.
|
|
2176
|
+
const keys = await this.readForWrite({ object }, { includeExpired: true });
|
|
1661
2177
|
return object === scopeObject ? keys.filter((k) => k.relation !== FACTS_PARENT_RELATION) : keys;
|
|
1662
2178
|
};
|
|
1663
2179
|
/**
|
|
@@ -1810,7 +2326,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1810
2326
|
*/
|
|
1811
2327
|
async ensureFactsRoot() {
|
|
1812
2328
|
const wanted = factsRootTuples(this.holderTypes);
|
|
1813
|
-
const current = await this.
|
|
2329
|
+
const current = await this.readForWrite({ object: factsScopeObject(APP_SCOPE_TYPE), relation: FACTS_ROOTED_RELATION }, { includeExpired: true });
|
|
1814
2330
|
const have = new Set(current.map((tuple) => tuple.user));
|
|
1815
2331
|
const missing = wanted.filter((tuple) => !have.has(tuple.user));
|
|
1816
2332
|
if (missing.length)
|
|
@@ -1827,6 +2343,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1827
2343
|
* catálogo escribe CERO tuplas.
|
|
1828
2344
|
*/
|
|
1829
2345
|
async projectCatalog(snapshot) {
|
|
2346
|
+
await this.assertModel('catalogProjection.project');
|
|
1830
2347
|
await this.ensureFactsRoot();
|
|
1831
2348
|
const wanted = new Map();
|
|
1832
2349
|
for (const tuple of factsCatalogTuples(snapshot.roles, this.holderTypes)) {
|
|
@@ -1844,7 +2361,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1844
2361
|
// otro user sobre `role:` no es de esta proyección y no se toca.
|
|
1845
2362
|
const current = new Map();
|
|
1846
2363
|
for (const holderType of Object.values(this.holderTypes)) {
|
|
1847
|
-
for (const key of await this.
|
|
2364
|
+
for (const key of await this.readForWrite({ user: `${holderType}:*`, object: `${FACTS_ROLE_TYPE}:` }, { includeExpired: true })) {
|
|
1848
2365
|
// Solo la familia del catálogo: si el modelo crece con otras
|
|
1849
2366
|
// relaciones sobre `role`, no son de esta proyección.
|
|
1850
2367
|
if (!key.relation.startsWith(FACTS_PERMITS_PREFIX))
|
|
@@ -1891,14 +2408,24 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1891
2408
|
*/
|
|
1892
2409
|
async purgeRole(roleUuid) {
|
|
1893
2410
|
assertCatalogUuid('rol', roleUuid);
|
|
2411
|
+
await this.assertModel('purgeRole');
|
|
1894
2412
|
const existing = await this.sql('purgeRole.role', () => db.from('authz_roles').where('uuid', roleUuid).select('uuid'));
|
|
1895
2413
|
if (!existing.length)
|
|
1896
2414
|
throw new UnknownRoleError(roleUuid);
|
|
1897
2415
|
const roleObject = `${FACTS_ROLE_TYPE}:${roleUuid}`;
|
|
1898
|
-
/**
|
|
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();
|
|
1899
2424
|
const bindingsOf = async () => {
|
|
1900
|
-
const edges = await this.
|
|
1901
|
-
|
|
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];
|
|
1902
2429
|
};
|
|
1903
2430
|
/** El scope del que cuelga un binding, según su propio id (`<scopeKey>|<roleUuid>`). */
|
|
1904
2431
|
const scopeOf = (binding) => {
|
|
@@ -1906,37 +2433,63 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1906
2433
|
const separator = id.lastIndexOf('|');
|
|
1907
2434
|
return separator > 0 ? id.slice(0, separator) : null;
|
|
1908
2435
|
};
|
|
1909
|
-
/**
|
|
2436
|
+
/** Los HECHOS purgables del rol: sus bindings enteros y las aristas del scope (sin la proyección: ver `projection`). */
|
|
1910
2437
|
const purgeable = async () => {
|
|
1911
2438
|
const keys = [];
|
|
1912
2439
|
for (const binding of await bindingsOf()) {
|
|
1913
|
-
keys.push(...(await this.
|
|
2440
|
+
keys.push(...(await this.readForWrite({ object: binding }, { includeExpired: true })));
|
|
1914
2441
|
const key = scopeOf(binding);
|
|
1915
2442
|
if (!key)
|
|
1916
2443
|
continue;
|
|
1917
2444
|
// La arista vive en el objeto `scope`, así que se lee de ahí (y solo
|
|
1918
2445
|
// la que apunta a ESTE binding: el scope tiene las de otros roles).
|
|
1919
|
-
const scopeEdges = await this.
|
|
2446
|
+
const scopeEdges = await this.readForWrite({ user: binding, relation: FACTS_BINDING_RELATION, object: factsScopeObject(key) }, { includeExpired: true });
|
|
1920
2447
|
keys.push(...scopeEdges);
|
|
1921
2448
|
}
|
|
1922
|
-
|
|
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 = [];
|
|
1923
2454
|
for (const holderType of Object.values(this.holderTypes)) {
|
|
1924
|
-
keys.push(...(await this.
|
|
2455
|
+
keys.push(...(await this.readForWrite({ user: `${holderType}:*`, object: roleObject }, { includeExpired: true })));
|
|
1925
2456
|
}
|
|
1926
2457
|
return keys;
|
|
1927
2458
|
};
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
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_*');
|
|
1940
2493
|
try {
|
|
1941
2494
|
await this.sql('purgeRole', () => withAuthzCatalogWrite(async (trx) => {
|
|
1942
2495
|
// `authz_assignments` es del driver `database` y aquí está vacía,
|
|
@@ -1952,6 +2505,25 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1952
2505
|
finally {
|
|
1953
2506
|
this.catalog.invalidate();
|
|
1954
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
|
+
}
|
|
1955
2527
|
}
|
|
1956
2528
|
/**
|
|
1957
2529
|
* Cuántos hechos VIGENTES tiene cada rol, en todos los scopes (3b-2j).
|
|
@@ -1985,12 +2557,15 @@ export class OpenFgaAuthorizationDriver {
|
|
|
1985
2557
|
async countRoleAssignments(roleUuids) {
|
|
1986
2558
|
for (const uuid of roleUuids)
|
|
1987
2559
|
assertCatalogUuid('rol', uuid);
|
|
2560
|
+
await this.assertModel('countRoleAssignments');
|
|
1988
2561
|
const counts = [];
|
|
1989
2562
|
for (const roleUuid of roleUuids) {
|
|
1990
2563
|
const edges = await this.readAllTuples({ user: `${FACTS_ROLE_TYPE}:${roleUuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
|
|
1991
2564
|
let total = 0;
|
|
1992
2565
|
for (const binding of new Set(edges.map((edge) => edge.object))) {
|
|
1993
|
-
|
|
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;
|
|
1994
2569
|
}
|
|
1995
2570
|
counts.push(total);
|
|
1996
2571
|
}
|
|
@@ -2015,8 +2590,25 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2015
2590
|
*/
|
|
2016
2591
|
async projectCatalogRole(roleUuid) {
|
|
2017
2592
|
assertCatalogUuid('rol', roleUuid);
|
|
2018
|
-
await this.
|
|
2019
|
-
|
|
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]);
|
|
2020
2612
|
const permissions = [...catalog.rolePermissionsOf(roleUuid)].sort();
|
|
2021
2613
|
const object = `${FACTS_ROLE_TYPE}:${roleUuid}`;
|
|
2022
2614
|
const wanted = new Map();
|
|
@@ -2025,7 +2617,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2025
2617
|
}
|
|
2026
2618
|
const current = new Map();
|
|
2027
2619
|
for (const holderType of Object.values(this.holderTypes)) {
|
|
2028
|
-
for (const key of await this.
|
|
2620
|
+
for (const key of await this.readForWrite({ user: `${holderType}:*`, object }, { includeExpired: true })) {
|
|
2029
2621
|
if (!key.relation.startsWith(FACTS_PERMITS_PREFIX))
|
|
2030
2622
|
continue;
|
|
2031
2623
|
current.set(factsTupleId(key), key);
|
|
@@ -2085,7 +2677,9 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2085
2677
|
const prune = options.prune === true;
|
|
2086
2678
|
const batchSize = reconcileBatchSize(options.batchSize);
|
|
2087
2679
|
const maxTuples = reconcileMaxTuples(options.maxTuples);
|
|
2088
|
-
|
|
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();
|
|
2089
2683
|
const now = this.now();
|
|
2090
2684
|
/** Lo que el destino DEBE tener, por id de tupla. */
|
|
2091
2685
|
const wanted = new Map();
|
|
@@ -2236,6 +2830,8 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2236
2830
|
*/
|
|
2237
2831
|
const foreignTypes = new Map();
|
|
2238
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 = [];
|
|
2239
2835
|
for (const tuple of current) {
|
|
2240
2836
|
const id = factsTupleId(tuple);
|
|
2241
2837
|
const family = familyOfTuple(tuple);
|
|
@@ -2245,6 +2841,25 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2245
2841
|
foreignTypes.set(type, (foreignTypes.get(type) ?? 0) + 1);
|
|
2246
2842
|
continue;
|
|
2247
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
|
+
}
|
|
2248
2863
|
if (tuple.relation === FACTS_ASSIGNEE_RELATION)
|
|
2249
2864
|
storeBindings.add(tuple.object);
|
|
2250
2865
|
if (tuple.relation === FACTS_PARENT_RELATION && tuple.object.startsWith(`${FACTS_SCOPE_TYPE}:`)) {
|
|
@@ -2303,6 +2918,13 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2303
2918
|
// que devuelve N filas y las N se descartan (caducadas, scopes que ya no
|
|
2304
2919
|
// resuelven) sigue sin respaldar NADA de lo que --prune va a borrar.
|
|
2305
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
|
+
}
|
|
2306
2928
|
if (massDelete && !dryRun && options.allowMassDelete !== true) {
|
|
2307
2929
|
throw new MassReconcileRefusedError(`authz:reconcile --to=openfga --prune borraría ${factsDeleted} tupla(s) de hechos del store y el ` +
|
|
2308
2930
|
`ORIGEN no ha aportado NI UN hecho utilizable (${facts.rows} leídos, todos descartados — mira ` +
|
|
@@ -2313,10 +2935,27 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2313
2935
|
`--allow-mass-delete.`);
|
|
2314
2936
|
}
|
|
2315
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
|
+
}
|
|
2316
2954
|
// Los DELETES primero: si la pasada muere entre medias, lo que queda es
|
|
2317
2955
|
// de MENOS (fail-closed), nunca una tupla vieja conviviendo con la nueva.
|
|
2318
2956
|
await this.applyReconcileWrites(deletes, []);
|
|
2319
2957
|
await this.applyReconcileWrites([], writes);
|
|
2958
|
+
await this.settleRoleVisibility(settlePairs.reverse(), { writes: 0 });
|
|
2320
2959
|
}
|
|
2321
2960
|
const totals = sumPhases(phases);
|
|
2322
2961
|
return {
|
|
@@ -2350,18 +2989,9 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2350
2989
|
* el modelo que sí sirve (fail-closed sobre un permiso vivo).
|
|
2351
2990
|
*/
|
|
2352
2991
|
async modelPermissions() {
|
|
2353
|
-
//
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
? await this.client.readAuthorizationModel()
|
|
2357
|
-
: await this.client.readLatestAuthorizationModel();
|
|
2358
|
-
const model = response?.authorization_model;
|
|
2359
|
-
if (!model?.type_definitions)
|
|
2360
|
-
return null;
|
|
2361
|
-
const role = model.type_definitions.find((definition) => definition.type === FACTS_ROLE_TYPE);
|
|
2362
|
-
if (!role?.relations)
|
|
2363
|
-
return null;
|
|
2364
|
-
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}`));
|
|
2365
2995
|
}
|
|
2366
2996
|
/**
|
|
2367
2997
|
* El árbol del ORIGEN, paginado (`scopes.enumerateEdges`), con los ciclos
|
|
@@ -2645,6 +3275,12 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2645
3275
|
ctx.discard('deny', 'unknown-permission', fact.detail);
|
|
2646
3276
|
continue;
|
|
2647
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
|
+
}
|
|
2648
3284
|
if (this.wantDeny(ctx, {
|
|
2649
3285
|
holder: fact.holder,
|
|
2650
3286
|
scope: fact.scope,
|
|
@@ -2788,6 +3424,9 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2788
3424
|
if (!Number.isFinite(limit) || limit < 1) {
|
|
2789
3425
|
throw new AuthorizationConfigError(`enumerateFacts: limit debe ser un entero >= 1 (llegó ${String(page.limit)})`);
|
|
2790
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');
|
|
2791
3430
|
const catalog = await this.catalog.view();
|
|
2792
3431
|
const permissionByRelation = new Map();
|
|
2793
3432
|
for (const slug of catalog.permissionSlugs) {
|
|
@@ -2832,12 +3471,19 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2832
3471
|
skipped.push({ kind: 'tuple', reason: 'unknown-holder-type', detail: id });
|
|
2833
3472
|
continue;
|
|
2834
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
|
+
}
|
|
2835
3481
|
facts.push({
|
|
2836
3482
|
kind: 'assignment',
|
|
2837
3483
|
holder,
|
|
2838
3484
|
scope: binding.scope,
|
|
2839
3485
|
roleUuid: binding.uuid,
|
|
2840
|
-
expiresAt:
|
|
3486
|
+
expiresAt: condition.validUntil,
|
|
2841
3487
|
detail: id,
|
|
2842
3488
|
});
|
|
2843
3489
|
continue;
|
|
@@ -2860,7 +3506,18 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2860
3506
|
skipped.push({ kind: 'tuple', reason: 'unknown-holder-type', detail: id });
|
|
2861
3507
|
continue;
|
|
2862
3508
|
}
|
|
2863
|
-
|
|
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
|
+
});
|
|
2864
3521
|
continue;
|
|
2865
3522
|
}
|
|
2866
3523
|
// **El deny del modo `resolver` de 2.2 ES un hecho y se emite** (3b-8 ·
|
|
@@ -2919,7 +3576,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2919
3576
|
const seenTokens = new Set();
|
|
2920
3577
|
let pages = 0;
|
|
2921
3578
|
do {
|
|
2922
|
-
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 });
|
|
2923
3580
|
pages += 1;
|
|
2924
3581
|
for (const tuple of this.tuplesOrThrow(response, 'reconcile.readEveryTuple')) {
|
|
2925
3582
|
const key = tuple?.key;
|
|
@@ -2929,12 +3586,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
2929
3586
|
`no se puede comparar (total: ${this.diagnostics.unparseableBindings})`);
|
|
2930
3587
|
continue;
|
|
2931
3588
|
}
|
|
2932
|
-
out.push({
|
|
2933
|
-
user: key.user,
|
|
2934
|
-
relation: key.relation,
|
|
2935
|
-
object: key.object,
|
|
2936
|
-
validUntil: toExpiryDate(key.condition?.context?.valid_until),
|
|
2937
|
-
});
|
|
3589
|
+
out.push({ user: key.user, relation: key.relation, object: key.object, ...storedCondition(key.condition) });
|
|
2938
3590
|
if (out.length > maxTuples) {
|
|
2939
3591
|
throw new ReconcileTooLargeError(`authz:reconcile --to=openfga: el store pasa de maxTuples (${maxTuples}) tuplas y la pasada ` +
|
|
2940
3592
|
`necesita la foto ENTERA del destino para saber qué sobra (que es la mitad del trabajo: ` +
|
|
@@ -3004,6 +3656,14 @@ export class OpenFgaAuthorizationDriver {
|
|
|
3004
3656
|
}
|
|
3005
3657
|
return tuples;
|
|
3006
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
|
+
}
|
|
3007
3667
|
async readAllTuples(filter, options = {}) {
|
|
3008
3668
|
// El instante con el que se filtra: el de la operación si lo trae (K9), o
|
|
3009
3669
|
// el de esta lectura.
|
|
@@ -3016,7 +3676,7 @@ export class OpenFgaAuthorizationDriver {
|
|
|
3016
3676
|
const response = await this.client.read(filter, {
|
|
3017
3677
|
pageSize: READ_PAGE_SIZE,
|
|
3018
3678
|
continuationToken,
|
|
3019
|
-
consistency: this.consistency,
|
|
3679
|
+
consistency: options.consistency ?? this.consistency,
|
|
3020
3680
|
});
|
|
3021
3681
|
pages += 1;
|
|
3022
3682
|
for (const tuple of this.tuplesOrThrow(response, `Read ${JSON.stringify(filter)}`)) {
|
|
@@ -3029,8 +3689,22 @@ export class OpenFgaAuthorizationDriver {
|
|
|
3029
3689
|
continue;
|
|
3030
3690
|
}
|
|
3031
3691
|
if (!options.includeExpired) {
|
|
3032
|
-
const
|
|
3033
|
-
|
|
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)
|
|
3034
3708
|
continue;
|
|
3035
3709
|
}
|
|
3036
3710
|
keys.push({ user: k.user, relation: k.relation, object: k.object });
|
|
@@ -3154,6 +3828,16 @@ function foreignReport(tuples, types, relationTypes) {
|
|
|
3154
3828
|
relations += count;
|
|
3155
3829
|
return { tuples, types: byType, relations, unknown: tuples - relations };
|
|
3156
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
|
+
}
|
|
3157
3841
|
/** La clave sola: un `delete` de FGA no lleva condición. */
|
|
3158
3842
|
function bareTuple(tuple) {
|
|
3159
3843
|
return { user: tuple.user, relation: tuple.relation, object: tuple.object };
|
|
@@ -3195,4 +3879,3 @@ function walkUpTo(parentOf, from, target) {
|
|
|
3195
3879
|
}
|
|
3196
3880
|
return null;
|
|
3197
3881
|
}
|
|
3198
|
-
//# sourceMappingURL=openfga_driver.js.map
|