@jantstack/adonis-authz 2.5.0-alpha.3 → 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 +414 -35
- 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 +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 +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 +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 +7 -5
- 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 +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 +11 -5
- package/build/src/drivers/database_relations_driver.js +90 -18
- 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 +121 -1
- package/build/src/errors.js +153 -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 +156 -28
- 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 +0 -1
- package/build/src/scope_outbox.js +0 -1
- 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 +7 -1
- package/build/src/testing/contract.js +77 -1
- 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 +89 -14
- package/build/src/types.js +4 -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
|
@@ -22,14 +22,45 @@
|
|
|
22
22
|
* sin uso (cleanup futuro).
|
|
23
23
|
*/
|
|
24
24
|
import { systemClock } from './clock.js';
|
|
25
|
-
import { RelationConfigError } from './errors.js';
|
|
25
|
+
import { AuthorizationConfigError, DenyReachRefusedError, RelationConfigError } from './errors.js';
|
|
26
26
|
import { guardSql } from './shared/backend_guard.js';
|
|
27
27
|
import db from '@adonisjs/lucid/services/db';
|
|
28
28
|
import { invalidateAuthzCatalog, withAuthzCatalogWrite } from './catalog/catalog_cache.js';
|
|
29
29
|
import { defineRelationsConfig } from './relations/define_relations_config.js';
|
|
30
|
+
import { DENY_REACH_KEY_PATTERN, deniedPublicRelation, denyReachBaselineOf, denyReachCheck, denyReachKey, denyReachLine, judgeDenyReach } from './relations/deny_reach.js';
|
|
30
31
|
const TABLE = 'authz_relations_config';
|
|
31
32
|
const ROW_ID = 1;
|
|
32
33
|
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
34
|
+
/** La lista de la salida forzada acotada, validada (L5): `undefined` ⇒ ninguna; array de `tipo#relación` ⇒ esa; otra cosa ⇒ 500. */
|
|
35
|
+
function namedShrinkExemptions(value) {
|
|
36
|
+
if (value === undefined)
|
|
37
|
+
return new Set();
|
|
38
|
+
const ok = Array.isArray(value) && value.every((key) => typeof key === 'string' && DENY_REACH_KEY_PATTERN.test(key));
|
|
39
|
+
if (!ok) {
|
|
40
|
+
throw new AuthorizationConfigError(`saveRelationsConfig: allowDenyReachShrink tiene que ser una lista de 'tipo#relación' (llegó ${JSON.stringify(value) ?? String(value)}). ` +
|
|
41
|
+
`Desde el sublote (8) la salida forzada exime SOLO los hallazgos que nombras —p. ej. { allowDenyReachShrink: ['note#editor'] }—; ` +
|
|
42
|
+
`no hay un true global.`);
|
|
43
|
+
}
|
|
44
|
+
return new Set(value);
|
|
45
|
+
}
|
|
46
|
+
/** Los nombres de la lista que no eximen ningún hallazgo `shrunk`/`orphan` de este guardado (orden de la lista). */
|
|
47
|
+
function unusedShrinkExemptions(exempt, judged) {
|
|
48
|
+
const used = new Set(judged.filter((finding) => finding.reason !== 'no-baseline').map(denyReachKey));
|
|
49
|
+
return [...exempt].filter((key) => !used.has(key));
|
|
50
|
+
}
|
|
51
|
+
function unusedShrinkExemptionsError(unused) {
|
|
52
|
+
return new AuthorizationConfigError(`saveRelationsConfig: allowDenyReachShrink nombra ${JSON.stringify(unused)}, y en este guardado ningún deny vivo de ` +
|
|
53
|
+
`esa(s) relación(es) encoge ni queda huérfano: no exime nada. Quita ese nombre de la lista (la salida forzada acotada ` +
|
|
54
|
+
`se usa en el guardado que la necesita y se retira después; pegada, eximiría en silencio un encogimiento futuro). ` +
|
|
55
|
+
`No se ha escrito nada (ni la fila ni la versión del catálogo).`);
|
|
56
|
+
}
|
|
57
|
+
/** `establishBaseline` validado (L5): solo `undefined`, `false` o `true`. */
|
|
58
|
+
function establishBaselineOf(value) {
|
|
59
|
+
if (value === undefined || value === false || value === true)
|
|
60
|
+
return value === true;
|
|
61
|
+
throw new AuthorizationConfigError(`saveRelationsConfig: establishBaseline tiene que ser true o false (llegó ${JSON.stringify(value) ?? String(value)}): ` +
|
|
62
|
+
`solo true adopta la línea base, y una opción mal escrita no puede levantar la guarda.`);
|
|
63
|
+
}
|
|
33
64
|
function connectionOf(options) {
|
|
34
65
|
return options?.connection ? db.connection(options.connection) : db;
|
|
35
66
|
}
|
|
@@ -41,21 +72,141 @@ function connectionOf(options) {
|
|
|
41
72
|
* siguiente pregunta—. Devuelve la config congelada.
|
|
42
73
|
*/
|
|
43
74
|
export async function saveRelationsConfig(spec, options = {}) {
|
|
44
|
-
// Valida ANTES de escribir: una config inválida no ensucia la tabla.
|
|
75
|
+
// Valida ANTES de escribir: una config inválida no ensucia la tabla. Las dos salidas de la guarda (8), también antes
|
|
76
|
+
// (L5: una forma rota es 500 sin tomar el cerrojo).
|
|
45
77
|
const config = defineRelationsConfig(spec);
|
|
46
|
-
const json = JSON.stringify(spec);
|
|
78
|
+
const json = JSON.stringify(persistableRelationsSpec(spec));
|
|
79
|
+
const { allowDenyReachShrink, establishBaseline, ...storeOptions } = options;
|
|
80
|
+
const exempt = namedShrinkExemptions(allowDenyReachShrink);
|
|
81
|
+
const adopt = establishBaselineOf(establishBaseline);
|
|
47
82
|
await withAuthzCatalogWrite(async (trx) => {
|
|
48
83
|
const existing = await trx.from(TABLE).where('id', ROW_ID).first();
|
|
84
|
+
// **La guarda (8) de `database`**, DENTRO de la transacción del catálogo (cerrojo de la fila de versión) y ANTES de la
|
|
85
|
+
// primera escritura: la línea base es la fila que acabamos de leer (TODO O NADA) y los denies vivos se leen por la
|
|
86
|
+
// MISMA trx. Cada hallazgo se niega salvo su salida con nombre: `no-baseline` ⇐ `establishBaseline: true` (solo existe
|
|
87
|
+
// sin línea base utilizable: con ella es inerte); `shrunk`/`orphan` ⇐ su `tipo#relación` en `allowDenyReachShrink`.
|
|
88
|
+
const judged = judgeDenyReach(baselineOf(existing), config, await liveDenyGroups(trx));
|
|
89
|
+
const refused = judged.filter((finding) => (finding.reason === 'no-baseline' ? !adopt : !exempt.has(denyReachKey(finding))));
|
|
90
|
+
if (refused.length > 0)
|
|
91
|
+
throw denyReachRefused(refused);
|
|
92
|
+
// **Un nombre de la salida acotada que no exime NADA en ESTE guardado ⇒ 500** (firma «(8 · cierre 1 · ronda)», 🟡 1 del
|
|
93
|
+
// auditor del cierre 1): «casa» = exime un `shrunk`/`orphan` de este guardado (un `no-baseline` no cuenta), calculado
|
|
94
|
+
// aquí, dentro de la trx. Así la salida se APAGA SOLA: una lista que se queda pegada en el pipeline no exime en silencio
|
|
95
|
+
// el encogimiento FUTURO de esa relación. Un huérfano heredado sigue siendo hallazgo en cada guardado: su nombre casa.
|
|
96
|
+
const unused = unusedShrinkExemptions(exempt, judged);
|
|
97
|
+
if (unused.length > 0)
|
|
98
|
+
throw unusedShrinkExemptionsError(unused);
|
|
49
99
|
if (existing) {
|
|
50
100
|
await trx.from(TABLE).where('id', ROW_ID).update({ spec: json, updated_at: systemClock() });
|
|
51
101
|
}
|
|
52
102
|
else {
|
|
53
103
|
await trx.table(TABLE).insert({ id: ROW_ID, spec: json, model_id: null, updated_at: systemClock() });
|
|
54
104
|
}
|
|
55
|
-
},
|
|
105
|
+
}, storeOptions);
|
|
56
106
|
invalidateAuthzCatalog();
|
|
57
107
|
return config;
|
|
58
108
|
}
|
|
109
|
+
/**
|
|
110
|
+
* La LÍNEA BASE de la guarda (8) y del detector de un driver: la config persistida, leída TODO O NADA
|
|
111
|
+
* (`denyReachBaselineOf`), o `null` = NO UTILIZABLE (sin fila, JSON roto, o una forma que la lectura no puede tipar). Una
|
|
112
|
+
* fila que no valida en 2.5 pero cuyos campos de alcance se tipan (p. ej. una clave que 2.5 no conoce) SÍ es línea base:
|
|
113
|
+
* re-guardarla con la spec corregida (la receta de B5) entra sin forzar.
|
|
114
|
+
*/
|
|
115
|
+
function baselineOf(row) {
|
|
116
|
+
if (!row)
|
|
117
|
+
return null;
|
|
118
|
+
let parsed;
|
|
119
|
+
try {
|
|
120
|
+
parsed = JSON.parse(String(row.spec));
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
return denyReachBaselineOf(parsed);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Los denies VIVOS de `authz_relations` agrupados por tipo y relación (las filas del driver `database`), por la conexión o
|
|
129
|
+
* la trx que se le pase. Todas las filas `denied_*`: el deny no caduca (D3) y una fila con `expires_at` escrita a mano cuenta
|
|
130
|
+
* igual (contar de más rechaza de más: lo conservador). El filtro del deny es UNO, aquí (`deniedPublicRelation`): una fila
|
|
131
|
+
* que no es un deny no pide línea base (S-A0).
|
|
132
|
+
*/
|
|
133
|
+
async function liveDenyGroups(connection) {
|
|
134
|
+
return denyGroupsOf(await liveDenyQuery(connection));
|
|
135
|
+
}
|
|
136
|
+
/** La consulta (un `GROUP BY` de `authz_relations`): aparte para que el detector de un driver le ponga su deadline. */
|
|
137
|
+
function liveDenyQuery(connection) {
|
|
138
|
+
return connection.from('authz_relations').select('object_type', 'relation').count('* as n').groupBy('object_type', 'relation');
|
|
139
|
+
}
|
|
140
|
+
function denyGroupsOf(rows) {
|
|
141
|
+
const groups = [];
|
|
142
|
+
for (const row of rows) {
|
|
143
|
+
const relation = deniedPublicRelation(String(row.relation));
|
|
144
|
+
if (relation !== null)
|
|
145
|
+
groups.push({ objectType: String(row.object_type), relation, count: Number(row.n) });
|
|
146
|
+
}
|
|
147
|
+
return groups;
|
|
148
|
+
}
|
|
149
|
+
function denyReachRefused(findings) {
|
|
150
|
+
const shrink = findings.filter((f) => f.reason !== 'no-baseline').map(denyReachKey);
|
|
151
|
+
const exits = [
|
|
152
|
+
...(findings.some((f) => f.reason === 'no-baseline')
|
|
153
|
+
? ['sin línea base utilizable, la adopción es saveRelationsConfig(spec, { establishBaseline: true }) con la spec que la flota sirve HOY, sin cambios']
|
|
154
|
+
: []),
|
|
155
|
+
...(shrink.length > 0
|
|
156
|
+
? [`si de verdad quieres que esos denies dejen de cortar, la salida forzada ACOTADA es saveRelationsConfig(spec, { allowDenyReachShrink: ${JSON.stringify(shrink)} })`]
|
|
157
|
+
: []),
|
|
158
|
+
];
|
|
159
|
+
const error = new DenyReachRefusedError(`saveRelationsConfig NEGADO (E_AUTHZ_DENY_REACH_REFUSED): ${findings.length} hallazgo(s) sobre denies vivos de authz_relations — ` +
|
|
160
|
+
`una config que encoge su alcance haría conceder, de forma persistente, lo que negaban (decisión (8): para todo deny ` +
|
|
161
|
+
`vivo, alcance nuevo ⊇ alcance viejo). ${findings.map(denyReachLine).join(' ')} No se ha escrito nada (ni la fila ni la ` +
|
|
162
|
+
`versión del catálogo). ${exits.join('; ')}.`);
|
|
163
|
+
error.findings = findings.map(({ objectType, relation, reason, count, lost, recipe }) => ({ objectType, relation, reason, count, lost, recipe }));
|
|
164
|
+
return error;
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* **El detector de UN solo driver** (firma (8 · detector de un driver), R4 del analista del panel): en un despliegue
|
|
168
|
+
* solo-`database` el detector de `authz:relations:reconcile --dry-run` no corre (necesita DOS drivers de relaciones:
|
|
169
|
+
* origen = destino es 500). Esta función compara la config que SIRVE el proceso (`served`) con la línea base persistida
|
|
170
|
+
* sobre los denies vivos de `authz_relations`, en las DOS direcciones:
|
|
171
|
+
* - persistida → servida: lo que la guarda negaría (`shrunk`, `orphan`, `no-baseline`);
|
|
172
|
+
* - servida → persistida: la línea base RANCIA (`stale`), que la guarda no puede ver.
|
|
173
|
+
*
|
|
174
|
+
* Solo LEE (la fila y un `GROUP BY` de `authz_relations`, por `options.connection` o la primaria), sin transacción y sin el
|
|
175
|
+
* cerrojo del catálogo, con el MISMO punto de paso que la guarda (`baselineOf` + `liveDenyGroups` + `denyReachCheck`).
|
|
176
|
+
* **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`.
|
|
177
|
+
* **Nunca** como fallo duro en `ready`: durante un despliegue un pod viejo que se reinicia sirve la config estrecha contra la
|
|
178
|
+
* fila nueva y entraría en un bucle de reinicios.
|
|
179
|
+
*/
|
|
180
|
+
export async function checkRelationsConfigDenyReach(served, options = {}) {
|
|
181
|
+
if (!served || !Array.isArray(served.objectTypes)) {
|
|
182
|
+
throw new AuthorizationConfigError('checkRelationsConfigDenyReach: pasa la config de relaciones que SIRVE este proceso (relations.config de config/authorization.ts, la de defineRelationsConfig).');
|
|
183
|
+
}
|
|
184
|
+
const connection = connectionOf(options);
|
|
185
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
186
|
+
const row = await guardSql('relations-config', 'read', timeoutMs, () => connection.from(TABLE).where('id', ROW_ID).first());
|
|
187
|
+
const groups = denyGroupsOf(await guardSql('relations-config', 'read', timeoutMs, () => liveDenyQuery(connection)));
|
|
188
|
+
const check = denyReachCheck(baselineOf(row), served, groups);
|
|
189
|
+
return {
|
|
190
|
+
...check,
|
|
191
|
+
ok: check.findings.length === 0 && check.staleBaseline.length === 0,
|
|
192
|
+
lines: [...check.findings, ...check.staleBaseline].map(denyReachLine),
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* **Lo que se persiste de la spec** (cierre-2 de R1 · P1, decisión del dueño 2026-10-20): `maxGroupDepth` y
|
|
197
|
+
* `database.enforceGroupDepth` son claves de DESPLIEGUE, no de modelo. Se VALIDAN (`defineRelationsConfig`, arriba: un
|
|
198
|
+
* valor malo es el mismo 500) pero NO se guardan. Nadie las lee de la base (el plan del reconcile toma el techo de
|
|
199
|
+
* `to.groupDepthLimit()`, no de la config persistida), y guardarlas rompía un despliegue mezclado: un proceso alpha.2/
|
|
200
|
+
* alpha.3 relee la fila con su lista blanca (B2, sin esas claves) y su `authz:relations:reconcile` sale con 1. No es el
|
|
201
|
+
* «descartar en silencio» de B2: se conocen y se validan; solo no se escriben. El resto de la spec va TAL CUAL.
|
|
202
|
+
*/
|
|
203
|
+
function persistableRelationsSpec(spec) {
|
|
204
|
+
const { maxGroupDepth: _deployment, ...rest } = spec;
|
|
205
|
+
if (rest.database === undefined || rest.database === null || typeof rest.database !== 'object')
|
|
206
|
+
return rest;
|
|
207
|
+
const { enforceGroupDepth: _enforce, ...database } = rest.database;
|
|
208
|
+
return { ...rest, database };
|
|
209
|
+
}
|
|
59
210
|
/** Lee el spec persistido (o `null` si no hay relaciones declaradas — opt-in). */
|
|
60
211
|
export async function readRelationsConfigSpec(options = {}) {
|
|
61
212
|
const row = await guardSql('relations-config', 'read', options.timeoutMs ?? DEFAULT_TIMEOUT_MS, () => connectionOf(options).from(TABLE).where('id', ROW_ID).first());
|
|
@@ -93,4 +244,3 @@ export async function readRelationsConfig(options = {}) {
|
|
|
93
244
|
const spec = await readRelationsConfigSpec(options);
|
|
94
245
|
return spec ? defineRelationsConfig(spec) : null;
|
|
95
246
|
}
|
|
96
|
-
//# sourceMappingURL=relations_config_store.js.map
|
|
@@ -76,4 +76,3 @@ export type RunRelationsReconcileOptions = Omit<RelationsReconcileOptions, 'from
|
|
|
76
76
|
* Pásale el manager del servicio, nunca uno construido o envuelto a mano.
|
|
77
77
|
*/
|
|
78
78
|
export declare function runRelationsReconcile(authorization: Pick<AuthorizationManager, 'withFrozenWrites' | 'relationsDriverKey' | 'relationsDriverFactory'>, options: RunRelationsReconcileOptions): Promise<RelationsReconcileReport>;
|
|
79
|
-
//# sourceMappingURL=relations_reconcile.d.ts.map
|
|
@@ -66,6 +66,58 @@ export declare function isServerWriteLimit(error: unknown): boolean;
|
|
|
66
66
|
* `cause`. Se mira la cadena entera de causas.
|
|
67
67
|
*/
|
|
68
68
|
export declare function isTimeoutLike(error: unknown): boolean;
|
|
69
|
+
/**
|
|
70
|
+
* Las clases de la decisión del dueño 2026-10-17 (3) y (4) (lote «antes de RC» · R3), separadas SIN leer el mensaje
|
|
71
|
+
* (F0 · M-d: 41 formas literales del SDK 0.9.6 contra OpenFGA v1.19, en `tests/fixtures/fga_errors/`):
|
|
72
|
+
*
|
|
73
|
+
* | clase | al llamante | en el relay |
|
|
74
|
+
* |---|---|---|
|
|
75
|
+
* | `timeout` (deadline propio, `ECONNABORTED`/`ETIMEDOUT`, el `FgaError` sin respuesta) | 503 `E_AUTHZ_BACKEND_TIMEOUT` | transitorio |
|
|
76
|
+
* | `transient` (5xx —501 incluido—, 408, 429, `deadline_exceeded`, sin respuesta, ítem `internal_error`; en una ESCRITURA el 504 y `deadline_exceeded` son `timeout`) | 503 `E_AUTHZ_BACKEND_UNAVAILABLE` | transitorio |
|
|
77
|
+
* | `resolution-limit` (`authorization_model_resolution_too_complex`, excepción o ítem) | 503 `E_AUTHZ_RESOLUTION_LIMIT` | transitorio |
|
|
78
|
+
* | `deployment` (401/403/404, 400 con código de la LISTA BLANCA, validación del cliente) | 500 `E_AUTHZ_CONFIG` «no es transitorio» | transitorio (marca) |
|
|
79
|
+
* | `race` (409, 400 `write_failed_due_to_invalid_input`) | 409 `E_AUTHZ_WRITE_CONFLICT` si escapa | transitorio (`isTransientRelayFailure`, cierre de R3) |
|
|
80
|
+
* | `unknown-4xx` (cualquier otro 4xx) | 500 `E_AUTHZ_CONFIG` con `cause` | transitorio (marca) |
|
|
81
|
+
*/
|
|
82
|
+
export type FgaFailureKind = 'timeout' | 'transient' | 'resolution-limit' | 'deployment' | 'race' | 'unknown-4xx';
|
|
83
|
+
export interface FgaFailure {
|
|
84
|
+
kind: FgaFailureKind;
|
|
85
|
+
/** El `statusCode` HTTP de la respuesta (ausente sin respuesta y en el error por ítem de `batchCheck`). */
|
|
86
|
+
status?: number;
|
|
87
|
+
/** `apiErrorCode` del SDK, o el valor de la clave del error por ítem (`input_error`/`internal_error`). */
|
|
88
|
+
apiErrorCode?: string;
|
|
89
|
+
}
|
|
90
|
+
/** El código de OpenFGA que dice «el grafo excede lo que resuelvo» (decisión 4: 503 con código propio). */
|
|
91
|
+
export declare const FGA_RESOLUTION_TOO_COMPLEX = "authorization_model_resolution_too_complex";
|
|
92
|
+
/**
|
|
93
|
+
* **La LISTA BLANCA de códigos de despliegue** (F0 · M-d): solo códigos MEDIDOS. Un 400 con un código fuera de ella (y todo 422)
|
|
94
|
+
* es `unknown-4xx` (la clase `FgaApiValidationError` no basta: el 400 de código desconocido es la misma clase). Dentro de
|
|
95
|
+
* la clase no se separan sin el mensaje —y no hace falta, la decisión 3 los junta—: relación/tipo ausente del modelo
|
|
96
|
+
* fijado ↔ contexto ausente en la tupla ↔ `current_time` ausente (los tres `validation_error`), y store ↔ modelo ausente
|
|
97
|
+
* con pin (los dos `authorization_model_not_found`).
|
|
98
|
+
*/
|
|
99
|
+
export declare const FGA_DEPLOYMENT_CODES: ReadonlySet<string>;
|
|
100
|
+
/**
|
|
101
|
+
* **`classifyFgaFailure(error)`**: la clase de un fallo de OpenFGA, por `statusCode` + `apiErrorCode` (lista blanca) o,
|
|
102
|
+
* en el error POR ÍTEM de un `batchCheck` 200, por la CLAVE del objeto `error` (`input_error`/`internal_error`). **Jamás
|
|
103
|
+
* lee `message` ni `apiErrorMessage`** (lo vigila la propiedad `fga.errors.classify` de la guardia; las ÚNICAS exenciones,
|
|
104
|
+
* por nombre y firmadas —decisión del dueño 2026-10-18 (5-bis)—, son `isDuplicateWrite`/`isTreeWriteRace`, que leen el
|
|
105
|
+
* SUB-tipo de la carrera «already exists» / «does not exist» y viven ENCIMA de esta clase). PUNTO DE PASO ÚNICO de los
|
|
106
|
+
* TRES sitios: `#guard` del driver de relaciones, `guardBackendErrors` y `alignBatchResults` (los dos drivers).
|
|
107
|
+
*/
|
|
108
|
+
export declare function classifyFgaFailure(error: unknown, context?: {
|
|
109
|
+
write?: boolean;
|
|
110
|
+
}): FgaFailure;
|
|
111
|
+
/**
|
|
112
|
+
* **El error PÚBLICO de un fallo de OpenFGA** (R3): `classifyFgaFailure` ⇒ la clase de la tabla de arriba. La causa
|
|
113
|
+
* original viaja SIEMPRE en `cause` (la carrera la leen `isDuplicateWrite`/`isTreeWriteRace` por la cadena). `timeoutMs`
|
|
114
|
+
* solo da la letra del timeout. `failure` deja pasar una clasificación ya hecha (el llamante que necesita mirarla antes).
|
|
115
|
+
*/
|
|
116
|
+
export declare function fgaFailureError(driver: string, operation: string, error: unknown, options?: {
|
|
117
|
+
timeoutMs?: number;
|
|
118
|
+
failure?: FgaFailure;
|
|
119
|
+
write?: boolean;
|
|
120
|
+
}): Error;
|
|
69
121
|
/**
|
|
70
122
|
* Ejecuta una consulta SQL con deadline y traduce su fallo. El builder de
|
|
71
123
|
* Lucid es perezoso (se ejecuta al hacer `await`), así que el `timeout` se
|
|
@@ -158,4 +210,3 @@ export declare function canonicalScope(resolver: ScopeChainResolver, scope: Scop
|
|
|
158
210
|
export declare function canonicalScopeTargets(resolver: ScopeChainResolver, scope: ScopeRef, operation: string): Promise<ScopeRef[]>;
|
|
159
211
|
/** La cadena canónica, o 422 `E_AUTHZ_UNKNOWN_SCOPE` si el scope no existe (para escrituras). */
|
|
160
212
|
export declare function assertKnownScope(resolver: ScopeChainResolver, scope: ScopeRef, operation: string): Promise<ScopeRef[]>;
|
|
161
|
-
//# sourceMappingURL=backend_guard.d.ts.map
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { AuthorizationBackendError, AuthorizationBackendTimeoutError, NoScopeResolverError, ScopeResolverError, UnknownScopeError, } from '../errors.js';
|
|
1
|
+
import { AuthorizationBackendError, AuthorizationBackendTimeoutError, AuthorizationConfigError, markDeploymentFailure, NoScopeResolverError, ResolutionLimitError, ScopeResolverError, UnknownScopeError, WriteConflictError, } from '../errors.js';
|
|
2
2
|
import { APP_SCOPE, APP_SCOPE_TYPE } from '../types.js';
|
|
3
3
|
import { assertScope, scopeSpellings } from '../identity.js';
|
|
4
4
|
/**
|
|
@@ -120,10 +120,173 @@ export function isTimeoutLike(error) {
|
|
|
120
120
|
return true;
|
|
121
121
|
if (current.code === 'ECONNABORTED' || current.code === 'ETIMEDOUT')
|
|
122
122
|
return true;
|
|
123
|
+
if (isBareFgaError(current))
|
|
124
|
+
return true;
|
|
123
125
|
current = current.cause;
|
|
124
126
|
}
|
|
125
127
|
return false;
|
|
126
128
|
}
|
|
129
|
+
/**
|
|
130
|
+
* **El `FgaError` SIN respuesta** (lote «antes de RC» · R3; F0 · M-d, nota 1 al auditor): el SDK 0.9.6 envuelve el
|
|
131
|
+
* timeout de axios —y también la conexión rechazada— en un `FgaError` genérico que NO conserva `code`, `cause` ni
|
|
132
|
+
* `statusCode` (fixtures `real-client-timeout`, `fake-client-timeout-slow-server`, `net-connection-refused`): solo
|
|
133
|
+
* `name` y `message`. Sin leer el mensaje (prohibido, decisión 2026-10-17 (3)) los dos NO se separan, así que se toma la
|
|
134
|
+
* clase CONSERVADORA: timeout —indeterminado—. Para el relay los dos son transitorios; para la auditoría
|
|
135
|
+
* (invariante 13) decir «quizá se escribió» de una conexión rechazada sobra, y callarlo de un timeout faltaría. Hasta R3
|
|
136
|
+
* solo lo salvaba que `withDeadline` arranca antes que el reloj del SDK. El nombre EXACTO `FgaError` (las subclases del
|
|
137
|
+
* SDK tienen el suyo) y SIN `statusCode`: con respuesta, lo decide `classifyFgaFailure`.
|
|
138
|
+
*/
|
|
139
|
+
function isBareFgaError(candidate) {
|
|
140
|
+
return candidate?.name === 'FgaError' && (candidate.statusCode === undefined || candidate.statusCode === null);
|
|
141
|
+
}
|
|
142
|
+
/** El código de OpenFGA que dice «el grafo excede lo que resuelvo» (decisión 4: 503 con código propio). */
|
|
143
|
+
export const FGA_RESOLUTION_TOO_COMPLEX = 'authorization_model_resolution_too_complex';
|
|
144
|
+
/**
|
|
145
|
+
* **La LISTA BLANCA de códigos de despliegue** (F0 · M-d): solo códigos MEDIDOS. Un 400 con un código fuera de ella (y todo 422)
|
|
146
|
+
* es `unknown-4xx` (la clase `FgaApiValidationError` no basta: el 400 de código desconocido es la misma clase). Dentro de
|
|
147
|
+
* la clase no se separan sin el mensaje —y no hace falta, la decisión 3 los junta—: relación/tipo ausente del modelo
|
|
148
|
+
* fijado ↔ contexto ausente en la tupla ↔ `current_time` ausente (los tres `validation_error`), y store ↔ modelo ausente
|
|
149
|
+
* con pin (los dos `authorization_model_not_found`).
|
|
150
|
+
*/
|
|
151
|
+
export const FGA_DEPLOYMENT_CODES = new Set([
|
|
152
|
+
'validation_error',
|
|
153
|
+
'authorization_model_not_found',
|
|
154
|
+
'latest_authorization_model_not_found',
|
|
155
|
+
'exceeded_entity_limit',
|
|
156
|
+
// `store_id_not_found` NO está (cierre de R3, ⚪-6 del tester): se midió solo como 404, que ya decide el status.
|
|
157
|
+
]);
|
|
158
|
+
/** El código con el que OpenFGA dice que venció SU deadline (504 por el grpc-gateway; F0 · M-d `fake-504-deadline-exceeded`). */
|
|
159
|
+
const FGA_DEADLINE_EXCEEDED = 'deadline_exceeded';
|
|
160
|
+
/** El 400 de la carrera (`isDuplicateWrite`/`isTreeWriteRace` leen su sub-tipo; aquí basta el código). */
|
|
161
|
+
const FGA_RACE_CODE = 'write_failed_due_to_invalid_input';
|
|
162
|
+
/**
|
|
163
|
+
* Los errores del SDK que se lanzan SIN red (el cliente valida antes de llamar: un `authorizationModelId` que no es un
|
|
164
|
+
* ULID, un parámetro obligatorio ausente). Deterministas: despliegue. Es lo ÚNICO que se decide por `name`, porque sin
|
|
165
|
+
* respuesta no hay `statusCode` que mirar (la regla general es «por `statusCode` + `apiErrorCode`, nunca por la clase JS»:
|
|
166
|
+
* 408/409/418/501 son los cuatro `FgaApiError`).
|
|
167
|
+
*/
|
|
168
|
+
const FGA_CLIENT_VALIDATION_NAMES = new Set(['FgaValidationError', 'FgaRequiredParamError']);
|
|
169
|
+
/**
|
|
170
|
+
* **`classifyFgaFailure(error)`**: la clase de un fallo de OpenFGA, por `statusCode` + `apiErrorCode` (lista blanca) o,
|
|
171
|
+
* en el error POR ÍTEM de un `batchCheck` 200, por la CLAVE del objeto `error` (`input_error`/`internal_error`). **Jamás
|
|
172
|
+
* lee `message` ni `apiErrorMessage`** (lo vigila la propiedad `fga.errors.classify` de la guardia; las ÚNICAS exenciones,
|
|
173
|
+
* por nombre y firmadas —decisión del dueño 2026-10-18 (5-bis)—, son `isDuplicateWrite`/`isTreeWriteRace`, que leen el
|
|
174
|
+
* SUB-tipo de la carrera «already exists» / «does not exist» y viven ENCIMA de esta clase). PUNTO DE PASO ÚNICO de los
|
|
175
|
+
* TRES sitios: `#guard` del driver de relaciones, `guardBackendErrors` y `alignBatchResults` (los dos drivers).
|
|
176
|
+
*/
|
|
177
|
+
export function classifyFgaFailure(error, context = {}) {
|
|
178
|
+
const item = error;
|
|
179
|
+
if (item && typeof item === 'object' && item.statusCode === undefined && ('input_error' in item || 'internal_error' in item)) {
|
|
180
|
+
// El error POR ÍTEM de `batchCheck` (respuesta 200): no es una excepción del SDK, es un objeto de la respuesta.
|
|
181
|
+
if (typeof item.internal_error === 'string')
|
|
182
|
+
return { kind: 'transient', apiErrorCode: item.internal_error };
|
|
183
|
+
if (typeof item.input_error === 'string') {
|
|
184
|
+
const code = item.input_error;
|
|
185
|
+
if (code === FGA_RESOLUTION_TOO_COMPLEX)
|
|
186
|
+
return { kind: 'resolution-limit', apiErrorCode: code };
|
|
187
|
+
if (FGA_DEPLOYMENT_CODES.has(code))
|
|
188
|
+
return { kind: 'deployment', apiErrorCode: code };
|
|
189
|
+
return { kind: 'unknown-4xx', apiErrorCode: code };
|
|
190
|
+
}
|
|
191
|
+
return { kind: 'transient' };
|
|
192
|
+
}
|
|
193
|
+
if (isTimeoutLike(error))
|
|
194
|
+
return { kind: 'timeout' };
|
|
195
|
+
const responded = respondedFgaError(error);
|
|
196
|
+
if (responded === null) {
|
|
197
|
+
let current = error;
|
|
198
|
+
for (let depth = 0; current && depth < 6; depth++) {
|
|
199
|
+
if (typeof current.name === 'string' && FGA_CLIENT_VALIDATION_NAMES.has(current.name))
|
|
200
|
+
return { kind: 'deployment' };
|
|
201
|
+
current = current.cause;
|
|
202
|
+
}
|
|
203
|
+
return { kind: 'transient' };
|
|
204
|
+
}
|
|
205
|
+
const { status, apiErrorCode } = responded;
|
|
206
|
+
const failure = (kind) => ({ kind, status, ...(apiErrorCode !== undefined ? { apiErrorCode } : {}) });
|
|
207
|
+
// Cierre de R3 (🟡 R3-A2 del auditor; invariante 13: «una escritura que vence el deadline es indeterminada»): en una
|
|
208
|
+
// ESCRITURA, el deadline del SERVIDOR (`deadline_exceeded`) y el 504 de un proxy/gateway son de la familia TIMEOUT —el
|
|
209
|
+
// `Write` puede haberse confirmado aguas arriba después de que se cortara la respuesta—, así que salen 503
|
|
210
|
+
// `E_AUTHZ_BACKEND_TIMEOUT` y el manager notifica `indeterminate: true`. El resto de 5xx en una escritura conserva
|
|
211
|
+
// «no ocurrió» (el servidor contestó que falló); en una LECTURA el 504 sigue siendo transitorio (no hay nada que auditar).
|
|
212
|
+
if (context.write === true && (status === 504 || apiErrorCode === FGA_DEADLINE_EXCEEDED))
|
|
213
|
+
return failure('timeout');
|
|
214
|
+
if (status >= 500 || status === 408 || status === 429)
|
|
215
|
+
return failure('transient');
|
|
216
|
+
if (status === 409)
|
|
217
|
+
return failure('race');
|
|
218
|
+
if (status === 401 || status === 403 || status === 404)
|
|
219
|
+
return failure('deployment');
|
|
220
|
+
// Solo el 400 lleva códigos medidos (cierre de R3, ⚪-6 del tester: un 422 se midió solo con código desconocido, así
|
|
221
|
+
// que cualquier 422 es `unknown-4xx` —mismo error público que el despliegue: 500 marcado—).
|
|
222
|
+
if (status === 400) {
|
|
223
|
+
if (apiErrorCode === FGA_RACE_CODE)
|
|
224
|
+
return failure('race');
|
|
225
|
+
if (apiErrorCode === FGA_RESOLUTION_TOO_COMPLEX)
|
|
226
|
+
return failure('resolution-limit');
|
|
227
|
+
if (apiErrorCode !== undefined && FGA_DEPLOYMENT_CODES.has(apiErrorCode))
|
|
228
|
+
return failure('deployment');
|
|
229
|
+
return failure('unknown-4xx');
|
|
230
|
+
}
|
|
231
|
+
if (status >= 400)
|
|
232
|
+
return failure('unknown-4xx');
|
|
233
|
+
return failure('transient');
|
|
234
|
+
}
|
|
235
|
+
/** El primer error de la cadena de `cause` que trae una respuesta HTTP (`statusCode` numérico). */
|
|
236
|
+
function respondedFgaError(error) {
|
|
237
|
+
let current = error;
|
|
238
|
+
for (let depth = 0; current && depth < 6; depth++) {
|
|
239
|
+
if (typeof current.statusCode === 'number') {
|
|
240
|
+
return {
|
|
241
|
+
status: current.statusCode,
|
|
242
|
+
...(typeof current.apiErrorCode === 'string' ? { apiErrorCode: current.apiErrorCode } : {}),
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
current = current.cause;
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* **El error PÚBLICO de un fallo de OpenFGA** (R3): `classifyFgaFailure` ⇒ la clase de la tabla de arriba. La causa
|
|
251
|
+
* original viaja SIEMPRE en `cause` (la carrera la leen `isDuplicateWrite`/`isTreeWriteRace` por la cadena). `timeoutMs`
|
|
252
|
+
* solo da la letra del timeout. `failure` deja pasar una clasificación ya hecha (el llamante que necesita mirarla antes).
|
|
253
|
+
*/
|
|
254
|
+
export function fgaFailureError(driver, operation, error, options = {}) {
|
|
255
|
+
const failure = options.failure ?? classifyFgaFailure(error, { write: options.write });
|
|
256
|
+
const what = [failure.status !== undefined ? `HTTP ${failure.status}` : null, failure.apiErrorCode ?? null]
|
|
257
|
+
.filter((x) => x !== null)
|
|
258
|
+
.join(' ');
|
|
259
|
+
switch (failure.kind) {
|
|
260
|
+
case 'timeout': {
|
|
261
|
+
const timeout = new AuthorizationBackendTimeoutError(driver, operation, options.timeoutMs ?? 0, error);
|
|
262
|
+
if (failure.status !== undefined) {
|
|
263
|
+
// Con respuesta (504 / `deadline_exceeded` en una escritura): no es NUESTRO deadline, es el del servidor o el
|
|
264
|
+
// de un proxy intermedio. La escritura es INDETERMINADA (invariante 13), no «no ocurrió».
|
|
265
|
+
timeout.message =
|
|
266
|
+
`El backend de autorización '${driver}' venció un deadline aguas arriba en ${operation} (${what}): la ` +
|
|
267
|
+
'escritura es INDETERMINADA —el servidor puede haberla confirmado después de cortarse la respuesta—.';
|
|
268
|
+
}
|
|
269
|
+
return timeout;
|
|
270
|
+
}
|
|
271
|
+
case 'transient':
|
|
272
|
+
return new AuthorizationBackendError(driver, operation, error);
|
|
273
|
+
case 'resolution-limit':
|
|
274
|
+
return new ResolutionLimitError(driver, operation, error);
|
|
275
|
+
case 'race':
|
|
276
|
+
return new WriteConflictError(`El backend de autorización '${driver}' rechazó ${operation} por una CARRERA con otro escritor (${what}): el ` +
|
|
277
|
+
'estado del store no es el que esta escritura esperaba. Relee y reintenta la escritura.', { cause: error });
|
|
278
|
+
case 'deployment':
|
|
279
|
+
return markDeploymentFailure(new AuthorizationConfigError(`El backend de autorización '${driver}' rechazó ${operation} (${what || 'validación del cliente, sin red'}): el ` +
|
|
280
|
+
'DESPLIEGUE no es el que espera la config —store o modelo inexistente (OPENFGA_STORE_ID/OPENFGA_MODEL_ID), ' +
|
|
281
|
+
'credenciales, o un tipo, relación o contexto que el modelo fijado no admite—. No es transitorio: repetida, la ' +
|
|
282
|
+
'operación vuelve a fallar igual hasta que se corrija el despliegue (openfga:provision publica el modelo de la ' +
|
|
283
|
+
'config). Para el relay no consume intento.', { cause: error }));
|
|
284
|
+
case 'unknown-4xx':
|
|
285
|
+
return markDeploymentFailure(new AuthorizationConfigError(`El backend de autorización '${driver}' rechazó ${operation} con un 4xx que este paquete no conoce (${what}). ` +
|
|
286
|
+
'No es transitorio (un 4xx dice «no reintentes»): mira la causa (cause) y la versión del servidor OpenFGA. ' +
|
|
287
|
+
'Para el relay no consume intento.', { cause: error }));
|
|
288
|
+
}
|
|
289
|
+
}
|
|
127
290
|
/**
|
|
128
291
|
* Ejecuta una consulta SQL con deadline y traduce su fallo. El builder de
|
|
129
292
|
* Lucid es perezoso (se ejecuta al hacer `await`), así que el `timeout` se
|
|
@@ -340,4 +503,3 @@ export async function assertKnownScope(resolver, scope, operation) {
|
|
|
340
503
|
}
|
|
341
504
|
return chain;
|
|
342
505
|
}
|
|
343
|
-
//# sourceMappingURL=backend_guard.js.map
|
|
@@ -50,4 +50,3 @@ export interface CallerTransactionOwner {
|
|
|
50
50
|
* (no llegó ninguna; el llamante decide qué hacer).
|
|
51
51
|
*/
|
|
52
52
|
export declare function assertCallerTransaction(operation: string, transaction: unknown, owner: CallerTransactionOwner): CallerTransaction | undefined;
|
|
53
|
-
//# sourceMappingURL=transaction_guard.d.ts.map
|
|
@@ -200,6 +200,13 @@ export interface DriverCapabilities {
|
|
|
200
200
|
* el destino los lee de ahí. Solo tiene casos en `level: '2.2'`.
|
|
201
201
|
*/
|
|
202
202
|
enumerateFacts: boolean;
|
|
203
|
+
/**
|
|
204
|
+
* **El driver es EXPERIMENTAL** (estrategia 2.5.0, firma 4; `AuthorizationDriverCapabilities.experimental`). Opcional
|
|
205
|
+
* (regla D15: un harness publicado de 2.4/2.5 sin la clave sigue valiendo y cuenta como `false`). Con `true` el juez fija
|
|
206
|
+
* que el driver lo declara y que `authorization.capabilities()` lo publica; con `false` (o ausente), que el driver NO lo
|
|
207
|
+
* declara y la copia normalizada dice `false`. No apaga ningún caso. `openfga` = `true` en 2.5.x; `database` = `false`.
|
|
208
|
+
*/
|
|
209
|
+
experimental?: boolean;
|
|
203
210
|
}
|
|
204
211
|
export type ContractLevel = 'core' | '2.0' | '2.1' | '2.2';
|
|
205
212
|
export interface DriverContractHarness {
|
|
@@ -280,4 +287,3 @@ export declare function registerAuthorizationDriverContract(harness: DriverContr
|
|
|
280
287
|
* y una de `BOTH_FACES_REQUIRED` queda sin cubrir con CUALQUIER valor.
|
|
281
288
|
*/
|
|
282
289
|
export declare function uncoveredCapabilities(capabilities: DriverCapabilities, covered: ReadonlySet<keyof DriverCapabilities>): (keyof DriverCapabilities)[];
|
|
283
|
-
//# sourceMappingURL=contract.d.ts.map
|
|
@@ -1922,6 +1922,33 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
1922
1922
|
await driver.revoke(alice, 'lead', unitA1);
|
|
1923
1923
|
assert.isFalse(await driver.authorize(alice, 'docs:write', unitA1));
|
|
1924
1924
|
});
|
|
1925
|
+
since('2.2', 'un deny en el OWNER sigue ganando sobre su rol local mientras el scope sale del owner y vuelve: authorize false antes, fuera y de vuelta (invariantes 2 y 18; lote «antes de RC» · F4, F-1); sin el deny: true, false, true', async ({ assert, }) => {
|
|
1926
|
+
// F-1 del tester (`loteRC-6b-estudio-tester.md` §6): PARIDAD entre drivers del `moved` con un rol LOCAL y un deny
|
|
1927
|
+
// en su owner. En `database` no hay estado intermedio (la regla se evalúa en cada pregunta); en `openfga` el
|
|
1928
|
+
// barrido F4 borra el binding ANTES de escribir la arista, así que tampoco lo hay (el prefijo lo juzga la
|
|
1929
|
+
// propiedad `openfga.roles.moved.order` de la guardia). Aquí se observa el estado de reposo en los tres puntos.
|
|
1930
|
+
const denied = subject();
|
|
1931
|
+
const plain = subject();
|
|
1932
|
+
const orgA = await orgUnder(tree, APP_SCOPE);
|
|
1933
|
+
const orgB = await orgUnder(tree, APP_SCOPE);
|
|
1934
|
+
const unit = await unitUnder(tree, orgA);
|
|
1935
|
+
const leaf = await unitUnder(tree, unit);
|
|
1936
|
+
await localRole(orgA, { slug: 'lead', scopeType: 'unit', permissions: ['docs:write'] });
|
|
1937
|
+
await driver.grant(denied, 'lead', unit);
|
|
1938
|
+
await driver.grant(plain, 'lead', unit);
|
|
1939
|
+
await driver.deny(denied, 'docs:write', orgA);
|
|
1940
|
+
const both = async () => [
|
|
1941
|
+
await driver.authorize(denied, 'docs:write', unit),
|
|
1942
|
+
await driver.authorize(denied, 'docs:write', leaf),
|
|
1943
|
+
await driver.authorize(plain, 'docs:write', unit),
|
|
1944
|
+
await driver.authorize(plain, 'docs:write', leaf),
|
|
1945
|
+
];
|
|
1946
|
+
assert.deepEqual(await both(), [false, false, true, true], 'dentro del owner: el deny gana, sin él concede (también abajo)');
|
|
1947
|
+
await tree.move(unit, orgB);
|
|
1948
|
+
assert.deepEqual(await both(), [false, false, false, false], 'fuera del owner: el rol local no existe (y el deny ya no hace falta)');
|
|
1949
|
+
await tree.move(unit, orgA);
|
|
1950
|
+
assert.deepEqual(await both(), [false, false, true, true], 'de vuelta: restaura lo concedido y el deny sigue ganando');
|
|
1951
|
+
});
|
|
1925
1952
|
since('2.2', 'la visibilidad se decide POR NIVEL, no por el conjunto de la cadena: si el árbol se recuelga y el owner queda POR DEBAJO del scope de la asignación, esa asignación deja de contar aunque el owner siga en la cadena de la pregunta', async ({ assert, }) => {
|
|
1926
1953
|
// 3B · B2 (desviación 2 del lote, sin caso hasta aquí). La regla es
|
|
1927
1954
|
// «una asignación en el scope S cuenta si su rol EXISTE EN S»: el owner
|
|
@@ -2072,6 +2099,35 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
2072
2099
|
assert.isFalse(await driver.authorize(victima, 'docs:write', unitU1), 'el rol de A ya no existe aquí');
|
|
2073
2100
|
assert.isTrue(await driver.hasRole(victima, 'lead', unitU1));
|
|
2074
2101
|
});
|
|
2102
|
+
since('2.2', 'K4 · revoke por SLUG decide con el catálogo EN FRESCO aunque el memo sirva con ventana { everyMs }: un rol local recién definido en OTRO proceso con el slug de uno global también se revoca (nunca un «revocado» mudo)', async ({ assert, }) => {
|
|
2103
|
+
// Asentar · cierre 2 (D-1 del re-ataque del cierre 1; gemelo del 🟡 5 de `openfga`). `{ everyMs }` es una ventana
|
|
2104
|
+
// de DECISIÓN (invariante 14); una ESCRITURA decide con la versión en fresco (K4). Con el memo de la ventana,
|
|
2105
|
+
// `revoke(alice, 'unit-editor', unit)` solo veía el global y el DELETE no tocaba la asignación del local homónimo:
|
|
2106
|
+
// seguía concediendo, en silencio y para siempre (al caducar la ventana, también en este proceso). La ventana solo se
|
|
2107
|
+
// construye si `driver.catalog` es un `CatalogCache` (los dos drivers del paquete): un driver con memo PROPIO corre
|
|
2108
|
+
// el caso SIN ventana y el K4 de `revoke` NO queda juzgado para él (cierre 3 · 🟡 B del tester: es su deber escrito,
|
|
2109
|
+
// no juzgado; un hook de harness para su memo sería contrato nuevo).
|
|
2110
|
+
const alice = subject();
|
|
2111
|
+
const orgA = await orgUnder(tree, APP_SCOPE);
|
|
2112
|
+
const unit = await unitUnder(tree, orgA);
|
|
2113
|
+
const memo = new CatalogCache({ revalidate: { everyMs: 3_600_000 } });
|
|
2114
|
+
let windowed = driver;
|
|
2115
|
+
if (driver.catalog instanceof CatalogCache) {
|
|
2116
|
+
windowed = Object.create(driver);
|
|
2117
|
+
Object.defineProperty(windowed, 'catalog', { value: memo, enumerable: true });
|
|
2118
|
+
await memo.view();
|
|
2119
|
+
}
|
|
2120
|
+
// «Otro proceso» define el local con el slug del global `unit-editor` (fila + versión, sin señal en memoria).
|
|
2121
|
+
const local = await localRole(orgA, { slug: 'unit-editor', scopeType: 'unit', permissions: ['docs:write'] });
|
|
2122
|
+
await driver.grant(alice, { uuid: local }, unit);
|
|
2123
|
+
assert.isTrue(await driver.authorize(alice, 'docs:write', unit));
|
|
2124
|
+
if (windowed !== driver) {
|
|
2125
|
+
assert.isNull((await memo.view()).roleByUuid(local), 'la ventana del memo sigue sirviendo la foto SIN el local (si no, el caso no juzga nada)');
|
|
2126
|
+
}
|
|
2127
|
+
await windowed.revoke(alice, 'unit-editor', unit);
|
|
2128
|
+
assert.isFalse(await driver.authorize(alice, 'docs:write', unit), 'la asignación del local homónimo también se revoca');
|
|
2129
|
+
assert.isFalse(await driver.hasRole(alice, { uuid: local }, unit));
|
|
2130
|
+
});
|
|
2075
2131
|
since('2.2', 'paridad entre drivers (3D · N1): una asignación cuyo rol está declarado para OTRO nivel no cuenta en ninguno de los dos — ni concede, ni es membresía, ni se enumera — y vuelve a contar en cuanto el nivel del rol coincide otra vez', async ({ assert, }) => {
|
|
2076
2132
|
// Desviación 3 del lote 3B («`database.authorize` exige además
|
|
2077
2133
|
// `r.scope_type = a.scope_type`, como ya hacía `openfga`») era una
|
|
@@ -3738,6 +3794,27 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
3738
3794
|
tree.chainOf = original;
|
|
3739
3795
|
}
|
|
3740
3796
|
}
|
|
3797
|
+
// ── Estrategia 2.5.0 · firma 4 · `experimental` (regla D15) ─────────
|
|
3798
|
+
// La capacidad DICE algo que el consumidor tiene que saber —«este driver está fuera de la garantía de la versión»— y
|
|
3799
|
+
// no apaga ningún caso: el juez corre ENTERO contra un driver experimental. Lo que se observa es la declaración: la
|
|
3800
|
+
// del driver y la copia normalizada que publica `authorization.capabilities()`, en las DOS caras.
|
|
3801
|
+
caseFor('experimental', {
|
|
3802
|
+
whenTrue: () => {
|
|
3803
|
+
test('experimental:true · el driver se declara EXPERIMENTAL (capabilities.experimental === true) y `authorization.capabilities()` lo publica tal cual; el juez corre igual contra él', async ({ assert, }) => {
|
|
3804
|
+
assert.strictEqual(driver.capabilities?.experimental, true, 'el harness declara experimental: true y el driver no: declara lo observable');
|
|
3805
|
+
const published = await managerOver().capabilities();
|
|
3806
|
+
assert.strictEqual(published.experimental, true);
|
|
3807
|
+
assert.isTrue(Object.isFrozen(published));
|
|
3808
|
+
});
|
|
3809
|
+
},
|
|
3810
|
+
whenFalse: () => {
|
|
3811
|
+
test('experimental:false · el driver NO se declara experimental (ausente o false) y `authorization.capabilities().experimental` es false, nunca undefined (D15)', async ({ assert, }) => {
|
|
3812
|
+
assert.notStrictEqual(driver.capabilities?.experimental, true, 'el harness declara experimental: false y el driver declara true: declara lo observable');
|
|
3813
|
+
const published = await managerOver().capabilities();
|
|
3814
|
+
assert.strictEqual(published.experimental, false);
|
|
3815
|
+
});
|
|
3816
|
+
},
|
|
3817
|
+
});
|
|
3741
3818
|
// ── 3b-2e · E2 · `roleInheritanceNative` ─────────────────────────────
|
|
3742
3819
|
caseFor('roleInheritanceNative', {
|
|
3743
3820
|
whenFalse: () => {
|
|
@@ -3868,4 +3945,3 @@ export function uncoveredCapabilities(capabilities, covered) {
|
|
|
3868
3945
|
* un skip disfrazado — «rechazo activo» sería una etiqueta.
|
|
3869
3946
|
*/
|
|
3870
3947
|
const BOTH_FACES_REQUIRED = new Set(['transactionalWrites']);
|
|
3871
|
-
//# sourceMappingURL=contract.js.map
|
|
@@ -20,4 +20,3 @@ export { runRelationsReconcileContract, registerRelationsReconcileContract, } fr
|
|
|
20
20
|
export type { RelationsReconcileHarness } from './relations_reconcile_contract.js';
|
|
21
21
|
export { runScopeOutboxContract, registerScopeOutboxContract, makeScopeOutbox, SCOPE_OUTBOX_CAPABILITY_KEYS, } from './scope_outbox_contract.js';
|
|
22
22
|
export type { ScopeOutboxCapabilities, ScopeOutboxContractHarness, ScopeOutboxContractTransactions, MakeScopeOutboxOptions, } from './scope_outbox_contract.js';
|
|
23
|
-
//# sourceMappingURL=main.d.ts.map
|