@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
|
@@ -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
|
|
@@ -132,8 +132,10 @@ export interface DriverCapabilities {
|
|
|
132
132
|
* y MySQL, y es la promesa que el README hace.
|
|
133
133
|
*
|
|
134
134
|
* Con `false` (SQLite, que serializa bloqueando la BASE ENTERA) el juez
|
|
135
|
-
* solo puede exigir lo innegociable: nunca dos ganadores
|
|
136
|
-
*
|
|
135
|
+
* solo puede exigir lo innegociable: nunca dos ganadores con el MISMO owner
|
|
136
|
+
* (el del ancestro puede ensombrecer al del descendiente: los dos finales
|
|
137
|
+
* legales de 3G · Y1, también en esta cara desde la rotación 2) y el
|
|
138
|
+
* perdedor no escribe — su transacción puede morir con `SQLITE_BUSY`, que es un 503
|
|
137
139
|
* legítimo. Declararlo `true` en un motor así convierte un flake en un
|
|
138
140
|
* fallo; declararlo `false` en PG/MySQL deja pasar un mutante que
|
|
139
141
|
* convierte la colisión en 503. Solo tiene casos en `level: '2.2'`.
|
|
@@ -200,6 +202,28 @@ export interface DriverCapabilities {
|
|
|
200
202
|
* el destino los lee de ahí. Solo tiene casos en `level: '2.2'`.
|
|
201
203
|
*/
|
|
202
204
|
enumerateFacts: boolean;
|
|
205
|
+
/**
|
|
206
|
+
* **El driver es EXPERIMENTAL** (estrategia 2.5.0, firma 4; `AuthorizationDriverCapabilities.experimental`). Opcional
|
|
207
|
+
* (regla D15: un harness publicado de 2.4/2.5 sin la clave sigue valiendo y cuenta como `false`). Con `true` el juez fija
|
|
208
|
+
* que el driver lo declara y que `authorization.capabilities()` lo publica; con `false` (o ausente), que el driver NO lo
|
|
209
|
+
* declara y la copia normalizada dice `false`. No apaga ningún caso. `openfga` = `true` en 2.5.x; `database` = `false`.
|
|
210
|
+
*/
|
|
211
|
+
experimental?: boolean;
|
|
212
|
+
/**
|
|
213
|
+
* **Toda decisión que combina concesión y deny se lee en UNA foto** (rotación 2 · cierre 3, L12;
|
|
214
|
+
* `AuthorizationDriverCapabilities.snapshotDecisions`). Opcional (regla D15: un harness publicado sin la clave sigue
|
|
215
|
+
* valiendo y cuenta como `false`). Solo tiene casos en `level: '2.1'` o superior (necesita `listDenies`). Con `true`
|
|
216
|
+
* el juez fija que el driver trae `readSnapshot`, que cada lectura pedida es EXACTAMENTE la del método homónimo (y lo
|
|
217
|
+
* no pedido no viene), que una pregunta mal formada es 422, y que el manager compone `effectivePermissions`,
|
|
218
|
+
* `authorizedScopes` y la policy de `defineScopedRole` con UNA llamada a `readSnapshot` y ninguna a
|
|
219
|
+
* `rolesInChain`/`listScopes`/`listDenies` (el espía cuenta solo las llamadas del MANAGER: lo que `readSnapshot` llame
|
|
220
|
+
* por dentro no cuenta). Con `false`, que el manager NO llama a `readSnapshot` —aunque el driver traiga un método con
|
|
221
|
+
* ese nombre: decide la capacidad— y compone como antes restando los denies (el hueco declarado de un driver sin la
|
|
222
|
+
* capacidad); sin `listDenies` la cara se queda en la declaración y su título lo dice. Que la foto sea de verdad UNA no lo puede
|
|
223
|
+
* observar un juez genérico sin meterse en el backend: en `database` lo fijan los casos de entrelazado forzado y la
|
|
224
|
+
* guardia `database.decision.snapshot` del paquete.
|
|
225
|
+
*/
|
|
226
|
+
snapshotDecisions?: boolean;
|
|
203
227
|
}
|
|
204
228
|
export type ContractLevel = 'core' | '2.0' | '2.1' | '2.2';
|
|
205
229
|
export interface DriverContractHarness {
|
|
@@ -280,4 +304,3 @@ export declare function registerAuthorizationDriverContract(harness: DriverContr
|
|
|
280
304
|
* y una de `BOTH_FACES_REQUIRED` queda sin cubrir con CUALQUIER valor.
|
|
281
305
|
*/
|
|
282
306
|
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
|
|
@@ -2961,7 +3017,7 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
2961
3017
|
// dejaba vivo un mutante que convertía la colisión en un 503.
|
|
2962
3018
|
const raceOfDefines = (serialized) => since('2.2', serialized
|
|
2963
3019
|
? 'dos defineScopedRole del MISMO (slug, nivel) con owners de la misma cadena, en paralelo y con memos distintos: EXACTAMENTE uno gana y el perdedor es 422 E_AUTHZ_CATALOG_CONFLICT — la unicidad no es una carrera'
|
|
2964
|
-
: 'dos defineScopedRole del MISMO (slug, nivel) con owners de la misma cadena, en paralelo y con memos distintos: nunca dos ganadores y el perdedor no escribe (sin escrituras de catálogo serializadas su transacción puede morir con 503)', async ({ assert }) => {
|
|
3020
|
+
: 'dos defineScopedRole del MISMO (slug, nivel) con owners de la misma cadena, en paralelo y con memos distintos: nunca dos ganadores con el MISMO owner (el del ancestro puede ensombrecer al del descendiente) y el perdedor no escribe (sin escrituras de catálogo serializadas su transacción puede morir con 503)', async ({ assert }) => {
|
|
2965
3021
|
// 3D · M2 (auditor V2 🔴 A, reproducido en PG: los dos se insertaban
|
|
2966
3022
|
// porque el unique de la base es (slug, scope_type, owner_scope_key)
|
|
2967
3023
|
// y cada uno comprobaba la colisión contra SU foto del memo). Ahora
|
|
@@ -3004,11 +3060,21 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
3004
3060
|
assert.oneOf(ok.length, [1, 2], `un ganador, o el del ancestro ensombreciendo al del descendiente (${estado})`);
|
|
3005
3061
|
}
|
|
3006
3062
|
else {
|
|
3007
|
-
// Sin serialización garantizada
|
|
3008
|
-
//
|
|
3009
|
-
//
|
|
3010
|
-
//
|
|
3011
|
-
|
|
3063
|
+
// Sin serialización garantizada cuántos confirman depende del
|
|
3064
|
+
// motor — SQLite bloquea la base entera y el segundo escritor
|
|
3065
|
+
// puede recibir `SQLITE_BUSY`, un fallo de backend legítimo (503)
|
|
3066
|
+
// porque su transacción NO se aplicó—, así que cero también vale.
|
|
3067
|
+
// Y los mismos DOS finales legales que la cara `true` (rotación 2 ·
|
|
3068
|
+
// cierre 2 · T-07): desde R2-05 el re-chequeo en la transacción
|
|
3069
|
+
// decide por AUTORIDAD, así que si el del descendiente confirma
|
|
3070
|
+
// mientras el del ancestro valida, el del ancestro ENTRA y lo
|
|
3071
|
+
// ensombrece (final 2 de 3G · Y1). Hasta alpha.4 aquí se afirmaba
|
|
3072
|
+
// «como mucho uno», que un backend sin el bloqueo de SQLite podía
|
|
3073
|
+
// poner rojo sin defecto (aserción flaky en el artefacto
|
|
3074
|
+
// publicado). Lo innegociable se afirma abajo igual que en la
|
|
3075
|
+
// cara `true`: nunca dos con el MISMO owner y, si son dos, el
|
|
3076
|
+
// extra es el del ANCESTRO.
|
|
3077
|
+
assert.isAtMost(ok.length, 2, `nunca dos ganadores con el MISMO owner (${estado})`);
|
|
3012
3078
|
}
|
|
3013
3079
|
for (const rejected of results.filter((r) => r.status === 'rejected')) {
|
|
3014
3080
|
const error = rejected.reason;
|
|
@@ -3105,22 +3171,18 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
3105
3171
|
await insertRoleUnseen({ slug: 'sigiloso', scopeType: 'unit', owner: scopeKey(orgA) });
|
|
3106
3172
|
await rejectsWith(assert, () => here.defineScopedRole(admin, unitA1, { ...spec, slug: 'sigiloso' }), conflicto);
|
|
3107
3173
|
assert.lengthOf(await rowsNamed('sigiloso', 'unit'), 1, 'no se escribe a ciegas sobre lo que el memo no vio');
|
|
3108
|
-
// Y la rama del re-chequeo que NO
|
|
3109
|
-
// U3, tester 3E · §4.2
|
|
3110
|
-
//
|
|
3111
|
-
//
|
|
3112
|
-
//
|
|
3113
|
-
//
|
|
3114
|
-
// Hasta 3F esa rama solo la observaba la carrera de arriba, y solo
|
|
3115
|
-
// en el harness cuyo árbol SQL ensancha la ventana: aquí va EN
|
|
3116
|
-
// SERIE (el memo se recalienta primero, así que la fila nueva le es
|
|
3117
|
-
// invisible y solo la relectura de la BASE la ve).
|
|
3174
|
+
// Y la rama del re-chequeo de un owner que NO es global ni ancestro-o-igual del owner pedido —una unit
|
|
3175
|
+
// HERMANA— (3F · U3, tester 3E · §4.2; volteada en la rotación 2 de M4 · R2-05). Hasta 2.5.0-alpha.4 se
|
|
3176
|
+
// rechazaba con un 422 «reintenta» que NOMBRABA el owner del hermano (otro tenant) y la colisión era falsa:
|
|
3177
|
+
// dos hermanos pueden compartir slug. Ahora se juzga con la MISMA regla de autoridad que la comprobación
|
|
3178
|
+
// previa y entra. EN SERIE: el memo se recalienta primero, así que la fila nueva le es invisible y solo la
|
|
3179
|
+
// relectura de la BASE la ve.
|
|
3118
3180
|
const unitA2 = await unitUnder(tree, orgA);
|
|
3119
3181
|
assert.deepEqual(await here.listRoles(admin, orgA), ['org-admin'], 'memo recalentado tras el fallo anterior');
|
|
3120
3182
|
await insertRoleUnseen({ slug: 'lateral', scopeType: 'unit', owner: scopeKey(unitA2) });
|
|
3121
|
-
const
|
|
3122
|
-
assert.
|
|
3123
|
-
assert.lengthOf(await rowsNamed('lateral', 'unit'),
|
|
3183
|
+
const lateral = await here.defineScopedRole(admin, unitA1, { ...spec, slug: 'lateral' });
|
|
3184
|
+
assert.equal(lateral.owner, scopeKey(unitA1), 'el homónimo de un HERMANO no es colisión: entra');
|
|
3185
|
+
assert.lengthOf(await rowsNamed('lateral', 'unit'), 2, 'los dos hermanos tienen su lateral@unit');
|
|
3124
3186
|
});
|
|
3125
3187
|
caseFor('serializedCatalogWrites', {
|
|
3126
3188
|
whenTrue: () => raceOfDefines(true),
|
|
@@ -3738,6 +3800,183 @@ export function registerAuthorizationDriverContract(harness, api) {
|
|
|
3738
3800
|
tree.chainOf = original;
|
|
3739
3801
|
}
|
|
3740
3802
|
}
|
|
3803
|
+
// ── Estrategia 2.5.0 · firma 4 · `experimental` (regla D15) ─────────
|
|
3804
|
+
// La capacidad DICE algo que el consumidor tiene que saber —«este driver está fuera de la garantía de la versión»— y
|
|
3805
|
+
// no apaga ningún caso: el juez corre ENTERO contra un driver experimental. Lo que se observa es la declaración: la
|
|
3806
|
+
// del driver y la copia normalizada que publica `authorization.capabilities()`, en las DOS caras.
|
|
3807
|
+
caseFor('experimental', {
|
|
3808
|
+
whenTrue: () => {
|
|
3809
|
+
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, }) => {
|
|
3810
|
+
assert.strictEqual(driver.capabilities?.experimental, true, 'el harness declara experimental: true y el driver no: declara lo observable');
|
|
3811
|
+
const published = await managerOver().capabilities();
|
|
3812
|
+
assert.strictEqual(published.experimental, true);
|
|
3813
|
+
assert.isTrue(Object.isFrozen(published));
|
|
3814
|
+
});
|
|
3815
|
+
},
|
|
3816
|
+
whenFalse: () => {
|
|
3817
|
+
test('experimental:false · el driver NO se declara experimental (ausente o false) y `authorization.capabilities().experimental` es false, nunca undefined (D15)', async ({ assert, }) => {
|
|
3818
|
+
assert.notStrictEqual(driver.capabilities?.experimental, true, 'el harness declara experimental: false y el driver declara true: declara lo observable');
|
|
3819
|
+
const published = await managerOver().capabilities();
|
|
3820
|
+
assert.strictEqual(published.experimental, false);
|
|
3821
|
+
});
|
|
3822
|
+
},
|
|
3823
|
+
});
|
|
3824
|
+
// ── Rotación 2 · cierre 3 · `snapshotDecisions` (regla D15, linaje L12) ─────────
|
|
3825
|
+
// Una decisión que combina lo que el holder TIENE con lo que tiene DENEGADO, leída en dos métodos del puerto sin
|
|
3826
|
+
// foto común, puede responder lo que ningún estado real responde (un offboarding entre las dos lecturas). La
|
|
3827
|
+
// capacidad dice que el driver lee las dos mitades de una vez; lo que el juez observa es el MÉTODO (`readSnapshot`):
|
|
3828
|
+
// su respuesta es la de los métodos homónimos, y el manager lo usa en vez de componer.
|
|
3829
|
+
const snapshotSeed = async () => {
|
|
3830
|
+
const alice = subject();
|
|
3831
|
+
const orgA = await orgUnder(tree, APP_SCOPE);
|
|
3832
|
+
const unitA1 = await unitUnder(tree, orgA);
|
|
3833
|
+
await driver.grant(alice, 'org-editor', orgA);
|
|
3834
|
+
await driver.grant(alice, 'unit-editor', unitA1);
|
|
3835
|
+
await driver.grant(alice, 'viewer', APP_SCOPE);
|
|
3836
|
+
await driver.deny(alice, 'docs:read', unitA1);
|
|
3837
|
+
await driver.deny(alice, 'billing:read', orgA);
|
|
3838
|
+
return { alice, orgA, unitA1 };
|
|
3839
|
+
};
|
|
3840
|
+
const canonical = (items) => items.map((item) => JSON.stringify(item)).sort();
|
|
3841
|
+
/**
|
|
3842
|
+
* El espía del par (rotación 2 · cierre 3b, tester 🟡 6): cuenta SOLO las llamadas que hace el MANAGER al driver. Cada
|
|
3843
|
+
* método se reenvía con `this` = el driver de verdad (no el proxy), así que lo que un `readSnapshot` llame por dentro
|
|
3844
|
+
* (`this.rolesInChain()` dentro de una transacción REPEATABLE READ, p. ej.) no se cuenta: el juez fija el contrato
|
|
3845
|
+
* (UNA llamada del manager), no la implementación.
|
|
3846
|
+
*/
|
|
3847
|
+
const watchManagerCalls = (target) => {
|
|
3848
|
+
const touched = [];
|
|
3849
|
+
// El proxy va sobre un objeto VACÍO (no sobre el driver): un driver congelado o con métodos no configurables no
|
|
3850
|
+
// choca con las invariantes de `Proxy`, y todo se lee del driver de verdad. Una VISTA que devuelve el driver
|
|
3851
|
+
// (`withChainResolver`, `withClock`: el manager las pide por request) se vigila igual, con la misma cuenta.
|
|
3852
|
+
const watch = (real) => new Proxy({}, {
|
|
3853
|
+
get: (_shell, prop) => {
|
|
3854
|
+
const value = Reflect.get(real, prop, real);
|
|
3855
|
+
if (typeof value === 'function' && typeof prop === 'string') {
|
|
3856
|
+
return (...args) => {
|
|
3857
|
+
touched.push(prop);
|
|
3858
|
+
const out = value.apply(real, args);
|
|
3859
|
+
const isView = out !== null && typeof out === 'object' && typeof out.then !== 'function' && typeof out.authorize === 'function';
|
|
3860
|
+
return isView ? watch(out) : out;
|
|
3861
|
+
};
|
|
3862
|
+
}
|
|
3863
|
+
return value;
|
|
3864
|
+
},
|
|
3865
|
+
has: (_shell, prop) => prop in real,
|
|
3866
|
+
});
|
|
3867
|
+
const watched = watch(target);
|
|
3868
|
+
const reads = () => touched.filter((name) => ['readSnapshot', 'rolesInChain', 'listScopes', 'listDenies', 'listRoles'].includes(name));
|
|
3869
|
+
return { watched, touched, reads };
|
|
3870
|
+
};
|
|
3871
|
+
/**
|
|
3872
|
+
* La parte de DELEGACIÓN de las dos caras (rotación 2 · cierre 3c, A/J1 del auditor y del tester): la policy de
|
|
3873
|
+
* `defineScopedRole` solo se juzga si el harness declara `purgeRole: true`. Sin `purgeRole` el manager no delega
|
|
3874
|
+
* (`defineScopedRole` es 500 `E_AUTHZ_UNSUPPORTED` ANTES de la policy, 3E · P4), y `purgeRole` es un par de `'2.2'`:
|
|
3875
|
+
* un tercero `'2.1'` con `listDenies` y sin `purgeRole` —legítimo, D15— salía ROJO con un caso nuevo. Decide la
|
|
3876
|
+
* DECLARACIÓN del harness, no la presencia del método, por el patrón del juez (como `listDenies` aquí mismo): una
|
|
3877
|
+
* declaración `true` solo es posible en `'2.2'` (en `'2.1'` el juez lanza al registrar un `true` sin caso) y ahí la
|
|
3878
|
+
* juzga su propio par —método presente y purga total, o ausente y 500—, mientras que un método presente en un
|
|
3879
|
+
* harness `'2.1'` no lo juzga nadie. El título de cada cara dice cuál de las dos se registró.
|
|
3880
|
+
*/
|
|
3881
|
+
const snapshotDelegation = harness.capabilities.purgeRole === true;
|
|
3882
|
+
caseFor('snapshotDecisions', {
|
|
3883
|
+
whenTrue: () => {
|
|
3884
|
+
since('2.1', 'snapshotDecisions:true · readSnapshot responde cada lectura pedida EXACTAMENTE como el método homónimo (rolesInChain, listScopes, listDenies), lo no pedido no viene, un permiso fuera del catálogo da listScopes [] y una pregunta mal formada es 422', async ({ assert, }) => {
|
|
3885
|
+
assert.typeOf(driver.readSnapshot, 'function', 'snapshotDecisions: true exige readSnapshot en el puerto');
|
|
3886
|
+
assert.strictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: true y el driver no');
|
|
3887
|
+
assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, true);
|
|
3888
|
+
const { alice, unitA1 } = await snapshotSeed();
|
|
3889
|
+
const chain = (await tree.chainOf(unitA1));
|
|
3890
|
+
const all = await driver.readSnapshot(alice, { rolesInChain: chain, listScopes: 'docs:write', listDenies: true });
|
|
3891
|
+
assert.sameMembers(Object.keys(all), ['rolesInChain', 'listScopes', 'listDenies']);
|
|
3892
|
+
assert.deepEqual(canonical(all.rolesInChain), canonical(await driver.rolesInChain(alice, chain)));
|
|
3893
|
+
assert.deepEqual(canonical(all.listScopes), canonical(await driver.listScopes(alice, 'docs:write')));
|
|
3894
|
+
assert.deepEqual(canonical(all.listDenies), canonical(await driver.listDenies(alice)));
|
|
3895
|
+
assert.lengthOf(all.listDenies, 2);
|
|
3896
|
+
assert.isAbove(all.rolesInChain.length, 0);
|
|
3897
|
+
// Con el deny de docs:read en la unit, listScopes de docs:read no lista la unit (ni lo que bloquea).
|
|
3898
|
+
const reads = await driver.readSnapshot(alice, { listScopes: 'docs:read' });
|
|
3899
|
+
assert.deepEqual(Object.keys(reads), ['listScopes'], 'lo no pedido no viene');
|
|
3900
|
+
assert.deepEqual(canonical(reads.listScopes), canonical(await driver.listScopes(alice, 'docs:read')));
|
|
3901
|
+
assert.deepEqual(await driver.readSnapshot(alice, { listScopes: 'no:existe' }), { listScopes: [] });
|
|
3902
|
+
for (const bad of [{}, { foo: true }, { listDenies: false }, { rolesInChain: 'x' }, null]) {
|
|
3903
|
+
await rejectsWith(assert, () => driver.readSnapshot(alice, bad), { status: 422, code: 'E_AUTHZ_INVALID_IDENTITY' });
|
|
3904
|
+
}
|
|
3905
|
+
});
|
|
3906
|
+
since('2.1', snapshotDelegation
|
|
3907
|
+
? 'snapshotDecisions:true · el manager compone effectivePermissions, authorizedScopes y la policy de defineScopedRole con UNA llamada a readSnapshot y CERO a rolesInChain/listScopes/listDenies, con el mismo resultado'
|
|
3908
|
+
: 'snapshotDecisions:true · el manager compone effectivePermissions y authorizedScopes con UNA llamada a readSnapshot y CERO a rolesInChain/listScopes/listDenies, con el mismo resultado (sin purgeRole: el manager no delega y la policy de defineScopedRole no se juzga aquí)', async ({ assert, }) => {
|
|
3909
|
+
const { alice, orgA, unitA1 } = await snapshotSeed();
|
|
3910
|
+
const { watched: spied, touched, reads } = watchManagerCalls(driver);
|
|
3911
|
+
const watched = managerOver({ delegablePermissions: ['docs:read', 'docs:write'] }, spied);
|
|
3912
|
+
assert.sameMembers(await watched.effectivePermissions(alice, unitA1), ['docs:write']);
|
|
3913
|
+
assert.deepEqual(reads(), ['readSnapshot']);
|
|
3914
|
+
touched.length = 0;
|
|
3915
|
+
// docs:write: org-editor en la org y unit-editor en la unit (viewer en app solo da docs:read) ⇒ la unit.
|
|
3916
|
+
assert.deepEqual(await watched.authorizedScopes(alice, 'docs:write', 'unit'), { kind: 'some', scopes: [unitA1] });
|
|
3917
|
+
assert.deepEqual(reads(), ['readSnapshot']);
|
|
3918
|
+
touched.length = 0;
|
|
3919
|
+
assert.deepEqual(await watched.authorizedScopes(alice, 'docs:read', 'unit'), { kind: 'all', excludedSubtrees: [{ scope: unitA1, includesDescendants: true }] });
|
|
3920
|
+
assert.deepEqual(reads(), ['readSnapshot']);
|
|
3921
|
+
if (!snapshotDelegation)
|
|
3922
|
+
return;
|
|
3923
|
+
assert.typeOf(driver.purgeRole, 'function', 'el harness declara purgeRole: true y el puerto no lo trae');
|
|
3924
|
+
// La policy de delegación: el actor tiene docs:read DENEGADO en la unit (owner) ⇒ 422 con UNA foto.
|
|
3925
|
+
const admin = subject();
|
|
3926
|
+
await driver.grant(admin, 'org-admin', orgA);
|
|
3927
|
+
await driver.deny(admin, 'docs:read', orgA);
|
|
3928
|
+
touched.length = 0;
|
|
3929
|
+
await rejectsWith(assert, () => watched.defineScopedRole(admin, orgA, { slug: 'lead', scopeType: 'unit', rank: 10, permissions: ['docs:read'] }), {
|
|
3930
|
+
status: 422,
|
|
3931
|
+
code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE',
|
|
3932
|
+
});
|
|
3933
|
+
assert.deepEqual(reads(), ['readSnapshot']);
|
|
3934
|
+
});
|
|
3935
|
+
},
|
|
3936
|
+
whenFalse: () => {
|
|
3937
|
+
// Rotación 2 · cierre 3b (H3 del auditor): el manager decide por la CAPACIDAD, no por la presencia del método, así
|
|
3938
|
+
// que esta cara NO exige que el puerto no traiga `readSnapshot` (un tercero puede tener un método con ese nombre
|
|
3939
|
+
// para otra cosa): exige que el manager NO lo llame. Sin `listDenies` no hay composición que juzgar y la cara
|
|
3940
|
+
// se queda en la declaración —lo dice su título (tester 🟡 6)—.
|
|
3941
|
+
if (!harness.capabilities.listDenies) {
|
|
3942
|
+
since('2.1', 'snapshotDecisions:false · (sin listDenies: solo la declaración) authorization.capabilities().snapshotDecisions es false y el driver no declara true; sin listDenies el manager no compone ninguna decisión con deny que juzgar', async ({ assert, }) => {
|
|
3943
|
+
assert.notStrictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: false y el driver declara true');
|
|
3944
|
+
assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, false);
|
|
3945
|
+
});
|
|
3946
|
+
return;
|
|
3947
|
+
}
|
|
3948
|
+
since('2.1', snapshotDelegation
|
|
3949
|
+
? 'snapshotDecisions:false · authorization.capabilities().snapshotDecisions es false y el manager compone effectivePermissions, authorizedScopes y la policy de defineScopedRole con rolesInChain/listScopes + listDenies SIN llamar a readSnapshot (aunque el driver traiga un método con ese nombre: decide la capacidad), restando los denies (el hueco DECLARADO de un driver sin la capacidad)'
|
|
3950
|
+
: 'snapshotDecisions:false · authorization.capabilities().snapshotDecisions es false y el manager compone effectivePermissions y authorizedScopes con rolesInChain/listScopes + listDenies SIN llamar a readSnapshot (aunque el driver traiga un método con ese nombre: decide la capacidad), restando los denies (el hueco DECLARADO de un driver sin la capacidad; sin purgeRole: el manager no delega y la policy de defineScopedRole no se juzga aquí)', async ({ assert, }) => {
|
|
3951
|
+
assert.notStrictEqual(driver.capabilities?.snapshotDecisions, true, 'el harness declara snapshotDecisions: false y el driver declara true');
|
|
3952
|
+
assert.strictEqual((await managerOver().capabilities()).snapshotDecisions, false);
|
|
3953
|
+
const { alice, orgA, unitA1 } = await snapshotSeed();
|
|
3954
|
+
const { watched: spied, touched, reads } = watchManagerCalls(driver);
|
|
3955
|
+
const watched = managerOver({ delegablePermissions: ['docs:read', 'docs:write'] }, spied);
|
|
3956
|
+
assert.sameMembers(await watched.effectivePermissions(alice, unitA1), ['docs:write'], 'el deny de docs:read en la unit se resta');
|
|
3957
|
+
assert.include(reads(), 'listDenies');
|
|
3958
|
+
assert.notInclude(reads(), 'readSnapshot');
|
|
3959
|
+
touched.length = 0;
|
|
3960
|
+
assert.deepEqual(await watched.authorizedScopes(alice, 'docs:read', 'unit'), { kind: 'all', excludedSubtrees: [{ scope: unitA1, includesDescendants: true }] });
|
|
3961
|
+
assert.deepEqual(await watched.authorizedScopes(alice, 'docs:write', 'unit'), { kind: 'some', scopes: [unitA1] });
|
|
3962
|
+
assert.include(reads(), 'listDenies');
|
|
3963
|
+
assert.notInclude(reads(), 'readSnapshot');
|
|
3964
|
+
if (!snapshotDelegation)
|
|
3965
|
+
return;
|
|
3966
|
+
assert.typeOf(driver.purgeRole, 'function', 'el harness declara purgeRole: true y el puerto no lo trae');
|
|
3967
|
+
const admin = subject();
|
|
3968
|
+
await driver.grant(admin, 'org-admin', orgA);
|
|
3969
|
+
await driver.deny(admin, 'docs:read', orgA);
|
|
3970
|
+
touched.length = 0;
|
|
3971
|
+
await rejectsWith(assert, () => watched.defineScopedRole(admin, orgA, { slug: 'lead', scopeType: 'unit', rank: 10, permissions: ['docs:read'] }), {
|
|
3972
|
+
status: 422,
|
|
3973
|
+
code: 'E_AUTHZ_PERMISSION_NOT_DELEGABLE',
|
|
3974
|
+
});
|
|
3975
|
+
assert.include(reads(), 'listDenies');
|
|
3976
|
+
assert.notInclude(reads(), 'readSnapshot');
|
|
3977
|
+
});
|
|
3978
|
+
},
|
|
3979
|
+
});
|
|
3741
3980
|
// ── 3b-2e · E2 · `roleInheritanceNative` ─────────────────────────────
|
|
3742
3981
|
caseFor('roleInheritanceNative', {
|
|
3743
3982
|
whenFalse: () => {
|
|
@@ -3868,4 +4107,3 @@ export function uncoveredCapabilities(capabilities, covered) {
|
|
|
3868
4107
|
* un skip disfrazado — «rechazo activo» sería una etiqueta.
|
|
3869
4108
|
*/
|
|
3870
4109
|
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
|
|
@@ -13,4 +13,3 @@ export { runRelationsDriverContract, registerRelationsDriverContract, makeRelati
|
|
|
13
13
|
export { createTestClock } from './test_clock.js';
|
|
14
14
|
export { runRelationsReconcileContract, registerRelationsReconcileContract, } from './relations_reconcile_contract.js';
|
|
15
15
|
export { runScopeOutboxContract, registerScopeOutboxContract, makeScopeOutbox, SCOPE_OUTBOX_CAPABILITY_KEYS, } from './scope_outbox_contract.js';
|
|
16
|
-
//# sourceMappingURL=main.js.map
|
|
@@ -290,4 +290,3 @@ export declare function runMigrationDirection(harness: MigrationContractHarness,
|
|
|
290
290
|
*/
|
|
291
291
|
export declare function runMigrationContract(harness: MigrationContractHarness): void;
|
|
292
292
|
export declare function registerMigrationContract(harness: MigrationContractHarness, api: ContractTestApi): void;
|
|
293
|
-
//# sourceMappingURL=migration_contract.d.ts.map
|