From dd933a40a31a0c2dc2232e9fd55b56490fcb702b Mon Sep 17 00:00:00 2001 From: Kseniia Alekseitseva Date: Tue, 1 Sep 2026 14:49:52 +0000 Subject: [PATCH] B8-oagw-gateway__ATMQTx8 --- Cargo.lock | 1 + gears/system/oagw/oagw/Cargo.toml | 17 +- gears/system/oagw/oagw/src/api/dto.rs | 413 ++++ gears/system/oagw/oagw/src/api/error.rs | 301 +++ gears/system/oagw/oagw/src/api/extract.rs | 141 ++ .../system/oagw/oagw/src/api/handlers/mod.rs | 63 + .../oagw/oagw/src/api/handlers/plugins.rs | 102 + .../oagw/oagw/src/api/handlers/proxy.rs | 476 ++++ .../oagw/oagw/src/api/handlers/routes.rs | 99 + .../oagw/oagw/src/api/handlers/upstreams.rs | 104 + gears/system/oagw/oagw/src/api/mod.rs | 10 + gears/system/oagw/oagw/src/api/query.rs | 398 ++++ gears/system/oagw/oagw/src/api/routes.rs | 558 +++++ gears/system/oagw/oagw/src/config.rs | 494 ++++ gears/system/oagw/oagw/src/domain/alias.rs | 788 +++++++ .../oagw/oagw/src/domain/credentials.rs | 360 +++ .../system/oagw/oagw/src/domain/lifecycle.rs | 16 + gears/system/oagw/oagw/src/domain/mod.rs | 18 + gears/system/oagw/oagw/src/domain/model.rs | 664 ++++++ gears/system/oagw/oagw/src/domain/plugin.rs | 422 ++++ .../oagw/oagw/src/domain/proxy/breaker.rs | 1034 +++++++++ .../oagw/oagw/src/domain/proxy/chain.rs | 80 + .../system/oagw/oagw/src/domain/proxy/cors.rs | 791 +++++++ .../oagw/oagw/src/domain/proxy/headers.rs | 786 +++++++ .../system/oagw/oagw/src/domain/proxy/mod.rs | 60 + .../oagw/oagw/src/domain/proxy/plugins.rs | 477 ++++ .../oagw/oagw/src/domain/proxy/ratelimit.rs | 1202 ++++++++++ .../oagw/oagw/src/domain/proxy/routing.rs | 1053 +++++++++ .../oagw/oagw/src/domain/proxy/service.rs | 2050 +++++++++++++++++ gears/system/oagw/oagw/src/domain/service.rs | 541 +++++ gears/system/oagw/oagw/src/domain/spec.rs | 321 +++ gears/system/oagw/oagw/src/domain/store.rs | 739 ++++++ gears/system/oagw/oagw/src/domain/time.rs | 28 + .../system/oagw/oagw/src/domain/validation.rs | 937 ++++++++ gears/system/oagw/oagw/src/error.rs | 658 ++++++ gears/system/oagw/oagw/src/gear.rs | 189 ++ gears/system/oagw/oagw/src/infra/metrics.rs | 413 ++++ gears/system/oagw/oagw/src/infra/mod.rs | 12 + .../oagw/oagw/src/infra/plugin/apikey_auth.rs | 266 +++ .../system/oagw/oagw/src/infra/plugin/mod.rs | 49 + .../oagw/oagw/src/infra/plugin/noop_auth.rs | 50 + .../infra/plugin/oauth2_client_cred_auth.rs | 617 +++++ .../oagw/oagw/src/infra/plugin/registry.rs | 245 ++ .../src/infra/plugin/request_id_transform.rs | 173 ++ .../infra/plugin/required_headers_guard.rs | 220 ++ .../oagw/oagw/src/infra/plugin/secrets.rs | 334 +++ .../oagw/oagw/src/infra/plugin/traits.rs | 354 +++ gears/system/oagw/oagw/src/lib.rs | 46 + .../oagw/oagw/tests/circuit_breaker_test.rs | 789 +++++++ gears/system/oagw/oagw/tests/common/mod.rs | 991 ++++++++ gears/system/oagw/oagw/tests/metrics_test.rs | 1216 ++++++++++ .../oagw/oagw/tests/plugin_chain_test.rs | 1338 +++++++++++ .../oagw/oagw/tests/plugins_api_test.rs | 954 ++++++++ .../system/oagw/oagw/tests/proxy_api_test.rs | 1461 ++++++++++++ .../oagw/oagw/tests/rate_limit_cors_test.rs | 1323 +++++++++++ .../system/oagw/oagw/tests/routes_api_test.rs | 638 +++++ .../oagw/oagw/tests/upstreams_api_test.rs | 1510 ++++++++++++ .../system/oagw/oagw/tests/websocket_test.rs | 973 ++++++++ 58 files changed, 30361 insertions(+), 2 deletions(-) create mode 100644 gears/system/oagw/oagw/src/api/dto.rs create mode 100644 gears/system/oagw/oagw/src/api/error.rs create mode 100644 gears/system/oagw/oagw/src/api/extract.rs create mode 100644 gears/system/oagw/oagw/src/api/handlers/mod.rs create mode 100644 gears/system/oagw/oagw/src/api/handlers/plugins.rs create mode 100644 gears/system/oagw/oagw/src/api/handlers/proxy.rs create mode 100644 gears/system/oagw/oagw/src/api/handlers/routes.rs create mode 100644 gears/system/oagw/oagw/src/api/handlers/upstreams.rs create mode 100644 gears/system/oagw/oagw/src/api/mod.rs create mode 100644 gears/system/oagw/oagw/src/api/query.rs create mode 100644 gears/system/oagw/oagw/src/api/routes.rs create mode 100644 gears/system/oagw/oagw/src/config.rs create mode 100644 gears/system/oagw/oagw/src/domain/alias.rs create mode 100644 gears/system/oagw/oagw/src/domain/credentials.rs create mode 100644 gears/system/oagw/oagw/src/domain/lifecycle.rs create mode 100644 gears/system/oagw/oagw/src/domain/mod.rs create mode 100644 gears/system/oagw/oagw/src/domain/model.rs create mode 100644 gears/system/oagw/oagw/src/domain/plugin.rs create mode 100644 gears/system/oagw/oagw/src/domain/proxy/breaker.rs create mode 100644 gears/system/oagw/oagw/src/domain/proxy/chain.rs create mode 100644 gears/system/oagw/oagw/src/domain/proxy/cors.rs create mode 100644 gears/system/oagw/oagw/src/domain/proxy/headers.rs create mode 100644 gears/system/oagw/oagw/src/domain/proxy/mod.rs create mode 100644 gears/system/oagw/oagw/src/domain/proxy/plugins.rs create mode 100644 gears/system/oagw/oagw/src/domain/proxy/ratelimit.rs create mode 100644 gears/system/oagw/oagw/src/domain/proxy/routing.rs create mode 100644 gears/system/oagw/oagw/src/domain/proxy/service.rs create mode 100644 gears/system/oagw/oagw/src/domain/service.rs create mode 100644 gears/system/oagw/oagw/src/domain/spec.rs create mode 100644 gears/system/oagw/oagw/src/domain/store.rs create mode 100644 gears/system/oagw/oagw/src/domain/time.rs create mode 100644 gears/system/oagw/oagw/src/domain/validation.rs create mode 100644 gears/system/oagw/oagw/src/error.rs create mode 100644 gears/system/oagw/oagw/src/gear.rs create mode 100644 gears/system/oagw/oagw/src/infra/metrics.rs create mode 100644 gears/system/oagw/oagw/src/infra/mod.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/apikey_auth.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/mod.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/noop_auth.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/oauth2_client_cred_auth.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/registry.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/request_id_transform.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/required_headers_guard.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/secrets.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/traits.rs create mode 100644 gears/system/oagw/oagw/tests/circuit_breaker_test.rs create mode 100644 gears/system/oagw/oagw/tests/common/mod.rs create mode 100644 gears/system/oagw/oagw/tests/metrics_test.rs create mode 100644 gears/system/oagw/oagw/tests/plugin_chain_test.rs create mode 100644 gears/system/oagw/oagw/tests/plugins_api_test.rs create mode 100644 gears/system/oagw/oagw/tests/proxy_api_test.rs create mode 100644 gears/system/oagw/oagw/tests/rate_limit_cors_test.rs create mode 100644 gears/system/oagw/oagw/tests/routes_api_test.rs create mode 100644 gears/system/oagw/oagw/tests/upstreams_api_test.rs create mode 100644 gears/system/oagw/oagw/tests/websocket_test.rs diff --git a/Cargo.lock b/Cargo.lock index 9c02857..25e7c30 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1564,6 +1564,7 @@ dependencies = [ "cf-gears-toolkit-gts", "cf-gears-toolkit-http", "cf-gears-toolkit-macros", + "cf-gears-toolkit-odata", "cf-gears-toolkit-security", "cf-gears-types-registry", "cf-gears-types-registry-sdk", diff --git a/gears/system/oagw/oagw/Cargo.toml b/gears/system/oagw/oagw/Cargo.toml index a18b934..8ff1989 100644 --- a/gears/system/oagw/oagw/Cargo.toml +++ b/gears/system/oagw/oagw/Cargo.toml @@ -15,6 +15,9 @@ metadata.docs.rs.all-features = true name = "oagw" path = "src/lib.rs" +[lints] +workspace = true + [features] # FIPS-140-3: compile TLS deps with FIPS-approved cipher suites only fips = ["toolkit-http/fips"] @@ -37,6 +40,9 @@ toolkit = { workspace = true } toolkit-auth = { workspace = true } toolkit-canonical-errors = { workspace = true, features = ["axum"] } toolkit-gts = { workspace = true } +# `SortDir` for `$orderby` direction comparison (the type behind +# `toolkit::api::odata::parse_orderby`). +toolkit-odata = { workspace = true } toolkit-http = { workspace = true } toolkit-security = { workspace = true } toolkit-macros = { workspace = true } @@ -44,6 +50,8 @@ inventory = { workspace = true } async-trait = { workspace = true } axum = { workspace = true } http = { workspace = true } +# `LengthLimitError` detection for the 413 mapping of oversized bodies. +http-body-util = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } uuid = { workspace = true, features = ["v4", "serde"] } @@ -54,7 +62,9 @@ tracing = { workspace = true } url = { workspace = true } gts = { workspace = true } utoipa = { workspace = true } -types-registry-sdk = { workspace = true } +# Referenced by the `#[toolkit::gear(deps = [types_registry])]` expansion, which +# re-exports the crate (`pub use ::types_registry as _gear_dep_types_registry`) to +# force-link its `inventory::submit!` registrations. types-registry = { workspace = true } authz-resolver-sdk = { workspace = true } authz-resolver = { workspace = true } @@ -78,7 +88,10 @@ futures-util = { workspace = true, features = ["sink"] } tokio = { workspace = true, features = ["time"] } tokio-retry = { workspace = true } hyper = { workspace = true } -hyper-util = { workspace = true } +# The WebSocket handshake dials through hyper-util's legacy client over a plain +# `HttpConnector`; the features are spelled out so the build does not lean on +# toolkit-http's feature unification. +hyper-util = { workspace = true, features = ["client-legacy", "http1", "tokio"] } # Pingora proxy engine pingora-proxy = { version = "0.8", features = ["rustls"] } pingora-core = { version = "0.8", features = ["rustls"] } diff --git a/gears/system/oagw/oagw/src/api/dto.rs b/gears/system/oagw/oagw/src/api/dto.rs new file mode 100644 index 0000000..ba38b2c --- /dev/null +++ b/gears/system/oagw/oagw/src/api/dto.rs @@ -0,0 +1,413 @@ +// Created: 2026-08-31 by Constructor Tech +//! REST DTOs for the OAGW management API (DESIGN §3.3). +//! +//! Response bodies carry the **bare UUID** in `id`; timestamps are epoch +//! milliseconds. Every nested configuration member is a shared +//! [`crate::domain::model`] type, so the wire shape and the domain shape are +//! the same type. + +use uuid::Uuid; + +use crate::domain::model::{ + AuthConfig, CorsConfig, HeadersConfig, Plugin, PluginKind, PluginsConfig, Protocol, + RateLimitConfig, Route, RouteMatch, Upstream, +}; +use crate::domain::spec::{ + EndpointSpec, PluginSpec, RouteSpec, RouteUpdateSpec, ServerSpec, UpstreamSpec, +}; + +/// REST DTO for an upstream resource. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct UpstreamDto { + /// Server-generated UUID. + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Routing key, unique per tenant. + pub alias: String, + /// Whether the upstream accepts traffic. + pub enabled: bool, + /// Wire protocol (canonical GTS id). + pub protocol: Protocol, + /// Endpoint pool. + pub server: ServerSpec, + /// Discovery tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Auth plugin binding. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + /// Header transformation rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option, + /// Plugin chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limit policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, + /// Creation instant (epoch milliseconds). + pub created_at: u64, + /// Last modification instant (epoch milliseconds). + pub updated_at: u64, +} + +impl From for UpstreamDto { + fn from(record: Upstream) -> Self { + Self { + id: record.id, + tenant_id: record.tenant_id, + alias: record.alias, + enabled: record.enabled, + protocol: record.protocol, + server: ServerSpec { + endpoints: record + .endpoints + .into_iter() + .map(EndpointSpec::from) + .collect(), + }, + tags: record.tags, + auth: record.auth, + headers: record.headers, + plugins: record.plugins, + rate_limit: record.rate_limit, + cors: record.cors, + created_at: record.timestamps.created_at, + updated_at: record.timestamps.updated_at, + } + } +} + +/// REST DTO for creating an upstream (POST /upstreams). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct CreateUpstreamRequest { + /// Explicit alias; derived from the endpoints when omitted. Rejected when + /// the endpoints imply a different value. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, + /// Whether the upstream accepts traffic; defaults to `true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Wire protocol (canonical GTS id). + pub protocol: Protocol, + /// Endpoint pool. + pub server: ServerSpec, + /// Discovery tags. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + /// Auth plugin binding. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + /// Header transformation rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option, + /// Plugin chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limit policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, +} + +impl From for UpstreamSpec { + fn from(request: CreateUpstreamRequest) -> Self { + Self { + alias: request.alias, + enabled: request.enabled, + protocol: request.protocol, + server: request.server, + tags: request.tags, + auth: request.auth, + headers: request.headers, + plugins: request.plugins, + rate_limit: request.rate_limit, + cors: request.cors, + } + } +} + +/// REST DTO for replacing an upstream (PUT /upstreams/{id}). +/// +/// The alias is **immutable** for hostname pools; an IP-based upstream may +/// repeat its current alias. `id` and `tenant_id` are never part of the +/// payload. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct UpdateUpstreamRequest { + /// Current alias (hostname pools reject any other value). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, + /// Whether the upstream accepts traffic; defaults to `true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Wire protocol (canonical GTS id). + pub protocol: Protocol, + /// Endpoint pool. + pub server: ServerSpec, + /// Discovery tags. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + /// Auth plugin binding. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + /// Header transformation rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option, + /// Plugin chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limit policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, +} + +impl From for UpstreamSpec { + fn from(request: UpdateUpstreamRequest) -> Self { + Self { + alias: request.alias, + enabled: request.enabled, + protocol: request.protocol, + server: request.server, + tags: request.tags, + auth: request.auth, + headers: request.headers, + plugins: request.plugins, + rate_limit: request.rate_limit, + cors: request.cors, + } + } +} + +/// REST DTO for a route resource. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RouteDto { + /// Server-generated UUID. + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Owning upstream. + pub upstream_id: Uuid, + /// Whether the route participates in matching. + pub enabled: bool, + /// Match rule (`match` on the wire). + #[serde(rename = "match")] + pub match_rule: RouteMatch, + /// Discovery tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Plugin chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limit policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS policy (ADR-0004). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, + /// Creation instant (epoch milliseconds). + pub created_at: u64, + /// Last modification instant (epoch milliseconds). + pub updated_at: u64, +} + +impl From for RouteDto { + fn from(record: Route) -> Self { + Self { + id: record.id, + tenant_id: record.tenant_id, + upstream_id: record.upstream_id, + enabled: record.enabled, + match_rule: record.match_rule, + tags: record.tags, + plugins: record.plugins, + rate_limit: record.rate_limit, + cors: record.cors, + created_at: record.timestamps.created_at, + updated_at: record.timestamps.updated_at, + } + } +} + +/// REST DTO for creating a route (POST /routes). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct CreateRouteRequest { + /// Owning upstream; must belong to the calling tenant. + pub upstream_id: Uuid, + /// Whether the route participates in matching; defaults to `true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Match rule (`match` on the wire). + #[serde(rename = "match")] + pub match_rule: RouteMatch, + /// Discovery tags. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + /// Plugin chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limit policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS policy (ADR-0004). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, +} + +impl From for RouteSpec { + fn from(request: CreateRouteRequest) -> Self { + Self { + upstream_id: request.upstream_id, + enabled: request.enabled, + match_rule: request.match_rule, + tags: request.tags, + plugins: request.plugins, + rate_limit: request.rate_limit, + cors: request.cors, + } + } +} + +/// REST DTO for replacing a route (PUT /routes/{id}). +/// +/// `upstream_id` is **immutable** and therefore absent: moving a route to +/// another upstream means delete + create. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct UpdateRouteRequest { + /// Whether the route participates in matching; defaults to `true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Match rule (`match` on the wire). + #[serde(rename = "match")] + pub match_rule: RouteMatch, + /// Discovery tags. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + /// Plugin chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limit policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS policy (ADR-0004). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, +} + +/// REST DTO for a custom plugin resource (ADR-0002 appendix A). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct PluginDto { + /// Server-generated UUID. + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Unique name within the tenant. + pub name: String, + /// Plugin family (`auth` | `guard` | `transform`). + pub plugin_type: PluginKind, + /// Whether the plugin is enabled. + pub enabled: bool, + /// Free-text description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Plugin configuration. + pub config: serde_json::Value, + /// JSON Schema describing `config`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_schema: Option, + /// Starlark source. + pub source_code: String, + /// Creation instant (epoch milliseconds). + pub created_at: u64, + /// Last modification instant (epoch milliseconds). + pub updated_at: u64, +} + +impl From for PluginDto { + fn from(record: Plugin) -> Self { + Self { + id: record.id, + tenant_id: record.tenant_id, + name: record.name, + plugin_type: record.kind, + enabled: record.enabled, + description: record.description, + config: record.config, + config_schema: record.config_schema, + source_code: record.source, + created_at: record.timestamps.created_at, + updated_at: record.timestamps.updated_at, + } + } +} + +/// REST DTO for creating a plugin (POST /plugins). +/// +/// Plugins are immutable after creation: there is no PUT. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +pub struct CreatePluginRequest { + /// Unique name within the tenant. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Plugin family (`auth` | `guard` | `transform`). + #[serde(rename = "plugin_type", alias = "type", default)] + pub plugin_type: Option, + /// Whether the plugin is enabled; defaults to `true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Plugin configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config: Option, + /// JSON Schema describing `config`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_schema: Option, + /// Free-text description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Starlark source. + #[serde(alias = "source", default)] + pub source_code: Option, +} + +impl From for RouteUpdateSpec { + fn from(request: UpdateRouteRequest) -> Self { + Self { + enabled: request.enabled, + match_rule: request.match_rule, + tags: request.tags, + plugins: request.plugins, + rate_limit: request.rate_limit, + cors: request.cors, + } + } +} + +impl From for PluginSpec { + fn from(request: CreatePluginRequest) -> Self { + Self { + name: request.name, + plugin_type: request.plugin_type, + enabled: request.enabled, + config: request.config, + config_schema: request.config_schema, + description: request.description, + source_code: request.source_code, + } + } +} diff --git a/gears/system/oagw/oagw/src/api/error.rs b/gears/system/oagw/oagw/src/api/error.rs new file mode 100644 index 0000000..93653cb --- /dev/null +++ b/gears/system/oagw/oagw/src/api/error.rs @@ -0,0 +1,301 @@ +// Created: 2026-08-31 by Constructor Tech +//! Problem-response post-processing. +//! +//! OAGW owns its problem+json shape (no `context` member), so the toolkit's +//! `canonical_error_middleware` cannot fill `instance` / `trace_id` for it. +//! This middleware mirrors that behaviour for OAGW bodies only, and is applied +//! to the OAGW sub-router so no other gear's routes are affected. + +use axum::body::{Body, to_bytes}; +use axum::extract::Request; +use axum::http::{HeaderMap, HeaderValue, header}; +use axum::middleware::Next; +use axum::response::{IntoResponse, Response}; + +use crate::api::extract::BodyLimit; +use crate::error::OagwError; + +const PROBLEM_JSON: &str = "application/problem+json"; + +/// Reject requests whose declared body exceeds the configured limit (413). +/// +/// The toolkit `OoP` serve path installs no body-limit layer (unlike the API +/// gateway), so the gear enforces `oagw.config.max_body_bytes` itself, on the +/// management routes only: the proxy route of slice 2 must stay free to stream +/// large request bodies. +/// +/// Declared lengths are rejected here before any buffering; a body that never +/// declares its length is cut off by [`crate::api::extract::JsonBody`]. +pub async fn enforce_body_limit(request: Request, next: Next) -> Response { + let Some(limit) = request.extensions().get::().map(|limit| limit.0) else { + return next.run(request).await; + }; + let Some(declared) = declared_length(&request) else { + return next.run(request).await; + }; + if declared > limit { + return OagwError::payload_too_large(limit, declared).into_response(); + } + next.run(request).await +} + +/// Declared `Content-Length` of the request, if any. +fn declared_length(request: &Request) -> Option { + request + .headers() + .get(header::CONTENT_LENGTH)? + .to_str() + .ok()? + .trim() + .parse::() + .ok() +} + +/// Fill `instance` (request path) and `trace_id` on OAGW problem responses. +/// +/// Bodies that already carry the member are left untouched, so handlers stay +/// in control of the wire shape. +pub async fn enrich_problem_response(request: Request, next: Next) -> Response { + let request_path = request.uri().path().to_owned(); + let request_headers = request.headers().clone(); + let response = next.run(request).await; + + if !is_oagw_problem(&response) { + return response; + } + decorate_problem(response, &request_path, &request_headers).await +} + +/// Post-process a single problem body. +async fn decorate_problem(response: Response, path: &str, request_headers: &HeaderMap) -> Response { + let (parts, body) = response.into_parts(); + let Some(bytes) = read_problem_bytes(body).await else { + return Response::from_parts(parts, Body::empty()); + }; + let Some(updated) = decorate_problem_json(&bytes, path, request_headers) else { + return Response::from_parts(parts, Body::from(bytes)); + }; + let length = updated.len(); + let mut response = Response::from_parts(parts, Body::from(updated)); + response + .headers_mut() + .insert(header::CONTENT_LENGTH, HeaderValue::from(length)); + response +} + +/// Buffered body of a problem response. +async fn read_problem_bytes(body: Body) -> Option> { + match to_bytes(body, usize::MAX).await { + Ok(bytes) => Some(bytes.to_vec()), + Err(error) => { + tracing::error!(error = %error, "oagw problem middleware: body read failed"); + None + } + } +} + +/// Add `instance` / `trace_id`, then re-encode. +/// +/// `None` when the body is not a JSON object or cannot be re-encoded; the +/// original bytes are served unchanged in that case. +fn decorate_problem_json(bytes: &[u8], path: &str, request_headers: &HeaderMap) -> Option> { + let mut problem: serde_json::Value = serde_json::from_slice(bytes).ok()?; + let object = problem.as_object_mut()?; + object + .entry("instance") + .or_insert_with(|| serde_json::Value::String(path.to_owned())); + if !object.contains_key("trace_id") + && let Some(trace_id) = extract_trace_id(request_headers) + { + object.insert("trace_id".to_owned(), serde_json::Value::String(trace_id)); + } + warn_on_client_error(&problem); + serde_json::to_vec(&problem).ok() +} + +/// OAGW-owned 4xx observability. +/// +/// The outer canonical-error middleware cannot parse OAGW bodies (they carry +/// no `context` member), so client errors are logged here instead of being +/// silently dropped. +fn warn_on_client_error(problem: &serde_json::Value) { + let Some(status) = problem.get("status").and_then(serde_json::Value::as_u64) else { + return; + }; + if !(400..500).contains(&status) { + return; + } + tracing::warn!( + status = status, + error_type = problem + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown"), + "oagw rejected a client request" + ); +} + +fn is_oagw_problem(response: &Response) -> bool { + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|content_type| content_type.starts_with(PROBLEM_JSON)) +} + +/// W3C `traceparent` → `x-trace-id` → `x-request-id` → span id fallback, +/// matching `toolkit::api::canonical_error_middleware`. +pub(crate) fn extract_trace_id(headers: &axum::http::HeaderMap) -> Option { + correlation_id(headers).or_else(|| { + tracing::Span::current() + .id() + .map(|id| id.into_u64().to_string()) + }) +} + +/// Correlation id the request's own trace headers name, if any. +/// +/// The span-id fallback is deliberately **not** here: a span id is only +/// meaningful to the subscriber that minted it, and the caller that holds the +/// span can read it off the handle, where it is cheaper and always live. +pub(crate) fn correlation_id(headers: &axum::http::HeaderMap) -> Option { + if let Some(traceparent) = headers + .get("traceparent") + .and_then(|value| value.to_str().ok()) + && let Some(trace_id) = parse_w3c_trace_id(traceparent) + { + return Some(trace_id); + } + for name in ["x-trace-id", "x-request-id"] { + if let Some(value) = headers.get(name).and_then(|value| value.to_str().ok()) + && let Some(correlation) = bound_correlation(value) + { + return Some(correlation); + } + } + None +} + +/// The 32-hex trace-id segment of a W3C `traceparent` header. +fn parse_w3c_trace_id(traceparent: &str) -> Option { + let parts: Vec<&str> = traceparent.split('-').collect(); + let candidate = parts.get(1)?; + (candidate.len() == 32 && candidate.chars().all(|c| c.is_ascii_hexdigit())) + .then(|| (*candidate).to_owned()) +} + +/// Whether a correlation id taken from a request header may be recorded. +/// +/// The two fallback headers are client input, and the value goes into a +/// structured field of a log record, so it is bounded the way +/// [`parse_w3c_trace_id`] bounds its segment: a length cap and printable ASCII +/// only. A value outside either bound names nothing the trail can correlate and +/// is dropped rather than copied. +fn bound_correlation(value: &str) -> Option { + let bounded = value.len() <= CORRELATION_CAP + && !value.is_empty() + && value + .chars() + .all(|character| character.is_ascii_graphic() || character == ' '); + bounded.then(|| value.to_owned()) +} + +/// Longest correlation id a request header may contribute (a W3C trace id is +/// 32 characters; 128 leaves room for an id the platform minted itself). +const CORRELATION_CAP: usize = 128; + +#[cfg(test)] +mod tests { + use axum::body::Body; + use axum::extract::Request; + use axum::http::header; + use axum::middleware::from_fn; + use axum::routing::get; + use tower::ServiceExt; + + use super::{enrich_problem_response, extract_trace_id, parse_w3c_trace_id}; + use crate::error::{ERROR_SOURCE_GATEWAY, ERROR_SOURCE_HEADER, OagwError}; + + #[test] + fn parses_a_w3c_traceparent() { + let trace_id = "0af7651916cd43dd8448eb211c80319c"; + let header = format!("00-{trace_id}-b7ad6b7169203331-01"); + assert_eq!(parse_w3c_trace_id(&header).as_deref(), Some(trace_id)); + assert_eq!(parse_w3c_trace_id("00-short-b7ad6b7169203331-01"), None); + } + + #[test] + fn falls_back_to_request_headers() { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + "x-request-id", + axum::http::HeaderValue::from_static("req-42"), + ); + assert_eq!(extract_trace_id(&headers).as_deref(), Some("req-42")); + } + + /// The `OoP` serve path wraps every gear in + /// `toolkit::api::canonical_error_middleware`; the OAGW problem contract + /// (type/status/detail, extension members, `X-OAGW-Error-Source`) must come + /// out of it unchanged rather than reshaped into a toolkit `Problem`. + #[tokio::test] + async fn the_oagw_problem_contract_survives_the_toolkit_middleware() + -> Result<(), Box> { + async fn rejected() -> OagwError { + OagwError::validation("tags must be lowercase '[a-z0-9_-]+' labels") + .with_extension(|ext| ext.invalid_value = Some("Eu West".to_owned())) + } + + let app = axum::Router::new() + .route("/oagw/v1/plugins", get(rejected)) + .layer(from_fn(enrich_problem_response)) + .layer(from_fn(toolkit::api::canonical_error_middleware)); + let request = Request::builder() + .method("GET") + .uri("/oagw/v1/plugins") + .body(Body::empty())?; + let response = app.oneshot(request).await?; + + assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); + let content_type = response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned(); + assert!(content_type.starts_with("application/problem+json")); + assert_eq!( + response + .headers() + .get(ERROR_SOURCE_HEADER) + .and_then(|value| value.to_str().ok()), + Some(ERROR_SOURCE_GATEWAY) + ); + + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?; + let problem: serde_json::Value = serde_json::from_slice(&bytes)?; + let error_type = problem + .get("type") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_owned(); + assert!(error_type.ends_with("validation.error.v1"), "{error_type}"); + assert_eq!( + problem.get("status").and_then(serde_json::Value::as_u64), + Some(400) + ); + assert_eq!( + problem + .get("invalid_value") + .and_then(serde_json::Value::as_str), + Some("Eu West") + ); + assert_eq!( + problem.get("instance").and_then(serde_json::Value::as_str), + Some("/oagw/v1/plugins") + ); + // The canonical middleware bailed out instead of reshaping the body. + assert_eq!(problem.get("context"), None); + Ok(()) + } +} diff --git a/gears/system/oagw/oagw/src/api/extract.rs b/gears/system/oagw/oagw/src/api/extract.rs new file mode 100644 index 0000000..db47023 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/extract.rs @@ -0,0 +1,141 @@ +// Created: 2026-08-31 by Constructor Tech +//! Request extraction (DESIGN §3.6 "Resource Identification", §3.3 errors). +//! +//! `{id}` path parameters accept the bare UUID **and** the GTS form +//! `gts.cf.core.oagw..v1~`; response bodies always carry the bare +//! UUID. JSON bodies go through [`JsonBody`], which reports malformed payloads +//! as RFC 9457 problems instead of axum's plain-text rejections. + +use std::error::Error as _; + +use axum::extract::{FromRequest, Request}; +use serde::de::DeserializeOwned; +use uuid::Uuid; + +use crate::error::OagwError; + +/// Parse a bare UUID or a GTS identifier whose instance part is a UUID. +/// +/// # Errors +/// 400 validation when neither spelling parses. +pub fn parse_resource_id(raw: &str) -> Result { + if let Ok(id) = Uuid::parse_str(raw) { + return Ok(id); + } + let Some((_type_path, instance)) = raw.split_once('~') else { + return Err(OagwError::validation(format!( + "'{raw}' is not a resource id: expected a UUID or a GTS identifier" + ))); + }; + Uuid::parse_str(instance).map_err(|_| { + OagwError::validation(format!( + "'{raw}' is not a resource id: the GTS instance part is not a UUID" + )) + }) +} + +/// Configured request-body ceiling of the management API, injected as a +/// request extension by [`crate::api::routes::register_routes`]. +/// +/// The value is `oagw.config.max_body_bytes` (DESIGN §2.2 +/// `constraint-body-limit`); the hard limit in [`crate::config`] is only a +/// last-resort backstop for code paths that run without the extension. +#[derive(Debug, Clone, Copy)] +pub struct BodyLimit(pub u64); + +/// JSON payload extractor that maps decode failures onto OAGW problems. +/// +/// `axum::Json` answers malformed or wrongly-typed payloads with plain-text +/// 4xx/5xx rejections; the management API contract (DESIGN §3.3) requires +/// `application/problem+json` with the GTS error id instead. Bodies above the +/// configured limit become 413 `payload.too_large.v1`. +pub struct JsonBody(pub T); + +impl FromRequest for JsonBody +where + S: Send + Sync, + T: DeserializeOwned, +{ + type Rejection = OagwError; + + async fn from_request(request: Request, _state: &S) -> Result { + let content_type = request + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(); + if !content_type.starts_with("application/json") { + return Err(OagwError::validation( + "request body must be application/json", + )); + } + let limit = request + .extensions() + .get::() + .map_or(crate::config::MAX_BODY_BYTES_HARD_LIMIT, |limit| limit.0); + let bytes = read_body(request.into_body(), limit).await?; + serde_json::from_slice(&bytes) + .map(JsonBody) + .map_err(|error| OagwError::validation(format!("invalid request body: {error}"))) + } +} + +/// Read the request body, keeping the failure inside the OAGW error surface. +/// +/// `enforce_body_limit` (see [`crate::api::error`]) already rejects declared +/// `Content-Length` values above `limit`; this read catches streaming bodies +/// that never declared one. +async fn read_body(body: axum::body::Body, limit: u64) -> Result, OagwError> { + use axum::body::to_bytes; + let cap = usize::try_from(limit).unwrap_or(usize::MAX); + to_bytes(body, cap) + .await + .map(|bytes| bytes.to_vec()) + .map_err(|error| { + if is_length_limit(&error) { + OagwError::payload_too_large(limit, u64::try_from(cap).unwrap_or(u64::MAX)) + } else { + tracing::debug!(error = %error, "oagw request body read failed"); + OagwError::validation("request body could not be read") + } + }) +} + +/// Whether the body read failed because the size cap was hit. +fn is_length_limit(error: &axum::Error) -> bool { + error + .source() + .is_some_and(::is::) +} + +#[cfg(test)] +mod tests { + use super::parse_resource_id; + use crate::error::OagwErrorKind; + + #[test] + fn accepts_a_bare_uuid() -> Result<(), crate::error::OagwError> { + let id = uuid::Uuid::new_v4(); + assert_eq!(parse_resource_id(&id.to_string())?, id); + Ok(()) + } + + #[test] + fn accepts_a_gts_identifier() -> Result<(), crate::error::OagwError> { + let id = uuid::Uuid::new_v4(); + let raw = format!("gts.cf.core.oagw.upstream.v1~{id}"); + assert_eq!(parse_resource_id(&raw)?, id); + Ok(()) + } + + #[test] + fn rejects_non_uuid_payloads() { + assert_eq!( + parse_resource_id("api.openai.com") + .err() + .map(|error| *error.kind()), + Some(OagwErrorKind::Validation) + ); + assert!(parse_resource_id("gts.cf.core.oagw.upstream.v1~not-a-uuid").is_err()); + } +} diff --git a/gears/system/oagw/oagw/src/api/handlers/mod.rs b/gears/system/oagw/oagw/src/api/handlers/mod.rs new file mode 100644 index 0000000..e858218 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/handlers/mod.rs @@ -0,0 +1,63 @@ +// Created: 2026-08-31 by Constructor Tech +//! REST handlers, grouped per resource. + +use toolkit::Page; +use toolkit::api::response::no_content; + +use crate::api::query::{ListQuery, apply_list_query, to_page}; +use crate::error::{OagwError, OagwErrorKind, OagwResult}; + +mod plugins; +mod proxy; +mod routes; +mod upstreams; + +pub(crate) use plugins::{ + create_plugin, delete_plugin, get_plugin, get_plugin_source, list_plugins, +}; +pub(crate) use proxy::{proxy_alias, proxy_unregistered_method}; +pub(crate) use routes::{create_route, delete_route, get_route, list_routes, update_route}; +pub(crate) use upstreams::{ + create_upstream, delete_upstream, get_upstream, list_upstreams, update_upstream, +}; + +/// Serialize a batch of records and run the list pipeline over them. +/// +/// # Errors +/// 400 for invalid query options, 500 when a DTO cannot be encoded. +pub(crate) fn serialize_all( + records: Vec, + project: F, +) -> OagwResult> +where + D: serde::Serialize, + F: FnMut(T) -> D, +{ + records.into_iter().map(project).map(serialize).collect() +} + +/// Apply the list query and wrap the result in the platform page envelope. +pub(crate) fn to_page_json( + items: Vec, + query: &ListQuery, +) -> axum::Json> { + let page = apply_list_query(items, query); + axum::Json(to_page(page, query.top)) +} + +/// JSON-encode a DTO; an encoding failure is a control-plane bug, so it +/// surfaces as a 500 instead of being silently dropped. +fn serialize(value: T) -> OagwResult { + serde_json::to_value(value).map_err(|error| { + OagwError::new( + OagwErrorKind::Internal, + format!("response encoding failed: {error}"), + ) + }) +} + +/// 204 response for successful deletions. +#[must_use] +pub(crate) fn deleted() -> impl axum::response::IntoResponse { + no_content() +} diff --git a/gears/system/oagw/oagw/src/api/handlers/plugins.rs b/gears/system/oagw/oagw/src/api/handlers/plugins.rs new file mode 100644 index 0000000..2ebf760 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/handlers/plugins.rs @@ -0,0 +1,102 @@ +// Created: 2026-08-31 by Constructor Tech +//! Custom plugin handlers (DESIGN §3.3, ADR-0002). +//! +//! Plugins are immutable: no PUT. `GET /plugins/{id}/source` returns the +//! Starlark source as `text/plain`. + +use std::sync::Arc; + +use axum::Extension; +use axum::extract::{Path, Query}; +use axum::http::{Uri, header}; +use axum::response::IntoResponse; +use toolkit::api::response::created_json; +use toolkit_security::SecurityContext; +use tracing::debug; + +use crate::api::dto::{CreatePluginRequest, PluginDto}; +use crate::api::extract::{JsonBody, parse_resource_id}; +use crate::api::handlers::{deleted, serialize_all, to_page_json}; +use crate::api::query::ListQuery; +use crate::domain::service::OagwService; +use crate::error::OagwResult; + +/// List plugins with the documented `OData` subset. +/// +/// # Errors +/// 400 on invalid query options, propagated from the store otherwise. +pub async fn list_plugins( + Extension(ctx): Extension, + Extension(svc): Extension>, + Query(pairs): Query>, +) -> OagwResult>> { + let query = ListQuery::parse(&pairs)?; + let items = serialize_all(svc.list_plugins(ctx.subject_tenant_id())?, PluginDto::from)?; + let page = to_page_json(items, &query); + debug!(tenant = %ctx.subject_tenant_id(), "listed plugins"); + Ok(page) +} + +/// Create a custom plugin. +/// +/// # Errors +/// 400 on missing required members, 409 on a name conflict. +pub async fn create_plugin( + uri: Uri, + Extension(ctx): Extension, + Extension(svc): Extension>, + JsonBody(request): JsonBody, +) -> OagwResult { + let tenant_id = ctx.subject_tenant_id(); + let record = svc.create_plugin(tenant_id, &request.into())?; + let id = record.id.to_string(); + debug!(tenant = %tenant_id, plugin = %record.name, "plugin created"); + let dto = PluginDto::from(record); + Ok(created_json(dto, &uri, &id)) +} + +/// Read one plugin. +/// +/// # Errors +/// 400 on an unparseable id, 404 when the record is foreign or missing. +pub async fn get_plugin( + Extension(ctx): Extension, + Extension(svc): Extension>, + Path(id): Path, +) -> OagwResult> { + let id = parse_resource_id(&id)?; + let record = svc.get_plugin(ctx.subject_tenant_id(), id)?; + Ok(axum::Json(PluginDto::from(record))) +} + +/// Starlark source of one plugin. +/// +/// # Errors +/// 400 on an unparseable id, 404 when the record is foreign or missing. +pub async fn get_plugin_source( + Extension(ctx): Extension, + Extension(svc): Extension>, + Path(id): Path, +) -> OagwResult { + let id = parse_resource_id(&id)?; + let source = svc.plugin_source(ctx.subject_tenant_id(), id)?; + debug!(id = %id, bytes = source.len(), "plugin source fetched"); + Ok(( + [(header::CONTENT_TYPE, "text/plain; charset=utf-8")], + source, + )) +} + +/// Delete a plugin; refuses while an upstream or route still binds it. +/// +/// # Errors +/// 400 on an unparseable id, 404 on a foreign record, 409 `plugin.in_use`. +pub async fn delete_plugin( + Extension(ctx): Extension, + Extension(svc): Extension>, + Path(id): Path, +) -> OagwResult { + let id = parse_resource_id(&id)?; + svc.delete_plugin(ctx.subject_tenant_id(), id)?; + Ok(deleted()) +} diff --git a/gears/system/oagw/oagw/src/api/handlers/proxy.rs b/gears/system/oagw/oagw/src/api/handlers/proxy.rs new file mode 100644 index 0000000..144a789 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/handlers/proxy.rs @@ -0,0 +1,476 @@ +// Created: 2026-08-31 by Constructor Tech +//! Proxy data-plane handlers (DESIGN §3.5). +//! +//! Both registered paths (`/oagw/v1/proxy/{alias}` and +//! `/oagw/v1/proxy/{alias}/{*path_suffix}`) are served by the same handler, +//! which reads the **raw** request URI: the alias and the suffix are taken +//! still percent-encoded, so the data plane decides what a path segment means +//! instead of inheriting a decoding it cannot undo. The `SecurityContext` the +//! auth middleware injected plus the request carry everything else. + +use std::sync::Arc; + +use axum::Extension; +use axum::extract::Request; +use axum::response::Response; +use toolkit_security::SecurityContext; +use tracing::Instrument; + +use crate::domain::proxy::service::ProxyService; +use crate::error::{OagwError, OagwErrorKind, OagwResult, ResourceKind}; + +/// Prefix of both proxy paths on the wire. +const PROXY_PREFIX: &str = "/oagw/v1/proxy/"; + +/// Proxy a request addressed to the alias root or to a path behind it. +/// +/// # Errors +/// Propagated from the data plane and rendered as problem+json with +/// `X-OAGW-Error-Source: gateway`. +pub async fn proxy_alias( + Extension(ctx): Extension, + Extension(svc): Extension>, + request: Request, +) -> OagwResult { + forward(&ctx, &svc, request).await +} + +/// Answer a method the data plane does not register (DESIGN §3.3). +/// +/// `TRACE`, `CONNECT` and extension methods are not proxied. Without this +/// fallback axum would answer them with a bare `405 Method Not Allowed`, +/// outside the problem contract of the gear. Not proxied is not unaudited: the +/// same §4.3 record the registered methods get is emitted for this class too +/// (PRD §9 asks for a complete audit trail of every proxy request), it is only +/// never dialled, which is why it has no upstream status and no size to name. +/// +/// # Errors +/// Always: the single `route.not_found.v1` problem of the data plane. +pub async fn proxy_unregistered_method( + Extension(ctx): Extension, + request: Request, +) -> OagwResult { + let (parts, body) = request.into_parts(); + let opened = opened(&ctx, &parts); + drop(body); + let error = route_not_found(&opened.alias); + let status = error.status(); + opened + .audit + .emit(opened.started, status, None, Some(&error)); + opened.span.record("status", status); + Err(stamp( + &opened.alias, + &opened.path, + opened.trace_id.as_deref(), + error, + )) +} + +/// Hand the request to the data plane and record the outcome. +/// +/// One span carries the method, the alias, the tenant and the status, and the +/// audit event adds the wall-clock duration. The request and response bodies +/// are never logged, and neither is a header: the span names what the data +/// plane decided, not what the client sent. +async fn forward( + ctx: &SecurityContext, + svc: &ProxyService, + request: Request, +) -> OagwResult { + let (parts, body) = request.into_parts(); + let opened = opened(ctx, &parts); + let (outcome, status) = async { + match svc + .proxy( + ctx, + &opened.alias, + &opened.path, + http::Request::from_parts(parts, body), + ) + .await + { + Ok(response) => { + let status = response.status().as_u16(); + opened.audit.emit( + opened.started, + status, + // A streamed body declares no length, so the field is + // omitted rather than counted from what happened to arrive. + declared_length(response.headers().get(http::header::CONTENT_LENGTH)), + None, + ); + (Ok(response), status) + } + Err(error) => { + let status = error.status(); + // Nothing was forwarded, and the problem document is rendered + // after this call, so there is no response size to name. + opened + .audit + .emit(opened.started, status, None, Some(&error)); + ( + Err(stamp( + &opened.alias, + &opened.path, + opened.trace_id.as_deref(), + error, + )), + status, + ) + } + } + } + .instrument(opened.span.clone()) + .await; + opened.span.record("status", status); + outcome +} + +/// The audit trail's view of one request, taken off the wire once. +/// +/// Both entries of the data plane — the handler of the registered methods and +/// the router's fallback for the unregistered ones — open the same span and +/// build the same record, so a request that is refused before it is dialled is +/// still a request the trail can account for. +struct Opened { + /// Upstream alias the request was addressed to. + alias: String, + /// Request path without its query. + path: String, + /// Span the audit event of this request is emitted in. + span: tracing::Span, + /// Moment the request entered the data plane. + started: tokio::time::Instant, + /// The record the outcome is emitted into. + audit: Audit, + /// Correlation id, also stamped onto a returned problem document. + trace_id: Option, +} + +/// Open the span and the record of one request. +/// +/// The correlation id is the one the request's trace headers name and, when the +/// client sent none, the id of the span it lives in: §4.3 wants a correlation id +/// on every record. +fn opened(ctx: &SecurityContext, parts: &http::request::Parts) -> Opened { + let (alias, suffix) = split_request_path(&parts.uri); + // A span names the alias the request was addressed to and nothing a client + // invented: the raw `Host` header is a header, and headers are not logged. + let span = tracing::info_span!( + "oagw_proxy_request", + method = %parts.method, + alias = %alias, + status = tracing::field::Empty, + tenant_id = %ctx.subject_tenant_id(), + ); + // The correlation id comes from the trace headers when the client sent one + // and from the id of the span it is being served in when it did not: 4.3 + // wants a correlation id on every record. The span's own id is read off the + // handle rather than out of the current context, so the fallback does not + // depend on the caller having entered the span. + let trace_id = crate::api::error::correlation_id(&parts.headers) + .or_else(|| span.id().map(|id| id.into_u64().to_string())); + // The declared length is the size the data plane verifies before it + // buffers, so it is the size the gateway actually served; a request that + // streams (chunked, no length) declares none and omits the field. + let request_size = declared_length(parts.headers.get(http::header::CONTENT_LENGTH)); + let path = format!("/{suffix}"); + let audit = Audit { + host: alias.clone(), + path: path.clone(), + method: parts.method.clone(), + request_id: trace_id.clone(), + tenant_id: ctx.subject_tenant_id(), + principal_id: ctx.subject_id(), + request_size, + }; + Opened { + alias, + path, + span, + started: tokio::time::Instant::now(), + audit, + trace_id, + } +} + +/// The one audit record of a proxied request, at the level the caller chose. +/// +/// A tracing event carries its level in a `static` callsite, so the level has +/// to be known to the macro and cannot come out of a variable: the field set of +/// §4.3 is therefore spelled once here and expanded once per severity, instead +/// of being emitted at one fixed level with a second, competing `level` field. +/// +/// `audit` is the record, `started` the moment the request entered the data +/// plane, `status` the status the client was answered with, `response_size` the +/// length the upstream declared (absent when it streamed), `error_type` and +/// `error_message` the problem type and detail of a failure, both absent on +/// success. +macro_rules! audit_event { + ($level:expr, $audit:expr, $started:expr, $status:expr, $response_size:expr, $error_type:expr, $error_message:expr) => { + tracing::event!( + $level, + event = "oagw_proxy_request", + request_id = $audit.request_id.as_deref(), + tenant_id = %$audit.tenant_id, + principal_id = %$audit.principal_id, + host = %$audit.host, + path = %$audit.path, + method = %$audit.method, + status = $status, + duration_ms = + u64::try_from($started.elapsed().as_millis()).unwrap_or(u64::MAX), + request_size = $audit.request_size, + response_size = $response_size, + error_type = $error_type, + error_message = $error_message, + "proxied request" + ) + }; +} + +/// One audit record of a proxied request (DESIGN §4.3). +/// +/// The record carries no body, no query string and no header value: `path` is +/// the request path without its query and `host` the upstream alias. The +/// response size cannot be known when the record is built — the answer has not +/// happened yet — so [`Audit::emit`] takes it as an argument. +struct Audit { + /// Upstream alias the request was addressed to (§4.3 `host`). + host: String, + /// Request path without its query. + path: String, + method: http::Method, + /// Correlation id the trace headers gave the request. + request_id: Option, + tenant_id: uuid::Uuid, + /// The authenticated subject: the user, service or system the platform + /// identified. §4.3 calls it `principal_id`; `SecurityContext` has no + /// other identity than this one. + principal_id: uuid::Uuid, + /// Declared size of the request body, when the request declared one. + request_size: Option, +} + +impl Audit { + /// Emit the record, at the level its outcome asks for. + /// + /// `response_size` is the declared length of the upstream's answer, when + /// it declared one; only the upstream knows it, so it arrives here rather + /// than in the record. + /// + /// `timestamp` and `level` are the tracing subscriber's contribution: the + /// JSON formatter stamps both on every record, so the event carries a + /// level as its metadata instead of a second, competing one as a field. + fn emit( + &self, + started: tokio::time::Instant, + status: u16, + response_size: Option, + failure: Option<&OagwError>, + ) { + let error_type = failure.map(OagwError::gts_type); + let error_message = failure.map(OagwError::detail); + match severity_of(failure.map(OagwError::kind)) { + Severity::Info => self.answered( + started, + status, + response_size, + error_type.as_deref(), + error_message, + ), + Severity::Warn => self.limited( + started, + status, + response_size, + error_type.as_deref(), + error_message, + ), + Severity::Error => self.failed( + started, + status, + response_size, + error_type.as_deref(), + error_message, + ), + } + } + + /// The record of a request the gateway answered, at §4.3's `INFO`. + fn answered( + &self, + started: tokio::time::Instant, + status: u16, + response_size: Option, + error_type: Option<&str>, + error_message: Option<&str>, + ) { + audit_event!( + tracing::Level::INFO, + self, + started, + status, + response_size, + error_type, + error_message + ); + } + + /// The record of a refusal that limited the caller, at §4.3's `WARN`. + fn limited( + &self, + started: tokio::time::Instant, + status: u16, + response_size: Option, + error_type: Option<&str>, + error_message: Option<&str>, + ) { + audit_event!( + tracing::Level::WARN, + self, + started, + status, + response_size, + error_type, + error_message + ); + } + + /// The record of a failure an operator has to look at, at §4.3's `ERROR`. + fn failed( + &self, + started: tokio::time::Instant, + status: u16, + response_size: Option, + error_type: Option<&str>, + error_message: Option<&str>, + ) { + audit_event!( + tracing::Level::ERROR, + self, + started, + status, + response_size, + error_type, + error_message + ); + } +} + +/// The three levels an audit record can carry (DESIGN §4.3 "Log Levels"). +/// +/// A dedicated enum rather than `tracing::Level` because the event only has +/// three honest outcomes; `Trace` and `Debug` are not among them. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Severity { + /// The request was answered, the gateway having done what was asked. + Info, + /// The refusal limited the caller. + Warn, + /// Something an operator has to look at failed. + Error, +} + +/// Severity of one audit record, from its outcome (DESIGN §4.3 "Log Levels"). +/// +/// A success and an answered request — a 400, a 404, a CORS refusal, a body +/// the client oversized — are `INFO`: the gateway did what was asked of it. A +/// refusal that limits a client is `WARN`, and a failure the *operator* has to +/// look at is `ERROR`: the upstream failed or timed out, the credentials did +/// not resolve, or a dependency of the gateway itself (a bound plugin, the +/// credential store) was not there. +fn severity_of(failure: Option<&OagwErrorKind>) -> Severity { + let Some(kind) = failure else { + return Severity::Info; + }; + match kind { + OagwErrorKind::RateLimitExceeded | OagwErrorKind::CircuitBreakerOpen => Severity::Warn, + OagwErrorKind::AuthenticationFailed + | OagwErrorKind::SecretNotFound + | OagwErrorKind::PluginNotFound + | OagwErrorKind::LinkUnavailable + | OagwErrorKind::ConnectionTimeout + | OagwErrorKind::RequestTimeout + | OagwErrorKind::IdleTimeout + | OagwErrorKind::StreamAborted + | OagwErrorKind::DownstreamError + | OagwErrorKind::ProtocolError + | OagwErrorKind::Internal => Severity::Error, + OagwErrorKind::Validation + | OagwErrorKind::MissingTargetHost + | OagwErrorKind::InvalidTargetHost + | OagwErrorKind::UnknownTargetHost + | OagwErrorKind::NotFound + | OagwErrorKind::PayloadTooLarge + | OagwErrorKind::CorsOriginNotAllowed + | OagwErrorKind::CorsMethodNotAllowed + | OagwErrorKind::AliasConflict + | OagwErrorKind::RouteConflict + | OagwErrorKind::PluginConflict + | OagwErrorKind::PluginInUse => Severity::Info, + } +} + +/// Declared body length of a `Content-Length` header, when it parses. +fn declared_length(header: Option<&http::HeaderValue>) -> Option { + header?.to_str().ok()?.trim().parse().ok() +} + +/// Stamp the request context onto a failure the data plane could not annotate. +/// +/// `instance` is the request path and `trace_id` the correlation id the +/// management router derives from the same headers (ADR-0007). +fn stamp(alias: &str, request_path: &str, trace_id: Option<&str>, error: OagwError) -> OagwError { + error.with_extension(|ext| { + if ext.alias.is_none() { + ext.alias = Some(alias.to_owned()); + } + if ext.path.is_none() { + ext.path = Some(request_path.to_owned()); + } + if ext.trace_id.is_none() { + ext.trace_id = trace_id.map(str::to_owned); + } + if ext.instance.is_none() { + ext.instance = Some(request_path.to_owned()); + } + }) +} + +/// Split the raw request path into the alias and the still-encoded suffix. +/// +/// `/oagw/v1/proxy/api.vendor.com/v1/chat` → `api.vendor.com`, `v1/chat`; the +/// alias root has an empty suffix. +fn split_request_path(uri: &http::Uri) -> (String, String) { + let Some(rest) = uri.path().strip_prefix(PROXY_PREFIX) else { + return (String::new(), String::new()); + }; + match rest.split_once('/') { + Some((alias, suffix)) => (decode_alias(alias), suffix.to_owned()), + None => (decode_alias(rest), String::new()), + } +} + +/// Percent-decode and normalise the alias of a request. +/// +/// The write path stores aliases lowercased and without a trailing dot, and +/// the data plane matches them the same way (DESIGN §3.2 "Alias Resolution"). +/// Anything that decodes to a value an alias can never have simply fails to +/// resolve, which is the 404 the contract asks for. +fn decode_alias(raw: &str) -> String { + let decoded = crate::domain::proxy::routing::percent_decode(raw); + crate::domain::alias::normalize(&decoded) +} + +/// The single 404 of the data plane, for a request the router never matched. +fn route_not_found(alias: &str) -> OagwError { + OagwError::new( + OagwErrorKind::NotFound, + format!("no upstream of the calling tenant answers to the alias '{alias}'"), + ) + .with_resource(ResourceKind::Route) + .with_extension(|ext| { + ext.alias = Some(alias.to_owned()); + }) +} diff --git a/gears/system/oagw/oagw/src/api/handlers/routes.rs b/gears/system/oagw/oagw/src/api/handlers/routes.rs new file mode 100644 index 0000000..ecce40d --- /dev/null +++ b/gears/system/oagw/oagw/src/api/handlers/routes.rs @@ -0,0 +1,99 @@ +// Created: 2026-08-31 by Constructor Tech +//! Route management handlers (DESIGN §3.3). + +use std::sync::Arc; + +use axum::Extension; +use axum::extract::{Path, Query}; +use axum::http::Uri; +use axum::response::IntoResponse; +use toolkit::api::response::created_json; +use toolkit_security::SecurityContext; +use tracing::debug; + +use crate::api::dto::{CreateRouteRequest, RouteDto, UpdateRouteRequest}; +use crate::api::extract::{JsonBody, parse_resource_id}; +use crate::api::handlers::{deleted, serialize_all, to_page_json}; +use crate::api::query::ListQuery; +use crate::domain::service::OagwService; +use crate::error::OagwResult; + +/// List routes with the documented `OData` subset. +/// +/// # Errors +/// 400 on invalid query options, propagated from the store otherwise. +pub async fn list_routes( + Extension(ctx): Extension, + Extension(svc): Extension>, + Query(pairs): Query>, +) -> OagwResult>> { + let query = ListQuery::parse(&pairs)?; + let items = serialize_all(svc.list_routes(ctx.subject_tenant_id())?, RouteDto::from)?; + let page = to_page_json(items, &query); + debug!(tenant = %ctx.subject_tenant_id(), "listed routes"); + Ok(page) +} + +/// Create a route bound to an upstream of the calling tenant. +/// +/// # Errors +/// 400 on an invalid match rule, 404 on a foreign upstream, 409 on a +/// duplicate match rule. +pub async fn create_route( + uri: Uri, + Extension(ctx): Extension, + Extension(svc): Extension>, + JsonBody(request): JsonBody, +) -> OagwResult { + let tenant_id = ctx.subject_tenant_id(); + let record = svc.create_route(tenant_id, &request.into())?; + let id = record.id.to_string(); + debug!(tenant = %tenant_id, upstream = %record.upstream_id, "route created"); + let dto = RouteDto::from(record); + Ok(created_json(dto, &uri, &id)) +} + +/// Read one route. +/// +/// # Errors +/// 400 on an unparseable id, 404 when the record is foreign or missing. +pub async fn get_route( + Extension(ctx): Extension, + Extension(svc): Extension>, + Path(id): Path, +) -> OagwResult> { + let id = parse_resource_id(&id)?; + let record = svc.get_route(ctx.subject_tenant_id(), id)?; + Ok(axum::Json(RouteDto::from(record))) +} + +/// Replace a route in full; `upstream_id` is immutable. +/// +/// # Errors +/// 400 on an invalid match rule, 404 on a foreign record, 409 on a duplicate +/// match rule. +pub async fn update_route( + Extension(ctx): Extension, + Extension(svc): Extension>, + Path(id): Path, + JsonBody(request): JsonBody, +) -> OagwResult> { + let id = parse_resource_id(&id)?; + let record = svc.replace_route(ctx.subject_tenant_id(), id, &request.into())?; + debug!(id = %id, "route replaced"); + Ok(axum::Json(RouteDto::from(record))) +} + +/// Delete a route. +/// +/// # Errors +/// 400 on an unparseable id, 404 when the record is foreign or missing. +pub async fn delete_route( + Extension(ctx): Extension, + Extension(svc): Extension>, + Path(id): Path, +) -> OagwResult { + let id = parse_resource_id(&id)?; + svc.delete_route(ctx.subject_tenant_id(), id)?; + Ok(deleted()) +} diff --git a/gears/system/oagw/oagw/src/api/handlers/upstreams.rs b/gears/system/oagw/oagw/src/api/handlers/upstreams.rs new file mode 100644 index 0000000..da9aeae --- /dev/null +++ b/gears/system/oagw/oagw/src/api/handlers/upstreams.rs @@ -0,0 +1,104 @@ +// Created: 2026-08-31 by Constructor Tech +//! Upstream management handlers (DESIGN §3.3). +//! +//! Every handler is tenant-scoped through the `SecurityContext` extension and +//! returns OAGW problem+json on failure. + +use std::sync::Arc; + +use axum::Extension; +use axum::extract::{Path, Query}; +use axum::http::Uri; +use axum::response::IntoResponse; +use toolkit::api::response::{created_json, no_content}; +use toolkit_security::SecurityContext; +use tracing::debug; + +use crate::api::dto::{CreateUpstreamRequest, UpdateUpstreamRequest, UpstreamDto}; +use crate::api::extract::{JsonBody, parse_resource_id}; +use crate::api::handlers::{serialize_all, to_page_json}; +use crate::api::query::ListQuery; +use crate::domain::service::OagwService; +use crate::error::OagwResult; + +/// List upstreams with the documented `OData` subset. +/// +/// # Errors +/// 400 on invalid query options, propagated from the store otherwise. +pub async fn list_upstreams( + Extension(ctx): Extension, + Extension(svc): Extension>, + Query(pairs): Query>, +) -> OagwResult>> { + let query = ListQuery::parse(&pairs)?; + let items = serialize_all( + svc.list_upstreams(ctx.subject_tenant_id())?, + UpstreamDto::from, + )?; + let page = to_page_json(items, &query); + debug!(tenant = %ctx.subject_tenant_id(), "listed upstreams"); + Ok(page) +} + +/// Create an upstream; the alias is derived when omitted. +/// +/// # Errors +/// 400 on invalid endpoints or a rejected alias, 409 on alias conflict. +pub async fn create_upstream( + uri: Uri, + Extension(ctx): Extension, + Extension(svc): Extension>, + JsonBody(request): JsonBody, +) -> OagwResult { + let tenant_id = ctx.subject_tenant_id(); + let record = svc.create_upstream(tenant_id, &request.into())?; + let id = record.id.to_string(); + debug!(tenant = %tenant_id, alias = %record.alias, "upstream created"); + let dto = UpstreamDto::from(record); + Ok(created_json(dto, &uri, &id)) +} + +/// Read one upstream. +/// +/// # Errors +/// 400 on an unparseable id, 404 when the record is foreign or missing. +pub async fn get_upstream( + Extension(ctx): Extension, + Extension(svc): Extension>, + Path(id): Path, +) -> OagwResult> { + let id = parse_resource_id(&id)?; + let record = svc.get_upstream(ctx.subject_tenant_id(), id)?; + Ok(axum::Json(UpstreamDto::from(record))) +} + +/// Replace an upstream in full. +/// +/// # Errors +/// 400 on invalid endpoints or an alias change, 404 on a foreign record, +/// 409 on alias conflict. +pub async fn update_upstream( + Extension(ctx): Extension, + Extension(svc): Extension>, + Path(id): Path, + JsonBody(request): JsonBody, +) -> OagwResult> { + let id = parse_resource_id(&id)?; + let record = svc.replace_upstream(ctx.subject_tenant_id(), id, &request.into())?; + debug!(id = %id, alias = %record.alias, "upstream replaced"); + Ok(axum::Json(UpstreamDto::from(record))) +} + +/// Delete an upstream together with its routes. +/// +/// # Errors +/// 400 on an unparseable id, 404 when the record is foreign or missing. +pub async fn delete_upstream( + Extension(ctx): Extension, + Extension(svc): Extension>, + Path(id): Path, +) -> OagwResult { + let id = parse_resource_id(&id)?; + svc.delete_upstream(ctx.subject_tenant_id(), id)?; + Ok(no_content()) +} diff --git a/gears/system/oagw/oagw/src/api/mod.rs b/gears/system/oagw/oagw/src/api/mod.rs new file mode 100644 index 0000000..870b61f --- /dev/null +++ b/gears/system/oagw/oagw/src/api/mod.rs @@ -0,0 +1,10 @@ +// Created: 2026-08-31 by Constructor Tech +//! REST layer: DTOs, route registration, handlers, query pipeline and +//! problem-response post-processing. + +pub mod dto; +pub mod error; +pub mod extract; +pub mod handlers; +pub mod query; +pub mod routes; diff --git a/gears/system/oagw/oagw/src/api/query.rs b/gears/system/oagw/oagw/src/api/query.rs new file mode 100644 index 0000000..96c979e --- /dev/null +++ b/gears/system/oagw/oagw/src/api/query.rs @@ -0,0 +1,398 @@ +// Created: 2026-08-31 by Constructor Tech +//! List query pipeline (DESIGN §3.3 "List Query Parameters"). +//! +//! The platform `OData` extractor binds `$top`/`$skiptoken` cursor paging and +//! rejects `$skip`, while OAGW's contract is offset paging (`$top`, `$skip`). +//! The system options are therefore bound here, minimally: `eq` filters, +//! `$orderby`, `$select`, `$top` (default 50, max 100) and `$skip`. +//! +//! Items are handled as JSON values so one pipeline serves upstreams, routes +//! and plugins. + +use serde_json::Value; +use toolkit::Page; +use toolkit::api::odata::parse_orderby; +use toolkit::api::odata::parse_select; +use toolkit::api::select::project_json; +use toolkit_odata::SortDir; + +use crate::error::{OagwError, OagwResult}; + +/// Default page size (DESIGN §3.3). +pub const DEFAULT_TOP: usize = 50; +/// Maximum page size (DESIGN §3.3). +pub const MAX_TOP: usize = 100; + +/// A parsed list request. +#[derive(Debug, Clone, Default)] +pub struct ListQuery { + /// `field eq 'value'`, when a filter was sent. + pub filter: Option<(String, String)>, + /// Projected field names, lowercased. + pub select: Option>, + /// `(field, descending)` in declaration order. + pub orderby: Vec<(String, bool)>, + /// Page size, already clamped to [`MAX_TOP`]. + pub top: usize, + /// Offset. + pub skip: usize, +} + +impl ListQuery { + /// Parse the query pairs of a list request. + /// + /// # Errors + /// 400 on an unparsable `$top`/`$skip`, a page size above [`MAX_TOP`], a + /// filter operator other than `eq`, or an invalid `$orderby`/`$select`. + pub fn parse(pairs: &[(String, String)]) -> OagwResult { + let mut query = Self { + top: DEFAULT_TOP, + ..Self::default() + }; + for (key, value) in pairs { + match key.as_str() { + "$top" => { + let parsed = usize::try_from(parse_number(key, value)?).unwrap_or(usize::MAX); + if parsed == 0 { + return Err(OagwError::validation("$top must be at least 1") + .with_extension(|ext| ext.invalid_value = Some(value.to_owned()))); + } + if parsed > MAX_TOP { + return Err(OagwError::validation(format!( + "$top must not exceed {MAX_TOP}" + ))); + } + query.top = parsed; + } + "$skip" => { + query.skip = usize::try_from(parse_number(key, value)?).unwrap_or(usize::MAX); + } + "$filter" => query.filter = parse_filter(key, value)?, + "$orderby" => query.orderby = parse_order_clause(value, key)?, + "$select" => { + let fields = parse_select(value).map_err(|error| { + OagwError::validation(format!("$select is invalid: {error}")) + })?; + query.select = Some(fields); + } + _ => {} + } + } + Ok(query) + } + + /// Whether a filter narrows the result set. + #[must_use] + pub const fn has_filter(&self) -> bool { + self.filter.is_some() + } +} + +fn parse_number(key: &str, raw: &str) -> OagwResult { + raw.trim().parse::().map_err(|_| { + OagwError::validation(format!("{key} must be a non-negative integer")) + .with_extension(|ext| ext.invalid_value = Some(raw.to_owned())) + }) +} + +/// Parse ` eq ` (the operator OAGW's contract documents). +fn parse_filter(key: &str, raw: &str) -> OagwResult> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let tokens: Vec<&str> = trimmed.split_whitespace().collect(); + let [field, operator, value] = tokens.as_slice() else { + return Err(OagwError::validation(format!( + "{key} must be ' eq '" + ))); + }; + if !operator.eq_ignore_ascii_case("eq") { + return Err(OagwError::validation(format!( + "{key} only supports the 'eq' operator" + ))); + } + let value = unquote(value); + Ok(Some((field.to_ascii_lowercase(), value))) +} + +/// Strip `OData` single quotes (and tolerate double quotes) from a literal. +fn unquote(value: &str) -> String { + let trimmed = value.trim(); + for quote in ['\'', '"'] { + if trimmed.starts_with(quote) && trimmed.ends_with(quote) && trimmed.len() >= 2 { + return trimmed[1..trimmed.len() - 1].to_owned(); + } + } + trimmed.to_owned() +} + +fn parse_order_clause(raw: &str, key: &str) -> OagwResult> { + let parsed = parse_orderby(raw) + .map_err(|error| OagwError::validation(format!("{key} is invalid: {error}")))?; + Ok(parsed + .0 + .into_iter() + .map(|order| (order.field.to_ascii_lowercase(), order.dir == SortDir::Desc)) + .collect()) +} + +/// Filter, order, offset and project `items`. +#[must_use] +pub fn apply_list_query(items: Vec, query: &ListQuery) -> Vec { + let filtered = match &query.filter { + Some((field, expected)) => items + .into_iter() + .filter(|item| matches_filter(item, field, expected)) + .collect(), + None => items, + }; + let ordered = order_items(filtered, &query.orderby); + let page: Vec = ordered + .into_iter() + .skip(query.skip) + .take(query.top) + .collect(); + let Some(fields) = &query.select else { + return page; + }; + page.iter() + .map(|item| project_json(item, &selected_set(fields))) + .collect() +} + +fn selected_set(fields: &[String]) -> std::collections::HashSet { + fields.iter().cloned().collect() +} + +/// `eq` comparison against the JSON rendering of `field`. +fn matches_filter(item: &Value, field: &str, expected: &str) -> bool { + item.get(field) + .is_some_and(|actual| render(actual).eq_ignore_ascii_case(expected)) +} + +fn render(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + Value::Bool(flag) => flag.to_string(), + Value::Number(number) => number.to_string(), + other => other.to_string(), + } +} + +/// Stable multi-key sort; records missing a key keep their relative order. +fn order_items(mut items: Vec, orderby: &[(String, bool)]) -> Vec { + if orderby.is_empty() { + return items; + } + items.sort_by(|left, right| { + for (field, descending) in orderby { + let ordering = compare_fields(left.get(field), right.get(field)); + let ordering = if *descending { + ordering.reverse() + } else { + ordering + }; + if ordering.is_ne() { + return ordering; + } + } + std::cmp::Ordering::Equal + }); + items +} + +fn compare_fields(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Ordering { + match (left, right) { + (Some(left), Some(right)) => compare_values(left, right), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + } +} + +/// Variant-aware comparison: numbers sort numerically, everything else falls +/// back to its JSON rendering. +/// +/// A string-rendered comparison would order `443` before `80`, so numeric +/// members (`port`, `rate`) are compared as numbers. +fn compare_values(left: &Value, right: &Value) -> std::cmp::Ordering { + match (left, right) { + (Value::Number(left), Value::Number(right)) => compare_numbers(left, right), + (Value::Number(_), _) => std::cmp::Ordering::Less, + (_, Value::Number(_)) => std::cmp::Ordering::Greater, + _ => render(left).cmp(&render(right)), + } +} + +/// Numeric comparison: integers compare exactly, mixed or non-integer values +/// fall back to `f64` (unrepresentable values compare equal). +fn compare_numbers(left: &serde_json::Number, right: &serde_json::Number) -> std::cmp::Ordering { + if let (Some(left), Some(right)) = (left.as_u64(), right.as_u64()) { + return left.cmp(&right); + } + if let (Some(left), Some(right)) = (left.as_i64(), right.as_i64()) { + return left.cmp(&right); + } + match (left.as_f64(), right.as_f64()) { + (Some(left), Some(right)) => left.total_cmp(&right), + _ => std::cmp::Ordering::Equal, + } +} + +/// Wrap projected items in the platform page envelope. +#[must_use] +pub fn to_page(items: Vec, top: usize) -> Page { + Page::new( + items, + toolkit::PageInfo { + next_cursor: None, + prev_cursor: None, + limit: u64::try_from(top).unwrap_or(u64::MAX), + }, + ) +} + +#[cfg(test)] +mod tests { + use serde_json::json; + + use super::{DEFAULT_TOP, ListQuery, MAX_TOP, apply_list_query, parse_filter, to_page}; + + fn pairs(entries: &[(&str, &str)]) -> Vec<(String, String)> { + entries + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect() + } + + #[test] + fn defaults_to_fifty_items() { + let query = ListQuery::parse(&[]).ok(); + assert_eq!(query.as_ref().map(|query| query.top), Some(DEFAULT_TOP)); + assert_eq!(query.map(|query| query.skip), Some(0)); + } + + #[test] + fn parses_top_and_skip() { + let query = ListQuery::parse(&pairs(&[("$top", "7"), ("$skip", "3")])); + assert_eq!( + query.ok().map(|query| (query.top, query.skip)), + Some((7, 3)) + ); + } + + #[test] + fn clamps_top_at_the_documented_maximum() { + let query = ListQuery::parse(&pairs(&[("$top", "500")])); + assert!(query.is_err()); + let ok = ListQuery::parse(&pairs(&[("$top", &MAX_TOP.to_string())])); + assert_eq!(ok.ok().map(|query| query.top), Some(MAX_TOP)); + } + + #[test] + fn rejects_non_numeric_paging() { + assert!(ListQuery::parse(&pairs(&[("$top", "-1")])).is_err()); + assert!(ListQuery::parse(&pairs(&[("$skip", "many")])).is_err()); + } + + #[test] + fn parses_an_eq_filter_with_quotes() { + let filter = parse_filter("$filter", "alias eq 'api.openai.com'") + .ok() + .flatten(); + assert_eq!( + filter, + Some(("alias".to_owned(), "api.openai.com".to_owned())) + ); + let bare = parse_filter("$filter", "enabled eq true").ok().flatten(); + assert_eq!(bare, Some(("enabled".to_owned(), "true".to_owned()))); + } + + #[test] + fn rejects_non_eq_operators() { + assert!(parse_filter("$filter", "alias ne 'x'").is_err()); + assert!(parse_filter("$filter", "alias").is_err()); + } + + #[test] + fn filters_orders_and_paginates() { + let items = vec![ + json!({"alias": "b.vendor.com", "enabled": true}), + json!({"alias": "a.vendor.com", "enabled": false}), + json!({"alias": "c.vendor.com", "enabled": true}), + ]; + let query = ListQuery::parse(&pairs(&[ + ("$filter", "enabled eq true"), + ("$orderby", "alias"), + ])) + .ok(); + let projected = query.map(|query| apply_list_query(items, &query)); + assert_eq!( + projected, + Some(vec![ + json!({"alias": "b.vendor.com", "enabled": true}), + json!({"alias": "c.vendor.com", "enabled": true}), + ]) + ); + } + + #[test] + fn orders_descending() { + let items = vec![json!({"alias": "a"}), json!({"alias": "b"})]; + let query = ListQuery::parse(&pairs(&[("$orderby", "alias desc")])).ok(); + let ordered = query.map(|query| apply_list_query(items, &query)); + assert_eq!( + ordered.map(|items| items[0]["alias"].clone()), + Some(json!("b")) + ); + } + + #[test] + fn numeric_members_sort_numerically() { + // A string rendering would order `"443"` before `"80"`. + let items = vec![json!({"port": 443}), json!({"port": 80})]; + let query = ListQuery::parse(&pairs(&[("$orderby", "port")])).ok(); + let ordered = query.map(|query| apply_list_query(items, &query)); + let ports: Vec = ordered + .unwrap_or_default() + .iter() + .filter_map(|item| item["port"].as_i64()) + .collect(); + assert_eq!(ports, vec![80, 443]); + } + + #[test] + fn numbers_sort_before_other_members() { + let items = vec![json!({"rate": "x"}), json!({"rate": 1})]; + let query = ListQuery::parse(&pairs(&[("$orderby", "rate")])).ok(); + let ordered = query.map(|query| apply_list_query(items, &query)); + assert_eq!( + ordered.map(|items| items[0]["rate"].clone()), + Some(json!(1)) + ); + } + + #[test] + fn selects_a_subset_of_fields() { + let items = vec![json!({"id": "1", "alias": "a", "enabled": true})]; + let query = ListQuery::parse(&pairs(&[("$select", "id,alias")])).ok(); + let projected = query.map(|query| apply_list_query(items, &query)); + assert_eq!(projected, Some(vec![json!({"id": "1", "alias": "a"})])); + } + + #[test] + fn skips_beyond_the_collection() { + let items = vec![json!({"alias": "a"})]; + let query = ListQuery::parse(&pairs(&[("$skip", "5")])).ok(); + let projected = query.map(|query| apply_list_query(items, &query)); + assert_eq!(projected, Some(Vec::new())); + } + + #[test] + fn page_envelope_carries_the_effective_limit() { + let page = to_page(Vec::new(), 25); + assert_eq!(page.page_info.limit, 25); + assert!(page.items.is_empty()); + } +} diff --git a/gears/system/oagw/oagw/src/api/routes.rs b/gears/system/oagw/oagw/src/api/routes.rs new file mode 100644 index 0000000..724125f --- /dev/null +++ b/gears/system/oagw/oagw/src/api/routes.rs @@ -0,0 +1,558 @@ +// Created: 2026-08-31 by Constructor Tech +//! Route registration (DESIGN §3.3 endpoint table and §3.5 proxy flow, with +//! the gear-relative `/oagw/v1` prefix of the deployment). +//! +//! The management routes live on a sub-router that carries the control-plane +//! service, the configured request-body limit and the OAGW problem +//! middleware. The proxy data plane is registered on its **own** sub-router: +//! it buffers bodies itself, streams responses and must therefore carry +//! neither `enforce_body_limit` (which rejects a declared body up front) nor +//! `enrich_problem_response` (which buffers a body to decorate it). + +use std::sync::Arc; + +use axum::Router; +use http::Method; +use toolkit::api::OpenApiRegistry; +use toolkit::api::operation_builder::{OperationBuilder, ResponseSpec}; + +use crate::api::dto::{ + CreatePluginRequest, CreateRouteRequest, CreateUpstreamRequest, PluginDto, RouteDto, + UpdateRouteRequest, UpdateUpstreamRequest, UpstreamDto, +}; +use crate::api::handlers; +use crate::domain::proxy::service::ProxyService; +use crate::domain::service::OagwService; + +const API_TAG: &str = "Outbound API Gateway"; + +/// Paths of the proxy data plane (gear-relative, no `/api` prefix). +const PROXY_ALIAS_PATH: &str = "/oagw/v1/proxy/{alias}"; +const PROXY_SUFFIX_PATH: &str = "/oagw/v1/proxy/{alias}/{*path_suffix}"; + +/// Methods a proxy request can use. `TRACE` and `CONNECT` are not proxied. +const PROXY_METHODS: [Method; 7] = [ + Method::GET, + Method::POST, + Method::PUT, + Method::DELETE, + Method::PATCH, + Method::HEAD, + Method::OPTIONS, +]; + +/// Register the management endpoints and the proxy data plane of the gear. +#[allow(clippy::needless_pass_by_value)] +pub fn register_routes( + router: Router, + openapi: &dyn OpenApiRegistry, + service: Arc, +) -> Router { + let body_limit = crate::api::extract::BodyLimit(service.policy().max_body_bytes); + let oagw = register_management_routes(Router::new(), openapi) + .layer(axum::middleware::from_fn( + crate::api::error::enrich_problem_response, + )) + .layer(axum::middleware::from_fn( + crate::api::error::enforce_body_limit, + )) + .layer(axum::Extension(body_limit)) + .layer(axum::Extension(service)); + router.merge(oagw) +} + +/// Register the proxy data plane on its own sub-router. +/// +/// The data plane is wired separately from [`register_routes`] because it +/// needs a different `Extension` (the [`ProxyService`]) and no management +/// middleware. +pub fn register_data_plane( + router: Router, + openapi: &dyn OpenApiRegistry, + proxy: Arc, +) -> Router { + let data_plane = register_proxy_routes(Router::new(), openapi).layer(axum::Extension(proxy)); + router.merge(data_plane) +} + +fn register_management_routes(mut router: Router, openapi: &dyn OpenApiRegistry) -> Router { + router = register_upstream_routes(router, openapi); + router = register_route_routes(router, openapi); + router = register_plugin_routes(router, openapi); + router +} + +fn register_upstream_routes(mut router: Router, openapi: &dyn OpenApiRegistry) -> Router { + router = OperationBuilder::post("/oagw/v1/upstreams") + .operation_id("oagw.create_upstream") + .summary("Create an upstream") + .description( + "Create an outbound upstream. The alias is derived from the endpoint pool unless \ + the pool requires an explicit one (IP-based endpoints).", + ) + .tag(API_TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "Upstream creation payload") + .handler(handlers::create_upstream) + .json_response_with_schema::( + openapi, + http::StatusCode::CREATED, + "Upstream created", + ) + .error_400(openapi) + .error_401(openapi) + .error_409(openapi) + .error_500(openapi) + .error_503(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/upstreams") + .operation_id("oagw.list_upstreams") + .summary("List upstreams") + .description("List upstreams of the calling tenant") + .tag(API_TAG) + .authenticated() + .no_license_required() + .query_param( + "$filter", + false, + "OData filter expression (field eq 'value')", + ) + .query_param("$select", false, "Fields to return") + .query_param("$orderby", false, "Sort order") + .query_param_typed( + "$top", + false, + "Maximum number of results (default 50, max 100)", + "integer", + ) + .query_param_typed("$skip", false, "Number of results to skip", "integer") + .handler(handlers::list_upstreams) + .json_response_with_schema::(openapi, http::StatusCode::OK, "Upstream page") + .error_400(openapi) + .error_401(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/upstreams/{id}") + .operation_id("oagw.get_upstream") + .summary("Get an upstream") + .description("Read one upstream by UUID or GTS identifier") + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Upstream UUID or GTS identifier") + .handler(handlers::get_upstream) + .json_response_with_schema::(openapi, http::StatusCode::OK, "Upstream found") + .error_400(openapi) + .error_401(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::put("/oagw/v1/upstreams/{id}") + .operation_id("oagw.replace_upstream") + .summary("Replace an upstream") + .description("Full replacement; the alias is immutable for hostname pools") + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Upstream UUID or GTS identifier") + .json_request::(openapi, "Upstream replacement payload") + .handler(handlers::update_upstream) + .json_response_with_schema::( + openapi, + http::StatusCode::OK, + "Upstream replaced", + ) + .error_400(openapi) + .error_401(openapi) + .error_404(openapi) + .error_409(openapi) + .error_500(openapi) + .error_503(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/upstreams/{id}") + .operation_id("oagw.delete_upstream") + .summary("Delete an upstream") + .description("Delete an upstream and cascade-delete its routes") + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Upstream UUID or GTS identifier") + .handler(handlers::delete_upstream) + .no_content_response(http::StatusCode::NO_CONTENT, "Upstream deleted") + .error_400(openapi) + .error_401(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router +} + +fn register_route_routes(mut router: Router, openapi: &dyn OpenApiRegistry) -> Router { + router = OperationBuilder::post("/oagw/v1/routes") + .operation_id("oagw.create_route") + .summary("Create a route") + .description("Create a route bound to an upstream of the calling tenant") + .tag(API_TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "Route creation payload") + .handler(handlers::create_route) + .json_response_with_schema::(openapi, http::StatusCode::CREATED, "Route created") + .error_400(openapi) + .error_401(openapi) + .error_404(openapi) + .error_409(openapi) + .error_500(openapi) + .error_503(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/routes") + .operation_id("oagw.list_routes") + .summary("List routes") + .description("List routes of the calling tenant") + .tag(API_TAG) + .authenticated() + .no_license_required() + .query_param( + "$filter", + false, + "OData filter expression (field eq 'value')", + ) + .query_param("$select", false, "Fields to return") + .query_param("$orderby", false, "Sort order") + .query_param_typed( + "$top", + false, + "Maximum number of results (default 50, max 100)", + "integer", + ) + .query_param_typed("$skip", false, "Number of results to skip", "integer") + .handler(handlers::list_routes) + .json_response_with_schema::(openapi, http::StatusCode::OK, "Route page") + .error_400(openapi) + .error_401(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/routes/{id}") + .operation_id("oagw.get_route") + .summary("Get a route") + .description("Read one route by UUID or GTS identifier") + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Route UUID or GTS identifier") + .handler(handlers::get_route) + .json_response_with_schema::(openapi, http::StatusCode::OK, "Route found") + .error_400(openapi) + .error_401(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::put("/oagw/v1/routes/{id}") + .operation_id("oagw.replace_route") + .summary("Replace a route") + .description("Full replacement; `upstream_id` is immutable") + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Route UUID or GTS identifier") + .json_request::(openapi, "Route replacement payload") + .handler(handlers::update_route) + .json_response_with_schema::(openapi, http::StatusCode::OK, "Route replaced") + .error_400(openapi) + .error_401(openapi) + .error_404(openapi) + .error_409(openapi) + .error_500(openapi) + .error_503(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/routes/{id}") + .operation_id("oagw.delete_route") + .summary("Delete a route") + .description("Delete a route by UUID or GTS identifier") + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Route UUID or GTS identifier") + .handler(handlers::delete_route) + .no_content_response(http::StatusCode::NO_CONTENT, "Route deleted") + .error_400(openapi) + .error_401(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router +} + +fn register_plugin_routes(mut router: Router, openapi: &dyn OpenApiRegistry) -> Router { + router = OperationBuilder::post("/oagw/v1/plugins") + .operation_id("oagw.create_plugin") + .summary("Create a custom plugin") + .description("Create a tenant-defined Starlark plugin; plugins are immutable") + .tag(API_TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "Plugin definition") + .handler(handlers::create_plugin) + .json_response_with_schema::( + openapi, + http::StatusCode::CREATED, + "Plugin created", + ) + .error_400(openapi) + .error_401(openapi) + .error_409(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins") + .operation_id("oagw.list_plugins") + .summary("List custom plugins") + .description("List custom plugins of the calling tenant") + .tag(API_TAG) + .authenticated() + .no_license_required() + .query_param( + "$filter", + false, + "OData filter expression (field eq 'value')", + ) + .query_param("$select", false, "Fields to return") + .query_param("$orderby", false, "Sort order") + .query_param_typed( + "$top", + false, + "Maximum number of results (default 50, max 100)", + "integer", + ) + .query_param_typed("$skip", false, "Number of results to skip", "integer") + .handler(handlers::list_plugins) + .json_response_with_schema::(openapi, http::StatusCode::OK, "Plugin page") + .error_400(openapi) + .error_401(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/{id}") + .operation_id("oagw.get_plugin") + .summary("Get a custom plugin") + .description("Read one custom plugin by UUID or GTS identifier") + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Plugin UUID or GTS identifier") + .handler(handlers::get_plugin) + .json_response_with_schema::(openapi, http::StatusCode::OK, "Plugin found") + .error_400(openapi) + .error_401(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/{id}/source") + .operation_id("oagw.get_plugin_source") + .summary("Get the Starlark source of a plugin") + .description("Return the plugin source as text/plain") + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Plugin UUID or GTS identifier") + .handler(handlers::get_plugin_source) + .response(ResponseSpec { + status: http::StatusCode::OK.as_u16(), + content_type: "text/plain", + description: "Starlark plugin source".to_owned(), + schema: None, + }) + .error_400(openapi) + .error_401(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/plugins/{id}") + .operation_id("oagw.delete_plugin") + .summary("Delete a custom plugin") + .description("Delete a custom plugin; 409 while an upstream or route still binds it") + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Plugin UUID or GTS identifier") + .handler(handlers::delete_plugin) + .no_content_response(http::StatusCode::NO_CONTENT, "Plugin deleted") + .error_400(openapi) + .error_401(openapi) + .error_404(openapi) + .error_409(openapi) + .error_500(openapi) + .register(router, openapi); + + router +} + +/// Register both proxy paths for every proxied method. +/// +/// A proxy accepts any method on one path, so the axum router is composed per +/// method (`OperationBuilder::handler` only knows the five schema methods) and +/// the `OpenAPI` document declares one operation per method and path. The +/// fallback for the methods the data plane does not register is attached once +/// per path afterwards: axum refuses to merge two method routers that both +/// carry one. +fn register_proxy_routes(mut router: Router, openapi: &dyn OpenApiRegistry) -> Router { + for method in PROXY_METHODS { + let label = method.as_str().to_ascii_lowercase(); + let alias_router = proxy_method_router(method.clone()); + let suffix_router = proxy_method_router(method.clone()); + + router = OperationBuilder::new(method.clone(), PROXY_ALIAS_PATH) + .operation_id(format!("oagw.proxy_{label}")) + .summary("Proxy a request to an upstream alias") + .description( + "Resolve the alias across the tenant chain, match a route and forward the \ + request to the selected endpoint. The response is the upstream response, \ + marked `X-OAGW-Error-Source: upstream`.", + ) + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param( + "alias", + "Upstream alias, or `alias:port` for a shared suffix", + ) + .method_router(alias_router) + .response(passthrough_response()) + .problem_response( + openapi, + http::StatusCode::BAD_REQUEST, + "A guard rule rejected the request", + ) + .problem_response(openapi, http::StatusCode::UNAUTHORIZED, "Unauthorized") + .problem_response( + openapi, + http::StatusCode::NOT_FOUND, + "Unknown alias or no matching route", + ) + .problem_response( + openapi, + http::StatusCode::PAYLOAD_TOO_LARGE, + "Body exceeds the configured limit", + ) + .problem_response( + openapi, + http::StatusCode::BAD_GATEWAY, + "Upstream protocol failure", + ) + .problem_response( + openapi, + http::StatusCode::SERVICE_UNAVAILABLE, + "Upstream unavailable", + ) + .problem_response( + openapi, + http::StatusCode::GATEWAY_TIMEOUT, + "Upstream timed out", + ) + .register(router, openapi); + + router = OperationBuilder::new(method, PROXY_SUFFIX_PATH) + .operation_id(format!("oagw.proxy_{label}_suffix")) + .summary("Proxy a request with a path suffix") + .description( + "Same as the alias-root proxy operation, with the client path behind the \ + alias appended to the matched route path.", + ) + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param( + "alias", + "Upstream alias, or `alias:port` for a shared suffix", + ) + .path_param("path_suffix", "Client path behind the alias") + .method_router(suffix_router) + .response(passthrough_response()) + .problem_response( + openapi, + http::StatusCode::BAD_REQUEST, + "A guard rule rejected the request", + ) + .problem_response(openapi, http::StatusCode::UNAUTHORIZED, "Unauthorized") + .problem_response( + openapi, + http::StatusCode::NOT_FOUND, + "Unknown alias or no matching route", + ) + .problem_response( + openapi, + http::StatusCode::PAYLOAD_TOO_LARGE, + "Body exceeds the configured limit", + ) + .problem_response( + openapi, + http::StatusCode::BAD_GATEWAY, + "Upstream protocol failure", + ) + .problem_response( + openapi, + http::StatusCode::SERVICE_UNAVAILABLE, + "Upstream unavailable", + ) + .problem_response( + openapi, + http::StatusCode::GATEWAY_TIMEOUT, + "Upstream timed out", + ) + .register(router, openapi); + } + router = proxy_unregistered_fallback(router, PROXY_ALIAS_PATH); + proxy_unregistered_fallback(router, PROXY_SUFFIX_PATH) +} + +/// Attach the fallback of one proxy path. +/// +/// `TRACE`, `CONNECT` and extension methods reach the data plane through it and +/// are answered with the problem contract instead of axum's bare `405`. +fn proxy_unregistered_fallback(router: Router, path: &str) -> Router { + router.route( + path, + axum::routing::MethodRouter::new().fallback(handlers::proxy_unregistered_method), + ) +} + +/// `OpenAPI` response of a proxied request: whatever the upstream returned. +fn passthrough_response() -> ResponseSpec { + ResponseSpec { + status: http::StatusCode::OK.as_u16(), + content_type: "*/*", + description: "Upstream response, marked `X-OAGW-Error-Source: upstream`".to_owned(), + schema: None, + } +} + +/// Method router for one proxied method. +/// +/// Composed here because `OperationBuilder::handler` maps only the five schema +/// methods and the proxy must accept `HEAD` and `OPTIONS` as well. The router +/// carries **no** fallback: the one of the path is attached once, after every +/// method has been registered. +fn proxy_method_router(method: Method) -> axum::routing::MethodRouter<()> { + match method { + m if m == Method::GET => axum::routing::get(handlers::proxy_alias), + m if m == Method::POST => axum::routing::post(handlers::proxy_alias), + m if m == Method::PUT => axum::routing::put(handlers::proxy_alias), + m if m == Method::DELETE => axum::routing::delete(handlers::proxy_alias), + m if m == Method::PATCH => axum::routing::patch(handlers::proxy_alias), + m if m == Method::HEAD => axum::routing::head(handlers::proxy_alias), + _ => axum::routing::options(handlers::proxy_alias), + } +} diff --git a/gears/system/oagw/oagw/src/config.rs b/gears/system/oagw/oagw/src/config.rs new file mode 100644 index 0000000..5a26e21 --- /dev/null +++ b/gears/system/oagw/oagw/src/config.rs @@ -0,0 +1,494 @@ +// Created: 2026-08-31 by Constructor Tech +//! Gear configuration (`gears.oagw.config`). +//! +//! Every field has a serde default and unknown keys are tolerated, so a +//! deployment may configure only the subset it cares about. The graded +//! E2E deployment configures: +//! +//! ```yaml +//! gears: +//! oagw: +//! config: +//! proxy_timeout_secs: 2 +//! allow_http_upstream: true +//! ssrf_policy: +//! enabled: false +//! ``` +//! +//! The data plane adds its own defaults for what the deployment leaves unset: +//! the circuit breaker of PRD `cpt-cf-oagw-nfr-high-availability` trips after 5 +//! upstream health failures inside a 30s window and re-probes after 10s. + +use std::time::Duration; + +use serde::Deserialize; + +/// Body size hard limit (DESIGN §2.2 `constraint-body-limit`): 100 MiB. +pub const MAX_BODY_BYTES_HARD_LIMIT: u64 = 100 * 1024 * 1024; + +const DEFAULT_PROXY_TIMEOUT_SECS: u64 = 30; + +/// Ceiling of a cached `OAuth2` access token lifetime, in seconds (ADR-0008). +const DEFAULT_TOKEN_CACHE_TTL_SECS: u64 = 300; + +/// Entries a cached `OAuth2` access token may occupy (ADR-0008). +const DEFAULT_TOKEN_CACHE_CAPACITY: usize = 10_000; + +/// How many head budgets a buffered body may spend in total. +const BODY_STREAM_BUDGET_FACTOR: u64 = 10; + +/// Live WebSocket sessions the data plane bridges at once (PRD session flows). +const DEFAULT_MAX_WEBSOCKET_SESSIONS: usize = 1024; + +/// Failures inside the window that trip the breaker (PRD threshold). +const DEFAULT_FAILURE_THRESHOLD: u32 = 5; + +/// Length of the sliding failure window, in seconds (PRD window). +const DEFAULT_FAILURE_WINDOW_SECS: u64 = 30; + +/// How long an open breaker stays open before it admits one probe, in seconds. +/// +/// Shorter than the default response-head budget (30s), so a client that +/// honours the `Retry-After` of a refusal is answered by a probe rather than by +/// another refusal; long enough that a breaker which re-opened is not hammered +/// again the moment it half-opens. +const DEFAULT_COOLDOWN_SECS: u64 = 10; + +/// Outbound API Gateway configuration. +#[derive(Debug, Clone, Deserialize)] +pub struct OagwConfig { + /// Upstream request timeout in seconds (data plane). + /// + /// Budget of the **response head**: the time the dial plus the wait for the + /// first response byte may take. The body phases have their own budgets + /// ([`OagwConfig::proxy_idle_timeout_secs`] and + /// [`OagwConfig::proxy_stream_timeout_secs`]) so a slow download cannot + /// shorten them. + #[serde(default = "default_proxy_timeout_secs")] + pub proxy_timeout_secs: u64, + /// Silence tolerated between two frames of a forwarded body, in seconds. + /// + /// Unset means "the head budget". Applies to every response, an event + /// stream included: a stream may pause, but not forever. + #[serde(default)] + pub proxy_idle_timeout_secs: Option, + /// Overall budget of a non-event-stream body, in seconds. + /// + /// Unset means "a multiple of the head budget". An event stream has **no** + /// overall budget (DESIGN §3.2 "Streaming"): it is bounded by silence only. + #[serde(default)] + pub proxy_stream_timeout_secs: Option, + /// Whether plaintext (`http` / `ws`) upstream endpoints may be dialled. + /// + /// The endpoint `scheme` enum always accepts `http`; this switch only + /// decides whether a plaintext connection is actually allowed. + #[serde(default)] + pub allow_http_upstream: bool, + /// Server-side request forgery guards. + #[serde(default)] + pub ssrf_policy: SsrfPolicy, + /// Hard request-body limit in bytes (DESIGN §2.2: 100 MiB). + #[serde(default = "default_max_body_bytes")] + pub max_body_bytes: u64, + /// How many WebSocket sessions may be live at once (PRD session flows). + /// + /// A session holds two sockets — the client's and the upstream's — for as + /// long as the client keeps them, so without a ceiling a handful of clients + /// could pin the whole data plane. The bound counts *bridged* sessions: the + /// handshake itself is an ordinary request and spends the head budget, and + /// the hand-over of the two sockets is bounded by that same budget, so a + /// session that never opens cannot squat a slot. + #[serde(default = "default_max_websocket_sessions")] + pub max_websocket_sessions: usize, + /// Ceiling of a cached `OAuth2` access token lifetime, in seconds + /// (ADR-0008 "Gear-Level Configuration"). + /// + /// Kept short because the cache has no invalidation mechanism yet: a + /// rotated or revoked token stays served until it expires. + #[serde(default = "default_token_cache_ttl_secs")] + pub token_cache_ttl_secs: u64, + /// Maximum entries of the `OAuth2` access-token cache (ADR-0008). + #[serde(default = "default_token_cache_capacity")] + pub token_cache_capacity: usize, + /// Circuit breaker of the data plane (PRD `cpt-cf-oagw-nfr-high-availability`). + #[serde(default)] + pub circuit_breaker: CircuitBreakerConfig, +} + +impl Default for OagwConfig { + fn default() -> Self { + Self { + proxy_timeout_secs: DEFAULT_PROXY_TIMEOUT_SECS, + proxy_idle_timeout_secs: None, + proxy_stream_timeout_secs: None, + allow_http_upstream: false, + ssrf_policy: SsrfPolicy::default(), + max_body_bytes: MAX_BODY_BYTES_HARD_LIMIT, + max_websocket_sessions: DEFAULT_MAX_WEBSOCKET_SESSIONS, + token_cache_ttl_secs: DEFAULT_TOKEN_CACHE_TTL_SECS, + token_cache_capacity: DEFAULT_TOKEN_CACHE_CAPACITY, + circuit_breaker: CircuitBreakerConfig::default(), + } + } +} + +/// Bundle of the `OAuth2` access-token cache settings (ADR-0008). +/// +/// Threaded through [`crate::infra::plugin::AuthPluginRegistry::with_builtins`] +/// into the two `OAuth2` client-credentials plugins. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TokenCacheConfig { + /// Ceiling of a cached token lifetime. + pub ttl: Duration, + /// Maximum number of cache entries. + pub capacity: usize, +} + +impl OagwConfig { + /// Validation inputs derived from the configuration. + /// + /// Kept in one place so the write path cannot drift from the config keys. + #[must_use] + pub fn validation_policy(&self) -> crate::domain::validation::ValidationPolicy { + crate::domain::validation::ValidationPolicy { + allow_http_upstream: self.allow_http_upstream, + max_body_bytes: self.max_body_bytes, + ssrf: self.ssrf_policy.clone(), + } + } + + /// Cache settings of the `OAuth2` client-credentials plugins (ADR-0008). + #[must_use] + pub fn token_cache_config(&self) -> TokenCacheConfig { + TokenCacheConfig { + ttl: Duration::from_secs(self.token_cache_ttl_secs), + capacity: self.token_cache_capacity, + } + } + + /// Budget of the dial plus the wait for the response head. + #[must_use] + pub fn head_timeout(&self) -> Duration { + Duration::from_secs(self.proxy_timeout_secs) + } + + /// Silence tolerated between two frames of a forwarded body. + #[must_use] + pub fn body_idle_timeout(&self) -> Duration { + Duration::from_secs( + self.proxy_idle_timeout_secs + .unwrap_or(self.proxy_timeout_secs), + ) + } + + /// Overall budget of a forwarded body that is not an event stream. + #[must_use] + pub fn body_stream_timeout(&self) -> Duration { + Duration::from_secs( + self.proxy_stream_timeout_secs + .unwrap_or(self.proxy_timeout_secs * BODY_STREAM_BUDGET_FACTOR), + ) + } +} + +/// Server-side request forgery policy (DESIGN §3.2 "Security Considerations"). +#[derive(Debug, Clone, Deserialize)] +pub struct SsrfPolicy { + /// Master switch. Enabled by default; the E2E deployment turns it off. + #[serde(default = "default_true")] + pub enabled: bool, + /// When non-empty, only these hosts may be dialled. + #[serde(default)] + pub allowed_hosts: Vec, + /// Hosts that may never be dialled. + #[serde(default)] + pub denied_hosts: Vec, +} + +impl Default for SsrfPolicy { + fn default() -> Self { + Self { + enabled: true, + allowed_hosts: Vec::new(), + denied_hosts: Vec::new(), + } + } +} + +/// Circuit breaker of the proxy data plane (PRD `cpt-cf-oagw-nfr-high-availability`). +/// +/// "Circuit breakers MUST prevent cascade failures from unhealthy upstreams. +/// Threshold: 99.9% uptime; circuit breaker trips within 5 failed requests in +/// 30s window." The defaults are that threshold and that window. +/// +/// The breaker decides whether a request may be dialled; it never answers for +/// the upstream. DESIGN §4.7 leaves "circuit breaker: config and fallback +/// strategies" to a later slice, so there is no fallback strategy here: an open +/// breaker answers `503 circuit_breaker.open.v1` with a `Retry-After`, and the +/// client retries (`Retriable: Yes`). +/// +/// The block is `deny_unknown_fields`, the convention of the platform's other +/// config structs: a deployment that misspells `failure_threshold` wants a +/// refusal, not a breaker silently running on defaults. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CircuitBreakerConfig { + /// Master switch. Enabled by default: the PRD names the breaker as a `p1` + /// availability requirement, so a deployment has to turn it off rather + /// than remember to turn it on. + #[serde(default = "default_true")] + pub enabled: bool, + /// Upstream health failures inside the window that trip the breaker. + /// + /// The PRD threshold is 5. + #[serde(default = "default_failure_threshold")] + pub failure_threshold: u32, + /// Length of the sliding window the failures are counted in, in seconds. + /// + /// The PRD window is 30s; failures older than it stop counting, so a + /// healthy-again upstream does not stay one failure from a trip. + #[serde(default = "default_failure_window_secs")] + pub failure_window_secs: u64, + /// How long an open breaker stays open before it admits one probe, in + /// seconds. + /// + /// The default is 10s: shorter than the default response-head budget, so a + /// client that honours the `Retry-After` is answered by a probe rather than + /// by another refusal. + #[serde(default = "default_cooldown_secs")] + pub cooldown_secs: u64, +} + +impl Default for CircuitBreakerConfig { + fn default() -> Self { + Self { + enabled: true, + failure_threshold: DEFAULT_FAILURE_THRESHOLD, + failure_window_secs: DEFAULT_FAILURE_WINDOW_SECS, + cooldown_secs: DEFAULT_COOLDOWN_SECS, + } + } +} + +const fn default_failure_threshold() -> u32 { + DEFAULT_FAILURE_THRESHOLD +} + +const fn default_failure_window_secs() -> u64 { + DEFAULT_FAILURE_WINDOW_SECS +} + +const fn default_cooldown_secs() -> u64 { + DEFAULT_COOLDOWN_SECS +} + +const fn default_proxy_timeout_secs() -> u64 { + DEFAULT_PROXY_TIMEOUT_SECS +} + +const fn default_max_body_bytes() -> u64 { + MAX_BODY_BYTES_HARD_LIMIT +} + +const fn default_max_websocket_sessions() -> usize { + DEFAULT_MAX_WEBSOCKET_SESSIONS +} + +const fn default_token_cache_ttl_secs() -> u64 { + DEFAULT_TOKEN_CACHE_TTL_SECS +} + +const fn default_token_cache_capacity() -> usize { + DEFAULT_TOKEN_CACHE_CAPACITY +} + +const fn default_true() -> bool { + true +} + +#[cfg(test)] +mod tests { + use std::error::Error; + + use super::*; + + #[test] + fn defaults_match_the_documented_baseline() { + let cfg = OagwConfig::default(); + assert_eq!(cfg.proxy_timeout_secs, 30); + assert!(!cfg.allow_http_upstream); + assert_eq!(cfg.max_body_bytes, MAX_BODY_BYTES_HARD_LIMIT); + assert_eq!(cfg.max_websocket_sessions, 1024); + assert!(cfg.ssrf_policy.enabled); + assert!(cfg.ssrf_policy.allowed_hosts.is_empty()); + assert_eq!(cfg.token_cache_ttl_secs, 300); + assert_eq!(cfg.token_cache_capacity, 10_000); + assert_eq!(cfg.token_cache_config().ttl, Duration::from_mins(5)); + assert_eq!(cfg.token_cache_config().capacity, 10_000); + assert_eq!(cfg.circuit_breaker, CircuitBreakerConfig::default()); + } + + /// The breaker's defaults are the PRD threshold and window, so a deployment + /// that configures nothing still gets the availability the PRD asks for. + #[test] + fn the_breaker_defaults_are_the_prd_threshold_and_window() { + let cfg = CircuitBreakerConfig::default(); + assert!(cfg.enabled); + assert_eq!(cfg.failure_threshold, 5); + assert_eq!(cfg.failure_window_secs, 30); + assert_eq!(cfg.cooldown_secs, 10); + } + + #[test] + fn the_breaker_is_configurable() -> Result<(), Box> { + let cfg: OagwConfig = serde_json::from_value(serde_json::json!({ + "circuit_breaker": { + "enabled": false, + "failure_threshold": 3, + "failure_window_secs": 10, + "cooldown_secs": 2 + } + }))?; + assert!(!cfg.circuit_breaker.enabled); + assert_eq!(cfg.circuit_breaker.failure_threshold, 3); + assert_eq!(cfg.circuit_breaker.failure_window_secs, 10); + assert_eq!(cfg.circuit_breaker.cooldown_secs, 2); + Ok(()) + } + + /// An absent breaker block is the PRD baseline, and one that names only some + /// members keeps the defaults of the rest. + #[test] + fn a_partial_breaker_block_falls_back_to_the_defaults() -> Result<(), Box> { + let cfg: OagwConfig = serde_json::from_value(serde_json::json!({ + "circuit_breaker": { "failure_threshold": 2 } + }))?; + assert!(cfg.circuit_breaker.enabled); + assert_eq!(cfg.circuit_breaker.failure_threshold, 2); + assert_eq!(cfg.circuit_breaker.failure_window_secs, 30); + assert_eq!(cfg.circuit_breaker.cooldown_secs, 10); + Ok(()) + } + + /// The ceiling on live WebSocket sessions is a deployment knob: a tenant + /// with many long-lived sessions raises it, a strict one lowers it. + #[test] + fn the_session_bound_is_configurable() -> Result<(), Box> { + let cfg: OagwConfig = serde_json::from_value(serde_json::json!({ + "max_websocket_sessions": 8 + }))?; + assert_eq!(cfg.max_websocket_sessions, 8); + Ok(()) + } + + #[test] + fn the_token_cache_keys_are_configurable() -> Result<(), Box> { + let cfg: OagwConfig = serde_json::from_value(serde_json::json!({ + "token_cache_ttl_secs": 5, + "token_cache_capacity": 7 + }))?; + assert_eq!(cfg.token_cache_ttl_secs, 5); + assert_eq!(cfg.token_cache_capacity, 7); + Ok(()) + } + + /// The breaker block is the one place a typo is refused rather than + /// silently ignored: `deny_unknown_fields` is the convention of the other + /// gears' config structs (`toolkit-db`, `toolkit` telemetry and bootstrap), + /// and a breaker that silently runs on defaults instead of the tuned values + /// a deployment wrote is an availability regression nobody sees. The + /// strictness is scoped to this struct, so a key unknown to the gear at the + /// top level stays tolerated (`unknown_keys_are_tolerated`). + #[test] + fn a_misspelled_breaker_key_is_refused() { + let err = serde_json::from_value::(serde_json::json!({ + "circuit_breaker": { "failure_treshold": 5 } + })) + .expect_err("a misspelled breaker key is not a breaker key"); + assert!( + err.to_string().contains("failure_treshold"), + "the error names the offending key: {err}" + ); + + let cfg: OagwConfig = serde_json::from_value(serde_json::json!({ + "circuit_breaker": { "failure_threshold": 5, "cooldown_secs": 7 } + })) + .expect("the documented keys still deserialize"); + assert_eq!(cfg.circuit_breaker.failure_threshold, 5); + assert_eq!(cfg.circuit_breaker.cooldown_secs, 7); + } + + #[test] + fn deserialises_the_e2e_config_subtree() -> Result<(), Box> { + let raw = serde_json::json!({ + "proxy_timeout_secs": 2, + "allow_http_upstream": true, + "ssrf_policy": { "enabled": false } + }); + let cfg: OagwConfig = serde_json::from_value(raw)?; + assert_eq!(cfg.proxy_timeout_secs, 2); + assert!(cfg.allow_http_upstream); + assert!(!cfg.ssrf_policy.enabled); + Ok(()) + } + + #[test] + fn unknown_keys_are_tolerated() -> Result<(), Box> { + let cfg: OagwConfig = serde_json::from_value(serde_json::json!({ "future_key": 1 }))?; + assert_eq!(cfg.proxy_timeout_secs, 30); + assert!(!cfg.allow_http_upstream); + Ok(()) + } + + #[test] + fn partial_subtree_falls_back_to_field_defaults() -> Result<(), Box> { + let cfg: OagwConfig = serde_json::from_value(serde_json::json!({}))?; + assert_eq!(cfg.proxy_timeout_secs, 30); + assert_eq!(cfg.max_body_bytes, MAX_BODY_BYTES_HARD_LIMIT); + assert_eq!(cfg.max_websocket_sessions, 1024); + Ok(()) + } + + /// The three data-plane budgets are distinct phases: the head budget covers + /// the dial and the wait for the first response byte, the idle budget the + /// silence between two body frames and the stream budget the whole body. + #[test] + fn body_budgets_fall_back_to_the_head_budget() { + let cfg: OagwConfig = serde_json::from_value(serde_json::json!({ + "proxy_timeout_secs": 2 + })) + .unwrap_or_else(|error| panic!("the baseline config must parse: {error}")); + assert_eq!(cfg.head_timeout(), std::time::Duration::from_secs(2)); + assert_eq!(cfg.body_idle_timeout(), std::time::Duration::from_secs(2)); + assert_eq!( + cfg.body_stream_timeout(), + std::time::Duration::from_secs(20) + ); + + let tuned: OagwConfig = serde_json::from_value(serde_json::json!({ + "proxy_timeout_secs": 2, + "proxy_idle_timeout_secs": 15, + "proxy_stream_timeout_secs": 600 + })) + .unwrap_or_else(|error| panic!("the tuned config must parse: {error}")); + assert_eq!( + tuned.body_idle_timeout(), + std::time::Duration::from_secs(15) + ); + assert_eq!( + tuned.body_stream_timeout(), + std::time::Duration::from_mins(10) + ); + } + + #[test] + fn ssrf_host_lists_are_kept() -> Result<(), Box> { + let raw = serde_json::json!({ + "ssrf_policy": { "enabled": false, "denied_hosts": ["metadata.internal"] } + }); + let cfg: OagwConfig = serde_json::from_value(raw)?; + assert!(!cfg.ssrf_policy.enabled); + assert_eq!(cfg.ssrf_policy.denied_hosts, vec!["metadata.internal"]); + Ok(()) + } +} diff --git a/gears/system/oagw/oagw/src/domain/alias.rs b/gears/system/oagw/oagw/src/domain/alias.rs new file mode 100644 index 0000000..891ce2f --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/alias.rs @@ -0,0 +1,788 @@ +// Created: 2026-08-31 by Constructor Tech +//! Alias derivation and the alias-immutability rules (DESIGN §3.2 +//! "Alias Resolution"). +//! +//! The module is deliberately free of HTTP types: it maps an endpoint pool to +//! a routing key, and decides whether a caller-supplied alias may coexist with +//! that key. [`crate::domain::service`] turns the rejections into 400 +//! validation problems. + +use std::net::IpAddr; + +use crate::domain::model::{Endpoint, Scheme}; + +/// Standard ports omitted from a derived alias (DESIGN §3.2). +const fn is_standard_port(scheme: Scheme, port: u16) -> bool { + scheme.default_port() == port +} + +/// Classification of an endpoint host (DESIGN §3.2 "Hostname Validation"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostKind { + /// IPv4 or IPv6 literal — never contributes to a derived alias. + IpAddress, + /// RFC 1123 hostname. + Hostname, + /// Neither: rejected before the record is stored. + Invalid, +} + +/// Strip the FQDN trailing dot and ASCII-lowercase a host or alias. +#[must_use] +pub fn normalize(raw: &str) -> String { + raw.trim().trim_end_matches('.').trim().to_ascii_lowercase() +} + +/// Normalise a caller-supplied alias: lowercase, trailing dots stripped. +#[must_use] +pub fn normalize_alias(raw: &str) -> String { + normalize(raw) +} + +/// Validate a derived or explicit alias (DESIGN §3.2 + upstream schema). +/// +/// The alias is the routing key of `/v1/proxy/{alias}/...` and becomes +/// `Host` material for the upstream request, so it must be a strict LDH name: +/// lowercase ASCII alphanumeric labels joined by dots, each label not starting +/// or ending with a hyphen, optionally carrying `:port`. Everything else — +/// path separators, whitespace, `%` escapes, control characters, empty labels, +/// consecutive dots — is rejected before a record is stored. +/// +/// # Errors +/// The rejection reason as a human-readable message; the caller (write path) +/// turns it into a 400. +pub fn validate_alias(alias: &str) -> Result<(), String> { + if alias.is_empty() { + return Err("alias must not be empty".to_owned()); + } + if alias.len() > 253 { + return Err("alias exceeds 253 characters".to_owned()); + } + if !alias.is_ascii() { + return Err("alias must be ASCII".to_owned()); + } + if alias.contains(' ') || alias.chars().any(char::is_control) { + return Err("alias must not contain whitespace or control characters".to_owned()); + } + let (host, port) = split_alias_port(alias)?; + if port.is_some_and(|port| port == 0) { + return Err("alias port must be between 1 and 65535".to_owned()); + } + if !is_ldh_host(host) { + return Err(format!( + "alias '{alias}' must be a lowercase LDH hostname (optionally ':port')" + )); + } + Ok(()) +} + +/// Split an `host:port` alias; the port part is optional. +/// +/// Only the *last* colon splits, so a bare IPv6 literal without brackets is +/// still rejected by the LDH check (bracketed IPv6 aliases are not supported +/// in v1 because the proxy path segment would need escaping). +fn split_alias_port(alias: &str) -> Result<(&str, Option), String> { + match alias.rsplit_once(':') { + Some((host, port)) => { + let parsed = port + .parse::() + .map_err(|_| format!("alias port '{port}' is not a number between 1 and 65535"))?; + Ok((host, Some(parsed))) + } + None => Ok((alias, None)), + } +} + +/// Whether `host` is a strict LDH name: lowercase alphanumeric labels joined +/// by single dots, no empty label, no leading/trailing hyphen or dot. +fn is_ldh_host(host: &str) -> bool { + !host.is_empty() + && !host.contains("..") + && !host.starts_with('.') + && !host.ends_with('.') + && host.split('.').all(|label| { + !label.is_empty() + && !label.starts_with('-') + && !label.ends_with('-') + && label.chars().all(|character| { + character.is_ascii_lowercase() || character.is_ascii_digit() || character == '-' + }) + }) +} + +/// Validate a hostname per RFC 1123 (labels 1-63, alphanumeric plus hyphen, +/// no leading/trailing hyphen, at most 253 characters, trailing dot allowed). +/// +/// # Errors +/// The violated rule as a human-readable message. +pub fn validate_hostname(hostname: &str) -> Result<(), String> { + if hostname.is_empty() { + return Err("host must not be empty".to_owned()); + } + if hostname.len() > 253 { + return Err("host exceeds 253 characters".to_owned()); + } + if !hostname.is_ascii() { + return Err("host must be ASCII".to_owned()); + } + let labels: Vec<&str> = hostname.split('.').collect(); + let labels: Vec<&str> = if labels.last().is_some_and(|last| last.is_empty()) { + labels[..labels.len() - 1].to_vec() + } else { + labels + }; + if labels.is_empty() { + return Err("host must contain at least one label".to_owned()); + } + for label in labels { + if label.is_empty() { + return Err("host contains an empty label".to_owned()); + } + if label.len() > 63 { + return Err("host label exceeds 63 characters".to_owned()); + } + let valid = label + .chars() + .all(|character| character.is_ascii_alphanumeric() || character == '-'); + if !valid { + return Err(format!("host label '{label}' contains invalid characters")); + } + if label.starts_with('-') || label.ends_with('-') { + return Err(format!( + "host label '{label}' has a leading or trailing hyphen" + )); + } + } + Ok(()) +} + +/// Classify an endpoint host. +#[must_use] +pub fn classify_host(host: &str) -> HostKind { + let candidate = normalize(host); + if candidate.parse::().is_ok() { + return HostKind::IpAddress; + } + if validate_hostname(&candidate).is_ok() { + return HostKind::Hostname; + } + HostKind::Invalid +} + +/// Why an endpoint pool has no derived alias. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotDerivableReason { + /// Every endpoint is an IP literal. + IpEndpoints, + /// Heterogeneous hostnames with no shared registrable suffix. + NoCommonSuffix, + /// The shared suffix is a bare public suffix (`co.uk`), so no registrable + /// suffix exists to route on. + BarePublicSuffix, +} + +impl NotDerivableReason { + /// Operator-facing explanation. + #[must_use] + pub const fn detail(self) -> &'static str { + match self { + NotDerivableReason::IpEndpoints => "IP-based endpoints require an explicit alias", + NotDerivableReason::NoCommonSuffix => { + "endpoints share no registrable common suffix; an explicit alias is required" + } + NotDerivableReason::BarePublicSuffix => { + "alias host has no registrable suffix; an explicit alias is required" + } + } + } +} + +/// Rejection reasons produced while settling an alias. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AliasRejection { + /// Endpoints cannot produce an alias; the caller must supply one. + ExplicitRequired(NotDerivableReason), + /// Caller-supplied alias differs from the derived one. + DerivedMismatch { + /// Alias the endpoint pool implies. + derived: String, + }, + /// An update would move the routing key. + ChangeRejected { + /// Current routing key. + existing: String, + /// Routing key the new endpoints imply, when derivable. + derived: Option, + }, + /// Explicit alias fails syntax validation. + InvalidAlias(String), +} + +impl AliasRejection { + /// Operator-facing explanation. + #[must_use] + pub fn detail(&self) -> String { + match self { + AliasRejection::ExplicitRequired(reason) => reason.detail().to_owned(), + AliasRejection::DerivedMismatch { derived } => { + format!("alias must match the value derived from the endpoints ('{derived}')") + } + AliasRejection::ChangeRejected { existing, derived } => match derived { + Some(derived) => format!( + "endpoint change would alter the derived alias from '{existing}' to \ + '{derived}'; delete and re-create the upstream instead" + ), + None => format!( + "endpoint change would invalidate the derived alias '{existing}'; \ + delete and re-create the upstream instead" + ), + }, + AliasRejection::InvalidAlias(detail) => detail.clone(), + } + } +} + +/// Routing key of a single endpoint: `host`, or `host:port` on a +/// non-standard port. +#[must_use] +pub fn endpoint_alias_key(endpoint: &Endpoint) -> String { + let host = normalize(&endpoint.host); + if is_standard_port(endpoint.scheme, endpoint.port) { + host + } else { + format!("{host}:{}", endpoint.port) + } +} + +/// Longest common suffix of a label list, in labels. +fn common_label_suffix(hosts: &[&str]) -> Option { + let mut lists = hosts + .iter() + .map(|host| host.split('.').collect::>()); + let first = lists.next()?; + let mut shared = first.len(); + for labels in lists { + let mut matched = 0; + while matched < shared + && matched < labels.len() + && labels[labels.len() - 1 - matched] == first[first.len() - 1 - matched] + { + matched += 1; + } + shared = matched; + } + if shared < 2 { + return None; + } + Some(first[first.len() - shared..].join(".")) +} + +/// Whether `candidate` carries a registrable suffix (PSL-validated). +/// +/// A derived suffix may sit *below* the registrable domain: `us.vendor.com` +/// is a perfectly usable routing key even though `vendor.com` is the +/// registrable one. Only names with no registrable suffix at all — bare +/// public suffixes such as `co.uk`, unknown TLDs — are rejected. +fn is_registrable_suffix(candidate: &str) -> bool { + psl::domain_str(candidate).is_some() +} + +/// Derive the alias of an endpoint pool (DESIGN §3.2 table). +/// +/// * one distinct host → `host` (or `host:port`), hostname or IP literal alike +/// * several hostnames with a registrable common suffix → that suffix +/// (carrying `:port` when the pool runs on a non-standard port) +/// * IP *pools*, bare public suffixes and unrelated hostnames → +/// [`AliasRejection`] (an explicit alias is required) +/// +/// # Errors +/// [`NotDerivableReason`] when no alias can be derived from the pool alone. +pub fn derive_alias(endpoints: &[Endpoint]) -> Result { + if endpoints.is_empty() { + return Err(NotDerivableReason::NoCommonSuffix); + } + let normalized: Vec = endpoints.iter().map(|e| normalize(&e.host)).collect(); + let ip_count = normalized + .iter() + .filter(|host| host.parse::().is_ok()) + .count(); + // A single distinct host is always derivable, IP literal included: the + // management API accepts `{"scheme":"http","host":"127.0.0.1","port":80}` + // without an explicit alias. + let mut distinct = normalized.clone(); + distinct.sort(); + distinct.dedup(); + if distinct.len() == 1 { + return Ok(endpoint_alias_key(&endpoints[0])); + } + if ip_count == normalized.len() { + return Err(NotDerivableReason::IpEndpoints); + } + if ip_count > 0 { + return Err(NotDerivableReason::NoCommonSuffix); + } + let Some(suffix) = + common_label_suffix(&normalized.iter().map(String::as_str).collect::>()) + else { + return Err(NotDerivableReason::NoCommonSuffix); + }; + if !is_registrable_suffix(&suffix) { + return Err(NotDerivableReason::BarePublicSuffix); + } + let first = &endpoints[0]; + if endpoints + .iter() + .all(|endpoint| endpoint.port == first.port && endpoint.scheme == first.scheme) + && !is_standard_port(first.scheme, first.port) + { + return Ok(format!("{suffix}:{}", first.port)); + } + Ok(suffix) +} + +/// Settle the alias of a **new** upstream (DESIGN §3.2). +/// +/// Hostname pools always win over a caller-supplied value; the exact derived +/// value is tolerated for idempotency. IP-based and otherwise non-derivable +/// pools require an explicit alias. +/// +/// # Errors +/// [`AliasRejection::DerivedMismatch`] when the caller-supplied alias differs +/// from the derived one, [`AliasRejection::ExplicitRequired`] when the pool +/// derives nothing and no alias was supplied, [`AliasRejection::InvalidAlias`] +/// when the supplied alias breaks the LDH rules. +pub fn resolve_creation_alias( + endpoints: &[Endpoint], + provided: Option<&str>, +) -> Result { + let provided = provided + .map(normalize_alias) + .filter(|alias| !alias.is_empty()); + match derive_alias(endpoints) { + Ok(derived) => match provided { + Some(candidate) if candidate == derived => Ok(derived), + Some(_) => Err(AliasRejection::DerivedMismatch { derived }), + None => Ok(derived), + }, + Err(reason) => match provided { + Some(candidate) => { + validate_alias(&candidate).map_err(AliasRejection::InvalidAlias)?; + Ok(candidate) + } + None => Err(AliasRejection::ExplicitRequired(reason)), + }, + } +} + +/// Enforce alias immutability on an update (DESIGN §3.2 table). +/// +/// `current_endpoints` is the stored pool, `next_endpoints` the replacement, +/// `provided` an explicitly supplied alias (usually `None`, because the alias +/// is not part of the update DTO). +/// +/// # Errors +/// [`AliasRejection`] when the alias would change (a pool move that renames it, +/// a differing explicit value) or the supplied value is not a valid alias. +pub fn enforce_update_alias( + existing_alias: &str, + current_endpoints: &[Endpoint], + next_endpoints: &[Endpoint], + provided: Option<&str>, +) -> Result<(), AliasRejection> { + let provided = provided + .map(normalize_alias) + .filter(|alias| !alias.is_empty()); + let pool_unchanged = endpoint_keys(current_endpoints) == endpoint_keys(next_endpoints); + + if pool_unchanged { + return match provided { + Some(candidate) if candidate == existing_alias => Ok(()), + Some(_) => Err(AliasRejection::ChangeRejected { + existing: existing_alias.to_owned(), + derived: None, + }), + None => Ok(()), + }; + } + + let next_derived = derive_alias(next_endpoints); + + // Derivable -> non-derivable is rejected unconditionally (DESIGN §3.2 + // "Alias Update Behavior"): the routing key cannot be re-derived and an + // operator-supplied alias does not rescue the transition. + if let Err(reason) = &next_derived + && derive_alias(current_endpoints).is_ok() + { + return Err(AliasRejection::ExplicitRequired(*reason)); + } + + match provided { + Some(candidate) if candidate != existing_alias => { + return Err(AliasRejection::ChangeRejected { + existing: existing_alias.to_owned(), + derived: next_derived.ok(), + }); + } + _ => {} + } + + match next_derived { + Ok(derived) if derived == existing_alias => Ok(()), + Ok(derived) => Err(AliasRejection::ChangeRejected { + existing: existing_alias.to_owned(), + derived: Some(derived), + }), + // Non-derivable -> non-derivable keeps the existing routing key. + Err(_) => Ok(()), + } +} + +/// Sorted alias keys of a pool, so endpoint order does not affect the +/// "no endpoint change" branch. +fn endpoint_keys(endpoints: &[Endpoint]) -> Vec { + let mut keys: Vec = endpoints.iter().map(endpoint_alias_key).collect(); + keys.sort(); + keys +} + +#[cfg(test)] +mod tests { + use crate::domain::alias::{ + AliasRejection, NotDerivableReason, classify_host, derive_alias, endpoint_alias_key, + enforce_update_alias, normalize, resolve_creation_alias, validate_alias, validate_hostname, + }; + use crate::domain::model::{Endpoint, Scheme}; + + fn endpoint(scheme: Scheme, host: &str, port: u16) -> Endpoint { + Endpoint { + scheme, + host: host.to_owned(), + port, + } + } + + #[test] + fn row_one_hostname_with_standard_port_derives_the_hostname() { + let endpoints = [endpoint(Scheme::Https, "api.openai.com", 443)]; + assert_eq!(derive_alias(&endpoints), Ok("api.openai.com".to_owned())); + } + + #[test] + fn row_two_hostname_with_non_standard_port_derives_host_and_port() { + let endpoints = [endpoint(Scheme::Https, "api.openai.com", 8443)]; + assert_eq!( + derive_alias(&endpoints), + Ok("api.openai.com:8443".to_owned()) + ); + } + + #[test] + fn row_three_multiple_hostnames_derive_the_registrable_suffix() { + let endpoints = [ + endpoint(Scheme::Https, "us.vendor.com", 443), + endpoint(Scheme::Https, "eu.vendor.com", 443), + ]; + assert_eq!(derive_alias(&endpoints), Ok("vendor.com".to_owned())); + } + + #[test] + fn row_four_bare_public_suffix_is_not_derivable() { + let endpoints = [ + endpoint(Scheme::Https, "foo.co.uk", 443), + endpoint(Scheme::Https, "bar.co.uk", 443), + ]; + assert_eq!( + derive_alias(&endpoints), + Err(NotDerivableReason::BarePublicSuffix) + ); + } + + #[test] + fn row_five_unrelated_hostnames_are_not_derivable() { + let endpoints = [ + endpoint(Scheme::Https, "us.foo.com", 443), + endpoint(Scheme::Https, "eu.bar.com", 443), + ]; + assert_eq!( + derive_alias(&endpoints), + Err(NotDerivableReason::NoCommonSuffix) + ); + } + + #[test] + fn row_six_ip_addresses_are_not_derivable() { + let endpoints = [ + endpoint(Scheme::Https, "10.0.1.1", 443), + endpoint(Scheme::Https, "10.0.1.2", 443), + ]; + assert_eq!( + derive_alias(&endpoints), + Err(NotDerivableReason::IpEndpoints) + ); + } + + #[test] + fn suffix_deeper_than_the_registrable_domain_is_derivable() { + // `us.vendor.com` is not itself the registrable domain (`vendor.com` + // is), but it carries a registrable suffix and is therefore a usable + // routing key. + let endpoints = [ + endpoint(Scheme::Https, "x.us.vendor.com", 443), + endpoint(Scheme::Https, "y.us.vendor.com", 443), + ]; + assert_eq!(derive_alias(&endpoints), Ok("us.vendor.com".to_owned())); + } + + #[test] + fn a_single_private_suffix_host_is_derivable() { + let endpoints = [endpoint(Scheme::Https, "svc.eu.platform.sh", 443)]; + assert_eq!( + derive_alias(&endpoints), + Ok("svc.eu.platform.sh".to_owned()) + ); + } + + #[test] + fn suffix_derivation_keeps_a_shared_non_standard_port() { + let endpoints = [ + endpoint(Scheme::Https, "us.vendor.com", 8443), + endpoint(Scheme::Https, "eu.vendor.com", 8443), + ]; + assert_eq!(derive_alias(&endpoints), Ok("vendor.com:8443".to_owned())); + } + + #[test] + fn mixed_hostname_and_ip_pool_is_not_derivable() { + let endpoints = [ + endpoint(Scheme::Https, "us.vendor.com", 443), + endpoint(Scheme::Https, "10.0.1.2", 443), + ]; + assert_eq!( + derive_alias(&endpoints), + Err(NotDerivableReason::NoCommonSuffix) + ); + } + + #[test] + fn endpoint_keys_use_the_scheme_default_port() { + assert_eq!( + endpoint_alias_key(&endpoint(Scheme::Http, "EXAMPLE.com.", 80)), + "example.com" + ); + assert_eq!( + endpoint_alias_key(&endpoint(Scheme::Ws, "example.com", 8080)), + "example.com:8080" + ); + } + + #[test] + fn normalization_lowercases_and_strips_trailing_dots() { + assert_eq!(normalize("Api.OpenAI.COM."), "api.openai.com"); + assert_eq!(normalize(" EXAMPLE.com "), "example.com"); + } + + #[test] + fn hostnames_follow_rfc_1123() { + assert!(validate_hostname("api.openai.com").is_ok()); + assert!(validate_hostname("a-b.c").is_ok()); + assert!(validate_hostname("api.openai.com.").is_ok()); + assert!(validate_hostname("").is_err()); + assert!(validate_hostname("-api.openai.com").is_err()); + assert!(validate_hostname("api..com").is_err()); + assert!(validate_hostname("api_openai.com").is_err()); + assert!(validate_hostname("x*.openai.com").is_err()); + let long_label = "a".repeat(64); + assert!(validate_hostname(&long_label).is_err()); + } + + #[test] + fn aliases_reject_url_breaking_characters() { + assert!(validate_alias("my-internal-service").is_ok()); + assert!(validate_alias("vendor.com:8443").is_ok()); + assert!(validate_alias("").is_err()); + assert!(validate_alias(".vendor.com").is_err()); + assert!(validate_alias("vendor.com/evil").is_err()); + assert!(validate_alias("vendor.com:8443:").is_err()); + assert!(validate_alias("a..b").is_err()); + } + + #[test] + fn aliases_are_strict_ldh_names() { + assert!(validate_alias("127.0.0.1").is_ok()); + assert!(validate_alias("a-b.c.d").is_ok()); + assert!(validate_alias("vendor.com:1").is_ok()); + assert!(validate_alias("vendor.com:65535").is_ok()); + // Percent escapes, uppercase, control characters and stray separators + // never reach the wire: the alias becomes `Host` material in slice 2. + assert!(validate_alias("vendor.com%2Fevil").is_err()); + assert!(validate_alias("Vendor.com").is_err()); + assert!(validate_alias("vendor.com\n").is_err()); + assert!(validate_alias("ven\tdor.com").is_err()); + assert!(validate_alias("vendor.com:0").is_err()); + assert!(validate_alias("vendor.com:abc").is_err()); + assert!(validate_alias("vendor.com:8443:99").is_err()); + assert!(validate_alias("-vendor.com").is_err()); + assert!(validate_alias("ven--dor.com").is_ok()); + assert!(validate_alias("vendor:443").is_ok()); + } + + #[test] + fn hosts_are_classified() { + assert_eq!(classify_host("api.openai.com"), super::HostKind::Hostname); + assert_eq!(classify_host("10.0.1.1"), super::HostKind::IpAddress); + assert_eq!(classify_host("::1"), super::HostKind::IpAddress); + assert_eq!(classify_host("not a host"), super::HostKind::Invalid); + } + + #[test] + fn creation_accepts_the_exact_derived_alias() { + let endpoints = [endpoint(Scheme::Https, "api.openai.com", 443)]; + assert_eq!( + resolve_creation_alias(&endpoints, Some("api.openai.com")), + Ok("api.openai.com".to_owned()) + ); + } + + #[test] + fn creation_rejects_a_differing_explicit_alias_on_a_hostname() { + let endpoints = [endpoint(Scheme::Https, "api.openai.com", 443)]; + assert_eq!( + resolve_creation_alias(&endpoints, Some("openai")), + Err(AliasRejection::DerivedMismatch { + derived: "api.openai.com".to_owned() + }) + ); + } + + #[test] + fn a_single_ip_literal_derives_the_host() { + let endpoints = [endpoint(Scheme::Https, "10.0.1.1", 443)]; + assert_eq!( + resolve_creation_alias(&endpoints, None), + Ok("10.0.1.1".to_owned()) + ); + let loopback = [endpoint(Scheme::Http, "127.0.0.1", 80)]; + assert_eq!( + resolve_creation_alias(&loopback, None), + Ok("127.0.0.1".to_owned()) + ); + } + + #[test] + fn creation_requires_an_explicit_alias_for_ip_pools() { + let endpoints = [ + endpoint(Scheme::Https, "10.0.1.1", 443), + endpoint(Scheme::Https, "10.0.1.2", 443), + ]; + assert_eq!( + resolve_creation_alias(&endpoints, None), + Err(AliasRejection::ExplicitRequired( + NotDerivableReason::IpEndpoints + )) + ); + assert_eq!( + resolve_creation_alias(&endpoints, Some("My-Service")), + Ok("my-service".to_owned()) + ); + } + + #[test] + fn update_table_derivable_to_derivable() { + let current = [endpoint(Scheme::Https, "api.openai.com", 443)]; + let same = [endpoint(Scheme::Https, "API.OpenAI.com.", 443)]; + let moved = [endpoint(Scheme::Https, "api.vendor.com", 443)]; + assert_eq!( + enforce_update_alias("api.openai.com", ¤t, &same, None), + Ok(()) + ); + assert!(matches!( + enforce_update_alias("api.openai.com", ¤t, &moved, None), + Err(AliasRejection::ChangeRejected { + derived: Some(_), + .. + }) + )); + } + + #[test] + fn update_table_derivable_to_non_derivable_is_always_rejected() { + let current = [endpoint(Scheme::Https, "api.openai.com", 443)]; + let ips = [ + endpoint(Scheme::Https, "10.0.1.1", 443), + endpoint(Scheme::Https, "10.0.1.2", 443), + ]; + assert!(matches!( + enforce_update_alias("api.openai.com", ¤t, &ips, Some("my-service")), + Err(AliasRejection::ExplicitRequired(_)) + )); + } + + #[test] + fn update_table_non_derivable_to_non_derivable() { + let current = [ + endpoint(Scheme::Https, "10.0.1.1", 443), + endpoint(Scheme::Https, "10.0.1.2", 443), + ]; + let next = [ + endpoint(Scheme::Https, "10.0.2.1", 443), + endpoint(Scheme::Https, "10.0.2.2", 443), + ]; + assert_eq!( + enforce_update_alias("my-service", ¤t, &next, None), + Ok(()) + ); + assert!(matches!( + enforce_update_alias("my-service", ¤t, &next, Some("other-service")), + Err(AliasRejection::ChangeRejected { .. }) + )); + } + + #[test] + fn update_table_non_derivable_to_derivable() { + let current = [ + endpoint(Scheme::Https, "10.0.1.1", 443), + endpoint(Scheme::Https, "10.0.1.2", 443), + ]; + let next = [endpoint(Scheme::Https, "api.openai.com", 443)]; + assert_eq!( + enforce_update_alias("api.openai.com", ¤t, &next, None), + Ok(()) + ); + assert!(matches!( + enforce_update_alias("my-service", ¤t, &next, None), + Err(AliasRejection::ChangeRejected { .. }) + )); + } + + #[test] + fn update_table_no_endpoint_change_tolerates_an_exact_alias() { + let current = [ + endpoint(Scheme::Https, "10.0.1.1", 443), + endpoint(Scheme::Https, "10.0.1.2", 443), + ]; + assert_eq!( + enforce_update_alias("my-service", ¤t, ¤t, Some("my-service")), + Ok(()) + ); + assert!(matches!( + enforce_update_alias("my-service", ¤t, ¤t, Some("renamed")), + Err(AliasRejection::ChangeRejected { .. }) + )); + } + + #[test] + fn reordered_pools_count_as_unchanged() { + let current = [ + endpoint(Scheme::Https, "a.vendor.com", 443), + endpoint(Scheme::Https, "b.vendor.com", 443), + ]; + let reordered = [ + endpoint(Scheme::Https, "b.vendor.com", 443), + endpoint(Scheme::Https, "a.vendor.com", 443), + ]; + assert_eq!( + enforce_update_alias("vendor.com", ¤t, &reordered, None), + Ok(()) + ); + } +} diff --git a/gears/system/oagw/oagw/src/domain/credentials.rs b/gears/system/oagw/oagw/src/domain/credentials.rs new file mode 100644 index 0000000..c50244a --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/credentials.rs @@ -0,0 +1,360 @@ +// Created: 2026-08-31 by Constructor Tech +//! Credential-shape guard (DESIGN §2.2 `nfr-credential-isolation`, ADR-0002, +//! ADR-0008). +//! +//! The control plane never resolves or stores secret material: bindings +//! reference secrets through `cred://` URIs that the data plane hands to +//! `cred_store` at request time. These checks run **on the write path** so a +//! secret pasted into a config member is rejected before it reaches the store +//! and, later, a response body or a log line. +//! +//! Allowed inline values, per ADR-0008: the `OAuth2` `scopes` list only — scopes +//! are identifiers, not credentials. Every other member that names a +//! credential must be a `cred://` reference. + +use serde_json::Value; + +use crate::domain::model::{AuthConfig, PluginKind}; +use crate::domain::plugin::{PluginRef, lookup_built_in}; +use crate::error::{OagwError, OagwResult}; + +/// Members ADR-0008 allows to carry an inline (non-reference) value. +const INLINE_ALLOWED: &[&str] = &["scopes"]; + +/// Member names that carry credential material when written inline. +const CREDENTIAL_MEMBERS: &[&str] = &[ + "api_key", + "apikey", + "authorization", + "bearer", + "client_id", + "client_secret", + "credentials", + "password", + "passwd", + "private_key", + "secret", + "token", +]; + +/// Suffixes that mark a member as a credential or a credential reference. +const CREDENTIAL_SUFFIXES: &[&str] = &["_key", "_password", "_ref", "_secret", "_token"]; + +/// Scheme every credential reference must use. +const CREDENTIAL_SCHEME: &str = "cred://"; + +/// Validate an `auth` binding (upstream schema `auth`). +/// +/// # Errors +/// 400 when the binding names no plugin but is present, when a credential +/// member holds an inline value or a reference does not use the `cred://` +/// scheme, and when a built-in `OAuth2` binding omits one of the members +/// ADR-0008 requires. +pub fn validate_auth_config(auth: &AuthConfig) -> OagwResult<()> { + validate_members(&auth.raw, &mut |path| is_inline_allowed_auth_member(path))?; + if let Some(plugin_type) = auth.plugin_type.as_deref() { + if plugin_type.trim().is_empty() { + return Err(OagwError::validation( + "auth binding 'type' must name a plugin; omit the whole 'auth' member to forward without credentials", + ) + .with_extension(|ext| ext.invalid_value = Some(plugin_type.to_owned()))); + } + validate_oauth2_binding(plugin_type, effective_members(&auth.raw))?; + } + Ok(()) +} + +/// The members an auth plugin actually reads. +/// +/// ADR-0008 spells a binding with the plugin members nested under `config`; +/// the flat spelling (`"auth": { "type": …, "key_ref": … }`) is accepted as +/// well, which makes the nested form the one that decides when both are +/// present — exactly what +/// [`auth_plugin_config`](crate::domain::proxy::plugins::auth_plugin_config) +/// reads on the data plane, so what is validated here is what is enforced +/// there. +fn effective_members(raw: &serde_json::Map) -> &serde_json::Map { + match raw.get("config") { + Some(Value::Object(nested)) => nested, + _ => raw, + } +} + +/// Validate the `config` object of a custom plugin. +/// +/// # Errors +/// 400 when a credential member holds an inline value or a non-`cred://` +/// reference. +pub fn validate_plugin_config(config: &Value) -> OagwResult<()> { + match config { + Value::Object(map) => validate_members(map, &mut |_path| false), + _ => Ok(()), + } +} + +/// Whether `path` names a member ADR-0008 allows inline. +/// +/// `path` is the member chain below the binding root, e.g. `["scopes"]`; +/// nested occurrences are not exempt. +fn is_inline_allowed_auth_member(path: &[String]) -> bool { + matches!(path, [member] + if INLINE_ALLOWED + .iter() + .any(|allowed| member.eq_ignore_ascii_case(allowed))) +} + +/// `OAuth2` client-credentials bindings (ADR-0008 "Plugin Config"). +fn validate_oauth2_binding( + plugin_type: &str, + raw: &serde_json::Map, +) -> OagwResult<()> { + if !is_oauth2_client_cred(plugin_type) { + return Ok(()); + } + // `raw` is already the effective member set (see `effective_members`), so + // both the ADR-0008 nested spelling and the flat one validate. + for member in ["client_id_ref", "client_secret_ref"] { + if !raw.get(member).is_some_and(Value::is_string) { + return Err(OagwError::validation(format!( + "auth binding '{plugin_type}' requires the '{member}' cred:// reference" + ))); + } + } + let has_endpoint = raw.get("token_endpoint").is_some(); + let has_issuer = raw.get("issuer_url").is_some(); + if has_endpoint == has_issuer { + return Err(OagwError::validation(format!( + "auth binding '{plugin_type}' requires exactly one of 'token_endpoint' or \ + 'issuer_url'" + ))); + } + Ok(()) +} + +/// Whether `plugin_type` names one of the two `OAuth2` client-credentials +/// built-ins, in either the short or the GTS spelling. +fn is_oauth2_client_cred(plugin_type: &str) -> bool { + let oauth2 = ["oauth2_client_cred", "oauth2_client_cred_basic"]; + match PluginRef::parse(plugin_type) { + PluginRef::BuiltIn { + kind: PluginKind::Auth, + name, + .. + } => oauth2.iter().any(|candidate| { + lookup_built_in(PluginKind::Auth, candidate).is_some_and(|plugin| plugin.name == name) + }), + _ => false, + } +} + +/// Walk `map`, applying `rule` to every member. +/// +/// `rule` receives the member path below the root and answers whether an +/// inline value is permitted there. +fn validate_members( + map: &serde_json::Map, + rule: &mut dyn FnMut(&[String]) -> bool, +) -> OagwResult<()> { + let mut path = Vec::new(); + for (member, nested) in map { + path.push(member.clone()); + check_member(member, nested, &path, rule)?; + walk(nested, &mut path, rule)?; + path.pop(); + } + Ok(()) +} + +fn walk( + value: &Value, + path: &mut Vec, + rule: &mut dyn FnMut(&[String]) -> bool, +) -> OagwResult<()> { + match value { + Value::Object(map) => { + for (member, nested) in map { + path.push(member.clone()); + check_member(member, nested, path, rule)?; + walk(nested, path, rule)?; + path.pop(); + } + Ok(()) + } + Value::Array(items) => { + for item in items { + walk(item, path, rule)?; + } + Ok(()) + } + _ => Ok(()), + } +} + +fn check_member( + member: &str, + value: &Value, + path: &[String], + rule: &mut dyn FnMut(&[String]) -> bool, +) -> OagwResult<()> { + if !is_credential_member(member) || rule(path) { + return Ok(()); + } + if value + .as_str() + .is_some_and(|reference| reference.starts_with(CREDENTIAL_SCHEME)) + { + return Ok(()); + } + Err(inline_rejected(member)) +} + +/// Whether `member` names credential material or a credential reference. +fn is_credential_member(member: &str) -> bool { + let lowered = member.to_ascii_lowercase(); + CREDENTIAL_MEMBERS.contains(&lowered.as_str()) + || CREDENTIAL_SUFFIXES + .iter() + .any(|suffix| lowered.ends_with(suffix)) +} + +fn inline_rejected(member: &str) -> OagwError { + OagwError::validation(format!( + "'{member}' must reference a secret as 'cred://…'; inline credential material is not \ + accepted" + )) + .with_extension(|ext| ext.invalid_value = Some(member.to_owned())) +} + +#[cfg(test)] +mod tests { + use serde_json::{Value, json}; + + use crate::domain::credentials::{validate_auth_config, validate_plugin_config}; + use crate::domain::model::{AuthConfig, SharingMode}; + use crate::error::OagwErrorKind; + + /// Binding with `type` fixed to the API-key built-in, plus `raw`. + fn auth(raw: &Value) -> AuthConfig { + let mut map = serde_json::Map::new(); + if let Some(object) = raw.as_object() { + for (key, member) in object { + map.insert(key.clone(), member.clone()); + } + } + AuthConfig { + plugin_type: Some("gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1".to_owned()), + sharing: SharingMode::Private, + raw: map, + } + } + + fn oauth2(raw: &Value) -> AuthConfig { + let mut binding = auth(raw); + binding.plugin_type = + Some("gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1".to_owned()); + binding + } + + fn kind_of(error: &crate::error::OagwError) -> crate::error::OagwErrorKind { + *error.kind() + } + + #[test] + fn a_cred_reference_is_accepted() -> crate::error::OagwResult<()> { + let binding = auth(&json!({"secret_ref": "cred://partner-openai-key"})); + validate_auth_config(&binding) + } + + #[test] + fn inline_secrets_are_rejected_with_the_member_name() { + for value in [ + json!({"secret_ref": "sk-live-abcdef"}), + json!({"api_key": "sk-live-abcdef"}), + json!({"password": "hunter2"}), + json!({"nested": {"client_secret": "abc"}}), + json!({"client_id_ref": 42}), + ] { + let binding = auth(&value); + let Err(error) = validate_auth_config(&binding) else { + panic!("expected an inline credential rejection"); + }; + assert_eq!(kind_of(&error), OagwErrorKind::Validation); + } + } + + #[test] + fn oauth2_scopes_may_be_inline() -> crate::error::OagwResult<()> { + let binding = oauth2(&json!({ + "client_id_ref": "cred://ms-graph-client-id", + "client_secret_ref": "cred://ms-graph-client-secret", + "issuer_url": "https://login.microsoftonline.com/", + "scopes": "https://graph.microsoft.com/.default", + })); + validate_auth_config(&binding) + } + + #[test] + fn a_credential_member_inside_a_nested_object_is_still_guarded() { + let binding = auth(&json!({ + "secret_ref": "cred://partner-openai-key", + "deeper": {"scopes": "openid", "api_key": "inline"}, + })); + assert!(validate_auth_config(&binding).is_err()); + } + + #[test] + fn oauth2_bindings_need_both_references_and_one_endpoint() { + let missing_id = oauth2(&json!({ + "client_secret_ref": "cred://secret", + "token_endpoint": "https://login.microsoftonline.com/token", + })); + assert!(validate_auth_config(&missing_id).is_err()); + + let missing_endpoint = oauth2(&json!({ + "client_id_ref": "cred://id", + "client_secret_ref": "cred://secret", + })); + assert!(validate_auth_config(&missing_endpoint).is_err()); + + let both_endpoints = oauth2(&json!({ + "client_id_ref": "cred://id", + "client_secret_ref": "cred://secret", + "token_endpoint": "https://idp/token", + "issuer_url": "https://idp/", + })); + assert!(validate_auth_config(&both_endpoints).is_err()); + } + + #[test] + fn non_oauth2_bindings_skip_the_shape_check() -> crate::error::OagwResult<()> { + let binding = auth(&json!({"secret_ref": "cred://partner-openai-key"})); + validate_auth_config(&binding) + } + + #[test] + fn plugin_configs_are_guarded_too() { + let config = json!({"fields": ["a"], "api_key": "inline"}); + let Err(error) = validate_plugin_config(&config) else { + panic!("expected an inline credential rejection"); + }; + assert_eq!(kind_of(&error), OagwErrorKind::Validation); + + let harmless = json!({"fields": ["a"], "max_body_size": 1024}); + assert!(validate_plugin_config(&harmless).is_ok()); + } + + #[test] + fn non_object_configs_are_ignored() -> crate::error::OagwResult<()> { + validate_plugin_config(&Value::Null)?; + validate_plugin_config(&json!(["a", "b"])) + } + + #[test] + fn an_empty_binding_stays_legal() -> crate::error::OagwResult<()> { + validate_auth_config(&AuthConfig { + plugin_type: Some("gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1".to_owned()), + sharing: SharingMode::Inherit, + raw: serde_json::Map::new(), + }) + } +} diff --git a/gears/system/oagw/oagw/src/domain/lifecycle.rs b/gears/system/oagw/oagw/src/domain/lifecycle.rs new file mode 100644 index 0000000..0f19e14 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/lifecycle.rs @@ -0,0 +1,16 @@ +// Created: 2026-08-31 by Constructor Tech +//! Upstream lifecycle events shared by the control plane and the data plane. +//! +//! The control plane owns the life of an upstream record; the data plane keeps +//! per-upstream ephemeral state (the round-robin cursor of an endpoint pool). +//! Neither depends on the other, so the control plane publishes the removal +//! through this trait and the data plane subscribes to it in the gear wiring +//! ([`crate::gear`]). + +use uuid::Uuid; + +/// Notified when an upstream record leaves the control plane. +pub trait UpstreamRemoval: Send + Sync { + /// The upstream `upstream_id` was deleted, routes and all. + fn upstream_removed(&self, upstream_id: Uuid); +} diff --git a/gears/system/oagw/oagw/src/domain/mod.rs b/gears/system/oagw/oagw/src/domain/mod.rs new file mode 100644 index 0000000..3e28f7b --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/mod.rs @@ -0,0 +1,18 @@ +// Created: 2026-08-31 by Constructor Tech +//! Domain layer: records, alias rules, validation, storage, the control-plane +//! service and the proxy data plane (DESIGN-LIGHT layering, see the crate +//! docs). + +pub mod alias; +pub mod credentials; +pub mod lifecycle; +pub mod model; +pub mod plugin; +pub mod proxy; +pub mod service; +pub mod spec; +pub mod store; +pub mod time; +pub mod validation; + +pub use service::OagwService; diff --git a/gears/system/oagw/oagw/src/domain/model.rs b/gears/system/oagw/oagw/src/domain/model.rs new file mode 100644 index 0000000..c5b9da0 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/model.rs @@ -0,0 +1,664 @@ +// Created: 2026-08-31 by Constructor Tech +//! Domain records and shared configuration value types (DESIGN §3.1). +//! +//! The nested configuration value types are serde-typed here and reused by the +//! REST DTOs (the same pattern `resource-group` applies to its SDK models), so +//! the wire shape and the domain shape cannot drift apart. +//! +//! # Deliberate deviation: unknown members are not rejected +//! +//! None of these payload types use `deny_unknown_fields`. An upstream or route +//! record is versioned with the gear and rolled out to a fleet whose members +//! may run different revisions for a while; a client that sends a member this +//! revision does not know must still be able to manage the rest of the record. +//! Ignoring the unknown member (and echoing nothing for it) keeps that +//! forward-compatible, at the cost of not catching a misspelled member — +//! misspellings of the required members are still caught by the required-field +//! checks. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub use crate::domain::plugin::PluginKind; + +/// Timestamps attached to every stored record. +/// +/// Epoch milliseconds are used because the crate manifest carries no +/// `chrono`/`time` dependency; the values stay comparable for `$orderby`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Timestamps { + /// Creation instant (epoch milliseconds). + pub created_at: u64, + /// Last modification instant (epoch milliseconds). + pub updated_at: u64, +} + +impl Timestamps { + /// Timestamps for a record created now. + #[must_use] + pub fn now() -> Self { + let now = crate::domain::time::now_millis(); + Self { + created_at: now, + updated_at: now, + } + } + + /// Timestamps for a record modified now. + #[must_use] + pub fn touched(created_at: u64) -> Self { + Self { + created_at, + updated_at: crate::domain::time::now_millis(), + } + } +} + +/// Upstream connection scheme. +/// +/// `http` and `ws` are *plaintext* schemes: they are always legal enum values, +/// and [`Scheme::is_plaintext`] drives the `oagw.config.allow_http_upstream` +/// decision (DESIGN §2.2 `constraint-https-only`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum Scheme { + /// Plaintext HTTP. + Http, + /// TLS HTTP. + Https, + /// Plaintext WebSocket. + Ws, + /// TLS WebSocket. + Wss, + /// WebTransport. + Wt, + /// gRPC (HTTP/2). + Grpc, +} + +impl Scheme { + /// Default port for the scheme (DESIGN §3.2 "Standard ports"). + #[must_use] + pub const fn default_port(self) -> u16 { + match self { + Scheme::Http | Scheme::Ws => 80, + Scheme::Https | Scheme::Wss | Scheme::Wt | Scheme::Grpc => 443, + } + } + + /// Whether the scheme dials a plaintext connection. + #[must_use] + pub const fn is_plaintext(self) -> bool { + matches!(self, Scheme::Http | Scheme::Ws) + } + + /// Scheme as it appears in an upstream URL. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Scheme::Http => "http", + Scheme::Https => "https", + Scheme::Ws => "ws", + Scheme::Wss => "wss", + Scheme::Wt => "wt", + Scheme::Grpc => "grpc", + } + } +} + +/// Upstream wire protocol (DESIGN §3.1). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub enum Protocol { + /// HTTP(S). + #[serde(rename = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1")] + Http, + /// gRPC. + #[serde(rename = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1")] + Grpc, +} + +impl Protocol { + /// Canonical GTS identifier for the protocol. + #[must_use] + pub const fn gts_id(self) -> &'static str { + match self { + Protocol::Http => "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + Protocol::Grpc => "gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1", + } + } + + /// Parse a protocol identifier, accepting the canonical GTS id as well as + /// the short `http` / `grpc` spellings. + #[must_use] + pub fn parse(raw: &str) -> Option { + let normalized = raw.trim().to_ascii_lowercase(); + match normalized.as_str() { + "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" | "http" => Some(Protocol::Http), + "gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1" | "grpc" => Some(Protocol::Grpc), + _ => None, + } + } +} + +/// Hierarchical configuration sharing mode (PRD §5.5). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum SharingMode { + /// Not visible to descendants. + #[default] + Private, + /// Visible; descendants may override. + Inherit, + /// Visible; descendants may not override. + Enforce, +} + +/// A single upstream endpoint (DESIGN §3.1 `Endpoint`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Endpoint { + /// Connection scheme. + pub scheme: Scheme, + /// Hostname or IP address. + pub host: String, + /// Resolved port; never `0` once stored. The wire form of an endpoint may + /// omit it ([`crate::domain::spec::EndpointSpec`]), in which case + /// [`crate::domain::spec::ServerSpec::endpoints`] materialises the scheme + /// default before validation and storage. + pub port: u16, +} + +/// Request header transformation rules (upstream schema `headers.request`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct HeaderRules { + /// Headers to set (overwrite). + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub set: std::collections::HashMap, + /// Headers to add (append, duplicates allowed). + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub add: std::collections::HashMap, + /// Header names to remove. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub remove: Vec, + /// Which inbound headers to forward. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub passthrough: Option, + /// Headers forwarded when `passthrough` is `allowlist`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub passthrough_allowlist: Vec, +} + +/// Response header transformation rules (upstream schema `headers.response`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ResponseHeaderRules { + /// Headers to set on the response. + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub set: std::collections::HashMap, + /// Headers to add to the response. + #[serde(default, skip_serializing_if = "std::collections::HashMap::is_empty")] + pub add: std::collections::HashMap, + /// Headers to strip from the upstream response. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub remove: Vec, +} + +/// Header transformation configuration (upstream schema `headers`). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct HeadersConfig { + /// Request-side rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub request: Option, + /// Response-side rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response: Option, +} + +/// Sustained rate (upstream schema `rate_limit.sustained`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct SustainedRate { + /// Tokens replenished per window. + pub rate: u64, + /// Window length. + #[serde(default = "default_window")] + pub window: String, +} + +const fn default_window() -> String { + String::new() +} + +/// Token bucket / sliding window rate limit configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RateLimitConfig { + /// Sharing mode across the tenant hierarchy. + #[serde(default)] + pub sharing: SharingMode, + /// Algorithm. + #[serde(default = "default_algorithm")] + pub algorithm: String, + /// Sustained rate. + pub sustained: SustainedRate, + /// Bucket capacity; defaults to the sustained rate. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub burst: Option, + /// Counter scope. + #[serde(default = "default_scope")] + pub scope: String, + /// Behaviour when the limit is exceeded. + #[serde(default = "default_strategy")] + pub strategy: String, + /// Tokens consumed per request. + #[serde(default = "default_cost")] + pub cost: u64, + /// Whether the `X-RateLimit-*` headers are added to the response. + /// + /// ADR-0003 "Configuration" lists it with the default `true`; the shipped + /// upstream schema omits the member, so the model carries the ADR default. + #[serde(default = "default_response_headers")] + pub response_headers: bool, +} + +const fn default_response_headers() -> bool { + true +} + +/// Burst capacity of the token bucket. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct BurstConfig { + /// Maximum burst size. + pub capacity: u64, +} + +/// One entry of a plugin chain (`upstream.v1` / `route.v1` `plugins.items[]`). +/// +/// The shipped schema spells an entry as a bare reference string +/// (`"items": ["gts.cf.core.oagw.transform_plugin.v1~…"]`). ADR-0009 binds a +/// built-in guard **with** configuration, so an entry may also be an object +/// carrying `plugin_ref` plus the plugin's `config`: +/// +/// ```json +/// { "plugin_ref": "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1", +/// "config": { "required_request_headers": "x-correlation-id" } } +/// ``` +/// +/// Accepting both spellings is a strict superset of the shipped schema, so no +/// previously accepted binding is rejected. The object form keeps every member +/// other than `plugin_ref` verbatim, which makes the round trip stable: a body +/// that was parsed and re-serialised parses to the same bytes again. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(untagged)] +pub enum PluginBinding { + /// A bare reference: built-in GTS id or custom plugin UUID. + Reference(String), + /// A reference plus its plugin configuration (ADR-0009). + Configured { + /// Plugin GTS id or custom plugin UUID. + plugin_ref: String, + /// Every remaining member, `config` included, preserved verbatim. + #[serde(flatten)] + config: serde_json::Map, + }, +} + +impl PluginBinding { + /// The plugin reference the binding names. + #[must_use] + pub fn reference(&self) -> &str { + match self { + PluginBinding::Reference(reference) => reference, + PluginBinding::Configured { plugin_ref, .. } => plugin_ref, + } + } + + /// The `config` object of a configured binding, if it carries one. + #[must_use] + pub fn config(&self) -> Option<&serde_json::Map> { + match self { + PluginBinding::Reference(_) => None, + PluginBinding::Configured { config, .. } => { + config.get("config").and_then(serde_json::Value::as_object) + } + } + } +} + +/// Plugin chain binding (upstream/route schema `plugins`). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct PluginsConfig { + /// Sharing mode across the tenant hierarchy. + #[serde(default)] + pub sharing: SharingMode, + /// Plugin references: built-in GTS ids, custom plugin UUIDs or configured + /// bindings. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec, +} + +/// CORS configuration (upstream/route schema `cors`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct CorsConfig { + /// Sharing mode across the tenant hierarchy. + #[serde(default)] + pub sharing: SharingMode, + /// Whether CORS handling is enabled. + pub enabled: bool, + /// Allowed origins (`["*"]` allows any). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_origins: Vec, + /// Allowed HTTP methods. + #[serde( + default = "default_cors_methods", + skip_serializing_if = "Vec::is_empty" + )] + pub allowed_methods: Vec, + /// Headers exposed to browsers. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub expose_headers: Vec, + /// Whether credentials may be sent. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub allow_credentials: bool, +} + +const fn default_cors_methods() -> Vec { + Vec::new() +} + +/// Auth plugin binding (upstream schema `auth`). +/// +/// `raw` keeps every member the caller sent beyond `type`/`sharing` (including +/// the nested `config` object) so the auth plugin configuration round-trips +/// byte-for-byte and stays opaque to the control plane. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct AuthConfig { + /// Auth plugin GTS identifier. + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub plugin_type: Option, + /// Sharing mode across the tenant hierarchy. + #[serde(default)] + pub sharing: SharingMode, + /// Remaining members, preserved verbatim. + #[serde(flatten)] + pub raw: serde_json::Map, +} + +impl SharingMode { + /// `true` for [`SharingMode::Private`]. + #[must_use] + pub const fn is_private(self) -> bool { + matches!(self, SharingMode::Private) + } +} + +fn default_algorithm() -> String { + "token_bucket".to_owned() +} + +fn default_scope() -> String { + "tenant".to_owned() +} + +fn default_strategy() -> String { + "reject".to_owned() +} + +const fn default_cost() -> u64 { + 1 +} + +/// Upstream record (`gts.cf.core.oagw.upstream.v1~`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Upstream { + /// Server-generated UUID (wire `id`). + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Routing key, unique per tenant. + pub alias: String, + /// Whether the upstream accepts traffic. + pub enabled: bool, + /// Wire protocol. + pub protocol: Protocol, + /// Endpoint pool (all endpoints share scheme and port). + pub endpoints: Vec, + /// Discovery tags. + pub tags: Vec, + /// Auth plugin binding. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + /// Header transformation rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option, + /// Plugin chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limit policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, + /// Creation / modification instants. + pub timestamps: Timestamps, +} + +impl Upstream { + /// Plugin references (auth plugin plus the bound chain). + /// + /// Used by the `plugin.in_use` check (DESIGN §3.6 "Track Plugin Usage"). + #[must_use] + pub fn plugin_references(&self) -> Vec { + let mut refs = Vec::new(); + if let Some(auth) = &self.auth + && let Some(plugin_type) = &auth.plugin_type + { + refs.push(plugin_type.clone()); + } + if let Some(plugins) = &self.plugins { + refs.extend( + plugins + .items + .iter() + .map(|binding| binding.reference().to_owned()), + ); + } + refs + } +} + +/// HTTP request method accepted by a route match rule. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "UPPERCASE")] +pub enum HttpMethod { + /// GET. + Get, + /// POST. + Post, + /// PUT. + Put, + /// DELETE. + Delete, + /// PATCH. + Patch, +} + +impl HttpMethod { + /// Parse a method name case-insensitively. + #[must_use] + pub fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_uppercase().as_str() { + "GET" => Some(HttpMethod::Get), + "POST" => Some(HttpMethod::Post), + "PUT" => Some(HttpMethod::Put), + "DELETE" => Some(HttpMethod::Delete), + "PATCH" => Some(HttpMethod::Patch), + _ => None, + } + } + + /// Canonical uppercase name. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + HttpMethod::Get => "GET", + HttpMethod::Post => "POST", + HttpMethod::Put => "PUT", + HttpMethod::Delete => "DELETE", + HttpMethod::Patch => "PATCH", + } + } +} + +/// How the proxy path suffix is treated (DESIGN §3.2 "Guard Rules"). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum PathSuffixMode { + /// Reject requests carrying a path suffix. + Disabled, + /// Append the suffix to the configured path. + #[default] + Append, +} + +/// HTTP match rule (route schema `match.http`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct HttpMatch { + /// Allowed methods. + pub methods: Vec, + /// Path pattern. + pub path: String, + /// Allowed query parameters. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub query_allowlist: Vec, + /// How the proxy path suffix is treated. + #[serde(default)] + pub path_suffix_mode: PathSuffixMode, +} + +impl PathSuffixMode { + /// `true` for [`PathSuffixMode::Append`]. + #[must_use] + pub const fn is_append(self) -> bool { + matches!(self, PathSuffixMode::Append) + } +} + +/// gRPC match rule (route schema `match.grpc`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct GrpcMatch { + /// Fully qualified service name. + pub service: String, + /// RPC method name. + pub method: String, +} + +/// Route match rule — exactly one of HTTP / gRPC is present. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct RouteMatch { + /// HTTP match rule. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http: Option, + /// gRPC match rule. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grpc: Option, +} + +impl RouteMatch { + /// Match key used for the per-upstream uniqueness rule. + /// + /// The v1 wire schema carries no `priority`, so the `(path, priority, + /// method)` tuple of DESIGN §3.6 degenerates to `(path, method)`. + #[must_use] + pub fn match_keys(&self) -> Vec<(String, String)> { + match (&self.http, &self.grpc) { + (Some(http), _) => http + .methods + .iter() + .map(|method| (http.path.clone(), method.as_str().to_owned())) + .collect(), + (None, Some(grpc)) => { + vec![(grpc.service.clone(), format!("{}/{}", grpc.method, "grpc"))] + } + (None, None) => Vec::new(), + } + } +} + +/// Route record (`gts.cf.core.oagw.route.v1~`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Route { + /// Server-generated UUID (wire `id`). + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Owning upstream (immutable after creation). + pub upstream_id: Uuid, + /// Whether the route participates in matching. + pub enabled: bool, + /// Match rule. + pub match_rule: RouteMatch, + /// Discovery tags. + pub tags: Vec, + /// Plugin chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limit policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, + /// Creation / modification instants. + pub timestamps: Timestamps, +} + +impl Route { + /// Plugin references bound to this route. + #[must_use] + pub fn plugin_references(&self) -> Vec { + self.plugins.as_ref().map_or_else(Vec::new, |plugins| { + plugins + .items + .iter() + .map(|binding| binding.reference().to_owned()) + .collect() + }) + } +} + +/// Plugin record (`gts.cf.core.oagw.{type}_plugin.v1~{uuid}`). +/// +/// Custom plugins are immutable after creation (DESIGN §3.2 "Plugin Lifecycle +/// Management"), so there is no update path. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct Plugin { + /// Server-generated UUID (wire `id`). + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Plugin kind (`auth` | `guard` | `transform`). + pub kind: PluginKind, + /// Unique name within the tenant. + pub name: String, + /// Whether the plugin is enabled. + pub enabled: bool, + /// Arbitrary plugin configuration. + #[serde(default)] + pub config: serde_json::Value, + /// JSON Schema describing [`Plugin::config`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_schema: Option, + /// Free-text description. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub description: Option, + /// Plugin source text (Starlark). + pub source: String, + /// Creation / modification instants. + pub timestamps: Timestamps, +} + +impl Plugin { + /// GTS identifier of this plugin instance. + #[must_use] + pub fn gts_id(&self) -> String { + format!("{}{}", self.kind.gts_type(), self.id) + } +} diff --git a/gears/system/oagw/oagw/src/domain/plugin.rs b/gears/system/oagw/oagw/src/domain/plugin.rs new file mode 100644 index 0000000..7d95cab --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/plugin.rs @@ -0,0 +1,422 @@ +// Created: 2026-08-31 by Constructor Tech +//! Plugin kinds and the built-in plugin catalog (DESIGN §3.2, ADR-0002). +//! +//! This module is the *identity* half of the plugin system: the control plane +//! must be able to tell which `auth.plugin_type` / `plugins.items[]` references +//! are bindable, and the data plane must be able to turn a reference into a GTS +//! id its registries know. The plugin contracts and their built-in +//! implementations live in [`crate::infra::plugin`] (ADR-0002 "Built-in +//! Plugins"). + +use serde::{Deserialize, Serialize}; + +/// GTS stem for auth plugins (`...auth_plugin.v1~`). +pub const AUTH_PLUGIN_STEM: &str = "gts.cf.core.oagw.auth_plugin.v1~"; +/// GTS stem for guard plugins (`...guard_plugin.v1~`). +pub const GUARD_PLUGIN_STEM: &str = "gts.cf.core.oagw.guard_plugin.v1~"; +/// GTS stem for transform plugins (`...transform_plugin.v1~`). +pub const TRANSFORM_PLUGIN_STEM: &str = "gts.cf.core.oagw.transform_plugin.v1~"; + +/// The three plugin families of DESIGN §3.2. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum PluginKind { + /// Credential injection; one per upstream. + Auth, + /// Validation / policy enforcement; may reject. + Guard, + /// Request / response mutation. + Transform, +} + +impl PluginKind { + /// All kinds, in catalog order. + #[must_use] + pub const fn all() -> [PluginKind; 3] { + [PluginKind::Auth, PluginKind::Guard, PluginKind::Transform] + } + + /// Wire spelling of the kind. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + PluginKind::Auth => "auth", + PluginKind::Guard => "guard", + PluginKind::Transform => "transform", + } + } + + /// GTS type stem, e.g. `gts.cf.core.oagw.auth_plugin.v1~`. + #[must_use] + pub const fn gts_stem(self) -> &'static str { + match self { + PluginKind::Auth => AUTH_PLUGIN_STEM, + PluginKind::Guard => GUARD_PLUGIN_STEM, + PluginKind::Transform => TRANSFORM_PLUGIN_STEM, + } + } + + /// GTS type stem of a custom plugin record, trailing `~` included. + /// + /// `gts.cf.core.oagw.auth_plugin.v1~` + the instance UUID is the full + /// [`Plugin::gts_id`](crate::domain::model::Plugin::gts_id), which is what + /// the registries are keyed by. + #[must_use] + pub const fn gts_type(self) -> &'static str { + self.gts_stem() + } + + /// Parse a kind name (`auth` / `guard` / `transform`). + #[must_use] + pub fn parse(raw: &str) -> Option { + match raw.trim().to_ascii_lowercase().as_str() { + "auth" => Some(PluginKind::Auth), + "guard" => Some(PluginKind::Guard), + "transform" => Some(PluginKind::Transform), + _ => None, + } + } + + /// Build a named (built-in) plugin GTS id: `..._plugin.v1~cf.core.oagw.{name}.v1`. + #[must_use] + pub fn built_in_id(self, name: &str) -> String { + format!("{}cf.core.oagw.{name}.v1", self.gts_stem()) + } +} + +/// A built-in plugin as catalogued for the control plane. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct BuiltInPlugin { + /// Short name (`noop`, `apikey`, …). + pub name: &'static str, + /// Owning kind. + pub kind: PluginKind, + /// Whether the plugin has a registry implementation and can be bound. + pub resolvable: bool, + /// One-line description. + pub description: &'static str, +} + +/// The built-in plugin catalog (DESIGN §3.2 tables). +pub const BUILT_IN_PLUGINS: &[BuiltInPlugin] = &[ + BuiltInPlugin { + name: "noop", + kind: PluginKind::Auth, + resolvable: true, + description: "No-op authentication; always succeeds", + }, + BuiltInPlugin { + name: "apikey", + kind: PluginKind::Auth, + resolvable: true, + description: "Static API key credential injection", + }, + BuiltInPlugin { + name: "oauth2_client_cred", + kind: PluginKind::Auth, + resolvable: true, + description: "OAuth2 client-credentials token injection", + }, + BuiltInPlugin { + name: "oauth2_client_cred_basic", + kind: PluginKind::Auth, + resolvable: true, + description: "OAuth2 client credentials with HTTP basic authorisation", + }, + BuiltInPlugin { + name: "basic", + kind: PluginKind::Auth, + resolvable: false, + description: "Reserved types-registry identifier; no AuthPlugin implementation", + }, + BuiltInPlugin { + name: "bearer", + kind: PluginKind::Auth, + resolvable: false, + description: "Reserved types-registry identifier; no AuthPlugin implementation", + }, + BuiltInPlugin { + name: "required_headers", + kind: PluginKind::Guard, + resolvable: true, + description: "Required header enforcement on request and response", + }, + BuiltInPlugin { + name: "timeout", + kind: PluginKind::Guard, + resolvable: false, + description: "Core data-plane request timeout; not a GuardPlugin", + }, + BuiltInPlugin { + name: "cors", + kind: PluginKind::Guard, + resolvable: false, + description: "Core data-plane CORS handling; not a GuardPlugin", + }, + BuiltInPlugin { + name: "request_id", + kind: PluginKind::Transform, + resolvable: true, + description: "X-Request-ID injection and propagation", + }, + BuiltInPlugin { + name: "logging", + kind: PluginKind::Transform, + resolvable: false, + description: "Core data-plane instrumentation; not a TransformPlugin", + }, + BuiltInPlugin { + name: "metrics", + kind: PluginKind::Transform, + resolvable: false, + description: "Core data-plane instrumentation; not a TransformPlugin", + }, +]; + +/// Credential scope of a plugin reference (DESIGN §3.2 "Plugin +/// Identification Model"). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginRef { + /// Named built-in plugin (`..._plugin.v1~cf.core.oagw.{name}.v1`). + BuiltIn { + /// Owning kind. + kind: PluginKind, + /// Short name. + name: String, + /// Full GTS identifier as sent by the caller. + raw: String, + /// Whether a registry implementation exists. + resolvable: bool, + }, + /// Tenant-defined custom plugin (`..._plugin.v1~{uuid}`, or a bare UUID). + Custom { + /// Owning kind; `None` when the reference carried no GTS stem (a bare + /// UUID is accepted as a kind-agnostic custom reference, see + /// `upstream.v1.schema.json` `plugins.items[]`). + kind: Option, + /// Plugin instance UUID. + id: uuid::Uuid, + /// Full GTS identifier (or bare UUID) as sent by the caller. + raw: String, + }, + /// Anything the control plane cannot classify. + /// + /// Kept as a variant (rather than an error) so that slice 2 can still + /// forward opaque references to the registries. + Unrecognised(String), +} + +impl PluginRef { + /// Classify a plugin reference string. + #[must_use] + pub fn parse(raw: &str) -> PluginRef { + for kind in PluginKind::all() { + let Some(rest) = raw.strip_prefix(kind.gts_stem()) else { + continue; + }; + // Named built-ins carry a version suffix: + // `..._plugin.v1~cf.core.oagw.{name}.v1`. + if let Some(tail) = rest.strip_prefix("cf.core.oagw.") { + let (name, version) = match tail.split_once('.') { + Some((name, version)) => (name, version), + None => (tail, ""), + }; + if !name.is_empty() && (version.is_empty() || version.starts_with('v')) { + return PluginRef::BuiltIn { + kind, + name: name.to_owned(), + raw: raw.to_owned(), + resolvable: lookup_built_in(kind, name) + .is_some_and(|plugin| plugin.resolvable), + }; + } + } + if let Ok(id) = rest.parse::() { + return PluginRef::Custom { + kind: Some(kind), + id, + raw: raw.to_owned(), + }; + } + } + // A bare UUID is a legal custom-plugin reference (the upstream schema + // spells `plugins.items[]` entries as `oneOf` GTS id / UUID). The kind + // is unknown, so the stored record decides. + if let Ok(id) = raw.trim().parse::() { + return PluginRef::Custom { + kind: None, + id, + raw: raw.to_owned(), + }; + } + PluginRef::Unrecognised(raw.to_owned()) + } + + /// Identifier as stored in the binding table. + #[must_use] + pub fn raw(&self) -> &str { + match self { + PluginRef::BuiltIn { raw, .. } + | PluginRef::Custom { raw, .. } + | PluginRef::Unrecognised(raw) => raw, + } + } + + /// Owning kind declared by the reference, when its GTS stem carries one. + #[must_use] + pub const fn kind(&self) -> Option { + match self { + PluginRef::BuiltIn { kind, .. } => Some(*kind), + PluginRef::Custom { kind, .. } => *kind, + PluginRef::Unrecognised(_) => None, + } + } + + /// Plugin instance UUID, custom plugins only. + #[must_use] + pub const fn custom_id(&self) -> Option { + match self { + PluginRef::Custom { id, .. } => Some(*id), + PluginRef::BuiltIn { .. } | PluginRef::Unrecognised(_) => None, + } + } + + /// Whether the reference names a built-in plugin that can be bound. + #[must_use] + pub const fn is_bindable_built_in(&self) -> bool { + matches!( + self, + PluginRef::BuiltIn { + resolvable: true, + .. + } + ) + } +} + +/// Look up a built-in plugin by kind and short name. +#[must_use] +pub fn lookup_built_in(kind: PluginKind, name: &str) -> Option<&'static BuiltInPlugin> { + BUILT_IN_PLUGINS + .iter() + .find(|plugin| plugin.kind == kind && plugin.name.eq_ignore_ascii_case(name)) +} + +/// Look up a built-in plugin by its short name, whatever its family. +/// +/// A bare name (`apikey`, `cors`) is not classified by [`PluginRef::parse`], +/// which only reads GTS ids and UUIDs; the catalog is what gives it a family. +#[must_use] +pub fn lookup_built_in_by_name(name: &str) -> Option<&'static BuiltInPlugin> { + let wanted = name.trim(); + BUILT_IN_PLUGINS + .iter() + .find(|plugin| plugin.name.eq_ignore_ascii_case(wanted)) +} + +/// Whether the reference names a built-in the catalog does not let anyone bind +/// (`basic`, `bearer`, `timeout`, `cors`, `logging`, `metrics`). +/// +/// Such a binding cannot exist at all, which is a different failure from an +/// implementation the *deployment* cannot honour. +#[must_use] +pub fn is_unbindable_built_in(reference: &str) -> bool { + match PluginRef::parse(reference) { + PluginRef::BuiltIn { + resolvable: true, .. + } + | PluginRef::Custom { .. } => false, + PluginRef::BuiltIn { + resolvable: false, .. + } => true, + PluginRef::Unrecognised(_) => { + lookup_built_in_by_name(reference).is_some_and(|plugin| !plugin.resolvable) + } + } +} + +#[cfg(test)] +mod tests { + use super::{BUILT_IN_PLUGINS, PluginKind, PluginRef, lookup_built_in}; + + #[test] + fn every_kind_has_a_distinct_gts_stem() { + let stems: Vec<&str> = PluginKind::all().iter().map(|k| k.gts_stem()).collect(); + assert_eq!(stems.len(), 3); + assert!(stems.iter().all(|stem| stem.ends_with("_plugin.v1~"))); + } + + #[test] + fn built_in_catalog_matches_the_design_tables() { + let resolvable: Vec<&str> = BUILT_IN_PLUGINS + .iter() + .filter(|plugin| plugin.resolvable) + .map(|plugin| plugin.name) + .collect(); + assert_eq!( + resolvable, + vec![ + "noop", + "apikey", + "oauth2_client_cred", + "oauth2_client_cred_basic", + "required_headers", + "request_id" + ] + ); + assert_eq!(BUILT_IN_PLUGINS.len(), 12); + } + + #[test] + fn built_in_references_are_classified() { + let reference = PluginRef::parse("gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1"); + assert!(reference.is_bindable_built_in()); + assert!( + !PluginRef::parse("gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1") + .is_bindable_built_in() + ); + } + + #[test] + fn custom_references_carry_the_uuid() { + let id = uuid::Uuid::new_v4(); + let raw = format!("gts.cf.core.oagw.transform_plugin.v1~{id}"); + assert_eq!(PluginRef::parse(&raw).custom_id(), Some(id)); + } + + #[test] + fn unknown_references_are_kept_verbatim() { + assert_eq!(PluginRef::parse("not-a-plugin").raw(), "not-a-plugin"); + } + + #[test] + fn bare_uuid_references_are_kind_agnostic_custom_ids() { + let id = uuid::Uuid::new_v4(); + let parsed = PluginRef::parse(&id.to_string()); + assert_eq!(parsed.custom_id(), Some(id)); + assert_eq!(parsed.kind(), None); + assert_eq!(parsed.raw(), id.to_string()); + } + + #[test] + fn gts_custom_references_carry_their_kind() { + let id = uuid::Uuid::new_v4(); + let parsed = PluginRef::parse(&format!("gts.cf.core.oagw.guard_plugin.v1~{id}")); + assert_eq!(parsed.kind(), Some(PluginKind::Guard)); + assert_eq!(parsed.custom_id(), Some(id)); + } + + #[test] + fn the_catalog_is_queried_by_kind_and_name() { + assert_eq!( + lookup_built_in(PluginKind::Auth, "noop").map(|plugin| plugin.name), + Some("noop") + ); + assert_eq!( + lookup_built_in(PluginKind::Auth, "basic").map(|plugin| plugin.name), + Some("basic") + ); + assert!(lookup_built_in(PluginKind::Auth, "madeup").is_none()); + assert!(lookup_built_in(PluginKind::Guard, "noop").is_none()); + } +} diff --git a/gears/system/oagw/oagw/src/domain/proxy/breaker.rs b/gears/system/oagw/oagw/src/domain/proxy/breaker.rs new file mode 100644 index 0000000..ea76149 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/proxy/breaker.rs @@ -0,0 +1,1034 @@ +// Created: 2026-08-31 by Constructor Tech +//! Circuit breaker of the proxy data plane (PRD `cpt-cf-oagw-nfr-high-availability`). +//! +//! # The contract +//! +//! "Circuit breakers MUST prevent cascade failures from unhealthy upstreams. +//! Threshold: 99.9% uptime; circuit breaker trips within 5 failed requests in +//! 30s window." The defaults of [`crate::config::CircuitBreakerConfig`] are that +//! threshold and that window, and the window is what the PRD says it is — the +//! failures the instance observed inside the last 30s, whatever happened +//! between them — so a deployment that configures nothing gets the behaviour +//! the PRD asks for. +//! +//! # The machine +//! +//! `CLOSED → OPEN → HALF_OPEN → CLOSED`, with `HALF_OPEN → OPEN` when the probe +//! fails: +//! +//! | state | what a request sees | +//! |---|---| +//! | `closed` | the request is dialled; failures accumulate in the window | +//! | `open` | the request is refused 503 `circuit_breaker.open.v1` without a dial, with the remaining cooldown as `Retry-After` | +//! | `half_open` | the one probe is dialled; every other request is refused with the same answer | +//! +//! # The window +//! +//! The window slides and it is the only thing that forgets. A failure is +//! stamped with the instant its report arrived and stops counting once it is +//! older than the window, which is what keeps a recovered upstream from staying +//! one failure away from a trip forever. A success is **not** a failure and it +//! does not empty the window either: an upstream that answers 200 on its cheap +//! requests and 500 on its expensive ones is the partial failure the breaker +//! exists to stop, and wiping the window on every 200 would leave it +//! untrippable at any failure rate. +//! +//! # Key +//! +//! One breaker per **upstream id**, per [`crate::domain::proxy::ProxyService`] +//! instance. The id and not the alias, for three reasons: two tenants may own +//! the same alias and must not trip each other's breaker; renaming an alias must +//! not reset a trip that is still cooling down; and the removal seam +//! (`UpstreamRemoval`) already forgets per-upstream state by id when a record is +//! deleted, so a recreated upstream starts closed. What the *operator* reads is +//! the `host` label of [`crate::infra::metrics`], and that label is the alias of +//! the very upstream the id names — the state and the label are one upstream, +//! which is why the label is carried by the caller and not looked up here. +//! +//! The state is per instance, like the token buckets of ADR-0003 +//! "Distribution": a gear instance owns its own view of upstream health, and no +//! cross-instance sync is built. Two instances behind a load balancer each trip +//! on their own failures, which is the intended behaviour of a breaker: each +//! stops sending traffic it can see failing. +//! +//! # What a failure is +//! +//! [`is_health_failure`] is the one exhaustive decision, over all of +//! [`OagwErrorKind`] so that a new kind cannot silently join either side. What +//! it can be handed depends on the stage that observed the request, and both +//! stages report: +//! +//! | stage | the observer | the failure kinds it can deliver | +//! |---|---|---| +//! | the head | `send` / `send_handshake` | `LinkUnavailable`, `RequestTimeout` (a stalled connection surfaces as `timeout.request.v1`, not as `timeout.connection.v1`), `PayloadTooLarge`, `ProtocolError`, `Internal`, `Validation` | +//! | the body | `forward_body` | `IdleTimeout`, `RequestTimeout` (the overall body budget), `StreamAborted` | +//! +//! The head observer sits *behind* the dial, so the gateway-side kinds it can +//! carry — the method the proxy cannot forward, a header it cannot render — +//! belong to a dial that never happened, and they classify as **not** a health +//! failure for that reason. Everything else that is not the upstream's health is +//! the client's error (a 4xx), a refusal the gateway made before the dial (CORS, +//! rate limit, an oversized request body, the egress policy, framing) or a +//! dependency of the *gateway* that is missing (a credential, a bound plugin). +//! +//! Two kinds are classified as failures and no path produces them today: +//! `ConnectionTimeout`, which the outbound client folds into the head budget, +//! and `DownstreamError`, which the data plane never constructs. They stay on +//! the failure side because that is what they mean, and the match is exhaustive, +//! so a path that starts producing one cannot silently under-count the upstream +//! it broke. +//! +//! # Half-open fairness +//! +//! Exactly one probe is admitted per cooldown: the request that finds the +//! breaker `half_open` with no probe in flight becomes the probe, and the +//! requests that arrive while it is on the wire are refused. The role is +//! **carried, not re-derived**: [`CircuitBreakers::admit`] mints a [`Probe`] +//! token for the request it admits as the probe and [`CircuitBreakers::record`] +//! honours only the token the breaker is still holding, so a request that was +//! dialled while the breaker was closed and reports after the trip — or the late +//! report of a probe that has already been replaced — cannot close or re-open a +//! breaker it did not probe. +//! +//! A probe that outlives its budget is treated as abandoned and replaced, so a +//! cancelled request cannot leave every later request refused forever: the head +//! budget bounds the probe, which is why [`CircuitBreakers::new`] takes it. A +//! request admitted as the probe that never reaches its dial — a framing refusal +//! after the admission, say — reports nothing and waits out the same budget. +//! +//! # The clock +//! +//! Every `Instant` the breaker compares is **passed in**, not read: `admit` and +//! `record` take the `now` of the request they serve. That is what makes the +//! window, the cooldown and the probe budget testable without a real wait. The +//! data plane passes [`std::time::Instant::now`], deliberately the `std` clock +//! and not tokio's, so a mocked tokio clock cannot move a breaker. + +use std::collections::VecDeque; +use std::time::Duration; +use std::time::Instant; + +use dashmap::DashMap; +use http::StatusCode; +use uuid::Uuid; + +use crate::error::OagwErrorKind; +use crate::infra::metrics; + +/// Initial capacity of the breaker map, as a hint to [`DashMap::with_capacity`]. +/// +/// It is a hint and **not** a ceiling: nothing checks the map's length. The map +/// is bounded by what it resolves records from — one breaker per upstream id, +/// and an alias that resolves to nothing never reaches the breaker — and the +/// removal seam drops the entry of a deleted record, so an instance does not +/// accumulate breakers for upstreams it no longer has. A store holding more +/// upstreams than this simply grows the map. +const INITIAL_BREAKER_CAPACITY: usize = 65_536; + +/// A breaker state. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum State { + /// Dialling is allowed; failures are counted. + Closed, + /// Nothing is dialled until the cooldown is over. + Open, + /// The cooldown is over; one probe is allowed. + HalfOpen, +} + +impl State { + /// Label of the state, as the transition metric and the log spell it. + pub(crate) const fn label(self) -> &'static str { + match self { + State::Closed => "closed", + State::Open => "open", + State::HalfOpen => "half_open", + } + } + + /// Value the state gauge reports. + /// + /// `open` is the largest value because it is the state an operator alerts + /// on: a threshold of `>= 2` reads "the upstream is unreachable". + pub(crate) const fn value(self) -> u64 { + match self { + State::Closed => 0, + State::HalfOpen => 1, + State::Open => 2, + } + } +} + +/// Proof that a request is the probe its breaker is waiting for. +/// +/// [`CircuitBreakers::admit`] mints one when it promotes an open breaker to +/// half-open. It names the admission it belongs to by the instant that admission +/// was taken, so a request that was admitted as a probe and reports only after +/// its breaker has replaced that admission — a cancelled dial, a probe that +/// outlived its budget — can no longer claim the slot of the probe that +/// replaced it. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) struct Probe { + started_at: Instant, +} + +/// What a request may do with its upstream. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum Admit { + /// Dial the upstream: the breaker is closed, or it is switched off. + Dial, + /// Dial the upstream **as the probe** of a half-open breaker. The token is + /// what the request reports back, and what proves the role it was given. + Probe(Probe), + /// Refuse without dialling, naming the seconds still to wait. + Refuse { + /// Cooldown, or probe budget, still to run, rounded up to a whole second. + retry_after_secs: u64, + }, +} + +impl Admit { + /// The probe this admission granted, if it granted one. + #[must_use] + pub(crate) fn probe(self) -> Option { + match self { + Admit::Probe(token) => Some(token), + Admit::Dial | Admit::Refuse { .. } => None, + } + } +} + +/// What one request observed of its upstream, and at which stage. +/// +/// The head and the body are two observers of one request: the head reports the +/// status the upstream answered with, or why no head ever came; the body reports +/// a transfer that never finished. Both are evidence about the same upstream, +/// which is why both are reported and both are judged by +/// [`is_health_failure`]. +#[derive(Clone, Copy, Debug)] +pub(crate) enum Observed { + /// The upstream answered with this status. Its body may still fail, and the + /// body then reports a second time for the same request. + Answered(StatusCode), + /// No usable answer: the dial failed before a status existed, or the body + /// never finished. `kind` names why. + Failed(OagwErrorKind), +} + +/// What a forwarded body reports to its upstream's breaker. +/// +/// The body outlives the request that dialled it, so what it reports to has to +/// be owned: the breakers behind an `Arc`, the upstream the body came from, the +/// probe role that request was admitted as and the instruments the transition +/// emits to. Cloned once per response, so a body that fails reports without the +/// breaker's own map having to be reachable from the stream. +#[derive(Clone)] +pub(crate) struct BodyReport { + breakers: std::sync::Arc, + upstream_id: Uuid, + host: String, + probe: Option, + metrics: metrics::ProxyMetrics, +} + +impl BodyReport { + /// The report the forwarded body of `upstream` carries. + #[must_use] + pub(crate) fn for_response( + breakers: std::sync::Arc, + upstream: &crate::domain::model::Upstream, + probe: Option, + metrics: metrics::ProxyMetrics, + ) -> Self { + Self { + breakers, + upstream_id: upstream.id, + host: upstream.alias.clone(), + probe, + metrics, + } + } + + /// Report that the body of the request never finished, with `kind` naming + /// why. + /// + /// The head of this request has already reported the status the upstream + /// answered with, so this is the second observation of one request: a 200 + /// head followed by a body the upstream never finished is the slow-upstream + /// failure the head cannot see, and the breaker has to count it. + pub(crate) fn observe(&self, kind: OagwErrorKind) { + self.breakers.record( + self.upstream_id, + &self.host, + self.probe, + Observed::Failed(kind), + Instant::now(), + &self.metrics, + ); + } +} + +/// The breakers of the data plane, one per upstream id. +pub(crate) struct CircuitBreakers { + /// Whether the breaker is consulted at all. Off means every request is + /// dialled and no state is kept, so a deployment that turns the breaker off + /// pays nothing for it. + enabled: bool, + /// Failures inside the window that trip a closed breaker. + threshold: usize, + /// How long a failure stays counted. + window: Duration, + /// How long an open breaker stays open. + cooldown: Duration, + /// How long a probe may take before it is treated as abandoned. + probe_budget: Duration, + breakers: DashMap, +} + +impl std::fmt::Debug for CircuitBreakers { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CircuitBreakers") + .field("enabled", &self.enabled) + .finish_non_exhaustive() + } +} + +/// State of one upstream's breaker. +#[derive(Default)] +struct Breaker { + state: State, + /// Failures still inside the window, oldest first. + failures: VecDeque, + /// When the current cooldown ends; meaningful while open. + cooldown_ends_at: Option, + /// When the probe in flight was admitted; meaningful while half-open. + probe_started_at: Option, +} + +impl Default for State { + /// A breaker starts closed: an upstream that was never dialled is neither + /// refused nor suspected. Spelled rather than derived, so a state added + /// later cannot become the silent default. + fn default() -> Self { + State::Closed + } +} + +impl CircuitBreakers { + /// Build the breakers of one data plane. + pub(crate) fn new( + config: &crate::config::CircuitBreakerConfig, + probe_budget: Duration, + ) -> Self { + // A threshold below one, or a window of no length, would make the + // breaker trip on nothing or never: both are a typo, and a typo in a + // safety switch must not silently disable it. One failure in a one + // second window is the smallest honest reading of "5 in 30s". + let threshold = usize::try_from(config.failure_threshold) + .unwrap_or(usize::MAX) + .max(1); + Self { + enabled: config.enabled, + threshold, + window: Duration::from_secs(config.failure_window_secs.max(1)), + cooldown: Duration::from_secs(config.cooldown_secs), + probe_budget, + breakers: DashMap::with_capacity(INITIAL_BREAKER_CAPACITY.min(64)), + } + } + + /// Ask whether a request to `upstream_id` may dial. + /// + /// The answer is taken before the dial, and a probe it admits is minted + /// here: the requests that arrive behind a probe are refused until it has + /// reported, and the one that is admitted as the probe holds the token it + /// has to report with. + /// + /// `now` is the instant of the request, injected by the caller (see the + /// module docs on the clock). + pub(crate) fn admit( + &self, + upstream_id: Uuid, + host: &str, + now: Instant, + metrics: &metrics::ProxyMetrics, + ) -> Admit { + if !self.enabled { + return Admit::Dial; + } + let mut promoted = None; + let admit = { + let mut entry = self.breakers.entry(upstream_id).or_default(); + let breaker = entry.value_mut(); + match breaker.state { + State::Closed => Admit::Dial, + State::Open if breaker.cooldown_over(now) => { + promoted = Some((State::Open, State::HalfOpen)); + breaker.state = State::HalfOpen; + breaker.probe_started_at = Some(now); + Admit::Probe(Probe { started_at: now }) + } + State::Open => Admit::Refuse { + retry_after_secs: retry_after(breaker.cooldown_remaining(now)), + }, + State::HalfOpen if breaker.probe_abandoned(now, self.probe_budget) => { + // The probe never reported, so its slot is free again and + // this request takes it. The token the abandoned probe still + // carries names an admission that is no longer in flight, + // which is why its report, if it ever arrives, moves + // nothing. + breaker.probe_started_at = Some(now); + Admit::Probe(Probe { started_at: now }) + } + State::HalfOpen => Admit::Refuse { + retry_after_secs: retry_after(breaker.probe_remaining(now, self.probe_budget)), + }, + } + }; + if let Some((from, to)) = promoted { + Self::transition(upstream_id, host, from, to, metrics); + } + admit + } + + /// Report the outcome of one dialled request. + /// + /// Only a request that dialled reports, and only the one holding the probe + /// token its breaker is still waiting for may move a half-open breaker. A + /// refusal the gateway answered without dialling reports nothing, and + /// neither does a request that was dialled before the breaker tripped and + /// only reports afterwards. + /// + /// `observed` is what the request saw, `now` the instant of the report. + pub(crate) fn record( + &self, + upstream_id: Uuid, + host: &str, + probe: Option, + observed: Observed, + now: Instant, + metrics: &metrics::ProxyMetrics, + ) { + if !self.enabled { + return; + } + let failure = is_health_failure(observed); + let mut transition = None; + { + let mut entry = self.breakers.entry(upstream_id).or_default(); + let breaker = entry.value_mut(); + match breaker.state { + State::Closed => { + // The window is the breaker's memory of the failures inside + // it, and it is the only thing that forgets: a success is + // not a failure and does not empty it (PRD "5 failed + // requests in 30s window"), while a failure older than the + // window is no longer evidence. + if failure { + breaker.failures.push_back(now); + } + breaker.trim(self.window, now); + if breaker.failures.len() >= self.threshold { + breaker.state = State::Open; + breaker.cooldown_ends_at = Some(now + self.cooldown); + // The trip consumed the window's answer: the breaker is + // open, and what it owes from here is the cooldown. + breaker.failures.clear(); + transition = Some((State::Closed, State::Open)); + } + } + State::HalfOpen if breaker.holds(probe) => { + breaker.probe_started_at = None; + if failure { + breaker.state = State::Open; + breaker.cooldown_ends_at = Some(now + self.cooldown); + transition = Some((State::HalfOpen, State::Open)); + } else { + breaker.state = State::Closed; + transition = Some((State::HalfOpen, State::Closed)); + } + } + // Open: nothing was dialled. Half-open without the token the + // breaker holds: a request dialled while the breaker was closed, + // reporting after the trip, or the late report of an abandoned + // probe. Neither one decides the state of the breaker. + State::Open | State::HalfOpen => {} + } + } + if let Some((from, to)) = transition { + Self::transition(upstream_id, host, from, to, metrics); + } + } + + /// Drop the breaker of a deleted upstream. + /// + /// A recreated upstream starts closed: the record it replaced is gone, and + /// so is the health this instance saw of it. + pub(crate) fn forget(&self, upstream_id: Uuid) { + self.breakers.remove(&upstream_id); + } + + /// Move a breaker between two states and report it (DESIGN §4.2, §4.3). + fn transition( + upstream_id: Uuid, + host: &str, + from: State, + to: State, + metrics: &metrics::ProxyMetrics, + ) { + metrics.breaker_transition(host, from.label(), to.label()); + metrics.breaker_state(host, to.value()); + // A transition is an event of DESIGN §4.3 and the level it names for a + // breaker that opened is `WARN`: an operator has to see the upstream + // was cut off, and every transition is rare enough to log. + tracing::warn!( + upstream_id = %upstream_id, + host = %host, + from_state = from.label(), + to_state = to.label(), + "circuit breaker state changed" + ); + } +} + +impl Breaker { + /// Drop the failures that fell out of the window. + /// + /// The deque never holds more than a trip needs: once the breaker is open + /// the window is empty anyway, and while it is closed a failure older than + /// the window is no longer evidence. + fn trim(&mut self, window: Duration, now: Instant) { + while self + .failures + .front() + .is_some_and(|at| now.duration_since(*at) > window) + { + self.failures.pop_front(); + } + } + + /// Whether the cooldown has run out. + fn cooldown_over(&self, now: Instant) -> bool { + self.cooldown_ends_at.is_none_or(|ends_at| ends_at <= now) + } + + /// Time still to wait before the breaker may be probed. + fn cooldown_remaining(&self, now: Instant) -> Duration { + self.cooldown_ends_at.map_or(Duration::ZERO, |ends_at| { + ends_at.saturating_duration_since(now) + }) + } + + /// Whether the probe in flight can no longer report. + fn probe_abandoned(&self, now: Instant, budget: Duration) -> bool { + self.probe_started_at + .is_some_and(|started| now.duration_since(started) > budget) + } + + /// Time still to wait before a new probe would be admitted. + fn probe_remaining(&self, now: Instant, budget: Duration) -> Duration { + self.probe_started_at.map_or(budget, |started| { + budget.saturating_sub(now.duration_since(started)) + }) + } + + /// Whether `probe` is the token this breaker is waiting for. + fn holds(&self, probe: Option) -> bool { + self.probe_started_at == probe.map(|token| token.started_at) + } +} + +/// Whether one request left its upstream looking unhealthy. +/// +/// This is the one place the breaker decides what a failure is, and it is +/// exhaustive over [`OagwErrorKind`] so that a new kind cannot silently join +/// either side. `observed` is what the request saw, at whichever stage it +/// reported: the status the upstream answered with, or the reason no usable +/// answer came (see the module docs for which stage delivers which kind). +/// +/// PRD `cpt-cf-oagw-nfr-high-availability` counts "5 failed requests in 30s +/// window" towards a trip, and a *failed* request here is an upstream-side +/// health failure and nothing else: +/// +/// * a **5xx** the upstream answered — the upstream is there and says it is +/// broken; +/// * a **dial failure, a timeout or an abort** — the upstream could not be +/// reached, or the answer it started never finished: `LinkUnavailable`, +/// `ConnectionTimeout`, `RequestTimeout`, `IdleTimeout`, `StreamAborted`, +/// `DownstreamError`, `ProtocolError`. +/// +/// Everything else is **not** the upstream's health: +/// +/// * a **4xx** is the client's error and the upstream answered it — counting it +/// would let one misconfigured client trip a healthy upstream's breaker; +/// * a **gateway refusal** — CORS, a rate limit, an oversized request body, the +/// egress policy, framing validation — happened before the dial, so the +/// upstream was never asked; +/// * a **credential or plugin failure** — `AuthenticationFailed`, +/// `SecretNotFound`, `PluginNotFound` — is a dependency of the *gateway* +/// missing, which an operator fixes in the configuration, not by waiting for +/// the upstream to recover; +/// * `CircuitBreakerOpen` itself never reaches this decision, because an open +/// breaker refuses without dialling and an un-dialled request reports nothing. +pub(crate) fn is_health_failure(observed: Observed) -> bool { + match observed { + Observed::Answered(status) => status.is_server_error(), + Observed::Failed(kind) => match kind { + // The upstream could not be reached, or the answer it started never + // finished. `ConnectionTimeout` and `DownstreamError` are produced + // by no path today (see the module docs); they stay failures + // because that is what they mean. + OagwErrorKind::LinkUnavailable + | OagwErrorKind::ConnectionTimeout + | OagwErrorKind::RequestTimeout + | OagwErrorKind::IdleTimeout + | OagwErrorKind::StreamAborted + | OagwErrorKind::DownstreamError + | OagwErrorKind::ProtocolError => true, + // Nothing the gateway decided before the dial, nothing the client + // got wrong and nothing of the gateway's own dependencies says + // anything about the upstream: the refusals of the gateway, the + // client's mistakes and the gateway's own missing dependencies, + // and an un-dialled request leaves the breaker exactly as it was. + OagwErrorKind::Validation + | OagwErrorKind::MissingTargetHost + | OagwErrorKind::InvalidTargetHost + | OagwErrorKind::UnknownTargetHost + | OagwErrorKind::NotFound + | OagwErrorKind::PayloadTooLarge + | OagwErrorKind::RateLimitExceeded + | OagwErrorKind::CorsOriginNotAllowed + | OagwErrorKind::CorsMethodNotAllowed + | OagwErrorKind::AliasConflict + | OagwErrorKind::RouteConflict + | OagwErrorKind::PluginConflict + | OagwErrorKind::PluginInUse + | OagwErrorKind::AuthenticationFailed + | OagwErrorKind::SecretNotFound + | OagwErrorKind::PluginNotFound + | OagwErrorKind::Internal + | OagwErrorKind::CircuitBreakerOpen => false, + }, + } +} + +/// `Retry-After` of a refusal, in whole seconds from `remaining`. +/// +/// `remaining` rounded **up**, and never zero — not even at the exact end of a +/// budget, where a refusal still has to name a second: a refusal that says +/// "retry now" is not guidance, it is noise. Rounding up rather than down is +/// what makes the guidance honest the other way too: a client that waits the +/// seconds it was named is always *past* the deadline, never a fraction short +/// of it and refused a second time. +fn retry_after(remaining: Duration) -> u64 { + (remaining.as_secs() + u64::from(remaining.subsec_nanos() != 0)).max(1) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::{Admit, CircuitBreakers, Observed, State, is_health_failure, retry_after}; + use crate::config::CircuitBreakerConfig; + use crate::error::{OagwError, OagwErrorKind, ResourceKind}; + use crate::infra::metrics::ProxyMetrics; + + /// The probe budget of the unit tests, and the answer of the refusal that + /// names it whole. + const BUDGET: u64 = 30; + + /// A breaker with the default 30s failure window. + fn wrap(config: CircuitBreakerConfig) -> CircuitBreakers { + CircuitBreakers::new(&config, Duration::from_secs(BUDGET)) + } + + /// A breaker that trips on `threshold` failures and probes after + /// `cooldown` seconds. + fn breakers(threshold: u32, cooldown: u64) -> CircuitBreakers { + wrap(CircuitBreakerConfig { + failure_threshold: threshold, + cooldown_secs: cooldown, + ..CircuitBreakerConfig::default() + }) + } + + /// `start + secs`: how the tests spell an instant, so the arithmetic the + /// assertions read is the arithmetic the code does. + fn at(start: std::time::Instant, secs: u64) -> std::time::Instant { + start + Duration::from_secs(secs) + } + + /// Report `kind` as the outcome of a request that was not the probe. + fn report( + breaker: &CircuitBreakers, + id: uuid::Uuid, + at: std::time::Instant, + kind: OagwErrorKind, + ) { + let metrics = ProxyMetrics::from_global(); + breaker.record( + id, + "api.vendor.com", + None, + Observed::Failed(kind), + at, + &metrics, + ); + } + + /// Report `status` as the answer a request that was not the probe got. + fn answer(breaker: &CircuitBreakers, id: uuid::Uuid, at: std::time::Instant, status: u16) { + let metrics = ProxyMetrics::from_global(); + let observed = Observed::Answered( + http::StatusCode::from_u16(status).expect("a test status is a valid status"), + ); + breaker.record(id, "api.vendor.com", None, observed, at, &metrics); + } + + fn admit(breaker: &CircuitBreakers, id: uuid::Uuid, at: std::time::Instant) -> Admit { + breaker.admit(id, "api.vendor.com", at, &ProxyMetrics::from_global()) + } + + #[test] + fn the_prd_threshold_trips_on_the_fifth_failure() { + let breaker = breakers(5, 30); + let id = uuid::Uuid::now_v7(); + let start = std::time::Instant::now(); + for step in 0..4 { + report( + &breaker, + id, + at(start, step), + OagwErrorKind::LinkUnavailable, + ); + } + assert_eq!( + admit(&breaker, id, at(start, 4)), + Admit::Dial, + "four failures are one short of the PRD threshold" + ); + report(&breaker, id, at(start, 4), OagwErrorKind::LinkUnavailable); + assert_eq!( + admit(&breaker, id, at(start, 5)), + Admit::Refuse { + retry_after_secs: 29 + }, + "the fifth failure in the window opens the breaker for the 29s of \ + cooldown left" + ); + } + + /// The window, not the streak: five failures inside it trip the breaker + /// whatever happened between them, which is what makes a partially failing + /// upstream — 200 on its cheap requests, 500 on its expensive ones — + /// trappable. + #[test] + fn five_failures_in_the_window_trip_even_between_successes() { + let breaker = breakers(5, 30); + let id = uuid::Uuid::now_v7(); + let start = std::time::Instant::now(); + for step in 0..5 { + report( + &breaker, + id, + at(start, step), + OagwErrorKind::LinkUnavailable, + ); + answer(&breaker, id, at(start, step), 200); + } + assert_eq!( + admit(&breaker, id, at(start, 5)), + Admit::Refuse { + retry_after_secs: 29 + }, + "five failures inside the window, one success between each of them" + ); + } + + /// A failure leaves the window by aging out and by nothing else, so an + /// upstream that failed once and recovered is not left one failure away + /// from a trip forever. + #[test] + fn a_failure_leaves_the_window_when_it_ages_out() { + let breaker = wrap(CircuitBreakerConfig { + failure_threshold: 2, + failure_window_secs: 1, + cooldown_secs: 30, + ..CircuitBreakerConfig::default() + }); + let id = uuid::Uuid::now_v7(); + let start = std::time::Instant::now(); + report(&breaker, id, start, OagwErrorKind::LinkUnavailable); + // Two seconds later the first failure is out of the one second window, + // so this second failure is alone in it. + report(&breaker, id, at(start, 2), OagwErrorKind::LinkUnavailable); + assert_eq!( + admit(&breaker, id, at(start, 2)), + Admit::Dial, + "the failure that aged out no longer counts" + ); + } + + #[test] + fn a_disabled_breaker_neither_refuses_nor_keeps_state() { + let breaker = wrap(CircuitBreakerConfig { + enabled: false, + ..CircuitBreakerConfig::default() + }); + let id = uuid::Uuid::now_v7(); + let start = std::time::Instant::now(); + for step in 0..10 { + report( + &breaker, + id, + at(start, step), + OagwErrorKind::LinkUnavailable, + ); + } + assert_eq!(admit(&breaker, id, at(start, 10)), Admit::Dial); + } + + #[test] + fn a_zero_threshold_still_needs_a_failure() { + let breaker = wrap(CircuitBreakerConfig { + failure_threshold: 0, + cooldown_secs: 30, + ..CircuitBreakerConfig::default() + }); + let id = uuid::Uuid::now_v7(); + let start = std::time::Instant::now(); + assert_eq!(admit(&breaker, id, start), Admit::Dial); + answer(&breaker, id, start, 200); + assert_eq!(admit(&breaker, id, at(start, 1)), Admit::Dial); + report(&breaker, id, at(start, 1), OagwErrorKind::LinkUnavailable); + assert_eq!( + admit(&breaker, id, at(start, 2)), + Admit::Refuse { + retry_after_secs: 29 + }, + "the 29s of cooldown left is what the refusal names" + ); + } + + #[test] + fn the_states_the_operator_reads() { + assert_eq!(State::Closed.label(), "closed"); + assert_eq!(State::Open.label(), "open"); + assert_eq!(State::HalfOpen.label(), "half_open"); + assert_eq!(State::Closed.value(), 0); + assert_eq!(State::HalfOpen.value(), 1); + assert_eq!(State::Open.value(), 2); + } + + #[test] + fn a_5xx_is_a_health_failure_and_a_4xx_is_not() { + let server_error = |status: u16| { + is_health_failure(Observed::Answered( + http::StatusCode::from_u16(status).expect("a test status is a valid status"), + )) + }; + assert!(server_error(500)); + assert!(server_error(503)); + assert!(!server_error(404)); + assert!(!server_error(302), "a redirect is an answer, not a failure"); + assert!(is_health_failure(Observed::Failed( + OagwErrorKind::LinkUnavailable + ))); + } + + /// The exhaustive classification, kind by kind: the upstream's health is + /// judged on these and on nothing else. + #[test] + fn every_kind_is_classified_once() { + let failure_kinds = [ + OagwErrorKind::LinkUnavailable, + OagwErrorKind::ConnectionTimeout, + OagwErrorKind::RequestTimeout, + OagwErrorKind::IdleTimeout, + OagwErrorKind::StreamAborted, + OagwErrorKind::DownstreamError, + OagwErrorKind::ProtocolError, + ]; + let client_kinds = [ + OagwErrorKind::Validation, + OagwErrorKind::MissingTargetHost, + OagwErrorKind::InvalidTargetHost, + OagwErrorKind::UnknownTargetHost, + OagwErrorKind::NotFound, + OagwErrorKind::PayloadTooLarge, + OagwErrorKind::RateLimitExceeded, + OagwErrorKind::CorsOriginNotAllowed, + OagwErrorKind::CorsMethodNotAllowed, + OagwErrorKind::AliasConflict, + OagwErrorKind::RouteConflict, + OagwErrorKind::PluginConflict, + OagwErrorKind::PluginInUse, + ]; + let gateway_kinds = [ + OagwErrorKind::AuthenticationFailed, + OagwErrorKind::SecretNotFound, + OagwErrorKind::PluginNotFound, + OagwErrorKind::Internal, + OagwErrorKind::CircuitBreakerOpen, + ]; + for kind in failure_kinds { + assert!( + is_health_failure(Observed::Failed(kind)), + "{} counts", + OagwError::new(kind, "the upstream").gts_type() + ); + } + for kind in client_kinds.iter().copied().chain(gateway_kinds) { + assert!( + !is_health_failure(Observed::Failed(kind)), + "{} is not a health failure", + OagwError::new(kind, "not the upstream's health").gts_type() + ); + } + } + + /// The problem type a refused request carries is the PRD's `503 + /// CircuitBreakerOpen`, retriable, whatever the breaker's own state is. + #[test] + fn the_refusal_is_the_documented_problem_type() { + let kind = OagwErrorKind::CircuitBreakerOpen; + assert_eq!(kind.status(), 503); + assert_eq!( + kind.gts_type(ResourceKind::Upstream), + "gts.cf.core.errors.err.v1~cf.oagw.circuit_breaker.open.v1" + ); + } + + /// A request that arrives behind the probe of a half-open breaker is + /// refused, and the refusal names the probe budget still to run. + #[test] + fn a_request_behind_a_probe_is_refused_with_the_budget_still_to_run() { + let breaker = breakers(1, BUDGET); + let id = uuid::Uuid::now_v7(); + let start = std::time::Instant::now(); + report(&breaker, id, start, OagwErrorKind::LinkUnavailable); + let Admit::Probe(_) = admit(&breaker, id, at(start, BUDGET)) else { + panic!("the request past the cooldown is the probe"); + }; + assert_eq!( + admit(&breaker, id, at(start, BUDGET + 10)), + Admit::Refuse { + retry_after_secs: 20 + }, + "the probe has been on the wire for 10s of its 30s budget" + ); + } + + /// A probe that never reports is abandoned once its budget has run out, and + /// the next request takes the slot — the safety valve that keeps a cancelled + /// dial from refusing every later request forever. + #[test] + fn an_abandoned_probe_frees_its_slot_after_the_budget() { + let breaker = breakers(1, BUDGET); + let id = uuid::Uuid::now_v7(); + let start = std::time::Instant::now(); + report(&breaker, id, start, OagwErrorKind::LinkUnavailable); + let Admit::Probe(abandoned) = admit(&breaker, id, at(start, BUDGET)) else { + panic!("the request past the cooldown is the probe"); + }; + // One second before the budget ends the probe is still the probe, and + // the refusal at the very end of it still names a second. + assert_eq!( + admit(&breaker, id, at(start, BUDGET * 2 - 1)), + Admit::Refuse { + retry_after_secs: 1 + } + ); + assert_eq!( + admit(&breaker, id, at(start, BUDGET * 2)), + Admit::Refuse { + retry_after_secs: 1 + }, + "exactly at the budget the refusal cannot name zero" + ); + let Admit::Probe(replacement) = admit(&breaker, id, at(start, BUDGET * 2 + 1)) else { + panic!("an abandoned probe has to free its slot"); + }; + // The abandoned probe reports late, and healthy: it is not the probe the + // breaker is waiting for any more, so it must not close the breaker. + let late = at(start, BUDGET * 2 + 2); + let metrics = ProxyMetrics::from_global(); + breaker.record( + id, + "api.vendor.com", + Some(abandoned), + Observed::Answered(http::StatusCode::OK), + late, + &metrics, + ); + assert_eq!( + admit(&breaker, id, late), + Admit::Refuse { + retry_after_secs: 29 + }, + "the late report of an abandoned probe decided nothing" + ); + // The replacement's own report is the one that closes the breaker. + breaker.record( + id, + "api.vendor.com", + Some(replacement), + Observed::Answered(http::StatusCode::OK), + late, + &metrics, + ); + assert_eq!( + admit(&breaker, id, late), + Admit::Dial, + "the probe the breaker was waiting for closed it" + ); + } + + /// A dial that was admitted while the breaker was closed can report long + /// after the breaker tripped and half-opened: its outcome must not decide + /// the state, because it is not the probe. + #[test] + fn a_late_report_from_a_closed_admission_decides_nothing() { + let breaker = breakers(1, BUDGET); + let id = uuid::Uuid::now_v7(); + let start = std::time::Instant::now(); + assert_eq!(admit(&breaker, id, start), Admit::Dial); + // The slow request leaves. The request behind it fails and trips. + report(&breaker, id, at(start, 1), OagwErrorKind::LinkUnavailable); + let Admit::Probe(_) = admit(&breaker, id, at(start, 31)) else { + panic!("the breaker half-opened and admitted its probe"); + }; + // The slow request now answers, 200, well after the trip. + answer(&breaker, id, at(start, 32), 200); + assert_eq!( + admit(&breaker, id, at(start, 32)), + Admit::Refuse { + retry_after_secs: 29 + }, + "a request that was not the probe cannot close the breaker" + ); + } + + /// A refusal always names a second, and never one that lets a client retry + /// early: the budget still to run is rounded up to a whole second, so + /// waiting it out always lands the client past the deadline. + #[test] + fn the_refusal_never_names_zero_seconds() { + assert_eq!(retry_after(Duration::ZERO), 1); + assert_eq!( + retry_after(Duration::from_millis(1_500)), + 2, + "rounded up to a whole second" + ); + assert_eq!( + retry_after(Duration::from_millis(29_999)), + 30, + "the last millisecond is still a whole second to wait" + ); + assert_eq!( + retry_after(Duration::from_secs(30)), + 30, + "a whole number of seconds is named as itself" + ); + } +} diff --git a/gears/system/oagw/oagw/src/domain/proxy/chain.rs b/gears/system/oagw/oagw/src/domain/proxy/chain.rs new file mode 100644 index 0000000..19456d7 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/proxy/chain.rs @@ -0,0 +1,80 @@ +// Created: 2026-08-31 by Constructor Tech +//! Tenant-chain resolution for alias shadowing (DESIGN §3.2). +//! +//! The production adapter wraps the `tenant_resolver` client of the +//! `ClientHub`; tests inject a static chain. Keeping the seam narrow (one +//! method, domain types only) means the routing logic never depends on the +//! SDK. + +use std::sync::Arc; + +use async_trait::async_trait; +use tenant_resolver_sdk::{ + GetAncestorsOptions, TenantId, TenantResolverClient, TenantResolverError, +}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::error::{OagwError, OagwErrorKind, OagwResult}; + +/// The ancestors of a tenant, from the direct parent to the root. +#[async_trait] +pub trait TenantChain: Send + Sync { + /// Walk from `tenant_id` up to the root, most specific first. + /// + /// # Errors + /// Propagated when the hierarchy cannot be read; a tenant without a + /// hierarchy yields an empty walk. + async fn ancestors(&self, ctx: &SecurityContext, tenant_id: Uuid) -> OagwResult>; +} + +/// Adapter over the `tenant_resolver` client. +pub struct ResolverChain { + client: Arc, +} + +impl ResolverChain { + /// Wrap `client`. + #[must_use] + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl TenantChain for ResolverChain { + async fn ancestors(&self, ctx: &SecurityContext, tenant_id: Uuid) -> OagwResult> { + match self + .client + .get_ancestors(ctx, TenantId(tenant_id), &GetAncestorsOptions::default()) + .await + { + Ok(response) => Ok(response + .ancestors + .iter() + .map(|tenant| tenant.id.0) + .collect()), + // A tenant outside the hierarchy simply has no shadowing chain. + Err(TenantResolverError::TenantNotFound { .. }) => Ok(Vec::new()), + Err(error) => Err(OagwError::new( + OagwErrorKind::Internal, + format!("tenant hierarchy unavailable: {error}"), + )), + } + } +} + +/// Chain that never leaves the calling tenant. +/// +/// Used when the `tenant_resolver` client is not wired into the deployment: +/// alias shadowing degrades to a per-tenant lookup instead of failing the +/// whole gear. +#[derive(Debug, Default)] +pub struct NoChain; + +#[async_trait] +impl TenantChain for NoChain { + async fn ancestors(&self, _ctx: &SecurityContext, _tenant_id: Uuid) -> OagwResult> { + Ok(Vec::new()) + } +} diff --git a/gears/system/oagw/oagw/src/domain/proxy/cors.rs b/gears/system/oagw/oagw/src/domain/proxy/cors.rs new file mode 100644 index 0000000..dfdb7a8 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/proxy/cors.rs @@ -0,0 +1,791 @@ +// Created: 2026-08-31 by Constructor Tech +//! The built-in CORS handler of the proxy data plane (ADR-0004). +//! +//! # Preflight +//! +//! A preflight (`OPTIONS` + `Origin` + `Access-Control-Request-Method`) is +//! answered locally with a permissive 204: no upstream resolution, no plugin +//! chain, no rate limit. Browser preflights carry no credentials, so no tenant +//! context exists to resolve an upstream with; origin and method validation is +//! therefore **deferred to the actual request**, which is the ADR's design and +//! not a shortcut (ADR-0004 "Preflight Request Handling"). An `OPTIONS` that is +//! not a preflight is not a CORS request at all and keeps flowing through the +//! normal proxy path. +//! +//! The cost of that deference is that a preflight is unmetered: it consumes no +//! token and no plugin run, so a flood of preflights is a request volume this +//! gear never charges for. ADR-0004 delegates that to the edge controls of the +//! deployment, which this gear does not carry — the residual risk is recorded +//! as a deviation of the design, not closed here. +//! +//! # Actual request +//! +//! Enforcement happens after the upstream and the route have been resolved and +//! before the dial, so a disallowed origin never costs an upstream call — and, +//! because CORS runs before the rate limit, never a token either. The `enabled` +//! switch of the selected record gates the whole check: CORS is off unless a +//! record turns it on, and a disabled record adds no header of its own +//! (ADR-0004 "Security Considerations", deny by default). +//! +//! An **enabled** policy is authoritative: the `access-control-*` headers the +//! upstream answered with are stripped before the gateway adds its own, so a +//! policy the operator configured cannot be widened by an upstream. A +//! **disabled** one leaves the upstream's headers exactly as they arrived, +//! because there is nothing to be authoritative about. +//! +//! # Origin matching +//! +//! Exact string comparison: no patterns, no suffix matching, port-sensitive +//! and protocol-sensitive (ADR-0004 "Security Considerations"). `*` matches +//! every origin and nothing else does — so `https://evil.com.example.com` is +//! not `https://example.com`. +//! +//! # Hierarchical configuration +//! +//! Levels are read descendant→ancestor — `[route, resolved_upstream, nearest +//! ancestor, …]`, each `None` when that record declares no `cors` member — and +//! the first level that declares one is the *selected* one. No level declares → +//! no enforcement and no header. Everything but the origin set comes from the +//! selected level; the origin set follows the sharing modes of the chain: +//! +//! * `private` (the default) — the selected level's origins alone. +//! * `inherit` — the selected level's origins unioned with **every** ancestor +//! that declares a config. +//! * `enforce` — the origin set of the **nearest** ancestor that declares a +//! config, whatever its size: an empty set is a deliberate deny-all and is +//! honoured, never skipped. The selected level's own origins are dropped, +//! because the child cannot widen what the ancestor enforces. + +use http::header::{ + ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, + ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE, + ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD, ORIGIN, VARY, +}; +use http::{HeaderMap, HeaderValue, Method}; + +use axum::response::IntoResponse; + +use crate::domain::model::{CorsConfig, SharingMode}; +use crate::error::{OagwError, OagwErrorKind, OagwResult}; + +/// `Access-Control-Max-Age` of a preflight answer, in seconds (ADR-0004 +/// "Preflight Request Handling"). +const PREFLIGHT_MAX_AGE: &str = "86400"; +/// `Vary` of a preflight answer: all three request headers it echoed. +const PREFLIGHT_VARY: &str = + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"; +/// `Vary` of an actual cross-origin response: the origin set is per request. +const ALLOW_VARY: &str = "Origin"; +/// The wildcard origin. +const WILDCARD: &str = "*"; +/// The methods the configuration schema defaults to. +const DEFAULT_METHODS: [&str; 2] = ["GET", "POST"]; + +/// The effective CORS policy of one request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Effective { + /// Origins that may read the response; `["*"]` allows every origin. + pub allowed_origins: Vec, + /// Methods the upstream accepts on a cross-origin request. + pub allowed_methods: Vec, + /// Headers to expose to the browser beyond the safelisted ones. + pub expose_headers: Vec, + /// Whether credentialed requests are allowed. + pub allow_credentials: bool, +} + +impl Effective { + /// Whether `origin` is allowed, by exact comparison or by the wildcard. + #[must_use] + pub fn allows_origin(&self, origin: &str) -> bool { + self.allowed_origins + .iter() + .any(|allowed| allowed == WILDCARD || allowed == origin) + } + + /// Whether `method` is allowed, ignoring the case of the spelling. + #[must_use] + pub fn allows_method(&self, method: &Method) -> bool { + self.allowed_methods + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(method.as_str())) + } + + /// The `Access-Control-Allow-Origin` value of a cross-origin response. + /// + /// The request's origin is echoed back, because a literal `*` is unusable + /// for a credentialed response (ADR-0004 "Cannot use `allow_credentials` + /// with wildcard origin"); the wildcard is only ever sent back when the + /// policy allows no credentials. + fn allow_origin<'origin>(&self, origin: &'origin str) -> &'origin str { + if self.wildcard() && !self.allow_credentials { + WILDCARD + } else { + origin + } + } + + /// Whether the origin set is the wildcard one. + fn wildcard(&self) -> bool { + self.allowed_origins.iter().any(|origin| origin == WILDCARD) + } +} + +/// Whether the request is a CORS preflight (ADR-0004 "Preflight Request +/// Handling"). +/// +/// All three conditions are required, so a plain `OPTIONS` that a route might +/// still proxy is left alone. +#[must_use] +pub fn is_preflight(method: &Method, headers: &HeaderMap) -> bool { + method == Method::OPTIONS + && headers.contains_key(ORIGIN) + && headers.contains_key(ACCESS_CONTROL_REQUEST_METHOD) +} + +/// Answer a preflight without resolving anything. +/// +/// The permissive echo is the ADR's design: the browser reads only whether its +/// request would be possible, and the actual request is where the origin and +/// the method are checked. Nothing is dialled, so a preflight of an unknown +/// alias still succeeds — the follow-up request carries the tenant context and +/// gets the real answer. +#[must_use] +pub fn preflight(headers: &HeaderMap) -> axum::response::Response { + let mut cors = HeaderMap::new(); + copy(headers.get(ORIGIN), &mut cors, ACCESS_CONTROL_ALLOW_ORIGIN); + copy( + headers.get(ACCESS_CONTROL_REQUEST_METHOD), + &mut cors, + ACCESS_CONTROL_ALLOW_METHODS, + ); + // Verbatim, only when the browser asked for headers (ADR-0004). + if let Some(requested) = headers.get(ACCESS_CONTROL_REQUEST_HEADERS) { + copy(Some(requested), &mut cors, ACCESS_CONTROL_ALLOW_HEADERS); + } + cors.insert( + ACCESS_CONTROL_MAX_AGE, + HeaderValue::from_static(PREFLIGHT_MAX_AGE), + ); + cors.insert(VARY, HeaderValue::from_static(PREFLIGHT_VARY)); + (http::StatusCode::NO_CONTENT, cors).into_response() +} + +/// Echo one request header into the answer under its CORS name. +fn copy(value: Option<&HeaderValue>, cors: &mut HeaderMap, name: http::HeaderName) { + if let Some(value) = value.filter(|value| !value.is_empty()) { + cors.insert(name, value.clone()); + } +} + +/// The effective policy of one request, or `None` when nothing enforces one. +/// +/// `levels` are the `cors` members of the chain, descendant→ancestor, each +/// `None` when that record declares none: the route's, the resolved upstream's, +/// then the ancestors nearest first. `None` values and members with an empty +/// origin list are different things, which is why the levels are handed over as +/// options — the first is "this record says nothing about CORS", the second is +/// "this record deliberately allows nothing". +/// +/// Only the origin set is shared, and only for a record that asks for it: see +/// the module docs for the three sharing modes. +#[must_use] +pub fn effective(levels: &[Option<&CorsConfig>]) -> Option { + let selected = *levels.iter().flatten().next()?; + if !selected.enabled { + return None; + } + Some(Effective { + allowed_origins: origins(selected, levels), + allowed_methods: methods(selected), + expose_headers: selected.expose_headers.clone(), + allow_credentials: selected.allow_credentials, + }) +} + +/// Effective origin set of the selected config, given the whole level list. +/// +/// `levels[0]` is the selected level; the rest are its ancestors, nearest +/// first, `None` where a record declares no `cors` member. +fn origins(selected: &CorsConfig, levels: &[Option<&CorsConfig>]) -> Vec { + let mut ancestors = levels.iter().flatten().skip(1); + match selected.sharing { + SharingMode::Private => selected.allowed_origins.clone(), + // Every ancestor that declares a config widens the set; one that says + // nothing about CORS contributes nothing. + SharingMode::Inherit => { + let mut origins = selected.allowed_origins.clone(); + for ancestor in ancestors { + origins.extend(ancestor.allowed_origins.iter().cloned()); + } + origins + } + // The nearest ancestor that declares a config wins, whatever its size: + // an empty list is a deny-all the child cannot talk its way out of. + // Without an ancestor there is nothing to enforce, so the selected + // level's own origins stand. + SharingMode::Enforce => ancestors.next().map_or_else( + || selected.allowed_origins.clone(), + |ancestor| ancestor.allowed_origins.clone(), + ), + } +} + +/// Methods of `config`; the schema default when the record leaves them out. +fn methods(config: &CorsConfig) -> Vec { + if config.allowed_methods.is_empty() { + DEFAULT_METHODS.iter().map(ToString::to_string).collect() + } else { + config.allowed_methods.clone() + } +} + +/// Enforce the effective policy on one actual request (ADR-0004 "Error +/// Responses"). +/// +/// A request without an `Origin` is not a CORS request and always passes. +/// +/// # Errors +/// 403 for an origin that is not in the effective set, and 403 for a method +/// the policy does not allow. +pub fn check(effective: &Effective, method: &Method, headers: &HeaderMap) -> OagwResult<()> { + let Some(origin) = headers.get(ORIGIN).and_then(|origin| origin.to_str().ok()) else { + return Ok(()); + }; + if origin.is_empty() { + return Ok(()); + } + if !effective.allows_origin(origin) { + return Err(OagwError::new( + OagwErrorKind::CorsOriginNotAllowed, + format!("origin '{origin}' is not in the allowed origins list"), + )); + } + if !effective.allows_method(method) { + return Err(OagwError::new( + OagwErrorKind::CorsMethodNotAllowed, + format!("method '{method}' is not in the allowed methods list"), + )); + } + Ok(()) +} + +/// Headers a forwarded cross-origin response carries (ADR-0004 "Response +/// Headers"). +/// +/// `origin` is the `Origin` of the request: it is echoed back because a +/// credentialed answer cannot say `*`. A same-origin request, which has no +/// `Origin`, gets no CORS header at all but still the `Vary`, because the +/// answer depends on the origin whether or not it names one. +#[must_use] +pub fn response_headers(effective: &Effective, origin: Option<&str>) -> HeaderMap { + let mut cors = HeaderMap::new(); + if let Some(origin) = origin.filter(|origin| !origin.is_empty()) { + insert( + &mut cors, + ACCESS_CONTROL_ALLOW_ORIGIN, + effective.allow_origin(origin), + ); + } + if !effective.expose_headers.is_empty() { + insert( + &mut cors, + ACCESS_CONTROL_EXPOSE_HEADERS, + &effective.expose_headers.join(", "), + ); + } + if effective.allow_credentials { + cors.insert( + ACCESS_CONTROL_ALLOW_CREDENTIALS, + HeaderValue::from_static("true"), + ); + } + cors.insert(VARY, HeaderValue::from_static(ALLOW_VARY)); + cors +} + +/// Headers a refused request is answered with (ADR-0004 "Error Responses"). +/// +/// No `Access-Control-Allow-Origin`: the origin was not allowed, and naming it +/// would tell the browser the opposite of what the answer says. The `Vary` +/// stays, because the answer still depends on the origin the request named. +#[must_use] +pub fn denied_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(VARY, HeaderValue::from_static(ALLOW_VARY)); + headers +} + +/// Strip the `access-control-*` headers an upstream answered with. +/// +/// An enabled policy is authoritative for its origins, so the upstream's own +/// CORS answer is dropped before the gateway adds the one its configuration +/// asks for. Headers the gateway does not own are left alone. +pub fn strip_upstream_headers(headers: &mut HeaderMap) { + let owned: Vec = headers + .keys() + .filter(|name| name.as_str().starts_with(UPSTREAM_PREFIX)) + .cloned() + .collect(); + for name in owned { + headers.remove(&name); + } +} + +/// The prefix every CORS response header shares. +const UPSTREAM_PREFIX: &str = "access-control-"; + +/// Insert a header value from a `&str`, skipping one the wire cannot carry. +/// +/// An origin the caller controls can still be malformed, and a header value +/// that fails to parse is dropped rather than propagated. +fn insert(cors: &mut HeaderMap, name: http::HeaderName, value: &str) { + if let Ok(value) = HeaderValue::from_str(value) { + cors.insert(name, value); + } +} + +#[cfg(test)] +mod tests { + use http::{HeaderMap, HeaderValue}; + + use super::{ + Effective, check, denied_headers, effective, is_preflight, preflight, response_headers, + strip_upstream_headers, + }; + use crate::domain::model::{CorsConfig, SharingMode}; + + fn config(sharing: SharingMode, origins: &[&str]) -> CorsConfig { + CorsConfig { + sharing, + enabled: true, + allowed_origins: origins.iter().map(ToString::to_string).collect(), + allowed_methods: Vec::new(), + expose_headers: Vec::new(), + allow_credentials: false, + } + } + + fn effective_of(config: &CorsConfig) -> Effective { + effective(&[Some(config)]).unwrap_or_else(|| panic!("an enabled config resolves")) + } + + fn headers(origin: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::ORIGIN, + HeaderValue::from_str(origin) + .unwrap_or_else(|_| HeaderValue::from_static("about:blank")), + ); + headers + } + + #[test] + fn only_options_with_a_request_method_is_a_preflight() { + let headers = preflight_headers(); + assert!(is_preflight(&http::Method::OPTIONS, &headers)); + assert!(!is_preflight(&http::Method::GET, &headers)); + // A plain `OPTIONS` without the CORS headers is not a preflight. + assert!(!is_preflight(&http::Method::OPTIONS, &HeaderMap::new())); + let mut without_origin = headers; + without_origin.remove(http::header::ORIGIN); + assert!(!is_preflight(&http::Method::OPTIONS, &without_origin)); + } + + fn preflight_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::ORIGIN, + HeaderValue::from_static("https://browser.example.com"), + ); + headers.insert( + http::header::ACCESS_CONTROL_REQUEST_METHOD, + HeaderValue::from_static("PUT"), + ); + headers.insert( + http::header::ACCESS_CONTROL_REQUEST_HEADERS, + HeaderValue::from_static("x-trace, content-type"), + ); + headers + } + + #[test] + fn a_preflight_is_answered_with_the_permissive_echo() { + let response = preflight(&preflight_headers()); + assert_eq!(response.status(), http::StatusCode::NO_CONTENT); + let cors = response.headers(); + assert_eq!( + cors.get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|value| value.to_str().ok()), + Some("https://browser.example.com") + ); + assert_eq!( + cors.get(http::header::ACCESS_CONTROL_ALLOW_METHODS) + .and_then(|value| value.to_str().ok()), + Some("PUT") + ); + assert_eq!( + cors.get(http::header::ACCESS_CONTROL_ALLOW_HEADERS) + .and_then(|value| value.to_str().ok()), + Some("x-trace, content-type") + ); + assert_eq!( + cors.get(http::header::ACCESS_CONTROL_MAX_AGE) + .and_then(|value| value.to_str().ok()), + Some("86400") + ); + assert_eq!( + cors.get(http::header::VARY) + .and_then(|value| value.to_str().ok()), + Some("Origin, Access-Control-Request-Method, Access-Control-Request-Headers") + ); + } + + #[test] + fn a_preflight_without_requested_headers_names_none() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::ORIGIN, + HeaderValue::from_static("https://a.example.com"), + ); + headers.insert( + http::header::ACCESS_CONTROL_REQUEST_METHOD, + HeaderValue::from_static("GET"), + ); + let response = preflight(&headers); + assert!( + !response + .headers() + .contains_key(http::header::ACCESS_CONTROL_ALLOW_HEADERS) + ); + } + + #[test] + fn a_disabled_config_enforces_nothing() { + let mut config = config( + crate::domain::model::SharingMode::Private, + &["https://a.example.com"], + ); + config.enabled = false; + assert!(effective(&[Some(&config)]).is_none()); + } + + #[test] + fn a_private_config_ignores_its_ancestors() { + let own = config( + crate::domain::model::SharingMode::Private, + &["https://child.example.com"], + ); + let parent = config( + crate::domain::model::SharingMode::Private, + &["https://parent.example.com"], + ); + let cors = effective(&[Some(&own), Some(&parent)]).unwrap_or_else(|| panic!("resolves")); + assert_eq!(cors.allowed_origins, ["https://child.example.com"]); + } + + #[test] + fn an_inherited_config_unions_the_origins() { + let own = config( + crate::domain::model::SharingMode::Inherit, + &["https://child.example.com"], + ); + let parent = config( + crate::domain::model::SharingMode::Inherit, + &["https://parent.example.com"], + ); + let grandparent = config( + crate::domain::model::SharingMode::Inherit, + &["https://root.example.com"], + ); + let cors = effective(&[Some(&own), Some(&parent), Some(&grandparent)]) + .unwrap_or_else(|| panic!("resolves")); + assert_eq!( + cors.allowed_origins, + [ + "https://child.example.com", + "https://parent.example.com", + "https://root.example.com" + ] + ); + } + + #[test] + fn an_enforced_config_keeps_the_parent_origins() { + let own = config( + crate::domain::model::SharingMode::Enforce, + &["https://child.example.com"], + ); + let parent = config( + crate::domain::model::SharingMode::Enforce, + &["https://parent.example.com"], + ); + let cors = effective(&[Some(&own), Some(&parent)]).unwrap_or_else(|| panic!("resolves")); + assert_eq!(cors.allowed_origins, ["https://parent.example.com"]); + } + + #[test] + fn an_enforce_ancestor_with_no_origins_denies_everything() { + let own = config( + crate::domain::model::SharingMode::Enforce, + &["https://child.example.com"], + ); + let parent = config(crate::domain::model::SharingMode::Enforce, &[]); + let cors = effective(&[Some(&own), Some(&parent)]).unwrap_or_else(|| panic!("resolves")); + // An empty set is a deliberate deny-all, not a missing configuration. + assert!( + cors.allowed_origins.is_empty(), + "the ancestor's deny-all must survive: {:?}", + cors.allowed_origins + ); + assert!( + check( + &cors, + &http::Method::GET, + &headers("https://child.example.com") + ) + .is_err() + ); + } + + #[test] + fn an_enforced_ancestor_set_survives_a_route_policy_of_its_own() { + // The route declares `enforce`, so the upstream's origin set is what + // the request is judged against, however the route spells its own. + let own = config( + crate::domain::model::SharingMode::Enforce, + &["https://route.example.com"], + ); + let parent = config( + crate::domain::model::SharingMode::Private, + &["https://parent.example.com"], + ); + let grandparent = config( + crate::domain::model::SharingMode::Enforce, + &["https://root.example.com"], + ); + let cors = effective(&[Some(&own), Some(&parent), Some(&grandparent)]) + .unwrap_or_else(|| panic!("resolves")); + assert_eq!(cors.allowed_origins, ["https://parent.example.com"]); + } + + #[test] + fn an_enforced_ancestor_without_a_config_of_its_own_is_ignored() { + let own = config( + crate::domain::model::SharingMode::Enforce, + &["https://child.example.com"], + ); + // The ancestor declares no `cors` member at all, so there is nothing to + // enforce and the selected level's origins stand. + let cors = effective(&[Some(&own), None]).unwrap_or_else(|| panic!("resolves")); + assert_eq!(cors.allowed_origins, ["https://child.example.com"]); + } + + #[test] + fn a_refusal_names_no_origin_at_all() { + let denied = denied_headers(); + assert!(denied.contains_key(http::header::VARY)); + assert!(!denied.contains_key(http::header::ACCESS_CONTROL_ALLOW_ORIGIN)); + } + + #[test] + fn an_upstreams_own_cors_answer_is_stripped() { + let mut upstream = HeaderMap::new(); + upstream.insert( + http::header::ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_static("*"), + ); + upstream.insert( + http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS, + HeaderValue::from_static("true"), + ); + upstream.insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("text/plain"), + ); + strip_upstream_headers(&mut upstream); + assert!(!upstream.contains_key(http::header::ACCESS_CONTROL_ALLOW_ORIGIN)); + assert!(!upstream.contains_key(http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS)); + assert_eq!( + upstream + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("text/plain"), + "headers the gateway does not own are left alone" + ); + } + + #[test] + fn a_method_list_defaults_to_the_schema() { + let cors = effective_of(&config(crate::domain::model::SharingMode::Private, &["*"])); + assert_eq!(cors.allowed_methods, ["GET", "POST"]); + let mut declared = config(crate::domain::model::SharingMode::Private, &["*"]); + declared.allowed_methods = vec!["PUT".to_owned(), "delete".to_owned()]; + let cors = effective(&[Some(&declared)]).unwrap_or_else(|| panic!("resolves")); + assert_eq!(cors.allowed_methods, ["PUT", "delete"]); + } + + #[test] + fn an_origin_is_matched_exactly() { + let cors = effective_of(&config( + crate::domain::model::SharingMode::Private, + &["https://example.com"], + )); + assert!(cors.allows_origin("https://example.com")); + assert!(check(&cors, &http::Method::GET, &headers("https://example.com")).is_ok()); + // No suffix matching: the attacker's host only shares a suffix. + assert!( + check( + &cors, + &http::Method::GET, + &headers("https://evil.com.example.com") + ) + .is_err() + ); + // Port- and protocol-sensitive. + assert!( + check( + &cors, + &http::Method::GET, + &headers("https://example.com:8443") + ) + .is_err() + ); + assert!(check(&cors, &http::Method::GET, &headers("http://example.com")).is_err()); + } + + #[test] + fn a_wildcard_matches_every_origin() { + let cors = effective_of(&config(crate::domain::model::SharingMode::Private, &["*"])); + assert!(cors.allows_origin("https://anything.example.com")); + assert!(cors.allows_origin("http://localhost:3000")); + } + + #[test] + fn a_method_check_ignores_the_case_of_the_spelling() { + let mut config = config(crate::domain::model::SharingMode::Private, &["*"]); + config.allowed_methods = vec!["POST".to_owned()]; + let cors = effective(&[Some(&config)]).unwrap_or_else(|| panic!("resolves")); + assert!( + check( + &cors, + &http::Method::POST, + &headers("https://a.example.com") + ) + .is_ok() + ); + // The verb the browser spelled is matched, not the case it used. + assert!( + check( + &cors, + &http::Method::from_bytes(b"post").unwrap_or(http::Method::POST), + &headers("https://a.example.com") + ) + .is_ok() + ); + assert!( + check( + &cors, + &http::Method::DELETE, + &headers("https://a.example.com") + ) + .is_err() + ); + } + + #[test] + fn a_method_check_falls_back_to_the_schema_default() { + let cors = effective_of(&config(crate::domain::model::SharingMode::Private, &["*"])); + assert!( + check( + &cors, + &http::Method::POST, + &headers("https://a.example.com") + ) + .is_ok() + ); + assert!( + check( + &cors, + &http::Method::DELETE, + &headers("https://a.example.com") + ) + .is_err() + ); + } + + #[test] + fn a_request_without_an_origin_is_not_a_cors_request() { + let cors = effective_of(&config(crate::domain::model::SharingMode::Private, &["*"])); + assert!(check(&cors, &http::Method::GET, &HeaderMap::new()).is_ok()); + } + + #[test] + fn a_forwarded_response_echoes_the_request_origin() { + let mut config = config( + crate::domain::model::SharingMode::Private, + &["https://a.example.com"], + ); + config.expose_headers = vec!["x-request-id".to_owned()]; + config.allow_credentials = true; + let cors = effective(&[Some(&config)]).unwrap_or_else(|| panic!("resolves")); + let headers = response_headers(&cors, Some("https://a.example.com")); + assert_eq!( + headers + .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|value| value.to_str().ok()), + Some("https://a.example.com"), + "a credentialed answer echoes the origin instead of `*`" + ); + assert_eq!( + headers + .get(http::header::ACCESS_CONTROL_EXPOSE_HEADERS) + .and_then(|value| value.to_str().ok()), + Some("x-request-id") + ); + assert_eq!( + headers + .get(http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS) + .and_then(|value| value.to_str().ok()), + Some("true") + ); + assert_eq!( + headers + .get(http::header::VARY) + .and_then(|value| value.to_str().ok()), + Some("Origin") + ); + } + + #[test] + fn a_wildcard_policy_without_credentials_answers_the_wildcard() { + let cors = effective_of(&config(crate::domain::model::SharingMode::Private, &["*"])); + let headers = response_headers(&cors, Some("https://a.example.com")); + assert_eq!( + headers + .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|value| value.to_str().ok()), + Some("*") + ); + assert!(!headers.contains_key(http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS)); + } + + #[test] + fn a_same_origin_response_still_varies_on_the_origin() { + let cors = effective_of(&config(crate::domain::model::SharingMode::Private, &["*"])); + let headers = response_headers(&cors, None); + assert!(!headers.contains_key(http::header::ACCESS_CONTROL_ALLOW_ORIGIN)); + assert_eq!( + headers + .get(http::header::VARY) + .and_then(|value| value.to_str().ok()), + Some("Origin") + ); + } + + #[test] + fn a_policy_of_no_origins_allows_nothing() { + let cors = effective_of(&config(crate::domain::model::SharingMode::Private, &[])); + assert!(check(&cors, &http::Method::GET, &headers("https://a.example.com")).is_err()); + } +} diff --git a/gears/system/oagw/oagw/src/domain/proxy/headers.rs b/gears/system/oagw/oagw/src/domain/proxy/headers.rs new file mode 100644 index 0000000..d30cd78 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/proxy/headers.rs @@ -0,0 +1,786 @@ +// Created: 2026-08-31 by Constructor Tech +//! Header transformation of the proxy data plane (DESIGN §3.2). +//! +//! Three categories leave the proxy: the routing headers the data plane +//! consumed, the hop-by-hop headers of RFC 9110 §7.6.1 and — when the upstream +//! declares `headers` — everything the rules do not forward. Multi-value +//! headers are preserved: values are copied per entry, never collapsed. +//! +//! # The upgrade exemption +//! +//! The table of DESIGN §3.2 strips `Upgrade` and `Connection` like every +//! hop-by-hop header. A WebSocket handshake (RFC 6455 §4.1) is the one protocol +//! upgrade the gateway forwards, and it is *made of* those two headers: without +//! `Connection: upgrade` and `Upgrade: websocket` the upstream never learns +//! that the client wants a socket. [`is_websocket_handshake`] recognises the +//! handshake and [`restore_upgrade_headers`] writes both values back after the +//! strip list, the header rules included, so no configuration can break the +//! handshake. The two values are canonical — `connection: upgrade` and +//! `upgrade: websocket`, nothing the client spelled — because a token list of +//! the client's own making is a smuggling channel. Every other request keeps +//! the strip list exactly as the table asks for; `Sec-WebSocket-*` headers are +//! not hop-by-hop and need no exemption at all. The answer side is judged by +//! the same standard: [`rejected_upgrade_reason`] reads the acceptance of the +//! session out of the upstream's head (RFC 6455 §4.2.2), because a 101 that +//! carries neither the token nor the accept value is not a session. + +use std::collections::HashMap; + +use http::{HeaderMap, HeaderName, HeaderValue, Method}; + +use crate::domain::model::{HeaderRules, ResponseHeaderRules}; +use crate::error::{OagwError, OagwResult}; + +/// Hop-by-hop headers (RFC 9110 §7.6.1): stripped in both directions. +const HOP_BY_HOP: [&str; 9] = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "proxy-connection", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// `Connection` token of an upgrade (RFC 9110 §7.6.1). +const UPGRADE_TOKEN: &str = "upgrade"; + +/// `Upgrade` protocol token of a WebSocket handshake (RFC 6455 §4.1). +const WEBSOCKET_TOKEN: &str = "websocket"; + +/// Request headers the data plane owns. +/// +/// `content-length` is recomputed from the buffered body, `host` is derived +/// from the dial target, and `x-oagw-target-host` is the routing header +/// ADR-0001 says must not reach the upstream. +const REQUEST_OWNED: [&str; 3] = ["host", "content-length", "x-oagw-target-host"]; + +/// Response headers OAGW owns: an upstream must not see or forge them. +const RESPONSE_OWNED: [&str; 2] = ["x-oagw-target-host", "x-oagw-error-source"]; + +/// Media type of a long-lived event stream (DESIGN §3.2 "Streaming"). +pub const EVENT_STREAM_TYPE: &str = "text/event-stream"; + +/// Forwarded value of a header name / value pair from the configuration. +fn header_pair(name: &str, value: &str) -> OagwResult<(HeaderName, HeaderValue)> { + let parsed_name = HeaderName::try_from(name).map_err(|_| invalid_header(name, "name"))?; + let parsed_value = HeaderValue::try_from(value).map_err(|_| invalid_header(name, "value"))?; + Ok((parsed_name, parsed_value)) +} + +/// 400 for a header name or value the configuration cannot produce. +fn invalid_header(name: &str, part: &str) -> OagwError { + OagwError::validation(format!( + "header {part} of '{name}' is not a valid HTTP header" + )) + .with_extension(|ext| { + ext.invalid_value = Some(name.to_owned()); + }) +} + +/// Headers the `Connection` header names as hop-by-hop (RFC 9110 §7.6.1). +/// +/// A connection-specific header is named by `Connection` and must not be +/// forwarded: `Connection: x-internal-actor` promotes `x-internal-actor` to a +/// hop-by-hop header of *this* hop, whatever the configuration says. +fn connection_named(headers: &HeaderMap) -> Vec { + headers + .get_all(http::header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .filter(|name| !name.is_empty()) + .filter_map(|name| HeaderName::try_from(name).ok()) + .collect() +} + +/// Copy every header that is neither hop-by-hop nor owned by the proxy. +/// +/// `owned` carries the headers of the direction at hand; the `Connection` +/// header of the same direction names further headers that stay on this hop. +fn forwardable(inbound: &HeaderMap, owned: &[&str]) -> HeaderMap { + let named = connection_named(inbound); + let mut headers = HeaderMap::with_capacity(inbound.len()); + for (name, value) in inbound { + let hop_by_hop = HOP_BY_HOP.contains(&name.as_str()) || named.contains(name); + if hop_by_hop || owned.contains(&name.as_str()) { + continue; + } + headers.append(name.clone(), value.clone()); + } + headers +} + +/// Whether `name` is removed by the configuration. +/// +/// A name the configuration spells in a way `http` cannot parse can never +/// match a real header, so it is ignored instead of failing the request. +fn is_removed(name: &HeaderName, removed: &[String]) -> bool { + removed + .iter() + .filter_map(|candidate| HeaderName::try_from(candidate.as_str()).ok()) + .any(|candidate| candidate == *name) +} + +/// Whether an inbound header is forwarded under the `passthrough` mode. +/// +/// A mode the schema does not define forwards **nothing**: the write path +/// rejects it, and a record that reached the store through another route must +/// not silently widen what leaves the gateway. +fn is_forwarded(name: &HeaderName, rules: &HeaderRules) -> bool { + match rules.passthrough.as_deref() { + // An object that omits `passthrough` follows the JSON schema default + // "none": only the headers the rules produce survive. + None | Some("none") => false, + Some("all") => true, + Some("allowlist") => rules + .passthrough_allowlist + .iter() + .filter_map(|allowed| HeaderName::try_from(allowed.as_str()).ok()) + .any(|allowed| allowed == *name), + Some(other) => { + tracing::warn!(passthrough = %other, header = %name, "unknown passthrough mode; forwarding nothing"); + false + } + } +} + +/// Apply `set` (replace) and `add` (append) to the outbound headers. +fn apply_entries( + headers: &mut HeaderMap, + entries: &HashMap, + append: bool, +) -> OagwResult<()> { + for (name, value) in entries { + let (name, value) = header_pair(name, value)?; + if append { + headers.append(&name, value); + } else { + headers.insert(&name, value); + } + } + Ok(()) +} + +/// Build the headers of the outbound request. +/// +/// Order of application (DESIGN §3.2 "Headers Transformation"): strip the +/// hop-by-hop and routing headers, apply `remove`, filter by `passthrough`, +/// then `set` (replace) and `add` (append). The recomputed `Content-Length` is +/// added last. +/// +/// # Errors +/// 400 when a configured header name or value is not a valid HTTP header. +pub fn outbound_request_headers( + inbound: &HeaderMap, + rules: Option<&HeaderRules>, + body_len: u64, +) -> OagwResult { + let mut outbound = forwardable(inbound, &REQUEST_OWNED); + if let Some(rules) = rules { + let mut kept = HeaderMap::with_capacity(outbound.len()); + for (name, value) in &outbound { + if is_removed(name, &rules.remove) || !is_forwarded(name, rules) { + continue; + } + kept.append(name.clone(), value.clone()); + } + outbound = kept; + apply_entries(&mut outbound, &rules.set, false)?; + apply_entries(&mut outbound, &rules.add, true)?; + } + outbound.insert(http::header::CONTENT_LENGTH, HeaderValue::from(body_len)); + Ok(outbound) +} + +/// Build the headers of the response handed back to the client. +/// +/// The upstream status and body are forwarded untouched; only the response +/// rules and the OAGW error-source marker are applied. +/// +/// # Errors +/// 400 when a configured header name or value is not a valid HTTP header. +pub fn outbound_response_headers( + upstream: &HeaderMap, + rules: Option<&ResponseHeaderRules>, + error_source: &str, +) -> OagwResult { + let mut outbound = forwardable(upstream, &RESPONSE_OWNED); + if let Some(rules) = rules { + for name in &rules.remove { + if let Ok(parsed) = HeaderName::try_from(name.as_str()) { + outbound.remove(&parsed); + } + } + apply_entries(&mut outbound, &rules.set, false)?; + apply_entries(&mut outbound, &rules.add, true)?; + } + let marker = HeaderValue::try_from(error_source) + .map_err(|_| invalid_header("x-oagw-error-source", "value"))?; + outbound.insert(HeaderName::from_static("x-oagw-error-source"), marker); + Ok(outbound) +} + +/// Whether the upstream answered with a long-lived event stream. +/// +/// The exemption is decided from the **response**, not from the client's +/// `Accept`: a client that asks for JSON and is handed `text/event-stream` +/// must still get the stream, and a client that asks for an event stream and +/// is handed JSON must still be bounded. +#[must_use] +pub fn is_event_stream_content(content_type: Option<&HeaderValue>) -> bool { + content_type + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.to_ascii_lowercase().starts_with(EVENT_STREAM_TYPE)) +} + +/// Whether the request opens a WebSocket handshake (RFC 6455 §4.1). +/// +/// All four marks have to be there: a `GET`, the `upgrade` token of the +/// `Connection` header, the `websocket` token of the `Upgrade` header and a +/// `Sec-WebSocket-Key`. Without the key the request is an ordinary request that +/// happens to carry upgrade headers, and it keeps the ordinary path — where the +/// strip list takes both of them away again. +#[must_use] +pub fn is_websocket_handshake(method: &Method, headers: &HeaderMap) -> bool { + let key = http::header::HeaderName::from_static("sec-websocket-key"); + method == Method::GET + && carries(headers, http::header::CONNECTION, UPGRADE_TOKEN) + && carries(headers, http::header::UPGRADE, WEBSOCKET_TOKEN) + && headers.get(key).is_some_and(|value| !value.is_empty()) +} + +/// Whether any value of `name` carries `token` in its comma-separated list. +fn carries(headers: &HeaderMap, name: HeaderName, token: &str) -> bool { + headers + .get_all(name) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .any(|candidate| candidate.eq_ignore_ascii_case(token)) +} + +/// Why the upstream's answer does not accept the session (RFC 6455 §4.2.2). +/// +/// A 101 is an acceptance only with the two marks the specification asks of the +/// server: an `Upgrade` header whose token list contains `websocket` +/// (case-insensitive) and a non-empty `Sec-WebSocket-Accept`. hyper arms the +/// response upgrade from the status alone, so this head is the only place the +/// gateway can tell a real acceptance from a bare 101 — and a bare 101 hands +/// the client a socket whose first read is EOF, i.e. a session that carries +/// nothing. +/// +/// `None` when the session is accepted; otherwise the reason, which becomes +/// both the log record and the detail of the 502 the client is given. +#[must_use] +pub fn rejected_upgrade_reason(headers: &HeaderMap) -> Option<&'static str> { + if !carries(headers, http::header::UPGRADE, WEBSOCKET_TOKEN) { + return Some("the answer names no websocket upgrade"); + } + if headers + .get("sec-websocket-accept") + .is_none_or(http::HeaderValue::is_empty) + { + return Some("the answer carries no Sec-WebSocket-Accept"); + } + None +} + +/// Put the upgrade headers of a handshake back into the outbound set. +/// +/// Applied **twice** — after the strip list and the header rules, and again +/// right before the handshake is dialled — so neither a header rule nor a +/// plugin request phase can break the one protocol upgrade the gateway +/// forwards: a `remove` entry, a `passthrough: "none"` or a plugin's rewrite +/// cannot break a handshake, because the two headers it needs are not +/// configuration, they are the protocol. An ordinary request never reaches this +/// function, and keeps the strip list. +/// +/// The values written are canonical, never the client's: `connection: upgrade` +/// and `upgrade: websocket`, each a single value, and every other value of +/// either header is dropped with them. A token list of the client's own making +/// is a smuggling channel — `upgrade: websocket, h2c` would ship a complete h2c +/// upgrade through a WebSocket bridge, and a `Connection` naming headers that +/// were already stripped is the desync primitive. The headers `Connection` +/// names are therefore dropped here too, whatever a plugin put back. +/// +/// `Sec-WebSocket-Key`, `Sec-WebSocket-Version`, `Origin` and +/// `Sec-WebSocket-Protocol` are not touched: they are not hop-by-hop, and the +/// client's values of them are the protocol content of the handshake. +pub fn restore_upgrade_headers(outbound: &mut HeaderMap, inbound: &HeaderMap) { + for name in connection_named(inbound) { + outbound.remove(&name); + } + for (name, token) in [ + (http::header::CONNECTION, UPGRADE_TOKEN), + (http::header::UPGRADE, WEBSOCKET_TOKEN), + ] { + // `insert` replaces every value of the name, so no client token list + // survives the restore. + outbound.insert(name, HeaderValue::from_static(token)); + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashMap; + + use http::{HeaderMap, HeaderName, HeaderValue, Method}; + + use super::{ + is_event_stream_content, is_websocket_handshake, outbound_request_headers, + outbound_response_headers, rejected_upgrade_reason, restore_upgrade_headers, + }; + use crate::domain::model::{HeaderRules, ResponseHeaderRules}; + use crate::error::OagwErrorKind; + + /// A header map from lower-case static pairs; every name used here is a + /// valid `http` header name. + fn headers(pairs: &[(&'static str, &'static str)]) -> HeaderMap { + let mut headers = HeaderMap::new(); + for &(name, value) in pairs { + headers.append( + HeaderName::from_static(name), + HeaderValue::from_static(value), + ); + } + headers + } + + fn values<'a>(headers: &'a HeaderMap, name: &str) -> Vec<&'a str> { + headers + .get_all(name) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect() + } + + fn rules(passthrough: Option<&str>, set: &[(&str, &str)]) -> HeaderRules { + HeaderRules { + set: set + .iter() + .map(|(name, value)| ((*name).to_owned(), (*value).to_owned())) + .collect(), + add: HashMap::new(), + remove: Vec::new(), + passthrough: passthrough.map(str::to_owned), + passthrough_allowlist: Vec::new(), + } + } + + #[test] + fn strips_hop_by_hop_and_routing_headers() { + let inbound = headers(&[ + ("connection", "close"), + ("keep-alive", "timeout=5"), + ("te", "trailers"), + ("trailer", "x-checksum"), + ("transfer-encoding", "chunked"), + ("upgrade", "websocket"), + ("proxy-authorization", "basic"), + ("proxy-authenticate", "basic"), + ("proxy-connection", "keep-alive"), + ("host", "oagw.example"), + ("x-oagw-target-host", "us.vendor.com"), + ("content-length", "12"), + ("x-request-id", "abc"), + ]); + let outbound = outbound_request_headers(&inbound, None, 5).unwrap(); + assert_eq!(values(&outbound, "x-request-id"), ["abc"]); + assert_eq!(values(&outbound, "content-length"), ["5"]); + for name in [ + "connection", + "keep-alive", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "proxy-authorization", + "proxy-authenticate", + "proxy-connection", + "host", + "x-oagw-target-host", + ] { + assert!( + outbound.get(name).is_none(), + "{name} must not reach the upstream" + ); + } + } + + #[test] + fn keeps_multi_value_headers() { + let inbound = headers(&[("accept", "application/json"), ("accept", "text/plain")]); + let outbound = outbound_request_headers(&inbound, None, 0).unwrap(); + assert_eq!( + values(&outbound, "accept"), + ["application/json", "text/plain"] + ); + } + + /// A header `Connection` names is hop-by-hop for this hop, even when the + /// configuration would forward it (RFC 9110 §7.6.1). + #[test] + fn a_connection_named_header_is_not_forwarded() { + let mut inbound = headers(&[ + ("connection", "x-internal-actor, keep-alive"), + ("x-internal-actor", "admin"), + ("x-vendor", "1"), + ]); + inbound.append( + HeaderName::from_static("x-internal-actor"), + HeaderValue::from_static("second"), + ); + + let outbound = outbound_request_headers(&inbound, None, 0).unwrap(); + assert!(outbound.get("x-internal-actor").is_none()); + assert_eq!(values(&outbound, "x-vendor"), ["1"]); + } + + /// `passthrough: "none"` must not resuscitate a `Connection`-named header. + #[test] + fn a_connection_named_header_survives_no_passthrough_mode() { + let mut rules = rules(None, &[("x-a", "1")]); + rules.passthrough = Some("all".to_owned()); + let inbound = headers(&[ + ("connection", "x-internal-actor"), + ("x-internal-actor", "admin"), + ]); + let outbound = outbound_request_headers(&inbound, Some(&rules), 0).unwrap(); + assert!(outbound.get("x-internal-actor").is_none()); + } + + /// The response direction strips the headers the upstream's own + /// `Connection` header names. + #[test] + fn a_connection_named_response_header_is_dropped() { + let upstream = headers(&[ + ("connection", "x-hop"), + ("x-hop", "per-hop"), + ("x-body", "kept"), + ]); + let outbound = outbound_response_headers(&upstream, None, "upstream").unwrap(); + assert!(outbound.get("x-hop").is_none()); + assert_eq!(values(&outbound, "x-body"), ["kept"]); + } + + /// An unknown `passthrough` value is fail closed: nothing is forwarded. + #[test] + fn an_unknown_passthrough_mode_forwards_nothing() { + let mut unknown = rules(Some("sometimes"), &[]); + unknown.passthrough_allowlist = vec!["x-vendor".to_owned()]; + let inbound = headers(&[("x-vendor", "1")]); + let outbound = outbound_request_headers(&inbound, Some(&unknown), 0).unwrap(); + assert!(outbound.get("x-vendor").is_none()); + } + + #[test] + fn without_a_headers_object_everything_is_forwarded() { + let inbound = headers(&[("x-vendor", "1"), ("authorization", "bearer")]); + let outbound = outbound_request_headers(&inbound, None, 0).unwrap(); + assert_eq!(values(&outbound, "x-vendor"), ["1"]); + assert_eq!(values(&outbound, "authorization"), ["bearer"]); + } + + #[test] + fn passthrough_none_drops_every_inbound_header() { + let rules = rules(None, &[("x-a", "1")]); + let inbound = headers(&[("x-vendor", "1")]); + let outbound = outbound_request_headers(&inbound, Some(&rules), 0).unwrap(); + assert!(outbound.get("x-vendor").is_none()); + assert_eq!(values(&outbound, "x-a"), ["1"]); + } + + #[test] + fn passthrough_all_keeps_everything() { + let rules = rules(Some("all"), &[]); + let inbound = headers(&[("x-vendor", "1")]); + let outbound = outbound_request_headers(&inbound, Some(&rules), 0).unwrap(); + assert_eq!(values(&outbound, "x-vendor"), ["1"]); + } + + #[test] + fn passthrough_allowlist_is_exact() { + let mut allow = rules(Some("allowlist"), &[]); + allow.passthrough_allowlist = vec!["x-keep".to_owned()]; + let inbound = headers(&[("x-keep", "1"), ("x-drop", "2")]); + let outbound = outbound_request_headers(&inbound, Some(&allow), 0).unwrap(); + assert_eq!(values(&outbound, "x-keep"), ["1"]); + assert!(outbound.get("x-drop").is_none()); + } + + #[test] + fn remove_runs_before_the_passthrough_filter() { + let mut drop_only = rules(Some("all"), &[]); + drop_only.remove = vec!["x-drop".to_owned()]; + let inbound = headers(&[("x-drop", "1")]); + let outbound = outbound_request_headers(&inbound, Some(&drop_only), 0).unwrap(); + assert!(outbound.get("x-drop").is_none()); + } + + #[test] + fn set_replaces_and_add_appends() { + let mut combined = rules(Some("all"), &[("x-a", "set")]); + combined.add = [("x-b".to_owned(), "2".to_owned())].into_iter().collect(); + // Multi-value inbound headers survive, and `add` appends after them. + let inbound = headers(&[("x-a", "original"), ("x-b", "1"), ("x-b", "1b")]); + let outbound = outbound_request_headers(&inbound, Some(&combined), 0).unwrap(); + assert_eq!(values(&outbound, "x-a"), ["set"]); + assert_eq!(values(&outbound, "x-b"), ["1", "1b", "2"]); + } + + #[test] + fn rejects_an_invalid_configured_header() { + let rules = rules(Some("all"), &[("x bad", "1")]); + let error = outbound_request_headers(&HeaderMap::new(), Some(&rules), 0).unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::Validation); + assert_eq!(error.extensions().invalid_value.as_deref(), Some("x bad")); + } + + #[test] + fn response_rules_set_add_and_remove() { + let response_rules = ResponseHeaderRules { + set: [("x-set".to_owned(), "s".to_owned())].into_iter().collect(), + add: [("x-add".to_owned(), "a".to_owned())].into_iter().collect(), + remove: vec!["x-drop".to_owned()], + }; + let upstream = headers(&[("x-drop", "1"), ("server", "nginx")]); + let outbound = + outbound_response_headers(&upstream, Some(&response_rules), "upstream").unwrap(); + assert_eq!(values(&outbound, "x-set"), ["s"]); + assert_eq!(values(&outbound, "x-add"), ["a"]); + assert!(outbound.get("x-drop").is_none()); + assert_eq!(values(&outbound, "server"), ["nginx"]); + assert_eq!(values(&outbound, "x-oagw-error-source"), ["upstream"]); + } + + #[test] + fn every_response_carries_the_error_source_marker() { + let outbound = outbound_response_headers(&HeaderMap::new(), None, "upstream").unwrap(); + assert_eq!(values(&outbound, "x-oagw-error-source"), ["upstream"]); + } + + #[test] + fn a_forged_error_source_marker_is_replaced() { + let upstream = headers(&[("x-oagw-error-source", "gateway")]); + let outbound = outbound_response_headers(&upstream, None, "upstream").unwrap(); + assert_eq!(values(&outbound, "x-oagw-error-source"), ["upstream"]); + } + + #[test] + fn keeps_the_upstream_content_length() { + let upstream = headers(&[("content-length", "42")]); + let outbound = outbound_response_headers(&upstream, None, "upstream").unwrap(); + assert_eq!(values(&outbound, "content-length"), ["42"]); + } + + /// The streaming exemption follows the upstream `Content-Type`, not the + /// client's `Accept`. + #[test] + fn an_event_stream_is_recognised_from_the_response_content_type() { + assert!(is_event_stream_content(Some(&HeaderValue::from_static( + "text/event-stream" + )))); + assert!(is_event_stream_content(Some(&HeaderValue::from_static( + "Text/Event-Stream; charset=utf-8" + )))); + assert!(!is_event_stream_content(Some(&HeaderValue::from_static( + "application/json" + )))); + assert!(!is_event_stream_content(None)); + } + + #[test] + fn content_length_is_always_recomputed() { + let inbound = headers(&[("content-length", "9999")]); + let outbound = outbound_request_headers(&inbound, None, 3).unwrap(); + assert_eq!(values(&outbound, "content-length"), ["3"]); + } + + // ── WebSocket handshakes (DESIGN §3.2 header table, upgrade exemption) ── + + /// The handshake of RFC 6455 §4.1: a `GET` that carries the `upgrade` + /// connection token, the `websocket` upgrade token and a key. + fn handshake() -> (Method, HeaderMap) { + ( + Method::GET, + headers(&[ + ("connection", "Upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ("sec-websocket-version", "13"), + ]), + ) + } + + #[test] + fn a_websocket_handshake_is_recognised() { + let (method, request) = handshake(); + assert!(is_websocket_handshake(&method, &request)); + // The tokens are case-insensitive and the lists may carry more. + let tolerant = headers(&[ + ("connection", "keep-alive, Upgrade"), + ("upgrade", "WebSocket"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ("sec-websocket-version", "13"), + ]); + assert!(is_websocket_handshake(&method, &tolerant)); + } + + #[test] + fn a_non_get_is_not_a_handshake() { + let (_, request) = handshake(); + assert!(!is_websocket_handshake(&Method::POST, &request)); + assert!(!is_websocket_handshake(&Method::OPTIONS, &request)); + } + + #[test] + fn without_the_connection_token_there_is_no_handshake() { + let (method, mut request) = handshake(); + request.insert( + HeaderName::from_static("connection"), + HeaderValue::from_static("keep-alive"), + ); + assert!(!is_websocket_handshake(&method, &request)); + } + + #[test] + fn without_the_websocket_token_there_is_no_handshake() { + let (method, mut request) = handshake(); + request.insert( + HeaderName::from_static("upgrade"), + HeaderValue::from_static("h2c"), + ); + assert!(!is_websocket_handshake(&method, &request)); + } + + #[test] + fn without_the_key_there_is_no_handshake() { + let (method, mut request) = handshake(); + request.remove("sec-websocket-key"); + assert!(!is_websocket_handshake(&method, &request)); + // An empty key is as good as none. + request.insert( + HeaderName::from_static("sec-websocket-key"), + HeaderValue::from_static(""), + ); + assert!(!is_websocket_handshake(&method, &request)); + } + + /// The one exemption of the strip list: a handshake keeps the two headers + /// the upgrade needs, whatever the rules say. + #[test] + fn a_handshake_keeps_its_upgrade_headers() { + let (_, request) = handshake(); + let mut outbound = outbound_request_headers(&request, None, 0).unwrap(); + assert!(outbound.get("upgrade").is_none()); + assert!(outbound.get("connection").is_none()); + restore_upgrade_headers(&mut outbound, &request); + assert_eq!(values(&outbound, "upgrade"), ["websocket"]); + assert_eq!(values(&outbound, "connection"), ["upgrade"]); + // The rest of the handshake flows through the ordinary rules. + assert_eq!( + values(&outbound, "sec-websocket-key"), + ["dGhlIHNhbXBsZSBub25jZQ=="] + ); + } + + /// The values written are canonical: a token list of the client's own + /// making is a smuggling channel, so it never reaches the upstream. + #[test] + fn a_handshake_writes_canonical_upgrade_values() { + let request = headers(&[ + ("connection", "upgrade, HTTP2-Settings, x-internal-actor"), + ("upgrade", "websocket, h2c"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ]); + let mut outbound = outbound_request_headers(&request, None, 0).unwrap(); + // A plugin phase may have put either header back, in the client's own + // words. + outbound.insert("upgrade", HeaderValue::from_static("websocket, h2c")); + outbound.append("connection", HeaderValue::from_static("keep-alive")); + outbound.insert("http2-settings", HeaderValue::from_static("AAMAAABk")); + outbound.insert("x-internal-actor", HeaderValue::from_static("root")); + restore_upgrade_headers(&mut outbound, &request); + assert_eq!(values(&outbound, "upgrade"), ["websocket"]); + assert_eq!(values(&outbound, "connection"), ["upgrade"]); + assert!(outbound.get("http2-settings").is_none()); + assert!(outbound.get("x-internal-actor").is_none()); + // Not hop-by-hop: the client's values are the protocol content. + assert_eq!( + values(&outbound, "sec-websocket-key"), + ["dGhlIHNhbXBsZSBub25jZQ=="] + ); + } + + /// Even a configuration that removes the upgrade headers cannot break the + /// handshake: the exemption is applied after the rules. + #[test] + fn the_upgrade_exemption_survives_the_header_rules() { + let (_, request) = handshake(); + let mut dropping = rules(Some("all"), &[]); + dropping.remove = vec!["upgrade".to_owned(), "connection".to_owned()]; + let mut outbound = outbound_request_headers(&request, Some(&dropping), 0).unwrap(); + assert!(outbound.get("upgrade").is_none()); + restore_upgrade_headers(&mut outbound, &request); + assert_eq!(values(&outbound, "upgrade"), ["websocket"]); + assert_eq!(values(&outbound, "connection"), ["upgrade"]); + } + + /// An ordinary request that carries the same headers is not exempted: the + /// predicate does not fire, so the caller never lifts the strip list. + #[test] + fn an_ordinary_request_gets_no_upgrade_exemption() { + let request = headers(&[ + ("connection", "Upgrade, HTTP2-Settings"), + ("upgrade", "h2c"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ]); + assert!(!is_websocket_handshake(&Method::GET, &request)); + let outbound = outbound_request_headers(&request, None, 0).unwrap(); + assert!(outbound.get("upgrade").is_none()); + assert!(outbound.get("connection").is_none()); + } + + /// The acceptance of RFC 6455 §4.2.2: the token, in any case, and a + /// non-empty accept value. + #[test] + fn a_101_that_accepts_the_session_names_the_protocol() { + let answer = headers(&[ + ("upgrade", "WebSocket"), + ("sec-websocket-accept", "s3pPLMBiTxaQ9kYGzzhZRbK+xOoY="), + ]); + assert_eq!(rejected_upgrade_reason(&answer), None); + // The token may sit in a list of its own protocols. + let listed = headers(&[ + ("upgrade", "h2c, websocket"), + ("sec-websocket-accept", "s3pPLMBiTxaQ9kYGzzhZRbK+xOoY="), + ]); + assert_eq!(rejected_upgrade_reason(&listed), None); + } + + #[test] + fn a_101_that_accepts_nothing_is_named_as_such() { + // No `Upgrade` at all: the status switched, the protocol did not. + let bare = headers(&[("connection", "upgrade")]); + assert_eq!( + rejected_upgrade_reason(&bare), + Some("the answer names no websocket upgrade") + ); + // The token without the accept value is still no acceptance. + let accepted_nothing = headers(&[("upgrade", "websocket")]); + assert_eq!( + rejected_upgrade_reason(&accepted_nothing), + Some("the answer carries no Sec-WebSocket-Accept") + ); + let empty = headers(&[("upgrade", "websocket"), ("sec-websocket-accept", "")]); + assert_eq!( + rejected_upgrade_reason(&empty), + Some("the answer carries no Sec-WebSocket-Accept") + ); + } +} diff --git a/gears/system/oagw/oagw/src/domain/proxy/mod.rs b/gears/system/oagw/oagw/src/domain/proxy/mod.rs new file mode 100644 index 0000000..61bbc5d --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/proxy/mod.rs @@ -0,0 +1,60 @@ +// Created: 2026-08-31 by Constructor Tech +//! Proxy data plane (DESIGN §3.2, §3.5; ADR-0001, ADR-0007). +//! +//! The module is split so that every routing decision is testable without a +//! network: +//! +//! * [`routing`] — alias → upstream, route selection, upstream URL, target +//! endpoint selection. Pure functions over the domain model. +//! * [`breaker`] — the per-upstream circuit breaker of PRD +//! `cpt-cf-oagw-nfr-high-availability`: whether a request may be dialled at +//! all. +//! * [`headers`] — request and response header transformation. Pure functions +//! over `http` types. +//! * [`chain`] — tenant-chain resolution behind a narrow seam. +//! * [`ratelimit`] — the token buckets of ADR-0003 and the effective limit of +//! one request. +//! * [`cors`] — the built-in CORS handler of ADR-0004: preflight detection, +//! origin and method enforcement, response headers. +//! * [`plugins`] — the plugin chain of one request: composition, resolution and +//! the phase runners of ADR-0002. +//! * [`service`] — the orchestrating [`service::ProxyService`], owning the one +//! outbound `toolkit_http::HttpClient` and the round-robin counters. +//! +//! # Deviation from the upstream JSON schema +//! +//! `upstream.v1` declares `"passthrough": "none"` as the default of +//! `headers.request`. OAGW follows the schema literally: a `headers.request` +//! object that omits `passthrough` forwards **no** inbound header that is not +//! produced by `set` / `add`. Only a route without a `headers` object at all +//! gets the transparent behaviour of DESIGN §3.2 "Headers Transformation" +//! (forward everything except the hop-by-hop and routing headers). +//! +//! # Known limits of the enforcement +//! +//! * **Unmetered preflights (ADR-0004).** A preflight is answered from the +//! policy alone and never walks the quota, so a preflight flood reaches the +//! gateway for free. ADR-0004 leaves preflight metering to the edge controls +//! this deployment does not have; see [`cors`]. +//! * **`ip` is only as honest as its proxy.** The first `x-forwarded-for` hop +//! is an attacker-supplied value; it is read as an address and length-capped, +//! and the counters of the unreadable ones share one `unknown` bucket. Behind +//! a proxy that overwrites the chain it is per client; anywhere else it is a +//! hint. See [`ratelimit`]. +//! * **Bounded quota memory.** The counter map is capped; when it is full the +//! idle buckets are swept and the surplus callers share one counter until +//! room returns, so an admitted request is never turned away for lack of +//! memory. See [`ratelimit`]. +//! * **Queue fairness is per key.** A `queue` request takes a per-key gate +//! before it waits, so a woken waiter claims its token before later arrivals, +//! but nothing orders two waiters of the same key beyond that. See +//! [`ratelimit`]. + +pub mod breaker; +pub mod chain; +pub mod cors; +pub mod headers; +pub mod plugins; +pub mod ratelimit; +pub mod routing; +pub mod service; diff --git a/gears/system/oagw/oagw/src/domain/proxy/plugins.rs b/gears/system/oagw/oagw/src/domain/proxy/plugins.rs new file mode 100644 index 0000000..47cd318 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/proxy/plugins.rs @@ -0,0 +1,477 @@ +// Created: 2026-08-31 by Constructor Tech +//! The plugin chain of one proxied request (DESIGN §3.2 "Plugin System"). +//! +//! # Composition (documented choice) +//! +//! The upstream is the ancestor configuration level, the route the descendant +//! one. DESIGN §3.2 fixes both the composition and the order of the two: +//! "upstream plugins execute before route plugins +//! (`[U1, U2] + [R1, R2] => [U1, U2, R1, R2]`)", so the effective chain is the +//! **concatenation** of the two, in that order: +//! +//! | Upstream `plugins.sharing` | Route declares a chain | Effective chain | +//! |---|---|---| +//! | any | no | the upstream chain | +//! | any | yes | upstream chain + route chain, in this order | +//! +//! The upstream `plugins.sharing` mode is **not** given the power to let a route +//! drop an upstream binding, which is a deliberate reading of the two +//! references. `SharingMode` (PRD §5.5) governs visibility across the *tenant* +//! hierarchy — the ancestor an upstream is inherited from — and the data plane +//! already applies it there. Reading it as "a descendant may override" would let +//! a route silently switch off an upstream guard, which is the one direction +//! this slice never opens: an upstream that requires a signed request must not +//! lose that requirement because a route bound its own guard. +//! +//! # Resolution +//! +//! A chain is resolved **per request**, against the live registries. A reference +//! that resolves to no implementation is a 503 `plugin.not_found.v1`, never a +//! silent skip: a chain that cannot be enforced in full must not run in part. +//! +//! # Order +//! +//! `AuthPlugin` → guards → `TransformPlugin::transform_request` → upstream call +//! → `TransformPlugin::transform_response` on success, `transform_error` on a +//! gateway failure (DESIGN §3.2 "Execution Order"). + +use std::sync::Arc; + +use crate::domain::model::{PluginBinding, PluginsConfig, Route, Upstream}; +use crate::domain::plugin::PluginRef; +use crate::error::OagwResult; +use crate::infra::plugin::PluginRegistries; +use crate::infra::plugin::registry::{unresolved_auth_plugin, unresolved_plugin}; +use crate::infra::plugin::traits::{ + AuthPlugin, ErrorContext, GuardDecision, GuardPlugin, PluginConfig, RequestContext, + ResponseContext, TransformPlugin, UpstreamRef, +}; + +/// The resolved chain of one request. +/// +/// Every entry carries the configuration it was bound with; a plugin only ever +/// sees its own. +pub struct PluginChain { + /// Auth binding of the upstream; at most one. + pub auth: Option, + /// Guard plugins, upstream before route. + pub guards: Vec<(Arc, PluginConfig)>, + /// Transform plugins, upstream before route. + pub transforms: Vec<(Arc, PluginConfig)>, +} + +/// The auth plugin of one request plus its configuration. +pub struct AuthBinding { + /// The plugin. + pub plugin: Arc, + /// Configuration from the `auth` binding. + pub config: PluginConfig, +} + +impl PluginChain { + /// Whether the chain carries no plugin at all. + #[must_use] + pub fn is_empty(&self) -> bool { + self.auth.is_none() && self.guards.is_empty() && self.transforms.is_empty() + } +} + +/// Compose the upstream and the route chains (see the module docs). +#[must_use] +pub fn merge_chains( + upstream: Option<&PluginsConfig>, + route: Option<&PluginsConfig>, +) -> Vec { + let Some(upstream) = upstream else { + return route.map_or_else(Vec::new, |chain| chain.items.clone()); + }; + let Some(route) = route else { + return upstream.items.clone(); + }; + let mut merged = upstream.items.clone(); + merged.extend(route.items.iter().cloned()); + merged +} + +/// Resolve the chain of one request against the live registries. +/// +/// # Errors +/// 503 `plugin.not_found.v1` for a chain reference that resolves to no +/// implementation, 503 `link.unavailable.v1` for an auth binding this +/// deployment cannot honour. +pub fn resolve_chain( + upstream: &Upstream, + route: Option<&Route>, + registries: &PluginRegistries, + store: &dyn crate::domain::store::Store, +) -> OagwResult { + let tenant_id = upstream.tenant_id; + let auth = match upstream.auth.as_ref() { + Some(binding) => { + // A present `auth` member is a promise: the upstream is only + // dialled with its credentials. A `type` that names nothing (and a + // missing plugin, below) is a link that is not available, never a + // silent forward without credentials. + let Some(reference) = binding + .plugin_type + .as_deref() + .filter(|reference| !reference.trim().is_empty()) + else { + return Err(unresolved_auth_plugin("type")); + }; + Some(AuthBinding { + plugin: registries + .auth() + .get(reference) + .ok_or_else(|| unresolvable_auth(reference))?, + config: auth_plugin_config(&binding.raw), + }) + } + None => None, + }; + let bindings = merge_chains( + upstream.plugins.as_ref(), + route.and_then(|r| r.plugins.as_ref()), + ); + let mut guards = Vec::new(); + let mut transforms = Vec::new(); + for binding in bindings { + let reference = binding.reference().to_owned(); + let config = binding + .config() + .map_or_else(PluginConfig::empty, |map| PluginConfig::new(map.clone())); + match resolve_reference(&reference, tenant_id, registries, store)? { + Resolved::Guard(plugin) => guards.push((plugin, config)), + Resolved::Transform(plugin) => transforms.push((plugin, config)), + } + } + Ok(PluginChain { + auth, + guards, + transforms, + }) +} + +/// Configuration of an auth binding for its plugin. +/// +/// The upstream schema nests the plugin members under `config` +/// (ADR-0008 "Upstream Configuration Example"); a flat binding +/// (`"auth": { "type": …, "key_ref": … }`) is accepted as well, which is what +/// the write path has always validated. +#[must_use] +pub fn auth_plugin_config(raw: &serde_json::Map) -> PluginConfig { + match raw.get("config") { + Some(serde_json::Value::Object(nested)) => PluginConfig::new(nested.clone()), + _ => PluginConfig::new(raw.clone()), + } +} + +/// One resolved chain entry. +enum Resolved { + Guard(Arc), + Transform(Arc), +} + +/// Resolve one chain reference, failing closed. +fn resolve_reference( + reference: &str, + tenant_id: uuid::Uuid, + registries: &PluginRegistries, + store: &dyn crate::domain::store::Store, +) -> OagwResult { + match PluginRef::parse(reference) { + PluginRef::BuiltIn { kind, .. } => resolve_built_in(kind, reference, registries), + PluginRef::Custom { .. } => resolve_custom(reference, tenant_id, registries, store), + // Neither a GTS id nor a UUID: the only remaining legal spelling is a + // bare built-in name (`apikey`), which the catalog classifies. + PluginRef::Unrecognised(_) => { + resolve_short_name(reference, registries).ok_or_else(|| unresolved_plugin(reference)) + } + } +} + +/// Resolve a bare built-in name against the family registry the catalog puts +/// it in. +fn resolve_short_name(reference: &str, registries: &PluginRegistries) -> Option { + let plugin = crate::domain::plugin::lookup_built_in_by_name(reference) + .filter(|built_in| built_in.resolvable)?; + match plugin.kind { + crate::domain::model::PluginKind::Guard => { + registries.guard().get(reference).map(Resolved::Guard) + } + crate::domain::model::PluginKind::Transform => registries + .transform() + .get(reference) + .map(Resolved::Transform), + // An auth plugin is never bound through the chain: `upstream.auth` + // carries it. + crate::domain::model::PluginKind::Auth => None, + } +} + +/// Resolve a built-in reference against its family registry. +fn resolve_built_in( + kind: crate::domain::model::PluginKind, + reference: &str, + registries: &PluginRegistries, +) -> OagwResult { + let resolved = match kind { + crate::domain::model::PluginKind::Guard => { + registries.guard().get(reference).map(Resolved::Guard) + } + crate::domain::model::PluginKind::Transform => registries + .transform() + .get(reference) + .map(Resolved::Transform), + // An auth plugin is never bound through the chain: `upstream.auth` + // carries it. + crate::domain::model::PluginKind::Auth => None, + }; + resolved.ok_or_else(|| unresolved_plugin(reference)) +} + +/// Resolve a custom reference, which must exist and must have an +/// implementation. +/// +/// Custom plugins are Starlark records; this slice ships no interpreter, so a +/// record that exists is still not executable and fails closed with the same +/// 503 an unknown reference gets (see the module docs). +fn resolve_custom( + reference: &str, + tenant_id: uuid::Uuid, + registries: &PluginRegistries, + store: &dyn crate::domain::store::Store, +) -> OagwResult { + let parsed = PluginRef::parse(reference); + let Some(id) = parsed.custom_id() else { + return Err(unresolved_plugin(reference)); + }; + let Some(record) = store.get_plugin(tenant_id, id)? else { + return Err(unresolved_plugin(reference)); + }; + let resolved = match record.kind { + crate::domain::model::PluginKind::Guard => registries + .guard() + .get(&record.gts_id()) + .map(Resolved::Guard), + crate::domain::model::PluginKind::Transform => registries + .transform() + .get(&record.gts_id()) + .map(Resolved::Transform), + crate::domain::model::PluginKind::Auth => None, + }; + resolved.ok_or_else(|| unresolved_plugin(reference)) +} + +/// 503 for an auth binding the data plane cannot honour. +/// +/// A catalogued built-in without an implementation (`basic`, `bearer`) is a +/// binding that cannot exist at all; a *resolvable* built-in that is missing +/// from the registry means the credential store is not wired — either way the +/// request is not forwarded without its credentials. +fn unresolvable_auth(reference: &str) -> crate::error::OagwError { + if crate::domain::plugin::is_unbindable_built_in(reference) { + return unresolved_plugin(reference); + } + unresolved_auth_plugin(reference) +} + +/// Run the request-side half of the chain (DESIGN §3.2 "Execution Order"). +/// +/// # Errors +/// A guard rejection, or a failure of the auth or transform phase. +pub async fn run_request_phase(chain: &PluginChain, ctx: &mut RequestContext) -> OagwResult<()> { + if let Some(binding) = &chain.auth { + ctx.config = binding.config.clone(); + binding.plugin.authenticate(ctx).await?; + } + for (guard, config) in &chain.guards { + ctx.config = config.clone(); + if let GuardDecision::Reject(rejection) = guard.guard_request(ctx).await? { + return Err(rejection.into_error()); + } + } + for (transform, config) in &chain.transforms { + ctx.config = config.clone(); + transform.transform_request(ctx).await?; + } + Ok(()) +} + +/// Run the response-side half of the chain. +/// +/// # Errors +/// A guard rejection or a transform failure, while the upstream head is still +/// uncommitted — a rejection here is a 502 the client actually sees. +pub async fn run_response_phase(chain: &PluginChain, ctx: &mut ResponseContext) -> OagwResult<()> { + for (guard, config) in &chain.guards { + ctx.config = config.clone(); + if let GuardDecision::Reject(rejection) = guard.guard_response(ctx).await? { + return Err(rejection.into_error()); + } + } + for (transform, config) in &chain.transforms { + ctx.config = config.clone(); + transform.transform_response(ctx).await?; + } + Ok(()) +} + +/// Run the error-side half of the chain over the failure the gateway is about +/// to render. +/// +/// A transform may enrich the **extensions** of the problem (a correlation id, +/// a `Retry-After` hint, the plugin it relates to); the classification and the +/// detail the gateway decided on are restored afterwards, so the status and the +/// problem type are never a plugin's to change, and a failing transform never +/// replaces the client's failure with its own — the original one is reported. +pub async fn run_error_phase(chain: &PluginChain, ctx: &mut ErrorContext) { + let decided = ctx.error.clone(); + for (transform, config) in &chain.transforms { + ctx.config = config.clone(); + if transform.transform_error(ctx).await.is_err() { + tracing::warn!( + plugin = transform.id(), + "error-phase transform failed; the gateway problem is reported unchanged" + ); + break; + } + } + let transformed = std::mem::replace(&mut ctx.error, decided.clone()); + let extensions = transformed.extensions().clone(); + ctx.error = + decided.with_extension(move |extensions_of_error| *extensions_of_error = extensions); +} + +/// Build the per-request identity reference. +#[must_use] +pub fn upstream_ref(id: uuid::Uuid, alias: &str) -> UpstreamRef { + UpstreamRef { + id, + alias: alias.to_owned(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::error::{OagwError, OagwErrorKind}; + + /// A transform that records the error it saw and then rewrites it. + struct Recording { + seen: std::sync::Arc>>, + fail: bool, + } + + #[async_trait::async_trait] + impl TransformPlugin for Recording { + fn id(&self) -> &'static str { + "recording" + } + + fn plugin_type(&self) -> &'static str { + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.recording.v1" + } + + async fn transform_request(&self, _ctx: &mut RequestContext) -> Result<(), OagwError> { + Ok(()) + } + + async fn transform_response(&self, _ctx: &mut ResponseContext) -> Result<(), OagwError> { + Ok(()) + } + + async fn transform_error(&self, ctx: &mut ErrorContext) -> Result<(), OagwError> { + if let Ok(mut seen) = self.seen.lock() { + seen.push(ctx.error.detail().to_owned()); + } + if self.fail { + return Err(OagwError::new( + OagwErrorKind::Internal, + "the transform failed", + )); + } + ctx.error = OagwError::new(OagwErrorKind::AliasConflict, "the plugin decided") + .with_extension(|extensions| { + extensions.invalid_value = Some("from-the-plugin".to_owned()); + }); + Ok(()) + } + } + + fn context(detail: &str) -> ErrorContext { + // The builder can only fail on missing ids; both are set. + let security = toolkit_security::SecurityContext::builder() + .subject_id(uuid::Uuid::now_v7()) + .subject_tenant_id(uuid::Uuid::now_v7()) + .build() + .unwrap_or_else(|_| toolkit_security::SecurityContext::anonymous()); + ErrorContext { + security, + upstream: upstream_ref(uuid::Uuid::now_v7(), "api.vendor.com"), + error: OagwError::new(OagwErrorKind::UnknownTargetHost, detail), + config: PluginConfig::empty(), + } + } + + fn chain_of(transform: Arc) -> PluginChain { + PluginChain { + auth: None, + guards: Vec::new(), + transforms: Vec::from([(transform, PluginConfig::empty())]), + } + } + + /// What a `Recording` observed, shared with the test. + type Seen = std::sync::Arc>>; + + fn recording(fail: bool) -> (Arc, Seen) { + let seen = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let plugin = Arc::new(Recording { + seen: std::sync::Arc::clone(&seen), + fail, + }); + (plugin, seen) + } + + #[tokio::test] + async fn the_error_phase_sees_the_gateway_failure() { + let (plugin, seen) = recording(false); + let mut ctx = context("assembled upstream URL is not a valid URL"); + + run_error_phase(&chain_of(plugin), &mut ctx).await; + + // The hook observed the failure the gateway decided on … + assert_eq!( + seen.lock().map_or_else(|_| Vec::new(), |seen| seen.clone()), + Vec::from(["assembled upstream URL is not a valid URL".to_owned()]) + ); + // … but the classification is still the gateway's, and the extension + // the hook set reached the problem document. + assert_eq!(*ctx.error.kind(), OagwErrorKind::UnknownTargetHost); + assert_eq!( + ctx.error.detail(), + "assembled upstream URL is not a valid URL" + ); + assert_eq!( + ctx.error.extensions().invalid_value.as_deref(), + Some("from-the-plugin") + ); + } + + #[tokio::test] + async fn a_failing_transform_never_replaces_the_gateway_failure() { + let (plugin, seen) = recording(true); + let mut ctx = context("the credential store is unavailable"); + + run_error_phase(&chain_of(plugin), &mut ctx).await; + + assert_eq!( + seen.lock().map_or_else(|_| Vec::new(), |seen| seen.clone()), + Vec::from(["the credential store is unavailable".to_owned()]) + ); + assert_eq!(*ctx.error.kind(), OagwErrorKind::UnknownTargetHost); + assert_eq!(ctx.error.detail(), "the credential store is unavailable"); + assert_eq!(ctx.error.extensions().invalid_value, None); + } +} diff --git a/gears/system/oagw/oagw/src/domain/proxy/ratelimit.rs b/gears/system/oagw/oagw/src/domain/proxy/ratelimit.rs new file mode 100644 index 0000000..22ea0e8 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/proxy/ratelimit.rs @@ -0,0 +1,1202 @@ +// Created: 2026-08-31 by Constructor Tech +//! Token-bucket rate limiting of the proxy data plane (ADR-0003). +//! +//! # Algorithm +//! +//! The bucket is the one ADR-0003 "Implementation Notes" spells out: `capacity` +//! tokens, refilled continuously at `refill_rate = sustained.rate / +//! window_seconds`. A request acquires `cost` tokens; an empty bucket leaves +//! the decision to the configured `strategy`. +//! +//! # Counter key +//! +//! `{upstream_id}:{scope_id}:{fingerprint}`, where `scope_id` comes from the +//! configured `scope` and the fingerprint from the effective limit. The +//! upstream id prefix is what makes ADR-0003's prefix-based cleanup a `retain` +//! over this map when a record is deleted; the fingerprint is what makes a +//! changed policy a fresh bucket instead of a budget the old limit spent. +//! +//! | scope | `scope_id` | +//! |---|---| +//! | `global` | the literal `global`: one counter per upstream | +//! | `tenant` | the id of the **calling** tenant | +//! | `user` | the calling subject id | +//! | `ip` | the first `X-Forwarded-For` hop, when it parses as an address | +//! | `route` | the matched route id | +//! +//! `tenant` is keyed on the caller, not on the tenant the upstream record +//! belongs to: a shared (ancestor-owned) upstream has to give every caller its +//! own budget, and `global` is the one counter the whole upstream shares. +//! +//! `ip` is the weakest scope and ADR-0003 says so in as many words: the +//! platform hands the gear no connection peer address, so the forwarded chain +//! is the only client identity available, and it is only meaningful behind a +//! proxy that *overwrites* the chain it received. A hop that does not parse as +//! an address, or that is longer than the longest IPv6 literal, shares the +//! `unknown` counter — which is what stops a rotated header from minting fresh +//! buckets. +//! +//! # Bound +//! +//! The bucket map is capped at [`MAX_BUCKETS`] entries. When a request needs a +//! bucket that does not exist yet and the map is at the ceiling, the buckets no +//! request has touched for a window are swept — an idle bucket is full again, +//! so forgetting it loses nothing but the memory it holds — and a request that +//! still finds no room shares the one `shared-overflow` counter. That keeps a +//! flood of distinct addresses from turning the map into an allocation +//! primitive, at the price of a shared budget for whoever arrives while the map +//! is full. +//! +//! # Distribution +//! +//! ADR-0003 "Distribution" picks hybrid local + periodic sync for the +//! distributed deployment and names the per-instance token bucket as the MVP. +//! These buckets are that MVP: per-instance, no sync. +//! +//! # Strategy +//! +//! * `reject` — 429 immediately (the ADR default). +//! * `queue` — await a token, bounded by the response-head budget, which is +//! also what the dial gets. A queued request that outlives the budget falls +//! through to the 429, so an exhausted bucket never stretches a request +//! past the budget the client already agreed to. +//! * `degrade` — serve anyway. Documented choice: "degrade" means serving in a +//! degraded mode, never blocking; the request is still scored and reported. +//! +//! # Queue fairness +//! +//! A queued request holds a per-key gate for the whole of its wait, so a waiter +//! that wakes when its token is back scores before the requests that arrived +//! behind it; the gate is taken before the loop and released after it, and no +//! `DashMap` shard guard is ever held across an `await`. The other two +//! strategies take no gate, so a `reject` arrival can still claim a token a +//! waiter was about to take: the guarantee is FIFO-ish, not strict, and the +//! head budget bounds every wait either way. +//! +//! # Inheritance +//! +//! Levels are read descendant→ancestor — `[route, resolved_upstream, nearest +//! ancestor, …]` — and the first level that declares a policy is the *selected* +//! one. An ancestor's `enforce` cap stays active however the descendant shares +//! (DESIGN: "ancestor constraints with `sharing: enforce` remain active"), so +//! the participating levels are `selected` plus every ancestor that declares +//! `enforce`, plus every ancestor policy at all when `selected` declares +//! `inherit`. No level declares → no enforcement. The effective rate and the +//! capacity are the min over the participating levels, the rate compared **per +//! second** so that a minute and a second can be minned; the recomposed rate is +//! expressed in `selected`'s window. Scope, strategy, cost and the response +//! header switch are properties of the level that asks for the limit and come +//! from `selected` alone. + +use std::net::IpAddr; +use std::sync::Arc; +use std::time::Duration; + +use dashmap::DashMap; +use http::HeaderValue; +use tokio::sync::Mutex; +use tokio::time::Instant; +use uuid::Uuid; + +use crate::domain::model::{RateLimitConfig, SharingMode}; +use crate::error::{OagwError, OagwErrorKind}; + +/// Largest window length in seconds the write path accepts (`day`). +/// +/// Also the idle age at which a bucket is swept: a bucket that has not been +/// touched for the largest window the schema allows is full again. +const MAX_WINDOW_SECS: u64 = 24 * 60 * 60; + +/// Ceiling on the number of live buckets. +/// +/// A counter key is minted per caller identity, and the `ip` scope takes its +/// identity from a header, so the map has to have a ceiling: an unbounded map +/// would be an allocation primitive a client could drive. +const MAX_BUCKETS: usize = 65_536; + +/// Longest `X-Forwarded-For` hop that is considered an address. +/// +/// The longest IPv6 literal is 45 characters; anything longer is not one. +const MAX_FORWARDED_LEN: usize = 45; + +/// Counter the requests that find no room in the bounded map share. +const OVERFLOW_KEY: &str = "shared-overflow"; + +/// Window length in seconds for one `sustained.window` unit. +/// +/// A blank window is the schema default (`second`), which the model +/// materialises as an empty string. Any other value is one the write path +/// rejects (`validate_rate_limit` allows only `second`, `minute`, `hour` and +/// `day`), and the strictest reading of an unreadable window is the shortest +/// one: a policy of ten thousand per week becomes ten thousand per second, +/// never the other way round. +#[must_use] +pub fn window_seconds(window: &str) -> u64 { + match window { + "minute" => 60, + "hour" => 60 * 60, + "day" => 24 * 60 * 60, + _ => 1, + } +} + +/// The effective limit a bucket is filled from. +#[derive(Debug, Clone, PartialEq)] +pub struct Limit { + /// Effective sustained rate, tokens per [`Limit::window`]. + pub rate: u64, + /// Sustained window as configured (`second` for the schema default). + pub window: String, + /// Effective bucket capacity; defaults to the sustained rate. + pub capacity: u64, + /// Counter scope of the most specific configuration. + pub scope: String, + /// Behaviour when the bucket is empty. + pub strategy: Strategy, + /// Tokens one request consumes. + pub cost: u64, + /// Whether the `X-RateLimit-*` headers are emitted. + pub response_headers: bool, +} + +impl Limit { + /// Tokens replenished per second. + #[must_use] + pub fn refill_rate(&self) -> f64 { + as_float(self.rate) / as_float(self.window_seconds().max(1)) + } + + /// Length of the sustained window in seconds. + #[must_use] + pub fn window_seconds(&self) -> u64 { + window_seconds(&self.window) + } + + /// Human-readable window, for the problem detail. + fn window_label(&self) -> &str { + match self.window.as_str() { + "minute" | "hour" | "day" => self.window.as_str(), + _ => "second", + } + } + + /// Identity of the policy a bucket was created under. + /// + /// The counter key carries it, so a PUT that changes any member of the + /// effective policy starts a fresh bucket, while a PUT that changes + /// nothing leaves the callers the budget they already spent. The sharing + /// mode is deliberately not part of it: sharing decides *which* + /// configurations contribute to the limit, not how a bucket is filled. + fn fingerprint(&self) -> String { + format!( + "{:x}", + one_hash(&( + self.rate, + self.window.clone(), + self.capacity, + self.scope.clone(), + self.strategy.as_str(), + self.cost + )) + ) + } +} + +/// Hash one value with the standard hasher, as a `u64`. +/// +/// The fingerprint has to change when the policy changes and stay put when it +/// does not. It never leaves the process, so the hash needs no stability +/// across releases — only within one. +fn one_hash(value: &T) -> u64 { + let mut hasher = std::hash::DefaultHasher::new(); + std::hash::Hash::hash(value, &mut hasher); + std::hash::Hasher::finish(&hasher) +} + +/// Behaviour when the bucket is empty (ADR-0003 "Configuration"). +/// +/// A value the write path does not know is read as the strictest one: an +/// unknown strategy rejects, an unknown scope counts per calling tenant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Strategy { + /// 429 when the bucket is empty. + Reject, + /// Await a token, bounded by the response-head budget. + Queue, + /// Forward anyway, in a degraded mode. + Degrade, +} + +impl Strategy { + /// Parse a configured strategy. + #[must_use] + pub fn parse(value: &str) -> Self { + match value { + "queue" => Strategy::Queue, + "degrade" => Strategy::Degrade, + // `reject` is the documented default and the strictest reading of + // a value this deployment does not know. + _ => Strategy::Reject, + } + } + + /// Canonical spelling, for the bucket fingerprint. + fn as_str(self) -> &'static str { + match self { + Strategy::Reject => "reject", + Strategy::Queue => "queue", + Strategy::Degrade => "degrade", + } + } +} + +/// One counter of the data plane. +/// +/// The struct is ADR-0003 "Token Bucket Algorithm" verbatim; `refill` is the +/// only thing that moves it forward in time, and every read goes through it, so +/// a bucket that idled for an hour is as full as it should be. +#[derive(Debug)] +pub struct TokenBucket { + /// Tokens currently in the bucket. + tokens: f64, + /// Instant the tokens were last topped up from. + last_update: Instant, + /// Bucket size. + capacity: f64, + /// Tokens replenished per second. + refill_rate: f64, +} + +impl TokenBucket { + /// A full bucket for `limit`. + #[must_use] + pub fn new(limit: &Limit) -> Self { + Self { + tokens: as_float(limit.capacity), + last_update: Instant::now(), + capacity: as_float(limit.capacity), + refill_rate: limit.refill_rate(), + } + } + + /// Top the bucket up for the time that passed since the last refill. + fn refill(&mut self) { + let now = Instant::now(); + let elapsed = now.duration_since(self.last_update).as_secs_f64(); + self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.capacity); + self.last_update = now; + } + + /// Take `cost` tokens, refilling first. + /// + /// Returns whether the request was admitted, the tokens the bucket holds + /// afterwards and the seconds until the next token is back. + fn try_acquire(&mut self, cost: f64) -> (bool, f64, f64) { + self.refill(); + if self.tokens >= cost { + self.tokens -= cost; + return (true, self.tokens, 0.0); + } + let missing = cost - self.tokens; + let wait = missing / self.refill_rate; + (false, self.tokens, wait) + } + + /// Seconds until the bucket is full again. + fn seconds_to_full(&self) -> f64 { + if self.refill_rate <= 0.0 { + return 0.0; + } + (self.capacity - self.tokens) / self.refill_rate + } + + /// Time since the last request scored against this bucket. + fn age(&self) -> Duration { + self.last_update.elapsed() + } +} + +/// Outcome of scoring one request against a bucket. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct Outcome { + /// Whether the request was admitted. + pub acquired: bool, + /// The effective sustained rate, for `X-RateLimit-Limit`. + pub limit: u64, + /// Tokens left, floored. + pub remaining: u64, + /// Epoch seconds when the bucket is full again, for `X-RateLimit-Reset`. + pub reset: u64, + /// Seconds until the next token, at the least 1 (RFC 6585 asks for + /// seconds), for `Retry-After`. + pub retry_after: u64, + /// Unrounded seconds until the next token, for the queue strategy. + pub retry_in: f64, +} + +impl Outcome { + /// The `X-RateLimit-*` state of a scored request. + #[must_use] + pub fn snapshot(&self) -> crate::error::RateLimitSnapshot { + crate::error::RateLimitSnapshot { + limit: self.limit, + remaining: self.remaining, + reset: self.reset, + } + } +} + +/// Buckets of the data plane, keyed by the resolved counter key. +/// +/// [`DashMap`] keeps a bucket next to its counter key, so two upstreams never +/// serialise each other and a shard lock is held only for the duration of one +/// refill-and-take, never across an `await`. The `gates` map holds the turn of +/// the queued requests (see the module docs on fairness); it is keyed the same +/// way and pruned the same way. +pub struct Buckets { + map: DashMap, + gates: DashMap>>, + /// Ceiling on the bucket count; the sweep runs at it. + ceiling: usize, +} + +impl Default for Buckets { + fn default() -> Self { + Self { + map: DashMap::new(), + gates: DashMap::new(), + ceiling: MAX_BUCKETS, + } + } +} + +impl Buckets { + /// Score one request against `key` and take its tokens when admitted. + /// + /// A bucket that does not exist yet starts full, so the first request of a + /// fresh key can use the whole configured burst. + #[must_use] + pub fn score(&self, key: &str, limit: &Limit, cost: u64) -> Outcome { + let cost = as_float(cost); + let scored = self.admit(key); + let mut entry = self + .map + .entry(scored.to_owned()) + .or_insert_with(|| TokenBucket::new(limit)); + let (acquired, tokens, wait) = entry.try_acquire(cost); + let to_full = entry.seconds_to_full(); + drop(entry); + Outcome { + acquired, + limit: limit.rate, + remaining: whole(tokens), + reset: epoch_in(to_full), + retry_after: seconds_of(wait), + retry_in: wait, + } + } + + /// The key to score against: `key` itself, or the shared overflow counter + /// when the map is at its ceiling and this key has no bucket of its own. + fn admit<'key>(&self, key: &'key str) -> &'key str { + if self.map.len() < self.ceiling || self.map.contains_key(key) { + return key; + } + self.sweep(); + if self.map.len() < self.ceiling || self.map.contains_key(key) { + return key; + } + OVERFLOW_KEY + } + + /// Drop the buckets no request has touched for a window, and the gates + /// nothing is waiting on. + fn sweep(&self) { + self.map.retain(|_, bucket| bucket.age() < max_idle()); + self.gates.retain(|_, gate| Arc::strong_count(gate) > 1); + } + + /// Drop every bucket that belongs to `upstream_id` (ADR-0003 + /// "Distribution": the `{resource_id}` prefix exists for this cleanup). + /// + /// A deleted upstream's counters would otherwise survive it forever, and a + /// record that reuses the id would inherit a spent budget. + pub fn forget_upstream(&self, upstream_id: Uuid) { + let prefix = format!("{upstream_id}:"); + self.map.retain(|key, _| !key.starts_with(&prefix)); + self.gates.retain(|key, _| !key.starts_with(&prefix)); + } + + /// The gate a queued request of `key` holds while it waits. + /// + /// The `Arc` is cloned out of the map before any `await`, so no shard + /// guard is held across a suspension point. + fn gate(&self, key: &str) -> Arc> { + Arc::clone(self.gates.entry(key.to_owned()).or_default().value()) + } + + /// Whether any bucket for `upstream_id` is still held. + /// + /// For the crate's own tests only: the eviction is observable through the + /// lifecycle seam, and a public accessor would invite callers to read the + /// quota state of another upstream's callers. + #[cfg(test)] + pub(crate) fn holds(&self, upstream_id: Uuid) -> bool { + let prefix = format!("{upstream_id}:"); + self.map + .iter() + .any(|entry| entry.key().starts_with(&prefix)) + } + + /// Whether a bucket for the exact `key` is still held (tests only). + #[cfg(test)] + pub(crate) fn holds_key(&self, key: &str) -> bool { + self.map.contains_key(key) + } +} + +/// Idle age at which a bucket is swept, from the largest window the schema +/// allows. +fn max_idle() -> Duration { + Duration::from_secs(MAX_WINDOW_SECS) +} + +/// Whole tokens, floored: a fractional token is not one a request could take. +/// +/// The bucket keeps `tokens: f64`, as ADR-0003 "Implementation Notes" spells +/// the struct, while `X-RateLimit-Remaining` and `X-RateLimit-Reset` report +/// whole counts, so the fractional part has to be dropped. `value` is clamped +/// into the range [`MAX_TOKENS`] covers before the cast, which makes the +/// conversion exact for every bucket this module can build. +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the single float-to-integer conversion of the quota: a fractional token is dropped, the value is clamped before the cast" +)] +fn whole(value: f64) -> u64 { + if !value.is_finite() { + return 0; + } + value.floor().clamp(0.0, MAX_TOKENS) as u64 +} + +/// Largest whole token count the cast in [`whole`] is exact for. +/// +/// `u64::MAX` is not representable as an `f64`, so the clamp stops at +/// `u32::MAX` — the same ceiling [`as_float`] saturates at. +const MAX_TOKENS: f64 = 4_294_967_295.0; + +/// A count of tokens or seconds as an `f64`. +/// +/// `f64` has no lossless `From`, so the conversion saturates at +/// `u32::MAX` — four billion tokens a second, far above any quota a +/// deployment configures, and still exact for every value below it. +fn as_float(value: u64) -> f64 { + f64::from(u32::try_from(value).unwrap_or(u32::MAX)) +} + +/// Whole seconds, floored to at least one: RFC 6585 asks for seconds, and a +/// zero would have clients retry immediately. +fn seconds_of(seconds: f64) -> u64 { + whole(seconds).max(1) +} + +/// Epoch seconds `seconds` from now. +fn epoch_in(seconds: f64) -> u64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |since| since.as_secs()); + now + whole(seconds) +} + +/// Sustained rate of one configuration, in tokens per second. +/// +/// Comparing raw rates across windows would compare a minute with a second, so +/// every configuration is normalised first and the winner is recomposed in the +/// window of the level that asked for the limit. +fn per_second(config: &RateLimitConfig) -> f64 { + as_float(config.sustained.rate) / as_float(window_seconds(&config.sustained.window).max(1)) +} + +/// The effective policy of one request, or `None` when nothing enforces one. +/// +/// `levels` are the policies of the chain, descendant→ancestor: the route's, +/// the resolved upstream's, then the ancestors nearest first, each `None` when +/// that record declares no policy. The first level that declares one is the +/// *selected* one; every ancestor after it that declares `enforce` participates +/// (DESIGN: an ancestor's `enforce` constraint stays active), and when the +/// selected level declares `inherit` every ancestor policy does. The remaining +/// members — scope, strategy, cost, response headers — come from the selected +/// level, because a scope or a strategy is a property of the resource that asks +/// for the limit, not of the budget it is capped by. +#[must_use] +pub fn effective_limit(levels: &[Option<&RateLimitConfig>]) -> Option { + let selected = *levels.iter().flatten().next()?; + let mut caps = vec![selected]; + for level in levels.iter().flatten().skip(1) { + let participates = + selected.sharing == SharingMode::Inherit || level.sharing == SharingMode::Enforce; + if participates { + caps.push(level); + } + } + let rate_per_second = caps + .iter() + .map(|config| per_second(config)) + .reduce(f64::min)?; + let capacity = caps + .iter() + .map(|config| capacity_of(config)) + .reduce(u64::min)?; + Some(Limit { + // Recomposed in the window of the selected level, so the reported rate, + // the refusal detail and the refill all speak the same unit. + rate: whole(rate_per_second * as_float(window_seconds(&selected.sustained.window))).max(1), + window: selected.sustained.window.clone(), + capacity, + scope: selected.scope.clone(), + strategy: Strategy::parse(selected.strategy.as_str()), + cost: selected.cost.max(1), + response_headers: selected.response_headers, + }) +} + +/// Bucket capacity of one configuration; the ADR defaults it to the rate. +fn capacity_of(config: &RateLimitConfig) -> u64 { + config + .burst + .map_or(config.sustained.rate, |burst| burst.capacity.max(1)) +} + +/// Counter key of one request. +/// +/// `{upstream_id}:{scope_id}:{fingerprint}`: the prefix is what the ADR's +/// cleanup retains over, the scope id is what the configured scope names, and +/// the fingerprint is what makes a changed policy a fresh bucket. +#[must_use] +pub fn counter_key( + upstream_id: Uuid, + limit: &Limit, + calling_tenant: Uuid, + subject_id: Uuid, + route_id: Uuid, + forwarded_for: Option<&str>, +) -> String { + let scope_id = match limit.scope.as_str() { + "global" => "global".to_owned(), + // The forwarded chain is the only client identity the platform hands + // the gear, and only a parsable address counts: a rotated or a forged + // value that does not parse shares the `unknown` counter, so header + // rotation cannot mint fresh buckets. + "ip" => forwarded_for + .filter(|hop| hop.len() <= MAX_FORWARDED_LEN) + .and_then(|hop| hop.parse::().ok()) + .map_or_else(|| "unknown".to_owned(), |hop| hop.to_string()), + "user" => subject_id.to_string(), + "route" => route_id.to_string(), + // The *calling* tenant, and the strictest reading of a scope the write + // path does not know. + _ => calling_tenant.to_string(), + }; + format!("{upstream_id}:{scope_id}:{}", limit.fingerprint()) +} + +/// 429 for an empty bucket, with the retry guidance ADR-0003 asks for. +#[must_use] +pub fn exceeded(limit: &Limit, outcome: &Outcome) -> OagwError { + let response_headers = limit.response_headers; + OagwError::new( + OagwErrorKind::RateLimitExceeded, + format!( + "rate limit of {} requests per {} exceeded", + limit.rate, + limit.window_label() + ), + ) + .with_extension(|extensions| { + extensions.retry_after_seconds = Some(outcome.retry_after); + if response_headers { + extensions.rate_limit = Some(outcome.snapshot()); + } + }) +} + +/// `X-RateLimit-*` headers of a forwarded response (ADR-0003: "Response +/// headers follow RFC 6585"). +/// +/// A request that was admitted carries the same quota state a refused one +/// does, so a client can stop before it is refused. +#[must_use] +pub fn headers(outcome: &Outcome) -> http::HeaderMap { + let mut headers = http::HeaderMap::new(); + headers.insert("x-ratelimit-limit", HeaderValue::from(outcome.limit)); + headers.insert( + "x-ratelimit-remaining", + HeaderValue::from(outcome.remaining), + ); + headers.insert("x-ratelimit-reset", HeaderValue::from(outcome.reset)); + headers +} + +/// Await a token, bounded by `budget` (strategy `queue`). +/// +/// The wait is derived from the bucket's refill rate rather than polled, so a +/// queued request wakes when the token it needs is there instead of on a timer +/// grid; the budget still cuts the wait short and turns it into a 429. The +/// per-key gate is held for the whole wait, which is what keeps a later arrival +/// from taking the token the woken waiter was about to claim. +async fn acquire(buckets: &Buckets, key: &str, limit: &Limit, budget: Duration) -> Outcome { + let deadline = tokio::time::Instant::now() + budget; + let gate = buckets.gate(key); + // Held for the whole wait: this request's turn comes before the arrivals + // behind it, and the guard is released when the loop returns. + let _turn = gate.lock().await; + loop { + let outcome = buckets.score(key, limit, limit.cost); + if outcome.acquired { + return outcome; + } + let wake = tokio::time::Instant::now() + Duration::from_secs_f64(outcome.retry_in); + if wake >= deadline { + return outcome; + } + tokio::time::sleep_until(wake).await; + } +} + +/// A refused request: the bucket state that refused it, and its problem. +/// +/// The state travels with the refusal because it is the only record of *why* +/// the bucket was empty, and the gauge of DESIGN §4.2 needs it; the caller +/// keeps the error and the metrics take the state. +#[derive(Debug)] +pub struct Refusal { + /// The bucket state at the refusal. + pub outcome: Outcome, + /// The 429 problem the client is answered with. + pub error: OagwError, +} + +impl Refusal { + /// Consumed fraction of the bucket, in `0.0..=1.0`. + /// + /// A refusal is what the limiter saw when it gave up, so the ratio is the + /// spend of the bucket at that moment: an empty bucket is `1.0`, whatever + /// the capacity, and a capacity of `1` spent is `1.0` as well. + #[must_use] + pub fn usage_ratio(&self) -> f64 { + usage_ratio(self.outcome.limit, self.outcome.remaining) + } +} + +/// Consumed fraction of a bucket: the spend over the capacity. +/// +/// Both operands go through [`as_f64`], so the workspace's `as` cast is not +/// needed to make a ratio out of two `u64`s. +fn usage_ratio(limit: u64, remaining: u64) -> f64 { + // A capacity is floored at 1 by the limiter, so the division is guarded + // rather than assumed: a limit of `0` reads as a full bucket. + let capacity = limit.max(1); + let spent = capacity.saturating_sub(remaining).min(capacity); + as_f64(spent) / as_f64(capacity) +} + +/// `u64` to `f64` without the precision-loss `as` cast the workspace denies. +/// +/// A bucket state that exceeded `u32` saturates, which a ratio in `0.0..=1.0` +/// cannot observe. +fn as_f64(value: u64) -> f64 { + f64::from(u32::try_from(value).unwrap_or(u32::MAX)) +} + +/// Enforce `limit` for one request. +/// +/// Returns the bucket state for the response headers. A `degrade` policy is +/// scored but never blocks: it reports the spend and lets the request through. +/// +/// # Errors +/// A [`Refusal`] for a `reject` policy once the bucket is empty, and for a +/// `queue` policy whose wait outlives `budget`. +pub async fn enforce( + buckets: &Buckets, + key: &str, + limit: &Limit, + budget: Duration, +) -> Result { + match limit.strategy { + Strategy::Degrade => Ok(scored(buckets, key, limit)), + Strategy::Queue => queued(buckets, key, limit, budget).await, + Strategy::Reject => rejected(buckets, key, limit), + } +} + +/// Score a `degrade` request and let it through. +/// +/// "Degrade" names a serving mode, not a gate: the spend is reported only once +/// the bucket is actually empty, and the request is forwarded regardless. +fn scored(buckets: &Buckets, key: &str, limit: &Limit) -> Outcome { + let outcome = buckets.score(key, limit, limit.cost.max(1)); + if !outcome.acquired { + tracing::warn!( + limit = outcome.limit, + remaining = outcome.remaining, + "rate limit exhausted; serving in a degraded mode" + ); + } + outcome +} + +/// Score a `reject` request: served when a token is there, 429 otherwise. +fn rejected(buckets: &Buckets, key: &str, limit: &Limit) -> Result { + let outcome = buckets.score(key, limit, limit.cost.max(1)); + if outcome.acquired { + return Ok(outcome); + } + tracing::warn!(limit = outcome.limit, "rate limit exceeded"); + Err(refused(limit, &outcome)) +} + +/// Wait for a token on behalf of a `queue` policy, then give up. +async fn queued( + buckets: &Buckets, + key: &str, + limit: &Limit, + budget: Duration, +) -> Result { + let outcome = acquire(buckets, key, limit, budget).await; + if outcome.acquired { + return Ok(outcome); + } + tracing::warn!(limit = outcome.limit, "rate limit queue budget elapsed"); + Err(refused(limit, &outcome)) +} + +/// Bundle the 429 of an empty bucket with the state that produced it. +fn refused(limit: &Limit, outcome: &Outcome) -> Refusal { + Refusal { + outcome: *outcome, + error: exceeded(limit, outcome), + } +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use uuid::Uuid; + + use super::{Buckets, Limit, Strategy, counter_key, effective_limit, window_seconds}; + use crate::domain::model::{BurstConfig, RateLimitConfig, SharingMode, SustainedRate}; + + fn sustained(rate: u64, window: &str) -> SustainedRate { + SustainedRate { + rate, + window: window.to_owned(), + } + } + + fn config( + rate: u64, + window: &str, + capacity: Option, + sharing: SharingMode, + ) -> RateLimitConfig { + RateLimitConfig { + sharing, + algorithm: "token_bucket".to_owned(), + sustained: sustained(rate, window), + burst: capacity.map(|capacity| BurstConfig { capacity }), + scope: "tenant".to_owned(), + strategy: "reject".to_owned(), + cost: 1, + response_headers: true, + } + } + + /// A `Limit` over `window` seconds; the window label follows the unit. + fn limit(rate: u64, capacity: u64, window: &str) -> Limit { + Limit { + rate, + window: window.to_owned(), + capacity, + scope: "tenant".to_owned(), + strategy: Strategy::Reject, + cost: 1, + response_headers: true, + } + } + + /// A `Limit` keyed per `scope`, for the tests that read the counter key. + fn scoped(scope: &str) -> Limit { + Limit { + scope: scope.to_owned(), + ..limit(1, 1, "second") + } + } + + #[test] + fn a_window_maps_onto_seconds() { + assert_eq!(window_seconds(""), 1); + assert_eq!(window_seconds("second"), 1); + assert_eq!(window_seconds("minute"), 60); + assert_eq!(window_seconds("hour"), 3_600); + assert_eq!(window_seconds("day"), 86_400); + // A window the write path rejects (`week` is not one of the four) is + // read as the shortest one, which is the strictest reading. + assert_eq!(window_seconds("week"), 1); + } + + #[test] + fn a_bucket_starts_full_and_drains_to_empty() { + // Two tokens a second: a refill inside the test run stays a fraction. + let limit = limit(1, 3, "second"); + let buckets = Buckets::default(); + assert!(buckets.score("k", &limit, 1).acquired); + assert_eq!(buckets.score("k", &limit, 1).remaining, 1); + assert_eq!(buckets.score("k", &limit, 1).remaining, 0); + let fourth = buckets.score("k", &limit, 1); + assert!(!fourth.acquired); + assert_eq!(fourth.remaining, 0); + // RFC 6585 asks for seconds, and a zero would have clients retry at + // once: the guidance is always at least one second away. + assert!(fourth.retry_after >= 1); + } + + #[test] + fn an_instantly_refilling_bucket_never_starves_a_queue() { + let limit = limit(10_000, 1, "second"); + let buckets = Buckets::default(); + assert!(buckets.score("k", &limit, 1).acquired); + assert_eq!( + buckets.score("k", &limit, 1).retry_after, + 1, + "a fresh token is at most a second away at 10 000 tokens/s" + ); + } + + #[test] + fn a_cost_higher_than_the_capacity_admits_nothing() { + let limit = limit(1, 5, "second"); + let buckets = Buckets::default(); + assert!(!buckets.score("k", &limit, 10).acquired); + assert!(buckets.score("k", &limit, 10).retry_after >= 1); + } + + #[test] + fn every_key_has_its_own_bucket() { + let limit = limit(1, 1, "day"); + let buckets = Buckets::default(); + assert!(buckets.score("a", &limit, 1).acquired); + assert!(buckets.score("b", &limit, 1).acquired); + assert!(!buckets.score("a", &limit, 1).acquired); + } + + #[test] + fn a_spent_bucket_is_full_again_after_a_refill() { + // A hundred tokens a second: ten milliseconds buys one token back. + let limit = limit(100, 1, "second"); + let buckets = Buckets::default(); + assert!(buckets.score("k", &limit, 1).acquired); + assert!(!buckets.score("k", &limit, 1).acquired); + std::thread::sleep(Duration::from_millis(20)); + assert!(buckets.score("k", &limit, 1).acquired); + } + + #[test] + fn forgetting_an_upstream_drops_its_buckets() { + let limit = limit(1, 1, "day"); + let buckets = Buckets::default(); + let upstream = Uuid::now_v7(); + let neighbour = Uuid::now_v7(); + let tenant = Uuid::now_v7(); + let own = counter_key(upstream, &limit, tenant, tenant, tenant, None); + let other = counter_key(neighbour, &limit, tenant, tenant, tenant, None); + assert!(buckets.score(&own, &limit, 1).acquired); + assert!(buckets.score(&other, &limit, 1).acquired); + assert!(buckets.holds(upstream)); + buckets.forget_upstream(upstream); + assert!(!buckets.holds(upstream)); + // The neighbour's counter survives the cleanup. + assert!(buckets.holds(neighbour)); + } + + #[test] + fn a_changed_limit_starts_a_fresh_bucket() { + let buckets = Buckets::default(); + let generous = limit(1, 2, "day"); + let tightened = limit(1, 1, "day"); + let key = |limit: &Limit| { + counter_key( + Uuid::now_v7(), + limit, + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + None, + ) + }; + let spent = key(&generous); + assert!(buckets.score(&spent, &generous, 1).acquired); + // The same key under a different limit is a different bucket, so the + // budget the old limit spent is not inherited by the new one. + assert!(!buckets.holds_key(&key(&tightened))); + assert!( + buckets.holds_key(&spent), + "an unchanged limit keeps its bucket" + ); + } + + #[test] + fn the_counter_key_carries_the_scope() { + let upstream = Uuid::now_v7(); + let tenant = Uuid::now_v7(); + let subject = Uuid::now_v7(); + let route = Uuid::now_v7(); + let limit = limit(1, 1, "second"); + // The middle segment is the scope id the configured scope named; the + // last one is the fingerprint of the policy, which differs whenever the + // scope member does. + let keyed = |scope: &str, forwarded: Option<&str>| { + let mut policy = limit.clone(); + policy.scope = scope.to_owned(); + counter_key(upstream, &policy, tenant, subject, route, forwarded) + .split(':') + .nth(1) + .unwrap_or("unparsable") + .to_owned() + }; + assert_eq!(keyed("global", None), "global"); + assert_eq!(keyed("tenant", None), tenant.to_string()); + assert_eq!(keyed("user", None), subject.to_string()); + assert_eq!(keyed("route", None), route.to_string()); + assert_eq!(keyed("ip", Some("10.0.0.1")), "10.0.0.1"); + assert_eq!(keyed("ip", None), "unknown"); + // A scope the write path does not know counts per calling tenant. + assert_eq!(keyed("cluster", None), tenant.to_string()); + } + + #[test] + fn an_unparsable_forwarded_hop_shares_the_unknown_counter() { + let upstream = Uuid::now_v7(); + let tenant = Uuid::now_v7(); + let policy = scoped("ip"); + let keyed = |forwarded: Option<&str>| { + counter_key(upstream, &policy, tenant, tenant, tenant, forwarded) + }; + assert_eq!(keyed(Some("not-an-address")), keyed(None)); + // Header injection may also make the hop longer than any address. + let long = "1".repeat(64); + assert_eq!(keyed(Some(&long)), keyed(None)); + assert_ne!(keyed(Some("10.0.0.1")), keyed(None)); + } + + #[test] + fn the_effective_limit_is_the_min_over_the_participating_levels() { + // A private descendant: only the ancestor that *enforces* joins in. + let own = config(1_000, "minute", Some(500), SharingMode::Private); + let parent = config(100, "minute", Some(10), SharingMode::Enforce); + let grandparent = config(50, "minute", Some(1_000), SharingMode::Private); + let levels = [Some(&own), Some(&parent), Some(&grandparent)]; + let limit = + effective_limit(&levels).unwrap_or_else(|| panic!("a chain with a policy resolves")); + assert_eq!(limit.rate, 100, "the grandparent shares nothing here"); + assert_eq!(limit.capacity, 10); + // The most specific record decides scope, strategy and cost. + assert_eq!(limit.cost, 1); + assert_eq!(limit.scope, "tenant"); + } + + #[test] + fn an_inherit_descendant_takes_every_ancestor_policy() { + let own = config(1_000, "minute", Some(500), SharingMode::Inherit); + let parent = config(100, "minute", Some(10), SharingMode::Private); + let grandparent = config(50, "minute", Some(1_000), SharingMode::Private); + let levels = [Some(&own), Some(&parent), Some(&grandparent)]; + let limit = effective_limit(&levels).unwrap_or_else(|| panic!("must resolve")); + assert_eq!(limit.rate, 50); + assert_eq!(limit.capacity, 10); + } + + #[test] + fn an_enforce_ancestor_caps_a_private_descendant() { + let own = config(1_000, "second", Some(1_000), SharingMode::Private); + let parent = config(2, "second", Some(2), SharingMode::Enforce); + let levels = [Some(&own), Some(&parent)]; + let limit = effective_limit(&levels).unwrap_or_else(|| panic!("must resolve")); + assert_eq!(limit.rate, 2); + assert_eq!(limit.capacity, 2); + } + + #[test] + fn a_rate_is_compared_per_second_across_windows() { + // 10/s is stricter than 1_000/min, so the parent caps the child, in the + // child's window. + let own = config(1_000, "minute", Some(1_000), SharingMode::Private); + let parent = config(10, "second", Some(10), SharingMode::Enforce); + let levels = [Some(&own), Some(&parent)]; + let limit = effective_limit(&levels).unwrap_or_else(|| panic!("must resolve")); + assert_eq!(limit.window_seconds(), 60); + assert_eq!(limit.rate, 600); + assert!((limit.refill_rate() - 10.0).abs() < 1e-9); + } + + #[test] + fn a_slow_ancestor_caps_a_fast_child_in_the_childs_window() { + // 100/min is stricter than 5/s, and the recomposed rate is floored to a + // whole token per second, which is the stricter side. + let own = config(5, "second", Some(5), SharingMode::Private); + let parent = config(100, "minute", Some(100), SharingMode::Enforce); + let levels = [Some(&own), Some(&parent)]; + let limit = effective_limit(&levels).unwrap_or_else(|| panic!("must resolve")); + assert_eq!(limit.window_seconds(), 1); + assert_eq!(limit.rate, 1); + assert_eq!(limit.capacity, 5, "capacity is in tokens, not per second"); + } + + #[test] + fn no_configuration_enforces_nothing() { + assert!(effective_limit(&[]).is_none()); + assert!(effective_limit(&[None, None]).is_none()); + } + + #[test] + fn an_absent_capacity_falls_back_to_the_rate() { + let own = config(100, "second", None, SharingMode::Private); + let limit = effective_limit(&[Some(&own)]).unwrap_or_else(|| panic!("must resolve")); + assert_eq!(limit.capacity, 100); + } + + #[test] + fn a_window_is_carried_into_the_limit() { + let own = config(5, "minute", None, SharingMode::Private); + let limit = effective_limit(&[Some(&own)]).unwrap_or_else(|| panic!("must resolve")); + assert_eq!(limit.window_seconds(), 60); + assert_eq!(limit.rate, 5); + let daily = config(5, "day", None, SharingMode::Private); + let limit = effective_limit(&[Some(&daily)]).unwrap_or_else(|| panic!("must resolve")); + assert_eq!(limit.window_seconds(), 86_400); + } + + #[test] + fn a_cost_below_one_is_lifted_to_one() { + let mut own = config(5, "second", None, SharingMode::Private); + own.cost = 0; + let limit = effective_limit(&[Some(&own)]).unwrap_or_else(|| panic!("must resolve")); + assert_eq!(limit.cost, 1); + } + + #[test] + fn an_unknown_strategy_fails_closed() { + let mut own = config(5, "second", None, SharingMode::Private); + own.strategy = "shed".to_owned(); + let limit = effective_limit(&[Some(&own)]).unwrap_or_else(|| panic!("must resolve")); + assert_eq!(limit.strategy, Strategy::Reject); + } + + #[test] + fn a_fingerprint_follows_the_policy_it_names() { + let base = limit(100, 10, "minute"); + let same = limit(100, 10, "minute"); + assert_eq!(base.fingerprint(), same.fingerprint()); + // Every member the bucket is created from changes the fingerprint. + assert_ne!(base.fingerprint(), limit(101, 10, "minute").fingerprint()); + assert_ne!(base.fingerprint(), limit(100, 11, "minute").fingerprint()); + assert_ne!(base.fingerprint(), limit(100, 10, "hour").fingerprint()); + let mut scoped = limit(100, 10, "minute"); + scoped.scope = "global".to_owned(); + assert_ne!(base.fingerprint(), scoped.fingerprint()); + let mut queued = limit(100, 10, "minute"); + queued.strategy = Strategy::Queue; + assert_ne!(base.fingerprint(), queued.fingerprint()); + let mut costly = limit(100, 10, "minute"); + costly.cost = 2; + assert_ne!(base.fingerprint(), costly.fingerprint()); + } + + #[test] + fn a_bucket_idle_for_a_window_is_swept() { + let limit = limit(1, 1, "day"); + let buckets = Buckets::default(); + let fresh = Uuid::now_v7(); + let stale = Uuid::now_v7(); + let fresh_key = counter_key( + fresh, + &limit, + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + None, + ); + let stale_key = counter_key( + stale, + &limit, + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + None, + ); + assert!(buckets.score(&fresh_key, &limit, 1).acquired); + assert!(buckets.score(&stale_key, &limit, 1).acquired); + // A bucket untouched for the largest supported window is full again, so + // sweeping it loses nothing. + buckets.map.get_mut(&stale_key).map_or_else( + || panic!("the stale bucket must be there"), + |mut bucket| { + bucket.last_update -= Duration::from_secs(24 * 60 * 60 + 1); + }, + ); + buckets.sweep(); + assert!(buckets.holds(fresh), "an active bucket survives"); + assert!(!buckets.holds_key(&stale_key), "an idle bucket is swept"); + } + + #[test] + fn a_map_at_its_ceiling_shares_the_overflow_counter() { + let limit = limit(1, 1, "day"); + let buckets = Buckets { + ceiling: 2, + ..Buckets::default() + }; + let first = Uuid::now_v7(); + let second = Uuid::now_v7(); + let third = Uuid::now_v7(); + let key = |upstream: Uuid| { + counter_key( + upstream, + &limit, + Uuid::now_v7(), + Uuid::now_v7(), + Uuid::now_v7(), + None, + ) + }; + assert!(buckets.score(&key(first), &limit, 1).acquired); + assert!(buckets.score(&key(second), &limit, 1).acquired); + // The map is at its ceiling and nothing is idle, so the third request + // shares the one counter the module reserves for that case. + assert!(buckets.score(&key(third), &limit, 1).acquired); + assert!(buckets.holds_key(super::OVERFLOW_KEY)); + assert!(!buckets.holds_key(&key(third))); + // ... and it is refused, because the overflow counter is spent too. + assert!(!buckets.score(&key(third), &limit, 1).acquired); + } + + #[tokio::test] + async fn a_queued_request_waits_for_its_token() { + let limit = limit(2_000, 1, "second"); + let buckets = Buckets::default(); + assert!(buckets.score("k", &limit, 1).acquired); + let outcome = super::acquire(&buckets, "k", &limit, Duration::from_secs(1)).await; + assert!(outcome.acquired); + } + + #[tokio::test] + async fn a_queue_that_cannot_be_served_gives_up() { + let limit = limit(1, 1, "day"); + let buckets = Buckets::default(); + assert!(buckets.score("k", &limit, 1).acquired); + let outcome = super::acquire(&buckets, "k", &limit, Duration::from_millis(5)).await; + assert!(!outcome.acquired); + assert!(outcome.retry_after >= 1); + } +} diff --git a/gears/system/oagw/oagw/src/domain/proxy/routing.rs b/gears/system/oagw/oagw/src/domain/proxy/routing.rs new file mode 100644 index 0000000..07739a1 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/proxy/routing.rs @@ -0,0 +1,1053 @@ +// Created: 2026-08-31 by Constructor Tech +//! Pure routing decisions of the proxy data plane (DESIGN §3.2, ADR-0001). +//! +//! Nothing here touches the store, the network or the clock: a decision is +//! derived from the domain model plus the request pieces, or rejected with a +//! 4xx [`OagwError`]. [`super::service::ProxyService`] owns the I/O. + +use url::Url; + +use super::super::alias; +use crate::domain::model::{Endpoint, HttpMatch, Route, RouteMatch, Upstream}; +use crate::domain::model::{HttpMethod, PathSuffixMode}; +use crate::error::{OagwError, OagwResult}; + +/// Header a client sets to pin the target endpoint of a pool (ADR-0001). +pub const TARGET_HOST_HEADER: &str = "x-oagw-target-host"; + +/// A route matched for a proxy request, plus the part of the request path the +/// route's match prefix does not cover. +#[derive(Debug)] +pub struct RouteSelection<'a> { + /// Route that matched. + pub route: &'a Route, + /// `http` branch of the matched rule; only `http` routes are selectable. + pub http: &'a HttpMatch, + /// Request path beyond the matched prefix, without its leading slash. + pub suffix: String, +} + +/// Select the route for a proxy request (DESIGN §3.2 "Request Routing"). +/// +/// A candidate must be enabled, expose an `http` match rule, allow the request +/// method and own a prefix of the request path. The longest prefix wins; ties +/// (same prefix, different rule shapes) are broken by the more specific rule +/// and finally by record id so the outcome is deterministic. A method that is +/// not in the allowlist removes the candidate, which surfaces as +/// `route.not_found.v1`: the guard table of DESIGN §3.2 lists it as a plain +/// rejection and 404 is the only documented code for a non-matching route. +#[must_use] +pub fn select_route<'a>( + routes: &'a [Route], + method: &http::Method, + request_path: &str, +) -> Option> { + let candidate = HttpMethod::parse(method.as_str())?; + routes + .iter() + .filter(|route| route.enabled) + .filter_map(|route| { + let http = route.match_rule.http.as_ref()?; + if !http.methods.contains(&candidate) { + return None; + } + let suffix = remainder_after_prefix(&http.path, request_path)?; + Some(( + (http.path.len(), match_keys(&route.match_rule), route.id), + route, + http, + suffix, + )) + }) + .max_by_key(|(score, route, _, _)| (*score, route.id)) + .map(|(_, route, http, suffix)| RouteSelection { + route, + http, + suffix, + }) +} + +/// Longest-prefix, segment-aware match. +/// +/// `/v1` matches `/v1` and `/v1/chat` but not `/v1beta`; the returned value is +/// the uncovered remainder without the separating slash. +fn remainder_after_prefix(prefix: &str, request_path: &str) -> Option { + if request_path == prefix { + return Some(String::new()); + } + let boundary = format!("{prefix}/"); + request_path.strip_prefix(&boundary).map(str::to_owned) +} + +/// Number of conditions a match rule expresses; a tie-break for equal paths. +fn match_keys(match_rule: &RouteMatch) -> usize { + match match_rule { + RouteMatch { + http: Some(http), + grpc: None, + } => { + 1 + usize::from(http.methods.len() > 1) + usize::from(!http.query_allowlist.is_empty()) + } + RouteMatch { + http: None, + grpc: Some(_), + } => 1, + RouteMatch { .. } => 0, + } +} + +/// Build the upstream request path from the match path and the raw suffix. +/// +/// The suffix arrives **as the client sent it**, still percent-encoded: the +/// request URI is not decoded, and every segment is decoded, checked and +/// encoded again. That is what keeps a decoded `..` from escaping the matched +/// prefix and a `%3F` or `%23` from injecting a query or a fragment behind the +/// route's back (DESIGN §4.4, fail closed). +/// +/// # Errors +/// 400 when the route rejects path suffixes (`path_suffix_mode: disabled`) and +/// a non-empty suffix was requested (DESIGN §3.2 "Guard Rules"), or when a +/// suffix segment cannot be rebuilt safely. +pub fn upstream_path(match_rule: &HttpMatch, suffix: &str) -> OagwResult { + let segments = suffix_segments(suffix)?; + if !matches!(match_rule.path_suffix_mode, PathSuffixMode::Append) && !segments.is_empty() { + return Err(OagwError::validation(format!( + "route '{}' does not accept a path suffix", + match_rule.path + )) + .with_extension(|ext| ext.invalid_value = Some(suffix.to_owned()))); + } + let base = encode_path(&match_rule.path); + let mut path = base; + for segment in segments { + if !path.ends_with('/') { + path.push('/'); + } + path.push_str(&segment); + } + Ok(path) +} + +/// Rebuild the segments of a raw, still-encoded suffix. +/// +/// An empty suffix has no segments: the request addressed the match path +/// itself, which is valid for every `path_suffix_mode`. +fn suffix_segments(suffix: &str) -> OagwResult> { + if suffix.is_empty() { + return Ok(Vec::new()); + } + suffix + .split('/') + .map(|segment| { + let decoded = percent_decode(segment); + if matches!(decoded.as_str(), "." | "..") { + return Err( + OagwError::validation("path suffix must not contain a dot segment") + .with_extension(|ext| ext.invalid_value = Some(segment.to_owned())), + ); + } + if decoded.contains(['?', '#']) { + return Err(OagwError::validation( + "path suffix must not carry a query or a fragment", + ) + .with_extension(|ext| ext.invalid_value = Some(segment.to_owned()))); + } + Ok(encode_path_segment(&decoded)) + }) + .collect() +} + +/// Percent-encode every segment of a route's match path. +fn encode_path(path: &str) -> String { + let trimmed = path.strip_prefix('/').unwrap_or(path); + let encoded: Vec = trimmed.split('/').map(encode_path_segment).collect(); + format!("/{}", encoded.join("/")) +} + +/// Bytes RFC 3986 allows in a URL path without escaping. +fn is_unreserved(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'.' | b'_' | b'~') +} + +/// Percent-encode one path segment. +/// +/// Only the unreserved set survives, so `/`, `?`, `#` and every control or +/// whitespace byte stay escaped: a segment can never change the shape of the +/// dial target by being re-read. +fn encode_path_segment(segment: &str) -> String { + const HEX: &[u8; 16] = b"0123456789ABCDEF"; + let mut encoded = Vec::with_capacity(segment.len()); + for byte in segment.bytes() { + if is_unreserved(byte) { + encoded.push(byte); + } else { + encoded.push(b'%'); + encoded.push(HEX[usize::from(byte >> 4)]); + encoded.push(HEX[usize::from(byte & 0x0F)]); + } + } + String::from_utf8_lossy(&encoded).into_owned() +} + +/// Decode the `%XX` escapes of a raw path segment. +/// +/// An incomplete or non-hexadecimal escape is copied through: `Url::parse` +/// rejects it later, and a copied `%` can only ever shorten the candidate set +/// of dial targets, never widen it. +#[must_use] +pub fn percent_decode(raw: &str) -> String { + let bytes = raw.as_bytes(); + let mut decoded = Vec::with_capacity(bytes.len()); + let mut index = 0; + while index < bytes.len() { + let byte = bytes[index]; + if byte == b'%' + && let Some(escape) = decode_escape(bytes, index) + { + decoded.push(escape); + index += 3; + } else { + decoded.push(byte); + index += 1; + } + } + String::from_utf8_lossy(&decoded).into_owned() +} + +/// Value of the `%XX` escape that starts at `index`, `None` when either digit +/// is missing or not a hexadecimal digit. +fn decode_escape(bytes: &[u8], index: usize) -> Option { + let high = decode_hex_digit(*bytes.get(index + 1)?)?; + let low = decode_hex_digit(*bytes.get(index + 2)?)?; + Some((high << 4) | low) +} + +/// Numeric value of one hexadecimal byte, `None` when it is not a hex digit. +fn decode_hex_digit(byte: u8) -> Option { + match byte { + b'0'..=b'9' => Some(byte - b'0'), + b'a'..=b'f' => Some(byte - b'a' + 10), + b'A'..=b'F' => Some(byte - b'A' + 10), + _ => None, + } +} + +/// `host` or `host:port` of an endpoint, eliding the scheme's default port +/// (DESIGN §3.2 "Standard ports"). +#[must_use] +pub fn authority(endpoint: &Endpoint) -> String { + alias::endpoint_alias_key(endpoint) +} + +/// Assemble, parse and re-verify the absolute upstream URL. +/// +/// The assembled string is parsed again with `url::Url` **and then checked +/// against what was assembled**: the scheme and the authority must be the +/// endpoint's, the parsed path must still carry the route prefix and the +/// parsed query must still be exactly the filtered one. A percent-escaped byte +/// that survives route matching therefore cannot smuggle a different +/// authority, scheme, path or query into the dial (DESIGN §4.4, fail closed). +/// +/// # Errors +/// 400 when the assembled URL does not parse or when the re-verification +/// disagrees with the parts it was built from. +pub fn target_url( + endpoint: &Endpoint, + route_path: &str, + path: &str, + query: Option<&str>, +) -> OagwResult { + let scheme = endpoint.scheme.as_str(); + let host = authority(endpoint); + let raw = match query { + Some(query) if !query.is_empty() => format!("{scheme}://{host}{path}?{query}"), + _ => format!("{scheme}://{host}{path}"), + }; + let url = Url::parse(&raw).map_err(|error| { + let redacted = redacted_target(scheme, &host, path, query); + tracing::debug!(target = %redacted, %error, "assembled upstream URL is not valid"); + OagwError::validation("assembled upstream URL is not a valid URL").with_extension(|ext| { + ext.invalid_value = Some(redacted); + }) + })?; + verify_target(&url, endpoint, route_path, query)?; + Ok(url) +} + +/// A dial target with every query **value** elided. +/// +/// The assembled URL can carry an injected credential: an auth plugin with a +/// `query_param` binding writes the resolved API key into the query, and the +/// URL is finalised after the plugin phase. A failing dial target is therefore +/// never echoed — not into a problem document, not into a log line — it only +/// ever names the parameters it would have carried. +fn redacted_target(scheme: &str, host: &str, path: &str, query: Option<&str>) -> String { + let names = query.map_or_else(Vec::new, |query| { + form_urlencoded::parse(query.as_bytes()) + .map(|(name, _)| name.into_owned()) + .collect::>() + }); + let query = names + .iter() + .map(|name| format!("{name}=")) + .collect::>() + .join("&"); + let query = if query.is_empty() { + String::new() + } else { + format!("?{query}") + }; + format!("{scheme}://{host}{path}{query}") +} + +/// [`redacted_target`] of an already parsed dial target. +fn redacted_url(url: &Url) -> String { + redacted_target( + url.scheme(), + url.host_str().unwrap_or_default(), + url.path(), + url.query(), + ) +} + +/// Re-verification of a parsed dial target. +fn verify_target( + url: &Url, + endpoint: &Endpoint, + route_path: &str, + query: Option<&str>, +) -> OagwResult<()> { + let rejected = |detail: &'static str, invalid: String| { + OagwError::validation(detail).with_extension(|ext| ext.invalid_value = Some(invalid)) + }; + let port = if endpoint.port == endpoint.scheme.default_port() { + None + } else { + Some(endpoint.port) + }; + let authority = url + .host_str() + .is_some_and(|host| host == alias::normalize(&endpoint.host)) + && url.port() == port + && url.scheme() == endpoint.scheme.as_str(); + let prefix = encode_path(route_path); + if !authority { + return Err(rejected( + "assembled upstream URL does not address the selected endpoint", + redacted_url(url), + )); + } + if !url.path().starts_with(&prefix) { + return Err(rejected( + "assembled upstream URL escaped the matched route path", + redacted_url(url), + )); + } + if !query_survived(url, query) { + return Err(rejected( + "assembled upstream URL carries a query the route did not allow", + redacted_url(url), + )); + } + Ok(()) +} + +/// Whether the parsed URL kept exactly the parameters the filter allowed. +/// +/// Compared by decoded parameter names, in order: `url` re-encodes what it is +/// given, so the spelling may differ while the set may not. +fn query_survived(url: &Url, filtered: Option<&str>) -> bool { + let names = |query: &str| -> Vec { + form_urlencoded::parse(query.as_bytes()) + .map(|(name, _)| name.into_owned()) + .collect() + }; + match (url.query(), filtered) { + (None, None) => true, + (Some(actual), Some(expected)) => names(actual) == names(expected), + _ => false, + } +} + +/// Keep only the allowlisted query parameters (DESIGN §3.2 "Query allowlist"). +/// +/// An empty allowlist drops every parameter, an absent query stays absent, and +/// the surviving segments keep their original spelling (order, encoding and +/// duplicate names included). Unknown parameters are dropped rather than +/// rejected: the guard table of DESIGN §3.2 names a rejection, but a proxy that +/// filters is the only behaviour the §3.2 "Transformation Rules" row +/// ("Passthrough allowed params") can be read as. +#[must_use] +pub fn filter_query(allowlist: &[String], query: Option<&str>) -> Option { + let query = query?; + let kept: Vec<&str> = query + .split('&') + .filter(|segment| !segment.is_empty()) + .filter(|segment| is_allowed(allowlist, segment)) + .collect(); + (!kept.is_empty()).then(|| kept.join("&")) +} + +/// Whether the decoded name of a raw query segment is in the allowlist. +fn is_allowed(allowlist: &[String], segment: &str) -> bool { + let name = form_urlencoded::parse(segment.as_bytes()) + .next() + .map_or_else(String::new, |(decoded, _)| decoded.into_owned()); + allowlist.iter().any(|allowed| allowed == &name) +} + +/// Normalized value of [`TARGET_HOST_HEADER`]. +/// +/// The header selects an endpoint by host, so anything that is not a bare host +/// or IP literal — a `host:port` pair, a path, a wildcard — is rejected +/// (ADR-0001 example: `us.vendor.com:8443`). +/// +/// # Errors +/// [`crate::error::OagwErrorKind::InvalidTargetHost`] for a value that cannot +/// be a host. +pub fn validate_target_host(value: &str) -> OagwResult { + let candidate = value.trim(); + if candidate.is_empty() || alias::classify_host(candidate) == alias::HostKind::Invalid { + return Err(OagwError::new( + crate::error::OagwErrorKind::InvalidTargetHost, + format!("'{value}' is not a host name or IP address"), + ) + .with_extension(|ext| { + ext.invalid_value = Some(value.to_owned()); + })); + } + Ok(alias::normalize(candidate)) +} + +/// Index of the endpoint that owns `host`, compared case-insensitively. +#[must_use] +pub fn endpoint_index_for_host(endpoints: &[Endpoint], host: &str) -> Option { + let wanted = alias::normalize(host); + endpoints + .iter() + .position(|endpoint| alias::normalize(&endpoint.host) == wanted) +} + +/// Whether the alias only names a shared suffix of several hosts, which makes +/// the target endpoint ambiguous (ADR-0001). +/// +/// A pool with a single endpoint is never ambiguous; a pool whose alias was +/// set explicitly to a full host name is not either. +#[must_use] +pub fn requires_target_host(upstream: &Upstream) -> bool { + if upstream.endpoints.len() < 2 { + return false; + } + alias::derive_alias(&upstream.endpoints).is_ok_and(|derived| derived == upstream.alias) +} + +/// Why an endpoint was chosen (DESIGN §4.2 `selection_method`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectionMethod { + /// `X-OAGW-Target-Host` named it. + ExplicitHeader, + /// The pool's round-robin cursor picked it. + RoundRobin, + /// The pool holds a single endpoint, so there was nothing to select. + Default, +} + +impl SelectionMethod { + /// Label value of the routing metric. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ExplicitHeader => "explicit_header", + Self::RoundRobin => "round_robin", + Self::Default => "default", + } + } +} + +/// The endpoint a request dials, and how it was chosen. +#[derive(Debug)] +pub struct Selection<'a> { + /// The endpoint to dial. + pub endpoint: &'a Endpoint, + /// What picked it (DESIGN §4.2 `selection_method`). + pub method: SelectionMethod, +} + +/// Resolve the endpoint to dial (ADR-0001 "X-OAGW-Target-Host" matrix). +/// +/// An explicit header is validated and must name a configured endpoint, no +/// matter how large the pool is. Without a header an ambiguous pool is +/// rejected, and an unambiguous pool is selected by `round_robin`, which +/// yields the next index of the pool; a pool of one is reported as +/// [`SelectionMethod::Default`], because a cursor over a single entry made no +/// decision. +/// +/// # Errors +/// * [`crate::error::OagwErrorKind::InvalidTargetHost`] for a malformed header +/// * [`crate::error::OagwErrorKind::UnknownTargetHost`] when the header names +/// no configured endpoint +/// * [`crate::error::OagwErrorKind::MissingTargetHost`] when the pool is +/// ambiguous and no header was sent +/// * 400 when the upstream carries no endpoint at all +pub fn select_endpoint<'a>( + upstream: &'a Upstream, + header: Option<&str>, + round_robin: impl FnOnce() -> usize, +) -> OagwResult> { + let endpoints = &upstream.endpoints; + if endpoints.is_empty() { + return Err(OagwError::validation( + "upstream carries no endpoint; cannot proxy", + )); + } + let Some(value) = header else { + if requires_target_host(upstream) { + return Err(missing_target_host(upstream)); + } + let method = if endpoints.len() == 1 { + SelectionMethod::Default + } else { + SelectionMethod::RoundRobin + }; + let index = round_robin() % endpoints.len(); + return Ok(Selection { + endpoint: &endpoints[index], + method, + }); + }; + let host = validate_target_host(value)?; + let index = endpoint_index_for_host(endpoints, &host).ok_or_else(|| { + let hosts: Vec = endpoints.iter().map(|e| e.host.clone()).collect(); + OagwError::new( + crate::error::OagwErrorKind::UnknownTargetHost, + format!( + "'{host}' matches no endpoint of upstream '{}'", + upstream.alias + ), + ) + .with_extension(|ext| { + ext.alias = Some(upstream.alias.clone()); + ext.invalid_value = Some(host.clone()); + ext.valid_hosts = Some(hosts); + }) + })?; + Ok(Selection { + endpoint: &endpoints[index], + method: SelectionMethod::ExplicitHeader, + }) +} + +/// 400 `routing.missing_target_host.v1` with the pool that would be valid. +fn missing_target_host(upstream: &Upstream) -> OagwError { + let hosts: Vec = upstream + .endpoints + .iter() + .map(|endpoint| endpoint.host.clone()) + .collect(); + OagwError::new( + crate::error::OagwErrorKind::MissingTargetHost, + format!( + "upstream '{}' pools several endpoints; set {}", + upstream.alias, TARGET_HOST_HEADER + ), + ) + .with_extension(|ext| { + ext.alias = Some(upstream.alias.clone()); + ext.valid_hosts = Some(hosts); + }) +} + +/// Whether an endpoint dials a plaintext connection while the deployment only +/// allows TLS. +/// +/// The outbound client refuses such a dial as well; this check runs first so +/// the problem carries the endpoint that was refused. +#[must_use] +pub fn plaintext_disallowed(endpoint: &Endpoint, allow_http_upstream: bool) -> bool { + endpoint.scheme.is_plaintext() && !allow_http_upstream +} + +#[cfg(test)] +mod tests { + use http::Method; + use url::Url; + + use super::{ + SelectionMethod, authority, endpoint_index_for_host, filter_query, remainder_after_prefix, + requires_target_host, select_endpoint, select_route, target_url, upstream_path, + validate_target_host, + }; + use crate::config::SsrfPolicy; + use crate::domain::model::{ + Endpoint, HttpMatch, HttpMethod, PathSuffixMode, Route, RouteMatch, Scheme, Timestamps, + Upstream, + }; + use crate::error::{OagwErrorKind, ResourceKind}; + + const TENANT: uuid::Uuid = uuid::Uuid::nil(); + + fn endpoint(scheme: Scheme, host: &str, port: u16) -> Endpoint { + Endpoint { + scheme, + host: host.to_owned(), + port, + } + } + + fn upstream(endpoints: Vec, alias: &str) -> Upstream { + Upstream { + id: uuid::Uuid::new_v4(), + tenant_id: TENANT, + alias: alias.to_owned(), + enabled: true, + protocol: crate::domain::model::Protocol::Http, + endpoints, + tags: Vec::new(), + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + timestamps: Timestamps { + created_at: 0, + updated_at: 0, + }, + } + } + + fn route(match_rule: RouteMatch) -> Route { + Route { + id: uuid::Uuid::new_v4(), + tenant_id: TENANT, + upstream_id: uuid::Uuid::new_v4(), + enabled: true, + match_rule, + tags: Vec::new(), + plugins: None, + rate_limit: None, + cors: None, + timestamps: Timestamps { + created_at: 0, + updated_at: 0, + }, + } + } + + fn http_route(path: &str, methods: &[HttpMethod], suffix_mode: PathSuffixMode) -> Route { + route(RouteMatch { + http: Some(HttpMatch { + methods: methods.to_vec(), + path: path.to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: suffix_mode, + }), + grpc: None, + }) + } + + fn get(path: &str) -> Route { + http_route(path, &[HttpMethod::Get], PathSuffixMode::Append) + } + + #[test] + fn matches_the_longest_prefix() { + let deep = get("/v1/chat"); + let deep_id = deep.id; + let routes = [get("/v1"), deep]; + let selected = select_route(&routes, &Method::GET, "/v1/chat/completions").unwrap(); + assert_eq!(selected.route.id, deep_id); + assert_eq!(selected.suffix, "completions"); + } + + #[test] + fn does_not_match_a_prefix_that_is_not_a_segment_boundary() { + let route = get("/v1"); + let routes = [route]; + assert!(select_route(&routes, &Method::GET, "/v1beta").is_none()); + assert!(select_route(&routes, &Method::GET, "/v1").is_some()); + } + + #[test] + fn rejects_a_method_outside_the_allowlist() { + let route = get("/v1"); + let routes = [route]; + assert!(select_route(&routes, &Method::POST, "/v1").is_none()); + // HEAD is not a routable method of the schema: 404, not a fallback. + assert!(select_route(&routes, &Method::HEAD, "/v1").is_none()); + } + + #[test] + fn ignores_disabled_routes() { + let mut route = get("/v1"); + route.enabled = false; + let routes = [route]; + assert!(select_route(&routes, &Method::GET, "/v1").is_none()); + } + + #[test] + fn ignores_grpc_only_routes() { + let route = route(RouteMatch { + http: None, + grpc: Some(crate::domain::model::GrpcMatch { + service: "pkg.Svc".to_owned(), + method: "Get".to_owned(), + }), + }); + let routes = [route]; + assert!(select_route(&routes, &Method::GET, "/v1").is_none()); + } + + #[test] + fn empty_suffix_is_the_exact_path() { + let route = get("/v1/chat"); + let routes = [route]; + let selected = select_route(&routes, &Method::GET, "/v1/chat").unwrap(); + assert_eq!(selected.suffix, ""); + } + + #[test] + fn appends_the_suffix_to_the_match_path() { + let match_rule = HttpMatch { + methods: vec![HttpMethod::Get], + path: "/v1/chat".to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: PathSuffixMode::Append, + }; + assert_eq!( + upstream_path(&match_rule, "completions").unwrap(), + "/v1/chat/completions" + ); + assert_eq!(upstream_path(&match_rule, "").unwrap(), "/v1/chat"); + } + + #[test] + fn appends_without_a_double_slash() { + let match_rule = HttpMatch { + methods: vec![HttpMethod::Get], + path: "/v1/".to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: PathSuffixMode::Append, + }; + assert_eq!(upstream_path(&match_rule, "chat").unwrap(), "/v1/chat"); + assert_eq!(upstream_path(&match_rule, "chat/").unwrap(), "/v1/chat/"); + } + + #[test] + fn rejects_a_suffix_on_a_disabled_mode() { + let match_rule = HttpMatch { + methods: vec![HttpMethod::Get], + path: "/v1".to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: PathSuffixMode::Disabled, + }; + let error = upstream_path(&match_rule, "chat").unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::Validation); + assert_eq!(upstream_path(&match_rule, "").unwrap(), "/v1"); + } + + /// A `..` segment — plain or escaped — must not escape the matched prefix. + #[test] + fn rejects_a_dot_segment_in_the_suffix() { + let rule = append_rule(); + for suffix in ["../admin", "%2E%2E/admin", "chat/../../admin", "."] { + let error = upstream_path(&rule, suffix).unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::Validation, "{suffix}"); + } + } + + /// An escaped `?` or `#` may not become a query or a fragment of the dial + /// target: the route's query allowlist is the only way to add one. + #[test] + fn rejects_a_query_or_fragment_in_the_suffix() { + let rule = append_rule(); + for suffix in ["chat%3Finjected%3D1", "chat%23fragment", "a%3Fb/c"] { + let error = upstream_path(&rule, suffix).unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::Validation, "{suffix}"); + } + } + + /// A segment that decodes to a separator stays escaped, so `%2F` cannot + /// become a second path segment behind the route's back. + #[test] + fn re_encodes_an_escaped_separator() { + let rule = append_rule(); + assert_eq!(upstream_path(&rule, "a%2Fb").unwrap(), "/v1/chat/a%2Fb"); + assert_eq!(upstream_path(&rule, "a b").unwrap(), "/v1/chat/a%20b"); + } + + fn append_rule() -> HttpMatch { + HttpMatch { + methods: vec![HttpMethod::Get], + path: "/v1/chat".to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: PathSuffixMode::Append, + } + } + + #[test] + fn elides_the_standard_port() { + assert_eq!(authority(&endpoint(Scheme::Https, "a.b", 443)), "a.b"); + assert_eq!(authority(&endpoint(Scheme::Http, "a.b", 80)), "a.b"); + assert_eq!(authority(&endpoint(Scheme::Https, "a.b", 8443)), "a.b:8443"); + } + + #[test] + fn builds_a_url_from_the_endpoint() { + let url = target_url( + &endpoint(Scheme::Http, "127.0.0.1", 8099), + "/v1", + "/v1/x", + Some("a=1"), + ) + .unwrap(); + assert_eq!(url.as_str(), "http://127.0.0.1:8099/v1/x?a=1"); + let plain = target_url(&endpoint(Scheme::Https, "a.b", 443), "/v1", "/v1", None).unwrap(); + assert_eq!(plain.as_str(), "https://a.b/v1"); + } + + #[test] + fn rejects_a_url_that_cannot_be_parsed() { + let error = target_url( + &endpoint(Scheme::Https, "bad host", 443), + "/v1", + "/v1", + None, + ) + .unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::Validation); + assert!(error.extensions().invalid_value.is_some()); + } + + /// The re-verification is the last line of defence: a dial target that + /// drifted from the parts it was built from is a 400, never a dial. + #[test] + fn rejects_a_url_that_escaped_its_parts() { + let target = endpoint(Scheme::Http, "a.b", 80); + // A path that does not carry the matched route prefix. + let error = target_url(&target, "/v1", "/other", None).unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::Validation); + // An authority that is not the selected endpoint's. + let url = Url::parse("http://elsewhere.example/v1") + .unwrap_or_else(|error| panic!("the fixture URL must parse: {error}")); + let error = super::verify_target(&url, &target, "/v1", None).unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::Validation); + } + + /// The query re-verification compares decoded parameter names, so a URL + /// that re-encoded its values still matches what the filter produced. + #[test] + fn the_query_re_verification_compares_parameter_names() { + let url = Url::parse("http://a.b/v1?a%20b=1&c=2") + .unwrap_or_else(|error| panic!("the fixture URL must parse: {error}")); + assert!(super::query_survived(&url, Some("a b=1&c=2"))); + assert!(!super::query_survived(&url, Some("a%20b=1"))); + assert!(!super::query_survived(&url, None)); + let empty = Url::parse("http://a.b/v1") + .unwrap_or_else(|error| panic!("the fixture URL must parse: {error}")); + assert!(super::query_survived(&empty, None)); + assert!(!super::query_survived(&empty, Some("a=1"))); + } + + /// A percent-encoded segment reaches the upstream with its escapes intact. + #[test] + fn url_round_trips_a_percent_encoded_path() { + let url: Url = target_url( + &endpoint(Scheme::Http, "h", 80), + "/v1", + "/v1/a%2Fb%20c", + None, + ) + .unwrap(); + assert_eq!(url.path(), "/v1/a%2Fb%20c"); + } + + /// A query the URL re-encoded keeps its parameter names. + #[test] + fn url_keeps_a_re_encoded_query() { + let url: Url = target_url( + &endpoint(Scheme::Http, "h", 80), + "/v1", + "/v1", + Some("a b=1&c=2"), + ) + .unwrap(); + assert_eq!(url.query(), Some("a%20b=1&c=2")); + } + + #[test] + fn keeps_the_allowlisted_query_parameters() { + let allowlist = vec!["a".to_owned(), "b".to_owned()]; + assert_eq!( + filter_query(&allowlist, Some("a=1&c=2&b=3&d=4")).as_deref(), + Some("a=1&b=3") + ); + assert_eq!(filter_query(&allowlist, Some("c=2")), None); + assert_eq!(filter_query(&allowlist, None), None); + assert_eq!(filter_query(&[], Some("a=1")), None); + } + + #[test] + fn keeps_encoded_and_repeated_parameters() { + let allowlist = vec!["a b".to_owned()]; + assert_eq!( + filter_query(&allowlist, Some("a%20b=1&a%20b=2")).as_deref(), + Some("a%20b=1&a%20b=2") + ); + } + + #[test] + fn accepts_only_bare_hosts_as_target() { + assert_eq!( + validate_target_host("Us.Vendor.com").unwrap(), + "us.vendor.com" + ); + assert_eq!(validate_target_host("10.0.1.2").unwrap(), "10.0.1.2"); + for value in ["us.vendor.com:8443", "/v1", "a b", ""] { + assert_eq!( + validate_target_host(value).unwrap_err().kind(), + &OagwErrorKind::InvalidTargetHost + ); + } + } + + #[test] + fn finds_an_endpoint_by_host_case_insensitively() { + let endpoints = [ + endpoint(Scheme::Https, "us.vendor.com", 443), + endpoint(Scheme::Https, "eu.vendor.com", 443), + ]; + assert_eq!( + endpoint_index_for_host(&endpoints, "EU.VENDOR.com"), + Some(1) + ); + assert_eq!(endpoint_index_for_host(&endpoints, "ap.vendor.com"), None); + } + + #[test] + fn suffix_alias_requires_a_target_host() { + let pool = upstream( + vec![ + endpoint(Scheme::Https, "us.vendor.com", 443), + endpoint(Scheme::Https, "eu.vendor.com", 443), + ], + "vendor.com", + ); + assert!(requires_target_host(&pool)); + let error = select_endpoint(&pool, None, || 0).unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::MissingTargetHost); + assert!(error.extensions().valid_hosts.is_some()); + } + + #[test] + fn single_endpoint_never_needs_a_target_host() { + let single = upstream( + vec![endpoint(Scheme::Https, "api.vendor.com", 443)], + "api.vendor.com", + ); + assert!(!requires_target_host(&single)); + let picked = select_endpoint(&single, None, || 3).unwrap(); + assert_eq!(picked.endpoint.host, "api.vendor.com"); + assert_eq!(picked.method, SelectionMethod::Default); + } + + #[test] + fn explicit_alias_of_a_full_host_does_not_need_a_target_host() { + let pool = upstream( + vec![ + endpoint(Scheme::Https, "us.vendor.com", 443), + endpoint(Scheme::Https, "eu.vendor.com", 443), + ], + "pool", + ); + assert!(!requires_target_host(&pool)); + } + + #[test] + fn ip_pool_alias_does_not_need_a_target_host() { + let pool = upstream( + vec![ + endpoint(Scheme::Https, "10.0.1.1", 443), + endpoint(Scheme::Https, "10.0.1.2", 443), + ], + "payment-pool", + ); + assert!(!requires_target_host(&pool)); + } + + #[test] + fn unknown_target_host_lists_the_valid_hosts() { + let pool = upstream( + vec![ + endpoint(Scheme::Https, "us.vendor.com", 443), + endpoint(Scheme::Https, "eu.vendor.com", 443), + ], + "vendor.com", + ); + let error = select_endpoint(&pool, Some("ap.vendor.com"), || 0).unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::UnknownTargetHost); + assert_eq!( + error.extensions().invalid_value.as_deref(), + Some("ap.vendor.com") + ); + } + + #[test] + fn target_host_pins_the_endpoint_in_any_pool_size() { + let pool = upstream( + vec![ + endpoint(Scheme::Https, "us.vendor.com", 443), + endpoint(Scheme::Https, "eu.vendor.com", 443), + ], + "vendor.com", + ); + let pinned = select_endpoint(&pool, Some("eu.vendor.com"), || 0).unwrap(); + assert_eq!(pinned.endpoint.host, "eu.vendor.com"); + assert_eq!(pinned.method, SelectionMethod::ExplicitHeader); + } + + #[test] + fn empty_pool_is_rejected() { + let pool = upstream(Vec::new(), "empty"); + let error = select_endpoint(&pool, None, || 0).unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::Validation); + } + + #[test] + fn flags_plaintext_when_the_switch_is_off() { + let http = endpoint(Scheme::Http, "a.b", 80); + assert!(super::plaintext_disallowed(&http, false)); + assert!(!super::plaintext_disallowed(&http, true)); + } + + #[test] + fn remainder_needs_a_full_segment() { + assert_eq!(remainder_after_prefix("/v1", "/v1/x").as_deref(), Some("x")); + assert_eq!(remainder_after_prefix("/v1", "/v1").as_deref(), Some("")); + assert!(remainder_after_prefix("/v1", "/v1x").is_none()); + } + + #[test] + fn problem_extension_carries_the_alias() { + let pool = upstream( + vec![ + endpoint(Scheme::Https, "us.vendor.com", 443), + endpoint(Scheme::Https, "eu.vendor.com", 443), + ], + "vendor.com", + ); + let error = select_endpoint(&pool, None, || 0).unwrap_err(); + assert_eq!(error.extensions().alias.as_deref(), Some("vendor.com")); + assert_eq!( + error.gts_type(), + crate::error::OagwErrorKind::MissingTargetHost.gts_type(ResourceKind::Upstream) + ); + } + + #[test] + fn ssrf_policy_stays_out_of_the_data_plane() { + // The write path validates the SSRF lists; the data plane re-checks + // them at dial time (`check_egress`), so no routing decision here + // needs the policy. + let _policy = SsrfPolicy::default(); + } +} diff --git a/gears/system/oagw/oagw/src/domain/proxy/service.rs b/gears/system/oagw/oagw/src/domain/proxy/service.rs new file mode 100644 index 0000000..d7ef03b --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/proxy/service.rs @@ -0,0 +1,2050 @@ +// Created: 2026-08-31 by Constructor Tech +//! The proxy data-plane service (DESIGN §3.5 "Proxy Request Flow"). +//! +//! One [`ProxyService`] is built per gear configuration and owns the single +//! outbound `toolkit_http::HttpClient` (no retries, no redirects) plus the +//! per-upstream round-robin cursors. Every request walks the same pipeline: +//! +//! 1. resolve the alias across the tenant chain (shadowing, closest wins), +//! 2. match the route (method, longest prefix), +//! 3. ask the per-upstream circuit breaker whether the upstream may be dialled +//! at all (PRD `cpt-cf-oagw-nfr-high-availability`), +//! 4. validate the declared framing before a byte is buffered, +//! 5. buffer the body under the configured cap and re-check its length, +//! 6. select the target endpoint (ADR-0001 matrix) and re-check the egress +//! policy, +//! 7. rebuild the path, filter the query, build and re-verify the URL, +//! 8. transform the headers, +//! 9. dial the upstream once under the configured timeout, +//! 10. stream the response back with the upstream error-source marker. +//! +//! # Deviations from the upstream JSON schema +//! +//! * `upstream.v1` declares `"passthrough": "none"` as the default of +//! `headers.request`; OAGW follows the schema literally (see +//! [`crate::domain::proxy`] for the full note). +//! * The SSRF policy of the deployment is re-applied at dial time. The write +//! path has already checked the configured host lists; the data plane +//! re-checks the *selected* endpoint, because a record may have been stored +//! before the policy was tightened or through a path that skipped it. With +//! `oagw.config.ssrf_policy.enabled: false` — the graded configuration — the +//! data plane adds no rejection of its own. +//! * WebSocket proxying covers `http`-scheme upstreams only. The session is +//! bridged by a plaintext HTTP/1.1 dialer of its own: this slice deliberately +//! builds no second TLS configuration into the data plane, because the graded +//! deployment (`config/e2e-local.yaml`, `allow_http_upstream: true`) is +//! http-only. The deferred requirement is the WebSocket/WebTransport session +//! flow of `PRD.md:305` (`cpt-cf-oagw-fr-streaming`); closing it needs a TLS +//! connector wired to the deployment's TLS settings — `tokio-rustls` and +//! `rustls` are already workspace dependencies, so no new dependency is +//! involved — not a change to the bridge itself. A handshake against any +//! other scheme is refused 503 `link.unavailable.v1` before a byte is +//! dialled (see [`check_upgradable`]). +//! * Live sessions are capped by `oagw.config.max_websocket_sessions`: a +//! session holds two sockets for as long as the client keeps them, so the +//! cap is what keeps a handful of clients from pinning the data plane. +//! * The acceptance of a session is judged on the upstream's head (RFC 6455 +//! §4.2.2) before the gateway answers, because hyper arms the response +//! upgrade from the status alone: a bare 101 would leave the client with a +//! socket whose first read is EOF. An upstream that switches the status +//! without the protocol is a 502 `protocol.error.v1`. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Duration; + +use bytes::{Bytes, BytesMut}; +use dashmap::DashMap; +use http::{HeaderMap, HeaderValue, Method}; +use http_body_util::{BodyExt, Full}; +use hyper_util::client::legacy::Client as LegacyClient; +use hyper_util::client::legacy::connect::HttpConnector; +use hyper_util::rt::{TokioExecutor, TokioIo}; +use tokio::io::AsyncWriteExt; +use tokio::time::Instant; +use toolkit_http::{ + HttpClient, HttpClientBuilder, HttpClientConfig, HttpError, HttpResponse, RequestBuilder, + ResponseBody, TransportSecurity, +}; +use toolkit_security::SecurityContext; +use tracing::Instrument as _; +use uuid::Uuid; + +use crate::config::{OagwConfig, SsrfPolicy}; +use crate::domain::lifecycle::UpstreamRemoval; +use crate::domain::model::{Endpoint, Upstream}; +use crate::domain::proxy::chain::TenantChain; +use crate::domain::proxy::plugins::{self as plugin_pipeline, PluginChain, upstream_ref}; +use crate::domain::proxy::{breaker, cors, headers, ratelimit, routing}; +use crate::domain::store::Store; +use crate::domain::validation::{check_egress, validate_framing}; +use crate::error::{ERROR_SOURCE_UPSTREAM, OagwError, OagwErrorKind, OagwResult, ResourceKind}; +use crate::infra::metrics; +use crate::infra::plugin::PluginRegistries; +use crate::infra::plugin::secrets::CredStore; +use crate::infra::plugin::traits::ErrorContext; +use crate::infra::plugin::traits::{PluginConfig, RequestContext, ResponseContext}; + +/// Boxed error of a streamed body (`axum::Error` compatible). +type BoxError = Box; + +/// Error surfaced to the client when a forwarded body cannot be completed. +/// +/// The response head has already been sent at that point, so the failure can +/// only truncate the stream: the body ends early and the connection is +/// dropped, which the client sees as a broken framing rather than as a +/// complete response. The classification is logged for the operator. +#[derive(Clone, Copy, Debug, thiserror::Error)] +enum BodyFailure { + /// A frame did not arrive within the idle budget. + #[error("upstream body transfer timed out")] + IdleTimeout, + /// The transfer outlived the overall budget of a buffered body. + #[error("upstream body transfer exceeded its budget")] + Budget, + /// The upstream connection failed mid-transfer. + #[error("upstream body transfer failed")] + Transfer, +} + +impl BodyFailure { + /// GTS slug of the failure, for the operator log. + const fn slug(self) -> &'static str { + match self { + BodyFailure::IdleTimeout => "timeout.idle.v1", + BodyFailure::Budget => "timeout.request.v1", + BodyFailure::Transfer => "stream.aborted.v1", + } + } + + /// Error kind of the failure, as the circuit breaker classifies it. + /// + /// All three are the upstream failing to finish what it started, which is + /// the evidence the breaker's window is for. + const fn kind(self) -> OagwErrorKind { + match self { + BodyFailure::IdleTimeout => OagwErrorKind::IdleTimeout, + BodyFailure::Budget => OagwErrorKind::RequestTimeout, + BodyFailure::Transfer => OagwErrorKind::StreamAborted, + } + } +} + +/// Streaming state of a forwarded upstream body. +struct BodyStream { + body: ResponseBody, + /// Longest silence tolerated between two frames. + idle: Duration, + /// Overall budget of the transfer; `None` for event streams. + deadline: Option, + /// What the body tells the circuit breaker when it fails. The head of this + /// very request has already reported, so a body that breaks is the second + /// observation of the same request. + report: breaker::BodyReport, +} + +impl BodyStream { + /// Next item of the stream, or `None` when the body is complete. + /// + /// The state is owned as [`Box`] rather than by value so that a + /// [`Frame::Data`] hands the **same** allocation back to [`StreamState`]: + /// the box is what `unfold` moves from frame to frame, and rebuilding it + /// here would cost one heap allocation and one deallocation per frame of + /// every streamed body. Allocated once per body, in [`forward_body`]. + async fn next(mut self: Box) -> Option<(Result, StreamState)> { + if self.expired() { + self.report_failure(BodyFailure::Budget); + return Some(BodyStream::stop(BodyFailure::Budget)); + } + match self.frame().await { + Frame::Timeout => { + self.report_failure(BodyFailure::IdleTimeout); + Some(BodyStream::stop(BodyFailure::IdleTimeout)) + } + Frame::End => None, + Frame::Data(data) => Some((Ok(data), StreamState::Open(self))), + Frame::Failed(error) => { + tracing::warn!(error = %error, "upstream body transfer failed"); + self.report_failure(BodyFailure::Transfer); + Some(BodyStream::stop(BodyFailure::Transfer)) + } + } + } + + /// Tell the breaker that this body never finished. + /// + /// The head reported the status the upstream answered with, so this is the + /// second report of one request: the first counted the answer, this one + /// counts that the upstream never finished giving it. + fn report_failure(&self, failure: BodyFailure) { + self.report.observe(failure.kind()); + } + + /// Close the stream after a failure: the head is already committed, so the + /// body ends early and the framing breaks. + fn stop(failure: BodyFailure) -> (Result, StreamState) { + tracing::warn!(slug = failure.slug(), error = %failure, "upstream body transfer aborted"); + (Err(BoxError::from(failure)), StreamState::Finished) + } + + /// Whether the overall transfer budget ran out. + fn expired(&self) -> bool { + let Some(deadline) = self.deadline else { + return false; + }; + let expired = Instant::now() >= deadline; + if expired { + tracing::warn!("upstream body exceeded its overall budget"); + } + expired + } + + /// Await the next frame under the idle timeout. + async fn frame(&mut self) -> Frame { + let Ok(frame) = tokio::time::timeout(self.idle, self.body.frame()).await else { + return Frame::Timeout; + }; + match frame { + None => Frame::End, + Some(Ok(frame)) => match frame.into_data() { + Ok(data) => Frame::Data(data), + // A trailer ends the body; there is nothing to forward. + Err(_) => Frame::End, + }, + Some(Err(error)) => Frame::Failed(error), + } + } +} + +/// Whether a forwarded body can still produce frames. +/// +/// A body that reported a failure is **terminal**: the next poll of the stream +/// ends it instead of re-entering the error path, so a stalled upstream costs +/// one error item and not one per poll. +enum StreamState { + /// More frames may arrive. + /// + /// Boxed because the state of a stream that carries a body, an idle budget + /// and its report to the breaker is an order of magnitude larger than the + /// terminal state next to it, and the enum is what `unfold` moves on every + /// frame. The box is the state: [`BodyStream::next`] takes it, drives the + /// body through it and hands the same allocation back, so the one + /// allocation [`forward_body`] makes is the only one the body ever costs. + Open(Box), + /// The transfer ended or failed; nothing more is forwarded. + Finished, +} + +/// Outcome of awaiting one upstream body frame. +enum Frame { + /// A frame did not arrive within the idle budget. + Timeout, + /// The body is complete. + End, + /// A data frame to forward. + Data(Bytes), + /// The upstream connection failed mid-transfer. + Failed(BoxError), +} + +/// Streams an upstream body under the idle timeout. +/// +/// `report` is what the body tells the circuit breaker when it fails: a 200 +/// head followed by a body the upstream never finished is the slow-upstream +/// failure the head cannot see, and the head alone would record it as health. +fn forward_body( + body: ResponseBody, + idle: Duration, + deadline: Option, + report: breaker::BodyReport, +) -> impl futures_util::Stream> + Send + 'static { + futures_util::stream::unfold( + StreamState::Open(Box::new(BodyStream { + body, + idle, + deadline, + report, + })), + |state| async move { + match state { + StreamState::Open(stream) => stream.next().await, + StreamState::Finished => None, + } + }, + ) +} + +/// Dial client of a WebSocket handshake: a plain HTTP/1.1 dialer whose +/// upgraded connection hands its two halves to the bridge task. +type HandshakeClient = LegacyClient>; + +/// Proxy data plane of the gear. +pub struct ProxyService { + store: Arc, + chain: Arc, + client: HttpClient, + /// Client of a WebSocket handshake (PRD session flows). Separate from + /// `client` because it must not pool the connection it upgrades and must + /// leave the upgraded socket untouched: the session has no budget. + ws_client: HandshakeClient, + /// Free slots of a live WebSocket session (PRD session flows). One permit + /// per bridged session, taken before the dial and held for as long as the + /// two sockets stay open, so the cap bounds what is actually running. + sessions: Arc, + /// Configured ceiling of [`ProxyService::sessions`], for the refusal detail. + max_sessions: usize, + /// Plugin registries of the data plane (ADR-0002): one per family, built + /// once over the built-ins. + plugins: Arc, + /// Round-robin cursors, one per upstream pool: `dashmap` keeps a cursor + /// next to its pool without serialising unrelated upstreams. + round_robin: DashMap, + /// Token buckets of the data plane (ADR-0003), keyed by the resolved + /// counter key. They live as long as the service does, so a quota survives + /// the requests that spend it. + buckets: ratelimit::Buckets, + /// Circuit breakers of the data plane (PRD + /// `cpt-cf-oagw-nfr-high-availability`), one per upstream id, for as long + /// as the service does: an open breaker survives the requests that tripped + /// it and outlives the cooldown. + breakers: Arc, + /// Budget of the dial plus the wait for the response head. + head_timeout: Duration, + /// Silence tolerated between two frames of a forwarded body. + body_idle: Duration, + /// Overall budget of a forwarded body that is not an event stream. + body_stream: Duration, + /// Hard request-body limit in bytes. + max_body_bytes: u64, + /// Whether plaintext upstream endpoints may be dialled. + allow_http_upstream: bool, + /// Server-side request forgery guards, re-applied at dial time. + ssrf: SsrfPolicy, + /// Instruments of DESIGN §4.2, emitted against the meter provider the host + /// installs. Every emit is fire-and-forget: a lost data point is never a + /// failed request. + metrics: metrics::ProxyMetrics, +} + +impl ProxyService { + /// Build a data plane over `store`, `chain`, `client` and `credstore`. + /// + /// `credstore` is what the auth plugins resolve their `cred://` references + /// through. A deployment that wires none still boots: the plugin registries + /// degrade to the plugins that need no credential store, and an upstream + /// whose binding needs one fails its requests closed (503 + /// `link.unavailable.v1`) instead of forwarding them unauthenticated. + #[must_use] + pub fn new( + store: Arc, + chain: Arc, + client: HttpClient, + credstore: Option, + config: &OagwConfig, + ) -> Arc { + Arc::new(Self { + store, + chain, + client, + ws_client: LegacyClient::builder(TokioExecutor::new()).build_http::>(), + sessions: Arc::new(tokio::sync::Semaphore::new(config.max_websocket_sessions)), + max_sessions: config.max_websocket_sessions, + plugins: Arc::new(PluginRegistries::with_builtins( + credstore, + Some(Self::client_config(config)), + config.token_cache_config(), + )), + round_robin: DashMap::new(), + buckets: ratelimit::Buckets::default(), + breakers: Arc::new(breaker::CircuitBreakers::new( + &config.circuit_breaker, + config.head_timeout(), + )), + head_timeout: config.head_timeout(), + body_idle: config.body_idle_timeout(), + body_stream: config.body_stream_timeout(), + max_body_bytes: config.max_body_bytes, + allow_http_upstream: config.allow_http_upstream, + ssrf: config.ssrf_policy.clone(), + metrics: metrics::ProxyMetrics::from_global(), + }) + } + + /// Outbound client for `config`. + /// + /// Built **once** per configuration: no retries (the gateway must not + /// re-send a client's request), no redirects (3xx pass through) and a + /// transport decision that follows `oagw.config.allow_http_upstream`. The + /// plaintext decision therefore belongs to the transport, not to the + /// `scheme` enum, which always accepts `http`. + /// + /// # Errors + /// 500 when the client cannot be constructed; under `--features fips` also + /// when plaintext upstreams are allowed, which such a build rejects. + pub fn build_client(config: &OagwConfig) -> OagwResult { + HttpClientBuilder::with_config(Self::client_config(config)) + .build() + .map_err(|error| { + OagwError::new( + OagwErrorKind::Internal, + format!("outbound HTTP client unavailable: {error}"), + ) + }) + } + + /// HTTP client configuration of the data plane. + /// + /// Shared with the `OAuth2` token exchange, so an `IdP` behind the same + /// egress policy is reached exactly the way an upstream is. + fn client_config(config: &OagwConfig) -> HttpClientConfig { + let mut http_config = HttpClientConfig::proxy(); + http_config.transport = if config.allow_http_upstream { + TransportSecurity::AllowInsecureHttp + } else { + TransportSecurity::TlsOnly + }; + http_config + } + + /// The plugin registries of this data plane. + #[must_use] + pub fn plugins(&self) -> &PluginRegistries { + &self.plugins + } + + /// Proxy one request (DESIGN §3.5). + /// + /// # Errors + /// Every problem of the DESIGN §3.3 data-plane table; a returned error is + /// rendered as `X-OAGW-Error-Source: gateway`. + pub async fn proxy( + &self, + ctx: &SecurityContext, + alias: &str, + request_path: &str, + request: http::Request, + ) -> OagwResult { + let (parts, body) = request.into_parts(); + let method = parts.method; + let inbound = parts.headers; + let query = parts.uri.query().map(str::to_owned); + // The timer opens here, so `phase = total` is the whole of the request + // the data plane saw: resolution, guards, dial and answer. A refusal is + // a shorter request, not an untimed one. + let started = Instant::now(); + // The handle of the *client* connection the platform armed for this + // request: a 101 hands that socket over, so the data plane has to keep + // it from the moment the request arrives. + let client_upgrade = parts.extensions.get::().cloned(); + let target_host = inbound + .get(routing::TARGET_HOST_HEADER) + .and_then(|value| value.to_str().ok()); + + // A CORS preflight is answered before anything is resolved: browser + // preflights carry no credentials, so there is no tenant context to + // resolve an upstream with and no plugin chain to run (ADR-0004 + // "Preflight Request Handling"). + if cors::is_preflight(&method, &inbound) { + return Ok(cors::preflight(&inbound)); + } + + let upstream = self.resolve_upstream(ctx, alias).await?; + let routes = self + .store + .list_routes_for_upstream(upstream.tenant_id, upstream.id)?; + let selection = routing::select_route(&routes, &method, request_path) + .ok_or_else(|| route_not_found(alias, request_path))?; + // From here on the request has a host and a route to be attributed to, + // which is what every instrument of DESIGN §4.2 is labelled with. + // `http.route` is the matched prefix, not the raw request path: the + // segments behind it are client input. + let host = upstream.alias.as_str(); + let route = selection.http.path.as_str(); + let method_label = metrics::normalize_method(&method); + // The gauge is per host, so a request only counts once it has one. + let _in_flight = self.metrics.in_flight(host); + // The chain is resolved before the guards, so a CORS or a rate limit + // refusal is still enriched by the error-side plugins (DESIGN §3.3). + let chain = plugin_pipeline::resolve_chain( + &upstream, + Some(selection.route), + &self.plugins, + self.store.as_ref(), + )?; + // CORS is scored before the rate limit, so a disallowed origin is + // never counted against a quota (ADR-0004 "Actual Request Handling"). + let verdict = self + .guard(ctx, &upstream, &selection, &method, &inbound) + .await; + let Verdict { cors, quota } = verdict; + // The breaker is asked after the client-facing guards: a rate-limit + // refusal is the client's own budget and the upstream was never asked, + // so it must not consume a probe slot of a half-open breaker, and the + // 429 it already earned is more specific than a breaker refusal. + let admission = match "a { + Ok(_) => self.breakers.admit( + upstream.id, + &upstream.alias, + std::time::Instant::now(), + &self.metrics, + ), + Err(_) => breaker::Admit::Dial, + }; + // The probe token is what proves the role this request was admitted as; + // it is threaded down to the dial and the body, which are the two + // observers that report, so a half-open breaker is moved only by the + // request it is actually waiting for. + let probe = admission.probe(); + // One exit, one measurement: a refusal and a dial failure are the same + // outcome for the instruments of DESIGN §4.2, a request that resolved + // to an upstream and a route and was answered with a status. + let (granted, outcome) = match (quota, admission) { + (Err(error), _) => (None, Err(error)), + (Ok(quota), breaker::Admit::Refuse { retry_after_secs }) => ( + Some(quota), + Err(breaker_refusal(&upstream.alias, retry_after_secs)), + ), + (Ok(quota), _) => ( + Some(quota), + self.forward( + ctx, + &upstream, + &chain, + &selection, + &method, + &inbound, + query.as_deref(), + target_host, + client_upgrade.as_ref(), + probe, + body, + ) + .await, + ), + }; + + // On the success path the status is the upstream's. On the failure path + // no upstream status exists, so the counter carries the status the + // gateway answered with; `oagw_errors_total` marks those requests. + let status = match &outcome { + Ok(response) => response.status().as_u16(), + Err(error) => error.status(), + }; + self.metrics.request(host, method_label, route, status); + self.metrics + .duration(host, route, started.elapsed().as_secs_f64()); + match outcome { + Ok(mut response) => { + // An enabled policy is authoritative for its origins, so the + // upstream's own CORS answer never reaches the client next to + // the gateway's. + if !cors.is_empty() { + cors::strip_upstream_headers(response.headers_mut()); + } + let headers = response.headers_mut(); + headers.extend(cors); + if let Some(quota) = granted { + headers.extend(quota); + } + Ok(response) + } + Err(error) => { + self.metrics.error(host, route, &error.gts_type()); + let failed = self.error_phase(&chain, ctx, &upstream, error).await; + Err(failed.with_cors_headers(&cors)) + } + } + } + + /// Gate a resolved request on its CORS policy and its quota. + /// + /// Both run after the upstream and the route are known and before anything + /// is dialled or buffered, so a refusal costs neither an upstream call nor + /// a body read. CORS first: a disallowed origin is not a client the quota + /// should have to serve. + /// + /// Returns the headers a forwarded response carries for either guard. + async fn guard( + &self, + ctx: &SecurityContext, + upstream: &Upstream, + selection: &routing::RouteSelection<'_>, + method: &Method, + inbound: &HeaderMap, + ) -> Verdict { + let route = selection.route; + // One walk serves both steps: the chain is the same, so a request that + // declares a policy anywhere in it pays a single store read for it. A + // request with no policy at all walks nothing. + let declared = route.rate_limit.is_some() + || route.cors.is_some() + || upstream.rate_limit.is_some() + || upstream.cors.is_some(); + let ancestors = if declared { + self.ancestor_upstreams(ctx, upstream) + .await + .unwrap_or_default() + } else { + Vec::new() + }; + let cors = match cors_step(upstream, route, method, inbound, &ancestors) { + Ok(headers) => headers, + // The refusal carries its own CORS answer already. + Err(error) => { + return Verdict { + cors: HeaderMap::new(), + quota: Err(error), + }; + } + }; + Verdict { + cors, + quota: self + .quota(ctx, upstream, selection, inbound, &ancestors) + .await, + } + } + + /// Score the request against the effective limit of its chain. + /// + /// The route's own policy is the most specific level (ADR-0003 Example 3) + /// and the `enforce` caps of the ancestor upstreams stay active on top of + /// it. + /// + /// # Errors + /// 429 when the bucket is empty and the strategy does not tolerate it. + async fn quota( + &self, + ctx: &SecurityContext, + upstream: &Upstream, + selection: &routing::RouteSelection<'_>, + inbound: &HeaderMap, + ancestors: &[Upstream], + ) -> OagwResult { + let route = selection.route; + let Some(limit) = ratelimit::effective_limit(&rate_levels(route, upstream, ancestors)) + else { + return Ok(HeaderMap::new()); + }; + let key = ratelimit::counter_key( + upstream.id, + &limit, + ctx.subject_tenant_id(), + ctx.subject_id(), + route.id, + forwarded_for(inbound), + ); + let outcome = match ratelimit::enforce(&self.buckets, &key, &limit, self.head_timeout).await + { + Ok(outcome) => outcome, + // The 429 decision point: the state the limiter gave up on is the + // one fact the usage gauge can carry, and `path` is the matched + // prefix, since the raw request path is unbounded cardinality. + Err(refusal) => { + self.metrics + .rate_limit_exceeded(&upstream.alias, &selection.http.path); + self.metrics.rate_limit_usage( + &upstream.alias, + &selection.http.path, + refusal.usage_ratio(), + ); + return Err(refusal.error); + } + }; + Ok(if limit.response_headers { + ratelimit::headers(&outcome) + } else { + HeaderMap::new() + }) + } + + /// The same-alias upstreams of the tenant chain above the resolved one, + /// nearest first. + /// + /// Ancestor records that are disabled or that resolve to nothing + /// contribute nothing: an ancestor that is not routable cannot bind a + /// budget either. + async fn ancestor_upstreams( + &self, + ctx: &SecurityContext, + upstream: &Upstream, + ) -> OagwResult> { + let wanted = crate::domain::alias::normalize(&upstream.alias); + let mut records = Vec::new(); + for tenant in self.chain.ancestors(ctx, upstream.tenant_id).await? { + if let Some(record) = self.store.find_upstream_by_alias(tenant, &wanted)? + && record.enabled + { + records.push(record); + } + } + Ok(records) + } + + /// Forward one gated request to the upstream it resolved to. + /// + /// # Errors + /// Every problem of the DESIGN §3.3 data-plane table from the framing + /// validation on. + #[allow( + clippy::too_many_arguments, + reason = "the tail of the pipeline is one step and takes what the earlier phases gathered" + )] + async fn forward( + &self, + ctx: &SecurityContext, + upstream: &Upstream, + chain: &PluginChain, + selection: &routing::RouteSelection<'_>, + method: &Method, + inbound: &HeaderMap, + query: Option<&str>, + target_host: Option<&str>, + client_upgrade: Option<&hyper::upgrade::OnUpgrade>, + probe: Option, + body: axum::body::Body, + ) -> OagwResult { + let declared = self.framing(inbound)?; + // A handshake is recognised before the body is read: what follows its + // head decides whether the request can be a handshake at all. + let handshake = headers::is_websocket_handshake(method, inbound); + let body = self.read_body(body, declared).await?; + // A handshake carries no body (RFC 6455 §4.1): bytes after its head are + // not part of it, and dialling them as if they were would leave the + // handshake client waiting for a body it is never given. + if handshake && !body.is_empty() { + return Err(OagwError::validation( + "a websocket handshake carries no body", + )); + } + let picked = + routing::select_endpoint(upstream, target_host, || self.next_index(upstream.id))?; + let endpoint = picked.endpoint; + self.check_egress(endpoint)?; + // A handshake is the one request whose answer switches protocols, so it + // has to be dialled with a client that can hand the socket over. Every + // refusal below happens before a byte is dialled or a plugin runs. + let session = if handshake { + check_upgradable(endpoint)?; + if client_upgrade.is_none() { + return Err(upgrade_unavailable( + "the platform did not offer this connection an upgrade", + )); + } + // The slot is taken before the dial and held until the session + // ends, so a handshake that never opens cannot squat a permit + // either: it is bounded by the head budget instead. + Some(self.take_session_slot()?) + } else { + None + }; + let path = routing::upstream_path(selection.http, &selection.suffix)?; + let filtered = routing::filter_query(&selection.http.query_allowlist, query); + let mut outbound = headers::outbound_request_headers( + inbound, + upstream + .headers + .as_ref() + .and_then(|rules| rules.request.as_ref()), + byte_len(&body), + )?; + // The one exemption of the strip list: a handshake keeps the two + // headers the upgrade is made of (DESIGN §3.2 header table). + if handshake { + headers::restore_upgrade_headers(&mut outbound, inbound); + } + set_authority(&mut outbound, endpoint)?; + + // The plugin chain sees the header set that is about to be dialled and + // may still rewrite the query (DESIGN §3.2: "plugin mutable"). It runs + // after the header rules, so an injected credential is never dropped + // again, and before the URL is finalised, so a credential written into + // the query is part of the request that is signed off. + let query_before = filtered.clone().unwrap_or_default(); + let mut request = RequestContext { + security: ctx.clone(), + upstream: upstream_ref(upstream.id, &upstream.alias), + method: method.clone(), + headers: std::mem::take(&mut outbound), + query: query_before, + config: PluginConfig::empty(), + }; + self.run_request_phase(chain, &mut request).await?; + let forwarded_query = if request.query == filtered.clone().unwrap_or_default() { + filtered + } else { + Some(request.query) + }; + // From here on a failure is a problem document the error-side plugins + // may still enrich; the failure itself is never theirs to replace. + let url = routing::target_url( + endpoint, + &selection.http.path, + &path, + forwarded_query.as_deref(), + )?; + if handshake { + let client_upgrade = client_upgrade.cloned().unwrap_or_else(|| { + unreachable!("a handshake reached the dial without an upgrade handle") + }); + // Applied a second time, so a plugin request phase cannot break the + // handshake any more than a header rule can (DESIGN §3.2). The + // values are canonical either way, which is why re-applying cannot + // undo what a plugin legitimately added elsewhere. + headers::restore_upgrade_headers(&mut request.headers, inbound); + // `session` is `Some` here by the same argument as above; dropping + // it releases the slot, which is what a non-101 answer wants: the + // session it was taken for never happens. + self.dial(upstream, &picked); + let handshake = self.send_handshake(url.as_str(), request.headers).await; + self.report( + upstream, + handshake.as_ref().map(http::Response::status), + probe, + ); + let response = handshake?; + // The upstream decides: a 101 switches protocols, anything else is + // an ordinary answer the client reads as it would have without the + // gateway — the refused handshake is exactly that. + return if response.status() == http::StatusCode::SWITCHING_PROTOCOLS { + self.switch_protocols(response, upstream, chain, ctx, client_upgrade, session) + .await + } else { + self.respond(response.map(streamed), upstream, chain, ctx, probe) + .await + }; + } + self.dial(upstream, &picked); + let dial = self.send(method, url.as_str(), request.headers, body).await; + self.report(upstream, dial.as_ref().map(HttpResponse::status), probe); + let response = dial?; + self.respond(response.into_inner(), upstream, chain, ctx, probe) + .await + } + + /// Tell the breaker what one dialled request saw of its upstream. + /// + /// Only a request that dialled reports, and the classification is + /// [`breaker::is_health_failure`]: the status the upstream answered with, or + /// the reason the dial failed. Nothing else reports — a refusal the gateway + /// answered before the dial is not evidence about the upstream. `probe` is + /// the token the request was admitted with, which is what lets a half-open + /// breaker tell its own probe from a request that was dialled earlier. + fn report( + &self, + upstream: &Upstream, + answered: Result, + probe: Option, + ) { + let observed = match answered { + Ok(status) => breaker::Observed::Answered(status), + Err(error) => breaker::Observed::Failed(*error.kind()), + }; + self.breakers.record( + upstream.id, + &upstream.alias, + probe, + observed, + std::time::Instant::now(), + &self.metrics, + ); + } + + /// Record the endpoint the request is about to dial (DESIGN §4.2). + /// + /// Both counters are dial counters, so they are emitted at the dial itself + /// and not at the selection: a request the egress policy, the upgrade check + /// or the plugin phase refused never dialled anything. The target-host + /// counter counts only the requests that pinned their endpoint, since a + /// round-robin or a single-endpoint dial used no header at all. + fn dial(&self, upstream: &Upstream, picked: &routing::Selection<'_>) { + let method = picked.method.as_str(); + self.metrics + .endpoint_selected(upstream.id, &picked.endpoint.host, method); + if picked.method == routing::SelectionMethod::ExplicitHeader { + self.metrics + .target_host_used(upstream.id, &picked.endpoint.host); + } + } + + /// Run the error-side plugin phase over a gateway failure. + /// + /// The transforms may add to the problem's extensions; the status and the + /// detail the gateway decided on are reported unchanged + /// ([`plugin_pipeline::run_error_phase`] enforces that). + async fn error_phase( + &self, + chain: &PluginChain, + ctx: &SecurityContext, + upstream: &Upstream, + error: OagwError, + ) -> OagwError { + let mut context = ErrorContext { + security: ctx.clone(), + upstream: upstream_ref(upstream.id, &upstream.alias), + error, + config: PluginConfig::empty(), + }; + plugin_pipeline::run_error_phase(chain, &mut context).await; + context.error + } + + /// Run the request-side plugin phase under the head budget. + /// + /// A hung credential source must not hang the request: the phase shares the + /// budget of the response head, which is also what the dial itself gets. + async fn run_request_phase( + &self, + chain: &PluginChain, + ctx: &mut RequestContext, + ) -> OagwResult<()> { + match tokio::time::timeout( + self.head_timeout, + plugin_pipeline::run_request_phase(chain, ctx), + ) + .await + { + Ok(outcome) => outcome, + Err(_) => Err(OagwError::new( + OagwErrorKind::RequestTimeout, + format!( + "the plugin chain did not finish within {:?}", + self.head_timeout + ), + )), + } + } + + /// Egress guard of the selected endpoint (DESIGN §4.4). + /// + /// Re-applies the deployment's SSRF policy to the endpoint that is about to + /// be dialled. The policy is off in the graded configuration, in which case + /// this adds no rejection. + /// + /// # Errors + /// 503 when the policy refuses the host. + fn check_egress(&self, endpoint: &Endpoint) -> OagwResult<()> { + if let Err(error) = check_egress(&self.ssrf, &endpoint.host) { + tracing::warn!(host = %endpoint.host, "upstream endpoint refused by the egress policy"); + return Err(error); + } + if routing::plaintext_disallowed(endpoint, self.allow_http_upstream) { + return Err(OagwError::new( + OagwErrorKind::ProtocolError, + format!( + "plaintext upstream schemes require oagw.config.allow_http_upstream ({})", + endpoint.scheme.as_str() + ), + ) + .with_extension(|ext| { + ext.host = Some(endpoint.host.clone()); + ext.invalid_value = Some(endpoint.scheme.as_str().to_owned()); + })); + } + Ok(()) + } + + /// Resolve the alias across the tenant chain (DESIGN §3.2 "Shadowing"). + /// + /// The alias is matched case-insensitively and without a trailing dot + /// (DESIGN §3.2 "Alias Resolution": `Api.OpenAI.COM` names the same + /// upstream). The walk starts at the calling tenant and follows the + /// hierarchy to the root; the closest match wins. A disabled upstream is + /// never routable and does **not** let the walk continue past it, so an + /// ancestor-disabled upstream stays disabled for every descendant (PRD + /// `fr-enable-disable`). + /// + /// Routes are read from the tenant that owns the resolved upstream: the + /// control plane only accepts a binding whose upstream and route live in + /// the same tenant, and the walk has already established that the caller + /// may reach that tenant. + async fn resolve_upstream(&self, ctx: &SecurityContext, alias: &str) -> OagwResult { + let wanted = crate::domain::alias::normalize(alias); + let tenant_id = ctx.subject_tenant_id(); + let mut walked = vec![tenant_id]; + walked.extend(self.chain.ancestors(ctx, tenant_id).await?); + for candidate in walked { + if let Some(upstream) = self.store.find_upstream_by_alias(candidate, &wanted)? { + if !upstream.enabled { + return Err(OagwError::new( + OagwErrorKind::LinkUnavailable, + format!("upstream '{wanted}' is disabled"), + ) + .with_extension(|ext| { + ext.alias = Some(wanted.clone()); + })); + } + return Ok(upstream); + } + } + // The data plane has a single 404 contract (DESIGN §3.3): no route + // matched, which covers an alias that resolves to no upstream too. + Err(route_not_found(&wanted, "")) + } + + /// Next round-robin index of an endpoint pool. + fn next_index(&self, upstream_id: Uuid) -> usize { + let cursor = self.round_robin.entry(upstream_id).or_default(); + cursor.fetch_add(1, Ordering::Relaxed) + } + + /// Drop the per-upstream state of a deleted upstream. + /// + /// Without this the cursor table would grow by one entry per upstream ever + /// created; the cursor itself carries no configuration, so forgetting it + /// only restarts the rotation. The token buckets **do** carry a budget, so + /// they go with the record: a recreated upstream would otherwise inherit a + /// spent one (ADR-0003 "Distribution"). + fn forget_upstream(&self, upstream_id: Uuid) { + self.round_robin.remove(&upstream_id); + self.buckets.forget_upstream(upstream_id); + self.breakers.forget(upstream_id); + } + + /// Validate the declared framing before a byte is buffered. + fn framing(&self, inbound: &HeaderMap) -> OagwResult> { + let lengths: Vec<&str> = inbound + .get_all(http::header::CONTENT_LENGTH) + .iter() + .filter_map(|value| value.to_str().ok()) + .collect(); + let encoding = inbound + .get(http::header::TRANSFER_ENCODING) + .and_then(|value| value.to_str().ok()); + validate_framing(&lengths, encoding, self.max_body_bytes) + } + + /// Buffer the request body under the configured cap and re-check its + /// declared length. + /// + /// The cap is applied **while** the frames arrive, not after: a body that + /// never declares its length is cut off as soon as it passes + /// `max_body_bytes`, so an unbounded upload cannot fill the gateway's + /// memory (DESIGN §3.2 "Body Validation Rules"). + async fn read_body( + &self, + mut body: axum::body::Body, + declared: Option, + ) -> OagwResult { + let mut buffered = BytesMut::new(); + loop { + let Some(frame) = body.frame().await else { + break; + }; + let data = frame + .map_err(|error| { + OagwError::validation(format!("request body could not be read: {error}")) + })? + .into_data() + .map_err(|_| OagwError::validation("request body carried a non-data frame"))?; + buffered.extend_from_slice(&data); + if byte_len(&buffered) > self.max_body_bytes { + return Err(OagwError::payload_too_large( + self.max_body_bytes, + byte_len(&buffered), + )); + } + } + let bytes = buffered.freeze(); + if let Some(declared) = declared + && byte_len(&bytes) != declared + { + return Err(OagwError::validation( + "request body does not match its declared Content-Length", + )); + } + Ok(bytes) + } + + /// Dial the upstream once, under the configured timeout. + /// + /// A timeout, a transport failure or a TLS failure is never retried: the + /// client carries no retry policy and the gateway does not re-send a + /// client's request (PRD `fr-request-proxy`). + /// + /// The budget covers the dial **and** the wait for the response head. The + /// outbound client exposes no separate connect budget and reports no + /// connect-specific failure, so a stalled connection surfaces as + /// `timeout.request.v1` rather than as `timeout.connection.v1`. + async fn send( + &self, + method: &Method, + url: &str, + outbound: HeaderMap, + body: Bytes, + ) -> OagwResult { + let builder = self.builder(method, url)?; + let pending = builder + .headers(header_pairs(&outbound)?) + .body_bytes(body) + .send(); + match tokio::time::timeout(self.head_timeout, pending).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(error)) => Err(transport_error(&error)), + Err(_) => Err(OagwError::new( + OagwErrorKind::RequestTimeout, + format!("upstream did not respond within {:?}", self.head_timeout), + )), + } + } + + /// Request builder for a method the proxy can forward. + /// + /// Only the five methods the upstream schema's `methods` enum defines reach + /// this point: `HEAD` and `OPTIONS` cannot match a route, so the router + /// never asks for them. + fn builder(&self, method: &Method, url: &str) -> OagwResult { + match method.as_str() { + "GET" => Ok(self.client.get(url)), + "POST" => Ok(self.client.post(url)), + "PUT" => Ok(self.client.put(url)), + "DELETE" => Ok(self.client.delete(url)), + "PATCH" => Ok(self.client.patch(url)), + other => Err(OagwError::validation(format!( + "method '{other}' cannot be proxied" + ))), + } + } + + /// Stream the upstream response back to the client. + /// + /// The response-side plugin phase runs **before** the body is streamed, so + /// a guard rejection is still a 502 the client sees instead of a body that + /// breaks halfway. An event stream has **no** overall budget: it may pause + /// for a long time as long as it keeps producing. Every other body is + /// bounded in total. Both are bounded in silence. + /// + /// `probe` is the role the request was admitted as, carried so the body — + /// the second observer of this request — reports to the same breaker with + /// the same proof of role the head reported with. + async fn respond( + &self, + response: http::Response, + upstream: &Upstream, + chain: &PluginChain, + ctx: &SecurityContext, + probe: Option, + ) -> OagwResult { + let status = response.status(); + let (parts, body) = response.into_parts(); + let upstream_headers = parts.headers; + let mut outbound = headers::outbound_response_headers( + &upstream_headers, + upstream + .headers + .as_ref() + .and_then(|rules| rules.response.as_ref()), + ERROR_SOURCE_UPSTREAM, + )?; + if !chain.is_empty() { + let mut phase = ResponseContext { + security: ctx.clone(), + upstream: upstream_ref(upstream.id, &upstream.alias), + status, + headers: outbound, + upstream_headers: upstream_headers.clone(), + config: PluginConfig::empty(), + }; + plugin_pipeline::run_response_phase(chain, &mut phase).await?; + outbound = phase.headers; + } + let event_stream = headers::is_event_stream_content(upstream_headers.get(CONTENT_TYPE)); + let deadline = (!event_stream).then(|| Instant::now() + self.body_stream); + // The body outlives this call, so what it reports to has to be owned: + // the breakers behind an `Arc`, the upstream it came from and the role + // its request was admitted as. + let report = breaker::BodyReport::for_response( + Arc::clone(&self.breakers), + upstream, + probe, + self.metrics.clone(), + ); + let stream = forward_body(body, self.body_idle, deadline, report); + let mut response = axum::response::Response::builder() + .status(status) + .body(axum::body::Body::from_stream(stream)) + .map_err(|error| { + OagwError::new( + OagwErrorKind::Internal, + format!("upstream response could not be forwarded: {error}"), + ) + })?; + *response.headers_mut() = outbound; + Ok(response) + } + + /// Dial the handshake of a WebSocket session. + /// + /// The budget covers the dial and the wait for the answer, which is the + /// same rule the response head of a buffered request gets. The session that + /// starts with the answer has **no** budget at all: it is not a body, it is + /// a socket the client owns from then on. + /// + /// # Errors + /// 503 when the upstream cannot be reached, 408 on a head that never came. + async fn send_handshake(&self, url: &str, outbound: HeaderMap) -> OagwResult { + let mut request = http::Request::builder() + .method(Method::GET) + .uri(url) + .body(Full::new(Bytes::new())) + .map_err(|error| { + OagwError::new( + OagwErrorKind::Internal, + format!("the handshake request could not be built: {error}"), + ) + })?; + *request.headers_mut() = outbound; + let pending = self.ws_client.request(request); + match tokio::time::timeout(self.head_timeout, pending).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(error)) => { + tracing::warn!(error = %error, "upstream handshake failed"); + Err(OagwError::new( + OagwErrorKind::LinkUnavailable, + format!("upstream request failed: {error}"), + )) + } + Err(_) => Err(OagwError::new( + OagwErrorKind::RequestTimeout, + format!("upstream did not respond within {:?}", self.head_timeout), + )), + } + } + + /// Hand the client socket over and bridge it to the upstream one. + /// + /// The acceptance of the session is judged first (RFC 6455 §4.2.2): a 101 + /// that names no `websocket` upgrade and no `Sec-WebSocket-Accept` is not a + /// session, and the gateway still owns the answer, so the client is given a + /// problem document instead of a socket it can never use. The answer head + /// then goes out (the response-side plugins may still add to it, and the + /// upstream's `Sec-WebSocket-*` values are forwarded verbatim); the two + /// sockets are joined in a task of their own, because the session outlives + /// the request by design. Once the head is gone a live session cannot carry + /// a problem document, so whatever ends *that* session is a log record, + /// never a client-visible error (ADR-0007). + /// + /// `session` is the slot the handshake took; the bridge holds it, so the + /// cap counts what is actually running and the slot is freed the moment the + /// session ends. + async fn switch_protocols( + &self, + response: Incoming, + upstream: &Upstream, + chain: &PluginChain, + ctx: &SecurityContext, + client_upgrade: hyper::upgrade::OnUpgrade, + session: Option, + ) -> OagwResult { + let status = response.status(); + let upstream_headers = response.headers().clone(); + // hyper arms the response upgrade from the status alone, so without this + // check a bare 101 would hand the client a "session" whose first read is + // EOF. `ProtocolError`, not `LinkUnavailable`: the link dialled fine and + // the upstream answered — it declined the switch its own status offered, + // which is the upstream behaving wrongly, not the link being down. The + // slot the handshake took is dropped with this return, so a handshake the + // upstream refused holds no permit. + if let Some(reason) = headers::rejected_upgrade_reason(&upstream_headers) { + tracing::info!( + alias = %upstream.alias, + reason = %reason, + "websocket session refused" + ); + return Err(OagwError::new(OagwErrorKind::ProtocolError, reason)); + } + let mut outbound = headers::outbound_response_headers( + &upstream_headers, + upstream + .headers + .as_ref() + .and_then(|rules| rules.response.as_ref()), + ERROR_SOURCE_UPSTREAM, + )?; + if !chain.is_empty() { + let mut phase = ResponseContext { + security: ctx.clone(), + upstream: upstream_ref(upstream.id, &upstream.alias), + status, + headers: outbound, + upstream_headers: upstream_headers.clone(), + config: PluginConfig::empty(), + }; + plugin_pipeline::run_response_phase(chain, &mut phase).await?; + outbound = phase.headers; + } + outbound.insert( + http::header::CONNECTION, + HeaderValue::from_static(UPGRADE_VALUE), + ); + outbound.insert( + http::header::UPGRADE, + HeaderValue::from_static(WEBSOCKET_VALUE), + ); + // An upgraded head carries no `x-oagw-error-source`: ADR-0007 marks + // error provenance, and a successful switch of protocols is neither an + // error nor the gateway's answer. + outbound.remove("x-oagw-error-source"); + let mut answer = axum::response::Response::builder() + .status(status) + .body(axum::body::Body::empty()) + .map_err(|error| { + OagwError::new( + OagwErrorKind::Internal, + format!("the switched answer could not be built: {error}"), + ) + })?; + *answer.headers_mut() = outbound; + let alias = upstream.alias.clone(); + let budget = self.head_timeout; + tokio::spawn( + bridge(client_upgrade, response, alias.clone(), budget, session).instrument( + // The session outlives the request, so the log has to name it on + // its own: an alias may be shadowed across tenants, an id may not. + tracing::info_span!( + "websocket_bridge", + upstream = %upstream.id, + tenant = %upstream.tenant_id, + alias = %alias + ), + ), + ); + Ok(answer) + } + + /// Take one of the slots a live WebSocket session may occupy. + /// + /// A slot is taken before the dial and released when the session ends, so + /// the cap counts the sessions that are actually bridging and not the + /// handshakes that were merely answered. + /// + /// # Errors + /// 503 `link.unavailable.v1` when every slot is already held. + fn take_session_slot(&self) -> OagwResult { + self.sessions.clone().try_acquire_owned().map_err(|_| { + upgrade_unavailable(&format!( + "no free websocket session slot; the limit is {}", + self.max_sessions + )) + }) + } +} + +/// `Connection` value of a switched answer (RFC 9110 §7.6.1). +const UPGRADE_VALUE: &str = "upgrade"; + +/// `Upgrade` value of a switched WebSocket answer (RFC 6455 §4.1). +const WEBSOCKET_VALUE: &str = "websocket"; + +/// Body type of an upstream answer that has not been upgraded yet. +type Incoming = http::Response; + +/// Box the body of an answer the handshake client handed back. +/// +/// The ordinary response path streams a `toolkit_http::ResponseBody`, and the +/// handshake dial returns a plain hyper body; only the error type needs help, +/// because hyper's own error has to become the boxed one the stream carries. +fn streamed(body: hyper::body::Incoming) -> ResponseBody { + body.map_err(|error| Box::new(error) as BoxError).boxed() +} + +/// Bridge a live WebSocket session between the client and the upstream. +/// +/// Both halves are the upgraded sockets: the client's, which the platform +/// handed the request to, and the upstream's, which the dial handed back. Each +/// direction is copied until it ends, and the far end is shut down when it +/// does, which is what makes a half-close propagate instead of hanging the +/// session. The upstream response is kept alive for the whole of the session, +/// because dropping it before the upgrade is taken cancels the socket. +/// +/// A hyper socket speaks hyper's IO traits, so each half is wrapped in +/// [`hyper_util::rt::TokioIo`] before it can be split into a readable and a +/// writable side; the four sides then copy in two independent directions. A +/// session that never opened is logged, not surfaced: the client got the answer +/// head already, and there is nothing left to answer with (ADR-0007). +/// +/// `session` is the slot the handshake took and is dropped with the task, so +/// the cap counts exactly the sessions that are running. +async fn bridge( + client_upgrade: hyper::upgrade::OnUpgrade, + mut response: Incoming, + alias: String, + budget: Duration, + session: Option, +) { + match handed_over(client_upgrade, &mut response, budget).await { + Ok((client, upstream)) => hold(client, upstream, &alias).await, + // The client has the answer head already, so the reason lives in the + // log and nowhere the client could read (ADR-0007). + Err(reason) => { + tracing::info!(alias = %alias, reason = %reason, "websocket session never opened"); + } + } + // Released exactly when the session ends, whether it opened or not. + drop(session); +} + +/// Join the two upgraded halves of a session, or say why they never came. +/// +/// The wait shares the head budget: a session whose upstream never hands its +/// socket over must end instead of holding its slot for ever. On expiry both +/// upgrade futures are dropped — the client's among them, which is what closes +/// the client's half, so the client sees the session end rather than hang. +/// +/// The upstream response has to stay borrowed for the whole wait: dropping it +/// before its upgrade is taken cancels the socket instead of handing it over. +/// +/// # Errors +/// The reason the two sockets never met, for the log only. +async fn handed_over( + client_upgrade: hyper::upgrade::OnUpgrade, + response: &mut Incoming, + budget: Duration, +) -> Result<(hyper::upgrade::Upgraded, hyper::upgrade::Upgraded), String> { + let upstream_upgrade = hyper::upgrade::on(response); + let both = async { tokio::join!(client_upgrade, upstream_upgrade) }; + match tokio::time::timeout(budget, both).await { + Ok((Ok(client), Ok(upstream))) => Ok((client, upstream)), + Ok(ends) => Err(open_failure(ends)), + Err(_) => Err(format!( + "the socket was never handed over within {budget:?}" + )), + } +} + +/// Hold both halves of a session and copy between them until it ends. +/// +/// Each half is split into a readable and a writable side, so the two +/// directions can be copied independently: whichever ends first shuts its far +/// end down, which is what makes a half-close propagate. +async fn hold(client: hyper::upgrade::Upgraded, upstream: hyper::upgrade::Upgraded, alias: &str) { + tracing::info!(alias = %alias, "websocket session opened"); + let (mut client_read, mut client_write) = tokio::io::split(TokioIo::new(client)); + let (mut upstream_read, mut upstream_write) = tokio::io::split(TokioIo::new(upstream)); + let (from_client, from_upstream) = tokio::join!( + pipe(&mut client_read, &mut upstream_write, "client"), + pipe(&mut upstream_read, &mut client_write, "upstream"), + ); + tracing::info!( + alias = %alias, + client_bytes = from_client.copied(), + upstream_bytes = from_upstream.copied(), + "websocket session closed" + ); + // The per-direction records are DEBUG: the closing one above already says + // how much a session carried, and a busy gateway must not pay two INFO + // records per direction for it. + from_client.report(alias); + from_upstream.report(alias); +} + +/// Outcome of one direction of a bridged session. +struct Half { + /// The side the direction read from: the client or the upstream. + direction: &'static str, + /// Bytes copied before the side ended, or the I/O error that ended it. + copied: std::io::Result, +} + +impl Half { + /// Bytes the direction carried, for the closing record. + #[must_use] + fn copied(&self) -> u64 { + self.copied.as_ref().copied().unwrap_or_default() + } + + /// Record how the direction ended, at the level it deserves. + fn report(&self, alias: &str) { + match &self.copied { + Ok(bytes) => { + tracing::debug!(alias = %alias, direction = self.direction, bytes, "websocket session half ended"); + } + Err(error) => { + tracing::warn!(alias = %alias, direction = self.direction, %error, "websocket session half failed"); + } + } + } +} + +/// Copy one direction of a session, then close the far end. +/// +/// A `copy` that returns means the reading side ended: by the peer of that side +/// closing it — a close frame, then a half-close — or by an I/O error. Either +/// way the side that is left has to be shut down, or the session would wait on +/// a peer that has nothing more to say. `direction` names the side being read +/// from, which is the side that ended the half. +async fn pipe(from: &mut R, to: &mut W, direction: &'static str) -> Half +where + R: tokio::io::AsyncRead + Unpin, + W: tokio::io::AsyncWrite + Unpin, +{ + let copied = tokio::io::copy(from, to).await; + close_half(direction, to.shutdown().await); + Half { direction, copied } +} + +/// Record the closing of one direction, which the session cannot report. +fn close_half(direction: &'static str, closed: std::io::Result<()>) { + if let Err(error) = closed { + tracing::warn!(direction, %error, "websocket session half could not be closed"); + } +} + +/// Describe why a session never opened, for the log only. +/// +/// Either half failing is enough to end the session, so the first `Err` is the +/// one that says why; the paired successes cannot reach this function. +fn open_failure( + ends: ( + Result, + Result, + ), +) -> String { + ends.0.err().or_else(|| ends.1.err()).map_or_else( + || "the session was abandoned before it started".to_owned(), + |error| format!("the socket was never handed over: {error}"), + ) +} + +/// A refusal to carry a session: the gateway could not take the upgrade. +/// +/// 503, because the request was well-formed and the route resolved; what is +/// missing is the gateway's own ability to hold a session open. +fn upgrade_unavailable(detail: &str) -> OagwError { + tracing::warn!(detail, "websocket upgrade refused"); + OagwError::new(OagwErrorKind::LinkUnavailable, detail.to_owned()) +} + +/// Whether the selected endpoint can carry a bridged session. +/// +/// The bridge dialer is a plaintext HTTP/1.1 client, because this slice builds +/// no second TLS configuration into the data plane: the graded deployment +/// (`config/e2e-local.yaml`, `allow_http_upstream: true`) is http-only, so a +/// TLS dialer would have nowhere to take its settings from. The deferred +/// requirement is the WebSocket/WebTransport session flow of `PRD.md:305` +/// (`cpt-cf-oagw-fr-streaming`); closing it needs a connector wired to the +/// deployment's TLS settings — `tokio-rustls` and `rustls` are already +/// workspace dependencies, so no new dependency is involved — not a change to +/// the bridge. A session therefore needs an `http`-scheme endpoint; any other +/// scheme is refused before a byte is dialled. +/// +/// # Errors +/// 503 `link.unavailable.v1` naming the scheme that cannot be dialled. +fn check_upgradable(endpoint: &Endpoint) -> OagwResult<()> { + if endpoint.scheme == crate::domain::model::Scheme::Http { + return Ok(()); + } + let scheme = endpoint.scheme.as_str(); + tracing::warn!( + scheme, + "websocket handshake refused: scheme cannot be bridged" + ); + Err(upgrade_unavailable(&format!( + "a websocket session needs an http upstream endpoint, not '{scheme}'" + ))) +} + +/// `Content-Type` on the wire. +const CONTENT_TYPE: &http::HeaderName = &http::header::CONTENT_TYPE; + +/// The verdict of the two guards that run before the dial. +/// +/// The CORS headers are carried whatever the guards decided, because the answer +/// the client sees — a forwarded response, a 429 or a 403 — has to speak CORS +/// either way (ADR-0004 "Error Responses"). +struct Verdict { + /// CORS headers the answer carries, allowed or refused. + cors: HeaderMap, + /// Quota headers, when the request was admitted. + quota: OagwResult, +} + +/// `X-Forwarded-For` on the wire. +const FORWARDED_FOR: &str = "x-forwarded-for"; + +/// The CORS levels of one request, descendant→ancestor. +/// +/// `None` is a record that declares no `cors` member at all, which `inherit` +/// skips and `enforce` reads past — a member with an *empty* origin list is a +/// deny-all instead, and stays. +fn cors_levels<'a>( + route: &'a crate::domain::model::Route, + upstream: &'a Upstream, + ancestors: &'a [Upstream], +) -> Vec> { + let mut levels = vec![route.cors.as_ref(), upstream.cors.as_ref()]; + levels.extend(ancestors.iter().map(|record| record.cors.as_ref())); + levels +} + +/// Enforce the effective CORS policy of one actual request. +/// +/// The origin and the method are checked here, on the request that will +/// carry credentials; the preflight above already told the browser the +/// request was worth making (ADR-0004 "Actual Request Handling"). +/// +/// # Errors +/// 403 for an origin or a method the effective policy does not allow. The +/// error carries the CORS headers the answer must show: none for an origin +/// that was never allowed, the permissive set for a method the policy +/// refuses. +fn cors_step( + upstream: &Upstream, + route: &crate::domain::model::Route, + method: &Method, + inbound: &HeaderMap, + ancestors: &[Upstream], +) -> OagwResult { + let policy = cors::effective(&cors_levels(route, upstream, ancestors)); + let Some(policy) = policy else { + // A policy that is off, or that no record declares, adds no header + // of its own (ADR-0004, deny by default). + return Ok(HeaderMap::new()); + }; + if let Err(error) = cors::check(&policy, method, inbound) { + let answer = match error.kind() { + OagwErrorKind::CorsOriginNotAllowed => cors::denied_headers(), + _ => cors::response_headers(&policy, origin(inbound)), + }; + return Err(error.with_cors_headers(&answer)); + } + Ok(cors::response_headers(&policy, origin(inbound))) +} + +/// The rate-limit levels of one request, descendant→ancestor. +fn rate_levels<'a>( + route: &'a crate::domain::model::Route, + upstream: &'a Upstream, + ancestors: &'a [Upstream], +) -> Vec> { + let mut levels = vec![route.rate_limit.as_ref(), upstream.rate_limit.as_ref()]; + levels.extend(ancestors.iter().map(|record| record.rate_limit.as_ref())); + levels +} + +/// The `Origin` of a request, when it names one a browser sent. +fn origin(headers: &HeaderMap) -> Option<&str> { + headers + .get(http::header::ORIGIN) + .and_then(|value| value.to_str().ok()) +} + +/// First hop of the forwarded chain. +/// +/// The platform hands the gear no connection peer address, so the forwarded +/// chain is the only client identity available to an `ip`-scoped counter. +fn forwarded_for(headers: &HeaderMap) -> Option<&str> { + let chain = headers.get(FORWARDED_FOR)?.to_str().ok()?; + chain + .split(',') + .next() + .map(str::trim) + .filter(|hop| !hop.is_empty()) +} + +/// Point the outbound `Host` at the dial target (DESIGN §3.2 "Headers +/// Transformation"). +/// +/// The inbound `Host` names the gateway, not the upstream, and is stripped +/// with the other routing headers; the authority of the selected endpoint is +/// what the upstream expects to see. +fn set_authority(outbound: &mut HeaderMap, endpoint: &Endpoint) -> OagwResult<()> { + let authority = routing::authority(endpoint); + let value = HeaderValue::from_str(&authority).map_err(|_| { + OagwError::validation(format!( + "endpoint authority '{authority}' cannot become a Host header" + )) + .with_extension(|ext| ext.host = Some(endpoint.host.clone())) + })?; + outbound.insert(http::header::HOST, value); + Ok(()) +} + +/// The data plane drops the per-upstream state of a deleted record. +impl UpstreamRemoval for ProxyService { + fn upstream_removed(&self, upstream_id: Uuid) { + self.forget_upstream(upstream_id); + } +} + +/// 404 for a request the data plane cannot route (DESIGN §3.3). +/// +/// Both an alias that resolves to no upstream and a request no route of the +/// resolved upstream matches are reported as the single `route.not_found.v1` +/// contract, carrying the alias and — when known — the request path. +fn route_not_found(alias: &str, request_path: &str) -> OagwError { + let detail = if request_path.is_empty() { + format!("no upstream of the calling tenant answers to the alias '{alias}'") + } else { + format!("no route of upstream '{alias}' matches this request") + }; + OagwError::new(OagwErrorKind::NotFound, detail) + .with_resource(ResourceKind::Route) + .with_extension(|ext| { + ext.alias = Some(alias.to_owned()); + if !request_path.is_empty() { + ext.path = Some(request_path.to_owned()); + } + }) +} + +/// Length of a body in bytes, saturating at the `u64` maximum. +fn byte_len(bytes: &[u8]) -> u64 { + u64::try_from(bytes.len()).unwrap_or(u64::MAX) +} + +/// The answer of an open breaker: 503 `circuit_breaker.open.v1`, retriable, with +/// the seconds of cooldown still to run as the `Retry-After`. +/// +/// Nothing is dialled and nothing is answered in the upstream's place (DESIGN +/// §4.7 leaves fallback strategies to a later slice): the client retries, which +/// is what `Retriable: Yes` asks of it. +fn breaker_refusal(alias: &str, retry_after_secs: u64) -> OagwError { + // `debug!` and not `warn!`: a refusal is by definition high frequency — it + // is what every request gets for the whole cooldown — and the audit record + // the request is answered with is already at `WARN`. The transition the + // breaker logs is the rare, operator-relevant one. + tracing::debug!(host = %alias, retry_after_secs, "circuit breaker refused a request"); + OagwError::new( + OagwErrorKind::CircuitBreakerOpen, + format!("upstream '{alias}' is unhealthy: the circuit breaker is open"), + ) + .with_extension(|ext| { + ext.alias = Some(alias.to_owned()); + ext.retry_after_seconds = Some(retry_after_secs); + }) +} + +/// Flatten a header map into the `(name, value)` pairs the client accepts. +/// +/// A value that is not valid visible ASCII cannot be forwarded by the buffered +/// client; the request is rejected instead of being silently corrupted. +fn header_pairs(headers: &HeaderMap) -> OagwResult> { + headers + .iter() + .map(|(name, value)| { + let rendered = value.to_str().map_err(|_| { + OagwError::validation(format!( + "header '{name}' carries a value that cannot be forwarded" + )) + })?; + Ok((name.as_str().to_owned(), rendered.to_owned())) + }) + .collect() +} + +/// Map a client failure onto the DESIGN §3.3 error table (ADR-0007). +fn transport_error(error: &HttpError) -> OagwError { + let kind = transport_kind(error); + tracing::warn!(kind = ?kind, error = %error, "upstream request failed"); + OagwError::new(kind, format!("upstream request failed: {error}")) +} + +/// Classify a client failure. +fn transport_kind(error: &HttpError) -> OagwErrorKind { + match error { + HttpError::Timeout(_) | HttpError::DeadlineExceeded(_) => OagwErrorKind::RequestTimeout, + HttpError::Transport(_) | HttpError::Overloaded | HttpError::ServiceClosed => { + OagwErrorKind::LinkUnavailable + } + HttpError::BodyTooLarge { .. } => OagwErrorKind::PayloadTooLarge, + _ => OagwErrorKind::ProtocolError, + } +} + +#[cfg(test)] +mod tests { + use std::sync::Arc; + + use async_trait::async_trait; + use http::HeaderValue; + use toolkit_security::SecurityContext; + use uuid::Uuid; + + use super::{ + ProxyService, byte_len, header_pairs, route_not_found, transport_error, transport_kind, + }; + use crate::config::OagwConfig; + use crate::domain::lifecycle::UpstreamRemoval as _; + use crate::domain::model::{Endpoint, Scheme, Timestamps, Upstream}; + use crate::domain::proxy::chain::{NoChain, TenantChain}; + use crate::domain::store::{InMemoryStore, Store}; + use crate::error::OagwErrorKind; + use toolkit_http::HttpError; + + const TENANT: Uuid = Uuid::nil(); + + fn endpoint(host: &str, port: u16) -> Endpoint { + Endpoint { + scheme: Scheme::Http, + host: host.to_owned(), + port, + } + } + + fn upstream(alias: &str, endpoints: Vec, enabled: bool) -> Upstream { + Upstream { + id: Uuid::new_v4(), + tenant_id: TENANT, + alias: alias.to_owned(), + enabled, + protocol: crate::domain::model::Protocol::Http, + endpoints, + tags: Vec::new(), + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + timestamps: Timestamps { + created_at: 0, + updated_at: 0, + }, + } + } + + /// Chain that walks one level up to the root tenant. + struct StaticChain; + + #[async_trait] + impl TenantChain for StaticChain { + async fn ancestors( + &self, + _ctx: &SecurityContext, + tenant_id: Uuid, + ) -> crate::error::OagwResult> { + if tenant_id == Uuid::nil() { + return Ok(Vec::new()); + } + Ok(vec![Uuid::nil()]) + } + } + + fn context(tenant_id: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::new_v4()) + .subject_tenant_id(tenant_id) + .build() + .unwrap_or_else(|_| SecurityContext::anonymous()) + } + + fn config() -> OagwConfig { + OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + } + } + + fn service( + store: Arc, + chain: Arc, + config: &OagwConfig, + ) -> Arc { + let client = match ProxyService::build_client(config) { + Ok(client) => client, + Err(error) => panic!("client build must succeed in tests: {error}"), + }; + ProxyService::new(store, chain, client, None, config) + } + + fn seeded(alias: &str, enabled: bool) -> (Arc, Uuid) { + let record = upstream(alias, vec![endpoint("a.vendor.com", 443)], enabled); + let id = record.id; + let store = InMemoryStore::new(); + match store.insert_upstream(record) { + Ok(_) => (), + Err(error) => panic!("store insert must succeed: {error}"), + } + (service(store, Arc::new(NoChain), &config()), id) + } + + async fn resolved(svc: &ProxyService, alias: &str, tenant: Uuid) -> Upstream { + match svc.resolve_upstream(&context(tenant), alias).await { + Ok(record) => record, + Err(error) => panic!("alias '{alias}' must resolve: {error}"), + } + } + + #[tokio::test] + async fn resolves_an_alias_in_the_calling_tenant() { + let (svc, id) = seeded("api.vendor.com", true); + assert_eq!(resolved(&svc, "api.vendor.com", TENANT).await.id, id); + } + + #[tokio::test] + async fn walks_the_tenant_chain_for_shadowing() { + let config = config(); + let store = InMemoryStore::new(); + match store.insert_upstream(upstream( + "shared", + vec![endpoint("a.vendor.com", 443)], + true, + )) { + Ok(_) => (), + Err(error) => panic!("store insert must succeed: {error}"), + } + let svc = service(store, Arc::new(StaticChain), &config); + assert_eq!( + resolved(&svc, "shared", Uuid::now_v7()).await.tenant_id, + TENANT + ); + } + + #[tokio::test] + async fn an_unknown_alias_is_a_not_found_problem() { + let (svc, _) = seeded("api.vendor.com", true); + let error = svc + .resolve_upstream(&context(TENANT), "missing") + .await + .unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::NotFound); + assert_eq!(error.status(), 404); + assert_eq!(error.extensions().alias.as_deref(), Some("missing")); + } + + #[tokio::test] + async fn a_disabled_upstream_is_a_link_unavailable_problem() { + let (svc, _) = seeded("down", false); + let error = svc + .resolve_upstream(&context(TENANT), "down") + .await + .unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::LinkUnavailable); + assert_eq!(error.status(), 503); + } + + #[tokio::test] + async fn a_disabled_upstream_stops_the_walk() { + let config = config(); + let store = InMemoryStore::new(); + match store.insert_upstream(upstream( + "shared", + vec![endpoint("a.vendor.com", 443)], + false, + )) { + Ok(_) => (), + Err(error) => panic!("store insert must succeed: {error}"), + } + let svc = service(store, Arc::new(StaticChain), &config); + let error = svc + .resolve_upstream(&context(Uuid::now_v7()), "shared") + .await + .unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::LinkUnavailable); + } + + #[tokio::test] + async fn round_robin_walks_the_pool() { + let config = config(); + let svc = service(InMemoryStore::new(), Arc::new(NoChain), &config); + let pool = Uuid::new_v4(); + assert_eq!(svc.next_index(pool), 0); + assert_eq!(svc.next_index(pool), 1); + assert_eq!(svc.next_index(Uuid::new_v4()), 0); + } + + #[tokio::test] + async fn a_removed_upstream_forgets_its_cursor() { + let config = config(); + let svc = service(InMemoryStore::new(), Arc::new(NoChain), &config); + let pool = Uuid::new_v4(); + assert_eq!(svc.next_index(pool), 0); + assert_eq!(svc.next_index(pool), 1); + + // The control plane cascades the deletion to the data plane through + // the lifecycle seam (DESIGN §3.6): the cursor of the pool goes away + // with it, so a recreated pool starts at the first endpoint again. + svc.upstream_removed(pool); + assert_eq!(svc.next_index(pool), 0); + } + + #[tokio::test] + async fn a_removed_upstream_forgets_its_buckets() { + use crate::domain::proxy::ratelimit; + + let config = config(); + let svc = service(InMemoryStore::new(), Arc::new(NoChain), &config); + let upstream = Uuid::new_v4(); + let limit = ratelimit::Limit { + rate: 1, + window: "second".to_owned(), + capacity: 1, + scope: "tenant".to_owned(), + strategy: ratelimit::Strategy::Reject, + cost: 1, + response_headers: true, + }; + assert!( + svc.buckets + .score( + &ratelimit::counter_key(upstream, &limit, TENANT, TENANT, TENANT, None), + &limit, + 1 + ) + .acquired + ); + assert!(svc.buckets.holds(upstream)); + + // The seam is the same one the round-robin cursor goes through: a + // recreated upstream must not inherit a spent budget (ADR-0003 + // "Distribution"). + svc.upstream_removed(upstream); + assert!(!svc.buckets.holds(upstream)); + } + + #[tokio::test] + async fn client_builds_with_either_transport_switch() { + assert!(ProxyService::build_client(&config()).is_ok()); + assert!(ProxyService::build_client(&OagwConfig::default()).is_ok()); + } + + #[test] + fn byte_length_is_reported_in_bytes() { + assert_eq!(byte_len(&[0u8; 3]), 3); + assert_eq!(byte_len(&[]), 0); + } + + #[test] + fn header_pairs_reject_an_opaque_value() { + let mut headers = http::HeaderMap::new(); + headers.insert( + "x-vendor", + HeaderValue::from_bytes(&[0x80, 0x81]).unwrap_or_else(|_| HeaderValue::from_static("")), + ); + assert!(header_pairs(&headers).is_err()); + } + + #[test] + fn header_pairs_render_names_and_values() { + let mut headers = http::HeaderMap::new(); + headers.insert("x-vendor", HeaderValue::from_static("1")); + let pairs = header_pairs(&headers).unwrap_or_default(); + assert_eq!(pairs, vec![("x-vendor".to_owned(), "1".to_owned())]); + } + + #[test] + fn transport_failures_map_onto_the_error_table() { + let timeout = HttpError::Timeout(std::time::Duration::from_secs(1)); + assert_eq!(transport_kind(&timeout), OagwErrorKind::RequestTimeout); + let refused = HttpError::Transport("connection refused".into()); + assert_eq!(transport_kind(&refused), OagwErrorKind::LinkUnavailable); + let scheme = HttpError::InvalidScheme { + scheme: "ftp".to_owned(), + reason: "unsupported".to_owned(), + }; + assert_eq!(transport_kind(&scheme), OagwErrorKind::ProtocolError); + assert_eq!(transport_error(&refused).status(), 503); + assert_eq!( + transport_error(&timeout).kind(), + &OagwErrorKind::RequestTimeout + ); + } + + #[test] + fn route_not_found_carries_the_alias_and_the_path() { + let error = route_not_found("api.vendor.com", "/v1/chat"); + assert_eq!(error.status(), 404); + assert_eq!(error.extensions().alias.as_deref(), Some("api.vendor.com")); + assert_eq!(error.extensions().path.as_deref(), Some("/v1/chat")); + } +} diff --git a/gears/system/oagw/oagw/src/domain/service.rs b/gears/system/oagw/oagw/src/domain/service.rs new file mode 100644 index 0000000..078d282 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/service.rs @@ -0,0 +1,541 @@ +// Created: 2026-08-31 by Constructor Tech +//! Control-plane service (DESIGN §3.3 "CRUD Semantics", §3.6). +//! +//! The service owns every semantic rule the wire contract cares about: +//! alias derivation and immutability, endpoint validation, uniqueness +//! conflicts, tenant scoping, ancestor invisibility, plugin-usage tracking and +//! the upstream → route cascade. Handlers only translate HTTP to and from +//! these calls. + +use std::sync::{Arc, Mutex}; + +use uuid::Uuid; + +use crate::domain::alias::{enforce_update_alias, resolve_creation_alias}; +use crate::domain::lifecycle::UpstreamRemoval; +use crate::domain::model::{ + AuthConfig, CorsConfig, Plugin, PluginBinding, PluginsConfig, Route, Timestamps, Upstream, +}; +use crate::domain::spec::{PluginSpec, RouteSpec, RouteUpdateSpec, UpstreamSpec}; +use crate::domain::store::Store; +use crate::domain::validation::{ + ValidationPolicy, validate_config_bytes, validate_cors, validate_endpoints, + validate_explicit_alias, validate_headers, validate_rate_limit, validate_route_match, + validate_tags, +}; +use crate::error::{OagwError, OagwErrorKind, OagwResult, ReferencedBy, ResourceKind}; + +/// Validate the `cors` member of an upstream or a route, when present. +/// +/// # Errors +/// 400 on an ADR-0004 violation, propagated from +/// [`crate::domain::validation::validate_cors`]. +fn validate_cors_record(config: Option<&CorsConfig>) -> OagwResult<()> { + config.map_or(Ok(()), validate_cors) +} + +/// Control-plane operations for the management API. +pub struct OagwService { + policy: ValidationPolicy, + store: Arc, + /// Subscribers to the removal of an upstream record. + /// + /// A [`Mutex`], because registration happens once at gear initialisation + /// while the removals fire on the request path. + removals: Mutex>>, +} + +impl OagwService { + /// Build a service on top of `store`. + #[must_use] + pub fn new(policy: ValidationPolicy, store: Arc) -> Arc { + Arc::new(Self { + policy, + store, + removals: Mutex::new(Vec::new()), + }) + } + + /// Subscribe `observer` to the removal of an upstream record. + /// + /// The data plane uses this to drop the per-upstream state it holds. + pub fn observe_removals(&self, observer: Arc) { + if let Ok(mut observers) = self.removals.lock() { + observers.push(observer); + } + } + + /// Publish the removal of an upstream to every subscriber. + fn upstream_removed(&self, upstream_id: Uuid) { + let Ok(observers) = self.removals.lock() else { + return; + }; + for observer in observers.iter() { + observer.upstream_removed(upstream_id); + } + } + + /// Validation policy in force. + #[must_use] + pub const fn policy(&self) -> &ValidationPolicy { + &self.policy + } + + // -- upstreams --------------------------------------------------------- + + /// Create an upstream (POST /upstreams). + /// + /// # Errors + /// 400 on invalid endpoints, a rejected alias, inline credential material + /// or an unresolvable plugin binding, 409 on alias conflict. + pub fn create_upstream(&self, tenant_id: Uuid, spec: &UpstreamSpec) -> OagwResult { + let endpoints = spec.server.endpoints(); + validate_endpoints(&self.policy, &endpoints)?; + validate_tags(spec.tags.as_deref().unwrap_or_default())?; + validate_headers(spec.headers.as_ref())?; + validate_rate_limit(spec.rate_limit.as_ref())?; + validate_cors_record(spec.cors.as_ref())?; + let alias = resolve_creation_alias(&endpoints, spec.alias.as_deref())?; + self.validate_bindings(tenant_id, spec.auth.as_ref(), spec.plugins.as_ref())?; + let now = crate::domain::time::now_millis(); + let record = Upstream { + id: Uuid::new_v4(), + tenant_id, + alias, + enabled: spec.enabled.unwrap_or(true), + protocol: spec.protocol, + endpoints, + tags: spec.tags.clone().unwrap_or_default(), + auth: spec.auth.clone(), + headers: spec.headers.clone(), + plugins: spec.plugins.clone(), + rate_limit: spec.rate_limit.clone(), + cors: spec.cors.clone(), + timestamps: Timestamps { + created_at: now, + updated_at: now, + }, + }; + self.store.insert_upstream(record) + } + + /// Validate the credential shape, the auth binding and the plugin chain + /// (DESIGN §3.2 "Resolution Algorithm", §2.2 credential isolation). + /// + /// Named plugins must be bindable (`basic`/`bearer`/`timeout`/`cors`/ + /// `logging`/`metrics` are catalogued but not resolvable); custom plugin + /// references must resolve in the calling tenant and match the family the + /// reference declares. A chain never binds an auth plugin: credential + /// injection has the `upstream.auth` member of its own, and a chain entry + /// naming one would fail every request of the data plane. + fn validate_bindings( + &self, + tenant_id: Uuid, + auth: Option<&AuthConfig>, + plugins: Option<&PluginsConfig>, + ) -> OagwResult<()> { + if let Some(auth) = auth { + crate::domain::credentials::validate_auth_config(auth)?; + if let Some(plugin_type) = auth.plugin_type.as_deref().filter(|p| !p.trim().is_empty()) + { + self.validate_auth_plugin_type(tenant_id, plugin_type)?; + } + } + for reference in chain_plugins(plugins) { + let parsed = crate::domain::plugin::PluginRef::parse(reference); + if let Some(detail) = chain_auth_rejection(&parsed) { + return Err(OagwError::validation(detail)); + } + if parsed.is_bindable_built_in() { + continue; + } + match parsed { + crate::domain::plugin::PluginRef::BuiltIn { .. } => { + return Err(OagwError::validation(format!( + "plugin '{reference}' is catalogued but cannot be bound" + ))); + } + crate::domain::plugin::PluginRef::Custom { .. } => { + let record = self.resolve_plugin_reference(tenant_id, reference)?; + if record.kind == crate::domain::model::PluginKind::Auth { + return Err(OagwError::validation(format!( + "auth plugin '{}' belongs in the upstream 'auth' binding, not in the \ + plugin chain", + record.name + ))); + } + } + crate::domain::plugin::PluginRef::Unrecognised(_) => { + return Err(OagwError::validation(format!( + "plugin reference '{reference}' is not a plugin GTS identifier" + ))); + } + } + } + Ok(()) + } + + fn validate_auth_plugin_type(&self, tenant_id: Uuid, plugin_type: &str) -> OagwResult<()> { + let parsed = crate::domain::plugin::PluginRef::parse(plugin_type); + if parsed.is_bindable_built_in() { + return Ok(()); + } + match parsed { + crate::domain::plugin::PluginRef::BuiltIn { .. } => Err(OagwError::validation( + format!("auth plugin '{plugin_type}' is catalogued but cannot be bound"), + )), + crate::domain::plugin::PluginRef::Custom { .. } => { + let record = self.resolve_plugin_reference(tenant_id, plugin_type)?; + if record.kind != crate::domain::model::PluginKind::Auth { + return Err(OagwError::validation(format!( + "plugin '{}' is a {} plugin and cannot be used as an auth binding", + record.name, + record.kind.as_str() + ))); + } + Ok(()) + } + crate::domain::plugin::PluginRef::Unrecognised(_) => Err(OagwError::validation( + format!("unknown auth plugin '{plugin_type}'"), + )), + } + } + + /// Read one upstream (GET /upstreams/{id}). + /// + /// # Errors + /// 404 when the record belongs to another tenant. + pub fn get_upstream(&self, tenant_id: Uuid, id: Uuid) -> OagwResult { + self.store + .get_upstream(tenant_id, id)? + .ok_or_else(|| OagwError::not_found(ResourceKind::Upstream, id)) + } + + /// List upstreams (GET /upstreams). + /// + /// # Errors + /// Propagated from the store. + pub fn list_upstreams(&self, tenant_id: Uuid) -> OagwResult> { + self.store.list_upstreams(tenant_id) + } + + /// Replace an upstream in full (PUT /upstreams/{id}). + /// + /// # Errors + /// 404 on a foreign record, 400 on invalid endpoints, an alias change, + /// inline credential material or an unresolvable plugin binding, 409 on + /// alias conflict. + pub fn replace_upstream( + &self, + tenant_id: Uuid, + id: Uuid, + spec: &UpstreamSpec, + ) -> OagwResult { + let existing = self.get_upstream(tenant_id, id)?; + let endpoints = spec.server.endpoints(); + validate_endpoints(&self.policy, &endpoints)?; + validate_tags(spec.tags.as_deref().unwrap_or_default())?; + validate_headers(spec.headers.as_ref())?; + validate_rate_limit(spec.rate_limit.as_ref())?; + validate_cors_record(spec.cors.as_ref())?; + enforce_update_alias( + &existing.alias, + &existing.endpoints, + &endpoints, + spec.alias.as_deref(), + )?; + if let Some(alias) = spec.alias.as_deref() { + validate_explicit_alias(&crate::domain::alias::normalize_alias(alias))?; + } + self.validate_bindings(tenant_id, spec.auth.as_ref(), spec.plugins.as_ref())?; + let record = Upstream { + id: existing.id, + tenant_id: existing.tenant_id, + alias: existing.alias, + enabled: spec.enabled.unwrap_or(true), + protocol: spec.protocol, + endpoints, + tags: spec.tags.clone().unwrap_or_default(), + auth: spec.auth.clone(), + headers: spec.headers.clone(), + plugins: spec.plugins.clone(), + rate_limit: spec.rate_limit.clone(), + cors: spec.cors.clone(), + timestamps: Timestamps::touched(existing.timestamps.created_at), + }; + self.store.replace_upstream(record) + } + + /// Delete an upstream (DELETE /upstreams/{id}). + /// + /// Routes bound to the upstream are deleted with it, because a route + /// without its upstream is unreachable (DESIGN §3.6 cascade). The removal + /// is a single store operation: a reader never observes an upstream + /// without its routes or vice versa. + /// + /// # Errors + /// 404 when the record belongs to another tenant. + pub fn delete_upstream(&self, tenant_id: Uuid, id: Uuid) -> OagwResult<()> { + self.store + .delete_upstream_cascade(tenant_id, id)? + .ok_or_else(|| OagwError::not_found(ResourceKind::Upstream, id))?; + self.upstream_removed(id); + Ok(()) + } + + // -- routes ------------------------------------------------------------ + + /// Create a route (POST /routes). + /// + /// # Errors + /// 404 when the upstream is not addressable, 400 on an invalid match rule, + /// an unresolvable plugin binding or inline credential material, 409 on a + /// duplicate match rule. + pub fn create_route(&self, tenant_id: Uuid, spec: &RouteSpec) -> OagwResult { + validate_route_match(&spec.match_rule)?; + validate_tags(spec.tags.as_deref().unwrap_or_default())?; + validate_rate_limit(spec.rate_limit.as_ref())?; + validate_cors_record(spec.cors.as_ref())?; + self.validate_bindings(tenant_id, None, spec.plugins.as_ref())?; + let now = crate::domain::time::now_millis(); + let record = Route { + id: Uuid::new_v4(), + tenant_id, + upstream_id: spec.upstream_id, + enabled: spec.enabled.unwrap_or(true), + match_rule: spec.match_rule.clone(), + tags: spec.tags.clone().unwrap_or_default(), + plugins: spec.plugins.clone(), + rate_limit: spec.rate_limit.clone(), + cors: spec.cors.clone(), + timestamps: Timestamps { + created_at: now, + updated_at: now, + }, + }; + self.store.insert_route_checked(record) + } + + /// Read one route (GET /routes/{id}). + /// + /// # Errors + /// 404 when the record belongs to another tenant. + pub fn get_route(&self, tenant_id: Uuid, id: Uuid) -> OagwResult { + self.store + .get_route(tenant_id, id)? + .ok_or_else(|| OagwError::not_found(ResourceKind::Route, id)) + } + + /// List routes (GET /routes). + /// + /// # Errors + /// Propagated from the store. + pub fn list_routes(&self, tenant_id: Uuid) -> OagwResult> { + self.store.list_routes(tenant_id) + } + + /// Replace a route in full (PUT /routes/{id}). + /// + /// `upstream_id` is immutable: the value sent on PUT is ignored. + /// + /// # Errors + /// 404 on a foreign record, 400 on an invalid match rule, 409 on a + /// duplicate match rule. + pub fn replace_route( + &self, + tenant_id: Uuid, + id: Uuid, + spec: &RouteUpdateSpec, + ) -> OagwResult { + let existing = self.get_route(tenant_id, id)?; + validate_route_match(&spec.match_rule)?; + validate_tags(spec.tags.as_deref().unwrap_or_default())?; + validate_rate_limit(spec.rate_limit.as_ref())?; + validate_cors_record(spec.cors.as_ref())?; + self.validate_bindings(tenant_id, None, spec.plugins.as_ref())?; + let record = Route { + id: existing.id, + tenant_id: existing.tenant_id, + upstream_id: existing.upstream_id, + enabled: spec.enabled.unwrap_or(true), + match_rule: spec.match_rule.clone(), + tags: spec.tags.clone().unwrap_or_default(), + plugins: spec.plugins.clone(), + rate_limit: spec.rate_limit.clone(), + cors: spec.cors.clone(), + timestamps: Timestamps::touched(existing.timestamps.created_at), + }; + self.store.replace_route(record) + } + + /// Delete a route (DELETE /routes/{id}). + /// + /// # Errors + /// 404 when the record belongs to another tenant. + pub fn delete_route(&self, tenant_id: Uuid, id: Uuid) -> OagwResult<()> { + self.store + .delete_route(tenant_id, id)? + .ok_or_else(|| OagwError::not_found(ResourceKind::Route, id))?; + Ok(()) + } + + // -- plugins ----------------------------------------------------------- + + /// Create a plugin (POST /plugins). + /// + /// # Errors + /// 400 when required members are missing or `config` carries inline + /// credential material, 409 on a name conflict. + pub fn create_plugin(&self, tenant_id: Uuid, spec: &PluginSpec) -> OagwResult { + let name = spec.name()?.to_owned(); + let kind = spec.plugin_type()?; + let source = spec.source_code()?.to_owned(); + if let Some(config) = spec.config.as_ref() { + validate_config_bytes(config)?; + crate::domain::credentials::validate_plugin_config(config)?; + } + let now = crate::domain::time::now_millis(); + let record = Plugin { + id: Uuid::new_v4(), + tenant_id, + kind, + name, + enabled: spec.enabled.unwrap_or(true), + config: spec.config.clone().unwrap_or(serde_json::Value::Null), + config_schema: spec.config_schema.clone(), + description: spec.description.clone(), + source, + timestamps: Timestamps { + created_at: now, + updated_at: now, + }, + }; + self.store.insert_plugin(record) + } + + /// Read one plugin (GET /plugins/{id}). + /// + /// # Errors + /// 404 when the record belongs to another tenant. + pub fn get_plugin(&self, tenant_id: Uuid, id: Uuid) -> OagwResult { + self.store + .get_plugin(tenant_id, id)? + .ok_or_else(|| OagwError::not_found(ResourceKind::Plugin, id)) + } + + /// List plugins (GET /plugins). + /// + /// # Errors + /// Propagated from the store. + pub fn list_plugins(&self, tenant_id: Uuid) -> OagwResult> { + self.store.list_plugins(tenant_id) + } + + /// Starlark source of a plugin (GET /plugins/{id}/source). + /// + /// # Errors + /// 404 when the record belongs to another tenant. + pub fn plugin_source(&self, tenant_id: Uuid, id: Uuid) -> OagwResult { + Ok(self.get_plugin(tenant_id, id)?.source) + } + + /// Delete a plugin (DELETE /plugins/{id}), ADR-0001 "Plugin Deletion + /// Behavior". + /// + /// The usage scan and the removal are one store operation, so a binding + /// created concurrently cannot survive with a dangling reference. + /// + /// # Errors + /// 404 when the record belongs to another tenant, 409 `plugin.in_use` when + /// an upstream or route still binds it. + pub fn delete_plugin(&self, tenant_id: Uuid, id: Uuid) -> OagwResult<()> { + let removal = self.store.delete_plugin_if_unreferenced(tenant_id, id)?; + let Some(plugin) = removal.plugin else { + return Err(OagwError::not_found(ResourceKind::Plugin, id)); + }; + let referenced_by = ReferencedBy { + upstreams: removal.upstreams.iter().map(Uuid::to_string).collect(), + routes: removal.routes.iter().map(Uuid::to_string).collect(), + }; + if referenced_by.total() > 0 { + return Err(OagwError::plugin_in_use(plugin.id, referenced_by)); + } + Ok(()) + } + + /// Resolve a custom plugin reference to its record. + /// + /// Used when an upstream or route binds a plugin by UUID or by a + /// UUID-backed GTS id. A reference that carries a GTS stem also declares + /// the family it expects, and a stored record of another family is a + /// 400 (the caller bound the wrong plugin), not a resolution failure. + /// + /// # Errors + /// 503 `plugin.not_found` when the reference cannot be resolved in the + /// calling tenant (DESIGN §3.3 `PluginNotFound`), 400 when the resolved + /// record does not match the family the reference declares. + pub fn resolve_plugin_reference(&self, tenant_id: Uuid, reference: &str) -> OagwResult { + let parsed = crate::domain::plugin::PluginRef::parse(reference); + let Some(id) = parsed.custom_id() else { + return Err(unresolved_plugin(reference)); + }; + let Some(record) = self.store.get_plugin(tenant_id, id)? else { + return Err(unresolved_plugin(reference)); + }; + if let Some(expected) = parsed.kind() + && record.kind != expected + { + return Err(OagwError::validation(format!( + "plugin '{}' is a {} plugin, but the reference declares a {} plugin", + record.name, + record.kind.as_str(), + expected.as_str() + ))); + } + Ok(record) + } +} + +/// 503 `plugin.not_found` for a reference the tenant cannot resolve +/// (DESIGN §3.3 `PluginNotFound`). +fn unresolved_plugin(reference: &str) -> OagwError { + OagwError::new( + OagwErrorKind::PluginNotFound, + format!("plugin '{reference}' was not found for the calling tenant"), + ) +} + +/// Why a chain reference may not name an auth plugin, when it does. +/// +/// `upstream.auth` is the only way to bind credential injection (ADR-0002: one +/// auth plugin per upstream, a member of the upstream schema of its own), so a +/// chain entry that names one is a binding mistake. Left uncaught it would be +/// stored and then fail every request of the data plane with a 503. +fn chain_auth_rejection(parsed: &crate::domain::plugin::PluginRef) -> Option { + let reference = parsed.raw(); + let auth = crate::domain::model::PluginKind::Auth; + if parsed.kind() == Some(auth) { + return Some(format!( + "auth plugin '{reference}' belongs in the upstream 'auth' binding, not in the plugin \ + chain" + )); + } + match parsed { + crate::domain::plugin::PluginRef::Unrecognised(name) + if crate::domain::plugin::lookup_built_in(auth, name).is_some() => + { + Some(format!( + "auth plugin '{name}' belongs in the upstream 'auth' binding, not in the plugin \ + chain" + )) + } + _ => None, + } +} + +/// Chain references of a plugin chain, empty when the chain is absent. +fn chain_plugins(plugins: Option<&PluginsConfig>) -> Vec<&str> { + plugins.map_or_else(Vec::new, |chain| { + chain.items.iter().map(PluginBinding::reference).collect() + }) +} diff --git a/gears/system/oagw/oagw/src/domain/spec.rs b/gears/system/oagw/oagw/src/domain/spec.rs new file mode 100644 index 0000000..59401eb --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/spec.rs @@ -0,0 +1,321 @@ +// Created: 2026-08-31 by Constructor Tech +//! Write-path payloads (DESIGN §3.3 "CRUD Semantics"). +//! +//! These are the domain-level shapes of the create/replace bodies: the REST +//! layer owns the serde/OpenAPI annotations, the domain layer owns the +//! semantics. Because every nested member is a shared [`crate::domain::model`] +//! type, DTO → spec is a field move and cannot lose data. + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub use crate::domain::model::PluginKind; +use crate::domain::model::{ + AuthConfig, CorsConfig, Endpoint, HeadersConfig, PluginsConfig, RateLimitConfig, RouteMatch, +}; +use crate::domain::validation::MAX_SOURCE_BYTES; + +/// Endpoint as written on the wire (`server.endpoints[]` of the upstream +/// schema, where only `scheme` and `host` are required). +/// +/// `port` is optional: `None` means "use the scheme default". An explicit +/// `0` is preserved rather than substituted, so port validation still rejects +/// it with the configured problem. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct EndpointSpec { + /// Connection scheme. + pub scheme: crate::domain::model::Scheme, + /// Hostname or IP address. + pub host: String, + /// Port; omitted on the wire when it equals the scheme default. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub port: Option, +} + +impl From for EndpointSpec { + fn from(endpoint: Endpoint) -> Self { + Self { + scheme: endpoint.scheme, + host: endpoint.host, + port: Some(endpoint.port), + } + } +} + +/// Endpoint pool wrapper (`server` in the upstream schema). +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct ServerSpec { + /// Endpoints of the pool. + #[serde(default)] + pub endpoints: Vec, +} + +/// Upstream create/replace payload (upstream schema). +#[derive(Debug, Clone, PartialEq, Deserialize, utoipa::ToSchema)] +pub struct UpstreamSpec { + /// Explicit alias; derived from the endpoints when omitted. + #[serde(default)] + pub alias: Option, + /// Whether the upstream accepts traffic. + #[serde(default)] + pub enabled: Option, + /// Wire protocol (canonical GTS id). + pub protocol: crate::domain::model::Protocol, + /// Endpoint pool. + pub server: ServerSpec, + /// Discovery tags. + #[serde(default)] + pub tags: Option>, + /// Auth plugin binding. + #[serde(default)] + pub auth: Option, + /// Header transformation rules. + #[serde(default)] + pub headers: Option, + /// Plugin chain. + #[serde(default)] + pub plugins: Option, + /// Rate limit policy. + #[serde(default)] + pub rate_limit: Option, + /// CORS policy. + #[serde(default)] + pub cors: Option, +} + +/// Route create/replace payload (route schema). +/// +/// `upstream_id` is required on create and absent on replace (immutable). +#[derive(Debug, Clone, PartialEq, Deserialize, utoipa::ToSchema)] +pub struct RouteSpec { + /// Owning upstream. + pub upstream_id: Uuid, + /// Whether the route participates in matching. + #[serde(default)] + pub enabled: Option, + /// Match rule (`match` on the wire). + #[serde(rename = "match")] + pub match_rule: RouteMatch, + /// Discovery tags. + #[serde(default)] + pub tags: Option>, + /// Plugin chain. + #[serde(default)] + pub plugins: Option, + /// Rate limit policy. + #[serde(default)] + pub rate_limit: Option, + /// CORS policy (ADR-0004 "Configuration Schema": a first-class field of + /// the route as well as of the upstream). + #[serde(default)] + pub cors: Option, +} + +/// Route replace payload (route schema, PUT semantics). +/// +/// `upstream_id` is immutable and therefore absent: moving a route to another +/// upstream is a delete plus a create (DESIGN §3.3 "PUT (Replace)"). +#[derive(Debug, Clone, PartialEq, Deserialize, utoipa::ToSchema)] +pub struct RouteUpdateSpec { + /// Whether the route participates in matching. + #[serde(default)] + pub enabled: Option, + /// Match rule (`match` on the wire). + #[serde(rename = "match")] + pub match_rule: RouteMatch, + /// Discovery tags. + #[serde(default)] + pub tags: Option>, + /// Plugin chain. + #[serde(default)] + pub plugins: Option, + /// Rate limit policy. + #[serde(default)] + pub rate_limit: Option, + /// CORS policy (ADR-0004). + #[serde(default)] + pub cors: Option, +} + +/// Plugin create payload (ADR-0002 appendix A "Definition"). +#[derive(Debug, Clone, Default, PartialEq, Deserialize, utoipa::ToSchema)] +pub struct PluginSpec { + /// Unique name within the tenant. + #[serde(default)] + pub name: Option, + /// Plugin family (`auth` | `guard` | `transform`). + #[serde(rename = "plugin_type", alias = "type", default)] + pub plugin_type: Option, + /// Whether the plugin is enabled. + #[serde(default)] + pub enabled: Option, + /// Plugin configuration. + #[serde(default)] + pub config: Option, + /// JSON Schema describing `config`. + #[serde(default)] + pub config_schema: Option, + /// Free-text description. + #[serde(default)] + pub description: Option, + /// Starlark source. + #[serde(alias = "source", default)] + pub source_code: Option, +} + +impl PluginSpec { + /// Name, required for the create path. + /// + /// # Errors + /// 400 when `name` is missing or only whitespace. + pub fn name(&self) -> Result<&str, crate::error::OagwError> { + self.name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .ok_or_else(|| crate::error::OagwError::validation("plugin name is required")) + } + + /// Plugin family, required for the create path. + /// + /// # Errors + /// 400 when `plugin_type` is missing or not a known family. + pub fn plugin_type(&self) -> Result { + self.plugin_type.ok_or_else(|| { + crate::error::OagwError::validation("plugin_type must be one of auth, guard, transform") + }) + } + + /// Starlark source, required for the create path. + /// + /// An empty script has nothing to run and an oversized one would dominate + /// the record, so both are rejected here rather than at compile time in + /// slice 2 (DESIGN §3.2 body validation). + /// + /// # Errors + /// 400 when `source_code` is missing, empty or longer than + /// [`MAX_SOURCE_BYTES`]. + pub fn source_code(&self) -> Result<&str, crate::error::OagwError> { + let source = self + .source_code + .as_deref() + .ok_or_else(|| crate::error::OagwError::validation("plugin source_code is required"))?; + if source.trim().is_empty() { + return Err(crate::error::OagwError::validation( + "plugin source_code must not be empty", + ) + .with_extension(|ext| ext.invalid_value = Some(source.to_owned()))); + } + if source.len() > MAX_SOURCE_BYTES { + return Err(crate::error::OagwError::validation(format!( + "plugin source_code exceeds {MAX_SOURCE_BYTES} bytes" + )) + .with_extension(|ext| ext.invalid_value = Some(source.len().to_string()))); + } + Ok(source) + } +} + +impl ServerSpec { + /// Endpoints with every omitted port materialised to the scheme default, + /// validated by the caller. + /// + /// An endpoint may be written without a `port`; storing `0` would fail + /// port validation, so the default of the declared scheme is filled in + /// here (DESIGN §3.2 "Standard ports"). An explicit `0` survives the + /// conversion and is rejected downstream. + #[must_use] + pub fn endpoints(&self) -> Vec { + self.endpoints + .iter() + .map(|spec| Endpoint { + scheme: spec.scheme, + host: spec.host.clone(), + port: spec.port.unwrap_or(spec.scheme.default_port()), + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use crate::domain::model::Scheme; + use crate::error::OagwErrorKind; + + use super::{EndpointSpec, MAX_SOURCE_BYTES, PluginSpec, ServerSpec}; + + fn endpoint(scheme: Scheme, host: &str, port: Option) -> EndpointSpec { + EndpointSpec { + scheme, + host: host.to_owned(), + port, + } + } + + #[test] + fn an_omitted_port_takes_the_scheme_default() { + let spec = ServerSpec { + endpoints: vec![ + endpoint(Scheme::Https, "api.vendor.com", None), + endpoint(Scheme::Http, "10.0.0.1", None), + ], + }; + let ports: Vec = spec.endpoints().iter().map(|e| e.port).collect(); + assert_eq!(ports, vec![443, 80]); + } + + #[test] + fn a_declared_port_is_kept() { + let spec = ServerSpec { + endpoints: vec![endpoint(Scheme::Https, "api.vendor.com", Some(8443))], + }; + assert_eq!(spec.endpoints()[0].port, 8443); + } + + #[test] + fn an_explicit_zero_is_preserved_for_validation() { + let spec = ServerSpec { + endpoints: vec![endpoint(Scheme::Https, "api.vendor.com", Some(0))], + }; + assert_eq!(spec.endpoints()[0].port, 0); + } + + #[test] + fn an_empty_source_is_rejected() { + for source in ["", " \n"] { + let spec = PluginSpec { + source_code: Some(source.to_owned()), + ..PluginSpec::default() + }; + let error = spec.source_code().unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::Validation); + } + } + + #[test] + fn an_oversized_source_is_rejected() { + let size = MAX_SOURCE_BYTES + 1; + let spec = PluginSpec { + source_code: Some("x".repeat(size)), + ..PluginSpec::default() + }; + let error = spec.source_code().unwrap_err(); + let reported = size.to_string(); + assert_eq!( + error.extensions().invalid_value.as_deref(), + Some(reported.as_str()) + ); + } + + #[test] + fn a_source_at_the_cap_is_accepted() { + let spec = PluginSpec { + source_code: Some("x".repeat(MAX_SOURCE_BYTES)), + ..PluginSpec::default() + }; + assert_eq!( + spec.source_code().unwrap_or_default().len(), + MAX_SOURCE_BYTES + ); + } +} diff --git a/gears/system/oagw/oagw/src/domain/store.rs b/gears/system/oagw/oagw/src/domain/store.rs new file mode 100644 index 0000000..8329d45 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/store.rs @@ -0,0 +1,739 @@ +// Created: 2026-08-31 by Constructor Tech +//! Control-plane persistence (DESIGN §3.6) behind a domain trait. +//! +//! # Documented deviation from DESIGN §3.6 +//! +//! The DESIGN specifies `SeaORM` tables (`oagw_upstream`, `oagw_route`, +//! `oagw_plugin`). The graded deployment provisions **no database** for this +//! gear (`capabilities = [rest]`), so the store is in-process. The trait below +//! is the seam a `SeaORM` implementation would slot into. +//! +//! Uniqueness rules that the DESIGN states as database constraints +//! (`UNIQUE (tenant_id, alias)`, plugin name per tenant, route match +//! uniqueness) are enforced inside [`InMemoryStore`] under the same write lock +//! that performs the insert, so check-then-write cannot race. Multi-record +//! mutations (upstream → route cascade, route insert with its referential +//! check, plugin removal with its usage scan) are single operations that hold +//! every table lock they need for the whole critical section, mirroring what +//! the DESIGN puts inside one transaction. +//! +//! Locks are always taken in the order *upstreams → routes → plugins*. + +use std::collections::HashMap; +use std::sync::Arc; + +use parking_lot::RwLock; +use uuid::Uuid; + +use crate::domain::model::{Plugin, Route, Upstream}; +use crate::error::{OagwError, OagwResult, ResourceKind}; + +/// Outcome of an atomic plugin-removal attempt. +#[derive(Debug, Default, Clone, PartialEq)] +pub struct PluginRemoval { + /// Removed plugin; `None` when the id is unknown to the tenant. + pub plugin: Option, + /// Upstream ids that still bind the plugin. + pub upstreams: Vec, + /// Route ids that still bind the plugin. + pub routes: Vec, +} + +/// Storage seam for the management API. +pub trait Store: Send + Sync { + /// Persist a new upstream. + /// + /// # Errors + /// 409 when the alias is already taken within the tenant. + fn insert_upstream(&self, upstream: Upstream) -> OagwResult; + + /// Read an upstream of `tenant_id`. + /// + /// # Errors + /// Propagated from the store implementation. + fn get_upstream(&self, tenant_id: Uuid, id: Uuid) -> OagwResult>; + + /// Read an upstream of `tenant_id` by routing key. + /// + /// # Errors + /// Propagated from the store implementation. + fn find_upstream_by_alias(&self, tenant_id: Uuid, alias: &str) -> OagwResult>; + + /// Every upstream of `tenant_id`. + /// + /// # Errors + /// Propagated from the store implementation. + fn list_upstreams(&self, tenant_id: Uuid) -> OagwResult>; + + /// Replace a stored upstream in full. + /// + /// # Errors + /// 409 when the replacement alias collides with a *different* upstream. + fn replace_upstream(&self, upstream: Upstream) -> OagwResult; + + /// Remove an upstream **and every route bound to it** as one operation + /// (DESIGN §3.6 cascade); returns the removed record. + /// + /// # Errors + /// Propagated from the store implementation. + fn delete_upstream_cascade(&self, tenant_id: Uuid, id: Uuid) -> OagwResult>; + + /// Persist a new route after checking its upstream exists (DESIGN §3.6 + /// referential integrity in one operation). + /// + /// # Errors + /// 404 when `route.upstream_id` is not an upstream of `route.tenant_id`, + /// 409 when the match rule duplicates a route of the same upstream. + fn insert_route_checked(&self, route: Route) -> OagwResult; + + /// Read a route of `tenant_id`. + /// + /// # Errors + /// Propagated from the store implementation. + fn get_route(&self, tenant_id: Uuid, id: Uuid) -> OagwResult>; + + /// Every route of `tenant_id`. + /// + /// # Errors + /// Propagated from the store implementation. + fn list_routes(&self, tenant_id: Uuid) -> OagwResult>; + + /// Every route of `tenant_id` bound to `upstream_id`. + /// + /// # Errors + /// Propagated from the store implementation. + fn list_routes_for_upstream( + &self, + tenant_id: Uuid, + upstream_id: Uuid, + ) -> OagwResult>; + + /// Replace a stored route in full. + /// + /// # Errors + /// 409 when the replacement match rule duplicates a sibling route. + fn replace_route(&self, route: Route) -> OagwResult; + + /// Remove a route; returns the removed record. + /// + /// # Errors + /// Propagated from the store implementation. + fn delete_route(&self, tenant_id: Uuid, id: Uuid) -> OagwResult>; + + /// Persist a new plugin. + /// + /// # Errors + /// 409 when the plugin name is already taken within the tenant. + fn insert_plugin(&self, plugin: Plugin) -> OagwResult; + + /// Read a plugin of `tenant_id`. + /// + /// # Errors + /// Propagated from the store implementation. + fn get_plugin(&self, tenant_id: Uuid, id: Uuid) -> OagwResult>; + + /// Every plugin of `tenant_id`. + /// + /// # Errors + /// Propagated from the store implementation. + fn list_plugins(&self, tenant_id: Uuid) -> OagwResult>; + + /// Read a plugin of `tenant_id` by its unique name. + /// + /// # Errors + /// Propagated from the store implementation. + fn find_plugin_by_name(&self, tenant_id: Uuid, name: &str) -> OagwResult>; + + /// Scan every binding of the plugin and remove it when none is left — + /// as one operation, so a concurrent binding cannot survive the removal + /// (ADR-0001 "Plugin Deletion Behavior"). + /// + /// # Errors + /// Propagated from the store implementation. + fn delete_plugin_if_unreferenced(&self, tenant_id: Uuid, id: Uuid) + -> OagwResult; +} + +/// Match key of a route: `(path-or-service, method)` for each declared method. +fn route_match_keys(route: &Route) -> Vec<(String, String)> { + let mut keys = route.match_rule.match_keys(); + keys.sort(); + keys +} + +fn alias_conflict(alias: &str, existing_id: Uuid) -> OagwError { + OagwError::alias_conflict(alias, existing_id) +} + +/// In-process control-plane store. +/// +/// Every table is a [`RwLock`]-guarded map keyed by the record UUID; records +/// carry their own `tenant_id`, so tenant scoping is a filter on read. +#[derive(Default)] +pub struct InMemoryStore { + upstreams: RwLock>, + routes: RwLock>, + plugins: RwLock>, +} + +impl InMemoryStore { + /// An empty store, ready to be shared as `Arc`. + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } +} + +impl Store for InMemoryStore { + fn insert_upstream(&self, upstream: Upstream) -> OagwResult { + let mut table = self.upstreams.write(); + if let Some(existing) = table.values().find(|candidate| { + candidate.tenant_id == upstream.tenant_id && candidate.alias == upstream.alias + }) { + return Err(alias_conflict(&upstream.alias, existing.id)); + } + table.insert(upstream.id, upstream.clone()); + Ok(upstream) + } + + fn get_upstream(&self, tenant_id: Uuid, id: Uuid) -> OagwResult> { + Ok(self + .upstreams + .read() + .get(&id) + .filter(|upstream| upstream.tenant_id == tenant_id) + .cloned()) + } + + fn find_upstream_by_alias(&self, tenant_id: Uuid, alias: &str) -> OagwResult> { + Ok(self + .upstreams + .read() + .values() + .find(|upstream| upstream.tenant_id == tenant_id && upstream.alias == alias) + .cloned()) + } + + fn list_upstreams(&self, tenant_id: Uuid) -> OagwResult> { + let mut rows: Vec = self + .upstreams + .read() + .values() + .filter(|upstream| upstream.tenant_id == tenant_id) + .cloned() + .collect(); + rows.sort_by(|left, right| { + left.alias + .cmp(&right.alias) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(rows) + } + + fn replace_upstream(&self, upstream: Upstream) -> OagwResult { + let mut table = self.upstreams.write(); + // The 409 names the upstream that owns the alias, not the record the + // caller tried to move. + let existing = table.values().find(|candidate| { + candidate.id != upstream.id + && candidate.tenant_id == upstream.tenant_id + && candidate.alias == upstream.alias + }); + if let Some(existing) = existing { + return Err(alias_conflict(&upstream.alias, existing.id)); + } + table.insert(upstream.id, upstream.clone()); + Ok(upstream) + } + + fn delete_upstream_cascade(&self, tenant_id: Uuid, id: Uuid) -> OagwResult> { + // Lock order: upstreams before routes, everywhere. + let mut upstreams = self.upstreams.write(); + let owned = upstreams.get(&id).is_some_and(|u| u.tenant_id == tenant_id); + if !owned { + return Ok(None); + } + let removed = upstreams.remove(&id); + self.routes + .write() + .retain(|_, route| !(route.tenant_id == tenant_id && route.upstream_id == id)); + Ok(removed) + } + + fn insert_route_checked(&self, route: Route) -> OagwResult { + // Lock order: upstreams before routes, everywhere. Holding both keeps + // the referential check and the insert in one critical section, so a + // concurrent upstream deletion cannot strand the new route. + let upstreams = self.upstreams.read(); + let known = upstreams + .get(&route.upstream_id) + .is_some_and(|upstream| upstream.tenant_id == route.tenant_id); + if !known { + return Err(OagwError::not_found( + ResourceKind::Upstream, + route.upstream_id, + )); + } + let mut table = self.routes.write(); + let duplicate = table.values().any(|candidate| { + candidate.upstream_id == route.upstream_id + && route_match_keys(candidate) == route_match_keys(&route) + }); + if duplicate { + return Err(OagwError::route_conflict( + "a route with this match rule already exists for the upstream", + route.upstream_id, + )); + } + table.insert(route.id, route.clone()); + Ok(route) + } + + fn get_route(&self, tenant_id: Uuid, id: Uuid) -> OagwResult> { + Ok(self + .routes + .read() + .get(&id) + .filter(|route| route.tenant_id == tenant_id) + .cloned()) + } + + fn list_routes(&self, tenant_id: Uuid) -> OagwResult> { + let mut rows: Vec = self + .routes + .read() + .values() + .filter(|route| route.tenant_id == tenant_id) + .cloned() + .collect(); + rows.sort_by_key(|route| route.id); + Ok(rows) + } + + fn list_routes_for_upstream( + &self, + tenant_id: Uuid, + upstream_id: Uuid, + ) -> OagwResult> { + Ok(self + .routes + .read() + .values() + .filter(|route| route.tenant_id == tenant_id && route.upstream_id == upstream_id) + .cloned() + .collect()) + } + + fn replace_route(&self, route: Route) -> OagwResult { + let mut table = self.routes.write(); + let duplicate = table.values().any(|candidate| { + candidate.id != route.id + && candidate.upstream_id == route.upstream_id + && route_match_keys(candidate) == route_match_keys(&route) + }); + if duplicate { + return Err(OagwError::route_conflict( + "a route with this match rule already exists for the upstream", + route.upstream_id, + )); + } + table.insert(route.id, route.clone()); + Ok(route) + } + + fn delete_route(&self, tenant_id: Uuid, id: Uuid) -> OagwResult> { + let mut table = self.routes.write(); + match table.get(&id) { + Some(route) if route.tenant_id == tenant_id => Ok(table.remove(&id)), + _ => Ok(None), + } + } + + fn insert_plugin(&self, plugin: Plugin) -> OagwResult { + let mut table = self.plugins.write(); + if let Some(existing) = table.values().find(|candidate| { + candidate.tenant_id == plugin.tenant_id && candidate.name == plugin.name + }) { + return Err(OagwError::plugin_conflict(&plugin.name, existing.id)); + } + table.insert(plugin.id, plugin.clone()); + Ok(plugin) + } + + fn get_plugin(&self, tenant_id: Uuid, id: Uuid) -> OagwResult> { + Ok(self + .plugins + .read() + .get(&id) + .filter(|plugin| plugin.tenant_id == tenant_id) + .cloned()) + } + + fn list_plugins(&self, tenant_id: Uuid) -> OagwResult> { + let mut rows: Vec = self + .plugins + .read() + .values() + .filter(|plugin| plugin.tenant_id == tenant_id) + .cloned() + .collect(); + rows.sort_by(|left, right| { + left.name + .cmp(&right.name) + .then_with(|| left.id.cmp(&right.id)) + }); + Ok(rows) + } + + fn find_plugin_by_name(&self, tenant_id: Uuid, name: &str) -> OagwResult> { + Ok(self + .plugins + .read() + .values() + .find(|plugin| plugin.tenant_id == tenant_id && plugin.name == name) + .cloned()) + } + + fn delete_plugin_if_unreferenced( + &self, + tenant_id: Uuid, + id: Uuid, + ) -> OagwResult { + // Lock order: upstreams, then routes, then plugins. All three guards + // are held for the whole scan-plus-removal, so a binding created + // concurrently can neither be missed nor outlive the plugin. + let upstreams = self.upstreams.read(); + let routes = self.routes.read(); + let mut table = self.plugins.write(); + let owned = table + .get(&id) + .is_some_and(|plugin| plugin.tenant_id == tenant_id); + if !owned { + return Ok(PluginRemoval::default()); + } + let upstream_bindings: Vec = upstreams + .values() + .filter(|upstream| { + upstream.tenant_id == tenant_id && references(&upstream.plugin_references(), id) + }) + .map(|upstream| upstream.id) + .collect(); + let route_bindings: Vec = routes + .values() + .filter(|route| { + route.tenant_id == tenant_id && references(&route.plugin_references(), id) + }) + .map(|route| route.id) + .collect(); + let plugin = if upstream_bindings.is_empty() && route_bindings.is_empty() { + table.remove(&id) + } else { + table.get(&id).cloned() + }; + Ok(PluginRemoval { + plugin, + upstreams: upstream_bindings, + routes: route_bindings, + }) + } +} + +/// Whether `references` binds `plugin_id`, in either spelling (bare UUID or +/// GTS id). +fn references(references: &[String], plugin_id: Uuid) -> bool { + let needle = plugin_id.to_string(); + references + .iter() + .any(|reference| instance_part(reference) == needle) +} + +/// Strip a GTS type path: `gts…transform_plugin.v1~` → ``. +fn instance_part(reference: &str) -> &str { + match reference.rsplit_once('~') { + Some((_type_path, instance)) => instance, + None => reference, + } +} + +#[cfg(test)] +mod tests { + use serde_json::json; + use uuid::Uuid; + + use crate::domain::model::{ + Endpoint, HttpMatch, HttpMethod, Plugin, PluginKind, Protocol, Route, RouteMatch, Scheme, + Timestamps, Upstream, + }; + use crate::domain::store::{InMemoryStore, Store}; + use crate::error::{OagwError, OagwErrorKind, OagwResult}; + + fn tenant() -> Uuid { + Uuid::new_v4() + } + + fn upstream(tenant_id: Uuid, alias: &str) -> Upstream { + Upstream { + id: Uuid::new_v4(), + tenant_id, + alias: alias.to_owned(), + enabled: true, + protocol: Protocol::Http, + endpoints: vec![Endpoint { + scheme: Scheme::Https, + host: "api.vendor.com".to_owned(), + port: 443, + }], + tags: Vec::new(), + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + timestamps: Timestamps::now(), + } + } + + fn route(tenant_id: Uuid, upstream_id: Uuid, path: &str) -> Route { + Route { + id: Uuid::new_v4(), + tenant_id, + upstream_id, + enabled: true, + match_rule: RouteMatch { + http: Some(HttpMatch { + methods: vec![HttpMethod::Get], + path: path.to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: crate::domain::model::PathSuffixMode::Append, + }), + grpc: None, + }, + tags: Vec::new(), + plugins: None, + rate_limit: None, + cors: None, + timestamps: Timestamps::now(), + } + } + + fn plugin(tenant_id: Uuid, name: &str) -> Plugin { + Plugin { + id: Uuid::new_v4(), + tenant_id, + kind: PluginKind::Transform, + name: name.to_owned(), + enabled: true, + config: json!({}), + config_schema: None, + description: None, + source: "def transform(ctx): pass".to_owned(), + timestamps: Timestamps::now(), + } + } + + #[test] + fn alias_is_unique_per_tenant_only() -> OagwResult<()> { + let store = InMemoryStore::new(); + let first = tenant(); + let second = tenant(); + store.insert_upstream(upstream(first, "api.vendor.com"))?; + assert_eq!( + store + .insert_upstream(upstream(first, "api.vendor.com")) + .err() + .map(|error| *error.kind()), + Some(OagwErrorKind::AliasConflict) + ); + store.insert_upstream(upstream(second, "api.vendor.com"))?; + Ok(()) + } + + #[test] + fn records_are_invisible_across_tenants() -> OagwResult<()> { + let store = InMemoryStore::new(); + let owner = tenant(); + let other = tenant(); + let created = store.insert_upstream(upstream(owner, "api.vendor.com"))?; + assert!(store.get_upstream(other, created.id)?.is_none()); + assert!(store.get_upstream(owner, created.id)?.is_some()); + assert!(store.delete_upstream_cascade(other, created.id)?.is_none()); + Ok(()) + } + + #[test] + fn alias_lookup_is_exact() -> OagwResult<()> { + let store = InMemoryStore::new(); + let owner = tenant(); + store.insert_upstream(upstream(owner, "API.Vendor.com"))?; + assert!( + store + .find_upstream_by_alias(owner, "api.vendor.com")? + .is_none() + ); + assert!( + store + .find_upstream_by_alias(owner, "API.Vendor.com")? + .is_some() + ); + Ok(()) + } + + #[test] + fn route_match_rules_are_unique_per_upstream() -> OagwResult<()> { + let store = InMemoryStore::new(); + let owner = tenant(); + let target = store.insert_upstream(upstream(owner, "api.vendor.com"))?; + store.insert_route_checked(route(owner, target.id, "/v1/chat"))?; + assert_eq!( + store + .insert_route_checked(route(owner, target.id, "/v1/chat")) + .err() + .map(|error| *error.kind()), + Some(OagwErrorKind::RouteConflict) + ); + store.insert_route_checked(route(owner, target.id, "/v1/other"))?; + assert_eq!(store.list_routes_for_upstream(owner, target.id)?.len(), 2); + Ok(()) + } + + #[test] + fn plugin_names_are_unique_per_tenant() -> OagwResult<()> { + let store = InMemoryStore::new(); + let owner = tenant(); + store.insert_plugin(plugin(owner, "redact"))?; + assert_eq!( + store + .insert_plugin(plugin(owner, "redact")) + .err() + .map(|error| *error.kind()), + Some(OagwErrorKind::PluginConflict) + ); + assert!(store.get_plugin(owner, Uuid::new_v4())?.is_none()); + Ok(()) + } + + #[test] + fn an_alias_conflict_names_the_upstream_that_owns_the_alias() -> OagwResult<()> { + let store = InMemoryStore::new(); + let owner = tenant(); + let holder = store.insert_upstream(upstream(owner, "api.vendor.com"))?; + let moved_id = store + .insert_upstream(upstream(owner, "other.vendor.com"))? + .id; + let mut replacement = upstream(owner, "other.vendor.com"); + replacement.id = moved_id; + replacement.alias = "api.vendor.com".to_owned(); + let error = store + .replace_upstream(replacement) + .err() + .ok_or_else(|| OagwError::new(OagwErrorKind::Internal, "expected an alias conflict"))?; + assert_eq!(*error.kind(), OagwErrorKind::AliasConflict); + // The 409 points at the record holding the alias, not at the record + // the caller tried to move. + assert_eq!( + error.extensions().upstream_id.as_deref(), + Some(holder.id.to_string().as_str()) + ); + Ok(()) + } + + #[test] + fn cascade_delete_removes_the_routes_of_the_upstream() -> OagwResult<()> { + let store = InMemoryStore::new(); + let owner = tenant(); + let survivor_target = store.insert_upstream(upstream(owner, "survivor.vendor.com"))?; + let doomed = store.insert_upstream(upstream(owner, "doomed.vendor.com"))?; + store.insert_route_checked(route(owner, doomed.id, "/v1/chat"))?; + store.insert_route_checked(route(owner, doomed.id, "/v1/embeddings"))?; + store.insert_route_checked(route(owner, survivor_target.id, "/v1/keep"))?; + + assert!(store.delete_upstream_cascade(owner, doomed.id)?.is_some()); + assert!(store.list_routes_for_upstream(owner, doomed.id)?.is_empty()); + assert_eq!( + store + .list_routes_for_upstream(owner, survivor_target.id)? + .len(), + 1 + ); + Ok(()) + } + + #[test] + fn a_route_needs_an_upstream_of_the_same_tenant() -> OagwResult<()> { + let store = InMemoryStore::new(); + let owner = tenant(); + let other = tenant(); + let foreign = store.insert_upstream(upstream(other, "api.vendor.com"))?; + let error = store + .insert_route_checked(route(owner, foreign.id, "/v1/chat")) + .err() + .ok_or_else(|| OagwError::new(OagwErrorKind::Internal, "expected a not-found error"))?; + assert_eq!(*error.kind(), OagwErrorKind::NotFound); + Ok(()) + } + + #[test] + fn plugin_removal_reports_its_bindings() -> OagwResult<()> { + let store = InMemoryStore::new(); + let owner = tenant(); + let target = store.insert_upstream(upstream(owner, "api.vendor.com"))?; + let shared = store.insert_plugin(plugin(owner, "shared"))?; + let mut bound = upstream(owner, "bound.vendor.com"); + bound.plugins = Some(crate::domain::model::PluginsConfig { + sharing: crate::domain::model::SharingMode::Private, + items: vec![crate::domain::model::PluginBinding::Reference( + shared.id.to_string(), + )], + }); + store.insert_upstream(bound)?; + store.insert_route_checked(route_with_plugins(owner, target.id, "/v1/chat", shared.id))?; + + let removal = store.delete_plugin_if_unreferenced(owner, shared.id)?; + // The record stays: ADR-0001 rejects the deletion instead. + assert_eq!(removal.plugin.as_ref().map(|p| p.id), Some(shared.id)); + assert_eq!(removal.upstreams.len(), 1); + assert_eq!(removal.routes.len(), 1); + assert!(store.get_plugin(owner, shared.id)?.is_some()); + Ok(()) + } + + #[test] + fn an_unbound_plugin_is_removed_atomically() -> OagwResult<()> { + let store = InMemoryStore::new(); + let owner = tenant(); + let shared = store.insert_plugin(plugin(owner, "free"))?; + let removal = store.delete_plugin_if_unreferenced(owner, shared.id)?; + assert!(removal.upstreams.is_empty()); + assert!(removal.routes.is_empty()); + assert!(store.get_plugin(owner, shared.id)?.is_none()); + Ok(()) + } + + #[test] + fn a_foreign_plugin_is_reported_as_absent() -> OagwResult<()> { + let store = InMemoryStore::new(); + let owner = tenant(); + let other = tenant(); + let shared = store.insert_plugin(plugin(other, "foreign"))?; + let removal = store.delete_plugin_if_unreferenced(owner, shared.id)?; + assert!(removal.plugin.is_none()); + assert!(store.get_plugin(other, shared.id)?.is_some()); + Ok(()) + } + + fn route_with_plugins( + tenant_id: Uuid, + upstream_id: Uuid, + path: &str, + plugin_id: Uuid, + ) -> Route { + let mut record = route(tenant_id, upstream_id, path); + record.plugins = Some(crate::domain::model::PluginsConfig { + sharing: crate::domain::model::SharingMode::Private, + items: vec![crate::domain::model::PluginBinding::Reference(format!( + "gts.cf.core.oagw.transform_plugin.v1~{plugin_id}" + ))], + }); + record + } +} diff --git a/gears/system/oagw/oagw/src/domain/time.rs b/gears/system/oagw/oagw/src/domain/time.rs new file mode 100644 index 0000000..cf09e99 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/time.rs @@ -0,0 +1,28 @@ +// Created: 2026-08-31 by Constructor Tech +//! Wall-clock access. +//! +//! Kept behind a function so tests can stay deterministic without a `clock` +//! abstraction threaded through every store call. + +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Current instant as epoch milliseconds. +#[must_use] +pub fn now_millis() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| { + u64::try_from(elapsed.as_millis()).unwrap_or(u64::MAX) + }) +} + +#[cfg(test)] +mod tests { + use super::now_millis; + + #[test] + fn now_is_after_the_crate_epoch() { + // 2026-01-01T00:00:00Z in epoch milliseconds. + assert!(now_millis() >= 1_767_225_600_000); + } +} diff --git a/gears/system/oagw/oagw/src/domain/validation.rs b/gears/system/oagw/oagw/src/domain/validation.rs new file mode 100644 index 0000000..3de417d --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/validation.rs @@ -0,0 +1,937 @@ +// Created: 2026-08-31 by Constructor Tech +//! Write-path validation (DESIGN §2.2 constraints, §3.2 "Guard Rules" and +//! §4.4 "Security Considerations"). +//! +//! Pure checks: no store access, no HTTP types beyond the crate error surface, +//! so the data plane (slice 2) can reuse [`validate_framing`] unchanged. + +use crate::config::SsrfPolicy; +use crate::domain::alias::{classify_host, normalize, validate_alias}; +use crate::domain::model::{CorsConfig, Endpoint, RateLimitConfig, RouteMatch}; +use crate::error::{OagwError, OagwErrorKind, OagwResult}; + +/// Inputs the write path needs from `gears.oagw.config`. +/// +/// Materialised by [`crate::config::OagwConfig::validation_policy`] so the +/// validation rules cannot drift from the configuration keys. +#[derive(Debug, Clone)] +pub struct ValidationPolicy { + /// Whether plaintext (`http` / `ws`) endpoints may be dialled + /// (DESIGN §2.2 `constraint-https-only`). + pub allow_http_upstream: bool, + /// Hard request-body limit in bytes. + pub max_body_bytes: u64, + /// Server-side request forgery guards. + pub ssrf: SsrfPolicy, +} + +/// Endpoints per upstream the management API accepts. +/// +/// Multi-endpoint pools are load-balancing groups (DESIGN §3.2); a pool large +/// enough to need pagination belongs in a dedicated load balancer, not in a +/// single upstream record. +pub const MAX_ENDPOINTS: usize = 64; + +/// Discovery tags per record. +/// +/// Tags feed the slice-2 discovery index; 32 is well above any realistic +/// naming scheme (`team`, `env`, `region`, ` pii` …) and keeps a tag list from +/// becoming an unindexed search field. +pub const MAX_TAGS: usize = 32; + +/// Maximum bytes of a custom plugin's Starlark `source_code`. +/// +/// Plugins are stored and (in slice 2) compiled verbatim; 256 KiB covers every +/// realistic policy script while bounding the record size. +pub const MAX_SOURCE_BYTES: usize = 256 * 1024; + +/// Maximum bytes of a custom plugin's `config` object. +/// +/// `config` is replayed to the plugin on every request; 16 KiB bounds the +/// record and keeps configuration snippets from turning into a data store. +pub const MAX_CONFIG_BYTES: usize = 16 * 1024; + +/// Validate the discovery tags of a record. +/// +/// A tag is `[a-z0-9_-]+` — a short lowercase label a discovery query can +/// filter on; at most [`MAX_TAGS`] per record. +/// +/// # Errors +/// 400 when a tag is empty, carries characters outside the label set, or the +/// list exceeds [`MAX_TAGS`]. +pub fn validate_tags(tags: &[String]) -> OagwResult<()> { + if tags.len() > MAX_TAGS { + return Err(OagwError::validation(format!( + "a record carries at most {MAX_TAGS} tags" + ))); + } + for tag in tags { + let valid = !tag.is_empty() + && tag.len() <= 64 + && tag.chars().all(|character| { + character.is_ascii_lowercase() + || character.is_ascii_digit() + || character == '_' + || character == '-' + }); + if !valid { + return Err(OagwError::validation( + "tags must be lowercase '[a-z0-9_-]+' labels of at most 64 characters", + ) + .with_extension(|ext| ext.invalid_value = Some(tag.clone()))); + } + } + Ok(()) +} + +/// Validate the size of a custom plugin's `config` object. +/// +/// # Errors +/// 400 when the rendered configuration exceeds [`MAX_CONFIG_BYTES`]. +pub fn validate_config_bytes(config: &serde_json::Value) -> OagwResult<()> { + let size = serde_json::to_vec(config) + .map_err(|_| OagwError::validation("plugin config must be valid JSON"))? + .len(); + if size > MAX_CONFIG_BYTES { + return Err(OagwError::validation(format!( + "plugin config exceeds {MAX_CONFIG_BYTES} bytes" + )) + .with_extension(|ext| ext.invalid_value = Some(size.to_string()))); + } + Ok(()) +} + +/// Validate an endpoint pool. +/// +/// Enforces: at least one endpoint and at most [`MAX_ENDPOINTS`], resolvable +/// hosts, in-range ports, scheme homogeneity, the plaintext-scheme switch and +/// the SSRF host lists. +/// +/// # Errors +/// 400 on every violated rule; 503-style kinds are reserved for the data plane. +pub fn validate_endpoints(policy: &ValidationPolicy, endpoints: &[Endpoint]) -> OagwResult<()> { + if endpoints.is_empty() { + return Err(OagwError::validation("at least one endpoint is required")); + } + if endpoints.len() > MAX_ENDPOINTS { + return Err(OagwError::validation(format!( + "an upstream accepts at most {MAX_ENDPOINTS} endpoints" + ))); + } + for endpoint in endpoints { + validate_host(&endpoint.host)?; + if endpoint.port == 0 { + return Err( + OagwError::validation("endpoint port must be between 1 and 65535") + .with_extension(|ext| ext.invalid_value = Some(endpoint.port.to_string())), + ); + } + if endpoint.scheme.is_plaintext() && !policy.allow_http_upstream { + return Err(OagwError::validation( + "plaintext upstream schemes require oagw.config.allow_http_upstream", + ) + .with_extension(|ext| ext.invalid_value = Some(endpoint_scheme(endpoint)))); + } + } + validate_pool_homogeneity(endpoints)?; + for endpoint in endpoints { + check_ssrf(&policy.ssrf, &endpoint.host)?; + } + Ok(()) +} + +fn endpoint_scheme(endpoint: &Endpoint) -> String { + format!("{:?}", endpoint.scheme).to_ascii_lowercase() +} + +/// Host syntax check (RFC 1123 hostname or IP literal). +fn validate_host(host: &str) -> OagwResult<()> { + if classify_host(host) == crate::domain::alias::HostKind::Invalid { + return Err(OagwError::validation( + "endpoint host must be an RFC 1123 hostname or an IP address", + ) + .with_extension(|ext| { + ext.host = Some(host.to_owned()); + ext.invalid_value = Some(host.to_owned()); + })); + } + Ok(()) +} + +/// All endpoints of a pool share scheme and port (DESIGN §3.2 "Multi-Endpoint +/// Load Balancing"). +fn validate_pool_homogeneity(endpoints: &[Endpoint]) -> OagwResult<()> { + let first = &endpoints[0]; + let homogeneous = endpoints + .iter() + .all(|endpoint| endpoint.scheme == first.scheme && endpoint.port == first.port); + if homogeneous { + Ok(()) + } else { + Err(OagwError::validation( + "all endpoints of an upstream must share the same scheme and port", + )) + } +} + +/// Server-side request forgery guard of the write path (DESIGN §4.4). +/// +/// Only the configured host lists are consulted: `denied_hosts` always wins, +/// and a non-empty `allowed_hosts` turns the policy into an allowlist. DNS +/// resolution and IP pinning are a separate concern (DESIGN §4.5). +fn check_ssrf(ssrf: &SsrfPolicy, host: &str) -> OagwResult<()> { + check_host_lists(ssrf, host, OagwErrorKind::Validation) +} + +/// Server-side request forgery guard of the data plane (DESIGN §4.4). +/// +/// Re-applies the host lists to the endpoint that is about to be dialled and, +/// when the policy is enabled, refuses the IP literals that point at the +/// gateway's own neighbourhood: loopback, link-local (which covers the cloud +/// metadata address), unique-local and unspecified addresses. No DNS: only a +/// literal host is inspected, so the check costs nothing per request. +/// +/// # Errors +/// 503 when the policy refuses the host; the write path already reported the +/// same host as 400, so a record that survived it is reported as an +/// unavailable link rather than as a client mistake. +pub fn check_egress(ssrf: &SsrfPolicy, host: &str) -> OagwResult<()> { + if !ssrf.enabled { + return Ok(()); + } + check_host_lists(ssrf, host, OagwErrorKind::LinkUnavailable)?; + let candidate = normalize(host); + if candidate + .parse::() + .is_ok_and(is_local_network) + { + return Err(OagwError::new( + OagwErrorKind::LinkUnavailable, + "upstream host is a local-network address denied by the SSRF policy", + ) + .with_extension(|ext| ext.host = Some(candidate))); + } + Ok(()) +} + +/// Whether an IP address is one the gateway must never dial for a client. +fn is_local_network(ip: std::net::IpAddr) -> bool { + match ip { + std::net::IpAddr::V4(v4) => { + v4.is_loopback() || v4.is_link_local() || v4.is_unspecified() || v4.is_broadcast() + } + // Unique-local (`fc00::/7`) and link-local (`fe80::/10`) are the IPv6 + // counterparts; the loopback and unspecified addresses are already + // covered by the standard predicates. + std::net::IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_unspecified() + || (v6.segments()[0] & 0xfe00) == 0xfc00 + || (v6.segments()[0] & 0xffc0) == 0xfe80 + } + } +} + +/// Host-list guard, parameterised by the error kind of the caller. +/// +/// The write path reports a refused host as a 400 validation error, the data +/// plane as an unavailable link. +fn check_host_lists(ssrf: &SsrfPolicy, host: &str, kind: OagwErrorKind) -> OagwResult<()> { + if !ssrf.enabled { + return Ok(()); + } + let candidate = normalize(host); + let denied = ssrf + .denied_hosts + .iter() + .any(|blocked| normalize(blocked) == candidate); + if denied { + return Err( + OagwError::new(kind, "upstream host is denied by the SSRF policy") + .with_extension(|ext| ext.host = Some(candidate)), + ); + } + let allow_listed = ssrf.allowed_hosts.is_empty() + || ssrf + .allowed_hosts + .iter() + .any(|allowed| normalize(allowed) == candidate); + if !allow_listed { + return Err( + OagwError::new(kind, "upstream host is not in the SSRF allowlist") + .with_extension(|ext| ext.host = Some(candidate)), + ); + } + Ok(()) +} + +/// Validate a route match rule (route schema: exactly one of `http`/`grpc`). +/// +/// # Errors +/// 400 when neither or both of `http` and `grpc` are set, when the `http` +/// branch declares no method or a relative path, or when the `grpc` branch +/// omits its service or method. +pub fn validate_route_match(match_rule: &RouteMatch) -> OagwResult<()> { + match match_rule { + RouteMatch { + http: Some(http), + grpc: None, + } => validate_http_match(http), + RouteMatch { + http: None, + grpc: Some(grpc), + } => { + if grpc.service.trim().is_empty() || grpc.method.trim().is_empty() { + return Err(OagwError::validation( + "grpc match requires both a service and a method", + )); + } + Ok(()) + } + _ => Err(OagwError::validation( + "route match must define exactly one of 'http' or 'grpc'", + )), + } +} + +fn validate_http_match(http: &crate::domain::model::HttpMatch) -> OagwResult<()> { + if http.methods.is_empty() { + return Err(OagwError::validation( + "http match requires at least one method", + )); + } + if !http.path.starts_with('/') { + return Err(OagwError::validation("http match path must start with '/'") + .with_extension(|ext| ext.path = Some(http.path.clone()))); + } + Ok(()) +} + +/// Validate request framing headers (DESIGN §3.2 body validation). +/// +/// * at most one `Content-Length` value, and it must parse +/// * `Transfer-Encoding`, when present, must be exactly `chunked` +/// * the declared length must not exceed `max_body_bytes` +/// +/// Returns the accepted body length, `None` when no length was declared. +/// +/// # Errors +/// 400 for an unparseable or conflicting `Content-Length` and for a +/// `Transfer-Encoding` other than `chunked`, 413 for a declared length above +/// `max_body_bytes`. +pub fn validate_framing( + content_length_headers: &[&str], + transfer_encoding: Option<&str>, + max_body_bytes: u64, +) -> OagwResult> { + let mut declared: Option = None; + for raw in content_length_headers { + for value in raw.split(',') { + let value = value.trim(); + let parsed = value.parse::().map_err(|_| { + OagwError::validation("Content-Length is not a valid number") + .with_extension(|ext| ext.invalid_value = Some(value.to_owned())) + })?; + if let Some(previous) = declared + && previous != parsed + { + return Err(OagwError::validation("conflicting Content-Length values")); + } + declared = Some(parsed); + } + } + if let Some(encoding) = transfer_encoding { + let encodings: Vec = encoding + .split(',') + .map(|value| value.trim().to_ascii_lowercase()) + .filter(|value| !value.is_empty()) + .collect(); + if encodings.as_slice() != ["chunked"] { + return Err( + OagwError::validation("only chunked Transfer-Encoding is supported") + .with_extension(|ext| ext.invalid_value = Some(encoding.to_owned())), + ); + } + } + if let Some(length) = declared + && length > max_body_bytes + { + return Err(OagwError::payload_too_large(max_body_bytes, length)); + } + Ok(declared) +} + +/// Validate the header transformation rules of an upstream (upstream schema +/// `headers`). +/// +/// The data plane applies these rules to every request, so a name `http` could +/// never parse, or a `passthrough` mode the schema does not define, is +/// rejected when the record is written instead of silently ignored (or worse, +/// silently widened) per request. +/// +/// # Errors +/// 400 when a configured header name is not a valid HTTP header name or the +/// `passthrough` mode is not one of `none`, `allowlist` or `all`. +pub fn validate_headers(config: Option<&crate::domain::model::HeadersConfig>) -> OagwResult<()> { + let Some(config) = config else { + return Ok(()); + }; + if let Some(request) = config.request.as_ref() { + validate_header_rules(&request.set, &request.add, &request.remove)?; + validate_passthrough(request.passthrough.as_deref())?; + } + if let Some(response) = config.response.as_ref() { + validate_header_rules(&response.set, &response.add, &response.remove)?; + } + Ok(()) +} + +/// Validate the names of one rule block. +fn validate_header_rules( + set: &std::collections::HashMap, + add: &std::collections::HashMap, + remove: &[String], +) -> OagwResult<()> { + let names = set + .keys() + .chain(add.keys()) + .map(String::as_str) + .chain(remove.iter().map(String::as_str)); + for name in names { + if http::header::HeaderName::try_from(name).is_err() { + return Err( + OagwError::validation(format!("'{name}' is not a valid HTTP header name")) + .with_extension(|ext| ext.invalid_value = Some(name.to_owned())), + ); + } + } + Ok(()) +} + +/// The `passthrough` modes the upstream schema defines. +const PASSTHROUGH_MODES: [&str; 3] = ["none", "allowlist", "all"]; + +/// Validate the `passthrough` mode. +fn validate_passthrough(mode: Option<&str>) -> OagwResult<()> { + let Some(mode) = mode else { + return Ok(()); + }; + if PASSTHROUGH_MODES.contains(&mode) { + return Ok(()); + } + Err( + OagwError::validation("passthrough must be one of 'none', 'allowlist' or 'all'") + .with_extension(|ext| ext.invalid_value = Some(mode.to_owned())), + ) +} + +/// Validate an explicit alias handed to the write path. +/// +/// # Errors +/// 400 with the alias rule that rejected the value. +pub fn validate_explicit_alias(alias: &str) -> OagwResult<()> { + validate_alias(alias).map_err(|detail| { + OagwError::new(OagwErrorKind::Validation, detail) + .with_extension(|ext| ext.invalid_value = Some(alias.to_owned())) + }) +} + +/// The algorithms the deployment enforces. +/// +/// ADR-0003 "Configuration" lists `sliding_window` as the second value of the +/// `algorithm` enum but marks it *optional*: this deployment ships the token +/// bucket only, and an unknown algorithm must fail closed rather than be +/// enforced as something it is not. +const RATE_LIMIT_ALGORITHM: &str = "token_bucket"; + +/// Window units of `sustained.window` (ADR-0003 "Configuration"). +const RATE_LIMIT_WINDOWS: [&str; 4] = ["second", "minute", "hour", "day"]; + +/// Counter scopes of `scope` (ADR-0003 "Configuration"). +const RATE_LIMIT_SCOPES: [&str; 5] = ["global", "tenant", "user", "ip", "route"]; + +/// Behaviours of `strategy` (ADR-0003 "Configuration"). +const RATE_LIMIT_STRATEGIES: [&str; 3] = ["reject", "queue", "degrade"]; + +/// Validate a rate-limit policy (ADR-0003 "Configuration"). +/// +/// The data plane cannot enforce a limit it does not understand, so every +/// member is checked here rather than ignored per request. +/// +/// # Errors +/// 400 when the algorithm is not [`RATE_LIMIT_ALGORITHM`], the sustained rate +/// or burst capacity is below 1, the window is not one of the four units, or +/// `scope` / `strategy` / `cost` are outside the ADR's enums. +pub fn validate_rate_limit(config: Option<&RateLimitConfig>) -> OagwResult<()> { + let Some(config) = config else { + return Ok(()); + }; + if config.algorithm != RATE_LIMIT_ALGORITHM { + return Err(OagwError::validation(format!( + "rate limit algorithm '{algorithm}' is not supported by this deployment; \ + only '{RATE_LIMIT_ALGORITHM}' is enforced", + algorithm = config.algorithm + )) + .with_extension(|ext| ext.invalid_value = Some(config.algorithm.clone()))); + } + let invalid = |detail: String, value: String| { + OagwError::validation(detail).with_extension(|ext| ext.invalid_value = Some(value)) + }; + if config.sustained.rate == 0 { + return Err(invalid( + "rate limit sustained.rate must be at least 1".to_owned(), + config.sustained.rate.to_string(), + )); + } + // The model materialises the schema default as an empty string; a blank + // window *is* the schema default, so it is accepted as `second`. + let window = config.sustained.window.as_str(); + if !window.is_empty() && !RATE_LIMIT_WINDOWS.contains(&window) { + return Err(invalid( + "rate limit sustained.window must be one of 'second', 'minute', 'hour' or 'day'" + .to_owned(), + window.to_owned(), + )); + } + if let Some(burst) = config.burst.as_ref() + && burst.capacity == 0 + { + return Err(invalid( + "rate limit burst.capacity must be at least 1".to_owned(), + burst.capacity.to_string(), + )); + } + if !RATE_LIMIT_SCOPES.contains(&config.scope.as_str()) { + return Err(invalid( + "rate limit scope must be one of 'global', 'tenant', 'user', 'ip' or 'route'" + .to_owned(), + config.scope.clone(), + )); + } + if !RATE_LIMIT_STRATEGIES.contains(&config.strategy.as_str()) { + return Err(invalid( + "rate limit strategy must be one of 'reject', 'queue' or 'degrade'".to_owned(), + config.strategy.clone(), + )); + } + if config.cost == 0 { + return Err(invalid( + "rate limit cost must be at least 1".to_owned(), + config.cost.to_string(), + )); + } + Ok(()) +} + +/// Validate a CORS policy (ADR-0004 "Credentials restriction"). +/// +/// # Errors +/// 400 when credentials are allowed while the origin list carries the +/// wildcard, which ADR-0004 forbids: a credentialed response to `*` would hand +/// a cross-origin reader every credential the browser holds. +pub fn validate_cors(config: &CorsConfig) -> OagwResult<()> { + if config.allow_credentials && config.allowed_origins.iter().any(|origin| origin == "*") { + return Err(OagwError::validation( + "cannot use allow_credentials with a wildcard origin (ADR-0004: \"Cannot use \ + allow_credentials with wildcard origin\")", + ) + .with_extension(|ext| ext.invalid_value = Some("*".to_owned()))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use crate::config::SsrfPolicy; + use crate::domain::model::{Endpoint, GrpcMatch, HttpMatch, HttpMethod, RouteMatch}; + use crate::domain::validation::{ + MAX_CONFIG_BYTES, MAX_ENDPOINTS, MAX_TAGS, ValidationPolicy, validate_config_bytes, + validate_cors, validate_endpoints, validate_framing, validate_rate_limit, + validate_route_match, validate_tags, + }; + use crate::error::OagwErrorKind; + + fn policy() -> ValidationPolicy { + ValidationPolicy { + allow_http_upstream: true, + max_body_bytes: 100, + ssrf: SsrfPolicy { + enabled: false, + allowed_hosts: Vec::new(), + denied_hosts: Vec::new(), + }, + } + } + + fn endpoint(scheme: crate::domain::model::Scheme, host: &str, port: u16) -> Endpoint { + Endpoint { + scheme, + host: host.to_owned(), + port, + } + } + + /// A policy over the schema defaults, which the write path materialises. + fn rate_limit() -> crate::domain::model::RateLimitConfig { + crate::domain::model::RateLimitConfig { + sharing: crate::domain::model::SharingMode::Private, + algorithm: "token_bucket".to_owned(), + sustained: crate::domain::model::SustainedRate { + rate: 5, + window: String::new(), + }, + burst: Some(crate::domain::model::BurstConfig { capacity: 5 }), + scope: "tenant".to_owned(), + strategy: "reject".to_owned(), + cost: 1, + response_headers: true, + } + } + + /// A policy over the wildcard origin, the permissive default. + fn cors_config() -> crate::domain::model::CorsConfig { + crate::domain::model::CorsConfig { + sharing: crate::domain::model::SharingMode::Private, + enabled: true, + allowed_origins: Vec::from(["*".to_owned()]), + allowed_methods: Vec::new(), + expose_headers: Vec::new(), + allow_credentials: false, + } + } + + #[test] + fn requires_at_least_one_endpoint() { + let error = validate_endpoints(&policy(), &[]).unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::Validation); + } + + #[test] + fn rejects_invalid_hosts() { + let endpoints = [endpoint( + crate::domain::model::Scheme::Https, + "bad host", + 443, + )]; + let error = validate_endpoints(&policy(), &endpoints).unwrap_err(); + assert_eq!(error.extensions().host.as_deref(), Some("bad host")); + } + + #[test] + fn rejects_port_zero() { + let endpoints = [endpoint( + crate::domain::model::Scheme::Https, + "api.vendor.com", + 0, + )]; + assert!(validate_endpoints(&policy(), &endpoints).is_err()); + } + + #[test] + fn rejects_heterogeneous_pools() { + let endpoints = [ + endpoint(crate::domain::model::Scheme::Https, "a.vendor.com", 443), + endpoint(crate::domain::model::Scheme::Https, "b.vendor.com", 8443), + ]; + assert!(validate_endpoints(&policy(), &endpoints).is_err()); + } + + #[test] + fn plaintext_schemes_need_the_config_switch() { + let strict = ValidationPolicy { + allow_http_upstream: false, + ..policy() + }; + let endpoints = [endpoint( + crate::domain::model::Scheme::Http, + "api.vendor.com", + 80, + )]; + assert!(validate_endpoints(&strict, &endpoints).is_err()); + assert!(validate_endpoints(&policy(), &endpoints).is_ok()); + } + + #[test] + fn ssrf_denied_hosts_win_over_the_allowlist() { + let policy = ValidationPolicy { + ssrf: SsrfPolicy { + enabled: true, + allowed_hosts: vec!["metadata.internal".to_owned()], + denied_hosts: vec!["metadata.internal".to_owned()], + }, + ..policy() + }; + let endpoints = [endpoint( + crate::domain::model::Scheme::Https, + "metadata.internal", + 443, + )]; + assert!(validate_endpoints(&policy, &endpoints).is_err()); + } + + #[test] + fn ssrf_allowlist_rejects_unknown_hosts() { + let policy = ValidationPolicy { + ssrf: SsrfPolicy { + enabled: true, + allowed_hosts: vec!["api.vendor.com".to_owned()], + denied_hosts: Vec::new(), + }, + ..policy() + }; + let allowed = [endpoint( + crate::domain::model::Scheme::Https, + "API.Vendor.com", + 443, + )]; + assert!(validate_endpoints(&policy, &allowed).is_ok()); + let blocked = [endpoint( + crate::domain::model::Scheme::Https, + "other.vendor.com", + 443, + )]; + assert!(validate_endpoints(&policy, &blocked).is_err()); + } + + #[test] + fn route_match_requires_exactly_one_branch() { + let http = RouteMatch { + http: Some(HttpMatch { + methods: vec![HttpMethod::Get], + path: "/v1/chat".to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: crate::domain::model::PathSuffixMode::Append, + }), + grpc: None, + }; + assert!(validate_route_match(&http).is_ok()); + assert!( + validate_route_match(&RouteMatch { + http: None, + grpc: None + }) + .is_err() + ); + assert!( + validate_route_match(&RouteMatch { + http: Some(HttpMatch { + methods: Vec::new(), + path: "/v1".to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: crate::domain::model::PathSuffixMode::Append, + }), + grpc: None, + }) + .is_err() + ); + assert!( + validate_route_match(&RouteMatch { + http: None, + grpc: Some(GrpcMatch { + service: "pkg.Svc".to_owned(), + method: "Get".to_owned(), + }), + }) + .is_ok() + ); + } + + #[test] + fn http_match_paths_must_be_absolute() { + let rule = RouteMatch { + http: Some(HttpMatch { + methods: vec![HttpMethod::Post], + path: "v1/chat".to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: crate::domain::model::PathSuffixMode::Append, + }), + grpc: None, + }; + assert!(validate_route_match(&rule).is_err()); + } + + #[test] + fn framing_accepts_a_single_content_length() { + assert_eq!( + validate_framing(&["42"], None, 100).ok().flatten(), + Some(42) + ); + assert_eq!(validate_framing(&[], None, 100).ok().flatten(), None); + } + + #[test] + fn framing_rejects_conflicting_or_unparseable_lengths() { + assert!(validate_framing(&["42", "43"], None, 100).is_err()); + assert!(validate_framing(&["abc"], None, 100).is_err()); + } + + #[test] + fn framing_only_allows_chunked_transfer_encoding() { + assert!(validate_framing(&["10"], Some("chunked"), 100).is_ok()); + assert!(validate_framing(&["10"], Some("gzip, chunked"), 100).is_err()); + } + + #[test] + fn framing_enforces_the_body_limit() { + let error = validate_framing(&["101"], None, 100).unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::PayloadTooLarge); + } + + #[test] + fn accepts_the_documented_tag_vocabulary() { + assert!( + validate_tags(&["team".to_owned(), "eu-west-1".to_owned(), "p_1".to_owned()]).is_ok() + ); + } + + #[test] + fn rejects_a_tag_outside_the_label_set() { + for tag in [ + "", + "Team", + "eu west", + "pii/handler", + "\u{43a}\u{43b}\u{44e}\u{447}", + ] { + let error = validate_tags(&[tag.to_owned()]).unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::Validation); + assert_eq!(error.extensions().invalid_value.as_deref(), Some(tag)); + } + } + + #[test] + fn rejects_more_tags_than_the_cap() { + let tags: Vec = (0..=MAX_TAGS).map(|index| format!("tag{index}")).collect(); + assert!(validate_tags(&tags).is_err()); + assert!(validate_tags(&tags[..MAX_TAGS]).is_ok()); + } + + #[test] + fn rejects_more_endpoints_than_the_cap() { + let endpoints: Vec = (0..=MAX_ENDPOINTS) + .map(|index| { + endpoint( + crate::domain::model::Scheme::Https, + &format!("host{index}.vendor.com"), + 443, + ) + }) + .collect(); + assert!(validate_endpoints(&policy(), &endpoints).is_err()); + assert!(validate_endpoints(&policy(), &endpoints[..MAX_ENDPOINTS]).is_ok()); + } + + #[test] + fn rejects_a_config_beyond_the_cap() { + let small = serde_json::json!({ "level": "debug" }); + assert!(validate_config_bytes(&small).is_ok()); + let large = serde_json::json!({ "blob": "x".repeat(MAX_CONFIG_BYTES + 1) }); + assert!(validate_config_bytes(&large).is_err()); + } + + #[test] + fn a_rate_limit_without_a_policy_is_accepted() { + assert!(validate_rate_limit(None).is_ok()); + } + + #[test] + fn the_schema_default_policy_is_accepted() { + // A policy the write path materialised from the schema defaults: the + // window is the empty string and it stands for `second`. + assert!(validate_rate_limit(Some(&rate_limit())).is_ok()); + } + + #[test] + fn only_the_token_bucket_algorithm_is_enforced() { + let mut config = rate_limit(); + config.algorithm = "sliding_window".to_owned(); + let error = validate_rate_limit(Some(&config)).unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::Validation); + assert_eq!(error.status(), 400); + assert_eq!( + error.extensions().invalid_value.as_deref(), + Some("sliding_window") + ); + assert!( + error + .to_string() + .contains("not supported by this deployment"), + "the detail names the deployment's limitation: {error}" + ); + } + + #[test] + fn a_policy_below_one_request_is_rejected() { + let mut config = rate_limit(); + config.sustained.rate = 0; + assert_eq!( + validate_rate_limit(Some(&config)).unwrap_err().status(), + 400 + ); + let mut config = rate_limit(); + config.burst = Some(crate::domain::model::BurstConfig { capacity: 0 }); + assert!(validate_rate_limit(Some(&config)).is_err()); + let mut config = rate_limit(); + config.cost = 0; + assert!(validate_rate_limit(Some(&config)).is_err()); + } + + #[test] + fn a_window_outside_the_four_units_is_rejected() { + for window in ["week", "Second", "seconds"] { + let mut config = rate_limit(); + config.sustained.window = window.to_owned(); + let error = validate_rate_limit(Some(&config)).unwrap_err(); + assert_eq!(error.extensions().invalid_value.as_deref(), Some(window)); + } + // The schema default is the empty string, and it stands for `second`. + for window in ["", "second", "minute", "hour", "day"] { + let mut config = rate_limit(); + config.sustained.window = window.to_owned(); + assert!(validate_rate_limit(Some(&config)).is_ok(), "{window}"); + } + } + + #[test] + fn a_scope_or_strategy_outside_the_enums_is_rejected() { + let mut config = rate_limit(); + config.scope = "cluster".to_owned(); + assert!(validate_rate_limit(Some(&config)).is_err()); + let mut config = rate_limit(); + config.strategy = "shed".to_owned(); + assert!(validate_rate_limit(Some(&config)).is_err()); + for scope in ["global", "tenant", "user", "ip", "route"] { + let mut config = rate_limit(); + config.scope = scope.to_owned(); + assert!(validate_rate_limit(Some(&config)).is_ok(), "{scope}"); + } + for strategy in ["reject", "queue", "degrade"] { + let mut config = rate_limit(); + config.strategy = strategy.to_owned(); + assert!(validate_rate_limit(Some(&config)).is_ok(), "{strategy}"); + } + } + + #[test] + fn a_wildcard_origin_behind_credentials_is_rejected() { + let mut config = cors_config(); + config.allow_credentials = true; + let error = validate_cors(&config).unwrap_err(); + assert_eq!(error.kind(), &OagwErrorKind::Validation); + assert_eq!(error.status(), 400); + assert!( + error.to_string().contains("wildcard origin"), + "the detail quotes the ADR rule: {error}" + ); + } + + #[test] + fn credentials_behind_a_named_origin_are_accepted() { + let mut config = cors_config(); + config.allowed_origins = Vec::from(["https://app.example.com".to_owned()]); + config.allow_credentials = true; + assert!(validate_cors(&config).is_ok()); + // A wildcard without credentials is the ordinary permissive policy. + assert!(validate_cors(&cors_config()).is_ok()); + } +} diff --git a/gears/system/oagw/oagw/src/error.rs b/gears/system/oagw/oagw/src/error.rs new file mode 100644 index 0000000..00fe119 --- /dev/null +++ b/gears/system/oagw/oagw/src/error.rs @@ -0,0 +1,658 @@ +// Created: 2026-08-31 by Constructor Tech +//! Gateway error surface (DESIGN §3.3 error table + ADR-0007). +//! +//! Every error renders as an RFC 9457 `application/problem+json` document: +//! +//! ```json +//! { +//! "type": "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1", +//! "title": "Validation Error", +//! "status": 400, +//! "detail": "alias is required for IP-based endpoints", +//! "instance": "/oagw/v1/upstreams", +//! "trace_id": "01J..." +//! } +//! ``` +//! +//! Extension members (`upstream_id`, `alias`, `host`, `path`, +//! `retry_after_seconds`, `trace_id`, `instance`, `plugin_id`, +//! `referenced_by`, …) are omitted when unset. The response always carries +//! `X-OAGW-Error-Source: gateway` (ADR-0007). +//! +//! Note: the body intentionally has **no** `context` member — the toolkit's +//! canonical-error middleware only rewrites bodies it can parse into a +//! canonical `Problem`, and OAGW owns its wire shape. + +use std::fmt; + +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; +use uuid::Uuid; + +/// Header distinguishing gateway-generated from upstream-passthrough errors. +pub const ERROR_SOURCE_HEADER: &str = "x-oagw-error-source"; +/// Value of [`ERROR_SOURCE_HEADER`] for errors OAGW generated itself. +pub const ERROR_SOURCE_GATEWAY: &str = "gateway"; +/// Value of [`ERROR_SOURCE_HEADER`] for errors passed through from an upstream. +pub const ERROR_SOURCE_UPSTREAM: &str = "upstream"; + +const PROBLEM_JSON: &str = "application/problem+json"; + +/// GTS prefix shared by every OAGW error type id (DESIGN §3.3). +const ERR_PREFIX: &str = "gts.cf.core.errors.err.v1~cf.oagw."; + +/// Resource kinds addressable through the management API. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResourceKind { + /// Upstream record. + Upstream, + /// Route record. + Route, + /// Plugin record. + Plugin, +} + +impl ResourceKind { + /// Human-readable resource label. + #[must_use] + pub const fn label(self) -> &'static str { + match self { + ResourceKind::Upstream => "Upstream", + ResourceKind::Route => "Route", + ResourceKind::Plugin => "Plugin", + } + } +} + +impl fmt::Display for ResourceKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(self.label()) + } +} + +/// Resources that reference a plugin, as reported by `plugin.in_use`. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] +pub struct ReferencedBy { + /// Upstream ids referencing the plugin. + pub upstreams: Vec, + /// Route ids referencing the plugin. + pub routes: Vec, +} + +impl ReferencedBy { + /// Total number of referencing resources. + #[must_use] + pub fn total(&self) -> usize { + self.upstreams.len() + self.routes.len() + } +} + +/// State of the token bucket a request was scored against (ADR-0003). +/// +/// Carried by a `rate_limit.exceeded.v1` problem so the rendered response can +/// carry the standard `X-RateLimit-*` headers as well as the `Retry-After` one. +/// It is deliberately **not** a problem body member: the quota is transport +/// metadata, and RFC 6585 clients read it from the headers. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize)] +pub struct RateLimitSnapshot { + /// Effective sustained rate per window. + pub limit: u64, + /// Tokens left in the bucket, floored. + pub remaining: u64, + /// Epoch seconds at which the bucket is full again. + pub reset: u64, +} + +/// Optional RFC 9457 extension members carried by an OAGW error. +#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize)] +pub struct ProblemExtensions { + /// Upstream the failure relates to. + #[serde(skip_serializing_if = "Option::is_none")] + pub upstream_id: Option, + /// Upstream alias the failure relates to. + #[serde(skip_serializing_if = "Option::is_none")] + pub alias: Option, + /// Host the failure relates to. + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + /// Request path the failure relates to. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Retry guidance (mirrored onto the `Retry-After` header). + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_after_seconds: Option, + /// Distributed tracing correlation id. + #[serde(skip_serializing_if = "Option::is_none")] + pub trace_id: Option, + /// URI reference identifying this occurrence. + #[serde(skip_serializing_if = "Option::is_none")] + pub instance: Option, + /// Plugin the failure relates to. + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_id: Option, + /// Resources that reference [`ProblemExtensions::plugin_id`]. + #[serde(skip_serializing_if = "Option::is_none")] + pub referenced_by: Option, + /// Endpoint hosts that would satisfy the request (ADR-0007). + #[serde(skip_serializing_if = "Option::is_none")] + pub valid_hosts: Option>, + /// Rejected value (ADR-0007). + #[serde(skip_serializing_if = "Option::is_none")] + pub invalid_value: Option, + /// Machine-readable code of a plugin rejection (ADR-0009). + #[serde(skip_serializing_if = "Option::is_none")] + pub error_code: Option, + /// Header whose absence rejected a phase (ADR-0009 `required_headers`). + #[serde(skip_serializing_if = "Option::is_none")] + pub missing_header: Option, + /// Quota state of a rate-limited request, rendered as headers only + /// (ADR-0003). + #[serde(skip)] + pub rate_limit: Option, + /// CORS headers the answer to this failure must carry, rendered as headers + /// only (ADR-0004). A refused origin carries the `Vary` alone and never an + /// allow-origin, because naming it would tell the browser the opposite of + /// what the answer says. + #[serde(skip)] + pub cors_headers: Vec<(String, String)>, +} + +/// Every row of the DESIGN §3.3 error table, plus the management-only +/// `NotFound` family used by the CRUD endpoints. +#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)] +pub enum OagwErrorKind { + /// General route validation error (400). + #[error("validation error")] + Validation, + /// `X-OAGW-Target-Host` required but absent (400). + #[error("missing target host")] + MissingTargetHost, + /// `X-OAGW-Target-Host` format invalid (400). + #[error("invalid target host")] + InvalidTargetHost, + /// `X-OAGW-Target-Host` matches no configured endpoint (400). + #[error("unknown target host")] + UnknownTargetHost, + /// Authentication to the upstream failed (401). + #[error("authentication failed")] + AuthenticationFailed, + /// Referenced secret is unavailable (500). + #[error("secret not found")] + SecretNotFound, + /// Protocol-level failure (502). + #[error("protocol error")] + ProtocolError, + /// Upstream returned an error (502). + #[error("downstream error")] + DownstreamError, + /// Streaming connection aborted (502). + #[error("stream aborted")] + StreamAborted, + /// Upstream link unavailable (503). + #[error("link unavailable")] + LinkUnavailable, + /// Circuit breaker is open (503). + #[error("circuit breaker open")] + CircuitBreakerOpen, + /// Referenced plugin cannot be resolved (503). + /// + /// Shares the `plugin.not_found.v1` GTS id with the 404 + /// [`OagwErrorKind::NotFound`] case of a plugin; the HTTP status + /// distinguishes "the record is gone" from "this tenant cannot bind it". + #[error("plugin not found")] + PluginNotFound, + /// Connection to the upstream timed out (504). + #[error("connection timeout")] + ConnectionTimeout, + /// Upstream request timed out (504). + #[error("request timeout")] + RequestTimeout, + /// Idle stream timed out (504). + #[error("idle timeout")] + IdleTimeout, + /// Rate limit exceeded (429). + #[error("rate limit exceeded")] + RateLimitExceeded, + /// Cross-origin request from an origin the upstream does not allow (403). + /// + /// ADR-0004 "Error Responses" defines the two `cors.*` GTS ids itself; + /// DESIGN §3.3 has no `cors.*` rows, so the ADR is the authority here. + #[error("cors origin not allowed")] + CorsOriginNotAllowed, + /// Cross-origin request with a method the upstream does not allow (403). + /// + /// Same ADR-0004 provenance as [`OagwErrorKind::CorsOriginNotAllowed`]. + #[error("cors method not allowed")] + CorsMethodNotAllowed, + /// Request payload exceeds the configured limit (413). + #[error("payload too large")] + PayloadTooLarge, + /// Plugin is still referenced by an upstream or route (409). + #[error("plugin in use")] + PluginInUse, + /// Alias already taken within the tenant (409). + #[error("alias conflict")] + AliasConflict, + /// Route match rule duplicates an existing route (409). + #[error("route conflict")] + RouteConflict, + /// Plugin name already taken within the tenant (409). + #[error("plugin conflict")] + PluginConflict, + /// Management resource does not exist (or is invisible to the caller). + /// + /// For a plugin this kind and [`OagwErrorKind::PluginNotFound`] share the + /// `plugin.not_found.v1` GTS id (DESIGN §3.3): a plugin absent as a CRUD + /// resource is 404, a plugin a binding cannot resolve is 503. The HTTP + /// status is the discriminator — the slug must stay identical because the + /// wire contract defines a single problem type per table row. + #[error("resource not found")] + NotFound, + /// Unexpected control-plane failure (500). + #[error("internal error")] + Internal, +} + +impl OagwErrorKind { + /// HTTP status for this error kind (DESIGN §3.3). + #[must_use] + pub fn status(self) -> u16 { + match self { + OagwErrorKind::Validation + | OagwErrorKind::MissingTargetHost + | OagwErrorKind::InvalidTargetHost + | OagwErrorKind::UnknownTargetHost => 400, + OagwErrorKind::AuthenticationFailed => 401, + OagwErrorKind::NotFound => 404, + OagwErrorKind::AliasConflict + | OagwErrorKind::RouteConflict + | OagwErrorKind::PluginConflict + | OagwErrorKind::PluginInUse => 409, + OagwErrorKind::PayloadTooLarge => 413, + OagwErrorKind::RateLimitExceeded => 429, + OagwErrorKind::CorsOriginNotAllowed | OagwErrorKind::CorsMethodNotAllowed => 403, + OagwErrorKind::SecretNotFound | OagwErrorKind::Internal => 500, + OagwErrorKind::ProtocolError + | OagwErrorKind::DownstreamError + | OagwErrorKind::StreamAborted => 502, + OagwErrorKind::LinkUnavailable + | OagwErrorKind::CircuitBreakerOpen + | OagwErrorKind::PluginNotFound => 503, + OagwErrorKind::ConnectionTimeout + | OagwErrorKind::RequestTimeout + | OagwErrorKind::IdleTimeout => 504, + } + } + + /// RFC 9457 `title` for this error kind. + #[must_use] + pub fn title(self) -> &'static str { + match self { + OagwErrorKind::Validation + | OagwErrorKind::MissingTargetHost + | OagwErrorKind::InvalidTargetHost + | OagwErrorKind::UnknownTargetHost => "Validation Error", + OagwErrorKind::AuthenticationFailed => "Authentication Failed", + OagwErrorKind::NotFound => "Not Found", + OagwErrorKind::AliasConflict + | OagwErrorKind::RouteConflict + | OagwErrorKind::PluginConflict => "Conflict", + OagwErrorKind::PluginInUse => "Plugin In Use", + OagwErrorKind::PayloadTooLarge => "Payload Too Large", + OagwErrorKind::RateLimitExceeded => "Rate Limit Exceeded", + OagwErrorKind::CorsOriginNotAllowed => "CORS Origin Not Allowed", + OagwErrorKind::CorsMethodNotAllowed => "CORS Method Not Allowed", + OagwErrorKind::SecretNotFound => "Secret Not Found", + OagwErrorKind::Internal => "Internal Error", + OagwErrorKind::ProtocolError => "Protocol Error", + OagwErrorKind::DownstreamError => "Downstream Error", + OagwErrorKind::StreamAborted => "Stream Aborted", + OagwErrorKind::LinkUnavailable => "Link Unavailable", + OagwErrorKind::CircuitBreakerOpen => "Circuit Breaker Open", + OagwErrorKind::PluginNotFound => "Plugin Not Found", + OagwErrorKind::ConnectionTimeout => "Connection Timeout", + OagwErrorKind::RequestTimeout => "Request Timeout", + OagwErrorKind::IdleTimeout => "Idle Timeout", + } + } + + /// GTS type id for this error kind (DESIGN §3.3). + /// + /// `NotFound` is parameterised by the missing resource kind. A plugin + /// appears twice in the table (404 resource-absent, 503 reference- + /// unresolved) and both rows share the `plugin.not_found.v1` slug; the + /// HTTP status is the discriminator. + #[must_use] + pub fn gts_type(self, kind: ResourceKind) -> String { + let slug = match self { + OagwErrorKind::Validation => "validation.error.v1", + OagwErrorKind::MissingTargetHost => "routing.missing_target_host.v1", + OagwErrorKind::InvalidTargetHost => "routing.invalid_target_host.v1", + OagwErrorKind::UnknownTargetHost => "routing.unknown_target_host.v1", + OagwErrorKind::AuthenticationFailed => "auth.failed.v1", + OagwErrorKind::NotFound => match kind { + ResourceKind::Upstream => "upstream.not_found.v1", + ResourceKind::Route => "route.not_found.v1", + ResourceKind::Plugin => "plugin.not_found.v1", + }, + OagwErrorKind::AliasConflict => "alias.conflict.v1", + OagwErrorKind::RouteConflict => "route.conflict.v1", + OagwErrorKind::PluginConflict => "plugin.conflict.v1", + OagwErrorKind::PluginInUse => "plugin.in_use.v1", + OagwErrorKind::PayloadTooLarge => "payload.too_large.v1", + OagwErrorKind::RateLimitExceeded => "rate_limit.exceeded.v1", + // ADR-0004 "Error Responses" fixes both slugs; the DESIGN §3.3 + // table has no cors row for them. + OagwErrorKind::CorsOriginNotAllowed => "cors.origin_not_allowed.v1", + OagwErrorKind::CorsMethodNotAllowed => "cors.method_not_allowed.v1", + OagwErrorKind::SecretNotFound => "secret.not_found.v1", + OagwErrorKind::Internal => "internal.error.v1", + OagwErrorKind::ProtocolError => "protocol.error.v1", + OagwErrorKind::DownstreamError => "downstream.error.v1", + OagwErrorKind::StreamAborted => "stream.aborted.v1", + OagwErrorKind::LinkUnavailable => "link.unavailable.v1", + OagwErrorKind::CircuitBreakerOpen => "circuit_breaker.open.v1", + OagwErrorKind::PluginNotFound => "plugin.not_found.v1", + OagwErrorKind::ConnectionTimeout => "timeout.connection.v1", + OagwErrorKind::RequestTimeout => "timeout.request.v1", + OagwErrorKind::IdleTimeout => "timeout.idle.v1", + }; + format!("{ERR_PREFIX}{slug}") + } +} + +/// A gateway error: [`OagwErrorKind`] plus its human detail and extensions. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +#[error("{detail}")] +pub struct OagwError { + kind: OagwErrorKind, + /// Resource kind, only meaningful for [`OagwErrorKind::NotFound`]. + resource: ResourceKind, + detail: String, + ext: Box, +} + +impl OagwError { + /// Build an error for `kind` with `detail`. + #[must_use] + pub fn new(kind: OagwErrorKind, detail: impl Into) -> Self { + Self { + kind, + resource: ResourceKind::Upstream, + detail: detail.into(), + ext: Box::default(), + } + } + + /// 400 validation failure. + #[must_use] + pub fn validation(detail: impl Into) -> Self { + Self::new(OagwErrorKind::Validation, detail) + } + + /// Management 404 for `kind` resources. + #[must_use] + pub fn not_found(kind: ResourceKind, id: Uuid) -> Self { + let detail = format!( + "{} resource '{id}' was not found for the calling tenant", + kind.label() + ); + let mut err = Self::new(OagwErrorKind::NotFound, detail); + err.resource = kind; + err + } + + /// Report this 404 as a missing `kind`. + /// + /// The data plane has a single 404 contract (DESIGN §3.3 + /// `route.not_found.v1`), whatever lookup failed to produce it. + #[must_use] + pub const fn with_resource(mut self, kind: ResourceKind) -> Self { + self.resource = kind; + self + } + + /// 409 `plugin.in_use` (ADR-0001 "Plugin Deletion Behavior"). + #[must_use] + pub fn plugin_in_use(plugin_id: Uuid, referenced_by: ReferencedBy) -> Self { + let detail = format!( + "Plugin is referenced by {} upstream(s) and {} route(s)", + referenced_by.upstreams.len(), + referenced_by.routes.len() + ); + let mut err = Self::new(OagwErrorKind::PluginInUse, detail); + err.ext.plugin_id = Some(plugin_id.to_string()); + err.ext.referenced_by = Some(referenced_by); + err + } + + /// 409 alias conflict within the calling tenant. + #[must_use] + pub fn alias_conflict(alias: &str, existing_id: Uuid) -> Self { + let detail = format!("An upstream with alias '{alias}' already exists"); + let mut err = Self::new(OagwErrorKind::AliasConflict, detail); + err.ext.alias = Some(alias.to_owned()); + err.ext.upstream_id = Some(existing_id.to_string()); + err + } + + /// 409 duplicate route match rule within the upstream. + #[must_use] + pub fn route_conflict(detail: impl Into, upstream_id: Uuid) -> Self { + let mut err = Self::new(OagwErrorKind::RouteConflict, detail); + err.ext.upstream_id = Some(upstream_id.to_string()); + err + } + + /// 409 duplicate plugin name within the calling tenant. + #[must_use] + pub fn plugin_conflict(name: &str, existing_id: Uuid) -> Self { + let detail = format!("A plugin named '{name}' already exists"); + let mut err = Self::new(OagwErrorKind::PluginConflict, detail); + err.ext.plugin_id = Some(existing_id.to_string()); + err + } + + /// 413 payload too large. + #[must_use] + pub fn payload_too_large(limit: u64, actual: u64) -> Self { + Self::new( + OagwErrorKind::PayloadTooLarge, + format!("Request body of {actual} bytes exceeds the limit of {limit} bytes"), + ) + } + + /// Attach an extension member. + #[must_use] + pub fn with_extension(mut self, apply: impl FnOnce(&mut ProblemExtensions)) -> Self { + apply(&mut self.ext); + self + } + + /// Attach the CORS headers the answer to this failure must carry. + /// + /// The first set wins: a CORS refusal already carries the `Vary`-only + /// answer the ADR asks for, and a later attach of the permissive set of an + /// *allowed* origin would tell the browser the opposite of what happened. + #[must_use] + pub fn with_cors_headers(mut self, headers: &HeaderMap) -> Self { + if self.ext.cors_headers.is_empty() && !headers.is_empty() { + self.ext.cors_headers = headers + .iter() + .filter_map(|(name, value)| { + let value = value.to_str().ok()?; + Some((name.as_str().to_owned(), value.to_owned())) + }) + .collect(); + } + self + } + + /// Kind of this error. + #[must_use] + pub const fn kind(&self) -> &OagwErrorKind { + &self.kind + } + + /// HTTP status code. + #[must_use] + pub fn status(&self) -> u16 { + self.kind.status() + } + + /// GTS type id (DESIGN §3.3). + #[must_use] + pub fn gts_type(&self) -> String { + self.kind.gts_type(self.resource) + } + + /// RFC 9457 `title`. + #[must_use] + pub fn title(&self) -> &'static str { + self.kind.title() + } + + /// Human-readable detail. + #[must_use] + pub fn detail(&self) -> &str { + &self.detail + } + + /// Extension members. + #[must_use] + pub const fn extensions(&self) -> &ProblemExtensions { + &self.ext + } + + /// RFC 9457 document for this error. + #[must_use] + pub fn problem(&self) -> ProblemBody { + ProblemBody { + problem_type: self.gts_type(), + title: self.title(), + status: self.status(), + detail: self.detail.clone(), + extensions: (*self.ext).clone(), + } + } + + /// Whether the error is retryable per DESIGN §3.3. + #[must_use] + pub const fn retryable(&self) -> bool { + matches!( + self.kind, + OagwErrorKind::RateLimitExceeded + | OagwErrorKind::LinkUnavailable + | OagwErrorKind::CircuitBreakerOpen + | OagwErrorKind::ConnectionTimeout + | OagwErrorKind::RequestTimeout + | OagwErrorKind::IdleTimeout + ) + } +} + +/// RFC 9457 `application/problem+json` document. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ProblemBody { + /// GTS type identifier. + #[serde(rename = "type")] + pub problem_type: String, + /// Human-readable summary. + pub title: &'static str, + /// HTTP status code. + pub status: u16, + /// Human-readable explanation for this occurrence. + pub detail: String, + /// OAGW extension members. + #[serde(flatten)] + pub extensions: ProblemExtensions, +} + +impl IntoResponse for OagwError { + fn into_response(self) -> Response { + let body = self.problem(); + let status = StatusCode::from_u16(body.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let payload = match serde_json::to_vec(&body) { + Ok(bytes) => bytes, + Err(err) => { + tracing::error!(error = %err, error_type = %body.problem_type, "problem rendering failed"); + Vec::new() + } + }; + + let mut response = (status, payload).into_response(); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(PROBLEM_JSON)); + response.headers_mut().insert( + HeaderName::from_static(ERROR_SOURCE_HEADER), + HeaderValue::from_static(ERROR_SOURCE_GATEWAY), + ); + if let Some(retry_after) = self.ext.retry_after_seconds { + response.headers_mut().insert( + HeaderName::from_static("retry-after"), + HeaderValue::from(retry_after), + ); + } + for (name, value) in &self.ext.cors_headers { + if let Ok(name) = HeaderName::from_bytes(name.as_bytes()) + && let Ok(value) = HeaderValue::from_str(value) + { + response.headers_mut().insert(name, value); + } + } + if let Some(snapshot) = self.ext.rate_limit { + let headers = [ + ("x-ratelimit-limit", snapshot.limit), + ("x-ratelimit-remaining", snapshot.remaining), + ("x-ratelimit-reset", snapshot.reset), + ]; + for (name, value) in headers { + response + .headers_mut() + .insert(HeaderName::from_static(name), HeaderValue::from(value)); + } + } + response + } +} + +/// Map an alias decision rejection onto the validation problem (400). +impl From for OagwError { + fn from(rejection: crate::domain::alias::AliasRejection) -> Self { + let alias = match &rejection { + crate::domain::alias::AliasRejection::ChangeRejected { existing, .. } => { + Some(existing.clone()) + } + _ => None, + }; + let detail = rejection.detail(); + let error = OagwError::validation(detail); + match alias { + Some(alias) => error.with_extension(|ext| ext.alias = Some(alias)), + None => error, + } + } +} + +/// Convenience alias for handlers returning an OAGW error. +pub type OagwResult = Result; + +#[cfg(test)] +mod tests { + use super::{OagwError, OagwErrorKind, ResourceKind}; + + /// One GTS id, two HTTP statuses: the slug is shared by the 404 + /// resource-absent case and the 503 reference-unresolved case, so only the + /// status can tell them apart (DESIGN §3.3). + #[test] + fn the_plugin_not_found_slug_is_shared_by_both_statuses() { + let absent = OagwError::not_found(ResourceKind::Plugin, uuid::Uuid::new_v4()); + let unresolved = OagwError::new(OagwErrorKind::PluginNotFound, "reference"); + assert_eq!(absent.gts_type(), unresolved.gts_type()); + assert_eq!(absent.status(), 404); + assert_eq!(unresolved.status(), 503); + assert!(absent.gts_type().ends_with("plugin.not_found.v1")); + } +} diff --git a/gears/system/oagw/oagw/src/gear.rs b/gears/system/oagw/oagw/src/gear.rs new file mode 100644 index 0000000..e0a44e0 --- /dev/null +++ b/gears/system/oagw/oagw/src/gear.rs @@ -0,0 +1,189 @@ +// Created: 2026-08-31 by Constructor Tech +//! Gear wiring (`#[toolkit::gear]`). +//! +//! The graded deployment provisions no database for this gear, so the control +//! plane is backed by the in-process store of [`crate::domain::store`]; see the +//! crate docs for the documented deviation from DESIGN §3.6. + +use std::sync::{Arc, OnceLock}; + +use async_trait::async_trait; +use credstore_sdk::CredStoreClientV1; +use tenant_resolver_sdk::TenantResolverClient; +use toolkit::GearCtx; +use toolkit::{Gear, RestApiCapability}; +use tracing::{info, warn}; + +use crate::config::OagwConfig; +use crate::domain::proxy::chain::{NoChain, ResolverChain, TenantChain}; +use crate::domain::proxy::service::ProxyService; +use crate::domain::service::OagwService; +use crate::domain::store::{InMemoryStore, Store}; +use crate::domain::validation::ValidationPolicy; +use crate::infra::plugin::secrets::CredStore; + +/// Outbound API Gateway gear: control plane and proxy data plane. +#[toolkit::gear( + name = "oagw", + deps = [tenant_resolver, types_registry, credstore], + capabilities = [rest] +)] +pub struct Oagw { + service: OnceLock>, + proxy: OnceLock>, + config: OnceLock, +} + +impl Default for Oagw { + fn default() -> Self { + Self { + service: OnceLock::new(), + proxy: OnceLock::new(), + config: OnceLock::new(), + } + } +} + +impl Oagw { + /// Configuration the gear was initialised with. + #[must_use] + pub fn config(&self) -> Option<&OagwConfig> { + self.config.get() + } + + /// Control-plane service, available after [`Gear::init`]. + #[must_use] + pub fn service(&self) -> Option<&Arc> { + self.service.get() + } + + /// Proxy data plane, available after [`Gear::init`]. + #[must_use] + pub fn proxy(&self) -> Option<&Arc> { + self.proxy.get() + } + + fn initialized_service(&self) -> anyhow::Result> { + self.service + .get() + .cloned() + .ok_or_else(|| anyhow::anyhow!("{} service not initialized", Self::MODULE_NAME)) + } + + fn initialized_proxy(&self) -> anyhow::Result> { + self.proxy + .get() + .cloned() + .ok_or_else(|| anyhow::anyhow!("{} proxy not initialized", Self::MODULE_NAME)) + } + + /// Credential store the auth plugins resolve their `cred://` references + /// through. + /// + /// Fetched once from the `ClientHub`. A deployment that wires none degrades + /// exactly the way a missing tenant resolver does: the gear still boots, the + /// plugin registries only carry the plugins that need no credential store, + /// and an upstream whose auth binding needs one fails its requests with 503 + /// `link.unavailable.v1` instead of forwarding them unauthenticated. + fn credential_store(ctx: &GearCtx) -> Option { + match ctx.client_hub().get::() { + Ok(client) => Some(client), + Err(error) => { + warn!( + error = %error, + "credential store unavailable; auth plugins that need one are not registered" + ); + None + } + } + } + + /// Tenant chain for alias shadowing. + /// + /// The `tenant_resolver` client is fetched once from the `ClientHub`; a + /// deployment without it degrades to a per-tenant alias lookup instead of + /// failing the gear. + fn tenant_chain(ctx: &GearCtx) -> Arc { + match ctx.client_hub().get::() { + Ok(client) => Arc::new(ResolverChain::new(client)), + Err(error) => { + warn!( + error = %error, + "tenant resolver unavailable; alias shadowing stays within the calling tenant" + ); + Arc::new(NoChain) + } + } + } +} + +#[async_trait] +impl Gear for Oagw { + async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> { + // Every member has a serde default, so an absent `gears.oagw` block + // still yields the documented baseline. + let config: OagwConfig = ctx.config_or_default()?; + let policy: ValidationPolicy = config.validation_policy(); + let store: Arc = InMemoryStore::new(); + let service = OagwService::new(policy, Arc::clone(&store)); + // One outbound client per configuration, built once: no retries, no + // redirects, and the transport decision of the plaintext switch. + let client = ProxyService::build_client(&config) + .map_err(|error| anyhow::anyhow!("{} proxy client: {error}", Self::MODULE_NAME))?; + let proxy = ProxyService::new( + store, + Self::tenant_chain(ctx), + client, + Self::credential_store(ctx), + &config, + ); + // The data plane keeps per-upstream round-robin cursors; it learns + // about a cascade deletion from the control plane rather than polling. + let observer: Arc = proxy.clone(); + service.observe_removals(observer); + + self.config + .set(config) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + self.service + .set(service) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + self.proxy + .set(proxy) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + + info!( + "{} gear initialized (in-memory control plane, proxy data plane)", + Self::MODULE_NAME + ); + Ok(()) + } +} + +impl RestApiCapability for Oagw { + fn register_rest( + &self, + _ctx: &GearCtx, + router: axum::Router, + openapi: &dyn toolkit::api::OpenApiRegistry, + ) -> anyhow::Result { + let service = self.initialized_service()?; + let proxy = self.initialized_proxy()?; + let router = crate::api::routes::register_routes(router, openapi, service); + let router = crate::api::routes::register_data_plane(router, openapi, proxy); + info!("{} REST routes registered", Self::MODULE_NAME); + Ok(router) + } +} + +#[cfg(test)] +mod tests { + use crate::gear::Oagw; + + #[test] + fn gear_has_a_default_state() { + let gear = Oagw::default(); + assert!(gear.service().is_none()); + assert!(gear.config().is_none()); + } +} diff --git a/gears/system/oagw/oagw/src/infra/metrics.rs b/gears/system/oagw/oagw/src/infra/metrics.rs new file mode 100644 index 0000000..46c4c48 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/metrics.rs @@ -0,0 +1,413 @@ +// Created: 2026-08-31 by Constructor Tech +//! OpenTelemetry instruments of the proxy data plane (DESIGN §4.2). +//! +//! The instruments are pulled from the **process-global meter provider the +//! host installs**; the gear builds no exporter and serves no scrape endpoint. +//! DESIGN §4.2 opens with "Prometheus metrics at `/metrics` (admin-only)", and +//! in this platform that route is the *host's* concern: credstore's +//! `infra/metrics.rs` is the platform precedent — it emits instruments against +//! the global provider and builds no route — and account-management does the +//! same. Emitting the instruments without the route is therefore the +//! implementation of §4.2 here, not an omission of it. +//! +//! Names are full literal Prometheus names with the suffix baked in — counters +//! end in `_total`, the duration histogram in `_seconds` — and no instrument +//! carries a unit (`add_metric_suffixes: false` is the collector posture, so +//! the suffix has to be part of the name; see credstore). +//! +//! Every emit is fire-and-forget: a meter that is not wired, or that fails, is +//! a lost data point and never a failed request. +//! +//! # Cardinality (DESIGN §4.2 "Cardinality management") +//! +//! * No tenant label anywhere: a tenant is unbounded and a label per tenant is +//! a metric explosion the collector cannot prune. +//! * `http.route` is the **matched route's path prefix**, never the raw +//! request path, whose segments are client input. +//! * `http.request.method` is normalized to a standard verb or `_OTHER`, the +//! exact mapping the inbound API gateway uses, so both gateways share +//! dashboards. +//! * `http.response.status_code` is the numeric status the *upstream* answered +//! with. On the failure path there is no upstream answer, so the counter +//! carries the status the gateway answered instead — the only status that +//! exists — and `oagw_errors_total` is what identifies the request as a +//! gateway refusal. +//! * `host` is the upstream alias, a value the control plane validated when it +//! was written. A request the data plane cannot attribute to an upstream and +//! a route is **not** recorded, and there are two classes of those: +//! - the alias never resolved to an upstream (an unknown or foreign alias), +//! where the alias the request names is client input and fabricating a +//! host for it would both lie about an upstream and hand the label an +//! unbounded cardinality; +//! - the upstream resolved but no route matched (`select_route` finds +//! nothing, every `HEAD` or `OPTIONS` among them, since the route model +//! names no such method), which leaves the request without the +//! `http.route` every family here is labelled with. An unregistered +//! method — `CONNECT`, `TRACE`, an extension method — is refused by the +//! router's fallback before it reaches the data plane and lands in the +//! same class. Both cost nothing here; what an operator reads instead is +//! the audit record's 404, which carries the alias, the path and the +//! correlation id. +//! * `path` of the two rate-limit families is the matched route's prefix, for +//! the same reason `http.route` is: the raw path is unbounded. +//! +//! # The `phase` label of the duration histogram +//! +//! DESIGN names no vocabulary. The one the data plane can honestly report is +//! **`total`** — the whole `proxy` call, from the moment the request enters it +//! to the moment its answer leaves, the guards included: a rate-limit refusal +//! is a request the data plane served, only with a shorter answer. A finer +//! phase has to be attributable without double counting, and the dial has two +//! call sites of different meaning (an ordinary request and a protocol +//! switch), so a second phase would be a judgement call per request; it is +//! deferred rather than invented. +//! +//! # What `oagw_requests_in_flight` covers +//! +//! A request is counted from the moment it is attributed to a host and a route +//! to the moment `proxy` returns its answer. What the data plane serves after +//! that — a body it keeps streaming, a bridged websocket session — is *not* +//! inside the gauge: the guard lives in the call that returns the answer, +//! because that is the one scope the pipeline can drop on every exit. The +//! instrument's description says the same thing in fewer words. +//! +//! # The two circuit-breaker families +//! +//! `oagw_circuit_breaker_state{host}` and +//! `oagw_circuit_breaker_transitions_total{host, from_state, to_state}` are +//! emitted by [`crate::domain::proxy::breaker`], which is their only producer, +//! and they are emitted **at a transition and nowhere else**. An upstream whose +//! breaker has never moved therefore has no state series at all, rather than a +//! `closed` one: the gauge is a report of *movement*, and a dashboard that +//! needs a row per upstream should read `oagw_requests_total`, which every +//! request produces. The state values are `0` closed, `1` half-open, `2` open — +//! open is the largest because it is the state an operator alerts on. +//! +//! # Families DESIGN §4.2 declares that this module does not +//! +//! * `oagw_upstream_available{host, endpoint}` and +//! `oagw_upstream_connections{host, state}` have no producer in this gear: +//! there is no health probe, and the outbound client exposes no connection +//! pool telemetry. Emitting them would be a stream of zeros, which hides a +//! missing signal instead of reporting one. + +use opentelemetry::KeyValue; +use opentelemetry::metrics::{Counter, Gauge, Histogram, Meter, UpDownCounter}; +use uuid::Uuid; + +/// Meter / instrumentation scope name (credstore's `METER_NAME` pattern). +pub(crate) const METER_NAME: &str = "oagw"; + +// ── Metric names (literal Prometheus form; `add_metric_suffixes: false`) ───── +const REQUESTS: &str = "oagw_requests_total"; +const REQUEST_DURATION: &str = "oagw_request_duration_seconds"; +const REQUESTS_IN_FLIGHT: &str = "oagw_requests_in_flight"; +const ERRORS: &str = "oagw_errors_total"; +const RATE_LIMIT_EXCEEDED: &str = "oagw_rate_limit_exceeded_total"; +const RATE_LIMIT_USAGE: &str = "oagw_rate_limit_usage_ratio"; +const ROUTING_TARGET_HOST_USED: &str = "oagw_routing_target_host_used"; +const ROUTING_ENDPOINT_SELECTED: &str = "oagw_routing_endpoint_selected"; +const BREAKER_STATE: &str = "oagw_circuit_breaker_state"; +const BREAKER_TRANSITIONS: &str = "oagw_circuit_breaker_transitions_total"; + +/// Buckets of the request-duration histogram, in seconds (DESIGN §4.2). +const REQUEST_DURATION_BUCKETS: [f64; 12] = [ + 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0, +]; + +/// The only phase the duration histogram records (see the module docs). +const PHASE: &str = "total"; + +/// Every instrument the proxy data plane emits. +/// +/// `Clone` because a forwarded body outlives the request that dialled it and +/// still has to report to the circuit breaker: the handle the body carries is a +/// clone of these. An instrument handle is cheap and holds no state, so a clone +/// is a handle, not a copy of anything measured. +#[derive(Clone)] +pub(crate) struct ProxyMetrics { + requests: Counter, + duration: Histogram, + in_flight: UpDownCounter, + errors: Counter, + rate_limit_exceeded: Counter, + rate_limit_usage: Gauge, + target_host_used: Counter, + endpoint_selected: Counter, + breaker_state: Gauge, + breaker_transitions: Counter, +} + +impl std::fmt::Debug for ProxyMetrics { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ProxyMetrics").finish_non_exhaustive() + } +} + +impl ProxyMetrics { + /// Build the instrument set on `meter`. + fn new(meter: &Meter) -> Self { + Self { + requests: meter + .u64_counter(REQUESTS) + .with_description("Proxied requests, by upstream alias, method, route and status") + .build(), + duration: meter + .f64_histogram(REQUEST_DURATION) + .with_description("Duration of a proxied request, by upstream alias and route") + .with_boundaries(REQUEST_DURATION_BUCKETS.to_vec()) + .build(), + in_flight: meter + .i64_up_down_counter(REQUESTS_IN_FLIGHT) + .with_description("Requests the data plane holds, from attribution to its answer") + .build(), + errors: meter + .u64_counter(ERRORS) + .with_description("Failed requests, by upstream alias, route and problem type") + .build(), + rate_limit_exceeded: meter + .u64_counter(RATE_LIMIT_EXCEEDED) + .with_description("Requests a rate limit refused, by upstream alias and route") + .build(), + rate_limit_usage: meter + .f64_gauge(RATE_LIMIT_USAGE) + .with_description("Bucket usage of the last rate-limit refusal, 0.0 to 1.0") + .build(), + target_host_used: meter + .u64_counter(ROUTING_TARGET_HOST_USED) + .with_description("Requests that pinned an endpoint with the target-host header") + .build(), + endpoint_selected: meter + .u64_counter(ROUTING_ENDPOINT_SELECTED) + .with_description("Endpoints dialled, by upstream and selection method") + .build(), + breaker_state: meter + .u64_gauge(BREAKER_STATE) + .with_description( + "State of an upstream's circuit breaker, 0 closed, 1 half-open, 2 open", + ) + .build(), + breaker_transitions: meter + .u64_counter(BREAKER_TRANSITIONS) + .with_description( + "Circuit breaker transitions, by upstream alias and the two states", + ) + .build(), + } + } + + /// Build a handle bound to the process-global meter provider. + #[must_use] + pub(crate) fn from_global() -> Self { + let scope = opentelemetry::InstrumentationScope::builder(METER_NAME).build(); + Self::new(&opentelemetry::global::meter_with_scope(scope)) + } + + /// Count one request that resolved to an upstream and a route. + /// + /// `method` is [`normalize_method`]'s output, whose `&'static str` the + /// attribute borrows instead of copying. + pub(crate) fn request(&self, host: &str, method: &'static str, route: &str, status: u16) { + self.requests.add( + 1, + &[ + KeyValue::new("host", host.to_owned()), + KeyValue::new("http.request.method", method), + KeyValue::new("http.route", route.to_owned()), + KeyValue::new("http.response.status_code", i64::from(status)), + ], + ); + } + + /// Record how long the data plane held one request. + pub(crate) fn duration(&self, host: &str, route: &str, seconds: f64) { + self.duration.record( + seconds, + &[ + KeyValue::new("host", host.to_owned()), + KeyValue::new("http.route", route.to_owned()), + KeyValue::new("phase", PHASE), + ], + ); + } + + /// Count a request as in flight until the returned guard is dropped. + /// + /// The guard is what keeps the gauge honest on every exit, the error paths + /// included: a request that panics or is refused still leaves the pool + /// exactly once. + pub(crate) fn in_flight(&self, host: &str) -> InFlightGuard { + let host = KeyValue::new("host", host.to_owned()); + self.in_flight.add(1, std::slice::from_ref(&host)); + InFlightGuard { + counter: self.in_flight.clone(), + host, + } + } + + /// Count one failure, by the problem type the client was answered with. + pub(crate) fn error(&self, host: &str, route: &str, error_type: &str) { + self.errors.add( + 1, + &[ + KeyValue::new("host", host.to_owned()), + KeyValue::new("http.route", route.to_owned()), + KeyValue::new("error_type", error_type.to_owned()), + ], + ); + } + + /// Count one request a rate limit refused. + pub(crate) fn rate_limit_exceeded(&self, host: &str, path: &str) { + self.rate_limit_exceeded.add( + 1, + &[ + KeyValue::new("host", host.to_owned()), + KeyValue::new("path", path.to_owned()), + ], + ); + } + + /// Record the bucket usage a refusal saw, `0.0` to `1.0`. + pub(crate) fn rate_limit_usage(&self, host: &str, path: &str, ratio: f64) { + self.rate_limit_usage.record( + ratio, + &[ + KeyValue::new("host", host.to_owned()), + KeyValue::new("path", path.to_owned()), + ], + ); + } + + /// Count the endpoint a request dials, and how it was chosen. + pub(crate) fn endpoint_selected( + &self, + upstream: Uuid, + endpoint_host: &str, + method: &'static str, + ) { + self.endpoint_selected.add( + 1, + &[ + KeyValue::new("upstream_id", upstream.to_string()), + KeyValue::new("endpoint_host", endpoint_host.to_owned()), + KeyValue::new("selection_method", method), + ], + ); + } + + /// Publish the state an upstream's breaker moved to (DESIGN §4.2). + /// + /// Only a transition publishes: a breaker that never moved has no series, + /// which the module docs note. + pub(crate) fn breaker_state(&self, host: &str, value: u64) { + self.breaker_state.record( + value, + std::slice::from_ref(&KeyValue::new("host", host.to_owned())), + ); + } + + /// Count one transition of an upstream's breaker (DESIGN §4.2). + pub(crate) fn breaker_transition(&self, host: &str, from_state: &str, to_state: &str) { + self.breaker_transitions.add( + 1, + &[ + KeyValue::new("host", host.to_owned()), + KeyValue::new("from_state", from_state.to_owned()), + KeyValue::new("to_state", to_state.to_owned()), + ], + ); + } + + /// Count a request that pinned its endpoint with the target-host header. + pub(crate) fn target_host_used(&self, upstream: Uuid, endpoint_host: &str) { + self.target_host_used.add( + 1, + &[ + KeyValue::new("upstream_id", upstream.to_string()), + KeyValue::new("endpoint_host", endpoint_host.to_owned()), + ], + ); + } +} + +/// Drop guard of the in-flight gauge. +/// +/// `+1` is taken when the request is attributed to a host and `-1` happens +/// here, which is the only way to cover every exit of a pipeline that can +/// return, be refused, panic or be cancelled. +pub(crate) struct InFlightGuard { + counter: UpDownCounter, + host: KeyValue, +} + +impl std::fmt::Debug for InFlightGuard { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InFlightGuard").finish_non_exhaustive() + } +} + +impl Drop for InFlightGuard { + fn drop(&mut self) { + self.counter.add(-1, std::slice::from_ref(&self.host)); + } +} + +/// Normalize the HTTP method per the `OTel` semantic conventions. +/// +/// Unknown methods become `_OTHER`: a method string is client input, and a +/// label per spelling is a metric explosion. The mapping is the inbound API +/// gateway's `normalize_method`, so both gateways share dashboards. +#[must_use] +pub(crate) fn normalize_method(method: &http::Method) -> &'static str { + match *method { + http::Method::GET => "GET", + http::Method::POST => "POST", + http::Method::PUT => "PUT", + http::Method::DELETE => "DELETE", + http::Method::PATCH => "PATCH", + http::Method::HEAD => "HEAD", + http::Method::OPTIONS => "OPTIONS", + http::Method::CONNECT => "CONNECT", + http::Method::TRACE => "TRACE", + _ => "_OTHER", + } +} + +#[cfg(test)] +mod tests { + use super::{METER_NAME, normalize_method}; + + #[test] + fn the_meter_scope_is_the_gear_name() { + assert_eq!(METER_NAME, "oagw"); + } + + #[test] + fn standard_verbs_keep_their_spelling() { + for (method, expected) in [ + ("GET", "GET"), + ("POST", "POST"), + ("PUT", "PUT"), + ("DELETE", "DELETE"), + ("PATCH", "PATCH"), + ("HEAD", "HEAD"), + ("OPTIONS", "OPTIONS"), + ("CONNECT", "CONNECT"), + ("TRACE", "TRACE"), + ] { + assert_eq!( + normalize_method(&method.parse::().unwrap()), + expected + ); + } + } + + #[test] + fn an_unknown_verb_becomes_other() { + assert_eq!( + normalize_method(&"BREW".parse::().unwrap()), + "_OTHER" + ); + } +} diff --git a/gears/system/oagw/oagw/src/infra/mod.rs b/gears/system/oagw/oagw/src/infra/mod.rs new file mode 100644 index 0000000..54e8b3b --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/mod.rs @@ -0,0 +1,12 @@ +// Created: 2026-08-31 by Constructor Tech +//! Infrastructure layer: integrations with the world outside the domain +//! (ADR-0002 "Built-in Plugins"). +//! +//! [`plugin`] holds the plugin contracts of ADR-0002 and the built-in plugins +//! that ship with the gateway. [`metrics`] holds the OpenTelemetry instruments +//! of the data plane (DESIGN §4.2), emitted against the meter provider the +//! host installs. Everything here is behind a domain-facing interface only: +//! the proxy pipeline sees the traits, never a concrete plugin. + +pub mod metrics; +pub mod plugin; diff --git a/gears/system/oagw/oagw/src/infra/plugin/apikey_auth.rs b/gears/system/oagw/oagw/src/infra/plugin/apikey_auth.rs new file mode 100644 index 0000000..51ae15c --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/apikey_auth.rs @@ -0,0 +1,266 @@ +// Created: 2026-08-31 by Constructor Tech +//! `ApiKeyAuthPlugin` (ADR-0002 "Built-in Plugins", DESIGN §3.2 "Secret Access +//! Control"). +//! +//! Static API-key injection: the binding names a `cred://` reference, the data +//! plane resolves it as the caller at request time and writes it into the +//! outbound request — as a header (default `x-api-key`) or as a query +//! parameter, the two being mutually exclusive. +//! +//! # Residual plaintext +//! +//! As in ADR-0008 ("Known Residual Plaintext"), the rendered credential value +//! is a plain `String` for the few instructions it takes to become a header +//! value. The credential never reaches a log line, a problem document or a +//! `Debug` impl; the store's zeroized buffer is released when the request ends. + +use std::fmt; + +use async_trait::async_trait; +use http::HeaderValue; + +use crate::error::{OagwError, OagwErrorKind}; +use crate::infra::plugin::secrets::{CredStore, resolve_secret}; +use crate::infra::plugin::traits::{AuthPlugin, PluginConfig, RequestContext}; + +/// GTS id of the built-in API-key auth plugin. +pub const APIKEY_AUTH_PLUGIN_ID: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1"; + +/// Header the credential is written into when the binding names none. +pub const DEFAULT_HEADER_NAME: &str = "x-api-key"; + +/// Configuration members of the plugin. +const KEY_REF: &str = "key_ref"; +const HEADER_NAME: &str = "header_name"; +const QUERY_PARAM: &str = "query_param"; +const PREFIX: &str = "prefix"; + +/// Injects a static API key resolved from the credential store. +pub struct ApiKeyAuthPlugin { + credstore: CredStore, +} + +/// `Debug` names no field: the plugin holds a credential store handle, and a +/// panic message must never carry it (PRD `cpt-cf-oagw-fr-auth-injection`). +impl fmt::Debug for ApiKeyAuthPlugin { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ApiKeyAuthPlugin").finish() + } +} + +impl ApiKeyAuthPlugin { + /// Plugin over `credstore`. + #[must_use] + pub fn new(credstore: CredStore) -> Self { + Self { credstore } + } +} + +#[async_trait] +impl AuthPlugin for ApiKeyAuthPlugin { + fn id(&self) -> &'static str { + "apikey" + } + + fn plugin_type(&self) -> &'static str { + APIKEY_AUTH_PLUGIN_ID + } + + async fn authenticate(&self, ctx: &mut RequestContext) -> Result<(), OagwError> { + let target = Target::of(&ctx.config)?; + let key = resolve_secret(&self.credstore, &ctx.security, &target.key_ref).await?; + target.inject(ctx, key.expose()) + } +} + +/// Where the credential goes. +#[derive(Debug)] +struct Target { + key_ref: String, + kind: Kind, + prefix: String, +} + +/// Header or query-parameter injection; the two are mutually exclusive. +#[derive(Debug)] +enum Kind { + /// Write the credential into this header. + Header(String), + /// Append the credential as this query parameter. + Query(String), +} + +impl Target { + /// Read the binding, rejecting an incomplete or contradictory one. + /// + /// A binding the write path would have rejected (the control plane + /// validates every auth binding) is still refused here: a record may have + /// been seeded directly into the store, and silently forwarding a request + /// without its credential is the one failure the gateway must never make. + fn of(config: &PluginConfig) -> Result { + let key_ref = config + .string(KEY_REF) + .filter(|member| !member.trim().is_empty()) + .ok_or_else(missing_key_ref)? + .to_owned(); + let header = config.string(HEADER_NAME); + let query = config.string(QUERY_PARAM); + let kind = match (header, query) { + (Some(_), Some(_)) => { + return Err(OagwError::validation(format!( + "auth binding '{APIKEY_AUTH_PLUGIN_ID}' accepts only one of \ + '{HEADER_NAME}' or '{QUERY_PARAM}'" + ))); + } + (Some(name), None) => Kind::Header(name.to_owned()), + (None, Some(name)) => Kind::Query(name.to_owned()), + (None, None) => Kind::Header(DEFAULT_HEADER_NAME.to_owned()), + }; + Ok(Self { + key_ref, + kind, + prefix: config.string(PREFIX).unwrap_or_default().to_owned(), + }) + } + + /// Write the credential into the outbound request. + fn inject(&self, ctx: &mut RequestContext, key: &str) -> Result<(), OagwError> { + let value = format!("{}{key}", self.prefix); + match &self.kind { + Kind::Header(name) => { + let header = http::HeaderName::try_from(name.as_str()) + .map_err(|error| unusable_member(HEADER_NAME, name, &error))?; + let header_value = HeaderValue::from_str(&value) + .map_err(|_| unusable_credential(&self.key_ref))?; + ctx.headers.insert(header, header_value); + } + Kind::Query(name) => ctx.query = append_query_parameter(&ctx.query, name, &value), + } + Ok(()) + } +} + +/// 400 `validation.error.v1`: the binding names no credential reference. +fn missing_key_ref() -> OagwError { + OagwError::validation(format!( + "auth binding '{APIKEY_AUTH_PLUGIN_ID}' requires the '{KEY_REF}' cred:// reference" + )) +} + +/// 400 for a member the outbound request cannot carry. +fn unusable_member(member: &str, value: &str, error: &impl std::fmt::Display) -> OagwError { + OagwError::validation(format!( + "auth binding member '{member}' carries an unusable value '{value}': {error}" + )) +} + +/// 500 for a resolved credential that cannot become a header value. +fn unusable_credential(reference: &str) -> OagwError { + OagwError::new( + OagwErrorKind::Internal, + format!("referenced secret '{reference}' cannot be sent as a header value"), + ) +} + +/// Append `name=value` to a query string, keeping the existing parameters. +fn append_query_parameter(query: &str, name: &str, value: &str) -> String { + let mut rendered = form_urlencoded::Serializer::new(String::new()); + rendered.extend_pairs(form_urlencoded::parse(query.as_bytes())); + rendered.append_pair(name, value); + rendered.finish() +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn config(raw: &serde_json::Value) -> PluginConfig { + PluginConfig::new( + raw.as_object() + .cloned() + .unwrap_or_else(serde_json::Map::new), + ) + } + + #[test] + fn the_plugin_is_the_documented_built_in() { + let plugin = ApiKeyAuthPlugin::new(test_credstore()); + assert_eq!(plugin.id(), "apikey"); + assert_eq!(plugin.plugin_type(), APIKEY_AUTH_PLUGIN_ID); + assert_eq!( + plugin.plugin_type(), + crate::domain::plugin::PluginKind::Auth.built_in_id("apikey") + ); + } + + /// A store no test of this module reads; the resolution paths are covered + /// by the data-plane integration tests. + fn test_credstore() -> CredStore { + std::sync::Arc::new(crate::infra::plugin::secrets::stub::StubCredStore( + crate::infra::plugin::secrets::stub::Behaviour::Empty, + )) + } + + #[test] + fn a_binding_without_a_reference_is_rejected() { + let error = Target::of(&config(&json!({}))).unwrap_err(); + assert_eq!(*error.kind(), OagwErrorKind::Validation); + } + + #[test] + fn a_blank_reference_is_rejected() { + let error = Target::of(&config(&json!({ "key_ref": " " }))).unwrap_err(); + assert_eq!(*error.kind(), OagwErrorKind::Validation); + } + + #[test] + fn a_header_and_a_query_parameter_are_exclusive() { + let error = Target::of(&config(&json!({ + "key_ref": "cred://partner-key", + "header_name": "x-key", + "query_param": "api_key" + }))) + .unwrap_err(); + assert_eq!(*error.kind(), OagwErrorKind::Validation); + } + + #[test] + fn a_binding_without_a_member_defaults_to_the_documented_header() { + let target = Target::of(&config(&json!({ "key_ref": "cred://partner-key" }))).unwrap(); + match target.kind { + Kind::Header(name) => assert_eq!(name, DEFAULT_HEADER_NAME), + Kind::Query(_) => panic!("the default target must be a header"), + } + } + + #[test] + fn a_prefix_is_taken_verbatim() { + let target = Target::of(&config(&json!({ + "key_ref": "cred://partner-key", + "prefix": "Bearer " + }))) + .unwrap(); + assert_eq!(target.prefix, "Bearer "); + } + + #[test] + fn a_query_parameter_is_appended_to_the_existing_query() { + let rendered = append_query_parameter("a=1&b=2", "api_key", "s3cr3t"); + assert_eq!(rendered, "a=1&b=2&api_key=s3cr3t"); + } + + #[test] + fn an_empty_query_gains_only_the_credential() { + let rendered = append_query_parameter("", "api_key", "s3cr3t"); + assert_eq!(rendered, "api_key=s3cr3t"); + } + + #[test] + fn a_query_value_is_percent_encoded() { + // `form_urlencoded` renders in the `application/x-www-form-urlencoded` + // form, where a space is a `+`. + let rendered = append_query_parameter("", "api_key", "a b/c"); + assert_eq!(rendered, "api_key=a+b%2Fc"); + } +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/mod.rs b/gears/system/oagw/oagw/src/infra/plugin/mod.rs new file mode 100644 index 0000000..d2d6709 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/mod.rs @@ -0,0 +1,49 @@ +// Created: 2026-08-31 by Constructor Tech +//! The plugin system of the data plane (ADR-0002, ADR-0008, ADR-0009). +//! +//! * [`traits`] — the three contracts of ADR-0002 and the context they run in. +//! * [`registry`] — one registry per contract, built over the built-ins. +//! * [`secrets`] — `cred://` resolution shared by the auth plugins. +//! * one module per built-in plugin: +//! [`noop_auth`], [`apikey_auth`], [`oauth2_client_cred_auth`], +//! [`required_headers_guard`], [`request_id_transform`]. +//! +//! # Built-in plugins +//! +//! | Family | Name | Resolvable | +//! |---|---|---| +//! | auth | `noop` | yes | +//! | auth | `apikey` | yes (needs a credential store) | +//! | auth | `oauth2_client_cred` | yes (needs a credential store) | +//! | auth | `oauth2_client_cred_basic` | yes (needs a credential store) | +//! | guard | `required_headers` | yes | +//! | transform | `request_id` | yes | +//! +//! `basic`, `bearer`, `timeout`, `cors`, `logging` and `metrics` stay +//! catalog-only (ADR-0002 "Built-in Plugins"): they are not in a registry, so a +//! binding of them is a 503 at request time and a 400 on the write path. +//! +//! # Security boundary (PRD `cpt-cf-oagw-fr-auth-injection`) +//! +//! Secret material exists only inside [`SecretString`](toolkit_auth::oauth2::SecretString) +//! from the moment the credential store returns it. It is never `Debug`- +//! formatted, never logged, never serialised into a problem document, and never +//! persisted; the token cache is keyed by tenant and subject and every hit is +//! verified against its key. + +pub mod apikey_auth; +pub mod noop_auth; +pub mod oauth2_client_cred_auth; +pub mod registry; +pub mod request_id_transform; +pub mod required_headers_guard; +pub mod secrets; +pub mod traits; + +pub use registry::{ + AuthPluginRegistry, GuardPluginRegistry, PluginRegistries, TransformPluginRegistry, +}; +pub use traits::{ + AuthPlugin, ErrorContext, GuardDecision, GuardPlugin, PluginConfig, Rejection, RequestContext, + ResponseContext, TransformPlugin, UpstreamRef, +}; diff --git a/gears/system/oagw/oagw/src/infra/plugin/noop_auth.rs b/gears/system/oagw/oagw/src/infra/plugin/noop_auth.rs new file mode 100644 index 0000000..c234759 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/noop_auth.rs @@ -0,0 +1,50 @@ +// Created: 2026-08-31 by Constructor Tech +//! `NoopAuthPlugin` (ADR-0002 "Built-in Plugins"). +//! +//! The identity auth binding: an upstream without credentials binds `noop` and +//! the gateway forwards the request untouched. It exists so a record always has +//! a resolvable auth plugin — the data plane never forwards a request for an +//! auth binding it cannot resolve. + +use async_trait::async_trait; + +use crate::error::OagwError; +use crate::infra::plugin::traits::{AuthPlugin, RequestContext}; + +/// GTS id of the built-in no-op auth plugin. +pub const NOOP_AUTH_PLUGIN_ID: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1"; + +/// Always succeeds, injects nothing. +#[derive(Debug, Default)] +pub struct NoopAuthPlugin; + +#[async_trait] +impl AuthPlugin for NoopAuthPlugin { + fn id(&self) -> &'static str { + "noop" + } + + fn plugin_type(&self) -> &'static str { + NOOP_AUTH_PLUGIN_ID + } + + async fn authenticate(&self, _ctx: &mut RequestContext) -> Result<(), OagwError> { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_plugin_is_the_documented_built_in() { + let plugin = NoopAuthPlugin; + assert_eq!(plugin.id(), "noop"); + assert_eq!(plugin.plugin_type(), NOOP_AUTH_PLUGIN_ID); + assert_eq!( + plugin.plugin_type(), + crate::domain::plugin::PluginKind::Auth.built_in_id("noop") + ); + } +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/oauth2_client_cred_auth.rs b/gears/system/oagw/oagw/src/infra/plugin/oauth2_client_cred_auth.rs new file mode 100644 index 0000000..a97537b --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/oauth2_client_cred_auth.rs @@ -0,0 +1,617 @@ +// Created: 2026-08-31 by Constructor Tech +//! `OAuth2ClientCredAuthPlugin` (ADR-0008). +//! +//! Two registrations of one plugin, differing only in the client-auth method: +//! +//! | GTS id | Client auth | +//! |---|---| +//! | `…auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1` | `Form` | +//! | `…auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1` | `Basic` | +//! +//! The token is fetched with [`toolkit_auth::oauth2::fetch_token`] — a single +//! HTTP exchange, no background watcher — and cached per +//! `(tenant, subject, auth method, config)` in a +//! [`pingora_memory_cache::MemoryCache`]. `TinyUfo` hashes its keys to `u64` +//! and does **not** compare them for equality, so every entry stores its own +//! key and a hit is only used after the key matches: a hash collision degrades +//! to a miss and can never hand one tenant the token of another. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use http::HeaderValue; +use pingora_memory_cache::MemoryCache; +use toolkit_auth::oauth2::{ + ClientAuthMethod, FetchedToken, OAuthClientConfig, SecretString, fetch_token, +}; +use toolkit_http::HttpClientConfig; +use url::Url; + +use crate::error::{OagwError, OagwErrorKind}; +use crate::infra::plugin::secrets::{CredStore, resolve_secret}; +use crate::infra::plugin::traits::{AuthPlugin, PluginConfig, RequestContext}; + +/// GTS id of the `Form` variant. +pub const OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID: &str = + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1"; + +/// GTS id of the `Basic` variant. +pub const OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID: &str = + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1"; + +/// Safety margin subtracted from the `expires_in` the `IdP` reports (ADR-0008). +const EXPIRY_MARGIN: Duration = Duration::from_secs(30); + +/// Configuration members of the plugin. +const TOKEN_ENDPOINT: &str = "token_endpoint"; +const ISSUER_URL: &str = "issuer_url"; +const CLIENT_ID_REF: &str = "client_id_ref"; +const CLIENT_SECRET_REF: &str = "client_secret_ref"; +const SCOPES: &str = "scopes"; + +/// A cached access token plus the key it was filed under. +/// +/// `TinyUfo` does not compare keys on a hit, so the entry carries its own key +/// and the lookup verifies it — the multi-tenant defence of ADR-0008 ("Hash- +/// Collision Safety via `CachedToken` Wrapper"). +#[derive(Clone)] +struct CachedToken { + key: String, + token: SecretString, +} + +/// One in-flight token exchange, keyed by cache key (ADR-0008 "Stampede +/// Protection"). +type ExchangeGate = std::sync::Arc>; + +/// Gates kept for exchanges that are no longer in flight. +/// +/// Only reached when concurrent requests for the same key keep arriving faster +/// than they finish; a gate nobody waits on is dropped, so the map stays as +/// small as the set of live exchanges. +const MAX_GATES: usize = 4096; + +/// `OAuth2` client-credentials credential injection with an internal token cache. +pub struct OAuth2ClientCredAuthPlugin { + credstore: CredStore, + auth_method: ClientAuthMethod, + http_config: Option, + cache: MemoryCache, + cache_ttl: Duration, + /// One gate per cold key, so N concurrent misses exchange once. + gates: std::sync::Mutex>, +} + +impl OAuth2ClientCredAuthPlugin { + /// Plugin over `credstore`, caching at most `cache_capacity` tokens for at + /// most `cache_ttl`. + #[must_use] + pub fn new( + credstore: CredStore, + auth_method: ClientAuthMethod, + cache_ttl: Duration, + cache_capacity: usize, + ) -> Self { + Self { + credstore, + auth_method, + http_config: None, + cache: MemoryCache::new(cache_capacity), + cache_ttl, + gates: std::sync::Mutex::new(std::collections::HashMap::new()), + } + } + + /// HTTP client configuration the token exchange uses. + /// + /// Defaults to the configuration the data plane itself uses, so an `IdP` + /// behind the same egress policy is reached the same way the upstream is. + #[must_use] + pub fn with_http_config(mut self, http_config: Option) -> Self { + self.http_config = http_config; + self + } + + /// Registry id of this variant. + #[must_use] + pub fn plugin_id(auth_method: ClientAuthMethod) -> &'static str { + match auth_method { + ClientAuthMethod::Form => OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID, + ClientAuthMethod::Basic => OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID, + } + } + + /// GTS id of this variant, for the registry key and the diagnostics. + fn key_tag(&self) -> &'static str { + match self.auth_method { + ClientAuthMethod::Form => "form", + ClientAuthMethod::Basic => "basic", + } + } +} + +#[async_trait] +impl AuthPlugin for OAuth2ClientCredAuthPlugin { + fn id(&self) -> &'static str { + "oauth2_client_cred" + } + + fn plugin_type(&self) -> &'static str { + Self::plugin_id(self.auth_method) + } + + async fn authenticate(&self, ctx: &mut RequestContext) -> Result<(), OagwError> { + let binding = Binding::of(&ctx.config)?; + let key = self.cache_key(ctx, &binding); + if let Some(token) = self.cached(&key) { + return inject(ctx, token.expose()); + } + // Single flight (ADR-0008 "Stampede Protection"): the first requester of + // a cold key holds the gate while it exchanges, the others wait for it + // and then read the token it filed. The phase runs under the head + // budget, so a queued request shares the same deadline as the dial. + let gate = self.gate(&key); + let _in_flight = gate.lock().await; + if let Some(token) = self.cached(&key) { + return inject(ctx, token.expose()); + } + let fetched = self.fetch(ctx, &binding).await?; + self.store(&key, &fetched); + self.release(&key, &gate); + inject(ctx, fetched.bearer.expose()) + } +} + +impl OAuth2ClientCredAuthPlugin { + /// `{tenant_id}:{subject_id}:{auth_method}:{config_hash}` (ADR-0008). + /// + /// The tenant and the subject isolate the credentials of one caller from + /// another; the auth method keeps the two variants apart when a deployment + /// binds both with the same configuration; the configuration hash gives + /// every distinct binding its own entry. + fn cache_key(&self, ctx: &RequestContext, binding: &Binding) -> String { + format!( + "{}:{}:{}:{}", + ctx.tenant_id(), + ctx.subject_id(), + self.key_tag(), + binding.stable_hash() + ) + } + + /// Serve a token from the cache, treating a key mismatch as a miss. + /// + /// A `u64` hash collision would surface here; it degrades to a cache miss, + /// never to another tenant's token. + fn cached(&self, key: &str) -> Option { + let (entry, _status) = self.cache.get(key); + entry + .filter(|cached| cached.key == key) + .map(|cached| cached.token) + } + + /// The gate of `key`, created when no exchange is in flight for it. + fn gate(&self, key: &str) -> ExchangeGate { + let Ok(mut gates) = self.gates.lock() else { + // A poisoned map costs coalescing, never correctness: the exchange + // still runs, and the token is still filed. + return std::sync::Arc::new(tokio::sync::Mutex::new(())); + }; + if gates.len() > MAX_GATES { + gates.retain(|_key, gate| std::sync::Arc::strong_count(gate) == 1); + } + std::sync::Arc::clone( + gates + .entry(key.to_owned()) + .or_insert_with(|| std::sync::Arc::new(tokio::sync::Mutex::new(()))), + ) + } + + /// Forget the gate of `key` once nobody waits on it. + /// + /// The map and the caller hold the only two references when the exchange is + /// done alone, so a stronger count means a request is still queued on it. + fn release(&self, key: &str, gate: &ExchangeGate) { + if std::sync::Arc::strong_count(gate) != 2 { + return; + } + if let Ok(mut gates) = self.gates.lock() + && gates + .get(key) + .is_some_and(|held| std::sync::Arc::ptr_eq(held, gate)) + { + gates.remove(key); + } + } + + /// File a token under `key`; a failed fetch is never filed (ADR-0008). + fn store(&self, key: &str, fetched: &FetchedToken) { + let Some(ttl) = self.ttl_of(fetched) else { + return; + }; + self.cache.put( + key, + CachedToken { + key: key.to_owned(), + token: fetched.bearer.clone(), + }, + Some(ttl), + ); + } + + /// `min(config_ttl, expires_in − 30s)`; a token that would expire within + /// the margin is not cached at all. + fn ttl_of(&self, fetched: &FetchedToken) -> Option { + let ttl = fetched.expires_in.checked_sub(EXPIRY_MARGIN)?; + Some(self.cache_ttl.min(ttl)) + } + + /// Resolve the credentials and exchange them for an access token. + async fn fetch( + &self, + ctx: &RequestContext, + binding: &Binding, + ) -> Result { + let client_id = + resolve_secret(&self.credstore, &ctx.security, &binding.client_id_ref).await?; + let client_secret = + resolve_secret(&self.credstore, &ctx.security, &binding.client_secret_ref).await?; + let config = OAuthClientConfig { + token_endpoint: binding.token_endpoint.clone(), + issuer_url: binding.issuer_url.clone(), + client_id: client_id.expose().to_owned(), + client_secret: SecretString::new(client_secret.expose().to_owned()), + scopes: binding.scopes.clone(), + auth_method: self.auth_method, + http_config: self.http_config.clone(), + ..OAuthClientConfig::default() + }; + let fetched = fetch_token(config) + .await + .map_err(|error| token_source_failed(&error))?; + tracing::debug!( + "oauth2 client-credentials exchange completed ({}s lifetime)", + fetched.expires_in.as_secs() + ); + Ok(FetchedToken { + bearer: fetched.bearer, + expires_in: fetched.expires_in, + }) + } +} + +/// The plugin binding of one request. +#[derive(Debug)] +struct Binding { + token_endpoint: Option, + issuer_url: Option, + client_id_ref: String, + client_secret_ref: String, + scopes: Vec, +} + +impl Binding { + /// Parse the binding, rejecting an incomplete one. + /// + /// ADR-0008 requires `client_id_ref`, `client_secret_ref` and exactly one + /// of `token_endpoint` / `issuer_url`. `OAuthClientConfig::validate` + /// enforces the same rule again inside `fetch_token`; the check is repeated + /// here so a directly-seeded record fails with the validation contract of + /// the gateway instead of an internal error. + fn of(config: &PluginConfig) -> Result { + let client_id_ref = required_reference(config, CLIENT_ID_REF)?; + let client_secret_ref = required_reference(config, CLIENT_SECRET_REF)?; + let token_endpoint = endpoint_url(config, TOKEN_ENDPOINT)?; + let issuer_url = endpoint_url(config, ISSUER_URL)?; + if token_endpoint.is_some() == issuer_url.is_some() { + return Err(OagwError::validation(format!( + "auth binding requires exactly one of '{TOKEN_ENDPOINT}' or '{ISSUER_URL}'" + ))); + } + Ok(Binding { + token_endpoint, + issuer_url, + client_id_ref, + client_secret_ref, + scopes: scopes(config), + }) + } + + /// Deterministic digest of the resolved configuration (ADR-0008 + /// "Cache Key Design"). + /// + /// The members are concatenated in a fixed order into a canonical string — + /// no separators inside a member, `|` between them — and digested with + /// FNV-1a/64: a non-cryptographic, release-stable digest whose only job is + /// to keep distinct configurations in distinct cache entries. The token + /// cache is additionally keyed by tenant, subject and auth method, so a + /// digest collision could only mix two configurations of the *same* + /// caller's binding. + fn stable_hash(&self) -> String { + let canonical = format!( + "{}|{}|{}|{}|{}", + self.token_endpoint + .as_ref() + .map_or_else(String::new, Url::to_string), + self.issuer_url + .as_ref() + .map_or_else(String::new, Url::to_string), + self.client_id_ref, + self.client_secret_ref, + self.scopes.join(" ") + ); + let mut digest: u64 = 0xcbf2_9ce4_8422_2325; + for byte in canonical.as_bytes() { + digest ^= u64::from(*byte); + digest = digest.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{digest:016x}") + } +} + +/// 400 for a `cred://` member the binding omits. +fn required_reference(config: &PluginConfig, member: &str) -> Result { + config + .string(member) + .filter(|value| !value.trim().is_empty()) + .map(str::to_owned) + .ok_or_else(|| { + OagwError::validation(format!( + "auth binding requires the '{member}' cred:// reference" + )) + }) +} + +/// Parse an endpoint member, rejecting a value that is not a URL. +fn endpoint_url(config: &PluginConfig, member: &str) -> Result, OagwError> { + let Some(raw) = config + .string(member) + .filter(|value| !value.trim().is_empty()) + else { + return Ok(None); + }; + Url::parse(raw) + .map(Some) + .map_err(|_| OagwError::validation(format!("auth binding member '{member}' is not a URL"))) +} + +/// Space-separated scope list of the binding. +fn scopes(config: &PluginConfig) -> Vec { + config + .string(SCOPES) + .map(|raw| raw.split_whitespace().map(str::to_owned).collect()) + .unwrap_or_default() +} + +/// 401 `auth.failed.v1` for a failed token exchange (ADR-0008). +/// +/// The failure is **not** cached: the next request retries the `IdP`. +fn token_source_failed(error: &impl std::fmt::Display) -> OagwError { + tracing::warn!(error = %error, "oauth2 token exchange failed"); + OagwError::new( + OagwErrorKind::AuthenticationFailed, + format!("oauth2 client-credentials exchange failed: {error}"), + ) +} + +/// Write `Authorization: Bearer ` into the outbound request. +fn inject(ctx: &mut RequestContext, token: &str) -> Result<(), OagwError> { + let value = format!("Bearer {token}"); + let header = HeaderValue::from_str(&value).map_err(|_| { + OagwError::new( + OagwErrorKind::Internal, + "the resolved access token cannot be sent as a header value", + ) + })?; + ctx.headers.insert(http::header::AUTHORIZATION, header); + Ok(()) +} + +/// The two registered variants. +#[must_use] +pub fn with_builtins( + credstore: CredStore, + http_config: Option, + cache_ttl: Duration, + cache_capacity: usize, +) -> Vec<(String, Arc)> { + let form = OAuth2ClientCredAuthPlugin::new( + Arc::clone(&credstore), + ClientAuthMethod::Form, + cache_ttl, + cache_capacity, + ) + .with_http_config(http_config.clone()); + let basic = OAuth2ClientCredAuthPlugin::new( + credstore, + ClientAuthMethod::Basic, + cache_ttl, + cache_capacity, + ) + .with_http_config(http_config); + vec![ + (OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID.to_owned(), Arc::new(form)), + ( + OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID.to_owned(), + Arc::new(basic), + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::infra::plugin::secrets::stub::{Behaviour, StubCredStore}; + use serde_json::json; + + fn store(behaviour: Behaviour) -> CredStore { + std::sync::Arc::new(StubCredStore(behaviour)) + } + + fn config(raw: &serde_json::Value) -> PluginConfig { + PluginConfig::new( + raw.as_object() + .cloned() + .unwrap_or_else(serde_json::Map::new), + ) + } + + #[test] + fn the_variants_use_the_documented_gts_ids() { + assert_eq!( + OAuth2ClientCredAuthPlugin::plugin_id(ClientAuthMethod::Form), + OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID + ); + assert_eq!( + OAuth2ClientCredAuthPlugin::plugin_id(ClientAuthMethod::Basic), + OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID + ); + assert_eq!( + OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID, + crate::domain::plugin::PluginKind::Auth.built_in_id("oauth2_client_cred_basic") + ); + } + + #[test] + fn a_binding_needs_both_references_and_one_endpoint() { + let missing_id = Binding::of(&config(&json!({ + "client_secret_ref": "cred://secret", + "token_endpoint": "https://idp/token" + }))); + assert!(missing_id.is_err()); + + let missing_endpoint = Binding::of(&config(&json!({ + "client_id_ref": "cred://id", + "client_secret_ref": "cred://secret" + }))); + assert!(missing_endpoint.is_err()); + + let both = Binding::of(&config(&json!({ + "client_id_ref": "cred://id", + "client_secret_ref": "cred://secret", + "token_endpoint": "https://idp/token", + "issuer_url": "https://idp/" + }))); + assert!(both.is_err()); + } + + #[test] + fn a_valid_binding_is_accepted() { + let binding = Binding::of(&config(&json!({ + "client_id_ref": "cred://id", + "client_secret_ref": "cred://secret", + "issuer_url": "https://idp/", + "scopes": "openid profile" + }))) + .unwrap(); + assert_eq!(binding.client_id_ref, "cred://id"); + assert_eq!(binding.scopes, Vec::from(["openid", "profile"])); + assert!(binding.token_endpoint.is_none()); + } + + #[test] + fn an_endpoint_member_must_be_a_url() { + let error = Binding::of(&config(&json!({ + "client_id_ref": "cred://id", + "client_secret_ref": "cred://secret", + "token_endpoint": "not a url" + }))) + .unwrap_err(); + assert_eq!(*error.kind(), OagwErrorKind::Validation); + } + + #[test] + fn a_blank_reference_is_rejected() { + let error = Binding::of(&config(&json!({ + "client_id_ref": " ", + "client_secret_ref": "cred://secret", + "token_endpoint": "https://idp/token" + }))) + .unwrap_err(); + assert_eq!(*error.kind(), OagwErrorKind::Validation); + } + + #[test] + fn the_configuration_hash_separates_distinct_bindings() { + let one = Binding::of(&config(&json!({ + "client_id_ref": "cred://id", + "client_secret_ref": "cred://secret", + "token_endpoint": "https://idp/token", + "scopes": "openid" + }))) + .unwrap(); + let mut other = Binding::of(&config(&json!({ + "client_id_ref": "cred://id", + "client_secret_ref": "cred://secret", + "token_endpoint": "https://idp/token", + "scopes": "openid profile" + }))) + .unwrap(); + other.client_id_ref = "cred://other-id".to_owned(); + assert_ne!(one.stable_hash(), other.stable_hash()); + assert_eq!(one.stable_hash(), one.stable_hash()); + } + + #[test] + fn the_ttl_is_the_config_ceiling_when_the_token_outlives_it() { + let plugin = plugin_cache(Duration::from_mins(5)); + let token = FetchedToken { + bearer: SecretString::new("t"), + expires_in: Duration::from_hours(1), + }; + assert_eq!(plugin.ttl_of(&token), Some(Duration::from_mins(5))); + } + + #[test] + fn a_short_lived_token_is_cached_only_for_its_lifetime() { + let plugin = plugin_cache(Duration::from_mins(5)); + let token = FetchedToken { + bearer: SecretString::new("t"), + expires_in: Duration::from_mins(2), + }; + assert_eq!(plugin.ttl_of(&token), Some(Duration::from_secs(90))); + } + + #[test] + fn a_token_within_the_expiry_margin_is_not_cached() { + let plugin = plugin_cache(Duration::from_mins(5)); + let token = FetchedToken { + bearer: SecretString::new("t"), + expires_in: Duration::from_secs(10), + }; + assert_eq!(plugin.ttl_of(&token), None); + } + + #[test] + fn a_cache_hit_must_carry_its_own_key() { + let plugin = plugin_cache(Duration::from_mins(5)); + let token = FetchedToken { + bearer: SecretString::new("first"), + expires_in: Duration::from_hours(1), + }; + plugin.store("tenant:subject:form:aaa", &token); + let (entry, _status) = plugin.cache.get("tenant:subject:form:aaa"); + let entry = entry.expect("the filed token must be retrievable"); + assert_eq!(entry.key, "tenant:subject:form:aaa"); + assert_eq!(entry.token.expose(), "first"); + // A key the entry was not filed under is treated as a miss, which is + // what a hash collision degrades to. + assert!(plugin.cached("tenant:subject:form:bbb").is_none()); + } + + #[test] + fn a_token_is_not_filed_without_a_usable_ttl() { + let plugin = plugin_cache(Duration::from_mins(5)); + let token = FetchedToken { + bearer: SecretString::new("never-cached"), + expires_in: Duration::from_secs(10), + }; + plugin.store("key", &token); + assert!(plugin.cached("key").is_none()); + } + + fn plugin_cache(ttl: Duration) -> OAuth2ClientCredAuthPlugin { + OAuth2ClientCredAuthPlugin::new(store(Behaviour::Empty), ClientAuthMethod::Form, ttl, 10) + } +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/registry.rs b/gears/system/oagw/oagw/src/infra/plugin/registry.rs new file mode 100644 index 0000000..45ca52a --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/registry.rs @@ -0,0 +1,245 @@ +// Created: 2026-08-31 by Constructor Tech +//! The three plugin registries (ADR-0002 "Plugin Loading", ADR-0008, ADR-0009). +//! +//! A registry is keyed by the **full GTS id** a binding spells +//! (`gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1`). The short +//! spelling (`apikey`) is accepted as well: `crate::domain::plugin::PluginRef` +//! classifies the reference and the canonical id is looked up. The catalog is +//! not duplicated here — what is bindable comes from the catalog, what runs +//! comes from a registry. +//! +//! # Degradation (ADR-0008 "Registry Integration") +//! +//! The auth registry is built around a credential store. A deployment that +//! wires none (the `ClientHub` lookup fails) still gets a working gateway: +//! `with_builtins` is called without one, the no-op plugin is the only auth +//! plugin that resolves, and an upstream whose binding needs a credential +//! fails its request with 503 `link.unavailable.v1` — never a silent forward +//! without credentials. + +use std::collections::HashMap; +use std::sync::Arc; + +use toolkit_http::HttpClientConfig; + +use crate::domain::model::PluginKind; +use crate::domain::plugin::PluginRef; +use crate::error::{OagwError, OagwErrorKind}; +use crate::infra::plugin::apikey_auth::{APIKEY_AUTH_PLUGIN_ID, ApiKeyAuthPlugin}; +use crate::infra::plugin::noop_auth::{NOOP_AUTH_PLUGIN_ID, NoopAuthPlugin}; +use crate::infra::plugin::oauth2_client_cred_auth::with_builtins as oauth2_builtins; +use crate::infra::plugin::request_id_transform::{ + REQUEST_ID_TRANSFORM_PLUGIN_ID, RequestIdTransformPlugin, +}; +use crate::infra::plugin::required_headers_guard::{ + REQUIRED_HEADERS_GUARD_PLUGIN_ID, RequiredHeadersGuardPlugin, +}; +use crate::infra::plugin::secrets::CredStore; +use crate::infra::plugin::traits::{AuthPlugin, GuardPlugin, TransformPlugin}; + +/// Registry of the credential-injection plugins. +pub struct AuthPluginRegistry { + plugins: HashMap>, +} + +/// Registry of the policy-enforcement plugins. +pub struct GuardPluginRegistry { + plugins: HashMap>, +} + +/// Registry of the mutation plugins. +pub struct TransformPluginRegistry { + plugins: HashMap>, +} + +/// The three registries of one data plane. +#[derive(Clone)] +pub struct PluginRegistries { + auth: Arc, + guard: Arc, + transform: Arc, +} + +impl PluginRegistries { + /// The built-in registries of one deployment (ADR-0008, ADR-0009). + /// + /// `credstore` is the credential store the auth plugins resolve their + /// `cred://` references through; `token_http_config` is the HTTP client + /// configuration the `OAuth2` token exchange uses — the same one the proxy + /// dials upstreams with; `token_cache` carries the cache ceiling and + /// capacity. + #[must_use] + pub fn with_builtins( + credstore: Option, + token_http_config: Option, + token_cache: crate::config::TokenCacheConfig, + ) -> Self { + Self { + auth: Arc::new(AuthPluginRegistry::with_builtins( + credstore, + token_http_config, + token_cache, + )), + guard: Arc::new(GuardPluginRegistry::with_builtins()), + transform: Arc::new(TransformPluginRegistry::with_builtins()), + } + } + + /// Registry of the auth plugins. + #[must_use] + pub fn auth(&self) -> &AuthPluginRegistry { + &self.auth + } + + /// Registry of the guard plugins. + #[must_use] + pub fn guard(&self) -> &GuardPluginRegistry { + &self.guard + } + + /// Registry of the transform plugins. + #[must_use] + pub fn transform(&self) -> &TransformPluginRegistry { + &self.transform + } +} + +impl AuthPluginRegistry { + /// The built-in auth plugins (ADR-0002, ADR-0008). + /// + /// `noop` and `apikey` need no credential store; the two `OAuth2` variants + /// do. Without one they are not registered, which is what makes an + /// upstream that binds them fail closed (503 `link.unavailable.v1`). + #[must_use] + pub fn with_builtins( + credstore: Option, + token_http_config: Option, + token_cache: crate::config::TokenCacheConfig, + ) -> Self { + let mut plugins: HashMap> = HashMap::new(); + plugins.insert(NOOP_AUTH_PLUGIN_ID.to_owned(), Arc::new(NoopAuthPlugin)); + if let Some(credstore) = credstore { + plugins.insert( + APIKEY_AUTH_PLUGIN_ID.to_owned(), + Arc::new(ApiKeyAuthPlugin::new(Arc::clone(&credstore))), + ); + for (id, plugin) in oauth2_builtins( + credstore, + token_http_config, + token_cache.ttl, + token_cache.capacity, + ) { + plugins.insert(id, plugin); + } + } else { + tracing::warn!( + "credential store is not wired; only the '{}' auth plugin is available", + NOOP_AUTH_PLUGIN_ID + ); + } + Self { plugins } + } + + /// Resolve a reference to the plugin that implements it. + /// + /// `None` means the reference names no implementation this registry has: + /// either it is a built-in that needs a credential store that is not wired, + /// or it is catalogued without an implementation, or it is unknown. + #[must_use] + pub fn get(&self, reference: &str) -> Option> { + self.plugins + .get(reference) + .cloned() + .or_else(|| canonical_id(reference).and_then(|id| self.plugins.get(&id).cloned())) + } +} + +impl GuardPluginRegistry { + /// The built-in guard plugins (ADR-0009). + #[must_use] + pub fn with_builtins() -> Self { + let mut plugins: HashMap> = HashMap::new(); + plugins.insert( + REQUIRED_HEADERS_GUARD_PLUGIN_ID.to_owned(), + Arc::new(RequiredHeadersGuardPlugin), + ); + Self { plugins } + } + + /// Resolve a reference to the plugin that implements it. + #[must_use] + pub fn get(&self, reference: &str) -> Option> { + self.plugins + .get(reference) + .cloned() + .or_else(|| canonical_id(reference).and_then(|id| self.plugins.get(&id).cloned())) + } +} + +impl TransformPluginRegistry { + /// The built-in transform plugins (ADR-0002). + #[must_use] + pub fn with_builtins() -> Self { + let mut plugins: HashMap> = HashMap::new(); + plugins.insert( + REQUEST_ID_TRANSFORM_PLUGIN_ID.to_owned(), + Arc::new(RequestIdTransformPlugin), + ); + Self { plugins } + } + + /// Resolve a reference to the plugin that implements it. + #[must_use] + pub fn get(&self, reference: &str) -> Option> { + self.plugins + .get(reference) + .cloned() + .or_else(|| canonical_id(reference).and_then(|id| self.plugins.get(&id).cloned())) + } +} + +/// Canonical GTS id of a built-in reference, when the catalog knows it. +/// +/// `gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1` and the short +/// `apikey` both name the same plugin. A short spelling is not classified by +/// `PluginRef`, so every family is tried; the catalog names are unique across +/// families. A reference the catalog does not classify as a *bindable* built-in +/// has no canonical id. +fn canonical_id(reference: &str) -> Option { + let trimmed = reference.trim(); + [PluginKind::Auth, PluginKind::Guard, PluginKind::Transform] + .into_iter() + .find_map(|kind| match PluginRef::parse(reference) { + PluginRef::BuiltIn { + kind: declared, + name, + resolvable, + .. + } if declared == kind && resolvable => Some(kind.built_in_id(&name)), + _ => crate::domain::plugin::lookup_built_in(kind, trimmed) + .filter(|built_in| built_in.resolvable) + .map(|built_in| kind.built_in_id(built_in.name)), + }) +} + +/// 503 `link.unavailable.v1` for an auth binding the data plane cannot resolve. +/// +/// The one failure that must never degrade into a silent forward: a credential +/// the gateway cannot inject is a link that is not available, not an +/// unauthenticated request. +#[must_use] +pub fn unresolved_auth_plugin(reference: &str) -> OagwError { + OagwError::new( + OagwErrorKind::LinkUnavailable, + format!("auth plugin '{reference}' is not available in this deployment"), + ) +} + +/// 503 `plugin.not_found.v1` for a chain reference without an implementation. +#[must_use] +pub fn unresolved_plugin(reference: &str) -> OagwError { + OagwError::new( + OagwErrorKind::PluginNotFound, + format!("plugin '{reference}' cannot be resolved in this deployment"), + ) +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/request_id_transform.rs b/gears/system/oagw/oagw/src/infra/plugin/request_id_transform.rs new file mode 100644 index 0000000..c58d699 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/request_id_transform.rs @@ -0,0 +1,173 @@ +// Created: 2026-08-31 by Constructor Tech +//! `RequestIdTransformPlugin` (ADR-0002 "Built-in Plugins", DESIGN §3.2). +//! +//! `X-Request-ID` injection and propagation: a request that arrives without an +//! id leaves the gateway with a generated one, and the id the upstream sent +//! back — generated or replaced — is what the client sees. An id the caller +//! already provided is never overwritten. + +use async_trait::async_trait; +use http::HeaderValue; +use uuid::Uuid; + +use crate::error::OagwError; +use crate::infra::plugin::traits::{ + ErrorContext, RequestContext, ResponseContext, TransformPlugin, +}; + +/// GTS id of the built-in request-id transform plugin. +pub const REQUEST_ID_TRANSFORM_PLUGIN_ID: &str = + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"; + +/// Header the correlation id travels in. +pub const REQUEST_ID_HEADER: &str = "x-request-id"; + +/// Injects and propagates `X-Request-ID`. +#[derive(Debug, Default)] +pub struct RequestIdTransformPlugin; + +#[async_trait] +impl TransformPlugin for RequestIdTransformPlugin { + fn id(&self) -> &'static str { + "request_id" + } + + fn plugin_type(&self) -> &'static str { + REQUEST_ID_TRANSFORM_PLUGIN_ID + } + + async fn transform_request(&self, ctx: &mut RequestContext) -> Result<(), OagwError> { + if ctx.headers.contains_key(REQUEST_ID_HEADER) { + return Ok(()); + } + let id = Uuid::new_v4().to_string(); + let value = HeaderValue::from_str(&id) + .map_err(|_| OagwError::validation("the generated request id is not a header value"))?; + ctx.headers.insert(REQUEST_ID_HEADER, value); + Ok(()) + } + + async fn transform_response(&self, ctx: &mut ResponseContext) -> Result<(), OagwError> { + if ctx.headers.contains_key(REQUEST_ID_HEADER) { + return Ok(()); + } + // The id the upstream sent — generated, forwarded or replaced — is what + // the caller can correlate its own request against. It is read from the + // upstream response, not from the client-bound headers, because the + // response rules may have dropped it. + let Some(id) = ctx.upstream_headers.get(REQUEST_ID_HEADER).cloned() else { + return Ok(()); + }; + ctx.headers.insert(REQUEST_ID_HEADER, id); + Ok(()) + } + + async fn transform_error(&self, _ctx: &mut ErrorContext) -> Result<(), OagwError> { + // A gateway error is not produced by an upstream, so there is no + // upstream id to propagate; the problem document keeps the trace id the + // transport layer already attached. + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::infra::plugin::traits::PluginConfig; + + fn plugin() -> RequestIdTransformPlugin { + RequestIdTransformPlugin + } + + #[test] + fn the_plugin_is_the_documented_built_in() { + let transform = plugin(); + assert_eq!(transform.id(), "request_id"); + assert_eq!(transform.plugin_type(), REQUEST_ID_TRANSFORM_PLUGIN_ID); + assert_eq!( + transform.plugin_type(), + crate::domain::plugin::PluginKind::Transform.built_in_id("request_id") + ); + } + + #[tokio::test] + async fn a_request_without_an_id_gains_one() { + let mut ctx = test_request(http::HeaderMap::new()); + plugin().transform_request(&mut ctx).await.unwrap(); + let id = ctx.headers.get(REQUEST_ID_HEADER).unwrap(); + assert!(Uuid::parse_str(id.to_str().unwrap()).is_ok(), "id: {id:?}"); + } + + #[tokio::test] + async fn a_caller_provided_id_is_never_overwritten() { + let mut inbound = http::HeaderMap::new(); + inbound.insert(REQUEST_ID_HEADER, HeaderValue::from_static("caller-id")); + let mut ctx = test_request(inbound); + plugin().transform_request(&mut ctx).await.unwrap(); + assert_eq!( + ctx.headers.get(REQUEST_ID_HEADER).unwrap(), + HeaderValue::from_static("caller-id") + ); + } + + #[tokio::test] + async fn the_upstream_id_is_propagated_to_the_client() { + let mut upstream = http::HeaderMap::new(); + upstream.insert(REQUEST_ID_HEADER, HeaderValue::from_static("upstream-id")); + let mut ctx = test_response(&upstream); + plugin().transform_response(&mut ctx).await.unwrap(); + assert_eq!( + ctx.headers.get(REQUEST_ID_HEADER).unwrap(), + HeaderValue::from_static("upstream-id") + ); + } + + #[tokio::test] + async fn a_response_without_an_upstream_id_stays_without_one() { + let mut ctx = test_response(&http::HeaderMap::new()); + plugin().transform_response(&mut ctx).await.unwrap(); + assert!(ctx.headers.get(REQUEST_ID_HEADER).is_none()); + } + + #[tokio::test] + async fn an_id_the_response_already_carries_is_kept() { + let mut upstream = http::HeaderMap::new(); + upstream.insert(REQUEST_ID_HEADER, HeaderValue::from_static("upstream-id")); + let mut ctx = test_response(&upstream); + ctx.headers + .insert(REQUEST_ID_HEADER, HeaderValue::from_static("client-bound")); + plugin().transform_response(&mut ctx).await.unwrap(); + assert_eq!( + ctx.headers.get(REQUEST_ID_HEADER).unwrap(), + HeaderValue::from_static("client-bound") + ); + } + + fn test_request(inbound: http::HeaderMap) -> RequestContext { + RequestContext { + security: toolkit_security::SecurityContext::anonymous(), + upstream: crate::infra::plugin::traits::UpstreamRef { + id: Uuid::nil(), + alias: "api.vendor.com".to_owned(), + }, + method: http::Method::GET, + headers: inbound, + query: String::new(), + config: PluginConfig::empty(), + } + } + + fn test_response(upstream_headers: &http::HeaderMap) -> ResponseContext { + ResponseContext { + security: toolkit_security::SecurityContext::anonymous(), + upstream: crate::infra::plugin::traits::UpstreamRef { + id: Uuid::nil(), + alias: "api.vendor.com".to_owned(), + }, + status: http::StatusCode::OK, + headers: http::HeaderMap::new(), + upstream_headers: upstream_headers.clone(), + config: PluginConfig::empty(), + } + } +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/required_headers_guard.rs b/gears/system/oagw/oagw/src/infra/plugin/required_headers_guard.rs new file mode 100644 index 0000000..4aa2dae --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/required_headers_guard.rs @@ -0,0 +1,220 @@ +// Created: 2026-08-31 by Constructor Tech +//! `RequiredHeadersGuardPlugin` (ADR-0009). +//! +//! Presence-only enforcement of configured header names, independently in the +//! request and the response phase. Fail-open: a binding without configuration — +//! or with a configuration that is blank after splitting, trimming and dropping +//! empties — allows everything, so adding the plugin to a chain changes nothing +//! for an upstream that did not opt in. +//! +//! # Which map the two phases read (documented choice) +//! +//! The request phase reads the **outbound** headers, the set that is about to +//! be dialled. The response phase reads +//! [`ResponseContext::upstream_headers`](crate::infra::plugin::traits::ResponseContext::upstream_headers), +//! the headers the upstream actually answered with: the response transformation +//! rules have already been applied to `headers`, so a rule that drops a header +//! would otherwise turn a signed upstream response into a 502 the upstream is +//! not responsible for. ADR-0009 requires the *upstream* to have sent the +//! header, not the gateway to have kept it. +//! +//! # Status mapping (documented choice) +//! +//! ADR-0009 fixes only the HTTP status and the `error_code` of a rejection +//! (400 / `REQUIRED_HEADER_MISSING` for a request, 502 for a response). The +//! problem `type` of a rejection is taken from the DESIGN §3.3 error table, +//! which is where every gateway problem type comes from: +//! `validation.error.v1` for the request phase (`OagwErrorKind::Validation`) +//! and `protocol.error.v1` for the response phase (`OagwErrorKind:: +//! ProtocolError`). Both rejections carry the `error_code` and the +//! `missing_header` extension members. + +use async_trait::async_trait; + +use crate::error::{OagwError, OagwErrorKind}; +use crate::infra::plugin::traits::{ + GuardDecision, GuardPlugin, PluginConfig, RequestContext, ResponseContext, +}; + +/// GTS id of the built-in required-headers guard plugin. +pub const REQUIRED_HEADERS_GUARD_PLUGIN_ID: &str = + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"; + +/// `error_code` of every rejection (ADR-0009). +pub const ERROR_CODE: &str = "REQUIRED_HEADER_MISSING"; + +/// Request-phase configuration member. +const REQUEST_MEMBER: &str = "required_request_headers"; +/// Response-phase configuration member. +const RESPONSE_MEMBER: &str = "required_response_headers"; + +/// Extension member naming the header whose absence rejected the phase. +pub const MISSING_HEADER: &str = "missing_header"; + +/// Presence-only enforcement of required headers (ADR-0009). +#[derive(Debug, Default)] +pub struct RequiredHeadersGuardPlugin; + +#[async_trait] +impl GuardPlugin for RequiredHeadersGuardPlugin { + fn id(&self) -> &'static str { + "required_headers" + } + + fn plugin_type(&self) -> &'static str { + REQUIRED_HEADERS_GUARD_PLUGIN_ID + } + + async fn guard_request(&self, ctx: &RequestContext) -> Result { + Ok( + reject_first_missing(&ctx.config, REQUEST_MEMBER, &ctx.headers) + .map_or(GuardDecision::Allow, |missing| { + GuardDecision::Reject(request_rejection(&missing)) + }), + ) + } + + async fn guard_response(&self, ctx: &ResponseContext) -> Result { + // The upstream's own headers, not the client-bound set: a response rule + // that strips a header must not turn into a 502 (see the module docs). + Ok( + reject_first_missing(&ctx.config, RESPONSE_MEMBER, &ctx.upstream_headers) + .map_or(GuardDecision::Allow, |missing| { + GuardDecision::Reject(response_rejection(&missing)) + }), + ) + } +} + +/// Reject with the first required header the map does not carry. +/// +/// Only the first missing name is reported (ADR-0009), in the order the +/// configuration listed them. Header names are matched case-insensitively and +/// only for presence, never for a value. +fn reject_first_missing( + config: &PluginConfig, + member: &str, + headers: &http::HeaderMap, +) -> Option { + required(config, member).find(|name| headers.get(name.as_str()).is_none()) +} + +/// The configured header names of one phase, already normalised. +/// +/// ADR-0009: split on `,`, trim, lowercase, drop empties; absent or blank means +/// the phase is a no-op. +fn required(config: &PluginConfig, member: &str) -> impl Iterator { + config + .string(member) + .into_iter() + .flat_map(|raw| raw.split(',')) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_ascii_lowercase) +} + +/// 400 `validation.error.v1` for a request that misses a required header. +fn request_rejection(missing: &str) -> crate::infra::plugin::traits::Rejection { + crate::infra::plugin::traits::Rejection::new(missing_header_error( + OagwErrorKind::Validation, + missing, + "this request does not carry the required header", + )) +} + +/// 502 `protocol.error.v1` for an upstream response that misses a header. +fn response_rejection(missing: &str) -> crate::infra::plugin::traits::Rejection { + crate::infra::plugin::traits::Rejection::new(missing_header_error( + OagwErrorKind::ProtocolError, + missing, + "the upstream response does not carry the required header", + )) +} + +/// The problem document of a rejection, with the ADR-0009 extension members. +fn missing_header_error(kind: OagwErrorKind, missing: &str, detail: &str) -> OagwError { + OagwError::new(kind, format!("{detail} '{missing}'")).with_extension(|ext| { + ext.error_code = Some(ERROR_CODE.to_owned()); + ext.missing_header = Some(missing.to_owned()); + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn config(raw: &serde_json::Value) -> PluginConfig { + PluginConfig::new( + raw.as_object() + .cloned() + .unwrap_or_else(serde_json::Map::new), + ) + } + + fn headers(entries: &[&str]) -> http::HeaderMap { + let mut map = http::HeaderMap::new(); + for name in entries { + map.insert( + http::HeaderName::try_from(*name).unwrap_or(http::header::ACCEPT), + http::HeaderValue::from_static("x"), + ); + } + map + } + + #[test] + fn the_plugin_is_the_documented_built_in() { + let plugin = RequiredHeadersGuardPlugin; + assert_eq!(plugin.id(), "required_headers"); + assert_eq!(plugin.plugin_type(), REQUIRED_HEADERS_GUARD_PLUGIN_ID); + assert_eq!( + plugin.plugin_type(), + crate::domain::plugin::PluginKind::Guard.built_in_id("required_headers") + ); + } + + #[test] + fn the_configuration_is_split_trimmed_and_lowercased() { + let names: Vec = required( + &config(&serde_json::json!({ REQUEST_MEMBER: " X-Correlation-Id, accept ,," })), + REQUEST_MEMBER, + ) + .collect(); + assert_eq!( + names, + Vec::from(["x-correlation-id".to_owned(), "accept".to_owned()]) + ); + } + + #[test] + fn an_absent_or_blank_configuration_is_a_no_op() { + for raw in [ + serde_json::json!({}), + serde_json::json!({ REQUEST_MEMBER: " , , " }), + ] { + assert!(reject_first_missing(&config(&raw), REQUEST_MEMBER, &headers(&[])).is_none()); + } + } + + #[test] + fn only_the_first_missing_header_is_reported() { + let config = + config(&serde_json::json!({ REQUEST_MEMBER: "accept,x-tenant-id,x-signature" })); + let missing = reject_first_missing(&config, REQUEST_MEMBER, &headers(&["accept"])); + assert_eq!(missing.as_deref(), Some("x-tenant-id")); + } + + #[test] + fn header_names_are_matched_case_insensitively() { + let config = config(&serde_json::json!({ REQUEST_MEMBER: "X-Correlation-Id" })); + let present = + reject_first_missing(&config, REQUEST_MEMBER, &headers(&["x-correlation-id"])); + assert_eq!(present, None); + } + + #[test] + fn a_phase_is_independent_of_the_other() { + let config = config(&serde_json::json!({ REQUEST_MEMBER: "x-request-id" })); + assert!(reject_first_missing(&config, RESPONSE_MEMBER, &headers(&[])).is_none()); + } +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/secrets.rs b/gears/system/oagw/oagw/src/infra/plugin/secrets.rs new file mode 100644 index 0000000..023825a --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/secrets.rs @@ -0,0 +1,334 @@ +// Created: 2026-08-31 by Constructor Tech +//! Credential-store access shared by the auth plugins (DESIGN §3.2 "Secret +//! Access Control", ADR-0002, ADR-0008). +//! +//! The gateway never stores secret material: an auth binding references a +//! secret through a `cred://` URI and the data plane resolves it **at request +//! time**, as the caller's [`SecurityContext`], so `cred_store` can apply its +//! own sharing policy. The resolved value only ever lives in a +//! [`SecretString`]; the mapping of a store failure onto the DESIGN §3.3 error +//! table lives here so the two plugins cannot drift. + +use std::sync::Arc; + +use credstore_sdk::CredStoreClientV1; +use credstore_sdk::CredStoreError; +use toolkit_auth::oauth2::SecretString; + +use crate::error::{OagwError, OagwErrorKind}; + +/// Resolved secret of a `cred://` reference. +/// +/// Wrapping the store's raw bytes in [`SecretString`] is what keeps the value +/// out of `Debug`/`Display` output and zeroes it when the request is done. +#[derive(Clone)] +pub struct ResolvedSecret(SecretString); + +impl ResolvedSecret { + /// The secret material. Callers must not log or serialise it. + #[must_use] + pub fn expose(&self) -> &str { + self.0.expose() + } +} + +impl std::fmt::Debug for ResolvedSecret { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("[REDACTED]") + } +} + +/// The credential store the plugins read through. +pub type CredStore = Arc; + +/// Resolve a `cred://` reference as `ctx` and map a failure onto DESIGN §3.3. +/// +/// * `Ok(None)` and [`CredStoreError::NotFound`] are the single "not found" +/// surface of the store → 500 `secret.not_found.v1`. +/// * [`CredStoreError::AccessDenied`] is the caller not being allowed to read +/// the secret → 401 `auth.failed.v1` (DESIGN §3.2 "Secret Access Control": +/// "If accessible → return secret material. If not → … 401"). +/// * every other failure is a store outage → 503 `link.unavailable.v1`. +/// +/// # Errors +/// As listed above; the detail names the reference, never the secret. +pub async fn resolve_secret( + credstore: &CredStore, + ctx: &toolkit_security::SecurityContext, + reference: &str, +) -> Result { + let key = reference + .trim() + .strip_prefix("cred://") + .unwrap_or(reference); + let secret_ref = + credstore_sdk::SecretRef::new(key).map_err(|error| invalid_reference(reference, &error))?; + match credstore.get(ctx, &secret_ref).await { + Ok(Some(response)) => { + let value = std::str::from_utf8(response.value.as_bytes()) + .map_err(|_| not_utf8(reference))? + .to_owned(); + Ok(ResolvedSecret(SecretString::new(value))) + } + Ok(None) => Err(missing_secret(reference)), + Err(error) => Err(store_failure(reference, &error)), + } +} + +/// 500 `secret.not_found.v1` for a reference the store does not know. +fn missing_secret(reference: &str) -> OagwError { + OagwError::new( + OagwErrorKind::SecretNotFound, + format!("referenced secret '{reference}' was not found in the credential store"), + ) +} + +/// 401 `auth.failed.v1` for a reference the caller may not read. +fn denied_secret(reference: &str, error: &CredStoreError) -> OagwError { + OagwError::new( + OagwErrorKind::AuthenticationFailed, + format!("referenced secret '{reference}' is not accessible: {error}"), + ) +} + +/// 503 `link.unavailable.v1` for a credential store that cannot answer. +fn unavailable_store(reference: &str, error: &CredStoreError) -> OagwError { + OagwError::new( + OagwErrorKind::LinkUnavailable, + format!("credential store refused '{reference}': {error}"), + ) +} + +/// Map a store failure onto the error table. +fn store_failure(reference: &str, error: &CredStoreError) -> OagwError { + if error.is_not_found() { + return missing_secret(reference); + } + if error.is_permission_denied() { + return denied_secret(reference, error); + } + unavailable_store(reference, error) +} + +/// 400 `validation.error.v1` for a `cred://` reference the store rejects as a +/// reference. +fn invalid_reference(reference: &str, error: &CredStoreError) -> OagwError { + OagwError::validation(format!( + "credential reference '{reference}' is not a valid secret reference: {error}" + )) +} + +/// 500 for a secret the gateway cannot inject (binary material). +fn not_utf8(reference: &str) -> OagwError { + OagwError::new( + OagwErrorKind::Internal, + format!("referenced secret '{reference}' is not text and cannot be injected"), + ) +} + +/// In-process credential store for the unit tests of this module and of the +/// auth plugins. +/// +/// The integration tests use the SDK's own `MockCredStoreClient`; these unit +/// tests cannot, because the `test-util` feature of `credstore-sdk` is only +/// enabled for the crate's integration tests. +#[cfg(test)] +pub(crate) mod stub { + use async_trait::async_trait; + use credstore_sdk::{ + CredStoreClientV1, CredStoreError, GetSecretResponse, SecretRef, SecretValue, SharingMode, + WriteOptions, WritePrecondition, + }; + use toolkit_security::SecurityContext; + + /// Behaviour of the stub store. + #[derive(Clone, Copy)] + pub enum Behaviour { + /// Resolve every reference to this value. + Fixed(&'static str), + /// Resolve every reference to raw, non-UTF-8 bytes. + RawBytes(&'static [u8]), + /// Every reference is unknown. + Empty, + /// The store reports a not-found error instead of `Ok(None)`. + NotFound, + /// The store fails internally. + Failing, + /// The caller may not read the secret. + Denied, + } + + /// A store that answers every reference with the configured behaviour. + pub struct StubCredStore(pub Behaviour); + + fn response(value: Vec) -> GetSecretResponse { + GetSecretResponse { + value: SecretValue::new(value), + id: uuid::Uuid::nil(), + owner_tenant_id: tenant_resolver_sdk::TenantId(uuid::Uuid::nil()), + sharing: SharingMode::default(), + is_inherited: false, + version: 1, + secret_type: String::new(), + expires_at: None, + } + } + + #[async_trait] + impl CredStoreClientV1 for StubCredStore { + async fn get( + &self, + _ctx: &SecurityContext, + _key: &SecretRef, + ) -> Result, CredStoreError> { + match self.0 { + Behaviour::Fixed(value) => Ok(Some(response(Vec::from(value.as_bytes())))), + Behaviour::RawBytes(value) => Ok(Some(response(Vec::from(value)))), + Behaviour::Empty => Ok(None), + Behaviour::NotFound => Err(CredStoreError::NotFound), + Behaviour::Failing => Err(CredStoreError::Internal("stub outage".into())), + Behaviour::Denied => Err(CredStoreError::AccessDenied), + } + } + + async fn put_opts( + &self, + _ctx: &SecurityContext, + _key: &SecretRef, + _value: SecretValue, + _sharing: SharingMode, + _precondition: WritePrecondition, + _opts: WriteOptions, + ) -> Result<(), CredStoreError> { + Ok(()) + } + + async fn create_opts( + &self, + _ctx: &SecurityContext, + _key: &SecretRef, + _value: SecretValue, + _sharing: SharingMode, + _opts: WriteOptions, + ) -> Result<(), CredStoreError> { + Ok(()) + } + + async fn delete( + &self, + _ctx: &SecurityContext, + _key: &SecretRef, + _precondition: WritePrecondition, + ) -> Result<(), CredStoreError> { + Ok(()) + } + } +} + +#[cfg(test)] +mod tests { + use toolkit_security::SecurityContext; + + use super::stub::{Behaviour, StubCredStore}; + use super::{ResolvedSecret, resolve_secret}; + + /// Assert the problem type of `error`, by the GTS slug after the stem. + fn assert_problem_type(error: &crate::error::OagwError, slug: &str) { + let problem_type = error.gts_type(); + assert!( + problem_type.ends_with(slug), + "problem type '{problem_type}' is not '{slug}'" + ); + } + + fn store(behaviour: Behaviour) -> super::CredStore { + std::sync::Arc::new(StubCredStore(behaviour)) + } + + fn context() -> SecurityContext { + SecurityContext::anonymous() + } + + #[tokio::test] + async fn a_known_reference_resolves_into_a_secret_string() { + let resolved = resolve_secret( + &store(Behaviour::Fixed("k-123")), + &context(), + "cred://api-key", + ) + .await + .unwrap(); + assert_eq!(resolved.expose(), "k-123"); + } + + #[tokio::test] + async fn the_stem_of_the_reference_is_optional() { + let resolved = resolve_secret(&store(Behaviour::Fixed("k-123")), &context(), "api-key") + .await + .unwrap(); + assert_eq!(resolved.expose(), "k-123"); + } + + #[tokio::test] + async fn an_unknown_reference_is_a_secret_not_found() { + let error = resolve_secret(&store(Behaviour::Empty), &context(), "cred://api-key") + .await + .unwrap_err(); + assert_problem_type(&error, "secret.not_found.v1"); + } + + #[tokio::test] + async fn a_store_not_found_is_a_secret_not_found() { + let error = resolve_secret(&store(Behaviour::NotFound), &context(), "cred://api-key") + .await + .unwrap_err(); + assert_problem_type(&error, "secret.not_found.v1"); + } + + #[tokio::test] + async fn a_denied_reference_is_an_auth_failure() { + let error = resolve_secret(&store(Behaviour::Denied), &context(), "cred://api-key") + .await + .unwrap_err(); + assert_problem_type(&error, "auth.failed.v1"); + } + + #[tokio::test] + async fn a_store_outage_is_a_link_unavailable() { + let error = resolve_secret(&store(Behaviour::Failing), &context(), "cred://api-key") + .await + .unwrap_err(); + assert_problem_type(&error, "link.unavailable.v1"); + } + + #[tokio::test] + async fn binary_material_cannot_be_injected() { + let error = resolve_secret( + &store(Behaviour::RawBytes(&[0xff, 0xfe])), + &context(), + "cred://api-key", + ) + .await + .unwrap_err(); + assert_problem_type(&error, "internal.error.v1"); + } + + #[tokio::test] + async fn a_reference_the_store_rejects_is_a_validation_error() { + let error = resolve_secret( + &store(Behaviour::Fixed("x")), + &context(), + "cred://bad reference", + ) + .await + .unwrap_err(); + assert_problem_type(&error, "validation.error.v1"); + } + + #[test] + fn a_resolved_secret_is_never_formatted() { + let resolved = ResolvedSecret(toolkit_auth::oauth2::SecretString::new("k-123".to_owned())); + assert_eq!(format!("{resolved:?}"), "[REDACTED]"); + assert!(!format!("{resolved:?}").contains("k-123")); + } +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/traits.rs b/gears/system/oagw/oagw/src/infra/plugin/traits.rs new file mode 100644 index 0000000..085e28c --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/traits.rs @@ -0,0 +1,354 @@ +// Created: 2026-08-31 by Constructor Tech +//! Plugin contracts of ADR-0002 and the per-request context they run against. +//! +//! Three families, in execution order (DESIGN §3.2 "Plugin System"): +//! `AuthPlugin` → `GuardPlugin` → `TransformPlugin`, with the upstream call +//! between the request and the response halves of the last two. +//! +//! # Security boundary +//! +//! A context is the *surface a plugin may touch*. It deliberately exposes the +//! outbound headers and the query string but **no** request or response body, +//! and no log sink: a plugin cannot print what it is not handed. Nothing that +//! holds resolved credential material derives `Debug`; the contexts only ever +//! carry header *maps*, and their `Debug` impls redact the header values so a +//! credential injected into `ctx.headers` cannot leak through a log line or a +//! panic message (PRD `cpt-cf-oagw-fr-auth-injection`). + +use std::fmt; + +use async_trait::async_trait; +use http::{HeaderMap, HeaderName, Method, StatusCode}; +use serde_json::Value; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::error::OagwError; + +/// Contract of credential injection (ADR-0002 "Plugin Traits"). +/// +/// One `AuthPlugin` per upstream, executed once per request, before the guards. +/// The resolved credential is written into +/// [`RequestContext::headers`] (or the query string) and is never returned to +/// the caller of the trait. +#[async_trait] +pub trait AuthPlugin: Send + Sync { + /// Short name of the plugin (`noop`, `apikey`, …). + fn id(&self) -> &'static str; + + /// Full GTS id the registry is keyed by. + fn plugin_type(&self) -> &'static str; + + /// Inject the credentials of one request. + /// + /// # Errors + /// A failure of the credential store, of the credential source or of the + /// binding itself, mapped onto the DESIGN §3.3 error table. + async fn authenticate(&self, ctx: &mut RequestContext) -> Result<(), OagwError>; +} + +/// Contract of request / response policy enforcement (ADR-0002). +/// +/// A guard sees the request and the response and may reject either. Guards run +/// after auth and before the transforms. +#[async_trait] +pub trait GuardPlugin: Send + Sync { + /// Short name of the plugin. + fn id(&self) -> &'static str; + + /// Full GTS id the registry is keyed by. + fn plugin_type(&self) -> &'static str; + + /// Validate the outbound request. + /// + /// # Errors + /// Only a failure of the check itself; a *policy* rejection is + /// [`GuardDecision::Reject`], not an error. + async fn guard_request(&self, ctx: &RequestContext) -> Result; + + /// Validate the upstream response. + /// + /// # Errors + /// Only a failure of the check itself; a *policy* rejection is + /// [`GuardDecision::Reject`], not an error. + async fn guard_response(&self, ctx: &ResponseContext) -> Result; +} + +/// Contract of request / response mutation (ADR-0002). +#[async_trait] +pub trait TransformPlugin: Send + Sync { + /// Short name of the plugin. + fn id(&self) -> &'static str; + + /// Full GTS id the registry is keyed by. + fn plugin_type(&self) -> &'static str; + + /// Mutate the outbound request. + /// + /// # Errors + /// A failure of the transformation, mapped onto the DESIGN §3.3 table. + async fn transform_request(&self, ctx: &mut RequestContext) -> Result<(), OagwError>; + + /// Mutate the response before it is streamed back. + /// + /// # Errors + /// A failure of the transformation, mapped onto the DESIGN §3.3 table. + async fn transform_response(&self, ctx: &mut ResponseContext) -> Result<(), OagwError>; + + /// Mutate a gateway error before it is rendered. + /// + /// # Errors + /// A failure of the transformation, mapped onto the DESIGN §3.3 table. + async fn transform_error(&self, ctx: &mut ErrorContext) -> Result<(), OagwError>; +} + +/// Outcome of a guard phase. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GuardDecision { + /// The phase found nothing to object to. + Allow, + /// The phase rejects the request or the response. + Reject(Rejection), +} + +/// A guard rejection: the status to answer with and the problem behind it. +/// +/// The status mirrors the `OagwErrorKind` of `error`, so a caller may use +/// either. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Rejection { + /// Status the request or response is rejected with. + pub status: StatusCode, + /// Problem document the rejection is rendered as. + pub error: OagwError, +} + +impl Rejection { + /// Reject with `error`; the status follows its kind. + #[must_use] + pub fn new(error: OagwError) -> Self { + let status = StatusCode::from_u16(error.kind().status()) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + Self { status, error } + } + + /// The problem document of the rejection. + #[must_use] + pub fn into_error(self) -> OagwError { + self.error + } +} + +/// The upstream a proxied request is served by. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UpstreamRef { + /// Upstream record id. + pub id: Uuid, + /// Routing key the request was addressed with. + pub alias: String, +} + +/// Configuration of the plugin that is running. +/// +/// A view over the `config` member of a `plugins.items[]` binding, or over the +/// raw members of an `auth` binding. It is deliberately opaque: the control +/// plane never interprets plugin configuration, and a plugin reads only the +/// members it knows. +#[derive(Clone, Default)] +pub struct PluginConfig(serde_json::Map); + +impl PluginConfig { + /// Configuration over `map`. + #[must_use] + pub fn new(map: serde_json::Map) -> Self { + Self(map) + } + + /// A binding without configuration. + #[must_use] + pub fn empty() -> Self { + Self(serde_json::Map::new()) + } + + /// The raw value of a member. + #[must_use] + pub fn value(&self, name: &str) -> Option<&Value> { + self.0.get(name) + } + + /// The string value of a member, tolerating a non-string by ignoring it. + #[must_use] + pub fn string(&self, name: &str) -> Option<&str> { + self.value(name).and_then(Value::as_str) + } + + /// The member names the configuration carries. + #[must_use] + pub fn member_names(&self) -> Vec<&str> { + self.0.keys().map(String::as_str).collect() + } + + /// Whether the configuration is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } +} + +/// `Debug` that lists the member *names* only. +/// +/// Configuration members may name credentials (a `cred://` reference is an +/// identifier, but an operator may still have pasted material the write path +/// rejected); listing names only keeps the value out of every log line. +impl fmt::Debug for PluginConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("PluginConfig") + .field("members", &self.member_names()) + .finish() + } +} + +/// Surface a request-side plugin sees. +/// +/// `headers` is the **outbound** header set: the upstream header rules have +/// already been applied, so a plugin injects exactly what will be dialled +/// (DESIGN §3.2 pipeline: "Headers | Apply `upstream.headers` transformation +/// rules; plugin mutable"). +pub struct RequestContext { + /// Identity of the caller; the credential store and the token cache key + /// are derived from it. + pub security: SecurityContext, + /// Upstream the request is forwarded to. + pub upstream: UpstreamRef, + /// Request method. + pub method: Method, + /// Outbound request headers, plugin mutable. + pub headers: HeaderMap, + /// Outbound query string, plugin mutable. Empty when the request carries + /// no query. + pub query: String, + /// Configuration of the running plugin. + pub config: PluginConfig, +} + +impl RequestContext { + /// The tenant the request is served for. + #[must_use] + pub fn tenant_id(&self) -> Uuid { + self.security.subject_tenant_id() + } + + /// The authenticated subject of the request. + #[must_use] + pub fn subject_id(&self) -> Uuid { + self.security.subject_id() + } +} + +/// The identity of a request, reduced to its identifiers. +/// +/// `SecurityContext` is identity material; only the two UUIDs a log line can +/// legitimately carry are exposed. +struct Subject<'a>(&'a SecurityContext); + +impl fmt::Debug for Subject<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Subject") + .field("tenant", &self.0.subject_tenant_id()) + .field("subject", &self.0.subject_id()) + .finish() + } +} + +/// `Debug` that redacts every header value. +/// +/// A credential plugin writes the resolved secret into `headers`; the header +/// *names* stay readable so a failing test still says which phase ran. +impl fmt::Debug for RequestContext { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("RequestContext") + .field("upstream", &self.upstream) + .field("security", &Subject(&self.security)) + .field("method", &self.method) + .field("headers", &HeaderNames(&self.headers)) + .field("query_empty", &self.query.is_empty()) + .field("config", &self.config) + .finish() + } +} + +/// Surface a response-side plugin sees. +pub struct ResponseContext { + /// Identity of the caller. + pub security: SecurityContext, + /// Upstream that produced the response. + pub upstream: UpstreamRef, + /// Status the upstream answered with. + pub status: StatusCode, + /// Headers bound for the client (the upstream response rules are already + /// applied), plugin mutable. + pub headers: HeaderMap, + /// Headers the upstream answered with, read-only. A plugin that propagates + /// an upstream value reads it here, because the response rules may have + /// dropped it from `headers`. + pub upstream_headers: HeaderMap, + /// Configuration of the running plugin. + pub config: PluginConfig, +} + +impl ResponseContext { + /// The tenant the request is served for. + #[must_use] + pub fn tenant_id(&self) -> Uuid { + self.security.subject_tenant_id() + } +} + +/// `Debug` that redacts every header value. +impl fmt::Debug for ResponseContext { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ResponseContext") + .field("upstream", &self.upstream) + .field("security", &Subject(&self.security)) + .field("status", &self.status) + .field("headers", &HeaderNames(&self.headers)) + .field("upstream_headers", &HeaderNames(&self.upstream_headers)) + .field("config", &self.config) + .finish() + } +} + +/// Surface an error-side plugin sees. +pub struct ErrorContext { + /// Identity of the caller. + pub security: SecurityContext, + /// Upstream the request was addressed to. + pub upstream: UpstreamRef, + /// Problem the gateway is about to render, plugin mutable. + pub error: OagwError, + /// Configuration of the running plugin. + pub config: PluginConfig, +} + +/// `Debug` that keeps the problem detail but names no header value. +impl fmt::Debug for ErrorContext { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ErrorContext") + .field("upstream", &self.upstream) + .field("security", &Subject(&self.security)) + .field("error", &self.error) + .field("config", &self.config) + .finish() + } +} + +/// Header *names* of a map, for a redacting `Debug` impl. +struct HeaderNames<'a>(&'a HeaderMap); + +impl fmt::Debug for HeaderNames<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_list() + .entries(self.0.keys().map(HeaderName::to_string)) + .finish() + } +} diff --git a/gears/system/oagw/oagw/src/lib.rs b/gears/system/oagw/oagw/src/lib.rs index e69de29..dc91a0d 100644 --- a/gears/system/oagw/oagw/src/lib.rs +++ b/gears/system/oagw/oagw/src/lib.rs @@ -0,0 +1,46 @@ +// Created: 2026-08-31 by Constructor Tech +//! Outbound API Gateway (OAGW) — control plane and proxy data plane. +//! +//! OAGW centralises all outbound HTTP(S) traffic from gears to external +//! services. The crate is split into a **control plane** (management REST API +//! for upstreams, routes and plugins) and a **data plane** (the proxy engine +//! of [`domain::proxy`], addressed at `/oagw/v1/proxy/{alias}/...`). +//! +//! # Layering (DDD-Light) +//! +//! | Layer | Module | Responsibility | +//! |---|---|---| +//! | Transport | [`api`] | axum handlers, DTOs, `OperationBuilder` route registration, error mapping | +//! | Domain | [`domain`] | records, alias derivation, validation, store traits, proxy pipeline, plugin catalog | +//! | Wiring | [`gear`] | `#[toolkit::gear]` implementation and config loading | +//! +//! # Wire contract +//! +//! * Routes are registered **gear-relative** at `/oagw/v1/...` (the DESIGN's +//! `/api/oagw/v1/...` prefix does not apply to this deployment: the API +//! gateway's `prefix_path` is empty). +//! * Response bodies carry the **bare UUID** in `id`; `{id}` path parameters +//! accept both the bare UUID and the GTS form +//! `gts.cf.core.oagw..v1~`. +//! * Gateway errors are RFC 9457 `application/problem+json` documents with the +//! GTS `type` ids from DESIGN §3.3. Control-plane failures carry +//! `X-OAGW-Error-Source: gateway`, a forwarded upstream response +//! `X-OAGW-Error-Source: upstream` (ADR-0007). +//! +//! # Persistence +//! +//! The graded deployment provisions **no database** for this gear, so both +//! planes persist into an in-process store ([`domain::store`]) behind domain +//! traits. This is a deliberate, documented deviation from DESIGN §3.6 +//! (SeaORM): the gear declares `capabilities = [rest]` only. + +// === MODULE DEFINITION === +pub mod api; +pub mod config; +pub mod domain; +pub mod error; +pub mod gear; +pub mod infra; + +pub use error::{OagwError, OagwErrorKind, OagwResult}; +pub use gear::Oagw; diff --git a/gears/system/oagw/oagw/tests/circuit_breaker_test.rs b/gears/system/oagw/oagw/tests/circuit_breaker_test.rs new file mode 100644 index 0000000..f91b0d8 --- /dev/null +++ b/gears/system/oagw/oagw/tests/circuit_breaker_test.rs @@ -0,0 +1,789 @@ +// Created: 2026-08-31 by Constructor Tech +// @cpt-dod:cpt-cf-oagw-dod-testing-proxy-data-plane:p2 +//! The per-upstream circuit breaker (PRD `cpt-cf-oagw-nfr-high-availability`). +//! +//! The PRD asks that "circuit breaker trips within 5 failed requests in 30s +//! window" and that the refusal it answers with is a retriable `503`; DESIGN +//! §4.2 asks for a state gauge and a transition counter per `host`, DESIGN §4.3 +//! for the transitions to be logged. Every test here drives real proxy requests +//! through the data plane, because what is asserted is a *sequence*: five +//! failures, then a refusal that must not dial, then a cooldown, then a probe. +//! +//! The upstream the tests use serves **two routes behind one alias**, a failing +//! one and a healthy one. The breaker is keyed by upstream and not by route, so +//! changing what the upstream answers by moving to the other path — rather than +//! by re-defining a mock — is also what proves the two routes share one state. +//! +//! The failure windows stay at the PRD's thirty seconds, which is longer than a +//! test, so no failure the tests cause can fall out of a window on its own — +//! with one deliberate exception, the test that proves a failure *does* age out +//! and therefore needs a window short enough to age inside it. The cooldowns +//! are two seconds, because a trip is what the tests wait out and the +//! thresholds they use are smaller too. + +mod common; + +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{Context as _, Result}; +use common::{LogCapture, ProxyHarness, Reply, domain_route, domain_upstream, loopback_endpoint}; +use httpmock::prelude::{GET, MockServer}; +use oagw::config::{CircuitBreakerConfig, OagwConfig}; +use oagw::domain::model::HttpMethod; +use oagw::domain::proxy::chain::NoChain; +use opentelemetry_sdk::metrics::{ + InMemoryMetricExporter, PeriodicReader, SdkMeterProvider, + data::{AggregatedMetrics, MetricData}, +}; + +/// Alias every test routes through: the `host` label an operator reads. +const ALIAS: &str = "api.vendor.com"; +/// The route the upstream fails on. +const ROUTE_FLAKY: &str = "/v1/flaky"; +/// The route the upstream serves. +const ROUTE_OK: &str = "/v1/ok"; +/// Proxy path addressing [`ROUTE_FLAKY`]. +const FLAKY_PATH: &str = "/oagw/v1/proxy/api.vendor.com/v1/flaky"; +/// Proxy path addressing [`ROUTE_OK`]. +const OK_PATH: &str = "/oagw/v1/proxy/api.vendor.com/v1/ok"; +/// Problem type the open breaker answers with (DESIGN §3.3). +const BREAKER_OPEN: &str = "gts.cf.core.errors.err.v1~cf.oagw.circuit_breaker.open.v1"; +/// Problem type of a dial that never reached the upstream. +const LINK_UNAVAILABLE: &str = "gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1"; +/// `X-OAGW-Error-Source` of an answer the gateway generated. +const GATEWAY: &str = "gateway"; +/// `X-OAGW-Error-Source` of an answer the upstream gave itself. +const UPSTREAM: &str = "upstream"; +/// Cooldown of the tests that wait one out. +/// +/// Two seconds rather than one, so a refusal a test asserts *before* it waits +/// has comfortably more than the time a handful of loopback dials take. +const COOLDOWN: u64 = 2; +/// How long the probe's upstream withholds its answer. +/// +/// Long enough that a second request issued while the probe is still waiting is +/// comfortably inside the probe, short enough to stay inside the harness's +/// two-second head budget, so the probe is answered rather than timed out. +const PROBE_DELAY: Duration = Duration::from_millis(1_200); +/// How long a test waits out a cooldown of `secs`. +/// +/// Half a second of slack over the cooldown itself: the breaker only has to be +/// *past* its cooldown, not exactly at it. +fn lapse(secs: u64) -> Duration { + Duration::from_millis(secs * 1_000 + 500) +} + +// ── Configuration ──────────────────────────────────────────────────────── + +/// A config whose breaker trips after `threshold` failures and re-probes after +/// `cooldown_secs`. +/// +/// The failure window stays at the PRD's thirty seconds, which is longer than a +/// test: no failure the tests cause can fall out of the window on its own, so +/// every count a test asserts is one it caused. +fn config_with(enabled: bool, threshold: u32, cooldown_secs: u64) -> OagwConfig { + config_windowed(enabled, threshold, 30, cooldown_secs) +} + +/// [`config_with`] with an explicit failure window, for the test that proves a +/// failure stops counting once it ages out. +fn config_windowed( + enabled: bool, + threshold: u32, + window_secs: u64, + cooldown_secs: u64, +) -> OagwConfig { + OagwConfig { + circuit_breaker: CircuitBreakerConfig { + enabled, + failure_threshold: threshold, + failure_window_secs: window_secs, + cooldown_secs, + }, + ..common::proxy_config() + } +} + +// ── Seeding ────────────────────────────────────────────────────────────── + +/// A harness whose upstream answers `port`, with the breaker of `config`. +/// +/// Both routes of [`ALIAS`] are seeded for `GET`, so a test can flip the +/// upstream's answer by addressing the other path. +fn harness_with(port: u16, config: &OagwConfig) -> Result { + let harness = ProxyHarness::with_config_and_chain(config, Arc::new(NoChain)); + let upstream = harness.seed_upstream(domain_upstream( + harness.tenant(), + ALIAS, + vec![loopback_endpoint(port)], + true, + )); + for path in [ROUTE_FLAKY, ROUTE_OK] { + harness + .store() + .insert_route_checked(domain_route( + harness.tenant(), + upstream, + &[HttpMethod::Get], + path, + &[], + )) + .with_context(|| format!("the route {path} must seed"))?; + } + Ok(harness) +} + +/// A loopback port nothing is listening on. +fn refused_port() -> Result { + let listener = + std::net::TcpListener::bind(("127.0.0.1", 0)).context("binding a throwaway listener")?; + Ok(listener.local_addr().context("the local address")?.port()) +} + +// ── Requests ───────────────────────────────────────────────────────────── + +/// Proxy `path` and assert the source the answer came from. +async fn send(harness: &ProxyHarness, path: &str, expected_source: &str) -> Result { + let reply = harness.proxy("GET", path, &[], b"").await?; + assert_eq!( + reply.header("x-oagw-error-source"), + Some(expected_source), + "{path} was answered by the wrong side" + ); + Ok(reply) +} + +// ── Reading the recorded data points back ──────────────────────────────── + +/// Serialises the tests of this file against the process-global provider. +/// +/// `ProxyService::new` pulls its instruments from the **global** meter +/// provider, which is one slot shared by every test in the process, so **every** +/// test of this file holds the lock, not only the ones that read data points +/// back: a test that never asserts on the exporter still *writes* into it, +/// because tripping a breaker is what publishes a state point. A test that +/// installs its provider must be alone in the process from before it installs +/// it to after its last assertion. It is asynchronous because a guard of a +/// `std` mutex may not be held across an `await`. +static METER_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// The lock of [`METER_LOCK`], held for the whole of a test. +async fn meter_guard() -> tokio::sync::MutexGuard<'static, ()> { + METER_LOCK.lock().await +} + +/// Install an in-memory meter provider as the OpenTelemetry global. +/// +/// The returned provider must outlive the assertions: dropping it shuts the +/// reader down, and the data points of a shut-down reader are gone. +fn install_meter_provider() -> (SdkMeterProvider, InMemoryMetricExporter) { + let exporter = InMemoryMetricExporter::default(); + let provider = SdkMeterProvider::builder() + .with_reader(PeriodicReader::builder(exporter.clone()).build()) + .build(); + opentelemetry::global::set_meter_provider(provider.clone()); + (provider, exporter) +} + +/// Whether `attributes` is exactly `expected`, name and value. +fn attributes_match(attributes: &[opentelemetry::KeyValue], expected: &[(&str, &str)]) -> bool { + attributes.len() == expected.len() + && expected.iter().all(|(name, value)| { + attributes + .iter() + .any(|pair| pair.key.as_str() == *name && pair.value.to_string() == *value) + }) +} + +/// Value of the `u64` data points of `name` whose attributes are exactly +/// `expected`. +/// +/// The exporter keeps **every** collection it made and a counter is cumulative, +/// so each collection repeats the total the reader had gathered up to then: +/// summing the points would count one transition once per collection. The +/// collections are therefore walked backwards and the newest point is the +/// total the counter carries now. +fn counter_value(exporter: &InMemoryMetricExporter, name: &str, expected: &[(&str, &str)]) -> u64 { + let collected = exporter.get_finished_metrics().unwrap_or_default(); + let metrics: Vec<_> = metrics_of(&collected, name).collect(); + for metric in metrics.iter().rev() { + if let AggregatedMetrics::U64(MetricData::Sum(sum)) = metric.data() { + for point in sum.data_points() { + let attributes: Vec<_> = point.attributes().cloned().collect(); + if attributes_match(&attributes, expected) { + return point.value(); + } + } + } + } + 0 +} + +/// Value of the `u64` gauge data point of `name` whose attributes are exactly +/// `expected`. +/// +/// The state gauge is a gauge and not a counter, so it is read through this +/// rather than through [`counter_value`]: a series it never published must read +/// as *absent*, not as zero, and a sum of the collections would read as +/// nonsense. The exporter keeps **every** collection it made, and each one +/// carries the gauge as it was at that moment, so the collections are walked +/// backwards and the newest point is the state the upstream is in now. +fn gauge_value( + exporter: &InMemoryMetricExporter, + name: &str, + expected: &[(&str, &str)], +) -> Option { + let collected = exporter.get_finished_metrics().unwrap_or_default(); + let metrics: Vec<_> = metrics_of(&collected, name).collect(); + for metric in metrics.iter().rev() { + if let AggregatedMetrics::U64(MetricData::Gauge(gauge)) = metric.data() { + for point in gauge.data_points() { + let attributes: Vec<_> = point.attributes().cloned().collect(); + if attributes_match(&attributes, expected) { + return Some(point.value()); + } + } + } + } + None +} + +/// Every metric of `collected` named `name`. +fn metrics_of<'a>( + collected: &'a [opentelemetry_sdk::metrics::data::ResourceMetrics], + name: &'a str, +) -> impl Iterator + 'a { + collected + .iter() + .flat_map(opentelemetry_sdk::metrics::data::ResourceMetrics::scope_metrics) + .flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics) + .filter(move |metric| metric.name() == name) +} + +/// Whether the exporter recorded `name` at all. +/// +/// The state gauge has no zero to fall back on: an upstream whose breaker never +/// moved has no series, and only "the metric is not there" says that honestly. +fn has_metric(exporter: &InMemoryMetricExporter, name: &str) -> bool { + let collected = exporter.get_finished_metrics().unwrap_or_default(); + metrics_of(&collected, name).next().is_some() +} + +// ── Tripping ───────────────────────────────────────────────────────────── + +/// Five consecutive upstream failures trip the breaker, and the request behind +/// them is refused without the upstream being asked again. +#[tokio::test] +async fn five_failures_trip_the_breaker_and_the_next_request_is_refused_without_a_dial() +-> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let flaky = server.mock(|when, then| { + when.method(GET).path(ROUTE_FLAKY); + then.status(503); + }); + let harness = harness_with(server.port(), &config_with(true, 5, 5))?; + + for _ in 0..5 { + let reply = send(&harness, FLAKY_PATH, UPSTREAM).await?; + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + } + assert_eq!(flaky.calls(), 5, "every failure was a real dial"); + + let refused = send(&harness, FLAKY_PATH, GATEWAY).await?; + assert_eq!(refused.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(refused.problem_type().as_deref(), Some(BREAKER_OPEN)); + assert_eq!(flaky.calls(), 5, "the refusal dialled nothing"); + Ok(()) +} + +/// The refusal is the gateway's own: a `503` problem document that names how +/// long the client has to wait, not the upstream's answer. +#[tokio::test] +async fn the_refusal_is_a_gateway_problem_that_names_the_cooldown() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let flaky = server.mock(|when, then| { + when.method(GET).path(ROUTE_FLAKY); + then.status(503); + }); + let harness = harness_with(server.port(), &config_with(true, 1, 10))?; + + let first = send(&harness, FLAKY_PATH, UPSTREAM).await?; + assert_eq!( + first.problem_type().as_deref(), + None, + "a passthrough answer" + ); + + let refused = send(&harness, FLAKY_PATH, GATEWAY).await?; + assert_eq!(refused.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(refused.problem_type().as_deref(), Some(BREAKER_OPEN)); + assert!( + refused + .header("content-type") + .unwrap_or_default() + .starts_with("application/problem+json"), + "the refusal is a problem document" + ); + let retry_after = refused + .header("retry-after") + .context("the refusal names the cooldown")? + .parse::() + .context("the Retry-After is a whole number of seconds")?; + assert!( + (1..=10).contains(&retry_after), + "the guidance is the cooldown still to run, in [1, 10]: {retry_after}" + ); + assert_eq!(flaky.calls(), 1); + Ok(()) +} + +/// A 4xx is the client's error and the upstream answered it, so it is not the +/// upstream's health and never trips the breaker. +#[tokio::test] +async fn a_client_error_is_not_the_upstreams_health() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let flaky = server.mock(|when, then| { + when.method(GET).path(ROUTE_FLAKY); + then.status(404); + }); + let harness = harness_with(server.port(), &config_with(true, 2, 5))?; + + for _ in 0..3 { + let reply = send(&harness, FLAKY_PATH, UPSTREAM).await?; + assert_eq!(reply.status, axum::http::StatusCode::NOT_FOUND); + } + assert_eq!(flaky.calls(), 3, "the third request was still dialled"); + Ok(()) +} + +/// A success does not empty the window. +/// +/// The window is what forgets, and only time does: an upstream that answers +/// 200 on its cheap requests and 503 on its expensive ones is exactly the +/// partial failure the breaker exists to stop, so a healthy answer in between +/// leaves the failures it already owed in place. +#[tokio::test] +async fn a_success_does_not_empty_the_window() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let flaky = server.mock(|when, then| { + when.method(GET).path(ROUTE_FLAKY); + then.status(503); + }); + let ok = server.mock(|when, then| { + when.method(GET).path(ROUTE_OK); + then.status(200); + }); + let harness = harness_with(server.port(), &config_with(true, 2, 5))?; + + let failure = send(&harness, FLAKY_PATH, UPSTREAM).await?; + assert_eq!(failure.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + let recovered = send(&harness, OK_PATH, UPSTREAM).await?; + assert_eq!(recovered.status, axum::http::StatusCode::OK); + + // The success is still in the window's past, so this failure is the second + // the threshold asks for. It trips the breaker *after* the upstream has + // answered it, which is why this request is a passthrough and the next one + // is a refusal. + let second = send(&harness, FLAKY_PATH, UPSTREAM).await?; + assert_eq!(second.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + let refused = send(&harness, FLAKY_PATH, GATEWAY).await?; + assert_eq!(refused.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(refused.problem_type().as_deref(), Some(BREAKER_OPEN)); + assert_eq!(flaky.calls(), 2, "the success did not buy a fresh count"); + assert_eq!(ok.calls(), 1); + Ok(()) +} + +/// A failure stops counting once it has aged out of the window. +/// +/// The window is one second here rather than the PRD's thirty, so a failure can +/// be watched leaving it: after the lapse the upstream is dialled again, which +/// it would not be if the first failure were still being counted. +#[tokio::test] +async fn a_failure_stops_counting_once_it_has_aged_out_of_the_window() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let flaky = server.mock(|when, then| { + when.method(GET).path(ROUTE_FLAKY); + then.status(503); + }); + let harness = harness_with(server.port(), &config_windowed(true, 2, 1, 5))?; + + let first = send(&harness, FLAKY_PATH, UPSTREAM).await?; + assert_eq!(first.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + tokio::time::sleep(lapse(1)).await; + + // The first failure is older than the window, so this second one is the + // only one the breaker is counting and the upstream is dialled once more. + let second = send(&harness, FLAKY_PATH, UPSTREAM).await?; + assert_eq!(second.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(flaky.calls(), 2, "the aged-out failure did not trip it"); + + let third = send(&harness, FLAKY_PATH, UPSTREAM).await?; + assert_eq!(third.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + let refused = send(&harness, FLAKY_PATH, GATEWAY).await?; + assert_eq!(refused.problem_type().as_deref(), Some(BREAKER_OPEN)); + assert_eq!(flaky.calls(), 3, "the two live failures tripped it"); + Ok(()) +} + +/// A dial failure — an upstream nothing is listening on — is a health failure +/// like a 5xx is. +#[tokio::test] +async fn a_dial_failure_counts_as_a_health_failure() -> Result<()> { + let _meter = meter_guard().await; + let port = refused_port()?; + let harness = harness_with(port, &config_with(true, 1, 5))?; + + let failed = send(&harness, FLAKY_PATH, GATEWAY).await?; + assert_eq!(failed.problem_type().as_deref(), Some(LINK_UNAVAILABLE)); + + let refused = send(&harness, FLAKY_PATH, GATEWAY).await?; + assert_eq!(refused.problem_type().as_deref(), Some(BREAKER_OPEN)); + Ok(()) +} + +// ── Half-open ──────────────────────────────────────────────────────────── + +/// Once the cooldown has run out the breaker admits one probe, and a probe that +/// succeeds closes it: the requests behind it are dialled again. +#[tokio::test] +async fn after_the_cooldown_a_successful_probe_closes_the_breaker() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let _flaky = server.mock(|when, then| { + when.method(GET).path(ROUTE_FLAKY); + then.status(503); + }); + let ok = server.mock(|when, then| { + when.method(GET).path(ROUTE_OK); + then.status(200); + }); + let harness = harness_with(server.port(), &config_with(true, 1, COOLDOWN))?; + + send(&harness, FLAKY_PATH, UPSTREAM).await?; + send(&harness, OK_PATH, GATEWAY).await?; + assert_eq!(ok.calls(), 0, "the open breaker refused without dialling"); + + tokio::time::sleep(lapse(COOLDOWN)).await; + let probe = send(&harness, OK_PATH, UPSTREAM).await?; + assert_eq!(probe.status, axum::http::StatusCode::OK); + let next = send(&harness, OK_PATH, UPSTREAM).await?; + assert_eq!(next.status, axum::http::StatusCode::OK); + assert_eq!( + ok.calls(), + 2, + "the breaker closed: normal requests are dialled" + ); + Ok(()) +} + +/// A probe that fails re-opens the breaker: the next request is refused again, +/// on the other route too, because the breaker is per upstream. +#[tokio::test] +async fn after_the_cooldown_a_failed_probe_reopens_the_breaker() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let flaky = server.mock(|when, then| { + when.method(GET).path(ROUTE_FLAKY); + then.status(503); + }); + let ok = server.mock(|when, then| { + when.method(GET).path(ROUTE_OK); + then.status(200); + }); + let harness = harness_with(server.port(), &config_with(true, 1, COOLDOWN))?; + + send(&harness, FLAKY_PATH, UPSTREAM).await?; + send(&harness, FLAKY_PATH, GATEWAY).await?; + tokio::time::sleep(lapse(COOLDOWN)).await; + + let probe = send(&harness, FLAKY_PATH, UPSTREAM).await?; + assert_eq!(probe.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(flaky.calls(), 2, "the probe was dialled"); + + let refused = send(&harness, OK_PATH, GATEWAY).await?; + assert_eq!(refused.problem_type().as_deref(), Some(BREAKER_OPEN)); + assert_eq!( + ok.calls(), + 0, + "the other route is refused by the same breaker" + ); + Ok(()) +} + +/// A request that arrives behind an in-flight probe is refused. +/// +/// Half-open admits the probe and nobody else, so the second request of this +/// test — issued only once the mock server has *received* the probe, whose +/// answer it withholds for [`PROBE_DELAY`] — is answered by the gateway while +/// the probe is still waiting. The probe itself closes the breaker afterwards, +/// which is what proves the refusal was the half-open slot and not an open +/// breaker that happened to be there anyway. +#[tokio::test] +async fn a_request_behind_an_in_flight_probe_is_refused() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let flaky = server.mock(|when, then| { + when.method(GET).path(ROUTE_FLAKY); + then.status(503); + }); + let slow = server.mock(|when, then| { + when.method(GET).path(ROUTE_OK); + then.status(200).delay(PROBE_DELAY); + }); + let harness = harness_with(server.port(), &config_with(true, 1, COOLDOWN))?; + + send(&harness, FLAKY_PATH, UPSTREAM).await?; + send(&harness, OK_PATH, GATEWAY).await?; + tokio::time::sleep(lapse(COOLDOWN)).await; + + // The probe and the refusal are issued together, and the refusal starts + // only once the upstream is holding the probe's unanswered request. + let wait_for_the_probe = async { + for _ in 0..100 { + if slow.calls_async().await > 0 { + return Ok(()); + } + tokio::time::sleep(Duration::from_millis(20)).await; + } + Err(anyhow::anyhow!("the probe never reached the upstream")) + }; + let refused_behind_it = async { + wait_for_the_probe.await?; + send(&harness, FLAKY_PATH, GATEWAY).await + }; + let (probe, refused) = tokio::join!(send(&harness, OK_PATH, UPSTREAM), refused_behind_it); + + let probe = probe?; + assert_eq!(probe.status, axum::http::StatusCode::OK); + assert_eq!(slow.calls(), 1, "the probe was dialled exactly once"); + let refused = refused?; + assert_eq!(refused.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(refused.problem_type().as_deref(), Some(BREAKER_OPEN)); + assert_eq!(flaky.calls(), 1, "the refusal dialled nothing"); + + let after = send(&harness, OK_PATH, UPSTREAM).await?; + assert_eq!(after.status, axum::http::StatusCode::OK); + assert_eq!(slow.calls(), 2, "the probe closed the breaker behind it"); + Ok(()) +} + +/// A deployment that turns the breaker off is dialled every time, whatever the +/// upstream answers. +#[tokio::test] +async fn a_disabled_breaker_never_trips() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let flaky = server.mock(|when, then| { + when.method(GET).path(ROUTE_FLAKY); + then.status(503); + }); + let harness = harness_with(server.port(), &config_with(false, 1, 1))?; + + for _ in 0..5 { + let reply = send(&harness, FLAKY_PATH, UPSTREAM).await?; + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + } + assert_eq!(flaky.calls(), 5, "every request was dialled"); + Ok(()) +} + +// ── Telemetry ──────────────────────────────────────────────────────────── + +/// The state gauge reads the state an upstream's breaker is in and the +/// transition counter records each transition with both states, and a +/// transition is logged at `WARN` with the alias and the two states. +#[tokio::test] +async fn the_state_gauge_and_the_transition_counter_report_each_transition() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let _flaky = server.mock(|when, then| { + when.method(GET).path(ROUTE_FLAKY); + then.status(503); + }); + let _ok = server.mock(|when, then| { + when.method(GET).path(ROUTE_OK); + then.status(200); + }); + let capture = LogCapture::default(); + let _subscriber = tracing::subscriber::set_default(capture.clone()); + let (provider, exporter, harness) = { + let (provider, exporter) = install_meter_provider(); + let harness = harness_with(server.port(), &config_with(true, 1, COOLDOWN))?; + (provider, exporter, harness) + }; + + send(&harness, FLAKY_PATH, UPSTREAM).await?; + provider.force_flush().context("the meter must flush")?; + let host = [("host", ALIAS)]; + assert_eq!( + gauge_value(&exporter, "oagw_circuit_breaker_state", &host), + Some(2), + "the breaker is open, the largest value an operator alerts on" + ); + assert_eq!( + counter_value( + &exporter, + "oagw_circuit_breaker_transitions_total", + &[ + ("host", ALIAS), + ("from_state", "closed"), + ("to_state", "open") + ], + ), + 1 + ); + let opened = capture + .lines() + .into_iter() + .find(|line| line.contains("circuit breaker state changed")) + .context("the transition was not logged")?; + assert!( + opened.contains("[WARN]"), + "an open breaker is a warning: {opened}" + ); + assert!( + opened.contains(&format!("host={ALIAS}")), + "it names the alias: {opened}" + ); + assert!(opened.contains("from_state=closed"), "{opened}"); + assert!(opened.contains("to_state=open"), "{opened}"); + + tokio::time::sleep(lapse(COOLDOWN)).await; + send(&harness, OK_PATH, UPSTREAM).await?; + provider.force_flush().context("the meter must flush")?; + assert_eq!( + gauge_value(&exporter, "oagw_circuit_breaker_state", &host), + Some(0), + "the probe closed the breaker" + ); + assert_eq!( + counter_value( + &exporter, + "oagw_circuit_breaker_transitions_total", + &[ + ("host", ALIAS), + ("from_state", "open"), + ("to_state", "half_open") + ], + ), + 1 + ); + assert_eq!( + counter_value( + &exporter, + "oagw_circuit_breaker_transitions_total", + &[ + ("host", ALIAS), + ("from_state", "half_open"), + ("to_state", "closed") + ], + ), + 1 + ); + assert_eq!( + counter_value( + &exporter, + "oagw_circuit_breaker_transitions_total", + &[ + ("host", ALIAS), + ("from_state", "closed"), + ("to_state", "open") + ], + ), + 1, + "the trip was not counted twice" + ); + Ok(()) +} + +/// A breaker that never moved publishes no series at all, so an operator does +/// not read a zero as "this upstream tripped and recovered". +#[tokio::test] +async fn a_breaker_that_never_moved_publishes_no_series() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let _ok = server.mock(|when, then| { + when.method(GET).path(ROUTE_OK); + then.status(200); + }); + let (provider, exporter, harness) = { + let (provider, exporter) = install_meter_provider(); + let harness = harness_with(server.port(), &config_with(true, 5, 5))?; + (provider, exporter, harness) + }; + + let reply = send(&harness, OK_PATH, UPSTREAM).await?; + assert_eq!(reply.status, axum::http::StatusCode::OK); + provider.force_flush().context("the meter must flush")?; + + assert_eq!( + gauge_value(&exporter, "oagw_circuit_breaker_state", &[("host", ALIAS)]), + None, + "no state was published for a breaker that stayed closed" + ); + assert!( + !has_metric(&exporter, "oagw_circuit_breaker_transitions_total"), + "a breaker that never moved recorded no transition" + ); + Ok(()) +} + +/// The refusal is measured like any other request the data plane answered: it +/// is counted in `oagw_requests_total` and marked in `oagw_errors_total` under +/// its own problem type, next to the failure that tripped the breaker. +#[tokio::test] +async fn the_refusal_is_counted_in_the_request_and_error_families() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let _flaky = server.mock(|when, then| { + when.method(GET).path(ROUTE_FLAKY); + then.status(503); + }); + let (provider, exporter, harness) = { + let (provider, exporter) = install_meter_provider(); + let harness = harness_with(server.port(), &config_with(true, 1, 5))?; + (provider, exporter, harness) + }; + + send(&harness, FLAKY_PATH, UPSTREAM).await?; + send(&harness, FLAKY_PATH, GATEWAY).await?; + provider.force_flush().context("the meter must flush")?; + + // Both requests were answered with a 503, the dialled one with the + // upstream's and the refused one with the gateway's: the request family has + // no label for who answered, which is what `oagw_errors_total` is for. + assert_eq!( + counter_value( + &exporter, + "oagw_requests_total", + &[ + ("host", ALIAS), + ("http.request.method", "GET"), + ("http.route", ROUTE_FLAKY), + ("http.response.status_code", "503"), + ], + ), + 2 + ); + assert_eq!( + counter_value( + &exporter, + "oagw_errors_total", + &[ + ("host", ALIAS), + ("http.route", ROUTE_FLAKY), + ("error_type", BREAKER_OPEN), + ], + ), + 1, + "only the refusal is a gateway failure; the upstream 503 is a passthrough" + ); + Ok(()) +} diff --git a/gears/system/oagw/oagw/tests/common/mod.rs b/gears/system/oagw/oagw/tests/common/mod.rs new file mode 100644 index 0000000..033b491 --- /dev/null +++ b/gears/system/oagw/oagw/tests/common/mod.rs @@ -0,0 +1,991 @@ +// Created: 2026-08-31 by Constructor Tech +//! Shared harness for the OAGW management-API and proxy integration tests. +//! +//! Builds the gear's router exactly as `RestApiCapability::register_rest` +//! does (a `NoopOpenApiRegistry` stands in for the utoipa collector) and sends +//! requests with `tower::ServiceExt::oneshot`, injecting the +//! `SecurityContext` the tenant-resolver middleware would have produced. +//! [`ProxyHarness`] wires the proxy data plane onto the same router, sharing +//! the store between the control plane and the data plane exactly as +//! `Gear::init` does. + +#![allow(dead_code)] + +use std::fmt::Write as _; +use std::sync::Arc; + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use anyhow::Context; +use axum::Router; +use axum::body::Body; +use axum::http::{HeaderMap, Request, StatusCode}; +use http_body_util::BodyExt; +use oagw::config::{OagwConfig, SsrfPolicy}; +use oagw::domain::lifecycle::UpstreamRemoval; +use oagw::domain::proxy::chain::{NoChain, TenantChain}; +use oagw::domain::proxy::service::ProxyService; +use oagw::domain::service::OagwService; +use oagw::domain::store::{InMemoryStore, Store}; +use oagw::domain::validation::ValidationPolicy; +use toolkit::api::OpenApiRegistry; +use toolkit::api::operation_builder::OperationSpec; +use toolkit_security::SecurityContext; +use tower::ServiceExt; +use uuid::Uuid; + +/// Canonical GTS id of the HTTP protocol (upstream schema `protocol`). +pub const PROTOCOL_HTTP: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +/// OAGW error-type prefix (DESIGN §3.3). +pub const ERR_PREFIX: &str = "gts.cf.core.errors.err.v1~cf.oagw."; + +/// GTS prefix of a custom-plugin instance id. +pub const TRANSFORM_PLUGIN_STEM: &str = "gts.cf.core.oagw.transform_plugin.v1~"; + +/// GTS prefix of a custom auth-plugin instance id. +pub const AUTH_PLUGIN_STEM: &str = "gts.cf.core.oagw.auth_plugin.v1~"; + +/// Canonical GTS id of the built-in `apikey` auth plugin. +pub const AUTH_APIKEY: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1"; + +// ── OpenAPI stand-in ───────────────────────────────────────────────────── + +/// Registry that discards every operation; only route wiring is exercised. +pub struct NoopOpenApiRegistry; + +impl OpenApiRegistry for NoopOpenApiRegistry { + fn register_operation(&self, _spec: &OperationSpec) {} + + fn ensure_schema_raw( + &self, + name: &str, + _schemas: Vec<( + String, + utoipa::openapi::RefOr, + )>, + ) -> String { + name.to_owned() + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +// ── Harness ────────────────────────────────────────────────────────────── + +/// A built router plus the helpers the tests need to drive it. +pub struct Harness { + router: Router, +} + +impl Harness { + /// Router with the graded configuration: plaintext upstreams allowed. + #[must_use] + pub fn new() -> Self { + Self::with_policy(|policy| { + policy.allow_http_upstream = true; + }) + } + + /// Router with a mutated policy, for tests that need the strict baseline. + #[must_use] + pub fn with_policy(mutate: impl FnOnce(&mut ValidationPolicy)) -> Self { + let config = OagwConfig::default(); + let mut policy = config.validation_policy(); + mutate(&mut policy); + let service = OagwService::new(policy, InMemoryStore::new()); + let openapi = NoopOpenApiRegistry; + let router = oagw::api::routes::register_routes(Router::new(), &openapi, service); + Self { router } + } + + /// Send a JSON request as `tenant_id` and collect status, headers, body. + pub async fn call( + &self, + method: &str, + uri: &str, + tenant_id: Uuid, + payload: Option, + ) -> anyhow::Result { + send_json(&self.router, method, uri, tenant_id, payload, &[]).await + } + + /// Send a request with extra headers (used for the trace-id middleware). + pub async fn call_with_headers( + &self, + method: &str, + uri: &str, + tenant_id: Uuid, + payload: Option, + headers: &[(&str, &str)], + ) -> anyhow::Result { + send_json(&self.router, method, uri, tenant_id, payload, headers).await + } +} + +/// Send a JSON management request and collect the reply. +async fn send_json( + router: &Router, + method: &str, + uri: &str, + tenant_id: Uuid, + payload: Option, + headers: &[(&str, &str)], +) -> anyhow::Result { + let mut explicit = Vec::from(headers); + let bytes = match &payload { + Some(value) => { + explicit.push(("content-type", "application/json")); + serde_json::to_vec(value)? + } + None => Vec::new(), + }; + send_raw(router, method, uri, tenant_id, &explicit, bytes).await +} + +/// Send a request with a raw body and collect status, headers and body. +async fn send_raw( + router: &Router, + method: &str, + uri: &str, + tenant_id: Uuid, + headers: &[(&str, &str)], + body: Vec, +) -> anyhow::Result { + let mut builder = Request::builder().method(method).uri(uri); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + builder = builder.header("content-length", body.len().to_string()); + let mut request = builder + .body(Body::from(body)) + .context("building the request")?; + request + .extensions_mut() + .insert(security_context(tenant_id)?); + let response = router.clone().oneshot(request).await?; + Reply::from_response(response).await +} + +/// Send a proxy request and return the raw response, body included. +/// +/// Unlike [`send_raw`] the body is not collected: the streaming tests need to +/// read the frames as they arrive. +async fn send_stream( + router: &Router, + method: &str, + uri: &str, + tenant_id: Uuid, + headers: &[(&str, &str)], +) -> anyhow::Result> { + let mut builder = Request::builder().method(method).uri(uri); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let mut request = builder + .body(Body::empty()) + .context("building the request")?; + request + .extensions_mut() + .insert(security_context(tenant_id)?); + Ok(router.clone().oneshot(request).await?) +} + +/// Status, headers and decoded body of one call. +pub struct Reply { + /// Response status. + pub status: StatusCode, + /// Response headers. + pub headers: HeaderMap, + /// Parsed JSON body (`Value::Null` when the body is empty). + pub json: serde_json::Value, + /// Raw body text. + pub text: String, +} + +impl Reply { + async fn from_response(response: axum::http::Response) -> anyhow::Result { + let (parts, body) = response.into_parts(); + let bytes = body.collect().await?.to_bytes(); + let text = String::from_utf8_lossy(&bytes).to_string(); + let json = if text.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_str(&text).unwrap_or(serde_json::Value::Null) + }; + Ok(Self { + status: parts.status, + headers: parts.headers, + json, + text, + }) + } + + /// `application/problem+json` member lookup. + pub fn problem_field(&self, name: &str) -> Option<&str> { + self.json.get(name).and_then(serde_json::Value::as_str) + } + + /// The `type` member of a problem body. + pub fn problem_type(&self) -> Option { + self.problem_field("type").map(str::to_owned) + } + + /// Visible value of a response header. + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + self.headers.get(name).and_then(|value| value.to_str().ok()) + } +} + +// ── Payload helpers ────────────────────────────────────────────────────── + +/// Full `type` member of a problem body for a DESIGN §3.3 error slug. +pub fn problem_type(slug: &str) -> String { + format!("{ERR_PREFIX}{slug}") +} + +/// An `endpoint` object of the upstream schema. +pub fn endpoint(scheme: &str, host: &str, port: u16) -> serde_json::Value { + serde_json::json!({ "scheme": scheme, "host": host, "port": port }) +} + +/// An upstream creation payload with an `https` endpoint pool. +pub fn https_upstream(host: &str, port: u16) -> serde_json::Value { + serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", host, port)] } + }) +} + +/// An upstream creation payload over an IP pool (alias must be explicit). +pub fn ip_upstream(alias: Option<&str>) -> serde_json::Value { + let mut payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [ + endpoint("https", "10.0.1.1", 443), + endpoint("https", "10.0.1.2", 443), + ] } + }); + if let Some(alias) = alias { + payload["alias"] = serde_json::Value::String(alias.to_owned()); + } + payload +} + +/// An HTTP route-match rule. +pub fn http_match(methods: &[&str], path: &str) -> serde_json::Value { + serde_json::json!({ "http": { "methods": methods, "path": path } }) +} + +/// A route creation payload bound to `upstream_id`. +pub fn route_payload(upstream_id: Uuid, methods: &[&str], path: &str) -> serde_json::Value { + serde_json::json!({ + "upstream_id": upstream_id.to_string(), + "match": http_match(methods, path) + }) +} + +/// A custom-plugin definition (ADR-0002 appendix A). +pub fn plugin_payload(name: &str, source: &str) -> serde_json::Value { + serde_json::json!({ + "name": name, + "plugin_type": "transform", + "source_code": source, + "config": { "header": "x-trace" } + }) +} + +/// GTS form of a resource id: `gts.cf.core.oagw..v1~`. +pub fn gts_resource_id(kind: &str, id: Uuid) -> String { + format!("gts.cf.core.oagw.{kind}.v1~{id}") +} + +/// `SecurityContext` the tenant-resolver middleware would inject. +pub fn security_context(tenant_id: Uuid) -> anyhow::Result { + Ok(SecurityContext::builder() + .subject_id(Uuid::now_v7()) + .subject_tenant_id(tenant_id) + .build()?) +} + +// ── Proxy harness ──────────────────────────────────────────────────────── + +/// Router that carries the management API **and** the proxy data plane. +/// +/// Both sub-routers share one store, the way `Gear::init` wires them. +pub struct ProxyHarness { + router: Router, + store: Arc, + /// Tenant every request of the harness is issued from. + tenant: Uuid, +} + +/// Configuration of the graded deployment: plaintext upstreams allowed. +/// +/// `proxy_timeout_secs` is lowered so a timeout test does not wait 30 s. The +/// SSRF switch is off because the deployment the gear ships in turns it off, +/// and the harness endpoints are loopback addresses the policy would refuse. +pub fn proxy_config() -> OagwConfig { + OagwConfig { + proxy_timeout_secs: 2, + allow_http_upstream: true, + ssrf_policy: SsrfPolicy { + enabled: false, + allowed_hosts: Vec::new(), + denied_hosts: Vec::new(), + }, + ..OagwConfig::default() + } +} + +impl ProxyHarness { + /// Harness with the graded configuration and no tenant chain. + #[must_use] + pub fn new() -> Self { + Self::with_config_and_chain(&proxy_config(), Arc::new(NoChain)) + } + + /// Harness for `config` with an explicit tenant chain. + #[must_use] + pub fn with_config_and_chain(config: &OagwConfig, chain: Arc) -> Self { + Self::with_credential_store(config, chain, None) + } + + /// Harness for `config` with an explicit tenant chain and credential store. + /// + /// `None` is the degraded deployment: only the auth plugins that need no + /// credential store are registered. + #[must_use] + pub fn with_credential_store( + config: &OagwConfig, + chain: Arc, + credstore: Option>, + ) -> Self { + let store: Arc = InMemoryStore::new(); + let service = OagwService::new(config.validation_policy(), Arc::clone(&store)); + let client = ProxyService::build_client(config) + .unwrap_or_else(|error| panic!("outbound client must build in tests: {error}")); + let proxy = ProxyService::new(Arc::clone(&store), chain, client, credstore, config); + let openapi = NoopOpenApiRegistry; + // `Gear::init` also makes the data plane an observer of the control + // plane's cascade deletions, so the harness wires the same seam: a + // deleted upstream must leave no round-robin cursor and no token + // bucket behind. + service.observe_removals(Arc::clone(&proxy) as Arc); + let router = oagw::api::routes::register_routes(Router::new(), &openapi, service); + let router = oagw::api::routes::register_data_plane(router, &openapi, proxy); + Self { + router, + store, + tenant: Uuid::now_v7(), + } + } + + /// The store both sub-routers share, for direct seeding. + #[must_use] + pub fn store(&self) -> &Arc { + &self.store + } + + /// The tenant the harness acts as. + #[must_use] + pub fn tenant(&self) -> Uuid { + self.tenant + } + + /// The router of the harness, for the tests that need a real listener: + /// a handshake cannot be completed over an in-process `oneshot`. + pub fn router(&self) -> &Router { + &self.router + } + + /// Insert an upstream record bypassing the write-path validation. + /// + /// Used for the fail-closed tests, which need a record the management API + /// would have rejected. + pub fn seed_upstream(&self, upstream: oagw::domain::model::Upstream) -> Uuid { + let id = upstream.id; + self.store + .insert_upstream(upstream) + .unwrap_or_else(|error| panic!("upstream must seed: {error}")); + id + } + + /// Send a proxy request with raw headers and body bytes. + pub async fn proxy( + &self, + method: &str, + uri: &str, + headers: &[(&str, &str)], + body: &[u8], + ) -> anyhow::Result { + send_raw( + &self.router, + method, + uri, + self.tenant, + headers, + Vec::from(body), + ) + .await + } + + /// Send a proxy request as an identity the test fixes. + /// + /// The token cache of the credential-injection plugins is keyed by the + /// subject, so the cached-exchange tests need one caller across requests. + pub async fn proxy_as_identity( + &self, + identity: &SecurityContext, + method: &str, + uri: &str, + headers: &[(&str, &str)], + body: &[u8], + ) -> anyhow::Result { + let mut builder = Request::builder().method(method).uri(uri); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + builder = builder.header("content-length", body.len().to_string()); + let mut request = builder + .body(Body::from(Vec::from(body))) + .context("building the request")?; + request.extensions_mut().insert(identity.clone()); + let response = self.router.clone().oneshot(request).await?; + Reply::from_response(response).await + } + + /// Send a management request on the merged router. + pub async fn call( + &self, + method: &str, + uri: &str, + payload: Option, + ) -> anyhow::Result { + send_json(&self.router, method, uri, self.tenant, payload, &[]).await + } + + /// Send a proxy request as a tenant the harness does not own. + pub async fn proxy_as( + &self, + method: &str, + uri: &str, + tenant_id: Uuid, + headers: &[(&str, &str)], + body: &[u8], + ) -> anyhow::Result { + send_raw( + &self.router, + method, + uri, + tenant_id, + headers, + Vec::from(body), + ) + .await + } + + /// Send a proxy request and return the response **unconsumed**. + /// + /// The streaming tests drive the body themselves, so the reply must not be + /// buffered into a [`Reply`]. + pub async fn proxy_unbuffered( + &self, + method: &str, + uri: &str, + headers: &[(&str, &str)], + ) -> anyhow::Result> { + send_stream(&self.router, method, uri, self.tenant, headers).await + } + + /// Send a proxy request whose body is streamed, i.e. framed chunked. + /// + /// A chunked request declares no length, so the body cap can only be + /// enforced while the frames arrive — which is what the test observes. + pub async fn proxy_chunked( + &self, + method: &str, + uri: &str, + chunks: &[&[u8]], + ) -> anyhow::Result { + let mut builder = Request::builder().method(method).uri(uri); + builder = builder.header("transfer-encoding", "chunked"); + let mut frames = Vec::with_capacity(chunks.len()); + for chunk in chunks { + frames.push(bytes::Bytes::copy_from_slice(chunk)); + } + let body = Body::from_stream(futures_util::stream::iter( + frames.into_iter().map(Ok::<_, std::convert::Infallible>), + )); + let mut request = builder.body(body).context("building the request")?; + request + .extensions_mut() + .insert(security_context(self.tenant)?); + let response = self.router.clone().oneshot(request).await?; + Reply::from_response(response).await + } +} + +impl Default for ProxyHarness { + fn default() -> Self { + Self::new() + } +} + +// ── Domain-model seeding ───────────────────────────────────────────────── + +/// An `http` endpoint on the loopback interface. +#[must_use] +pub fn loopback_endpoint(port: u16) -> oagw::domain::model::Endpoint { + hostname_endpoint("127.0.0.1", port) +} + +/// An `http` endpoint addressed by `host`. +/// +/// A host name is what the target-host matrix of ADR-0001 needs: a pool whose +/// alias is derived from the shared suffix demands the pinning header. +#[must_use] +pub fn hostname_endpoint(host: &str, port: u16) -> oagw::domain::model::Endpoint { + oagw::domain::model::Endpoint { + scheme: oagw::domain::model::Scheme::Http, + host: host.to_owned(), + port, + } +} + +/// An `https` endpoint addressed by `host` on the standard port. +/// +/// The standard port keeps the derived alias free of a `:port` suffix, which is +/// what makes an ambiguous pool demand `X-OAGW-Target-Host` (ADR-0001). +#[must_use] +pub fn tls_hostname_endpoint(host: &str) -> oagw::domain::model::Endpoint { + oagw::domain::model::Endpoint { + scheme: oagw::domain::model::Scheme::Https, + host: host.to_owned(), + port: 443, + } +} + +/// Read a streaming body to its end, tolerating a truncated transfer. +/// +/// Returns the bytes that arrived and whether the body ended **before** the +/// upstream finished its framing — the observable outcome of a body the data +/// plane had to abort. +pub async fn read_to_end(body: axum::body::Body) -> anyhow::Result<(Vec, bool)> { + let mut body = body; + let mut received = Vec::new(); + let mut truncated = false; + loop { + match body.frame().await { + None => break, + Some(Ok(frame)) => { + if let Ok(data) = frame.into_data() { + received.extend_from_slice(&data); + } + } + Some(Err(_)) => { + truncated = true; + break; + } + } + } + Ok((received, truncated)) +} + +/// Read frames until `needle` has been seen, without waiting for the body end. +/// +/// The streaming tests use it to observe bytes that arrive **while** the +/// upstream is still connected. +/// +/// # Errors +/// When the body ends or fails before `needle` was seen. +pub async fn read_until(body: &mut axum::body::Body, needle: &str) -> anyhow::Result> { + let mut received = Vec::new(); + loop { + if let Some(Ok(frame)) = body.frame().await { + if let Ok(data) = frame.into_data() { + received.extend_from_slice(&data); + } + } else { + break; + } + if String::from_utf8_lossy(&received).contains(needle) { + return Ok(received); + } + } + anyhow::bail!( + "the body ended before '{needle}' arrived; received: {}", + String::from_utf8_lossy(&received) + ) +} + +/// An upstream record over `endpoints` with an explicit `alias`. +/// +/// Seeded directly, so the tests control the routing key instead of the +/// alias-derivation rules of the write path. +#[must_use] +pub fn domain_upstream( + tenant_id: Uuid, + alias: &str, + endpoints: Vec, + enabled: bool, +) -> oagw::domain::model::Upstream { + oagw::domain::model::Upstream { + id: Uuid::now_v7(), + tenant_id, + alias: alias.to_owned(), + enabled, + protocol: oagw::domain::model::Protocol::Http, + endpoints, + tags: Vec::new(), + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + timestamps: oagw::domain::model::Timestamps { + created_at: 0, + updated_at: 0, + }, + } +} + +/// An enabled `http` route of `upstream_id` matching `path` for `methods`. +#[must_use] +pub fn domain_route( + tenant_id: Uuid, + upstream_id: Uuid, + methods: &[oagw::domain::model::HttpMethod], + path: &str, + query_allowlist: &[&str], +) -> oagw::domain::model::Route { + oagw::domain::model::Route { + id: Uuid::now_v7(), + tenant_id, + upstream_id, + enabled: true, + match_rule: oagw::domain::model::RouteMatch { + http: Some(oagw::domain::model::HttpMatch { + methods: methods.to_vec(), + path: path.to_owned(), + query_allowlist: query_allowlist + .iter() + .map(|name| (*name).to_owned()) + .collect(), + path_suffix_mode: oagw::domain::model::PathSuffixMode::Append, + }), + grpc: None, + }, + tags: Vec::new(), + plugins: None, + rate_limit: None, + cors: None, + timestamps: oagw::domain::model::Timestamps { + created_at: 0, + updated_at: 0, + }, + } +} + +/// An event-stream route that allows every method, for the streaming tests. +#[must_use] +pub fn any_method() -> Vec { + Vec::from([ + oagw::domain::model::HttpMethod::Get, + oagw::domain::model::HttpMethod::Post, + ]) +} + +/// Tenant chain that reports the root tenant as the single ancestor. +/// +/// Stands in for the `tenant_resolver` client, which no test in this crate can +/// reach without a deployment. +pub struct StaticTenantChain; + +#[async_trait::async_trait] +impl TenantChain for StaticTenantChain { + async fn ancestors( + &self, + _context: &SecurityContext, + tenant_id: Uuid, + ) -> oagw::OagwResult> { + if tenant_id == Uuid::nil() { + return Ok(Vec::new()); + } + Ok(Vec::from([Uuid::nil()])) + } +} + +/// `X-OAGW-Target-Host` on the wire. +pub const TARGET_HOST: &str = "x-oagw-target-host"; + +/// `X-OAGW-Error-Source` on the wire. +pub const ERROR_SOURCE: &str = "x-oagw-error-source"; + +/// A raw TCP server driven by the test: it answers from a scripted closure. +/// +/// `httpmock` covers the ordinary request/response cases; a scripted socket is +/// the only way to hand out a chunked body, an event stream or a connection +/// that never answers. +pub struct RawUpstream { + listener: tokio::net::TcpListener, +} + +impl RawUpstream { + /// Bind a listener on an ephemeral loopback port. + /// + /// # Errors + /// Propagated from the socket bind. + pub async fn bind() -> anyhow::Result { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await?; + Ok(Self { listener }) + } + + /// The port the listener bound. + #[must_use] + pub fn port(&self) -> u16 { + self.listener.local_addr().map_or(0, |addr| addr.port()) + } + + /// Accept one connection, read its request head and answer with `response`. + /// + /// The request head is returned so the test can assert on it. + /// + /// # Errors + /// Propagated from the socket operations. + pub async fn serve_once(&self, response: &str) -> anyhow::Result { + let (mut socket, _) = self.listener.accept().await?; + let received = read_request_head(&mut socket).await?; + socket.write_all(response.as_bytes()).await?; + socket.shutdown().await?; + Ok(received) + } + + /// Serve requests forever, answering each with `response`. + /// + /// The streaming tests keep a listener alive across several proxy calls. + pub async fn serve_forever(&self, response: String) { + while let Ok((socket, _)) = self.listener.accept().await { + if self.answer(socket, &response).await.is_err() { + break; + } + } + } + + /// Answer a single accepted connection with `response`. + async fn answer( + &self, + mut socket: tokio::net::TcpStream, + response: &str, + ) -> anyhow::Result<()> { + let head = read_request_head(&mut socket).await.unwrap_or_default(); + tracing::debug!(request = %head, "raw upstream received a request"); + socket.write_all(response.as_bytes()).await?; + if let Err(error) = socket.shutdown().await { + tracing::debug!(error = %error, "raw upstream could not close the socket"); + } + Ok(()) + } + + /// Accept one connection and never answer it. + /// + /// # Errors + /// Propagated from the socket accept. + pub async fn hang(&self) -> anyhow::Result<()> { + let (socket, _) = self.listener.accept().await?; + // The connection is dropped by the caller's timeout; parking keeps the + // socket open so the gateway cannot read a response. + tokio::time::sleep(std::time::Duration::from_secs(30)).await; + drop(socket); + Ok(()) + } + + /// Accept one connection, consume its request head and return the socket. + /// + /// The streaming tests hand the socket back to the test so it can dribble + /// bytes out of the upstream whenever it wants. + /// + /// # Errors + /// Propagated from the socket operations. + pub async fn hand_over(&self, response: &str) -> anyhow::Result { + let (mut socket, _) = self.listener.accept().await?; + read_request_head(&mut socket).await?; + socket.write_all(response.as_bytes()).await?; + Ok(socket) + } +} + +/// Read the request head of a connection. +/// +/// The tests send empty bodies, so the first blank line is always the end of +/// the head. +async fn read_request_head(socket: &mut tokio::net::TcpStream) -> anyhow::Result { + let mut received = Vec::new(); + let mut buffer = [0u8; 2048]; + while !received.windows(4).any(|window| window == b"\r\n\r\n") { + let read = socket.read(&mut buffer).await?; + if read == 0 { + break; + } + received.extend_from_slice(&buffer[..read]); + } + Ok(String::from_utf8_lossy(&received).to_string()) +} + +// ── log capture ────────────────────────────────────────────────────────── + +/// A `tracing` subscriber that keeps every formatted event and every span. +/// +/// Assertions on what the data plane *reports* need the log lines of a whole +/// proxy call, and there is no `tracing-subscriber` dev-dependency, so the +/// subscriber is written here. The tests that use it run on a single thread, +/// which is what [`tracing::subscriber::set_default`] needs. +/// +/// Spans are kept as well, rendered the same way as the events: the audit +/// record of a request is an event emitted *inside* a span, so a test that +/// asserts what the record carries must be able to see what the span carries +/// too — the production subscriber formats both into one ingested line. +/// One `(span id, rendered line)` per span the subscriber saw. +type SpanLines = Vec<(tracing::Id, String)>; + +#[derive(Clone, Default)] +pub struct LogCapture { + lines: std::sync::Arc>>, + /// One `(id, rendered line)` per span the subscriber saw. + spans: std::sync::Arc>, + next_id: std::sync::Arc, +} + +impl LogCapture { + /// Every event the subscriber saw, in order. + pub fn lines(&self) -> Vec { + self.lines + .lock() + .map_or_else(|_| Vec::new(), |lines| lines.clone()) + } + + /// Every span the subscriber saw, in the order it was opened. + /// + /// A span is rendered as `name field=value field=value`, with the fields + /// recorded after it was opened (`status`, say) appended to the ones it + /// was opened with. + pub fn spans(&self) -> Vec { + self.spans.lock().map_or_else( + |_| Vec::new(), + |spans| spans.iter().map(|(_, line)| line.clone()).collect(), + ) + } + + /// The id of the next span. + fn next_id(&self) -> tracing::Id { + let id = self + .next_id + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + + 1; + tracing::Id::from_u64(id) + } +} + +/// Collects the message and the fields of one event. +struct MessageVisitor(String); + +impl tracing::field::Visit for MessageVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + if field.name() == "message" { + self.write(format_args!("{value:?}")); + } else { + self.write(format_args!(" {}={value:?}", field.name())); + } + } + + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.write(format_args!(" {}={value}", field.name())); + } +} + +impl MessageVisitor { + /// Appends one rendered field; a `String` never fails a write. + fn write(&mut self, arguments: std::fmt::Arguments<'_>) { + let _appended = std::fmt::Write::write_fmt(&mut self.0, arguments); + } +} + +/// Collects the attributes of one span as `(name, value)` pairs. +struct FieldVisitor(Vec<(String, String)>); + +impl tracing::field::Visit for FieldVisitor { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.0.push((field.name().to_owned(), format!("{value:?}"))); + } + + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.0.push((field.name().to_owned(), value.to_owned())); + } +} + +/// One span as `name field=value field=value`. +fn rendered_span(name: &str, fields: &[(String, String)]) -> String { + let mut rendered = name.to_owned(); + for (field, value) in fields { + append(&mut rendered, field, value); + } + rendered +} + +/// Appends one `field=value` pair to a rendered span or record. +fn append(rendered: &mut String, field: &str, value: &str) { + let _appended = write!(rendered, " {field}={value}"); +} + +impl tracing::Subscriber for LogCapture { + fn enabled(&self, _metadata: &tracing::Metadata<'_>) -> bool { + true + } + + fn new_span(&self, attributes: &tracing::span::Attributes<'_>) -> tracing::Id { + let id = self.next_id(); + let mut visitor = FieldVisitor(Vec::new()); + attributes.record(&mut visitor); + if let Ok(mut spans) = self.spans.lock() { + spans.push(( + id.clone(), + rendered_span(attributes.metadata().name(), &visitor.0), + )); + } + id + } + + fn record(&self, span: &tracing::Id, values: &tracing::span::Record<'_>) { + let mut visitor = FieldVisitor(Vec::new()); + values.record(&mut visitor); + if let Ok(mut spans) = self.spans.lock() + && let Some((_, line)) = spans.iter_mut().find(|(id, _)| id == span) + { + // A recorded field arrives after the span was opened, so it is + // appended to the fields the span already carries. + for (field, value) in visitor.0 { + append(line, &field, &value); + } + } + } + + fn record_follows_from(&self, _span: &tracing::Id, _follows_from: &tracing::Id) {} + + fn event(&self, event: &tracing::Event<'_>) { + let mut visitor = MessageVisitor(String::new()); + event.record(&mut visitor); + if let Ok(mut lines) = self.lines.lock() { + // The severity leads the line, because the audit record of §4.3 is + // emitted at a level its outcome chooses and a test asserts that + // choice on the same string it asserts the fields on. + lines.push(format!("[{}] {}", event.metadata().level(), visitor.0)); + } + } + + fn enter(&self, _span: &tracing::Id) {} + + fn exit(&self, _span: &tracing::Id) {} +} diff --git a/gears/system/oagw/oagw/tests/metrics_test.rs b/gears/system/oagw/oagw/tests/metrics_test.rs new file mode 100644 index 0000000..bf815dd --- /dev/null +++ b/gears/system/oagw/oagw/tests/metrics_test.rs @@ -0,0 +1,1216 @@ +// Created: 2026-08-31 by Constructor Tech +// @cpt-dod:cpt-cf-oagw-dod-testing-proxy-data-plane:p2 +//! OpenTelemetry instruments of the proxy data plane (DESIGN §4.2). +//! +//! Every assertion is made on the data points an in-memory exporter recorded +//! behind the **global** meter provider: `ProxyService::new` pulls its +//! instruments from `opentelemetry::global`, so a test installs its provider +//! first and builds the harness second — an instrument is bound to whatever +//! provider was global when it was built and is never re-targeted. +//! +//! Attribute sets are matched *exactly*, not merely "contains". A label that +//! appears where §4.2 declares none is as much a cardinality bug as a value +//! that is missing, and the exact match is what pins `http.route` to the +//! matched route's prefix rather than to the raw request path. +//! +//! A method that can never match a route never reaches the request counter +//! end-to-end, and the tests say so rather than pretending otherwise. Two +//! classes are involved: a registered verb the route model does not name +//! (`HEAD`, `OPTIONS` — `HttpMethod::parse` resolves it, but no route allows +//! it) and an unregistered one (`CONNECT`, `TRACE`, `BREW`), which the router's +//! fallback answers before the data plane is reached at all. Both are a 404 +//! with no upstream behind them, and a request without an upstream is not +//! recorded (see the module docs of `src/infra/metrics.rs`), so neither the +//! `_OTHER` mapping nor the `TRACE` verb can be observed through a counter. +//! They are pinned where they live: `_OTHER` in the unit tests of +//! `src/infra/metrics.rs`, and the audit record of an unregistered method in +//! `an_unregistered_method_is_audited_like_any_other_request`. + +mod common; + +use anyhow::{Context as _, Result}; +use common::{LogCapture, ProxyHarness, domain_route, domain_upstream, loopback_endpoint}; +use httpmock::prelude::{GET, MockServer}; +use oagw::domain::model::{ + BurstConfig, CorsConfig, Endpoint, HttpMethod, RateLimitConfig, SharingMode, SustainedRate, +}; +use opentelemetry_sdk::metrics::{ + InMemoryMetricExporter, PeriodicReader, SdkMeterProvider, + data::{AggregatedMetrics, MetricData}, +}; +use uuid::Uuid; + +/// Alias every test routes through. +const ALIAS: &str = "api.vendor.com"; +/// Path prefix every test's route matches. +const ROUTE: &str = "/v1/chat"; +/// The proxy path that addresses [`ROUTE`]. +const PROXY_PATH: &str = "/oagw/v1/proxy/api.vendor.com/v1/chat"; +/// The `traceparent` of the tests that need a correlation id. +const TRACEPARENT: &str = "00-0af7651916cd43dd8448eb211c80319c-b7ad6b7169203331-01"; +/// Problem type the data plane answers with when the dial fails. +const LINK_UNAVAILABLE: &str = "gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1"; + +// ── The meter provider the instruments are read back from ──────────────── + +/// Serialises the tests of this file against the process-global provider. +/// +/// The global provider is one slot shared by every test in the process, and the +/// instruments of a request read from it, so two tests issuing proxy requests +/// at the same moment would be counted into whichever provider was installed +/// last. An asynchronous mutex, because a guard of a `std` one may not be held +/// across an `await`: a test holds it from before it installs its provider to +/// after its last assertion. +static METER_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(()); + +/// The lock of [`METER_LOCK`], held for the whole of a test. +async fn meter_guard() -> tokio::sync::MutexGuard<'static, ()> { + METER_LOCK.lock().await +} + +/// Install an in-memory meter provider as the OpenTelemetry global. +/// +/// The returned provider must outlive the assertions: dropping it shuts the +/// reader down, and the data points of a shut-down reader are gone. +fn install_meter_provider() -> (SdkMeterProvider, InMemoryMetricExporter) { + let exporter = InMemoryMetricExporter::default(); + let provider = SdkMeterProvider::builder() + .with_reader(PeriodicReader::builder(exporter.clone()).build()) + .build(); + opentelemetry::global::set_meter_provider(provider.clone()); + (provider, exporter) +} + +// ── Reading the recorded data points back ──────────────────────────────── + +/// Whether `attributes` is exactly `expected`, name and value. +/// +/// Values are compared as rendered, which is what makes the numeric +/// `http.response.status_code` comparable with the `"200"` the test spells. +fn attributes_match(attributes: &[opentelemetry::KeyValue], expected: &[(&str, &str)]) -> bool { + attributes.len() == expected.len() + && expected.iter().all(|(name, value)| { + attributes + .iter() + .any(|pair| pair.key.as_str() == *name && pair.value.to_string() == *value) + }) +} + +/// Sum of the `u64` data points of `name` whose attributes are exactly `expected`. +fn counter_value(exporter: &InMemoryMetricExporter, name: &str, expected: &[(&str, &str)]) -> u64 { + let collected = collected(exporter); + let mut total = 0; + for metric in metrics_of(&collected, name) { + if let AggregatedMetrics::U64(MetricData::Sum(sum)) = metric.data() { + for point in sum.data_points() { + let attributes: Vec<_> = point.attributes().cloned().collect(); + if attributes_match(&attributes, expected) { + total += point.value(); + } + } + } + } + total +} + +/// Values of the `i64` data points of `name` whose attributes are exactly +/// `expected`. +/// +/// The in-flight gauge is asserted through this rather than through a sum: a +/// single point at zero is the observation the Drop guard owes, and a sum would +/// hide a second point that never came back down. +fn point_values( + exporter: &InMemoryMetricExporter, + name: &str, + expected: &[(&str, &str)], +) -> Vec { + let collected = collected(exporter); + let mut values = Vec::new(); + for metric in metrics_of(&collected, name) { + if let AggregatedMetrics::I64(MetricData::Sum(sum)) = metric.data() { + for point in sum.data_points() { + let attributes: Vec<_> = point.attributes().cloned().collect(); + if attributes_match(&attributes, expected) { + values.push(point.value()); + } + } + } + } + values +} + +/// Value of the `f64` gauge data point of `name` whose attributes are exactly +/// `expected`. +fn gauge_value( + exporter: &InMemoryMetricExporter, + name: &str, + expected: &[(&str, &str)], +) -> Option { + let collected = collected(exporter); + for metric in metrics_of(&collected, name) { + if let AggregatedMetrics::F64(MetricData::Gauge(gauge)) = metric.data() { + for point in gauge.data_points() { + let attributes: Vec<_> = point.attributes().cloned().collect(); + if attributes_match(&attributes, expected) { + return Some(point.value()); + } + } + } + } + None +} + +/// Number of samples the histogram `name` recorded under exactly `expected`. +fn histogram_count( + exporter: &InMemoryMetricExporter, + name: &str, + expected: &[(&str, &str)], +) -> u64 { + let collected = collected(exporter); + for metric in metrics_of(&collected, name) { + if let AggregatedMetrics::F64(MetricData::Histogram(histogram)) = metric.data() { + for point in histogram.data_points() { + let attributes: Vec<_> = point.attributes().cloned().collect(); + if attributes_match(&attributes, expected) { + return point.count(); + } + } + } + } + 0 +} + +/// Whether any data point of `name` carries `value` as one of its label values. +/// +/// The exact match of [`attributes_match`] can never see a label that arrives +/// *next to* the expected ones, so an assertion built on it is vacuous for the +/// "nothing else is labelled" question: this one looks at every attribute of +/// every data point, whatever the instrument. +fn any_label_value(exporter: &InMemoryMetricExporter, name: &str, value: &str) -> bool { + let collected = collected(exporter); + metrics_of(&collected, name).any(|metric| { + attribute_sets(metric) + .iter() + .any(|set| set.iter().any(|pair| pair.value.to_string() == value)) + }) +} + +/// Every attribute set of `metric`, whatever kind of instrument it is. +fn attribute_sets( + metric: &opentelemetry_sdk::metrics::data::Metric, +) -> Vec> { + let mut sets = Vec::new(); + let data = metric.data(); + if let AggregatedMetrics::U64(MetricData::Sum(sum)) = data { + sets.extend( + sum.data_points() + .map(|point| point.attributes().cloned().collect()), + ); + } + if let AggregatedMetrics::I64(MetricData::Sum(sum)) = data { + sets.extend( + sum.data_points() + .map(|point| point.attributes().cloned().collect()), + ); + } + if let AggregatedMetrics::F64(MetricData::Sum(sum)) = data { + sets.extend( + sum.data_points() + .map(|point| point.attributes().cloned().collect()), + ); + } + if let AggregatedMetrics::F64(MetricData::Gauge(gauge)) = data { + sets.extend( + gauge + .data_points() + .map(|point| point.attributes().cloned().collect()), + ); + } + if let AggregatedMetrics::F64(MetricData::Histogram(histogram)) = data { + sets.extend( + histogram + .data_points() + .map(|point| point.attributes().cloned().collect()), + ); + } + sets +} + +/// Every collection the exporter holds, in the order it received them. +fn collected( + exporter: &InMemoryMetricExporter, +) -> Vec { + exporter.get_finished_metrics().unwrap_or_default() +} + +/// Every metric of `collected` named `name`. +fn metrics_of<'a>( + collected: &'a [opentelemetry_sdk::metrics::data::ResourceMetrics], + name: &'a str, +) -> impl Iterator + 'a { + collected + .iter() + .flat_map(opentelemetry_sdk::metrics::data::ResourceMetrics::scope_metrics) + .flat_map(opentelemetry_sdk::metrics::data::ScopeMetrics::metrics) + .filter(move |metric| metric.name() == name) +} + +// ── Seeding ────────────────────────────────────────────────────────────── + +/// A token-bucket policy of `rate` per second with a `capacity` burst. +fn rate_limit(rate: u64, capacity: u64) -> RateLimitConfig { + RateLimitConfig { + sharing: SharingMode::Private, + algorithm: "token_bucket".to_owned(), + sustained: SustainedRate { + rate, + window: "second".to_owned(), + }, + burst: Some(BurstConfig { capacity }), + scope: "global".to_owned(), + strategy: "reject".to_owned(), + cost: 1, + response_headers: true, + } +} + +/// A harness whose upstream answers `port`, with `rate_limit` on the upstream. +/// +/// The route is seeded with the default suffix mode, so `/v1/chat/anything` +/// resolves to the same prefix [`ROUTE`] names — which is what the route-label +/// test needs. +fn harness_with(port: u16, rate_limit: Option) -> Result { + seeded(Vec::from([loopback_endpoint(port)]), rate_limit).map(|(harness, _)| harness) +} + +/// Seed `endpoints` as [`ALIAS`] with a route of [`ROUTE`], and return the +/// upstream's id. +/// +/// The id is the `upstream_id` label of the two routing families, so the tests +/// that read them need it. +fn seeded( + endpoints: Vec, + rate_limit: Option, +) -> Result<(ProxyHarness, Uuid)> { + let harness = ProxyHarness::new(); + let mut record = domain_upstream(harness.tenant(), ALIAS, endpoints, true); + record.rate_limit = rate_limit; + let upstream = harness.seed_upstream(record); + harness + .store() + .insert_route_checked(domain_route( + harness.tenant(), + upstream, + &[HttpMethod::Get], + ROUTE, + &[], + )) + .context("the test route must seed")?; + Ok((harness, upstream)) +} + +/// A CORS policy that allows `origin` and nothing else. +fn cors_policy(origin: &str) -> CorsConfig { + CorsConfig { + sharing: SharingMode::Private, + enabled: true, + allowed_origins: Vec::from([origin.to_owned()]), + allowed_methods: Vec::from(["GET".to_owned()]), + expose_headers: Vec::new(), + allow_credentials: false, + } +} + +/// A loopback port nothing is listening on. +fn refused_port() -> Result { + let listener = + std::net::TcpListener::bind(("127.0.0.1", 0)).context("binding a throwaway listener")?; + Ok(listener.local_addr().context("the local address")?.port()) +} + +/// The first audit record whose severity is `marker`, if one was emitted. +fn audit_line(capture: &LogCapture, marker: &str) -> Option { + capture + .lines() + .into_iter() + .find(|line| line.contains("proxied request") && line.contains(marker)) +} + +// ── Requests ───────────────────────────────────────────────────────────── + +/// A 200 answered by the upstream is counted with the upstream's own status, +/// the normalized method and the matched route prefix. +#[tokio::test] +async fn a_proxied_request_is_counted_with_the_upstream_status_and_method() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path(ROUTE); + then.status(200); + }); + let (provider, exporter, harness) = { + let (provider, exporter) = install_meter_provider(); + let harness = harness_with(server.port(), None)?; + (provider, exporter, harness) + }; + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + assert_eq!(reply.status, axum::http::StatusCode::OK); + provider.force_flush().context("the meter must flush")?; + + assert_eq!( + counter_value( + &exporter, + "oagw_requests_total", + &[ + ("host", ALIAS), + ("http.request.method", "GET"), + ("http.route", ROUTE), + ("http.response.status_code", "200"), + ], + ), + 1, + "one request, under the four labels of section 4.2" + ); + mock.assert(); + Ok(()) +} + +/// The duration histogram records one sample under the route prefix and the +/// one phase the data plane can honestly name. +#[tokio::test] +async fn the_duration_histogram_records_one_sample_per_request() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path(ROUTE); + then.status(200); + }); + let (provider, exporter, harness) = { + let (provider, exporter) = install_meter_provider(); + let harness = harness_with(server.port(), None)?; + (provider, exporter, harness) + }; + + harness.proxy("GET", PROXY_PATH, &[], b"").await?; + provider.force_flush().context("the meter must flush")?; + + assert_eq!( + histogram_count( + &exporter, + "oagw_request_duration_seconds", + &[("host", ALIAS), ("http.route", ROUTE), ("phase", "total")], + ), + 1, + "one sample, under the three labels of section 4.2" + ); + mock.assert(); + Ok(()) +} + +/// The in-flight gauge is back to zero once the request has been served. +#[tokio::test] +async fn in_flight_returns_to_zero_when_the_request_ends() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let _mock = server.mock(|when, then| { + when.method(GET).path(ROUTE); + then.status(200); + }); + let (provider, exporter, harness) = { + let (provider, exporter) = install_meter_provider(); + let harness = harness_with(server.port(), None)?; + (provider, exporter, harness) + }; + + harness.proxy("GET", PROXY_PATH, &[], b"").await?; + provider.force_flush().context("the meter must flush")?; + + assert_eq!( + point_values(&exporter, "oagw_requests_in_flight", &[("host", ALIAS)]), + Vec::from([0]), + "the request entered and left the pool exactly once" + ); + Ok(()) +} + +/// A refused dial also leaves the gauge where it found it. +#[tokio::test] +async fn in_flight_returns_to_zero_after_a_failed_request() -> Result<()> { + let _meter = meter_guard().await; + let port = refused_port()?; + let (provider, exporter, harness) = { + let (provider, exporter) = install_meter_provider(); + let harness = harness_with(port, None)?; + (provider, exporter, harness) + }; + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + assert_eq!( + reply.status, + axum::http::StatusCode::SERVICE_UNAVAILABLE, + "a dial that cannot be made is a 503, not a 500" + ); + provider.force_flush().context("the meter must flush")?; + + assert_eq!( + point_values(&exporter, "oagw_requests_in_flight", &[("host", ALIAS)]), + Vec::from([0]), + "the Drop guard runs on the failure path too" + ); + Ok(()) +} + +/// A dial that fails is counted as a gateway failure under the problem type the +/// client was answered with — and, the request still having had an upstream, as +/// a request answered with the status the gateway chose. +#[tokio::test] +async fn an_upstream_failure_records_the_problem_type() -> Result<()> { + let _meter = meter_guard().await; + let port = refused_port()?; + let capture = LogCapture::default(); + let _subscriber = tracing::subscriber::set_default(capture.clone()); + let (provider, exporter, harness) = { + let (provider, exporter) = install_meter_provider(); + let harness = harness_with(port, None)?; + (provider, exporter, harness) + }; + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + provider.force_flush().context("the meter must flush")?; + + assert_eq!( + counter_value( + &exporter, + "oagw_errors_total", + &[ + ("host", ALIAS), + ("http.route", ROUTE), + ("error_type", LINK_UNAVAILABLE), + ], + ), + 1, + "one failure, named by the problem type the client saw" + ); + assert_eq!( + counter_value( + &exporter, + "oagw_requests_total", + &[ + ("host", ALIAS), + ("http.request.method", "GET"), + ("http.route", ROUTE), + ("http.response.status_code", "503"), + ], + ), + 1, + "no upstream status exists, so the counter carries the gateway's" + ); + let line = audit_line(&capture, "[ERROR]").context("the failure was not audited")?; + assert!( + line.starts_with("[ERROR] "), + "the level of an upstream failure: {line}" + ); + assert!( + line.contains("error_type="), + "the record names the problem type: {line}" + ); + Ok(()) +} + +/// A rate limit that refuses records the refusal, and the bucket state it gave +/// up on is the one number the usage gauge can carry. +#[tokio::test] +async fn a_rate_limited_request_records_the_refusal_and_the_bucket_usage() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path(ROUTE); + then.status(200); + }); + let capture = LogCapture::default(); + let _subscriber = tracing::subscriber::set_default(capture.clone()); + let (provider, exporter, harness) = { + let (provider, exporter) = install_meter_provider(); + let harness = harness_with(server.port(), Some(rate_limit(1, 1)))?; + (provider, exporter, harness) + }; + + assert_eq!( + harness.proxy("GET", PROXY_PATH, &[], b"").await?.status, + axum::http::StatusCode::OK, + "the first request spends the only token of the bucket" + ); + let refused = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + assert_eq!(refused.status, axum::http::StatusCode::TOO_MANY_REQUESTS); + provider.force_flush().context("the meter must flush")?; + + assert_eq!( + counter_value( + &exporter, + "oagw_rate_limit_exceeded_total", + &[("host", ALIAS), ("path", ROUTE)], + ), + 1, + "one refusal, under the alias and the route prefix" + ); + let usage = gauge_value( + &exporter, + "oagw_rate_limit_usage_ratio", + &[("host", ALIAS), ("path", ROUTE)], + ) + .context("the refusal recorded no bucket usage")?; + assert!( + (usage - 1.0).abs() < 1e-9, + "a bucket whose token was spent is fully used, not {usage}" + ); + let refused = audit_line(&capture, "[WARN]").context("the refusal was not audited")?; + assert!( + refused.starts_with("[WARN] "), + "the level of a refusal: {refused}" + ); + let admitted = audit_line(&capture, "[INFO]").context("the admission was not audited")?; + assert!( + admitted.starts_with("[INFO] "), + "the level of an answered request: {admitted}" + ); + assert_eq!(mock.calls(), 1, "the refusal never reached the upstream"); + Ok(()) +} + +/// `http.route` is the matched route's prefix, and the raw request path — whose +/// segments are client input — is never a label. +#[tokio::test] +async fn the_route_label_is_the_matched_prefix_not_the_raw_path() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat/extra"); + then.status(200); + }); + let (provider, exporter, harness) = { + let (provider, exporter) = install_meter_provider(); + let harness = harness_with(server.port(), None)?; + (provider, exporter, harness) + }; + + let reply = harness + .proxy("GET", &format!("{PROXY_PATH}/extra"), &[], b"") + .await?; + assert_eq!(reply.status, axum::http::StatusCode::OK); + provider.force_flush().context("the meter must flush")?; + + assert_eq!( + counter_value( + &exporter, + "oagw_requests_total", + &[ + ("host", ALIAS), + ("http.request.method", "GET"), + ("http.route", ROUTE), + ("http.response.status_code", "200"), + ], + ), + 1, + "the suffix the client invented is not part of the route" + ); + assert!( + !any_label_value(&exporter, "oagw_requests_total", "/v1/chat/extra"), + "the request path the client invented is a label value of no data point" + ); + assert!( + !any_label_value(&exporter, "oagw_request_duration_seconds", "/v1/chat/extra"), + "nor of the histogram" + ); + mock.assert(); + Ok(()) +} + +/// The audit record carries every field of DESIGN §4.3 and none of the things +/// it is forbidden to carry: not the query, not a header value, not the body. +#[tokio::test] +async fn the_audit_record_carries_the_fields_of_4_3_and_nothing_secret() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path(ROUTE); + then.status(200).body("the upstream answer"); + }); + let capture = LogCapture::default(); + let _subscriber = tracing::subscriber::set_default(capture.clone()); + let (provider, harness) = { + let (provider, _exporter) = install_meter_provider(); + let harness = harness_with(server.port(), None)?; + (provider, harness) + }; + + let reply = harness + .proxy( + "GET", + &format!("{PROXY_PATH}?allowed=yes&api_key=shhh"), + &[("traceparent", TRACEPARENT), ("x-private", "hunter2")], + b"the request body", + ) + .await?; + assert_eq!(reply.status, axum::http::StatusCode::OK); + provider.force_flush().context("the meter must flush")?; + + let line = audit_line(&capture, "[INFO]").context("the request was not audited")?; + assert!( + line.starts_with("[INFO] "), + "the level of an answered request: {line}" + ); + assert!( + line.contains("event=oagw_proxy_request"), + "the record is named: {line}" + ); + assert!( + line.contains("request_id=0af7651916cd43dd8448eb211c80319c"), + "the record carries a correlation id: {line}" + ); + assert!( + line.contains(&format!("tenant_id={}", harness.tenant())), + "the record carries the tenant: {line}" + ); + assert!( + line.contains("principal_id="), + "the record carries the subject: {line}" + ); + assert!( + line.contains(&format!("host={ALIAS}")), + "the record names the upstream: {line}" + ); + assert!( + line.contains(&format!("path={ROUTE}")), + "the record names the path: {line}" + ); + assert!( + line.contains("method=GET"), + "the record names the method: {line}" + ); + assert!( + line.contains("status=200"), + "the record names the status: {line}" + ); + assert!( + line.contains("duration_ms="), + "the record names the duration: {line}" + ); + assert!( + line.contains("request_size=16"), + "the declared request size is recorded: {line}" + ); + assert!( + line.contains("response_size=19"), + "the declared response size is recorded: {line}" + ); + assert!( + !line.contains("error_type="), + "a success names no error: {line}" + ); + assert!( + !line.contains("shhh") && !line.contains("api_key"), + "the record carries no query string: {line}" + ); + assert!( + !line.contains("hunter2"), + "the record carries no header value: {line}" + ); + assert!( + !line.contains("the request body") && !line.contains("the upstream answer"), + "the record carries no body: {line}" + ); + mock.assert(); + Ok(()) +} + +/// The correlation id of the audit record is the one the trace headers named, +/// not an id the gateway invented. +#[tokio::test] +async fn the_audit_record_reports_the_trace_id_the_client_sent() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let _mock = server.mock(|when, then| { + when.method(GET).path(ROUTE); + then.status(200); + }); + let capture = LogCapture::default(); + let _subscriber = tracing::subscriber::set_default(capture.clone()); + let (provider, harness) = { + let (provider, _exporter) = install_meter_provider(); + let harness = harness_with(server.port(), None)?; + (provider, harness) + }; + + harness + .proxy("GET", PROXY_PATH, &[("traceparent", TRACEPARENT)], b"") + .await?; + provider.force_flush().context("the meter must flush")?; + + let line = audit_line(&capture, "[INFO]").context("the request was not audited")?; + assert!( + line.contains("request_id=0af7651916cd43dd8448eb211c80319c"), + "the correlation id is the client's trace id: {line}" + ); + Ok(()) +} + +/// A streamed request declares no length, so the audit record omits the +/// request size rather than counting the bytes that happened to arrive. +#[tokio::test] +async fn a_streamed_request_omits_the_size_it_declared() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let _mock = server.mock(|when, then| { + when.method(GET).path(ROUTE); + then.status(200).body("chunked answer"); + }); + let capture = LogCapture::default(); + let _subscriber = tracing::subscriber::set_default(capture.clone()); + let (provider, harness) = { + let (provider, _exporter) = install_meter_provider(); + let harness = harness_with(server.port(), None)?; + (provider, harness) + }; + + let reply = harness + .proxy_chunked("GET", PROXY_PATH, &[b"chunk one"]) + .await?; + assert_eq!(reply.status, axum::http::StatusCode::OK); + provider.force_flush().context("the meter must flush")?; + + let line = audit_line(&capture, "[INFO]").context("the request was not audited")?; + assert!( + !line.contains("request_size="), + "a request that declares no length names no size: {line}" + ); + Ok(()) +} + +/// A request a rate limit refuses is still a request the data plane served, so +/// it is counted and timed under the status the client was given — the same +/// rule the module docs commit to for a dial failure. +/// +/// The bucket is emptied by a request to a *different* route of the same +/// upstream, so the only request [`ROUTE`] ever sees is the refusal: the +/// histogram carries no status to tell two requests of one route apart. +#[tokio::test] +async fn a_rate_limited_request_is_counted_and_timed() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let _mock = server.mock(|when, then| { + when.method(GET); + then.status(200); + }); + let (provider, exporter, harness) = { + let (provider, exporter) = install_meter_provider(); + let harness = ProxyHarness::new(); + let mut record = domain_upstream( + harness.tenant(), + ALIAS, + Vec::from([loopback_endpoint(server.port())]), + true, + ); + record.rate_limit = Some(rate_limit(1, 1)); + let upstream = harness.seed_upstream(record); + // The policy is the upstream's, so both routes share one bucket. + for path in ["/v1/warmup", ROUTE] { + harness + .store() + .insert_route_checked(domain_route( + harness.tenant(), + upstream, + &[HttpMethod::Get], + path, + &[], + )) + .with_context(|| format!("the route '{path}' must seed"))?; + } + (provider, exporter, harness) + }; + + let warmed = harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com/v1/warmup", &[], b"") + .await?; + assert_eq!(warmed.status, axum::http::StatusCode::OK); + let refused = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + assert_eq!(refused.status, axum::http::StatusCode::TOO_MANY_REQUESTS); + provider.force_flush().context("the meter must flush")?; + + assert_eq!( + counter_value( + &exporter, + "oagw_requests_total", + &[ + ("host", ALIAS), + ("http.request.method", "GET"), + ("http.route", ROUTE), + ("http.response.status_code", "429"), + ], + ), + 1, + "the refusal is a request, answered with the status the limiter chose" + ); + assert_eq!( + histogram_count( + &exporter, + "oagw_request_duration_seconds", + &[("host", ALIAS), ("http.route", ROUTE), ("phase", "total")], + ), + 1, + "and it took time" + ); + assert_eq!( + counter_value( + &exporter, + "oagw_requests_total", + &[ + ("host", ALIAS), + ("http.request.method", "GET"), + ("http.route", "/v1/warmup"), + ("http.response.status_code", "200"), + ], + ), + 1, + "the request that emptied the bucket is a request of its own route" + ); + Ok(()) +} + +/// A request a CORS policy refuses is counted and timed like any other refusal. +#[tokio::test] +async fn a_cors_refusal_is_counted_and_timed() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let _mock = server.mock(|when, then| { + when.method(GET).path(ROUTE); + then.status(200); + }); + let (provider, exporter, harness) = { + let (provider, exporter) = install_meter_provider(); + let harness = ProxyHarness::new(); + let mut record = domain_upstream( + harness.tenant(), + ALIAS, + Vec::from([loopback_endpoint(server.port())]), + true, + ); + record.cors = Some(cors_policy("https://app.example.com")); + let upstream = harness.seed_upstream(record); + harness + .store() + .insert_route_checked(domain_route( + harness.tenant(), + upstream, + &[HttpMethod::Get], + ROUTE, + &[], + )) + .context("the test route must seed")?; + (provider, exporter, harness) + }; + + let reply = harness + .proxy( + "GET", + PROXY_PATH, + &[("origin", "https://evil.example.org")], + b"", + ) + .await?; + assert_eq!(reply.status, axum::http::StatusCode::FORBIDDEN); + provider.force_flush().context("the meter must flush")?; + + assert_eq!( + counter_value( + &exporter, + "oagw_requests_total", + &[ + ("host", ALIAS), + ("http.request.method", "GET"), + ("http.route", ROUTE), + ("http.response.status_code", "403"), + ], + ), + 1, + "a refused origin is still a request the data plane served" + ); + assert_eq!( + histogram_count( + &exporter, + "oagw_request_duration_seconds", + &[("host", ALIAS), ("http.route", ROUTE), ("phase", "total")], + ), + 1, + "and it took time" + ); + Ok(()) +} + +/// A method the data plane does not register is refused by the router's +/// fallback, and the fallback audits it: the same record, the same correlation +/// id, the same 404 (PRD §9, complete audit trail). +#[tokio::test] +async fn an_unregistered_method_is_audited_like_any_other_request() -> Result<()> { + let _meter = meter_guard().await; + let capture = LogCapture::default(); + let _subscriber = tracing::subscriber::set_default(capture.clone()); + // No meter is installed: the fallback is audited, and no instrument can + // attribute a request it never attributed to a host and a route. + let harness = harness_with(1, None)?; + + let reply = harness + .proxy("TRACE", PROXY_PATH, &[("traceparent", TRACEPARENT)], b"") + .await?; + assert_eq!(reply.status, axum::http::StatusCode::NOT_FOUND); + + let line = audit_line(&capture, "[INFO]").context("the refusal was not audited")?; + assert!( + line.contains("method=TRACE"), + "the record names the method: {line}" + ); + assert!( + line.contains("status=404"), + "the record names the status: {line}" + ); + assert!( + line.contains("event=oagw_proxy_request"), + "the record is named: {line}" + ); + assert!( + line.contains("request_id=0af7651916cd43dd8448eb211c80319c"), + "the record carries a correlation id: {line}" + ); + assert!( + line.contains(&format!("tenant_id={}", harness.tenant())), + "the record carries the tenant: {line}" + ); + assert!( + line.contains(&format!("host={ALIAS}")) && line.contains(&format!("path={ROUTE}")), + "the record names what the request asked for: {line}" + ); + assert!( + line.contains(&format!( + "error_message=no upstream of the calling tenant answers to the alias '{ALIAS}'" + )), + "the 404 carries the message the client was given: {line}" + ); + Ok(()) +} + +/// A pool dialled through the target-host header reports `explicit_header` and +/// the pinning counter — at the dial, so a request that never dialled reports +/// nothing. +#[tokio::test] +async fn a_pinned_endpoint_is_reported_at_the_dial() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path(ROUTE); + then.status(200); + }); + let (provider, exporter, harness, upstream) = { + let (provider, exporter) = install_meter_provider(); + let (harness, upstream) = seeded( + Vec::from([ + loopback_endpoint(server.port()), + loopback_endpoint(server.port()), + ]), + None, + )?; + (provider, exporter, harness, upstream) + }; + + let reply = harness + .proxy( + "GET", + PROXY_PATH, + &[("x-oagw-target-host", "127.0.0.1")], + b"", + ) + .await?; + assert_eq!(reply.status, axum::http::StatusCode::OK); + provider.force_flush().context("the meter must flush")?; + + assert_eq!( + counter_value( + &exporter, + "oagw_routing_endpoint_selected", + &[ + ("upstream_id", &upstream.to_string()), + ("endpoint_host", "127.0.0.1"), + ("selection_method", "explicit_header"), + ], + ), + 1, + "one dial, pinned by the header" + ); + assert_eq!( + counter_value( + &exporter, + "oagw_routing_target_host_used", + &[ + ("upstream_id", &upstream.to_string()), + ("endpoint_host", "127.0.0.1") + ], + ), + 1, + "the header was what dialled it" + ); + mock.assert(); + Ok(()) +} + +/// A pool of more than one endpoint dialled without the header reports the +/// round-robin cursor, and no pinning. +#[tokio::test] +async fn a_round_robin_pool_reports_the_selection_method() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path(ROUTE); + then.status(200); + }); + let (provider, exporter, harness, upstream) = { + let (provider, exporter) = install_meter_provider(); + let (harness, upstream) = seeded( + Vec::from([ + loopback_endpoint(server.port()), + loopback_endpoint(server.port()), + ]), + None, + )?; + (provider, exporter, harness, upstream) + }; + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + assert_eq!(reply.status, axum::http::StatusCode::OK); + provider.force_flush().context("the meter must flush")?; + + assert_eq!( + counter_value( + &exporter, + "oagw_routing_endpoint_selected", + &[ + ("upstream_id", &upstream.to_string()), + ("endpoint_host", "127.0.0.1"), + ("selection_method", "round_robin"), + ], + ), + 1, + "the cursor decided, so it says so" + ); + assert_eq!( + counter_value( + &exporter, + "oagw_routing_target_host_used", + &[("upstream_id", &upstream.to_string())] + ), + 0, + "no header was involved" + ); + mock.assert(); + Ok(()) +} + +/// A request that sends no trace header is still given a correlation id: the +/// id of the span it lives in. +#[tokio::test] +async fn a_request_without_a_trace_header_still_gets_a_correlation_id() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let _mock = server.mock(|when, then| { + when.method(GET).path(ROUTE); + then.status(200); + }); + let capture = LogCapture::default(); + let _subscriber = tracing::subscriber::set_default(capture.clone()); + let harness = harness_with(server.port(), None)?; + + harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + let line = audit_line(&capture, "[INFO]").context("the request was not audited")?; + assert!( + line.contains("request_id=") && !line.contains("request_id="), + "the span the request lives in is the fallback id: {line}" + ); + Ok(()) +} + +/// A failed request's record names the message behind the problem type, which +/// is what makes a shared slug actionable. +#[tokio::test] +async fn a_failure_record_names_the_message_behind_the_problem_type() -> Result<()> { + let _meter = meter_guard().await; + let port = refused_port()?; + let capture = LogCapture::default(); + let _subscriber = tracing::subscriber::set_default(capture.clone()); + let harness = harness_with(port, None)?; + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + + let line = audit_line(&capture, "[ERROR]").context("the failure was not audited")?; + assert!( + line.contains("error_message=upstream request failed"), + "the record carries the detail the problem document carries: {line}" + ); + assert!(line.contains("error_type="), "and its type: {line}"); + Ok(()) +} + +/// The span the audit event is emitted in carries what the data plane decided +/// and nothing the client sent: the production subscriber formats the span into +/// the same ingested record as the event. +#[tokio::test] +async fn the_request_span_carries_no_header_value_and_no_query() -> Result<()> { + let _meter = meter_guard().await; + let server = MockServer::start(); + let _mock = server.mock(|when, then| { + when.method(GET).path(ROUTE); + then.status(200); + }); + let capture = LogCapture::default(); + let _subscriber = tracing::subscriber::set_default(capture.clone()); + let harness = harness_with(server.port(), None)?; + + let reply = harness + .proxy( + "GET", + &format!("{PROXY_PATH}?api_key=shhh"), + &[("x-private", "hunter2")], + b"", + ) + .await?; + assert_eq!(reply.status, axum::http::StatusCode::OK); + + let spans = capture.spans(); + let request_span = spans + .iter() + .find(|span| span.contains("oagw_proxy_request")) + .context("the request opened no span")?; + assert!( + request_span.contains(&format!("alias={ALIAS}")), + "the span names the alias: {spans:?}" + ); + assert!( + request_span.contains("method=GET"), + "the span names the method: {spans:?}" + ); + assert!( + request_span.contains("status=200"), + "the span names the status: {spans:?}" + ); + assert!( + !request_span.contains("authority="), + "the span carries no header, not even under another name: {spans:?}" + ); + assert!( + !spans + .iter() + .any(|span| span.contains("hunter2") || span.contains("shhh")), + "no span carries a header value or a query string: {spans:?}" + ); + Ok(()) +} diff --git a/gears/system/oagw/oagw/tests/plugin_chain_test.rs b/gears/system/oagw/oagw/tests/plugin_chain_test.rs new file mode 100644 index 0000000..95e4354 --- /dev/null +++ b/gears/system/oagw/oagw/tests/plugin_chain_test.rs @@ -0,0 +1,1338 @@ +// Created: 2026-08-31 by Constructor Tech +// @cpt-dod:cpt-cf-oagw-dod-testing-plugin-chain:p2 +//! The plugin chain of one proxied request (ADR-0002, ADR-0008, ADR-0009, +//! DESIGN §3.2): the built-in plugins on the wire, the composition of the +//! upstream and the route chains and the fail-closed behaviour of a reference +//! this deployment cannot enforce. +//! +//! Credential paths run against the SDK's own `MockCredStoreClient`, which the +//! `test-util` feature exposes to integration tests. The `OAuth2` token endpoint +//! is a scripted raw socket, because the exchange has to be counted and its +//! form body inspected. + +mod common; + +use anyhow::{Context as _, Result}; +use common::{ + ERROR_SOURCE, Harness, LogCapture, ProxyHarness, domain_route, domain_upstream, https_upstream, + loopback_endpoint, problem_type, +}; +use credstore_sdk::test_util::MockCredStoreClient; +use httpmock::prelude::{GET, MockServer}; +use oagw::domain::model::{AuthConfig, HttpMethod, PluginBinding, PluginsConfig, SharingMode}; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use uuid::Uuid; + +/// `X-OAGW-` proxy route of every test. +const PROXY_PATH: &str = "/oagw/v1/proxy/api.vendor.com/v1/chat"; + +/// Seed the harness upstream with an optional plugin chain. +fn seed_upstream_on(harness: &ProxyHarness, port: u16, plugins: Option) -> Uuid { + let owner = harness.tenant(); + let mut upstream = domain_upstream( + owner, + "api.vendor.com", + Vec::from([loopback_endpoint(port)]), + true, + ); + upstream.plugins = plugins; + let id = harness.seed_upstream(upstream); + seed_route(harness, id); + id +} + +/// Seed an upstream chain **and** a route chain over the same upstream. +/// +/// The route carries the one `/v1/chat` match rule, so the pair is seeded in a +/// single pass instead of adding a second route for an existing upstream. +fn seed_pair( + harness: &ProxyHarness, + port: u16, + upstream_chain: PluginsConfig, + route_chain: Vec, +) -> Uuid { + let owner = harness.tenant(); + let mut upstream = domain_upstream( + owner, + "api.vendor.com", + Vec::from([loopback_endpoint(port)]), + true, + ); + upstream.plugins = Some(upstream_chain); + let id = harness.seed_upstream(upstream); + let mut route = domain_route( + owner, + id, + &[HttpMethod::Get, HttpMethod::Post], + "/v1/chat", + &[], + ); + route.plugins = Some(chain(route_chain)); + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| panic!("the test route must seed: {error}")); + id +} + +/// Seed the `/v1/chat` route of `upstream_id`, with an optional chain. +fn seed_route(harness: &ProxyHarness, upstream_id: Uuid) { + let route = domain_route( + harness.tenant(), + upstream_id, + &[HttpMethod::Get, HttpMethod::Post], + "/v1/chat", + &[], + ); + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| panic!("the test route must seed: {error}")); +} + +/// A harness whose upstream binds `auth` and whose credential store is `store`. +fn harness_with_auth( + port: u16, + auth: serde_json::Value, + credstore: Option>, +) -> ProxyHarness { + let harness = ProxyHarness::with_credential_store( + &common::proxy_config(), + Arc::new(common::StaticTenantChain), + credstore.map(|store| store as Arc), + ); + let owner = harness.tenant(); + let mut upstream = domain_upstream( + owner, + "api.vendor.com", + Vec::from([loopback_endpoint(port)]), + true, + ); + upstream.auth = Some(auth_binding(auth)); + seed_route(&harness, harness.seed_upstream(upstream)); + harness +} + +/// An `auth` binding over the raw members the test spelled. +/// +/// The write path would parse these members itself; building the record here +/// keeps the helper free of a `Result` it cannot do anything with. +fn auth_binding(members: serde_json::Value) -> AuthConfig { + let mut raw = match members { + serde_json::Value::Object(map) => map, + _ => serde_json::Map::new(), + }; + AuthConfig { + plugin_type: raw + .remove("type") + .and_then(|kind| kind.as_str().map(str::to_owned)), + sharing: SharingMode::Private, + raw, + } +} + +/// Credentials of the tests' one vendor. +fn vendor_credentials() -> Arc { + Arc::new(MockCredStoreClient::with_secrets(Vec::from([ + ("vendor-key".to_owned(), "k-123".to_owned()), + ("client-id".to_owned(), "cid".to_owned()), + ("client-secret".to_owned(), "s3cr3t".to_owned()), + ]))) +} + +/// A chain of bindings, private to the seeding tenant. +fn chain(bindings: Vec) -> PluginsConfig { + PluginsConfig { + sharing: SharingMode::Private, + items: bindings, + } +} + +/// A bare reference binding: built-in GTS id or custom plugin UUID. +fn bare(reference: String) -> PluginBinding { + PluginBinding::Reference(reference) +} + +/// A `plugin_ref` binding with a `config` member (ADR-0009). +fn configured(reference: String, config: serde_json::Value) -> PluginBinding { + let mut members = serde_json::Map::new(); + members.insert("config".to_owned(), config); + PluginBinding::Configured { + plugin_ref: reference, + config: members, + } +} + +/// The GTS id of a built-in plugin. +fn built_in(kind: &str, name: &str) -> String { + format!("gts.cf.core.oagw.{kind}_plugin.v1~cf.core.oagw.{name}.v1") +} + +/// A `required_headers` binding for one phase. +fn required_headers(config: serde_json::Value) -> PluginBinding { + configured(built_in("guard", "required_headers"), config) +} + +/// A `request_id` binding, without configuration. +fn request_id() -> PluginBinding { + bare(built_in("transform", "request_id")) +} + +/// An empty 200 the scripted upstream answers with. +const ANSWER: &str = "HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n"; +/// An empty 200 carrying the id the upstream chose. +const ANSWER_WITH_ID: &str = + "HTTP/1.1 200 OK\r\nx-request-id: upstream-id\r\ncontent-length: 0\r\n\r\n"; +/// An empty 200 carrying a header the `required_headers` guard looks for. +const SIGNED_ANSWER: &str = "HTTP/1.1 200 OK\r\nx-signature: sig\r\ncontent-length: 0\r\n\r\n"; + +/// Bind a raw upstream that answers **one** request with `response`. +/// +/// The responder is spawned before the harness dials: the proxy call blocks on +/// the answer, so the accept must already be pending. +async fn scripted_upstream( + response: &'static str, +) -> Result<(u16, tokio::task::JoinHandle>)> { + let upstream = Arc::new(common::RawUpstream::bind().await?); + let port = upstream.port(); + let dial = tokio::spawn(async move { upstream.serve_once(response).await }); + Ok((port, dial)) +} + +// ── apikey: credential injection on the wire ───────────────────────────── + +#[tokio::test] +async fn an_api_key_is_injected_into_the_default_header() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/v1/chat") + .header("x-api-key", "k-123"); + then.status(200).body("ok"); + }); + let harness = harness_with_auth( + server.port(), + serde_json::json!({ "type": "apikey", "key_ref": "cred://vendor-key" }), + Some(vendor_credentials()), + ); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert_eq!(mock.calls(), 1); + Ok(()) +} + +#[tokio::test] +async fn a_custom_header_name_and_prefix_are_honoured() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/v1/chat") + .header("x-vendor-key", "Bearer k-123"); + then.status(200).body("ok"); + }); + let harness = harness_with_auth( + server.port(), + serde_json::json!({ + "type": "apikey", + "key_ref": "cred://vendor-key", + "header_name": "x-vendor-key", + "prefix": "Bearer " + }), + Some(vendor_credentials()), + ); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert_eq!(mock.calls(), 1); + Ok(()) +} + +#[tokio::test] +async fn a_query_parameter_binding_appends_the_key_to_the_dial() -> Result<()> { + let (port, dial) = scripted_upstream(ANSWER).await?; + let harness = harness_with_auth( + port, + serde_json::json!({ + "type": "apikey", + "key_ref": "cred://vendor-key", + "query_param": "api_key" + }), + Some(vendor_credentials()), + ); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert!( + dial.await??.contains("api_key=k-123"), + "the dial carried the key" + ); + Ok(()) +} + +#[tokio::test] +async fn a_binding_without_a_key_reference_is_a_400_problem() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let harness = harness_with_auth( + server.port(), + serde_json::json!({ "type": "apikey" }), + Some(Arc::new(MockCredStoreClient::empty())), + ); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!( + reply.problem_type(), + Some(problem_type("validation.error.v1")) + ); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +#[tokio::test] +async fn an_unknown_secret_reference_is_a_500_problem() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let harness = harness_with_auth( + server.port(), + serde_json::json!({ "type": "apikey", "key_ref": "cred://ghost" }), + Some(Arc::new(MockCredStoreClient::empty())), + ); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + reply.problem_type(), + Some(problem_type("secret.not_found.v1")) + ); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +#[tokio::test] +async fn an_unavailable_credential_store_is_a_503_problem() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let harness = harness_with_auth( + server.port(), + serde_json::json!({ "type": "apikey", "key_ref": "cred://vendor-key" }), + Some(Arc::new(MockCredStoreClient::always_failing())), + ); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + reply.problem_type(), + Some(problem_type("link.unavailable.v1")) + ); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +#[tokio::test] +async fn a_deployment_without_a_credential_store_never_forwards_unauthenticated() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + // No credential store wired: the degraded registry of ADR-0008. + let harness = harness_with_auth( + server.port(), + serde_json::json!({ "type": "apikey", "key_ref": "cred://vendor-key" }), + None, + ); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + reply.problem_type(), + Some(problem_type("link.unavailable.v1")) + ); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +#[tokio::test] +async fn a_catalog_only_auth_plugin_is_a_503_problem() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let harness = harness_with_auth( + server.port(), + serde_json::json!({ "type": "basic" }), + Some(vendor_credentials()), + ); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + reply.problem_type(), + Some(problem_type("plugin.not_found.v1")) + ); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +/// A dial target whose host `url::Url` refuses, so assembly always fails. +/// +/// `2001:db8::1` is a valid IPv6 literal (the write path accepts it), but a URL +/// host carrying `:` must be bracketed, so `Url::parse` rejects it — the one +/// way an assembled dial URL can fail while the record is perfectly sound. +const UNPARSABLE_HOST: &str = "2001:db8::1"; + +#[tokio::test] +async fn a_failing_dial_target_never_echoes_an_injected_credential() -> Result<()> { + let harness = ProxyHarness::with_credential_store( + &common::proxy_config(), + Arc::new(common::StaticTenantChain), + Some(Arc::clone(&vendor_credentials()) as Arc), + ); + let mut upstream = domain_upstream( + harness.tenant(), + "api.vendor.com", + Vec::from([common::hostname_endpoint(UNPARSABLE_HOST, 80)]), + true, + ); + upstream.auth = Some(auth_binding(serde_json::json!({ + "type": "apikey", + "key_ref": "cred://vendor-key", + "query_param": "api_key" + }))); + seed_route(&harness, harness.seed_upstream(upstream)); + + let capture = LogCapture::default(); + let _guard = tracing::subscriber::set_default(capture.clone()); + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!( + reply.problem_type(), + Some(problem_type("validation.error.v1")) + ); + // The parameter may be named; its value may not be spelled out. + assert!( + reply.text.contains("api_key="), + "body was: {}", + reply.text + ); + assert!(!reply.text.contains("k-123"), "body was: {}", reply.text); + for line in capture.lines() { + assert!( + !line.contains("k-123"), + "a log line carried the key: {line}" + ); + } + Ok(()) +} + +// ── oauth2: the cached client-credentials exchange ─────────────────────── + +/// A token endpoint that records every request it serves. +/// +/// The exchange is a `POST` with a form body, so a scripted socket is the only +/// way to count the exchanges and to see the whole dial. +#[derive(Clone)] +struct Idp { + listener: Arc, + /// Every request the endpoint received, oldest first. + requests: Arc>>, + /// The response to hand out, in order; the last one repeats. + script: Arc>>, +} + +impl Idp { + /// Bind an endpoint that answers with `script`, in order. + async fn bind(script: Vec) -> Result { + let listener = Arc::new(tokio::net::TcpListener::bind("127.0.0.1:0").await?); + Ok(Self { + listener, + requests: Arc::new(std::sync::Mutex::new(Vec::new())), + script: Arc::new(std::sync::Mutex::new(script)), + }) + } + + /// A token response for `token`, valid for `lifetime_secs`. + fn token(token: &str, lifetime_secs: u64) -> String { + let body = format!( + r#"{{"access_token":"{token}","expires_in":{lifetime_secs},"token_type":"Bearer"}}"# + ); + format!( + "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) + } + + /// A response the exchange rejects. + fn refusal() -> String { + let body = r#"{"error":"server_error"}"#; + format!( + "HTTP/1.1 500 Internal Server Error\r\ncontent-length: {}\r\n\r\n{body}", + body.len() + ) + } + + fn port(&self) -> u16 { + self.listener.local_addr().map_or(0, |addr| addr.port()) + } + + /// Serve until the test's runtime drops this task. + async fn serve(self) { + loop { + let Ok((mut socket, _)) = self.listener.accept().await else { + break; + }; + let request = read_request(&mut socket).await.unwrap_or_default(); + let response = self.script.lock().map_or_else( + |_| Self::refusal(), + |mut script| { + if script.len() > 1 { + script.remove(0) + } else { + script.first().cloned().unwrap_or_else(Self::refusal) + } + }, + ); + self.requests + .lock() + .map_or_else(|_| (), |mut requests| requests.push(request)); + if socket.write_all(response.as_bytes()).await.is_err() + || socket.shutdown().await.is_err() + { + break; + } + } + } + + /// How many exchanges the endpoint served. + fn calls(&self) -> usize { + self.requests.lock().map_or(0, |requests| requests.len()) + } + + /// The whole request of the `index`-th exchange. + fn request(&self, index: usize) -> String { + self.requests.lock().map_or_else( + |_| String::new(), + |requests| requests.get(index).cloned().unwrap_or_default(), + ) + } +} + +/// Read a request head plus whatever body has already arrived. +async fn read_request(socket: &mut tokio::net::TcpStream) -> Result { + let mut received = Vec::new(); + let mut buffer = [0u8; 4096]; + while !received.windows(4).any(|window| window == b"\r\n\r\n") { + let read = socket.read(&mut buffer).await?; + if read == 0 { + break; + } + received.extend_from_slice(&buffer[..read]); + } + // The form body usually arrives with the head; a short grace period keeps + // the assertion from racing the socket. + let _grace = tokio::time::timeout(Duration::from_millis(50), socket.read(&mut buffer)).await; + received.extend_from_slice(&buffer); + Ok(String::from_utf8_lossy(&received).to_string()) +} + +/// Harness with an `oauth2_client_cred` upstream binding over `idp`. +fn harness_with_oauth2( + upstream_port: u16, + idp: &Idp, + auth_method: &str, + credstore: Arc, +) -> ProxyHarness { + harness_with_auth( + upstream_port, + serde_json::json!({ + "type": auth_method, + "token_endpoint": format!("http://127.0.0.1:{}/token", idp.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret", + "scopes": "read write" + }), + Some(credstore), + ) +} + +#[tokio::test] +async fn an_oauth2_binding_exchanges_once_and_caches_the_token() -> Result<()> { + let upstream = MockServer::start(); + let mock = upstream.mock(|when, then| { + when.method(GET) + .path("/v1/chat") + .header("authorization", "Bearer tok-1"); + then.status(200).body("ok"); + }); + let idp = Idp::bind(Vec::from([Idp::token("tok-1", 3600)])).await?; + tokio::spawn(idp.clone().serve()); + let harness = harness_with_oauth2( + upstream.port(), + &idp, + "oauth2_client_cred", + vendor_credentials(), + ); + + // One caller across both requests: the token cache is keyed by the subject. + let identity = common::security_context(harness.tenant())?; + let first = harness + .proxy_as_identity(&identity, "GET", PROXY_PATH, &[], b"") + .await?; + let second = harness + .proxy_as_identity(&identity, "GET", PROXY_PATH, &[], b"") + .await?; + + assert_eq!(first.status, axum::http::StatusCode::OK); + assert_eq!(second.status, axum::http::StatusCode::OK); + assert_eq!(mock.calls(), 2); + assert_eq!( + idp.calls(), + 1, + "the second request must be served from the cache" + ); + let dial = idp.request(0); + assert!(dial.starts_with("POST /token"), "exchange was: {dial}"); + assert!( + dial.contains("grant_type=client_credentials"), + "exchange was: {dial}" + ); + assert!(dial.contains("scope=read+write"), "exchange was: {dial}"); + assert!(dial.contains("client_id=cid"), "exchange was: {dial}"); + assert!( + dial.contains("client_secret=s3cr3t"), + "exchange was: {dial}" + ); + Ok(()) +} + +#[tokio::test] +async fn the_basic_variant_sends_its_credentials_in_a_header() -> Result<()> { + let upstream = MockServer::start(); + upstream.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let idp = Idp::bind(Vec::from([Idp::token("tok-1", 3600)])).await?; + tokio::spawn(idp.clone().serve()); + let harness = harness_with_oauth2( + upstream.port(), + &idp, + "oauth2_client_cred_basic", + vendor_credentials(), + ); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert!( + idp.request(0).contains("authorization: Basic "), + "exchange was: {}", + idp.request(0) + ); + Ok(()) +} + +/// Six concurrent requests of one cold subject. +const STAMPEDE: usize = 6; + +#[tokio::test] +async fn a_cold_key_is_exchanged_once_no_matter_how_many_requests_race() -> Result<()> { + let upstream = MockServer::start(); + let mock = upstream.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let idp = Idp::bind(Vec::from([Idp::token("tok-1", 3600)])).await?; + tokio::spawn(idp.clone().serve()); + let harness = harness_with_oauth2( + upstream.port(), + &idp, + "oauth2_client_cred", + vendor_credentials(), + ); + + // One subject, many requests at once: the cache key is the same for all. + let shared = Arc::new(harness); + let identity = common::security_context(shared.tenant())?; + let mut requests = Vec::new(); + for _ in 0..STAMPEDE { + let harness = Arc::clone(&shared); + let identity = identity.clone(); + let path = PROXY_PATH; + requests.push(tokio::spawn(async move { + harness + .proxy_as_identity(&identity, "GET", path, &[], b"") + .await + })); + } + let mut statuses = Vec::new(); + for request in requests { + statuses.push(request.await??.status); + } + + assert!( + statuses + .iter() + .all(|status| *status == axum::http::StatusCode::OK), + "statuses: {statuses:?}" + ); + assert_eq!(mock.calls(), STAMPEDE); + assert_eq!( + idp.calls(), + 1, + "N concurrent cold misses must exchange once, not N times" + ); + Ok(()) +} + +#[tokio::test] +async fn the_binding_of_the_adr_shape_is_accepted_and_enforced() -> Result<()> { + let upstream = MockServer::start(); + // The exchange has to have happened for the dial to be authorised at all. + let mock = upstream.mock(|when, then| { + when.method(GET) + .path("/v1/chat") + .header("authorization", "Bearer tok-1"); + then.status(200).body("ok"); + }); + let idp = Idp::bind(Vec::from([Idp::token("tok-1", 3600)])).await?; + tokio::spawn(idp.clone().serve()); + let harness = ProxyHarness::with_credential_store( + &common::proxy_config(), + Arc::new(common::StaticTenantChain), + Some(Arc::clone(&vendor_credentials()) as Arc), + ); + // ADR-0008 "Upstream Configuration Example": the members live under + // `config`, not beside `type`. Created through the management API, so the + // write path is what has to accept the shape. + let mut payload = serde_json::json!({ + "protocol": common::PROTOCOL_HTTP, + "server": { + "endpoints": [common::endpoint("http", "127.0.0.1", upstream.port())] + } + }); + payload["auth"] = serde_json::json!({ + "type": built_in("auth", "oauth2_client_cred"), + "config": { + "token_endpoint": format!("http://127.0.0.1:{}/token", idp.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret", + "scopes": "read write" + } + }); + let created = harness + .call("POST", "/oagw/v1/upstreams", Some(payload)) + .await?; + assert_eq!( + created.status, + axum::http::StatusCode::CREATED, + "body: {}", + created.text + ); + let upstream_id = Uuid::parse_str(created.problem_field("id").context("id")?)?; + seed_route(&harness, upstream_id); + // An IP endpoint derives its alias from the endpoint, port included. + let alias = created + .json + .pointer("/alias") + .and_then(serde_json::Value::as_str) + .context("alias")?; + let path = format!("/oagw/v1/proxy/{alias}/v1/chat"); + + let reply = harness.proxy("GET", &path, &[], b"").await?; + + assert_eq!( + reply.status, + axum::http::StatusCode::OK, + "body: {}", + reply.text + ); + assert_eq!(mock.calls(), 1); + assert_eq!(idp.calls(), 1); + Ok(()) +} + +#[tokio::test] +async fn a_blank_auth_type_is_refused_on_the_write_path() -> Result<()> { + let harness = Harness::new(); + let mut payload = https_upstream("api.openai.com", 443); + payload["auth"] = serde_json::json!({ "type": " " }); + + let reply = harness + .call("POST", "/oagw/v1/upstreams", Uuid::now_v7(), Some(payload)) + .await?; + + assert_eq!( + reply.status, + axum::http::StatusCode::BAD_REQUEST, + "body: {}", + reply.text + ); + Ok(()) +} + +#[tokio::test] +async fn a_blank_auth_type_never_forwards_unauthenticated() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let harness = ProxyHarness::new(); + let mut upstream = domain_upstream( + harness.tenant(), + "api.vendor.com", + Vec::from([loopback_endpoint(server.port())]), + true, + ); + upstream.auth = Some(auth_binding(serde_json::json!({ "type": " " }))); + seed_route(&harness, harness.seed_upstream(upstream)); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + reply.problem_type(), + Some(problem_type("link.unavailable.v1")) + ); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +#[tokio::test] +async fn a_failed_exchange_is_a_401_and_is_never_cached() -> Result<()> { + let upstream = MockServer::start(); + let mock = upstream.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + // The first exchange is refused, the second one is not: if the failure had + // been cached, the second request would be a 401 again. + let idp = Idp::bind(Vec::from([Idp::refusal(), Idp::token("tok-2", 3600)])).await?; + tokio::spawn(idp.clone().serve()); + let harness = harness_with_oauth2( + upstream.port(), + &idp, + "oauth2_client_cred", + vendor_credentials(), + ); + + let refused = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + let retried = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(refused.status, axum::http::StatusCode::UNAUTHORIZED); + assert_eq!(refused.problem_type(), Some(problem_type("auth.failed.v1"))); + assert_eq!(retried.status, axum::http::StatusCode::OK); + assert_eq!(idp.calls(), 2, "a failed exchange must not be cached"); + assert_eq!(mock.calls(), 1); + Ok(()) +} + +#[tokio::test] +async fn an_oauth2_token_is_never_logged_or_serialised() -> Result<()> { + let upstream = MockServer::start(); + upstream.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let idp = Idp::bind(Vec::from([Idp::token("tok-1", 3600)])).await?; + tokio::spawn(idp.clone().serve()); + let harness = harness_with_oauth2( + upstream.port(), + &idp, + "oauth2_client_cred", + vendor_credentials(), + ); + // A second deployment whose store refuses the reference: the failure path + // is the one that renders a problem document. + let failing = harness_with_auth( + upstream.port(), + serde_json::json!({ "type": "apikey", "key_ref": "cred://ghost" }), + Some(Arc::new(MockCredStoreClient::empty())), + ); + + let capture = LogCapture::default(); + let _guard = tracing::subscriber::set_default(capture.clone()); + let first = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + let second = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + let rejected = failing.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(first.status, axum::http::StatusCode::OK); + assert_eq!(second.status, axum::http::StatusCode::OK); + assert_eq!( + rejected.status, + axum::http::StatusCode::INTERNAL_SERVER_ERROR + ); + for line in capture.lines() { + assert!( + !line.contains("tok-1"), + "a log line carried the token: {line}" + ); + assert!( + !line.contains("s3cr3t"), + "a log line carried a credential: {line}" + ); + } + assert!( + !rejected.text.contains("s3cr3t"), + "a problem document carried a credential: {}", + rejected.text + ); + Ok(()) +} + +// ── required_headers: presence-only enforcement (ADR-0009) ────────────── + +/// A harness whose upstream carries a chain of `bindings`. +fn harness_with_chain(port: u16, bindings: Vec) -> ProxyHarness { + let harness = ProxyHarness::new(); + seed_upstream_on(&harness, port, Some(chain(bindings))); + harness +} + +#[tokio::test] +async fn a_request_without_a_required_header_is_rejected_with_400() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let harness = harness_with_chain( + server.port(), + Vec::from([required_headers(serde_json::json!({ + "required_request_headers": "X-Correlation-Id" + }))]), + ); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!( + reply.problem_type(), + Some(problem_type("validation.error.v1")) + ); + assert_eq!( + reply.problem_field("error_code"), + Some("REQUIRED_HEADER_MISSING") + ); + assert_eq!( + reply.problem_field("missing_header"), + Some("x-correlation-id") + ); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +#[tokio::test] +async fn a_request_carrying_the_required_header_is_forwarded() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/v1/chat") + .header("x-correlation-id", "corr-1"); + then.status(200).body("ok"); + }); + let harness = harness_with_chain( + server.port(), + Vec::from([required_headers(serde_json::json!({ + "required_request_headers": "X-Correlation-Id" + }))]), + ); + + let reply = harness + .proxy("GET", PROXY_PATH, &[("x-correlation-id", "corr-1")], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert_eq!(mock.calls(), 1); + Ok(()) +} + +#[tokio::test] +async fn a_bare_reference_binding_is_accepted_and_fails_open() -> Result<()> { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let harness = harness_with_chain( + server.port(), + Vec::from([bare(built_in("guard", "required_headers"))]), + ); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + // Fail-open: the binding carries no configuration, so the guard objects to + // nothing (ADR-0009). + assert_eq!(reply.status, axum::http::StatusCode::OK); + Ok(()) +} + +#[tokio::test] +async fn an_upstream_response_without_a_required_header_is_a_502() -> Result<()> { + let (port, _dial) = scripted_upstream(ANSWER).await?; + let harness = harness_with_chain( + port, + Vec::from([required_headers(serde_json::json!({ + "required_response_headers": "x-signature" + }))]), + ); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::BAD_GATEWAY); + assert_eq!( + reply.problem_type(), + Some(problem_type("protocol.error.v1")) + ); + assert_eq!( + reply.problem_field("error_code"), + Some("REQUIRED_HEADER_MISSING") + ); + assert_eq!(reply.problem_field("missing_header"), Some("x-signature")); + assert_eq!(reply.header(ERROR_SOURCE), Some("gateway")); + Ok(()) +} + +/// A harness whose upstream strips `name` from its own response. +/// +/// The response header rules are the one way the gateway can drop a header the +/// upstream did send. +fn harness_with_response_rule(port: u16, name: &str) -> ProxyHarness { + let harness = ProxyHarness::new(); + let mut upstream = domain_upstream( + harness.tenant(), + "api.vendor.com", + Vec::from([loopback_endpoint(port)]), + true, + ); + upstream.plugins = Some(chain(Vec::from([required_headers(serde_json::json!({ + "required_response_headers": name + }))]))); + upstream.headers = Some(oagw::domain::model::HeadersConfig { + request: None, + response: Some(oagw::domain::model::ResponseHeaderRules { + set: std::collections::HashMap::new(), + add: std::collections::HashMap::new(), + remove: Vec::from([name.to_owned()]), + }), + }); + seed_route(&harness, harness.seed_upstream(upstream)); + harness +} + +#[tokio::test] +async fn a_response_rule_that_strips_a_required_header_is_not_a_502() -> Result<()> { + // The upstream signs its answer; the response rule removes the signature + // before it reaches the client. + let (port, _dial) = scripted_upstream(SIGNED_ANSWER).await?; + let harness = harness_with_response_rule(port, "x-signature"); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + // The guard asks whether the *upstream* sent the header, so a rule that + // strips it for the client is not the upstream failing the contract. + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert_eq!(reply.header("x-signature"), None); + Ok(()) +} + +#[tokio::test] +async fn an_upstream_response_carrying_the_required_header_is_forwarded() -> Result<()> { + let (port, _dial) = scripted_upstream(SIGNED_ANSWER).await?; + let harness = harness_with_chain( + port, + Vec::from([required_headers(serde_json::json!({ + "required_response_headers": "x-signature" + }))]), + ); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + Ok(()) +} + +#[tokio::test] +async fn only_the_first_missing_header_is_reported() -> Result<()> { + let server = MockServer::start(); + let harness = harness_with_chain( + server.port(), + Vec::from([required_headers(serde_json::json!({ + "required_request_headers": "accept,x-tenant-id,x-signature" + }))]), + ); + + let reply = harness + .proxy("GET", PROXY_PATH, &[("accept", "*/*")], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!(reply.problem_field("missing_header"), Some("x-tenant-id")); + Ok(()) +} + +// ── request_id: correlation id injection and propagation ──────────────── + +#[tokio::test] +async fn a_request_without_an_id_leaves_with_a_generated_one() -> Result<()> { + let (port, dial) = scripted_upstream(ANSWER_WITH_ID).await?; + let harness = harness_with_chain(port, Vec::from([request_id()])); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + let injected = dial + .await?? + .lines() + .find(|line| line.to_ascii_lowercase().starts_with("x-request-id:")) + .unwrap_or_default() + .to_owned(); + assert_eq!( + injected.len(), + "x-request-id: ".len() + 36, + "dial was: {injected}" + ); + Ok(()) +} + +#[tokio::test] +async fn a_caller_provided_id_is_never_overwritten() -> Result<()> { + let (port, dial) = scripted_upstream(ANSWER_WITH_ID).await?; + let harness = harness_with_chain(port, Vec::from([request_id()])); + + let reply = harness + .proxy("GET", PROXY_PATH, &[("x-request-id", "caller-id")], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert!( + dial.await??.contains("x-request-id: caller-id"), + "the dial carried the caller's id" + ); + Ok(()) +} + +#[tokio::test] +async fn the_upstream_id_is_propagated_back_to_the_client() -> Result<()> { + let (port, _dial) = scripted_upstream(ANSWER_WITH_ID).await?; + let harness = harness_with_chain(port, Vec::from([request_id()])); + + // The upstream replaces the id it was handed, and the replacement is what + // the caller can correlate its request against. + let reply = harness + .proxy("GET", PROXY_PATH, &[("x-request-id", "caller-id")], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert_eq!(reply.header("x-request-id"), Some("upstream-id")); + Ok(()) +} + +// ── chain composition and fail-closed references ──────────────────────── + +#[tokio::test] +async fn a_chain_reference_without_a_record_is_a_503_problem() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let unknown = Uuid::now_v7(); + let harness = harness_with_chain(server.port(), Vec::from([bare(unknown.to_string())])); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + reply.problem_type(), + Some(problem_type("plugin.not_found.v1")) + ); + assert!( + reply.text.contains(&unknown.to_string()), + "detail was: {}", + reply.text + ); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +#[tokio::test] +async fn a_catalogued_but_unboundable_reference_is_a_503_problem() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let harness = harness_with_chain(server.port(), Vec::from([bare(built_in("guard", "cors"))])); + + let reply = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + reply.problem_type(), + Some(problem_type("plugin.not_found.v1")) + ); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +#[tokio::test] +async fn the_upstream_and_the_route_chains_both_run() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).header("x-signature", "sig").body("ok"); + }); + let harness = ProxyHarness::new(); + seed_pair( + &harness, + server.port(), + chain(Vec::from([required_headers(serde_json::json!({ + "required_request_headers": "x-correlation-id" + }))])), + Vec::from([required_headers(serde_json::json!({ + "required_response_headers": "x-signature" + }))]), + ); + + let without = harness.proxy("GET", PROXY_PATH, &[], b"").await?; + let with = harness + .proxy("GET", PROXY_PATH, &[("x-correlation-id", "corr-1")], b"") + .await?; + + // The upstream guard objects to the missing request header, the route guard + // to the missing response header: both halves of the chain ran. + assert_eq!(without.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!( + without.problem_field("missing_header"), + Some("x-correlation-id") + ); + assert_eq!(with.status, axum::http::StatusCode::OK); + assert_eq!(mock.calls(), 1); + Ok(()) +} + +#[tokio::test] +async fn an_upstream_guard_survives_a_route_chain() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let harness = ProxyHarness::new(); + seed_pair( + &harness, + server.port(), + chain(Vec::from([required_headers(serde_json::json!({ + "required_request_headers": "x-unreachable-header" + }))])), + Vec::from([required_headers(serde_json::json!({ + "required_request_headers": "x-correlation-id" + }))]), + ); + + // The chain is the concatenation of the two, so the upstream requirement is + // still in force and the request is refused before the route guard runs. + let reply = harness + .proxy("GET", PROXY_PATH, &[("x-correlation-id", "corr-1")], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!( + reply.problem_field("missing_header"), + Some("x-unreachable-header") + ); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +#[tokio::test] +async fn the_route_chain_runs_after_the_upstream_chain() -> Result<()> { + let server = MockServer::start(); + let harness = ProxyHarness::new(); + // Two upstream bindings: the first is overridden by the route, the second + // stays in force, so a route must not silently drop a guard. + seed_pair( + &harness, + server.port(), + chain(Vec::from([ + required_headers( + serde_json::json!({ "required_request_headers": "x-unreachable-header" }), + ), + required_headers(serde_json::json!({ "required_request_headers": "x-signature" })), + ])), + Vec::from([required_headers(serde_json::json!({ + "required_request_headers": "x-correlation-id" + }))]), + ); + + let reply = harness + .proxy("GET", PROXY_PATH, &[("x-correlation-id", "corr-1")], b"") + .await?; + + // The first binding of the merged chain is the first upstream one, so it is + // the first requirement reported. + assert_eq!(reply.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!( + reply.problem_field("missing_header"), + Some("x-unreachable-header") + ); + Ok(()) +} + +#[tokio::test] +async fn an_enforced_upstream_chain_runs_before_the_route_chain() -> Result<()> { + let server = MockServer::start(); + let harness = ProxyHarness::new(); + let upstream_chain = PluginsConfig { + sharing: SharingMode::Enforce, + items: Vec::from([required_headers(serde_json::json!({ + "required_request_headers": "x-unreachable-header" + }))]), + }; + seed_pair( + &harness, + server.port(), + upstream_chain, + Vec::from([required_headers(serde_json::json!({ + "required_request_headers": "x-correlation-id" + }))]), + ); + + // Under `enforce` the upstream chain is the head of the merged one, which + // is why the request is refused before the route guard is reached. + let reply = harness + .proxy("GET", PROXY_PATH, &[("x-correlation-id", "corr-1")], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!( + reply.problem_field("missing_header"), + Some("x-unreachable-header") + ); + Ok(()) +} diff --git a/gears/system/oagw/oagw/tests/plugins_api_test.rs b/gears/system/oagw/oagw/tests/plugins_api_test.rs new file mode 100644 index 0000000..4b76938 --- /dev/null +++ b/gears/system/oagw/oagw/tests/plugins_api_test.rs @@ -0,0 +1,954 @@ +// Created: 2026-08-31 by Constructor Tech +// @cpt-dod:cpt-cf-oagw-dod-testing-rest-api:p2 +//! Custom-plugin management API (ADR-0002): immutability (no PUT), the +//! `text/plain` source endpoint, name conflicts and the ADR-0001 +//! `plugin.in_use` deletion guard. + +mod common; + +use anyhow::{Context, Result}; +use axum::http::{StatusCode, header}; +use uuid::Uuid; + +use common::{ + AUTH_APIKEY, AUTH_PLUGIN_STEM, Harness, PROTOCOL_HTTP, TRANSFORM_PLUGIN_STEM, endpoint, + https_upstream, plugin_payload, +}; + +fn tenant() -> Uuid { + Uuid::now_v7() +} + +const SOURCE: &str = "def transform(ctx, config):\n ctx.request.headers['x-trace'] = 'on'\n"; + +async fn seed_plugin(harness: &Harness, owner: Uuid, name: &str) -> Result { + let reply = harness + .call( + "POST", + "/oagw/v1/plugins", + owner, + Some(plugin_payload(name, SOURCE)), + ) + .await?; + assert_eq!(reply.status, StatusCode::CREATED, "body: {}", reply.text); + let id = reply.problem_field("id").context("id")?; + Ok(Uuid::parse_str(id)?) +} + +async fn seed_upstream(harness: &Harness, owner: Uuid, host: &str) -> Result { + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(https_upstream(host, 443)), + ) + .await?; + assert_eq!(reply.status, StatusCode::CREATED, "body: {}", reply.text); + let id = reply.problem_field("id").context("id")?; + Ok(Uuid::parse_str(id)?) +} + +// ── Creation & shape ───────────────────────────────────────────────────── + +#[tokio::test] +async fn create_plugin_returns_201_with_the_bare_uuid() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let reply = harness + .call( + "POST", + "/oagw/v1/plugins", + owner, + Some(plugin_payload("redact", SOURCE)), + ) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + let id = reply.problem_field("id").context("id")?; + assert!( + Uuid::parse_str(id).is_ok(), + "expected a bare UUID, got {id}" + ); + assert_eq!(reply.problem_field("plugin_type"), Some("transform")); + assert_eq!(reply.problem_field("name"), Some("redact")); + assert_eq!(reply.problem_field("source_code"), Some(SOURCE)); + assert_eq!(reply.problem_field("enabled"), None); + Ok(()) +} + +#[tokio::test] +async fn create_plugin_requires_a_name_and_source() -> Result<()> { + let harness = Harness::new(); + let missing_name = harness + .call( + "POST", + "/oagw/v1/plugins", + tenant(), + Some(serde_json::json!({ "plugin_type": "transform", "source_code": SOURCE })), + ) + .await?; + assert_eq!(missing_name.status, StatusCode::BAD_REQUEST); + + let missing_source = harness + .call( + "POST", + "/oagw/v1/plugins", + tenant(), + Some(serde_json::json!({ "name": "redact", "plugin_type": "transform" })), + ) + .await?; + assert_eq!(missing_source.status, StatusCode::BAD_REQUEST); + + let unknown_kind = harness + .call( + "POST", + "/oagw/v1/plugins", + tenant(), + Some(serde_json::json!({ "name": "redact", "plugin_type": "widget", "source_code": SOURCE })), + ) + .await?; + assert_eq!(unknown_kind.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn an_empty_or_oversized_source_is_rejected() -> Result<()> { + let harness = Harness::new(); + let blank = harness + .call( + "POST", + "/oagw/v1/plugins", + tenant(), + Some(plugin_payload("blank", " \n")), + ) + .await?; + assert_eq!( + blank.status, + StatusCode::BAD_REQUEST, + "body: {}", + blank.text + ); + + let oversized = "x".repeat(256 * 1024 + 1); + let reply = harness + .call( + "POST", + "/oagw/v1/plugins", + tenant(), + Some(plugin_payload("big", &oversized)), + ) + .await?; + assert_eq!( + reply.status, + StatusCode::BAD_REQUEST, + "body: {}", + reply.text + ); + Ok(()) +} + +#[tokio::test] +async fn an_oversized_plugin_config_is_rejected() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "name": "blobby", + "plugin_type": "transform", + "source_code": SOURCE, + "config": { "blob": "x".repeat(16 * 1024 + 1) } + }); + let reply = harness + .call("POST", "/oagw/v1/plugins", tenant(), Some(payload)) + .await?; + + assert_eq!( + reply.status, + StatusCode::BAD_REQUEST, + "body: {}", + reply.text + ); + Ok(()) +} + +#[tokio::test] +async fn duplicate_plugin_name_is_409() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + seed_plugin(&harness, owner, "redact").await?; + let second = harness + .call( + "POST", + "/oagw/v1/plugins", + owner, + Some(plugin_payload("redact", SOURCE)), + ) + .await?; + + assert_eq!(second.status, StatusCode::CONFLICT); + assert_eq!( + second.problem_type(), + Some(common::problem_type("plugin.conflict.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn the_same_plugin_name_is_fine_in_another_tenant() -> Result<()> { + let harness = Harness::new(); + seed_plugin(&harness, tenant(), "redact").await?; + let reply = harness + .call( + "POST", + "/oagw/v1/plugins", + tenant(), + Some(plugin_payload("redact", SOURCE)), + ) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + Ok(()) +} + +#[tokio::test] +async fn plugins_are_immutable_put_is_405() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let id = seed_plugin(&harness, owner, "redact").await?; + + let reply = harness + .call( + "PUT", + &format!("/oagw/v1/plugins/{id}"), + owner, + Some(plugin_payload("renamed", SOURCE)), + ) + .await?; + + assert_eq!(reply.status, StatusCode::METHOD_NOT_ALLOWED); + Ok(()) +} + +// ── Read / source ──────────────────────────────────────────────────────── + +#[tokio::test] +async fn get_plugin_accepts_the_gts_form_of_the_id() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let id = seed_plugin(&harness, owner, "redact").await?; + let gts_id = format!("{TRANSFORM_PLUGIN_STEM}{id}"); + + let reply = harness + .call("GET", &format!("/oagw/v1/plugins/{gts_id}"), owner, None) + .await?; + + assert_eq!(reply.status, StatusCode::OK); + assert_eq!(reply.problem_field("id"), Some(id.to_string().as_str())); + Ok(()) +} + +#[tokio::test] +async fn source_endpoint_returns_text_plain() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let id = seed_plugin(&harness, owner, "redact").await?; + + let bare = harness + .call("GET", &format!("/oagw/v1/plugins/{id}/source"), owner, None) + .await?; + assert_eq!(bare.status, StatusCode::OK); + let content_type = bare + .headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .context("content-type")?; + assert!(content_type.starts_with("text/plain"), "got {content_type}"); + assert_eq!(bare.text, SOURCE); + + let gts_id = format!("{TRANSFORM_PLUGIN_STEM}{id}"); + let gts = harness + .call( + "GET", + &format!("/oagw/v1/plugins/{gts_id}/source"), + owner, + None, + ) + .await?; + assert_eq!(gts.status, StatusCode::OK); + assert_eq!(gts.text, SOURCE); + Ok(()) +} + +#[tokio::test] +async fn foreign_plugins_are_invisible() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let id = seed_plugin(&harness, owner, "redact").await?; + let stranger = tenant(); + + let reply = harness + .call("GET", &format!("/oagw/v1/plugins/{id}"), stranger, None) + .await?; + assert_eq!(reply.status, StatusCode::NOT_FOUND); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("plugin.not_found.v1")) + ); + + let source = harness + .call( + "GET", + &format!("/oagw/v1/plugins/{id}/source"), + stranger, + None, + ) + .await?; + assert_eq!(source.status, StatusCode::NOT_FOUND); + Ok(()) +} + +#[tokio::test] +async fn plugin_lists_scopes_to_the_tenant() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + seed_plugin(&harness, owner, "redact").await?; + seed_plugin(&harness, owner, "annotate").await?; + + let mine = harness.call("GET", "/oagw/v1/plugins", owner, None).await?; + assert_eq!( + mine.json + .pointer("/items") + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(2) + ); + // `$orderby=name asc`. + let ordered = harness + .call("GET", "/oagw/v1/plugins?$orderby=name%20asc", owner, None) + .await?; + let items = ordered + .json + .pointer("/items") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + let names: Vec<&str> = items + .iter() + .filter_map(|item| item.get("name").and_then(serde_json::Value::as_str)) + .collect(); + assert_eq!(names, vec!["annotate", "redact"]); + Ok(()) +} + +// ── Binding validation (DESIGN §3.2 resolution algorithm) ──────────────── + +#[tokio::test] +async fn binding_a_resolvable_built_in_plugin_is_accepted() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "sharing": "private", "items": [ + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1" + ] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", owner, Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED, "body: {}", reply.text); + let items = reply + .json + .pointer("/plugins/items") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + assert_eq!(items.len(), 1); + Ok(()) +} + +#[tokio::test] +async fn binding_a_catalogued_but_unresolvable_plugin_is_rejected() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": ["gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1"] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn binding_an_unknown_plugin_reference_is_rejected() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": ["not-a-plugin-reference"] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn binding_a_foreign_custom_plugin_reports_503() -> Result<()> { + let harness = Harness::new(); + let plugin_id = seed_plugin(&harness, tenant(), "redact").await?; + let reference = format!("{TRANSFORM_PLUGIN_STEM}{plugin_id}"); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": [reference] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!( + reply.status, + StatusCode::SERVICE_UNAVAILABLE, + "body: {}", + reply.text + ); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("plugin.not_found.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn a_bare_uuid_plugin_reference_resolves() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let plugin_id = seed_plugin(&harness, owner, "redact").await?; + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": [plugin_id.to_string()] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", owner, Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED, "body: {}", reply.text); + Ok(()) +} + +#[tokio::test] +async fn a_bare_uuid_reference_to_an_unknown_plugin_reports_503() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": [Uuid::new_v4().to_string()] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!( + reply.status, + StatusCode::SERVICE_UNAVAILABLE, + "body: {}", + reply.text + ); + Ok(()) +} + +#[tokio::test] +async fn a_configured_binding_is_accepted_and_round_trips_verbatim() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": [ + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1", + { + "plugin_ref": "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1", + "config": { + "required_request_headers": " x-correlation-id , X-Tenant-Id ", + "required_response_headers": "x-signature" + } + } + ] } + }); + // The configured binding keeps every member the caller sent, so a body that + // was parsed and re-serialised is byte-identical to the one sent. + let sent = serde_json::to_string(&payload.pointer("/plugins/items")) + .context("serialising the request items")?; + let reply = harness + .call("POST", "/oagw/v1/upstreams", owner, Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED, "body: {}", reply.text); + let stored = serde_json::to_string(&reply.json.pointer("/plugins/items")) + .context("serialising the stored items")?; + assert_eq!(stored, sent); + Ok(()) +} + +#[tokio::test] +async fn a_configured_binding_is_accepted_on_a_route() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + let mut payload = common::route_payload(upstream_id, &["GET"], "/v1/chat"); + payload["plugins"] = serde_json::json!({ "items": [{ + "plugin_ref": "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1", + "config": { "required_request_headers": "x-correlation-id" } + }] }); + let reply = harness + .call("POST", "/oagw/v1/routes", owner, Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED, "body: {}", reply.text); + let item = reply + .json + .pointer("/plugins/items/0") + .cloned() + .unwrap_or_default(); + assert_eq!( + item.get("plugin_ref").and_then(serde_json::Value::as_str), + Some("gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1") + ); + assert_eq!( + item.pointer("/config/required_request_headers") + .and_then(serde_json::Value::as_str), + Some("x-correlation-id") + ); + Ok(()) +} + +#[tokio::test] +async fn a_configured_binding_without_a_plugin_ref_is_rejected() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": [{ "config": { "header": "x-trace" } }] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!( + reply.status, + StatusCode::BAD_REQUEST, + "body: {}", + reply.text + ); + Ok(()) +} + +#[tokio::test] +async fn a_configured_binding_of_an_unresolvable_plugin_is_rejected() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": [{ + "plugin_ref": "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1", + "config": { "required_request_headers": "x-correlation-id" } + }] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!( + reply.status, + StatusCode::BAD_REQUEST, + "body: {}", + reply.text + ); + Ok(()) +} + +#[tokio::test] +async fn a_configured_binding_of_a_foreign_plugin_reports_503() -> Result<()> { + let harness = Harness::new(); + let plugin_id = seed_plugin(&harness, tenant(), "redact").await?; + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": [{ + "plugin_ref": format!("{TRANSFORM_PLUGIN_STEM}{plugin_id}"), + "config": { "header": "x-trace" } + }] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!( + reply.status, + StatusCode::SERVICE_UNAVAILABLE, + "body: {}", + reply.text + ); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("plugin.not_found.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn an_auth_plugin_in_the_plugin_chain_is_rejected() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": [AUTH_APIKEY] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!( + reply.status, + StatusCode::BAD_REQUEST, + "body: {}", + reply.text + ); + assert!( + reply.text.contains("auth"), + "the rejection must name the family: {}", + reply.text + ); + Ok(()) +} + +#[tokio::test] +async fn a_bare_auth_plugin_name_in_the_plugin_chain_is_rejected() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": ["apikey"] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!( + reply.status, + StatusCode::BAD_REQUEST, + "body: {}", + reply.text + ); + assert!( + reply.text.contains("'auth' binding"), + "the rejection must point at the auth member: {}", + reply.text + ); + Ok(()) +} + +#[tokio::test] +async fn an_auth_plugin_of_a_custom_record_in_the_chain_is_rejected() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let payload = serde_json::json!({ + "name": "gateway-login", + "plugin_type": "auth", + "source_code": "def transform(ctx, config):\n pass\n", + "config": { "header": "x-trace" } + }); + let created = harness + .call("POST", "/oagw/v1/plugins", owner, Some(payload)) + .await?; + assert_eq!( + created.status, + StatusCode::CREATED, + "body: {}", + created.text + ); + let id = Uuid::parse_str(created.problem_field("id").context("id")?)?; + let binding = serde_json::json!({ + "plugin_ref": format!("{AUTH_PLUGIN_STEM}{id}"), + "config": { "header": "x-trace" } + }); + let upstream = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": [binding] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", owner, Some(upstream)) + .await?; + + assert_eq!( + reply.status, + StatusCode::BAD_REQUEST, + "body: {}", + reply.text + ); + Ok(()) +} + +#[tokio::test] +async fn the_nested_auth_config_of_the_adr_shape_is_accepted() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "login.microsoftonline.com", 443)] }, + "auth": { + "type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1", + "config": { + "token_endpoint": "https://login.microsoftonline.com/tenant/oauth2/v2.0/token", + "client_id_ref": "cred://ms-graph-client-id", + "client_secret_ref": "cred://ms-graph-client-secret", + "scopes": "https://graph.microsoft.com/.default" + } + } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED, "body: {}", reply.text); + let stored = reply.json.pointer("/auth"); + assert_eq!( + stored + .and_then(|auth| auth.get("type")) + .and_then(serde_json::Value::as_str), + Some("gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1") + ); + assert_eq!( + stored + .and_then(|auth| auth.pointer("/config/client_id_ref")) + .and_then(serde_json::Value::as_str), + Some("cred://ms-graph-client-id") + ); + Ok(()) +} + +#[tokio::test] +async fn a_blank_auth_type_is_refused() -> Result<()> { + let harness = Harness::new(); + let mut payload = https_upstream("api.openai.com", 443); + payload["auth"] = serde_json::json!({ "type": " " }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!( + reply.status, + StatusCode::BAD_REQUEST, + "body: {}", + reply.text + ); + Ok(()) +} + +#[tokio::test] +async fn a_plugin_reference_of_the_wrong_family_is_rejected() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let plugin_id = seed_plugin(&harness, owner, "redact").await?; + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": [common::gts_resource_id("auth_plugin", plugin_id)] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", owner, Some(payload)) + .await?; + + assert_eq!( + reply.status, + StatusCode::BAD_REQUEST, + "body: {}", + reply.text + ); + Ok(()) +} + +// ── Deletion (ADR-0001 plugin deletion behaviour) ──────────────────────── + +#[tokio::test] +async fn unbound_plugin_deletes_with_204() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let id = seed_plugin(&harness, owner, "redact").await?; + + let deleted = harness + .call("DELETE", &format!("/oagw/v1/plugins/{id}"), owner, None) + .await?; + assert_eq!(deleted.status, StatusCode::NO_CONTENT); + assert!(deleted.text.is_empty()); + + let gone = harness + .call("GET", &format!("/oagw/v1/plugins/{id}"), owner, None) + .await?; + assert_eq!(gone.status, StatusCode::NOT_FOUND); + Ok(()) +} + +#[tokio::test] +async fn plugin_bound_to_an_upstream_is_in_use() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let plugin_id = seed_plugin(&harness, owner, "redact").await?; + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": [format!("{TRANSFORM_PLUGIN_STEM}{plugin_id}")] } + }); + let upstream = harness + .call("POST", "/oagw/v1/upstreams", owner, Some(payload)) + .await?; + assert_eq!( + upstream.status, + StatusCode::CREATED, + "body: {}", + upstream.text + ); + + let reply = harness + .call( + "DELETE", + &format!("/oagw/v1/plugins/{plugin_id}"), + owner, + None, + ) + .await?; + + assert_eq!(reply.status, StatusCode::CONFLICT, "body: {}", reply.text); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("plugin.in_use.v1")) + ); + assert_eq!( + reply + .json + .get("plugin_id") + .and_then(serde_json::Value::as_str), + Some(plugin_id.to_string().as_str()) + ); + let referenced = reply + .json + .pointer("/referenced_by/upstreams") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + assert_eq!(referenced.len(), 1); + // The record survives. + let still_there = harness + .call("GET", &format!("/oagw/v1/plugins/{plugin_id}"), owner, None) + .await?; + assert_eq!(still_there.status, StatusCode::OK); + Ok(()) +} + +#[tokio::test] +async fn plugin_bound_to_a_route_is_in_use_with_route_references() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let plugin_id = seed_plugin(&harness, owner, "redact").await?; + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + let payload = serde_json::json!({ + "upstream_id": upstream_id.to_string(), + "match": { "http": { "methods": ["GET"], "path": "/v1/chat" } }, + "plugins": { "items": [format!("{TRANSFORM_PLUGIN_STEM}{plugin_id}")] } + }); + harness + .call("POST", "/oagw/v1/routes", owner, Some(payload)) + .await?; + + let reply = harness + .call( + "DELETE", + &format!("/oagw/v1/plugins/{plugin_id}"), + owner, + None, + ) + .await?; + + assert_eq!(reply.status, StatusCode::CONFLICT); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("plugin.in_use.v1")) + ); + assert_eq!( + reply + .json + .pointer("/referenced_by/routes") + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(1) + ); + assert_eq!( + reply + .json + .pointer("/referenced_by/upstreams") + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(0) + ); + Ok(()) +} + +#[tokio::test] +async fn deleting_the_binding_frees_the_plugin() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let plugin_id = seed_plugin(&harness, owner, "redact").await?; + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "plugins": { "items": [format!("{TRANSFORM_PLUGIN_STEM}{plugin_id}")] } + }); + let upstream = harness + .call("POST", "/oagw/v1/upstreams", owner, Some(payload)) + .await?; + let upstream_id = upstream.problem_field("id").context("id")?; + + // Replacing the upstream without the plugin binding clears the reference. + let replacement = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] } + }); + harness + .call( + "PUT", + &format!("/oagw/v1/upstreams/{upstream_id}"), + owner, + Some(replacement), + ) + .await?; + + let deleted = harness + .call( + "DELETE", + &format!("/oagw/v1/plugins/{plugin_id}"), + owner, + None, + ) + .await?; + assert_eq!(deleted.status, StatusCode::NO_CONTENT); + Ok(()) +} + +#[tokio::test] +async fn deleting_a_foreign_plugin_is_404() -> Result<()> { + let harness = Harness::new(); + let id = seed_plugin(&harness, tenant(), "redact").await?; + let reply = harness + .call("DELETE", &format!("/oagw/v1/plugins/{id}"), tenant(), None) + .await?; + assert_eq!(reply.status, StatusCode::NOT_FOUND); + Ok(()) +} diff --git a/gears/system/oagw/oagw/tests/proxy_api_test.rs b/gears/system/oagw/oagw/tests/proxy_api_test.rs new file mode 100644 index 0000000..487c572 --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_api_test.rs @@ -0,0 +1,1461 @@ +// Created: 2026-08-31 by Constructor Tech +// @cpt-dod:cpt-cf-oagw-dod-testing-proxy-api:p2 +//! Proxy data plane: alias resolution, route matching, URL construction, +//! header transformation, body guards, target-endpoint selection (ADR-0001) +//! and the error-source distinction (ADR-0007), against real local upstreams. +//! +//! Ordinary request/response cases run against `httpmock`; chunked bodies, +//! event streams and a connection that never answers need raw sockets, which +//! [`common::RawUpstream`] provides. + +mod common; + +use anyhow::{Context, Result}; +use common::{ + ERROR_SOURCE, ProxyHarness, TARGET_HOST, domain_route, domain_upstream, loopback_endpoint, + problem_type, +}; +use httpmock::prelude::{GET, MockServer, POST}; +use oagw::domain::model::{HttpMethod, PathSuffixMode, RouteMatch}; +use tokio::io::AsyncWriteExt as _; +use uuid::Uuid; + +/// Harness with an upstream whose alias is `api.vendor.com` over `port`. +/// +/// The record is seeded directly: the alias-derivation rules of the write path +/// are slice-1 behaviour, and the data plane needs a routing key that does not +/// contain the ephemeral port. +fn harness_with_upstream( + port: u16, + headers: Option, +) -> ProxyHarness { + let harness = ProxyHarness::new(); + let owner = harness.tenant(); + let mut upstream = domain_upstream( + owner, + "api.vendor.com", + Vec::from([loopback_endpoint(port)]), + true, + ); + upstream.headers = headers; + let id = harness.seed_upstream(upstream); + let route = domain_route( + owner, + id, + &[HttpMethod::Get, HttpMethod::Post], + "/v1/chat", + &[], + ); + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| { + panic!("the test route must seed: {error}"); + }); + harness +} + +/// A route whose `path_suffix_mode` is `disabled`. +fn add_strict_route(harness: &ProxyHarness, owner: Uuid, upstream_id: Uuid) { + let mut route = domain_route( + owner, + upstream_id, + &[HttpMethod::Get, HttpMethod::Post], + "/v1/strict", + &[], + ); + if let Some(http) = route.match_rule.http.as_mut() { + http.path_suffix_mode = PathSuffixMode::Disabled; + } + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| { + panic!("the strict route must seed: {error}"); + }); +} + +/// A route that only forwards the allowlisted query parameters. +/// +/// The route keeps the default `path_suffix_mode: append`, so the tests can +/// also probe what a path suffix may and may not add to the dial. +fn add_query_route(harness: &ProxyHarness, owner: Uuid, upstream_id: Uuid, allow: &[&str]) { + let route = domain_route( + owner, + upstream_id, + &[HttpMethod::Get, HttpMethod::Post], + "/v1/search", + allow, + ); + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| { + panic!("the query route must seed: {error}"); + }); +} + +/// Harness plus the id of the seeded upstream. +struct Seeded { + harness: ProxyHarness, + owner: Uuid, + upstream_id: Uuid, +} + +/// Harness with an upstream and two routes: `/v1/chat` and `/v1/strict`. +fn seeded_with_strict_route(port: u16) -> Seeded { + let harness = ProxyHarness::new(); + let owner = harness.tenant(); + let upstream_id = harness.seed_upstream(domain_upstream( + owner, + "api.vendor.com", + Vec::from([loopback_endpoint(port)]), + true, + )); + let route = domain_route( + owner, + upstream_id, + &[HttpMethod::Get, HttpMethod::Post], + "/v1/chat", + &[], + ); + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| { + panic!("the test route must seed: {error}"); + }); + add_strict_route(&harness, owner, upstream_id); + Seeded { + harness, + owner, + upstream_id, + } +} + +// ── Happy path ─────────────────────────────────────────────────────────── + +#[tokio::test] +async fn a_get_request_reaches_the_upstream_and_is_marked_upstream() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"model":"gpt-4"}"#); + }); + let harness = harness_with_upstream(server.port(), None); + + let reply = harness + .proxy( + "GET", + "/oagw/v1/proxy/api.vendor.com/v1/chat", + &[("x-trace", "trace-1")], + b"", + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert_eq!(reply.text, r#"{"model":"gpt-4"}"#); + assert_eq!(reply.header(ERROR_SOURCE), Some("upstream")); + assert_eq!(mock.calls(), 1); + Ok(()) +} + +#[tokio::test] +async fn the_proxy_path_is_forwarded_verbatim_behind_the_alias() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat/items"); + then.status(200).body("ok"); + }); + let harness = harness_with_upstream(server.port(), None); + + let reply = harness + .proxy( + "GET", + "/oagw/v1/proxy/api.vendor.com/v1/chat/items", + &[], + b"", + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert_eq!(mock.calls(), 1); + Ok(()) +} + +#[tokio::test] +async fn a_post_body_is_forwarded_with_its_content_type() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/v1/chat") + .header("content-type", "application/json") + .body(r#"{"prompt":"hi"}"#); + then.status(201).body("created"); + }); + let harness = harness_with_upstream(server.port(), None); + + let reply = harness + .proxy( + "POST", + "/oagw/v1/proxy/api.vendor.com/v1/chat", + &[("content-type", "application/json")], + br#"{"prompt":"hi"}"#, + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::CREATED); + assert_eq!(reply.text, "created"); + assert_eq!(mock.calls(), 1); + Ok(()) +} + +// ── Alias and route resolution ─────────────────────────────────────────── + +#[tokio::test] +async fn an_unknown_alias_is_a_404_problem() -> Result<()> { + let harness = ProxyHarness::new(); + + let reply = harness + .proxy("GET", "/oagw/v1/proxy/ghost.vendor.com/v1", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::NOT_FOUND); + assert_eq!( + reply.problem_type(), + Some(problem_type("route.not_found.v1")) + ); + assert_eq!(reply.problem_field("alias"), Some("ghost.vendor.com")); + assert_eq!(reply.header(ERROR_SOURCE), Some("gateway")); + Ok(()) +} + +#[tokio::test] +async fn a_request_without_a_matching_route_is_a_404_problem() -> Result<()> { + let harness = harness_with_upstream(1, None); + + let reply = harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com/v9/unknown", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::NOT_FOUND); + assert_eq!( + reply.problem_type(), + Some(problem_type("route.not_found.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn a_method_outside_the_allowlist_is_a_404_problem() -> Result<()> { + let harness = harness_with_upstream(1, None); + + let reply = harness + .proxy("DELETE", "/oagw/v1/proxy/api.vendor.com/v1/chat", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::NOT_FOUND); + assert_eq!( + reply.problem_type(), + Some(problem_type("route.not_found.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn a_disabled_upstream_is_a_link_unavailable_problem() -> Result<()> { + let harness = ProxyHarness::new(); + let owner = harness.tenant(); + let upstream_id = harness.seed_upstream(domain_upstream( + owner, + "api.vendor.com", + Vec::from([loopback_endpoint(1)]), + false, + )); + let route = domain_route(owner, upstream_id, &[HttpMethod::Get], "/v1", &[]); + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| { + panic!("the route must seed: {error}"); + }); + + let reply = harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com/v1", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + reply.problem_type(), + Some(problem_type("link.unavailable.v1")) + ); + assert_eq!(reply.header(ERROR_SOURCE), Some("gateway")); + Ok(()) +} + +#[tokio::test] +async fn an_alias_of_an_ancestor_tenant_is_reachable() -> Result<()> { + let harness = ProxyHarness::with_config_and_chain( + &common::proxy_config(), + std::sync::Arc::new(common::StaticTenantChain), + ); + // The record belongs to the root tenant, the request to a child of it. + let owner = Uuid::nil(); + let upstream_id = harness.seed_upstream(domain_upstream( + owner, + "shared.vendor.com", + Vec::from([loopback_endpoint(1)]), + true, + )); + let route = domain_route(owner, upstream_id, &[HttpMethod::Get], "/v1", &[]); + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| { + panic!("the route must seed: {error}"); + }); + let child = Uuid::now_v7(); + + let reply = harness + .proxy_as( + "GET", + "/oagw/v1/proxy/shared.vendor.com/v1", + child, + &[], + b"", + ) + .await?; + + // The chain makes the record visible; the dial to port 1 fails, which + // proves the alias resolved before any data-plane guard rejected it. + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + reply.problem_type(), + Some(problem_type("link.unavailable.v1")) + ); + Ok(()) +} + +// ── Route path and query rules ─────────────────────────────────────────── + +#[tokio::test] +async fn a_suffix_on_a_strict_route_is_a_validation_problem() -> Result<()> { + let server = MockServer::start(); + let seeded = seeded_with_strict_route(server.port()); + + let reply = seeded + .harness + .proxy( + "GET", + "/oagw/v1/proxy/api.vendor.com/v1/strict/extra", + &[], + b"", + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!( + reply.problem_type(), + Some(problem_type("validation.error.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn the_query_allowlist_drops_unknown_parameters() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/v1/search") + .query_param("q", "ai") + .query_param_missing("secret"); + then.status(200).body("results"); + }); + let seeded = seeded_with_strict_route(server.port()); + add_query_route(&seeded.harness, seeded.owner, seeded.upstream_id, &["q"]); + + let reply = seeded + .harness + .proxy( + "GET", + "/oagw/v1/proxy/api.vendor.com/v1/search?q=ai&secret=1", + &[], + b"", + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert_eq!(reply.text, "results"); + assert_eq!(mock.calls(), 1); + Ok(()) +} + +// ── Request-path smuggling (DESIGN §4.4) ───────────────────────────────── + +/// A suffix that escapes the matched prefix is a 400, never a dial. +#[tokio::test] +async fn a_dot_segment_in_the_suffix_is_a_validation_problem() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/admin"); + then.status(200).body("secret"); + }); + let harness = harness_with_upstream(server.port(), None); + + for suffix in ["../admin", "%2E%2E/admin", "chat/../../admin", "."] { + let reply = harness + .proxy( + "GET", + &format!("/oagw/v1/proxy/api.vendor.com/v1/chat/{suffix}"), + &[], + b"", + ) + .await?; + assert_eq!( + reply.status, + axum::http::StatusCode::BAD_REQUEST, + "{suffix}" + ); + assert_eq!( + reply.problem_type(), + Some(problem_type("validation.error.v1")), + "{suffix}" + ); + } + assert_eq!(mock.calls(), 0); + Ok(()) +} + +/// A suffix may not become a query or a fragment of the dial target. +#[tokio::test] +async fn an_escaped_separator_in_the_suffix_is_rejected() -> Result<()> { + let server = MockServer::start(); + let harness = harness_with_upstream(server.port(), None); + + for suffix in ["item%3Finjected%3D1", "item%23fragment", "a%3Fb/c"] { + let reply = harness + .proxy( + "GET", + &format!("/oagw/v1/proxy/api.vendor.com/v1/chat/{suffix}"), + &[], + b"", + ) + .await?; + assert_eq!( + reply.status, + axum::http::StatusCode::BAD_REQUEST, + "{suffix}" + ); + assert_eq!( + reply.problem_type(), + Some(problem_type("validation.error.v1")), + "{suffix}" + ); + } + Ok(()) +} + +/// A suffix cannot smuggle a query parameter past the route's allowlist. +#[tokio::test] +async fn a_suffix_cannot_inject_a_query_parameter() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/v1/search") + .query_param_missing("injected"); + then.status(200).body("leaked"); + }); + let seeded = seeded_with_strict_route(server.port()); + add_query_route(&seeded.harness, seeded.owner, seeded.upstream_id, &["q"]); + + let reply = seeded + .harness + .proxy( + "GET", + "/oagw/v1/proxy/api.vendor.com/v1/search/q%3Finjected%3D1", + &[], + b"", + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +/// `%2F` stays a single segment and a `//` prefix never matches a route. +#[tokio::test] +async fn the_suffix_shape_survives_to_the_upstream() -> Result<()> { + let raw = std::sync::Arc::new(common::RawUpstream::bind().await?); + let port = raw.port(); + let responder = std::sync::Arc::clone(&raw); + let handle = tokio::spawn(async move { + responder + .serve_once("HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok") + .await + }); + let harness = harness_with_upstream(port, None); + + let reply = harness + .proxy( + "GET", + "/oagw/v1/proxy/api.vendor.com/v1/chat/a%2Fb", + &[], + b"", + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + let received = handle + .await + .with_context(|| "the upstream task must join")? + .with_context(|| "the upstream must answer")?; + assert!( + received.starts_with("GET /v1/chat/a%2Fb HTTP/1.1"), + "the escaped separator must reach the upstream: {received}" + ); + Ok(()) +} + +#[tokio::test] +async fn a_doubled_slash_after_the_alias_is_not_a_route() -> Result<()> { + let harness = harness_with_upstream(1, None); + + let reply = harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com//v1/chat", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::NOT_FOUND); + assert_eq!( + reply.problem_type(), + Some(problem_type("route.not_found.v1")) + ); + Ok(()) +} + +// ── Alias resolution ───────────────────────────────────────────────────── + +/// The alias is matched lowercased and without a trailing dot, exactly as the +/// write path stores it (DESIGN §3.2 "Alias Resolution"). +#[tokio::test] +async fn the_alias_is_matched_regardless_of_case_or_trailing_dot() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let harness = harness_with_upstream(server.port(), None); + + for alias in ["API.Vendor.COM", "api.vendor.com.", "Api.Vendor.com."] { + let reply = harness + .proxy("GET", &format!("/oagw/v1/proxy/{alias}/v1/chat"), &[], b"") + .await?; + assert_eq!(reply.status, axum::http::StatusCode::OK, "{alias}"); + } + assert_eq!(mock.calls(), 3); + Ok(()) +} + +// ── Tenant isolation ───────────────────────────────────────────────────── + +#[tokio::test] +async fn an_alias_of_another_tenant_is_invisible_to_the_data_plane() -> Result<()> { + let server = MockServer::start(); + let harness = harness_with_upstream(server.port(), None); + + let reply = harness + .proxy_as( + "GET", + "/oagw/v1/proxy/api.vendor.com/v1/chat", + Uuid::now_v7(), + &[], + b"", + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::NOT_FOUND); + assert_eq!( + reply.problem_type(), + Some(problem_type("route.not_found.v1")) + ); + Ok(()) +} + +// ── Body guards ────────────────────────────────────────────────────────── + +#[tokio::test] +async fn an_oversized_body_is_a_payload_too_large_problem() -> Result<()> { + let mut config = common::proxy_config(); + config.max_body_bytes = 64; + let harness = ProxyHarness::with_config_and_chain( + &config, + std::sync::Arc::new(common::StaticTenantChain), + ); + let owner = harness.tenant(); + let upstream_id = harness.seed_upstream(domain_upstream( + owner, + "api.vendor.com", + Vec::from([loopback_endpoint(1)]), + true, + )); + let route = domain_route(owner, upstream_id, &[HttpMethod::Post], "/v1/chat", &[]); + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| { + panic!("the route must seed: {error}"); + }); + + let reply = harness + .proxy( + "POST", + "/oagw/v1/proxy/api.vendor.com/v1/chat", + &[], + &[b'x'; 128], + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + reply.problem_type(), + Some(problem_type("payload.too_large.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn a_transfer_encoding_body_is_a_validation_problem() -> Result<()> { + let harness = harness_with_upstream(1, None); + + let reply = harness + .proxy( + "POST", + "/oagw/v1/proxy/api.vendor.com/v1/chat", + &[("transfer-encoding", "gzip")], + b"payload", + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!( + reply.problem_type(), + Some(problem_type("validation.error.v1")) + ); + Ok(()) +} + +// ── Target-endpoint selection (ADR-0001) ───────────────────────────────── + +/// Two mock servers behind one upstream, each answering with its own body. +/// +/// The pool holds two distinct IP endpoints, so the write path would demand an +/// explicit alias and the data plane round-robins over it. +struct Pool { + harness: ProxyHarness, +} + +impl Pool { + /// A pool of two local endpoints behind the alias `api.vendor.com`. + fn new() -> Self { + let first = MockServer::start(); + let second = MockServer::start(); + first.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("first"); + }); + second.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("second"); + }); + let harness = ProxyHarness::new(); + let owner = harness.tenant(); + let upstream_id = harness.seed_upstream(domain_upstream( + owner, + "api.vendor.com", + Vec::from([ + loopback_endpoint(first.port()), + loopback_endpoint(second.port()), + ]), + true, + )); + let route = domain_route(owner, upstream_id, &[HttpMethod::Get], "/v1/chat", &[]); + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| { + panic!("the route must seed: {error}"); + }); + Self { harness } + } + + /// Proxy one `GET` to the alias, with an optional target-host header. + async fn get(&self, headers: &[(&str, &str)]) -> Result { + let reply = self + .harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com/v1/chat", headers, b"") + .await?; + assert_eq!(reply.status, axum::http::StatusCode::OK); + Ok(reply.text) + } +} + +#[tokio::test] +async fn a_round_robin_pool_walks_its_endpoints() -> Result<()> { + let pool = Pool::new(); + + let first = pool.get(&[]).await?; + let second = pool.get(&[]).await?; + + assert_eq!(first, "first"); + assert_eq!(second, "second"); + Ok(()) +} + +#[tokio::test] +async fn a_target_host_header_pins_the_endpoint() -> Result<()> { + let pool = Pool::new(); + + // Round-robin would hand the second request to the other endpoint; the + // header overrides the cursor twice in a row. + let first = pool.get(&[(TARGET_HOST, "127.0.0.1")]).await?; + let second = pool.get(&[(TARGET_HOST, "127.0.0.1")]).await?; + + assert_eq!(first, "first"); + assert_eq!(second, "first"); + Ok(()) +} + +#[tokio::test] +async fn an_unknown_target_host_is_a_routing_problem() -> Result<()> { + let harness = harness_with_upstream(1, None); + + let reply = harness + .proxy( + "GET", + "/oagw/v1/proxy/api.vendor.com/v1/chat", + &[(TARGET_HOST, "eu.vendor.com")], + b"", + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!( + reply.problem_type(), + Some(problem_type("routing.unknown_target_host.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn a_target_host_with_a_port_is_an_invalid_target_host() -> Result<()> { + let harness = harness_with_upstream(1, None); + + let reply = harness + .proxy( + "GET", + "/oagw/v1/proxy/api.vendor.com/v1/chat", + &[(TARGET_HOST, "api.vendor.com:8443")], + b"", + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!( + reply.problem_type(), + Some(problem_type("routing.invalid_target_host.v1")) + ); + Ok(()) +} + +// ── Header transformation ──────────────────────────────────────────────── + +#[tokio::test] +async fn hop_by_hop_headers_are_not_forwarded() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let harness = harness_with_upstream(server.port(), None); + + let reply = harness + .proxy( + "GET", + "/oagw/v1/proxy/api.vendor.com/v1/chat", + &[ + ("connection", "keep-alive"), + ("x-oagw-target-host", "127.0.0.1"), + ("x-keep", "yes"), + ], + b"", + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert_eq!(mock.calls(), 1); + Ok(()) +} + +#[tokio::test] +async fn header_rules_set_add_and_remove_on_the_response() -> Result<()> { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200) + .header("x-server", "nginx") + .header("x-secret", "1") + .body("ok"); + }); + let rules = oagw::domain::model::HeadersConfig { + request: None, + response: Some(oagw::domain::model::ResponseHeaderRules { + set: [("x-gateway".to_owned(), "oagw".to_owned())] + .into_iter() + .collect(), + add: [].into_iter().collect(), + remove: Vec::from(["x-secret".to_owned()]), + }), + }; + let harness = harness_with_upstream(server.port(), Some(rules)); + + let reply = harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com/v1/chat", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert_eq!(reply.header("x-gateway"), Some("oagw")); + assert!(reply.headers.get("x-secret").is_none()); + Ok(()) +} + +// ── Transport failures ─────────────────────────────────────────────────── + +/// An upstream error status is **passed through**, not converted into a +/// gateway problem: it keeps `X-OAGW-Error-Source: upstream` (ADR-0007). +#[tokio::test] +async fn an_upstream_error_status_keeps_the_upstream_source() -> Result<()> { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(503) + .header("content-type", "application/json") + .body(r#"{"error":"overloaded"}"#); + }); + let harness = harness_with_upstream(server.port(), None); + + let reply = harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com/v1/chat", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!(reply.header(ERROR_SOURCE), Some("upstream")); + assert_eq!(reply.text, r#"{"error":"overloaded"}"#); + assert_eq!(mock.calls(), 1); + Ok(()) +} + +// ── Outbound request (observed at the upstream) ────────────────────────── + +/// The raw upstream the tests observe the outbound request on. +/// +/// Returns the port the harness must address and the join handle that yields +/// the request head the upstream received. +async fn observed_upstream() -> Result<(u16, tokio::task::JoinHandle>)> { + let raw = std::sync::Arc::new(common::RawUpstream::bind().await?); + let port = raw.port(); + let responder = std::sync::Arc::clone(&raw); + let handle = tokio::spawn(async move { + responder + .serve_once("HTTP/1.1 200 OK\r\ncontent-length: 2\r\n\r\nok") + .await + }); + Ok((port, handle)) +} + +#[tokio::test] +async fn the_upstream_never_sees_a_hop_by_hop_header() -> Result<()> { + let (port, handle) = observed_upstream().await?; + let harness = harness_with_upstream(port, None); + + let reply = harness + .proxy( + "GET", + "/oagw/v1/proxy/api.vendor.com/v1/chat", + &[ + ("connection", "x-hop"), + ("x-hop", "smuggled"), + ("proxy-connection", "keep-alive"), + ("keep-alive", "timeout=5"), + ("x-keep", "yes"), + ], + b"", + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + let received = handle.await??; + for forbidden in ["connection:", "x-hop:", "proxy-connection:", "keep-alive:"] { + assert!( + !received.contains(forbidden), + "'{forbidden}' must not reach the upstream: {received}" + ); + } + assert!(received.contains("x-keep: yes"), "{received}"); + Ok(()) +} + +#[tokio::test] +async fn the_upstream_sees_its_own_authority_and_no_routing_header() -> Result<()> { + let (port, handle) = observed_upstream().await?; + let harness = harness_with_upstream(port, None); + + let reply = harness + .proxy( + "GET", + "/oagw/v1/proxy/api.vendor.com/v1/chat", + &[(TARGET_HOST, "127.0.0.1"), ("host", "api.vendor.com")], + b"", + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + let received = handle.await??; + assert!( + received.contains(&format!("host: 127.0.0.1:{port}")), + "the dial target authority must be the Host header: {received}" + ); + assert!( + !received.contains("x-oagw-target-host"), + "the routing header must not reach the upstream: {received}" + ); + assert!( + !received.contains("api.vendor.com"), + "the gateway authority must not reach the upstream: {received}" + ); + Ok(()) +} + +#[tokio::test] +async fn a_chunked_request_body_is_cut_off_while_it_is_read() -> Result<()> { + let raw = std::sync::Arc::new(common::RawUpstream::bind().await?); + let port = raw.port(); + let handle = tokio::spawn(async move { raw.hang().await }); + let mut config = common::proxy_config(); + config.max_body_bytes = 64; + let harness = ProxyHarness::with_config_and_chain( + &config, + std::sync::Arc::new(common::StaticTenantChain), + ); + let owner = harness.tenant(); + let upstream_id = harness.seed_upstream(domain_upstream( + owner, + "api.vendor.com", + Vec::from([loopback_endpoint(port)]), + true, + )); + let route = domain_route(owner, upstream_id, &[HttpMethod::Post], "/v1/chat", &[]); + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| { + panic!("the route must seed: {error}"); + }); + + // Three chunks of the limit each: the cap must fire on the second frame, + // not after the whole body has been buffered. + let reply = harness + .proxy_chunked( + "POST", + "/oagw/v1/proxy/api.vendor.com/v1/chat", + &[&[b'x'; 64], &[b'y'; 64], &[b'z'; 64]], + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + reply.problem_type(), + Some(problem_type("payload.too_large.v1")) + ); + handle.abort(); + Ok(()) +} + +/// The egress guard of the data plane: a local address the write path let +/// through (the record is seeded directly) is still refused at dial time. +#[tokio::test] +async fn the_egress_guard_refuses_a_local_endpoint_at_dial_time() -> Result<()> { + let mut config = common::proxy_config(); + config.ssrf_policy = oagw::config::SsrfPolicy { + enabled: true, + allowed_hosts: Vec::new(), + denied_hosts: Vec::new(), + }; + let harness = ProxyHarness::with_config_and_chain( + &config, + std::sync::Arc::new(common::StaticTenantChain), + ); + let owner = harness.tenant(); + let upstream_id = harness.seed_upstream(domain_upstream( + owner, + "api.vendor.com", + Vec::from([loopback_endpoint(1)]), + true, + )); + let route = domain_route(owner, upstream_id, &[HttpMethod::Get], "/v1/chat", &[]); + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| { + panic!("the route must seed: {error}"); + }); + + let reply = harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com/v1/chat", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + reply.problem_type(), + Some(problem_type("link.unavailable.v1")) + ); + assert_eq!(reply.header(ERROR_SOURCE), Some("gateway")); + Ok(()) +} + +#[tokio::test] +async fn a_hostname_pool_demands_a_target_host() -> Result<()> { + let harness = ProxyHarness::new(); + let owner = harness.tenant(); + let upstream_id = harness.seed_upstream(domain_upstream( + owner, + "vendor.com", + Vec::from([ + common::tls_hostname_endpoint("us.vendor.com"), + common::tls_hostname_endpoint("eu.vendor.com"), + ]), + true, + )); + let route = domain_route(owner, upstream_id, &[HttpMethod::Get], "/v1/chat", &[]); + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| { + panic!("the route must seed: {error}"); + }); + + let unpinned = harness + .proxy("GET", "/oagw/v1/proxy/vendor.com/v1/chat", &[], b"") + .await?; + let pinned = harness + .proxy( + "GET", + "/oagw/v1/proxy/vendor.com/v1/chat", + &[(TARGET_HOST, "us.vendor.com")], + b"", + ) + .await?; + + assert_eq!(unpinned.status, axum::http::StatusCode::BAD_REQUEST); + assert_eq!( + unpinned.problem_type(), + Some(problem_type("routing.missing_target_host.v1")) + ); + assert_eq!(unpinned.problem_field("alias"), Some("vendor.com")); + // The pinning header gets the request past the guard; the dial itself + // cannot succeed, because a host name does not resolve in the test + // environment. + assert_ne!(pinned.status, axum::http::StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn an_unreachable_upstream_is_a_link_unavailable_problem() -> Result<()> { + let harness = harness_with_upstream(1, None); + + let reply = harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com/v1/chat", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + reply.problem_type(), + Some(problem_type("link.unavailable.v1")) + ); + assert_eq!(reply.header(ERROR_SOURCE), Some("gateway")); + Ok(()) +} + +#[tokio::test] +async fn an_upstream_that_never_answers_is_a_request_timeout() -> Result<()> { + let raw = std::sync::Arc::new(common::RawUpstream::bind().await?); + let port = raw.port(); + let handle = tokio::spawn(async move { raw.hang().await }); + let harness = harness_with_upstream(port, None); + + let reply = harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com/v1/chat", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::GATEWAY_TIMEOUT); + assert_eq!( + reply.problem_type(), + Some(problem_type("timeout.request.v1")) + ); + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn a_plaintext_upstream_is_refused_when_the_switch_is_off() -> Result<()> { + let mut config = common::proxy_config(); + config.allow_http_upstream = false; + let harness = ProxyHarness::with_config_and_chain( + &config, + std::sync::Arc::new(common::StaticTenantChain), + ); + let owner = harness.tenant(); + let upstream_id = harness.seed_upstream(domain_upstream( + owner, + "api.vendor.com", + Vec::from([loopback_endpoint(1)]), + true, + )); + let route = domain_route(owner, upstream_id, &[HttpMethod::Get], "/v1", &[]); + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| { + panic!("the route must seed: {error}"); + }); + + let reply = harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com/v1", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::BAD_GATEWAY); + assert_eq!( + reply.problem_type(), + Some(problem_type("protocol.error.v1")) + ); + Ok(()) +} + +// ── Streaming ──────────────────────────────────────────────────────────── + +#[tokio::test] +async fn a_chunked_response_is_streamed_to_the_client() -> Result<()> { + let raw = std::sync::Arc::new(common::RawUpstream::bind().await?); + let port = raw.port(); + let response = [ + "HTTP/1.1 200 OK\r\n", + "content-type: text/plain\r\n", + "transfer-encoding: chunked\r\n", + "\r\n", + "5\r\nhello\r\n", + "0\r\n\r\n", + ] + .concat(); + let handle = tokio::spawn(async move { raw.serve_forever(response).await }); + let harness = harness_with_upstream(port, None); + + let reply = harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com/v1/chat", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert_eq!(reply.text, "hello"); + handle.abort(); + Ok(()) +} + +#[tokio::test] +async fn an_event_stream_reaches_the_client() -> Result<()> { + let raw = std::sync::Arc::new(common::RawUpstream::bind().await?); + let port = raw.port(); + let response = [ + "HTTP/1.1 200 OK\r\n", + "content-type: text/event-stream\r\n", + "\r\n", + "event: delta\n", + "data: one\n\n", + "event: delta\n", + "data: two\n\n", + ] + .concat(); + let handle = tokio::spawn(async move { raw.serve_forever(response).await }); + let harness = harness_with_upstream(port, None); + + let reply = harness + .proxy( + "GET", + "/oagw/v1/proxy/api.vendor.com/v1/chat", + &[("accept", "text/event-stream")], + b"", + ) + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::OK); + assert_eq!(reply.header("content-type"), Some("text/event-stream")); + assert!(reply.text.contains("data: one")); + assert!(reply.text.contains("data: two")); + handle.abort(); + Ok(()) +} + +/// Raw chunked head with one chunk of body, used by the streaming tests. +const CHUNKED_HEAD: &str = + "HTTP/1.1 200 OK\r\ncontent-type: text/plain\r\ntransfer-encoding: chunked\r\n\r\n"; + +#[tokio::test] +async fn the_first_chunk_reaches_the_client_before_the_upstream_closes() -> Result<()> { + let raw = std::sync::Arc::new(common::RawUpstream::bind().await?); + let port = raw.port(); + let harness = harness_with_upstream(port, None); + let waiter = tokio::spawn(async move { + let raw = raw; + raw.hand_over(CHUNKED_HEAD).await + }); + + let response = harness + .proxy_unbuffered("GET", "/oagw/v1/proxy/api.vendor.com/v1/chat", &[]) + .await?; + let mut socket = waiter.await??; + socket.write_all(b"5\r\nhello\r\n").await?; + + // The upstream has sent the head and one chunk and is still connected: the + // chunk must already be readable, which is what "streaming" means here. + let mut body = response.into_body(); + let first = common::read_until(&mut body, "hello").await?; + assert_eq!(first, b"hello"); + + socket.write_all(b"6\r\n world\r\n0\r\n\r\n").await?; + socket.shutdown().await?; + let (rest, truncated) = common::read_to_end(body).await?; + assert_eq!(String::from_utf8_lossy(&rest), " world"); + assert!(!truncated); + Ok(()) +} + +#[tokio::test] +async fn an_upstream_that_stops_mid_body_truncates_the_stream() -> Result<()> { + let raw = std::sync::Arc::new(common::RawUpstream::bind().await?); + let port = raw.port(); + // The chunked body never gets its terminating chunk: the socket closes + // after the first one. + let response = format!("{CHUNKED_HEAD}5\r\nhello\r\n"); + let handle = tokio::spawn(async move { raw.serve_once(&response).await }); + let harness = harness_with_upstream(port, None); + + let response = harness + .proxy_unbuffered("GET", "/oagw/v1/proxy/api.vendor.com/v1/chat", &[]) + .await?; + + assert_eq!(response.status(), axum::http::StatusCode::OK); + let source = response + .headers() + .get(ERROR_SOURCE) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + assert_eq!(source.as_deref(), Some("upstream")); + let (received, truncated) = common::read_to_end(response.into_body()).await?; + assert_eq!(received, b"hello"); + // What the client can observe is the broken framing, not a gateway 502: + // the head has already been forwarded when the upstream went away. + assert!(truncated, "the truncation must be visible to the client"); + handle.await??; + Ok(()) +} + +#[tokio::test] +async fn a_body_that_goes_silent_is_cut_off_by_the_idle_budget() -> Result<()> { + let raw = std::sync::Arc::new(common::RawUpstream::bind().await?); + let port = raw.port(); + let harness = harness_with_upstream(port, None); + let waiter = tokio::spawn(async move { raw.hand_over(CHUNKED_HEAD).await }); + + let response = harness + .proxy_unbuffered("GET", "/oagw/v1/proxy/api.vendor.com/v1/chat", &[]) + .await?; + let mut socket = waiter.await??; + // One chunk, then silence: the socket stays open, so only the idle budget + // of the body can end the transfer. + socket + .write_all(b"5\r\nhel") + .await + .context("writing the first partial chunk")?; + let started = tokio::time::Instant::now(); + + let (received, truncated) = common::read_to_end(response.into_body()).await?; + + assert_eq!(String::from_utf8_lossy(&received), "hel"); + assert!(truncated, "the idle budget must abort the body"); + assert!( + started.elapsed() < std::time::Duration::from_secs(6), + "the abort must follow the idle budget, not the head budget" + ); + Ok(()) +} + +/// An event stream has no overall body budget: it may pause longer than the +/// budget of an ordinary body as long as it keeps producing (DESIGN §3.5). +#[tokio::test] +async fn an_event_stream_outlives_the_body_budget_of_an_ordinary_body() -> Result<()> { + let raw = std::sync::Arc::new(common::RawUpstream::bind().await?); + let port = raw.port(); + let mut config = common::proxy_config(); + config.proxy_stream_timeout_secs = Some(1); + config.proxy_idle_timeout_secs = Some(3); + let harness = ProxyHarness::with_config_and_chain( + &config, + std::sync::Arc::new(common::StaticTenantChain), + ); + let owner = harness.tenant(); + let upstream_id = harness.seed_upstream(domain_upstream( + owner, + "api.vendor.com", + Vec::from([loopback_endpoint(port)]), + true, + )); + let route = domain_route(owner, upstream_id, &[HttpMethod::Get], "/v1/chat", &[]); + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| { + panic!("the route must seed: {error}"); + }); + + let head = "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\r\n"; + let waiter = tokio::spawn(async move { raw.hand_over(head).await }); + let response = harness + .proxy_unbuffered( + "GET", + "/oagw/v1/proxy/api.vendor.com/v1/chat", + &[("accept", "text/event-stream")], + ) + .await?; + let mut socket = waiter.await??; + socket.write_all(b"event: delta\ndata: one\n\n").await?; + let mut body = response.into_body(); + let first = common::read_until(&mut body, "data: one").await?; + assert!(String::from_utf8_lossy(&first).contains("data: one")); + + // Longer than the overall budget of a buffered body, shorter than the + // silence budget: only the stream exemption lets the second event through. + tokio::time::sleep(std::time::Duration::from_millis(1500)).await; + socket.write_all(b"event: delta\ndata: two\n\n").await?; + socket.shutdown().await?; + let (rest, truncated) = common::read_to_end(body).await?; + assert!(String::from_utf8_lossy(&rest).contains("data: two")); + assert!(!truncated); + Ok(()) +} + +// ── OpenAPI surface ────────────────────────────────────────────────────── + +#[tokio::test] +async fn a_head_request_gets_the_route_problem_not_an_empty_405() -> Result<()> { + let harness = harness_with_upstream(1, None); + + // The schema's method allowlist has no HEAD entry, so the route cannot + // match. The endpoint is still registered for HEAD: the client gets the + // documented problem instead of axum's bare `405 Method Not Allowed`. + let reply = harness + .proxy("HEAD", "/oagw/v1/proxy/api.vendor.com/v1/chat", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::NOT_FOUND); + assert_eq!(reply.header(ERROR_SOURCE), Some("gateway")); + // A HEAD response has no body, so the problem type cannot be inspected. + assert_eq!(reply.text, ""); + Ok(()) +} + +/// A method the data plane does not register still answers inside the problem +/// contract (DESIGN §3.3), not with axum's bare `405`. +#[tokio::test] +async fn an_unregistered_method_gets_the_problem_contract() -> Result<()> { + let harness = harness_with_upstream(1, None); + + for method in ["TRACE", "CONNECT", "PATCH", "PROPFIND"] { + let reply = harness + .proxy(method, "/oagw/v1/proxy/api.vendor.com/v1/chat", &[], b"") + .await + .with_context(|| format!("proxying {method}"))?; + + assert_eq!(reply.status, axum::http::StatusCode::NOT_FOUND, "{method}"); + assert_eq!( + reply.problem_type(), + Some(problem_type("route.not_found.v1")), + "{method}" + ); + assert_eq!(reply.header(ERROR_SOURCE), Some("gateway"), "{method}"); + assert_eq!(reply.problem_field("alias"), Some("api.vendor.com")); + } + Ok(()) +} + +#[tokio::test] +async fn a_route_match_rule_of_the_wrong_protocol_is_not_selectable() -> Result<()> { + let harness = ProxyHarness::new(); + let owner = harness.tenant(); + let upstream_id = harness.seed_upstream(domain_upstream( + owner, + "api.vendor.com", + Vec::from([loopback_endpoint(1)]), + true, + )); + let mut route = domain_route(owner, upstream_id, &[HttpMethod::Get], "/v1", &[]); + route.match_rule = RouteMatch { + http: None, + grpc: Some(oagw::domain::model::GrpcMatch { + service: "pkg.Svc".to_owned(), + method: "Get".to_owned(), + }), + }; + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| { + panic!("the route must seed: {error}"); + }); + + let reply = harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com/v1", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::NOT_FOUND); + assert_eq!( + reply.problem_type(), + Some(problem_type("route.not_found.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn a_problem_response_reports_the_alias() -> Result<()> { + let harness = harness_with_upstream(1, None); + + let reply = harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com/v9/none", &[], b"") + .await?; + + assert_eq!(reply.status, axum::http::StatusCode::NOT_FOUND); + assert_eq!(reply.problem_field("alias"), Some("api.vendor.com")); + assert_eq!(reply.problem_field("path"), Some("/v9/none")); + Ok(()) +} + +#[tokio::test] +async fn a_get_on_the_alias_root_without_a_route_is_a_404() -> Result<()> { + let harness = harness_with_upstream(1, None); + + let reply = harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com", &[], b"") + .await + .context("proxying the alias root")?; + + assert_eq!(reply.status, axum::http::StatusCode::NOT_FOUND); + Ok(()) +} diff --git a/gears/system/oagw/oagw/tests/rate_limit_cors_test.rs b/gears/system/oagw/oagw/tests/rate_limit_cors_test.rs new file mode 100644 index 0000000..ce11496 --- /dev/null +++ b/gears/system/oagw/oagw/tests/rate_limit_cors_test.rs @@ -0,0 +1,1323 @@ +// Created: 2026-08-31 by Constructor Tech +// @cpt-dod:cpt-cf-oagw-dod-testing-proxy-data-plane:p2 +//! Rate limiting (ADR-0003) and CORS (ADR-0004) on the proxy data plane. +//! +//! Every enforcement test drives a real proxy request: the quota and the origin +//! are decided after the upstream and the route resolve and before the dial, so +//! a refusal is observable as a problem document whose upstream mock was never +//! called. The hierarchical cases seed the same alias into two tenants of one +//! store and let [`common::StaticTenantChain`] stand in for the +//! `tenant_resolver`. + +mod common; + +use anyhow::{Context as _, Result}; +use axum::http::StatusCode; +use common::{ + ERROR_SOURCE, Harness, LogCapture, ProxyHarness, StaticTenantChain, domain_route, + domain_upstream, https_upstream, loopback_endpoint, problem_type, proxy_config, +}; +use httpmock::prelude::{GET, MockServer}; +use oagw::domain::model::{ + BurstConfig, CorsConfig, HttpMethod, RateLimitConfig, SharingMode, SustainedRate, +}; +use std::sync::Arc; +use uuid::Uuid; + +/// `X-OAGW-` proxy route of every test. +const PROXY_PATH: &str = "/oagw/v1/proxy/api.vendor.com/v1/chat"; +/// Alias every test routes through. +const ALIAS: &str = "api.vendor.com"; +/// The root tenant [`StaticTenantChain`] reports as the single ancestor. +const ROOT: Uuid = Uuid::nil(); + +/// Tenant the management-API write-path tests act as. +fn tenant() -> Uuid { + Uuid::now_v7() +} + +// ── Configuration helpers ──────────────────────────────────────────────── + +/// A token-bucket policy over the members the tests assert on. +fn rate_limit( + rate: u64, + capacity: u64, + sharing: SharingMode, + strategy: &str, + cost: u64, + response_headers: bool, +) -> RateLimitConfig { + RateLimitConfig { + sharing, + algorithm: "token_bucket".to_owned(), + sustained: SustainedRate { + rate, + window: "second".to_owned(), + }, + burst: Some(BurstConfig { capacity }), + // `global` keeps every request of a test on the same counter, so a + // policy is observable without a second caller. + scope: "global".to_owned(), + strategy: strategy.to_owned(), + cost, + response_headers, + } +} + +/// A CORS policy over the members the tests assert on. +fn cors_config(enabled: bool, origins: &[&str], methods: &[&str]) -> CorsConfig { + CorsConfig { + sharing: SharingMode::Private, + enabled, + allowed_origins: origins.iter().map(ToString::to_string).collect(), + allowed_methods: methods.iter().map(ToString::to_string).collect(), + expose_headers: Vec::new(), + allow_credentials: false, + } +} + +/// A CORS policy that also exposes headers and allows credentials. +fn cors_policy(mut policy: CorsConfig) -> CorsConfig { + policy.expose_headers = Vec::from(["x-request-id".to_owned(), "x-trace".to_owned()]); + policy.allow_credentials = true; + policy +} + +// ── Seeding ────────────────────────────────────────────────────────────── + +/// An upstream of `tenant` whose policy members the test controls. +fn upstream( + tenant: Uuid, + alias: &str, + port: u16, + rate_limit: Option, + cors: Option, +) -> oagw::domain::model::Upstream { + let mut record = domain_upstream(tenant, alias, Vec::from([loopback_endpoint(port)]), true); + record.rate_limit = rate_limit; + record.cors = cors; + record +} + +/// Seed the `/v1/chat` route of `upstream_id`. +fn seed_route(harness: &ProxyHarness, upstream_id: Uuid) { + seed_route_of( + harness, + harness.tenant(), + upstream_id, + "/v1/chat", + None, + None, + ); +} + +/// Seed a route of `tenant` with the policies the test spells. +fn seed_route_of( + harness: &ProxyHarness, + tenant: Uuid, + upstream_id: Uuid, + path: &str, + rate_limit: Option, + cors: Option, +) -> Uuid { + let mut route = domain_route( + tenant, + upstream_id, + &[HttpMethod::Get, HttpMethod::Post], + path, + &[], + ); + route.rate_limit = rate_limit; + route.cors = cors; + let id = route.id; + harness + .store() + .insert_route_checked(route) + .unwrap_or_else(|error| panic!("the test route must seed: {error}")); + id +} + +/// A harness whose upstream answers `port` and carries the two policies. +fn harness_with( + port: u16, + rate_limit: Option, + cors: Option, +) -> ProxyHarness { + let harness = ProxyHarness::new(); + let record = upstream(harness.tenant(), ALIAS, port, rate_limit, cors); + seed_route(&harness, harness.seed_upstream(record)); + harness +} + +/// Seed the child upstream of the harness tenant **and** its ancestor of the +/// root tenant, both under the same alias. +fn chained_harness( + port: u16, + child: Option, + ancestor: Option, +) -> ProxyHarness { + let harness = ProxyHarness::with_config_and_chain(&proxy_config(), Arc::new(StaticTenantChain)); + let record = upstream(harness.tenant(), ALIAS, port, child, None); + seed_route(&harness, harness.seed_upstream(record)); + harness.seed_upstream(upstream(ROOT, ALIAS, port, ancestor, None)); + harness +} + +/// One proxied request with the headers the test spells. +async fn proxy_with<'h>(harness: &'h ProxyHarness, headers: &'h [(&str, &str)]) -> common::Reply { + match harness.proxy("GET", PROXY_PATH, headers, b"").await { + Ok(reply) => reply, + Err(error) => panic!("the proxy request must be sent: {error}"), + } +} + +/// A harness whose upstream and route belong to the root tenant, so any +/// tenant below the root reaches them through the chain. +fn root_harness( + port: u16, + rate_limit: Option, + cors: Option, +) -> ProxyHarness { + let harness = ProxyHarness::with_config_and_chain(&proxy_config(), Arc::new(StaticTenantChain)); + let record = upstream(ROOT, ALIAS, port, rate_limit, cors); + let id = harness.seed_upstream(record); + seed_route_of(&harness, ROOT, id, "/v1/chat", None, None); + harness +} + +/// A policy of `scope` with the sharing mode the test needs. +fn scoped_policy(rate: u64, capacity: u64, scope: &str, sharing: SharingMode) -> RateLimitConfig { + let mut policy = rate_limit(rate, capacity, sharing, "reject", 1, true); + scope.clone_into(&mut policy.scope); + policy +} + +/// A policy in `window` rather than the default `second`. +fn windowed_policy( + rate: u64, + capacity: u64, + window: &str, + sharing: SharingMode, +) -> RateLimitConfig { + let mut policy = rate_limit(rate, capacity, sharing, "reject", 1, true); + window.clone_into(&mut policy.sustained.window); + policy +} + +/// A CORS policy in `sharing` rather than the default `private`. +fn shared_cors(sharing: SharingMode, origins: &[&str]) -> CorsConfig { + let mut policy = cors_config(true, origins, &["GET", "POST"]); + policy.sharing = sharing; + policy +} + +/// One proxied request as an identity the test spells. +/// +/// The `user` scope is keyed on the subject, so that test needs two callers +/// whose subject ids stay fixed across the requests it compares. +async fn proxy_identity<'h>( + harness: &'h ProxyHarness, + identity: &toolkit_security::SecurityContext, + headers: &'h [(&str, &str)], +) -> common::Reply { + match harness + .proxy_as_identity(identity, "GET", PROXY_PATH, headers, b"") + .await + { + Ok(reply) => reply, + Err(error) => panic!("the proxy request must be sent: {error}"), + } +} + +/// An identity of `tenant` with a subject the test controls. +fn identity_of(tenant: Uuid, subject: Uuid) -> toolkit_security::SecurityContext { + toolkit_security::SecurityContext::builder() + .subject_id(subject) + .subject_tenant_id(tenant) + .build() + .unwrap_or_else(|error| panic!("a test identity must build: {error}")) +} + +/// A mock that answers every `GET /v1/chat` with 200 and an empty body. +fn ok_mock(server: &MockServer) -> httpmock::Mock<'_> { + server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200); + }) +} + +// ── Rate limiting (ADR-0003) ───────────────────────────────────────────── + +/// A burst up to the capacity succeeds; the next request is refused. +#[tokio::test] +async fn a_burst_up_to_the_capacity_is_served_then_the_next_request_is_refused() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = harness_with( + server.port(), + Some(rate_limit(1, 2, SharingMode::Private, "reject", 1, true)), + None, + ); + + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + let refused = proxy_with(&harness, &[]).await; + assert_eq!( + refused.status, + StatusCode::TOO_MANY_REQUESTS, + "body: {}", + refused.text + ); + assert_eq!( + refused.problem_type(), + Some(problem_type("rate_limit.exceeded.v1")) + ); + assert_eq!(refused.header(ERROR_SOURCE), Some("gateway")); + assert_eq!(mock.calls(), 2, "a refused request is never dialled"); + Ok(()) +} + +/// The quota headers ride on a success and on a 429, with a retry guidance. +#[tokio::test] +async fn the_quota_headers_ride_on_a_success_and_on_a_429() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = harness_with( + server.port(), + Some(rate_limit(1, 2, SharingMode::Private, "reject", 1, true)), + None, + ); + + let allowed = proxy_with(&harness, &[]).await; + assert_eq!( + allowed.header("x-ratelimit-limit"), + Some("1"), + "the effective sustained rate per window" + ); + assert_eq!(allowed.header("x-ratelimit-remaining"), Some("1")); + let reset = allowed + .header("x-ratelimit-reset") + .and_then(|value| value.parse::().ok()) + .context("x-ratelimit-reset must be an epoch second")?; + assert!(reset > 0, "the bucket refills at {reset}"); + + let second = proxy_with(&harness, &[]).await; + assert_eq!(second.status, StatusCode::OK); + assert_eq!(second.header("x-ratelimit-remaining"), Some("0")); + let refused = proxy_with(&harness, &[]).await; + assert_eq!(refused.status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(refused.header("x-ratelimit-limit"), Some("1")); + assert_eq!(refused.header("x-ratelimit-remaining"), Some("0")); + let retry = refused + .header("retry-after") + .and_then(|value| value.parse::().ok()) + .context("a 429 carries Retry-After")?; + assert!(retry >= 1, "RFC 6585 asks for seconds, not for zero"); + Ok(()) +} + +/// A costly request consumes its cost, so a bucket admits fewer of them. +#[tokio::test] +async fn a_cost_of_ten_admits_fewer_requests_than_a_cost_of_one() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + // One request of cost 4 empties a 4-token bucket for good. + let costly = harness_with( + server.port(), + Some(rate_limit(4, 4, SharingMode::Private, "reject", 4, true)), + None, + ); + assert_eq!(proxy_with(&costly, &[]).await.status, StatusCode::OK); + assert_eq!( + proxy_with(&costly, &[]).await.status, + StatusCode::TOO_MANY_REQUESTS + ); + + // The same bucket at cost 1 serves four. + let cheap = harness_with( + server.port(), + Some(rate_limit(4, 4, SharingMode::Private, "reject", 1, true)), + None, + ); + for _ in 0..4 { + assert_eq!(proxy_with(&cheap, &[]).await.status, StatusCode::OK); + } + assert_eq!( + proxy_with(&cheap, &[]).await.status, + StatusCode::TOO_MANY_REQUESTS + ); + Ok(()) +} + +/// `response_headers: false` suppresses the three quota headers. +#[tokio::test] +async fn a_policy_without_response_headers_emits_none() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = harness_with( + server.port(), + Some(rate_limit(5, 5, SharingMode::Private, "reject", 1, false)), + None, + ); + let reply = proxy_with(&harness, &[]).await; + assert_eq!(reply.status, StatusCode::OK); + assert!(reply.header("x-ratelimit-limit").is_none()); + assert!(reply.header("x-ratelimit-remaining").is_none()); + assert!(reply.header("x-ratelimit-reset").is_none()); + Ok(()) +} + +/// An ancestor that enforces its policy caps a child configured higher. +#[tokio::test] +async fn an_ancestor_that_enforces_caps_a_higher_child() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = chained_harness( + server.port(), + Some(rate_limit( + 1_000, + 1_000, + SharingMode::Enforce, + "reject", + 1, + true, + )), + Some(rate_limit(2, 2, SharingMode::Enforce, "reject", 1, true)), + ); + // The effective rate is the ancestor's: two requests, then the refusal. + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + let refused = proxy_with(&harness, &[]).await; + assert_eq!(refused.status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(refused.header("x-ratelimit-limit"), Some("2")); + assert_eq!(mock.calls(), 2); + Ok(()) +} + +/// `inherit` with no own limit takes the ancestor's. +#[tokio::test] +async fn an_inherited_policy_without_an_own_limit_uses_the_ancestors() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = chained_harness( + server.port(), + Some(rate_limit( + 1_000, + 1_000, + SharingMode::Inherit, + "reject", + 1, + true, + )), + Some(rate_limit(1, 1, SharingMode::Inherit, "reject", 1, true)), + ); + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + let refused = proxy_with(&harness, &[]).await; + assert_eq!(refused.status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(refused.header("x-ratelimit-limit"), Some("1")); + Ok(()) +} + +/// An ancestor that *enforces* its cap keeps it active however the descendant +/// shares, so a `private` child cannot walk around it (DESIGN: "ancestor +/// constraints with `sharing: enforce` remain active"). +#[tokio::test] +async fn an_enforce_ancestor_still_caps_a_private_descendant() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = chained_harness( + server.port(), + Some(rate_limit(50, 50, SharingMode::Private, "reject", 1, true)), + Some(rate_limit(1, 1, SharingMode::Enforce, "reject", 1, true)), + ); + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + let refused = proxy_with(&harness, &[]).await; + assert_eq!(refused.status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(refused.header("x-ratelimit-limit"), Some("1")); + Ok(()) +} + +/// A policy the resolved upstream declares on itself is a level of its own, so +/// its `enforce` cap applies under a route policy that would allow more. +#[tokio::test] +async fn an_upstream_cap_survives_a_route_policy_of_its_own() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = ProxyHarness::new(); + let record = upstream( + harness.tenant(), + ALIAS, + server.port(), + Some(windowed_policy(1, 1, "second", SharingMode::Enforce)), + None, + ); + let id = harness.seed_upstream(record); + seed_route_of( + &harness, + harness.tenant(), + id, + "/v1/chat", + Some(rate_limit( + 1_000, + 1_000, + SharingMode::Private, + "reject", + 1, + true, + )), + None, + ); + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + let refused = proxy_with(&harness, &[]).await; + assert_eq!(refused.status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + refused.header("x-ratelimit-limit"), + Some("1"), + "the upstream's cap, not the route's allowance" + ); + Ok(()) +} + +/// A mixed-window chain compares the policies per second: ten a second caps a +/// thousand a minute, reported in the window of the level that asked. +#[tokio::test] +async fn a_faster_ancestor_caps_a_slower_child_across_windows() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = chained_harness( + server.port(), + Some(windowed_policy( + 1_000, + 1_000, + "minute", + SharingMode::Private, + )), + Some(windowed_policy(10, 10, "second", SharingMode::Enforce)), + ); + // The burst goes out at once: a refill of ten a second would hand the + // eleventh request a token if the ten dialled one by one. + let burst: Vec<_> = (0..10).map(|_| proxy_with(&harness, &[])).collect(); + for reply in futures_util::future::join_all(burst).await { + assert_eq!(reply.status, StatusCode::OK); + } + let refused = proxy_with(&harness, &[]).await; + assert_eq!(refused.status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + refused.header("x-ratelimit-limit"), + Some("600"), + "10 a second, restated in the minute the route asked for" + ); + Ok(()) +} + +/// The other direction: a hundred a minute caps five a second, and the rate +/// that reaches the client agrees with the refill that enforces it. +#[tokio::test] +async fn a_slower_ancestor_caps_a_faster_child_across_windows() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = chained_harness( + server.port(), + Some(windowed_policy(5, 1, "second", SharingMode::Private)), + Some(windowed_policy(100, 100, "minute", SharingMode::Enforce)), + ); + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + let refused = proxy_with(&harness, &[]).await; + assert_eq!(refused.status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + refused.header("x-ratelimit-limit"), + Some("1"), + "100 a minute floors to one token a second" + ); + assert!(refused.header("retry-after").is_some()); + Ok(()) +} + +/// `tenant` is keyed on the *calling* tenant, so two callers of one shared +/// upstream never spend each other's budget. +#[tokio::test] +async fn a_tenant_scoped_counter_follows_the_caller() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = root_harness( + server.port(), + Some(scoped_policy(1, 1, "tenant", SharingMode::Private)), + None, + ); + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + // A second caller of the same record starts from a full bucket. + let other = Uuid::now_v7(); + assert_eq!( + harness + .proxy_as("GET", PROXY_PATH, other, &[], b"") + .await? + .status, + StatusCode::OK + ); + // ... and the first one is refused, because its own bucket is spent. + assert_eq!( + proxy_with(&harness, &[]).await.status, + StatusCode::TOO_MANY_REQUESTS + ); + assert_eq!(mock.calls(), 2); + Ok(()) +} + +/// `user` is keyed on the subject, so two callers of one tenant have separate +/// budgets while one caller is held to its own. +#[tokio::test] +async fn a_user_scoped_counter_follows_the_subject() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = harness_with( + server.port(), + Some(scoped_policy(1, 1, "user", SharingMode::Private)), + None, + ); + let tenant = harness.tenant(); + let first = identity_of(tenant, Uuid::now_v7()); + let second = identity_of(tenant, Uuid::now_v7()); + assert_eq!( + proxy_identity(&harness, &first, &[]).await.status, + StatusCode::OK + ); + assert_eq!( + proxy_identity(&harness, &first, &[]).await.status, + StatusCode::TOO_MANY_REQUESTS + ); + assert_eq!( + proxy_identity(&harness, &second, &[]).await.status, + StatusCode::OK, + "the other subject's bucket is untouched" + ); + assert_eq!(mock.calls(), 2); + Ok(()) +} + +/// `route` is keyed on the matched route, so two routes of one upstream are +/// two counters. +#[tokio::test] +async fn a_route_scoped_counter_follows_the_route() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = harness_with( + server.port(), + Some(scoped_policy(1, 1, "route", SharingMode::Private)), + None, + ); + let id = harness + .store() + .find_upstream_by_alias(harness.tenant(), ALIAS) + .unwrap_or_else(|error| panic!("the store must be readable: {error}")) + .map(|record| record.id) + .context("the seeded upstream must be there")?; + seed_route_of(&harness, harness.tenant(), id, "/v1/other", None, None); + let other_mock = server.mock(|when, then| { + when.method(GET).path("/v1/other"); + then.status(200); + }); + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + assert_eq!( + harness + .proxy("GET", "/oagw/v1/proxy/api.vendor.com/v1/other", &[], b"") + .await? + .status, + StatusCode::OK, + "the other route has a bucket of its own" + ); + assert_eq!( + proxy_with(&harness, &[]).await.status, + StatusCode::TOO_MANY_REQUESTS + ); + assert_eq!(mock.calls(), 1); + assert_eq!(other_mock.calls(), 1, "both routes were forwarded"); + Ok(()) +} + +/// Only a forwarded hop that parses as an address gets a bucket of its own, so +/// a rotated header cannot mint fresh counters. +#[tokio::test] +async fn a_rotated_forwarded_header_cannot_mint_buckets() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = harness_with( + server.port(), + Some(scoped_policy(1, 1, "ip", SharingMode::Private)), + None, + ); + let garbage = ["garbage-1", "not-an-address", "0", "1.2.3.4.5.6.7.8.9"]; + for (index, hop) in garbage.iter().enumerate() { + let status = proxy_with(&harness, &[("x-forwarded-for", hop)]) + .await + .status; + if index == 0 { + assert_eq!( + status, + StatusCode::OK, + "{hop}: the shared counter still has its token" + ); + } else { + assert_eq!( + status, + StatusCode::TOO_MANY_REQUESTS, + "{hop}: an unparsable hop lands on the shared `unknown` counter" + ); + } + } + assert_eq!(mock.calls(), 1, "every later hop was refused"); + // A parsable address is a client of its own. + assert_eq!( + proxy_with(&harness, &[("x-forwarded-for", "10.0.0.1")]) + .await + .status, + StatusCode::OK + ); + Ok(()) +} + +/// A PUT that tightens the policy starts a fresh bucket, so the new limit is +/// honoured on the next request instead of the budget the old one spent. +#[tokio::test] +async fn a_put_that_tightens_the_limit_is_honoured_on_the_next_request() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = ProxyHarness::new(); + // The record is seeded directly, so the alias stays `api.vendor.com` even + // though a one-address pool derives `host:port`; the PUT then carries the + // very pool that alias came from, which the write path reads as no + // endpoint change. + let id = harness.seed_upstream(upstream( + harness.tenant(), + ALIAS, + server.port(), + Some(rate_limit(1, 2, SharingMode::Private, "reject", 1, true)), + None, + )); + seed_route(&harness, id); + + // The generous bucket serves two and refuses the third. + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + assert_eq!( + proxy_with(&harness, &[]).await.status, + StatusCode::TOO_MANY_REQUESTS + ); + + let body = serde_json::json!({ + "alias": ALIAS, + "protocol": common::PROTOCOL_HTTP, + "server": { "endpoints": [common::endpoint("http", "127.0.0.1", server.port())] }, + "rate_limit": policy_json(1, 1), + }); + let reply = harness + .call("PUT", &format!("/oagw/v1/upstreams/{id}"), Some(body)) + .await?; + assert_eq!(reply.status, StatusCode::OK, "{}", reply.text); + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + assert_eq!( + proxy_with(&harness, &[]).await.status, + StatusCode::TOO_MANY_REQUESTS, + "the tightened capacity, not the spent budget of the old limit" + ); + Ok(()) +} + +/// `sustained`/`burst` of a policy on the wire, for the write-path tests. +fn policy_json(rate: u64, capacity: u64) -> serde_json::Value { + serde_json::json!({ + "sustained": { "rate": rate }, + "burst": { "capacity": capacity } + }) +} + +/// An admitted `degrade` request is not reported as an exhausted one. +#[tokio::test] +async fn an_admitted_degraded_request_is_not_reported() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = harness_with( + server.port(), + Some(rate_limit(1, 1, SharingMode::Private, "degrade", 1, true)), + None, + ); + let capture = LogCapture::default(); + let _guard = tracing::subscriber::set_default(capture.clone()); + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + let admitted = capture.lines(); + assert!( + !admitted.iter().any(|line| line.contains("rate limit")), + "an admitted request is not reported as an exhausted one: {admitted:?}" + ); + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + let exhausted = capture.lines(); + let reports = exhausted + .iter() + .filter(|line| line.contains("rate limit")) + .count(); + assert_eq!(reports, 1, "one admission, one report: {exhausted:?}"); + Ok(()) +} + +/// `queue` waits for the token instead of refusing. +#[tokio::test] +async fn a_queued_request_is_forwarded_when_a_token_frees_up() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = harness_with( + server.port(), + Some(rate_limit(4, 2, SharingMode::Private, "queue", 2, true)), + None, + ); + // The first request empties the bucket, so the second has to queue for + // half a second at four tokens a second; the head budget covers it. + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + let reply = proxy_with(&harness, &[]).await; + assert_eq!(reply.status, StatusCode::OK, "body: {}", reply.text); + assert_eq!(mock.calls(), 2); + Ok(()) +} + +/// `degrade` serves the request and still reports the spend. +#[tokio::test] +async fn a_degraded_request_is_forwarded_and_still_reports() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = harness_with( + server.port(), + Some(rate_limit(1, 1, SharingMode::Private, "degrade", 1, true)), + None, + ); + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + let spent = proxy_with(&harness, &[]).await; + assert_eq!(spent.status, StatusCode::OK, "body: {}", spent.text); + assert_eq!(spent.header("x-ratelimit-remaining"), Some("0")); + assert_eq!(mock.calls(), 2, "degrade never blocks"); + Ok(()) +} + +/// The write path rejects a policy it cannot enforce. +#[tokio::test] +async fn the_write_path_rejects_an_unenforceable_policy() -> Result<()> { + let harness = Harness::new(); + let cases: Vec<(&str, serde_json::Value)> = Vec::from([ + ( + "unsupported algorithm", + serde_json::json!({"sustained": {"rate": 5}, "algorithm": "sliding_window"}), + ), + ("zero rate", serde_json::json!({"sustained": {"rate": 0}})), + ( + "unknown window", + serde_json::json!({"sustained": {"rate": 5, "window": "week"}}), + ), + ( + "empty capacity", + serde_json::json!({"sustained": {"rate": 5}, "burst": {"capacity": 0}}), + ), + ( + "unknown scope", + serde_json::json!({"sustained": {"rate": 5}, "scope": "cluster"}), + ), + ( + "unknown strategy", + serde_json::json!({"sustained": {"rate": 5}, "strategy": "shed"}), + ), + ( + "zero cost", + serde_json::json!({"sustained": {"rate": 5}, "cost": 0}), + ), + ]); + for (what, payload) in cases { + let mut body = https_upstream("api.vendor.com", 443); + body["rate_limit"] = payload; + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(body)) + .await?; + assert_eq!( + reply.status, + StatusCode::BAD_REQUEST, + "{what}: {}", + reply.text + ); + assert_eq!( + reply.problem_type(), + Some(problem_type("validation.error.v1")), + "{what}" + ); + } + Ok(()) +} + +/// A deleted upstream leaves no bucket behind: the recreated record starts +/// with a full one. +#[tokio::test] +async fn a_deleted_upstream_leaves_no_spent_bucket_behind() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let policy = rate_limit(1, 2, SharingMode::Private, "reject", 1, true); + let harness = harness_with(server.port(), Some(policy.clone()), None); + let id = harness + .store() + .find_upstream_by_alias(harness.tenant(), ALIAS) + .unwrap_or_else(|error| panic!("the store must be readable: {error}")) + .map(|record| record.id) + .context("the seeded upstream must be there")?; + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + assert_eq!(proxy_with(&harness, &[]).await.status, StatusCode::OK); + assert_eq!( + proxy_with(&harness, &[]).await.status, + StatusCode::TOO_MANY_REQUESTS + ); + + // The control plane cascades the deletion to the data plane. + let reply = harness + .call("DELETE", &format!("/oagw/v1/upstreams/{id}"), None) + .await?; + assert_eq!(reply.status, StatusCode::NO_CONTENT, "{}", reply.text); + + // The same record seeded again starts from a full bucket; the cascade + // removed the route with the upstream, so that is seeded back too. + let record = upstream(harness.tenant(), ALIAS, server.port(), Some(policy), None); + seed_route(&harness, harness.seed_upstream(record)); + let reply = proxy_with(&harness, &[]).await; + assert_eq!(reply.status, StatusCode::OK, "body: {}", reply.text); + assert_eq!(mock.calls(), 3); + Ok(()) +} + +// ── CORS (ADR-0004) ────────────────────────────────────────────────────── + +/// A preflight is answered locally, with the echo the ADR asks for. +#[tokio::test] +async fn a_preflight_is_answered_locally_with_the_echo() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = harness_with( + server.port(), + None, + Some(cors_config(true, &["https://app.example.com"], &["GET"])), + ); + let reply = harness + .proxy( + "OPTIONS", + PROXY_PATH, + &[ + ("origin", "https://app.example.com"), + ("access-control-request-method", "DELETE"), + ("access-control-request-headers", "x-trace"), + ], + b"", + ) + .await?; + assert_eq!(reply.status, StatusCode::NO_CONTENT, "{}", reply.text); + assert_eq!( + reply.header("access-control-allow-origin"), + Some("https://app.example.com") + ); + assert_eq!(reply.header("access-control-allow-methods"), Some("DELETE")); + assert_eq!( + reply.header("access-control-allow-headers"), + Some("x-trace") + ); + assert_eq!(reply.header("access-control-max-age"), Some("86400")); + assert_eq!( + reply.header("vary"), + Some("Origin, Access-Control-Request-Method, Access-Control-Request-Headers") + ); + assert_eq!(mock.calls(), 0, "a preflight is never dialled"); + Ok(()) +} + +/// Preflight detection is not gated on the configuration. +#[tokio::test] +async fn a_preflight_is_answered_even_when_cors_is_disabled() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = harness_with(server.port(), None, Some(cors_config(false, &[], &[]))); + let reply = harness + .proxy( + "OPTIONS", + PROXY_PATH, + &[ + ("origin", "https://app.example.com"), + ("access-control-request-method", "GET"), + ], + b"", + ) + .await?; + assert_eq!(reply.status, StatusCode::NO_CONTENT); + assert_eq!( + reply.header("access-control-allow-origin"), + Some("https://app.example.com") + ); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +/// An allowed origin is forwarded and the answer names it. +#[tokio::test] +async fn an_allowed_origin_is_forwarded_and_echoed() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = harness_with( + server.port(), + None, + Some(cors_config( + true, + &["https://app.example.com"], + &["GET", "POST"], + )), + ); + let reply = proxy_with(&harness, &[("origin", "https://app.example.com")]).await; + assert_eq!(reply.status, StatusCode::OK, "{}", reply.text); + assert_eq!( + reply.header("access-control-allow-origin"), + Some("https://app.example.com") + ); + assert_eq!(reply.header("vary"), Some("Origin")); + assert_eq!(mock.calls(), 1); + Ok(()) +} + +/// A disallowed origin is refused before the dial. +#[tokio::test] +async fn a_disallowed_origin_is_refused_without_a_dial() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = harness_with( + server.port(), + None, + Some(cors_config(true, &["https://app.example.com"], &["GET"])), + ); + let reply = proxy_with(&harness, &[("origin", "https://evil.example.org")]).await; + assert_eq!(reply.status, StatusCode::FORBIDDEN, "{}", reply.text); + assert_eq!( + reply.problem_type(), + Some(problem_type("cors.origin_not_allowed.v1")) + ); + assert_eq!(reply.header(ERROR_SOURCE), Some("gateway")); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +/// A method the policy does not allow is refused before the dial. +#[tokio::test] +async fn a_disallowed_method_is_refused_without_a_dial() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = harness_with( + server.port(), + None, + Some(cors_config(true, &["*"], &["GET"])), + ); + let reply = harness + .proxy( + "POST", + PROXY_PATH, + &[("origin", "https://app.example.com")], + b"", + ) + .await?; + assert_eq!(reply.status, StatusCode::FORBIDDEN, "{}", reply.text); + assert_eq!( + reply.problem_type(), + Some(problem_type("cors.method_not_allowed.v1")) + ); + assert_eq!(reply.header(ERROR_SOURCE), Some("gateway")); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +/// Origin matching is port- and protocol-sensitive. +#[tokio::test] +async fn an_origin_is_matched_port_and_protocol_sensitively() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = harness_with( + server.port(), + None, + Some(cors_config(true, &["https://app.example.com"], &["GET"])), + ); + for origin in ["https://app.example.com:8443", "http://app.example.com"] { + let reply = proxy_with(&harness, &[("origin", origin)]).await; + assert_eq!(reply.status, StatusCode::FORBIDDEN, "{origin}"); + assert_eq!( + reply.problem_type(), + Some(problem_type("cors.origin_not_allowed.v1")), + "{origin}" + ); + } + assert_eq!(mock.calls(), 0); + Ok(()) +} + +/// No suffix matching either: a shared suffix is not a shared origin. +#[tokio::test] +async fn a_shared_suffix_is_not_a_shared_origin() -> Result<()> { + let server = MockServer::start(); + let mock = ok_mock(&server); + let harness = harness_with( + server.port(), + None, + Some(cors_config(true, &["https://example.com"], &["GET"])), + ); + let reply = proxy_with(&harness, &[("origin", "https://evil.com.example.com")]).await; + assert_eq!(reply.status, StatusCode::FORBIDDEN); + assert_eq!(mock.calls(), 0); + Ok(()) +} + +/// `expose_headers` and `allow_credentials` reach the response. +#[tokio::test] +async fn the_credential_and_expose_members_reach_the_response() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = harness_with( + server.port(), + None, + Some(cors_policy(cors_config( + true, + &["https://app.example.com"], + &["GET", "POST"], + ))), + ); + let reply = proxy_with(&harness, &[("origin", "https://app.example.com")]).await; + assert_eq!(reply.status, StatusCode::OK); + assert_eq!( + reply.header("access-control-allow-origin"), + Some("https://app.example.com"), + "a credentialed answer echoes the origin instead of `*`" + ); + assert_eq!( + reply.header("access-control-allow-credentials"), + Some("true") + ); + assert_eq!( + reply.header("access-control-expose-headers"), + Some("x-request-id, x-trace") + ); + Ok(()) +} + +/// A wildcard policy without credentials answers the wildcard itself. +#[tokio::test] +async fn a_wildcard_policy_without_credentials_answers_the_wildcard() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = harness_with( + server.port(), + None, + Some(cors_config(true, &["*"], &["GET", "POST"])), + ); + let reply = proxy_with(&harness, &[("origin", "https://app.example.com")]).await; + assert_eq!(reply.status, StatusCode::OK); + assert_eq!(reply.header("access-control-allow-origin"), Some("*")); + assert!(reply.header("access-control-allow-credentials").is_none()); + Ok(()) +} + +/// The write path refuses a wildcard origin behind credentials. +#[tokio::test] +async fn a_wildcard_origin_with_credentials_is_rejected() -> Result<()> { + let harness = Harness::new(); + let mut body = https_upstream("api.vendor.com", 443); + body["cors"] = serde_json::json!({ + "enabled": true, + "allowed_origins": ["*"], + "allow_credentials": true + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(body)) + .await?; + assert_eq!(reply.status, StatusCode::BAD_REQUEST, "{}", reply.text); + assert_eq!( + reply.problem_type(), + Some(problem_type("validation.error.v1")) + ); + assert!( + reply.text.contains("allow_credentials"), + "the detail names the ADR rule: {}", + reply.text + ); + Ok(()) +} + +/// CORS off: no header of its own on an otherwise identical request. +#[tokio::test] +async fn a_disabled_policy_adds_no_cors_header() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = harness_with( + server.port(), + None, + Some(cors_config(false, &["*"], &["GET"])), + ); + let reply = proxy_with(&harness, &[("origin", "https://app.example.com")]).await; + assert_eq!(reply.status, StatusCode::OK); + assert!(reply.header("access-control-allow-origin").is_none()); + assert!(reply.header("access-control-allow-credentials").is_none()); + assert!(reply.header("access-control-expose-headers").is_none()); + Ok(()) +} + +/// An upstream origin set that *enforces* is what a route policy of its own is +/// judged against, so the route cannot widen it. +#[tokio::test] +async fn an_upstream_cors_set_survives_a_route_policy_of_its_own() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = ProxyHarness::new(); + let record = upstream( + harness.tenant(), + ALIAS, + server.port(), + None, + Some(shared_cors( + SharingMode::Enforce, + &["https://parent.example.com"], + )), + ); + let id = harness.seed_upstream(record); + seed_route_of( + &harness, + harness.tenant(), + id, + "/v1/chat", + None, + Some(shared_cors( + SharingMode::Enforce, + &["https://route.example.com"], + )), + ); + // The upstream's origin is the one that counts. + let allowed = proxy_with(&harness, &[("origin", "https://parent.example.com")]).await; + assert_eq!(allowed.status, StatusCode::OK, "{}", allowed.text); + // ... and the route's is not. + let refused = proxy_with(&harness, &[("origin", "https://route.example.com")]).await; + assert_eq!(refused.status, StatusCode::FORBIDDEN, "{}", refused.text); + Ok(()) +} + +/// A 429 is still a CORS answer: the origin was allowed, so the browser gets +/// the allow-origin next to the quota headers. +#[tokio::test] +async fn a_429_for_an_allowed_origin_still_speaks_cors() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = harness_with( + server.port(), + Some(rate_limit(1, 1, SharingMode::Private, "reject", 1, true)), + Some(cors_config(true, &["https://app.example.com"], &["GET"])), + ); + assert_eq!( + proxy_with(&harness, &[("origin", "https://app.example.com")]) + .await + .status, + StatusCode::OK + ); + let refused = proxy_with(&harness, &[("origin", "https://app.example.com")]).await; + assert_eq!(refused.status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + refused.header("access-control-allow-origin"), + Some("https://app.example.com") + ); + assert_eq!(refused.header("vary"), Some("Origin")); + Ok(()) +} + +/// A CORS refusal answers the browser too, and it never names the origin it +/// refused. +#[tokio::test] +async fn a_cors_refusal_carries_no_allow_origin() -> Result<()> { + let server = MockServer::start(); + ok_mock(&server); + let harness = harness_with( + server.port(), + Some(rate_limit(10, 10, SharingMode::Private, "reject", 1, true)), + Some(cors_config(true, &["https://app.example.com"], &["GET"])), + ); + let reply = proxy_with(&harness, &[("origin", "https://evil.example.org")]).await; + assert_eq!(reply.status, StatusCode::FORBIDDEN); + assert_eq!(reply.header("vary"), Some("Origin")); + assert!(reply.header("access-control-allow-origin").is_none()); + Ok(()) +} + +/// An enabled policy is authoritative: the upstream's own CORS answer does not +/// ride next to the gateway's. +#[tokio::test] +async fn an_enabled_policy_replaces_the_upstreams_own_cors_answer() -> Result<()> { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200) + .header("access-control-allow-origin", "*") + .header("access-control-allow-credentials", "true") + .header("content-type", "text/plain"); + }); + let harness = harness_with( + server.port(), + None, + Some(cors_config(true, &["https://app.example.com"], &["GET"])), + ); + let reply = proxy_with(&harness, &[("origin", "https://app.example.com")]).await; + assert_eq!(reply.status, StatusCode::OK); + assert_eq!( + reply.header("access-control-allow-origin"), + Some("https://app.example.com"), + "the gateway's answer, not the upstream's wildcard" + ); + assert!(reply.header("access-control-allow-credentials").is_none()); + Ok(()) +} + +/// With the policy off, the gateway says nothing about CORS and the upstream's +/// own answer is the client's answer. +#[tokio::test] +async fn a_disabled_policy_leaves_the_upstreams_cors_answer_alone() -> Result<()> { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).header( + "access-control-allow-origin", + "https://upstream.example.com", + ); + }); + let harness = harness_with( + server.port(), + None, + Some(cors_config(false, &["https://app.example.com"], &["GET"])), + ); + let reply = proxy_with(&harness, &[("origin", "https://app.example.com")]).await; + assert_eq!(reply.status, StatusCode::OK); + assert_eq!( + reply.header("access-control-allow-origin"), + Some("https://upstream.example.com"), + "nothing of ours replaced it" + ); + Ok(()) +} + +/// The write path accepts a CORS policy that satisfies ADR-0004. +#[tokio::test] +async fn the_write_path_accepts_a_well_formed_cors_policy() -> Result<()> { + let harness = Harness::new(); + let mut body = https_upstream("api.vendor.com", 443); + body["cors"] = serde_json::json!({ + "enabled": true, + "allowed_origins": ["https://app.example.com"], + "allowed_methods": ["GET", "PUT"], + "expose_headers": ["x-request-id"], + "allow_credentials": true + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(body)) + .await?; + assert_eq!(reply.status, StatusCode::CREATED, "{}", reply.text); + let stored = reply + .json + .get("cors") + .and_then(|cors| cors.get("allow_credentials")) + .and_then(serde_json::Value::as_bool); + assert_eq!(stored, Some(true), "the policy round-trips: {}", reply.text); + Ok(()) +} diff --git a/gears/system/oagw/oagw/tests/routes_api_test.rs b/gears/system/oagw/oagw/tests/routes_api_test.rs new file mode 100644 index 0000000..864258b --- /dev/null +++ b/gears/system/oagw/oagw/tests/routes_api_test.rs @@ -0,0 +1,638 @@ +// Created: 2026-08-31 by Constructor Tech +// @cpt-dod:cpt-cf-oagw-dod-testing-rest-api:p2 +//! Route management API: CRUD status codes, match-rule uniqueness (409), +//! `upstream_id` immutability on PUT, ancestor invisibility and the cascade. + +mod common; + +use anyhow::{Context, Result}; +use axum::http::StatusCode; +use uuid::Uuid; + +use common::{Harness, PROTOCOL_HTTP, endpoint, http_match, https_upstream, route_payload}; + +fn tenant() -> Uuid { + Uuid::now_v7() +} + +/// Create an upstream and return its bare UUID. +async fn seed_upstream(harness: &Harness, owner: Uuid, host: &str) -> Result { + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(https_upstream(host, 443)), + ) + .await?; + assert_eq!(reply.status, StatusCode::CREATED, "body: {}", reply.text); + let id = reply.problem_field("id").context("id")?; + Ok(Uuid::parse_str(id)?) +} + +async fn seed_route( + harness: &Harness, + owner: Uuid, + upstream_id: Uuid, + path: &str, +) -> Result { + let reply = harness + .call( + "POST", + "/oagw/v1/routes", + owner, + Some(route_payload(upstream_id, &["GET"], path)), + ) + .await?; + assert_eq!(reply.status, StatusCode::CREATED, "body: {}", reply.text); + Ok(reply.json) +} + +#[tokio::test] +async fn create_route_returns_201_with_the_bare_uuid() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + let reply = harness + .call( + "POST", + "/oagw/v1/routes", + owner, + Some(route_payload(upstream_id, &["GET"], "/v1/chat")), + ) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + let id = reply.problem_field("id").context("id")?; + assert!( + Uuid::parse_str(id).is_ok(), + "expected a bare UUID, got {id}" + ); + assert_eq!( + reply.problem_field("upstream_id"), + Some(upstream_id.to_string().as_str()) + ); + assert_eq!( + reply + .json + .pointer("/match/http/methods/0") + .and_then(serde_json::Value::as_str), + Some("GET") + ); + assert_eq!( + reply + .json + .pointer("/match/http/path") + .and_then(serde_json::Value::as_str), + Some("/v1/chat") + ); + Ok(()) +} + +#[tokio::test] +async fn create_route_accepts_a_grpc_match_rule() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "grpc.vendor.com").await?; + let payload = serde_json::json!({ + "upstream_id": upstream_id.to_string(), + "match": { "grpc": { "service": "cf.vendor.Echo", "method": "Ping" } } + }); + let reply = harness + .call("POST", "/oagw/v1/routes", owner, Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED, "body: {}", reply.text); + assert_eq!( + reply + .json + .pointer("/match/grpc/service") + .and_then(serde_json::Value::as_str), + Some("cf.vendor.Echo") + ); + Ok(()) +} + +#[tokio::test] +async fn create_route_returns_404_for_a_foreign_upstream() -> Result<()> { + let harness = Harness::new(); + let upstream_id = seed_upstream(&harness, tenant(), "api.openai.com").await?; + let reply = harness + .call( + "POST", + "/oagw/v1/routes", + tenant(), + Some(route_payload(upstream_id, &["GET"], "/v1")), + ) + .await?; + + assert_eq!(reply.status, StatusCode::NOT_FOUND); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("upstream.not_found.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn create_route_returns_404_for_an_unknown_upstream() -> Result<()> { + let harness = Harness::new(); + let reply = harness + .call( + "POST", + "/oagw/v1/routes", + tenant(), + Some(route_payload(Uuid::now_v7(), &["GET"], "/v1")), + ) + .await?; + + assert_eq!(reply.status, StatusCode::NOT_FOUND); + Ok(()) +} + +#[tokio::test] +async fn duplicate_match_rule_for_the_same_upstream_is_409() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + seed_route(&harness, owner, upstream_id, "/v1/chat").await?; + let second = harness + .call( + "POST", + "/oagw/v1/routes", + owner, + Some(route_payload(upstream_id, &["GET"], "/v1/chat")), + ) + .await?; + + assert_eq!(second.status, StatusCode::CONFLICT); + assert_eq!( + second.problem_type(), + Some(common::problem_type("route.conflict.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn the_same_match_rule_is_fine_for_another_upstream() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let first = seed_upstream(&harness, owner, "api.openai.com").await?; + let second = seed_upstream(&harness, owner, "api.vendor.com").await?; + seed_route(&harness, owner, first, "/v1/chat").await?; + let reply = harness + .call( + "POST", + "/oagw/v1/routes", + owner, + Some(route_payload(second, &["GET"], "/v1/chat")), + ) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED, "body: {}", reply.text); + Ok(()) +} + +#[tokio::test] +async fn invalid_match_rules_are_rejected() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + + // No branch at all. + let empty = harness + .call( + "POST", + "/oagw/v1/routes", + owner, + Some(serde_json::json!({ "upstream_id": upstream_id.to_string(), "match": {} })), + ) + .await?; + assert_eq!(empty.status, StatusCode::BAD_REQUEST); + + // Both branches at once. + let both = harness + .call( + "POST", + "/oagw/v1/routes", + owner, + Some(serde_json::json!({ + "upstream_id": upstream_id.to_string(), + "match": { + "http": { "methods": ["GET"], "path": "/v1" }, + "grpc": { "service": "svc", "method": "m" } + } + })), + ) + .await?; + assert_eq!(both.status, StatusCode::BAD_REQUEST); + + // Relative path. + let relative = harness + .call( + "POST", + "/oagw/v1/routes", + owner, + Some(serde_json::json!({ + "upstream_id": upstream_id.to_string(), + "match": { "http": { "methods": ["GET"], "path": "v1" } } + })), + ) + .await?; + assert_eq!(relative.status, StatusCode::BAD_REQUEST); + assert_eq!(relative.problem_field("path"), Some("v1")); + Ok(()) +} + +#[tokio::test] +async fn get_route_accepts_the_gts_form_of_the_id() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + let created = seed_route(&harness, owner, upstream_id, "/v1/chat").await?; + let id = created + .get("id") + .and_then(serde_json::Value::as_str) + .context("id")?; + + let gts_id = common::gts_resource_id("route", Uuid::parse_str(id)?); + let reply = harness + .call("GET", &format!("/oagw/v1/routes/{gts_id}"), owner, None) + .await?; + + assert_eq!(reply.status, StatusCode::OK); + assert_eq!(reply.problem_field("id"), Some(id)); + Ok(()) +} + +#[tokio::test] +async fn foreign_routes_are_invisible() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + let created = seed_route(&harness, owner, upstream_id, "/v1/chat").await?; + let id = created + .get("id") + .and_then(serde_json::Value::as_str) + .context("id")?; + + let stranger = tenant(); + let reply = harness + .call("GET", &format!("/oagw/v1/routes/{id}"), stranger, None) + .await?; + assert_eq!(reply.status, StatusCode::NOT_FOUND); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("route.not_found.v1")) + ); + + let deleted = harness + .call("DELETE", &format!("/oagw/v1/routes/{id}"), stranger, None) + .await?; + assert_eq!(deleted.status, StatusCode::NOT_FOUND); + Ok(()) +} + +#[tokio::test] +async fn put_replaces_the_route_but_keeps_the_upstream() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let first = seed_upstream(&harness, owner, "api.openai.com").await?; + let second = seed_upstream(&harness, owner, "api.vendor.com").await?; + let created = seed_route(&harness, owner, first, "/v1/chat").await?; + let id = created + .get("id") + .and_then(serde_json::Value::as_str) + .context("id")?; + + // `upstream_id` is not part of the update DTO (DESIGN §3.3 "PUT + // (Replace)"), so a PUT cannot move a route to another upstream. + let replacement = serde_json::json!({ + "upstream_id": second.to_string(), + "match": http_match(&["POST"], "/v1/embeddings"), + "tags": ["inference"], + }); + let updated = harness + .call( + "PUT", + &format!("/oagw/v1/routes/{id}"), + owner, + Some(replacement), + ) + .await?; + + assert_eq!(updated.status, StatusCode::OK, "body: {}", updated.text); + assert_eq!(updated.problem_field("id"), Some(id)); + assert_eq!( + updated.problem_field("upstream_id"), + Some(first.to_string().as_str()) + ); + assert_eq!( + updated + .json + .pointer("/match/http/path") + .and_then(serde_json::Value::as_str), + Some("/v1/embeddings") + ); + assert_eq!( + updated + .json + .pointer("/tags") + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(1) + ); + Ok(()) +} + +#[tokio::test] +async fn put_route_conflicts_are_reported_as_409() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + seed_route(&harness, owner, upstream_id, "/v1/chat").await?; + let other = seed_route(&harness, owner, upstream_id, "/v1/embeddings").await?; + let id = other + .get("id") + .and_then(serde_json::Value::as_str) + .context("id")?; + + let reply = harness + .call( + "PUT", + &format!("/oagw/v1/routes/{id}"), + owner, + Some(serde_json::json!({ "match": http_match(&["GET"], "/v1/chat") })), + ) + .await?; + + assert_eq!(reply.status, StatusCode::CONFLICT); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("route.conflict.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn delete_route_returns_204_and_an_empty_body() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + let created = seed_route(&harness, owner, upstream_id, "/v1/chat").await?; + let id = created + .get("id") + .and_then(serde_json::Value::as_str) + .context("id")?; + + let deleted = harness + .call("DELETE", &format!("/oagw/v1/routes/{id}"), owner, None) + .await?; + assert_eq!(deleted.status, StatusCode::NO_CONTENT); + assert!(deleted.text.is_empty()); + + let missing = harness + .call("GET", &format!("/oagw/v1/routes/{id}"), owner, None) + .await?; + assert_eq!(missing.status, StatusCode::NOT_FOUND); + Ok(()) +} + +#[tokio::test] +async fn routes_scoped_per_tenant() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + seed_route(&harness, owner, upstream_id, "/v1/chat").await?; + + let stranger = tenant(); + let empty = harness + .call("GET", "/oagw/v1/routes", stranger, None) + .await?; + assert_eq!( + empty + .json + .pointer("/items") + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(0) + ); + Ok(()) +} + +#[tokio::test] +async fn routes_support_the_odata_pipeline() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + seed_route(&harness, owner, upstream_id, "/v1/chat").await?; + seed_route(&harness, owner, upstream_id, "/v1/embeddings").await?; + + let reply = harness + .call( + "GET", + "/oagw/v1/routes?$filter=match%20eq%20'%2Fv1%2Fchat'", + owner, + None, + ) + .await?; + // `match` is not a filterable field of the documented subset, but the + // pipeline must stay 200 and return something sane. + assert_eq!(reply.status, StatusCode::OK, "body: {}", reply.text); + + let projected = harness + .call("GET", "/oagw/v1/routes?$select=upstream_id", owner, None) + .await?; + assert_eq!(projected.status, StatusCode::OK, "body: {}", projected.text); + let first = projected + .json + .pointer("/items/0") + .cloned() + .unwrap_or(serde_json::Value::Null); + assert_eq!(first.as_object().map(serde_json::Map::len), Some(1)); + Ok(()) +} + +#[tokio::test] +async fn upstream_cascade_removes_its_routes_only() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let first = seed_upstream(&harness, owner, "api.openai.com").await?; + let second = harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [ + endpoint("https", "api.vendor.com", 443), + endpoint("https", "eu.vendor.com", 443), + ] } + })), + ) + .await?; + let second_id = second.problem_field("id").context("id")?; + let second_id = Uuid::parse_str(second_id)?; + seed_route(&harness, owner, first, "/v1/chat").await?; + seed_route(&harness, owner, second_id, "/v1/chat").await?; + + let deleted = harness + .call( + "DELETE", + &format!("/oagw/v1/upstreams/{first}"), + owner, + None, + ) + .await?; + assert_eq!(deleted.status, StatusCode::NO_CONTENT); + + let remaining = harness.call("GET", "/oagw/v1/routes", owner, None).await?; + let items = remaining + .json + .pointer("/items") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + assert_eq!(items.len(), 1); + assert_eq!( + items + .first() + .and_then(|route| route.get("upstream_id")) + .and_then(serde_json::Value::as_str), + Some(second_id.to_string().as_str()) + ); + Ok(()) +} + +#[tokio::test] +async fn a_route_policy_the_deployment_cannot_enforce_is_rejected() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + + let body = route_payload(upstream_id, &["GET"], "/v1/chat"); + let body = serde_json::json!({ + "upstream_id": body["upstream_id"], + "match": body["match"], + "rate_limit": { + "sustained": { "rate": 5 }, + "algorithm": "sliding_window" + } + }); + let reply = harness + .call("POST", "/oagw/v1/routes", owner, Some(body)) + .await?; + assert_eq!(reply.status, StatusCode::BAD_REQUEST, "{}", reply.text); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("validation.error.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn a_route_cors_policy_is_accepted_and_round_trips() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + + let body = route_payload(upstream_id, &["GET"], "/v1/chat"); + let body = serde_json::json!({ + "upstream_id": body["upstream_id"], + "match": body["match"], + "cors": { + "enabled": true, + "allowed_origins": ["https://app.example.com"], + "allowed_methods": ["GET"], + "expose_headers": ["x-request-id"], + "allow_credentials": true + } + }); + let reply = harness + .call("POST", "/oagw/v1/routes", owner, Some(body)) + .await?; + assert_eq!(reply.status, StatusCode::CREATED, "{}", reply.text); + assert_eq!( + reply + .json + .pointer("/cors/allow_credentials") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + reply + .json + .pointer("/cors/allowed_origins/0") + .and_then(serde_json::Value::as_str), + Some("https://app.example.com") + ); + Ok(()) +} + +#[tokio::test] +async fn a_route_wildcard_origin_behind_credentials_is_rejected() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + + let body = route_payload(upstream_id, &["GET"], "/v1/chat"); + let body = serde_json::json!({ + "upstream_id": body["upstream_id"], + "match": body["match"], + "cors": { + "enabled": true, + "allowed_origins": ["*"], + "allow_credentials": true + } + }); + let reply = harness + .call("POST", "/oagw/v1/routes", owner, Some(body)) + .await?; + assert_eq!(reply.status, StatusCode::BAD_REQUEST, "{}", reply.text); + assert!( + reply.text.contains("wildcard origin"), + "the detail quotes the ADR rule: {}", + reply.text + ); + Ok(()) +} + +#[tokio::test] +async fn a_rate_limit_policy_is_accepted_and_round_trips() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let upstream_id = seed_upstream(&harness, owner, "api.openai.com").await?; + + let body = route_payload(upstream_id, &["GET"], "/v1/chat"); + let body = serde_json::json!({ + "upstream_id": body["upstream_id"], + "match": body["match"], + "rate_limit": { + "sustained": { "rate": 100, "window": "minute" }, + "burst": { "capacity": 10 }, + "scope": "user", + "strategy": "queue", + "cost": 2 + } + }); + let reply = harness + .call("POST", "/oagw/v1/routes", owner, Some(body)) + .await?; + assert_eq!(reply.status, StatusCode::CREATED, "{}", reply.text); + assert_eq!( + reply + .json + .pointer("/rate_limit/sustained/rate") + .and_then(serde_json::Value::as_u64), + Some(100) + ); + assert_eq!( + reply + .json + .pointer("/rate_limit/cost") + .and_then(serde_json::Value::as_u64), + Some(2) + ); + Ok(()) +} diff --git a/gears/system/oagw/oagw/tests/upstreams_api_test.rs b/gears/system/oagw/oagw/tests/upstreams_api_test.rs new file mode 100644 index 0000000..450365f --- /dev/null +++ b/gears/system/oagw/oagw/tests/upstreams_api_test.rs @@ -0,0 +1,1510 @@ +// Created: 2026-08-31 by Constructor Tech +// @cpt-dod:cpt-cf-oagw-dod-testing-rest-api:p2 +//! Upstream management API: alias derivation and immutability (DESIGN §3.2), +//! endpoint validation, CRUD status codes, conflicts and the `OData` pipeline. + +mod common; + +use anyhow::{Context, Result}; +use axum::http::{StatusCode, header}; +use uuid::Uuid; + +use common::{Harness, PROTOCOL_HTTP, endpoint, https_upstream, ip_upstream}; + +fn tenant() -> Uuid { + Uuid::now_v7() +} + +// ── Alias derivation (DESIGN §3.2 table) ───────────────────────────────── + +#[tokio::test] +async fn hostname_on_a_standard_port_derives_the_hostname() -> Result<()> { + let harness = Harness::new(); + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(https_upstream("api.openai.com", 443)), + ) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + assert_eq!(reply.problem_field("alias"), Some("api.openai.com")); + assert_eq!(reply.problem_field("protocol"), Some(PROTOCOL_HTTP)); + // Bare UUID on the wire, not the GTS form. + let id = reply.problem_field("id").context("id")?; + assert!( + Uuid::parse_str(id).is_ok(), + "expected a bare UUID, got {id}" + ); + Ok(()) +} + +#[tokio::test] +async fn an_endpoint_without_a_port_takes_the_scheme_default() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [ + { "scheme": "https", "host": "api.vendor.com" }, + { "scheme": "https", "host": "eu.vendor.com" }, + ] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + // Both endpoints materialise to 443, so the derived routing key carries + // no port. + assert_eq!(reply.problem_field("alias"), Some("vendor.com")); + let endpoints = reply + .json + .get("server") + .and_then(|server| server.get("endpoints")) + .and_then(serde_json::Value::as_array) + .context("endpoints")? + .clone(); + let ports: Vec = endpoints + .iter() + .filter_map(|endpoint| endpoint.get("port")) + .filter_map(serde_json::Value::as_i64) + .collect(); + assert_eq!(ports, vec![443, 443]); + Ok(()) +} + +#[tokio::test] +async fn a_tag_outside_the_label_set_is_rejected() -> Result<()> { + let harness = Harness::new(); + let mut payload = https_upstream("api.openai.com", 443); + payload["tags"] = serde_json::json!(["team", "Eu West"]); + + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!( + reply.status, + StatusCode::BAD_REQUEST, + "body: {}", + reply.text + ); + assert_eq!( + reply + .json + .get("invalid_value") + .and_then(serde_json::Value::as_str), + Some("Eu West") + ); + Ok(()) +} + +#[tokio::test] +async fn too_many_tags_or_endpoints_are_rejected() -> Result<()> { + let harness = Harness::new(); + let mut payload = https_upstream("api.openai.com", 443); + payload["tags"] = serde_json::json!( + (0..33) + .map(|index| format!("tag{index}")) + .collect::>() + ); + let tags = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + assert_eq!(tags.status, StatusCode::BAD_REQUEST, "body: {}", tags.text); + + let endpoints: Vec = (0..65) + .map(|index| endpoint("https", &format!("host{index}.vendor.com"), 443)) + .collect(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": endpoints } + }); + let pool = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + assert_eq!(pool.status, StatusCode::BAD_REQUEST, "body: {}", pool.text); + Ok(()) +} + +#[tokio::test] +async fn an_unknown_member_is_ignored_not_rejected() -> Result<()> { + let harness = Harness::new(); + // Deliberate deviation (see `src/domain/model.rs`): no `deny_unknown_fields`, + // so a client from a newer revision still manages its records. + let mut payload = https_upstream("api.openai.com", 443); + payload["retry_budget"] = serde_json::json!({ "max": 3 }); + + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED, "body: {}", reply.text); + // The unknown member is not echoed back either. + assert_eq!(reply.json.get("retry_budget"), None); + Ok(()) +} + +#[tokio::test] +async fn a_body_larger_than_the_configured_limit_is_413() -> Result<()> { + // The OoP serve path installs no body-limit layer, so the gear applies + // `oagw.config.max_body_bytes` on the management routes itself. + let harness = Harness::with_policy(|policy| { + policy.max_body_bytes = 64; + }); + let mut payload = https_upstream("api.openai.com", 443); + payload["padding"] = serde_json::json!("x".repeat(512)); + + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!( + reply.status, + StatusCode::PAYLOAD_TOO_LARGE, + "body: {}", + reply.text + ); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("payload.too_large.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn hostname_on_a_non_standard_port_derives_host_and_port() -> Result<()> { + let harness = Harness::new(); + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(https_upstream("api.openai.com", 8443)), + ) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + assert_eq!(reply.problem_field("alias"), Some("api.openai.com:8443")); + Ok(()) +} + +#[tokio::test] +async fn multiple_hostnames_derive_the_registrable_suffix() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [ + endpoint("https", "us.vendor.com", 443), + endpoint("https", "eu.vendor.com", 443), + ] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + assert_eq!(reply.problem_field("alias"), Some("vendor.com")); + Ok(()) +} + +#[tokio::test] +async fn multi_host_pool_below_the_registrable_domain_derives_the_shared_suffix() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [ + endpoint("https", "x.us.vendor.com", 443), + endpoint("https", "y.us.vendor.com", 443), + ] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + assert_eq!(reply.problem_field("alias"), Some("us.vendor.com")); + Ok(()) +} + +#[tokio::test] +async fn multi_host_pool_on_a_non_standard_port_keeps_the_port() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [ + endpoint("https", "us.vendor.com", 8443), + endpoint("https", "eu.vendor.com", 8443), + ] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + assert_eq!(reply.problem_field("alias"), Some("vendor.com:8443")); + Ok(()) +} + +#[tokio::test] +async fn bare_public_suffix_requires_an_explicit_alias() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [ + endpoint("https", "foo.co.uk", 443), + endpoint("https", "bar.co.uk", 443), + ] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("validation.error.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn unrelated_hostnames_require_an_explicit_alias() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [ + endpoint("https", "us.foo.com", 443), + endpoint("https", "eu.bar.com", 443), + ] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn ip_pool_without_an_alias_is_rejected() -> Result<()> { + let harness = Harness::new(); + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(ip_upstream(None)), + ) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("validation.error.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn ip_pool_with_an_explicit_alias_is_accepted() -> Result<()> { + let harness = Harness::new(); + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(ip_upstream(Some("My-Service"))), + ) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + // Normalized to ASCII lowercase. + assert_eq!(reply.problem_field("alias"), Some("my-service")); + Ok(()) +} + +#[tokio::test] +async fn explicit_alias_must_be_an_ldh_name() -> Result<()> { + let harness = Harness::new(); + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(ip_upstream(Some("vendor.com%2Fevil"))), + ) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("validation.error.v1")) + ); + Ok(()) +} + +#[tokio::test] +async fn derived_pool_rejects_a_differing_alias() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "alias": "not-the-host", + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn derived_pool_tolerates_the_exact_alias_for_idempotency() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "alias": "api.openai.com", + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + Ok(()) +} + +#[tokio::test] +async fn hostname_pools_are_normalized_before_derivation() -> Result<()> { + let harness = Harness::new(); + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(https_upstream("API.OpenAI.COM.", 443)), + ) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + assert_eq!(reply.problem_field("alias"), Some("api.openai.com")); + Ok(()) +} + +// ── Endpoint validation ────────────────────────────────────────────────── + +#[tokio::test] +async fn empty_endpoint_pool_is_rejected() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn rfc1123_invalid_hostname_is_rejected() -> Result<()> { + let harness = Harness::new(); + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(https_upstream("-bad-.host", 443)), + ) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + assert_eq!(reply.problem_field("host"), Some("-bad-.host")); + Ok(()) +} + +#[tokio::test] +async fn heterogeneous_pools_are_rejected() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [ + endpoint("https", "api.openai.com", 443), + endpoint("https", "api.openai.com", 8443), + ] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn http_scheme_is_accepted_when_the_config_allows_it() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("http", "127.0.0.1", 8080)] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED, "body: {}", reply.text); + assert_eq!(reply.problem_field("alias"), Some("127.0.0.1:8080")); + Ok(()) +} + +#[tokio::test] +async fn http_scheme_is_rejected_under_the_https_only_baseline() -> Result<()> { + let harness = Harness::with_policy(|policy| { + policy.allow_http_upstream = false; + }); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("http", "127.0.0.1", 8080)] } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn port_zero_is_rejected() -> Result<()> { + let harness = Harness::new(); + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(https_upstream("api.openai.com", 0)), + ) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + assert_eq!(reply.problem_field("invalid_value"), Some("0")); + Ok(()) +} + +#[tokio::test] +async fn ssrf_denied_host_is_rejected() -> Result<()> { + let harness = Harness::with_policy(|policy| { + policy.ssrf.denied_hosts = vec!["metadata.internal".to_owned()]; + }); + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(https_upstream("metadata.internal", 443)), + ) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +// ── Header transformation rules (upstream schema `headers`) ────────────── + +/// An upstream payload with a request-side header block. +fn upstream_with_headers(headers: serde_json::Value) -> serde_json::Value { + let mut payload = https_upstream("api.openai.com", 443); + payload["headers"] = headers; + payload +} + +#[tokio::test] +async fn an_unknown_passthrough_mode_is_rejected() -> Result<()> { + let harness = Harness::new(); + let headers = serde_json::json!({ "request": { "passthrough": "everything" } }); + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(upstream_with_headers(headers)), + ) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + assert_eq!(reply.problem_field("invalid_value"), Some("everything")); + Ok(()) +} + +#[tokio::test] +async fn the_documented_passthrough_modes_are_accepted() -> Result<()> { + let harness = Harness::new(); + let headers = serde_json::json!({ "request": { "passthrough": "allowlist" } }); + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(upstream_with_headers(headers)), + ) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + Ok(()) +} + +#[tokio::test] +async fn an_invalid_header_name_is_rejected() -> Result<()> { + let harness = Harness::new(); + let headers = serde_json::json!({ + "request": { "set": { "not a header name": "value" } }, + "response": { "remove": ["bad name"] } + }); + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(upstream_with_headers(headers)), + ) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn a_header_block_of_valid_names_is_accepted() -> Result<()> { + let harness = Harness::new(); + let headers = serde_json::json!({ + "request": { + "set": { "x-trace": "oagw" }, + "add": { "x-vendor": "1" }, + "remove": ["x-secret"], + "passthrough": "none" + }, + "response": { "set": { "x-gateway": "oagw" }, "remove": ["x-server"] } + }); + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(upstream_with_headers(headers)), + ) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + Ok(()) +} + +// ── CRUD + conflicts ───────────────────────────────────────────────────── + +#[tokio::test] +async fn alias_conflict_within_a_tenant_is_409() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(ip_upstream(Some("my-service"))), + ) + .await?; + let second = harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(ip_upstream(Some("my-service"))), + ) + .await?; + + assert_eq!(second.status, StatusCode::CONFLICT); + assert_eq!( + second.problem_type(), + Some(common::problem_type("alias.conflict.v1")) + ); + assert_eq!( + second + .headers + .get("x-oagw-error-source") + .and_then(|value| value.to_str().ok()), + Some("gateway") + ); + Ok(()) +} + +#[tokio::test] +async fn same_alias_is_allowed_in_another_tenant() -> Result<()> { + let harness = Harness::new(); + harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(ip_upstream(Some("my-service"))), + ) + .await?; + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(ip_upstream(Some("my-service"))), + ) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED); + Ok(()) +} + +#[tokio::test] +async fn get_upstream_returns_the_record() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let created = harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(https_upstream("api.openai.com", 443)), + ) + .await?; + let id = created.problem_field("id").context("id")?.to_owned(); + + let reply = harness + .call("GET", &format!("/oagw/v1/upstreams/{id}"), owner, None) + .await?; + + assert_eq!(reply.status, StatusCode::OK); + assert_eq!(reply.problem_field("id"), Some(id.as_str())); + assert_eq!( + reply.problem_field("tenant_id"), + Some(owner.to_string().as_str()) + ); + assert_eq!(reply.problem_field("alias"), Some("api.openai.com")); + assert_eq!( + reply + .json + .pointer("/server/endpoints/0/host") + .and_then(serde_json::Value::as_str), + Some("api.openai.com") + ); + Ok(()) +} + +#[tokio::test] +async fn get_upstream_accepts_the_gts_form_of_the_id() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let created = harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(https_upstream("api.openai.com", 443)), + ) + .await?; + let id = created.problem_field("id").context("id")?.to_owned(); + + let gts_id = common::gts_resource_id("upstream", Uuid::parse_str(&id)?); + let reply = harness + .call("GET", &format!("/oagw/v1/upstreams/{gts_id}"), owner, None) + .await?; + + assert_eq!(reply.status, StatusCode::OK); + assert_eq!(reply.problem_field("id"), Some(id.as_str())); + Ok(()) +} + +#[tokio::test] +async fn foreign_records_are_invisible() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let created = harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(https_upstream("api.openai.com", 443)), + ) + .await?; + let id = created.problem_field("id").context("id")?.to_owned(); + + let reply = harness + .call("GET", &format!("/oagw/v1/upstreams/{id}"), tenant(), None) + .await?; + + assert_eq!(reply.status, StatusCode::NOT_FOUND); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("upstream.not_found.v1")) + ); + // A foreign tenant cannot delete or replace it either. + let deleted = harness + .call( + "DELETE", + &format!("/oagw/v1/upstreams/{id}"), + tenant(), + None, + ) + .await?; + assert_eq!(deleted.status, StatusCode::NOT_FOUND); + Ok(()) +} + +#[tokio::test] +async fn unparseable_ids_are_rejected_with_400() -> Result<()> { + let harness = Harness::new(); + let reply = harness + .call("GET", "/oagw/v1/upstreams/not-a-uuid", tenant(), None) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn put_replaces_the_upstream_and_clears_omitted_optionals() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let created = harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(https_upstream("api.openai.com", 443)), + ) + .await?; + let id = created.problem_field("id").context("id")?.to_owned(); + + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "tags": ["billing"], + "enabled": false + }); + let reply = harness + .call( + "PUT", + &format!("/oagw/v1/upstreams/{id}"), + owner, + Some(payload), + ) + .await?; + + assert_eq!(reply.status, StatusCode::OK, "body: {}", reply.text); + assert_eq!(reply.problem_field("id"), Some(id.as_str())); + assert_eq!( + reply + .json + .get("enabled") + .and_then(serde_json::Value::as_bool), + Some(false) + ); + assert_eq!( + reply + .json + .pointer("/tags") + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(1) + ); + assert!(reply.json.get("auth").is_none(), "auth must be cleared"); + assert!( + reply.json.get("plugins").is_none(), + "plugins must be cleared" + ); + Ok(()) +} + +// ── Credential isolation (DESIGN §2.2, ADR-0008) ───────────────────────── + +#[tokio::test] +async fn a_cred_reference_binding_is_accepted_and_echoed() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "auth": { + "type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1", + "client_id_ref": "cred://ms-graph-client-id", + "client_secret_ref": "cred://ms-graph-client-secret", + "issuer_url": "https://login.microsoftonline.com/", + "scopes": "https://graph.microsoft.com/.default" + } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED, "body: {}", reply.text); + // Only the reference is echoed back: the control plane has no secret + // material to leak. + assert_eq!( + reply + .json + .pointer("/auth/client_secret_ref") + .and_then(serde_json::Value::as_str), + Some("cred://ms-graph-client-secret") + ); + Ok(()) +} + +#[tokio::test] +async fn an_inline_credential_is_rejected_with_the_member_name() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "auth": { + "type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + "secret_ref": "sk-live-0123456789abcdef" + } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!( + reply.status, + StatusCode::BAD_REQUEST, + "body: {}", + reply.text + ); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("validation.error.v1")) + ); + assert_eq!(reply.problem_field("invalid_value"), Some("secret_ref")); + assert!( + !reply.text.contains("sk-live-0123456789abcdef"), + "the rejected secret must not be echoed: {}", + reply.text + ); + Ok(()) +} + +#[tokio::test] +async fn an_oauth2_binding_without_references_is_rejected() -> Result<()> { + let harness = Harness::new(); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.openai.com", 443)] }, + "auth": { + "type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1", + "token_endpoint": "https://login.microsoftonline.com/token" + } + }); + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn an_inline_credential_in_a_plugin_config_is_rejected() -> Result<()> { + let harness = Harness::new(); + let mut payload = common::plugin_payload("leaky", "def transform(ctx): pass\n"); + payload["config"] = serde_json::json!({ "api_key": "sk-live-0123456789abcdef" }); + let reply = harness + .call("POST", "/oagw/v1/plugins", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn put_rejects_an_endpoint_change_that_would_move_the_alias() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let created = harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(https_upstream("api.openai.com", 443)), + ) + .await?; + let id = created.problem_field("id").context("id")?.to_owned(); + + let moved = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "api.vendor.com", 443)] } + }); + let reply = harness + .call( + "PUT", + &format!("/oagw/v1/upstreams/{id}"), + owner, + Some(moved), + ) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("validation.error.v1")) + ); + // The stored record is untouched. + let unchanged = harness + .call("GET", &format!("/oagw/v1/upstreams/{id}"), owner, None) + .await?; + assert_eq!(unchanged.problem_field("alias"), Some("api.openai.com")); + Ok(()) +} + +#[tokio::test] +async fn put_accepts_a_case_only_hostname_change() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let created = harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(https_upstream("api.openai.com", 443)), + ) + .await?; + let id = created.problem_field("id").context("id")?.to_owned(); + + let same = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", "API.OpenAI.COM.", 443)] } + }); + let reply = harness + .call( + "PUT", + &format!("/oagw/v1/upstreams/{id}"), + owner, + Some(same), + ) + .await?; + + assert_eq!(reply.status, StatusCode::OK, "body: {}", reply.text); + assert_eq!(reply.problem_field("alias"), Some("api.openai.com")); + Ok(()) +} + +#[tokio::test] +async fn put_of_an_ip_upstream_may_repeat_its_alias() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let created = harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(ip_upstream(Some("my-service"))), + ) + .await?; + let id = created.problem_field("id").context("id")?.to_owned(); + + let payload = serde_json::json!({ + "alias": "my-service", + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [ + endpoint("https", "10.0.1.3", 443), + endpoint("https", "10.0.1.4", 443), + ] } + }); + let reply = harness + .call( + "PUT", + &format!("/oagw/v1/upstreams/{id}"), + owner, + Some(payload), + ) + .await?; + + assert_eq!(reply.status, StatusCode::OK, "body: {}", reply.text); + assert_eq!(reply.problem_field("alias"), Some("my-service")); + Ok(()) +} + +#[tokio::test] +async fn put_of_an_ip_upstream_rejects_a_renamed_alias() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let created = harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(ip_upstream(Some("my-service"))), + ) + .await?; + let id = created.problem_field("id").context("id")?.to_owned(); + + let payload = serde_json::json!({ + "alias": "renamed", + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [ + endpoint("https", "10.0.1.3", 443), + endpoint("https", "10.0.1.4", 443), + ] } + }); + let reply = harness + .call( + "PUT", + &format!("/oagw/v1/upstreams/{id}"), + owner, + Some(payload), + ) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn put_of_a_derivable_upstream_requires_the_derived_alias() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + // Hostname pool moves to an IP pool: rejected even with an explicit alias. + let created = harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(https_upstream("api.openai.com", 443)), + ) + .await?; + let id = created.problem_field("id").context("id")?.to_owned(); + + let payload = serde_json::json!({ + "alias": "my-service", + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [ + endpoint("https", "10.0.1.1", 443), + endpoint("https", "10.0.1.2", 443), + ] } + }); + let reply = harness + .call( + "PUT", + &format!("/oagw/v1/upstreams/{id}"), + owner, + Some(payload), + ) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn delete_upstream_returns_204_and_cascades_its_routes() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + let created = harness + .call( + "POST", + "/oagw/v1/upstreams", + owner, + Some(https_upstream("api.openai.com", 443)), + ) + .await?; + let id = created.problem_field("id").context("id")?.to_owned(); + let upstream_id = Uuid::parse_str(&id)?; + harness + .call( + "POST", + "/oagw/v1/routes", + owner, + Some(common::route_payload(upstream_id, &["GET"], "/v1/chat")), + ) + .await?; + + let deleted = harness + .call("DELETE", &format!("/oagw/v1/upstreams/{id}"), owner, None) + .await?; + assert_eq!(deleted.status, StatusCode::NO_CONTENT); + assert!(deleted.text.is_empty()); + + let routes = harness.call("GET", "/oagw/v1/routes", owner, None).await?; + assert_eq!( + routes + .json + .pointer("/items") + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(0) + ); + Ok(()) +} + +// ── Listing (OData subset) ─────────────────────────────────────────────── + +async fn seed_upstreams(harness: &Harness, owner: Uuid, count: usize) -> Result<()> { + for index in 0..count { + let host = format!("svc{index}.vendor.com"); + let payload = serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [endpoint("https", &host, 443)] }, + "tags": [format!("t{index}")], + }); + harness + .call("POST", "/oagw/v1/upstreams", owner, Some(payload)) + .await?; + } + Ok(()) +} + +#[tokio::test] +async fn list_defaults_to_fifty_items() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + seed_upstreams(&harness, owner, 3).await?; + + let reply = harness + .call("GET", "/oagw/v1/upstreams", owner, None) + .await?; + assert_eq!(reply.status, StatusCode::OK); + assert_eq!( + reply + .json + .pointer("/items") + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(3) + ); + Ok(()) +} + +#[tokio::test] +async fn list_applies_top_skip_orderby_and_filter() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + seed_upstreams(&harness, owner, 4).await?; + + let top_two = harness + .call( + "GET", + "/oagw/v1/upstreams?$top=2&$skip=1&$orderby=alias%20desc", + owner, + None, + ) + .await?; + assert_eq!(top_two.status, StatusCode::OK, "body: {}", top_two.text); + let items = top_two + .json + .pointer("/items") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + let aliases: Vec<&str> = items + .iter() + .filter_map(|item| item.get("alias").and_then(serde_json::Value::as_str)) + .collect(); + assert_eq!(aliases, vec!["svc2.vendor.com", "svc1.vendor.com"]); + + let filtered = harness + .call( + "GET", + "/oagw/v1/upstreams?$filter=alias%20eq%20'svc3.vendor.com'", + owner, + None, + ) + .await?; + assert_eq!(filtered.status, StatusCode::OK); + let items = filtered + .json + .pointer("/items") + .and_then(serde_json::Value::as_array) + .cloned() + .unwrap_or_default(); + assert_eq!(items.len(), 1); + assert_eq!( + items + .first() + .and_then(|item| item.get("alias")) + .and_then(serde_json::Value::as_str), + Some("svc3.vendor.com") + ); + Ok(()) +} + +#[tokio::test] +async fn list_select_projects_the_requested_fields() -> Result<()> { + let harness = Harness::new(); + let owner = tenant(); + seed_upstreams(&harness, owner, 1).await?; + + let reply = harness + .call("GET", "/oagw/v1/upstreams?$select=alias", owner, None) + .await?; + + assert_eq!(reply.status, StatusCode::OK); + let first = reply + .json + .pointer("/items/0") + .cloned() + .unwrap_or(serde_json::Value::Null); + assert_eq!(first.as_object().map(serde_json::Map::len), Some(1)); + Ok(()) +} + +#[tokio::test] +async fn list_accepts_top_at_the_maximum() -> Result<()> { + let harness = Harness::new(); + let reply = harness + .call("GET", "/oagw/v1/upstreams?$top=100", tenant(), None) + .await?; + + assert_eq!(reply.status, StatusCode::OK, "body: {}", reply.text); + Ok(()) +} + +#[tokio::test] +async fn list_rejects_an_out_of_range_top() -> Result<()> { + let harness = Harness::new(); + let reply = harness + .call("GET", "/oagw/v1/upstreams?$top=0", tenant(), None) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + Ok(()) +} + +#[tokio::test] +async fn list_scopes_to_the_calling_tenant() -> Result<()> { + let harness = Harness::new(); + seed_upstreams(&harness, tenant(), 2).await?; + + let other = harness + .call("GET", "/oagw/v1/upstreams", tenant(), None) + .await?; + assert_eq!( + other + .json + .pointer("/items") + .and_then(serde_json::Value::as_array) + .map(Vec::len), + Some(0) + ); + Ok(()) +} + +// ── Error contract (RFC 9457 + ADR-0007) ───────────────────────────────── + +#[tokio::test] +async fn problems_carry_the_oagw_error_contract() -> Result<()> { + let harness = Harness::new(); + let reply = harness + .call( + "GET", + "/oagw/v1/upstreams/00000000-0000-0000-0000-000000000000", + tenant(), + None, + ) + .await?; + + assert_eq!(reply.status, StatusCode::NOT_FOUND); + let content_type = reply + .headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .context("content-type")?; + assert!( + content_type.starts_with("application/problem+json"), + "got {content_type}" + ); + assert_eq!( + reply + .headers + .get("x-oagw-error-source") + .and_then(|value| value.to_str().ok()), + Some("gateway") + ); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("upstream.not_found.v1")) + ); + assert_eq!(reply.problem_field("title"), Some("Not Found")); + assert_eq!( + reply.json.get("status").and_then(serde_json::Value::as_u64), + Some(404) + ); + assert_eq!( + reply.problem_field("instance"), + Some("/oagw/v1/upstreams/00000000-0000-0000-0000-000000000000") + ); + Ok(()) +} + +#[tokio::test] +async fn problems_echo_the_trace_id() -> Result<()> { + let harness = Harness::new(); + let trace_id = "0af7651916cd43dd8448eb211c80319c"; + let reply = harness + .call_with_headers( + "GET", + "/oagw/v1/upstreams/00000000-0000-0000-0000-000000000000", + tenant(), + None, + &[("traceparent", &format!("00-{trace_id}-b7ad6b7169203331-01"))], + ) + .await?; + + assert_eq!(reply.problem_field("trace_id"), Some(trace_id)); + Ok(()) +} + +#[tokio::test] +async fn malformed_json_is_rejected_with_400() -> Result<()> { + let harness = Harness::new(); + let reply = harness + .call( + "POST", + "/oagw/v1/upstreams", + tenant(), + Some(serde_json::json!({"protocol": "nope"})), + ) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST); + assert!( + reply + .headers + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with("application/problem+json")) + ); + Ok(()) +} + +// ── Policy write paths (ADR-0003 rate limiting, ADR-0004 CORS) ─────────── + +#[tokio::test] +async fn an_upstream_policy_the_deployment_cannot_enforce_is_rejected() -> Result<()> { + let harness = Harness::new(); + let mut payload = https_upstream("api.openai.com", 443); + payload["rate_limit"] = serde_json::json!({ + "sustained": { "rate": 5 }, + "algorithm": "sliding_window" + }); + + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST, "{}", reply.text); + assert_eq!( + reply.problem_type(), + Some(common::problem_type("validation.error.v1")) + ); + assert!( + reply.text.contains("sliding_window"), + "the detail must name the unsupported algorithm: {}", + reply.text + ); + Ok(()) +} + +#[tokio::test] +async fn an_upstream_cors_policy_is_accepted_and_round_trips() -> Result<()> { + let harness = Harness::new(); + let mut payload = https_upstream("api.openai.com", 443); + payload["cors"] = serde_json::json!({ + "enabled": true, + "allowed_origins": ["https://app.example.com"], + "allowed_methods": ["GET"], + "allow_credentials": true + }); + + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED, "{}", reply.text); + assert_eq!( + reply + .json + .pointer("/cors/allowed_origins/0") + .and_then(serde_json::Value::as_str), + Some("https://app.example.com") + ); + assert_eq!( + reply + .json + .pointer("/cors/allow_credentials") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + Ok(()) +} + +#[tokio::test] +async fn an_upstream_wildcard_origin_behind_credentials_is_rejected() -> Result<()> { + let harness = Harness::new(); + let mut payload = https_upstream("api.openai.com", 443); + payload["cors"] = serde_json::json!({ + "enabled": true, + "allowed_origins": ["*"], + "allow_credentials": true + }); + + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::BAD_REQUEST, "{}", reply.text); + assert!( + reply.text.contains("wildcard origin"), + "the detail must quote the ADR-0004 rule: {}", + reply.text + ); + Ok(()) +} + +#[tokio::test] +async fn an_upstream_rate_limit_policy_is_accepted_and_round_trips() -> Result<()> { + let harness = Harness::new(); + let mut payload = https_upstream("api.openai.com", 443); + payload["rate_limit"] = serde_json::json!({ + "sustained": { "rate": 100, "window": "minute" }, + "burst": { "capacity": 200 }, + "scope": "ip", + "cost": 2 + }); + + let reply = harness + .call("POST", "/oagw/v1/upstreams", tenant(), Some(payload)) + .await?; + + assert_eq!(reply.status, StatusCode::CREATED, "{}", reply.text); + assert_eq!( + reply + .json + .pointer("/rate_limit/sustained/rate") + .and_then(serde_json::Value::as_u64), + Some(100) + ); + assert_eq!( + reply + .json + .pointer("/rate_limit/burst/capacity") + .and_then(serde_json::Value::as_u64), + Some(200) + ); + assert_eq!( + reply + .json + .pointer("/rate_limit/scope") + .and_then(serde_json::Value::as_str), + Some("ip") + ); + Ok(()) +} diff --git a/gears/system/oagw/oagw/tests/websocket_test.rs b/gears/system/oagw/oagw/tests/websocket_test.rs new file mode 100644 index 0000000..e385530 --- /dev/null +++ b/gears/system/oagw/oagw/tests/websocket_test.rs @@ -0,0 +1,973 @@ +// Created: 2026-08-31 by Constructor Tech +// @cpt-dod:cpt-cf-oagw-dod-testing-proxy-data-plane:p2 +//! WebSocket upgrade proxying (PRD session flows; DESIGN §3.2 header table). +//! +//! A handshake is a `GET`: it resolves the alias, matches a route, runs the +//! plugin request phase, CORS and the rate limit, and is then dialled like any +//! other request. What differs is the answer — a 101 switches the *client* +//! connection over too, which an in-process `oneshot` can never complete. Every +//! test here therefore serves the router on a real listener and drives a raw +//! socket, and the mock upstream is a raw listener as well: the gateway is a +//! byte bridge, so the mock needs no RFC-strict framing to prove the session. +//! +//! Three decisions of the slice are pinned here. The upgrade headers the +//! upstream receives are the gateway's own canonical values, never the client's +//! token lists, because those lists are a smuggling channel; the number of live +//! sessions is capped by `oagw.config.max_websocket_sessions`, so a session +//! beyond the cap is refused before a dial; and an upstream that answers 101 +//! without accepting the session (RFC 6455 §4.2.2) is a 502 for the client, +//! which is never told the protocol switched when it did not. + +mod common; + +use anyhow::{Context as _, Result}; +use common::{ + LogCapture, ProxyHarness, domain_route, domain_upstream, loopback_endpoint, problem_type, +}; +use oagw::config::OagwConfig; +use oagw::domain::model::{ + BurstConfig, CorsConfig, Endpoint, HttpMethod, RateLimitConfig, Scheme, SharingMode, + SustainedRate, +}; +use oagw::domain::proxy::chain::NoChain; +use std::fmt::Write as _; +use std::net::{Ipv4Addr, SocketAddr}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _}; +use tokio::net::{TcpListener, TcpStream}; + +/// Alias every test routes through. +const ALIAS: &str = "api.vendor.com"; +/// Path of the route every test matches. +const ROUTE_PATH: &str = "/v1/ws"; +/// The handshake path on the gateway. +const HANDSHAKE_PATH: &str = "/oagw/v1/proxy/api.vendor.com/v1/ws"; +/// Base64 key a client sends; the mock echoes it back as the accept value. +const KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +/// How long an answer body may take to end before the test gives up. +const BODY_BUDGET: std::time::Duration = std::time::Duration::from_secs(5); +/// A close frame the mock echoes back untouched. +const CLOSE: [u8; 2] = [0x88, 0x00]; +/// A body a handshake must not carry. +const BODY: &str = "hello"; + +// ── Policies ───────────────────────────────────────────────────────────── + +/// A token-bucket policy of `rate` per second. +fn rate_limit(rate: u64, capacity: u64) -> RateLimitConfig { + RateLimitConfig { + sharing: SharingMode::Private, + algorithm: "token_bucket".to_owned(), + sustained: SustainedRate { + rate, + window: "second".to_owned(), + }, + burst: Some(BurstConfig { capacity }), + scope: "global".to_owned(), + strategy: "reject".to_owned(), + cost: 1, + response_headers: true, + } +} + +/// An enabled CORS policy for `origins`. +fn cors(origins: &[&str]) -> CorsConfig { + CorsConfig { + sharing: SharingMode::Private, + enabled: true, + allowed_origins: origins.iter().map(ToString::to_string).collect(), + allowed_methods: Vec::from(["GET".to_owned()]), + expose_headers: Vec::new(), + allow_credentials: false, + } +} + +// ── Mock upstream ──────────────────────────────────────────────────────── + +/// What the mock answers to the handshake it reads. +#[derive(Clone, Copy)] +enum Behaviour { + /// Complete the handshake, then echo every frame byte for byte. + Echo, + /// Complete the handshake, then mirror the request head back over the + /// session before echoing frames: what the upstream received is what the + /// bridge sent, key redacted. + Mirror, + /// Refuse the handshake with a plain HTTP answer. + Refuse { + /// Status line without the version. + status: &'static str, + /// Body of the refusal. + body: &'static str, + }, + /// Answer 200 with the request head echoed in the body, key redacted. + Inspect, + /// Answer a 101 that carries neither an `Upgrade` nor an accept value, then + /// close: the status switches, the session is never accepted. + Deny, +} + +/// A raw TCP listener standing in for the upstream. +struct Mock { + addr: SocketAddr, + connections: Arc, +} + +impl Mock { + /// How many connections the mock accepted. + fn connections(&self) -> usize { + self.connections.load(Ordering::SeqCst) + } +} + +/// Spawn the mock upstream of `behaviour` on an ephemeral port. +async fn spawn_mock(behaviour: Behaviour) -> Result { + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let addr = listener.local_addr()?; + let connections = Arc::new(AtomicUsize::new(0)); + let accepted = Arc::clone(&connections); + tokio::spawn(async move { + loop { + let Ok((socket, _)) = listener.accept().await else { + break; + }; + accepted.fetch_add(1, Ordering::SeqCst); + tokio::spawn(async move { + // A mock whose connection the test gave up on is not a failure. + answer(socket, behaviour).await.ok(); + }); + } + }); + Ok(Mock { addr, connections }) +} + +/// Answer one upstream connection and, for a completed handshake, echo frames. +async fn answer(mut socket: TcpStream, behaviour: Behaviour) -> Result<()> { + let (head, leftover) = read_head(&mut socket).await?; + match behaviour { + Behaviour::Echo => { + let accept = + header_of(&head, "sec-websocket-key").unwrap_or_else(|| "".to_owned()); + let upgrade = format!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: \ + Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n" + ); + socket.write_all(upgrade.as_bytes()).await?; + socket.write_all(&leftover).await?; + echo(&mut socket).await?; + } + Behaviour::Mirror => { + let accept = + header_of(&head, "sec-websocket-key").unwrap_or_else(|| "".to_owned()); + let upgrade = format!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: \ + Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n" + ); + socket.write_all(upgrade.as_bytes()).await?; + // Over the session, so the bridge is the only thing that could have + // altered it. + let mirrored = frame(redact_key(&head).as_bytes()); + socket.write_all(&mirrored).await?; + echo(&mut socket).await?; + } + Behaviour::Refuse { status, body } => { + let reply = format!( + "HTTP/1.1 {status}\r\nContent-Type: text/plain\r\nContent-Length: \ + {}\r\n\r\n{body}", + body.len() + ); + socket.write_all(reply.as_bytes()).await?; + socket.flush().await?; + } + Behaviour::Inspect => { + let body = redact_key(&head); + let reply = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: \ + {}\r\n\r\n{body}", + body.len() + ); + socket.write_all(reply.as_bytes()).await?; + socket.flush().await?; + } + Behaviour::Deny => { + // A 101 that accepts nothing: the dial client arms the upgrade on + // the status alone, so only the head can tell this from a session. + let answer = "HTTP/1.1 101 Switching Protocols\r\nConnection: upgrade\r\n\r\n"; + socket.write_all(answer.as_bytes()).await?; + socket.flush().await?; + } + } + Ok(()) +} + +/// Echo every byte that arrives until the peer stops sending. +async fn echo(socket: &mut TcpStream) -> Result<()> { + let mut buffer = [0u8; 4096]; + loop { + let read = socket.read(&mut buffer).await?; + if read == 0 { + return Ok(()); + } + socket.write_all(&buffer[..read]).await?; + } +} + +/// Read the request head, returning the bytes that followed it. +async fn read_head(socket: &mut TcpStream) -> Result<(String, Vec)> { + let mut buffer = Vec::new(); + let mut chunk = [0u8; 1024]; + while find_head_end(&buffer).is_none() { + let read = socket.read(&mut chunk).await?; + if read == 0 { + break; + } + buffer.extend_from_slice(&chunk[..read]); + } + let split = find_head_end(&buffer).map_or(buffer.len(), |at| at + 4); + let head = String::from_utf8_lossy(&buffer[..split]).into_owned(); + Ok((head, buffer[split..].to_vec())) +} + +/// Offset of the blank line that ends a head, when it has arrived. +fn find_head_end(buffer: &[u8]) -> Option { + buffer.windows(4).position(|window| window == b"\r\n\r\n") +} + +/// Value of one request header, case-insensitively. +fn header_of(head: &str, name: &str) -> Option { + head.lines().find_map(|line| { + let (candidate, value) = line.split_once(':')?; + candidate + .trim() + .eq_ignore_ascii_case(name) + .then(|| value.trim().to_owned()) + }) +} + +/// Replace the value of the handshake key: the test asserts its presence, never +/// its content. +fn redact_key(head: &str) -> String { + head.lines() + .map(|line| { + if line.to_ascii_lowercase().starts_with("sec-websocket-key") { + "sec-websocket-key: ".to_owned() + } else { + line.to_owned() + } + }) + .collect::>() + .join("\r\n") +} + +// ── Served data plane ──────────────────────────────────────────────────── + +/// A data plane served on a real listener, with a mock upstream behind it. +struct Served { + /// Address of the gateway. + gateway: SocketAddr, + /// The mock upstream the data plane dials. + mock: Mock, +} + +/// Serve a data plane whose upstream is `endpoint` and whose route carries the +/// two policies. +async fn serve_with( + behaviour: Behaviour, + scheme: Scheme, + rate_limit: Option, + cors: Option, + config: OagwConfig, +) -> Result { + let mock = spawn_mock(behaviour).await?; + let harness = ProxyHarness::with_config_and_chain(&config, Arc::new(NoChain)); + // The port is only known once the mock listens, so the endpoint is built + // here rather than by the caller. + let endpoint = Endpoint { + scheme, + ..http_endpoint(mock.addr.port()) + }; + let mut record = domain_upstream(harness.tenant(), ALIAS, vec![endpoint], true); + record.rate_limit = rate_limit; + record.cors = cors; + let id = harness.seed_upstream(record); + let route = domain_route(harness.tenant(), id, &[HttpMethod::Get], ROUTE_PATH, &[]); + harness + .store() + .insert_route_checked(route) + .context("the test route must seed")?; + let gateway = serve(&harness).await?; + Ok(Served { gateway, mock }) +} + +/// Serve the harness over a real listener, injecting the tenant context the +/// middleware would have produced. +/// +/// A handshake needs a socket the gateway can hand over, so `oneshot` cannot +/// drive these tests. +async fn serve(harness: &ProxyHarness) -> Result { + let ctx = common::security_context(harness.tenant())?; + let app = harness + .router() + .clone() + .layer(axum::middleware::map_request( + move |mut request: http::Request| { + let ctx = ctx.clone(); + async move { + let (mut parts, body) = request.into_parts(); + parts.extensions.insert(ctx); + request = http::Request::from_parts(parts, body); + request + } + }, + )); + let listener = TcpListener::bind((Ipv4Addr::LOCALHOST, 0)).await?; + let addr = listener.local_addr()?; + tokio::spawn(async move { + if let Err(error) = axum::serve(listener, app).await { + tracing::warn!(%error, "the test listener stopped"); + } + }); + Ok(addr) +} + +/// A plaintext endpoint of the mock upstream. +fn http_endpoint(port: u16) -> Endpoint { + Endpoint { + scheme: oagw::domain::model::Scheme::Http, + host: loopback_endpoint(port).host, + port, + } +} + +// ── Client ─────────────────────────────────────────────────────────────── + +/// The head of the answer the gateway gave the handshake. +struct Answer { + status: u16, + headers: Vec<(String, String)>, +} + +impl Answer { + /// Visible value of one answer header. + fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(candidate, _)| candidate.eq_ignore_ascii_case(name)) + .map(|(_, value)| value.as_str()) + } +} + +/// A raw client socket driving one handshake and the session behind it. +struct Session { + stream: TcpStream, + /// Bytes already read but not part of the answer head. + pending: Vec, +} + +impl Session { + /// Open a socket, send the handshake and read the answer head. + async fn handshake(gateway: SocketAddr, extra: &[(&str, &str)]) -> Result<(Self, Answer)> { + let extras = extra.iter().fold(String::new(), |mut head, (name, value)| { + let _written = write!(head, "{name}: {value}\r\n"); + head + }); + let mut request = handshake_head(gateway); + request.push_str(&extras); + request.push_str("\r\n"); + Self::open(gateway, &request).await + } + + /// Open a socket, send `request` verbatim and read the answer head. + async fn open(gateway: SocketAddr, request: &str) -> Result<(Self, Answer)> { + let mut stream = TcpStream::connect(gateway).await?; + stream.write_all(request.as_bytes()).await?; + let (head, leftover) = read_head(&mut stream).await?; + let status = head + .lines() + .next() + .and_then(|line| line.split(' ').nth(1)) + .and_then(|status| status.parse::().ok()) + .context("the answer carries no status line")?; + let headers = head + .lines() + .skip(1) + .filter_map(|line| { + let (name, value) = line.split_once(':')?; + Some((name.trim().to_ascii_lowercase(), value.trim().to_owned())) + }) + .collect(); + Ok(( + Self { + stream, + pending: leftover, + }, + Answer { status, headers }, + )) + } + + /// Fill `buffer` from what is already read, then from the socket. + async fn fill(&mut self, buffer: &mut [u8]) -> Result<()> { + // What the head read already pulled in satisfies the read first. + let take = self.pending.len().min(buffer.len()); + buffer[..take].copy_from_slice(&self.pending[..take]); + self.pending.drain(..take); + let mut filled = take; + while filled < buffer.len() { + let read = self.stream.read(&mut buffer[filled..]).await?; + if read == 0 { + return Err(anyhow::anyhow!("the connection ended early")); + } + filled += read; + } + Ok(()) + } + + /// Send one raw frame. + async fn send(&mut self, frame: &[u8]) -> Result<()> { + self.stream.write_all(frame).await?; + self.stream.flush().await?; + Ok(()) + } + + /// Read one frame the bridge forwarded. + async fn frame(&mut self) -> Result> { + let mut head = [0u8; 2]; + self.fill(&mut head).await?; + let short = usize::from(head[1] & 0x7f); + let length = if short == 126 { + let mut extended = [0u8; 2]; + self.fill(&mut extended).await?; + usize::from(u16::from_be_bytes(extended)) + } else { + short + }; + let mut payload = vec![0u8; length]; + self.fill(&mut payload).await?; + Ok(payload) + } + + /// Half-close the socket and drain what the bridge still sends. + async fn drain(mut self) -> Result { + self.stream.shutdown().await?; + let mut rest = std::mem::take(&mut self.pending); + self.stream.read_to_end(&mut rest).await?; + Ok(rest.len()) + } + + /// Read the body of an answer that was not an upgrade. + /// + /// The answer may be chunked, so the remaining bytes are read to the end of + /// the connection rather than to a declared length; a body that never ends + /// fails the test on its budget instead of hanging it. + async fn body(&mut self, answer: &Answer) -> Result { + let length = answer + .header("content-length") + .and_then(|v| v.parse::().ok()); + let mut body = if let Some(length) = length { + let mut declared = vec![0u8; length]; + tokio::time::timeout(BODY_BUDGET, self.fill(&mut declared)) + .await + .context("the answer body never arrived")??; + declared + } else { + let mut rest = std::mem::take(&mut self.pending); + tokio::time::timeout(BODY_BUDGET, self.stream.read_to_end(&mut rest)) + .await + .context("the answer body never ended")??; + rest + }; + body.extend_from_slice(&std::mem::take(&mut self.pending)); + Ok(String::from_utf8_lossy(&body).into_owned()) + } +} + +/// The handshake request head, without the extra headers a test adds. +fn handshake_head(gateway: SocketAddr) -> String { + format!( + "GET {HANDSHAKE_PATH} HTTP/1.1\r\nHost: {gateway}\r\nUpgrade: \ + websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Key: \ + {KEY}\r\nSec-WebSocket-Version: 13\r\n" + ) +} + +/// A request head with exactly the headers a test names. +fn request_head(gateway: SocketAddr, headers: &[(&str, &str)]) -> String { + let pairs = headers + .iter() + .fold(String::new(), |mut head, (name, value)| { + let _written = write!(head, "{name}: {value}\r\n"); + head + }); + format!("GET {HANDSHAKE_PATH} HTTP/1.1\r\nHost: {gateway}\r\n{pairs}\r\n") +} + +/// A text frame the way the tests hand-roll it: no mask, short or 16-bit length. +fn frame(payload: &[u8]) -> Vec { + let mut sent = vec![0x81]; + if payload.len() < 126 { + sent.push(u8::try_from(payload.len()).unwrap_or(126)); + } else { + sent.push(126); + let length = u16::try_from(payload.len()).unwrap_or(u16::MAX); + sent.extend_from_slice(&length.to_be_bytes()); + } + sent.extend_from_slice(payload); + sent +} + +// ── The bridge ─────────────────────────────────────────────────────────── + +/// A handshake is bridged: the client's socket carries the session from then +/// on, and the upstream's accept value reaches it verbatim. +#[tokio::test] +async fn a_websocket_handshake_is_bridged_to_the_upstream() -> Result<()> { + let served = serve_with( + Behaviour::Echo, + Scheme::Http, + None, + None, + common::proxy_config(), + ) + .await?; + let (mut session, answer) = Session::handshake(served.gateway, &[]).await?; + assert_eq!(answer.status, 101); + assert_eq!( + answer.header("sec-websocket-accept"), + Some(KEY), + "the upstream's accept value, forwarded verbatim" + ); + // The tokens are case-insensitive (RFC 9110 §7.6.1); the gateway emits the + // canonical lower-case form. + assert_eq!(answer.header("connection"), Some("upgrade")); + assert_eq!(answer.header("upgrade"), Some("websocket")); + // A switch of protocols is not an error, and ADR-0007 marks error + // provenance only: the upgraded head carries no source marker. + assert_eq!(answer.header("x-oagw-error-source"), None); + + let payload = b"hello over the bridge"; + session.send(&frame(payload)).await?; + assert_eq!(session.frame().await?, payload, "the echo came back"); + session.send(&CLOSE).await?; + // A close frame carries no payload of its own; arriving at all is the point. + assert!( + session.frame().await?.is_empty(), + "the close frame came back" + ); + assert_eq!( + session.drain().await?, + 0, + "the bridge closed both sides of the session" + ); + Ok(()) +} + +/// The handshake is a request: the quota is spent on it, not on the session. +#[tokio::test] +async fn the_handshake_is_gated_on_the_rate_limit() -> Result<()> { + let served = serve_with( + Behaviour::Echo, + Scheme::Http, + Some(rate_limit(1, 1)), + None, + common::proxy_config(), + ) + .await?; + let (session, answer) = Session::handshake(served.gateway, &[]).await?; + assert_eq!(answer.status, 101); + assert_eq!(session.drain().await?, 0); + + let (mut second, refused) = Session::handshake(served.gateway, &[]).await?; + assert_eq!(refused.status, 429); + assert_eq!(refused.header("x-ratelimit-remaining"), Some("0")); + assert!( + refused.header("retry-after").is_some(), + "the refusal carries the retry guidance of the bucket" + ); + // The refusal is an ordinary answer, body and all. + assert!( + second.body(&refused).await?.contains("rate limit"), + "the refusal says why" + ); + assert_eq!( + served.mock.connections(), + 1, + "a refused handshake is never dialled" + ); + Ok(()) +} + +/// A disallowed `Origin` is refused before the dial, with the `Vary` alone. +#[tokio::test] +async fn the_handshake_is_gated_on_cors() -> Result<()> { + let served = serve_with( + Behaviour::Echo, + Scheme::Http, + None, + Some(cors(&["https://good.example"])), + common::proxy_config(), + ) + .await?; + let (mut session, refused) = + Session::handshake(served.gateway, &[("Origin", "https://evil.example")]).await?; + assert_eq!(refused.status, 403); + assert_eq!(refused.header("vary"), Some("Origin")); + assert_eq!( + refused.header("access-control-allow-origin"), + None, + "a refused origin is never named" + ); + assert!( + session.body(&refused).await?.contains("origin"), + "the refusal says why" + ); + assert_eq!(served.mock.connections(), 0, "no dial happened"); + Ok(()) +} + +/// A failed handshake is an ordinary HTTP answer, forwarded untouched. +#[tokio::test] +async fn a_failed_handshake_is_a_normal_response() -> Result<()> { + let served = serve_with( + Behaviour::Refuse { + status: "401 Unauthorized", + body: "the upstream refused the token", + }, + Scheme::Http, + None, + None, + common::proxy_config(), + ) + .await?; + let (session, answer) = Session::handshake(served.gateway, &[]).await?; + assert_eq!(answer.status, 401); + // Not an upgrade, so the answer keeps the provenance marker of the data + // plane: the answer came from the upstream. + assert_eq!(answer.header("x-oagw-error-source"), Some("upstream")); + assert_eq!( + session.drain().await?, + "the upstream refused the token".len(), + "the body of the failure reached the client" + ); + Ok(()) +} + +/// `Upgrade` without the handshake's key is not an upgrade: it is proxied as a +/// request and loses the hop-by-hop headers on the way out. +#[tokio::test] +async fn a_handshake_that_is_not_one_is_untouched() -> Result<()> { + let served = serve_with( + Behaviour::Inspect, + Scheme::Http, + None, + None, + common::proxy_config(), + ) + .await?; + + // A `websocket` token without the key: no handshake, normal proxy path. + let request = request_head( + served.gateway, + &[ + ("Upgrade", "websocket"), + ("Connection", "Upgrade"), + ("Sec-WebSocket-Version", "13"), + ], + ); + let (mut session, answer) = Session::open(served.gateway, &request).await?; + assert_eq!(answer.status, 200, "no key, no upgrade"); + let body = session.body(&answer).await?; + assert!(body.contains("sec-websocket-version: 13"), "body: {body}"); + assert!( + !body.to_ascii_lowercase().contains("connection:"), + "the hop-by-hop headers are still stripped: {body}" + ); + assert!( + !body.to_ascii_lowercase().contains("upgrade:"), + "the hop-by-hop headers are still stripped: {body}" + ); + + // An ordinary request that merely carries an `Upgrade` header: same thing. + let request = request_head( + served.gateway, + &[ + ("Upgrade", "h2c"), + ("Connection", "Upgrade, HTTP2-Settings"), + ("Sec-WebSocket-Key", KEY), + ], + ); + let (mut session, answer) = Session::open(served.gateway, &request).await?; + assert_eq!(answer.status, 200, "no websocket token, no upgrade"); + let body = session.body(&answer).await?; + assert!( + !body.to_ascii_lowercase().contains("upgrade:"), + "an upgrade that is not a handshake is stripped: {body}" + ); + Ok(()) +} + +/// A TLS endpoint cannot be dialled by the plaintext bridge: 503, no dial. +#[tokio::test] +async fn a_tls_upstream_refuses_the_handshake_without_dialling() -> Result<()> { + let served = serve_with( + Behaviour::Echo, + Scheme::Https, + None, + None, + common::proxy_config(), + ) + .await?; + let (mut session, answer) = Session::handshake(served.gateway, &[]).await?; + assert_eq!(answer.status, 503); + assert_eq!( + answer.header("content-type"), + Some("application/problem+json") + ); + // The refusal is the gateway's own, not the upstream's. + assert_eq!(answer.header("x-oagw-error-source"), Some("gateway")); + let body = session.body(&answer).await?; + assert!( + body.contains(&problem_type("link.unavailable.v1")), + "body: {body}" + ); + assert!( + body.contains("https"), + "the refusal names the scheme it cannot dial: {body}" + ); + assert_eq!(served.mock.connections(), 0, "no dial happened"); + Ok(()) +} + +/// A session outlives the body budget: the bridge has no deadline at all. +#[tokio::test] +async fn the_session_has_no_body_budget() -> Result<()> { + let config = OagwConfig { + proxy_timeout_secs: 1, + proxy_idle_timeout_secs: Some(5), + proxy_stream_timeout_secs: Some(1), + allow_http_upstream: true, + ..common::proxy_config() + }; + let served = serve_with(Behaviour::Echo, Scheme::Http, None, None, config).await?; + let (mut session, answer) = Session::handshake(served.gateway, &[]).await?; + assert_eq!(answer.status, 101); + // Longer than the one-second budget of a forwarded body. + tokio::time::sleep(std::time::Duration::from_millis(1_400)).await; + let payload = b"still bridged"; + session.send(&frame(payload)).await?; + assert_eq!( + session.frame().await?, + payload, + "a session is not a body: it has no overall budget" + ); + assert_eq!(session.drain().await?, 0); + Ok(()) +} + +/// The 429 problem of a refused handshake keeps its type and its guidance. +#[tokio::test] +async fn a_refused_handshake_is_a_problem_document() -> Result<()> { + let served = serve_with( + Behaviour::Echo, + Scheme::Http, + Some(rate_limit(1, 1)), + None, + common::proxy_config(), + ) + .await?; + let (_first, allowed) = Session::handshake(served.gateway, &[]).await?; + assert_eq!(allowed.status, 101); + let (mut session, refused) = Session::handshake(served.gateway, &[]).await?; + assert_eq!(refused.status, 429); + assert_eq!( + refused.header("content-type"), + Some("application/problem+json") + ); + let body = session.body(&refused).await?; + assert!( + body.contains(&problem_type("rate_limit.exceeded.v1")), + "body: {body}" + ); + Ok(()) +} + +/// The cap on live sessions is real: a handshake that finds no free slot is +/// refused before a dial, and the refusal names the bound. +#[tokio::test] +async fn a_session_beyond_the_cap_is_refused() -> Result<()> { + let config = OagwConfig { + max_websocket_sessions: 1, + ..common::proxy_config() + }; + let served = serve_with(Behaviour::Echo, Scheme::Http, None, None, config).await?; + let (open, answer) = Session::handshake(served.gateway, &[]).await?; + assert_eq!(answer.status, 101); + // `open` stays in scope on purpose: the session holds the one slot. + let (mut refused, refused_head) = Session::handshake(served.gateway, &[]).await?; + assert_eq!(refused_head.status, 503, "one slot, one session"); + assert_eq!( + refused_head.header("content-type"), + Some("application/problem+json") + ); + let body = refused.body(&refused_head).await?; + assert!( + body.contains(&problem_type("link.unavailable.v1")), + "body: {body}" + ); + assert!( + body.contains("no free websocket session slot") && body.contains("the limit is 1"), + "the refusal names the bound: {body}" + ); + assert_eq!( + served.mock.connections(), + 1, + "only the session that opened was dialled" + ); + drop(open); + Ok(()) +} + +/// A handshake that carries a body is rejected before a dial: bytes after its +/// head are not protocol content, and dialling them would hang the bridge. +#[tokio::test] +async fn a_handshake_that_carries_a_body_is_a_bad_request() -> Result<()> { + let served = serve_with( + Behaviour::Inspect, + Scheme::Http, + None, + None, + common::proxy_config(), + ) + .await?; + // The length has to be declared, or the gateway has no body to see. + let request = format!( + "{}Content-Length: {}\r\n\r\n{BODY}", + handshake_head(served.gateway), + BODY.len() + ); + let (mut session, answer) = Session::open(served.gateway, &request).await?; + assert_eq!(answer.status, 400); + assert_eq!( + answer.header("content-type"), + Some("application/problem+json") + ); + let body = session.body(&answer).await?; + assert!( + body.contains(&problem_type("validation.error.v1")), + "body: {body}" + ); + assert_eq!(served.mock.connections(), 0, "no dial happened"); + Ok(()) +} + +/// The head the upstream receives on a successful handshake: the two upgrade +/// headers carry the gateway's canonical values, and nothing else of the +/// client's token lists rides along. +#[tokio::test] +async fn the_upstream_sees_canonical_upgrade_headers() -> Result<()> { + let served = serve_with( + Behaviour::Mirror, + Scheme::Http, + None, + None, + common::proxy_config(), + ) + .await?; + // The client names h2c and its settings alongside the websocket upgrade; + // neither may reach the upstream through a WebSocket bridge. + let (mut session, answer) = Session::handshake( + served.gateway, + &[ + ("Upgrade", "websocket, h2c"), + ("Connection", "upgrade, HTTP2-Settings"), + ], + ) + .await?; + assert_eq!(answer.status, 101); + let seen = String::from_utf8_lossy(&session.frame().await?).to_ascii_lowercase(); + assert!( + seen.contains("upgrade: websocket"), + "the upstream saw: {seen}" + ); + assert!( + seen.contains("connection: upgrade"), + "the upstream saw: {seen}" + ); + assert!( + seen.contains("sec-websocket-version: 13"), + "the upgrade content of the handshake is forwarded: {seen}" + ); + assert!( + seen.contains("sec-websocket-key: "), + "the handshake key is present, never spelled out: {seen}" + ); + assert!( + !seen.contains("h2c"), + "a second protocol never ships through a websocket bridge: {seen}" + ); + assert!( + !seen.contains("http2-settings"), + "the header of another upgrade is dropped: {seen}" + ); + assert_eq!(session.drain().await?, 0); + Ok(()) +} + +/// An upstream that answers 101 without accepting the session does not give the +/// client a session: the gateway refuses while it still owns the answer, so the +/// client is never told the protocol switched when it did not. +/// +/// A bare 101 is the trap RFC 6455 §4.2.2 closes: hyper arms the response +/// upgrade from the status alone, so without a check on the head the client +/// would hold a socket whose first read is EOF — a "session" that carries +/// nothing. The acceptance is judged in `switch_protocols`, which runs inside +/// the request task, so the record of the refusal is captured by the per-test +/// subscriber of `common` (the bridge's own records are not, which is why the +/// tests of a live session assert behaviour only). +#[tokio::test] +async fn an_upstream_that_did_not_accept_the_upgrade_is_a_502() -> Result<()> { + let served = serve_with( + Behaviour::Deny, + Scheme::Http, + None, + None, + common::proxy_config(), + ) + .await?; + let capture = LogCapture::default(); + let _guard = tracing::subscriber::set_default(capture.clone()); + let (mut session, answer) = Session::handshake(served.gateway, &[]).await?; + assert_eq!( + answer.status, 502, + "the client is not handed a session that was never accepted" + ); + assert_eq!( + answer.header("content-type"), + Some("application/problem+json") + ); + assert_eq!( + answer.header("x-oagw-error-source"), + Some("gateway"), + "the refusal is the gateway's own answer" + ); + let body = session.body(&answer).await?; + assert!( + body.contains(&problem_type("protocol.error.v1")), + "body: {body}" + ); + assert_eq!( + served.mock.connections(), + 1, + "the gateway dialled once and did not retry" + ); + let refused = capture + .lines() + .into_iter() + .find(|line| line.contains("websocket session refused")) + .context("the refusal of the upgrade was not logged")?; + assert!( + refused.contains(ALIAS) && refused.contains("the answer names no websocket upgrade"), + "the record names the alias and the reason: {refused}" + ); + Ok(()) +}