@jantstack/adonis-authz 2.5.0-alpha.2 → 2.5.0-alpha.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +743 -28
- package/build/commands/authz_catalog_diff.d.ts +0 -1
- package/build/commands/authz_catalog_diff.js +0 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts +0 -1
- package/build/commands/authz_catalog_prune_orphans.js +0 -1
- package/build/commands/authz_catalog_sync.d.ts +0 -1
- package/build/commands/authz_catalog_sync.js +0 -1
- package/build/commands/authz_freeze.d.ts +0 -1
- package/build/commands/authz_freeze.js +0 -1
- package/build/commands/authz_reconcile.d.ts +0 -1
- package/build/commands/authz_reconcile.js +0 -1
- package/build/commands/authz_relations_reconcile.d.ts +6 -1
- package/build/commands/authz_relations_reconcile.js +163 -5
- package/build/commands/authz_scopes_relay.d.ts +0 -1
- package/build/commands/authz_scopes_relay.js +49 -5
- package/build/commands/authz_scopes_requeue.d.ts +0 -1
- package/build/commands/authz_scopes_requeue.js +14 -2
- package/build/commands/authz_unfreeze.d.ts +0 -1
- package/build/commands/authz_unfreeze.js +0 -1
- package/build/commands/main.d.ts +0 -1
- package/build/commands/main.js +0 -1
- package/build/commands/openfga_provision.d.ts +72 -1
- package/build/commands/openfga_provision.js +236 -4
- package/build/configure.d.ts +0 -1
- package/build/configure.js +0 -1
- package/build/index.d.ts +11 -9
- package/build/index.js +12 -5
- package/build/providers/authz_provider.d.ts +0 -1
- package/build/providers/authz_provider.js +0 -1
- package/build/services/main.d.ts +0 -1
- package/build/services/main.js +0 -1
- package/build/services/relations.d.ts +0 -1
- package/build/services/relations.js +0 -1
- package/build/src/catalog/catalog.d.ts +0 -1
- package/build/src/catalog/catalog.js +0 -1
- package/build/src/catalog/catalog_cache.d.ts +13 -1
- package/build/src/catalog/catalog_cache.js +25 -1
- package/build/src/clock.d.ts +0 -1
- package/build/src/clock.js +0 -1
- package/build/src/define_config.d.ts +0 -1
- package/build/src/define_config.js +24 -1
- package/build/src/drivers/database_driver.d.ts +6 -1
- package/build/src/drivers/database_driver.js +22 -2
- package/build/src/drivers/database_relations_driver.d.ts +46 -5
- package/build/src/drivers/database_relations_driver.js +297 -59
- package/build/src/drivers/openfga_driver.d.ts +159 -26
- package/build/src/drivers/openfga_driver.js +980 -231
- package/build/src/drivers/openfga_facts.d.ts +112 -1
- package/build/src/drivers/openfga_facts.js +351 -11
- package/build/src/drivers/openfga_relations_driver.d.ts +109 -8
- package/build/src/drivers/openfga_relations_driver.js +817 -53
- package/build/src/errors.d.ts +233 -1
- package/build/src/errors.js +283 -1
- package/build/src/expiry.d.ts +51 -2
- package/build/src/expiry.js +96 -2
- package/build/src/freeze.d.ts +0 -1
- package/build/src/freeze.js +0 -1
- package/build/src/hierarchical_resolver.d.ts +0 -1
- package/build/src/hierarchical_resolver.js +0 -1
- package/build/src/http/app_access_middleware.d.ts +0 -1
- package/build/src/http/app_access_middleware.js +0 -1
- package/build/src/http/resource_access_middleware.d.ts +0 -1
- package/build/src/http/resource_access_middleware.js +0 -1
- package/build/src/identity.d.ts +2 -1
- package/build/src/identity.js +4 -1
- package/build/src/manager.d.ts +0 -1
- package/build/src/manager.js +164 -31
- package/build/src/memoize_ancestors.d.ts +0 -1
- package/build/src/memoize_ancestors.js +0 -1
- package/build/src/models/authz_assignment.d.ts +5 -6
- package/build/src/models/authz_assignment.js +0 -1
- package/build/src/models/authz_deny.d.ts +5 -6
- package/build/src/models/authz_deny.js +0 -1
- package/build/src/models/authz_permission.d.ts +5 -6
- package/build/src/models/authz_permission.js +0 -1
- package/build/src/models/authz_role.d.ts +5 -6
- package/build/src/models/authz_role.js +0 -1
- package/build/src/models/authz_role_permission.d.ts +5 -6
- package/build/src/models/authz_role_permission.js +0 -1
- package/build/src/openfga.d.ts +3 -4
- package/build/src/openfga.js +5 -3
- package/build/src/reconcile.d.ts +0 -1
- package/build/src/reconcile.js +0 -1
- package/build/src/relation_partition_trigger.d.ts +0 -1
- package/build/src/relation_partition_trigger.js +0 -1
- package/build/src/relations/check_many.d.ts +1 -2
- package/build/src/relations/check_many.js +6 -5
- package/build/src/relations/define_relations_config.d.ts +140 -5
- package/build/src/relations/define_relations_config.js +205 -6
- package/build/src/relations/deny_reach.d.ts +102 -0
- package/build/src/relations/deny_reach.js +230 -0
- package/build/src/relations/group_depth.d.ts +215 -0
- package/build/src/relations/group_depth.js +453 -0
- package/build/src/relations/manager.d.ts +46 -8
- package/build/src/relations/manager.js +178 -16
- package/build/src/relations/page.d.ts +19 -0
- package/build/src/relations/page.js +74 -0
- package/build/src/relations/reconcile.d.ts +274 -2
- package/build/src/relations/reconcile.js +1006 -162
- package/build/src/relations/tuple_key.d.ts +0 -1
- package/build/src/relations/tuple_key.js +0 -1
- package/build/src/relations/tuple_order.d.ts +127 -0
- package/build/src/relations/tuple_order.js +599 -0
- package/build/src/relations_config_store.d.ts +57 -2
- package/build/src/relations_config_store.js +155 -5
- package/build/src/relations_reconcile.d.ts +0 -1
- package/build/src/relations_reconcile.js +0 -1
- package/build/src/scope_outbox.d.ts +0 -1
- package/build/src/scope_outbox.js +0 -1
- package/build/src/shared/backend_guard.d.ts +60 -1
- package/build/src/shared/backend_guard.js +179 -2
- package/build/src/shared/sql_expiry.d.ts +0 -1
- package/build/src/shared/sql_expiry.js +0 -1
- package/build/src/shared/transaction_guard.d.ts +0 -1
- package/build/src/shared/transaction_guard.js +0 -1
- package/build/src/sql_descendants.d.ts +0 -1
- package/build/src/sql_descendants.js +0 -1
- package/build/src/testing/contract.d.ts +7 -1
- package/build/src/testing/contract.js +77 -1
- package/build/src/testing/main.d.ts +1 -2
- package/build/src/testing/main.js +1 -2
- package/build/src/testing/migration_contract.d.ts +0 -1
- package/build/src/testing/migration_contract.js +0 -1
- package/build/src/testing/relations_contract.d.ts +25 -1
- package/build/src/testing/relations_contract.js +996 -49
- package/build/src/testing/relations_reconcile_contract.d.ts +9 -1
- package/build/src/testing/relations_reconcile_contract.js +207 -1
- package/build/src/testing/scope_outbox_contract.d.ts +0 -1
- package/build/src/testing/scope_outbox_contract.js +0 -1
- package/build/src/testing/scope_tree.d.ts +0 -1
- package/build/src/testing/scope_tree.js +0 -1
- package/build/src/testing/test_clock.d.ts +0 -1
- package/build/src/testing/test_clock.js +0 -1
- package/build/src/traits/authz_scopes.d.ts +0 -1
- package/build/src/traits/authz_scopes.js +0 -1
- package/build/src/traits/has_uuid.d.ts +5 -6
- package/build/src/traits/has_uuid.js +0 -1
- package/build/src/types.d.ts +170 -15
- package/build/src/types.js +6 -1
- package/build/stubs/config/authorization.stub +22 -6
- package/build/stubs/main.d.ts +0 -1
- package/build/stubs/main.js +0 -1
- package/package.json +4 -3
- package/build/commands/authz_catalog_diff.d.ts.map +0 -1
- package/build/commands/authz_catalog_diff.js.map +0 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts.map +0 -1
- package/build/commands/authz_catalog_prune_orphans.js.map +0 -1
- package/build/commands/authz_catalog_sync.d.ts.map +0 -1
- package/build/commands/authz_catalog_sync.js.map +0 -1
- package/build/commands/authz_freeze.d.ts.map +0 -1
- package/build/commands/authz_freeze.js.map +0 -1
- package/build/commands/authz_reconcile.d.ts.map +0 -1
- package/build/commands/authz_reconcile.js.map +0 -1
- package/build/commands/authz_relations_reconcile.d.ts.map +0 -1
- package/build/commands/authz_relations_reconcile.js.map +0 -1
- package/build/commands/authz_scopes_relay.d.ts.map +0 -1
- package/build/commands/authz_scopes_relay.js.map +0 -1
- package/build/commands/authz_scopes_requeue.d.ts.map +0 -1
- package/build/commands/authz_scopes_requeue.js.map +0 -1
- package/build/commands/authz_unfreeze.d.ts.map +0 -1
- package/build/commands/authz_unfreeze.js.map +0 -1
- package/build/commands/main.d.ts.map +0 -1
- package/build/commands/main.js.map +0 -1
- package/build/commands/openfga_provision.d.ts.map +0 -1
- package/build/commands/openfga_provision.js.map +0 -1
- package/build/configure.d.ts.map +0 -1
- package/build/configure.js.map +0 -1
- package/build/index.d.ts.map +0 -1
- package/build/index.js.map +0 -1
- package/build/providers/authz_provider.d.ts.map +0 -1
- package/build/providers/authz_provider.js.map +0 -1
- package/build/services/main.d.ts.map +0 -1
- package/build/services/main.js.map +0 -1
- package/build/services/relations.d.ts.map +0 -1
- package/build/services/relations.js.map +0 -1
- package/build/src/catalog/catalog.d.ts.map +0 -1
- package/build/src/catalog/catalog.js.map +0 -1
- package/build/src/catalog/catalog_cache.d.ts.map +0 -1
- package/build/src/catalog/catalog_cache.js.map +0 -1
- package/build/src/clock.d.ts.map +0 -1
- package/build/src/clock.js.map +0 -1
- package/build/src/define_config.d.ts.map +0 -1
- package/build/src/define_config.js.map +0 -1
- package/build/src/drivers/database_driver.d.ts.map +0 -1
- package/build/src/drivers/database_driver.js.map +0 -1
- package/build/src/drivers/database_relations_driver.d.ts.map +0 -1
- package/build/src/drivers/database_relations_driver.js.map +0 -1
- package/build/src/drivers/openfga_driver.d.ts.map +0 -1
- package/build/src/drivers/openfga_driver.js.map +0 -1
- package/build/src/drivers/openfga_facts.d.ts.map +0 -1
- package/build/src/drivers/openfga_facts.js.map +0 -1
- package/build/src/drivers/openfga_relations_driver.d.ts.map +0 -1
- package/build/src/drivers/openfga_relations_driver.js.map +0 -1
- package/build/src/errors.d.ts.map +0 -1
- package/build/src/errors.js.map +0 -1
- package/build/src/expiry.d.ts.map +0 -1
- package/build/src/expiry.js.map +0 -1
- package/build/src/freeze.d.ts.map +0 -1
- package/build/src/freeze.js.map +0 -1
- package/build/src/hierarchical_resolver.d.ts.map +0 -1
- package/build/src/hierarchical_resolver.js.map +0 -1
- package/build/src/http/app_access_middleware.d.ts.map +0 -1
- package/build/src/http/app_access_middleware.js.map +0 -1
- package/build/src/http/resource_access_middleware.d.ts.map +0 -1
- package/build/src/http/resource_access_middleware.js.map +0 -1
- package/build/src/identity.d.ts.map +0 -1
- package/build/src/identity.js.map +0 -1
- package/build/src/manager.d.ts.map +0 -1
- package/build/src/manager.js.map +0 -1
- package/build/src/memoize_ancestors.d.ts.map +0 -1
- package/build/src/memoize_ancestors.js.map +0 -1
- package/build/src/models/authz_assignment.d.ts.map +0 -1
- package/build/src/models/authz_assignment.js.map +0 -1
- package/build/src/models/authz_deny.d.ts.map +0 -1
- package/build/src/models/authz_deny.js.map +0 -1
- package/build/src/models/authz_permission.d.ts.map +0 -1
- package/build/src/models/authz_permission.js.map +0 -1
- package/build/src/models/authz_role.d.ts.map +0 -1
- package/build/src/models/authz_role.js.map +0 -1
- package/build/src/models/authz_role_permission.d.ts.map +0 -1
- package/build/src/models/authz_role_permission.js.map +0 -1
- package/build/src/openfga.d.ts.map +0 -1
- package/build/src/openfga.js.map +0 -1
- package/build/src/reconcile.d.ts.map +0 -1
- package/build/src/reconcile.js.map +0 -1
- package/build/src/relation_partition_trigger.d.ts.map +0 -1
- package/build/src/relation_partition_trigger.js.map +0 -1
- package/build/src/relations/check_many.d.ts.map +0 -1
- package/build/src/relations/check_many.js.map +0 -1
- package/build/src/relations/define_relations_config.d.ts.map +0 -1
- package/build/src/relations/define_relations_config.js.map +0 -1
- package/build/src/relations/manager.d.ts.map +0 -1
- package/build/src/relations/manager.js.map +0 -1
- package/build/src/relations/reconcile.d.ts.map +0 -1
- package/build/src/relations/reconcile.js.map +0 -1
- package/build/src/relations/tuple_key.d.ts.map +0 -1
- package/build/src/relations/tuple_key.js.map +0 -1
- package/build/src/relations_config_store.d.ts.map +0 -1
- package/build/src/relations_config_store.js.map +0 -1
- package/build/src/relations_reconcile.d.ts.map +0 -1
- package/build/src/relations_reconcile.js.map +0 -1
- package/build/src/scope_outbox.d.ts.map +0 -1
- package/build/src/scope_outbox.js.map +0 -1
- package/build/src/shared/backend_guard.d.ts.map +0 -1
- package/build/src/shared/backend_guard.js.map +0 -1
- package/build/src/shared/sql_expiry.d.ts.map +0 -1
- package/build/src/shared/sql_expiry.js.map +0 -1
- package/build/src/shared/transaction_guard.d.ts.map +0 -1
- package/build/src/shared/transaction_guard.js.map +0 -1
- package/build/src/sql_descendants.d.ts.map +0 -1
- package/build/src/sql_descendants.js.map +0 -1
- package/build/src/testing/contract.d.ts.map +0 -1
- package/build/src/testing/contract.js.map +0 -1
- package/build/src/testing/main.d.ts.map +0 -1
- package/build/src/testing/main.js.map +0 -1
- package/build/src/testing/migration_contract.d.ts.map +0 -1
- package/build/src/testing/migration_contract.js.map +0 -1
- package/build/src/testing/relations_contract.d.ts.map +0 -1
- package/build/src/testing/relations_contract.js.map +0 -1
- package/build/src/testing/relations_reconcile_contract.d.ts.map +0 -1
- package/build/src/testing/relations_reconcile_contract.js.map +0 -1
- package/build/src/testing/scope_outbox_contract.d.ts.map +0 -1
- package/build/src/testing/scope_outbox_contract.js.map +0 -1
- package/build/src/testing/scope_tree.d.ts.map +0 -1
- package/build/src/testing/scope_tree.js.map +0 -1
- package/build/src/testing/test_clock.d.ts.map +0 -1
- package/build/src/testing/test_clock.js.map +0 -1
- package/build/src/traits/authz_scopes.d.ts.map +0 -1
- package/build/src/traits/authz_scopes.js.map +0 -1
- package/build/src/traits/has_uuid.d.ts.map +0 -1
- package/build/src/traits/has_uuid.js.map +0 -1
- package/build/src/types.d.ts.map +0 -1
- package/build/src/types.js.map +0 -1
- package/build/stubs/main.d.ts.map +0 -1
- package/build/stubs/main.js.map +0 -1
|
@@ -25,18 +25,26 @@
|
|
|
25
25
|
*/
|
|
26
26
|
import db from '@adonisjs/lucid/services/db';
|
|
27
27
|
import { v7 as uuidv7 } from 'uuid';
|
|
28
|
-
import { AuthorizationConfigError, InvalidIdentityError, UnsupportedDialectError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
|
|
28
|
+
import { AuthorizationBackendTimeoutError, AuthorizationConfigError, InvalidIdentityError, MembersOfDenyLimitError, UnsupportedDialectError, UnsupportedOperationError, WriteConflictError, } from '../errors.js';
|
|
29
29
|
import { assertPartition, assertSubject, assertExpiresAt, partitionKey } from '../identity.js';
|
|
30
30
|
import { relationPartitionTrigger, relationPartitionTriggerDrops } from '../relation_partition_trigger.js';
|
|
31
31
|
import { isClock, systemClock } from '../clock.js';
|
|
32
|
-
import { resolveGrantExpiry, sameInstant } from '../expiry.js';
|
|
32
|
+
import { isActiveExpiry, resolveGrantExpiry, sameInstant } from '../expiry.js';
|
|
33
|
+
import { assertGroupDepthSettings, groupDepthLimitError, groupNestingVerdict, isGroupNesting, plannedByReconcile } from '../relations/group_depth.js';
|
|
33
34
|
import { sqlExpiryCodec } from '../shared/sql_expiry.js';
|
|
34
|
-
import { guardSql, isDeadlock, isUniqueViolation } from '../shared/backend_guard.js';
|
|
35
|
+
import { guardSql, isDeadlock, isUniqueViolation, withDeadline } from '../shared/backend_guard.js';
|
|
35
36
|
import { assertCallerTransaction } from '../shared/transaction_guard.js';
|
|
36
37
|
import { isRelUserset } from '../types.js';
|
|
37
|
-
import { assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject } from '../relations/define_relations_config.js';
|
|
38
|
+
import { assertRelationDeclared, assertObjectTypeDeclared, assertUsersetSubject, assertRelationDenyable, assertDenySubject, assertDenyNoExpiry, cutPathAllows, decideFrom, deniableAbove, deniedRelationOf, storedRelationKind, denyPhaseOf, relationsDown, RELATION_DENY_PREFIX, } from '../relations/define_relations_config.js';
|
|
38
39
|
import { assertCheckManyObjects, distinctObjects, expandByPosition } from '../relations/check_many.js';
|
|
40
|
+
import { assertEnumeratePage, assertNoListPage, enumerateLimit } from '../relations/page.js';
|
|
39
41
|
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
42
|
+
/**
|
|
43
|
+
* **Cota de candidatos de `membersOf` cuando el objeto tiene un deny vigente** (lote D · cierre 1 de D1, 🟡 4): con deny
|
|
44
|
+
* cada candidato se juzga con un `check` (una CTE por candidato). Pasada ⇒ 500 `E_AUTHZ_MEMBERS_DENY_LIMIT`, nunca una
|
|
45
|
+
* lista parcial. En «Limits» del README, atada a esta constante (`scope_depth.spec`).
|
|
46
|
+
*/
|
|
47
|
+
export const RELATIONS_MEMBERS_DENY_CHECK_MAX = 1_000;
|
|
40
48
|
const RELATIONS_TABLE = 'authz_relations';
|
|
41
49
|
/** Longitud máxima del id de un objeto/holder de relación (columna `varchar(64)`). */
|
|
42
50
|
const RELATION_ID_MAX = 64;
|
|
@@ -52,9 +60,6 @@ const GROUP_MEMBER = 'member';
|
|
|
52
60
|
* userset puede colisionar con ella.
|
|
53
61
|
*/
|
|
54
62
|
const HOLDER_RELATION = '';
|
|
55
|
-
/** Tamaño de página por defecto y tope de `enumerateRelations` (origen de reconcile). */
|
|
56
|
-
const DEFAULT_ENUMERATE_LIMIT = 100;
|
|
57
|
-
const MAX_ENUMERATE_LIMIT = 1_000;
|
|
58
63
|
/** Formato de id de relación: minúsculas, dígitos y `._-`; sin separadores del store. */
|
|
59
64
|
const RELATION_ID_FORMAT = /^[a-z0-9_.-]+$/;
|
|
60
65
|
const DIALECTS = {
|
|
@@ -110,6 +115,9 @@ export class DatabaseRelationsDriver {
|
|
|
110
115
|
#database;
|
|
111
116
|
#expiryCodec;
|
|
112
117
|
constructor(config, options = {}, database = db) {
|
|
118
|
+
// Cierre de R1 · 🟡 4 del auditor: el driver RE-VALIDA el techo de la config (una config extendida con spread no pasó
|
|
119
|
+
// por `defineRelationsConfig`; `NaN` o `enforceGroupDepth: true` sin número apagaban el gate en silencio). 500.
|
|
120
|
+
assertGroupDepthSettings(config.maxGroupDepth, config.enforceGroupDepth, 'DatabaseRelationsDriver');
|
|
113
121
|
this.#config = config;
|
|
114
122
|
this.#connectionName = options.connection;
|
|
115
123
|
this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
@@ -125,6 +133,14 @@ export class DatabaseRelationsDriver {
|
|
|
125
133
|
listObjectsTruncation: false,
|
|
126
134
|
injectableClock: true,
|
|
127
135
|
transactionalWrites: this.#transactionalWrites,
|
|
136
|
+
// Lote D · D1: el deny vive en la MISMA tabla (`relation = 'denied_<rel>'`) y TODA lectura lo honra SIEMPRE
|
|
137
|
+
// (D8: también en un tipo sin opt-in —una fila deny es un hecho—).
|
|
138
|
+
relationDenies: true,
|
|
139
|
+
// Cierre 2 de D2 · 🟠 N1: renovar (otra caducidad) es delete + insert en UNA transacción (`#upsert`): ningún
|
|
140
|
+
// instante sin la tupla, así que una membresía que transporta un deny se renueva sin ventana.
|
|
141
|
+
atomicRenewal: true,
|
|
142
|
+
// Estrategia 2.5.0 · firma 4 (D15): `false` EXPLÍCITO — `database` es el driver ESTABLE de 2.5.0.
|
|
143
|
+
experimental: false,
|
|
128
144
|
});
|
|
129
145
|
}
|
|
130
146
|
/**
|
|
@@ -293,21 +309,30 @@ export class DatabaseRelationsDriver {
|
|
|
293
309
|
b.where('subject_relation', relation);
|
|
294
310
|
}
|
|
295
311
|
/* ── Expansión de includes (un nivel, hacia ABAJO desde la relación) ──── */
|
|
312
|
+
/** `down*(R)` — desde el lote D, la función ÚNICA de la config (`relationsDown`), la misma que el doble y el oráculo. */
|
|
296
313
|
#expandDown(objectType, relation) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
314
|
+
return relationsDown(this.#config, objectType, relation);
|
|
315
|
+
}
|
|
316
|
+
/**
|
|
317
|
+
* **Lo que una lectura con deny tiene que traer para `R`** (lote D · D5/D8): los grants de `down*(R)` y los denies de
|
|
318
|
+
* `up*(down*(R))` (almacenados como `denied_<rel>`). SIEMPRE, también en un tipo sin opt-in (D8: una fila deny es un
|
|
319
|
+
* hecho; si el tipo retiró el opt-in con denies vivos, `database` sigue denegando). Sin filas `denied_*` la respuesta
|
|
320
|
+
* es la de 2.4.0-alpha.10 (el corte no tiene nada que cortar).
|
|
321
|
+
*/
|
|
322
|
+
#readRelations(objectType, relation) {
|
|
323
|
+
return [...this.#expandDown(objectType, relation), ...deniableAbove(this.#config, objectType, relation).map(deniedRelationOf)];
|
|
324
|
+
}
|
|
325
|
+
/** Reparte las relaciones leídas de un objeto en grants y denies (nombre PÚBLICO). */
|
|
326
|
+
#split(relations) {
|
|
327
|
+
const allowed = new Set();
|
|
328
|
+
const denied = new Set();
|
|
329
|
+
for (const r of relations) {
|
|
330
|
+
if (r.startsWith(RELATION_DENY_PREFIX))
|
|
331
|
+
denied.add(r.slice(RELATION_DENY_PREFIX.length));
|
|
332
|
+
else
|
|
333
|
+
allowed.add(r);
|
|
309
334
|
}
|
|
310
|
-
return
|
|
335
|
+
return { allowed, denied };
|
|
311
336
|
}
|
|
312
337
|
/* ── Escrituras ───────────────────────────────────────────────────────── */
|
|
313
338
|
async relate(subject, relation, object, partition, options) {
|
|
@@ -321,14 +346,88 @@ export class DatabaseRelationsDriver {
|
|
|
321
346
|
this.#assertId(`la relación de '${object.type}'`, relation, RELATION_ID_MAX);
|
|
322
347
|
// R-15 (defensa en profundidad; el manager ya lo validó): los tres estados.
|
|
323
348
|
assertExpiresAt(options?.expiresAt);
|
|
324
|
-
|
|
349
|
+
await this.#upsert('relate', subject, relation, object, partition, options?.expiresAt, options);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* **`deny` = la tupla `denied_<rel>`, por el MISMO camino idempotente que `relate`** (lote D · D1): F-05 sobre el
|
|
353
|
+
* nombre PÚBLICO, la puerta de escritura del opt-in (fase `'write'`, D10), ni `group` ni `*`, y SIN caducidad (D3:
|
|
354
|
+
* `expiresAt` ⇒ 422; un deny escrito a mano con caducidad se rehace permanente). Por la transacción del llamante si
|
|
355
|
+
* llega (L-4, como `relate`).
|
|
356
|
+
*/
|
|
357
|
+
async deny(subject, relation, object, partition, options) {
|
|
358
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
359
|
+
assertRelationDenyable(this.#config, object, relation);
|
|
360
|
+
assertDenyNoExpiry(options);
|
|
361
|
+
assertPartition(partition);
|
|
362
|
+
this.#assertObject(object);
|
|
363
|
+
assertDenySubject(subject);
|
|
364
|
+
await this.#upsert('deny', subject, deniedRelationOf(relation), object, partition, null, options);
|
|
365
|
+
}
|
|
366
|
+
/** `removeDeny`: borra la tupla `denied_<rel>`. Sobre CUALQUIER tipo declarado (D8/R2-4 b). No-op si no está. */
|
|
367
|
+
async removeDeny(subject, relation, object, partition, options) {
|
|
368
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
369
|
+
assertDenyNoExpiry(options);
|
|
370
|
+
assertPartition(partition);
|
|
371
|
+
this.#assertObject(object);
|
|
372
|
+
await this.#deleteTuple('removeDeny', subject, deniedRelationOf(relation), object, partition, options);
|
|
373
|
+
}
|
|
374
|
+
/**
|
|
375
|
+
* **El techo de anidación de grupos de ESTE driver** (lote «antes de RC» · R1, decisión del dueño 2026-10-17 (5)):
|
|
376
|
+
* `database` no tiene techo técnico (la CTE deduplica y termina con ciclos), así que es `null` SALVO el opt-in
|
|
377
|
+
* explícito `database.enforceGroupDepth: true`, que aplica el `maxGroupDepth` de la config (obligatorio y numérico
|
|
378
|
+
* con el opt-in: lo valida `defineRelationsConfig` al arrancar).
|
|
379
|
+
*/
|
|
380
|
+
async groupDepthLimit() {
|
|
381
|
+
return this.#groupDepthLimit();
|
|
382
|
+
}
|
|
383
|
+
#groupDepthLimit() {
|
|
384
|
+
const { maxGroupDepth, enforceGroupDepth } = assertGroupDepthSettings(this.#config.maxGroupDepth, this.#config.enforceGroupDepth, 'DatabaseRelationsDriver');
|
|
385
|
+
return enforceGroupDepth && typeof maxGroupDepth === 'number' ? maxGroupDepth : null;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* **El gate de R1 en `database`** (opt-in): la arista `group:<parent>#member@group:<child>#member` que se va a escribir
|
|
389
|
+
* VIVA no puede cerrar un ciclo ni dejar un camino de grupos anidados más largo que el techo. El grafo se LEE POR EL
|
|
390
|
+
* MISMO CLIENTE QUE ESCRIBE (`trx`: la transacción del llamante con `{ transaction }`, o la interna de la escritura) —
|
|
391
|
+
* T-6 del tester: leído por la conexión del motor, un llamante que en UNA transacción escribe `A⊂B` y luego `B⊂C`
|
|
392
|
+
* pasaría las dos (la segunda no ve la primera, sin confirmar). Lo que esto NO cierra, declarado: dos transacciones
|
|
393
|
+
* CONCURRENTES que escriben cada una media cadena (cada una ve su foto, sin cerrojo) — la carrera entre procesos.
|
|
394
|
+
* Una sentencia por nivel del BFS (y por cada 500 grupos del nivel), con la caducidad ESTRICTA de la lectura.
|
|
395
|
+
*/
|
|
396
|
+
async #assertGroupNesting(trx, operation, partitionKey, edge, now, limit) {
|
|
397
|
+
const neighbours = async (groups, column) => {
|
|
398
|
+
const out = [];
|
|
399
|
+
for (let i = 0; i < groups.length; i += 500) {
|
|
400
|
+
const chunk = groups.slice(i, i + 500);
|
|
401
|
+
const rows = await this.#sql(`${operation}.groupDepth`, () => this.#whereActive(trx
|
|
402
|
+
.from(RELATIONS_TABLE)
|
|
403
|
+
.where('partition_key', partitionKey)
|
|
404
|
+
.where('object_type', GROUP_TYPE)
|
|
405
|
+
.where('relation', GROUP_MEMBER)
|
|
406
|
+
.where('subject_type', GROUP_TYPE)
|
|
407
|
+
.where('subject_relation', GROUP_MEMBER)
|
|
408
|
+
.whereIn(column, chunk), now).select({ child: 'subject_uuid', parent: 'object_uuid' }));
|
|
409
|
+
for (const row of rows)
|
|
410
|
+
out.push({ child: row.child, parent: row.parent });
|
|
411
|
+
}
|
|
412
|
+
return out;
|
|
413
|
+
};
|
|
414
|
+
const verdict = await groupNestingVerdict(edge, limit, {
|
|
415
|
+
parentsOf: (groups) => neighbours(groups, 'subject_uuid'),
|
|
416
|
+
childrenOf: (groups) => neighbours(groups, 'object_uuid'),
|
|
417
|
+
});
|
|
418
|
+
if (verdict.kind !== 'ok') {
|
|
419
|
+
throw groupDepthLimitError(verdict, `group:${edge.parent}#member@group:${edge.child}#member`, limit);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
/** El check-then-delete-insert idempotente de `relate`/`deny` sobre la relación ALMACENADA. */
|
|
423
|
+
async #upsert(operation, subject, relation, object, partition, requested, options) {
|
|
325
424
|
const partitionKey = this.#keyOf(partition);
|
|
326
425
|
const s = this.#subjectColumns(subject, partitionKey);
|
|
327
426
|
const codec = this.#expiry;
|
|
328
427
|
const now = this.#now();
|
|
329
428
|
// L-4: por la transacción del llamante si llegó (`#writer`, juzgada AQUÍ,
|
|
330
429
|
// después de F-05 y la gramática y antes de la primera sentencia).
|
|
331
|
-
const { client, external } = this.#writer(
|
|
430
|
+
const { client, external } = this.#writer(operation, options);
|
|
332
431
|
// El check-then-delete-insert idempotente. Con transacción EXTERNA corre
|
|
333
432
|
// tal cual sobre ella (el trigger de partición dispara en ESE INSERT,
|
|
334
433
|
// dentro de la trx del consumidor; NUNCA se abre una interna: la fila
|
|
@@ -336,7 +435,7 @@ export class DatabaseRelationsDriver {
|
|
|
336
435
|
// una transacción INTERNA de la conexión del driver (la atomicidad
|
|
337
436
|
// trigger+insert de 4-3). Cada sentencia lleva su deadline (`#sql`).
|
|
338
437
|
const write = async (trx) => {
|
|
339
|
-
const existing = await this.#sql(
|
|
438
|
+
const existing = await this.#sql(`${operation}.select`, () => trx
|
|
340
439
|
.from(RELATIONS_TABLE)
|
|
341
440
|
.where('partition_key', partitionKey)
|
|
342
441
|
.where('object_type', object.type)
|
|
@@ -352,17 +451,28 @@ export class DatabaseRelationsDriver {
|
|
|
352
451
|
// Los tres estados (invariante 10): omitido preserva la VIGENTE (una
|
|
353
452
|
// caducada revive sin caducidad), null la quita, Date la fija.
|
|
354
453
|
const expiresAt = resolveGrantExpiry(previous, requested, now);
|
|
454
|
+
// Idempotente (invariante 6): la misma caducidad no reescribe nada — y va ANTES del gate de R1: un `relate`
|
|
455
|
+
// repetido de una arista preexistente (sembrada antes de alpha.4 o con el gate apagado) sigue siendo un no-op.
|
|
456
|
+
if (current && sameInstant(previous, expiresAt))
|
|
457
|
+
return;
|
|
458
|
+
// R1 (opt-in en `database`): una arista de anidación que queda VIVA —nueva, renovada o revivida— pasa el gate,
|
|
459
|
+
// leyendo el grafo por ESTE `trx` (T-6). Una vencida no conecta nada (la misma regla que la lectura). Antes del
|
|
460
|
+
// DELETE: un 422 no deja la arista vieja borrada.
|
|
461
|
+
const limit = this.#groupDepthLimit();
|
|
462
|
+
if (limit !== null &&
|
|
463
|
+
isGroupNesting(subject, relation, object) &&
|
|
464
|
+
isActiveExpiry(expiresAt, now) &&
|
|
465
|
+
!plannedByReconcile(options, partition, { child: subject.object.id, parent: object.id })) {
|
|
466
|
+
await this.#assertGroupNesting(trx, operation, partitionKey, { child: subject.object.id, parent: object.id }, now, limit);
|
|
467
|
+
}
|
|
355
468
|
if (current) {
|
|
356
|
-
// Idempotente (invariante 6): la misma caducidad no reescribe nada.
|
|
357
|
-
if (sameInstant(previous, expiresAt))
|
|
358
|
-
return;
|
|
359
469
|
// **INSERT/DELETE-ONLY (decisión (c))**: cambiar la caducidad es
|
|
360
470
|
// BORRAR la fila e INSERTAR otra —nunca un UPDATE—; la fila nueva
|
|
361
471
|
// tiene otro `uuid`, que es lo que lo hace observable. Dentro de la
|
|
362
472
|
// trx del llamante, el rollback devuelve la fila (y la caducidad) VIEJA.
|
|
363
|
-
await this.#sql(
|
|
473
|
+
await this.#sql(`${operation}.delete`, () => trx.from(RELATIONS_TABLE).where('uuid', current.uuid).delete());
|
|
364
474
|
}
|
|
365
|
-
await this.#sql(
|
|
475
|
+
await this.#sql(`${operation}.insert`, () => trx.table(RELATIONS_TABLE).insert({
|
|
366
476
|
uuid: uuidv7(),
|
|
367
477
|
partition_key: partitionKey,
|
|
368
478
|
object_type: object.type,
|
|
@@ -383,11 +493,11 @@ export class DatabaseRelationsDriver {
|
|
|
383
493
|
await write(client);
|
|
384
494
|
}
|
|
385
495
|
catch (error) {
|
|
386
|
-
this.#poisoned(
|
|
496
|
+
this.#poisoned(operation, error);
|
|
387
497
|
}
|
|
388
498
|
return;
|
|
389
499
|
}
|
|
390
|
-
await this.#sql(
|
|
500
|
+
await this.#sql(operation, () => client.transaction(write));
|
|
391
501
|
}
|
|
392
502
|
async unrelate(subject, relation, object, partition, options) {
|
|
393
503
|
// L-0 · F-05 también al RETIRAR (paridad con `openfga`).
|
|
@@ -395,11 +505,15 @@ export class DatabaseRelationsDriver {
|
|
|
395
505
|
assertPartition(partition);
|
|
396
506
|
this.#assertObject(object);
|
|
397
507
|
this.#assertId(`la relación de '${object.type}'`, relation, RELATION_ID_MAX);
|
|
508
|
+
await this.#deleteTuple('unrelate', subject, relation, object, partition, options);
|
|
509
|
+
}
|
|
510
|
+
/** El DELETE de UNA tupla (relación ALMACENADA): `unrelate` y `removeDeny`. Un `unrelate` nunca toca `denied_*` (D-04). */
|
|
511
|
+
async #deleteTuple(operation, subject, relation, object, partition, options) {
|
|
398
512
|
const partitionKey = this.#keyOf(partition);
|
|
399
513
|
const s = this.#subjectColumns(subject, partitionKey);
|
|
400
|
-
const { client, external } = this.#writer(
|
|
514
|
+
const { client, external } = this.#writer(operation, options);
|
|
401
515
|
try {
|
|
402
|
-
await this.#sql(
|
|
516
|
+
await this.#sql(operation, () => client
|
|
403
517
|
.from(RELATIONS_TABLE)
|
|
404
518
|
.where('partition_key', partitionKey)
|
|
405
519
|
.where('object_type', object.type)
|
|
@@ -412,7 +526,7 @@ export class DatabaseRelationsDriver {
|
|
|
412
526
|
}
|
|
413
527
|
catch (error) {
|
|
414
528
|
if (external)
|
|
415
|
-
this.#poisoned(
|
|
529
|
+
this.#poisoned(operation, error);
|
|
416
530
|
throw error;
|
|
417
531
|
}
|
|
418
532
|
}
|
|
@@ -433,21 +547,25 @@ export class DatabaseRelationsDriver {
|
|
|
433
547
|
this.#assertObject(object);
|
|
434
548
|
const partitionKey = this.#keyOf(partition);
|
|
435
549
|
const s = this.#subjectColumns(subject, partitionKey);
|
|
436
|
-
|
|
550
|
+
// Lote D · D5: los grants de `down*(R)` Y los denies de `up*(down*(R))`, en UNA consulta sobre la misma CTE
|
|
551
|
+
// `principal` (el deny también viaja por `group#member`, anidado, y una membresía caducada no lo transporta);
|
|
552
|
+
// el CORTE se decide en `cutPathAllows`, la función ÚNICA de la config.
|
|
553
|
+
const relations = this.#readRelations(object.type, relation);
|
|
437
554
|
const meta = this.#dialectMeta();
|
|
438
555
|
const q = (name) => `${meta.quote}${name}${meta.quote}`;
|
|
439
556
|
const at = this.#expiry.bind(this.#now());
|
|
440
557
|
const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
|
|
441
558
|
const relPlaceholders = relations.map(() => '?').join(', ');
|
|
442
559
|
const sql = cte +
|
|
443
|
-
` SELECT ${meta.hint}
|
|
560
|
+
` SELECT ${meta.hint}DISTINCT r.${q('relation')} AS ${q('relation')} FROM ${q(RELATIONS_TABLE)} r ` +
|
|
444
561
|
`JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
|
|
445
562
|
`AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
|
|
446
563
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? AND r.${q('object_uuid')} = ? ` +
|
|
447
|
-
`AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')}
|
|
564
|
+
`AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')}`;
|
|
448
565
|
const bindings = [...cteBindings, partitionKey, object.type, object.id, ...relations, at];
|
|
449
566
|
const rows = rowsOf(await this.#raw('check', sql, bindings));
|
|
450
|
-
|
|
567
|
+
const { allowed, denied } = this.#split(rows.map((row) => String(row.relation)));
|
|
568
|
+
return cutPathAllows(this.#config, object.type, relation, allowed, denied);
|
|
451
569
|
}
|
|
452
570
|
/**
|
|
453
571
|
* **`checkMany` = UNA consulta** (plan 2.5 · lote A, P4-8): la MISMA CTE
|
|
@@ -460,7 +578,35 @@ export class DatabaseRelationsDriver {
|
|
|
460
578
|
* de la consulta (defensa en profundidad: el manager ya lo hizo).
|
|
461
579
|
*/
|
|
462
580
|
async checkMany(subject, relation, objects, partition) {
|
|
463
|
-
|
|
581
|
+
const { distinct, index, facts } = await this.#readMany('checkMany', subject, relation, objects, partition);
|
|
582
|
+
const answers = distinct.map((object) => {
|
|
583
|
+
const { allowed, denied } = this.#split(facts.get(`${object.type}:${object.id}`) ?? []);
|
|
584
|
+
return cutPathAllows(this.#config, object.type, relation, allowed, denied);
|
|
585
|
+
});
|
|
586
|
+
return expandByPosition(index, answers);
|
|
587
|
+
}
|
|
588
|
+
/**
|
|
589
|
+
* **`decideMany` = la consulta de `checkMany`** (lote D · D9): los grants de `down*(R)` y los denies de
|
|
590
|
+
* `up*(down*(R))` de cada objeto distinto, con UN `now` y en UNA consulta, y la regla AMPLIA de `decideFrom` (la
|
|
591
|
+
* misma función que el doble y `openfga` cuando alinea su `batchCheck`). Tope, `[]` y F-05 por ítem como `checkMany`.
|
|
592
|
+
*/
|
|
593
|
+
async decideMany(subject, relation, objects, partition) {
|
|
594
|
+
const { distinct, index, facts } = await this.#readMany('decideMany', subject, relation, objects, partition);
|
|
595
|
+
const answers = distinct.map((object) => {
|
|
596
|
+
const { allowed, denied } = this.#split(facts.get(`${object.type}:${object.id}`) ?? []);
|
|
597
|
+
return decideFrom(this.#config, object.type, relation, allowed, denied);
|
|
598
|
+
});
|
|
599
|
+
return index.map((at) => answers[at]);
|
|
600
|
+
}
|
|
601
|
+
/**
|
|
602
|
+
* **`checkMany`/`decideMany` = UNA consulta** (plan 2.5 · lote A, P4-8; lote D): la MISMA CTE `principal` de
|
|
603
|
+
* `check` (el sujeto y sus grupos, transitivo, solo vigentes) y un `SELECT DISTINCT object_type, object_uuid,
|
|
604
|
+
* relation` sobre los hechos de la partición que casan con CUALQUIER objeto distinto del lote —agrupados por TIPO,
|
|
605
|
+
* porque los `includes` se expanden por tipo— con grants Y denies (`#readRelations`), con UN solo `now` para todo
|
|
606
|
+
* el lote. F-05 y la gramática del id, POR ÍTEM, antes de la consulta (defensa en profundidad).
|
|
607
|
+
*/
|
|
608
|
+
async #readMany(operation, subject, relation, objects, partition) {
|
|
609
|
+
assertCheckManyObjects(objects, operation);
|
|
464
610
|
for (const object of objects) {
|
|
465
611
|
assertRelationDeclared(this.#config, object, relation);
|
|
466
612
|
this.#assertObject(object);
|
|
@@ -473,7 +619,7 @@ export class DatabaseRelationsDriver {
|
|
|
473
619
|
const q = (name) => `${meta.quote}${name}${meta.quote}`;
|
|
474
620
|
const at = this.#expiry.bind(this.#now());
|
|
475
621
|
const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
|
|
476
|
-
// Un grupo por TIPO de objeto: `(object_type = ? AND object_uuid IN (…) AND relation IN (<
|
|
622
|
+
// Un grupo por TIPO de objeto: `(object_type = ? AND object_uuid IN (…) AND relation IN (<grants y denies del tipo>))`.
|
|
477
623
|
const byType = new Map();
|
|
478
624
|
for (const object of distinct) {
|
|
479
625
|
const ids = byType.get(object.type) ?? [];
|
|
@@ -483,7 +629,7 @@ export class DatabaseRelationsDriver {
|
|
|
483
629
|
const groups = [];
|
|
484
630
|
const groupBindings = [];
|
|
485
631
|
for (const [type, ids] of byType) {
|
|
486
|
-
const relations = this.#
|
|
632
|
+
const relations = this.#readRelations(type, relation);
|
|
487
633
|
groups.push(`(r.${q('object_type')} = ? AND r.${q('object_uuid')} IN (${ids.map(() => '?').join(', ')}) ` +
|
|
488
634
|
`AND r.${q('relation')} IN (${relations.map(() => '?').join(', ')}))`);
|
|
489
635
|
groupBindings.push(type, ...ids, ...relations);
|
|
@@ -492,52 +638,78 @@ export class DatabaseRelationsDriver {
|
|
|
492
638
|
// ⚪ A8 (auditor del lote A): el hint de MySQL va ANTES de DISTINCT (como en
|
|
493
639
|
// `listObjects`); detrás, el optimizador lo ignora y a > 1000 niveles de
|
|
494
640
|
// anidación `checkMany` era `ERROR 3636` (503) donde `check` respondía.
|
|
495
|
-
` SELECT ${meta.hint}DISTINCT r.${q('object_type')} AS object_type, r.${q('object_uuid')} AS object_uuid ` +
|
|
641
|
+
` SELECT ${meta.hint}DISTINCT r.${q('object_type')} AS object_type, r.${q('object_uuid')} AS object_uuid, ` +
|
|
642
|
+
`r.${q('relation')} AS relation ` +
|
|
496
643
|
`FROM ${q(RELATIONS_TABLE)} r ` +
|
|
497
644
|
`JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
|
|
498
645
|
`AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
|
|
499
646
|
`WHERE r.${q('partition_key')} = ? AND (${groups.join(' OR ')}) AND ${this.#activeSql(q, 'r')}`;
|
|
500
647
|
const bindings = [...cteBindings, partitionKey, ...groupBindings, at];
|
|
501
|
-
const rows = rowsOf(await this.#raw(
|
|
502
|
-
const
|
|
503
|
-
const
|
|
504
|
-
|
|
648
|
+
const rows = rowsOf(await this.#raw(operation, sql, bindings));
|
|
649
|
+
const facts = new Map();
|
|
650
|
+
for (const row of rows) {
|
|
651
|
+
const key = `${row.object_type}:${row.object_uuid}`;
|
|
652
|
+
const list = facts.get(key) ?? [];
|
|
653
|
+
list.push(String(row.relation));
|
|
654
|
+
facts.set(key, list);
|
|
655
|
+
}
|
|
656
|
+
return { distinct, index, facts };
|
|
505
657
|
}
|
|
506
|
-
async listObjects(subject, relation, objectType, partition
|
|
658
|
+
async listObjects(subject, relation, objectType, partition) {
|
|
507
659
|
assertRelationDeclared(this.#config, { type: objectType }, relation);
|
|
508
660
|
assertPartition(partition);
|
|
509
661
|
this.#assertId('el tipo de objeto', objectType, RELATION_ID_MAX);
|
|
662
|
+
// 7.4: sin página (un quinto argumento ≠ undefined ⇒ 500 con cero sentencias), después de F-05 y la partición.
|
|
663
|
+
assertNoListPage('listObjects', arguments[4], 'database');
|
|
510
664
|
const partitionKey = this.#keyOf(partition);
|
|
511
665
|
const s = this.#subjectColumns(subject, partitionKey);
|
|
512
|
-
|
|
666
|
+
// Lote D · D-15: `listObjects` RESTA (la misma regla de corte que `check`, objeto a objeto).
|
|
667
|
+
const relations = this.#readRelations(objectType, relation);
|
|
513
668
|
const meta = this.#dialectMeta();
|
|
514
669
|
const q = (name) => `${meta.quote}${name}${meta.quote}`;
|
|
515
670
|
const at = this.#expiry.bind(this.#now());
|
|
516
671
|
const { cte, bindings: cteBindings } = this.#principalCte(meta, q, s, partitionKey, at);
|
|
517
672
|
const relPlaceholders = relations.map(() => '?').join(', ');
|
|
518
673
|
const sql = cte +
|
|
519
|
-
` SELECT ${meta.hint}DISTINCT r.${q('object_uuid')} AS ${q('object_uuid')}
|
|
674
|
+
` SELECT ${meta.hint}DISTINCT r.${q('object_uuid')} AS ${q('object_uuid')}, r.${q('relation')} AS ${q('relation')} ` +
|
|
675
|
+
`FROM ${q(RELATIONS_TABLE)} r ` +
|
|
520
676
|
`JOIN principal p ON r.${q('subject_type')} = p.p_type AND r.${q('subject_uuid')} = p.p_uuid ` +
|
|
521
677
|
`AND COALESCE(r.${q('subject_relation')}, '') = p.p_rel ` +
|
|
522
678
|
`WHERE r.${q('partition_key')} = ? AND r.${q('object_type')} = ? ` +
|
|
523
679
|
`AND r.${q('relation')} IN (${relPlaceholders}) AND ${this.#activeSql(q, 'r')}`;
|
|
524
680
|
const bindings = [...cteBindings, partitionKey, objectType, ...relations, at];
|
|
525
681
|
const rows = rowsOf(await this.#raw('listObjects', sql, bindings));
|
|
526
|
-
const
|
|
527
|
-
|
|
682
|
+
const byObject = new Map();
|
|
683
|
+
for (const row of rows) {
|
|
684
|
+
const id = String(row.object_uuid);
|
|
685
|
+
const list = byObject.get(id) ?? [];
|
|
686
|
+
list.push(String(row.relation));
|
|
687
|
+
byObject.set(id, list);
|
|
688
|
+
}
|
|
689
|
+
const objects = [...byObject]
|
|
690
|
+
.filter(([, rels]) => {
|
|
691
|
+
const { allowed, denied } = this.#split(rels);
|
|
692
|
+
return cutPathAllows(this.#config, objectType, relation, allowed, denied);
|
|
693
|
+
})
|
|
694
|
+
.map(([id]) => ({ type: objectType, id }))
|
|
528
695
|
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
|
|
529
696
|
return { objects };
|
|
530
697
|
}
|
|
531
|
-
async listSubjects(relation, object, partition
|
|
698
|
+
async listSubjects(relation, object, partition) {
|
|
532
699
|
assertRelationDeclared(this.#config, object, relation);
|
|
533
700
|
assertPartition(partition);
|
|
534
701
|
this.#assertObject(object);
|
|
702
|
+
assertNoListPage('listSubjects', arguments[3], 'database');
|
|
703
|
+
return this.#directSubjects('listSubjects', relation, object, partition);
|
|
704
|
+
}
|
|
705
|
+
/** Los sujetos DIRECTOS vigentes de UNA relación almacenada (`listSubjects`; `listDenies` con `denied_<rel>`). */
|
|
706
|
+
async #directSubjects(operation, relation, object, partition) {
|
|
535
707
|
const partitionKey = this.#keyOf(partition);
|
|
536
708
|
// DIRECTOS del relation EXACTO (invariante 7): ni transitivo (eso es
|
|
537
709
|
// `membersOf`) ni derivado por includes. `usersetSubjects: true` ⇒ los
|
|
538
710
|
// usersets (`group#member`) salen junto con los holders. Solo VIGENTES (R-15).
|
|
539
711
|
const now = this.#now();
|
|
540
|
-
const rows = await this.#sql(
|
|
712
|
+
const rows = await this.#sql(operation, () => this.#whereActive(this.#connection()
|
|
541
713
|
.from(RELATIONS_TABLE)
|
|
542
714
|
.where('partition_key', partitionKey)
|
|
543
715
|
.where('object_type', object.type)
|
|
@@ -546,6 +718,27 @@ export class DatabaseRelationsDriver {
|
|
|
546
718
|
const subjects = rows.map((row) => this.#rowToSubject(row));
|
|
547
719
|
return { subjects };
|
|
548
720
|
}
|
|
721
|
+
/**
|
|
722
|
+
* **`listDenies`** (lote D · D1): los sujetos con deny DIRECTO de `relation` sobre `object` —hechos, como
|
|
723
|
+
* `listSubjects` sobre la relación almacenada `denied_<rel>`; no resta ni deriva—. Sobre CUALQUIER tipo declarado
|
|
724
|
+
* (D8/R2-4 b: con el opt-in retirado, los denies vivos se siguen pudiendo listar y borrar).
|
|
725
|
+
*/
|
|
726
|
+
async listDenies(relation, object, partition) {
|
|
727
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
728
|
+
assertPartition(partition);
|
|
729
|
+
this.#assertObject(object);
|
|
730
|
+
assertNoListPage('listDenies', arguments[3], 'database');
|
|
731
|
+
return this.#directSubjects('listDenies', deniedRelationOf(relation), object, partition);
|
|
732
|
+
}
|
|
733
|
+
/**
|
|
734
|
+
* **`denyPhase` — ¿admite este driver escrituras de deny en el tipo?** (lote D · D2, R2-5): la fase del opt-in EN
|
|
735
|
+
* LA CONFIG DE ESTE DRIVER (`'write'`, `'read'` o `null`). F-05 estricto del par (422), cero sentencias: es
|
|
736
|
+
* configuración. La llama `reconcileRelations` en la fase de plan cuando el origen trae denies.
|
|
737
|
+
*/
|
|
738
|
+
async denyPhase(object, relation) {
|
|
739
|
+
assertRelationDeclared(this.#config, object, relation);
|
|
740
|
+
return denyPhaseOf(this.#config, object.type) ?? null;
|
|
741
|
+
}
|
|
549
742
|
/**
|
|
550
743
|
* Membresía TRANSITIVA de un objeto-grupo: todos los HOLDERS que son `member`
|
|
551
744
|
* directa o a través de grupos anidados (usersets `group#member`). CTE
|
|
@@ -553,12 +746,13 @@ export class DatabaseRelationsDriver {
|
|
|
553
746
|
* directos): en un grupo con `member(g1#member, g2)`, `membersOf(g2)` trae a
|
|
554
747
|
* los holders de g1 y `listSubjects(member, g2)` solo el userset `g1#member`.
|
|
555
748
|
*/
|
|
556
|
-
async membersOf(object, relation, partition
|
|
749
|
+
async membersOf(object, relation, partition) {
|
|
557
750
|
// Cierre-2 · 🟡 3: F-05 (par, ESTRICTA) también en `membersOf`, la octava
|
|
558
751
|
// operación del puerto; antes solo `#assertObject` (gramática).
|
|
559
752
|
assertRelationDeclared(this.#config, object, relation);
|
|
560
753
|
assertPartition(partition);
|
|
561
754
|
this.#assertObject(object);
|
|
755
|
+
assertNoListPage('membersOf', arguments[3], 'database');
|
|
562
756
|
const partitionKey = this.#keyOf(partition);
|
|
563
757
|
const relations = this.#expandDown(object.type, relation);
|
|
564
758
|
const meta = this.#dialectMeta();
|
|
@@ -604,7 +798,45 @@ export class DatabaseRelationsDriver {
|
|
|
604
798
|
partitionKey, at,
|
|
605
799
|
];
|
|
606
800
|
const rows = rowsOf(await this.#raw('membersOf', sql, bindings));
|
|
607
|
-
const
|
|
801
|
+
const candidates = rows.map((row) => ({ type: String(row.subject_type), uuid: String(row.subject_uuid) }));
|
|
802
|
+
// **Lote D · `membersOf` RESTA** (juez ronda 2: «listObjects y membersOf restan»; D8: siempre, con o sin opt-in).
|
|
803
|
+
// Si el objeto tiene alguna fila `denied_*` vigente, cada candidato se juzga con `check` —el MISMO corte de camino
|
|
804
|
+
// que el resto de lecturas, un `check` por candidato—; sin filas deny, cero consultas de más (la respuesta y el coste
|
|
805
|
+
// de 2.4.0-alpha.10). Coste declarado: O(miembros) consultas cuando el objeto tiene denies.
|
|
806
|
+
const denied = await this.#sql('membersOf.denies', () => this.#whereActive(this.#connection()
|
|
807
|
+
.from(RELATIONS_TABLE)
|
|
808
|
+
.where('partition_key', partitionKey)
|
|
809
|
+
.where('object_type', object.type)
|
|
810
|
+
.where('object_uuid', object.id)
|
|
811
|
+
.whereIn('relation', deniableAbove(this.#config, object.type, relation).map(deniedRelationOf)), this.#now())
|
|
812
|
+
.select('uuid')
|
|
813
|
+
.limit(1));
|
|
814
|
+
if (denied.length === 0)
|
|
815
|
+
return { subjects: candidates };
|
|
816
|
+
// **Cierre 1 de D1 · 🟡 4 del auditor**: con deny, N `check` en serie sin cota ni deadline total era una
|
|
817
|
+
// amplificación barata para quien puede escribir un deny (disponibilidad, no concesión). Cota DECLARADA
|
|
818
|
+
// (`RELATIONS_MEMBERS_DENY_CHECK_MAX`, «Limits» del README) ⇒ 500 ANTES del primer `check`, nunca una lista parcial;
|
|
819
|
+
// y deadline TOTAL sobre el bucle (`timeoutMs` del driver) ⇒ 503 `E_AUTHZ_BACKEND_TIMEOUT`, el bucle se para.
|
|
820
|
+
if (candidates.length > RELATIONS_MEMBERS_DENY_CHECK_MAX) {
|
|
821
|
+
throw new MembersOfDenyLimitError(`membersOf(${object.type}, '${relation}'): el objeto tiene un deny vigente y ${candidates.length} candidatos; con deny ` +
|
|
822
|
+
`cada candidato se juzga con un check y la cota es ${RELATIONS_MEMBERS_DENY_CHECK_MAX} ` +
|
|
823
|
+
`(RELATIONS_MEMBERS_DENY_CHECK_MAX). No se devuelve una lista parcial: pregunta check por sujeto, o retira el deny.`);
|
|
824
|
+
}
|
|
825
|
+
let expired = false;
|
|
826
|
+
const loop = (async () => {
|
|
827
|
+
const subjects = [];
|
|
828
|
+
for (const candidate of candidates) {
|
|
829
|
+
if (expired)
|
|
830
|
+
break;
|
|
831
|
+
if (await this.check(candidate, relation, object, partition))
|
|
832
|
+
subjects.push(candidate);
|
|
833
|
+
}
|
|
834
|
+
return subjects;
|
|
835
|
+
})();
|
|
836
|
+
const subjects = await withDeadline(loop, this.#timeoutMs, () => {
|
|
837
|
+
expired = true;
|
|
838
|
+
return new AuthorizationBackendTimeoutError('database-relations', 'membersOf', this.#timeoutMs);
|
|
839
|
+
});
|
|
608
840
|
return { subjects };
|
|
609
841
|
}
|
|
610
842
|
/* ── Purga (invariante 11): el DELETE demuestra el cero ───────────────── */
|
|
@@ -680,9 +912,12 @@ export class DatabaseRelationsDriver {
|
|
|
680
912
|
*/
|
|
681
913
|
async enumerateRelations(partition, page) {
|
|
682
914
|
assertPartition(partition);
|
|
915
|
+
// 7.4 (injerto 4, H2/H3): limit entero ≥ 1 y after = un uuid canónico (el cursor que ESTE driver emite) o 422,
|
|
916
|
+
// con cero sentencias; un limit por encima del techo se recorta en #enumerateRows.
|
|
917
|
+
const checked = assertEnumeratePage('enumerateRelations', page, 'uuid', 'database');
|
|
683
918
|
const wanted = new Map();
|
|
684
919
|
wanted.set(partitionKey(partition), partition);
|
|
685
|
-
return this.#enumerateRows('enumerateRelations', wanted,
|
|
920
|
+
return this.#enumerateRows('enumerateRelations', wanted, checked);
|
|
686
921
|
}
|
|
687
922
|
/**
|
|
688
923
|
* **`enumerateRelationsMany` — K particiones en UNA consulta** (2.4.0-alpha.8
|
|
@@ -711,7 +946,8 @@ export class DatabaseRelationsDriver {
|
|
|
711
946
|
if (!wanted.has(key))
|
|
712
947
|
wanted.set(key, partition);
|
|
713
948
|
}
|
|
714
|
-
|
|
949
|
+
const checked = assertEnumeratePage('enumerateRelationsMany', page, 'uuid', 'database');
|
|
950
|
+
return this.#enumerateRows('enumerateRelationsMany', wanted, checked);
|
|
715
951
|
}
|
|
716
952
|
/**
|
|
717
953
|
* La consulta de `enumerateRelations`/`enumerateRelationsMany`: los hechos
|
|
@@ -723,7 +959,8 @@ export class DatabaseRelationsDriver {
|
|
|
723
959
|
async #enumerateRows(operation, wanted, page) {
|
|
724
960
|
const partitionKeys = [...wanted.keys()];
|
|
725
961
|
const declaredTypes = [GROUP_TYPE, ...this.#config.objectTypes.map((t) => t.type)];
|
|
726
|
-
|
|
962
|
+
// La COPIA validada en la entrada (assertEnumeratePage), no la página del llamante: aquí solo el default y el RECORTE al techo, declarado en «Limits».
|
|
963
|
+
const limit = enumerateLimit(page);
|
|
727
964
|
const after = page?.after;
|
|
728
965
|
const rows = await this.#sql(operation, () => {
|
|
729
966
|
let q = this.#connection()
|
|
@@ -740,7 +977,9 @@ export class DatabaseRelationsDriver {
|
|
|
740
977
|
const pageRows = hasMore ? rows.slice(0, limit) : rows;
|
|
741
978
|
const tuples = pageRows.map((row) => ({
|
|
742
979
|
subject: this.#rowToSubject(row),
|
|
743
|
-
relation:
|
|
980
|
+
// Lote D · D2: `denied_<rel>` sale como `{ relation: <rel>, kind: 'deny' }` (punto de paso único,
|
|
981
|
+
// `storedRelationKind`); hasta D2 salía la relación almacenada y el reconcile la contaba como no declarada.
|
|
982
|
+
...storedRelationKind(String(row.relation)),
|
|
744
983
|
object: { type: String(row.object_type), id: String(row.object_uuid) },
|
|
745
984
|
// La partición canónica PEDIDA: el barrido de ortografías ya la unificó.
|
|
746
985
|
partition: wanted.get(String(row.partition_key)),
|
|
@@ -793,4 +1032,3 @@ function rowsOf(result) {
|
|
|
793
1032
|
}
|
|
794
1033
|
return result?.rows ?? [];
|
|
795
1034
|
}
|
|
796
|
-
//# sourceMappingURL=database_relations_driver.js.map
|