@jantstack/adonis-authz 2.5.0-alpha.3 → 2.5.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +437 -48
- package/build/commands/authz_catalog_diff.d.ts +0 -1
- package/build/commands/authz_catalog_diff.js +0 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts +17 -3
- package/build/commands/authz_catalog_prune_orphans.js +28 -6
- package/build/commands/authz_catalog_sync.d.ts +0 -1
- package/build/commands/authz_catalog_sync.js +0 -1
- package/build/commands/authz_freeze.d.ts +0 -1
- package/build/commands/authz_freeze.js +0 -1
- package/build/commands/authz_reconcile.d.ts +0 -1
- package/build/commands/authz_reconcile.js +0 -1
- package/build/commands/authz_relations_reconcile.d.ts +0 -1
- package/build/commands/authz_relations_reconcile.js +56 -8
- package/build/commands/authz_scopes_relay.d.ts +0 -1
- package/build/commands/authz_scopes_relay.js +54 -6
- package/build/commands/authz_scopes_requeue.d.ts +0 -1
- package/build/commands/authz_scopes_requeue.js +16 -3
- package/build/commands/authz_unfreeze.d.ts +0 -1
- package/build/commands/authz_unfreeze.js +0 -1
- package/build/commands/main.d.ts +0 -1
- package/build/commands/main.js +0 -1
- package/build/commands/openfga_provision.d.ts +10 -1
- package/build/commands/openfga_provision.js +43 -3
- package/build/configure.d.ts +0 -1
- package/build/configure.js +0 -1
- package/build/index.d.ts +9 -7
- package/build/index.js +10 -3
- package/build/providers/authz_provider.d.ts +0 -1
- package/build/providers/authz_provider.js +0 -1
- package/build/services/main.d.ts +0 -1
- package/build/services/main.js +0 -1
- package/build/services/relations.d.ts +0 -1
- package/build/services/relations.js +0 -1
- package/build/src/catalog/catalog.d.ts +0 -1
- package/build/src/catalog/catalog.js +0 -1
- package/build/src/catalog/catalog_cache.d.ts +13 -1
- package/build/src/catalog/catalog_cache.js +27 -2
- package/build/src/clock.d.ts +0 -1
- package/build/src/clock.js +0 -1
- package/build/src/define_config.d.ts +0 -1
- package/build/src/define_config.js +24 -1
- package/build/src/drivers/database_driver.d.ts +60 -3
- package/build/src/drivers/database_driver.js +160 -25
- package/build/src/drivers/database_relations_driver.d.ts +11 -5
- package/build/src/drivers/database_relations_driver.js +115 -33
- package/build/src/drivers/openfga_driver.d.ts +159 -26
- package/build/src/drivers/openfga_driver.js +882 -199
- package/build/src/drivers/openfga_facts.d.ts +91 -1
- package/build/src/drivers/openfga_facts.js +277 -3
- package/build/src/drivers/openfga_relations_driver.d.ts +41 -13
- package/build/src/drivers/openfga_relations_driver.js +422 -228
- package/build/src/errors.d.ts +160 -1
- package/build/src/errors.js +161 -1
- package/build/src/expiry.d.ts +51 -2
- package/build/src/expiry.js +96 -2
- package/build/src/freeze.d.ts +0 -1
- package/build/src/freeze.js +0 -1
- package/build/src/hierarchical_resolver.d.ts +0 -1
- package/build/src/hierarchical_resolver.js +0 -1
- package/build/src/http/app_access_middleware.d.ts +0 -1
- package/build/src/http/app_access_middleware.js +0 -1
- package/build/src/http/resource_access_middleware.d.ts +0 -1
- package/build/src/http/resource_access_middleware.js +0 -1
- package/build/src/identity.d.ts +9 -2
- package/build/src/identity.js +33 -1
- package/build/src/manager.d.ts +24 -1
- package/build/src/manager.js +381 -85
- package/build/src/memoize_ancestors.d.ts +0 -1
- package/build/src/memoize_ancestors.js +0 -1
- package/build/src/models/authz_assignment.d.ts +5 -6
- package/build/src/models/authz_assignment.js +0 -1
- package/build/src/models/authz_deny.d.ts +5 -6
- package/build/src/models/authz_deny.js +0 -1
- package/build/src/models/authz_permission.d.ts +5 -6
- package/build/src/models/authz_permission.js +0 -1
- package/build/src/models/authz_role.d.ts +5 -6
- package/build/src/models/authz_role.js +0 -1
- package/build/src/models/authz_role_permission.d.ts +5 -6
- package/build/src/models/authz_role_permission.js +0 -1
- package/build/src/openfga.d.ts +2 -3
- package/build/src/openfga.js +4 -2
- package/build/src/reconcile.d.ts +0 -1
- package/build/src/reconcile.js +0 -1
- package/build/src/relation_partition_trigger.d.ts +0 -1
- package/build/src/relation_partition_trigger.js +0 -1
- package/build/src/relations/check_many.d.ts +0 -1
- package/build/src/relations/check_many.js +0 -1
- package/build/src/relations/define_relations_config.d.ts +25 -3
- package/build/src/relations/define_relations_config.js +8 -3
- package/build/src/relations/deny_reach.d.ts +102 -0
- package/build/src/relations/deny_reach.js +230 -0
- package/build/src/relations/group_depth.d.ts +215 -0
- package/build/src/relations/group_depth.js +453 -0
- package/build/src/relations/manager.d.ts +14 -7
- package/build/src/relations/manager.js +60 -15
- package/build/src/relations/page.d.ts +19 -0
- package/build/src/relations/page.js +74 -0
- package/build/src/relations/reconcile.d.ts +58 -4
- package/build/src/relations/reconcile.js +266 -12
- package/build/src/relations/tuple_key.d.ts +0 -1
- package/build/src/relations/tuple_key.js +0 -1
- package/build/src/relations/tuple_order.d.ts +5 -4
- package/build/src/relations/tuple_order.js +5 -4
- package/build/src/relations_config_store.d.ts +57 -2
- package/build/src/relations_config_store.js +155 -5
- package/build/src/relations_reconcile.d.ts +0 -1
- package/build/src/relations_reconcile.js +0 -1
- package/build/src/scope_outbox.d.ts +36 -3
- package/build/src/scope_outbox.js +78 -18
- package/build/src/shared/backend_guard.d.ts +52 -1
- package/build/src/shared/backend_guard.js +164 -2
- package/build/src/shared/sql_expiry.d.ts +0 -1
- package/build/src/shared/sql_expiry.js +0 -1
- package/build/src/shared/transaction_guard.d.ts +0 -1
- package/build/src/shared/transaction_guard.js +0 -1
- package/build/src/sql_descendants.d.ts +0 -1
- package/build/src/sql_descendants.js +0 -1
- package/build/src/testing/contract.d.ts +26 -3
- package/build/src/testing/contract.js +258 -20
- package/build/src/testing/main.d.ts +0 -1
- package/build/src/testing/main.js +0 -1
- package/build/src/testing/migration_contract.d.ts +0 -1
- package/build/src/testing/migration_contract.js +0 -1
- package/build/src/testing/relations_contract.d.ts +7 -1
- package/build/src/testing/relations_contract.js +174 -7
- package/build/src/testing/relations_reconcile_contract.d.ts +0 -1
- package/build/src/testing/relations_reconcile_contract.js +0 -1
- package/build/src/testing/scope_outbox_contract.d.ts +0 -1
- package/build/src/testing/scope_outbox_contract.js +0 -1
- package/build/src/testing/scope_tree.d.ts +0 -1
- package/build/src/testing/scope_tree.js +0 -1
- package/build/src/testing/test_clock.d.ts +0 -1
- package/build/src/testing/test_clock.js +0 -1
- package/build/src/traits/authz_scopes.d.ts +0 -1
- package/build/src/traits/authz_scopes.js +0 -1
- package/build/src/traits/has_uuid.d.ts +5 -6
- package/build/src/traits/has_uuid.js +0 -1
- package/build/src/types.d.ts +138 -14
- package/build/src/types.js +6 -1
- package/build/stubs/config/authorization.stub +22 -6
- package/build/stubs/main.d.ts +0 -1
- package/build/stubs/main.js +0 -1
- package/package.json +4 -3
- package/build/commands/authz_catalog_diff.d.ts.map +0 -1
- package/build/commands/authz_catalog_diff.js.map +0 -1
- package/build/commands/authz_catalog_prune_orphans.d.ts.map +0 -1
- package/build/commands/authz_catalog_prune_orphans.js.map +0 -1
- package/build/commands/authz_catalog_sync.d.ts.map +0 -1
- package/build/commands/authz_catalog_sync.js.map +0 -1
- package/build/commands/authz_freeze.d.ts.map +0 -1
- package/build/commands/authz_freeze.js.map +0 -1
- package/build/commands/authz_reconcile.d.ts.map +0 -1
- package/build/commands/authz_reconcile.js.map +0 -1
- package/build/commands/authz_relations_reconcile.d.ts.map +0 -1
- package/build/commands/authz_relations_reconcile.js.map +0 -1
- package/build/commands/authz_scopes_relay.d.ts.map +0 -1
- package/build/commands/authz_scopes_relay.js.map +0 -1
- package/build/commands/authz_scopes_requeue.d.ts.map +0 -1
- package/build/commands/authz_scopes_requeue.js.map +0 -1
- package/build/commands/authz_unfreeze.d.ts.map +0 -1
- package/build/commands/authz_unfreeze.js.map +0 -1
- package/build/commands/main.d.ts.map +0 -1
- package/build/commands/main.js.map +0 -1
- package/build/commands/openfga_provision.d.ts.map +0 -1
- package/build/commands/openfga_provision.js.map +0 -1
- package/build/configure.d.ts.map +0 -1
- package/build/configure.js.map +0 -1
- package/build/index.d.ts.map +0 -1
- package/build/index.js.map +0 -1
- package/build/providers/authz_provider.d.ts.map +0 -1
- package/build/providers/authz_provider.js.map +0 -1
- package/build/services/main.d.ts.map +0 -1
- package/build/services/main.js.map +0 -1
- package/build/services/relations.d.ts.map +0 -1
- package/build/services/relations.js.map +0 -1
- package/build/src/catalog/catalog.d.ts.map +0 -1
- package/build/src/catalog/catalog.js.map +0 -1
- package/build/src/catalog/catalog_cache.d.ts.map +0 -1
- package/build/src/catalog/catalog_cache.js.map +0 -1
- package/build/src/clock.d.ts.map +0 -1
- package/build/src/clock.js.map +0 -1
- package/build/src/define_config.d.ts.map +0 -1
- package/build/src/define_config.js.map +0 -1
- package/build/src/drivers/database_driver.d.ts.map +0 -1
- package/build/src/drivers/database_driver.js.map +0 -1
- package/build/src/drivers/database_relations_driver.d.ts.map +0 -1
- package/build/src/drivers/database_relations_driver.js.map +0 -1
- package/build/src/drivers/openfga_driver.d.ts.map +0 -1
- package/build/src/drivers/openfga_driver.js.map +0 -1
- package/build/src/drivers/openfga_facts.d.ts.map +0 -1
- package/build/src/drivers/openfga_facts.js.map +0 -1
- package/build/src/drivers/openfga_relations_driver.d.ts.map +0 -1
- package/build/src/drivers/openfga_relations_driver.js.map +0 -1
- package/build/src/errors.d.ts.map +0 -1
- package/build/src/errors.js.map +0 -1
- package/build/src/expiry.d.ts.map +0 -1
- package/build/src/expiry.js.map +0 -1
- package/build/src/freeze.d.ts.map +0 -1
- package/build/src/freeze.js.map +0 -1
- package/build/src/hierarchical_resolver.d.ts.map +0 -1
- package/build/src/hierarchical_resolver.js.map +0 -1
- package/build/src/http/app_access_middleware.d.ts.map +0 -1
- package/build/src/http/app_access_middleware.js.map +0 -1
- package/build/src/http/resource_access_middleware.d.ts.map +0 -1
- package/build/src/http/resource_access_middleware.js.map +0 -1
- package/build/src/identity.d.ts.map +0 -1
- package/build/src/identity.js.map +0 -1
- package/build/src/manager.d.ts.map +0 -1
- package/build/src/manager.js.map +0 -1
- package/build/src/memoize_ancestors.d.ts.map +0 -1
- package/build/src/memoize_ancestors.js.map +0 -1
- package/build/src/models/authz_assignment.d.ts.map +0 -1
- package/build/src/models/authz_assignment.js.map +0 -1
- package/build/src/models/authz_deny.d.ts.map +0 -1
- package/build/src/models/authz_deny.js.map +0 -1
- package/build/src/models/authz_permission.d.ts.map +0 -1
- package/build/src/models/authz_permission.js.map +0 -1
- package/build/src/models/authz_role.d.ts.map +0 -1
- package/build/src/models/authz_role.js.map +0 -1
- package/build/src/models/authz_role_permission.d.ts.map +0 -1
- package/build/src/models/authz_role_permission.js.map +0 -1
- package/build/src/openfga.d.ts.map +0 -1
- package/build/src/openfga.js.map +0 -1
- package/build/src/reconcile.d.ts.map +0 -1
- package/build/src/reconcile.js.map +0 -1
- package/build/src/relation_partition_trigger.d.ts.map +0 -1
- package/build/src/relation_partition_trigger.js.map +0 -1
- package/build/src/relations/check_many.d.ts.map +0 -1
- package/build/src/relations/check_many.js.map +0 -1
- package/build/src/relations/define_relations_config.d.ts.map +0 -1
- package/build/src/relations/define_relations_config.js.map +0 -1
- package/build/src/relations/manager.d.ts.map +0 -1
- package/build/src/relations/manager.js.map +0 -1
- package/build/src/relations/reconcile.d.ts.map +0 -1
- package/build/src/relations/reconcile.js.map +0 -1
- package/build/src/relations/tuple_key.d.ts.map +0 -1
- package/build/src/relations/tuple_key.js.map +0 -1
- package/build/src/relations/tuple_order.d.ts.map +0 -1
- package/build/src/relations/tuple_order.js.map +0 -1
- package/build/src/relations_config_store.d.ts.map +0 -1
- package/build/src/relations_config_store.js.map +0 -1
- package/build/src/relations_reconcile.d.ts.map +0 -1
- package/build/src/relations_reconcile.js.map +0 -1
- package/build/src/scope_outbox.d.ts.map +0 -1
- package/build/src/scope_outbox.js.map +0 -1
- package/build/src/shared/backend_guard.d.ts.map +0 -1
- package/build/src/shared/backend_guard.js.map +0 -1
- package/build/src/shared/sql_expiry.d.ts.map +0 -1
- package/build/src/shared/sql_expiry.js.map +0 -1
- package/build/src/shared/transaction_guard.d.ts.map +0 -1
- package/build/src/shared/transaction_guard.js.map +0 -1
- package/build/src/sql_descendants.d.ts.map +0 -1
- package/build/src/sql_descendants.js.map +0 -1
- package/build/src/testing/contract.d.ts.map +0 -1
- package/build/src/testing/contract.js.map +0 -1
- package/build/src/testing/main.d.ts.map +0 -1
- package/build/src/testing/main.js.map +0 -1
- package/build/src/testing/migration_contract.d.ts.map +0 -1
- package/build/src/testing/migration_contract.js.map +0 -1
- package/build/src/testing/relations_contract.d.ts.map +0 -1
- package/build/src/testing/relations_contract.js.map +0 -1
- package/build/src/testing/relations_reconcile_contract.d.ts.map +0 -1
- package/build/src/testing/relations_reconcile_contract.js.map +0 -1
- package/build/src/testing/scope_outbox_contract.d.ts.map +0 -1
- package/build/src/testing/scope_outbox_contract.js.map +0 -1
- package/build/src/testing/scope_tree.d.ts.map +0 -1
- package/build/src/testing/scope_tree.js.map +0 -1
- package/build/src/testing/test_clock.d.ts.map +0 -1
- package/build/src/testing/test_clock.js.map +0 -1
- package/build/src/traits/authz_scopes.d.ts.map +0 -1
- package/build/src/traits/authz_scopes.js.map +0 -1
- package/build/src/traits/has_uuid.d.ts.map +0 -1
- package/build/src/traits/has_uuid.js.map +0 -1
- package/build/src/types.d.ts.map +0 -1
- package/build/src/types.js.map +0 -1
- package/build/stubs/main.d.ts.map +0 -1
- package/build/stubs/main.js.map +0 -1
|
@@ -27,14 +27,16 @@
|
|
|
27
27
|
* clave, niega la dirección rancia y congela; la guardia de superficie de
|
|
28
28
|
* `command_flags.spec.ts` impide re-exportarlo por ningún subpath.
|
|
29
29
|
*/
|
|
30
|
-
import { ActorRequiredError, AuthorizationConfigError, CheckManyBatchError, DenyMigrationRefusedError, InvalidIdentityError, InvalidSlugError, MassReconcileRefusedError, NoScopeResolverError, RelationConfigError, RelationConfigUnknownKeyError, RelationDenyExpiryError, RelationNotDenyableError, RelationTypeUnknownError, RelationUnknownError, RelationUsersetUnsupportedError, UnknownScopeError, UnsupportedOperationError, isPartialWrite, markPartialWrite, } from '../errors.js';
|
|
30
|
+
import { ActorRequiredError, AuthorizationConfigError, CheckManyBatchError, DenyMigrationRefusedError, GroupDepthLimitError, InvalidIdentityError, InvalidRelationPageError, InvalidSlugError, MassReconcileRefusedError, NoScopeResolverError, RelationConfigError, RelationConfigUnknownKeyError, RelationDenyExpiryError, RelationNotDenyableError, RelationTypeUnknownError, RelationUnknownError, RelationUsersetUnsupportedError, UnknownScopeError, UnsupportedOperationError, isPartialWrite, markPartialWrite, } from '../errors.js';
|
|
31
31
|
import { systemClock } from '../clock.js';
|
|
32
32
|
import { isActiveExpiry, sameInstant } from '../expiry.js';
|
|
33
33
|
import { assertPartition, scopeKey } from '../identity.js';
|
|
34
34
|
import { isRelUserset } from '../types.js';
|
|
35
35
|
import { assertRelationDeclared, assertSupportedUserset, RELATION_DENY_PREFIX, RELATION_USERSET_TYPE, relationsUp } from './define_relations_config.js';
|
|
36
|
+
import { denyReachCheck } from './deny_reach.js';
|
|
36
37
|
import { relationTupleKey } from './tuple_key.js';
|
|
37
38
|
import { carriedDenies, denyCarrierGroups, relationTransitionOrder } from './tuple_order.js';
|
|
39
|
+
import { groupChainDepth, plannedRelateOptions } from './group_depth.js';
|
|
38
40
|
/**
|
|
39
41
|
* La clave con la que se casan origen y destino es `relationTupleKey` (plan
|
|
40
42
|
* 2.5 · lote A, P3-4): la MISMA función pura y exportada que un consumidor
|
|
@@ -74,6 +76,7 @@ export const RELATIONS_RECONCILE_DETAIL_REASONS = Object.freeze([
|
|
|
74
76
|
'undeclared',
|
|
75
77
|
'deny-conditional',
|
|
76
78
|
'renewal-unsafe',
|
|
79
|
+
'invalid-condition',
|
|
77
80
|
]);
|
|
78
81
|
/**
|
|
79
82
|
* **Una receta POR MOTIVO** (cierre 1 de D2 · 🟡 6, ⚪ 11). `honor` = el deny ya está en el destino y lo que falta es
|
|
@@ -138,6 +141,19 @@ export function partitionRefusalRecipe(reasons) {
|
|
|
138
141
|
out.push(`deny-conditional: un deny no caduca (D3) y este destino no puede rehacerlo permanente en una escritura. removeDeny + ` +
|
|
139
142
|
`deny de esa tupla en el DESTINO aceptando la ventana de forma EXPLÍCITA — entre los dos el deny no niega —, y repite.`);
|
|
140
143
|
}
|
|
144
|
+
if (set.has('too-deep')) {
|
|
145
|
+
out.push(`too-deep: la cadena de grupos anidados que dejaría la pasada en esta partición es más larga que el techo del DESTINO ` +
|
|
146
|
+
`(en openfga, más allá todo check de un no-miembro es 503 E_AUTHZ_RESOLUTION_LIMIT). Saltar la arista que excede ` +
|
|
147
|
+
`quitaría los denies que transporta: no se hace. Aplana la anidación en el ORIGEN (unrelate de la arista y miembros ` +
|
|
148
|
+
`metidos en un grupo menos profundo) —sin --prune cuenta también lo que el destino ya tiene de más—, o sube el techo ` +
|
|
149
|
+
`del destino (OPENFGA_RESOLVE_NODE_LIMIT en el servidor; maxGroupDepth en la config solo lo baja), y repite.`);
|
|
150
|
+
}
|
|
151
|
+
if (set.has('invalid-condition')) {
|
|
152
|
+
out.push(`invalid-condition: una membresía de group con una condición que el paquete no escribe ni sabe leer puede estar VIVA en ` +
|
|
153
|
+
`el servidor (un not_expired con current_time congelado lo está) y transportar los denies de la partición: el paquete ` +
|
|
154
|
+
`no sabe qué concede y no migra la partición a ciegas. Revisa esa tupla en su store (SDK/CLI de OpenFGA): bórrala, o ` +
|
|
155
|
+
`reescríbela por el paquete (unrelate + relate con la caducidad que corresponda), y repite.`);
|
|
156
|
+
}
|
|
141
157
|
return out.join(' ');
|
|
142
158
|
}
|
|
143
159
|
/**
|
|
@@ -215,11 +231,26 @@ export async function reconcileRelations(options) {
|
|
|
215
231
|
}
|
|
216
232
|
}
|
|
217
233
|
: undefined;
|
|
234
|
+
// R1 (decisión del dueño 2026-10-17 (5); 🔴 C-1): el techo de anidación del DESTINO — el calibrado en `openfga`, de
|
|
235
|
+
// solo lectura, así que también en `--dry-run` (mismo 500 si la config promete más de lo que el servidor resuelve) —.
|
|
236
|
+
// Un driver sin el método no tiene techo para el plan.
|
|
237
|
+
// Solo si hay alguna arista de anidación en el origen o en el destino de las particiones pedidas: sin ninguna, la cadena
|
|
238
|
+
// final es 0 y no hay nada que preguntar (ni calibración que pagar, ni la puerta del modelo de la calibración por
|
|
239
|
+
// delante de la del deny).
|
|
240
|
+
const anyNesting = [...enumeratedByKey.values(), ...destByKeyAll.values()].some((tuples) => tuples.some(isNestingTuple));
|
|
241
|
+
const groupDepthLimit = anyNesting && typeof to.groupDepthLimit === 'function' ? await to.groupDepthLimit() : null;
|
|
218
242
|
const plans = [];
|
|
219
243
|
for (const partition of partitions) {
|
|
220
244
|
const enumerated = enumeratedByKey.get(scopeKey(partition)) ?? [];
|
|
221
245
|
const destTuples = destByKeyAll.get(scopeKey(partition)) ?? [];
|
|
222
|
-
plans.push(planPartition(partition, enumerated, destTuples, {
|
|
246
|
+
plans.push(planPartition(partition, enumerated, destTuples, {
|
|
247
|
+
now,
|
|
248
|
+
prune,
|
|
249
|
+
toConfig,
|
|
250
|
+
atomicRenewal: to.capabilities?.atomicRenewal === true,
|
|
251
|
+
cuts,
|
|
252
|
+
groupDepthLimit,
|
|
253
|
+
}));
|
|
223
254
|
}
|
|
224
255
|
const report = {
|
|
225
256
|
dryRun,
|
|
@@ -238,7 +269,10 @@ export async function reconcileRelations(options) {
|
|
|
238
269
|
partitions: plans.map((plan) => emptyPartitionReport(plan.partition)),
|
|
239
270
|
orderResidue: [],
|
|
240
271
|
partitionsRefused: [],
|
|
272
|
+
tooDeep: [],
|
|
241
273
|
};
|
|
274
|
+
if (options.denyReach !== undefined)
|
|
275
|
+
report.denyReach = detectDenyReach(options.denyReach, enumeratedByKey, destByKeyAll);
|
|
242
276
|
for (const plan of plans) {
|
|
243
277
|
// **El seguro de borrado masivo** (R-17, paridad con `authz:reconcile` de
|
|
244
278
|
// roles / AA2). `prune` + un ORIGEN sin una sola tupla utilizable + un destino
|
|
@@ -255,6 +289,8 @@ export async function reconcileRelations(options) {
|
|
|
255
289
|
`una sola relación. Comprueba el --from y el store; si de verdad quieres vaciarlo, --allow-mass-delete.` +
|
|
256
290
|
`${partitions.length > 1 ? ' No se ha escrito nada en ninguna partición.' : ''}`);
|
|
257
291
|
}
|
|
292
|
+
if (plan.tooDeep)
|
|
293
|
+
report.tooDeep.push({ partition: scopeKey(plan.partition), ...plan.tooDeep });
|
|
258
294
|
report.massDelete ||= plan.massDelete;
|
|
259
295
|
report.skipped.expired += plan.skipped.expired;
|
|
260
296
|
report.skipped.undeclared += plan.skipped.undeclared;
|
|
@@ -303,7 +339,7 @@ export async function reconcileRelations(options) {
|
|
|
303
339
|
*/
|
|
304
340
|
function refusePartition(plan, row, report, prune) {
|
|
305
341
|
const refusal = plan.refusal;
|
|
306
|
-
const PRUNABLE = new Set(['invalid-identity', 'userset-unsupported', 'undeclared']);
|
|
342
|
+
const PRUNABLE = new Set(['invalid-identity', 'userset-unsupported', 'undeclared', 'invalid-condition']);
|
|
307
343
|
for (const detail of plan.details) {
|
|
308
344
|
report.details.push(prune && detail.side === 'destino' && PRUNABLE.has(detail.reason) ? { ...detail, pruned: false } : detail);
|
|
309
345
|
}
|
|
@@ -388,7 +424,7 @@ async function applyPartition(plan, row, report, context) {
|
|
|
388
424
|
// gramática se LISTA siempre, en los dos lados; con `prune` el del destino lleva `pruned: false` desde el plan. El
|
|
389
425
|
// `renewal-unsafe` (cierre 2 de D2 · 🟠 N1) no es un borrado: no lleva `pruned`.
|
|
390
426
|
// Cierre 4 de D2: `deny-conditional` (ya del PLAN) lleva `pruned: false` siempre.
|
|
391
|
-
const PRUNABLE = new Set(['invalid-identity', 'userset-unsupported', 'undeclared']);
|
|
427
|
+
const PRUNABLE = new Set(['invalid-identity', 'userset-unsupported', 'undeclared', 'invalid-condition']);
|
|
392
428
|
for (const detail of plan.details) {
|
|
393
429
|
report.details.push(prune && detail.side === 'destino' && PRUNABLE.has(detail.reason) ? { ...detail, pruned: false } : detail);
|
|
394
430
|
}
|
|
@@ -547,7 +583,11 @@ async function denyGate(to, plans) {
|
|
|
547
583
|
push({ objectType, relation, reason: 'undeclared', message: String(error?.message ?? error), recipe: denyRefusalRecipe('undeclared', objectType, relation) });
|
|
548
584
|
continue;
|
|
549
585
|
}
|
|
550
|
-
|
|
586
|
+
// Cierre de R3 (⚪ R3-A7): «el modelo fijado no declara denied_*» es SOLO el 500 de la puerta del modelo (sin
|
|
587
|
+
// respuesta HTTP en la cadena de `cause`). Un 500 E_AUTHZ_CONFIG que trae la respuesta del servidor (401/403/404,
|
|
588
|
+
// store inexistente, un 4xx desconocido: R3) es del DESPLIEGUE, no del modelo: se propaga con su letra en vez de
|
|
589
|
+
// reportarse como negativa `reason: 'model'` con la receta del modelo (fail-closed igual: nada se escribe).
|
|
590
|
+
if (error?.status === 500 && error?.code === AuthorizationConfigError.code && !carriesHttpResponse(error)) {
|
|
551
591
|
push({ objectType, relation, reason: 'model', message: String(error?.message ?? error), recipe: denyRefusalRecipe('model', objectType, relation) });
|
|
552
592
|
continue;
|
|
553
593
|
}
|
|
@@ -688,7 +728,7 @@ function partitionsOf(options) {
|
|
|
688
728
|
});
|
|
689
729
|
}
|
|
690
730
|
function planPartition(partition, enumerated, destTuples, context) {
|
|
691
|
-
const { now, prune, toConfig, atomicRenewal, cuts } = context;
|
|
731
|
+
const { now, prune, toConfig, atomicRenewal, cuts, groupDepthLimit = null } = context;
|
|
692
732
|
// R-15: la caducada LLEGA del origen (el puerto lo exige) y aquí se DESCARTA
|
|
693
733
|
// contándola: no se migra ni respalda nada en el destino. Decidido con UN
|
|
694
734
|
// `now` para toda la pasada.
|
|
@@ -706,12 +746,30 @@ function planPartition(partition, enumerated, destTuples, context) {
|
|
|
706
746
|
const usersetResidue = [];
|
|
707
747
|
const undeclaredResidue = [];
|
|
708
748
|
const denyRefusals = [];
|
|
749
|
+
/** Cierre de L11 (🟡 A2): las membresías (origen o destino) con una condición que el paquete no escribe ni sabe leer. */
|
|
750
|
+
const invalidCarriers = [];
|
|
709
751
|
const refuse = (tuple, reason, message) => {
|
|
710
752
|
const objectType = String(tuple?.object?.type);
|
|
711
753
|
const relation = String(tuple?.relation);
|
|
712
754
|
denyRefusals.push({ objectType, relation, reason, message, recipe: denyRefusalRecipe(reason, objectType, relation) });
|
|
713
755
|
};
|
|
714
756
|
for (const tuple of enumerated) {
|
|
757
|
+
// L11 (injerto 3 del juez): una tupla del ORIGEN cuya condición el paquete no escribe o no sabe leer
|
|
758
|
+
// (`invalidCondition`, `fgaTupleExpiry`) NO se migra —su `expiresAt` null no es «permanente»—; se lista y es deriva.
|
|
759
|
+
// Un deny así NIEGA la PASADA (500 `E_AUTHZ_DENY_MIGRATION_REFUSED`: perder un deny no es una pérdida declarable); una
|
|
760
|
+
// MEMBRESÍA así en una partición con algún deny niega SU partición (cierre de L11, abajo).
|
|
761
|
+
if (typeof tuple?.invalidCondition === 'string') {
|
|
762
|
+
if (kindOf(tuple) === 'deny') {
|
|
763
|
+
refuse(tuple, 'invalid', `condición que el paquete no escribe: ${tuple.invalidCondition}`);
|
|
764
|
+
continue;
|
|
765
|
+
}
|
|
766
|
+
skipped.invalid += 1;
|
|
767
|
+
invalid.push({ side: 'origen', reason: 'invalid-condition', tuple: describeTuple(tuple), message: tuple.invalidCondition });
|
|
768
|
+
// Cierre de L11 (🟡 A2): una MEMBRESÍA así puede ser una portadora VIVA — se decide abajo si niega la partición.
|
|
769
|
+
if (tuple?.object?.type === RELATION_USERSET_TYPE)
|
|
770
|
+
invalidCarriers.push(tuple);
|
|
771
|
+
continue;
|
|
772
|
+
}
|
|
715
773
|
if (!isActiveExpiry(tuple.expiresAt ?? null, now)) {
|
|
716
774
|
skipped.expired += 1;
|
|
717
775
|
continue;
|
|
@@ -794,6 +852,8 @@ function planPartition(partition, enumerated, destTuples, context) {
|
|
|
794
852
|
/** Cierre 4 de D2 · 🟡 C3-2: los denies del destino VENCIDOS (no cuentan como presentes, pero el destino los tiene). */
|
|
795
853
|
const expiredDenyByKey = new Map();
|
|
796
854
|
const residue = new Set();
|
|
855
|
+
/** L11: las claves del destino con una condición que el paquete no escribe: el origen no las reescribe. */
|
|
856
|
+
const blockedKeys = new Set();
|
|
797
857
|
for (const tuple of destTuples) {
|
|
798
858
|
// ⚪ 8 (cierre 1 de D2): un deny del DESTINO sin `kind` (un driver de terceros que no marca) es la MISMA negativa
|
|
799
859
|
// `unmarked` que en el origen, decidida en el PLAN: planificarlo como allow lo podaría con `unrelate(denied_*)` (que
|
|
@@ -831,6 +891,20 @@ function planPartition(partition, enumerated, destTuples, context) {
|
|
|
831
891
|
undeclaredResidue.push({ side: 'destino', reason: 'undeclared', tuple: describeTuple(tuple), message: undeclared });
|
|
832
892
|
continue;
|
|
833
893
|
}
|
|
894
|
+
// L11 · H8: una tupla del DESTINO con una condición que el paquete no escribe es PRESENTE e INTOCABLE: `extra` con
|
|
895
|
+
// motivo y nunca `toDelete` (ni con `--prune`), y su CLAVE no se reescribe desde el origen (un `relate` sobre ella es
|
|
896
|
+
// 409 a mitad de pasada). No entra en el contexto del orden, y NO porque el servidor la ignore —un `not_expired` con
|
|
897
|
+
// `current_time` congelado está VIVO (medido, H2)—, sino porque si es una MEMBRESÍA (la única forma de transportar un
|
|
898
|
+
// deny) en una partición con algún deny, la partición se NIEGA (cierre de L11, 🟡 A2: cero escrituras, el orden no se
|
|
899
|
+
// usa); sin ningún deny en la partición no transporta nada que el orden tenga que ordenar.
|
|
900
|
+
if (typeof tuple?.invalidCondition === 'string') {
|
|
901
|
+
residue.add(tuple);
|
|
902
|
+
invalid.push({ side: 'destino', reason: 'invalid-condition', tuple: describeTuple(tuple), message: tuple.invalidCondition });
|
|
903
|
+
blockedKeys.add(keyed.key);
|
|
904
|
+
if (tuple?.object?.type === RELATION_USERSET_TYPE)
|
|
905
|
+
invalidCarriers.push(tuple);
|
|
906
|
+
continue;
|
|
907
|
+
}
|
|
834
908
|
// Cierre 3 de D2 · 🟠 C2-1 / 🟡 C2-2: una tupla del destino VENCIDA en `now` (el MISMO `now` con el que el origen
|
|
835
909
|
// descarta las suyas) no conecta nada —ni en `database` ni en FGA— y el plan la trata como AUSENTE: su reescritura es
|
|
836
910
|
// una escritura normal (nunca `toUpdate`, nunca `renewal-unsafe`) y no entra en el contexto del orden (ni portadora,
|
|
@@ -844,6 +918,27 @@ function planPartition(partition, enumerated, destTuples, context) {
|
|
|
844
918
|
}
|
|
845
919
|
destByKey.set(keyed.key, tuple);
|
|
846
920
|
}
|
|
921
|
+
// L11 · H8: una clave que el destino tiene con una condición que el paquete no escribe NO se reescribe desde el origen
|
|
922
|
+
// (sería un `relate` que el driver rechaza con 409 a mitad de pasada): la tupla del origen se lista y es deriva; un deny
|
|
923
|
+
// NIEGA la partición. Sale también del contexto del orden (no se va a escribir).
|
|
924
|
+
for (const key of blockedKeys) {
|
|
925
|
+
const tuple = sourceByKey.get(key);
|
|
926
|
+
if (!tuple)
|
|
927
|
+
continue;
|
|
928
|
+
sourceByKey.delete(key);
|
|
929
|
+
sourceTuples.splice(sourceTuples.indexOf(tuple), 1);
|
|
930
|
+
if (kindOf(tuple) === 'deny') {
|
|
931
|
+
refuse(tuple, 'invalid', `el DESTINO tiene ${describeTuple(tuple)} con una condición que el paquete no escribe`);
|
|
932
|
+
continue;
|
|
933
|
+
}
|
|
934
|
+
skipped.invalid += 1;
|
|
935
|
+
invalid.push({
|
|
936
|
+
side: 'origen',
|
|
937
|
+
reason: 'invalid-condition',
|
|
938
|
+
tuple: describeTuple(tuple),
|
|
939
|
+
message: 'la clave existe en el DESTINO con una condición que el paquete no escribe: no se reescribe (bórrala allí a mano y repite)',
|
|
940
|
+
});
|
|
941
|
+
}
|
|
847
942
|
const toWrite = [];
|
|
848
943
|
const toUpdate = [];
|
|
849
944
|
const denyWrite = [];
|
|
@@ -968,14 +1063,58 @@ function planPartition(partition, enumerated, destTuples, context) {
|
|
|
968
1063
|
// (también con `--prune` y las vencidas): queda EXACTAMENTE como estaba, que es lo único certificable; el resto migra.
|
|
969
1064
|
// Sustituye a la retención del cierre 4 (`relationWithheldWrites`, `withheld-by-unsafe-deny`, `carrier-conflict`): retener
|
|
970
1065
|
// tupla a tupla no cubría lo que el destino YA concedía (D4-1) y su negativa era global (D4-3).
|
|
971
|
-
const causes = [...denyConditional, ...renewalUnsafe].map((detail) => ({
|
|
1066
|
+
const causes = [...denyConditional, ...renewalUnsafe].map((detail) => ({
|
|
1067
|
+
reason: detail.reason,
|
|
1068
|
+
tuple: detail.tuple,
|
|
1069
|
+
}));
|
|
1070
|
+
// **Cierre de L11 (🟡 A2 del auditor; criterio del dueño: ante la duda, la opción que nunca concede de más)**: una
|
|
1071
|
+
// membresía `foreign`/`unparseable` (origen o destino) en una partición con ALGÚN deny (origen o destino, vivo o no,
|
|
1072
|
+
// marcado o no) NIEGA la partición. El servidor puede estar evaluándola VIVA y transportando ese deny; migrarla sin ella
|
|
1073
|
+
// —o escribir en el destino alrededor de ella— fabricaba un `check = true` que ningún estado del origen concedía (medido:
|
|
1074
|
+
// `.claude/reproducciones/loteRC-L11-auditor-carrier.ts`). Mismo mecanismo que la opción A: cero escrituras en ella.
|
|
1075
|
+
const anyDeny = [...enumerated, ...destTuples].some((tuple) => kindOf(tuple) === 'deny' || String(tuple?.relation ?? '').startsWith(RELATION_DENY_PREFIX));
|
|
1076
|
+
if (anyDeny)
|
|
1077
|
+
for (const tuple of invalidCarriers)
|
|
1078
|
+
causes.push({ reason: 'invalid-condition', tuple: describeTuple(tuple) });
|
|
1079
|
+
// **R1 (decisión del dueño 2026-10-17 (5); 🔴 C-1 del auditor)**: la cadena de grupos anidados que dejaría la pasada
|
|
1080
|
+
// —el estado FINAL (T-5 del tester): las aristas vivas del origen, más las del destino que se QUEDAN (sin `--prune`,
|
|
1081
|
+
// todas las vivas; con él y sin él, TODO el residuo intocable que siga vivo —`invalid-identity`, `undeclared`,
|
|
1082
|
+
// `invalid-condition`, …: nadie lo borra y el servidor lo evalúa; cierre de R1 · 🟡 3 del auditor, hasta entonces
|
|
1083
|
+
// solo `invalid-condition`)—, y las de condición ilegible cuentan como vivas— no puede pasar
|
|
1084
|
+
// del techo del destino. Si pasa, la partición se NIEGA (cero escrituras): saltar la arista que excede quitaría los
|
|
1085
|
+
// denies que transporta y el destino concedería lo que el origen niega. Un ciclo NO niega por sí solo (OpenFGA lo
|
|
1086
|
+
// evalúa bien): cuenta, de forma conservadora, como k − 1 saltos de su recorrido.
|
|
1087
|
+
let tooDeep;
|
|
1088
|
+
if (groupDepthLimit !== null) {
|
|
1089
|
+
const edges = [];
|
|
1090
|
+
const push = (tuple) => {
|
|
1091
|
+
if (isNestingTuple(tuple))
|
|
1092
|
+
edges.push({ child: tuple.subject.object.id, parent: tuple.object.id });
|
|
1093
|
+
};
|
|
1094
|
+
for (const tuple of sourceTuples)
|
|
1095
|
+
push(tuple);
|
|
1096
|
+
if (!prune)
|
|
1097
|
+
for (const tuple of destByKey.values())
|
|
1098
|
+
push(tuple);
|
|
1099
|
+
for (const tuple of destTuples)
|
|
1100
|
+
if (residue.has(tuple) && residueAlive(tuple, now))
|
|
1101
|
+
push(tuple);
|
|
1102
|
+
const { depth, chain } = groupChainDepth(edges);
|
|
1103
|
+
if (depth > groupDepthLimit) {
|
|
1104
|
+
tooDeep = { depth, limit: groupDepthLimit, chain };
|
|
1105
|
+
causes.push({
|
|
1106
|
+
reason: 'too-deep',
|
|
1107
|
+
tuple: `cadena de ${depth} saltos de grupos anidados > techo ${groupDepthLimit} del destino: ${chain.slice(0, 8).join(' ⊂ ')}${chain.length > 8 ? ' ⊂ …' : ''}`,
|
|
1108
|
+
});
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
972
1111
|
const refusal = causes.length > 0
|
|
973
1112
|
? {
|
|
974
1113
|
partition: scopeKey(partition),
|
|
975
1114
|
causes,
|
|
976
1115
|
pending: {
|
|
977
1116
|
write: toWrite.length + denyWrite.length,
|
|
978
|
-
update: toUpdate.length + denyUpdate.length +
|
|
1117
|
+
update: toUpdate.length + denyUpdate.length + denyConditional.length + renewalUnsafe.length,
|
|
979
1118
|
delete: prune ? allowDelete.length + denyDelete.length : 0,
|
|
980
1119
|
},
|
|
981
1120
|
recipe: partitionRefusalRecipe(causes.map((cause) => cause.reason)),
|
|
@@ -1007,8 +1146,39 @@ function planPartition(partition, enumerated, destTuples, context) {
|
|
|
1007
1146
|
shortening,
|
|
1008
1147
|
denyConditional: denyConditional.length,
|
|
1009
1148
|
refusal,
|
|
1149
|
+
...(tooDeep ? { tooDeep } : {}),
|
|
1010
1150
|
};
|
|
1011
1151
|
}
|
|
1152
|
+
/**
|
|
1153
|
+
* R1: ¿es una arista de anidación `group:P#member@group:C#member` (allow)? La misma forma que `isGroupNesting`, y con
|
|
1154
|
+
* ids de TEXTO (el residuo `invalid-identity` del destino puede traer cualquier cosa: con un id que no es texto no hay
|
|
1155
|
+
* arista que contar).
|
|
1156
|
+
*/
|
|
1157
|
+
function isNestingTuple(tuple) {
|
|
1158
|
+
return (kindOf(tuple) === 'allow' &&
|
|
1159
|
+
tuple?.object?.type === RELATION_USERSET_TYPE &&
|
|
1160
|
+
typeof tuple.object.id === 'string' &&
|
|
1161
|
+
tuple.relation === 'member' &&
|
|
1162
|
+
isRelUserset(tuple.subject) &&
|
|
1163
|
+
tuple.subject.object?.type === RELATION_USERSET_TYPE &&
|
|
1164
|
+
typeof tuple.subject.object.id === 'string' &&
|
|
1165
|
+
tuple.subject.relation === 'member');
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* R1 · 🟡 3: ¿sigue VIVA en el servidor una tupla del RESIDUO del destino? Una con condición ilegible, sí (no se sabe
|
|
1169
|
+
* leer: cuenta, más negativas nunca menos); una con caducidad legible, si no ha vencido en `now`; una caducidad que no
|
|
1170
|
+
* se entiende, también viva (conservador).
|
|
1171
|
+
*/
|
|
1172
|
+
function residueAlive(tuple, now) {
|
|
1173
|
+
if (typeof tuple?.invalidCondition === 'string')
|
|
1174
|
+
return true;
|
|
1175
|
+
try {
|
|
1176
|
+
return liveInDestination(tuple, now);
|
|
1177
|
+
}
|
|
1178
|
+
catch {
|
|
1179
|
+
return true;
|
|
1180
|
+
}
|
|
1181
|
+
}
|
|
1012
1182
|
/**
|
|
1013
1183
|
* **¿Cuenta una tupla del DESTINO en el plan?** (cierre 3 de D2 · 🟠 C2-1 / 🟡 C2-2): sí si está VIVA en `now` (el mismo
|
|
1014
1184
|
* `now` de toda la pasada, con el que el origen descarta sus caducadas); una VENCIDA es AUSENTE — no conecta nada en
|
|
@@ -1036,6 +1206,62 @@ function orderView(tuple) {
|
|
|
1036
1206
|
function kindOf(tuple) {
|
|
1037
1207
|
return tuple?.kind === 'deny' ? 'deny' : 'allow';
|
|
1038
1208
|
}
|
|
1209
|
+
/**
|
|
1210
|
+
* **El detector de (8) sobre lo ya enumerado**: agrupa los denies de cada lado por tipo y relación pública —contando CLAVES
|
|
1211
|
+
* DE TUPLA distintas (`relationTupleKey`): un deny espejado en origen y destino es uno, firma (8 · `count`)— y les pasa
|
|
1212
|
+
* `denyReachCheck` (la MISMA función que la guarda de `database` y el detector de un driver), en las dos direcciones. Un deny
|
|
1213
|
+
* es una tupla con `kind: 'deny'` (o, sin marcar, con la relación almacenada `denied_*`: la cuenta igual — la puerta del
|
|
1214
|
+
* deny ya la niega aparte como `unmarked`). LÍMITE: lo que no se enumera no se ve (un TIPO retirado de la config del
|
|
1215
|
+
* driver no se enumera; la guarda de `database` y `checkRelationsConfigDenyReach` sí leen la tabla entera).
|
|
1216
|
+
*/
|
|
1217
|
+
function detectDenyReach(options, source, destination) {
|
|
1218
|
+
const sides = new Map();
|
|
1219
|
+
const keys = new Map();
|
|
1220
|
+
for (const [side, byKey] of [['origen', source], ['destino', destination]]) {
|
|
1221
|
+
for (const tuples of byKey.values()) {
|
|
1222
|
+
for (const tuple of tuples) {
|
|
1223
|
+
const stored = typeof tuple?.relation === 'string' ? tuple.relation : '';
|
|
1224
|
+
const relation = kindOf(tuple) === 'deny' ? stored : stored.startsWith(RELATION_DENY_PREFIX) ? stored.slice(RELATION_DENY_PREFIX.length) : null;
|
|
1225
|
+
const objectType = tuple?.object?.type;
|
|
1226
|
+
if (!relation || typeof objectType !== 'string')
|
|
1227
|
+
continue;
|
|
1228
|
+
const group = `${objectType}#${relation}`;
|
|
1229
|
+
let identity;
|
|
1230
|
+
try {
|
|
1231
|
+
identity = relationTupleKey({ ...tuple, kind: 'deny', relation });
|
|
1232
|
+
}
|
|
1233
|
+
catch {
|
|
1234
|
+
identity = JSON.stringify([scopeKeyOrNull(tuple?.partition), objectType, tuple?.object?.id, relation, tuple?.subject]);
|
|
1235
|
+
}
|
|
1236
|
+
keys.set(group, (keys.get(group) ?? new Set()).add(identity));
|
|
1237
|
+
sides.set(group, (sides.get(group) ?? new Set()).add(side));
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
const groups = [...keys].map(([group, identities]) => {
|
|
1242
|
+
const [objectType, relation] = group.split('#');
|
|
1243
|
+
return { objectType, relation, count: identities.size };
|
|
1244
|
+
});
|
|
1245
|
+
const check = denyReachCheck(options.baseline, options.current, groups);
|
|
1246
|
+
const withSides = (finding) => ({
|
|
1247
|
+
...finding,
|
|
1248
|
+
sides: [...(sides.get(`${finding.objectType}#${finding.relation}`) ?? [])].sort(),
|
|
1249
|
+
});
|
|
1250
|
+
return {
|
|
1251
|
+
baseline: check.baseline,
|
|
1252
|
+
findings: check.findings.filter((finding) => finding.reason !== 'no-baseline').map(withSides),
|
|
1253
|
+
stale: check.staleBaseline.map(withSides),
|
|
1254
|
+
unjudged: check.findings.filter((finding) => finding.reason === 'no-baseline').reduce((total, finding) => total + finding.count, 0),
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
function scopeKeyOrNull(partition) {
|
|
1258
|
+
try {
|
|
1259
|
+
return scopeKey(partition);
|
|
1260
|
+
}
|
|
1261
|
+
catch {
|
|
1262
|
+
return null;
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1039
1265
|
/**
|
|
1040
1266
|
* **F-05 del destino sobre una fila ENUMERADA del destino** (cierre 3 del lote B ·
|
|
1041
1267
|
* 🟠 N9, capa 1): la MISMA `assertRelationDeclared` que corre en la primera línea
|
|
@@ -1072,6 +1298,20 @@ export const RELATION_REJECTIONS = Object.freeze({
|
|
|
1072
1298
|
[RelationNotDenyableError.code]: { kind: 'refused', refusal: 'phase', where: "denyGate: denyPhase del DESTINO ≠ 'write' ⇒ 500 antes de escribir" },
|
|
1073
1299
|
[RelationDenyExpiryError.code]: { kind: 'unreachable', why: 'el reconcile nunca pasa expiresAt a deny/removeDeny (D3: el deny no caduca)' },
|
|
1074
1300
|
[CheckManyBatchError.code]: { kind: 'unreachable', why: 'el reconcile no llama a checkMany ni a decideMany' },
|
|
1301
|
+
// Lote «antes de RC» · R1: el gate de anidación de grupos de los drivers del paquete. La profundidad la decide el PLAN
|
|
1302
|
+
// (partición NEGADA, causa `too-deep`, sobre el estado FINAL) y la pasada escribe con la marca del plan (`plannedRelateOptions`),
|
|
1303
|
+
// que esos drivers respetan (un ciclo preexistente se migra; un estado intermedio no se certifica). Un driver de
|
|
1304
|
+
// terceros que lo lance igual se PROPAGA (fail-closed: la pasada se corta con su marca de parcial).
|
|
1305
|
+
[GroupDepthLimitError.code]: {
|
|
1306
|
+
kind: 'unreachable',
|
|
1307
|
+
why: 'planPartition decide la profundidad (partitionsRefused too-deep) y relateOrSkip escribe con plannedRelateOptions (marca no falsificable atada a la arista): el gate de los drivers del paquete no se consulta en la pasada',
|
|
1308
|
+
},
|
|
1309
|
+
// Lote «antes de RC» · 7.4: la página de `enumerate*`. El reconcile no pasa `limit` y como `after` solo el cursor que el
|
|
1310
|
+
// ORIGEN acaba de emitir (`enumerateAllMany`); un 422 de página ahí es un driver que rechaza su propio cursor: se propaga.
|
|
1311
|
+
[InvalidRelationPageError.code]: {
|
|
1312
|
+
kind: 'unreachable',
|
|
1313
|
+
why: 'enumerateAllMany no pasa limit y solo reenvía como after el cursor que el ORIGEN acaba de emitir: un 422 de página sería un driver que rechaza su propio cursor (se propaga, la pasada no empieza a escribir)',
|
|
1314
|
+
},
|
|
1075
1315
|
[RelationConfigError.code]: { kind: 'unreachable', why: 'se lanza al DEFINIR la config; cuando el reconcile corre los drivers ya están construidos' },
|
|
1076
1316
|
[RelationConfigUnknownKeyError.code]: { kind: 'unreachable', why: 'se lanza al DEFINIR la config (clave desconocida de la spec, B2)' },
|
|
1077
1317
|
[InvalidSlugError.code]: { kind: 'unreachable', why: 'gramática de SLUGS del catálogo de roles: ninguna escritura de relaciones la invoca' },
|
|
@@ -1102,6 +1342,10 @@ export const RELATION_422_REASONS = Object.freeze(Object.fromEntries(Object.entr
|
|
|
1102
1342
|
*/
|
|
1103
1343
|
export const RELATION_RESIDUES = Object.freeze({
|
|
1104
1344
|
'invalid-identity': { where: 'plan', how: 'enumeratedKey (origen y destino); capa 2 por RELATION_REJECTIONS' },
|
|
1345
|
+
'invalid-condition': {
|
|
1346
|
+
where: 'plan',
|
|
1347
|
+
how: 'planPartition (L11): una tupla con RelationTuple.invalidCondition — origen: no se migra (skipped.invalid; un deny NIEGA la pasada); destino: presente e intocable (extra, nunca toDelete) y su clave no se reescribe desde el origen; una membresía de group así en una partición con algún deny NIEGA la partición (partitionsRefused, cierre de L11)',
|
|
1348
|
+
},
|
|
1105
1349
|
'userset-unsupported': { where: 'plan', how: 'classifySubject (origen y destino, con o sin toConfig); capa 2 por RELATION_REJECTIONS' },
|
|
1106
1350
|
'undeclared': { where: 'plan', how: 'classifyDestinationRow (destino, con toConfig); sin toConfig, capa 2 por RELATION_REJECTIONS' },
|
|
1107
1351
|
'renewal-unsafe': {
|
|
@@ -1156,9 +1400,10 @@ function declaredIn(config, objectType) {
|
|
|
1156
1400
|
*/
|
|
1157
1401
|
async function relateOrSkip(to, tuple) {
|
|
1158
1402
|
try {
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1403
|
+
// R1: la profundidad de los grupos ya la decidió el PLAN sobre el estado final (y negó la partición si excedía); el
|
|
1404
|
+
// gate del driver no se repite aquí (`plannedRelateOptions`: un ciclo preexistente se migra, y un estado intermedio
|
|
1405
|
+
// de la pasada no es el que se certifica). La marca es la IDENTIDAD del objeto, atada a ESTA arista.
|
|
1406
|
+
await to.relate(tuple.subject, tuple.relation, tuple.object, tuple.partition, plannedRelateOptions({ expiresAt: tuple.expiresAt ?? null }, tuple));
|
|
1162
1407
|
return true;
|
|
1163
1408
|
}
|
|
1164
1409
|
catch (error) {
|
|
@@ -1169,4 +1414,13 @@ async function relateOrSkip(to, tuple) {
|
|
|
1169
1414
|
throw error;
|
|
1170
1415
|
}
|
|
1171
1416
|
}
|
|
1172
|
-
|
|
1417
|
+
/** ¿Trae la cadena de `cause` una respuesta HTTP del backend (`statusCode` numérico, la forma del SDK de OpenFGA)? */
|
|
1418
|
+
function carriesHttpResponse(error) {
|
|
1419
|
+
let current = error;
|
|
1420
|
+
for (let depth = 0; current && depth < 6; depth++) {
|
|
1421
|
+
if (typeof current.statusCode === 'number')
|
|
1422
|
+
return true;
|
|
1423
|
+
current = current.cause;
|
|
1424
|
+
}
|
|
1425
|
+
return false;
|
|
1426
|
+
}
|
|
@@ -57,4 +57,3 @@ export function relationTupleKey(tuple) {
|
|
|
57
57
|
assertRelationId('la relación', tuple.relation);
|
|
58
58
|
return `${kind}|${scopeKey(tuple.partition)}|${tuple.object.type}:${tuple.object.id}#${tuple.relation}@${relationSubjectKey(tuple.subject)}`;
|
|
59
59
|
}
|
|
60
|
-
//# sourceMappingURL=tuple_key.js.map
|
|
@@ -42,8 +42,10 @@
|
|
|
42
42
|
* reparto NO depende del orden de enumeración del origen ni del `Read`.
|
|
43
43
|
*
|
|
44
44
|
* **Lo que garantiza, exactamente** (demostrado por fuerza bruta; README «Denies migrate as denies»): **si el grafo de
|
|
45
|
-
* precedencias no tiene un CICLO, ningún
|
|
46
|
-
* ningún sujeto denegado en los dos concede en ninguno.**
|
|
45
|
+
* precedencias no tiene un CICLO, ningún PREFIJO (el estado del store tras cada `Write` de la secuencia) concede algo que
|
|
46
|
+
* no concedían el estado inicial o el final, y ningún sujeto denegado en los dos concede en ninguno.** Es una garantía
|
|
47
|
+
* sobre PREFIJOS —una operación CORTADA—, no sobre lectores: un `Check` que abarca dos `Write` consecutivos puede mezclar
|
|
48
|
+
* lecturas de uno y otro (lector rasgado, fila «torn read» de Limits; decisión (6a) del dueño, linaje L12). El RESIDUO es exactamente el ciclo (`residue`): dos membresías
|
|
47
49
|
* que cambian en la misma pasada cuyas exigencias se contradicen —la forma típica: el mismo sujeto entra (o sale) a la
|
|
48
50
|
* vez en dos grupos y cada uno trae un deny que corta un allow que trae el otro—. Ahí NO existe en general ningún orden
|
|
49
51
|
* seguro (la fuerza bruta lo mide: de los casos del ciclo que conceden, una parte no admite NINGUNA permutación del diff
|
|
@@ -111,7 +113,7 @@ export declare function denyCarrierGroups(views: Iterable<RelationOrderView>): S
|
|
|
111
113
|
export declare function carriedDenies(views: Iterable<RelationOrderView>, group: string): RelationOrderView[];
|
|
112
114
|
/**
|
|
113
115
|
* **El orden de una TRANSICIÓN** (cierre 2 de D2 · 🟡 N3): ver el docblock del módulo. `residue` = las membresías de los
|
|
114
|
-
* ciclos del grafo de precedencias (sin garantía); vacío ⇒ ningún
|
|
116
|
+
* ciclos del grafo de precedencias (sin garantía); vacío ⇒ ningún PREFIJO concede fuera de inicial ∪ final.
|
|
115
117
|
*/
|
|
116
118
|
export declare function relationTransitionOrder<T>(transition: RelationTransition<T>, view: (item: T) => RelationOrderView, options?: {
|
|
117
119
|
cuts?: RelationCuts;
|
|
@@ -123,4 +125,3 @@ export declare function relationTransitionOrder<T>(transition: RelationTransitio
|
|
|
123
125
|
export declare function relationWriteOrder<T>(items: readonly T[], view: (item: T) => RelationOrderView, context?: readonly RelationOrderView[]): T[];
|
|
124
126
|
/** **El orden de BORRADO** de un conjunto que solo SALE (las purgas de `openfga`): allows → membresías → denies. */
|
|
125
127
|
export declare function relationDeleteOrder<T>(items: readonly T[], view: (item: T) => RelationOrderView, context?: readonly RelationOrderView[]): T[];
|
|
126
|
-
//# sourceMappingURL=tuple_order.d.ts.map
|
|
@@ -42,8 +42,10 @@
|
|
|
42
42
|
* reparto NO depende del orden de enumeración del origen ni del `Read`.
|
|
43
43
|
*
|
|
44
44
|
* **Lo que garantiza, exactamente** (demostrado por fuerza bruta; README «Denies migrate as denies»): **si el grafo de
|
|
45
|
-
* precedencias no tiene un CICLO, ningún
|
|
46
|
-
* ningún sujeto denegado en los dos concede en ninguno.**
|
|
45
|
+
* precedencias no tiene un CICLO, ningún PREFIJO (el estado del store tras cada `Write` de la secuencia) concede algo que
|
|
46
|
+
* no concedían el estado inicial o el final, y ningún sujeto denegado en los dos concede en ninguno.** Es una garantía
|
|
47
|
+
* sobre PREFIJOS —una operación CORTADA—, no sobre lectores: un `Check` que abarca dos `Write` consecutivos puede mezclar
|
|
48
|
+
* lecturas de uno y otro (lector rasgado, fila «torn read» de Limits; decisión (6a) del dueño, linaje L12). El RESIDUO es exactamente el ciclo (`residue`): dos membresías
|
|
47
49
|
* que cambian en la misma pasada cuyas exigencias se contradicen —la forma típica: el mismo sujeto entra (o sale) a la
|
|
48
50
|
* vez en dos grupos y cada uno trae un deny que corta un allow que trae el otro—. Ahí NO existe en general ningún orden
|
|
49
51
|
* seguro (la fuerza bruta lo mide: de los casos del ciclo que conceden, una parte no admite NINGUNA permutación del diff
|
|
@@ -240,7 +242,7 @@ function reach(graph, index, group) {
|
|
|
240
242
|
}
|
|
241
243
|
/**
|
|
242
244
|
* **El orden de una TRANSICIÓN** (cierre 2 de D2 · 🟡 N3): ver el docblock del módulo. `residue` = las membresías de los
|
|
243
|
-
* ciclos del grafo de precedencias (sin garantía); vacío ⇒ ningún
|
|
245
|
+
* ciclos del grafo de precedencias (sin garantía); vacío ⇒ ningún PREFIJO concede fuera de inicial ∪ final.
|
|
244
246
|
*/
|
|
245
247
|
export function relationTransitionOrder(transition, view, options = {}) {
|
|
246
248
|
const writes = (transition.writes ?? []).map((item) => ({ item, view: view(item) }));
|
|
@@ -595,4 +597,3 @@ export function relationDeleteOrder(items, view, context = []) {
|
|
|
595
597
|
* `refusal`), y el resto migra. Una partición negada queda exactamente como estaba, que es lo único que se puede
|
|
596
598
|
* certificar sin una escritura atómica de la renovación (openfga/openfga#2151).
|
|
597
599
|
*/
|
|
598
|
-
//# sourceMappingURL=tuple_order.js.map
|
|
@@ -1,8 +1,33 @@
|
|
|
1
1
|
import type { RelationsConfig, RelationsConfigSpec } from './relations/define_relations_config.js';
|
|
2
|
+
import type { DenyReachFinding } from './relations/deny_reach.js';
|
|
2
3
|
export interface RelationsConfigStoreOptions {
|
|
3
4
|
connection?: string;
|
|
4
5
|
timeoutMs?: number;
|
|
5
6
|
}
|
|
7
|
+
/**
|
|
8
|
+
* Opciones de `saveRelationsConfig`: las del store más las DOS salidas con nombre de la guarda del alcance de los denies
|
|
9
|
+
* (8), separadas a propósito (A3 del auditor del panel: una salida que se usa como paso rutinario de adopción y no caduca es
|
|
10
|
+
* un interruptor de apagado).
|
|
11
|
+
*/
|
|
12
|
+
export interface SaveRelationsConfigOptions extends RelationsConfigStoreOptions {
|
|
13
|
+
/**
|
|
14
|
+
* **La adopción de la línea base** (firma (8 · línea base)): levanta SOLO el motivo `no-baseline`, y SOLO cuando no hay
|
|
15
|
+
* línea base utilizable (sin fila, o una fila que la lectura TODO O NADA no puede tipar); con una línea base utilizable es
|
|
16
|
+
* INERTE (no sirve para estrechar después). Sigue juzgando los huérfanos. Solo `true` la levanta; un valor que no es
|
|
17
|
+
* boolean es 500 `E_AUTHZ_CONFIG` (L5). Úsala UNA vez, con la spec que la flota sirve HOY, en una release que no cambie
|
|
18
|
+
* `relations.config` (adoptar en una release que estrecha certifica el estrechamiento: A4).
|
|
19
|
+
*/
|
|
20
|
+
establishBaseline?: boolean;
|
|
21
|
+
/**
|
|
22
|
+
* **La salida forzada ACOTADA** (firma (8 · salida forzada)): la lista de `tipo#relación` cuyos hallazgos `shrunk`/`orphan`
|
|
23
|
+
* se guardan aunque encojan el alcance de sus denies vivos. Exime SOLO esos: un hallazgo no nombrado se niega igual (un
|
|
24
|
+
* encogimiento nuevo en otra relación no viaja en el mismo guardado forzado). No exime `no-baseline` (esa es
|
|
25
|
+
* `establishBaseline`). No hay `true` global: cualquier forma que no sea un array de `tipo#relación` es 500
|
|
26
|
+
* `E_AUTHZ_CONFIG` (L5). Y un nombre que en ESE guardado no exime ningún `shrunk`/`orphan` también es 500
|
|
27
|
+
* `E_AUTHZ_CONFIG` sin escribir (cierre 1 del sublote (8)): la lista se retira tras el guardado que la necesita.
|
|
28
|
+
*/
|
|
29
|
+
allowDenyReachShrink?: readonly string[];
|
|
30
|
+
}
|
|
6
31
|
/**
|
|
7
32
|
* **Guarda (persiste) la config de relaciones** bajo el gate de versión: la
|
|
8
33
|
* valida (⚪4/F-05 de config), la serializa a JSON y hace UPSERT de la fila
|
|
@@ -10,7 +35,38 @@ export interface RelationsConfigStoreOptions {
|
|
|
10
35
|
* última sentencia (invariante 14): un sync en otro proceso la ve en la
|
|
11
36
|
* siguiente pregunta—. Devuelve la config congelada.
|
|
12
37
|
*/
|
|
13
|
-
export declare function saveRelationsConfig(spec: RelationsConfigSpec, options?:
|
|
38
|
+
export declare function saveRelationsConfig(spec: RelationsConfigSpec, options?: SaveRelationsConfigOptions): Promise<RelationsConfig>;
|
|
39
|
+
/** Lo que devuelve `checkRelationsConfigDenyReach`. */
|
|
40
|
+
export interface RelationsConfigDenyReachCheck {
|
|
41
|
+
/** `'persisted'`: hay línea base utilizable; `'none'`: no la hay (sin fila, o una fila que no se puede tipar). */
|
|
42
|
+
baseline: 'persisted' | 'none';
|
|
43
|
+
/**
|
|
44
|
+
* Persistida → servida: `shrunk`, `orphan` (también sin línea base) y, con `baseline: 'none'` y denies vivos,
|
|
45
|
+
* `no-baseline` — lo mismo que juzgaría la guarda al guardar la config servida, sin salidas forzadas.
|
|
46
|
+
*/
|
|
47
|
+
findings: DenyReachFinding[];
|
|
48
|
+
/** Servida → persistida: `stale` — la línea base es más estrecha que lo servido en grupos con denies vivos (A1/S-B). */
|
|
49
|
+
staleBaseline: DenyReachFinding[];
|
|
50
|
+
/** `true` sii no hay ningún hallazgo en ninguna dirección (`no-baseline` cuenta como hallazgo). */
|
|
51
|
+
ok: boolean;
|
|
52
|
+
/** Una línea por hallazgo, con el prefijo estable de su rama (`denyReachLine`). */
|
|
53
|
+
lines: string[];
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* **El detector de UN solo driver** (firma (8 · detector de un driver), R4 del analista del panel): en un despliegue
|
|
57
|
+
* solo-`database` el detector de `authz:relations:reconcile --dry-run` no corre (necesita DOS drivers de relaciones:
|
|
58
|
+
* origen = destino es 500). Esta función compara la config que SIRVE el proceso (`served`) con la línea base persistida
|
|
59
|
+
* sobre los denies vivos de `authz_relations`, en las DOS direcciones:
|
|
60
|
+
* - persistida → servida: lo que la guarda negaría (`shrunk`, `orphan`, `no-baseline`);
|
|
61
|
+
* - servida → persistida: la línea base RANCIA (`stale`), que la guarda no puede ver.
|
|
62
|
+
*
|
|
63
|
+
* Solo LEE (la fila y un `GROUP BY` de `authz_relations`, por `options.connection` o la primaria), sin transacción y sin el
|
|
64
|
+
* cerrojo del catálogo, con el MISMO punto de paso que la guarda (`baselineOf` + `liveDenyGroups` + `denyReachCheck`).
|
|
65
|
+
* **Receta**: en CI, DESPUÉS del paso que guarda la config y ANTES de servir, y en un cron; falla el job si `ok` es `false`.
|
|
66
|
+
* **Nunca** como fallo duro en `ready`: durante un despliegue un pod viejo que se reinicia sirve la config estrecha contra la
|
|
67
|
+
* fila nueva y entraría en un bucle de reinicios.
|
|
68
|
+
*/
|
|
69
|
+
export declare function checkRelationsConfigDenyReach(served: Pick<RelationsConfig, 'objectTypes'>, options?: RelationsConfigStoreOptions): Promise<RelationsConfigDenyReachCheck>;
|
|
14
70
|
/** Lee el spec persistido (o `null` si no hay relaciones declaradas — opt-in). */
|
|
15
71
|
export declare function readRelationsConfigSpec(options?: RelationsConfigStoreOptions): Promise<RelationsConfigSpec | null>;
|
|
16
72
|
/**
|
|
@@ -19,4 +75,3 @@ export declare function readRelationsConfigSpec(options?: RelationsConfigStoreOp
|
|
|
19
75
|
* relación de la base, así que el modelo que republique los lleva.
|
|
20
76
|
*/
|
|
21
77
|
export declare function readRelationsConfig(options?: RelationsConfigStoreOptions): Promise<RelationsConfig | null>;
|
|
22
|
-
//# sourceMappingURL=relations_config_store.d.ts.map
|