@jantstack/adonis-authz 1.1.0 → 2.4.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +837 -51
- package/build/commands/authz_catalog_diff.d.ts +28 -0
- package/build/commands/authz_catalog_diff.d.ts.map +1 -0
- package/build/commands/authz_catalog_diff.js +67 -0
- package/build/commands/authz_catalog_diff.js.map +1 -0
- package/build/commands/authz_catalog_prune_orphans.d.ts +78 -0
- package/build/commands/authz_catalog_prune_orphans.d.ts.map +1 -0
- package/build/commands/authz_catalog_prune_orphans.js +136 -0
- package/build/commands/authz_catalog_prune_orphans.js.map +1 -0
- package/build/commands/authz_catalog_sync.d.ts +37 -0
- package/build/commands/authz_catalog_sync.d.ts.map +1 -0
- package/build/commands/authz_catalog_sync.js +81 -0
- package/build/commands/authz_catalog_sync.js.map +1 -0
- package/build/commands/authz_freeze.d.ts +44 -0
- package/build/commands/authz_freeze.d.ts.map +1 -0
- package/build/commands/authz_freeze.js +95 -0
- package/build/commands/authz_freeze.js.map +1 -0
- package/build/commands/authz_reconcile.d.ts +102 -0
- package/build/commands/authz_reconcile.d.ts.map +1 -0
- package/build/commands/authz_reconcile.js +294 -0
- package/build/commands/authz_reconcile.js.map +1 -0
- package/build/commands/authz_relations_reconcile.d.ts +73 -0
- package/build/commands/authz_relations_reconcile.d.ts.map +1 -0
- package/build/commands/authz_relations_reconcile.js +225 -0
- package/build/commands/authz_relations_reconcile.js.map +1 -0
- package/build/commands/authz_scopes_relay.d.ts +47 -0
- package/build/commands/authz_scopes_relay.d.ts.map +1 -0
- package/build/commands/authz_scopes_relay.js +141 -0
- package/build/commands/authz_scopes_relay.js.map +1 -0
- package/build/commands/authz_unfreeze.d.ts +37 -0
- package/build/commands/authz_unfreeze.d.ts.map +1 -0
- package/build/commands/authz_unfreeze.js +92 -0
- package/build/commands/authz_unfreeze.js.map +1 -0
- package/build/commands/main.d.ts +8 -1
- package/build/commands/main.d.ts.map +1 -1
- package/build/commands/main.js +8 -1
- package/build/commands/main.js.map +1 -1
- package/build/commands/openfga_provision.d.ts +46 -4
- package/build/commands/openfga_provision.d.ts.map +1 -1
- package/build/commands/openfga_provision.js +91 -7
- package/build/commands/openfga_provision.js.map +1 -1
- package/build/configure.d.ts +11 -0
- package/build/configure.d.ts.map +1 -1
- package/build/configure.js +37 -1
- package/build/configure.js.map +1 -1
- package/build/index.d.ts +75 -7
- package/build/index.d.ts.map +1 -1
- package/build/index.js +67 -5
- package/build/index.js.map +1 -1
- package/build/providers/authz_provider.d.ts +26 -2
- package/build/providers/authz_provider.d.ts.map +1 -1
- package/build/providers/authz_provider.js +48 -2
- package/build/providers/authz_provider.js.map +1 -1
- package/build/services/relations.d.ts +14 -0
- package/build/services/relations.d.ts.map +1 -0
- package/build/services/relations.js +17 -0
- package/build/services/relations.js.map +1 -0
- package/build/src/catalog/catalog.d.ts +289 -0
- package/build/src/catalog/catalog.d.ts.map +1 -0
- package/build/src/catalog/catalog.js +859 -0
- package/build/src/catalog/catalog.js.map +1 -0
- package/build/src/catalog/catalog_cache.d.ts +324 -0
- package/build/src/catalog/catalog_cache.d.ts.map +1 -0
- package/build/src/catalog/catalog_cache.js +666 -0
- package/build/src/catalog/catalog_cache.js.map +1 -0
- package/build/src/clock.d.ts +24 -0
- package/build/src/clock.d.ts.map +1 -0
- package/build/src/clock.js +7 -0
- package/build/src/clock.js.map +1 -0
- package/build/src/define_config.d.ts +201 -7
- package/build/src/define_config.d.ts.map +1 -1
- package/build/src/define_config.js.map +1 -1
- package/build/src/drivers/database_driver.d.ts +324 -15
- package/build/src/drivers/database_driver.d.ts.map +1 -1
- package/build/src/drivers/database_driver.js +1107 -128
- package/build/src/drivers/database_driver.js.map +1 -1
- package/build/src/drivers/database_relations_driver.d.ts +75 -0
- package/build/src/drivers/database_relations_driver.d.ts.map +1 -0
- package/build/src/drivers/database_relations_driver.js +450 -0
- package/build/src/drivers/database_relations_driver.js.map +1 -0
- package/build/src/drivers/openfga_driver.d.ts +963 -55
- package/build/src/drivers/openfga_driver.d.ts.map +1 -1
- package/build/src/drivers/openfga_driver.js +2693 -372
- package/build/src/drivers/openfga_driver.js.map +1 -1
- package/build/src/drivers/openfga_facts.d.ts +369 -0
- package/build/src/drivers/openfga_facts.d.ts.map +1 -0
- package/build/src/drivers/openfga_facts.js +813 -0
- package/build/src/drivers/openfga_facts.js.map +1 -0
- package/build/src/drivers/openfga_relations_driver.d.ts +120 -0
- package/build/src/drivers/openfga_relations_driver.d.ts.map +1 -0
- package/build/src/drivers/openfga_relations_driver.js +466 -0
- package/build/src/drivers/openfga_relations_driver.js.map +1 -0
- package/build/src/errors.d.ts +586 -0
- package/build/src/errors.d.ts.map +1 -1
- package/build/src/errors.js +583 -0
- package/build/src/errors.js.map +1 -1
- package/build/src/expiry.d.ts +27 -0
- package/build/src/expiry.d.ts.map +1 -0
- package/build/src/expiry.js +50 -0
- package/build/src/expiry.js.map +1 -0
- package/build/src/freeze.d.ts +120 -0
- package/build/src/freeze.d.ts.map +1 -0
- package/build/src/freeze.js +172 -0
- package/build/src/freeze.js.map +1 -0
- package/build/src/hierarchical_resolver.d.ts +56 -0
- package/build/src/hierarchical_resolver.d.ts.map +1 -0
- package/build/src/hierarchical_resolver.js +87 -0
- package/build/src/hierarchical_resolver.js.map +1 -0
- package/build/src/{middleware → http}/app_access_middleware.d.ts +8 -6
- package/build/src/http/app_access_middleware.d.ts.map +1 -0
- package/build/src/{middleware → http}/app_access_middleware.js +10 -26
- package/build/src/http/app_access_middleware.js.map +1 -0
- package/build/src/http/resource_access_middleware.d.ts +105 -0
- package/build/src/http/resource_access_middleware.d.ts.map +1 -0
- package/build/src/http/resource_access_middleware.js +81 -0
- package/build/src/http/resource_access_middleware.js.map +1 -0
- package/build/src/identity.d.ts +228 -0
- package/build/src/identity.d.ts.map +1 -0
- package/build/src/identity.js +457 -0
- package/build/src/identity.js.map +1 -0
- package/build/src/manager.d.ts +536 -7
- package/build/src/manager.d.ts.map +1 -1
- package/build/src/manager.js +2676 -23
- package/build/src/manager.js.map +1 -1
- package/build/src/memoize_ancestors.d.ts +23 -0
- package/build/src/memoize_ancestors.d.ts.map +1 -0
- package/build/src/memoize_ancestors.js +42 -0
- package/build/src/memoize_ancestors.js.map +1 -0
- package/build/src/models/authz_assignment.d.ts +11 -11
- package/build/src/models/authz_assignment.d.ts.map +1 -1
- package/build/src/models/authz_deny.d.ts +11 -11
- package/build/src/models/authz_deny.d.ts.map +1 -1
- package/build/src/models/authz_permission.d.ts +17 -11
- package/build/src/models/authz_permission.d.ts.map +1 -1
- package/build/src/models/authz_permission.js +4 -0
- package/build/src/models/authz_permission.js.map +1 -1
- package/build/src/models/authz_role.d.ts +19 -12
- package/build/src/models/authz_role.d.ts.map +1 -1
- package/build/src/models/authz_role.js +6 -1
- package/build/src/models/authz_role.js.map +1 -1
- package/build/src/models/authz_role_permission.d.ts +11 -11
- package/build/src/models/authz_role_permission.d.ts.map +1 -1
- package/build/src/openfga.d.ts +29 -0
- package/build/src/openfga.d.ts.map +1 -0
- package/build/src/openfga.js +26 -0
- package/build/src/openfga.js.map +1 -0
- package/build/src/reconcile.d.ts +37 -0
- package/build/src/reconcile.d.ts.map +1 -0
- package/build/src/reconcile.js +69 -0
- package/build/src/reconcile.js.map +1 -0
- package/build/src/relation_partition_trigger.d.ts +8 -0
- package/build/src/relation_partition_trigger.d.ts.map +1 -0
- package/build/src/relation_partition_trigger.js +85 -0
- package/build/src/relation_partition_trigger.js.map +1 -0
- package/build/src/relations/define_relations_config.d.ts +58 -0
- package/build/src/relations/define_relations_config.d.ts.map +1 -0
- package/build/src/relations/define_relations_config.js +144 -0
- package/build/src/relations/define_relations_config.js.map +1 -0
- package/build/src/relations/manager.d.ts +38 -0
- package/build/src/relations/manager.d.ts.map +1 -0
- package/build/src/relations/manager.js +156 -0
- package/build/src/relations/manager.js.map +1 -0
- package/build/src/relations/reconcile.d.ts +62 -0
- package/build/src/relations/reconcile.d.ts.map +1 -0
- package/build/src/relations/reconcile.js +138 -0
- package/build/src/relations/reconcile.js.map +1 -0
- package/build/src/relations_config_store.d.ts +22 -0
- package/build/src/relations_config_store.d.ts.map +1 -0
- package/build/src/relations_config_store.js +74 -0
- package/build/src/relations_config_store.js.map +1 -0
- package/build/src/scope_outbox.d.ts +69 -0
- package/build/src/scope_outbox.d.ts.map +1 -0
- package/build/src/scope_outbox.js +291 -0
- package/build/src/scope_outbox.js.map +1 -0
- package/build/src/shared/backend_guard.d.ts +106 -0
- package/build/src/shared/backend_guard.d.ts.map +1 -0
- package/build/src/shared/backend_guard.js +246 -0
- package/build/src/shared/backend_guard.js.map +1 -0
- package/build/src/shared/sql_expiry.d.ts +53 -0
- package/build/src/shared/sql_expiry.d.ts.map +1 -0
- package/build/src/shared/sql_expiry.js +66 -0
- package/build/src/shared/sql_expiry.js.map +1 -0
- package/build/src/sql_descendants.d.ts +97 -0
- package/build/src/sql_descendants.d.ts.map +1 -0
- package/build/src/sql_descendants.js +203 -0
- package/build/src/sql_descendants.js.map +1 -0
- package/build/src/testing/contract.d.ts +212 -7
- package/build/src/testing/contract.d.ts.map +1 -1
- package/build/src/testing/contract.js +3449 -24
- package/build/src/testing/contract.js.map +1 -1
- package/build/src/testing/main.d.ts +10 -2
- package/build/src/testing/main.d.ts.map +1 -1
- package/build/src/testing/main.js +5 -1
- package/build/src/testing/main.js.map +1 -1
- package/build/src/testing/migration_contract.d.ts +284 -0
- package/build/src/testing/migration_contract.d.ts.map +1 -0
- package/build/src/testing/migration_contract.js +586 -0
- package/build/src/testing/migration_contract.js.map +1 -0
- package/build/src/testing/relations_contract.d.ts +51 -0
- package/build/src/testing/relations_contract.d.ts.map +1 -0
- package/build/src/testing/relations_contract.js +654 -0
- package/build/src/testing/relations_contract.js.map +1 -0
- package/build/src/testing/relations_reconcile_contract.d.ts +24 -0
- package/build/src/testing/relations_reconcile_contract.d.ts.map +1 -0
- package/build/src/testing/relations_reconcile_contract.js +172 -0
- package/build/src/testing/relations_reconcile_contract.js.map +1 -0
- package/build/src/testing/scope_tree.d.ts +65 -0
- package/build/src/testing/scope_tree.d.ts.map +1 -0
- package/build/src/testing/scope_tree.js +145 -0
- package/build/src/testing/scope_tree.js.map +1 -0
- package/build/src/traits/authz_scopes.d.ts +30 -6
- package/build/src/traits/authz_scopes.d.ts.map +1 -1
- package/build/src/traits/authz_scopes.js +30 -18
- package/build/src/traits/authz_scopes.js.map +1 -1
- package/build/src/traits/has_uuid.d.ts +12 -12
- package/build/src/traits/has_uuid.d.ts.map +1 -1
- package/build/src/types.d.ts +1313 -28
- package/build/src/types.d.ts.map +1 -1
- package/build/src/types.js +20 -4
- package/build/src/types.js.map +1 -1
- package/build/stubs/config/app_acl.stub +4 -2
- package/build/stubs/config/authorization.stub +168 -14
- package/build/stubs/migration.stub +183 -13
- package/build/stubs/scopes_outbox_migration.stub +57 -0
- package/package.json +14 -7
- package/build/commands/openfga_import.d.ts +0 -28
- package/build/commands/openfga_import.d.ts.map +0 -1
- package/build/commands/openfga_import.js +0 -74
- package/build/commands/openfga_import.js.map +0 -1
- package/build/src/catalog.d.ts +0 -3
- package/build/src/catalog.d.ts.map +0 -1
- package/build/src/catalog.js +0 -103
- package/build/src/catalog.js.map +0 -1
- package/build/src/middleware/app_access_middleware.d.ts.map +0 -1
- package/build/src/middleware/app_access_middleware.js.map +0 -1
|
@@ -1,138 +1,215 @@
|
|
|
1
|
-
import { Exception } from '@adonisjs/core/exceptions';
|
|
2
1
|
import db from '@adonisjs/lucid/services/db';
|
|
3
|
-
import { OpenFgaClient, ClientWriteRequestOnDuplicateWrites, ClientWriteRequestOnMissingDeletes, } from '@openfga/sdk';
|
|
4
|
-
import { AuthorizationBackendError } from '../errors.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
2
|
+
import { OpenFgaClient, ClientWriteRequestOnDuplicateWrites, ClientWriteRequestOnMissingDeletes, ConsistencyPreference, } from '@openfga/sdk';
|
|
3
|
+
import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, AuthorizationInternalError, InvalidIdentityError, MassReconcileRefusedError, ReconcileTooLargeError, PurgeIncompleteError, ScopeCycleError, ScopeDriftUnguardedError, ScopeTreeDriftError, UnknownPermissionError, UnknownRoleError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
|
|
4
|
+
import { APP_SCOPE_TYPE } from '../types.js';
|
|
5
|
+
import { AUTHZ_TABLES_ORIGIN, RECONCILE_MAX_DETAILS, emptyReconcilePhases as emptyPhases, reconcileBatchSize, reconcileMaxTuples, sumReconcilePhases as sumPhases, } from '../reconcile.js';
|
|
6
|
+
import { assertRoleAssignableAt, declaredRoleAt, fromDbScopeUuid, hasRoleTargets, resolveRoleQuery, rolesToRevoke, visibleRoleFor, } from './database_driver.js';
|
|
7
|
+
import { assertCatalogUuid, assertIdentity, assertScope, chainKeysFrom, isCatalogUuid, isValidScope, normalizeRoleQuery, scopeFromKey, scopeKey, } from '../identity.js';
|
|
8
|
+
import { resolveGrantExpiry, sameInstant, toExpiryDate } from '../expiry.js';
|
|
9
|
+
import { assertKnownScope, canonicalScope, canonicalScopeTargets, guardSql, isAuthzError, isTimeoutLike, resolveChain, rootOnlyResolver, withDeadline, } from '../shared/backend_guard.js';
|
|
10
|
+
import { CatalogCache, GLOBAL_OWNER_KEY, assertCatalogOptions, isRoleVisibleWith, withAuthzCatalogWrite } from '../catalog/catalog_cache.js';
|
|
11
|
+
import { FACTS_ASSIGNEE_RELATION, FACTS_BINDING_RELATION, FACTS_BINDING_TYPE, FACTS_DENIED_PREFIX, FACTS_PARENT_RELATION, FACTS_PERMITS_PREFIX, FACTS_ROLE_RELATION, FACTS_ROLE_TYPE, FACTS_ROOTED_RELATION, FACTS_SCOPE_TYPE, assertFactsModelPublishable, assertHolderTypes, factsBindingObject, factsBindingTuples, factsCatalogTuples, factsDenyTuple, factsParentTuple, factsRelationsOf, factsRootTuples, factsScopeBindingTuple, factsScopeObject, factsTupleId, openFgaFactsModel, } from './openfga_facts.js';
|
|
12
|
+
import { readCatalogProjectionSnapshot } from '../catalog/catalog.js';
|
|
13
|
+
import { isSqliteDialect, sqlExpiryCodec } from '../shared/sql_expiry.js';
|
|
14
|
+
import { isClock, systemClock } from '../clock.js';
|
|
14
15
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* la misma clave —p. ej. `{org, 'anization|X'}` y `{'org|anization', 'X'}`—
|
|
19
|
-
* y un grant en uno autorizaría en el otro (confusión de privilegios).
|
|
20
|
-
*
|
|
21
|
-
* El driver `database` es inmune por construcción (guarda tipo y uuid en
|
|
22
|
-
* columnas separadas, sin codificar); esta validación protege la única ruta
|
|
23
|
-
* que serializa el scope a un string.
|
|
16
|
+
* La inyectividad de `holderTypes` la comprueba el módulo del modelo
|
|
17
|
+
* (`openfga_facts.ts`, compartido por los dos generadores). Se re-exporta
|
|
18
|
+
* desde aquí porque el subpath `/openfga` es la puerta publicada.
|
|
24
19
|
*/
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
}
|
|
20
|
+
export { assertHolderTypes };
|
|
21
|
+
// La clave de scope del id del binding (`app` | `<tipo>|<uuid>`) es la
|
|
22
|
+
// misma `scopeKey` del paquete (`identity.ts`; desde 3B también el owner de
|
|
23
|
+
// un rol local): `|` es el separador y `assertScope` impide que un
|
|
24
|
+
// componente lo lleve (dos scopes distintos no producen la misma clave) y
|
|
25
|
+
// rechaza `{app, uuid}` (L0.10).
|
|
32
26
|
/**
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
27
|
+
* Id de binding (`<scopeKey>|<uuid>`: `app|<uuid>` o `<tipo>|<uuidScope>|<uuid>`)
|
|
28
|
+
* → scope + uuid del ROL (3b-2k · K2: el `deny_binding`, que era el otro
|
|
29
|
+
* consumidor de esta gramática, se fue con el modo `resolver`; el deny es hoy
|
|
30
|
+
* una relación del scope). Se parsea DESDE LA DERECHA (3A · A1): el último componente
|
|
31
|
+
* es el uuid y el resto la clave del scope, que tiene 1 parte (`app`) o 2
|
|
32
|
+
* (`<tipo>|<uuid>`). Antes el último componente era el slug codificado
|
|
33
|
+
* (`docs~read`) y el parseo contaba partes: ambiguo en cuanto la clave del
|
|
34
|
+
* scope variara de longitud (panel 2026-08-28, §2-C), y con un escape no
|
|
35
|
+
* inyectivo desde el llamante (L0.8a).
|
|
36
|
+
*
|
|
37
|
+
* `null` si no tiene la forma del motor O si alguna parte no pasa su
|
|
38
|
+
* gramática —el scope, la de identidad; el uuid, la de UUID canónico del
|
|
39
|
+
* catálogo—: un id que el driver no escribiría no es un hecho del motor
|
|
40
|
+
* aunque esté en el store. Los ids de 1.x/2.0–2.1 (con slug) caen aquí:
|
|
41
|
+
* 2.2 no los lee, y `authz:reconcile` (3b-3) los reportará como deriva.
|
|
42
|
+
* Exportada para probarla sin servidor.
|
|
36
43
|
*/
|
|
37
|
-
function
|
|
38
|
-
|
|
39
|
-
if (
|
|
40
|
-
return
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
if (parts.length === 2 && parts[0] === APP_SCOPE_TYPE) {
|
|
47
|
-
return { scope: { type: APP_SCOPE_TYPE, uuid: null }, slug: decodeSlug(parts[1]) };
|
|
44
|
+
export function parseBindingId(id) {
|
|
45
|
+
const cut = id.lastIndexOf('|');
|
|
46
|
+
if (cut < 0)
|
|
47
|
+
return null;
|
|
48
|
+
const uuid = id.slice(cut + 1);
|
|
49
|
+
const keyParts = id.slice(0, cut).split('|');
|
|
50
|
+
let scope;
|
|
51
|
+
if (keyParts.length === 1 && keyParts[0] === APP_SCOPE_TYPE) {
|
|
52
|
+
scope = { type: APP_SCOPE_TYPE, uuid: null };
|
|
48
53
|
}
|
|
49
|
-
if (
|
|
50
|
-
|
|
54
|
+
else if (keyParts.length === 2) {
|
|
55
|
+
scope = { type: keyParts[0], uuid: keyParts[1] };
|
|
51
56
|
}
|
|
52
|
-
|
|
57
|
+
else {
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
if (!isValidScope(scope) || !isCatalogUuid(uuid))
|
|
61
|
+
return null;
|
|
62
|
+
return { scope, uuid };
|
|
53
63
|
}
|
|
54
64
|
/** `<tipoFga>:<uuid>` a partir del morph name del holder. */
|
|
55
65
|
function fgaSubjectWith(subject, holderTypes) {
|
|
56
66
|
const fgaType = holderTypes[subject.type];
|
|
57
67
|
if (!fgaType) {
|
|
58
|
-
|
|
68
|
+
// Contradicción de config (D15): el modelo del store no tiene ese tipo.
|
|
69
|
+
throw new AuthorizationConfigError(`Holder type '${subject.type}' no está en el modelo FGA ` +
|
|
59
70
|
`(declarados: ${Object.keys(holderTypes).join(', ') || 'ninguno'}). ` +
|
|
60
|
-
`Añádelo a holderTypes y regenera el authorization model
|
|
71
|
+
`Añádelo a holderTypes y regenera el authorization model.`);
|
|
61
72
|
}
|
|
62
73
|
return `${fgaType}:${subject.uuid}`;
|
|
63
74
|
}
|
|
64
|
-
function checkContext() {
|
|
65
|
-
return { current_time: new Date().toISOString() };
|
|
66
|
-
}
|
|
67
75
|
/**
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
76
|
+
* El `context` de TODA consulta que evalúe relaciones: checks de roles y de
|
|
77
|
+
* denies. Un único constructor a propósito (S17): en cuanto una tupla del
|
|
78
|
+
* camino lleva la condición `not_expired`, un check sin `current_time` falla
|
|
79
|
+
* entero (400 → 503). Hoy los denies no llevan condición; el modo facts (3b)
|
|
80
|
+
* evalúa deny y grant en un solo check, así que no hay margen. Las
|
|
81
|
+
* enumeraciones no evalúan nada (`Read` devuelve tuplas escritas): filtran
|
|
82
|
+
* la caducidad en cliente con el MISMO reloj del driver (`now()`, J1): el
|
|
83
|
+
* `current_time` que viaja en cada check es el instante que decide.
|
|
71
84
|
*/
|
|
72
|
-
function
|
|
73
|
-
|
|
74
|
-
return true;
|
|
75
|
-
if (!storedValidUntil || !requested)
|
|
76
|
-
return false;
|
|
77
|
-
const stored = Date.parse(storedValidUntil);
|
|
78
|
-
return Number.isFinite(stored) && stored === requested.getTime();
|
|
85
|
+
function checkContext(now) {
|
|
86
|
+
return { current_time: now.toISOString() };
|
|
79
87
|
}
|
|
80
88
|
/**
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
89
|
+
* Alinea los resultados de un batchCheck con los checks pedidos por
|
|
90
|
+
* `correlationId`, no por posición (L0.14). El SDK reparte el lote en
|
|
91
|
+
* sub-lotes paralelos y concatena las respuestas según llegan: el orden no es
|
|
92
|
+
* el de los checks. Cardinalidad igual no basta —un id duplicado y otro
|
|
93
|
+
* ausente pasan el conteo—: cada check debe tener EXACTAMENTE un resultado y
|
|
94
|
+
* ningún resultado puede ser de un check que no se pidió.
|
|
84
95
|
*/
|
|
85
|
-
export function
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
relations: { denied: { directly_related_user_types: direct } },
|
|
108
|
-
},
|
|
109
|
-
},
|
|
110
|
-
],
|
|
111
|
-
conditions: {
|
|
112
|
-
not_expired: {
|
|
113
|
-
name: 'not_expired',
|
|
114
|
-
expression: 'current_time < valid_until',
|
|
115
|
-
parameters: {
|
|
116
|
-
current_time: { type_name: 'TYPE_NAME_TIMESTAMP' },
|
|
117
|
-
valid_until: { type_name: 'TYPE_NAME_TIMESTAMP' },
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
|
-
},
|
|
121
|
-
};
|
|
96
|
+
export function correlateBatchResults(checks, results) {
|
|
97
|
+
const byId = new Map();
|
|
98
|
+
for (const result of results) {
|
|
99
|
+
const id = result.correlationId;
|
|
100
|
+
if (byId.has(id)) {
|
|
101
|
+
throw new AuthorizationInternalError(`OpenFGA batchCheck devolvió dos resultados para el correlationId '${id}'`);
|
|
102
|
+
}
|
|
103
|
+
byId.set(id, result);
|
|
104
|
+
}
|
|
105
|
+
const aligned = checks.map((check) => {
|
|
106
|
+
const result = byId.get(check.correlationId);
|
|
107
|
+
if (!result) {
|
|
108
|
+
throw new AuthorizationInternalError(`OpenFGA batchCheck no devolvió resultado para el check '${check.correlationId}' (${check.relation} ${check.object})`);
|
|
109
|
+
}
|
|
110
|
+
return result;
|
|
111
|
+
});
|
|
112
|
+
if (byId.size !== checks.length) {
|
|
113
|
+
const requested = new Set(checks.map((c) => c.correlationId));
|
|
114
|
+
const foreign = [...byId.keys()].filter((id) => !requested.has(id));
|
|
115
|
+
throw new AuthorizationInternalError(`OpenFGA batchCheck devolvió resultados de checks no pedidos: ${foreign.join(', ')}`);
|
|
116
|
+
}
|
|
117
|
+
return aligned;
|
|
122
118
|
}
|
|
123
119
|
/**
|
|
124
|
-
* Crea un store nuevo + escribe el authorization model
|
|
125
|
-
*
|
|
126
|
-
*
|
|
127
|
-
*
|
|
120
|
+
* Crea un store nuevo + escribe el authorization model **`facts` (c2r)**
|
|
121
|
+
* derivado de los holders y de los PERMISOS del consumidor (3b-2k · K2:
|
|
122
|
+
* antes escribía el modelo del modo `resolver`, que ya no existe). Para
|
|
123
|
+
* bootstrap de un appliance o del harness de tests. El `name` lo decide el
|
|
124
|
+
* caller (el comando `openfga:provision` resuelve APP_NAME del entorno — el
|
|
125
|
+
* motor no lee env).
|
|
126
|
+
*
|
|
127
|
+
* Los permisos entran aquí porque el modelo (c2r) declara CUATRO relaciones
|
|
128
|
+
* por permiso: un store provisionado sin ellos no puede responder a ninguna
|
|
129
|
+
* pregunta. `assertFactsModelPublishable` comprueba antes las cotas (nombre
|
|
130
|
+
* de relación y techo de 262.144 bytes ⇒ 500 `E_AUTHZ_MODEL_TOO_LARGE`).
|
|
131
|
+
* Añadir un permiso al catálogo obliga a republicar el modelo: es lo que
|
|
132
|
+
* hace `syncAuthzCatalog` con la proyección inyectada, y por eso el modelo
|
|
133
|
+
* versionado del store se escribe con `--store-id`.
|
|
134
|
+
*
|
|
135
|
+
* **Y los tipos de RELACIÓN (ReBAC) van FUSIONADOS** (Fase 4-8): si el
|
|
136
|
+
* consumidor declaró `relations.config`, `openfga:provision` los pasa aquí y
|
|
137
|
+
* el modelo publicado los incluye, de modo que un store recién aprovisionado
|
|
138
|
+
* ya acepta tuplas de relación (`document#viewer`…) SIN un
|
|
139
|
+
* `authz:catalog:sync` previo. Sin ellos el modelo es facts-only y una tupla
|
|
140
|
+
* de relación es un `validation_error` del servidor («type 'document' not
|
|
141
|
+
* found»). El gate de bytes mide el modelo FUSIONADO.
|
|
128
142
|
*/
|
|
129
|
-
export async function provisionOpenFgaStore(apiUrl, name, holderTypeMap) {
|
|
143
|
+
export async function provisionOpenFgaStore(apiUrl, name, holderTypeMap, permissions, relations) {
|
|
130
144
|
const client = new OpenFgaClient({ apiUrl });
|
|
131
145
|
const store = await client.createStore({ name });
|
|
132
146
|
const scoped = new OpenFgaClient({ apiUrl, storeId: store.id });
|
|
133
|
-
const model = await scoped.writeAuthorizationModel(
|
|
147
|
+
const model = await scoped.writeAuthorizationModel(openFgaFactsModel(holderTypeMap, permissions, relations));
|
|
134
148
|
return { storeId: store.id, modelId: model.authorization_model_id };
|
|
135
149
|
}
|
|
150
|
+
/**
|
|
151
|
+
* ¿El error (o su cadena de causas) es el rechazo de FGA a escribir una tuple
|
|
152
|
+
* key que ya existe? Es la ÚNICA señal de carrera check-then-write que
|
|
153
|
+
* `grant` acepta: un 400 de validación (`validation_error`) o un 5xx no son
|
|
154
|
+
* "alguien escribió antes" y se propagan clasificados, con el error del SDK
|
|
155
|
+
* como causa (D6). Verificado contra OpenFGA v1.19: el duplicado llega como
|
|
156
|
+
* HTTP 400 con `apiErrorCode: 'write_failed_due_to_invalid_input'` y el
|
|
157
|
+
* mensaje "cannot write a tuple which already exists".
|
|
158
|
+
*
|
|
159
|
+
* **Y el 409 tiene nombre propio** (3b-2f · R3, medido contra el servidor):
|
|
160
|
+
* es el `Aborted` de un `Write` transaccional cuyas tuplas escribió otra
|
|
161
|
+
* transacción a la vez ("transactional write failed due to conflict: one or
|
|
162
|
+
* more tuples to write were inserted by another transaction"). Dice lo mismo
|
|
163
|
+
* —otro escritor llegó antes— y se trata igual: releer y re-aplicar. Lo que
|
|
164
|
+
* NO dice es QUÉ tupla chocó, así que quién existía lo decide la relectura y
|
|
165
|
+
* nunca el mensaje.
|
|
166
|
+
*/
|
|
167
|
+
export function isDuplicateWrite(error) {
|
|
168
|
+
let current = error;
|
|
169
|
+
for (let depth = 0; current && depth < 6; depth++) {
|
|
170
|
+
if (current.statusCode === 409)
|
|
171
|
+
return true;
|
|
172
|
+
if (current.apiErrorCode === 'write_failed_due_to_invalid_input' &&
|
|
173
|
+
/already exists/i.test(String(current.apiErrorMessage ?? current.message ?? ''))) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
current = current.cause;
|
|
177
|
+
}
|
|
178
|
+
return false;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* ¿El `Write` de una arista del árbol chocó con OTRO escritor? (3b-2h · 🟠 4)
|
|
182
|
+
* Son los dos lados del mismo choque: la tupla que se escribe ya está
|
|
183
|
+
* (`isDuplicateWrite`) o la que se borra ya no está —la acaba de borrar el
|
|
184
|
+
* otro—. En `reparent` las dos vienen SIEMPRE de una carrera: la lista de
|
|
185
|
+
* deletes se acaba de leer del store. Cualquier otro fallo del write se
|
|
186
|
+
* propaga clasificado, como hasta ahora.
|
|
187
|
+
*/
|
|
188
|
+
function isTreeWriteRace(error) {
|
|
189
|
+
if (isDuplicateWrite(error))
|
|
190
|
+
return true;
|
|
191
|
+
let current = error;
|
|
192
|
+
for (let depth = 0; current && depth < 6; depth++) {
|
|
193
|
+
if (current.apiErrorCode === 'write_failed_due_to_invalid_input' &&
|
|
194
|
+
/does not exist/i.test(String(current.apiErrorMessage ?? current.message ?? ''))) {
|
|
195
|
+
return true;
|
|
196
|
+
}
|
|
197
|
+
current = current.cause;
|
|
198
|
+
}
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
/**
|
|
202
|
+
* Receta que acompaña al 503 de un `grant` SIN `expiresAt` cuando no se pudo
|
|
203
|
+
* leer la caducidad vigente: preservar exige saber qué hay, y asumir
|
|
204
|
+
* "permanente" sería L0.4 en modo degradado. Se añade al mensaje del error
|
|
205
|
+
* ya clasificado (mismo tipo, mismo `code`, misma causa).
|
|
206
|
+
*/
|
|
207
|
+
function withPreserveRecipe(error) {
|
|
208
|
+
error.message +=
|
|
209
|
+
`. 'grant' sin 'expiresAt' necesita leer la caducidad vigente para preservarla y no se ` +
|
|
210
|
+
`escribe a ciegas: si la intención es "permanente", pasa { expiresAt: null }; si es temporal, una Date.`;
|
|
211
|
+
return error;
|
|
212
|
+
}
|
|
136
213
|
/**
|
|
137
214
|
* Devuelve el cliente con TODOS sus métodos envueltos: un fallo de red o un
|
|
138
215
|
* 5xx sale como `AuthorizationBackendError` (503) y no como el `FgaError` del
|
|
@@ -148,21 +225,28 @@ export async function provisionOpenFgaStore(apiUrl, name, holderTypeMap) {
|
|
|
148
225
|
* ahí el error del SDK es la información más útil y no rompe ninguna
|
|
149
226
|
* abstracción.
|
|
150
227
|
*/
|
|
151
|
-
function guardBackendErrors(client) {
|
|
228
|
+
function guardBackendErrors(client, timeoutMs) {
|
|
152
229
|
return new Proxy(client, {
|
|
153
230
|
get(target, prop, receiver) {
|
|
154
231
|
const value = Reflect.get(target, prop, receiver);
|
|
155
232
|
if (typeof value !== 'function')
|
|
156
233
|
return value;
|
|
157
234
|
return (...args) => {
|
|
158
|
-
const
|
|
235
|
+
const operation = String(prop);
|
|
236
|
+
const fail = (cause) => isTimeoutLike(cause)
|
|
237
|
+
? new AuthorizationBackendTimeoutError('openfga', operation, timeoutMs, cause)
|
|
238
|
+
: new AuthorizationBackendError('openfga', operation, cause);
|
|
159
239
|
try {
|
|
160
240
|
const result = value.apply(target, args);
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
241
|
+
if (!(result instanceof Promise))
|
|
242
|
+
return result;
|
|
243
|
+
// Deadline TOTAL por llamada (reintentos del SDK incluidos): el
|
|
244
|
+
// `timeout` de axios corta cada intento, pero el SDK reintenta los
|
|
245
|
+
// errores de red con backoff y sin esto el llamante esperaría la
|
|
246
|
+
// suma de todos. Un deadline es un deadline.
|
|
247
|
+
return withDeadline(result.catch((error) => {
|
|
248
|
+
throw fail(error);
|
|
249
|
+
}), timeoutMs, () => new AuthorizationBackendTimeoutError('openfga', operation, timeoutMs));
|
|
166
250
|
}
|
|
167
251
|
catch (error) {
|
|
168
252
|
throw fail(error);
|
|
@@ -172,378 +256,2615 @@ function guardBackendErrors(client) {
|
|
|
172
256
|
});
|
|
173
257
|
}
|
|
174
258
|
/**
|
|
175
|
-
*
|
|
176
|
-
* los denies de las tablas `authz_*` como tuples del store FGA.
|
|
259
|
+
* **El gate de construcción de `facts`** (3b-2d, panel 2 cruce 4 · S5).
|
|
177
260
|
*
|
|
178
|
-
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
*
|
|
184
|
-
*
|
|
261
|
+
* En `hierarchy: 'facts'` el árbol vive en el store de FGA y FGA es el PDP.
|
|
262
|
+
* El consumidor notifica `scopes.moved` dentro de su transacción, el paquete
|
|
263
|
+
* escribe la arista en FGA… y si esa transacción hace `rollback` —una
|
|
264
|
+
* constraint, una validación, un timeout de pool: no hace falta un crash—
|
|
265
|
+
* la escritura de FGA NO se deshace. SQL sigue diciendo que la unit es del
|
|
266
|
+
* tenant A y FGA que es del B: todos los holders con rol en B tienen acceso
|
|
267
|
+
* a una unidad de A, y la aplicación, que lista y audita contra SQL, no
|
|
268
|
+
* puede verlo. La ventana no es un hueco entre dos operaciones: dura hasta
|
|
269
|
+
* que alguien lo descubra.
|
|
270
|
+
*
|
|
271
|
+
* Por eso `scopes.outbox` no puede ser una recomendación: una recomendación
|
|
272
|
+
* no es un mecanismo. O está el puerto, o está la firma del dueño.
|
|
185
273
|
*/
|
|
186
|
-
export
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
skippedExpired: 0,
|
|
197
|
-
dryRun: options.dryRun ?? false,
|
|
198
|
-
};
|
|
199
|
-
const tuples = [];
|
|
200
|
-
const assignments = await db
|
|
201
|
-
.from('authz_assignments as a')
|
|
202
|
-
.join('authz_roles as r', 'r.uuid', 'a.role_uuid')
|
|
203
|
-
.select('a.holder_type', 'a.holder_uuid', 'a.scope_type', 'a.scope_uuid', 'a.expires_at')
|
|
204
|
-
.select('r.slug as role_slug');
|
|
205
|
-
const rowScope = (row) => ({
|
|
206
|
-
type: row.scope_type,
|
|
207
|
-
uuid: row.scope_uuid === APP_SCOPE_DB_UUID ? null : row.scope_uuid,
|
|
208
|
-
});
|
|
209
|
-
for (const row of assignments) {
|
|
210
|
-
const expiresAt = row.expires_at ? new Date(row.expires_at) : null;
|
|
211
|
-
if (expiresAt && expiresAt <= now) {
|
|
212
|
-
result.skippedExpired++;
|
|
213
|
-
continue;
|
|
214
|
-
}
|
|
215
|
-
const scope = rowScope(row);
|
|
216
|
-
const key = {
|
|
217
|
-
user: fgaSubjectWith({ type: row.holder_type, uuid: row.holder_uuid }, options.holderTypes),
|
|
218
|
-
relation: 'assignee',
|
|
219
|
-
object: `role_binding:${scopeKey(scope)}|${encodeSlug(row.role_slug)}`,
|
|
220
|
-
};
|
|
221
|
-
tuples.push(expiresAt
|
|
222
|
-
? {
|
|
223
|
-
...key,
|
|
224
|
-
condition: { name: 'not_expired', context: { valid_until: expiresAt.toISOString() } },
|
|
225
|
-
}
|
|
226
|
-
: key);
|
|
227
|
-
result.assignments++;
|
|
228
|
-
}
|
|
229
|
-
const denies = await db
|
|
230
|
-
.from('authz_denies as d')
|
|
231
|
-
.join('authz_permissions as p', 'p.uuid', 'd.permission_uuid')
|
|
232
|
-
.select('d.holder_type', 'd.holder_uuid', 'd.scope_type', 'd.scope_uuid')
|
|
233
|
-
.select('p.slug as permission_slug');
|
|
234
|
-
for (const row of denies) {
|
|
235
|
-
const scope = rowScope(row);
|
|
236
|
-
tuples.push({
|
|
237
|
-
user: fgaSubjectWith({ type: row.holder_type, uuid: row.holder_uuid }, options.holderTypes),
|
|
238
|
-
relation: 'denied',
|
|
239
|
-
object: `deny_binding:${scopeKey(scope)}|${encodeSlug(row.permission_slug)}`,
|
|
240
|
-
});
|
|
241
|
-
result.denies++;
|
|
242
|
-
}
|
|
243
|
-
if (!result.dryRun && tuples.length > 0) {
|
|
244
|
-
// Chunks: el write transaccional de FGA tiene límite de tuples por request.
|
|
245
|
-
for (let i = 0; i < tuples.length; i += 50) {
|
|
246
|
-
await client.writeTuples(tuples.slice(i, i + 50), {
|
|
247
|
-
conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore },
|
|
248
|
-
});
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
return result;
|
|
274
|
+
export function assertScopeDriftGuarded(options) {
|
|
275
|
+
if (options.outbox)
|
|
276
|
+
return;
|
|
277
|
+
if (options.acceptScopeDriftRisk === true)
|
|
278
|
+
return;
|
|
279
|
+
throw new ScopeDriftUnguardedError('OpenFgaAuthorizationDriver necesita la outbox del árbol: pasa la misma ' +
|
|
280
|
+
"'scopes.outbox' del config (el manager encola ahí los cambios del árbol dentro de TU transacción y " +
|
|
281
|
+
"'authz:scopes:relay' los aplica). Sin ella, un rollback de tu transacción deja el árbol de FGA " +
|
|
282
|
+
'adelantado al tuyo y esa escalada no se ve desde tu base. Si mueves el árbol solo desde la ' +
|
|
283
|
+
"plataforma y lo asumes, dilo por escrito con acceptScopeDriftRisk: true.");
|
|
252
284
|
}
|
|
285
|
+
export const DEFAULT_TIMEOUT_MS = 5_000;
|
|
286
|
+
/**
|
|
287
|
+
* Opciones de un `Write` que da por buena una tupla que ya estaba. Solo se
|
|
288
|
+
* usa donde el duplicado es IDÉNTICO por construcción (las aristas de (c2),
|
|
289
|
+
* que no llevan condición) o donde ya se sabe que la asignación no existe:
|
|
290
|
+
* en FGA la condición NO es parte de la clave, así que ignorar duplicados
|
|
291
|
+
* sobre una tupla con caducidad se quedaría la vieja (S7).
|
|
292
|
+
*/
|
|
293
|
+
const IGNORE_DUPLICATE_WRITES = {
|
|
294
|
+
conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore },
|
|
295
|
+
};
|
|
296
|
+
/**
|
|
297
|
+
* Vueltas que da el `grant` ante un choque de escritura antes de rendirse con
|
|
298
|
+
* un 409 (3b-2f · R3). Dos bastan para el caso real —la primera descubre que
|
|
299
|
+
* la estructura ya estaba, la segunda la da por buena—; la tercera es el
|
|
300
|
+
* margen para una contención de verdad.
|
|
301
|
+
*/
|
|
302
|
+
const GRANT_WRITE_ATTEMPTS = 3;
|
|
303
|
+
/** Vueltas de relectura de una arista del árbol antes de decir 409 (3b-2h · 🟠 4). */
|
|
304
|
+
const TREE_WRITE_ATTEMPTS = 3;
|
|
305
|
+
/** Tope de operaciones por `Write` en FGA (verificado: `exceeded_entity_limit` a partir de 100). */
|
|
306
|
+
const PURGE_BATCH_SIZE = 100;
|
|
307
|
+
/** Tamaño de página de `Read` (máximo del servidor). */
|
|
308
|
+
const READ_PAGE_SIZE = 100;
|
|
309
|
+
/**
|
|
310
|
+
* El deny del modo `resolver` (≤ 2.2), que el modelo de hoy ni declara pero
|
|
311
|
+
* un store de aquella versión sí tiene: `enumerateFacts` lo EMITE como deny
|
|
312
|
+
* (3b-8 · A2) porque `reconcile` es el sustituto documentado del importador
|
|
313
|
+
* y perderlo en silencio rompía el invariante 2 con el verificador en verde.
|
|
314
|
+
*/
|
|
315
|
+
const LEGACY_DENY_BINDING_TYPE = 'deny_binding';
|
|
316
|
+
const LEGACY_DENIED_RELATION = 'denied';
|
|
317
|
+
/**
|
|
318
|
+
* Cota de páginas de una enumeración (1.000.000 de tuplas a 100 por página).
|
|
319
|
+
* Un `continuation_token` que no avanza —un servidor roto, un proxy o una
|
|
320
|
+
* caché delante— era un bucle infinito que ningún deadline cortaba, porque
|
|
321
|
+
* el deadline es por llamada (D12, auditor H7).
|
|
322
|
+
*/
|
|
323
|
+
export const MAX_READ_PAGES = 10_000;
|
|
324
|
+
/**
|
|
325
|
+
* Tope de saltos al subir el árbol DEL STORE (3b-2e · E1). No es el techo de
|
|
326
|
+
* decisión —ese lo pone el servidor al evaluar (c2) y está medido en
|
|
327
|
+
* `FACTS_MAX_RESOLVE_DEPTH`—: es la red del recorrido, para que un árbol con
|
|
328
|
+
* una deriva que el anti-ciclos no vio no deje el proceso dando vueltas.
|
|
329
|
+
*/
|
|
330
|
+
export const MAX_SCOPE_CHAIN_HOPS = 1_000;
|
|
253
331
|
export class OpenFgaAuthorizationDriver {
|
|
332
|
+
/**
|
|
333
|
+
* Lo que este driver declara (3b-2e · E2). Depende del MODO, así que es un
|
|
334
|
+
* getter y no un campo: una vista por prototipo (`withChainResolver`,
|
|
335
|
+
* `withClock`) declara lo mismo que su original.
|
|
336
|
+
*
|
|
337
|
+
* `roleInheritanceNative` y `listObjectsInherited` son `false` **también en
|
|
338
|
+
* `facts`**, y eso es el cruce 6 del panel: `hasRole`/`listRoles`/
|
|
339
|
+
* `listRoleScopes`/`listSubjects`/`listScopes` siguen usando `resolveChain`
|
|
340
|
+
* (en (c2) no hay alternativa, y está medido), y los `list*` enumeran con
|
|
341
|
+
* `Read` paginado, nunca con `ListObjects` (que trunca al tope del servidor
|
|
342
|
+
* sin señal). Lo único que `facts` cambia es `authorize`.
|
|
343
|
+
*/
|
|
344
|
+
get capabilities() {
|
|
345
|
+
return Object.freeze({
|
|
346
|
+
hierarchyFacts: true,
|
|
347
|
+
singleCheckAuthorize: true,
|
|
348
|
+
roleInheritanceNative: false,
|
|
349
|
+
listObjectsInherited: false,
|
|
350
|
+
// 3b-2e · E4 / 3b-2j: purgar un rol y contar sus hechos necesitan
|
|
351
|
+
// enumerar sus bindings, y eso lo permiten las aristas de (c2)
|
|
352
|
+
// (`role_binding#role` y `scope#binding`).
|
|
353
|
+
purgeRole: true,
|
|
354
|
+
countRoleAssignments: true,
|
|
355
|
+
// 3b-2k · K1 · R2 (c): la decisión no pasa por el árbol, así que el
|
|
356
|
+
// objeto del store se compone con la ortografía del LLAMANTE y un alias
|
|
357
|
+
// del uuid no encuentra sus hechos (fail-CLOSED).
|
|
358
|
+
canonicalScopeReads: false,
|
|
359
|
+
// 3b-3b: sabe ser el ORIGEN de una migración — sus hechos son tuplas
|
|
360
|
+
// del store, y solo él sabe volverlas a `(holder, rol, scope)`.
|
|
361
|
+
enumerateFacts: true,
|
|
362
|
+
});
|
|
363
|
+
}
|
|
254
364
|
client;
|
|
255
|
-
|
|
365
|
+
chainResolver;
|
|
256
366
|
holderTypes;
|
|
367
|
+
/**
|
|
368
|
+
* Contadores observables del driver. `unparseableBindings`: ids del store
|
|
369
|
+
* que el motor no entiende (L0.16). Cada uno es un hecho que las
|
|
370
|
+
* enumeraciones NO muestran; se registra y se cuenta, jamás un `continue`
|
|
371
|
+
* mudo — quien opera el store tiene que poder verlo.
|
|
372
|
+
*/
|
|
373
|
+
diagnostics = { unparseableBindings: 0 };
|
|
374
|
+
logger;
|
|
375
|
+
timeoutMs;
|
|
376
|
+
consistency;
|
|
377
|
+
/** Reloj de pared del driver (J1): el ÚNICO `now` de checks, filtros y re-grant. */
|
|
378
|
+
now;
|
|
379
|
+
/**
|
|
380
|
+
* Memo del catálogo (2A): permisos, roles por nivel y roles que conceden
|
|
381
|
+
* cada permiso se leen de aquí en el camino caliente; antes eran dos
|
|
382
|
+
* consultas SQL por `authorize`. Los hechos siguen en FGA en cada pregunta.
|
|
383
|
+
* Se revalida contra `authz_catalog_version` (2D · F1): cada operación
|
|
384
|
+
* toma la foto UNA vez (`view()`) y lee de ella todo lo que necesita.
|
|
385
|
+
* `catalog.invalidate()` fuerza la recarga de ESTE memo.
|
|
386
|
+
*/
|
|
387
|
+
catalog;
|
|
257
388
|
constructor(options) {
|
|
389
|
+
assertHolderTypes(options.holderTypes);
|
|
390
|
+
assertCatalogOptions('OpenFgaAuthorizationDriver', options);
|
|
391
|
+
if (options.now !== undefined && !isClock(options.now)) {
|
|
392
|
+
throw new AuthorizationConfigError(`OpenFgaAuthorizationDriver: 'now' debe ser una función () => Date (llegó ${typeof options.now})`);
|
|
393
|
+
}
|
|
394
|
+
this.now = options.now ?? systemClock;
|
|
395
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
396
|
+
this.catalog =
|
|
397
|
+
options.catalog ?? new CatalogCache({ driver: 'openfga', timeoutMs, revalidate: options.catalogRevalidate });
|
|
258
398
|
this.client = guardBackendErrors(new OpenFgaClient({
|
|
259
399
|
apiUrl: options.apiUrl,
|
|
260
400
|
storeId: options.storeId,
|
|
261
401
|
authorizationModelId: options.modelId,
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
402
|
+
// `baseOptions` se funde en la config de axios de cada request: es la
|
|
403
|
+
// única vía del SDK (no tiene `timeoutMs` propio; su default es 10 s).
|
|
404
|
+
baseOptions: { timeout: timeoutMs },
|
|
405
|
+
// Sin reintentos a escondidas (ver `OpenFgaDriverOptions.retryParams`).
|
|
406
|
+
retryParams: { maxRetry: 0, ...options.retryParams },
|
|
407
|
+
}), timeoutMs);
|
|
408
|
+
// Sin resolutor solo existe la raíz (L0.3: el default plano desapareció).
|
|
409
|
+
this.chainResolver = options.resolveChain ?? rootOnlyResolver;
|
|
266
410
|
this.holderTypes = options.holderTypes;
|
|
411
|
+
assertScopeDriftGuarded(options);
|
|
412
|
+
this.logger = options.logger ?? console;
|
|
413
|
+
this.timeoutMs = timeoutMs;
|
|
414
|
+
this.consistency =
|
|
415
|
+
options.consistency === 'minimize_latency'
|
|
416
|
+
? ConsistencyPreference.MinimizeLatency
|
|
417
|
+
: ConsistencyPreference.HigherConsistency;
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* `[scope canónico, ...ancestros]`, o `null` si el scope no existe
|
|
421
|
+
* (lecturas: denegar). `chain[0]` —la fila del consumidor, no lo que
|
|
422
|
+
* escribió el llamante— es el scope que va en la clave de cada binding
|
|
423
|
+
* (2.5-B · K1): un alias del uuid que el árbol funde con la fila real
|
|
424
|
+
* llega aquí ya canónico y el `deny_binding` escrito canónico casa.
|
|
425
|
+
*/
|
|
426
|
+
chain(scope, operation) {
|
|
427
|
+
return resolveChain(this.chainResolver, scope, operation);
|
|
428
|
+
}
|
|
429
|
+
/** La cadena o 422: una escritura no puede ir a un scope que nadie reconoce. */
|
|
430
|
+
knownScope(scope, operation) {
|
|
431
|
+
return assertKnownScope(this.chainResolver, scope, operation);
|
|
432
|
+
}
|
|
433
|
+
/** El scope canónico para `scopes.detached`/`purgeScope` (ver `canonicalScope`). */
|
|
434
|
+
canonicalOrSelf(scope, operation) {
|
|
435
|
+
return canonicalScope(this.chainResolver, scope, operation);
|
|
436
|
+
}
|
|
437
|
+
/** Los destinos de un delete de hechos (`revoke`/`removeDeny`): canónico, o el fan-out de alias (3b-8 · A4). */
|
|
438
|
+
canonicalTargets(scope, operation) {
|
|
439
|
+
return canonicalScopeTargets(this.chainResolver, scope, operation);
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Vista de este driver con OTRO resolutor de ancestros y el mismo estado
|
|
443
|
+
* (cliente, memo del catálogo, deadline, diagnósticos). Es lo que usa
|
|
444
|
+
* `AuthorizationManager.forRequest()` para leer con un resolutor memoizado
|
|
445
|
+
* sin tocar el driver compartido: hereda por prototipo y solo sobrescribe
|
|
446
|
+
* el resolutor.
|
|
447
|
+
*/
|
|
448
|
+
withChainResolver(resolveChain) {
|
|
449
|
+
const view = Object.create(this);
|
|
450
|
+
view.chainResolver = resolveChain;
|
|
451
|
+
return view;
|
|
452
|
+
}
|
|
453
|
+
/**
|
|
454
|
+
* Vista de este driver con OTRO reloj de pared (2.5 · J1): mismo cliente,
|
|
455
|
+
* store, memo y resolutor; solo cambia el `now` que viaja como
|
|
456
|
+
* `current_time` y filtra las enumeraciones. Lo aplica el manager con
|
|
457
|
+
* `config.clock` y el juez para fijar el instante.
|
|
458
|
+
*/
|
|
459
|
+
withClock(now) {
|
|
460
|
+
if (!isClock(now)) {
|
|
461
|
+
throw new AuthorizationConfigError(`withClock: now debe ser una función () => Date (llegó ${typeof now})`);
|
|
462
|
+
}
|
|
463
|
+
const view = Object.create(this);
|
|
464
|
+
view.now = now;
|
|
465
|
+
return view;
|
|
466
|
+
}
|
|
467
|
+
/**
|
|
468
|
+
* El `context` de los checks de UNA operación, con el reloj de ESTE driver
|
|
469
|
+
* (o vista): se construye una vez por operación y viaja en todos sus
|
|
470
|
+
* checks (2.5-B · K9). Antes se leía el reloj por check y un mismo
|
|
471
|
+
* `authorize` evaluaba el deny en un instante y el rol en otro.
|
|
472
|
+
*/
|
|
473
|
+
checkContext() {
|
|
474
|
+
return checkContext(this.now());
|
|
267
475
|
}
|
|
268
|
-
|
|
269
|
-
|
|
476
|
+
/**
|
|
477
|
+
* Consulta al catálogo local clasificando su fallo. Con este driver el
|
|
478
|
+
* catálogo SQL sigue siendo una dependencia dura de cada pregunta: su caída
|
|
479
|
+
* era un error crudo de Lucid que se presentaba como bug de aplicación (N3).
|
|
480
|
+
*/
|
|
481
|
+
sql(operation, fn) {
|
|
482
|
+
return guardSql('openfga', operation, this.timeoutMs, fn);
|
|
270
483
|
}
|
|
271
484
|
fgaSubject(subject) {
|
|
272
485
|
return fgaSubjectWith(subject, this.holderTypes);
|
|
273
486
|
}
|
|
274
487
|
/**
|
|
275
|
-
* batchCheck
|
|
276
|
-
*
|
|
488
|
+
* Un batchCheck con TODOS los checks (el SDK trocea a 50 por request y
|
|
489
|
+
* paraleliza), cada uno con un `correlationId` propio, y la respuesta
|
|
490
|
+
* alineada por ese id: un resultado por check, ni uno más ni uno menos.
|
|
277
491
|
*/
|
|
278
492
|
async batchCheckAll(checks) {
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
493
|
+
if (checks.length === 0)
|
|
494
|
+
return [];
|
|
495
|
+
const withIds = checks.map((check, index) => ({
|
|
496
|
+
...check,
|
|
497
|
+
correlationId: String(index),
|
|
498
|
+
}));
|
|
499
|
+
const response = await this.client.batchCheck({ checks: withIds }, { consistency: this.consistency });
|
|
500
|
+
const results = correlateBatchResults(withIds, response.result);
|
|
501
|
+
// Un 200 con `error` en un check individual (`input_error`,
|
|
502
|
+
// `internal_error`…) es una caída parcial del backend, no un "sin
|
|
503
|
+
// permiso": se clasifica igual que un 5xx (invariante 5, D1). Antes la
|
|
504
|
+
// fase de roles y `hasRole` lo colapsaban en `false`.
|
|
505
|
+
const failed = results.find((r) => r.error);
|
|
506
|
+
if (failed) {
|
|
507
|
+
throw new AuthorizationBackendError('openfga', `batchCheck (${failed.request?.relation} ${failed.request?.object})`, failed.error);
|
|
289
508
|
}
|
|
290
509
|
return results;
|
|
291
510
|
}
|
|
292
|
-
// ── Catálogo local (compartido entre drivers)
|
|
511
|
+
// ── Catálogo local (compartido entre drivers), desde el memo (2A) ──────
|
|
512
|
+
// Una carga por driver/proceso en vez de una o dos consultas SQL por
|
|
513
|
+
// pregunta, más una revalidación por operación (2D · F1). Un fallo de
|
|
514
|
+
// carga sale como 503, igual que antes.
|
|
293
515
|
async findPermission(slug) {
|
|
294
|
-
return
|
|
295
|
-
}
|
|
296
|
-
async findRoleOrFail(slug, scopeType) {
|
|
297
|
-
const role = await db
|
|
298
|
-
.from('authz_roles')
|
|
299
|
-
.where('slug', slug)
|
|
300
|
-
.where('scope_type', scopeType)
|
|
301
|
-
.select('uuid')
|
|
302
|
-
.first();
|
|
303
|
-
if (!role) {
|
|
304
|
-
throw new Exception(`Rol '${slug}' no existe en el catálogo para el nivel '${scopeType}'`, {
|
|
305
|
-
status: 422,
|
|
306
|
-
});
|
|
307
|
-
}
|
|
516
|
+
return (await this.catalog.view()).permission(slug);
|
|
308
517
|
}
|
|
309
|
-
/**
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
}
|
|
322
|
-
return byScopeType;
|
|
518
|
+
/**
|
|
519
|
+
* Filtro por catálogo de las lecturas de membresía (`listRoles`,
|
|
520
|
+
* `listRoleScopes`, `rolesInChain`, `listScopes`): un binding cuyo uuid ya
|
|
521
|
+
* no está en `authz_roles` —o está, pero declarado para OTRO nivel, o es
|
|
522
|
+
* local a un scope que no está en la cadena del binding (3B · B2)— es una
|
|
523
|
+
* tupla huérfana, no una membresía; igual que en `database`, donde el
|
|
524
|
+
* catálogo lo excluye (D5). La tupla la recoge `authz:reconcile`. Desde 3A
|
|
525
|
+
* la resolución es por uuid (`roleByUuid`), nunca por slug. `chainKeys` es
|
|
526
|
+
* la cadena del scope del BINDING (desde él hacia la raíz).
|
|
527
|
+
*/
|
|
528
|
+
declaredRole(catalog, binding, chainKeys) {
|
|
529
|
+
return declaredRoleAt(catalog, binding.uuid, binding.scope.type, chainKeys);
|
|
323
530
|
}
|
|
324
531
|
// ── Contrato ──────────────────────────────────────────────────────────
|
|
325
532
|
async authorize(subject, permission, scope) {
|
|
326
|
-
|
|
533
|
+
assertIdentity({ subject, permission, scope });
|
|
534
|
+
// Una foto del catálogo por pregunta: el permiso sale de la misma versión
|
|
535
|
+
// (y se paga una sola revalidación). Es lo ÚNICO local que queda.
|
|
536
|
+
const catalog = await this.catalog.view();
|
|
537
|
+
const perm = catalog.permission(permission);
|
|
327
538
|
if (!perm)
|
|
328
539
|
return false;
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
540
|
+
// La jerarquía, el catálogo y los denies ya están en el store, así que la
|
|
541
|
+
// pregunta entera cabe en UN `Check` (3b-2c). Hasta 3b-2k había además un
|
|
542
|
+
// modo `resolver` que expandía la cadena del consumidor a un `batchCheck`
|
|
543
|
+
// de N×M: se borró con él.
|
|
544
|
+
return this.factsAuthorize(this.fgaSubject(subject), permission, scope);
|
|
545
|
+
}
|
|
546
|
+
/**
|
|
547
|
+
* **`authorize` del modo `facts` (3b-2c): UN solo `Check`.**
|
|
548
|
+
*
|
|
549
|
+
* `can_<P>` sobre `scope:<key>` con el subject como user. El modelo (c2)
|
|
550
|
+
* ya lleva dentro las tres cosas que el modo `resolver` compone aquí:
|
|
551
|
+
* la herencia hacia abajo (`<P> from parent`), el deny explícito heredado
|
|
552
|
+
* (`denied_<P> from parent`) y la resta que hace ganar al deny
|
|
553
|
+
* (`can_<P> = <P> but not denied_<P>`). No hay `batchCheck`, no se expande
|
|
554
|
+
* la cadena y **no se llama al resolutor del consumidor** (cruce 6 del
|
|
555
|
+
* panel 2, que es también el literal que el README puede prometer).
|
|
556
|
+
*
|
|
557
|
+
* Lo único local que queda es el MEMO del catálogo, y es OBLIGATORIO: lo
|
|
558
|
+
* comprueba el llamante antes de llegar aquí. Sin esa guardia un permiso
|
|
559
|
+
* desconocido sería un `Check` de una relación que el modelo no declara —
|
|
560
|
+
* un 400 del servidor que saldría como 503— en vez del `false` que exige el
|
|
561
|
+
* invariante 5.
|
|
562
|
+
*
|
|
563
|
+
* Un scope que el árbol del consumidor no conoce no tiene tuplas en el
|
|
564
|
+
* store: responde `false` sin preguntar por él (invariante 9), pero aquí
|
|
565
|
+
* eso lo decide el propio store, no `resolveChain`. Cualquier fallo del
|
|
566
|
+
* backend sale como 503 desde el cliente envuelto; jamás un `false` mudo.
|
|
567
|
+
*/
|
|
568
|
+
async factsAuthorize(user, permission, scope) {
|
|
569
|
+
const response = await this.client.check({
|
|
345
570
|
user,
|
|
346
|
-
relation:
|
|
347
|
-
object:
|
|
348
|
-
context: checkContext(),
|
|
349
|
-
})
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
571
|
+
relation: factsRelationsOf(permission).can,
|
|
572
|
+
object: factsScopeObject(scopeKey(scope)),
|
|
573
|
+
context: this.checkContext(),
|
|
574
|
+
}, { consistency: this.consistency });
|
|
575
|
+
return response.allowed === true;
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* `authorize` sobre N scopes con UN batchCheck (2.1, B6): los checks de
|
|
579
|
+
* todas las cadenas viajan juntos (el SDK trocea a 50 y paraleliza) y se
|
|
580
|
+
* atribuyen a su scope por posición dentro del lote correlacionado
|
|
581
|
+
* (L0.14). Misma regla que `authorize`, por scope: `error` en cualquier
|
|
582
|
+
* check ⇒ 503 entero (D1); deny `allowed` ⇒ false; rol `allowed` ⇒ true.
|
|
583
|
+
* Scope desconocido o sin rol que conceda ⇒ false sin checks.
|
|
584
|
+
*/
|
|
585
|
+
async authorizeMany(subject, permission, scopes) {
|
|
586
|
+
assertIdentity({ subject, permission });
|
|
587
|
+
for (const scope of scopes)
|
|
588
|
+
assertIdentity({ scope });
|
|
589
|
+
if (scopes.length === 0)
|
|
590
|
+
return [];
|
|
591
|
+
const catalog = await this.catalog.view();
|
|
592
|
+
const perm = catalog.permission(permission);
|
|
593
|
+
if (!perm)
|
|
594
|
+
return scopes.map(() => false);
|
|
595
|
+
// UN `batchCheck` con UN item por scope —no el N×M de la cadena por el
|
|
596
|
+
// catálogo del modo `resolver`, borrado en 3b-2k—, y un scope repetido
|
|
597
|
+
// comparte item.
|
|
598
|
+
return this.factsAuthorizeMany(this.fgaSubject(subject), permission, scopes);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* `authorizeMany` del modo `facts` (3b-2c): **UN `batchCheck` de N items**,
|
|
602
|
+
* uno por scope DISTINTO. En el modo `resolver` cada scope aporta los
|
|
603
|
+
* denies de su cadena más un check por (nivel, rol que concede): el lote
|
|
604
|
+
* crecía como N×M. Aquí cada scope es exactamente una pregunta,
|
|
605
|
+
* `can_<P>@scope:<key>`, y un scope repetido comparte item y respuesta
|
|
606
|
+
* (G2, CR9) en vez de duplicar el lote.
|
|
607
|
+
*
|
|
608
|
+
* Un `error` en cualquier check sigue siendo 503 entero (invariante 5, D1):
|
|
609
|
+
* lo lanza `batchCheckAll` antes de mirar un solo `allowed`.
|
|
610
|
+
*/
|
|
611
|
+
async factsAuthorizeMany(user, permission, scopes) {
|
|
612
|
+
const relation = factsRelationsOf(permission).can;
|
|
613
|
+
// Un instante para todo el lote (K9): N scopes, una pregunta.
|
|
614
|
+
const context = this.checkContext();
|
|
615
|
+
const batch = [];
|
|
616
|
+
const itemByScope = new Map();
|
|
617
|
+
const slots = [];
|
|
618
|
+
for (const scope of scopes) {
|
|
619
|
+
const id = scopeKey(scope);
|
|
620
|
+
let item = itemByScope.get(id);
|
|
621
|
+
if (item === undefined) {
|
|
622
|
+
item = batch.length;
|
|
623
|
+
batch.push({ user, relation, object: factsScopeObject(id), context });
|
|
624
|
+
itemByScope.set(id, item);
|
|
625
|
+
}
|
|
626
|
+
slots.push(item);
|
|
627
|
+
}
|
|
628
|
+
const results = await this.batchCheckAll(batch);
|
|
629
|
+
return slots.map((item) => results[item].allowed === true);
|
|
354
630
|
}
|
|
355
631
|
async grant(subject, role, scope, options = {}) {
|
|
356
|
-
|
|
632
|
+
assertIdentity({ subject, role, scope, expiresAt: options.expiresAt });
|
|
633
|
+
// El binding lleva la identidad canónica del árbol (K1), nunca la forma
|
|
634
|
+
// del llamante, y el uuid del rol, nunca su slug (3A · A1). El rol tiene
|
|
635
|
+
// que EXISTIR en ese scope (3B · B2: global, o local a un ancestro-o-igual)
|
|
636
|
+
// con una composición legal (B5).
|
|
637
|
+
const chain = await this.knownScope(scope, 'grant');
|
|
638
|
+
const [target] = chain;
|
|
639
|
+
const catalog = await this.catalog.view();
|
|
640
|
+
const declared = resolveRoleQuery(catalog, role, target, chainKeysFrom(chain)[0]);
|
|
641
|
+
assertRoleAssignableAt(catalog, declared);
|
|
642
|
+
const roleUuid = declared.uuid;
|
|
357
643
|
const key = {
|
|
358
644
|
user: this.fgaSubject(subject),
|
|
359
645
|
relation: 'assignee',
|
|
360
|
-
object: `role_binding:${scopeKey(
|
|
646
|
+
object: `role_binding:${scopeKey(target)}|${roleUuid}`,
|
|
361
647
|
};
|
|
362
|
-
|
|
648
|
+
// **La forma nueva de los objetos (3b-2c), en UNA sola escritura
|
|
649
|
+
// (3b-2f · R3).** En (c2) la asignación no es alcanzable con el
|
|
650
|
+
// `assignee` a secas: el binding tiene que colgar del scope
|
|
651
|
+
// (`scope#binding`) y apuntar a su rol (`role_binding#role`). Son
|
|
652
|
+
// ESTRUCTURA —no llevan caducidad ni dicen quién está asignado, así que
|
|
653
|
+
// sin `assignee` no conceden nada—, pero mandarlas en un `Write` aparte
|
|
654
|
+
// (como hacía el 2c) hacía del `grant` una escritura NO atómica: contra
|
|
655
|
+
// un `purgeScope` concurrente quedaba el `assignee` sin su arista, y
|
|
656
|
+
// entonces `listRoles`/`hasRole` decían que la asignación existe y
|
|
657
|
+
// `authorize` que no concede — dos lecturas contradiciéndose sobre el
|
|
658
|
+
// mismo hecho, que es peor que perder la escritura. Ahora las TRES van
|
|
659
|
+
// en el mismo `Write` (transaccional en FGA): o están las tres o no está
|
|
660
|
+
// ninguna.
|
|
661
|
+
const structure = factsBindingTuples(scopeKey(target), roleUuid);
|
|
662
|
+
/** El write COMPLETO de una asignación: su estructura y ella. */
|
|
663
|
+
const writeSet = (tuple) => [...structure, tuple];
|
|
664
|
+
const tupleFor = (expiresAt) => expiresAt
|
|
363
665
|
? {
|
|
364
666
|
...key,
|
|
365
667
|
condition: {
|
|
366
668
|
name: 'not_expired',
|
|
367
|
-
context: { valid_until:
|
|
669
|
+
context: { valid_until: expiresAt.toISOString() },
|
|
368
670
|
},
|
|
369
671
|
}
|
|
370
672
|
: key;
|
|
371
673
|
// FGA no admite delete+write de la misma tuple key en una transacción, así
|
|
372
|
-
// que
|
|
674
|
+
// que cambiar la expiración obliga a dos llamadas — y entre ellas hay un
|
|
373
675
|
// instante en el que authorize() responde false.
|
|
374
676
|
//
|
|
375
677
|
// Se mira primero qué hay, para NO pagar esa ventana cuando no hace falta:
|
|
376
678
|
// - si no existe la tuple → solo write (el caso del primer grant);
|
|
377
|
-
// - si existe
|
|
378
|
-
// - solo si la
|
|
379
|
-
//
|
|
679
|
+
// - si existe con la caducidad que toca → no-op (un seeder no toca nada);
|
|
680
|
+
// - solo si la caducidad CAMBIA de verdad se hace delete+write.
|
|
681
|
+
// Y la lectura es lo que hace posible "omitido = preservar" (L0.4).
|
|
380
682
|
const current = await this.readAssignment(key);
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
683
|
+
if (current.kind === 'unknown') {
|
|
684
|
+
// Sin lectura no hay forma de preservar una caducidad vigente: asumir
|
|
685
|
+
// "permanente" sería exactamente el defecto en modo degradado. Con un
|
|
686
|
+
// objetivo explícito (`Date`/`null`) sí se puede escribir a ciegas.
|
|
687
|
+
if (options.expiresAt === undefined)
|
|
688
|
+
throw withPreserveRecipe(current.error);
|
|
689
|
+
const expiresAt = options.expiresAt;
|
|
690
|
+
const existed = await this.writeAssignment(key, tupleFor(expiresAt), structure);
|
|
691
|
+
return { existed, expiresAt };
|
|
692
|
+
}
|
|
693
|
+
if (current.kind === 'present') {
|
|
694
|
+
const expiresAt = resolveGrantExpiry(current.validUntil, options.expiresAt, this.now());
|
|
695
|
+
if (sameInstant(current.validUntil, expiresAt)) {
|
|
696
|
+
return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
|
|
697
|
+
}
|
|
698
|
+
await this.replaceAssignment(key, tupleFor(expiresAt), structure);
|
|
699
|
+
return { existed: true, previousExpiresAt: current.validUntil, expiresAt };
|
|
700
|
+
}
|
|
701
|
+
// No había nada: un write basta y no hay ventana de denegación. Ese write
|
|
702
|
+
// puede chocar por DOS motivos distintos, y la diferencia importa
|
|
703
|
+
// (D6 + 3b-2f · R3):
|
|
704
|
+
//
|
|
705
|
+
// - la ASIGNACIÓN ya existe — otro proceso la escribió entre el read y
|
|
706
|
+
// el write, o dos `grant` simultáneos —: se relee y se re-aplica sobre
|
|
707
|
+
// lo que quedó, para que gane el último escritor y no se pierda esta
|
|
708
|
+
// caducidad;
|
|
709
|
+
// - la ESTRUCTURA ya estaba — otro holder con el mismo rol en el mismo
|
|
710
|
+
// scope, que es el caso más común que hay — o la escribió otro `grant`
|
|
711
|
+
// a la vez: la asignación sigue sin existir, así que se repite el
|
|
712
|
+
// MISMO write ignorando duplicados (las aristas no llevan condición,
|
|
713
|
+
// así que un duplicado suyo es idéntico por construcción). Sigue
|
|
714
|
+
// siendo UNA escritura y sigue siendo atómica.
|
|
715
|
+
//
|
|
716
|
+
// Cuál de los dos fue lo dice la RELECTURA, no el error: el conflicto
|
|
717
|
+
// transaccional de FGA (`Aborted`, HTTP 409) no nombra ninguna tupla.
|
|
718
|
+
// Cualquier otro fallo del write no es una carrera y se propaga (D6); y
|
|
719
|
+
// una contención que no cede en `GRANT_WRITE_ATTEMPTS` vueltas sale como
|
|
720
|
+
// 409 —el estado del destino no es el que esta escritura esperaba—,
|
|
721
|
+
// jamás como un 503 "el backend no respondió", porque respondió.
|
|
722
|
+
//
|
|
723
|
+
// El límite honesto: si entre la relectura y el write con `Ignore` un
|
|
724
|
+
// tercero escribe la asignación, la suya se queda y esta se pierde en
|
|
725
|
+
// silencio (FGA no tiene un compare-and-set). Es la misma ventana que ya
|
|
726
|
+
// tenía el `replace`, estrechada a dos llamadas seguidas.
|
|
727
|
+
const expiresAt = options.expiresAt ?? null;
|
|
728
|
+
for (let attempt = 0;; attempt++) {
|
|
389
729
|
try {
|
|
390
|
-
await this.client.writeTuples(
|
|
391
|
-
return;
|
|
730
|
+
await this.client.writeTuples(writeSet(tupleFor(expiresAt)), attempt === 0 ? undefined : IGNORE_DUPLICATE_WRITES);
|
|
731
|
+
return { existed: false, expiresAt };
|
|
392
732
|
}
|
|
393
|
-
catch {
|
|
394
|
-
|
|
733
|
+
catch (error) {
|
|
734
|
+
if (!isDuplicateWrite(error))
|
|
735
|
+
throw error;
|
|
736
|
+
if (attempt >= GRANT_WRITE_ATTEMPTS - 1) {
|
|
737
|
+
throw new WriteConflictError(`grant: ${GRANT_WRITE_ATTEMPTS} intentos y el store sigue en conflicto sobre ` +
|
|
738
|
+
`${key.object}. Reintenta la escritura.`, { cause: error });
|
|
739
|
+
}
|
|
740
|
+
const raced = await this.readAssignment(key);
|
|
741
|
+
if (raced.kind === 'present') {
|
|
742
|
+
const target = resolveGrantExpiry(raced.validUntil, options.expiresAt, this.now());
|
|
743
|
+
if (!sameInstant(raced.validUntil, target)) {
|
|
744
|
+
await this.replaceAssignment(key, tupleFor(target), structure);
|
|
745
|
+
}
|
|
746
|
+
return { existed: true, previousExpiresAt: raced.validUntil, expiresAt: target };
|
|
747
|
+
}
|
|
748
|
+
if (raced.kind === 'unknown') {
|
|
749
|
+
// La relectura FALLÓ: sin objetivo explícito no se sabe qué
|
|
750
|
+
// preservar, y el error de lectura va como causa.
|
|
751
|
+
if (options.expiresAt === undefined)
|
|
752
|
+
throw withPreserveRecipe(raced.error);
|
|
753
|
+
const existed = await this.writeAssignment(key, tupleFor(options.expiresAt), structure);
|
|
754
|
+
return { existed, expiresAt: options.expiresAt };
|
|
755
|
+
}
|
|
756
|
+
// `absent` con estructura: el choque fue de las aristas, que las
|
|
757
|
+
// comparten todos los holders del mismo rol en el mismo scope. Otra
|
|
758
|
+
// vuelta, esta vez dándolas por buenas.
|
|
395
759
|
}
|
|
396
760
|
}
|
|
761
|
+
}
|
|
762
|
+
/**
|
|
763
|
+
* Write directo de la asignación CON su estructura (3b-2f · R3); si algo ya
|
|
764
|
+
* estaba, camino largo. Devuelve si existía la ASIGNACIÓN —lo dice la
|
|
765
|
+
* relectura, no el error: el choque puede ser de las aristas, que en (c2)
|
|
766
|
+
* las comparten todos los holders del mismo rol en el mismo scope—.
|
|
767
|
+
* Cualquier otro fallo se propaga tal cual (ya clasificado).
|
|
768
|
+
*/
|
|
769
|
+
async writeAssignment(key, tuple, structure = []) {
|
|
770
|
+
try {
|
|
771
|
+
await this.client.writeTuples([...structure, tuple]);
|
|
772
|
+
return false;
|
|
773
|
+
}
|
|
774
|
+
catch (error) {
|
|
775
|
+
if (!isDuplicateWrite(error))
|
|
776
|
+
throw error;
|
|
777
|
+
// Sin estructura el duplicado solo puede ser la asignación; con ella
|
|
778
|
+
// puede ser una arista compartida, y entonces quién existía lo dice la
|
|
779
|
+
// relectura y no el error.
|
|
780
|
+
const existed = structure.length ? (await this.readAssignment(key)).kind !== 'absent' : true;
|
|
781
|
+
await this.replaceAssignment(key, tuple, structure);
|
|
782
|
+
return existed;
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
/**
|
|
786
|
+
* delete + write (dos llamadas: FGA no admite ambas sobre la misma key en
|
|
787
|
+
* una). El write repone la ESTRUCTURA junto a la asignación: si un
|
|
788
|
+
* `purgeScope` concurrente se llevó la arista `scope#binding` entre medias,
|
|
789
|
+
* lo que queda vuelve a ser coherente en vez de una asignación inerte que
|
|
790
|
+
* `listRoles` ve y `authorize` no (3b-2f · R3).
|
|
791
|
+
*/
|
|
792
|
+
async replaceAssignment(key, tuple, structure = []) {
|
|
397
793
|
await this.client.deleteTuples([key], {
|
|
398
794
|
conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
|
|
399
795
|
});
|
|
400
|
-
await this.client.writeTuples([
|
|
401
|
-
conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore },
|
|
402
|
-
});
|
|
796
|
+
await this.client.writeTuples([...structure, tuple], IGNORE_DUPLICATE_WRITES);
|
|
403
797
|
}
|
|
404
798
|
/**
|
|
405
799
|
* Estado actual de una asignación, con TRES resultados posibles y no dos.
|
|
406
800
|
*
|
|
407
801
|
* Distinguir `unknown` de `present` sin condición es lo que impide un bug
|
|
408
802
|
* feo: si un fallo de lectura se pareciera a "existe y sin expiración", un
|
|
409
|
-
* grant sin expiración saldría por el atajo del no-op y se perdería
|
|
410
|
-
*
|
|
803
|
+
* grant sin expiración saldría por el atajo del no-op y se perdería. El
|
|
804
|
+
* error de lectura (ya clasificado como 503) viaja con el resultado para
|
|
805
|
+
* que quien no pueda seguir sin él lo propague.
|
|
411
806
|
*/
|
|
412
807
|
async readAssignment(key) {
|
|
413
808
|
try {
|
|
414
|
-
const response = await this.client.read(key);
|
|
809
|
+
const response = await this.client.read(key, { consistency: this.consistency });
|
|
415
810
|
const tuple = response.tuples?.[0];
|
|
416
811
|
if (!tuple)
|
|
417
812
|
return { kind: 'absent' };
|
|
418
813
|
const validUntil = tuple.key?.condition?.context?.valid_until;
|
|
419
|
-
return { kind: 'present', validUntil:
|
|
814
|
+
return { kind: 'present', validUntil: toExpiryDate(validUntil) };
|
|
420
815
|
}
|
|
421
|
-
catch {
|
|
422
|
-
|
|
423
|
-
// funciona exista o no la tuple.
|
|
424
|
-
return { kind: 'unknown' };
|
|
816
|
+
catch (error) {
|
|
817
|
+
return { kind: 'unknown', error };
|
|
425
818
|
}
|
|
426
819
|
}
|
|
427
820
|
async revoke(subject, role, scope) {
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
821
|
+
assertIdentity({ subject, role, scope });
|
|
822
|
+
// Rol fuera del catálogo para ese nivel ⇒ 422, como en `grant` (D10). Se
|
|
823
|
+
// quitan los bindings de TODOS los roles con ese nombre en el scope
|
|
824
|
+
// exacto (3B): a lo sumo uno es visible ahí; quitar nunca concede.
|
|
825
|
+
const named = rolesToRevoke(await this.catalog.view(), role, scope);
|
|
826
|
+
// Sin cadena, TODAS las ortografías de las que el uuid puede ser alias
|
|
827
|
+
// (3b-8 · A4): con una sola, el alias hacía del delete un no-op
|
|
828
|
+
// silencioso (`onMissingDeletes: Ignore`) y el hecho canónico seguía ahí.
|
|
829
|
+
const targets = await this.canonicalTargets(scope, 'revoke');
|
|
830
|
+
const user = this.fgaSubject(subject);
|
|
831
|
+
await this.client.deleteTuples(targets.flatMap((target) => named.map((r) => ({ user, relation: 'assignee', object: `role_binding:${scopeKey(target)}|${r.uuid}` }))), { conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore } });
|
|
435
832
|
}
|
|
436
833
|
async hasRole(subject, role, scope) {
|
|
834
|
+
assertIdentity({ subject, role, scope });
|
|
437
835
|
const user = this.fgaSubject(subject);
|
|
438
|
-
const chain = await this.chain(scope);
|
|
439
|
-
|
|
836
|
+
const chain = await this.chain(scope, 'hasRole');
|
|
837
|
+
if (!chain)
|
|
838
|
+
return false;
|
|
839
|
+
// El id del binding lleva el scope (y con él su tipo) y el UUID del rol
|
|
840
|
+
// que el catálogo declara con ese slug para el tipo de ESE nivel (3A):
|
|
841
|
+
// en cada nivel solo casa el rol de ese nivel. Con `{ slug, scopeType }`
|
|
842
|
+
// se recorta la cadena a los niveles de ese tipo (L0.6). Y solo se
|
|
843
|
+
// pregunta por los niveles para los que el catálogo declara el rol (D5):
|
|
844
|
+
// un rol retirado no es membresía aunque su tupla siga en el store.
|
|
845
|
+
const catalog = await this.catalog.view();
|
|
846
|
+
const targets = hasRoleTargets(catalog, role, chain);
|
|
847
|
+
if (targets.length === 0)
|
|
848
|
+
return false;
|
|
849
|
+
const context = this.checkContext();
|
|
850
|
+
const results = await this.batchCheckAll(targets.map(({ scope: s, roleUuid }) => ({
|
|
440
851
|
user,
|
|
441
852
|
relation: 'assignee',
|
|
442
|
-
object: `role_binding:${scopeKey(s)}|${
|
|
443
|
-
context
|
|
853
|
+
object: `role_binding:${scopeKey(s)}|${roleUuid}`,
|
|
854
|
+
context,
|
|
444
855
|
})));
|
|
445
|
-
return results.some((r) => r.allowed
|
|
856
|
+
return results.some((r) => r.allowed);
|
|
446
857
|
}
|
|
447
858
|
async deny(subject, permission, scope) {
|
|
859
|
+
assertIdentity({ subject, permission, scope });
|
|
448
860
|
const perm = await this.findPermission(permission);
|
|
449
|
-
if (!perm)
|
|
450
|
-
throw new
|
|
451
|
-
|
|
452
|
-
await this.client.writeTuples([
|
|
453
|
-
{
|
|
454
|
-
|
|
455
|
-
relation: 'denied',
|
|
456
|
-
object: `deny_binding:${scopeKey(scope)}|${encodeSlug(permission)}`,
|
|
457
|
-
},
|
|
458
|
-
], { conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore } });
|
|
861
|
+
if (!perm)
|
|
862
|
+
throw new UnknownPermissionError(permission);
|
|
863
|
+
const [target] = await this.knownScope(scope, 'deny');
|
|
864
|
+
await this.client.writeTuples([this.denyTuple(subject, permission, target)], {
|
|
865
|
+
conflict: { onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore },
|
|
866
|
+
});
|
|
459
867
|
}
|
|
460
868
|
async removeDeny(subject, permission, scope) {
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
869
|
+
assertIdentity({ subject, permission, scope });
|
|
870
|
+
const perm = await this.findPermission(permission);
|
|
871
|
+
if (!perm)
|
|
872
|
+
throw new UnknownPermissionError(permission);
|
|
873
|
+
// Mismo fan-out que `revoke` (3b-8 · A4): quitar un deny de menos por un
|
|
874
|
+
// alias sería un deny fantasma que nadie puede levantar.
|
|
875
|
+
const targets = await this.canonicalTargets(scope, 'removeDeny');
|
|
876
|
+
await this.client.deleteTuples(targets.map((target) => this.denyTuple(subject, permission, target)), { conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore } });
|
|
468
877
|
}
|
|
878
|
+
/**
|
|
879
|
+
* El hecho de un deny (3b-2c): una relación DEL SCOPE,
|
|
880
|
+
* `scope:<key>#denied_<P>@<holder>`. Así el modelo lo hereda hacia abajo
|
|
881
|
+
* por `parent` y `can_<P>` puede restarlo dentro del mismo `Check`
|
|
882
|
+
* (invariante 2) sin que el paquete pasee la cadena. Hasta 3b-2k · K2 el
|
|
883
|
+
* modo `resolver` lo guardaba en un objeto propio
|
|
884
|
+
* (`deny_binding:<scopeKey>|<permissionUuid>`) y el paquete expandía la
|
|
885
|
+
* cadena a un check por nivel; ese tipo se borró con el modo.
|
|
886
|
+
*
|
|
887
|
+
* La relación lleva el SLUG proyectado (no el uuid) porque el modelo la
|
|
888
|
+
* declara por nombre. El slug que llega aquí es el del catálogo: el
|
|
889
|
+
* llamante ya pasó por `findPermission`, que es quien decide qué existe.
|
|
890
|
+
*/
|
|
891
|
+
denyTuple(subject, permission, target) {
|
|
892
|
+
const user = this.fgaSubject(subject);
|
|
893
|
+
return factsDenyTuple(scopeKey(target), permission, user);
|
|
894
|
+
}
|
|
895
|
+
/**
|
|
896
|
+
* Holders con asignación vigente del rol en el scope exacto: `Read` por
|
|
897
|
+
* objeto exacto, paginado, con la caducidad filtrada en cliente. Antes era
|
|
898
|
+
* `ListUsers`, que trunca al tope del servidor sin señal (L0.7).
|
|
899
|
+
*/
|
|
469
900
|
async listSubjects(role, scope) {
|
|
470
|
-
|
|
471
|
-
|
|
901
|
+
assertIdentity({ role, scope });
|
|
902
|
+
// Un rol que el catálogo no declara para ese nivel (en ningún owner) no
|
|
903
|
+
// tiene holders (D5): nada que leer, ni árbol ni store. Un scope que el
|
|
904
|
+
// árbol no conoce no existe para el motor (D8, K1): nada; uno que conoce
|
|
905
|
+
// se lee bajo su identidad canónica, y el rol tiene que existir AHÍ (3B ·
|
|
906
|
+
// B2); el que existe se lee por su uuid (3A).
|
|
907
|
+
const catalog = await this.catalog.view();
|
|
908
|
+
const asked = normalizeRoleQuery(role);
|
|
909
|
+
if (asked.uuid !== undefined ? catalog.roleByUuid(asked.uuid) === null : catalog.rolesNamed(asked.slug, asked.scopeType ?? scope.type).length === 0) {
|
|
910
|
+
return [];
|
|
911
|
+
}
|
|
912
|
+
const chain = await this.chain(scope, 'listSubjects');
|
|
913
|
+
if (!chain)
|
|
914
|
+
return [];
|
|
915
|
+
const declared = visibleRoleFor(catalog, asked, scope, chainKeysFrom(chain)[0]);
|
|
916
|
+
if (!declared)
|
|
917
|
+
return [];
|
|
472
918
|
const fgaToMorph = Object.fromEntries(Object.entries(this.holderTypes).map(([morph, fga]) => [fga, morph]));
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
919
|
+
const tuples = await this.readAllTuples({
|
|
920
|
+
relation: 'assignee',
|
|
921
|
+
object: `role_binding:${scopeKey(chain[0])}|${declared.uuid}`,
|
|
922
|
+
});
|
|
923
|
+
const results = [];
|
|
924
|
+
for (const tuple of tuples) {
|
|
925
|
+
// `<tipoFga>:<uuid>`; un userset (`#`) o un tipo que no está en el mapa
|
|
926
|
+
// no es un holder que este driver haya escrito.
|
|
927
|
+
const separator = tuple.user.indexOf(':');
|
|
928
|
+
const fgaType = separator > 0 ? tuple.user.slice(0, separator) : '';
|
|
929
|
+
const uuid = separator > 0 ? tuple.user.slice(separator + 1) : '';
|
|
930
|
+
const morph = fgaToMorph[fgaType];
|
|
931
|
+
if (!morph || !uuid || uuid.includes('#')) {
|
|
932
|
+
this.diagnostics.unparseableBindings += 1;
|
|
933
|
+
this.warn(`authz(openfga): el user '${tuple.user}' de '${tuple.object}' no es un holder del motor; se ignora en la enumeración (total: ${this.diagnostics.unparseableBindings})`);
|
|
934
|
+
continue;
|
|
484
935
|
}
|
|
936
|
+
results.push({ type: morph, uuid });
|
|
485
937
|
}
|
|
486
938
|
return results;
|
|
487
939
|
}
|
|
488
|
-
/**
|
|
489
|
-
|
|
490
|
-
|
|
940
|
+
/**
|
|
941
|
+
* Bindings del subject ya parseados (asignaciones directas vigentes). Los
|
|
942
|
+
* ids que no se entienden se registran y se cuentan, no se descartan.
|
|
943
|
+
*/
|
|
944
|
+
async listBindings(subject, at) {
|
|
945
|
+
const tuples = await this.readAllTuples({
|
|
491
946
|
user: this.fgaSubject(subject),
|
|
492
947
|
relation: 'assignee',
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
948
|
+
object: 'role_binding:',
|
|
949
|
+
}, { at });
|
|
950
|
+
return this.parseBindings('role_binding', tuples.map((t) => t.object));
|
|
951
|
+
}
|
|
952
|
+
/** Scopes (por clave) donde el subject tiene un deny directo del permiso (por su uuid). */
|
|
953
|
+
async deniedScopeKeys(subject, permissionUuid, at) {
|
|
954
|
+
// El deny es una relación DEL SCOPE: se pide por esa relación exacta y la
|
|
955
|
+
// lectura devuelve ya solo los denies de ESTE permiso (una request, sin
|
|
956
|
+
// filtrar en cliente). Un permiso que el catálogo ya no declara no tiene
|
|
957
|
+
// relación que leer: sin denies (D5).
|
|
958
|
+
const slug = (await this.catalog.view()).permissionSlug(permissionUuid);
|
|
959
|
+
if (!slug)
|
|
960
|
+
return new Set();
|
|
961
|
+
const tuples = await this.readAllTuples({
|
|
962
|
+
user: this.fgaSubject(subject),
|
|
963
|
+
relation: factsRelationsOf(slug).denied,
|
|
964
|
+
object: `${FACTS_SCOPE_TYPE}:`,
|
|
965
|
+
}, { at });
|
|
966
|
+
return new Set(tuples.map((t) => t.object.slice(FACTS_SCOPE_TYPE.length + 1)));
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Los denies DIRECTOS del holder en el modo `facts`, ya traducidos a
|
|
970
|
+
* `(scope, permiso)`. No hay `deny_binding` que enumerar: se leen de una
|
|
971
|
+
* pasada las tuplas del holder sobre objetos `scope:` y se quedan las de la
|
|
972
|
+
* familia `denied_<P>`. La vuelta de relación a slug la da el CATÁLOGO —una
|
|
973
|
+
* relación que ya no declara ningún permiso no es un deny (D5), igual que
|
|
974
|
+
* un `deny_binding` de un permiso retirado—, y una clave de scope que el
|
|
975
|
+
* motor no entiende se cuenta y se registra, nunca se descarta en silencio
|
|
976
|
+
* (L0.16).
|
|
977
|
+
*/
|
|
978
|
+
async factsDenies(subject, catalog, at) {
|
|
979
|
+
const permissionByRelation = new Map();
|
|
980
|
+
for (const slug of catalog.permissionSlugs) {
|
|
981
|
+
permissionByRelation.set(factsRelationsOf(slug).denied, slug);
|
|
982
|
+
}
|
|
983
|
+
const tuples = await this.readAllTuples({ user: this.fgaSubject(subject), object: `${FACTS_SCOPE_TYPE}:` }, { at });
|
|
984
|
+
const denies = [];
|
|
985
|
+
for (const tuple of tuples) {
|
|
986
|
+
if (!tuple.relation.startsWith(FACTS_DENIED_PREFIX))
|
|
987
|
+
continue;
|
|
988
|
+
const permission = permissionByRelation.get(tuple.relation);
|
|
989
|
+
if (!permission)
|
|
990
|
+
continue;
|
|
991
|
+
const scope = scopeFromKey(tuple.object.slice(FACTS_SCOPE_TYPE.length + 1));
|
|
992
|
+
if (!scope) {
|
|
993
|
+
this.diagnostics.unparseableBindings += 1;
|
|
994
|
+
this.warn(`authz(openfga): el objeto '${tuple.object}' de un deny no tiene la forma de un scope del motor; se ignora en la enumeración (total: ${this.diagnostics.unparseableBindings})`);
|
|
995
|
+
continue;
|
|
996
|
+
}
|
|
997
|
+
denies.push({ scope, permission });
|
|
998
|
+
}
|
|
999
|
+
return denies;
|
|
1000
|
+
}
|
|
1001
|
+
parseBindings(type, objects) {
|
|
1002
|
+
const parsed = [];
|
|
1003
|
+
for (const obj of objects) {
|
|
1004
|
+
const id = obj.replace(new RegExp(`^${type}:`), '');
|
|
1005
|
+
const binding = parseBindingId(id);
|
|
1006
|
+
if (binding) {
|
|
1007
|
+
parsed.push(binding);
|
|
1008
|
+
}
|
|
1009
|
+
else {
|
|
1010
|
+
this.diagnostics.unparseableBindings += 1;
|
|
1011
|
+
this.warn(`authz(openfga): binding '${type}:${id}' no tiene la forma del motor; se ignora en la enumeración (total: ${this.diagnostics.unparseableBindings})`);
|
|
1012
|
+
}
|
|
1013
|
+
}
|
|
1014
|
+
return parsed;
|
|
1015
|
+
}
|
|
1016
|
+
warn(message) {
|
|
1017
|
+
this.logger.warn(message);
|
|
497
1018
|
}
|
|
498
1019
|
async listRoles(subject, scope) {
|
|
499
|
-
|
|
1020
|
+
assertIdentity({ subject, scope });
|
|
1021
|
+
// Un scope que el árbol no conoce no existe para el motor (D8): nada.
|
|
1022
|
+
const chain = await this.chain(scope, 'listRoles');
|
|
1023
|
+
if (!chain)
|
|
1024
|
+
return [];
|
|
1025
|
+
const prefix = scopeKey(chain[0]);
|
|
1026
|
+
const keys = chainKeysFrom(chain)[0];
|
|
1027
|
+
const catalog = await this.catalog.view();
|
|
500
1028
|
const roles = new Set();
|
|
501
|
-
for (const
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
1029
|
+
for (const binding of await this.listBindings(subject)) {
|
|
1030
|
+
if (scopeKey(binding.scope) !== prefix)
|
|
1031
|
+
continue;
|
|
1032
|
+
const declared = this.declaredRole(catalog, binding, keys);
|
|
1033
|
+
if (declared)
|
|
1034
|
+
roles.add(declared.slug);
|
|
505
1035
|
}
|
|
506
1036
|
return [...roles];
|
|
507
1037
|
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Roles directos vigentes del holder en cada scope de la cadena (2D · G5):
|
|
1040
|
+
* UNA lectura (`Read` paginado de sus bindings) agrupada por scope, en vez
|
|
1041
|
+
* de un `listRoles` por nivel. Solo roles que el catálogo declara para ese
|
|
1042
|
+
* nivel (D5). La cadena viene ya resuelta por el manager.
|
|
1043
|
+
*/
|
|
1044
|
+
async rolesInChain(subject, chain) {
|
|
1045
|
+
assertIdentity({ subject });
|
|
1046
|
+
for (const scope of chain)
|
|
1047
|
+
assertScope(scope);
|
|
1048
|
+
if (chain.length === 0)
|
|
1049
|
+
return [];
|
|
1050
|
+
const catalog = await this.catalog.view();
|
|
1051
|
+
const keysFrom = chainKeysFrom(chain);
|
|
1052
|
+
const wanted = new Map(chain.map((s, i) => [scopeKey(s), { scope: s, keys: keysFrom[i] }]));
|
|
1053
|
+
const seen = new Set();
|
|
1054
|
+
const result = [];
|
|
1055
|
+
for (const binding of await this.listBindings(subject)) {
|
|
1056
|
+
const id = scopeKey(binding.scope);
|
|
1057
|
+
const level = wanted.get(id);
|
|
1058
|
+
if (!level)
|
|
1059
|
+
continue;
|
|
1060
|
+
const scope = level.scope;
|
|
1061
|
+
const declared = this.declaredRole(catalog, binding, level.keys);
|
|
1062
|
+
if (!declared)
|
|
1063
|
+
continue;
|
|
1064
|
+
// Dedupe por IDENTIDAD (3D · M1: el uuid, no el slug).
|
|
1065
|
+
const dedupe = `${id}\u001f${declared.uuid}`;
|
|
1066
|
+
if (seen.has(dedupe))
|
|
1067
|
+
continue;
|
|
1068
|
+
seen.add(dedupe);
|
|
1069
|
+
result.push({ scope, role: declared });
|
|
1070
|
+
}
|
|
1071
|
+
return result;
|
|
1072
|
+
}
|
|
508
1073
|
async listRoleScopes(subject, scopeType) {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
1074
|
+
assertIdentity({ subject, scopeType });
|
|
1075
|
+
const catalog = await this.catalog.view();
|
|
1076
|
+
const byScope = new Map();
|
|
1077
|
+
for (const binding of await this.listBindings(subject)) {
|
|
1078
|
+
if (binding.scope.type !== scopeType)
|
|
1079
|
+
continue;
|
|
1080
|
+
const k = scopeKey(binding.scope);
|
|
1081
|
+
if (!byScope.has(k))
|
|
1082
|
+
byScope.set(k, { scope: binding.scope, bindings: [] });
|
|
1083
|
+
byScope.get(k).bindings.push(binding);
|
|
1084
|
+
}
|
|
1085
|
+
// Los scopes que el árbol ya no conoce no se listan (D8): una consulta
|
|
1086
|
+
// al resolutor por scope, el mismo coste que `listScopes`. Y con la
|
|
1087
|
+
// cadena se decide si alguno de sus roles existe ahí (D5, 3B · B2).
|
|
1088
|
+
const known = [];
|
|
1089
|
+
for (const { scope, bindings } of byScope.values()) {
|
|
1090
|
+
const chain = await this.chain(scope, 'listRoleScopes');
|
|
1091
|
+
if (!chain)
|
|
1092
|
+
continue;
|
|
1093
|
+
const keys = chainKeysFrom(chain)[0];
|
|
1094
|
+
if (bindings.some((b) => this.declaredRole(catalog, b, keys)))
|
|
1095
|
+
known.push(scope);
|
|
515
1096
|
}
|
|
516
|
-
return
|
|
1097
|
+
return known;
|
|
517
1098
|
}
|
|
518
1099
|
async listScopes(subject, permission) {
|
|
519
|
-
|
|
1100
|
+
assertIdentity({ subject, permission });
|
|
1101
|
+
const catalog = await this.catalog.view();
|
|
1102
|
+
const perm = catalog.permission(permission);
|
|
520
1103
|
if (!perm)
|
|
521
1104
|
return [];
|
|
522
|
-
const granting =
|
|
523
|
-
// Denies del subject para este permiso
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
const deniedKeys = new Set((denyResponse.objects ?? [])
|
|
530
|
-
.map((obj) => obj.replace(/^deny_binding:/, ''))
|
|
531
|
-
.map((id) => parseBindingId(id))
|
|
532
|
-
.filter((p) => Boolean(p && p.slug === permission))
|
|
533
|
-
.map((p) => scopeKey(p.scope)));
|
|
1105
|
+
const granting = catalog.rolesGranting(perm.uuid);
|
|
1106
|
+
// Denies directos del subject para este permiso: TODOS, paginando. Con
|
|
1107
|
+
// `ListObjects` el tope del servidor se consumía con los denies de
|
|
1108
|
+
// cualquier permiso y el relevante podía quedar fuera: fail-open (L0.7).
|
|
1109
|
+
// Las dos lecturas filtran la caducidad con el MISMO instante (K9).
|
|
1110
|
+
const at = this.now();
|
|
1111
|
+
const deniedKeys = await this.deniedScopeKeys(subject, perm.uuid, at);
|
|
534
1112
|
const result = new Map();
|
|
535
|
-
for (const
|
|
536
|
-
const
|
|
537
|
-
if (!
|
|
1113
|
+
for (const binding of await this.listBindings(subject, at)) {
|
|
1114
|
+
const role = (granting.get(binding.scope.type) ?? []).find((r) => r.uuid === binding.uuid);
|
|
1115
|
+
if (!role)
|
|
538
1116
|
continue;
|
|
539
|
-
|
|
1117
|
+
// Un scope que el árbol ya no conoce no concede: no se lista. Y el rol
|
|
1118
|
+
// tiene que existir ahí (3B · B2: global u owner en la cadena).
|
|
1119
|
+
const chain = await this.chain(binding.scope, 'listScopes');
|
|
1120
|
+
if (!chain)
|
|
1121
|
+
continue;
|
|
1122
|
+
if (!isRoleVisibleWith(role, chainKeysFrom(chain)[0]))
|
|
540
1123
|
continue;
|
|
541
|
-
const chain = await this.chain(parsed.scope);
|
|
542
1124
|
const blocked = chain.some((s) => deniedKeys.has(scopeKey(s)));
|
|
543
1125
|
if (!blocked)
|
|
544
|
-
result.set(scopeKey(
|
|
1126
|
+
result.set(scopeKey(binding.scope), binding.scope);
|
|
545
1127
|
}
|
|
546
1128
|
return [...result.values()];
|
|
547
1129
|
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Denies directos del holder (2.1, B5): `Read` paginado de sus relaciones
|
|
1132
|
+
* `denied_<P>` sobre objetos `scope:` (nunca ListObjects, L0.7), filtrados
|
|
1133
|
+
* por el catálogo (un permiso retirado no es un deny, D5), por scope exacto
|
|
1134
|
+
* si se pide, y por scopes que el árbol conoce (D8).
|
|
1135
|
+
*/
|
|
1136
|
+
async listDenies(subject, scope) {
|
|
1137
|
+
assertIdentity(scope ? { subject, scope } : { subject });
|
|
1138
|
+
const chain = scope ? await this.chain(scope, 'listDenies') : null;
|
|
1139
|
+
if (scope && !chain)
|
|
1140
|
+
return [];
|
|
1141
|
+
const wanted = chain ? scopeKey(chain[0]) : null;
|
|
1142
|
+
const view = await this.catalog.view();
|
|
1143
|
+
const denies = await this.factsDenies(subject, view);
|
|
1144
|
+
const result = [];
|
|
1145
|
+
for (const deny of denies) {
|
|
1146
|
+
if (wanted !== null) {
|
|
1147
|
+
if (scopeKey(deny.scope) !== wanted)
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
else if (!(await this.chain(deny.scope, 'listDenies'))) {
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
result.push({ permission: deny.permission, scope: deny.scope });
|
|
1154
|
+
}
|
|
1155
|
+
return result;
|
|
1156
|
+
}
|
|
1157
|
+
/* ── El ÁRBOL como hechos (3b-2b) ──────────────────────────────────── */
|
|
1158
|
+
/**
|
|
1159
|
+
* Un nodo tiene como mucho UN padre. El paquete nunca escribe dos (cada
|
|
1160
|
+
* `moved` sustituye la arista entera dentro de un `Write`), así que dos
|
|
1161
|
+
* padres son DERIVA: alguien más escribe en el store, y mientras tanto la
|
|
1162
|
+
* herencia está trayendo hechos de dos ramas. Se lanza; no se "arregla",
|
|
1163
|
+
* porque elegir cuál sobrevive sería adivinar cuál de las dos concesiones
|
|
1164
|
+
* vivas es la buena (cruce 8: «si devuelve >1 ⇒ drift ⇒ lanza»).
|
|
1165
|
+
*/
|
|
1166
|
+
assertOneParent(object, current) {
|
|
1167
|
+
if (current.length <= 1)
|
|
1168
|
+
return;
|
|
1169
|
+
throw new ScopeTreeDriftError(`El árbol del store tiene ${current.length} padres para ${object} ` +
|
|
1170
|
+
`(${current.map((t) => t.user).join(', ')}); el paquete solo escribe uno. ` +
|
|
1171
|
+
`No se elige por ti: reconstruye el árbol con authz:reconcile.`);
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* El consumidor colgó un scope nuevo (o recolgó uno que ya existía: un
|
|
1175
|
+
* `attach` sobre un nodo conocido ES un move). En modo `facts` eso es UNA
|
|
1176
|
+
* tupla `scope:<hijo>#parent@scope:<padre>`; en modo `resolver` no es nada
|
|
1177
|
+
* (la jerarquía la resuelve el paquete en cada pregunta).
|
|
1178
|
+
*/
|
|
1179
|
+
async onScopeAttached(child, parent) {
|
|
1180
|
+
await this.reparent(child, parent, 'scopes.attached');
|
|
1181
|
+
}
|
|
1182
|
+
/**
|
|
1183
|
+
* El consumidor movió un scope. Procedimiento fijado en el cruce 8 del
|
|
1184
|
+
* panel 2: un `Read` del padre actual —obligatorio, porque FGA rechaza
|
|
1185
|
+
* borrar una tupla inexistente—, la cadena del padre NUEVO en el store
|
|
1186
|
+
* (3b-8 · A5: el anti-ciclos del consumidor no ve un store
|
|
1187
|
+
* desincronizado) y **UN solo `Write`** con el delete del padre viejo y el
|
|
1188
|
+
* write del nuevo, que es atómico dentro de la request. O(profundidad)
|
|
1189
|
+
* requests, UNA mutación.
|
|
1190
|
+
*/
|
|
1191
|
+
async onScopeMoved(child, newParent) {
|
|
1192
|
+
await this.reparent(child, newParent, 'scopes.moved');
|
|
1193
|
+
}
|
|
1194
|
+
/**
|
|
1195
|
+
* **Anti-ciclos, en el PAQUETE y antes de escribir** (cruce 3 del panel 2,
|
|
1196
|
+
* bloqueante S2). Medido contra OpenFGA v1.19: el servidor ACEPTA una
|
|
1197
|
+
* arista que cierra un ciclo, no se cuelga, responde en 2-7 ms y la
|
|
1198
|
+
* herencia se vuelve bidireccional —un grant en un descendiente concede en
|
|
1199
|
+
* el ancestro, y con la raíz dentro del ciclo concede en todo el store—.
|
|
1200
|
+
* Fail-open mudo: no hay nada que capturar. La suite lo reproduce contra el
|
|
1201
|
+
* `:8101` para que nadie proponga delegar esto en el backend.
|
|
1202
|
+
*
|
|
1203
|
+
* Las tres validaciones del cruce 8, en orden y sin escribir nada si
|
|
1204
|
+
* fallan: (i) la raíz no cuelga de nadie; (ii) el padre EXISTE según el
|
|
1205
|
+
* árbol del consumidor; (iii) el hijo no es ancestro-o-igual del padre.
|
|
1206
|
+
* Las mismas que hace `AuthorizationManager.#assertEdge`: aquí se repiten
|
|
1207
|
+
* por defensa en profundidad, porque `manager.driver()` es la salida
|
|
1208
|
+
* documentada de todas las barreras del paquete.
|
|
1209
|
+
*
|
|
1210
|
+
* Devuelve las dos claves CANÓNICAS (invariante 17): un alias del uuid ni
|
|
1211
|
+
* evade la comprobación de ciclo ni abre una segunda rama en el store.
|
|
1212
|
+
*/
|
|
1213
|
+
async assertEdge(child, parent, operation) {
|
|
1214
|
+
assertScope(child);
|
|
1215
|
+
assertScope(parent);
|
|
1216
|
+
if (child.type === APP_SCOPE_TYPE) {
|
|
1217
|
+
throw new InvalidIdentityError(`${operation}: la raíz \`app\` no puede colgar de nada`);
|
|
1218
|
+
}
|
|
1219
|
+
// (ii) 422 E_AUTHZ_UNKNOWN_SCOPE si el padre no existe.
|
|
1220
|
+
const parentChain = await this.knownScope(parent, operation);
|
|
1221
|
+
// El hijo, si el árbol ya lo conoce, con su identidad canónica.
|
|
1222
|
+
const childChain = await this.chain(child, operation);
|
|
1223
|
+
const childKey = scopeKey(childChain ? childChain[0] : child);
|
|
1224
|
+
if (parentChain.some((s) => scopeKey(s) === childKey)) {
|
|
1225
|
+
throw new ScopeCycleError(`${operation}: ${parent.type}:${parent.uuid ?? ''} desciende de ${childKey} (o es él mismo); ` +
|
|
1226
|
+
`colgarlo cerraría un ciclo y FGA lo evaluaría en los dos sentidos: un grant en el ` +
|
|
1227
|
+
`descendiente concedería en el ancestro.`);
|
|
1228
|
+
}
|
|
1229
|
+
return { childKey, parentKey: scopeKey(parentChain[0]) };
|
|
1230
|
+
}
|
|
1231
|
+
/**
|
|
1232
|
+
* El consumidor sacó un scope del árbol. En modo `facts` se borra su
|
|
1233
|
+
* arista `#parent` — y **la arista es lo ÚLTIMO** (S6, cruce 9): el
|
|
1234
|
+
* manager llama primero a `purgeScope`, que borra los hechos del scope y
|
|
1235
|
+
* DEMUESTRA cero o lanza (invariante 11).
|
|
1236
|
+
*
|
|
1237
|
+
* **El motivo del orden cambió con (c2r) y el orden NO** (3b-2i). La razón
|
|
1238
|
+
* que se escribió en 3b-2b —«un scope sin ancestro dejaría de heredar los
|
|
1239
|
+
* denies del padre y sus permisos serían INDENEGABLES»— ya no es cierta:
|
|
1240
|
+
* ése era exactamente el 🔴 1 del auditor R2 y hoy un scope que no alcanza
|
|
1241
|
+
* `app` no concede nada (`can_<P>` exige `rooted`). Lo que sigue justificando
|
|
1242
|
+
* el orden es lo otro: una purga que muere a medias tiene que dejar denies
|
|
1243
|
+
* de MÁS, nunca de menos, y borrar la arista antes convertiría el fallo en
|
|
1244
|
+
* «se quedaron hechos vivos en un nodo que ya nadie purga» (los recoge
|
|
1245
|
+
* `authz:reconcile`, pero mientras tanto el nodo es invisible para el
|
|
1246
|
+
* árbol). Con la arista al final, una purga fallida se reintenta.
|
|
1247
|
+
*
|
|
1248
|
+
* No se tocan las aristas de los HIJOS (`scope:<hijo>#parent@scope:<este>`):
|
|
1249
|
+
* el consumidor notifica un `detached` por nodo, o un `moved` para
|
|
1250
|
+
* recolgarlos — y **desde (c2r) esos hijos, mientras tanto, DENIEGAN** (su
|
|
1251
|
+
* cadena ya no llega a la raíz) en vez de conceder de más. Lo que quede sin
|
|
1252
|
+
* nodo arriba lo ve `authz:reconcile` (3b-3).
|
|
1253
|
+
*/
|
|
1254
|
+
async onScopeDetached(child) {
|
|
1255
|
+
assertScope(child);
|
|
1256
|
+
if (child.type === APP_SCOPE_TYPE) {
|
|
1257
|
+
throw new InvalidIdentityError('scopes.detached: la raíz `app` no se puede borrar ni purgar');
|
|
1258
|
+
}
|
|
1259
|
+
const scope = await this.canonicalOrSelf(child, 'scopes.detached');
|
|
1260
|
+
const object = factsScopeObject(scopeKey(scope));
|
|
1261
|
+
const current = await this.readAllTuples({ object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
|
|
1262
|
+
// Aquí NO se exige un solo padre: el nodo se va entero y llevarse las dos
|
|
1263
|
+
// aristas de una deriva es lo correcto (a diferencia de `moved`, donde
|
|
1264
|
+
// habría que elegir cuál sobrevive).
|
|
1265
|
+
if (current.length === 0)
|
|
1266
|
+
return;
|
|
1267
|
+
await this.client.write({ writes: [], deletes: current });
|
|
1268
|
+
}
|
|
1269
|
+
/**
|
|
1270
|
+
* Escribe la arista del árbol dejando UNA sola: se lee la que hay y se
|
|
1271
|
+
* sustituye en el mismo `Write`. Sin diferencia no se llama al servidor
|
|
1272
|
+
* (invariante 6: re-anexar al mismo padre es un no-op seguro, y además
|
|
1273
|
+
* escribir una tupla que ya está sería un conflicto con los defaults
|
|
1274
|
+
* estrictos del SDK).
|
|
1275
|
+
*
|
|
1276
|
+
* **Y el choque con otro escritor del árbol no es una caída** (3b-2h ·
|
|
1277
|
+
* 🟠 4, invariante 6). Medido contra el `:8101`: dos `attached` del mismo
|
|
1278
|
+
* nodo al mismo padre a la vez —lo que hacen dos pasadas del relay sobre el
|
|
1279
|
+
* mismo lote, porque `pending()` no reserva nada— y el perdedor se llevaba
|
|
1280
|
+
* un **503 «el backend no respondió»**, cuando el backend respondió
|
|
1281
|
+
* perfectamente («cannot write a tuple which already exists»). Aquí se hace
|
|
1282
|
+
* lo que el invariante 6 manda desde `grant`: releer y re-aplicar sobre lo
|
|
1283
|
+
* que quedó —así el re-intento ve la arista del otro y sale por el no-op—,
|
|
1284
|
+
* y una contención que no cede en `TREE_WRITE_ATTEMPTS` vueltas sale como
|
|
1285
|
+
* 409 `E_AUTHZ_WRITE_CONFLICT`, nunca como un 503.
|
|
1286
|
+
*
|
|
1287
|
+
* Esto NO convierte dos escritores en uno: dos `attached` del mismo nodo a
|
|
1288
|
+
* padres DISTINTOS siguen pudiendo dejar dos aristas (FGA no tiene
|
|
1289
|
+
* compare-and-set y el `Read` de arriba es un check-then-write). Lo que
|
|
1290
|
+
* impide esa carrera es el ESCRITOR ÚNICO del relay (`ScopeOutbox.acquire`).
|
|
1291
|
+
*/
|
|
1292
|
+
async reparent(child, parent, operation) {
|
|
1293
|
+
const { childKey, parentKey } = await this.assertEdge(child, parent, operation);
|
|
1294
|
+
const wanted = factsParentTuple(childKey, parentKey);
|
|
1295
|
+
for (let attempt = 0;; attempt++) {
|
|
1296
|
+
const current = await this.readAllTuples({ object: wanted.object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
|
|
1297
|
+
this.assertOneParent(wanted.object, current);
|
|
1298
|
+
if (current.length === 1 && current[0].user === wanted.user) {
|
|
1299
|
+
// **El atajo idempotente TAMBIÉN barre** (3b-8 · A3). El relay
|
|
1300
|
+
// reintenta: un `moved` cuyo primer intento escribió la arista y
|
|
1301
|
+
// murió ANTES del barrido vuelve a entregarse, cae aquí, y hasta
|
|
1302
|
+
// 3b-8 el `return` a secas se saltaba `sweepLocalRoleBindings` — el
|
|
1303
|
+
// rol local del tenant A seguía concediendo en el subárbol del B
|
|
1304
|
+
// para siempre, con el relay en verde (invariante 18 roto por
|
|
1305
|
+
// COMPOSICIÓN de dos piezas correctas: la idempotencia y el barrido).
|
|
1306
|
+
// Coste: cero requests sin roles locales, como el propio barrido.
|
|
1307
|
+
await this.sweepLocalRoleBindings(childKey);
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
// **El ciclo se mira también en el árbol DEL STORE** (3b-8 · A5).
|
|
1311
|
+
// `assertEdge` valida contra la cadena del CONSUMIDOR, pero quien
|
|
1312
|
+
// decide en `facts` es el store, y una outbox aplicada en desorden (un
|
|
1313
|
+
// `moved` aparcado + el move inverso posterior) lo deja desincronizado:
|
|
1314
|
+
// la arista nueva —legal para el consumidor— puede cerrar un ciclo
|
|
1315
|
+
// REAL, que FGA acepta y evalúa en los dos sentidos (cruce 3:
|
|
1316
|
+
// fail-open mudo). `storeChain` del barrido solo lanzaba DESPUÉS de
|
|
1317
|
+
// escribir y no deshacía nada. Se sube por la cadena del PADRE en el
|
|
1318
|
+
// store antes del `Write`; el coste es O(profundidad) lecturas por
|
|
1319
|
+
// cambio de árbol, fuera del camino caliente.
|
|
1320
|
+
if ((await this.storeChain(parentKey)).includes(childKey)) {
|
|
1321
|
+
throw new ScopeCycleError(`${operation}: en el árbol del STORE ${factsScopeObject(parentKey)} desciende de ${childKey} ` +
|
|
1322
|
+
`(o es él mismo); escribir la arista cerraría un ciclo real que FGA evalúa en los dos ` +
|
|
1323
|
+
`sentidos. El árbol del store está desincronizado del consumidor (una outbox aplicada en ` +
|
|
1324
|
+
`desorden, una entrada aparcada): reconstrúyelo con authz:reconcile.`);
|
|
1325
|
+
}
|
|
1326
|
+
try {
|
|
1327
|
+
await this.client.write({ writes: [wanted], deletes: current });
|
|
1328
|
+
}
|
|
1329
|
+
catch (error) {
|
|
1330
|
+
if (!isTreeWriteRace(error))
|
|
1331
|
+
throw error;
|
|
1332
|
+
if (attempt >= TREE_WRITE_ATTEMPTS - 1) {
|
|
1333
|
+
throw new WriteConflictError(`${operation}: ${TREE_WRITE_ATTEMPTS} intentos y el árbol del store sigue en conflicto sobre ` +
|
|
1334
|
+
`${wanted.object}. El relay es escritor ÚNICO: si hay dos pasadas a la vez, dale un lease a tu outbox.`, { cause: error });
|
|
1335
|
+
}
|
|
1336
|
+
continue;
|
|
1337
|
+
}
|
|
1338
|
+
await this.sweepLocalRoleBindings(childKey);
|
|
1339
|
+
return;
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
/**
|
|
1343
|
+
* **El barrido del rol local** (3b-2e · E1; decisión del dueño del
|
|
1344
|
+
* 2026-08-30, opción 1).
|
|
1345
|
+
*
|
|
1346
|
+
* En (c2) el modelo no tiene `owner`, así que `authorize` NO vuelve a
|
|
1347
|
+
* decidir con el árbol de hoy si un rol LOCAL sigue siendo visible: un
|
|
1348
|
+
* `role_binding` concede mientras su scope alcance al que pregunta. Sin
|
|
1349
|
+
* esto, mover una unit fuera de la organización dueña de un rol local
|
|
1350
|
+
* dejaría de retirar lo concedido (el invariante 18 en `database`), que es
|
|
1351
|
+
* un **fail-open** — y encima uno que solo se ve comparando drivers.
|
|
1352
|
+
*
|
|
1353
|
+
* Lo que se toca es la arista `scope#binding`, que es lo que hace
|
|
1354
|
+
* ALCANZABLE la asignación: se BORRA donde el owner del rol ya no está en
|
|
1355
|
+
* la cadena y se REESCRIBE donde vuelve a estarlo (invariante 18: volver la
|
|
1356
|
+
* unit a su sitio restaura). No se toca el `assignee` —el hecho de la
|
|
1357
|
+
* asignación no cambia porque el árbol se mueva, igual que en `database`—
|
|
1358
|
+
* ni nada de un rol GLOBAL, cuya visibilidad no depende del árbol.
|
|
1359
|
+
*
|
|
1360
|
+
* **Por subárbol, no por nodo** (consecuencia 2): los descendientes del
|
|
1361
|
+
* nodo movido también cambian de cadena.
|
|
1362
|
+
*
|
|
1363
|
+
* Coste: si el catálogo no tiene NI UN rol local —el caso de todo consumidor
|
|
1364
|
+
* que no usa delegación— son **cero** requests y `moved` sigue siendo el
|
|
1365
|
+
* `Read` + `Write` del cruce 8. Con roles locales: una lectura por rol local
|
|
1366
|
+
* (sus bindings, por `role_binding#role`), la bajada del subárbol y un
|
|
1367
|
+
* `Write` por lote.
|
|
1368
|
+
*/
|
|
1369
|
+
async sweepLocalRoleBindings(movedKey) {
|
|
1370
|
+
const catalog = await this.catalog.view();
|
|
1371
|
+
const locals = catalog.localRoles();
|
|
1372
|
+
if (locals.length === 0)
|
|
1373
|
+
return;
|
|
1374
|
+
// La cadena del nodo movido y su subárbol se leen del STORE, no del
|
|
1375
|
+
// consumidor: el árbol que decide en `facts` es el del store, y con la
|
|
1376
|
+
// outbox el relay aplica esto MÁS TARDE, cuando el árbol del consumidor
|
|
1377
|
+
// puede haber seguido moviéndose.
|
|
1378
|
+
const chainOfMoved = await this.storeChain(movedKey);
|
|
1379
|
+
/** `scopeKey` → claves de su cadena (él incluido), con el árbol de AHORA. */
|
|
1380
|
+
const subtree = new Map([[movedKey, chainOfMoved]]);
|
|
1381
|
+
const pending = [movedKey];
|
|
1382
|
+
while (pending.length) {
|
|
1383
|
+
const key = pending.pop();
|
|
1384
|
+
const children = await this.readAllTuples({ user: factsScopeObject(key), relation: FACTS_PARENT_RELATION, object: `${FACTS_SCOPE_TYPE}:` }, { includeExpired: true });
|
|
1385
|
+
for (const edge of children) {
|
|
1386
|
+
const childKey = edge.object.slice(FACTS_SCOPE_TYPE.length + 1);
|
|
1387
|
+
if (subtree.has(childKey))
|
|
1388
|
+
continue;
|
|
1389
|
+
subtree.set(childKey, [childKey, ...subtree.get(key)]);
|
|
1390
|
+
pending.push(childKey);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
const writes = [];
|
|
1394
|
+
const deletes = [];
|
|
1395
|
+
for (const role of locals) {
|
|
1396
|
+
// Los bindings de ESTE rol, esté donde esté: `role_binding:…#role@role:<uuid>`.
|
|
1397
|
+
// El filtro lleva `user`, que es lo que el servidor real exige cuando el
|
|
1398
|
+
// objeto es solo un tipo (lección del 2c).
|
|
1399
|
+
const bindings = await this.readAllTuples({ user: `${FACTS_ROLE_TYPE}:${role.uuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
|
|
1400
|
+
for (const binding of this.parseBindings(FACTS_BINDING_TYPE, bindings.map((t) => t.object))) {
|
|
1401
|
+
const chainKeys = subtree.get(scopeKey(binding.scope));
|
|
1402
|
+
if (!chainKeys)
|
|
1403
|
+
continue;
|
|
1404
|
+
this.classifyBindingEdge(catalog, binding, chainKeys, writes, deletes);
|
|
1405
|
+
}
|
|
1406
|
+
}
|
|
1407
|
+
await this.applyBindingSweep(writes, deletes);
|
|
1408
|
+
}
|
|
1409
|
+
/**
|
|
1410
|
+
* **El barrido por NIVEL** (3b-2g · R1; decisión del dueño del 2026-08-30
|
|
1411
|
+
* (2), mismo mecanismo que E1).
|
|
1412
|
+
*
|
|
1413
|
+
* El modelo (c2) tampoco lleva el NIVEL (`scope_type`) del rol: la
|
|
1414
|
+
* proyección dice qué permisos vincula, no en qué nivel se declara. Sin
|
|
1415
|
+
* esto, cambiar el `scope_type` de un rol retira lo concedido en `database`
|
|
1416
|
+
* —donde `declaredRoleAt` se evalúa en cada pregunta— y **sigue
|
|
1417
|
+
* concediendo** en `facts`, que es la divergencia R1 del lote 2e.
|
|
1418
|
+
*
|
|
1419
|
+
* Se cierra igual que el owner: barriendo la arista `scope#binding` de los
|
|
1420
|
+
* bindings de ESE rol con la regla única de visibilidad. Lo llama
|
|
1421
|
+
* `projectCatalogRole`, que es el hook de «una escritura de catálogo cambió
|
|
1422
|
+
* este rol»: el manager lo dispara tras `defineScopedRole`/`updateScopedRole`
|
|
1423
|
+
* y un escritor «a mano» de `authz_*` tiene el mismo deber que ya tenía con
|
|
1424
|
+
* el espejo de permisos (sin él, en `facts` un rol recién definido no
|
|
1425
|
+
* concedería nada y quitarle un permiso seguiría concediéndolo).
|
|
1426
|
+
*
|
|
1427
|
+
* Coste: una lectura (los bindings del rol) y, **solo si el rol es LOCAL y
|
|
1428
|
+
* tiene bindings**, la cadena del store de cada scope distinto donde cuelga
|
|
1429
|
+
* uno; un `Write` por lote si hay algo que barrer. Un rol sin bindings —el
|
|
1430
|
+
* caso de todo `defineScopedRole`— son 0 escrituras.
|
|
1431
|
+
*/
|
|
1432
|
+
async sweepRoleVisibility(roleUuid) {
|
|
1433
|
+
const catalog = await this.catalog.view();
|
|
1434
|
+
// Un rol que el catálogo ya no declara no tiene visibilidad que decidir:
|
|
1435
|
+
// sus hechos son de `purgeRole` (que los borra enteros) y de
|
|
1436
|
+
// `authz:reconcile`, no de este barrido.
|
|
1437
|
+
const role = catalog.roleByUuid(roleUuid);
|
|
1438
|
+
if (!role)
|
|
1439
|
+
return;
|
|
1440
|
+
const bindings = await this.readAllTuples({ user: `${FACTS_ROLE_TYPE}:${roleUuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
|
|
1441
|
+
if (bindings.length === 0)
|
|
1442
|
+
return;
|
|
1443
|
+
const writes = [];
|
|
1444
|
+
const deletes = [];
|
|
1445
|
+
/** `scopeKey` → su cadena en el árbol del STORE, una vez por scope. */
|
|
1446
|
+
const chains = new Map();
|
|
1447
|
+
for (const binding of this.parseBindings(FACTS_BINDING_TYPE, bindings.map((t) => t.object))) {
|
|
1448
|
+
const key = scopeKey(binding.scope);
|
|
1449
|
+
let chainKeys = chains.get(key);
|
|
1450
|
+
if (!chainKeys) {
|
|
1451
|
+
// La cadena solo decide para un rol LOCAL: la visibilidad de un
|
|
1452
|
+
// global no depende del árbol, así que no se paga por leerlo.
|
|
1453
|
+
chainKeys = role.owner === GLOBAL_OWNER_KEY ? [key] : await this.storeChain(key);
|
|
1454
|
+
chains.set(key, chainKeys);
|
|
1455
|
+
}
|
|
1456
|
+
this.classifyBindingEdge(catalog, binding, chainKeys, writes, deletes);
|
|
1457
|
+
}
|
|
1458
|
+
await this.applyBindingSweep(writes, deletes);
|
|
1459
|
+
}
|
|
1460
|
+
/**
|
|
1461
|
+
* La arista `scope#binding` de un binding, a escribir o a borrar según la
|
|
1462
|
+
* **regla única de visibilidad** (`declaredRoleAt`, la misma que evalúa
|
|
1463
|
+
* `database` en cada pregunta): el rol tiene que estar declarado para el
|
|
1464
|
+
* NIVEL de ese scope (3b-2g · R1) y ser global o tener a su owner en la
|
|
1465
|
+
* cadena (3b-2e · E1). Visible ⇒ la arista se (re)escribe; no visible ⇒ se
|
|
1466
|
+
* borra. El `assignee` no se toca: la asignación existe igual, lo que
|
|
1467
|
+
* cambia es dónde se la ve.
|
|
1468
|
+
*/
|
|
1469
|
+
classifyBindingEdge(catalog, binding, chainKeys, writes, deletes) {
|
|
1470
|
+
const edge = factsScopeBindingTuple(scopeKey(binding.scope), binding.uuid);
|
|
1471
|
+
if (declaredRoleAt(catalog, binding.uuid, binding.scope.type, chainKeys))
|
|
1472
|
+
writes.push(edge);
|
|
1473
|
+
else
|
|
1474
|
+
deletes.push(edge);
|
|
1475
|
+
}
|
|
1476
|
+
/**
|
|
1477
|
+
* Aplica el barrido en lotes ≤ 100 (el límite del `Write`).
|
|
1478
|
+
*
|
|
1479
|
+
* `Ignore` en las dos direcciones: el barrido dice el estado que DEBE
|
|
1480
|
+
* quedar, no el delta — borrar lo que ya no está y reescribir lo que ya
|
|
1481
|
+
* estaba son no-ops, no errores (invariante 6).
|
|
1482
|
+
*/
|
|
1483
|
+
async applyBindingSweep(writes, deletes) {
|
|
1484
|
+
if (!writes.length && !deletes.length)
|
|
1485
|
+
return;
|
|
1486
|
+
const operations = [
|
|
1487
|
+
...deletes.map((tuple) => ({ delete: tuple })),
|
|
1488
|
+
...writes.map((tuple) => ({ write: tuple })),
|
|
1489
|
+
];
|
|
1490
|
+
for (let i = 0; i < operations.length; i += PURGE_BATCH_SIZE) {
|
|
1491
|
+
const chunk = operations.slice(i, i + PURGE_BATCH_SIZE);
|
|
1492
|
+
await this.client.write({
|
|
1493
|
+
writes: chunk.filter((o) => o.write).map((o) => o.write),
|
|
1494
|
+
deletes: chunk.filter((o) => o.delete).map((o) => o.delete),
|
|
1495
|
+
}, {
|
|
1496
|
+
conflict: {
|
|
1497
|
+
onDuplicateWrites: ClientWriteRequestOnDuplicateWrites.Ignore,
|
|
1498
|
+
onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore,
|
|
1499
|
+
},
|
|
1500
|
+
});
|
|
1501
|
+
}
|
|
1502
|
+
}
|
|
1503
|
+
/**
|
|
1504
|
+
* `[key, ...ancestros]` según el ÁRBOL DEL STORE (3b-2e · E1), subiendo por
|
|
1505
|
+
* `scope#parent`. Es la cadena con la que FGA va a decidir, que es la que
|
|
1506
|
+
* tiene que gobernar el barrido; el resolutor del consumidor no participa.
|
|
1507
|
+
* Un nodo con más de un padre es deriva y se dice (`assertOneParent`), y el
|
|
1508
|
+
* recorrido está acotado por el mismo tope de páginas que las
|
|
1509
|
+
* enumeraciones: un ciclo escrito a mano no cuelga el proceso.
|
|
1510
|
+
*/
|
|
1511
|
+
async storeChain(key) {
|
|
1512
|
+
const chain = [key];
|
|
1513
|
+
const seen = new Set([key]);
|
|
1514
|
+
for (let hop = 0; hop < MAX_SCOPE_CHAIN_HOPS; hop++) {
|
|
1515
|
+
const object = factsScopeObject(chain[chain.length - 1]);
|
|
1516
|
+
const parents = await this.readAllTuples({ object, relation: FACTS_PARENT_RELATION }, { includeExpired: true });
|
|
1517
|
+
this.assertOneParent(object, parents);
|
|
1518
|
+
if (parents.length === 0)
|
|
1519
|
+
return chain;
|
|
1520
|
+
const parentKey = parents[0].user.slice(FACTS_SCOPE_TYPE.length + 1);
|
|
1521
|
+
if (seen.has(parentKey)) {
|
|
1522
|
+
throw new ScopeCycleError(`El árbol del store tiene un ciclo en ${factsScopeObject(parentKey)}: no se puede decidir la ` +
|
|
1523
|
+
`visibilidad de un rol local. Reconstruye el árbol con authz:reconcile.`);
|
|
1524
|
+
}
|
|
1525
|
+
seen.add(parentKey);
|
|
1526
|
+
chain.push(parentKey);
|
|
1527
|
+
}
|
|
1528
|
+
throw new AuthorizationInternalError(`El árbol del store pasa de ${MAX_SCOPE_CHAIN_HOPS} niveles desde ${factsScopeObject(key)}; ` +
|
|
1529
|
+
`no se puede resolver la cadena para el barrido de roles locales.`);
|
|
1530
|
+
}
|
|
1531
|
+
/**
|
|
1532
|
+
* Purga del scope exacto en FGA (N7, S6, B2). No hay "borrar todo lo de
|
|
1533
|
+
* este objeto": se leen por objeto EXACTO los bindings posibles — un
|
|
1534
|
+
* `role_binding` por cada rol del catálogo de ese `scope_type` — más el
|
|
1535
|
+
* objeto `scope:<key>` (donde viven los `denied_<P>` y el `#binding`),
|
|
1536
|
+
* paginando `Read` (nunca ListObjects: trunca sin avisar, L0.7), se borra
|
|
1537
|
+
* en lotes ≤ 100 (límite del Write) y se vuelve a leer cada objeto: si
|
|
1538
|
+
* queda algo, se lanza. Un rol retirado del catálogo deja bindings
|
|
1539
|
+
* inalcanzables por esta vía; es el precio de no tener un índice por
|
|
1540
|
+
* objeto, y lo vigilará `authz:reconcile` (3b).
|
|
1541
|
+
*/
|
|
1542
|
+
async purgeScope(purged) {
|
|
1543
|
+
assertScope(purged);
|
|
1544
|
+
if (purged.type === APP_SCOPE_TYPE) {
|
|
1545
|
+
throw new InvalidIdentityError('purgeScope: la raíz `app` no se purga');
|
|
1546
|
+
}
|
|
1547
|
+
// La identidad canónica si el árbol aún lo conoce (K1); tal cual si ya no.
|
|
1548
|
+
const scope = await this.canonicalOrSelf(purged, 'purgeScope');
|
|
1549
|
+
const key = scopeKey(scope);
|
|
1550
|
+
const roles = await this.sql('purgeScope.roles', () => db.from('authz_roles').where('scope_type', scope.type).select('uuid'));
|
|
1551
|
+
// **Los denies no son objetos propios** (3b-2c): son relaciones
|
|
1552
|
+
// `denied_<P>` DEL SCOPE, así que se purgan leyendo el objeto
|
|
1553
|
+
// `scope:<key>` —una lectura en vez de O(permisos)—, y con ellos se va el
|
|
1554
|
+
// enlace `#binding` de los bindings que colgaban de aquí. Lo que NO se
|
|
1555
|
+
// toca es la arista `parent`: la borra `scopes.detached` DESPUÉS de que
|
|
1556
|
+
// esta purga demuestre cero (S6, cruce 9). Al revés, una purga que muriera
|
|
1557
|
+
// a medias dejaría el scope sin ancestro y, desde (c2r), sin conceder
|
|
1558
|
+
// nada — pero también sin nadie que volviera a purgarlo.
|
|
1559
|
+
const scopeObject = factsScopeObject(key);
|
|
1560
|
+
const objects = [...roles.map((r) => `role_binding:${key}|${r.uuid}`), scopeObject];
|
|
1561
|
+
/** Lo purgable de un objeto: todo, salvo la arista `parent` del scope. */
|
|
1562
|
+
const purgeable = async (object) => {
|
|
1563
|
+
const keys = await this.readAllTuples({ object }, { includeExpired: true });
|
|
1564
|
+
return object === scopeObject ? keys.filter((k) => k.relation !== FACTS_PARENT_RELATION) : keys;
|
|
1565
|
+
};
|
|
1566
|
+
/** Borra en lotes ≤ 100 (límite del `Write`), sin quejarse de lo que ya no está. */
|
|
1567
|
+
const deleteKeys = async (keys) => {
|
|
1568
|
+
for (let i = 0; i < keys.length; i += PURGE_BATCH_SIZE) {
|
|
1569
|
+
await this.client.deleteTuples(keys.slice(i, i + PURGE_BATCH_SIZE), {
|
|
1570
|
+
conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
|
|
1571
|
+
});
|
|
1572
|
+
}
|
|
1573
|
+
};
|
|
1574
|
+
/** Las dos aristas de (c2): estructura, no hechos (`#role` y `#binding`). */
|
|
1575
|
+
const isStructure = (k) => k.relation === FACTS_BINDING_RELATION || k.relation === FACTS_ROLE_RELATION;
|
|
1576
|
+
// **El ORDEN de la purga es parte del contrato** (3b-2f · R3).
|
|
1577
|
+
// No hay transacción que abarque los N objetos, así que un `grant`
|
|
1578
|
+
// concurrente aterriza en algún hueco; lo que se elige es que NINGÚN
|
|
1579
|
+
// hueco deje una asignación que `listRoles`/`hasRole` ven y `authorize`
|
|
1580
|
+
// no honra —enumerada y sin conceder, que es peor que perder la
|
|
1581
|
+
// escritura—:
|
|
1582
|
+
//
|
|
1583
|
+
// 1. **la ESTRUCTURA primero** (`role_binding#role` y `scope#binding`).
|
|
1584
|
+
// Son deterministas —`factsBindingTuples(key, rol)`—, así que se
|
|
1585
|
+
// borran a ciegas y esta fase no cuesta ni una lectura.
|
|
1586
|
+
// 2. **los HECHOS después** (`assignee`), releyendo cada objeto: lo que
|
|
1587
|
+
// alguien escribió después de (1) también se borra.
|
|
1588
|
+
// 3. **los `denied_<P>` del scope, los últimos.**
|
|
1589
|
+
//
|
|
1590
|
+
// Con el `grant` escribiendo sus TRES tuplas en un solo `Write`, un
|
|
1591
|
+
// grant que aterriza antes de (2) pierde su asignación ahí (cero,
|
|
1592
|
+
// coherente) y uno que aterriza después se queda ENTERO con las aristas
|
|
1593
|
+
// que él mismo escribió (concede, coherente). Al revés —las aristas al
|
|
1594
|
+
// final, como hasta 3b-2e— el grant que caía en medio se quedaba con el
|
|
1595
|
+
// `assignee` y sin arista.
|
|
1596
|
+
// Los denies van los ÚLTIMOS porque una purga que muere a medias tiene
|
|
1597
|
+
// que dejar denies de MÁS, nunca de menos (invariante 2): entre (1) y
|
|
1598
|
+
// (3) lo que queda del scope es inerte, no permisivo. Y (2) y (3) borran
|
|
1599
|
+
// SOLO lo suyo: una arista que un grant concurrente reescribió no se
|
|
1600
|
+
// toca —sería volver a huerfanar su asignación—; queda como residuo, y
|
|
1601
|
+
// el residuo es justo lo que la demostración de cero reporta. La arista
|
|
1602
|
+
// `parent` sigue sin tocarse: la borra `scopes.detached` DESPUÉS de que
|
|
1603
|
+
// esta purga demuestre cero (S6, cruce 9).
|
|
1604
|
+
await deleteKeys(roles.flatMap((r) => factsBindingTuples(key, r.uuid)));
|
|
1605
|
+
for (const object of objects) {
|
|
1606
|
+
if (object === scopeObject)
|
|
1607
|
+
continue;
|
|
1608
|
+
await deleteKeys((await purgeable(object)).filter((k) => !isStructure(k)));
|
|
1609
|
+
}
|
|
1610
|
+
// **Las aristas `#binding` de roles que el catálogo YA NO declara en este
|
|
1611
|
+
// nivel también se purgan** (3b-8 · C2). El barrido no es atómico con el
|
|
1612
|
+
// catálogo: un rol cuya fila se borró (o cambió de `scope_type`) entre el
|
|
1613
|
+
// commit y esta purga dejaba una arista que el paso 1 no ve —solo mira
|
|
1614
|
+
// `authz_roles WHERE scope_type = <este>`— y que la demostración de cero
|
|
1615
|
+
// SÍ cuenta ⇒ `E_AUTHZ_PURGE_INCOMPLETE` en TODOS los intentos y
|
|
1616
|
+
// `detached` bloqueado para siempre. Borrarla nunca concede (es la arista
|
|
1617
|
+
// de visibilidad, y su rol ya no existe en este nivel), y la arista de un
|
|
1618
|
+
// rol DEL catálogo que un `grant` concurrente reescribió no se toca
|
|
1619
|
+
// (`known` la protege): esa sigue saliendo como residuo, que es lo
|
|
1620
|
+
// correcto — se reintenta.
|
|
1621
|
+
const known = new Set(roles.map((r) => `role_binding:${key}|${r.uuid}`));
|
|
1622
|
+
await deleteKeys((await purgeable(scopeObject)).filter((k) => k.relation === FACTS_BINDING_RELATION && !known.has(k.user)));
|
|
1623
|
+
await deleteKeys((await purgeable(scopeObject)).filter((k) => k.relation.startsWith(FACTS_DENIED_PREFIX)));
|
|
1624
|
+
// Demostrar cero: lo que no se puede demostrar, se reporta.
|
|
1625
|
+
const residue = [];
|
|
1626
|
+
for (const object of objects) {
|
|
1627
|
+
const left = await purgeable(object);
|
|
1628
|
+
if (left.length)
|
|
1629
|
+
residue.push(`${object} (${left.length})`);
|
|
1630
|
+
}
|
|
1631
|
+
if (residue.length) {
|
|
1632
|
+
throw new PurgeIncompleteError(`purgeScope ${key}: quedan tuplas tras el borrado — ${residue.join('; ')}. ` +
|
|
1633
|
+
`No confirmes el borrado del scope; reintenta la purga.`);
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
/**
|
|
1637
|
+
* La **proyección derivada del catálogo** en el store (3b-2a · A5; regla
|
|
1638
|
+
* del catálogo reescrita, panel 2 cruce 7). Se pasa a `syncAuthzCatalog`,
|
|
1639
|
+
* que la usa en dos momentos: comprueba que el catálogo que va a quedar es
|
|
1640
|
+
* publicable (cotas de nombre y techo del modelo) ANTES de escribir, y
|
|
1641
|
+
* rehace las tuplas `role:<uuid>#permits_<P>@<holder>:*` con el catálogo ya
|
|
1642
|
+
* confirmado.
|
|
1643
|
+
*
|
|
1644
|
+
* Sigue sin ser el catálogo: es un espejo reconstruible que ningún camino
|
|
1645
|
+
* de LECTURA de este driver consulta para responder qué permisos tiene un
|
|
1646
|
+
* rol (A6). Quien decide es `authz_*` a través del memo.
|
|
1647
|
+
*/
|
|
1648
|
+
catalogProjection() {
|
|
1649
|
+
return {
|
|
1650
|
+
assertPublishable: (permissions) => {
|
|
1651
|
+
assertFactsModelPublishable(this.holderTypes, permissions, (message) => this.warn(message));
|
|
1652
|
+
},
|
|
1653
|
+
project: (snapshot) => this.projectCatalog(snapshot),
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* **El marcador de raíz de (c2r)** (3b-2i): `scope:app#rooted@<holder>:*`,
|
|
1658
|
+
* una tupla por holder type en todo el store y CERO por scope.
|
|
1659
|
+
*
|
|
1660
|
+
* Va aquí —en la proyección del catálogo, o sea en cada `syncAuthzCatalog`—
|
|
1661
|
+
* y no en `attached`, porque el evento que hace falta cubrir es **añadir un
|
|
1662
|
+
* holderType al `config`**: sin esto ese holder denegaría en TODO el store
|
|
1663
|
+
* aunque el modelo se haya republicado, que es la única forma realista de
|
|
1664
|
+
* quedarse sin marcador en un store vivo. Es idempotente: un `Read` y, solo
|
|
1665
|
+
* si falta algo, un `Write` con lo que falta (0 escrituras en el caso
|
|
1666
|
+
* normal).
|
|
1667
|
+
*
|
|
1668
|
+
* Solo en modo `facts`: el modelo del modo `resolver` no declara `rooted` y
|
|
1669
|
+
* escribirlo sería un 400 del servidor.
|
|
1670
|
+
*
|
|
1671
|
+
* ⚠️ Sin marcador el store entero DENIEGA (fail-closed, medido). Por eso se
|
|
1672
|
+
* repone en cada sync y `authz:reconcile` (3b-3) tiene el deber escrito de
|
|
1673
|
+
* reportarlo como deriva cuando falte.
|
|
1674
|
+
*/
|
|
1675
|
+
async ensureFactsRoot() {
|
|
1676
|
+
const wanted = factsRootTuples(this.holderTypes);
|
|
1677
|
+
const current = await this.readAllTuples({ object: factsScopeObject(APP_SCOPE_TYPE), relation: FACTS_ROOTED_RELATION }, { includeExpired: true });
|
|
1678
|
+
const have = new Set(current.map((tuple) => tuple.user));
|
|
1679
|
+
const missing = wanted.filter((tuple) => !have.has(tuple.user));
|
|
1680
|
+
if (missing.length)
|
|
1681
|
+
await this.client.write({ writes: missing, deletes: [] });
|
|
1682
|
+
}
|
|
1683
|
+
/**
|
|
1684
|
+
* Espeja los vínculos rol→permiso: escribe lo que falta y BORRA lo que
|
|
1685
|
+
* sobra, en UN `Write` por lote con deletes y writes juntos (cruce 8:
|
|
1686
|
+
* queda prohibido el patrón `deleteTuples()` + `writeTuples()`, que no es
|
|
1687
|
+
* atómico). Con (c2) quitar un permiso de un rol son tantos deletes como
|
|
1688
|
+
* holders y ninguna reescritura del modelo.
|
|
1689
|
+
*
|
|
1690
|
+
* Sin diferencias no se llama al servidor: un `sync` que no cambió el
|
|
1691
|
+
* catálogo escribe CERO tuplas.
|
|
1692
|
+
*/
|
|
1693
|
+
async projectCatalog(snapshot) {
|
|
1694
|
+
await this.ensureFactsRoot();
|
|
1695
|
+
const wanted = new Map();
|
|
1696
|
+
for (const tuple of factsCatalogTuples(snapshot.roles, this.holderTypes)) {
|
|
1697
|
+
wanted.set(factsTupleId(tuple), tuple);
|
|
1698
|
+
}
|
|
1699
|
+
// Lo que la proyección tiene HOY. `Read` por prefijo de tipo, paginado y
|
|
1700
|
+
// acotado como todas las enumeraciones del driver (L0.7) — y UNA lectura
|
|
1701
|
+
// por holder, porque el servidor REAL rechaza con un 400 un `Read` cuyo
|
|
1702
|
+
// objeto es solo el tipo y no trae user («the object type field is
|
|
1703
|
+
// required and both the object id and user cannot be empty»). Con el
|
|
1704
|
+
// comodín de usuario de (c2) eso no cuesta nada: la proyección son
|
|
1705
|
+
// exactamente las tuplas `<holder>:*`, así que preguntando por cada
|
|
1706
|
+
// comodín se ve el espejo ENTERO —incluidas las tuplas de roles que el
|
|
1707
|
+
// catálogo ya no lista, que es lo que hay que borrar—. Lo escrito con
|
|
1708
|
+
// otro user sobre `role:` no es de esta proyección y no se toca.
|
|
1709
|
+
const current = new Map();
|
|
1710
|
+
for (const holderType of Object.values(this.holderTypes)) {
|
|
1711
|
+
for (const key of await this.readAllTuples({ user: `${holderType}:*`, object: `${FACTS_ROLE_TYPE}:` }, { includeExpired: true })) {
|
|
1712
|
+
// Solo la familia del catálogo: si el modelo crece con otras
|
|
1713
|
+
// relaciones sobre `role`, no son de esta proyección.
|
|
1714
|
+
if (!key.relation.startsWith(FACTS_PERMITS_PREFIX))
|
|
1715
|
+
continue;
|
|
1716
|
+
current.set(factsTupleId(key), key);
|
|
1717
|
+
}
|
|
1718
|
+
}
|
|
1719
|
+
const writes = [...wanted.values()].filter((tuple) => !current.has(factsTupleId(tuple)));
|
|
1720
|
+
const deletes = [...current.values()].filter((tuple) => !wanted.has(factsTupleId(tuple)));
|
|
1721
|
+
// Los deletes van primero dentro del lote: quitar un permiso tiene que
|
|
1722
|
+
// caber en la misma request que lo que lo sustituye.
|
|
1723
|
+
const operations = [
|
|
1724
|
+
...deletes.map((tuple) => ({ delete: tuple })),
|
|
1725
|
+
...writes.map((tuple) => ({ write: tuple })),
|
|
1726
|
+
];
|
|
1727
|
+
for (let i = 0; i < operations.length; i += PURGE_BATCH_SIZE) {
|
|
1728
|
+
const chunk = operations.slice(i, i + PURGE_BATCH_SIZE);
|
|
1729
|
+
await this.client.write({
|
|
1730
|
+
writes: chunk.filter((o) => o.write).map((o) => o.write),
|
|
1731
|
+
deletes: chunk.filter((o) => o.delete).map((o) => o.delete),
|
|
1732
|
+
});
|
|
1733
|
+
}
|
|
1734
|
+
return { written: writes.length, deleted: deletes.length, unchanged: wanted.size - writes.length };
|
|
1735
|
+
}
|
|
1736
|
+
/**
|
|
1737
|
+
* **Purga un ROL con sus hechos** (3b-2e · E4; hasta aquí este driver no lo
|
|
1738
|
+
* traía y por eso `defineScopedRole` era 500 `E_AUTHZ_UNSUPPORTED` antes de
|
|
1739
|
+
* escribir, 3E · P4).
|
|
1740
|
+
*
|
|
1741
|
+
* Lo que lo hace posible es (c2): el binding APUNTA A SU ROL
|
|
1742
|
+
* (`role_binding:…#role@role:<uuid>`), así que los bindings de un rol se
|
|
1743
|
+
* enumeran filtrando por `user` — y con la arista `scope#binding` se sabe de
|
|
1744
|
+
* qué scope cuelga cada uno. En el modo `resolver` esas dos aristas no
|
|
1745
|
+
* existen y el método TAMPOCO: el constructor lo retira (el manager lo lee
|
|
1746
|
+
* como «no sé purgar» y se niega antes de escribir).
|
|
1747
|
+
*
|
|
1748
|
+
* Orden: **hechos primero, catálogo después** (el mismo de `detached`, S6).
|
|
1749
|
+
* No hay transacción que abarque FGA y SQL, así que lo que se garantiza es
|
|
1750
|
+
* la dirección segura: mientras la fila del rol siga viva, lo que quede en
|
|
1751
|
+
* el store es visible y reintentable; al revés quedarían hechos huérfanos
|
|
1752
|
+
* que resucitarían al recrear el slug. Y se DEMUESTRA cero (invariante 11):
|
|
1753
|
+
* si algo sobrevive, 500 `E_AUTHZ_PURGE_INCOMPLETE` y el catálogo no se
|
|
1754
|
+
* toca.
|
|
1755
|
+
*/
|
|
1756
|
+
async purgeRole(roleUuid) {
|
|
1757
|
+
assertCatalogUuid('rol', roleUuid);
|
|
1758
|
+
const existing = await this.sql('purgeRole.role', () => db.from('authz_roles').where('uuid', roleUuid).select('uuid'));
|
|
1759
|
+
if (!existing.length)
|
|
1760
|
+
throw new UnknownRoleError(roleUuid);
|
|
1761
|
+
const roleObject = `${FACTS_ROLE_TYPE}:${roleUuid}`;
|
|
1762
|
+
/** Los objetos `role_binding` de este rol, por su arista `#role`. */
|
|
1763
|
+
const bindingsOf = async () => {
|
|
1764
|
+
const edges = await this.readAllTuples({ user: roleObject, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
|
|
1765
|
+
return [...new Set(edges.map((edge) => edge.object))];
|
|
1766
|
+
};
|
|
1767
|
+
/** El scope del que cuelga un binding, según su propio id (`<scopeKey>|<roleUuid>`). */
|
|
1768
|
+
const scopeOf = (binding) => {
|
|
1769
|
+
const id = binding.slice(FACTS_BINDING_TYPE.length + 1);
|
|
1770
|
+
const separator = id.lastIndexOf('|');
|
|
1771
|
+
return separator > 0 ? id.slice(0, separator) : null;
|
|
1772
|
+
};
|
|
1773
|
+
/** Todo lo purgable del rol: sus bindings enteros, las aristas del scope y su proyección. */
|
|
1774
|
+
const purgeable = async () => {
|
|
1775
|
+
const keys = [];
|
|
1776
|
+
for (const binding of await bindingsOf()) {
|
|
1777
|
+
keys.push(...(await this.readAllTuples({ object: binding }, { includeExpired: true })));
|
|
1778
|
+
const key = scopeOf(binding);
|
|
1779
|
+
if (!key)
|
|
1780
|
+
continue;
|
|
1781
|
+
// La arista vive en el objeto `scope`, así que se lee de ahí (y solo
|
|
1782
|
+
// la que apunta a ESTE binding: el scope tiene las de otros roles).
|
|
1783
|
+
const scopeEdges = await this.readAllTuples({ user: binding, relation: FACTS_BINDING_RELATION, object: factsScopeObject(key) }, { includeExpired: true });
|
|
1784
|
+
keys.push(...scopeEdges);
|
|
1785
|
+
}
|
|
1786
|
+
// La proyección del catálogo del rol: `role:<uuid>#permits_<P>@<holder>:*`.
|
|
1787
|
+
for (const holderType of Object.values(this.holderTypes)) {
|
|
1788
|
+
keys.push(...(await this.readAllTuples({ user: `${holderType}:*`, object: roleObject }, { includeExpired: true })));
|
|
1789
|
+
}
|
|
1790
|
+
return keys;
|
|
1791
|
+
};
|
|
1792
|
+
const keys = await purgeable();
|
|
1793
|
+
for (let i = 0; i < keys.length; i += PURGE_BATCH_SIZE) {
|
|
1794
|
+
await this.client.deleteTuples(keys.slice(i, i + PURGE_BATCH_SIZE), {
|
|
1795
|
+
conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
|
|
1796
|
+
});
|
|
1797
|
+
}
|
|
1798
|
+
const residue = await purgeable();
|
|
1799
|
+
if (residue.length) {
|
|
1800
|
+
throw new PurgeIncompleteError(`purgeRole ${roleUuid}: quedan ${residue.length} tuplas tras el borrado ` +
|
|
1801
|
+
`(${residue.slice(0, 3).map((k) => `${k.user}#${k.relation}@${k.object}`).join('; ')}…). ` +
|
|
1802
|
+
`El rol NO se borra del catálogo: reintenta la purga.`);
|
|
1803
|
+
}
|
|
1804
|
+
try {
|
|
1805
|
+
await this.sql('purgeRole', () => withAuthzCatalogWrite(async (trx) => {
|
|
1806
|
+
// `authz_assignments` es del driver `database` y aquí está vacía,
|
|
1807
|
+
// pero se limpia igual: un store que viene de una migración entre
|
|
1808
|
+
// drivers no puede dejar filas apuntando a un rol que ya no existe.
|
|
1809
|
+
await this.sql('purgeRole.assignments', () => trx.from('authz_assignments').where('role_uuid', roleUuid).delete());
|
|
1810
|
+
await this.sql('purgeRole.links', () => trx.from('authz_role_permissions').where('role_uuid', roleUuid).delete());
|
|
1811
|
+
const deleted = await this.sql('purgeRole.role.delete', () => trx.from('authz_roles').where('uuid', roleUuid).delete());
|
|
1812
|
+
if (Number(Array.isArray(deleted) ? deleted[0] : deleted) === 0)
|
|
1813
|
+
throw new UnknownRoleError(roleUuid);
|
|
1814
|
+
}, { driver: 'openfga', timeoutMs: this.timeoutMs }));
|
|
1815
|
+
}
|
|
1816
|
+
finally {
|
|
1817
|
+
this.catalog.invalidate();
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
1821
|
+
* Cuántos hechos VIGENTES tiene cada rol, en todos los scopes (3b-2j).
|
|
1822
|
+
*
|
|
1823
|
+
* Aquí los hechos son TUPLAS, no filas: `role_binding:<scope>|<rol>#assignee@<holder>`,
|
|
1824
|
+
* con la caducidad en su *condition*. Por eso esta pregunta es del PUERTO
|
|
1825
|
+
* y no del barrido: hasta 3b-2j `pruneOrphanRoles` contaba
|
|
1826
|
+
* `authz_assignments` —la tabla del driver `database`, vacía con este— y
|
|
1827
|
+
* el `stillGranting` que se lee justo antes de purgar decía SIEMPRE «este
|
|
1828
|
+
* rol no concede», sobre roles que concedían (medido en el lote 2i).
|
|
1829
|
+
*
|
|
1830
|
+
* Lo hace posible lo mismo que hace posible `purgeRole`: el binding apunta
|
|
1831
|
+
* a su rol (`role_binding:…#role@role:<uuid>`, (c2)), así que los bindings
|
|
1832
|
+
* de un rol se enumeran filtrando por `user`. En modo `resolver` esa arista
|
|
1833
|
+
* no existe y el método TAMPOCO (el constructor lo retira, y el manager lo
|
|
1834
|
+
* lee como «no lo sé»).
|
|
1835
|
+
*
|
|
1836
|
+
* La arista estructural se lee con `includeExpired` —no caduca, la
|
|
1837
|
+
* caducidad está en el `assignee`— y los assignees sin él: la caducidad es
|
|
1838
|
+
* ESTRICTA y con el reloj del driver, igual que en `authorize`. Un `user`
|
|
1839
|
+
* que no se entiende como holder se cuenta igual (a diferencia de
|
|
1840
|
+
* `listSubjects`, que lo descarta): aquí contar de MÁS es el lado seguro —
|
|
1841
|
+
* marca el rol para que un humano lo mire— y contar de menos es decir «no
|
|
1842
|
+
* concede» sobre algo que sí.
|
|
1843
|
+
*
|
|
1844
|
+
* Coste: por rol preguntado, una lectura de sus bindings más una por
|
|
1845
|
+
* binding. Lo llama `pruneOrphanRoles` con los HUÉRFANOS de la pasada (no
|
|
1846
|
+
* con el catálogo entero) y corre en un comando de plataforma, no en el
|
|
1847
|
+
* camino de una petición.
|
|
1848
|
+
*/
|
|
1849
|
+
async countRoleAssignments(roleUuids) {
|
|
1850
|
+
for (const uuid of roleUuids)
|
|
1851
|
+
assertCatalogUuid('rol', uuid);
|
|
1852
|
+
const counts = [];
|
|
1853
|
+
for (const roleUuid of roleUuids) {
|
|
1854
|
+
const edges = await this.readAllTuples({ user: `${FACTS_ROLE_TYPE}:${roleUuid}`, relation: FACTS_ROLE_RELATION, object: `${FACTS_BINDING_TYPE}:` }, { includeExpired: true });
|
|
1855
|
+
let total = 0;
|
|
1856
|
+
for (const binding of new Set(edges.map((edge) => edge.object))) {
|
|
1857
|
+
total += (await this.readAllTuples({ relation: FACTS_ASSIGNEE_RELATION, object: binding })).length;
|
|
1858
|
+
}
|
|
1859
|
+
counts.push(total);
|
|
1860
|
+
}
|
|
1861
|
+
return counts;
|
|
1862
|
+
}
|
|
1863
|
+
/**
|
|
1864
|
+
* **Rehace la proyección de UN rol** (3b-2e · E4). En (c2) lo que un rol
|
|
1865
|
+
* concede son tuplas (`role:<uuid>#permits_<P>@<holder>:*`), no el catálogo
|
|
1866
|
+
* local: una escritura de catálogo que no las toque deja un rol que no
|
|
1867
|
+
* concede nada (`defineScopedRole`) o que sigue concediendo lo que ya no
|
|
1868
|
+
* vincula (`updateScopedRole`) — lo segundo es un fail-open. `syncAuthzCatalog`
|
|
1869
|
+
* ya lo hace para el catálogo entero cuando el consumidor le pasa la
|
|
1870
|
+
* proyección; esto es lo mismo para las escrituras de la API de delegación,
|
|
1871
|
+
* y cuesta una lectura por holder.
|
|
1872
|
+
*
|
|
1873
|
+
* **Y son DOS proyecciones, no una** (3b-2g · R1): lo que el rol concede
|
|
1874
|
+
* (`permits_<P>`) y **dónde es visible** (`scope#binding`, `sweepRoleVisibility`).
|
|
1875
|
+
* El modelo (c2) no lleva el NIVEL del rol, así que un `scope_type` que
|
|
1876
|
+
* cambia sin barrer deja la asignación concediendo en un nivel que el
|
|
1877
|
+
* catálogo ya no declara — retirado en `database` y vivo aquí, que es la
|
|
1878
|
+
* divergencia R1 del lote 2e.
|
|
1879
|
+
*/
|
|
1880
|
+
async projectCatalogRole(roleUuid) {
|
|
1881
|
+
assertCatalogUuid('rol', roleUuid);
|
|
1882
|
+
await this.sweepRoleVisibility(roleUuid);
|
|
1883
|
+
const catalog = await this.catalog.view();
|
|
1884
|
+
const permissions = [...catalog.rolePermissionsOf(roleUuid)].sort();
|
|
1885
|
+
const object = `${FACTS_ROLE_TYPE}:${roleUuid}`;
|
|
1886
|
+
const wanted = new Map();
|
|
1887
|
+
for (const tuple of factsCatalogTuples([{ uuid: roleUuid, permissions }], this.holderTypes)) {
|
|
1888
|
+
wanted.set(factsTupleId(tuple), tuple);
|
|
1889
|
+
}
|
|
1890
|
+
const current = new Map();
|
|
1891
|
+
for (const holderType of Object.values(this.holderTypes)) {
|
|
1892
|
+
for (const key of await this.readAllTuples({ user: `${holderType}:*`, object }, { includeExpired: true })) {
|
|
1893
|
+
if (!key.relation.startsWith(FACTS_PERMITS_PREFIX))
|
|
1894
|
+
continue;
|
|
1895
|
+
current.set(factsTupleId(key), key);
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
const writes = [...wanted.values()].filter((tuple) => !current.has(factsTupleId(tuple)));
|
|
1899
|
+
const deletes = [...current.values()].filter((tuple) => !wanted.has(factsTupleId(tuple)));
|
|
1900
|
+
if (!writes.length && !deletes.length)
|
|
1901
|
+
return;
|
|
1902
|
+
await this.client.write({ writes, deletes });
|
|
1903
|
+
}
|
|
1904
|
+
/* ── `authz:reconcile --to=openfga` (3b-3a) ───────────────────────────── */
|
|
1905
|
+
/**
|
|
1906
|
+
* **Reconstruye el store desde `authz_*` + el árbol del consumidor.**
|
|
1907
|
+
*
|
|
1908
|
+
* Es la razón de ser de la fase («todo en un driver o todo en otro, con una
|
|
1909
|
+
* migración idempotente y bidireccional») y la ÚNICA primitiva de migración
|
|
1910
|
+
* del paquete: `openfga:import` se borró en 3b-2k · K2 porque escribía las
|
|
1911
|
+
* tuplas de un modelo que ya no existe.
|
|
1912
|
+
*
|
|
1913
|
+
* Migra las TRES cosas que hacen completo a este driver:
|
|
1914
|
+
* 1. el **marcador de raíz** (`scope:app#rooted@<holder>:*`, 3b-2i) — sin
|
|
1915
|
+
* él el store entero DENIEGA, así que va primero;
|
|
1916
|
+
* 2. la **proyección del catálogo** (`role:<uuid>#permits_<P>`), leída con
|
|
1917
|
+
* la MISMA función que usa `syncAuthzCatalog` (`readCatalogProjectionSnapshot`),
|
|
1918
|
+
* para que reconcile no "arregle" en cada pasada lo que el sync deja bien;
|
|
1919
|
+
* 3. el **árbol** (`scope#parent`) desde `scopes.enumerateEdges`, y
|
|
1920
|
+
* 4. los **hechos**: `authz_assignments` (assignee + las dos aristas de
|
|
1921
|
+
* (c2)) y `authz_denies` (`scope#denied_<P>`).
|
|
1922
|
+
*
|
|
1923
|
+
* **Qué borra y qué no.** Lo DERIVADO —marcador, catálogo y árbol— es un
|
|
1924
|
+
* espejo de datos locales que nadie más escribe: lo que sobra se borra
|
|
1925
|
+
* siempre (cruce 9 · S7 lo exige para las aristas que `enumerateEdges` no
|
|
1926
|
+
* respalda, y es lo que repara un nodo con DOS padres, 3b-2h · 🟠 4). Los
|
|
1927
|
+
* HECHOS solo se borran con `prune`: son irreversibles y su origen depende
|
|
1928
|
+
* de qué driver esté vivo. La excepción, a propósito, es la arista
|
|
1929
|
+
* `scope#binding` de una asignación que el origen SÍ respalda pero cuya
|
|
1930
|
+
* regla de visibilidad dice que NO (invariante 18): dejarla es fail-OPEN
|
|
1931
|
+
* —es justo la escritura que `scopes.moved`/`projectCatalogRole` pudieron
|
|
1932
|
+
* perder si el relay no pasó—, así que se borra siempre y se cuenta en
|
|
1933
|
+
* `drift.roleVisibility`.
|
|
1934
|
+
*
|
|
1935
|
+
* **Nada de `Ignore` a ciegas** (cruce 9 · S7): el importador viejo escribía
|
|
1936
|
+
* con `onDuplicateWrites: Ignore` y por eso una tupla que ya estaba con OTRA
|
|
1937
|
+
* caducidad se quedaba como estaba y encima se contaba como escrita —rompía
|
|
1938
|
+
* los invariantes 3 y 6—. Aquí el estado del destino se LEE entero antes de
|
|
1939
|
+
* decidir, la diferencia de caducidad se resuelve con delete + write, y los
|
|
1940
|
+
* contadores salen del diff, no del write. `Ignore` se conserva solo como
|
|
1941
|
+
* red contra una carrera (y por eso la migración va con `manager.freeze()`).
|
|
1942
|
+
*
|
|
1943
|
+
* `dryRun` es el VERIFICADOR: mismo recorrido, cero escrituras, mismos
|
|
1944
|
+
* números. Read-only por contrato (cruce 4 · S18): **un `--fix` está
|
|
1945
|
+
* prohibido** y no se implementa ni se deja preparado.
|
|
1946
|
+
*/
|
|
1947
|
+
async reconcile(source, options = {}) {
|
|
1948
|
+
const dryRun = options.dryRun === true;
|
|
1949
|
+
const prune = options.prune === true;
|
|
1950
|
+
const batchSize = reconcileBatchSize(options.batchSize);
|
|
1951
|
+
const maxTuples = reconcileMaxTuples(options.maxTuples);
|
|
1952
|
+
const catalog = await this.catalog.view();
|
|
1953
|
+
const now = this.now();
|
|
1954
|
+
/** Lo que el destino DEBE tener, por id de tupla. */
|
|
1955
|
+
const wanted = new Map();
|
|
1956
|
+
/** Aristas de visibilidad que NO pueden existir (invariante 18). */
|
|
1957
|
+
const forbidden = new Set();
|
|
1958
|
+
const skipped = {};
|
|
1959
|
+
const details = [];
|
|
1960
|
+
const note = (kind, reason, detail) => {
|
|
1961
|
+
skipped[reason] = (skipped[reason] ?? 0) + 1;
|
|
1962
|
+
if (details.length < RECONCILE_MAX_DETAILS)
|
|
1963
|
+
details.push({ kind, reason, detail });
|
|
1964
|
+
};
|
|
1965
|
+
const want = (tuple, family, validUntil = null) => {
|
|
1966
|
+
wanted.set(factsTupleId(tuple), { tuple, family, validUntil });
|
|
1967
|
+
};
|
|
1968
|
+
// 1. El marcador de raíz. Va el primero porque sin él `can_<P>` es `false`
|
|
1969
|
+
// en TODO el store (fail-closed total, medido en 3b-2i).
|
|
1970
|
+
for (const tuple of factsRootTuples(this.holderTypes))
|
|
1971
|
+
want(tuple, 'root');
|
|
1972
|
+
// 2. La proyección del catálogo, con la MISMA foto que el sync.
|
|
1973
|
+
const snapshot = await readCatalogProjectionSnapshot(db, (operation, fn) => this.sql(`reconcile.catalog.${operation}`, fn));
|
|
1974
|
+
// Lo que el MODELO publicado declara: un permiso que el catálogo tiene y
|
|
1975
|
+
// el modelo no (el store se quedó en una versión anterior) haría que el
|
|
1976
|
+
// `Write` de su `permits_<P>` fuese un 400 del servidor a mitad de la
|
|
1977
|
+
// migración. Se detecta con una lectura y se cuenta, en vez de reventar.
|
|
1978
|
+
const publishable = await this.modelPermissions();
|
|
1979
|
+
const usable = (permission) => publishable === null || publishable.has(factsRelationsOf(permission).permits);
|
|
1980
|
+
for (const role of snapshot.roles) {
|
|
1981
|
+
const permissions = role.permissions.filter((permission) => {
|
|
1982
|
+
if (usable(permission))
|
|
1983
|
+
return true;
|
|
1984
|
+
note('tuple', 'permission-not-in-model', `role:${role.uuid} permits_${permission}`);
|
|
1985
|
+
return false;
|
|
1986
|
+
});
|
|
1987
|
+
for (const tuple of factsCatalogTuples([{ uuid: role.uuid, permissions }], this.holderTypes)) {
|
|
1988
|
+
want(tuple, 'catalog');
|
|
1989
|
+
}
|
|
1990
|
+
}
|
|
1991
|
+
// 3. El ÁRBOL, paginado por cursor y con los ciclos DETECTADOS: FGA acepta
|
|
1992
|
+
// un ciclo de `parent` y lo evalúa —un grant en cualquier nodo concede
|
|
1993
|
+
// en la raíz (cruce 3, reproducido dos veces)—, así que ninguna arista
|
|
1994
|
+
// de un ciclo se escribe y el ciclo se reporta.
|
|
1995
|
+
const { parentOf, cycles } = await this.readSourceTree(source, batchSize, note);
|
|
1996
|
+
for (const [child, parent] of parentOf)
|
|
1997
|
+
want(factsParentTuple(child, parent), 'tree');
|
|
1998
|
+
/** `[key, ...ancestros]` según el árbol del ORIGEN, o `null` si no llega a la raíz. */
|
|
1999
|
+
const chains = new Map();
|
|
2000
|
+
const chainOf = (key) => {
|
|
2001
|
+
const memo = chains.get(key);
|
|
2002
|
+
if (memo !== undefined)
|
|
2003
|
+
return memo;
|
|
2004
|
+
const chain = [];
|
|
2005
|
+
let current = key;
|
|
2006
|
+
const seen = new Set();
|
|
2007
|
+
while (current !== undefined && !seen.has(current)) {
|
|
2008
|
+
chain.push(current);
|
|
2009
|
+
if (current === APP_SCOPE_TYPE) {
|
|
2010
|
+
chains.set(key, chain);
|
|
2011
|
+
return chain;
|
|
2012
|
+
}
|
|
2013
|
+
seen.add(current);
|
|
2014
|
+
current = parentOf.get(current);
|
|
2015
|
+
}
|
|
2016
|
+
chains.set(key, null);
|
|
2017
|
+
return null;
|
|
2018
|
+
};
|
|
2019
|
+
// 4. Los HECHOS, **de quien sea su fuente de verdad** (3b-5). `authz_*`
|
|
2020
|
+
// lo es en la MIGRACIÓN `database` → `openfga`, y por eso esa
|
|
2021
|
+
// dirección es la vía de salida de un store escrito por otra versión.
|
|
2022
|
+
// Pero cuando el motor ya SIRVE desde este driver los hechos son las
|
|
2023
|
+
// tuplas del store, no esas tablas: leerlas ahí resucitaba lo revocado
|
|
2024
|
+
// tras el cutover y dejaba el barrido de visibilidad sin aplicar (los
|
|
2025
|
+
// dos 🔴 del auditor final). Quién es el origen lo decide el manager y
|
|
2026
|
+
// llega en `source.factsOrigin`; aquí solo se obedece.
|
|
2027
|
+
const origin = source.factsOrigin ?? {
|
|
2028
|
+
name: AUTHZ_TABLES_ORIGIN,
|
|
2029
|
+
authzTables: true,
|
|
2030
|
+
};
|
|
2031
|
+
const factsCtx = { note, usable, chainOf, want, forbidden };
|
|
2032
|
+
const facts = origin.authzTables
|
|
2033
|
+
? await this.readSourceFacts(catalog, batchSize, now, factsCtx)
|
|
2034
|
+
: await this.readOriginFacts(source, batchSize, maxTuples, catalog, now, factsCtx);
|
|
2035
|
+
// 5. El destino ENTERO, tal como está. **La cota es DECLARADA** (3b-3b ·
|
|
2036
|
+
// B5): este volcado entra en memoria —el ORIGEN se lee por lotes con
|
|
2037
|
+
// cursor, el destino no—, así que pasar de `maxTuples` es un 500 con
|
|
2038
|
+
// nombre antes de escribir nada, no un OOM a mitad de migración.
|
|
2039
|
+
const current = await this.readEveryTuple(maxTuples);
|
|
2040
|
+
const phases = emptyPhases();
|
|
2041
|
+
const drift = {
|
|
2042
|
+
rootMarker: false,
|
|
2043
|
+
multiParent: [],
|
|
2044
|
+
roleVisibility: 0,
|
|
2045
|
+
pendingRelay: 0,
|
|
2046
|
+
deadRelay: 0,
|
|
2047
|
+
};
|
|
2048
|
+
const deletes = [];
|
|
2049
|
+
const writes = [];
|
|
2050
|
+
const seen = new Set();
|
|
2051
|
+
const parents = new Map();
|
|
2052
|
+
/** Bindings que el store YA conoce (tienen `assignee`): ver `drift.roleVisibility`. */
|
|
2053
|
+
const storeBindings = new Set();
|
|
2054
|
+
for (const tuple of current) {
|
|
2055
|
+
const id = factsTupleId(tuple);
|
|
2056
|
+
if (tuple.relation === FACTS_ASSIGNEE_RELATION)
|
|
2057
|
+
storeBindings.add(tuple.object);
|
|
2058
|
+
const family = familyOfTuple(tuple);
|
|
2059
|
+
if (tuple.relation === FACTS_PARENT_RELATION && tuple.object.startsWith(`${FACTS_SCOPE_TYPE}:`)) {
|
|
2060
|
+
parents.set(tuple.object, (parents.get(tuple.object) ?? 0) + 1);
|
|
2061
|
+
}
|
|
2062
|
+
const target = wanted.get(id);
|
|
2063
|
+
if (target) {
|
|
2064
|
+
seen.add(id);
|
|
2065
|
+
if (sameInstant(tuple.validUntil, target.validUntil)) {
|
|
2066
|
+
phases[target.family].unchanged += 1;
|
|
2067
|
+
continue;
|
|
2068
|
+
}
|
|
2069
|
+
// La caducidad NO es parte de la clave en FGA: cambiarla es borrar y
|
|
2070
|
+
// volver a escribir (por eso `Ignore` se queda la vieja — S7).
|
|
2071
|
+
phases[target.family].updated += 1;
|
|
2072
|
+
deletes.push(bareTuple(tuple));
|
|
2073
|
+
writes.push(reconcileWriteForm(target));
|
|
2074
|
+
continue;
|
|
2075
|
+
}
|
|
2076
|
+
phases[family].extra += 1;
|
|
2077
|
+
const visibilityDrift = forbidden.has(id);
|
|
2078
|
+
if (visibilityDrift)
|
|
2079
|
+
drift.roleVisibility += 1;
|
|
2080
|
+
// Lo derivado se rehace entero; los hechos solo con `--prune`.
|
|
2081
|
+
if (family === 'facts' && !prune && !visibilityDrift) {
|
|
2082
|
+
note('tuple', 'extra-fact', id);
|
|
2083
|
+
continue;
|
|
2084
|
+
}
|
|
2085
|
+
phases[family].deleted += 1;
|
|
2086
|
+
deletes.push(bareTuple(tuple));
|
|
2087
|
+
}
|
|
2088
|
+
for (const [id, target] of wanted) {
|
|
2089
|
+
if (seen.has(id))
|
|
2090
|
+
continue;
|
|
2091
|
+
if (target.family === 'root')
|
|
2092
|
+
drift.rootMarker = true;
|
|
2093
|
+
// Una arista de visibilidad que falta con el binding YA en el store es
|
|
2094
|
+
// la escritura del invariante 18 que el relay pudo perder; en una
|
|
2095
|
+
// migración a un store vacío no falta nada, sobra todo.
|
|
2096
|
+
if (target.tuple.relation === FACTS_BINDING_RELATION && storeBindings.has(target.tuple.user)) {
|
|
2097
|
+
drift.roleVisibility += 1;
|
|
2098
|
+
}
|
|
2099
|
+
phases[target.family].written += 1;
|
|
2100
|
+
writes.push(reconcileWriteForm(target));
|
|
2101
|
+
}
|
|
2102
|
+
for (const [object, count] of parents) {
|
|
2103
|
+
if (count > 1)
|
|
2104
|
+
drift.multiParent.push(object);
|
|
2105
|
+
}
|
|
2106
|
+
drift.multiParent.sort();
|
|
2107
|
+
// El seguro contra el origen ciego (AA2 aplicado a la migración): borrar
|
|
2108
|
+
// hechos con `authz_*` VACÍO es casi siempre apuntar a la base equivocada
|
|
2109
|
+
// o estar mirando el driver que NO escribe ahí.
|
|
2110
|
+
const factsDeleted = phases.facts.deleted;
|
|
2111
|
+
// 3b-8 · B1: sobre los hechos UTILIZABLES, no el conteo crudo — un origen
|
|
2112
|
+
// que devuelve N filas y las N se descartan (caducadas, scopes que ya no
|
|
2113
|
+
// resuelven) sigue sin respaldar NADA de lo que --prune va a borrar.
|
|
2114
|
+
const massDelete = prune && factsDeleted > 0 && facts.usable === 0;
|
|
2115
|
+
if (massDelete && !dryRun && options.allowMassDelete !== true) {
|
|
2116
|
+
throw new MassReconcileRefusedError(`authz:reconcile --to=openfga --prune borraría ${factsDeleted} tupla(s) de hechos del store y el ` +
|
|
2117
|
+
`ORIGEN no ha aportado NI UN hecho utilizable (${facts.rows} leídos, todos descartados — mira ` +
|
|
2118
|
+
`'skipped'). Eso es la firma de una base equivocada, de un árbol que ya no resuelve esos scopes, ` +
|
|
2119
|
+
`o de que los hechos los está escribiendo el driver 'openfga' (que los guarda en el store, no en ` +
|
|
2120
|
+
`'authz_assignments'/'authz_denies'): esta pasada dejaría el store sin nada concedido y no hay desde ` +
|
|
2121
|
+
`dónde reconstruirlo. Comprueba la conexión y el driver activo; si de verdad quieres vaciarlo, ` +
|
|
2122
|
+
`--allow-mass-delete.`);
|
|
2123
|
+
}
|
|
2124
|
+
if (!dryRun) {
|
|
2125
|
+
// Los DELETES primero: si la pasada muere entre medias, lo que queda es
|
|
2126
|
+
// de MENOS (fail-closed), nunca una tupla vieja conviviendo con la nueva.
|
|
2127
|
+
await this.applyReconcileWrites(deletes, []);
|
|
2128
|
+
await this.applyReconcileWrites([], writes);
|
|
2129
|
+
}
|
|
2130
|
+
const totals = sumPhases(phases);
|
|
2131
|
+
return {
|
|
2132
|
+
to: 'openfga',
|
|
2133
|
+
factsFrom: origin.name,
|
|
2134
|
+
dryRun,
|
|
2135
|
+
prune,
|
|
2136
|
+
...totals,
|
|
2137
|
+
phases,
|
|
2138
|
+
skipped,
|
|
2139
|
+
details,
|
|
2140
|
+
cycles,
|
|
2141
|
+
drift,
|
|
2142
|
+
massDelete,
|
|
2143
|
+
};
|
|
2144
|
+
}
|
|
2145
|
+
/**
|
|
2146
|
+
* Las relaciones `permits_<P>` que DECLARA el modelo publicado del store, o
|
|
2147
|
+
* `null` si no se puede saber (un store sin modelo). No es una barrera de
|
|
2148
|
+
* seguridad: es la diferencia entre contar un permiso que este store no
|
|
2149
|
+
* puede llevar y morirse con un 400 a mitad de la migración.
|
|
2150
|
+
*/
|
|
2151
|
+
async modelPermissions() {
|
|
2152
|
+
const response = await this.client.readLatestAuthorizationModel();
|
|
2153
|
+
const model = response?.authorization_model;
|
|
2154
|
+
if (!model?.type_definitions)
|
|
2155
|
+
return null;
|
|
2156
|
+
const role = model.type_definitions.find((definition) => definition.type === FACTS_ROLE_TYPE);
|
|
2157
|
+
if (!role?.relations)
|
|
2158
|
+
return null;
|
|
2159
|
+
return new Set(Object.keys(role.relations).filter((name) => name.startsWith(FACTS_PERMITS_PREFIX)));
|
|
2160
|
+
}
|
|
2161
|
+
/**
|
|
2162
|
+
* El árbol del ORIGEN, paginado (`scopes.enumerateEdges`), con los ciclos
|
|
2163
|
+
* apartados. Un ciclo no se escribe NUNCA: FGA lo evalúa y la herencia pasa
|
|
2164
|
+
* a ser bidireccional (un grant en un descendiente concede en el ancestro,
|
|
2165
|
+
* cruce 3), así que aquí sale como reporte y sus nodos se quedan sin arista
|
|
2166
|
+
* —o sea, sin `rooted`, o sea denegando (fail-closed)—.
|
|
2167
|
+
*/
|
|
2168
|
+
async readSourceTree(source, batchSize, note) {
|
|
2169
|
+
if (typeof source.enumerateEdges !== 'function') {
|
|
2170
|
+
throw new AuthorizationConfigError("authz:reconcile necesita 'scopes.enumerateEdges' en config/authorization.ts: sin el árbol del consumidor " +
|
|
2171
|
+
'no se puede reconstruir el del store (y suponerlo plano sería inventar una jerarquía).');
|
|
2172
|
+
}
|
|
2173
|
+
const parentOf = new Map();
|
|
2174
|
+
const cycles = [];
|
|
2175
|
+
const cycleNodes = new Set();
|
|
2176
|
+
const seenCursors = new Set();
|
|
2177
|
+
let after;
|
|
2178
|
+
for (let page = 0;; page++) {
|
|
2179
|
+
const result = await source.enumerateEdges({ limit: batchSize, after });
|
|
2180
|
+
if (!result || !Array.isArray(result.edges)) {
|
|
2181
|
+
throw new AuthorizationConfigError(`scopes.enumerateEdges devolvió ${typeof result} en vez de { edges, cursor? }`);
|
|
2182
|
+
}
|
|
2183
|
+
if (result.edges.length > batchSize) {
|
|
2184
|
+
throw new AuthorizationConfigError(`scopes.enumerateEdges devolvió ${result.edges.length} aristas con limit=${batchSize}: ` +
|
|
2185
|
+
`no se puede paginar lo que no cabe en el lote.`);
|
|
2186
|
+
}
|
|
2187
|
+
for (const edge of result.edges) {
|
|
2188
|
+
if (!edge || !isValidScope(edge.child) || !isValidScope(edge.parent)) {
|
|
2189
|
+
note('edge', 'invalid-scope', JSON.stringify(edge ?? null));
|
|
2190
|
+
continue;
|
|
2191
|
+
}
|
|
2192
|
+
if (edge.child.type === APP_SCOPE_TYPE) {
|
|
2193
|
+
note('edge', 'root-child', `la raíz 'app' no cuelga de nada`);
|
|
2194
|
+
continue;
|
|
2195
|
+
}
|
|
2196
|
+
const child = scopeKey(edge.child);
|
|
2197
|
+
const parent = scopeKey(edge.parent);
|
|
2198
|
+
const previous = parentOf.get(child);
|
|
2199
|
+
if (previous !== undefined) {
|
|
2200
|
+
note('edge', 'two-parents-in-source', `${child} → ${previous} y ${parent}`);
|
|
2201
|
+
continue;
|
|
2202
|
+
}
|
|
2203
|
+
const cycle = walkUpTo(parentOf, parent, child);
|
|
2204
|
+
if (cycle) {
|
|
2205
|
+
// `cycle` ya viene con TODOS los nodos del ciclo (`[padre … hijo]`).
|
|
2206
|
+
cycles.push(cycle);
|
|
2207
|
+
for (const node of cycle)
|
|
2208
|
+
cycleNodes.add(node);
|
|
2209
|
+
note('edge', 'cycle', `${child} → ${parent} cierra un ciclo`);
|
|
2210
|
+
continue;
|
|
2211
|
+
}
|
|
2212
|
+
parentOf.set(child, parent);
|
|
2213
|
+
}
|
|
2214
|
+
if (result.cursor === undefined || result.cursor === null)
|
|
2215
|
+
break;
|
|
2216
|
+
if (seenCursors.has(result.cursor)) {
|
|
2217
|
+
throw new AuthorizationConfigError(`scopes.enumerateEdges: el cursor '${result.cursor}' se repite (página ${page + 1}); no avanza.`);
|
|
2218
|
+
}
|
|
2219
|
+
seenCursors.add(result.cursor);
|
|
2220
|
+
after = result.cursor;
|
|
2221
|
+
if (page >= RECONCILE_MAX_PAGES) {
|
|
2222
|
+
throw new AuthorizationInternalError(`scopes.enumerateEdges: más de ${RECONCILE_MAX_PAGES} páginas sin agotar el árbol.`);
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
// Un ciclo deja a TODOS sus nodos sin arista, no solo al que la cerró: si
|
|
2226
|
+
// no, cuál se queda fuera dependería del orden de enumeración.
|
|
2227
|
+
for (const node of cycleNodes) {
|
|
2228
|
+
if (parentOf.delete(node))
|
|
2229
|
+
note('edge', 'cycle', `${node} pertenece a un ciclo: su arista no se escribe`);
|
|
2230
|
+
}
|
|
2231
|
+
return { parentOf, cycles };
|
|
2232
|
+
}
|
|
2233
|
+
/**
|
|
2234
|
+
* Los HECHOS del origen (`authz_assignments` y `authz_denies`), leídos **por
|
|
2235
|
+
* lotes con cursor** sobre la clave primaria: una pasada interrumpida se
|
|
2236
|
+
* repite y converge (es idempotente), y una base grande no entra entera en
|
|
2237
|
+
* memoria de golpe. Los DOS barridos van en la MISMA transacción de lectura
|
|
2238
|
+
* repetible (3b-6, `withSourceSnapshot`): sueltos, componían dos mitades de
|
|
2239
|
+
* dos operaciones distintas y FABRICABAN un permiso.
|
|
2240
|
+
*
|
|
2241
|
+
* Cada fila que NO se migra sale contada y con su motivo:
|
|
2242
|
+
* - `unknown-scope`: el árbol del consumidor ya no resuelve ese scope
|
|
2243
|
+
* (`detached` de un ancestro). Sus tuplas del store son las de la
|
|
2244
|
+
* «resurrección» (3b-0b · AA4) y las borra `--prune`.
|
|
2245
|
+
* - `unknown-role` / `unknown-permission`: el catálogo ya no lo declara
|
|
2246
|
+
* (un rol retirado; invariante 11 dice que los recoge este comando).
|
|
2247
|
+
* - `expired`: la asignación ya no concede; migrarla sería escribir una
|
|
2248
|
+
* caducidad pasada.
|
|
2249
|
+
* - `role-not-visible`: la asignación existe (y `listRoles`/`hasRole` la
|
|
2250
|
+
* enumeran), pero el rol NO es visible en ese scope con el árbol y el
|
|
2251
|
+
* catálogo de HOY (invariante 18), así que su arista `scope#binding` no
|
|
2252
|
+
* se escribe — y si el store la tiene, se borra.
|
|
2253
|
+
* - `unknown-holder-type`: el `holderTypes` del config no declara ese
|
|
2254
|
+
* morph name, así que no hay usuario FGA que escribir.
|
|
2255
|
+
*/
|
|
2256
|
+
async readSourceFacts(catalog, batchSize, now, ctx) {
|
|
2257
|
+
const expiry = sqlExpiryCodec(db.connection());
|
|
2258
|
+
let rows = 0;
|
|
2259
|
+
// Hechos que RESPALDAN algo en el destino (3b-8 · B1): el seguro de
|
|
2260
|
+
// borrado masivo mira esto, no el conteo crudo.
|
|
2261
|
+
let usable = 0;
|
|
2262
|
+
// Los DOS barridos, en UNA sola foto (3b-6). Ver `withSourceSnapshot`.
|
|
2263
|
+
return this.withSourceSnapshot(async (client) => {
|
|
2264
|
+
await this.eachRow(client, 'reconcile.assignments', batchSize, (query) => query
|
|
2265
|
+
.from('authz_assignments')
|
|
2266
|
+
.select('uuid', 'holder_type', 'holder_uuid', 'role_uuid', 'scope_type', 'scope_uuid')
|
|
2267
|
+
.select(expiry.select('expires_at')), (row) => {
|
|
2268
|
+
rows += 1;
|
|
2269
|
+
if (this.wantAssignment(catalog, now, ctx, {
|
|
2270
|
+
holder: { type: row.holder_type, uuid: String(row.holder_uuid) },
|
|
2271
|
+
// `scope_uuid` es NOT NULL y la RAÍZ va con el centinela (K1/L0.10):
|
|
2272
|
+
// un `?? null` nunca es null y `scopeKey` rechazaba `app` con uuid,
|
|
2273
|
+
// así que un grant en la raíz reventaba la migración entera (3b-3b).
|
|
2274
|
+
scope: { type: row.scope_type, uuid: fromDbScopeUuid(String(row.scope_uuid)) },
|
|
2275
|
+
roleUuid: String(row.role_uuid),
|
|
2276
|
+
expiresAt: expiry.fromDb(row.expires_at),
|
|
2277
|
+
detail: `${row.holder_type}:${row.holder_uuid} → ${row.role_uuid} @ ${row.scope_type}:${row.scope_uuid ?? ''}`,
|
|
2278
|
+
})) {
|
|
2279
|
+
usable += 1;
|
|
2280
|
+
}
|
|
2281
|
+
});
|
|
2282
|
+
await this.eachRow(client, 'reconcile.denies', batchSize, (query) => query.from('authz_denies').select('uuid', 'holder_type', 'holder_uuid', 'permission_uuid', 'scope_type', 'scope_uuid'), (row) => {
|
|
2283
|
+
rows += 1;
|
|
2284
|
+
const label = `${row.holder_type}:${row.holder_uuid} ⊘ ${row.permission_uuid} @ ${row.scope_type}:${row.scope_uuid ?? ''}`;
|
|
2285
|
+
// En `authz_denies` el permiso es un uuid; por el puerto es un slug
|
|
2286
|
+
// (`ReconcileFact.permission`), que es lo que el catálogo local sabe
|
|
2287
|
+
// traducir. Aquí se traduce una vez y el resto es común.
|
|
2288
|
+
const permission = catalog.permissionSlug(String(row.permission_uuid));
|
|
2289
|
+
if (!permission) {
|
|
2290
|
+
ctx.note('deny', 'unknown-permission', label);
|
|
2291
|
+
return;
|
|
2292
|
+
}
|
|
2293
|
+
if (this.wantDeny(ctx, {
|
|
2294
|
+
holder: { type: row.holder_type, uuid: String(row.holder_uuid) },
|
|
2295
|
+
scope: { type: row.scope_type, uuid: fromDbScopeUuid(String(row.scope_uuid)) },
|
|
2296
|
+
permission,
|
|
2297
|
+
detail: label,
|
|
2298
|
+
})) {
|
|
2299
|
+
usable += 1;
|
|
2300
|
+
}
|
|
2301
|
+
});
|
|
2302
|
+
return { rows, usable };
|
|
2303
|
+
});
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* **La foto CONSISTENTE del origen** (3b-6; 🔴 3 del panel 3, verificado en
|
|
2307
|
+
* el código por el juez).
|
|
2308
|
+
*
|
|
2309
|
+
* `readSourceFacts` recorre `authz_assignments` y DESPUÉS `authz_denies`.
|
|
2310
|
+
* Cada barrido se construía sobre la conexión global, así que entre los dos
|
|
2311
|
+
* había un hueco —el tiempo de pasear la primera tabla entera por lotes de
|
|
2312
|
+
* 100: segundos o minutos en una base real— por el que se colaban
|
|
2313
|
+
* operaciones de negocio COMPUESTAS. Un offboarding es `revoke` +
|
|
2314
|
+
* `removeDeny`: si cae ahí, la pasada se queda con la mitad de cada una y
|
|
2315
|
+
* escribe en el destino **el rol sin su deny**, o sea un permiso que NI el
|
|
2316
|
+
* estado anterior NI el posterior concedían, con el reporte diciendo
|
|
2317
|
+
* `written=13 extra=0 skipped={} clean=true`. No es una pérdida: es una
|
|
2318
|
+
* ESCALADA fabricada, y el operador no tiene ni un motivo para desconfiar
|
|
2319
|
+
* del verde.
|
|
2320
|
+
*
|
|
2321
|
+
* Con los dos barridos dentro de UNA transacción de lectura repetible, el
|
|
2322
|
+
* peor resultado de la ventana deja de ser «un estado que nunca existió» y
|
|
2323
|
+
* pasa a ser «el estado consistente de `t0`»: la deriva recuperable que
|
|
2324
|
+
* repite la pasada siguiente.
|
|
2325
|
+
*
|
|
2326
|
+
* **Qué garantiza cada motor, porque no es lo mismo** (Fase 2.5 dixit):
|
|
2327
|
+
* - **PostgreSQL**: `BEGIN TRANSACTION ISOLATION LEVEL REPEATABLE READ`.
|
|
2328
|
+
* La foto se toma en la primera sentencia de la transacción y no se
|
|
2329
|
+
* mueve; garantía del motor.
|
|
2330
|
+
* - **MySQL/InnoDB**: `SET TRANSACTION ISOLATION LEVEL REPEATABLE READ` +
|
|
2331
|
+
* `BEGIN`, y la lectura consistente queda fijada en la primera consulta.
|
|
2332
|
+
* REPEATABLE READ es su default, pero se DECLARA a propósito: el default
|
|
2333
|
+
* es config del servidor y no una promesa del paquete.
|
|
2334
|
+
* - **SQLite**: no acepta nivel de aislamiento —knex avisa y lo ignora—,
|
|
2335
|
+
* así que no se le pide: su transacción de LECTURA ya es una foto (en
|
|
2336
|
+
* WAL el lector conserva su snapshot mientras un escritor confirma; sin
|
|
2337
|
+
* WAL el escritor espera al lector). Por eso aquí se pregunta el
|
|
2338
|
+
* dialecto en vez de mandar el nivel a ciegas.
|
|
2339
|
+
*
|
|
2340
|
+
* **Lo que esto NO cubre y queda declarado**: cubre la dirección cuyo
|
|
2341
|
+
* origen es `authz_*` (`--to=openfga`). Cuando la fuente de verdad de los
|
|
2342
|
+
* hechos es el STORE (`readOriginFacts` → `enumerateFacts`: `--to=database`
|
|
2343
|
+
* y la pasada de mantenimiento) las páginas de `Read` **tampoco son una
|
|
2344
|
+
* foto consistente** y no hay REPEATABLE READ que valga: ahí la misma
|
|
2345
|
+
* composición de media transacción sigue siendo posible. La instrumentación
|
|
2346
|
+
* posible en esa dirección es `readChanges({ startTime })` —detectar y
|
|
2347
|
+
* nombrar la tupla, no prevenirla—, y no está hecha.
|
|
2348
|
+
*/
|
|
2349
|
+
async withSourceSnapshot(fn) {
|
|
2350
|
+
const options = isSqliteDialect(db.connection())
|
|
2351
|
+
? undefined
|
|
2352
|
+
: { isolationLevel: 'repeatable read' };
|
|
2353
|
+
// Lo que lance `fn` ya viene clasificado (cada consulta pasa por
|
|
2354
|
+
// `this.sql`); lo que falle al ABRIR o cerrar la transacción es la base y
|
|
2355
|
+
// se clasifica aquí, que un error crudo de knex no cruza la frontera.
|
|
2356
|
+
let inner = null;
|
|
2357
|
+
try {
|
|
2358
|
+
return await db.transaction(async (trx) => {
|
|
2359
|
+
try {
|
|
2360
|
+
return await fn(trx);
|
|
2361
|
+
}
|
|
2362
|
+
catch (error) {
|
|
2363
|
+
inner = { error };
|
|
2364
|
+
throw error;
|
|
2365
|
+
}
|
|
2366
|
+
}, options);
|
|
2367
|
+
}
|
|
2368
|
+
catch (error) {
|
|
2369
|
+
if (inner !== null && inner.error === error)
|
|
2370
|
+
throw error;
|
|
2371
|
+
if (isAuthzError(error))
|
|
2372
|
+
throw error;
|
|
2373
|
+
if (isTimeoutLike(error)) {
|
|
2374
|
+
throw new AuthorizationBackendTimeoutError('openfga', 'reconcile.snapshot', this.timeoutMs, error);
|
|
2375
|
+
}
|
|
2376
|
+
throw new AuthorizationBackendError('openfga', 'reconcile.snapshot', error);
|
|
2377
|
+
}
|
|
2378
|
+
}
|
|
2379
|
+
/**
|
|
2380
|
+
* **Los hechos por el PUERTO** (3b-5): la otra fuente de verdad posible.
|
|
2381
|
+
*
|
|
2382
|
+
* Se usa cuando `authz_*` NO manda —el caso que faltaba: el motor ya sirve
|
|
2383
|
+
* desde este driver y sus hechos son las tuplas del store—, y también sirve
|
|
2384
|
+
* para migrar desde otro driver que sepa `enumerateFacts`. El recorrido y
|
|
2385
|
+
* los motivos son **los mismos** que los de `readSourceFacts` (`expired`,
|
|
2386
|
+
* `unknown-scope`, `unknown-role`, `unknown-holder-type`,
|
|
2387
|
+
* `role-not-visible`): lo único que cambia es de dónde salen las filas, que
|
|
2388
|
+
* es justo la decisión que no se estaba tomando.
|
|
2389
|
+
*
|
|
2390
|
+
* Consecuencia buscada: con el store como origen, `wanted` describe lo que
|
|
2391
|
+
* el store YA tiene, así que la pasada no escribe ni borra un solo hecho —y
|
|
2392
|
+
* sí rehace lo DERIVADO (marcador, catálogo, árbol) y aplica el barrido de
|
|
2393
|
+
* visibilidad del invariante 18 con el árbol y el catálogo de HOY, que es
|
|
2394
|
+
* la reparación que el invariante promete y no existía.
|
|
2395
|
+
*
|
|
2396
|
+
* Disciplina del cursor idéntica a la de `--to=database`: como mucho
|
|
2397
|
+
* `limit` por página, el cursor tiene que AVANZAR y la cota `maxTuples` se
|
|
2398
|
+
* aplica también al origen.
|
|
2399
|
+
*/
|
|
2400
|
+
async readOriginFacts(source, batchSize, maxTuples, catalog, now, ctx) {
|
|
2401
|
+
const enumerate = source.facts;
|
|
2402
|
+
if (typeof enumerate !== 'function') {
|
|
2403
|
+
throw new UnsupportedOperationError('enumerateFacts', 'authz:reconcile --to=openfga', 'origen', `El ORIGEN de esta pasada no son 'authz_assignments'/'authz_denies', así que tiene que saber ` +
|
|
2404
|
+
`enumerar sus hechos: sin eso la pasada leería cero hechos y con --prune vaciaría el store.`);
|
|
2405
|
+
}
|
|
2406
|
+
let rows = 0;
|
|
2407
|
+
let usable = 0;
|
|
2408
|
+
let after;
|
|
2409
|
+
const seenCursors = new Set();
|
|
2410
|
+
for (let page = 0;; page++) {
|
|
2411
|
+
const got = await enumerate({ limit: batchSize, after });
|
|
2412
|
+
if (got.facts.length > batchSize) {
|
|
2413
|
+
throw new AuthorizationInternalError(`authz:reconcile: el origen devolvió ${got.facts.length} hechos con limit=${batchSize}`);
|
|
2414
|
+
}
|
|
2415
|
+
for (const skip of got.skipped ?? [])
|
|
2416
|
+
ctx.note(skip.kind, skip.reason, skip.detail);
|
|
2417
|
+
for (const fact of got.facts) {
|
|
2418
|
+
rows += 1;
|
|
2419
|
+
if (rows > maxTuples) {
|
|
2420
|
+
throw new ReconcileTooLargeError(`authz:reconcile --to=openfga: el ORIGEN pasa de maxTuples (${maxTuples}) hechos y la pasada ` +
|
|
2421
|
+
`necesita compararlos contra el estado ENTERO del destino, que entra en memoria. Sube ` +
|
|
2422
|
+
`maxTuples si tu proceso lo aguanta; no hay migración por particiones en esta versión.`);
|
|
2423
|
+
}
|
|
2424
|
+
if (fact.kind === 'assignment') {
|
|
2425
|
+
if (this.wantAssignment(catalog, now, ctx, {
|
|
2426
|
+
holder: fact.holder,
|
|
2427
|
+
scope: fact.scope,
|
|
2428
|
+
roleUuid: String(fact.roleUuid ?? ''),
|
|
2429
|
+
expiresAt: fact.expiresAt ?? null,
|
|
2430
|
+
detail: fact.detail,
|
|
2431
|
+
})) {
|
|
2432
|
+
usable += 1;
|
|
2433
|
+
}
|
|
2434
|
+
continue;
|
|
2435
|
+
}
|
|
2436
|
+
if (!fact.permission) {
|
|
2437
|
+
ctx.note('deny', 'unknown-permission', fact.detail);
|
|
2438
|
+
continue;
|
|
2439
|
+
}
|
|
2440
|
+
if (this.wantDeny(ctx, {
|
|
2441
|
+
holder: fact.holder,
|
|
2442
|
+
scope: fact.scope,
|
|
2443
|
+
permission: fact.permission,
|
|
2444
|
+
detail: fact.detail,
|
|
2445
|
+
})) {
|
|
2446
|
+
usable += 1;
|
|
2447
|
+
}
|
|
2448
|
+
}
|
|
2449
|
+
const cursor = got.cursor;
|
|
2450
|
+
if (!cursor)
|
|
2451
|
+
break;
|
|
2452
|
+
if (seenCursors.has(cursor)) {
|
|
2453
|
+
throw new AuthorizationInternalError(`authz:reconcile: el cursor del origen se repite (página ${page + 1}); no avanza`);
|
|
2454
|
+
}
|
|
2455
|
+
seenCursors.add(cursor);
|
|
2456
|
+
after = cursor;
|
|
2457
|
+
}
|
|
2458
|
+
return { rows, usable };
|
|
2459
|
+
}
|
|
2460
|
+
/**
|
|
2461
|
+
* Una ASIGNACIÓN del origen (venga de `authz_*` o del puerto) traducida a
|
|
2462
|
+
* lo que el store debe tener: el `assignee` con su caducidad, la arista
|
|
2463
|
+
* `role_binding#role` y —solo si el rol es VISIBLE ahí— la `scope#binding`.
|
|
2464
|
+
* Es la única implementación de esa regla en esta dirección: tenerla dos
|
|
2465
|
+
* veces era tenerla distinta según de dónde salieran los hechos.
|
|
2466
|
+
*/
|
|
2467
|
+
wantAssignment(catalog, now, ctx, fact) {
|
|
2468
|
+
if (fact.expiresAt !== null && fact.expiresAt <= now) {
|
|
2469
|
+
ctx.note('assignment', 'expired', fact.detail);
|
|
2470
|
+
return false;
|
|
2471
|
+
}
|
|
2472
|
+
const key = scopeKey(fact.scope);
|
|
2473
|
+
const chain = ctx.chainOf(key);
|
|
2474
|
+
if (chain === null) {
|
|
2475
|
+
ctx.note('assignment', 'unknown-scope', fact.detail);
|
|
2476
|
+
return false;
|
|
2477
|
+
}
|
|
2478
|
+
const role = catalog.roleByUuid(fact.roleUuid);
|
|
2479
|
+
if (!role) {
|
|
2480
|
+
ctx.note('assignment', 'unknown-role', fact.detail);
|
|
2481
|
+
return false;
|
|
2482
|
+
}
|
|
2483
|
+
let user;
|
|
2484
|
+
try {
|
|
2485
|
+
user = this.fgaSubject(fact.holder);
|
|
2486
|
+
}
|
|
2487
|
+
catch {
|
|
2488
|
+
ctx.note('assignment', 'unknown-holder-type', fact.detail);
|
|
2489
|
+
return false;
|
|
2490
|
+
}
|
|
2491
|
+
const object = factsBindingObject(key, role.uuid);
|
|
2492
|
+
ctx.want({ user, relation: FACTS_ASSIGNEE_RELATION, object }, 'facts', fact.expiresAt);
|
|
2493
|
+
ctx.want({ user: `${FACTS_ROLE_TYPE}:${role.uuid}`, relation: FACTS_ROLE_RELATION, object }, 'facts');
|
|
2494
|
+
// La arista `scope#binding` significa «el rol es VISIBLE aquí»
|
|
2495
|
+
// (3b-2g · R1), y la regla es la misma que evalúa `database` en cada
|
|
2496
|
+
// pregunta: nivel declarado + owner en la cadena.
|
|
2497
|
+
const edge = factsScopeBindingTuple(key, role.uuid);
|
|
2498
|
+
if (declaredRoleAt(catalog, role.uuid, fact.scope.type, chain)) {
|
|
2499
|
+
ctx.want(edge, 'facts');
|
|
2500
|
+
}
|
|
2501
|
+
else {
|
|
2502
|
+
ctx.forbidden.add(factsTupleId(edge));
|
|
2503
|
+
ctx.note('assignment', 'role-not-visible', fact.detail);
|
|
2504
|
+
}
|
|
2505
|
+
return true;
|
|
2506
|
+
}
|
|
2507
|
+
/** Un DENY del origen (permiso ya como slug) traducido a `scope#denied_<P>`. */
|
|
2508
|
+
wantDeny(ctx, fact) {
|
|
2509
|
+
if (!ctx.usable(fact.permission)) {
|
|
2510
|
+
ctx.note('deny', 'permission-not-in-model', fact.detail);
|
|
2511
|
+
return false;
|
|
2512
|
+
}
|
|
2513
|
+
const key = scopeKey(fact.scope);
|
|
2514
|
+
if (ctx.chainOf(key) === null) {
|
|
2515
|
+
ctx.note('deny', 'unknown-scope', fact.detail);
|
|
2516
|
+
return false;
|
|
2517
|
+
}
|
|
2518
|
+
let user;
|
|
2519
|
+
try {
|
|
2520
|
+
user = this.fgaSubject(fact.holder);
|
|
2521
|
+
}
|
|
2522
|
+
catch {
|
|
2523
|
+
ctx.note('deny', 'unknown-holder-type', fact.detail);
|
|
2524
|
+
return false;
|
|
2525
|
+
}
|
|
2526
|
+
ctx.want(factsDenyTuple(key, fact.permission, user), 'facts');
|
|
2527
|
+
return true;
|
|
2528
|
+
}
|
|
2529
|
+
/**
|
|
2530
|
+
* Pasea una tabla `authz_*` por lotes de `batchSize` con cursor sobre
|
|
2531
|
+
* `uuid` (clave primaria: orden total y estable). Es lo que hace la pasada
|
|
2532
|
+
* REANUDABLE y lo que impide que una base grande entre entera de golpe.
|
|
2533
|
+
*
|
|
2534
|
+
* **El cliente llega por parámetro** (3b-6): antes cada página se construía
|
|
2535
|
+
* sobre el `db` global, así que dos barridos consecutivos eran dos fotos
|
|
2536
|
+
* distintas. Quien decide la foto es `withSourceSnapshot`, y aquí solo se
|
|
2537
|
+
* obedece — un call-site nuevo que pase `db` vuelve a abrir el hueco, y por
|
|
2538
|
+
* eso el parámetro es obligatorio y no tiene default.
|
|
2539
|
+
*/
|
|
2540
|
+
async eachRow(client, operation, batchSize, build, handle) {
|
|
2541
|
+
let after;
|
|
2542
|
+
for (;;) {
|
|
2543
|
+
const rows = await this.sql(operation, () => {
|
|
2544
|
+
const query = build(client);
|
|
2545
|
+
if (after !== undefined)
|
|
2546
|
+
query.where('uuid', '>', after);
|
|
2547
|
+
return query.orderBy('uuid', 'asc').limit(batchSize);
|
|
2548
|
+
});
|
|
2549
|
+
for (const row of rows)
|
|
2550
|
+
handle(row);
|
|
2551
|
+
if (rows.length < batchSize)
|
|
2552
|
+
return;
|
|
2553
|
+
after = String(rows[rows.length - 1].uuid);
|
|
2554
|
+
}
|
|
2555
|
+
}
|
|
2556
|
+
/**
|
|
2557
|
+
* **Los hechos de este store, paginados** (3b-3b): la mitad ORIGEN de la
|
|
2558
|
+
* migración, la que hace posible `authz:reconcile --to=database`.
|
|
2559
|
+
*
|
|
2560
|
+
* Se lee con `Read` y su `continuation_token`, no con `ListObjects`: el
|
|
2561
|
+
* plan lo dice con esas palabras y el motivo es que `ListObjects` **no
|
|
2562
|
+
* tiene `continuation_token`** —corta al tope del servidor sin señal
|
|
2563
|
+
* (S16)—, así que no hay forma de pasear un store entero con él. `Read`
|
|
2564
|
+
* sin filtro es además lo único que ve la basura de una versión anterior,
|
|
2565
|
+
* que es lo que sale en `skipped`.
|
|
2566
|
+
*
|
|
2567
|
+
* **Nada se filtra**: una asignación caducada sale con su `expiresAt` para
|
|
2568
|
+
* que el DESTINO la cuente con su motivo. Si se filtrara aquí desaparecería
|
|
2569
|
+
* sin dejar rastro en ningún contador, que es exactamente lo que una
|
|
2570
|
+
* migración no puede hacer. Por lo mismo NO interviene el reloj del driver.
|
|
2571
|
+
*
|
|
2572
|
+
* Lo que no es un hecho —la estructura de (c2) (`parent`, `binding`,
|
|
2573
|
+
* `role`, `rooted`) y la proyección del catálogo (`permits_<P>`)— no se
|
|
2574
|
+
* emite ni se cuenta: es DERIVADO, se rehace desde el catálogo y el árbol
|
|
2575
|
+
* del consumidor, y en esta dirección ni siquiera se migra. Lo que sí se
|
|
2576
|
+
* cuenta es lo que TENDRÍA que ser un hecho y no se entiende.
|
|
2577
|
+
*/
|
|
2578
|
+
async enumerateFacts(page) {
|
|
2579
|
+
const limit = Math.trunc(page.limit);
|
|
2580
|
+
if (!Number.isFinite(limit) || limit < 1) {
|
|
2581
|
+
throw new AuthorizationConfigError(`enumerateFacts: limit debe ser un entero >= 1 (llegó ${String(page.limit)})`);
|
|
2582
|
+
}
|
|
2583
|
+
const catalog = await this.catalog.view();
|
|
2584
|
+
const permissionByRelation = new Map();
|
|
2585
|
+
for (const slug of catalog.permissionSlugs) {
|
|
2586
|
+
permissionByRelation.set(factsRelationsOf(slug).denied, slug);
|
|
2587
|
+
}
|
|
2588
|
+
const fgaToMorph = Object.fromEntries(Object.entries(this.holderTypes).map(([morph, fga]) => [fga, morph]));
|
|
2589
|
+
const holderOf = (user) => {
|
|
2590
|
+
const separator = user.indexOf(':');
|
|
2591
|
+
if (separator <= 0)
|
|
2592
|
+
return null;
|
|
2593
|
+
const morph = fgaToMorph[user.slice(0, separator)];
|
|
2594
|
+
const uuid = user.slice(separator + 1);
|
|
2595
|
+
if (!morph || !uuid || uuid.includes('#') || uuid === '*')
|
|
2596
|
+
return null;
|
|
2597
|
+
return { type: morph, uuid };
|
|
2598
|
+
};
|
|
2599
|
+
const facts = [];
|
|
2600
|
+
const skipped = [];
|
|
2601
|
+
// El `pageSize` que se le pide al servidor va acotado por el tope de
|
|
2602
|
+
// página del driver: pedir 1 000 es un 400 del servidor, y el contrato del
|
|
2603
|
+
// puerto es «como mucho `limit`», no «exactamente `limit`».
|
|
2604
|
+
const response = await this.client.read({}, { pageSize: Math.min(limit, READ_PAGE_SIZE), continuationToken: page.after, consistency: this.consistency });
|
|
2605
|
+
for (const tuple of response.tuples ?? []) {
|
|
2606
|
+
const key = tuple?.key;
|
|
2607
|
+
if (!key?.user || !key?.relation || !key?.object) {
|
|
2608
|
+
skipped.push({ kind: 'tuple', reason: 'unparseable-tuple', detail: JSON.stringify(key ?? null) });
|
|
2609
|
+
continue;
|
|
2610
|
+
}
|
|
2611
|
+
const id = `${key.user}#${key.relation}@${key.object}`;
|
|
2612
|
+
if (key.relation === FACTS_ASSIGNEE_RELATION) {
|
|
2613
|
+
const binding = key.object.startsWith(`${FACTS_BINDING_TYPE}:`)
|
|
2614
|
+
? parseBindingId(key.object.slice(FACTS_BINDING_TYPE.length + 1))
|
|
2615
|
+
: null;
|
|
2616
|
+
if (!binding) {
|
|
2617
|
+
// Un id de binding que este motor no escribiría (los de 1.x/2.1
|
|
2618
|
+
// llevaban el slug): es un hecho que NADIE puede migrar.
|
|
2619
|
+
skipped.push({ kind: 'tuple', reason: 'unparseable-binding', detail: id });
|
|
2620
|
+
continue;
|
|
2621
|
+
}
|
|
2622
|
+
const holder = holderOf(key.user);
|
|
2623
|
+
if (!holder) {
|
|
2624
|
+
skipped.push({ kind: 'tuple', reason: 'unknown-holder-type', detail: id });
|
|
2625
|
+
continue;
|
|
2626
|
+
}
|
|
2627
|
+
facts.push({
|
|
2628
|
+
kind: 'assignment',
|
|
2629
|
+
holder,
|
|
2630
|
+
scope: binding.scope,
|
|
2631
|
+
roleUuid: binding.uuid,
|
|
2632
|
+
expiresAt: toExpiryDate(key.condition?.context?.valid_until),
|
|
2633
|
+
detail: id,
|
|
2634
|
+
});
|
|
2635
|
+
continue;
|
|
2636
|
+
}
|
|
2637
|
+
if (key.relation.startsWith(FACTS_DENIED_PREFIX) && key.object.startsWith(`${FACTS_SCOPE_TYPE}:`)) {
|
|
2638
|
+
const permission = permissionByRelation.get(key.relation);
|
|
2639
|
+
if (!permission) {
|
|
2640
|
+
// La relación existe en el modelo pero el catálogo ya no declara
|
|
2641
|
+
// ese permiso (D5): no es un deny, y quien lo recoge es `--prune`.
|
|
2642
|
+
skipped.push({ kind: 'tuple', reason: 'unknown-permission', detail: id });
|
|
2643
|
+
continue;
|
|
2644
|
+
}
|
|
2645
|
+
const scope = scopeFromKey(key.object.slice(FACTS_SCOPE_TYPE.length + 1));
|
|
2646
|
+
if (!scope) {
|
|
2647
|
+
skipped.push({ kind: 'tuple', reason: 'invalid-scope', detail: id });
|
|
2648
|
+
continue;
|
|
2649
|
+
}
|
|
2650
|
+
const holder = holderOf(key.user);
|
|
2651
|
+
if (!holder) {
|
|
2652
|
+
skipped.push({ kind: 'tuple', reason: 'unknown-holder-type', detail: id });
|
|
2653
|
+
continue;
|
|
2654
|
+
}
|
|
2655
|
+
facts.push({ kind: 'deny', holder, scope, permission, detail: id });
|
|
2656
|
+
continue;
|
|
2657
|
+
}
|
|
2658
|
+
// **El deny del modo `resolver` de 2.2 ES un hecho y se emite** (3b-8 ·
|
|
2659
|
+
// A2): `deny_binding:<scopeKey>|<permissionUuid>#denied@<holder>`. El
|
|
2660
|
+
// modelo (c2r) ni declara ese tipo, pero un store escrito por la
|
|
2661
|
+
// versión anterior lo tiene, y `reconcile` es el sustituto documentado
|
|
2662
|
+
// del importador borrado en 3b-2k · K2: descartarlo en silencio perdía
|
|
2663
|
+
// los denies explícitos de la migración con el verificador en VERDE
|
|
2664
|
+
// (invariante 2 roto sin una línea en ningún contador). El permiso va
|
|
2665
|
+
// por uuid en el id (así los escribía 2.2); el catálogo local lo
|
|
2666
|
+
// traduce a slug, y lo que no traduce sale contado, jamás mudo.
|
|
2667
|
+
if (key.relation === LEGACY_DENIED_RELATION && key.object.startsWith(`${LEGACY_DENY_BINDING_TYPE}:`)) {
|
|
2668
|
+
const binding = parseBindingId(key.object.slice(LEGACY_DENY_BINDING_TYPE.length + 1));
|
|
2669
|
+
if (!binding) {
|
|
2670
|
+
skipped.push({ kind: 'tuple', reason: 'unparseable-binding', detail: id });
|
|
2671
|
+
continue;
|
|
2672
|
+
}
|
|
2673
|
+
const permission = catalog.permissionSlug(binding.uuid);
|
|
2674
|
+
if (!permission) {
|
|
2675
|
+
skipped.push({ kind: 'tuple', reason: 'unknown-permission', detail: id });
|
|
2676
|
+
continue;
|
|
2677
|
+
}
|
|
2678
|
+
const holder = holderOf(key.user);
|
|
2679
|
+
if (!holder) {
|
|
2680
|
+
skipped.push({ kind: 'tuple', reason: 'unknown-holder-type', detail: id });
|
|
2681
|
+
continue;
|
|
2682
|
+
}
|
|
2683
|
+
facts.push({ kind: 'deny', holder, scope: binding.scope, permission, detail: id });
|
|
2684
|
+
continue;
|
|
2685
|
+
}
|
|
2686
|
+
// Estructura y proyección del catálogo: derivado, no es un hecho.
|
|
2687
|
+
}
|
|
2688
|
+
const cursor = response.continuation_token || undefined;
|
|
2689
|
+
return { facts, ...(skipped.length ? { skipped } : {}), ...(cursor ? { cursor } : {}) };
|
|
2690
|
+
}
|
|
2691
|
+
/**
|
|
2692
|
+
* El store ENTERO, con la caducidad de cada tupla. Un `Read` sin filtro es
|
|
2693
|
+
* la única forma de ver lo que SOBRA —incluida la basura de una versión
|
|
2694
|
+
* anterior, cuyos tipos el modelo de hoy ni declara (se lee y se borra, se
|
|
2695
|
+
* comprobó contra el servidor)—: filtrando por objeto solo se ve lo que ya
|
|
2696
|
+
* se sabe que existe. Paginado y acotado como todas las enumeraciones.
|
|
2697
|
+
*/
|
|
2698
|
+
async readEveryTuple(maxTuples) {
|
|
2699
|
+
const out = [];
|
|
2700
|
+
let continuationToken;
|
|
2701
|
+
const seenTokens = new Set();
|
|
2702
|
+
let pages = 0;
|
|
2703
|
+
do {
|
|
2704
|
+
const response = await this.client.read({}, { pageSize: READ_PAGE_SIZE, continuationToken, consistency: this.consistency });
|
|
2705
|
+
pages += 1;
|
|
2706
|
+
for (const tuple of response.tuples ?? []) {
|
|
2707
|
+
const key = tuple?.key;
|
|
2708
|
+
if (!key?.user || !key?.relation || !key?.object) {
|
|
2709
|
+
this.diagnostics.unparseableBindings += 1;
|
|
2710
|
+
this.warn(`authz(openfga): tupla malformada en el Read de reconcile (${JSON.stringify(key ?? null)}); ` +
|
|
2711
|
+
`no se puede comparar (total: ${this.diagnostics.unparseableBindings})`);
|
|
2712
|
+
continue;
|
|
2713
|
+
}
|
|
2714
|
+
out.push({
|
|
2715
|
+
user: key.user,
|
|
2716
|
+
relation: key.relation,
|
|
2717
|
+
object: key.object,
|
|
2718
|
+
validUntil: toExpiryDate(key.condition?.context?.valid_until),
|
|
2719
|
+
});
|
|
2720
|
+
if (out.length > maxTuples) {
|
|
2721
|
+
throw new ReconcileTooLargeError(`authz:reconcile --to=openfga: el store pasa de maxTuples (${maxTuples}) tuplas y la pasada ` +
|
|
2722
|
+
`necesita la foto ENTERA del destino para saber qué sobra (que es la mitad del trabajo: ` +
|
|
2723
|
+
`las aristas que ya nadie respalda, el nodo con dos padres, la basura de otra versión). ` +
|
|
2724
|
+
`Sube maxTuples si tu proceso lo aguanta; no hay migración por particiones en esta versión.`);
|
|
2725
|
+
}
|
|
2726
|
+
}
|
|
2727
|
+
continuationToken = response.continuation_token || undefined;
|
|
2728
|
+
if (continuationToken) {
|
|
2729
|
+
if (seenTokens.has(continuationToken)) {
|
|
2730
|
+
throw new AuthorizationInternalError(`reconcile: el continuation_token se repite (página ${pages}); el servidor no avanza`);
|
|
2731
|
+
}
|
|
2732
|
+
if (pages >= MAX_READ_PAGES) {
|
|
2733
|
+
throw new AuthorizationInternalError(`reconcile: más de ${MAX_READ_PAGES} páginas sin agotar el store`);
|
|
2734
|
+
}
|
|
2735
|
+
seenTokens.add(continuationToken);
|
|
2736
|
+
}
|
|
2737
|
+
} while (continuationToken);
|
|
2738
|
+
return out;
|
|
2739
|
+
}
|
|
2740
|
+
/**
|
|
2741
|
+
* Aplica el plan en lotes ≤ 100 (el límite del `Write`). `Ignore` en las dos
|
|
2742
|
+
* direcciones es RED, no política: el plan sale de un diff sobre el estado
|
|
2743
|
+
* leído y la migración corre con las escrituras congeladas, así que un
|
|
2744
|
+
* duplicado o un borrado que ya no está solo puede venir de una carrera —y
|
|
2745
|
+
* en una carrera es preferible seguir a abortar la migración entera—.
|
|
2746
|
+
*/
|
|
2747
|
+
async applyReconcileWrites(deletes, writes) {
|
|
2748
|
+
for (let i = 0; i < deletes.length; i += PURGE_BATCH_SIZE) {
|
|
2749
|
+
await this.client.deleteTuples(deletes.slice(i, i + PURGE_BATCH_SIZE), {
|
|
2750
|
+
conflict: { onMissingDeletes: ClientWriteRequestOnMissingDeletes.Ignore },
|
|
2751
|
+
});
|
|
2752
|
+
}
|
|
2753
|
+
for (let i = 0; i < writes.length; i += PURGE_BATCH_SIZE) {
|
|
2754
|
+
await this.client.writeTuples(writes.slice(i, i + PURGE_BATCH_SIZE), IGNORE_DUPLICATE_WRITES);
|
|
2755
|
+
}
|
|
2756
|
+
}
|
|
2757
|
+
/**
|
|
2758
|
+
* TODAS las tuplas que casan con el filtro, paginando `Read` hasta agotar
|
|
2759
|
+
* el `continuation_token`, sin las caducadas. Es la única primitiva de
|
|
2760
|
+
* enumeración del driver (L0.7): `Read` no tiene tope de resultados —a
|
|
2761
|
+
* diferencia de `ListObjects`/`ListUsers`, que cortan al máximo del
|
|
2762
|
+
* servidor sin ninguna señal— y devuelve la condición de cada tupla, así
|
|
2763
|
+
* que la caducidad se filtra aquí con el mismo reloj que `checkContext`.
|
|
2764
|
+
*
|
|
2765
|
+
* Contrapartida, documentada en el README: `Read` devuelve tuplas
|
|
2766
|
+
* ESCRITAS, no relaciones computadas. Con el modelo que genera este paquete
|
|
2767
|
+
* (`assignee`/`denied` directas) es exactamente lo mismo; un modelo
|
|
2768
|
+
* extendido con relaciones derivadas sobre `role_binding` no se enumeraría
|
|
2769
|
+
* por aquí.
|
|
2770
|
+
*/
|
|
2771
|
+
async readAllTuples(filter, options = {}) {
|
|
2772
|
+
// El instante con el que se filtra: el de la operación si lo trae (K9), o
|
|
2773
|
+
// el de esta lectura.
|
|
2774
|
+
const now = options.at ?? this.now();
|
|
2775
|
+
const keys = [];
|
|
2776
|
+
let continuationToken;
|
|
2777
|
+
const seenTokens = new Set();
|
|
2778
|
+
let pages = 0;
|
|
2779
|
+
do {
|
|
2780
|
+
const response = await this.client.read(filter, {
|
|
2781
|
+
pageSize: READ_PAGE_SIZE,
|
|
2782
|
+
continuationToken,
|
|
2783
|
+
consistency: this.consistency,
|
|
2784
|
+
});
|
|
2785
|
+
pages += 1;
|
|
2786
|
+
for (const tuple of response.tuples ?? []) {
|
|
2787
|
+
const k = tuple?.key;
|
|
2788
|
+
if (!k?.user || !k?.relation || !k?.object) {
|
|
2789
|
+
// Una tupla que el motor no puede leer es un hecho que las
|
|
2790
|
+
// enumeraciones NO muestran: se cuenta y se registra (L0.16, H16).
|
|
2791
|
+
this.diagnostics.unparseableBindings += 1;
|
|
2792
|
+
this.warn(`authz(openfga): tupla malformada en Read ${JSON.stringify(filter)} (${JSON.stringify(k ?? null)}); se ignora en la enumeración (total: ${this.diagnostics.unparseableBindings})`);
|
|
2793
|
+
continue;
|
|
2794
|
+
}
|
|
2795
|
+
if (!options.includeExpired) {
|
|
2796
|
+
const validUntil = toExpiryDate(k.condition?.context?.valid_until);
|
|
2797
|
+
if (validUntil && validUntil <= now)
|
|
2798
|
+
continue;
|
|
2799
|
+
}
|
|
2800
|
+
keys.push({ user: k.user, relation: k.relation, object: k.object });
|
|
2801
|
+
}
|
|
2802
|
+
continuationToken = response.continuation_token || undefined;
|
|
2803
|
+
if (continuationToken) {
|
|
2804
|
+
if (seenTokens.has(continuationToken)) {
|
|
2805
|
+
throw new AuthorizationInternalError(`Read ${JSON.stringify(filter)}: el continuation_token se repite (página ${pages}); el servidor no avanza`);
|
|
2806
|
+
}
|
|
2807
|
+
if (pages >= MAX_READ_PAGES) {
|
|
2808
|
+
throw new AuthorizationInternalError(`Read ${JSON.stringify(filter)}: más de ${MAX_READ_PAGES} páginas sin agotar el continuation_token`);
|
|
2809
|
+
}
|
|
2810
|
+
seenTokens.add(continuationToken);
|
|
2811
|
+
}
|
|
2812
|
+
} while (continuationToken);
|
|
2813
|
+
return keys;
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
/* ── `authz:reconcile` (3b-3a): piezas puras ────────────────────────────── */
|
|
2817
|
+
/** Tope de páginas de `scopes.enumerateEdges` (10 000 000 de nodos a 100). */
|
|
2818
|
+
const RECONCILE_MAX_PAGES = 100_000;
|
|
2819
|
+
/**
|
|
2820
|
+
* A qué FAMILIA pertenece una tupla del store, que es lo que decide si esta
|
|
2821
|
+
* pasada puede borrarla por su cuenta:
|
|
2822
|
+
* - `root`, `catalog` y `tree` son ESPEJOS de datos locales (el `holderTypes`
|
|
2823
|
+
* del config, `authz_roles`/`authz_permissions`, `scopes.enumerateEdges`)
|
|
2824
|
+
* que nadie más escribe: lo que sobra se borra siempre;
|
|
2825
|
+
* - todo lo demás son HECHOS —`assignee`, las dos aristas de (c2), los
|
|
2826
|
+
* `denied_<P>` y cualquier tupla de un modelo anterior— y solo los borra
|
|
2827
|
+
* `--prune`.
|
|
2828
|
+
*/
|
|
2829
|
+
function familyOfTuple(tuple) {
|
|
2830
|
+
if (tuple.relation === FACTS_ROOTED_RELATION)
|
|
2831
|
+
return 'root';
|
|
2832
|
+
if (tuple.object.startsWith(`${FACTS_ROLE_TYPE}:`) && tuple.relation.startsWith(FACTS_PERMITS_PREFIX))
|
|
2833
|
+
return 'catalog';
|
|
2834
|
+
if (tuple.object.startsWith(`${FACTS_SCOPE_TYPE}:`) && tuple.relation === FACTS_PARENT_RELATION)
|
|
2835
|
+
return 'tree';
|
|
2836
|
+
return 'facts';
|
|
2837
|
+
}
|
|
2838
|
+
/** La clave sola: un `delete` de FGA no lleva condición. */
|
|
2839
|
+
function bareTuple(tuple) {
|
|
2840
|
+
return { user: tuple.user, relation: tuple.relation, object: tuple.object };
|
|
2841
|
+
}
|
|
2842
|
+
/** La tupla tal como se ESCRIBE: con la condición `not_expired` si caduca. */
|
|
2843
|
+
function reconcileWriteForm(target) {
|
|
2844
|
+
if (target.validUntil === null)
|
|
2845
|
+
return target.tuple;
|
|
2846
|
+
return {
|
|
2847
|
+
...target.tuple,
|
|
2848
|
+
condition: { name: 'not_expired', context: { valid_until: target.validUntil.toISOString() } },
|
|
2849
|
+
};
|
|
2850
|
+
}
|
|
2851
|
+
/**
|
|
2852
|
+
* ¿Se llega a `target` subiendo desde `from`? Devuelve el camino recorrido
|
|
2853
|
+
* (`[from, ..., target]`) o `null`. Es el anti-ciclos del árbol del ORIGEN: la
|
|
2854
|
+
* arista que cierra el ciclo es la que se está clasificando, así que el mapa
|
|
2855
|
+
* por el que se sube NUNCA tiene uno (el cinturón de `seen` es por si acaso).
|
|
2856
|
+
*/
|
|
2857
|
+
function walkUpTo(parentOf, from, target) {
|
|
2858
|
+
const path = [];
|
|
2859
|
+
const seen = new Set();
|
|
2860
|
+
let current = from;
|
|
2861
|
+
while (current !== undefined && !seen.has(current)) {
|
|
2862
|
+
path.push(current);
|
|
2863
|
+
if (current === target)
|
|
2864
|
+
return path;
|
|
2865
|
+
seen.add(current);
|
|
2866
|
+
current = parentOf.get(current);
|
|
2867
|
+
}
|
|
2868
|
+
return null;
|
|
548
2869
|
}
|
|
549
2870
|
//# sourceMappingURL=openfga_driver.js.map
|