@jantstack/adonis-authz 2.5.0-alpha.3 → 2.5.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +437 -48
- package/build/commands/authz_catalog_diff.d.ts +0 -1
- package/build/commands/authz_catalog_diff.js +0 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts +17 -3
- package/build/commands/authz_catalog_prune_orphans.js +28 -6
- package/build/commands/authz_catalog_sync.d.ts +0 -1
- package/build/commands/authz_catalog_sync.js +0 -1
- package/build/commands/authz_freeze.d.ts +0 -1
- package/build/commands/authz_freeze.js +0 -1
- package/build/commands/authz_reconcile.d.ts +0 -1
- package/build/commands/authz_reconcile.js +0 -1
- package/build/commands/authz_relations_reconcile.d.ts +0 -1
- package/build/commands/authz_relations_reconcile.js +56 -8
- package/build/commands/authz_scopes_relay.d.ts +0 -1
- package/build/commands/authz_scopes_relay.js +54 -6
- package/build/commands/authz_scopes_requeue.d.ts +0 -1
- package/build/commands/authz_scopes_requeue.js +16 -3
- package/build/commands/authz_unfreeze.d.ts +0 -1
- package/build/commands/authz_unfreeze.js +0 -1
- package/build/commands/main.d.ts +0 -1
- package/build/commands/main.js +0 -1
- package/build/commands/openfga_provision.d.ts +10 -1
- package/build/commands/openfga_provision.js +43 -3
- package/build/configure.d.ts +0 -1
- package/build/configure.js +0 -1
- package/build/index.d.ts +9 -7
- package/build/index.js +10 -3
- package/build/providers/authz_provider.d.ts +0 -1
- package/build/providers/authz_provider.js +0 -1
- package/build/services/main.d.ts +0 -1
- package/build/services/main.js +0 -1
- package/build/services/relations.d.ts +0 -1
- package/build/services/relations.js +0 -1
- package/build/src/catalog/catalog.d.ts +0 -1
- package/build/src/catalog/catalog.js +0 -1
- package/build/src/catalog/catalog_cache.d.ts +13 -1
- package/build/src/catalog/catalog_cache.js +27 -2
- package/build/src/clock.d.ts +0 -1
- package/build/src/clock.js +0 -1
- package/build/src/define_config.d.ts +0 -1
- package/build/src/define_config.js +24 -1
- package/build/src/drivers/database_driver.d.ts +60 -3
- package/build/src/drivers/database_driver.js +160 -25
- package/build/src/drivers/database_relations_driver.d.ts +11 -5
- package/build/src/drivers/database_relations_driver.js +115 -33
- package/build/src/drivers/openfga_driver.d.ts +159 -26
- package/build/src/drivers/openfga_driver.js +882 -199
- package/build/src/drivers/openfga_facts.d.ts +91 -1
- package/build/src/drivers/openfga_facts.js +277 -3
- package/build/src/drivers/openfga_relations_driver.d.ts +41 -13
- package/build/src/drivers/openfga_relations_driver.js +422 -228
- package/build/src/errors.d.ts +160 -1
- package/build/src/errors.js +161 -1
- package/build/src/expiry.d.ts +51 -2
- package/build/src/expiry.js +96 -2
- package/build/src/freeze.d.ts +0 -1
- package/build/src/freeze.js +0 -1
- package/build/src/hierarchical_resolver.d.ts +0 -1
- package/build/src/hierarchical_resolver.js +0 -1
- package/build/src/http/app_access_middleware.d.ts +0 -1
- package/build/src/http/app_access_middleware.js +0 -1
- package/build/src/http/resource_access_middleware.d.ts +0 -1
- package/build/src/http/resource_access_middleware.js +0 -1
- package/build/src/identity.d.ts +9 -2
- package/build/src/identity.js +33 -1
- package/build/src/manager.d.ts +24 -1
- package/build/src/manager.js +381 -85
- package/build/src/memoize_ancestors.d.ts +0 -1
- package/build/src/memoize_ancestors.js +0 -1
- package/build/src/models/authz_assignment.d.ts +5 -6
- package/build/src/models/authz_assignment.js +0 -1
- package/build/src/models/authz_deny.d.ts +5 -6
- package/build/src/models/authz_deny.js +0 -1
- package/build/src/models/authz_permission.d.ts +5 -6
- package/build/src/models/authz_permission.js +0 -1
- package/build/src/models/authz_role.d.ts +5 -6
- package/build/src/models/authz_role.js +0 -1
- package/build/src/models/authz_role_permission.d.ts +5 -6
- package/build/src/models/authz_role_permission.js +0 -1
- package/build/src/openfga.d.ts +2 -3
- package/build/src/openfga.js +4 -2
- package/build/src/reconcile.d.ts +0 -1
- package/build/src/reconcile.js +0 -1
- package/build/src/relation_partition_trigger.d.ts +0 -1
- package/build/src/relation_partition_trigger.js +0 -1
- package/build/src/relations/check_many.d.ts +0 -1
- package/build/src/relations/check_many.js +0 -1
- package/build/src/relations/define_relations_config.d.ts +25 -3
- package/build/src/relations/define_relations_config.js +8 -3
- package/build/src/relations/deny_reach.d.ts +102 -0
- package/build/src/relations/deny_reach.js +230 -0
- package/build/src/relations/group_depth.d.ts +215 -0
- package/build/src/relations/group_depth.js +453 -0
- package/build/src/relations/manager.d.ts +14 -7
- package/build/src/relations/manager.js +60 -15
- package/build/src/relations/page.d.ts +19 -0
- package/build/src/relations/page.js +74 -0
- package/build/src/relations/reconcile.d.ts +58 -4
- package/build/src/relations/reconcile.js +266 -12
- package/build/src/relations/tuple_key.d.ts +0 -1
- package/build/src/relations/tuple_key.js +0 -1
- package/build/src/relations/tuple_order.d.ts +5 -4
- package/build/src/relations/tuple_order.js +5 -4
- package/build/src/relations_config_store.d.ts +57 -2
- package/build/src/relations_config_store.js +155 -5
- package/build/src/relations_reconcile.d.ts +0 -1
- package/build/src/relations_reconcile.js +0 -1
- package/build/src/scope_outbox.d.ts +36 -3
- package/build/src/scope_outbox.js +78 -18
- package/build/src/shared/backend_guard.d.ts +52 -1
- package/build/src/shared/backend_guard.js +164 -2
- package/build/src/shared/sql_expiry.d.ts +0 -1
- package/build/src/shared/sql_expiry.js +0 -1
- package/build/src/shared/transaction_guard.d.ts +0 -1
- package/build/src/shared/transaction_guard.js +0 -1
- package/build/src/sql_descendants.d.ts +0 -1
- package/build/src/sql_descendants.js +0 -1
- package/build/src/testing/contract.d.ts +26 -3
- package/build/src/testing/contract.js +258 -20
- package/build/src/testing/main.d.ts +0 -1
- package/build/src/testing/main.js +0 -1
- package/build/src/testing/migration_contract.d.ts +0 -1
- package/build/src/testing/migration_contract.js +0 -1
- package/build/src/testing/relations_contract.d.ts +7 -1
- package/build/src/testing/relations_contract.js +174 -7
- package/build/src/testing/relations_reconcile_contract.d.ts +0 -1
- package/build/src/testing/relations_reconcile_contract.js +0 -1
- package/build/src/testing/scope_outbox_contract.d.ts +0 -1
- package/build/src/testing/scope_outbox_contract.js +0 -1
- package/build/src/testing/scope_tree.d.ts +0 -1
- package/build/src/testing/scope_tree.js +0 -1
- package/build/src/testing/test_clock.d.ts +0 -1
- package/build/src/testing/test_clock.js +0 -1
- package/build/src/traits/authz_scopes.d.ts +0 -1
- package/build/src/traits/authz_scopes.js +0 -1
- package/build/src/traits/has_uuid.d.ts +5 -6
- package/build/src/traits/has_uuid.js +0 -1
- package/build/src/types.d.ts +138 -14
- package/build/src/types.js +6 -1
- package/build/stubs/config/authorization.stub +22 -6
- package/build/stubs/main.d.ts +0 -1
- package/build/stubs/main.js +0 -1
- package/package.json +4 -3
- package/build/commands/authz_catalog_diff.d.ts.map +0 -1
- package/build/commands/authz_catalog_diff.js.map +0 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts.map +0 -1
- package/build/commands/authz_catalog_prune_orphans.js.map +0 -1
- package/build/commands/authz_catalog_sync.d.ts.map +0 -1
- package/build/commands/authz_catalog_sync.js.map +0 -1
- package/build/commands/authz_freeze.d.ts.map +0 -1
- package/build/commands/authz_freeze.js.map +0 -1
- package/build/commands/authz_reconcile.d.ts.map +0 -1
- package/build/commands/authz_reconcile.js.map +0 -1
- package/build/commands/authz_relations_reconcile.d.ts.map +0 -1
- package/build/commands/authz_relations_reconcile.js.map +0 -1
- package/build/commands/authz_scopes_relay.d.ts.map +0 -1
- package/build/commands/authz_scopes_relay.js.map +0 -1
- package/build/commands/authz_scopes_requeue.d.ts.map +0 -1
- package/build/commands/authz_scopes_requeue.js.map +0 -1
- package/build/commands/authz_unfreeze.d.ts.map +0 -1
- package/build/commands/authz_unfreeze.js.map +0 -1
- package/build/commands/main.d.ts.map +0 -1
- package/build/commands/main.js.map +0 -1
- package/build/commands/openfga_provision.d.ts.map +0 -1
- package/build/commands/openfga_provision.js.map +0 -1
- package/build/configure.d.ts.map +0 -1
- package/build/configure.js.map +0 -1
- package/build/index.d.ts.map +0 -1
- package/build/index.js.map +0 -1
- package/build/providers/authz_provider.d.ts.map +0 -1
- package/build/providers/authz_provider.js.map +0 -1
- package/build/services/main.d.ts.map +0 -1
- package/build/services/main.js.map +0 -1
- package/build/services/relations.d.ts.map +0 -1
- package/build/services/relations.js.map +0 -1
- package/build/src/catalog/catalog.d.ts.map +0 -1
- package/build/src/catalog/catalog.js.map +0 -1
- package/build/src/catalog/catalog_cache.d.ts.map +0 -1
- package/build/src/catalog/catalog_cache.js.map +0 -1
- package/build/src/clock.d.ts.map +0 -1
- package/build/src/clock.js.map +0 -1
- package/build/src/define_config.d.ts.map +0 -1
- package/build/src/define_config.js.map +0 -1
- package/build/src/drivers/database_driver.d.ts.map +0 -1
- package/build/src/drivers/database_driver.js.map +0 -1
- package/build/src/drivers/database_relations_driver.d.ts.map +0 -1
- package/build/src/drivers/database_relations_driver.js.map +0 -1
- package/build/src/drivers/openfga_driver.d.ts.map +0 -1
- package/build/src/drivers/openfga_driver.js.map +0 -1
- package/build/src/drivers/openfga_facts.d.ts.map +0 -1
- package/build/src/drivers/openfga_facts.js.map +0 -1
- package/build/src/drivers/openfga_relations_driver.d.ts.map +0 -1
- package/build/src/drivers/openfga_relations_driver.js.map +0 -1
- package/build/src/errors.d.ts.map +0 -1
- package/build/src/errors.js.map +0 -1
- package/build/src/expiry.d.ts.map +0 -1
- package/build/src/expiry.js.map +0 -1
- package/build/src/freeze.d.ts.map +0 -1
- package/build/src/freeze.js.map +0 -1
- package/build/src/hierarchical_resolver.d.ts.map +0 -1
- package/build/src/hierarchical_resolver.js.map +0 -1
- package/build/src/http/app_access_middleware.d.ts.map +0 -1
- package/build/src/http/app_access_middleware.js.map +0 -1
- package/build/src/http/resource_access_middleware.d.ts.map +0 -1
- package/build/src/http/resource_access_middleware.js.map +0 -1
- package/build/src/identity.d.ts.map +0 -1
- package/build/src/identity.js.map +0 -1
- package/build/src/manager.d.ts.map +0 -1
- package/build/src/manager.js.map +0 -1
- package/build/src/memoize_ancestors.d.ts.map +0 -1
- package/build/src/memoize_ancestors.js.map +0 -1
- package/build/src/models/authz_assignment.d.ts.map +0 -1
- package/build/src/models/authz_assignment.js.map +0 -1
- package/build/src/models/authz_deny.d.ts.map +0 -1
- package/build/src/models/authz_deny.js.map +0 -1
- package/build/src/models/authz_permission.d.ts.map +0 -1
- package/build/src/models/authz_permission.js.map +0 -1
- package/build/src/models/authz_role.d.ts.map +0 -1
- package/build/src/models/authz_role.js.map +0 -1
- package/build/src/models/authz_role_permission.d.ts.map +0 -1
- package/build/src/models/authz_role_permission.js.map +0 -1
- package/build/src/openfga.d.ts.map +0 -1
- package/build/src/openfga.js.map +0 -1
- package/build/src/reconcile.d.ts.map +0 -1
- package/build/src/reconcile.js.map +0 -1
- package/build/src/relation_partition_trigger.d.ts.map +0 -1
- package/build/src/relation_partition_trigger.js.map +0 -1
- package/build/src/relations/check_many.d.ts.map +0 -1
- package/build/src/relations/check_many.js.map +0 -1
- package/build/src/relations/define_relations_config.d.ts.map +0 -1
- package/build/src/relations/define_relations_config.js.map +0 -1
- package/build/src/relations/manager.d.ts.map +0 -1
- package/build/src/relations/manager.js.map +0 -1
- package/build/src/relations/reconcile.d.ts.map +0 -1
- package/build/src/relations/reconcile.js.map +0 -1
- package/build/src/relations/tuple_key.d.ts.map +0 -1
- package/build/src/relations/tuple_key.js.map +0 -1
- package/build/src/relations/tuple_order.d.ts.map +0 -1
- package/build/src/relations/tuple_order.js.map +0 -1
- package/build/src/relations_config_store.d.ts.map +0 -1
- package/build/src/relations_config_store.js.map +0 -1
- package/build/src/relations_reconcile.d.ts.map +0 -1
- package/build/src/relations_reconcile.js.map +0 -1
- package/build/src/scope_outbox.d.ts.map +0 -1
- package/build/src/scope_outbox.js.map +0 -1
- package/build/src/shared/backend_guard.d.ts.map +0 -1
- package/build/src/shared/backend_guard.js.map +0 -1
- package/build/src/shared/sql_expiry.d.ts.map +0 -1
- package/build/src/shared/sql_expiry.js.map +0 -1
- package/build/src/shared/transaction_guard.d.ts.map +0 -1
- package/build/src/shared/transaction_guard.js.map +0 -1
- package/build/src/sql_descendants.d.ts.map +0 -1
- package/build/src/sql_descendants.js.map +0 -1
- package/build/src/testing/contract.d.ts.map +0 -1
- package/build/src/testing/contract.js.map +0 -1
- package/build/src/testing/main.d.ts.map +0 -1
- package/build/src/testing/main.js.map +0 -1
- package/build/src/testing/migration_contract.d.ts.map +0 -1
- package/build/src/testing/migration_contract.js.map +0 -1
- package/build/src/testing/relations_contract.d.ts.map +0 -1
- package/build/src/testing/relations_contract.js.map +0 -1
- package/build/src/testing/relations_reconcile_contract.d.ts.map +0 -1
- package/build/src/testing/relations_reconcile_contract.js.map +0 -1
- package/build/src/testing/scope_outbox_contract.d.ts.map +0 -1
- package/build/src/testing/scope_outbox_contract.js.map +0 -1
- package/build/src/testing/scope_tree.d.ts.map +0 -1
- package/build/src/testing/scope_tree.js.map +0 -1
- package/build/src/testing/test_clock.d.ts.map +0 -1
- package/build/src/testing/test_clock.js.map +0 -1
- package/build/src/traits/authz_scopes.d.ts.map +0 -1
- package/build/src/traits/authz_scopes.js.map +0 -1
- package/build/src/traits/has_uuid.d.ts.map +0 -1
- package/build/src/traits/has_uuid.js.map +0 -1
- package/build/src/types.d.ts.map +0 -1
- package/build/src/types.js.map +0 -1
- package/build/stubs/main.d.ts.map +0 -1
- package/build/stubs/main.js.map +0 -1
|
@@ -29,28 +29,32 @@
|
|
|
29
29
|
* Pureza: vive detrás del subpath `@jantstack/adonis-authz/openfga`
|
|
30
30
|
* (`src/openfga.ts`), como el driver `facts` de roles: es lo único que puede
|
|
31
31
|
* tocar `@openfga/sdk` (peer opcional). La abstracción no filtra: ningún error
|
|
32
|
-
* del SDK escapa (todo va por `#guard`, que clasifica
|
|
32
|
+
* del SDK escapa (todo va por `#guard`, que clasifica con `classifyFgaFailure`, R3).
|
|
33
33
|
*/
|
|
34
34
|
import { OpenFgaClient, ConsistencyPreference, ClientWriteRequestOnMissingDeletes, ClientWriteRequestOnDuplicateWrites, } from '@openfga/sdk';
|
|
35
|
-
import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, InvalidIdentityError, PurgeIncompleteError, RenewalRacedError, RenewalUnsafeError, UnsupportedOperationError, markPartialWrite, } from '../errors.js';
|
|
35
|
+
import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, InvalidIdentityError, PurgeIncompleteError, RenewalRacedError, RenewalUnsafeError, ResolutionLimitError, UnsupportedOperationError, markDeploymentFailure, markPartialWrite, } from '../errors.js';
|
|
36
36
|
import { assertPartition, assertSubject, assertRelationId, assertExpiresAt, partitionKey } from '../identity.js';
|
|
37
37
|
import { isClock, systemClock } from '../clock.js';
|
|
38
|
-
import { isActiveExpiry, resolveGrantExpiry, sameInstant
|
|
38
|
+
import { fgaTupleExpiry, isActiveExpiry, resolveGrantExpiry, sameInstant } from '../expiry.js';
|
|
39
39
|
import { isRelUserset } from '../types.js';
|
|
40
40
|
import { alignBatchResults } from './openfga_driver.js';
|
|
41
|
-
import { FGA_MAX_BATCH_CHECK,
|
|
41
|
+
import { FGA_MAX_BATCH_CHECK, assertExperimentalOptIn, assertPinnedModel, factsModelDifferences, factsModelDriftError } from './openfga_facts.js';
|
|
42
42
|
import { assertCheckManyObjects, distinctObjects, expandByPosition } from '../relations/check_many.js';
|
|
43
|
+
import { assertEnumeratePage, assertNoListPage } from '../relations/page.js';
|
|
43
44
|
import { assertFgaObjectId, FACTS_EXPIRY_CONDITION, FACTS_GROUP_TYPE, } from './openfga_facts.js';
|
|
44
|
-
import { assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject, assertRelationDenyable, assertDenySubject, assertDenyNoExpiry, denyPhaseOf, deniedRelationOf, storedRelationKind,
|
|
45
|
+
import { assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject, assertRelationDenyable, assertDenySubject, assertDenyNoExpiry, denyPhaseOf, deniedRelationOf, storedRelationKind, relationsDown, RELATION_DENY_PREFIX, RELATION_USERSET_RELATION, RELATION_USERSET_TYPE, } from '../relations/define_relations_config.js';
|
|
45
46
|
import { relationDeleteOrder, relationOrderClass } from '../relations/tuple_order.js';
|
|
46
|
-
import {
|
|
47
|
+
import { GROUP_DEPTH_CALIBRATION_BACKOFF_MS, GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS, GroupDepthBudget, assertGroupDepthSettings, calibrateGroupDepth, groupNestingError, groupNestingVerdict, isGroupNesting, plannedByReconcile, } from '../relations/group_depth.js';
|
|
48
|
+
import { fgaFailureError, isServerWriteLimit, withDeadline } from '../shared/backend_guard.js';
|
|
47
49
|
export const DEFAULT_TIMEOUT_MS = 5_000;
|
|
48
50
|
/**
|
|
49
51
|
* **Tope de operaciones por `Write` en FGA** — `--max-tuples-per-write` del servidor, default 100. MEDIDO en v1.19.0
|
|
50
52
|
* (`openfga-dev`, lote D · cierre 1 de D1): 100 deletes en un `Write` entran; 101 —o 50 writes + 51 deletes— es 400
|
|
51
53
|
* «The number of write operations exceeds the allowed limit of 100» y NO se aplica nada (el `Write` es transaccional).
|
|
52
|
-
* Es también la frontera de la purga atómica (`#purgeByFilters`): una purga que cabe aquí es UN
|
|
53
|
-
*
|
|
54
|
+
* Es también la frontera de la purga atómica PARA EL ESCRITOR (`#purgeByFilters`): una purga que cabe aquí es UN
|
|
55
|
+
* `Write`, sin PREFIJO que cortar —un `Check` concurrente SÍ puede observar ese `Write` a medias: lector rasgado, fila
|
|
56
|
+
* «torn read» de Limits, decisión (6a) del dueño—; con más membresías de grupo que esto, el residuo es una fila DECLARADA
|
|
57
|
+
* (README, «Deny on relations»).
|
|
54
58
|
* **Requisito del despliegue: `--max-tuples-per-write` ≥ 100** (README, «Limits»). Un servidor con el tope MÁS BAJO
|
|
55
59
|
* responde 400 `exceeded_entity_limit` al primer `Write` que lo pase, y ése puede no ser el primero de la purga (con
|
|
56
60
|
* tope 50 y `[3 allows][60 membresías][40 denies]` el `Write` de 3 ya se aplicó): el driver lo dice como **500
|
|
@@ -127,6 +131,42 @@ const IGNORE_DUPLICATE_WRITES = {
|
|
|
127
131
|
const IGNORE_MISSING_DELETES = {
|
|
128
132
|
conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
|
|
129
133
|
};
|
|
134
|
+
/** La condición de una tupla leída, en la forma de `StoredRelationTuple` (el ÚNICO paso por `fgaTupleExpiry`). */
|
|
135
|
+
function storedCondition(condition) {
|
|
136
|
+
const expiry = fgaTupleExpiry(condition);
|
|
137
|
+
if (expiry.kind === 'expiry')
|
|
138
|
+
return { validUntil: expiry.at };
|
|
139
|
+
if (expiry.kind === 'none')
|
|
140
|
+
return { validUntil: null };
|
|
141
|
+
return { validUntil: null, invalid: `${expiry.kind}: ${expiry.detail}` };
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* **El error de una llamada DENTRO de la espera tras una calibración fallida** (cierre-3 de R1 · R-5): una instancia NUEVA
|
|
145
|
+
* por llamada, con `cause` = el error memorizado. Misma clase, `status`, `code` y `details` que el memorizado, salvo un
|
|
146
|
+
* TIMEOUT (`AuthorizationBackendTimeoutError`), que pasa a 503 `AuthorizationBackendError` no-timeout: lo que venció fue
|
|
147
|
+
* la sonda de OTRA llamada; esta no tocó la red y su escritura no ocurrió, así que el manager no debe notificar
|
|
148
|
+
* `indeterminate` (invariante 13).
|
|
149
|
+
*/
|
|
150
|
+
function backoffError(memorized, remainingMs) {
|
|
151
|
+
const suffix = ` (la calibración del techo de anidación falló hace poco; en espera ${Math.max(0, Math.ceil(remainingMs))} ms más, sin sondear)`;
|
|
152
|
+
if (memorized instanceof AuthorizationBackendTimeoutError) {
|
|
153
|
+
const error = new AuthorizationBackendError('openfga-relations', 'groupDepthLimit', memorized);
|
|
154
|
+
error.message = `${memorized.message}${suffix}`;
|
|
155
|
+
return error;
|
|
156
|
+
}
|
|
157
|
+
if (!(memorized instanceof Error))
|
|
158
|
+
return memorized;
|
|
159
|
+
const copy = Object.create(Object.getPrototypeOf(memorized));
|
|
160
|
+
for (const key of Reflect.ownKeys(memorized)) {
|
|
161
|
+
if (key === 'partialWrite' || key === 'stack')
|
|
162
|
+
continue;
|
|
163
|
+
Object.defineProperty(copy, key, Object.getOwnPropertyDescriptor(memorized, key));
|
|
164
|
+
}
|
|
165
|
+
Object.defineProperty(copy, 'message', { value: `${memorized.message}${suffix}`, writable: true, configurable: true, enumerable: false });
|
|
166
|
+
Object.defineProperty(copy, 'cause', { value: memorized, writable: true, configurable: true, enumerable: false });
|
|
167
|
+
Error.captureStackTrace?.(copy, backoffError);
|
|
168
|
+
return copy;
|
|
169
|
+
}
|
|
130
170
|
export class OpenFgaRelationsDriver {
|
|
131
171
|
/**
|
|
132
172
|
* Lo que declara (cada valor tiene su cara en `runRelationsDriverContract`):
|
|
@@ -183,6 +223,8 @@ export class OpenFgaRelationsDriver {
|
|
|
183
223
|
// ajusta (usa `database` para lo que tenga que ir en su transacción, o
|
|
184
224
|
// `requireTransactionalWrites` para fallar al arrancar).
|
|
185
225
|
transactionalWrites: false,
|
|
226
|
+
// Estrategia 2.5.0 · firma 4 (regla D15: opcional, normalizada a `false`): EXPERIMENTAL en 2.5.x, como el de roles.
|
|
227
|
+
experimental: true,
|
|
186
228
|
});
|
|
187
229
|
client;
|
|
188
230
|
#config;
|
|
@@ -197,13 +239,29 @@ export class OpenFgaRelationsDriver {
|
|
|
197
239
|
#monotonic;
|
|
198
240
|
#logger;
|
|
199
241
|
/**
|
|
200
|
-
* **
|
|
201
|
-
*
|
|
202
|
-
*
|
|
242
|
+
* **La comparación modelo ↔ config de L11** (`#modelGate`): se lee UNA vez por proceso (`readAuthorizationModel` del
|
|
243
|
+
* `modelId` fijado, obligatorio) y se comparte con las vistas `withClock` (objeto que se muta). Un fallo transitorio
|
|
244
|
+
* al leerla no se memoriza (503 y se reintenta en la siguiente).
|
|
203
245
|
*/
|
|
204
246
|
#modelMemo = {};
|
|
247
|
+
/**
|
|
248
|
+
* **La calibración del techo de anidación de grupos** (R1, decisión 5 + 5-bis): UNA por proceso, compartida con las
|
|
249
|
+
* vistas `withClock` (objeto que se muta, como `#modelMemo`). Se memoriza SOLO el éxito: un fallo (503, deadline, el
|
|
250
|
+
* 500 de una sonda ciega) no se queda — la siguiente operación vuelve a calibrar (C13a), pero no antes de
|
|
251
|
+
* `GROUP_DEPTH_CALIBRATION_BACKOFF_MS` (`failure`: hasta entonces se relanza ese mismo error sin sondear; cierre-2 de
|
|
252
|
+
* R1 · D5). Y un 503 `E_AUTHZ_RESOLUTION_LIMIT` de una lectura invalida el éxito memorizado, como mucho una vez cada
|
|
253
|
+
* `GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS` (`invalidatedAt`; P2 del juez): el límite BAJADO en el servidor se ve
|
|
254
|
+
* sin reiniciar la app.
|
|
255
|
+
*/
|
|
256
|
+
#groupDepthMemo = {};
|
|
205
257
|
diagnostics = { unparseableTuples: 0 };
|
|
206
258
|
constructor(config, options) {
|
|
259
|
+
// Estrategia 2.5.0 · firma 4: `openfga` es EXPERIMENTAL en 2.5.x. PRIMERA línea: sin el token, 500 sin crear el cliente
|
|
260
|
+
// (la vista `withClock` reconstruye con las MISMAS opciones, así que hereda el token y vuelve a pasar por aquí).
|
|
261
|
+
assertExperimentalOptIn('OpenFgaRelationsDriver', options?.experimental);
|
|
262
|
+
// Cierre de R1 · 🟡 4 del auditor: el driver RE-VALIDA el techo de la config (una config extendida con spread no pasó
|
|
263
|
+
// por `defineRelationsConfig`; `NaN` apagaba el gate en silencio). 500 al construir.
|
|
264
|
+
assertGroupDepthSettings(config.maxGroupDepth, config.enforceGroupDepth, 'OpenFgaRelationsDriver');
|
|
207
265
|
this.#config = config;
|
|
208
266
|
this.#options = options;
|
|
209
267
|
this.#now = options.now ?? systemClock;
|
|
@@ -256,18 +314,10 @@ export class OpenFgaRelationsDriver {
|
|
|
256
314
|
}
|
|
257
315
|
this.#monotonic = monotonic ?? (() => performance.now());
|
|
258
316
|
this.#logger = options.logger ?? console;
|
|
259
|
-
// **
|
|
260
|
-
//
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
if (hasDenyTypes(config) && !options.modelId) {
|
|
264
|
-
throw new AuthorizationConfigError(`OpenFgaRelationsDriver: la config de relaciones declara deny en ${config.objectTypes
|
|
265
|
-
.filter((t) => t.deny !== undefined)
|
|
266
|
-
.map((t) => `'${t.type}' (${t.deny})`)
|
|
267
|
-
.join(', ')} y el driver no tiene modelId fijado (D14). Con deny activo cada pod tiene que evaluar contra un modelo ` +
|
|
268
|
-
`CONOCIDO que declare denied_<rel>: publica el modelo (openfga:provision), fija OPENFGA_MODEL_ID en TODA la flota ` +
|
|
269
|
-
`y despliega. Sin el pin, un modelo republicado sin denied_* haría conceder sobre objetos denegados.`);
|
|
270
|
-
}
|
|
317
|
+
// **L11 · `modelId` OBLIGATORIO, siempre** (decisión del dueño 2026-10-17 (2); sustituye a D14 —«solo con deny»— y a
|
|
318
|
+
// `#assertPinnedForDecideMany`, P5/P6): sin el pin, la puerta L11 compara el modelo del arranque y el servidor
|
|
319
|
+
// evalúa con el último de AHORA, así que la igualdad no protegería nada.
|
|
320
|
+
assertPinnedModel('OpenFgaRelationsDriver', options.modelId);
|
|
271
321
|
this.client = new OpenFgaClient({
|
|
272
322
|
apiUrl: options.apiUrl,
|
|
273
323
|
storeId: options.storeId,
|
|
@@ -289,8 +339,9 @@ export class OpenFgaRelationsDriver {
|
|
|
289
339
|
}
|
|
290
340
|
const view = new OpenFgaRelationsDriver(this.#config, { ...this.#options, now });
|
|
291
341
|
view.client = this.client;
|
|
292
|
-
// La
|
|
342
|
+
// La puerta L11 es del PROCESO: la vista comparte la lectura del modelo (el MISMO objeto, que se muta).
|
|
293
343
|
view.#modelMemo = this.#modelMemo;
|
|
344
|
+
view.#groupDepthMemo = this.#groupDepthMemo;
|
|
294
345
|
return view;
|
|
295
346
|
}
|
|
296
347
|
/** El `context` de todo `Check`/`ListObjects`: la condición `not_expired` se evalúa contra este instante (un `now` por operación). */
|
|
@@ -309,21 +360,28 @@ export class OpenFgaRelationsDriver {
|
|
|
309
360
|
* que dice «venció el deadline» —503 `E_AUTHZ_BACKEND_TIMEOUT`, indeterminado—,
|
|
310
361
|
* y para lo que el cliente lanza por su cuenta el `isTimeoutLike` ESTRICTO
|
|
311
362
|
* de `backend_guard.ts` (`ECONNABORTED`/`ETIMEDOUT`/`.timeout` numérico/
|
|
312
|
-
* `KnexTimeoutError
|
|
363
|
+
* `KnexTimeoutError` y, desde R3, el `FgaError` sin respuesta, por la cadena de `cause`); el RESTO lo clasifica
|
|
364
|
+
* `classifyFgaFailure` (R3: transitorio 503, `E_AUTHZ_RESOLUTION_LIMIT` 503, despliegue 500, carrera 409, 4xx
|
|
365
|
+
* desconocido 500). Hasta el cierre-2 este
|
|
313
366
|
* driver duplicaba la función con una rama por SUBSTRING del mensaje
|
|
314
367
|
* (`/timeout/i`): un error que decía «no es timeout» salía como timeout con
|
|
315
368
|
* `indeterminate: true` sin haber borrado nada, y el docblock afirmaba en
|
|
316
369
|
* falso «mismo criterio que roles».
|
|
317
370
|
*/
|
|
318
|
-
async #guard(operation, fn) {
|
|
371
|
+
async #guard(operation, fn, write = false) {
|
|
319
372
|
const fail = (error) => {
|
|
320
373
|
// Un error del paquete (422/500 ya clasificado) se propaga tal cual.
|
|
321
374
|
if (error instanceof InvalidIdentityError || error instanceof PurgeIncompleteError)
|
|
322
375
|
return error;
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
376
|
+
// R3 (decisión del dueño 2026-10-17 (3)): la clase la decide `classifyFgaFailure`, el MISMO punto de paso que el
|
|
377
|
+
// driver de roles (por `statusCode` + `apiErrorCode`, jamás por el mensaje).
|
|
378
|
+
// Cierre de R3 (🟡 R3-A2): `write` ⇒ el 504 / `deadline_exceeded` del `Write` es indeterminado (timeout).
|
|
379
|
+
const failure = fgaFailureError('openfga-relations', operation, error, { timeoutMs: this.#timeoutMs, write });
|
|
380
|
+
// Cierre-2 de R1 · D5 (P2 del juez): un 503 de profundidad FUERA de la calibración dice que el servidor ya no
|
|
381
|
+
// resuelve lo que el techo memorizado prometía (límite bajado sin reiniciar la app) ⇒ re-calibrar, acotado.
|
|
382
|
+
if (failure instanceof ResolutionLimitError && operation !== 'groupDepthLimit')
|
|
383
|
+
this.#invalidateGroupDepth();
|
|
384
|
+
return failure;
|
|
327
385
|
};
|
|
328
386
|
let pending;
|
|
329
387
|
try {
|
|
@@ -336,145 +394,68 @@ export class OpenFgaRelationsDriver {
|
|
|
336
394
|
throw fail(error);
|
|
337
395
|
}), this.#timeoutMs, () => new AuthorizationBackendTimeoutError('openfga-relations', operation, this.#timeoutMs));
|
|
338
396
|
}
|
|
339
|
-
/* ──
|
|
340
|
-
/** Las relaciones `<tipo>#denied_<rel>` que declara el modelo que sirve este driver (memo por proceso). */
|
|
341
|
-
#modelDenied() {
|
|
342
|
-
this.#readModel();
|
|
343
|
-
return this.#modelMemo.denied;
|
|
344
|
-
}
|
|
397
|
+
/* ── L11: el modelo FIJADO es el que genera la config (una puerta, por tipo) ─────────────────────────── */
|
|
345
398
|
/**
|
|
346
|
-
* **
|
|
347
|
-
*
|
|
348
|
-
*
|
|
349
|
-
*
|
|
399
|
+
* **La lectura del modelo, UNA por proceso** (L11; memo compartido con las vistas `withClock` — es un OBJETO que se
|
|
400
|
+
* MUTA, nunca se reasigna en la vista, H11 b): `readAuthorizationModel` del `modelId` FIJADO (obligatorio desde L11) y
|
|
401
|
+
* `factsModelDifferences` contra la config de este proceso, con el MISMO mapa de holders que usa `openfga:provision`
|
|
402
|
+
* (H11 a). Se memoriza el ÉXITO de la lectura —también si hay diferencias: el modelo fijado no cambia (C13 b)— y un
|
|
403
|
+
* generador que no puede generar (500, H11 d); **un fallo al LEER el modelo NO se memoriza, sea de la clase que sea**
|
|
404
|
+
* (C13 a; cierre de L11): la siguiente operación vuelve a leer.
|
|
350
405
|
*/
|
|
351
|
-
#
|
|
352
|
-
this.#readModel();
|
|
353
|
-
return this.#modelMemo.drift;
|
|
354
|
-
}
|
|
355
|
-
/** UNA lectura del modelo por proceso (memo compartido con las vistas `withClock`); un fallo no se memoriza. */
|
|
356
|
-
#readModel() {
|
|
357
|
-
if (this.#modelMemo.denied)
|
|
358
|
-
return;
|
|
406
|
+
#modelGate() {
|
|
359
407
|
const memo = this.#modelMemo;
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
const drift = new Map();
|
|
372
|
-
const expectedTypes = [
|
|
373
|
-
{ type: RELATION_USERSET_TYPE, relations: { member: { this: {} } } },
|
|
374
|
-
...this.#config.objectTypes.map((spec) => ({
|
|
375
|
-
type: spec.type,
|
|
376
|
-
relations: factsRelationRewrites(spec),
|
|
377
|
-
alternative: spec.deny === undefined ? factsRelationRewrites({ ...spec, deny: 'read' }) : undefined,
|
|
378
|
-
})),
|
|
379
|
-
];
|
|
380
|
-
for (const { type, relations: expectedRelations, alternative } of expectedTypes) {
|
|
381
|
-
const servedRelations = definitions.find((definition) => definition?.type === type)?.relations ?? {};
|
|
382
|
-
// Cierre 6 de D2 · 🟡 D5-2: la excepción D8 vale solo si el modelo trae la forma CON deny ENTERA para esa
|
|
383
|
-
// relación: `<rel>` Y `denied_<rel>` (el cierre del corte de camino). Con `denied_editor = this` (sin `or
|
|
384
|
-
// denied_viewer`) un modelo a mano concedía `editor` a quien tiene deny en `viewer`, donde `database` niega.
|
|
385
|
-
const denyFormMatches = (name) => {
|
|
386
|
-
if (!alternative)
|
|
387
|
-
return false;
|
|
388
|
-
const denied = deniedRelationOf(name);
|
|
389
|
-
return sameRewrite(servedRelations[name], alternative[name]) && sameRewrite(servedRelations[denied], alternative[denied]);
|
|
390
|
-
};
|
|
391
|
-
const differing = Object.keys(expectedRelations).filter((name) => !sameRewrite(servedRelations[name], expectedRelations[name]) && !denyFormMatches(name));
|
|
392
|
-
if (differing.length > 0)
|
|
393
|
-
drift.set(type, differing);
|
|
394
|
-
}
|
|
395
|
-
// Cierre 1 de D1 · ⚪ 7: no basta con que EXISTA `denied_<rel>`: `<rel>` tiene que RESTARLO.
|
|
396
|
-
// Cierre 2 de D1 · ⚪ N4: tampoco basta con la FORMA. En un tipo CON opt-in, `<rel>` y `denied_<rel>` tienen que
|
|
397
|
-
// ser EXACTAMENTE las reglas que genera el paquete para ese tipo (`factsRelationRewrites`, la fuente única del
|
|
398
|
-
// generador), por igualdad estructural tras `canonicalRewrite`: un modelo a mano con `denied_editor: [user]` (sin
|
|
399
|
-
// `or denied_viewer`, el cierre del corte de camino) restaba bien y CONCEDÍA `editor` a un owner con deny en
|
|
400
|
-
// `viewer`, donde `database` niega. En un tipo SIN opt-in (D8: opt-in retirado con el modelo aún publicado) no hay
|
|
401
|
-
// regla del paquete con la que comparar: cuenta la forma de ⚪ 7 (`difference` con `subtract` = `denied_<rel>`),
|
|
402
|
-
// que es lo que `decideMany` necesita para saber qué deny preguntar.
|
|
403
|
-
const out = new Set();
|
|
404
|
-
for (const definition of definitions) {
|
|
405
|
-
const relations = definition?.relations ?? {};
|
|
406
|
-
const spec = this.#config.objectTypes.find((t) => t.type === definition?.type);
|
|
407
|
-
const expected = spec && spec.deny !== undefined ? factsRelationRewrites(spec) : undefined;
|
|
408
|
-
for (const name of Object.keys(relations)) {
|
|
409
|
-
if (!name.startsWith(RELATION_DENY_PREFIX))
|
|
410
|
-
continue;
|
|
411
|
-
const relation = name.slice(RELATION_DENY_PREFIX.length);
|
|
412
|
-
const base = relations[relation];
|
|
413
|
-
const ok = expected
|
|
414
|
-
? sameRewrite(base, expected[relation]) && sameRewrite(relations[name], expected[name])
|
|
415
|
-
: base?.difference?.subtract?.computedUserset?.relation === name;
|
|
416
|
-
if (ok)
|
|
417
|
-
out.add(`${definition.type}#${name}`);
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
return { denied: out, drift };
|
|
421
|
-
}).catch((error) => {
|
|
422
|
-
// Un fallo (503) NO se memoriza: la siguiente lo vuelve a intentar.
|
|
423
|
-
if (memo.denied === denied) {
|
|
424
|
-
memo.denied = undefined;
|
|
425
|
-
memo.drift = undefined;
|
|
408
|
+
if (memo.gate)
|
|
409
|
+
return memo.gate;
|
|
410
|
+
// Cierre de L11 (🟡-6 del tester): «comparación hecha ⇒ memo» y «fallo al leer el modelo ⇒ no memo», por la FASE y no
|
|
411
|
+
// por la clase del error — con R3 un 401/403 o un modelo inexistente serán 500 de despliegue y no pueden quedarse
|
|
412
|
+
// memorizados de por vida en el proceso.
|
|
413
|
+
let compared = false;
|
|
414
|
+
const gate = this.#guard('readAuthorizationModel', async () => {
|
|
415
|
+
const response = await this.client.readAuthorizationModel();
|
|
416
|
+
const served = response?.authorization_model;
|
|
417
|
+
if (!Array.isArray(served?.type_definitions)) {
|
|
418
|
+
throw new Error('la respuesta de ReadAuthorizationModel no trae type_definitions; no se toma por un modelo igual');
|
|
426
419
|
}
|
|
420
|
+
return served;
|
|
421
|
+
}).then((served) => {
|
|
422
|
+
compared = true;
|
|
423
|
+
return factsModelDifferences(served, { holderTypes: this.#holderTypes, relations: this.#config, slice: 'relations' });
|
|
424
|
+
});
|
|
425
|
+
const pending = gate.catch((error) => {
|
|
426
|
+
if (!compared && memo.gate === pending)
|
|
427
|
+
memo.gate = undefined;
|
|
427
428
|
throw error;
|
|
428
429
|
});
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
// Las dos promesas derivan de UNA: si nadie espera una de ellas, su rechazo no puede quedar sin capturar.
|
|
433
|
-
memo.denied.catch(() => { });
|
|
434
|
-
memo.drift.catch(() => { });
|
|
430
|
+
memo.gate = pending;
|
|
431
|
+
pending.catch(() => { });
|
|
432
|
+
return pending;
|
|
435
433
|
}
|
|
436
434
|
/**
|
|
437
|
-
* **
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
*
|
|
435
|
+
* **La puerta L11** (lote «antes de RC», decisión del dueño 2026-10-17 (1); sustituye a P1–P4 —D-31, R2, la excepción
|
|
436
|
+
* D8 por relación y `#modelDenied`— y a P6 —`#assertPinnedForDecideMany`—): antes de la PRIMERA llamada al servidor
|
|
437
|
+
* de la operación, el modelo que sirve el driver tiene que ser IGUAL al que genera la config en los tipos que la
|
|
438
|
+
* operación toca, más `group` (un `group#member` distinto cambia quién es miembro y, con ello, qué concede un `check`
|
|
439
|
+
* de cualquier tipo), más los holders y las CONDICIONES (deciden la vigencia de toda tupla). Si no, 500
|
|
440
|
+
* `E_AUTHZ_CONFIG` nombrando las diferencias por ruta y CERO llamadas que evalúen o escriban.
|
|
441
|
+
*
|
|
442
|
+
* Sin exenciones (injerto 4 del juez): las purgas y los `list*` pasan la puerta ENTERA —el orden de la purga (L10) se
|
|
443
|
+
* demostró sobre la semántica de la CONFIG y la vigencia la decide la condición del modelo—; `enumerateRelations(Many)`,
|
|
444
|
+
* que es el ORIGEN de una migración y tiene que poder rescatar un store con deriva, pasa SOLO la parte `conditions`
|
|
445
|
+
* (`part: 'conditions'`). El orden en cada operación: la puerta de `{ transaction }`, F-05 y la gramática (422, cero
|
|
446
|
+
* llamadas) van ANTES; la puerta va justo antes de la primera llamada (H1-5).
|
|
442
447
|
*/
|
|
443
|
-
async #
|
|
444
|
-
const
|
|
445
|
-
const
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const declared = await this.#modelDenied();
|
|
455
|
-
const missing = wanted.filter((name) => !declared.has(name));
|
|
456
|
-
if (missing.length > 0) {
|
|
457
|
-
throw new AuthorizationConfigError(`OpenFgaRelationsDriver: la config declara deny y el modelo que sirve este driver (modelId ` +
|
|
458
|
-
`${this.#options.modelId ?? '(último)'}) NO declara ${missing.join(', ')} —o la declara pero <rel> o denied_<rel> no son las reglas que genera el paquete (but not y el cierre del corte de camino)— (D-31). Con ese modelo el servidor no ` +
|
|
459
|
-
`resta el deny: un pod así concedería sobre objetos denegados. Republica el modelo desde la config actual ` +
|
|
460
|
-
`(openfga:provision) y fija el modelId nuevo en TODA la flota antes de desplegar.`);
|
|
461
|
-
}
|
|
462
|
-
}
|
|
463
|
-
// R2 del auditor del panel «óptimo» (cierre 5 de D2): la MISMA comparación en los tipos SIN deny. Un pod con una config
|
|
464
|
-
// que ESTRECHA una regla (`viewer` deja de incluir `editor`) y el pin a un modelo viejo concedía por la regla vieja,
|
|
465
|
-
// en silencio, donde `database` con la misma config niega. Misma lectura del modelo, ya memorizada.
|
|
466
|
-
// Cierre 6 de D2 · ⚪ (D5-2): `group` se compara en TODA operación, no solo en las suyas — un `group#member` del
|
|
467
|
-
// modelo que no es `this` cambia quién es miembro y, con ello, qué concede un `check` de `document`.
|
|
468
|
-
const drift = await this.#modelDrift();
|
|
469
|
-
const compared = [...new Set([...distinct, RELATION_USERSET_TYPE])];
|
|
470
|
-
const drifted = compared.flatMap((type) => (drift.get(type) ?? []).map((relation) => `${type}#${relation}`));
|
|
471
|
-
if (drifted.length > 0) {
|
|
472
|
-
throw new AuthorizationConfigError(`OpenFgaRelationsDriver: el modelo que sirve este driver (modelId ${this.#options.modelId ?? '(último)'}) declara ` +
|
|
473
|
-
`${drifted.join(', ')} con una regla DISTINTA de la que genera la config de este proceso (includes / deny), o no la ` +
|
|
474
|
-
`declara (D-31 en todos los tipos, R2). Con ese modelo el servidor decide por la regla del modelo: un pod así ` +
|
|
475
|
-
`concedería lo que la config —y database con la misma config— niega. Republica el modelo desde la config actual ` +
|
|
476
|
-
`(openfga:provision) y fija el modelId nuevo en TODA la flota antes de desplegar.`);
|
|
477
|
-
}
|
|
448
|
+
async #assertModel(operation, types, part = 'full') {
|
|
449
|
+
const comparison = await this.#modelGate();
|
|
450
|
+
const touched = [...types];
|
|
451
|
+
// Una operación sobre `group` (una membresía) cambia lo que concede o niega CUALQUIER tipo —y su renovación lee los
|
|
452
|
+
// `denied_*` de los tipos con deny (`#carriedDeny`)—: compara todos los tipos de la config.
|
|
453
|
+
const groupWide = touched.includes(RELATION_USERSET_TYPE) ? this.#config.objectTypes.map((t) => t.type) : [];
|
|
454
|
+
const relevant = new Set([...touched, ...groupWide, RELATION_USERSET_TYPE, ...Object.values(this.#holderTypes)]);
|
|
455
|
+
const hits = comparison.differences.filter((d) => part === 'conditions' ? d.type === null && d.path.startsWith('conditions.') : d.type === null || relevant.has(d.type));
|
|
456
|
+
if (hits.length > 0)
|
|
457
|
+
throw factsModelDriftError('OpenFgaRelationsDriver', operation, this.#options.modelId, hits);
|
|
458
|
+
return comparison;
|
|
478
459
|
}
|
|
479
460
|
/* ── Composición y parseo de ids (⚪5: partición por string en el id) ────── */
|
|
480
461
|
/** `<type>:<scopeKey(partition)>|<objectUuid>`. */
|
|
@@ -599,12 +580,31 @@ export class OpenFgaRelationsDriver {
|
|
|
599
580
|
const user = this.#subjectUser(subject, partitionKey);
|
|
600
581
|
const key = { user, relation, object: objectId };
|
|
601
582
|
const now = this.#now();
|
|
602
|
-
await this.#
|
|
583
|
+
await this.#assertModel('relate', [object.type]);
|
|
603
584
|
const current = await this.#readTuple('relate', key);
|
|
585
|
+
// L11 · H8: la clave YA existe con una condición que el paquete no escribe (`foreign`) o no sabe leer
|
|
586
|
+
// (`unparseable`). Tratarla como «sin caducidad» haría de este `relate` un no-op mudo (y la tupla inerte para el
|
|
587
|
+
// servidor, M-a); como «vencida», una renovación en dos `Write` sin mirar portadoras. 409, cero escrituras, receta.
|
|
588
|
+
if (current?.invalid !== undefined) {
|
|
589
|
+
throw RenewalUnsafeError.foreignCondition('relate', `${objectId}#${relation}@${user}`, current.invalid);
|
|
590
|
+
}
|
|
604
591
|
const previous = current ? current.validUntil : null;
|
|
605
592
|
const expiresAt = resolveGrantExpiry(previous, options?.expiresAt, now);
|
|
606
593
|
if (current && sameInstant(previous, expiresAt))
|
|
607
594
|
return;
|
|
595
|
+
// R1 (decisión del dueño 2026-10-17 (5)): una arista de anidación que queda VIVA —nueva, renovada o revivida— pasa el
|
|
596
|
+
// gate del techo (calibrado, o el `maxGroupDepth` menor de la config) ANTES de cualquier `Write`: 422 y cero escrituras.
|
|
597
|
+
// El no-op idéntico de ARRIBA va ANTES (invariante 6: un `relate` repetido de una arista preexistente no se vuelve 422).
|
|
598
|
+
if (isGroupNesting(subject, relation, object) &&
|
|
599
|
+
isActiveExpiry(expiresAt, now) &&
|
|
600
|
+
!plannedByReconcile(options, partition, { child: subject.object.id, parent: object.id })) {
|
|
601
|
+
const limit = await this.groupDepthLimit();
|
|
602
|
+
// El presupuesto (y su deadline TOTAL) empieza DESPUÉS de la calibración: el primer `relate` de un proceso en frío
|
|
603
|
+
// paga la calibración aparte y no puede dar un 422 espurio por ella (cierre de R1, panel).
|
|
604
|
+
const budget = new GroupDepthBudget();
|
|
605
|
+
if (limit !== null)
|
|
606
|
+
await this.#assertGroupNesting(partitionKey, { child: subject.object.id, parent: object.id }, now, limit, budget);
|
|
607
|
+
}
|
|
608
608
|
const tuple = expiresAt
|
|
609
609
|
? { ...key, condition: { name: FACTS_EXPIRY_CONDITION, context: { valid_until: expiresAt.toISOString() } } }
|
|
610
610
|
: key;
|
|
@@ -620,11 +620,11 @@ export class OpenFgaRelationsDriver {
|
|
|
620
620
|
if (carried !== null)
|
|
621
621
|
throw RenewalUnsafeError.carrier(membership, carried);
|
|
622
622
|
}
|
|
623
|
-
await this.#guard('relate', () => this.client.write({ deletes: [key] }, IGNORE_MISSING_DELETES));
|
|
623
|
+
await this.#guard('relate', () => this.client.write({ deletes: [key] }, IGNORE_MISSING_DELETES), true);
|
|
624
624
|
// Cierre 2 de D2 · 🟠 N1 (b): el delete YA entró — si el write falla, la escritura es PARCIAL (invariante 13: el
|
|
625
625
|
// manager notifica `indeterminate: true`); hasta el cierre era un 503 «sin escribir» con la tupla borrada.
|
|
626
626
|
try {
|
|
627
|
-
await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES));
|
|
627
|
+
await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES), true);
|
|
628
628
|
}
|
|
629
629
|
catch (error) {
|
|
630
630
|
// Cierre 3 de D2 · 🟡 C2-3: la membresía quedó BORRADA. Si entre la comprobación y el delete entró un deny en el
|
|
@@ -652,7 +652,181 @@ export class OpenFgaRelationsDriver {
|
|
|
652
652
|
}
|
|
653
653
|
return;
|
|
654
654
|
}
|
|
655
|
-
await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES));
|
|
655
|
+
await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES), true);
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* **El techo de anidación de grupos de ESTE driver** (lote «antes de RC» · R1, decisiones del dueño 2026-10-17 (5) y
|
|
659
|
+
* (5-bis), opción (a)): ACTIVO por defecto. Se CALIBRA contra el servidor (`#calibratedGroupDepth`) y es el mínimo de
|
|
660
|
+
* la sonda positiva y la negativa (22 en OpenFGA v1.19 con `--resolve-node-limit` por defecto, medido en F0 · M-c); el
|
|
661
|
+
* `maxGroupDepth` de la config solo puede BAJARLO — uno mayor que el calibrado es 500 `E_AUTHZ_CONFIG` con las dos
|
|
662
|
+
* cifras (el despliegue promete un techo que el servidor no resuelve) —, y `false` lo apaga de forma explícita (sin
|
|
663
|
+
* calibrar). Solo lectura: lo usa `relate` antes de escribir una arista `group#member` y el plan de
|
|
664
|
+
* `reconcileRelations` sobre el destino.
|
|
665
|
+
*/
|
|
666
|
+
async groupDepthLimit() {
|
|
667
|
+
const configured = assertGroupDepthSettings(this.#config.maxGroupDepth, this.#config.enforceGroupDepth, 'OpenFgaRelationsDriver').maxGroupDepth;
|
|
668
|
+
if (configured === false)
|
|
669
|
+
return null;
|
|
670
|
+
const calibrated = await this.#calibratedGroupDepth();
|
|
671
|
+
if (configured !== undefined && configured > calibrated) {
|
|
672
|
+
throw Object.assign(new AuthorizationConfigError(`OpenFgaRelationsDriver: maxGroupDepth = ${configured} en la config de relaciones y el servidor solo resuelve ` +
|
|
673
|
+
`${calibrated} saltos de grupos anidados (calibrado con tuplas contextuales: el mínimo de un miembro y de un ` +
|
|
674
|
+
'no-miembro con membresía). Un techo por encima del calibrado admitiría cadenas que darán 503 ' +
|
|
675
|
+
`E_AUTHZ_RESOLUTION_LIMIT a los no-miembros. Baja maxGroupDepth a ≤ ${calibrated}, o sube ` +
|
|
676
|
+
'OPENFGA_RESOLVE_NODE_LIMIT (--resolve-node-limit) en el servidor y reinicia la app: SUBIR el límite no lo ' +
|
|
677
|
+
'detecta nada (la calibración se memoriza por proceso); BAJARLO sí, sin reinicio — el 503 de profundidad de ' +
|
|
678
|
+
'una lectura la re-calibra, como mucho una vez por minuto.'), { details: { maxGroupDepth: configured, calibrated } });
|
|
679
|
+
}
|
|
680
|
+
return configured ?? calibrated;
|
|
681
|
+
}
|
|
682
|
+
/**
|
|
683
|
+
* La calibración memorizada (solo el éxito; una en vuelo se comparte). Tras un fallo, durante
|
|
684
|
+
* `GROUP_DEPTH_CALIBRATION_BACKOFF_MS` se relanza la MISMA CLASE de error sin sondear (cierre-2 de R1 · D5: el backoff);
|
|
685
|
+
* después se vuelve a calibrar. Cada llamada de la ventana recibe un error NUEVO con `cause` = el memorizado (cierre-3 ·
|
|
686
|
+
* R-5: compartir la instancia compartía cualquier anotación de un llamador), y un TIMEOUT de la calibración se relanza
|
|
687
|
+
* como 503 no-timeout: la escritura de la ventana no tocó la red, así que no es indeterminada (invariante 13).
|
|
688
|
+
*/
|
|
689
|
+
#calibratedGroupDepth() {
|
|
690
|
+
const memo = this.#groupDepthMemo;
|
|
691
|
+
if (memo.calibrated)
|
|
692
|
+
return memo.calibrated;
|
|
693
|
+
if (memo.failure && this.#monotonic() - memo.failure.at < GROUP_DEPTH_CALIBRATION_BACKOFF_MS) {
|
|
694
|
+
return Promise.reject(backoffError(memo.failure.error, GROUP_DEPTH_CALIBRATION_BACKOFF_MS - (this.#monotonic() - memo.failure.at)));
|
|
695
|
+
}
|
|
696
|
+
const pending = this.#calibrate().then((calibrated) => {
|
|
697
|
+
if (memo.calibrated === pending)
|
|
698
|
+
memo.failure = undefined;
|
|
699
|
+
return calibrated;
|
|
700
|
+
}, (error) => {
|
|
701
|
+
if (memo.calibrated === pending) {
|
|
702
|
+
memo.calibrated = undefined;
|
|
703
|
+
memo.failure = { at: this.#monotonic(), error };
|
|
704
|
+
}
|
|
705
|
+
throw error;
|
|
706
|
+
});
|
|
707
|
+
memo.calibrated = pending;
|
|
708
|
+
pending.catch(() => { });
|
|
709
|
+
return pending;
|
|
710
|
+
}
|
|
711
|
+
/**
|
|
712
|
+
* **Re-calibración tras un 503 de profundidad** (cierre-2 de R1 · D5 (a), P2 del juez): olvida el techo memorizado para
|
|
713
|
+
* que la siguiente escritura de anidación lo vuelva a medir, como mucho UNA vez cada
|
|
714
|
+
* `GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS` por proceso (reloj monótono). Es lo que hace honesta (5-bis) —«nadie
|
|
715
|
+
* recibe 503 dentro del límite»— cuando el operador BAJA el límite del servidor sin reiniciar; subirlo no da 503 y
|
|
716
|
+
* sigue pidiendo reinicio. Sin techo memorizado (nunca calibró, o falló) no hay nada que olvidar.
|
|
717
|
+
*/
|
|
718
|
+
#invalidateGroupDepth() {
|
|
719
|
+
const memo = this.#groupDepthMemo;
|
|
720
|
+
if (memo.calibrated === undefined)
|
|
721
|
+
return;
|
|
722
|
+
const now = this.#monotonic();
|
|
723
|
+
if (memo.invalidatedAt !== undefined && now - memo.invalidatedAt < GROUP_DEPTH_RECALIBRATION_MIN_INTERVAL_MS)
|
|
724
|
+
return;
|
|
725
|
+
memo.calibrated = undefined;
|
|
726
|
+
memo.invalidatedAt = now;
|
|
727
|
+
}
|
|
728
|
+
/**
|
|
729
|
+
* **La calibración** (decisión 5: «con tuplas contextuales, solo lectura, forma de dominio»; 5-bis: el MÍNIMO de las
|
|
730
|
+
* dos sondas): un `Check` con TODO el grafo en `contextual_tuples` sobre ids que no existen en el store —
|
|
731
|
+
* `<holder>:authz-calibration-member ∈ g0 ⊂ … ⊂ gK` y la relación de DOMINIO del primer tipo declarado sobre
|
|
732
|
+
* `gK#member` (positiva: T), y `<holder>:authz-calibration-outsider ∈ h0 ⊂ h1` (negativa CON membresía: F; sin
|
|
733
|
+
* membresías la sonda es CIEGA, M-c) —, siempre con `HIGHER_CONSISTENCY`. Pasa antes la puerta L11 (el modelo fijado es
|
|
734
|
+
* el de la config). Sin ningún tipo con relación (que acepte `group#member`) calibra con `group#member` y resta 1
|
|
735
|
+
* (T-4: la forma `member` resuelve un salto más que la de dominio).
|
|
736
|
+
*/
|
|
737
|
+
async #calibrate() {
|
|
738
|
+
const domainType = this.#config.objectTypes.find((t) => t.relations.length > 0);
|
|
739
|
+
const domain = domainType ? { type: domainType.type, relation: domainType.relations[0].name } : null;
|
|
740
|
+
await this.#assertModel('groupDepthLimit', [domain?.type ?? RELATION_USERSET_TYPE]);
|
|
741
|
+
const holder = Object.values(this.#holderTypes)[0];
|
|
742
|
+
if (!holder) {
|
|
743
|
+
throw new AuthorizationConfigError('OpenFgaRelationsDriver: sin holderTypes no se puede calibrar el techo de anidación de grupos (la sonda necesita un holder).');
|
|
744
|
+
}
|
|
745
|
+
const ns = 'authz-calibration';
|
|
746
|
+
const group = (name) => `${RELATION_USERSET_TYPE}:${ns}|${name}`;
|
|
747
|
+
const member = `${holder}:${ns}-member`;
|
|
748
|
+
const outsider = `${holder}:${ns}-outsider`;
|
|
749
|
+
const context = this.#checkContext(this.#now());
|
|
750
|
+
const probe = async (depth, side) => {
|
|
751
|
+
const tuples = [
|
|
752
|
+
{ user: member, relation: RELATION_USERSET_RELATION, object: group('g0') },
|
|
753
|
+
];
|
|
754
|
+
for (let i = 0; i < depth; i++) {
|
|
755
|
+
tuples.push({ user: `${group(`g${i}`)}#${RELATION_USERSET_RELATION}`, relation: RELATION_USERSET_RELATION, object: group(`g${i + 1}`) });
|
|
756
|
+
}
|
|
757
|
+
tuples.push({ user: outsider, relation: RELATION_USERSET_RELATION, object: group('h0') });
|
|
758
|
+
tuples.push({ user: `${group('h0')}#${RELATION_USERSET_RELATION}`, relation: RELATION_USERSET_RELATION, object: group('h1') });
|
|
759
|
+
const top = `${group(`g${depth}`)}#${RELATION_USERSET_RELATION}`;
|
|
760
|
+
const target = domain
|
|
761
|
+
? { relation: domain.relation, object: `${domain.type}:${ns}|x` }
|
|
762
|
+
: { relation: RELATION_USERSET_RELATION, object: group(`g${depth}`) };
|
|
763
|
+
if (domain)
|
|
764
|
+
tuples.push({ user: top, relation: domain.relation, object: target.object });
|
|
765
|
+
try {
|
|
766
|
+
const response = await this.#guard('groupDepthLimit', () => this.client.check({ user: side === 'positive' ? member : outsider, relation: target.relation, object: target.object, contextualTuples: tuples, context }, { consistency: ConsistencyPreference.HigherConsistency }));
|
|
767
|
+
return (response?.allowed === true) === (side === 'positive') ? 'ok' : 'blind';
|
|
768
|
+
}
|
|
769
|
+
catch (error) {
|
|
770
|
+
if (error instanceof ResolutionLimitError)
|
|
771
|
+
return 'too-complex';
|
|
772
|
+
throw error;
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
const calibrated = await calibrateGroupDepth(probe);
|
|
776
|
+
return domain ? calibrated : Math.max(0, calibrated - 1);
|
|
777
|
+
}
|
|
778
|
+
/**
|
|
779
|
+
* **El gate de R1 en `openfga`**: lee el grafo de anidación de la partición hacia ARRIBA desde el contenedor (un `Read`
|
|
780
|
+
* por `user` = `group:<g>#member` y tipo `group:`, por grupo) y hacia ABAJO desde el contenido (un `Read` por objeto
|
|
781
|
+
* `group:<g>` y relación `member`, que trae también los holders: O(miembros) en una escritura rara, declarado), con la
|
|
782
|
+
* vigencia del instante de la escritura (una arista vencida no conecta; una con condición `foreign`/`unparseable`
|
|
783
|
+
* CUENTA: más 422, nunca menos). Concurrencia: dos `relate` que juntas exceden pasan las dos (cada una lee el grafo
|
|
784
|
+
* sin la otra) — declarado parcial, como `scopes.maxDepth`.
|
|
785
|
+
*
|
|
786
|
+
* **Presupuesto** (cierre de R1 · 🟠 1 del auditor): cada página de `Read` pasa por `GroupDepthBudget` —requests,
|
|
787
|
+
* tuplas leídas (los holders que el `Read` hacia abajo no puede filtrar cuentan) y deadline TOTAL del veredicto—; al
|
|
788
|
+
* pasarlo, 422 `too-many-groups` sin escribir.
|
|
789
|
+
*/
|
|
790
|
+
async #assertGroupNesting(partitionKey, edge, now, limit, budget) {
|
|
791
|
+
const pages = { before: () => budget.beforeRead(), after: (n) => budget.charge(n) };
|
|
792
|
+
const prefix = `${RELATION_USERSET_TYPE}:${partitionKey}|`;
|
|
793
|
+
const live = (t) => t.invalid !== undefined || isActiveExpiry(t.validUntil ?? null, now);
|
|
794
|
+
const idOf = (fullObject) => {
|
|
795
|
+
const parsed = this.#parseObjectId(fullObject);
|
|
796
|
+
return parsed && parsed.type === RELATION_USERSET_TYPE && parsed.partitionKey === partitionKey ? parsed.objectUuid : null;
|
|
797
|
+
};
|
|
798
|
+
const verdict = await groupNestingVerdict(edge, limit, {
|
|
799
|
+
parentsOf: async (groups) => {
|
|
800
|
+
const out = [];
|
|
801
|
+
for (const child of groups) {
|
|
802
|
+
const user = `${prefix}${child}#${RELATION_USERSET_RELATION}`;
|
|
803
|
+
for (const t of await this.#readAll('relate', { user, relation: RELATION_USERSET_RELATION, object: `${RELATION_USERSET_TYPE}:` }, pages)) {
|
|
804
|
+
const parent = t.user === user && t.relation === RELATION_USERSET_RELATION && live(t) ? idOf(t.object) : null;
|
|
805
|
+
if (parent !== null)
|
|
806
|
+
out.push({ child, parent });
|
|
807
|
+
}
|
|
808
|
+
}
|
|
809
|
+
return out;
|
|
810
|
+
},
|
|
811
|
+
childrenOf: async (groups) => {
|
|
812
|
+
const out = [];
|
|
813
|
+
for (const parent of groups) {
|
|
814
|
+
const object = `${prefix}${parent}`;
|
|
815
|
+
for (const t of await this.#readAll('relate', { object, relation: RELATION_USERSET_RELATION }, pages)) {
|
|
816
|
+
const suffix = `#${RELATION_USERSET_RELATION}`;
|
|
817
|
+
if (t.object !== object || t.relation !== RELATION_USERSET_RELATION || !t.user.endsWith(suffix) || !live(t))
|
|
818
|
+
continue;
|
|
819
|
+
const child = idOf(t.user.slice(0, -suffix.length));
|
|
820
|
+
if (child !== null)
|
|
821
|
+
out.push({ child, parent });
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
return out;
|
|
825
|
+
},
|
|
826
|
+
});
|
|
827
|
+
if (verdict.kind !== 'ok') {
|
|
828
|
+
throw groupNestingError('openfga-relations', verdict, `group:${edge.parent}#member@group:${edge.child}#member`, limit);
|
|
829
|
+
}
|
|
656
830
|
}
|
|
657
831
|
/**
|
|
658
832
|
* **¿Transporta el grupo un deny vivo?** (cierre 2 de D2 · 🟠 N1): el `#member` de `group` —o el de un grupo que lo
|
|
@@ -676,14 +850,16 @@ export class OpenFgaRelationsDriver {
|
|
|
676
850
|
// no anunciaba, y las particiones siguientes no migraban.
|
|
677
851
|
if (t.relation.startsWith(RELATION_DENY_PREFIX) &&
|
|
678
852
|
t.object.startsWith(`${type}:${partitionKey}|`) &&
|
|
679
|
-
|
|
853
|
+
// L11: un deny con una condición `foreign`/`unparseable` cuenta como VIVO (fail-closed: más 409, nunca menos).
|
|
854
|
+
(t.invalid !== undefined || isActiveExpiry(t.validUntil ?? null, now))) {
|
|
680
855
|
return `${t.object}#${t.relation}@${t.user}`;
|
|
681
856
|
}
|
|
682
857
|
}
|
|
683
858
|
}
|
|
684
859
|
for (const parent of await this.#readAll('relate', { user: userset, relation: RELATION_USERSET_RELATION, object: `${RELATION_USERSET_TYPE}:` })) {
|
|
685
860
|
// Cierre 3 de D2 · ⚪ C2-4: una arista de anidación VENCIDA no transporta nada (no conecta en FGA).
|
|
686
|
-
|
|
861
|
+
// L11: una arista con condición `foreign`/`unparseable` se sigue (más grupos que mirar: más 409, nunca menos).
|
|
862
|
+
if (parent.invalid === undefined && !isActiveExpiry(parent.validUntil ?? null, now))
|
|
687
863
|
continue;
|
|
688
864
|
if (!seen.has(parent.object)) {
|
|
689
865
|
seen.add(parent.object);
|
|
@@ -716,7 +892,7 @@ export class OpenFgaRelationsDriver {
|
|
|
716
892
|
const found = response.tuples.map((t) => t.key).find((k) => k?.user === key.user && k?.relation === key.relation && k?.object === key.object);
|
|
717
893
|
if (!found)
|
|
718
894
|
return null;
|
|
719
|
-
return { ...key,
|
|
895
|
+
return { ...key, ...storedCondition(found.condition) };
|
|
720
896
|
}
|
|
721
897
|
async unrelate(subject, relation, object, partition, options) {
|
|
722
898
|
this.#rejectTransaction('unrelate', options);
|
|
@@ -727,10 +903,10 @@ export class OpenFgaRelationsDriver {
|
|
|
727
903
|
const partitionKey = this.#keyOf(partition);
|
|
728
904
|
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
729
905
|
const user = this.#subjectUser(subject, partitionKey);
|
|
730
|
-
await this.#
|
|
906
|
+
await this.#assertModel('unrelate', [object.type]);
|
|
731
907
|
await this.#guard('unrelate', () =>
|
|
732
908
|
// Re-unrelate no-op seguro: borrar una tupla ausente se ignora.
|
|
733
|
-
this.client.write({ deletes: [{ user, relation, object: objectId }] }, IGNORE_MISSING_DELETES));
|
|
909
|
+
this.client.write({ deletes: [{ user, relation, object: objectId }] }, IGNORE_MISSING_DELETES), true);
|
|
734
910
|
}
|
|
735
911
|
/**
|
|
736
912
|
* **`deny` = UN `Write` de la tupla `denied_<rel>`** (lote D · D1): sin caducidad (D3), así que no hay renovación
|
|
@@ -738,7 +914,7 @@ export class OpenFgaRelationsDriver {
|
|
|
738
914
|
* CON condición devuelve 409 y, desde el cierre 1 de D2 (🟡 4), sale como 500 `E_AUTHZ_CONFIG` con la receta
|
|
739
915
|
* (`AuthorizationConfigError.conditionalDeny`; hasta entonces era un 503 que se repetía para siempre). Antes del `Write`: la
|
|
740
916
|
* puerta de `{ transaction }` (L-5), F-05 sobre el nombre PÚBLICO, la puerta del opt-in (fase `'write'`), sin
|
|
741
|
-
* caducidad, la partición, `*`, el sujeto y la
|
|
917
|
+
* caducidad, la partición, `*`, el sujeto y la puerta del modelo (L11). CERO llamadas si algo de eso falla.
|
|
742
918
|
*/
|
|
743
919
|
async deny(subject, relation, object, partition, options) {
|
|
744
920
|
this.#rejectTransaction('deny', options);
|
|
@@ -750,16 +926,17 @@ export class OpenFgaRelationsDriver {
|
|
|
750
926
|
const partitionKey = this.#keyOf(partition);
|
|
751
927
|
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
752
928
|
const user = this.#subjectUser(subject, partitionKey);
|
|
753
|
-
await this.#
|
|
929
|
+
await this.#assertModel('deny', [object.type]);
|
|
754
930
|
const key = { user, relation: deniedRelationOf(relation), object: objectId };
|
|
755
931
|
try {
|
|
756
|
-
await this.#guard('deny', () => this.client.write({ writes: [key] }, IGNORE_DUPLICATE_WRITES));
|
|
932
|
+
await this.#guard('deny', () => this.client.write({ writes: [key] }, IGNORE_DUPLICATE_WRITES), true);
|
|
757
933
|
}
|
|
758
934
|
catch (error) {
|
|
759
935
|
// Cierre 1 de D2 · 🟡 4: el 409 de una clave que ya existe CON condición (medido en v1.19.0: «already exists with
|
|
760
936
|
// a different condition») no es una caída. Se confirma RELEYENDO la tupla (no por el texto del mensaje): si está y
|
|
761
|
-
// lleva condición, 500 con la receta; si no, el
|
|
762
|
-
|
|
937
|
+
// lleva condición, 500 con la receta; si no, el 409 de la carrera (R3; hasta R3, 503).
|
|
938
|
+
// R3: el 409 es la clase «carrera» (409 `E_AUTHZ_WRITE_CONFLICT`, antes 503); se mira la CAUSA del SDK.
|
|
939
|
+
if (error?.cause?.statusCode === 409) {
|
|
763
940
|
const found = await this.#guard('deny', () => this.client.read(key, { consistency: this.#consistency }));
|
|
764
941
|
if (Array.isArray(found?.tuples) && found.tuples.some((t) => t?.key?.condition)) {
|
|
765
942
|
throw AuthorizationConfigError.conditionalDeny('deny', `${key.object}#${key.relation}@${key.user}`);
|
|
@@ -772,7 +949,7 @@ export class OpenFgaRelationsDriver {
|
|
|
772
949
|
* **`removeDeny` = UN delete de `denied_<rel>`** (no-op si no está). Sobre CUALQUIER tipo declarado (D8/R2-4 b):
|
|
773
950
|
* `Write` con delete no valida la relación contra el modelo (medido en v1.19.0), así que un deny vivo de un tipo
|
|
774
951
|
* que retiró el opt-in se puede borrar. Ojo: quitar un deny CONCEDE (filas declaradas S8–S10).
|
|
775
|
-
* **La
|
|
952
|
+
* **La puerta del modelo (L11, antes D-31) SÍ corre aquí** (letra corregida en el cierre 1 de D1, ⚪ 5 del auditor:
|
|
776
953
|
* la decisión 4 del informe decía que no): con el opt-in vivo y un modelo fijado sin `denied_*` ⇒ 500 antes del
|
|
777
954
|
* delete. Con el opt-in RETIRADO no hay guarda (el tipo no está en la config con `deny`) y el delete borra de verdad
|
|
778
955
|
* (medido por el auditor: `listDenies` de 1 a 0 y `check` vuelve a `true` con el modelo viejo) — retirar es reversible.
|
|
@@ -785,8 +962,8 @@ export class OpenFgaRelationsDriver {
|
|
|
785
962
|
const partitionKey = this.#keyOf(partition);
|
|
786
963
|
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
787
964
|
const user = this.#subjectUser(subject, partitionKey);
|
|
788
|
-
await this.#
|
|
789
|
-
await this.#guard('removeDeny', () => this.client.write({ deletes: [{ user, relation: deniedRelationOf(relation), object: objectId }] }, IGNORE_MISSING_DELETES));
|
|
965
|
+
await this.#assertModel('removeDeny', [object.type]);
|
|
966
|
+
await this.#guard('removeDeny', () => this.client.write({ deletes: [{ user, relation: deniedRelationOf(relation), object: objectId }] }, IGNORE_MISSING_DELETES), true);
|
|
790
967
|
}
|
|
791
968
|
/* ── Lecturas ───────────────────────────────────────────────────────────── */
|
|
792
969
|
/**
|
|
@@ -810,7 +987,7 @@ export class OpenFgaRelationsDriver {
|
|
|
810
987
|
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
811
988
|
const user = this.#subjectUser(subject, partitionKey);
|
|
812
989
|
const context = this.#checkContext(this.#now());
|
|
813
|
-
await this.#
|
|
990
|
+
await this.#assertModel('check', [object.type]);
|
|
814
991
|
const response = await this.#guard('check', () => this.client.check({ user, relation, object: objectId, context }, { consistency: this.#consistency }));
|
|
815
992
|
return response.allowed === true;
|
|
816
993
|
}
|
|
@@ -834,7 +1011,7 @@ export class OpenFgaRelationsDriver {
|
|
|
834
1011
|
const user = this.#subjectUser(subject, partitionKey);
|
|
835
1012
|
const { distinct, index } = distinctObjects(objects);
|
|
836
1013
|
const context = this.#checkContext(this.#now());
|
|
837
|
-
await this.#
|
|
1014
|
+
await this.#assertModel('checkMany', distinct.map((object) => object.type));
|
|
838
1015
|
const checks = distinct.map((object, i) => ({
|
|
839
1016
|
user,
|
|
840
1017
|
relation,
|
|
@@ -869,13 +1046,13 @@ export class OpenFgaRelationsDriver {
|
|
|
869
1046
|
for (const object of objects)
|
|
870
1047
|
assertRelationDeclared(this.#config, object, relation);
|
|
871
1048
|
assertPartition(partition);
|
|
872
|
-
this.#assertPinnedForDecideMany();
|
|
873
1049
|
const partitionKey = this.#keyOf(partition);
|
|
874
1050
|
const user = this.#subjectUser(subject, partitionKey);
|
|
875
1051
|
const { distinct, index } = distinctObjects(objects);
|
|
876
1052
|
const context = this.#checkContext(this.#now());
|
|
877
|
-
|
|
878
|
-
|
|
1053
|
+
// L11: con la puerta, el modelo ES el que genera la config (con D8 por TIPO ENTERO): un tipo en forma `deny` declara
|
|
1054
|
+
// `denied_<rel>` para TODAS sus relaciones, uno en forma `plain` para ninguna.
|
|
1055
|
+
const { denyForm } = await this.#assertModel('decideMany', distinct.map((object) => object.type));
|
|
879
1056
|
const checks = [];
|
|
880
1057
|
const plan = [];
|
|
881
1058
|
distinct.forEach((object, i) => {
|
|
@@ -884,7 +1061,7 @@ export class OpenFgaRelationsDriver {
|
|
|
884
1061
|
const type = this.#config.objectTypes.find((t) => t.type === object.type);
|
|
885
1062
|
const sources = down.filter((name) => {
|
|
886
1063
|
const includes = type?.relations.find((r) => r.name === name)?.includes ?? [];
|
|
887
|
-
return includes.length === 0 &&
|
|
1064
|
+
return includes.length === 0 && denyForm.get(object.type) === 'deny';
|
|
888
1065
|
});
|
|
889
1066
|
plan.push({ object, sources });
|
|
890
1067
|
// `correlationId` del servidor: `^[\w\d-]{1,36}$` (sin `:`): `a<i>` para R, `d<i>-<k>` para la fuente k.
|
|
@@ -910,27 +1087,9 @@ export class OpenFgaRelationsDriver {
|
|
|
910
1087
|
});
|
|
911
1088
|
return index.map((at) => answers[at]);
|
|
912
1089
|
}
|
|
913
|
-
/**
|
|
914
|
-
* **`decideMany` exige `modelId` FIJADO, con o sin tipos con deny** (lote D · cierre 1 de D1, 🟡 3 del auditor; D14
|
|
915
|
-
* extendido a la primitiva de cadena). `decideMany` decide qué `denied_*` preguntar con el memo del modelo
|
|
916
|
-
* (`#modelDenied`), y `check` lo resta el SERVIDOR con el modelo que evalúa: sin pin, el memo es el «último» del
|
|
917
|
-
* momento de la primera lectura y el servidor evalúa con el último de AHORA. Republicado un modelo con deny (flota con
|
|
918
|
-
* dos configs, reactivación del opt-in), `check` daba `false` por el deny y `decideMany` respondía `none` —la cadena
|
|
919
|
-
* del llamante seguía subiendo y concedía por el padre: fail-open en la primitiva—. Con el pin, memo y `Check` son
|
|
920
|
-
* del MISMO modelo. 500 `E_AUTHZ_CONFIG` con cero llamadas, ANTES de leer nada.
|
|
921
|
-
*/
|
|
922
|
-
#assertPinnedForDecideMany() {
|
|
923
|
-
if (this.#options.modelId)
|
|
924
|
-
return;
|
|
925
|
-
throw new AuthorizationConfigError(`OpenFgaRelationsDriver.decideMany: exige modelId FIJADO (D14, también sin tipos con deny). decideMany decide qué ` +
|
|
926
|
-
`denied_<rel> preguntar con el modelo que leyó una vez, y sin pin el servidor evalúa check con el ÚLTIMO modelo del ` +
|
|
927
|
-
`store: tras republicar un modelo con deny respondería 'none' donde check responde false, y la cadena del llamante ` +
|
|
928
|
-
`concedería por el padre. Receta: provision (openfga:provision) → fijar modelId (OPENFGA_MODEL_ID) en TODA la flota ` +
|
|
929
|
-
`→ desplegar. No se ha llamado al servidor.`);
|
|
930
|
-
}
|
|
931
1090
|
/**
|
|
932
1091
|
* **`denyPhase` — ¿admite este driver escrituras de deny en el tipo?** (lote D · D2, R2-5). La fase del opt-in EN
|
|
933
|
-
* LA CONFIG DE ESTE DRIVER y, con opt-in, el modelo FIJADO comprobado con la MISMA
|
|
1092
|
+
* LA CONFIG DE ESTE DRIVER y, con opt-in, el modelo FIJADO comprobado con la MISMA puerta L11 que `deny` (500 si
|
|
934
1093
|
* no declara `denied_<rel>`). F-05 estricto del par, antes de nada (422, cero llamadas). La llama
|
|
935
1094
|
* `reconcileRelations` en la fase de plan cuando el origen trae denies: un destino `openfga` sin `'write'` en el
|
|
936
1095
|
* tipo, o con un pin a un modelo sin el deny, NIEGA la pasada antes de la primera escritura (jamás `skipped`).
|
|
@@ -940,19 +1099,27 @@ export class OpenFgaRelationsDriver {
|
|
|
940
1099
|
const phase = denyPhaseOf(this.#config, object.type);
|
|
941
1100
|
if (phase === undefined)
|
|
942
1101
|
return null;
|
|
943
|
-
await this.#
|
|
1102
|
+
await this.#assertModel('denyPhase', [object.type]);
|
|
944
1103
|
return phase;
|
|
945
1104
|
}
|
|
946
1105
|
/** `listDenies` = `Read` por `(object, denied_<rel>)`: los denies DIRECTOS (hechos). Cualquier tipo declarado (D8). */
|
|
947
|
-
async listDenies(relation, object, partition
|
|
1106
|
+
async listDenies(relation, object, partition) {
|
|
948
1107
|
assertRelationDeclared(this.#config, object, relation);
|
|
949
1108
|
assertPartition(partition);
|
|
1109
|
+
// 7.4: sin página (≠ undefined ⇒ 500 con cero llamadas al cliente), después de F-05 y la partición.
|
|
1110
|
+
assertNoListPage('listDenies', arguments[3], 'openfga');
|
|
950
1111
|
const partitionKey = this.#keyOf(partition);
|
|
951
1112
|
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
952
1113
|
const now = this.#now();
|
|
1114
|
+
await this.#assertModel('listDenies', [object.type]);
|
|
953
1115
|
const tuples = await this.#readAll('listDenies', { object: objectId, relation: deniedRelationOf(relation) });
|
|
954
1116
|
const subjects = [];
|
|
955
1117
|
for (const t of tuples) {
|
|
1118
|
+
// L11: una condición `foreign`/`unparseable` no se lista (se avisa y se cuenta): el paquete no sabe si está viva.
|
|
1119
|
+
if (t.invalid !== undefined) {
|
|
1120
|
+
this.#warnUnparseable('listDenies', `${t.object}#${t.relation}@${t.user} (${t.invalid})`);
|
|
1121
|
+
continue;
|
|
1122
|
+
}
|
|
956
1123
|
if (t.validUntil && t.validUntil.getTime() <= now.getTime())
|
|
957
1124
|
continue;
|
|
958
1125
|
const subject = this.#userToSubject(t.user);
|
|
@@ -1006,13 +1173,14 @@ export class OpenFgaRelationsDriver {
|
|
|
1006
1173
|
* se consume entre todas — otra razón para señalar el truncado y no callar
|
|
1007
1174
|
* (documentado en el README).
|
|
1008
1175
|
*/
|
|
1009
|
-
async listObjects(subject, relation, objectType, partition
|
|
1176
|
+
async listObjects(subject, relation, objectType, partition) {
|
|
1010
1177
|
assertRelationDeclared(this.#config, { type: objectType }, relation);
|
|
1011
1178
|
assertPartition(partition);
|
|
1179
|
+
assertNoListPage('listObjects', arguments[4], 'openfga');
|
|
1012
1180
|
const partitionKey = this.#keyOf(partition);
|
|
1013
1181
|
const user = this.#subjectUser(subject, partitionKey);
|
|
1014
1182
|
const context = this.#checkContext(this.#now());
|
|
1015
|
-
await this.#
|
|
1183
|
+
await this.#assertModel('listObjects', [objectType]);
|
|
1016
1184
|
const { raw, truncated } = await this.#listObjectsPage({ user, relation, type: objectType, context });
|
|
1017
1185
|
const objects = [];
|
|
1018
1186
|
for (const full of raw) {
|
|
@@ -1034,18 +1202,26 @@ export class OpenFgaRelationsDriver {
|
|
|
1034
1202
|
* derivado por includes. `usersetSubjects: true` ⇒ los usersets
|
|
1035
1203
|
* (`group#member`) salen junto con los holders.
|
|
1036
1204
|
*/
|
|
1037
|
-
async listSubjects(relation, object, partition
|
|
1205
|
+
async listSubjects(relation, object, partition) {
|
|
1038
1206
|
assertRelationDeclared(this.#config, object, relation);
|
|
1039
1207
|
assertPartition(partition);
|
|
1208
|
+
assertNoListPage('listSubjects', arguments[3], 'openfga');
|
|
1040
1209
|
const partitionKey = this.#keyOf(partition);
|
|
1041
1210
|
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
1042
1211
|
// `Read` devuelve tuplas ESCRITAS, no evaluadas: la caducidad se filtra en
|
|
1043
1212
|
// cliente con el MISMO reloj del driver (R-15, estricta: la que vence en
|
|
1044
1213
|
// `now` ya no cuenta), como las enumeraciones del driver de roles.
|
|
1045
1214
|
const now = this.#now();
|
|
1215
|
+
await this.#assertModel('listSubjects', [object.type]);
|
|
1046
1216
|
const tuples = await this.#readAll('listSubjects', { object: objectId, relation });
|
|
1047
1217
|
const subjects = [];
|
|
1048
1218
|
for (const t of tuples) {
|
|
1219
|
+
// L11: una condición `foreign`/`unparseable` NO es un hecho vigente que listar (sería «permanente» por defecto);
|
|
1220
|
+
// se avisa y se cuenta en `diagnostics.unparseableTuples`.
|
|
1221
|
+
if (t.invalid !== undefined) {
|
|
1222
|
+
this.#warnUnparseable('listSubjects', `${t.object}#${t.relation}@${t.user} (${t.invalid})`);
|
|
1223
|
+
continue;
|
|
1224
|
+
}
|
|
1049
1225
|
if (t.validUntil && t.validUntil.getTime() <= now.getTime())
|
|
1050
1226
|
continue;
|
|
1051
1227
|
const subject = this.#userToSubject(t.user);
|
|
@@ -1085,13 +1261,26 @@ export class OpenFgaRelationsDriver {
|
|
|
1085
1261
|
* la CADUCADA también llega, con su `expiresAt` (R-15), para que el destino
|
|
1086
1262
|
* la cuente en `skipped`.
|
|
1087
1263
|
*
|
|
1264
|
+
* **Una tupla con condición AJENA llega marcada** (`invalidCondition`, L11) y
|
|
1265
|
+
* con `expiresAt` = la ÉPOCA. Eso es conservador SOLO para los allows: un
|
|
1266
|
+
* lector que ignore la marca lee el deny (o la membresía de `group` que lo
|
|
1267
|
+
* transporta) como VENCIDO y deja de negar —fail-OPEN— (cierre-2 · ⚪ C3).
|
|
1268
|
+
* Ninguna fecha sirve para los dos tipos de tupla: el lector tiene que mirar
|
|
1269
|
+
* `invalidCondition` ANTES que `expiresAt` (el plan del paquete lo hace).
|
|
1270
|
+
*
|
|
1088
1271
|
* **Coste: O(tuplas del store) por PASADA** (2.4.0-alpha.6): es un barrido
|
|
1089
1272
|
* entero por llamada, sea cual sea el tamaño de la partición (medido: 5 000
|
|
1090
1273
|
* tuplas ⇒ 50 páginas ≈ 170 ms; 15 000 ⇒ 151 páginas ≈ 0,5 s). Para K
|
|
1091
1274
|
* particiones usa `enumerateRelationsMany`: UN barrido en vez de K.
|
|
1092
1275
|
*/
|
|
1093
|
-
async enumerateRelations(partition,
|
|
1276
|
+
async enumerateRelations(partition, page) {
|
|
1094
1277
|
assertPartition(partition);
|
|
1278
|
+
// 7.4 (injerto 4): limit basura ⇒ 422; after ⇒ 500 (este driver no emite cursor: devuelve TODO en una página).
|
|
1279
|
+
// Un limit válido se IGNORA (una página, O(store)): declarado en «Limits».
|
|
1280
|
+
assertEnumeratePage('enumerateRelations', page, 'none', 'openfga');
|
|
1281
|
+
// L11: el ORIGEN de una migración pasa SOLO la parte `conditions` de la puerta (tiene que poder rescatar un store
|
|
1282
|
+
// con deriva de reglas; la vigencia de lo que emite sí depende de la condición del modelo).
|
|
1283
|
+
await this.#assertModel('enumerateRelations', [], 'conditions');
|
|
1095
1284
|
return this.#sweep([partition], 'enumerateRelations');
|
|
1096
1285
|
}
|
|
1097
1286
|
/**
|
|
@@ -1120,13 +1309,15 @@ export class OpenFgaRelationsDriver {
|
|
|
1120
1309
|
* se cuenta en `diagnostics.unparseableTuples` y se avisa. Sin particiones
|
|
1121
1310
|
* es 500 `E_AUTHZ_CONFIG` con cero lecturas: no hay «éxito vacío».
|
|
1122
1311
|
*/
|
|
1123
|
-
async enumerateRelationsMany(partitions,
|
|
1312
|
+
async enumerateRelationsMany(partitions, page) {
|
|
1124
1313
|
if (!Array.isArray(partitions) || partitions.length === 0) {
|
|
1125
1314
|
throw new AuthorizationConfigError('enumerateRelationsMany: sin particiones no hay nada que enumerar (y un barrido del store para nadie no es un éxito vacío). ' +
|
|
1126
1315
|
'Pasa al menos una partición.');
|
|
1127
1316
|
}
|
|
1128
1317
|
for (const partition of partitions)
|
|
1129
1318
|
assertPartition(partition);
|
|
1319
|
+
assertEnumeratePage('enumerateRelationsMany', page, 'none', 'openfga');
|
|
1320
|
+
await this.#assertModel('enumerateRelationsMany', [], 'conditions');
|
|
1130
1321
|
return this.#sweep(partitions, 'enumerateRelationsMany');
|
|
1131
1322
|
}
|
|
1132
1323
|
/** El barrido: UN `Read({})` paginado del store entero, repartido por la clave ENTERA de partición. */
|
|
@@ -1162,7 +1353,13 @@ export class OpenFgaRelationsDriver {
|
|
|
1162
1353
|
...storedRelationKind(key.relation),
|
|
1163
1354
|
object: { type: parsed.type, id: parsed.objectUuid },
|
|
1164
1355
|
partition,
|
|
1165
|
-
|
|
1356
|
+
// Cierre de L11 (⚪ A3.3 del auditor): con una condición que el paquete no escribe (o no sabe leer) `expiresAt` NO
|
|
1357
|
+
// es `null` sino la ÉPOCA. Cierre-2 (⚪ C3): eso es fail-CLOSED para un allow que lea un tercero sin mirar
|
|
1358
|
+
// `invalidCondition` (lo lee VENCIDO), pero fail-OPEN para un deny o una membresía que transporta uno (deja de negar);
|
|
1359
|
+
// con `null` era al revés. Ninguna fecha vale para los dos: la marca es lo que manda, y VIAJA al plan, que la
|
|
1360
|
+
// clasifica antes de mirar la fecha (`invalid-condition`, L9): nunca se migra ni se poda como sobrante.
|
|
1361
|
+
expiresAt: key.invalid !== undefined ? new Date(0) : key.validUntil,
|
|
1362
|
+
...(key.invalid !== undefined ? { invalidCondition: key.invalid } : {}),
|
|
1166
1363
|
});
|
|
1167
1364
|
}
|
|
1168
1365
|
return { tuples };
|
|
@@ -1182,6 +1379,7 @@ export class OpenFgaRelationsDriver {
|
|
|
1182
1379
|
assertObjectTypeDeclared(this.#config, object);
|
|
1183
1380
|
assertPartition(partition);
|
|
1184
1381
|
const objectId = this.#objectId(object.type, this.#keyOf(partition), object.id);
|
|
1382
|
+
await this.#assertModel('purgeObject', [object.type]);
|
|
1185
1383
|
const progress = { deleted: 0 };
|
|
1186
1384
|
try {
|
|
1187
1385
|
await this.#purgeByFilters('purgeObject', [[{ object: objectId }, `objeto ${objectId}`]], progress);
|
|
@@ -1207,6 +1405,8 @@ export class OpenFgaRelationsDriver {
|
|
|
1207
1405
|
assertSubject(subject);
|
|
1208
1406
|
const user = this.#subjectUser(subject, this.#keyOf(partition));
|
|
1209
1407
|
const types = [FACTS_GROUP_TYPE, ...this.#config.objectTypes.map((t) => t.type)];
|
|
1408
|
+
// Toca TODOS los tipos declarados: la puerta los compara todos.
|
|
1409
|
+
await this.#assertModel('purgeSubject', types);
|
|
1210
1410
|
const progress = { deleted: 0 };
|
|
1211
1411
|
try {
|
|
1212
1412
|
await this.#purgeByFilters('purgeSubject', types.map((type) => [{ user, object: `${type}:` }, `sujeto ${user} en '${type}'`]), progress);
|
|
@@ -1241,7 +1441,8 @@ export class OpenFgaRelationsDriver {
|
|
|
1241
1441
|
* estado inicial o el final** —con la única excepción DECLARADA de abajo—.
|
|
1242
1442
|
*
|
|
1243
1443
|
* - **Cabe en `RELATIONS_PURGE_WRITE_MAX` (100) ⇒ UN solo `Write`**: FGA lo aplica de forma transaccional (medido:
|
|
1244
|
-
* 101 es 400 y no se aplica nada), así que NO hay
|
|
1444
|
+
* 101 es 400 y no se aplica nada), así que NO hay PREFIJO que cortar (atómico para el ESCRITOR: un `Check`
|
|
1445
|
+
* concurrente puede observar el `Write` a medias —lector rasgado, fila «torn read» de Limits, decisión (6a)—).
|
|
1245
1446
|
* - **No cabe ⇒ tres clases, cada una en sus propios `Write`, en el orden de BORRADO del punto de paso ÚNICO
|
|
1246
1447
|
* `relationDeleteOrder`** (`src/relations/tuple_order.ts`; cierre 1 de D2 · 🟠 1: el mismo que el reconcile): (1) los allows sobre objetos que no son
|
|
1247
1448
|
* `group` —quitar un allow solo resta (D-12, monotonía)—; (2) las MEMBRESÍAS `group:*#member@<sujeto>` —una membresía
|
|
@@ -1266,7 +1467,7 @@ export class OpenFgaRelationsDriver {
|
|
|
1266
1467
|
read.push({ filter, what, keys: await this.#readAll(operation, filter) });
|
|
1267
1468
|
for (const batch of purgeWrites(read.flatMap((r) => r.keys))) {
|
|
1268
1469
|
try {
|
|
1269
|
-
await this.#guard(operation, () => this.client.deleteTuples(batch.map((k) => ({ user: k.user, relation: k.relation, object: k.object })), IGNORE_MISSING_DELETES));
|
|
1470
|
+
await this.#guard(operation, () => this.client.deleteTuples(batch.map((k) => ({ user: k.user, relation: k.relation, object: k.object })), IGNORE_MISSING_DELETES), true);
|
|
1270
1471
|
}
|
|
1271
1472
|
catch (error) {
|
|
1272
1473
|
// Cierre 2 de D1 · ⚪ N3: el 400 del tope de operaciones por `Write` del SERVIDOR no es una caída (503
|
|
@@ -1304,12 +1505,15 @@ export class OpenFgaRelationsDriver {
|
|
|
1304
1505
|
* sondea las cinco operaciones que entran por aquí con un mutante que
|
|
1305
1506
|
* «termina» en la página vacía.
|
|
1306
1507
|
*/
|
|
1307
|
-
async #readAll(operation, filter
|
|
1508
|
+
async #readAll(operation, filter,
|
|
1509
|
+
/** R1: el presupuesto del gate de anidación (antes de cada página y con las tuplas de cada una). */
|
|
1510
|
+
pageHooks) {
|
|
1308
1511
|
const keys = [];
|
|
1309
1512
|
let continuationToken;
|
|
1310
1513
|
const seenTokens = new Set();
|
|
1311
1514
|
let pages = 0;
|
|
1312
1515
|
do {
|
|
1516
|
+
pageHooks?.before();
|
|
1313
1517
|
const response = await this.#guard(operation, () => this.client.read(filter, {
|
|
1314
1518
|
pageSize: READ_PAGE_SIZE,
|
|
1315
1519
|
continuationToken,
|
|
@@ -1322,6 +1526,7 @@ export class OpenFgaRelationsDriver {
|
|
|
1322
1526
|
if (!Array.isArray(response?.tuples)) {
|
|
1323
1527
|
throw new AuthorizationBackendError('openfga-relations', operation, new Error(`la respuesta del Read (página ${pages}) no trae 'tuples'; no se toma por vacía`));
|
|
1324
1528
|
}
|
|
1529
|
+
pageHooks?.after(response.tuples.length);
|
|
1325
1530
|
for (const tuple of response.tuples) {
|
|
1326
1531
|
const key = tuple?.key;
|
|
1327
1532
|
if (!key?.user || !key?.relation || !key?.object) {
|
|
@@ -1330,12 +1535,7 @@ export class OpenFgaRelationsDriver {
|
|
|
1330
1535
|
}
|
|
1331
1536
|
// Con su caducidad (R-15): quien la lee decide si filtra (`listSubjects`),
|
|
1332
1537
|
// la emite (`enumerateRelations`) o la ignora (`purge*` borra TODO).
|
|
1333
|
-
keys.push({
|
|
1334
|
-
user: key.user,
|
|
1335
|
-
relation: key.relation,
|
|
1336
|
-
object: key.object,
|
|
1337
|
-
validUntil: toExpiryDate(key.condition?.context?.valid_until),
|
|
1338
|
-
});
|
|
1538
|
+
keys.push({ user: key.user, relation: key.relation, object: key.object, ...storedCondition(key.condition) });
|
|
1339
1539
|
}
|
|
1340
1540
|
continuationToken = response.continuation_token || undefined;
|
|
1341
1541
|
if (continuationToken) {
|
|
@@ -1364,23 +1564,18 @@ export class OpenFgaRelationsDriver {
|
|
|
1364
1564
|
// de modo que un 400 de validación de FGA o el cuerpo de un 502 que mencionara
|
|
1365
1565
|
// la palabra salía como 503 `E_AUTHZ_BACKEND_TIMEOUT` con `indeterminate: true`
|
|
1366
1566
|
// sin haber borrado nada (medido por el auditor: `alpha3-cierre-parcial.ts` B).
|
|
1367
|
-
/** Igualdad ESTRUCTURAL de dos reglas de reescritura (D-31, cierre 2 de D1 · ⚪ N4); una ausente nunca es igual. */
|
|
1368
|
-
function sameRewrite(served, generated) {
|
|
1369
|
-
if (served === undefined || generated === undefined)
|
|
1370
|
-
return false;
|
|
1371
|
-
return JSON.stringify(canonicalRewrite(served)) === JSON.stringify(canonicalRewrite(generated));
|
|
1372
|
-
}
|
|
1373
1567
|
/** La LETRA del 500 de N3 (fijada por `relations_openfga.spec`, «⚪ N3 · …»). */
|
|
1374
1568
|
function purgeWriteLimitError(operation, size, deleted, cause) {
|
|
1375
1569
|
const sdk = cause?.cause?.apiErrorMessage;
|
|
1376
|
-
|
|
1570
|
+
// R3: es un fallo de DESPLIEGUE (transitorio para el relay), como el que ya clasificó `#guard`.
|
|
1571
|
+
return markDeploymentFailure(new AuthorizationConfigError(`OpenFgaRelationsDriver: ${operation}: el servidor OpenFGA rechazó un Write de ${size} tuplas por su tope de ` +
|
|
1377
1572
|
`operaciones por Write (exceeded_entity_limit${typeof sdk === 'string' ? `: «${sdk}»` : ''}). El driver parte las ` +
|
|
1378
1573
|
`purgas en RELATIONS_PURGE_WRITE_MAX = ${RELATIONS_PURGE_WRITE_MAX} y exige --max-tuples-per-write ≥ ` +
|
|
1379
1574
|
`${RELATIONS_PURGE_WRITE_MAX} en el servidor (OPENFGA_MAX_TUPLES_PER_WRITE; el default es ${RELATIONS_PURGE_WRITE_MAX}). ` +
|
|
1380
1575
|
`No es transitorio: repetida, la purga vuelve a fallar igual. ` +
|
|
1381
1576
|
(deleted > 0
|
|
1382
1577
|
? `Ya se habían borrado ${deleted} tuplas en Write anteriores: la purga quedó A MEDIAS (escritura parcial).`
|
|
1383
|
-
: 'No se borró nada.'), { cause });
|
|
1578
|
+
: 'No se borró nada.'), { cause }));
|
|
1384
1579
|
}
|
|
1385
1580
|
/**
|
|
1386
1581
|
* **Los `Write` de una purga, en orden** (lote D · cierre 1 de D1, 🟠 1; ver `#purgeByFilters`): uno solo si todo cabe
|
|
@@ -1452,4 +1647,3 @@ function annotateRacedDeny(error, membership, recheck, applied = false) {
|
|
|
1452
1647
|
}
|
|
1453
1648
|
return error;
|
|
1454
1649
|
}
|
|
1455
|
-
//# sourceMappingURL=openfga_relations_driver.js.map
|