@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
|
@@ -16,8 +16,9 @@
|
|
|
16
16
|
* (`../identity.js`); JAMÁS de `../manager` ni de `../drivers/*`. Es lo que
|
|
17
17
|
* mantiene `relations/` desacoplado de `roles/`.
|
|
18
18
|
*/
|
|
19
|
-
import { RelationConfigError, RelationConfigUnknownKeyError, RelationTypeUnknownError, RelationUnknownError, RelationUsersetUnsupportedError, } from '../errors.js';
|
|
19
|
+
import { RelationConfigError, RelationConfigUnknownKeyError, RelationNotDenyableError, RelationDenyExpiryError, RelationTypeUnknownError, RelationUnknownError, RelationUsersetUnsupportedError, } from '../errors.js';
|
|
20
20
|
import { RESERVED_FACTS_TYPES, RESERVED_SLUGS, RESERVED_SLUG_PREFIXES } from '../identity.js';
|
|
21
|
+
import { assertGroupDepthSettings } from './group_depth.js';
|
|
21
22
|
/**
|
|
22
23
|
* **El ÚNICO userset que el modelo admite como sujeto** (plan 2.5 · lote B · B3):
|
|
23
24
|
* `group#member`. El generador del modelo fusionado emite, para CADA relación de
|
|
@@ -28,6 +29,21 @@ export const RELATION_USERSET_TYPE = 'group';
|
|
|
28
29
|
export const RELATION_USERSET_RELATION = 'member';
|
|
29
30
|
/** La cota de FGA para el nombre de una relación (50). Un tipo de objeto llega a 254. */
|
|
30
31
|
const RELATION_NAME_MAX = 50;
|
|
32
|
+
/**
|
|
33
|
+
* **El prefijo de la relación ALMACENADA de un deny** (plan 2.5 · lote D · D1): `deny(u, viewer, doc)` escribe la
|
|
34
|
+
* tupla `doc#denied_viewer@u` —en `authz_relations` (`database`) y en el store (`openfga`)—. Es el MISMO prefijo que
|
|
35
|
+
* la familia del deny de roles (`FACTS_DENIED_PREFIX`) y está en `RESERVED_SLUG_PREFIXES`: ninguna relación de dominio
|
|
36
|
+
* puede empezar por él (⚪4), así que el nombre público y el almacenado nunca colisionan.
|
|
37
|
+
*/
|
|
38
|
+
export const RELATION_DENY_PREFIX = 'denied_';
|
|
39
|
+
/**
|
|
40
|
+
* **La cota de una relación DENEGABLE** (D1, juez ronda 2 · fila D: «≤ 43 caracteres»): `denied_<rel>` tiene que caber
|
|
41
|
+
* en los 50 de FGA (y en la columna `relation` de `authz_relations`), así que una relación de un tipo con `deny`
|
|
42
|
+
* mide como mucho `50 − 'denied_'.length` = 43. DERIVADA, como `MAX_SLUG_LENGTH`. En un tipo SIN `deny` la cota sigue
|
|
43
|
+
* siendo 50 (D-18: una cota global sería una ruptura para quien no usa el deny).
|
|
44
|
+
*/
|
|
45
|
+
export const DENIABLE_RELATION_NAME_MAX = RELATION_NAME_MAX - RELATION_DENY_PREFIX.length;
|
|
46
|
+
const DENY_PHASES = ['read', 'write'];
|
|
31
47
|
const OBJECT_TYPE_MAX = 254;
|
|
32
48
|
/** Nombres FGA: minúsculas, dígitos y `._-`; sin `:`/`#`/`@`/espacios ni `|`. */
|
|
33
49
|
const FGA_NAME_FORMAT = /^[a-z0-9][a-z0-9._-]*$/;
|
|
@@ -43,10 +59,10 @@ const FGA_NAME_FORMAT = /^[a-z0-9][a-z0-9._-]*$/;
|
|
|
43
59
|
* Las claves del lote D entran AQUÍ, en el mismo commit que su efecto.
|
|
44
60
|
*/
|
|
45
61
|
export const RELATIONS_SPEC_KEYS = Object.freeze({
|
|
46
|
-
root: Object.freeze(['objectTypes', 'holderTypes', 'database']),
|
|
47
|
-
objectType: Object.freeze(['type', 'relations']),
|
|
62
|
+
root: Object.freeze(['objectTypes', 'holderTypes', 'database', 'maxGroupDepth']),
|
|
63
|
+
objectType: Object.freeze(['type', 'relations', 'deny']),
|
|
48
64
|
relation: Object.freeze(['name', 'includes']),
|
|
49
|
-
database: Object.freeze(['membersOf']),
|
|
65
|
+
database: Object.freeze(['membersOf', 'enforceGroupDepth']),
|
|
50
66
|
});
|
|
51
67
|
const _exhaustiveRelationsRoot = {};
|
|
52
68
|
const _exhaustiveRelationsObjectType = {};
|
|
@@ -371,6 +387,23 @@ export function defineRelationsConfig(spec) {
|
|
|
371
387
|
}
|
|
372
388
|
}
|
|
373
389
|
}
|
|
390
|
+
// D1 · D10: el opt-in del deny, sus dos fases y la cota de 43 (D-18).
|
|
391
|
+
if (objectType.deny !== undefined) {
|
|
392
|
+
if (!DENY_PHASES.includes(objectType.deny)) {
|
|
393
|
+
reject(`Config de relaciones inválida: 'objectTypes[${typeIndex}].deny' de '${type}' es ${JSON.stringify(objectType.deny)}; ` +
|
|
394
|
+
`los valores son 'read' (fase 1: el modelo declara denied_<rel> y las lecturas lo honran; deny() sigue siendo 422) ` +
|
|
395
|
+
`y 'write' (fase 2: además se aceptan escrituras). No hay 'true': un interruptor único no tiene secuencia de ` +
|
|
396
|
+
`despliegue segura (un pod con el modelo viejo ignora el deny que otro escribe) — publica 'read', despliega la ` +
|
|
397
|
+
`flota entera con el modelId nuevo y solo entonces, en otro despliegue, 'write'.`);
|
|
398
|
+
}
|
|
399
|
+
for (const relation of objectType.relations) {
|
|
400
|
+
if (relation.name.length > DENIABLE_RELATION_NAME_MAX) {
|
|
401
|
+
reject(`Config de relaciones inválida: la relación '${type}#${relation.name}' tiene ${relation.name.length} caracteres y el ` +
|
|
402
|
+
`tipo declara deny: su relación almacenada '${RELATION_DENY_PREFIX}${relation.name}' no cabe en los ` +
|
|
403
|
+
`${RELATION_NAME_MAX} de FGA. Una relación de un tipo denegable mide como mucho ${DENIABLE_RELATION_NAME_MAX}.`);
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
}
|
|
374
407
|
// B1: ningún ciclo en el grafo de includes del tipo (directo, de 2, de N).
|
|
375
408
|
const cycle = findIncludesCycle(objectType.relations);
|
|
376
409
|
if (cycle) {
|
|
@@ -393,13 +426,179 @@ export function defineRelationsConfig(spec) {
|
|
|
393
426
|
const frozenTypes = spec.objectTypes.map((objectType) => {
|
|
394
427
|
const relations = objectType.relations.map((relation) => Object.freeze({ name: relation.name, includes: relation.includes ? Object.freeze([...relation.includes]) : undefined }));
|
|
395
428
|
declared.set(objectType.type, new Set(relations.map((r) => r.name)));
|
|
396
|
-
|
|
429
|
+
// D-29: un tipo SIN deny se congela con EXACTAMENTE las dos claves de 2.4.0-alpha.10 (el generador del modelo lo
|
|
430
|
+
// emite byte a byte igual); `deny` solo aparece cuando se declaró.
|
|
431
|
+
return objectType.deny === undefined
|
|
432
|
+
? Object.freeze({ type: objectType.type, relations: Object.freeze(relations) })
|
|
433
|
+
: Object.freeze({ type: objectType.type, relations: Object.freeze(relations), deny: objectType.deny });
|
|
397
434
|
});
|
|
435
|
+
// R1 (decisión del dueño 2026-10-17 (5); V2-1 del auditor): el techo se valida AL ARRANCAR, 500 y no 422 — un valor
|
|
436
|
+
// así no es una pregunta mal hecha, es un despliegue que apagaría el límite en silencio.
|
|
437
|
+
const { maxGroupDepth, enforceGroupDepth } = assertGroupDepthSettings(spec.maxGroupDepth, spec.database?.enforceGroupDepth);
|
|
398
438
|
return Object.freeze({
|
|
399
439
|
objectTypes: Object.freeze(frozenTypes),
|
|
400
440
|
membersOf: spec.database?.membersOf === true,
|
|
441
|
+
...(maxGroupDepth === undefined ? {} : { maxGroupDepth }),
|
|
442
|
+
...(enforceGroupDepth === true ? { enforceGroupDepth: true } : {}),
|
|
401
443
|
hasType: (objectType) => declared.has(objectType),
|
|
402
444
|
isDeclared: (objectType, relation) => declared.get(objectType)?.has(relation) ?? false,
|
|
403
445
|
});
|
|
404
446
|
}
|
|
405
|
-
|
|
447
|
+
/* ── Deny (plan 2.5 · lote D · D1): la fase del opt-in y los cierres del grafo de `includes` ─────────────────── */
|
|
448
|
+
/** La fase del opt-in del deny de un tipo (`undefined` = sin opt-in; `group` nunca). */
|
|
449
|
+
export function denyPhaseOf(config, objectType) {
|
|
450
|
+
return config.objectTypes.find((t) => t.type === objectType)?.deny;
|
|
451
|
+
}
|
|
452
|
+
/** ¿Algún tipo de la config declara el opt-in (en cualquier fase)? Decide D14 (`modelId` fijado obligatorio en `openfga`). */
|
|
453
|
+
export function hasDenyTypes(config) {
|
|
454
|
+
return config.objectTypes.some((t) => t.deny !== undefined);
|
|
455
|
+
}
|
|
456
|
+
/** La relación ALMACENADA del deny de `relation` (`viewer` ⇒ `denied_viewer`). */
|
|
457
|
+
export function deniedRelationOf(relation) {
|
|
458
|
+
return `${RELATION_DENY_PREFIX}${relation}`;
|
|
459
|
+
}
|
|
460
|
+
/**
|
|
461
|
+
* **La consulta del README §Upgrading que cuenta los denies VIVOS de relaciones** (plan 2.5 · lote D · D2; D12/S17: no
|
|
462
|
+
* hay rollback por debajo de 2.5 con denies vivos — antes de volver atrás tiene que dar 0). Portable a los tres motores
|
|
463
|
+
* (`substr` existe en SQLite, PostgreSQL y MySQL; `LIKE 'denied\_%'` no: SQLite no tiene escape por defecto). La ejecutan
|
|
464
|
+
* `tests/upgrade_recipe.spec.ts` (los cuatro modos, contra el README byte a byte) y el paso 21 del runbook.
|
|
465
|
+
*/
|
|
466
|
+
export const RELATION_DENIES_COUNT_SQL = "SELECT count(*) AS n FROM authz_relations WHERE substr(relation, 1, 7) = 'denied_';";
|
|
467
|
+
/**
|
|
468
|
+
* **La relación ALMACENADA, leída de vuelta: nombre público + `kind`** (plan 2.5 · lote D · D2; el reconcile con
|
|
469
|
+
* `kind`). Es el PUNTO DE PASO ÚNICO de `enumerateRelations(Many)` en los dos drivers y en el doble: `denied_viewer`
|
|
470
|
+
* ⇒ `{ relation: 'viewer', kind: 'deny' }`; cualquier otra ⇒ `{ relation, kind: 'allow' }`. Inequívoco porque el
|
|
471
|
+
* prefijo está en `RESERVED_SLUG_PREFIXES` (ninguna relación de dominio puede empezar por `denied_`). Hasta D2 los
|
|
472
|
+
* drivers emitían `relation: 'denied_viewer'` sin `kind`, y `reconcileRelations` lo contaba como relación NO
|
|
473
|
+
* declarada (`skipped.undeclared`): una migración `database → openfga` no llevaba los denies.
|
|
474
|
+
*/
|
|
475
|
+
export function storedRelationKind(stored) {
|
|
476
|
+
return typeof stored === 'string' && stored.startsWith(RELATION_DENY_PREFIX) && stored.length > RELATION_DENY_PREFIX.length
|
|
477
|
+
? { relation: stored.slice(RELATION_DENY_PREFIX.length), kind: 'deny' }
|
|
478
|
+
: { relation: stored, kind: 'allow' };
|
|
479
|
+
}
|
|
480
|
+
/**
|
|
481
|
+
* **`down*(R)`: las relaciones cuyo hecho DIRECTO satisface `R`** (R y todo lo que incluye, transitivamente). Es el
|
|
482
|
+
* PUNTO DE PASO ÚNICO del grafo de `includes` hacia abajo (lote D: antes lo reimplementaban `#expandDown` del driver
|
|
483
|
+
* `database` y el doble; con el deny hacen falta las dos direcciones en tres sitios y, calculadas tres veces,
|
|
484
|
+
* divergen). En el retículo del contrato: `down*(viewer) = {viewer, editor, owner}`.
|
|
485
|
+
*/
|
|
486
|
+
export function relationsDown(config, objectType, relation) {
|
|
487
|
+
const out = new Set([relation]);
|
|
488
|
+
const type = config.objectTypes.find((t) => t.type === objectType);
|
|
489
|
+
const queue = [relation];
|
|
490
|
+
while (queue.length) {
|
|
491
|
+
const current = queue.pop();
|
|
492
|
+
const def = type?.relations.find((r) => r.name === current);
|
|
493
|
+
for (const inc of def?.includes ?? []) {
|
|
494
|
+
if (!out.has(inc)) {
|
|
495
|
+
out.add(inc);
|
|
496
|
+
queue.push(inc);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
return [...out];
|
|
501
|
+
}
|
|
502
|
+
/**
|
|
503
|
+
* **`up*(R)`: las relaciones que INCLUYEN a `R`** (R y todo lo que la incluye, transitivamente). Un deny en cualquiera
|
|
504
|
+
* de ellas CORTA un grant de `R` (D5, corte de camino): `up*(owner) = {owner, editor, viewer}` ⇒ un deny en `editor`
|
|
505
|
+
* anula entero un grant de `owner`.
|
|
506
|
+
*/
|
|
507
|
+
export function relationsUp(config, objectType, relation) {
|
|
508
|
+
const out = new Set([relation]);
|
|
509
|
+
const type = config.objectTypes.find((t) => t.type === objectType);
|
|
510
|
+
const queue = [relation];
|
|
511
|
+
while (queue.length) {
|
|
512
|
+
const current = queue.pop();
|
|
513
|
+
for (const candidate of type?.relations ?? []) {
|
|
514
|
+
if ((candidate.includes ?? []).includes(current) && !out.has(candidate.name)) {
|
|
515
|
+
out.add(candidate.name);
|
|
516
|
+
queue.push(candidate.name);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
return [...out];
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* **D5 · CORTE DE CAMINO, la regla ÚNICA** (decisión del dueño 2026-10-14; tester §2.1, la tabla T1 de 64 estados):
|
|
524
|
+
* `check(s, Q, o) = T` sii existe un grant `T ∈ A ∩ down*(Q)` tal que `up*(T) ∩ D = ∅`, con `A` = las relaciones con
|
|
525
|
+
* allow vivo que alcanzan al sujeto (directo o por `group#member`, anidado) y `D` = las relaciones con deny que lo
|
|
526
|
+
* alcanzan igual. Es EXACTAMENTE lo que el modelo FGA evalúa en el servidor (`X = (this ∨ I…) but not denied_X`,
|
|
527
|
+
* `denied_X = this ∨ denied_P` por cada `P ⊇ X`), y es la función que usan el driver `database`, el doble y el
|
|
528
|
+
* oráculo de la suite: tres copias divergirían (L8).
|
|
529
|
+
*/
|
|
530
|
+
export function cutPathAllows(config, objectType, relation, allowed, denied) {
|
|
531
|
+
for (const granted of relationsDown(config, objectType, relation)) {
|
|
532
|
+
if (!allowed.has(granted))
|
|
533
|
+
continue;
|
|
534
|
+
if (relationsUp(config, objectType, granted).every((above) => !denied.has(above)))
|
|
535
|
+
return true;
|
|
536
|
+
}
|
|
537
|
+
return false;
|
|
538
|
+
}
|
|
539
|
+
/** Las relaciones del deny que `decideMany`/`check` tienen que leer para `R`: `up*(down*(R))` (D9, definición AMPLIA). */
|
|
540
|
+
export function deniableAbove(config, objectType, relation) {
|
|
541
|
+
const out = new Set();
|
|
542
|
+
for (const granted of relationsDown(config, objectType, relation)) {
|
|
543
|
+
for (const above of relationsUp(config, objectType, granted))
|
|
544
|
+
out.add(above);
|
|
545
|
+
}
|
|
546
|
+
return [...out];
|
|
547
|
+
}
|
|
548
|
+
/**
|
|
549
|
+
* **D9 · `decideMany` AMPLIO, la regla ÚNICA** (decisión del dueño 2026-10-14; juez ronda 2 §4): `allow` si `check`
|
|
550
|
+
* concede (el deny ya está aplicado dentro del objeto por corte de camino); si no, `deny` si el sujeto tiene un deny
|
|
551
|
+
* vivo sobre ALGUNA relación de `up*(down*(R))` en el objeto; si no, `none`. Más amplia que `check` a propósito
|
|
552
|
+
* (fail-closed en la cadena del llamante): un `viewer` heredado con un deny(`editor`) en el hijo da `deny`.
|
|
553
|
+
*/
|
|
554
|
+
export function decideFrom(config, objectType, relation, allowed, denied) {
|
|
555
|
+
if (cutPathAllows(config, objectType, relation, allowed, denied))
|
|
556
|
+
return 'allow';
|
|
557
|
+
return deniableAbove(config, objectType, relation).some((r) => denied.has(r)) ? 'deny' : 'none';
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* **La puerta de ESCRITURA del deny** (D10/D-17): `deny()` solo entra sobre un tipo en fase `'write'`; sin opt-in, en
|
|
561
|
+
* `'read'` o sobre `group` ⇒ 422 `E_AUTHZ_RELATION_NOT_DENYABLE`. La aplican el manager, los dos drivers y el doble,
|
|
562
|
+
* DESPUÉS de F-05 (un tipo no declarado sigue siendo `E_AUTHZ_RELATION_TYPE_UNKNOWN`). `removeDeny`/`listDenies` NO
|
|
563
|
+
* pasan por aquí: aceptan cualquier tipo declarado (D8/R2-4 b: retirar el opt-in tiene que ser reversible).
|
|
564
|
+
*/
|
|
565
|
+
export function assertRelationDenyable(config, object, relation) {
|
|
566
|
+
const phase = denyPhaseOf(config, object.type);
|
|
567
|
+
if (phase === 'write')
|
|
568
|
+
return;
|
|
569
|
+
const why = object.type === RELATION_USERSET_TYPE
|
|
570
|
+
? `'${RELATION_USERSET_TYPE}' es el tipo built-in de los usersets: un deny sobre la pertenencia a un grupo no existe (para ` +
|
|
571
|
+
`sacar a alguien de un grupo, unrelate(u, member, group:g))`
|
|
572
|
+
: phase === 'read'
|
|
573
|
+
? `el tipo '${object.type}' está en la FASE 1 del opt-in (deny: 'read'): el modelo y las lecturas ya honran el deny, ` +
|
|
574
|
+
`pero las escrituras no se aceptan hasta que TODA la flota sirva el modelo nuevo; pásalo a deny: 'write' en un ` +
|
|
575
|
+
`segundo despliegue`
|
|
576
|
+
: `el tipo '${object.type}' no declara deny en defineRelationsConfig (opt-in por tipo, en dos fases: 'read' y luego 'write')`;
|
|
577
|
+
throw new RelationNotDenyableError(`deny(…, '${relation}', ${object.type}): no se puede denegar — ${why}. No se ha escrito nada.`);
|
|
578
|
+
}
|
|
579
|
+
/**
|
|
580
|
+
* **El comodín `*` no es un sujeto de deny** (D-17): un deny a «todos» sería un apagado del objeto que nadie
|
|
581
|
+
* auditaría como tal. Hoy la gramática de holder ya rechaza `*` (422 `E_AUTHZ_INVALID_IDENTITY`); esta guarda lo dice
|
|
582
|
+
* con el código del deny ANTES de la gramática, para que un cambio de la gramática no lo abra en silencio.
|
|
583
|
+
*/
|
|
584
|
+
export function assertDenySubject(subject) {
|
|
585
|
+
if (subject && typeof subject === 'object' && subject.uuid === '*') {
|
|
586
|
+
throw new RelationNotDenyableError(`deny: el comodín '*' no es un sujeto de deny (un deny a «todos» apagaría el objeto sin que ningún grant lo explique). ` +
|
|
587
|
+
`Deniega a un holder o a un group#member. No se ha escrito nada.`);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
/**
|
|
591
|
+
* **D3: el deny no caduca en v1.** `expiresAt` en las opciones de `deny`/`removeDeny` —incluso `null`, que en `relate`
|
|
592
|
+
* significa «quitar la caducidad»— es 422 `E_AUTHZ_DENY_EXPIRY_UNSUPPORTED` ANTES de tocar el backend, con la razón
|
|
593
|
+
* medida (M5). La clave PRESENTE es lo que se rechaza: un `null` aceptado sería una puerta para que la siguiente
|
|
594
|
+
* versión la lea como «permanente» sin que nadie lo decidiera.
|
|
595
|
+
*/
|
|
596
|
+
export function assertDenyNoExpiry(options) {
|
|
597
|
+
if (options && typeof options === 'object' && 'expiresAt' in options) {
|
|
598
|
+
throw new RelationDenyExpiryError(`deny/removeDeny: 'expiresAt' no se admite — el deny de relaciones NO caduca en v1 (D3). Medido en OpenFGA v1.19.0 ` +
|
|
599
|
+
`(M5): FGA rechaza delete+write de la misma clave en un Write (400 cannot_allow_duplicate_tuples_in_one_request) y ` +
|
|
600
|
+
`on_duplicate: ignore con otra condición es 409, así que cambiar la caducidad de un deny VIVO abre una ventana en la ` +
|
|
601
|
+
`que deja de bloquear (y si el segundo Write cae, el deny desaparece). Quita la clave; para un acceso temporal, ` +
|
|
602
|
+
`caduca el GRANT (relate con expiresAt), no el deny. No se ha escrito nada.`);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { RelationsConfig } from './define_relations_config.js';
|
|
2
|
+
type ConfigLike = Pick<RelationsConfig, 'objectTypes'>;
|
|
3
|
+
/** Un deny vivo, agregado por tipo y relación PÚBLICA (`editor`, no `denied_editor`). */
|
|
4
|
+
export interface LiveDenyGroup {
|
|
5
|
+
objectType: string;
|
|
6
|
+
relation: string;
|
|
7
|
+
/**
|
|
8
|
+
* Cuántos denies vivos hay sobre `objectType#relation`: filas de `authz_relations` en la guarda; en el detector de
|
|
9
|
+
* reconcile, CLAVES DE TUPLA DISTINTAS (firma (8 · `count`): un deny espejado en origen y destino cuenta una vez).
|
|
10
|
+
*/
|
|
11
|
+
count: number;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Por qué un grupo de denies vivos es un hallazgo: `shrunk`/`orphan` (la config nueva encoge su alcance), `no-baseline`
|
|
15
|
+
* (sin línea base utilizable no se puede juzgar) o `stale` (la línea base es más estrecha que lo servido; solo el detector).
|
|
16
|
+
*/
|
|
17
|
+
export type DenyReachReason = 'shrunk' | 'orphan' | 'no-baseline' | 'stale';
|
|
18
|
+
/** Un grupo de denies vivos cuyo alcance encoge con la config nueva, con su receta. */
|
|
19
|
+
export interface DenyReachFinding {
|
|
20
|
+
objectType: string;
|
|
21
|
+
relation: string;
|
|
22
|
+
reason: DenyReachReason;
|
|
23
|
+
count: number;
|
|
24
|
+
/**
|
|
25
|
+
* El alcance con la config VIEJA (`down*` en la línea base); `null` = sin línea base o ya huérfano allí. Con `stale`, el
|
|
26
|
+
* alcance que CREE la línea base (`null` si no declara la relación).
|
|
27
|
+
*/
|
|
28
|
+
before: string[] | null;
|
|
29
|
+
/** El alcance con la config NUEVA; `[]` si quedó huérfano. Con `stale`, el de la config SERVIDA. */
|
|
30
|
+
after: string[];
|
|
31
|
+
/**
|
|
32
|
+
* Lo que deja de cortar (`before \ after`); con `orphan` sin línea base, `[relation]`; con `no-baseline`, `[]` (no se
|
|
33
|
+
* sabe); con `stale`, lo que el deny corta con lo servido y la línea base no sabe (`after \ before`).
|
|
34
|
+
*/
|
|
35
|
+
lost: string[];
|
|
36
|
+
/** La receta (español, como el resto de la letra del paquete). */
|
|
37
|
+
recipe: string;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* **El alcance de un deny sobre `objectType#relation`** con `config`: `down*(relation)` ordenado, o `null` si la config no
|
|
41
|
+
* declara ese tipo o esa relación (huérfano).
|
|
42
|
+
*/
|
|
43
|
+
export declare function denyReachOf(config: ConfigLike, objectType: string, relation: string): string[] | null;
|
|
44
|
+
/** La relación ALMACENADA `denied_<rel>` ⇒ `<rel>`; cualquier otra ⇒ `null` (no es un deny). */
|
|
45
|
+
export declare function deniedPublicRelation(stored: string): string | null;
|
|
46
|
+
/** La clave `tipo#relación` de un grupo o hallazgo: la que nombra la salida forzada acotada (`allowDenyReachShrink`). */
|
|
47
|
+
export declare function denyReachKey(group: {
|
|
48
|
+
objectType: string;
|
|
49
|
+
relation: string;
|
|
50
|
+
}): string;
|
|
51
|
+
/** La forma de una clave `tipo#relación` que acepta la salida forzada acotada (dos nombres no vacíos, sin `#` ni blancos). */
|
|
52
|
+
export declare const DENY_REACH_KEY_PATTERN: RegExp;
|
|
53
|
+
/**
|
|
54
|
+
* **La lectura de la LÍNEA BASE, TODO O NADA y tipada** (firma (8 · línea base); conflicto 6 del juez del panel): de la spec
|
|
55
|
+
* persistida (el JSON ya parseado) lee SOLO lo que decide un alcance —`objectTypes[].{type, relations[].{name, includes}}`—,
|
|
56
|
+
* sin pasar por `defineRelationsConfig` (una fila de 2.4/alpha.3 con una clave que 2.5 no conoce sigue siendo una línea
|
|
57
|
+
* base: la receta de B5, re-guardar la misma spec corregida, entra sin forzar). Cualquier forma rota EN LO QUE LEE —un
|
|
58
|
+
* objeto que no lo es, `objectTypes`/`relations` que no son arrays, un `type`/`name` que no es string, un `includes`
|
|
59
|
+
* presente que no es un array de strings— deja la línea base como NO UTILIZABLE (`null`): **nunca se filtra** (filtrar
|
|
60
|
+
* subestima el alcance viejo y deja pasar el estrechamiento: S-D3 del tester del panel). Un `includes` ausente es `[]`
|
|
61
|
+
* (la spec lo permite).
|
|
62
|
+
*/
|
|
63
|
+
export declare function denyReachBaselineOf(parsed: unknown): ConfigLike | null;
|
|
64
|
+
/**
|
|
65
|
+
* **La propiedad de (8)**: para cada grupo de denies vivos, ¿`alcance(after) ⊇ alcance(before)`? Devuelve los que la
|
|
66
|
+
* violan (`shrunk` | `orphan`, orden estable por tipo y relación), vacío si ninguno. `before = null` = sin línea base: solo
|
|
67
|
+
* `orphan` (el motivo `no-baseline` lo añade `judgeDenyReach`).
|
|
68
|
+
*/
|
|
69
|
+
export declare function denyReachFindings(before: ConfigLike | null, after: ConfigLike, denies: Iterable<LiveDenyGroup>): DenyReachFinding[];
|
|
70
|
+
/**
|
|
71
|
+
* **El juicio de la guarda (y la mitad «persistida → servida» de los detectores)**: `denyReachFindings` más, SIN línea base
|
|
72
|
+
* utilizable (`baseline === null`), un hallazgo `no-baseline` por cada grupo de denies vivos que no sea ya `orphan` (de un
|
|
73
|
+
* huérfano sí se sabe que encoge: su alcance es ∅). Sin denies vivos no hay hallazgo alguno: la primera fila se crea sin
|
|
74
|
+
* forzar. Orden estable por tipo, relación y motivo.
|
|
75
|
+
*/
|
|
76
|
+
export declare function judgeDenyReach(baseline: ConfigLike | null, next: ConfigLike, denies: Iterable<LiveDenyGroup>): DenyReachFinding[];
|
|
77
|
+
/**
|
|
78
|
+
* **La línea base RANCIA** (firma (8 · rancia); conflicto 3 del juez): la dirección inversa, `denyReachFindings(servida,
|
|
79
|
+
* persistida)`. Un grupo de denies vivos cuyo alcance con lo SERVIDO no cabe en el de la línea base (la fila no declara la
|
|
80
|
+
* relación que se sirve, o la declara más estrecha) es un hallazgo `stale`: la flota sirvió (o sirve) algo que la fila no
|
|
81
|
+
* registra, y desde esa fila un estrechamiento de vuelta a ella parecería «sin cambio» (A1/S-B). Un grupo que lo servido
|
|
82
|
+
* tampoco declara no es rancio (es `orphan` en la otra dirección).
|
|
83
|
+
*/
|
|
84
|
+
export declare function staleDenyReach(baseline: ConfigLike, served: ConfigLike, denies: Iterable<LiveDenyGroup>): DenyReachFinding[];
|
|
85
|
+
/** Lo que devuelven los dos detectores (el de un driver y el de `--dry-run`) para un estado: las dos direcciones. */
|
|
86
|
+
export interface DenyReachCheck {
|
|
87
|
+
/** `'persisted'`: hay línea base utilizable; `'none'`: no la hay (sin fila, o fila que la lectura TODO O NADA no tipa). */
|
|
88
|
+
baseline: 'persisted' | 'none';
|
|
89
|
+
/** Persistida → servida: `shrunk`, `orphan` y, sin línea base, `no-baseline` (el juicio de la guarda, sin salidas). */
|
|
90
|
+
findings: DenyReachFinding[];
|
|
91
|
+
/** Servida → persistida: `stale` (solo con línea base). */
|
|
92
|
+
staleBaseline: DenyReachFinding[];
|
|
93
|
+
}
|
|
94
|
+
/** **Los dos sentidos sobre el mismo estado** (firma (8 · detector de un driver)): el punto de paso de los detectores. */
|
|
95
|
+
export declare function denyReachCheck(baseline: ConfigLike | null, served: ConfigLike, denies: Iterable<LiveDenyGroup>): DenyReachCheck;
|
|
96
|
+
/**
|
|
97
|
+
* Una línea por hallazgo, con el prefijo ESTABLE de su rama (CLAUDE.md punto 7): la usan el error de la guarda, el detector
|
|
98
|
+
* de un driver y el comando. Prefijos: «deny con alcance ENCOGIDO», «deny HUÉRFANO», «deny SIN línea base», «línea base
|
|
99
|
+
* RANCIA».
|
|
100
|
+
*/
|
|
101
|
+
export declare function denyReachLine(finding: DenyReachFinding): string;
|
|
102
|
+
export {};
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* **El ALCANCE de un deny vivo, y cuándo un cambio de config lo ENCOGE** (lote RC · sublote (8); decisión §2026-10-15 (8)
|
|
3
|
+
* del dueño y firmas «(8 · panel)» 2026-10-22). Módulo PURO (sin `db`, sin drivers: regla 2 de pureza) y PUNTO DE PASO
|
|
4
|
+
* ÚNICO de las tres piezas de (8) —los gemelos guarda ↔ detector de un driver ↔ `--dry-run`—:
|
|
5
|
+
*
|
|
6
|
+
* - la **guarda de `database`** (`saveRelationsConfig`, `src/relations_config_store.ts`): `judgeDenyReach` sobre la línea
|
|
7
|
+
* base persistida (leída TODO O NADA por `denyReachBaselineOf`), la config nueva y los denies vivos de `authz_relations`;
|
|
8
|
+
* niega (500 `E_AUTHZ_DENY_REACH_REFUSED`, cero escrituras) salvo las dos salidas con nombre: `establishBaseline: true`
|
|
9
|
+
* (solo el motivo `no-baseline`) y `allowDenyReachShrink: ['tipo#relación', …]` (solo esos hallazgos);
|
|
10
|
+
* - el **detector de un solo driver** (`checkRelationsConfigDenyReach`, mismo módulo de la guarda): `denyReachCheck`, la
|
|
11
|
+
* misma línea base y los mismos denies, en las DOS direcciones (persistida → servida, y servida → persistida: la línea
|
|
12
|
+
* base RANCIA, A1/S-B del panel);
|
|
13
|
+
* - el **detector de `authz:relations:reconcile --dry-run`** (`reconcileRelations({ denyReach })`): `denyReachCheck` sobre
|
|
14
|
+
* los denies que la pasada YA enumeró, en origen y destino. En `openfga` es solo detección (regla (ritmo): cero
|
|
15
|
+
* arreglos hasta 2.6; la guarda de `openfga:provision` sigue siendo el límite declarado `openfga.provision.deny-reach`).
|
|
16
|
+
*
|
|
17
|
+
* **El alcance** de un deny sobre `T#X` es el conjunto de relaciones cuyos grants CORTA (D5, corte de camino:
|
|
18
|
+
* `cutPathAllows` corta un grant de `g` sii algún deny vivo está en `up*(g)`, o sea sii `g ∈ down*(X)`):
|
|
19
|
+
* `alcance(T#X) = down*(X)`. Con `editor: { includes: ['owner'] }` un deny en `editor` corta `{editor, owner}`; estrechado
|
|
20
|
+
* a `includes: []` corta solo `{editor}` y el owner directo vuelve a conceder (el escenario firmado).
|
|
21
|
+
*
|
|
22
|
+
* **La propiedad firmada es «para todo deny vivo, alcance_nuevo ⊇ alcance_viejo»**, NO «la relación sigue existiendo».
|
|
23
|
+
* Motivos de un hallazgo:
|
|
24
|
+
* - `shrunk`: `T#X` sigue declarada pero `down*_nuevo(X) ⊉ down*_viejo(X)` —un `includes` estrechado, directo o
|
|
25
|
+
* TRANSITIVO, o una relación de `down*_viejo(X)` retirada del tipo—;
|
|
26
|
+
* - `orphan`: `T#X` ya no está declarada (relación o tipo retirados): el deny no lo lee nadie (alcance ∅) y ningún
|
|
27
|
+
* `removeDeny` lo alcanza (F-05); si la relación se vuelve a declarar, el deny resucita. Se juzga SIEMPRE contra la
|
|
28
|
+
* config nueva, con o sin línea base, y aunque ya fuera huérfano en la línea base (firma (8 · salida forzada): P2 «juzgar
|
|
29
|
+
* por delta» NO entra — una AUSENCIA en una fila que puede estar rancia no prueba que el deny ya fuera huérfano en la flota);
|
|
30
|
+
* - `no-baseline`: no hay línea base UTILIZABLE (sin fila, o una fila que la lectura TODO O NADA no puede tipar) y el deny
|
|
31
|
+
* vive: no se puede juzgar si encoge (firma (8 · línea base));
|
|
32
|
+
* - `stale` (solo el detector): la línea base no sabe que el deny corta algo que la config SERVIDA sí corta — la fila es
|
|
33
|
+
* más estrecha que lo servido, y desde ella un estrechamiento de vuelta parecería «sin cambio» (firma (8 · rancia)).
|
|
34
|
+
*
|
|
35
|
+
* Decisiones que siguen (informe `loteRC-8-informe.md`, sin cambio del dueño): una relación de `down*_viejo(X)` RETIRADA del
|
|
36
|
+
* tipo cuenta como encogimiento aunque sus grants ya no concedan (la propiedad, literal); retirar el opt-in `deny` de un tipo
|
|
37
|
+
* NO cambia el alcance en `database` (las lecturas honran la fila igual) y no se juzga aquí — en `openfga` lo juzga la guarda
|
|
38
|
+
* de `provision` (`--allow-drop-deny`).
|
|
39
|
+
*/
|
|
40
|
+
import { RELATION_DENY_PREFIX, relationsDown } from './define_relations_config.js';
|
|
41
|
+
/**
|
|
42
|
+
* **El alcance de un deny sobre `objectType#relation`** con `config`: `down*(relation)` ordenado, o `null` si la config no
|
|
43
|
+
* declara ese tipo o esa relación (huérfano).
|
|
44
|
+
*/
|
|
45
|
+
export function denyReachOf(config, objectType, relation) {
|
|
46
|
+
const type = config.objectTypes.find((t) => t.type === objectType);
|
|
47
|
+
if (!type || !type.relations.some((r) => r.name === relation))
|
|
48
|
+
return null;
|
|
49
|
+
return relationsDown(config, objectType, relation).sort();
|
|
50
|
+
}
|
|
51
|
+
/** La relación ALMACENADA `denied_<rel>` ⇒ `<rel>`; cualquier otra ⇒ `null` (no es un deny). */
|
|
52
|
+
export function deniedPublicRelation(stored) {
|
|
53
|
+
return typeof stored === 'string' && stored.startsWith(RELATION_DENY_PREFIX) && stored.length > RELATION_DENY_PREFIX.length
|
|
54
|
+
? stored.slice(RELATION_DENY_PREFIX.length)
|
|
55
|
+
: null;
|
|
56
|
+
}
|
|
57
|
+
/** La clave `tipo#relación` de un grupo o hallazgo: la que nombra la salida forzada acotada (`allowDenyReachShrink`). */
|
|
58
|
+
export function denyReachKey(group) {
|
|
59
|
+
return `${group.objectType}#${group.relation}`;
|
|
60
|
+
}
|
|
61
|
+
/** La forma de una clave `tipo#relación` que acepta la salida forzada acotada (dos nombres no vacíos, sin `#` ni blancos). */
|
|
62
|
+
export const DENY_REACH_KEY_PATTERN = /^[^#\s]+#[^#\s]+$/;
|
|
63
|
+
/**
|
|
64
|
+
* **La lectura de la LÍNEA BASE, TODO O NADA y tipada** (firma (8 · línea base); conflicto 6 del juez del panel): de la spec
|
|
65
|
+
* persistida (el JSON ya parseado) lee SOLO lo que decide un alcance —`objectTypes[].{type, relations[].{name, includes}}`—,
|
|
66
|
+
* sin pasar por `defineRelationsConfig` (una fila de 2.4/alpha.3 con una clave que 2.5 no conoce sigue siendo una línea
|
|
67
|
+
* base: la receta de B5, re-guardar la misma spec corregida, entra sin forzar). Cualquier forma rota EN LO QUE LEE —un
|
|
68
|
+
* objeto que no lo es, `objectTypes`/`relations` que no son arrays, un `type`/`name` que no es string, un `includes`
|
|
69
|
+
* presente que no es un array de strings— deja la línea base como NO UTILIZABLE (`null`): **nunca se filtra** (filtrar
|
|
70
|
+
* subestima el alcance viejo y deja pasar el estrechamiento: S-D3 del tester del panel). Un `includes` ausente es `[]`
|
|
71
|
+
* (la spec lo permite).
|
|
72
|
+
*/
|
|
73
|
+
export function denyReachBaselineOf(parsed) {
|
|
74
|
+
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
75
|
+
return null;
|
|
76
|
+
const objectTypes = parsed.objectTypes;
|
|
77
|
+
if (!Array.isArray(objectTypes))
|
|
78
|
+
return null;
|
|
79
|
+
const types = [];
|
|
80
|
+
for (const t of objectTypes) {
|
|
81
|
+
if (t === null || typeof t !== 'object' || Array.isArray(t))
|
|
82
|
+
return null;
|
|
83
|
+
if (typeof t.type !== 'string' || !Array.isArray(t.relations))
|
|
84
|
+
return null;
|
|
85
|
+
const relations = [];
|
|
86
|
+
for (const r of t.relations) {
|
|
87
|
+
if (r === null || typeof r !== 'object' || Array.isArray(r) || typeof r.name !== 'string')
|
|
88
|
+
return null;
|
|
89
|
+
if (r.includes !== undefined && !(Array.isArray(r.includes) && r.includes.every((i) => typeof i === 'string')))
|
|
90
|
+
return null;
|
|
91
|
+
relations.push({ name: r.name, includes: Array.isArray(r.includes) ? r.includes.filter((i) => typeof i === 'string') : [] });
|
|
92
|
+
}
|
|
93
|
+
types.push({ type: t.type, relations });
|
|
94
|
+
}
|
|
95
|
+
return { objectTypes: types };
|
|
96
|
+
}
|
|
97
|
+
function mergeGroups(denies) {
|
|
98
|
+
const groups = new Map();
|
|
99
|
+
for (const deny of denies) {
|
|
100
|
+
const key = denyReachKey(deny);
|
|
101
|
+
const seen = groups.get(key);
|
|
102
|
+
groups.set(key, seen ? { ...seen, count: seen.count + deny.count } : { ...deny });
|
|
103
|
+
}
|
|
104
|
+
return [...groups.values()];
|
|
105
|
+
}
|
|
106
|
+
const byKey = (a, b) => denyReachKey(a).localeCompare(denyReachKey(b)) || String(a.reason ?? '').localeCompare(String(b.reason ?? ''));
|
|
107
|
+
/**
|
|
108
|
+
* **La propiedad de (8)**: para cada grupo de denies vivos, ¿`alcance(after) ⊇ alcance(before)`? Devuelve los que la
|
|
109
|
+
* violan (`shrunk` | `orphan`, orden estable por tipo y relación), vacío si ninguno. `before = null` = sin línea base: solo
|
|
110
|
+
* `orphan` (el motivo `no-baseline` lo añade `judgeDenyReach`).
|
|
111
|
+
*/
|
|
112
|
+
export function denyReachFindings(before, after, denies) {
|
|
113
|
+
const findings = [];
|
|
114
|
+
for (const group of mergeGroups(denies).sort(byKey)) {
|
|
115
|
+
const old = before ? denyReachOf(before, group.objectType, group.relation) : null;
|
|
116
|
+
const now = denyReachOf(after, group.objectType, group.relation);
|
|
117
|
+
if (now === null) {
|
|
118
|
+
findings.push({
|
|
119
|
+
...group,
|
|
120
|
+
reason: 'orphan',
|
|
121
|
+
before: old,
|
|
122
|
+
after: [],
|
|
123
|
+
lost: old ?? [group.relation],
|
|
124
|
+
recipe: orphanRecipe(group),
|
|
125
|
+
});
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
if (old === null)
|
|
129
|
+
continue;
|
|
130
|
+
const lost = old.filter((relation) => !now.includes(relation));
|
|
131
|
+
if (lost.length === 0)
|
|
132
|
+
continue;
|
|
133
|
+
findings.push({ ...group, reason: 'shrunk', before: old, after: now, lost, recipe: shrunkRecipe(group, lost) });
|
|
134
|
+
}
|
|
135
|
+
return findings;
|
|
136
|
+
}
|
|
137
|
+
/**
|
|
138
|
+
* **El juicio de la guarda (y la mitad «persistida → servida» de los detectores)**: `denyReachFindings` más, SIN línea base
|
|
139
|
+
* utilizable (`baseline === null`), un hallazgo `no-baseline` por cada grupo de denies vivos que no sea ya `orphan` (de un
|
|
140
|
+
* huérfano sí se sabe que encoge: su alcance es ∅). Sin denies vivos no hay hallazgo alguno: la primera fila se crea sin
|
|
141
|
+
* forzar. Orden estable por tipo, relación y motivo.
|
|
142
|
+
*/
|
|
143
|
+
export function judgeDenyReach(baseline, next, denies) {
|
|
144
|
+
const groups = mergeGroups(denies);
|
|
145
|
+
const findings = denyReachFindings(baseline, next, groups);
|
|
146
|
+
if (baseline !== null)
|
|
147
|
+
return findings;
|
|
148
|
+
const judged = new Set(findings.map(denyReachKey));
|
|
149
|
+
for (const group of groups) {
|
|
150
|
+
if (judged.has(denyReachKey(group)))
|
|
151
|
+
continue;
|
|
152
|
+
findings.push({ ...group, reason: 'no-baseline', before: null, after: denyReachOf(next, group.objectType, group.relation) ?? [], lost: [], recipe: noBaselineRecipe(group) });
|
|
153
|
+
}
|
|
154
|
+
return findings.sort(byKey);
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* **La línea base RANCIA** (firma (8 · rancia); conflicto 3 del juez): la dirección inversa, `denyReachFindings(servida,
|
|
158
|
+
* persistida)`. Un grupo de denies vivos cuyo alcance con lo SERVIDO no cabe en el de la línea base (la fila no declara la
|
|
159
|
+
* relación que se sirve, o la declara más estrecha) es un hallazgo `stale`: la flota sirvió (o sirve) algo que la fila no
|
|
160
|
+
* registra, y desde esa fila un estrechamiento de vuelta a ella parecería «sin cambio» (A1/S-B). Un grupo que lo servido
|
|
161
|
+
* tampoco declara no es rancio (es `orphan` en la otra dirección).
|
|
162
|
+
*/
|
|
163
|
+
export function staleDenyReach(baseline, served, denies) {
|
|
164
|
+
return denyReachFindings(served, baseline, denies)
|
|
165
|
+
.filter((reverse) => reverse.before !== null)
|
|
166
|
+
.map((reverse) => ({
|
|
167
|
+
objectType: reverse.objectType,
|
|
168
|
+
relation: reverse.relation,
|
|
169
|
+
count: reverse.count,
|
|
170
|
+
reason: 'stale',
|
|
171
|
+
before: reverse.reason === 'orphan' ? null : reverse.after,
|
|
172
|
+
after: reverse.before,
|
|
173
|
+
lost: reverse.lost,
|
|
174
|
+
recipe: staleRecipe(reverse),
|
|
175
|
+
}));
|
|
176
|
+
}
|
|
177
|
+
/** **Los dos sentidos sobre el mismo estado** (firma (8 · detector de un driver)): el punto de paso de los detectores. */
|
|
178
|
+
export function denyReachCheck(baseline, served, denies) {
|
|
179
|
+
const groups = mergeGroups(denies);
|
|
180
|
+
return {
|
|
181
|
+
baseline: baseline === null ? 'none' : 'persisted',
|
|
182
|
+
findings: judgeDenyReach(baseline, served, groups),
|
|
183
|
+
staleBaseline: baseline === null ? [] : staleDenyReach(baseline, served, groups),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
function shrunkRecipe(group, lost) {
|
|
187
|
+
return (`antes de estrechar, vuelve a emitir cada uno de esos ${group.count} deny(s) sobre una relación de '${group.objectType}' que ` +
|
|
188
|
+
`con la config nueva siga cortando ${lost.map((r) => `'${r}'`).join(', ')} (deny(<sujeto>, '<esa relación>', <objeto>); ` +
|
|
189
|
+
`listDenies(<objeto>, '${group.relation}') los enumera), o deja el includes como estaba; si de verdad deben dejar de ` +
|
|
190
|
+
`cortarlo, nombra este hallazgo en la salida forzada: allowDenyReachShrink: ['${denyReachKey(group)}']`);
|
|
191
|
+
}
|
|
192
|
+
function orphanRecipe(group) {
|
|
193
|
+
return (`antes de retirar '${denyReachKey(group)}', quita esos ${group.count} deny(s) con removeDeny (o re-emítelos sobre ` +
|
|
194
|
+
`una relación que se quede) y retírala después; si ya la retiraste, vuelve a declararla, haz removeDeny y retírala — ` +
|
|
195
|
+
`un deny huérfano no lo lee nadie, ningún removeDeny lo alcanza (F-05) y resucita si la relación se vuelve a declarar ` +
|
|
196
|
+
`(y re-declararla tras forzar la retirada resucita también los grants que cortaba)`);
|
|
197
|
+
}
|
|
198
|
+
function noBaselineRecipe(group) {
|
|
199
|
+
return (`guarda UNA vez la spec que la flota sirve HOY, sin cambios, con saveRelationsConfig(spec, { establishBaseline: true }), ` +
|
|
200
|
+
`en una release que NO cambie relations.config (adoptar en una release que estrecha certifica el estrechamiento); ` +
|
|
201
|
+
`desde esa fila la guarda juzga cada cambio de '${denyReachKey(group)}'`);
|
|
202
|
+
}
|
|
203
|
+
function staleRecipe(reverse) {
|
|
204
|
+
return (`la línea base (authz_relations_config) es más estrecha que la config que se sirve: guarda con saveRelationsConfig la ` +
|
|
205
|
+
`config que sirve la flota —persistir la config es paso obligatorio de release si usas deny— y vuelve a correr la ` +
|
|
206
|
+
`comprobación; mientras la fila no lo sepa, un estrechamiento de vuelta a ella no lo niega la guarda y los ` +
|
|
207
|
+
`${reverse.count} deny(s) de '${denyReachKey(reverse)}' dejarían de cortar ${reverse.lost.map((r) => `'${r}'`).join(', ')}`);
|
|
208
|
+
}
|
|
209
|
+
/**
|
|
210
|
+
* Una línea por hallazgo, con el prefijo ESTABLE de su rama (CLAUDE.md punto 7): la usan el error de la guarda, el detector
|
|
211
|
+
* de un driver y el comando. Prefijos: «deny con alcance ENCOGIDO», «deny HUÉRFANO», «deny SIN línea base», «línea base
|
|
212
|
+
* RANCIA».
|
|
213
|
+
*/
|
|
214
|
+
export function denyReachLine(finding) {
|
|
215
|
+
const where = `${denyReachKey(finding)} (${finding.count} deny(s))`;
|
|
216
|
+
switch (finding.reason) {
|
|
217
|
+
case 'orphan':
|
|
218
|
+
return `deny HUÉRFANO ${where}: la config nueva no declara la relación (cortaba ${finding.lost.join(', ')}). Receta: ${finding.recipe}.`;
|
|
219
|
+
case 'no-baseline':
|
|
220
|
+
return (`deny SIN línea base ${where}: no hay config persistida utilizable en authz_relations_config con la que comparar, así que ` +
|
|
221
|
+
`no se puede saber si la config nueva encoge su alcance (hoy corta ${finding.after.join(', ')}). Receta: ${finding.recipe}.`);
|
|
222
|
+
case 'stale':
|
|
223
|
+
return (`línea base RANCIA ${where}: con la config servida el deny corta ${finding.after.join(', ')}, y la config persistida ` +
|
|
224
|
+
`${finding.before === null ? 'no declara la relación' : `solo sabe de ${finding.before.join(', ')}`} (no sabe de ` +
|
|
225
|
+
`${finding.lost.join(', ')}). Receta: ${finding.recipe}.`);
|
|
226
|
+
default:
|
|
227
|
+
return (`deny con alcance ENCOGIDO ${where}: cortaba ${finding.before.join(', ')} y con la config nueva solo ${finding.after.join(', ')} ` +
|
|
228
|
+
`(deja de cortar ${finding.lost.join(', ')}: lo que negaba vuelve a conceder). Receta: ${finding.recipe}.`);
|
|
229
|
+
}
|
|
230
|
+
}
|