From 42b0ea22acfdb820492580787e6076f49e92f9e8 Mon Sep 17 00:00:00 2001 From: Kseniia Alekseitseva Date: Tue, 1 Sep 2026 14:37:55 +0000 Subject: [PATCH] B8-oagw-gateway__SQCbFXX --- gears/system/oagw/oagw/src/api/mod.rs | 3 + gears/system/oagw/oagw/src/api/rest/dto.rs | 493 ++++ .../oagw/oagw/src/api/rest/dto_tests.rs | 222 ++ .../oagw/oagw/src/api/rest/error_layer.rs | 447 ++++ .../oagw/oagw/src/api/rest/extractors.rs | 161 ++ .../oagw/src/api/rest/extractors_tests.rs | 168 ++ .../oagw/oagw/src/api/rest/handlers/mod.rs | 51 + .../oagw/src/api/rest/handlers/plugins.rs | 130 + .../oagw/oagw/src/api/rest/handlers/proxy.rs | 1327 +++++++++ .../oagw/src/api/rest/handlers/proxy_tests.rs | 196 ++ .../oagw/oagw/src/api/rest/handlers/routes.rs | 127 + .../oagw/src/api/rest/handlers/upstreams.rs | 127 + gears/system/oagw/oagw/src/api/rest/mod.rs | 16 + gears/system/oagw/oagw/src/api/rest/routes.rs | 347 +++ .../oagw/oagw/src/api/rest/test_support.rs | 281 ++ gears/system/oagw/oagw/src/config.rs | 222 ++ gears/system/oagw/oagw/src/config_tests.rs | 139 + gears/system/oagw/oagw/src/domain/audit.rs | 387 +++ .../oagw/oagw/src/domain/audit_tests.rs | 222 ++ gears/system/oagw/oagw/src/domain/cors.rs | 543 ++++ .../system/oagw/oagw/src/domain/cors_tests.rs | 598 +++++ gears/system/oagw/oagw/src/domain/error.rs | 714 +++++ gears/system/oagw/oagw/src/domain/metrics.rs | 624 +++++ .../oagw/oagw/src/domain/metrics_tests.rs | 326 +++ gears/system/oagw/oagw/src/domain/mod.rs | 19 + gears/system/oagw/oagw/src/domain/model.rs | 948 +++++++ gears/system/oagw/oagw/src/domain/odata.rs | 852 ++++++ .../oagw/oagw/src/domain/odata_tests.rs | 325 +++ gears/system/oagw/oagw/src/domain/plugin.rs | 1057 ++++++++ .../oagw/oagw/src/domain/plugin_tests.rs | 679 +++++ .../system/oagw/oagw/src/domain/rate_limit.rs | 1154 ++++++++ .../oagw/oagw/src/domain/rate_limit_tests.rs | 1014 +++++++ gears/system/oagw/oagw/src/domain/services.rs | 617 +++++ .../oagw/oagw/src/domain/services_tests.rs | 624 +++++ .../system/oagw/oagw/src/domain/validation.rs | 1259 +++++++++ .../oagw/oagw/src/domain/validation_tests.rs | 1274 +++++++++ gears/system/oagw/oagw/src/error_tests.rs | 432 +++ gears/system/oagw/oagw/src/gear.rs | 295 +++ gears/system/oagw/oagw/src/gear_tests.rs | 71 + gears/system/oagw/oagw/src/infra/mod.rs | 10 + .../oagw/oagw/src/infra/plugin/apikey.rs | 216 ++ .../oagw/src/infra/plugin/apikey_tests.rs | 226 ++ .../system/oagw/oagw/src/infra/plugin/mod.rs | 539 ++++ .../system/oagw/oagw/src/infra/plugin/noop.rs | 42 + .../oagw/oagw/src/infra/plugin/noop_tests.rs | 34 + .../oagw/oagw/src/infra/plugin/oauth2.rs | 440 +++ .../oagw/src/infra/plugin/oauth2_tests.rs | 695 +++++ .../oagw/src/infra/plugin/plugin_tests.rs | 941 +++++++ .../oagw/oagw/src/infra/plugin/request_id.rs | 105 + .../oagw/src/infra/plugin/request_id_tests.rs | 247 ++ .../oagw/src/infra/plugin/required_headers.rs | 133 + .../infra/plugin/required_headers_tests.rs | 277 ++ .../oagw/oagw/src/infra/plugin/secret.rs | 162 ++ .../oagw/src/infra/plugin/secret_tests.rs | 140 + gears/system/oagw/oagw/src/infra/proxy.rs | 1296 +++++++++ .../system/oagw/oagw/src/infra/proxy_tests.rs | 960 +++++++ gears/system/oagw/oagw/src/infra/storage.rs | 885 +++++++ gears/system/oagw/oagw/src/lib.rs | 59 + gears/system/oagw/oagw/src/model_tests.rs | 426 +++ gears/system/oagw/oagw/src/storage_tests.rs | 821 ++++++ .../oagw/oagw/tests/management_api_test.rs | 1315 +++++++++ gears/system/oagw/oagw/tests/proxy_test.rs | 2360 +++++++++++++++++ 62 files changed, 30820 insertions(+) create mode 100644 gears/system/oagw/oagw/src/api/mod.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/dto.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/dto_tests.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/error_layer.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/extractors.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/extractors_tests.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/handlers/mod.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/handlers/plugins.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/handlers/proxy.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/handlers/proxy_tests.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/handlers/routes.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/handlers/upstreams.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/mod.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/routes.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/test_support.rs create mode 100644 gears/system/oagw/oagw/src/config.rs create mode 100644 gears/system/oagw/oagw/src/config_tests.rs create mode 100644 gears/system/oagw/oagw/src/domain/audit.rs create mode 100644 gears/system/oagw/oagw/src/domain/audit_tests.rs create mode 100644 gears/system/oagw/oagw/src/domain/cors.rs create mode 100644 gears/system/oagw/oagw/src/domain/cors_tests.rs create mode 100644 gears/system/oagw/oagw/src/domain/error.rs create mode 100644 gears/system/oagw/oagw/src/domain/metrics.rs create mode 100644 gears/system/oagw/oagw/src/domain/metrics_tests.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/odata.rs create mode 100644 gears/system/oagw/oagw/src/domain/odata_tests.rs create mode 100644 gears/system/oagw/oagw/src/domain/plugin.rs create mode 100644 gears/system/oagw/oagw/src/domain/plugin_tests.rs create mode 100644 gears/system/oagw/oagw/src/domain/rate_limit.rs create mode 100644 gears/system/oagw/oagw/src/domain/rate_limit_tests.rs create mode 100644 gears/system/oagw/oagw/src/domain/services.rs create mode 100644 gears/system/oagw/oagw/src/domain/services_tests.rs create mode 100644 gears/system/oagw/oagw/src/domain/validation.rs create mode 100644 gears/system/oagw/oagw/src/domain/validation_tests.rs create mode 100644 gears/system/oagw/oagw/src/error_tests.rs create mode 100644 gears/system/oagw/oagw/src/gear.rs create mode 100644 gears/system/oagw/oagw/src/gear_tests.rs create mode 100644 gears/system/oagw/oagw/src/infra/mod.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/apikey.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/apikey_tests.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/mod.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/noop.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/noop_tests.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/oauth2.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/oauth2_tests.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/plugin_tests.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/request_id.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/request_id_tests.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/required_headers.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/required_headers_tests.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/secret.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/secret_tests.rs create mode 100644 gears/system/oagw/oagw/src/infra/proxy.rs create mode 100644 gears/system/oagw/oagw/src/infra/proxy_tests.rs create mode 100644 gears/system/oagw/oagw/src/infra/storage.rs create mode 100644 gears/system/oagw/oagw/src/model_tests.rs create mode 100644 gears/system/oagw/oagw/src/storage_tests.rs create mode 100644 gears/system/oagw/oagw/tests/management_api_test.rs create mode 100644 gears/system/oagw/oagw/tests/proxy_test.rs 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..d41fc3a --- /dev/null +++ b/gears/system/oagw/oagw/src/api/mod.rs @@ -0,0 +1,3 @@ +//! HTTP surface of the OAGW gear. + +pub mod rest; diff --git a/gears/system/oagw/oagw/src/api/rest/dto.rs b/gears/system/oagw/oagw/src/api/rest/dto.rs new file mode 100644 index 0000000..c2156d9 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/dto.rs @@ -0,0 +1,493 @@ +//! Wire DTOs of the OAGW management API. +//! +//! Request DTOs are `deny_unknown_fields` and never carry server-generated +//! fields (`id`, `tenant_id`, `created_at`, `updated_at`); the route replace +//! DTO additionally omits `upstream_id`, which is immutable. Response DTOs +//! mirror the domain model 1:1 and add the RFC 3339 timestamps the model keeps +//! as `SystemTime`. +//! +//! Nested configuration sections are projected into the OpenAPI document as +//! free-form objects (`#[schema(value_type = Object)]`): their shape is pinned +//! by `docs/schemas/upstream.v1.schema.json` and `route.v1.schema.json`, which +//! the domain model mirrors field-for-field, so the schema stays accurate +//! without duplicating every section type. +//! +//! List endpoints return [`toolkit::Page`] envelopes. Because `$select` +//! projects fields out of the serialised items, page items are emitted as the +//! projected JSON objects while the OpenAPI document describes the full item +//! schema ([`UpstreamDto`], [`RouteDto`], [`PluginDto`]). + +use uuid::Uuid; + +use crate::domain::audit::format_rfc3339; +use crate::domain::model::{ + AuthConfig, CorsConfig, HeadersConfig, Plugin, PluginConfig, Protocol, RateLimitConfig, Route, + RouteMatch, ServerConfig, Upstream, empty_json_object, +}; +use crate::domain::validation::{PluginInput, RouteInput, UpstreamInput}; + +// --------------------------------------------------------------------------- +// Upstreams +// --------------------------------------------------------------------------- + +/// Body of `POST /oagw/v1/upstreams` and `PUT /oagw/v1/upstreams/{id}`. +/// +/// `alias` is optional: hostname-based endpoint pools always derive it, and a +/// supplied alias must match the derived value (DESIGN section 3.1). `PUT` +/// replaces the whole resource; omitted optional fields are cleared. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +#[serde(deny_unknown_fields)] +pub struct UpstreamRequest { + /// Explicit alias; required for IP-based or non-derivable pools. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = String, nullable = true)] + pub alias: Option, + /// Defaults to `true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = bool, nullable = true)] + pub enabled: Option, + /// Discovery tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Endpoint pool (`server.endpoints`). + #[schema(value_type = Object)] + pub server: ServerConfig, + /// Upstream protocol; defaults to + /// `gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1`. + #[serde(default)] + #[schema(value_type = String)] + pub protocol: Protocol, + /// Auth plugin binding. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object, nullable = true)] + pub auth: Option, + /// Header transformation rules; defaults to no rules. + #[serde(default)] + #[schema(value_type = Object)] + pub headers: HeadersConfig, + /// Plugin chain; defaults to an empty chain. + #[serde(default)] + #[schema(value_type = Object)] + pub plugins: PluginConfig, + /// Rate-limit budget. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object, nullable = true)] + pub rate_limit: Option, + /// CORS configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object, nullable = true)] + pub cors: Option, +} + +impl UpstreamRequest { + /// View of the request as the validator input. + #[must_use] + pub fn as_input(&self) -> UpstreamInput { + UpstreamInput { + alias: self.alias.clone(), + enabled: self.enabled, + tags: self.tags.clone(), + server: self.server.clone(), + protocol: self.protocol, + auth: self.auth.clone(), + headers: self.headers.clone(), + plugins: self.plugins.clone(), + rate_limit: self.rate_limit.clone(), + cors: self.cors.clone(), + } + } +} + +/// Wire representation of an upstream. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +#[serde(deny_unknown_fields)] +pub struct UpstreamDto { + /// GTS instance id, e.g. `gts.cf.core.oagw.upstream.v1~{uuid}`. + pub id: Uuid, + /// Routing alias, unique per tenant. + pub alias: String, + /// Disabled upstreams reject every request. + pub enabled: bool, + /// Discovery tags. + pub tags: Vec, + /// Endpoint pool (`server.endpoints`). + #[schema(value_type = Object)] + pub server: ServerConfig, + /// Upstream protocol. + #[schema(value_type = String)] + pub protocol: Protocol, + /// Auth plugin binding. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object, nullable = true)] + pub auth: Option, + /// Header transformation rules. + #[schema(value_type = Object)] + pub headers: HeadersConfig, + /// Plugin chain. + #[schema(value_type = Object)] + pub plugins: PluginConfig, + /// Rate-limit budget. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object, nullable = true)] + pub rate_limit: Option, + /// CORS configuration. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object, nullable = true)] + pub cors: Option, + /// Creation instant, RFC 3339 UTC. + pub created_at: String, + /// Last modification instant, RFC 3339 UTC. + pub updated_at: String, +} + +impl From<&Upstream> for UpstreamDto { + fn from(upstream: &Upstream) -> Self { + Self { + id: upstream.id, + alias: upstream.alias.clone(), + enabled: upstream.enabled, + tags: upstream.tags.clone(), + server: upstream.server.clone(), + protocol: upstream.protocol, + auth: upstream.auth.clone(), + headers: upstream.headers.clone(), + plugins: upstream.plugins.clone(), + rate_limit: upstream.rate_limit.clone(), + cors: upstream.cors.clone(), + created_at: format_rfc3339(upstream.created_at), + updated_at: format_rfc3339(upstream.updated_at), + } + } +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// Body of `POST /oagw/v1/routes`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +#[serde(deny_unknown_fields)] +pub struct CreateRouteRequest { + /// Owning upstream; must belong to the calling tenant. + pub upstream_id: Uuid, + /// Match rules; exactly one of `http` / `grpc`. + #[serde(rename = "match")] + #[schema(value_type = Object)] + pub r#match: RouteMatch, + /// Header transformation overrides; defaults to no rules. + #[serde(default)] + #[schema(value_type = Object)] + pub headers: HeadersConfig, + /// Plugin chain; defaults to an empty chain. + #[serde(default)] + #[schema(value_type = Object)] + pub plugins: PluginConfig, + /// Route-level rate-limit override. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object, nullable = true)] + pub rate_limit: Option, + /// Route-level CORS override. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object, nullable = true)] + pub cors: Option, + /// Defaults to `true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = bool, nullable = true)] + pub enabled: Option, + /// Match priority; defaults to `0`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = i32, nullable = true)] + pub priority: Option, + /// Discovery tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +impl CreateRouteRequest { + /// View of the request as the validator input. + #[must_use] + pub fn as_input(&self) -> RouteInput { + RouteInput { + r#match: self.r#match.clone(), + headers: self.headers.clone(), + plugins: self.plugins.clone(), + rate_limit: self.rate_limit.clone(), + cors: self.cors.clone(), + enabled: self.enabled, + priority: self.priority, + tags: self.tags.clone(), + } + } +} + +/// Body of `PUT /oagw/v1/routes/{id}`. +/// +/// `upstream_id` is deliberately absent: it is immutable and the service keeps +/// the stored value (DESIGN section 3.3 "PUT (Replace)"). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +#[serde(deny_unknown_fields)] +pub struct ReplaceRouteRequest { + /// Match rules; exactly one of `http` / `grpc`. + #[serde(rename = "match")] + #[schema(value_type = Object)] + pub r#match: RouteMatch, + /// Header transformation overrides; defaults to no rules. + #[serde(default)] + #[schema(value_type = Object)] + pub headers: HeadersConfig, + /// Plugin chain; defaults to an empty chain. + #[serde(default)] + #[schema(value_type = Object)] + pub plugins: PluginConfig, + /// Route-level rate-limit override. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object, nullable = true)] + pub rate_limit: Option, + /// Route-level CORS override. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object, nullable = true)] + pub cors: Option, + /// Defaults to `true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = bool, nullable = true)] + pub enabled: Option, + /// Match priority; defaults to `0`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = i32, nullable = true)] + pub priority: Option, + /// Discovery tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +impl ReplaceRouteRequest { + /// View of the request as the validator input. + #[must_use] + pub fn as_input(&self) -> RouteInput { + RouteInput { + r#match: self.r#match.clone(), + headers: self.headers.clone(), + plugins: self.plugins.clone(), + rate_limit: self.rate_limit.clone(), + cors: self.cors.clone(), + enabled: self.enabled, + priority: self.priority, + tags: self.tags.clone(), + } + } +} + +/// Wire representation of a route. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +#[serde(deny_unknown_fields)] +pub struct RouteDto { + /// GTS instance id, e.g. `gts.cf.core.oagw.route.v1~{uuid}`. + pub id: Uuid, + /// Owning upstream instance id. + pub upstream_id: Uuid, + /// Match rules. + #[serde(rename = "match")] + #[schema(value_type = Object)] + pub r#match: RouteMatch, + /// Header transformation overrides. + #[schema(value_type = Object)] + pub headers: HeadersConfig, + /// Plugin chain. + #[schema(value_type = Object)] + pub plugins: PluginConfig, + /// Route-level rate-limit override. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object, nullable = true)] + pub rate_limit: Option, + /// Route-level CORS override. + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object, nullable = true)] + pub cors: Option, + /// Disabled routes never match. + pub enabled: bool, + /// Match priority; higher wins. + pub priority: i32, + /// Discovery tags. + pub tags: Vec, + /// Creation instant, RFC 3339 UTC. + pub created_at: String, + /// Last modification instant, RFC 3339 UTC. + pub updated_at: String, +} + +impl From<&Route> for RouteDto { + fn from(route: &Route) -> Self { + Self { + id: route.id, + upstream_id: route.upstream_id, + r#match: route.r#match.clone(), + headers: route.headers.clone(), + plugins: route.plugins.clone(), + rate_limit: route.rate_limit.clone(), + cors: route.cors.clone(), + enabled: route.enabled, + priority: route.priority, + tags: route.tags.clone(), + created_at: format_rfc3339(route.created_at), + updated_at: format_rfc3339(route.updated_at), + } + } +} + +// --------------------------------------------------------------------------- +// Plugins +// --------------------------------------------------------------------------- + +/// Body of `POST /oagw/v1/plugins`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(request)] +#[serde(deny_unknown_fields)] +pub struct CreatePluginRequest { + /// Plugin base type GTS id, e.g. `gts.cf.core.oagw.guard_plugin.v1`. + pub plugin_type: String, + /// Plugin configuration payload; defaults to `{}`. + #[serde(default = "empty_json_object")] + pub config: serde_json::Value, + /// Defaults to `true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + #[schema(value_type = bool, nullable = true)] + pub enabled: Option, + /// Discovery tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, +} + +impl CreatePluginRequest { + /// View of the request as the validator input. + #[must_use] + pub fn as_input(&self) -> PluginInput { + PluginInput { + plugin_type: self.plugin_type.clone(), + config: self.config.clone(), + enabled: self.enabled, + tags: self.tags.clone(), + } + } +} + +/// Wire representation of a plugin. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +#[serde(deny_unknown_fields)] +pub struct PluginDto { + /// GTS instance id, e.g. `gts.cf.core.oagw.plugin.v1~{uuid}`. + pub id: Uuid, + /// Plugin base type GTS id. + pub plugin_type: String, + /// Plugin configuration payload. + pub config: serde_json::Value, + /// Disabled plugins are skipped by the chain builder instead of failing the + /// upstream with `503 PluginNotFound`. + pub enabled: bool, + /// Discovery tags. + pub tags: Vec, + /// Creation instant, RFC 3339 UTC. + pub created_at: String, + /// Last modification instant, RFC 3339 UTC. + pub updated_at: String, +} + +impl From<&Plugin> for PluginDto { + fn from(plugin: &Plugin) -> Self { + Self { + id: plugin.id, + plugin_type: plugin.plugin_type.clone(), + config: plugin.config.clone(), + enabled: plugin.enabled, + tags: plugin.tags.clone(), + created_at: format_rfc3339(plugin.created_at), + updated_at: format_rfc3339(plugin.updated_at), + } + } +} + +/// Body of `GET /oagw/v1/plugins/{id}/source`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +#[serde(deny_unknown_fields)] +pub struct PluginSourceDto { + /// Plugin GTS instance id. + pub plugin_id: Uuid, + /// Plugin base type GTS id. + pub plugin_type: String, + /// Deterministic Starlark rendering of the plugin definition. + pub source: String, +} + +// --------------------------------------------------------------------------- +// List envelopes +// --------------------------------------------------------------------------- + +/// OpenAPI projection of the platform page envelope for upstreams. +/// +/// The runtime returns `toolkit::Page`; this type declares the +/// same `{ items, page_info }` shape for the OpenAPI document because +/// `toolkit-odata` only implements `ToSchema` for `Page` behind its +/// `with-utoipa` feature, which this crate does not enable. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct UpstreamPageDto { + /// One page of upstreams, after `$filter`, `$orderby`, `$skip` and `$top`. + pub items: Vec, + /// Envelope metadata. + pub page_info: PageMetaDto, +} + +/// OpenAPI projection of the platform page envelope for routes. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct RoutePageDto { + /// One page of routes. + pub items: Vec, + /// Envelope metadata. + pub page_info: PageMetaDto, +} + +/// OpenAPI projection of the platform page envelope for plugins. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct PluginPageDto { + /// One page of plugins. + pub items: Vec, + /// Envelope metadata. + pub page_info: PageMetaDto, +} + +/// Metadata of the platform page envelope. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response)] +pub struct PageMetaDto { + /// Cursor of the next page; always `null`, OAGW pages with offsets. + pub next_cursor: Option, + /// Cursor of the previous page; always `null`, OAGW pages with offsets. + pub prev_cursor: Option, + /// Effective page size (`$top` after clamping). + pub limit: u64, +} + +// --------------------------------------------------------------------------- +// Sharing modes +// --------------------------------------------------------------------------- +/// `true` when any hierarchical section of `upstream` is `enforce`, which +/// blocks a descendant from overriding it (DESIGN "Hierarchical +/// Configuration"). +/// +/// The rule lives in the domain layer next to the bind check that uses it; it +/// is re-exported here because the OpenAPI document and the +/// `X-OAGW-*` semantics of the wire DTOs depend on it. +#[doc(inline)] +pub use crate::domain::model::enforces_override; + +#[cfg(test)] +#[path = "dto_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/api/rest/dto_tests.rs b/gears/system/oagw/oagw/src/api/rest/dto_tests.rs new file mode 100644 index 0000000..2150754 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/dto_tests.rs @@ -0,0 +1,222 @@ +//! Tests for [`crate::api::rest::dto`]. + +use serde_json::json; +use uuid::Uuid; + +use super::{CreatePluginRequest, CreateRouteRequest, ReplaceRouteRequest, UpstreamRequest}; +use crate::domain::model::{ + CorsConfig, Endpoint, GrpcMatch, HttpMatch, HttpMethod, PathSuffixMode, Protocol, RouteMatch, + Scheme, ServerConfig, SharingMode, Upstream, +}; + +fn tenant() -> Uuid { + Uuid::from_u128(0x42) +} + +// -- request DTOs keep server fields out ---------------------------------------- + +#[test] +fn upstream_requests_reject_server_generated_fields() { + let body = json!({ + "id": "gts.cf.core.oagw.upstream.v1~00000000-0000-0000-0000-000000000001", + "tenant_id": tenant(), + "created_at": "2026-02-03T11:09:37.431Z", + "server": {"endpoints": []} + }); + let error = serde_json::from_value::(body).expect_err("rejected"); + assert!(error.to_string().contains("unknown field"), "{error}"); +} + +#[test] +fn route_replace_requests_reject_upstream_id() { + let body = json!({ + "upstream_id": Uuid::from_u128(0x1), + "match": {"http": {"methods": ["GET"], "path": "/v1"}} + }); + let error = serde_json::from_value::(body).expect_err("rejected"); + assert!(error.to_string().contains("upstream_id"), "{error}"); +} + +#[test] +fn route_create_requests_accept_upstream_id() { + let body = json!({ + "upstream_id": Uuid::from_u128(0x1), + "match": {"http": {"methods": ["GET"], "path": "/v1"}}, + "headers": {}, + "plugins": {} + }); + let request = serde_json::from_value::(body).expect("accepted"); + assert_eq!(request.upstream_id, Uuid::from_u128(0x1)); + assert_eq!( + request + .as_input() + .r#match + .http + .as_ref() + .map(|http| http.path.as_str()), + Some("/v1") + ); +} + +#[test] +fn plugin_requests_default_to_an_empty_object_config() { + let request: CreatePluginRequest = serde_json::from_value(json!({ + "plugin_type": "gts.cf.core.oagw.guard_plugin.v1" + })) + .expect("accepted"); + assert_eq!(request.config, json!({})); + assert!(request.as_input().config.is_object()); +} + +// -- conversions ---------------------------------------------------------------- + +#[test] +fn upstream_requests_convert_into_validator_inputs() { + let request = UpstreamRequest { + alias: Some("payments".to_owned()), + enabled: Some(false), + tags: vec!["team-a".to_owned()], + server: ServerConfig { + endpoints: vec![Endpoint { + scheme: Scheme::Https, + host: "10.0.0.1".to_owned(), + port: 8443, + }], + }, + protocol: Protocol::Grpc, + auth: None, + headers: crate::domain::model::HeadersConfig::default(), + plugins: crate::domain::model::PluginConfig::default(), + rate_limit: None, + cors: None, + }; + let input = request.as_input(); + assert_eq!(input.alias.as_deref(), Some("payments")); + assert!(!input.enabled.expect("explicit")); + assert_eq!(input.server.endpoints.len(), 1); + assert_eq!(input.protocol, Protocol::Grpc); +} + +#[test] +fn route_requests_keep_their_match_block() { + let r#match = RouteMatch { + http: None, + grpc: Some(GrpcMatch { + service: "svc.v1.Svc".to_owned(), + method: "Get".to_owned(), + }), + }; + let create = CreateRouteRequest { + upstream_id: Uuid::from_u128(0x7), + r#match: r#match.clone(), + headers: crate::domain::model::HeadersConfig::default(), + plugins: crate::domain::model::PluginConfig::default(), + rate_limit: None, + cors: None, + enabled: None, + priority: Some(5), + tags: Vec::new(), + }; + let replace = ReplaceRouteRequest { + r#match: r#match.clone(), + headers: crate::domain::model::HeadersConfig::default(), + plugins: crate::domain::model::PluginConfig::default(), + rate_limit: None, + cors: None, + enabled: None, + priority: Some(5), + tags: Vec::new(), + }; + assert_eq!(create.as_input().r#match, r#match); + assert_eq!(replace.as_input().r#match, r#match); +} + +// -- response DTOs -------------------------------------------------------------- + +#[test] +fn upstream_dtos_render_timestamps_and_hide_nothing() { + let upstream = Upstream { + id: Uuid::from_u128(0x3), + alias: "api.openai.com".to_owned(), + cors: Some(CorsConfig { + enabled: true, + sharing: SharingMode::Private, + allowed_origins: vec!["https://app.example.com".to_owned()], + allowed_methods: Vec::new(), + allow_headers: Vec::new(), + expose_headers: Vec::new(), + allow_credentials: false, + max_age: None, + }), + ..Upstream::default() + }; + let rendered = serde_json::to_value(super::UpstreamDto::from(&upstream)).expect("serialises"); + assert_eq!(rendered["id"], json!(Uuid::from_u128(0x3).to_string())); + assert_eq!(rendered["alias"], json!("api.openai.com")); + assert!(rendered["created_at"].is_string()); + assert!(rendered["cors"]["allowed_origins"].is_array()); + assert!( + rendered["tenant_id"].is_null(), + "tenant_id stays off the wire" + ); +} + +#[test] +fn route_dtos_render_the_match_block_under_match() { + let route = crate::domain::model::Route { + r#match: RouteMatch { + http: Some(HttpMatch { + methods: vec![HttpMethod::Get, HttpMethod::Post], + path: "/v1/pay".to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + ..crate::domain::model::Route::default() + }; + let rendered = serde_json::to_value(super::RouteDto::from(&route)).expect("serialises"); + assert_eq!(rendered["match"]["http"]["path"], json!("/v1/pay")); + assert_eq!(rendered["match"]["http"]["methods"], json!(["GET", "POST"])); +} + +#[test] +fn plugin_dtos_render_their_type_and_config() { + let plugin = crate::domain::model::Plugin { + plugin_type: "gts.cf.core.oagw.guard_plugin.v1".to_owned(), + config: json!({"headers": ["x-request-id"]}), + ..crate::domain::model::Plugin::default() + }; + let rendered = serde_json::to_value(super::PluginDto::from(&plugin)).expect("serialises"); + assert_eq!( + rendered["plugin_type"], + json!("gts.cf.core.oagw.guard_plugin.v1") + ); + assert_eq!(rendered["config"]["headers"], json!(["x-request-id"])); +} + +// -- sharing modes --------------------------------------------------------------- + +#[test] +fn enforced_sections_block_a_descendant_override() { + let mut upstream = Upstream::default(); + assert!(!super::enforces_override(&upstream), "nothing configured"); + + upstream.rate_limit = Some(crate::domain::model::RateLimitConfig { + sharing: SharingMode::Enforce, + algorithm: crate::domain::model::RateLimitAlgorithm::TokenBucket, + sustained: crate::domain::model::SustainedRateConfig { + rate: 1, + window: crate::domain::model::RateLimitWindow::Second, + }, + burst: None, + scope: crate::domain::model::RateLimitScope::Tenant, + strategy: crate::domain::model::RateLimitStrategy::Reject, + response_headers: true, + cost: 1, + }); + assert!(super::enforces_override(&upstream)); + + upstream.rate_limit.as_mut().expect("configured").sharing = SharingMode::Inherit; + assert!(!super::enforces_override(&upstream)); +} diff --git a/gears/system/oagw/oagw/src/api/rest/error_layer.rs b/gears/system/oagw/oagw/src/api/rest/error_layer.rs new file mode 100644 index 0000000..b9bfacb --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/error_layer.rs @@ -0,0 +1,447 @@ +//! Cross-cutting error-source stamping and problem-body correlation (ADR-0007). +//! +//! Every response the gear emits must carry `X-OAGW-Error-Source`, so that +//! clients can tell a gateway-side failure (`gateway`) from a failure the +//! upstream itself returned (`upstream`, stamped by the proxy engine in +//! slice 4). The middleware is additive: it only fills the header in when the +//! inner service has not already set it. +//! +//! The same layer fills the two correlation fields the ADR-0007 problem +//! examples carry but the taxonomy itself cannot know: `trace_id` (from the +//! request) and `instance` (the request path, when the handler left it unset). +//! It is the last thing that touches a problem response before it leaves the +//! gear, and it is deliberately fail-safe: any parsing surprise returns the +//! inner response byte-for-byte. +//! +//! ## Upstream problem documents are passthrough +//! +//! An upstream is free to answer `application/problem+json` itself, and the +//! proxy stamps that answer `X-OAGW-Error-Source: upstream` before it reaches +//! this layer. Such a body is passthrough (ADR-0007): the gateway does not own +//! it, so injecting its own `trace_id`/`instance` would misattribute the +//! failure and rewriting it would cost a full buffer of an arbitrarily large +//! upstream payload. The correlation step therefore runs for gateway-produced +//! problem documents only. + +use axum::body::Body; +use axum::extract::Request; +use axum::http::{HeaderMap, HeaderName, HeaderValue, header}; +use axum::middleware::Next; +use axum::response::Response; + +use crate::domain::error::{ + APPLICATION_PROBLEM_JSON, ERROR_SOURCE_GATEWAY, ERROR_SOURCE_HEADER, ERROR_SOURCE_UPSTREAM, + ProblemBody, +}; + +/// Largest problem body the layer rewrites. +/// +/// Problem documents are gear-generated and therefore tiny; the only field a +/// caller can inflate is a validation `detail`, whose inputs are bounded by the +/// management body cap (1 MiB). Two MiB therefore never truncates a real body, +/// and a body that still will not fit is handed through untouched rather than +/// parsed. +const MAX_PROBLEM_BODY_BYTES: usize = 2 * 1024 * 1024; + +/// Request facts the correlation step needs after the handler has run. +#[derive(Debug, Clone)] +struct RequestTrace { + /// `traceparent` / `x-request-id` / `x-trace-id` correlation id, `None` when + /// the request carries none (the field stays absent rather than invented). + trace_id: Option, + /// Path of the request, for a problem `instance` the handler left unset. + path: String, +} + +impl RequestTrace { + fn from_parts(headers: &HeaderMap, path: &str) -> Self { + Self { + trace_id: trace_id_of(headers), + path: path.to_owned(), + } + } +} + +/// Correlation id of a request: `traceparent` (W3C trace-id segment), then +/// `x-request-id`, then `x-trace-id` — first present wins, none is generated. +fn trace_id_of(headers: &HeaderMap) -> Option { + if let Some(traceparent) = headers + .get("traceparent") + .and_then(|value| value.to_str().ok()) + .and_then(w3c_trace_id) + { + return Some(traceparent); + } + for name in ["x-request-id", "x-trace-id"] { + if let Some(value) = headers.get(name).and_then(|value| value.to_str().ok()) { + return Some(value.to_owned()); + } + } + None +} + +/// Extracts the 32-hex trace-id segment of a W3C `traceparent` header. +/// +/// `00---`: a malformed header falls through to the +/// `x-request-id` / `x-trace-id` fallbacks instead of poisoning the field. +fn w3c_trace_id(traceparent: &str) -> Option { + let parts: Vec<&str> = traceparent.split('-').collect(); + if parts.len() >= 4 && parts[0] == "00" { + Some(parts[1].to_owned()) + } else { + None + } +} + +/// Stamps `X-OAGW-Error-Source: gateway` on any response that does not +/// already carry the header, and correlates problem bodies with the request. +pub async fn error_source_layer(request: Request, next: Next) -> Response { + let trace = RequestTrace::from_parts(request.headers(), request.uri().path()); + let mut response = next.run(request).await; + { + let headers = response.headers_mut(); + if !headers.contains_key(ERROR_SOURCE_HEADER) { + headers.insert( + HeaderName::from_static(ERROR_SOURCE_HEADER), + HeaderValue::from_static(ERROR_SOURCE_GATEWAY), + ); + } + } + if is_problem_response(&response) && !is_upstream_response(&response) { + response = correlate(response, &trace).await; + } + response +} + +/// `true` when the response body is the upstream's own, which the data plane +/// stamped before this layer ran. +/// +/// The check reads the stamp the handler already set, so it stays correct even +/// if a future change moves the stamping into the engine after the layer: a +/// response without the header (or stamped `gateway`) is the gateway's own and +/// keeps being correlated. +fn is_upstream_response(response: &Response) -> bool { + response + .headers() + .get(ERROR_SOURCE_HEADER) + .and_then(|value| value.to_str().ok()) + .is_some_and(|source| source == ERROR_SOURCE_UPSTREAM) +} + +/// `true` when `response` is an RFC 9457 problem document. +fn is_problem_response(response: &Response) -> bool { + response + .headers() + .get(header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|media_type| media_type.starts_with(APPLICATION_PROBLEM_JSON)) +} + +/// Rewrites the problem body of `response` with the request's `trace_id` and, +/// when absent, its `instance` as the `instance`. +/// +/// Fail-safe by construction: a body that cannot be buffered, parsed or +/// re-serialised is returned unchanged. +async fn correlate(response: Response, trace: &RequestTrace) -> Response { + let (parts, body) = response.into_parts(); + let bytes = match axum::body::to_bytes(body, MAX_PROBLEM_BODY_BYTES).await { + Ok(bytes) => bytes, + // Unreachable for a gear-generated problem document (see + // [`MAX_PROBLEM_BODY_BYTES`]): the original body is gone, so the + // response is completed with the headers it already carried. + Err(error) => { + tracing::warn!(error = %error, "problem body could not be buffered"); + return Response::from_parts(parts, Body::empty()); + } + }; + let mut problem: ProblemBody = match serde_json::from_slice(&bytes) { + Ok(problem) => problem, + Err(error) => { + tracing::warn!(error = %error, "problem body is not a known problem document"); + return Response::from_parts(parts, Body::from(bytes)); + } + }; + if problem.trace_id.is_none() { + problem.trace_id = trace.trace_id.clone(); + } + if problem.instance.is_none() { + problem.instance = Some(trace.path.clone()); + } + problem.apply_context_extensions(); + let rewritten_body = match serde_json::to_vec(&problem) { + Ok(rewritten) => rewritten, + Err(error) => { + tracing::warn!(error = %error, "problem body could not be re-serialised"); + return Response::from_parts(parts, Body::from(bytes)); + } + }; + let mut rewritten = Response::from_parts(parts, Body::from(rewritten_body.clone())); + rewritten.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from(rewritten_body.len()), + ); + rewritten +} + +#[cfg(test)] +mod tests { + #![allow( + clippy::expect_used, + clippy::unwrap_used, + reason = "test-only assertions" + )] + + use super::error_source_layer; + use crate::domain::error::{ + APPLICATION_PROBLEM_JSON, ERROR_SOURCE_GATEWAY, ERROR_SOURCE_HEADER, ERROR_SOURCE_UPSTREAM, + OagwError, + }; + use axum::Router; + use axum::body::Body; + use axum::http::{HeaderValue, Request as HttpRequest, StatusCode, header}; + use axum::middleware::from_fn; + use axum::response::{IntoResponse, Response}; + use axum::routing::get; + use tower::ServiceExt; + + /// A problem document the upstream answered itself: a complete one, so it + /// *would* parse, but with no correlation fields — the gateway must not + /// invent any. + const UPSTREAM_PROBLEM_BODY: &str = concat!( + r#"{"type":"https://errors.example.com/outage","title":"Upstream Outage","#, + r#""status":503,"detail":"the upstream is over capacity"}"# + ); + + async fn ok_handler() -> &'static str { + std::future::ready("ok").await + } + + async fn already_stamped() -> Response { + let mut response = Response::new(Body::empty()); + response.headers_mut().insert( + ERROR_SOURCE_HEADER, + HeaderValue::from_static(ERROR_SOURCE_UPSTREAM), + ); + std::future::ready(response).await + } + + async fn failing() -> OagwError { + std::future::ready(OagwError::not_found("no such upstream")).await + } + + async fn alias_failure() -> OagwError { + std::future::ready( + OagwError::unknown_target_host("host is not part of the upstream pool") + .with_alias("payments") + .with_upstream_id(uuid::Uuid::from_u128(0xA1)), + ) + .await + } + + async fn plain_failure() -> Response { + (StatusCode::BAD_REQUEST, "not json").into_response() + } + + /// The problem document an upstream answered itself, stamped `upstream` by + /// the data plane before this layer ran. + async fn upstream_problem() -> Response { + let response = ( + StatusCode::SERVICE_UNAVAILABLE, + [( + header::CONTENT_TYPE, + HeaderValue::from_static(APPLICATION_PROBLEM_JSON), + )], + UPSTREAM_PROBLEM_BODY, + ) + .into_response(); + let mut stamped = response; + stamped.headers_mut().insert( + ERROR_SOURCE_HEADER, + HeaderValue::from_static(ERROR_SOURCE_UPSTREAM), + ); + std::future::ready(stamped).await + } + fn source_of(response: &Response) -> Option { + response + .headers() + .get(ERROR_SOURCE_HEADER) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned) + } + + fn into_owned(response: axum::http::Response) -> Response { + response.into_response() + } + + async fn json_of(response: Response) -> serde_json::Value { + let bytes = http_body_util::BodyExt::collect(response.into_body()) + .await + .expect("body") + .to_bytes(); + serde_json::from_slice(&bytes).expect("problem json") + } + + async fn send(app: Router, path: &str, headers: &[(&str, &str)]) -> Response { + let mut builder = HttpRequest::builder().method("GET").uri(path); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + let request = builder.body(Body::empty()).expect("request"); + into_owned(app.oneshot(request).await.expect("response")) + } + + fn app() -> Router { + Router::new() + .route("/plain", get(ok_handler)) + .route("/stamped", get(already_stamped)) + .route("/error", get(failing)) + .route("/alias", get(alias_failure)) + .route("/plain-error", get(plain_failure)) + .route("/upstream-problem", get(upstream_problem)) + .layer(from_fn(error_source_layer)) + } + + #[tokio::test] + async fn stamps_gateway_on_untagged_and_error_responses() { + let app = app(); + + let plain = send(app.clone(), "/plain", &[]).await; + assert_eq!(plain.status(), StatusCode::OK); + assert_eq!(source_of(&plain).as_deref(), Some(ERROR_SOURCE_GATEWAY)); + + let stamped = send(app.clone(), "/stamped", &[]).await; + assert_eq!(source_of(&stamped).as_deref(), Some(ERROR_SOURCE_UPSTREAM)); + + let failed = send(app, "/error", &[]).await; + assert_eq!(failed.status(), StatusCode::NOT_FOUND); + assert_eq!(source_of(&failed).as_deref(), Some(ERROR_SOURCE_GATEWAY)); + } + + #[tokio::test] + async fn a_problem_body_carries_the_trace_id_of_the_request() { + let from_traceparent = send( + app(), + "/error", + &[( + "traceparent", + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + )], + ) + .await; + let body = json_of(from_traceparent).await; + assert_eq!( + body["trace_id"], "4bf92f3577b34da6a3ce929d0e0e4736", + "{body}" + ); + assert_eq!( + body["instance"], "/error", + "instance falls back to the path" + ); + + let from_request_id = send(app(), "/error", &[("x-request-id", "req-9")]).await; + assert_eq!(json_of(from_request_id).await["trace_id"], "req-9"); + + let from_trace_header = send(app(), "/error", &[("x-trace-id", "trace-9")]).await; + assert_eq!(json_of(from_trace_header).await["trace_id"], "trace-9"); + + let unmatched = send(app(), "/error", &[]).await; + assert!( + json_of(unmatched).await.get("trace_id").is_none(), + "no id is invented" + ); + } + + #[tokio::test] + async fn traceparent_wins_over_the_other_headers() { + let response = send( + app(), + "/error", + &[ + ( + "traceparent", + "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01", + ), + ("x-request-id", "req-1"), + ], + ) + .await; + assert_eq!( + json_of(response).await["trace_id"], + "4bf92f3577b34da6a3ce929d0e0e4736" + ); + } + + #[tokio::test] + async fn a_malformed_traceparent_falls_through_to_the_request_id() { + let response = send( + app(), + "/error", + &[ + ("traceparent", "not-a-traceparent"), + ("x-request-id", "req-2"), + ], + ) + .await; + assert_eq!(json_of(response).await["trace_id"], "req-2"); + } + + #[tokio::test] + async fn an_existing_instance_is_kept() { + let response = send(app(), "/alias", &[]).await; + let body = json_of(response).await; + assert_eq!(body["instance"], "/alias", "the handler set no instance"); + } + + #[tokio::test] + async fn context_extension_fields_are_mirrored_at_the_top_level() { + let response = send(app(), "/alias", &[("x-request-id", "req-3")]).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some(APPLICATION_PROBLEM_JSON) + ); + let body = json_of(response).await; + assert_eq!(body["alias"], "payments", "{body}"); + assert_eq!( + body["upstream_id"], + uuid::Uuid::from_u128(0xA1).to_string(), + "{body}" + ); + assert_eq!(body["context"]["alias"], "payments", "{body}"); + assert_eq!(body["trace_id"], "req-3", "{body}"); + } + + #[tokio::test] + async fn a_non_problem_failure_passes_through_untouched() { + let response = send(app(), "/plain-error", &[("x-request-id", "req-4")]).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let bytes = http_body_util::BodyExt::collect(response.into_body()) + .await + .expect("body") + .to_bytes(); + assert_eq!(&bytes[..], b"not json"); + } + + #[tokio::test] + async fn an_upstream_problem_document_passes_through_byte_for_byte() { + let response = send(app(), "/upstream-problem", &[("x-request-id", "req-5")]).await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + source_of(&response).as_deref(), + Some(ERROR_SOURCE_UPSTREAM), + "the upstream keeps its own stamp" + ); + let bytes = http_body_util::BodyExt::collect(response.into_body()) + .await + .expect("body") + .to_bytes(); + assert_eq!( + &bytes[..], + UPSTREAM_PROBLEM_BODY.as_bytes(), + "the gateway rewrites nothing: not its document, not its correlation ids" + ); + } +} diff --git a/gears/system/oagw/oagw/src/api/rest/extractors.rs b/gears/system/oagw/oagw/src/api/rest/extractors.rs new file mode 100644 index 0000000..31fff56 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/extractors.rs @@ -0,0 +1,161 @@ +//! Extractors shared by the OAGW handlers. +//! +//! [`JsonBody`] replaces axum's `Json`: axum maps *deserialisation* failures to +//! `422`, while DESIGN section 3.3 requires `400` for a malformed body and for +//! an unknown field, so every failure is re-rendered as an +//! [`OagwError::Validation`] problem document. +//! +//! [`UpstreamList`], [`RouteList`] and [`PluginList`] parse the OData query +//! parameters of the matching list endpoint (see [`crate::domain::odata`]); an +//! unknown field or a malformed expression is a `400` before the handler runs. + +use axum::body::HttpBody; +use axum::extract::FromRequest; +use axum::extract::FromRequestParts; +use axum::http::Request; +use axum::http::header::CONTENT_LENGTH; +use axum::http::request::Parts; +use serde::de::DeserializeOwned; + +use crate::domain::error::OagwError; +use crate::domain::odata::{FieldCatalog, ListQuery, PLUGIN_FIELDS, ROUTE_FIELDS, UPSTREAM_FIELDS}; + +/// Largest accepted management-API request body (1 MiB). +const MAX_BODY_BYTES: usize = 1024 * 1024; + +/// Strict JSON body: `400` for invalid syntax, unknown fields and wrong types. +#[derive(Debug, Clone)] +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 bytes = read_body(request).await?; + let value: serde_json::Value = serde_json::from_slice(&bytes) + .map_err(|error| OagwError::validation(format!("malformed JSON body: {error}")))?; + let parsed = T::deserialize(value) + .map_err(|error| OagwError::validation(format!("invalid request body: {error}")))?; + Ok(Self(parsed)) + } +} + +/// Reads a request body, rejecting an oversized one with `413`. +/// +/// The body is collected frame by frame instead of through +/// `axum::body::to_bytes`: a `chunked` body carries no `content-length`, so the +/// declared-length check below never fires for it, and axum's own limit error +/// is an opaque `axum_core::Error` that cannot be told apart from a transport +/// failure without the `http-body-util` type it wraps. Counting the bytes here +/// keeps both spellings of an oversized body on the same `413` path — DESIGN +/// section 3.3 — and leaves a genuine read failure as a `400`. +/// +/// # Errors +/// +/// Returns [`OagwError::PayloadTooLarge`] when the body (declared or streamed) +/// exceeds the cap and [`OagwError::Validation`] when the body cannot be read. +async fn read_body(request: Request) -> Result { + let declared = request + .headers() + .get(CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + if let Some(length) = declared.filter(|length| *length > MAX_BODY_BYTES) { + return Err(OagwError::payload_too_large(format!( + "request body of {length} bytes exceeds the {MAX_BODY_BYTES} byte limit" + ))); + } + + let mut body = request.into_body(); + let mut buffer = bytes::BytesMut::new(); + loop { + let frame = + match futures_util::future::poll_fn(|cx| std::pin::Pin::new(&mut body).poll_frame(cx)) + .await + { + Some(Ok(frame)) => frame, + Some(Err(error)) => { + return Err(OagwError::validation(format!( + "request body could not be read: {error}" + ))); + } + None => break, + }; + let Ok(data) = frame.into_data() else { + continue; // a trailer, which a JSON body never carries + }; + if buffer.len() + data.len() > MAX_BODY_BYTES { + return Err(OagwError::payload_too_large(format!( + "request body exceeds the {MAX_BODY_BYTES} byte limit" + ))); + } + buffer.extend_from_slice(&data); + } + Ok(buffer.freeze()) +} + +/// Parsed OData query of a list request. +#[derive(Debug, Clone)] +pub struct UpstreamList(pub ListQuery); + +/// Parsed OData query of the route list request. +#[derive(Debug, Clone)] +pub struct RouteList(pub ListQuery); + +/// Parsed OData query of the plugin list request. +#[derive(Debug, Clone)] +pub struct PluginList(pub ListQuery); + +impl FromRequestParts for UpstreamList +where + S: Send + Sync, +{ + type Rejection = OagwError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + parse_list(parts, &UPSTREAM_FIELDS).map(Self) + } +} + +impl FromRequestParts for RouteList +where + S: Send + Sync, +{ + type Rejection = OagwError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + parse_list(parts, &ROUTE_FIELDS).map(Self) + } +} + +impl FromRequestParts for PluginList +where + S: Send + Sync, +{ + type Rejection = OagwError; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + parse_list(parts, &PLUGIN_FIELDS).map(Self) + } +} + +/// Parses the query string of `parts` against `catalog`. +/// +/// # Errors +/// +/// Returns the [`OagwError::Validation`] reported by +/// [`ListQuery::parse`]. +fn parse_list(parts: &Parts, catalog: &'static FieldCatalog) -> Result { + ListQuery::parse(parts.uri.query(), catalog) +} + +#[cfg(test)] +#[path = "extractors_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/api/rest/extractors_tests.rs b/gears/system/oagw/oagw/src/api/rest/extractors_tests.rs new file mode 100644 index 0000000..8d30f91 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/extractors_tests.rs @@ -0,0 +1,168 @@ +//! Tests for [`crate::api::rest::extractors`]. + +use axum::body::Body; +use axum::extract::FromRequest; +use axum::http::Request; +use serde_json::json; + +use super::{JsonBody, ListQuery, MAX_BODY_BYTES}; +use crate::domain::error::OagwError; +use crate::domain::model::{Endpoint, Scheme, ServerConfig}; +use crate::domain::odata::UPSTREAM_FIELDS; + +async fn body(raw: &str) -> Result +where + T: serde::de::DeserializeOwned, +{ + let request = Request::builder() + .method("POST") + .uri("/oagw/v1/upstreams") + .header("content-type", "application/json") + .body(Body::from(raw.to_owned())) + .expect("request builds"); + JsonBody::::from_request(request, &()) + .await + .map(|parsed| parsed.0) +} + +async fn oversized_body() -> OagwError { + let request = Request::builder() + .method("POST") + .uri("/oagw/v1/upstreams") + .header("content-length", (MAX_BODY_BYTES + 1).to_string()) + .body(Body::from("x")) + .expect("request builds"); + JsonBody::::from_request(request, &()) + .await + .expect_err("body is too large") +} + +#[derive(Debug, serde::Deserialize, PartialEq)] +#[serde(deny_unknown_fields)] +struct Probe { + alias: String, +} + +#[tokio::test] +async fn a_strict_body_accepts_unknown_nothing() { + let probe = body::("{\"alias\": \"payments\"}") + .await + .expect("parsed"); + assert_eq!( + probe, + Probe { + alias: "payments".to_owned(), + } + ); +} + +#[tokio::test] +async fn a_malformed_body_is_a_400() { + let error = body::("{\"alias\": }").await.expect_err("malformed"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST); + assert!(error.detail().contains("malformed JSON body"), "{error}"); +} + +#[tokio::test] +async fn an_unknown_field_is_a_400() { + let error = body::("{\"alias\": \"payments\", \"tenant_id\": \"x\"}") + .await + .expect_err("unknown field"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST); + assert!(error.detail().contains("unknown field"), "{error}"); +} + +#[tokio::test] +async fn a_wrong_typed_field_is_a_400() { + let error = body::("{\"alias\": 7}") + .await + .expect_err("wrong type"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST); + assert!(error.detail().contains("invalid request body"), "{error}"); +} + +#[tokio::test] +async fn an_oversized_body_is_rejected() { + let error = oversized_body().await; + assert_eq!(error.status(), axum::http::StatusCode::PAYLOAD_TOO_LARGE); + assert!(error.detail().contains("exceeds"), "{error}"); +} + +#[tokio::test] +async fn an_oversized_chunked_body_is_rejected_too() { + // No `content-length`: the cap has to be enforced while streaming. + let request = Request::builder() + .method("POST") + .uri("/oagw/v1/upstreams") + .header("content-type", "application/json") + .header("transfer-encoding", "chunked") + .body(Body::from(vec![b'x'; MAX_BODY_BYTES + 1])) + .expect("request builds"); + let error = JsonBody::::from_request(request, &()) + .await + .expect_err("chunked body is too large"); + assert_eq!(error.status(), axum::http::StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + error.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.payload.too_large.v1" + ); + assert!(error.detail().contains("exceeds"), "{error}"); +} + +#[tokio::test] +async fn a_chunked_body_at_the_limit_is_accepted() { + // A JSON object padded to exactly the cap, so the boundary stays inclusive. + let padding = MAX_BODY_BYTES - 20; + let payload = { + let mut raw = String::from("{\"alias\":\""); + raw.push_str(&"a".repeat(padding)); + raw.push_str("\"}"); + raw + }; + let request = Request::builder() + .method("POST") + .uri("/oagw/v1/upstreams") + .header("content-type", "application/json") + .header("transfer-encoding", "chunked") + .body(Body::from(payload)) + .expect("request builds"); + let parsed = JsonBody::::from_request(request, &()) + .await + .expect("the body is exactly at the cap"); + assert_eq!(parsed.0["alias"].as_str().map(str::len), Some(padding)); +} + +#[test] +fn the_upstream_catalog_shapes_list_queries() { + let query = ListQuery::parse( + Some("$filter=alias+eq+%27api.openai.com%27&$top=5"), + &UPSTREAM_FIELDS, + ) + .expect("query parses"); + assert_eq!(query.top, 5); + let rows = vec![serde_json::json!({"alias": "api.openai.com", "id": "a"})]; + assert_eq!( + query.apply_to_rows(rows.clone())[0], + json!({"alias": "api.openai.com", "id": "a"}) + ); + + let error = ListQuery::parse(Some("$filter=tenant_id eq 'x'"), &UPSTREAM_FIELDS) + .expect_err("tenant_id is not queryable"); + assert!(error.detail().contains("tenant_id"), "{error}"); +} + +#[test] +fn endpoint_drafts_deserialise_through_the_dto_catalog() { + // Sanity check for the catalog constants: the fields they name are the + // ones the DTOs actually serialise. + assert!(UPSTREAM_FIELDS.filterable.contains(&"protocol")); + let server = ServerConfig { + endpoints: vec![Endpoint { + scheme: Scheme::Https, + host: "api.openai.com".to_owned(), + port: 443, + }], + }; + let rendered = serde_json::to_value(server).expect("serialises"); + assert!(rendered["endpoints"][0]["host"].is_string()); +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/mod.rs b/gears/system/oagw/oagw/src/api/rest/handlers/mod.rs new file mode 100644 index 0000000..3ce58b0 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/mod.rs @@ -0,0 +1,51 @@ +//! Handlers of the OAGW management REST surface. +//! +//! Every handler is deliberately thin: it extracts the caller's +//! [`SecurityContext`], the service and (for a mutation) the `x-request-id` +//! header, translates the wire DTO into the validator input and returns either +//! the canonical JSON response or an [`OagwError`], which the gear's error +//! layer renders as `application/problem+json` with the ADR-0007 +//! `X-OAGW-Error-Source: gateway` header. +//! +//! Errors are returned as `Result<_, OagwError>` rather than the toolkit's +//! canonical `CanonicalError`: the OAGW problem taxonomy carries OAGW GTS +//! error types (`gts.cf.core.errors.err.v1~cf.oagw.*`) and OAGW-specific +//! extension fields (`alias`, `plugin_id`, `referenced_by`), which the +//! canonical catalogue cannot express. +//! +//! Path segment ids are parsed by this module rather than by an extractor +//! (`Path` + [`path_uuid`]): axum's own `Path` rejection is a +//! plain-text `400`, which would bypass the problem-document contract, so a +//! malformed id is turned into a `404` problem document instead. A resource +//! that cannot be id'd is indistinguishable from one that does not exist +//! (DESIGN section 3.3), and the caller sees the same +//! `application/problem+json` / `X-OAGW-Error-Source: gateway` pair as for any +//! other failure. + +pub(crate) mod plugins; +pub(crate) mod proxy; +pub(crate) mod routes; +pub(crate) mod upstreams; + +use crate::domain::error::OagwError; + +/// Service every handler receives through `Extension>`. +pub(crate) type Service = std::sync::Arc; + +/// Parses a `{id}` path segment: the canonical bare UUID, then the GTS-form +/// instance id (`gts.cf.core.oagw..v1~{uuid}`) via `parse`. +/// +/// # Errors +/// +/// Returns [`OagwError::NotFound`] with `invalid {resource} id` as the detail +/// when neither spelling matches, so the response is a problem document. +pub(crate) fn path_uuid( + raw: &str, + resource: &str, + parse: impl Fn(&str) -> Option, +) -> Result { + let parsed = raw.parse::().ok().or_else(|| parse(raw)); + parsed.ok_or_else(|| { + OagwError::not_found(format!("invalid {resource} id")).with_invalid_value(raw) + }) +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/plugins.rs b/gears/system/oagw/oagw/src/api/rest/handlers/plugins.rs new file mode 100644 index 0000000..a98dfc9 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/plugins.rs @@ -0,0 +1,130 @@ +//! Plugin handlers of the OAGW management REST surface. + +use axum::Extension; +use axum::extract::Path; +use axum::http::HeaderMap; +use axum::http::Uri; +use axum::response::IntoResponse; + +use super::{Service, path_uuid}; +use crate::api::rest::dto::{CreatePluginRequest, PluginDto, PluginSourceDto}; +use crate::api::rest::extractors::{JsonBody, PluginList}; +use crate::domain::error::{ApiResult, OagwError}; +use crate::domain::model::parse_plugin_id; +use toolkit::api::canonical_prelude::{created_json, no_content, ok_json}; +use toolkit_security::SecurityContext; + +/// The request id of a mutation, from the `x-request-id` header. +fn request_id(headers: &HeaderMap) -> Option<&str> { + headers + .get("x-request-id") + .and_then(|value| value.to_str().ok()) +} + +/// Missing plugin problem document for `id`. +fn missing(id: uuid::Uuid) -> OagwError { + OagwError::not_found(format!( + "plugin gts.cf.core.oagw.plugin.v1~{id} does not exist" + )) + .with_plugin_id(format!("gts.cf.core.oagw.plugin.v1~{id}")) +} + +/// `POST /oagw/v1/plugins` — registers a plugin for the calling tenant. +/// +/// Plugins are immutable: there is no `PUT`, and a re-registration of the same +/// id is a `409` (DESIGN section 3.3). +/// +/// # Errors +/// +/// Returns the problem document reported by +/// [`ControlPlaneService::create_plugin`](crate::domain::services::ControlPlaneService::create_plugin). +pub async fn create_plugin( + uri: Uri, + Extension(ctx): Extension, + Extension(svc): Extension, + headers: HeaderMap, + JsonBody(request): JsonBody, +) -> ApiResult { + let input = request.as_input(); + let created = svc.create_plugin(&ctx, request_id(&headers), &input)?; + Ok(created_json( + PluginDto::from(&*created), + &uri, + &created.id.to_string(), + )) +} + +/// `GET /oagw/v1/plugins` — lists the plugins of the calling tenant. +/// +/// # Errors +/// +/// Returns the problem document reported by the OData query parser. +pub async fn list_plugins( + Extension(ctx): Extension, + Extension(svc): Extension, + PluginList(query): PluginList, +) -> ApiResult { + let items = svc + .list_plugins(&ctx) + .into_iter() + .map(|plugin| PluginDto::from(&*plugin)) + .collect(); + let page = query.apply_values(items)?; + Ok(ok_json(page)) +} + +/// `GET /oagw/v1/plugins/{id}` — reads one plugin of the calling tenant. +/// +/// # Errors +/// +/// Returns [`OagwError::NotFound`] when the calling tenant does not own the +/// plugin. +pub async fn get_plugin( + Extension(ctx): Extension, + Extension(svc): Extension, + Path(raw_id): Path, +) -> ApiResult { + let id = path_uuid(&raw_id, "plugin", parse_plugin_id)?; + let plugin = svc.get_plugin(&ctx, id).ok_or_else(|| missing(id))?; + Ok(ok_json(PluginDto::from(&*plugin))) +} + +/// `GET /oagw/v1/plugins/{id}/source` — returns the deterministic plugin +/// definition rendered for inspection and review. +/// +/// # Errors +/// +/// Returns [`OagwError::NotFound`] when the calling tenant does not own the +/// plugin. +pub async fn get_plugin_source( + Extension(ctx): Extension, + Extension(svc): Extension, + Path(raw_id): Path, +) -> ApiResult { + let id = path_uuid(&raw_id, "plugin", parse_plugin_id)?; + let (plugin, source) = svc.plugin_source(&ctx, id)?; + let rendered = PluginSourceDto { + plugin_id: plugin.id, + plugin_type: plugin.plugin_type.clone(), + source, + }; + Ok(ok_json(rendered)) +} + +/// `DELETE /oagw/v1/plugins/{id}` — deletes a plugin of the calling tenant. +/// +/// # Errors +/// +/// Returns [`OagwError::PluginInUse`] with `plugin_id` and `referenced_by` +/// when an upstream or a route still references the plugin (ADR-0001), and +/// [`OagwError::NotFound`] when the calling tenant does not own it. +pub async fn delete_plugin( + Extension(ctx): Extension, + Extension(svc): Extension, + Path(raw_id): Path, + headers: HeaderMap, +) -> ApiResult { + let id = path_uuid(&raw_id, "plugin", parse_plugin_id)?; + svc.delete_plugin(&ctx, request_id(&headers), id)?; + Ok(no_content()) +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/proxy.rs b/gears/system/oagw/oagw/src/api/rest/handlers/proxy.rs new file mode 100644 index 0000000..c1665b7 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/proxy.rs @@ -0,0 +1,1327 @@ +//! Data-plane handlers of the OAGW proxy surface. +//! +//! `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}]` runs the orchestration of +//! DESIGN section 3.5, in the documented order: +//! +//! 0. a CORS preflight (`OPTIONS` + `Origin` + +//! `Access-Control-Request-Method`) is answered locally with `204` +//! (ADR-0004): browser preflights carry no credentials, so they have no +//! tenant context, no per-request auth and no upstream round-trip. The +//! configured answer is used when the upstream and a route resolve without a +//! [`SecurityContext`]; every other preflight gets the permissive answer of +//! [`crate::domain::cors::unresolved_preflight`]; +//! 1. extract the [`SecurityContext`] the platform authenticated; +//! 2. resolve the upstream by alias over the tenant chain (descendant → +//! ancestor, closest match wins); a disabled upstream is a `503`, an +//! unknown alias a `404`; +//! 3. resolve the route: method allowlist plus longest path prefix over the +//! same chain, enabled routes only; a `grpc` upstream answers `501`; +//! 4. merge the effective configuration root → child per sharing mode +//! (upstream below route); +//! 5. CORS: an actual cross-origin request is validated and rejected with +//! `403` (its preflight was answered in step 0); +//! 6. rate limit, with the ADR-0003 response headers — before the body is +//! read, because the decision needs no payload; +//! 7. the request body: a declared `Content-Length` over the cap is a `413` +//! *before* a single byte is buffered (DESIGN "Body Validation Rules": +//! reject before buffering), then the body is read and re-validated; +//! 8. the ADR-0002 plugin chain `authenticate` → `guard_request` → +//! `transform_request`; a binding naming a disabled plugin resource is +//! skipped, an enabled-but-unresolvable one is a `503`; +//! 9. endpoint selection (ADR-0001) and the outbound call — the headers and +//! query parameters the chain injected (PRD §5.2 credential injection) ride +//! on it, overriding their client counterparts — then `guard_response` → +//! `transform_response` and the response header rules; +//! 10. the proxy log line, the metrics and the ADR-0007 +//! `X-OAGW-Error-Source` stamp (`upstream` on every upstream answer, +//! `gateway` on every error). +//! +//! ## Residual platform limitation (documented) +//! +//! ADR-0004 wants an unauthenticated preflight to be answered without any +//! authentication. The proxy operations are declared `.authenticated()` in +//! [`crate::api::rest::routes`], which the platform's gateway middleware +//! enforces before the request reaches this handler, so an unauthenticated +//! preflight is answered here only when the platform lets it through (the +//! gear's own router has no per-verb auth gate). The step-0 answer is +//! therefore complete for authenticated callers — including the permissive +//! fallback when the alias does not resolve — and the unauthenticated case is +//! covered as far as the platform allows. Platform files are out of scope for +//! this gear. +//! +//! Nothing here returns `Err` to the framework: every failure goes through +//! [`Failed`], which runs the chain's `transform_error` phase and renders an +//! RFC 9457 problem document with the same header, so a client can always tell +//! whether the gateway or the upstream produced a response. + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; + +use axum::Extension; +use axum::extract::{ConnectInfo, Path, Request}; +use axum::http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode}; +use axum::response::Response; +use bytes::Bytes; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use super::Service; +use crate::domain::audit::{ProxyExchange, ProxyOutcome, log_proxy}; +use crate::domain::cors::{ + EffectiveCorsConfig, PreflightResponse, actual_response_headers, evaluate_preflight, + is_preflight, merge_cors, unresolved_preflight, validate_actual_request, +}; +use crate::domain::error::{ + ERROR_SOURCE_GATEWAY, ERROR_SOURCE_HEADER, ERROR_SOURCE_UPSTREAM, OagwError, ProblemBody, +}; +use crate::domain::metrics::{MetricsRegistry, host_label, route_label}; +use crate::domain::model::{ + CorsConfig, Endpoint, HttpMethod, PathSuffixMode, Protocol, RateLimitConfig, + ResolvedProxyTarget, Route, Scheme, Upstream, +}; +use crate::domain::plugin::{ + BodyPayload, CorsOutcome, ErrorContext, PluginChain, RequestContext, ResponseContext, +}; +use crate::domain::rate_limit::{ + EffectiveRateLimit, QUEUE_MAX_WAIT, RateLimitReservation, RateLimitScopeValues, + RateLimiterRegistry, resolve_effective_rate_limit, +}; +use crate::infra::plugin::{DisabledPlugins, PluginRegistry}; +use crate::infra::proxy::{ + MAX_REQUEST_BODY_BYTES, ProxyBody, ProxyEngine, ProxyRequest, ProxyResponse, + TARGET_HOST_HEADER, is_websocket_upgrade, validate_declared_body_size, +}; +use crate::infra::storage::RegistryStore; + +/// GTS error type of the `501` a `grpc` upstream answers with. +const NOT_IMPLEMENTED_TYPE: &str = "gts.cf.core.errors.err.v1~cf.oagw.protocol.not_implemented.v1"; + +/// Message `http_body_util::LengthLimitError` renders, which +/// [`axum::body::to_bytes`] surfaces for a body past its limit (see +/// [`body_read_error`]). +const LENGTH_LIMIT_ERROR_MESSAGE: &str = "length limit exceeded"; + +/// Everything the data-plane handlers need, injected as one `Extension`. +#[derive(Clone)] +pub struct DataPlane { + /// Registry, validator and tenant hierarchy shared with the management + /// surface. + pub service: Service, + /// Outbound transport (ADR-0006). + pub engine: Arc, + /// Metrics the proxy records into. + pub metrics: Arc, + /// Plugin factory (ADR-0002). + pub plugins: Arc, + /// Rate-limit buckets (ADR-0003). + pub rate_limiters: Arc, +} + +/// Inbound request the extractors already disassembled. +struct Inbound { + method: Method, + alias: String, + path_suffix: String, + query: String, + headers: HeaderMap, + body: axum::body::Body, + security: Option, + /// Address of the peer socket, when the transport records it. + peer: Option, + /// Upgrade handle of the client socket, present when the caller asked for + /// a protocol switch. + upgrade: Option, +} + +/// Refunds a granted ADR-0003 `queue` reservation unless the request completes. +/// +/// The reservation debits the bucket before the body is read, so every later +/// step — the body cap, the plugin chain, the outbound call — runs on budget +/// that is already spent. Each of those steps returns early through a `?`, and +/// each of them must put the tokens back: a failed request must not consume +/// budget for nothing. Holding the reservation in a guard turns that from ten +/// separate call sites into one: the refund happens where the orchestration +/// unwinds, and only [`ReservationGuard::disarm`] — the success path — keeps it. +struct ReservationGuard { + registry: Arc, + reservation: Option, +} + +impl ReservationGuard { + /// A guard that holds nothing yet. + fn new(registry: Arc) -> Self { + Self { + registry, + reservation: None, + } + } + + /// Takes over the reservation the limiter granted, if it granted one. + fn arm(&mut self, reservation: Option) { + self.reservation = reservation; + } + + /// The request completed: the debit is its own, nothing is refunded. + fn disarm(&mut self) { + self.reservation = None; + } +} + +impl Drop for ReservationGuard { + fn drop(&mut self) { + if let Some(reservation) = self.reservation.take() { + self.registry.release(reservation, Instant::now()); + } + } +} + +/// A failed orchestration, together with the plugin chain to run the error +/// phase over (`None` when the failure happened before the chain was built). +struct Failed { + error: OagwError, + plugins: Option, + /// Extra response headers of the failure, e.g. the ADR-0003 rate-limit + /// budget of a rejected request. + headers: Vec<(HeaderName, HeaderValue)>, +} + +impl Failed { + /// Wraps an error produced before the chain existed. + fn early(error: OagwError) -> Self { + Self { + error, + plugins: None, + headers: Vec::new(), + } + } + + /// Wraps an error produced after `plugins` was built. + fn late(error: OagwError, plugins: &PluginChain) -> Self { + Self { + error, + plugins: Some(plugins.clone()), + headers: Vec::new(), + } + } + + /// Attaches response headers to the failure. + fn with_headers(mut self, headers: Vec<(HeaderName, HeaderValue)>) -> Self { + self.headers = headers; + self + } +} + +/// The answer of a successful orchestration, still missing its proxy stamp. +type Answer = (u16, Response); + +/// `GET|POST|… /oagw/v1/proxy/{alias}` — proxies a request without a suffix. +/// +/// All failures are rendered as `application/problem+json`; the handler never +/// returns an `Err` to the framework, so the ADR-0007 stamp and the proxy log +/// line always land. +pub async fn proxy_alias( + method: Method, + Extension(plane): Extension, + Path(alias): Path, + request: Request, +) -> Response { + let (parts, body) = request.into_parts(); + dispatch( + &plane, + Inbound { + method, + alias, + path_suffix: String::new(), + query: parts.uri.query().map(str::to_owned).unwrap_or_default(), + headers: parts.headers, + body, + security: parts.extensions.get::().cloned(), + peer: peer_of(&parts.extensions), + upgrade: parts.extensions.get::().cloned(), + }, + ) + .await +} + +/// `GET|POST|… /oagw/v1/proxy/{alias}/{*path_suffix}` — proxies a request whose +/// path continues past the alias. +/// +/// All failures are rendered as `application/problem+json`; see +/// [`proxy_alias`]. +pub async fn proxy_alias_path( + method: Method, + Extension(plane): Extension, + Path((alias, path_suffix)): Path<(String, String)>, + request: Request, +) -> Response { + let (parts, body) = request.into_parts(); + dispatch( + &plane, + Inbound { + method, + alias, + path_suffix, + query: parts.uri.query().map(str::to_owned).unwrap_or_default(), + headers: parts.headers, + body, + security: parts.extensions.get::().cloned(), + peer: peer_of(&parts.extensions), + upgrade: parts.extensions.get::().cloned(), + }, + ) + .await +} + +/// Runs the orchestration and renders the answer, whatever the outcome. +/// +/// Every exchange — a proxied answer, a gateway error, a rejected request — is +/// reported twice: as the metrics of DESIGN §4.2 and as the structured audit +/// record of DESIGN §4.3 ([`log_proxy`]). Both are labelled with the *matched +/// route pattern*, never with the path the client asked for. +async fn dispatch(plane: &DataPlane, inbound: Inbound) -> Response { + let started = Instant::now(); + let alias = inbound.alias.clone(); + let verb = inbound.method.as_str().to_owned(); + let security = inbound.security.clone(); + let mut exchange = ProxyExchange::new(&alias, &verb); + exchange.request_id = header_str(&inbound.headers, "x-request-id"); + if let Some(security) = security.as_ref() { + exchange.tenant_id = Some(security.subject_tenant_id().to_string()); + exchange.principal_id = Some(security.subject_id().to_string()); + } + plane.metrics.inc_in_flight(&alias); + + let (status, source, answer) = match orchestrate(plane, inbound, &mut exchange).await { + Ok((status, answer)) => (status, ERROR_SOURCE_UPSTREAM, answer), + Err(failed) => { + let Failed { + error, + plugins, + headers, + } = failed; + let status = error.status().as_u16(); + let error = run_error_phase(plugins, error).await; + let error_type = error.gts_type(); + exchange.error_type = Some(error_type.clone()); + exchange.error_message = Some(error.detail().to_owned()); + exchange.outcome = ProxyOutcome::Gateway; + plane.metrics.record_error( + host_label(&exchange.route, &alias), + &exchange.route, + &error_type, + ); + let mut answer = error.into_response_with_source(ERROR_SOURCE_GATEWAY); + for (name, value) in headers { + answer.headers_mut().insert(name, value); + } + (status, ERROR_SOURCE_GATEWAY, answer) + } + }; + + let elapsed = started.elapsed(); + exchange.status = status; + exchange.duration_ms = elapsed.as_millis() as u64; + // Every dimension the client controls is collapsed onto a fixed literal + // once nothing resolved: see the `Cardinality` section of the metrics + // module. `host_label` reads the *final* route, so a request that never + // matched anything reports `unmatched` on both axes. + let metric_host = host_label(&exchange.route, &alias); + plane + .metrics + .record_request(metric_host, &verb, &exchange.route, status); + plane.metrics.record_duration( + metric_host, + &exchange.route, + crate::domain::metrics::PHASE_TOTAL, + elapsed.as_secs_f64(), + ); + plane.metrics.dec_in_flight(&alias); + tracing::info!( + target: "oagw.proxy", + alias = %alias, + route = %exchange.route, + method = %verb, + status, + source, + elapsed_ms = elapsed.as_millis() as u64, + "proxy request completed" + ); + log_proxy(&exchange); + answer +} + +/// Runs the nine steps. +async fn orchestrate( + plane: &DataPlane, + inbound: Inbound, + exchange: &mut ProxyExchange, +) -> Result { + let Inbound { + method, + alias, + path_suffix, + query, + headers, + body, + security, + peer, + upgrade: downstream_upgrade, + } = inbound; + + // The client-visible location of the request, for problem documents. The + // *metrics* are labelled with the matched route pattern instead (see step + // 3), because the client path would mint one metric series per resource. + let request_path = format!("/{alias}{path_suffix}"); + + let origin = header_str(&headers, "origin"); + let request_method = header_str(&headers, "access-control-request-method"); + let request_headers = header_str(&headers, "access-control-request-headers"); + let preflight = is_preflight( + method.as_str(), + origin.as_deref(), + request_method.as_deref(), + ); + + // -- 0. the CORS preflight (ADR-0004) --------------------------------- + // + // A browser preflight carries no credentials, so it arrives without a + // tenant context and must never be answered with `401` or `404`. It is + // answered from the registry when an upstream and a route resolve without + // a `SecurityContext`; every other preflight gets the permissive answer + // that grants nothing. Origin and method enforcement happens on the actual + // request, in step 5. + if preflight { + let target = resolve_preflight_target( + plane, + security.as_ref(), + &alias, + &method, + &path_suffix, + &request_path, + ) + .await; + let configured = match target.filter(|target| target.upstream.enabled) { + Some(target) => { + let cors = merged_cors(&target.upstream, target.route.as_deref()); + preflight_answer( + cors.as_ref(), + origin.as_deref(), + request_method.as_deref(), + request_headers.as_deref(), + ) + .ok() + } + None => None, + }; + return match configured { + Some(answer) => Ok(answer), + None => fallback_preflight(request_method.as_deref(), request_headers.as_deref()), + }; + } + + // -- 1. the authenticated caller ------------------------------------- + let Some(security) = security else { + return Err(Failed::early( + OagwError::authentication_failed( + "the proxy surface requires an authenticated security context", + ) + .with_alias(&alias) + .with_path(&request_path), + )); + }; + + // -- 2. the upstream -------------------------------------------------- + let chain = plane.service.tenant_chain(&security).await; + let target = match lookup_dp_cache( + plane, + security.subject_tenant_id(), + &alias, + &method, + &path_suffix, + ) { + Some(target) => target, + None => { + let target = resolve_target( + plane, + &chain, + &alias, + &method, + &path_suffix, + preflight, + &request_path, + )?; + plane.service.store().store_dp_cache( + security.subject_tenant_id(), + &alias, + method.as_str(), + &path_suffix, + Arc::clone(&target), + ); + target + } + }; + if !target.upstream.enabled { + return Err(Failed::early( + OagwError::link_unavailable(format!("upstream '{alias}' is disabled")) + .with_alias(&alias) + .with_upstream_id(target.upstream.id) + .with_path(&request_path), + )); + } + let upstream = Arc::clone(&target.upstream); + + // -- 3. the route ----------------------------------------------------- + let Some(route) = target.route.as_deref().map(Arc::new) else { + return Err(Failed::early( + OagwError::route_not_found(format!( + "no route of upstream '{alias}' matches {} {request_path}", + method.as_str() + )) + .with_alias(&alias) + .with_upstream_id(upstream.id) + .with_path(&request_path), + )); + }; + // The metric label of the matched route: its configured path, plus the + // method, so two routes that share a path with disjoint method sets stay + // distinguishable and a path suffix can never mint a series. + exchange.route = route_label( + method.as_str(), + route.r#match.http.as_ref().map(|http| http.path.as_str()), + ); + + // -- 4. the merged configuration -------------------------------------- + let cors = merged_cors(&upstream, Some(&route)); + let rate_limit = effective_rate_limit(&upstream, Some(&route)); + + let mut context = RequestContext::builder() + .method(method.as_str()) + .target_host( + upstream + .server + .endpoints + .first() + .map_or_else(String::new, |endpoint: &Endpoint| endpoint.host.clone()), + ) + .path( + route + .r#match + .http + .as_ref() + .map_or_else(String::new, |http| http.path.clone()), + ) + .path_suffix(path_suffix.clone()) + .query(query.clone()) + .headers(headers.clone()) + .tenant_id(security.subject_tenant_id()) + .subject_id(security.subject_id()) + .security(Arc::new(security.clone())) + .upstream_id(upstream.id) + .alias(alias.clone()) + .request_id(header_str(&headers, "x-request-id").unwrap_or_default()) + .peer_ip(client_identity(&headers, peer).unwrap_or_default()) + .route_id(route.id) + .cors(CorsOutcome::Disabled) + .build(); + exchange.host = context.target_host.clone(); + + // -- 5. CORS ---------------------------------------------------------- + if let Some(cors) = cors.as_ref() { + if let Err(error) = validate_actual_request(cors, origin.as_deref(), method.as_str()) { + return Err(Failed::early(error.with_path(&request_path))); + } + context.cors = CorsOutcome::Allowed { + headers: actual_response_headers(cors, origin.as_deref()), + }; + } + + // -- 6. rate limit ---------------------------------------------------- + // + // Before the body is read: the decision needs no payload, so an exhausted + // budget is answered without buffering the request (DESIGN "Body + // Validation Rules": reject before buffering). The `ip` scope keys on the + // peer socket address, never on a client-controlled header alone. + let mut reservation = ReservationGuard::new(Arc::clone(&plane.rate_limiters)); + if let Some(limit) = rate_limit.as_ref() { + let scope_values = RateLimitScopeValues { + tenant_id: Some(security.subject_tenant_id().to_string()), + subject_id: Some(security.subject_id().to_string()), + peer_ip: client_identity(&headers, peer), + route_id: Some(route.id.to_string()), + }; + let decision = plane.rate_limiters.check( + upstream.id, + limit, + &scope_values, + Instant::now(), + epoch_now(), + ); + plane.metrics.set_rate_limit_usage_ratio( + &alias, + &exchange.route, + usage_ratio(limit, decision.remaining), + ); + if decision.is_limited() { + plane + .metrics + .record_rate_limit_exceeded(&alias, &exchange.route); + let headers = decision.headers(); + return Err(Failed::early(decision.into_error()).with_headers(headers)); + } + // ADR-0003 `strategy: queue`: the limiter reserved a token, so the + // request waits for it instead of being rejected. The wait is bounded + // twice — by the limiter's own bound and by the proxy budget — and is + // spent here, before the body is read, so a queued request still does + // not buffer its payload. + // + // The debit stays on the bucket from here to the end of the + // orchestration: the guard refunds it if any of the steps below fails, + // and only the success path at the end of step 9 keeps it. + reservation.arm(plane.rate_limiters.reservation( + upstream.id, + limit, + &scope_values, + &decision, + )); + wait_for_reserved_token(decision.queue_wait, plane.engine.proxy_timeout()).await; + context.rate_limit = Some(decision); + } + + // -- 7. the request body ---------------------------------------------- + // + // A declared size over the cap is rejected before the body is buffered, so + // a 100 MiB upload never reaches memory; the post-read checks keep guarding + // the mismatch cases a declared header alone cannot catch. + validate_declared_body_size(&headers).map_err(Failed::early)?; + context.body = read_payload(&headers, body).await.map_err(Failed::early)?; + exchange.request_size = context + .body + .buffered_len() + .map_or(0, |length| u64::try_from(length).unwrap_or(u64::MAX)); + + // -- 8. the plugin chain ---------------------------------------------- + let disabled = disabled_plugins(plane, &chain, &upstream, &route); + let plugins = plane + .plugins + .build_chain(&upstream, Some(&route), &disabled) + .map_err(|error| Failed::early(error.with_alias(&alias).with_path(&request_path)))?; + if let Err(error) = plugins.authenticate(&mut context).await { + return Err(Failed::late(error, &plugins)); + } + if let Err(error) = plugins.guard_request(&context).await { + return Err(Failed::late(error, &plugins)); + } + if let Err(error) = plugins.transform_request(&mut context).await { + return Err(Failed::late(error, &plugins)); + } + + // -- 9. the outbound call --------------------------------------------- + let pinned = context + .header(TARGET_HOST_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let selection = plane + .engine + .select_endpoint(&upstream, pinned.as_deref()) + .map_err(|error| { + Failed::late(error.with_alias(&alias).with_path(&request_path), &plugins) + })?; + + let upgrade = is_websocket_upgrade(&headers) + && matches!(selection.endpoint.scheme, Scheme::Ws | Scheme::Wss); + let outbound = outbound_path(&route, &path_suffix); + exchange.outbound_path = outbound.clone(); + exchange.endpoint = ProxyEngine::render_endpoint(&selection.endpoint); + let proxy_request = ProxyRequest { + method: method.clone(), + path: outbound, + query: query.clone(), + headers: context.headers.clone(), + injected_headers: context.injected_headers.clone(), + injected_query: context.injected_query.clone(), + body: request_body(&context.body), + target_host: Some(selection.endpoint.host.clone()), + upgrade, + downstream_upgrade: upgrade.then(|| downstream_upgrade.clone()).flatten(), + }; + + let response = plane + .engine + .send(&upstream, Some(&route), &selection.endpoint, proxy_request) + .await + .map_err(|error| Failed::late(error, &plugins))?; + let response = plane + .engine + .prepare_response(&upstream, Some(&route), response) + .map_err(|error| Failed::late(error, &plugins))?; + + let mut response_context = ResponseContext::builder() + .status(response.status) + .headers(response.headers.clone()) + .body(BodyPayload::Streaming) + .request_id(context.request_id.clone().unwrap_or_default()) + .build(); + if let Err(error) = plugins.guard_response(&response_context).await { + return Err(Failed::late(error, &plugins)); + } + if let Err(error) = plugins.transform_response(&mut response_context).await { + return Err(Failed::late(error, &plugins)); + } + + // The upstream answered, whatever status it produced: the exchange is an + // `upstream` outcome and the client gets the status it gave us. + exchange.outcome = ProxyOutcome::Upstream; + exchange.response_size = declared_body_size(&response.headers); + let rate_limit_headers = context + .rate_limit + .as_ref() + .map(|decision| decision.headers()) + .unwrap_or_default(); + let cors_headers = cors_outcome_headers(&context.cors); + let status = response.status.as_u16(); + let mut answer = render_upstream( + response, + response_context.headers, + rate_limit_headers.into_iter().chain(cors_headers).collect(), + ); + // A protocol switch hands the client socket to the caller: hyper finds the + // upgrade handle in the answer extensions. + if status == http::StatusCode::SWITCHING_PROTOCOLS.as_u16() + && let Some(handle) = downstream_upgrade + { + answer.extensions_mut().insert(handle); + } + // The exchange completed, whatever status the upstream gave: the budget the + // queue reservation spent is now this request's, and is not refunded. + reservation.disarm(); + Ok((status, answer)) +} + +/// The response headers of a CORS-annotated request. +fn cors_outcome_headers(outcome: &CorsOutcome) -> Vec<(HeaderName, HeaderValue)> { + match outcome { + CorsOutcome::Allowed { headers } | CorsOutcome::Preflight { headers } => headers.clone(), + CorsOutcome::Disabled | CorsOutcome::Rejected { .. } => Vec::new(), + } +} + +// --------------------------------------------------------------------------- +// Resolution +// --------------------------------------------------------------------------- + +/// Reads the DP cache, which only ever holds validated resolutions. +/// +/// The key carries the *calling* tenant, not the owner of the resolved +/// upstream: two callers whose tenant chains differ must not share a snapshot, +/// because the chain decides which routes are visible. An upstream mutation +/// therefore cannot be expressed as a key prefix over the callers that resolved +/// it, so [`crate::infra::storage::RegistryStore::flush_upstream_caches`] +/// drops the whole data-plane snapshot cache instead — the same footprint a +/// route mutation already has. +fn lookup_dp_cache( + plane: &DataPlane, + tenant_id: Uuid, + alias: &str, + method: &Method, + path_suffix: &str, +) -> Option> { + plane + .service + .store() + .lookup_dp_cache(tenant_id, alias, method.as_str(), path_suffix) + .filter(|target| target.upstream.enabled) + .and_then(|target| reconcile_dp_snapshot(plane.service.store(), &target)) +} + +/// Reconciles a cached resolution against the authoritative registry. +/// +/// **The cache is only ever served what the registry still agrees with.** The +/// snapshot cache is dropped wholesale on an upstream or route mutation, but the +/// request that fills it is not atomic with the mutation: a request can resolve +/// from the authoritative maps, be preempted before +/// [`RegistryStore::store_dp_cache`], and see its snapshot flushed by a +/// concurrent `replace_upstream` / `replace_route` / `delete_upstream` — which +/// then repopulates the cache with the pre-mutation state. Re-reading the cached +/// ids closes that window: +/// +/// * the upstream is re-read by its id (owner tenant + id, which the snapshot +/// carries); a snapshot the registry no longer holds, or whose upstream is now +/// disabled, is not served; +/// * the route is re-read as well — replacing a route does not touch the +/// upstream `Arc`, so an unchanged upstream proves nothing about the route. +/// A route that has moved to another upstream, or was disabled, makes the +/// snapshot stale; +/// * both `Arc`s identical → the snapshot is the one the registry still holds +/// and is used as is (the fast path of every request not racing a mutation); +/// * an `Arc` differs → the snapshot is rebuilt from the fresh ones, which is +/// also how a route-less snapshot (a preflight resolution) reconciles: it +/// holds nothing but the upstream to re-validate; +/// * an id is gone → `None`, which makes the caller resolve from the +/// authoritative maps. +fn reconcile_dp_snapshot( + store: &RegistryStore, + cached: &Arc, +) -> Option> { + let upstream = store + .get_upstream(cached.upstream.tenant_id, cached.upstream.id) + .filter(|upstream| upstream.enabled)?; + let route = match cached.route.as_ref() { + Some(cached_route) => { + let fresh = store.get_route(cached_route.tenant_id, cached_route.id)?; + if fresh.upstream_id != upstream.id || !fresh.enabled { + // The cached route no longer belongs to an enabled route of + // this upstream: the snapshot is stale, not route-less. + return None; + } + Some(fresh) + } + // A preflight snapshot binds the alias to an upstream only. + None => None, + }; + let unchanged = Arc::ptr_eq(&upstream, &cached.upstream) + && route.as_ref().map(Arc::as_ptr) == cached.route.as_ref().map(Arc::as_ptr); + if unchanged { + return Some(Arc::clone(cached)); + } + Some(Arc::new(ResolvedProxyTarget { upstream, route })) +} + +/// Resolves `(upstream, route)` for a preflight, or `None` when the request +/// cannot be bound to a configured upstream without a security context. +/// +/// ADR-0004: a browser preflight carries no credentials, so an unauthenticated +/// caller is *expected* here and must not be answered with `401`; the same +/// fallback covers an unknown alias, an unmatchable route, a disabled upstream +/// and a `grpc` one. +async fn resolve_preflight_target( + plane: &DataPlane, + security: Option<&SecurityContext>, + alias: &str, + method: &Method, + path_suffix: &str, + request_path: &str, +) -> Option> { + let security = security?; + let chain = plane.service.tenant_chain(security).await; + lookup_dp_cache( + plane, + security.subject_tenant_id(), + alias, + method, + path_suffix, + ) + .or_else(|| { + resolve_target( + plane, + &chain, + alias, + method, + path_suffix, + true, + request_path, + ) + .ok() + }) +} + +/// The disabled plugin resources of the resolved tenant chain, for the chain +/// builder to skip. +/// +/// A binding that names a plugin resource with `enabled: false` must degrade to +/// "not applied" instead of answering `503 PluginNotFound`, which is what an +/// enabled-but-unresolvable reference keeps doing. The scan is skipped when the +/// request carries no plugin binding at all, so the hot path of a gateway +/// without custom plugins never touches the plugin registry. +fn disabled_plugins( + plane: &DataPlane, + chain: &[Uuid], + upstream: &Upstream, + route: &Route, +) -> DisabledPlugins { + if upstream.plugins.items.is_empty() && route.plugins.items.is_empty() { + return DisabledPlugins::default(); + } + DisabledPlugins::of(plane.service.store().list_plugins(chain)) +} + +/// Resolves `(upstream, route)` for one proxy request. +fn resolve_target( + plane: &DataPlane, + chain: &[Uuid], + alias: &str, + method: &Method, + path_suffix: &str, + preflight: bool, + request_path: &str, +) -> Result, Failed> { + let store = plane.service.store(); + let Some(upstream) = store.resolve_upstream_alias(chain, alias) else { + return Err(Failed::early( + OagwError::route_not_found(format!( + "no upstream with alias '{alias}' is visible to the calling tenant" + )) + .with_alias(alias) + .with_path(request_path), + )); + }; + if upstream.protocol == Protocol::Grpc { + return Err(Failed::early(not_implemented())); + } + let routes = store.list_routes(chain); + let Some(route) = best_route(&routes, &upstream, method, path_suffix, preflight) else { + return Err(Failed::early( + OagwError::route_not_found(format!( + "no route of upstream '{alias}' matches {} {request_path}", + method.as_str() + )) + .with_alias(alias) + .with_upstream_id(upstream.id) + .with_path(request_path), + )); + }; + Ok(Arc::new(ResolvedProxyTarget { + upstream, + route: Some(route), + })) +} + +/// Picks the best matching route: method allowlist, then longest path prefix, +/// then the highest priority, then the order the store returned them in. +/// +/// The tie-break on priority is not cosmetic: two routes may claim the same +/// path prefix as long as their priorities differ, and `max_by_key` alone would +/// hand the request to the *lowest* of them (Rust returns the last maximum of +/// the list, and the store sorts highest-priority-first). +/// +/// A CORS preflight is answered by any enabled route that owns the path: the +/// browser never sends the application verb, so holding it to the allowlist +/// would turn every preflight into a `404`. +fn best_route( + routes: &[Arc], + upstream: &Upstream, + method: &Method, + path_suffix: &str, + preflight: bool, +) -> Option> { + let path = format!("/{}", path_suffix.trim_start_matches('/')); + routes + .iter() + .filter(|route| route.upstream_id == upstream.id && route.enabled) + .filter(|route| preflight || matches_method(route, method)) + .filter(|route| matches_path(route, &path)) + .enumerate() + .max_by_key(|(declaration, route)| { + ( + match_length(route, &path).unwrap_or_default(), + route.priority, + // Deterministic last resort for two routes that agree on both + // length and priority: the earlier one in the resolved list. + std::cmp::Reverse(*declaration), + ) + }) + .map(|(_, route)| Arc::clone(route)) +} + +/// `true` when the route's method allowlist admits `method`. +fn matches_method(route: &Route, method: &Method) -> bool { + let Some(http) = route.r#match.http.as_ref() else { + return false; + }; + http_method(method).is_some_and(|converted| http.methods.contains(&converted)) +} + +/// `true` when the route's path prefix matches `path` at a segment boundary. +fn matches_path(route: &Route, path: &str) -> bool { + let Some(http) = route.r#match.http.as_ref() else { + return false; + }; + let prefix = http.path.as_str(); + if !path.starts_with(prefix) { + return false; + } + let rest = &path[prefix.len()..]; + rest.is_empty() || rest.starts_with('/') || prefix.ends_with('/') +} + +/// Length of the matching path prefix, used to pick the longest one. +fn match_length(route: &Route, path: &str) -> Option { + let http = route.r#match.http.as_ref()?; + matches_path(route, path).then_some(http.path.len()) +} + +/// Maps an HTTP method onto the route-match enum. +fn http_method(method: &Method) -> Option { + match method.as_str() { + "GET" => Some(HttpMethod::Get), + "POST" => Some(HttpMethod::Post), + "PUT" => Some(HttpMethod::Put), + "DELETE" => Some(HttpMethod::Delete), + "PATCH" => Some(HttpMethod::Patch), + _ => None, + } +} + +/// Derives the outbound path from the route pattern and the matched suffix. +/// +/// The path suffix of a proxy request is upstream-relative, so the matched +/// route prefix is re-attached and only the remainder past it is appended +/// (`/orders` + `orders/42/items` → `/orders/42/items`, never +/// `/orders/orders/42/items`). +fn outbound_path(route: &Route, suffix: &str) -> String { + let Some(http) = route.r#match.http.as_ref() else { + return format!("/{suffix}").trim_start_matches('/').to_owned(); + }; + if http.path_suffix_mode == PathSuffixMode::Disabled { + return normalize_prefix(&http.path); + } + let prefix = normalize_prefix(&http.path); + let requested = format!("/{suffix}").trim_start_matches('/').to_owned(); + let requested = format!("/{requested}"); + let remainder = requested + .strip_prefix(&prefix) + .unwrap_or(&requested) + .to_owned(); + format!("{prefix}{remainder}") +} + +/// Normalises a route path prefix to a leading slash and no trailing one. +fn normalize_prefix(path: &str) -> String { + let trimmed = format!("/{path}").trim_start_matches('/').to_owned(); + format!("/{}", trimmed.trim_end_matches('/')) +} + +/// Builds the `501` problem document for a `grpc` upstream. +fn not_implemented() -> OagwError { + OagwError::Forbidden(Box::new(ProblemBody::bare( + NOT_IMPLEMENTED_TYPE.to_owned(), + "Not Implemented".to_owned(), + 501, + "grpc upstreams are not proxied by this gateway yet".to_owned(), + ))) +} + +/// Runs the error phase of the plugin chain over a failure. +/// +/// A failing transform is swallowed: the original taxonomy error renders, which +/// is more useful to the client than an opaque `502`. +async fn run_error_phase(plugins: Option, error: OagwError) -> OagwError { + let Some(plugins) = plugins else { + return error; + }; + let mut context = ErrorContext::from_error(error); + if plugins.transform_error(&mut context).await.is_err() { + return context.error; + } + context.error +} + +// --------------------------------------------------------------------------- +// Bodies +// --------------------------------------------------------------------------- + +/// Reads and validates the request body. +/// +/// Called after [`validate_declared_body_size`], so the declared size of an +/// oversized body has already been rejected and this buffers at most the cap. +/// +/// # Errors +/// +/// [`OagwError::PayloadTooLarge`] above the 100 MiB cap and +/// [`OagwError::Validation`] when `Content-Length` or `Transfer-Encoding` +/// disagree with the actual bytes. +async fn read_payload( + headers: &HeaderMap, + body: axum::body::Body, +) -> Result { + let bytes = axum::body::to_bytes(body, MAX_REQUEST_BODY_BYTES) + .await + .map_err(body_read_error)?; + validate_body(headers, &bytes)?; + Ok(if bytes.is_empty() { + BodyPayload::Empty + } else { + BodyPayload::Buffered(bytes) + }) +} + +/// Maps a body-read failure onto the taxonomy. +/// +/// An undeclared body (chunked, or without a `Content-Length`) that runs past +/// the 100 MiB cap fails inside `axum::body::to_bytes` with +/// `http_body_util::LengthLimitError`, which DESIGN's body validation rules +/// answer with `413` like any other body over the cap. The gear does not depend +/// on `http-body-util` outside its tests, so the type cannot be downcast here; +/// the limit case is instead recognised by the message that type renders +/// (`"length limit exceeded"`, stable across `http-body-util` 0.1, and the only +/// error `to_bytes` produces for a body that exhausts its limit). Every other +/// failure stays a `400` validation error. +fn body_read_error(error: axum::Error) -> OagwError { + if error.to_string() == LENGTH_LIMIT_ERROR_MESSAGE { + OagwError::payload_too_large(format!( + "request body exceeds the {MAX_REQUEST_BODY_BYTES} byte cap" + )) + } else { + OagwError::validation(format!("request body could not be read: {error}")) + } +} + +/// Validates the declared and actual body size, delegating to the engine so +/// the gateway and the transport agree on the same rules. +fn validate_body(headers: &HeaderMap, bytes: &Bytes) -> Result<(), OagwError> { + crate::infra::proxy::validate_body(headers, Some(bytes.as_ref())).map(|_| ()) +} + +/// The transport body the engine forwards. +/// +/// The handler always buffers the inbound body ([`read_payload`]), so the +/// streaming arm is only reachable from a plugin that replaced the payload. +fn request_body(payload: &BodyPayload) -> ProxyBody { + match payload { + BodyPayload::Empty | BodyPayload::Streaming => ProxyBody::Empty, + BodyPayload::Buffered(bytes) => ProxyBody::Buffered(bytes.clone()), + } +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +/// Renders an upstream answer, with the ADR-0007 `upstream` stamp. +fn render_upstream( + response: ProxyResponse, + headers: HeaderMap, + rate_limit_headers: Vec<(HeaderName, HeaderValue)>, +) -> Response { + let mut builder = Response::builder().status(response.status); + for (name, value) in headers { + // Iterating a `HeaderMap` yields `Option`; `None` marks a + // continuation of the previous multi-valued header, already emitted. + let Some(name) = name else { + continue; + }; + builder = builder.header(name, value); + } + for (name, value) in rate_limit_headers { + builder = builder.header(name, value); + } + builder + .header(ERROR_SOURCE_HEADER, ERROR_SOURCE_UPSTREAM) + .body(response.body) + .unwrap_or_else(|error| { + OagwError::protocol_error(format!("invalid upstream response: {error}")) + .into_response_with_source(ERROR_SOURCE_GATEWAY) + }) +} + +/// Renders a CORS preflight answer. +/// +/// The browser never sends the application verb, so the preflight is answered +/// from the merged CORS configuration alone: a disabled or absent +/// configuration yields a bare `204`, an allowed origin an annotated one. +fn preflight_answer( + cors: Option<&EffectiveCorsConfig>, + origin: Option<&str>, + request_method: Option<&str>, + request_headers: Option<&str>, +) -> Result { + render_preflight(cors.map_or_else( + || PreflightResponse { + status: 204, + headers: Vec::new(), + allowed: false, + }, + |cors| evaluate_preflight(cors, origin, request_method, request_headers), + )) +} + +/// Renders the permissive answer of a preflight no configured upstream owns. +/// +/// ADR-0004 "Preflight Request Handling": the preflight is answered `204` +/// without a tenant context, and enforcement is deferred to the actual request. +/// No CORS configuration is available here, so nothing is granted — the answer +/// names no `Access-Control-Allow-Origin`, which is what keeps the browser from +/// reading a cross-origin response the gateway could not validate. +/// +/// # Errors +/// +/// [`OagwError::ProtocolError`] when the answer cannot be rendered, which the +/// caller reports like any other gateway failure. +fn fallback_preflight( + request_method: Option<&str>, + request_headers: Option<&str>, +) -> Result { + render_preflight(unresolved_preflight(request_method, request_headers)) + .map_err(|error| Failed::early(OagwError::protocol_error(error.to_string()))) +} + +/// Turns a preflight decision into the response parts. +fn render_preflight(outcome: PreflightResponse) -> Result { + let mut builder = Response::builder() + .status(StatusCode::from_u16(outcome.status).unwrap_or(StatusCode::NO_CONTENT)); + for (name, value) in outcome.headers { + builder = builder.header(name, value); + } + let answer = builder + .header(ERROR_SOURCE_HEADER, ERROR_SOURCE_GATEWAY) + .body(axum::body::Body::empty()) + .map_err(|error| { + OagwError::protocol_error(format!("invalid preflight response: {error}")) + })?; + Ok((outcome.status, answer)) +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// `GET /oagw/v1/metrics` — renders the in-process metrics registry in the +/// Prometheus text exposition format. +/// +/// Admin-only: the route is registered with `.authenticated()`, and the +/// licence gate is deliberately left open so a scrape can run without a +/// licence. +pub async fn get_metrics(Extension(plane): Extension) -> Response { + let rendered = plane.metrics.render(); + match Response::builder() + .status(StatusCode::OK) + .header( + axum::http::header::CONTENT_TYPE, + "text/plain; version=0.0.4; charset=utf-8", + ) + .header(ERROR_SOURCE_HEADER, ERROR_SOURCE_GATEWAY) + .body(axum::body::Body::from(rendered)) + { + Ok(response) => response, + Err(error) => OagwError::protocol_error(format!("metrics rendering failed: {error}")) + .into_response_with_source(ERROR_SOURCE_GATEWAY), + } +} + +/// Merged CORS configuration over the upstream → route chain. +fn merged_cors(upstream: &Upstream, route: Option<&Route>) -> Option { + let mut chain: Vec<&CorsConfig> = Vec::new(); + if let Some(cors) = upstream.cors.as_ref() { + chain.push(cors); + } + if let Some(cors) = route.and_then(|route| route.cors.as_ref()) { + chain.push(cors); + } + merge_cors(&chain) +} + +/// Merged rate-limit configuration over the upstream → route chain. +fn effective_rate_limit(upstream: &Upstream, route: Option<&Route>) -> Option { + let mut chain: Vec<&RateLimitConfig> = Vec::new(); + if let Some(limit) = upstream.rate_limit.as_ref() { + chain.push(limit); + } + if let Some(limit) = route.and_then(|route| route.rate_limit.as_ref()) { + chain.push(limit); + } + resolve_effective_rate_limit(&chain) +} + +/// First value of a header as UTF-8. +fn header_str(headers: &HeaderMap, name: &str) -> Option { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) +} + +/// The peer socket address of the request, when the transport records one. +/// +/// `axum::serve` runs this gear's router through +/// `into_make_service_with_connect_info::`, so a request that +/// arrived over the listener always carries it; the `None` case is a request +/// that reached the handler from a transport that does not (the `oneshot` test +/// harness, an in-process caller). +fn peer_of(extensions: &axum::http::Extensions) -> Option { + extensions + .get::>() + .map(|ConnectInfo(address)| *address) +} + +/// Client identity for the `ip` rate-limit scope (ADR-0003). +/// +/// **The peer socket address wins, and `X-Forwarded-For` is only ever a +/// fallback.** The header is client-controlled: a limit keyed on it hands every +/// caller a fresh budget simply by rotating the header (the limit is bypassed +/// for free), and lets a caller spend a *specific* other caller's budget by +/// naming them. The socket address cannot be forged, so the budget of one +/// connection is exactly that connection's. +/// +/// When no socket address is available — a request that did not come through +/// the axum listener — the *last* `X-Forwarded-For` entry is used rather than +/// the first. A proxy chain appends the address it saw, so the last entry is +/// the one the nearest trusted proxy observed, while the first is the one the +/// client chose to claim. +/// +/// The trade-off is documented, not accidental: behind a single front proxy +/// that does not append per-client entries, the `ip` scope collapses every +/// caller into one budget. That is the conservative failure mode — callers +/// share a limit rather than escaping one. +fn client_identity(headers: &HeaderMap, peer: Option) -> Option { + if let Some(peer) = peer { + return Some(peer.ip().to_string()); + } + header_str(headers, "x-forwarded-for").and_then(|forwarded| { + forwarded + .rsplit(',') + .next() + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_owned) + }) +} + +/// Waits out a `queue` strategy reservation (ADR-0003 `strategy: queue`). +/// +/// The limiter only ever grants a wait up to [`QUEUE_MAX_WAIT`]; the wait is +/// additionally capped by the proxy budget (`proxy_timeout_secs`), so a queued +/// request never spends longer waiting than the upstream call it is queued for +/// would have been allowed to take. A `None` wait (every other strategy) is a +/// no-op. +async fn wait_for_reserved_token(wait: Option, budget: Duration) { + let Some(wait) = wait else { + return; + }; + let granted = wait.min(QUEUE_MAX_WAIT).min(budget); + if granted.is_zero() { + return; + } + tokio::time::sleep(granted).await; +} + +/// Body size the upstream declared for its answer, `0` when it declared none. +/// +/// The response body streams, so this is the declared size and not the bytes +/// that actually crossed the socket; the audit record records it as such. +fn declared_body_size(headers: &HeaderMap) -> u64 { + headers + .get(axum::http::header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|declared| declared.trim().parse::().ok()) + .unwrap_or(0) +} + +/// Consumed fraction of the rate-limit budget, clamped to `0.0..=1.0`. +fn usage_ratio(limit: &EffectiveRateLimit, remaining: u64) -> f64 { + if limit.capacity == 0 { + return 0.0; + } + let consumed = limit.capacity.saturating_sub(remaining); + consumed as f64 / limit.capacity as f64 +} + +/// Unix seconds, used by the sliding-window rate limiter. +fn epoch_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()) +} + +#[cfg(test)] +#[path = "proxy_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/proxy_tests.rs b/gears/system/oagw/oagw/src/api/rest/handlers/proxy_tests.rs new file mode 100644 index 0000000..57dec26 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/proxy_tests.rs @@ -0,0 +1,196 @@ +//! Unit tests of the data-plane helpers of [`crate::api::rest::handlers::proxy`]. + +use std::sync::Arc; +use std::time::SystemTime; + +use uuid::Uuid; + +use super::reconcile_dp_snapshot; +use crate::domain::model::{ + HeadersConfig, PluginConfig, Protocol, ResolvedProxyTarget, Route, RouteMatch, Upstream, +}; +use crate::infra::storage::{CacheLimits, RegistryStore}; + +const TENANT: Uuid = Uuid::from_u128(0x10); +const UPSTREAM_ID: Uuid = Uuid::from_u128(0xA1); +const ROUTE_ID: Uuid = Uuid::from_u128(0xB1); +const OTHER_UPSTREAM_ID: Uuid = Uuid::from_u128(0xA2); + +fn store() -> RegistryStore { + RegistryStore::new(CacheLimits { + upstream: 8, + route: 8, + plugin: 8, + dp: 8, + }) +} + +fn upstream(id: Uuid, alias: &str) -> Upstream { + Upstream { + id, + enabled: true, + alias: alias.to_owned(), + tags: Vec::new(), + server: crate::domain::model::ServerConfig { + endpoints: Vec::new(), + }, + protocol: Protocol::Http, + auth: None, + headers: HeadersConfig::default(), + plugins: PluginConfig::default(), + rate_limit: None, + cors: None, + tenant_id: TENANT, + created_at: SystemTime::UNIX_EPOCH, + updated_at: SystemTime::UNIX_EPOCH, + } +} + +fn route(upstream_id: Uuid) -> Route { + Route { + id: ROUTE_ID, + upstream_id, + r#match: RouteMatch::default(), + headers: HeadersConfig::default(), + plugins: PluginConfig::default(), + rate_limit: None, + cors: None, + enabled: true, + priority: 0, + tags: Vec::new(), + tenant_id: TENANT, + created_at: SystemTime::UNIX_EPOCH, + updated_at: SystemTime::UNIX_EPOCH, + } +} + +fn snapshot(upstream: Arc, route: Arc) -> Arc { + Arc::new(ResolvedProxyTarget { + upstream, + route: Some(route), + }) +} + +/// Inserts the upstream and its route and returns the snapshot a resolution +/// would have cached, holding the very `Arc`s the registry handed out. +fn seeded(alias: &str) -> (RegistryStore, Arc) { + let store = store(); + let inserted = store + .insert_upstream(upstream(UPSTREAM_ID, alias)) + .expect("inserts"); + let inserted_route = store.insert_route(route(inserted.id)).expect("inserts"); + let target = snapshot(Arc::clone(&inserted), Arc::clone(&inserted_route)); + (store, target) +} + +#[test] +fn an_unchanged_snapshot_is_served_as_is() { + let (store, cached) = seeded("orders"); + // The registry still holds the very `Arc`s the resolution produced, so the + // reconcile hands the cached snapshot back untouched instead of rebuilding + // it (the fast path every request that is not racing a mutation takes). + let reconciled = reconcile_dp_snapshot(&store, &cached).expect("the snapshot survives"); + assert!( + Arc::ptr_eq(&reconciled, &cached), + "an unchanged snapshot must not be rebuilt" + ); + assert_eq!(reconciled.upstream.alias, "orders"); +} + +#[test] +fn a_replaced_upstream_is_re_read_from_the_registry() { + let (store, cached) = seeded("orders"); + // A concurrent mutation replaced the upstream after this request resolved: + // the registry now holds a different `Arc` for the same id. + let mut replacement = cached.upstream.as_ref().clone(); + replacement.tags = vec!["replaced".to_owned()]; + let fresh_upstream = store.replace_upstream(replacement).expect("replaces"); + assert!(!Arc::ptr_eq(&fresh_upstream, &cached.upstream)); + + let reconciled = reconcile_dp_snapshot(&store, &cached).expect("the route still belongs"); + assert!( + Arc::ptr_eq(&reconciled.upstream, &fresh_upstream), + "the fresh upstream is served, not the cached one" + ); + assert_eq!(reconciled.upstream.tags, vec!["replaced".to_owned()]); + // The route is re-read too: it still belongs to this upstream. + assert_eq!(reconciled.route.as_ref().expect("route").id, ROUTE_ID); +} + +#[test] +fn a_deleted_upstream_makes_the_snapshot_stale() { + let (store, cached) = seeded("orders"); + assert!( + store.delete_upstream(TENANT, cached.upstream.id), + "the upstream is deleted" + ); + assert!( + reconcile_dp_snapshot(&store, &cached).is_none(), + "a flushed-away upstream is never served from the cache" + ); +} + +#[test] +fn a_route_that_moved_away_makes_the_snapshot_stale() { + let (store, cached) = seeded("orders"); + // A second upstream appears and the route is re-pointed at it, so the + // cached route no longer belongs to the cached upstream. + let other = store + .insert_upstream(upstream(OTHER_UPSTREAM_ID, "billing")) + .expect("inserts"); + let mut moved = cached.route.as_ref().expect("route").as_ref().clone(); + moved.upstream_id = other.id; + store.replace_route(moved).expect("replaces"); + + assert!( + reconcile_dp_snapshot(&store, &cached).is_none(), + "a route that no longer belongs to the upstream is not served" + ); +} + +#[test] +fn a_disabled_route_makes_the_snapshot_stale() { + let (store, cached) = seeded("orders"); + let mut disabled = cached.route.as_ref().expect("route").as_ref().clone(); + disabled.enabled = false; + store.replace_route(disabled).expect("replaces"); + assert!(reconcile_dp_snapshot(&store, &cached).is_none()); +} + +#[test] +fn a_snapshot_without_a_route_is_reconciled_against_the_upstream_alone() { + // A preflight resolution binds the alias to an upstream and nothing else, + // so the upstream is all it has to re-validate. + let (store, cached) = seeded("orders"); + let routeless = Arc::new(ResolvedProxyTarget { + upstream: Arc::clone(&cached.upstream), + route: None, + }); + assert!(reconcile_dp_snapshot(&store, &routeless).is_some()); + + let mut replacement = cached.upstream.as_ref().clone(); + replacement.tags = vec!["replaced".to_owned()]; + let fresh = store.replace_upstream(replacement).expect("replaces"); + let reconciled = reconcile_dp_snapshot(&store, &routeless).expect("reconciles"); + assert!(reconciled.route.is_none(), "still a route-less snapshot"); + assert!(Arc::ptr_eq(&reconciled.upstream, &fresh)); + + // But the upstream disappearing leaves nothing to serve. + assert!(store.delete_upstream(TENANT, fresh.id)); + assert!(reconcile_dp_snapshot(&store, &routeless).is_none()); +} + +#[test] +fn a_disabled_upstream_is_never_reconciled_back_into_service() { + // `lookup_dp_cache` drops a snapshot whose upstream is disabled before it + // reaches the reconcile, so a disabled upstream cannot be resurrected here; + // the reconcile itself only ever sees an enabled one. + let (store, cached) = seeded("orders"); + let mut disabled = cached.upstream.as_ref().clone(); + disabled.enabled = false; + store.replace_upstream(disabled).expect("replaces"); + assert!( + reconcile_dp_snapshot(&store, &cached).is_none(), + "the route no longer belongs to an enabled upstream, so the snapshot is stale" + ); +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/routes.rs b/gears/system/oagw/oagw/src/api/rest/handlers/routes.rs new file mode 100644 index 0000000..c0fcf26 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/routes.rs @@ -0,0 +1,127 @@ +//! Route handlers of the OAGW management REST surface. + +use axum::Extension; +use axum::extract::Path; +use axum::http::HeaderMap; +use axum::http::Uri; +use axum::response::IntoResponse; + +use super::{Service, path_uuid}; +use crate::api::rest::dto::{CreateRouteRequest, ReplaceRouteRequest, RouteDto}; +use crate::api::rest::extractors::{JsonBody, RouteList}; +use crate::domain::error::{ApiResult, OagwError}; +use crate::domain::model::parse_route_id; +use toolkit::api::canonical_prelude::{created_json, no_content, ok_json}; +use toolkit_security::SecurityContext; + +/// The request id of a mutation, from the `x-request-id` header. +fn request_id(headers: &HeaderMap) -> Option<&str> { + headers + .get("x-request-id") + .and_then(|value| value.to_str().ok()) +} + +/// Missing route problem document for `id`. +fn missing_route(id: uuid::Uuid) -> OagwError { + OagwError::not_found(format!( + "route gts.cf.core.oagw.route.v1~{id} does not exist" + )) +} + +/// `POST /oagw/v1/routes` — creates a route under an owned upstream. +/// +/// # Errors +/// +/// Returns the problem document reported by +/// [`ControlPlaneService::create_route`](crate::domain::services::ControlPlaneService::create_route). +pub async fn create_route( + uri: Uri, + Extension(ctx): Extension, + Extension(svc): Extension, + headers: HeaderMap, + JsonBody(request): JsonBody, +) -> ApiResult { + let upstream_id = request.upstream_id; + let input = request.as_input(); + let created = svc + .create_route(&ctx, request_id(&headers), upstream_id, &input) + .await?; + Ok(created_json( + RouteDto::from(&*created), + &uri, + &created.id.to_string(), + )) +} + +/// `GET /oagw/v1/routes` — lists the routes of the calling tenant. +/// +/// # Errors +/// +/// Returns the problem document reported by the OData query parser. +pub async fn list_routes( + Extension(ctx): Extension, + Extension(svc): Extension, + RouteList(query): RouteList, +) -> ApiResult { + let items = svc + .list_routes(&ctx) + .into_iter() + .map(|route| RouteDto::from(&*route)) + .collect(); + let page = query.apply_values(items)?; + Ok(ok_json(page)) +} + +/// `GET /oagw/v1/routes/{id}` — reads one route of the calling tenant. +/// +/// # Errors +/// +/// Returns [`OagwError::NotFound`] when the calling tenant does not own the +/// route. +pub async fn get_route( + Extension(ctx): Extension, + Extension(svc): Extension, + Path(raw_id): Path, +) -> ApiResult { + let id = path_uuid(&raw_id, "route", parse_route_id)?; + let route = svc.get_route(&ctx, id).ok_or_else(|| missing_route(id))?; + Ok(ok_json(RouteDto::from(&*route))) +} + +/// `PUT /oagw/v1/routes/{id}` — replaces a route; `upstream_id` is immutable. +/// +/// # Errors +/// +/// Returns the problem document reported by +/// [`ControlPlaneService::replace_route`](crate::domain::services::ControlPlaneService::replace_route). +pub async fn replace_route( + Extension(ctx): Extension, + Extension(svc): Extension, + Path(raw_id): Path, + headers: HeaderMap, + JsonBody(request): JsonBody, +) -> ApiResult { + let id = path_uuid(&raw_id, "route", parse_route_id)?; + let input = request.as_input(); + let replaced = svc + .replace_route(&ctx, request_id(&headers), id, &input) + .await?; + Ok(ok_json(RouteDto::from(&*replaced))) +} + +/// `DELETE /oagw/v1/routes/{id}` — deletes a route of the calling tenant. +/// +/// # Errors +/// +/// Returns [`OagwError::NotFound`] when the calling tenant does not own the +/// route. +pub async fn delete_route( + Extension(ctx): Extension, + Extension(svc): Extension, + Path(raw_id): Path, + headers: HeaderMap, +) -> ApiResult { + let id = path_uuid(&raw_id, "route", parse_route_id)?; + svc.delete_route(&ctx, request_id(&headers), id)?; + Ok(no_content()) +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/upstreams.rs b/gears/system/oagw/oagw/src/api/rest/handlers/upstreams.rs new file mode 100644 index 0000000..ddea70f --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/upstreams.rs @@ -0,0 +1,127 @@ +//! Upstream handlers of the OAGW management REST surface. + +use axum::Extension; +use axum::extract::Path; +use axum::http::HeaderMap; +use axum::http::Uri; +use axum::response::IntoResponse; + +use super::{Service, path_uuid}; +use crate::api::rest::dto::{UpstreamDto, UpstreamRequest}; +use crate::api::rest::extractors::{JsonBody, UpstreamList}; +use crate::domain::error::{ApiResult, OagwError}; +use crate::domain::model::parse_upstream_id; +use toolkit::api::canonical_prelude::{created_json, no_content, ok_json}; +use toolkit_security::SecurityContext; + +/// The request id of a mutation, from the `x-request-id` header. +fn request_id(headers: &HeaderMap) -> Option<&str> { + headers + .get("x-request-id") + .and_then(|value| value.to_str().ok()) +} + +/// Missing upstream problem document for `id`. +fn missing(id: uuid::Uuid) -> OagwError { + OagwError::not_found(format!( + "upstream gts.cf.core.oagw.upstream.v1~{id} does not exist" + )) + .with_upstream_id(id) +} + +/// `POST /oagw/v1/upstreams` — creates an upstream for the calling tenant. +/// +/// # Errors +/// +/// Returns the problem document reported by +/// [`ControlPlaneService::create_upstream`](crate::domain::services::ControlPlaneService::create_upstream). +pub async fn create_upstream( + uri: Uri, + Extension(ctx): Extension, + Extension(svc): Extension, + headers: HeaderMap, + JsonBody(request): JsonBody, +) -> ApiResult { + let input = request.as_input(); + let created = svc + .create_upstream(&ctx, request_id(&headers), &input) + .await?; + Ok(created_json( + UpstreamDto::from(&*created), + &uri, + &created.id.to_string(), + )) +} + +/// `GET /oagw/v1/upstreams` — lists the upstreams of the calling tenant. +/// +/// # Errors +/// +/// Returns the problem document reported by the OData query parser. +pub async fn list_upstreams( + Extension(ctx): Extension, + Extension(svc): Extension, + UpstreamList(query): UpstreamList, +) -> ApiResult { + let items = svc + .list_upstreams(&ctx) + .into_iter() + .map(|upstream| UpstreamDto::from(&*upstream)) + .collect(); + let page = query.apply_values(items)?; + Ok(ok_json(page)) +} + +/// `GET /oagw/v1/upstreams/{id}` — reads one upstream of the calling tenant. +/// +/// # Errors +/// +/// Returns [`OagwError::NotFound`] when the calling tenant does not own the +/// upstream. +pub async fn get_upstream( + Extension(ctx): Extension, + Extension(svc): Extension, + Path(raw_id): Path, +) -> ApiResult { + let id = path_uuid(&raw_id, "upstream", parse_upstream_id)?; + let upstream = svc.get_upstream(&ctx, id).ok_or_else(|| missing(id))?; + Ok(ok_json(UpstreamDto::from(&*upstream))) +} + +/// `PUT /oagw/v1/upstreams/{id}` — replaces an upstream of the calling tenant. +/// +/// # Errors +/// +/// Returns the problem document reported by +/// [`ControlPlaneService::replace_upstream`](crate::domain::services::ControlPlaneService::replace_upstream). +pub async fn replace_upstream( + Extension(ctx): Extension, + Extension(svc): Extension, + Path(raw_id): Path, + headers: HeaderMap, + JsonBody(request): JsonBody, +) -> ApiResult { + let id = path_uuid(&raw_id, "upstream", parse_upstream_id)?; + let input = request.as_input(); + let replaced = svc + .replace_upstream(&ctx, request_id(&headers), id, &input) + .await?; + Ok(ok_json(UpstreamDto::from(&*replaced))) +} + +/// `DELETE /oagw/v1/upstreams/{id}` — deletes an upstream and its routes. +/// +/// # Errors +/// +/// Returns [`OagwError::NotFound`] when the calling tenant does not own the +/// upstream. +pub async fn delete_upstream( + Extension(ctx): Extension, + Extension(svc): Extension, + Path(raw_id): Path, + headers: HeaderMap, +) -> ApiResult { + let id = path_uuid(&raw_id, "upstream", parse_upstream_id)?; + svc.delete_upstream(&ctx, request_id(&headers), id)?; + Ok(no_content()) +} diff --git a/gears/system/oagw/oagw/src/api/rest/mod.rs b/gears/system/oagw/oagw/src/api/rest/mod.rs new file mode 100644 index 0000000..74c1c36 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/mod.rs @@ -0,0 +1,16 @@ +//! REST surface of the OAGW gear. +//! +//! Slice 2 installs the management routes (`/oagw/v1/upstreams`, +//! `/oagw/v1/routes`, `/oagw/v1/plugins`) and slice 4 the proxy routes, both +//! layered through [`error_source_layer`]. + +pub mod dto; +pub mod error_layer; +pub mod extractors; +pub mod handlers; +pub mod routes; + +#[cfg(any(test, feature = "test-utils"))] +pub mod test_support; + +pub use error_layer::error_source_layer; diff --git a/gears/system/oagw/oagw/src/api/rest/routes.rs b/gears/system/oagw/oagw/src/api/rest/routes.rs new file mode 100644 index 0000000..0dd767e --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/routes.rs @@ -0,0 +1,347 @@ +//! Route registrations of the OAGW management API. +//! +//! Fifteen operations under `/oagw/v1`, registered through +//! [`OperationBuilder`] so the OpenAPI document is generated with the +//! platform's canonical `Problem` schema, the `authenticated` security +//! requirement and the `OAGW` tag. +//! +//! ## List envelope (documented choice) +//! +//! List endpoints return the platform's `toolkit::Page` envelope +//! (`{ items, page_info }`) rather than a bare array, so the OAGW lists stay +//! wire-compatible with every other gear and with the platform SDK paging +//! helpers. Offset paging needs no cursor, so `page_info.next_cursor` and +//! `prev_cursor` stay `null` and `limit` carries the effective `$top`. +//! +//! Because `$select` projects fields out of the serialised items, the runtime +//! body carries the *projected* objects while the OpenAPI document declares +//! the full item schema (`UpstreamDto`/`RouteDto`/`PluginDto`) inside +//! `Page<…>`: utoipa cannot express a `$select`-dependent shape. +//! +//! ## No plugin `PUT` +//! +//! Plugins are immutable (DESIGN section 3.3), so there is no +//! `PUT /oagw/v1/plugins/{id}`: a re-registration of the same id is a `409` +//! and a redefinition must be modelled as a new plugin. + +use axum::Router; +use http::StatusCode; +use toolkit::api::OpenApiRegistry; +use toolkit::api::operation_builder::OperationBuilder; + +use crate::api::rest::dto::{ + CreatePluginRequest, CreateRouteRequest, PluginDto, PluginPageDto, PluginSourceDto, + ReplaceRouteRequest, RouteDto, RoutePageDto, UpstreamDto, UpstreamPageDto, UpstreamRequest, +}; +use crate::api::rest::handlers; + +/// Tag of the management operations in the OpenAPI document. +const API_TAG: &str = "OAGW Management"; + +/// Registers the management routes of the gear. +#[allow(clippy::needless_pass_by_value)] +pub fn register_routes(mut router: Router, openapi: &dyn OpenApiRegistry) -> Router { + // -- upstreams ----------------------------------------------------------- + + router = OperationBuilder::get("/oagw/v1/upstreams") + .operation_id("oagw.list_upstreams") + .summary("List upstreams") + .description("List the upstreams of the calling tenant, newest first.") + .tag(API_TAG) + .authenticated() + .no_license_required() + .handler(handlers::upstreams::list_upstreams) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Paginated upstreams", + ) + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/oagw/v1/upstreams") + .operation_id("oagw.create_upstream") + .summary("Create an upstream") + .description( + "Create an upstream for the calling tenant. The alias is derived from the endpoint \ + pool when every endpoint is a hostname; a supplied alias must match it.", + ) + .tag(API_TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "Upstream creation draft") + .handler(handlers::upstreams::create_upstream) + .json_response_with_schema::(openapi, StatusCode::CREATED, "Created upstream") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/upstreams/{id}") + .operation_id("oagw.get_upstream") + .summary("Read an upstream") + .description("Read one upstream of the calling tenant.") + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Upstream instance id") + .handler(handlers::upstreams::get_upstream) + .json_response_with_schema::(openapi, StatusCode::OK, "The upstream") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::put("/oagw/v1/upstreams/{id}") + .operation_id("oagw.replace_upstream") + .summary("Replace an upstream") + .description( + "Replace an upstream of the calling tenant. The alias and the endpoint pool derived \ + from it are immutable; omitted optional sections are cleared.", + ) + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Upstream instance id") + .json_request::(openapi, "Upstream replacement draft") + .handler(handlers::upstreams::replace_upstream) + .json_response_with_schema::(openapi, StatusCode::OK, "Replaced upstream") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/upstreams/{id}") + .operation_id("oagw.delete_upstream") + .summary("Delete an upstream") + .description("Delete an upstream of the calling tenant together with its routes.") + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Upstream instance id") + .handler(handlers::upstreams::delete_upstream) + .no_content_response(StatusCode::NO_CONTENT, "Upstream deleted") + .standard_errors(openapi) + .register(router, openapi); + + // -- routes -------------------------------------------------------------- + + router = OperationBuilder::get("/oagw/v1/routes") + .operation_id("oagw.list_routes") + .summary("List routes") + .description("List the routes of the calling tenant, highest priority first.") + .tag(API_TAG) + .authenticated() + .no_license_required() + .handler(handlers::routes::list_routes) + .json_response_with_schema::(openapi, StatusCode::OK, "Paginated routes") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/oagw/v1/routes") + .operation_id("oagw.create_route") + .summary("Create a route") + .description("Create a route under an upstream of the calling tenant.") + .tag(API_TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "Route creation draft") + .handler(handlers::routes::create_route) + .json_response_with_schema::(openapi, StatusCode::CREATED, "Created route") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/routes/{id}") + .operation_id("oagw.get_route") + .summary("Read a route") + .description("Read one route of the calling tenant.") + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Route instance id") + .handler(handlers::routes::get_route) + .json_response_with_schema::(openapi, StatusCode::OK, "The route") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::put("/oagw/v1/routes/{id}") + .operation_id("oagw.replace_route") + .summary("Replace a route") + .description( + "Replace a route of the calling tenant. `upstream_id` is immutable and the stored \ + value is kept.", + ) + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Route instance id") + .json_request::(openapi, "Route replacement draft") + .handler(handlers::routes::replace_route) + .json_response_with_schema::(openapi, StatusCode::OK, "Replaced route") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/routes/{id}") + .operation_id("oagw.delete_route") + .summary("Delete a route") + .description("Delete a route of the calling tenant.") + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Route instance id") + .handler(handlers::routes::delete_route) + .no_content_response(StatusCode::NO_CONTENT, "Route deleted") + .standard_errors(openapi) + .register(router, openapi); + + // -- plugins ------------------------------------------------------------- + + router = OperationBuilder::get("/oagw/v1/plugins") + .operation_id("oagw.list_plugins") + .summary("List plugins") + .description("List the plugins of the calling tenant.") + .tag(API_TAG) + .authenticated() + .no_license_required() + .handler(handlers::plugins::list_plugins) + .json_response_with_schema::(openapi, StatusCode::OK, "Paginated plugins") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/oagw/v1/plugins") + .operation_id("oagw.create_plugin") + .summary("Register a plugin") + .description( + "Register a plugin for the calling tenant. Plugins are immutable: there is no PUT.", + ) + .tag(API_TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "Plugin registration draft") + .handler(handlers::plugins::create_plugin) + .json_response_with_schema::(openapi, StatusCode::CREATED, "Registered plugin") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/{id}") + .operation_id("oagw.get_plugin") + .summary("Read a plugin") + .description("Read one plugin of the calling tenant.") + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Plugin instance id") + .handler(handlers::plugins::get_plugin) + .json_response_with_schema::(openapi, StatusCode::OK, "The plugin") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/{id}/source") + .operation_id("oagw.get_plugin_source") + .summary("Read the plugin definition source") + .description( + "Return the deterministic rendering of the plugin definition, for inspection and \ + review.", + ) + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Plugin instance id") + .handler(handlers::plugins::get_plugin_source) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "Rendered plugin source", + ) + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/plugins/{id}") + .operation_id("oagw.delete_plugin") + .summary("Delete a plugin") + .description( + "Delete a plugin of the calling tenant. The request is rejected with `409` while an \ + upstream or a route still references the plugin.", + ) + .tag(API_TAG) + .authenticated() + .no_license_required() + .path_param("id", "Plugin instance id") + .handler(handlers::plugins::delete_plugin) + .no_content_response(StatusCode::NO_CONTENT, "Plugin deleted") + .standard_errors(openapi) + .register(router, openapi); + + router +} + +/// Tag of the data-plane operations in the OpenAPI document. +const PROXY_TAG: &str = "OAGW Proxy"; + +/// Content type of the proxied response in the OpenAPI document. +const PROXY_MEDIA_TYPE: &str = "*/*"; + +/// Content type of the metrics endpoint in the OpenAPI document. +const METRICS_MEDIA_TYPE: &str = "text/plain; version=0.0.4"; + +/// Registers the data-plane routes: the two proxy operations and the metrics +/// endpoint. +/// +/// The proxy surface answers *every* HTTP method, so the routes are attached +/// with [`axum::routing::any`] rather than one `OperationBuilder` per verb; +/// the OpenAPI document declares each operation once, with the generic +/// `*/*` response the upstream actually produces. +pub fn register_proxy_routes(mut router: Router, openapi: &dyn OpenApiRegistry) -> Router { + router = OperationBuilder::get("/oagw/v1/proxy/{alias}") + .operation_id("oagw.proxy") + .summary("Proxy an HTTP request") + .description( + "Forward a request to the upstream that owns the alias, after route resolution, \ + CORS, rate limiting and the plugin chain.", + ) + .tag(PROXY_TAG) + .authenticated() + .no_license_required() + .method_router(axum::routing::any(handlers::proxy::proxy_alias)) + .text_response( + http::StatusCode::OK, + "Proxied upstream response", + PROXY_MEDIA_TYPE, + ) + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/proxy/{alias}/{*path_suffix}") + .operation_id("oagw.proxy_path") + .summary("Proxy an HTTP request with a path suffix") + .description( + "Forward a request whose path continues past the alias. The suffix is appended to \ + the matched route path unless the route disables suffix handling.", + ) + .tag(PROXY_TAG) + .authenticated() + .no_license_required() + .path_param("alias", "Upstream routing alias") + .path_param("path_suffix", "Remainder of the request path") + .method_router(axum::routing::any(handlers::proxy::proxy_alias_path)) + .text_response( + http::StatusCode::OK, + "Proxied upstream response", + PROXY_MEDIA_TYPE, + ) + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/metrics") + .operation_id("oagw.get_metrics") + .summary("Read the data-plane metrics") + .description( + "Render the in-process metrics registry in the Prometheus text exposition format.", + ) + .tag(PROXY_TAG) + .authenticated() + .no_license_required() + .handler(handlers::proxy::get_metrics) + .text_response( + http::StatusCode::OK, + "Metrics in Prometheus text format", + METRICS_MEDIA_TYPE, + ) + .register(router, openapi); + + router +} diff --git a/gears/system/oagw/oagw/src/api/rest/test_support.rs b/gears/system/oagw/oagw/src/api/rest/test_support.rs new file mode 100644 index 0000000..01d4c8c --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/test_support.rs @@ -0,0 +1,281 @@ +//! Router assembly for the management-API integration tests. +//! +//! The gear's `RestApiCapability::register_rest` needs a `GearCtx`, which the +//! integration tests cannot build; this module builds the same router over an +//! explicitly supplied [`RegistryStore`] + [`OagwConfig`] pair, so the tests +//! exercise the real handler stack — extractors, [`ControlPlaneService`], +//! [`error_source_layer`] — without the runtime. +//! +//! Every fallible step stays fallible: the module never panics, so the +//! `expect`/`unwrap` budget of this crate remains inside the test files. +//! +//! [`RestApiCapability::register_rest`]: crate::gear::OagwGear::register_rest + +use std::collections::HashMap; +use std::sync::Arc; + +use toolkit::api::OpenApiRegistryImpl; +use toolkit_security::{SecurityContext, SecurityContextBuildError}; +use uuid::Uuid; + +use crate::config::OagwConfig; +use crate::domain::metrics::MetricsRegistry; +use crate::domain::rate_limit::RateLimiterRegistry; +use crate::domain::services::{ControlPlaneService, NoHierarchy, TenantHierarchy}; +use crate::infra::plugin::{ + PluginRegistry, SecretResolverTrait, TokenCacheConfig, UnavailableSecretResolver, +}; +use crate::infra::proxy::ProxyEngine; +use crate::infra::storage::{CacheLimits, RegistryStore}; +use crate::{ + api::rest::error_source_layer, api::rest::handlers::proxy::DataPlane, api::rest::routes, + domain::validation::Validator, +}; + +/// L1 budgets large enough that no test observes a flush. +#[must_use] +pub fn limits() -> CacheLimits { + CacheLimits { + upstream: 64, + route: 64, + plugin: 64, + dp: 64, + } +} + +/// A management router and the service that owns its registry. +pub struct TestApp { + /// Router to send requests to. + pub router: axum::Router, + /// Service backing the router, for direct assertions on the registry. + pub service: Arc, +} + +impl TestApp { + /// Sends `request` to the router. + /// + /// # Errors + /// + /// The underlying `Router` service is infallible, so this never fails. + pub async fn send( + &mut self, + request: axum::http::Request, + ) -> Result { + use tower::ServiceExt; + self.router.clone().oneshot(request).await + } +} + +/// Builds a management router over a fresh registry and `hierarchy`. +#[must_use] +pub fn build_app(config: OagwConfig, hierarchy: Arc) -> TestApp { + let registry = Arc::new(RegistryStore::new(limits())); + let service = Arc::new(ControlPlaneService::new( + Arc::clone(®istry), + Validator::new(config), + hierarchy, + )); + let openapi = OpenApiRegistryImpl::new(); + let router = routes::register_routes(axum::Router::new(), &openapi) + .layer(axum::Extension(Arc::clone(&service))) + .layer(axum::middleware::from_fn(error_source_layer)); + TestApp { router, service } +} + +/// Builds a management router without a tenant hierarchy. +#[must_use] +pub fn build_app_without_hierarchy(config: OagwConfig) -> TestApp { + build_app(config, Arc::new(NoHierarchy)) +} + +/// A data-plane router and everything the tests assert against. +pub struct ProxyApp { + /// Router carrying the proxy and metrics routes. + pub router: axum::Router, + /// Registry the tests seed upstreams and routes into. + pub service: Arc, + /// Outbound engine, for direct endpoint-selection assertions. + pub engine: Arc, + /// Metrics the data plane recorded, for direct rendering assertions. + pub metrics: Arc, +} + +impl ProxyApp { + /// Sends `request` to the router. + /// + /// # Errors + /// + /// The underlying `Router` service is infallible, so this never fails. + pub async fn send( + &mut self, + request: axum::http::Request, + ) -> Result { + use tower::ServiceExt; + self.router.clone().oneshot(request).await + } +} + +/// Builds the data-plane router over a fresh registry. +/// +/// The management routes are mounted on the same router, so a test can seed a +/// resource over REST and then proxy to it, exactly as a caller would. +#[must_use] +pub fn build_proxy_app(config: OagwConfig, hierarchy: Arc) -> ProxyApp { + build_proxy_app_with_resolver(config, hierarchy, Arc::new(UnavailableSecretResolver)) +} + +/// Builds the data-plane router whose auth plugins resolve credentials from +/// `secrets` (keyed by the bare `cred://` reference, see +/// [`StaticSecretResolver`](crate::infra::plugin::StaticSecretResolver)). +#[must_use] +pub fn build_proxy_app_with_secrets( + config: OagwConfig, + hierarchy: Arc, + secrets: HashMap, +) -> ProxyApp { + build_proxy_app_with_resolver( + config, + hierarchy, + Arc::new(crate::infra::plugin::StaticSecretResolver::new(secrets)), + ) +} + +fn build_proxy_app_with_resolver( + config: OagwConfig, + hierarchy: Arc, + resolver: Arc, +) -> ProxyApp { + let registry = Arc::new(RegistryStore::new(limits())); + let service = Arc::new(ControlPlaneService::new( + Arc::clone(®istry), + Validator::new(config.clone()), + hierarchy, + )); + let metrics = Arc::new(MetricsRegistry::new()); + let engine = Arc::new(ProxyEngine::new(&config, Arc::clone(&metrics))); + let plugins = Arc::new(PluginRegistry::with_builtins( + resolver, + TokenCacheConfig::from(&config), + )); + // One registry for the data plane and the store, exactly as the gear + // builder wires them: an upstream deleted in a test must lose its buckets. + let rate_limiters = Arc::new(RateLimiterRegistry::default()); + registry.attach_rate_limiters(Arc::clone(&rate_limiters)); + let plane = DataPlane { + service: Arc::clone(&service), + engine: Arc::clone(&engine), + metrics: Arc::clone(&metrics), + plugins, + rate_limiters, + }; + let openapi = OpenApiRegistryImpl::new(); + let router = routes::register_routes(axum::Router::new(), &openapi) + .layer(axum::Extension(Arc::clone(&service))) + .layer(axum::middleware::from_fn(error_source_layer)) + .merge( + routes::register_proxy_routes(axum::Router::new(), &openapi) + .layer(axum::Extension(plane)) + .layer(axum::middleware::from_fn(error_source_layer)), + ); + ProxyApp { + router, + service, + engine, + metrics, + } +} + +/// Builds a data-plane router without a tenant hierarchy. +#[must_use] +pub fn build_proxy_app_without_hierarchy(config: OagwConfig) -> ProxyApp { + build_proxy_app(config, Arc::new(NoHierarchy)) +} + +/// Builds a proxy request for one data-plane call. +/// +/// # Errors +/// +/// Returns the underlying `http` error, which the test reports as a harness +/// failure. +pub fn proxy_request( + method: &'static str, + path: &str, + ctx: SecurityContext, + headers: &[(&'static str, &str)], +) -> Result, axum::http::Error> { + let mut builder = axum::http::Request::builder() + .method(method) + .uri(path) + .extension(ctx); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + builder.body(axum::body::Body::empty()) +} + +/// Builds an anonymous proxy request: no `SecurityContext` extension, which is +/// how a browser preflight arrives (ADR-0004 "Preflight Request Handling"). +/// +/// # Errors +/// +/// Returns the underlying `http` error, which the test reports as a harness +/// failure. +pub fn anonymous_request( + method: &'static str, + path: &str, + headers: &[(&'static str, &str)], +) -> Result, axum::http::Error> { + let mut builder = axum::http::Request::builder().method(method).uri(path); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + builder.body(axum::body::Body::empty()) +} + +/// Builds an `axum` request for one management call. +/// +/// # Errors +/// +/// Returns the underlying `http` error when the method/URI pair cannot be +/// assembled, which the test reports as a harness failure. +pub fn request( + method: &'static str, + path: &str, + ctx: SecurityContext, + body: Option<&str>, +) -> Result, axum::http::Error> { + let builder = axum::http::Request::builder() + .method(method) + .uri(path) + .extension(ctx); + let Some(payload) = body else { + return builder.body(axum::body::Body::empty()); + }; + builder + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(axum::body::Body::from(payload.to_owned())) +} + +/// A security context for `tenant`. +/// +/// # Errors +/// +/// Returns the underlying builder error, which the test reports as a harness +/// failure. +pub fn caller(tenant: Uuid) -> Result { + SecurityContext::builder() + .subject_id(Uuid::from_u128(0x101)) + .subject_tenant_id(tenant) + .build() +} + +/// Collects the response body as a UTF-8 string. +/// +/// # Errors +/// +/// Returns the underlying body error, which the test reports as a harness +/// failure. +pub async fn body(response: axum::response::Response) -> Result { + let bytes = axum::body::to_bytes(response.into_body(), usize::MAX).await?; + Ok(String::from_utf8_lossy(&bytes).to_string()) +} diff --git a/gears/system/oagw/oagw/src/config.rs b/gears/system/oagw/oagw/src/config.rs new file mode 100644 index 0000000..05fc673 --- /dev/null +++ b/gears/system/oagw/oagw/src/config.rs @@ -0,0 +1,222 @@ +//! Gear configuration for the OAGW outbound API gateway. +//! +//! Operator-facing knobs consumed by [`crate::gear::OagwGear::init`]. The +//! schema is flat (matching `gears.oagw.config` in `config/e2e-local.yaml`) +//! so a deployment only needs to override the keys it cares about. +//! +//! Every key except the ones the e2e config sets is defaulted: the e2e +//! config block is exactly +//! `{proxy_timeout_secs: 2, allow_http_upstream: true, ssrf_policy: {enabled: false}}`, +//! so all other fields MUST carry a `#[serde(default = "...")]` or be +//! reachable through a section-level `default`. Unknown keys are rejected +//! (`deny_unknown_fields`) so a typo surfaces as a loud `init` failure +//! instead of silently ignored configuration. + +use anyhow::bail; +use serde::{Deserialize, Serialize}; + +/// Gear configuration for `cf-gears-oagw`. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct OagwConfig { + /// Data-plane proxy timeout in seconds. Applies to the upstream connect + /// phase and to the overall request/response exchange. `0` is rejected by + /// [`OagwConfig::validate`] (a zero timeout would abort every request). + /// e2e value: `2`. + #[serde(default = "default_proxy_timeout_secs")] + pub proxy_timeout_secs: u64, + + /// Allow plaintext `http` (and `ws`) upstream endpoint schemes. `false` + /// (the default) enforces the HTTPS-only MVP posture from the design + /// constraints; the e2e config enables it so local mock upstreams can be + /// reached without TLS. + #[serde(default)] + pub allow_http_upstream: bool, + + /// Server-side request forgery gate for resolved upstream hosts. Disabled + /// by default; when enabled, slice 4 resolves and pins the target IP and + /// rejects disallowed ranges before opening a connection. + #[serde(default)] + pub ssrf_policy: SsrfPolicy, + + /// OAuth2 client-credentials token cache TTL in seconds (upper bound). + /// See the OAuth2 ADR: the effective TTL is + /// `min(self.token_cache_ttl_secs, expires_in - 30)`. `0` is rejected. + #[serde(default = "default_token_cache_ttl_secs")] + pub token_cache_ttl_secs: u64, + + /// Maximum number of cached upstream access tokens. `0` is rejected. + #[serde(default = "default_token_cache_capacity")] + pub token_cache_capacity: usize, + + /// Control-plane L1 budget for the `upstream:{tenant_id}:{alias}` cache. + /// `0` is rejected (the cache would be permanently empty). + #[serde(default = "default_l1_entries")] + pub upstream_l1_cache_max_entries: usize, + + /// Control-plane L1 budget for the `route:{upstream_id}:{method}:{path_prefix}` cache. + /// `0` is rejected. + #[serde(default = "default_l1_entries")] + pub route_l1_cache_max_entries: usize, + + /// Control-plane L1 budget for the `plugin:{plugin_id}` cache. `0` is rejected. + #[serde(default = "default_l1_entries")] + pub plugin_l1_cache_max_entries: usize, + + /// Data-plane L1 budget for the resolved `(upstream, route)` hot cache. + /// `0` is rejected. + #[serde(default = "default_dp_entries")] + pub dp_cache_max_entries: usize, +} + +impl Default for OagwConfig { + fn default() -> Self { + Self { + proxy_timeout_secs: default_proxy_timeout_secs(), + allow_http_upstream: false, + ssrf_policy: SsrfPolicy::default(), + token_cache_ttl_secs: default_token_cache_ttl_secs(), + token_cache_capacity: default_token_cache_capacity(), + upstream_l1_cache_max_entries: default_l1_entries(), + route_l1_cache_max_entries: default_l1_entries(), + plugin_l1_cache_max_entries: default_l1_entries(), + dp_cache_max_entries: default_dp_entries(), + } + } +} + +impl OagwConfig { + /// Reject configurations that would produce undefined runtime behaviour: + /// zero timeouts (every request would be aborted immediately) and zero + /// cache capacities (every lookup would miss forever). + /// + /// # Errors + /// + /// Returns a message naming every invalid field, so a deployment with + /// several misconfigured knobs sees all of them in one `init` failure. + pub fn validate(&self) -> anyhow::Result<()> { + let mut invalid: Vec = Vec::new(); + + if self.proxy_timeout_secs == 0 { + invalid.push( + "proxy_timeout_secs (must be > 0; zero would abort every request)".to_owned(), + ); + } + if self.token_cache_ttl_secs == 0 { + invalid.push( + "token_cache_ttl_secs (must be > 0; zero would expire every cached token instantly)" + .to_owned(), + ); + } + for (name, value) in [ + ("token_cache_capacity", self.token_cache_capacity), + ( + "upstream_l1_cache_max_entries", + self.upstream_l1_cache_max_entries, + ), + ( + "route_l1_cache_max_entries", + self.route_l1_cache_max_entries, + ), + ( + "plugin_l1_cache_max_entries", + self.plugin_l1_cache_max_entries, + ), + ("dp_cache_max_entries", self.dp_cache_max_entries), + ] { + if value == 0 { + invalid.push(format!( + "{name} (must be > 0; zero makes the cache unusable)" + )); + } + } + invalid.extend(self.ssrf_policy.validate()); + + if invalid.is_empty() { + Ok(()) + } else { + bail!("oagw configuration is invalid: {}", invalid.join("; ")) + } + } + + /// Data-plane proxy timeout as a [`std::time::Duration`]. + #[must_use] + pub const fn proxy_timeout(&self) -> std::time::Duration { + std::time::Duration::from_secs(self.proxy_timeout_secs) + } +} + +/// Server-side request forgery gate. +/// +/// Disabled by default: the design requires HTTPS-only upstreams for the MVP +/// and leaves IP-pinning rules to a separate concern, so the gate only engages +/// when an operator explicitly turns it on. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct SsrfPolicy { + /// Master switch. `false` disables every check below. + pub enabled: bool, + + /// Allow upstream hosts that resolve into private / link-local ranges. + /// `true` (the default) keeps the on-premise deployment posture; set it to + /// `false` together with `enabled` to block loopback, RFC 1918 and + /// link-local targets. + pub allow_private_networks: bool, + + /// Explicit CIDR allowlist (`a.b.c.d/nn`). Entries outside this list are + /// rejected when the policy is enabled and the list is non-empty. + pub allowed_ip_ranges: Vec, +} + +impl Default for SsrfPolicy { + fn default() -> Self { + Self { + enabled: false, + allow_private_networks: true, + allowed_ip_ranges: Vec::new(), + } + } +} + +impl SsrfPolicy { + /// Field-level checks shared with [`OagwConfig::validate`]. + fn validate(&self) -> Vec { + let mut invalid: Vec = Vec::new(); + if !self.enabled { + return invalid; + } + for range in &self.allowed_ip_ranges { + if range.trim().is_empty() { + invalid.push( + "ssrf_policy.allowed_ip_ranges (entries must be non-empty CIDR blocks)" + .to_owned(), + ); + } + } + invalid + } +} + +fn default_proxy_timeout_secs() -> u64 { + 30 +} + +fn default_token_cache_ttl_secs() -> u64 { + 300 +} + +fn default_token_cache_capacity() -> usize { + 10_000 +} + +fn default_l1_entries() -> usize { + 10_000 +} + +fn default_dp_entries() -> usize { + 1_000 +} + +#[cfg(test)] +#[path = "config_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/config_tests.rs b/gears/system/oagw/oagw/src/config_tests.rs new file mode 100644 index 0000000..dfd3d79 --- /dev/null +++ b/gears/system/oagw/oagw/src/config_tests.rs @@ -0,0 +1,139 @@ +//! Tests for [`crate::config`]. + +use crate::config::{OagwConfig, SsrfPolicy}; + +fn e2e_config() -> OagwConfig { + OagwConfig { + proxy_timeout_secs: 2, + allow_http_upstream: true, + ssrf_policy: SsrfPolicy { + enabled: false, + allow_private_networks: true, + allowed_ip_ranges: Vec::new(), + }, + ..OagwConfig::default() + } +} + +#[test] +fn defaults_are_valid_and_documented() { + let config = OagwConfig::default(); + config.validate().expect("defaults must validate"); + + assert_eq!(config.proxy_timeout_secs, 30); + assert!(!config.allow_http_upstream); + assert_eq!(config.token_cache_ttl_secs, 300); + assert_eq!(config.token_cache_capacity, 10_000); + assert_eq!(config.upstream_l1_cache_max_entries, 10_000); + assert_eq!(config.route_l1_cache_max_entries, 10_000); + assert_eq!(config.plugin_l1_cache_max_entries, 10_000); + assert_eq!(config.dp_cache_max_entries, 1_000); + assert!(!config.ssrf_policy.enabled); + assert!(config.ssrf_policy.allow_private_networks); + assert_eq!(config.proxy_timeout(), std::time::Duration::from_secs(30)); +} + +#[test] +fn e2e_config_block_round_trips() { + // Exactly the block from `config/e2e-local.yaml`: three keys, everything + // else defaulted. `deny_unknown_fields` must not reject it. + let raw = serde_json::json!({ + "proxy_timeout_secs": 2, + "allow_http_upstream": true, + "ssrf_policy": { "enabled": false } + }); + let parsed: OagwConfig = serde_json::from_value(raw).expect("e2e block must parse"); + assert_eq!(parsed.proxy_timeout_secs, 2); + assert!(parsed.allow_http_upstream); + assert!(!parsed.ssrf_policy.enabled); + assert_eq!(parsed.token_cache_ttl_secs, 300); + parsed.validate().expect("e2e block must validate"); + + // And the inverse: serialising the e2e config must produce those keys. + let encoded = serde_json::to_value(e2e_config()).expect("config must serialise"); + assert_eq!(encoded["proxy_timeout_secs"], serde_json::json!(2)); + assert_eq!(encoded["allow_http_upstream"], serde_json::json!(true)); + assert_eq!(encoded["ssrf_policy"]["enabled"], serde_json::json!(false)); +} + +#[test] +fn unknown_keys_are_rejected() { + let raw = serde_json::json!({ "proxy_timeout": 5 }); + let error = serde_json::from_value::(raw).expect_err("typo must be rejected"); + assert!(error.to_string().contains("unknown field"), "{error}"); +} + +#[test] +fn ssrf_policy_rejects_unknown_keys_and_empty_ranges() { + let raw = serde_json::json!({ "enabled": true, "allow_list": ["10.0.0.0/8"] }); + let error = serde_json::from_value::(raw).expect_err("typo must be rejected"); + assert!(error.to_string().contains("unknown field"), "{error}"); + + let config = OagwConfig { + ssrf_policy: SsrfPolicy { + enabled: true, + allow_private_networks: true, + allowed_ip_ranges: vec![" ".to_owned()], + }, + ..OagwConfig::default() + }; + let error = config.validate().expect_err("empty CIDR must be rejected"); + assert!(error.to_string().contains("allowed_ip_ranges"), "{error}"); +} + +#[test] +fn every_zero_knob_is_named_by_validate() { + let config = OagwConfig { + proxy_timeout_secs: 0, + token_cache_ttl_secs: 0, + token_cache_capacity: 0, + upstream_l1_cache_max_entries: 0, + route_l1_cache_max_entries: 0, + plugin_l1_cache_max_entries: 0, + dp_cache_max_entries: 0, + ..OagwConfig::default() + }; + let error = config.validate().expect_err("zero knobs must be rejected"); + let message = error.to_string(); + for name in [ + "proxy_timeout_secs", + "token_cache_ttl_secs", + "token_cache_capacity", + "upstream_l1_cache_max_entries", + "route_l1_cache_max_entries", + "plugin_l1_cache_max_entries", + "dp_cache_max_entries", + ] { + assert!(message.contains(name), "{name} missing from: {message}"); + } +} + +#[test] +fn json_round_trip_is_lossless() { + let config = OagwConfig { + proxy_timeout_secs: 7, + allow_http_upstream: true, + ssrf_policy: SsrfPolicy { + enabled: true, + allow_private_networks: false, + allowed_ip_ranges: vec!["10.0.0.0/8".to_owned()], + }, + token_cache_ttl_secs: 60, + token_cache_capacity: 16, + upstream_l1_cache_max_entries: 32, + route_l1_cache_max_entries: 64, + plugin_l1_cache_max_entries: 128, + dp_cache_max_entries: 256, + }; + let encoded = serde_json::to_string(&config).expect("serialise"); + let decoded: OagwConfig = serde_json::from_str(&encoded).expect("deserialise"); + assert_eq!(decoded, config); + config.validate().expect("round trip must stay valid"); +} + +#[test] +fn ssrf_defaults_apply_when_section_is_absent() { + let parsed: OagwConfig = serde_json::from_value(serde_json::json!({})).expect("empty config"); + assert_eq!(parsed.ssrf_policy, SsrfPolicy::default()); + assert_eq!(parsed.ssrf_policy.allowed_ip_ranges, Vec::::new()); +} diff --git a/gears/system/oagw/oagw/src/domain/audit.rs b/gears/system/oagw/oagw/src/domain/audit.rs new file mode 100644 index 0000000..02da3db --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/audit.rs @@ -0,0 +1,387 @@ +//! ADR-0001 structured audit logging for the OAGW control plane. +//! +//! Every control-plane mutation (create, replace, delete) emits one JSON line +//! under the [`AUDIT_TARGET`] tracing target so a collector can pick the +//! records up from stdout regardless of how the subscriber formats plain +//! messages. The payload keeps the ADR-0001 field set: +//! +//! ```json +//! { +//! "timestamp": "2026-02-03T11:09:37.431Z", +//! "level": "INFO", +//! "event": "upstream.create", +//! "request_id": null, +//! "tenant_id": "…", +//! "principal_id": "…", +//! "resource_type": "upstream", +//! "resource_id": "gts.cf.core.oagw.upstream.v1~…", +//! "alias": "api.openai.com", +//! "error_type": null +//! } +//! ``` +//! +//! The data plane emits the same shape for every proxied request, with the +//! DESIGN §4.3 request field set (see [`ProxyExchange`]). +//! +//! The gear has no `time`/`chrono` dependency, so [`format_rfc3339`] renders +//! timestamps from [`std::time::SystemTime`] directly (proleptic Gregorian +//! calendar, Howard Hinnant's `civil_from_days` algorithm). + +use std::time::SystemTime; + +use serde_json::{Map, Value}; +use toolkit_security::SecurityContext; + +/// `tracing` target every OAGW audit line is emitted under. +pub const AUDIT_TARGET: &str = "oagw.audit"; + +/// `level` field of an audit payload; the control plane only logs successful +/// mutations, so it is constant. +pub const AUDIT_LEVEL: &str = "INFO"; + +/// `level` value DESIGN §4.3 gives rate-limit rejections and circuit-breaker +/// openings. +pub const AUDIT_LEVEL_WARN: &str = "WARN"; + +/// `level` value DESIGN §4.3 gives upstream failures, timeouts and auth +/// failures. +pub const AUDIT_LEVEL_ERROR: &str = "ERROR"; + +/// `event` field of a proxied-request record. +pub const PROXY_EVENT: &str = "proxy.request"; + +/// Seconds per day. +const SECS_PER_DAY: i64 = 86_400; + +/// Days from `0000-03-01` to `1970-01-01` (Hinnant's epoch shift). +const DAYS_TO_UNIX_EPOCH: i64 = 719_468; + +/// Renders `time` as an RFC 3339 UTC timestamp with millisecond precision. +/// +/// Pre-epoch instants render with a negative year rather than being clamped, +/// so a caller can never mistake a clamped value for a real one. +#[must_use] +pub fn format_rfc3339(time: SystemTime) -> String { + let (secs, millis) = match time.duration_since(SystemTime::UNIX_EPOCH) { + Ok(delta) => ( + i64::try_from(delta.as_secs()).unwrap_or(i64::MAX), + delta.subsec_millis(), + ), + Err(error) => { + let delta = error.duration(); + let secs = i64::try_from(delta.as_secs()).unwrap_or(i64::MAX); + let millis = delta.subsec_millis(); + if millis == 0 { + (-secs, 0) + } else { + (-secs - 1, 1_000 - millis) + } + } + }; + + let days = secs.div_euclid(SECS_PER_DAY); + let secs_of_day = secs.rem_euclid(SECS_PER_DAY); + let (year, month, day) = civil_from_days(days); + let hour = secs_of_day / 3_600; + let minute = (secs_of_day % 3_600) / 60; + let second = secs_of_day % 60; + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}.{millis:03}Z") +} + +/// Converts a day count relative to the Unix epoch into `(year, month, day)`. +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let shifted = days + DAYS_TO_UNIX_EPOCH; + let era = shifted.div_euclid(146_097); + let day_of_era = shifted.rem_euclid(146_097); + let year_of_era = + (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) / 365; + let year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let mp = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * mp + 2) / 5 + 1; + let month = if mp < 10 { mp + 3 } else { mp - 9 }; + let year = if month <= 2 { year + 1 } else { year }; + ( + year, + u32::try_from(month).unwrap_or(1), + u32::try_from(day).unwrap_or(1), + ) +} + +/// Builds the ADR-0001 audit payload for one control-plane mutation. +/// +/// Pure on purpose: [`log_mutation`] only adds the `tracing` emission, so unit +/// tests can assert on the payload itself. +#[must_use] +pub fn mutation_event( + event: &str, + ctx: &SecurityContext, + resource_type: &str, + resource_id: &str, + request_id: Option<&str>, + alias: Option<&str>, + detail: Option<&str>, +) -> Value { + let mut payload = Map::new(); + payload.insert( + "timestamp".to_owned(), + Value::String(format_rfc3339(SystemTime::now())), + ); + payload.insert("level".to_owned(), Value::String(AUDIT_LEVEL.to_owned())); + payload.insert("event".to_owned(), Value::String(event.to_owned())); + payload.insert( + "request_id".to_owned(), + request_id.map_or(Value::Null, |id| Value::String(id.to_owned())), + ); + payload.insert( + "tenant_id".to_owned(), + Value::String(ctx.subject_tenant_id().to_string()), + ); + payload.insert( + "principal_id".to_owned(), + Value::String(ctx.subject_id().to_string()), + ); + payload.insert( + "resource_type".to_owned(), + Value::String(resource_type.to_owned()), + ); + payload.insert( + "resource_id".to_owned(), + Value::String(resource_id.to_owned()), + ); + payload.insert( + "alias".to_owned(), + alias.map_or(Value::Null, |value| Value::String(value.to_owned())), + ); + payload.insert( + "detail".to_owned(), + detail.map_or(Value::Null, |value| Value::String(value.to_owned())), + ); + payload.insert("error_type".to_owned(), Value::Null); + Value::Object(payload) +} + +/// Emits the ADR-0001 audit line for a control-plane mutation. +pub fn log_mutation( + event: &str, + ctx: &SecurityContext, + resource_type: &str, + resource_id: &str, + request_id: Option<&str>, + alias: Option<&str>, + detail: Option<&str>, +) { + let payload = mutation_event( + event, + ctx, + resource_type, + resource_id, + request_id, + alias, + detail, + ); + tracing::info!(target: AUDIT_TARGET, event = %event, "{payload}"); +} + +/// Which side produced the answer of a proxied request, in the ADR-0007 +/// `X-OAGW-Error-Source` vocabulary. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ProxyOutcome { + /// The gateway answered without an upstream answer. + #[default] + Gateway, + /// The upstream answered. + Upstream, +} + +impl ProxyOutcome { + /// The JSON spelling of the outcome. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Gateway => "gateway", + Self::Upstream => "upstream", + } + } +} + +/// The DESIGN §4.3 request field set of one proxied request. +/// +/// [`log_proxy`] renders it as one JSON line under [`AUDIT_TARGET`]; the +/// collector joins it with the control-plane records on `request_id`. No body, +/// query parameter, header or credential is ever recorded here (DESIGN §4.3 +/// "No PII" / "No secrets"): `path` is the *outbound* path the route rewrite +/// produced, without its query string. +#[derive(Debug, Clone, Default)] +pub struct ProxyExchange { + /// Correlation id of the request, echoed from the client or generated by a + /// plugin. + pub request_id: Option, + /// Tenant of the authenticated caller. + pub tenant_id: Option, + /// Principal of the authenticated caller. + pub principal_id: Option, + /// Proxy alias the request addressed. + pub alias: String, + /// Target host the route resolved to. + pub host: String, + /// Metric label of the matched route (see + /// [`crate::domain::metrics::route_label`]); [`UNMATCHED_ROUTE`] when no + /// route resolved. + pub route: String, + /// Upstream endpoint the request was actually sent to. + pub endpoint: String, + /// HTTP method of the request. + pub method: String, + /// Outbound path, after the route rewrite and without its query. + pub outbound_path: String, + /// Response status the client received. + pub status: u16, + /// Milliseconds from the request reaching the handler to the answer. + pub duration_ms: u64, + /// Bytes of the request body (`0` when it was not buffered). + pub request_size: u64, + /// Bytes of the response body as the upstream declared them (`0` when it + /// declared none or streamed them). + pub response_size: u64, + /// Which side produced the answer. + pub outcome: ProxyOutcome, + /// GTS error type of a failed request. + pub error_type: Option, + /// Human-readable message of a failed request. + pub error_message: Option, +} + +impl ProxyExchange { + /// Starts the record of one proxied request, before a route is known. + #[must_use] + pub fn new(alias: &str, method: &str) -> Self { + Self { + alias: alias.to_owned(), + method: method.to_owned(), + route: crate::domain::metrics::UNMATCHED_ROUTE.to_owned(), + ..Self::default() + } + } + + /// The DESIGN §4.3 log level of the record. + /// + /// | Outcome | Status | Level | + /// |---|---|---| + /// | `upstream` | `5xx` | `ERROR` (§4.3 "upstream failures, timeouts") | + /// | `upstream` | `2xx`/`3xx`/`4xx` | `INFO` (a normal operation: the + /// gateway forwarded it and the caller reads the status itself) | + /// | `gateway` | `401`/`403` | `ERROR` (auth failure) | + /// | `gateway` | `429` | `WARN` (rate limit exceeded) | + /// | `gateway` | other `5xx` | `ERROR` (upstream failure, timeout) | + /// | `gateway` | anything else | `INFO` (`404` route, `400` validation, + /// `204` preflight) | + #[must_use] + pub fn level(&self) -> &'static str { + match self.status { + 401 | 403 => AUDIT_LEVEL_ERROR, + 429 => AUDIT_LEVEL_WARN, + status if status >= 500 => AUDIT_LEVEL_ERROR, + _ => AUDIT_LEVEL, + } + } +} + +/// Builds the DESIGN §4.3 audit payload of one proxied request. +/// +/// Pure on purpose: [`log_proxy`] only adds the `tracing` emission and the +/// level dispatch, so unit tests can assert on the payload itself. +#[must_use] +pub fn proxy_event(exchange: &ProxyExchange) -> Value { + let mut payload = Map::new(); + payload.insert( + "timestamp".to_owned(), + Value::String(format_rfc3339(SystemTime::now())), + ); + payload.insert( + "level".to_owned(), + Value::String(exchange.level().to_owned()), + ); + payload.insert("event".to_owned(), Value::String(PROXY_EVENT.to_owned())); + payload.insert( + "request_id".to_owned(), + exchange + .request_id + .clone() + .map_or(Value::Null, Value::String), + ); + payload.insert( + "tenant_id".to_owned(), + exchange + .tenant_id + .clone() + .map_or(Value::Null, Value::String), + ); + payload.insert( + "principal_id".to_owned(), + exchange + .principal_id + .clone() + .map_or(Value::Null, Value::String), + ); + payload.insert("alias".to_owned(), Value::String(exchange.alias.clone())); + payload.insert("host".to_owned(), Value::String(exchange.host.clone())); + payload.insert("route".to_owned(), Value::String(exchange.route.clone())); + payload.insert( + "endpoint".to_owned(), + Value::String(exchange.endpoint.clone()), + ); + payload.insert("method".to_owned(), Value::String(exchange.method.clone())); + payload.insert( + "path".to_owned(), + Value::String(exchange.outbound_path.clone()), + ); + payload.insert("status".to_owned(), Value::from(exchange.status)); + payload.insert("duration_ms".to_owned(), Value::from(exchange.duration_ms)); + payload.insert( + "request_size".to_owned(), + Value::from(exchange.request_size), + ); + payload.insert( + "response_size".to_owned(), + Value::from(exchange.response_size), + ); + payload.insert( + "outcome".to_owned(), + Value::String(exchange.outcome.as_str().to_owned()), + ); + payload.insert( + "error_type".to_owned(), + exchange + .error_type + .clone() + .map_or(Value::Null, Value::String), + ); + payload.insert( + "error_message".to_owned(), + exchange + .error_message + .clone() + .map_or(Value::Null, Value::String), + ); + Value::Object(payload) +} + +/// Emits the DESIGN §4.3 audit line of one proxied request, at the level the +/// design assigns to its outcome. +pub fn log_proxy(exchange: &ProxyExchange) { + let payload = proxy_event(exchange); + match exchange.level() { + AUDIT_LEVEL_WARN => { + tracing::warn!(target: AUDIT_TARGET, event = PROXY_EVENT, "{payload}") + } + AUDIT_LEVEL_ERROR => { + tracing::error!(target: AUDIT_TARGET, event = PROXY_EVENT, "{payload}") + } + _ => tracing::info!(target: AUDIT_TARGET, event = PROXY_EVENT, "{payload}"), + } +} + +#[cfg(test)] +#[path = "audit_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/audit_tests.rs b/gears/system/oagw/oagw/src/domain/audit_tests.rs new file mode 100644 index 0000000..f921cb3 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/audit_tests.rs @@ -0,0 +1,222 @@ +//! Tests for [`crate::domain::audit`]. + +use std::time::{Duration, SystemTime}; + +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use serde_json::Value; + +use super::{ + AUDIT_LEVEL, AUDIT_TARGET, PROXY_EVENT, ProxyExchange, ProxyOutcome, format_rfc3339, + log_mutation, log_proxy, mutation_event, proxy_event, +}; + +fn ctx(tenant: Uuid, subject: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(subject) + .subject_tenant_id(tenant) + .build() + .expect("security context") +} + +#[test] +fn rfc3339_renders_known_instants() { + let epoch = format_rfc3339(SystemTime::UNIX_EPOCH); + assert_eq!(epoch, "1970-01-01T00:00:00.000Z"); + + // 2026-02-03T11:09:37.431Z is the ADR-0001 example instant. + let instant = SystemTime::UNIX_EPOCH + Duration::from_millis(1_770_116_977_431); + assert_eq!(format_rfc3339(instant), "2026-02-03T11:09:37.431Z"); + + // Leap-year day and end-of-year rollover both stay on the proleptic + // Gregorian calendar. + let leap = SystemTime::UNIX_EPOCH + Duration::from_millis(1_709_164_800_000); + assert_eq!(format_rfc3339(leap), "2024-02-29T00:00:00.000Z"); + let nye = SystemTime::UNIX_EPOCH + Duration::from_millis(1_767_225_599_999); + assert_eq!(format_rfc3339(nye), "2025-12-31T23:59:59.999Z"); +} + +#[test] +fn mutation_event_carries_the_adr_field_set() { + let tenant = Uuid::from_u128(0x10); + let subject = Uuid::from_u128(0x20); + let payload = mutation_event( + "upstream.create", + &ctx(tenant, subject), + "upstream", + "gts.cf.core.oagw.upstream.v1~00000000-0000-0000-0000-000000000001", + None, + Some("api.openai.com"), + None, + ); + + assert_eq!(payload["level"], AUDIT_LEVEL); + assert_eq!(payload["event"], "upstream.create"); + assert_eq!(payload["tenant_id"], tenant.to_string()); + assert_eq!(payload["principal_id"], subject.to_string()); + assert_eq!(payload["resource_type"], "upstream"); + assert_eq!( + payload["resource_id"], + "gts.cf.core.oagw.upstream.v1~00000000-0000-0000-0000-000000000001" + ); + assert_eq!(payload["alias"], "api.openai.com"); + assert!(payload["request_id"].is_null()); + assert!(payload["detail"].is_null()); + assert!(payload["error_type"].is_null()); + assert!(payload["timestamp"].is_string(), "timestamp is rendered"); +} + +#[test] +fn mutation_event_is_serialisable_and_target_is_stable() { + let payload = mutation_event( + "route.delete", + &ctx(Uuid::from_u128(0x10), Uuid::from_u128(0x20)), + "route", + "gts.cf.core.oagw.route.v1~00000000-0000-0000-0000-000000000002", + Some("req_1"), + None, + Some("cascade from upstream delete"), + ); + let rendered = serde_json::to_string(&payload).expect("payload is JSON"); + assert!( + rendered.contains("\"event\":\"route.delete\""), + "{rendered}" + ); + assert!(rendered.contains("\"request_id\":\"req_1\""), "{rendered}"); + assert!(rendered.contains("\"detail\":\"cascade from upstream delete\"")); + assert_eq!(AUDIT_TARGET, "oagw.audit"); +} + +#[test] +fn log_mutation_does_not_panic() { + log_mutation( + "plugin.create", + &ctx(Uuid::from_u128(0x10), Uuid::from_u128(0x20)), + "plugin", + "gts.cf.core.oagw.guard_plugin.v1~00000000-0000-0000-0000-000000000003", + None, + None, + None, + ); +} + +/// A synthetic proxied exchange, fully populated. +fn proxied_exchange(outcome: ProxyOutcome) -> ProxyExchange { + ProxyExchange { + request_id: Some("req_proxy_1".to_owned()), + tenant_id: Some(Uuid::from_u128(0x10).to_string()), + principal_id: Some(Uuid::from_u128(0x20).to_string()), + alias: "api.openai.com".to_owned(), + host: "api.openai.com".to_owned(), + route: "GET /v1/orders".to_owned(), + endpoint: "https://10.0.0.7:8443".to_owned(), + method: "GET".to_owned(), + outbound_path: "/v1/orders/42".to_owned(), + status: 200, + duration_ms: 12, + request_size: 0, + response_size: 2048, + outcome, + error_type: None, + error_message: None, + } +} + +#[test] +fn proxy_event_carries_the_design_field_set() { + let payload = proxy_event(&proxied_exchange(ProxyOutcome::Upstream)); + // DESIGN §4.3: every field of the request record is present and non-empty. + for field in [ + "timestamp", + "level", + "event", + "request_id", + "tenant_id", + "principal_id", + "host", + "path", + "method", + "status", + "duration_ms", + "request_size", + "response_size", + ] { + let value = &payload[field]; + assert!( + !value.is_null(), + "field '{field}' is missing from the record" + ); + match value { + Value::String(text) => assert!(!text.is_empty(), "field '{field}' is empty"), + Value::Number(number) => { + assert!(number.as_u64().is_some(), "field '{field}' is not integral") + } + other => panic!("field '{field}' is neither a string nor a number: {other}"), + } + } + // The gateway-side fields the design names per proxy concern. + assert_eq!(payload["event"], PROXY_EVENT); + assert_eq!(payload["alias"], "api.openai.com"); + assert_eq!(payload["route"], "GET /v1/orders"); + assert_eq!(payload["endpoint"], "https://10.0.0.7:8443"); + assert_eq!(payload["outcome"], "upstream"); + assert!(payload["error_type"].is_null()); + assert!(payload["error_message"].is_null()); +} + +#[test] +fn proxy_event_reports_gateway_failures() { + let mut exchange = proxied_exchange(ProxyOutcome::Gateway); + exchange.status = 429; + exchange.error_type = + Some("gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1".to_owned()); + exchange.error_message = Some("the rate budget of this upstream is exhausted".to_owned()); + let payload = proxy_event(&exchange); + assert_eq!(payload["status"], 429); + assert_eq!(payload["outcome"], "gateway"); + assert_eq!( + payload["error_type"], + "gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1" + ); + assert_eq!( + payload["error_message"], + "the rate budget of this upstream is exhausted" + ); + assert!(serde_json::to_string(&payload).is_ok(), "payload is JSON"); +} + +#[test] +fn proxy_levels_follow_the_design_table() { + // An upstream answer is a normal operation unless the upstream failed. + assert_eq!(proxied_exchange(ProxyOutcome::Upstream).level(), "INFO"); + let mut upstream_client_error = proxied_exchange(ProxyOutcome::Upstream); + upstream_client_error.status = 404; + assert_eq!(upstream_client_error.level(), "INFO"); + let mut upstream_redirect = proxied_exchange(ProxyOutcome::Upstream); + upstream_redirect.status = 302; + assert_eq!(upstream_redirect.level(), "INFO"); + // §4.3 "ERROR": upstream failures, timeouts. + let mut upstream_error = proxied_exchange(ProxyOutcome::Upstream); + upstream_error.status = 503; + assert_eq!(upstream_error.level(), "ERROR"); + // Gateway answers are graded by cause (§4.3 "Log Levels"). + let mut auth_failure = proxied_exchange(ProxyOutcome::Gateway); + auth_failure.status = 401; + assert_eq!(auth_failure.level(), "ERROR"); + let mut rate_limited = proxied_exchange(ProxyOutcome::Gateway); + rate_limited.status = 429; + assert_eq!(rate_limited.level(), "WARN"); + let mut upstream_unreachable = proxied_exchange(ProxyOutcome::Gateway); + upstream_unreachable.status = 502; + assert_eq!(upstream_unreachable.level(), "ERROR"); + let mut no_route = proxied_exchange(ProxyOutcome::Gateway); + no_route.status = 404; + assert_eq!(no_route.level(), "INFO"); +} + +#[test] +fn log_proxy_does_not_panic() { + log_proxy(&proxied_exchange(ProxyOutcome::Gateway)); + log_proxy(&proxied_exchange(ProxyOutcome::Upstream)); +} diff --git a/gears/system/oagw/oagw/src/domain/cors.rs b/gears/system/oagw/oagw/src/domain/cors.rs new file mode 100644 index 0000000..ea4d868 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/cors.rs @@ -0,0 +1,543 @@ +//! CORS (ADR-0004): the built-in CORS handler's decision logic. +//! +//! The functions here are pure: they take an origin, a method and the +//! effective configuration and return either a decision or the exact response +//! headers to emit. No axum router or service state is involved, so slice 4 +//! maps the returned [`PreflightResponse`] / header pairs onto its response +//! parts directly. +//! +//! ## Security posture (ADR-0004) +//! +//! * CORS is off unless `enabled` is set; +//! * origin matching is an exact, byte-for-byte comparison — no patterns, and +//! scheme/host case is *not* normalised, so an operator must list origins +//! exactly as browsers send them; +//! * matching is port- and protocol-sensitive (`https://a.example.com:443` is +//! a different origin from `http://a.example.com` and from +//! `https://a.example.com:8443`); +//! * `*` matches every origin but cannot be combined with +//! `allow_credentials`; +//! * `Vary: Origin` is emitted on every CORS-affected response. +//! +//! ## Preflight +//! +//! A preflight (`OPTIONS` + `Origin` + `Access-Control-Request-Method`) is +//! answered locally with a permissive `204` that *echoes* the requested +//! origin, method and headers; origin and method enforcement happens on the +//! subsequent actual request. Preflight requests bypass per-request auth and +//! plugin checks but remain subject to infrastructure-level controls. A +//! preflight no configured upstream owns — the caller is unauthenticated, the +//! alias does not resolve — gets [`unresolved_preflight`]: the same `204`, but +//! without an `Access-Control-Allow-Origin`, because no configuration was +//! available to verify a grant against. +//! +//! ## Inheritance +//! +//! [`merge_cors`] walks an ancestor → descendant chain keyed on the *outer* +//! entry's `sharing` mode: `private` hides the parent from descendants, +//! `inherit` unions the origin (and header) sets, `enforce` keeps the parent's +//! set and drops the child's additions. Because the union of two individually +//! valid drafts is not itself validated, the merged configuration is checked +//! against the credentials rule again: a wildcard origin in the merged set +//! always disables `allow_credentials`. + +use axum::http::{HeaderName, HeaderValue}; + +use crate::domain::error::OagwError; +use crate::domain::model::{CorsConfig, CorsMethod, SharingMode}; +use crate::domain::plugin::cors_error; + +/// `Access-Control-Max-Age` the ADR-0004 pins the preflight response to. +pub const PREFLIGHT_MAX_AGE: &str = "86400"; + +/// `Access-Control-Allow-Origin` header name. +pub const ALLOW_ORIGIN_HEADER: &str = "access-control-allow-origin"; +/// `Access-Control-Allow-Methods` header name. +pub const ALLOW_METHODS_HEADER: &str = "access-control-allow-methods"; +/// `Access-Control-Allow-Headers` header name. +pub const ALLOW_HEADERS_HEADER: &str = "access-control-allow-headers"; +/// `Access-Control-Allow-Credentials` header name. +pub const ALLOW_CREDENTIALS_HEADER: &str = "access-control-allow-credentials"; +/// `Access-Control-Expose-Headers` header name. +pub const EXPOSE_HEADERS_HEADER: &str = "access-control-expose-headers"; +/// `Access-Control-Max-Age` header name. +pub const MAX_AGE_HEADER: &str = "access-control-max-age"; +/// `Vary` header name. +pub const VARY_HEADER: &str = "vary"; +/// Inbound `Origin` header name. +pub const ORIGIN_HEADER: &str = "origin"; +/// Inbound `Access-Control-Request-Method` header name. +pub const REQUEST_METHOD_HEADER: &str = "access-control-request-method"; +/// Inbound `Access-Control-Request-Headers` header name. +pub const REQUEST_HEADERS_HEADER: &str = "access-control-request-headers"; + +/// Wildcard origin: matches every origin, never with credentials. +pub const WILDCARD_ORIGIN: &str = "*"; + +/// `Vary` value of a preflight response (ADR-0004). +pub const PREFLIGHT_VARY: &str = + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"; + +/// `Vary` value of an actual CORS response (ADR-0004). +pub const ACTUAL_VARY: &str = "Origin"; + +/// GTS error type id of a rejected origin (ADR-0004). +pub const CORS_ORIGIN_NOT_ALLOWED_TYPE: &str = + "gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1"; + +/// GTS error type id of a rejected method (ADR-0004). +pub const CORS_METHOD_NOT_ALLOWED_TYPE: &str = + "gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1"; + +/// Default `allowed_methods` when the configuration omits the field (the +/// ADR-0004 schema default). +const DEFAULT_ALLOWED_METHODS: [&str; 2] = ["GET", "POST"]; + +/// Fully resolved CORS configuration. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct EffectiveCorsConfig { + /// Master switch; `false` disables every CORS response header. + pub enabled: bool, + /// Allowed origins; `["*"]` allows any origin. + pub allowed_origins: Vec, + /// Allowed methods, upper-case. + pub allowed_methods: Vec, + /// Headers accepted on preflight (echoed when the browser asks for others). + pub allow_headers: Vec, + /// Headers exposed to the browser beyond the CORS-safelisted set. + pub expose_headers: Vec, + /// Allow credentials; requires non-wildcard origins. + pub allow_credentials: bool, + /// `Access-Control-Max-Age` override in seconds. + pub max_age: Option, +} + +impl EffectiveCorsConfig { + /// `true` when `origin` is allowed. + /// + /// The comparison trims surrounding ASCII whitespace and is otherwise an + /// exact byte match (see the module docs). + #[must_use] + pub fn is_origin_allowed(&self, origin: &str) -> bool { + let candidate = origin.trim(); + if candidate.is_empty() { + return false; + } + self.allowed_origins + .iter() + .any(|allowed| allowed.trim() == candidate || allowed.trim() == WILDCARD_ORIGIN) + } + + /// `true` when `method` is allowed (case-insensitive on the method name). + #[must_use] + pub fn is_method_allowed(&self, method: &str) -> bool { + let candidate = method.trim().to_ascii_uppercase(); + !candidate.is_empty() && self.allowed_methods.contains(&candidate) + } + + /// `true` when the configuration allows every origin. + #[must_use] + pub fn is_wildcard(&self) -> bool { + self.allowed_origins + .iter() + .any(|allowed| allowed.trim() == WILDCARD_ORIGIN) + } + + /// `Access-Control-Max-Age` value, defaulting to the ADR-0004 constant. + #[must_use] + pub fn max_age(&self) -> String { + self.max_age + .map(|seconds| seconds.to_string()) + .unwrap_or_else(|| PREFLIGHT_MAX_AGE.to_owned()) + } +} + +impl From<&CorsConfig> for EffectiveCorsConfig { + fn from(config: &CorsConfig) -> Self { + let mut allowed_methods: Vec = config + .allowed_methods + .iter() + .map(|method| cors_method_name(method).to_owned()) + .collect(); + if allowed_methods.is_empty() { + allowed_methods = DEFAULT_ALLOWED_METHODS + .iter() + .map(|m| (*m).to_owned()) + .collect(); + } + Self { + enabled: config.enabled, + allowed_origins: config.allowed_origins.clone(), + allowed_methods, + allow_headers: config.allow_headers.clone(), + expose_headers: config.expose_headers.clone(), + allow_credentials: config.allow_credentials, + max_age: config.max_age, + } + } +} + +/// Wire spelling of a configured CORS method. +#[must_use] +pub const fn cors_method_name(method: &CorsMethod) -> &'static str { + match method { + CorsMethod::Get => "GET", + CorsMethod::Post => "POST", + CorsMethod::Put => "PUT", + CorsMethod::Patch => "PATCH", + CorsMethod::Delete => "DELETE", + CorsMethod::Head => "HEAD", + CorsMethod::Options => "OPTIONS", + } +} + +/// Rejects a configuration that combines `allow_credentials` with the wildcard +/// origin (ADR-0004 security restriction). +/// +/// # Errors +/// +/// Returns [`OagwError::validation`] naming the offending combination. +pub fn validate_cors_config(config: &CorsConfig) -> Result<(), OagwError> { + if config.allow_credentials + && config + .allowed_origins + .iter() + .any(|origin| origin.trim() == WILDCARD_ORIGIN) + { + return Err( + OagwError::validation("cannot use allow_credentials with wildcard origin") + .with_invalid_value(WILDCARD_ORIGIN), + ); + } + Ok(()) +} + +/// Resolves the effective CORS configuration over an ancestor → descendant +/// chain, keyed on the *outer* entry's sharing mode. +/// +/// Entries with `enabled == false` contribute nothing: a disabled hop is +/// invisible to its descendants rather than widening or narrowing them. +/// +/// The result is re-checked against the ADR-0004 credentials rule +/// ([`enforce_no_credentials_with_wildcard`]): a merged configuration is +/// assembled from drafts that each validated on their own, so the union it +/// forms is never itself passed through [`validate_cors_config`]. +/// +/// Returns `None` when no entry contributes a configuration. +#[must_use] +pub fn merge_cors(chain: &[&CorsConfig]) -> Option { + let mut effective: Option = None; + let mut previous_sharing: Option = None; + for config in chain { + if !config.enabled { + continue; + } + let own = EffectiveCorsConfig::from(*config); + effective = Some(match (effective, previous_sharing) { + (None, _) => own, + (Some(_parent), Some(SharingMode::Private)) => own, + (Some(parent), Some(SharingMode::Inherit)) => union(&parent, &own), + (Some(parent), Some(SharingMode::Enforce)) => parent, + (Some(parent), None) => parent, + }); + previous_sharing = Some(config.sharing); + } + effective.map(enforce_no_credentials_with_wildcard) +} + +/// Applies the ADR-0004 rule "cannot use `allow_credentials` with wildcard +/// origin" to an already-assembled effective configuration. +/// +/// [`validate_cors_config`] enforces the rule on a *single* draft, at write +/// time. A merged configuration is built from two such drafts, and the union of +/// two individually valid drafts can be invalid together: +/// +/// ```text +/// upstream: { sharing: "inherit", allowed_origins: ["*"], allow_credentials: false } +/// route: { allowed_origins: ["https://app.example.com"], allow_credentials: true } +/// merged: allowed_origins: ["*", "https://app.example.com"], allow_credentials: true +/// ``` +/// +/// Merged configurations are never re-validated, so the invariant is enforced +/// here, where the merge happens. **The wildcard wins and the credential grant +/// is dropped**: `actual_response_headers` then answers the literal `*` — which +/// a browser refuses to pair with credentials — and never emits +/// `Access-Control-Allow-Credentials`, so no response can carry an echoed +/// (attacker-chosen) origin next to a credential grant. Dropping the wildcard +/// instead would keep the credentials but silently lock out every origin the +/// wildcard already serves, which is the wider regression. +fn enforce_no_credentials_with_wildcard(mut config: EffectiveCorsConfig) -> EffectiveCorsConfig { + if config.is_wildcard() && config.allow_credentials { + config.allow_credentials = false; + } + config +} + +fn union(parent: &EffectiveCorsConfig, child: &EffectiveCorsConfig) -> EffectiveCorsConfig { + EffectiveCorsConfig { + enabled: child.enabled || parent.enabled, + allowed_origins: union_lists(&parent.allowed_origins, &child.allowed_origins), + allowed_methods: union_lists(&parent.allowed_methods, &child.allowed_methods), + allow_headers: union_lists(&parent.allow_headers, &child.allow_headers), + expose_headers: union_lists(&parent.expose_headers, &child.expose_headers), + allow_credentials: parent.allow_credentials || child.allow_credentials, + max_age: child.max_age.or(parent.max_age), + } +} + +fn union_lists(parent: &[String], child: &[String]) -> Vec { + let mut merged: Vec = Vec::with_capacity(parent.len() + child.len()); + for value in parent.iter().chain(child.iter()) { + if !merged.contains(value) { + merged.push(value.clone()); + } + } + merged +} + +// --------------------------------------------------------------------------- +// Preflight +// --------------------------------------------------------------------------- + +/// A locally answered CORS preflight. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PreflightResponse { + /// HTTP status (`204 No Content`). + pub status: u16, + /// Response headers, in emission order. + pub headers: Vec<(HeaderName, HeaderValue)>, + /// `true` when the preflight grants the requested cross-origin access. + /// + /// The response stays permissive either way (ADR-0004 echoes the request), + /// so this flag — not the presence of an allow header — is the verdict a + /// caller should log or surface. + pub allowed: bool, +} + +impl PreflightResponse { + /// `true` when the preflight grants the requested cross-origin access. + #[must_use] + pub const fn is_allowed(&self) -> bool { + self.allowed + } + + /// Value of a single header, for assertions and for the proxy handler. + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + self.headers + .iter() + .find(|(candidate, _)| candidate.as_str() == name) + .map(|(_, value)| value.to_str().unwrap_or_default()) + } +} + +/// Evaluates a CORS preflight request (ADR-0004 "Preflight Request Handling"). +/// +/// The response is permissive: the requested origin, method and headers are +/// echoed back and enforcement is deferred to the actual request. When CORS is +/// disabled (or the request carries no `Origin`) only the `Vary` triplet is +/// returned, so a misrouted preflight never advertises a cross-origin grant. +#[must_use] +pub fn evaluate_preflight( + config: &EffectiveCorsConfig, + origin: Option<&str>, + request_method: Option<&str>, + request_headers: Option<&str>, +) -> PreflightResponse { + let mut headers = vec![vary_header(PREFLIGHT_VARY)]; + let granted = config.enabled + && origin + .map(str::trim) + .is_some_and(|origin| !origin.is_empty() && config.is_origin_allowed(origin)); + let Some(origin) = origin.map(str::trim).filter(|value| !value.is_empty()) else { + return PreflightResponse { + status: 204, + headers, + allowed: false, + }; + }; + if !config.enabled { + return PreflightResponse { + status: 204, + headers, + allowed: false, + }; + } + + headers.push(header(ALLOW_ORIGIN_HEADER, origin)); + let methods = request_method + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_uppercase()) + .unwrap_or_else(|| config.allowed_methods.join(", ")); + headers.push(header(ALLOW_METHODS_HEADER, &methods)); + let requested_headers = request_headers + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| config.allow_headers.join(", ")); + headers.push(header(ALLOW_HEADERS_HEADER, &requested_headers)); + if config.allow_credentials { + headers.push(header(ALLOW_CREDENTIALS_HEADER, "true")); + } + headers.push(header(MAX_AGE_HEADER, &config.max_age())); + PreflightResponse { + status: 204, + headers, + allowed: granted, + } +} + +/// `true` when the inbound request is a CORS preflight. +#[must_use] +pub fn is_preflight(method: &str, origin: Option<&str>, request_method: Option<&str>) -> bool { + method.eq_ignore_ascii_case("OPTIONS") + && origin.is_some_and(|value| !value.trim().is_empty()) + && request_method.is_some_and(|value| !value.trim().is_empty()) +} + +/// `Access-Control-Allow-Methods` of a preflight the gateway cannot bind to a +/// configured upstream: every method the proxy surface forwards, so the browser +/// retries the actual request and the gateway enforces the origin there. +pub const FALLBACK_ALLOWED_METHODS: &str = "GET, HEAD, POST, PUT, PATCH, DELETE"; + +/// `Access-Control-Allow-Headers` of such a preflight when the browser named +/// none, i.e. the headers a cross-origin caller needs for an authenticated +/// gateway request. +pub const FALLBACK_ALLOW_HEADERS: &str = "authorization, content-type, x-request-id"; + +/// Answers a preflight that cannot be bound to a configured upstream +/// (ADR-0004 "Preflight Request Handling"). +/// +/// A browser preflight carries no credentials, so the gateway may have no +/// tenant context and must still answer `204` locally instead of failing it +/// with `401` or `404`. No CORS configuration is available in that case, so +/// the answer echoes the requested method and headers, pins the ADR-0004 +/// max-age and carries the `Vary` triplet — but **no** +/// `Access-Control-Allow-Origin`: a grant the gateway could not verify against +/// a configuration is never advertised, and the browser therefore blocks the +/// cross-origin read. Enforcement still happens on the actual request. +#[must_use] +pub fn unresolved_preflight( + request_method: Option<&str>, + request_headers: Option<&str>, +) -> PreflightResponse { + let mut headers = vec![vary_header(PREFLIGHT_VARY)]; + let methods = request_method + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(|value| value.to_ascii_uppercase()) + .unwrap_or_else(|| FALLBACK_ALLOWED_METHODS.to_owned()); + headers.push(header(ALLOW_METHODS_HEADER, &methods)); + let requested = request_headers + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| FALLBACK_ALLOW_HEADERS.to_owned()); + headers.push(header(ALLOW_HEADERS_HEADER, &requested)); + headers.push(header(MAX_AGE_HEADER, PREFLIGHT_MAX_AGE)); + PreflightResponse { + status: 204, + headers, + allowed: false, + } +} + +// --------------------------------------------------------------------------- +// Actual requests +// --------------------------------------------------------------------------- + +/// Validates an actual cross-origin request (ADR-0004 "Actual Request +/// Handling"). +/// +/// Same-origin requests (no `Origin` header) and requests to a CORS-disabled +/// upstream are never rejected here: CORS only governs cross-origin access. +/// +/// # Errors +/// +/// Returns a `403` problem document with the ADR-0004 GTS error id when the +/// origin or the method is not allowed. +pub fn validate_actual_request( + config: &EffectiveCorsConfig, + origin: Option<&str>, + method: &str, +) -> Result<(), OagwError> { + if !config.enabled { + return Ok(()); + } + let Some(origin) = origin.map(str::trim).filter(|value| !value.is_empty()) else { + return Ok(()); + }; + if !config.is_origin_allowed(origin) { + return Err(cors_error( + CORS_ORIGIN_NOT_ALLOWED_TYPE, + "CORS Origin Not Allowed", + format!("Origin '{origin}' not in allowed origins list"), + origin.to_owned(), + )); + } + if !config.is_method_allowed(method) { + return Err(cors_error( + CORS_METHOD_NOT_ALLOWED_TYPE, + "CORS Method Not Allowed", + format!("Method '{}' not in allowed methods list", method.trim()), + method.trim().to_owned(), + )); + } + Ok(()) +} + +/// Response headers an actual CORS request contributes to the upstream +/// response. +/// +/// Returns the `Vary: Origin` header plus the allow/expose set. The list is +/// empty when CORS is disabled or when the origin is not allowed: a rejected +/// request never reaches the upstream (its `403` carries its own headers) and +/// a same-origin request gets no CORS headers. +#[must_use] +pub fn actual_response_headers( + config: &EffectiveCorsConfig, + origin: Option<&str>, +) -> Vec<(HeaderName, HeaderValue)> { + if !config.enabled { + return Vec::new(); + } + let Some(origin) = origin.map(str::trim).filter(|value| !value.is_empty()) else { + return vec![vary_header(ACTUAL_VARY)]; + }; + if !config.is_origin_allowed(origin) { + return Vec::new(); + } + let mut headers = vec![vary_header(ACTUAL_VARY)]; + let allow_origin = if config.is_wildcard() && !config.allow_credentials { + WILDCARD_ORIGIN.to_owned() + } else { + origin.to_owned() + }; + headers.push(header(ALLOW_ORIGIN_HEADER, &allow_origin)); + if config.allow_credentials { + headers.push(header(ALLOW_CREDENTIALS_HEADER, "true")); + } + if !config.expose_headers.is_empty() { + headers.push(header( + EXPOSE_HEADERS_HEADER, + &config.expose_headers.join(", "), + )); + } + headers +} + +fn vary_header(value: &str) -> (HeaderName, HeaderValue) { + header(VARY_HEADER, value) +} + +fn header(name: &'static str, value: &str) -> (HeaderName, HeaderValue) { + let header_name = HeaderName::from_static(name); + let header_value = + HeaderValue::from_str(value).unwrap_or_else(|_| HeaderValue::from_static("*")); + (header_name, header_value) +} + +#[cfg(test)] +#[path = "cors_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/cors_tests.rs b/gears/system/oagw/oagw/src/domain/cors_tests.rs new file mode 100644 index 0000000..f92a0ac --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/cors_tests.rs @@ -0,0 +1,598 @@ +//! Tests for [`crate::domain::cors`]. + +use axum::http::{HeaderName, HeaderValue, StatusCode}; + +use super::{ + ACTUAL_VARY, ALLOW_CREDENTIALS_HEADER, ALLOW_HEADERS_HEADER, ALLOW_METHODS_HEADER, + ALLOW_ORIGIN_HEADER, CORS_METHOD_NOT_ALLOWED_TYPE, CORS_ORIGIN_NOT_ALLOWED_TYPE, + EXPOSE_HEADERS_HEADER, EffectiveCorsConfig, MAX_AGE_HEADER, ORIGIN_HEADER, PREFLIGHT_MAX_AGE, + PREFLIGHT_VARY, PreflightResponse, REQUEST_HEADERS_HEADER, REQUEST_METHOD_HEADER, VARY_HEADER, + WILDCARD_ORIGIN, actual_response_headers, evaluate_preflight, is_preflight, merge_cors, + validate_actual_request, validate_cors_config, +}; +use crate::domain::model::{CorsConfig, CorsMethod, SharingMode}; + +fn cors_config(origins: &[&str]) -> CorsConfig { + CorsConfig { + enabled: true, + sharing: SharingMode::Private, + allowed_origins: origins.iter().map(|origin| (*origin).to_owned()).collect(), + allowed_methods: vec![CorsMethod::Get, CorsMethod::Post], + allow_headers: vec!["x-trace-id".to_owned()], + expose_headers: vec!["x-request-id".to_owned()], + allow_credentials: false, + max_age: None, + } +} + +fn effective(origins: &[&str]) -> EffectiveCorsConfig { + EffectiveCorsConfig::from(&cors_config(origins)) +} + +fn header_value(headers: &[(HeaderName, HeaderValue)], name: &str) -> Option { + headers + .iter() + .find(|(header_name, _)| header_name.as_str() == name) + .map(|(_, value)| value.to_str().unwrap_or_default().to_owned()) +} + +// --------------------------------------------------------------------------- +// Origin matching +// --------------------------------------------------------------------------- + +#[test] +fn origin_match_is_exact_and_case_sensitive() { + let config = effective(&["https://App.Example.com"]); + assert!(config.is_origin_allowed("https://App.Example.com")); + assert!(!config.is_origin_allowed("https://app.example.com")); + assert!(!config.is_origin_allowed("http://App.Example.com")); +} + +#[test] +fn origin_match_is_port_and_protocol_sensitive() { + let config = effective(&["https://a.example.com"]); + assert!(!config.is_origin_allowed("http://a.example.com")); + assert!(!config.is_origin_allowed("https://a.example.com:8443")); + assert!(!config.is_origin_allowed("https://a.example.com:443")); +} + +#[test] +fn origin_matching_ignores_surrounding_whitespace_only() { + let config = effective(&[" https://a.example.com "]); + assert!(config.is_origin_allowed(" https://a.example.com ")); + assert!(!config.is_origin_allowed("https://a.example.com/x")); +} + +#[test] +fn empty_origin_is_never_allowed() { + let config = effective(&["https://a.example.com"]); + assert!(!config.is_origin_allowed("")); + assert!(!config.is_origin_allowed(" ")); +} + +#[test] +fn wildcard_matches_every_origin() { + let config = effective(&[WILDCARD_ORIGIN]); + assert!(config.is_origin_allowed("https://anything.example")); + assert!(config.is_origin_allowed("http://localhost:3000")); +} + +#[test] +fn method_matching_is_case_insensitive() { + let config = effective(&["https://a.example.com"]); + assert!(config.is_method_allowed("GET")); + assert!(config.is_method_allowed(" get ")); + assert!(config.is_method_allowed("post")); + assert!(!config.is_method_allowed("DELETE")); + assert!(!config.is_method_allowed("")); +} + +#[test] +fn omitted_methods_default_to_get_and_post() { + let mut cors = cors_config(&["https://a.example.com"]); + cors.allowed_methods.clear(); + let config = EffectiveCorsConfig::from(&cors); + assert!(config.is_method_allowed("GET")); + assert!(config.is_method_allowed("POST")); + assert!(!config.is_method_allowed("PUT")); +} + +#[test] +fn disabled_configuration_reports_itself_as_disabled() { + let mut cors = cors_config(&["https://a.example.com"]); + cors.enabled = false; + assert!(!EffectiveCorsConfig::from(&cors).enabled); +} + +// --------------------------------------------------------------------------- +// Configuration validation +// --------------------------------------------------------------------------- + +#[test] +fn wildcard_with_credentials_is_rejected() { + let mut cors = cors_config(&[WILDCARD_ORIGIN]); + cors.allow_credentials = true; + let error = validate_cors_config(&cors).expect_err("must be rejected"); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); +} + +#[test] +fn credentials_with_explicit_origins_are_accepted() { + let mut cors = cors_config(&["https://a.example.com"]); + cors.allow_credentials = true; + assert!(validate_cors_config(&cors).is_ok()); +} + +// --------------------------------------------------------------------------- +// Preflight detection +// --------------------------------------------------------------------------- + +#[test] +fn preflight_detection_requires_method_and_origin() { + assert!(is_preflight( + "OPTIONS", + Some("https://a.example.com"), + Some("GET") + )); + assert!(!is_preflight( + "GET", + Some("https://a.example.com"), + Some("GET") + )); + assert!(!is_preflight("OPTIONS", None, Some("GET"))); + assert!(!is_preflight( + "OPTIONS", + Some("https://a.example.com"), + None + )); + assert!(!is_preflight("OPTIONS", Some(" "), Some("GET"))); +} + +// --------------------------------------------------------------------------- +// Preflight evaluation +// --------------------------------------------------------------------------- + +#[test] +fn preflight_for_allowed_origin_returns_204_and_headers() { + let response = evaluate_preflight( + &effective(&["https://a.example.com"]), + Some("https://a.example.com"), + Some("POST"), + Some("x-trace-id, content-type"), + ); + assert_eq!(response.status, 204); + assert!(response.is_allowed()); + assert_eq!( + header_value(&response.headers, ALLOW_ORIGIN_HEADER).as_deref(), + Some("https://a.example.com") + ); + assert_eq!( + header_value(&response.headers, ALLOW_METHODS_HEADER).as_deref(), + Some("POST") + ); + assert_eq!( + header_value(&response.headers, ALLOW_HEADERS_HEADER).as_deref(), + Some("x-trace-id, content-type") + ); + assert_eq!( + header_value(&response.headers, MAX_AGE_HEADER).as_deref(), + Some(PREFLIGHT_MAX_AGE) + ); + assert_eq!( + header_value(&response.headers, VARY_HEADER).as_deref(), + Some(PREFLIGHT_VARY) + ); +} + +#[test] +fn preflight_for_unknown_origin_still_echoes_but_is_not_allowed() { + let response = evaluate_preflight( + &effective(&["https://a.example.com"]), + Some("https://evil.example.com"), + Some("POST"), + None, + ); + // ADR-0004: the preflight is permissive and echoes the request, while the + // verdict is carried by `allowed` and enforced on the actual request. + assert_eq!(response.status, 204); + assert!(!response.is_allowed()); + assert_eq!( + response.header(ALLOW_ORIGIN_HEADER), + Some("https://evil.example.com") + ); +} + +#[test] +fn preflight_without_origin_is_not_a_cors_request() { + let response = evaluate_preflight(&effective(&["https://a.example.com"]), None, None, None); + assert!(!response.is_allowed()); + assert_eq!(header_value(&response.headers, ALLOW_METHODS_HEADER), None); + assert_eq!( + header_value(&response.headers, VARY_HEADER).as_deref(), + Some(PREFLIGHT_VARY) + ); +} + +#[test] +fn preflight_vary_names_all_three_request_headers() { + let response = evaluate_preflight( + &effective(&["https://a.example.com"]), + Some("https://a.example.com"), + Some("DELETE"), + None, + ); + let vary = response.header(VARY_HEADER).expect("vary"); + for token in [ + "Origin", + "Access-Control-Request-Method", + "Access-Control-Request-Headers", + ] { + assert!(vary.contains(token), "vary must contain {token}"); + } +} + +#[test] +fn preflight_omitting_the_method_falls_back_to_the_configuration() { + let response = evaluate_preflight( + &effective(&["https://a.example.com"]), + Some("https://a.example.com"), + None, + None, + ); + assert!(response.is_allowed()); + assert_eq!( + header_value(&response.headers, ALLOW_METHODS_HEADER).as_deref(), + Some("GET, POST") + ); +} + +#[test] +fn preflight_max_age_uses_the_configuration_override() { + let mut cors = cors_config(&["https://a.example.com"]); + cors.max_age = Some(600); + let response = evaluate_preflight( + &EffectiveCorsConfig::from(&cors), + Some("https://a.example.com"), + Some("GET"), + None, + ); + assert_eq!( + header_value(&response.headers, MAX_AGE_HEADER).as_deref(), + Some("600") + ); +} + +#[test] +fn disabled_cors_answers_no_preflight_grant() { + let mut cors = cors_config(&["https://a.example.com"]); + cors.enabled = false; + let response = evaluate_preflight( + &EffectiveCorsConfig::from(&cors), + Some("https://a.example.com"), + Some("GET"), + None, + ); + assert!(!response.is_allowed()); + assert_eq!(header_value(&response.headers, ALLOW_ORIGIN_HEADER), None); +} + +#[test] +fn wildcard_preflight_emits_the_requested_origin() { + let response = evaluate_preflight( + &effective(&[WILDCARD_ORIGIN]), + Some("https://a.example.com"), + Some("GET"), + None, + ); + assert!(response.is_allowed()); + assert_eq!( + header_value(&response.headers, ALLOW_ORIGIN_HEADER).as_deref(), + Some("https://a.example.com") + ); +} + +#[test] +fn preflight_header_values_are_valid_header_values() { + let response = evaluate_preflight( + &effective(&["https://a.example.com"]), + Some("https://a.example.com"), + Some("PUT"), + Some("x-trace-id"), + ); + for (name, value) in &response.headers { + assert!(HeaderName::from_bytes(name.as_str().as_bytes()).is_ok()); + assert!(!value.as_bytes().is_empty()); + } +} + +// --------------------------------------------------------------------------- +// Actual requests +// --------------------------------------------------------------------------- + +#[test] +fn actual_request_from_allowed_origin_is_accepted() { + let config = effective(&["https://a.example.com"]); + assert!(validate_actual_request(&config, Some("https://a.example.com"), "GET").is_ok()); + assert!(validate_actual_request(&config, Some("https://a.example.com"), "DELETE").is_err()); +} + +#[test] +fn actual_request_from_unknown_origin_is_rejected_with_403() { + let error = validate_actual_request( + &effective(&["https://a.example.com"]), + Some("https://evil.example.com"), + "GET", + ) + .expect_err("403"); + assert_eq!(error.status(), StatusCode::FORBIDDEN); + assert_eq!(error.problem_body().r#type, CORS_ORIGIN_NOT_ALLOWED_TYPE); + assert_eq!( + error.problem_body().context.invalid_value.as_deref(), + Some("https://evil.example.com") + ); +} + +#[test] +fn actual_request_with_disallowed_method_is_rejected_with_403() { + let error = validate_actual_request( + &effective(&["https://a.example.com"]), + Some("https://a.example.com"), + "TRACE", + ) + .expect_err("403"); + assert_eq!(error.problem_body().r#type, CORS_METHOD_NOT_ALLOWED_TYPE); + assert_eq!( + error.problem_body().context.invalid_value, + Some("TRACE".to_owned()) + ); +} + +#[test] +fn actual_request_without_origin_is_not_a_cors_request() { + let config = effective(&["https://a.example.com"]); + assert!(validate_actual_request(&config, None, "DELETE").is_ok()); + assert!(validate_actual_request(&config, Some(" "), "DELETE").is_ok()); +} + +#[test] +fn disabled_cors_never_rejects_an_actual_request() { + let mut cors = cors_config(&["https://a.example.com"]); + cors.enabled = false; + let config = EffectiveCorsConfig::from(&cors); + assert!(validate_actual_request(&config, Some("https://evil.example.com"), "GET").is_ok()); +} + +#[test] +fn actual_response_headers_expose_the_configured_list() { + let headers = super::actual_response_headers( + &effective(&["https://a.example.com"]), + Some("https://a.example.com"), + ); + assert_eq!( + header_value(&headers, ALLOW_ORIGIN_HEADER).as_deref(), + Some("https://a.example.com") + ); + assert_eq!( + header_value(&headers, EXPOSE_HEADERS_HEADER).as_deref(), + Some("x-request-id") + ); + assert_eq!( + header_value(&headers, VARY_HEADER).as_deref(), + Some(ACTUAL_VARY) + ); +} + +#[test] +fn wildcard_actual_response_emits_the_star() { + let headers = super::actual_response_headers( + &effective(&[WILDCARD_ORIGIN]), + Some("https://a.example.com"), + ); + assert_eq!( + header_value(&headers, ALLOW_ORIGIN_HEADER).as_deref(), + Some(WILDCARD_ORIGIN) + ); + assert_eq!(header_value(&headers, ALLOW_CREDENTIALS_HEADER), None); +} + +#[test] +fn actual_response_headers_are_empty_when_disabled() { + let mut cors = cors_config(&["https://a.example.com"]); + cors.enabled = false; + let headers = super::actual_response_headers( + &EffectiveCorsConfig::from(&cors), + Some("https://a.example.com"), + ); + assert!(headers.is_empty()); +} + +#[test] +fn actual_response_headers_are_empty_for_unknown_origins() { + let headers = super::actual_response_headers( + &effective(&["https://a.example.com"]), + Some("https://evil.example.com"), + ); + assert!(headers.is_empty()); +} + +#[test] +fn credentials_actual_response_emits_the_allow_credentials_header() { + let mut cors = cors_config(&["https://a.example.com"]); + cors.allow_credentials = true; + let headers = super::actual_response_headers( + &EffectiveCorsConfig::from(&cors), + Some("https://a.example.com"), + ); + assert_eq!( + header_value(&headers, ALLOW_CREDENTIALS_HEADER).as_deref(), + Some("true") + ); +} + +// --------------------------------------------------------------------------- +// Inheritance +// --------------------------------------------------------------------------- + +#[test] +fn private_parent_is_replaced_by_the_child() { + let mut parent = cors_config(&["https://parent.example.com"]); + parent.sharing = SharingMode::Private; + let child = cors_config(&["https://child.example.com"]); + let merged = merge_cors(&[&parent, &child]).expect("effective"); + assert_eq!(merged.allowed_origins, vec!["https://child.example.com"]); +} + +#[test] +fn inherit_unions_parent_and_child_origins() { + let mut parent = cors_config(&["https://parent.example.com"]); + parent.sharing = SharingMode::Inherit; + let child = cors_config(&["https://child.example.com"]); + let merged = merge_cors(&[&parent, &child]).expect("effective"); + assert_eq!( + merged.allowed_origins, + vec!["https://parent.example.com", "https://child.example.com"] + ); + assert_eq!(merged.allow_headers, vec!["x-trace-id".to_owned()]); +} + +#[test] +fn enforce_keeps_the_parent_set() { + let mut parent = cors_config(&["https://parent.example.com"]); + parent.sharing = SharingMode::Enforce; + let child = cors_config(&["https://child.example.com"]); + let merged = merge_cors(&[&parent, &child]).expect("effective"); + assert_eq!(merged.allowed_origins, vec!["https://parent.example.com"]); +} + +#[test] +fn disabled_entries_are_skipped() { + let mut parent = cors_config(&["https://parent.example.com"]); + parent.sharing = SharingMode::Inherit; + parent.enabled = false; + let child = cors_config(&["https://child.example.com"]); + let merged = merge_cors(&[&parent, &child]).expect("effective"); + assert_eq!(merged.allowed_origins, vec!["https://child.example.com"]); +} + +#[test] +fn empty_chain_yields_none() { + let chain: Vec<&CorsConfig> = Vec::new(); + assert!(merge_cors(&chain).is_none()); +} + +#[test] +fn inherited_chain_survives_three_levels() { + let mut root = cors_config(&["https://root.example.com"]); + root.sharing = SharingMode::Inherit; + let mut middle = cors_config(&["https://middle.example.com"]); + middle.sharing = SharingMode::Inherit; + let leaf = cors_config(&["https://leaf.example.com"]); + let merged = merge_cors(&[&root, &middle, &leaf]).expect("effective"); + assert_eq!( + merged.allowed_origins, + vec![ + "https://root.example.com", + "https://middle.example.com", + "https://leaf.example.com" + ] + ); +} + +/// A merged wildcard must never be paired with a credential grant (ADR-0004 +/// "Security defaults"): the upstream wildcard and the route credentials are +/// each valid on their own, but the union of the two is the combination the +/// write-time validation forbids. +#[test] +fn a_merged_wildcard_never_carries_credentials() { + let mut upstream = cors_config(&[WILDCARD_ORIGIN]); + upstream.sharing = SharingMode::Inherit; + let mut route = cors_config(&["https://app.example.com"]); + route.allow_credentials = true; + + let merged = merge_cors(&[&upstream, &route]).expect("effective"); + assert!( + merged.is_wildcard(), + "the union keeps the wildcard: {:?}", + merged.allowed_origins + ); + assert!( + !merged.allow_credentials, + "the wildcard drops the credential grant" + ); + + // An attacker-chosen origin: the answer may not name it *and* grant + // credentials for it. + let attacker = "https://attacker.example"; + let headers = actual_response_headers(&merged, Some(attacker)); + assert!( + header_value(&headers, ALLOW_CREDENTIALS_HEADER).is_none(), + "no merged answer may grant credentials: {headers:?}" + ); + assert_eq!( + header_value(&headers, ALLOW_ORIGIN_HEADER).as_deref(), + Some(WILDCARD_ORIGIN), + "the wildcard answers with the literal, never with the echoed origin" + ); + + let preflight = evaluate_preflight(&merged, Some(attacker), Some("POST"), None); + assert!(preflight.header(ALLOW_CREDENTIALS_HEADER).is_none()); +} + +/// The reversed merge — credentials on the parent, wildcard on the child — is +/// equally forbidden, so the route's origins keep working without credentials. +#[test] +fn a_merged_wildcard_from_the_child_drops_the_inherited_credentials() { + let mut upstream = cors_config(&["https://app.example.com"]); + upstream.sharing = SharingMode::Inherit; + upstream.allow_credentials = true; + let route = cors_config(&[WILDCARD_ORIGIN]); + + let merged = merge_cors(&[&upstream, &route]).expect("effective"); + assert!(merged.is_wildcard()); + assert!(!merged.allow_credentials); + let headers = actual_response_headers(&merged, Some("https://app.example.com")); + assert!(header_value(&headers, ALLOW_CREDENTIALS_HEADER).is_none()); +} + +// --------------------------------------------------------------------------- +// Header plumbing +// --------------------------------------------------------------------------- +#[test] +fn header_names_are_usable_as_axum_names() { + for name in [ + ALLOW_ORIGIN_HEADER, + ALLOW_METHODS_HEADER, + ALLOW_HEADERS_HEADER, + ALLOW_CREDENTIALS_HEADER, + EXPOSE_HEADERS_HEADER, + MAX_AGE_HEADER, + VARY_HEADER, + ORIGIN_HEADER, + REQUEST_METHOD_HEADER, + REQUEST_HEADERS_HEADER, + ] { + assert!( + HeaderName::from_bytes(name.as_bytes()).is_ok(), + "header name '{name}' must be valid" + ); + } +} + +#[test] +fn effective_config_defaults_max_age_to_the_adr_constant() { + assert_eq!( + effective(&["https://a.example.com"]).max_age(), + PREFLIGHT_MAX_AGE + ); +} + +#[test] +fn preflight_response_is_debug_and_clone() { + let response = PreflightResponse { + status: 204, + headers: Vec::new(), + allowed: false, + }; + assert_eq!(response.clone(), response); + assert!(format!("{response:?}").contains("204")); +} diff --git a/gears/system/oagw/oagw/src/domain/error.rs b/gears/system/oagw/oagw/src/domain/error.rs new file mode 100644 index 0000000..fb28f95 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/error.rs @@ -0,0 +1,714 @@ +//! Typed error taxonomy for the OAGW gear (DESIGN section 3.3). +//! +//! Every failure the gear reports is one [`OagwError`] variant. Rendering is +//! RFC 9457 `application/problem+json` with: +//! +//! * `type` — the GTS error type id (`gts.cf.core.errors.err.v1~cf.oagw.*.v1`); +//! * `title`, `status`, `detail`, `instance` — the RFC fields; +//! * the gear-specific extension fields (`alias`, `host`, `upstream_id`, +//! `valid_hosts`, `trace_id`, ...) **at the top level of the body**, which is +//! where the ADR-0007 wire examples and the DESIGN section 3.3 +//! "Extension Fields" list put them; +//! * the same fields *also* nested under `context` (deliberate duplication, see +//! [`ProblemBody`]); +//! * the `X-OAGW-Error-Source` header pinned to `gateway` (ADR-0007), since +//! every error produced by this enum originates in the gateway itself. +//! +//! `X-OAGW-Error-Source: upstream` is stamped by the proxy engine (slice 4) +//! when the *upstream* response is itself the failure, never here. +//! +//! Each variant owns its already-rendered [`ProblemBody`] behind a [`Box`], so +//! the enum is pointer-sized and cheap to pass through `Result` on the hot +//! data-plane path (slice 4). +//! +//! ## GTS type ids beyond the DESIGN section 3.3 table +//! +//! The DESIGN error table does not name the management-plane statuses, so the +//! taxonomy adds three ids to it (the remaining ids are the table's own): +//! +//! | GTS id fragment | HTTP | Used for | +//! |---|---|---| +//! | `conflict` | 409 | duplicate alias / duplicate route match rule | +//! | `not_found` | 404 | a resource the calling tenant does not own | +//! | `tenancy.bind_forbidden` | 403 | an ancestor *enforces* the alias a descendant tries to bind (DESIGN "Hierarchical Configuration") | +//! +//! `cors.forbidden` stays reserved for CORS denials; tenancy denials are +//! reported as `tenancy.bind_forbidden` so a client can tell the two apart. + +use std::fmt; + +use axum::http::{HeaderName, HeaderValue, StatusCode}; +use axum::response::{IntoResponse, Response}; +use serde::{Deserialize, Serialize}; + +/// Media type of the RFC 9457 problem documents this gear emits. +pub const APPLICATION_PROBLEM_JSON: &str = "application/problem+json"; + +/// `X-OAGW-Error-Source` header name (ADR-0007). +pub const ERROR_SOURCE_HEADER: &str = "x-oagw-error-source"; + +/// Error-source value produced by the gateway itself. +pub const ERROR_SOURCE_GATEWAY: &str = "gateway"; + +/// Error-source value produced when the upstream response is the failure. +pub const ERROR_SOURCE_UPSTREAM: &str = "upstream"; + +/// GTS base type of every OAGW error instance. +pub const ERROR_TYPE_BASE: &str = "gts.cf.core.errors.err.v1~cf.oagw"; + +/// Result alias of the OAGW handlers: the error side is always this gear's own +/// taxonomy rather than the toolkit's canonical catalogue, because an OAGW +/// problem document carries OAGW GTS error types and OAGW extension fields. +pub type ApiResult = Result; + +/// Extension fields rendered inside the problem `context` object. +/// +/// Every field is optional and omitted when `None`; the object itself is +/// always emitted (possibly empty) so clients can rely on `context` being +/// present. The same values are mirrored at the top level of the body +/// ([`ProblemBody`]). +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ProblemContext { + /// Upstream alias that could not be resolved, or the resolved alias. + #[serde(skip_serializing_if = "Option::is_none")] + pub alias: Option, + /// Upstream GTS instance id involved in the failure. + #[serde(skip_serializing_if = "Option::is_none")] + pub upstream_id: Option, + /// Target host involved in the failure. + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + /// Request path involved in the failure. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Hosts advertised by the upstream endpoint pool (for host-mismatch errors). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub valid_hosts: Vec, + /// Value rejected by validation. + #[serde(skip_serializing_if = "Option::is_none")] + pub invalid_value: Option, + /// Plugin instance id involved in the failure. + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_id: Option, + /// Seconds after which the client may retry. + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_after_seconds: Option, + /// Resources preventing a deletion (ADR-0001 `PluginInUse`). + #[serde(skip_serializing_if = "referenced_by_is_empty")] + pub referenced_by: Option>, +} + +impl ProblemContext { + /// `true` when no extension field is populated. + #[must_use] + pub fn is_empty(&self) -> bool { + self.alias.is_none() + && self.upstream_id.is_none() + && self.host.is_none() + && self.path.is_none() + && self.valid_hosts.is_empty() + && self.invalid_value.is_none() + && self.plugin_id.is_none() + && self.retry_after_seconds.is_none() + && self.referenced_by.is_none() + } +} + +/// Resources that still reference a plugin. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ReferencedBy { + /// Upstream GTS ids referencing the plugin. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub upstreams: Vec, + /// Route GTS ids referencing the plugin. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub routes: Vec, +} + +impl ReferencedBy { + /// `true` when neither resource kind still references the plugin. + #[must_use] + pub fn is_empty(&self) -> bool { + self.upstreams.is_empty() && self.routes.is_empty() + } +} + +fn referenced_by_is_empty(value: &Option>) -> bool { + value + .as_ref() + .is_none_or(|referenced| referenced.is_empty()) +} + +/// Flat RFC 9457 problem document. +/// +/// ## Two views of one extension field +/// +/// The OAGW extension fields are emitted **twice**, on purpose: +/// +/// * **top level** — where the ADR-0007 wire examples and the DESIGN section +/// 3.3 "Extension Fields" list put them, so a client reads +/// `body["upstream_id"]` and not `body["context"]["upstream_id"]`; +/// * **inside `context`** — because the platform's +/// `toolkit::api::canonical_error_middleware` parses *every* +/// `application/problem+json` response and requires the +/// `type`/`title`/`status`/`detail`/`context` quintet; dropping `context` +/// would make that middleware log an error and re-emit the body untouched. +/// +/// [`ProblemBody::apply_context_extensions`] copies the `context` values into +/// the top-level fields, so both views always carry the same value and the +/// nested object stays the single source of truth. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct ProblemBody { + /// GTS error type id. + pub r#type: String, + /// Short human-readable summary. + pub title: String, + /// HTTP status. + pub status: u16, + /// Human-readable failure detail. + pub detail: String, + /// Request path that triggered the failure, when known. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub instance: Option, + /// Distributed-tracing correlation id, filled from the request by + /// [`crate::api::rest::error_layer::error_source_layer`]. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub trace_id: Option, + /// Upstream involved in the failure (ADR-0007 extension field). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub upstream_id: Option, + /// Alias involved in the failure (ADR-0007 extension field). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, + /// Target host involved in the failure (ADR-0007 extension field). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub host: Option, + /// Request path involved in the failure (ADR-0007 extension field). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub path: Option, + /// Hosts advertised by the upstream endpoint pool. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub valid_hosts: Vec, + /// Value rejected by validation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub invalid_value: Option, + /// Plugin instance id involved in the failure. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugin_id: Option, + /// Resources preventing a deletion (ADR-0001 `PluginInUse`). + #[serde(default, skip_serializing_if = "referenced_by_is_empty")] + pub referenced_by: Option>, + /// Seconds after which the client may retry. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub retry_after_seconds: Option, + /// Gear-specific extension fields, nested for the platform middleware. + #[serde(default)] + pub context: ProblemContext, +} + +impl ProblemBody { + /// Builds a body with no extension fields, for the fixed taxonomic shape of + /// one [`OagwError`] variant. + #[must_use] + pub fn bare(r#type: String, title: String, status: u16, detail: String) -> Self { + Self { + r#type, + title, + status, + detail, + instance: None, + trace_id: None, + upstream_id: None, + alias: None, + host: None, + path: None, + valid_hosts: Vec::new(), + invalid_value: None, + plugin_id: None, + referenced_by: None, + retry_after_seconds: None, + context: ProblemContext::default(), + } + } + + /// Copies the `context` extension fields to the ADR-0007 top-level fields. + /// + /// Idempotent: a top-level field already present (e.g. a `trace_id` the + /// error layer added) is left alone, and the `context` object is never + /// cleared, so running this on an already-flat body changes nothing. + pub fn apply_context_extensions(&mut self) { + macro_rules! mirror { + ($($field:ident),+ $(,)?) => { + $(if self.$field.is_none() { + self.$field = self.context.$field.clone(); + })+ + }; + } + mirror!( + upstream_id, + alias, + host, + path, + invalid_value, + plugin_id, + referenced_by, + retry_after_seconds, + ); + if self.valid_hosts.is_empty() { + self.valid_hosts = self.context.valid_hosts.clone(); + } + } +} + +/// Static classification of one [`OagwError`] variant: its GTS type id, HTTP +/// status and RFC 9457 `title`. +struct ErrorSpec { + gts_fragment: &'static str, + status: u16, + title: &'static str, +} + +impl ErrorSpec { + const fn new(gts_fragment: &'static str, status: u16, title: &'static str) -> Self { + Self { + gts_fragment, + status, + title, + } + } + + /// Builds a body for `detail`, with no extension fields set yet. + fn body(&self, detail: String) -> ProblemBody { + ProblemBody::bare( + format!("{ERROR_TYPE_BASE}.{}.v1", self.gts_fragment), + self.title.to_owned(), + self.status, + detail, + ) + } +} + +const VALIDATION: ErrorSpec = ErrorSpec::new("validation.error", 400, "Validation Error"); +const MISSING_TARGET_HOST: ErrorSpec = + ErrorSpec::new("routing.missing_target_host", 400, "Missing Target Host"); +const INVALID_TARGET_HOST: ErrorSpec = + ErrorSpec::new("routing.invalid_target_host", 400, "Invalid Target Host"); +const UNKNOWN_TARGET_HOST: ErrorSpec = + ErrorSpec::new("routing.unknown_target_host", 400, "Unknown Target Host"); +const AUTHENTICATION_FAILED: ErrorSpec = + ErrorSpec::new("auth.failed", 401, "Authentication Failed"); +const ROUTE_NOT_FOUND: ErrorSpec = ErrorSpec::new("route.not_found", 404, "Route Not Found"); +const PLUGIN_IN_USE: ErrorSpec = ErrorSpec::new("plugin.in_use", 409, "Plugin In Use"); +const PAYLOAD_TOO_LARGE: ErrorSpec = ErrorSpec::new("payload.too_large", 413, "Payload Too Large"); +const RATE_LIMIT_EXCEEDED: ErrorSpec = + ErrorSpec::new("rate_limit.exceeded", 429, "Rate Limit Exceeded"); +const SECRET_NOT_FOUND: ErrorSpec = ErrorSpec::new("secret.not_found", 500, "Secret Not Found"); +const PROTOCOL_ERROR: ErrorSpec = ErrorSpec::new("protocol.error", 502, "Protocol Error"); +const DOWNSTREAM_ERROR: ErrorSpec = ErrorSpec::new("downstream.error", 502, "Downstream Error"); +const STREAM_ABORTED: ErrorSpec = ErrorSpec::new("stream.aborted", 502, "Stream Aborted"); +const LINK_UNAVAILABLE: ErrorSpec = ErrorSpec::new("link.unavailable", 503, "Link Unavailable"); +const CIRCUIT_BREAKER_OPEN: ErrorSpec = + ErrorSpec::new("circuit_breaker.open", 503, "Circuit Breaker Open"); +const PLUGIN_NOT_FOUND: ErrorSpec = ErrorSpec::new("plugin.not_found", 503, "Plugin Not Found"); +const CONNECTION_TIMEOUT: ErrorSpec = + ErrorSpec::new("timeout.connection", 504, "Connection Timeout"); +const REQUEST_TIMEOUT: ErrorSpec = ErrorSpec::new("timeout.request", 504, "Request Timeout"); +const IDLE_TIMEOUT: ErrorSpec = ErrorSpec::new("timeout.idle", 504, "Idle Timeout"); +const CONFLICT: ErrorSpec = ErrorSpec::new("conflict", 409, "Conflict"); +const NOT_FOUND: ErrorSpec = ErrorSpec::new("not_found", 404, "Not Found"); +const FORBIDDEN: ErrorSpec = ErrorSpec::new("cors.forbidden", 403, "Forbidden"); +const BIND_FORBIDDEN: ErrorSpec = ErrorSpec::new("tenancy.bind_forbidden", 403, "Bind Forbidden"); + +/// OAGW error taxonomy (DESIGN section 3.3). +#[derive(Debug, Clone)] +pub enum OagwError { + /// Request payload failed structural or semantic validation. + Validation(Box), + /// Proxy request carried no target host. + MissingTargetHost(Box), + /// Proxy target host is malformed or not allowed. + InvalidTargetHost(Box), + /// Proxy target host does not match any endpoint of the upstream. + UnknownTargetHost(Box), + /// Credentials were rejected by the auth plugin or the credential store. + AuthenticationFailed(Box), + /// No route matched the request. + RouteNotFound(Box), + /// Plugin is still referenced by an upstream or a route. + PluginInUse(Box), + /// Request or response body exceeded the configured cap. + PayloadTooLarge(Box), + /// Rate-limit budget exhausted. + RateLimitExceeded(Box), + /// A credential secret referenced by a plugin does not exist. + SecretNotFound(Box), + /// Upstream violated the HTTP protocol contract. + ProtocolError(Box), + /// Upstream returned an error the gateway maps through. + DownstreamError(Box), + /// Streaming exchange was aborted mid-flight. + StreamAborted(Box), + /// No healthy link to the upstream. + LinkUnavailable(Box), + /// Circuit breaker for the upstream is open. + CircuitBreakerOpen(Box), + /// A plugin reference does not resolve to a known plugin. + PluginNotFound(Box), + /// Upstream connection phase exceeded the budget. + ConnectionTimeout(Box), + /// Full request/response exchange exceeded the budget. + RequestTimeout(Box), + /// Idle read/write window elapsed. + IdleTimeout(Box), + /// Uniqueness conflict (duplicate alias, duplicate route match rule). + Conflict(Box), + /// Requested resource does not exist. + NotFound(Box), + /// CORS preflight or actual-request check rejected the request. + Forbidden(Box), + /// A hierarchical bind rule of an ancestor refused the alias (DESIGN + /// "Hierarchical Configuration"): the ancestor *enforces* its sections. + /// + /// Distinct from [`OagwError::Forbidden`], which stays the CORS denial, so + /// a client can tell a tenancy refusal from an origin refusal. + BindForbidden(Box), +} + +impl OagwError { + /// The already-rendered problem document for this variant. + #[must_use] + pub const fn problem_body(&self) -> &ProblemBody { + match self { + Self::Validation(body) + | Self::MissingTargetHost(body) + | Self::InvalidTargetHost(body) + | Self::UnknownTargetHost(body) + | Self::AuthenticationFailed(body) + | Self::RouteNotFound(body) + | Self::PluginInUse(body) + | Self::PayloadTooLarge(body) + | Self::RateLimitExceeded(body) + | Self::SecretNotFound(body) + | Self::ProtocolError(body) + | Self::DownstreamError(body) + | Self::StreamAborted(body) + | Self::LinkUnavailable(body) + | Self::CircuitBreakerOpen(body) + | Self::PluginNotFound(body) + | Self::ConnectionTimeout(body) + | Self::RequestTimeout(body) + | Self::IdleTimeout(body) + | Self::Conflict(body) + | Self::NotFound(body) + | Self::Forbidden(body) + | Self::BindForbidden(body) => body, + } + } + + /// Mutable problem document, for builders that enrich an error in place. + const fn problem_body_mut(&mut self) -> &mut ProblemBody { + match self { + Self::Validation(body) + | Self::MissingTargetHost(body) + | Self::InvalidTargetHost(body) + | Self::UnknownTargetHost(body) + | Self::AuthenticationFailed(body) + | Self::RouteNotFound(body) + | Self::PluginInUse(body) + | Self::PayloadTooLarge(body) + | Self::RateLimitExceeded(body) + | Self::SecretNotFound(body) + | Self::ProtocolError(body) + | Self::DownstreamError(body) + | Self::StreamAborted(body) + | Self::LinkUnavailable(body) + | Self::CircuitBreakerOpen(body) + | Self::PluginNotFound(body) + | Self::ConnectionTimeout(body) + | Self::RequestTimeout(body) + | Self::IdleTimeout(body) + | Self::Conflict(body) + | Self::NotFound(body) + | Self::Forbidden(body) + | Self::BindForbidden(body) => body, + } + } + + /// Full GTS error type id for this variant, e.g. + /// `gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1`. + #[must_use] + pub fn gts_type(&self) -> String { + self.problem_body().r#type.clone() + } + + /// HTTP status this variant renders as. + #[must_use] + pub fn status(&self) -> StatusCode { + StatusCode::from_u16(self.problem_body().status) + .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR) + } + + /// Human-readable failure detail. + #[must_use] + pub fn detail(&self) -> &str { + &self.problem_body().detail + } + + /// Extension fields carried by this error. + #[must_use] + pub fn context(&self) -> &ProblemContext { + &self.problem_body().context + } + + /// Sets the `alias` extension field. + #[must_use] + pub fn with_alias(mut self, alias: impl Into) -> Self { + self.problem_body_mut().context.alias = Some(alias.into()); + self + } + + /// Sets the `host` extension field. + #[must_use] + pub fn with_host(mut self, host: impl Into) -> Self { + self.problem_body_mut().context.host = Some(host.into()); + self + } + + /// Sets the `path` extension field, which also becomes the problem + /// `instance`. + #[must_use] + pub fn with_path(mut self, path: impl Into) -> Self { + let path = path.into(); + let body = self.problem_body_mut(); + body.context.path = Some(path.clone()); + body.instance = Some(path); + self + } + + /// Sets the `upstream_id` extension field to the GTS instance id of `id`. + #[must_use] + pub fn with_upstream_id(mut self, id: uuid::Uuid) -> Self { + self.problem_body_mut().context.upstream_id = Some(id.to_string()); + self + } + + /// Sets the `valid_hosts` extension field. + #[must_use] + pub fn with_valid_hosts(mut self, hosts: Vec) -> Self { + self.problem_body_mut().context.valid_hosts = hosts; + self + } + + /// Sets the `invalid_value` extension field. + #[must_use] + pub fn with_invalid_value(mut self, value: impl Into) -> Self { + self.problem_body_mut().context.invalid_value = Some(value.into()); + self + } + + /// Sets the `plugin_id` extension field. + #[must_use] + pub fn with_plugin_id(mut self, plugin_id: impl Into) -> Self { + self.problem_body_mut().context.plugin_id = Some(plugin_id.into()); + self + } + + /// Sets the `retry_after_seconds` extension field, which also becomes the + /// `Retry-After` response header. + #[must_use] + pub fn with_retry_after_seconds(mut self, seconds: u64) -> Self { + self.problem_body_mut().context.retry_after_seconds = Some(seconds); + self + } + + /// Sets the `referenced_by` extension field. + #[must_use] + pub fn with_referenced_by(mut self, referenced_by: ReferencedBy) -> Self { + self.problem_body_mut().context.referenced_by = Some(Box::new(referenced_by)); + self + } + + /// Renders this error as an axum [`Response`]. + /// + /// `source` becomes the `X-OAGW-Error-Source` header value (`gateway` for + /// errors produced by this enum, `upstream` when the proxy engine maps a + /// failing upstream response through this taxonomy). + /// + /// The `context` extension fields are mirrored to the top level of the body + /// (ADR-0007) at this boundary — the only place a [`ProblemBody`] reaches + /// the wire — and `context` itself is kept for the platform middleware (see + /// the [`ProblemBody`] documentation). + #[must_use] + pub fn into_response_with_source(self, source: &str) -> Response { + let status = self.status(); + let retry_after = self.context().retry_after_seconds; + let mut body = self.problem_body().clone(); + body.apply_context_extensions(); + let mut response = (status, axum::Json(body)).into_response(); + let headers = response.headers_mut(); + headers.insert( + axum::http::header::CONTENT_TYPE, + HeaderValue::from_static(APPLICATION_PROBLEM_JSON), + ); + headers.insert( + HeaderName::from_static(ERROR_SOURCE_HEADER), + HeaderValue::from_str(source) + .unwrap_or_else(|_| HeaderValue::from_static(ERROR_SOURCE_GATEWAY)), + ); + if let Some(retry) = retry_after + && let Ok(value) = HeaderValue::from_str(&retry.to_string()) + { + headers.insert(axum::http::header::RETRY_AFTER, value); + } + response + } +} + +impl fmt::Display for OagwError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(formatter, "{} ({})", self.detail(), self.gts_type()) + } +} + +impl std::error::Error for OagwError {} + +impl From for Response { + fn from(error: OagwError) -> Self { + error.into_response_with_source(ERROR_SOURCE_GATEWAY) + } +} + +impl IntoResponse for OagwError { + fn into_response(self) -> Response { + Response::from(self) + } +} + +/// Creates a [`ProblemContext`] extension payload. +#[must_use] +pub fn problem_context() -> ProblemContextBuilder { + ProblemContextBuilder::default() +} + +/// Builds a [`ProblemContext`] extension payload fluently. +#[derive(Debug, Clone, Default)] +pub struct ProblemContextBuilder { + context: ProblemContext, +} + +impl ProblemContextBuilder { + /// Sets `alias`. + #[must_use] + pub fn alias(mut self, alias: impl Into) -> Self { + self.context.alias = Some(alias.into()); + self + } + + /// Sets `upstream_id`. + #[must_use] + pub fn upstream_id(mut self, upstream_id: impl Into) -> Self { + self.context.upstream_id = Some(upstream_id.into()); + self + } + + /// Sets `host`. + #[must_use] + pub fn host(mut self, host: impl Into) -> Self { + self.context.host = Some(host.into()); + self + } + + /// Sets `path`. + #[must_use] + pub fn path(mut self, path: impl Into) -> Self { + self.context.path = Some(path.into()); + self + } + + /// Sets `valid_hosts`. + #[must_use] + pub fn valid_hosts(mut self, hosts: Vec) -> Self { + self.context.valid_hosts = hosts; + self + } + + /// Sets `invalid_value`. + #[must_use] + pub fn invalid_value(mut self, value: impl Into) -> Self { + self.context.invalid_value = Some(value.into()); + self + } + + /// Sets `plugin_id`. + #[must_use] + pub fn plugin_id(mut self, plugin_id: impl Into) -> Self { + self.context.plugin_id = Some(plugin_id.into()); + self + } + + /// Sets `retry_after_seconds`. + #[must_use] + pub fn retry_after_seconds(mut self, seconds: u64) -> Self { + self.context.retry_after_seconds = Some(seconds); + self + } + + /// Sets `referenced_by`. + #[must_use] + pub fn referenced_by(mut self, referenced_by: ReferencedBy) -> Self { + self.context.referenced_by = Some(Box::new(referenced_by)); + self + } + + /// Consumes the builder, returning the populated context. + #[must_use] + pub fn build(self) -> ProblemContext { + self.context + } +} + +macro_rules! oagw_error_constructors { + ($($constructor:ident => $variant:ident, $spec:ident),+ $(,)?) => { + impl OagwError { + $( + #[doc = concat!("Builds [`OagwError::", stringify!($variant), "`] with a detail message.")] + #[must_use] + pub fn $constructor(detail: impl Into) -> Self { + Self::$variant(Box::new($spec.body(detail.into()))) + } + )+ + } + }; +} + +oagw_error_constructors! { + validation => Validation, VALIDATION, + missing_target_host => MissingTargetHost, MISSING_TARGET_HOST, + invalid_target_host => InvalidTargetHost, INVALID_TARGET_HOST, + unknown_target_host => UnknownTargetHost, UNKNOWN_TARGET_HOST, + authentication_failed => AuthenticationFailed, AUTHENTICATION_FAILED, + route_not_found => RouteNotFound, ROUTE_NOT_FOUND, + plugin_in_use => PluginInUse, PLUGIN_IN_USE, + payload_too_large => PayloadTooLarge, PAYLOAD_TOO_LARGE, + rate_limit_exceeded => RateLimitExceeded, RATE_LIMIT_EXCEEDED, + secret_not_found => SecretNotFound, SECRET_NOT_FOUND, + protocol_error => ProtocolError, PROTOCOL_ERROR, + downstream_error => DownstreamError, DOWNSTREAM_ERROR, + stream_aborted => StreamAborted, STREAM_ABORTED, + link_unavailable => LinkUnavailable, LINK_UNAVAILABLE, + circuit_breaker_open => CircuitBreakerOpen, CIRCUIT_BREAKER_OPEN, + plugin_not_found => PluginNotFound, PLUGIN_NOT_FOUND, + connection_timeout => ConnectionTimeout, CONNECTION_TIMEOUT, + request_timeout => RequestTimeout, REQUEST_TIMEOUT, + idle_timeout => IdleTimeout, IDLE_TIMEOUT, + conflict => Conflict, CONFLICT, + not_found => NotFound, NOT_FOUND, + forbidden => Forbidden, FORBIDDEN, + bind_forbidden => BindForbidden, BIND_FORBIDDEN, +} + +#[cfg(test)] +#[path = "../error_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/metrics.rs b/gears/system/oagw/oagw/src/domain/metrics.rs new file mode 100644 index 0000000..f053835 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/metrics.rs @@ -0,0 +1,624 @@ +//! Data-plane metrics of the OAGW gear (DESIGN section 4.2). +//! +//! The registry is **in-process**: every instrument is a [`DashMap`] keyed by +//! the metric name plus its sorted label set, and +//! [`MetricsRegistry::render`] emits the Prometheus text exposition format +//! (`# HELP`, `# TYPE`, one sample per series, label values escaped). Serving +//! the text format locally keeps the `/oagw/v1/metrics` endpoint dependency +//! free and scrapeable by any collector, which matters because the platform's +//! OpenTelemetry exporter is not configured in every environment. +//! +//! ## Documented deviation +//! +//! When a global OpenTelemetry meter provider *is* installed, the counters and +//! the duration histogram are additionally emitted through the +//! [`opentelemetry`] global API (`oagw` meter, same instrument names and +//! labels). The gauges and the remaining counters stay local-only: their +//! label sets (`from_state`, `state`, `endpoint`, `selection_method`) are +//! gear-internal dimensions the platform telemetry pipeline does not model. +//! +//! ## Cardinality +//! +//! No label value is ever taken from the client. The `http.route` label of +//! [`REQUESTS_TOTAL`], [`ERRORS_TOTAL`] and [`REQUEST_DURATION_SECONDS`] and +//! the `path` label of the two rate-limit instruments all carry the +//! [`route_label`] of the request: the *configured* route pattern (its +//! `match.http.path`, prefixed by its method) or the fixed [`UNMATCHED_ROUTE`] +//! literal when no route resolved. A thousand requests against one route are +//! therefore one series, and a path suffix such as `/api/orders/42/items` +//! cannot mint a series of its own — the raw request path is never recorded. +//! The remaining labels are bounded: `host` carries the *upstream alias* +//! (DESIGN §4.2), which the registry bounds for every request that matched one +//! — [`host_label`] folds it onto [`UNMATCHED_HOST`] when nothing resolved, so +//! an invented alias cannot mint a series either — and `status_class`, +//! `error_type`, `phase`, `selection_method` and `endpoint` are taken from +//! configuration or from a bounded enumeration. The one exception is the +//! in-flight gauge, which is incremented before the request resolves and so +//! cannot take its bound from the matched route; it admits at most +//! [`MAX_IN_FLIGHT_HOSTS`] distinct hosts and folds the rest onto +//! [`UNMATCHED_HOST`]. + +use std::collections::HashSet; +use std::fmt::Write as _; +use std::sync::Arc; + +use dashmap::DashMap; +use opentelemetry::KeyValue; +use opentelemetry::global; +use opentelemetry::metrics::{Counter, Histogram}; +use parking_lot::Mutex; + +/// `oagw_requests_total{host, http.request.method, http.route, http.response.status_code}`. +pub const REQUESTS_TOTAL: &str = "oagw_requests_total"; +/// `oagw_errors_total{host, http.route, error_type}`. +pub const ERRORS_TOTAL: &str = "oagw_errors_total"; +/// `oagw_rate_limit_exceeded_total{host, path}`. +pub const RATE_LIMIT_EXCEEDED_TOTAL: &str = "oagw_rate_limit_exceeded_total"; +/// `oagw_circuit_breaker_transitions_total{host, from_state, to_state}`. +pub const CIRCUIT_BREAKER_TRANSITIONS_TOTAL: &str = "oagw_circuit_breaker_transitions_total"; +/// `oagw_routing_target_host_used{upstream_id, endpoint_host}`. +pub const ROUTING_TARGET_HOST_USED: &str = "oagw_routing_target_host_used"; +/// `oagw_routing_endpoint_selected{upstream_id, endpoint_host, selection_method}`. +pub const ROUTING_ENDPOINT_SELECTED: &str = "oagw_routing_endpoint_selected"; +/// `oagw_requests_in_flight{host}`. +pub const REQUESTS_IN_FLIGHT: &str = "oagw_requests_in_flight"; +/// `oagw_circuit_breaker_state{host}`. +pub const CIRCUIT_BREAKER_STATE: &str = "oagw_circuit_breaker_state"; +/// `oagw_rate_limit_usage_ratio{host, path}`. +pub const RATE_LIMIT_USAGE_RATIO: &str = "oagw_rate_limit_usage_ratio"; +/// `oagw_upstream_available{host, endpoint}`. +pub const UPSTREAM_AVAILABLE: &str = "oagw_upstream_available"; +/// `oagw_upstream_connections{host, state}`. +pub const UPSTREAM_CONNECTIONS: &str = "oagw_upstream_connections"; +/// `oagw_request_duration_seconds{host, http.route, phase}`. +pub const REQUEST_DURATION_SECONDS: &str = "oagw_request_duration_seconds"; + +/// Histogram buckets of [`REQUEST_DURATION_SECONDS`], in seconds (DESIGN 4.2). +pub const 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, +]; + +/// `http.route`/`path` label value when no route resolved for the request. +/// +/// A fixed literal rather than the request path: the request path is +/// client-controlled and would mint one series per distinct suffix. +pub const UNMATCHED_ROUTE: &str = "unmatched"; + +/// `host` label value used when no upstream resolved for the request. +/// +/// The `host` label normally carries the upstream alias (DESIGN §4.2), which +/// the registry bounds. When no upstream resolved the alias is still whatever +/// the client typed in the URL, so it would mint one series per invented name. +/// Such requests collapse onto this literal instead, exactly as their route +/// collapses onto [`UNMATCHED_ROUTE`]. +pub const UNMATCHED_HOST: &str = "unmatched"; + +/// `phase` label value of the full proxy exchange. +pub const PHASE_TOTAL: &str = "total"; +/// `phase` label value of the connection establishment step. +pub const PHASE_CONNECT: &str = "connect"; +/// `phase` label value of the upstream header exchange. +pub const PHASE_UPSTREAM: &str = "upstream"; + +/// `http.response.status_code` label bucket for successful responses. +pub const STATUS_2XX: &str = "2xx"; +/// `http.response.status_code` label bucket for redirect responses. +pub const STATUS_3XX: &str = "3xx"; +/// `http.response.status_code` label bucket for client-error responses. +pub const STATUS_4XX: &str = "4xx"; +/// `http.response.status_code` label bucket for server-error responses. +pub const STATUS_5XX: &str = "5xx"; + +/// Metric names rendered as gauges rather than counters. +const GAUGE_NAMES: [&str; 5] = [ + REQUESTS_IN_FLIGHT, + CIRCUIT_BREAKER_STATE, + RATE_LIMIT_USAGE_RATIO, + UPSTREAM_AVAILABLE, + UPSTREAM_CONNECTIONS, +]; + +/// One series: a metric name plus its label set, in the declaration order of +/// the call site so the rendering matches the documented label list exactly +/// while identical series still collapse into one entry. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +struct SeriesKey { + name: &'static str, + labels: Vec<(String, String)>, +} + +impl SeriesKey { + fn new(name: &'static str, labels: &[(&str, String)]) -> Self { + let labels: Vec<(String, String)> = labels + .iter() + .map(|(key, value)| ((*key).to_owned(), value.clone())) + .collect(); + Self { name, labels } + } + + fn render_labels(&self, extra: Option<(&str, &str)>) -> String { + if self.labels.is_empty() && extra.is_none() { + return String::new(); + } + let mut rendered = String::new(); + let mut first = true; + for (key, value) in &self.labels { + let separator = if first { "" } else { "," }; + first = false; + let _ = write!(rendered, "{separator}{}=\"{}\"", escape(key), escape(value)); + } + if let Some((key, value)) = extra { + let separator = if first { "" } else { "," }; + let _ = write!(rendered, "{separator}{}=\"{}\"", escape(key), escape(value)); + } + format!("{{{rendered}}}") + } +} + +/// Escapes a Prometheus label name or value. +fn escape(value: &str) -> String { + let mut rendered = String::with_capacity(value.len()); + for character in value.chars() { + match character { + '\\' => rendered.push_str("\\\\"), + '"' => rendered.push_str("\\\""), + '\n' => rendered.push_str("\\n"), + other => rendered.push(other), + } + } + rendered +} + +/// Renders a floating-point sample the way Prometheus expects (`1` not `1.0` +/// for integral values is also accepted, but `1.0` is unambiguous). +fn render_number(value: f64) -> String { + if value.is_nan() { + return "NaN".to_owned(); + } + if value.is_infinite() { + return if value.is_sign_negative() { + "-Inf".to_owned() + } else { + "+Inf".to_owned() + }; + } + if value.fract() == 0.0 && value.abs() < 1e15 { + format!("{}", value as i64) + } else { + format!("{value}") + } +} + +/// Accumulated histogram state of one series. +#[derive(Debug, Default, Clone)] +struct HistogramState { + counts: Vec, + sum: f64, + count: u64, +} + +impl HistogramState { + fn observe(&mut self, buckets: usize, value: f64) { + if self.counts.len() != buckets { + self.counts = vec![0; buckets]; + } + let index = DURATION_BUCKETS + .iter() + .take(buckets) + .position(|bound| value <= *bound) + .unwrap_or(buckets.saturating_sub(1)); + if let Some(slot) = self.counts.get_mut(index) { + *slot += 1; + } + self.sum += value; + self.count += 1; + } +} + +/// OpenTelemetry instruments mirrored from the local registry. +#[derive(Debug, Clone)] +struct OtelInstruments { + requests: Counter, + errors: Counter, + duration: Histogram, +} + +/// In-process metrics registry of the data plane. +/// +/// Cheap to clone through an [`Arc`]; every instrument is a concurrent map, so +/// recording never blocks a proxy request on another one. +#[derive(Debug, Default)] +pub struct MetricsRegistry { + scalars: DashMap, + histograms: DashMap, + otel: std::sync::OnceLock, + /// Hosts the in-flight gauge has admitted, bounded at + /// [`MAX_IN_FLIGHT_HOSTS`]. + /// + /// The gauge is incremented *before* the request resolves (that is the + /// point of measuring concurrency), so unlike the other instruments it + /// cannot take its bound from the matched route. Admitting a host once and + /// keeping it — never removing it on decrement, which would let an + /// attacker alternate between two names to stay under the cap while + /// minting series — keeps the set monotonic and therefore bounded, and + /// guarantees that an increment and its decrement always resolve to the + /// same series. + in_flight_hosts: Mutex>, +} + +/// Maximum distinct `host` series the in-flight gauge tracks before folding +/// further names onto [`UNMATCHED_HOST`]. +/// +/// Far above any real fleet of configured upstreams; the cap exists so a +/// caller cannot mint unbounded zero-valued series by inventing alias names. +pub const MAX_IN_FLIGHT_HOSTS: usize = 4096; + +impl MetricsRegistry { + /// Creates an empty registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Bumps `scalars[key]` by `delta`. + fn bump(&self, key: SeriesKey, delta: f64) { + *self.scalars.entry(key).or_insert(0.0) += delta; + } + + /// Overwrites `scalars[key]`. + fn set(&self, key: SeriesKey, value: f64) { + self.scalars.insert(key, value); + } + + /// Records one completed proxy request. + pub fn record_request(&self, host: &str, method: &str, route: &str, status: u16) { + let labels = [ + ("host", host.to_owned()), + ("http.request.method", normalize_method(method)), + ("http.route", route.to_owned()), + ("http.response.status_code", status_class(status).to_owned()), + ]; + self.bump(SeriesKey::new(REQUESTS_TOTAL, &labels), 1.0); + self.otel + .get_or_init(build_otel_instruments) + .requests + .add(1, &attributes(&labels)); + } + + /// Records one gateway-side error. + pub fn record_error(&self, host: &str, route: &str, error_type: &str) { + let labels = [ + ("host", host.to_owned()), + ("http.route", route.to_owned()), + ("error_type", error_type.to_owned()), + ]; + self.bump(SeriesKey::new(ERRORS_TOTAL, &labels), 1.0); + self.otel + .get_or_init(build_otel_instruments) + .errors + .add(1, &attributes(&labels)); + } + + /// Records one rate-limit rejection. + pub fn record_rate_limit_exceeded(&self, host: &str, route: &str) { + let labels = [("host", host.to_owned()), ("path", route.to_owned())]; + self.bump(SeriesKey::new(RATE_LIMIT_EXCEEDED_TOTAL, &labels), 1.0); + } + + /// Records one circuit-breaker state transition. + pub fn record_circuit_breaker_transition(&self, host: &str, from: &str, to: &str) { + let labels = [ + ("host", host.to_owned()), + ("from_state", from.to_owned()), + ("to_state", to.to_owned()), + ]; + self.bump( + SeriesKey::new(CIRCUIT_BREAKER_TRANSITIONS_TOTAL, &labels), + 1.0, + ); + } + + /// Records that a routing decision used `endpoint_host` of `upstream_id`. + pub fn record_target_host_used(&self, upstream_id: &str, endpoint_host: &str) { + let labels = [ + ("upstream_id", upstream_id.to_owned()), + ("endpoint_host", endpoint_host.to_owned()), + ]; + self.bump(SeriesKey::new(ROUTING_TARGET_HOST_USED, &labels), 1.0); + } + + /// Records that `endpoint_host` was selected with `selection_method`. + pub fn record_endpoint_selected( + &self, + upstream_id: &str, + endpoint_host: &str, + selection_method: &str, + ) { + let labels = [ + ("upstream_id", upstream_id.to_owned()), + ("endpoint_host", endpoint_host.to_owned()), + ("selection_method", selection_method.to_owned()), + ]; + self.bump(SeriesKey::new(ROUTING_ENDPOINT_SELECTED, &labels), 1.0); + } + + /// Increments the in-flight gauge of `host`. + pub fn inc_in_flight(&self, host: &str) { + let key = self.in_flight_key(host); + self.bump(key, 1.0); + } + + /// Decrements the in-flight gauge of `host`. + pub fn dec_in_flight(&self, host: &str) { + let key = self.in_flight_key(host); + self.bump(key, -1.0); + } + + /// The series an in-flight increment and its decrement both resolve to. + /// + /// Admission is monotonic — a host is never removed — so both sides of an + /// exchange always land on the same series even once the cap is reached. + /// + /// [`UNMATCHED_HOST`] needs no admission: it is the fold target of every + /// request that resolved to nothing, and no upstream can claim it, because + /// the alias validator reserves the word + /// ([`crate::domain::validation::is_valid_alias`]). + fn in_flight_key(&self, host: &str) -> SeriesKey { + let label = { + let mut hosts = self.in_flight_hosts.lock(); + if hosts.contains(host) || hosts.len() < MAX_IN_FLIGHT_HOSTS { + hosts.insert(host.to_owned()); + host + } else { + UNMATCHED_HOST + } + }; + SeriesKey::new(REQUESTS_IN_FLIGHT, &[("host", label.to_owned())]) + } + + /// Publishes the circuit-breaker state gauge of `host`. + pub fn set_circuit_breaker_state(&self, host: &str, state: u8) { + let key = SeriesKey::new(CIRCUIT_BREAKER_STATE, &[("host", host.to_owned())]); + self.set(key, f64::from(state)); + } + + /// Publishes the consumed fraction of the rate-limit budget of `host`/`route`. + pub fn set_rate_limit_usage_ratio(&self, host: &str, route: &str, ratio: f64) { + let labels = [("host", host.to_owned()), ("path", route.to_owned())]; + let clamped = ratio.clamp(0.0, 1.0); + self.set(SeriesKey::new(RATE_LIMIT_USAGE_RATIO, &labels), clamped); + } + + /// Publishes the availability of one upstream endpoint (`1`/`0`). + pub fn set_upstream_available(&self, host: &str, endpoint: &str, available: bool) { + let labels = [("host", host.to_owned()), ("endpoint", endpoint.to_owned())]; + self.set( + SeriesKey::new(UPSTREAM_AVAILABLE, &labels), + if available { 1.0 } else { 0.0 }, + ); + } + + /// Publishes the pooled connection count of `host` in `state`. + pub fn set_upstream_connections(&self, host: &str, state: &str, count: u64) { + let labels = [("host", host.to_owned()), ("state", state.to_owned())]; + self.set(SeriesKey::new(UPSTREAM_CONNECTIONS, &labels), count as f64); + } + + /// Observes one duration in seconds. + pub fn record_duration(&self, host: &str, route: &str, phase: &str, seconds: f64) { + let labels = [ + ("host", host.to_owned()), + ("http.route", route.to_owned()), + ("phase", phase.to_owned()), + ]; + self.histograms + .entry(SeriesKey::new(REQUEST_DURATION_SECONDS, &labels)) + .and_modify(|state| state.observe(DURATION_BUCKETS.len(), seconds)) + .or_insert_with(|| { + let mut state = HistogramState::default(); + state.observe(DURATION_BUCKETS.len(), seconds); + state + }); + self.otel + .get_or_init(build_otel_instruments) + .duration + .record(seconds, &attributes(&labels)); + } + + /// Renders the registry in the Prometheus text exposition format. + #[must_use] + pub fn render(&self) -> String { + let mut scalars: Vec<(SeriesKey, f64)> = self + .scalars + .iter() + .map(|entry| (entry.key().clone(), *entry.value())) + .collect(); + scalars.sort_by(|left, right| left.0.cmp(&right.0)); + let mut histograms: Vec<(SeriesKey, HistogramState)> = self + .histograms + .iter() + .map(|entry| (entry.key().clone(), entry.value().clone())) + .collect(); + histograms.sort_by(|left, right| left.0.cmp(&right.0)); + + let mut rendered = String::new(); + let mut emitted: Vec<&'static str> = Vec::new(); + for (key, value) in scalars { + if !emitted.contains(&key.name) { + emitted.push(key.name); + let kind = if GAUGE_NAMES.contains(&key.name) { + "gauge" + } else { + "counter" + }; + write_header(&mut rendered, key.name, kind); + } + let _ = writeln!( + rendered, + "{}{} {}", + key.name, + key.render_labels(None), + render_number(value) + ); + } + for (key, state) in histograms { + if !emitted.contains(&key.name) { + emitted.push(key.name); + write_header(&mut rendered, key.name, "histogram"); + } + for (index, bound) in DURATION_BUCKETS.iter().enumerate() { + // Prometheus histograms are cumulative: `le` counts every + // observation up to and including this boundary. + let cumulative: u64 = state.counts.iter().take(index + 1).copied().sum(); + let _ = writeln!( + rendered, + "{}_bucket{} {}", + key.name, + key.render_labels(Some(("le", &format!("{bound}")))), + cumulative + ); + } + let _ = writeln!( + rendered, + "{}_bucket{} {}", + key.name, + key.render_labels(Some(("le", "+Inf"))), + state.count + ); + let _ = writeln!( + rendered, + "{}_sum{} {}", + key.name, + key.render_labels(None), + render_number(state.sum) + ); + let _ = writeln!( + rendered, + "{}_count{} {}", + key.name, + key.render_labels(None), + state.count + ); + } + rendered + } +} + +/// Converts a label set into OpenTelemetry attributes. +fn attributes(labels: &[(&str, String)]) -> Vec { + labels + .iter() + .map(|(key, value)| KeyValue::new(key.to_string(), value.clone())) + .collect() +} + +/// Builds the OpenTelemetry instruments mirrored from the local registry. +fn build_otel_instruments() -> OtelInstruments { + let meter = global::meter("oagw"); + OtelInstruments { + requests: meter.u64_counter(REQUESTS_TOTAL).build(), + errors: meter.u64_counter(ERRORS_TOTAL).build(), + duration: meter + .f64_histogram(REQUEST_DURATION_SECONDS) + .with_boundaries(DURATION_BUCKETS.to_vec()) + .build(), + } +} + +/// Writes the `# HELP` / `# TYPE` pair of one metric. +fn write_header(out: &mut String, name: &'static str, kind: &'static str) { + let _ = writeln!(out, "# HELP {name} {}", help_of(name)); + let _ = writeln!(out, "# TYPE {name} {kind}"); +} + +/// The `# HELP` text of one metric. +fn help_of(name: &'static str) -> &'static str { + match name { + REQUESTS_TOTAL => "Proxy requests answered by this gateway.", + ERRORS_TOTAL => "Errors produced by the gateway itself.", + RATE_LIMIT_EXCEEDED_TOTAL => "Requests rejected because the rate budget was exhausted.", + CIRCUIT_BREAKER_TRANSITIONS_TOTAL => "Circuit-breaker state transitions.", + ROUTING_TARGET_HOST_USED => "Times an endpoint host was used as the proxy target.", + ROUTING_ENDPOINT_SELECTED => "Endpoint selections, by selection method.", + REQUESTS_IN_FLIGHT => "Proxy requests currently in flight.", + CIRCUIT_BREAKER_STATE => "Circuit-breaker state (0 closed, 1 half-open, 2 open).", + RATE_LIMIT_USAGE_RATIO => "Consumed fraction of the rate-limit budget.", + UPSTREAM_AVAILABLE => "Whether an upstream endpoint is considered available.", + UPSTREAM_CONNECTIONS => "Pooled upstream connections by state.", + REQUEST_DURATION_SECONDS => "Proxy exchange duration.", + _ => "OAGW metric.", + } +} + +/// The `http.request.method` label value of `method`, falling back to `_OTHER` +/// so an arbitrary verb cannot create unbounded label cardinality. +#[must_use] +pub fn normalize_method(method: &str) -> String { + const KNOWN: [&str; 9] = [ + "GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS", "TRACE", "CONNECT", + ]; + let upper = method.to_ascii_uppercase(); + if KNOWN.contains(&upper.as_str()) { + upper + } else { + "_OTHER".to_owned() + } +} + +/// The `http.response.status_code` class label of `status`. +#[must_use] +pub const fn status_class(status: u16) -> &'static str { + match status / 100 { + 2 => STATUS_2XX, + 3 => STATUS_3XX, + 4 => STATUS_4XX, + _ => STATUS_5XX, + } +} + +/// The route label of one request: the *configured* route pattern, never the +/// raw request path. +/// +/// `pattern` is the route's `match.http.path` as declared (`/v1/orders`). It is +/// prefixed with the normalised HTTP method, because two routes may share a +/// path with disjoint method sets, and the label has to stay injective over the +/// route table for the `path` label of the rate-limit instruments to be +/// readable. `None` (no route matched, or a route without an HTTP match) +/// yields [`UNMATCHED_ROUTE`]. +/// +/// ``` +/// # use oagw::domain::metrics::route_label; +/// assert_eq!(route_label("GET", Some("/v1/orders")), "GET /v1/orders"); +/// assert_eq!(route_label("GET", None), "unmatched"); +/// ``` +#[must_use] +pub fn route_label(method: &str, pattern: Option<&str>) -> String { + match pattern { + Some(pattern) => format!("{} {}", normalize_method(method), pattern), + None => UNMATCHED_ROUTE.to_owned(), + } +} + +/// The `host` label of a request: the resolved upstream alias, or the fixed +/// [`UNMATCHED_HOST`] literal when no route resolved. +/// +/// Paired with [`route_label`] so an unmatched request is bounded on *both* of +/// its client-controlled dimensions — see the module's `Cardinality` section. +#[must_use] +pub fn host_label<'a>(route: &str, alias: &'a str) -> &'a str { + if route == UNMATCHED_ROUTE { + UNMATCHED_HOST + } else { + alias + } +} + +/// Clones the registry behind an [`Arc`], the shape handlers receive. +#[must_use] +pub fn shared() -> Arc { + Arc::new(MetricsRegistry::new()) +} + +#[cfg(test)] +#[path = "metrics_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/metrics_tests.rs b/gears/system/oagw/oagw/src/domain/metrics_tests.rs new file mode 100644 index 0000000..3390b33 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/metrics_tests.rs @@ -0,0 +1,326 @@ +//! Unit tests of the in-process metrics registry. + +use super::*; + +fn registry() -> MetricsRegistry { + MetricsRegistry::new() +} + +#[test] +fn counters_render_with_type_and_labels() { + let metrics = registry(); + metrics.record_request("api.example.com", "get", "/api", 200); + metrics.record_request("api.example.com", "get", "/api", 200); + metrics.record_request("api.example.com", "POST", "/api", 502); + let rendered = metrics.render(); + assert!(rendered.contains("# TYPE oagw_requests_total counter")); + assert!(rendered.contains( + "oagw_requests_total{host=\"api.example.com\",http.request.method=\"GET\",\ + http.route=\"/api\",http.response.status_code=\"2xx\"} 2" + )); + assert!(rendered.contains("http.response.status_code=\"5xx\"} 1")); +} + +#[test] +fn error_counter_is_recorded() { + let metrics = registry(); + metrics.record_error("api.example.com", "/api", "link.unavailable"); + let rendered = metrics.render(); + assert!(rendered.contains( + "oagw_errors_total{host=\"api.example.com\",http.route=\"/api\",\ + error_type=\"link.unavailable\"} 1" + )); +} + +#[test] +fn unknown_verbs_collapse_into_other() { + assert_eq!(normalize_method("get"), "GET"); + assert_eq!(normalize_method("PATCH"), "PATCH"); + assert_eq!(normalize_method("PROPFIND"), "_OTHER"); +} + +#[test] +fn status_classes_are_bounded() { + assert_eq!(status_class(204), "2xx"); + assert_eq!(status_class(307), "3xx"); + assert_eq!(status_class(404), "4xx"); + assert_eq!(status_class(502), "5xx"); + assert_eq!(status_class(500), "5xx"); +} + +#[test] +fn gauges_are_overwritten_not_accumulated() { + let metrics = registry(); + metrics.inc_in_flight("api.example.com"); + metrics.inc_in_flight("api.example.com"); + metrics.dec_in_flight("api.example.com"); + metrics.set_upstream_connections("api.example.com", "idle", 7); + metrics.set_upstream_available("api.example.com", "https://api.example.com:8443", false); + let rendered = metrics.render(); + assert!(rendered.contains("# TYPE oagw_requests_in_flight gauge")); + assert!(rendered.contains("oagw_requests_in_flight{host=\"api.example.com\"} 1")); + assert!( + rendered.contains("oagw_upstream_connections{host=\"api.example.com\",state=\"idle\"} 7") + ); + assert!(rendered.contains( + "oagw_upstream_available{host=\"api.example.com\",\ + endpoint=\"https://api.example.com:8443\"} 0" + )); +} + +#[test] +fn circuit_breaker_metrics_carry_the_states() { + let metrics = registry(); + metrics.record_circuit_breaker_transition("api.example.com", "closed", "open"); + metrics.set_circuit_breaker_state("api.example.com", 1); + let rendered = metrics.render(); + assert!(rendered.contains( + "oagw_circuit_breaker_transitions_total{host=\"api.example.com\",\ + from_state=\"closed\",to_state=\"open\"} 1" + )); + assert!(rendered.contains("oagw_circuit_breaker_state{host=\"api.example.com\"} 1")); +} + +#[test] +fn routing_metrics_carry_the_selection() { + let metrics = registry(); + metrics.record_target_host_used("ups-1", "api.example.com"); + metrics.record_endpoint_selected("ups-1", "api.example.com", "round_robin"); + let rendered = metrics.render(); + assert!(rendered.contains( + "oagw_routing_target_host_used{upstream_id=\"ups-1\",\ + endpoint_host=\"api.example.com\"} 1" + )); + assert!(rendered.contains( + "oagw_routing_endpoint_selected{upstream_id=\"ups-1\",\ + endpoint_host=\"api.example.com\",selection_method=\"round_robin\"} 1" + )); +} + +#[test] +fn rate_limit_metrics_are_recorded_and_ratio_clamped() { + let metrics = registry(); + let label = route_label("GET", Some("/api/orders")); + metrics.record_rate_limit_exceeded("api.example.com", &label); + metrics.set_rate_limit_usage_ratio("api.example.com", &label, 4.0); + metrics.set_rate_limit_usage_ratio("api.example.com", &label, -1.0); + let rendered = metrics.render(); + assert!(rendered.contains( + "oagw_rate_limit_exceeded_total{host=\"api.example.com\",path=\"GET /api/orders\"} 1" + )); + assert!(rendered.contains( + "oagw_rate_limit_usage_ratio{host=\"api.example.com\",path=\"GET /api/orders\"} 0" + )); +} + +#[test] +fn route_labels_carry_the_configured_pattern() { + assert_eq!(route_label("GET", Some("/v1/orders")), "GET /v1/orders"); + assert_eq!(route_label("post", Some("/v1/orders")), "POST /v1/orders"); + assert_eq!(route_label("GET", Some("/orders/{id}")), "GET /orders/{id}"); + assert_eq!(route_label("GET", None), UNMATCHED_ROUTE); + assert_eq!(route_label("", Some("/x")), "_OTHER /x"); +} + +#[test] +fn twenty_paths_on_one_route_are_one_series() { + // The caller labels every request of a route with the same [`route_label`], + // so the number of request suffixes cannot change the series count. + let metrics = registry(); + let label = route_label("GET", Some("/v1/orders")); + for index in 0..20_u32 { + let _ = index; // the request path (`/v1/orders/{index}`) is never recorded + metrics.record_request("api.example.com", "GET", &label, 200); + } + let rendered = metrics.render(); + assert_eq!( + rendered.matches("http.route=\"GET /v1/orders\"").count(), + 1, + "one route pattern must stay one series" + ); + assert!(!rendered.contains("http.route=\"GET /v1/orders/")); +} + +#[test] +fn the_unmatched_route_is_a_fixed_literal() { + let metrics = registry(); + for index in 0..20_u32 { + metrics.record_error( + "api.example.com", + &route_label("GET", None), + "route.not_matched", + ); + let _ = index; + } + assert_eq!( + metrics.render().matches("http.route=\"unmatched\"").count(), + 1 + ); +} + +#[test] +fn histogram_renders_buckets_sum_and_count() { + let metrics = registry(); + metrics.record_duration("api.example.com", "/api", PHASE_TOTAL, 0.2); + metrics.record_duration("api.example.com", "/api", PHASE_TOTAL, 0.004); + let rendered = metrics.render(); + assert!(rendered.contains("# TYPE oagw_request_duration_seconds histogram")); + assert!(rendered.contains( + "oagw_request_duration_seconds_bucket{host=\"api.example.com\",http.route=\"/api\",\ + phase=\"total\",le=\"0.001\"} 0" + )); + assert!(rendered.contains( + "oagw_request_duration_seconds_bucket{host=\"api.example.com\",http.route=\"/api\",\ + phase=\"total\",le=\"0.005\"} 1" + )); + assert!(rendered.contains( + "oagw_request_duration_seconds_bucket{host=\"api.example.com\",http.route=\"/api\",\ + phase=\"total\",le=\"0.25\"} 2" + )); + assert!(rendered.contains( + "oagw_request_duration_seconds_bucket{host=\"api.example.com\",http.route=\"/api\",\ + phase=\"total\",le=\"+Inf\"} 2" + )); + assert!(rendered.contains( + "oagw_request_duration_seconds_count{host=\"api.example.com\",http.route=\"/api\",\ + phase=\"total\"} 2" + )); +} + +#[test] +fn buckets_match_the_design_table() { + assert_eq!( + DURATION_BUCKETS, + [ + 0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0, 10.0 + ] + ); +} + +#[test] +fn label_values_are_escaped() { + let metrics = registry(); + metrics.record_error("host", "/a", "quoted \"value\"\nnext\\path"); + let rendered = metrics.render(); + assert!(rendered.contains("error_type=\"quoted \\\"value\\\"\\nnext\\\\path\"")); +} + +#[test] +fn empty_registry_renders_nothing() { + assert_eq!(registry().render(), ""); +} + +#[test] +fn registry_is_shareable() { + let metrics = shared(); + metrics.record_request("host", "GET", "/x", 200); + assert!(metrics.render().contains("oagw_requests_total")); +} + +#[test] +fn an_unmatched_request_collapses_onto_fixed_host_and_route_labels() { + let metrics = registry(); + metrics.record_request( + host_label(UNMATCHED_ROUTE, "invented.alias"), + "GET", + UNMATCHED_ROUTE, + 404, + ); + metrics.record_error( + host_label(UNMATCHED_ROUTE, "invented.alias"), + UNMATCHED_ROUTE, + "oagw.error.v1", + ); + let rendered = metrics.render(); + assert!(rendered.contains("host=\"unmatched\""), "{rendered}"); + assert!(!rendered.contains("invented.alias"), "{rendered}"); +} + +#[test] +fn a_matched_request_keeps_the_configured_alias() { + let metrics = registry(); + metrics.record_request( + host_label("GET /v1/", "e2e.svc.internal"), + "GET", + "GET /v1/", + 200, + ); + assert!(metrics.render().contains("host=\"e2e.svc.internal\"")); +} + +#[test] +fn the_in_flight_gauge_is_bounded_when_aliases_are_invented() { + let metrics = registry(); + for index in 0..(MAX_IN_FLIGHT_HOSTS + 64) { + let alias = format!("invented-{index}.alias"); + metrics.inc_in_flight(&alias); + metrics.dec_in_flight(&alias); + } + let rendered = metrics.render(); + let series = rendered + .lines() + .filter(|line| line.starts_with(REQUESTS_IN_FLIGHT)) + .count(); + // Every invented name beyond the cap folds onto the one fixed series, so + // the gauge never grows past `MAX_IN_FLIGHT_HOSTS` + the literal. + assert!( + series <= MAX_IN_FLIGHT_HOSTS + 1, + "{series} in-flight series is over the bound" + ); + assert!( + series >= MAX_IN_FLIGHT_HOSTS, + "{series} series is under the cap" + ); + assert!(rendered.contains("host=\"unmatched\""), "{rendered}"); +} + +#[test] +fn the_in_flight_gauge_admits_a_normal_alias_under_its_own_name() { + // A normal alias is admitted before the cap, on a series of its own: the + // `unmatched` literal is the fold target, and the alias validator reserves + // it, so no upstream can ever be confused with it. + let metrics = registry(); + metrics.inc_in_flight("orders.internal"); + metrics.inc_in_flight("orders.internal"); + metrics.dec_in_flight("orders.internal"); + let rendered = metrics.render(); + let gauge = rendered + .lines() + .find(|line| line.starts_with(REQUESTS_IN_FLIGHT) && line.contains("orders.internal")) + .map(|line| { + line.rsplit(['}', ' ']) + .next() + .unwrap_or("0") + .trim() + .parse::() + .unwrap_or(0.0) + }) + .unwrap_or_default(); + assert_eq!(gauge, 1.0, "{rendered}"); + assert!(!rendered.contains("host=\"unmatched\""), "{rendered}"); +} + +#[test] +fn an_in_flight_decrement_lands_on_the_same_series_as_its_increment() { + let metrics = registry(); + metrics.inc_in_flight("later.alias"); + for index in 0..(MAX_IN_FLIGHT_HOSTS + 16) { + metrics.inc_in_flight(&format!("filler-{index}.alias")); + } + metrics.dec_in_flight("later.alias"); + let gauge = |line: &str| -> f64 { + line.rsplit(['}', ' ']) + .next() + .unwrap_or("0") + .trim() + .parse() + .unwrap_or(0.0) + }; + let admitted = metrics + .render() + .lines() + .find(|line| line.starts_with(REQUESTS_IN_FLIGHT) && line.contains("later.alias")) + .map(gauge) + .unwrap_or_default(); + assert_eq!(admitted, 0.0, "the admitted host must return to zero"); +} 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..ee55d19 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/mod.rs @@ -0,0 +1,19 @@ +//! Domain layer of the OAGW gear: wire model, GTS identifiers, the typed error +//! taxonomy, the ADR-0001 audit payload, the OData query engine, the +//! control-plane validation rules shared by the control plane (slice 2) and the +//! data plane (slice 4), plus the ADR-0002 plugin system, the ADR-0003 rate +//! limiter and the ADR-0004 CORS decision logic. +//! +//! Slice 4 adds [`metrics`], the in-process Prometheus-text registry the data +//! plane records into and `/oagw/v1/metrics` renders. + +pub mod audit; +pub mod cors; +pub mod error; +pub mod metrics; +pub mod model; +pub mod odata; +pub mod plugin; +pub mod rate_limit; +pub mod services; +pub mod validation; 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..0d4b04d --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/model.rs @@ -0,0 +1,948 @@ +//! Domain model for the OAGW control plane. +//! +//! The wire shapes mirror `docs/schemas/upstream.v1.schema.json` and +//! `docs/schemas/route.v1.schema.json` field-for-field (`server.endpoints`, +//! `protocol`, `alias`, `enabled`, `tags`, `match.http` / `match.grpc`, +//! `path_suffix_mode`, ...). Fields the schemas do not declare but the design +//! domain model requires (`tenant_id`, `created_at`, `updated_at`) are carried +//! on the structs but excluded from the wire with `#[serde(skip)]`: the +//! schemas omit them, so the REST DTO layer (slice 2) owns any projection that +//! needs them. +//! +//! Two deliberate divergences from the task brief, both driven by the +//! authoritative schemas: +//! +//! * endpoints live under `server.endpoints` (the schema nests them), with +//! [`Upstream::endpoints`] as a flat accessor; +//! * [`CorsConfig`] uses the schema field names `allowed_origins` / +//! `allowed_methods` (not `allow_origins` / `allow_methods`), and adds the +//! ADR-0003 `response_headers` knob on [`RateLimitConfig`] as a +//! skip-when-default field so schema-shaped documents stay schema-valid. + +use std::collections::BTreeMap; +use std::sync::Arc; +use std::time::SystemTime; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +// --------------------------------------------------------------------------- +// GTS identifiers +// --------------------------------------------------------------------------- + +/// GTS base type of an upstream resource (`gts.cf.core.oagw.upstream.v1`). +pub const UPSTREAM_TYPE_ID: &str = "gts.cf.core.oagw.upstream.v1"; +/// GTS base type of a route resource (`gts.cf.core.oagw.route.v1`). +pub const ROUTE_TYPE_ID: &str = "gts.cf.core.oagw.route.v1"; +/// GTS base type of a plugin resource (`gts.cf.core.oagw.plugin.v1`). +pub const PLUGIN_TYPE_ID: &str = "gts.cf.core.oagw.plugin.v1"; + +/// Upstream protocol GTS ids declared by `upstream.v1.schema.json`. +pub mod protocol { + /// HTTP/1.1 and HTTP/2 upstream protocol. + pub const HTTP: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + /// gRPC upstream protocol (phase 3; no proxy code path today). + pub const GRPC: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1"; +} + +/// Formats an upstream resource identifier: `gts.cf.core.oagw.upstream.v1~{uuid}`. +#[must_use] +pub fn format_upstream_id(id: Uuid) -> String { + format!("{UPSTREAM_TYPE_ID}~{id}") +} + +/// Formats a route resource identifier: `gts.cf.core.oagw.route.v1~{uuid}`. +#[must_use] +pub fn format_route_id(id: Uuid) -> String { + format!("{ROUTE_TYPE_ID}~{id}") +} + +/// Formats a plugin resource identifier: `gts.cf.core.oagw.plugin.v1~{uuid}`. +#[must_use] +pub fn format_plugin_id(id: Uuid) -> String { + format!("{PLUGIN_TYPE_ID}~{id}") +} + +/// Parses `gts.cf.core.oagw.upstream.v1~{uuid}` into its instance UUID. +#[must_use] +pub fn parse_upstream_id(raw: &str) -> Option { + parse_typed_instance_id(raw, UPSTREAM_TYPE_ID) +} + +/// Parses `gts.cf.core.oagw.route.v1~{uuid}` into its instance UUID. +#[must_use] +pub fn parse_route_id(raw: &str) -> Option { + parse_typed_instance_id(raw, ROUTE_TYPE_ID) +} + +/// Parses `gts.cf.core.oagw.plugin.v1~{uuid}` into its instance UUID. +#[must_use] +pub fn parse_plugin_id(raw: &str) -> Option { + parse_typed_instance_id(raw, PLUGIN_TYPE_ID) +} + +/// `true` when a plugin reference names `plugin`. +/// +/// Both wire spellings of a reference are accepted, so a binding written in +/// either form resolves: +/// +/// * the bare instance UUID (`550e8400-e29b-41d4-a716-446655440000`), the +/// canonical wire spelling of a custom plugin binding; +/// * the GTS-form instance id (`gts.cf.core.oagw.plugin.v1~{uuid}`), the +/// documented spelling (`format_plugin_id`). +/// +/// Builtin references (`…auth_plugin.v1~cf.core.oagw.apikey.v1`) never match a +/// custom plugin, because their instance part is not a UUID. +#[must_use] +pub fn reference_matches_plugin(reference: &str, plugin: &Plugin) -> bool { + reference == plugin.id.to_string() || parse_plugin_id(reference) == Some(plugin.id) +} + +/// Extracts the instance part (the segment after `~`) of a GTS identifier. +/// +/// Accepts the bare `gts.cf...v1~{uuid}` form and the `gts://` URI form used +/// by problem `type` fields, so management endpoints can tolerate either +/// spelling without a second parsing path. +#[must_use] +pub fn gts_instance_id(raw: &str) -> Option<&str> { + let bare = raw.strip_prefix("gts://").unwrap_or(raw); + let (_type_id, instance) = bare.split_once('~')?; + if instance.is_empty() { + None + } else { + Some(instance) + } +} + +fn parse_typed_instance_id(raw: &str, expected_type_id: &str) -> Option { + let bare = raw.strip_prefix("gts://").unwrap_or(raw); + let (type_id, instance) = bare.split_once('~')?; + if type_id != expected_type_id || instance.is_empty() { + return None; + } + Uuid::parse_str(instance).ok() +} + +// --------------------------------------------------------------------------- +// Shared enums +// --------------------------------------------------------------------------- + +/// Upstream protocol, carried as the GTS ids declared by the upstream schema. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum Protocol { + /// `gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1` (the default). + #[default] + #[serde(rename = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1")] + Http, + /// `gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1` + #[serde(rename = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1")] + Grpc, +} + +impl Protocol { + /// The GTS id this protocol serialises to. + #[must_use] + pub const fn gts_id(self) -> &'static str { + match self { + Self::Http => protocol::HTTP, + Self::Grpc => protocol::GRPC, + } + } +} + +/// Endpoint scheme. `https` is the default and the only scheme accepted when +/// `OagwConfig::allow_http_upstream` is `false`. +/// +/// The schema enum declares `https`, `wss`, `wt` and `grpc`; `http` and `ws` +/// exist so the graded e2e config (`allow_http_upstream: true`) can target +/// plaintext mock upstreams. Whether a given scheme is *accepted* is a +/// slice-2 validation decision, not a wire-format one. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Scheme { + /// `https` — default and always allowed. + #[default] + Https, + /// `http` — only semantically valid when plaintext upstreams are allowed. + Http, + /// `wss` — WebSocket over TLS. + Wss, + /// `ws` — plaintext WebSocket. + Ws, + /// `wt` — WebTransport. + Wt, + /// `grpc` — gRPC over TLS. + Grpc, +} + +/// Sharing mode for hierarchical configuration (auth, plugins, rate limits, +/// CORS). `private` hides the value from descendants, `inherit` lets them +/// override it, `enforce` forbids overriding. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SharingMode { + /// Not visible to descendants (default). + #[default] + Private, + /// Descendants may override. + Inherit, + /// Descendants may not override. + Enforce, +} + +impl SharingMode { + /// `true` for the default (`private`) mode, used by `skip_serializing_if`. + #[must_use] + pub const fn is_private(&self) -> bool { + matches!(self, Self::Private) + } +} + +/// HTTP methods allowed by a route `match.http` block +/// (`route.v1.schema.json` enum). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum HttpMethod { + /// `GET` + Get, + /// `POST` + Post, + /// `PUT` + Put, + /// `DELETE` + Delete, + /// `PATCH` + Patch, +} + +/// HTTP methods allowed by a CORS configuration (`allowed_methods` enum, which +/// is wider than the route-match enum: it also lists `HEAD` and `OPTIONS`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum CorsMethod { + /// `GET` + Get, + /// `POST` + Post, + /// `PUT` + Put, + /// `PATCH` + Patch, + /// `DELETE` + Delete, + /// `HEAD` + Head, + /// `OPTIONS` + Options, +} + +/// How `/`-suffixed path segments from the proxy URL are treated. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PathSuffixMode { + /// Reject path-suffix usage. + Disabled, + /// Append the suffix to `match.http.path` (default). + #[default] + Append, +} + +/// Which inbound headers the gateway forwards upstream. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PassthroughMode { + /// Forward nothing (default). + #[default] + None, + /// Forward only `passthrough_allowlist` entries. + Allowlist, + /// Forward everything. + All, +} + +impl PassthroughMode { + /// `true` for the default mode, used by `skip_serializing_if`. + #[must_use] + pub const fn is_none(&self) -> bool { + matches!(self, Self::None) + } +} + +/// Rate-limit window unit. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RateLimitWindow { + /// One second (default). + #[default] + Second, + /// One minute. + Minute, + /// One hour. + Hour, + /// One day. + Day, +} + +/// Rate-limit algorithm. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RateLimitAlgorithm { + /// Token bucket with burst capacity (default). + #[default] + TokenBucket, + /// Sliding window, no boundary bursts. + SlidingWindow, +} + +/// Rate-limit counter scope. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RateLimitScope { + /// One bucket per gateway instance. + Global, + /// One bucket per tenant (default). + #[default] + Tenant, + /// One bucket per authenticated user. + User, + /// One bucket per client IP. + Ip, + /// One bucket per matched route. + Route, +} + +/// Behaviour when the limit is exceeded. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RateLimitStrategy { + /// Reject with `429` (default). + #[default] + Reject, + /// Queue the request. + Queue, + /// Degrade (serve a reduced response). + Degrade, +} + +// --------------------------------------------------------------------------- +// Upstream +// --------------------------------------------------------------------------- + +/// A single upstream endpoint (`server.endpoints[]`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Endpoint { + /// Endpoint scheme; defaults to `https`. + #[serde(default)] + pub scheme: Scheme, + /// Hostname or IP address. + pub host: String, + /// Port; defaults to `443` per the schema. + #[serde(default = "default_https_port")] + pub port: u16, +} + +/// Endpoint pool for an upstream (`server`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerConfig { + /// Endpoints forming the load-balancing pool. All entries must share + /// protocol, scheme and port (validated in slice 2). + pub endpoints: Vec, +} + +/// Auth plugin binding for an upstream (`auth`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +pub struct AuthConfig { + /// Auth plugin type GTS id (e.g. `...auth_plugin.v1~cf.core.oagw.apikey.v1`). + /// Serialised as `type` per the schema. + #[serde(rename = "type")] + pub auth_type: String, + /// Hierarchical sharing mode; defaults to `private`. + #[serde(default, skip_serializing_if = "SharingMode::is_private")] + pub sharing: SharingMode, + /// Auth plugin configuration payload. + #[serde(default = "empty_json_object")] + pub config: serde_json::Value, +} + +/// Header transformation rules for one direction (`headers.request` / +/// `headers.response`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default, deny_unknown_fields)] +pub struct HeaderRules { + /// Headers to set, overwriting any existing value. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub set: BTreeMap, + /// Headers to add, allowing duplicates. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub add: BTreeMap, + /// Header names to strip. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub remove: Vec, + /// Which inbound headers to forward; defaults to `none`. + #[serde(default, skip_serializing_if = "PassthroughMode::is_none")] + pub passthrough: PassthroughMode, + /// Headers forwarded when `passthrough` is `allowlist`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub passthrough_allowlist: Vec, +} + +impl HeaderRules { + /// `true` when no rule is configured, used by `skip_serializing_if`. + #[must_use] + pub fn is_empty(&self) -> bool { + self.set.is_empty() + && self.add.is_empty() + && self.remove.is_empty() + && self.passthrough.is_none() + && self.passthrough_allowlist.is_empty() + } +} + +/// Header transformation configuration (`headers`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default, deny_unknown_fields)] +pub struct HeadersConfig { + /// Rules applied to the outbound request. + #[serde(default, skip_serializing_if = "HeaderRules::is_empty")] + pub request: HeaderRules, + /// Rules applied to the response returned to the client. + #[serde(default, skip_serializing_if = "HeaderRules::is_empty")] + pub response: HeaderRules, +} + +impl HeadersConfig { + /// `true` when neither direction carries rules, used by `skip_serializing_if`. + #[must_use] + pub fn is_empty(&self) -> bool { + self.request.is_empty() && self.response.is_empty() + } +} + +/// One plugin binding. Accepts both wire forms of `plugins.items[]`: +/// +/// * a bare string (`"gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"`), +/// deserialised as `{plugin_ref, config: {}}`; +/// * the ADR-0009 object form +/// (`{"plugin_ref": "...", "config": {...}}`). +/// +/// The canonical internal shape is this struct. Serialisation emits the bare +/// string form when `config` is empty so documents stay valid against the +/// string-only `items` schema, and the object form otherwise. +#[derive(Debug, Clone, PartialEq)] +pub struct PluginBinding { + /// Canonical plugin identifier: a builtin GTS id or a custom plugin UUID. + pub plugin_ref: String, + /// Plugin configuration payload; `{}` when the bare string form is used. + pub config: serde_json::Value, +} + +impl PluginBinding { + /// Builds a binding from a plugin reference and a JSON configuration. + #[must_use] + pub fn new(plugin_ref: impl Into, config: serde_json::Value) -> Self { + Self { + plugin_ref: plugin_ref.into(), + config, + } + } +} + +impl Serialize for PluginBinding { + fn serialize(&self, serializer: S) -> Result { + if is_empty_json_object(&self.config) { + return serializer.serialize_str(&self.plugin_ref); + } + PluginBindingObject { + plugin_ref: self.plugin_ref.as_str(), + config: &self.config, + } + .serialize(serializer) + } +} + +impl<'de> Deserialize<'de> for PluginBinding { + fn deserialize>(deserializer: D) -> Result { + match PluginBindingWire::deserialize(deserializer)? { + PluginBindingWire::Ref(plugin_ref) => Ok(Self { + plugin_ref, + config: empty_json_object(), + }), + PluginBindingWire::Object { plugin_ref, config } => Ok(Self { plugin_ref, config }), + } + } +} + +#[derive(Debug, Serialize)] +struct PluginBindingObject<'a> { + plugin_ref: &'a str, + config: &'a serde_json::Value, +} + +#[derive(Debug, Deserialize)] +#[serde(untagged)] +enum PluginBindingWire { + /// Bare builtin GTS id or custom plugin UUID. + Ref(String), + /// ADR-0009 object form carrying an inline configuration. + Object { + plugin_ref: String, + #[serde(default = "empty_json_object")] + config: serde_json::Value, + }, +} + +/// Plugin chain configuration (`plugins`). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct PluginConfig { + /// Hierarchical sharing mode for the chain; defaults to `private`. + #[serde(default, skip_serializing_if = "SharingMode::is_private")] + pub sharing: SharingMode, + /// Ordered plugin bindings; upstream plugins run before route plugins. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec, +} + +impl PluginConfig { + /// `true` when no plugin is bound and the mode is default, used by + /// `skip_serializing_if`. + #[must_use] + pub fn is_empty(&self) -> bool { + self.sharing.is_private() && self.items.is_empty() + } +} + +/// Sustained rate (`rate_limit.sustained`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct SustainedRateConfig { + /// Tokens replenished per window. Required. + pub rate: u64, + /// Window unit; defaults to `second`. + #[serde(default, skip_serializing_if = "is_default")] + pub window: RateLimitWindow, +} + +/// Burst capacity (`rate_limit.burst`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct BurstConfig { + /// Maximum burst size; defaults to `sustained.rate` when omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capacity: Option, +} + +/// Rate limiting configuration (`rate_limit`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RateLimitConfig { + /// Hierarchical sharing mode; defaults to `private`. + #[serde(default, skip_serializing_if = "SharingMode::is_private")] + pub sharing: SharingMode, + /// Algorithm; defaults to `token_bucket`. + #[serde(default, skip_serializing_if = "is_default")] + pub algorithm: RateLimitAlgorithm, + /// Sustained rate. Required. + pub sustained: SustainedRateConfig, + /// Burst capacity; defaults to `sustained.rate` when omitted. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub burst: Option, + /// Counter scope; defaults to `tenant`. + #[serde(default, skip_serializing_if = "is_default")] + pub scope: RateLimitScope, + /// Over-limit behaviour; defaults to `reject`. + #[serde(default, skip_serializing_if = "is_default")] + pub strategy: RateLimitStrategy, + /// Emit `X-RateLimit-*` response headers (ADR-0003); defaults to `true`. + #[serde(default = "default_true", skip_serializing_if = "is_true")] + pub response_headers: bool, + /// Tokens consumed per request; defaults to `1`. + #[serde(default = "default_cost", skip_serializing_if = "is_one_u64")] + pub cost: u64, +} + +/// CORS configuration (`cors`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CorsConfig { + /// Master switch. Required per the schema. + pub enabled: bool, + /// Hierarchical sharing mode; defaults to `private`. + #[serde(default, skip_serializing_if = "SharingMode::is_private")] + pub sharing: SharingMode, + /// Allowed origins; `["*"]` allows any origin. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_origins: Vec, + /// Allowed HTTP methods; defaults to `GET`/`POST` semantics in the schema. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_methods: Vec, + /// Headers accepted on preflight. Not declared by the schema (which + /// echoes the preflight request headers instead); kept for the ADR-0004 + /// `Access-Control-Allow-Headers` extension. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allow_headers: Vec, + /// Headers exposed to the browser beyond the CORS-safelisted set. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub expose_headers: Vec, + /// Allow credentials; requires non-wildcard origins (validated in slice 2). + #[serde(default, skip_serializing_if = "is_false")] + pub allow_credentials: bool, + /// `Access-Control-Max-Age` override. The schema does not declare it and + /// ADR-0004 pins the preflight response to `86400`, so this stays + /// unset unless an operator opts in. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_age: Option, +} + +/// Tenant-scoped root configuration object representing an external service. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Upstream { + /// System-generated UUID. Omitted when unset. + #[serde(default, skip_serializing_if = "Uuid::is_nil")] + pub id: Uuid, + /// Disabled upstreams reject every request; defaults to `true`. + #[serde(default = "default_true")] + pub enabled: bool, + /// Routing identifier, unique per tenant. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub alias: String, + /// Discovery tags; effective tags are an ancestor/descendant union. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Endpoint pool. Required. + pub server: ServerConfig, + /// Upstream protocol. Required. + pub protocol: Protocol, + /// Auth plugin binding. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + /// Header transformation rules. + #[serde(default, skip_serializing_if = "HeadersConfig::is_empty")] + pub headers: HeadersConfig, + /// Plugin chain. + #[serde(default, skip_serializing_if = "PluginConfig::is_empty")] + pub plugins: PluginConfig, + /// Rate limiting. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, + /// Owning tenant. Not part of the wire schema. + #[serde(skip, default = "zero_uuid")] + pub tenant_id: Uuid, + /// Creation timestamp. Not part of the wire schema. + #[serde(skip, default = "unix_epoch")] + pub created_at: SystemTime, + /// Last modification timestamp. Not part of the wire schema. + #[serde(skip, default = "unix_epoch")] + pub updated_at: SystemTime, +} + +impl Upstream { + /// Flat view of the endpoint pool (`server.endpoints`). + #[must_use] + pub fn endpoints(&self) -> &[Endpoint] { + &self.server.endpoints + } +} + +/// `true` when any hierarchical section of `upstream` is `enforce`, which +/// blocks a descendant from overriding it (DESIGN "Hierarchical +/// Configuration"). +#[must_use] +pub fn enforces_override(upstream: &Upstream) -> bool { + let sections = [ + upstream.auth.as_ref().map(|auth: &AuthConfig| auth.sharing), + Some(upstream.plugins.sharing), + upstream + .rate_limit + .as_ref() + .map(|limit: &RateLimitConfig| limit.sharing), + upstream.cors.as_ref().map(|cors: &CorsConfig| cors.sharing), + ]; + sections + .into_iter() + .flatten() + .any(|sharing: SharingMode| sharing == SharingMode::Enforce) +} + +impl Default for Upstream { + fn default() -> Self { + Self { + id: Uuid::nil(), + enabled: true, + alias: String::new(), + tags: Vec::new(), + server: ServerConfig { + endpoints: Vec::new(), + }, + protocol: Protocol::default(), + auth: None, + headers: HeadersConfig::default(), + plugins: PluginConfig::default(), + rate_limit: None, + cors: None, + tenant_id: Uuid::nil(), + created_at: unix_epoch(), + updated_at: unix_epoch(), + } + } +} + +// --------------------------------------------------------------------------- +// Route +// --------------------------------------------------------------------------- + +/// HTTP match rules (`match.http`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HttpMatch { + /// Methods supported by this route. Required, at least one. + pub methods: Vec, + /// Path pattern. Required. + pub path: String, + /// Allowed query parameters; empty allows none. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub query_allowlist: Vec, + /// Path-suffix handling; defaults to `append`. + #[serde(default, skip_serializing_if = "is_default")] + pub path_suffix_mode: PathSuffixMode, +} + +/// gRPC match rules (`match.grpc`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GrpcMatch { + /// Fully qualified service name (e.g. `foo.v1.UserService`). Required. + pub service: String, + /// RPC method name (e.g. `GetUser`). Required. + pub method: String, +} + +/// Protocol-scoped inbound matching rules (`match`). Exactly one of `http` / +/// `grpc` must be present (validated in slice 2). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default, deny_unknown_fields)] +pub struct RouteMatch { + /// HTTP match rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http: Option, + /// gRPC match rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grpc: Option, +} + +impl RouteMatch { + /// Protocol implied by the populated variant, if exactly one is set. + #[must_use] + pub fn protocol(&self) -> Option { + match (self.http.is_some(), self.grpc.is_some()) { + (true, false) => Some(Protocol::Http), + (false, true) => Some(Protocol::Grpc), + _ => None, + } + } +} + +/// Belongs to an upstream and defines its match rules. +/// +/// `enabled` and `priority` are design-domain fields that `route.v1.schema.json` +/// does not declare; the route root has no `additionalProperties: false`, so +/// they are always emitted rather than skipped. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Route { + /// System-generated UUID. Omitted when unset. + #[serde(default, skip_serializing_if = "Uuid::is_nil")] + pub id: Uuid, + /// Owning upstream. Required, immutable after creation. + pub upstream_id: Uuid, + /// Match rules. Required. + #[serde(rename = "match")] + pub r#match: RouteMatch, + /// Header transformation overrides. + #[serde(default, skip_serializing_if = "HeadersConfig::is_empty")] + pub headers: HeadersConfig, + /// Plugin chain appended after the upstream chain. + #[serde(default, skip_serializing_if = "PluginConfig::is_empty")] + pub plugins: PluginConfig, + /// Route-level rate limiting override. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// Route-level CORS override. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, + /// Disabled routes are skipped by the resolver; defaults to `true`. + #[serde(default = "default_true")] + pub enabled: bool, + /// Match priority; higher wins, defaults to `0`. + #[serde(default)] + pub priority: i32, + /// Discovery tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Owning tenant. Not part of the wire schema. + #[serde(skip, default = "zero_uuid")] + pub tenant_id: Uuid, + /// Creation timestamp. Not part of the wire schema. + #[serde(skip, default = "unix_epoch")] + pub created_at: SystemTime, + /// Last modification timestamp. Not part of the wire schema. + #[serde(skip, default = "unix_epoch")] + pub updated_at: SystemTime, +} + +impl Default for Route { + fn default() -> Self { + Self { + id: Uuid::nil(), + upstream_id: Uuid::nil(), + r#match: RouteMatch::default(), + headers: HeadersConfig::default(), + plugins: PluginConfig::default(), + rate_limit: None, + cors: None, + enabled: true, + priority: 0, + tags: Vec::new(), + tenant_id: Uuid::nil(), + created_at: unix_epoch(), + updated_at: unix_epoch(), + } + } +} + +// --------------------------------------------------------------------------- +// Plugin +// --------------------------------------------------------------------------- + +/// A tenant-defined custom plugin resource. +/// +/// There is no `plugin.v1.schema.json` in the design docs; the shape follows +/// the design domain model. `tenant_id` / timestamps are excluded from the +/// wire, consistent with the other resources. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Plugin { + /// System-generated UUID. + pub id: Uuid, + /// Plugin base type GTS id (e.g. `gts.cf.core.oagw.guard_plugin.v1`). + pub plugin_type: String, + /// Plugin configuration payload. + #[serde(default = "empty_json_object")] + pub config: serde_json::Value, + /// Disabled plugins are skipped by the chain builder; defaults to `true`. + #[serde(default = "default_true")] + pub enabled: bool, + /// Discovery tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Owning tenant. Not part of the wire schema. + #[serde(skip, default = "zero_uuid")] + pub tenant_id: Uuid, + /// Creation timestamp. Not part of the wire schema. + #[serde(skip, default = "unix_epoch")] + pub created_at: SystemTime, + /// Last modification timestamp. Not part of the wire schema. + #[serde(skip, default = "unix_epoch")] + pub updated_at: SystemTime, +} + +impl Default for Plugin { + fn default() -> Self { + Self { + id: Uuid::nil(), + plugin_type: String::new(), + config: empty_json_object(), + enabled: true, + tags: Vec::new(), + tenant_id: Uuid::nil(), + created_at: unix_epoch(), + updated_at: unix_epoch(), + } + } +} + +// --------------------------------------------------------------------------- +// Data-plane resolution snapshot +// --------------------------------------------------------------------------- + +/// Resolved proxy target cached by the data-plane L1 cache +/// (`dp_cache_max_entries`). Slice 4 refines this with the merged effective +/// configuration; the shape is stable enough to key the cache today. +#[derive(Debug, Clone)] +pub struct ResolvedProxyTarget { + /// Upstream selected by alias (closest tenant wins). + pub upstream: Arc, + /// Matching route, `None` when no route matched. + pub route: Option>, +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// `true` when `value` equals `T::default()`, used by `skip_serializing_if`. +fn is_default(value: &T) -> bool { + *value == T::default() +} + +/// `true` for `true`, used by `skip_serializing_if` on defaulted booleans. +fn is_true(value: &bool) -> bool { + *value +} + +/// `true` for `false`, used by `skip_serializing_if` on defaulted booleans +/// whose default is `false`. +fn is_false(value: &bool) -> bool { + !*value +} + +/// `true` for `1`, used by `skip_serializing_if` on the rate-limit cost. +fn is_one_u64(value: &u64) -> bool { + *value == 1 +} + +/// Schema default for `server.endpoints[].port`. +const fn default_https_port() -> u16 { + 443 +} + +/// Schema default for `rate_limit.cost`. +const fn default_cost() -> u64 { + 1 +} + +/// Schema default for `enabled`. +const fn default_true() -> bool { + true +} + +/// Zero UUID for skipped, non-wire fields. +const fn zero_uuid() -> Uuid { + Uuid::nil() +} + +/// Epoch for skipped, non-wire timestamps ([`SystemTime`] has no `Default`). +const fn unix_epoch() -> SystemTime { + SystemTime::UNIX_EPOCH +} + +/// Fresh empty JSON object, the default for free-form configuration payloads. +pub fn empty_json_object() -> serde_json::Value { + serde_json::Value::Object(serde_json::Map::new()) +} + +/// `true` for a `null`-free empty JSON object. +#[must_use] +pub fn is_empty_json_object(value: &serde_json::Value) -> bool { + matches!(value, serde_json::Value::Object(map) if map.is_empty()) +} + +#[cfg(test)] +#[path = "../model_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/odata.rs b/gears/system/oagw/oagw/src/domain/odata.rs new file mode 100644 index 0000000..02afd95 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/odata.rs @@ -0,0 +1,852 @@ +//! Local OData query engine for the OAGW list endpoints (DESIGN "List Query +//! Parameters"). +//! +//! The platform extractor (`toolkit::api::odata::OData`) does not accept +//! `$skip`, which DESIGN requires on every list endpoint, so this gear ships a +//! small engine of its own covering exactly the documented subset: +//! +//! | Parameter | Behaviour | +//! |---|---| +//! | `$filter` | `field eq value` / `field ne value`, combined with `and`/`or`, parentheses allowed | +//! | `$select` | comma-separated field names, projected out of every item | +//! | `$orderby` | comma-separated `field [asc\|desc]`, multi-key and stable | +//! | `$top` | page size; defaults to [`DEFAULT_PAGE_SIZE`], clamped to [`MAX_PAGE_SIZE`] | +//! | `$skip` | number of items to skip; defaults to `0` | +//! +//! Only top-level fields listed in the resource's [`FieldCatalog`] are +//! accepted: an unknown field, an unknown operator or a malformed literal is a +//! `400` [`OagwError::Validation`]. `$top` above [`MAX_PAGE_SIZE`] is clamped +//! instead of rejected so a paging client never hard-fails on a page size it +//! cannot discover. Query parameters that do not start with `$` are ignored +//! (the platform may add its own); an unknown `$`-prefixed parameter is +//! rejected so a typo never silently returns an unfiltered list. + +use std::cmp::Ordering; + +use serde::Serialize; +use serde::de::DeserializeOwned; +use serde_json::Value; + +use crate::domain::error::OagwError; + +/// Page size used when `$top` is absent (DESIGN: "default: 50"). +pub const DEFAULT_PAGE_SIZE: usize = 50; + +/// Largest accepted page size (DESIGN: "max: 100"); larger `$top` values are +/// clamped to this. +pub const MAX_PAGE_SIZE: usize = 100; + +/// Largest accepted `$filter` expression, in bytes. +/// +/// The platform extractor caps its own `$filter` at the same budget +/// (`toolkit::api::odata::MAX_FILTER_LEN`), so both surfaces agree on how much +/// filter text a caller may send. +pub const MAX_FILTER_LEN: usize = 8 * 1024; + +/// Deepest accepted parenthesis nesting of a `$filter` expression. +/// +/// `parse_primary` recurses once per `(`; a hostile `$filter` of `(((((…` +/// would otherwise recurse unbounded and overflow the stack inside a request +/// handler, so nesting beyond this budget is a `400`. +pub const MAX_FILTER_DEPTH: usize = 32; + +/// Sort direction of one `$orderby` key. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SortDirection { + /// Ascending (the default). + Asc, + /// Descending. + Desc, +} + +/// Comparison operator of a `$filter` clause. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CompareOp { + /// `eq` + Equal, + /// `ne` + NotEqual, +} + +/// Literal of a `$filter` clause. +#[derive(Debug, Clone, PartialEq)] +pub enum FilterValue { + /// `'single quoted'` text (`''` escapes a quote). + Text(String), + /// Bare number, optionally signed with an optional fraction/exponent. + Number(f64), + /// `true` / `false`. + Flag(bool), + /// `null`, which also matches an absent field. + Null, +} + +/// Parsed `$filter` expression. +#[derive(Debug, Clone, PartialEq)] +pub enum Filter { + /// One `field op value` comparison. + Compare { + /// Canonical field name (an alias has already been resolved). + field: String, + /// Comparison operator. + op: CompareOp, + /// Literal to compare against. + value: FilterValue, + }, + /// Conjunction; every clause must hold. + All(Vec), + /// Disjunction; at least one clause must hold. + Any(Vec), +} + +/// Fields a resource exposes to the query engine. +#[derive(Debug, Clone, Copy)] +pub struct FieldCatalog { + /// Fields accepted by `$filter`. + pub filterable: &'static [&'static str], + /// Fields accepted by `$orderby`. + pub sortable: &'static [&'static str], + /// Fields accepted by `$select`. + pub selectable: &'static [&'static str], + /// Wire-name aliases, e.g. `("type", "plugin_type")`: the alias is accepted + /// everywhere and replaced by its target before use. + pub aliases: &'static [(&'static str, &'static str)], +} + +/// A parsed, validated list query. +#[derive(Debug, Clone, PartialEq)] +pub struct ListQuery { + /// `$filter`, `None` when absent. + pub filter: Option, + /// `$select` as canonical field names, in request order. + pub select: Vec<&'static str>, + /// `$orderby` as `(canonical field name, direction)` pairs, in request order. + pub orderby: Vec<(&'static str, SortDirection)>, + /// Page size after clamping. + pub top: usize, + /// Number of items to skip. + pub skip: usize, +} + +impl ListQuery { + /// Parses a raw query string (`x-www-form-urlencoded`, as sent on the wire). + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when a `$`-prefixed parameter is + /// unknown, a field is not in the catalog, a filter expression is + /// malformed, a sort direction is unknown, or `$top`/`$skip` are not + /// non-negative integers. + pub fn parse(raw: Option<&str>, catalog: &FieldCatalog) -> Result { + let mut query = Self { + filter: None, + select: Vec::new(), + orderby: Vec::new(), + top: DEFAULT_PAGE_SIZE, + skip: 0, + }; + let Some(raw) = raw else { + return Ok(query); + }; + + for (key, value) in form_urlencoded::parse(raw.as_bytes()) { + let key = key.as_ref(); + let value = value.as_ref(); + match key { + "$filter" => { + if value.len() > MAX_FILTER_LEN { + return Err(OagwError::validation(format!( + "field `$filter`: must be at most {MAX_FILTER_LEN} bytes, got {}", + value.len() + ))); + } + query.filter = Some(parse_filter(value, catalog)?); + } + "$select" => query.select = parse_select(value, catalog)?, + "$orderby" => query.orderby = parse_orderby(value, catalog)?, + "$top" => query.top = parse_size(value, "$top")?, + "$skip" => query.skip = parse_size(value, "$skip")?, + _ if key.starts_with('$') => { + return Err(OagwError::validation(format!( + "field `{key}`: unsupported OData query parameter" + ))); + } + _ => {} + } + } + query.top = query.top.min(MAX_PAGE_SIZE); + Ok(query) + } + + /// Applies the query to `items`, returning the canonical page envelope. + /// + /// Items are round-tripped through their JSON representation so `$filter` + /// and `$orderby` use the wire field names and `$select` can drop fields. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when an item cannot be serialised or + /// re-read, which cannot happen for the gear's own DTOs. + pub fn apply(&self, items: Vec) -> Result, OagwError> + where + T: Serialize + DeserializeOwned, + { + let mut rows = Vec::with_capacity(items.len()); + for item in &items { + rows.push(serde_json::to_value(item).map_err(|error| { + OagwError::validation(format!("list item is not serialisable: {error}")) + })?); + } + let paged = self.apply_to_rows(rows); + let mut projected = Vec::with_capacity(paged.len()); + for row in paged { + projected.push(T::deserialize(row).map_err(|error| { + OagwError::validation(format!("list item is not readable: {error}")) + })?); + } + Ok(self.page(projected)) + } + + /// Wraps already-paged items in the canonical page envelope. + /// + /// Offset paging needs no cursors, so both stay `null` and `limit` reports + /// the effective page size. + #[must_use] + pub fn page(&self, items: Vec) -> toolkit::Page { + toolkit::Page { + items, + page_info: toolkit::PageInfo { + next_cursor: None, + prev_cursor: None, + limit: u64::try_from(self.top).unwrap_or(0), + }, + } + } + + /// Serialises `items`, applies the query and returns the projected page. + /// + /// List handlers use this instead of [`Self::apply`]: `$select` removes + /// fields from the item objects, so the wire items cannot be read back + /// into a struct with required fields. The OpenAPI document still declares + /// the full item schema. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when an item cannot be serialised, + /// which would breach the wire contract rather than a caller mistake. + pub fn apply_values( + &self, + items: Vec, + ) -> Result, OagwError> { + let rows = serialise_rows(items)?; + Ok(self.page(self.apply_to_rows(rows))) + } + + /// Filters, sorts, skips, tops and projects `rows`. + #[must_use] + pub fn apply_to_rows(&self, mut rows: Vec) -> Vec { + if let Some(filter) = &self.filter { + rows.retain(|row| filter.matches(row)); + } + if !self.orderby.is_empty() { + sort_rows(&mut rows, &self.orderby); + } + if self.skip > 0 { + let skip = self.skip.min(rows.len()); + rows.drain(0..skip); + } + if rows.len() > self.top { + rows.truncate(self.top); + } + if !self.select.is_empty() { + rows = rows + .into_iter() + .map(|row| project(&row, &self.select)) + .collect(); + } + rows + } +} + +impl Filter { + /// Evaluates the filter against one row. + #[must_use] + pub fn matches(&self, row: &Value) -> bool { + match self { + Self::Compare { field, op, value } => { + let equal = match row.get(field) { + Some(Value::String(actual)) => match value { + FilterValue::Text(expected) => actual == expected, + _ => false, + }, + Some(Value::Number(actual)) => match value { + FilterValue::Number(expected) => actual + .as_f64() + .is_some_and(|actual| numbers_equal(actual, *expected)), + _ => false, + }, + Some(Value::Bool(actual)) => match value { + FilterValue::Flag(expected) => actual == expected, + _ => false, + }, + Some(Value::Null) | None => matches!(value, FilterValue::Null), + Some(Value::Array(_)) | Some(Value::Object(_)) => false, + }; + match op { + CompareOp::Equal => equal, + CompareOp::NotEqual => !equal, + } + } + Self::All(clauses) => clauses.iter().all(|clause| clause.matches(row)), + Self::Any(clauses) => clauses.iter().any(|clause| clause.matches(row)), + } + } +} + +/// Tolerant numeric equality (the crate denies `float_cmp`-style exact +/// comparisons): two numbers are equal when they differ by at most one ULP +/// scaled by magnitude. +#[must_use] +fn numbers_equal(left: f64, right: f64) -> bool { + (left - right).abs() <= f64::EPSILON * left.abs().max(right.abs()).max(1.0) +} + +/// Sorts rows by the `$orderby` keys, stable and multi-key. +fn sort_rows(rows: &mut Vec, orderby: &[(&'static str, SortDirection)]) { + let mut indexed: Vec<(Vec, Value)> = rows + .iter() + .map(|row| { + let keys = orderby + .iter() + .map(|(field, _)| sort_key(row, field)) + .collect(); + (keys, row.clone()) + }) + .collect(); + indexed.sort_by(|(left, _), (right, _)| { + let mut first = Ordering::Equal; + for (index, (_, direction)) in orderby.iter().enumerate() { + let mut ordering = left[index].compare(&right[index]); + if *direction == SortDirection::Desc { + ordering = ordering.reverse(); + } + if ordering != Ordering::Equal { + first = ordering; + break; + } + } + first + }); + *rows = indexed.into_iter().map(|(_, row)| row).collect(); +} + +/// One extracted sort key of a row. +#[derive(Debug, Clone)] +enum SortKey { + /// String field. + Text(String), + /// Numeric field. + Number(f64), + /// Boolean field. + Flag(bool), + /// The field is absent (sorts last). + Absent, +} + +impl SortKey { + /// Compares two keys; an absent key always sorts last. + #[must_use] + fn compare(&self, other: &Self) -> Ordering { + match (self, other) { + (Self::Absent, Self::Absent) => Ordering::Equal, + (Self::Absent, _) => Ordering::Greater, + (_, Self::Absent) => Ordering::Less, + (Self::Text(left), Self::Text(right)) => left.cmp(right), + (Self::Number(left), Self::Number(right)) => left.total_cmp(right), + (Self::Flag(left), Self::Flag(right)) => left.cmp(right), + _ => Ordering::Equal, + } + } +} + +/// Extracts the sort key of `row` for `field`. +#[must_use] +fn sort_key(row: &Value, field: &str) -> SortKey { + match row.get(field) { + Some(Value::String(text)) => SortKey::Text(text.clone()), + Some(Value::Number(number)) => number.as_f64().map_or(SortKey::Absent, SortKey::Number), + Some(Value::Bool(flag)) => SortKey::Flag(*flag), + _ => SortKey::Absent, + } +} + +/// Keeps only the selected keys of `row`. +#[must_use] +fn project(row: &Value, select: &[&'static str]) -> Value { + match row { + Value::Object(fields) => { + let mut projected = serde_json::Map::new(); + for field in select { + if let Some(value) = fields.get(*field) { + projected.insert((*field).to_owned(), value.clone()); + } + } + Value::Object(projected) + } + _ => row.clone(), + } +} + +/// Catalog of the upstream list endpoint. +/// +/// Top-level wire fields only: nested sections (`server.endpoints[]`, +/// `plugins.items[]`) are not addressable by the query engine. +pub const UPSTREAM_FIELDS: FieldCatalog = FieldCatalog { + filterable: &[ + "id", + "alias", + "enabled", + "protocol", + "created_at", + "updated_at", + ], + sortable: &[ + "id", + "alias", + "enabled", + "protocol", + "created_at", + "updated_at", + ], + selectable: &[ + "id", + "alias", + "enabled", + "tags", + "server", + "protocol", + "auth", + "headers", + "plugins", + "rate_limit", + "cors", + "created_at", + "updated_at", + ], + aliases: &[], +}; + +/// Catalog of the route list endpoint. +pub const ROUTE_FIELDS: FieldCatalog = FieldCatalog { + filterable: &[ + "id", + "upstream_id", + "enabled", + "priority", + "created_at", + "updated_at", + ], + sortable: &[ + "id", + "upstream_id", + "enabled", + "priority", + "created_at", + "updated_at", + ], + selectable: &[ + "id", + "upstream_id", + "match", + "headers", + "plugins", + "rate_limit", + "cors", + "enabled", + "priority", + "tags", + "created_at", + "updated_at", + ], + aliases: &[], +}; + +/// Catalog of the plugin list endpoint. `type` is the DESIGN spelling of +/// `plugin_type` and is accepted as an alias. +pub const PLUGIN_FIELDS: FieldCatalog = FieldCatalog { + filterable: &["id", "plugin_type", "enabled", "created_at", "updated_at"], + sortable: &["id", "plugin_type", "enabled", "created_at", "updated_at"], + selectable: &[ + "id", + "plugin_type", + "config", + "enabled", + "tags", + "created_at", + "updated_at", + ], + aliases: &[("type", "plugin_type")], +}; + +impl FieldCatalog { + /// Resolves `field` to its canonical catalog name, `None` when unknown. + #[must_use] + pub fn canonical(&self, field: &str) -> Option<&'static str> { + for (alias, target) in self.aliases { + if *alias == field { + return Some(target); + } + } + self.filterable + .iter() + .chain(self.sortable) + .chain(self.selectable) + .find(|candidate| **candidate == field) + .copied() + } + + /// Comma-separated list of the fields accepted for `purpose`, for error + /// messages. + #[must_use] + fn names(&self, purpose: CatalogPurpose) -> String { + let fields: &[&str] = match purpose { + CatalogPurpose::Filter => self.filterable, + CatalogPurpose::Sort => self.sortable, + CatalogPurpose::Select => self.selectable, + }; + fields.join(", ") + } +} + +/// Which catalog list an error message talks about. +#[derive(Debug, Clone, Copy)] +enum CatalogPurpose { + /// `$filter` + Filter, + /// `$orderby` + Sort, + /// `$select` + Select, +} + +/// Parses `$select` into canonical field names. +fn parse_select(raw: &str, catalog: &FieldCatalog) -> Result, OagwError> { + let mut selected: Vec<&'static str> = Vec::new(); + for token in raw.split(',') { + let token = token.trim(); + if token.is_empty() { + continue; + } + let Some(field) = catalog.canonical(token) else { + return Err(unknown_field(token, catalog, CatalogPurpose::Select)); + }; + if !selected.contains(&field) { + selected.push(field); + } + } + Ok(selected) +} + +/// Parses `$orderby` into `(field, direction)` pairs. +fn parse_orderby( + raw: &str, + catalog: &FieldCatalog, +) -> Result, OagwError> { + let mut keys: Vec<(&'static str, SortDirection)> = Vec::new(); + for token in raw.split(',') { + let token = token.trim(); + if token.is_empty() { + continue; + } + let (name, direction) = match token.split_once(char::is_whitespace) { + Some((name, rest)) => { + let rest = rest.trim(); + let direction = match rest { + "asc" => SortDirection::Asc, + "desc" => SortDirection::Desc, + _ => { + return Err(OagwError::validation(format!( + "field `$orderby`: direction `{rest}` is not `asc` or `desc`" + ))); + } + }; + (name, direction) + } + None => (token, SortDirection::Asc), + }; + let Some(field) = catalog.canonical(name) else { + return Err(unknown_field(name, catalog, CatalogPurpose::Sort)); + }; + if !catalog.sortable.contains(&field) { + return Err(unknown_field(name, catalog, CatalogPurpose::Sort)); + } + if !keys.iter().any(|(existing, _)| *existing == field) { + keys.push((field, direction)); + } + } + Ok(keys) +} + +/// Parses `$top` / `$skip`. +fn parse_size(raw: &str, parameter: &str) -> Result { + raw.parse::().map_err(|_| { + OagwError::validation(format!( + "field `{parameter}`: must be a non-negative integer, got `{raw}`" + )) + }) +} + +/// Reads a single-quoted OData string literal, where `''` escapes a quote, and +/// returns the text plus the number of bytes consumed (both quotes included). +fn parse_string_literal(input: &str) -> Result<(String, usize), OagwError> { + let bytes = input.as_bytes(); + let mut text = String::new(); + let mut cursor = 0; + while cursor < bytes.len() { + if bytes[cursor] == b'\'' { + if bytes.get(cursor + 1) == Some(&b'\'') { + text.push('\''); + cursor += 2; + } else { + return Ok((text, cursor + 2)); + } + } else { + let Some(character) = input[cursor..].chars().next() else { + break; + }; + text.push(character); + cursor += character.len_utf8(); + } + } + Err(OagwError::validation( + "field `$filter`: string literal is not closed".to_owned(), + )) +} + +/// Builds the "unknown field" error for `purpose`. +fn unknown_field(field: &str, catalog: &FieldCatalog, purpose: CatalogPurpose) -> OagwError { + let clause = match purpose { + CatalogPurpose::Filter => "`$filter`", + CatalogPurpose::Sort => "`$orderby`", + CatalogPurpose::Select => "`$select`", + }; + OagwError::validation(format!( + "field {clause}: `{field}` is not a queryable field; allowed: {}", + catalog.names(purpose) + )) +} + +/// Parses a `$filter` expression. +fn parse_filter(raw: &str, catalog: &FieldCatalog) -> Result { + let mut parser = FilterParser { + input: raw.trim(), + position: 0, + depth: 0, + }; + if parser.input.is_empty() { + return Err(OagwError::validation( + "field `$filter`: must not be empty".to_owned(), + )); + } + let filter = parser.parse_or(catalog)?; + parser.skip_whitespace(); + if parser.position != parser.input.len() { + return Err(OagwError::validation(format!( + "field `$filter`: unexpected trailing input `{}`", + &parser.input[parser.position..] + ))); + } + Ok(filter) +} + +/// Serialises typed list items into the JSON rows the engine works on. +/// +/// # Errors +/// +/// Returns [`OagwError::Validation`] when an item cannot be serialised, which +/// would breach the wire contract rather than a caller mistake. +fn serialise_rows(items: Vec) -> Result, OagwError> { + let mut rows = Vec::with_capacity(items.len()); + for item in items { + rows.push(serde_json::to_value(&item).map_err(|error| { + OagwError::validation(format!("list item is not serialisable: {error}")) + })?); + } + Ok(rows) +} + +/// Recursive-descent parser for the `$filter` subset. +struct FilterParser<'a> { + /// Remaining input. + input: &'a str, + /// Byte offset into `input`. + position: usize, + /// Current parenthesis nesting depth, bounded by [`MAX_FILTER_DEPTH`]. + depth: usize, +} + +impl<'a> FilterParser<'a> { + /// `or` level of the grammar. + fn parse_or(&mut self, catalog: &FieldCatalog) -> Result { + let mut left = self.parse_and(catalog)?; + while self.eat_keyword("or") { + let right = self.parse_and(catalog)?; + left = Filter::Any(vec![left, right]); + } + Ok(left) + } + + /// `and` level of the grammar. + fn parse_and(&mut self, catalog: &FieldCatalog) -> Result { + let mut left = self.parse_primary(catalog)?; + while self.eat_keyword("and") { + let right = self.parse_primary(catalog)?; + left = Filter::All(vec![left, right]); + } + Ok(left) + } + + /// One parenthesised group or one comparison. + fn parse_primary(&mut self, catalog: &FieldCatalog) -> Result { + self.skip_whitespace(); + if self.peek() == Some('(') { + self.depth += 1; + if self.depth > MAX_FILTER_DEPTH { + return Err(OagwError::validation(format!( + "field `$filter`: nested groups must not be deeper than {MAX_FILTER_DEPTH}" + ))); + } + self.position += 1; + let group = self.parse_or(catalog)?; + self.skip_whitespace(); + if self.peek() != Some(')') { + return Err(OagwError::validation( + "field `$filter`: expected `)` to close a group".to_owned(), + )); + } + self.position += 1; + self.depth -= 1; + return Ok(group); + } + + let field = self.parse_identifier()?; + self.skip_whitespace(); + let op = if self.eat_keyword("eq") { + CompareOp::Equal + } else if self.eat_keyword("ne") { + CompareOp::NotEqual + } else { + return Err(OagwError::validation( + "field `$filter`: only `eq` and `ne` comparisons are supported".to_owned(), + )); + }; + self.skip_whitespace(); + let value = self.parse_value()?; + + let canonical = catalog + .canonical(&field) + .ok_or_else(|| unknown_field(&field, catalog, CatalogPurpose::Filter))?; + if !catalog.filterable.contains(&canonical) { + return Err(unknown_field(&field, catalog, CatalogPurpose::Filter)); + } + Ok(Filter::Compare { + field: canonical.to_owned(), + op, + value, + }) + } + + /// Reads a bare identifier (`[A-Za-z_][A-Za-z0-9_.]*`). + fn parse_identifier(&mut self) -> Result { + let rest = &self.input[self.position..]; + let end = rest + .char_indices() + .find(|(_, character)| { + !character.is_ascii_alphanumeric() && *character != '_' && *character != '.' + }) + .map_or(rest.len(), |(index, _)| index); + if end == 0 { + return Err(OagwError::validation( + "field `$filter`: expected a field name".to_owned(), + )); + } + let identifier = rest[..end].to_owned(); + self.position += end; + Ok(identifier) + } + + /// Reads one literal: `'text'`, a number, `true`, `false` or `null`. + fn parse_value(&mut self) -> Result { + self.skip_whitespace(); + let rest = &self.input[self.position..]; + if let Some(text) = rest.strip_prefix('\'') { + let (literal, consumed) = parse_string_literal(text)?; + self.position += consumed; + return Ok(FilterValue::Text(literal)); + } + for (literal, value) in [ + ("true", FilterValue::Flag(true)), + ("false", FilterValue::Flag(false)), + ("null", FilterValue::Null), + ] { + if rest.starts_with(literal) { + self.position += literal.len(); + return Ok(value); + } + } + let end = rest + .char_indices() + .find(|(_, character)| { + !character.is_ascii_digit() && *character != '-' && *character != '.' + }) + .map_or(rest.len(), |(index, _)| index); + if end == 0 { + return Err(OagwError::validation( + "field `$filter`: string values must be single-quoted".to_owned(), + )); + } + let number = &rest[..end]; + self.position += end; + number.parse::().map(FilterValue::Number).map_err(|_| { + OagwError::validation(format!( + "field `$filter`: `{number}` is neither a number, a boolean nor a quoted string" + )) + }) + } + + /// Advances past whitespace. + fn skip_whitespace(&mut self) { + let rest = &self.input[self.position..]; + let trimmed = rest.trim_start(); + self.position += rest.len() - trimmed.len(); + } + + /// Consumes `keyword` when it starts here and is not a field-name prefix. + fn eat_keyword(&mut self, keyword: &str) -> bool { + self.skip_whitespace(); + let rest = &self.input[self.position..]; + let Some(after) = rest.strip_prefix(keyword) else { + return false; + }; + if after + .chars() + .next() + .is_some_and(|character| character.is_ascii_alphanumeric() || character == '_') + { + return false; + } + self.position += keyword.len(); + self.skip_whitespace(); + true + } + + /// Next byte without consuming it. + #[must_use] + fn peek(&self) -> Option { + self.input[self.position..].chars().next() + } +} + +#[cfg(test)] +#[path = "odata_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/odata_tests.rs b/gears/system/oagw/oagw/src/domain/odata_tests.rs new file mode 100644 index 0000000..88b2dd8 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/odata_tests.rs @@ -0,0 +1,325 @@ +//! Tests for [`crate::domain::odata`]. + +use serde_json::{Value, json}; + +use super::{ + DEFAULT_PAGE_SIZE, FieldCatalog, ListQuery, MAX_PAGE_SIZE, PLUGIN_FIELDS, ROUTE_FIELDS, + UPSTREAM_FIELDS, +}; +use crate::domain::error::OagwError; + +/// Catalog covering every field the fixtures below carry. `server` is +/// deliberately selectable but not sortable so both rejections stay testable. +const CATALOG: FieldCatalog = FieldCatalog { + filterable: &["id", "alias", "plugin_type", "enabled", "priority"], + sortable: &["id", "alias", "plugin_type", "enabled", "priority"], + selectable: &[ + "id", + "alias", + "plugin_type", + "enabled", + "priority", + "server", + "tags", + "config", + ], + aliases: &[("type", "plugin_type")], +}; + +#[test] +fn the_plugin_catalog_resolves_the_type_alias() { + assert_eq!(CATALOG.canonical("type"), Some("plugin_type")); + assert_eq!(CATALOG.canonical("plugin_type"), Some("plugin_type")); + assert_eq!(CATALOG.canonical("secret"), None); + assert_eq!(PLUGIN_FIELDS.canonical("type"), Some("plugin_type")); + assert_eq!(PLUGIN_FIELDS.canonical("config"), Some("config")); + assert_eq!(ROUTE_FIELDS.canonical("upstream_id"), Some("upstream_id")); + assert_eq!(UPSTREAM_FIELDS.canonical("server"), Some("server")); + assert_eq!(UPSTREAM_FIELDS.canonical("upstream_id"), None); +} + +fn rows() -> Vec { + vec![ + json!({"id": "a", "alias": "api.openai.com", "enabled": true, "priority": 1}), + json!({"id": "b", "alias": "vendor.com", "enabled": false, "priority": 3}), + json!({"id": "c", "alias": "api.vendor.com", "enabled": true, "priority": 2}), + ] +} + +fn parsed(raw: Option<&str>) -> ListQuery { + ListQuery::parse(raw, &CATALOG).expect("query parses") +} + +fn applied(raw: Option<&str>) -> Vec { + parsed(raw).apply_to_rows(rows()) +} + +fn error_of(raw: &str) -> OagwError { + ListQuery::parse(Some(raw), &CATALOG).expect_err("query is rejected") +} + +// -- defaults and paging -------------------------------------------------------- + +#[test] +fn an_absent_query_yields_the_default_page() { + let query = parsed(None); + assert_eq!(query.top, DEFAULT_PAGE_SIZE); + assert_eq!(query.skip, 0); + assert!(query.filter.is_none()); + assert!(query.select.is_empty()); + assert!(query.orderby.is_empty()); + assert_eq!(applied(None).len(), 3); +} + +#[test] +fn top_and_skip_page_the_rows() { + assert_eq!(applied(Some("$top=2")).len(), 2); + assert_eq!(applied(Some("$skip=2")).len(), 1); + assert_eq!(applied(Some("$top=2&$skip=1"))[0]["id"], json!("b")); + assert!(applied(Some("$skip=9")).is_empty(), "skip past the end"); + assert!(applied(Some("$top=0")).is_empty()); +} + +#[test] +fn top_above_the_maximum_is_clamped() { + let query = parsed(Some("$top=999")); + assert_eq!(query.top, MAX_PAGE_SIZE); + + let error = error_of("$top=lots"); + assert!(error.detail().contains("$top"), "{error}"); + let error = error_of("$skip=-1"); + assert!(error.detail().contains("$skip"), "{error}"); +} + +#[test] +fn page_envelope_reports_the_limit_without_cursors() { + let query = parsed(Some("$top=7")); + let page = query.page(vec![1_u8, 2, 3]); + assert_eq!(page.items, vec![1_u8, 2, 3]); + assert_eq!(page.page_info.limit, 7); + assert!(page.page_info.next_cursor.is_none()); + assert!(page.page_info.prev_cursor.is_none()); +} + +#[test] +fn unknown_dollar_parameters_are_rejected_and_plain_ones_ignored() { + let error = error_of("$select2=alias"); + assert!(error.detail().contains("$select2"), "{error}"); + assert_eq!(parsed(Some("trace_id=abc&$top=1")).top, 1); +} + +// -- $filter ------------------------------------------------------------------ + +#[test] +fn filter_compares_strings_numbers_and_booleans() { + assert_eq!( + applied(Some("$filter=alias eq 'api.openai.com'"))[0]["id"], + json!("a") + ); + assert_eq!(applied(Some("$filter=alias ne 'api.openai.com'")).len(), 2); + assert_eq!(applied(Some("$filter=enabled eq true")).len(), 2); + assert_eq!(applied(Some("$filter=enabled eq false")).len(), 1); + assert_eq!(applied(Some("$filter=priority eq 3"))[0]["id"], json!("b")); + assert!(applied(Some("$filter=priority eq 99")).is_empty()); + assert!( + applied(Some("$filter=alias ne null")).len() == 3, + "absent fields only match null" + ); + let error = error_of("$filter=missing eq null"); + assert!(error.detail().contains("missing"), "{error}"); +} + +#[test] +fn filter_combines_clauses_with_and_or_and_parentheses() { + let raw = "$filter=enabled eq true and priority eq 1"; + assert_eq!(applied(Some(raw))[0]["id"], json!("a")); + + let raw = "$filter=alias eq 'vendor.com' or alias eq 'api.vendor.com'"; + assert_eq!(applied(Some(raw)).len(), 2); + + // `and` binds tighter than `or`. + let raw = "$filter=enabled eq false or enabled eq true and priority eq 1"; + let rows = applied(Some(raw)); + let ids: Vec<&str> = rows.iter().filter_map(|row| row["id"].as_str()).collect(); + assert_eq!(ids, vec!["a", "b"], "rows keep their input order"); + + let raw = "$filter=(alias eq 'vendor.com' or alias eq 'api.openai.com') and enabled eq false"; + let rows = applied(Some(raw)); + let ids: Vec<&str> = rows.iter().filter_map(|row| row["id"].as_str()).collect(); + assert_eq!(ids, vec!["b"]); +} + +#[test] +fn filter_rejects_unknown_fields_and_operators() { + let error = error_of("$filter=secret eq 'x'"); + assert!(error.detail().contains("`$filter`"), "{error}"); + assert!(error.detail().contains("alias"), "{error}"); + + for raw in [ + "$filter=alias gt 'x'", + "$filter=alias contains 'x'", + "$filter=alias eq", + "$filter=eq 'x'", + "$filter=alias eq 'x' and", + "$filter=(alias eq 'x'", + "$filter=alias eq unquoted", + "$filter=alias eq 'x' extra", + "$filter=", + "$filter=alias eq 'unclosed", + ] { + assert!( + ListQuery::parse(Some(raw), &CATALOG).is_err(), + "{raw} must be rejected" + ); + } +} + +#[test] +fn filter_escapes_and_compares_the_type_alias() { + let rows = vec![json!({"id": "p", "plugin_type": "gts.cf.core.oagw.guard_plugin.v1"})]; + let query = ListQuery::parse( + Some("$filter=type eq 'gts.cf.core.oagw.guard_plugin.v1'"), + &CATALOG, + ) + .expect("query parses"); + assert_eq!(query.apply_to_rows(rows.clone())[0]["id"], json!("p")); + + let query = ListQuery::parse( + Some("$filter=type ne 'gts.cf.core.oagw.guard_plugin.v1'"), + &CATALOG, + ) + .expect("query parses"); + assert!(query.apply_to_rows(rows).is_empty()); + + // `''` escapes a quote inside a string literal. + let quoted = vec![json!({"id": "q", "alias": "o'brien.example"})]; + let query = ListQuery::parse(Some("$filter=alias eq 'o''brien.example'"), &CATALOG) + .expect("query parses"); + assert_eq!(query.apply_to_rows(quoted)[0]["id"], json!("q")); +} + +#[test] +fn deeply_nested_filter_groups_are_rejected_instead_of_overflowing_the_stack() { + // 32 levels of nesting still parse… + let deep = "$filter=".to_owned() + &"(".repeat(32) + "alias eq 'a'" + &")".repeat(32); + ListQuery::parse(Some(&deep), &CATALOG).expect("the documented depth budget"); + + // …33 levels are a 400 rather than a stack overflow inside a handler. + let deeper = "$filter=".to_owned() + &"(".repeat(33) + "alias eq 'a'" + &")".repeat(33); + let error = error_of(&deeper); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + assert_eq!( + error.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); + assert!(error.detail().contains("deeper than 32"), "{error}"); +} + +#[test] +fn an_oversized_filter_is_rejected_before_it_is_parsed() { + let mut raw = String::from("$filter=alias eq '"); + raw.push_str(&"a".repeat(9 * 1024)); + raw.push('\''); + let error = error_of(&raw); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + assert!(error.detail().contains("8192 bytes"), "{error}"); + + // Just inside the budget parses fine. + let mut inside = String::from("$filter=alias eq '"); + inside.push_str(&"a".repeat(8 * 1024 - "$filter=alias eq ''".len())); + inside.push('\''); + ListQuery::parse(Some(&inside), &CATALOG).expect("inside the budget"); +} + +// -- $orderby ----------------------------------------------------------------- + +#[test] +fn orderby_sorts_multi_key_with_directions() { + let sorted = applied(Some("$orderby=enabled desc,priority asc")); + let ids: Vec<&str> = sorted.iter().filter_map(|row| row["id"].as_str()).collect(); + assert_eq!(ids, vec!["a", "c", "b"]); + + let sorted = applied(Some("$orderby=alias desc")); + let ids: Vec<&str> = sorted.iter().filter_map(|row| row["id"].as_str()).collect(); + assert_eq!(ids, vec!["b", "c", "a"]); +} + +#[test] +fn orderby_is_stable_for_equal_keys_and_keeps_the_input_order() { + let sorted = applied(Some("$orderby=enabled asc")); + let ids: Vec<&str> = sorted.iter().filter_map(|row| row["id"].as_str()).collect(); + assert_eq!(ids, vec!["b", "a", "c"], "equal keys keep the input order"); +} + +#[test] +fn orderby_puts_absent_keys_last_and_rejects_unknown_directions() { + let sorted = applied(Some("$orderby=plugin_type asc")); + assert_eq!(sorted.len(), 3, "absent keys still return every row"); + + let error = error_of("$orderby=alias sideways"); + assert!(error.detail().contains("sideways"), "{error}"); + let error = error_of("$orderby=server asc"); + assert!(error.detail().contains("`$orderby`"), "{error}"); +} + +// -- $select ------------------------------------------------------------------ + +#[test] +fn select_projects_the_requested_fields() { + let projected = applied(Some("$select=alias,enabled")); + assert_eq!(projected.len(), 3); + assert_eq!( + projected[0], + json!({"alias": "api.openai.com", "enabled": true}) + ); + assert_eq!( + projected[2], + json!({"alias": "api.vendor.com", "enabled": true}) + ); +} + +#[test] +fn select_can_repeat_fields_and_may_be_combined_with_filter() { + let raw = "$filter=enabled eq false&$select=id"; + assert_eq!(applied(Some(raw)), vec![json!({"id": "b"})]); + + let raw = "$select=id,id,alias"; + let projected = applied(Some(raw)); + assert_eq!(projected[0], json!({"id": "a", "alias": "api.openai.com"})); +} + +#[test] +fn select_rejects_unknown_fields() { + let error = error_of("$select=secret"); + assert!(error.detail().contains("`$select`"), "{error}"); + assert!(error.detail().contains("server"), "{error}"); +} + +// -- the typed round trip -------------------------------------------------------- + +#[test] +fn apply_round_trips_typed_items_through_json() { + #[derive(Debug, PartialEq, serde::Serialize, serde::Deserialize)] + struct Row { + id: String, + enabled: bool, + } + + let items = vec![ + Row { + id: "a".to_owned(), + enabled: true, + }, + Row { + id: "b".to_owned(), + enabled: false, + }, + ]; + let query = + ListQuery::parse(Some("$filter=enabled eq false&$top=1"), &CATALOG).expect("query parses"); + let page = query.apply(items).expect("round trip"); + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].id, "b"); + assert!(!page.items[0].enabled); + assert_eq!(page.page_info.limit, 1); +} 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..88490ae --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/plugin.rs @@ -0,0 +1,1057 @@ +//! Plugin system core (ADR-0002). +//! +//! Three plugin types with separate traits, the contexts they operate on and +//! the deterministic execution order the data plane (slice 4) relies on: +//! +//! ```text +//! Auth (auth_plugin) -> credential injection +//! Guards (guard_plugin) -> validation, may reject +//! Transforms (transform_plugin) -> request mutation +//! -> upstream call +//! Transforms (transform_plugin) -> response / error mutation +//! ``` +//! +//! ## Ordering +//! +//! Upstream plugins execute before route plugins, and within one tier the +//! declaration order of `plugins.items[]` is preserved. The phase buckets are +//! global: every auth plugin runs before every guard, and every guard runs +//! before every transform, regardless of tier. [`PluginChain`] keeps the +//! `(tier, declaration index)` pair of every link and returns the links of a +//! phase sorted by it. +//! +//! ## Configuration source +//! +//! ADR-0008 refers to `ctx.config` — the configuration payload of the plugin +//! *binding* being executed. The ADR-0002 traits carry no configuration +//! parameter and plugin definitions are immutable (ADR-0002: "updates are +//! performed by creating a new plugin version"), so every built-in plugin is +//! constructed with its binding configuration by the +//! [`crate::infra::plugin::PluginRegistry`] factory and treats it as +//! immutable. [`RequestContext::plugin_config`] stays on the context for data +//! planes that want to surface the same value to external plugins; the +//! built-ins never read it, which is why the guard phase (whose ADR-0002 +//! signature takes `&RequestContext`) is unaffected by its per-link nature. +//! +//! ## Result type +//! +//! Every plugin method returns `Result`: the gear's own +//! [`crate::domain::error::OagwError`] taxonomy, so a plugin rejection renders +//! as the same `application/problem+json` document a gateway error does. + +use std::sync::Arc; + +use async_trait::async_trait; +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use bytes::Bytes; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::{OagwError, ProblemBody, ProblemContext}; +use crate::domain::rate_limit::RateLimitDecision; + +/// GTS base type of an auth plugin (`gts.cf.core.oagw.auth_plugin.v1~*`). +pub const AUTH_PLUGIN_TYPE_ID: &str = "gts.cf.core.oagw.auth_plugin.v1"; +/// GTS base type of a guard plugin (`gts.cf.core.oagw.guard_plugin.v1~*`). +pub const GUARD_PLUGIN_TYPE_ID: &str = "gts.cf.core.oagw.guard_plugin.v1"; +/// GTS base type of a transform plugin (`gts.cf.core.oagw.transform_plugin.v1~*`). +pub const TRANSFORM_PLUGIN_TYPE_ID: &str = "gts.cf.core.oagw.transform_plugin.v1"; + +/// Built-in plugin GTS instance ids and the catalog-only identifiers that have +/// no backing implementation (ADR-0002 / PRD "Built-in Plugins"). +pub mod builtin { + /// Auth no-op: no credential injection. + pub const NOOP_AUTH: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1"; + /// API key injection (header or query parameter). + pub const APIKEY_AUTH: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1"; + /// OAuth2 client credentials, client authentication in the form body. + pub const OAUTH2_CLIENT_CRED: &str = + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1"; + /// OAuth2 client credentials, client authentication in the `Authorization` + /// header. + pub const OAUTH2_CLIENT_CRED_BASIC: &str = + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1"; + /// Required header enforcement (request and response phases). + pub const REQUIRED_HEADERS_GUARD: &str = + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"; + /// `X-Request-ID` propagation. + pub const REQUEST_ID_TRANSFORM: &str = + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"; + + /// Catalog-only auth identifier: HTTP Basic, no backing plugin. + pub const BASIC_AUTH: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.basic.v1"; + /// Catalog-only auth identifier: Bearer injection, no backing plugin. + pub const BEARER_AUTH: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.bearer.v1"; + /// Catalog-only guard identifier: request timeout, core data-plane logic. + pub const TIMEOUT_GUARD: &str = "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.timeout.v1"; + /// Catalog-only guard identifier: CORS, core data-plane logic. + pub const CORS_GUARD: &str = "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1"; + /// Catalog-only transform identifier: logging, core data-plane logic. + pub const LOGGING_TRANSFORM: &str = + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.logging.v1"; + /// Catalog-only transform identifier: metrics, core data-plane logic. + pub const METRICS_TRANSFORM: &str = + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.metrics.v1"; + + /// GTS base type of `id`, i.e. the part before the `~` separator. + /// + /// Catalog-only identifiers carry their base type too, so an operator + /// binding `cf.core.oagw.cors.v1` gets a precise "not resolvable" error + /// instead of a generic not-found. + #[must_use] + pub fn base_type(plugin_ref: &str) -> Option<&str> { + plugin_ref.split('~').next().filter(|base| !base.is_empty()) + } +} + +// --------------------------------------------------------------------------- +// Contexts +// --------------------------------------------------------------------------- + +/// Request body as seen by a plugin. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub enum BodyPayload { + /// No body at all (`GET`, `HEAD`, `204`). + #[default] + Empty, + /// Fully buffered body. Auth and transform plugins only ever see this. + Buffered(Bytes), + /// The body is streamed by the data plane and is not materialised here. + Streaming, +} + +impl BodyPayload { + /// Length of the buffered body in bytes; `None` for streaming bodies. + #[must_use] + pub fn buffered_len(&self) -> Option { + match self { + Self::Buffered(bytes) => Some(bytes.len()), + Self::Empty | Self::Streaming => None, + } + } + + /// The buffered bytes, or `None` for streaming and empty bodies. + #[must_use] + pub fn as_buffered(&self) -> Option<&Bytes> { + match self { + Self::Buffered(bytes) => Some(bytes), + Self::Empty | Self::Streaming => None, + } + } + + /// `true` when the data plane streams this body instead of buffering it. + #[must_use] + pub fn is_streaming(&self) -> bool { + matches!(self, Self::Streaming) + } +} + +/// CORS evaluation outcome the proxy handler records on the request. +#[derive(Debug, Clone)] +pub enum CorsOutcome { + /// CORS is disabled for the resolved upstream/route, or the request is + /// not a CORS request (no `Origin` header). + Disabled, + /// Preflight answered locally by the gateway; carries the response headers. + Preflight { + /// `Access-Control-*` headers to emit on the `204`. + headers: Vec<(HeaderName, HeaderValue)>, + }, + /// Actual cross-origin request accepted; carries the response headers. + Allowed { + /// `Access-Control-*` headers to merge into the upstream response. + headers: Vec<(HeaderName, HeaderValue)>, + }, + /// Actual cross-origin request rejected before reaching the upstream. + Rejected { + /// The 403 problem document to return to the caller. + error: OagwError, + }, +} + +/// Everything a plugin can observe or mutate about the proxied request. +#[derive(Debug, Clone)] +pub struct RequestContext { + /// HTTP method of the inbound request (`GET`, `POST`, ...). + pub method: String, + /// Host the request is proxied to (the pinned target host). + pub target_host: String, + /// Matched route path prefix. + pub path: String, + /// Path suffix taken from the proxy URL (`path_suffix_mode: append`). + pub path_suffix: String, + /// Raw query string, without the leading `?`. + pub query: String, + /// Inbound request headers. Auth and transform plugins mutate this map. + pub headers: HeaderMap, + /// Request body. + pub body: BodyPayload, + /// Tenant of the authenticated subject. + pub tenant_id: Uuid, + /// Authenticated subject id, `None` for anonymous or preflight requests. + pub subject_id: Option, + /// Security context handed to the credential store when a plugin resolves + /// a `cred://` reference. + pub security: Option>, + /// Resolved upstream id. + pub upstream_id: Option, + /// Upstream alias the request was routed through. + pub alias: String, + /// Correlation id, propagated from the inbound `X-Request-ID` or generated. + pub request_id: Option, + /// Client address, used by the `ip` rate-limit scope. + pub peer_ip: Option, + /// Matched route id, used by the `route` rate-limit scope. + pub route_id: Option, + /// CORS evaluation outcome. + pub cors: CorsOutcome, + /// Rate-limit outcome, `None` when the request was not rate limited. + pub rate_limit: Option, + /// Headers injected into the outbound request, in injection order. + pub injected_headers: Vec<(HeaderName, HeaderValue)>, + /// Query parameters appended to the outbound request, in injection order. + pub injected_query: Vec<(String, String)>, + /// Configuration of the plugin currently executing (ADR-0008 `ctx.config`). + /// Populated by the data plane; the built-in plugins receive the same + /// payload at construction time and do not read it (see the module docs). + pub plugin_config: serde_json::Value, +} + +impl Default for RequestContext { + fn default() -> Self { + Self { + method: String::new(), + target_host: String::new(), + path: String::new(), + path_suffix: String::new(), + query: String::new(), + headers: HeaderMap::new(), + body: BodyPayload::Empty, + tenant_id: Uuid::nil(), + subject_id: None, + security: None, + upstream_id: None, + alias: String::new(), + request_id: None, + peer_ip: None, + route_id: None, + cors: CorsOutcome::Disabled, + rate_limit: None, + injected_headers: Vec::new(), + injected_query: Vec::new(), + plugin_config: crate::domain::model::empty_json_object(), + } + } +} + +impl RequestContext { + /// Builder for a request context (see [`RequestContextBuilder`]). + #[must_use] + pub fn builder() -> RequestContextBuilder { + RequestContextBuilder::default() + } + + /// First value of a header, compared case-insensitively on the name. + #[must_use] + pub fn header(&self, name: &str) -> Option<&HeaderValue> { + let lowered = name.to_ascii_lowercase(); + self.headers + .iter() + .find(|(key, _)| key.as_str() == lowered) + .map(|(_, value)| value) + } + + /// `true` when a header with this name is present (case-insensitive). + #[must_use] + pub fn has_header(&self, name: &str) -> bool { + self.header(name).is_some() + } + + /// Records a header the outbound request must carry. + /// + /// An earlier injection of the same header name is replaced rather than + /// appended, so a plugin that runs twice (or two plugins writing the same + /// header) still leaves exactly one outbound value. + pub fn inject_header(&mut self, name: HeaderName, value: HeaderValue) { + match self + .injected_headers + .iter_mut() + .find(|(candidate, _)| candidate.as_str() == name.as_str()) + { + Some(entry) => entry.1 = value, + None => self.injected_headers.push((name, value)), + } + } + + /// Records a query parameter the outbound request must carry. + /// + /// Like [`Self::inject_header`], an earlier injection of the same name is + /// replaced. + pub fn inject_query(&mut self, name: impl Into, value: impl Into) { + let name = name.into(); + let value = value.into(); + match self + .injected_query + .iter_mut() + .find(|(candidate, _)| *candidate == name) + { + Some(entry) => entry.1 = value, + None => self.injected_query.push((name, value)), + } + } +} + +/// Builds a [`RequestContext`] fluently; every field defaults. +#[derive(Debug, Clone, Default)] +pub struct RequestContextBuilder { + context: RequestContext, +} + +impl RequestContextBuilder { + /// Sets the HTTP method. + #[must_use] + pub fn method(mut self, method: impl Into) -> Self { + self.context.method = method.into(); + self + } + + /// Sets the target host. + #[must_use] + pub fn target_host(mut self, host: impl Into) -> Self { + self.context.target_host = host.into(); + self + } + + /// Sets the matched path. + #[must_use] + pub fn path(mut self, path: impl Into) -> Self { + self.context.path = path.into(); + self + } + + /// Sets the path suffix. + #[must_use] + pub fn path_suffix(mut self, suffix: impl Into) -> Self { + self.context.path_suffix = suffix.into(); + self + } + + /// Sets the raw query string. + #[must_use] + pub fn query(mut self, query: impl Into) -> Self { + self.context.query = query.into(); + self + } + + /// Replaces the header map. + #[must_use] + pub fn headers(mut self, headers: HeaderMap) -> Self { + self.context.headers = headers; + self + } + + /// Sets the request body. + #[must_use] + pub fn body(mut self, body: BodyPayload) -> Self { + self.context.body = body; + self + } + + /// Sets the tenant id. + #[must_use] + pub fn tenant_id(mut self, tenant_id: Uuid) -> Self { + self.context.tenant_id = tenant_id; + self + } + + /// Sets the subject id. + #[must_use] + pub fn subject_id(mut self, subject_id: Uuid) -> Self { + self.context.subject_id = Some(subject_id); + self + } + + /// Sets the security context used for `cred://` resolution. + #[must_use] + pub fn security(mut self, security: Arc) -> Self { + self.context.security = Some(security); + self + } + + /// Sets the upstream id. + #[must_use] + pub fn upstream_id(mut self, upstream_id: Uuid) -> Self { + self.context.upstream_id = Some(upstream_id); + self + } + + /// Sets the upstream alias. + #[must_use] + pub fn alias(mut self, alias: impl Into) -> Self { + self.context.alias = alias.into(); + self + } + + /// Sets the request id. + #[must_use] + pub fn request_id(mut self, request_id: impl Into) -> Self { + self.context.request_id = Some(request_id.into()); + self + } + + /// Sets the client address. + #[must_use] + pub fn peer_ip(mut self, peer_ip: impl Into) -> Self { + self.context.peer_ip = Some(peer_ip.into()); + self + } + + /// Sets the matched route id. + #[must_use] + pub fn route_id(mut self, route_id: Uuid) -> Self { + self.context.route_id = Some(route_id); + self + } + + /// Sets the CORS outcome. + #[must_use] + pub fn cors(mut self, cors: CorsOutcome) -> Self { + self.context.cors = cors; + self + } + + /// Sets the rate-limit outcome. + #[must_use] + pub fn rate_limit(mut self, decision: RateLimitDecision) -> Self { + self.context.rate_limit = Some(decision); + self + } + + /// Sets the executing plugin configuration. + #[must_use] + pub fn plugin_config(mut self, config: serde_json::Value) -> Self { + self.context.plugin_config = config; + self + } + + /// Consumes the builder, returning the context. + #[must_use] + pub fn build(self) -> RequestContext { + self.context + } +} + +/// Everything a plugin can observe about the upstream response. +#[derive(Debug, Clone)] +pub struct ResponseContext { + /// Upstream response status. + pub status: StatusCode, + /// Upstream response headers; transforms may rewrite them. + pub headers: HeaderMap, + /// Response body. + pub body: BodyPayload, + /// Correlation id propagated from the request, when known. + pub request_id: Option, + /// Configuration of the plugin currently executing (ADR-0008 `ctx.config`). + pub plugin_config: serde_json::Value, +} + +impl Default for ResponseContext { + fn default() -> Self { + Self { + status: StatusCode::OK, + headers: HeaderMap::new(), + body: BodyPayload::Empty, + request_id: None, + plugin_config: crate::domain::model::empty_json_object(), + } + } +} + +impl ResponseContext { + /// Builder for a response context (see [`ResponseContextBuilder`]). + #[must_use] + pub fn builder() -> ResponseContextBuilder { + ResponseContextBuilder::default() + } + + /// Value of a single header (case-insensitive), for diagnostics and for + /// the proxy handler. + #[must_use] + pub fn header(&self, name: &str) -> Option<&HeaderValue> { + self.headers.get(name) + } + + /// `true` when a header with this name is present (case-insensitive). + #[must_use] + pub fn has_header(&self, name: &str) -> bool { + let lowered = name.to_ascii_lowercase(); + self.headers.iter().any(|(key, _)| key.as_str() == lowered) + } +} + +/// Builds a [`ResponseContext`] fluently; every field defaults. +#[derive(Debug, Clone, Default)] +pub struct ResponseContextBuilder { + context: ResponseContext, +} + +impl ResponseContextBuilder { + /// Sets the response status. + #[must_use] + pub fn status(mut self, status: StatusCode) -> Self { + self.context.status = status; + self + } + + /// Replaces the header map. + #[must_use] + pub fn headers(mut self, headers: HeaderMap) -> Self { + self.context.headers = headers; + self + } + + /// Sets the response body. + #[must_use] + pub fn body(mut self, body: BodyPayload) -> Self { + self.context.body = body; + self + } + + /// Sets the propagated request id. + #[must_use] + pub fn request_id(mut self, request_id: impl Into) -> Self { + self.context.request_id = Some(request_id.into()); + self + } + + /// Sets the executing plugin configuration. + #[must_use] + pub fn plugin_config(mut self, config: serde_json::Value) -> Self { + self.context.plugin_config = config; + self + } + + /// Consumes the builder, returning the context. + #[must_use] + pub fn build(self) -> ResponseContext { + self.context + } +} + +/// Everything a transform plugin can rewrite about a gateway error. +/// +/// `status` and `retry_after` are snapshots of `error` taken at construction +/// so a transform can rewrite the rendered response without re-deriving them +/// from the taxonomy. +#[derive(Debug, Clone)] +pub struct ErrorContext { + /// The gateway error being rendered. + pub error: OagwError, + /// HTTP status the error renders as. + pub status: StatusCode, + /// Response headers for the problem document (including `Retry-After`). + pub headers: HeaderMap, + /// `Retry-After` value in seconds, when the error carries one. + pub retry_after: Option, + /// Correlation id propagated from the request, when known. + pub request_id: Option, + /// Configuration of the plugin currently executing (ADR-0008 `ctx.config`). + pub plugin_config: serde_json::Value, +} + +impl ErrorContext { + /// Wraps a gateway error, taking its status and `Retry-After` hint. + #[must_use] + pub fn from_error(error: OagwError) -> Self { + let status = error.status(); + let retry_after = error.context().retry_after_seconds; + Self { + error, + status, + headers: HeaderMap::new(), + retry_after, + request_id: None, + plugin_config: crate::domain::model::empty_json_object(), + } + } + + /// The rendered problem document of the wrapped error. + #[must_use] + pub fn problem_body(&self) -> &ProblemBody { + self.error.problem_body() + } +} + +// --------------------------------------------------------------------------- +// Guard decisions +// --------------------------------------------------------------------------- + +/// Verdict of a guard plugin phase. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GuardDecision { + /// The phase accepted the request or response. + Allow, + /// The phase rejected it; carries the status and the machine-readable + /// error code the problem document reports. + Reject { + /// HTTP status to return (`400` for request-phase rejections, `502` + /// for response-phase rejections, ...). + status: StatusCode, + /// Stable error code, e.g. `REQUIRED_HEADER_MISSING` (ADR-0009). + error_code: String, + /// Human-readable detail for the problem document. + detail: String, + }, +} + +impl GuardDecision { + /// An [`GuardDecision::Allow`] verdict. + #[must_use] + pub const fn allow() -> Self { + Self::Allow + } + + /// Builds a [`GuardDecision::Reject`] verdict. + #[must_use] + pub fn reject( + status: StatusCode, + error_code: impl Into, + detail: impl Into, + ) -> Self { + Self::Reject { + status, + error_code: error_code.into(), + detail: detail.into(), + } + } + + /// `true` for [`GuardDecision::Allow`]. + #[must_use] + pub const fn is_allow(&self) -> bool { + matches!(self, Self::Allow) + } + + /// HTTP status of the verdict; `None` when the phase allowed. + #[must_use] + pub const fn status(&self) -> Option { + match self { + Self::Allow => None, + Self::Reject { status, .. } => Some(*status), + } + } + + /// Stable error code of the verdict; `None` when the phase allowed. + #[must_use] + pub const fn error_code(&self) -> Option<&str> { + match self { + Self::Allow => None, + Self::Reject { error_code, .. } => Some(error_code.as_str()), + } + } + + /// Converts a rejection into the gear's error taxonomy so a guard + /// rejection renders like any other gateway error. + /// + /// # Errors + /// + /// Returns the rejection mapped onto [`OagwError`]; allowing verdicts + /// never reach this method, but when they do the error is + /// [`OagwError::validation`] with the rejection detail. + pub fn into_error(self) -> OagwError { + let Self::Reject { + status, + error_code, + detail, + } = self + else { + return OagwError::validation("guard phase allowed, no rejection to map"); + }; + let detail = format!("{error_code}: {detail}"); + match status { + StatusCode::BAD_REQUEST => OagwError::validation(detail), + StatusCode::UNAUTHORIZED => OagwError::authentication_failed(detail), + StatusCode::FORBIDDEN => OagwError::forbidden(detail), + StatusCode::TOO_MANY_REQUESTS => OagwError::rate_limit_exceeded(detail), + StatusCode::NOT_FOUND => OagwError::route_not_found(detail), + StatusCode::BAD_GATEWAY => OagwError::downstream_error(detail), + StatusCode::SERVICE_UNAVAILABLE => OagwError::link_unavailable(detail), + _ => OagwError::downstream_error(detail), + } + } +} + +// --------------------------------------------------------------------------- +// Plugin traits (ADR-0002 signatures, verbatim) +// --------------------------------------------------------------------------- + +/// Credential injection. Executed once per request, before guards. +#[async_trait] +pub trait AuthPlugin: Send + Sync { + /// Registry key of this plugin (the full GTS plugin id for built-ins). + fn id(&self) -> &str; + + /// GTS base type of this plugin (`gts.cf.core.oagw.auth_plugin.v1`). + fn plugin_type(&self) -> &str; + + /// Injects credentials into `ctx`. + /// + /// # Errors + /// + /// Returns an [`OagwError`] when the credential cannot be resolved or + /// does not match the configured expectation. + async fn authenticate(&self, ctx: &mut RequestContext) -> Result<(), OagwError>; +} + +/// Validation and policy enforcement. Executed after auth, before transform. +#[async_trait] +pub trait GuardPlugin: Send + Sync { + /// Registry key of this plugin (the full GTS plugin id for built-ins). + fn id(&self) -> &str; + + /// GTS base type of this plugin (`gts.cf.core.oagw.guard_plugin.v1`). + fn plugin_type(&self) -> &str; + + /// Validates the request before it is proxied. + /// + /// # Errors + /// + /// Returns an [`OagwError`] when the guard itself fails (as opposed to + /// rejecting the request, which is reported through [`GuardDecision`]). + async fn guard_request(&self, ctx: &RequestContext) -> Result; + + /// Validates the upstream response before it is returned to the caller. + /// + /// # Errors + /// + /// Returns an [`OagwError`] when the guard itself fails. + async fn guard_response(&self, ctx: &ResponseContext) -> Result; +} + +/// Request/response/error mutation. Executed around the proxy call. +#[async_trait] +pub trait TransformPlugin: Send + Sync { + /// Registry key of this plugin (the full GTS plugin id for built-ins). + fn id(&self) -> &str; + + /// GTS base type of this plugin (`gts.cf.core.oagw.transform_plugin.v1`). + fn plugin_type(&self) -> &str; + + /// Rewrites the request before it leaves the gateway. + /// + /// # Errors + /// + /// Returns an [`OagwError`] when the transformation cannot be applied. + async fn transform_request(&self, ctx: &mut RequestContext) -> Result<(), OagwError>; + + /// Rewrites the upstream response before it is returned to the caller. + /// + /// # Errors + /// + /// Returns an [`OagwError`] when the transformation cannot be applied. + async fn transform_response(&self, ctx: &mut ResponseContext) -> Result<(), OagwError>; + + /// Rewrites the problem document the gateway is about to return. + /// + /// # Errors + /// + /// Returns an [`OagwError`] when the transformation cannot be applied. + async fn transform_error(&self, ctx: &mut ErrorContext) -> Result<(), OagwError>; +} + +// --------------------------------------------------------------------------- +// Chain +// --------------------------------------------------------------------------- + +/// Tier of a plugin binding: the upstream chain runs before the route chain. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum PluginTier { + /// Plugins bound on the upstream (`upstream.plugins` / `upstream.auth`). + #[default] + Upstream, + /// Plugins bound on the matched route (`route.plugins`). + Route, +} + +/// The concrete plugin a chain link resolves to. +#[derive(Clone)] +enum PluginHandle { + Auth(Arc), + Guard(Arc), + Transform(Arc), +} + +impl std::fmt::Debug for PluginHandle { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let kind = match self { + Self::Auth(_) => "auth", + Self::Guard(_) => "guard", + Self::Transform(_) => "transform", + }; + formatter.write_str(kind) + } +} + +/// One resolved plugin occurrence in a chain. +#[derive(Debug, Clone)] +struct ChainLink { + tier: PluginTier, + declaration: usize, + plugin_ref: String, + plugin: PluginHandle, +} + +/// Ordered plugin chain (ADR-0002 execution order). +/// +/// Built by [`crate::infra::plugin::PluginRegistry::build_chain`] from an +/// upstream and its matched route; the runner methods below are the only +/// execution path the data plane needs. +#[derive(Debug, Clone)] +pub struct PluginChain { + links: Vec, +} + +impl PluginChain { + /// Builds an empty chain. + #[must_use] + pub const fn new() -> Self { + Self { links: Vec::new() } + } + + /// Appends an auth plugin. + pub fn push_auth( + &mut self, + tier: PluginTier, + declaration: usize, + plugin_ref: impl Into, + plugin: Arc, + ) { + self.links.push(ChainLink { + tier, + declaration, + plugin_ref: plugin_ref.into(), + plugin: PluginHandle::Auth(plugin), + }); + } + + /// Appends a guard plugin. + pub fn push_guard( + &mut self, + tier: PluginTier, + declaration: usize, + plugin_ref: impl Into, + plugin: Arc, + ) { + self.links.push(ChainLink { + tier, + declaration, + plugin_ref: plugin_ref.into(), + plugin: PluginHandle::Guard(plugin), + }); + } + + /// Appends a transform plugin. + pub fn push_transform( + &mut self, + tier: PluginTier, + declaration: usize, + plugin_ref: impl Into, + plugin: Arc, + ) { + self.links.push(ChainLink { + tier, + declaration, + plugin_ref: plugin_ref.into(), + plugin: PluginHandle::Transform(plugin), + }); + } + + /// `true` when no plugin is bound. + #[must_use] + pub fn is_empty(&self) -> bool { + self.links.is_empty() + } + + /// Number of resolved plugin links. + #[must_use] + pub fn len(&self) -> usize { + self.links.len() + } + + /// Plugin references in execution order (upstream first, then route). + #[must_use] + pub fn plugin_refs(&self) -> Vec { + self.ordered() + .into_iter() + .map(|link| link.plugin_ref.clone()) + .collect() + } + + /// The links of this chain in ADR-0002 execution order. + fn ordered(&self) -> Vec<&ChainLink> { + let mut links: Vec<&ChainLink> = self.links.iter().collect(); + links.sort_by_key(|link| (link.tier, link.declaration)); + links + } + + fn links_of(&self, filter: impl Fn(&PluginHandle) -> bool) -> Vec<&ChainLink> { + self.ordered() + .into_iter() + .filter(|link| filter(&link.plugin)) + .collect() + } + + /// Auth plugins in execution order. + #[must_use] + pub fn auth_plugins(&self) -> Vec> { + self.links_of(|handle| matches!(handle, PluginHandle::Auth(_))) + .into_iter() + .filter_map(|link| match &link.plugin { + PluginHandle::Auth(plugin) => Some(Arc::clone(plugin)), + _ => None, + }) + .collect() + } + + /// Guard plugins in execution order. + #[must_use] + pub fn guard_plugins(&self) -> Vec> { + self.links_of(|handle| matches!(handle, PluginHandle::Guard(_))) + .into_iter() + .filter_map(|link| match &link.plugin { + PluginHandle::Guard(plugin) => Some(Arc::clone(plugin)), + _ => None, + }) + .collect() + } + + /// Transform plugins in execution order. + #[must_use] + pub fn transform_plugins(&self) -> Vec> { + self.links_of(|handle| matches!(handle, PluginHandle::Transform(_))) + .into_iter() + .filter_map(|link| match &link.plugin { + PluginHandle::Transform(plugin) => Some(Arc::clone(plugin)), + _ => None, + }) + .collect() + } + + /// Runs every auth plugin in order, stopping at the first failure. + /// + /// # Errors + /// + /// Returns the error of the failing plugin; earlier injections stay in + /// `ctx` because the data plane aborts the request on the next line. + pub async fn authenticate(&self, ctx: &mut RequestContext) -> Result<(), OagwError> { + for link in self.links_of(|handle| matches!(handle, PluginHandle::Auth(_))) { + if let PluginHandle::Auth(plugin) = &link.plugin { + plugin.authenticate(ctx).await?; + } + } + Ok(()) + } + + /// Runs the request phase of every guard plugin in execution order. + /// + /// # Errors + /// + /// Returns the mapped rejection of the first guard that rejects. + pub async fn guard_request(&self, ctx: &RequestContext) -> Result<(), OagwError> { + for link in self.links_of(|handle| matches!(handle, PluginHandle::Guard(_))) { + if let PluginHandle::Guard(plugin) = &link.plugin { + let decision = plugin.guard_request(ctx).await?; + if let GuardDecision::Reject { .. } = decision { + return Err(decision.into_error()); + } + } + } + Ok(()) + } + + /// Runs the response phase of every guard plugin in execution order. + /// + /// # Errors + /// + /// Returns the mapped rejection of the first guard that rejects. + pub async fn guard_response(&self, ctx: &ResponseContext) -> Result<(), OagwError> { + for link in self.links_of(|handle| matches!(handle, PluginHandle::Guard(_))) { + if let PluginHandle::Guard(plugin) = &link.plugin { + let decision = plugin.guard_response(ctx).await?; + if let GuardDecision::Reject { .. } = decision { + return Err(decision.into_error()); + } + } + } + Ok(()) + } + + /// Runs the request phase of every transform plugin in execution order. + /// + /// # Errors + /// + /// Returns the error of the failing transform. + pub async fn transform_request(&self, ctx: &mut RequestContext) -> Result<(), OagwError> { + for link in self.links_of(|handle| matches!(handle, PluginHandle::Transform(_))) { + if let PluginHandle::Transform(plugin) = &link.plugin { + plugin.transform_request(ctx).await?; + } + } + Ok(()) + } + + /// Runs the response phase of every transform plugin in execution order. + /// + /// # Errors + /// + /// Returns the error of the failing transform. + pub async fn transform_response(&self, ctx: &mut ResponseContext) -> Result<(), OagwError> { + for link in self.links_of(|handle| matches!(handle, PluginHandle::Transform(_))) { + if let PluginHandle::Transform(plugin) = &link.plugin { + plugin.transform_response(ctx).await?; + } + } + Ok(()) + } + + /// Runs the error phase of every transform plugin in execution order. + /// + /// # Errors + /// + /// Returns the error of the failing transform. + pub async fn transform_error(&self, ctx: &mut ErrorContext) -> Result<(), OagwError> { + for link in self.links_of(|handle| matches!(handle, PluginHandle::Transform(_))) { + if let PluginHandle::Transform(plugin) = &link.plugin { + plugin.transform_error(ctx).await?; + } + } + Ok(()) + } +} + +impl Default for PluginChain { + fn default() -> Self { + Self::new() + } +} + +/// Builds a CORS problem document with the ADR-0004 GTS error ids. +/// +/// The two CORS error ids are not part of the [`crate::domain::error`] +/// taxonomy (which has a single `cors.forbidden` variant), so they are +/// rendered here through the public [`OagwError::Forbidden`] variant with an +/// explicit GTS type id. +#[must_use] +pub fn cors_error(gts_type: &str, title: &str, detail: String, invalid_value: String) -> OagwError { + OagwError::Forbidden(Box::new(ProblemBody { + context: ProblemContext { + invalid_value: Some(invalid_value), + ..ProblemContext::default() + }, + ..ProblemBody::bare(gts_type.to_owned(), title.to_owned(), 403, detail) + })) +} + +#[cfg(test)] +#[path = "plugin_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/plugin_tests.rs b/gears/system/oagw/oagw/src/domain/plugin_tests.rs new file mode 100644 index 0000000..495c27e --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/plugin_tests.rs @@ -0,0 +1,679 @@ +//! Tests for [`crate::domain::plugin`]. + +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use bytes::Bytes; +use uuid::Uuid; + +use super::{ + AUTH_PLUGIN_TYPE_ID, AuthPlugin, BodyPayload, CorsOutcome, ErrorContext, GUARD_PLUGIN_TYPE_ID, + GuardDecision, GuardPlugin, PluginChain, PluginTier, RequestContext, ResponseContext, + TRANSFORM_PLUGIN_TYPE_ID, TransformPlugin, builtin, cors_error, +}; +use crate::domain::cors::CORS_ORIGIN_NOT_ALLOWED_TYPE; +use crate::domain::error::{OagwError, ProblemBody, ProblemContext}; +use crate::domain::model::RateLimitStrategy; +use crate::domain::rate_limit::RateLimitDecision; + +fn request() -> RequestContext { + RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1/payments") + .tenant_id(Uuid::from_u128(0x11)) + .build() +} + +fn reject_bad_request() -> GuardDecision { + GuardDecision::reject(StatusCode::BAD_REQUEST, "TEST_REJECTED", "test rejection") +} + +// --------------------------------------------------------------------------- +// Test doubles +// --------------------------------------------------------------------------- + +#[derive(Default)] +struct RecordingAuth { + calls: AtomicUsize, + fail: bool, +} + +impl std::fmt::Debug for RecordingAuth { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("RecordingAuth") + } +} + +#[async_trait] +impl AuthPlugin for RecordingAuth { + fn id(&self) -> &str { + "test.auth" + } + + fn plugin_type(&self) -> &str { + AUTH_PLUGIN_TYPE_ID + } + + async fn authenticate(&self, _ctx: &mut RequestContext) -> Result<(), OagwError> { + self.calls.fetch_add(1, Ordering::SeqCst); + if self.fail { + Err(OagwError::authentication_failed("nope")) + } else { + Ok(()) + } + } +} + +struct RecordingGuard { + calls: AtomicUsize, + verdict: Mutex, +} + +impl std::fmt::Debug for RecordingGuard { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("RecordingGuard") + } +} + +impl Default for RecordingGuard { + fn default() -> Self { + Self { + calls: AtomicUsize::new(0), + verdict: Mutex::new(GuardDecision::Allow), + } + } +} + +impl RecordingGuard { + fn rejecting() -> Self { + Self { + calls: AtomicUsize::new(0), + verdict: Mutex::new(reject_bad_request()), + } + } +} + +#[async_trait] +impl GuardPlugin for RecordingGuard { + fn id(&self) -> &str { + "test.guard" + } + + fn plugin_type(&self) -> &str { + GUARD_PLUGIN_TYPE_ID + } + + async fn guard_request(&self, _ctx: &RequestContext) -> Result { + self.calls.fetch_add(1, Ordering::SeqCst); + Ok(self + .verdict + .lock() + .map(|guard| guard.clone()) + .unwrap_or(GuardDecision::Allow)) + } + + async fn guard_response(&self, ctx: &ResponseContext) -> Result { + let _ = ctx; + Ok(GuardDecision::Allow) + } +} + +#[derive(Default)] +struct RecordingTransform { + calls: AtomicUsize, + fail: bool, +} + +impl std::fmt::Debug for RecordingTransform { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("RecordingTransform") + } +} + +#[async_trait] +impl TransformPlugin for RecordingTransform { + fn id(&self) -> &str { + "test.transform" + } + + fn plugin_type(&self) -> &str { + TRANSFORM_PLUGIN_TYPE_ID + } + + async fn transform_request(&self, ctx: &mut RequestContext) -> Result<(), OagwError> { + self.calls.fetch_add(1, Ordering::SeqCst); + ctx.request_id = Some("from-transform".to_owned()); + if self.fail { + Err(OagwError::downstream_error("transform failed")) + } else { + Ok(()) + } + } + + async fn transform_response(&self, _ctx: &mut ResponseContext) -> Result<(), OagwError> { + Ok(()) + } + + async fn transform_error(&self, _ctx: &mut ErrorContext) -> Result<(), OagwError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// builtin ids +// --------------------------------------------------------------------------- + +#[test] +fn builtin_ids_carry_their_base_type() { + assert_eq!( + builtin::NOOP_AUTH, + format!("{AUTH_PLUGIN_TYPE_ID}~cf.core.oagw.noop.v1") + ); + assert_eq!( + builtin::APIKEY_AUTH, + format!("{AUTH_PLUGIN_TYPE_ID}~cf.core.oagw.apikey.v1") + ); + assert_eq!( + builtin::OAUTH2_CLIENT_CRED, + format!("{AUTH_PLUGIN_TYPE_ID}~cf.core.oagw.oauth2_client_cred.v1") + ); + assert_eq!( + builtin::OAUTH2_CLIENT_CRED_BASIC, + format!("{AUTH_PLUGIN_TYPE_ID}~cf.core.oagw.oauth2_client_cred_basic.v1") + ); + assert_eq!( + builtin::REQUIRED_HEADERS_GUARD, + format!("{GUARD_PLUGIN_TYPE_ID}~cf.core.oagw.required_headers.v1") + ); + assert_eq!( + builtin::REQUEST_ID_TRANSFORM, + format!("gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1") + ); + assert_eq!( + builtin::base_type(builtin::APIKEY_AUTH), + Some(AUTH_PLUGIN_TYPE_ID) + ); + assert_eq!( + builtin::base_type("cf.core.oagw.apikey.v1"), + Some("cf.core.oagw.apikey.v1") + ); + assert_eq!(builtin::base_type(""), None); +} + +#[test] +fn catalog_only_ids_are_declared_but_distinct_from_builtins() { + for catalog_only in [ + builtin::BASIC_AUTH, + builtin::BEARER_AUTH, + builtin::TIMEOUT_GUARD, + builtin::CORS_GUARD, + builtin::LOGGING_TRANSFORM, + builtin::METRICS_TRANSFORM, + ] { + assert_ne!(catalog_only, builtin::NOOP_AUTH); + assert_ne!(catalog_only, builtin::APIKEY_AUTH); + assert_ne!(catalog_only, builtin::REQUIRED_HEADERS_GUARD); + assert_ne!(catalog_only, builtin::REQUEST_ID_TRANSFORM); + } +} + +// --------------------------------------------------------------------------- +// BodyPayload +// --------------------------------------------------------------------------- + +#[test] +fn body_payload_reports_its_shape() { + assert_eq!(BodyPayload::default(), BodyPayload::Empty); + assert_eq!(BodyPayload::Empty.buffered_len(), None); + assert_eq!( + BodyPayload::Buffered(Bytes::from_static(b"abc")).buffered_len(), + Some(3) + ); + assert_eq!( + BodyPayload::Buffered(Bytes::from_static(b"abc")) + .as_buffered() + .map(Bytes::as_ref), + Some(&b"abc"[..]) + ); + assert!(BodyPayload::Streaming.is_streaming()); + assert!(!BodyPayload::Empty.is_streaming()); +} + +// --------------------------------------------------------------------------- +// Request / response / error contexts +// --------------------------------------------------------------------------- + +#[test] +fn request_context_builder_defaults() { + let ctx = request(); + assert_eq!(ctx.method, "GET"); + assert_eq!(ctx.alias, "payments"); + assert!(ctx.headers.is_empty()); + assert_eq!(ctx.body, BodyPayload::Empty); + assert!(matches!(ctx.cors, CorsOutcome::Disabled)); + assert!(ctx.rate_limit.is_none()); + assert!(ctx.injected_headers.is_empty()); + assert!(ctx.injected_query.is_empty()); + assert!(ctx.security.is_none()); + assert!(ctx.upstream_id.is_none()); +} + +#[test] +fn request_context_header_lookup_is_case_insensitive() { + let mut headers = HeaderMap::new(); + headers.insert("x-api-key", HeaderValue::from_static("secret")); + let ctx = RequestContext::builder() + .method("POST") + .alias("payments") + .path("/v1") + .tenant_id(Uuid::from_u128(1)) + .headers(headers) + .build(); + assert_eq!( + ctx.header("x-api-key") + .and_then(|value| value.to_str().ok()), + Some("secret") + ); + assert_eq!( + ctx.header("X-Api-Key") + .and_then(|value| value.to_str().ok()), + Some("secret") + ); + assert!(ctx.has_header("x-api-key")); + assert!(!ctx.has_header("x-other")); +} + +#[test] +fn request_context_carries_rate_limit_and_cors() { + let decision = RateLimitDecision { + allowed: true, + limit: 10, + remaining: 9, + reset_epoch_secs: 1, + retry_after_seconds: None, + queue_wait: None, + degraded: false, + strategy: RateLimitStrategy::Reject, + emit_headers: true, + }; + let ctx = RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1") + .tenant_id(Uuid::from_u128(1)) + .rate_limit(decision) + .build(); + let recorded = ctx.rate_limit.as_ref().expect("decision"); + assert!(recorded.is_allowed()); + assert_eq!(recorded.limit, 10); +} + +#[test] +fn response_context_reports_headers() { + let mut headers = HeaderMap::new(); + headers.insert("x-upstream", HeaderValue::from_static("1")); + let ctx = ResponseContext::builder() + .status(StatusCode::OK) + .headers(headers) + .request_id("abc") + .build(); + assert_eq!(ctx.status, StatusCode::OK); + assert_eq!( + ctx.header("x-upstream").map(HeaderValue::as_bytes), + Some(b"1".as_slice()) + ); + assert!(ctx.has_header("x-upstream")); + assert!(!ctx.has_header("x-request-id")); + assert_eq!(ctx.request_id.as_deref(), Some("abc")); + assert_eq!(ctx.body, BodyPayload::Empty); +} + +#[test] +fn error_context_renders_the_problem_body() { + let error = cors_error( + CORS_ORIGIN_NOT_ALLOWED_TYPE, + "CORS Origin Not Allowed", + "Origin 'https://evil.example.com' not in allowed origins list".to_owned(), + "https://evil.example.com".to_owned(), + ); + let ctx = ErrorContext::from_error(error); + assert_eq!(ctx.status, StatusCode::FORBIDDEN); + assert_eq!(ctx.problem_body().r#type, CORS_ORIGIN_NOT_ALLOWED_TYPE); + assert_eq!( + ctx.problem_body(), + &ProblemBody { + context: ProblemContext { + invalid_value: Some("https://evil.example.com".to_owned()), + ..ProblemContext::default() + }, + ..ProblemBody::bare( + CORS_ORIGIN_NOT_ALLOWED_TYPE.to_owned(), + "CORS Origin Not Allowed".to_owned(), + 403, + "Origin 'https://evil.example.com' not in allowed origins list".to_owned(), + ) + } + ); +} + +// --------------------------------------------------------------------------- +// Guard decisions +// --------------------------------------------------------------------------- + +#[test] +fn guard_decision_allow_has_no_status() { + let decision = GuardDecision::allow(); + assert!(decision.is_allow()); + assert_eq!(decision.status(), None); + assert_eq!(decision.error_code(), None); +} + +#[test] +fn guard_decision_reject_carries_status_and_code() { + let decision = GuardDecision::reject(StatusCode::BAD_REQUEST, "REQUIRED_HEADER_MISSING", "x"); + assert!(!decision.is_allow()); + assert_eq!(decision.status(), Some(StatusCode::BAD_REQUEST)); + assert_eq!(decision.error_code(), Some("REQUIRED_HEADER_MISSING")); +} + +#[test] +fn guard_rejection_maps_onto_the_error_taxonomy() { + let validation = GuardDecision::reject(StatusCode::BAD_REQUEST, "A", "a").into_error(); + assert_eq!(validation.status(), StatusCode::BAD_REQUEST); + let unauthorized = GuardDecision::reject(StatusCode::UNAUTHORIZED, "A", "a").into_error(); + assert_eq!(unauthorized.status(), StatusCode::UNAUTHORIZED); + let forbidden = GuardDecision::reject(StatusCode::FORBIDDEN, "A", "a").into_error(); + assert_eq!(forbidden.status(), StatusCode::FORBIDDEN); + let throttled = GuardDecision::reject(StatusCode::TOO_MANY_REQUESTS, "A", "a").into_error(); + assert_eq!(throttled.status(), StatusCode::TOO_MANY_REQUESTS); + let gateway = GuardDecision::reject(StatusCode::BAD_GATEWAY, "A", "a").into_error(); + assert_eq!(gateway.status(), StatusCode::BAD_GATEWAY); + let unavailable = GuardDecision::reject(StatusCode::SERVICE_UNAVAILABLE, "A", "a").into_error(); + assert_eq!(unavailable.status(), StatusCode::SERVICE_UNAVAILABLE); + // A status outside the taxonomy folds into the downstream-error family + // rather than being reported as a client error. + let custom = GuardDecision::reject(StatusCode::IM_A_TEAPOT, "A", "a").into_error(); + assert_eq!(custom.status(), StatusCode::BAD_GATEWAY); +} + +#[test] +fn cors_error_is_a_403_with_the_gts_type() { + let error = cors_error( + CORS_ORIGIN_NOT_ALLOWED_TYPE, + "CORS Origin Not Allowed", + "nope".to_owned(), + "https://a.example.com".to_owned(), + ); + assert_eq!(error.status(), StatusCode::FORBIDDEN); + assert_eq!(error.problem_body().status, 403); + assert_eq!( + error.problem_body().context.invalid_value.as_deref(), + Some("https://a.example.com") + ); +} + +// --------------------------------------------------------------------------- +// Plugin chain +// --------------------------------------------------------------------------- + +#[test] +fn empty_chain_is_a_no_op() { + let chain = PluginChain::new(); + assert!(chain.is_empty()); + assert_eq!(chain.len(), 0); + assert!(chain.plugin_refs().is_empty()); + assert!(chain.auth_plugins().is_empty()); + assert!(chain.guard_plugins().is_empty()); + assert!(chain.transform_plugins().is_empty()); + assert!(PluginChain::default().is_empty()); +} + +#[test] +fn plugin_refs_preserve_insertion_order() { + let mut chain = PluginChain::new(); + chain.push_auth( + PluginTier::Upstream, + 0, + "auth", + Arc::new(RecordingAuth::default()), + ); + chain.push_guard( + PluginTier::Upstream, + 1, + "guard-1", + Arc::new(RecordingGuard::default()), + ); + chain.push_guard( + PluginTier::Route, + 0, + "guard-2", + Arc::new(RecordingGuard::default()), + ); + chain.push_transform( + PluginTier::Route, + 1, + "transform", + Arc::new(RecordingTransform::default()), + ); + assert_eq!(chain.len(), 4); + assert_eq!( + chain.plugin_refs(), + vec!["auth", "guard-1", "guard-2", "transform"] + ); + assert_eq!(chain.auth_plugins().len(), 1); + assert_eq!(chain.guard_plugins().len(), 2); + assert_eq!(chain.transform_plugins().len(), 1); + assert!(format!("{chain:?}").contains("guard-1")); +} + +#[tokio::test] +async fn chain_runs_auth_before_guards_before_transforms() { + let order = Arc::new(Mutex::new(Vec::<&'static str>::new())); + + struct Tracer { + phase: &'static str, + order: Arc>>, + } + + impl std::fmt::Debug for Tracer { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.phase) + } + } + + #[async_trait] + impl AuthPlugin for Tracer { + fn id(&self) -> &str { + self.phase + } + + fn plugin_type(&self) -> &str { + AUTH_PLUGIN_TYPE_ID + } + + async fn authenticate(&self, _ctx: &mut RequestContext) -> Result<(), OagwError> { + self.order.lock().expect("order").push(self.phase); + Ok(()) + } + } + + #[async_trait] + impl GuardPlugin for Tracer { + fn id(&self) -> &str { + self.phase + } + + fn plugin_type(&self) -> &str { + GUARD_PLUGIN_TYPE_ID + } + + async fn guard_request(&self, _ctx: &RequestContext) -> Result { + self.order.lock().expect("order").push(self.phase); + Ok(GuardDecision::Allow) + } + + async fn guard_response(&self, _ctx: &ResponseContext) -> Result { + Ok(GuardDecision::Allow) + } + } + + #[async_trait] + impl TransformPlugin for Tracer { + fn id(&self) -> &str { + self.phase + } + + fn plugin_type(&self) -> &str { + TRANSFORM_PLUGIN_TYPE_ID + } + + async fn transform_request(&self, _ctx: &mut RequestContext) -> Result<(), OagwError> { + self.order.lock().expect("order").push(self.phase); + Ok(()) + } + + async fn transform_response(&self, _ctx: &mut ResponseContext) -> Result<(), OagwError> { + Ok(()) + } + + async fn transform_error(&self, _ctx: &mut ErrorContext) -> Result<(), OagwError> { + Ok(()) + } + } + + let mut chain = PluginChain::new(); + chain.push_transform( + PluginTier::Route, + 0, + "transform", + Arc::new(Tracer { + phase: "transform", + order: Arc::clone(&order), + }), + ); + chain.push_guard( + PluginTier::Upstream, + 0, + "guard", + Arc::new(Tracer { + phase: "guard", + order: Arc::clone(&order), + }), + ); + chain.push_auth( + PluginTier::Upstream, + 0, + "auth", + Arc::new(Tracer { + phase: "auth", + order: Arc::clone(&order), + }), + ); + + let mut ctx = request(); + chain.authenticate(&mut ctx).await.expect("auth"); + chain.guard_request(&ctx).await.expect("guard"); + chain.transform_request(&mut ctx).await.expect("transform"); + + assert_eq!( + *order.lock().expect("order"), + vec!["auth", "guard", "transform"] + ); +} + +#[tokio::test] +async fn guard_rejection_aborts_the_chain_with_the_mapped_error() { + let mut chain = PluginChain::new(); + chain.push_guard( + PluginTier::Upstream, + 0, + "rejecting", + Arc::new(RecordingGuard::rejecting()), + ); + chain.push_guard( + PluginTier::Upstream, + 1, + "after", + Arc::new(RecordingGuard::default()), + ); + + let ctx = request(); + let error = chain.guard_request(&ctx).await.expect_err("rejection"); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + assert!(error.detail().contains("TEST_REJECTED")); +} + +#[tokio::test] +async fn transform_failure_aborts_the_chain() { + let mut chain = PluginChain::new(); + chain.push_transform( + PluginTier::Upstream, + 0, + "failing", + Arc::new(RecordingTransform { + calls: AtomicUsize::new(0), + fail: true, + }), + ); + chain.push_transform( + PluginTier::Upstream, + 1, + "after", + Arc::new(RecordingTransform::default()), + ); + let mut ctx = request(); + assert!(chain.transform_request(&mut ctx).await.is_err()); + assert_eq!(ctx.request_id.as_deref(), Some("from-transform")); +} + +#[tokio::test] +async fn auth_failure_is_surfaced_verbatim() { + let mut chain = PluginChain::new(); + chain.push_auth( + PluginTier::Upstream, + 0, + "auth", + Arc::new(RecordingAuth { + calls: AtomicUsize::new(0), + fail: true, + }), + ); + let mut ctx = request(); + let error = chain.authenticate(&mut ctx).await.expect_err("401"); + assert_eq!(error.status(), StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn chain_response_and_error_phases_run_to_completion() { + let mut chain = PluginChain::new(); + chain.push_guard( + PluginTier::Upstream, + 0, + "guard", + Arc::new(RecordingGuard::default()), + ); + chain.push_transform( + PluginTier::Upstream, + 1, + "transform", + Arc::new(RecordingTransform::default()), + ); + let response = ResponseContext::builder().status(StatusCode::OK).build(); + chain + .guard_response(&response) + .await + .expect("guard response"); + let mut response = response; + chain + .transform_response(&mut response) + .await + .expect("transform response"); + let mut error = ErrorContext::from_error(OagwError::route_not_found("no route")); + chain + .transform_error(&mut error) + .await + .expect("transform error"); +} diff --git a/gears/system/oagw/oagw/src/domain/rate_limit.rs b/gears/system/oagw/oagw/src/domain/rate_limit.rs new file mode 100644 index 0000000..2a9316b --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/rate_limit.rs @@ -0,0 +1,1154 @@ +//! Rate limiting (ADR-0003): token bucket with dual-rate configuration, +//! optional sliding window, hierarchical inheritance and the in-memory +//! limiter registry the data plane consults. +//! +//! ## Reference algorithm +//! +//! The token bucket follows the ADR-0003 reference implementation exactly: +//! +//! ```text +//! tokens = min(tokens + elapsed * refill_rate, capacity) +//! acquire(cost) when tokens >= cost, then tokens -= cost +//! ``` +//! +//! `refill_rate` is derived from the dual-rate configuration as +//! `sustained.rate / window` and `capacity` defaults to `sustained.rate` when +//! `burst.capacity` is omitted, so a bucket starts full and absorbs a burst up +//! to its capacity before throttling to the sustained rate. +//! +//! The clock is passed in by the caller ([`Instant`]) instead of being read +//! with [`std::time::Instant::now`] inside the bucket: every decision is +//! therefore reproducible in tests and the registry stays free of hidden +//! global state. +//! +//! ## Inheritance +//! +//! [`resolve_effective_rate_limit`] walks an ancestor → descendant chain of +//! [`RateLimitConfig`] and applies the ADR-0003 inheritance table, keyed on +//! the *outer* entry's `sharing` mode: +//! +//! | Outer sharing | Inner specifies | Effective | +//! |---|---|---| +//! | `private` | any | inner only | +//! | `inherit` | none | outer's limit | +//! | `inherit` | own | `min(outer, inner)` | +//! | `enforce` | any | `min(outer, inner)` | +//! +//! "Specifies none" is expressed by the entry being absent from the chain +//! (`sustained.rate` is required by the schema, so a `rate_limit` object +//! always carries a rate). Rates are compared as tokens per second, so entries +//! declared with different windows are still ordered correctly; the window of +//! the winning entry is kept. +//! +//! ## Strategies +//! +//! * `reject` — the ADR behaviour: no tokens means `429` plus `Retry-After`. +//! * `queue` — **deviation, documented.** ADR-0003 names `queue` as an enum +//! value but defines no queue depth or wait semantics. Here a request whose +//! tokens are not yet available is *reserved* when the projected wait is at +//! most [`QUEUE_MAX_WAIT`], by debiting the bucket (which may go negative — +//! a credit for work already promised). The decision carries +//! [`RateLimitDecision::queue_wait`] and the data plane awaits it before +//! forwarding; past the bound the request is rejected like `reject`. +//! A sliding window is a log of past hits rather than a refillable balance, +//! so a reservation inside the bound can still fail to deliver: the request +//! is then answered `429`, always with `Retry-After` (ADR-0003), and nothing +//! is debited (a log cannot go into credit), so no reservation is captured. +//! A granted reservation is refunded through +//! [`RateLimiterRegistry::reservation`] → [`RateLimiterRegistry::release`] +//! when the request fails afterwards. +//! * `degrade` — **deviation, documented.** The request is always served; when +//! the bucket could not cover the cost the decision is flagged +//! ([`RateLimitDecision::degraded`]) and the response carries the +//! `X-OAGW-Degraded` marker header. Only the tokens actually available are +//! debited. +//! +//! ## Bucket lifetime +//! +//! A bucket is created on demand and lives until it is dropped, so the registry +//! needs an explicit bound: its key space is +//! `upstream × scope × client`, and the client half of that is not under the +//! operator's control (an `ip`-scoped limit over the public internet would grow +//! a bucket per address). [`RateLimiterRegistry`] therefore holds at most +//! [`MAX_BUCKETS`] buckets and evicts the least recently used eighth once the +//! bound is reached — one scan per batch, amortised over a thousand +//! insertions, and never more work than the check itself. A bucket is dropped +//! with its upstream as well: the store calls +//! [`RateLimiterRegistry::clear_upstream`] when an upstream is deleted, so a +//! recreated upstream of the same alias starts from an empty budget instead of +//! inheriting the deleted one's. + +use std::collections::VecDeque; +use std::time::{Duration, Instant}; + +use axum::http::{HeaderName, HeaderValue}; +use dashmap::DashMap; +use uuid::Uuid; + +use crate::domain::error::OagwError; +use crate::domain::model::{ + BurstConfig, RateLimitAlgorithm, RateLimitConfig, RateLimitScope, RateLimitStrategy, + RateLimitWindow, SharingMode, +}; + +/// Longest wait a `queue` strategy request is allowed to spend waiting for +/// tokens before it is rejected (see the module docs for the deviation note). +pub const QUEUE_MAX_WAIT: Duration = Duration::from_secs(1); + +/// Upper bound of live buckets in a [`RateLimiterRegistry`]. +/// +/// One bucket per `(upstream, scope, client)` key that has been seen, so an +/// `ip`-scoped limit over the public internet is the only way to reach this — +/// and 8192 addresses is far below what one process can afford. +pub const MAX_BUCKETS: usize = 8192; + +/// Fraction of [`MAX_BUCKETS`] evicted at once, least recently used first. +const EVICTION_BATCH: usize = MAX_BUCKETS / 8; + +/// Marker header emitted when a `degrade` strategy served a throttled request. +pub const DEGRADED_HEADER: &str = "x-oagw-degraded"; + +/// Value of [`DEGRADED_HEADER`]. +pub const DEGRADED_HEADER_VALUE: &str = "rate-limit"; + +/// `X-RateLimit-Limit` header name. +pub const RATE_LIMIT_HEADER: &str = "x-ratelimit-limit"; +/// `X-RateLimit-Remaining` header name. +pub const RATE_LIMIT_REMAINING_HEADER: &str = "x-ratelimit-remaining"; +/// `X-RateLimit-Reset` header name (absolute epoch seconds). +pub const RATE_LIMIT_RESET_HEADER: &str = "x-ratelimit-reset"; +/// `Retry-After` header name. +pub const RETRY_AFTER_HEADER: &str = "retry-after"; + +/// GTS error type id of the `429` problem document (ADR-0003). +pub const RATE_LIMIT_EXCEEDED_TYPE: &str = + "gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1"; + +/// Duration of one rate-limit window unit. +#[must_use] +pub fn window_duration(window: RateLimitWindow) -> Duration { + match window { + RateLimitWindow::Second => Duration::from_secs(1), + RateLimitWindow::Minute => Duration::from_secs(60), + RateLimitWindow::Hour => Duration::from_secs(60 * 60), + RateLimitWindow::Day => Duration::from_secs(60 * 60 * 24), + } +} + +// --------------------------------------------------------------------------- +// Effective configuration +// --------------------------------------------------------------------------- + +/// Fully resolved rate-limit configuration: what a limiter actually enforces. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveRateLimit { + /// Enforcement algorithm. + pub algorithm: RateLimitAlgorithm, + /// Sustained tokens replenished per [`Self::window`]. + pub sustained_rate: u64, + /// Window unit of the sustained rate. + pub window: RateLimitWindow, + /// Maximum burst (bucket capacity); defaults to `sustained_rate`. + pub capacity: u64, + /// Counter scope. + pub scope: RateLimitScope, + /// Over-limit behaviour. + pub strategy: RateLimitStrategy, + /// Whether `X-RateLimit-*` headers are emitted. + pub response_headers: bool, + /// Tokens consumed per request. + pub cost: u64, +} + +impl EffectiveRateLimit { + /// Projects one configuration entry onto the effective shape. + /// + /// `burst.capacity` falls back to `sustained.rate` (ADR-0003) and `cost` + /// falls back to `1`. + #[must_use] + pub fn from_config(config: &RateLimitConfig) -> Self { + Self { + algorithm: config.algorithm, + sustained_rate: config.sustained.rate, + window: config.sustained.window, + capacity: Self::capacity_of(config), + scope: config.scope, + strategy: config.strategy, + response_headers: config.response_headers, + cost: config.cost.max(1), + } + } + + fn capacity_of(config: &RateLimitConfig) -> u64 { + config + .burst + .as_ref() + .and_then(|burst: &BurstConfig| burst.capacity) + .unwrap_or(config.sustained.rate) + .max(1) + } + + /// Window length of this limit. + #[must_use] + pub fn window_duration(&self) -> Duration { + window_duration(self.window) + } + + /// Sustained replenishment rate in tokens per second. + /// + /// [`Duration::as_secs_f64`] cannot produce a negative value here, so the + /// division is exact for the window units the schema allows. + #[must_use] + #[allow( + clippy::cast_precision_loss, + reason = "rate magnitudes fit f64 exactly for the window units the schema allows" + )] + pub fn refill_rate(&self) -> f64 { + let window_secs = self.window_duration().as_secs_f64(); + if window_secs <= 0.0 { + return 0.0; + } + self.sustained_rate as f64 / window_secs + } + + /// Bucket capacity as `f64`. + #[must_use] + #[allow( + clippy::cast_precision_loss, + reason = "bucket capacities are bounded by configuration integers well below f64 precision" + )] + pub fn capacity_f64(&self) -> f64 { + self.capacity as f64 + } + + /// Cost of one request as `f64`. + #[must_use] + #[allow( + clippy::cast_precision_loss, + reason = "request costs are small configuration integers" + )] + pub fn cost_f64(&self) -> f64 { + self.cost as f64 + } +} + +/// Resolves the effective limit over an ancestor → descendant chain of +/// rate-limit configurations (ADR-0003 inheritance table). +/// +/// `chain` must be ordered outermost (most ancestral / upstream) first and +/// innermost (route) last. Returns `None` when no entry contributes a limit. +#[must_use] +pub fn resolve_effective_rate_limit(chain: &[&RateLimitConfig]) -> Option { + let mut effective: Option = None; + for (index, config) in chain.iter().enumerate() { + let own = EffectiveRateLimit::from_config(config); + effective = Some(match effective { + // The outermost entry always contributes its own limit. + None => own, + Some(parent) => match chain[index - 1].sharing { + SharingMode::Private => own, + SharingMode::Inherit | SharingMode::Enforce => merge_restrictive(&parent, &own), + }, + }); + } + effective +} + +/// `min(parent, child)` over the sustained rate (normalised to tokens per +/// second) and the burst capacity. The window, scope, strategy, cost and +/// header switches of the *winning* entry are kept, so the most restrictive +/// configuration decides how the counter is shaped. +#[allow( + clippy::cast_precision_loss, + reason = "rates are compared as tokens per second; magnitudes fit f64" +)] +fn merge_restrictive( + parent: &EffectiveRateLimit, + child: &EffectiveRateLimit, +) -> EffectiveRateLimit { + let parent_rate = parent.sustained_rate as f64 / parent.window_duration().as_secs_f64(); + let child_rate = child.sustained_rate as f64 / child.window_duration().as_secs_f64(); + if child_rate <= parent_rate { + child.clone() + } else { + parent.clone() + } +} + +// --------------------------------------------------------------------------- +// Token bucket (ADR-0003 reference algorithm) +// --------------------------------------------------------------------------- + +/// Token bucket: sustained replenishment with burst capacity. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct TokenBucket { + /// Tokens currently available. + pub tokens: f64, + /// Maximum burst size. + pub capacity: f64, + /// Tokens replenished per second. + pub refill_rate: f64, + /// Instant the bucket was last refilled. + pub last_refill: Instant, +} + +impl TokenBucket { + /// Creates a full bucket. + #[must_use] + pub fn new(capacity: f64, refill_rate: f64, now: Instant) -> Self { + Self { + tokens: capacity, + capacity, + refill_rate, + last_refill: now, + } + } + + /// Replenishes the bucket: `tokens = min(tokens + elapsed * rate, capacity)`. + /// + /// A clock that runs backwards (never on a monotonic [`Instant`], but + /// cheap to defend against) is treated as no elapsed time. + pub fn refill(&mut self, now: Instant) { + let elapsed = now + .checked_duration_since(self.last_refill) + .unwrap_or_default(); + self.tokens = (self.tokens + elapsed.as_secs_f64() * self.refill_rate).min(self.capacity); + self.last_refill = now; + } + + /// Acquires `cost` tokens, refilling first. + /// + /// Returns `true` and debits the bucket when at least `cost` tokens are + /// available, `false` and leaves the bucket untouched otherwise. + pub fn try_acquire(&mut self, cost: f64, now: Instant) -> bool { + self.refill(now); + if self.tokens >= cost { + self.tokens -= cost; + true + } else { + false + } + } + + /// Acquires `cost` tokens, debiting the bucket even below zero (a credit + /// for work already promised). Used by the `queue` strategy. + pub fn acquire_credit(&mut self, cost: f64, now: Instant) { + self.refill(now); + self.tokens -= cost; + if self.tokens < -self.capacity { + self.tokens = -self.capacity; + } + } + + /// Debits as many tokens as are available, never below zero. Used by the + /// `degrade` strategy. + pub fn acquire_partial(&mut self, cost: f64, now: Instant) -> bool { + self.refill(now); + if self.tokens >= cost { + self.tokens -= cost; + true + } else { + self.tokens = 0.0; + false + } + } + + /// Refunds `cost` tokens a granted `queue` reservation debited, so a + /// request that fails after the debit cannot spend the budget twice. + /// + /// The bucket is refilled first and the debit is then undone, which + /// restores exactly the balance the bucket would carry had it never been + /// debited — the refill the wait granted is not lost, and the balance is + /// clamped to the capacity so a late refund cannot mint tokens. + pub fn release(&mut self, cost: f64, now: Instant) { + self.refill(now); + self.tokens = (self.tokens + cost).min(self.capacity); + } + + /// Duration until the bucket holds at least `target` tokens. + /// + /// [`Duration::MAX`] when the bucket never replenishes. + #[must_use] + pub fn time_to_tokens(&self, target: f64, now: Instant) -> Duration { + if self.refill_rate <= 0.0 { + return Duration::MAX; + } + let elapsed = now + .checked_duration_since(self.last_refill) + .unwrap_or_default(); + let projected = (self.tokens + elapsed.as_secs_f64() * self.refill_rate).min(self.capacity); + if projected >= target { + return Duration::ZERO; + } + let deficit = target - projected; + Duration::from_secs_f64(deficit / self.refill_rate) + } + + /// Tokens available at `now` without mutating the bucket. + #[must_use] + pub fn tokens_at(&self, now: Instant) -> f64 { + let elapsed = now + .checked_duration_since(self.last_refill) + .unwrap_or_default(); + (self.tokens + elapsed.as_secs_f64() * self.refill_rate).min(self.capacity) + } +} + +// --------------------------------------------------------------------------- +// Sliding window +// --------------------------------------------------------------------------- + +/// Sliding-window counter: at most `capacity` tokens inside any `window`. +/// +/// The window is a log of `(instant, cost)` hits, pruned on every check, so +/// memory is bounded by `capacity` entries per active key. +#[derive(Debug, Clone, PartialEq)] +pub struct SlidingWindow { + capacity: f64, + window: Duration, + hits: VecDeque<(Instant, f64)>, + consumed: f64, +} + +impl SlidingWindow { + /// Creates an empty window. + #[must_use] + pub fn new(capacity: f64, window: Duration) -> Self { + Self { + capacity, + window, + hits: VecDeque::new(), + consumed: 0.0, + } + } + + /// Drops hits that left the window. + fn prune(&mut self, now: Instant) { + while let Some((at, cost)) = self.hits.front() { + if now.checked_duration_since(*at).unwrap_or_default() < self.window { + break; + } + self.consumed -= cost; + self.hits.pop_front(); + } + } + + /// Acquires `cost` tokens. + pub fn try_acquire(&mut self, cost: f64, now: Instant) -> bool { + self.prune(now); + if self.consumed + cost > self.capacity { + return false; + } + self.hits.push_back((now, cost)); + self.consumed += cost; + true + } + + /// Refunds `cost` tokens a granted `queue` reservation debited, so a + /// request that fails after the debit cannot spend the budget twice. + /// + /// The window is a log of hits rather than a balance, so the refund drops + /// the *newest* hit of that cost; nothing is removed when no hit of that + /// cost is live, so a refund never credits a balance that was not charged. + pub fn release(&mut self, cost: f64, now: Instant) { + self.prune(now); + if let Some(index) = self + .hits + .iter() + .rposition(|(_, hit_cost)| *hit_cost == cost) + && let Some((_, hit_cost)) = self.hits.remove(index) + { + self.consumed -= hit_cost; + } + } + + /// Tokens still available inside the window at `now`. + #[must_use] + pub fn remaining(&self, now: Instant) -> f64 { + (self.capacity - self.consumed_at(now)).max(0.0) + } + + fn consumed_at(&self, now: Instant) -> f64 { + self.hits + .iter() + .filter(|(at, _)| now.checked_duration_since(*at).unwrap_or_default() < self.window) + .map(|(_, cost)| cost) + .sum() + } + + /// Duration until `cost` tokens fit inside the window, or + /// [`Duration::MAX`] when they never will. + #[must_use] + pub fn time_to_tokens(&self, cost: f64, now: Instant) -> Duration { + if self.consumed + cost <= self.capacity { + return Duration::ZERO; + } + let mut accrued = 0.0; + for (at, hit_cost) in &self.hits { + if now.checked_duration_since(*at).unwrap_or_default() >= self.window { + continue; + } + accrued += hit_cost; + if self.consumed - accrued + cost <= self.capacity { + let wait = at + .checked_add(self.window) + .and_then(|expires| expires.checked_duration_since(now)); + return wait.unwrap_or(Duration::MAX); + } + } + Duration::MAX + } + + /// Duration until the window is empty again, i.e. the full capacity is + /// available. + #[must_use] + pub fn time_to_reset(&self, now: Instant) -> Duration { + self.hits + .front() + .and_then(|(at, _)| { + at.checked_add(self.window) + .and_then(|expires| expires.checked_duration_since(now)) + }) + .unwrap_or(Duration::ZERO) + } +} + +// --------------------------------------------------------------------------- +// Decision +// --------------------------------------------------------------------------- + +/// Outcome of one rate-limit check. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RateLimitDecision { + /// `true` when the request may proceed. + pub allowed: bool, + /// Configured limit (`X-RateLimit-Limit`). + pub limit: u64, + /// Tokens left after this request (`X-RateLimit-Remaining`). + pub remaining: u64, + /// Absolute epoch second at which the budget resets + /// (`X-RateLimit-Reset`). + pub reset_epoch_secs: u64, + /// `Retry-After` seconds for a rejected request. + pub retry_after_seconds: Option, + /// Bounded wait a `queue` strategy granted; the data plane awaits it. + pub queue_wait: Option, + /// `true` when a `degrade` strategy served a throttled request. + pub degraded: bool, + /// Configured strategy. + pub strategy: RateLimitStrategy, + /// Whether `X-RateLimit-*` headers should be emitted. + pub emit_headers: bool, +} + +impl RateLimitDecision { + /// `true` when the request may proceed (including degraded service). + #[must_use] + pub const fn is_allowed(&self) -> bool { + self.allowed + } + + /// `true` when this decision must be answered with `429`. + #[must_use] + pub const fn is_limited(&self) -> bool { + !self.allowed + } + + /// Response headers this decision contributes. + /// + /// Empty when `response_headers` is disabled in the configuration, so the + /// data plane can append the result unconditionally. + #[must_use] + pub fn headers(&self) -> Vec<(HeaderName, HeaderValue)> { + if !self.emit_headers { + return Vec::new(); + } + let mut headers = vec![ + header(RATE_LIMIT_HEADER, &self.limit.to_string()), + header(RATE_LIMIT_REMAINING_HEADER, &self.remaining.to_string()), + header(RATE_LIMIT_RESET_HEADER, &self.reset_epoch_secs.to_string()), + ]; + if let Some(retry_after) = self.retry_after_seconds { + headers.push(header(RETRY_AFTER_HEADER, &retry_after.to_string())); + } + if self.degraded { + headers.push(header(DEGRADED_HEADER, DEGRADED_HEADER_VALUE)); + } + headers + } + + /// The `429` problem document for a rejected request (ADR-0003), carrying + /// the `Retry-After` hint in the problem `context`. + #[must_use] + pub fn into_error(self) -> OagwError { + let mut error = OagwError::rate_limit_exceeded(format!( + "rate limit of {} requests per {} exceeded", + self.limit, + self.strategy_hint() + )); + if let Some(retry_after) = self.retry_after_seconds { + error = error.with_retry_after_seconds(retry_after); + } + error + } + + fn strategy_hint(&self) -> &'static str { + match self.strategy { + RateLimitStrategy::Reject => "the configured window", + RateLimitStrategy::Queue => "the configured window (queued)", + RateLimitStrategy::Degrade => "the configured window (degraded)", + } + } +} + +/// Builds a single response header, dropping values that are not valid ASCII +/// header values (never the case for the numbers rendered here). +fn header(name: &'static str, value: &str) -> (HeaderName, HeaderValue) { + let header_name = HeaderName::from_static(name); + let header_value = + HeaderValue::from_str(value).unwrap_or_else(|_| HeaderValue::from_static("0")); + (header_name, header_value) +} + +// --------------------------------------------------------------------------- +// Scope + registry +// --------------------------------------------------------------------------- + +/// Scope identifiers a caller supplies for one rate-limit check. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RateLimitScopeValues { + /// Tenant id of the authenticated subject. + pub tenant_id: Option, + /// Authenticated subject id. + pub subject_id: Option, + /// Client address for the `ip` scope: the peer socket address when the + /// transport supplies one, otherwise the last `X-Forwarded-For` entry (the + /// data plane documents why the header is only ever a fallback). + pub peer_ip: Option, + /// Matched route id. + pub route_id: Option, +} + +impl RateLimitScopeValues { + /// The value the configured scope resolves to, falling back to the tenant + /// id and then to a shared bucket so a missing scope value can never + /// widen the limit. + #[must_use] + pub fn identifier(&self, scope: RateLimitScope) -> String { + let candidate = match scope { + RateLimitScope::Global => None, + RateLimitScope::Tenant => self.tenant_id.clone(), + RateLimitScope::User => self.subject_id.clone().or_else(|| self.tenant_id.clone()), + RateLimitScope::Ip => self.peer_ip.clone().or_else(|| self.tenant_id.clone()), + RateLimitScope::Route => self.route_id.clone().or_else(|| self.tenant_id.clone()), + }; + candidate.unwrap_or_else(|| "unscoped".to_owned()) + } +} + +/// One limiter instance held by the registry. +#[derive(Debug, Clone)] +enum Limiter { + Bucket(TokenBucket), + Window(SlidingWindow), +} + +impl Limiter { + fn matches(&self, effective: &EffectiveRateLimit) -> bool { + match self { + Self::Bucket(bucket) => { + bucket.capacity == effective.capacity_f64() + && bucket.refill_rate == effective.refill_rate() + } + Self::Window(window) => window.capacity == effective.capacity_f64(), + } + } + + /// `true` when this limiter is the algorithm `effective` configures, which + /// a refund has to confirm before it credits a balance: a configuration + /// change that only keeps the capacity would otherwise hand the tokens of + /// the old bucket to the new one. + fn holds(&self, algorithm: RateLimitAlgorithm) -> bool { + matches!( + (self, algorithm), + (Self::Bucket(_), RateLimitAlgorithm::TokenBucket) + | (Self::Window(_), RateLimitAlgorithm::SlidingWindow) + ) + } + + fn release(&mut self, cost: f64, now: Instant) { + match self { + Self::Bucket(bucket) => bucket.release(cost, now), + Self::Window(window) => window.release(cost, now), + } + } +} + +/// A limiter together with the instant its bucket was last consulted, which is +/// what the eviction of [`MAX_BUCKETS`] orders on. +#[derive(Debug, Clone)] +struct Entry { + limiter: Limiter, + last_used: Instant, +} + +/// Per-(upstream, scope) limiter registry. +/// +/// Counters are process-local (ADR-0003 MVP: "Per-instance rate limiting in +/// Data Plane"), keyed by `ratelimit:{upstream_id}:{scope}:{identifier}` so a +/// configuration change to one upstream never touches another's buckets. The +/// registry holds at most [`MAX_BUCKETS`] buckets and drops the least recently +/// used eighth beyond that, so a bucket-per-client key space cannot grow the +/// registry without bound. +#[derive(Debug, Default)] +pub struct RateLimiterRegistry { + limiters: DashMap, +} + +impl RateLimiterRegistry { + /// Creates an empty registry. + #[must_use] + pub fn new() -> Self { + Self { + limiters: DashMap::new(), + } + } + + /// Number of live limiters. + #[must_use] + pub fn len(&self) -> usize { + self.limiters.len() + } + + /// `true` when no limiter has been created yet. + #[must_use] + pub fn is_empty(&self) -> bool { + self.limiters.is_empty() + } + + /// Drops every limiter, e.g. when the upstream it belongs to is deleted. + pub fn clear(&self) { + self.limiters.clear(); + } + + /// Drops the limiters of one upstream. + pub fn clear_upstream(&self, upstream_id: Uuid) { + let prefix = format!("ratelimit:{upstream_id}:"); + self.limiters.retain(|key, _| !key.starts_with(&prefix)); + } + + /// Counter key of one `(upstream, scope)` pair. + /// + /// Two decisions live in this key. First, the client half of an `ip` scope + /// is the *peer socket address* whenever the transport supplies one, and + /// only falls back to the last `X-Forwarded-For` entry — a client-controlled + /// header must never mint a bucket of its own, or the limit is bypassed by + /// rotating the header and a caller can spend another caller's budget (the + /// data plane derives the value in its `client_identity` helper and the + /// trade-off is documented there). Second, the key space is not under the + /// operator's control, which is why [`check`] bounds the number of buckets + /// it creates. + #[must_use] + pub fn scope_key( + &self, + upstream_id: Uuid, + scope: RateLimitScope, + values: &RateLimitScopeValues, + ) -> String { + format!( + "ratelimit:{upstream_id}:{scope:?}:{}", + values.identifier(scope) + ) + } + + /// Runs one check and returns the decision (and consumes the tokens). + /// + /// Creating a bucket may evict: the registry is bounded by [`MAX_BUCKETS`], + /// and the least recently used eighth is dropped wholesale once the bound is + /// reached. An evicted bucket starts full again, which is why eviction only + /// ever happens in a batch far larger than the steady-state request rate of + /// one key. + #[must_use] + pub fn check( + &self, + upstream_id: Uuid, + effective: &EffectiveRateLimit, + values: &RateLimitScopeValues, + now: Instant, + epoch_now: u64, + ) -> RateLimitDecision { + let key = self.scope_key(upstream_id, effective.scope, values); + let decision = { + let mut entry = self.limiters.entry(key).or_insert_with(|| Entry { + limiter: effective.clone().into_limiter(now), + last_used: now, + }); + entry.value_mut().last_used = now; + if !entry.value().limiter.matches(effective) { + entry.value_mut().limiter = effective.clone().into_limiter(now); + } + entry + .value_mut() + .limiter + .evaluate(effective, now, epoch_now) + }; + self.evict(); + decision + } + + /// Captures the refund handle of a granted `queue` reservation. + /// + /// `None` unless the decision actually debited the bucket *and* queued: + /// a request admitted outright holds no reservation, and a `429` holds no + /// debit. The second half matters for a sliding window, whose `429` from a + /// full queue carries a `queue_wait` (the projected wait) without having + /// recorded a hit — a handle for it would refund a hit some *other* request + /// paid for. + #[must_use] + pub fn reservation( + &self, + upstream_id: Uuid, + effective: &EffectiveRateLimit, + values: &RateLimitScopeValues, + decision: &RateLimitDecision, + ) -> Option { + if !decision.allowed { + return None; + } + decision.queue_wait?; + Some(RateLimitReservation { + key: self.scope_key(upstream_id, effective.scope, values), + effective: effective.clone(), + cost: effective.cost_f64(), + }) + } + + /// Refunds a [`RateLimitReservation`], putting the debited tokens back. + /// + /// A bucket that no longer exists (evicted) or that no longer matches the + /// configuration the reservation was granted under (rebuilt by a + /// configuration change) holds no debit, so it is left alone — a refund + /// must never credit a balance that was not charged. + pub fn release(&self, reservation: RateLimitReservation, now: Instant) { + let Some(mut entry) = self.limiters.get_mut(&reservation.key) else { + return; + }; + entry.value_mut().last_used = now; + if entry.value().limiter.holds(reservation.effective.algorithm) + && entry.value().limiter.matches(&reservation.effective) + { + entry.value_mut().limiter.release(reservation.cost, now); + } + } + + /// Drops the least recently used buckets when the registry reached its + /// bound; a no-op below [`MAX_BUCKETS`]. + fn evict(&self) { + if self.limiters.len() < MAX_BUCKETS { + return; + } + let mut candidates: Vec<(String, Instant)> = self + .limiters + .iter() + .map(|entry| (entry.key().clone(), entry.value().last_used)) + .collect(); + candidates.sort_by_key(|(_, last_used)| *last_used); + for (key, _) in candidates.into_iter().take(EVICTION_BATCH) { + self.limiters.remove(&key); + } + tracing::debug!( + target: "oagw.rate_limit", + buckets = self.limiters.len(), + "rate-limit registry reached its bound and evicted the least recently used buckets" + ); + } +} + +/// The budget a granted `queue` reservation holds, ready to be refunded. +/// +/// A `queue` strategy debits the bucket up front (the token bucket even below +/// zero) and the data plane spends the granted wait before forwarding. When the +/// request then fails — an oversized body, a plugin rejection, an unreachable +/// upstream — the debit must not be lost, or every failed request silently +/// consumes budget no request was served for. +/// +/// [`RateLimiterRegistry::reservation`] captures the handle and +/// [`RateLimiterRegistry::release`] refunds it; dropping the value without +/// releasing simply leaves the debit in place. +#[derive(Debug, Clone)] +pub struct RateLimitReservation { + /// Resolved limiter key (`ratelimit:{upstream_id}:{scope}:{identifier}`), + /// so the refund lands on exactly the bucket the debit came from. + key: String, + /// Configuration the reservation was granted under. + effective: EffectiveRateLimit, + /// Tokens to put back. + cost: f64, +} + +impl EffectiveRateLimit { + fn into_limiter(self, now: Instant) -> Limiter { + match self.algorithm { + RateLimitAlgorithm::TokenBucket => Limiter::Bucket(TokenBucket::new( + self.capacity_f64(), + self.refill_rate(), + now, + )), + RateLimitAlgorithm::SlidingWindow => Limiter::Window(SlidingWindow::new( + self.capacity_f64(), + self.window_duration(), + )), + } + } +} + +impl Limiter { + /// Applies the configured strategy and renders the decision. + fn evaluate( + &mut self, + effective: &EffectiveRateLimit, + now: Instant, + epoch_now: u64, + ) -> RateLimitDecision { + match &mut *self { + Self::Bucket(bucket) => bucket.evaluate(effective, now, epoch_now), + Self::Window(window) => window.evaluate(effective, now, epoch_now), + } + } +} + +/// Rounds a duration up to whole seconds, never below one second: a client +/// told to retry in `0s` would immediately hammer the gateway again. +#[must_use] +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "the value is clamped non-negative before truncating to whole seconds" +)] +fn retry_after_seconds(wait: Duration) -> u64 { + let secs = wait.as_secs_f64().ceil(); + if secs < 1.0 { + 1 + } else if secs > f64::from(u32::MAX) { + u64::from(u32::MAX) + } else { + secs as u64 + } +} + +/// Renders an absolute epoch second for a relative duration. +#[must_use] +#[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "epoch seconds plus a bounded duration always fit u64" +)] +fn reset_epoch(epoch_now: u64, wait: Duration) -> u64 { + epoch_now.saturating_add(retry_after_seconds(wait)) +} + +impl TokenBucket { + fn evaluate( + &mut self, + effective: &EffectiveRateLimit, + now: Instant, + epoch_now: u64, + ) -> RateLimitDecision { + let cost = effective.cost_f64(); + let limit = effective.capacity.max(effective.sustained_rate); + let reset = reset_epoch( + epoch_now, + self.time_to_tokens(effective.capacity_f64(), now), + ); + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "remaining quota is reported as whole tokens and clamped non-negative" + )] + let shape = |allowed: bool, + remaining: f64, + retry_after: Option, + queue_wait: Option, + degraded: bool| { + RateLimitDecision { + allowed, + limit, + remaining: remaining.max(0.0) as u64, + reset_epoch_secs: reset, + retry_after_seconds: retry_after, + queue_wait, + degraded, + strategy: effective.strategy, + emit_headers: effective.response_headers, + } + }; + + match effective.strategy { + RateLimitStrategy::Reject => { + if self.try_acquire(cost, now) { + shape(true, self.tokens, None, None, false) + } else { + let wait = self.time_to_tokens(cost, now); + shape( + false, + self.tokens, + Some(retry_after_seconds(wait)), + None, + false, + ) + } + } + RateLimitStrategy::Queue => { + let wait = self.time_to_tokens(cost, now); + if wait <= QUEUE_MAX_WAIT { + self.acquire_credit(cost, now); + let queued_wait = if wait.is_zero() { None } else { Some(wait) }; + shape(true, self.tokens.max(0.0), None, queued_wait, false) + } else { + shape( + false, + self.tokens.max(0.0), + Some(retry_after_seconds(wait)), + None, + false, + ) + } + } + RateLimitStrategy::Degrade => { + // Degraded service never rejects: the request is served and + // flagged instead (see the module docs). + let served = self.acquire_partial(cost, now); + shape(true, self.tokens, None, None, !served) + } + } + } +} + +impl SlidingWindow { + fn evaluate( + &mut self, + effective: &EffectiveRateLimit, + now: Instant, + epoch_now: u64, + ) -> RateLimitDecision { + let cost = effective.cost_f64(); + let limit = effective.capacity.max(effective.sustained_rate); + let reset = reset_epoch(epoch_now, self.time_to_reset(now)); + + match effective.strategy { + RateLimitStrategy::Reject => { + if self.try_acquire(cost, now) { + self.decision( + true, + limit, + self.remaining(now), + reset, + None, + None, + false, + effective, + ) + } else { + let wait = self.time_to_tokens(cost, now); + self.decision( + false, + limit, + self.remaining(now), + reset, + Some(retry_after_seconds(wait)), + None, + false, + effective, + ) + } + } + RateLimitStrategy::Queue => { + let wait = self.time_to_tokens(cost, now); + if wait <= QUEUE_MAX_WAIT { + let granted = self.try_acquire(cost, now); + // The window is at capacity, so the reservation this + // strategy grants cannot always be delivered: a request + // that is still denied must carry the ADR-0003 + // `Retry-After` like any other `429`, not only a queue + // wait. + let retry_after = if granted { + None + } else { + Some(retry_after_seconds(wait)) + }; + self.decision( + granted, + limit, + self.remaining(now), + reset, + retry_after, + if wait.is_zero() { None } else { Some(wait) }, + false, + effective, + ) + } else { + self.decision( + false, + limit, + self.remaining(now), + reset, + Some(retry_after_seconds(wait)), + None, + false, + effective, + ) + } + } + RateLimitStrategy::Degrade => { + let served = self.try_acquire(cost, now); + self.decision( + true, + limit, + self.remaining(now), + reset, + None, + None, + !served, + effective, + ) + } + } + } + + #[allow( + clippy::too_many_arguments, + reason = "the sliding window decision carries every ADR-0003 output field" + )] + fn decision( + &self, + allowed: bool, + limit: u64, + remaining: f64, + reset_epoch_secs: u64, + retry_after_seconds: Option, + queue_wait: Option, + degraded: bool, + effective: &EffectiveRateLimit, + ) -> RateLimitDecision { + RateLimitDecision { + allowed, + limit, + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "remaining quota is reported as whole tokens and clamped non-negative" + )] + remaining: remaining.max(0.0) as u64, + reset_epoch_secs, + retry_after_seconds, + queue_wait, + degraded, + strategy: effective.strategy, + emit_headers: effective.response_headers, + } + } +} + +#[cfg(test)] +#[path = "rate_limit_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/rate_limit_tests.rs b/gears/system/oagw/oagw/src/domain/rate_limit_tests.rs new file mode 100644 index 0000000..08b25b1 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/rate_limit_tests.rs @@ -0,0 +1,1014 @@ +//! Tests for [`crate::domain::rate_limit`]. + +use std::time::{Duration, Instant}; + +use axum::http::HeaderValue; +use uuid::Uuid; + +use super::{ + DEGRADED_HEADER, DEGRADED_HEADER_VALUE, EffectiveRateLimit, MAX_BUCKETS, QUEUE_MAX_WAIT, + RATE_LIMIT_EXCEEDED_TYPE, RATE_LIMIT_HEADER, RATE_LIMIT_REMAINING_HEADER, + RATE_LIMIT_RESET_HEADER, RETRY_AFTER_HEADER, RateLimitScopeValues, RateLimiterRegistry, + SlidingWindow, TokenBucket, resolve_effective_rate_limit, window_duration, +}; +use crate::domain::model::{ + BurstConfig, RateLimitAlgorithm, RateLimitConfig, RateLimitScope, RateLimitStrategy, + RateLimitWindow, SharingMode, SustainedRateConfig, +}; + +const UPSTREAM: Uuid = Uuid::from_u128(0x77); + +fn start() -> Instant { + // A fixed reference point; every test works with relative offsets. + Instant::now() +} + +fn sustained(rate: u64, window: RateLimitWindow) -> SustainedRateConfig { + SustainedRateConfig { rate, window } +} + +fn config( + rate: u64, + window: RateLimitWindow, + capacity: Option, + sharing: SharingMode, +) -> RateLimitConfig { + RateLimitConfig { + sharing, + algorithm: RateLimitAlgorithm::TokenBucket, + sustained: sustained(rate, window), + burst: capacity.map(|value| BurstConfig { + capacity: Some(value), + }), + scope: RateLimitScope::Tenant, + strategy: RateLimitStrategy::Reject, + response_headers: true, + cost: 1, + } +} + +fn effective(rate: u64, window: RateLimitWindow, capacity: Option) -> EffectiveRateLimit { + EffectiveRateLimit::from_config(&config(rate, window, capacity, SharingMode::Private)) +} + +fn values( + tenant: &str, + subject: Option<&str>, + peer: Option<&str>, + route: Option<&str>, +) -> RateLimitScopeValues { + RateLimitScopeValues { + tenant_id: Some(tenant.to_owned()), + subject_id: subject.map(str::to_owned), + peer_ip: peer.map(str::to_owned), + route_id: route.map(str::to_owned), + } +} + +fn header_value(headers: &[(axum::http::HeaderName, HeaderValue)], name: &str) -> Option { + headers + .iter() + .find(|(candidate, _)| candidate.as_str() == name) + .map(|(_, value)| value.to_str().unwrap_or_default().to_owned()) +} + +// --------------------------------------------------------------------------- +// Window mapping +// --------------------------------------------------------------------------- + +#[test] +fn window_durations_follow_the_schema() { + assert_eq!( + window_duration(RateLimitWindow::Second), + Duration::from_secs(1) + ); + assert_eq!( + window_duration(RateLimitWindow::Minute), + Duration::from_secs(60) + ); + assert_eq!( + window_duration(RateLimitWindow::Hour), + Duration::from_secs(3_600) + ); + assert_eq!( + window_duration(RateLimitWindow::Day), + Duration::from_secs(86_400) + ); +} + +#[test] +fn effective_config_projects_the_schema_fields() { + let mut config = config(600, RateLimitWindow::Minute, None, SharingMode::Private); + config.strategy = RateLimitStrategy::Queue; + config.cost = 3; + config.scope = RateLimitScope::User; + config.response_headers = false; + let effective = EffectiveRateLimit::from_config(&config); + assert_eq!(effective.sustained_rate, 600); + assert_eq!(effective.window, RateLimitWindow::Minute); + assert_eq!( + effective.capacity, 600, + "capacity defaults to the sustained rate" + ); + assert_eq!(effective.strategy, RateLimitStrategy::Queue); + assert_eq!(effective.cost, 3); + assert_eq!(effective.scope, RateLimitScope::User); + assert!(!effective.response_headers); + assert!( + (effective.refill_rate() - 10.0).abs() < 1e-9, + "600/min is 10/s" + ); +} + +#[test] +fn effective_config_honours_an_explicit_capacity_and_a_zero_cost() { + let mut config = config( + 100, + RateLimitWindow::Second, + Some(1_000), + SharingMode::Private, + ); + config.cost = 0; + let effective = EffectiveRateLimit::from_config(&config); + assert_eq!(effective.capacity, 1_000); + assert_eq!(effective.cost, 1, "a zero cost is clamped to one token"); +} + +// --------------------------------------------------------------------------- +// Inheritance +// --------------------------------------------------------------------------- + +#[test] +fn no_entry_yields_none() { + let chain: Vec<&RateLimitConfig> = Vec::new(); + assert!(resolve_effective_rate_limit(&chain).is_none()); +} + +#[test] +fn a_single_entry_is_used_verbatim() { + let outer = config(50, RateLimitWindow::Second, Some(80), SharingMode::Private); + let effective = resolve_effective_rate_limit(&[&outer]).expect("effective"); + assert_eq!(effective.sustained_rate, 50); + assert_eq!(effective.capacity, 80); +} + +#[test] +fn private_parent_hides_the_limit_from_the_child() { + let parent = config(1_000, RateLimitWindow::Second, None, SharingMode::Private); + let child = config(10, RateLimitWindow::Minute, None, SharingMode::Inherit); + let effective = resolve_effective_rate_limit(&[&parent, &child]).expect("effective"); + assert_eq!(effective.sustained_rate, 10); + assert_eq!(effective.window, RateLimitWindow::Minute); +} + +#[test] +fn inherit_with_a_child_limit_takes_the_minimum() { + let parent = config(100, RateLimitWindow::Second, None, SharingMode::Inherit); + let child = config(1_000, RateLimitWindow::Second, None, SharingMode::Private); + let effective = resolve_effective_rate_limit(&[&parent, &child]).expect("effective"); + assert_eq!(effective.sustained_rate, 100); +} + +#[test] +fn inherit_compares_rates_across_different_windows() { + // 100/minute (1.67/s) is more restrictive than 10/second. + let parent = config(100, RateLimitWindow::Minute, None, SharingMode::Inherit); + let child = config(10, RateLimitWindow::Second, None, SharingMode::Private); + let effective = resolve_effective_rate_limit(&[&parent, &child]).expect("effective"); + assert_eq!(effective.sustained_rate, 100); + assert_eq!(effective.window, RateLimitWindow::Minute); +} + +#[test] +fn enforce_always_merges_the_limits() { + let parent = config(500, RateLimitWindow::Minute, None, SharingMode::Enforce); + let child = config(100, RateLimitWindow::Minute, None, SharingMode::Private); + let effective = resolve_effective_rate_limit(&[&parent, &child]).expect("effective"); + assert_eq!(effective.sustained_rate, 100); + + let child = config(1_000, RateLimitWindow::Minute, None, SharingMode::Private); + let effective = resolve_effective_rate_limit(&[&parent, &child]).expect("effective"); + assert_eq!(effective.sustained_rate, 500); +} + +#[test] +fn three_level_chain_uses_the_most_restrictive_limit() { + let root = config(1_000, RateLimitWindow::Minute, None, SharingMode::Inherit); + let middle = config(50, RateLimitWindow::Second, None, SharingMode::Inherit); + let leaf = config(10, RateLimitWindow::Second, None, SharingMode::Private); + let effective = resolve_effective_rate_limit(&[&root, &middle, &leaf]).expect("effective"); + assert_eq!(effective.sustained_rate, 10); +} + +#[test] +fn inheritance_keeps_the_winning_capacity() { + let parent = config( + 100, + RateLimitWindow::Second, + Some(200), + SharingMode::Inherit, + ); + let child = config( + 10, + RateLimitWindow::Second, + Some(1_000), + SharingMode::Private, + ); + let effective = resolve_effective_rate_limit(&[&parent, &child]).expect("effective"); + assert_eq!(effective.sustained_rate, 10); + assert_eq!( + effective.capacity, 1_000, + "the winning entry shapes the bucket" + ); +} + +// --------------------------------------------------------------------------- +// Token bucket +// --------------------------------------------------------------------------- + +#[test] +fn bucket_starts_full_and_refills_at_the_configured_rate() { + let now = start(); + let mut bucket = TokenBucket::new(10.0, 2.0, now); + assert_eq!(bucket.tokens, 10.0); + bucket.refill(now + Duration::from_secs(1)); + assert_eq!( + bucket.tokens, 10.0, + "a full bucket cannot exceed its capacity" + ); + assert_eq!(bucket.last_refill, now + Duration::from_secs(1)); +} + +#[test] +fn bucket_refill_follows_the_reference_formula() { + let now = start(); + let mut bucket = TokenBucket::new(10.0, 2.0, now); + bucket.tokens = 0.0; + bucket.refill(now + Duration::from_millis(1_500)); + assert!( + (bucket.tokens - 3.0).abs() < 1e-9, + "1.5s at 2/s is 3 tokens" + ); +} + +#[test] +fn try_acquire_debits_and_refuses_when_empty() { + let now = start(); + let mut bucket = TokenBucket::new(2.0, 0.0, now); + assert!(bucket.try_acquire(1.0, now)); + assert!(bucket.try_acquire(1.0, now)); + assert!(!bucket.try_acquire(1.0, now), "capacity exhausted"); + assert_eq!(bucket.tokens, 0.0); +} + +#[test] +fn try_acquire_refills_before_debiting() { + let now = start(); + let mut bucket = TokenBucket::new(1.0, 1.0, now); + assert!(bucket.try_acquire(1.0, now)); + assert!(!bucket.try_acquire(1.0, now + Duration::from_millis(500))); + assert!( + bucket.try_acquire(1.0, now + Duration::from_secs(1)), + "1 token refilled" + ); + assert!((bucket.tokens_at(now + Duration::from_secs(1)) - 0.0).abs() < 1e-9); +} + +#[test] +fn try_acquire_leaves_the_bucket_untouched_on_rejection() { + let now = start(); + let mut bucket = TokenBucket::new(1.0, 0.0, now); + assert!(bucket.try_acquire(1.0, now)); + assert!(!bucket.try_acquire(1.0, now)); + assert_eq!(bucket.tokens, 0.0); +} + +#[test] +fn burst_capacity_absorbs_a_burst_then_throttles() { + let now = start(); + // 10 tokens burst, 1 token per second. + let mut bucket = TokenBucket::new(10.0, 1.0, now); + for _ in 0..10 { + assert!(bucket.try_acquire(1.0, now), "the burst must be absorbed"); + } + assert!(!bucket.try_acquire(1.0, now)); + assert!( + bucket.try_acquire(1.0, now + Duration::from_secs(1)), + "one token refilled" + ); +} + +#[test] +fn time_to_tokens_is_the_deficit_over_the_rate() { + let now = start(); + let mut bucket = TokenBucket::new(10.0, 2.0, now); + bucket.tokens = 0.0; + assert_eq!(bucket.time_to_tokens(1.0, now), Duration::from_millis(500)); + assert_eq!(bucket.time_to_tokens(10.0, now), Duration::from_secs(5)); +} + +#[test] +fn time_to_tokens_is_zero_when_the_target_is_already_available() { + let now = start(); + let bucket = TokenBucket::new(10.0, 2.0, now); + assert_eq!(bucket.time_to_tokens(5.0, now), Duration::ZERO); +} + +#[test] +fn a_stalled_bucket_never_replenishes() { + let now = start(); + let mut bucket = TokenBucket::new(1.0, 0.0, now); + bucket.tokens = 0.0; + assert_eq!(bucket.time_to_tokens(1.0, now), Duration::MAX); +} + +// --------------------------------------------------------------------------- +// Sliding window +// --------------------------------------------------------------------------- + +#[test] +fn sliding_window_counts_hits_inside_the_window() { + let now = start(); + let mut window = SlidingWindow::new(3.0, Duration::from_secs(10)); + assert!(window.try_acquire(1.0, now)); + assert!(window.try_acquire(1.0, now)); + assert!(window.try_acquire(1.0, now)); + assert!(!window.try_acquire(1.0, now)); + assert_eq!(window.remaining(now), 0.0); +} + +#[test] +fn sliding_window_frees_capacity_as_hits_expire() { + let now = start(); + let mut window = SlidingWindow::new(3.0, Duration::from_secs(10)); + for offset in 0..3 { + assert!(window.try_acquire(1.0, now + Duration::from_secs(offset))); + } + assert_eq!(window.remaining(now + Duration::from_secs(2)), 0.0); + assert_eq!( + window.remaining(now + Duration::from_secs(10)), + 1.0, + "the first hit expired" + ); +} + +#[test] +fn sliding_window_time_to_tokens_reports_the_expiry() { + let now = start(); + let mut window = SlidingWindow::new(2.0, Duration::from_secs(10)); + assert!(window.try_acquire(2.0, now)); + assert_eq!( + window.time_to_tokens(1.0, now + Duration::from_secs(1)), + Duration::from_secs(9) + ); +} + +#[test] +fn sliding_window_time_to_reset_waits_for_the_oldest_hit() { + let now = start(); + let mut window = SlidingWindow::new(3.0, Duration::from_secs(10)); + assert!(window.try_acquire(1.0, now)); + assert!(window.try_acquire(1.0, now + Duration::from_secs(4))); + assert_eq!( + window.time_to_reset(now + Duration::from_secs(4)), + Duration::from_secs(6) + ); + assert_eq!( + window.time_to_reset(now + Duration::from_secs(40)), + Duration::ZERO + ); +} + +// --------------------------------------------------------------------------- +// Registry and strategies +// --------------------------------------------------------------------------- + +#[test] +fn scope_identifiers_fall_back_to_the_tenant() { + let scoped = values( + "tenant-a", + Some("subject-a"), + Some("10.0.0.1"), + Some("route-a"), + ); + assert_eq!(scoped.identifier(RateLimitScope::Tenant), "tenant-a"); + assert_eq!(scoped.identifier(RateLimitScope::User), "subject-a"); + assert_eq!(scoped.identifier(RateLimitScope::Ip), "10.0.0.1"); + assert_eq!(scoped.identifier(RateLimitScope::Route), "route-a"); + assert_eq!(scoped.identifier(RateLimitScope::Global), "unscoped"); +} + +#[test] +fn missing_scope_values_never_widen_the_limit() { + let scoped = values("tenant-a", None, None, None); + assert_eq!(scoped.identifier(RateLimitScope::User), "tenant-a"); + assert_eq!(scoped.identifier(RateLimitScope::Ip), "tenant-a"); + assert_eq!(scoped.identifier(RateLimitScope::Route), "tenant-a"); +} + +#[test] +fn registry_keys_are_per_upstream_and_per_scope() { + let registry = RateLimiterRegistry::new(); + let effective = effective(10, RateLimitWindow::Second, Some(10)); + let left = registry.scope_key( + UPSTREAM, + RateLimitScope::Tenant, + &values("t1", None, None, None), + ); + let right = registry.scope_key( + UPSTREAM, + RateLimitScope::User, + &values("t1", Some("s1"), None, None), + ); + assert_ne!(left, right); + let other = registry.scope_key( + Uuid::from_u128(0x78), + RateLimitScope::Tenant, + &values("t1", None, None, None), + ); + assert_ne!(left, other); + + let now = start(); + let epoch = 1_000; + let first = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + epoch, + ); + let second = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + epoch, + ); + assert!(first.is_allowed()); + assert!(second.is_allowed()); + assert_eq!(registry.len(), 1); + assert!(!registry.is_empty()); +} + +#[test] +fn registry_tracks_one_counter_per_tenant() { + let registry = RateLimiterRegistry::new(); + let effective = effective(1, RateLimitWindow::Second, Some(1)); + let now = start(); + let epoch = 1_000; + let first = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + epoch, + ); + let second = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + epoch, + ); + let other = registry.check( + UPSTREAM, + &effective, + &values("t2", None, None, None), + now, + epoch, + ); + assert!(first.is_allowed()); + assert!(second.is_limited()); + assert!(other.is_allowed()); + assert_eq!(registry.len(), 2); +} + +#[test] +fn reject_strategy_emits_the_adr_headers_and_retry_after() { + let registry = RateLimiterRegistry::new(); + let effective = effective(1, RateLimitWindow::Second, Some(1)); + let now = start(); + let epoch = 1_000; + let granted = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + epoch, + ); + assert!(granted.is_allowed()); + let headers = granted.headers(); + assert_eq!( + header_value(&headers, RATE_LIMIT_HEADER).as_deref(), + Some("1") + ); + assert_eq!( + header_value(&headers, RATE_LIMIT_REMAINING_HEADER).as_deref(), + Some("0") + ); + // The single token is spent at epoch 1000 and refills one second later. + assert_eq!( + header_value(&headers, RATE_LIMIT_RESET_HEADER).as_deref(), + Some("1001") + ); + assert_eq!(header_value(&headers, RETRY_AFTER_HEADER), None); + + let rejected = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + epoch, + ); + assert!(rejected.is_limited()); + assert_eq!(rejected.retry_after_seconds, Some(1)); + let headers = rejected.headers(); + assert_eq!( + header_value(&headers, RETRY_AFTER_HEADER).as_deref(), + Some("1") + ); + + let error = rejected.into_error(); + assert_eq!(error.status(), axum::http::StatusCode::TOO_MANY_REQUESTS); + assert_eq!(error.problem_body().r#type, RATE_LIMIT_EXCEEDED_TYPE); + assert_eq!(error.problem_body().context.retry_after_seconds, Some(1)); +} + +#[test] +fn headers_are_suppressed_when_the_configuration_disables_them() { + let mut config = config(1, RateLimitWindow::Second, Some(1), SharingMode::Private); + config.response_headers = false; + let effective = EffectiveRateLimit::from_config(&config); + let registry = RateLimiterRegistry::new(); + let decision = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + start(), + 1_000, + ); + assert!(decision.is_allowed()); + assert!(decision.headers().is_empty()); +} + +#[test] +fn queue_strategy_grants_a_short_wait_within_the_bound() { + let registry = RateLimiterRegistry::new(); + let mut config = config(2, RateLimitWindow::Second, Some(1), SharingMode::Private); + config.strategy = RateLimitStrategy::Queue; + let effective = EffectiveRateLimit::from_config(&config); + let now = start(); + let first = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + 1_000, + ); + assert!(first.is_allowed()); + assert_eq!(first.queue_wait, None); + + // 500 ms of wait is within QUEUE_MAX_WAIT, so the request is queued. + let second = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + 1_000, + ); + assert!(second.is_allowed()); + assert_eq!(second.queue_wait, Some(Duration::from_millis(500))); + assert!(second.queue_wait.unwrap() <= QUEUE_MAX_WAIT); +} + +#[test] +fn queue_strategy_rejects_a_wait_beyond_the_bound() { + let registry = RateLimiterRegistry::new(); + // One token per minute: once the bucket is spent the projected wait for + // the next token is a full minute, far beyond QUEUE_MAX_WAIT. + let mut config = config(1, RateLimitWindow::Minute, Some(1), SharingMode::Private); + config.strategy = RateLimitStrategy::Queue; + let effective = EffectiveRateLimit::from_config(&config); + let now = start(); + let epoch = 1_000; + let granted = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + epoch, + ); + assert!(granted.is_allowed()); + let rejected = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + epoch, + ); + assert!( + rejected.is_limited(), + "a minute of waiting is not queueable" + ); + assert!(rejected.queue_wait.is_none()); + let retry_after = rejected.retry_after_seconds.expect("retry after"); + assert!(retry_after > 1, "retry-after must exceed the queue bound"); +} + +#[test] +fn a_queue_strategy_waits_where_a_reject_strategy_refuses() { + // The same bucket (rate 2/s, capacity 1) under both strategies: the queue + // strategy reserves a token and hands back a bounded wait, the reject + // strategy answers `429` for the very same request. + let now = start(); + let values = values("t1", None, None, None); + let queue = { + let mut config = config(2, RateLimitWindow::Second, Some(1), SharingMode::Private); + config.strategy = RateLimitStrategy::Queue; + EffectiveRateLimit::from_config(&config) + }; + let reject = { + let mut config = config(2, RateLimitWindow::Second, Some(1), SharingMode::Private); + config.strategy = RateLimitStrategy::Reject; + EffectiveRateLimit::from_config(&config) + }; + + let queue_registry = RateLimiterRegistry::new(); + assert!( + queue_registry + .check(UPSTREAM, &queue, &values, now, 1_000) + .is_allowed() + ); + let second = queue_registry.check(UPSTREAM, &queue, &values, now, 1_000); + assert!(second.is_allowed(), "the queued request is admitted"); + let wait = second.queue_wait.expect("the queued request waits"); + assert!(wait > Duration::ZERO, "the wait is the token refill time"); + assert!(wait <= QUEUE_MAX_WAIT.min(Duration::from_millis(600))); + + let reject_registry = RateLimiterRegistry::new(); + assert!( + reject_registry + .check(UPSTREAM, &reject, &values, now, 1_000) + .is_allowed() + ); + let second = reject_registry.check(UPSTREAM, &reject, &values, now, 1_000); + assert!(second.is_limited(), "the same request is refused"); + assert!(second.queue_wait.is_none()); +} + +#[test] +fn a_recreated_upstream_starts_from_an_empty_budget() { + // `clear_upstream` is what the store calls when an upstream is deleted: the + // buckets of the *deleted* id are gone, so a new upstream (new id, same + // alias) cannot inherit the budget its predecessor already spent. + let registry = RateLimiterRegistry::new(); + let effective = effective(1, RateLimitWindow::Second, Some(1)); + let now = start(); + let values = values("t1", None, None, None); + let deleted = Uuid::from_u128(0x99); + let recreated = Uuid::from_u128(0xaa); + + assert!( + registry + .check(deleted, &effective, &values, now, 1_000) + .is_allowed() + ); + assert!( + registry + .check(deleted, &effective, &values, now, 1_000) + .is_limited(), + "the old upstream's budget is spent" + ); + assert!( + registry + .check(recreated, &effective, &values, now, 1_000) + .is_allowed(), + "a different id is a different bucket" + ); + registry.clear_upstream(deleted); + assert_eq!(registry.len(), 1, "only the deleted upstream's buckets go"); + assert!( + registry + .check(deleted, &effective, &values, now, 1_000) + .is_allowed(), + "the dropped bucket starts full again" + ); +} + +#[test] +fn the_registry_is_bounded_and_evicts_the_least_recently_used() { + // One key per upstream, in increasing touch order: once the bound is + // reached the *oldest* buckets are the ones that have to go. + let registry = RateLimiterRegistry::new(); + let effective = effective(1, RateLimitWindow::Second, Some(1)); + let base = start(); + let total = MAX_BUCKETS + MAX_BUCKETS / 8; + for index in 0..total { + let upstream = Uuid::from_u128(index as u128 + 1); + let now = base + Duration::from_micros(index as u64 + 1); + let _ = registry.check( + upstream, + &effective, + &values("t1", None, None, None), + now, + 1_000, + ); + } + assert!( + registry.len() <= MAX_BUCKETS, + "the registry never grows past its bound ({} live buckets)", + registry.len() + ); + let values = values("t1", None, None, None); + let oldest = registry.scope_key(Uuid::from_u128(1), RateLimitScope::Tenant, &values); + assert!( + !registry.limiters.contains_key(&oldest), + "the least recently used bucket is evicted first" + ); + let newest = registry.scope_key( + Uuid::from_u128(total as u128), + RateLimitScope::Tenant, + &values, + ); + assert!( + registry.limiters.contains_key(&newest), + "the most recently used bucket survives the eviction" + ); +} + +#[test] +fn degrade_strategy_serves_and_flags_the_response() { + let registry = RateLimiterRegistry::new(); + let mut config = config(1, RateLimitWindow::Second, Some(1), SharingMode::Private); + config.strategy = RateLimitStrategy::Degrade; + let effective = EffectiveRateLimit::from_config(&config); + let now = start(); + let epoch = 1_000; + let first = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + epoch, + ); + assert!(first.is_allowed()); + assert!(!first.degraded); + let second = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + epoch, + ); + assert!(second.is_allowed(), "degrade never rejects"); + assert!(second.degraded); + let headers = second.headers(); + assert_eq!( + header_value(&headers, DEGRADED_HEADER).as_deref(), + Some(DEGRADED_HEADER_VALUE) + ); +} + +#[test] +fn sliding_window_algorithm_is_honoured_by_the_registry() { + let registry = RateLimiterRegistry::new(); + let mut config = config(2, RateLimitWindow::Second, None, SharingMode::Private); + config.algorithm = RateLimitAlgorithm::SlidingWindow; + let effective = EffectiveRateLimit::from_config(&config); + let now = start(); + let epoch = 1_000; + let first = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + epoch, + ); + let second = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + epoch, + ); + let third = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + epoch, + ); + assert!(first.is_allowed()); + assert!(second.is_allowed()); + assert!(third.is_limited()); +} + +#[test] +fn clearing_an_upstream_drops_only_its_limiters() { + let registry = RateLimiterRegistry::new(); + let effective = effective(1, RateLimitWindow::Second, Some(1)); + let now = start(); + let _ = registry.check( + UPSTREAM, + &effective, + &values("t1", None, None, None), + now, + 1_000, + ); + let _ = registry.check( + Uuid::from_u128(0x99), + &effective, + &values("t1", None, None, None), + now, + 1_000, + ); + assert_eq!(registry.len(), 2); + registry.clear_upstream(Uuid::from_u128(0x99)); + assert_eq!(registry.len(), 1); + registry.clear(); + assert!(registry.is_empty()); +} + +#[test] +fn a_configured_capacity_change_resets_the_counter() { + let registry = RateLimiterRegistry::new(); + let now = start(); + let first = effective(1, RateLimitWindow::Second, Some(1)); + let _granted = registry.check( + UPSTREAM, + &first, + &values("t1", None, None, None), + now, + 1_000, + ); + let second = effective(1, RateLimitWindow::Second, Some(5)); + let granted = registry.check( + UPSTREAM, + &second, + &values("t1", None, None, None), + now, + 1_000, + ); + assert!(granted.is_allowed()); + assert_eq!(granted.limit, 5); +} + +// --------------------------------------------------------------------------- +// Queue reservations and their refunds +// --------------------------------------------------------------------------- + +#[test] +fn a_denied_queue_reservation_still_carries_the_retry_after() { + // A sliding window is a log of hits, so a reservation inside the queue + // bound cannot always be delivered: at capacity there is no hit to record + // and the request is denied even though the projected wait is short. That + // answer is a `429` all the same, so it carries the ADR-0003 `Retry-After` + // like a rejection past the bound does. + let registry = RateLimiterRegistry::new(); + let mut config = config(2, RateLimitWindow::Second, Some(2), SharingMode::Private); + config.algorithm = RateLimitAlgorithm::SlidingWindow; + config.strategy = RateLimitStrategy::Queue; + let effective = EffectiveRateLimit::from_config(&config); + let now = start(); + let values = values("t1", None, None, None); + let first = registry.check(UPSTREAM, &effective, &values, now, 1_000); + let second = registry.check(UPSTREAM, &effective, &values, now, 1_000); + assert!(first.is_allowed() && second.is_allowed()); + assert!(first.queue_wait.is_none() && second.queue_wait.is_none()); + + let denied = registry.check(UPSTREAM, &effective, &values, now, 1_000); + assert!(denied.is_limited(), "the window is at capacity"); + // The oldest of the two hits expires one second after it was recorded, + // inside the queue bound, so the request was *queued* and then denied. + assert_eq!(denied.queue_wait, Some(Duration::from_secs(1))); + assert!(denied.queue_wait.unwrap() <= QUEUE_MAX_WAIT); + assert_eq!(denied.retry_after_seconds, Some(1)); + let headers = denied.headers(); + assert_eq!( + header_value(&headers, RETRY_AFTER_HEADER).as_deref(), + Some("1") + ); + let error = denied.into_error(); + assert_eq!(error.status(), axum::http::StatusCode::TOO_MANY_REQUESTS); + assert_eq!(error.problem_body().context.retry_after_seconds, Some(1)); +} + +#[test] +fn sliding_window_release_drops_the_newest_hit_of_that_cost() { + let now = start(); + let mut window = SlidingWindow::new(3.0, Duration::from_secs(10)); + assert!(window.try_acquire(1.0, now)); + assert!(window.try_acquire(2.0, now)); + assert!(!window.try_acquire(1.0, now), "the window is at capacity"); + + window.release(2.0, now); + assert_eq!( + window.remaining(now), + 2.0, + "the refunded cost is free again" + ); + window.release(1.0, now); + assert_eq!(window.remaining(now), 3.0, "the window is empty again"); + window.release(1.0, now); + assert_eq!( + window.remaining(now), + 3.0, + "a refund never credits a balance that was not charged" + ); +} + +#[test] +fn token_bucket_release_restores_the_balance_without_minting_tokens() { + let now = start(); + let mut bucket = TokenBucket::new(2.0, 1.0, now); + assert!(bucket.try_acquire(2.0, now)); + assert!(!bucket.try_acquire(1.0, now), "the bucket is empty"); + + bucket.release(2.0, now); + assert_eq!(bucket.tokens_at(now), 2.0); + + // The refund clamps to the capacity, so a request that failed after the + // bucket already refilled cannot turn its debit into extra budget. + bucket.release(2.0, now + Duration::from_secs(3)); + assert_eq!(bucket.tokens_at(now + Duration::from_secs(3)), 2.0); +} + +#[test] +fn a_failed_queued_request_puts_its_tokens_back() { + // Two identical buckets, each spending one token through the `queue` + // strategy for a request that then fails. Only the first gets the refund, + // so only its next request waits for a single token again instead of two. + let queue = || { + let mut config = config(2, RateLimitWindow::Second, Some(1), SharingMode::Private); + config.strategy = RateLimitStrategy::Queue; + EffectiveRateLimit::from_config(&config) + }; + let effective = queue(); + let now = start(); + let values = values("t1", None, None, None); + let spend = |registry: &RateLimiterRegistry| { + let granted = registry.check(UPSTREAM, &effective, &values, now, 1_000); + assert!(granted.is_allowed(), "the bucket starts full"); + assert_eq!(granted.queue_wait, None); + let queued = registry.check(UPSTREAM, &effective, &values, now, 1_000); + assert_eq!( + queued.queue_wait, + Some(Duration::from_millis(500)), + "the queued request debits the token it waits for" + ); + registry + .reservation(UPSTREAM, &effective, &values, &queued) + .expect("a queued request holds a reservation") + }; + + let refunded = RateLimiterRegistry::new(); + refunded.release(spend(&refunded), now); + let after_refund = refunded.check(UPSTREAM, &effective, &values, now, 1_000); + assert_eq!( + after_refund.queue_wait, + Some(Duration::from_millis(500)), + "the bucket is back at the balance it had before the debit" + ); + + let kept = RateLimiterRegistry::new(); + drop(spend(&kept)); + let without_refund = kept.check(UPSTREAM, &effective, &values, now, 1_000); + assert_eq!( + without_refund.queue_wait, + Some(Duration::from_secs(1)), + "an unreleased debit is still held" + ); +} + +#[test] +fn a_denied_request_holds_no_reservation_to_refund() { + // A sliding window cannot go into credit: its `429` from a full queue + // carries the projected wait but recorded no hit, so it captures no + // reservation — a handle for it would pay back a hit another request made. + let registry = RateLimiterRegistry::new(); + let mut config = config(2, RateLimitWindow::Second, Some(2), SharingMode::Private); + config.algorithm = RateLimitAlgorithm::SlidingWindow; + config.strategy = RateLimitStrategy::Queue; + let effective = EffectiveRateLimit::from_config(&config); + let now = start(); + let values = values("t1", None, None, None); + let first = registry.check(UPSTREAM, &effective, &values, now, 1_000); + let second = registry.check(UPSTREAM, &effective, &values, now, 1_000); + assert!(first.is_allowed() && second.is_allowed()); + assert!(first.queue_wait.is_none() && second.queue_wait.is_none()); + + let denied = registry.check(UPSTREAM, &effective, &values, now, 1_000); + assert!(denied.is_limited()); + assert!(denied.queue_wait.is_some()); + assert!( + registry + .reservation(UPSTREAM, &effective, &values, &denied) + .is_none() + ); + assert!( + registry + .check(UPSTREAM, &effective, &values, now, 1_000) + .is_limited(), + "the two granted hits are still held" + ); +} diff --git a/gears/system/oagw/oagw/src/domain/services.rs b/gears/system/oagw/oagw/src/domain/services.rs new file mode 100644 index 0000000..f160f03 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/services.rs @@ -0,0 +1,617 @@ +//! Control-plane services of the OAGW management API. +//! +//! [`ControlPlaneService`] is the single writer of the [`RegistryStore`] and +//! the single place where the three cross-cutting rules of the management API +//! live: +//! +//! * **tenancy** — every resource is owned by the calling tenant +//! (`SecurityContext::subject_tenant_id`); a resource of another tenant is +//! indistinguishable from a missing one (404, DESIGN section 3.3); +//! * **hierarchy** — a descendant may bind an upstream alias that an ancestor +//! already owns, but only while the ancestor does not *enforce* any of its +//! hierarchical sections (403 otherwise, DESIGN "Hierarchical +//! Configuration"); +//! * **audit** — every accepted mutation emits an ADR-0001 audit line with the +//! request id, tenant, principal, resource and alias. +//! +//! Handlers stay thin: they translate the wire DTOs into the validator inputs +//! and hand the request id (`x-request-id`) through to the audit trail. + +use std::sync::Arc; + +use async_trait::async_trait; +use tenant_resolver_sdk::{BarrierMode, GetAncestorsOptions, TenantId, TenantResolverClient}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::audit; +use crate::domain::error::{OagwError, ReferencedBy}; +use crate::domain::model::{ + Plugin, Route, Upstream, enforces_override, format_plugin_id, format_route_id, + format_upstream_id, +}; +use crate::domain::validation::{PluginCatalog, PluginInput, RouteInput, UpstreamInput, Validator}; +use crate::infra::storage::RegistryStore; + +/// Audit resource type of an upstream. +const UPSTREAM_RESOURCE: &str = "upstream"; +/// Audit resource type of a route. +const ROUTE_RESOURCE: &str = "route"; +/// Audit resource type of a plugin. +const PLUGIN_RESOURCE: &str = "plugin"; + +/// Source of the ancestor chain of a tenant (DESIGN "Hierarchical +/// Configuration"). +#[async_trait] +pub trait TenantHierarchy: Send + Sync { + /// Ancestor tenant ids of `tenant`, nearest parent first and excluding + /// `tenant` itself. + /// + /// Resolution failures degrade to "no ancestors": a control-plane write + /// must not fail because a sibling subsystem is unavailable, and the + /// authoritative alias-uniqueness check in the store still runs. + async fn ancestors(&self, ctx: &SecurityContext, tenant: Uuid) -> Vec; +} + +/// Hierarchy of a tenant that has no ancestors (root tenants, tests). +#[derive(Debug, Default, Clone, Copy)] +pub struct NoHierarchy; + +#[async_trait] +impl TenantHierarchy for NoHierarchy { + async fn ancestors(&self, _ctx: &SecurityContext, _tenant: Uuid) -> Vec { + Vec::new() + } +} + +/// Hierarchy backed by the `tenant_resolver` dependency. +pub struct ResolverHierarchy { + client: Arc, +} + +impl ResolverHierarchy { + /// Wraps a resolver client. + #[must_use] + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl TenantHierarchy for ResolverHierarchy { + async fn ancestors(&self, ctx: &SecurityContext, tenant: Uuid) -> Vec { + let options = GetAncestorsOptions { + barrier_mode: BarrierMode::Respect, + }; + let response = self + .client + .get_ancestors(ctx, TenantId(tenant), &options) + .await; + response + .map(|resolved| { + resolved + .ancestors + .iter() + .map(|ancestor| ancestor.id.0) + .collect() + }) + .unwrap_or_default() + } +} + +/// Control-plane service: validation, tenancy, hierarchy and audit for the +/// three registry resources. +pub struct ControlPlaneService { + store: Arc, + validator: Validator, + hierarchy: Arc, +} + +impl ControlPlaneService { + /// Builds a service over `store`. + /// + /// `hierarchy` supplies the ancestor chain used by the bind rule; pass + /// [`NoHierarchy`] when the gear runs without a tenant resolver. + #[must_use] + pub fn new( + store: Arc, + validator: Validator, + hierarchy: Arc, + ) -> Self { + Self { + store, + validator, + hierarchy, + } + } + + /// The registry the service writes to. + #[must_use] + pub fn store(&self) -> &Arc { + &self.store + } + + /// The validator the service applies. + #[must_use] + pub fn validator(&self) -> &Validator { + &self.validator + } + + /// The tenant chain a data-plane request resolves over: the calling tenant + /// first, then its ancestors nearest parent first (DESIGN "Hierarchical + /// Configuration"). + /// + /// Resolution failures degrade to a single-element chain, exactly like the + /// control-plane writes: a broken tenant resolver must not take the proxy + /// surface down. + pub async fn tenant_chain(&self, ctx: &SecurityContext) -> Vec { + let tenant = ctx.subject_tenant_id(); + let mut chain = vec![tenant]; + chain.extend(self.hierarchy.ancestors(ctx, tenant).await); + chain + } + + // -- upstreams --------------------------------------------------------- + + /// Creates an upstream for the calling tenant. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when the draft is invalid (including + /// an unresolvable plugin/auth binding), [`OagwError::BindForbidden`] when + /// an ancestor enforces the alias, and [`OagwError::Conflict`] when the + /// calling tenant already owns the alias. + pub async fn create_upstream( + &self, + ctx: &SecurityContext, + request_id: Option<&str>, + input: &UpstreamInput, + ) -> Result, OagwError> { + let tenant_id = ctx.subject_tenant_id(); + let mut upstream = self.validator.validate_upstream(input)?; + self.validator + .validate_bindings(&upstream, &self.plugin_catalog(tenant_id))?; + self.check_ancestor_bind(ctx, tenant_id, &upstream.alias) + .await?; + upstream.id = Uuid::new_v4(); + upstream.tenant_id = tenant_id; + let stored = self.store.insert_upstream(upstream)?; + audit::log_mutation( + "upstream.created", + ctx, + UPSTREAM_RESOURCE, + &format_upstream_id(stored.id), + request_id, + Some(&stored.alias), + None, + ); + Ok(stored) + } + + /// Replaces an upstream of the calling tenant. The alias and the endpoint + /// pool derived from it are immutable (DESIGN section 3.1). + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when the replacement is invalid or + /// would change the alias, [`OagwError::BindForbidden`] when an ancestor + /// enforces the alias (checked here exactly as on the create path, so a + /// `PUT` cannot rebind an alias an ancestor has since switched to + /// `enforce`), and [`OagwError::NotFound`] / [`OagwError::Conflict`] per + /// the store rules. + pub async fn replace_upstream( + &self, + ctx: &SecurityContext, + request_id: Option<&str>, + upstream_id: Uuid, + input: &UpstreamInput, + ) -> Result, OagwError> { + let tenant_id = ctx.subject_tenant_id(); + let existing = self + .store + .get_upstream(tenant_id, upstream_id) + .ok_or_else(|| missing_upstream(upstream_id))?; + let mut replaced = self.validator.validate_upstream_replace(&existing, input)?; + self.validator + .validate_bindings(&replaced, &self.plugin_catalog(tenant_id))?; + self.check_ancestor_bind(ctx, tenant_id, &replaced.alias) + .await?; + replaced.id = existing.id; + replaced.tenant_id = tenant_id; + let stored = self.store.replace_upstream(replaced)?; + audit::log_mutation( + "upstream.replaced", + ctx, + UPSTREAM_RESOURCE, + &format_upstream_id(stored.id), + request_id, + Some(&stored.alias), + None, + ); + Ok(stored) + } + + /// Reads one upstream of the calling tenant. + #[must_use] + pub fn get_upstream(&self, ctx: &SecurityContext, upstream_id: Uuid) -> Option> { + self.store + .get_upstream(ctx.subject_tenant_id(), upstream_id) + } + + /// Lists the upstreams of the calling tenant, newest first. + #[must_use] + pub fn list_upstreams(&self, ctx: &SecurityContext) -> Vec> { + self.store.list_upstreams(&[ctx.subject_tenant_id()]) + } + + /// Deletes an upstream of the calling tenant together with its routes. + /// + /// # Errors + /// + /// Returns [`OagwError::NotFound`] when the calling tenant does not own + /// the upstream. + pub fn delete_upstream( + &self, + ctx: &SecurityContext, + request_id: Option<&str>, + upstream_id: Uuid, + ) -> Result, OagwError> { + let tenant_id = ctx.subject_tenant_id(); + let removed = self + .store + .get_upstream(tenant_id, upstream_id) + .ok_or_else(|| missing_upstream(upstream_id))?; + if !self.store.delete_upstream(tenant_id, upstream_id) { + return Err(missing_upstream(upstream_id)); + } + audit::log_mutation( + "upstream.deleted", + ctx, + UPSTREAM_RESOURCE, + &format_upstream_id(upstream_id), + request_id, + Some(&removed.alias), + None, + ); + Ok(removed) + } + + // -- routes ------------------------------------------------------------ + + /// Creates a route under an upstream of the calling tenant. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when the draft is invalid (including + /// an unresolvable plugin binding) or the match protocol differs from the + /// upstream protocol, [`OagwError::NotFound`] when the upstream is not + /// owned by the calling tenant, and [`OagwError::Conflict`] on a duplicate + /// match rule. + pub async fn create_route( + &self, + ctx: &SecurityContext, + request_id: Option<&str>, + upstream_id: Uuid, + input: &RouteInput, + ) -> Result, OagwError> { + let tenant_id = ctx.subject_tenant_id(); + let owner = self + .store + .get_upstream(tenant_id, upstream_id) + .ok_or_else(|| missing_upstream(upstream_id))?; + let mut route = self.validator.validate_route(upstream_id, &owner, input)?; + self.validator + .validate_route_bindings(&route, &self.plugin_catalog(tenant_id))?; + route.id = Uuid::new_v4(); + route.tenant_id = tenant_id; + let stored = self.store.insert_route(route)?; + audit::log_mutation( + "route.created", + ctx, + ROUTE_RESOURCE, + &format_route_id(stored.id), + request_id, + None, + Some(&format!("upstream {}", format_upstream_id(upstream_id))), + ); + Ok(stored) + } + + /// Replaces a route of the calling tenant. `upstream_id` is immutable, so + /// the stored owner is kept (DESIGN section 3.3 "PUT (Replace)"). + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when the replacement is invalid, + /// [`OagwError::NotFound`] when the route is unknown to the tenant, and + /// [`OagwError::Conflict`] on a duplicate match rule. + pub async fn replace_route( + &self, + ctx: &SecurityContext, + request_id: Option<&str>, + route_id: Uuid, + input: &RouteInput, + ) -> Result, OagwError> { + let tenant_id = ctx.subject_tenant_id(); + let existing = self + .store + .get_route(tenant_id, route_id) + .ok_or_else(|| missing_route(route_id))?; + let owner = self + .store + .get_upstream(tenant_id, existing.upstream_id) + .ok_or_else(|| missing_upstream(existing.upstream_id))?; + let mut replaced = self + .validator + .validate_route(existing.upstream_id, &owner, input)?; + self.validator + .validate_route_bindings(&replaced, &self.plugin_catalog(tenant_id))?; + replaced.id = existing.id; + replaced.upstream_id = existing.upstream_id; + replaced.tenant_id = tenant_id; + let stored = self.store.replace_route(replaced)?; + audit::log_mutation( + "route.replaced", + ctx, + ROUTE_RESOURCE, + &format_route_id(stored.id), + request_id, + None, + Some(&format!( + "upstream {}", + format_upstream_id(stored.upstream_id) + )), + ); + Ok(stored) + } + + /// Reads one route of the calling tenant. + #[must_use] + pub fn get_route(&self, ctx: &SecurityContext, route_id: Uuid) -> Option> { + self.store.get_route(ctx.subject_tenant_id(), route_id) + } + + /// Lists the routes of the calling tenant, highest priority first. + #[must_use] + pub fn list_routes(&self, ctx: &SecurityContext) -> Vec> { + self.store.list_routes(&[ctx.subject_tenant_id()]) + } + + /// Deletes a route of the calling tenant. + /// + /// # Errors + /// + /// Returns [`OagwError::NotFound`] when the calling tenant does not own + /// the route. + pub fn delete_route( + &self, + ctx: &SecurityContext, + request_id: Option<&str>, + route_id: Uuid, + ) -> Result, OagwError> { + let tenant_id = ctx.subject_tenant_id(); + let removed = self + .store + .get_route(tenant_id, route_id) + .ok_or_else(|| missing_route(route_id))?; + if !self.store.delete_route(tenant_id, route_id) { + return Err(missing_route(route_id)); + } + audit::log_mutation( + "route.deleted", + ctx, + ROUTE_RESOURCE, + &format_route_id(route_id), + request_id, + None, + Some(&format!( + "upstream {}", + format_upstream_id(removed.upstream_id) + )), + ); + Ok(removed) + } + + // -- plugins ----------------------------------------------------------- + + /// Registers a plugin for the calling tenant. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when `plugin_type` is not a GTS type + /// identifier or `config` is not a JSON object, and + /// [`OagwError::Conflict`] when the id already exists. + pub fn create_plugin( + &self, + ctx: &SecurityContext, + request_id: Option<&str>, + input: &PluginInput, + ) -> Result, OagwError> { + let mut plugin = self.validator.validate_plugin(input)?; + plugin.id = Uuid::new_v4(); + plugin.tenant_id = ctx.subject_tenant_id(); + let stored = self.store.insert_plugin(plugin)?; + audit::log_mutation( + "plugin.created", + ctx, + PLUGIN_RESOURCE, + &format_plugin_id(stored.id), + request_id, + None, + Some(&stored.plugin_type), + ); + Ok(stored) + } + + /// Reads one plugin of the calling tenant. + #[must_use] + pub fn get_plugin(&self, ctx: &SecurityContext, plugin_id: Uuid) -> Option> { + self.store.get_plugin(ctx.subject_tenant_id(), plugin_id) + } + + /// Lists the plugins of the calling tenant, sorted by id. + #[must_use] + pub fn list_plugins(&self, ctx: &SecurityContext) -> Vec> { + self.store.list_plugins(&[ctx.subject_tenant_id()]) + } + + /// Deletes a plugin of the calling tenant. + /// + /// # Errors + /// + /// Returns [`OagwError::PluginInUse`] carrying `plugin_id` and + /// `referenced_by` when an upstream or a route still references the plugin + /// (ADR-0001), and [`OagwError::NotFound`] when the calling tenant does + /// not own it. + /// + /// The *blocking* scan is tenant-wide, so a binding held by another tenant + /// still refuses the delete. The `referenced_by` body only names resources + /// of the calling tenant: management responses never disclose another + /// tenant's resource ids (DESIGN section 3.3 "tenancy"). + pub fn delete_plugin( + &self, + ctx: &SecurityContext, + request_id: Option<&str>, + plugin_id: Uuid, + ) -> Result, OagwError> { + let tenant_id = ctx.subject_tenant_id(); + let removed = self + .store + .get_plugin(tenant_id, plugin_id) + .ok_or_else(|| missing_plugin(plugin_id))?; + let upstreams = self.store.upstream_ids_referencing_plugin(&removed); + let routes = self.store.route_ids_referencing_plugin(&removed); + if !upstreams.is_empty() || !routes.is_empty() { + // The caller's own resources, for the wire body. The management + // surface is strictly own-tenant scoped (DESIGN section 3.3: an + // ancestor's resources are invisible to a descendant), so the + // chain here is the calling tenant alone. + let own_upstreams = self + .store + .upstream_ids_referencing_plugin_in(&removed, &[tenant_id]); + let own_routes = self + .store + .route_ids_referencing_plugin_in(&removed, &[tenant_id]); + return Err(OagwError::plugin_in_use(format!( + "plugin {} is still referenced by {} upstream(s) and {} route(s); unbind them first", + format_plugin_id(plugin_id), + upstreams.len(), + routes.len() + )) + .with_plugin_id(format_plugin_id(plugin_id)) + .with_referenced_by(ReferencedBy { + upstreams: own_upstreams + .iter() + .map(|id| format_upstream_id(*id)) + .collect(), + routes: own_routes.iter().map(|id| format_route_id(*id)).collect(), + })); + } + if self.store.delete_plugin(tenant_id, plugin_id).is_none() { + return Err(missing_plugin(plugin_id)); + } + audit::log_mutation( + "plugin.deleted", + ctx, + PLUGIN_RESOURCE, + &format_plugin_id(plugin_id), + request_id, + None, + Some(&removed.plugin_type), + ); + Ok(removed) + } + + /// Renders the deterministic Starlark definition of a plugin. + /// + /// # Errors + /// + /// Returns [`OagwError::NotFound`] when the calling tenant does not own + /// the plugin. + pub fn plugin_source( + &self, + ctx: &SecurityContext, + plugin_id: Uuid, + ) -> Result<(Arc, String), OagwError> { + let plugin = self + .get_plugin(ctx, plugin_id) + .ok_or_else(|| missing_plugin(plugin_id))?; + let source = crate::domain::validation::render_plugin_source( + &format_plugin_id(plugin.id), + &plugin.plugin_type, + &plugin.tenant_id, + plugin.enabled, + &plugin.tags, + &plugin.config, + ); + Ok((plugin, source)) + } + + // -- hierarchy --------------------------------------------------------- + + /// Rejects an alias an ancestor already owns while that ancestor enforces + /// its hierarchical sections. + async fn check_ancestor_bind( + &self, + ctx: &SecurityContext, + tenant_id: Uuid, + alias: &str, + ) -> Result<(), OagwError> { + let mut chain = self.hierarchy.ancestors(ctx, tenant_id).await; + chain.push(tenant_id); + let Some(owner) = self.store.resolve_upstream_alias(&chain, alias) else { + return Ok(()); + }; + if owner.tenant_id == tenant_id { + // The store surfaces a same-tenant clash as a 409 conflict. + return Ok(()); + } + if enforces_override(&owner) { + return Err(OagwError::bind_forbidden(format!( + "alias `{alias}` is owned by ancestor {} and its configuration is enforced; a descendant cannot override it", + format_upstream_id(owner.id) + )) + .with_alias(alias) + .with_upstream_id(owner.id)); + } + Ok(()) + } + + /// The plugin ids the calling tenant may bind: the builtin ids the plugin + /// registry implements, plus the custom plugins the tenant owns. + /// + /// Scoped to the calling tenant on purpose (see + /// [`Validator::validate_route_bindings`](crate::domain::validation::Validator::validate_route_bindings)): + /// ancestor resolution is asynchronous and not plumbed into the validator, + /// so the bind check never widens to an ancestor's plugins. + fn plugin_catalog(&self, tenant_id: Uuid) -> PluginCatalog { + PluginCatalog::of_plugins(&self.store.list_plugins(&[tenant_id])) + } +} + +fn missing_upstream(upstream_id: Uuid) -> OagwError { + OagwError::not_found(format!( + "upstream {} does not exist", + format_upstream_id(upstream_id) + )) + .with_upstream_id(upstream_id) +} + +fn missing_route(route_id: Uuid) -> OagwError { + OagwError::not_found(format!( + "route {} does not exist", + format_route_id(route_id) + )) +} + +fn missing_plugin(plugin_id: Uuid) -> OagwError { + OagwError::not_found(format!( + "plugin {} does not exist", + format_plugin_id(plugin_id) + )) + .with_plugin_id(format_plugin_id(plugin_id)) +} + +#[cfg(test)] +#[path = "services_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/services_tests.rs b/gears/system/oagw/oagw/src/domain/services_tests.rs new file mode 100644 index 0000000..d937ff6 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/services_tests.rs @@ -0,0 +1,624 @@ +//! Tests for [`crate::domain::services`]. + +use std::sync::Arc; + +use async_trait::async_trait; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use super::{ControlPlaneService, NoHierarchy, TenantHierarchy}; +use crate::config::OagwConfig; +use crate::domain::error::OagwError; +use crate::domain::model::{CorsConfig, Endpoint, Protocol, Scheme, ServerConfig, SharingMode}; +use crate::domain::validation::{RouteInput, UpstreamInput, Validator, route_match_key}; +use crate::infra::storage::{CacheLimits, RegistryStore}; + +const TENANT: Uuid = Uuid::from_u128(0x11); +const ANCESTOR: Uuid = Uuid::from_u128(0x22); + +fn context(tenant: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::from_u128(0x33)) + .subject_tenant_id(tenant) + .build() + .expect("security context") +} + +/// Hierarchy with a single fixed ancestor, so the bind rule is testable +/// without a tenant resolver. +#[derive(Debug, Default)] +struct FixedHierarchy { + ancestor: std::sync::Mutex>, +} + +#[async_trait] +impl TenantHierarchy for FixedHierarchy { + async fn ancestors(&self, _ctx: &SecurityContext, _tenant: Uuid) -> Vec { + self.ancestor + .lock() + .map(|guard| guard.iter().copied().collect()) + .unwrap_or_default() + } +} + +fn endpoint(host: &str) -> Endpoint { + Endpoint { + scheme: Scheme::Https, + host: host.to_owned(), + port: 443, + } +} + +fn upstream_input(hosts: &[&str], alias: Option<&str>) -> UpstreamInput { + UpstreamInput { + alias: alias.map(ToOwned::to_owned), + enabled: None, + tags: Vec::new(), + server: ServerConfig { + endpoints: hosts.iter().map(|host| endpoint(host)).collect(), + }, + protocol: Protocol::Http, + auth: None, + headers: crate::domain::model::HeadersConfig::default(), + plugins: crate::domain::model::PluginConfig::default(), + rate_limit: None, + cors: None, + } +} + +fn limits() -> CacheLimits { + CacheLimits { + upstream: 16, + route: 16, + plugin: 16, + dp: 16, + } +} + +fn service() -> ControlPlaneService { + service_with_hierarchy(NoHierarchy) +} + +fn service_with_hierarchy(hierarchy: impl TenantHierarchy + 'static) -> ControlPlaneService { + ControlPlaneService::new( + Arc::new(RegistryStore::new(limits())), + Validator::new(OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }), + Arc::new(hierarchy), + ) +} + +async fn create_upstream( + svc: &ControlPlaneService, + tenant: Uuid, + hosts: &[&str], + alias: Option<&str>, +) -> Result, OagwError> { + let input = upstream_input(hosts, alias); + svc.create_upstream(&context(tenant), Some("req-1"), &input) + .await +} + +#[tokio::test] +async fn an_upstream_is_created_for_the_calling_tenant() { + let svc = service(); + let created = create_upstream(&svc, TENANT, &["api.openai.com"], None) + .await + .expect("created"); + assert_eq!(created.tenant_id, TENANT); + assert_eq!(created.alias, "api.openai.com"); + assert!(created.enabled, "enabled defaults to true"); + assert_ne!(created.id, Uuid::nil(), "the store assigns an id"); + + // The alias is resolvable for the owning tenant only. + assert!( + svc.store() + .resolve_upstream_alias(&[TENANT], "api.openai.com") + .is_some() + ); + assert!( + svc.store() + .resolve_upstream_alias(&[ANCESTOR], "api.openai.com") + .is_none() + ); +} + +#[tokio::test] +async fn a_duplicate_alias_conflicts() { + let svc = service(); + create_upstream(&svc, TENANT, &["api.openai.com"], None) + .await + .expect("created"); + let error = create_upstream(&svc, TENANT, &["api.openai.com"], None) + .await + .expect_err("duplicate alias"); + assert_eq!(error.status(), axum::http::StatusCode::CONFLICT); +} + +#[tokio::test] +async fn the_same_alias_in_another_tenant_is_fine() { + let svc = service(); + create_upstream(&svc, ANCESTOR, &["api.openai.com"], None) + .await + .expect("created"); + create_upstream(&svc, TENANT, &["api.openai.com"], None) + .await + .expect("a sibling tenant may reuse the alias"); +} + +#[tokio::test] +async fn an_enforced_ancestor_alias_is_forbidden() { + let hierarchy = FixedHierarchy::default(); + *hierarchy.ancestor.lock().expect("lock") = Some(ANCESTOR); + let svc = service_with_hierarchy(hierarchy); + + let owner = create_upstream(&svc, ANCESTOR, &["api.openai.com"], None) + .await + .expect("ancestor upstream"); + svc.store() + .get_upstream(ANCESTOR, owner.id) + .expect("stored"); + + // Flip the ancestor to `enforce`: the descendant is rejected with 403. + let enforced = crate::domain::model::Upstream { + plugins: crate::domain::model::PluginConfig { + sharing: SharingMode::Enforce, + items: Vec::new(), + }, + ..(*svc + .store() + .get_upstream(ANCESTOR, owner.id) + .expect("stored")) + .clone() + }; + svc.store().replace_upstream(enforced).expect("replaced"); + + let error = create_upstream(&svc, TENANT, &["api.openai.com"], None) + .await + .expect_err("ancestor enforces the alias"); + assert_eq!(error.status(), axum::http::StatusCode::FORBIDDEN); + assert_eq!(error.context().alias.as_deref(), Some("api.openai.com")); +} + +#[tokio::test] +async fn a_private_ancestor_alias_is_bindable() { + let hierarchy = FixedHierarchy::default(); + *hierarchy.ancestor.lock().expect("lock") = Some(ANCESTOR); + let svc = service_with_hierarchy(hierarchy); + create_upstream(&svc, ANCESTOR, &["api.openai.com"], None) + .await + .expect("ancestor upstream"); + create_upstream(&svc, TENANT, &["api.openai.com"], None) + .await + .expect("private ancestor configuration does not block the bind"); +} + +#[tokio::test] +async fn replace_keeps_the_alias_and_the_tenant() { + let svc = service(); + let created = create_upstream(&svc, TENANT, &["api.openai.com"], None) + .await + .expect("created"); + let replacement = upstream_input(&["eu.api.openai.com", "us.api.openai.com"], None); + let replaced = svc + .replace_upstream(&context(TENANT), Some("req-2"), created.id, &replacement) + .await + .expect("replaced"); + assert_eq!(replaced.id, created.id); + assert_eq!(replaced.alias, "api.openai.com"); + assert_eq!(replaced.tenant_id, TENANT); + assert_eq!(replaced.server.endpoints.len(), 2); +} + +#[tokio::test] +async fn replacing_a_foreign_upstream_is_a_404() { + let svc = service(); + let created = create_upstream(&svc, ANCESTOR, &["api.openai.com"], None) + .await + .expect("created"); + let error = svc + .replace_upstream( + &context(TENANT), + None, + created.id, + &upstream_input(&["api.openai.com"], None), + ) + .await + .expect_err("not owned by the caller"); + assert_eq!(error.status(), axum::http::StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn delete_upstream_cascades_its_routes() { + let svc = service(); + let created = create_upstream(&svc, TENANT, &["api.openai.com"], None) + .await + .expect("created"); + let route = svc + .create_route( + &context(TENANT), + None, + created.id, + &route_input("/v1", &["GET"]), + ) + .await + .expect("route"); + let removed = svc + .delete_upstream(&context(TENANT), Some("req-3"), created.id) + .expect("deleted"); + assert_eq!(removed.id, created.id); + assert!( + svc.get_route(&context(TENANT), route.id).is_none(), + "routes cascade" + ); + let error = svc + .delete_upstream(&context(TENANT), None, created.id) + .expect_err("already gone"); + assert_eq!(error.status(), axum::http::StatusCode::NOT_FOUND); +} + +// -- routes ------------------------------------------------------------------- + +fn route_input(path: &str, methods: &[&str]) -> RouteInput { + RouteInput { + r#match: crate::domain::model::RouteMatch { + http: Some(crate::domain::model::HttpMatch { + methods: methods + .iter() + .map(|method| match *method { + "GET" => crate::domain::model::HttpMethod::Get, + "POST" => crate::domain::model::HttpMethod::Post, + _ => crate::domain::model::HttpMethod::Delete, + }) + .collect(), + path: path.to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: crate::domain::model::PathSuffixMode::Append, + }), + grpc: None, + }, + headers: crate::domain::model::HeadersConfig::default(), + plugins: crate::domain::model::PluginConfig::default(), + rate_limit: None, + cors: None, + enabled: None, + priority: None, + tags: Vec::new(), + } +} + +#[tokio::test] +async fn a_route_is_created_under_an_owned_upstream() { + let svc = service(); + let owner = create_upstream(&svc, TENANT, &["api.openai.com"], None) + .await + .expect("created"); + let route = svc + .create_route( + &context(TENANT), + Some("req-4"), + owner.id, + &route_input("/v1", &["GET"]), + ) + .await + .expect("route"); + assert_eq!(route.upstream_id, owner.id); + assert_eq!(route.tenant_id, TENANT); + assert_eq!(route.priority, 0, "priority defaults to 0"); + assert_eq!( + route_match_key(&route), + crate::domain::validation::MatchKey::Http { + path: "/v1".to_owned(), + methods: [crate::domain::model::HttpMethod::Get] + .into_iter() + .collect(), + } + ); +} + +#[tokio::test] +async fn a_route_for_a_foreign_upstream_is_a_404() { + let svc = service(); + let owner = create_upstream(&svc, ANCESTOR, &["api.openai.com"], None) + .await + .expect("created"); + let error = svc + .create_route( + &context(TENANT), + None, + owner.id, + &route_input("/v1", &["GET"]), + ) + .await + .expect_err("upstream belongs to another tenant"); + assert_eq!(error.status(), axum::http::StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn a_duplicate_match_rule_conflicts() { + let svc = service(); + let owner = create_upstream(&svc, TENANT, &["api.openai.com"], None) + .await + .expect("created"); + svc.create_route( + &context(TENANT), + None, + owner.id, + &route_input("/v1", &["GET"]), + ) + .await + .expect("first route"); + let error = svc + .create_route( + &context(TENANT), + None, + owner.id, + &route_input("/v1", &["GET"]), + ) + .await + .expect_err("duplicate match rule"); + assert_eq!(error.status(), axum::http::StatusCode::CONFLICT); +} + +#[tokio::test] +async fn replace_route_keeps_its_upstream() { + let svc = service(); + let owner = create_upstream(&svc, TENANT, &["api.openai.com"], None) + .await + .expect("created"); + let route = svc + .create_route( + &context(TENANT), + None, + owner.id, + &route_input("/v1", &["GET"]), + ) + .await + .expect("route"); + let replacement = RouteInput { + priority: Some(7), + ..route_input("/v2", &["POST"]) + }; + let replaced = svc + .replace_route(&context(TENANT), Some("req-5"), route.id, &replacement) + .await + .expect("replaced"); + assert_eq!(replaced.id, route.id); + assert_eq!(replaced.upstream_id, owner.id, "upstream_id is immutable"); + assert_eq!(replaced.priority, 7); +} + +#[tokio::test] +async fn routes_list_their_tenant_only() { + let svc = service(); + let owner = create_upstream(&svc, TENANT, &["api.openai.com"], None) + .await + .expect("created"); + let foreign = create_upstream(&svc, ANCESTOR, &["api.openai.com"], None) + .await + .expect("created"); + svc.create_route( + &context(TENANT), + None, + owner.id, + &route_input("/own", &["GET"]), + ) + .await + .expect("route"); + svc.create_route( + &context(ANCESTOR), + None, + foreign.id, + &route_input("/other", &["GET"]), + ) + .await + .expect("route"); + let listed = svc.list_routes(&context(TENANT)); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].tenant_id, TENANT); +} + +// -- plugins ------------------------------------------------------------------ + +fn plugin_input(plugin_type: &str) -> crate::domain::validation::PluginInput { + crate::domain::validation::PluginInput { + plugin_type: plugin_type.to_owned(), + config: serde_json::json!({"headers": ["x-request-id"]}), + enabled: None, + tags: Vec::new(), + } +} + +const GUARD_PLUGIN: &str = "gts.cf.core.oagw.guard_plugin.v1"; + +#[tokio::test] +async fn a_plugin_is_created_and_rendered() { + let svc = service(); + let plugin = svc + .create_plugin(&context(TENANT), Some("req-6"), &plugin_input(GUARD_PLUGIN)) + .expect("created"); + assert_eq!(plugin.tenant_id, TENANT); + assert!(plugin.enabled); + + let (_, source) = svc + .plugin_source(&context(TENANT), plugin.id) + .expect("source"); + assert!( + source.contains("PLUGIN_TYPE = \"gts.cf.core.oagw.guard_plugin.v1\""), + "{source}" + ); +} + +#[tokio::test] +async fn a_plugin_in_use_cannot_be_deleted() { + let svc = service(); + let plugin = svc + .create_plugin(&context(TENANT), None, &plugin_input(GUARD_PLUGIN)) + .expect("created"); + let owner = create_upstream(&svc, TENANT, &["api.openai.com"], None) + .await + .expect("created"); + + // Bind the plugin into the upstream chain. + let mut bound = (*owner).clone(); + bound + .plugins + .items + .push(crate::domain::model::PluginBinding::new( + plugin.id.to_string(), + serde_json::json!({}), + )); + svc.store().replace_upstream(bound).expect("rebound"); + + let error = svc + .delete_plugin(&context(TENANT), Some("req-7"), plugin.id) + .expect_err("still referenced"); + assert_eq!(error.status(), axum::http::StatusCode::CONFLICT); + assert_eq!( + error.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1" + ); + let expected_plugin = format!("gts.cf.core.oagw.plugin.v1~{}", plugin.id); + assert_eq!( + error.context().plugin_id.as_deref(), + Some(expected_plugin.as_str()) + ); + let referenced = error + .context() + .referenced_by + .as_ref() + .expect("referenced_by"); + let expected_owner = format!("gts.cf.core.oagw.upstream.v1~{}", owner.id); + assert_eq!(referenced.upstreams, vec![expected_owner]); + assert!(referenced.routes.is_empty()); + + // Unbinding clears the conflict. + let unbound = (*owner).clone(); + svc.store().replace_upstream(unbound).expect("rebound"); + let removed = svc + .delete_plugin(&context(TENANT), None, plugin.id) + .expect("deleted"); + assert_eq!(removed.id, plugin.id); +} + +#[tokio::test] +async fn a_plugin_in_use_by_a_route_cannot_be_deleted() { + let svc = service(); + let plugin = svc + .create_plugin(&context(TENANT), None, &plugin_input(GUARD_PLUGIN)) + .expect("created"); + let owner = create_upstream(&svc, TENANT, &["api.openai.com"], None) + .await + .expect("created"); + let mut route_input = route_input("/v1", &["GET"]); + route_input + .plugins + .items + .push(crate::domain::model::PluginBinding::new( + plugin.id.to_string(), + serde_json::json!({}), + )); + let route = svc + .create_route(&context(TENANT), None, owner.id, &route_input) + .await + .expect("route"); + + let error = svc + .delete_plugin(&context(TENANT), None, plugin.id) + .expect_err("still referenced by the route"); + let referenced = error + .context() + .referenced_by + .as_ref() + .expect("referenced_by"); + let expected_route = format!("gts.cf.core.oagw.route.v1~{}", route.id); + assert_eq!(referenced.routes, vec![expected_route]); +} + +#[tokio::test] +async fn a_foreign_binding_blocks_the_delete_without_naming_foreign_resources() { + let svc = service(); + let plugin = svc + .create_plugin(&context(TENANT), None, &plugin_input(GUARD_PLUGIN)) + .expect("created"); + + // A binding held by *another* tenant (a shared plugin resolved by a + // sibling subsystem, a racing write, a future surface): the store holds an + // upstream of `ANCESTOR` that references the caller's plugin. + let mut bound = (*create_upstream(&svc, ANCESTOR, &["foreign.example.com"], None) + .await + .expect("created")) + .clone(); + bound + .plugins + .items + .push(crate::domain::model::PluginBinding::new( + plugin.id.to_string(), + serde_json::json!({}), + )); + svc.store().replace_upstream(bound).expect("bound"); + + let error = svc + .delete_plugin(&context(TENANT), Some("req-8"), plugin.id) + .expect_err("a foreign binding still blocks the delete"); + assert_eq!(error.status(), axum::http::StatusCode::CONFLICT); + assert_eq!( + error.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1" + ); + // The count in the detail is tenant-wide, the body is not: the wire must + // not name another tenant's resource ids (DESIGN section 3.3). + assert!(error.detail().contains("1 upstream(s)"), "{error}"); + let referenced = error + .context() + .referenced_by + .as_ref() + .expect("referenced_by"); + assert!(referenced.upstreams.is_empty(), "{referenced:?}"); + assert!(referenced.routes.is_empty(), "{referenced:?}"); + + // Unbinding clears the conflict. + let mut unbound = (*svc + .store() + .resolve_upstream_alias(&[ANCESTOR], "foreign.example.com") + .expect("stored")) + .clone(); + unbound.plugins.items.clear(); + svc.store().replace_upstream(unbound).expect("unbound"); + let removed = svc + .delete_plugin(&context(TENANT), None, plugin.id) + .expect("deleted"); + assert_eq!(removed.id, plugin.id); +} + +#[tokio::test] +async fn plugin_lists_are_tenant_scoped() { + let svc = service(); + svc.create_plugin(&context(TENANT), None, &plugin_input(GUARD_PLUGIN)) + .expect("created"); + assert_eq!(svc.list_plugins(&context(TENANT)).len(), 1); + assert!(svc.list_plugins(&context(ANCESTOR)).is_empty()); +} + +#[tokio::test] +async fn cors_conflicts_are_surfaced_as_validation_errors() { + let svc = service(); + let mut input = upstream_input(&["api.openai.com"], None); + input.cors = Some(CorsConfig { + enabled: true, + sharing: SharingMode::Private, + allowed_origins: vec!["*".to_owned()], + allowed_methods: Vec::new(), + allow_headers: Vec::new(), + expose_headers: Vec::new(), + allow_credentials: true, + max_age: None, + }); + let error = svc + .create_upstream(&context(TENANT), None, &input) + .await + .expect_err("credentials with a wildcard origin"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST); +} 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..b1e8541 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/validation.rs @@ -0,0 +1,1259 @@ +//! Deterministic control-plane validation and alias derivation +//! (DESIGN section 3.1 alias rules, section 3.3 CRUD semantics). +//! +//! The [`Validator`] is pure: it takes the operator configuration it needs +//! ([`OagwConfig::allow_http_upstream`]) plus one resource draft and either +//! returns the populated domain model or an [`OagwError`] carrying the GTS +//! problem type, HTTP status and extension fields DESIGN section 3.3 assigns. +//! Nothing here touches the store, so every rule is unit-testable in +//! isolation and the control-plane service composes them. +//! +//! ## Alias derivation +//! +//! The alias is a routing key, not a label, so it is *derived* from the +//! endpoint pool whenever the pool makes that possible: +//! +//! | Endpoint pool | Derived alias | +//! |---|---| +//! | single hostname, standard port | hostname | +//! | single hostname, non-standard port | `hostname:port` | +//! | several hostnames, registrable common suffix (>= 2 labels) | common suffix | +//! | several hostnames, registrable common suffix, non-standard port | `suffix:port` | +//! | mixed / IPv4 / IPv6 endpoints | not derivable -> explicit alias required | +//! +//! A hostname-based pool always derives: a user-supplied alias that differs +//! from the derived value is rejected (400) while the exact derived value is +//! accepted silently (idempotent). A pool whose only common suffix is a bare +//! public suffix (`co.uk`) is *not* derivable — the public-suffix list decides +//! (the `psl` crate). Non-derivable pools require an explicit alias. +//! +//! ## Immutability +//! +//! `id` and `tenant_id` are server-generated, and the alias is immutable once +//! set (it is the routing key in `/oagw/v1/proxy/{alias}/...`): a replace whose +//! recomputed alias differs from the stored one is rejected, and a pool that +//! becomes non-derivable is rejected outright. +//! +//! ## Plugin bindings +//! +//! A draft that binds a plugin (`auth.type`, `plugins.items[].plugin_ref`) is +//! resolved at bind time against the [`PluginCatalog`] the caller passes in: +//! a builtin id, or a custom plugin of the calling tenant in either wire +//! spelling (bare UUID or `gts.cf.core.oagw.plugin.v1~{uuid}`). An +//! unresolvable reference is a `400` here rather than a `503` +//! `plugin.not_found` per request on the data plane. The catalog covers the +//! calling tenant's own plugins: ancestor resolution is asynchronous, so a +//! descendant binds only the builtins and its own custom plugins (see +//! `ControlPlaneService::plugin_catalog`). + +use std::collections::BTreeSet; +use std::net::{Ipv4Addr, Ipv6Addr}; +use std::str::FromStr; +use std::sync::Arc; + +use uuid::Uuid; + +use crate::config::OagwConfig; +use crate::domain::error::OagwError; +use crate::domain::model::{ + AuthConfig, CorsConfig, Endpoint, HeadersConfig, HttpMethod, Plugin, PluginConfig, Protocol, + RateLimitConfig, Route, RouteMatch, Scheme, ServerConfig, Upstream, parse_plugin_id, +}; + +/// Longest RFC 1123 hostname (excluding a trailing dot). +pub const MAX_HOSTNAME_LEN: usize = 253; + +/// Longest single DNS label. +pub const MAX_LABEL_LEN: usize = 63; + +/// Most tags a resource may carry. +/// +/// The JSON schemas bound only the tag *spelling*; this cap keeps a discovery +/// index from growing without bound. Tags are stored verbatim, not deduplicated. +pub const MAX_TAG_COUNT: usize = 32; + +/// Longest single tag. +pub const MAX_TAG_LEN: usize = 64; + +/// Default port for the TLS-based schemes (`https`, `wss`, `wt`, `grpc`). +pub const STANDARD_TLS_PORT: u16 = 443; + +/// Default port for the plaintext schemes (`http`, `ws`). +pub const STANDARD_PLAINTEXT_PORT: u16 = 80; + +/// Minimum port value the endpoint schema accepts. +pub const MIN_PORT: u16 = 1; + +/// Minimum number of labels a common suffix needs to be a routing alias. +pub const MIN_SUFFIX_LABELS: usize = 2; + +/// Characters forbidden in an endpoint host (userinfo, path, query, fragment +/// and percent-encoding introducers). +const FORBIDDEN_HOST_CHARS: &[char] = &['/', '\\', '?', '#', '@', '%']; + +/// Upstream creation/replace draft: everything the caller supplies, with the +/// server-generated fields absent. +#[derive(Debug, Clone)] +pub struct UpstreamInput { + /// Alias as supplied on the wire; `None` when omitted. + pub alias: Option, + /// `enabled` flag; `None` falls back to the schema default (`true`). + pub enabled: Option, + /// Discovery tags. + pub tags: Vec, + /// Endpoint pool. + pub server: ServerConfig, + /// Upstream protocol. + pub protocol: Protocol, + /// Auth plugin binding. + pub auth: Option, + /// Header transformation rules. + pub headers: HeadersConfig, + /// Plugin chain. + pub plugins: PluginConfig, + /// Rate-limit budget. + pub rate_limit: Option, + /// CORS configuration. + pub cors: Option, +} + +/// Route creation/replace draft. `upstream_id` is deliberately absent: it is +/// immutable and the control-plane service injects it (from the request body on +/// create, from the stored route on replace). +#[derive(Debug, Clone)] +pub struct RouteInput { + /// Match rules; exactly one of `http` / `grpc`. + pub r#match: RouteMatch, + /// Header transformation overrides. + pub headers: HeadersConfig, + /// Plugin chain. + pub plugins: PluginConfig, + /// Route-level rate-limit override. + pub rate_limit: Option, + /// Route-level CORS override. + pub cors: Option, + /// `enabled` flag; `None` falls back to the schema default (`true`). + pub enabled: Option, + /// Match priority; `None` falls back to `0`. + pub priority: Option, + /// Discovery tags. + pub tags: Vec, +} + +/// Plugin creation draft. Plugins are immutable, so there is no replace draft. +#[derive(Debug, Clone)] +pub struct PluginInput { + /// Plugin base type GTS id (e.g. `gts.cf.core.oagw.guard_plugin.v1`). + pub plugin_type: String, + /// Plugin configuration payload (must be a JSON object). + pub config: serde_json::Value, + /// `enabled` flag; `None` falls back to `true`. + pub enabled: Option, + /// Discovery tags. + pub tags: Vec, +} + +/// A plugin the calling tenant may bind: its instance id and the base type it +/// was registered as. +#[derive(Debug, Clone, PartialEq, Eq)] +struct CatalogPlugin { + id: Uuid, + /// GTS base type of the plugin, absent when the caller only had ids to + /// hand over (see [`PluginCatalog::of`]). + plugin_type: Option, +} + +/// The plugins a caller may bind, as seen by [`Validator::validate_bindings`]. +/// +/// The [`Validator`] stays pure, so the caller (the control-plane service, the +/// only place that knows the tenant) hands in the catalogue instead of the +/// validator reaching into the registry. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PluginCatalog { + /// Custom plugins the calling tenant owns. + plugins: Vec, +} + +impl PluginCatalog { + /// Builds a catalogue from the custom plugin ids of the calling tenant. + /// + /// Only identities are recorded, so the *slot* of a binding is not checked + /// against the plugin's kind (see [`PluginSlot`]). Production callers build + /// the catalogue with [`PluginCatalog::of_plugins`], which carries the base + /// type and enables that check. + #[must_use] + pub fn of>(plugin_ids: I) -> Self { + Self { + plugins: plugin_ids + .into_iter() + .map(|id| CatalogPlugin { + id, + plugin_type: None, + }) + .collect(), + } + } + + /// Builds a catalogue from the plugins the calling tenant owns, recording + /// the base type each one was created as. + #[must_use] + pub fn of_plugins(plugins: &[Arc]) -> Self { + Self { + plugins: plugins + .iter() + .map(|plugin| CatalogPlugin { + id: plugin.id, + plugin_type: Some(plugin.plugin_type.clone()), + }) + .collect(), + } + } + + /// `true` when `reference` resolves to a bindable plugin: a builtin the + /// registry implements, or a custom plugin of the calling tenant in either + /// wire spelling (bare UUID or GTS-form id). + #[must_use] + pub fn resolves(&self, reference: &str) -> bool { + if is_builtin_plugin_ref(reference) { + return true; + } + self.custom_instance(reference).is_some() + } + + /// The GTS base type `reference` was registered as, when the catalogue can + /// tell. + /// + /// A built-in carries its base type inside its own id (the bare + /// instance-fragment spelling resolves through [`BUILTIN_PLUGIN_REFS`]); a + /// custom plugin carries the base type it was created with. `None` means + /// the reference does not resolve to a known plugin, or its kind is not + /// recorded, in which case the slot check cannot run. + #[must_use] + fn kind_of(&self, reference: &str) -> Option { + if let Some(base) = builtin_base_type(reference) { + return Some(base.to_owned()); + } + self.custom_instance(reference)?.1 + } + + /// The `(id, recorded base type)` of the custom plugin `reference` names, in + /// either wire spelling. + fn custom_instance(&self, reference: &str) -> Option<(Uuid, Option)> { + let instance = match Uuid::parse_str(reference) { + Ok(id) => id, + Err(_) => parse_plugin_id(reference)?, + }; + self.plugins + .iter() + .find(|plugin| plugin.id == instance) + .map(|plugin| (plugin.id, plugin.plugin_type.clone())) + } +} + +/// The slot a plugin binding sits in, and the plugin kinds that fit it. +/// +/// DESIGN §3.4 models the association as `Upstream "1" --> "0..1" Plugin` for +/// the *auth* binding and `Upstream/Route "1" --> "*" Plugin` for the chains, +/// and ADR-0002 types the plugins accordingly: an auth slot takes an +/// `auth_plugin`, a chain slot takes a `guard_plugin` or a `transform_plugin`. +/// The engine resolves the three kinds through separate registries, so a +/// reference bound in the wrong slot would only ever surface as a `503 +/// plugin.not_found` on the data plane; validating the kind at bind time turns +/// that into a `400` that names the slot instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginSlot { + /// The `auth` binding of an upstream. + Auth, + /// A binding of an upstream or route plugin chain. + Chain, +} + +impl PluginSlot { + /// Human-readable name of the slot, for the problem detail. + #[must_use] + fn as_str(self) -> &'static str { + match self { + Self::Auth => "auth", + Self::Chain => "plugins", + } + } + + /// The base types the slot accepts. + #[must_use] + fn accepts(self) -> &'static [&'static str] { + match self { + Self::Auth => &[crate::domain::plugin::AUTH_PLUGIN_TYPE_ID], + Self::Chain => &[ + crate::domain::plugin::GUARD_PLUGIN_TYPE_ID, + crate::domain::plugin::TRANSFORM_PLUGIN_TYPE_ID, + ], + } + } +} + +/// The GTS base type of a built-in plugin reference, in either spelling. +#[must_use] +fn builtin_base_type(reference: &str) -> Option<&'static str> { + BUILTIN_PLUGIN_REFS.iter().find_map(|gts_id| { + let (base, instance) = gts_id.split_once('~')?; + (*gts_id == reference || instance == reference).then_some(base) + }) +} + +/// GTS instance fragments of the built-in plugins that actually exist +/// ([`PluginRegistry::with_builtins`](crate::infra::plugin::PluginRegistry::with_builtins), +/// ADR-0002). +/// +/// The full GTS ids live in [`crate::domain::plugin::builtin`]; the fragment is +/// what an operator writes in a binding. The catalog-only identifiers (`basic`, +/// `bearer`, `timeout`, `cors`, `logging`, `metrics`) are deliberately absent: +/// they have no backing implementation, so binding one is a `400` here rather +/// than a `503` at proxy time. +pub const BUILTIN_PLUGIN_REFS: &[&str] = &[ + crate::domain::plugin::builtin::NOOP_AUTH, + crate::domain::plugin::builtin::APIKEY_AUTH, + crate::domain::plugin::builtin::OAUTH2_CLIENT_CRED, + crate::domain::plugin::builtin::OAUTH2_CLIENT_CRED_BASIC, + crate::domain::plugin::builtin::REQUIRED_HEADERS_GUARD, + crate::domain::plugin::builtin::REQUEST_ID_TRANSFORM, +]; + +/// `true` when `reference` names a built-in plugin, in the full GTS spelling or +/// in the bare instance-fragment spelling (`cf.core.oagw.apikey.v1`). +#[must_use] +fn is_builtin_plugin_ref(reference: &str) -> bool { + builtin_base_type(reference).is_some() +} + +/// `true` when `tag` matches the schema pattern `^[a-z0-9_-]+$`. +#[must_use] +fn is_valid_tag(tag: &str) -> bool { + !tag.is_empty() + && tag.bytes().all(|byte| { + byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-' + }) +} + +/// How a host was classified, used to decide whether an alias is derivable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum HostKind { + /// RFC 1123 hostname; the payload is the lowercased, trailing-dot-free + /// spelling used for alias derivation. + Hostname(String), + /// Dotted-quad IPv4 address. + Ipv4, + /// IPv6 address (bracketed or inline). + Ipv6, +} + +/// Identity of a route's match rule for uniqueness checks (DESIGN section 3.3: +/// "same path + priority + method -> 409"). +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum MatchKey { + /// HTTP match: path pattern plus the set of allowed methods. + Http { + /// Path pattern. + path: String, + /// Allowed methods, order-insensitive. + methods: BTreeSet, + }, + /// gRPC match: service plus method. + Grpc { + /// Fully qualified service name. + service: String, + /// RPC method name. + method: String, + }, +} + +/// Match-rule key of a route, used by both the service and the registry. +#[must_use] +pub fn route_match_key(route: &Route) -> MatchKey { + match (&route.r#match.http, &route.r#match.grpc) { + (Some(http), _) => MatchKey::Http { + path: http.path.clone(), + methods: http.methods.iter().copied().collect(), + }, + (None, Some(grpc)) => MatchKey::Grpc { + service: grpc.service.clone(), + method: grpc.method.clone(), + }, + // An unvalidated route cannot reach the store; a stable key keeps the + // registry total without inventing a third variant. + (None, None) => MatchKey::Grpc { + service: String::new(), + method: String::new(), + }, + } +} + +/// `true` when `scheme`/`port` is the scheme's standard port, which the derived +/// alias omits. +#[must_use] +pub const fn is_standard_port(scheme: Scheme, port: u16) -> bool { + match scheme { + Scheme::Http | Scheme::Ws => port == STANDARD_PLAINTEXT_PORT, + Scheme::Https | Scheme::Wss | Scheme::Wt | Scheme::Grpc => port == STANDARD_TLS_PORT, + } +} + +/// `true` when the scheme is accepted under the given configuration. +/// +/// `https`/`wss`/`wt`/`grpc` are always legal; `http`/`ws` are only legal when +/// the operator allows plaintext upstreams. +#[must_use] +pub const fn is_scheme_allowed(scheme: Scheme, allow_http_upstream: bool) -> bool { + match scheme { + Scheme::Http | Scheme::Ws => allow_http_upstream, + Scheme::Https | Scheme::Wss | Scheme::Wt | Scheme::Grpc => true, + } +} + +/// `true` when `alias` matches the upstream schema pattern +/// `^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$` and is not reserved. +/// +/// One alias is reserved: the `unmatched` literal the metrics fold every +/// unresolved request onto ([`crate::domain::metrics::UNMATCHED_HOST`]). An +/// upstream that claimed it would share a metric series with requests that +/// never resolved anywhere, so the label it reports could no longer be trusted. +#[must_use] +pub fn is_valid_alias(alias: &str) -> bool { + if alias == crate::domain::metrics::UNMATCHED_HOST { + return false; + } + let bytes = alias.as_bytes(); + let Some((&first, rest)) = bytes.split_first() else { + return false; + }; + let Some((&last, middle)) = rest.split_last() else { + return first.is_ascii_lowercase() || first.is_ascii_digit(); + }; + let is_edge = |byte: &u8| byte.is_ascii_lowercase() || byte.is_ascii_digit(); + let is_inner = |byte: &u8| is_edge(byte) || matches!(byte, b'.' | b':' | b'-'); + is_edge(&first) && is_edge(&last) && middle.iter().all(is_inner) +} + +/// Normalises an alias: ASCII lowercase, surrounding whitespace removed, +/// trailing dots stripped (DESIGN section 3.1 alias normalisation). +#[must_use] +pub fn normalize_alias(alias: &str) -> String { + alias + .trim() + .to_ascii_lowercase() + .trim_end_matches('.') + .to_owned() +} + +/// `true` when `candidate` looks like a GTS *type* identifier +/// (`gts...v1`, lower-case, no instance part). +#[must_use] +pub fn is_gts_type_id(candidate: &str) -> bool { + let Some(rest) = candidate.strip_prefix("gts.") else { + return false; + }; + if rest.is_empty() { + return false; + } + let shape_ok = candidate + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '.' | '_' | '-')); + shape_ok && rest.split('.').count() >= 3 +} + +/// Classifies and validates an endpoint host. +/// +/// # Errors +/// +/// Returns a human-readable reason when the host is not an RFC 1123 hostname, +/// an IPv4 address or an IPv6 literal — i.e. when it carries a port, path, +/// userinfo, whitespace, an empty label or a malformed IP literal. +pub fn classify_host(raw: &str) -> Result { + if raw.is_empty() { + return Err("host must not be empty".to_owned()); + } + if raw.chars().any(char::is_whitespace) { + return Err("host must not contain whitespace".to_owned()); + } + if let Some(bad) = raw.chars().find(|c| FORBIDDEN_HOST_CHARS.contains(c)) { + return Err(format!("host must not contain {bad:?}")); + } + + // A single trailing dot is FQDN notation and tolerated (DESIGN section 3.1). + let host = raw.strip_suffix('.').unwrap_or(raw); + if host.is_empty() { + return Err("host must not be empty".to_owned()); + } + if host.len() > MAX_HOSTNAME_LEN { + return Err(format!("host exceeds {MAX_HOSTNAME_LEN} characters")); + } + + if host.contains(':') { + // Bracketed or inline IPv6 only: a hostname may never carry a port. + let inner = host + .strip_prefix('[') + .and_then(|rest| rest.strip_suffix(']')) + .unwrap_or(host); + return Ipv6Addr::from_str(inner) + .map(|_parsed| HostKind::Ipv6) + .map_err(|_| "host must be an IPv6 literal without a port".to_owned()); + } + + let labels: Vec<&str> = host.split('.').collect(); + if labels + .iter() + .all(|label| !label.is_empty() && label.bytes().all(|byte| byte.is_ascii_digit())) + { + // All-numeric dotted form: only a valid IPv4 address is acceptable, so + // `256.1.1.1` is rejected instead of being read as a hostname. + if labels.len() != 4 { + return Err("all-numeric host must be a dotted-quad IPv4 address".to_owned()); + } + return Ipv4Addr::from_str(host) + .map(|_parsed| HostKind::Ipv4) + .map_err(|_| "host is not a valid IPv4 address".to_owned()); + } + + for label in &labels { + if label.is_empty() { + return Err("host must not contain empty labels".to_owned()); + } + if label.len() > MAX_LABEL_LEN { + return Err(format!("host label exceeds {MAX_LABEL_LEN} characters")); + } + let invalid = label + .bytes() + .find(|byte| !(byte.is_ascii_alphanumeric() || *byte == b'-')); + if let Some(byte) = invalid { + return Err(format!( + "host label contains the unsupported character {:?}", + byte as char + )); + } + if label.starts_with('-') || label.ends_with('-') { + return Err("host label must not start or end with a hyphen".to_owned()); + } + } + + Ok(HostKind::Hostname(host.to_ascii_lowercase())) +} + +/// Common suffix of several label lists, longest first, `None` when the hosts +/// share nothing with at least [`MIN_SUFFIX_LABELS`] labels or when the only +/// shared suffix is a bare public suffix. +fn common_suffix(hosts: &[Vec]) -> Option { + let shortest = hosts.iter().map(Vec::len).min()?; + let mut best: Option = None; + for count in (MIN_SUFFIX_LABELS..=shortest).rev() { + let candidate: &[String] = &hosts[0][hosts[0].len() - count..]; + let shared = hosts + .iter() + .all(|labels| labels[labels.len() - count..] == *candidate); + if shared { + best = Some(count); + break; + } + } + let count = best?; + let suffix = hosts[0][hosts[0].len() - count..].join("."); + // A bare public suffix (`co.uk`) is not a registrable domain, so it must + // not become a routing alias (DESIGN section 3.1). + if is_bare_public_suffix(&suffix) { + return None; + } + Some(suffix) +} + +/// `true` when the public-suffix list knows `candidate` as a public suffix and +/// it is not itself a registrable domain. +fn is_bare_public_suffix(candidate: &str) -> bool { + psl::suffix_str(candidate).is_some() && psl::domain_str(candidate).is_none() +} + +/// Deterministic textual rendering of the plugin definition returned by +/// `GET /oagw/v1/plugins/{id}/source`. +/// +/// Starlark-flavoured assignment lines plus a pretty-printed configuration +/// object, sorted by key so the rendering is byte-stable for byte-equal +/// definitions. +#[must_use] +pub fn render_plugin_source( + plugin_id: &str, + plugin_type: &str, + tenant_id: &Uuid, + enabled: bool, + tags: &[String], + config: &serde_json::Value, +) -> String { + let tags = tags + .iter() + .map(|tag| format!("{tag:?}")) + .collect::>() + .join(", "); + let mut lines = vec![ + format!("# plugin_id: {plugin_id}"), + format!("# tenant_id: {tenant_id}"), + format!("PLUGIN_TYPE = {plugin_type:?}"), + format!("ENABLED = {}", if enabled { "True" } else { "False" }), + format!("TAGS = [{tags}]"), + "CONFIG = ".to_owned(), + ]; + if let Ok(pretty) = serde_json::to_string_pretty(config) { + lines.push(pretty); + } else { + lines.push("{}".to_owned()); + } + let mut rendered = lines.join("\n"); + rendered.push('\n'); + rendered +} + +/// Applies the DESIGN section 3.1 / 3.3 validation rules. +#[derive(Debug, Clone)] +pub struct Validator { + config: OagwConfig, +} + +impl Validator { + /// Builds a validator bound to the effective gear configuration. + #[must_use] + pub const fn new(config: OagwConfig) -> Self { + Self { config } + } + + /// The configuration the validator enforces. + #[must_use] + pub const fn config(&self) -> &OagwConfig { + &self.config + } + + /// Validates the endpoint pool. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when the pool is empty, when any + /// endpoint host/scheme/port is invalid, or when the pool mixes schemes or + /// ports. + pub fn validate_pool(&self, endpoints: &[Endpoint]) -> Result<(), OagwError> { + if endpoints.is_empty() { + return Err(OagwError::validation( + "field `server.endpoints`: at least one endpoint is required", + )); + } + let first = &endpoints[0]; + for endpoint in endpoints { + self.validate_endpoint(endpoint)?; + if endpoint.scheme != first.scheme { + return Err(OagwError::validation(format!( + "field `server.endpoints`: every endpoint must use the same scheme; `{}` uses `{:?}` while the first uses `{:?}`", + endpoint.host, endpoint.scheme, first.scheme + )) + .with_host(endpoint.host.as_str())); + } + if endpoint.port != first.port { + return Err(OagwError::validation(format!( + "field `server.endpoints`: every endpoint must use the same port; `{}` uses {} while the first uses {}", + endpoint.host, endpoint.port, first.port + )) + .with_host(endpoint.host.as_str())); + } + } + Ok(()) + } + + /// Validates one endpoint (scheme gate, host shape, port range). + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] describing the offending field. + pub fn validate_endpoint(&self, endpoint: &Endpoint) -> Result<(), OagwError> { + if !is_scheme_allowed(endpoint.scheme, self.config.allow_http_upstream) { + return Err(OagwError::validation(format!( + "field `server.endpoints[].scheme`: scheme `{:?}` requires `allow_http_upstream: true`", + endpoint.scheme + )) + .with_host(endpoint.host.as_str()) + .with_invalid_value(format!("{:?}", endpoint.scheme).to_ascii_lowercase())); + } + if endpoint.port < MIN_PORT { + return Err(OagwError::validation(format!( + "field `server.endpoints[].port`: port must be between {MIN_PORT} and 65535" + )) + .with_host(endpoint.host.as_str()) + .with_invalid_value(endpoint.port.to_string())); + } + match classify_host(&endpoint.host) { + Ok(_) => Ok(()), + Err(reason) => Err(OagwError::validation(format!( + "field `server.endpoints[].host`: {reason}" + )) + .with_host(endpoint.host.as_str()) + .with_invalid_value(endpoint.host.as_str())), + } + } + + /// Derives the alias of an endpoint pool, `None` when the pool is not + /// derivable (IP endpoints, mixed hosts, bare-public-suffix pools). + /// + /// The caller must have validated the pool first + /// ([`Validator::validate_pool`]): the first endpoint supplies the scheme + /// and port the derivation consults. + #[must_use] + pub fn derive_alias(&self, endpoints: &[Endpoint]) -> Option { + let first = endpoints.first()?; + let standard = is_standard_port(first.scheme, first.port); + let mut hosts: Vec> = Vec::with_capacity(endpoints.len()); + for endpoint in endpoints { + match classify_host(&endpoint.host) { + Ok(HostKind::Hostname(host)) => { + hosts.push(host.split('.').map(ToOwned::to_owned).collect()); + } + _ => return None, + } + } + let core = if hosts.len() == 1 { + hosts[0].join(".") + } else { + common_suffix(&hosts)? + }; + Some(if standard { + core + } else { + format!("{core}:{}", first.port) + }) + } + + /// Resolves the alias of a *new* upstream. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when the pool is derivable and the + /// supplied alias differs from the derived value, when the pool is not + /// derivable and no (or an invalid) alias was supplied, or when the resolved + /// alias is not a valid one — a *derived* alias is checked too, because the + /// hostname the pool is derived from decides it and could otherwise name a + /// reserved word. + pub fn resolve_create_alias( + &self, + endpoints: &[Endpoint], + supplied: Option<&str>, + ) -> Result { + let derived = self.derive_alias(endpoints); + match derived { + Some(derived) => { + if let Some(supplied) = supplied.map(normalize_alias) + && supplied != derived + { + return Err(OagwError::validation(format!( + "field `alias`: hostname-based endpoints always derive the alias; expected `{derived}`" + )) + .with_alias(derived.as_str()) + .with_invalid_value(supplied)); + } + self.check_alias_shape(&derived)?; + Ok(derived) + } + None => { + let supplied = supplied.map(normalize_alias).ok_or_else(|| { + OagwError::validation( + "field `alias`: an explicit alias is required for IP-based or non-derivable endpoint pools", + ) + })?; + self.check_alias_shape(&supplied)?; + Ok(supplied) + } + } + } + + /// Resolves the alias of a *replaced* upstream, enforcing alias + /// immutability (DESIGN section 3.1 alias update behaviour). + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when the recomputed alias would + /// change, when the pool turns non-derivable, or when the supplied alias + /// differs from the derived/stored value. + pub fn resolve_replace_alias( + &self, + stored_alias: &str, + stored_endpoints: &[Endpoint], + endpoints: &[Endpoint], + supplied: Option<&str>, + ) -> Result { + let was_derivable = self.derive_alias(stored_endpoints).is_some(); + match self.derive_alias(endpoints) { + Some(derived) => { + if let Some(supplied) = supplied.map(normalize_alias) + && supplied != derived + { + return Err(OagwError::validation(format!( + "field `alias`: hostname-based endpoints always derive the alias; expected `{derived}`" + )) + .with_alias(derived.as_str()) + .with_invalid_value(supplied)); + } + if derived != stored_alias { + return Err(OagwError::validation(format!( + "field `alias`: the alias is immutable; these endpoints would derive `{derived}` but the upstream is registered as `{stored_alias}` — delete and re-create the upstream" + )) + .with_alias(stored_alias) + .with_invalid_value(derived)); + } + Ok(stored_alias.to_owned()) + } + None => { + if was_derivable { + return Err(OagwError::validation( + "field `alias`: replacing a derivable endpoint pool with a non-derivable one would change the alias — delete and re-create the upstream", + ) + .with_alias(stored_alias)); + } + if let Some(supplied) = supplied.map(normalize_alias) + && supplied != stored_alias + { + return Err(OagwError::validation(format!( + "field `alias`: the alias is immutable and cannot be overridden; the upstream is registered as `{stored_alias}`" + )) + .with_alias(stored_alias) + .with_invalid_value(supplied)); + } + Ok(stored_alias.to_owned()) + } + } + } + + /// Validates the alias pattern of a caller-supplied alias. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when the alias does not match the + /// upstream schema pattern, and when it is the reserved `unmatched` literal + /// the metrics fold unresolved requests onto. + pub fn check_alias_shape(&self, alias: &str) -> Result<(), OagwError> { + if alias == crate::domain::metrics::UNMATCHED_HOST { + return Err(OagwError::validation(format!( + "field `alias`: `{}` is a reserved word", + crate::domain::metrics::UNMATCHED_HOST + )) + .with_alias(alias) + .with_invalid_value(alias)); + } + if !is_valid_alias(alias) { + return Err(OagwError::validation( + "field `alias`: must match ^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$", + ) + .with_alias(alias) + .with_invalid_value(alias)); + } + Ok(()) + } + + /// Validates `cors.allow_credentials` against a wildcard origin. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when credentials are allowed while the + /// origin list contains `*` (the CORS schema's `if/then` rule). + pub fn validate_cors(&self, cors: &CorsConfig) -> Result<(), OagwError> { + if cors.allow_credentials && cors.allowed_origins.iter().any(|origin| origin == "*") { + return Err(OagwError::validation( + "field `cors`: `allow_credentials` cannot be combined with the wildcard origin `*`", + ) + .with_invalid_value("*")); + } + Ok(()) + } + + /// Validates a tag list (schema pattern `^[a-z0-9_-]+$`, deduplicated, at + /// most [`MAX_TAG_COUNT`] entries of at most [`MAX_TAG_LEN`] bytes). + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] naming the offending tag. + pub fn validate_tags(&self, tags: &[String], field: &str) -> Result<(), OagwError> { + if tags.len() > MAX_TAG_COUNT { + return Err(OagwError::validation(format!( + "field `{field}`: at most {MAX_TAG_COUNT} tags are allowed, got {}", + tags.len() + )) + .with_invalid_value(tags.len().to_string())); + } + for tag in tags { + if tag.len() > MAX_TAG_LEN { + return Err(OagwError::validation(format!( + "field `{field}`: a tag must be at most {MAX_TAG_LEN} characters" + )) + .with_invalid_value(tag.clone())); + } + if !is_valid_tag(tag) { + return Err(OagwError::validation(format!( + "field `{field}`: a tag must match ^[a-z0-9_-]+$" + )) + .with_invalid_value(tag.clone())); + } + } + Ok(()) + } + + /// Validates the rate-limit budget. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when `sustained.rate` or + /// `burst.capacity` is below `1`. + pub fn validate_rate_limit(&self, rate_limit: &RateLimitConfig) -> Result<(), OagwError> { + if rate_limit.sustained.rate < 1 { + return Err(OagwError::validation( + "field `rate_limit.sustained.rate`: must be at least 1", + ) + .with_invalid_value(rate_limit.sustained.rate.to_string())); + } + if let Some(capacity) = rate_limit.burst.as_ref().and_then(|burst| burst.capacity) + && capacity < 1 + { + return Err(OagwError::validation( + "field `rate_limit.burst.capacity`: must be at least 1", + ) + .with_invalid_value(capacity.to_string())); + } + Ok(()) + } + + /// Validates an upstream draft and returns the populated domain model. + /// + /// The returned model carries a nil `id`/`tenant_id` and epoch timestamps: + /// the control-plane service fills those in. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] for every rule listed in the module + /// documentation. + pub fn validate_upstream(&self, input: &UpstreamInput) -> Result { + let endpoints = &input.server.endpoints; + self.validate_pool(endpoints)?; + if let Some(rate_limit) = &input.rate_limit { + self.validate_rate_limit(rate_limit)?; + } + if let Some(cors) = &input.cors { + self.validate_cors(cors)?; + } + self.validate_tags(&input.tags, "tags")?; + let alias = self.resolve_create_alias(endpoints, input.alias.as_deref())?; + Ok(self.build_upstream(input, alias)) + } + + /// Validates an upstream *replace* against the stored resource and returns + /// the populated model (nil `id`/`tenant_id`). + /// + /// The alias is immutable, so it is resolved against the stored pool rather + /// than derived from scratch; everything else is a full replacement. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] for the same rules as + /// [`Validator::validate_upstream`] plus the alias-immutability rules + /// documented on [`Validator::resolve_replace_alias`]. + pub fn validate_upstream_replace( + &self, + existing: &Upstream, + input: &UpstreamInput, + ) -> Result { + let endpoints = &input.server.endpoints; + self.validate_pool(endpoints)?; + if let Some(rate_limit) = &input.rate_limit { + self.validate_rate_limit(rate_limit)?; + } + if let Some(cors) = &input.cors { + self.validate_cors(cors)?; + } + self.validate_tags(&input.tags, "tags")?; + let alias = self.resolve_replace_alias( + &existing.alias, + existing.endpoints(), + endpoints, + input.alias.as_deref(), + )?; + Ok(self.build_upstream(input, alias)) + } + + /// Assembles the upstream model of a validated draft. + fn build_upstream(&self, input: &UpstreamInput, alias: String) -> Upstream { + Upstream { + enabled: input.enabled.unwrap_or(true), + alias, + tags: input.tags.clone(), + server: ServerConfig { + endpoints: input.server.endpoints.clone(), + }, + protocol: input.protocol, + auth: input.auth.clone(), + headers: input.headers.clone(), + plugins: input.plugins.clone(), + rate_limit: input.rate_limit.clone(), + cors: input.cors.clone(), + ..Upstream::default() + } + } + + /// Validates a route draft against its owning upstream and returns the + /// populated domain model (nil `id`/`tenant_id`, epoch timestamps). + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when the match block is missing or + /// ambiguous, when its shape is invalid, when the rate-limit/CORS budgets + /// are invalid, or when the match protocol differs from the upstream + /// protocol. + pub fn validate_route( + &self, + upstream_id: Uuid, + upstream: &Upstream, + input: &RouteInput, + ) -> Result { + match (&input.r#match.http, &input.r#match.grpc) { + (Some(_), Some(_)) => { + return Err(OagwError::validation( + "field `match`: exactly one of `http` or `grpc` must be present", + ) + .with_upstream_id(upstream_id)); + } + (None, None) => { + return Err(OagwError::validation( + "field `match`: one of `http` or `grpc` must be present", + ) + .with_upstream_id(upstream_id)); + } + (Some(http), None) => { + self.validate_http_match(upstream_id, upstream, http)?; + } + (None, Some(grpc)) => { + self.validate_grpc_match(upstream_id, upstream, grpc)?; + } + } + if let Some(rate_limit) = &input.rate_limit { + self.validate_rate_limit(rate_limit)?; + } + if let Some(cors) = &input.cors { + self.validate_cors(cors)?; + } + self.validate_tags(&input.tags, "tags")?; + + Ok(Route { + upstream_id, + r#match: input.r#match.clone(), + headers: input.headers.clone(), + plugins: input.plugins.clone(), + rate_limit: input.rate_limit.clone(), + cors: input.cors.clone(), + enabled: input.enabled.unwrap_or(true), + priority: input.priority.unwrap_or(0), + tags: input.tags.clone(), + ..Route::default() + }) + } + + /// Validates the `match.http` block of a route. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] for an empty method list, an empty or + /// non-absolute path (the match is a path *prefix* of the request path, + /// which always starts with `/`) or a protocol mismatch with the owning + /// upstream. + fn validate_http_match( + &self, + upstream_id: Uuid, + upstream: &Upstream, + http: &crate::domain::model::HttpMatch, + ) -> Result<(), OagwError> { + if http.methods.is_empty() { + return Err(OagwError::validation( + "field `match.http.methods`: at least one method is required", + ) + .with_upstream_id(upstream_id)); + } + if http.path.is_empty() { + return Err( + OagwError::validation("field `match.http.path`: must not be empty") + .with_upstream_id(upstream_id), + ); + } + if !http.path.starts_with('/') { + return Err( + OagwError::validation("field `match.http.path`: must start with '/'") + .with_path(http.path.as_str()) + .with_upstream_id(upstream_id), + ); + } + if upstream.protocol != Protocol::Http { + return Err(OagwError::validation( + "field `match.http`: an HTTP match requires an upstream with the HTTP protocol", + ) + .with_path(http.path.as_str()) + .with_upstream_id(upstream_id)); + } + Ok(()) + } + + /// Validates the `match.grpc` block of a route. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] for empty `service`/`method` fields or + /// a protocol mismatch with the owning upstream. + fn validate_grpc_match( + &self, + upstream_id: Uuid, + upstream: &Upstream, + grpc: &crate::domain::model::GrpcMatch, + ) -> Result<(), OagwError> { + if grpc.service.is_empty() || grpc.method.is_empty() { + return Err(OagwError::validation( + "field `match.grpc`: `service` and `method` must not be empty", + ) + .with_upstream_id(upstream_id)); + } + if upstream.protocol != Protocol::Grpc { + return Err(OagwError::validation( + "field `match.grpc`: a gRPC match requires an upstream with the gRPC protocol", + ) + .with_upstream_id(upstream_id)); + } + Ok(()) + } + + /// Validates a plugin draft and returns the populated domain model (nil + /// `id`/`tenant_id`, epoch timestamps). + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when `plugin_type` is not a GTS type + /// identifier or when `config` is not a JSON object. + pub fn validate_plugin(&self, input: &PluginInput) -> Result { + let plugin_type = input.plugin_type.trim(); + if !is_gts_type_id(plugin_type) { + return Err(OagwError::validation( + "field `plugin_type`: must be a GTS type identifier such as `gts.cf.core.oagw.guard_plugin.v1`", + ) + .with_plugin_id(plugin_type) + .with_invalid_value(plugin_type)); + } + if !input.config.is_object() { + return Err( + OagwError::validation("field `config`: must be a JSON object") + .with_plugin_id(plugin_type) + .with_invalid_value("non-object configuration"), + ); + } + self.validate_tags(&input.tags, "tags")?; + + Ok(Plugin { + plugin_type: plugin_type.to_owned(), + config: input.config.clone(), + enabled: input.enabled.unwrap_or(true), + tags: input.tags.clone(), + ..Plugin::default() + }) + } + + /// Resolves every plugin reference of an upstream draft against `catalog`: + /// the `auth.type` auth plugin and the `plugins.items[].plugin_ref` chain. + /// + /// Both wire spellings of a reference are accepted (see + /// [`reference_matches_plugin`]): the canonical bare UUID of a custom + /// plugin, its GTS instance id, and the builtin plugin ids. A reference the + /// catalog cannot resolve is a **400 `ValidationError`**: the control plane + /// refuses the draft before it reaches the registry, because an + /// unresolvable binding would otherwise only surface as a 503 + /// `PluginNotFound` on the data plane, per request. + /// + /// The catalog is the calling tenant's own plugin set (see + /// `ControlPlaneService::plugin_catalog`): ancestor resolution is + /// asynchronous and is not plumbed into the pure validator, so a descendant + /// binds only its own custom plugins and the builtins. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] naming the unresolvable reference. + pub fn validate_bindings( + &self, + upstream: &Upstream, + catalog: &PluginCatalog, + ) -> Result<(), OagwError> { + if let Some(auth) = &upstream.auth { + self.validate_plugin_ref("auth.type", PluginSlot::Auth, &auth.auth_type, catalog)?; + } + for binding in &upstream.plugins.items { + self.validate_plugin_ref( + "plugins.items[].plugin_ref", + PluginSlot::Chain, + &binding.plugin_ref, + catalog, + )?; + } + Ok(()) + } + + /// Resolves every `plugins.items[].plugin_ref` of a route draft. + /// + /// # Errors + /// + /// Same rules as [`Validator::validate_bindings`]. + pub fn validate_route_bindings( + &self, + route: &Route, + catalog: &PluginCatalog, + ) -> Result<(), OagwError> { + for binding in &route.plugins.items { + self.validate_plugin_ref( + "plugins.items[].plugin_ref", + PluginSlot::Chain, + &binding.plugin_ref, + catalog, + )?; + } + Ok(()) + } + + /// Checks one reference against the catalog: it must resolve, and its kind + /// must fit the slot it is bound in. + /// + /// A reference the catalogue cannot place is a `400` naming it; a reference + /// that resolves but whose base type does not fit [`PluginSlot`] is a `400` + /// naming the reference *and* the kind the slot expects, because the data + /// plane would otherwise answer `503 PluginNotFound` for as long as the + /// binding stayed in place. + fn validate_plugin_ref( + &self, + field: &str, + slot: PluginSlot, + reference: &str, + catalog: &PluginCatalog, + ) -> Result<(), OagwError> { + let reference = reference.trim(); + if !catalog.resolves(reference) { + return Err(OagwError::validation(format!( + "field `{field}`: plugin `{reference}` is not registered for this tenant" + )) + .with_plugin_id(reference) + .with_invalid_value(reference)); + } + let Some(kind) = catalog.kind_of(reference) else { + return Ok(()); + }; + if !slot.accepts().contains(&kind.as_str()) { + return Err(OagwError::validation(format!( + "field `{field}`: plugin `{reference}` is a {kind} and cannot be bound in the {} \ + slot, which requires {}", + slot.as_str(), + slot.accepts().to_vec().join(" or ") + )) + .with_plugin_id(reference) + .with_invalid_value(reference)); + } + Ok(()) + } +} + +#[cfg(test)] +#[path = "validation_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/validation_tests.rs b/gears/system/oagw/oagw/src/domain/validation_tests.rs new file mode 100644 index 0000000..5701157 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/validation_tests.rs @@ -0,0 +1,1274 @@ +//! Tests for [`crate::domain::validation`]. + +use uuid::Uuid; + +use super::{ + MAX_HOSTNAME_LEN, MIN_PORT, MatchKey, UpstreamInput, Validator, classify_host, is_gts_type_id, + is_scheme_allowed, is_standard_port, is_valid_alias, normalize_alias, render_plugin_source, + route_match_key, +}; +use crate::config::OagwConfig; +use crate::domain::model::{ + CorsConfig, Endpoint, GrpcMatch, HttpMatch, HttpMethod, PathSuffixMode, Protocol, + RateLimitConfig, RateLimitWindow, Route, Scheme, SharingMode, SustainedRateConfig, +}; + +fn validator() -> Validator { + Validator::new(OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }) +} + +fn tls_validator() -> Validator { + Validator::new(OagwConfig::default()) +} + +fn endpoint(host: &str) -> Endpoint { + Endpoint { + scheme: Scheme::Https, + host: host.to_owned(), + port: 443, + } +} + +fn endpoint_with(host: &str, scheme: Scheme, port: u16) -> Endpoint { + Endpoint { + scheme, + host: host.to_owned(), + port, + } +} + +fn input(endpoints: Vec, alias: Option<&str>) -> UpstreamInput { + UpstreamInput { + alias: alias.map(ToOwned::to_owned), + enabled: None, + tags: Vec::new(), + server: crate::domain::model::ServerConfig { endpoints }, + protocol: Protocol::Http, + auth: None, + headers: crate::domain::model::HeadersConfig::default(), + plugins: crate::domain::model::PluginConfig::default(), + rate_limit: None, + cors: None, + } +} + +/// CORS configuration with only the fields under test set. +fn cors(allow_credentials: bool, origins: &[&str]) -> CorsConfig { + CorsConfig { + enabled: true, + sharing: SharingMode::Private, + allowed_origins: origins.iter().map(|origin| (*origin).to_owned()).collect(), + allowed_methods: Vec::new(), + allow_headers: Vec::new(), + expose_headers: Vec::new(), + allow_credentials, + max_age: None, + } +} + +/// Rate-limit budget with only the fields under test set. +fn rate_limit(rate: u64, burst_capacity: Option) -> RateLimitConfig { + RateLimitConfig { + sharing: SharingMode::Private, + algorithm: crate::domain::model::RateLimitAlgorithm::TokenBucket, + sustained: SustainedRateConfig { + rate, + window: RateLimitWindow::Second, + }, + burst: burst_capacity.map(|capacity| crate::domain::model::BurstConfig { + capacity: Some(capacity), + }), + scope: crate::domain::model::RateLimitScope::Tenant, + strategy: crate::domain::model::RateLimitStrategy::Reject, + response_headers: true, + cost: 1, + } +} + +// -- alias derivation table (DESIGN section 3.1) ----------------------------- + +#[test] +fn single_hostname_on_a_standard_port_derives_the_hostname() { + let derived = validator().derive_alias(&[endpoint("api.openai.com")]); + assert_eq!(derived.as_deref(), Some("api.openai.com")); +} + +#[test] +fn single_hostname_on_a_non_standard_port_derives_hostname_port() { + let derived = validator().derive_alias(&[endpoint_with("api.openai.com", Scheme::Https, 8443)]); + assert_eq!(derived.as_deref(), Some("api.openai.com:8443")); +} + +#[test] +fn multi_host_pool_with_common_suffix_derives_the_suffix() { + let derived = validator().derive_alias(&[endpoint("us.vendor.com"), endpoint("eu.vendor.com")]); + assert_eq!(derived.as_deref(), Some("vendor.com")); +} + +#[test] +fn multi_host_pool_with_suffix_and_non_standard_port_keeps_the_port() { + let pool = vec![ + endpoint_with("us.vendor.com", Scheme::Https, 8443), + endpoint_with("eu.vendor.com", Scheme::Https, 8443), + ]; + let derived = validator().derive_alias(&pool); + assert_eq!(derived.as_deref(), Some("vendor.com:8443")); +} + +#[test] +fn plaintext_standard_port_is_80_not_443() { + let pool = vec![endpoint_with("api.example.com", Scheme::Http, 80)]; + assert_eq!( + validator().derive_alias(&pool).as_deref(), + Some("api.example.com") + ); + let non_standard = vec![endpoint_with("api.example.com", Scheme::Http, 8080)]; + assert_eq!( + validator().derive_alias(&non_standard).as_deref(), + Some("api.example.com:8080") + ); +} + +#[test] +fn ip_endpoints_are_not_derivable() { + let ipv4 = vec![endpoint("10.0.1.1"), endpoint("10.0.1.2")]; + assert!(validator().derive_alias(&ipv4).is_none()); + let ipv6 = vec![endpoint("2001:db8::1")]; + assert!(validator().derive_alias(&ipv6).is_none()); + let mixed = vec![endpoint("api.example.com"), endpoint("10.0.1.2")]; + assert!(validator().derive_alias(&mixed).is_none()); +} + +#[test] +fn bare_public_suffix_pools_are_not_derivable() { + let pool = vec![endpoint("foo.co.uk"), endpoint("bar.co.uk")]; + assert!( + validator().derive_alias(&pool).is_none(), + "co.uk is a public suffix" + ); +} + +#[test] +fn pools_without_a_common_suffix_are_not_derivable() { + let pool = vec![endpoint("us.foo.com"), endpoint("eu.bar.com")]; + assert!(validator().derive_alias(&pool).is_none()); +} + +#[test] +fn identical_hosts_derive_the_host() { + let pool = vec![endpoint("api.example.com"), endpoint("api.example.com")]; + assert_eq!( + validator().derive_alias(&pool).as_deref(), + Some("api.example.com") + ); +} + +#[test] +fn nested_common_suffix_picks_the_longest_shared_tail() { + let pool = vec![ + endpoint("a.b.vendor.com"), + endpoint("c.b.vendor.com"), + endpoint("d.b.vendor.com"), + ]; + assert_eq!( + validator().derive_alias(&pool).as_deref(), + Some("b.vendor.com") + ); +} + +#[test] +fn user_alias_must_equal_the_derived_value() { + let pool = vec![endpoint("api.openai.com")]; + let error = validator() + .resolve_create_alias(&pool, Some("openai")) + .expect_err("differing alias is rejected"); + assert!( + matches!(error, crate::domain::error::OagwError::Validation(_)), + "{error:?}" + ); + assert_eq!(error.context().alias.as_deref(), Some("api.openai.com")); + assert_eq!(error.context().invalid_value.as_deref(), Some("openai")); + assert!(error.detail().contains("api.openai.com"), "{error}"); + + // The exact derived value is accepted silently (idempotent), including a + // case/spelling variant that normalises to it. + assert_eq!( + validator() + .resolve_create_alias(&pool, Some("api.openai.com")) + .expect("exact"), + "api.openai.com" + ); + assert_eq!( + validator() + .resolve_create_alias(&pool, Some("Api.OpenAI.COM.")) + .expect("normalised"), + "api.openai.com" + ); +} + +#[test] +fn non_derivable_pools_require_an_explicit_alias() { + let pool = vec![endpoint("10.0.1.1")]; + let missing = validator().resolve_create_alias(&pool, None); + assert!(missing.is_err(), "alias is required"); + + let supplied = validator().resolve_create_alias(&pool, Some("my-service")); + assert_eq!(supplied.expect("alias accepted"), "my-service"); + + let invalid = validator().resolve_create_alias(&pool, Some("Bad Alias!")); + assert!(invalid.is_err(), "alias pattern is enforced"); +} + +// -- alias pattern + normalisation ------------------------------------------- + +#[test] +fn alias_pattern_is_enforced() { + for accepted in [ + "a", + "api", + "api.openai.com", + "vendor.com:8443", + "my-service", + "0abc", + ] { + assert!(is_valid_alias(accepted), "{accepted} must be accepted"); + } + for rejected in [ + "", "-api", "api-", "Api", "api .com", "api com", "api/com", "api_com", ".api", "api.", + "a b", "@", "*", + ] { + assert!(!is_valid_alias(rejected), "{rejected} must be rejected"); + } +} + +#[test] +fn alias_normalisation_lowercases_and_strips_trailing_dots() { + assert_eq!(normalize_alias("Api.OpenAI.COM."), "api.openai.com"); + assert_eq!(normalize_alias(" Vendor.COM "), "vendor.com"); + assert_eq!(normalize_alias("already-normal"), "already-normal"); +} + +/// `unmatched` is the literal the metrics fold every unresolved request onto +/// ([`crate::domain::metrics::UNMATCHED_HOST`]), so no upstream may claim it: +/// its in-flight series would be indistinguishable from a request that resolved +/// to nothing. +#[test] +fn the_metrics_fold_literal_is_a_reserved_alias() { + assert!(!is_valid_alias(crate::domain::metrics::UNMATCHED_HOST)); + + let error = validator() + .check_alias_shape(crate::domain::metrics::UNMATCHED_HOST) + .expect_err("reserved alias is rejected"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST); + assert!( + error.detail().contains("reserved"), + "the error must name the reservation: {}", + error.detail() + ); + assert_eq!( + error.context().alias.as_deref(), + Some(crate::domain::metrics::UNMATCHED_HOST) + ); + + // The reservation is an alias-level rule, so a hostname-based pool that + // would derive it is rejected the same way through the create path. + let derived = validator().resolve_create_alias(&[endpoint("unmatched")], None); + assert!(derived.is_err(), "a derived reserved alias is rejected too"); +} + +// -- hostname / IP validation ------------------------------------------------- + +#[test] +fn valid_hosts_are_classified() { + let cases = [ + ( + "api.openai.com", + super::HostKind::Hostname("api.openai.com".to_owned()), + ), + ( + "API.OpenAI.COM", + super::HostKind::Hostname("api.openai.com".to_owned()), + ), + ( + "api.openai.com.", + super::HostKind::Hostname("api.openai.com".to_owned()), + ), + ( + "localhost", + super::HostKind::Hostname("localhost".to_owned()), + ), + ( + "a-b.c-d.example", + super::HostKind::Hostname("a-b.c-d.example".to_owned()), + ), + ( + "xn--80ak6aa92e.com", + super::HostKind::Hostname("xn--80ak6aa92e.com".to_owned()), + ), + ("10.0.1.1", super::HostKind::Ipv4), + ("255.255.255.255", super::HostKind::Ipv4), + ("2001:db8::1", super::HostKind::Ipv6), + ("[2001:db8::1]", super::HostKind::Ipv6), + ("::1", super::HostKind::Ipv6), + ]; + for (host, expected) in cases { + assert_eq!(classify_host(host).expect(host), expected, "{host}"); + } +} + +#[test] +fn invalid_hosts_are_rejected() { + let rejected = [ + "", + " ", + "api .com", + "api..com", + ".api.com", + "api.com:443", + "[2001:db8::1]:443", + "api.com/v1", + "api.com?x=1", + "user@api.com", + "api%20.com", + "api.com\\x", + "-api.com", + "api-.com", + "256.1.1.1", + "1.2.3.4.5", + "1.2.3", + "2001:db8::1::2", + "2001:db8::1:", + ]; + for host in rejected { + assert!(classify_host(host).is_err(), "{host} must be rejected"); + } +} + +#[test] +fn hostname_length_limits_are_enforced() { + let long_label = "a".repeat(64); + assert!( + classify_host(&format!("api.{long_label}.com")).is_err(), + "label > 63" + ); + let ok_label = "a".repeat(63); + assert!(classify_host(&format!("api.{ok_label}.com")).is_ok()); + + let mut long_host = String::new(); + while long_host.len() < MAX_HOSTNAME_LEN { + long_host.push_str("ab."); + } + assert!(classify_host(&long_host).is_err(), "host > 253"); +} + +// -- scheme gating, ports and pool consistency -------------------------------- + +#[test] +fn plaintext_schemes_are_gated_by_the_configuration() { + for scheme in [Scheme::Https, Scheme::Wss, Scheme::Wt, Scheme::Grpc] { + assert!( + is_scheme_allowed(scheme, false), + "{scheme:?} is always allowed" + ); + assert!(is_scheme_allowed(scheme, true)); + } + assert!(!is_scheme_allowed(Scheme::Http, false)); + assert!(!is_scheme_allowed(Scheme::Ws, false)); + assert!(is_scheme_allowed(Scheme::Http, true)); + assert!(is_scheme_allowed(Scheme::Ws, true)); +} + +#[test] +fn http_scheme_is_rejected_when_plaintext_upstreams_are_not_allowed() { + let strict = tls_validator(); + let error = strict + .validate_endpoint(&endpoint_with("api.example.com", Scheme::Http, 443)) + .expect_err("http requires allow_http_upstream"); + assert!(error.detail().contains("allow_http_upstream"), "{error}"); + assert!( + error.detail().contains("server.endpoints[].scheme"), + "{error}" + ); + + // The same endpoint validates once the operator allows plaintext. + assert!( + validator() + .validate_endpoint(&endpoint_with("api.example.com", Scheme::Http, 80)) + .is_ok() + ); + assert!( + tls_validator() + .validate_endpoint(&endpoint_with("api.example.com", Scheme::Wss, 443)) + .is_ok() + ); +} + +#[test] +fn port_zero_is_rejected() { + let error = validator() + .validate_endpoint(&endpoint_with("api.example.com", Scheme::Https, 0)) + .expect_err("port 0 is invalid"); + assert_eq!(MIN_PORT, 1); + assert!(error.detail().contains("port"), "{error}"); +} + +#[test] +fn pool_must_share_scheme_and_port() { + let mixed_scheme = vec![ + endpoint_with("us.vendor.com", Scheme::Https, 443), + endpoint_with("eu.vendor.com", Scheme::Wss, 443), + ]; + let error = validator() + .validate_pool(&mixed_scheme) + .expect_err("scheme mismatch"); + assert!(error.detail().contains("same scheme"), "{error}"); + + let mixed_port = vec![ + endpoint_with("us.vendor.com", Scheme::Https, 443), + endpoint_with("eu.vendor.com", Scheme::Https, 8443), + ]; + let error = validator() + .validate_pool(&mixed_port) + .expect_err("port mismatch"); + assert!(error.detail().contains("same port"), "{error}"); + + let empty: Vec = Vec::new(); + let error = validator() + .validate_pool(&empty) + .expect_err("pool must not be empty"); + assert!(error.detail().contains("at least one endpoint"), "{error}"); + + let consistent = vec![ + endpoint_with("us.vendor.com", Scheme::Https, 8443), + endpoint_with("eu.vendor.com", Scheme::Https, 8443), + ]; + assert!(validator().validate_pool(&consistent).is_ok()); +} + +// -- CORS and rate-limit budgets ---------------------------------------------- + +#[test] +fn allow_credentials_rejects_the_wildcard_origin() { + assert!( + validator() + .validate_cors(&cors(true, &["https://app.example.com"])) + .is_ok() + ); + + let error = validator() + .validate_cors(&cors(true, &["*"])) + .expect_err("wildcard origin with credentials"); + assert!(error.detail().contains("allow_credentials"), "{error}"); + + assert!( + validator().validate_cors(&cors(false, &["*"])).is_ok(), + "wildcard without credentials is fine" + ); +} + +#[test] +fn rate_limit_budget_requires_positive_rates() { + assert!( + validator() + .validate_rate_limit(&rate_limit(1, None)) + .is_ok() + ); + assert!( + validator() + .validate_rate_limit(&rate_limit(10, Some(20))) + .is_ok() + ); + + let error = validator() + .validate_rate_limit(&rate_limit(0, None)) + .expect_err("rate 0"); + assert!(error.detail().contains("sustained.rate"), "{error}"); + + let error = validator() + .validate_rate_limit(&rate_limit(10, Some(0))) + .expect_err("capacity 0"); + assert!(error.detail().contains("burst.capacity"), "{error}"); +} + +// -- alias immutability on replace (DESIGN section 3.1 update matrix) ---------- + +fn stored(endpoints: Vec, alias: &str) -> crate::domain::model::Upstream { + crate::domain::model::Upstream { + alias: alias.to_owned(), + server: crate::domain::model::ServerConfig { endpoints }, + ..crate::domain::model::Upstream::default() + } +} + +fn replace(endpoints: Vec, alias: Option<&str>) -> UpstreamInput { + input(endpoints, alias) +} + +#[test] +fn derivable_pool_keeps_its_alias_while_endpoints_change() { + let existing = stored(vec![endpoint("api.openai.com")], "api.openai.com"); + let draft = replace( + vec![endpoint("eu.api.openai.com"), endpoint("us.api.openai.com")], + None, + ); + let replaced = validator() + .validate_upstream_replace(&existing, &draft) + .expect("same derived alias"); + assert_eq!(replaced.alias, "api.openai.com"); + + // A pool whose recomputed alias differs is rejected. + let draft = replace(vec![endpoint("api.vendor.com")], None); + let error = validator() + .validate_upstream_replace(&existing, &draft) + .expect_err("alias would change"); + assert!(error.detail().contains("immutable"), "{error}"); + assert_eq!(error.context().alias.as_deref(), Some("api.openai.com")); +} + +#[test] +fn derivable_pool_cannot_become_non_derivable() { + let existing = stored(vec![endpoint("api.openai.com")], "api.openai.com"); + let draft = replace(vec![endpoint("10.0.0.1")], Some("api.openai.com")); + let error = validator() + .validate_upstream_replace(&existing, &draft) + .expect_err("pool turns non-derivable"); + assert!(error.detail().contains("non-derivable"), "{error}"); +} + +#[test] +fn non_derivable_pool_keeps_an_explicit_alias() { + let existing = stored(vec![endpoint("10.0.0.1")], "payments"); + let draft = replace(vec![endpoint("10.0.0.2")], None); + let replaced = validator() + .validate_upstream_replace(&existing, &draft) + .expect("alias is kept"); + assert_eq!(replaced.alias, "payments"); + + let draft = replace(vec![endpoint("10.0.0.2")], Some("other")); + let error = validator() + .validate_upstream_replace(&existing, &draft) + .expect_err("alias is immutable"); + assert!(error.detail().contains("cannot be overridden"), "{error}"); +} + +#[test] +fn non_derivable_pool_cannot_become_derivable_with_a_new_alias() { + let existing = stored(vec![endpoint("10.0.0.1")], "payments"); + let draft = replace(vec![endpoint("api.openai.com")], None); + let error = validator() + .validate_upstream_replace(&existing, &draft) + .expect_err("derived alias differs from the stored one"); + assert!(error.detail().contains("immutable"), "{error}"); +} + +#[test] +fn validate_upstream_defaults_enabled_and_derives_the_alias() { + let draft = input(vec![endpoint("api.openai.com")], None); + let model = validator().validate_upstream(&draft).expect("valid"); + assert!(model.enabled, "enabled defaults to true"); + assert_eq!(model.alias, "api.openai.com"); + assert!(model.id.is_nil(), "id is server-generated"); + assert!(model.tenant_id.is_nil(), "tenant_id is server-generated"); +} + +#[test] +fn validate_upstream_rejects_an_invalid_pool_before_the_alias() { + let draft = input(vec![endpoint("10.0.1.1")], None); + let error = validator() + .validate_upstream(&draft) + .expect_err("alias required"); + assert!(error.detail().contains("explicit alias"), "{error}"); + + let draft = input(Vec::new(), Some("payments")); + let error = validator() + .validate_upstream(&draft) + .expect_err("empty pool"); + assert!(error.detail().contains("at least one endpoint"), "{error}"); +} + +// -- route validation ----------------------------------------------------------- + +fn http_match(path: &str, methods: &[HttpMethod]) -> HttpMatch { + HttpMatch { + methods: methods.to_vec(), + path: path.to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: PathSuffixMode::Append, + } +} + +fn upstream(protocol: Protocol) -> crate::domain::model::Upstream { + crate::domain::model::Upstream { + alias: "api.example.com".to_owned(), + protocol, + ..crate::domain::model::Upstream::default() + } +} + +fn route_input(r#match: crate::domain::model::RouteMatch) -> super::RouteInput { + super::RouteInput { + r#match, + headers: crate::domain::model::HeadersConfig::default(), + plugins: crate::domain::model::PluginConfig::default(), + rate_limit: None, + cors: None, + enabled: None, + priority: Some(3), + tags: Vec::new(), + } +} + +#[test] +fn validate_route_requires_exactly_one_match_variant() { + let owner = upstream(Protocol::Http); + let empty = route_input(crate::domain::model::RouteMatch::default()); + let error = validator() + .validate_route(Uuid::from_u128(1), &owner, &empty) + .expect_err("match required"); + assert!( + error.detail().contains("one of `http` or `grpc`"), + "{error}" + ); + + let both = crate::domain::model::RouteMatch { + http: Some(http_match("/v1", &[HttpMethod::Get])), + grpc: Some(GrpcMatch { + service: "svc.v1.Svc".to_owned(), + method: "Get".to_owned(), + }), + }; + let error = validator() + .validate_route(Uuid::from_u128(1), &owner, &route_input(both)) + .expect_err("ambiguous match"); + assert!(error.detail().contains("exactly one"), "{error}"); +} + +#[test] +fn validate_route_enforces_match_shape_and_protocol() { + let owner = upstream(Protocol::Http); + let no_methods = route_input(crate::domain::model::RouteMatch { + http: Some(http_match("/v1", &[])), + grpc: None, + }); + let error = validator() + .validate_route(Uuid::from_u128(1), &owner, &no_methods) + .expect_err("methods required"); + assert!(error.detail().contains("at least one method"), "{error}"); + + let empty_path = route_input(crate::domain::model::RouteMatch { + http: Some(http_match("", &[HttpMethod::Get])), + grpc: None, + }); + let error = validator() + .validate_route(Uuid::from_u128(1), &owner, &empty_path) + .expect_err("path required"); + assert!(error.detail().contains("path"), "{error}"); + + let relative_path = route_input(crate::domain::model::RouteMatch { + http: Some(http_match("v1/orders", &[HttpMethod::Get])), + grpc: None, + }); + let error = validator() + .validate_route(Uuid::from_u128(1), &owner, &relative_path) + .expect_err("path must be rooted"); + assert!(error.detail().contains("must start with '/'"), "{error}"); + assert_eq!( + error.context().path.as_deref(), + Some("v1/orders"), + "{error}" + ); + + let grpc_on_http_upstream = route_input(crate::domain::model::RouteMatch { + http: None, + grpc: Some(GrpcMatch { + service: "svc.v1.Svc".to_owned(), + method: "Get".to_owned(), + }), + }); + let error = validator() + .validate_route(Uuid::from_u128(1), &owner, &grpc_on_http_upstream) + .expect_err("protocol mismatch"); + assert!(error.detail().contains("gRPC"), "{error}"); + + let grpc_owner = upstream(Protocol::Grpc); + let ok = validator().validate_route(Uuid::from_u128(1), &grpc_owner, &grpc_on_http_upstream); + assert!(ok.is_ok(), "{ok:?}"); + + let http_on_grpc_upstream = route_input(crate::domain::model::RouteMatch { + http: Some(http_match("/v1", &[HttpMethod::Get])), + grpc: None, + }); + let error = validator() + .validate_route(Uuid::from_u128(1), &grpc_owner, &http_on_grpc_upstream) + .expect_err("protocol mismatch"); + assert!(error.detail().contains("HTTP"), "{error}"); +} + +#[test] +fn route_match_key_ignores_method_order_and_optional_fields() { + let mut route = Route { + r#match: crate::domain::model::RouteMatch { + http: Some(http_match("/v1/pay", &[HttpMethod::Get, HttpMethod::Post])), + grpc: None, + }, + priority: 4, + ..Route::default() + }; + let mut reordered = route.clone(); + reordered.r#match.http.as_mut().expect("http").methods = + vec![HttpMethod::Post, HttpMethod::Get]; + assert_eq!(route_match_key(&route), route_match_key(&reordered)); + + reordered + .r#match + .http + .as_mut() + .expect("http") + .query_allowlist = vec!["q".to_owned()]; + assert_eq!( + route_match_key(&route), + route_match_key(&reordered), + "query allowlist is not part of the key" + ); + + reordered.priority = 9; + assert_eq!( + route_match_key(&route), + route_match_key(&reordered), + "priority is compared separately from the match rule" + ); + + route.r#match.http.as_mut().expect("http").path = "/v2/pay".to_owned(); + assert_ne!(route_match_key(&route), route_match_key(&reordered)); + + reordered.r#match.http.as_mut().expect("http").path = "/v2/pay".to_owned(); + reordered.r#match.http.as_mut().expect("http").methods = vec![HttpMethod::Get]; + assert_ne!( + route_match_key(&route), + route_match_key(&reordered), + "a different method set is a different match rule" + ); +} + +#[test] +fn route_match_key_covers_grpc_matches() { + let route = Route { + r#match: crate::domain::model::RouteMatch { + http: None, + grpc: Some(GrpcMatch { + service: "svc.v1.Svc".to_owned(), + method: "Get".to_owned(), + }), + }, + priority: 0, + ..Route::default() + }; + assert_eq!( + route_match_key(&route), + MatchKey::Grpc { + service: "svc.v1.Svc".to_owned(), + method: "Get".to_owned(), + } + ); +} + +// -- plugin validation ---------------------------------------------------------- + +#[test] +fn validate_plugin_defaults_enabled_and_requires_a_gts_type() { + let draft = super::PluginInput { + plugin_type: "gts.cf.core.oagw.guard_plugin.v1".to_owned(), + config: serde_json::json!({"headers": ["x-request-id"]}), + enabled: None, + tags: vec!["guard".to_owned()], + }; + let model = validator().validate_plugin(&draft).expect("valid"); + assert!(model.enabled, "enabled defaults to true"); + assert!(model.id.is_nil()); + assert_eq!(model.plugin_type, "gts.cf.core.oagw.guard_plugin.v1"); + + for rejected in [ + "", + "guard", + "cf.core.oagw.guard_plugin.v1", + "GTS.cf.core.x.v1", + ] { + let draft = super::PluginInput { + plugin_type: rejected.to_owned(), + config: serde_json::json!({}), + enabled: None, + tags: Vec::new(), + }; + assert!( + validator().validate_plugin(&draft).is_err(), + "{rejected} must be rejected" + ); + } +} + +#[test] +fn validate_plugin_requires_an_object_config() { + let draft = super::PluginInput { + plugin_type: "gts.cf.core.oagw.guard_plugin.v1".to_owned(), + config: serde_json::json!([1, 2, 3]), + enabled: None, + tags: Vec::new(), + }; + let error = validator() + .validate_plugin(&draft) + .expect_err("array config"); + assert!(error.detail().contains("`config`"), "{error}"); +} + +#[test] +fn gts_type_ids_are_recognised() { + assert!(is_gts_type_id("gts.cf.core.oagw.guard_plugin.v1")); + assert!(is_gts_type_id("gts.cf.core.oagw.auth_plugin.v1")); + assert!(!is_gts_type_id("gts.cf.v1")); + assert!(!is_gts_type_id("cf.core.oagw.guard_plugin.v1")); + assert!(!is_gts_type_id("gts.CF.Core.v1")); + assert!(!is_gts_type_id( + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.apikey.v1" + )); +} + +// -- standard ports and plugin source rendering --------------------------------- + +#[test] +fn standard_ports_follow_the_scheme() { + assert!(is_standard_port(Scheme::Https, 443)); + assert!(is_standard_port(Scheme::Wss, 443)); + assert!(is_standard_port(Scheme::Wt, 443)); + assert!(is_standard_port(Scheme::Grpc, 443)); + assert!(is_standard_port(Scheme::Http, 80)); + assert!(is_standard_port(Scheme::Ws, 80)); + assert!(!is_standard_port(Scheme::Https, 8443)); + assert!(!is_standard_port(Scheme::Http, 8080)); +} + +#[test] +fn plugin_source_is_deterministic_and_sorted() { + let tenant = Uuid::from_u128(0x77); + let first = render_plugin_source( + "gts.cf.core.oagw.guard_plugin.v1~00000000-0000-0000-0000-000000000004", + "gts.cf.core.oagw.guard_plugin.v1", + &tenant, + true, + &["guard".to_owned()], + &serde_json::json!({"z": 1, "a": {"b": 2}}), + ); + let second = render_plugin_source( + "gts.cf.core.oagw.guard_plugin.v1~00000000-0000-0000-0000-000000000004", + "gts.cf.core.oagw.guard_plugin.v1", + &tenant, + true, + &["guard".to_owned()], + &serde_json::json!({"a": {"b": 2}, "z": 1}), + ); + assert_eq!(first, second, "key order must not matter"); + assert!( + first.contains("PLUGIN_TYPE = \"gts.cf.core.oagw.guard_plugin.v1\""), + "{first}" + ); + assert!(first.contains("ENABLED = True"), "{first}"); + assert!(first.contains("TAGS = [\"guard\"]"), "{first}"); + assert!( + first.starts_with("# plugin_id: gts.cf.core.oagw.guard_plugin.v1~"), + "{first}" + ); +} + +#[test] +fn standard_errors_carry_oagw_context() { + // Spot check that the errors raised by the validator keep the DESIGN + // section 3.3 shape (400 + the GTS validation type). + let error = validator() + .resolve_create_alias(&[endpoint("10.0.0.1")], None) + .expect_err("alias required"); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + assert_eq!( + error.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); + assert_eq!(error.context().alias, None, "no alias was derived"); +} + +// -- tag validation (schema pattern `^[a-z0-9_-]+$`) ---------------------------- + +#[test] +fn tags_must_match_the_schema_pattern_and_bounded_counts() { + let v = validator(); + let error = v + .validate_tags(&["Bad Tag".to_owned()], "tags") + .expect_err("uppercase and space"); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + assert!(error.detail().contains("^[a-z0-9_-]+$"), "{error}"); + assert_eq!(error.context().invalid_value.as_deref(), Some("Bad Tag")); + + let long = "a".repeat(65); + let error = v + .validate_tags(std::slice::from_ref(&long), "tags") + .expect_err("too long"); + assert!(error.detail().contains("64"), "{error}"); + + let too_many: Vec = (0..33).map(|index| format!("tag-{index}")).collect(); + let error = v.validate_tags(&too_many, "tags").expect_err("too many"); + assert!(error.detail().contains("at most 32 tags"), "{error}"); + + v.validate_tags(&["payments_eu-1".to_owned()], "tags") + .expect("pattern accepted"); + v.validate_tags(&[], "tags").expect("empty is fine"); +} + +#[test] +fn tags_are_validated_on_every_resource_kind() { + let v = validator(); + let mut draft = input(vec![endpoint("api.openai.com")], Some("api.openai.com")); + draft.tags = vec!["NOT VALID".to_owned()]; + let error = v.validate_upstream(&draft).expect_err("upstream tags"); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + + let mut plugin = super::PluginInput { + plugin_type: "gts.cf.core.oagw.guard_plugin.v1".to_owned(), + config: serde_json::json!({}), + enabled: None, + tags: vec!["NOT VALID".to_owned()], + }; + let error = v.validate_plugin(&plugin).expect_err("plugin tags"); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + + let mut route = route_input(crate::domain::model::RouteMatch { + http: Some(http_match("/v1", &[HttpMethod::Get])), + grpc: None, + }); + route.tags = vec!["NOT VALID".to_owned()]; + let error = v + .validate_route(Uuid::from_u128(1), &upstream(Protocol::Http), &route) + .expect_err("route tags"); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + + plugin.tags = vec!["ok_tag".to_owned()]; + v.validate_plugin(&plugin).expect("valid plugin tags"); +} + +// -- plugin binding resolution --------------------------------------------------- + +fn catalog(ids: &[u128]) -> super::PluginCatalog { + super::PluginCatalog::of(ids.iter().map(|id| Uuid::from_u128(*id))) +} + +#[test] +fn a_builtin_reference_resolves_in_both_spellings() { + let catalog = catalog(&[]); + assert!(catalog.resolves(crate::domain::plugin::builtin::APIKEY_AUTH)); + let fragment = crate::domain::plugin::builtin::APIKEY_AUTH + .rsplit('~') + .next() + .expect("fragment"); + assert!(catalog.resolves(fragment), "{fragment}"); + assert!( + !catalog.resolves("cf.core.oagw.logging.v1"), + "catalog-only id" + ); +} + +#[test] +fn a_custom_reference_resolves_as_a_bare_uuid_and_as_a_gts_id() { + let id = Uuid::from_u128(0xC1); + let catalog = catalog(&[0xC1]); + assert!(catalog.resolves(&id.to_string())); + assert!(catalog.resolves(&format!("gts.cf.core.oagw.plugin.v1~{id}"))); + assert!(!catalog.resolves(&Uuid::from_u128(0xC2).to_string())); + assert!(!catalog.resolves("gts.cf.core.oagw.plugin.v1~not-a-uuid")); +} + +#[test] +fn an_unknown_upstream_plugin_reference_is_a_400() { + let v = validator(); + + // A catalogue-only auth id: no implementation backs it, so it can never + // resolve (503 `plugin.not_found` at proxy time, 400 here). + let mut draft = input(vec![endpoint("api.openai.com")], Some("api.openai.com")); + draft.auth = Some(crate::domain::model::AuthConfig { + auth_type: "cf.core.oagw.logging.v1".to_owned(), + sharing: SharingMode::Private, + config: serde_json::json!({}), + }); + let validated = v.validate_upstream(&draft).expect("valid draft"); + let error = v + .validate_bindings(&validated, &catalog(&[])) + .expect_err("unknown auth plugin"); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + assert_eq!( + error.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); + assert!( + error.detail().contains("cf.core.oagw.logging.v1"), + "{error}" + ); + assert_eq!( + error.context().plugin_id.as_deref(), + Some("cf.core.oagw.logging.v1") + ); + + // An unknown custom-plugin UUID in the chain is rejected the same way. + let mut draft = input(vec![endpoint("api.openai.com")], Some("api.openai.com")); + draft + .plugins + .items + .push(crate::domain::model::PluginBinding::new( + "00000000-0000-0000-0000-0000000000c2", + serde_json::json!({}), + )); + let validated = v.validate_upstream(&draft).expect("valid draft"); + let error = v + .validate_bindings(&validated, &catalog(&[0xC1])) + .expect_err("unknown plugin in the chain"); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + assert!( + error + .detail() + .contains("00000000-0000-0000-0000-0000000000c2"), + "{error}" + ); + + // A reference that resolves, in either spelling, is accepted. + let mut draft = input(vec![endpoint("api.openai.com")], Some("api.openai.com")); + draft.auth = Some(crate::domain::model::AuthConfig { + auth_type: crate::domain::plugin::builtin::APIKEY_AUTH.to_owned(), + sharing: SharingMode::Private, + config: serde_json::json!({}), + }); + draft + .plugins + .items + .push(crate::domain::model::PluginBinding::new( + "00000000-0000-0000-0000-0000000000c1", + serde_json::json!({}), + )); + let validated = v.validate_upstream(&draft).expect("valid draft"); + v.validate_bindings(&validated, &catalog(&[0xC1])) + .expect("resolves"); +} + +#[test] +fn a_plugin_bound_in_the_wrong_slot_is_a_400() { + let v = validator(); + // A transform reference in the `auth` slot: the kind does not fit the slot, + // so the binding is rejected before it can produce a per-request 503. + let mut draft = input(vec![endpoint("api.openai.com")], Some("api.openai.com")); + draft.auth = Some(crate::domain::model::AuthConfig { + auth_type: crate::domain::plugin::builtin::REQUEST_ID_TRANSFORM.to_owned(), + sharing: SharingMode::Private, + config: serde_json::json!({}), + }); + let validated = v.validate_upstream(&draft).expect("valid draft"); + let error = v + .validate_bindings(&validated, &catalog(&[])) + .expect_err("transform in the auth slot"); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + assert_eq!( + error.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); + assert!( + error + .detail() + .contains(crate::domain::plugin::builtin::REQUEST_ID_TRANSFORM), + "the detail names the offending reference: {error}" + ); + assert!( + error + .detail() + .contains(crate::domain::plugin::AUTH_PLUGIN_TYPE_ID), + "the detail names the expected kind: {error}" + ); + assert!( + error.detail().contains("auth"), + "the detail names the slot: {error}" + ); + assert_eq!( + error.context().plugin_id.as_deref(), + Some(crate::domain::plugin::builtin::REQUEST_ID_TRANSFORM) + ); + + // The same reference in a chain slot, which is what it was created for. + let mut draft = input(vec![endpoint("api.openai.com")], Some("api.openai.com")); + draft + .plugins + .items + .push(crate::domain::model::PluginBinding::new( + crate::domain::plugin::builtin::REQUEST_ID_TRANSFORM, + serde_json::json!({}), + )); + let validated = v.validate_upstream(&draft).expect("valid draft"); + v.validate_bindings(&validated, &catalog(&[])) + .expect("a transform belongs in the chain"); +} + +#[test] +fn a_guard_bound_in_the_auth_slot_is_rejected_and_in_the_chain_is_accepted() { + let v = validator(); + let guard = crate::domain::plugin::builtin::REQUIRED_HEADERS_GUARD; + + let mut draft = input(vec![endpoint("api.openai.com")], Some("api.openai.com")); + draft.auth = Some(crate::domain::model::AuthConfig { + auth_type: guard.to_owned(), + sharing: SharingMode::Private, + config: serde_json::json!({}), + }); + let validated = v.validate_upstream(&draft).expect("valid draft"); + let error = v + .validate_bindings(&validated, &catalog(&[])) + .expect_err("guard in the auth slot"); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + + let mut draft = input(vec![endpoint("api.openai.com")], Some("api.openai.com")); + draft + .plugins + .items + .push(crate::domain::model::PluginBinding::new( + guard, + serde_json::json!({}), + )); + let validated = v.validate_upstream(&draft).expect("valid draft"); + v.validate_bindings(&validated, &catalog(&[])) + .expect("a guard belongs in the chain"); +} + +#[test] +fn an_auth_plugin_bound_in_a_chain_is_rejected_and_in_the_auth_slot_is_accepted() { + let v = validator(); + let auth = crate::domain::plugin::builtin::APIKEY_AUTH; + + let mut draft = input(vec![endpoint("api.openai.com")], Some("api.openai.com")); + draft + .plugins + .items + .push(crate::domain::model::PluginBinding::new( + auth, + serde_json::json!({}), + )); + let validated = v.validate_upstream(&draft).expect("valid draft"); + let error = v + .validate_bindings(&validated, &catalog(&[])) + .expect_err("auth plugin in the chain"); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + assert!( + error + .detail() + .contains(crate::domain::plugin::TRANSFORM_PLUGIN_TYPE_ID), + "the detail names what a chain slot takes: {error}" + ); + + let mut draft = input(vec![endpoint("api.openai.com")], Some("api.openai.com")); + draft.auth = Some(crate::domain::model::AuthConfig { + auth_type: auth.to_owned(), + sharing: SharingMode::Private, + config: serde_json::json!({}), + }); + let validated = v.validate_upstream(&draft).expect("valid draft"); + v.validate_bindings(&validated, &catalog(&[])) + .expect("an auth plugin belongs in the auth slot"); +} + +#[test] +fn a_mismatched_custom_plugin_is_caught_when_the_catalogue_carries_its_kind() { + let v = validator(); + // The tenant owns a guard plugin and bound it into the `auth` slot: the + // catalogue knows the plugin's base type, so the binding is rejected. + let plugin = crate::domain::model::Plugin { + id: Uuid::from_u128(0xC1), + plugin_type: crate::domain::plugin::GUARD_PLUGIN_TYPE_ID.to_owned(), + ..crate::domain::model::Plugin::default() + }; + let reference = plugin.id.to_string(); + let typed = super::PluginCatalog::of_plugins(&[std::sync::Arc::new(plugin)]); + + let mut draft = input(vec![endpoint("api.openai.com")], Some("api.openai.com")); + draft.auth = Some(crate::domain::model::AuthConfig { + auth_type: reference.clone(), + sharing: SharingMode::Private, + config: serde_json::json!({}), + }); + let validated = v.validate_upstream(&draft).expect("valid draft"); + let error = v + .validate_bindings(&validated, &typed) + .expect_err("guard in the auth slot"); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + + // In its own slot the same reference is accepted. + let mut draft = input(vec![endpoint("api.openai.com")], Some("api.openai.com")); + draft + .plugins + .items + .push(crate::domain::model::PluginBinding::new( + reference, + serde_json::json!({}), + )); + let validated = v.validate_upstream(&draft).expect("valid draft"); + v.validate_bindings(&validated, &typed) + .expect("a custom guard belongs in the chain"); +} + +#[test] +fn a_mismatched_route_binding_is_rejected() { + let owner = upstream(Protocol::Http); + let mut route = route_input(crate::domain::model::RouteMatch { + http: Some(http_match("/v1", &[HttpMethod::Get])), + grpc: None, + }); + route + .plugins + .items + .push(crate::domain::model::PluginBinding::new( + crate::domain::plugin::builtin::APIKEY_AUTH, + serde_json::json!({}), + )); + let validated = validator() + .validate_route(Uuid::from_u128(1), &owner, &route) + .expect("route shape is valid"); + let error = validator() + .validate_route_bindings(&validated, &catalog(&[])) + .expect_err("auth plugin in a route chain"); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + assert!( + error + .detail() + .contains(crate::domain::plugin::builtin::APIKEY_AUTH), + "{error}" + ); +} + +#[test] +fn an_unknown_route_plugin_reference_is_a_400() { + let owner = upstream(Protocol::Http); + let mut route = route_input(crate::domain::model::RouteMatch { + http: Some(http_match("/v1", &[HttpMethod::Get])), + grpc: None, + }); + route + .plugins + .items + .push(crate::domain::model::PluginBinding::new( + "gts.cf.core.oagw.plugin.v1~00000000-0000-0000-0000-0000000000c1", + serde_json::json!({}), + )); + let validated = validator() + .validate_route(Uuid::from_u128(1), &owner, &route) + .expect("route shape is valid"); + let error = validator() + .validate_route_bindings(&validated, &catalog(&[])) + .expect_err("unknown plugin"); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + assert!( + error + .detail() + .contains("00000000-0000-0000-0000-0000000000c1"), + "{error}" + ); +} diff --git a/gears/system/oagw/oagw/src/error_tests.rs b/gears/system/oagw/oagw/src/error_tests.rs new file mode 100644 index 0000000..2ca90d1 --- /dev/null +++ b/gears/system/oagw/oagw/src/error_tests.rs @@ -0,0 +1,432 @@ +//! Tests for [`crate::domain::error`]. + +use crate::domain::error::{ + APPLICATION_PROBLEM_JSON, ERROR_SOURCE_GATEWAY, ERROR_SOURCE_HEADER, ERROR_SOURCE_UPSTREAM, + OagwError, ReferencedBy, problem_context, +}; +use crate::domain::model::format_upstream_id; +use axum::Router; +use axum::body::Body; +use axum::http::{Request as HttpRequest, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::routing::get; +use tower::ServiceExt; +use uuid::Uuid; + +fn into_owned(response: axum::http::Response) -> Response { + response.into_response() +} + +/// Renders `error` exactly as the wire sees it: the problem body the response +/// carries, with the `context` extension fields mirrored to the top level. +/// +/// The wire path itself (`into_response` plus the error layer) is asserted in +/// [`crate::api::rest::error_layer`] and the integration tests; this helper +/// keeps the JSON assertions synchronous. +fn rendered(error: &OagwError) -> serde_json::Value { + let mut body = error.problem_body().clone(); + body.apply_context_extensions(); + serde_json::to_value(&body).expect("serialise") +} + +#[test] +fn every_variant_maps_to_its_design_status() { + let upstream_id = Uuid::from_u128(0xA1); + let cases: Vec<(OagwError, StatusCode, &str)> = vec![ + ( + OagwError::validation("alias is required"), + StatusCode::BAD_REQUEST, + "validation.error", + ), + ( + OagwError::missing_target_host("no target host on the request"), + StatusCode::BAD_REQUEST, + "routing.missing_target_host", + ), + ( + OagwError::invalid_target_host("target host is not an IP or hostname"), + StatusCode::BAD_REQUEST, + "routing.invalid_target_host", + ), + ( + OagwError::unknown_target_host("host is not part of the upstream pool"), + StatusCode::BAD_REQUEST, + "routing.unknown_target_host", + ), + ( + OagwError::authentication_failed("api key rejected"), + StatusCode::UNAUTHORIZED, + "auth.failed", + ), + ( + OagwError::route_not_found("no route matched POST /v1/x"), + StatusCode::NOT_FOUND, + "route.not_found", + ), + ( + OagwError::plugin_in_use("plugin is still referenced"), + StatusCode::CONFLICT, + "plugin.in_use", + ), + ( + OagwError::payload_too_large("body exceeds 100 MB"), + StatusCode::PAYLOAD_TOO_LARGE, + "payload.too_large", + ), + ( + OagwError::rate_limit_exceeded("tenant budget exhausted"), + StatusCode::TOO_MANY_REQUESTS, + "rate_limit.exceeded", + ), + ( + OagwError::secret_not_found("secret payments-key is missing"), + StatusCode::INTERNAL_SERVER_ERROR, + "secret.not_found", + ), + ( + OagwError::protocol_error("upstream sent a malformed header block"), + StatusCode::BAD_GATEWAY, + "protocol.error", + ), + ( + OagwError::downstream_error("upstream returned 500"), + StatusCode::BAD_GATEWAY, + "downstream.error", + ), + ( + OagwError::stream_aborted("SSE stream reset by peer"), + StatusCode::BAD_GATEWAY, + "stream.aborted", + ), + ( + OagwError::link_unavailable("no healthy endpoint"), + StatusCode::SERVICE_UNAVAILABLE, + "link.unavailable", + ), + ( + OagwError::circuit_breaker_open("upstream breaker is open"), + StatusCode::SERVICE_UNAVAILABLE, + "circuit_breaker.open", + ), + ( + OagwError::plugin_not_found("plugin 42 is not registered"), + StatusCode::SERVICE_UNAVAILABLE, + "plugin.not_found", + ), + ( + OagwError::connection_timeout("connect phase exceeded 2s"), + StatusCode::GATEWAY_TIMEOUT, + "timeout.connection", + ), + ( + OagwError::request_timeout("exchange exceeded 2s"), + StatusCode::GATEWAY_TIMEOUT, + "timeout.request", + ), + ( + OagwError::idle_timeout("no bytes for 2s"), + StatusCode::GATEWAY_TIMEOUT, + "timeout.idle", + ), + ( + OagwError::conflict("duplicate alias"), + StatusCode::CONFLICT, + "conflict", + ), + ( + OagwError::not_found("no such route"), + StatusCode::NOT_FOUND, + "not_found", + ), + ( + OagwError::forbidden("origin not allowed"), + StatusCode::FORBIDDEN, + "cors.forbidden", + ), + ( + OagwError::bind_forbidden("ancestor enforces the alias"), + StatusCode::FORBIDDEN, + "tenancy.bind_forbidden", + ), + ]; + + for (error, expected_status, expected_fragment) in cases { + assert_eq!(error.status(), expected_status, "{error}"); + let gts_type = error.gts_type(); + assert_eq!( + gts_type, + format!("gts.cf.core.errors.err.v1~cf.oagw.{expected_fragment}.v1"), + "{error}" + ); + let body = error.problem_body(); + assert_eq!(body.status, expected_status.as_u16(), "{error}"); + assert!(!body.title.is_empty(), "{error}"); + assert_eq!(body.r#type, gts_type); + assert_eq!(body.detail, error.detail(), "{error}"); + assert_eq!(error.context(), &body.context, "{error}"); + } + + // `with_upstream_id` carries the GTS instance id (a UUID string). + let with_id = OagwError::not_found("x").with_upstream_id(upstream_id); + let expected = upstream_id.to_string(); + assert_eq!( + with_id.context().upstream_id.as_deref(), + Some(expected.as_str()) + ); +} + +#[test] +fn problem_body_carries_the_context_extension_object() { + let error = OagwError::unknown_target_host("host does not belong to the upstream pool") + .with_alias("payments") + .with_host("evil.example.com") + .with_path("/oagw/v1/proxy/payments/v1/pay") + .with_valid_hosts(vec!["api.example.com".to_owned()]) + .with_invalid_value("evil.example.com"); + let body = rendered(&error); + assert_eq!(body["title"], "Unknown Target Host"); + assert_eq!(body["status"], serde_json::json!(400)); + assert_eq!(body["detail"], "host does not belong to the upstream pool"); + assert_eq!(body["instance"], "/oagw/v1/proxy/payments/v1/pay"); + let context = &body["context"]; + assert!(context.is_object()); + assert_eq!(context["alias"], "payments"); + assert_eq!(context["host"], "evil.example.com"); + assert_eq!( + context["valid_hosts"], + serde_json::json!(["api.example.com"]) + ); + assert_eq!(context["invalid_value"], "evil.example.com"); + assert!( + context.get("plugin_id").is_none(), + "unset fields are omitted" + ); + assert!(context.get("referenced_by").is_none()); +} + +#[test] +fn extension_fields_are_emitted_at_the_top_level_and_in_context() { + let error = OagwError::unknown_target_host("host does not belong to the upstream pool") + .with_alias("payments") + .with_host("evil.example.com") + .with_path("/oagw/v1/proxy/payments/v1/pay") + .with_upstream_id(Uuid::from_u128(0xA1)) + .with_valid_hosts(vec!["api.example.com".to_owned()]) + .with_invalid_value("evil.example.com"); + let body = rendered(&error); + + // ADR-0007 wire examples: the extension fields sit next to the RFC fields. + assert_eq!(body["alias"], "payments", "{body}"); + assert_eq!(body["host"], "evil.example.com", "{body}"); + assert_eq!(body["path"], "/oagw/v1/proxy/payments/v1/pay", "{body}"); + assert_eq!( + body["upstream_id"], + Uuid::from_u128(0xA1).to_string(), + "{body}" + ); + assert_eq!(body["valid_hosts"], serde_json::json!(["api.example.com"])); + assert_eq!(body["invalid_value"], "evil.example.com"); + + // ... and the nested `context` object carries the same values. + assert_eq!(body["context"]["alias"], "payments", "{body}"); + assert_eq!(body["context"]["host"], "evil.example.com", "{body}"); + assert_eq!(body["context"]["path"], "/oagw/v1/proxy/payments/v1/pay"); + assert_eq!( + body["context"]["upstream_id"], + Uuid::from_u128(0xA1).to_string() + ); + assert_eq!(body["context"]["valid_hosts"], body["valid_hosts"]); + assert_eq!(body["context"]["invalid_value"], "evil.example.com"); + + // A body without extensions renders neither view. + let plain = rendered(&OagwError::validation("nope")); + assert!(plain.get("alias").is_none(), "{plain}"); + assert!(plain.get("upstream_id").is_none(), "{plain}"); + assert!(plain.get("valid_hosts").is_none(), "{plain}"); + assert!(plain["context"].is_object(), "context stays on the wire"); + assert!(plain["context"].as_object().expect("object").is_empty()); +} + +#[test] +fn a_problem_body_round_trips_through_its_wire_shape() { + let error = OagwError::unknown_target_host("host is unknown") + .with_alias("payments") + .with_valid_hosts(vec!["api.example.com".to_owned()]); + let body = rendered(&error); + let reparsed: crate::domain::error::ProblemBody = serde_json::from_value(body).expect("parses"); + assert_eq!(reparsed.alias.as_deref(), Some("payments")); + assert_eq!(reparsed.context.alias.as_deref(), Some("payments")); + assert_eq!(reparsed.valid_hosts, vec!["api.example.com".to_owned()]); + assert_eq!(reparsed.status, 400); +} + +#[test] +fn plugin_in_use_reports_its_referencers() { + let error = OagwError::plugin_in_use("plugin is still referenced") + .with_plugin_id("gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.apikey.v1") + .with_referenced_by(ReferencedBy { + upstreams: vec![format_upstream_id(Uuid::from_u128(0xA1))], + routes: Vec::new(), + }); + let body = rendered(&error); + assert_eq!(body["status"], serde_json::json!(409)); + assert_eq!( + body["context"]["plugin_id"], + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.apikey.v1" + ); + assert_eq!( + body["context"]["referenced_by"]["upstreams"], + serde_json::json!([format_upstream_id(Uuid::from_u128(0xA1))]) + ); + assert!(body["context"]["referenced_by"].get("routes").is_none()); + assert_eq!( + body["plugin_id"], "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.apikey.v1", + "the extension field is top level too" + ); + assert_eq!( + body["referenced_by"]["upstreams"], + serde_json::json!([format_upstream_id(Uuid::from_u128(0xA1))]) + ); +} + +#[test] +fn retry_after_is_a_top_level_extension_field_and_a_header() { + let error = + OagwError::rate_limit_exceeded("tenant budget exhausted").with_retry_after_seconds(15); + let body = rendered(&error); + assert_eq!(body["retry_after_seconds"], serde_json::json!(15), "{body}"); + assert_eq!( + body["context"]["retry_after_seconds"], + serde_json::json!(15) + ); +} + +#[test] +fn response_sets_content_type_error_source_and_retry_after() { + let error = + OagwError::rate_limit_exceeded("tenant budget exhausted").with_retry_after_seconds(30); + let response = into_owned(error.clone().into_response()); + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + response + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some(APPLICATION_PROBLEM_JSON) + ); + assert_eq!( + response + .headers() + .get(ERROR_SOURCE_HEADER) + .and_then(|value| value.to_str().ok()), + Some(ERROR_SOURCE_GATEWAY) + ); + assert_eq!( + response + .headers() + .get(axum::http::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()), + Some("30") + ); + + let upstream_failure = OagwError::downstream_error("upstream returned 502"); + let mapped = upstream_failure.into_response_with_source(ERROR_SOURCE_UPSTREAM); + assert_eq!( + mapped + .headers() + .get(ERROR_SOURCE_HEADER) + .and_then(|value| value.to_str().ok()), + Some(ERROR_SOURCE_UPSTREAM) + ); + assert!( + !mapped + .headers() + .contains_key(axum::http::header::RETRY_AFTER) + ); +} + +#[test] +fn display_and_error_impl_expose_type_and_detail() { + let error = OagwError::route_not_found("no route matched"); + let rendered = error.to_string(); + assert!(rendered.contains("no route matched"), "{rendered}"); + assert!(rendered.contains("route.not_found"), "{rendered}"); + + fn takes_std_error(error: impl std::error::Error) -> usize { + error.to_string().len() + } + assert!(takes_std_error(error) > 0); +} + +#[test] +fn problem_context_builder_builds_the_same_shape_as_the_fluent_setters() { + let built = problem_context() + .alias("payments") + .host("api.example.com") + .retry_after_seconds(5) + .build(); + let fluent = OagwError::rate_limit_exceeded("x") + .with_alias("payments") + .with_host("api.example.com") + .with_retry_after_seconds(5) + .context() + .clone(); + assert_eq!(built, fluent); + assert_eq!(built.alias.as_deref(), Some("payments")); + assert_eq!(built.retry_after_seconds, Some(5)); +} + +#[tokio::test] +async fn handlers_return_problem_json_through_the_axum_surface() { + async fn failing() -> OagwError { + std::future::ready(OagwError::validation("alias is required")).await + } + + let app = Router::new().route("/boom", get(failing)); + let request = HttpRequest::get("/boom") + .body(Body::empty()) + .expect("request"); + let response = into_owned(app.oneshot(request).await.expect("response")); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response + .headers() + .get(ERROR_SOURCE_HEADER) + .and_then(|value| value.to_str().ok()), + Some(ERROR_SOURCE_GATEWAY) + ); + let bytes = http_body_util::BodyExt::collect(response.into_body()) + .await + .expect("body") + .to_bytes(); + let body: serde_json::Value = serde_json::from_slice(&bytes).expect("problem json"); + assert_eq!(body["title"], "Validation Error"); + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); + assert!(body["context"].is_object()); +} + +#[test] +fn the_bind_denial_is_not_the_cors_denial() { + let bind = OagwError::bind_forbidden("an ancestor enforces the alias") + .with_alias("api.openai.com") + .with_upstream_id(Uuid::from_u128(0xA1)); + assert_eq!( + bind.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.tenancy.bind_forbidden.v1" + ); + assert_eq!(bind.status(), StatusCode::FORBIDDEN); + + let cors = OagwError::forbidden("origin not allowed"); + assert_eq!( + cors.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.cors.forbidden.v1" + ); + + let body = rendered(&bind); + assert_eq!(body["type"], bind.gts_type(), "{body}"); + assert_eq!(body["alias"], "api.openai.com", "{body}"); + assert_eq!(body["context"]["alias"], "api.openai.com", "{body}"); +} diff --git a/gears/system/oagw/oagw/src/gear.rs b/gears/system/oagw/oagw/src/gear.rs new file mode 100644 index 0000000..6fd20de --- /dev/null +++ b/gears/system/oagw/oagw/src/gear.rs @@ -0,0 +1,295 @@ +//! Gear entry point of the OAGW outbound API gateway. +//! +//! [`OagwGear`] wires the gear configuration into the shared +//! [`RegistryStore`] that the control plane (slice 2) and the data plane +//! (slice 4) both read, and installs the ADR-0007 error-source middleware on +//! the REST surface. +//! +//! ## Lifecycle +//! +//! `init` is synchronous and fail-fast: an invalid configuration must abort +//! gear start-up rather than surface as a per-request failure. `serve` then +//! parks until the runtime cancels it — this gear owns no background workers +//! in slice 1 (no token refreshers, no reconcilers), and the proxy engine +//! arriving in slice 4 is driven by the REST surface rather than by a worker +//! loop. + +use std::sync::Arc; + +use async_trait::async_trait; +use credstore_sdk::CredStoreClientV1; +use tenant_resolver_sdk::TenantResolverClient; +use toolkit::api::OpenApiRegistry; +use toolkit::{Gear, GearCtx, RestApiCapability}; + +use crate::api::rest::error_source_layer; +use crate::api::rest::handlers::proxy::DataPlane; +use crate::config::OagwConfig; +use crate::domain::metrics::MetricsRegistry; +use crate::domain::rate_limit::RateLimiterRegistry; +use crate::domain::services::{ + ControlPlaneService, NoHierarchy, ResolverHierarchy, TenantHierarchy, +}; +use crate::infra::plugin::{ + CredStoreSecretResolver, PluginRegistry, SecretResolverTrait, TokenCacheConfig, + UnavailableSecretResolver, +}; +use crate::infra::proxy::ProxyEngine; +use crate::infra::storage::{CacheLimits, RegistryStore}; +use crate::{api::rest::routes as rest_routes, domain::validation::Validator}; + +/// Cache budgets derived from the gear configuration (ADR-0006: the control +/// plane owns the `upstream`/`route`/`plugin` L1 caches, the data plane the +/// `dp` one; both are provisioned from the same operator knobs). +fn cache_limits(config: &OagwConfig) -> CacheLimits { + CacheLimits { + upstream: config.upstream_l1_cache_max_entries, + route: config.route_l1_cache_max_entries, + plugin: config.plugin_l1_cache_max_entries, + dp: config.dp_cache_max_entries, + } +} + +/// OAGW outbound API gateway gear. +/// +/// Declares the dependencies the design assigns to this gear (`credstore` for +/// secrets, `tenant_resolver` for ancestor chains, `types_registry` for GTS +/// types and `authz_resolver` for tenant-scoped authorisation) and the `rest` +/// capability it exposes. The `stateful` capability is not declared: the +/// toolkit generates its lifecycle entry point from +/// `lifecycle(entry = "serve")` using `tokio_util::sync::CancellationToken`, +/// which is not a direct dependency of this crate (see `Cargo.toml`), and the +/// registry is process-local state exposed through REST rather than a +/// stateful capability contract. +#[toolkit::gear( + name = "oagw", + deps = [credstore, tenant_resolver, types_registry, authz_resolver], + capabilities = [rest] +)] +pub struct OagwGear { + /// Registry built during `init`; `None` before start-up completes. + registry: std::sync::OnceLock>, + /// Effective configuration captured during `init`. + config: std::sync::OnceLock, + /// Control-plane service built during `init`. + service: std::sync::OnceLock>, + /// Plugin registry built during `init` (ADR-0002). + plugins: std::sync::OnceLock>, + /// Outbound proxy engine built during `init` (ADR-0006). + engine: std::sync::OnceLock>, + /// Data-plane metrics registry built during `init`. + metrics: std::sync::OnceLock>, + /// Rate-limit buckets (ADR-0003), shared by the data plane and the store. + /// + /// The store keeps a handle so an upstream mutation can drop the buckets + /// that belong to it; the data plane keeps the same handle so both sides + /// see one set of budgets. + rate_limiters: std::sync::OnceLock>, +} + +impl Default for OagwGear { + fn default() -> Self { + Self { + registry: std::sync::OnceLock::new(), + config: std::sync::OnceLock::new(), + service: std::sync::OnceLock::new(), + plugins: std::sync::OnceLock::new(), + engine: std::sync::OnceLock::new(), + metrics: std::sync::OnceLock::new(), + rate_limiters: std::sync::OnceLock::new(), + } + } +} + +impl OagwGear { + /// Registry built during `init`, or `None` before start-up completes. + #[must_use] + pub fn registry(&self) -> Option<&Arc> { + self.registry.get() + } + + /// Configuration captured during `init`, or `None` before start-up. + #[must_use] + pub fn effective_config(&self) -> Option<&OagwConfig> { + self.config.get() + } + + /// Control-plane service built during `init`, or `None` before start-up. + #[must_use] + pub fn service(&self) -> Option<&Arc> { + self.service.get() + } + + /// Plugin registry built during `init`, or `None` before start-up. + /// + /// The registry is fail-closed: without a `credstore` dependency it still + /// exposes every built-in, but the `cred://`-backed auth plugins fail at + /// use time with `500 SecretNotFound` instead of proxying unauthenticated. + #[must_use] + pub fn plugins(&self) -> Option<&Arc> { + self.plugins.get() + } + + /// Builds the ADR-0002 plugin registry over the best available credential + /// resolver. + fn build_plugins( + config: &OagwConfig, + resolver: Arc, + ) -> PluginRegistry { + PluginRegistry::with_builtins(resolver, TokenCacheConfig::from(config)) + } + + /// The control-plane service, or an error when `init` has not run. + fn service_or(&self) -> anyhow::Result> { + self.service + .get() + .cloned() + .ok_or_else(|| anyhow::anyhow!("oagw control-plane service not initialized")) + } + + /// The plugin registry, or an error when `init` has not run. + fn plugins_or(&self) -> anyhow::Result> { + self.plugins + .get() + .cloned() + .ok_or_else(|| anyhow::anyhow!("oagw plugin registry not initialized")) + } + + /// The proxy engine, or an error when `init` has not run. + fn engine_or(&self) -> anyhow::Result> { + self.engine + .get() + .cloned() + .ok_or_else(|| anyhow::anyhow!("oagw proxy engine not initialized")) + } + + /// The metrics registry, or an error when `init` has not run. + fn metrics_or(&self) -> anyhow::Result> { + self.metrics + .get() + .cloned() + .ok_or_else(|| anyhow::anyhow!("oagw metrics registry not initialized")) + } + + /// Builds the data-plane extension the proxy handlers read. + fn build_data_plane(&self) -> anyhow::Result { + Ok(DataPlane { + service: self.service_or()?, + engine: self.engine_or()?, + metrics: self.metrics_or()?, + plugins: self.plugins_or()?, + rate_limiters: self.rate_limiters(), + }) + } + + /// The one rate-limit registry of this process, creating it on first use. + /// + /// `init` hands the same handle to the [`RegistryStore`], which is what + /// makes an upstream mutation drop the buckets of the upstream it touched. + fn rate_limiters(&self) -> Arc { + Arc::clone( + self.rate_limiters + .get_or_init(|| Arc::new(RateLimiterRegistry::default())), + ) + } + + /// Builds the control-plane service over a fresh registry. + fn build_service( + config: &OagwConfig, + registry: Arc, + hierarchy: Arc, + ) -> ControlPlaneService { + ControlPlaneService::new(registry, Validator::new(config.clone()), hierarchy) + } + + /// Service loop. Slice 1 owns no background workers, so the future parks + /// until the runtime drops it. + pub async fn serve(self: Arc) -> anyhow::Result<()> { + std::future::pending::<()>().await; + Ok(()) + } +} + +#[async_trait] +impl Gear for OagwGear { + /// Loads and validates the gear configuration and builds the registry. + /// + /// # Errors + /// + /// Returns the underlying error when the configuration cannot be decoded + /// or fails [`OagwConfig::validate`]. + async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> { + let config: OagwConfig = ctx.config_or_default()?; + config.validate()?; + let limits = cache_limits(&config); + let registry = Arc::new(RegistryStore::new(limits)); + // The store learns the rate-limit registry before the data plane does, + // so an upstream deleted through the management surface takes its + // buckets with it from the very first request. + registry.attach_rate_limiters(self.rate_limiters()); + let hierarchy: Arc = + match ctx.client_hub().try_get::() { + Some(client) => Arc::new(ResolverHierarchy::new(client)), + None => Arc::new(NoHierarchy), + }; + let service = Self::build_service(&config, Arc::clone(®istry), hierarchy); + let resolver: Arc = + match ctx.client_hub().try_get::() { + Some(client) => Arc::new(CredStoreSecretResolver::new(client)), + None => Arc::new(UnavailableSecretResolver), + }; + let plugins = Self::build_plugins(&config, resolver); + let metrics = Arc::new(MetricsRegistry::new()); + let engine = Arc::new(ProxyEngine::new(&config, Arc::clone(&metrics))); + self.config + .set(config) + .map_err(|_| anyhow::anyhow!("oagw gear already initialised"))?; + self.registry + .set(registry) + .map_err(|_| anyhow::anyhow!("oagw gear already initialised"))?; + self.service + .set(Arc::new(service)) + .map_err(|_| anyhow::anyhow!("oagw gear already initialised"))?; + self.plugins + .set(Arc::new(plugins)) + .map_err(|_| anyhow::anyhow!("oagw gear already initialised"))?; + self.engine + .set(engine) + .map_err(|_| anyhow::anyhow!("oagw gear already initialised"))?; + self.metrics + .set(metrics) + .map_err(|_| anyhow::anyhow!("oagw gear already initialised"))?; + Ok(()) + } +} + +impl RestApiCapability for OagwGear { + /// Registers the fifteen management operations, the two proxy operations + /// and the metrics endpoint, each family wrapped in its own ADR-0007 + /// error-source layer and `Extension`. + /// + /// The oagw routes are built on fresh sub-routers and then merged: layers + /// only affect the routes registered before them, so a `/healthz` route + /// the platform already mounted on the incoming router stays outside the + /// oagw middleware. + fn register_rest( + &self, + _ctx: &GearCtx, + router: axum::Router, + openapi: &dyn OpenApiRegistry, + ) -> anyhow::Result { + let service = self.service_or()?; + let management = rest_routes::register_routes(axum::Router::new(), openapi) + .layer(axum::Extension(service)) + .layer(axum::middleware::from_fn(error_source_layer)); + let data_plane = self.build_data_plane()?; + let data = rest_routes::register_proxy_routes(axum::Router::new(), openapi) + .layer(axum::Extension(data_plane)) + .layer(axum::middleware::from_fn(error_source_layer)); + Ok(router.merge(management).merge(data)) + } +} + +#[cfg(test)] +#[path = "gear_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/gear_tests.rs b/gears/system/oagw/oagw/src/gear_tests.rs new file mode 100644 index 0000000..09961ce --- /dev/null +++ b/gears/system/oagw/oagw/src/gear_tests.rs @@ -0,0 +1,71 @@ +//! Tests for [`crate::gear`]. + +use std::sync::Arc; + +use crate::config::{OagwConfig, SsrfPolicy}; +use crate::domain::model::gts_instance_id; +use crate::gear::{OagwGear, cache_limits}; +use crate::infra::storage::RegistryStore; + +#[test] +fn cache_limits_follow_the_operator_knobs() { + let config = OagwConfig { + upstream_l1_cache_max_entries: 11, + route_l1_cache_max_entries: 12, + plugin_l1_cache_max_entries: 13, + dp_cache_max_entries: 14, + ..OagwConfig::default() + }; + let limits = cache_limits(&config); + assert_eq!(limits.upstream, 11); + assert_eq!(limits.route, 12); + assert_eq!(limits.plugin, 13); + assert_eq!(limits.dp, 14); +} + +#[test] +fn defaults_produce_a_usable_registry() { + let limits = cache_limits(&OagwConfig::default()); + let registry = RegistryStore::new(limits); + assert!(registry.caches_are_empty()); + assert!(registry.list_upstreams(&[]).is_empty()); +} + +#[test] +fn gear_starts_uninitialised_and_serves_until_cancelled() { + let gear = Arc::new(OagwGear::default()); + assert!(gear.registry().is_none(), "init has not run yet"); + assert!(gear.effective_config().is_none(), "init has not run yet"); + + let parked = gear.clone().serve(); + // Drop the parked future without polling it: the contract under test is + // that `serve` never resolves on its own, which the type system plus a + // non-polling drop assert here. + drop(parked); +} + +#[test] +fn ssrf_policy_defaults_are_documented_and_validated() { + let config = OagwConfig { + ssrf_policy: SsrfPolicy { + enabled: true, + allow_private_networks: false, + allowed_ip_ranges: vec!["10.0.0.0/8".to_owned()], + }, + ..OagwConfig::default() + }; + config.validate().expect("policy must validate"); + assert_eq!( + gts_instance_id("gts://cf.core.oagw.upstream.v1~abc"), + Some("abc") + ); +} + +#[tokio::test] +async fn serve_parks_until_dropped() { + let gear = Arc::new(OagwGear::default()); + let task = tokio::spawn(gear.clone().serve()); + tokio::task::yield_now().await; + assert!(!task.is_finished(), "serve must not resolve on its own"); + task.abort(); +} 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..a6da40d --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/mod.rs @@ -0,0 +1,10 @@ +//! Infrastructure layer of the OAGW gear: the in-memory registry store and +//! its read-optimised L1 caches, the plugin system the data plane runs and the +//! outbound proxy engine behind the proxy routes. + +pub mod plugin; +pub mod proxy; +pub mod storage; + +pub use proxy::{ProxyEngine, ProxyRequest, ProxyResponse}; +pub use storage::{CacheLimits, RegistryStore}; diff --git a/gears/system/oagw/oagw/src/infra/plugin/apikey.rs b/gears/system/oagw/oagw/src/infra/plugin/apikey.rs new file mode 100644 index 0000000..09336ea --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/apikey.rs @@ -0,0 +1,216 @@ +//! Built-in API key auth plugin +//! (`gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1`). +//! +//! Injects a statically configured API key into the outbound request, either as +//! a header (default `x-api-key`) or as a query parameter. The key is sourced +//! from the credential store through a `cred://` reference (preferred, ADR-0008 +//! and the DESIGN "Secret Access Control" flow) or, for tests and non-sensitive +//! upstreams, from an inline literal. +//! +//! ## Configuration keys +//! +//! | Key | Required | Meaning | +//! |---|---|---| +//! | `header_name` | no | Header the key is injected into; default `x-api-key`. | +//! | `query_name` | no | Query parameter the key is injected into; takes precedence over `header_name`. | +//! | `key` | one of | Inline API key. | +//! | `secret_ref` | one of | `cred://` reference resolved at request time. | +//! +//! ## Failure mapping +//! +//! A missing credential source, an unresolvable reference or a credential that +//! resolves to an empty value all reject the request with `401` +//! `gts.cf.core.errors.err.v1~cf.oagw.auth.failed.v1` +//! ([`OagwError::AuthenticationFailed`]), matching the DESIGN secret-resolution +//! flow ("If not accessible -> return error, OAGW returns 401 Unauthorized"). + +use std::sync::Arc; + +use async_trait::async_trait; +use axum::http::{HeaderName, HeaderValue}; +use serde::Deserialize; + +use crate::domain::error::OagwError; +use crate::domain::plugin::{AUTH_PLUGIN_TYPE_ID, AuthPlugin, RequestContext, builtin}; +use crate::infra::plugin::secret::{SecretResolver, strip_secret_scheme}; + +/// Header the API key is injected into when the configuration names none. +pub const DEFAULT_API_KEY_HEADER: &str = "x-api-key"; + +/// Configuration payload of the API key plugin. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields, default)] +struct ApiKeyPluginConfig { + /// Header the key is injected into. + header_name: String, + /// Query parameter the key is injected into; wins over `header_name`. + query_name: Option, + /// Inline API key. + key: Option, + /// `cred://` reference resolved at request time. + secret_ref: Option, +} + +impl Default for ApiKeyPluginConfig { + fn default() -> Self { + Self { + header_name: DEFAULT_API_KEY_HEADER.to_owned(), + query_name: None, + key: None, + secret_ref: None, + } + } +} + +/// Where the credential comes from. +#[derive(Debug, Clone, PartialEq, Eq)] +enum ApiKeySource { + /// A literal value from the plugin configuration. + Literal(String), + /// A `cred://` reference resolved per request. + Reference(String), +} + +/// API key injection plugin. +pub struct ApiKeyAuthPlugin { + resolver: Arc, + header_name: String, + query_name: Option, + source: ApiKeySource, +} + +// The resolver port is not `Debug` (its implementations may wrap the credential +// store), so the plugin reports only its identity. +impl std::fmt::Debug for ApiKeyAuthPlugin { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ApiKeyAuthPlugin") + .field("id", &Self::PLUGIN_ID) + .field("header_name", &self.header_name) + .field("query_name", &self.query_name) + .finish_non_exhaustive() + } +} + +impl ApiKeyAuthPlugin { + /// Registry key of this plugin. + pub const PLUGIN_ID: &'static str = builtin::APIKEY_AUTH; + + /// GTS base type of this plugin. + pub const PLUGIN_TYPE: &'static str = AUTH_PLUGIN_TYPE_ID; + + /// Builds the plugin from a binding configuration payload. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when the payload is not an object with + /// the documented keys, or when neither `key` nor `secret_ref` is set. + pub fn new( + resolver: Arc, + config: &serde_json::Value, + ) -> Result { + let parsed = parse_config(config)?; + let source = match (parsed.key.as_ref(), parsed.secret_ref.as_ref()) { + (Some(key), _) => ApiKeySource::Literal(key.clone()), + (None, Some(reference)) => ApiKeySource::Reference(reference.clone()), + (None, None) => { + return Err(OagwError::validation( + "api key plugin requires either `key` or `secret_ref`", + )); + } + }; + Ok(Self { + resolver, + header_name: parsed.header_name, + query_name: parsed.query_name, + source, + }) + } + + /// Resolves and injects the credential into `ctx`. + /// + /// # Errors + /// + /// Returns [`OagwError::AuthenticationFailed`] when the credential is + /// missing, cannot be resolved, or resolves to an empty value. + async fn credential(&self, ctx: &RequestContext) -> Result { + let value = match &self.source { + ApiKeySource::Literal(key) => key.clone(), + ApiKeySource::Reference(reference) => self.resolve(ctx, reference).await?, + }; + if value.trim().is_empty() { + return Err(OagwError::authentication_failed( + "the configured api key credential is empty", + )); + } + Ok(value) + } + + async fn resolve(&self, ctx: &RequestContext, reference: &str) -> Result { + let Some(security) = ctx.security.clone() else { + return Err(OagwError::authentication_failed( + "no security context is available to resolve the api key credential", + )); + }; + self.resolver + .resolve(&security, reference) + .await? + .ok_or_else(|| { + OagwError::authentication_failed(format!( + "api key credential '{reference}' is not accessible to this tenant" + )) + }) + } +} + +fn parse_config(config: &serde_json::Value) -> Result { + let payload = match config { + serde_json::Value::Null => &serde_json::Value::Object(serde_json::Map::new()), + value => value, + }; + serde_json::from_value(payload.clone()) + .map_err(|error| OagwError::validation(format!("invalid api key plugin config: {error}"))) +} + +fn inject(ctx: &mut RequestContext, name: &str, value: &str) -> Result<(), OagwError> { + let header_name = HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| OagwError::validation(format!("invalid api key header name '{name}'")))?; + let header_value = HeaderValue::from_str(value) + .map_err(|_| OagwError::validation("api key credential is not a valid header value"))?; + ctx.headers + .insert(header_name.clone(), header_value.clone()); + ctx.inject_header(header_name, header_value); + Ok(()) +} + +#[async_trait] +impl AuthPlugin for ApiKeyAuthPlugin { + fn id(&self) -> &str { + builtin::APIKEY_AUTH + } + + fn plugin_type(&self) -> &str { + AUTH_PLUGIN_TYPE_ID + } + + async fn authenticate(&self, ctx: &mut RequestContext) -> Result<(), OagwError> { + let value = self.credential(ctx).await?; + match &self.query_name { + Some(name) => { + ctx.inject_query(name.clone(), value); + } + None => inject(ctx, &self.header_name, &value)?, + } + Ok(()) + } +} + +/// Bare key of a `cred://` reference, for diagnostics and tests. +#[must_use] +pub fn api_key_reference_key(reference: &str) -> &str { + strip_secret_scheme(reference) +} + +#[cfg(test)] +#[path = "apikey_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/plugin/apikey_tests.rs b/gears/system/oagw/oagw/src/infra/plugin/apikey_tests.rs new file mode 100644 index 0000000..3bdd6f6 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/apikey_tests.rs @@ -0,0 +1,226 @@ +//! Tests for [`crate::infra::plugin::apikey`]. + +use std::collections::HashMap; +use std::sync::Arc; + +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use super::{ApiKeyAuthPlugin, DEFAULT_API_KEY_HEADER, api_key_reference_key}; +use crate::domain::plugin::{AUTH_PLUGIN_TYPE_ID, AuthPlugin, RequestContext, builtin}; +use crate::infra::plugin::secret::{SecretResolver, StaticSecretResolver}; + +const TENANT: Uuid = Uuid::from_u128(0x11); + +fn context() -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::from_u128(0x33)) + .subject_tenant_id(TENANT) + .build() + .expect("security context") +} + +fn request() -> RequestContext { + RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1/payments") + .tenant_id(TENANT) + .security(Arc::new(context())) + .build() +} + +fn static_resolver() -> Arc { + Arc::new(StaticSecretResolver::new(HashMap::from([ + ("payments-key".to_owned(), "resolved-key".to_owned()), + ("empty-key".to_owned(), " ".to_owned()), + ]))) +} + +fn literal_plugin(config: serde_json::Value) -> ApiKeyAuthPlugin { + ApiKeyAuthPlugin::new(static_resolver(), &config).expect("plugin") +} + +#[test] +fn plugin_declares_the_adr_ids() { + let plugin = literal_plugin(serde_json::json!({ "key": "abc" })); + assert_eq!(plugin.id(), builtin::APIKEY_AUTH); + assert_eq!(plugin.plugin_type(), AUTH_PLUGIN_TYPE_ID); + assert_eq!(ApiKeyAuthPlugin::PLUGIN_ID, builtin::APIKEY_AUTH); +} + +#[test] +fn configuration_requires_a_key_or_a_reference() { + let error = ApiKeyAuthPlugin::new(static_resolver(), &serde_json::json!({})) + .expect_err("no credential source"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST); +} + +#[test] +fn configuration_rejects_unknown_keys() { + let error = ApiKeyAuthPlugin::new( + static_resolver(), + &serde_json::json!({ "key": "abc", "unknown": true }), + ) + .expect_err("unknown key"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST); +} + +#[test] +fn configuration_rejects_a_non_object_payload() { + let error = ApiKeyAuthPlugin::new(static_resolver(), &serde_json::json!(["key"])) + .expect_err("not an object"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST); +} + +#[test] +fn a_null_payload_is_treated_as_an_empty_object() { + let error = ApiKeyAuthPlugin::new(static_resolver(), &serde_json::Value::Null) + .expect_err("no credential source"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST); +} + +#[test] +fn default_header_is_x_api_key() { + assert_eq!(DEFAULT_API_KEY_HEADER, "x-api-key"); +} + +#[tokio::test] +async fn literal_key_is_injected_into_the_default_header() { + let mut ctx = request(); + literal_plugin(serde_json::json!({ "key": "raw-key" })) + .authenticate(&mut ctx) + .await + .expect("injected"); + assert_eq!( + ctx.header(DEFAULT_API_KEY_HEADER) + .and_then(|value| value.to_str().ok()), + Some("raw-key") + ); + assert_eq!(ctx.injected_headers.len(), 1); + assert_eq!(ctx.injected_headers[0].0.as_str(), DEFAULT_API_KEY_HEADER); + assert!(ctx.injected_query.is_empty()); +} + +#[tokio::test] +async fn custom_header_name_is_honoured() { + let mut ctx = request(); + literal_plugin(serde_json::json!({ "key": "raw-key", "header_name": "x-vendor-key" })) + .authenticate(&mut ctx) + .await + .expect("injected"); + assert_eq!( + ctx.header("x-vendor-key") + .and_then(|value| value.to_str().ok()), + Some("raw-key") + ); + assert!(!ctx.has_header(DEFAULT_API_KEY_HEADER)); +} + +#[tokio::test] +async fn query_name_routes_the_key_into_the_query_string() { + let mut ctx = request(); + literal_plugin(serde_json::json!({ "key": "raw-key", "query_name": "apiKey" })) + .authenticate(&mut ctx) + .await + .expect("injected"); + assert!( + ctx.injected_query + .iter() + .any(|(name, value)| name == "apiKey" && value == "raw-key") + ); + assert!(!ctx.has_header(DEFAULT_API_KEY_HEADER)); +} + +#[tokio::test] +async fn reference_key_is_resolved_at_request_time() { + let mut ctx = request(); + literal_plugin(serde_json::json!({ "secret_ref": "cred://payments-key" })) + .authenticate(&mut ctx) + .await + .expect("injected"); + assert_eq!( + ctx.header(DEFAULT_API_KEY_HEADER) + .and_then(|value| value.to_str().ok()), + Some("resolved-key") + ); +} + +#[tokio::test] +async fn an_unresolvable_reference_rejects_with_401() { + let mut ctx = request(); + let plugin = ApiKeyAuthPlugin::new( + static_resolver(), + &serde_json::json!({ "secret_ref": "cred://absent-key" }), + ) + .expect("plugin"); + let error = plugin.authenticate(&mut ctx).await.expect_err("401"); + assert_eq!(error.status(), axum::http::StatusCode::UNAUTHORIZED); + assert!(error.detail().contains("absent-key")); +} + +#[tokio::test] +async fn an_empty_resolved_value_rejects_with_401() { + let mut ctx = request(); + let plugin = literal_plugin(serde_json::json!({ "secret_ref": "cred://empty-key" })); + let error = plugin.authenticate(&mut ctx).await.expect_err("401"); + assert_eq!(error.status(), axum::http::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn an_empty_literal_rejects_with_401() { + let mut ctx = request(); + let plugin = literal_plugin(serde_json::json!({ "key": " " })); + let error = plugin.authenticate(&mut ctx).await.expect_err("401"); + assert_eq!(error.status(), axum::http::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn a_missing_security_context_rejects_with_401() { + let mut ctx = RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1") + .tenant_id(TENANT) + .build(); + let plugin = literal_plugin(serde_json::json!({ "secret_ref": "cred://payments-key" })); + let error = plugin.authenticate(&mut ctx).await.expect_err("401"); + assert_eq!(error.status(), axum::http::StatusCode::UNAUTHORIZED); + assert!(error.detail().contains("no security context")); +} + +#[tokio::test] +async fn a_failing_credential_store_surfaces_the_store_error() { + let mut ctx = request(); + let plugin = ApiKeyAuthPlugin::new( + Arc::new(CredStoreFailing), + &serde_json::json!({ "secret_ref": "cred://payments-key" }), + ) + .expect("plugin"); + let error = plugin.authenticate(&mut ctx).await.expect_err("500"); + assert_eq!( + error.status(), + axum::http::StatusCode::INTERNAL_SERVER_ERROR + ); +} + +#[test] +fn reference_keys_strip_the_scheme() { + assert_eq!(api_key_reference_key("cred://payments-key"), "payments-key"); +} + +/// Resolver that always fails, exercising the store-error path. +struct CredStoreFailing; + +#[async_trait::async_trait] +impl SecretResolver for CredStoreFailing { + async fn resolve( + &self, + _ctx: &SecurityContext, + secret_ref: &str, + ) -> Result, crate::domain::error::OagwError> { + Err(crate::domain::error::OagwError::secret_not_found(format!( + "credential store lookup for '{secret_ref}' failed" + ))) + } +} 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..df6b3a8 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/mod.rs @@ -0,0 +1,539 @@ +//! Built-in plugins and the plugin registry (ADR-0002 "Built-in Plugins"). +//! +//! Every built-in is a native Rust implementation registered under its GTS +//! plugin id; external plugins (separate ToolKit gears) register through the +//! same [`PluginRegistry`] factories. +//! +//! | GTS plugin id | Kind | Module | +//! |---|---|---| +//! | `...auth_plugin.v1~cf.core.oagw.noop.v1` | auth | [`noop`] | +//! | `...auth_plugin.v1~cf.core.oagw.apikey.v1` | auth | [`apikey`] | +//! | `...auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1` | auth | [`oauth2`] | +//! | `...auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1` | auth | [`oauth2`] | +//! | `...guard_plugin.v1~cf.core.oagw.required_headers.v1` | guard | [`required_headers`] | +//! | `...transform_plugin.v1~cf.core.oagw.request_id.v1` | transform | [`request_id`] | +//! +//! The catalog-only identifiers (`basic`, `bearer`, `timeout`, `cors`, +//! `logging`, `metrics`) are deliberately **not** registered: they have no +//! backing implementation or are core data-plane logic, so binding one must +//! fail with `503 PluginNotFound` instead of silently doing nothing. +//! +//! The same loud failure is the fate of an *enabled* plugin resource the +//! process cannot run (a custom plugin registered over `POST /plugins`, whose +//! Starlark runtime is not deployed here): [`PluginRegistry::build_chain`] +//! answers `503 PluginNotFound` rather than dropping a plugin an operator +//! asked for. A plugin resource with `enabled: false` is the opposite case: its +//! binding is **skipped**, because an operator disabling a plugin expects the +//! request to be served without it, not the upstream to break. The +//! `upstream.auth` binding is never skipped. The disabled set is handed in by +//! the caller as a [`DisabledPlugins`], so the registry stays free of any +//! `RegistryStore` dependency. +//! +//! Configuration is parsed once per plugin construction and treated as +//! immutable (ADR-0002), which is why the guard phase — whose ADR-0002 +//! signature takes `&RequestContext` — can read it directly. +//! +//! ## Instance memoisation +//! +//! A plugin instance is stateful by design: the ADR-0008 OAuth2 plugin owns the +//! token cache that keeps the IdP out of the per-request path. Because +//! [`PluginRegistry::build_chain`] runs for *every* proxied request, the +//! registry memoises the instances it constructed, keyed by the resolved +//! registry key and a fingerprint of the binding configuration, so a binding +//! always resolves to the same instance — and therefore to the same token +//! cache — for as long as the registry lives. A construction that *failed* is +//! never memoised, so a misconfigured binding keeps failing (and keeps being +//! retried) until its configuration changes. + +pub mod apikey; +pub mod noop; +pub mod oauth2; +pub mod request_id; +pub mod required_headers; +pub mod secret; + +use std::collections::HashMap; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::Arc; + +use parking_lot::RwLock; + +use crate::domain::error::OagwError; +use crate::domain::model::{Plugin, PluginBinding, Route, Upstream, reference_matches_plugin}; +use crate::domain::plugin::{ + AUTH_PLUGIN_TYPE_ID, AuthPlugin, GUARD_PLUGIN_TYPE_ID, GuardPlugin, PluginChain, PluginTier, + TRANSFORM_PLUGIN_TYPE_ID, TransformPlugin, +}; +use crate::infra::plugin::apikey::ApiKeyAuthPlugin; +use crate::infra::plugin::noop::NoopAuthPlugin; +use crate::infra::plugin::oauth2::OAuth2ClientCredAuthPlugin; +use crate::infra::plugin::request_id::RequestIdTransformPlugin; +use crate::infra::plugin::required_headers::RequiredHeadersGuardPlugin; +use crate::infra::plugin::secret::SecretResolver; + +pub use crate::infra::plugin::oauth2::{ + BASIC_AUTH_METHOD_TAG, FORM_AUTH_METHOD_TAG, TokenCacheConfig, +}; +pub use crate::infra::plugin::request_id::REQUEST_ID_HEADER; +pub use crate::infra::plugin::required_headers::REQUIRED_HEADER_MISSING; +pub use crate::infra::plugin::secret::{ + CredStoreSecretResolver, SecretResolver as SecretResolverTrait, StaticSecretResolver, + UnavailableSecretResolver, strip_secret_scheme, +}; + +/// Splits a comma-separated header list into the ADR-0009 normal form: entries +/// are trimmed, lower-cased and empty entries are dropped. +#[must_use] +pub fn parse_header_names(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_ascii_lowercase) + .collect() +} + +/// Factory of an auth plugin from a binding configuration payload. +pub type AuthPluginFactory = + Arc Result, OagwError> + Send + Sync>; + +/// Factory of a guard plugin from a binding configuration payload. +pub type GuardPluginFactory = + Arc Result, OagwError> + Send + Sync>; + +/// Factory of a transform plugin from a binding configuration payload. +pub type TransformPluginFactory = + Arc Result, OagwError> + Send + Sync>; + +/// A plugin the registry resolved by id, whatever kind it is. +#[derive(Clone)] +enum ResolvedPlugin { + Auth(Arc), + Guard(Arc), + Transform(Arc), +} + +/// Memoisation key of a constructed plugin: the resolved registry key plus a +/// fingerprint of the binding configuration. +type ConstructionKey = (String, u64); + +/// Upper bound of the memo table. +/// +/// A gateway binds a handful of plugin configurations, so a thousand +/// constructions cover every realistic deployment. Reaching the bound clears +/// the table wholesale — the same flush-on-full policy the L1 caches use — so +/// a churn of configurations cannot grow it without bound. +const CONSTRUCTED_CAPACITY: usize = 1024; + +/// The disabled plugin resources a chain build must skip. +/// +/// The data-plane caller reads them out of its registry store (the disabled +/// plugins of the resolved tenant chain) and hands them to +/// [`PluginRegistry::build_chain`], which keeps [`PluginRegistry`] free of any +/// store dependency. A binding reference matches one of these plugins in either +/// wire spelling — the bare instance UUID and the GTS-form +/// `gts.cf.core.oagw.plugin.v1~{uuid}` id (see [`reference_matches_plugin`]) — +/// while a built-in plugin id never matches, because its instance part is not a +/// UUID. +#[derive(Debug, Clone, Default)] +pub struct DisabledPlugins { + /// The disabled plugin resources of the resolved tenant chain. + plugins: Vec>, +} + +impl DisabledPlugins { + /// Collects the disabled plugins (`enabled: false`) of `plugins`. + #[must_use] + pub fn of>>(plugins: I) -> Self { + Self { + plugins: plugins + .into_iter() + .filter(|plugin| !plugin.enabled) + .collect(), + } + } + + /// `true` when no plugin resource is disabled. + #[must_use] + pub fn is_empty(&self) -> bool { + self.plugins.is_empty() + } + + /// `true` when `reference` names a disabled plugin resource, in either wire + /// spelling. + #[must_use] + pub fn is_disabled(&self, reference: &str) -> bool { + self.plugins + .iter() + .any(|plugin| reference_matches_plugin(reference, plugin)) + } +} + +/// Maps plugin ids to the factories that construct them. +/// +/// Lookups accept the full GTS id and the bare instance fragment +/// (`cf.core.oagw.apikey.v1`), so an operator may write either form; a ref +/// that resolves to nothing is a `503` [`OagwError::PluginNotFound`]. +#[derive(Default)] +pub struct PluginRegistry { + auth: HashMap, + guard: HashMap, + transform: HashMap, + /// Plugin instances already constructed, by `(resolved key, config hash)`. + constructed: RwLock>, +} + +impl Clone for PluginRegistry { + fn clone(&self) -> Self { + Self { + auth: self.auth.clone(), + guard: self.guard.clone(), + transform: self.transform.clone(), + constructed: RwLock::new(self.constructed.read().clone()), + } + } +} + +impl PluginRegistry { + /// Creates an empty registry (no built-ins). + #[must_use] + pub fn empty() -> Self { + Self::default() + } + + /// Creates a registry with every built-in plugin (ADR-0002). + /// + /// `resolver` sources the `cred://` references of the auth plugins; pass + /// [`UnavailableSecretResolver`] when the gear runs without a credential + /// store, which fails every resolution with `500 SecretNotFound` at use + /// time rather than degrading to an unauthenticated upstream call. + #[must_use] + pub fn with_builtins(resolver: Arc, token_cache: TokenCacheConfig) -> Self { + let mut registry = Self::empty(); + registry.register_auth(noop::NoopAuthPlugin::PLUGIN_ID, { + Arc::new(move |_config| Ok(Arc::new(NoopAuthPlugin) as Arc)) + }); + registry.register_auth(apikey::ApiKeyAuthPlugin::PLUGIN_ID, { + let resolver = Arc::clone(&resolver); + Arc::new(move |config| { + let resolver = Arc::clone(&resolver); + let plugin = ApiKeyAuthPlugin::new(resolver, config)?; + Ok(Arc::new(plugin) as Arc) + }) + }); + registry.register_auth(oauth2::OAuth2ClientCredAuthPlugin::FORM_PLUGIN_ID, { + let resolver = Arc::clone(&resolver); + Arc::new(move |config| { + let resolver = Arc::clone(&resolver); + let plugin = OAuth2ClientCredAuthPlugin::new( + resolver, + toolkit_auth::ClientAuthMethod::Form, + token_cache, + config, + )?; + Ok(Arc::new(plugin) as Arc) + }) + }); + registry.register_auth(oauth2::OAuth2ClientCredAuthPlugin::BASIC_PLUGIN_ID, { + let resolver = Arc::clone(&resolver); + Arc::new(move |config| { + let resolver = Arc::clone(&resolver); + let plugin = OAuth2ClientCredAuthPlugin::new( + resolver, + toolkit_auth::ClientAuthMethod::Basic, + token_cache, + config, + )?; + Ok(Arc::new(plugin) as Arc) + }) + }); + registry.register_guard( + required_headers::RequiredHeadersGuardPlugin::PLUGIN_ID, + Arc::new(|config| { + Ok(Arc::new(RequiredHeadersGuardPlugin::new(config)) as Arc) + }), + ); + registry.register_transform( + request_id::RequestIdTransformPlugin::PLUGIN_ID, + Arc::new(|_config| Ok(Arc::new(RequestIdTransformPlugin) as Arc)), + ); + registry + } + + /// Registers an auth plugin factory under `plugin_ref`. + /// + /// Re-registering a reference forgets the instances built for it: a new + /// factory must never be shadowed by a memoised predecessor. + pub fn register_auth(&mut self, plugin_ref: impl Into, factory: AuthPluginFactory) { + self.auth.insert(plugin_ref.into(), factory); + self.constructed.write().clear(); + } + + /// Registers a guard plugin factory under `plugin_ref`. + /// + /// Re-registering a reference forgets the instances built for it, exactly + /// like [`PluginRegistry::register_auth`]. + pub fn register_guard(&mut self, plugin_ref: impl Into, factory: GuardPluginFactory) { + self.guard.insert(plugin_ref.into(), factory); + self.constructed.write().clear(); + } + + /// Registers a transform plugin factory under `plugin_ref`. + /// + /// Re-registering a reference forgets the instances built for it, exactly + /// like [`PluginRegistry::register_auth`]. + pub fn register_transform( + &mut self, + plugin_ref: impl Into, + factory: TransformPluginFactory, + ) { + self.transform.insert(plugin_ref.into(), factory); + self.constructed.write().clear(); + } + + /// `true` when `plugin_ref` resolves to a registered plugin. + #[must_use] + pub fn contains(&self, plugin_ref: &str) -> bool { + self.resolve_key(plugin_ref).is_some() + } + + /// Number of registered plugins, across all three kinds. + #[must_use] + pub fn len(&self) -> usize { + self.auth.len() + self.guard.len() + self.transform.len() + } + + /// `true` when no plugin is registered. + #[must_use] + pub fn is_empty(&self) -> bool { + self.len() == 0 + } + + /// Builds an auth plugin from `(id, config)`. + /// + /// # Errors + /// + /// Returns [`OagwError::PluginNotFound`] when the id is unknown, and the + /// plugin's own error when the configuration is invalid. + pub fn build_auth( + &self, + plugin_ref: &str, + config: &serde_json::Value, + ) -> Result, OagwError> { + match self.build_any(plugin_ref, config)? { + ResolvedPlugin::Auth(plugin) => Ok(plugin), + _ => Err(plugin_not_found(plugin_ref)), + } + } + + /// Builds a guard plugin from `(id, config)`. + /// + /// # Errors + /// + /// Returns [`OagwError::PluginNotFound`] when the id is unknown, and the + /// plugin's own error when the configuration is invalid. + pub fn build_guard( + &self, + plugin_ref: &str, + config: &serde_json::Value, + ) -> Result, OagwError> { + match self.build_any(plugin_ref, config)? { + ResolvedPlugin::Guard(plugin) => Ok(plugin), + _ => Err(plugin_not_found(plugin_ref)), + } + } + + /// Builds a transform plugin from `(id, config)`. + /// + /// # Errors + /// + /// Returns [`OagwError::PluginNotFound`] when the id is unknown, and the + /// plugin's own error when the configuration is invalid. + pub fn build_transform( + &self, + plugin_ref: &str, + config: &serde_json::Value, + ) -> Result, OagwError> { + match self.build_any(plugin_ref, config)? { + ResolvedPlugin::Transform(plugin) => Ok(plugin), + _ => Err(plugin_not_found(plugin_ref)), + } + } + + /// Builds the ADR-0002 plugin chain of one proxy request: the upstream + /// auth binding first, then the upstream plugin chain, then the route + /// plugin chain (upstream tier before route tier, declaration order kept). + /// + /// A plugin binding whose reference names one of `disabled_plugins` (a + /// plugin resource with `enabled: false`) is skipped, so a disabled plugin + /// degrades to "not applied" instead of breaking the upstream; the + /// `upstream.auth` binding is never skipped. + /// + /// # Errors + /// + /// Returns [`OagwError::PluginNotFound`] when a binding that is not skipped + /// (the auth binding included) references an unknown plugin, and the + /// plugin's own error when its configuration is invalid. + pub fn build_chain( + &self, + upstream: &Upstream, + route: Option<&Route>, + disabled_plugins: &DisabledPlugins, + ) -> Result { + let mut chain = PluginChain::new(); + if let Some(auth) = upstream.auth.as_ref() { + let plugin = self.build_auth(&auth.auth_type, &auth.config)?; + chain.push_auth(PluginTier::Upstream, 0, auth.auth_type.clone(), plugin); + } + for (index, binding) in upstream.plugins.items.iter().enumerate() { + self.push_binding( + &mut chain, + PluginTier::Upstream, + index, + binding, + disabled_plugins, + )?; + } + if let Some(route) = route { + for (index, binding) in route.plugins.items.iter().enumerate() { + self.push_binding( + &mut chain, + PluginTier::Route, + index, + binding, + disabled_plugins, + )?; + } + } + Ok(chain) + } + + fn push_binding( + &self, + chain: &mut PluginChain, + tier: PluginTier, + declaration: usize, + binding: &PluginBinding, + disabled_plugins: &DisabledPlugins, + ) -> Result<(), OagwError> { + if disabled_plugins.is_disabled(&binding.plugin_ref) { + return Ok(()); + } + match self.build_any(&binding.plugin_ref, &binding.config)? { + ResolvedPlugin::Auth(plugin) => { + chain.push_auth(tier, declaration, binding.plugin_ref.clone(), plugin); + } + ResolvedPlugin::Guard(plugin) => { + chain.push_guard(tier, declaration, binding.plugin_ref.clone(), plugin); + } + ResolvedPlugin::Transform(plugin) => { + chain.push_transform(tier, declaration, binding.plugin_ref.clone(), plugin); + } + } + Ok(()) + } + + /// Builds — or returns the memoised — plugin instance of one binding. + /// + /// The memo key is the *resolved* registry key, so both spellings of a + /// reference (the GTS id and the bare instance fragment) share one + /// instance, which is what a stable token cache across requests needs. + /// + /// # Errors + /// + /// Returns [`OagwError::PluginNotFound`] when the id is unknown, and the + /// plugin's own error when the configuration is invalid. A failure is never + /// memoised: the next request constructs the plugin again. + fn build_any( + &self, + plugin_ref: &str, + config: &serde_json::Value, + ) -> Result { + let Some(key) = self.resolve_key(plugin_ref) else { + return Err(plugin_not_found(plugin_ref)); + }; + let config_hash = config_hash(config); + if let Some(cached) = self.constructed.read().get(&(key.clone(), config_hash)) { + return Ok(cached.clone()); + } + let built = self.construct(&key, plugin_ref, config)?; + let mut constructed = self.constructed.write(); + if constructed.len() >= CONSTRUCTED_CAPACITY + && !constructed.contains_key(&(key.clone(), config_hash)) + { + constructed.clear(); + } + constructed.insert((key, config_hash), built.clone()); + Ok(built) + } + + /// Runs the factory of `key` once, without touching the memo table. + fn construct( + &self, + key: &str, + plugin_ref: &str, + config: &serde_json::Value, + ) -> Result { + if let Some(factory) = self.auth.get(key) { + return factory(config).map(ResolvedPlugin::Auth); + } + if let Some(factory) = self.guard.get(key) { + return factory(config).map(ResolvedPlugin::Guard); + } + self.transform + .get(key) + .ok_or_else(|| plugin_not_found(plugin_ref))?(config) + .map(ResolvedPlugin::Transform) + } + + /// Canonical registry key of a plugin reference, trying the bare ref and + /// then each GTS base type prefix. + fn resolve_key(&self, plugin_ref: &str) -> Option { + if self.auth.contains_key(plugin_ref) + || self.guard.contains_key(plugin_ref) + || self.transform.contains_key(plugin_ref) + { + return Some(plugin_ref.to_owned()); + } + for base in [ + AUTH_PLUGIN_TYPE_ID, + GUARD_PLUGIN_TYPE_ID, + TRANSFORM_PLUGIN_TYPE_ID, + ] { + let qualified = format!("{base}~{plugin_ref}"); + if self.auth.contains_key(&qualified) + || self.guard.contains_key(&qualified) + || self.transform.contains_key(&qualified) + { + return Some(qualified); + } + } + None + } +} + +/// `503` problem document for an unresolvable plugin reference. +fn plugin_not_found(plugin_ref: &str) -> OagwError { + OagwError::plugin_not_found(format!("plugin '{plugin_ref}' is not registered")) + .with_plugin_id(plugin_ref) +} + +/// Stable fingerprint of a binding configuration, the second half of the +/// memoisation key. +/// +/// `serde_json::Map` is a `BTreeMap` in this workspace, so the canonical +/// serialisation is already key-sorted and two equal payloads hash equally no +/// matter the order their keys arrived in. The serialisation of a value that +/// cannot be represented (impossible for a decoded `serde_json::Value`) hashes +/// as empty, which can only ever make two such configurations share an +/// instance — never hand out a wrong one. +fn config_hash(config: &serde_json::Value) -> u64 { + let mut hasher = DefaultHasher::new(); + serde_json::to_vec(config) + .unwrap_or_default() + .hash(&mut hasher); + hasher.finish() +} + +#[cfg(test)] +#[path = "plugin_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/plugin/noop.rs b/gears/system/oagw/oagw/src/infra/plugin/noop.rs new file mode 100644 index 0000000..f97756f --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/noop.rs @@ -0,0 +1,42 @@ +//! Built-in auth no-op plugin (`gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1`). +//! +//! The default auth binding of an upstream that needs no outbound credential: +//! it changes nothing about the request and never fails, which keeps the +//! ADR-0002 execution order (auth -> guards -> transform) intact for upstreams +//! that would otherwise have no auth plugin at all. + +use async_trait::async_trait; + +use crate::domain::error::OagwError; +use crate::domain::plugin::{AUTH_PLUGIN_TYPE_ID, AuthPlugin, RequestContext, builtin}; + +/// Auth no-op plugin. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopAuthPlugin; + +impl NoopAuthPlugin { + /// Registry key of this plugin. + pub const PLUGIN_ID: &'static str = builtin::NOOP_AUTH; + + /// GTS base type of this plugin. + pub const PLUGIN_TYPE: &'static str = AUTH_PLUGIN_TYPE_ID; +} + +#[async_trait] +impl AuthPlugin for NoopAuthPlugin { + fn id(&self) -> &str { + Self::PLUGIN_ID + } + + fn plugin_type(&self) -> &str { + Self::PLUGIN_TYPE + } + + async fn authenticate(&self, _ctx: &mut RequestContext) -> Result<(), OagwError> { + Ok(()) + } +} + +#[cfg(test)] +#[path = "noop_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/plugin/noop_tests.rs b/gears/system/oagw/oagw/src/infra/plugin/noop_tests.rs new file mode 100644 index 0000000..6aa17e3 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/noop_tests.rs @@ -0,0 +1,34 @@ +//! Tests for [`crate::infra::plugin::noop`]. + +use uuid::Uuid; + +use super::NoopAuthPlugin; +use crate::domain::plugin::{AUTH_PLUGIN_TYPE_ID, AuthPlugin, RequestContext, builtin}; + +fn request() -> RequestContext { + RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1/payments") + .tenant_id(Uuid::from_u128(0x11)) + .build() +} + +#[test] +fn noop_plugin_declares_the_adr_ids() { + let plugin = NoopAuthPlugin; + assert_eq!(plugin.id(), builtin::NOOP_AUTH); + assert_eq!(plugin.plugin_type(), AUTH_PLUGIN_TYPE_ID); + assert_eq!(NoopAuthPlugin::PLUGIN_ID, builtin::NOOP_AUTH); + assert_eq!(NoopAuthPlugin::PLUGIN_TYPE, AUTH_PLUGIN_TYPE_ID); +} + +#[tokio::test] +async fn noop_plugin_injects_nothing() { + let mut ctx = request(); + NoopAuthPlugin.authenticate(&mut ctx).await.expect("noop"); + assert!(ctx.injected_headers.is_empty()); + assert!(ctx.injected_query.is_empty()); + assert!(ctx.security.is_none()); + assert!(ctx.request_id.is_none()); +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/oauth2.rs b/gears/system/oagw/oagw/src/infra/plugin/oauth2.rs new file mode 100644 index 0000000..4931697 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/oauth2.rs @@ -0,0 +1,440 @@ +//! Built-in OAuth2 client-credentials auth plugin (ADR-0008), registered twice: +//! +//! | GTS plugin id | Client auth method | +//! |---|---| +//! | `...auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1` | `Form` (credentials in the request body) | +//! | `...auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1` | `Basic` (credentials in the `Authorization` header) | +//! +//! ## Configuration keys +//! +//! | Key | Required | Meaning | +//! |---|---|---| +//! | `token_endpoint` | XOR `issuer_url` | Direct token endpoint URL. | +//! | `issuer_url` | XOR `token_endpoint` | OIDC issuer URL; the token endpoint is discovered. | +//! | `client_id_ref` | yes | `cred://` reference for the client id. | +//! | `client_secret_ref` | yes | `cred://` reference for the client secret. | +//! | `scopes` | no | Space-separated OAuth2 scopes. | +//! +//! ## Token cache +//! +//! * TTL = `min(configured ttl, expires_in - 30s safety margin)`; +//! * tokens with `expires_in <= 30s` are **not** cached; +//! * key = `{tenant}:{subject}:{auth_method_tag}:{config_hash}`, with the +//! original key verified on every hit so a hash collision can never hand out +//! another tenant's token; +//! * failed fetches are **never** cached — the next request retries the IdP; +//! * the default TTL and the capacity come from +//! [`OagwConfig::token_cache_ttl_secs`] / [`OagwConfig::token_cache_capacity`]. +//! +//! ## Failure mapping +//! +//! ADR-0008 reports both credential-store and IdP failures as an internal +//! plugin error without pinning an HTTP status, so this module maps them onto +//! the gear's own taxonomy: +//! +//! | Failure | Status | GTS type | +//! |---|---|---| +//! | Malformed plugin configuration | 400 | `cf.oagw.validation.error.v1` | +//! | Credential store unreachable / failing | 500 | `cf.oagw.secret.not_found.v1` | +//! | Reference not accessible to the tenant | 401 | `cf.oagw.auth.failed.v1` | +//! | Token endpoint request failed | 502 | `cf.oagw.downstream.error.v1` | +//! +//! ## Deviation from ADR-0008 (documented) +//! +//! The ADR stores `SecretString` in the cache; the in-memory cache this gear +//! uses requires `Clone`, which `SecretString` does not implement, so the +//! cached token is a plain `String` whose `Debug` impl is redacted. The value +//! is still never logged, and the cache holds only bearer tokens (not client +//! secrets, which stay inside the fetch call). + +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use axum::http::{HeaderName, HeaderValue}; +use pingora_memory_cache::MemoryCache; +use serde::Deserialize; +use toolkit_auth::{ClientAuthMethod, OAuthClientConfig, SecretString, fetch_token}; +use toolkit_security::SecurityContext; +use url::Url; + +use crate::config::OagwConfig; +use crate::domain::error::OagwError; +use crate::domain::plugin::{AUTH_PLUGIN_TYPE_ID, AuthPlugin, RequestContext, builtin}; +use crate::infra::plugin::secret::SecretResolver; + +/// Safety margin subtracted from the IdP-reported `expires_in` (ADR-0008). +pub const TOKEN_EXPIRY_SAFETY_MARGIN: Duration = Duration::from_secs(30); + +/// `Authorization` header the bearer token is injected into. +pub const AUTHORIZATION_HEADER: &str = "authorization"; + +/// Tag of the `Form` client-auth method in the cache key. +pub const FORM_AUTH_METHOD_TAG: &str = "form"; + +/// Tag of the `Basic` client-auth method in the cache key. +pub const BASIC_AUTH_METHOD_TAG: &str = "basic"; + +/// Cache configuration handed down from [`OagwConfig`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TokenCacheConfig { + /// Upper bound for a cached access token TTL. + pub ttl: Duration, + /// Maximum number of cached tokens. + pub capacity: usize, +} + +impl From<&OagwConfig> for TokenCacheConfig { + fn from(config: &OagwConfig) -> Self { + Self { + ttl: Duration::from_secs(config.token_cache_ttl_secs), + capacity: config.token_cache_capacity, + } + } +} + +/// Configuration payload of the OAuth2 plugin. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(deny_unknown_fields)] +struct OAuth2PluginConfig { + /// Direct token endpoint URL; mutually exclusive with `issuer_url`. + #[serde(default, skip_serializing_if = "Option::is_none")] + token_endpoint: Option, + /// OIDC issuer URL; mutually exclusive with `token_endpoint`. + #[serde(default, skip_serializing_if = "Option::is_none")] + issuer_url: Option, + /// `cred://` reference for the client id. + client_id_ref: String, + /// `cred://` reference for the client secret. + client_secret_ref: String, + /// Space-separated OAuth2 scopes. + #[serde(default)] + scopes: Option, +} + +/// A cached bearer token, carrying the key it was stored under so a hash +/// collision degrades to a miss instead of another tenant's token (ADR-0008). +#[derive(Clone)] +struct CachedToken { + key: String, + token: Arc, +} + +impl std::fmt::Debug for CachedToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CachedToken") + .field("key", &self.key) + .field("token", &"[REDACTED]") + .finish() + } +} + +/// OAuth2 client credentials plugin. +pub struct OAuth2ClientCredAuthPlugin { + resolver: Arc, + auth_method: ClientAuthMethod, + config: OAuth2PluginConfig, + /// Deterministic fingerprint of the binding configuration (cache key). + config_hash: String, + cache: MemoryCache, + cache_ttl: Duration, + http_config: Option, +} + +// The resolver port and the token cache are not `Debug`, so the plugin reports +// only its identity and configuration fingerprint. +impl std::fmt::Debug for OAuth2ClientCredAuthPlugin { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OAuth2ClientCredAuthPlugin") + .field("id", &self.id()) + .field("auth_method", &self.auth_method) + .field("config_hash", &self.config_hash) + .field("cache_ttl", &self.cache_ttl) + .finish_non_exhaustive() + } +} + +impl OAuth2ClientCredAuthPlugin { + /// Registry key of the `Form` variant. + pub const FORM_PLUGIN_ID: &'static str = builtin::OAUTH2_CLIENT_CRED; + + /// Registry key of the `Basic` variant. + pub const BASIC_PLUGIN_ID: &'static str = builtin::OAUTH2_CLIENT_CRED_BASIC; + + /// GTS base type of this plugin. + pub const PLUGIN_TYPE: &'static str = AUTH_PLUGIN_TYPE_ID; + + /// Builds one plugin variant from a binding configuration payload. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when the payload does not match the + /// documented keys, when `token_endpoint` and `issuer_url` are both set or + /// both missing, when a reference is not a `cred://` URI, or when the + /// token endpoint is not a valid URL. + pub fn new( + resolver: Arc, + auth_method: ClientAuthMethod, + cache_config: TokenCacheConfig, + config: &serde_json::Value, + ) -> Result { + let parsed = parse_config(config)?; + validate_endpoints(&parsed)?; + validate_references(&parsed)?; + let cache = MemoryCache::new(cache_config.capacity.max(1)); + Ok(Self { + resolver, + auth_method, + config: parsed, + config_hash: config_fingerprint(config), + cache, + cache_ttl: cache_config.ttl, + http_config: None, + }) + } + + /// Overrides the HTTP client configuration used for the token exchange + /// (and for OIDC discovery). Tests install a plaintext-tolerant preset. + pub fn set_http_config(&mut self, http_config: toolkit_http::HttpClientConfig) { + self.http_config = Some(http_config); + } + + /// Cache key for one `(tenant, subject, auth method, config)` tuple. + #[must_use] + pub fn cache_key(&self, ctx: &RequestContext) -> String { + format!( + "{}:{}:{}:{}", + tenant_of(ctx), + subject_of(ctx), + auth_method_tag(self.auth_method), + self.config_hash + ) + } + + /// Deterministic fingerprint of the binding configuration. + #[must_use] + pub fn config_fingerprint(&self) -> String { + self.config_hash.clone() + } + + fn lookup(&self, key: &str) -> Option> { + let (entry, _status) = self.cache.get(key); + let entry = entry?; + if entry.key != key { + return None; + } + Some(entry.token) + } + + fn store(&self, key: &str, token: SecretString, ttl: Duration) { + self.cache.put( + &key.to_owned(), + CachedToken { + key: key.to_owned(), + token: Arc::new(token), + }, + Some(ttl), + ); + } + + async fn resolve( + &self, + security: &SecurityContext, + reference: &str, + ) -> Result { + self.resolver + .resolve(security, reference) + .await? + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + OagwError::authentication_failed(format!( + "oauth2 credential '{reference}' is not accessible to this tenant" + )) + }) + } + + async fn fetch_bearer_token( + &self, + security: &SecurityContext, + ) -> Result<(String, Duration), OagwError> { + let client_id = self.resolve(security, &self.config.client_id_ref).await?; + let client_secret = self + .resolve(security, &self.config.client_secret_ref) + .await?; + let client_config = OAuthClientConfig { + token_endpoint: self + .parsed_url("token_endpoint", self.config.token_endpoint.as_ref())?, + issuer_url: self.parsed_url("issuer_url", self.config.issuer_url.as_ref())?, + client_id, + client_secret: SecretString::new(client_secret), + scopes: self.parsed_scopes(), + auth_method: self.auth_method, + http_config: self.http_config.clone(), + ..OAuthClientConfig::default() + }; + client_config.validate().map_err(|error| { + OagwError::validation(format!("invalid oauth2 plugin configuration: {error}")) + })?; + let fetched = fetch_token(client_config).await.map_err(|error| { + OagwError::downstream_error(format!("oauth2 token endpoint request failed: {error}")) + })?; + Ok((fetched.bearer.expose().to_owned(), fetched.expires_in)) + } + + fn parsed_url(&self, field: &str, value: Option<&String>) -> Result, OagwError> { + value + .map(|raw| { + Url::parse(raw).map_err(|error| { + OagwError::validation(format!("invalid oauth2 {field} '{raw}': {error}")) + }) + }) + .transpose() + } + + fn parsed_scopes(&self) -> Vec { + self.config + .scopes + .as_deref() + .map(|scopes| scopes.split_whitespace().map(str::to_owned).collect()) + .unwrap_or_default() + } + + /// Cache TTL for a token that expires in `expires_in`, or `None` when the + /// token must not be cached (ADR-0008). + #[must_use] + pub fn cache_ttl_for(&self, expires_in: Duration) -> Option { + if expires_in <= TOKEN_EXPIRY_SAFETY_MARGIN { + return None; + } + let ttl = expires_in + .checked_sub(TOKEN_EXPIRY_SAFETY_MARGIN) + .unwrap_or_default(); + Some(self.cache_ttl.min(ttl)).filter(|ttl| !ttl.is_zero()) + } +} + +fn parse_config(config: &serde_json::Value) -> Result { + let payload = match config { + serde_json::Value::Null => &serde_json::Value::Object(serde_json::Map::new()), + value => value, + }; + serde_json::from_value(payload.clone()) + .map_err(|error| OagwError::validation(format!("invalid oauth2 plugin config: {error}"))) +} + +fn validate_endpoints(config: &OAuth2PluginConfig) -> Result<(), OagwError> { + match (config.token_endpoint.as_ref(), config.issuer_url.as_ref()) { + (Some(_), Some(_)) => Err(OagwError::validation( + "oauth2 plugin accepts either `token_endpoint` or `issuer_url`, not both", + )), + (None, None) => Err(OagwError::validation( + "oauth2 plugin requires one of `token_endpoint` or `issuer_url`", + )), + _ => Ok(()), + } +} + +fn validate_references(config: &OAuth2PluginConfig) -> Result<(), OagwError> { + for (field, reference) in [ + ("client_id_ref", &config.client_id_ref), + ("client_secret_ref", &config.client_secret_ref), + ] { + if !reference.starts_with(crate::infra::plugin::secret::SECRET_REF_SCHEME) { + return Err(OagwError::validation(format!( + "oauth2 plugin field `{field}` must be a '{scheme}' reference, got '{reference}'", + scheme = crate::infra::plugin::secret::SECRET_REF_SCHEME + ))); + } + } + Ok(()) +} + +/// Deterministic, order-independent fingerprint of the binding configuration. +/// +/// `serde_json::Map` is a `BTreeMap` in this workspace, so the canonical +/// serialisation is already key-sorted; the value is hashed to keep cache keys +/// short. +#[must_use] +fn config_fingerprint(config: &serde_json::Value) -> String { + let canonical = serde_json::to_string(config).unwrap_or_default(); + let mut hasher = DefaultHasher::new(); + canonical.hash(&mut hasher); + format!("{:016x}", hasher.finish()) +} + +/// Tag an auth method contributes to the cache key (ADR-0008). +#[must_use] +pub const fn auth_method_tag(method: ClientAuthMethod) -> &'static str { + match method { + ClientAuthMethod::Form => FORM_AUTH_METHOD_TAG, + ClientAuthMethod::Basic => BASIC_AUTH_METHOD_TAG, + } +} + +/// Tenant of the request, preferring the security context. +fn tenant_of(ctx: &RequestContext) -> uuid::Uuid { + ctx.security + .as_ref() + .map(|security| security.subject_tenant_id()) + .unwrap_or(ctx.tenant_id) +} + +/// Subject of the request, preferring the security context. +fn subject_of(ctx: &RequestContext) -> uuid::Uuid { + ctx.security + .as_ref() + .map(|security| security.subject_id()) + .or(ctx.subject_id) + .unwrap_or_default() +} + +fn inject_bearer(ctx: &mut RequestContext, token: &str) -> Result<(), OagwError> { + let value = format!("Bearer {token}"); + let header_value = HeaderValue::from_str(&value) + .map_err(|_| OagwError::validation("oauth2 bearer token is not a valid header value"))?; + let header_name = HeaderName::from_static(AUTHORIZATION_HEADER); + ctx.headers + .insert(header_name.clone(), header_value.clone()); + ctx.inject_header(header_name, header_value); + Ok(()) +} + +#[async_trait] +impl AuthPlugin for OAuth2ClientCredAuthPlugin { + fn id(&self) -> &str { + match self.auth_method { + ClientAuthMethod::Form => builtin::OAUTH2_CLIENT_CRED, + ClientAuthMethod::Basic => builtin::OAUTH2_CLIENT_CRED_BASIC, + } + } + + fn plugin_type(&self) -> &str { + AUTH_PLUGIN_TYPE_ID + } + + async fn authenticate(&self, ctx: &mut RequestContext) -> Result<(), OagwError> { + let key = self.cache_key(ctx); + if let Some(token) = self.lookup(&key) { + inject_bearer(ctx, token.expose())?; + return Ok(()); + } + let Some(security) = ctx.security.clone() else { + return Err(OagwError::authentication_failed( + "no security context is available to resolve the oauth2 credentials", + )); + }; + let (token, expires_in) = self.fetch_bearer_token(&security).await?; + if let Some(ttl) = self.cache_ttl_for(expires_in) { + self.store(&key, SecretString::new(token.clone()), ttl); + } + inject_bearer(ctx, &token)?; + Ok(()) + } +} + +#[cfg(test)] +#[path = "oauth2_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/plugin/oauth2_tests.rs b/gears/system/oagw/oagw/src/infra/plugin/oauth2_tests.rs new file mode 100644 index 0000000..a327756 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/oauth2_tests.rs @@ -0,0 +1,695 @@ +//! Tests for [`crate::infra::plugin::oauth2`]. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use credstore_sdk::test_util::MockCredStoreClient; +use httpmock::prelude::*; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use super::{ + AUTHORIZATION_HEADER, BASIC_AUTH_METHOD_TAG, FORM_AUTH_METHOD_TAG, OAuth2ClientCredAuthPlugin, + TOKEN_EXPIRY_SAFETY_MARGIN, TokenCacheConfig, +}; +use crate::config::OagwConfig; +use crate::domain::plugin::{AUTH_PLUGIN_TYPE_ID, AuthPlugin, RequestContext, builtin}; +use crate::infra::plugin::secret::{CredStoreSecretResolver, SecretResolver, StaticSecretResolver}; + +const TENANT: Uuid = Uuid::from_u128(0x11); +const SUBJECT: Uuid = Uuid::from_u128(0x33); + +fn security() -> Arc { + security_for(SUBJECT, TENANT) +} + +fn security_for(subject: Uuid, tenant: Uuid) -> Arc { + Arc::new( + SecurityContext::builder() + .subject_id(subject) + .subject_tenant_id(tenant) + .build() + .expect("security context"), + ) +} + +fn resolver() -> Arc { + Arc::new(StaticSecretResolver::new(HashMap::from([ + ("client-id".to_owned(), "test-client".to_owned()), + ("client-secret".to_owned(), "test-secret".to_owned()), + ("other-client-id".to_owned(), "other-client".to_owned()), + ("other-client-secret".to_owned(), "other-secret".to_owned()), + ]))) +} + +fn cache_config() -> TokenCacheConfig { + TokenCacheConfig { + ttl: Duration::from_secs(300), + capacity: 16, + } +} + +fn plugin( + auth_method: toolkit_auth::ClientAuthMethod, + config: serde_json::Value, +) -> OAuth2ClientCredAuthPlugin { + let mut plugin = + OAuth2ClientCredAuthPlugin::new(resolver(), auth_method, cache_config(), &config) + .expect("plugin"); + plugin.set_http_config(toolkit_http::HttpClientConfig::for_testing()); + plugin +} + +fn form_plugin(config: serde_json::Value) -> OAuth2ClientCredAuthPlugin { + plugin(toolkit_auth::ClientAuthMethod::Form, config) +} + +fn basic_plugin(config: serde_json::Value) -> OAuth2ClientCredAuthPlugin { + plugin(toolkit_auth::ClientAuthMethod::Basic, config) +} + +fn request() -> RequestContext { + RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1/payments") + .tenant_id(TENANT) + .security(security()) + .build() +} + +fn token_body(token: &str, expires_in: u64) -> String { + format!(r#"{{"access_token":"{token}","expires_in":{expires_in},"token_type":"Bearer"}}"#) +} + +fn token_endpoint_config(server: &MockServer) -> serde_json::Value { + endpoint_config(&format!("http://localhost:{}/token", server.port())) +} + +fn endpoint_config(token_endpoint: &str) -> serde_json::Value { + serde_json::json!({ + "token_endpoint": token_endpoint, + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret" + }) +} + +const FIRST_TOKEN_ENDPOINT: &str = "https://idp-one.example.com/token"; +const OTHER_TOKEN_ENDPOINT: &str = "https://idp-two.example.com/token"; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +#[test] +fn plugin_declares_the_adr_ids() { + let config = serde_json::json!({ + "token_endpoint": "https://idp.example.com/token", + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret" + }); + assert_eq!( + form_plugin(config.clone()).id(), + builtin::OAUTH2_CLIENT_CRED + ); + assert_eq!(basic_plugin(config).id(), builtin::OAUTH2_CLIENT_CRED_BASIC); + assert_eq!( + OAuth2ClientCredAuthPlugin::FORM_PLUGIN_ID, + builtin::OAUTH2_CLIENT_CRED + ); + assert_eq!( + OAuth2ClientCredAuthPlugin::BASIC_PLUGIN_ID, + builtin::OAUTH2_CLIENT_CRED_BASIC + ); + assert_eq!(OAuth2ClientCredAuthPlugin::PLUGIN_TYPE, AUTH_PLUGIN_TYPE_ID); +} + +#[test] +fn token_endpoint_and_issuer_url_are_mutually_exclusive() { + let config = serde_json::json!({ + "token_endpoint": "https://idp.example.com/token", + "issuer_url": "https://idp.example.com", + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret" + }); + let error = OAuth2ClientCredAuthPlugin::new( + resolver(), + toolkit_auth::ClientAuthMethod::Form, + cache_config(), + &config, + ) + .expect_err("both set"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST); +} + +#[test] +fn one_of_token_endpoint_or_issuer_url_is_required() { + let config = serde_json::json!({ + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret" + }); + let error = OAuth2ClientCredAuthPlugin::new( + resolver(), + toolkit_auth::ClientAuthMethod::Form, + cache_config(), + &config, + ) + .expect_err("neither set"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST); +} + +#[test] +fn references_must_be_cred_uris() { + let config = serde_json::json!({ + "token_endpoint": "https://idp.example.com/token", + "client_id_ref": "client-id", + "client_secret_ref": "cred://client-secret" + }); + let error = OAuth2ClientCredAuthPlugin::new( + resolver(), + toolkit_auth::ClientAuthMethod::Form, + cache_config(), + &config, + ) + .expect_err("not a cred:// reference"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST); + assert!(error.detail().contains("client_id_ref")); +} + +#[test] +fn unknown_configuration_keys_are_rejected() { + let config = serde_json::json!({ + "token_endpoint": "https://idp.example.com/token", + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret", + "unknown": true + }); + let error = OAuth2ClientCredAuthPlugin::new( + resolver(), + toolkit_auth::ClientAuthMethod::Form, + cache_config(), + &config, + ) + .expect_err("unknown key"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn an_invalid_token_endpoint_url_is_rejected_with_400() { + let config = endpoint_config("not a url"); + let mut ctx = request(); + let error = form_plugin(config) + .authenticate(&mut ctx) + .await + .expect_err("400"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_REQUEST); + assert!(error.detail().contains("token_endpoint")); +} + +#[test] +fn the_method_tags_match_the_adr() { + assert_eq!( + super::auth_method_tag(toolkit_auth::ClientAuthMethod::Form), + FORM_AUTH_METHOD_TAG + ); + assert_eq!( + super::auth_method_tag(toolkit_auth::ClientAuthMethod::Basic), + BASIC_AUTH_METHOD_TAG + ); + assert_eq!(FORM_AUTH_METHOD_TAG, "form"); + assert_eq!(BASIC_AUTH_METHOD_TAG, "basic"); +} + +#[test] +fn the_config_fingerprint_is_order_independent_and_stable() { + let left = form_plugin(endpoint_config(FIRST_TOKEN_ENDPOINT)); + let right = form_plugin(serde_json::json!({ + "client_secret_ref": "cred://client-secret", + "token_endpoint": FIRST_TOKEN_ENDPOINT, + "client_id_ref": "cred://client-id" + })); + assert_eq!(left.config_fingerprint(), right.config_fingerprint()); + + let different = form_plugin(endpoint_config(OTHER_TOKEN_ENDPOINT)); + assert_ne!(left.config_fingerprint(), different.config_fingerprint()); +} + +#[test] +fn cache_keys_separate_tenants_subjects_methods_and_configs() { + let base = form_plugin(endpoint_config(FIRST_TOKEN_ENDPOINT)); + let ctx = request(); + + let key = base.cache_key(&ctx); + assert!( + key.starts_with(&format!("{TENANT}:{SUBJECT}:form:")), + "key is '{key}'" + ); + + let other_tenant = RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1") + .tenant_id(TENANT) + .security(security_for(SUBJECT, Uuid::from_u128(0x99))) + .build(); + assert_ne!(key, base.cache_key(&other_tenant)); + + let other_subject = RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1") + .tenant_id(TENANT) + .security(security_for(Uuid::from_u128(0x44), TENANT)) + .build(); + assert_ne!(key, base.cache_key(&other_subject)); + + let other_method = basic_plugin(endpoint_config(FIRST_TOKEN_ENDPOINT)); + assert_ne!(key, other_method.cache_key(&ctx)); + + let other_config = form_plugin(endpoint_config(OTHER_TOKEN_ENDPOINT)); + assert_ne!(key, other_config.cache_key(&ctx)); +} + +#[test] +fn the_config_cache_ttl_is_derived_from_the_gear_configuration() { + let config = OagwConfig::default(); + let cache = TokenCacheConfig::from(&config); + assert_eq!(cache.ttl, Duration::from_secs(config.token_cache_ttl_secs)); + assert_eq!(cache.capacity, config.token_cache_capacity); + assert_eq!(cache.ttl, Duration::from_secs(300)); + assert_eq!(cache.capacity, 10_000); +} + +// --------------------------------------------------------------------------- +// Cache TTL rules (ADR-0008) +// --------------------------------------------------------------------------- + +#[test] +fn the_safety_margin_is_thirty_seconds() { + assert_eq!(TOKEN_EXPIRY_SAFETY_MARGIN, Duration::from_secs(30)); +} + +#[test] +fn ttl_is_capped_by_the_configured_ttl() { + let base = form_plugin(endpoint_config(FIRST_TOKEN_ENDPOINT)); + assert_eq!( + base.cache_ttl_for(Duration::from_secs(3_600)), + Some(Duration::from_secs(300)) + ); +} + +#[test] +fn ttl_is_reduced_by_the_safety_margin() { + let base = form_plugin(endpoint_config(FIRST_TOKEN_ENDPOINT)); + assert_eq!( + base.cache_ttl_for(Duration::from_secs(45)), + Some(Duration::from_secs(15)) + ); +} + +#[test] +fn tokens_expiring_within_the_margin_are_not_cached() { + let base = form_plugin(endpoint_config(FIRST_TOKEN_ENDPOINT)); + assert_eq!(base.cache_ttl_for(Duration::from_secs(30)), None); + assert_eq!(base.cache_ttl_for(Duration::from_secs(29)), None); + assert_eq!(base.cache_ttl_for(Duration::from_secs(0)), None); +} + +#[test] +fn a_zero_configured_ttl_disables_the_cache() { + let plugin = OAuth2ClientCredAuthPlugin::new( + resolver(), + toolkit_auth::ClientAuthMethod::Form, + TokenCacheConfig { + ttl: Duration::ZERO, + capacity: 16, + }, + &token_endpoint_config(&MockServer::start()), + ) + .expect("plugin"); + assert_eq!(plugin.cache_ttl_for(Duration::from_secs(3_600)), None); +} + +#[tokio::test] +async fn an_unavailable_credential_store_rejects_with_500() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(200) + .header("content-type", "application/json") + .body(token_body("tok", 3_600)); + }); + let mut plugin = OAuth2ClientCredAuthPlugin::new( + Arc::new(CredStoreSecretResolver::new(Arc::new( + MockCredStoreClient::always_failing(), + ))), + toolkit_auth::ClientAuthMethod::Form, + cache_config(), + &token_endpoint_config(&server), + ) + .expect("plugin"); + plugin.set_http_config(toolkit_http::HttpClientConfig::for_testing()); + let mut ctx = request(); + let error = plugin.authenticate(&mut ctx).await.expect_err("500"); + assert_eq!( + error.status(), + axum::http::StatusCode::INTERNAL_SERVER_ERROR + ); +} + +// --------------------------------------------------------------------------- +// Token exchange +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn form_credentials_travel_in_the_request_body() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/token") + .body_includes("client_id=test-client") + .body_includes("client_secret=test-secret"); + then.status(200) + .header("content-type", "application/json") + .body(token_body("tok-form", 3_600)); + }); + + let mut ctx = request(); + form_plugin(token_endpoint_config(&server)) + .authenticate(&mut ctx) + .await + .expect("authenticated"); + mock.assert(); + assert_eq!( + ctx.header(AUTHORIZATION_HEADER) + .and_then(|value| value.to_str().ok()), + Some("Bearer tok-form") + ); + assert_eq!(ctx.injected_headers.len(), 1); + assert_eq!(ctx.injected_headers[0].0.as_str(), AUTHORIZATION_HEADER); +} + +#[tokio::test] +async fn basic_credentials_travel_in_the_authorization_header() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/token") + .header("authorization", "Basic dGVzdC1jbGllbnQ6dGVzdC1zZWNyZXQ="); + then.status(200) + .header("content-type", "application/json") + .body(token_body("tok-basic", 3_600)); + }); + + let mut ctx = request(); + basic_plugin(token_endpoint_config(&server)) + .authenticate(&mut ctx) + .await + .expect("authenticated"); + mock.assert(); + assert_eq!( + ctx.header(AUTHORIZATION_HEADER) + .and_then(|value| value.to_str().ok()), + Some("Bearer tok-basic") + ); +} + +#[tokio::test] +async fn scopes_are_forwarded_to_the_token_endpoint() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST) + .path("/token") + .body_includes("scope=read+write"); + then.status(200) + .header("content-type", "application/json") + .body(token_body("tok-scoped", 3_600)); + }); + + let config = serde_json::json!({ + "token_endpoint": format!("http://localhost:{}/token", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret", + "scopes": "read write" + }); + let mut ctx = request(); + form_plugin(config) + .authenticate(&mut ctx) + .await + .expect("authenticated"); + mock.assert(); +} + +#[tokio::test] +async fn a_second_request_is_served_from_the_cache() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(200) + .header("content-type", "application/json") + .body(token_body("tok-cached", 3_600)); + }); + + let cache = form_plugin(token_endpoint_config(&server)); + let mut first = request(); + cache.authenticate(&mut first).await.expect("first"); + let mut second = request(); + cache.authenticate(&mut second).await.expect("second"); + mock.assert(); + assert_eq!( + second + .header(AUTHORIZATION_HEADER) + .and_then(|value| value.to_str().ok()), + Some("Bearer tok-cached") + ); +} + +#[tokio::test] +async fn different_plugins_do_not_share_a_cached_token() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(200) + .header("content-type", "application/json") + .body(token_body("tok-isolated", 3_600)); + }); + + let left = form_plugin(token_endpoint_config(&server)); + let mut right = form_plugin(token_endpoint_config(&server)); + right.set_http_config(toolkit_http::HttpClientConfig::for_testing()); + + let mut first = request(); + left.authenticate(&mut first).await.expect("first"); + let mut second = request(); + right.authenticate(&mut second).await.expect("second"); + assert_eq!( + mock.calls(), + 2, + "each plugin instance fetches its own token" + ); +} + +#[tokio::test] +async fn a_short_lived_token_is_not_cached() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(200) + .header("content-type", "application/json") + .body(token_body("tok-short", 20)); + }); + + let cache = form_plugin(token_endpoint_config(&server)); + let mut first = request(); + cache.authenticate(&mut first).await.expect("first"); + let mut second = request(); + cache.authenticate(&mut second).await.expect("second"); + assert_eq!( + mock.calls(), + 2, + "tokens within the safety margin are never cached" + ); +} + +#[tokio::test] +async fn a_failed_fetch_is_never_cached() { + let server = MockServer::start(); + let mut failing = server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(500).body("idp unavailable"); + }); + let succeeding = server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(200) + .header("content-type", "application/json") + .body(token_body("tok-recovered", 3_600)); + }); + + let cache = form_plugin(token_endpoint_config(&server)); + let mut first = request(); + let error = cache.authenticate(&mut first).await.expect_err("502"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_GATEWAY); + assert_eq!(failing.calls(), 1); + + // Retire the failing mock so the retry reaches the succeeding one. + failing.delete(); + let mut second = request(); + cache.authenticate(&mut second).await.expect("retried"); + assert_eq!(succeeding.calls(), 1); +} + +#[tokio::test] +async fn an_idp_error_response_maps_to_502() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(400).body(r#"{"error":"invalid_client"}"#); + }); + let mut ctx = request(); + let error = form_plugin(token_endpoint_config(&server)) + .authenticate(&mut ctx) + .await + .expect_err("502"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_GATEWAY); + assert!( + error + .detail() + .contains("oauth2 token endpoint request failed") + ); +} + +#[tokio::test] +async fn a_non_bearer_token_type_maps_to_502() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"access_token":"tok","token_type":"mac"}"#); + }); + let mut ctx = request(); + let error = form_plugin(token_endpoint_config(&server)) + .authenticate(&mut ctx) + .await + .expect_err("502"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_GATEWAY); +} + +#[tokio::test] +async fn a_missing_security_context_rejects_with_401() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(200) + .header("content-type", "application/json") + .body(token_body("tok", 3_600)); + }); + let mut ctx = RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1") + .tenant_id(TENANT) + .build(); + let error = form_plugin(token_endpoint_config(&server)) + .authenticate(&mut ctx) + .await + .expect_err("401"); + assert_eq!(error.status(), axum::http::StatusCode::UNAUTHORIZED); +} + +#[tokio::test] +async fn an_inaccessible_credential_rejects_with_401() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(200) + .header("content-type", "application/json") + .body(token_body("tok", 3_600)); + }); + let unavailable = Arc::new(StaticSecretResolver::new(HashMap::new())); + let mut plugin = OAuth2ClientCredAuthPlugin::new( + unavailable, + toolkit_auth::ClientAuthMethod::Form, + cache_config(), + &token_endpoint_config(&server), + ) + .expect("plugin"); + plugin.set_http_config(toolkit_http::HttpClientConfig::for_testing()); + let mut ctx = request(); + let error = plugin.authenticate(&mut ctx).await.expect_err("401"); + assert_eq!(error.status(), axum::http::StatusCode::UNAUTHORIZED); + assert!(error.detail().contains("client-id")); +} + +#[tokio::test] +async fn oidc_discovery_resolves_the_token_endpoint() { + let server = MockServer::start(); + let token_endpoint = format!("http://localhost:{}/oauth/token", server.port()); + let discovery = server.mock(|when, then| { + when.method(GET).path("/.well-known/openid-configuration"); + then.status(200) + .header("content-type", "application/json") + .body(format!(r#"{{"token_endpoint":"{token_endpoint}"}}"#)); + }); + let exchange = server.mock(|when, then| { + when.method(POST).path("/oauth/token"); + then.status(200) + .header("content-type", "application/json") + .body(token_body("tok-discovered", 3_600)); + }); + + let config = serde_json::json!({ + "issuer_url": format!("http://localhost:{}", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret" + }); + let mut ctx = request(); + form_plugin(config) + .authenticate(&mut ctx) + .await + .expect("authenticated"); + discovery.assert(); + exchange.assert(); + assert_eq!( + ctx.header(AUTHORIZATION_HEADER) + .and_then(|value| value.to_str().ok()), + Some("Bearer tok-discovered") + ); +} + +#[tokio::test] +async fn a_failed_discovery_maps_to_502() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/.well-known/openid-configuration"); + then.status(500).body("idp unavailable"); + }); + let config = serde_json::json!({ + "issuer_url": format!("http://localhost:{}", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret" + }); + let mut ctx = request(); + let error = form_plugin(config) + .authenticate(&mut ctx) + .await + .expect_err("502"); + assert_eq!(error.status(), axum::http::StatusCode::BAD_GATEWAY); +} + +#[tokio::test] +async fn the_bearer_token_is_injected_exactly_once() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/token"); + then.status(200) + .header("content-type", "application/json") + .body(token_body("tok-once", 3_600)); + }); + let cache = form_plugin(token_endpoint_config(&server)); + let mut ctx = request(); + cache.authenticate(&mut ctx).await.expect("first"); + cache.authenticate(&mut ctx).await.expect("second"); + assert_eq!(ctx.injected_headers.len(), 1); +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/plugin_tests.rs b/gears/system/oagw/oagw/src/infra/plugin/plugin_tests.rs new file mode 100644 index 0000000..8c2a7e6 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/plugin_tests.rs @@ -0,0 +1,941 @@ +//! Tests for [`crate::infra::plugin::PluginRegistry`]. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use super::{ + DisabledPlugins, PluginRegistry, SecretResolverTrait, StaticSecretResolver, + UnavailableSecretResolver, +}; +use crate::config::OagwConfig; +use crate::domain::error::OagwError; +use crate::domain::model::{ + AuthConfig, Endpoint, Plugin, PluginBinding, PluginConfig, Protocol, Route, RouteMatch, Scheme, + ServerConfig, SharingMode, Upstream, format_plugin_id, +}; +use crate::domain::plugin::{ + AuthPlugin, GUARD_PLUGIN_TYPE_ID, GuardPlugin, RequestContext, builtin, +}; +use crate::infra::plugin::oauth2::TokenCacheConfig; +use crate::infra::plugin::request_id::REQUEST_ID_HEADER; + +const TENANT: Uuid = Uuid::from_u128(0x11); + +fn resolver() -> Arc { + Arc::new(StaticSecretResolver::new(HashMap::from([ + ("client-id".to_owned(), "client-id-value".to_owned()), + ("client-secret".to_owned(), "client-secret-value".to_owned()), + ("payments-key".to_owned(), "resolved-key".to_owned()), + ]))) +} + +fn registry() -> PluginRegistry { + PluginRegistry::with_builtins(resolver(), TokenCacheConfig::from(&OagwConfig::default())) +} + +fn empty_registry() -> PluginRegistry { + PluginRegistry::empty() +} + +/// The disabled set of a chain build that has nothing to skip. +fn nothing_disabled() -> DisabledPlugins { + DisabledPlugins::of(Vec::>::new()) +} + +/// A plugin resource with `enabled: false` for `id`. +fn disabled_plugin(id: Uuid) -> Plugin { + Plugin { + id, + plugin_type: "gts.cf.core.oagw.guard_plugin.v1".to_owned(), + enabled: false, + ..Plugin::default() + } +} + +/// The disabled set holding exactly `plugins` (already `enabled: false`). +fn disabled(plugins: Vec) -> DisabledPlugins { + DisabledPlugins::of(plugins.into_iter().map(Arc::new)) +} + +fn upstream_with_auth(auth_type: &str, config: serde_json::Value) -> Upstream { + Upstream { + id: Uuid::from_u128(0x55), + enabled: true, + alias: "payments".to_owned(), + tags: Vec::new(), + server: ServerConfig { + endpoints: vec![Endpoint { + scheme: Scheme::Https, + host: "payments.internal".to_owned(), + port: 443, + }], + }, + protocol: Protocol::Http, + auth: Some(AuthConfig { + auth_type: auth_type.to_owned(), + sharing: SharingMode::Private, + config, + }), + headers: Default::default(), + plugins: PluginConfig::default(), + rate_limit: None, + cors: None, + tenant_id: TENANT, + created_at: std::time::SystemTime::UNIX_EPOCH, + updated_at: std::time::SystemTime::UNIX_EPOCH, + } +} + +fn route_with_plugins(bindings: Vec) -> Route { + Route { + id: Uuid::from_u128(0x66), + upstream_id: Uuid::from_u128(0x55), + r#match: RouteMatch::default(), + headers: Default::default(), + plugins: PluginConfig { + sharing: SharingMode::Private, + items: bindings, + }, + rate_limit: None, + cors: None, + enabled: true, + priority: 0, + tags: Vec::new(), + tenant_id: TENANT, + created_at: std::time::SystemTime::UNIX_EPOCH, + updated_at: std::time::SystemTime::UNIX_EPOCH, + } +} + +fn security() -> Arc { + Arc::new( + SecurityContext::builder() + .subject_id(Uuid::from_u128(0x33)) + .subject_tenant_id(TENANT) + .build() + .expect("security context"), + ) +} + +fn request() -> RequestContext { + RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1/payments") + .tenant_id(TENANT) + .security(security()) + .build() +} + +/// Unwraps an expected failure without requiring the success type to be +/// `Debug`: the ADR-0002 plugin traits are object-safe ports, not `Debug`. +fn expect_error(result: Result, message: &str) -> OagwError { + match result { + Ok(_) => panic!("{message}"), + Err(error) => error, + } +} + +// --------------------------------------------------------------------------- +// Registry construction +// --------------------------------------------------------------------------- + +#[test] +fn with_builtins_registers_exactly_the_six_builtins() { + let registry = registry(); + assert_eq!(registry.len(), 6); + assert!(!registry.is_empty()); + + for id in [ + builtin::NOOP_AUTH, + builtin::APIKEY_AUTH, + builtin::OAUTH2_CLIENT_CRED, + builtin::OAUTH2_CLIENT_CRED_BASIC, + builtin::REQUIRED_HEADERS_GUARD, + builtin::REQUEST_ID_TRANSFORM, + ] { + assert!(registry.contains(id), "'{id}' must be registered"); + } +} + +#[test] +fn catalog_only_ids_are_not_registered() { + let registry = registry(); + for id in [ + builtin::BASIC_AUTH, + builtin::BEARER_AUTH, + builtin::TIMEOUT_GUARD, + builtin::CORS_GUARD, + builtin::LOGGING_TRANSFORM, + builtin::METRICS_TRANSFORM, + ] { + assert!(!registry.contains(id), "'{id}' must not be registered"); + } +} + +#[test] +fn an_empty_registry_has_no_plugins() { + let registry = empty_registry(); + assert!(registry.is_empty()); + assert_eq!(registry.len(), 0); + assert!(!registry.contains(builtin::NOOP_AUTH)); +} + +#[test] +fn lookups_accept_the_bare_instance_fragment() { + let registry = registry(); + assert!(registry.contains("cf.core.oagw.noop.v1")); + assert!(registry.contains("cf.core.oagw.apikey.v1")); + assert!(registry.contains("cf.core.oagw.required_headers.v1")); + assert!(registry.contains("cf.core.oagw.request_id.v1")); +} + +// --------------------------------------------------------------------------- +// Building a single plugin +// --------------------------------------------------------------------------- + +#[test] +fn an_unknown_plugin_id_is_a_503() { + let registry = registry(); + let error = expect_error( + registry.build_auth(builtin::BASIC_AUTH, &serde_json::json!({})), + "not registered", + ); + assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + error.problem_body().r#type, + "gts.cf.core.errors.err.v1~cf.oagw.plugin.not_found.v1" + ); + assert_eq!( + error.problem_body().context.plugin_id.as_deref(), + Some(builtin::BASIC_AUTH) + ); +} + +#[test] +fn an_unknown_kind_is_reported_the_same_way() { + let registry = registry(); + for build in [ + |registry: &PluginRegistry| { + registry + .build_auth("totally-unknown.v1", &serde_json::json!({})) + .err() + }, + |registry: &PluginRegistry| { + registry + .build_guard("totally-unknown.v1", &serde_json::json!({})) + .err() + }, + |registry: &PluginRegistry| { + registry + .build_transform("totally-unknown.v1", &serde_json::json!({})) + .err() + }, + ] { + let error = build(®istry).expect("must fail"); + assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE); + } +} + +#[test] +fn a_known_id_of_the_wrong_kind_is_a_503() { + let registry = registry(); + let error = expect_error( + registry.build_guard(builtin::NOOP_AUTH, &serde_json::json!({})), + "auth is not a guard", + ); + assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +#[test] +fn the_noop_plugin_builds_without_configuration() { + let registry = registry(); + let plugin = registry + .build_auth(builtin::NOOP_AUTH, &serde_json::Value::Null) + .expect("plugin"); + assert_eq!(plugin.id(), builtin::NOOP_AUTH); +} + +#[test] +fn the_required_headers_plugin_builds_from_a_configuration() { + let registry = registry(); + let plugin = registry + .build_guard( + builtin::REQUIRED_HEADERS_GUARD, + &serde_json::json!({ + "required_request_headers": "x-trace-id" + }), + ) + .expect("plugin"); + assert_eq!(plugin.id(), builtin::REQUIRED_HEADERS_GUARD); +} + +#[test] +fn the_request_id_plugin_builds_from_any_configuration() { + let registry = registry(); + let plugin = registry + .build_transform(builtin::REQUEST_ID_TRANSFORM, &serde_json::Value::Null) + .expect("plugin"); + assert_eq!(plugin.id(), builtin::REQUEST_ID_TRANSFORM); +} + +#[test] +fn the_apikey_plugin_validates_its_configuration() { + let registry = registry(); + let error = expect_error( + registry.build_auth(builtin::APIKEY_AUTH, &serde_json::json!({})), + "no credential source", + ); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); +} + +#[test] +fn the_oauth2_plugins_validate_their_configuration() { + let registry = registry(); + for id in [ + builtin::OAUTH2_CLIENT_CRED, + builtin::OAUTH2_CLIENT_CRED_BASIC, + ] { + let error = expect_error( + registry.build_auth(id, &serde_json::json!({ "client_id_ref": "cred://x" })), + "incomplete configuration", + ); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + } +} + +#[test] +fn the_oauth2_plugins_accept_the_documented_configuration() { + let registry = registry(); + let config = serde_json::json!({ + "token_endpoint": "https://idp.example.com/token", + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret" + }); + assert!( + registry + .build_auth(builtin::OAUTH2_CLIENT_CRED, &config) + .is_ok() + ); + assert!( + registry + .build_auth(builtin::OAUTH2_CLIENT_CRED_BASIC, &config) + .is_ok() + ); +} + +#[test] +fn an_empty_registry_returns_a_503_for_every_id() { + let registry = empty_registry(); + let error = expect_error( + registry.build_auth(builtin::NOOP_AUTH, &serde_json::json!({})), + "empty registry", + ); + assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +#[test] +fn a_registry_can_be_extended_with_a_custom_plugin() { + let mut registry = registry(); + registry.register_auth( + "cf.core.oagw.custom.v1", + Arc::new(|_config| Ok(Arc::new(CustomAuth) as Arc)), + ); + assert_eq!(registry.len(), 7); + assert!( + registry + .build_auth("cf.core.oagw.custom.v1", &serde_json::json!({})) + .is_ok() + ); +} + +struct CustomAuth; + +#[async_trait::async_trait] +impl AuthPlugin for CustomAuth { + fn id(&self) -> &str { + "cf.core.oagw.custom.v1" + } + + fn plugin_type(&self) -> &str { + crate::domain::plugin::AUTH_PLUGIN_TYPE_ID + } + + async fn authenticate(&self, ctx: &mut RequestContext) -> Result<(), OagwError> { + ctx.request_id = Some("custom".to_owned()); + Ok(()) + } +} + +struct NoopGuard; + +#[async_trait::async_trait] +impl GuardPlugin for NoopGuard { + fn id(&self) -> &str { + "cf.core.oagw.custom-guard.v1" + } + + fn plugin_type(&self) -> &str { + GUARD_PLUGIN_TYPE_ID + } + + async fn guard_request( + &self, + _ctx: &RequestContext, + ) -> Result { + Ok(crate::domain::plugin::GuardDecision::allow()) + } + + async fn guard_response( + &self, + _ctx: &crate::domain::plugin::ResponseContext, + ) -> Result { + Ok(crate::domain::plugin::GuardDecision::allow()) + } +} + +#[test] +fn a_custom_guard_registers_under_its_own_kind() { + let mut registry = empty_registry(); + registry.register_guard( + "cf.core.oagw.custom-guard.v1", + Arc::new(|_config| Ok(Arc::new(NoopGuard) as Arc)), + ); + assert_eq!(registry.len(), 1); + assert!( + registry + .build_guard("cf.core.oagw.custom-guard.v1", &serde_json::json!({})) + .is_ok() + ); + let error = expect_error( + registry.build_auth("cf.core.oagw.custom-guard.v1", &serde_json::json!({})), + "guard is not an auth plugin", + ); + assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +// --------------------------------------------------------------------------- +// Building a chain +// --------------------------------------------------------------------------- + +#[test] +fn the_chain_starts_with_the_upstream_auth_binding() { + let registry = registry(); + let upstream = upstream_with_auth( + builtin::APIKEY_AUTH, + serde_json::json!({ "key": "raw-key" }), + ); + let chain = registry + .build_chain(&upstream, None, ¬hing_disabled()) + .expect("chain"); + assert_eq!(chain.len(), 1); + assert_eq!(chain.plugin_refs(), vec![builtin::APIKEY_AUTH]); + assert_eq!(chain.auth_plugins().len(), 1); +} + +#[test] +fn an_upstream_without_auth_yields_only_the_plugin_chain() { + let registry = registry(); + let mut upstream = upstream_with_auth(builtin::NOOP_AUTH, serde_json::json!({})); + upstream.auth = None; + let chain = registry + .build_chain(&upstream, None, ¬hing_disabled()) + .expect("chain"); + assert!(chain.is_empty()); +} + +#[test] +fn upstream_plugins_run_before_route_plugins() { + let registry = registry(); + let mut upstream = upstream_with_auth(builtin::NOOP_AUTH, serde_json::json!({})); + upstream.auth = None; + upstream.plugins = PluginConfig { + sharing: SharingMode::Private, + items: vec![ + PluginBinding::new(builtin::REQUIRED_HEADERS_GUARD, serde_json::json!({})), + PluginBinding::new(builtin::REQUEST_ID_TRANSFORM, serde_json::json!({})), + ], + }; + let route = route_with_plugins(vec![ + PluginBinding::new(builtin::REQUEST_ID_TRANSFORM, serde_json::json!({})), + PluginBinding::new(builtin::REQUIRED_HEADERS_GUARD, serde_json::json!({})), + ]); + let chain = registry + .build_chain(&upstream, Some(&route), ¬hing_disabled()) + .expect("chain"); + assert_eq!( + chain.plugin_refs(), + vec![ + builtin::REQUIRED_HEADERS_GUARD, + builtin::REQUEST_ID_TRANSFORM, + builtin::REQUEST_ID_TRANSFORM, + builtin::REQUIRED_HEADERS_GUARD, + ] + ); +} + +#[test] +fn declaration_order_is_kept_within_a_chain() { + let registry = registry(); + let mut upstream = upstream_with_auth(builtin::NOOP_AUTH, serde_json::json!({})); + upstream.auth = None; + upstream.plugins = PluginConfig { + sharing: SharingMode::Private, + items: vec![ + PluginBinding::new(builtin::REQUEST_ID_TRANSFORM, serde_json::json!({})), + PluginBinding::new(builtin::REQUIRED_HEADERS_GUARD, serde_json::json!({})), + ], + }; + let chain = registry + .build_chain(&upstream, None, ¬hing_disabled()) + .expect("chain"); + assert_eq!( + chain.plugin_refs(), + vec![ + builtin::REQUEST_ID_TRANSFORM, + builtin::REQUIRED_HEADERS_GUARD + ] + ); +} + +#[test] +fn an_unknown_binding_fails_the_whole_chain() { + let registry = registry(); + let mut upstream = upstream_with_auth(builtin::NOOP_AUTH, serde_json::json!({})); + upstream.auth = None; + upstream.plugins = PluginConfig { + sharing: SharingMode::Private, + items: vec![ + PluginBinding::new(builtin::REQUEST_ID_TRANSFORM, serde_json::json!({})), + PluginBinding::new("cf.core.oagw.logging.v1", serde_json::json!({})), + ], + }; + let error = registry + .build_chain(&upstream, None, ¬hing_disabled()) + .expect_err("503"); + assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + error.problem_body().context.plugin_id.as_deref(), + Some("cf.core.oagw.logging.v1") + ); +} + +#[test] +fn an_invalid_auth_configuration_fails_the_chain() { + let registry = registry(); + let upstream = upstream_with_auth(builtin::APIKEY_AUTH, serde_json::json!({})); + let error = registry + .build_chain(&upstream, None, ¬hing_disabled()) + .expect_err("400"); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); +} + +#[test] +fn a_bare_instance_fragment_is_accepted_in_a_binding() { + let registry = registry(); + let mut upstream = upstream_with_auth(builtin::NOOP_AUTH, serde_json::json!({})); + upstream.auth = None; + upstream.plugins = PluginConfig { + sharing: SharingMode::Private, + items: vec![PluginBinding::new( + "cf.core.oagw.request_id.v1", + serde_json::json!({}), + )], + }; + let chain = registry + .build_chain(&upstream, None, ¬hing_disabled()) + .expect("chain"); + assert_eq!(chain.plugin_refs(), vec!["cf.core.oagw.request_id.v1"]); +} + +// --------------------------------------------------------------------------- +// Skipping a disabled plugin resource +// --------------------------------------------------------------------------- + +#[test] +fn the_disabled_set_matches_both_spellings_of_a_reference() { + let id = Uuid::from_u128(0x77); + let disabled = disabled(vec![disabled_plugin(id)]); + assert!(!disabled.is_empty()); + assert!(disabled.is_disabled(&id.to_string()), "bare instance UUID"); + assert!(disabled.is_disabled(&format_plugin_id(id)), "GTS-form id"); + assert!( + !disabled.is_disabled(builtin::REQUIRED_HEADERS_GUARD), + "a builtin id is never a custom plugin reference" + ); + assert!( + !disabled.is_disabled(&Uuid::from_u128(0x78).to_string()), + "another plugin resource is not disabled" + ); +} + +#[test] +fn the_disabled_set_only_holds_disabled_resources() { + let id = Uuid::from_u128(0x77); + let enabled = Plugin { + id, + ..Plugin::default() + }; + let disabled = DisabledPlugins::of(vec![Arc::new(enabled)]); + assert!(disabled.is_empty()); + assert!(!disabled.is_disabled(&id.to_string())); +} + +#[test] +fn a_disabled_plugin_resource_is_skipped_in_both_spellings() { + let registry = registry(); + let id = Uuid::from_u128(0x77); + let disabled = disabled(vec![disabled_plugin(id)]); + for reference in [id.to_string(), format_plugin_id(id)] { + let mut upstream = upstream_with_auth(builtin::NOOP_AUTH, serde_json::json!({})); + upstream.auth = None; + upstream.plugins = PluginConfig { + sharing: SharingMode::Private, + items: vec![ + PluginBinding::new(reference.clone(), serde_json::json!({})), + PluginBinding::new(builtin::REQUEST_ID_TRANSFORM, serde_json::json!({})), + ], + }; + let chain = registry + .build_chain(&upstream, None, &disabled) + .expect("the disabled binding must not fail the build"); + assert_eq!( + chain.plugin_refs(), + vec![builtin::REQUEST_ID_TRANSFORM], + "'{reference}' is disabled and must be skipped" + ); + } +} + +#[test] +fn a_disabled_plugin_resource_is_skipped_on_the_route_tier_too() { + let registry = registry(); + let id = Uuid::from_u128(0x77); + let mut upstream = upstream_with_auth(builtin::NOOP_AUTH, serde_json::json!({})); + upstream.auth = None; + let route = route_with_plugins(vec![ + PluginBinding::new(format_plugin_id(id), serde_json::json!({})), + PluginBinding::new(builtin::REQUIRED_HEADERS_GUARD, serde_json::json!({})), + ]); + let chain = registry + .build_chain( + &upstream, + Some(&route), + &disabled(vec![disabled_plugin(id)]), + ) + .expect("chain"); + assert_eq!( + chain.plugin_refs(), + vec![builtin::REQUIRED_HEADERS_GUARD], + "the remaining route binding is still built" + ); +} + +#[test] +fn an_enabled_plugin_resource_still_fails_the_chain_with_a_503() { + let registry = registry(); + // The reference names a plugin resource the tenant keeps enabled, so it is + // not in the disabled set: an unresolvable reference stays a loud 503 + // instead of being dropped from the chain. + let enabled = Uuid::from_u128(0x77); + let mut upstream = upstream_with_auth(builtin::NOOP_AUTH, serde_json::json!({})); + upstream.auth = None; + upstream.plugins = PluginConfig { + sharing: SharingMode::Private, + items: vec![ + PluginBinding::new(enabled.to_string(), serde_json::json!({})), + PluginBinding::new(builtin::REQUEST_ID_TRANSFORM, serde_json::json!({})), + ], + }; + let error = registry + .build_chain( + &upstream, + None, + &disabled(vec![disabled_plugin(Uuid::from_u128(0x78))]), + ) + .expect_err("503"); + assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + error.problem_body().context.plugin_id.as_deref(), + Some(enabled.to_string().as_str()) + ); +} + +#[test] +fn the_auth_binding_is_never_skipped_by_the_disabled_set() { + let registry = registry(); + let id = Uuid::from_u128(0x77); + let upstream = upstream_with_auth(&id.to_string(), serde_json::json!({})); + let error = registry + .build_chain(&upstream, None, &disabled(vec![disabled_plugin(id)])) + .expect_err("503"); + assert_eq!(error.status(), StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + error.problem_body().context.plugin_id.as_deref(), + Some(id.to_string().as_str()) + ); +} + +// --------------------------------------------------------------------------- +// End-to-end chain behaviour +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn the_built_chain_injects_the_api_key() { + let registry = registry(); + let upstream = upstream_with_auth( + builtin::APIKEY_AUTH, + serde_json::json!({ "secret_ref": "cred://payments-key" }), + ); + let chain = registry + .build_chain(&upstream, None, ¬hing_disabled()) + .expect("chain"); + let mut ctx = request(); + chain.authenticate(&mut ctx).await.expect("authenticated"); + assert_eq!( + ctx.header("x-api-key") + .and_then(|value| value.to_str().ok()), + Some("resolved-key") + ); +} + +#[tokio::test] +async fn the_built_chain_stamps_a_request_id_and_enforces_the_guard() { + let registry = registry(); + let upstream = upstream_with_auth(builtin::NOOP_AUTH, serde_json::json!({})); + let mut upstream = upstream; + upstream.auth = Some(AuthConfig { + auth_type: builtin::NOOP_AUTH.to_owned(), + sharing: SharingMode::Private, + config: serde_json::json!({}), + }); + upstream.plugins = PluginConfig { + sharing: SharingMode::Private, + items: vec![ + PluginBinding::new( + builtin::REQUIRED_HEADERS_GUARD, + serde_json::json!({ + "required_request_headers": "x-trace-id" + }), + ), + PluginBinding::new(builtin::REQUEST_ID_TRANSFORM, serde_json::json!({})), + ], + }; + let chain = registry + .build_chain(&upstream, None, ¬hing_disabled()) + .expect("chain"); + + let mut rejected = request(); + chain.authenticate(&mut rejected).await.expect("auth"); + let error = chain.guard_request(&rejected).await.expect_err("400"); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + + let mut accepted = RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1/payments") + .tenant_id(TENANT) + .headers(HeaderMap::from_iter([( + HeaderName::from_static("x-trace-id"), + HeaderValue::from_static("present"), + )])) + .security(security()) + .build(); + chain.authenticate(&mut accepted).await.expect("auth"); + chain.guard_request(&accepted).await.expect("guard"); + chain + .transform_request(&mut accepted) + .await + .expect("transform"); + assert!(accepted.request_id.is_some()); + assert!(accepted.has_header(REQUEST_ID_HEADER)); +} + +#[tokio::test] +async fn the_unavailable_resolver_fails_the_chain_at_use_time() { + let registry = PluginRegistry::with_builtins( + Arc::new(UnavailableSecretResolver), + TokenCacheConfig::from(&OagwConfig::default()), + ); + let upstream = upstream_with_auth( + builtin::APIKEY_AUTH, + serde_json::json!({ "secret_ref": "cred://payments-key" }), + ); + let chain = registry + .build_chain(&upstream, None, ¬hing_disabled()) + .expect("chain"); + let mut ctx = request(); + let error = chain.authenticate(&mut ctx).await.expect_err("500"); + assert_eq!(error.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert!( + ctx.injected_headers.is_empty(), + "never proxy unauthenticated" + ); +} + +#[test] +fn the_builtins_registry_is_cloneable() { + let registry = registry(); + let clone = registry.clone(); + assert_eq!(clone.len(), registry.len()); +} + +// --------------------------------------------------------------------------- +// Instance memoisation (ADR-0008 token cache survival) +// --------------------------------------------------------------------------- + +/// The OAuth2 configuration the memoisation tests bind. +fn oauth2_config() -> serde_json::Value { + serde_json::json!({ + "token_endpoint": "https://idp.example.com/token", + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret" + }) +} + +#[test] +fn the_same_binding_returns_the_same_instance() { + let registry = registry(); + let first = registry + .build_auth(builtin::OAUTH2_CLIENT_CRED, &oauth2_config()) + .expect("plugin"); + let second = registry + .build_auth(builtin::OAUTH2_CLIENT_CRED, &oauth2_config()) + .expect("plugin"); + assert!( + Arc::ptr_eq(&first, &second), + "the token cache of the ADR-0008 plugin must survive across requests" + ); +} + +#[test] +fn both_spellings_of_a_reference_share_one_instance() { + let registry = registry(); + let qualified = registry + .build_auth(builtin::OAUTH2_CLIENT_CRED, &oauth2_config()) + .expect("plugin"); + let bare = registry + .build_auth("cf.core.oagw.oauth2_client_cred.v1", &oauth2_config()) + .expect("plugin"); + assert!( + Arc::ptr_eq(&qualified, &bare), + "both spellings resolve to the same registry key" + ); +} + +#[test] +fn a_different_configuration_builds_a_new_instance() { + let registry = registry(); + let first = registry + .build_auth(builtin::OAUTH2_CLIENT_CRED, &oauth2_config()) + .expect("plugin"); + let other = registry + .build_auth( + builtin::OAUTH2_CLIENT_CRED, + &serde_json::json!({ + "token_endpoint": "https://other.example.com/token", + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret" + }), + ) + .expect("plugin"); + assert!(!Arc::ptr_eq(&first, &other)); +} + +#[test] +fn a_failed_construction_is_never_memoised() { + let registry = registry(); + let config = serde_json::json!({ "client_id_ref": "cred://x" }); + let first = expect_error( + registry.build_auth(builtin::OAUTH2_CLIENT_CRED, &config), + "incomplete configuration", + ); + let second = expect_error( + registry.build_auth(builtin::OAUTH2_CLIENT_CRED, &config), + "still incomplete", + ); + assert_eq!(first.status(), second.status()); + assert_eq!(first.detail(), second.detail(), "the same error every time"); +} + +#[test] +fn re_registering_a_reference_forgets_its_instances() { + let registry = registry(); + let first = registry + .build_auth(builtin::NOOP_AUTH, &serde_json::Value::Null) + .expect("plugin"); + let mut registry = registry; + registry.register_auth( + builtin::NOOP_AUTH, + Arc::new(|_config| Ok(Arc::new(NoopAuth) as Arc)), + ); + let second = registry + .build_auth(builtin::NOOP_AUTH, &serde_json::Value::Null) + .expect("plugin"); + assert!(!Arc::ptr_eq(&first, &second)); +} + +struct NoopAuth; + +#[async_trait::async_trait] +impl AuthPlugin for NoopAuth { + fn id(&self) -> &str { + builtin::NOOP_AUTH + } + + fn plugin_type(&self) -> &str { + crate::domain::plugin::AUTH_PLUGIN_TYPE_ID + } + + async fn authenticate(&self, _ctx: &mut RequestContext) -> Result<(), OagwError> { + Ok(()) + } +} + +#[test] +fn two_chain_builds_construct_a_binding_once() { + let constructions = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let counter = Arc::clone(&constructions); + let mut registry = registry(); + registry.register_auth( + "cf.core.oagw.counting.v1", + Arc::new(move |_config| { + counter.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok(Arc::new(NoopAuth) as Arc) + }), + ); + let upstream = upstream_with_auth("cf.core.oagw.counting.v1", serde_json::json!({})); + registry + .build_chain(&upstream, None, ¬hing_disabled()) + .expect("first chain"); + registry + .build_chain(&upstream, None, ¬hing_disabled()) + .expect("second chain"); + assert_eq!( + constructions.load(std::sync::atomic::Ordering::SeqCst), + 1, + "every request rebuilds the chain, so the instance must be memoised" + ); +} + +#[test] +fn the_memo_table_stays_bounded() { + let mut registry = empty_registry(); + registry.register_auth( + "cf.core.oagw.counting.v1", + Arc::new(|_config| Ok(Arc::new(NoopAuth) as Arc)), + ); + for index in 0..2_000u32 { + registry + .build_auth( + "cf.core.oagw.counting.v1", + &serde_json::json!({ "index": index }), + ) + .expect("plugin"); + } + assert!(registry.constructed.read().len() <= 1024); +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/request_id.rs b/gears/system/oagw/oagw/src/infra/plugin/request_id.rs new file mode 100644 index 0000000..c6a3d7c --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/request_id.rs @@ -0,0 +1,105 @@ +//! Built-in request id transform plugin +//! (`gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1`). +//! +//! `X-Request-ID` propagation: the header name and the propagation semantics +//! come from ADR-0002 ("RequestIdTransformPlugin: X-Request-ID propagation"). +//! +//! * Request phase — an inbound `X-Request-ID` is propagated unchanged; +//! otherwise a fresh UUID is minted. Either way the value is recorded on +//! [`RequestContext::request_id`] and stamped on the outbound request, so +//! the upstream sees exactly one id and the gateway can correlate. +//! * Response phase — the response carries the propagated id when the upstream +//! did not set one itself. +//! * Error phase — the problem document carries the id as well, so a rejected +//! request stays correlatable end to end. + +use async_trait::async_trait; +use axum::http::{HeaderName, HeaderValue}; +use uuid::Uuid; + +use crate::domain::error::OagwError; +use crate::domain::plugin::{ + ErrorContext, RequestContext, ResponseContext, TRANSFORM_PLUGIN_TYPE_ID, TransformPlugin, + builtin, +}; + +/// Header the request id is propagated in. +pub const REQUEST_ID_HEADER: &str = "x-request-id"; + +/// Request id propagation plugin. +#[derive(Debug, Default, Clone, Copy)] +pub struct RequestIdTransformPlugin; + +impl RequestIdTransformPlugin { + /// Registry key of this plugin. + pub const PLUGIN_ID: &'static str = builtin::REQUEST_ID_TRANSFORM; + + /// GTS base type of this plugin. + pub const PLUGIN_TYPE: &'static str = TRANSFORM_PLUGIN_TYPE_ID; +} + +fn header_name() -> HeaderName { + HeaderName::from_static(REQUEST_ID_HEADER) +} + +fn stamp(ctx: &mut RequestContext, request_id: &str) { + let Ok(value) = HeaderValue::from_str(request_id) else { + return; + }; + let name = header_name(); + ctx.headers.insert(name.clone(), value.clone()); + ctx.inject_header(name, value); +} + +#[async_trait] +impl TransformPlugin for RequestIdTransformPlugin { + fn id(&self) -> &str { + Self::PLUGIN_ID + } + + fn plugin_type(&self) -> &str { + Self::PLUGIN_TYPE + } + + async fn transform_request(&self, ctx: &mut RequestContext) -> Result<(), OagwError> { + let propagated = ctx + .header(REQUEST_ID_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_owned) + .unwrap_or_else(|| Uuid::new_v4().to_string()); + ctx.request_id = Some(propagated.clone()); + stamp(ctx, &propagated); + Ok(()) + } + + async fn transform_response(&self, ctx: &mut ResponseContext) -> Result<(), OagwError> { + let Some(request_id) = ctx.request_id.clone() else { + return Ok(()); + }; + if ctx.has_header(REQUEST_ID_HEADER) { + return Ok(()); + } + let Ok(value) = HeaderValue::from_str(&request_id) else { + return Ok(()); + }; + ctx.headers.insert(header_name(), value); + Ok(()) + } + + async fn transform_error(&self, ctx: &mut ErrorContext) -> Result<(), OagwError> { + let Some(request_id) = ctx.request_id.clone() else { + return Ok(()); + }; + let Ok(value) = HeaderValue::from_str(&request_id) else { + return Ok(()); + }; + ctx.headers.insert(header_name(), value); + Ok(()) + } +} + +#[cfg(test)] +#[path = "request_id_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/plugin/request_id_tests.rs b/gears/system/oagw/oagw/src/infra/plugin/request_id_tests.rs new file mode 100644 index 0000000..43b160b --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/request_id_tests.rs @@ -0,0 +1,247 @@ +//! Tests for [`crate::infra::plugin::request_id`]. + +use axum::http::{HeaderMap, HeaderValue, StatusCode}; +use uuid::Uuid; + +use super::{REQUEST_ID_HEADER, RequestIdTransformPlugin}; +use crate::domain::plugin::{ + ErrorContext, RequestContext, ResponseContext, TRANSFORM_PLUGIN_TYPE_ID, TransformPlugin, + builtin, +}; + +fn request() -> RequestContext { + RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1/payments") + .tenant_id(Uuid::from_u128(0x11)) + .build() +} + +#[test] +fn plugin_declares_the_adr_ids() { + let plugin = RequestIdTransformPlugin; + assert_eq!(plugin.id(), builtin::REQUEST_ID_TRANSFORM); + assert_eq!(plugin.plugin_type(), TRANSFORM_PLUGIN_TYPE_ID); + assert_eq!( + RequestIdTransformPlugin::PLUGIN_ID, + builtin::REQUEST_ID_TRANSFORM + ); + assert_eq!( + RequestIdTransformPlugin::PLUGIN_TYPE, + TRANSFORM_PLUGIN_TYPE_ID + ); + assert_eq!(REQUEST_ID_HEADER, "x-request-id"); +} + +#[tokio::test] +async fn an_inbound_request_id_is_propagated_unchanged() { + let mut headers = HeaderMap::new(); + headers.insert(REQUEST_ID_HEADER, HeaderValue::from_static("inbound-id")); + let mut ctx = RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1") + .tenant_id(Uuid::from_u128(1)) + .headers(headers) + .build(); + + RequestIdTransformPlugin + .transform_request(&mut ctx) + .await + .expect("transform"); + assert_eq!(ctx.request_id.as_deref(), Some("inbound-id")); + assert_eq!( + ctx.header(REQUEST_ID_HEADER) + .and_then(|value| value.to_str().ok()), + Some("inbound-id") + ); + assert_eq!(ctx.injected_headers.len(), 1); + assert_eq!(ctx.injected_headers[0].0.as_str(), REQUEST_ID_HEADER); +} + +#[tokio::test] +async fn a_blank_request_id_is_replaced() { + let mut headers = HeaderMap::new(); + headers.insert(REQUEST_ID_HEADER, HeaderValue::from_static(" ")); + let mut ctx = RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1") + .tenant_id(Uuid::from_u128(1)) + .headers(headers) + .build(); + RequestIdTransformPlugin + .transform_request(&mut ctx) + .await + .expect("transform"); + let minted = ctx.request_id.clone().expect("request id"); + assert!( + Uuid::parse_str(&minted).is_ok(), + "'{minted}' must be a UUID" + ); +} + +#[tokio::test] +async fn an_absent_request_id_is_minted() { + let mut ctx = request(); + assert!(ctx.request_id.is_none()); + RequestIdTransformPlugin + .transform_request(&mut ctx) + .await + .expect("transform"); + let minted = ctx.request_id.clone().expect("request id"); + assert!( + Uuid::parse_str(&minted).is_ok(), + "'{minted}' must be a UUID" + ); + assert!(ctx.has_header(REQUEST_ID_HEADER)); + assert_eq!(ctx.injected_headers.len(), 1); +} + +#[tokio::test] +async fn a_whitespace_trimmed_id_is_propagated() { + let mut headers = HeaderMap::new(); + headers.insert(REQUEST_ID_HEADER, HeaderValue::from_static(" padded-id ")); + let mut ctx = RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1") + .tenant_id(Uuid::from_u128(1)) + .headers(headers) + .build(); + RequestIdTransformPlugin + .transform_request(&mut ctx) + .await + .expect("transform"); + assert_eq!(ctx.request_id.as_deref(), Some("padded-id")); +} + +#[tokio::test] +async fn an_unusable_inbound_id_is_replaced() { + let mut headers = HeaderMap::new(); + headers.insert( + REQUEST_ID_HEADER, + HeaderValue::from_bytes(&[0xFF, 0xFE]).expect("opaque bytes"), + ); + let mut ctx = RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1") + .tenant_id(Uuid::from_u128(1)) + .headers(headers) + .build(); + // The header cannot be read as UTF-8, so a fresh id is minted. + RequestIdTransformPlugin + .transform_request(&mut ctx) + .await + .expect("transform"); + let minted = ctx.request_id.clone().expect("request id"); + assert!( + Uuid::parse_str(&minted).is_ok(), + "'{minted}' must be a UUID" + ); +} + +#[tokio::test] +async fn the_response_carries_the_propagated_id() { + let mut ctx = request(); + RequestIdTransformPlugin + .transform_request(&mut ctx) + .await + .expect("transform"); + let request_id = ctx.request_id.clone().expect("request id"); + + let mut response = ResponseContext::builder() + .status(StatusCode::OK) + .request_id(&request_id) + .build(); + RequestIdTransformPlugin + .transform_response(&mut response) + .await + .expect("response"); + assert_eq!( + response + .header(REQUEST_ID_HEADER) + .and_then(|value| value.to_str().ok()), + Some(request_id.as_str()) + ); +} + +#[tokio::test] +async fn an_upstream_response_id_wins_over_the_propagated_one() { + let mut ctx = request(); + RequestIdTransformPlugin + .transform_request(&mut ctx) + .await + .expect("transform"); + + let mut response = ResponseContext::builder().status(StatusCode::OK).build(); + response + .headers + .insert(REQUEST_ID_HEADER, HeaderValue::from_static("upstream-id")); + RequestIdTransformPlugin + .transform_response(&mut response) + .await + .expect("response"); + assert_eq!( + response + .header(REQUEST_ID_HEADER) + .and_then(|value| value.to_str().ok()), + Some("upstream-id") + ); +} + +#[tokio::test] +async fn a_response_without_a_request_id_is_left_untouched() { + let mut response = ResponseContext::builder().status(StatusCode::OK).build(); + RequestIdTransformPlugin + .transform_response(&mut response) + .await + .expect("response"); + assert!(!response.has_header(REQUEST_ID_HEADER)); +} + +#[tokio::test] +async fn the_problem_document_carries_the_id() { + let mut ctx = request(); + RequestIdTransformPlugin + .transform_request(&mut ctx) + .await + .expect("transform"); + let request_id = ctx.request_id.clone().expect("request id"); + + let mut error = ErrorContext::from_error(crate::domain::error::OagwError::route_not_found( + "no route matched", + )); + error.request_id = Some(request_id.clone()); + RequestIdTransformPlugin + .transform_error(&mut error) + .await + .expect("error"); + assert_eq!( + error + .headers + .get(REQUEST_ID_HEADER) + .and_then(|value| value.to_str().ok()), + Some(request_id.as_str()) + ); +} + +#[tokio::test] +async fn an_error_without_a_request_id_is_left_untouched() { + let mut error = ErrorContext::from_error(crate::domain::error::OagwError::route_not_found( + "no route matched", + )); + RequestIdTransformPlugin + .transform_error(&mut error) + .await + .expect("error"); + assert!(error.headers.get(REQUEST_ID_HEADER).is_none()); +} + +#[test] +fn the_plugin_is_shareable_across_threads() { + fn assert_send_sync() {} + assert_send_sync::(); +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/required_headers.rs b/gears/system/oagw/oagw/src/infra/plugin/required_headers.rs new file mode 100644 index 0000000..344dccc --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/required_headers.rs @@ -0,0 +1,133 @@ +//! Built-in required-headers guard plugin +//! (`gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1`). +//! +//! Stateless presence check on the request (before proxying) and on the +//! upstream response (before returning to the caller), per ADR-0009: +//! +//! * config keys `required_request_headers` / `required_response_headers`, +//! comma-separated, trimmed, lower-cased, empty entries dropped; +//! * an absent or blank list makes the phase a no-op (fail-open, so binding +//! the plugin changes nothing for an upstream that does not opt in); +//! * a request-phase rejection is `400`, a response-phase rejection `502`; +//! * the error code is always `REQUIRED_HEADER_MISSING`; +//! * only the **first** missing header is reported per rejection; +//! * only presence is checked, never the value. + +use async_trait::async_trait; +use axum::http::StatusCode; + +use crate::domain::error::OagwError; +use crate::domain::plugin::{ + GUARD_PLUGIN_TYPE_ID, GuardDecision, GuardPlugin, RequestContext, ResponseContext, builtin, +}; +use crate::infra::plugin::parse_header_names; + +/// Stable error code of every rejection of this plugin (ADR-0009). +pub const REQUIRED_HEADER_MISSING: &str = "REQUIRED_HEADER_MISSING"; + +/// Status of a request-phase rejection. +pub const REQUEST_REJECT_STATUS: u16 = 400; + +/// Status of a response-phase rejection. +pub const RESPONSE_REJECT_STATUS: u16 = 502; + +/// Required header enforcement plugin. +#[derive(Debug, Default, Clone)] +pub struct RequiredHeadersGuardPlugin { + required_request: Vec, + required_response: Vec, +} + +impl RequiredHeadersGuardPlugin { + /// Registry key of this plugin. + pub const PLUGIN_ID: &'static str = builtin::REQUIRED_HEADERS_GUARD; + + /// GTS base type of this plugin. + pub const PLUGIN_TYPE: &'static str = GUARD_PLUGIN_TYPE_ID; + + /// Builds the plugin from a binding configuration payload. + /// + /// Unparseable or blank payloads degrade to a no-op plugin (fail-open), + /// matching the ADR-0009 behaviour for an unconfigured phase rather than + /// failing the request. + #[must_use] + pub fn new(config: &serde_json::Value) -> Self { + Self { + required_request: required_list(config, "required_request_headers"), + required_response: required_list(config, "required_response_headers"), + } + } + + /// Request headers this plugin requires, for diagnostics and tests. + #[must_use] + pub fn required_request_headers(&self) -> &[String] { + &self.required_request + } + + /// Response headers this plugin requires, for diagnostics and tests. + #[must_use] + pub fn required_response_headers(&self) -> &[String] { + &self.required_response + } + + fn first_missing<'a>( + &self, + required: &'a [String], + present: &dyn Fn(&str) -> bool, + ) -> Option<&'a str> { + required + .iter() + .find(|name| !present(name.as_str())) + .map(String::as_str) + } +} + +fn required_list(config: &serde_json::Value, key: &str) -> Vec { + let Some(raw) = config.get(key).and_then(serde_json::Value::as_str) else { + return Vec::new(); + }; + parse_header_names(raw) +} + +#[async_trait] +impl GuardPlugin for RequiredHeadersGuardPlugin { + fn id(&self) -> &str { + builtin::REQUIRED_HEADERS_GUARD + } + + fn plugin_type(&self) -> &str { + GUARD_PLUGIN_TYPE_ID + } + + async fn guard_request(&self, ctx: &RequestContext) -> Result { + if self.required_request.is_empty() { + return Ok(GuardDecision::allow()); + } + match self.first_missing(&self.required_request, &|name| ctx.has_header(name)) { + Some(missing) => Ok(GuardDecision::reject( + StatusCode::from_u16(REQUEST_REJECT_STATUS).unwrap_or(StatusCode::BAD_REQUEST), + REQUIRED_HEADER_MISSING, + format!("required request header '{missing}' is missing"), + )), + None => Ok(GuardDecision::allow()), + } + } + + async fn guard_response(&self, ctx: &ResponseContext) -> Result { + if self.required_response.is_empty() { + return Ok(GuardDecision::allow()); + } + match self.first_missing(&self.required_response, &|name| ctx.has_header(name)) { + Some(missing) => Ok(GuardDecision::reject( + StatusCode::from_u16(RESPONSE_REJECT_STATUS).unwrap_or(StatusCode::BAD_GATEWAY), + REQUIRED_HEADER_MISSING, + format!("required response header '{missing}' is missing"), + )), + None => Ok(GuardDecision::allow()), + } + } +} + +#[cfg(test)] +#[path = "required_headers_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/plugin/required_headers_tests.rs b/gears/system/oagw/oagw/src/infra/plugin/required_headers_tests.rs new file mode 100644 index 0000000..df2bdc8 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/required_headers_tests.rs @@ -0,0 +1,277 @@ +//! Tests for [`crate::infra::plugin::required_headers`]. + +use std::sync::Arc; + +use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode}; +use uuid::Uuid; + +use super::{ + REQUEST_REJECT_STATUS, REQUIRED_HEADER_MISSING, RESPONSE_REJECT_STATUS, + RequiredHeadersGuardPlugin, +}; +use crate::domain::plugin::{ + GUARD_PLUGIN_TYPE_ID, GuardDecision, GuardPlugin, PluginChain, PluginTier, RequestContext, + ResponseContext, builtin, +}; +use crate::infra::plugin::parse_header_names; + +#[test] +fn parse_header_names_normalises_a_comma_separated_list() { + assert_eq!( + parse_header_names(" X-Trace-ID , , x-api-key,,"), + vec!["x-trace-id".to_owned(), "x-api-key".to_owned()] + ); + assert_eq!(parse_header_names(" "), Vec::::new()); + assert_eq!(parse_header_names(""), Vec::::new()); + assert_eq!( + parse_header_names("X-Request-ID,CONTENT-TYPE"), + vec!["x-request-id".to_owned(), "content-type".to_owned()] + ); +} + +fn plugin(config: serde_json::Value) -> RequiredHeadersGuardPlugin { + RequiredHeadersGuardPlugin::new(&config) +} + +fn request(required: &'static [&'static str]) -> RequestContext { + let mut headers = HeaderMap::new(); + for name in required { + headers.insert( + HeaderName::from_static(name), + HeaderValue::from_static("present"), + ); + } + RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1/payments") + .tenant_id(Uuid::from_u128(0x11)) + .headers(headers) + .build() +} + +fn response(required: &'static [&'static str]) -> ResponseContext { + let mut headers = HeaderMap::new(); + for name in required { + headers.insert( + HeaderName::from_static(name), + HeaderValue::from_static("present"), + ); + } + ResponseContext::builder() + .status(StatusCode::OK) + .headers(headers) + .build() +} + +#[test] +fn plugin_declares_the_adr_ids() { + let built = plugin(serde_json::json!({})); + assert_eq!(built.id(), builtin::REQUIRED_HEADERS_GUARD); + assert_eq!(built.plugin_type(), GUARD_PLUGIN_TYPE_ID); + assert_eq!( + RequiredHeadersGuardPlugin::PLUGIN_ID, + builtin::REQUIRED_HEADERS_GUARD + ); +} + +#[test] +fn request_headers_are_normalised_from_the_configuration() { + let built = plugin(serde_json::json!({ + "required_request_headers": " X-Trace-ID ,, x-api-key" + })); + assert_eq!( + built.required_request_headers(), + &["x-trace-id".to_owned(), "x-api-key".to_owned()] + ); + assert!(built.required_response_headers().is_empty()); +} + +#[test] +fn response_headers_are_normalised_from_the_configuration() { + let built = plugin(serde_json::json!({ + "required_response_headers": "X-Request-ID" + })); + assert_eq!( + built.required_response_headers(), + &["x-request-id".to_owned()] + ); + assert!(built.required_request_headers().is_empty()); +} + +#[test] +fn a_blank_configuration_is_a_no_op() { + let built = plugin(serde_json::json!({})); + assert!(built.required_request_headers().is_empty()); + assert!(built.required_response_headers().is_empty()); +} + +#[test] +fn a_non_string_configuration_is_ignored() { + let built = plugin(serde_json::json!({ "required_request_headers": 42 })); + assert!(built.required_request_headers().is_empty()); +} + +#[test] +fn a_broken_configuration_degrades_to_a_no_op() { + let built = plugin(serde_json::json!(["not", "an", "object"])); + assert!(built.required_request_headers().is_empty()); + assert!(built.required_response_headers().is_empty()); +} + +#[test] +fn the_adr_pins_the_two_rejection_statuses() { + assert_eq!(REQUEST_REJECT_STATUS, 400); + assert_eq!(RESPONSE_REJECT_STATUS, 502); + assert_eq!(REQUIRED_HEADER_MISSING, "REQUIRED_HEADER_MISSING"); +} + +#[tokio::test] +async fn all_present_request_headers_allow_the_request() { + let built = plugin(serde_json::json!({ + "required_request_headers": "x-trace-id, x-api-key" + })); + let ctx = request(&["x-trace-id", "x-api-key"]); + assert_eq!( + built.guard_request(&ctx).await.expect("decision"), + GuardDecision::Allow + ); +} + +#[tokio::test] +async fn a_missing_request_header_is_rejected_with_400() { + let built = plugin(serde_json::json!({ + "required_request_headers": "x-trace-id, x-api-key" + })); + let ctx = request(&["x-trace-id"]); + let decision = built.guard_request(&ctx).await.expect("decision"); + assert_eq!(decision.status(), Some(StatusCode::BAD_REQUEST)); + assert_eq!(decision.error_code(), Some(REQUIRED_HEADER_MISSING)); +} + +#[tokio::test] +async fn only_the_first_missing_header_is_reported() { + let built = plugin(serde_json::json!({ + "required_request_headers": "x-trace-id, x-api-key, x-third" + })); + let ctx = request(&[]); + let detail = built + .guard_request(&ctx) + .await + .expect("decision") + .into_error() + .detail() + .to_owned(); + assert!(detail.contains("x-trace-id"), "detail is '{detail}'"); + assert!(!detail.contains("x-api-key"), "detail is '{detail}'"); + assert!(!detail.contains("x-third"), "detail is '{detail}'"); +} + +#[tokio::test] +async fn an_absent_request_list_is_a_no_op() { + let built = plugin(serde_json::json!({ + "required_response_headers": "x-request-id" + })); + let ctx = request(&[]); + assert_eq!( + built.guard_request(&ctx).await.expect("decision"), + GuardDecision::Allow + ); +} + +#[tokio::test] +async fn an_empty_list_value_is_a_no_op() { + let built = plugin(serde_json::json!({ "required_request_headers": " , , " })); + let ctx = request(&[]); + assert_eq!( + built.guard_request(&ctx).await.expect("decision"), + GuardDecision::Allow + ); +} + +#[tokio::test] +async fn a_missing_response_header_is_rejected_with_502() { + let built = plugin(serde_json::json!({ + "required_response_headers": "x-request-id" + })); + let ctx = response(&[]); + let decision = built.guard_response(&ctx).await.expect("decision"); + assert_eq!(decision.status(), Some(StatusCode::BAD_GATEWAY)); + assert_eq!(decision.error_code(), Some(REQUIRED_HEADER_MISSING)); +} + +#[tokio::test] +async fn a_present_response_header_allows_the_response() { + let built = plugin(serde_json::json!({ + "required_response_headers": "x-request-id" + })); + let ctx = response(&["x-request-id"]); + assert_eq!( + built.guard_response(&ctx).await.expect("decision"), + GuardDecision::Allow + ); +} + +#[tokio::test] +async fn an_absent_response_list_is_a_no_op() { + let built = plugin(serde_json::json!({ + "required_request_headers": "x-trace-id" + })); + let ctx = response(&[]); + assert_eq!( + built.guard_response(&ctx).await.expect("decision"), + GuardDecision::Allow + ); +} + +#[tokio::test] +async fn only_presence_is_checked_never_the_value() { + let built = plugin(serde_json::json!({ "required_request_headers": "x-trace-id" })); + let mut headers = HeaderMap::new(); + headers.insert( + HeaderName::from_static("x-trace-id"), + HeaderValue::from_static(""), + ); + let ctx = RequestContext::builder() + .method("GET") + .alias("payments") + .path("/v1") + .tenant_id(Uuid::from_u128(1)) + .headers(headers) + .build(); + assert_eq!( + built.guard_request(&ctx).await.expect("decision"), + GuardDecision::Allow + ); +} + +#[tokio::test] +async fn the_chain_runner_maps_a_rejection_onto_the_taxonomy() { + let mut chain = PluginChain::new(); + chain.push_guard( + PluginTier::Upstream, + 0, + "required_headers", + Arc::new(plugin(serde_json::json!({ + "required_request_headers": "x-trace-id" + }))), + ); + let error = chain.guard_request(&request(&[])).await.expect_err("400"); + assert_eq!(error.status(), StatusCode::BAD_REQUEST); + assert!(error.detail().contains(REQUIRED_HEADER_MISSING)); +} + +#[tokio::test] +async fn the_chain_runner_maps_a_response_rejection_onto_502() { + let mut chain = PluginChain::new(); + chain.push_guard( + PluginTier::Upstream, + 0, + "required_headers", + Arc::new(plugin(serde_json::json!({ + "required_response_headers": "x-request-id" + }))), + ); + let error = chain.guard_response(&response(&[])).await.expect_err("502"); + assert_eq!(error.status(), StatusCode::BAD_GATEWAY); +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/secret.rs b/gears/system/oagw/oagw/src/infra/plugin/secret.rs new file mode 100644 index 0000000..cd09029 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/secret.rs @@ -0,0 +1,162 @@ +//! Secret resolution port for the auth plugins (DESIGN "Secret Access +//! Control"). +//! +//! Auth configuration never carries credential material: it carries a +//! `cred://` reference that has to be resolved at request time against the +//! credential store, which owns the tenant-scoped sharing policy. The plugins +//! depend on the [`SecretResolver`] port instead of on `credstore-sdk` +//! directly, so tests (and a deployment without a credstore dependency) can +//! substitute another implementation. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use credstore_sdk::{CredStoreClientV1, SecretRef}; +use toolkit_security::SecurityContext; + +use crate::domain::error::OagwError; + +/// URI scheme of a credential store reference (DESIGN "Secret Access Control"). +pub const SECRET_REF_SCHEME: &str = "cred://"; + +/// Resolves a `cred://` reference to its secret material. +/// +/// The returned value is consumed immediately by the caller to build the +/// outbound credential and is never logged or persisted. +#[async_trait] +pub trait SecretResolver: Send + Sync { + /// Resolves `secret_ref` in the scope of `ctx`. + /// + /// `Ok(None)` means the reference is well-formed but no secret is + /// accessible to the caller; the caller decides which error to surface. + /// + /// # Errors + /// + /// Returns [`OagwError::SecretNotFound`] when the reference is malformed + /// or the credential store itself fails. + async fn resolve( + &self, + ctx: &SecurityContext, + secret_ref: &str, + ) -> Result, OagwError>; +} + +/// Strips the `cred://` scheme from a reference, returning the bare key. +/// +/// Surrounding whitespace is trimmed as well: the credential store validates +/// keys against `[a-zA-Z0-9_-]`, so the URI decoration must not be handed to +/// it. +#[must_use] +pub fn strip_secret_scheme(secret_ref: &str) -> &str { + let trimmed = secret_ref.trim(); + trimmed + .strip_prefix(SECRET_REF_SCHEME) + .or_else(|| trimmed.strip_prefix("cred:")) + .unwrap_or(trimmed) + .trim() +} + +/// Builds a credential store reference from a bare key. +/// +/// # Errors +/// +/// Returns [`OagwError::secret_not_found`] when the key is not a valid +/// [`SecretRef`] (empty, too long, or containing characters outside the +/// credential store alphabet). +pub fn secret_ref(secret_ref: &str) -> Result { + SecretRef::new(strip_secret_scheme(secret_ref)).map_err(|error| { + OagwError::secret_not_found(format!("invalid secret reference '{secret_ref}': {error}")) + }) +} + +/// Resolver backed by the `credstore` dependency. +pub struct CredStoreSecretResolver { + client: Arc, +} + +impl CredStoreSecretResolver { + /// Wraps a credential store client. + #[must_use] + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl SecretResolver for CredStoreSecretResolver { + async fn resolve( + &self, + ctx: &SecurityContext, + secret_ref: &str, + ) -> Result, OagwError> { + let reference = self::secret_ref(secret_ref)?; + match self.client.get(ctx, &reference).await { + Ok(Some(response)) => { + let value = String::from_utf8_lossy(response.value.as_bytes()).into_owned(); + Ok(Some(value)) + } + Ok(None) => Ok(None), + Err(error) => Err(OagwError::secret_not_found(format!( + "credential store lookup for '{secret_ref}' failed: {error}" + ))), + } + } +} + +/// Fail-closed resolver installed when the `credstore` dependency is absent. +/// +/// A gateway that cannot resolve credentials must not silently fall back to an +/// unauthenticated upstream call, so every resolution fails with +/// [`OagwError::SecretNotFound`] (500) at use time. +#[derive(Debug, Default, Clone, Copy)] +pub struct UnavailableSecretResolver; + +#[async_trait] +impl SecretResolver for UnavailableSecretResolver { + async fn resolve( + &self, + _ctx: &SecurityContext, + secret_ref: &str, + ) -> Result, OagwError> { + Err(OagwError::secret_not_found(format!( + "no credential store is available to resolve '{secret_ref}'" + ))) + } +} + +/// In-memory resolver for tests and for operators who pin a credential +/// statically. +#[derive(Debug, Default, Clone)] +pub struct StaticSecretResolver { + secrets: HashMap, +} + +impl StaticSecretResolver { + /// Creates a resolver serving `secrets`, keyed by the *bare* reference key + /// (without the `cred://` scheme). + #[must_use] + pub fn new(secrets: HashMap) -> Self { + Self { secrets } + } + + /// Adds one secret. + pub fn insert(&mut self, key: impl Into, value: impl Into) { + self.secrets.insert(key.into(), value.into()); + } +} + +#[async_trait] +impl SecretResolver for StaticSecretResolver { + async fn resolve( + &self, + _ctx: &SecurityContext, + secret_ref: &str, + ) -> Result, OagwError> { + Ok(self.secrets.get(strip_secret_scheme(secret_ref)).cloned()) + } +} + +#[cfg(test)] +#[path = "secret_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/plugin/secret_tests.rs b/gears/system/oagw/oagw/src/infra/plugin/secret_tests.rs new file mode 100644 index 0000000..f7f8bfb --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/secret_tests.rs @@ -0,0 +1,140 @@ +//! Tests for [`crate::infra::plugin::secret`]. + +use std::collections::HashMap; +use std::sync::Arc; + +use credstore_sdk::test_util::MockCredStoreClient; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use super::{ + CredStoreSecretResolver, SECRET_REF_SCHEME, SecretResolver, StaticSecretResolver, + UnavailableSecretResolver, secret_ref, strip_secret_scheme, +}; + +const TENANT: Uuid = Uuid::from_u128(0x11); + +fn context() -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::from_u128(0x33)) + .subject_tenant_id(TENANT) + .build() + .expect("security context") +} + +#[tokio::test] +async fn credstore_resolver_returns_the_secret() { + let resolver = + CredStoreSecretResolver::new(Arc::new(MockCredStoreClient::with_secrets(vec![( + "payments-key".to_owned(), + "s3cret".to_owned(), + )]))); + let resolved = resolver + .resolve(&context(), "cred://payments-key") + .await + .expect("resolved"); + assert_eq!(resolved.as_deref(), Some("s3cret")); +} + +#[tokio::test] +async fn credstore_resolver_maps_a_missing_secret_to_none() { + let resolver = CredStoreSecretResolver::new(Arc::new(MockCredStoreClient::empty())); + let resolved = resolver + .resolve(&context(), "cred://absent-key") + .await + .expect("no error"); + assert_eq!(resolved, None); +} + +#[tokio::test] +async fn credstore_resolver_maps_a_store_failure_to_secret_not_found() { + let resolver = CredStoreSecretResolver::new(Arc::new(MockCredStoreClient::always_failing())); + let error = resolver + .resolve(&context(), "cred://payments-key") + .await + .expect_err("500"); + assert_eq!( + error.status(), + axum::http::StatusCode::INTERNAL_SERVER_ERROR + ); + assert!(error.detail().contains("credential store lookup")); +} + +#[tokio::test] +async fn credstore_resolver_rejects_a_malformed_reference() { + let resolver = CredStoreSecretResolver::new(Arc::new(MockCredStoreClient::empty())); + let error = resolver + .resolve(&context(), "cred://not a valid key!") + .await + .expect_err("malformed reference"); + assert_eq!( + error.status(), + axum::http::StatusCode::INTERNAL_SERVER_ERROR + ); +} + +#[tokio::test] +async fn unavailable_resolver_fails_closed() { + let resolver = UnavailableSecretResolver; + let error = resolver + .resolve(&context(), "cred://payments-key") + .await + .expect_err("fail closed"); + assert_eq!( + error.status(), + axum::http::StatusCode::INTERNAL_SERVER_ERROR + ); + assert!(error.detail().contains("no credential store")); +} + +#[tokio::test] +async fn static_resolver_serves_its_map() { + let mut secrets = HashMap::new(); + secrets.insert("payments-key".to_owned(), "static-value".to_owned()); + let resolver = StaticSecretResolver::new(secrets); + let resolved = resolver + .resolve(&context(), "cred://payments-key") + .await + .expect("resolved"); + assert_eq!(resolved.as_deref(), Some("static-value")); + assert_eq!( + resolver + .resolve(&context(), "cred://absent") + .await + .expect("none"), + None + ); +} + +#[test] +fn scheme_and_stripping_are_consistent() { + assert_eq!(SECRET_REF_SCHEME, "cred://"); + assert_eq!(strip_secret_scheme("cred://payments-key"), "payments-key"); + assert_eq!(strip_secret_scheme("cred:payments-key"), "payments-key"); + assert_eq!(strip_secret_scheme("payments-key"), "payments-key"); + assert_eq!( + strip_secret_scheme(" cred://payments-key "), + "payments-key" + ); +} + +#[test] +fn secret_ref_builds_a_valid_store_reference() { + let reference = secret_ref("cred://payments-key").expect("reference"); + assert_eq!(reference.as_ref(), "payments-key"); + assert!(secret_ref("cred://invalid key!").is_err()); +} + +#[tokio::test] +async fn static_resolver_is_usable_through_the_trait_object() { + let mut secrets = HashMap::new(); + secrets.insert("k".to_owned(), "v".to_owned()); + let resolver: Arc = Arc::new(StaticSecretResolver::new(secrets)); + assert_eq!( + resolver + .resolve(&context(), "cred://k") + .await + .expect("resolved"), + Some("v".to_owned()) + ); +} diff --git a/gears/system/oagw/oagw/src/infra/proxy.rs b/gears/system/oagw/oagw/src/infra/proxy.rs new file mode 100644 index 0000000..ae8582d --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy.rs @@ -0,0 +1,1296 @@ +//! Outbound proxy engine of the OAGW data plane (DESIGN section 3.5). +//! +//! [`ProxyEngine`] owns the single shared upstream transport +//! (`pingora_core::connectors::TransportConnector`, ADR-0006) and turns a +//! resolved `(upstream, route, endpoint)` plus a client [`ProxyRequest`] into +//! an upstream HTTP/1.1 exchange. It is deliberately *protocol-only*: tenancy, +//! route resolution, CORS, rate limiting and the plugin chain live in the +//! handler, so the engine stays independently testable. +//! +//! Responsibilities +//! +//! * **endpoint selection** (ADR-0001): one endpoint wins outright, an explicit +//! `x-oagw-target-host` is validated and matched case-insensitively, a +//! common-suffix alias pool demands the header, and a distinct-host pool is +//! load-balanced round-robin; +//! * **header pipeline** in both directions, with hop-by-hop stripping; +//! * **credential injection** (PRD §5.2): the headers and query parameters the +//! plugin chain injected are applied *after* the client pipeline, so an +//! injected credential replaces a caller-supplied value of the same name and +//! survives a `passthrough: none` posture; +//! * **body validation before forwarding** (content length, transfer encoding, +//! total size); +//! * **error mapping** onto the [`OagwError`] taxonomy; +//! * **streaming**: the response body is never buffered, so SSE and other +//! long-lived streams flow through incrementally; +//! * **upgrade**: a WebSocket upgrade is forwarded and the two connections are +//! spliced once both ends switch protocols. +//! +//! ## Documented deviations +//! +//! * The `proxy_timeout` budget covers connection establishment plus the +//! request/response *header* exchange. Once headers arrive, the body streams +//! without an overall deadline: an overall deadline would abort every SSE +//! stream at a fixed wall-clock time. +//! * The engine does not hand connections back to pingora's reuse pool after +//! an exchange, because the connection is owned by the spawned task that +//! drives the hyper client. [`TransportConnector`] still provides the L4 and +//! TLS timeouts, the TLS material and the socket discipline. +//! * A WebSocket upgrade is forwarded for `ws`/`wss` endpoints; a non-101 +//! answer is mapped to `502 ProtocolError` rather than streamed. +//! * **Upstream protocol version.** The engine speaks HTTP/1.1 upstream +//! ([`hyper::client::conn::http1`] over the pingora +//! [`TransportConnector`]), always. DESIGN §4.4 credits pingora with +//! "adaptive per-host HTTP/2 detection"; that capability is not wired up +//! here, so an HTTP/2-only upstream (and the `grpc` protocol, which the +//! handler answers with `501`) is out of scope. Every outbound request is +//! stamped `Version::HTTP_11` explicitly rather than negotiated. +//! * **Protocol switches.** Only a WebSocket upgrade is proxied, and the +//! outbound handshake always signals `Connection: Upgrade` + +//! `Upgrade: websocket`: the scheme allowlist (`ws`/`wss`) is what tells the +//! engine a switch was meant, so a non-WebSocket `Upgrade` token (h2c, +//! SPDY, a custom protocol) is treated as an ordinary header, stripped by the +//! hop-by-hop filter and never forwarded. Upgrades to anything but +//! `websocket`, and HTTP/2 extended CONNECT, are out of scope. +//! * **`http.response.status_code` label semantics.** The request instrument +//! (`oagw_requests_total`) carries the *class* of the status — `2xx`, `3xx`, +//! `4xx`, `5xx` (see [`crate::domain::metrics::status_class`]) — and not the +//! numeric code. That is a deliberate deviation from the OpenTelemetry HTTP +//! semantic conventions, which put the exact status in the attribute: the +//! class keeps the series count bounded at four per `(host, route)` pair and +//! is what DESIGN §4.2's dashboards aggregate on. A caller who needs the +//! exact status reads it from the audit record (DESIGN §4.3), which carries +//! the numeric value. + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; + +use bytes::Bytes; +use dashmap::DashMap; +use futures_util::StreamExt; +use http::{HeaderMap, HeaderName, HeaderValue, Method, Uri, Version}; +use hyper_util::rt::TokioIo; +use pingora_core::connectors::{ConnectorOptions, TransportConnector}; +use pingora_core::upstreams::peer::HttpPeer; +use uuid::Uuid; + +use crate::config::{OagwConfig, SsrfPolicy}; +use crate::domain::error::OagwError; +use crate::domain::metrics::MetricsRegistry; +use crate::domain::model::{Endpoint, HeaderRules, PassthroughMode, Route, Scheme, Upstream}; +use crate::domain::validation::classify_host; + +/// Header a client may set to pin the endpoint the gateway calls (ADR-0001). +pub const TARGET_HOST_HEADER: &str = "x-oagw-target-host"; + +/// Hard cap on the buffered request body. +pub const MAX_REQUEST_BODY_BYTES: usize = 100 * 1024 * 1024; + +/// RFC 9110 section 7.6.1 hop-by-hop headers, always stripped in both +/// directions (DESIGN section 3.3). +const HOP_BY_HOP: [&str; 9] = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "proxy-connection", +]; + +/// Header consumed by the routing layer and never forwarded upstream. +const CONSUMED: [&str; 1] = [TARGET_HOST_HEADER]; + +/// Transport headers the gateway always carries, whatever the passthrough mode +/// says. `Host` is set separately from the endpoint. +const OWNED: [&str; 2] = ["content-type", "content-length"]; + +/// Client headers a WebSocket handshake cannot complete without (RFC 6455 +/// section 4.1). Forwarded verbatim on a protocol switch, independently of the +/// passthrough mode. +const WEBSOCKET_HANDSHAKE_HEADERS: [&str; 4] = [ + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-protocol", + "sec-websocket-extensions", +]; + +/// Status of a successful protocol switch. +const SWITCHING_PROTOCOLS: u16 = 101; + +/// Request payload handed to [`ProxyEngine::send`]. +#[derive(Debug, Default)] +pub struct ProxyRequest { + /// HTTP method of the client request. + pub method: Method, + /// Outbound path (already route-rewritten), without the query string. + pub path: String, + /// Raw query string, without the leading `?`; may be empty. + pub query: String, + /// Client headers, already plugin-transformed. + pub headers: HeaderMap, + /// Headers the plugin chain injected into the outbound request, in + /// injection order (ADR-0002 credential injection, request-id propagation). + /// + /// They are applied *after* the client passthrough filter, so an injected + /// credential replaces a client-supplied header of the same name and is + /// never dropped by `passthrough: none`. + pub injected_headers: Vec<(HeaderName, HeaderValue)>, + /// Query parameters the plugin chain injected, in injection order. + /// + /// They override a client parameter of the same name and are appended to + /// the surviving client parameters, URL-encoded. + pub injected_query: Vec<(String, String)>, + /// Client body. + pub body: ProxyBody, + /// Optional `x-oagw-target-host` value, already trimmed. + pub target_host: Option, + /// `true` when the client asked for a protocol upgrade and the + /// `Connection`/`Upgrade` headers must survive the pipeline. + pub upgrade: bool, + /// Upgrade handle of the *client* socket, lifted out of the inbound + /// request; the engine splices it with the upstream socket on a `101`. + pub downstream_upgrade: Option, +} + +/// Client body of a [`ProxyRequest`]. +#[derive(Debug, Default)] +pub enum ProxyBody { + /// No body at all. + #[default] + Empty, + /// Body already validated and buffered. + Buffered(Bytes), + /// Body still streaming from the client. + Stream(axum::body::Body), +} + +/// Upstream answer handed back to the handler. +#[derive(Debug)] +pub struct ProxyResponse { + /// Upstream status. + pub status: http::StatusCode, + /// Upstream headers, after the response header rules. + pub headers: HeaderMap, + /// Upstream body, streamed. + pub body: axum::body::Body, + /// `true` when the upstream switched protocols; the body channel is the + /// spliced tunnel rather than an HTTP body. + pub upgraded: bool, +} + +/// How [`ProxyEngine::select_endpoint`] chose the endpoint. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectionMethod { + /// The client pinned the endpoint with `x-oagw-target-host`. + ExplicitHeader, + /// The pool has several distinct hosts and no pin; round-robin decided. + RoundRobin, + /// The pool has exactly one endpoint. + Default, +} + +impl SelectionMethod { + /// The label value used by `oagw_routing_endpoint_selected`. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::ExplicitHeader => "explicit_header", + Self::RoundRobin => "round_robin", + Self::Default => "default", + } + } +} + +/// An endpoint chosen by [`ProxyEngine::select_endpoint`]. +#[derive(Debug, Clone)] +pub struct EndpointSelection { + /// The endpoint to call. + pub endpoint: Endpoint, + /// How it was chosen. + pub method: SelectionMethod, +} + +/// Outbound transport engine. +pub struct ProxyEngine { + connector: Arc, + timeout: std::time::Duration, + ssrf: SsrfPolicy, + metrics: Arc, + cursors: DashMap>, +} + +impl std::fmt::Debug for ProxyEngine { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("ProxyEngine") + .field("timeout", &self.timeout) + .field("ssrf", &self.ssrf) + .finish_non_exhaustive() + } +} + +impl ProxyEngine { + /// Builds an engine over a fresh shared transport connector. + /// + /// `metrics` receives the routing and duration instruments. + #[must_use] + pub fn new(config: &OagwConfig, metrics: Arc) -> Self { + let connector = Arc::new(TransportConnector::new(Some(ConnectorOptions::new(128)))); + Self::with_connector(config, connector, metrics) + } + + /// Builds an engine over an existing connector (tests, custom transports). + #[must_use] + pub fn with_connector( + config: &OagwConfig, + connector: Arc, + metrics: Arc, + ) -> Self { + Self { + connector, + timeout: config.proxy_timeout(), + ssrf: config.ssrf_policy.clone(), + metrics, + cursors: DashMap::new(), + } + } + + /// The shared transport connector (ADR-0006). + #[must_use] + pub fn connector(&self) -> &Arc { + &self.connector + } + + /// The proxy budget every upstream phase is bounded by + /// (`proxy_timeout_secs`). + /// + /// Exposed for the caller to bound its own pre-upstream waits — a `queue` + /// rate-limit strategy must never spend longer waiting for a token than the + /// upstream would have allowed in the first place. + #[must_use] + pub const fn proxy_timeout(&self) -> std::time::Duration { + self.timeout + } + + /// Selects the endpoint to call (ADR-0001). + /// + /// # Errors + /// + /// * [`OagwError::InvalidTargetHost`] — the pinned host is malformed; + /// * [`OagwError::UnknownTargetHost`] — the pinned host is not in the pool; + /// * [`OagwError::MissingTargetHost`] — the pool has several common-suffix + /// alias endpoints and the client pinned none; + /// * [`OagwError::LinkUnavailable`] — the pool is empty. + pub fn select_endpoint( + &self, + upstream: &Upstream, + requested: Option<&str>, + ) -> Result { + let endpoints = upstream.server.endpoints.as_slice(); + if endpoints.is_empty() { + return Err(empty_pool(upstream)); + } + let hosts: Vec = endpoints + .iter() + .map(|endpoint| endpoint.host.to_ascii_lowercase()) + .collect(); + + if let Some(pinned) = requested.map(str::trim).filter(|value| !value.is_empty()) { + let normalized = normalize_requested_host(pinned)?; + return match hosts + .iter() + .position(|host| host == &normalized || strip_trailing_dot(host) == normalized) + { + Some(index) => { + self.record_selection( + upstream, + &endpoints[index], + SelectionMethod::ExplicitHeader, + ); + Ok(EndpointSelection { + endpoint: endpoints[index].clone(), + method: SelectionMethod::ExplicitHeader, + }) + } + None => Err(OagwError::unknown_target_host(format!( + "target host '{pinned}' does not match any endpoint of upstream '{}'", + upstream.alias + )) + .with_upstream_id(upstream.id) + .with_host(pinned) + .with_valid_hosts(hosts.clone())), + }; + } + + if endpoints.len() == 1 { + self.record_selection(upstream, &endpoints[0], SelectionMethod::Default); + return Ok(EndpointSelection { + endpoint: endpoints[0].clone(), + method: SelectionMethod::Default, + }); + } + + if let Some(alias) = common_suffix_alias(&upstream.alias, &hosts) { + return Err(OagwError::missing_target_host(format!( + "upstream '{}' balances the '{alias}' endpoint alias; set {} to one of them", + upstream.alias, TARGET_HOST_HEADER + )) + .with_upstream_id(upstream.id) + .with_valid_hosts(hosts.clone())); + } + + let endpoint = self.round_robin(upstream.id, endpoints); + self.record_selection(upstream, &endpoint, SelectionMethod::RoundRobin); + Ok(EndpointSelection { + endpoint, + method: SelectionMethod::RoundRobin, + }) + } + + /// Bumps the routing metrics of a selection. + fn record_selection(&self, upstream: &Upstream, endpoint: &Endpoint, method: SelectionMethod) { + let upstream_id = upstream.id.to_string(); + self.metrics + .record_target_host_used(&upstream_id, &endpoint.host); + self.metrics + .record_endpoint_selected(&upstream_id, &endpoint.host, method.as_str()); + self.metrics.set_upstream_available( + &upstream.alias, + &Self::render_endpoint(endpoint), + true, + ); + } + + /// Renders an endpoint as the `endpoint` label value of the availability + /// gauge, and the `endpoint` field of the proxy audit record. + pub fn render_endpoint(endpoint: &Endpoint) -> String { + let scheme = match endpoint.scheme { + Scheme::Https => "https", + Scheme::Http => "http", + Scheme::Wss => "wss", + Scheme::Ws => "ws", + Scheme::Wt => "wt", + Scheme::Grpc => "grpc", + }; + format!("{scheme}://{}:{}", endpoint.host, endpoint.port) + } + + /// Advances the per-upstream round-robin cursor. + fn round_robin(&self, upstream_id: Uuid, endpoints: &[Endpoint]) -> Endpoint { + let cursor = self + .cursors + .entry(upstream_id) + .or_insert_with(|| Arc::new(AtomicUsize::new(0))) + .clone(); + let index = cursor.fetch_add(1, Ordering::Relaxed) % endpoints.len(); + endpoints[index].clone() + } + + /// Resolves `host` and applies the SSRF gate. + /// + /// # Errors + /// + /// [`OagwError::LinkUnavailable`] when the name does not resolve, and + /// [`OagwError::InvalidTargetHost`] when the resolved address is rejected + /// by `ssrf_policy`. + pub async fn resolve_host(&self, host: &str, port: u16) -> Result { + let mut resolved = tokio::net::lookup_host((host, port)) + .await + .map_err(|error| { + OagwError::link_unavailable(format!( + "upstream host '{host}' does not resolve: {error}" + )) + })?; + let Some(address) = resolved.next() else { + return Err(OagwError::link_unavailable(format!( + "upstream host '{host}' does not resolve to any address" + ))); + }; + if self.ssrf.enabled { + check_ssrf(&self.ssrf, address.ip())?; + } + Ok(address.ip()) + } + + /// Sends `request` to `target` and returns the upstream answer. + /// + /// The header pipeline runs in the documented order: strip hop-by-hop, + /// apply the passthrough mode of the merged upstream → route request + /// rules, insert the plugin-injected headers over the survivors, apply + /// those rules (`set`/`add`/`remove`), then set `Host` to the endpoint + /// authority. The outbound query is the client query with the + /// plugin-injected parameters appended and overriding their client + /// counterparts. + /// + /// The response body is never buffered: it streams from the upstream + /// connection into the client response. + /// + /// # Errors + /// + /// Maps transport failures onto the taxonomy: + /// [`OagwError::ConnectionTimeout`] for the connect phase, + /// [`OagwError::RequestTimeout`] for the header exchange, + /// [`OagwError::LinkUnavailable`] for refused or unresolvable peers, + /// [`OagwError::ProtocolError`] for TLS and handshake failures and + /// [`OagwError::StreamAborted`] for a body that dies mid-flight. + pub async fn send( + &self, + upstream: &Upstream, + route: Option<&Route>, + target: &Endpoint, + request: ProxyRequest, + ) -> Result { + let started = Instant::now(); + // The duration metrics are keyed on the route *pattern*, never on the + // outbound path: the latter carries the client's path suffix and would + // mint one series per resource (see `domain::metrics::route_label`). + let path = crate::domain::metrics::route_label( + request.method.as_str(), + route + .and_then(|route| route.r#match.http.as_ref()) + .map(|http| http.path.as_str()), + ); + let ip = self.resolve_host(&target.host, target.port).await?; + let peer = build_peer(ip, target, self.timeout); + + let connect = self.connector.get_stream(&peer); + let (stream, _reused) = match tokio::time::timeout(self.timeout, connect).await { + Ok(result) => result.map_err(|error| map_connect_error(&error, upstream, target))?, + Err(_) => { + return Err(OagwError::connection_timeout(format!( + "connecting to {}:{} exceeded the proxy budget", + target.host, target.port + )) + .with_upstream_id(upstream.id) + .with_host(&target.host)); + } + }; + self.metrics.record_duration( + &upstream.alias, + &path, + crate::domain::metrics::PHASE_CONNECT, + started.elapsed().as_secs_f64(), + ); + + let mut request = request; + let downstream_upgrade = request.downstream_upgrade.take(); + let mut outbound = self.build_outbound_request(upstream, route, request)?; + apply_header_rules( + outbound.headers_mut(), + &effective_header_rules(upstream, route), + )?; + set_host(outbound.headers_mut(), &authority(target))?; + + let io = TokioIo::new(stream); + let handshake = hyper::client::conn::http1::Builder::new().handshake(io); + let (mut sender, connection) = match tokio::time::timeout(self.timeout, handshake).await { + Ok(result) => result.map_err(|error| map_exchange_error(&error, upstream, target))?, + Err(_) => { + return Err(OagwError::request_timeout(format!( + "upstream handshake with {}:{} exceeded the proxy budget", + target.host, target.port + )) + .with_upstream_id(upstream.id) + .with_host(&target.host)); + } + }; + tokio::spawn(async move { + if let Err(error) = connection.with_upgrades().await { + tracing::debug!("upstream connection finished: {error}"); + } + }); + + let exchange = tokio::time::timeout(self.timeout, sender.send_request(outbound)); + let mut response = match exchange.await { + Ok(result) => result.map_err(|error| map_exchange_error(&error, upstream, target))?, + Err(_) => { + return Err(OagwError::request_timeout(format!( + "upstream {}:{} did not answer within the proxy budget", + target.host, target.port + )) + .with_upstream_id(upstream.id) + .with_host(&target.host)); + } + }; + self.metrics.record_duration( + &upstream.alias, + &path, + crate::domain::metrics::PHASE_UPSTREAM, + started.elapsed().as_secs_f64(), + ); + + // The upgrade handle must be lifted out *before* the response is + // deconstructed, so the 101 branch reads it from `hyper` directly. + let upgraded = response.status().as_u16() == SWITCHING_PROTOCOLS; + let upstream_upgrade = upgraded.then(|| hyper::upgrade::on(&mut response)); + let (parts, incoming) = response.into_parts(); + let body = if upgraded { + self.splice_upgrade(upstream_upgrade, downstream_upgrade, upstream, target) + .await?; + axum::body::Body::empty() + } else { + let metrics = Arc::clone(&self.metrics); + let host = upstream.alias.clone(); + let route_label = path.clone(); + let watched = axum::body::Body::new(incoming) + .into_data_stream() + .map(move |frame| { + if frame.is_err() { + metrics.record_error( + &host, + &route_label, + &OagwError::stream_aborted("upstream stream aborted").gts_type(), + ); + } + frame + }); + axum::body::Body::from_stream(watched) + }; + + Ok(ProxyResponse { + status: parts.status, + headers: parts.headers, + body, + upgraded, + }) + } + + /// Joins the two upgraded sockets of a `101 Switching Protocols` exchange. + /// + /// The upstream half comes from the exchanged response, the downstream half + /// from the `OnUpgrade` handle the caller lifted out of the inbound + /// request; a non-101 answer never reaches this point, so the caller maps + /// an unexpected status to a `502` before calling. + /// + /// # Errors + /// + /// [`OagwError::ProtocolError`] when either half refuses the upgrade. + async fn splice_upgrade( + &self, + upstream_upgrade: Option, + downstream: Option, + upstream: &Upstream, + target: &Endpoint, + ) -> Result<(), OagwError> { + let (Some(upstream_upgrade), Some(downstream)) = (upstream_upgrade, downstream) else { + // No client socket to splice: the caller asked for an upgrade it + // cannot carry, so the answer is a `502` rather than a socket + // nobody reads. + return Err(OagwError::protocol_error(format!( + "upstream {}:{} switched protocols but the caller did not request an upgrade", + target.host, target.port + )) + .with_upstream_id(upstream.id) + .with_host(&target.host)); + }; + let upstream_io = upstream_upgrade.await.map_err(|error| { + OagwError::protocol_error(format!( + "upstream {}:{} did not complete the protocol switch: {error}", + target.host, target.port + )) + .with_upstream_id(upstream.id) + .with_host(&target.host) + })?; + let upstream_id = upstream.id; + let host = target.host.clone(); + tokio::spawn(async move { + let mut downstream_io = match downstream.await { + Ok(io) => TokioIo::new(io), + Err(error) => { + tracing::debug!("downstream upgrade failed: {error}"); + return; + } + }; + let mut server_io = TokioIo::new(upstream_io); + match tokio::io::copy_bidirectional(&mut downstream_io, &mut server_io).await { + Ok((to_upstream, from_upstream)) => { + tracing::trace!( + "upgraded channel to {host} for {upstream_id}: {to_upstream} up, \ + {from_upstream} down" + ); + } + Err(error) => { + tracing::debug!("upgraded channel to {host} for {upstream_id} closed: {error}"); + } + } + }); + Ok(()) + } + + /// Builds the outbound HTTP/1.1 request body and URI; the header map is + /// completed by the caller. + /// + /// The header pipeline is: strip hop-by-hop from the client headers, apply + /// the passthrough mode, then `insert` the plugin-injected headers over the + /// survivors. The injected set is written last, so a credential a plugin + /// resolved replaces a client-supplied header of the same name and survives + /// a `passthrough: none` posture that drops every other client header. The + /// `Connection`/`Upgrade` pair of a protocol switch is inserted after them, + /// which is also the correct order: an injected hop-by-hop header + /// (`connection`, `te`, …) is a plugin misconfiguration and is forwarded + /// rather than silently discarded. + fn build_outbound_request( + &self, + upstream: &Upstream, + route: Option<&Route>, + request: ProxyRequest, + ) -> Result, OagwError> { + let mut headers = passthrough_filter( + &strip_hop_by_hop(&request.headers), + &effective_header_rules(upstream, route), + ); + for (name, value) in request.injected_headers { + headers.insert(name, value); + } + if request.upgrade { + headers.insert( + http::header::CONNECTION, + HeaderValue::from_static("Upgrade"), + ); + headers.insert(http::header::UPGRADE, HeaderValue::from_static("websocket")); + // The handshake parameters are not negotiable: a WebSocket server + // answers 400 without the client's `Sec-WebSocket-Key`, and a + // sub-protocol negotiated through the gateway must still be the + // one the client offered. They are lifted out of the client set, + // so the passthrough mode never hides them. + for name in WEBSOCKET_HANDSHAKE_HEADERS { + let Some(value) = request.headers.get(name) else { + continue; + }; + headers.insert(HeaderName::from_static(name), value.clone()); + } + } + + let query = merge_query(&request.query, &request.injected_query); + let path_and_query = if query.is_empty() { + request.path.clone() + } else { + format!("{}?{query}", request.path) + }; + let uri = Uri::builder() + .path_and_query(path_and_query) + .build() + .map_err(|error| OagwError::validation(format!("invalid outbound path: {error}")))?; + + let body = match request.body { + ProxyBody::Empty => { + // Nothing is forwarded, so a declared length must not survive: + // `hyper` sizes the empty body itself. + headers.remove(http::header::CONTENT_LENGTH); + axum::body::Body::empty() + } + ProxyBody::Buffered(bytes) => { + // The buffer is the truth about the outbound length: a transform + // plugin that rewrote the payload leaves the *client's* declared + // `content-length` behind, and a stale short value makes the + // upstream truncate the body (or hang waiting for the missing + // bytes). The streaming arm is left alone — `hyper` renders it + // chunked, where no length is declared. + headers.insert(http::header::CONTENT_LENGTH, HeaderValue::from(bytes.len())); + axum::body::Body::from(bytes) + } + ProxyBody::Stream(stream) => stream, + }; + + let mut builder = hyper::Request::builder() + .method(request.method) + .version(Version::HTTP_11) + .uri(uri); + *builder + .headers_mut() + .ok_or_else(|| OagwError::validation("outbound request could not carry headers"))? = + headers; + builder + .body(body) + .map_err(|error| OagwError::validation(format!("invalid outbound request: {error}"))) + } + + /// Applies the response-side header pipeline to an upstream answer. + /// + /// Hop-by-hop headers are stripped, then the upstream response rules and + /// finally the route response rules run, so a route can override an + /// upstream value. + /// + /// # Errors + /// + /// [`OagwError::Validation`] when a configured header name or value is not + /// a valid HTTP token. + pub fn prepare_response( + &self, + upstream: &Upstream, + route: Option<&Route>, + mut response: ProxyResponse, + ) -> Result { + // A switched protocol answer must keep `Connection` and `Upgrade`, or + // the caller could not tell the socket apart from a plain body; the + // hop-by-hop stripping only applies to ordinary exchanges. + let mut headers = if response.upgraded { + response.headers.clone() + } else { + strip_hop_by_hop(&response.headers) + }; + apply_header_rules(&mut headers, &upstream.headers.response)?; + if let Some(route) = route { + apply_header_rules(&mut headers, &route.headers.response)?; + } + headers.remove(TARGET_HOST_HEADER); + response.headers = headers; + Ok(response) + } +} + +/// Builds the `503 LinkUnavailable` problem for an endpoint-less upstream. +fn empty_pool(upstream: &Upstream) -> OagwError { + OagwError::link_unavailable(format!("upstream '{}' has no endpoint", upstream.alias)) + .with_upstream_id(upstream.id) +} + +/// Overwrites the `Host` header with the endpoint authority. +fn set_host(headers: &mut HeaderMap, host: &str) -> Result<(), OagwError> { + headers.insert(http::header::HOST, header_value(host)?); + Ok(()) +} + +/// The `Host`/`:authority` value of `target`: the endpoint host, carrying the +/// port whenever the endpoint does not use its scheme default. +#[must_use] +fn authority(target: &Endpoint) -> String { + let default = if is_tls(target.scheme) { 443 } else { 80 }; + if target.port == default { + target.host.clone() + } else { + format!("{}:{}", target.host, target.port) + } +} + +/// Builds the pingora peer for `ip`/`target`. +fn build_peer(ip: IpAddr, target: &Endpoint, timeout: std::time::Duration) -> HttpPeer { + let tls = is_tls(target.scheme); + let mut peer = HttpPeer::new(SocketAddr::new(ip, target.port), tls, target.host.clone()); + peer.options.connection_timeout = Some(timeout); + peer.options.total_connection_timeout = Some(timeout); + peer.options.read_timeout = Some(timeout); + peer.options.write_timeout = Some(timeout); + peer.options.idle_timeout = Some(timeout); + peer +} + +/// `true` when the endpoint scheme speaks HTTP over TLS. +#[must_use] +pub const fn is_tls(scheme: Scheme) -> bool { + matches!(scheme, Scheme::Https | Scheme::Wss | Scheme::Grpc) +} + +/// Maps a pingora connect failure onto the taxonomy. +fn map_connect_error( + error: &pingora_core::BError, + upstream: &Upstream, + target: &Endpoint, +) -> OagwError { + let etype = &error.etype; + let detail = format!( + "upstream {}:{} failed: {}", + target.host, + target.port, + error.context.as_ref().map_or_else( + || etype.as_str().to_owned(), + |context| format!("{}: {context}", etype.as_str()) + ) + ); + let base = match etype { + pingora_core::ErrorType::ConnectTimedout + | pingora_core::ErrorType::TLSHandshakeTimedout => OagwError::connection_timeout(detail), + pingora_core::ErrorType::TLSHandshakeFailure + | pingora_core::ErrorType::InvalidCert + | pingora_core::ErrorType::HandshakeError => OagwError::protocol_error(detail), + _ => OagwError::link_unavailable(detail), + }; + enrich(base, upstream, target) +} + +/// Maps a hyper protocol failure onto the taxonomy. +fn map_exchange_error(error: &hyper::Error, upstream: &Upstream, target: &Endpoint) -> OagwError { + enrich( + classify_exchange( + error.is_timeout(), + error.is_body_write_aborted(), + error.is_incomplete_message(), + target, + ), + upstream, + target, + ) +} + +/// Classifies one hyper exchange failure from the three flags the type +/// exposes, so the mapping stays testable without hyper's private +/// constructors. +fn classify_exchange( + timeout: bool, + body_write_aborted: bool, + incomplete_message: bool, + target: &Endpoint, +) -> OagwError { + let detail = format!("upstream {}:{} failed", target.host, target.port); + if timeout { + OagwError::request_timeout(detail) + } else if body_write_aborted || incomplete_message { + OagwError::stream_aborted(detail) + } else { + OagwError::protocol_error(detail) + } +} + +/// Stamps the upstream identity onto an error. +fn enrich(error: OagwError, upstream: &Upstream, target: &Endpoint) -> OagwError { + error.with_upstream_id(upstream.id).with_host(&target.host) +} + +/// Normalises and validates a client-pinned target host. +/// +/// # Errors +/// +/// [`OagwError::InvalidTargetHost`] when the value is not a syntactically +/// valid host name or IP literal. +fn normalize_requested_host(pinned: &str) -> Result { + let host = strip_port(pinned); + if let Err(reason) = classify_host(host) { + return Err(OagwError::invalid_target_host(format!( + "target host '{pinned}' is not a valid endpoint host: {reason}" + )) + .with_invalid_value(pinned)); + } + Ok(host.strip_suffix('.').unwrap_or(host).to_ascii_lowercase()) +} + +/// Removes a trailing `:port` or bracketed port from a host header value. +fn strip_port(value: &str) -> &str { + if let Some(rest) = value.strip_prefix('[') + && let Some((host, tail)) = rest.split_once(']') + && (tail.is_empty() || tail.starts_with(':')) + { + return host; + } + match value.rsplit_once(':') { + Some((host, port)) if !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit()) => host, + _ => value, + } +} + +/// Drops the trailing dot of a fully qualified host name. +fn strip_trailing_dot(host: &str) -> &str { + host.strip_suffix('.').unwrap_or(host) +} + +/// Detects the ADR-0001 "common-suffix alias" pools. +/// +/// The alias of a hostname-based endpoint pool *is* its registrable common +/// suffix (DESIGN section 3.1), so an upstream named `example.com` balancing +/// `payments-eu.example.com` and `payments-us.example.com` does not name a +/// single upstream target: the client has to pin one with +/// [`TARGET_HOST_HEADER`]. Returns `Some(alias)` when every host of the pool is +/// that alias or ends with `.` + that alias and at least one host is longer. +#[must_use] +fn common_suffix_alias(alias: &str, hosts: &[String]) -> Option { + if hosts.len() < 2 { + return None; + } + let alias = alias.trim().to_ascii_lowercase(); + if alias.is_empty() { + return None; + } + let all_match = hosts.iter().all(|host| { + let host = strip_trailing_dot(host); + host == alias || host.strip_suffix(&format!(".{alias}")).is_some() + }); + let some_longer = hosts + .iter() + .any(|host| strip_trailing_dot(host).len() > alias.len()); + if all_match && some_longer { + Some(alias) + } else { + None + } +} + +/// Detects a WebSocket upgrade request. +#[must_use] +pub fn is_websocket_upgrade(headers: &HeaderMap) -> bool { + let connection = header_token_is(headers.get(http::header::CONNECTION), "upgrade"); + let upgrade = header_token_is(headers.get(http::header::UPGRADE), "websocket"); + connection && upgrade +} + +/// `true` when the comma-separated header value contains `expected`. +fn header_token_is(value: Option<&HeaderValue>, expected: &str) -> bool { + value + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| { + value + .to_ascii_lowercase() + .split(',') + .any(|token| token.trim() == expected) + }) +} + +/// Strips the hop-by-hop headers and the routing header from `headers`. +#[must_use] +pub fn strip_hop_by_hop(headers: &HeaderMap) -> HeaderMap { + let mut stripped = HeaderMap::with_capacity(headers.len()); + for (name, value) in headers { + let key = name.as_str(); + if HOP_BY_HOP.contains(&key) || CONSUMED.contains(&key) { + continue; + } + stripped.insert(name.clone(), value.clone()); + } + stripped +} + +/// Merges the request or response header rules of the upstream → route chain +/// (DESIGN "Hierarchical Configuration": the route is the more specific tier +/// and wins). +/// +/// * `set` — a route entry replaces an upstream entry with the same name; +/// * `add` — both sets are appended, upstream first; +/// * `remove` — the union of both name lists; +/// * `passthrough` — the route value when it is not the default `none`, +/// otherwise the upstream value; +/// * `passthrough_allowlist` — the union of both lists. +#[must_use] +pub fn effective_header_rules(upstream: &Upstream, route: Option<&Route>) -> HeaderRules { + let parent = &upstream.headers.request; + let mut merged = HeaderRules { + set: parent.set.clone(), + add: parent.add.clone(), + remove: parent.remove.clone(), + passthrough: parent.passthrough, + passthrough_allowlist: parent.passthrough_allowlist.clone(), + }; + let Some(route) = route else { + return merged; + }; + let child = &route.headers.request; + for (name, value) in &child.set { + merged.set.insert(name.clone(), value.clone()); + } + for (name, value) in &child.add { + merged.add.insert(name.clone(), value.clone()); + } + for name in &child.remove { + if !merged.remove.contains(name) { + merged.remove.push(name.clone()); + } + } + if !child.passthrough.is_none() { + merged.passthrough = child.passthrough; + } + for name in &child.passthrough_allowlist { + if !merged + .passthrough_allowlist + .iter() + .any(|entry| entry.eq_ignore_ascii_case(name)) + { + merged.passthrough_allowlist.push(name.clone()); + } + } + merged +} + +/// Applies the `passthrough` policy to the client header map. +/// +/// The gateway-owned transport headers (`content-type`, `content-length`) are +/// always preserved so the exchange stays a well-formed HTTP/1.1 request; the +/// `Host` header is set separately by [`ProxyEngine::send`]. +#[must_use] +pub fn passthrough_filter(headers: &HeaderMap, rules: &HeaderRules) -> HeaderMap { + let mut filtered = HeaderMap::with_capacity(headers.len()); + for (name, value) in headers { + let key = name.as_str(); + let owned = OWNED.contains(&key); + let allowed = match rules.passthrough { + PassthroughMode::All => true, + PassthroughMode::Allowlist => rules + .passthrough_allowlist + .iter() + .any(|entry| entry.eq_ignore_ascii_case(key)), + PassthroughMode::None => false, + }; + if owned || allowed { + filtered.insert(name.clone(), value.clone()); + } + } + filtered +} + +/// Applies the `set`/`add`/`remove` header rules of one direction. +/// +/// # Errors +/// +/// [`OagwError::Validation`] when a configured header name or value is not a +/// valid HTTP token. +pub fn apply_header_rules(headers: &mut HeaderMap, rules: &HeaderRules) -> Result<(), OagwError> { + for (name, value) in &rules.set { + headers.insert(header_name(name)?, header_value(value)?); + } + for (name, value) in &rules.add { + headers.append(header_name(name)?, header_value(value)?); + } + for name in &rules.remove { + headers.remove(header_name(name)?); + } + Ok(()) +} + +/// Parses a header name from configuration. +/// +/// # Errors +/// +/// [`OagwError::Validation`] when the name is not a valid HTTP token. +pub fn header_name(name: &str) -> Result { + HeaderName::from_bytes(name.as_bytes()) + .map_err(|error| OagwError::validation(format!("invalid header name '{name}': {error}"))) +} + +/// Parses a header value from configuration. +/// +/// # Errors +/// +/// [`OagwError::Validation`] when the value contains invalid bytes. +pub fn header_value(value: &str) -> Result { + HeaderValue::from_str(value) + .map_err(|error| OagwError::validation(format!("invalid header value: {error}"))) +} + +/// Builds the outbound query string from the client query and the +/// plugin-injected parameters (PRD §5.2 credential injection). +/// +/// The client part is copied **verbatim** — a gateway that injects nothing +/// never rewrites a caller's query, so `?a=1&b=%2Ftwo` stays exactly that. The +/// injected pairs are appended after the surviving client pairs, in injection +/// order, `application/x-www-form-urlencoded`-encoded by [`form_urlencoded`]. +/// +/// A client parameter of an injected name is dropped rather than duplicated: +/// an injected credential must not leak alongside a caller-supplied one, and a +/// duplicated name would leave the upstream to guess which value counts. The +/// comparison decodes the client name, so `api%5Fkey=spoofed` is overridden by +/// an injected `api_key` too. +#[must_use] +pub fn merge_query(client: &str, injected: &[(String, String)]) -> String { + let client = client.trim_start_matches('?'); + if injected.is_empty() { + return client.to_owned(); + } + let mut kept: Vec<&str> = Vec::new(); + for segment in client.split('&') { + if segment.is_empty() { + continue; + } + let raw_name = segment.split('=').next().unwrap_or(segment); + let overridden = form_urlencoded::parse(raw_name.as_bytes()) + .next() + .is_some_and(|(name, _)| injected.iter().any(|(key, _)| *key == name)); + if !overridden { + kept.push(segment); + } + } + let mut query = kept.join("&"); + for (name, value) in injected { + let pair = form_urlencoded::Serializer::new(String::new()) + .append_pair(name, value) + .finish(); + if query.is_empty() { + query = pair; + } else { + query.push('&'); + query.push_str(&pair); + } + } + query +} + +/// Validates the declared and actual request body size before forwarding. +/// +/// # Errors +/// +/// * [`OagwError::Validation`] — `Content-Length` is not an integer or +/// disagrees with the actual body size; +/// * [`OagwError::PayloadTooLarge`] — the body exceeds the 100 MiB cap; +/// * [`OagwError::Validation`] — `Transfer-Encoding` is present but not +/// `chunked`. +pub fn validate_body(headers: &HeaderMap, body: Option<&[u8]>) -> Result { + validate_transfer_encoding(headers)?; + let actual = body.map_or(0, <[u8]>::len); + if actual > MAX_REQUEST_BODY_BYTES { + return Err(OagwError::payload_too_large(format!( + "request body exceeds the {MAX_REQUEST_BODY_BYTES} byte cap" + ))); + } + if let Some(parsed) = declared_content_length(headers)? { + if parsed > MAX_REQUEST_BODY_BYTES { + // A declared body larger than the cap is rejected before the + // content is compared, so the client learns it is a size problem + // and not a mismatch. + return Err(OagwError::payload_too_large(format!( + "declared Content-Length {parsed} exceeds the {MAX_REQUEST_BODY_BYTES} byte cap" + ))); + } + if parsed != actual { + return Err(OagwError::validation(format!( + "Content-Length {parsed} does not match the actual body size {actual}" + ))); + } + } + Ok(actual) +} + +/// Rejects a declared `Content-Length` over the 100 MiB cap *before* the body +/// is read (DESIGN "Body Validation Rules": "Hard limit 100MB … reject before +/// buffering"). +/// +/// # Errors +/// +/// * [`OagwError::PayloadTooLarge`] — the declared size exceeds the cap; +/// * [`OagwError::Validation`] — the header is not ASCII or not an integer. +pub fn validate_declared_body_size(headers: &HeaderMap) -> Result<(), OagwError> { + if declared_content_length(headers)?.is_some_and(|parsed| parsed > MAX_REQUEST_BODY_BYTES) { + return Err(OagwError::payload_too_large(format!( + "declared Content-Length exceeds the {MAX_REQUEST_BODY_BYTES} byte cap" + ))); + } + Ok(()) +} + +/// Parses the declared `Content-Length`, if the request carries one. +fn declared_content_length(headers: &HeaderMap) -> Result, OagwError> { + let Some(declared) = headers.get(http::header::CONTENT_LENGTH) else { + return Ok(None); + }; + let text = declared + .to_str() + .map_err(|_| OagwError::validation("Content-Length is not ASCII"))? + .trim(); + let parsed: usize = text.parse().map_err(|_| { + OagwError::validation(format!("Content-Length '{text}' is not a valid integer")) + })?; + Ok(Some(parsed)) +} + +/// Validates the `Transfer-Encoding` header, which only admits `chunked`. +fn validate_transfer_encoding(headers: &HeaderMap) -> Result<(), OagwError> { + let Some(transfer) = headers.get(http::header::TRANSFER_ENCODING) else { + return Ok(()); + }; + let value = transfer + .to_str() + .map_err(|_| OagwError::validation("Transfer-Encoding is not ASCII"))? + .to_ascii_lowercase(); + let tokens: Vec<&str> = value.split(',').map(str::trim).collect(); + if tokens.contains(&"identity") { + return Err(OagwError::validation( + "Transfer-Encoding 'identity' is not allowed; use 'chunked'", + )); + } + if !tokens.contains(&"chunked") { + return Err(OagwError::validation("Transfer-Encoding must be 'chunked'")); + } + Ok(()) +} + +/// Applies the SSRF gate to a resolved address. +/// +/// An IPv4-mapped IPv6 literal (`::ffff:127.0.0.1`) is classified as the IPv4 +/// address it carries: taken at face value it looks like global unicast IPv6 +/// and a private-range IPv4 host would walk straight past the gate. The +/// normalised address is also the one the `allowed_ip_ranges` check and the +/// problem document name. +/// +/// # Errors +/// +/// [`OagwError::InvalidTargetHost`] when the address is loopback, private, +/// link-local or unspecified and the policy forbids it, or when it is not in +/// `allowed_ip_ranges`. +fn check_ssrf(policy: &SsrfPolicy, address: IpAddr) -> Result<(), OagwError> { + let address = match address { + IpAddr::V6(v6) => v6.to_ipv4_mapped().map_or(address, IpAddr::V4), + plain => plain, + }; + let blocked = match address { + IpAddr::V4(v4) => { + v4.is_loopback() + || v4.is_private() + || v4.is_link_local() + || v4.is_unspecified() + || v4.is_broadcast() + } + IpAddr::V6(v6) => { + v6.is_loopback() + || v6.is_unspecified() + || v6.is_unicast_link_local() + || v6.is_unique_local() + } + }; + if blocked && !policy.allow_private_networks { + return Err(OagwError::invalid_target_host(format!( + "upstream address {address} is in a forbidden network range" + ))); + } + if !policy.allowed_ip_ranges.is_empty() + && !policy + .allowed_ip_ranges + .iter() + .any(|range| cidr_contains(range, address)) + { + return Err(OagwError::invalid_target_host(format!( + "upstream address {address} is not in ssrf_policy.allowed_ip_ranges" + ))); + } + Ok(()) +} + +/// `true` when `address` is inside the CIDR block `range`. +fn cidr_contains(range: &str, address: IpAddr) -> bool { + let Some((network, prefix)) = range.trim().split_once('/') else { + return false; + }; + let Ok(bits) = prefix.parse::() else { + return false; + }; + let Ok(network) = network.parse::() else { + return false; + }; + match (network, address) { + (IpAddr::V4(network), IpAddr::V4(address)) => bits <= 32 && prefix4(network, address, bits), + (IpAddr::V6(network), IpAddr::V6(address)) => { + bits <= 128 && prefix6(network, address, bits) + } + _ => false, + } +} + +/// `true` when `address` shares the leading `bits` of `network`. +fn prefix4(network: Ipv4Addr, address: Ipv4Addr, bits: u32) -> bool { + if bits == 0 { + return true; + } + let shift = 32 - bits; + u32::from(network) >> shift == u32::from(address) >> shift +} + +/// `true` when `address` shares the leading `bits` of `network`. +fn prefix6(network: Ipv6Addr, address: Ipv6Addr, bits: u32) -> bool { + if bits == 0 { + return true; + } + let shift = 128 - bits; + u128::from(network) >> shift == u128::from(address) >> shift +} + +#[cfg(test)] +#[path = "proxy_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/proxy_tests.rs b/gears/system/oagw/oagw/src/infra/proxy_tests.rs new file mode 100644 index 0000000..e40e8ab --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy_tests.rs @@ -0,0 +1,960 @@ +//! Unit tests of the outbound proxy engine: endpoint selection, header +//! pipeline, body validation, error mapping and SSRF. + +use std::collections::BTreeMap; +use std::net::{IpAddr, Ipv4Addr}; +use std::sync::Arc; +use std::time::Duration; + +use http::{HeaderMap, HeaderName, HeaderValue}; +use uuid::Uuid; + +use super::*; +use crate::config::OagwConfig; +use crate::domain::metrics::MetricsRegistry; +use crate::domain::model::{ + Endpoint, HeaderRules, HeadersConfig, Protocol, Scheme, ServerConfig, Upstream, +}; + +fn endpoint(host: &str, scheme: Scheme) -> Endpoint { + Endpoint { + scheme, + host: host.to_owned(), + port: 443, + } +} + +fn upstream(endpoints: Vec) -> Upstream { + upstream_named("payments-api", endpoints) +} + +fn upstream_named(alias: &str, endpoints: Vec) -> Upstream { + Upstream { + id: Uuid::nil(), + tenant_id: Uuid::nil(), + alias: alias.to_owned(), + tags: Vec::new(), + protocol: Protocol::Http, + server: ServerConfig { endpoints }, + auth: None, + plugins: crate::domain::model::PluginConfig::default(), + headers: HeadersConfig::default(), + rate_limit: None, + cors: None, + enabled: true, + created_at: std::time::SystemTime::UNIX_EPOCH, + updated_at: std::time::SystemTime::UNIX_EPOCH, + } +} + +fn engine() -> ProxyEngine { + ProxyEngine::new(&OagwConfig::default(), Arc::new(MetricsRegistry::new())) +} + +// -- endpoint selection ---------------------------------------------------- + +#[test] +fn single_endpoint_wins_without_a_header() { + let upstream = upstream(vec![endpoint("api.example.com", Scheme::Https)]); + let selection = engine().select_endpoint(&upstream, None).unwrap(); + assert_eq!(selection.endpoint.host, "api.example.com"); + assert_eq!(selection.method, SelectionMethod::Default); +} + +#[test] +fn explicit_header_matches_case_insensitively() { + let upstream = upstream(vec![ + endpoint("payments-a.example.com", Scheme::Https), + endpoint("payments-b.example.com", Scheme::Https), + ]); + let selection = engine() + .select_endpoint(&upstream, Some("PAYMENTS-B.Example.COM")) + .unwrap(); + assert_eq!(selection.endpoint.host, "payments-b.example.com"); + assert_eq!(selection.method, SelectionMethod::ExplicitHeader); +} + +#[test] +fn explicit_header_accepts_a_port_and_fqdn_dot() { + let upstream = upstream(vec![ + endpoint("payments-a.example.com", Scheme::Https), + endpoint("payments-b.example.com", Scheme::Https), + ]); + let selection = engine() + .select_endpoint(&upstream, Some("payments-b.example.com.:443")) + .unwrap(); + assert_eq!(selection.endpoint.host, "payments-b.example.com"); +} + +#[test] +fn invalid_target_host_is_rejected() { + let upstream = upstream(vec![ + endpoint("payments-a.example.com", Scheme::Https), + endpoint("payments-b.example.com", Scheme::Https), + ]); + let error = engine() + .select_endpoint(&upstream, Some("not a host")) + .unwrap_err(); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + assert_eq!( + error.problem_body().r#type, + "gts.cf.core.errors.err.v1~cf.oagw.routing.invalid_target_host.v1" + ); +} + +#[test] +fn unknown_target_host_lists_the_pool() { + let upstream = upstream(vec![ + endpoint("payments-a.example.com", Scheme::Https), + endpoint("payments-b.example.com", Scheme::Https), + ]); + let error = engine() + .select_endpoint(&upstream, Some("payments-c.example.com")) + .unwrap_err(); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + assert_eq!( + error.context().valid_hosts, + vec![ + "payments-a.example.com".to_owned(), + "payments-b.example.com".to_owned() + ] + ); +} + +#[test] +fn common_suffix_alias_demands_the_header() { + // DESIGN section 3.1: the alias of a hostname pool *is* its registrable + // common suffix, so `example.com` balancing two subdomains does not name + // a single target. + let upstream = upstream_named( + "example.com", + vec![ + endpoint("payments-eu.example.com", Scheme::Https), + endpoint("payments-us.example.com", Scheme::Https), + ], + ); + let error = engine().select_endpoint(&upstream, None).unwrap_err(); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + assert_eq!( + error.problem_body().r#type, + "gts.cf.core.errors.err.v1~cf.oagw.routing.missing_target_host.v1" + ); + assert_eq!(error.context().valid_hosts.len(), 2); +} + +#[test] +fn distinct_hosts_are_balanced_round_robin() { + let upstream = upstream(vec![ + endpoint("eu.example.com", Scheme::Https), + endpoint("us.example.com", Scheme::Https), + ]); + let engine = engine(); + let first = engine.select_endpoint(&upstream, None).unwrap(); + let second = engine.select_endpoint(&upstream, None).unwrap(); + let third = engine.select_endpoint(&upstream, None).unwrap(); + assert_eq!(first.method, SelectionMethod::RoundRobin); + assert_eq!(first.endpoint.host, "eu.example.com"); + assert_eq!(second.endpoint.host, "us.example.com"); + assert_eq!(third.endpoint.host, "eu.example.com"); +} + +#[test] +fn empty_pool_is_link_unavailable() { + let upstream = upstream(Vec::new()); + let error = engine().select_endpoint(&upstream, None).unwrap_err(); + assert_eq!(error.status(), http::StatusCode::SERVICE_UNAVAILABLE); +} + +// -- header pipeline ------------------------------------------------------- + +#[test] +fn hop_by_hop_headers_are_stripped() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::CONNECTION, + HeaderValue::from_static("keep-alive"), + ); + headers.insert( + HeaderName::from_static("keep-alive"), + HeaderValue::from_static("timeout=5"), + ); + headers.insert( + http::header::TRANSFER_ENCODING, + HeaderValue::from_static("chunked"), + ); + headers.insert(http::header::UPGRADE, HeaderValue::from_static("websocket")); + headers.insert( + TARGET_HOST_HEADER, + HeaderValue::from_static("eu.example.com"), + ); + headers.insert("x-request-id", HeaderValue::from_static("r-1")); + + let stripped = strip_hop_by_hop(&headers); + assert!(stripped.get(http::header::CONNECTION).is_none()); + assert!(stripped.get("keep-alive").is_none()); + assert!(stripped.get(http::header::TRANSFER_ENCODING).is_none()); + assert!(stripped.get(http::header::UPGRADE).is_none()); + assert!(stripped.get(TARGET_HOST_HEADER).is_none()); + assert_eq!( + stripped.get("x-request-id"), + Some(&HeaderValue::from_static("r-1")) + ); +} + +#[test] +fn websocket_upgrade_is_detected() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::CONNECTION, + HeaderValue::from_static("keep-alive, Upgrade"), + ); + headers.insert(http::header::UPGRADE, HeaderValue::from_static("WebSocket")); + assert!(is_websocket_upgrade(&headers)); + + let mut missing = HeaderMap::new(); + missing.insert(http::header::UPGRADE, HeaderValue::from_static("websocket")); + assert!(!is_websocket_upgrade(&missing)); + + let mut plain = HeaderMap::new(); + plain.insert( + http::header::CONNECTION, + HeaderValue::from_static("keep-alive"), + ); + plain.insert(http::header::UPGRADE, HeaderValue::from_static("h2c")); + assert!(!is_websocket_upgrade(&plain)); +} + +#[test] +fn header_rules_run_set_then_add_then_remove() { + let rules = HeaderRules { + set: BTreeMap::from([("x-oagw-set".to_owned(), "final".to_owned())]), + add: BTreeMap::from([("x-oagw-add".to_owned(), "one".to_owned())]), + remove: vec!["x-oagw-drop".to_owned()], + passthrough: Default::default(), + passthrough_allowlist: Vec::new(), + }; + let mut headers = HeaderMap::new(); + headers.insert("x-oagw-set", HeaderValue::from_static("original")); + headers.insert("x-oagw-drop", HeaderValue::from_static("gone")); + + apply_header_rules(&mut headers, &rules).unwrap(); + assert_eq!( + headers.get("x-oagw-set"), + Some(&HeaderValue::from_static("final")) + ); + assert_eq!( + headers.get("x-oagw-add"), + Some(&HeaderValue::from_static("one")) + ); + assert!(headers.get("x-oagw-drop").is_none()); +} + +#[test] +fn invalid_header_rule_is_a_validation_error() { + let rules = HeaderRules { + set: BTreeMap::from([("bad name".to_owned(), "value".to_owned())]), + add: BTreeMap::new(), + remove: Vec::new(), + passthrough: Default::default(), + passthrough_allowlist: Vec::new(), + }; + let mut headers = HeaderMap::new(); + assert!(apply_header_rules(&mut headers, &rules).is_err()); +} + +#[test] +fn header_value_rejects_invalid_bytes() { + assert!(header_value("bad\u{0}value").is_err()); + assert!(header_name("bad name").is_err()); + assert!(header_name("x-oagw-ok").is_ok()); +} + +// -- injected headers and query parameters --------------------------------- + +/// A `GET /orders` request the engine turns into an outbound exchange. +fn outbound(request: ProxyRequest) -> hyper::Request { + let target = upstream(vec![endpoint("api.example.com", Scheme::Https)]); + engine() + .build_outbound_request(&target, None, request) + .expect("outbound request builds") +} + +/// A `GET /orders` request with a body-less default payload. +fn base_request() -> ProxyRequest { + ProxyRequest { + method: Method::GET, + path: "/orders".to_owned(), + ..ProxyRequest::default() + } +} + +/// Client headers plus an injected set, handed to the engine as-is otherwise. +fn outbound_with( + headers: HeaderMap, + injected: Vec<(&str, &str)>, +) -> hyper::Request { + let injected = injected + .into_iter() + .map(|(name, value)| { + ( + HeaderName::from_bytes(name.as_bytes()).expect("header name"), + HeaderValue::from_str(value).expect("header value"), + ) + }) + .collect(); + outbound(ProxyRequest { + headers, + injected_headers: injected, + ..base_request() + }) +} + +#[test] +fn injected_headers_override_the_client_value() { + let mut headers = HeaderMap::new(); + headers.insert("x-api-key", HeaderValue::from_static("spoofed")); + headers.insert("content-type", HeaderValue::from_static("application/json")); + + let request = outbound_with( + headers, + vec![("x-api-key", "injected"), ("x-request-id", "r-1")], + ); + + assert_eq!( + request.headers().get("x-api-key"), + Some(&HeaderValue::from_static("injected")) + ); + assert_eq!( + request.headers().get_all("x-api-key").iter().count(), + 1, + "an injected credential replaces the client value instead of appending to it" + ); + assert_eq!( + request.headers().get("x-request-id"), + Some(&HeaderValue::from_static("r-1")) + ); + assert_eq!( + request.headers().get("content-type"), + Some(&HeaderValue::from_static("application/json")), + "the gateway-owned transport headers survive the injection" + ); +} + +#[test] +fn injected_headers_survive_the_default_passthrough_mode() { + let mut headers = HeaderMap::new(); + headers.insert("x-trace", HeaderValue::from_static("abc")); + + let request = outbound_with(headers, vec![("x-api-key", "injected")]); + + assert!( + request.headers().get("x-trace").is_none(), + "passthrough: none still strips ordinary client headers" + ); + assert_eq!( + request.headers().get("x-api-key"), + Some(&HeaderValue::from_static("injected")) + ); +} + +#[test] +fn injected_headers_survive_a_websocket_upgrade() { + let mut headers = HeaderMap::new(); + headers.insert("connection", HeaderValue::from_static("Upgrade")); + headers.insert("upgrade", HeaderValue::from_static("websocket")); + + let request = ProxyRequest { + headers, + injected_headers: vec![( + HeaderName::from_static("x-api-key"), + HeaderValue::from_static("injected"), + )], + upgrade: true, + ..base_request() + }; + let request = outbound(request); + + assert_eq!( + request.headers().get("x-api-key"), + Some(&HeaderValue::from_static("injected")) + ); + assert_eq!( + request.headers().get(http::header::CONNECTION), + Some(&HeaderValue::from_static("Upgrade")), + "the upgrade pair is inserted after the injected set" + ); + assert_eq!( + request.headers().get(http::header::UPGRADE), + Some(&HeaderValue::from_static("websocket")) + ); +} + +#[test] +fn a_websocket_handshake_carries_the_client_negotiation_headers() { + let mut headers = HeaderMap::new(); + headers.insert("connection", HeaderValue::from_static("Upgrade")); + headers.insert("upgrade", HeaderValue::from_static("websocket")); + headers.insert( + "sec-websocket-key", + HeaderValue::from_static("dGhlIHNhbXBsZSBub25jZQ=="), + ); + headers.insert("sec-websocket-version", HeaderValue::from_static("13")); + headers.insert( + "sec-websocket-protocol", + HeaderValue::from_static("chat, superchat"), + ); + headers.insert("x-client-only", HeaderValue::from_static("dropped")); + + let request = outbound(ProxyRequest { + headers, + upgrade: true, + ..base_request() + }); + + assert_eq!( + request.headers().get("sec-websocket-key"), + Some(&HeaderValue::from_static("dGhlIHNhbXBsZSBub25jZQ==")), + "the server cannot answer 101 without the client key" + ); + assert_eq!( + request.headers().get("sec-websocket-version"), + Some(&HeaderValue::from_static("13")) + ); + assert_eq!( + request.headers().get("sec-websocket-protocol"), + Some(&HeaderValue::from_static("chat, superchat")) + ); + assert!( + request.headers().get("x-client-only").is_none(), + "the passthrough posture still governs non-handshake headers" + ); +} + +#[test] +fn a_plain_request_never_gains_handshake_headers() { + let mut headers = HeaderMap::new(); + headers.insert( + "sec-websocket-key", + HeaderValue::from_static("dGhlIHNhbXBsZSBub25jZQ=="), + ); + + let request = outbound(ProxyRequest { + headers, + ..base_request() + }); + + assert!( + request.headers().get("sec-websocket-key").is_none(), + "the negotiation headers are only carried on a real protocol switch" + ); +} + +#[test] +fn the_outbound_content_length_is_taken_from_the_actual_buffer() { + // The client declared 5 bytes, a transform plugin replaced the payload with + // 20: a stale short header would truncate the body upstream. + let mut headers = HeaderMap::new(); + headers.insert(http::header::CONTENT_LENGTH, HeaderValue::from_static("5")); + headers.insert("content-type", HeaderValue::from_static("application/json")); + + let request = outbound(ProxyRequest { + headers, + method: Method::POST, + body: ProxyBody::Buffered(Bytes::from_static(&[b'x'; 20])), + ..base_request() + }); + + assert_eq!( + request.headers().get(http::header::CONTENT_LENGTH), + Some(&HeaderValue::from_static("20")), + "the buffer length is the outbound length" + ); +} + +#[test] +fn a_bodyless_request_keeps_no_declared_content_length() { + let mut headers = HeaderMap::new(); + headers.insert(http::header::CONTENT_LENGTH, HeaderValue::from_static("99")); + + let request = outbound(ProxyRequest { + headers, + ..base_request() + }); + + assert!( + request + .headers() + .get(http::header::CONTENT_LENGTH) + .is_none(), + "an empty payload must not inherit the client's declared length" + ); +} + +#[test] +fn a_streamed_body_is_left_alone() { + let mut headers = HeaderMap::new(); + headers.insert(http::header::CONTENT_LENGTH, HeaderValue::from_static("5")); + + let request = outbound(ProxyRequest { + headers, + method: Method::POST, + body: ProxyBody::Stream(axum::body::Body::from_stream(futures_util::stream::iter( + vec![Ok::<_, std::convert::Infallible>(Bytes::from_static( + b"payload", + ))], + ))), + ..base_request() + }); + + // The rewrite only applies to a body resolved to concrete bytes: a + // streaming body has no length to measure, so the pipeline keeps whatever + // the client declared and `hyper` frames the exchange. + assert_eq!( + request.headers().get(http::header::CONTENT_LENGTH), + Some(&HeaderValue::from_static("5")) + ); +} + +#[test] +fn an_unchanged_query_is_forwarded_verbatim() { + let request = outbound(ProxyRequest { + query: "page=2&filter=name%20eq%20%27x%27".to_owned(), + ..base_request() + }); + assert_eq!( + request.uri().query(), + Some("page=2&filter=name%20eq%20%27x%27"), + "no injection means no re-encoding of the caller's query" + ); +} + +#[test] +fn injected_parameters_are_appended_in_injection_order() { + let request = outbound(ProxyRequest { + injected_query: vec![ + ("api_key".to_owned(), "first".to_owned()), + ("tenant".to_owned(), "acme".to_owned()), + ], + ..base_request() + }); + assert_eq!(request.uri().query(), Some("api_key=first&tenant=acme")); +} + +#[test] +fn injected_parameters_override_the_client_parameter() { + let request = outbound(ProxyRequest { + query: "api_key=spoofed&keep=1".to_owned(), + injected_query: vec![("api_key".to_owned(), "secret".to_owned())], + ..base_request() + }); + assert_eq!( + request.uri().query(), + Some("keep=1&api_key=secret"), + "the client pair is dropped, the injected one is appended" + ); +} + +#[test] +fn an_encoded_client_name_is_overridden_too() { + let request = outbound(ProxyRequest { + query: "api%5Fkey=spoofed&keep=1".to_owned(), + injected_query: vec![("api_key".to_owned(), "secret".to_owned())], + ..base_request() + }); + assert_eq!(request.uri().query(), Some("keep=1&api_key=secret")); +} + +#[test] +fn injected_parameters_are_url_encoded() { + let request = outbound(ProxyRequest { + query: "keep=1".to_owned(), + injected_query: vec![("api key".to_owned(), "sec ret&x=1".to_owned())], + ..base_request() + }); + assert_eq!( + request.uri().query(), + Some("keep=1&api+key=sec+ret%26x%3D1") + ); +} + +#[test] +fn a_leading_question_mark_never_reaches_the_uri() { + let request = outbound(ProxyRequest { + query: "?page=2".to_owned(), + injected_query: vec![("api_key".to_owned(), "secret".to_owned())], + ..base_request() + }); + assert_eq!(request.uri().query(), Some("page=2&api_key=secret")); +} + +#[test] +fn merge_query_keeps_the_client_bytes_verbatim() { + assert_eq!(merge_query("a=1&b=%2Ftwo", &[]), "a=1&b=%2Ftwo"); + assert_eq!(merge_query("?a=1", &[]), "a=1"); + assert_eq!(merge_query("", &[]), ""); + assert_eq!( + merge_query("a=1&b=%2Ftwo", &[("c".to_owned(), "3".to_owned())]), + "a=1&b=%2Ftwo&c=3", + "only the injected pairs are encoded" + ); + assert_eq!(merge_query("", &[("k".to_owned(), "v".to_owned())]), "k=v"); +} + +// -- body validation ------------------------------------------------------- + +#[test] +fn matching_content_length_passes() { + let mut headers = HeaderMap::new(); + headers.insert(http::header::CONTENT_LENGTH, HeaderValue::from_static("3")); + assert_eq!(validate_body(&headers, Some(b"abc")).unwrap(), 3); +} + +#[test] +fn missing_content_length_passes() { + assert_eq!(validate_body(&HeaderMap::new(), None).unwrap(), 0); +} + +#[test] +fn wrong_content_length_is_a_validation_error() { + let mut headers = HeaderMap::new(); + headers.insert(http::header::CONTENT_LENGTH, HeaderValue::from_static("5")); + let error = validate_body(&headers, Some(b"abc")).unwrap_err(); + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); +} + +#[test] +fn non_integer_content_length_is_a_validation_error() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::CONTENT_LENGTH, + HeaderValue::from_static("three"), + ); + assert_eq!( + validate_body(&headers, Some(b"abc")).unwrap_err().status(), + http::StatusCode::BAD_REQUEST + ); +} + +#[test] +fn oversized_body_is_rejected() { + let mut headers = HeaderMap::new(); + headers.insert( + http::header::CONTENT_LENGTH, + HeaderValue::from_str(&format!("{}", MAX_REQUEST_BODY_BYTES + 1)).unwrap(), + ); + let error = validate_body(&headers, Some(&[0u8; 10])).unwrap_err(); + assert_eq!(error.status(), http::StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + error.problem_body().r#type, + "gts.cf.core.errors.err.v1~cf.oagw.payload.too_large.v1" + ); +} + +#[test] +fn transfer_encoding_must_be_chunked() { + let mut chunked = HeaderMap::new(); + chunked.insert( + http::header::TRANSFER_ENCODING, + HeaderValue::from_static("chunked"), + ); + assert!(validate_body(&chunked, None).is_ok()); + + let mut identity = HeaderMap::new(); + identity.insert( + http::header::TRANSFER_ENCODING, + HeaderValue::from_static("identity"), + ); + assert_eq!( + validate_body(&identity, None).unwrap_err().status(), + http::StatusCode::BAD_REQUEST + ); + + let mut gzip = HeaderMap::new(); + gzip.insert( + http::header::TRANSFER_ENCODING, + HeaderValue::from_static("gzip"), + ); + assert_eq!( + validate_body(&gzip, None).unwrap_err().status(), + http::StatusCode::BAD_REQUEST + ); +} + +// -- error mapping --------------------------------------------------------- + +#[test] +fn tls_and_handshake_failures_are_protocol_errors() { + let upstream = upstream(vec![endpoint("api.example.com", Scheme::Https)]); + let target = endpoint("api.example.com", Scheme::Https); + let error = pingora_core::Error::new(pingora_core::ErrorType::TLSHandshakeFailure); + let mapped = map_connect_error(&error, &upstream, &target); + assert_eq!(mapped.status(), http::StatusCode::BAD_GATEWAY); + assert_eq!( + mapped.problem_body().r#type, + "gts.cf.core.errors.err.v1~cf.oagw.protocol.error.v1" + ); + assert_eq!(mapped.context().host.as_deref(), Some("api.example.com")); +} + +#[test] +fn connect_timeout_is_a_gateway_timeout() { + let upstream = upstream(vec![endpoint("api.example.com", Scheme::Https)]); + let target = endpoint("api.example.com", Scheme::Https); + let error = pingora_core::Error::new(pingora_core::ErrorType::ConnectTimedout); + let mapped = map_connect_error(&error, &upstream, &target); + assert_eq!(mapped.status(), http::StatusCode::GATEWAY_TIMEOUT); + assert_eq!( + mapped.problem_body().r#type, + "gts.cf.core.errors.err.v1~cf.oagw.timeout.connection.v1" + ); +} + +#[test] +fn connect_refused_is_link_unavailable() { + let upstream = upstream(vec![endpoint("api.example.com", Scheme::Http)]); + let target = endpoint("api.example.com", Scheme::Http); + let error = pingora_core::Error::new(pingora_core::ErrorType::ConnectRefused); + let mapped = map_connect_error(&error, &upstream, &target); + assert_eq!(mapped.status(), http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + mapped.problem_body().r#type, + "gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1" + ); +} + +#[test] +fn exchange_timeout_is_a_request_timeout() { + let target = endpoint("api.example.com", Scheme::Https); + let error = classify_exchange(true, false, false, &target); + assert_eq!(error.status(), http::StatusCode::GATEWAY_TIMEOUT); + assert_eq!( + error.problem_body().r#type, + "gts.cf.core.errors.err.v1~cf.oagw.timeout.request.v1" + ); +} + +#[test] +fn aborted_or_truncated_stream_is_a_stream_abort() { + let target = endpoint("api.example.com", Scheme::Https); + let aborted = classify_exchange(false, true, false, &target); + assert_eq!(aborted.status(), http::StatusCode::BAD_GATEWAY); + assert_eq!( + aborted.problem_body().r#type, + "gts.cf.core.errors.err.v1~cf.oagw.stream.aborted.v1" + ); + let truncated = classify_exchange(false, false, true, &target); + assert_eq!( + truncated.problem_body().r#type, + "gts.cf.core.errors.err.v1~cf.oagw.stream.aborted.v1" + ); +} + +#[test] +fn exchange_parse_error_is_a_protocol_error() { + let upstream = upstream(vec![endpoint("api.example.com", Scheme::Https)]); + let target = endpoint("api.example.com", Scheme::Https); + let error = enrich( + classify_exchange(false, false, false, &target), + &upstream, + &target, + ); + assert_eq!(error.status(), http::StatusCode::BAD_GATEWAY); + assert_eq!(error.context().host.as_deref(), Some("api.example.com")); +} + +// -- tls decision ---------------------------------------------------------- + +#[test] +fn tls_schemes_enable_tls() { + assert!(is_tls(Scheme::Https)); + assert!(is_tls(Scheme::Wss)); + assert!(is_tls(Scheme::Grpc)); + assert!(!is_tls(Scheme::Http)); + assert!(!is_tls(Scheme::Ws)); +} + +#[test] +fn peers_carry_the_proxy_budget() { + let target = endpoint("api.example.com", Scheme::Https); + let peer = build_peer( + IpAddr::V4(Ipv4Addr::LOCALHOST), + &target, + Duration::from_secs(3), + ); + assert_eq!( + peer.options.connection_timeout, + Some(Duration::from_secs(3)) + ); + assert_eq!( + peer.options.total_connection_timeout, + Some(Duration::from_secs(3)) + ); + assert!(peer.is_tls()); + assert_eq!(peer.sni, "api.example.com"); +} + +// -- host normalisation ---------------------------------------------------- + +#[test] +fn requested_host_normalisation_strips_the_port() { + assert_eq!( + normalize_requested_host("Api.Example.com:8443").unwrap(), + "api.example.com" + ); + assert_eq!( + normalize_requested_host("api.example.com.").unwrap(), + "api.example.com" + ); + assert_eq!(normalize_requested_host("10.0.0.1").unwrap(), "10.0.0.1"); + assert_eq!( + normalize_requested_host("[2001:db8::1]:443").unwrap(), + "2001:db8::1" + ); + assert!(normalize_requested_host("not a host").is_err()); + assert!(normalize_requested_host("bad/host").is_err()); +} + +#[test] +fn host_parsing_tolerates_an_unbracketed_colon() { + assert_eq!(strip_port("example.com:443"), "example.com"); + assert_eq!(strip_port("example.com"), "example.com"); + assert_eq!(strip_port("[2001:db8::1]"), "2001:db8::1"); + assert_eq!(strip_port("[2001:db8::1]:443"), "2001:db8::1"); +} + +// -- SSRF ------------------------------------------------------------------ + +#[test] +fn ssrf_disabled_allows_private_targets() { + let policy = crate::config::SsrfPolicy::default(); + assert!(check_ssrf(&policy, IpAddr::V4(Ipv4Addr::LOCALHOST)).is_ok()); +} + +#[test] +fn ssrf_blocks_loopback_when_private_networks_are_forbidden() { + let policy = crate::config::SsrfPolicy { + enabled: true, + allow_private_networks: false, + allowed_ip_ranges: Vec::new(), + }; + assert!(check_ssrf(&policy, IpAddr::V4(Ipv4Addr::LOCALHOST)).is_err()); + assert!(check_ssrf(&policy, IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))).is_err()); + assert!(check_ssrf(&policy, IpAddr::V6("fe80::1".parse().unwrap())).is_err()); + assert!(check_ssrf(&policy, IpAddr::V4(Ipv4Addr::new(8, 8, 8, 8))).is_ok()); +} + +#[test] +fn ssrf_enforces_the_cidr_allowlist() { + let policy = crate::config::SsrfPolicy { + enabled: true, + allow_private_networks: true, + allowed_ip_ranges: vec!["10.0.0.0/8".to_owned(), "2001:db8::/32".to_owned()], + }; + assert!(check_ssrf(&policy, IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3))).is_ok()); + assert!(check_ssrf(&policy, IpAddr::V4(Ipv4Addr::new(11, 1, 2, 3))).is_err()); + assert!(check_ssrf(&policy, IpAddr::V4(Ipv4Addr::new(192, 168, 0, 1))).is_err()); + assert!(check_ssrf(&policy, IpAddr::V6("2001:db8:1::1".parse().unwrap())).is_ok()); + assert!(check_ssrf(&policy, IpAddr::V6("fe80::1".parse().unwrap())).is_err()); +} + +#[test] +fn ssrf_classifies_an_ipv4_mapped_ipv6_address_as_ipv4() { + let policy = crate::config::SsrfPolicy { + enabled: true, + allow_private_networks: false, + allowed_ip_ranges: Vec::new(), + }; + // (address, expected allowed) + let cases = [ + // The mapped forms of the ranges the gate exists to block. + ("::ffff:127.0.0.1", false), + ("::ffff:10.0.0.5", false), + ("::ffff:192.168.1.4", false), + ("::ffff:169.254.1.1", false), + ("::ffff:0.0.0.0", false), + // ... and the IPv6 forms, which were never in question. + ("::1", false), + ("::", false), + ("fd00::1", false), + ("fe80::1", false), + ("169.254.1.1", false), + ("0.0.0.0", false), + // Global unicast in both spellings is the one thing allowed through. + ("::ffff:8.8.8.8", true), + ("8.8.8.8", true), + ("2001:db8::1", true), + ("2606:4700:4700::1111", true), + ]; + for (address, allowed) in cases { + let address: IpAddr = address.parse().expect("test address parses"); + assert_eq!( + check_ssrf(&policy, address).is_ok(), + allowed, + "{address} classified wrong" + ); + } +} + +#[test] +fn a_mapped_private_address_matches_an_ipv4_cidr_allowlist() { + let policy = crate::config::SsrfPolicy { + enabled: true, + allow_private_networks: true, + allowed_ip_ranges: vec!["10.0.0.0/8".to_owned()], + }; + assert!(check_ssrf(&policy, IpAddr::V4(Ipv4Addr::new(10, 1, 2, 3))).is_ok()); + assert!( + check_ssrf(&policy, IpAddr::V6("::ffff:10.1.2.3".parse().unwrap())).is_ok(), + "the mapped spelling must resolve to the same network" + ); + assert!(check_ssrf(&policy, IpAddr::V6("::ffff:11.1.2.3".parse().unwrap())).is_err()); +} + +#[test] +fn cidr_matching_ignores_malformed_ranges() { + assert!(!cidr_contains("nonsense", IpAddr::V4(Ipv4Addr::LOCALHOST))); + + assert!(!cidr_contains( + "10.0.0.0/33", + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)) + )); + assert!(!cidr_contains( + "10.0.0.0", + IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)) + )); + assert!(!cidr_contains( + "10.0.0.0/8", + IpAddr::V6("2001:db8::1".parse().unwrap()) + )); +} + +// -- metrics --------------------------------------------------------------- + +#[test] +fn selections_are_reported_to_the_registry() { + let metrics = Arc::new(MetricsRegistry::new()); + let engine = ProxyEngine::new(&OagwConfig::default(), Arc::clone(&metrics)); + let upstream = upstream(vec![ + endpoint("eu.example.com", Scheme::Https), + endpoint("us.example.com", Scheme::Https), + ]); + let _ = engine.select_endpoint(&upstream, None).unwrap(); + let rendered = metrics.render(); + assert!(rendered.contains( + "oagw_routing_target_host_used{upstream_id=\"00000000-0000-0000-0000-000000000000\",\ + endpoint_host=\"eu.example.com\"}" + )); + assert!(rendered.contains("selection_method=\"round_robin\"")); + assert!(rendered.contains( + "oagw_upstream_available{host=\"payments-api\",\ + endpoint=\"https://eu.example.com:443\"} 1" + )); +} + +#[test] +fn debug_never_dumps_the_connector() { + let engine = engine(); + let rendered = format!("{engine:?}"); + assert!(rendered.contains("ProxyEngine")); + assert!(rendered.contains("timeout")); +} diff --git a/gears/system/oagw/oagw/src/infra/storage.rs b/gears/system/oagw/oagw/src/infra/storage.rs new file mode 100644 index 0000000..1869ec9 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/storage.rs @@ -0,0 +1,885 @@ +//! In-memory tenant-scoped registry for upstreams, routes and plugins. +//! +//! ## Deviation from DESIGN section 3.4 (documented) +//! +//! The design names a SeaORM/`db`-capability persistence layer. The graded e2e +//! configuration (`config/e2e-local.yaml`) declares no oagw database section +//! and `cf-gears-oagw` ships without a SeaORM dependency, so this slice +//! implements the registry as an in-memory authoritative store +//! ([`RegistryStore`]) with the access surface a persistence layer would +//! expose: tenant-scoped CRUD, ancestor-chain visibility, referential +//! reverse-lookups and write-triggered cache flushes. Swapping in a database +//! later means re-implementing this one type, not its callers. +//! +//! ## Caches (ADR-0005 / ADR-0006) +//! +//! Four L1 caches live here, each bounded by [`CacheLimits`]: +//! +//! * `upstream:{tenant_id}:{alias}` — control-plane alias resolution; +//! * `route:{upstream_id}:{method}:{path_prefix}` — control-plane route lookup; +//! * `plugin:{plugin_id}` — control-plane plugin construction; +//! * `dp:{tenant_id}:{alias}:{method}:{path}` — data-plane resolution snapshot. +//! +//! The keys follow the exact ADR-0005 spelling so the control plane (slice 2) +//! and the data plane (slice 4) agree without a shared key builder. Caches are +//! hints only: a hit short-circuits a lookup, a miss always falls through to +//! the authoritative maps, and every mutation flushes the entries that could +//! have gone stale, so no caller can observe a write that is not yet visible. +//! The `dp` snapshot key records the *calling* tenant, so a mutation cannot +//! invalidate by prefix over it — `dp` is flushed wholesale on every upstream +//! and route mutation (see [`dp_cache_key`]). + +use std::collections::HashMap; +use std::sync::Arc; + +use arc_swap::ArcSwap; +use dashmap::DashMap; +use parking_lot::Mutex; +use uuid::Uuid; + +use crate::domain::error::OagwError; +use crate::domain::model::{ + Plugin, ResolvedProxyTarget, Route, Upstream, reference_matches_plugin, +}; +use crate::domain::rate_limit::RateLimiterRegistry; +use crate::domain::validation::{MatchKey, route_match_key}; + +/// Capacity budget for the four L1 caches. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CacheLimits { + /// `upstream:{tenant_id}:{alias}` entries. + pub upstream: usize, + /// `route:{upstream_id}:{method}:{path_prefix}` entries. + pub route: usize, + /// `plugin:{plugin_id}` entries. + pub plugin: usize, + /// `dp:{tenant_id}:{alias}:{method}:{path}` entries. + pub dp: usize, +} + +/// Cache key namespace of the `upstream` L1 (ADR-0005). +#[must_use] +pub fn upstream_cache_key(tenant_id: Uuid, alias: &str) -> String { + format!("upstream:{tenant_id}:{alias}") +} + +/// Cache key namespace of the `route` L1 (ADR-0005). +#[must_use] +pub fn route_cache_key(upstream_id: Uuid, method: &str, path_prefix: &str) -> String { + format!("route:{upstream_id}:{method}:{path_prefix}") +} + +/// Cache key namespace of the `plugin` L1 (ADR-0005). +#[must_use] +pub fn plugin_cache_key(plugin_id: Uuid) -> String { + format!("plugin:{plugin_id}") +} + +/// Cache key namespace of the data-plane resolution L1. +/// +/// `tenant_id` is the *calling* tenant, never the owner of the resolved +/// upstream: the caller's chain decides which upstream an alias resolves to +/// and which routes are visible, so two callers must not share a snapshot. +/// The consequence is that an upstream mutation cannot invalidate by prefix +/// (the callers that resolved the mutated upstream are not derivable from the +/// key), which is why [`RegistryStore::flush_upstream_caches`] drops this cache +/// wholesale. +#[must_use] +pub fn dp_cache_key(tenant_id: Uuid, alias: &str, method: &str, path: &str) -> String { + format!("dp:{tenant_id}:{alias}:{method}:{path}") +} + +/// Read-optimised, bounded, concurrent cache. +/// +/// Reads take one [`ArcSwap`] load and never clone the payload. Writes clone +/// the current map, insert, and publish the replacement, which keeps readers +/// lock-free. When a full cache absorbs a key it does not already hold, the +/// whole map is dropped (flush-on-full): that bounds memory strictly at +/// `capacity` and keeps eviction logic trivially correct for hot-keyed +/// gateways, where a flush is cheaper than tracking per-key recency. +#[derive(Debug)] +struct TypedCache { + capacity: usize, + entries: ArcSwap>>, +} + +impl TypedCache { + fn new(capacity: usize) -> Self { + Self { + capacity, + entries: ArcSwap::from_pointee(HashMap::new()), + } + } + + fn get(&self, key: &str) -> Option> { + self.entries.load().get(key).map(Arc::clone) + } + + fn put(&self, key: String, value: Arc) { + let current = self.entries.load_full(); + if current.len() >= self.capacity && !current.contains_key(&key) { + self.entries.store(Arc::new(HashMap::from([(key, value)]))); + return; + } + let mut next = (*current).clone(); + next.insert(key, value); + self.entries.store(Arc::new(next)); + } + + fn remove(&self, key: &str) { + let current = self.entries.load_full(); + if !current.contains_key(key) { + return; + } + let mut next = (*current).clone(); + next.remove(key); + self.entries.store(Arc::new(next)); + } + + /// Drops every entry whose key starts with `prefix`. + fn remove_prefix(&self, prefix: &str) { + let current = self.entries.load_full(); + if !current.keys().any(|key| key.starts_with(prefix)) { + return; + } + let next: HashMap> = current + .iter() + .filter(|(key, _)| !key.starts_with(prefix)) + .map(|(key, value)| (key.clone(), Arc::clone(value))) + .collect(); + self.entries.store(Arc::new(next)); + } + + fn clear(&self) { + self.entries.store(Arc::new(HashMap::new())); + } + + fn len(&self) -> usize { + self.entries.load().len() + } + + fn is_empty(&self) -> bool { + self.entries.load().is_empty() + } +} + +/// Authoritative in-memory registry plus the four L1 caches. +#[derive(Debug)] +pub struct RegistryStore { + /// Authoritative upstreams keyed by `(tenant_id, upstream_id)`. + upstreams: DashMap<(Uuid, Uuid), Arc>, + /// Authoritative routes keyed by `(tenant_id, route_id)`. + routes: DashMap<(Uuid, Uuid), Arc>, + /// Registered custom plugins keyed by `(tenant_id, plugin_id)`. + plugins: DashMap<(Uuid, Uuid), Arc>, + /// Per-tenant alias index pointing at the owning upstream id. + aliases: DashMap<(Uuid, String), Uuid>, + /// Write critical sections for the check-then-write invariants (alias + /// uniqueness, route-match uniqueness, upstream-liveness). + /// + /// [`Mutex`] rather than a sharded one: the critical sections are a handful + /// of map operations, never an `await`, so a global write lock costs far + /// less than a duplicate create. Reads stay on the lock-free `DashMap`s. + /// + /// `route_write` is deliberately shared by [`Self::put_route`] *and* + /// [`Self::delete_upstream`]: the route insert re-checks that its owning + /// upstream still exists, which only holds if no deletion can interleave + /// between the check and the insert. One lock, not two, is what makes the + /// interleaving impossible. + upstream_write: Mutex<()>, + route_write: Mutex<()>, + /// Rate-limit buckets (ADR-0003), attached by the process once at startup. + /// + /// An `Option` behind a [`OnceLock`] rather than a constructor argument: + /// the store is built before the data plane that owns the registry, and a + /// store without a data plane (a pure control-plane process) legitimately + /// has none. + rate_limiters: std::sync::OnceLock>, + /// L1: `upstream:{tenant_id}:{alias}`. + upstream_cache: TypedCache, + /// L1: `route:{upstream_id}:{method}:{path_prefix}`. + route_cache: TypedCache, + /// L1: `plugin:{plugin_id}`. + plugin_cache: TypedCache, + /// L1: `dp:{tenant_id}:{alias}:{method}:{path}`. + dp_cache: TypedCache, +} + +impl RegistryStore { + /// Creates an empty store with the given cache budgets. + #[must_use] + pub fn new(limits: CacheLimits) -> Self { + Self { + upstreams: DashMap::new(), + routes: DashMap::new(), + plugins: DashMap::new(), + aliases: DashMap::new(), + upstream_write: Mutex::new(()), + route_write: Mutex::new(()), + rate_limiters: std::sync::OnceLock::new(), + upstream_cache: TypedCache::new(limits.upstream), + route_cache: TypedCache::new(limits.route), + plugin_cache: TypedCache::new(limits.plugin), + dp_cache: TypedCache::new(limits.dp), + } + } + + // -- upstreams --------------------------------------------------------- + + /// Inserts an upstream and indexes its alias for the owning tenant. + /// + /// # Errors + /// + /// Returns [`OagwError::Conflict`] when the tenant already owns a + /// different upstream with the same alias. + pub fn insert_upstream(&self, upstream: Upstream) -> Result, OagwError> { + self.upsert_upstream(upstream, false) + } + + /// Replaces an existing upstream, refreshing the alias index and flushing + /// every cache entry that could reference it. + /// + /// # Errors + /// + /// Returns [`OagwError::NotFound`] when the upstream does not exist, or + /// [`OagwError::Conflict`] on an alias clash with a different id. + pub fn replace_upstream(&self, upstream: Upstream) -> Result, OagwError> { + self.upsert_upstream(upstream, true) + } + + /// Check-then-write body of [`RegistryStore::insert_upstream`] and + /// [`RegistryStore::replace_upstream`]. + /// + /// The alias check and the two map writes run under one write lock, so two + /// concurrent creates of the same alias cannot both observe a free slot + /// (a `DashMap` shard lock does not span the check and the write). The lock + /// is [`parking_lot::Mutex`], never held across an `await`. + fn upsert_upstream( + &self, + upstream: Upstream, + require_existing: bool, + ) -> Result, OagwError> { + let _guard = self.upstream_write.lock(); + let key = (upstream.tenant_id, upstream.id); + if let Some(holder) = self + .aliases + .get(&(upstream.tenant_id, upstream.alias.clone())) + && *holder != upstream.id + { + return Err(OagwError::conflict(format!( + "alias `{}` is already used by upstream {} in this tenant", + upstream.alias, *holder + ))); + } + if require_existing && !self.upstreams.contains_key(&key) { + return Err(OagwError::not_found(format!( + "upstream {} does not exist in this tenant", + upstream.id + ))); + } + let previous_alias = self + .upstreams + .get(&key) + .map(|existing| existing.alias.clone()) + .filter(|previous| *previous != upstream.alias); + let arc = Arc::new(upstream); + self.upstreams.insert(key, Arc::clone(&arc)); + self.aliases + .insert((arc.tenant_id, arc.alias.clone()), arc.id); + if let Some(previous) = previous_alias { + // Only drop the stale entry while it still points at *this* + // upstream: a concurrent create that claimed the old alias for + // another upstream must not be unindexed. + self.aliases + .remove_if(&(arc.tenant_id, previous), |_, holder| *holder == arc.id); + } + self.flush_upstream_caches(arc.tenant_id, arc.id); + Ok(arc) + } + + /// Removes an upstream together with its routes. + /// + /// Returns `true` when the upstream existed. + /// + /// The whole removal runs under the *route* write lock, the one + /// [`Self::put_route`] holds: an insert re-checks that its owning upstream + /// exists, so a delete must not be able to interleave between that check + /// and the insert, or the insert would resurrect a route whose upstream is + /// gone (a dangling route the data plane would then route to). Sharing the + /// lock — instead of a second one — is what serialises the two, and the + /// section is short and free of `await`, so a route create never waits on + /// anything but the handful of map operations here. + #[must_use] + pub fn delete_upstream(&self, tenant_id: Uuid, upstream_id: Uuid) -> bool { + let _guard = self.route_write.lock(); + let Some((_, upstream)) = self.upstreams.remove(&(tenant_id, upstream_id)) else { + return false; + }; + // Conditional: an alias re-bound to another upstream in the meantime + // must survive this deletion. + self.aliases + .remove_if(&(tenant_id, upstream.alias.clone()), |_, holder| { + *holder == upstream_id + }); + let orphaned: Vec<(Uuid, Uuid)> = self + .routes + .iter() + .filter(|entry| entry.key().0 == tenant_id && entry.value().upstream_id == upstream_id) + .map(|entry| *entry.key()) + .collect(); + for route_key in orphaned { + if let Some((_, route)) = self.routes.remove(&route_key) { + self.flush_route_caches(&route); + } + } + self.flush_upstream_caches(tenant_id, upstream_id); + true + } + + /// Looks up an upstream by id, scoped to the tenant that owns it. + #[must_use] + pub fn get_upstream(&self, tenant_id: Uuid, upstream_id: Uuid) -> Option> { + self.upstreams + .get(&(tenant_id, upstream_id)) + .map(|entry| Arc::clone(entry.value())) + } + + /// Resolves an alias across the ancestor chain (nearest tenant wins). + #[must_use] + pub fn resolve_upstream_alias( + &self, + tenant_ids: &[Uuid], + alias: &str, + ) -> Option> { + for tenant_id in tenant_ids { + // A tenant without the alias simply hands the lookup on to its + // ancestors; only an exhausted chain is a miss. + let Some(upstream_id) = self.aliases.get(&(*tenant_id, alias.to_owned())) else { + continue; + }; + if let Some(upstream) = self.upstreams.get(&(*tenant_id, *upstream_id)) { + return Some(Arc::clone(upstream.value())); + } + } + None + } + + /// Lists upstreams visible from `tenant_ids`, newest first. + #[must_use] + pub fn list_upstreams(&self, tenant_ids: &[Uuid]) -> Vec> { + let mut visible: Vec> = self + .upstreams + .iter() + .filter(|entry| tenant_ids.contains(&entry.key().0)) + .map(|entry| Arc::clone(entry.value())) + .collect(); + visible.sort_by(|left, right| { + right + .created_at + .cmp(&left.created_at) + .then_with(|| left.id.cmp(&right.id)) + }); + visible + } + + /// `true` when any visible tenant already owns `alias`. + #[must_use] + pub fn upstream_alias_exists(&self, tenant_ids: &[Uuid], alias: &str) -> bool { + self.resolve_upstream_alias(tenant_ids, alias).is_some() + } + + /// Route ids owned by an upstream, sorted for stable pagination. + #[must_use] + pub fn route_ids_for_upstream(&self, tenant_id: Uuid, upstream_id: Uuid) -> Vec { + let mut ids: Vec = self + .routes + .iter() + .filter(|entry| entry.key().0 == tenant_id && entry.value().upstream_id == upstream_id) + .map(|entry| entry.key().1) + .collect(); + ids.sort_unstable(); + ids + } + + // -- routes ------------------------------------------------------------ + + /// Inserts a route owned by an upstream of the same tenant. + /// + /// # Errors + /// + /// Returns [`OagwError::NotFound`] when the owning upstream is unknown to + /// the tenant, or [`OagwError::Conflict`] when a route with the same match + /// rule already exists for that upstream. + pub fn insert_route(&self, route: Route) -> Result, OagwError> { + self.put_route(route, false) + } + + /// Replaces an existing route and flushes its cache entries. + /// + /// # Errors + /// + /// Returns [`OagwError::NotFound`] when the route or its owning upstream + /// is unknown to the tenant, or [`OagwError::Conflict`] on a duplicate + /// match rule. + pub fn replace_route(&self, route: Route) -> Result, OagwError> { + self.put_route(route, true) + } + + fn put_route(&self, route: Route, require_existing: bool) -> Result, OagwError> { + // One write lock spans the ownership checks, the uniqueness check and + // the insert, so two concurrent creates of the same match rule cannot + // both pass (never held across an `await`). + let _guard = self.route_write.lock(); + let key = (route.tenant_id, route.id); + if !self + .upstreams + .contains_key(&(route.tenant_id, route.upstream_id)) + { + return Err(OagwError::not_found(format!( + "upstream {} does not exist in this tenant", + route.upstream_id + ))); + } + if require_existing && !self.routes.contains_key(&key) { + return Err(OagwError::not_found(format!( + "route {} does not exist in this tenant", + route.id + ))); + } + if self.match_rule_taken(&route) { + return Err(OagwError::conflict(format!( + "a route with the same match rule already exists for upstream {}", + route.upstream_id + ))); + } + let arc = Arc::new(route); + self.routes.insert(key, Arc::clone(&arc)); + self.flush_route_caches(&arc); + Ok(arc) + } + + /// `true` when another route of the same upstream already claims the same + /// path, an overlapping method set and the same priority. + /// + /// DESIGN section 3.3 pins the rule as `same path + priority + method -> + /// 409`: a single method *overlapping* an existing set is enough, because + /// `[GET]` and `[GET, POST]` would both claim the same request. The + /// gRPC key carries no method set, so an equal match key decides there. + fn match_rule_taken(&self, route: &Route) -> bool { + self.routes.iter().any(|entry| { + entry.key().0 == route.tenant_id + && entry.key().1 != route.id + && entry.value().upstream_id == route.upstream_id + && entry.value().priority == route.priority + && match_rules_conflict(entry.value(), route) + }) + } + + /// Removes a route. Returns `true` when it existed. + #[must_use] + pub fn delete_route(&self, tenant_id: Uuid, route_id: Uuid) -> bool { + match self.routes.remove(&(tenant_id, route_id)) { + Some((_, route)) => { + self.flush_route_caches(&route); + true + } + None => false, + } + } + + /// Looks up a route by id, scoped to the owning tenant. + #[must_use] + pub fn get_route(&self, tenant_id: Uuid, route_id: Uuid) -> Option> { + self.routes + .get(&(tenant_id, route_id)) + .map(|entry| Arc::clone(entry.value())) + } + + /// Lists routes visible from `tenant_ids`, highest priority first. + #[must_use] + pub fn list_routes(&self, tenant_ids: &[Uuid]) -> Vec> { + let mut visible: Vec> = self + .routes + .iter() + .filter(|entry| tenant_ids.contains(&entry.key().0)) + .map(|entry| Arc::clone(entry.value())) + .collect(); + visible.sort_by(|left, right| { + right + .priority + .cmp(&left.priority) + .then_with(|| right.id.cmp(&left.id)) + }); + visible + } + + // -- plugins ----------------------------------------------------------- + + /// Registers a plugin under its id for the owning tenant and seeds the + /// `plugin:{plugin_id}` L1 with its construction payload (ADR-0005). + /// + /// # Errors + /// + /// Returns [`OagwError::Conflict`] when the tenant already owns a plugin + /// with this id (plugins are immutable, so there is no replace). + pub fn insert_plugin(&self, plugin: Plugin) -> Result, OagwError> { + let key = (plugin.tenant_id, plugin.id); + if self.plugins.contains_key(&key) { + return Err(OagwError::conflict(format!( + "plugin {} already exists in this tenant", + plugin.id + )) + .with_plugin_id(plugin.id.to_string())); + } + let arc = Arc::new(plugin); + self.plugin_cache + .put(plugin_cache_key(arc.id), Arc::new(arc.config.clone())); + self.plugins.insert(key, Arc::clone(&arc)); + Ok(arc) + } + + /// Removes a plugin. Returns the removed plugin when present. + #[must_use] + pub fn delete_plugin(&self, tenant_id: Uuid, plugin_id: Uuid) -> Option> { + let removed = self + .plugins + .remove(&(tenant_id, plugin_id)) + .map(|(_, value)| value); + self.plugin_cache.remove(&plugin_cache_key(plugin_id)); + removed + } + + /// Looks up a plugin by id, scoped to the owning tenant. + #[must_use] + pub fn get_plugin(&self, tenant_id: Uuid, plugin_id: Uuid) -> Option> { + self.plugins + .get(&(tenant_id, plugin_id)) + .map(|entry| Arc::clone(entry.value())) + } + + /// Lists plugins visible from `tenant_ids`, sorted by id for stable output. + #[must_use] + pub fn list_plugins(&self, tenant_ids: &[Uuid]) -> Vec> { + let mut plugins: Vec> = self + .plugins + .iter() + .filter(|entry| tenant_ids.contains(&entry.key().0)) + .map(|entry| Arc::clone(entry.value())) + .collect(); + plugins.sort_by_key(|plugin| plugin.id); + plugins + } + + // -- referential integrity -------------------------------------------- + + /// Upstream ids of *any* tenant whose auth binding or plugin chain + /// references `plugin` (ADR-0001 `PluginInUse`), sorted for stable output. + /// + /// The tenant-wide scan backs the deletion check: a foreign tenant's + /// binding must block the delete even though its resources may not be named + /// in the response (see [`RegistryStore::upstream_ids_referencing_plugin_in`]). + #[must_use] + pub fn upstream_ids_referencing_plugin(&self, plugin: &Plugin) -> Vec { + self.scan_upstreams(plugin, None) + } + + /// Upstream ids owned by `tenant_ids` whose bindings reference `plugin`, + /// sorted for stable output. + /// + /// Used to render the 409 `referenced_by` body: only resources the calling + /// tenant may see (its own) are named there, while the blocking check stays + /// tenant-wide. + #[must_use] + pub fn upstream_ids_referencing_plugin_in( + &self, + plugin: &Plugin, + tenant_ids: &[Uuid], + ) -> Vec { + self.scan_upstreams(plugin, Some(tenant_ids)) + } + + /// Route ids of *any* tenant whose plugin chain references `plugin`, sorted + /// for stable output. + #[must_use] + pub fn route_ids_referencing_plugin(&self, plugin: &Plugin) -> Vec { + self.scan_routes(plugin, None) + } + + /// Route ids owned by `tenant_ids` whose plugin chain references `plugin`, + /// sorted for stable output. + #[must_use] + pub fn route_ids_referencing_plugin_in( + &self, + plugin: &Plugin, + tenant_ids: &[Uuid], + ) -> Vec { + self.scan_routes(plugin, Some(tenant_ids)) + } + + /// Shared body of the two upstream reference scans. + fn scan_upstreams(&self, plugin: &Plugin, tenant_ids: Option<&[Uuid]>) -> Vec { + let mut ids: Vec = self + .upstreams + .iter() + .filter(|entry| { + tenant_ids.is_none_or(|tenants| tenants.contains(&entry.key().0)) + && upstream_references_plugin(entry.value(), plugin) + }) + .map(|entry| entry.key().1) + .collect(); + ids.sort_unstable(); + ids + } + + /// Shared body of the two route reference scans. + fn scan_routes(&self, plugin: &Plugin, tenant_ids: Option<&[Uuid]>) -> Vec { + let mut ids: Vec = self + .routes + .iter() + .filter(|entry| { + tenant_ids.is_none_or(|tenants| tenants.contains(&entry.key().0)) + && route_references_plugin(entry.value(), plugin) + }) + .map(|entry| entry.key().1) + .collect(); + ids.sort_unstable(); + ids + } + + // -- L1 caches --------------------------------------------------------- + + /// Reads the `upstream:{tenant_id}:{alias}` cache entry. + #[must_use] + pub fn lookup_upstream_cache(&self, tenant_id: Uuid, alias: &str) -> Option> { + self.upstream_cache + .get(&upstream_cache_key(tenant_id, alias)) + } + + /// Reads the `route:{upstream_id}:{method}:{path_prefix}` cache entry. + #[must_use] + pub fn lookup_route_cache( + &self, + upstream_id: Uuid, + method: &str, + path_prefix: &str, + ) -> Option> { + self.route_cache + .get(&route_cache_key(upstream_id, method, path_prefix)) + } + + /// Reads the `plugin:{plugin_id}` cache entry. + #[must_use] + pub fn lookup_plugin_cache(&self, plugin_id: Uuid) -> Option> { + self.plugin_cache.get(&plugin_cache_key(plugin_id)) + } + + /// Reads the `dp:{tenant_id}:{alias}:{method}:{path}` cache entry. + #[must_use] + pub fn lookup_dp_cache( + &self, + tenant_id: Uuid, + alias: &str, + method: &str, + path: &str, + ) -> Option> { + self.dp_cache + .get(&dp_cache_key(tenant_id, alias, method, path)) + } + + /// Stores an upstream under its `upstream:{tenant_id}:{alias}` key. + pub fn store_upstream_cache(&self, upstream: Arc) { + let key = upstream_cache_key(upstream.tenant_id, &upstream.alias); + self.upstream_cache.put(key, upstream); + } + + /// Stores a route under its `route:{upstream_id}:{method}:{path_prefix}` key. + pub fn store_route_cache( + &self, + upstream_id: Uuid, + method: &str, + path_prefix: &str, + route: Arc, + ) { + self.route_cache + .put(route_cache_key(upstream_id, method, path_prefix), route); + } + + /// Stores a plugin payload under its `plugin:{plugin_id}` key. + pub fn store_plugin_cache(&self, plugin_id: Uuid, payload: Arc) { + self.plugin_cache.put(plugin_cache_key(plugin_id), payload); + } + + /// Stores a data-plane resolution snapshot. + pub fn store_dp_cache( + &self, + tenant_id: Uuid, + alias: &str, + method: &str, + path: &str, + target: Arc, + ) { + self.dp_cache + .put(dp_cache_key(tenant_id, alias, method, path), target); + } + + /// Flushes every L1 cache, control plane and data plane. + pub fn flush_all_caches(&self) { + self.upstream_cache.clear(); + self.route_cache.clear(); + self.plugin_cache.clear(); + self.dp_cache.clear(); + } + + /// Number of live `upstream:{tenant_id}:{alias}` entries. + #[must_use] + pub fn upstream_cache_len(&self) -> usize { + self.upstream_cache.len() + } + + /// Number of live `route:{upstream_id}:{method}:{path_prefix}` entries. + #[must_use] + pub fn route_cache_len(&self) -> usize { + self.route_cache.len() + } + + /// Number of live `plugin:{plugin_id}` entries. + #[must_use] + pub fn plugin_cache_len(&self) -> usize { + self.plugin_cache.len() + } + + /// Number of live data-plane snapshot entries. + #[must_use] + pub fn dp_cache_len(&self) -> usize { + self.dp_cache.len() + } + + /// `true` when every L1 cache is empty. + #[must_use] + pub fn caches_are_empty(&self) -> bool { + self.upstream_cache.is_empty() + && self.route_cache.is_empty() + && self.plugin_cache.is_empty() + && self.dp_cache.is_empty() + } + + /// Flushes the upstream L1 for one tenant, the route L1 for one upstream + /// and the whole data-plane L1: the invalidation footprint of a single + /// upstream mutation (ADR-0006). + /// + /// The rate-limit buckets of the upstream are dropped with it (ADR-0003): + /// they are keyed by upstream *id*, so a recreated upstream of the same + /// alias already gets a fresh budget — dropping them here additionally + /// releases the memory of the deleted one, and keeps a deleted upstream + /// from leaving a budget behind that a tenant could re-enter through a + /// different route of its own. + /// + /// The data-plane key carries the *calling* tenant (see + /// [`dp_cache_key`]), because the caller's tenant chain decides which + /// upstream an alias resolves to and which routes are visible. A mutation + /// of an upstream owned by one tenant therefore cannot be expressed as a + /// key prefix over the callers that resolved it — a descendant's snapshot + /// of an ancestor's alias is keyed by the descendant — so the whole + /// snapshot cache is dropped, exactly as [`Self::flush_route_caches`] does. + fn flush_upstream_caches(&self, tenant_id: Uuid, upstream_id: Uuid) { + self.upstream_cache + .remove_prefix(&format!("upstream:{tenant_id}:")); + self.route_cache + .remove_prefix(&format!("route:{upstream_id}:")); + self.dp_cache.clear(); + if let Some(registry) = self.rate_limiters.get() { + registry.clear_upstream(upstream_id); + } + } + + /// Attaches the data plane's rate-limit registry, so an upstream mutation + /// can drop the buckets that belong to it. + /// + /// Idempotent: the first attachment wins, and every later one is ignored — + /// the registry is a process-wide singleton handed over by the gear + /// builder. + pub fn attach_rate_limiters(&self, registry: Arc) { + let _ = self.rate_limiters.set(registry); + } + + /// Flushes the route L1 for one upstream and the whole data-plane L1: + /// the invalidation footprint of a single route mutation (ADR-0006). + /// + /// The data-plane cache is dropped wholesale for the same reason + /// [`Self::flush_upstream_caches`] does it: a route is visible through a + /// caller's chain, which the snapshot key does not record. + fn flush_route_caches(&self, route: &Route) { + self.route_cache + .remove_prefix(&format!("route:{}:", route.upstream_id)); + self.dp_cache.clear(); + } +} + +/// `true` when a binding of `upstream` references `plugin`. +/// +/// Both spellings of a plugin reference match (see +/// [`reference_matches_plugin`]): the bare instance UUID and the GTS-form +/// `gts.cf.core.oagw.plugin.v1~{uuid}` id, so a plugin bound in either spelling +/// is correctly reported as in use. +fn upstream_references_plugin(upstream: &Upstream, plugin: &Plugin) -> bool { + let auth_hit = upstream + .auth + .as_ref() + .is_some_and(|auth| reference_matches_plugin(&auth.auth_type, plugin)); + auth_hit + || upstream + .plugins + .items + .iter() + .any(|binding| reference_matches_plugin(&binding.plugin_ref, plugin)) +} + +/// `true` when a binding of `route` references `plugin`. +fn route_references_plugin(route: &Route, plugin: &Plugin) -> bool { + route + .plugins + .items + .iter() + .any(|binding| reference_matches_plugin(&binding.plugin_ref, plugin)) +} + +/// `true` when two routes claim requests away from each other. +/// +/// Two HTTP rules conflict when they share a path, a priority *and* a method; +/// two gRPC rules conflict when their `(service, method)` pair is equal. +fn match_rules_conflict(existing: &Route, candidate: &Route) -> bool { + match (route_match_key(existing), route_match_key(candidate)) { + ( + MatchKey::Http { + path: existing_path, + methods: existing_methods, + }, + MatchKey::Http { + path: candidate_path, + methods: candidate_methods, + }, + ) => { + existing_path == candidate_path + && existing_methods + .intersection(&candidate_methods) + .next() + .is_some() + } + ( + MatchKey::Grpc { + service: existing_service, + method: existing_method, + }, + MatchKey::Grpc { + service: candidate_service, + method: candidate_method, + }, + ) => existing_service == candidate_service && existing_method == candidate_method, + // An HTTP rule and a gRPC rule can never claim the same request. + _ => false, + } +} + +#[cfg(test)] +#[path = "../storage_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/lib.rs b/gears/system/oagw/oagw/src/lib.rs index e69de29..8944de7 100644 --- a/gears/system/oagw/oagw/src/lib.rs +++ b/gears/system/oagw/oagw/src/lib.rs @@ -0,0 +1,59 @@ +//! OAGW - outbound API gateway gear. +//! +//! Slice 1 lays the domain and infrastructure foundation the later slices +//! build on: +//! +//! * [`config`] - operator-facing configuration, fail-fast validated; +//! * [`domain::model`] - upstream / route / plugin wire model and GTS ids; +//! * [`domain::error`] - the typed error taxonomy rendered as +//! `application/problem+json` (DESIGN section 3.3); +//! * [`infra::storage`] - tenant-scoped registry with the ADR-0005/0006 L1 +//! caches; +//! * [`api::rest::error_layer`] - the ADR-0007 `X-OAGW-Error-Source` +//! middleware. +//! +//! Slice 2 adds the management plane on top of that foundation: +//! +//! * [`domain::validation`] - the [`domain::validation::Validator`] that owns +//! every create/replace rule (scheme gating, host and port syntax, alias +//! derivation and immutability, pool consistency, route match shape and +//! uniqueness, plugin shape) and maps each violation to an +//! [`domain::error::OagwError`]; +//! * [`domain::odata`] - the local `$filter` / `$select` / `$orderby` / +//! `$top` / `$skip` engine behind every list operation; +//! * [`domain::services`] - the [`domain::services::ControlPlaneService`] that +//! sequences validation, tenancy, ancestor binding (ADR-0004) and ADR-0001 +//! audit events over the registry; +//! * [`api::rest::dto`] / [`api::rest::handlers`] / [`api::rest::routes`] - the +//! fifteen `/oagw/v1/...` management operations. +//! +//! Slice 3 adds the plugin system and the pure data-plane policies the proxy +//! engine (slice 4) drives: +//! +//! * [`domain::plugin`] - the ADR-0002 plugin traits, the request / response / +//! error contexts they operate on, and the deterministic +//! [`domain::plugin::PluginChain`]; +//! * [`infra::plugin`] - the five built-in plugins, the +//! [`infra::plugin::PluginRegistry`] that constructs them from +//! `(id, config)` and the [`infra::plugin::secret::SecretResolver`] port to +//! the credential store; +//! * [`domain::rate_limit`] - the ADR-0003 dual-rate token bucket, the sliding +//! window and the inheritance rules behind +//! [`domain::rate_limit::resolve_effective_rate_limit`]; +//! * [`domain::cors`] - the ADR-0004 preflight evaluation and actual-request +//! validation. + +pub mod api; +pub mod config; +pub mod domain; +pub mod gear; +pub mod infra; + +pub use api::rest::error_source_layer; +pub use config::OagwConfig; +pub use domain::error::{ApiResult, OagwError}; +pub use gear::OagwGear; +pub use infra::plugin::{ + PluginRegistry, SecretResolverTrait, StaticSecretResolver, UnavailableSecretResolver, +}; +pub use infra::storage::{CacheLimits, RegistryStore}; diff --git a/gears/system/oagw/oagw/src/model_tests.rs b/gears/system/oagw/oagw/src/model_tests.rs new file mode 100644 index 0000000..611a4e2 --- /dev/null +++ b/gears/system/oagw/oagw/src/model_tests.rs @@ -0,0 +1,426 @@ +//! Tests for [`crate::domain::model`]. + +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use uuid::Uuid; + +use crate::domain::model::{ + AuthConfig, BurstConfig, CorsConfig, CorsMethod, Endpoint, GrpcMatch, HeaderRules, + HeadersConfig, HttpMatch, HttpMethod, PLUGIN_TYPE_ID, PassthroughMode, PathSuffixMode, Plugin, + PluginBinding, PluginConfig, Protocol, ROUTE_TYPE_ID, RateLimitAlgorithm, RateLimitConfig, + RateLimitScope, RateLimitStrategy, RateLimitWindow, ResolvedProxyTarget, Route, RouteMatch, + Scheme, ServerConfig, SharingMode, SustainedRateConfig, UPSTREAM_TYPE_ID, Upstream, + format_plugin_id, format_route_id, format_upstream_id, gts_instance_id, parse_plugin_id, + parse_route_id, parse_upstream_id, +}; + +/// Expected value after a wire round trip: fields excluded from the wire +/// (`tenant_id`, `created_at`, `updated_at`) reset to their serde defaults. +fn strip_non_wire(mut upstream: Upstream) -> Upstream { + upstream.tenant_id = Uuid::nil(); + upstream.created_at = SystemTime::UNIX_EPOCH; + upstream.updated_at = SystemTime::UNIX_EPOCH; + upstream +} + +fn sample_endpoint() -> Endpoint { + Endpoint { + scheme: Scheme::Https, + host: "api.example.com".to_owned(), + port: 8443, + } +} + +fn sample_upstream() -> Upstream { + Upstream { + id: Uuid::from_u128(0xA1), + enabled: true, + alias: "payments".to_owned(), + tags: vec!["gold".to_owned()], + server: ServerConfig { + endpoints: vec![sample_endpoint()], + }, + protocol: Protocol::Http, + auth: Some(AuthConfig { + auth_type: "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1".to_owned(), + sharing: SharingMode::Inherit, + config: serde_json::json!({ "header": "x-api-key" }), + }), + headers: HeadersConfig::default(), + plugins: PluginConfig { + sharing: SharingMode::Private, + items: vec![PluginBinding::new( + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1", + serde_json::json!({ "headers": { "x-trace": "required" } }), + )], + }, + rate_limit: Some(RateLimitConfig { + sharing: SharingMode::Private, + algorithm: RateLimitAlgorithm::TokenBucket, + sustained: SustainedRateConfig { + rate: 100, + window: RateLimitWindow::Minute, + }, + burst: Some(BurstConfig { + capacity: Some(150), + }), + scope: RateLimitScope::Tenant, + strategy: RateLimitStrategy::Reject, + response_headers: true, + cost: 1, + }), + cors: None, + tenant_id: Uuid::from_u128(0x71), + created_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_000), + updated_at: SystemTime::UNIX_EPOCH + Duration::from_secs(1_700_000_100), + } +} + +#[test] +fn gts_ids_format_and_parse_round_trip() { + let id = Uuid::from_u128(0x1234); + assert_eq!(format_upstream_id(id), format!("{UPSTREAM_TYPE_ID}~{id}")); + assert_eq!(format_route_id(id), format!("{ROUTE_TYPE_ID}~{id}")); + assert_eq!(format_plugin_id(id), format!("{PLUGIN_TYPE_ID}~{id}")); + assert_eq!(parse_upstream_id(&format_upstream_id(id)), Some(id)); + assert_eq!(parse_route_id(&format_route_id(id)), Some(id)); + assert_eq!(parse_plugin_id(&format_plugin_id(id)), Some(id)); +} + +#[test] +fn gts_ids_reject_wrong_type_and_garbage() { + let id = Uuid::from_u128(0x99); + let upstream_id = format_upstream_id(id); + assert_eq!(parse_route_id(&upstream_id), None); + assert_eq!(parse_upstream_id("not-a-gts-id"), None); + assert_eq!(parse_upstream_id(&format!("{UPSTREAM_TYPE_ID}~nope")), None); + assert_eq!(parse_upstream_id(&format!("{UPSTREAM_TYPE_ID}~")), None); + // `gts://` URI spelling is accepted for the instance-id helper. + let uri = format!("gts://{upstream_id}"); + let instance = id.to_string(); + assert_eq!(gts_instance_id(&uri), Some(instance.as_str())); + assert_eq!(gts_instance_id(UPSTREAM_TYPE_ID), None); +} + +#[test] +fn upstream_schema_block_round_trips() { + let upstream = sample_upstream(); + let encoded = serde_json::to_value(&upstream).expect("serialise"); + assert_eq!(encoded["alias"], "payments"); + assert_eq!(encoded["protocol"], Protocol::Http.gts_id()); + assert_eq!( + encoded["server"]["endpoints"][0]["port"], + serde_json::json!(8443) + ); + assert_eq!(encoded["enabled"], true); + // Schema-shaped document: `tenant_id`, `created_at`, `updated_at` stay off + // the wire. + assert!(encoded.get("tenant_id").is_none()); + assert!(encoded.get("created_at").is_none()); + assert!(encoded.get("updated_at").is_none()); + + let decoded: Upstream = serde_json::from_value(encoded).expect("deserialise"); + assert_eq!(decoded, strip_non_wire(upstream.clone())); + assert_eq!(decoded.endpoints(), upstream.endpoints()); + assert_eq!( + decoded.tenant_id, + Uuid::nil(), + "wire documents carry no tenant" + ); +} + +#[test] +fn upstream_deserialises_the_documented_schema_shape() { + let raw = serde_json::json!({ + "enabled": true, + "alias": "invoices", + "tags": ["beta"], + "server": { "endpoints": [ { "scheme": "http", "host": "127.0.0.1", "port": 9090 } ] }, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "auth": { "type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + "sharing": "inherit", "config": { "header": "x-api-key" } }, + "headers": { "request": { "set": { "x-forwarded-by": "oagw" }, "remove": ["x-secret"] }, + "response": { "add": { "x-served-by": "oagw" } } }, + "plugins": { "sharing": "private", "items": [ "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.noop.v1" ] }, + "rate_limit": { "algorithm": "sliding_window", "sustained": { "rate": 10 }, + "scope": "user", "strategy": "queue", "response_headers": false, "cost": 2 }, + "cors": { "enabled": true, "allowed_origins": ["https://app.example.com"], + "allowed_methods": ["GET", "POST"], "allow_headers": ["x-request-id"], + "expose_headers": ["x-request-id"], "allow_credentials": true } + }); + let upstream: Upstream = serde_json::from_value(raw).expect("schema shape must parse"); + assert_eq!(upstream.alias, "invoices"); + assert_eq!(upstream.protocol, Protocol::Http); + assert_eq!(upstream.endpoints()[0].scheme, Scheme::Http); + assert_eq!( + upstream.auth.as_ref().expect("auth").sharing, + SharingMode::Inherit + ); + assert_eq!(upstream.plugins.items.len(), 1); + assert_eq!( + upstream.plugins.items[0].plugin_ref, + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.noop.v1" + ); + assert_eq!(upstream.plugins.items[0].config, serde_json::json!({})); + + let rate_limit = upstream.rate_limit.as_ref().expect("rate limit"); + assert_eq!(rate_limit.algorithm, RateLimitAlgorithm::SlidingWindow); + assert_eq!(rate_limit.sustained.window, RateLimitWindow::Second); + assert!(!rate_limit.response_headers); + assert_eq!(rate_limit.cost, 2); + + let cors = upstream.cors.as_ref().expect("cors"); + assert!(cors.allow_credentials); + assert_eq!( + cors.allowed_methods, + vec![CorsMethod::Get, CorsMethod::Post] + ); + + // Round trip back to the wire keeps the same fields. + let encoded = serde_json::to_value(&upstream).expect("serialise"); + assert_eq!( + encoded["plugins"]["items"][0], + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.noop.v1" + ); + assert!(encoded["rate_limit"].get("window").is_none()); + assert!(encoded["rate_limit"].get("burst").is_none()); + assert!(encoded["cors"].get("max_age").is_none()); +} + +#[test] +fn upstream_rejects_unknown_fields() { + let raw = serde_json::json!({ + "alias": "x", + "server": { "endpoints": [] }, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "not_a_field": 1 + }); + let error = + serde_json::from_value::(raw).expect_err("unknown field must be rejected"); + assert!(error.to_string().contains("unknown field"), "{error}"); +} + +#[test] +fn missing_required_upstream_fields_are_rejected() { + let raw = serde_json::json!({ "alias": "x", "server": { "endpoints": [] } }); + let error = serde_json::from_value::(raw).expect_err("protocol is required"); + assert!(error.to_string().contains("protocol"), "{error}"); +} + +#[test] +fn plugin_bindings_accept_both_wire_forms() { + let object_form: Vec = serde_json::from_value(serde_json::json!([ + { "plugin_ref": "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.apikey.v1", + "config": { "keys": ["k1"] } } + ])) + .expect("object form"); + assert_eq!(object_form.len(), 1); + assert_eq!( + object_form[0].plugin_ref, + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.apikey.v1" + ); + assert_eq!(object_form[0].config["keys"], serde_json::json!(["k1"])); + + let mixed: Vec = serde_json::from_value(serde_json::json!([ + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.noop.v1", + { "plugin_ref": "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.request_id.v1" } + ])) + .expect("mixed forms"); + assert_eq!(mixed.len(), 2); + assert_eq!(mixed[0].config, serde_json::json!({})); + assert_eq!( + mixed[1].config, + serde_json::json!({}), + "config defaults to empty object" + ); + + // Bare string in, bare string out when the config is empty; object out + // when it is not (ADR-0009). + let bare = serde_json::to_value(&mixed[0]).expect("serialise"); + assert_eq!( + bare, + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.noop.v1" + ); + let enriched = serde_json::to_value(&object_form[0]).expect("serialise"); + assert_eq!( + enriched["plugin_ref"], + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.apikey.v1" + ); + assert_eq!(enriched["config"]["keys"], serde_json::json!(["k1"])); +} + +#[test] +fn route_schema_block_round_trips() { + let route = Route { + id: Uuid::from_u128(0xB2), + upstream_id: Uuid::from_u128(0xA1), + r#match: RouteMatch { + http: Some(HttpMatch { + methods: vec![HttpMethod::Get, HttpMethod::Post], + path: "/v1/payments".to_owned(), + query_allowlist: vec!["cursor".to_owned()], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + headers: HeadersConfig { + request: HeaderRules { + passthrough: PassthroughMode::Allowlist, + passthrough_allowlist: vec!["x-trace".to_owned()], + ..HeaderRules::default() + }, + response: HeaderRules::default(), + }, + plugins: PluginConfig::default(), + rate_limit: None, + cors: Some(CorsConfig { + enabled: true, + sharing: SharingMode::Enforce, + allowed_origins: vec!["https://app.example.com".to_owned()], + allowed_methods: vec![CorsMethod::Get, CorsMethod::Options], + allow_headers: Vec::new(), + expose_headers: Vec::new(), + allow_credentials: false, + max_age: Some(3_600), + }), + enabled: true, + priority: 25, + tags: vec!["edge".to_owned()], + tenant_id: Uuid::from_u128(0x71), + created_at: SystemTime::UNIX_EPOCH, + updated_at: SystemTime::UNIX_EPOCH, + }; + + let encoded = serde_json::to_value(&route).expect("serialise"); + assert_eq!(encoded["match"]["http"]["path"], "/v1/payments"); + assert_eq!( + encoded["match"]["http"]["methods"], + serde_json::json!(["GET", "POST"]) + ); + assert_eq!(encoded["priority"], serde_json::json!(25)); + assert_eq!(encoded["cors"]["max_age"], serde_json::json!(3_600)); + assert!(encoded.get("tenant_id").is_none()); + assert!(encoded["headers"]["request"].get("set").is_none()); + + let decoded: Route = serde_json::from_value(encoded).expect("deserialise"); + let mut wire_only = route.clone(); + wire_only.tenant_id = Uuid::nil(); + wire_only.created_at = SystemTime::UNIX_EPOCH; + wire_only.updated_at = SystemTime::UNIX_EPOCH; + assert_eq!(decoded, wire_only); + assert_eq!(route.r#match.protocol(), Some(Protocol::Http)); +} + +#[test] +fn route_deserialises_the_documented_schema_shape() { + let raw = serde_json::json!({ + "upstream_id": "00000000-0000-0000-0000-00000000000a", + "match": { "grpc": { "service": "foo.v1.UserService", "method": "GetUser" } } + }); + let route: Route = serde_json::from_value(raw).expect("schema shape must parse"); + assert_eq!(route.r#match.protocol(), Some(Protocol::Grpc)); + assert!(route.enabled, "routes default to enabled"); + assert_eq!(route.priority, 0); + assert!(route.headers.is_empty()); +} + +#[test] +fn empty_route_match_has_no_protocol() { + let empty = RouteMatch::default(); + assert_eq!(empty.protocol(), None); + let both = RouteMatch { + http: Some(HttpMatch { + methods: vec![HttpMethod::Get], + path: "/".to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: PathSuffixMode::Disabled, + }), + grpc: Some(GrpcMatch { + service: "s".to_owned(), + method: "m".to_owned(), + }), + }; + assert_eq!(both.protocol(), None); + let encoded = serde_json::to_value(&both).expect("serialise"); + assert!(encoded.get("http").is_some()); + assert!(encoded.get("grpc").is_some()); +} + +#[test] +fn route_rejects_unknown_fields() { + let raw = serde_json::json!({ + "upstream_id": "00000000-0000-0000-0000-00000000000a", + "match": {}, + "bogus": true + }); + let error = serde_json::from_value::(raw).expect_err("unknown field must be rejected"); + assert!(error.to_string().contains("unknown field"), "{error}"); +} + +#[test] +fn plugin_resource_round_trips() { + let plugin = Plugin { + id: Uuid::from_u128(0xC3), + plugin_type: "gts.cf.core.oagw.guard_plugin.v1".to_owned(), + config: serde_json::json!({ "headers": ["x-trace"] }), + enabled: true, + tags: vec!["shared".to_owned()], + tenant_id: Uuid::from_u128(0x71), + created_at: SystemTime::UNIX_EPOCH, + updated_at: SystemTime::UNIX_EPOCH, + }; + let encoded = serde_json::to_value(&plugin).expect("serialise"); + assert_eq!(encoded["plugin_type"], "gts.cf.core.oagw.guard_plugin.v1"); + assert!(encoded.get("tenant_id").is_none()); + let decoded: Plugin = serde_json::from_value(encoded).expect("deserialise"); + let mut wire_only = plugin; + wire_only.tenant_id = Uuid::nil(); + wire_only.created_at = SystemTime::UNIX_EPOCH; + wire_only.updated_at = SystemTime::UNIX_EPOCH; + assert_eq!(decoded, wire_only); +} + +#[test] +fn resolved_proxy_target_is_shareable() { + let upstream = Arc::new(sample_upstream()); + let route = Arc::new(Route { + id: Uuid::from_u128(0xB2), + upstream_id: upstream.id, + ..Route::default() + }); + let target = ResolvedProxyTarget { + upstream: Arc::clone(&upstream), + route: Some(Arc::clone(&route)), + }; + let first = Arc::new(target); + let second = Arc::clone(&first); + assert!(Arc::ptr_eq(&first.upstream, &second.upstream)); + assert!(first.route.is_some()); +} + +#[test] +fn defaults_are_sensible() { + assert_eq!(Scheme::default(), Scheme::Https); + assert_eq!(Protocol::default(), Protocol::Http); + assert_eq!(SharingMode::default(), SharingMode::Private); + assert_eq!(PathSuffixMode::default(), PathSuffixMode::Append); + assert_eq!(PassthroughMode::default(), PassthroughMode::None); + assert_eq!(RateLimitWindow::default(), RateLimitWindow::Second); + assert_eq!( + RateLimitAlgorithm::default(), + RateLimitAlgorithm::TokenBucket + ); + assert_eq!(RateLimitScope::default(), RateLimitScope::Tenant); + assert_eq!(RateLimitStrategy::default(), RateLimitStrategy::Reject); + let upstream = Upstream::default(); + assert!(upstream.enabled); + assert!(upstream.endpoints().is_empty()); + assert!(upstream.alias.is_empty()); + let route = Route::default(); + assert!(route.enabled); + assert_eq!(route.priority, 0); + let plugin = Plugin::default(); + assert!(plugin.enabled); + assert_eq!(plugin.config, serde_json::json!({})); +} diff --git a/gears/system/oagw/oagw/src/storage_tests.rs b/gears/system/oagw/oagw/src/storage_tests.rs new file mode 100644 index 0000000..553b1ff --- /dev/null +++ b/gears/system/oagw/oagw/src/storage_tests.rs @@ -0,0 +1,821 @@ +//! Tests for [`crate::infra::storage`]. + +use std::sync::Arc; +use std::time::{Duration, SystemTime}; + +use axum::response::IntoResponse; +use uuid::Uuid; + +use crate::domain::error::{ERROR_SOURCE_HEADER, OagwError}; +use crate::domain::model::{ + AuthConfig, Plugin, PluginBinding, Protocol, ResolvedProxyTarget, Route, RouteMatch, + ServerConfig, SharingMode, Upstream, format_plugin_id, format_upstream_id, +}; +use crate::infra::storage::{ + CacheLimits, RegistryStore, dp_cache_key, plugin_cache_key, route_cache_key, upstream_cache_key, +}; + +const PARENT_TENANT: Uuid = Uuid::from_u128(0x10); +const CHILD_TENANT: Uuid = Uuid::from_u128(0x20); +const UNRELATED_TENANT: Uuid = Uuid::from_u128(0x30); + +fn limits() -> CacheLimits { + CacheLimits { + upstream: 8, + route: 8, + plugin: 8, + dp: 8, + } +} + +fn upstream(tenant: Uuid, id: u64, alias: &str) -> Upstream { + Upstream { + id: Uuid::from_u128(u128::from(id)), + enabled: true, + alias: alias.to_owned(), + tags: Vec::new(), + server: ServerConfig { + endpoints: Vec::new(), + }, + protocol: Protocol::Http, + auth: None, + headers: crate::domain::model::HeadersConfig::default(), + plugins: crate::domain::model::PluginConfig::default(), + rate_limit: None, + cors: None, + tenant_id: tenant, + created_at: SystemTime::UNIX_EPOCH + Duration::from_secs(id), + updated_at: SystemTime::UNIX_EPOCH + Duration::from_secs(id), + } +} + +fn http_match(path: &str) -> RouteMatch { + http_match_with(&["GET"], path) +} + +fn http_method(method: &str) -> crate::domain::model::HttpMethod { + match method { + "POST" => crate::domain::model::HttpMethod::Post, + "PUT" => crate::domain::model::HttpMethod::Put, + "DELETE" => crate::domain::model::HttpMethod::Delete, + "PATCH" => crate::domain::model::HttpMethod::Patch, + _ => crate::domain::model::HttpMethod::Get, + } +} + +fn http_match_with(methods: &[&str], path: &str) -> RouteMatch { + RouteMatch { + http: Some(crate::domain::model::HttpMatch { + methods: methods.iter().map(|method| http_method(method)).collect(), + path: path.to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: crate::domain::model::PathSuffixMode::Append, + }), + grpc: None, + } +} + +fn route(tenant: Uuid, id: u64, upstream_id: Uuid) -> Route { + route_with_match(tenant, id, upstream_id, RouteMatch::default(), 0) +} + +fn route_with_match( + tenant: Uuid, + id: u64, + upstream_id: Uuid, + r#match: RouteMatch, + priority: i32, +) -> Route { + Route { + id: Uuid::from_u128(u128::from(id)), + upstream_id, + r#match, + priority, + headers: crate::domain::model::HeadersConfig::default(), + plugins: crate::domain::model::PluginConfig::default(), + rate_limit: None, + cors: None, + enabled: true, + tags: Vec::new(), + tenant_id: tenant, + created_at: SystemTime::UNIX_EPOCH, + updated_at: SystemTime::UNIX_EPOCH, + } +} + +fn store() -> RegistryStore { + RegistryStore::new(limits()) +} + +#[test] +fn upstream_crud_is_tenant_scoped() { + let registry = store(); + let inserted = registry + .insert_upstream(upstream(PARENT_TENANT, 0xA1, "payments")) + .expect("insert"); + assert_eq!(inserted.alias, "payments"); + + assert!(registry.get_upstream(PARENT_TENANT, inserted.id).is_some()); + assert!(registry.get_upstream(CHILD_TENANT, inserted.id).is_none()); + assert!( + registry + .get_upstream(UNRELATED_TENANT, inserted.id) + .is_none() + ); + + registry + .replace_upstream(upstream(PARENT_TENANT, 0xA1, "payments-v2")) + .expect("replace"); + let reloaded = registry + .get_upstream(PARENT_TENANT, inserted.id) + .expect("still there"); + assert_eq!(reloaded.alias, "payments-v2"); + + assert!(registry.delete_upstream(PARENT_TENANT, inserted.id)); + assert!(!registry.delete_upstream(PARENT_TENANT, inserted.id)); + assert!(registry.get_upstream(PARENT_TENANT, inserted.id).is_none()); +} + +#[test] +fn duplicate_alias_conflicts_within_a_tenant_but_not_across_tenants() { + let registry = store(); + registry + .insert_upstream(upstream(PARENT_TENANT, 0xA1, "payments")) + .expect("insert"); + + let clash = registry.insert_upstream(upstream(PARENT_TENANT, 0xA2, "payments")); + assert!(matches!(clash, Err(OagwError::Conflict(..))), "{clash:?}"); + + let same_id_new_alias = registry.replace_upstream(upstream(PARENT_TENANT, 0xA1, "renamed")); + assert!(same_id_new_alias.is_ok(), "{same_id_new_alias:?}"); + assert!( + registry + .resolve_upstream_alias(&[PARENT_TENANT], "payments") + .is_none() + ); + assert!( + registry + .resolve_upstream_alias(&[PARENT_TENANT], "renamed") + .is_some() + ); + + let other_tenant = registry.insert_upstream(upstream(CHILD_TENANT, 0xB1, "payments")); + assert!(other_tenant.is_ok(), "{other_tenant:?}"); +} + +#[test] +fn alias_resolution_walks_the_ancestor_chain_nearest_first() { + let registry = store(); + registry + .insert_upstream(upstream(PARENT_TENANT, 0xA1, "shared")) + .expect("parent"); + registry + .insert_upstream(upstream(CHILD_TENANT, 0xB1, "shared")) + .expect("child"); + + let child_chain = [CHILD_TENANT, PARENT_TENANT]; + let resolved = registry + .resolve_upstream_alias(&child_chain, "shared") + .expect("child wins"); + assert_eq!(resolved.tenant_id, CHILD_TENANT); + + let parent_chain = [PARENT_TENANT]; + let parent_only = registry + .resolve_upstream_alias(&parent_chain, "shared") + .expect("parent wins"); + assert_eq!(parent_only.tenant_id, PARENT_TENANT); + + assert!( + registry + .resolve_upstream_alias(&[UNRELATED_TENANT], "shared") + .is_none() + ); + assert!(registry.upstream_alias_exists(&child_chain, "shared")); + assert!(!registry.upstream_alias_exists(&[UNRELATED_TENANT], "shared")); +} + +#[test] +fn list_upstreams_is_ordered_newest_first_and_tenant_scoped() { + let registry = store(); + for (id, tenant) in [ + (0xA1, PARENT_TENANT), + (0xA2, PARENT_TENANT), + (0xB1, CHILD_TENANT), + ] { + registry + .insert_upstream(upstream(tenant, id, &format!("u{id:02x}"))) + .expect("insert"); + } + let visible = registry.list_upstreams(&[PARENT_TENANT]); + assert_eq!(visible.len(), 2); + assert_eq!(visible[0].id, Uuid::from_u128(0xA2)); + assert_eq!(visible[1].id, Uuid::from_u128(0xA1)); + + let all = registry.list_upstreams(&[PARENT_TENANT, CHILD_TENANT]); + assert_eq!(all.len(), 3); + assert_eq!(all[0].id, Uuid::from_u128(0xB1)); +} + +#[test] +fn routes_require_a_tenant_local_upstream_and_unique_match_rules() { + let registry = store(); + let upstream_id = Uuid::from_u128(0xA1); + registry + .insert_upstream(upstream(PARENT_TENANT, 0xA1, "payments")) + .expect("upstream"); + + let orphan = registry.insert_route(route(CHILD_TENANT, 0xB1, upstream_id)); + assert!(matches!(orphan, Err(OagwError::NotFound(..))), "{orphan:?}"); + + registry + .insert_route(route(PARENT_TENANT, 0xB1, upstream_id)) + .expect("insert"); + let duplicate = registry.insert_route(route(PARENT_TENANT, 0xB2, upstream_id)); + assert!( + matches!(duplicate, Err(OagwError::Conflict(..))), + "{duplicate:?}" + ); + + assert!( + registry + .replace_route(route(PARENT_TENANT, 0xB1, upstream_id)) + .is_ok() + ); + assert!(matches!( + registry.replace_route(route(CHILD_TENANT, 0xB1, upstream_id)), + Err(OagwError::NotFound(..)) + )); + assert!(registry.delete_route(PARENT_TENANT, Uuid::from_u128(0xB1))); + assert!(!registry.delete_route(PARENT_TENANT, Uuid::from_u128(0xB1))); +} + +#[test] +fn overlapping_method_sets_at_the_same_path_and_priority_conflict() { + let registry = store(); + let upstream_id = Uuid::from_u128(0xA1); + registry + .insert_upstream(upstream(PARENT_TENANT, 0xA1, "payments")) + .expect("upstream"); + + // GET vs `GET|POST` on one path and priority: the sets overlap, so the + // second rule would steal the same requests (DESIGN section 3.3). + let existing = http_match_with(&["GET"], "/v1/pay"); + registry + .insert_route(route_with_match( + PARENT_TENANT, + 0xB1, + upstream_id, + existing, + 0, + )) + .expect("insert"); + + let overlapping = http_match_with(&["POST", "GET"], "/v1/pay"); + let clash = registry.insert_route(route_with_match( + PARENT_TENANT, + 0xB2, + upstream_id, + overlapping, + 0, + )); + assert!(matches!(clash, Err(OagwError::Conflict(..))), "{clash:?}"); + + // The mirrored order conflicts too: the wider set first, the narrower one + // second (a route never conflicts with its own replacement). + assert!(registry.delete_route(PARENT_TENANT, Uuid::from_u128(0xB1))); + registry + .insert_route(route_with_match( + PARENT_TENANT, + 0xB2, + upstream_id, + http_match_with(&["POST", "GET"], "/v1/pay"), + 0, + )) + .expect("the wider set is inserted first"); + let mirrored = registry.insert_route(route_with_match( + PARENT_TENANT, + 0xB1, + upstream_id, + http_match_with(&["GET"], "/v1/pay"), + 0, + )); + assert!( + matches!(mirrored, Err(OagwError::Conflict(..))), + "{mirrored:?}" + ); + let self_replace = registry.replace_route(route_with_match( + PARENT_TENANT, + 0xB2, + upstream_id, + http_match_with(&["POST"], "/v1/pay"), + 0, + )); + assert!( + self_replace.is_ok(), + "a route may replace itself: {self_replace:?}" + ); + + // A different priority at the same path is a distinct rule. + registry + .insert_route(route_with_match( + PARENT_TENANT, + 0xB3, + upstream_id, + http_match_with(&["GET"], "/v1/pay"), + 5, + )) + .expect("a different priority is a different rule"); + + // A disjoint method set at the same path and priority is fine. + registry + .insert_route(route_with_match( + PARENT_TENANT, + 0xB4, + upstream_id, + http_match_with(&["DELETE"], "/v1/pay"), + 0, + )) + .expect("disjoint methods do not conflict"); + + // gRPC rules still compare on `(service, method)`. + let grpc = RouteMatch { + http: None, + grpc: Some(crate::domain::model::GrpcMatch { + service: "payments.v1.Pay".to_owned(), + method: "Charge".to_owned(), + }), + }; + registry + .insert_route(route_with_match( + PARENT_TENANT, + 0xB5, + upstream_id, + grpc.clone(), + 0, + )) + .expect("insert"); + let clash = registry.insert_route(route_with_match(PARENT_TENANT, 0xB6, upstream_id, grpc, 0)); + assert!(matches!(clash, Err(OagwError::Conflict(..))), "{clash:?}"); +} + +#[tokio::test] +async fn concurrent_duplicate_alias_creates_yield_exactly_one_winner() { + use std::sync::Arc as StdArc; + use std::sync::atomic::{AtomicUsize, Ordering}; + + let registry = StdArc::new(store()); + let created = Arc::new(AtomicUsize::new(0)); + let conflicted = Arc::new(AtomicUsize::new(0)); + let mut handles = Vec::new(); + for index in 0..32u64 { + let registry = Arc::clone(®istry); + let created = Arc::clone(&created); + let conflicted = Arc::clone(&conflicted); + handles.push(tokio::task::spawn_blocking(move || { + let mut candidate = upstream(PARENT_TENANT, 0xA0 + index, "payments"); + candidate.id = Uuid::new_v4(); + match registry.insert_upstream(candidate) { + Ok(_) => created.fetch_add(1, Ordering::SeqCst), + Err(OagwError::Conflict(_)) => conflicted.fetch_add(1, Ordering::SeqCst), + Err(other) => panic!("unexpected error: {other:?}"), + } + })); + } + for handle in handles { + handle.await.expect("task"); + } + + assert_eq!(created.load(Ordering::SeqCst), 1, "exactly one 201"); + assert_eq!( + conflicted.load(Ordering::SeqCst), + 31, + "every other create is a 409" + ); + assert_eq!(registry.list_upstreams(&[PARENT_TENANT]).len(), 1); +} + +#[test] +fn deleting_an_upstream_removes_its_routes() { + let registry = store(); + let upstream_id = Uuid::from_u128(0xA1); + registry + .insert_upstream(upstream(PARENT_TENANT, 0xA1, "payments")) + .expect("upstream"); + registry + .insert_route(route(PARENT_TENANT, 0xB1, upstream_id)) + .expect("route"); + + assert_eq!( + registry.route_ids_for_upstream(PARENT_TENANT, upstream_id), + vec![Uuid::from_u128(0xB1)] + ); + assert!(registry.delete_upstream(PARENT_TENANT, upstream_id)); + assert!( + registry + .route_ids_for_upstream(PARENT_TENANT, upstream_id) + .is_empty() + ); + assert!( + registry + .get_route(PARENT_TENANT, Uuid::from_u128(0xB1)) + .is_none() + ); +} + +#[test] +fn list_routes_is_ordered_highest_priority_first() { + let registry = store(); + let upstream_id = Uuid::from_u128(0xA1); + registry + .insert_upstream(upstream(PARENT_TENANT, 0xA1, "payments")) + .expect("upstream"); + let high = route_with_match(PARENT_TENANT, 0xB1, upstream_id, http_match("/high"), 10); + let mid = route_with_match(PARENT_TENANT, 0xB2, upstream_id, http_match("/mid"), 5); + registry.insert_route(mid).expect("insert"); + registry.insert_route(high).expect("insert"); + + let visible = registry.list_routes(&[PARENT_TENANT]); + assert_eq!(visible.len(), 2); + assert_eq!(visible[0].id, Uuid::from_u128(0xB1)); + assert_eq!(visible[1].id, Uuid::from_u128(0xB2)); +} + +#[test] +fn plugin_reference_lookups_cover_auth_and_both_chain_levels() { + let registry = store(); + let plugin = Plugin { + id: Uuid::from_u128(0xC1), + tenant_id: PARENT_TENANT, + plugin_type: "gts.cf.core.oagw.guard_plugin.v1".to_owned(), + config: serde_json::json!({}), + ..Plugin::default() + }; + // The GTS instance-id spelling of a custom plugin, resolved through + // `parse_plugin_id`, next to the bare-UUID spelling. + let gts_reference = format_plugin_id(plugin.id); + let bare_reference = plugin.id.to_string(); + + let mut with_auth = upstream(PARENT_TENANT, 0xA1, "authed"); + with_auth.auth = Some(AuthConfig { + auth_type: gts_reference.clone(), + sharing: SharingMode::Inherit, + config: serde_json::json!({}), + }); + registry.insert_upstream(with_auth).expect("insert"); + + let mut with_chain = upstream(PARENT_TENANT, 0xA2, "chained"); + with_chain.plugins.items = vec![PluginBinding::new(bare_reference, serde_json::json!({}))]; + registry.insert_upstream(with_chain).expect("insert"); + + let clean = registry + .insert_upstream(upstream(PARENT_TENANT, 0xA3, "clean")) + .expect("insert"); + + let referencing = registry.upstream_ids_referencing_plugin(&plugin); + assert_eq!(referencing.len(), 2); + assert!(referencing.contains(&Uuid::from_u128(0xA1))); + assert!(referencing.contains(&Uuid::from_u128(0xA2))); + assert!(!referencing.contains(&clean.id)); + + let mut route_with_chain = route(PARENT_TENANT, 0xB1, clean.id); + route_with_chain.plugins.items = vec![PluginBinding::new(gts_reference, serde_json::json!({}))]; + registry.insert_route(route_with_chain).expect("insert"); + assert_eq!( + registry.route_ids_referencing_plugin(&plugin), + vec![Uuid::from_u128(0xB1)] + ); + + // The tenant-scoped variants narrow the result to the named tenants and + // still resolve both spellings. + let own = registry + .upstream_ids_referencing_plugin_in(&plugin, &[PARENT_TENANT]) + .len(); + assert_eq!(own, 2); + assert!( + registry + .upstream_ids_referencing_plugin_in(&plugin, &[CHILD_TENANT]) + .is_empty() + ); + assert!( + registry + .route_ids_referencing_plugin_in(&plugin, &[CHILD_TENANT]) + .is_empty() + ); +} + +#[test] +fn plugins_round_trip_through_the_registry_and_cache() { + let registry = store(); + let plugin_id = Uuid::from_u128(0xC1); + let plugin = Plugin { + id: plugin_id, + tenant_id: PARENT_TENANT, + plugin_type: "gts.cf.core.oagw.auth_plugin.v1".to_owned(), + config: serde_json::json!({ "kind": "apikey" }), + ..Plugin::default() + }; + registry.insert_plugin(plugin).expect("inserted"); + + assert!(registry.get_plugin(PARENT_TENANT, plugin_id).is_some()); + assert!(registry.get_plugin(CHILD_TENANT, plugin_id).is_none()); + assert_eq!( + registry.list_plugins(&[PARENT_TENANT])[0].plugin_type, + "gts.cf.core.oagw.auth_plugin.v1" + ); + assert!(registry.lookup_plugin_cache(plugin_id).is_some()); + + let removed = registry + .delete_plugin(PARENT_TENANT, plugin_id) + .expect("removed"); + assert_eq!(removed.config, serde_json::json!({ "kind": "apikey" })); + assert!(registry.get_plugin(PARENT_TENANT, plugin_id).is_none()); + assert!(registry.lookup_plugin_cache(plugin_id).is_none()); +} + +#[test] +fn cache_keys_follow_the_adr_0005_spellings() { + let tenant = PARENT_TENANT; + let upstream_id = Uuid::from_u128(0xA1); + assert_eq!( + upstream_cache_key(tenant, "payments"), + format!("upstream:{tenant}:payments") + ); + assert_eq!( + route_cache_key(upstream_id, "GET", "/v1"), + format!("route:{upstream_id}:GET:/v1") + ); + assert_eq!( + plugin_cache_key(upstream_id), + format!("plugin:{upstream_id}") + ); + assert_eq!( + dp_cache_key(tenant, "payments", "POST", "/v1/pay"), + format!("dp:{tenant}:payments:POST:/v1/pay") + ); +} + +#[test] +fn cache_hits_survive_until_an_authoritative_write_flushes_them() { + let registry = store(); + let inserted = registry + .insert_upstream(upstream(PARENT_TENANT, 0xA1, "payments")) + .expect("insert"); + + registry.store_upstream_cache(Arc::clone(&inserted)); + assert_eq!(registry.upstream_cache_len(), 1); + let hit = registry + .lookup_upstream_cache(PARENT_TENANT, "payments") + .expect("hit"); + assert_eq!(hit.id, inserted.id); + + registry + .replace_upstream(upstream(PARENT_TENANT, 0xA1, "payments")) + .expect("replace"); + assert_eq!(registry.upstream_cache_len(), 0, "mutation must invalidate"); + assert!( + registry + .lookup_upstream_cache(PARENT_TENANT, "payments") + .is_none() + ); +} + +#[test] +fn route_and_dp_caches_flush_prefixes_not_everything() { + let registry = store(); + let upstream_a = Uuid::from_u128(0xA1); + let upstream_b = Uuid::from_u128(0xA2); + registry + .insert_upstream(upstream(PARENT_TENANT, 0xA1, "a")) + .expect("upstream a"); + registry + .insert_upstream(upstream(PARENT_TENANT, 0xA2, "b")) + .expect("upstream b"); + let route_a = Arc::new(route_with_match( + PARENT_TENANT, + 0xB1, + upstream_a, + http_match("/a"), + 0, + )); + let route_b = Arc::new(route_with_match( + PARENT_TENANT, + 0xB2, + upstream_b, + http_match("/b"), + 0, + )); + registry + .insert_route(route_with_match( + PARENT_TENANT, + 0xB1, + upstream_a, + http_match("/a"), + 0, + )) + .expect("insert"); + registry + .insert_route(route_with_match( + PARENT_TENANT, + 0xB2, + upstream_b, + http_match("/b"), + 0, + )) + .expect("insert"); + + registry.store_route_cache(upstream_a, "GET", "/v1", Arc::clone(&route_a)); + registry.store_route_cache(upstream_b, "GET", "/v1", Arc::clone(&route_b)); + assert_eq!(registry.route_cache_len(), 2); + + assert!(registry.delete_route(PARENT_TENANT, route_a.id)); + assert_eq!( + registry.route_cache_len(), + 1, + "only upstream A entries are flushed" + ); + assert!( + registry + .lookup_route_cache(upstream_a, "GET", "/v1") + .is_none() + ); + assert!( + registry + .lookup_route_cache(upstream_b, "GET", "/v1") + .is_some() + ); + + let target = ResolvedProxyTarget { + upstream: Arc::new(upstream(PARENT_TENANT, 0xA1, "payments")), + route: None, + }; + registry.store_dp_cache(PARENT_TENANT, "payments", "GET", "/v1", Arc::new(target)); + assert_eq!(registry.dp_cache_len(), 1); + assert!( + registry + .lookup_dp_cache(PARENT_TENANT, "payments", "GET", "/v1") + .is_some() + ); + registry.flush_all_caches(); + assert!(registry.caches_are_empty()); +} + +#[test] +fn caches_flush_on_full_instead_of_growing_unbounded() { + let registry = RegistryStore::new(CacheLimits { + upstream: 2, + route: 2, + plugin: 2, + dp: 2, + }); + for index in 0..5u64 { + let inserted = registry + .insert_upstream(upstream( + PARENT_TENANT, + 0x1000 + index, + &format!("alias-{index}"), + )) + .expect("insert"); + registry.store_upstream_cache(Arc::clone(&inserted)); + } + assert!( + registry.upstream_cache_len() <= 2, + "cache must stay bounded" + ); + + for index in 0..5u64 { + registry.store_plugin_cache( + Uuid::from_u128(u128::from(0x2000 + index)), + Arc::new(serde_json::json!({ "index": index })), + ); + } + assert!(registry.plugin_cache_len() <= 2); +} + +#[test] +fn stored_errors_carry_the_gateway_source_header() { + let error = OagwError::conflict("duplicate alias"); + let rendered = error.into_response(); + assert!(rendered.headers().contains_key(ERROR_SOURCE_HEADER)); +} + +#[test] +fn upstream_ids_format_with_the_gts_prefix() { + let id = Uuid::from_u128(0xA1); + assert!(format_upstream_id(id).starts_with("gts.cf.core.oagw.upstream.v1~")); +} + +// -- H6: the route insert and the upstream delete share one critical section. + +/// A delete cannot enter the critical section an in-flight insert holds: it +/// has to wait, so it can never land between the insert's ownership check and +/// its write. +#[test] +fn an_upstream_delete_waits_for_the_route_critical_section() { + let registry = Arc::new(store()); + let upstream_id = Uuid::from_u128(0xA1); + registry + .insert_upstream(upstream(PARENT_TENANT, 0xA1, "payments")) + .expect("upstream"); + + // Hold the section exactly as `put_route` does while it checks and writes. + let guard = registry.route_write.lock(); + let deleter = { + let registry = Arc::clone(®istry); + std::thread::spawn(move || registry.delete_upstream(PARENT_TENANT, upstream_id)) + }; + std::thread::sleep(Duration::from_millis(20)); + // Deterministic either way: an unstarted delete has not removed anything, + // and a started one is parked on `route_write`. + assert!( + registry.get_upstream(PARENT_TENANT, upstream_id).is_some(), + "the delete must not run inside another writer's critical section" + ); + drop(guard); + assert!(deleter.join().expect("delete thread"), "delete completes"); + assert!( + registry.list_routes(&[PARENT_TENANT]).is_empty(), + "the delete takes its routes with it" + ); +} + +/// The interleaving the shared lock rules out: the delete lands before the +/// insert, and the insert is refused instead of resurrecting a route whose +/// upstream is gone. +#[test] +fn an_insert_after_an_upstream_delete_is_refused_without_leaving_a_route() { + let registry = store(); + let upstream_id = Uuid::from_u128(0xA1); + registry + .insert_upstream(upstream(PARENT_TENANT, 0xA1, "payments")) + .expect("upstream"); + assert!(registry.delete_upstream(PARENT_TENANT, upstream_id)); + + let attempt = registry.insert_route(route(PARENT_TENANT, 0xB1, upstream_id)); + assert!( + matches!(attempt, Err(OagwError::NotFound(..))), + "{attempt:?}" + ); + assert!( + registry + .get_route(PARENT_TENANT, Uuid::from_u128(0xB1)) + .is_none() + ); + assert!( + registry.list_routes(&[PARENT_TENANT]).is_empty(), + "no dangling route survives its upstream" + ); +} + +/// Under concurrent creates and a concurrent delete, no route that names a +/// gone upstream ever becomes visible: the shared critical section is the +/// whole point. +#[test] +fn concurrent_route_creates_and_an_upstream_delete_leave_no_dangling_route() { + let registry = Arc::new(store()); + let upstream_id = Uuid::from_u128(0xA1); + registry + .insert_upstream(upstream(PARENT_TENANT, 0xA1, "payments")) + .expect("upstream"); + + let writers: Vec<_> = (0..8u64) + .map(|index| { + let registry = Arc::clone(®istry); + std::thread::spawn(move || { + for offset in 0..8u64 { + let id = 0xB00 + index * 16 + offset; + let r#match = http_match_with(&["GET"], &format!("/v1/pay/{id}")); + // Either outcome is legitimate: the create lands, or the + // upstream is already gone and the create is refused. + let _ = registry.insert_route(route_with_match( + PARENT_TENANT, + id, + upstream_id, + r#match, + 0, + )); + } + }) + }) + .collect(); + let deleter = { + let registry = Arc::clone(®istry); + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(5)); + registry.delete_upstream(PARENT_TENANT, upstream_id) + }) + }; + for writer in writers { + writer.join().expect("writer thread"); + } + assert!(deleter.join().expect("delete thread")); + + for survivor in registry.list_routes(&[PARENT_TENANT]) { + assert!( + registry + .get_upstream(PARENT_TENANT, survivor.upstream_id) + .is_some(), + "route {} outlived its upstream", + survivor.id + ); + } +} diff --git a/gears/system/oagw/oagw/tests/management_api_test.rs b/gears/system/oagw/oagw/tests/management_api_test.rs new file mode 100644 index 0000000..c235869 --- /dev/null +++ b/gears/system/oagw/oagw/tests/management_api_test.rs @@ -0,0 +1,1315 @@ +//! Integration tests of the OAGW management API (slice 2). +//! +//! Every test drives the real handler stack — extractors, the +//! `ControlPlaneService`, the registry and the ADR-0007 error-source layer — +//! through `tower::ServiceExt::oneshot`, using the router builder in +//! `crate::api::rest::test_support` instead of a `GearCtx`. + +#![allow(clippy::expect_used)] + +use std::sync::Arc; + +use oagw::api::rest::test_support::{ + TestApp, body, build_app, build_app_without_hierarchy, caller, request, +}; +use oagw::config::OagwConfig; +use oagw::domain::error::OagwError; +use oagw::domain::model::SharingMode; +use oagw::domain::services::TenantHierarchy; +use serde_json::{Value, json}; +use uuid::Uuid; + +use async_trait::async_trait; +use toolkit_security::SecurityContext; + +const TENANT: Uuid = Uuid::from_u128(0x11); +const OTHER: Uuid = Uuid::from_u128(0x22); + +/// Body of a minimal valid upstream draft. +fn upstream_body(alias: Option<&str>, hosts: &[&str]) -> Value { + json!({ + "server": {"endpoints": hosts.iter().map(|host| { + json!({"scheme": "https", "host": host, "port": 443}) + }).collect::>()}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "alias": alias, + }) +} + +/// A single-host upstream draft, so the alias is derived from the hostname. +fn derived_upstream(host: &str) -> Value { + upstream_body(None, &[host]) +} + +/// Body of a minimal valid route draft. +fn route_body(path: &str, methods: &[&str]) -> Value { + json!({ + "match": {"http": {"methods": methods, "path": path, "path_suffix_mode": "append"}}, + "headers": {}, + "plugins": {}, + }) +} + +/// Adds an `x-request-id` header, returning the request for `call`. +fn with_request_id( + req: Result, axum::http::Error>, + id: &str, +) -> Result, axum::http::Error> { + let mut built = req.expect("request builds"); + built.headers_mut().insert( + "x-request-id", + axum::http::HeaderValue::from_str(id).expect("valid header value"), + ); + Ok(built) +} + +/// Sends a request and returns `(status, body)`. +async fn call( + app: &mut TestApp, + req: Result, axum::http::Error>, +) -> (axum::http::StatusCode, Value) { + let response = app + .send(req.expect("request builds")) + .await + .expect("infallible"); + let status = response.status(); + let text = body(response).await.expect("body reads"); + let parsed = if text.is_empty() { + Value::Null + } else { + serde_json::from_str(&text).expect("body is JSON") + }; + (status, parsed) +} + +/// Hierarchy that pins a single ancestor, for the bind-rule test. +struct FixedHierarchy; + +#[async_trait] +impl TenantHierarchy for FixedHierarchy { + async fn ancestors(&self, _ctx: &SecurityContext, _tenant: Uuid) -> Vec { + vec![OTHER] + } +} + +async fn app() -> TestApp { + build_app_without_hierarchy(OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }) +} + +fn ctx(tenant: Uuid) -> toolkit_security::SecurityContext { + caller(tenant).expect("security context") +} + +// -- upstream lifecycle --------------------------------------------------------- + +#[tokio::test] +async fn create_upstream_derives_the_alias_and_returns_201() { + let mut app = app().await; + let (status, created) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&derived_upstream("api.openai.com").to_string()), + ), + ) + .await; + + assert_eq!(status, axum::http::StatusCode::CREATED, "{created}"); + assert_eq!(created["alias"], json!("api.openai.com")); + assert_eq!(created["enabled"], json!(true), "enabled defaults to true"); + assert_eq!( + created["server"]["endpoints"][0]["host"], + json!("api.openai.com") + ); + assert_eq!( + created["protocol"], + json!("gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1") + ); + assert!( + created["created_at"].is_string(), + "timestamps are on the wire" + ); + assert!( + created["tenant_id"].is_null(), + "tenant_id stays off the wire" + ); + + // The id is a bare UUID and the Location header names the same resource. + let id = created["id"].as_str().expect("id is a string").to_owned(); + assert!(Uuid::parse_str(&id).is_ok(), "{id}"); + let (status, read) = call( + &mut app, + request( + "GET", + &format!("/oagw/v1/upstreams/{id}"), + ctx(TENANT), + None, + ), + ) + .await; + assert_eq!( + status, + axum::http::StatusCode::OK, + "the id round-trips: {read}" + ); +} + +#[tokio::test] +async fn the_created_response_carries_a_location_header() { + let mut app = app().await; + let response = app + .send( + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&derived_upstream("vendor.com").to_string()), + ) + .expect("request builds"), + ) + .await + .expect("infallible"); + assert_eq!(response.status(), axum::http::StatusCode::CREATED); + let id = response + .headers() + .get("location") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.rsplit('/').next()) + .map(ToOwned::to_owned) + .expect("Location names the new resource"); + let (status, read) = call( + &mut app, + request( + "GET", + &format!("/oagw/v1/upstreams/{id}"), + ctx(TENANT), + None, + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "{read}"); +} + +#[tokio::test] +async fn a_duplicate_alias_is_a_409_and_a_foreign_alias_is_invisible() { + let mut app = app().await; + let first = json!({"alias": "payments", "server": {"endpoints": [ + {"scheme": "https", "host": "10.0.0.1", "port": 443}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"}); + let (status, _) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&first.to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CREATED); + + let (status, body) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&first.to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CONFLICT, "{body}"); + assert_eq!( + body["type"], + json!("gts.cf.core.errors.err.v1~cf.oagw.conflict.v1"), + "{body}" + ); + + // The same alias in another tenant is a different resource. + let (status, _) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(OTHER), + Some(&first.to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CREATED); +} + +/// `unmatched` is the literal the data-plane metrics fold unresolved requests +/// onto, so an upstream may not claim it — through a supplied alias or through +/// the hostname a pool derives it from. +#[tokio::test] +async fn the_reserved_alias_unmatched_is_a_400() { + let mut app = app().await; + for body in [ + json!({"alias": "unmatched", "server": {"endpoints": [ + {"scheme": "https", "host": "10.0.0.1", "port": 443}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"}), + derived_upstream("unmatched"), + ] { + let (status, response) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&body.to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{response}"); + assert!( + response["detail"] + .as_str() + .expect("detail") + .contains("reserved"), + "{response}" + ); + } +} + +#[tokio::test] +async fn an_invalid_upstream_is_a_400_with_the_error_source_header() { + let mut app = app().await; + let (status, body) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&derived_upstream("api:8443:x").to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{body}"); + assert!( + body["detail"].as_str().expect("detail").contains("host"), + "{body}" + ); + + // A server-generated field is rejected before the handler runs. + let (status, body) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some( + r#"{"server": {"endpoints": []}, "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", "tenant_id": "abc"}"#, + ), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{body}"); + assert!( + body["detail"] + .as_str() + .expect("detail") + .contains("unknown field"), + "{body}" + ); +} + +#[tokio::test] +async fn a_malformed_body_is_a_400_with_the_gateway_error_source() { + let mut app = app().await; + let response = app + .send( + request("POST", "/oagw/v1/upstreams", ctx(TENANT), Some("{not json")) + .expect("request builds"), + ) + .await + .expect("infallible"); + assert_eq!(response.status(), axum::http::StatusCode::BAD_REQUEST); + assert_eq!( + response + .headers() + .get("x-oagw-error-source") + .map(|value| value.to_str().expect("value")), + Some("gateway"), + "ADR-0007 marks management-API errors as gateway-originated" + ); +} + +#[tokio::test] +async fn a_malformed_path_id_is_a_problem_document_not_a_plain_text_400() { + let mut app = app().await; + for (path, detail, invalid) in [ + ( + "/oagw/v1/upstreams/not-a-uuid", + "invalid upstream id", + "not-a-uuid", + ), + ( + "/oagw/v1/routes/not-a-uuid", + "invalid route id", + "not-a-uuid", + ), + ( + "/oagw/v1/plugins/not-a-uuid", + "invalid plugin id", + "not-a-uuid", + ), + // A GTS-form id of the *wrong* resource type is just as malformed. + ( + "/oagw/v1/upstreams/gts.cf.core.oagw.plugin.v1~00000000-0000-0000-0000-000000000001", + "invalid upstream id", + "gts.cf.core.oagw.plugin.v1~00000000-0000-0000-0000-000000000001", + ), + ] { + let response = app + .send(request("GET", path, ctx(TENANT), None).expect("request builds")) + .await + .expect("infallible"); + assert_eq!( + response.status(), + axum::http::StatusCode::NOT_FOUND, + "{path}" + ); + assert_eq!( + response + .headers() + .get("content-type") + .map(|value| value.to_str().expect("value")), + Some("application/problem+json"), + "{path}" + ); + assert_eq!( + response + .headers() + .get("x-oagw-error-source") + .map(|value| value.to_str().expect("value")), + Some("gateway"), + "{path}" + ); + let text = body(response).await.expect("body reads"); + let problem: Value = serde_json::from_str(&text).expect("problem json, not plain text"); + assert_eq!(problem["status"], json!(404), "{path}: {problem}"); + assert_eq!( + problem["type"], + json!("gts.cf.core.errors.err.v1~cf.oagw.not_found.v1"), + "{path}: {problem}" + ); + assert_eq!(problem["detail"], json!(detail), "{path}: {problem}"); + assert_eq!( + problem["context"]["invalid_value"], + json!(invalid), + "{path}" + ); + } +} + +#[tokio::test] +async fn a_well_formed_path_id_still_reads_a_missing_resource_as_a_404() { + let mut app = app().await; + let (status, problem) = call( + &mut app, + request( + "GET", + "/oagw/v1/upstreams/00000000-0000-0000-0000-00000000abcd", + ctx(TENANT), + None, + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::NOT_FOUND, "{problem}"); + assert_eq!( + problem["detail"], + json!( + "upstream gts.cf.core.oagw.upstream.v1~00000000-0000-0000-0000-00000000abcd does not exist" + ), + "{problem}" + ); + // The GTS-form spelling of the same id reads the same resource. + let (status, problem) = call( + &mut app, + request( + "GET", + "/oagw/v1/upstreams/gts.cf.core.oagw.upstream.v1~00000000-0000-0000-0000-00000000abcd", + ctx(TENANT), + None, + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::NOT_FOUND, "{problem}"); + assert_eq!(problem["status"], json!(404), "{problem}"); +} + +#[tokio::test] +async fn upstream_read_replace_and_delete_round_trip() { + let mut app = app().await; + let (status, created) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&derived_upstream("api.openai.com").to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CREATED, "{created}"); + let id = created["id"].as_str().expect("id").to_owned(); + let path = format!("/oagw/v1/upstreams/{id}"); + + // GET + let (status, read) = call(&mut app, request("GET", &path, ctx(TENANT), None)).await; + assert_eq!(status, axum::http::StatusCode::OK, "{read}"); + assert_eq!(read["alias"], json!("api.openai.com")); + + // GET from another tenant is a 404, not a 403. + let (status, missing) = call(&mut app, request("GET", &path, ctx(OTHER), None)).await; + assert_eq!(status, axum::http::StatusCode::NOT_FOUND, "{missing}"); + assert!( + missing["detail"] + .as_str() + .expect("detail") + .contains("upstream"), + "{missing}" + ); + + // PUT keeps the derived alias and clears omitted sections. + let replacement = json!({ + "server": {"endpoints": [ + {"scheme": "https", "host": "eu.api.openai.com", "port": 443}, + {"scheme": "https", "host": "us.api.openai.com", "port": 443}]}, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "tags": ["team-a"], + "enabled": false, + }); + let (status, replaced) = call( + &mut app, + with_request_id( + request("PUT", &path, ctx(TENANT), Some(&replacement.to_string())), + "req-9", + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "{replaced}"); + assert_eq!( + replaced["alias"], + json!("api.openai.com"), + "the alias is immutable" + ); + assert_eq!(replaced["enabled"], json!(false)); + assert_eq!(replaced["tags"], json!(["team-a"])); + assert_eq!( + replaced["server"]["endpoints"].as_array().map(Vec::len), + Some(2) + ); + + // A PUT whose pool would derive a different alias is a 400. + let renamed = upstream_body(None, &["api.vendor.com"]); + let (status, body) = call( + &mut app, + request("PUT", &path, ctx(TENANT), Some(&renamed.to_string())), + ) + .await; + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{body}"); + assert!( + body["detail"] + .as_str() + .expect("detail") + .contains("immutable"), + "{body}" + ); + assert_eq!(body["context"]["alias"], json!("api.openai.com"), "{body}"); + + // DELETE then GET is a 404. + let (status, _) = call( + &mut app, + with_request_id(request("DELETE", &path, ctx(TENANT), None), "req-10"), + ) + .await; + assert_eq!(status, axum::http::StatusCode::NO_CONTENT); + let (status, _) = call(&mut app, request("GET", &path, ctx(TENANT), None)).await; + assert_eq!(status, axum::http::StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn deleting_an_upstream_cascades_its_routes() { + let mut app = app().await; + let (_, upstream) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&derived_upstream("api.openai.com").to_string()), + ), + ) + .await; + let upstream_id = upstream["id"].as_str().expect("id").to_owned(); + let (_, route) = call( + &mut app, + request( + "POST", + "/oagw/v1/routes", + ctx(TENANT), + Some(&route_for(&upstream_id, "/v1", &["GET"]).to_string()), + ), + ) + .await; + assert_eq!(route["upstream_id"], json!(upstream_id), "{route}"); + + let (status, _) = call( + &mut app, + request( + "DELETE", + &format!("/oagw/v1/upstreams/{upstream_id}"), + ctx(TENANT), + None, + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::NO_CONTENT); + let (status, _) = call( + &mut app, + request( + "GET", + &format!("/oagw/v1/routes/{}", route["id"].as_str().expect("id")), + ctx(TENANT), + None, + ), + ) + .await; + assert_eq!( + status, + axum::http::StatusCode::NOT_FOUND, + "the route cascade is observable" + ); +} + +/// A route draft bound to `upstream_id`. +fn route_for(upstream_id: &str, path: &str, methods: &[&str]) -> Value { + let mut draft = route_body(path, methods); + draft["upstream_id"] = json!(upstream_id); + draft +} + +#[tokio::test] +async fn a_route_for_a_foreign_upstream_is_a_404() { + let mut app = app().await; + let (_, foreign) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(OTHER), + Some(&derived_upstream("api.openai.com").to_string()), + ), + ) + .await; + let foreign_id = foreign["id"].as_str().expect("id").to_owned(); + let (status, body) = call( + &mut app, + request( + "POST", + "/oagw/v1/routes", + ctx(TENANT), + Some(&route_for(&foreign_id, "/v1", &["GET"]).to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::NOT_FOUND, "{body}"); +} + +// -- route lifecycle ------------------------------------------------------------ + +#[tokio::test] +async fn route_crud_and_duplicate_match_rules() { + let mut app = app().await; + let (_, upstream) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&derived_upstream("api.openai.com").to_string()), + ), + ) + .await; + let upstream_id = upstream["id"].as_str().expect("id").to_owned(); + + let (status, created) = call( + &mut app, + request( + "POST", + "/oagw/v1/routes", + ctx(TENANT), + Some(&route_for(&upstream_id, "/v1", &["GET", "POST"]).to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CREATED, "{created}"); + assert_eq!(created["match"]["http"]["path"], json!("/v1")); + assert_eq!(created["match"]["http"]["methods"], json!(["GET", "POST"])); + assert_eq!(created["priority"], json!(0)); + assert_eq!(created["upstream_id"], json!(upstream_id)); + let route_id = created["id"].as_str().expect("id").to_owned(); + + // The same match rule at the same priority is a 409. + let (status, conflict) = call( + &mut app, + request( + "POST", + "/oagw/v1/routes", + ctx(TENANT), + Some(&route_for(&upstream_id, "/v1", &["POST", "GET"]).to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CONFLICT, "{conflict}"); + assert_eq!( + conflict["type"], + json!("gts.cf.core.errors.err.v1~cf.oagw.conflict.v1") + ); + + // A route whose match protocol differs from the upstream is a 400. + let grpc = json!({ + "upstream_id": upstream_id, + "match": {"grpc": {"service": "svc.v1.Svc", "method": "Get"}}, + "headers": {}, + "plugins": {}, + }); + let (status, body) = call( + &mut app, + request( + "POST", + "/oagw/v1/routes", + ctx(TENANT), + Some(&grpc.to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{body}"); + + // A replace carries no upstream_id; sending one is a 400. + let (status, body) = call( + &mut app, + request( + "PUT", + &format!("/oagw/v1/routes/{route_id}"), + ctx(TENANT), + Some(&route_for(&upstream_id, "/v2", &["GET"]).to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{body}"); + assert!( + body["detail"] + .as_str() + .expect("detail") + .contains("upstream_id"), + "{body}" + ); + + let mut replacement = route_body("/v2", &["GET"]); + replacement["priority"] = json!(5); + let (status, replaced) = call( + &mut app, + request( + "PUT", + &format!("/oagw/v1/routes/{route_id}"), + ctx(TENANT), + Some(&replacement.to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "{replaced}"); + assert_eq!(replaced["match"]["http"]["path"], json!("/v2")); + assert_eq!(replaced["priority"], json!(5)); + assert_eq!( + replaced["upstream_id"], + json!(upstream_id), + "upstream_id is immutable" + ); + + let (status, _) = call( + &mut app, + request( + "DELETE", + &format!("/oagw/v1/routes/{route_id}"), + ctx(TENANT), + None, + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::NO_CONTENT); + let (status, _) = call( + &mut app, + request( + "GET", + &format!("/oagw/v1/routes/{route_id}"), + ctx(TENANT), + None, + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::NOT_FOUND); +} + +// -- list queries --------------------------------------------------------------- + +async fn seed_routes(app: &mut TestApp) -> String { + let (_, upstream) = call( + app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&derived_upstream("api.openai.com").to_string()), + ), + ) + .await; + let upstream_id = upstream["id"].as_str().expect("id").to_owned(); + for (path, priority) in [("/v1", 1), ("/v2", 3), ("/v3", 2)] { + let mut draft = route_for(&upstream_id, path, &["GET"]); + draft["priority"] = json!(priority); + let (status, body) = call( + app, + request( + "POST", + "/oagw/v1/routes", + ctx(TENANT), + Some(&draft.to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CREATED, "{body}"); + } + upstream_id +} + +#[tokio::test] +async fn the_route_list_supports_filter_order_select_top_and_skip() { + let mut app = app().await; + let _unused = seed_routes(&mut app).await; + + // $orderby + $skip + $top + let (status, page) = call( + &mut app, + request( + "GET", + "/oagw/v1/routes?$orderby=priority%20desc&$top=2&$skip=1", + ctx(TENANT), + None, + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "{page}"); + assert_eq!(page["page_info"]["limit"], json!(2)); + assert!(page["page_info"]["next_cursor"].is_null(), "{page}"); + let priorities: Vec = page["items"] + .as_array() + .expect("items") + .iter() + .map(|item| item["priority"].as_i64().expect("priority")) + .collect(); + assert_eq!(priorities, vec![2, 1], "{page}"); + + // $filter + $select + let (status, page) = call( + &mut app, + request( + "GET", + "/oagw/v1/routes?$filter=priority%20eq%202&$select=priority,match", + ctx(TENANT), + None, + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "{page}"); + assert_eq!(page["items"].as_array().map(Vec::len), Some(1)); + let only = &page["items"][0]; + assert_eq!(only["priority"], json!(2)); + assert_eq!(only["match"]["http"]["path"], json!("/v3")); + assert!( + only.get("id").is_none(), + "$select drops the other fields: {only}" + ); + + // An unknown field is a 400, not a silent unfiltered list. + let (status, body) = call( + &mut app, + request( + "GET", + "/oagw/v1/routes?$filter=tenant_id%20eq%20%27x%27", + ctx(TENANT), + None, + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{body}"); + assert!( + body["detail"] + .as_str() + .expect("detail") + .contains("tenant_id"), + "{body}" + ); + + // An unknown $ parameter is a 400. + let (status, body) = call( + &mut app, + request( + "GET", + "/oagw/v1/routes?$filtert=priority%20eq%201", + ctx(TENANT), + None, + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{body}"); +} + +#[tokio::test] +async fn the_upstream_list_projects_selects_and_keeps_its_own_tenant() { + let mut app = app().await; + for host in ["api.openai.com", "vendor.com"] { + let (status, _) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&derived_upstream(host).to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CREATED); + } + let (status, _) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(OTHER), + Some(&derived_upstream("other.com").to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CREATED); + + let (status, page) = call( + &mut app, + request("GET", "/oagw/v1/upstreams?$select=alias", ctx(TENANT), None), + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "{page}"); + let aliases: Vec<&str> = page["items"] + .as_array() + .expect("items") + .iter() + .map(|item| item["alias"].as_str().expect("alias")) + .collect(); + assert!(aliases.contains(&"api.openai.com"), "{aliases:?}"); + assert!( + !aliases.contains(&"other.com"), + "lists are tenant-scoped: {aliases:?}" + ); +} + +// -- plugins -------------------------------------------------------------------- + +async fn seed_plugin(app: &mut TestApp) -> String { + let draft = json!({ + "plugin_type": "gts.cf.core.oagw.guard_plugin.v1", + "config": {"headers": ["x-request-id"]}, + }); + let (status, created) = call( + app, + request( + "POST", + "/oagw/v1/plugins", + ctx(TENANT), + Some(&draft.to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CREATED, "{created}"); + created["id"].as_str().expect("id").to_owned() +} + +#[tokio::test] +async fn plugin_crud_and_the_in_use_conflict_body() { + let mut app = app().await; + let plugin_id = seed_plugin(&mut app).await; + let path = format!("/oagw/v1/plugins/{plugin_id}"); + + let (status, read) = call(&mut app, request("GET", &path, ctx(TENANT), None)).await; + assert_eq!(status, axum::http::StatusCode::OK, "{read}"); + assert_eq!( + read["plugin_type"], + json!("gts.cf.core.oagw.guard_plugin.v1") + ); + assert_eq!(read["config"]["headers"], json!(["x-request-id"])); + + // The rendered source is stable and names the plugin. + let (status, source) = call( + &mut app, + request( + "GET", + &format!("/oagw/v1/plugins/{plugin_id}/source"), + ctx(TENANT), + None, + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "{source}"); + assert_eq!(source["plugin_id"], json!(plugin_id)); + assert!( + source["source"] + .as_str() + .expect("source") + .contains("PLUGIN_TYPE"), + "{source}" + ); + + // Bind the plugin into an upstream chain, then attempt a delete. + let (_, upstream) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&derived_upstream("api.openai.com").to_string()), + ), + ) + .await; + let upstream_id = upstream["id"].as_str().expect("id").to_owned(); + let mut draft = derived_upstream("api.openai.com"); + draft["plugins"] = json!({"items": [plugin_id]}); + let (status, body) = call( + &mut app, + request( + "PUT", + &format!("/oagw/v1/upstreams/{upstream_id}"), + ctx(TENANT), + Some(&draft.to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "{body}"); + assert_eq!(body["plugins"]["items"][0], json!(plugin_id), "{body}"); + + let (status, conflict) = call(&mut app, request("DELETE", &path, ctx(TENANT), None)).await; + assert_eq!(status, axum::http::StatusCode::CONFLICT, "{conflict}"); + assert_eq!( + conflict["type"], + json!("gts.cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1"), + "{conflict}" + ); + assert_eq!( + conflict["context"]["plugin_id"], + json!(format!("gts.cf.core.oagw.plugin.v1~{plugin_id}")) + ); + let referenced = &conflict["context"]["referenced_by"]; + assert_eq!( + referenced["upstreams"], + json!([format!("gts.cf.core.oagw.upstream.v1~{upstream_id}")]), + "{conflict}" + ); + + // Unbind, then the delete succeeds. + let (status, _) = call( + &mut app, + request( + "PUT", + &format!("/oagw/v1/upstreams/{upstream_id}"), + ctx(TENANT), + Some(&derived_upstream("api.openai.com").to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK); + let (status, _) = call(&mut app, request("DELETE", &path, ctx(TENANT), None)).await; + assert_eq!(status, axum::http::StatusCode::NO_CONTENT); + let (status, _) = call(&mut app, request("GET", &path, ctx(TENANT), None)).await; + assert_eq!(status, axum::http::StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn an_invalid_plugin_type_is_a_400_and_an_unknown_plugin_is_a_404() { + let mut app = app().await; + let draft = json!({"plugin_type": "not-a-gts-id"}); + let (status, body) = call( + &mut app, + request( + "POST", + "/oagw/v1/plugins", + ctx(TENANT), + Some(&draft.to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST, "{body}"); + + let (status, body) = call( + &mut app, + request( + "GET", + &format!("/oagw/v1/plugins/{}", Uuid::from_u128(0x999)), + ctx(TENANT), + None, + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::NOT_FOUND, "{body}"); + assert!( + body["detail"].as_str().expect("detail").contains("plugin"), + "{body}" + ); +} + +// -- hierarchy ------------------------------------------------------------------ + +#[tokio::test] +async fn an_enforced_ancestor_alias_is_a_403() { + let mut app = build_app( + OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }, + Arc::new(FixedHierarchy), + ); + let (_, ancestor) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(OTHER), + Some(&derived_upstream("api.openai.com").to_string()), + ), + ) + .await; + let ancestor_id = ancestor["id"].as_str().expect("id").to_owned(); + + // A private ancestor is bindable. + let (status, bound) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&derived_upstream("api.openai.com").to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CREATED, "{bound}"); + let bound_id = bound["id"].as_str().expect("id").to_owned(); + + // Flip the ancestor to `enforce`; the same bind is now a 403. + let enforced = { + let mut draft = derived_upstream("api.openai.com"); + draft["plugins"] = json!({"sharing": "enforce"}); + draft + }; + let (status, body) = call( + &mut app, + request( + "PUT", + &format!("/oagw/v1/upstreams/{ancestor_id}"), + ctx(OTHER), + Some(&enforced.to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "{body}"); + assert_eq!(body["plugins"]["sharing"], json!("enforce"), "{body}"); + + let (status, conflict) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&derived_upstream("api.openai.com").to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::FORBIDDEN, "{conflict}"); + assert_eq!( + conflict["type"], + json!("gts.cf.core.errors.err.v1~cf.oagw.tenancy.bind_forbidden.v1"), + "{conflict}" + ); + assert_eq!(conflict["context"]["alias"], json!("api.openai.com")); + + // The same denial on the replace path: a `PUT` that keeps the alias cannot + // sidestep the ancestor's `enforce` (F8). + let (status, replaced) = call( + &mut app, + request( + "PUT", + &format!("/oagw/v1/upstreams/{bound_id}"), + ctx(TENANT), + Some( + &{ + let mut draft = derived_upstream("api.openai.com"); + draft["tags"] = json!(["rebound"]); + draft + } + .to_string(), + ), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::FORBIDDEN, "{replaced}"); + assert_eq!( + replaced["type"], + json!("gts.cf.core.errors.err.v1~cf.oagw.tenancy.bind_forbidden.v1"), + "{replaced}" + ); + + // The ancestor itself is unaffected: `vendor.com` was never claimed, so the + // descendant may still bind it. + let (status, unclaimed) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&derived_upstream("vendor.com").to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CREATED, "{unclaimed}"); +} + +/// Hierarchy that resolves the nearest parent and a further ancestor, for the +/// two-level bind-rule test. +struct TwoLevelHierarchy { + /// Ancestor of the calling tenant (`TENANT`). + parent: Uuid, + /// Ancestor of `parent`. + grandparent: Uuid, +} + +#[async_trait] +impl TenantHierarchy for TwoLevelHierarchy { + async fn ancestors(&self, _ctx: &SecurityContext, tenant: Uuid) -> Vec { + if tenant == self.parent { + vec![self.grandparent] + } else { + vec![self.parent, self.grandparent] + } + } +} + +#[tokio::test] +async fn the_bind_rule_walks_the_whole_ancestor_chain() { + let parent = Uuid::from_u128(0x31); + let grandparent = Uuid::from_u128(0x41); + let mut app = build_app( + OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }, + Arc::new(TwoLevelHierarchy { + parent, + grandparent, + }), + ); + + // The grandparent claims the alias and enforces it. + let (status, claimed) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(grandparent), + Some(&derived_upstream("api.openai.com").to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CREATED, "{claimed}"); + let grandparent_id = claimed["id"].as_str().expect("id").to_owned(); + let mut enforced = derived_upstream("api.openai.com"); + enforced["plugins"] = json!({"sharing": "enforce"}); + let (status, body) = call( + &mut app, + request( + "PUT", + &format!("/oagw/v1/upstreams/{grandparent_id}"), + ctx(grandparent), + Some(&enforced.to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::OK, "{body}"); + + // The parent, which owns nothing, cannot bind it either: the rule walks the + // whole chain, not only the nearest ancestor. + let (status, denied) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(parent), + Some(&derived_upstream("api.openai.com").to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::FORBIDDEN, "{denied}"); + + // Nor can the leaf. + let (status, denied) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&derived_upstream("api.openai.com").to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::FORBIDDEN, "{denied}"); + assert_eq!( + denied["type"], + json!("gts.cf.core.errors.err.v1~cf.oagw.tenancy.bind_forbidden.v1"), + "{denied}" + ); + + // A private ancestor does not block: the leaf binds over it. + let (status, ancestor) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(grandparent), + Some(&derived_upstream("private.example.com").to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CREATED, "{ancestor}"); + let (status, bound) = call( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + ctx(TENANT), + Some(&derived_upstream("private.example.com").to_string()), + ), + ) + .await; + assert_eq!(status, axum::http::StatusCode::CREATED, "{bound}"); +} + +/// Keeps `OagwError` and `SharingMode` referenced for the doc-test surface of +/// the module (they are asserted on in the unit tests). +#[test] +fn the_error_and_sharing_types_are_exported() { + let error = OagwError::not_found("probe"); + assert_eq!(error.status(), axum::http::StatusCode::NOT_FOUND); + assert_eq!(SharingMode::default(), SharingMode::Private); +} diff --git a/gears/system/oagw/oagw/tests/proxy_test.rs b/gears/system/oagw/oagw/tests/proxy_test.rs new file mode 100644 index 0000000..be37767 --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_test.rs @@ -0,0 +1,2360 @@ +//! Integration tests of the OAGW data plane (slice 4). +//! +//! Every test drives the real proxy handler stack — alias resolution over the +//! tenant chain, route matching, CORS, rate limiting, the plugin chain, +//! endpoint selection, the outbound engine and the ADR-0007 error-source +//! stamping — through `tower::ServiceExt::oneshot`, with `httpmock` acting as +//! the upstream and a raw TCP socket for the streaming and upgrade cases. + +#![allow(clippy::expect_used)] + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use bytes::Bytes; +use futures_util::StreamExt; +use http_body_util::BodyExt; +use httpmock::prelude::*; +use oagw::api::rest::test_support::{ + ProxyApp, anonymous_request, body, build_proxy_app, build_proxy_app_with_secrets, + build_proxy_app_without_hierarchy, caller, proxy_request, request, +}; +use oagw::config::{OagwConfig, SsrfPolicy}; +use oagw::domain::model::{ + AuthConfig, BurstConfig, CorsConfig, CorsMethod, Endpoint, HeadersConfig, HttpMatch, + HttpMethod, PassthroughMode, PathSuffixMode, PluginBinding, Protocol, RateLimitAlgorithm, + RateLimitConfig, RateLimitScope, RateLimitStrategy, RateLimitWindow, Route, RouteMatch, Scheme, + ServerConfig, SustainedRateConfig, Upstream, +}; +use oagw::domain::services::TenantHierarchy; +use serde_json::json; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +const TENANT: Uuid = Uuid::from_u128(0x11); +const ANCESTOR: Uuid = Uuid::from_u128(0x22); + +/// Hierarchy that makes [`ANCESTOR`] the single parent of [`TENANT`]. +#[derive(Debug, Default, Clone, Copy)] +struct StaticHierarchy; + +#[async_trait] +impl TenantHierarchy for StaticHierarchy { + async fn ancestors(&self, _ctx: &SecurityContext, tenant: Uuid) -> Vec { + if tenant == TENANT { + vec![ANCESTOR] + } else { + Vec::new() + } + } +} + +/// Minimal data-plane configuration: plaintext upstreams, no SSRF gate. +fn proxy_config(timeout_secs: u64) -> OagwConfig { + OagwConfig { + proxy_timeout_secs: timeout_secs, + ssrf_policy: SsrfPolicy::default(), + ..OagwConfig::default() + } +} + +/// A single-host upstream pointing at `host:port` over plaintext HTTP. +fn upstream(alias: &str, port: u16) -> Upstream { + upstream_on(alias, "127.0.0.1", port) +} + +fn upstream_on(alias: &str, host: &str, port: u16) -> Upstream { + Upstream { + id: Uuid::new_v4(), + tenant_id: TENANT, + alias: alias.to_owned(), + tags: Vec::new(), + protocol: Protocol::Http, + server: ServerConfig { + endpoints: vec![Endpoint { + scheme: Scheme::Http, + host: host.to_owned(), + port, + }], + }, + auth: None, + plugins: Default::default(), + headers: HeadersConfig::default(), + rate_limit: None, + cors: None, + enabled: true, + created_at: std::time::SystemTime::UNIX_EPOCH, + updated_at: std::time::SystemTime::UNIX_EPOCH, + } +} + +/// An upstream that forwards every inbound header. +fn upstream_with_passthrough(alias: &str, port: u16) -> Upstream { + let mut draft = upstream(alias, port); + draft.headers.request.passthrough = PassthroughMode::All; + draft +} + +/// Registers `upstream` and one `GET path` route for it, returning the id. +fn seed(app: &ProxyApp, upstream: Upstream, path: &str) -> Uuid { + seed_route(app, upstream, path, HttpMethod::Get) +} + +/// Registers `upstream` and one `method path` route for it, returning the id. +fn seed_route(app: &ProxyApp, upstream: Upstream, path: &str, method: HttpMethod) -> Uuid { + let stored = app + .service + .store() + .insert_upstream(upstream) + .expect("upstream seeds"); + app.service + .store() + .insert_route(route(stored.id, path, method)) + .expect("route seeds"); + stored.id +} + +/// Sends a proxy request and returns `(status, headers, body-bytes)`. +async fn proxy( + app: &mut ProxyApp, + method: &'static str, + path: &str, +) -> (http::StatusCode, http::HeaderMap, Bytes) { + proxy_with(app, method, path, &[]).await +} + +/// Sends a proxy request with extra headers and returns the full answer. +async fn proxy_with( + app: &mut ProxyApp, + method: &'static str, + path: &str, + headers: &[(&'static str, &str)], +) -> (http::StatusCode, http::HeaderMap, Bytes) { + let request = proxy_request(method, path, caller(TENANT).expect("context"), headers) + .expect("request builds"); + let response = app.send(request).await.expect("infallible"); + let status = response.status(); + let response_headers = response.headers().clone(); + let payload = response + .into_body() + .collect() + .await + .expect("body collects") + .to_bytes(); + (status, response_headers, payload) +} + +// -- forwarding ------------------------------------------------------------- + +#[tokio::test] +async fn forwards_method_path_query_and_headers() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/orders") + .query_param("page", "2") + .header("x-trace", "abc"); + then.status(200).body("orders"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed( + &app, + upstream_with_passthrough("orders", server.port()), + "/", + ); + let (status, _, payload) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders/orders?page=2", + &[("x-trace", "abc")], + ) + .await; + assert_eq!( + status, + http::StatusCode::OK, + "body={}", + body_string(&payload) + ); + assert_eq!(payload, Bytes::from_static(b"orders")); +} + +/// `PassthroughMode::None` forwards no client header at all, so the upstream +/// never sees `x-trace`. +#[tokio::test] +async fn the_default_passthrough_mode_forwards_no_client_header() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/orders"); + then.status(200).body("orders"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", server.port()), "/"); + let (status, _, _) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders/orders", + &[("x-trace", "abc")], + ) + .await; + assert_eq!(status, http::StatusCode::OK); +} + +#[tokio::test] +async fn appends_the_path_suffix_to_the_matched_route() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/orders/42/items"); + then.status(200).body("items"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", server.port()), "/orders"); + let (status, _, payload) = + proxy(&mut app, "GET", "/oagw/v1/proxy/orders/orders/42/items").await; + assert_eq!(status, http::StatusCode::OK); + assert_eq!(payload, Bytes::from_static(b"items")); +} + +#[tokio::test] +async fn rewrites_the_host_header_to_the_endpoint() { + let server = MockServer::start(); + server.mock(|when, then| { + when.header("host", format!("127.0.0.1:{}", server.port())); + then.status(200).body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", server.port()), "/"); + let (status, _, _) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + assert_eq!(status, http::StatusCode::OK); +} + +#[tokio::test] +async fn applies_the_request_header_rules() { + let server = MockServer::start(); + server.mock(|when, then| { + when.header("x-set", "final").header("x-added", "one"); + then.status(200).body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream_with_passthrough("orders", server.port()); + draft + .headers + .request + .set + .insert("x-set".to_owned(), "final".to_owned()); + draft + .headers + .request + .add + .insert("x-added".to_owned(), "one".to_owned()); + draft.headers.request.remove.push("x-dropped".to_owned()); + seed(&app, draft, "/"); + let (status, _, _) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders", + &[("x-dropped", "gone")], + ) + .await; + assert_eq!(status, http::StatusCode::OK); +} + +#[tokio::test] +async fn applies_the_response_header_rules() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream("orders", server.port()); + draft + .headers + .response + .set + .insert("x-gateway".to_owned(), "oagw".to_owned()); + seed(&app, draft, "/"); + let (_, headers, _) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + assert_eq!( + headers + .get("x-gateway") + .and_then(|value| value.to_str().ok()), + Some("oagw") + ); +} + +#[tokio::test] +async fn upstream_status_body_and_headers_pass_through() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(201).header("x-upstream", "yes").body("created"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", server.port()), "/"); + let (status, headers, payload) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + assert_eq!(status, http::StatusCode::CREATED); + assert_eq!(payload, Bytes::from_static(b"created")); + assert_eq!( + headers + .get("x-upstream") + .and_then(|value| value.to_str().ok()), + Some("yes") + ); + assert_eq!( + headers + .get("x-oagw-error-source") + .and_then(|value| value.to_str().ok()), + Some("upstream") + ); +} + +#[tokio::test] +async fn strips_hop_by_hop_headers_in_both_directions() { + let server = MockServer::start(); + server.mock(|when, then| { + when.header_missing("connection").header_missing("te"); + then.status(200) + .header("connection", "keep-alive") + .header("keep-alive", "timeout=5") + .body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", server.port()), "/"); + let (_, headers, _) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders", + &[("connection", "keep-alive"), ("te", "trailers")], + ) + .await; + assert!(headers.get("connection").is_none()); + assert!(headers.get("keep-alive").is_none()); +} + +#[tokio::test] +async fn an_upstream_failure_is_stamped_upstream() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(500).body("boom"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", server.port()), "/"); + let (status, headers, payload) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + assert_eq!(status, http::StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!(payload, Bytes::from_static(b"boom")); + assert_eq!( + headers + .get("x-oagw-error-source") + .and_then(|value| value.to_str().ok()), + Some("upstream"), + "an upstream failure must never be stamped `gateway`" + ); +} + +/// An upstream that answers `application/problem+json` itself keeps its own +/// document: the gateway stamps the error source but injects none of its own +/// correlation fields (ADR-0007 "Passthrough"). +#[tokio::test] +async fn an_upstream_problem_document_passes_through_untouched() { + let upstream_problem = r#"{"type":"https://orders.example.com/errors/outage","title":"Outage","status":503,"detail":"over capacity"}"#; + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(503) + .header("content-type", "application/problem+json") + .body(upstream_problem); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", server.port()), "/"); + let (status, headers, payload) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders", + &[("x-request-id", "req-7")], + ) + .await; + assert_eq!(status, http::StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + headers + .get("x-oagw-error-source") + .and_then(|value| value.to_str().ok()), + Some("upstream") + ); + assert_eq!(body_string(&payload), upstream_problem, "byte for byte"); +} + +#[tokio::test] +async fn forwards_a_post_body() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/").body("payload"); + then.status(200).body("accepted"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed_route( + &app, + upstream("orders", server.port()), + "/", + HttpMethod::Post, + ); + let request = oagw::api::rest::test_support::proxy_request( + "POST", + "/oagw/v1/proxy/orders", + caller(TENANT).expect("context"), + &[("content-length", "7")], + ) + .expect("request builds") + .map(|_| axum::body::Body::from("payload")); + let response = app.send(request).await.expect("infallible"); + assert_eq!(response.status(), http::StatusCode::OK); +} + +// -- credential and header injection (PRD §5.2) ------------------------------ + +/// The bare registry key of the built-in `X-Request-ID` transform plugin. +const REQUEST_ID_PLUGIN: &str = "cf.core.oagw.request_id.v1"; + +/// The bare registry key of the built-in API key auth plugin. +const API_KEY_PLUGIN: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1"; + +/// An upstream bound to the API key plugin (injecting per `config`) and to the +/// `X-Request-ID` transform plugin, so one test sees both injected values. +fn api_key_upstream(alias: &str, port: u16, config: serde_json::Value) -> Upstream { + let mut draft = upstream(alias, port); + draft.auth = Some(AuthConfig { + auth_type: API_KEY_PLUGIN.to_owned(), + ..AuthConfig::default() + }); + draft.auth.as_mut().expect("auth binding").config = config; + draft.plugins.items = vec![PluginBinding::new(REQUEST_ID_PLUGIN, json!({}))]; + draft +} + +/// An upstream bound to the `X-Request-ID` transform plugin. +fn request_id_upstream(alias: &str, port: u16) -> Upstream { + let mut draft = upstream(alias, port); + draft.plugins.items = vec![PluginBinding::new(REQUEST_ID_PLUGIN, json!({}))]; + draft +} + +/// The request-id plugin stamps the outbound header even though the default +/// `passthrough: none` posture forwards no client header at all. +#[tokio::test] +async fn the_request_id_plugin_injects_the_outbound_header() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/orders") + .header("x-request-id", "probe-42") + .header_missing("x-probe-header"); + then.status(200).body("orders"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, request_id_upstream("orders", server.port()), "/"); + let (status, _, payload) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders/orders", + &[ + ("x-request-id", "probe-42"), + ("x-probe-header", "probe-123"), + ], + ) + .await; + assert_eq!(status, http::StatusCode::OK, "{}", body_string(&payload)); +} + +/// An API key credential is injected as a header and overrides the client +/// value of the same name; the request id still rides along. +#[tokio::test] +async fn an_api_key_plugin_injects_its_header_and_overrides_the_client_value() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/orders") + .header("x-upstream-key", "secret-key") + .header_not("x-upstream-key", "spoofed") + .header("x-request-id", "probe-42"); + then.status(200).body("orders"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let draft = api_key_upstream( + "orders", + server.port(), + json!({"key": "secret-key", "header_name": "x-upstream-key"}), + ); + seed(&app, draft, "/"); + let (status, _, payload) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders/orders", + &[("x-upstream-key", "spoofed"), ("x-request-id", "probe-42")], + ) + .await; + assert_eq!(status, http::StatusCode::OK, "{}", body_string(&payload)); +} + +/// An API key credential configured as `query_name` reaches the upstream query +/// string, URL-encoded, and replaces the client parameter of the same name. +#[tokio::test] +async fn an_api_key_plugin_injects_its_query_parameter() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/orders") + .query_param("api_key", "sec ret/1") + .query_param_not("api_key", "spoofed") + .query_param("keep", "1") + .header_missing("x-api-key"); + then.status(200).body("orders"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let draft = api_key_upstream( + "orders", + server.port(), + json!({"key": "sec ret/1", "query_name": "api_key"}), + ); + seed(&app, draft, "/"); + let (status, _, payload) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders/orders?api_key=spoofed&keep=1", + &[], + ) + .await; + assert_eq!(status, http::StatusCode::OK, "{}", body_string(&payload)); +} + +// -- memoised plugin instances (ADR-0008) ------------------------------------ + +/// The bare registry key of the built-in OAuth2 client-credentials auth plugin. +const OAUTH2_PLUGIN: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1"; + +/// The access-token body an IdP answers a client-credentials grant with. +fn token_body(token: &str, expires_in: u64) -> String { + format!(r#"{{"access_token":"{token}","expires_in":{expires_in},"token_type":"Bearer"}}"#) +} + +/// An upstream bound to the OAuth2 client-credentials plugin. +fn oauth2_upstream(alias: &str, port: u16, token_endpoint: &str) -> Upstream { + let mut draft = upstream(alias, port); + let mut auth = AuthConfig { + auth_type: OAUTH2_PLUGIN.to_owned(), + ..AuthConfig::default() + }; + auth.config = json!({ + "token_endpoint": token_endpoint, + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret" + }); + draft.auth = Some(auth); + draft +} + +/// The plugin instances of a binding are memoised, so a second proxied request +/// reuses the first bearer token instead of asking the IdP again (ADR-0008). +#[tokio::test] +async fn an_oauth2_binding_hits_the_token_endpoint_once_for_two_requests() { + let idp = MockServer::start(); + let token = idp.mock(|when, then| { + when.method(POST).path("/token"); + then.status(200).body(token_body("bearer-1", 3600)); + }); + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path("/orders") + .header("authorization", "Bearer bearer-1"); + then.status(200).body("orders"); + }); + let mut app = build_proxy_app_with_secrets( + proxy_config(2), + Arc::new(StaticHierarchy), + HashMap::from([ + ("client-id".to_owned(), "test-client".to_owned()), + ("client-secret".to_owned(), "test-secret".to_owned()), + ]), + ); + let draft = oauth2_upstream( + "orders", + server.port(), + &format!("http://localhost:{}/token", idp.port()), + ); + seed(&app, draft, "/"); + + for _ in 0..2 { + let (status, _, payload) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders/orders").await; + assert_eq!(status, http::StatusCode::OK, "{}", body_string(&payload)); + } + token.assert_calls_async(1).await; +} + +// -- resolution failures ---------------------------------------------------- + +#[tokio::test] +async fn an_unknown_alias_is_a_route_not_found_problem() { + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let (status, headers, payload) = proxy(&mut app, "GET", "/oagw/v1/proxy/ghost").await; + assert_eq!(status, http::StatusCode::NOT_FOUND); + assert_eq!( + headers + .get("x-oagw-error-source") + .and_then(|value| value.to_str().ok()), + Some("gateway") + ); + assert!(body_string(&payload).contains("cf.oagw.route.not_found.v1")); +} + +#[tokio::test] +async fn a_disabled_upstream_is_link_unavailable() { + let server = MockServer::start(); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream("orders", server.port()); + draft.enabled = false; + seed(&app, draft, "/"); + let (status, _, payload) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + assert_eq!(status, http::StatusCode::SERVICE_UNAVAILABLE); + assert!(body_string(&payload).contains("cf.oagw.link.unavailable.v1")); +} + +#[tokio::test] +async fn an_unmatched_path_is_a_route_not_found_problem() { + let server = MockServer::start(); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", server.port()), "/orders"); + let (status, _, payload) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders/nope").await; + assert_eq!(status, http::StatusCode::NOT_FOUND); + assert!(body_string(&payload).contains("cf.oagw.route.not_found.v1")); +} + +#[tokio::test] +async fn an_unmatched_method_is_a_route_not_found_problem() { + let server = MockServer::start(); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", server.port()), "/orders"); + let (status, _, _) = proxy(&mut app, "POST", "/oagw/v1/proxy/orders").await; + assert_eq!(status, http::StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn a_descendant_sees_an_ancestor_upstream_by_alias() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("inherited"); + }); + let mut app = build_proxy_app(proxy_config(2), Arc::new(StaticHierarchy)); + let mut draft = upstream("orders", server.port()); + draft.tenant_id = ANCESTOR; + let id = app + .service + .store() + .insert_upstream(draft) + .expect("upstream seeds") + .id; + let mut child_route = route(id, "/", HttpMethod::Get); + child_route.tenant_id = ANCESTOR; + app.service + .store() + .insert_route(child_route) + .expect("route seeds"); + let request = proxy_request( + "GET", + "/oagw/v1/proxy/orders", + caller(TENANT).expect("context"), + &[], + ) + .expect("request builds"); + let response = app.send(request).await.expect("infallible"); + assert_eq!(response.status(), http::StatusCode::OK); +} + +/// The data-plane cache is keyed by the *calling* tenant, so it is invalidated +/// wholesale whenever the owning tenant edits the upstream: a descendant that +/// already resolved the upstream must observe the change and not serve a stale +/// snapshot. +#[tokio::test] +async fn an_owner_edit_reaches_a_descendant_that_cached_the_upstream() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/orders"); + then.status(200).body("orders"); + }); + let mut app = build_proxy_app( + OagwConfig { + proxy_timeout_secs: 2, + allow_http_upstream: true, + ..OagwConfig::default() + }, + Arc::new(StaticHierarchy), + ); + let mut draft = upstream("orders", server.port()); + draft.tenant_id = ANCESTOR; + draft.cors = Some(cors_config()); + let id = app + .service + .store() + .insert_upstream(draft) + .expect("upstream seeds") + .id; + let mut child_route = route(id, "/orders", HttpMethod::Get); + child_route.tenant_id = ANCESTOR; + app.service + .store() + .insert_route(child_route) + .expect("route seeds"); + + // Warm the descendant's cache entry with an origin the owner allows. + let (warm, _, payload) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders/orders", + &[("origin", "https://portal.example.com")], + ) + .await; + assert_eq!(warm, http::StatusCode::OK, "{}", body_string(&payload)); + + // The owner tightens the CORS section. + let replacement = json!({ + "alias": "orders", + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "server": {"endpoints": [ + {"scheme": "http", "host": "127.0.0.1", "port": server.port()} + ]}, + "cors": { + "enabled": true, + "allowed_origins": ["https://other.example.com"], + "allowed_methods": ["GET"], + "allow_headers": ["content-type"] + }, + }); + let (status, body) = manage( + &mut app, + request( + "PUT", + &format!("/oagw/v1/upstreams/{id}"), + caller(ANCESTOR).expect("context"), + Some(&replacement.to_string()), + ), + ) + .await; + assert_eq!(status, http::StatusCode::OK, "{body}"); + + let (after, _, payload) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders/orders", + &[("origin", "https://portal.example.com")], + ) + .await; + assert_eq!( + after, + http::StatusCode::FORBIDDEN, + "{}", + body_string(&payload) + ); +} + +// -- route selection -------------------------------------------------------- + +/// The bare registry key of the built-in required-headers guard plugin. +const REQUIRED_HEADERS_PLUGIN: &str = + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"; + +/// Two routes claiming the same path are separated by priority, not by the +/// order the store returned them in: the higher-priority one wins even though +/// `max_by_key` alone would hand the request to the last maximum of the list. +#[tokio::test] +async fn the_higher_priority_route_wins_when_two_routes_share_a_path() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/orders"); + then.status(200).body("guarded"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let id = seed(&app, upstream("orders", server.port()), "/"); + + let mut low = route(id, "/orders", HttpMethod::Get); + low.priority = 0; + app.service.store().insert_route(low).expect("route seeds"); + + let mut high = route(id, "/orders", HttpMethod::Get); + high.priority = 10; + high.plugins.items = vec![PluginBinding::new( + REQUIRED_HEADERS_PLUGIN, + json!({"required_request_headers": "x-required"}), + )]; + app.service.store().insert_route(high).expect("route seeds"); + + let (without, _, payload) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders/orders").await; + assert_eq!( + without, + http::StatusCode::BAD_REQUEST, + "{}", + body_string(&payload) + ); + assert!(body_string(&payload).contains("REQUIRED_HEADER_MISSING")); + + let (with, _, payload) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders/orders", + &[("x-required", "1")], + ) + .await; + assert_eq!(with, http::StatusCode::OK, "{}", body_string(&payload)); +} + +// -- endpoint selection ----------------------------------------------------- + +#[tokio::test] +async fn a_pinned_target_host_is_used() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("pinned"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream("orders", server.port()); + draft.server.endpoints.push(Endpoint { + scheme: Scheme::Http, + host: "127.0.0.1".to_owned(), + port: server.port(), + }); + seed(&app, draft, "/"); + let (status, _, _) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders", + &[("x-oagw-target-host", "127.0.0.1")], + ) + .await; + assert_eq!(status, http::StatusCode::OK); +} + +#[tokio::test] +async fn a_malformed_target_host_is_a_bad_request() { + let server = MockServer::start(); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", server.port()), "/"); + let (status, _, payload) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders", + &[("x-oagw-target-host", "not a host")], + ) + .await; + assert_eq!(status, http::StatusCode::BAD_REQUEST); + assert!(body_string(&payload).contains("cf.oagw.routing.invalid_target_host.v1")); +} + +#[tokio::test] +async fn an_unknown_target_host_lists_the_pool() { + let server = MockServer::start(); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", server.port()), "/"); + let (status, _, payload) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders", + &[("x-oagw-target-host", "other.example.com")], + ) + .await; + assert_eq!(status, http::StatusCode::BAD_REQUEST); + assert!(body_string(&payload).contains("cf.oagw.routing.unknown_target_host.v1")); + assert!(body_string(&payload).contains("127.0.0.1")); +} + +#[tokio::test] +async fn round_robin_balances_two_endpoints() { + let first = MockServer::start(); + let second = MockServer::start(); + first.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("first"); + }); + second.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("second"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream_on("round-robin", "127.0.0.1", first.port()); + draft.server.endpoints.push(Endpoint { + scheme: Scheme::Http, + host: "127.0.0.1".to_owned(), + port: second.port(), + }); + seed(&app, draft, "/"); + + let mut answers = Vec::new(); + for _ in 0..4 { + let (_, _, payload) = proxy(&mut app, "GET", "/oagw/v1/proxy/round-robin").await; + answers.push(body_string(&payload)); + } + assert!(answers.contains(&"first".to_owned()), "{answers:?}"); + assert!(answers.contains(&"second".to_owned()), "{answers:?}"); +} + +// -- timeouts and size limits ---------------------------------------------- + +#[tokio::test] +async fn an_unreachable_upstream_is_link_unavailable() { + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", 1), "/"); + let (status, _, payload) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + assert_eq!(status, http::StatusCode::SERVICE_UNAVAILABLE); + assert!(body_string(&payload).contains("cf.oagw.link.unavailable.v1")); +} + +#[tokio::test] +async fn a_slow_upstream_is_a_gateway_timeout() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("late").delay(Duration::from_secs(4)); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(1)); + seed(&app, upstream("orders", server.port()), "/"); + let (status, _, payload) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + assert_eq!(status, http::StatusCode::GATEWAY_TIMEOUT); + assert!(body_string(&payload).contains("cf.oagw.timeout.request.v1")); +} + +#[tokio::test] +async fn a_mismatched_content_length_is_a_bad_request() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed_route( + &app, + upstream("orders", server.port()), + "/", + HttpMethod::Post, + ); + let request = oagw::api::rest::test_support::proxy_request( + "POST", + "/oagw/v1/proxy/orders", + caller(TENANT).expect("context"), + &[("content-length", "99")], + ) + .expect("request builds") + .map(|_| axum::body::Body::from("payload")); + let response = app.send(request).await.expect("infallible"); + assert_eq!(response.status(), http::StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn an_identity_transfer_encoding_is_a_bad_request() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed_route( + &app, + upstream("orders", server.port()), + "/", + HttpMethod::Post, + ); + let request = oagw::api::rest::test_support::proxy_request( + "POST", + "/oagw/v1/proxy/orders", + caller(TENANT).expect("context"), + &[("transfer-encoding", "identity")], + ) + .expect("request builds") + .map(|_| axum::body::Body::from("payload")); + let response = app.send(request).await.expect("infallible"); + assert_eq!(response.status(), http::StatusCode::BAD_REQUEST); +} + +/// A body whose reads fail, so a handler that buffers the payload instead of +/// answering from the declared headers surfaces the read error. +fn unreadable_body() -> axum::body::Body { + axum::body::Body::from_stream(futures_util::stream::iter(vec![ + Err::(std::io::Error::other("the body must never be read")), + ])) +} + +/// A declared `Content-Length` over the cap is rejected before the body is +/// buffered, so an oversized upload never reaches memory and the upstream is +/// never contacted (DESIGN "Body Validation Rules"). +#[tokio::test] +async fn a_declared_body_over_the_cap_is_rejected_before_it_is_read() { + let server = MockServer::start(); + let oversized = server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200).body("must never be reached"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed_route( + &app, + upstream("orders", server.port()), + "/", + HttpMethod::Post, + ); + let request = oagw::api::rest::test_support::proxy_request( + "POST", + "/oagw/v1/proxy/orders", + caller(TENANT).expect("context"), + &[("content-length", "999999999")], + ) + .expect("request builds") + .map(|_| unreadable_body()); + let response = app.send(request).await.expect("infallible"); + let status = response.status(); + let payload = body(response).await.expect("body reads"); + assert_eq!(status, http::StatusCode::PAYLOAD_TOO_LARGE, "{payload}"); + assert!( + payload.contains("cf.oagw.payload.too_large.v1"), + "{payload}" + ); + assert_eq!(oversized.calls_async().await, 0, "no upstream call"); +} + +/// A chunked (no `Content-Length`) body of `size` bytes, streamed in 1 MiB +/// frames so the test never materialises the whole payload up front. +fn chunked_body(size: usize) -> axum::body::Body { + let frame = Bytes::from(vec![b'x'; 1024 * 1024]); + let frames = size.div_ceil(frame.len()); + let stream = + futures_util::stream::repeat_with(move || Ok::(frame.clone())) + .take(frames); + axum::body::Body::from_stream(stream) +} + +/// An undeclared body over the cap is a `413` as well: the declared +/// `Content-Length` is only the fast path, so a caller that streams its upload +/// (or lies about its size) is answered with the same problem type once the +/// buffered content is measured (DESIGN "Body Validation Rules"). +#[tokio::test] +async fn an_undeclared_body_over_the_cap_is_a_413() { + let server = MockServer::start(); + let oversized = server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200).body("must never be reached"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed_route( + &app, + upstream("orders", server.port()), + "/", + HttpMethod::Post, + ); + let request = oagw::api::rest::test_support::proxy_request( + "POST", + "/oagw/v1/proxy/orders", + caller(TENANT).expect("context"), + &[("transfer-encoding", "chunked")], + ) + .expect("request builds") + .map(|_| chunked_body(oagw::infra::proxy::MAX_REQUEST_BODY_BYTES + 1)); + let response = app.send(request).await.expect("infallible"); + let status = response.status(); + let payload = body(response).await.expect("body reads"); + assert_eq!(status, http::StatusCode::PAYLOAD_TOO_LARGE, "{payload}"); + assert!( + payload.contains("cf.oagw.payload.too_large.v1"), + "{payload}" + ); + assert_eq!(oversized.calls_async().await, 0, "no upstream call"); +} + +/// An exhausted rate budget is answered before the body is validated, so a +/// caller over budget learns that instead of a body error. +#[tokio::test] +async fn the_rate_limit_is_enforced_before_the_body_is_read() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(POST).path("/"); + then.status(200).body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream("orders", server.port()); + draft.rate_limit = Some(RateLimitConfig { + sharing: oagw::domain::model::SharingMode::Private, + algorithm: RateLimitAlgorithm::SlidingWindow, + sustained: SustainedRateConfig { + rate: 1, + window: RateLimitWindow::Minute, + }, + burst: Some(BurstConfig { capacity: Some(1) }), + scope: RateLimitScope::Tenant, + strategy: RateLimitStrategy::Reject, + response_headers: true, + cost: 1, + }); + seed_route(&app, draft, "/", HttpMethod::Post); + + // Spend the budget on a well-formed request. + let first = oagw::api::rest::test_support::proxy_request( + "POST", + "/oagw/v1/proxy/orders", + caller(TENANT).expect("context"), + &[("content-length", "7")], + ) + .expect("request builds") + .map(|_| axum::body::Body::from("payload")); + let response = app.send(first).await.expect("infallible"); + assert_eq!(response.status(), http::StatusCode::OK); + + // The next one is over budget *and* carries a body error: the 429 wins. + let second = oagw::api::rest::test_support::proxy_request( + "POST", + "/oagw/v1/proxy/orders", + caller(TENANT).expect("context"), + &[("content-length", "999999999")], + ) + .expect("request builds") + .map(|_| unreadable_body()); + let response = app.send(second).await.expect("infallible"); + let status = response.status(); + let payload = body(response).await.expect("body reads"); + assert_eq!(status, http::StatusCode::TOO_MANY_REQUESTS, "{payload}"); +} + +// -- CORS ------------------------------------------------------------------- + +#[tokio::test] +async fn a_preflight_is_answered_locally_with_204() { + let server = MockServer::start(); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream("orders", server.port()); + draft.cors = Some(cors_config()); + seed(&app, draft, "/"); + let (status, headers, payload) = proxy_with( + &mut app, + "OPTIONS", + "/oagw/v1/proxy/orders", + &[ + ("origin", "https://portal.example.com"), + ("access-control-request-method", "GET"), + ], + ) + .await; + assert_eq!(status, http::StatusCode::NO_CONTENT); + assert!(payload.is_empty()); + assert_eq!( + headers + .get("access-control-allow-origin") + .and_then(|value| value.to_str().ok()), + Some("https://portal.example.com") + ); + // ADR-0004: a preflight varies on every request header it consumed. + assert_eq!( + headers.get("vary").and_then(|value| value.to_str().ok()), + Some("Origin, Access-Control-Request-Method, Access-Control-Request-Headers") + ); +} + +/// A preflight no configured upstream owns — here the caller has no security +/// context, exactly as a browser sends it — is answered `204` with the +/// permissive ADR-0004 posture: no `Access-Control-Allow-Origin` (nothing was +/// verified against a configuration), the requested method and headers echoed, +/// the pinned max-age and the `Vary` triplet. +#[tokio::test] +async fn an_unauthenticated_preflight_is_answered_permissively() { + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", 1), "/"); + let request = anonymous_request( + "OPTIONS", + "/oagw/v1/proxy/orders", + &[ + ("origin", "https://portal.example.com"), + ("access-control-request-method", "POST"), + ("access-control-request-headers", "content-type, x-trace-id"), + ], + ) + .expect("request builds"); + let response = app.send(request).await.expect("infallible"); + let status = response.status(); + let headers = response.headers().clone(); + let payload = body(response).await.expect("body reads"); + assert_eq!(status, http::StatusCode::NO_CONTENT, "{payload}"); + assert!(payload.is_empty()); + assert_eq!( + headers + .get("access-control-allow-origin") + .and_then(|value| value.to_str().ok()), + None, + "no grant may be advertised without a verified configuration" + ); + assert_eq!( + headers + .get("access-control-allow-methods") + .and_then(|v| v.to_str().ok()), + Some("POST") + ); + assert_eq!( + headers + .get("access-control-allow-headers") + .and_then(|v| v.to_str().ok()), + Some("content-type, x-trace-id") + ); + assert_eq!( + headers + .get("access-control-max-age") + .and_then(|v| v.to_str().ok()), + Some("86400") + ); + assert_eq!( + headers.get("vary").and_then(|value| value.to_str().ok()), + Some("Origin, Access-Control-Request-Method, Access-Control-Request-Headers") + ); +} + +/// An authenticated caller whose alias does not resolve gets the same +/// permissive preflight instead of the `404` an actual request would answer +/// with (ADR-0004: no upstream resolution, enforcement deferred). +#[tokio::test] +async fn a_preflight_for_an_unknown_alias_is_answered_permissively() { + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", 1), "/"); + let request = anonymous_request( + "OPTIONS", + "/oagw/v1/proxy/no-such-alias", + &[ + ("origin", "https://portal.example.com"), + ("access-control-request-method", "POST"), + ], + ) + .expect("request builds"); + let response = app.send(request).await.expect("infallible"); + assert_eq!(response.status(), http::StatusCode::NO_CONTENT); + assert!( + response + .headers() + .get("access-control-allow-origin") + .is_none() + ); + assert_eq!( + response + .headers() + .get("access-control-max-age") + .and_then(|value| value.to_str().ok()), + Some("86400") + ); +} + +/// An authenticated, resolvable preflight keeps the configured behaviour: the +/// origin is echoed because the gateway verified it against the upstream CORS +/// section. +#[tokio::test] +async fn a_preflight_for_a_disabled_upstream_stays_permissive() { + let server = MockServer::start(); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream("orders", server.port()); + draft.enabled = false; + draft.cors = Some(cors_config()); + seed(&app, draft, "/"); + let (status, headers, _) = proxy_with( + &mut app, + "OPTIONS", + "/oagw/v1/proxy/orders", + &[ + ("origin", "https://portal.example.com"), + ("access-control-request-method", "GET"), + ], + ) + .await; + assert_eq!(status, http::StatusCode::NO_CONTENT); + assert_eq!( + headers + .get("access-control-allow-origin") + .and_then(|value| value.to_str().ok()), + None, + "a disabled upstream verifies no grant" + ); +} + +#[tokio::test] +async fn a_disallowed_origin_is_forbidden() { + let server = MockServer::start(); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream("orders", server.port()); + draft.cors = Some(cors_config()); + seed(&app, draft, "/"); + let (status, _, _) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders", + &[("origin", "https://evil.example.com")], + ) + .await; + assert_eq!(status, http::StatusCode::FORBIDDEN); +} + +#[tokio::test] +async fn an_allowed_origin_is_proxied_and_annotated() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream("orders", server.port()); + draft.cors = Some(cors_config()); + seed(&app, draft, "/"); + let (status, headers, _) = proxy_with( + &mut app, + "GET", + "/oagw/v1/proxy/orders", + &[("origin", "https://portal.example.com")], + ) + .await; + assert_eq!(status, http::StatusCode::OK); + assert_eq!( + headers + .get("access-control-allow-origin") + .and_then(|value| value.to_str().ok()), + Some("https://portal.example.com") + ); +} + +// -- rate limiting ---------------------------------------------------------- + +#[tokio::test] +async fn an_exhausted_rate_budget_is_a_429_with_headers() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream("orders", server.port()); + draft.rate_limit = Some(RateLimitConfig { + sharing: oagw::domain::model::SharingMode::Private, + algorithm: RateLimitAlgorithm::SlidingWindow, + sustained: SustainedRateConfig { + rate: 1, + window: RateLimitWindow::Minute, + }, + burst: Some(BurstConfig { capacity: Some(1) }), + scope: RateLimitScope::Tenant, + strategy: RateLimitStrategy::Reject, + response_headers: true, + cost: 1, + }); + seed(&app, draft, "/"); + + let (first, first_headers, _) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + assert_eq!(first, http::StatusCode::OK); + assert!(first_headers.get("x-ratelimit-limit").is_some()); + let (second, second_headers, _) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + assert_eq!(second, http::StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + second_headers + .get("x-ratelimit-remaining") + .and_then(|value| value.to_str().ok()), + Some("0") + ); +} + +/// ADR-0003: a `429` always carries `Retry-After`, including one a `queue` +/// strategy answers because its sliding window cannot deliver the reservation it +/// granted — the window is a log of past hits, so the reserved token is not +/// there yet even though the wait is inside the queue bound. +#[tokio::test] +async fn a_denied_queue_reservation_answers_429_with_retry_after() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream("orders", server.port()); + draft.rate_limit = Some(RateLimitConfig { + sharing: oagw::domain::model::SharingMode::Private, + algorithm: RateLimitAlgorithm::SlidingWindow, + sustained: SustainedRateConfig { + rate: 2, + window: RateLimitWindow::Second, + }, + burst: Some(BurstConfig { capacity: Some(2) }), + scope: RateLimitScope::Tenant, + strategy: RateLimitStrategy::Queue, + response_headers: true, + cost: 1, + }); + seed(&app, draft, "/"); + + let _ = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + let _ = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + let (third, headers, _) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + + assert_eq!(third, http::StatusCode::TOO_MANY_REQUESTS); + let retry_after = headers + .get("retry-after") + .and_then(|value| value.to_str().ok()) + .expect("ADR-0003 requires Retry-After on every 429"); + assert_ne!(retry_after, "0", "{retry_after}"); + assert!( + headers.get("x-ratelimit-remaining").is_some(), + "the budget headers are still emitted" + ); +} + +/// A `queue` reservation the request never used is put back: a request that was +/// granted a token and then failed must not consume the budget, so the next +/// request is admitted without waiting for it again. +#[tokio::test] +async fn a_failed_queued_request_refunds_its_reservation() { + // Port 1 has no listener, so every request fails after the debit with a + // gateway error. + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream("orders", 1); + draft.rate_limit = Some(RateLimitConfig { + sharing: oagw::domain::model::SharingMode::Private, + algorithm: RateLimitAlgorithm::TokenBucket, + sustained: SustainedRateConfig { + rate: 2, + window: RateLimitWindow::Second, + }, + burst: Some(BurstConfig { capacity: Some(1) }), + scope: RateLimitScope::Tenant, + strategy: RateLimitStrategy::Queue, + response_headers: true, + cost: 1, + }); + seed(&app, draft, "/"); + + let path = "/oagw/v1/proxy/orders"; + let started = Instant::now(); + let (first, _, _) = proxy(&mut app, "GET", path).await; + assert_eq!(first, http::StatusCode::SERVICE_UNAVAILABLE); + // The bucket is empty now, so the second request queues half a second for + // its token, waits, and fails like the first. + let (second, _, _) = proxy(&mut app, "GET", path).await; + assert_eq!(second, http::StatusCode::SERVICE_UNAVAILABLE); + assert!(started.elapsed() >= Duration::from_millis(400)); + + // The failed request put its reserved token back: the third one is admitted + // without paying the wait again. + let started = Instant::now(); + let (third, _, _) = proxy(&mut app, "GET", path).await; + assert_eq!(third, http::StatusCode::SERVICE_UNAVAILABLE); + let third_elapsed = started.elapsed(); + assert!( + third_elapsed < Duration::from_millis(400), + "the refunded budget was not restored: {third_elapsed:?}" + ); +} + +// -- metrics ---------------------------------------------------------------- +#[tokio::test] +async fn the_metrics_endpoint_renders_the_data_plane_counters() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", server.port()), "/"); + let _ = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + + let rendered = app.metrics.render(); + assert!(rendered.contains("# TYPE oagw_requests_total counter")); + assert!(rendered.contains("http.request.method=\"GET\"")); + assert!(rendered.contains("http.response.status_code=\"2xx\"")); +} + +#[tokio::test] +async fn the_metrics_endpoint_serves_prometheus_text() { + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let _ = proxy(&mut app, "GET", "/oagw/v1/proxy/ghost").await; + let request = proxy_request( + "GET", + "/oagw/v1/metrics", + caller(TENANT).expect("context"), + &[], + ) + .expect("request builds"); + let response = app.send(request).await.expect("infallible"); + assert_eq!(response.status(), http::StatusCode::OK); + assert!( + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .starts_with("text/plain") + ); + let text = body(response).await.expect("body reads"); + assert!(text.contains("# TYPE oagw_requests_total counter")); +} + +#[tokio::test] +async fn failed_proxies_are_counted_as_errors() { + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let _ = proxy(&mut app, "GET", "/oagw/v1/proxy/ghost").await; + let rendered = app.metrics.render(); + assert!(rendered.contains("# TYPE oagw_errors_total counter")); + assert!( + rendered.contains("error_type=\"gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1\"") + ); +} + +// -- streaming -------------------------------------------------------------- + +#[tokio::test] +async fn a_server_sent_event_stream_arrives_incrementally() { + let peer = chunked_upstream().await; + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream("orders", peer.port()); + draft.server.endpoints[0].port = peer.port(); + seed(&app, draft, "/"); + + let request = proxy_request( + "GET", + "/oagw/v1/proxy/orders", + caller(TENANT).expect("context"), + &[], + ) + .expect("request builds"); + let started = Instant::now(); + let response = app.send(request).await.expect("infallible"); + assert_eq!(response.status(), http::StatusCode::OK); + + let mut arrivals = Vec::new(); + let mut stream = response.into_body().into_data_stream(); + while let Some(frame) = stream.next().await { + let frame = frame.expect("frame reads"); + arrivals.push(( + started.elapsed(), + String::from_utf8_lossy(&frame).to_string(), + )); + } + assert_eq!(arrivals.len(), 3, "three chunks, not one buffered body"); + assert_eq!(arrivals[0].1, "data: one\n\n"); + assert_eq!(arrivals[1].1, "data: two\n\n"); + assert_eq!(arrivals[2].1, "data: three\n\n"); + // The last frame lands at least two inter-chunk gaps after the first, so + // the gateway really forwarded while the upstream was still writing. + assert!( + arrivals[2].0 - arrivals[0].0 >= Duration::from_millis(80), + "stream was buffered: {arrivals:?}" + ); +} + +/// Upstream that answers with a `chunked` SSE response, one event every 80 ms. +async fn chunked_upstream() -> std::net::SocketAddr { + use tokio::net::TcpListener; + let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accepts"); + let mut buffer = vec![0_u8; 8192]; + let _ = socket.read(&mut buffer).await; + socket + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\ + Transfer-Encoding: chunked\r\nConnection: close\r\n\r\n", + ) + .await + .expect("writes head"); + for event in ["data: one\n\n", "data: two\n\n", "data: three\n\n"] { + socket + .write_all(format!("{:x}\r\n{event}\r\n", event.len()).as_bytes()) + .await + .expect("writes chunk"); + socket.flush().await.expect("flushes"); + tokio::time::sleep(Duration::from_millis(80)).await; + } + socket + .write_all(b"0\r\n\r\n") + .await + .expect("writes terminator"); + }); + address +} + +// -- websocket -------------------------------------------------------------- + +#[tokio::test] +async fn a_websocket_upgrade_is_spliced_bidirectionally() { + let peer = echo_upstream().await; + let app = build_proxy_app_without_hierarchy(proxy_config(5)); + let mut draft = upstream("orders", peer.port()); + draft.server.endpoints[0].scheme = Scheme::Ws; + seed(&app, draft, "/"); + + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("binds"); + let address = listener.local_addr().expect("address"); + let router = app + .router + .clone() + .layer(axum::Extension(caller(TENANT).expect("context"))); + tokio::spawn(async move { + loop { + let Ok((socket, _)) = listener.accept().await else { + return; + }; + let io = hyper_util::rt::TokioIo::new(socket); + let service = hyper_util::service::TowerToHyperService::new(router.clone()); + tokio::spawn(async move { + let _ = hyper::server::conn::http1::Builder::new() + .serve_connection(io, service) + .with_upgrades() + .await; + }); + } + }); + + let socket = tokio::net::TcpStream::connect(address) + .await + .expect("connects"); + let (mut sender, connection) = hyper::client::conn::http1::Builder::new() + .handshake(hyper_util::rt::TokioIo::new(socket)) + .await + .expect("handshakes"); + tokio::spawn(async move { + let _ = connection.with_upgrades().await; + }); + let request = hyper::Request::builder() + .method("GET") + .uri(format!("http://{address}/oagw/v1/proxy/orders")) + .header("connection", "Upgrade") + .header("upgrade", "websocket") + .header("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ==") + .header("sec-websocket-version", "13") + .body(axum::body::Body::empty()) + .expect("request builds"); + let response = sender.send_request(request).await.expect("sends"); + assert_eq!(response.status(), hyper::StatusCode::SWITCHING_PROTOCOLS); + assert_eq!( + response + .headers() + .get("x-oagw-error-source") + .and_then(|value| value.to_str().ok()), + Some("upstream") + ); + + let upgraded = hyper::upgrade::on(response).await.expect("upgrades"); + let mut upgraded = hyper_util::rt::TokioIo::new(upgraded); + let greeting = websocket_frame(b"hello!"); + let mut frame = vec![0_u8; greeting.len()]; + tokio::time::timeout(Duration::from_secs(3), upgraded.read_exact(&mut frame)) + .await + .expect("read does not hang") + .expect("reads"); + assert_eq!(frame, greeting); + upgraded + .write_all(&websocket_frame(b"ping")) + .await + .expect("writes"); + let mut echoed = [0_u8; 7]; + let echoed_len = tokio::time::timeout(Duration::from_secs(3), upgraded.read(&mut echoed)) + .await + .expect("read does not hang") + .expect("reads"); + assert_eq!(&echoed[..echoed_len], &websocket_frame(b"ping")); +} + +/// A raw TCP server that completes a WebSocket handshake and echoes frames. +async fn echo_upstream() -> std::net::SocketAddr { + use tokio::net::TcpListener; + let listener = TcpListener::bind("127.0.0.1:0").await.expect("binds"); + let address = listener.local_addr().expect("address"); + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.expect("accepts"); + let mut buffer = vec![0_u8; 8192]; + let read = socket.read(&mut buffer).await.expect("reads"); + let request = String::from_utf8_lossy(&buffer[..read]).to_string(); + let key = request + .lines() + .find_map(|line| line.split_once(": ")) + .filter(|(name, _)| name.eq_ignore_ascii_case("sec-websocket-key")) + .map(|(_, value)| value.trim().to_owned()) + .unwrap_or_default(); + let accept = websocket_accept(&key); + socket + .write_all( + format!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\ + Connection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n" + ) + .as_bytes(), + ) + .await + .expect("writes handshake"); + // Greet, then echo whatever the client sends, one frame at a time. + socket + .write_all(&websocket_frame(b"hello!")) + .await + .expect("writes greeting"); + loop { + let mut header = [0_u8; 2]; + match socket.read_exact(&mut header).await { + Ok(_) => {} + Err(_) => return, + } + let length = usize::from(header[1] & 0x7f); + let mut payload = vec![0_u8; length]; + if socket.read_exact(&mut payload).await.is_err() { + return; + } + if socket.write_all(&websocket_frame(&payload)).await.is_err() { + return; + } + } + }); + address +} + +/// Minimal unmasked WebSocket frame carrying `payload`. +fn websocket_frame(payload: &[u8]) -> Vec { + let mut frame = vec![0x81, u8::try_from(payload.len()).unwrap_or(127)]; + frame.extend_from_slice(payload); + frame +} + +/// `base64(sha1(key + GUID))` of a WebSocket handshake key. +fn websocket_accept(key: &str) -> String { + const GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + let mixed = format!("{key}{GUID}"); + let mut digest = [0_u8; 20]; + sha1(mixed.as_bytes(), &mut digest); + base64(&digest) +} + +/// SHA-1 of `input`, written into `digest` (20 bytes). +fn sha1(input: &[u8], digest: &mut [u8; 20]) { + let mut h: [u32; 5] = [ + 0x6745_2301, + 0xEFCD_AB89, + 0x98BA_DCFE, + 0x1032_5476, + 0xC3D2_E1F0, + ]; + let mut message = input.to_vec(); + let bit_length = u64::try_from(message.len()).unwrap_or(0) * 8; + message.push(0x80); + while message.len() % 64 != 56 { + message.push(0); + } + message.extend_from_slice(&bit_length.to_be_bytes()); + for block in message.chunks(64) { + let mut words = [0_u32; 80]; + for (index, word) in block.chunks(4).enumerate() { + words[index] = u32::from_be_bytes([word[0], word[1], word[2], word[3]]); + } + for index in 16..80 { + let value = words[index - 3] ^ words[index - 8] ^ words[index - 14] ^ words[index - 16]; + words[index] = value.rotate_left(1); + } + let (mut a, mut b, mut c, mut d, mut e) = (h[0], h[1], h[2], h[3], h[4]); + for (index, word) in words.iter().enumerate() { + let (f, k) = match index { + 0..=19 => ((b & c) | ((!b) & d), 0x5A82_7999_u32), + 20..=39 => (b ^ c ^ d, 0x6ED9_EBA1), + 40..=59 => ((b & c) | (b & d) | (c & d), 0x8F1B_BCDC), + _ => (b ^ c ^ d, 0xCA62_C1D6), + }; + let temp = a + .rotate_left(5) + .wrapping_add(f) + .wrapping_add(e) + .wrapping_add(k) + .wrapping_add(*word); + e = d; + d = c; + c = b.rotate_left(30); + b = a; + a = temp; + } + h[0] = h[0].wrapping_add(a); + h[1] = h[1].wrapping_add(b); + h[2] = h[2].wrapping_add(c); + h[3] = h[3].wrapping_add(d); + h[4] = h[4].wrapping_add(e); + } + for (slot, word) in digest.chunks_mut(4).zip(h.iter()) { + for (index, byte) in word.to_be_bytes().iter().enumerate() { + slot[index] = *byte; + } + } +} + +/// Standard base64 of `input`, padded with `=`. +fn base64(input: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut rendered = String::with_capacity(input.len().div_ceil(3) * 4); + for chunk in input.chunks(3) { + let triple = [ + chunk.first().copied().unwrap_or_default(), + chunk.get(1).copied().unwrap_or_default(), + chunk.get(2).copied().unwrap_or_default(), + ]; + let group = u32::from(triple[0]) << 16 | u32::from(triple[1]) << 8 | u32::from(triple[2]); + rendered.push(char::from(ALPHABET[((group >> 18) & 0x3f) as usize])); + rendered.push(char::from(ALPHABET[((group >> 12) & 0x3f) as usize])); + rendered.push(if chunk.len() > 1 { + char::from(ALPHABET[((group >> 6) & 0x3f) as usize]) + } else { + '=' + }); + rendered.push(if chunk.len() > 2 { + char::from(ALPHABET[(group & 0x3f) as usize]) + } else { + '=' + }); + } + rendered +} + +// -- disabled plugin resources ---------------------------------------------- + +/// Sends a management request to the proxy router and returns `(status, body)`. +/// +/// The management routes are mounted on the same router as the proxy surface, so +/// a test can register a plugin and then proxy to the upstream it is bound to. +async fn manage( + app: &mut ProxyApp, + req: Result, axum::http::Error>, +) -> (http::StatusCode, serde_json::Value) { + let response = app + .send(req.expect("request builds")) + .await + .expect("infallible"); + let status = response.status(); + let text = body(response).await.expect("body reads"); + let parsed = if text.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_str(&text).expect("body is JSON") + }; + (status, parsed) +} + +/// The upstream draft of the test upstream, pointing at `port` over HTTP. +fn orders_upstream(port: u16) -> serde_json::Value { + json!({ + "alias": "orders", + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "server": {"endpoints": [ + {"scheme": "http", "host": "127.0.0.1", "port": port} + ]}, + }) +} + +/// A custom plugin draft of the calling tenant. +fn plugin_draft(enabled: bool) -> serde_json::Value { + json!({ + "plugin_type": "gts.cf.core.oagw.guard_plugin.v1", + "config": {"headers": ["x-request-id"]}, + "enabled": enabled, + }) +} + +#[tokio::test] +async fn an_enabled_plugin_resource_is_a_503_and_a_disabled_one_is_skipped() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/orders"); + then.status(200).body("orders"); + }); + let mut app = build_proxy_app_without_hierarchy(OagwConfig { + proxy_timeout_secs: 2, + allow_http_upstream: true, + ..OagwConfig::default() + }); + let caller = caller(TENANT).expect("context"); + + let (status, created) = manage( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + caller.clone(), + Some(&orders_upstream(server.port()).to_string()), + ), + ) + .await; + assert_eq!(status, http::StatusCode::CREATED, "{created}"); + let upstream_id = created["id"].as_str().expect("id").to_owned(); + + let (status, route) = manage( + &mut app, + request( + "POST", + "/oagw/v1/routes", + caller.clone(), + Some( + &json!({ + "upstream_id": upstream_id, + "match": {"http": { + "methods": ["GET"], + "path": "/", + "path_suffix_mode": "append" + }}, + "headers": {}, + "plugins": {}, + }) + .to_string(), + ), + ), + ) + .await; + assert_eq!(status, http::StatusCode::CREATED, "{route}"); + + // An enabled custom plugin bound on the upstream: its Starlark runtime is + // not deployed in this process, so the request fails loudly with a 503. + let (status, plugin) = manage( + &mut app, + request( + "POST", + "/oagw/v1/plugins", + caller.clone(), + Some(&plugin_draft(true).to_string()), + ), + ) + .await; + assert_eq!(status, http::StatusCode::CREATED, "{plugin}"); + assert_eq!(plugin["enabled"], json!(true)); + let enabled_id = plugin["id"].as_str().expect("id").to_owned(); + + let mut draft = orders_upstream(server.port()); + draft["plugins"] = json!({"items": [enabled_id]}); + let (status, stored) = manage( + &mut app, + request( + "PUT", + &format!("/oagw/v1/upstreams/{upstream_id}"), + caller.clone(), + Some(&draft.to_string()), + ), + ) + .await; + assert_eq!(status, http::StatusCode::OK, "{stored}"); + assert_eq!(stored["plugins"]["items"][0], json!(enabled_id), "{stored}"); + + let (status, headers, payload) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders/orders").await; + assert_eq!(status, http::StatusCode::SERVICE_UNAVAILABLE, "{payload:?}"); + assert_eq!( + headers + .get("x-oagw-error-source") + .and_then(|value| value.to_str().ok()), + Some("gateway") + ); + let problem: serde_json::Value = serde_json::from_slice(&payload).expect("problem document"); + assert_eq!( + problem["type"], + json!("gts.cf.core.errors.err.v1~cf.oagw.plugin.not_found.v1"), + "{problem}" + ); + assert_eq!( + problem["context"]["plugin_id"], + json!(enabled_id), + "{problem}" + ); + + // Plugins are immutable, so disabling means registering a new plugin + // resource and re-binding the upstream to it. + let (status, disabled) = manage( + &mut app, + request( + "POST", + "/oagw/v1/plugins", + caller.clone(), + Some(&plugin_draft(false).to_string()), + ), + ) + .await; + assert_eq!(status, http::StatusCode::CREATED, "{disabled}"); + assert_eq!(disabled["enabled"], json!(false)); + let disabled_id = disabled["id"].as_str().expect("id").to_owned(); + + // The disabled plugin is bound in its GTS spelling, so both wire forms of a + // reference are covered by this test. + let mut draft = orders_upstream(server.port()); + draft["plugins"] = json!({"items": [format!("gts.cf.core.oagw.plugin.v1~{disabled_id}")]}); + let (status, stored) = manage( + &mut app, + request( + "PUT", + &format!("/oagw/v1/upstreams/{upstream_id}"), + caller.clone(), + Some(&draft.to_string()), + ), + ) + .await; + assert_eq!(status, http::StatusCode::OK, "{stored}"); + assert_eq!( + stored["plugins"]["items"][0], + json!(format!("gts.cf.core.oagw.plugin.v1~{disabled_id}")), + "{stored}" + ); + + let (status, _, payload) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders/orders").await; + assert_eq!(status, http::StatusCode::OK, "{}", body_string(&payload)); + assert_eq!(payload, Bytes::from_static(b"orders")); +} + +// -- metric cardinality (http.route) ----------------------------------------- + +/// Twenty callers hitting one route with twenty different path suffixes produce +/// exactly one `http.route` series — the configured pattern — and never a +/// series per client path. Prometheus has no way to drop a label value after +/// the fact, so the data plane must never label with the request. +#[tokio::test] +async fn twenty_request_paths_on_one_route_collapse_into_one_series() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET) + .path_matches(r"^/orders/customer-[0-9]+/items$"); + then.status(200).body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + seed(&app, upstream("orders", server.port()), "/orders"); + + for index in 0..20u32 { + let (status, _, _) = proxy( + &mut app, + "GET", + &format!("/oagw/v1/proxy/orders/orders/customer-{index}/items"), + ) + .await; + assert_eq!(status, http::StatusCode::OK, "suffix {index}"); + } + + let rendered = app.metrics.render(); + // The one and only series: the route pattern, not a request path. + assert!( + rendered.contains("http.route=\"GET /orders\""), + "{rendered}" + ); + let series = rendered + .lines() + .filter(|line| line.starts_with("oagw_requests_total{")) + .filter(|line| line.contains("http.route=")) + .count(); + assert_eq!(series, 1, "one route, one series: {rendered}"); + for index in 0..20u32 { + assert!( + !rendered.contains(&format!("customer-{index}")), + "a raw request path leaked into a label: {}", + rendered + ); + } +} + +/// A request that matched no route is labelled with the fixed literal +/// `unmatched`, never with the path or the alias the caller asked for. (The +/// `host` label keeps carrying the alias, as DESIGN §4.2 pins it.) +#[tokio::test] +async fn unmatched_requests_share_one_fixed_route_label() { + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + for host in ["ghost", "phantom", "spectre"] { + let (status, _, _) = proxy(&mut app, "GET", &format!("/oagw/v1/proxy/{host}")).await; + assert_eq!(status, http::StatusCode::NOT_FOUND); + } + let rendered = app.metrics.render(); + assert!(rendered.contains("http.route=\"unmatched\""), "{rendered}"); + // Every request series shares the one literal, whatever alias was asked + // for; only the (configured) host differs. + let routes: std::collections::BTreeSet = rendered + .lines() + .filter(|line| line.starts_with("oagw_requests_total{")) + .filter_map(|line| { + let start = line.find("http.route=\"")? + "http.route=\"".len(); + let end = line[start..].find('"')? + start; + Some(line[start..end].to_owned()) + }) + .collect(); + assert_eq!( + routes, + std::iter::once("unmatched".to_owned()).collect(), + "an unmatched request must not be labelled with the request: {rendered}" + ); +} + +// -- rate-limit key and bucket lifetime -------------------------------------- + +/// Builds a proxy request carrying `X-Forwarded-For` and the given peer socket +/// address, exactly as the axum listener would deliver it. +fn proxied_request( + method: &'static str, + path: &str, + headers: &[(&'static str, &str)], + peer: std::net::SocketAddr, +) -> axum::http::Request { + use axum::extract::ConnectInfo; + let mut request = proxy_request(method, path, caller(TENANT).expect("context"), headers) + .expect("request builds"); + request.extensions_mut().insert(ConnectInfo(peer)); + request +} + +/// A rate limit scoped to the client IP is keyed on the *peer socket address*: +/// two requests from one connection that spoof different `X-Forwarded-For` +/// values spend one budget, so rotating the header cannot buy a fresh one. +#[tokio::test] +async fn spoofed_forwarded_headers_share_the_peer_budget() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream("orders", server.port()); + draft.rate_limit = Some(RateLimitConfig { + sharing: oagw::domain::model::SharingMode::Private, + algorithm: RateLimitAlgorithm::SlidingWindow, + sustained: SustainedRateConfig { + rate: 1, + window: RateLimitWindow::Minute, + }, + burst: Some(BurstConfig { capacity: Some(1) }), + scope: RateLimitScope::Ip, + strategy: RateLimitStrategy::Reject, + response_headers: true, + cost: 1, + }); + seed(&app, draft, "/"); + + let peer = std::net::SocketAddr::from(([203, 0, 113, 7], 44_321)); + let send = async |app: &mut ProxyApp, forwarded: &'static str| { + let request = proxied_request( + "GET", + "/oagw/v1/proxy/orders", + &[("x-forwarded-for", forwarded)], + peer, + ); + app.send(request).await.expect("infallible").status() + }; + + // One connection, three claimed identities: one budget. + assert_eq!(send(&mut app, "198.51.100.1").await, http::StatusCode::OK); + assert_eq!( + send(&mut app, "198.51.100.2").await, + http::StatusCode::TOO_MANY_REQUESTS + ); + assert_eq!( + send(&mut app, "198.51.100.3, 198.51.100.4").await, + http::StatusCode::TOO_MANY_REQUESTS + ); + + // A genuinely different peer still has its own budget. + let other = std::net::SocketAddr::from(([203, 0, 113, 8], 44_321)); + let request = proxied_request("GET", "/oagw/v1/proxy/orders", &[], other); + let status = app.send(request).await.expect("infallible").status(); + assert_eq!( + status, + http::StatusCode::OK, + "a second peer has its own budget" + ); +} + +/// Deleting an upstream drops its buckets, so a recreated upstream on the same +/// alias starts from an empty budget instead of inheriting the spent one. +#[tokio::test] +async fn a_recreated_upstream_starts_with_a_fresh_rate_budget() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(2)); + let mut draft = upstream("orders", server.port()); + draft.rate_limit = Some(RateLimitConfig { + sharing: oagw::domain::model::SharingMode::Private, + algorithm: RateLimitAlgorithm::SlidingWindow, + sustained: SustainedRateConfig { + rate: 1, + window: RateLimitWindow::Minute, + }, + burst: Some(BurstConfig { capacity: Some(1) }), + scope: RateLimitScope::Tenant, + strategy: RateLimitStrategy::Reject, + response_headers: true, + cost: 1, + }); + let upstream_id = seed(&app, draft, "/"); + + let (first, _, _) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + assert_eq!(first, http::StatusCode::OK); + let (second, _, _) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + assert_eq!(second, http::StatusCode::TOO_MANY_REQUESTS); + + // Delete through the management surface, then recreate the same alias. + assert!(app.service.store().delete_upstream(TENANT, upstream_id)); + let recreated = app + .service + .store() + .insert_upstream(upstream("orders", server.port())) + .expect("recreated"); + app.service + .store() + .insert_route(route(recreated.id, "/", HttpMethod::Get)) + .expect("route seeds"); + + let (third, _, _) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + assert_eq!( + third, + http::StatusCode::OK, + "the recreated upstream must not inherit the spent budget" + ); +} + +/// A `queue` strategy serves the request that a `reject` strategy would have +/// refused: the second request waits for its reserved token (bounded by +/// `QUEUE_MAX_WAIT`) and is then forwarded, so both calls reach the upstream. +#[tokio::test] +async fn a_queue_strategy_serves_the_request_a_reject_strategy_refuses() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/"); + then.status(200).body("ok"); + }); + let mut app = build_proxy_app_without_hierarchy(proxy_config(5)); + let mut draft = upstream("orders", server.port()); + draft.rate_limit = Some(RateLimitConfig { + sharing: oagw::domain::model::SharingMode::Private, + algorithm: RateLimitAlgorithm::TokenBucket, + sustained: SustainedRateConfig { + rate: 2, + window: RateLimitWindow::Second, + }, + burst: Some(BurstConfig { capacity: Some(1) }), + scope: RateLimitScope::Tenant, + strategy: RateLimitStrategy::Queue, + response_headers: true, + cost: 1, + }); + seed(&app, draft, "/"); + + let started = Instant::now(); + let (first, _, _) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + let (second, _, _) = proxy(&mut app, "GET", "/oagw/v1/proxy/orders").await; + let elapsed = started.elapsed(); + + assert_eq!(first, http::StatusCode::OK); + assert_eq!( + second, + http::StatusCode::OK, + "a queued request is served, not refused" + ); + // The second call spent its reserved wait (half a second at 2 tokens/s), + // which stays under the limiter's own bound. + assert!( + elapsed >= Duration::from_millis(400), + "the second request did not wait: {elapsed:?}" + ); + assert!( + elapsed < Duration::from_secs(3), + "the wait is bounded, not unbounded: {elapsed:?}" + ); +} + +// -- route validation -------------------------------------------------------- + +/// A route path that is not rooted is rejected at management time, so it can +/// never be stored and then silently fail to match anything. +#[tokio::test] +async fn a_route_path_without_a_leading_slash_is_rejected() { + let server = MockServer::start(); + let mut app = build_proxy_app_without_hierarchy(OagwConfig { + proxy_timeout_secs: 2, + allow_http_upstream: true, + ..OagwConfig::default() + }); + let caller = caller(TENANT).expect("context"); + let (status, created) = manage( + &mut app, + request( + "POST", + "/oagw/v1/upstreams", + caller.clone(), + Some(&orders_upstream(server.port()).to_string()), + ), + ) + .await; + assert_eq!(status, http::StatusCode::CREATED, "{created}"); + let upstream_id = created["id"].as_str().expect("id").to_owned(); + + let (status, problem) = manage( + &mut app, + request( + "POST", + "/oagw/v1/routes", + caller, + Some( + &json!({ + "upstream_id": upstream_id, + "match": {"http": { + "methods": ["GET"], + "path": "orders", + "path_suffix_mode": "append" + }}, + }) + .to_string(), + ), + ), + ) + .await; + assert_eq!(status, http::StatusCode::BAD_REQUEST, "{problem}"); + assert_eq!( + problem["type"], + json!("gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1"), + "{problem}" + ); + assert_eq!(problem["context"]["path"], json!("orders"), "{problem}"); +} + +// -- helpers ---------------------------------------------------------------- + +/// Reads a body back as a string. +fn body_string(payload: &Bytes) -> String { + String::from_utf8_lossy(payload).to_string() +} + +/// A CORS section allowing exactly `https://portal.example.com`. +fn cors_config() -> CorsConfig { + CorsConfig { + enabled: true, + sharing: oagw::domain::model::SharingMode::Private, + allowed_origins: vec!["https://portal.example.com".to_owned()], + allowed_methods: vec![CorsMethod::Get], + allow_headers: vec!["content-type".to_owned()], + expose_headers: Vec::new(), + allow_credentials: false, + max_age: None, + } +} + +/// A route matching `method path` with `append` suffix handling. +fn route(upstream_id: Uuid, path: &str, method: HttpMethod) -> Route { + Route { + id: Uuid::new_v4(), + upstream_id, + r#match: RouteMatch { + http: Some(HttpMatch { + methods: vec![method], + path: path.to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + headers: HeadersConfig::default(), + plugins: Default::default(), + rate_limit: None, + cors: None, + enabled: true, + priority: 0, + tags: Vec::new(), + tenant_id: TENANT, + created_at: std::time::SystemTime::UNIX_EPOCH, + updated_at: std::time::SystemTime::UNIX_EPOCH, + } +}