@jantstack/adonis-authz 2.5.0-alpha.2 → 2.5.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +743 -28
- package/build/commands/authz_catalog_diff.d.ts +0 -1
- package/build/commands/authz_catalog_diff.js +0 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts +0 -1
- package/build/commands/authz_catalog_prune_orphans.js +0 -1
- package/build/commands/authz_catalog_sync.d.ts +0 -1
- package/build/commands/authz_catalog_sync.js +0 -1
- package/build/commands/authz_freeze.d.ts +0 -1
- package/build/commands/authz_freeze.js +0 -1
- package/build/commands/authz_reconcile.d.ts +0 -1
- package/build/commands/authz_reconcile.js +0 -1
- package/build/commands/authz_relations_reconcile.d.ts +6 -1
- package/build/commands/authz_relations_reconcile.js +163 -5
- package/build/commands/authz_scopes_relay.d.ts +0 -1
- package/build/commands/authz_scopes_relay.js +49 -5
- package/build/commands/authz_scopes_requeue.d.ts +0 -1
- package/build/commands/authz_scopes_requeue.js +14 -2
- package/build/commands/authz_unfreeze.d.ts +0 -1
- package/build/commands/authz_unfreeze.js +0 -1
- package/build/commands/main.d.ts +0 -1
- package/build/commands/main.js +0 -1
- package/build/commands/openfga_provision.d.ts +72 -1
- package/build/commands/openfga_provision.js +236 -4
- package/build/configure.d.ts +0 -1
- package/build/configure.js +0 -1
- package/build/index.d.ts +11 -9
- package/build/index.js +12 -5
- package/build/providers/authz_provider.d.ts +0 -1
- package/build/providers/authz_provider.js +0 -1
- package/build/services/main.d.ts +0 -1
- package/build/services/main.js +0 -1
- package/build/services/relations.d.ts +0 -1
- package/build/services/relations.js +0 -1
- package/build/src/catalog/catalog.d.ts +0 -1
- package/build/src/catalog/catalog.js +0 -1
- package/build/src/catalog/catalog_cache.d.ts +13 -1
- package/build/src/catalog/catalog_cache.js +25 -1
- package/build/src/clock.d.ts +0 -1
- package/build/src/clock.js +0 -1
- package/build/src/define_config.d.ts +0 -1
- package/build/src/define_config.js +24 -1
- package/build/src/drivers/database_driver.d.ts +6 -1
- package/build/src/drivers/database_driver.js +22 -2
- package/build/src/drivers/database_relations_driver.d.ts +46 -5
- package/build/src/drivers/database_relations_driver.js +297 -59
- package/build/src/drivers/openfga_driver.d.ts +159 -26
- package/build/src/drivers/openfga_driver.js +980 -231
- package/build/src/drivers/openfga_facts.d.ts +112 -1
- package/build/src/drivers/openfga_facts.js +351 -11
- package/build/src/drivers/openfga_relations_driver.d.ts +109 -8
- package/build/src/drivers/openfga_relations_driver.js +817 -53
- package/build/src/errors.d.ts +233 -1
- package/build/src/errors.js +283 -1
- package/build/src/expiry.d.ts +51 -2
- package/build/src/expiry.js +96 -2
- package/build/src/freeze.d.ts +0 -1
- package/build/src/freeze.js +0 -1
- package/build/src/hierarchical_resolver.d.ts +0 -1
- package/build/src/hierarchical_resolver.js +0 -1
- package/build/src/http/app_access_middleware.d.ts +0 -1
- package/build/src/http/app_access_middleware.js +0 -1
- package/build/src/http/resource_access_middleware.d.ts +0 -1
- package/build/src/http/resource_access_middleware.js +0 -1
- package/build/src/identity.d.ts +2 -1
- package/build/src/identity.js +4 -1
- package/build/src/manager.d.ts +0 -1
- package/build/src/manager.js +164 -31
- package/build/src/memoize_ancestors.d.ts +0 -1
- package/build/src/memoize_ancestors.js +0 -1
- package/build/src/models/authz_assignment.d.ts +5 -6
- package/build/src/models/authz_assignment.js +0 -1
- package/build/src/models/authz_deny.d.ts +5 -6
- package/build/src/models/authz_deny.js +0 -1
- package/build/src/models/authz_permission.d.ts +5 -6
- package/build/src/models/authz_permission.js +0 -1
- package/build/src/models/authz_role.d.ts +5 -6
- package/build/src/models/authz_role.js +0 -1
- package/build/src/models/authz_role_permission.d.ts +5 -6
- package/build/src/models/authz_role_permission.js +0 -1
- package/build/src/openfga.d.ts +3 -4
- package/build/src/openfga.js +5 -3
- package/build/src/reconcile.d.ts +0 -1
- package/build/src/reconcile.js +0 -1
- package/build/src/relation_partition_trigger.d.ts +0 -1
- package/build/src/relation_partition_trigger.js +0 -1
- package/build/src/relations/check_many.d.ts +1 -2
- package/build/src/relations/check_many.js +6 -5
- package/build/src/relations/define_relations_config.d.ts +140 -5
- package/build/src/relations/define_relations_config.js +205 -6
- package/build/src/relations/deny_reach.d.ts +102 -0
- package/build/src/relations/deny_reach.js +230 -0
- package/build/src/relations/group_depth.d.ts +215 -0
- package/build/src/relations/group_depth.js +453 -0
- package/build/src/relations/manager.d.ts +46 -8
- package/build/src/relations/manager.js +178 -16
- package/build/src/relations/page.d.ts +19 -0
- package/build/src/relations/page.js +74 -0
- package/build/src/relations/reconcile.d.ts +274 -2
- package/build/src/relations/reconcile.js +1006 -162
- package/build/src/relations/tuple_key.d.ts +0 -1
- package/build/src/relations/tuple_key.js +0 -1
- package/build/src/relations/tuple_order.d.ts +127 -0
- package/build/src/relations/tuple_order.js +599 -0
- package/build/src/relations_config_store.d.ts +57 -2
- package/build/src/relations_config_store.js +155 -5
- package/build/src/relations_reconcile.d.ts +0 -1
- package/build/src/relations_reconcile.js +0 -1
- package/build/src/scope_outbox.d.ts +0 -1
- package/build/src/scope_outbox.js +0 -1
- package/build/src/shared/backend_guard.d.ts +60 -1
- package/build/src/shared/backend_guard.js +179 -2
- package/build/src/shared/sql_expiry.d.ts +0 -1
- package/build/src/shared/sql_expiry.js +0 -1
- package/build/src/shared/transaction_guard.d.ts +0 -1
- package/build/src/shared/transaction_guard.js +0 -1
- package/build/src/sql_descendants.d.ts +0 -1
- package/build/src/sql_descendants.js +0 -1
- package/build/src/testing/contract.d.ts +7 -1
- package/build/src/testing/contract.js +77 -1
- package/build/src/testing/main.d.ts +1 -2
- package/build/src/testing/main.js +1 -2
- package/build/src/testing/migration_contract.d.ts +0 -1
- package/build/src/testing/migration_contract.js +0 -1
- package/build/src/testing/relations_contract.d.ts +25 -1
- package/build/src/testing/relations_contract.js +996 -49
- package/build/src/testing/relations_reconcile_contract.d.ts +9 -1
- package/build/src/testing/relations_reconcile_contract.js +207 -1
- package/build/src/testing/scope_outbox_contract.d.ts +0 -1
- package/build/src/testing/scope_outbox_contract.js +0 -1
- package/build/src/testing/scope_tree.d.ts +0 -1
- package/build/src/testing/scope_tree.js +0 -1
- package/build/src/testing/test_clock.d.ts +0 -1
- package/build/src/testing/test_clock.js +0 -1
- package/build/src/traits/authz_scopes.d.ts +0 -1
- package/build/src/traits/authz_scopes.js +0 -1
- package/build/src/traits/has_uuid.d.ts +5 -6
- package/build/src/traits/has_uuid.js +0 -1
- package/build/src/types.d.ts +170 -15
- package/build/src/types.js +6 -1
- package/build/stubs/config/authorization.stub +22 -6
- package/build/stubs/main.d.ts +0 -1
- package/build/stubs/main.js +0 -1
- package/package.json +4 -3
- package/build/commands/authz_catalog_diff.d.ts.map +0 -1
- package/build/commands/authz_catalog_diff.js.map +0 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts.map +0 -1
- package/build/commands/authz_catalog_prune_orphans.js.map +0 -1
- package/build/commands/authz_catalog_sync.d.ts.map +0 -1
- package/build/commands/authz_catalog_sync.js.map +0 -1
- package/build/commands/authz_freeze.d.ts.map +0 -1
- package/build/commands/authz_freeze.js.map +0 -1
- package/build/commands/authz_reconcile.d.ts.map +0 -1
- package/build/commands/authz_reconcile.js.map +0 -1
- package/build/commands/authz_relations_reconcile.d.ts.map +0 -1
- package/build/commands/authz_relations_reconcile.js.map +0 -1
- package/build/commands/authz_scopes_relay.d.ts.map +0 -1
- package/build/commands/authz_scopes_relay.js.map +0 -1
- package/build/commands/authz_scopes_requeue.d.ts.map +0 -1
- package/build/commands/authz_scopes_requeue.js.map +0 -1
- package/build/commands/authz_unfreeze.d.ts.map +0 -1
- package/build/commands/authz_unfreeze.js.map +0 -1
- package/build/commands/main.d.ts.map +0 -1
- package/build/commands/main.js.map +0 -1
- package/build/commands/openfga_provision.d.ts.map +0 -1
- package/build/commands/openfga_provision.js.map +0 -1
- package/build/configure.d.ts.map +0 -1
- package/build/configure.js.map +0 -1
- package/build/index.d.ts.map +0 -1
- package/build/index.js.map +0 -1
- package/build/providers/authz_provider.d.ts.map +0 -1
- package/build/providers/authz_provider.js.map +0 -1
- package/build/services/main.d.ts.map +0 -1
- package/build/services/main.js.map +0 -1
- package/build/services/relations.d.ts.map +0 -1
- package/build/services/relations.js.map +0 -1
- package/build/src/catalog/catalog.d.ts.map +0 -1
- package/build/src/catalog/catalog.js.map +0 -1
- package/build/src/catalog/catalog_cache.d.ts.map +0 -1
- package/build/src/catalog/catalog_cache.js.map +0 -1
- package/build/src/clock.d.ts.map +0 -1
- package/build/src/clock.js.map +0 -1
- package/build/src/define_config.d.ts.map +0 -1
- package/build/src/define_config.js.map +0 -1
- package/build/src/drivers/database_driver.d.ts.map +0 -1
- package/build/src/drivers/database_driver.js.map +0 -1
- package/build/src/drivers/database_relations_driver.d.ts.map +0 -1
- package/build/src/drivers/database_relations_driver.js.map +0 -1
- package/build/src/drivers/openfga_driver.d.ts.map +0 -1
- package/build/src/drivers/openfga_driver.js.map +0 -1
- package/build/src/drivers/openfga_facts.d.ts.map +0 -1
- package/build/src/drivers/openfga_facts.js.map +0 -1
- package/build/src/drivers/openfga_relations_driver.d.ts.map +0 -1
- package/build/src/drivers/openfga_relations_driver.js.map +0 -1
- package/build/src/errors.d.ts.map +0 -1
- package/build/src/errors.js.map +0 -1
- package/build/src/expiry.d.ts.map +0 -1
- package/build/src/expiry.js.map +0 -1
- package/build/src/freeze.d.ts.map +0 -1
- package/build/src/freeze.js.map +0 -1
- package/build/src/hierarchical_resolver.d.ts.map +0 -1
- package/build/src/hierarchical_resolver.js.map +0 -1
- package/build/src/http/app_access_middleware.d.ts.map +0 -1
- package/build/src/http/app_access_middleware.js.map +0 -1
- package/build/src/http/resource_access_middleware.d.ts.map +0 -1
- package/build/src/http/resource_access_middleware.js.map +0 -1
- package/build/src/identity.d.ts.map +0 -1
- package/build/src/identity.js.map +0 -1
- package/build/src/manager.d.ts.map +0 -1
- package/build/src/manager.js.map +0 -1
- package/build/src/memoize_ancestors.d.ts.map +0 -1
- package/build/src/memoize_ancestors.js.map +0 -1
- package/build/src/models/authz_assignment.d.ts.map +0 -1
- package/build/src/models/authz_assignment.js.map +0 -1
- package/build/src/models/authz_deny.d.ts.map +0 -1
- package/build/src/models/authz_deny.js.map +0 -1
- package/build/src/models/authz_permission.d.ts.map +0 -1
- package/build/src/models/authz_permission.js.map +0 -1
- package/build/src/models/authz_role.d.ts.map +0 -1
- package/build/src/models/authz_role.js.map +0 -1
- package/build/src/models/authz_role_permission.d.ts.map +0 -1
- package/build/src/models/authz_role_permission.js.map +0 -1
- package/build/src/openfga.d.ts.map +0 -1
- package/build/src/openfga.js.map +0 -1
- package/build/src/reconcile.d.ts.map +0 -1
- package/build/src/reconcile.js.map +0 -1
- package/build/src/relation_partition_trigger.d.ts.map +0 -1
- package/build/src/relation_partition_trigger.js.map +0 -1
- package/build/src/relations/check_many.d.ts.map +0 -1
- package/build/src/relations/check_many.js.map +0 -1
- package/build/src/relations/define_relations_config.d.ts.map +0 -1
- package/build/src/relations/define_relations_config.js.map +0 -1
- package/build/src/relations/manager.d.ts.map +0 -1
- package/build/src/relations/manager.js.map +0 -1
- package/build/src/relations/reconcile.d.ts.map +0 -1
- package/build/src/relations/reconcile.js.map +0 -1
- package/build/src/relations/tuple_key.d.ts.map +0 -1
- package/build/src/relations/tuple_key.js.map +0 -1
- package/build/src/relations_config_store.d.ts.map +0 -1
- package/build/src/relations_config_store.js.map +0 -1
- package/build/src/relations_reconcile.d.ts.map +0 -1
- package/build/src/relations_reconcile.js.map +0 -1
- package/build/src/scope_outbox.d.ts.map +0 -1
- package/build/src/scope_outbox.js.map +0 -1
- package/build/src/shared/backend_guard.d.ts.map +0 -1
- package/build/src/shared/backend_guard.js.map +0 -1
- package/build/src/shared/sql_expiry.d.ts.map +0 -1
- package/build/src/shared/sql_expiry.js.map +0 -1
- package/build/src/shared/transaction_guard.d.ts.map +0 -1
- package/build/src/shared/transaction_guard.js.map +0 -1
- package/build/src/sql_descendants.d.ts.map +0 -1
- package/build/src/sql_descendants.js.map +0 -1
- package/build/src/testing/contract.d.ts.map +0 -1
- package/build/src/testing/contract.js.map +0 -1
- package/build/src/testing/main.d.ts.map +0 -1
- package/build/src/testing/main.js.map +0 -1
- package/build/src/testing/migration_contract.d.ts.map +0 -1
- package/build/src/testing/migration_contract.js.map +0 -1
- package/build/src/testing/relations_contract.d.ts.map +0 -1
- package/build/src/testing/relations_contract.js.map +0 -1
- package/build/src/testing/relations_reconcile_contract.d.ts.map +0 -1
- package/build/src/testing/relations_reconcile_contract.js.map +0 -1
- package/build/src/testing/scope_outbox_contract.d.ts.map +0 -1
- package/build/src/testing/scope_outbox_contract.js.map +0 -1
- package/build/src/testing/scope_tree.d.ts.map +0 -1
- package/build/src/testing/scope_tree.js.map +0 -1
- package/build/src/testing/test_clock.d.ts.map +0 -1
- package/build/src/testing/test_clock.js.map +0 -1
- package/build/src/traits/authz_scopes.d.ts.map +0 -1
- package/build/src/traits/authz_scopes.js.map +0 -1
- package/build/src/traits/has_uuid.d.ts.map +0 -1
- package/build/src/traits/has_uuid.js.map +0 -1
- package/build/src/types.d.ts.map +0 -1
- package/build/src/types.js.map +0 -1
- package/build/stubs/main.d.ts.map +0 -1
- package/build/stubs/main.js.map +0 -1
|
@@ -29,23 +29,42 @@
|
|
|
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, 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 { 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 } from './openfga_facts.js';
|
|
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 } from '../relations/define_relations_config.js';
|
|
45
|
-
import {
|
|
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';
|
|
46
|
+
import { relationDeleteOrder, relationOrderClass } from '../relations/tuple_order.js';
|
|
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';
|
|
46
49
|
export const DEFAULT_TIMEOUT_MS = 5_000;
|
|
47
|
-
/**
|
|
48
|
-
|
|
50
|
+
/**
|
|
51
|
+
* **Tope de operaciones por `Write` en FGA** — `--max-tuples-per-write` del servidor, default 100. MEDIDO en v1.19.0
|
|
52
|
+
* (`openfga-dev`, lote D · cierre 1 de D1): 100 deletes en un `Write` entran; 101 —o 50 writes + 51 deletes— es 400
|
|
53
|
+
* «The number of write operations exceeds the allowed limit of 100» y NO se aplica nada (el `Write` es transaccional).
|
|
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»).
|
|
58
|
+
* **Requisito del despliegue: `--max-tuples-per-write` ≥ 100** (README, «Limits»). Un servidor con el tope MÁS BAJO
|
|
59
|
+
* responde 400 `exceeded_entity_limit` al primer `Write` que lo pase, y ése puede no ser el primero de la purga (con
|
|
60
|
+
* tope 50 y `[3 allows][60 membresías][40 denies]` el `Write` de 3 ya se aplicó): el driver lo dice como **500
|
|
61
|
+
* `E_AUTHZ_CONFIG`** nombrando el requisito —no como un 503 que parece transitorio: repetida, la purga vuelve a pedir
|
|
62
|
+
* un `Write` de más de lo que el servidor admite y no termina nunca— y, si ya borró algo, **marcado como escritura
|
|
63
|
+
* parcial** (`indeterminate: true`, invariante 13). Cierre 2 de D1 · ⚪ N3 del auditor (la letra anterior, «503 sin
|
|
64
|
+
* haber borrado nada», era falsa con varios `Write`). Uno con el tope más alto no cambia nada (el driver sigue partiendo
|
|
65
|
+
* en 100). Sin opción para bajarlo: la cota es del servidor y el remedio es su arranque.
|
|
66
|
+
*/
|
|
67
|
+
export const RELATIONS_PURGE_WRITE_MAX = 100;
|
|
49
68
|
/** Tamaño de página de `Read` (máximo del servidor). */
|
|
50
69
|
const READ_PAGE_SIZE = 100;
|
|
51
70
|
/**
|
|
@@ -112,6 +131,42 @@ const IGNORE_DUPLICATE_WRITES = {
|
|
|
112
131
|
const IGNORE_MISSING_DELETES = {
|
|
113
132
|
conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
|
|
114
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
|
+
}
|
|
115
170
|
export class OpenFgaRelationsDriver {
|
|
116
171
|
/**
|
|
117
172
|
* Lo que declara (cada valor tiene su cara en `runRelationsDriverContract`):
|
|
@@ -146,6 +201,13 @@ export class OpenFgaRelationsDriver {
|
|
|
146
201
|
enumerateRelations: true,
|
|
147
202
|
listObjectsTruncation: true,
|
|
148
203
|
injectableClock: true,
|
|
204
|
+
// Lote D · D1: el deny es la relación `denied_<rel>` del modelo fusionado (`X = … but not denied_X`); el corte lo
|
|
205
|
+
// evalúa el SERVIDOR en `check`/`batchCheck`/`ListObjects` (una lista cortada es un subconjunto: fail-closed).
|
|
206
|
+
relationDenies: true,
|
|
207
|
+
// Cierre 2 de D2 · 🟠 N1: `false` EXPLÍCITO — FGA no reemplaza la misma clave en un `Write` (M5): `relate` renueva
|
|
208
|
+
// con DOS (delete, write). Por eso NIEGA renovar una membresía que transporta un deny (409
|
|
209
|
+
// `E_AUTHZ_RENEWAL_UNSAFE`, cero escrituras) y marca parcial la renovación permitida cuyo 2.º `Write` falla.
|
|
210
|
+
atomicRenewal: false,
|
|
149
211
|
// L-2/L-5 (panel `{trx}`, (C)): `false` EXPLÍCITO, y no puede ser otra
|
|
150
212
|
// cosa — una tupla no entra en una transacción SQL: el store es otro
|
|
151
213
|
// servicio y no hay 2PC, así que «los dos o ninguno» con la transacción
|
|
@@ -161,6 +223,8 @@ export class OpenFgaRelationsDriver {
|
|
|
161
223
|
// ajusta (usa `database` para lo que tenga que ir en su transacción, o
|
|
162
224
|
// `requireTransactionalWrites` para fallar al arrancar).
|
|
163
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,
|
|
164
228
|
});
|
|
165
229
|
client;
|
|
166
230
|
#config;
|
|
@@ -174,8 +238,30 @@ export class OpenFgaRelationsDriver {
|
|
|
174
238
|
#listObjectsDeadlineMs;
|
|
175
239
|
#monotonic;
|
|
176
240
|
#logger;
|
|
241
|
+
/**
|
|
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).
|
|
245
|
+
*/
|
|
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 = {};
|
|
177
257
|
diagnostics = { unparseableTuples: 0 };
|
|
178
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');
|
|
179
265
|
this.#config = config;
|
|
180
266
|
this.#options = options;
|
|
181
267
|
this.#now = options.now ?? systemClock;
|
|
@@ -228,6 +314,10 @@ export class OpenFgaRelationsDriver {
|
|
|
228
314
|
}
|
|
229
315
|
this.#monotonic = monotonic ?? (() => performance.now());
|
|
230
316
|
this.#logger = options.logger ?? console;
|
|
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);
|
|
231
321
|
this.client = new OpenFgaClient({
|
|
232
322
|
apiUrl: options.apiUrl,
|
|
233
323
|
storeId: options.storeId,
|
|
@@ -249,6 +339,9 @@ export class OpenFgaRelationsDriver {
|
|
|
249
339
|
}
|
|
250
340
|
const view = new OpenFgaRelationsDriver(this.#config, { ...this.#options, now });
|
|
251
341
|
view.client = this.client;
|
|
342
|
+
// La puerta L11 es del PROCESO: la vista comparte la lectura del modelo (el MISMO objeto, que se muta).
|
|
343
|
+
view.#modelMemo = this.#modelMemo;
|
|
344
|
+
view.#groupDepthMemo = this.#groupDepthMemo;
|
|
252
345
|
return view;
|
|
253
346
|
}
|
|
254
347
|
/** El `context` de todo `Check`/`ListObjects`: la condición `not_expired` se evalúa contra este instante (un `now` por operación). */
|
|
@@ -267,21 +360,28 @@ export class OpenFgaRelationsDriver {
|
|
|
267
360
|
* que dice «venció el deadline» —503 `E_AUTHZ_BACKEND_TIMEOUT`, indeterminado—,
|
|
268
361
|
* y para lo que el cliente lanza por su cuenta el `isTimeoutLike` ESTRICTO
|
|
269
362
|
* de `backend_guard.ts` (`ECONNABORTED`/`ETIMEDOUT`/`.timeout` numérico/
|
|
270
|
-
* `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
|
|
271
366
|
* driver duplicaba la función con una rama por SUBSTRING del mensaje
|
|
272
367
|
* (`/timeout/i`): un error que decía «no es timeout» salía como timeout con
|
|
273
368
|
* `indeterminate: true` sin haber borrado nada, y el docblock afirmaba en
|
|
274
369
|
* falso «mismo criterio que roles».
|
|
275
370
|
*/
|
|
276
|
-
async #guard(operation, fn) {
|
|
371
|
+
async #guard(operation, fn, write = false) {
|
|
277
372
|
const fail = (error) => {
|
|
278
373
|
// Un error del paquete (422/500 ya clasificado) se propaga tal cual.
|
|
279
374
|
if (error instanceof InvalidIdentityError || error instanceof PurgeIncompleteError)
|
|
280
375
|
return error;
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
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;
|
|
285
385
|
};
|
|
286
386
|
let pending;
|
|
287
387
|
try {
|
|
@@ -294,6 +394,69 @@ export class OpenFgaRelationsDriver {
|
|
|
294
394
|
throw fail(error);
|
|
295
395
|
}), this.#timeoutMs, () => new AuthorizationBackendTimeoutError('openfga-relations', operation, this.#timeoutMs));
|
|
296
396
|
}
|
|
397
|
+
/* ── L11: el modelo FIJADO es el que genera la config (una puerta, por tipo) ─────────────────────────── */
|
|
398
|
+
/**
|
|
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.
|
|
405
|
+
*/
|
|
406
|
+
#modelGate() {
|
|
407
|
+
const memo = this.#modelMemo;
|
|
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');
|
|
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;
|
|
428
|
+
throw error;
|
|
429
|
+
});
|
|
430
|
+
memo.gate = pending;
|
|
431
|
+
pending.catch(() => { });
|
|
432
|
+
return pending;
|
|
433
|
+
}
|
|
434
|
+
/**
|
|
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).
|
|
447
|
+
*/
|
|
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;
|
|
459
|
+
}
|
|
297
460
|
/* ── Composición y parseo de ids (⚪5: partición por string en el id) ────── */
|
|
298
461
|
/** `<type>:<scopeKey(partition)>|<objectUuid>`. */
|
|
299
462
|
#objectId(objectType, partitionKey, objectUuid) {
|
|
@@ -380,8 +543,12 @@ export class OpenFgaRelationsDriver {
|
|
|
380
543
|
* - presente con la caducidad que toca ⇒ no-op (idempotente, invariante 6);
|
|
381
544
|
* - presente con OTRA ⇒ delete + write en DOS llamadas (FGA no admite
|
|
382
545
|
* delete+write de la misma clave en un `Write`, ni reescribir su
|
|
383
|
-
* condición): entre ambas
|
|
384
|
-
*
|
|
546
|
+
* condición): entre ambas la tupla NO está. Para un allow eso solo
|
|
547
|
+
* quita (fail-closed, la misma ventana que el `grant` de roles); para una
|
|
548
|
+
* membresía `group:*#member` que TRANSPORTA un deny, CONCEDERÍA — desde el
|
|
549
|
+
* cierre 2 de D2 (🟠 N1) esa renovación es 409 `E_AUTHZ_RENEWAL_UNSAFE`
|
|
550
|
+
* con cero escrituras (`#carriedDeny`), y si el 2.º `Write` de una
|
|
551
|
+
* renovación permitida falla el error va marcado PARCIAL (`markPartialWrite`).
|
|
385
552
|
*/
|
|
386
553
|
/**
|
|
387
554
|
* **`{ transaction }` se rechaza también AQUÍ** (L-5, defensa en
|
|
@@ -413,18 +580,303 @@ export class OpenFgaRelationsDriver {
|
|
|
413
580
|
const user = this.#subjectUser(subject, partitionKey);
|
|
414
581
|
const key = { user, relation, object: objectId };
|
|
415
582
|
const now = this.#now();
|
|
583
|
+
await this.#assertModel('relate', [object.type]);
|
|
416
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
|
+
}
|
|
417
591
|
const previous = current ? current.validUntil : null;
|
|
418
592
|
const expiresAt = resolveGrantExpiry(previous, options?.expiresAt, now);
|
|
419
593
|
if (current && sameInstant(previous, expiresAt))
|
|
420
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
|
+
}
|
|
421
608
|
const tuple = expiresAt
|
|
422
609
|
? { ...key, condition: { name: FACTS_EXPIRY_CONDITION, context: { valid_until: expiresAt.toISOString() } } }
|
|
423
610
|
: key;
|
|
424
611
|
if (current) {
|
|
425
|
-
|
|
612
|
+
// Cierre 2 de D2 · 🟠 N1 (decisión del dueño 2026-10-15 (9)): una membresía que TRANSPORTA un deny no se renueva
|
|
613
|
+
// con dos `Write` — entre el delete y el write el sujeto quedaría sin el deny y concedería. 409, CERO escrituras.
|
|
614
|
+
// Cierre 3 de D2 · ⚪ C2-4: una membresía YA VENCIDA no conecta nada (el deny ya no alcanza al sujeto por ella), así
|
|
615
|
+
// que no hay ventana que proteger: se reescribe sin comprobar portadora (hasta el cierre 3 era un 409 espurio).
|
|
616
|
+
const guarded = object.type === RELATION_USERSET_TYPE && isActiveExpiry(previous, now);
|
|
617
|
+
const membership = `${objectId}#${relation}@${user}`;
|
|
618
|
+
if (guarded) {
|
|
619
|
+
const carried = await this.#carriedDeny(objectId, partitionKey, now);
|
|
620
|
+
if (carried !== null)
|
|
621
|
+
throw RenewalUnsafeError.carrier(membership, carried);
|
|
622
|
+
}
|
|
623
|
+
await this.#guard('relate', () => this.client.write({ deletes: [key] }, IGNORE_MISSING_DELETES), true);
|
|
624
|
+
// Cierre 2 de D2 · 🟠 N1 (b): el delete YA entró — si el write falla, la escritura es PARCIAL (invariante 13: el
|
|
625
|
+
// manager notifica `indeterminate: true`); hasta el cierre era un 503 «sin escribir» con la tupla borrada.
|
|
626
|
+
try {
|
|
627
|
+
await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES), true);
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
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
|
|
631
|
+
// grupo (TOCTOU), ese deny ya no alcanza al sujeto —y no lo alcanzará hasta que la membresía se reescriba—: el
|
|
632
|
+
// error parcial lo NOMBRA (`carriedDeny`) con la receta. Mejor esfuerzo: si la re-lectura también falla, se dice.
|
|
633
|
+
if (guarded && error !== null && typeof error === 'object') {
|
|
634
|
+
annotateRacedDeny(error, membership, await this.#recheckCarried(objectId, partitionKey, now));
|
|
635
|
+
}
|
|
636
|
+
throw error !== null && typeof error === 'object' ? markPartialWrite(error) : error;
|
|
637
|
+
}
|
|
638
|
+
// Cierre 3 de D2 · 🟡 C2-3: la RE-COMPROBACIÓN. Un deny que entró entre `#carriedDeny` y los dos `Write` no alcanzó
|
|
639
|
+
// al sujeto durante la ventana; la renovación se aplicó (el estado final es correcto) y el error lo dice, marcado
|
|
640
|
+
// parcial (el manager notifica `indeterminate: true`). La ventana NO se cierra (FGA no tiene una primitiva para
|
|
641
|
+
// serializar `deny` contra la renovación): deja de ser muda. README «Every other renewal stays two `Write`s».
|
|
642
|
+
if (guarded) {
|
|
643
|
+
const recheck = await this.#recheckCarried(objectId, partitionKey, now);
|
|
644
|
+
if (recheck.deny !== null)
|
|
645
|
+
throw markPartialWrite(RenewalRacedError.raced(membership, recheck.deny));
|
|
646
|
+
if (recheck.error !== undefined) {
|
|
647
|
+
const error = recheck.error;
|
|
648
|
+
throw error !== null && typeof error === 'object'
|
|
649
|
+
? markPartialWrite(annotateRacedDeny(error, membership, recheck, true))
|
|
650
|
+
: error;
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
return;
|
|
654
|
+
}
|
|
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
|
+
}
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* **¿Transporta el grupo un deny vivo?** (cierre 2 de D2 · 🟠 N1): el `#member` de `group` —o el de un grupo que lo
|
|
833
|
+
* contiene, transitivamente (`group:G#member@group:H#member`: los miembros de H heredan los denies de G)— es sujeto de
|
|
834
|
+
* una tupla `denied_*` de la partición, en algún tipo con opt-in del deny. Devuelve la primera que encuentra (para la
|
|
835
|
+
* letra) o `null`. Coste: por cada grupo del cierre hacia arriba, un `Read` por `user` + tipo (el filtro que FGA admite:
|
|
836
|
+
* `user` y `object` tipo) por cada tipo con deny, y uno de sus padres. Solo corre en una RENOVACIÓN de membresía.
|
|
837
|
+
*/
|
|
838
|
+
async #carriedDeny(groupObjectId, partitionKey, now) {
|
|
839
|
+
const denyTypes = this.#config.objectTypes.filter((t) => denyPhaseOf(this.#config, t.type) !== undefined).map((t) => t.type);
|
|
840
|
+
if (denyTypes.length === 0)
|
|
841
|
+
return null;
|
|
842
|
+
const seen = new Set([groupObjectId]);
|
|
843
|
+
const queue = [groupObjectId];
|
|
844
|
+
while (queue.length > 0) {
|
|
845
|
+
const userset = `${queue.shift()}#${RELATION_USERSET_RELATION}`;
|
|
846
|
+
for (const type of denyTypes) {
|
|
847
|
+
for (const t of await this.#readAll('relate', { user: userset, object: `${type}:` })) {
|
|
848
|
+
// Cierre 6 de D2 · 🟡 D5-1: un deny VENCIDO no transporta nada (la MISMA regla que C2-4 para la arista y que el
|
|
849
|
+
// plan para toda tupla del destino: vencida = ausente). Contarlo hacía de la pasada REAL un 409 que el dry-run
|
|
850
|
+
// no anunciaba, y las particiones siguientes no migraban.
|
|
851
|
+
if (t.relation.startsWith(RELATION_DENY_PREFIX) &&
|
|
852
|
+
t.object.startsWith(`${type}:${partitionKey}|`) &&
|
|
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))) {
|
|
855
|
+
return `${t.object}#${t.relation}@${t.user}`;
|
|
856
|
+
}
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
for (const parent of await this.#readAll('relate', { user: userset, relation: RELATION_USERSET_RELATION, object: `${RELATION_USERSET_TYPE}:` })) {
|
|
860
|
+
// Cierre 3 de D2 · ⚪ C2-4: una arista de anidación VENCIDA no transporta nada (no conecta en FGA).
|
|
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))
|
|
863
|
+
continue;
|
|
864
|
+
if (!seen.has(parent.object)) {
|
|
865
|
+
seen.add(parent.object);
|
|
866
|
+
queue.push(parent.object);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
return null;
|
|
871
|
+
}
|
|
872
|
+
/** `#carriedDeny` tras los dos `Write` (🟡 C2-3): nunca lanza; un fallo de la re-lectura viaja en `error`. */
|
|
873
|
+
async #recheckCarried(groupObjectId, partitionKey, now) {
|
|
874
|
+
try {
|
|
875
|
+
return { deny: await this.#carriedDeny(groupObjectId, partitionKey, now) };
|
|
876
|
+
}
|
|
877
|
+
catch (error) {
|
|
878
|
+
return { deny: null, error };
|
|
426
879
|
}
|
|
427
|
-
await this.#guard('relate', () => this.client.write({ writes: [tuple] }, IGNORE_DUPLICATE_WRITES));
|
|
428
880
|
}
|
|
429
881
|
/**
|
|
430
882
|
* La tupla EXACTA de una clave (un `Read` por clave completa), con su caducidad si la lleva.
|
|
@@ -440,7 +892,7 @@ export class OpenFgaRelationsDriver {
|
|
|
440
892
|
const found = response.tuples.map((t) => t.key).find((k) => k?.user === key.user && k?.relation === key.relation && k?.object === key.object);
|
|
441
893
|
if (!found)
|
|
442
894
|
return null;
|
|
443
|
-
return { ...key,
|
|
895
|
+
return { ...key, ...storedCondition(found.condition) };
|
|
444
896
|
}
|
|
445
897
|
async unrelate(subject, relation, object, partition, options) {
|
|
446
898
|
this.#rejectTransaction('unrelate', options);
|
|
@@ -451,9 +903,67 @@ export class OpenFgaRelationsDriver {
|
|
|
451
903
|
const partitionKey = this.#keyOf(partition);
|
|
452
904
|
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
453
905
|
const user = this.#subjectUser(subject, partitionKey);
|
|
906
|
+
await this.#assertModel('unrelate', [object.type]);
|
|
454
907
|
await this.#guard('unrelate', () =>
|
|
455
908
|
// Re-unrelate no-op seguro: borrar una tupla ausente se ignora.
|
|
456
|
-
this.client.write({ deletes: [{ user, relation, object: objectId }] }, IGNORE_MISSING_DELETES));
|
|
909
|
+
this.client.write({ deletes: [{ user, relation, object: objectId }] }, IGNORE_MISSING_DELETES), true);
|
|
910
|
+
}
|
|
911
|
+
/**
|
|
912
|
+
* **`deny` = UN `Write` de la tupla `denied_<rel>`** (lote D · D1): sin caducidad (D3), así que no hay renovación
|
|
913
|
+
* ni ventana —un re-deny idéntico lo ignora el servidor (`on_duplicate: ignore`)—; una tupla de deny escrita a mano
|
|
914
|
+
* CON condición devuelve 409 y, desde el cierre 1 de D2 (🟡 4), sale como 500 `E_AUTHZ_CONFIG` con la receta
|
|
915
|
+
* (`AuthorizationConfigError.conditionalDeny`; hasta entonces era un 503 que se repetía para siempre). Antes del `Write`: la
|
|
916
|
+
* puerta de `{ transaction }` (L-5), F-05 sobre el nombre PÚBLICO, la puerta del opt-in (fase `'write'`), sin
|
|
917
|
+
* caducidad, la partición, `*`, el sujeto y la puerta del modelo (L11). CERO llamadas si algo de eso falla.
|
|
918
|
+
*/
|
|
919
|
+
async deny(subject, relation, object, partition, options) {
|
|
920
|
+
this.#rejectTransaction('deny', options);
|
|
921
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
922
|
+
assertRelationDenyable(this.#config, object, relation);
|
|
923
|
+
assertDenyNoExpiry(options);
|
|
924
|
+
assertPartition(partition);
|
|
925
|
+
assertDenySubject(subject);
|
|
926
|
+
const partitionKey = this.#keyOf(partition);
|
|
927
|
+
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
928
|
+
const user = this.#subjectUser(subject, partitionKey);
|
|
929
|
+
await this.#assertModel('deny', [object.type]);
|
|
930
|
+
const key = { user, relation: deniedRelationOf(relation), object: objectId };
|
|
931
|
+
try {
|
|
932
|
+
await this.#guard('deny', () => this.client.write({ writes: [key] }, IGNORE_DUPLICATE_WRITES), true);
|
|
933
|
+
}
|
|
934
|
+
catch (error) {
|
|
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
|
|
936
|
+
// a different condition») no es una caída. Se confirma RELEYENDO la tupla (no por el texto del mensaje): si está y
|
|
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) {
|
|
940
|
+
const found = await this.#guard('deny', () => this.client.read(key, { consistency: this.#consistency }));
|
|
941
|
+
if (Array.isArray(found?.tuples) && found.tuples.some((t) => t?.key?.condition)) {
|
|
942
|
+
throw AuthorizationConfigError.conditionalDeny('deny', `${key.object}#${key.relation}@${key.user}`);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
throw error;
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
/**
|
|
949
|
+
* **`removeDeny` = UN delete de `denied_<rel>`** (no-op si no está). Sobre CUALQUIER tipo declarado (D8/R2-4 b):
|
|
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
|
|
951
|
+
* que retiró el opt-in se puede borrar. Ojo: quitar un deny CONCEDE (filas declaradas S8–S10).
|
|
952
|
+
* **La puerta del modelo (L11, antes D-31) SÍ corre aquí** (letra corregida en el cierre 1 de D1, ⚪ 5 del auditor:
|
|
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
|
|
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
|
|
955
|
+
* (medido por el auditor: `listDenies` de 1 a 0 y `check` vuelve a `true` con el modelo viejo) — retirar es reversible.
|
|
956
|
+
*/
|
|
957
|
+
async removeDeny(subject, relation, object, partition, options) {
|
|
958
|
+
this.#rejectTransaction('removeDeny', options);
|
|
959
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
960
|
+
assertDenyNoExpiry(options);
|
|
961
|
+
assertPartition(partition);
|
|
962
|
+
const partitionKey = this.#keyOf(partition);
|
|
963
|
+
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
964
|
+
const user = this.#subjectUser(subject, partitionKey);
|
|
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);
|
|
457
967
|
}
|
|
458
968
|
/* ── Lecturas ───────────────────────────────────────────────────────────── */
|
|
459
969
|
/**
|
|
@@ -477,6 +987,7 @@ export class OpenFgaRelationsDriver {
|
|
|
477
987
|
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
478
988
|
const user = this.#subjectUser(subject, partitionKey);
|
|
479
989
|
const context = this.#checkContext(this.#now());
|
|
990
|
+
await this.#assertModel('check', [object.type]);
|
|
480
991
|
const response = await this.#guard('check', () => this.client.check({ user, relation, object: objectId, context }, { consistency: this.#consistency }));
|
|
481
992
|
return response.allowed === true;
|
|
482
993
|
}
|
|
@@ -500,6 +1011,7 @@ export class OpenFgaRelationsDriver {
|
|
|
500
1011
|
const user = this.#subjectUser(subject, partitionKey);
|
|
501
1012
|
const { distinct, index } = distinctObjects(objects);
|
|
502
1013
|
const context = this.#checkContext(this.#now());
|
|
1014
|
+
await this.#assertModel('checkMany', distinct.map((object) => object.type));
|
|
503
1015
|
const checks = distinct.map((object, i) => ({
|
|
504
1016
|
user,
|
|
505
1017
|
relation,
|
|
@@ -519,6 +1031,105 @@ export class OpenFgaRelationsDriver {
|
|
|
519
1031
|
}
|
|
520
1032
|
return expandByPosition(index, answers);
|
|
521
1033
|
}
|
|
1034
|
+
/**
|
|
1035
|
+
* **`decideMany` = un `batchCheck` con `R` y los `denied_<m>` de las relaciones FUENTE de `down*(R)`** (lote D ·
|
|
1036
|
+
* D9, juez ronda 2 §4): por el cierre ascendente del modelo (`denied_X = this ∨ denied_P`), `denied_<fuente>`
|
|
1037
|
+
* cubre todo `up*(down*(R))` — en el retículo del contrato, 2 ítems por objeto (`R` y `denied_owner`). Mismo
|
|
1038
|
+
* `current_time` en TODOS los ítems (un `now` por lote), alineado por `correlationId` (`alignBatchResults`, la
|
|
1039
|
+
* misma función que `checkMany` y roles), troceado por `FGA_MAX_BATCH_CHECK`, y un `error` en UN ítem ⇒ 503 para
|
|
1040
|
+
* todo el lote (nunca `none`: S15). La decisión sale de `decideFrom`, la regla ÚNICA (la misma que `database` y el
|
|
1041
|
+
* doble). En un tipo sin opt-in se pregunta el deny solo si el modelo que sirve el driver lo declara (opt-in
|
|
1042
|
+
* retirado con el modelo aún publicado, D8); si no, cada posición es `allow` o `none` (no hay deny posible).
|
|
1043
|
+
*/
|
|
1044
|
+
async decideMany(subject, relation, objects, partition) {
|
|
1045
|
+
assertCheckManyObjects(objects, 'decideMany');
|
|
1046
|
+
for (const object of objects)
|
|
1047
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
1048
|
+
assertPartition(partition);
|
|
1049
|
+
const partitionKey = this.#keyOf(partition);
|
|
1050
|
+
const user = this.#subjectUser(subject, partitionKey);
|
|
1051
|
+
const { distinct, index } = distinctObjects(objects);
|
|
1052
|
+
const context = this.#checkContext(this.#now());
|
|
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));
|
|
1056
|
+
const checks = [];
|
|
1057
|
+
const plan = [];
|
|
1058
|
+
distinct.forEach((object, i) => {
|
|
1059
|
+
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
1060
|
+
const down = relationsDown(this.#config, object.type, relation);
|
|
1061
|
+
const type = this.#config.objectTypes.find((t) => t.type === object.type);
|
|
1062
|
+
const sources = down.filter((name) => {
|
|
1063
|
+
const includes = type?.relations.find((r) => r.name === name)?.includes ?? [];
|
|
1064
|
+
return includes.length === 0 && denyForm.get(object.type) === 'deny';
|
|
1065
|
+
});
|
|
1066
|
+
plan.push({ object, sources });
|
|
1067
|
+
// `correlationId` del servidor: `^[\w\d-]{1,36}$` (sin `:`): `a<i>` para R, `d<i>-<k>` para la fuente k.
|
|
1068
|
+
checks.push({ user, relation, object: objectId, context, correlationId: `a${i}` });
|
|
1069
|
+
sources.forEach((source, k) => {
|
|
1070
|
+
checks.push({ user, relation: deniedRelationOf(source), object: objectId, context, correlationId: `d${i}-${k}` });
|
|
1071
|
+
});
|
|
1072
|
+
});
|
|
1073
|
+
const allowed = new Map();
|
|
1074
|
+
for (let start = 0; start < checks.length; start += FGA_MAX_BATCH_CHECK) {
|
|
1075
|
+
const chunk = checks.slice(start, start + FGA_MAX_BATCH_CHECK);
|
|
1076
|
+
const response = await this.#guard('decideMany', () => this.client.batchCheck({ checks: chunk }, { consistency: this.#consistency }));
|
|
1077
|
+
const results = alignBatchResults('openfga-relations', 'decideMany', chunk, response.result);
|
|
1078
|
+
results.forEach((r, i) => allowed.set(chunk[i].correlationId, r.allowed === true));
|
|
1079
|
+
}
|
|
1080
|
+
// D9, por posición: `allow` si el `Check` de R concede (el servidor ya aplicó el corte); si no, `deny` si alguno
|
|
1081
|
+
// de los `denied_<fuente>` alcanza al sujeto (su cierre cubre up*(down*(R))); si no, `none`. Es `decideFrom` con el
|
|
1082
|
+
// corte ya evaluado en el servidor (los dos drivers y el doble responden la misma tabla: casos D9 de la COMÚN).
|
|
1083
|
+
const answers = plan.map(({ sources }, i) => {
|
|
1084
|
+
if (allowed.get(`a${i}`))
|
|
1085
|
+
return 'allow';
|
|
1086
|
+
return sources.some((_, k) => allowed.get(`d${i}-${k}`)) ? 'deny' : 'none';
|
|
1087
|
+
});
|
|
1088
|
+
return index.map((at) => answers[at]);
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* **`denyPhase` — ¿admite este driver escrituras de deny en el tipo?** (lote D · D2, R2-5). La fase del opt-in EN
|
|
1092
|
+
* LA CONFIG DE ESTE DRIVER y, con opt-in, el modelo FIJADO comprobado con la MISMA puerta L11 que `deny` (500 si
|
|
1093
|
+
* no declara `denied_<rel>`). F-05 estricto del par, antes de nada (422, cero llamadas). La llama
|
|
1094
|
+
* `reconcileRelations` en la fase de plan cuando el origen trae denies: un destino `openfga` sin `'write'` en el
|
|
1095
|
+
* tipo, o con un pin a un modelo sin el deny, NIEGA la pasada antes de la primera escritura (jamás `skipped`).
|
|
1096
|
+
*/
|
|
1097
|
+
async denyPhase(object, relation) {
|
|
1098
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
1099
|
+
const phase = denyPhaseOf(this.#config, object.type);
|
|
1100
|
+
if (phase === undefined)
|
|
1101
|
+
return null;
|
|
1102
|
+
await this.#assertModel('denyPhase', [object.type]);
|
|
1103
|
+
return phase;
|
|
1104
|
+
}
|
|
1105
|
+
/** `listDenies` = `Read` por `(object, denied_<rel>)`: los denies DIRECTOS (hechos). Cualquier tipo declarado (D8). */
|
|
1106
|
+
async listDenies(relation, object, partition) {
|
|
1107
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
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');
|
|
1111
|
+
const partitionKey = this.#keyOf(partition);
|
|
1112
|
+
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
1113
|
+
const now = this.#now();
|
|
1114
|
+
await this.#assertModel('listDenies', [object.type]);
|
|
1115
|
+
const tuples = await this.#readAll('listDenies', { object: objectId, relation: deniedRelationOf(relation) });
|
|
1116
|
+
const subjects = [];
|
|
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
|
+
}
|
|
1123
|
+
if (t.validUntil && t.validUntil.getTime() <= now.getTime())
|
|
1124
|
+
continue;
|
|
1125
|
+
const subject = this.#userToSubject(t.user);
|
|
1126
|
+
if (subject)
|
|
1127
|
+
subjects.push(subject);
|
|
1128
|
+
else
|
|
1129
|
+
this.#warnUnparseable('listDenies', t.user);
|
|
1130
|
+
}
|
|
1131
|
+
return { subjects };
|
|
1132
|
+
}
|
|
522
1133
|
/**
|
|
523
1134
|
* **El envoltorio ÚNICO de `ListObjects`** (plan 2.5 · lote B · B4; linaje L8,
|
|
524
1135
|
* 5.ª iteración ⇒ M8: propiedad universal «toda enumeración que se puede
|
|
@@ -562,12 +1173,14 @@ export class OpenFgaRelationsDriver {
|
|
|
562
1173
|
* se consume entre todas — otra razón para señalar el truncado y no callar
|
|
563
1174
|
* (documentado en el README).
|
|
564
1175
|
*/
|
|
565
|
-
async listObjects(subject, relation, objectType, partition
|
|
1176
|
+
async listObjects(subject, relation, objectType, partition) {
|
|
566
1177
|
assertRelationDeclared(this.#config, { type: objectType }, relation);
|
|
567
1178
|
assertPartition(partition);
|
|
1179
|
+
assertNoListPage('listObjects', arguments[4], 'openfga');
|
|
568
1180
|
const partitionKey = this.#keyOf(partition);
|
|
569
1181
|
const user = this.#subjectUser(subject, partitionKey);
|
|
570
1182
|
const context = this.#checkContext(this.#now());
|
|
1183
|
+
await this.#assertModel('listObjects', [objectType]);
|
|
571
1184
|
const { raw, truncated } = await this.#listObjectsPage({ user, relation, type: objectType, context });
|
|
572
1185
|
const objects = [];
|
|
573
1186
|
for (const full of raw) {
|
|
@@ -589,18 +1202,26 @@ export class OpenFgaRelationsDriver {
|
|
|
589
1202
|
* derivado por includes. `usersetSubjects: true` ⇒ los usersets
|
|
590
1203
|
* (`group#member`) salen junto con los holders.
|
|
591
1204
|
*/
|
|
592
|
-
async listSubjects(relation, object, partition
|
|
1205
|
+
async listSubjects(relation, object, partition) {
|
|
593
1206
|
assertRelationDeclared(this.#config, object, relation);
|
|
594
1207
|
assertPartition(partition);
|
|
1208
|
+
assertNoListPage('listSubjects', arguments[3], 'openfga');
|
|
595
1209
|
const partitionKey = this.#keyOf(partition);
|
|
596
1210
|
const objectId = this.#objectId(object.type, partitionKey, object.id);
|
|
597
1211
|
// `Read` devuelve tuplas ESCRITAS, no evaluadas: la caducidad se filtra en
|
|
598
1212
|
// cliente con el MISMO reloj del driver (R-15, estricta: la que vence en
|
|
599
1213
|
// `now` ya no cuenta), como las enumeraciones del driver de roles.
|
|
600
1214
|
const now = this.#now();
|
|
1215
|
+
await this.#assertModel('listSubjects', [object.type]);
|
|
601
1216
|
const tuples = await this.#readAll('listSubjects', { object: objectId, relation });
|
|
602
1217
|
const subjects = [];
|
|
603
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
|
+
}
|
|
604
1225
|
if (t.validUntil && t.validUntil.getTime() <= now.getTime())
|
|
605
1226
|
continue;
|
|
606
1227
|
const subject = this.#userToSubject(t.user);
|
|
@@ -640,13 +1261,26 @@ export class OpenFgaRelationsDriver {
|
|
|
640
1261
|
* la CADUCADA también llega, con su `expiresAt` (R-15), para que el destino
|
|
641
1262
|
* la cuente en `skipped`.
|
|
642
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
|
+
*
|
|
643
1271
|
* **Coste: O(tuplas del store) por PASADA** (2.4.0-alpha.6): es un barrido
|
|
644
1272
|
* entero por llamada, sea cual sea el tamaño de la partición (medido: 5 000
|
|
645
1273
|
* tuplas ⇒ 50 páginas ≈ 170 ms; 15 000 ⇒ 151 páginas ≈ 0,5 s). Para K
|
|
646
1274
|
* particiones usa `enumerateRelationsMany`: UN barrido en vez de K.
|
|
647
1275
|
*/
|
|
648
|
-
async enumerateRelations(partition,
|
|
1276
|
+
async enumerateRelations(partition, page) {
|
|
649
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');
|
|
650
1284
|
return this.#sweep([partition], 'enumerateRelations');
|
|
651
1285
|
}
|
|
652
1286
|
/**
|
|
@@ -675,13 +1309,15 @@ export class OpenFgaRelationsDriver {
|
|
|
675
1309
|
* se cuenta en `diagnostics.unparseableTuples` y se avisa. Sin particiones
|
|
676
1310
|
* es 500 `E_AUTHZ_CONFIG` con cero lecturas: no hay «éxito vacío».
|
|
677
1311
|
*/
|
|
678
|
-
async enumerateRelationsMany(partitions,
|
|
1312
|
+
async enumerateRelationsMany(partitions, page) {
|
|
679
1313
|
if (!Array.isArray(partitions) || partitions.length === 0) {
|
|
680
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). ' +
|
|
681
1315
|
'Pasa al menos una partición.');
|
|
682
1316
|
}
|
|
683
1317
|
for (const partition of partitions)
|
|
684
1318
|
assertPartition(partition);
|
|
1319
|
+
assertEnumeratePage('enumerateRelationsMany', page, 'none', 'openfga');
|
|
1320
|
+
await this.#assertModel('enumerateRelationsMany', [], 'conditions');
|
|
685
1321
|
return this.#sweep(partitions, 'enumerateRelationsMany');
|
|
686
1322
|
}
|
|
687
1323
|
/** El barrido: UN `Read({})` paginado del store entero, repartido por la clave ENTERA de partición. */
|
|
@@ -713,10 +1349,17 @@ export class OpenFgaRelationsDriver {
|
|
|
713
1349
|
}
|
|
714
1350
|
tuples.push({
|
|
715
1351
|
subject,
|
|
716
|
-
relation:
|
|
1352
|
+
// Lote D · D2: `denied_<rel>` ⇒ `{ relation: <rel>, kind: 'deny' }` (`storedRelationKind`, punto de paso único).
|
|
1353
|
+
...storedRelationKind(key.relation),
|
|
717
1354
|
object: { type: parsed.type, id: parsed.objectUuid },
|
|
718
1355
|
partition,
|
|
719
|
-
|
|
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 } : {}),
|
|
720
1363
|
});
|
|
721
1364
|
}
|
|
722
1365
|
return { tuples };
|
|
@@ -736,9 +1379,10 @@ export class OpenFgaRelationsDriver {
|
|
|
736
1379
|
assertObjectTypeDeclared(this.#config, object);
|
|
737
1380
|
assertPartition(partition);
|
|
738
1381
|
const objectId = this.#objectId(object.type, this.#keyOf(partition), object.id);
|
|
1382
|
+
await this.#assertModel('purgeObject', [object.type]);
|
|
739
1383
|
const progress = { deleted: 0 };
|
|
740
1384
|
try {
|
|
741
|
-
await this.#
|
|
1385
|
+
await this.#purgeByFilters('purgeObject', [[{ object: objectId }, `objeto ${objectId}`]], progress);
|
|
742
1386
|
}
|
|
743
1387
|
catch (error) {
|
|
744
1388
|
throw this.#partialIfDeleted(error, progress);
|
|
@@ -761,11 +1405,11 @@ export class OpenFgaRelationsDriver {
|
|
|
761
1405
|
assertSubject(subject);
|
|
762
1406
|
const user = this.#subjectUser(subject, this.#keyOf(partition));
|
|
763
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);
|
|
764
1410
|
const progress = { deleted: 0 };
|
|
765
1411
|
try {
|
|
766
|
-
|
|
767
|
-
await this.#purgeByFilter('purgeSubject', { user, object: `${type}:` }, `sujeto ${user} en '${type}'`, progress);
|
|
768
|
-
}
|
|
1412
|
+
await this.#purgeByFilters('purgeSubject', types.map((type) => [{ user, object: `${type}:` }, `sujeto ${user} en '${type}'`]), progress);
|
|
769
1413
|
}
|
|
770
1414
|
catch (error) {
|
|
771
1415
|
throw this.#partialIfDeleted(error, progress);
|
|
@@ -775,7 +1419,7 @@ export class OpenFgaRelationsDriver {
|
|
|
775
1419
|
* **Una purga que muere a MEDIAS es indeterminada** (invariante 13,
|
|
776
1420
|
* 2.4.0-alpha.3, cierre del 🟡 6 del auditor). La purga NO es un `Write`:
|
|
777
1421
|
* son N `Read`+`deleteTuples` (ortografías × tipos, y lotes de
|
|
778
|
-
* `
|
|
1422
|
+
* `RELATIONS_PURGE_WRITE_MAX`), y un 503 que no es timeout —o el 500
|
|
779
1423
|
* `E_AUTHZ_PURGE_INCOMPLETE` de la demostración de cero— puede llegar
|
|
780
1424
|
* después de haber borrado parte. Si YA se borró al menos una tupla, el
|
|
781
1425
|
* error se marca (`markPartialWrite`, `src/errors.ts`: una propiedad, la
|
|
@@ -790,22 +1434,61 @@ export class OpenFgaRelationsDriver {
|
|
|
790
1434
|
return markPartialWrite(error);
|
|
791
1435
|
return error;
|
|
792
1436
|
}
|
|
793
|
-
/**
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1437
|
+
/**
|
|
1438
|
+
* **Lee TODOS los filtros, borra en un `Write` si cabe —y si no, en ORDEN GLOBAL: allows → membresías → denies— y
|
|
1439
|
+
* RE-LEE cada filtro para demostrar cero (o lanza)** (lote D · D-16/D-26/S3; cierre 1 de D1, 🟠 1 del auditor; el
|
|
1440
|
+
* orden S6 de roles, invariante 6). La propiedad: **una purga cortada a medias no concede nada que no concedieran el
|
|
1441
|
+
* estado inicial o el final** —con la única excepción DECLARADA de abajo—.
|
|
1442
|
+
*
|
|
1443
|
+
* - **Cabe en `RELATIONS_PURGE_WRITE_MAX` (100) ⇒ UN solo `Write`**: FGA lo aplica de forma transaccional (medido:
|
|
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)—).
|
|
1446
|
+
* - **No cabe ⇒ tres clases, cada una en sus propios `Write`, en el orden de BORRADO del punto de paso ÚNICO
|
|
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
|
|
1448
|
+
* `group` —quitar un allow solo resta (D-12, monotonía)—; (2) las MEMBRESÍAS `group:*#member@<sujeto>` —una membresía
|
|
1449
|
+
* TRANSPORTA allows y denies a la vez: borrarla antes que un allow directo deja ese allow sin el deny que lo cortaba
|
|
1450
|
+
* (🟠 1: con el orden «solo prefijo» de alpha.3-dev, `group:g#member@U` caía en el lote 1 y `doc:V#viewer@U` en el 2;
|
|
1451
|
+
* cortada entre los dos, U CONCEDÍA sobre V; la gemela `purgeSubject(g#member)` con la arista anidada
|
|
1452
|
+
* `group:h#member@group:g#member`)—, en UN `Write` PROPIO si caben (ordenadas por objeto, las portadoras de deny al
|
|
1453
|
+
* final: el reparto no depende del `Read`); (3) los `denied_*`, al final —quedan denies de MÁS, nunca de menos—.
|
|
1454
|
+
* - **Residuo DECLARADO**: con MÁS de 100 membresías del sujeto no caben en un `Write`, y una que transporta un deny
|
|
1455
|
+
* puede caer en un lote anterior a otra que transporta un allow (fila con caso en `relations_openfga.spec`, frase en
|
|
1456
|
+
* el README). No se niega la purga antes de borrar (invariante 11: una purga no espera; un 500 dejaría al sujeto de
|
|
1457
|
+
* baja con TODO su acceso): sale marcada como parcial (`indeterminate: true`) y se repite.
|
|
1458
|
+
*
|
|
1459
|
+
* `purgeObject` pasa por aquí igual: sobre un objeto que no es `group` no hay membresías (allows → denies); sobre
|
|
1460
|
+
* `group:g` todo son membresías de g, y cada miembro queda dentro o fuera de g —su estado inicial o el final—.
|
|
1461
|
+
* `progress.deleted` cuenta lo que YA se borró (un lote que falla no borró nada) para la marca de escritura parcial
|
|
1462
|
+
* (invariante 13).
|
|
1463
|
+
*/
|
|
1464
|
+
async #purgeByFilters(operation, filters, progress) {
|
|
1465
|
+
const read = [];
|
|
1466
|
+
for (const [filter, what] of filters)
|
|
1467
|
+
read.push({ filter, what, keys: await this.#readAll(operation, filter) });
|
|
1468
|
+
for (const batch of purgeWrites(read.flatMap((r) => r.keys))) {
|
|
1469
|
+
try {
|
|
1470
|
+
await this.#guard(operation, () => this.client.deleteTuples(batch.map((k) => ({ user: k.user, relation: k.relation, object: k.object })), IGNORE_MISSING_DELETES), true);
|
|
1471
|
+
}
|
|
1472
|
+
catch (error) {
|
|
1473
|
+
// Cierre 2 de D1 · ⚪ N3: el 400 del tope de operaciones por `Write` del SERVIDOR no es una caída (503
|
|
1474
|
+
// reintentable): es un despliegue con `--max-tuples-per-write` < `RELATIONS_PURGE_WRITE_MAX`. 500 con la letra;
|
|
1475
|
+
// la marca de escritura parcial la pone `#partialIfDeleted` si ya se aplicó un `Write` anterior.
|
|
1476
|
+
if (isServerWriteLimit(error))
|
|
1477
|
+
throw purgeWriteLimitError(operation, batch.length, progress.deleted, error);
|
|
1478
|
+
throw error;
|
|
1479
|
+
}
|
|
799
1480
|
progress.deleted += batch.length;
|
|
800
1481
|
}
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
1482
|
+
// Demostración de cero: re-lee tras borrar (invariante 11), solo los filtros que tenían algo.
|
|
1483
|
+
for (const { filter, what, keys } of read) {
|
|
1484
|
+
if (keys.length === 0)
|
|
1485
|
+
continue;
|
|
1486
|
+
const residue = await this.#readAll(operation, filter);
|
|
1487
|
+
if (residue.length > 0) {
|
|
1488
|
+
throw new PurgeIncompleteError(`${operation}: quedan ${residue.length} tuplas de ${what} tras el borrado ` +
|
|
1489
|
+
`(${residue.slice(0, 3).map((k) => `${k.user}#${k.relation}@${k.object}`).join('; ')}). ` +
|
|
1490
|
+
`No se confirma la purga.`);
|
|
1491
|
+
}
|
|
809
1492
|
}
|
|
810
1493
|
}
|
|
811
1494
|
/* ── Helpers ────────────────────────────────────────────────────────────── */
|
|
@@ -822,12 +1505,15 @@ export class OpenFgaRelationsDriver {
|
|
|
822
1505
|
* sondea las cinco operaciones que entran por aquí con un mutante que
|
|
823
1506
|
* «termina» en la página vacía.
|
|
824
1507
|
*/
|
|
825
|
-
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) {
|
|
826
1511
|
const keys = [];
|
|
827
1512
|
let continuationToken;
|
|
828
1513
|
const seenTokens = new Set();
|
|
829
1514
|
let pages = 0;
|
|
830
1515
|
do {
|
|
1516
|
+
pageHooks?.before();
|
|
831
1517
|
const response = await this.#guard(operation, () => this.client.read(filter, {
|
|
832
1518
|
pageSize: READ_PAGE_SIZE,
|
|
833
1519
|
continuationToken,
|
|
@@ -840,6 +1526,7 @@ export class OpenFgaRelationsDriver {
|
|
|
840
1526
|
if (!Array.isArray(response?.tuples)) {
|
|
841
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`));
|
|
842
1528
|
}
|
|
1529
|
+
pageHooks?.after(response.tuples.length);
|
|
843
1530
|
for (const tuple of response.tuples) {
|
|
844
1531
|
const key = tuple?.key;
|
|
845
1532
|
if (!key?.user || !key?.relation || !key?.object) {
|
|
@@ -848,12 +1535,7 @@ export class OpenFgaRelationsDriver {
|
|
|
848
1535
|
}
|
|
849
1536
|
// Con su caducidad (R-15): quien la lee decide si filtra (`listSubjects`),
|
|
850
1537
|
// la emite (`enumerateRelations`) o la ignora (`purge*` borra TODO).
|
|
851
|
-
keys.push({
|
|
852
|
-
user: key.user,
|
|
853
|
-
relation: key.relation,
|
|
854
|
-
object: key.object,
|
|
855
|
-
validUntil: toExpiryDate(key.condition?.context?.valid_until),
|
|
856
|
-
});
|
|
1538
|
+
keys.push({ user: key.user, relation: key.relation, object: key.object, ...storedCondition(key.condition) });
|
|
857
1539
|
}
|
|
858
1540
|
continuationToken = response.continuation_token || undefined;
|
|
859
1541
|
if (continuationToken) {
|
|
@@ -882,4 +1564,86 @@ export class OpenFgaRelationsDriver {
|
|
|
882
1564
|
// de modo que un 400 de validación de FGA o el cuerpo de un 502 que mencionara
|
|
883
1565
|
// la palabra salía como 503 `E_AUTHZ_BACKEND_TIMEOUT` con `indeterminate: true`
|
|
884
1566
|
// sin haber borrado nada (medido por el auditor: `alpha3-cierre-parcial.ts` B).
|
|
885
|
-
|
|
1567
|
+
/** La LETRA del 500 de N3 (fijada por `relations_openfga.spec`, «⚪ N3 · …»). */
|
|
1568
|
+
function purgeWriteLimitError(operation, size, deleted, cause) {
|
|
1569
|
+
const sdk = cause?.cause?.apiErrorMessage;
|
|
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 ` +
|
|
1572
|
+
`operaciones por Write (exceeded_entity_limit${typeof sdk === 'string' ? `: «${sdk}»` : ''}). El driver parte las ` +
|
|
1573
|
+
`purgas en RELATIONS_PURGE_WRITE_MAX = ${RELATIONS_PURGE_WRITE_MAX} y exige --max-tuples-per-write ≥ ` +
|
|
1574
|
+
`${RELATIONS_PURGE_WRITE_MAX} en el servidor (OPENFGA_MAX_TUPLES_PER_WRITE; el default es ${RELATIONS_PURGE_WRITE_MAX}). ` +
|
|
1575
|
+
`No es transitorio: repetida, la purga vuelve a fallar igual. ` +
|
|
1576
|
+
(deleted > 0
|
|
1577
|
+
? `Ya se habían borrado ${deleted} tuplas en Write anteriores: la purga quedó A MEDIAS (escritura parcial).`
|
|
1578
|
+
: 'No se borró nada.'), { cause }));
|
|
1579
|
+
}
|
|
1580
|
+
/**
|
|
1581
|
+
* **Los `Write` de una purga, en orden** (lote D · cierre 1 de D1, 🟠 1; ver `#purgeByFilters`): uno solo si todo cabe
|
|
1582
|
+
* en `RELATIONS_PURGE_WRITE_MAX`; si no, en el orden de BORRADO del punto de paso ÚNICO `relationDeleteOrder`
|
|
1583
|
+
* (`src/relations/tuple_order.ts`, cierre 1 de D2 · 🟠 1: el MISMO que usa el reconcile) — allows sobre objetos que no
|
|
1584
|
+
* son `group`, luego las membresías `group:*#member` (las portadoras de deny al final, ordenadas por objeto y sujeto) y
|
|
1585
|
+
* al final los `denied_*` —, con CADA CLASE en sus PROPIOS `Write` (con ≤ 100 membresías, uno). Pura: la tabla de verdad
|
|
1586
|
+
* del caso la juzga contra el servidor, y la PROPIEDAD la juzga sin servidor la guardia de superficie
|
|
1587
|
+
* (`GS · P · openfga.purge.order`, cierre 2 de D1 · ⚪ N2), con los mutantes de orden en el corredor publicado
|
|
1588
|
+
* (`npm run check:guard-mutants`). Exportada solo para eso: no sale por `/openfga`.
|
|
1589
|
+
*/
|
|
1590
|
+
export function purgeWrites(keys) {
|
|
1591
|
+
if (keys.length === 0)
|
|
1592
|
+
return [];
|
|
1593
|
+
if (keys.length <= RELATIONS_PURGE_WRITE_MAX)
|
|
1594
|
+
return [[...keys]];
|
|
1595
|
+
const view = (k) => ({ deny: k.relation.startsWith(RELATION_DENY_PREFIX), object: k.object, user: k.user });
|
|
1596
|
+
const ordered = relationDeleteOrder(keys, view);
|
|
1597
|
+
const chunks = (list) => {
|
|
1598
|
+
const out = [];
|
|
1599
|
+
for (let i = 0; i < list.length; i += RELATIONS_PURGE_WRITE_MAX)
|
|
1600
|
+
out.push(list.slice(i, i + RELATIONS_PURGE_WRITE_MAX));
|
|
1601
|
+
return out;
|
|
1602
|
+
};
|
|
1603
|
+
// Cada CLASE en sus propios `Write`: una membresía nunca comparte lote con el último allow ni con el primer deny.
|
|
1604
|
+
const runs = [];
|
|
1605
|
+
let previous;
|
|
1606
|
+
for (const k of ordered) {
|
|
1607
|
+
const cls = relationOrderClass(view(k));
|
|
1608
|
+
if (cls !== previous)
|
|
1609
|
+
runs.push([]);
|
|
1610
|
+
runs[runs.length - 1].push(k);
|
|
1611
|
+
previous = cls;
|
|
1612
|
+
}
|
|
1613
|
+
return runs.flatMap(chunks);
|
|
1614
|
+
}
|
|
1615
|
+
/**
|
|
1616
|
+
* **El error de una renovación en dos `Write` NOMBRA el deny que entró en la ventana** (cierre 3 de D2 · 🟡 C2-3). Sobre
|
|
1617
|
+
* el error que se propaga (su clase y su `code` no cambian): `carriedDeny` = el deny que la re-comprobación encontró, y la
|
|
1618
|
+
* letra con la receta; si la re-comprobación también falló, lo dice (`carriedDenyUnknown: true`). `applied` = la
|
|
1619
|
+
* renovación SÍ se aplicó y lo que falló es solo la re-comprobación. Un error congelado se propaga tal cual.
|
|
1620
|
+
*/
|
|
1621
|
+
function annotateRacedDeny(error, membership, recheck, applied = false) {
|
|
1622
|
+
let note = null;
|
|
1623
|
+
const target = error;
|
|
1624
|
+
try {
|
|
1625
|
+
if (recheck.deny !== null) {
|
|
1626
|
+
target.carriedDeny = recheck.deny;
|
|
1627
|
+
note =
|
|
1628
|
+
` — Cierre 3 de D2 (C2-3): la membresía ${membership} quedó BORRADA y un deny concurrente (${recheck.deny}) que ` +
|
|
1629
|
+
'entró en el grupo durante la renovación ya NO alcanza al sujeto por ella, y no lo alcanzará hasta que la ' +
|
|
1630
|
+
'membresía se reescriba. Receta: vuelve a escribirla (relate: ya no existe, así que es UN Write) o retira y ' +
|
|
1631
|
+
'reescribe ese deny después.';
|
|
1632
|
+
}
|
|
1633
|
+
else if (recheck.error !== undefined) {
|
|
1634
|
+
target.carriedDenyUnknown = true;
|
|
1635
|
+
note = applied
|
|
1636
|
+
? ` — Cierre 3 de D2 (C2-3): la renovación de ${membership} SE APLICÓ, pero la re-comprobación de un deny ` +
|
|
1637
|
+
'concurrente en su grupo falló: no se sabe si un deny entró en la ventana entre los dos Write. Receta: lista los ' +
|
|
1638
|
+
'denies del grupo (listDenies) y revisa las decisiones de ese sujeto en esa ventana.'
|
|
1639
|
+
: ` — Cierre 3 de D2 (C2-3): la membresía ${membership} quedó BORRADA y la re-comprobación de un deny concurrente ` +
|
|
1640
|
+
'en su grupo también falló. Receta: vuelve a escribir la membresía (relate) y lista los denies del grupo.';
|
|
1641
|
+
}
|
|
1642
|
+
if (note !== null && typeof target.message === 'string')
|
|
1643
|
+
target.message += note;
|
|
1644
|
+
}
|
|
1645
|
+
catch {
|
|
1646
|
+
// Un error congelado no admite la nota: se propaga el original (la clase y el `code` no cambian).
|
|
1647
|
+
}
|
|
1648
|
+
return error;
|
|
1649
|
+
}
|