diff --git a/Cargo.lock b/Cargo.lock index 9c02857..8560839 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1598,6 +1598,7 @@ dependencies = [ "tokio", "tokio-retry", "tokio-rustls", + "tokio-tungstenite", "tower", "tracing", "url", diff --git a/gears/system/oagw/oagw/Cargo.toml b/gears/system/oagw/oagw/Cargo.toml index a18b934..ba7ee49 100644 --- a/gears/system/oagw/oagw/Cargo.toml +++ b/gears/system/oagw/oagw/Cargo.toml @@ -42,7 +42,7 @@ toolkit-security = { workspace = true } toolkit-macros = { workspace = true } inventory = { workspace = true } async-trait = { workspace = true } -axum = { workspace = true } +axum = { workspace = true, features = ["ws"] } http = { workspace = true } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } @@ -79,6 +79,8 @@ tokio = { workspace = true, features = ["time"] } tokio-retry = { workspace = true } hyper = { workspace = true } hyper-util = { workspace = true } +# WebSocket upstream leg (frozen contract permits this single added dependency) +tokio-tungstenite = "0.29" # Pingora proxy engine pingora-proxy = { version = "0.8", features = ["rustls"] } pingora-core = { version = "0.8", features = ["rustls"] } diff --git a/gears/system/oagw/oagw/IMPLEMENTATION-NOTES.md b/gears/system/oagw/oagw/IMPLEMENTATION-NOTES.md new file mode 100644 index 0000000..f4fcb49 --- /dev/null +++ b/gears/system/oagw/oagw/IMPLEMENTATION-NOTES.md @@ -0,0 +1,52 @@ +# Implementation Notes + +Deviations and platform limitations observed while implementing the `oagw` gear +against `gears/system/oagw/docs/`. The documents remain the contract; everything +below is a place where the contract could not be met as written, with the reason +and the fail-safe behaviour that stands in. + +## Forced deviations + +| Area | Contract | Actual | Reason | +|---|---|---|---| +| WebTransport upstreams | `wt://` scheme in `upstream.v1` | Configured, but every proxied request answers `503 LinkUnavailable` | No WebTransport/QUIC client is in the workspace lockfile and the build is offline; failing closed keeps an unconfigured transport from silently degrading to HTTP | +| WebSocket over TLS (`wss://`) | `wss://` endpoints should upgrade | `wss://` upstreams answer `502 LinkUnavailable` at connect time | The locked `tokio-tungstenite` has no TLS feature enabled; `ws://` upgrades work end to end | +| `ConnectionTimeout` / `IdleTimeout` error types | Distinct `504` types | Both surface as `504 RequestTimeout` | The toolkit transport exposes one per-request timeout and no separate connect/idle budget, so the distinction is not observable | +| `connect_timeout_secs` knob | TCP connect timeout for the upstream leg | Accepted in configuration, not applied | `toolkit-http::HttpClientConfig` has no connect-timeout field; only the request timeout can be set | + +## Deliberate deferrals (documented, not missing) + +- **Config caching (ADR-0005, DESIGN §4.1)**: explicitly deferred by the design to + a future consideration. The `hot_cache_capacity` knob is accepted and retained + for that layer, but no L1 cache is built — the control plane is already + in-memory at this milestone, so a cache would front nothing. +- **Upstream-health and connection-pool gauges** (DESIGN §4.2): not instrumented; + the toolkit transport does not expose pool counters. Breaker state, transitions, + requests, durations, errors, rate-limit rejections and routing selections are. +- **Fine-grained authorization** (DESIGN "Authentication & Authorization" table): + the platform's gear API exposes only authenticated/anonymous/public gates and no + per-permission registration, so all seventeen oagw operations are registered + `.authenticated()`. The permission strings in the design are not enforceable + from a gear with the current `toolkit-security` surface. + +## Security posture worth restating + +- Credentials, secrets, request/response bodies, query strings and header values + never reach logs, audit events, metrics labels or problem documents. The audit + event field set is closed (see `src/infra/audit.rs`) and the error table's + `detail` strings are the only user-controlled text echoed back. +- Upstream URLs are HTTPS-only unless `allow_http_upstream` is explicitly `true`; + the SSRF guard refuses private, loopback, link-local and unique-local targets + unless `ssrf_policy.allow_private_addresses` is `true`. +- Rate-limit counters are keyed so that a missing client identity falls back to + the tenant rather than collapsing into one global bucket. +- The apikey plugin's query delivery mode appends the credential *after* the + route's query allowlist filter; caller parameters remain subject to it. + +## Testing + +Automated tests live in this crate (`tests/`, plus unit tests beside the code) +and cover the management CRUD surface, alias resolution and shadowing, config +merge semantics, the plugin chain order and rejection statuses, rate limiting, +circuit breaking, CORS preflight, SSRF and protocol policy, plain HTTP proxying, +server-sent-event streaming and WebSocket upgrades. 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..69e5d37 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/mod.rs @@ -0,0 +1,4 @@ +// Created: 2026-08-29 by Constructor Tech +//! Transport layer. + +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..65abaca --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/dto.rs @@ -0,0 +1,344 @@ +// Created: 2026-08-29 by Constructor Tech +//! REST DTOs. +//! +//! The wire shapes are the domain shapes (`docs/schemas/*.json`); DTOs exist to +//! attach OpenAPI metadata and to keep transport concerns out of the domain. + +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use utoipa::openapi::RefOr; +use utoipa::openapi::schema::{ArrayBuilder, ObjectBuilder, Ref, Schema, SchemaType, Type}; + +use crate::domain::model::{ + AuthConfig, CorsConfig, HeadersConfig, MatchConfig, PluginCreate, PluginDefinition, + PluginsConfig, RateLimitConfig, Route, RouteCreate, ServerConfig, Upstream, UpstreamCreate, +}; + +/// Query parameters accepted by the management list endpoints. +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ListQuery { + /// OData filter expression. + #[serde(rename = "$filter", default, skip_serializing_if = "Option::is_none")] + pub filter: Option, + /// Fields to return. + #[serde(rename = "$select", default, skip_serializing_if = "Option::is_none")] + pub select: Option, + /// Sort order, e.g. `created_at desc`. + #[serde(rename = "$orderby", default, skip_serializing_if = "Option::is_none")] + pub orderby: Option, + /// Page size (default 50, max 100). + #[serde(rename = "$top", default, skip_serializing_if = "Option::is_none")] + pub top: Option, + /// Page offset. + #[serde(rename = "$skip", default, skip_serializing_if = "Option::is_none")] + pub skip: Option, +} + +/// Page size default (`$top`). +pub const DEFAULT_PAGE_SIZE: usize = 50; +/// Page size maximum (`$top`). +pub const MAX_PAGE_SIZE: usize = 100; + +impl ListQuery { + /// Effective page size, clamped to `MAX_PAGE_SIZE`. + #[must_use] + pub fn page_size(&self) -> usize { + self.top.unwrap_or(DEFAULT_PAGE_SIZE).min(MAX_PAGE_SIZE) + } + + /// Effective page offset. + #[must_use] + pub fn offset(&self) -> usize { + self.skip.unwrap_or_default() + } +} + +/// Inline schema for the `toolkit_odata::PageInfo` member of a list envelope. +fn page_info_schema() -> RefOr { + ObjectBuilder::new() + .property( + "next_cursor", + ObjectBuilder::new().schema_type(SchemaType::from_iter([Type::String, Type::Null])), + ) + .property( + "prev_cursor", + ObjectBuilder::new().schema_type(SchemaType::from_iter([Type::String, Type::Null])), + ) + .property("limit", ObjectBuilder::new().schema_type(Type::Integer)) + .required("limit") + .into() +} + +/// OpenAPI components for the paged list envelopes. +/// +/// The handlers return `toolkit_odata::Page` (`items` + `page_info`), the +/// house paged response. `toolkit-odata`'s `with-utoipa` feature is not enabled +/// for this crate, so `Page` itself has no `ToSchema` impl here; each list +/// response registers a locally named component describing the same shape. +macro_rules! paged_list_schema { + ($name:ident, $item:ty) => { + /// Schema component of a paged list response (no runtime payload of its own). + #[derive(Debug, Clone, Copy)] + pub struct $name; + + impl utoipa::PartialSchema for $name { + fn schema() -> RefOr { + ObjectBuilder::new() + .property( + "items", + ArrayBuilder::new().items(Ref::from_schema_name( + <$item as ToSchema>::name().to_string(), + )), + ) + .required("items") + .property("page_info", page_info_schema()) + .required("page_info") + .into() + } + } + + impl ToSchema for $name { + fn name() -> std::borrow::Cow<'static, str> { + std::borrow::Cow::Borrowed(stringify!($name)) + } + } + + impl toolkit::api::api_dto::ResponseApiDto for $name {} + }; +} + +paged_list_schema!(UpstreamList, UpstreamDto); +paged_list_schema!(RouteList, RouteDto); +paged_list_schema!(PluginList, PluginDto); + +/// Upstream wire DTO. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct UpstreamDto { + /// System-generated id. + pub id: uuid::Uuid, + /// Owning tenant. + pub tenant_id: uuid::Uuid, + /// `true` when the upstream accepts traffic. + pub enabled: bool, + /// Normalized routing key. + pub alias: String, + /// Discovery tags. + pub tags: Vec, + /// Endpoint pool. + pub server: ServerConfig, + /// Protocol GTS identifier. + pub protocol: String, + /// Auth plugin binding. + #[serde(skip_serializing_if = "Option::is_none")] + pub auth: Option, + /// Header transformation rules. + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option, + /// Plugin chain. + #[serde(skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limits. + #[serde(skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub cors: Option, + /// Creation timestamp. + pub created_at: String, + /// Last update timestamp. + pub updated_at: String, +} + +impl From for UpstreamDto { + fn from(upstream: Upstream) -> Self { + Self { + id: upstream.id, + tenant_id: upstream.tenant_id, + enabled: upstream.spec.enabled, + alias: upstream.alias, + tags: upstream.spec.tags, + server: upstream.spec.server, + protocol: upstream.spec.protocol, + auth: upstream.spec.auth, + headers: upstream.spec.headers, + plugins: upstream.spec.plugins, + rate_limit: upstream.spec.rate_limit, + cors: upstream.spec.cors, + created_at: upstream.created_at, + updated_at: upstream.updated_at, + } + } +} + +impl From<&Upstream> for UpstreamDto { + fn from(upstream: &Upstream) -> Self { + UpstreamDto::from(upstream.clone()) + } +} + +/// Route wire DTO. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct RouteDto { + /// System-generated id. + pub id: uuid::Uuid, + /// Owning tenant. + pub tenant_id: uuid::Uuid, + /// Discovery tags. + pub tags: Vec, + /// Owning upstream. + pub upstream_id: uuid::Uuid, + /// `true` when the route participates in matching. + pub enabled: bool, + /// Protocol-scoped match rules. + #[serde(rename = "match")] + pub match_config: MatchConfig, + /// Plugin chain. + #[serde(skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limits. + #[serde(skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS configuration. + #[serde(skip_serializing_if = "Option::is_none")] + pub cors: Option, + /// Creation timestamp. + pub created_at: String, + /// Last update timestamp. + pub updated_at: String, +} + +impl From for RouteDto { + fn from(route: Route) -> Self { + Self { + id: route.id, + tenant_id: route.tenant_id, + tags: route.spec.tags, + upstream_id: route.spec.upstream_id, + enabled: route.spec.enabled, + match_config: route.spec.match_config, + plugins: route.spec.plugins, + rate_limit: route.spec.rate_limit, + cors: route.spec.cors, + created_at: route.created_at, + updated_at: route.updated_at, + } + } +} + +impl From<&Route> for RouteDto { + fn from(route: &Route) -> Self { + RouteDto::from(route.clone()) + } +} + +/// Plugin wire DTO. +#[derive(Debug, Clone, Serialize, ToSchema)] +pub struct PluginDto { + /// System-generated id. + pub id: uuid::Uuid, + /// Owning tenant. + pub tenant_id: uuid::Uuid, + /// `auth_plugin` | `guard_plugin` | `transform_plugin`. + pub plugin_type: String, + /// Human readable name. + pub name: String, + /// Optional JSON schema for the plugin config. + #[serde(skip_serializing_if = "Option::is_none")] + pub config_schema: Option, + /// Creation timestamp. + pub created_at: String, + /// Last update timestamp. + pub updated_at: String, +} + +impl From for PluginDto { + fn from(definition: PluginDefinition) -> Self { + Self { + id: definition.id, + tenant_id: definition.tenant_id, + plugin_type: definition.plugin_type, + name: definition.name, + config_schema: definition.config_schema, + created_at: definition.created_at, + updated_at: definition.updated_at, + } + } +} + +impl From<&PluginDefinition> for PluginDto { + fn from(definition: &PluginDefinition) -> Self { + PluginDto::from(definition.clone()) + } +} + +/// Request body for `POST /oagw/v1/upstreams`. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct CreateUpstreamBody(pub UpstreamCreate); + +/// Request body for `PUT /oagw/v1/upstreams/{id}`. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct ReplaceUpstreamBody(pub UpstreamCreate); + +/// Request body for `POST /oagw/v1/routes`. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct CreateRouteBody(pub RouteCreate); + +/// Request body for `PUT /oagw/v1/routes/{id}`. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct ReplaceRouteBody(pub RouteCreate); + +/// Request body for `POST /oagw/v1/plugins`. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct CreatePluginBody(pub PluginCreate); + +/// Request body for the enable / disable endpoints. +#[derive(Debug, Clone, Deserialize, ToSchema)] +pub struct EnabledBody { + /// Desired enabled flag. + pub enabled: bool, +} + +/// OpenAPI schema shims for the domain wire shapes. +/// +/// The domain model is the wire model (`docs/schemas/*.json`) and it does not +/// carry `utoipa` derives; the management document therefore describes these +/// blocks as free-form JSON objects. Request and response validation stays with +/// serde plus the domain validators, which is strictly stronger than the schema. +macro_rules! schema_as_any_value { + ($($t:ty),+ $(,)?) => { + $( + impl utoipa::PartialSchema for $t { + fn schema() -> RefOr { + ::schema() + } + } + + impl utoipa::ToSchema for $t {} + )+ + }; +} + +schema_as_any_value!( + crate::domain::model::ServerConfig, + crate::domain::model::AuthConfig, + crate::domain::model::HeadersConfig, + crate::domain::model::PluginsConfig, + crate::domain::model::RateLimitConfig, + crate::domain::model::CorsConfig, + crate::domain::model::MatchConfig, + crate::domain::model::HttpMatch, + crate::domain::model::GrpcMatch, + crate::domain::model::UpstreamCreate, + crate::domain::model::RouteCreate, + crate::domain::model::PluginCreate, +); + +impl toolkit::api::api_dto::ResponseApiDto for UpstreamDto {} +impl toolkit::api::api_dto::ResponseApiDto for RouteDto {} +impl toolkit::api::api_dto::ResponseApiDto for PluginDto {} +impl toolkit::api::api_dto::RequestApiDto for CreateUpstreamBody {} +impl toolkit::api::api_dto::RequestApiDto for ReplaceUpstreamBody {} +impl toolkit::api::api_dto::RequestApiDto for CreateRouteBody {} +impl toolkit::api::api_dto::RequestApiDto for ReplaceRouteBody {} +impl toolkit::api::api_dto::RequestApiDto for CreatePluginBody {} +impl toolkit::api::api_dto::RequestApiDto for EnabledBody {} diff --git a/gears/system/oagw/oagw/src/api/rest/error.rs b/gears/system/oagw/oagw/src/api/rest/error.rs new file mode 100644 index 0000000..6814c2a --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/error.rs @@ -0,0 +1,284 @@ +// Created: 2026-08-29 by Constructor Tech +//! Wire error mapping: RFC 9457 problem documents plus the +//! `X-OAGW-Error-Source` header (ADR-0007). + +use axum::http::{HeaderMap, StatusCode, header}; +use axum::response::{IntoResponse, Response}; +use serde_json::json; + +use crate::domain::error::{OagwError, error_type}; + +/// Gateway-originated error, ready for the wire. +/// +/// Wraps the domain error with the request-scoped problem members and the +/// routing context needed by the problem extensions. The members live behind a +/// single allocation so the error stays cheap to move through handler +/// `Result`s. +#[derive(Debug, Clone)] +pub struct ApiError { + /// Domain error. + pub kind: OagwError, + /// Request-scoped problem members. + pub context: Box, + /// `X-OAGW-Error-Source` override for errors reported on a live stream. + pub source: Option, +} + +/// Request-scoped members of a problem document. +#[derive(Debug, Clone, Default)] +pub struct ProblemContext { + /// RFC 9457 `instance` (request path). + pub instance: Option, + /// Distributed tracing correlation id. + pub trace_id: Option, + /// Upstream id the request was routed to. + pub upstream_id: Option, + /// Upstream host (alias) the request targeted. + pub host: Option, +} + +impl ApiError { + /// Wrap a domain error. + #[must_use] + pub fn new(kind: OagwError) -> Self { + Self { + kind, + context: Box::default(), + source: None, + } + } + + /// Set the RFC 9457 `instance`. + #[must_use] + pub fn with_instance(mut self, instance: impl Into) -> Self { + self.context.instance = Some(instance.into()); + self + } + + /// Set the `trace_id`. + #[must_use] + pub fn with_trace_id(mut self, trace_id: impl Into) -> Self { + self.context.trace_id = Some(trace_id.into()); + self + } + + /// Attach upstream routing context. + #[must_use] + pub fn with_upstream( + mut self, + upstream_id: impl Into, + host: impl Into, + ) -> Self { + self.context.upstream_id = Some(upstream_id.into()); + self.context.host = Some(host.into()); + self + } + + /// Set the error source header explicitly (used by the streaming paths). + #[must_use] + pub fn with_source_header(mut self, value: &str) -> Self { + if axum::http::HeaderValue::from_str(value).is_ok() { + self.source = Some(value.to_owned()); + } + self + } +} + +impl From for ApiError { + fn from(kind: OagwError) -> Self { + Self::new(kind) + } +} + +impl std::fmt::Display for ApiError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.kind) + } +} + +impl std::error::Error for ApiError {} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + let extras = self.kind.extras(); + let mut body = json!({ + "type": error_type(self.kind.type_suffix()), + "title": self.kind.title(), + "status": self.kind.status(), + "detail": self.kind.detail(), + }); + // DESIGN's error table publishes a Retriable column; surfacing it on + // the document spares clients a status-code table of their own. + body["retriable"] = json!(self.kind.retriable()); + if let Some(instance) = &self.context.instance { + body["instance"] = json!(instance); + body["path"] = json!(instance); + } + if let Some(trace_id) = &self.context.trace_id { + body["trace_id"] = json!(trace_id); + } + if let Some(upstream_id) = self + .context + .upstream_id + .as_ref() + .or(extras.upstream_id.as_ref()) + { + body["upstream_id"] = json!(upstream_id); + } + if let Some(host) = self.context.host.as_ref().or(extras.host.as_ref()) { + body["host"] = json!(host); + } + if let Some(retry) = extras.retry_after_seconds { + body["retry_after_seconds"] = json!(retry); + } + if let Some(valid_hosts) = extras.valid_hosts { + body["valid_hosts"] = json!(valid_hosts); + } + if let Some(alias) = extras.alias { + body["alias"] = json!(alias); + } + if let Some(invalid_value) = extras.invalid_value { + body["invalid_value"] = json!(invalid_value); + } + if let Some(references) = extras.referenced_by { + body["referenced_by"] = json!({ + "upstreams": references.upstreams, + "routes": references.routes, + }); + } + + let mut headers = HeaderMap::new(); + headers.insert( + header::CONTENT_TYPE, + header::HeaderValue::from_static("application/problem+json"), + ); + let source = self + .source + .and_then(|value| header::HeaderValue::from_str(&value).ok()) + .unwrap_or_else(|| header::HeaderValue::from_static("gateway")); + headers.insert( + header::HeaderName::from_static("x-oagw-error-source"), + source, + ); + if let Some(retry) = extras.retry_after_seconds + && let Ok(value) = header::HeaderValue::from_str(&retry.to_string()) + { + headers.insert(header::RETRY_AFTER, value); + } + + ( + StatusCode::from_u16(self.kind.status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + headers, + body.to_string(), + ) + .into_response() + } +} + +/// Extract a trace id from request headers, falling back to a generated UUID. +/// +/// Security: reads only the two correlation headers; no request body, query +/// parameter or other header value is ever logged. +#[must_use] +pub fn trace_id_from_headers(headers: &HeaderMap) -> String { + const CANDIDATES: [&str; 2] = ["x-request-id", "traceparent"]; + for candidate in CANDIDATES { + if let Some(value) = headers.get(candidate) + && let Ok(text) = value.to_str() + { + let trimmed = text.trim(); + let id = if candidate == "traceparent" { + trimmed.split('-').next_back().unwrap_or(trimmed) + } else { + trimmed + }; + if !id.is_empty() { + return id.to_owned(); + } + } + } + uuid::Uuid::new_v4().to_string() +} + +/// `true` for errors reported through an already-started stream. +#[must_use] +pub fn is_streaming_error(error: &OagwError) -> bool { + matches!(error, OagwError::StreamAborted(_)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rate_limit_error_sets_retry_after() { + let response = ApiError::new(OagwError::RateLimitExceeded(15)).into_response(); + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + let headers = response.headers(); + assert_eq!( + headers + .get("x-oagw-error-source") + .and_then(|v| v.to_str().ok()), + Some("gateway") + ); + assert_eq!( + headers.get("retry-after").and_then(|v| v.to_str().ok()), + Some("15") + ); + assert_eq!( + headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()), + Some("application/problem+json") + ); + } + + #[test] + fn type_suffix_is_gts_shaped() { + let error = OagwError::RouteNotFound("no route".to_owned()); + assert_eq!( + error_type(error.type_suffix()), + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); + } + + #[tokio::test] + async fn problem_body_has_extension_members() { + let response = ApiError::new(OagwError::MissingTargetHost { + valid_hosts: vec!["a.example.com".to_owned()], + alias: "vendor.com".to_owned(), + }) + .with_instance("/oagw/v1/proxy/vendor.com") + .into_response(); + let bytes = axum::body::to_bytes(response.into_body(), 64 * 1024) + .await + .expect("body"); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json"); + assert_eq!(value["status"], 400); + assert_eq!(value["valid_hosts"], json!(["a.example.com"])); + assert_eq!(value["alias"], "vendor.com"); + assert_eq!(value["instance"], "/oagw/v1/proxy/vendor.com"); + assert_eq!(value["path"], "/oagw/v1/proxy/vendor.com"); + } + + #[test] + fn retriable_matches_the_design_error_table() { + for (error, expected) in [ + (OagwError::RateLimitExceeded(1), true), + (OagwError::LinkUnavailable("x".to_owned()), true), + (OagwError::CircuitBreakerOpen, true), + (OagwError::Validation("x".to_owned()), false), + (OagwError::RouteNotFound("x".to_owned()), false), + (OagwError::PayloadTooLarge, false), + ] { + let response = ApiError::new(error).into_response(); + let bytes = tokio::runtime::Builder::new_current_thread() + .build() + .expect("runtime") + .block_on(axum::body::to_bytes(response.into_body(), 64 * 1024)) + .expect("body"); + let value: serde_json::Value = serde_json::from_slice(&bytes).expect("json"); + assert_eq!(value["retriable"], expected, "{}", value["detail"]); + } + } +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers/management.rs b/gears/system/oagw/oagw/src/api/rest/handlers/management.rs new file mode 100644 index 0000000..662d0f8 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/management.rs @@ -0,0 +1,328 @@ +// Created: 2026-08-29 by Constructor Tech +//! Management REST handlers: upstreams, routes and custom plugins. + +use std::sync::Arc; + +use axum::Extension; +use axum::Json; +use axum::extract::{Path, Query}; +use axum::http::StatusCode; +use axum::http::Uri; +use axum::response::IntoResponse; +use serde_json::Value; +use uuid::Uuid; + +use crate::api::rest::dto::{ListQuery, PluginDto, RouteDto, UpstreamDto}; +use crate::api::rest::error::ApiError; +use crate::api::rest::odata; +use crate::domain::error::OagwError; +use crate::domain::model::{PluginCreate, RouteCreate, UpstreamCreate}; +use crate::domain::services::management::ControlPlaneService; + +use super::{CtxExtension, Services, tenant_of}; + +type HandlerResult = Result; + +fn to_api(error: OagwError) -> ApiError { + ApiError::new(error) +} + +fn not_found(what: &str, id: Uuid) -> ApiError { + ApiError::new(OagwError::RouteNotFound(format!("{what} '{id}' not found"))) +} + +fn parse_body(result: Result) -> Result { + result.map_err(|error| ApiError::new(OagwError::Validation(error.to_string()))) +} + +fn serialize_page( + dtos: Vec, + query: &ListQuery, +) -> Result, ApiError> { + let items: Vec = dtos + .iter() + .map(|dto| serde_json::to_value(dto).unwrap_or_default()) + .collect(); + let (page, _total) = odata::apply_query_page(items, query).map_err(ApiError::new)?; + let page_size = u64::try_from(query.page_size()).unwrap_or_default(); + Ok(toolkit::Page::new( + page, + toolkit::PageInfo { + next_cursor: None, + prev_cursor: None, + limit: page_size, + }, + )) +} + +// --------------------------------------------------------------------------------------- +// Upstreams +// --------------------------------------------------------------------------------------- + +/// `POST /oagw/v1/upstreams` — create an upstream, 201 + `Location`. +pub async fn create_upstream( + uri: Uri, + Extension(services): Extension>, + ctx: CtxExtension, + body: Result, axum::extract::rejection::JsonRejection>, +) -> HandlerResult { + let spec = parse_body(body.map(|Json(value)| value))?; + let created = services + .control_plane + .create_upstream(tenant_of(&ctx), spec) + .map_err(to_api)?; + let location = format!("{}/{}", uri.path().trim_end_matches('/'), created.id); + Ok(( + StatusCode::CREATED, + [("location", location)], + axum::Json(UpstreamDto::from(created)), + )) +} + +/// `GET /oagw/v1/upstreams` — list with OData paging. +pub async fn list_upstreams( + Extension(services): Extension>, + ctx: CtxExtension, + Query(query): Query, +) -> HandlerResult { + let dtos: Vec = services + .control_plane + .list_upstreams(tenant_of(&ctx)) + .into_iter() + .map(Into::into) + .collect(); + Ok(axum::Json(serialize_page(dtos, &query)?)) +} + +/// `GET /oagw/v1/upstreams/{id}` — 200 / 404. +pub async fn get_upstream( + Extension(services): Extension>, + ctx: CtxExtension, + Path(id): Path, +) -> HandlerResult { + let upstream = services + .control_plane + .get_upstream(tenant_of(&ctx), id) + .ok_or_else(|| not_found("upstream", id))?; + Ok(axum::Json(UpstreamDto::from(upstream))) +} + +/// `PUT /oagw/v1/upstreams/{id}` — full replace, alias immutable. +pub async fn replace_upstream( + Extension(services): Extension>, + ctx: CtxExtension, + Path(id): Path, + body: Result, axum::extract::rejection::JsonRejection>, +) -> HandlerResult { + let spec = parse_body(body.map(|Json(value)| value))?; + let updated = services + .control_plane + .replace_upstream(tenant_of(&ctx), id, spec) + .map_err(to_api)?; + Ok(axum::Json(UpstreamDto::from(updated))) +} + +/// `DELETE /oagw/v1/upstreams/{id}` — 204 / 404. +pub async fn delete_upstream( + Extension(services): Extension>, + ctx: CtxExtension, + Path(id): Path, +) -> HandlerResult { + services + .control_plane + .delete_upstream(tenant_of(&ctx), id) + .map_err(to_api)?; + Ok(StatusCode::NO_CONTENT) +} + +/// `POST /oagw/v1/upstreams/{id}/enable` +pub async fn enable_upstream( + services: Extension>, + ctx: CtxExtension, + Path(id): Path, +) -> HandlerResult { + set_enabled(services, ctx, id, true).await +} + +/// `POST /oagw/v1/upstreams/{id}/disable` +pub async fn disable_upstream( + services: Extension>, + ctx: CtxExtension, + Path(id): Path, +) -> HandlerResult { + set_enabled(services, ctx, id, false).await +} + +async fn set_enabled( + services: Extension>, + ctx: CtxExtension, + id: Uuid, + enabled: bool, +) -> HandlerResult { + let upstream = services + .control_plane + .set_upstream_enabled(tenant_of(&ctx), id, enabled) + .map_err(to_api)?; + Ok(axum::Json(UpstreamDto::from(upstream))) +} + +// --------------------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------------------- + +/// `POST /oagw/v1/routes` — validates `upstream_id`. +pub async fn create_route( + Extension(services): Extension>, + ctx: CtxExtension, + body: Result, axum::extract::rejection::JsonRejection>, +) -> HandlerResult { + let spec = parse_body(body.map(|Json(value)| value))?; + let created = services + .control_plane + .create_route(tenant_of(&ctx), spec) + .map_err(to_api)?; + Ok((StatusCode::CREATED, axum::Json(RouteDto::from(created)))) +} + +/// `GET /oagw/v1/routes` — list with OData paging. +pub async fn list_routes( + Extension(services): Extension>, + ctx: CtxExtension, + Query(query): Query, +) -> HandlerResult { + let dtos: Vec = services + .control_plane + .list_routes(tenant_of(&ctx)) + .into_iter() + .map(Into::into) + .collect(); + Ok(axum::Json(serialize_page(dtos, &query)?)) +} + +/// `GET /oagw/v1/routes/{id}` — 200 / 404. +pub async fn get_route( + Extension(services): Extension>, + ctx: CtxExtension, + Path(id): Path, +) -> HandlerResult { + let route = services + .control_plane + .get_route(tenant_of(&ctx), id) + .ok_or_else(|| not_found("route", id))?; + Ok(axum::Json(RouteDto::from(route))) +} + +/// `PUT /oagw/v1/routes/{id}` — full replace, `upstream_id` immutable. +pub async fn replace_route( + Extension(services): Extension>, + ctx: CtxExtension, + Path(id): Path, + body: Result, axum::extract::rejection::JsonRejection>, +) -> HandlerResult { + let spec = parse_body(body.map(|Json(value)| value))?; + let updated = services + .control_plane + .replace_route(tenant_of(&ctx), id, spec) + .map_err(to_api)?; + Ok(axum::Json(RouteDto::from(updated))) +} + +/// `DELETE /oagw/v1/routes/{id}` — 204 / 404. +pub async fn delete_route( + Extension(services): Extension>, + ctx: CtxExtension, + Path(id): Path, +) -> HandlerResult { + services + .control_plane + .delete_route(tenant_of(&ctx), id) + .map_err(to_api)?; + Ok(StatusCode::NO_CONTENT) +} + +// --------------------------------------------------------------------------------------- +// Plugins +// --------------------------------------------------------------------------------------- + +/// `POST /oagw/v1/plugins` — register a custom plugin. +pub async fn create_plugin( + Extension(services): Extension>, + ctx: CtxExtension, + body: Result, axum::extract::rejection::JsonRejection>, +) -> HandlerResult { + let spec = parse_body(body.map(|Json(value)| value))?; + let created = services + .control_plane + .create_plugin(tenant_of(&ctx), spec) + .map_err(to_api)?; + Ok((StatusCode::CREATED, axum::Json(PluginDto::from(created)))) +} + +/// `GET /oagw/v1/plugins` — list with OData paging. +pub async fn list_plugins( + Extension(services): Extension>, + ctx: CtxExtension, + Query(query): Query, +) -> HandlerResult { + let dtos: Vec = services + .control_plane + .list_plugins(tenant_of(&ctx)) + .into_iter() + .map(Into::into) + .collect(); + Ok(axum::Json(serialize_page(dtos, &query)?)) +} + +/// `GET /oagw/v1/plugins/{id}` — 200 / 404. +pub async fn get_plugin( + Extension(services): Extension>, + ctx: CtxExtension, + Path(id): Path, +) -> HandlerResult { + let plugin = services + .control_plane + .get_plugin(tenant_of(&ctx), id) + .ok_or_else(|| not_found("plugin", id))?; + Ok(axum::Json(PluginDto::from(plugin))) +} + +/// `DELETE /oagw/v1/plugins/{id}` — 204, or 409 with `referenced_by`. +pub async fn delete_plugin( + Extension(services): Extension>, + ctx: CtxExtension, + Path(id): Path, +) -> HandlerResult { + services + .control_plane + .delete_plugin(tenant_of(&ctx), id) + .map_err(to_api)?; + Ok(StatusCode::NO_CONTENT) +} + +/// `GET /oagw/v1/plugins/{id}/source` — Starlark source as `text/plain`. +pub async fn get_plugin_source( + Extension(services): Extension>, + ctx: CtxExtension, + Path(id): Path, +) -> HandlerResult { + let plugin = services + .control_plane + .get_plugin(tenant_of(&ctx), id) + .ok_or_else(|| not_found("plugin", id))?; + Ok(( + [("content-type", "text/plain; charset=utf-8")], + plugin.source_code, + )) +} + +/// Build the services bundle used by the handlers. +#[must_use] +pub fn bundle( + control_plane: Arc, + data_plane: Arc, +) -> Arc { + Arc::new(Services { + control_plane, + data_plane, + }) +} 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..277b225 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/mod.rs @@ -0,0 +1,63 @@ +// Created: 2026-08-29 by Constructor Tech +//! Axum HTTP handlers (management + proxy data plane). + +pub mod management; +pub mod proxy; + +pub use management::{ + create_plugin, create_route, create_upstream, delete_plugin, delete_route, delete_upstream, + disable_upstream, enable_upstream, get_plugin, get_plugin_source, get_route, get_upstream, + list_plugins, list_routes, list_upstreams, replace_route, replace_upstream, +}; + +use std::sync::Arc; + +use axum::Extension; +use toolkit_security::SecurityContext; + +use crate::domain::services::management::ControlPlaneService; + +/// Control Plane + Data Plane services handed to the handlers. +#[derive(Clone)] +pub struct Services { + /// Control Plane. + pub control_plane: Arc, + /// Data Plane. + pub data_plane: Arc, +} + +/// Extract the calling tenant from the `SecurityContext`, falling back to the +/// anonymous tenant (nil UUID) when the host has no authn middleware. +/// +/// Security: the tenant id is the only field used for scoping; bearer tokens +/// are never read, logged or forwarded by the management API. +#[must_use] +pub fn calling_tenant(context: Option<&SecurityContext>) -> uuid::Uuid { + context.map_or_else(uuid::Uuid::nil, |ctx| ctx.subject_tenant_id()) +} + +/// Authenticated subject id, falling back to the nil UUID. +#[must_use] +pub fn calling_subject(context: Option<&SecurityContext>) -> uuid::Uuid { + context.map_or_else(uuid::Uuid::nil, |ctx| ctx.subject_id()) +} + +/// The optional authn middleware extension. Handlers fall back to an +/// anonymous [`SecurityContext`] so a host without authn still serves. +pub(crate) type CtxExtension = Option>; + +fn context_of(extension: &CtxExtension) -> Option<&SecurityContext> { + extension.as_ref().map(|extension| &extension.0) +} + +pub(crate) fn tenant_of(extension: &CtxExtension) -> uuid::Uuid { + calling_tenant(context_of(extension)) +} + +/// Security context of the data plane, falling back to the anonymous context +/// when the host has no authn middleware. +pub(crate) fn calling_security(extension: &CtxExtension) -> SecurityContext { + context_of(extension) + .cloned() + .unwrap_or_else(SecurityContext::anonymous) +} 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..56d2242 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/proxy.rs @@ -0,0 +1,538 @@ +// Created: 2026-08-29 by Constructor Tech +//! Data-plane REST handler: the proxy endpoint for HTTP, SSE and WebSocket. +//! +//! Security: the handler logs nothing about the request; the problem document +//! carries only the status, the GTS error type, the correlation id and the +//! upstream routing context. + +use std::sync::Arc; + +use axum::Extension; +use axum::body::Body; +use axum::extract::FromRequestParts; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use axum::extract::{Path, Request}; +use axum::http::Uri; +use axum::response::{IntoResponse, Response}; +use bytes::Bytes; +use futures_util::{SinkExt, StreamExt}; +use tokio_tungstenite::tungstenite; + +use futures_util::Stream; + +use super::{CtxExtension, Services, calling_security}; +use crate::api::rest::error::{ApiError, trace_id_from_headers}; +use crate::domain::error::{OagwError, error_type}; +use crate::domain::model::MAX_BODY_BYTES; +use crate::infra::proxy::headers; +use crate::infra::proxy::service::{ProxyBody, ProxyOutcome, ProxyRequest, WebSocketLeg}; + +/// Path parameters of the proxy route. +pub(crate) struct ProxyPath { + /// Routing alias. + pub alias: String, + /// Raw remainder of the path after the alias. + pub path_suffix: String, +} + +/// `any /oagw/v1/proxy/{alias}`. +pub async fn proxy( + Extension(services): Extension>, + ctx: CtxExtension, + Path(alias): Path, + uri: Uri, + request: Request, +) -> Response { + serve( + services, + ctx, + ProxyPath { + alias, + path_suffix: String::new(), + }, + uri, + request, + ) + .await +} + +/// `any /oagw/v1/proxy/{alias}/{*path_suffix}`. +pub async fn proxy_with_suffix( + Extension(services): Extension>, + ctx: CtxExtension, + Path((alias, suffix)): Path<(String, String)>, + uri: Uri, + request: Request, +) -> Response { + serve( + services, + ctx, + ProxyPath { + alias, + path_suffix: suffix, + }, + uri, + request, + ) + .await +} + +async fn serve( + services: Arc, + ctx: CtxExtension, + path: ProxyPath, + uri: Uri, + request: Request, +) -> Response { + let (parts, body) = request.into_parts(); + if is_websocket(&parts.method, &parts.headers) { + return upgrade(services, ctx, path, uri, parts).await; + } + let buffered = match axum::body::to_bytes(body, MAX_BODY_BYTES).await { + Ok(bytes) => bytes, + Err(_) => return problem(OagwError::PayloadTooLarge, uri.path(), None), + }; + if let Some(error) = validate_body(&parts.headers, buffered.len()) { + return problem( + error, + uri.path(), + Some(trace_id_from_headers(&parts.headers).as_str()), + ); + } + let proxy_request = build(path, &uri, parts.method, parts.headers, buffered, ctx); + let trace_id = proxy_request.trace_id.clone(); + let instance = proxy_request.instance.clone(); + match services.data_plane.handle(proxy_request).await { + Ok(outcome) => outcome_response(outcome), + Err(failure) => failure_response(failure, &instance, &trace_id), + } +} + +/// `true` for a WebSocket upgrade request. +fn is_websocket(method: &axum::http::Method, headers: &axum::http::HeaderMap) -> bool { + if *method != axum::http::Method::GET { + return false; + } + let connection = headers + .get(axum::http::header::CONNECTION) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.to_ascii_lowercase().contains("upgrade")); + let upgrade = headers + .get(axum::http::header::UPGRADE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.eq_ignore_ascii_case("websocket")); + connection && upgrade && headers.contains_key(axum::http::header::SEC_WEBSOCKET_KEY) +} + +/// Body validation before forwarding. +/// +/// # Errors +/// +/// Returns the wire error for a `Content-Length` mismatch or a non-chunked +/// `Transfer-Encoding`. +fn validate_body(headers: &axum::http::HeaderMap, actual: usize) -> Option { + if let Some(value) = headers.get(axum::http::header::CONTENT_LENGTH) + && let Ok(text) = value.to_str() + && let Ok(declared) = text.trim().parse::() + && declared != actual + { + return Some(OagwError::Validation( + "content-length does not match the request body".to_owned(), + )); + } + if let Some(value) = headers.get(axum::http::header::TRANSFER_ENCODING) + && let Ok(text) = value.to_str() + && !text.trim().eq_ignore_ascii_case("chunked") + { + return Some(OagwError::Validation( + "transfer-encoding is only supported as chunked".to_owned(), + )); + } + None +} + +/// Build the transport-neutral proxy request. +fn build( + path: ProxyPath, + uri: &Uri, + method: axum::http::Method, + headers: axum::http::HeaderMap, + body: Bytes, + ctx: CtxExtension, +) -> ProxyRequest { + let security = calling_security(&ctx); + let trace_id = trace_id_from_headers(&headers); + ProxyRequest { + method: method.as_str().to_owned(), + alias: path.alias, + path_suffix: path.path_suffix, + query: uri.query().map(str::to_owned), + headers, + body, + tenant_id: security.subject_tenant_id(), + subject_id: security.subject_id(), + client_ip: String::new(), + instance: uri.path().to_owned(), + trace_id, + security, + route_pattern: None, + } +} + +/// Turn a resolved outcome into the wire response. +fn outcome_response(outcome: ProxyOutcome) -> Response { + let mut builder = Response::builder().status(outcome.status); + for (name, value) in &outcome.headers { + builder = builder.header(name, value); + } + match outcome.body { + ProxyBody::Full(bytes) => builder.body(Body::from(bytes)), + ProxyBody::Stream(stream) => builder.body(Body::from_stream(relay_stream(stream))), + } + .unwrap_or_else(|error| problem(OagwError::Internal(error.to_string()), "", None)) +} + +/// Relay an upstream byte stream to the client. +/// +/// A mid-stream failure cannot change the status line (headers are already +/// sent), so it is reported as a final `event: error` frame carrying the GTS +/// error type and the stream then ends. The frame names the error type only: +/// no request, response or credential material is ever included. +fn relay_stream(stream: S) -> impl Stream> + Send +where + S: Stream> + Send + Unpin + 'static, +{ + futures_util::stream::unfold((stream, false), |(mut stream, aborted)| async move { + if aborted { + return None; + } + match StreamExt::next(&mut stream).await { + Some(Ok(bytes)) => Some((Ok(bytes), (stream, false))), + Some(Err(error)) => { + let frame = format!( + "event: error\ndata: {}\n\n", + error_type(error.type_suffix()) + ); + Some((Ok(Bytes::from(frame)), (stream, true))) + } + None => None, + } + }) +} + +/// RFC 9457 problem response for a gateway error. +fn problem(error: OagwError, instance: &str, trace_id: Option<&str>) -> Response { + let mut api = ApiError::new(error).with_instance(instance); + if let Some(trace_id) = trace_id { + api = api.with_trace_id(trace_id); + } + api.into_response() +} + +/// Gateway failure carrying the upstream routing context. +fn failure_response( + failure: crate::infra::proxy::service::ProxyFailure, + instance: &str, + trace_id: &str, +) -> Response { + let mut api = ApiError::new(failure.kind) + .with_instance(instance) + .with_trace_id(trace_id); + api.context.upstream_id = failure.upstream_id.map(|id| id.to_string()); + api.context.host = failure.host; + let mut response = api.into_response(); + // A rejected request still reports the budget it would have spent + // (ADR-0003), so the counters travel on the 429 next to `Retry-After`. + if let Some(report) = failure.rate.as_ref() { + let headers = response.headers_mut(); + for (name, value) in [ + ("x-ratelimit-limit", report.limit.to_string()), + ("x-ratelimit-remaining", report.remaining.to_string()), + ("x-ratelimit-reset", report.reset_epoch.to_string()), + ] { + if let (Ok(name), Some(value)) = ( + axum::http::HeaderName::from_bytes(name.as_bytes()), + headers::header_value(&value), + ) { + headers.insert(name, value); + } + } + } + response +} + +// --------------------------------------------------------------------------------------- +// WebSocket +// --------------------------------------------------------------------------------------- + +async fn upgrade( + services: Arc, + ctx: CtxExtension, + path: ProxyPath, + uri: Uri, + mut parts: axum::http::request::Parts, +) -> Response { + let headers = parts.headers.clone(); + let Ok(upgrade) = WebSocketUpgrade::from_request_parts(&mut parts, &()).await else { + return problem( + OagwError::Validation("request is not a websocket upgrade".to_owned()), + uri.path(), + None, + ); + }; + let proxy_request = build(path, &uri, parts.method, headers, Bytes::new(), ctx); + let trace_id = proxy_request.trace_id.clone(); + let instance = proxy_request.instance.clone(); + let leg = match services + .data_plane + .resolve_for_websocket(proxy_request) + .await + { + Ok(leg) => leg, + Err(failure) => return failure_response(failure, &instance, &trace_id), + }; + let upstream = match connect_upstream(&leg).await { + Ok(stream) => stream, + Err(error) => { + services.data_plane.websocket_failure(&leg); + let mut api = ApiError::new(error) + .with_instance(instance) + .with_trace_id(trace_id); + api.context.upstream_id = Some(leg.upstream_id.to_string()); + api.context.host = Some(leg.host); + return api.into_response(); + } + }; + services.data_plane.websocket_success(&leg); + upgrade.on_upgrade(move |socket: WebSocket| async move { + relay(socket, upstream).await; + }) +} + +/// Connect the upstream WebSocket leg. +/// +/// `wss://` requires a TLS backend the MVP build does not enable, so such +/// targets fail closed with `502 DownstreamError`. +async fn connect_upstream( + leg: &WebSocketLeg, +) -> Result< + tokio_tungstenite::WebSocketStream>, + OagwError, +> { + let mut builder = http::Request::builder().uri(leg.url.clone()); + for (name, value) in &leg.headers { + if is_handshake_header(name) { + continue; + } + if let (Ok(name), Ok(value)) = ( + http::header::HeaderName::from_bytes(name.as_bytes()), + http::header::HeaderValue::from_str(value), + ) { + builder = builder.header(name, value); + } + } + // The handshake material is always freshly minted for the upstream leg: + // the caller's key is never replayed and the authority is the upstream's. + let authority = http::Uri::try_from(leg.url.as_str()) + .ok() + .and_then(|uri| uri.authority().map(|value| value.as_str().to_owned())) + .unwrap_or_default(); + builder = builder + .header("host", authority) + .header("connection", "Upgrade") + .header("upgrade", "websocket") + .header("sec-websocket-version", "13") + .header( + "sec-websocket-key", + tungstenite::handshake::client::generate_key(), + ); + let request = builder + .body(()) + .map_err(|_| OagwError::ProtocolError("websocket target is not a valid URL".to_owned()))?; + match tokio_tungstenite::connect_async(request).await { + Ok((stream, _response)) => Ok(stream), + Err(_) => Err(OagwError::DownstreamError( + "upstream websocket handshake failed".to_owned(), + )), + } +} + +/// Headers the tunneling library computes itself. +fn is_handshake_header(name: &str) -> bool { + matches!( + name.to_ascii_lowercase().as_str(), + "host" + | "connection" + | "upgrade" + | "sec-websocket-key" + | "sec-websocket-version" + | "sec-websocket-extensions" + | "sec-websocket-protocol" + ) +} + +/// Relay frames in both directions until either side closes. +async fn relay( + client: WebSocket, + upstream: tokio_tungstenite::WebSocketStream< + tokio_tungstenite::MaybeTlsStream, + >, +) { + let (mut client_sink, mut client_stream) = client.split(); + let (mut upstream_sink, mut upstream_stream) = upstream.split(); + let outbound = async move { + while let Some(Ok(message)) = client_stream.next().await { + let Some(frame) = to_upstream_frame(message) else { + break; + }; + if upstream_sink.send(frame).await.is_err() { + break; + } + } + let _ = upstream_sink.close().await; + }; + let inbound = async move { + while let Some(Ok(message)) = upstream_stream.next().await { + let Some(frame) = to_client_frame(message) else { + break; + }; + if client_sink.send(frame).await.is_err() { + break; + } + } + let _ = client_sink.close().await; + }; + tokio::join!(outbound, inbound); +} + +/// Translate an axum frame into a tungstenite frame. +fn to_upstream_frame(message: Message) -> Option { + match message { + Message::Text(text) => Some(tungstenite::Message::text(text.as_str())), + Message::Binary(data) => Some(tungstenite::Message::binary(data)), + Message::Ping(data) => Some(tungstenite::Message::Ping(data)), + Message::Pong(data) => Some(tungstenite::Message::Pong(data)), + Message::Close(_) => Some(tungstenite::Message::Close(None)), + } +} + +/// Translate a tungstenite frame into an axum frame. +fn to_client_frame(message: tungstenite::Message) -> Option { + match message { + tungstenite::Message::Text(text) => Some(Message::text(text.as_str())), + tungstenite::Message::Binary(data) => Some(Message::binary(data)), + tungstenite::Message::Ping(data) => Some(Message::Ping(data)), + tungstenite::Message::Pong(data) => Some(Message::Pong(data)), + tungstenite::Message::Close(_) | tungstenite::Message::Frame(_) => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn upgrade_headers() -> axum::http::HeaderMap { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::CONNECTION, + axum::http::HeaderValue::from_static("Upgrade"), + ); + headers.insert( + axum::http::header::UPGRADE, + axum::http::HeaderValue::from_static("websocket"), + ); + headers.insert( + axum::http::header::SEC_WEBSOCKET_KEY, + axum::http::HeaderValue::from_static("dGhlIHNhbXBsZSBub25jZQ=="), + ); + headers + } + + #[test] + fn websocket_upgrade_is_detected_from_headers() { + assert!(is_websocket(&axum::http::Method::GET, &upgrade_headers())); + let mut incomplete = upgrade_headers(); + incomplete.remove(axum::http::header::SEC_WEBSOCKET_KEY); + assert!(!is_websocket(&axum::http::Method::GET, &incomplete)); + assert!(!is_websocket(&axum::http::Method::POST, &upgrade_headers())); + } + + #[test] + fn message_translation_is_lossless_for_data_frames() { + let text = Message::text("hello"); + assert_eq!( + to_upstream_frame(text.clone()), + Some(tungstenite::Message::text("hello")) + ); + assert_eq!( + to_client_frame(tungstenite::Message::text("hello")), + Some(text) + ); + let binary = Message::binary(Bytes::from_static(b"\x01\x02")); + assert_eq!( + to_upstream_frame(binary.clone()), + Some(tungstenite::Message::binary(Bytes::from_static(&[ + 0x01, 0x02 + ]))) + ); + assert_eq!( + to_client_frame(tungstenite::Message::binary(Bytes::from_static(&[ + 0x01, 0x02 + ]))), + Some(binary) + ); + } + + #[test] + fn control_frames_map_to_close() { + assert!(to_upstream_frame(Message::Close(None)).is_some()); + assert!(to_upstream_frame(Message::Ping(Bytes::new())).is_some()); + assert!(to_client_frame(tungstenite::Message::Close(None)).is_none()); + assert!( + to_client_frame(tungstenite::Message::Frame( + tungstenite::protocol::frame::Frame::ping(Bytes::new()) + )) + .is_none() + ); + } + + #[test] + fn handshake_headers_are_not_forwarded() { + assert!(is_handshake_header("Connection")); + assert!(is_handshake_header("SEC-WEBSOCKET-KEY")); + assert!(!is_handshake_header("authorization")); + } + + #[test] + fn body_validation_accepts_matching_length() { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::CONTENT_LENGTH, + axum::http::HeaderValue::from_static("3"), + ); + assert!(validate_body(&headers, 3).is_none()); + assert!(validate_body(&headers, 4).is_some()); + headers.remove(axum::http::header::CONTENT_LENGTH); + assert!(validate_body(&headers, 0).is_none()); + } + + #[test] + fn transfer_encoding_must_be_chunked() { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::TRANSFER_ENCODING, + axum::http::HeaderValue::from_static("gzip"), + ); + assert!(validate_body(&headers, 0).is_some()); + headers.insert( + axum::http::header::TRANSFER_ENCODING, + axum::http::HeaderValue::from_static("chunked"), + ); + assert!(validate_body(&headers, 0).is_none()); + } + + #[test] + fn body_limit_is_100_mib() { + assert_eq!(MAX_BODY_BYTES, 100 * 1024 * 1024); + } +} 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..30f7f67 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/mod.rs @@ -0,0 +1,8 @@ +// Created: 2026-08-29 by Constructor Tech +//! REST API: DTOs, OData evaluation, wire errors, handlers and routes. + +pub mod dto; +pub mod error; +pub mod handlers; +pub mod odata; +pub mod routes; diff --git a/gears/system/oagw/oagw/src/api/rest/odata.rs b/gears/system/oagw/oagw/src/api/rest/odata.rs new file mode 100644 index 0000000..77df1b4 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/odata.rs @@ -0,0 +1,252 @@ +// Created: 2026-08-29 by Constructor Tech +//! OData `$filter` / `$orderby` / `$select` / `$top` / `$skip` evaluation over +//! serialized DTOs. +//! +//! The filter grammar is intentionally small (`and`-separated +//! `field op literal` comparisons) because that is all the documented OAGW +//! queries need (`alias eq 'x'`, `enabled eq true`, `upstream_id eq ''`, +//! `type eq 'x'`). + +use serde_json::Value; + +use super::dto::ListQuery; +use crate::domain::error::OagwError; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Comparator { + Eq, + Ne, +} + +/// Apply `$filter` and `$orderby`, returning the filtered/sorted list. +/// +/// # Errors +/// +/// Returns [`OagwError::Validation`] when the query options cannot be parsed. +pub fn apply_filter_and_order( + mut items: Vec, + query: &ListQuery, +) -> Result, OagwError> { + if let Some(filter) = &query.filter { + items = apply_filter(items, filter)?; + } + if let Some(orderby) = &query.orderby { + apply_orderby(&mut items, orderby)?; + } + Ok(items) +} + +/// Apply `$filter`, `$orderby`, `$select`, `$skip` and `$top` to `items`, +/// returning the page and the total count before paging. +/// +/// # Errors +/// +/// Returns [`OagwError::Validation`] when the query options cannot be parsed. +pub fn apply_query_page( + items: Vec, + query: &ListQuery, +) -> Result<(Vec, usize), OagwError> { + let items = apply_filter_and_order(items, query)?; + let total = items.len(); + let page: Vec = items + .into_iter() + .skip(query.offset()) + .take(query.page_size()) + .collect(); + let page = if let Some(select) = &query.select { + page.into_iter().map(|item| project(item, select)).collect() + } else { + page + }; + Ok((page, total)) +} + +fn apply_filter(items: Vec, filter: &str) -> Result, OagwError> { + let clauses: Vec = filter + .split(" and ") + .map(str::trim) + .filter(|clause| !clause.is_empty()) + .map(ToOwned::to_owned) + .collect(); + if clauses.is_empty() { + return Err(invalid_filter(filter)); + } + let parsed: Vec<(String, Comparator, String)> = clauses + .iter() + .map(|clause| parse_clause(clause)) + .collect::, _>>()?; + Ok(items + .into_iter() + .filter(|item| parsed.iter().all(|clause| matches_clause(item, clause))) + .collect()) +} + +fn invalid_filter(filter: &str) -> OagwError { + OagwError::Validation(format!("unsupported $filter expression '{filter}'")) +} + +fn parse_clause(clause: &str) -> Result<(String, Comparator, String), OagwError> { + let (field, rest) = clause + .split_once(char::is_whitespace) + .ok_or_else(|| invalid_filter(clause))?; + let (op, value) = rest + .split_once(char::is_whitespace) + .ok_or_else(|| invalid_filter(clause))?; + let comparator = match op.trim() { + "eq" => Comparator::Eq, + "ne" => Comparator::Ne, + other => return Err(invalid_filter(&format!("operator '{other}'"))), + }; + Ok((field.trim().to_owned(), comparator, unquote(value.trim()))) +} + +fn unquote(value: &str) -> String { + let trimmed = value.trim(); + trimmed + .strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + .map_or_else(|| trimmed.to_owned(), ToOwned::to_owned) +} + +fn matches_clause(item: &Value, clause: &(String, Comparator, String)) -> bool { + let (field, comparator, expected) = clause; + let Some(actual) = item.get(field.as_str()) else { + return false; + }; + let equal = match actual { + Value::String(value) => value == expected, + Value::Bool(value) => expected.eq_ignore_ascii_case(&value.to_string()), + Value::Number(value) => value.to_string() == *expected, + _ => false, + }; + match comparator { + Comparator::Eq => equal, + Comparator::Ne => !equal, + } +} + +fn apply_orderby(items: &mut [Value], orderby: &str) -> Result<(), OagwError> { + let (field, direction) = orderby + .split_once(char::is_whitespace) + .map_or((orderby.trim(), "asc"), |(field, dir)| { + (field.trim(), dir.trim()) + }); + if !matches!(direction, "asc" | "desc") { + return Err(OagwError::Validation(format!( + "unsupported $orderby direction '{direction}'" + ))); + } + let key = field.to_owned(); + items.sort_by(|left, right| { + let ordering = compare_by(left.get(&key), right.get(&key)); + if direction == "desc" { + ordering.reverse() + } else { + ordering + } + }); + Ok(()) +} + +fn compare_by(left: Option<&Value>, right: Option<&Value>) -> std::cmp::Ordering { + match (left, right) { + (Some(Value::String(left)), Some(Value::String(right))) => left.cmp(right), + (Some(Value::Number(left)), Some(Value::Number(right))) => left + .as_f64() + .partial_cmp(&right.as_f64()) + .unwrap_or(std::cmp::Ordering::Equal), + (Some(Value::Bool(left)), Some(Value::Bool(right))) => left.cmp(right), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + _ => std::cmp::Ordering::Equal, + } +} + +fn project(item: Value, select: &str) -> Value { + let mut projected = serde_json::Map::new(); + for field in select.split(',') { + let field = field.trim(); + if field.is_empty() { + continue; + } + if let Some(value) = item.get(field) { + projected.insert(field.to_owned(), value.clone()); + } + } + Value::Object(projected) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(alias: &str, enabled: bool) -> Value { + serde_json::json!({ "alias": alias, "enabled": enabled }) + } + + #[test] + fn filters_and_orders() { + let items = vec![ + item("zeta.example.com", true), + item("alpha.example.com", false), + item("mike.example.com", true), + ]; + let query = ListQuery { + filter: Some("enabled eq true".to_owned()), + orderby: Some("alias asc".to_owned()), + select: None, + top: None, + skip: None, + }; + let page = apply_query_page(items, &query).expect("applied"); + assert_eq!(page.0.len(), 2); + assert_eq!(page.0[0]["alias"], "mike.example.com"); + } + + #[test] + fn top_is_clamped_and_skip_applies() { + let items: Vec = (0..150) + .map(|index| serde_json::json!({ "alias": format!("h{index:03}.example.com") })) + .collect(); + let query = ListQuery { + filter: None, + orderby: Some("alias asc".to_owned()), + select: None, + top: Some(500), + skip: Some(99), + }; + let page = apply_query_page(items, &query).expect("applied"); + assert_eq!(page.0.len(), 51); + assert_eq!(page.0[0]["alias"], "h099.example.com"); + } + + #[test] + fn select_projects_fields() { + let items = vec![serde_json::json!({ + "id": "1", "alias": "a.example.com", "server": { "endpoints": [] } + })]; + let query = ListQuery { + filter: None, + orderby: None, + select: Some("id,alias".to_owned()), + top: None, + skip: None, + }; + let page = apply_query_page(items, &query).expect("applied"); + assert!(page.0[0].get("server").is_none()); + assert_eq!(page.0[0]["alias"], "a.example.com"); + } + + #[test] + fn rejects_unknown_operator() { + let items = vec![item("a.example.com", true)]; + let query = ListQuery { + filter: Some("alias contains 'a'".to_owned()), + orderby: None, + select: None, + top: None, + skip: None, + }; + assert!(apply_query_page(items, &query).is_err()); + } +} 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..f044e31 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/routes.rs @@ -0,0 +1,372 @@ +// Created: 2026-08-29 by Constructor Tech +//! REST route registration. +//! +//! The management API is documented once through `OperationBuilder` under the +//! gear-relative `/oagw/v1/...` prefix, and the same handlers are re-registered +//! as undocumented axum routes under `/api/oagw/v1/...` because the DESIGN doc +//! spells the paths with the `/api` prefix. Both prefixes must work. + +use std::sync::Arc; + +use axum::Router; +use toolkit::api::{OpenApiRegistry, OperationBuilder}; + +use super::handlers::{self, Services}; +use crate::api::rest::dto::{ + CreatePluginBody, CreateRouteBody, CreateUpstreamBody, PluginDto, PluginList, ReplaceRouteBody, + ReplaceUpstreamBody, RouteDto, RouteList, UpstreamDto, UpstreamList, +}; + +const TAG: &str = "Outbound API Gateway"; + +/// Gear-relative prefix of every documented OAGW operation. +const BASE: &str = "/oagw/v1"; + +/// DESIGN-doc prefix, served by the same handlers. +const API_BASE: &str = "/api/oagw/v1"; + +/// Register every REST route for the OAGW gear. +pub fn register(router: Router, openapi: &dyn OpenApiRegistry, services: Arc) -> Router { + router + .merge( + management_routes(Router::new(), openapi).layer(axum::Extension(Arc::clone(&services))), + ) + .merge(undocumented_management().layer(axum::Extension(Arc::clone(&services)))) + .merge(proxy_routes().layer(axum::Extension(services))) +} + +fn upstream_id_path(suffix: &str) -> String { + format!("{BASE}/upstreams/{{id}}{suffix}") +} + +fn api_upstream_id_path(suffix: &str) -> String { + format!("{API_BASE}/upstreams/{{id}}{suffix}") +} + +fn management_routes(router: Router, openapi: &dyn OpenApiRegistry) -> Router { + let router = OperationBuilder::post(format!("{BASE}/upstreams")) + .operation_id("oagw.create_upstream") + .summary("Create an upstream") + .description( + "Register an outbound service. The alias is derived from the endpoints; \ + endpoints that are not derivable require an explicit `alias`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "Upstream to create") + .handler(handlers::create_upstream) + .json_response_with_schema::( + openapi, + axum::http::StatusCode::CREATED, + "Created upstream", + ) + .standard_errors(openapi) + .register(router, openapi); + + let router = OperationBuilder::get(format!("{BASE}/upstreams")) + .operation_id("oagw.list_upstreams") + .summary("List upstreams") + .description( + "List upstreams of the calling tenant with OData `$top` / `$skip` / \ + `$filter` / `$select` / `$orderby`.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .handler(handlers::list_upstreams) + .json_response_with_schema::(openapi, axum::http::StatusCode::OK, "Page") + .standard_errors(openapi) + .register(router, openapi); + + let router = OperationBuilder::get(upstream_id_path("")) + .operation_id("oagw.get_upstream") + .summary("Get an upstream") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Upstream identifier") + .handler(handlers::get_upstream) + .json_response_with_schema::(openapi, axum::http::StatusCode::OK, "Upstream") + .standard_errors(openapi) + .register(router, openapi); + + let router = OperationBuilder::put(upstream_id_path("")) + .operation_id("oagw.replace_upstream") + .summary("Replace an upstream") + .description("Full replace. `alias` is immutable; a change is rejected with 400.") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Upstream identifier") + .json_request::(openapi, "Replacement upstream") + .handler(handlers::replace_upstream) + .json_response_with_schema::( + openapi, + axum::http::StatusCode::OK, + "Replaced upstream", + ) + .standard_errors(openapi) + .register(router, openapi); + + let router = OperationBuilder::delete(upstream_id_path("")) + .operation_id("oagw.delete_upstream") + .summary("Delete an upstream") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Upstream identifier") + .handler(handlers::delete_upstream) + .no_content_response(axum::http::StatusCode::NO_CONTENT, "Deleted") + .standard_errors(openapi) + .register(router, openapi); + + let router = enable_disable(router, openapi); + let router = route_operations(router, openapi); + plugin_operations(router, openapi) +} + +fn enable_disable(router: Router, openapi: &dyn OpenApiRegistry) -> Router { + let router = OperationBuilder::post(upstream_id_path("/enable")) + .operation_id("oagw.enable_upstream") + .summary("Enable an upstream") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Upstream identifier") + .handler(handlers::enable_upstream) + .json_response_with_schema::( + openapi, + axum::http::StatusCode::OK, + "Upstream enabled", + ) + .standard_errors(openapi) + .register(router, openapi); + + OperationBuilder::post(upstream_id_path("/disable")) + .operation_id("oagw.disable_upstream") + .summary("Disable an upstream") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Upstream identifier") + .handler(handlers::disable_upstream) + .json_response_with_schema::( + openapi, + axum::http::StatusCode::OK, + "Upstream disabled", + ) + .standard_errors(openapi) + .register(router, openapi) +} + +fn route_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { + let router = OperationBuilder::post(format!("{BASE}/routes")) + .operation_id("oagw.create_route") + .summary("Create a route") + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "Route to create") + .handler(handlers::create_route) + .json_response_with_schema::( + openapi, + axum::http::StatusCode::CREATED, + "Created route", + ) + .standard_errors(openapi) + .register(router, openapi); + + let router = OperationBuilder::get(format!("{BASE}/routes")) + .operation_id("oagw.list_routes") + .summary("List routes") + .tag(TAG) + .authenticated() + .no_license_required() + .handler(handlers::list_routes) + .json_response_with_schema::(openapi, axum::http::StatusCode::OK, "Page") + .standard_errors(openapi) + .register(router, openapi); + + let router = OperationBuilder::get(format!("{BASE}/routes/{{id}}")) + .operation_id("oagw.get_route") + .summary("Get a route") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Route identifier") + .handler(handlers::get_route) + .json_response_with_schema::(openapi, axum::http::StatusCode::OK, "Route") + .standard_errors(openapi) + .register(router, openapi); + + let router = OperationBuilder::put(format!("{BASE}/routes/{{id}}")) + .operation_id("oagw.replace_route") + .summary("Replace a route") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Route identifier") + .json_request::(openapi, "Replacement route") + .handler(handlers::replace_route) + .json_response_with_schema::( + openapi, + axum::http::StatusCode::OK, + "Replaced route", + ) + .standard_errors(openapi) + .register(router, openapi); + + OperationBuilder::delete(format!("{BASE}/routes/{{id}}")) + .operation_id("oagw.delete_route") + .summary("Delete a route") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Route identifier") + .handler(handlers::delete_route) + .no_content_response(axum::http::StatusCode::NO_CONTENT, "Deleted") + .standard_errors(openapi) + .register(router, openapi) +} + +fn plugin_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { + let router = OperationBuilder::post(format!("{BASE}/plugins")) + .operation_id("oagw.create_plugin") + .summary("Register a custom plugin") + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "Plugin to register") + .handler(handlers::create_plugin) + .json_response_with_schema::( + openapi, + axum::http::StatusCode::CREATED, + "Registered plugin", + ) + .standard_errors(openapi) + .register(router, openapi); + + let router = OperationBuilder::get(format!("{BASE}/plugins")) + .operation_id("oagw.list_plugins") + .summary("List plugins") + .tag(TAG) + .authenticated() + .no_license_required() + .handler(handlers::list_plugins) + .json_response_with_schema::(openapi, axum::http::StatusCode::OK, "Page") + .standard_errors(openapi) + .register(router, openapi); + + let router = OperationBuilder::get(format!("{BASE}/plugins/{{id}}")) + .operation_id("oagw.get_plugin") + .summary("Get a plugin") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Plugin identifier") + .handler(handlers::get_plugin) + .json_response_with_schema::(openapi, axum::http::StatusCode::OK, "Plugin") + .standard_errors(openapi) + .register(router, openapi); + + let router = OperationBuilder::delete(format!("{BASE}/plugins/{{id}}")) + .operation_id("oagw.delete_plugin") + .summary("Delete a plugin") + .description( + "Fails with 409 and a `referenced_by` body when an upstream or route still \ + references the plugin.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Plugin identifier") + .handler(handlers::delete_plugin) + .no_content_response(axum::http::StatusCode::NO_CONTENT, "Deleted") + .standard_errors(openapi) + .register(router, openapi); + + OperationBuilder::get(format!("{BASE}/plugins/{{id}}/source")) + .operation_id("oagw.get_plugin_source") + .summary("Get plugin source") + .description("Returns the Starlark source of a custom plugin as `text/plain`.") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Plugin identifier") + .handler(handlers::get_plugin_source) + .no_content_response(axum::http::StatusCode::OK, "Starlark source") + .standard_errors(openapi) + .register(router, openapi) +} + +/// The same management handlers under the DESIGN-doc prefix. +fn undocumented_management() -> Router { + use axum::routing::{get, post}; + Router::new() + .route( + &format!("{API_BASE}/upstreams"), + post(handlers::create_upstream).get(handlers::list_upstreams), + ) + .route( + &api_upstream_id_path(""), + get(handlers::get_upstream) + .put(handlers::replace_upstream) + .delete(handlers::delete_upstream), + ) + .route( + &api_upstream_id_path("/enable"), + post(handlers::enable_upstream), + ) + .route( + &api_upstream_id_path("/disable"), + post(handlers::disable_upstream), + ) + .route( + &format!("{API_BASE}/routes"), + post(handlers::create_route).get(handlers::list_routes), + ) + .route( + &format!("{API_BASE}/routes/{{id}}"), + get(handlers::get_route) + .put(handlers::replace_route) + .delete(handlers::delete_route), + ) + .route( + &format!("{API_BASE}/plugins"), + post(handlers::create_plugin).get(handlers::list_plugins), + ) + .route( + &format!("{API_BASE}/plugins/{{id}}"), + get(handlers::get_plugin).delete(handlers::delete_plugin), + ) + .route( + &format!("{API_BASE}/plugins/{{id}}/source"), + get(handlers::get_plugin_source), + ) +} + +/// The data-plane proxy endpoints. +/// +/// `/proxy/{alias}` and `/proxy/{alias}/{*path_suffix}` are `any` routes so +/// every method and an SSE / WebSocket upgrade reach the same handler. +fn proxy_routes() -> Router { + const SUFFIX_PATH: &str = "{alias}/{*path_suffix}"; + Router::new() + .route( + &format!("{BASE}/proxy/{SUFFIX_PATH}"), + axum::routing::any(handlers::proxy::proxy_with_suffix), + ) + .route( + &format!("{BASE}/proxy/{{alias}}"), + axum::routing::any(handlers::proxy::proxy), + ) + .route( + &format!("{API_BASE}/proxy/{SUFFIX_PATH}"), + axum::routing::any(handlers::proxy::proxy_with_suffix), + ) + .route( + &format!("{API_BASE}/proxy/{{alias}}"), + axum::routing::any(handlers::proxy::proxy), + ) +} diff --git a/gears/system/oagw/oagw/src/config.rs b/gears/system/oagw/oagw/src/config.rs new file mode 100644 index 0000000..7ea7626 --- /dev/null +++ b/gears/system/oagw/oagw/src/config.rs @@ -0,0 +1,128 @@ +// Created: 2026-08-29 by Constructor Tech +//! Gear configuration (`OagwConfig`). +//! +//! The struct is `#[serde(default)]` **without** `deny_unknown_fields`: the run +//! configuration supplies only a subset of the keys (`proxy_timeout_secs`, +//! `allow_http_upstream`, `ssrf_policy.enabled`) and must parse. Unknown keys +//! are ignored so the gear keeps working when new knobs appear in the config +//! file. + +use serde::Deserialize; + +/// Server-side request forgery guard knobs. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(default)] +pub struct SsrfPolicy { + /// Reject upstream targets that resolve to private / loopback / + /// link-local / unique-local addresses. + /// + /// Defaults to `true`: an outbound gateway that forwarded to loopback by + /// default would be an open SSRF redirector. Deployments that must reach + /// private ranges say so with `allow_private_addresses`. + pub enabled: bool, + /// Explicit escape hatch for deployments that must reach private ranges. + pub allow_private_addresses: bool, +} + +impl Default for SsrfPolicy { + fn default() -> Self { + Self { + enabled: true, + allow_private_addresses: false, + } + } +} + +/// Configuration for the `oagw` outbound API gateway gear. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] +#[serde(default)] +pub struct OagwConfig { + /// Wall-clock budget for a single proxied request (upstream call included). + pub proxy_timeout_secs: u64, + /// `HTTPS`-only upstreams unless this is explicitly `true`. + /// + /// Security: plaintext upstreams are rejected with `ProtocolError` while + /// this stays `false`. + pub allow_http_upstream: bool, + /// SSRF guard configuration. + pub ssrf_policy: SsrfPolicy, + /// Maximum TTL handed to a cached OAuth2 token when the IdP omits `expires_in`. + pub token_cache_ttl_secs: u64, + /// OAuth2 token cache capacity (entries). + pub token_cache_capacity: usize, + /// Data-plane L1 effective-config cache capacity (entries). + pub hot_cache_capacity: usize, + /// TCP connect timeout for the upstream leg. + pub connect_timeout_secs: u64, +} + +impl Default for OagwConfig { + fn default() -> Self { + Self { + proxy_timeout_secs: 30, + allow_http_upstream: false, + ssrf_policy: SsrfPolicy::default(), + token_cache_ttl_secs: 300, + token_cache_capacity: 10_000, + hot_cache_capacity: 1_000, + connect_timeout_secs: 5, + } + } +} + +impl OagwConfig { + /// `proxy_timeout_secs` as a `std::time::Duration`. + #[must_use] + pub fn proxy_timeout(&self) -> std::time::Duration { + std::time::Duration::from_secs(self.proxy_timeout_secs) + } + + /// `connect_timeout_secs` as a `std::time::Duration`. + #[must_use] + pub fn connect_timeout(&self) -> std::time::Duration { + std::time::Duration::from_secs(self.connect_timeout_secs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_contract() { + let cfg = OagwConfig::default(); + assert_eq!(cfg.proxy_timeout_secs, 30); + assert!(!cfg.allow_http_upstream); + assert!(cfg.ssrf_policy.enabled, "SSRF protection is on by default"); + assert!(!cfg.ssrf_policy.allow_private_addresses); + assert_eq!(cfg.token_cache_ttl_secs, 300); + assert_eq!(cfg.token_cache_capacity, 10_000); + assert_eq!(cfg.hot_cache_capacity, 1_000); + assert_eq!(cfg.connect_timeout_secs, 5); + } + + #[test] + fn parses_partial_run_config() { + let raw = serde_json::json!({ + "proxy_timeout_secs": 2, + "allow_http_upstream": true, + "ssrf_policy": { "enabled": false } + }); + let cfg: OagwConfig = serde_json::from_value(raw).expect("partial config must parse"); + assert!( + !cfg.ssrf_policy.enabled, + "an explicit opt-out must be honoured" + ); + assert_eq!(cfg.proxy_timeout_secs, 2); + assert!(cfg.allow_http_upstream); + assert!(!cfg.ssrf_policy.enabled); + assert_eq!(cfg.token_cache_ttl_secs, 300); + } + + #[test] + fn ignores_unknown_keys() { + let raw = serde_json::json!({ "not_a_real_key": 1, "proxy_timeout_secs": 7 }); + let cfg: OagwConfig = serde_json::from_value(raw).expect("unknown keys are ignored"); + assert_eq!(cfg.proxy_timeout_secs, 7); + } +} diff --git a/gears/system/oagw/oagw/src/domain/alias.rs b/gears/system/oagw/oagw/src/domain/alias.rs new file mode 100644 index 0000000..ecce4aa --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/alias.rs @@ -0,0 +1,350 @@ +// Created: 2026-08-29 by Constructor Tech +//! Alias derivation from the upstream endpoint set. +//! +//! Alias behaviour is determined entirely by endpoint type (DESIGN §3.2 +//! "Alias Resolution"). Aliases are not arbitrary labels: they are derived from +//! the endpoints whenever derivation is possible. + +use std::collections::BTreeSet; + +use super::error::OagwError; +use super::model::{Endpoint, validate_hostname_like}; + +/// Outcome of alias derivation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DerivedAlias { + /// Derivation succeeded. + Derived(String), + /// Derivation is impossible; the caller must supply an alias. + NotDerivable(NotDerivable), +} + +/// Why derivation failed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum NotDerivable { + /// Hosts have no common registrable suffix under the Public Suffix List. + NoCommonSuffix, + /// The only common suffix is a bare public suffix (e.g. `co.uk`). + BarePublicSuffix, + /// Endpoints are IP literals. + IpEndpoints, + /// Multiple endpoints on different ports defeat the `host:port` form. + MixedPorts, +} + +impl NotDerivable { + /// Stable, user-facing description of the failure. + #[must_use] + pub fn as_str(&self) -> &'static str { + match self { + Self::NoCommonSuffix => "the endpoints share no registrable common suffix", + Self::BarePublicSuffix => "the common suffix is a bare public suffix", + Self::IpEndpoints => "the endpoints are IP literals", + Self::MixedPorts => "the endpoints use different ports", + } + } +} + +/// Compute the derived alias for an endpoint set. +/// +/// # Rules +/// +/// * Single endpoint, standard port (`http` → 80, everything else → 443): +/// the host name. +/// * Single endpoint, non-standard port: `host:port`. +/// * Multiple endpoints: the common suffix across all hostnames when it is at +/// least two labels **and** a registrable domain under the Public Suffix List +/// (i.e. not a bare public suffix). When all endpoints share a non-standard +/// port the alias is `suffix:port`. +/// * IP endpoints, bare public suffixes and heterogeneous hostnames are not +/// derivable. +#[must_use] +pub fn compute_derived_alias(endpoints: &[Endpoint]) -> DerivedAlias { + let Some(first) = endpoints.first() else { + return DerivedAlias::NotDerivable(NotDerivable::NoCommonSuffix); + }; + // IP endpoints are never derivable, not even a single one: the operator + // must name the upstream explicitly (DESIGN "Alias Enforcement Rules"). + if first.is_ip() { + return DerivedAlias::NotDerivable(NotDerivable::IpEndpoints); + } + if endpoints.len() == 1 { + return DerivedAlias::Derived(single_endpoint_alias(first)); + } + let hosts: Vec = endpoints + .iter() + .map(|e| e.normalized_host()) + .collect::>(); + if hosts.iter().any(|h| h.is_empty()) { + return DerivedAlias::NotDerivable(NotDerivable::NoCommonSuffix); + } + let Some(suffix) = common_suffix(&hosts) else { + return DerivedAlias::NotDerivable(NotDerivable::NoCommonSuffix); + }; + let Some(common_port) = common_port(endpoints) else { + return DerivedAlias::NotDerivable(NotDerivable::MixedPorts); + }; + if !suffix.contains('.') { + // A shared TLD ("com") is not a meaningful common suffix. + return DerivedAlias::NotDerivable(NotDerivable::NoCommonSuffix); + } + if !is_registrable_domain(&suffix) { + return DerivedAlias::NotDerivable(NotDerivable::BarePublicSuffix); + } + if common_port == first.standard_port() { + DerivedAlias::Derived(suffix) + } else { + DerivedAlias::Derived(format!("{suffix}:{common_port}")) + } +} + +fn single_endpoint_alias(endpoint: &Endpoint) -> String { + let host = endpoint.normalized_host(); + if endpoint.port == endpoint.standard_port() { + return host; + } + format!("{host}:{}", endpoint.port) +} + +/// Longest common dot-separated suffix across `hosts`, or `None` when one host +/// equals the whole suffix (i.e. the suffix would swallow an entire host). +fn common_suffix(hosts: &[String]) -> Option { + let label_sets: Vec> = hosts.iter().map(|h| h.split('.').collect()).collect(); + let shortest = label_sets.iter().map(Vec::len).min().unwrap_or(0); + if shortest < 2 { + return None; + } + let mut shared: Vec = Vec::new(); + for index in 1..=shortest { + let label = label_sets[0][label_sets[0].len() - index]; + if label_sets + .iter() + .all(|labels| labels[labels.len() - index] == label) + { + shared.push(label.to_owned()); + } else { + break; + } + } + if shared.len() < 2 { + return None; + } + shared.reverse(); + let suffix = shared.join("."); + // A suffix equal to one of the hosts means the pool is a subdomain of + // itself; the suffix must be strictly shorter than every host. + if hosts.iter().any(|h| h == &suffix) { + return None; + } + Some(suffix) +} + +/// The shared port of all endpoints, or `None` when they differ. +fn common_port(endpoints: &[Endpoint]) -> Option { + let ports: BTreeSet = endpoints.iter().map(|e| e.port).collect(); + if ports.len() != 1 { + return None; + } + ports.into_iter().next() +} + +/// `true` when `suffix` is a registrable domain: at least two labels and not a +/// bare public suffix under the Public Suffix List. +/// +/// `psl::domain_str` returns the registrable domain of its input, which is the +/// input itself only when the input already is a registrable domain. +fn is_registrable_domain(suffix: &str) -> bool { + psl::domain_str(suffix) == Some(suffix) +} + +/// Enforce the alias rules for a create or replace operation. +/// +/// # Semantics +/// +/// * Derivable endpoints: the alias **must** equal the derived value. A +/// differing explicit alias is rejected with `400`; the exact derived value is +/// accepted as an idempotent no-op. +/// * Non-derivable endpoints: an explicit alias is **required** (`400` when +/// missing) and is used as-is. +/// * `existing_alias` is set for replaces: the alias is immutable, so any +/// operation that would change it is rejected. +/// +/// # Errors +/// +/// Returns [`OagwError::Validation`] with a message naming the conflicting or +/// missing alias. +pub fn resolve_alias( + endpoints: &[Endpoint], + supplied: Option<&str>, + existing_alias: Option<&str>, +) -> Result<(String, bool), OagwError> { + let derived = compute_derived_alias(endpoints); + let resolved = match (&derived, supplied) { + (DerivedAlias::Derived(value), Some(supplied)) => { + let normalized = validate_hostname_like(supplied)?; + if normalized != *value { + return Err(OagwError::Validation(format!( + "alias '{normalized}' does not match the derived alias '{value}'; \ + hostname based endpoints cannot be re-aliased" + ))); + } + (normalized.clone(), true) + } + (DerivedAlias::Derived(value), None) => (value.clone(), true), + (DerivedAlias::NotDerivable(_), Some(supplied)) => { + (validate_hostname_like(supplied)?, false) + } + (DerivedAlias::NotDerivable(reason), None) => { + return Err(OagwError::Validation(format!( + "an explicit alias is required for these endpoints ({}); \ + endpoints are IP based or share no registrable common suffix", + reason.as_str() + ))); + } + }; + if let Some(existing) = existing_alias + && existing != resolved.0 + { + return Err(OagwError::Validation(format!( + "alias is immutable: existing alias '{existing}' cannot be changed to '{}'; \ + delete and re-create the upstream instead", + resolved.0 + ))); + } + Ok(resolved) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ep(scheme: &str, host: &str, port: u16) -> Endpoint { + Endpoint { + scheme: scheme.to_owned(), + host: host.to_owned(), + port, + } + } + + #[test] + fn single_host_standard_port() { + let endpoints = [ep("https", "api.openai.com", 443)]; + assert_eq!( + compute_derived_alias(&endpoints), + DerivedAlias::Derived("api.openai.com".to_owned()) + ); + } + + #[test] + fn single_host_non_standard_port() { + let endpoints = [ep("https", "api.openai.com", 8443)]; + assert_eq!( + compute_derived_alias(&endpoints), + DerivedAlias::Derived("api.openai.com:8443".to_owned()) + ); + } + + #[test] + fn common_suffix_multi_host() { + let endpoints = [ + ep("https", "us.vendor.com", 443), + ep("https", "eu.vendor.com", 443), + ]; + assert_eq!( + compute_derived_alias(&endpoints), + DerivedAlias::Derived("vendor.com".to_owned()) + ); + } + + #[test] + fn common_suffix_with_port() { + let endpoints = [ + ep("https", "us.vendor.com", 8443), + ep("https", "eu.vendor.com", 8443), + ]; + assert_eq!( + compute_derived_alias(&endpoints), + DerivedAlias::Derived("vendor.com:8443".to_owned()) + ); + } + + #[test] + fn bare_public_suffix_not_derivable() { + let endpoints = [ep("https", "foo.co.uk", 443), ep("https", "bar.co.uk", 443)]; + assert_eq!( + compute_derived_alias(&endpoints), + DerivedAlias::NotDerivable(NotDerivable::BarePublicSuffix) + ); + } + + #[test] + fn no_common_suffix_not_derivable() { + let endpoints = [ + ep("https", "us.foo.com", 443), + ep("https", "eu.bar.com", 443), + ]; + assert_eq!( + compute_derived_alias(&endpoints), + DerivedAlias::NotDerivable(NotDerivable::NoCommonSuffix) + ); + } + + #[test] + fn ip_endpoints_not_derivable() { + let endpoints = [ep("https", "10.0.1.1", 443), ep("https", "10.0.1.2", 443)]; + assert_eq!( + compute_derived_alias(&endpoints), + DerivedAlias::NotDerivable(NotDerivable::IpEndpoints) + ); + } + + #[test] + fn normalizes_case_and_trailing_dot() { + let endpoints = [ep("https", "Api.OpenAI.COM.", 443)]; + assert_eq!( + compute_derived_alias(&endpoints), + DerivedAlias::Derived("api.openai.com".to_owned()) + ); + } + + #[test] + fn supplied_alias_mismatch_rejected() { + let endpoints = [ep("https", "api.openai.com", 443)]; + let err = resolve_alias(&endpoints, Some("my-service"), None) + .expect_err("mismatching alias must be rejected"); + assert!(matches!(err, OagwError::Validation(_))); + } + + #[test] + fn supplied_alias_equal_is_idempotent() { + let endpoints = [ep("https", "api.openai.com", 443)]; + let (alias, derived) = + resolve_alias(&endpoints, Some("api.openai.com"), None).expect("accepted"); + assert_eq!(alias, "api.openai.com"); + assert!(derived); + } + + #[test] + fn non_derivable_requires_alias() { + let endpoints = [ep("https", "10.0.1.1", 443)]; + assert!(resolve_alias(&endpoints, None, None).is_err()); + let (alias, _) = resolve_alias(&endpoints, Some("my-service"), None).expect("accepted"); + assert_eq!(alias, "my-service"); + } + + #[test] + fn alias_is_immutable() { + let endpoints = [ep("https", "api.openai.com", 443)]; + assert!(resolve_alias(&endpoints, None, Some("api.openai.com")).is_ok()); + assert!(resolve_alias(&endpoints, None, Some("other.example.com")).is_err()); + } + + #[test] + fn rfc1123_rejections() { + assert!(validate_hostname_like("-bad.example.com").is_err()); + assert!(validate_hostname_like("bad-.example.com").is_err()); + assert!(validate_hostname_like("a..b").is_err()); + assert!(validate_hostname_like(&"a".repeat(64)).is_err()); + assert!(validate_hostname_like("").is_err()); + } +} 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..1e89fbb --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/error.rs @@ -0,0 +1,346 @@ +// Created: 2026-08-29 by Constructor Tech +//! Domain error taxonomy. +//! +//! Every variant maps 1:1 onto a row of the normative error table: HTTP status, +//! `~cf.oagw.…` GTS type suffix, title, retriable flag and extension members. +//! The wire encoding (`application/problem+json` + `X-OAGW-Error-Source`) lives +//! in [`crate::api::rest::error`] so the domain layer stays transport-free. + +/// Fully qualified GTS type id of the wire error. +/// +/// `error_type("validation.error.v1")` → +/// `gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1` +#[must_use] +pub fn error_type(suffix: &str) -> String { + format!("gts.cf.core.errors.err.v1~cf.oagw.{suffix}") +} + +/// Extra members attached to the problem document. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct ProblemExtras { + /// Upstream id that the failing request was routed to, when known. + pub upstream_id: Option, + /// Upstream host (alias) that the failing request targeted, when known. + pub host: Option, + /// Retry guidance in seconds (`rate_limit.exceeded.v1`). + pub retry_after_seconds: Option, + /// Endpoints that would have satisfied `X-OAGW-Target-Host`. + pub valid_hosts: Option>, + /// The alias a target-host error was resolved against. + pub alias: Option, + /// Echo of the rejected `X-OAGW-Target-Host` value. + pub invalid_value: Option, + /// `{"upstreams": [...], "routes": [...]}` for `plugin.in_use.v1`. + pub referenced_by: Option, +} + +/// Resources that still reference a plugin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct References { + /// Upstream ids still binding the plugin. + pub upstreams: Vec, + /// Route ids still binding the plugin. + pub routes: Vec, +} + +impl References { + /// Empty reference set. + #[must_use] + pub fn empty() -> Self { + Self { + upstreams: Vec::new(), + routes: Vec::new(), + } + } +} + +/// Domain error for the outbound API gateway. +/// +/// `Debug` is derived; no variant carries credential material. +#[derive(Debug, Clone, PartialEq)] +pub enum OagwError { + /// 400 — request or resource validation failed. + Validation(String), + /// 400 — route specific validation failure (e.g. `path_suffix_mode: disabled`). + RouteError(String), + /// 400 — multi-endpoint upstream needs `X-OAGW-Target-Host`. + MissingTargetHost { + /// Configured endpoint hosts. + valid_hosts: Vec, + /// Alias that was resolved. + alias: String, + }, + /// 400 — `X-OAGW-Target-Host` is not a bare host. + InvalidTargetHost { + /// The rejected header value. + invalid_value: String, + }, + /// 400 — `X-OAGW-Target-Host` is not a configured endpoint. + UnknownTargetHost { + /// The rejected header value. + invalid_value: String, + /// Configured endpoint hosts. + valid_hosts: Vec, + }, + /// 401 — auth plugin could not authenticate the request. + AuthenticationFailed(String), + /// 403 — `Origin` is not in the effective CORS allowlist. + CorsOriginNotAllowed(String), + /// 403 — method is not in the effective CORS allowlist. + CorsMethodNotAllowed(String), + /// 404 — no enabled route matched the request. + RouteNotFound(String), + /// 409 — plugin is still referenced by an upstream or route. + PluginInUse(References), + /// 413 — request body exceeds the 100 MiB hard limit. + PayloadTooLarge, + /// 429 — token bucket exhausted. + RateLimitExceeded(u64), + /// 500 — `cred_store` could not resolve a referenced secret. + SecretNotFound(String), + /// 502 — upstream returned a transport-level failure. + DownstreamError(String), + /// 502 — an SSE / WebSocket upstream leg aborted mid-stream. + StreamAborted(String), + /// 502 — protocol level failure (plaintext upstream, bad framing). + ProtocolError(String), + /// 503 — upstream link unavailable. + LinkUnavailable(String), + /// 503 — circuit breaker is open for the selected endpoint. + CircuitBreakerOpen, + /// 503 — plugin id has no registered implementation. + PluginNotFound(String), + /// 504 — TCP connect timeout. + ConnectionTimeout(String), + /// 504 — request timeout (wall clock budget exceeded). + RequestTimeout(String), + /// 504 — idle timeout while streaming. + IdleTimeout(String), + /// 500 — unexpected internal failure. Never leaks internals to the wire. + Internal(String), +} + +impl OagwError { + /// HTTP status the error maps to. + #[must_use] + pub fn status(&self) -> u16 { + match self { + Self::Validation(_) + | Self::RouteError(_) + | Self::MissingTargetHost { .. } + | Self::InvalidTargetHost { .. } + | Self::UnknownTargetHost { .. } => 400, + Self::AuthenticationFailed(_) => 401, + Self::CorsOriginNotAllowed(_) | Self::CorsMethodNotAllowed(_) => 403, + Self::RouteNotFound(_) => 404, + Self::PluginInUse(_) => 409, + Self::PayloadTooLarge => 413, + Self::RateLimitExceeded(_) => 429, + Self::SecretNotFound(_) => 500, + Self::DownstreamError(_) | Self::StreamAborted(_) | Self::ProtocolError(_) => 502, + Self::LinkUnavailable(_) | Self::CircuitBreakerOpen | Self::PluginNotFound(_) => 503, + Self::ConnectionTimeout(_) | Self::RequestTimeout(_) | Self::IdleTimeout(_) => 504, + Self::Internal(_) => 500, + } + } + + /// `~cf.oagw.` GTS type suffix. + #[must_use] + pub fn type_suffix(&self) -> &'static str { + match self { + Self::Validation(_) | Self::RouteError(_) => "validation.error.v1", + Self::MissingTargetHost { .. } => "routing.missing_target_host.v1", + Self::InvalidTargetHost { .. } => "routing.invalid_target_host.v1", + Self::UnknownTargetHost { .. } => "routing.unknown_target_host.v1", + Self::AuthenticationFailed(_) => "auth.failed.v1", + Self::CorsOriginNotAllowed(_) => "cors.origin_not_allowed.v1", + Self::CorsMethodNotAllowed(_) => "cors.method_not_allowed.v1", + Self::RouteNotFound(_) => "route.not_found.v1", + Self::PluginInUse(_) => "plugin.in_use.v1", + Self::PayloadTooLarge => "payload.too_large.v1", + Self::RateLimitExceeded(_) => "rate_limit.exceeded.v1", + Self::SecretNotFound(_) => "secret.not_found.v1", + Self::DownstreamError(_) => "downstream.error.v1", + Self::StreamAborted(_) => "stream.aborted.v1", + Self::ProtocolError(_) => "protocol.error.v1", + Self::LinkUnavailable(_) => "link.unavailable.v1", + Self::CircuitBreakerOpen => "circuit_breaker.open.v1", + Self::PluginNotFound(_) => "plugin.not_found.v1", + Self::ConnectionTimeout(_) => "timeout.connection.v1", + Self::RequestTimeout(_) => "timeout.request.v1", + Self::IdleTimeout(_) => "timeout.idle.v1", + Self::Internal(_) => "internal.error.v1", + } + } + + /// Human readable RFC 9457 `title`. + #[must_use] + pub fn title(&self) -> &'static str { + match self { + Self::Validation(_) => "Validation Error", + Self::RouteError(_) => "Route Error", + Self::MissingTargetHost { .. } => "Missing Target Host", + Self::InvalidTargetHost { .. } => "Invalid Target Host", + Self::UnknownTargetHost { .. } => "Unknown Target Host", + Self::AuthenticationFailed(_) => "Authentication Failed", + Self::CorsOriginNotAllowed(_) => "CORS Origin Not Allowed", + Self::CorsMethodNotAllowed(_) => "CORS Method Not Allowed", + Self::RouteNotFound(_) => "Route Not Found", + Self::PluginInUse(_) => "Plugin In Use", + Self::PayloadTooLarge => "Payload Too Large", + Self::RateLimitExceeded(_) => "Rate Limit Exceeded", + Self::SecretNotFound(_) => "Secret Not Found", + Self::DownstreamError(_) => "Downstream Error", + Self::StreamAborted(_) => "Stream Aborted", + Self::ProtocolError(_) => "Protocol Error", + Self::LinkUnavailable(_) => "Link Unavailable", + Self::CircuitBreakerOpen => "Circuit Breaker Open", + Self::PluginNotFound(_) => "Plugin Not Found", + Self::ConnectionTimeout(_) => "Connection Timeout", + Self::RequestTimeout(_) => "Request Timeout", + Self::IdleTimeout(_) => "Idle Timeout", + Self::Internal(_) => "Internal Error", + } + } + + /// RFC 9457 `detail` message. + /// + /// Security: never includes request/response bodies, query parameters, + /// headers, or credential material — only the values the error table + /// explicitly allows (identifiers, host names, invalid target-host values). + #[must_use] + pub fn detail(&self) -> String { + match self { + Self::Validation(msg) + | Self::RouteError(msg) + | Self::AuthenticationFailed(msg) + | Self::CorsOriginNotAllowed(msg) + | Self::CorsMethodNotAllowed(msg) + | Self::RouteNotFound(msg) + | Self::DownstreamError(msg) + | Self::StreamAborted(msg) + | Self::ProtocolError(msg) + | Self::LinkUnavailable(msg) + | Self::PluginNotFound(msg) + | Self::ConnectionTimeout(msg) + | Self::RequestTimeout(msg) + | Self::IdleTimeout(msg) + | Self::SecretNotFound(msg) + | Self::Internal(msg) => msg.clone(), + Self::MissingTargetHost { valid_hosts, alias } => format!( + "upstream '{alias}' has multiple endpoints with a common-suffix alias; \ + the 'x-oagw-target-host' header is required and must be one of: {valid_hosts:?}" + ), + Self::InvalidTargetHost { invalid_value } => format!( + "the 'x-oagw-target-host' header must be a bare host name or IP address, \ + got '{invalid_value}'" + ), + Self::UnknownTargetHost { + invalid_value, + valid_hosts, + } => format!( + "target host '{invalid_value}' does not match any configured endpoint \ + (valid hosts: {valid_hosts:?})" + ), + Self::PluginInUse(_) => { + "plugin is still referenced by at least one upstream or route".to_owned() + } + Self::PayloadTooLarge => "request body exceeds the 100 MiB limit".to_owned(), + Self::RateLimitExceeded(retry) => { + format!("rate limit exceeded; retry after {retry} second(s)") + } + Self::CircuitBreakerOpen => { + "circuit breaker is open for the selected upstream endpoint".to_owned() + } + } + } + + /// Whether the client may safely retry the request. + #[must_use] + pub fn retriable(&self) -> bool { + matches!( + self, + Self::RateLimitExceeded(_) + | Self::DownstreamError(_) + | Self::LinkUnavailable(_) + | Self::CircuitBreakerOpen + | Self::ConnectionTimeout(_) + | Self::RequestTimeout(_) + | Self::IdleTimeout(_) + ) + } + + /// Extension members carried by this error. + #[must_use] + pub fn extras(&self) -> ProblemExtras { + match self { + Self::MissingTargetHost { valid_hosts, alias } => ProblemExtras { + valid_hosts: Some(valid_hosts.clone()), + alias: Some(alias.clone()), + ..ProblemExtras::default() + }, + Self::InvalidTargetHost { invalid_value } => ProblemExtras { + invalid_value: Some(invalid_value.clone()), + ..ProblemExtras::default() + }, + Self::UnknownTargetHost { + invalid_value, + valid_hosts, + } => ProblemExtras { + invalid_value: Some(invalid_value.clone()), + valid_hosts: Some(valid_hosts.clone()), + ..ProblemExtras::default() + }, + Self::PluginInUse(references) => ProblemExtras { + referenced_by: Some(references.clone()), + ..ProblemExtras::default() + }, + Self::RateLimitExceeded(retry) => ProblemExtras { + retry_after_seconds: Some(*retry), + ..ProblemExtras::default() + }, + _ => ProblemExtras::default(), + } + } +} + +impl std::fmt::Display for OagwError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({})", self.status(), self.type_suffix()) + } +} + +impl std::error::Error for OagwError {} + +impl From for OagwError { + fn from(error: toolkit_http::HttpError) -> Self { + use toolkit_http::HttpError; + match error { + HttpError::Timeout(_) => Self::RequestTimeout("upstream request timed out".to_owned()), + HttpError::DeadlineExceeded(_) => { + Self::RequestTimeout("upstream deadline exceeded".to_owned()) + } + HttpError::Tls(_) => Self::LinkUnavailable("upstream TLS handshake failed".to_owned()), + HttpError::InsecureTransport => Self::ProtocolError( + "upstream scheme is not allowed by the transport policy".to_owned(), + ), + HttpError::InvalidScheme { .. } | HttpError::InvalidUri { .. } => { + Self::ProtocolError("upstream target is not a reachable URL".to_owned()) + } + HttpError::Overloaded | HttpError::ServiceClosed => { + Self::LinkUnavailable("outbound transport is saturated".to_owned()) + } + // Every remaining transport failure collapses into one surface so + // no internal address, URL or header value reaches the caller. + HttpError::RequestBuild(_) + | HttpError::InvalidHeaderName(_) + | HttpError::InvalidHeaderValue(_) + | HttpError::Transport(_) + | HttpError::BodyTooLarge { .. } + | HttpError::HttpStatus { .. } + | HttpError::Json(_) + | HttpError::FormEncode(_) + | _ => Self::DownstreamError("upstream transport failure".to_owned()), + } + } +} diff --git a/gears/system/oagw/oagw/src/domain/merge.rs b/gears/system/oagw/oagw/src/domain/merge.rs new file mode 100644 index 0000000..d2808e8 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/merge.rs @@ -0,0 +1,554 @@ +// Created: 2026-08-29 by Constructor Tech +//! Hierarchical merge of effective configuration (DESIGN §3.2). +//! +//! For each config block the chain is walked **ancestor → descendant**: +//! +//! | Field | Merge strategy | +//! |---|---| +//! | `auth` | `private` not inherited; `inherit` overridable; `enforce` forced | +//! | `rate_limit` | `min(ancestor, descendant)` per numeric field | +//! | `plugins` | concatenate, dedupe preserving first occurrence | +//! | `cors` | union of origins/methods/exposed headers when `inherit` | +//! | `headers` | same sharing semantics as `auth` | +//! | `tags` | always union | +//! | `enabled` | a disabled ancestor disables every descendant | + +use super::model::{ + AuthConfig, CorsConfig, HeadersConfig, PluginsConfig, RateAlgorithm, RateLimitConfig, Sharing, + Sustained, +}; +use uuid::Uuid; + +/// One link of the tenant chain, ordered ancestor first. +#[derive(Debug, Clone)] +pub struct ChainEntry { + /// Owning tenant id. + pub tenant_id: uuid::Uuid, + /// Upstream record for this tenant, when it exists. + pub upstream: Option, +} + +/// Effective, merged configuration for a proxied request. +#[derive(Debug, Clone)] +pub struct EffectiveConfig { + /// Effective auth plugin binding. + pub auth: Option, + /// Effective header transformation rules. + pub headers: HeadersConfig, + /// Effective plugin chain (ancestor items first). + pub plugins: PluginsConfig, + /// Effective rate limit. + pub rate_limit: Option, + /// Id of the resource that configured [`Self::rate_limit`]. + /// + /// ADR-0003 keys the bucket on `{resource_type}:{resource_id}` of the + /// *configuring* resource, so an upstream limit is one budget shared by + /// every route that does not override it, while a route limit is its own. + pub rate_limit_owner: Option, + /// Effective CORS configuration. + pub cors: Option, + /// Union of all tags. + pub tags: Vec, + /// `false` when any entry in the chain is disabled. + pub enabled: bool, +} + +fn is_enforced(sharing: Sharing) -> bool { + sharing == Sharing::Enforce +} + +fn is_private(sharing: Sharing) -> bool { + sharing == Sharing::Private +} + +/// Merge the tenant chain into an effective configuration. +/// +/// `chain` is ordered ancestor → descendant. +#[must_use] +pub fn merge_chain(chain: &[ChainEntry]) -> EffectiveConfig { + let mut auth: Option = None; + let mut headers = HeadersConfig::default(); + let mut plugins = PluginsConfig::default(); + let mut rate_limit: Option = None; + let mut rate_limit_owner: Option = None; + let mut cors: Option = None; + let mut tags: Vec = Vec::new(); + let mut enabled = true; + + for entry in chain { + let Some(spec) = entry.upstream.as_ref().map(|u| &u.spec) else { + continue; + }; + if !spec.enabled { + enabled = false; + } + for tag in &spec.tags { + if !tags.contains(tag) { + tags.push(tag.clone()); + } + } + auth = merge_auth(auth, spec.auth.clone()); + headers = merge_headers(headers, spec.headers.clone()); + plugins = merge_plugins(plugins, spec.plugins.clone()); + if spec.rate_limit.is_some() { + // The descendant's block wins the merged `sharing` gate, so it is + // also the resource the effective budget belongs to. + rate_limit_owner = Some(entry.upstream.as_ref().expect("checked").id); + } + rate_limit = merge_rate_limit(rate_limit, spec.rate_limit.clone()); + cors = merge_cors(cors, spec.cors.clone()); + } + + EffectiveConfig { + auth, + headers, + plugins, + rate_limit, + rate_limit_owner, + cors, + tags, + enabled, + } +} + +fn merge_maps( + current: &std::collections::BTreeMap, + next: &std::collections::BTreeMap, +) -> std::collections::BTreeMap { + let mut merged = current.clone(); + for (key, value) in next { + merged.insert(key.clone(), value.clone()); + } + merged +} + +fn merge_vecs(current: &[String], next: &[String]) -> Vec { + let mut merged = current.to_vec(); + for item in next { + if !merged.contains(item) { + merged.push(item.clone()); + } + } + merged +} + +/// Merge one `auth` block: `private` ancestors are not inherited, `enforce` +/// ancestors win, `inherit` ancestors are overridden by the descendant. +#[must_use] +pub fn merge_auth(current: Option, next: Option) -> Option { + // `private` blocks an ancestor's value only when a descendant supplies one. + let Some(descendant) = next else { + return current; + }; + let ancestor = match current { + Some(config) if is_private(config.sharing) => None, + other => other, + }; + match (ancestor, Some(descendant)) { + (None, next) => next, + (Some(ancestor), None) => Some(ancestor), + (Some(ancestor), Some(descendant)) => { + if is_enforced(ancestor.sharing) { + Some(ancestor) + } else { + Some(descendant) + } + } + } +} + +/// Merge `headers` blocks using the `auth` sharing semantics. +#[must_use] +pub fn merge_headers(current: HeadersConfig, next: Option) -> HeadersConfig { + // `private` blocks an ancestor's rules only when a descendant supplies some. + let Some(descendant) = next else { + return current; + }; + let current_sharing = current.sharing.unwrap_or(Sharing::Private); + let ancestor = if is_private(current_sharing) { + HeadersConfig::default() + } else { + current + }; + let forced = is_enforced(descendant.sharing.unwrap_or(Sharing::Private)); + if forced { + return descendant; + } + HeadersConfig { + sharing: descendant.sharing, + request: super::model::RequestHeaders { + set: merge_maps(&ancestor.request.set, &descendant.request.set), + add: merge_maps(&ancestor.request.add, &descendant.request.add), + remove: merge_vecs(&ancestor.request.remove, &descendant.request.remove), + passthrough: if descendant.request.passthrough == super::model::Passthrough::None { + ancestor.request.passthrough + } else { + descendant.request.passthrough + }, + passthrough_allowlist: merge_vecs( + &ancestor.request.passthrough_allowlist, + &descendant.request.passthrough_allowlist, + ), + }, + response: super::model::ResponseHeaders { + set: merge_maps(&ancestor.response.set, &descendant.response.set), + add: merge_maps(&ancestor.response.add, &descendant.response.add), + remove: merge_vecs(&ancestor.response.remove, &descendant.response.remove), + }, + } +} + +/// Concatenate two plugin chains, deduping and preserving first occurrence. +#[must_use] +pub fn merge_plugins(current: PluginsConfig, next: Option) -> PluginsConfig { + // `private` blocks an ancestor's chain only when a descendant binds one. + let Some(descendant) = next else { + return current; + }; + let ancestor = if is_private(current.sharing) { + PluginsConfig::default() + } else { + current + }; + let mut items = ancestor.items; + for item in descendant.items { + // An entry is inherited once per reference: a descendant that rebinds + // the same plugin with its own configuration replaces the ancestor's + // entry instead of running the plugin twice. + if let Some(existing) = items + .iter_mut() + .find(|existing| existing.reference() == item.reference()) + { + *existing = item; + } else { + items.push(item); + } + } + PluginsConfig { + sharing: descendant.sharing, + items, + } +} + +/// Merge two rate limits with `min()` semantics per numeric field. +#[must_use] +pub fn merge_rate_limit( + current: Option, + next: Option, +) -> Option { + // `private` blocks an ancestor limit only when a descendant supplies one. + let Some(descendant) = next else { + return current; + }; + let ancestor = match current { + Some(config) if is_private(config.sharing) => None, + other => other, + }; + let Some(ancestor) = ancestor else { + return Some(descendant); + }; + let mut merged = ancestor.clone(); + merged.sharing = descendant.sharing; + merged.sustained = Sustained { + rate: ancestor.sustained.rate.min(descendant.sustained.rate), + window: pick_window(&ancestor.sustained, &descendant.sustained), + }; + merged.burst = min_option_u32( + ancestor.burst.map(|b| b.capacity), + descendant.burst.map(|b| b.capacity), + ) + .map(|capacity| super::model::Burst { capacity }); + merged.cost = min_option_u32(ancestor.cost, descendant.cost); + merged.response_headers = descendant.response_headers.or(ancestor.response_headers); + merged.scope = descendant.scope; + Some(merged) +} + +fn rate_per_second(sustained: &Sustained) -> f64 { + let seconds = sustained.window.seconds(); + if seconds == 0 { + return 0.0; + } + f64::from(sustained.rate) / f64::from(u32::try_from(seconds).unwrap_or(u32::MAX)) +} + +fn min_option_u32(current: Option, next: Option) -> Option { + match (current, next) { + (Some(a), Some(b)) => Some(a.min(b)), + (Some(a), None) => Some(a), + (None, Some(b)) => Some(b), + (None, None) => None, + } +} + +fn pick_window(current: &Sustained, next: &Sustained) -> super::model::RateWindow { + // Keep the window of the stricter side so the effective throughput is the + // smaller of the two in tokens-per-second terms. + if rate_per_second(next) < rate_per_second(current) { + next.window + } else { + current.window + } +} + +/// Merge CORS blocks: `inherit` unions the allowlists, `enforce` forces the +/// ancestor value, `private` is not inherited. +#[must_use] +pub fn merge_cors(current: Option, next: Option) -> Option { + // `private` blocks an ancestor's allowlists only when a descendant supplies some. + let Some(descendant) = next else { + return current; + }; + let ancestor = match current { + Some(config) if is_private(config.sharing) => None, + other => other, + }; + let Some(ancestor) = ancestor else { + return Some(descendant); + }; + if is_enforced(ancestor.sharing) { + return Some(ancestor); + } + Some(CorsConfig { + sharing: descendant.sharing, + enabled: ancestor.enabled || descendant.enabled, + allowed_origins: merge_vecs(&ancestor.allowed_origins, &descendant.allowed_origins), + allowed_methods: Some(merge_vecs(&ancestor.methods(), &descendant.methods())), + expose_headers: merge_vecs(&ancestor.expose_headers, &descendant.expose_headers), + allow_credentials: ancestor.allow_credentials || descendant.allow_credentials, + }) +} + +/// Merge two route-level rate limits with the same `min()` semantics, used when +/// an upstream limit is combined with a route limit. +#[must_use] +pub fn merge_route_rate_limit( + upstream: Option, + route: Option, +) -> Option { + let Some(route) = route else { + return upstream; + }; + let Some(upstream) = upstream else { + return Some(route); + }; + let mut merged = upstream.clone(); + merged.sharing = route.sharing; + merged.algorithm = route.algorithm; + merged.sustained = Sustained { + rate: upstream.sustained.rate.min(route.sustained.rate), + window: pick_window(&upstream.sustained, &route.sustained), + }; + merged.burst = min_option_u32( + upstream.burst.map(|b| b.capacity), + route.burst.map(|b| b.capacity), + ) + .map(|capacity| super::model::Burst { capacity }); + merged.cost = min_option_u32(upstream.cost, route.cost); + merged.scope = if route.scope == super::model::RateScope::Tenant + && upstream.scope != super::model::RateScope::Tenant + { + upstream.scope + } else { + route.scope + }; + merged.response_headers = route.response_headers.or(upstream.response_headers); + Some(merged) +} + +/// Rate limit algorithm of the merged configuration. +#[must_use] +pub fn merged_algorithm(config: &RateLimitConfig) -> RateAlgorithm { + config.algorithm +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::model::{Endpoint, ServerConfig, Upstream, UpstreamCreate}; + use uuid::Uuid; + + fn upstream(tenant: Uuid, alias: &str, spec: UpstreamCreate) -> Upstream { + Upstream { + id: Uuid::new_v4(), + tenant_id: tenant, + alias: alias.to_owned(), + alias_derived: false, + created_at: "2026-01-01T00:00:00Z".to_owned(), + updated_at: "2026-01-01T00:00:00Z".to_owned(), + spec, + } + } + + fn spec() -> UpstreamCreate { + UpstreamCreate { + enabled: true, + alias: None, + tags: Vec::new(), + server: ServerConfig { + endpoints: vec![Endpoint { + scheme: "https".to_owned(), + host: "api.example.com".to_owned(), + port: 443, + }], + }, + protocol: crate::domain::model::PROTOCOL_HTTP.to_owned(), + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + } + } + + fn auth(plugin: &str, sharing: Sharing) -> AuthConfig { + AuthConfig { + plugin_type: format!("gts.cf.core.oagw.auth_plugin.v1~{plugin}"), + sharing, + config: serde_json::Map::new(), + } + } + + fn rate(sharing: Sharing, rate: u32, capacity: u32, cost: u32) -> RateLimitConfig { + RateLimitConfig { + sharing, + algorithm: RateAlgorithm::TokenBucket, + sustained: Sustained { + rate, + window: super::super::model::RateWindow::Second, + }, + burst: Some(super::super::model::Burst { capacity }), + budget: None, + scope: super::super::model::RateScope::Tenant, + strategy: super::super::model::RateStrategy::Reject, + cost: Some(cost), + response_headers: None, + } + } + + #[test] + fn enforced_ancestor_auth_wins() { + let ancestor = auth("cf.core.oagw.apikey.v1", Sharing::Enforce); + let descendant = auth("cf.core.oagw.noop.v1", Sharing::Inherit); + let merged = merge_auth(Some(ancestor.clone()), Some(descendant)); + assert_eq!(merged, Some(ancestor)); + } + + #[test] + fn private_ancestor_auth_is_kept_without_a_descendant_block() { + // `private` hides an ancestor's value from a descendant that supplies + // one; with no descendant block the configured auth still applies. + let ancestor = auth("cf.core.oagw.apikey.v1", Sharing::Private); + let merged = merge_auth(Some(ancestor.clone()), None); + assert_eq!(merged, Some(ancestor)); + } + + #[test] + fn private_ancestor_auth_is_dropped_for_a_descendant_block() { + let ancestor = auth("cf.core.oagw.apikey.v1", Sharing::Private); + let descendant = auth("cf.core.oagw.noop.v1", Sharing::Inherit); + let merged = merge_auth(Some(ancestor), Some(descendant)); + assert!(merged.is_some()); + } + + #[test] + fn rate_limit_min_merge() { + let merged = merge_rate_limit( + Some(rate(Sharing::Enforce, 10, 20, 5)), + Some(rate(Sharing::Inherit, 100, 50, 2)), + ) + .expect("merged"); + assert_eq!(merged.sustained.rate, 10); + assert_eq!(merged.capacity(), 20); + assert_eq!(merged.cost(), 2); + } + + #[test] + fn ancestor_disabled_disables_descendant() { + let mut ancestor = spec(); + ancestor.enabled = false; + let chain = [ + ChainEntry { + tenant_id: Uuid::new_v4(), + upstream: Some(upstream(Uuid::new_v4(), "api.example.com", ancestor)), + }, + ChainEntry { + tenant_id: Uuid::new_v4(), + upstream: Some(upstream(Uuid::new_v4(), "api.example.com", spec())), + }, + ]; + let merged = merge_chain(&chain); + assert!(!merged.enabled); + } + + #[test] + fn tags_union() { + let mut ancestor = spec(); + ancestor.tags = vec!["llm".to_owned(), "openai".to_owned()]; + let mut descendant = spec(); + descendant.tags = vec!["openai".to_owned(), "prod".to_owned()]; + let chain = [ + ChainEntry { + tenant_id: Uuid::new_v4(), + upstream: Some(upstream(Uuid::new_v4(), "api.example.com", ancestor)), + }, + ChainEntry { + tenant_id: Uuid::new_v4(), + upstream: Some(upstream(Uuid::new_v4(), "api.example.com", descendant)), + }, + ]; + let merged = merge_chain(&chain); + assert_eq!(merged.tags, vec!["llm", "openai", "prod"]); + } + + #[test] + fn plugins_concatenate_ancestor_first() { + let ancestor = PluginsConfig { + sharing: Sharing::Inherit, + items: vec![crate::domain::model::PluginItem::Reference( + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1".to_owned(), + )], + }; + let descendant = PluginsConfig { + sharing: Sharing::Inherit, + items: vec![ + crate::domain::model::PluginItem::Reference( + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1".to_owned(), + ), + crate::domain::model::PluginItem::Reference( + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1".to_owned(), + ), + ], + }; + let merged = merge_plugins(ancestor, Some(descendant)); + assert_eq!(merged.items.len(), 2); + assert_eq!( + merged.items[0].reference(), + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1" + ); + } + + #[test] + fn cors_union_on_inherit() { + let ancestor = CorsConfig { + sharing: Sharing::Inherit, + enabled: true, + allowed_origins: vec!["https://a.example.com".to_owned()], + allowed_methods: Some(vec!["GET".to_owned()]), + expose_headers: vec!["x-a".to_owned()], + allow_credentials: false, + }; + let descendant = CorsConfig { + sharing: Sharing::Inherit, + enabled: true, + allowed_origins: vec!["https://b.example.com".to_owned()], + allowed_methods: Some(vec!["POST".to_owned()]), + expose_headers: vec!["x-b".to_owned()], + allow_credentials: false, + }; + let merged = merge_cors(Some(ancestor), Some(descendant)).expect("merged"); + assert_eq!(merged.allowed_origins.len(), 2); + assert_eq!(merged.methods().len(), 2); + assert_eq!(merged.expose_headers.len(), 2); + } +} 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..52cace5 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/mod.rs @@ -0,0 +1,16 @@ +// Created: 2026-08-29 by Constructor Tech +//! OAGW domain layer: model, validation, alias derivation, hierarchical +//! merge, rate-limit maths, plugin contracts and the control-plane service. +//! +//! The domain never touches `axum::extract` / `toolkit` wiring; it only uses +//! `axum::http` primitives for status codes and header maps. + +pub mod alias; +pub mod error; +pub mod merge; +pub mod model; +pub mod plugin; +pub mod ports; +pub mod rate_limit; +pub mod repo; +pub mod services; 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..9ee6751 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/model.rs @@ -0,0 +1,1131 @@ +// Created: 2026-08-29 by Constructor Tech +//! Domain model for the outbound API gateway control plane. +//! +//! The wire shapes mirror `docs/schemas/upstream.v1.schema.json` and +//! `docs/schemas/route.v1.schema.json` exactly, including +//! `additionalProperties: false` (unknown fields are rejected) and the +//! documented defaults. + +use std::collections::BTreeSet; +use std::str::FromStr; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use super::error::OagwError; +use super::plugin; + +/// Hard request-body limit (DESIGN constraint `cpt-cf-oagw-constraint-body-limit`). +pub const MAX_BODY_BYTES: usize = 100 * 1024 * 1024; + +/// `gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1` +pub const PROTOCOL_HTTP: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +/// `gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1` +pub const PROTOCOL_GRPC: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1"; + +/// Hierarchical sharing mode. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Sharing { + /// Not visible to descendants. + #[default] + Private, + /// Descendants may override. + Inherit, + /// Descendants cannot override. + Enforce, +} + +/// Upstream endpoint (`scheme://host:port`). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Endpoint { + /// `https` | `wss` | `wt` | `grpc` (and `http` for explicitly permitted + /// plaintext upstreams). + pub scheme: String, + /// RFC 1123 hostname, IPv4 or IPv6 literal. + pub host: String, + /// Defaults to `443`. + #[serde(default = "default_port")] + pub port: u16, +} + +fn default_port() -> u16 { + 443 +} + +impl Endpoint { + /// Standard port for the endpoint scheme (HTTP 80, everything else 443). + #[must_use] + pub fn standard_port(&self) -> u16 { + if self.scheme == "http" { 80 } else { 443 } + } + + /// `true` when the host is an IPv4 / IPv6 literal. + #[must_use] + pub fn is_ip(&self) -> bool { + std::net::IpAddr::from_str(&self.host).is_ok() + } + + /// Normalized host for comparisons (ASCII lowercase, trailing dot stripped). + #[must_use] + pub fn normalized_host(&self) -> String { + normalize_host(&self.host) + } +} + +/// Normalize a host or alias: ASCII lowercase, trim, strip trailing dots. +#[must_use] +pub fn normalize_host(value: &str) -> String { + value + .trim() + .to_ascii_lowercase() + .trim_end_matches('.') + .to_owned() +} + +/// Validate a host / alias per RFC 1123: labels of 1..=63 characters, total +/// length <= 253, only ASCII alphanumerics and hyphens inside labels, labels +/// must not start or end with a hyphen, and an optional single `:port` suffix. +/// +/// A trailing dot (FQDN notation) is tolerated and stripped. +/// +/// # Errors +/// +/// Returns [`OagwError::Validation`] when the value is empty, longer than 253 +/// characters, contains an invalid character, or a label is empty / longer than +/// 63 characters / starts or ends with a hyphen. +pub fn validate_hostname_like(value: &str) -> Result { + let normalized = normalize_host(value); + if normalized.is_empty() { + return Err(OagwError::Validation( + "alias/host must not be empty".to_owned(), + )); + } + if normalized.len() > 253 { + return Err(OagwError::Validation( + "alias/host exceeds the 253 character limit".to_owned(), + )); + } + let (host_part, port_part) = split_port(&normalized); + if let Some(port) = port_part { + if host_part.is_empty() { + return Err(OagwError::Validation( + "alias/host must not start with ':'".to_owned(), + )); + } + if host_part.contains(':') { + return Err(OagwError::Validation( + "alias/host must be an RFC 1123 hostname optionally followed by ':port' \ + (IPv6 literals are not valid aliases)" + .to_owned(), + )); + } + if port > 65_535 { + return Err(OagwError::Validation(format!( + "port '{port}' in alias/host is out of range" + ))); + } + } + if host_part.is_empty() { + return Err(OagwError::Validation( + "alias/host must not be empty".to_owned(), + )); + } + for label in host_part.split('.') { + validate_label(label)?; + } + Ok(normalized) +} + +fn validate_label(label: &str) -> Result<(), OagwError> { + if label.is_empty() { + return Err(OagwError::Validation( + "alias/host must not contain empty labels".to_owned(), + )); + } + if label.len() > 63 { + return Err(OagwError::Validation( + "alias/host label exceeds 63 characters".to_owned(), + )); + } + if label.starts_with('-') || label.ends_with('-') { + return Err(OagwError::Validation( + "alias/host labels must not start or end with '-'".to_owned(), + )); + } + if !label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + { + return Err(OagwError::Validation(format!( + "alias/host label '{label}' contains characters outside [a-z0-9-]" + ))); + } + Ok(()) +} + +/// Split `host:port` into `(host, Some(port))`, keeping values whose tail is +/// not a decimal port intact. +fn split_port(value: &str) -> (&str, Option) { + match value.rsplit_once(':') { + Some((host, port)) => port + .parse::() + .map_or((value, None), |parsed| (host, Some(parsed))), + None => (value, None), + } +} + +/// Server configuration: a pool of endpoints. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ServerConfig { + /// One or more endpoints. All endpoints share `scheme` and `port`. + pub endpoints: Vec, +} + +/// Outbound authentication plugin binding. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct AuthConfig { + /// Plugin GTS identifier (`gts.cf.core.oagw.auth_plugin.v1~`). + #[serde(rename = "type")] + pub plugin_type: String, + /// Hierarchical sharing mode. + #[serde(default)] + pub sharing: Sharing, + /// Plugin configuration object. + #[serde(default)] + pub config: serde_json::Map, +} + +/// Header transformation rules. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct HeadersConfig { + /// Hierarchical sharing mode (`private` by default, matching the schema + /// which does not expose the field). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sharing: Option, + /// Rules applied to the request leg. + #[serde(default)] + pub request: RequestHeaders, + /// Rules applied to the response leg. + #[serde(default)] + pub response: ResponseHeaders, +} + +/// Request header rules. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct RequestHeaders { + /// Overwrite if present. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub set: std::collections::BTreeMap, + /// Append, duplicates allowed. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub add: std::collections::BTreeMap, + /// Header names to drop from the inbound request. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub remove: Vec, + /// `none` | `allowlist` | `all`. + #[serde(default)] + pub passthrough: Passthrough, + /// Headers forwarded when `passthrough` is `allowlist`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub passthrough_allowlist: Vec, +} + +/// Which inbound request headers are forwarded upstream. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum Passthrough { + /// Forward none (default). + #[default] + None, + /// Forward only the allowlisted names. + Allowlist, + /// Forward everything except routing and hop-by-hop headers. + All, +} + +/// Response header rules. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct ResponseHeaders { + /// Overwrite if present. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub set: std::collections::BTreeMap, + /// Append, duplicates allowed. + #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")] + pub add: std::collections::BTreeMap, + /// Names stripped from the upstream response. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub remove: Vec, +} + +/// Plugin chain binding. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct PluginsConfig { + /// Hierarchical sharing mode. + #[serde(default)] + pub sharing: Sharing, + /// GTS ids of built-ins or UUIDs of custom plugins, optionally carrying + /// that plugin's configuration (ADR-0009). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub items: Vec, +} + +/// One entry of a plugin chain: a bare reference or a reference with config. +/// +/// ADR-0009 binds `required_headers.v1` as +/// `{"plugin_ref": "…required_headers.v1", "config": {…}}`; the JSON Schema's +/// `oneOf` also admits the bare string form used by plugins that need no +/// configuration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PluginItem { + /// A plugin reference without configuration. + Reference(String), + /// A plugin reference with its per-binding configuration. + Configured { + /// GTS id or UUID of the plugin. + plugin_ref: String, + /// Configuration handed to the plugin at request time. + #[serde(default)] + config: serde_json::Value, + }, +} + +impl PluginItem { + /// The plugin reference this entry binds. + #[must_use] + pub fn reference(&self) -> &str { + match self { + Self::Reference(value) + | Self::Configured { + plugin_ref: value, .. + } => value, + } + } + + /// The configuration this entry carries, `null` when it has none. + #[must_use] + pub fn config(&self) -> serde_json::Value { + match self { + Self::Reference(_) => serde_json::Value::Null, + Self::Configured { config, .. } => config.clone(), + } + } +} + +/// Sustained rate definition. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Sustained { + /// Tokens replenished per `window`. + pub rate: u32, + /// `second` | `minute` | `hour` | `day`. + #[serde(default)] + pub window: RateWindow, +} + +impl Default for Sustained { + fn default() -> Self { + Self { + rate: 1, + window: RateWindow::Second, + } + } +} + +/// Time window for the sustained rate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateWindow { + /// One second (default). + #[default] + Second, + /// One minute. + Minute, + /// One hour. + Hour, + /// One day. + Day, +} + +impl RateWindow { + /// Window length in seconds. + #[must_use] + pub fn seconds(self) -> u64 { + match self { + Self::Second => 1, + Self::Minute => 60, + Self::Hour => 3_600, + Self::Day => 86_400, + } + } +} + +/// Burst capacity. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Burst { + /// Bucket capacity; defaults to `sustained.rate`. + pub capacity: u32, +} + +/// Token budget allocation across the hierarchy. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct Budget { + /// `unlimited` | `allocated` | `shared`. + #[serde(default)] + pub mode: BudgetMode, + /// Total tokens per window when the mode is not `unlimited`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total: Option, + /// Overcommit ratio (default 1.0). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overcommit_ratio: Option, +} + +/// Budget mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum BudgetMode { + /// No budget cap (default). + #[default] + Unlimited, + /// Explicitly allocated. + Allocated, + /// Shared across the hierarchy. + Shared, +} + +/// Rate limiting configuration. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RateLimitConfig { + /// Hierarchical sharing mode. + #[serde(default)] + pub sharing: Sharing, + /// `token_bucket` | `sliding_window`. + #[serde(default)] + pub algorithm: RateAlgorithm, + /// Sustained rate. + pub sustained: Sustained, + /// Burst capacity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub burst: Option, + /// Budget allocation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub budget: Option, + /// Counter scope; default `tenant`. + #[serde(default)] + pub scope: RateScope, + /// `reject` | `queue` | `degrade`. + #[serde(default)] + pub strategy: RateStrategy, + /// Tokens consumed per request; default 1. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cost: Option, + /// Emit `X-RateLimit-*` headers; default `true`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub response_headers: Option, +} + +impl RateLimitConfig { + /// Bucket capacity: `burst.capacity` when set, else `sustained.rate`. + #[must_use] + pub fn capacity(&self) -> u32 { + self.burst.map_or(self.sustained.rate, |b| b.capacity) + } + + /// Tokens consumed per request (default 1). + #[must_use] + pub fn cost(&self) -> u32 { + self.cost.unwrap_or(1) + } + + /// `X-RateLimit-*` headers enabled (default `true`). + #[must_use] + pub fn response_headers(&self) -> bool { + self.response_headers.unwrap_or(true) + } +} + +/// Rate limit algorithm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum RateAlgorithm { + /// Token bucket (default). + #[default] + TokenBucket, + /// Sliding window. + SlidingWindow, +} + +/// Rate limit counter scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateScope { + /// One bucket shared by every request. + Global, + /// One bucket per tenant (default). + #[default] + Tenant, + /// One bucket per authenticated subject. + User, + /// One bucket per client IP. + Ip, + /// One bucket per route. + Route, +} + +/// Behaviour when the limit is exceeded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateStrategy { + /// Reject with `429` (default). + #[default] + Reject, + /// Queue the request. + Queue, + /// Serve a degraded response. + Degrade, +} + +/// CORS configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct CorsConfig { + /// Hierarchical sharing mode. + #[serde(default)] + pub sharing: Sharing, + /// CORS is off unless explicitly enabled. + pub enabled: bool, + /// Origins allowed; `["*"]` allows any origin. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub allowed_origins: Vec, + /// Methods allowed; default `["GET", "POST"]`. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub allowed_methods: Option>, + /// 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 specific origins. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub allow_credentials: bool, +} + +impl CorsConfig { + /// Effective methods: the configured set or the `GET`/`POST` default. + #[must_use] + pub fn methods(&self) -> Vec { + self.allowed_methods + .clone() + .unwrap_or_else(|| vec!["GET".to_owned(), "POST".to_owned()]) + } +} + +/// Protocol-scoped inbound match rules. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MatchConfig { + /// HTTP match; exactly one of `http` / `grpc` must be present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http: Option, + /// gRPC match; exactly one of `http` / `grpc` must be present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grpc: Option, +} + +/// HTTP match rules. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct HttpMatch { + /// Method allowlist (minimum one). + pub methods: Vec, + /// Path pattern used as a prefix. + pub path: String, + /// Only these query parameters are forwarded; empty forwards none. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub query_allowlist: Vec, + /// `disabled` | `append` (default `append`). + #[serde(default)] + pub path_suffix_mode: PathSuffixMode, +} + +/// How `/{path_suffix}` from the proxy URL is treated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PathSuffixMode { + /// Append the suffix to `match.path` (default). + #[default] + Append, + /// A non-empty suffix is rejected with `400 RouteError`. + Disabled, +} + +/// gRPC match rules (catalogued; no gRPC proxy code path is reachable in MVP). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GrpcMatch { + /// Fully qualified service name. + pub service: String, + /// RPC method name. + pub method: String, +} + +fn default_true() -> bool { + true +} + +/// Creation payload for an upstream. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct UpstreamCreate { + /// Enabled flag; default `true`. + #[serde(default = "default_true")] + pub enabled: bool, + /// Explicit alias; required for non-derivable endpoints. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub alias: Option, + /// Discovery tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Endpoint pool. + pub server: ServerConfig, + /// Protocol GTS identifier. + pub protocol: String, + /// Auth plugin binding. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + /// Header transformation rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option, + /// Plugin chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limits. + #[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, +} + +/// Stored upstream. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Upstream { + /// System-generated id. + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Normalized routing key (immutable after creation). + pub alias: String, + /// `true` when the alias was derived from the endpoints. + pub alias_derived: bool, + /// Creation timestamp (RFC 3339). + pub created_at: String, + /// Last update timestamp (RFC 3339). + pub updated_at: String, + /// Stored upstream payload. + #[serde(flatten)] + pub spec: UpstreamCreate, +} + +/// Creation payload for a route. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RouteCreate { + /// Discovery tags. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Owning upstream. + pub upstream_id: Uuid, + /// Enabled flag; default `true`. + #[serde(default = "default_true")] + pub enabled: bool, + /// Protocol-scoped match rules. + #[serde(rename = "match")] + pub match_config: MatchConfig, + /// Plugin chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limits. + #[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, +} + +/// Stored route. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Route { + /// System-generated id. + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Creation timestamp (RFC 3339). + pub created_at: String, + /// Last update timestamp (RFC 3339). + pub updated_at: String, + /// Stored route payload. + #[serde(flatten)] + pub spec: RouteCreate, +} + +/// Custom plugin definition (Starlark source). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PluginDefinition { + /// System-generated id (UUID). + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// `auth_plugin` | `guard_plugin` | `transform_plugin`. + pub plugin_type: String, + /// Human readable name. + pub name: String, + /// Sandboxed Starlark source. + pub source_code: String, + /// Optional JSON schema for the plugin config. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_schema: Option, + /// Creation timestamp (RFC 3339). + pub created_at: String, + /// Last update timestamp (RFC 3339). + pub updated_at: String, +} + +/// Plugin creation payload. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PluginCreate { + /// `auth_plugin` | `guard_plugin` | `transform_plugin`. + pub plugin_type: String, + /// Human readable name. + pub name: String, + /// Sandboxed Starlark source. + pub source_code: String, + /// Optional JSON schema for the plugin config. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub config_schema: Option, +} + +/// GTS plugin-kind prefixes accepted for custom plugins. +const PLUGIN_KINDS: [&str; 3] = ["auth_plugin", "guard_plugin", "transform_plugin"]; + +/// Validate a tag against `^[a-z0-9_-]+$`. +fn validate_tag(tag: &str) -> Result<(), OagwError> { + let valid = !tag.is_empty() + && tag + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-'); + if valid { + Ok(()) + } else { + Err(OagwError::Validation(format!( + "tag '{tag}' must match ^[a-z0-9_-]+$" + ))) + } +} + +/// Validate a set of tags (also rejects duplicates). +fn validate_tags(tags: &[String]) -> Result<(), OagwError> { + for tag in tags { + validate_tag(tag)?; + } + let unique: BTreeSet<&String> = tags.iter().collect(); + if unique.len() != tags.len() { + return Err(OagwError::Validation( + "tags must not contain duplicates".to_owned(), + )); + } + Ok(()) +} + +/// Validate a plugin reference: a full GTS identifier or a bare UUID. +fn validate_plugin_ref(value: &str) -> Result<(), OagwError> { + if Uuid::parse_str(value).is_ok() { + return Ok(()); + } + validate_gts_id(value, "plugin reference") +} + +/// Validate a GTS identifier of the form `gts...v1~`. +/// +/// # Errors +/// +/// Returns [`OagwError::Validation`] when the value is not a GTS identifier. +pub fn validate_gts_id(value: &str, what: &str) -> Result<(), OagwError> { + if !value.starts_with("gts.") { + return Err(OagwError::Validation(format!( + "{what} '{value}' must be a GTS identifier" + ))); + } + let Some((_type_part, instance)) = value.split_once('~') else { + return Err(OagwError::Validation(format!( + "{what} '{value}' must contain '~'" + ))); + }; + if instance.is_empty() { + return Err(OagwError::Validation(format!( + "{what} '{value}' must have a non-empty instance part" + ))); + } + Ok(()) +} + +/// Validate the upstream creation payload. +/// +/// # Errors +/// +/// Returns [`OagwError::Validation`] describing the first violation found. +pub fn validate_upstream_create(spec: &UpstreamCreate) -> Result<(), OagwError> { + if spec.server.endpoints.is_empty() { + return Err(OagwError::Validation( + "server.endpoints must contain at least one endpoint".to_owned(), + )); + } + validate_protocol(&spec.protocol)?; + validate_tags(&spec.tags)?; + for endpoint in &spec.server.endpoints { + validate_scheme(&endpoint.scheme)?; + validate_hostname_like(&endpoint.host)?; + } + validate_endpoint_homogeneity(&spec.server.endpoints)?; + if let Some(auth) = &spec.auth { + validate_auth(auth)?; + } + if let Some(cors) = &spec.cors { + validate_cors(cors)?; + } + if let Some(rate) = &spec.rate_limit { + validate_rate_limit(rate)?; + } + if let Some(plugins) = &spec.plugins { + for item in &plugins.items { + validate_plugin_ref(item.reference())?; + } + } + Ok(()) +} + +fn validate_endpoint_homogeneity(endpoints: &[Endpoint]) -> Result<(), OagwError> { + let schemes: BTreeSet<&str> = endpoints.iter().map(|e| e.scheme.as_str()).collect(); + if schemes.len() > 1 { + return Err(OagwError::Validation( + "all endpoints of an upstream must use the same scheme".to_owned(), + )); + } + let ports: BTreeSet = endpoints.iter().map(|e| e.port).collect(); + if ports.len() > 1 { + return Err(OagwError::Validation( + "all endpoints of an upstream must use the same port".to_owned(), + )); + } + Ok(()) +} + +fn validate_auth(auth: &AuthConfig) -> Result<(), OagwError> { + validate_gts_id(&auth.plugin_type, "auth.type")?; + let instance = plugin_instance(&auth.plugin_type); + if Uuid::parse_str(instance).is_ok() { + return Ok(()); + } + if plugin::is_implementable_auth_plugin(instance) { + return Ok(()); + } + // A reserved catalog-only id has no implementation: as the upstream's only + // credential source it could never authenticate, so it is refused here + // rather than failing with `503 PluginNotFound` on every request. + if plugin::is_catalog_only_plugin(instance) { + return Err(OagwError::Validation(format!( + "auth.type '{instance}' is a catalog identifier with no implementation" + ))); + } + Err(OagwError::Validation(format!( + "auth.type '{instance}' is not a known auth plugin" + ))) +} + +/// Validate the route creation payload. +/// +/// # Errors +/// +/// Returns [`OagwError::Validation`] when the match rule is missing, ambiguous, +/// empty, or when tags / plugin refs are malformed. +pub fn validate_route_create(spec: &RouteCreate) -> Result<(), OagwError> { + validate_tags(&spec.tags)?; + match ( + spec.match_config.http.as_ref(), + spec.match_config.grpc.as_ref(), + ) { + (Some(http), None) => validate_http_match(http), + (None, Some(grpc)) => validate_grpc_match(grpc), + (Some(_), Some(_)) | (None, None) => Err(OagwError::Validation( + "route match must define exactly one of 'http' or 'grpc'".to_owned(), + )), + }?; + if let Some(cors) = &spec.cors { + validate_cors(cors)?; + } + if let Some(rate) = &spec.rate_limit { + validate_rate_limit(rate)?; + } + if let Some(plugins) = &spec.plugins { + for item in &plugins.items { + validate_plugin_ref(item.reference())?; + } + } + Ok(()) +} + +/// Validate a custom plugin payload. +/// +/// # Errors +/// +/// Returns [`OagwError::Validation`] when the plugin kind is unknown. +pub fn validate_plugin_create(spec: &PluginCreate) -> Result<(), OagwError> { + if !PLUGIN_KINDS.contains(&spec.plugin_type.as_str()) { + return Err(OagwError::Validation(format!( + "plugin_type '{}' must be one of {PLUGIN_KINDS:?}", + spec.plugin_type + ))); + } + if spec.name.trim().is_empty() { + return Err(OagwError::Validation("name must not be empty".to_owned())); + } + Ok(()) +} + +fn validate_protocol(protocol: &str) -> Result<(), OagwError> { + if protocol == PROTOCOL_HTTP || protocol == PROTOCOL_GRPC { + Ok(()) + } else { + Err(OagwError::Validation(format!( + "protocol '{protocol}' must be one of '{PROTOCOL_HTTP}' or '{PROTOCOL_GRPC}'" + ))) + } +} + +fn validate_scheme(scheme: &str) -> Result<(), OagwError> { + if matches!(scheme, "https" | "wss" | "wt" | "grpc" | "http") { + Ok(()) + } else { + Err(OagwError::Validation(format!( + "endpoint scheme '{scheme}' must be one of https|wss|wt|grpc" + ))) + } +} + +fn validate_http_match(http: &HttpMatch) -> Result<(), OagwError> { + if http.methods.is_empty() { + return Err(OagwError::Validation( + "match.http.methods must contain at least one method".to_owned(), + )); + } + const ALLOWED: [&str; 5] = ["GET", "POST", "PUT", "DELETE", "PATCH"]; + for method in &http.methods { + if !ALLOWED.contains(&method.as_str()) { + return Err(OagwError::Validation(format!( + "match.http.methods entry '{method}' must be one of {ALLOWED:?}" + ))); + } + } + if http.path.is_empty() { + return Err(OagwError::Validation( + "match.http.path must not be empty".to_owned(), + )); + } + if !http.path.starts_with('/') { + return Err(OagwError::Validation( + "match.http.path must start with '/'".to_owned(), + )); + } + Ok(()) +} + +fn validate_grpc_match(grpc: &GrpcMatch) -> Result<(), OagwError> { + if grpc.service.is_empty() || grpc.method.is_empty() { + return Err(OagwError::Validation( + "match.grpc.service and match.grpc.method must not be empty".to_owned(), + )); + } + Ok(()) +} + +fn validate_rate_limit(rate: &RateLimitConfig) -> Result<(), OagwError> { + if rate.sustained.rate == 0 { + return Err(OagwError::Validation( + "rate_limit.sustained.rate must be at least 1".to_owned(), + )); + } + if rate.capacity() == 0 { + return Err(OagwError::Validation( + "rate_limit.burst.capacity must be at least 1".to_owned(), + )); + } + if let Some(budget) = &rate.budget + && budget.mode != BudgetMode::Unlimited + && budget.total.is_none() + { + return Err(OagwError::Validation( + "rate_limit.budget.total is required when budget.mode is not 'unlimited'".to_owned(), + )); + } + if rate.strategy != RateStrategy::Reject { + return Err(OagwError::Validation( + "rate_limit.strategy must be 'reject': the gateway has no queue or degraded response to serve" + .to_owned(), + )); + } + if rate.cost() > rate.capacity() { + return Err(OagwError::Validation( + "rate_limit.cost must not exceed the burst capacity: a request would never fit the bucket" + .to_owned(), + )); + } + Ok(()) +} + +/// Validate a CORS block, including the `allow_credentials` + `*` rule. +/// +/// # Errors +/// +/// Returns [`OagwError::Validation`] for unknown methods or for +/// `allow_credentials: true` combined with a `*` origin. +pub fn validate_cors(cors: &CorsConfig) -> Result<(), OagwError> { + const ALLOWED_METHODS: [&str; 7] = ["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD", "OPTIONS"]; + if let Some(methods) = &cors.allowed_methods { + for method in methods { + if !ALLOWED_METHODS.contains(&method.as_str()) { + return Err(OagwError::Validation(format!( + "cors.allowed_methods entry '{method}' must be one of {ALLOWED_METHODS:?}" + ))); + } + } + } + if cors.allow_credentials && cors.allowed_origins.iter().any(|o| o == "*") { + return Err(OagwError::Validation( + "cors.allow_credentials must not be combined with allowed_origins '*'".to_owned(), + )); + } + for origin in &cors.allowed_origins { + if origin == "*" || origin.starts_with("http://") || origin.starts_with("https://") { + continue; + } + return Err(OagwError::Validation(format!( + "cors.allowed_origins entry '{origin}' must be '*' or an absolute origin" + ))); + } + Ok(()) +} + +/// Extract the plugin instance part of a GTS identifier (`…~`). +#[must_use] +pub fn plugin_instance(gts_id: &str) -> &str { + gts_id.split_once('~').map_or(gts_id, |(_, rest)| rest) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn upstream_json(body: serde_json::Value) -> Result { + serde_json::from_value(body) + } + + #[test] + fn rejects_unknown_fields() { + let err = upstream_json(serde_json::json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com" }] }, + "protocol": PROTOCOL_HTTP, + "nope": 1 + })) + .expect_err("unknown field must be rejected"); + assert!(err.to_string().contains("nope")); + } + + #[test] + fn rejects_empty_endpoints_and_bad_tags() { + let err = upstream_json(serde_json::json!({ + "server": { "endpoints": [] }, + "protocol": PROTOCOL_HTTP + })) + .expect("unknown-field-free body parses"); + assert!(matches!( + validate_upstream_create(&err), + Err(OagwError::Validation(msg)) if msg.contains("endpoints") + )); + + let bad = upstream_json(serde_json::json!({ + "tags": ["Bad Tag"], + "server": { "endpoints": [{ "scheme": "https", "host": "a.example.com" }] }, + "protocol": PROTOCOL_HTTP + })) + .expect("parses"); + assert!(validate_upstream_create(&bad).is_err()); + } + + #[test] + fn rejects_bad_protocol_and_scheme() { + let bad = upstream_json(serde_json::json!({ + "server": { "endpoints": [{ "scheme": "ftp", "host": "a.example.com" }] }, + "protocol": PROTOCOL_HTTP + })) + .expect("parses"); + assert!(validate_upstream_create(&bad).is_err()); + + let bad_proto = upstream_json(serde_json::json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "a.example.com" }] }, + "protocol": "gts.cf.core.oagw.protocol.v1~nope" + })) + .expect("parses"); + assert!(validate_upstream_create(&bad_proto).is_err()); + } + + #[test] + fn rejects_cors_credentials_with_wildcard() { + let cors = CorsConfig { + sharing: Sharing::Private, + enabled: true, + allowed_origins: vec!["*".to_owned()], + allowed_methods: None, + expose_headers: Vec::new(), + allow_credentials: true, + }; + assert!(validate_cors(&cors).is_err()); + } + + #[test] + fn rejects_route_match_ambiguity() { + let both = serde_json::from_value::(serde_json::json!({ + "upstream_id": Uuid::new_v4(), + "match": { + "http": { "methods": ["GET"], "path": "/a" }, + "grpc": { "service": "s", "method": "m" } + } + })) + .expect("parses"); + assert!(validate_route_create(&both).is_err()); + + let neither = serde_json::from_value::(serde_json::json!({ + "upstream_id": Uuid::new_v4(), + "match": {} + })) + .expect("parses"); + assert!(validate_route_create(&neither).is_err()); + + let empty_methods = serde_json::from_value::(serde_json::json!({ + "upstream_id": Uuid::new_v4(), + "match": { "http": { "methods": [], "path": "/a" } } + })) + .expect("parses"); + assert!(validate_route_create(&empty_methods).is_err()); + } +} diff --git a/gears/system/oagw/oagw/src/domain/plugin/catalog.rs b/gears/system/oagw/oagw/src/domain/plugin/catalog.rs new file mode 100644 index 0000000..9ba7f74 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/plugin/catalog.rs @@ -0,0 +1,8 @@ +// Created: 2026-08-29 by Constructor Tech +//! Plugin reference helpers shared between the domain and the registry. + +/// `true` when `gts_id`'s instance part equals `plugin_id` (a UUID string). +#[must_use] +pub fn plugin_instance_matches(gts_id: &str, plugin_id: &str) -> bool { + crate::domain::model::plugin_instance(gts_id) == plugin_id +} diff --git a/gears/system/oagw/oagw/src/domain/plugin/mod.rs b/gears/system/oagw/oagw/src/domain/plugin/mod.rs new file mode 100644 index 0000000..6bf059f --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/plugin/mod.rs @@ -0,0 +1,299 @@ +// Created: 2026-08-29 by Constructor Tech +//! Plugin traits and the plugin-chain execution model. +//! +//! Three plugin types with deterministic execution order (ADR-0002): +//! `Auth` → `Guard` → `Transform(on_request)` → upstream → +//! `Transform(on_response/on_error)`. + +use async_trait::async_trait; +use axum::http::{HeaderMap, StatusCode, Uri}; + +use super::error::OagwError; + +pub mod catalog; + +pub use catalog::plugin_instance_matches; + +/// Auth plugin instance part of the always-success no-op. +pub const AUTH_NOOP: &str = "cf.core.oagw.noop.v1"; +/// Auth plugin instance part of the `cred_store`-backed API key. +pub const AUTH_APIKEY: &str = "cf.core.oagw.apikey.v1"; +/// Auth plugin instance part of the OAuth2 client-credentials (form) plugin. +pub const AUTH_OAUTH2_CC: &str = "cf.core.oagw.oauth2_client_cred.v1"; +/// Auth plugin instance part of the OAuth2 client-credentials (basic) plugin. +pub const AUTH_OAUTH2_CC_BASIC: &str = "cf.core.oagw.oauth2_client_cred_basic.v1"; + +/// Guard plugin instance part of the required-headers guard. +pub const GUARD_REQUIRED_HEADERS: &str = "cf.core.oagw.required_headers.v1"; + +/// Transform plugin instance part of the request-id propagation plugin. +pub const TRANSFORM_REQUEST_ID: &str = "cf.core.oagw.request_id.v1"; + +/// Auth plugins that have a backing `AuthPlugin` implementation. +const IMPLEMENTABLE_AUTH: [&str; 4] = + [AUTH_NOOP, AUTH_APIKEY, AUTH_OAUTH2_CC, AUTH_OAUTH2_CC_BASIC]; + +/// Reserved guard/transform identifiers that are core data-plane logic, not +/// registry-resolvable plugins (ADR-0002). The PRD marks them "not +/// `plugins`-bindable", but a chain that names one is accepted at +/// configuration time and fails with `503 PluginNotFound` at proxy time. +const CATALOG_ONLY: [&str; 4] = [ + "cf.core.oagw.timeout.v1", + "cf.core.oagw.cors.v1", + "cf.core.oagw.logging.v1", + "cf.core.oagw.metrics.v1", +]; + +/// Reserved auth identifiers with no backing implementation (PRD). Configuring +/// one on an upstream is refused at configuration time: unlike a chain entry it +/// is the only credential source, so no request could ever succeed. +const CATALOG_ONLY_AUTH: [&str; 2] = ["cf.core.oagw.basic.v1", "cf.core.oagw.bearer.v1"]; + +/// `true` when `instance` resolves to a built-in auth plugin implementation. +#[must_use] +pub fn is_implementable_auth_plugin(instance: &str) -> bool { + IMPLEMENTABLE_AUTH.contains(&instance) +} + +/// `true` when `instance` is a reserved auth id with no implementation. +#[must_use] +pub fn is_catalog_only_plugin(instance: &str) -> bool { + CATALOG_ONLY_AUTH.contains(&instance) +} + +/// `true` when `instance` is a reserved guard/transform id the registries do +/// not resolve. +#[must_use] +pub fn is_catalog_only_chain_plugin(instance: &str) -> bool { + CATALOG_ONLY.contains(&instance) +} + +/// `true` when `instance` names something the catalog or a registry knows. +/// +/// Config-time plugin-chain validation accepts these and rejects everything +/// else; custom plugin instances (bare UUIDs) are checked separately. +#[must_use] +pub fn is_known_plugin(instance: &str) -> bool { + is_implementable_auth_plugin(instance) + || is_implementable_guard_plugin(instance) + || is_implementable_transform_plugin(instance) + || is_catalog_only_chain_plugin(instance) +} + +/// `true` when `instance` is a registered built-in guard plugin. +#[must_use] +pub fn is_implementable_guard_plugin(instance: &str) -> bool { + instance == GUARD_REQUIRED_HEADERS +} + +/// `true` when `instance` is a registered built-in transform plugin. +#[must_use] +pub fn is_implementable_transform_plugin(instance: &str) -> bool { + instance == TRANSFORM_REQUEST_ID +} + +/// Result of a guard plugin evaluation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GuardDecision { + /// Continue the chain. + Allow, + /// Stop the chain with the given wire error. + Reject { + /// HTTP status for the rejection. + status: StatusCode, + /// Machine readable error code (e.g. `REQUIRED_HEADER_MISSING`). + error_code: String, + /// Human readable explanation. + message: String, + }, +} + +/// Failure surfaced by a plugin. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PluginError { + /// Authentication failed → `401 AuthenticationFailed`. + Authentication(String), + /// Policy rejection with an explicit wire status. + Rejected { + /// HTTP status for the rejection. + status: StatusCode, + /// Machine readable error code. + error_code: String, + /// Human readable explanation. + message: String, + }, + /// Plugin implementation failure → `503 PluginNotFound` / + /// `500 Internal`. + Internal(String), +} + +impl From for OagwError { + fn from(err: PluginError) -> Self { + match err { + PluginError::Authentication(msg) => Self::AuthenticationFailed(msg), + PluginError::Rejected { + status, + error_code, + message, + } => { + let detail = format!("{error_code}: {message}"); + match status.as_u16() { + 400 => Self::Validation(detail), + 401 => Self::AuthenticationFailed(detail), + 403 => Self::CorsOriginNotAllowed(detail), + 404 => Self::RouteNotFound(detail), + 502 => Self::DownstreamError(detail), + 503 => Self::PluginNotFound(detail), + _ => Self::Internal(detail), + } + } + PluginError::Internal(msg) => Self::Internal(msg), + } + } +} + +/// Request-side plugin context. +#[derive(Debug)] +pub struct RequestContext { + /// Tenant that owns the resolved upstream. + pub tenant_id: uuid::Uuid, + /// Resolved upstream id. + pub upstream_id: uuid::Uuid, + /// Upstream alias used for routing. + pub alias: String, + /// Request method. + pub method: String, + /// Target path (route match path plus suffix). + pub path: String, + /// Raw query string (already allowlist filtered). + pub query: Option, + /// Mutable request headers. + pub headers: HeaderMap, + /// Request body (buffered; the data plane enforces the 100 MiB limit). + pub body: bytes::Bytes, + /// Inbound request URI (used for `instance` in problem documents). + pub uri: Uri, + /// Auth plugin configuration object. + pub config: serde_json::Map, + /// Caller security context, used by credential-resolving plugins only. + pub security: toolkit_security::SecurityContext, +} + +/// Response-side plugin context. +#[derive(Debug)] +pub struct ResponseContext { + /// Request headers echoed for correlation. + pub request_headers: HeaderMap, + /// Mutable response headers. + pub headers: HeaderMap, + /// Upstream response status. + pub status: StatusCode, + /// Response body (buffered). + pub body: bytes::Bytes, + /// Plugin configuration object. + pub config: serde_json::Map, +} + +/// Error-phase plugin context. +#[derive(Debug)] +pub struct ErrorContext { + /// Gateway error produced by the pipeline. + pub error: OagwError, + /// Mutable response headers for the synthetic response. + pub headers: HeaderMap, + /// Plugin configuration object. + pub config: serde_json::Map, +} + +/// Credential injection plugin. One per upstream. +#[async_trait] +pub trait AuthPlugin: Send + Sync { + /// Plugin instance id (the GTS instance part, e.g. `cf.core.oagw.apikey.v1`). + fn id(&self) -> &str; + + /// Plugin kind, e.g. `auth_plugin`. + fn plugin_type(&self) -> &str; + + /// Inject outbound credentials into `ctx.headers`. + /// + /// # Errors + /// + /// Returns [`PluginError::Authentication`] when the request cannot be + /// authenticated. + async fn authenticate(&self, ctx: &mut RequestContext) -> Result<(), PluginError>; +} + +/// Validation / policy plugin. Multiple per upstream or route. +#[async_trait] +pub trait GuardPlugin: Send + Sync { + /// Plugin instance id. + fn id(&self) -> &str; + + /// Plugin kind, e.g. `guard_plugin`. + fn plugin_type(&self) -> &str; + + /// Validate the outbound request. + /// + /// # Errors + /// + /// Returns [`PluginError`] when the guard cannot run. + async fn guard_request(&self, ctx: &RequestContext) -> Result; + + /// Validate the upstream response. + /// + /// # Errors + /// + /// Returns [`PluginError`] when the guard cannot evaluate the response. + async fn guard_response(&self, ctx: &ResponseContext) -> Result; +} + +/// Request / response mutation plugin. Multiple per upstream or route. +#[async_trait] +pub trait TransformPlugin: Send + Sync { + /// Plugin instance id. + fn id(&self) -> &str; + + /// Plugin kind, e.g. `transform_plugin`. + fn plugin_type(&self) -> &str; + + /// Mutate the outbound request. + /// + /// # Errors + /// + /// Returns [`PluginError`] when the transform fails. + async fn transform_request(&self, ctx: &mut RequestContext) -> Result<(), PluginError>; + + /// Mutate the response returned to the caller. + /// + /// # Errors + /// + /// Returns [`PluginError`] when the transform cannot run. + async fn transform_response(&self, ctx: &mut ResponseContext) -> Result<(), PluginError>; + + /// Mutate the synthetic error response. + /// + /// # Errors + /// + /// Returns [`PluginError`] when the transform cannot run. + async fn transform_error(&self, ctx: &mut ErrorContext) -> Result<(), PluginError>; +} + +/// A plugin instance resolved from the registry together with its config. +#[derive(Debug, Clone)] +pub struct PluginBinding { + /// Canonical plugin reference (`gts.…~` or a UUID). + pub plugin_ref: String, + /// Configuration JSON for the plugin. + pub config: serde_json::Value, +} + +/// Ordered plugin chain after hierarchical merge. +#[derive(Debug, Default)] +pub struct PluginChain { + /// Auth plugin (at most one). + pub auth: Option, + /// Guard plugins in execution order. + pub guards: Vec, + /// Transform plugins in execution order. + pub transforms: Vec, +} diff --git a/gears/system/oagw/oagw/src/domain/ports/metrics.rs b/gears/system/oagw/oagw/src/domain/ports/metrics.rs new file mode 100644 index 0000000..6d54355 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/ports/metrics.rs @@ -0,0 +1,89 @@ +// Created: 2026-08-29 by Constructor Tech +//! Observability port of the data plane (DESIGN §4.2). +//! +//! The domain knows *when* something worth measuring happens; the adapter +//! decides *how* it is exported. Keeping the instruments behind a port is what +//! lets the data plane stay free of a metering dependency. + +/// How an endpoint was chosen for a request (DESIGN §4.2 "Routing Metrics"). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectionMethod { + /// The caller named the endpoint with `X-OAGW-Target-Host`. + ExplicitHeader, + /// Endpoints were rotated. + RoundRobin, + /// The upstream has a single endpoint. + Default, +} + +impl SelectionMethod { + /// Label value of the selection method. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::ExplicitHeader => "explicit_header", + Self::RoundRobin => "round_robin", + Self::Default => "default", + } + } +} + +/// Circuit-breaker states reported on a transition. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BreakerState { + /// Traffic is allowed. + Closed, + /// Traffic is refused. + Open, +} + +impl BreakerState { + /// Label value of the state. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Closed => "closed", + Self::Open => "open", + } + } +} + +/// Observability port (DESIGN §4.2). +/// +/// Label cardinality follows the design's cardinality rules: no tenant labels, +/// `http.route` carries the normalized route match pattern and `host` carries +/// the upstream alias. +pub trait OagwMetricsPort: Send + Sync { + /// One completed proxied request, success or failure. + fn record_request( + &self, + host: &str, + route: &str, + method: &str, + status_code: u16, + duration_seconds: f64, + ); + + /// A request that the gateway rejected with an error type. + fn record_error(&self, host: &str, route: &str, error_type: &str); + + /// A request rejected because its budget was exhausted. + fn record_rate_limit_exceeded(&self, host: &str, path: &str); + + /// A circuit-breaker transition for an upstream endpoint. + fn record_breaker_transition(&self, host: &str, from: BreakerState, to: BreakerState); + + /// The blocking state of an upstream endpoint's breaker. + fn set_breaker_state(&self, host: &str, state: BreakerState); + + /// The caller selected a specific endpoint with `X-OAGW-Target-Host`. + fn record_target_host_used(&self, upstream_id: &str, endpoint_host: &str); + + /// An endpoint was chosen for a request. + fn record_endpoint_selected( + &self, + upstream_id: &str, + endpoint_host: &str, + method: SelectionMethod, + ); +} diff --git a/gears/system/oagw/oagw/src/domain/ports/mod.rs b/gears/system/oagw/oagw/src/domain/ports/mod.rs new file mode 100644 index 0000000..12abbf7 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/ports/mod.rs @@ -0,0 +1,6 @@ +// Created: 2026-08-29 by Constructor Tech +//! Domain ports: outward-facing capabilities the data plane depends on. + +pub mod metrics; + +pub use metrics::OagwMetricsPort; 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..bc8ffaa --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/rate_limit.rs @@ -0,0 +1,173 @@ +// Created: 2026-08-29 by Constructor Tech +//! Token bucket rate limiting (ADR-0003). +//! +//! `refill_rate = sustained.rate / window_seconds`, and the default bucket +//! capacity is `sustained.rate` when `burst.capacity` is absent. + +use std::time::Instant; + +/// Token bucket with lazy refill. +#[derive(Debug, Clone)] +pub struct TokenBucket { + tokens: f64, + last_update: Instant, + capacity: f64, + refill_rate: f64, +} + +impl TokenBucket { + /// Create a bucket that starts full. + #[must_use] + pub fn new(capacity: f64, refill_rate: f64) -> Self { + Self { + tokens: capacity, + last_update: Instant::now(), + capacity, + refill_rate, + } + } + + /// Create a bucket pre-filled to `initial_tokens`. + #[must_use] + pub fn with_tokens(capacity: f64, refill_rate: f64, initial_tokens: f64) -> Self { + let tokens = initial_tokens.clamp(0.0, capacity); + Self { + tokens, + last_update: Instant::now(), + capacity, + refill_rate, + } + } + + /// Refill lazily based on the elapsed time. + fn refill(&mut self) { + let now = Instant::now(); + let elapsed = now.duration_since(self.last_update).as_secs_f64(); + self.last_update = now; + if self.refill_rate > 0.0 { + self.tokens = (self.tokens + elapsed * self.refill_rate).min(self.capacity); + } + } + + /// Try to consume `cost` tokens. + #[must_use] + pub fn try_acquire(&mut self, cost: f64) -> bool { + self.refill(); + if self.tokens + f64::EPSILON >= cost { + self.tokens -= cost; + true + } else { + false + } + } + + /// Seconds until the bucket holds at least `cost` tokens (for `Retry-After`). + #[must_use] + pub fn seconds_until_tokens(&self, cost: f64) -> u64 { + let deficit = cost - self.current_tokens(); + if deficit <= 0.0 { + return 0; + } + if self.refill_rate <= 0.0 { + return u64::MAX; + } + ceil_to_u64(deficit / self.refill_rate) + } + + /// Tokens currently available (without refilling). + #[must_use] + pub fn current_tokens(&self) -> f64 { + let elapsed = Instant::now() + .duration_since(self.last_update) + .as_secs_f64(); + if self.refill_rate <= 0.0 { + self.tokens + } else { + (self.tokens + elapsed * self.refill_rate).min(self.capacity) + } + } + + /// Unix epoch seconds at which the bucket is full again. + #[must_use] + pub fn epoch_seconds_until_full(&self) -> u64 { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or_default(); + let deficit = self.capacity - self.current_tokens(); + if deficit <= 0.0 || self.refill_rate <= 0.0 { + return now; + } + now + ceil_to_u64(deficit / self.refill_rate) + } + + /// Bucket capacity. + #[must_use] + pub fn capacity(&self) -> f64 { + self.capacity + } + + /// Refill rate in tokens per second. + #[must_use] + pub fn refill_rate(&self) -> f64 { + self.refill_rate + } +} + +/// Compute the refill rate in tokens per second. +#[must_use] +pub fn refill_rate(sustained_rate: u32, window_seconds: u64) -> f64 { + if window_seconds == 0 { + return 0.0; + } + f64::from(sustained_rate) / f64::from(u32::try_from(window_seconds).unwrap_or(u32::MAX)) +} + +/// Round a non-negative duration in seconds up to whole seconds. +fn ceil_to_u64(seconds: f64) -> u64 { + let seconds = seconds.ceil(); + if seconds <= 1.0 { + 1 + } else if seconds >= f64::from(u32::MAX) { + u64::from(u32::MAX) + } else { + seconds as u64 + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn refills_over_time() { + // 50 tokens/second: 30 ms restores at least one token. + let mut bucket = TokenBucket::new(1.0, 50.0); + assert!(bucket.try_acquire(1.0)); + assert!(!bucket.try_acquire(1.0)); + std::thread::sleep(std::time::Duration::from_millis(30)); + assert!(bucket.try_acquire(1.0)); + } + + #[test] + fn respects_capacity() { + let mut bucket = TokenBucket::new(2.0, 100.0); + // A single request cannot consume more than the capacity allows. + assert!(bucket.try_acquire(2.0)); + assert!(!bucket.try_acquire(2.0)); + } + + #[test] + fn retry_after_is_positive() { + let mut bucket = TokenBucket::new(1.0, 1.0); + assert!(bucket.try_acquire(1.0)); + assert!(bucket.seconds_until_tokens(1.0) >= 1); + } + + #[test] + fn window_seconds_mapping() { + assert_eq!(refill_rate(10, 1), 10.0); + assert!((refill_rate(600, 60) - 10.0).abs() < 1e-9); + assert!((refill_rate(3_600, 3_600) - 1.0).abs() < 1e-9); + } +} diff --git a/gears/system/oagw/oagw/src/domain/repo.rs b/gears/system/oagw/oagw/src/domain/repo.rs new file mode 100644 index 0000000..f676d49 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/repo.rs @@ -0,0 +1,147 @@ +// Created: 2026-08-29 by Constructor Tech +//! Repository contracts for the control plane. +//! +//! The MVP store is in-memory (`infra/storage.rs`); the traits keep the domain +//! layer persistence-agnostic so a database-backed implementation can replace +//! it without touching the services. + +use uuid::Uuid; + +use super::error::OagwError; +use super::model::{PluginDefinition, Route, Upstream}; + +/// `UNIQUE (tenant_id, alias)` violation. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AliasConflict { + /// Conflicting alias. + pub alias: String, + /// Owning tenant. + pub tenant_id: Uuid, +} + +/// Persistence for upstreams. +pub trait UpstreamRepository: Send + Sync { + /// Insert a new upstream. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when `(tenant_id, alias)` already exists. + fn insert(&self, upstream: Upstream) -> Result; + + /// Replace an existing upstream. + /// + /// # Errors + /// + /// Returns [`OagwError::RouteNotFound`] when the id is unknown, and + /// [`OagwError::Validation`] when the replacement breaks a uniqueness + /// constraint. + fn update(&self, upstream: Upstream) -> Result; + + /// Fetch an upstream by id. + #[must_use] + fn get(&self, id: Uuid) -> Option; + + /// Fetch an upstream by `(tenant_id, alias)`, case-insensitive. + #[must_use] + fn get_by_alias(&self, tenant_id: Uuid, alias: &str) -> Option; + + /// All upstreams owned by `tenant_id`. + #[must_use] + fn list_by_tenant(&self, tenant_id: Uuid) -> Vec; + + /// Every upstream matching `alias` across all tenants, case-insensitive. + #[must_use] + fn list_by_alias(&self, alias: &str) -> Vec; + + /// Every upstream, across all tenants. + #[must_use] + fn list_all(&self) -> Vec; + + /// Delete an upstream by id. + /// + /// # Errors + /// + /// Returns [`OagwError::RouteNotFound`] when the id is unknown. + fn delete(&self, id: Uuid) -> Result<(), OagwError>; + + /// Count upstreams. + #[must_use] + fn count(&self) -> usize; +} + +/// Persistence for routes. +pub trait RouteRepository: Send + Sync { + /// Insert a new route. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when a matching route already exists. + fn insert(&self, route: Route) -> Result; + + /// Replace an existing route. + /// + /// # Errors + /// + /// Returns [`OagwError::RouteNotFound`] when the id is unknown. + fn update(&self, route: Route) -> Result; + + /// Fetch a route by id. + #[must_use] + fn get(&self, id: Uuid) -> Option; + + /// Every route owned by `tenant_id`. + #[must_use] + fn list_by_tenant(&self, tenant_id: Uuid) -> Vec; + + /// Every route bound to `upstream_id`. + #[must_use] + fn list_by_upstream(&self, upstream_id: Uuid) -> Vec; + + /// Every route, across all tenants. + #[must_use] + fn list_all(&self) -> Vec; + + /// Delete a route by id. + /// + /// # Errors + /// + /// Returns [`OagwError::RouteNotFound`] when the id is unknown. + fn delete(&self, id: Uuid) -> Result<(), OagwError>; + + /// Count routes. + #[must_use] + fn count(&self) -> usize; +} + +/// Persistence for custom (Starlark) plugins. +pub trait PluginRepository: Send + Sync { + /// Insert a new plugin. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when the name is taken. + fn insert(&self, plugin: PluginDefinition) -> Result; + + /// Fetch a plugin by id. + #[must_use] + fn get(&self, id: Uuid) -> Option; + + /// Every plugin owned by `tenant_id`. + #[must_use] + fn list_by_tenant(&self, tenant_id: Uuid) -> Vec; + + /// Every plugin, across all tenants. + #[must_use] + fn list_all(&self) -> Vec; + + /// Delete a plugin by id. + /// + /// # Errors + /// + /// Returns [`OagwError::RouteNotFound`] when the id is unknown. + fn delete(&self, id: Uuid) -> Result<(), OagwError>; + + /// Count plugins. + #[must_use] + fn count(&self) -> usize; +} diff --git a/gears/system/oagw/oagw/src/domain/services/management.rs b/gears/system/oagw/oagw/src/domain/services/management.rs new file mode 100644 index 0000000..381c27b --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/services/management.rs @@ -0,0 +1,671 @@ +// Created: 2026-08-29 by Constructor Tech +//! Control Plane service: CRUD for upstreams, routes and custom plugins. +//! +//! All operations are strictly scoped to the calling tenant (DESIGN §3.3 +//! "Tenant Scoping"): ancestor resources are invisible through the management +//! API and are only reachable through the data-plane tenant chain walk. + +use std::sync::Arc; + +use uuid::Uuid; + +use crate::domain::alias::{DerivedAlias, NotDerivable, compute_derived_alias, resolve_alias}; +use crate::domain::error::OagwError; +use crate::domain::merge::ChainEntry; +use crate::domain::model::{ + Endpoint, PluginCreate, PluginDefinition, Route, RouteCreate, Upstream, UpstreamCreate, + normalize_host, validate_plugin_create, validate_route_create, validate_upstream_create, +}; +use crate::domain::plugin; +use crate::domain::repo::{PluginRepository, RouteRepository, UpstreamRepository}; + +/// Timestamps used for `created_at` / `updated_at` (RFC 3339, UTC). +fn now_rfc3339() -> String { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + format_rfc3339(now.as_secs()) +} + +/// Format epoch seconds as an RFC 3339 UTC timestamp. +fn format_rfc3339(epoch_secs: u64) -> String { + let days = epoch_secs / 86_400; + let rem = epoch_secs % 86_400; + let (hour, minute, second) = (rem / 3_600, (rem % 3_600) / 60, rem % 60); + let (year, month, day) = civil_from_days(i64::try_from(days).unwrap_or(0)); + format!("{year:04}-{month:02}-{day:02}T{hour:02}:{minute:02}:{second:02}Z") +} + +/// Days-since-epoch → `(year, month, day)` (Howard Hinnant's algorithm). +fn civil_from_days(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + ( + if m <= 2 { y + 1 } else { y }, + u32::try_from(m).unwrap_or(1), + u32::try_from(d).unwrap_or(1), + ) +} + +fn not_found(what: &str, id: Uuid) -> OagwError { + OagwError::RouteNotFound(format!("{what} '{id}' not found")) +} + +/// A predicate naming the plugin references the data plane can resolve. +type PluginCatalog = dyn Fn(&str) -> bool + Send + Sync; + +/// Control Plane service. +pub struct ControlPlaneService { + upstreams: Arc, + routes: Arc, + plugins: Arc, + /// Plugin availability, supplied by the composition root once the data + /// plane's registry exists. Absent when the service runs without one. + plugin_catalog: std::sync::OnceLock>, +} + +impl ControlPlaneService { + /// Build the service over the given repositories. + #[must_use] + pub fn new( + upstreams: Arc, + routes: Arc, + plugins: Arc, + ) -> Self { + Self { + upstreams, + routes, + plugins, + plugin_catalog: std::sync::OnceLock::new(), + } + } + + /// Tell the control plane which plugins the data plane resolves, so a + /// dangling plugin reference is rejected at configuration time instead of + /// failing with `503` on the first request. + pub fn set_plugin_catalog(&self, catalog: Arc) { + let _ = self.plugin_catalog.set(catalog); + } + + /// Create an upstream, deriving or validating its alias. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] for validation failures, alias rule + /// violations and `(tenant_id, alias)` conflicts. + pub fn create_upstream( + &self, + tenant_id: Uuid, + spec: UpstreamCreate, + ) -> Result { + validate_upstream_create(&spec)?; + self.validate_plugin_chain(spec.plugins.as_ref())?; + let (alias, derived) = resolve_alias(&spec.server.endpoints, spec.alias.as_deref(), None)?; + let mut spec = spec; + spec.alias = Some(alias.clone()); + let now = now_rfc3339(); + let upstream = Upstream { + id: Uuid::new_v4(), + tenant_id, + alias, + alias_derived: derived, + created_at: now.clone(), + updated_at: now, + spec, + }; + crate::infra::audit::config_change("create", "upstream", upstream.id, tenant_id); + self.upstreams.insert(upstream) + } + + /// Replace an upstream in full. The alias is immutable. + /// + /// # Errors + /// + /// Returns [`OagwError::RouteNotFound`] for unknown ids and + /// [`OagwError::Validation`] for alias / validation violations. + pub fn replace_upstream( + &self, + tenant_id: Uuid, + id: Uuid, + spec: UpstreamCreate, + ) -> Result { + let existing = self + .upstreams + .get(id) + .ok_or_else(|| not_found("upstream", id))?; + if existing.tenant_id != tenant_id { + return Err(not_found("upstream", id)); + } + validate_upstream_create(&spec)?; + self.validate_plugin_chain(spec.plugins.as_ref())?; + let (alias, derived) = resolve_alias( + &spec.server.endpoints, + spec.alias.as_deref(), + Some(&existing.alias), + )?; + let mut spec = spec; + spec.alias = Some(alias.clone()); + let upstream = Upstream { + id: existing.id, + tenant_id: existing.tenant_id, + alias, + alias_derived: derived, + created_at: existing.created_at, + updated_at: now_rfc3339(), + spec, + }; + crate::infra::audit::config_change("replace", "upstream", upstream.id, tenant_id); + self.upstreams.update(upstream) + } + + /// Fetch an upstream owned by `tenant_id`. + #[must_use] + pub fn get_upstream(&self, tenant_id: Uuid, id: Uuid) -> Option { + self.upstreams + .get(id) + .filter(|upstream| upstream.tenant_id == tenant_id) + } + + /// List upstreams owned by `tenant_id`. + #[must_use] + pub fn list_upstreams(&self, tenant_id: Uuid) -> Vec { + self.upstreams.list_by_tenant(tenant_id) + } + + /// Delete an upstream owned by `tenant_id`. + /// + /// # Errors + /// + /// Returns [`OagwError::RouteNotFound`] when the upstream is missing or + /// owned by another tenant. + pub fn delete_upstream(&self, tenant_id: Uuid, id: Uuid) -> Result<(), OagwError> { + let existing = self + .upstreams + .get(id) + .ok_or_else(|| not_found("upstream", id))?; + if existing.tenant_id != tenant_id { + return Err(not_found("upstream", id)); + } + for route in self.routes.list_by_upstream(id) { + self.routes.delete(route.id)?; + } + crate::infra::audit::config_change("delete", "upstream", id, tenant_id); + self.upstreams.delete(id) + } + + /// Set the enabled flag of an upstream owned by `tenant_id`. + /// + /// # Errors + /// + /// Returns [`OagwError::RouteNotFound`] when the upstream is missing. + pub fn set_upstream_enabled( + &self, + tenant_id: Uuid, + id: Uuid, + enabled: bool, + ) -> Result { + let existing = self + .upstreams + .get(id) + .ok_or_else(|| not_found("upstream", id))?; + if existing.tenant_id != tenant_id { + return Err(not_found("upstream", id)); + } + let mut spec = existing.spec.clone(); + spec.enabled = enabled; + let upstream = Upstream { + updated_at: now_rfc3339(), + spec, + ..existing + }; + crate::infra::audit::config_change( + if enabled { "enable" } else { "disable" }, + "upstream", + id, + tenant_id, + ); + self.upstreams.update(upstream) + } + + /// Create a route bound to an upstream owned by `tenant_id`. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when the upstream is unknown, the + /// match rule is invalid, or the match rule collides. + pub fn create_route(&self, tenant_id: Uuid, spec: RouteCreate) -> Result { + validate_route_create(&spec)?; + self.validate_plugin_chain(spec.plugins.as_ref())?; + let upstream = self.upstreams.get(spec.upstream_id).ok_or_else(|| { + OagwError::Validation(format!("upstream '{}' does not exist", spec.upstream_id)) + })?; + if upstream.tenant_id != tenant_id { + return Err(OagwError::Validation(format!( + "upstream '{}' does not belong to this tenant", + spec.upstream_id + ))); + } + let now = now_rfc3339(); + let route = Route { + id: Uuid::new_v4(), + tenant_id, + created_at: now.clone(), + updated_at: now, + spec, + }; + crate::infra::audit::config_change("create", "route", route.id, tenant_id); + self.routes.insert(route) + } + + /// Replace a route in full. `upstream_id` is immutable. + /// + /// # Errors + /// + /// Returns [`OagwError::RouteNotFound`] for unknown ids and + /// [`OagwError::Validation`] for validation violations. + pub fn replace_route( + &self, + tenant_id: Uuid, + id: Uuid, + mut spec: RouteCreate, + ) -> Result { + let existing = self.routes.get(id).ok_or_else(|| not_found("route", id))?; + if existing.tenant_id != tenant_id { + return Err(not_found("route", id)); + } + spec.upstream_id = existing.spec.upstream_id; + validate_route_create(&spec)?; + self.validate_plugin_chain(spec.plugins.as_ref())?; + let route = Route { + id: existing.id, + tenant_id: existing.tenant_id, + created_at: existing.created_at, + updated_at: now_rfc3339(), + spec, + }; + crate::infra::audit::config_change("replace", "route", route.id, tenant_id); + self.routes.update(route) + } + + /// Fetch a route owned by `tenant_id`. + #[must_use] + pub fn get_route(&self, tenant_id: Uuid, id: Uuid) -> Option { + self.routes + .get(id) + .filter(|route| route.tenant_id == tenant_id) + } + + /// List routes owned by `tenant_id`. + #[must_use] + pub fn list_routes(&self, tenant_id: Uuid) -> Vec { + let mut routes = self.routes.list_by_tenant(tenant_id); + routes.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id))); + routes + } + + /// Delete a route owned by `tenant_id`. + /// + /// # Errors + /// + /// Returns [`OagwError::RouteNotFound`] when the route is missing. + pub fn delete_route(&self, tenant_id: Uuid, id: Uuid) -> Result<(), OagwError> { + let existing = self.routes.get(id).ok_or_else(|| not_found("route", id))?; + if existing.tenant_id != tenant_id { + return Err(not_found("route", id)); + } + crate::infra::audit::config_change("delete", "route", id, tenant_id); + self.routes.delete(id) + } + + /// Register a custom (Starlark) plugin. + /// + /// # Errors + /// + /// Returns [`OagwError::Validation`] when the payload is invalid. + pub fn create_plugin( + &self, + tenant_id: Uuid, + spec: PluginCreate, + ) -> Result { + validate_plugin_create(&spec)?; + let now = now_rfc3339(); + let definition = PluginDefinition { + id: Uuid::new_v4(), + tenant_id, + plugin_type: spec.plugin_type, + name: spec.name, + source_code: spec.source_code, + config_schema: spec.config_schema, + created_at: now.clone(), + updated_at: now, + }; + crate::infra::audit::config_change("create", "plugin", definition.id, tenant_id); + self.plugins.insert(definition) + } + + /// Fetch a plugin owned by `tenant_id`. + #[must_use] + pub fn get_plugin(&self, tenant_id: Uuid, id: Uuid) -> Option { + self.plugins + .get(id) + .filter(|definition| definition.tenant_id == tenant_id) + } + + /// List plugins owned by `tenant_id`. + #[must_use] + pub fn list_plugins(&self, tenant_id: Uuid) -> Vec { + let mut definitions = self.plugins.list_by_tenant(tenant_id); + definitions.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id))); + definitions + } + + /// Delete an unreferenced plugin. + /// + /// # Errors + /// + /// Returns [`OagwError::PluginInUse`] when an upstream or route still + /// references the plugin, [`OagwError::RouteNotFound`] when the plugin is + /// missing. + pub fn delete_plugin(&self, tenant_id: Uuid, id: Uuid) -> Result<(), OagwError> { + let existing = self + .plugins + .get(id) + .ok_or_else(|| not_found("plugin", id))?; + if existing.tenant_id != tenant_id { + return Err(not_found("plugin", id)); + } + let references = self.plugin_references(id)?; + if !references.upstreams.is_empty() || !references.routes.is_empty() { + return Err(OagwError::PluginInUse(references)); + } + crate::infra::audit::config_change("delete", "plugin", id, tenant_id); + self.plugins.delete(id) + } + + /// Upstreams and routes that reference `plugin_id`. + /// + /// # Errors + /// + /// Returns [`OagwError::RouteNotFound`] when the plugin is missing. + pub fn plugin_references( + &self, + plugin_id: Uuid, + ) -> Result { + self.plugins + .get(plugin_id) + .ok_or_else(|| not_found("plugin", plugin_id))?; + let needle = plugin_id.to_string(); + let mut references = crate::domain::error::References::empty(); + for upstream in self.upstreams.list_all() { + if upstream_referenced(&upstream, &needle) { + references.upstreams.push(upstream.id.to_string()); + } + } + for route in self.routes.list_all() { + if route_referenced(&route, &needle) { + references.routes.push(route.id.to_string()); + } + } + Ok(references) + } + + /// Walk the tenant chain and return every entry that owns an upstream with + /// `alias`, closest first (descendant → root). + /// + /// `chain` is the tenant chain ordered `[self, parent, …, root]`. + #[must_use] + pub fn resolve_alias_chain(&self, chain: &[Uuid], alias: &str) -> Vec { + let needle = normalize_host(alias); + chain + .iter() + .map(|tenant_id| ChainEntry { + tenant_id: *tenant_id, + upstream: self.upstreams.get_by_alias(*tenant_id, &needle), + }) + .collect() + } + + /// Every upstream that owns `alias` in the tenant chain, closest tenant + /// first, each paired with the configuration merged from its own position + /// in the chain (ancestor → descendant, up to the root). + /// + /// The data plane walks this list until a tenant's routes match, which is + /// what makes an ancestor's routes inherited at proxy time (DESIGN §3.3 + /// "Proxy (data plane): Inherited via tenant chain walk") while the closest + /// tenant still wins the routing target. + #[must_use] + pub fn upstream_candidates( + &self, + chain: &[Uuid], + alias: &str, + ) -> Vec<(Upstream, crate::domain::merge::EffectiveConfig)> { + let entries = self.resolve_alias_chain(chain, alias); + let mut candidates = Vec::new(); + for (index, _entry) in entries.iter().enumerate() { + let Some(upstream) = entries[index].upstream.clone() else { + continue; + }; + let mut effective_chain: Vec = entries[index..].to_vec(); + effective_chain.reverse(); + candidates.push(( + upstream, + crate::domain::merge::merge_chain(&effective_chain), + )); + } + candidates + } + + /// The selected upstream (closest tenant wins) for a proxy request. + #[must_use] + pub fn select_upstream( + &self, + chain: &[Uuid], + alias: &str, + ) -> Option<(Upstream, crate::domain::merge::EffectiveConfig)> { + self.upstream_candidates(chain, alias).into_iter().next() + } + + /// Routes of `upstream_id` that are usable for matching. + #[must_use] + pub fn routes_for_upstream(&self, upstream_id: Uuid) -> Vec { + self.routes.list_by_upstream(upstream_id) + } + + /// Resolve the `X-OAGW-Target-Host` selection failure reason for diagnostics. + #[must_use] + pub fn derivability(&self, endpoints: &[Endpoint]) -> Option { + match compute_derived_alias(endpoints) { + DerivedAlias::Derived(value) => Some(value), + DerivedAlias::NotDerivable(reason) => match reason { + NotDerivable::NoCommonSuffix => Some("no common suffix".to_owned()), + NotDerivable::BarePublicSuffix => Some("bare public suffix".to_owned()), + NotDerivable::IpEndpoints => Some("ip endpoints".to_owned()), + NotDerivable::MixedPorts => Some("mixed ports".to_owned()), + }, + } + } + + /// `true` when `reference` names a plugin with no implementation. + /// + /// Built-in and catalog-only identifiers answer by name; anything else is + /// asked of the data plane's registry when one was supplied. + #[must_use] + pub fn plugin_missing(&self, reference: &str) -> bool { + let instance = crate::domain::model::plugin_instance(reference); + if uuid::Uuid::parse_str(instance).is_ok() || plugin::is_known_plugin(instance) { + return false; + } + self.plugin_catalog + .get() + .is_none_or(|resolves| !resolves(reference)) + } + + /// Reject a plugin chain naming something no implementation resolves. + fn validate_plugin_chain( + &self, + config: Option<&crate::domain::model::PluginsConfig>, + ) -> Result<(), OagwError> { + let Some(config) = config else { + return Ok(()); + }; + for item in &config.items { + let reference = item.reference(); + if !self.plugin_missing(reference) { + continue; + } + return Err(OagwError::Validation(format!( + "plugins entry '{reference}' does not name a known plugin" + ))); + } + Ok(()) + } +} + +fn upstream_referenced(upstream: &Upstream, plugin_id: &str) -> bool { + if let Some(auth) = &upstream.spec.auth + && plugin::plugin_instance_matches(&auth.plugin_type, plugin_id) + { + return true; + } + upstream.spec.plugins.as_ref().is_some_and(|plugins| { + plugins + .items + .iter() + .any(|item| item.reference() == plugin_id) + }) +} + +fn route_referenced(route: &Route, plugin_id: &str) -> bool { + route.spec.plugins.as_ref().is_some_and(|plugins| { + plugins + .items + .iter() + .any(|item| item.reference() == plugin_id) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::model::ServerConfig; + use crate::infra::storage::Stores; + + fn service() -> (ControlPlaneService, Stores) { + let stores = Stores::new(); + ( + ControlPlaneService::new(stores.upstreams(), stores.routes(), stores.plugins()), + stores, + ) + } + + fn spec(hosts: &[(&str, u16)]) -> UpstreamCreate { + UpstreamCreate { + enabled: true, + alias: None, + tags: Vec::new(), + server: ServerConfig { + endpoints: hosts + .iter() + .map(|(host, port)| Endpoint { + scheme: "https".to_owned(), + host: (*host).to_owned(), + port: *port, + }) + .collect(), + }, + protocol: crate::domain::model::PROTOCOL_HTTP.to_owned(), + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + } + } + + #[test] + fn create_derives_alias_and_enforces_uniqueness() { + let (svc, _stores) = service(); + let tenant = Uuid::new_v4(); + let created = svc + .create_upstream(tenant, spec(&[("api.openai.com", 443)])) + .expect("created"); + assert_eq!(created.alias, "api.openai.com"); + assert!(created.alias_derived); + let second = svc.create_upstream(tenant, spec(&[("api.openai.com", 443)])); + assert!(matches!(second, Err(OagwError::Validation(_)))); + } + + #[test] + fn tenant_scoping_hides_ancestor_resources() { + let (svc, _stores) = service(); + let ancestor = Uuid::new_v4(); + let created = svc + .create_upstream(ancestor, spec(&[("api.openai.com", 443)])) + .expect("created"); + let descendant = Uuid::new_v4(); + assert!(svc.get_upstream(descendant, created.id).is_none()); + assert!( + svc.replace_upstream(descendant, created.id, spec(&[("api.openai.com", 443)])) + .is_err() + ); + } + + #[test] + fn delete_plugin_reports_references() { + let (svc, _stores) = service(); + let tenant = Uuid::new_v4(); + let plugin = svc + .create_plugin( + tenant, + PluginCreate { + plugin_type: "guard_plugin".to_owned(), + name: "my-guard".to_owned(), + source_code: "def apply(ctx):\n return ctx\n".to_owned(), + config_schema: None, + }, + ) + .expect("created"); + let mut spec = spec(&[("api.openai.com", 443)]); + spec.plugins = Some(crate::domain::model::PluginsConfig { + sharing: crate::domain::model::Sharing::Private, + items: vec![crate::domain::model::PluginItem::Reference( + plugin.id.to_string(), + )], + }); + let upstream = svc.create_upstream(tenant, spec).expect("created"); + let err = svc.delete_plugin(tenant, plugin.id).expect_err("in use"); + match err { + OagwError::PluginInUse(references) => { + assert_eq!(references.upstreams, vec![upstream.id.to_string()]); + assert!(references.routes.is_empty()); + } + other => panic!("unexpected error: {other:?}"), + } + } + + #[test] + fn select_closest_tenant_wins() { + let (svc, _stores) = service(); + let root = Uuid::new_v4(); + let child = Uuid::new_v4(); + let ancestor = svc + .create_upstream(root, spec(&[("api.openai.com", 443)])) + .expect("created"); + let descendant = svc + .create_upstream(child, spec(&[("api.openai.com", 443)])) + .expect("created"); + let chain = [child, root]; + let (selected, _effective) = svc + .select_upstream(&chain, "api.openai.com") + .expect("resolved"); + assert_eq!(selected.id, descendant.id); + assert_ne!(selected.id, ancestor.id); + } +} diff --git a/gears/system/oagw/oagw/src/domain/services/mod.rs b/gears/system/oagw/oagw/src/domain/services/mod.rs new file mode 100644 index 0000000..d4f4c9b --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/services/mod.rs @@ -0,0 +1,6 @@ +// Created: 2026-08-29 by Constructor Tech +//! Domain services. + +pub mod management; + +pub use management::ControlPlaneService; diff --git a/gears/system/oagw/oagw/src/gear.rs b/gears/system/oagw/oagw/src/gear.rs new file mode 100644 index 0000000..09aee1e --- /dev/null +++ b/gears/system/oagw/oagw/src/gear.rs @@ -0,0 +1,119 @@ +// Created: 2026-08-29 by Constructor Tech +//! Gear registration: the `oagw` outbound API gateway. + +use std::sync::{Arc, OnceLock}; + +use async_trait::async_trait; +use credstore_sdk::CredStoreClientV1; +use tenant_resolver_sdk::TenantResolverClient; +use toolkit::Gear; +use toolkit::RestApiCapability; +use toolkit::api::OpenApiRegistry; +use toolkit::context::GearCtx; +use tracing::info; + +use crate::config::OagwConfig; +use crate::domain::services::management::ControlPlaneService; +use crate::infra::plugin::PluginRegistry; +use crate::infra::proxy::service::DataPlaneService; +use crate::infra::storage::Stores; + +/// The `oagw` outbound API gateway gear. +#[toolkit::gear( + name = "oagw", + deps = [credstore, types_registry, authz_resolver, tenant_resolver], + capabilities = [rest] +)] +pub struct Oagw { + services: OnceLock>, +} + +impl Default for Oagw { + fn default() -> Self { + Self { + services: OnceLock::new(), + } + } +} + +impl Oagw { + /// The services bundle built at init (available after `Gear::init`). + #[must_use] + pub fn services(&self) -> Option> { + self.services.get().cloned() + } +} + +#[async_trait] +impl Gear for Oagw { + async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> { + let config: OagwConfig = ctx.config_or_default()?; + + let credstore = ctx + .client_hub() + .get::() + .map_err(|error| anyhow::anyhow!("failed to get CredStoreClientV1: {error}")) + .ok(); + let tenant_resolver = ctx + .client_hub() + .get::() + .map_err(|error| anyhow::anyhow!("failed to get TenantResolverClient: {error}")) + .ok(); + + let stores = Arc::new(Stores::new()); + let control_plane = Arc::new(ControlPlaneService::new( + stores.upstreams(), + stores.routes(), + stores.plugins(), + )); + let plugins = Arc::new(PluginRegistry::with_builtins( + credstore, + std::time::Duration::from_secs(config.token_cache_ttl_secs), + config.token_cache_capacity, + )); + // Configuration can now reject a dangling plugin reference instead of + // leaving it to fail with `503` on the first request. + control_plane.set_plugin_catalog({ + let plugins = Arc::clone(&plugins); + Arc::new(move |reference: &str| !plugins.missing(reference)) + }); + let data_plane = Arc::new( + DataPlaneService::new( + Arc::clone(&control_plane), + Arc::clone(&plugins), + tenant_resolver, + config, + ) + .map_err(|error| anyhow::anyhow!("oagw data plane: {error}"))?, + ); + + let services = Arc::new(crate::api::rest::handlers::Services { + control_plane, + data_plane, + }); + self.services + .set(services) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + + info!("oagw gear initialized"); + Ok(()) + } +} + +impl RestApiCapability for Oagw { + fn register_rest( + &self, + _ctx: &GearCtx, + router: axum::Router, + openapi: &dyn OpenApiRegistry, + ) -> anyhow::Result { + let services = self + .services + .get() + .cloned() + .ok_or_else(|| anyhow::anyhow!("oagw services not initialized"))?; + let router = crate::api::rest::routes::register(router, openapi, services); + info!("oagw REST routes registered"); + Ok(router) + } +} diff --git a/gears/system/oagw/oagw/src/infra/audit.rs b/gears/system/oagw/oagw/src/infra/audit.rs new file mode 100644 index 0000000..1a06b8f --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/audit.rs @@ -0,0 +1,116 @@ +// Created: 2026-08-29 by Constructor Tech +//! Structured audit events (DESIGN §4.3). +//! +//! One JSON event per proxied request, config change, authentication failure +//! and circuit-breaker transition, emitted through `tracing` so the host's +//! log pipeline picks them up. +//! +//! Security: the field set is closed. Request and response *bodies*, query +//! strings and header values are never formatted into an event, and neither +//! are credentials — `path` is the only request material beyond the identity +//! and sizing fields the design allowlists. + +use axum::http::StatusCode; +use uuid::Uuid; + +/// A completed proxy request, in the field set of DESIGN §4.3. +pub struct ProxyAudit<'a> { + /// Correlation id. + pub request_id: &'a str, + /// Caller tenant. + pub tenant_id: Uuid, + /// Authenticated subject. + pub principal_id: Uuid, + /// Routing alias requested. + pub alias: &'a str, + /// Method of the request. + pub method: &'a str, + /// Final status, when the request produced one. + pub status: Option, + /// Wall-clock duration of the pipeline. + pub duration_ms: u128, + /// Request body size in bytes. + pub request_size: usize, + /// Response body size in bytes. + pub response_size: usize, + /// Error type id, when the request failed at the gateway. + pub error_type: Option<&'a str>, +} + +/// Emit the audit event of a completed proxy request. +pub fn proxy_request(audit: &ProxyAudit<'_>) { + tracing::info!( + event = "proxy_request", + request_id = audit.request_id, + tenant_id = %audit.tenant_id, + principal_id = %audit.principal_id, + alias = audit.alias, + method = audit.method, + status = audit.status.map_or_else(|| "none".to_owned(), |s| s.as_u16().to_string()), + duration_ms = audit.duration_ms as u64, + request_size = audit.request_size as u64, + response_size = audit.response_size as u64, + error_type = audit.error_type.unwrap_or("none"), + "proxied request" + ); +} + +/// One configuration change on the control plane. +pub fn config_change(action: &str, resource: &str, id: Uuid, tenant_id: Uuid) { + tracing::info!( + event = "config_change", + action, + resource, + resource_id = %id, + tenant_id = %tenant_id, + "configuration changed" + ); +} + +/// A rejected authentication attempt. The reason is a coarse code, never the +/// presented credential or the error text that could embed one. +pub fn auth_failure(tenant_id: Uuid, upstream_id: Uuid, reason: &str) { + tracing::warn!( + event = "auth_failure", + tenant_id = %tenant_id, + upstream_id = %upstream_id, + reason, + "upstream authentication refused" + ); +} + +/// A circuit-breaker state transition for an upstream endpoint. +pub fn breaker_transition(upstream_id: Uuid, endpoint: &str, state: &str) { + tracing::warn!( + event = "circuit_breaker", + upstream_id = %upstream_id, + // An endpoint host is configuration, not request material. + endpoint, + state, + "circuit breaker transition" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_proxy_event_carryies_the_design_field_set() { + // The event is built from the allowlisted fields only; this test pins + // the call shape so a future edit cannot silently add request material. + let audit = ProxyAudit { + request_id: "trace-1", + tenant_id: Uuid::nil(), + principal_id: Uuid::nil(), + alias: "api.example.com", + method: "GET", + status: Some(StatusCode::OK), + duration_ms: 12, + request_size: 3, + response_size: 5, + error_type: None, + }; + proxy_request(&audit); + } +} diff --git a/gears/system/oagw/oagw/src/infra/metrics.rs b/gears/system/oagw/oagw/src/infra/metrics.rs new file mode 100644 index 0000000..98eae77 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/metrics.rs @@ -0,0 +1,223 @@ +// Created: 2026-08-29 by Constructor Tech +//! OpenTelemetry adapter implementing [`OagwMetricsPort`]. +//! +//! Instruments are pulled from the process-global meter provider installed by +//! the host; a no-op until an exporter is wired. Instrument names are full +//! literal Prometheus names: counters end in `_total`, duration histograms in +//! `_seconds`, with suffixes baked in (no `.with_unit()`), matching the +//! platform's `add_metric_suffixes: false` collector posture. +//! +//! Cardinality follows DESIGN §4.2: no tenant labels, `http.route` is the +//! normalized route match pattern (never the raw request path), method is +//! normalized to a standard verb or `_OTHER`, and `host` is the upstream alias. + +use opentelemetry::KeyValue; +use opentelemetry::metrics::{Counter, Gauge, Histogram, Meter}; + +use crate::domain::ports::metrics::{BreakerState, OagwMetricsPort, SelectionMethod}; + +/// Meter / instrumentation scope name. +const METER_NAME: &str = "oagw"; + +// ─── Metric names (literal Prometheus form; `add_metric_suffixes: false`) ───── +const REQUESTS: &str = "oagw_requests_total"; +const REQUEST_DURATION: &str = "oagw_request_duration_seconds"; +const ERRORS: &str = "oagw_errors_total"; +const RATE_LIMIT_EXCEEDED: &str = "oagw_rate_limit_exceeded_total"; +const BREAKER_TRANSITIONS: &str = "oagw_circuit_breaker_transitions_total"; +const TARGET_HOST_USED: &str = "oagw_routing_target_host_used"; +const ENDPOINT_SELECTED: &str = "oagw_routing_endpoint_selected"; + +/// OpenTelemetry-backed metrics handle for the `oagw` module. +pub struct OagwMetricsMeter { + requests: Counter, + request_duration: Histogram, + errors: Counter, + rate_limit_exceeded: Counter, + breaker_state: Gauge, + breaker_transitions: Counter, + target_host_used: Counter, + endpoint_selected: Counter, +} + +impl std::fmt::Debug for OagwMetricsMeter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OagwMetricsMeter").finish_non_exhaustive() + } +} + +/// Histogram boundaries of the request-duration instrument (DESIGN §4.2). +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, +]; + +/// Verbs the OTel HTTP semantic conventions name. +const STANDARD_METHODS: [&str; 9] = [ + "GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", +]; + +/// `http.request.method` normalization of the OTel HTTP semantic conventions. +fn normalized_method(method: &str) -> &str { + // A case-variant of a standard verb reports the verb itself; anything else + // the convention does not name collapses into `_OTHER`. + STANDARD_METHODS + .iter() + .copied() + .find(|standard| method.eq_ignore_ascii_case(standard)) + .unwrap_or("_OTHER") +} + +impl OagwMetricsMeter { + /// Build the instrument set from the supplied meter. + #[must_use] + pub fn new(meter: &Meter) -> Self { + Self { + requests: meter + .u64_counter(REQUESTS) + .with_description("Proxied requests by upstream, route, method and status") + .build(), + request_duration: meter + .f64_histogram(REQUEST_DURATION) + .with_description("End-to-end proxied request duration, by phase") + .with_boundaries(DURATION_BUCKETS.to_vec()) + .build(), + errors: meter + .u64_counter(ERRORS) + .with_description("Gateway rejections by error type") + .build(), + rate_limit_exceeded: meter + .u64_counter(RATE_LIMIT_EXCEEDED) + .with_description("Requests refused because their budget was exhausted") + .build(), + breaker_state: meter + .i64_gauge("oagw_circuit_breaker_state") + .with_description("Blocking state of an upstream endpoint's breaker") + .build(), + breaker_transitions: meter + .u64_counter(BREAKER_TRANSITIONS) + .with_description("Circuit-breaker transitions by from and to state") + .build(), + target_host_used: meter + .u64_counter(TARGET_HOST_USED) + .with_description("Requests that named their endpoint with X-OAGW-Target-Host") + .build(), + endpoint_selected: meter + .u64_counter(ENDPOINT_SELECTED) + .with_description("Endpoint selections by selection method") + .build(), + } + } + + /// Build the instrument set from the process-global meter provider. + #[must_use] + pub fn from_global() -> Self { + Self::new(&opentelemetry::global::meter(METER_NAME)) + } +} + +impl OagwMetricsPort for OagwMetricsMeter { + fn record_request( + &self, + host: &str, + route: &str, + method: &str, + status_code: u16, + duration_seconds: f64, + ) { + let attributes = [ + KeyValue::new("host", host.to_owned()), + KeyValue::new("http.route", route.to_owned()), + KeyValue::new("http.request.method", normalized_method(method).to_owned()), + KeyValue::new("http.response.status_code", i64::from(status_code)), + ]; + self.requests.add(1, &attributes); + self.request_duration.record(duration_seconds, &attributes); + } + + fn record_error(&self, host: &str, route: &str, error_type: &str) { + self.errors.add( + 1, + &[ + KeyValue::new("host", host.to_owned()), + KeyValue::new("http.route", route.to_owned()), + KeyValue::new("error_type", error_type.to_owned()), + ], + ); + } + + fn record_rate_limit_exceeded(&self, host: &str, path: &str) { + self.rate_limit_exceeded.add( + 1, + &[ + KeyValue::new("host", host.to_owned()), + KeyValue::new("path", path.to_owned()), + ], + ); + } + + fn record_breaker_transition(&self, host: &str, from: BreakerState, to: BreakerState) { + self.breaker_transitions.add( + 1, + &[ + KeyValue::new("host", host.to_owned()), + KeyValue::new("from_state", from.as_str().to_owned()), + KeyValue::new("to_state", to.as_str().to_owned()), + ], + ); + } + + fn set_breaker_state(&self, host: &str, state: BreakerState) { + self.breaker_state.record( + match state { + BreakerState::Closed => 0, + BreakerState::Open => 1, + }, + &[KeyValue::new("host", host.to_owned())], + ); + } + + fn record_target_host_used(&self, upstream_id: &str, endpoint_host: &str) { + self.target_host_used.add( + 1, + &[ + KeyValue::new("upstream_id", upstream_id.to_owned()), + KeyValue::new("endpoint_host", endpoint_host.to_owned()), + ], + ); + } + + fn record_endpoint_selected( + &self, + upstream_id: &str, + endpoint_host: &str, + method: SelectionMethod, + ) { + self.endpoint_selected.add( + 1, + &[ + KeyValue::new("upstream_id", upstream_id.to_owned()), + KeyValue::new("endpoint_host", endpoint_host.to_owned()), + KeyValue::new("selection_method", method.as_str().to_owned()), + ], + ); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn standard_verbs_are_kept_and_others_normalized() { + assert_eq!(normalized_method("get"), "GET"); + assert_eq!(normalized_method("PATCH"), "PATCH"); + assert_eq!(normalized_method("purge"), "_OTHER"); + } + + #[test] + fn breaker_states_map_to_the_documented_gauge_values() { + // 0 = closed / available, 1 = open / blocking. + assert_eq!(BreakerState::Closed.as_str(), "closed"); + assert_eq!(BreakerState::Open.as_str(), "open"); + } +} 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..a566df3 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/mod.rs @@ -0,0 +1,13 @@ +// Created: 2026-08-29 by Constructor Tech +//! Infrastructure layer: in-memory persistence, the plugin registry, the +//! audit log, the metrics adapter and the data-plane proxy engine. +//! +//! Runtime-owned privileged access lives here only: credential resolution +//! (`credstore_sdk`), outbound TLS (`toolkit_http`) and the WebSocket +//! upstream leg (`tokio-tungstenite`). + +pub mod audit; +pub mod metrics; +pub mod plugin; +pub mod proxy; +pub mod storage; diff --git a/gears/system/oagw/oagw/src/infra/plugin/auth.rs b/gears/system/oagw/oagw/src/infra/plugin/auth.rs new file mode 100644 index 0000000..2c454a4 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/auth.rs @@ -0,0 +1,486 @@ +// Created: 2026-08-29 by Constructor Tech +//! Built-in auth plugins. +//! +//! Security: credentials are read from the credential store and injected into +//! the outbound request only. They are never logged, never embedded in an +//! error message and never returned in a response. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use credstore_sdk::{CredStoreClientV1, SecretRef}; +use pingora_memory_cache::{CacheStatus, MemoryCache}; +use toolkit_auth::oauth2::{ClientAuthMethod, OAuthClientConfig, SecretString, fetch_token}; + +use crate::domain::error::OagwError; +use crate::domain::plugin::{ + AUTH_APIKEY, AUTH_NOOP, AUTH_OAUTH2_CC, AUTH_OAUTH2_CC_BASIC, PluginError, RequestContext, +}; + +/// Single-read accessor for a string-valued plugin config key. +pub(crate) fn config_str<'a>( + config: &'a serde_json::Map, + key: &str, +) -> Option<&'a str> { + config.get(key).and_then(serde_json::Value::as_str) +} + +/// Read a comma separated config entry into trimmed, lower-cased tokens. +pub(crate) fn config_list( + config: &serde_json::Map, + key: &str, +) -> Vec { + config_str(config, key) + .unwrap_or_default() + .split(',') + .map(str::trim) + .filter(|entry| !entry.is_empty()) + .map(str::to_ascii_lowercase) + .collect() +} + +/// Turn a `cred://…` reference (or a bare key) into a [`SecretRef`]. +/// +/// `SecretRef` accepts `[a-zA-Z0-9_-]` only, so hierarchical references are +/// reduced to their last path segment. +fn secret_ref(reference: &str) -> Option { + let stripped = reference.trim().trim_start_matches("cred://"); + let last = stripped.rsplit('/').next().unwrap_or(stripped); + SecretRef::new(last).ok() +} + +/// Read a secret through the credential store without ever formatting it. +async fn resolve_secret( + credstore: &Option>, + security: &toolkit_security::SecurityContext, + reference: &str, +) -> Result { + let store = credstore + .as_ref() + .ok_or_else(|| PluginError::Authentication("credential store unavailable".to_owned()))?; + let key = secret_ref(reference).ok_or_else(|| { + PluginError::Authentication("credential reference is not a valid key".to_owned()) + })?; + match store.get(security, &key).await { + Ok(Some(found)) => Ok(String::from_utf8_lossy(found.value.as_bytes()).into_owned()), + // `Ok(None)` (absent or inaccessible) and `Err(_)` collapse into the + // same failure: a 401 with no credential material in the detail. + _ => Err(PluginError::Authentication( + "credential not available".to_owned(), + )), + } +} + +// --------------------------------------------------------------------------------------- +// no-op +// --------------------------------------------------------------------------------------- + +/// `cf.core.oagw.noop.v1` — always succeeds. +pub struct NoopAuth; + +#[async_trait] +impl crate::domain::plugin::AuthPlugin for NoopAuth { + fn id(&self) -> &str { + AUTH_NOOP + } + + fn plugin_type(&self) -> &str { + "auth_plugin" + } + + async fn authenticate(&self, _ctx: &mut RequestContext) -> Result<(), PluginError> { + Ok(()) + } +} + +// --------------------------------------------------------------------------------------- +// api key +// --------------------------------------------------------------------------------------- + +/// `cf.core.oagw.apikey.v1` — resolves an API key from the credential store and +/// injects it under `header_name`, or into the query under `query_param_name` +/// when that is configured (ADR-0002 "API key injection (header/query)"). +pub struct ApiKeyAuth { + credstore: Option>, +} + +impl ApiKeyAuth { + pub(crate) fn new(credstore: Option>) -> Self { + Self { credstore } + } +} + +#[async_trait] +impl crate::domain::plugin::AuthPlugin for ApiKeyAuth { + fn id(&self) -> &str { + AUTH_APIKEY + } + + fn plugin_type(&self) -> &str { + "auth_plugin" + } + + async fn authenticate(&self, ctx: &mut RequestContext) -> Result<(), PluginError> { + let reference = config_str(&ctx.config, "api_key_ref") + .ok_or_else(|| { + PluginError::Authentication( + "auth plugin 'apikey' requires 'api_key_ref'".to_owned(), + ) + })? + .to_owned(); + let secret = resolve_secret(&self.credstore, &ctx.security, &reference).await?; + if let Some(query_param) = config_str(&ctx.config, "query_param_name") { + if query_param.is_empty() { + return Err(PluginError::Authentication( + "auth plugin 'apikey' has an empty query_param_name".to_owned(), + )); + } + ctx.query = Some(append_query_param( + ctx.query.as_deref(), + query_param, + &secret, + )); + return Ok(()); + } + let header_name = config_str(&ctx.config, "header_name") + .unwrap_or("authorization") + .to_ascii_lowercase(); + let value = axum::http::HeaderValue::from_str(&secret).map_err(|_| { + PluginError::Authentication("credential is not a valid header value".to_owned()) + })?; + ctx.headers.insert( + axum::http::HeaderName::from_bytes(header_name.as_bytes()).map_err(|_| { + PluginError::Authentication( + "auth plugin 'apikey' has an invalid header_name".to_owned(), + ) + })?, + value, + ); + Ok(()) + } +} + +// --------------------------------------------------------------------------------------- +// oauth2 client credentials +// --------------------------------------------------------------------------------------- + +/// Cached OAuth2 token. The bearer material is wrapped in a redacting type. +#[derive(Clone)] +pub struct CachedToken { + /// Cache key the entry was stored under (verified on lookup). + pub key: String, + /// The bearer token. + pub token: SecretString, +} + +impl std::fmt::Debug for CachedToken { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CachedToken") + .field("key", &self.key) + .field("token", &"[REDACTED]") + .finish() + } +} + +/// Build the `400 OAUTH2_CONFIG_INVALID` rejection used for malformed auth +/// plugin configuration. +fn invalid_oauth_config(message: &str) -> PluginError { + PluginError::Rejected { + status: axum::http::StatusCode::BAD_REQUEST, + error_code: "OAUTH2_CONFIG_INVALID".to_owned(), + message: message.to_owned(), + } +} + +/// Deterministic 64-bit FNV-1a digest of the sorted plugin config JSON. +fn config_hash(config: &serde_json::Map) -> String { + let canonical = canonical_json(&serde_json::Value::Object(config.clone())); + let mut hash: u64 = 0xcbf2_9ce4_8422_2325; + for byte in canonical.as_bytes() { + hash ^= u64::from(*byte); + hash = hash.wrapping_mul(0x0000_0100_0000_01b3); + } + format!("{hash:016x}") +} + +/// Deterministic, key-sorted JSON rendering (no third-party serializer needed). +fn canonical_json(value: &serde_json::Value) -> String { + match value { + serde_json::Value::Object(map) => { + let mut keys: Vec<&String> = map.keys().collect(); + keys.sort(); + let body: Vec = keys + .iter() + .map(|key| { + format!( + "{}:{}", + serde_json::to_string(key).unwrap_or_default(), + canonical_json(&map[*key]) + ) + }) + .collect(); + format!("{{{}}}", body.join(",")) + } + serde_json::Value::Array(items) => { + let body: Vec = items.iter().map(canonical_json).collect(); + format!("[{}]", body.join(",")) + } + other => other.to_string(), + } +} + +/// `cf.core.oagw.oauth2_client_cred.v1` / `…_basic.v1`. +pub struct OAuth2ClientCredentials { + credstore: Option>, + cache: Arc>, + ttl: Duration, + basic: bool, +} + +impl OAuth2ClientCredentials { + pub(crate) fn new( + credstore: Option>, + cache_capacity: usize, + ttl: Duration, + basic: bool, + ) -> Self { + Self { + credstore, + cache: Arc::new(MemoryCache::new(cache_capacity.max(1))), + ttl, + basic, + } + } + + async fn oauth_config(&self, ctx: &RequestContext) -> Result { + let endpoint = config_str(&ctx.config, "token_endpoint"); + let issuer = config_str(&ctx.config, "issuer_url"); + match (endpoint.is_some(), issuer.is_some()) { + (true, true) | (false, false) => { + return Err(invalid_oauth_config( + "auth plugin requires exactly one of 'token_endpoint' or 'issuer_url'", + )); + } + (true, false) | (false, true) => {} + } + let client_id_ref = config_str(&ctx.config, "client_id_ref") + .ok_or_else(|| invalid_oauth_config("auth plugin requires 'client_id_ref'"))? + .to_owned(); + let client_secret_ref = config_str(&ctx.config, "client_secret_ref") + .ok_or_else(|| invalid_oauth_config("auth plugin requires 'client_secret_ref'"))? + .to_owned(); + + // Resolve both credentials before building the config so that a missing + // secret never reaches the token endpoint. + let client_id = resolve_secret(&self.credstore, &ctx.security, &client_id_ref).await?; + let client_secret = + resolve_secret(&self.credstore, &ctx.security, &client_secret_ref).await?; + + let token_endpoint = match endpoint.map(url::Url::parse) { + Some(Ok(parsed)) => Some(parsed), + Some(Err(_)) => { + return Err(invalid_oauth_config( + "auth plugin endpoint is not a valid URL", + )); + } + None => None, + }; + let issuer_url = match issuer.map(url::Url::parse) { + Some(Ok(parsed)) => Some(parsed), + Some(Err(_)) => { + return Err(invalid_oauth_config( + "auth plugin endpoint is not a valid URL", + )); + } + None => None, + }; + + let scopes = config_str(&ctx.config, "scopes") + .unwrap_or_default() + .split([',', ' ']) + .map(str::trim) + .filter(|scope| !scope.is_empty()) + .map(str::to_owned) + .collect::>(); + + Ok(OAuthClientConfig { + token_endpoint, + issuer_url, + client_id, + client_secret: SecretString::new(client_secret), + scopes, + auth_method: if self.basic { + ClientAuthMethod::Basic + } else { + ClientAuthMethod::Form + }, + ..OAuthClientConfig::default() + }) + } + + fn cache_key(&self, ctx: &RequestContext) -> String { + let method_tag = if self.basic { "basic" } else { "form" }; + // The subject's *home* tenant, not the resolved one: a caller acting on + // behalf of another tenant still owns its own client credentials, so the + // cached token must not leak across that boundary. + format!( + "{}:{}:{}:{}", + ctx.security.subject_tenant_id(), + ctx.security.subject_id(), + method_tag, + config_hash(&ctx.config) + ) + } +} + +#[async_trait] +impl crate::domain::plugin::AuthPlugin for OAuth2ClientCredentials { + fn id(&self) -> &str { + if self.basic { + AUTH_OAUTH2_CC_BASIC + } else { + AUTH_OAUTH2_CC + } + } + + fn plugin_type(&self) -> &str { + "auth_plugin" + } + + async fn authenticate(&self, ctx: &mut RequestContext) -> Result<(), PluginError> { + let key = self.cache_key(ctx); + let (cached, status) = self.cache.get(&key); + if let (Some(entry), CacheStatus::Hit) = (cached, status) + && entry.key == key + { + inject_bearer(ctx, entry.token.expose())?; + return Ok(()); + } + + let config = self.oauth_config(ctx).await?; + let fetched = fetch_token(config).await; + // A failed fetch is never cached. + let fetched = fetched.map_err(|_| { + PluginError::Authentication("token endpoint rejected the client credentials".to_owned()) + })?; + + inject_bearer(ctx, fetched.bearer.expose())?; + + let ttl = self + .ttl + .min(fetched.expires_in.saturating_sub(Duration::from_secs(30))); + if ttl > Duration::ZERO { + self.cache.put( + &key, + CachedToken { + key: key.clone(), + token: fetched.bearer, + }, + Some(ttl), + ); + } + Ok(()) + } +} + +fn inject_bearer(ctx: &mut RequestContext, token: &str) -> Result<(), PluginError> { + let value = format!("Bearer {token}"); + let header = axum::http::HeaderValue::from_str(&value) + .map_err(|_| PluginError::Authentication("token is not a valid header value".to_owned()))?; + ctx.headers + .insert(axum::http::header::AUTHORIZATION, header); + Ok(()) +} + +/// Append `name=value` to a query string, preserving any existing parameters. +/// +/// The caller's parameters keep their original order and encoding; the injected +/// one goes last. +#[must_use] +pub fn append_query_param(query: Option<&str>, name: &str, value: &str) -> String { + // `append_pair` emits exactly `name=value` with the value percent-encoded. + let pair = url::form_urlencoded::Serializer::new(String::new()) + .append_pair(name, value) + .finish(); + match query { + Some(existing) if !existing.is_empty() => format!("{existing}&{pair}"), + _ => pair, + } +} + +/// Convert a credential-store failure into a domain error without leaking the +/// secret reference or value. +#[must_use] +pub fn secret_error(message: &str) -> OagwError { + OagwError::SecretNotFound(message.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn query_parameters_are_appended_without_disturbing_the_callers() { + assert_eq!( + append_query_param(None, "api-key", "abc123"), + "api-key=abc123" + ); + assert_eq!( + append_query_param(Some("page=2"), "api-key", "abc123"), + "page=2&api-key=abc123" + ); + assert_eq!( + append_query_param(Some(""), "api-key", "abc123"), + "api-key=abc123" + ); + // A value needing encoding stays a single parameter. + assert_eq!( + append_query_param(Some("page=2"), "api-key", "a=b&c"), + "page=2&api-key=a%3Db%26c" + ); + } + + #[test] + fn canonical_json_is_key_sorted() { + let mut map = serde_json::Map::new(); + map.insert("b".to_owned(), serde_json::json!(1)); + map.insert("a".to_owned(), serde_json::json!({"z": true, "y": [1, 2]})); + assert_eq!( + canonical_json(&serde_json::Value::Object(map)), + r#"{"a":{"y":[1,2],"z":true},"b":1}"# + ); + } + + #[test] + fn secret_ref_reduces_hierarchical_reference() { + let reference = secret_ref("cred://oagw/my-api-key-1").expect("valid"); + assert_eq!(reference.as_ref(), "my-api-key-1"); + } + + #[test] + fn secret_ref_accepts_bare_key() { + let reference = secret_ref("api_key").expect("valid"); + assert_eq!(reference.as_ref(), "api_key"); + } + + #[test] + fn config_list_trims_lowercases_and_drops_empty() { + let mut map = serde_json::Map::new(); + map.insert( + "required_request_headers".to_owned(), + serde_json::json!(" X-Trace-Id ,, x-Auth"), + ); + assert_eq!( + config_list(&map, "required_request_headers"), + vec!["x-trace-id", "x-auth"] + ); + assert!(config_list(&map, "missing").is_empty()); + } + + #[test] + fn secret_error_has_no_material() { + let error = secret_error("credential not available"); + assert!(!error.to_string().contains("Bearer")); + } +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/guard.rs b/gears/system/oagw/oagw/src/infra/plugin/guard.rs new file mode 100644 index 0000000..da3291a --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/guard.rs @@ -0,0 +1,153 @@ +// Created: 2026-08-29 by Constructor Tech +//! `cf.core.oagw.required_headers.v1` — presence-only header guard. +//! +//! Fail-open by design (DESIGN §9): an absent or blank configuration allows +//! every request; a present but unmet requirement is a hard rejection. + +use async_trait::async_trait; + +use crate::domain::plugin::{ + GUARD_REQUIRED_HEADERS, GuardDecision, GuardPlugin, PluginError, RequestContext, + ResponseContext, +}; +use crate::infra::plugin::auth::config_list; + +/// Guard that requires named headers on the request and/or the response. +/// +/// The guard is stateless: the requirement comes from the plugin binding +/// configuration handed to the request / response context, so one registered +/// instance serves every upstream. +#[derive(Debug, Clone, Default)] +pub struct RequiredHeadersGuard; + +impl RequiredHeadersGuard { + fn id_str() -> &'static str { + GUARD_REQUIRED_HEADERS + } +} + +#[async_trait] +impl GuardPlugin for RequiredHeadersGuard { + fn id(&self) -> &str { + Self::id_str() + } + + fn plugin_type(&self) -> &str { + "guard_plugin" + } + + async fn guard_request(&self, ctx: &RequestContext) -> Result { + let required = config_list(&ctx.config, "required_request_headers"); + Ok(match first_missing(&required, &ctx.headers) { + Some(missing) => GuardDecision::Reject { + status: axum::http::StatusCode::BAD_REQUEST, + error_code: "REQUIRED_HEADER_MISSING".to_owned(), + message: format!("required request header '{missing}' is absent"), + }, + None => GuardDecision::Allow, + }) + } + + async fn guard_response(&self, ctx: &ResponseContext) -> Result { + let required = config_list(&ctx.config, "required_response_headers"); + Ok(match first_missing(&required, &ctx.headers) { + Some(missing) => GuardDecision::Reject { + status: axum::http::StatusCode::BAD_GATEWAY, + error_code: "REQUIRED_HEADER_MISSING".to_owned(), + message: format!("required response header '{missing}' is absent"), + }, + None => GuardDecision::Allow, + }) + } +} + +/// First configured header name absent from `headers`, case-insensitively. +/// +/// Unparsable names can never be sent by a well-formed client, so they are +/// treated as missing rather than ignored. +fn first_missing(required: &[String], headers: &axum::http::HeaderMap) -> Option { + required + .iter() + .find(|name| { + axum::http::HeaderName::from_bytes(name.as_bytes()) + .is_ok_and(|normalized| !headers.contains_key(normalized)) + }) + .cloned() +} + +/// `PluginError` helper used when the guard cannot be evaluated at all. +#[must_use] +pub fn guard_error(message: &str) -> PluginError { + PluginError::Internal(message.to_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn headers(items: &[(&str, &str)]) -> axum::http::HeaderMap { + let mut map = axum::http::HeaderMap::new(); + for (name, value) in items { + let parsed = axum::http::HeaderName::from_bytes(name.as_bytes()).expect("name"); + map.insert( + parsed, + axum::http::HeaderValue::from_str(value).expect("value"), + ); + } + map + } + + fn context(headers: axum::http::HeaderMap, config: serde_json::Value) -> RequestContext { + RequestContext { + tenant_id: uuid::Uuid::nil(), + upstream_id: uuid::Uuid::nil(), + alias: "a.example.com".to_owned(), + method: "GET".to_owned(), + path: "/".to_owned(), + query: None, + headers, + body: bytes::Bytes::new(), + uri: axum::http::Uri::from_static("/"), + config: config.as_object().cloned().unwrap_or_default(), + security: toolkit_security::SecurityContext::anonymous(), + } + } + + #[tokio::test] + async fn request_phase_rejects_missing_header() { + let guard = RequiredHeadersGuard; + let ctx = context( + headers(&[("content-type", "application/json")]), + serde_json::json!({ "required_request_headers": "x-trace-id" }), + ); + assert!(matches!( + guard.guard_request(&ctx).await, + Ok(GuardDecision::Reject { + status: axum::http::StatusCode::BAD_REQUEST, + .. + }) + )); + } + + #[tokio::test] + async fn request_phase_allows_present_header() { + let guard = RequiredHeadersGuard; + let ctx = context( + headers(&[("X-Trace-Id", "abc")]), + serde_json::json!({ "required_request_headers": "x-trace-id" }), + ); + assert_eq!(guard.guard_request(&ctx).await, Ok(GuardDecision::Allow)); + } + + #[tokio::test] + async fn fail_open_when_no_headers_required() { + let guard = RequiredHeadersGuard; + let ctx = context(axum::http::HeaderMap::new(), serde_json::json!({})); + assert_eq!(guard.guard_request(&ctx).await, Ok(GuardDecision::Allow)); + } + + #[test] + fn guard_error_is_internal() { + assert!(matches!(guard_error("x"), PluginError::Internal(_))); + } +} 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..69c349a --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/mod.rs @@ -0,0 +1,12 @@ +// Created: 2026-08-29 by Constructor Tech +//! Built-in plugin implementations and the runtime plugin registry. +//! +//! All built-ins are constructed once at gear init and are immutable; the only +//! mutable state is the OAuth2 token cache, which never stores a failure. + +pub mod auth; +pub mod guard; +pub mod registry; +pub mod transform; + +pub use registry::PluginRegistry; diff --git a/gears/system/oagw/oagw/src/infra/plugin/registry.rs b/gears/system/oagw/oagw/src/infra/plugin/registry.rs new file mode 100644 index 0000000..16a9e50 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/registry.rs @@ -0,0 +1,149 @@ +// Created: 2026-08-29 by Constructor Tech +//! Runtime plugin registry (`with_builtins`). +//! +//! The registry owns the singleton built-in plugin instances. It is immutable +//! after construction: the only mutable state is the OAuth2 token cache held +//! by the OAuth2 plugins themselves. + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use credstore_sdk::CredStoreClientV1; + +use crate::domain::plugin::{AuthPlugin, GuardPlugin, PluginBinding, PluginError, TransformPlugin}; +use crate::infra::plugin::auth::{ApiKeyAuth, NoopAuth, OAuth2ClientCredentials}; +use crate::infra::plugin::guard::RequiredHeadersGuard; +use crate::infra::plugin::transform::RequestIdTransform; + +/// Runtime plugin registry. +pub struct PluginRegistry { + auth: HashMap>, + guards: HashMap>, + transforms: HashMap>, +} + +impl std::fmt::Debug for PluginRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PluginRegistry") + .field("auth", &self.auth.keys().collect::>()) + .field("guards", &self.guards.keys().collect::>()) + .field("transforms", &self.transforms.keys().collect::>()) + .finish() + } +} + +impl Default for PluginRegistry { + fn default() -> Self { + Self::empty() + } +} + +impl PluginRegistry { + /// Registry without any built-in. + #[must_use] + pub fn empty() -> Self { + Self { + auth: HashMap::new(), + guards: HashMap::new(), + transforms: HashMap::new(), + } + } + + /// Registry holding every built-in plugin. + /// + /// `credstore` is shared by the credential-resolving auth plugins; a + /// missing store makes those plugins fail closed with `401` at proxy time. + #[must_use] + pub fn with_builtins( + credstore: Option>, + token_cache_ttl: Duration, + token_cache_capacity: usize, + ) -> Self { + let mut registry = Self::empty(); + let noop: Arc = Arc::new(NoopAuth); + let apikey: Arc = Arc::new(ApiKeyAuth::new(credstore.clone())); + let oauth_form: Arc = Arc::new(OAuth2ClientCredentials::new( + credstore.clone(), + token_cache_capacity, + token_cache_ttl, + false, + )); + let oauth_basic: Arc = Arc::new(OAuth2ClientCredentials::new( + credstore, + token_cache_capacity, + token_cache_ttl, + true, + )); + registry.auth.insert(noop.id().to_owned(), noop); + registry.auth.insert(apikey.id().to_owned(), apikey); + registry.auth.insert(oauth_form.id().to_owned(), oauth_form); + registry + .auth + .insert(oauth_basic.id().to_owned(), oauth_basic); + + let required: Arc = Arc::new(RequiredHeadersGuard); + registry.guards.insert(required.id().to_owned(), required); + + let request_id: Arc = Arc::new(RequestIdTransform); + registry + .transforms + .insert(request_id.id().to_owned(), request_id); + registry + } + + /// Register an additional auth plugin (used by tests and future extensions). + pub fn register_auth(&mut self, plugin: Arc) { + self.auth.insert(plugin.id().to_owned(), plugin); + } + + /// Register an additional guard plugin. + pub fn register_guard(&mut self, plugin: Arc) { + self.guards.insert(plugin.id().to_owned(), plugin); + } + + /// Register an additional transform plugin. + pub fn register_transform(&mut self, plugin: Arc) { + self.transforms.insert(plugin.id().to_owned(), plugin); + } + + /// Resolve an auth plugin implementation, or `None` when the reference has + /// no implementation (catalog-only ids). + #[must_use] + pub fn auth_plugin(&self, binding: &PluginBinding) -> Option> { + self.auth + .get(crate::domain::model::plugin_instance(&binding.plugin_ref)) + .cloned() + } + + /// Resolve a guard plugin implementation. + #[must_use] + pub fn guard_plugin(&self, binding: &PluginBinding) -> Option> { + self.guards + .get(crate::domain::model::plugin_instance(&binding.plugin_ref)) + .cloned() + } + + /// Resolve a transform plugin implementation. + #[must_use] + pub fn transform_plugin(&self, binding: &PluginBinding) -> Option> { + self.transforms + .get(crate::domain::model::plugin_instance(&binding.plugin_ref)) + .cloned() + } + + /// `true` when no implementation exists for `reference`. + #[must_use] + pub fn missing(&self, reference: &str) -> bool { + let instance = crate::domain::model::plugin_instance(reference); + !self.auth.contains_key(instance) + && !self.guards.contains_key(instance) + && !self.transforms.contains_key(instance) + } +} + +/// Error surfaced when a bound plugin has no implementation. +#[must_use] +pub fn plugin_not_found(reference: &str) -> PluginError { + PluginError::Internal(format!("no implementation for plugin '{reference}'")) +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/transform.rs b/gears/system/oagw/oagw/src/infra/plugin/transform.rs new file mode 100644 index 0000000..a6dfbae --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/transform.rs @@ -0,0 +1,62 @@ +// Created: 2026-08-29 by Constructor Tech +//! `cf.core.oagw.request_id.v1` — request-id propagation transform. + +use async_trait::async_trait; + +use crate::domain::plugin::{ + ErrorContext, PluginError, RequestContext, ResponseContext, TRANSFORM_REQUEST_ID, + TransformPlugin, +}; + +/// Sets `x-request-id` on the outbound request when absent and echoes it on the +/// response. +pub struct RequestIdTransform; + +impl RequestIdTransform { + /// Header the plugin propagates. + pub(crate) const HEADER: &'static str = "x-request-id"; +} + +#[async_trait] +impl TransformPlugin for RequestIdTransform { + fn id(&self) -> &str { + TRANSFORM_REQUEST_ID + } + + fn plugin_type(&self) -> &str { + "transform_plugin" + } + + async fn transform_request(&self, ctx: &mut RequestContext) -> Result<(), PluginError> { + if !ctx + .headers + .contains_key(axum::http::HeaderName::from_static(Self::HEADER)) + { + let value = ctx.security.subject_id().as_simple().to_string(); + if let Ok(header) = axum::http::HeaderValue::from_str(&value) { + ctx.headers + .insert(axum::http::HeaderName::from_static(Self::HEADER), header); + } + } + Ok(()) + } + + async fn transform_response(&self, ctx: &mut ResponseContext) -> Result<(), PluginError> { + // Echo the propagated id so callers can correlate request and response. + if let Some(inbound) = ctx.request_headers.get(Self::HEADER) { + ctx.headers.insert( + axum::http::HeaderName::from_static(Self::HEADER), + inbound.clone(), + ); + } + Ok(()) + } + + async fn transform_error(&self, ctx: &mut ErrorContext) -> Result<(), PluginError> { + ctx.headers.insert( + axum::http::HeaderName::from_static(Self::HEADER), + axum::http::HeaderValue::from_static("0"), + ); + Ok(()) + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/circuit_breaker.rs b/gears/system/oagw/oagw/src/infra/proxy/circuit_breaker.rs new file mode 100644 index 0000000..258fd82 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/circuit_breaker.rs @@ -0,0 +1,210 @@ +// Created: 2026-08-29 by Constructor Tech +//! Per-`(upstream_id, endpoint)` sliding-failure circuit breaker (DESIGN §14). +//! +//! Core data-plane logic, not a plugin: after `failure_threshold` consecutive +//! failures the breaker opens for `cool_down`; requests while open fail fast +//! with `503 CircuitBreakerOpen`; the first request after the cool-down is let +//! through (half-open) and a success closes the breaker. + +use std::collections::HashMap; +use std::time::{Duration, Instant}; + +use parking_lot::Mutex; +use uuid::Uuid; + +use crate::domain::error::OagwError; + +/// Breaker state for one `(upstream_id, endpoint)` key. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +struct BreakerState { + consecutive_failures: u32, + /// Set when the breaker tripped. + opened_at: Option, + /// Set when a half-open probe is in flight. + probing: bool, +} + +impl BreakerState { + fn open(&mut self) { + self.opened_at = Some(Instant::now()); + } + + fn close(&mut self) { + self.consecutive_failures = 0; + self.opened_at = None; + self.probing = false; + } + + /// `true` when the breaker blocks traffic. + fn is_open(&self, cooldown: Duration) -> bool { + self.opened_at.is_some_and(|at| at.elapsed() < cooldown) + } + + /// `true` when the cool-down expired and a probe is allowed. + fn half_open_due(&self, cooldown: Duration) -> bool { + self.opened_at.is_some_and(|at| at.elapsed() >= cooldown) + } +} + +/// Registry of breakers, keyed by `(upstream_id, endpoint)` and by upstream id. +pub struct CircuitBreakerRegistry { + by_endpoint: Mutex>, + failure_threshold: u32, + cooldown: Duration, +} + +impl std::fmt::Debug for CircuitBreakerRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CircuitBreakerRegistry") + .field("failure_threshold", &self.failure_threshold) + .field("cooldown", &self.cooldown) + .finish_non_exhaustive() + } +} + +/// Circuit-breaker outcome of the pre-flight check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BreakerCheck { + /// Traffic is allowed. + Allowed, + /// Traffic is rejected: the breaker is open. + Open, +} + +impl Default for CircuitBreakerRegistry { + fn default() -> Self { + Self::new(5, Duration::from_secs(30)) + } +} + +impl CircuitBreakerRegistry { + /// Registry with the given failure threshold and cool-down. + #[must_use] + pub fn new(failure_threshold: u32, cooldown: Duration) -> Self { + Self { + by_endpoint: Mutex::new(HashMap::new()), + failure_threshold: failure_threshold.max(1), + cooldown, + } + } + + /// Pre-flight check. + #[must_use] + pub fn check(&self, upstream_id: Uuid, endpoint: &str) -> BreakerCheck { + let mut by_endpoint = self.by_endpoint.lock(); + let state = by_endpoint + .entry((upstream_id, endpoint.to_owned())) + .or_default(); + if state.is_open(self.cooldown) { + return BreakerCheck::Open; + } + if state.half_open_due(self.cooldown) { + if state.probing { + return BreakerCheck::Open; + } + state.probing = true; + } + BreakerCheck::Allowed + } + + /// Record a successful response: closes the breaker and resets the counter. + /// + /// Returns the state the breaker moved to, or `None` when it was already + /// closed — the data plane reports transitions (DESIGN §4.2). + pub fn record_success( + &self, + upstream_id: Uuid, + endpoint: &str, + ) -> Option { + let mut by_endpoint = self.by_endpoint.lock(); + let state = by_endpoint + .entry((upstream_id, endpoint.to_owned())) + .or_default(); + state.opened_at?; + state.close(); + crate::infra::audit::breaker_transition(upstream_id, endpoint, "closed"); + Some(crate::domain::ports::metrics::BreakerState::Closed) + } + + /// Record a failure: increments the counter and opens past the threshold. + /// + /// Returns the state the breaker moved to, or `None` when nothing changed. + pub fn record_failure( + &self, + upstream_id: Uuid, + endpoint: &str, + ) -> Option { + let threshold = self.failure_threshold; + let mut by_endpoint = self.by_endpoint.lock(); + let state = by_endpoint + .entry((upstream_id, endpoint.to_owned())) + .or_default(); + // Only the transition out of a *blocking* state is an event: a failure + // recorded while the breaker is already open just extends the trip. + let was_open = state.is_open(self.cooldown); + state.consecutive_failures = state.consecutive_failures.saturating_add(1); + state.probing = false; + if state.consecutive_failures >= threshold { + state.open(); + if !was_open { + crate::infra::audit::breaker_transition(upstream_id, endpoint, "opened"); + return Some(crate::domain::ports::metrics::BreakerState::Open); + } + } + None + } + + /// Consecutive failures recorded for an endpoint (test helper). + #[must_use] + pub fn failures(&self, upstream_id: Uuid, endpoint: &str) -> u32 { + self.by_endpoint + .lock() + .get(&(upstream_id, endpoint.to_owned())) + .map_or(0, |state| state.consecutive_failures) + } +} + +/// Map an open breaker to the wire error. +#[must_use] +pub fn breaker_open() -> OagwError { + OagwError::CircuitBreakerOpen +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn opens_after_threshold_and_resets_on_success() { + let registry = CircuitBreakerRegistry::new(3, Duration::from_secs(30)); + let id = uuid::Uuid::new_v4(); + for _ in 0..3 { + registry.record_failure(id, "a.example.com"); + } + assert_eq!(registry.check(id, "a.example.com"), BreakerCheck::Open); + registry.record_success(id, "a.example.com"); + assert_eq!(registry.check(id, "a.example.com"), BreakerCheck::Allowed); + assert_eq!(registry.failures(id, "a.example.com"), 0); + } + + #[test] + fn keys_are_per_endpoint() { + let registry = CircuitBreakerRegistry::new(2, Duration::from_secs(30)); + let id = uuid::Uuid::new_v4(); + registry.record_failure(id, "a.example.com"); + registry.record_failure(id, "b.example.com"); + assert_eq!(registry.check(id, "a.example.com"), BreakerCheck::Allowed); + } + + #[test] + fn half_open_after_cooldown() { + let registry = CircuitBreakerRegistry::new(1, Duration::from_millis(1)); + let id = uuid::Uuid::new_v4(); + registry.record_failure(id, "a.example.com"); + assert_eq!(registry.check(id, "a.example.com"), BreakerCheck::Open); + std::thread::sleep(Duration::from_millis(10)); + assert_eq!(registry.check(id, "a.example.com"), BreakerCheck::Allowed); + registry.record_failure(id, "a.example.com"); + assert_eq!(registry.check(id, "a.example.com"), BreakerCheck::Open); + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/headers.rs b/gears/system/oagw/oagw/src/infra/proxy/headers.rs new file mode 100644 index 0000000..a0e5876 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/headers.rs @@ -0,0 +1,250 @@ +// Created: 2026-08-29 by Constructor Tech +//! Hop-by-hop stripping and request / response header transformation rules. +//! +//! Security: header *values* are never formatted into a log line here; only +//! the rules themselves are applied. + +use axum::http::{HeaderMap, HeaderName, HeaderValue}; + +use crate::domain::model::{RequestHeaders, ResponseHeaders}; + +/// Headers never forwarded in either direction. +pub const HOP_BY_HOP: [&str; 8] = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// Header read and then stripped before forwarding (`X-OAGW-Target-Host`). +pub const TARGET_HOST_HEADER: &str = "x-oagw-target-host"; + +/// Header always present on gateway and upstream responses. +pub const ERROR_SOURCE_HEADER: &str = "x-oagw-error-source"; + +/// Build a header value, or `None` when the text is not valid. +#[must_use] +pub fn header_value(text: &str) -> Option { + HeaderValue::from_str(text).ok() +} + +/// Build a header name, or `None` when the text is not valid. +#[must_use] +pub fn header_name(text: &str) -> Option { + HeaderName::from_bytes(text.as_bytes()).ok() +} + +/// Strip hop-by-hop headers plus every header named in `Connection`. +pub fn strip_hop_by_hop(headers: &mut HeaderMap) { + let named: Vec = headers + .get_all(axum::http::header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(',')) + .map(str::trim) + .filter(|name| !name.is_empty() && !name.eq_ignore_ascii_case("close")) + .filter_map(header_name) + .collect(); + for name in named { + headers.remove(&name); + } + for name in HOP_BY_HOP { + headers.remove(name); + } +} + +/// Copy every inbound header named in `name` into `headers`. +fn copy_inbound(inbound: &HeaderMap, headers: &mut HeaderMap, name: &str) { + let Some(parsed) = header_name(name) else { + return; + }; + for value in inbound.get_all(&parsed) { + headers.append(parsed.clone(), value.clone()); + } +} + +/// Replace `headers` with the forwardable subset of `inbound` plus the rules. +pub fn build_request_headers( + inbound: &HeaderMap, + rules: &RequestHeaders, + skip: &[&str], +) -> HeaderMap { + let mut out = HeaderMap::new(); + match rules.passthrough { + crate::domain::model::Passthrough::None => {} + crate::domain::model::Passthrough::Allowlist => { + for name in &rules.passthrough_allowlist { + copy_inbound(inbound, &mut out, name); + } + } + crate::domain::model::Passthrough::All => { + for (name, value) in inbound { + out.insert(name.clone(), value.clone()); + } + } + } + for (name, value) in &rules.set { + if let (Some(parsed), Some(parsed_value)) = (header_name(name), header_value(value)) { + out.insert(parsed, parsed_value); + } + } + for (name, value) in &rules.add { + if let (Some(parsed), Some(parsed_value)) = (header_name(name), header_value(value)) { + out.append(parsed, parsed_value); + } + } + for name in &rules.remove { + if let Some(parsed) = header_name(name) { + out.remove(&parsed); + } + } + for name in skip { + out.remove(*name); + } + out +} + +/// Apply the response-leg header rules to the upstream response headers. +pub fn apply_response_rules(headers: &mut HeaderMap, rules: &ResponseHeaders) { + for (name, value) in &rules.set { + if let (Some(parsed), Some(parsed_value)) = (header_name(name), header_value(value)) { + headers.insert(parsed, parsed_value); + } + } + for (name, value) in &rules.add { + if let (Some(parsed), Some(parsed_value)) = (header_name(name), header_value(value)) { + headers.append(parsed, parsed_value); + } + } + for name in &rules.remove { + if let Some(parsed) = header_name(name) { + headers.remove(&parsed); + } + } +} + +/// Bare host part of an `X-OAGW-Target-Host` value (scheme, port, path stripped). +#[must_use] +pub fn bare_host(value: &str) -> String { + let stripped = value.trim(); + let without_scheme = stripped + .split_once("://") + .map_or(stripped, |(_scheme, rest)| rest); + let without_path = without_scheme + .split(['/', '?', '#']) + .next() + .unwrap_or(without_scheme); + let without_port = without_path + .rsplit_once(':') + // `a.b.c:8443` and `[::1]:8443` carry a port; an IPv6 literal such as + // `::1` or `fe80::1` does not, and splitting one would destroy it. + .filter(|(host, port)| { + !host.is_empty() + && !port.is_empty() + && port.bytes().all(|b| b.is_ascii_digit()) + && (host.contains('[') || !host.contains(':')) + }) + .map_or(without_path, |(host, _port)| host); + without_port + .trim() + .trim_matches(['[', ']']) + .trim_end_matches('.') + .to_ascii_lowercase() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn map(items: &[(&str, &str)]) -> HeaderMap { + let mut out = HeaderMap::new(); + for (name, value) in items { + out.insert( + header_name(name).expect("name"), + header_value(value).expect("value"), + ); + } + out + } + + #[test] + fn strips_hop_by_hop_and_connection_named() { + let mut headers = map(&[ + ("connection", "x-secret, close"), + ("x-secret", "value"), + ("keep-alive", "timeout=5"), + ("accept", "*/*"), + ]); + strip_hop_by_hop(&mut headers); + assert!(headers.get("connection").is_none()); + assert!(headers.get("x-secret").is_none()); + assert!(headers.get("keep-alive").is_none()); + assert_eq!( + headers.get("accept").and_then(|v| v.to_str().ok()), + Some("*/*") + ); + } + + #[test] + fn passthrough_none_forwards_nothing() { + let inbound = map(&[("x-a", "1"), ("content-type", "application/json")]); + let rules = RequestHeaders::default(); + let out = build_request_headers(&inbound, &rules, &[]); + assert!(out.is_empty()); + } + + #[test] + fn allowlist_forwards_only_listed() { + let inbound = map(&[("x-a", "1"), ("x-b", "2")]); + let rules = RequestHeaders { + passthrough: crate::domain::model::Passthrough::Allowlist, + passthrough_allowlist: vec!["x-a".to_owned()], + ..RequestHeaders::default() + }; + let out = build_request_headers(&inbound, &rules, &[]); + assert_eq!(out.get("x-a").and_then(|v| v.to_str().ok()), Some("1")); + assert!(out.get("x-b").is_none()); + } + + #[test] + fn set_add_remove_rules_apply() { + let rules = RequestHeaders { + set: std::iter::once(("x-set".to_owned(), "1".to_owned())).collect(), + add: std::iter::once(("x-add".to_owned(), "2".to_owned())).collect(), + remove: vec!["x-drop".to_owned()], + passthrough: crate::domain::model::Passthrough::All, + passthrough_allowlist: Vec::new(), + }; + let inbound = map(&[("x-drop", "old")]); + let mut out = build_request_headers(&inbound, &rules, &[]); + assert_eq!(out.get("x-set").and_then(|v| v.to_str().ok()), Some("1")); + assert_eq!(out.get("x-add").and_then(|v| v.to_str().ok()), Some("2")); + assert!(out.get("x-drop").is_none()); + apply_response_rules( + &mut out, + &ResponseHeaders { + remove: vec!["x-set".to_owned()], + ..ResponseHeaders::default() + }, + ); + assert!(out.get("x-set").is_none()); + } + + #[test] + fn bare_host_strips_scheme_port_and_path() { + assert_eq!(bare_host("us.vendor.com"), "us.vendor.com"); + assert_eq!(bare_host("https://us.vendor.com:8443/a/b"), "us.vendor.com"); + assert_eq!(bare_host(" US.Vendor.com. "), "us.vendor.com"); + assert_eq!(bare_host("[::1]:8080"), "::1"); + } + + #[test] + fn target_host_header_constant_is_lowercase() { + assert_eq!(TARGET_HOST_HEADER, "x-oagw-target-host"); + assert_eq!(ERROR_SOURCE_HEADER, "x-oagw-error-source"); + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/mod.rs b/gears/system/oagw/oagw/src/infra/proxy/mod.rs new file mode 100644 index 0000000..b547af4 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/mod.rs @@ -0,0 +1,12 @@ +// Created: 2026-08-29 by Constructor Tech +//! Data-plane proxy engine. + +pub mod circuit_breaker; +pub mod headers; +pub mod rate_limiter; +pub mod service; + +pub use circuit_breaker::{BreakerCheck, CircuitBreakerRegistry}; +pub use headers::{ERROR_SOURCE_HEADER, TARGET_HOST_HEADER}; +pub use rate_limiter::{RateDecision, RateKey, RateLimiterRegistry}; +pub use service::{DataPlaneService, ProxyBody, ProxyFailure, ProxyOutcome, ProxyRequest}; diff --git a/gears/system/oagw/oagw/src/infra/proxy/rate_limiter.rs b/gears/system/oagw/oagw/src/infra/proxy/rate_limiter.rs new file mode 100644 index 0000000..a66bec8 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/rate_limiter.rs @@ -0,0 +1,285 @@ +// Created: 2026-08-29 by Constructor Tech +//! Rate-limit registry over [`TokenBucket`]. +//! +//! Bucket keys follow the contract: `(scope, scope_key, upstream_or_route_id)`. +//! `token_bucket` uses the refill maths from `domain/rate_limit`; the +//! `sliding_window` algorithm is approximated with a fixed-window counter +//! (recorded as an MVP deviation — no ring-buffer history is kept). + +use std::collections::HashMap; +use std::time::{SystemTime, UNIX_EPOCH}; + +use parking_lot::Mutex; + +use crate::domain::model::{RateAlgorithm, RateLimitConfig}; +use crate::domain::rate_limit::TokenBucket; + +/// A counter key. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct RateKey { + /// Scope name (`global`, `tenant`, `user`, `ip`, `route`). + pub scope: String, + /// Scope discriminator (tenant id, subject id, client ip, route id). + pub scope_key: String, + /// Owning route or upstream id. + pub owner: String, +} + +impl std::fmt::Display for RateKey { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}|{}|{}", self.scope, self.scope_key, self.owner) + } +} + +/// Outcome of a rate-limit check. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RateDecision { + /// `false` when the request must be rejected with `429`. + pub allowed: bool, + /// Configured capacity (for `X-RateLimit-Limit`). + pub limit: u64, + /// Tokens left in the bucket. + pub remaining: u64, + /// Unix epoch seconds at which the bucket is full again. + pub reset_epoch: u64, + /// Seconds until `cost` tokens are available again. + pub retry_after: u64, +} + +/// Bucket store keyed by [`RateKey`]. +#[derive(Debug, Default)] +pub struct RateLimiterRegistry { + buckets: Mutex>, +} + +#[derive(Debug)] +enum RateBucket { + Token(TokenBucket), + Window { + window_started: std::time::Instant, + window_seconds: u64, + count: u64, + capacity: u64, + }, +} + +impl Default for RateBucket { + fn default() -> Self { + Self::Token(TokenBucket::new(1.0, 1.0)) + } +} + +impl RateLimiterRegistry { + /// Empty registry. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Apply the effective rate-limit configuration for one key. + /// + /// Returns `None` when no rate limit is configured (the request proceeds + /// with no `X-RateLimit-*` headers). + #[must_use] + pub fn check(&self, key: &RateKey, config: &RateLimitConfig) -> Option { + let capacity = config.capacity().max(1) as u64; + // Merging may pair a large per-request cost with an ancestor's smaller + // burst; clamping keeps every request affordable for the bucket. + let cost = u64::from(config.cost()).min(capacity); + let window = config.sustained.window.seconds(); + let rate = u64::from(config.sustained.rate); + + let mut buckets = self.buckets.lock(); + let bucket = buckets + .entry(key.to_string()) + .or_insert_with(|| match config.algorithm { + RateAlgorithm::TokenBucket => RateBucket::Token(TokenBucket::new( + capacity as f64, + crate::domain::rate_limit::refill_rate(config.sustained.rate, window), + )), + RateAlgorithm::SlidingWindow => RateBucket::Window { + window_started: std::time::Instant::now(), + window_seconds: window, + count: 0, + capacity: rate.max(1), + }, + }); + + Some(match bucket { + RateBucket::Token(token_bucket) => { + let allowed = + token_bucket.try_acquire(f64::from(u32::try_from(cost).unwrap_or(u32::MAX))); + RateDecision { + allowed, + limit: capacity, + remaining: token_bucket.current_tokens().max(0.0) as u64, + reset_epoch: token_bucket.epoch_seconds_until_full(), + retry_after: token_bucket + .seconds_until_tokens(f64::from(u32::try_from(cost).unwrap_or(u32::MAX))), + } + } + RateBucket::Window { + window_started, + window_seconds, + count, + capacity: limit, + } => { + let window_secs = (*window_seconds).max(1); + if window_started.elapsed().as_secs() >= window_secs { + *window_started = std::time::Instant::now(); + *count = 0; + } + let allowed = *count < *limit; + if allowed { + *count = count.saturating_add(cost.max(1)); + } + let elapsed = window_started.elapsed().as_secs(); + RateDecision { + allowed, + limit: capacity, + remaining: (*limit).saturating_sub(*count), + reset_epoch: epoch_now() + window_secs.saturating_sub(elapsed), + retry_after: if allowed { + 0 + } else { + window_secs.saturating_sub(elapsed).max(1) + }, + } + } + }) + } + + /// Drop every bucket (test helper). + pub fn reset(&self) { + self.buckets.lock().clear(); + } +} + +/// Current unix epoch in seconds. +fn epoch_now() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_or(0, |value| value.as_secs()) +} + +/// Per-subject and per-IP scopes need an identity to key on; without one the +/// request falls back to its tenant so it cannot share a bucket with the world. +#[must_use] +pub fn keyed_scope(value: &str, tenant_id: &str) -> String { + if value.is_empty() { + format!("tenant:{tenant_id}") + } else { + value.to_owned() + } +} + +/// Build the registry key for a resolved route / upstream pair. +#[must_use] +pub fn rate_key(config: &RateLimitConfig, scope_key: &str, owner: &str) -> RateKey { + RateKey { + scope: match config.scope { + crate::domain::model::RateScope::Global => "global".to_owned(), + crate::domain::model::RateScope::Tenant => "tenant".to_owned(), + crate::domain::model::RateScope::User => "user".to_owned(), + crate::domain::model::RateScope::Ip => "ip".to_owned(), + crate::domain::model::RateScope::Route => "route".to_owned(), + }, + scope_key: match config.scope { + crate::domain::model::RateScope::Global => "global".to_owned(), + _ => scope_key.to_owned(), + }, + owner: match config.scope { + crate::domain::model::RateScope::Global => "global".to_owned(), + _ => owner.to_owned(), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::model::{Burst, Sustained}; + + fn config() -> RateLimitConfig { + RateLimitConfig { + sharing: crate::domain::model::Sharing::Private, + algorithm: RateAlgorithm::TokenBucket, + sustained: Sustained { + rate: 2, + window: crate::domain::model::RateWindow::Second, + }, + burst: Some(Burst { capacity: 2 }), + budget: None, + scope: crate::domain::model::RateScope::Tenant, + strategy: crate::domain::model::RateStrategy::Reject, + cost: None, + response_headers: None, + } + } + + #[test] + fn key_includes_scope_and_owner() { + let cfg = config(); + let key = rate_key(&cfg, "tenant-1", "route-1"); + assert_eq!(key.scope, "tenant"); + assert_eq!(key.scope_key, "tenant-1"); + assert_eq!(key.owner, "route-1"); + } + + #[test] + fn exhausts_then_refills() { + let registry = RateLimiterRegistry::new(); + let cfg = config(); + let key = rate_key(&cfg, "tenant-1", "route-1"); + let first = registry.check(&key, &cfg).expect("decision"); + assert!(first.allowed); + let second = registry.check(&key, &cfg).expect("decision"); + assert!(second.allowed); + let third = registry.check(&key, &cfg).expect("decision"); + assert!(!third.allowed); + assert!(third.retry_after > 0 || third.reset_epoch >= epoch_now()); + } + + #[test] + fn sliding_window_counts_requests() { + let registry = RateLimiterRegistry::new(); + let mut cfg = config(); + cfg.algorithm = RateAlgorithm::SlidingWindow; + cfg.sustained = Sustained { + rate: 1, + window: crate::domain::model::RateWindow::Minute, + }; + let key = rate_key(&cfg, "t", "r"); + assert!(registry.check(&key, &cfg).expect("d").allowed); + assert!(!registry.check(&key, &cfg).expect("d").allowed); + } + + #[test] + fn cost_is_clamped_to_the_bucket_capacity() { + let registry = RateLimiterRegistry::new(); + let mut cfg = config(); + cfg.cost = Some(10); // burst capacity is 2 + let key = rate_key(&cfg, "tenant-1", "route-1"); + let first = registry.check(&key, &cfg).expect("decision"); + assert!( + first.allowed, + "a clamped cost of 2 still fits a capacity of 2" + ); + assert!(!registry.check(&key, &cfg).expect("decision").allowed); + } + + #[test] + fn missing_identity_falls_back_to_the_tenant() { + assert_eq!(keyed_scope("", "tenant-7"), "tenant:tenant-7"); + assert_eq!(keyed_scope("203.0.113.9", "tenant-7"), "203.0.113.9"); + } + + #[test] + fn global_scope_is_one_bucket() { + let mut cfg = config(); + cfg.scope = crate::domain::model::RateScope::Global; + let key = rate_key(&cfg, "tenant-1", "route-1"); + assert_eq!(key.scope_key, "global"); + assert_eq!(key.owner, "global"); + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/service.rs b/gears/system/oagw/oagw/src/infra/proxy/service.rs new file mode 100644 index 0000000..2f84bc3 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/service.rs @@ -0,0 +1,2079 @@ +// Created: 2026-08-29 by Constructor Tech +//! Data-plane proxy engine. +//! +//! The pipeline order is normative (contract §5): alias resolution → route +//! match → CORS preflight → hierarchical merge → auth → rate limit → guard → +//! transform → circuit breaker → forward → response plugins → response header +//! rules. +//! +//! Security: no request or response body, query string or header value is ever +//! logged; credentials are injected into the outbound request only and are +//! never formatted into an error message. + +use std::str::FromStr; +use std::sync::Arc; +use std::sync::atomic::{AtomicU64, Ordering}; + +use bytes::Bytes; +use futures_util::Stream; +use hyper::body::{Body as HttpBody, Frame}; +use toolkit_http::{HttpClient, HttpClientBuilder, HttpClientConfig, HttpResponse}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::config::OagwConfig; +use crate::domain::error::OagwError; +use crate::domain::merge::EffectiveConfig; +use crate::domain::model::{ + CorsConfig, Endpoint, HeadersConfig, MAX_BODY_BYTES, PathSuffixMode, RateLimitConfig, Route, + Upstream, +}; +use crate::domain::plugin::{PluginBinding, PluginError, RequestContext, ResponseContext}; +use crate::domain::services::management::ControlPlaneService; +use crate::infra::plugin::PluginRegistry; +use crate::infra::proxy::circuit_breaker::{BreakerCheck, CircuitBreakerRegistry, breaker_open}; +use crate::infra::proxy::headers::{ + ERROR_SOURCE_HEADER, HOP_BY_HOP, TARGET_HOST_HEADER, apply_response_rules, bare_host, + build_request_headers, strip_hop_by_hop, +}; +use crate::infra::proxy::rate_limiter::{RateDecision, RateLimiterRegistry, keyed_scope, rate_key}; + +type DataPlaneResult = Result; + +/// `text/event-stream` media type. +const SSE_MEDIA_TYPE: &str = "text/event-stream"; + +/// Detail of a `404 RouteNotFound` (no interpolated request values). +const NO_ROUTE: &str = "no enabled route matches this request"; + +/// `authorization`, forwarded only when the header rules allow it. +const AUTHORIZATION: &str = "authorization"; + +/// A proxied request in transport-neutral form. +#[derive(Debug, Clone)] +pub struct ProxyRequest { + /// Request method. + pub method: String, + /// Routing alias taken from the URL. + pub alias: String, + /// Raw remainder of the path after the alias (no leading slash). + pub path_suffix: String, + /// Raw query string, when present. + pub query: Option, + /// Inbound request headers. + pub headers: axum::http::HeaderMap, + /// Buffered request body. + pub body: Bytes, + /// Caller tenant. + pub tenant_id: Uuid, + /// Authenticated subject. + pub subject_id: Uuid, + /// Client ip (empty when the host does not expose peer addresses). + pub client_ip: String, + /// Request URI used as the RFC 9457 `instance`. + pub instance: String, + /// Correlation id. + pub trace_id: String, + /// Caller security context (credential resolution only). + pub security: SecurityContext, + /// Normalized path pattern of the matched route, set during resolution. + /// + /// Observability only: `http.route` carries the pattern rather than the + /// raw request path so metric label cardinality stays bounded (DESIGN + /// §4.2). `None` until a route matches. + pub route_pattern: Option, +} + +impl ProxyRequest { + /// `X-OAGW-Target-Host` when the caller selected a specific endpoint. + /// + /// The value is reported as the caller sent it: `select_endpoint` validates + /// it against ADR-0007 (a bare hostname or IP, no port, path or scheme) and + /// has to see a port-bearing value to report `invalid_target_host.v1` + /// instead of silently routing to the host the port was stripped from. + #[must_use] + pub fn target_host(&self) -> Option { + self.headers + .get(TARGET_HOST_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|host| !host.is_empty()) + .map(ToOwned::to_owned) + } +} + +/// A gateway failure with optional upstream routing context. +#[derive(Debug, Clone)] +pub struct ProxyFailure { + /// Domain error. + pub kind: OagwError, + /// Upstream the request would have been routed to. + pub upstream_id: Option, + /// Upstream host the request targeted. + pub host: Option, + /// Rate-limit budget the rejected request would have spent (ADR-0003). + pub rate: Option>, +} + +/// The `X-RateLimit-*` counters reported on a `429` (ADR-0003). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RateReport { + /// Configured limit. + pub limit: u64, + /// Tokens left in the bucket. + pub remaining: u64, + /// Unix epoch when the bucket is refilled. + pub reset_epoch: u64, +} + +impl ProxyFailure { + /// Wrap a domain error with no routing context. + #[must_use] + pub fn new(kind: OagwError) -> Self { + Self { + kind, + upstream_id: None, + host: None, + rate: None, + } + } + + /// Attach the upstream routing context. + #[must_use] + pub fn with_upstream(mut self, upstream_id: Uuid) -> Self { + self.upstream_id = Some(upstream_id); + self + } + + /// Attach the rate-limit counters a rejected request reports. + #[must_use] + pub fn with_rate(mut self, decision: &RateDecision) -> Self { + self.rate = Some(Box::new(RateReport { + limit: decision.limit, + remaining: decision.remaining, + reset_epoch: decision.reset_epoch, + })); + self + } + + /// Attach the upstream host. + #[must_use] + pub fn with_host(mut self, host: impl Into) -> Self { + self.host = Some(host.into()); + self + } +} + +impl From for ProxyFailure { + fn from(kind: OagwError) -> Self { + Self::new(kind) + } +} + +/// Error-carrying byte stream handed to the transport layer. +type PinnedErrorStream = std::pin::Pin> + Send>>; + +/// Body of a proxied response. +pub enum ProxyBody { + /// Fully buffered body. + Full(Bytes), + /// Streaming body; stream errors carry the abort reason. + Stream(PinnedErrorStream), +} + +impl std::fmt::Debug for ProxyBody { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Full(bytes) => f.debug_tuple("Full").field(&bytes.len()).finish(), + Self::Stream(_) => f.write_str("Stream(..)"), + } + } +} + +/// A proxied response ready for the wire. +#[derive(Debug)] +pub struct ProxyOutcome { + /// Upstream status code. + pub status: axum::http::StatusCode, + /// Response headers after transformation. + pub headers: axum::http::HeaderMap, + /// Response body. + pub body: ProxyBody, + /// Upstream the request was routed to. + pub upstream_id: Uuid, + /// Upstream host that served the request. + pub host: String, +} + +/// Target resolved for the WebSocket leg. +#[derive(Debug)] +pub struct WebSocketLeg { + /// `wss://` / `ws://` upstream URL. + pub url: String, + /// Headers to send on the upstream handshake. + pub headers: Vec<(String, String)>, + /// Upstream id. + pub upstream_id: Uuid, + /// Upstream host. + pub host: String, +} + +/// Everything the data plane needs, wired at gear init. +pub struct DataPlaneService { + control_plane: Arc, + plugins: Arc, + tenant_resolver: Option>, + rate_limiters: RateLimiterRegistry, + breakers: CircuitBreakerRegistry, + http: HttpClient, + config: OagwConfig, + round_robin: AtomicU64, + metrics: Arc, +} + +impl std::fmt::Debug for DataPlaneService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DataPlaneService") + .field("config", &self.config) + .finish_non_exhaustive() + } +} + +/// A fully resolved proxy target. +struct Resolved { + upstream: Upstream, + effective: EffectiveConfig, + route: Route, + remainder: String, +} + +impl DataPlaneService { + /// Build the service. + /// + /// # Errors + /// + /// Returns [`OagwError::Internal`] when the outbound HTTP client cannot be + /// initialised (TLS backend failure). + pub fn new( + control_plane: Arc, + plugins: Arc, + tenant_resolver: Option>, + config: OagwConfig, + ) -> Result { + Self::with_metrics( + control_plane, + plugins, + tenant_resolver, + config, + Arc::new(crate::infra::metrics::OagwMetricsMeter::from_global()), + ) + } + + /// Build the service with an explicit observability adapter. + /// + /// # Errors + /// + /// Returns [`OagwError::Internal`] when the outbound HTTP client cannot be + /// initialised (TLS backend failure). + pub fn with_metrics( + control_plane: Arc, + plugins: Arc, + tenant_resolver: Option>, + config: OagwConfig, + metrics: Arc, + ) -> Result { + let mut http_config = HttpClientConfig::proxy(); + http_config.request_timeout = config.proxy_timeout(); + let http = HttpClientBuilder::with_config(http_config) + .build() + .map_err(|error| OagwError::Internal(format!("outbound http client: {error}")))?; + let breakers = CircuitBreakerRegistry::default(); + Ok(Self { + control_plane, + plugins, + tenant_resolver, + rate_limiters: RateLimiterRegistry::new(), + breakers, + http, + config, + round_robin: AtomicU64::new(0), + metrics, + }) + } + + /// The plugin registry (tests install additional plugins through it). + #[must_use] + pub fn plugins(&self) -> &PluginRegistry { + &self.plugins + } + + /// The configured knobs. + #[must_use] + pub fn config(&self) -> &OagwConfig { + &self.config + } + + /// The circuit-breaker registry. + #[must_use] + pub fn breakers(&self) -> &CircuitBreakerRegistry { + &self.breakers + } + + /// The rate-limit registry. + #[must_use] + pub fn rate_limiters(&self) -> &RateLimiterRegistry { + &self.rate_limiters + } + + // --------------------------------------------------------------------- + // tenant chain + // --------------------------------------------------------------------- + + async fn tenant_chain(&self, security: &SecurityContext, tenant_id: Uuid) -> Vec { + let Some(resolver) = self.tenant_resolver.as_ref() else { + return vec![tenant_id]; + }; + let request = tenant_resolver_sdk::TenantId(tenant_id); + let response = resolver + .get_ancestors( + security, + request, + &tenant_resolver_sdk::GetAncestorsOptions::default(), + ) + .await; + let Ok(response) = response else { + // A resolver failure must not turn every request into a 404: + // degrade to the caller's own tenant. + return vec![tenant_id]; + }; + let mut chain: Vec = Vec::with_capacity(response.ancestors.len() + 1); + chain.push(response.tenant.id.0); + for ancestor in &response.ancestors { + chain.push(ancestor.id.0); + } + chain + } + + // --------------------------------------------------------------------- + // entry point + // --------------------------------------------------------------------- + + /// Run the proxy pipeline. + /// + /// # Errors + /// + /// Returns [`ProxyFailure`] for every gateway-side error in the contract's + /// error table. + pub async fn handle(&self, mut request: ProxyRequest) -> DataPlaneResult { + let started = std::time::Instant::now(); + let outcome = self.dispatch(&mut request).await; + self.audit(&request, &outcome, started); + self.metrics_request(&request, &outcome, started); + outcome + } + + /// Record the DESIGN §4.2 request, error and rate-limit instruments. + fn metrics_request( + &self, + request: &ProxyRequest, + outcome: &DataPlaneResult, + started: std::time::Instant, + ) { + let duration = started.elapsed().as_secs_f64(); + // `host` is the upstream alias and `http.route` the normalized route + // pattern (never the raw path), so label cardinality stays bounded. + let host = outcome + .as_ref() + .ok() + .map_or_else(|| request.alias.as_str(), |built| built.host.as_str()); + let route = request.route_pattern.as_deref().unwrap_or("unmatched"); + match outcome { + Ok(built) => { + self.metrics.record_request( + host, + route, + &request.method, + built.status.as_u16(), + duration, + ); + } + Err(failure) => { + let error_type = crate::domain::error::error_type(failure.kind.type_suffix()); + self.metrics.record_request( + host, + route, + &request.method, + failure.kind.status(), + duration, + ); + self.metrics.record_error(host, route, &error_type); + if failure.rate.is_some() { + self.metrics.record_rate_limit_exceeded(host, route); + } + } + } + } + + /// The pipeline itself, so [`Self::handle`] can audit every outcome. + async fn dispatch(&self, request: &mut ProxyRequest) -> DataPlaneResult { + // A browser preflight carries no credentials (WHATWG Fetch), so there is + // no tenant context to resolve an alias or a route with (ADR-0004). It is + // answered permissively before any resolution and validation is deferred + // to the actual request that follows it. + if preflight_requested(request) { + return Ok(preflight_response(request)); + } + let resolved = self.resolve(request).await?; + let cors = resolved.effective.cors.as_ref(); + if let Some(error) = cors_error(cors, request) { + return Err(ProxyFailure::new(error).with_upstream(resolved.upstream.id)); + } + self.execute(request, resolved).await + } + + /// Emit the DESIGN §4.3 audit event of one proxy request. + fn audit( + &self, + request: &ProxyRequest, + outcome: &DataPlaneResult, + started: std::time::Instant, + ) { + let (status, response_size, error_type) = match outcome { + Ok(built) => ( + Some(built.status), + match &built.body { + ProxyBody::Full(bytes) => bytes.len(), + // A relayed stream has no known length; its frames are + // never counted or read here. + ProxyBody::Stream(_) => 0, + }, + None, + ), + Err(failure) => ( + Some( + axum::http::StatusCode::from_u16(failure.kind.status()) + .unwrap_or(axum::http::StatusCode::INTERNAL_SERVER_ERROR), + ), + 0, + Some(crate::domain::error::error_type(failure.kind.type_suffix())), + ), + }; + crate::infra::audit::proxy_request(&crate::infra::audit::ProxyAudit { + request_id: &request.trace_id, + tenant_id: request.tenant_id, + principal_id: request.subject_id, + alias: &request.alias, + method: &request.method, + status, + duration_ms: started.elapsed().as_millis(), + request_size: request.body.len(), + response_size, + error_type: error_type.as_deref(), + }); + } + + /// Resolve the alias, the tenant chain and the matching route. + async fn resolve(&self, request: &mut ProxyRequest) -> DataPlaneResult { + let chain = self + .tenant_chain(&request.security, request.tenant_id) + .await; + let candidates = self + .control_plane + .upstream_candidates(&chain, &request.alias); + let Some((closest, closest_config)) = candidates.first() else { + return Err(ProxyFailure::new(OagwError::RouteNotFound(format!( + "no upstream is registered for alias '{}'", + request.alias + )))); + }; + if !closest_config.enabled { + return Err(ProxyFailure::new(OagwError::LinkUnavailable( + "upstream is disabled".to_owned(), + )) + .with_upstream(closest.id)); + } + // The closest tenant owns the routing target (DESIGN §3.3 "shadowing + // selects the routing target only"), while the matching route is + // inherited down the chain: walk descendant → root and take the first + // tenant whose routes match. A tenant that registered only the + // upstream therefore runs on the ancestor's route definition. + let request_path = normalized_request_path(&request.path_suffix); + for (upstream, _base) in &candidates { + let routes = self.control_plane.routes_for_upstream(upstream.id); + let matched = if preflight_requested(request) { + // A preflight carries `OPTIONS`, which the route allowlist + // rarely names (ADR-0004): fall back to a path-only match so + // the route's CORS configuration is still what answers the + // browser. + match_route(&routes, &request.method, &request_path) + .or_else(|_| match_preflight_route(&routes, &request_path)) + } else { + match_route(&routes, &request.method, &request_path) + }; + if let Ok((route, remainder)) = matched { + request.route_pattern = Some( + route + .spec + .match_config + .http + .as_ref() + .map_or_else(|| "unmatched".to_owned(), |http| http.path.clone()), + ); + return Ok(Resolved { + upstream: closest.clone(), + effective: with_route(closest_config, &route), + route, + remainder, + }); + } + } + Err(ProxyFailure::new(OagwError::RouteNotFound( + NO_ROUTE.to_owned(), + ))) + } + + /// Run the request leg after alias / route / CORS resolution. + async fn execute( + &self, + request: &ProxyRequest, + resolved: Resolved, + ) -> DataPlaneResult { + let Resolved { + upstream, + effective, + route, + remainder, + } = resolved; + let mut context = self.request_context(request, &upstream); + // The allowlist governs what the caller sent; plugins may append to the + // filtered query afterwards (e.g. an apikey delivered as a query + // parameter) and are not subject to it. + context.query = filtered_query(&route, request.query.as_deref()); + if let Some(binding) = effective.auth.as_ref().map(auth_binding) { + self.authenticate(&mut context, &binding, upstream.id) + .await?; + } + let rate_decision = self.enforce_rate_limit( + effective.rate_limit.as_ref(), + effective.rate_limit_owner.unwrap_or(upstream.id), + request, + upstream.id, + )?; + self.run_request_plugins(&mut context, &effective.plugins, upstream.id, true) + .await?; + + let endpoint = self.select_endpoint(&upstream, request.target_host())?; + self.check_target(&endpoint, upstream.id)?; + let path = target_path(&route, &remainder)?; + let query = context.query.clone(); + let host = endpoint.host.clone(); + self.check_breaker(upstream.id, &host)?; + + let url = target_url(&endpoint, &path, query.as_deref()); + let outcome = self + .send( + request.method.as_str(), + &url, + outbound_headers( + &context.headers, + &effective.headers.request, + &request.headers, + ), + request.body.clone(), + ) + .await; + + match outcome { + Ok(response) => { + self.breaker_success(upstream.id, &host); + // What makes the leg a stream is the body the upstream is + // actually producing (`text/event-stream`), not what the caller + // asked for: an `accept: text/event-stream` on a JSON reply must + // still go through the buffered response phases (ADR-0002). + let mut built = if is_streaming(response.headers()) { + self.streaming_response(&upstream, response).await + } else { + self.buffered_response( + &upstream, + &effective.plugins, + &effective.headers, + response, + &mut context, + ) + .await + }; + if let Ok(outcome) = built.as_mut() { + outcome.upstream_id = upstream.id; + outcome.host.clone_from(&host); + if let Some(decision) = &rate_decision { + set_rate_limit_headers(&mut outcome.headers, decision); + } + if let Some(cors) = effective.cors.as_ref() + && let Some(origin) = request_header(request, "origin") + { + set_cors_response_headers(&mut outcome.headers, cors, origin); + } + } + built + } + Err(error) => { + self.breaker_failure(upstream.id, &host); + Err(error.with_upstream(upstream.id).with_host(host)) + } + } + } + + /// Assemble the mutable request context handed to the plugin chain. + fn request_context(&self, request: &ProxyRequest, upstream: &Upstream) -> RequestContext { + RequestContext { + tenant_id: upstream.tenant_id, + upstream_id: upstream.id, + alias: upstream.alias.clone(), + method: request.method.clone(), + path: normalized_request_path(&request.path_suffix), + query: request.query.clone(), + // The chain sees the request as the caller sent it (ADR-0002: guards + // run before transforms, and a presence guard has to be able to see + // a caller-supplied header). The outbound header rules are applied + // when the request is composed for the upstream, in `outbound_headers`. + headers: request.headers.clone(), + body: request.body.clone(), + uri: request.instance.clone().parse().unwrap_or_default(), + config: serde_json::Map::new(), + security: request.security.clone(), + } + } + + /// Run the single auth plugin. + async fn authenticate( + &self, + context: &mut RequestContext, + binding: &PluginBinding, + upstream_id: Uuid, + ) -> DataPlaneResult<()> { + let plugin = self.plugins.auth_plugin(binding).ok_or_else(|| { + ProxyFailure::new(OagwError::PluginNotFound(format!( + "auth plugin '{}' has no implementation", + binding.plugin_ref + ))) + .with_upstream(upstream_id) + })?; + context.config = binding.config.as_object().cloned().unwrap_or_default(); + let failure = plugin.authenticate(context).await; + failure.map_err(|error| { + // DESIGN §4.3: failed authentication attempts are audited. Only the + // error's type code is recorded, never the presented credential or + // the message that could embed one. + crate::infra::audit::auth_failure( + context.tenant_id, + upstream_id, + crate::domain::error::error_type(match &error { + PluginError::Authentication(_) => "authentication.failed.v1", + other => crate::domain::error::OagwError::from(other.clone()).type_suffix(), + }) + .as_str(), + ); + ProxyFailure::new(OagwError::from(error)).with_upstream(upstream_id) + }) + } + + /// Reject requests over the effective rate limit. + fn enforce_rate_limit( + &self, + config: Option<&RateLimitConfig>, + owner: Uuid, + request: &ProxyRequest, + upstream_id: Uuid, + ) -> DataPlaneResult> { + let Some(config) = config else { + return Ok(None); + }; + let scope_key = match config.scope { + crate::domain::model::RateScope::User => keyed_scope( + &request.subject_id.to_string(), + &request.tenant_id.to_string(), + ), + crate::domain::model::RateScope::Ip => { + keyed_scope(&request.client_ip, &request.tenant_id.to_string()) + } + crate::domain::model::RateScope::Route => owner.to_string(), + _ => request.tenant_id.to_string(), + }; + let key = rate_key(config, &scope_key, &owner.to_string()); + let Some(decision) = self.rate_limiters.check(&key, config) else { + return Ok(None); + }; + if decision.allowed { + // Reporting the counters is opt-out, so the caller gets + // `X-RateLimit-*` on every accepted request by default. + return Ok(config.response_headers().then_some(decision)); + } + // ADR-0003 reports the exhausted budget on the 429 itself: the counters + // travel with the problem document, next to `Retry-After`. + Err( + ProxyFailure::new(OagwError::RateLimitExceeded(decision.retry_after.max(1))) + .with_upstream(upstream_id) + .with_rate(&decision), + ) + } + + /// Run guard and transform plugins in chain order. + /// + /// `request_phase` selects the guard phase; transforms always run their + /// request hook. + async fn run_request_plugins( + &self, + context: &mut RequestContext, + config: &crate::domain::model::PluginsConfig, + upstream_id: Uuid, + request_phase: bool, + ) -> DataPlaneResult<()> { + for binding in plugin_bindings(config) { + if self.plugins.missing(&binding.plugin_ref) { + // A chain entry that resolves to no implementation fails closed + // rather than being silently skipped (contract §9). + return Err(ProxyFailure::new(OagwError::PluginNotFound(format!( + "plugin '{}' has no implementation", + binding.plugin_ref + ))) + .with_upstream(upstream_id)); + } + context.config = binding.config.as_object().cloned().unwrap_or_default(); + if request_phase && let Some(guard) = self.plugins.guard_plugin(&binding) { + let decision = guard.guard_request(context).await; + reject_or_allow(decision, upstream_id)?; + } + if let Some(transform) = self.plugins.transform_plugin(&binding) { + transform + .transform_request(context) + .await + .map_err(|error| { + ProxyFailure::new(OagwError::from(error)).with_upstream(upstream_id) + })?; + } + } + Ok(()) + } + + /// Resolve the endpoint serving this request. + fn select_endpoint( + &self, + upstream: &Upstream, + requested: Option, + ) -> DataPlaneResult { + let endpoints = &upstream.spec.server.endpoints; + let selected = match endpoints.as_slice() { + [] => Err(ProxyFailure::new(OagwError::Validation( + "upstream has no endpoints".to_owned(), + ))), + [single] => match requested.as_deref() { + Some(host) => match normalized_target_host(host) { + Some(host) if single.normalized_host() == host => Ok(single.clone()), + Some(host) => Err(ProxyFailure::new(unknown_target_host(&host, endpoints))), + None => Err(ProxyFailure::new(invalid_target_host(host))), + }, + _ => Ok(single.clone()), + }, + many => match requested.as_deref() { + Some(host) => match normalized_target_host(host) { + Some(host) => many + .iter() + .find(|endpoint| endpoint.normalized_host() == host) + .cloned() + .ok_or_else(|| ProxyFailure::new(unknown_target_host(&host, many))), + None => Err(ProxyFailure::new(invalid_target_host(host))), + }, + None if upstream.alias_derived => Err(ProxyFailure::new(missing_target_host( + many, + &upstream.alias, + ))), + None => Ok(round_robin(many, &self.round_robin)), + }, + }?; + // DESIGN §4.2: which endpoint a request landed on, and whether the + // caller steered it there. + self.metrics.record_endpoint_selected( + &upstream.id.to_string(), + &selected.host, + match requested { + Some(_) => crate::domain::ports::metrics::SelectionMethod::ExplicitHeader, + None if endpoints.len() > 1 => { + crate::domain::ports::metrics::SelectionMethod::RoundRobin + } + None => crate::domain::ports::metrics::SelectionMethod::Default, + }, + ); + if requested.is_some() { + self.metrics + .record_target_host_used(&upstream.id.to_string(), &selected.host); + } + Ok(selected) + } + + /// SSRF and transport-security checks on the selected endpoint. + fn check_target(&self, endpoint: &Endpoint, upstream_id: Uuid) -> DataPlaneResult<()> { + let ssrf = &self.config.ssrf_policy; + if ssrf.enabled && !ssrf.allow_private_addresses && is_private_host(&endpoint.host) { + return Err(ProxyFailure::new(OagwError::Validation(format!( + "target host '{}' is not reachable under the SSRF policy", + endpoint.host + ))) + .with_upstream(upstream_id)); + } + if endpoint.scheme == "http" && !self.config.allow_http_upstream { + return Err(ProxyFailure::new(OagwError::ProtocolError( + "plain-http upstreams are disabled; configure an https endpoint".to_owned(), + )) + .with_upstream(upstream_id) + .with_host(endpoint.host.clone())); + } + Ok(()) + } + + /// Fail fast while the breaker is open. + fn check_breaker(&self, upstream_id: Uuid, host: &str) -> DataPlaneResult<()> { + if self.breakers.check(upstream_id, host) == BreakerCheck::Open { + return Err(ProxyFailure::new(breaker_open()) + .with_upstream(upstream_id) + .with_host(host)); + } + Ok(()) + } + + /// Send the upstream request. + async fn send( + &self, + method: &str, + url: &str, + headers: axum::http::HeaderMap, + body: Bytes, + ) -> DataPlaneResult { + let builder = builder_for(&self.http, method, url) + .headers(headers_to_pairs(&headers)) + .body_bytes(body); + builder + .send() + .await + .map_err(OagwError::from) + .map_err(ProxyFailure::new) + } + + /// Build the buffered response, running the response-side plugin phases. + async fn buffered_response( + &self, + upstream: &Upstream, + plugins: &crate::domain::model::PluginsConfig, + rules: &HeadersConfig, + response: HttpResponse, + context: &mut RequestContext, + ) -> DataPlaneResult { + let (status, headers, body) = split_response(response).await?; + let mut response_context = ResponseContext { + request_headers: context.headers.clone(), + headers, + status, + body, + config: serde_json::Map::new(), + }; + for binding in plugin_bindings(plugins) { + if self.plugins.missing(&binding.plugin_ref) { + return Err(ProxyFailure::new(OagwError::PluginNotFound(format!( + "plugin '{}' has no implementation", + binding.plugin_ref + ))) + .with_upstream(upstream.id)); + } + context.config = binding.config.as_object().cloned().unwrap_or_default(); + response_context.config = context.config.clone(); + if let Some(guard) = self.plugins.guard_plugin(&binding) { + let decision = guard.guard_response(&response_context).await; + reject_or_allow(decision, upstream.id)?; + } + if let Some(transform) = self.plugins.transform_plugin(&binding) { + transform + .transform_response(&mut response_context) + .await + .map_err(|error| { + ProxyFailure::new(OagwError::from(error)).with_upstream(upstream.id) + })?; + } + } + let mut headers = response_context.headers; + strip_hop_by_hop(&mut headers); + apply_response_rules(&mut headers, &rules.response); + set_source(&mut headers, source_for(status)); + Ok(ProxyOutcome { + status, + headers, + body: ProxyBody::Full(response_context.body), + upstream_id: upstream.id, + host: String::new(), + }) + } + + /// Build the streaming (SSE) response. + async fn streaming_response( + &self, + upstream: &Upstream, + response: HttpResponse, + ) -> DataPlaneResult { + let inner = response.into_inner(); + let status = inner.status(); + let mut headers = inner.headers().clone(); + strip_hop_by_hop(&mut headers); + // The stream is relayed from the upstream, so its headers attribute it + // to the upstream; a mid-stream abort is reported through an `error` + // event inside the body, because the status line is already on the wire. + set_source(&mut headers, "upstream"); + let stream = body_stream(inner.into_body()); + Ok(ProxyOutcome { + status, + headers, + body: ProxyBody::Stream(Box::pin(stream)), + upstream_id: upstream.id, + host: String::new(), + }) + } + + // --------------------------------------------------------------------- + // WebSocket leg + // --------------------------------------------------------------------- + + /// Resolve the upstream target for a WebSocket upgrade. + /// + /// # Errors + /// + /// Returns [`ProxyFailure`] for alias, route, auth, rate-limit and + /// transport-policy errors. + pub async fn resolve_for_websocket( + &self, + mut request: ProxyRequest, + ) -> DataPlaneResult { + let resolved = self.resolve(&mut request).await?; + let upstream = resolved.upstream.clone(); + let effective = resolved.effective.clone(); + // The handshake is a proxied request like any other, so it answers to + // the same policy phases before the upgrade (contract §5): CORS on the + // actual request, then auth, rate limit, guards and transforms. + if let Some(error) = cors_error(resolved.effective.cors.as_ref(), &request) { + return Err(ProxyFailure::new(error).with_upstream(upstream.id)); + } + let mut context = self.request_context(&request, &upstream); + // The allowlist governs what the caller sent; plugins may append to the + // filtered query afterwards and are not subject to it. + context.query = filtered_query(&resolved.route, request.query.as_deref()); + if let Some(binding) = effective.auth.as_ref().map(auth_binding) { + self.authenticate(&mut context, &binding, upstream.id) + .await?; + } + self.enforce_rate_limit( + effective.rate_limit.as_ref(), + effective.rate_limit_owner.unwrap_or(upstream.id), + &request, + upstream.id, + )?; + self.run_request_plugins(&mut context, &effective.plugins, upstream.id, true) + .await?; + let endpoint = self.select_endpoint(&upstream, request.target_host())?; + self.check_target(&endpoint, upstream.id)?; + let path = target_path(&resolved.route, &resolved.remainder)?; + let url = ws_url(&endpoint, &path, context.query.as_deref())?; + let host = endpoint.host.clone(); + self.check_breaker(upstream.id, &host)?; + Ok(WebSocketLeg { + url, + headers: headers_to_pairs(&outbound_headers( + &context.headers, + &effective.headers.request, + &request.headers, + )), + upstream_id: upstream.id, + host, + }) + } + + /// Record a successful WebSocket leg. + pub fn websocket_success(&self, leg: &WebSocketLeg) { + self.breaker_success(leg.upstream_id, &leg.host); + } + + /// Record a failed WebSocket leg. + pub fn websocket_failure(&self, leg: &WebSocketLeg) { + self.breaker_failure(leg.upstream_id, &leg.host); + } + + /// Close the breaker for an endpoint and report the transition (DESIGN §4.2). + fn breaker_success(&self, upstream_id: Uuid, host: &str) { + if let Some(to) = self.breakers.record_success(upstream_id, host) { + self.metrics.set_breaker_state(host, to); + } + } + + /// Trip the breaker for an endpoint and report the transition (DESIGN §4.2). + fn breaker_failure(&self, upstream_id: Uuid, host: &str) { + if let Some(to) = self.breakers.record_failure(upstream_id, host) { + self.metrics.set_breaker_state(host, to); + // Only two states exist, so the state the breaker left is its + // opposite. + let from = match to { + crate::domain::ports::metrics::BreakerState::Closed => { + crate::domain::ports::metrics::BreakerState::Open + } + crate::domain::ports::metrics::BreakerState::Open => { + crate::domain::ports::metrics::BreakerState::Closed + } + }; + self.metrics.record_breaker_transition(host, from, to); + } + } +} + +// --------------------------------------------------------------------------------------- +// free helpers +// --------------------------------------------------------------------------------------- + +fn unknown_target_host(host: &str, endpoints: &[Endpoint]) -> OagwError { + OagwError::UnknownTargetHost { + invalid_value: host.to_owned(), + valid_hosts: endpoint_hosts(endpoints), + } +} + +/// ADR-0007: `X-OAGW-Target-Host` must be a bare hostname or IP address — no +/// scheme, port, path or other special characters. Returns the canonical +/// lowercase host, or `None` when the value is malformed and must be reported +/// as `invalid_target_host.v1` rather than looked up. +#[must_use] +pub fn normalized_target_host(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() + || trimmed.contains(['/', '?', '#', '@', ' ', '\t']) + || trimmed.contains("://") + { + return None; + } + if let Ok(ip) = std::net::IpAddr::from_str(trimmed) { + return Some(ip.to_string()); + } + if let Some(rest) = trimmed.strip_prefix('[') { + let (inside, tail) = rest.split_once(']')?; + if !tail.is_empty() { + // `[::1]:8080` — a port is not a valid target host (ADR-0007). + return None; + } + return std::net::IpAddr::from_str(inside) + .ok() + .map(|ip| ip.to_string()); + } + // Anything else carrying a colon is a `host:port` pair, which the header + // must not contain. + if trimmed.contains(':') { + return None; + } + let host = trimmed.trim_end_matches('.').to_ascii_lowercase(); + if !is_valid_hostname(&host) { + return None; + } + Some(host) +} + +/// RFC 1123 hostname: dot-separated labels of letters, digits and hyphens that +/// neither start nor end with a hyphen, each at most 63 bytes, 253 in total. +#[must_use] +pub fn is_valid_hostname(host: &str) -> bool { + if host.is_empty() || host.len() > 253 { + return false; + } + host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + && !label.starts_with('-') + && !label.ends_with('-') + }) +} + +fn invalid_target_host(host: &str) -> OagwError { + OagwError::InvalidTargetHost { + invalid_value: host.to_owned(), + } +} + +fn missing_target_host(endpoints: &[Endpoint], alias: &str) -> OagwError { + OagwError::MissingTargetHost { + valid_hosts: endpoint_hosts(endpoints), + alias: alias.to_owned(), + } +} + +fn endpoint_hosts(endpoints: &[Endpoint]) -> Vec { + endpoints.iter().map(Endpoint::normalized_host).collect() +} + +/// `X-OAGW-Error-Source` value for a response that was produced by the upstream. +/// +/// ADR-0007: the header names the origin of the response the caller received, so +/// anything relayed from the upstream — a 200 as much as its errors — is +/// attributed to `upstream`, while every response the gateway generates itself +/// (problems, the CORS preflight) is attributed to `gateway`. +#[must_use] +pub fn source_for(_status: axum::http::StatusCode) -> &'static str { + "upstream" +} + +fn set_source(headers: &mut axum::http::HeaderMap, value: &str) { + if let Some(parsed) = crate::infra::proxy::headers::header_value(value) { + headers.insert( + axum::http::HeaderName::from_static(ERROR_SOURCE_HEADER), + parsed, + ); + } +} + +/// Attach the `X-RateLimit-*` counters of an accepted request. +fn set_rate_limit_headers(headers: &mut axum::http::HeaderMap, decision: &RateDecision) { + let pairs = [ + ("x-ratelimit-limit", decision.limit.to_string()), + ("x-ratelimit-remaining", decision.remaining.to_string()), + ("x-ratelimit-reset", decision.reset_epoch.to_string()), + ]; + for (name, value) in pairs { + let Some(parsed) = crate::infra::proxy::headers::header_value(&value) else { + continue; + }; + if let Ok(name) = axum::http::HeaderName::from_bytes(name.as_bytes()) { + headers.insert(name, parsed); + } + } +} + +/// Request path used for matching, always starting with `/`. +#[must_use] +pub fn normalized_request_path(suffix: &str) -> String { + let trimmed = suffix.trim_matches('/'); + if trimmed.is_empty() { + "/".to_owned() + } else { + format!("/{trimmed}") + } +} + +/// Longest matching route plus the path remainder after the matched prefix. +/// +/// # Errors +/// +/// Returns [`OagwError::RouteNotFound`] when no route matches. +pub fn match_route( + routes: &[Route], + method: &str, + request_path: &str, +) -> Result<(Route, String), OagwError> { + let mut best: Option<(usize, Route, String)> = None; + for route in routes { + if !route.spec.enabled { + continue; + } + let Some(http) = route.spec.match_config.http.as_ref() else { + continue; + }; + if !http + .methods + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(method)) + { + continue; + } + let Some(remainder) = segment_prefix(&http.path, request_path) else { + continue; + }; + let depth = segment_depth(&http.path); + if best + .as_ref() + .is_none_or(|(best_depth, _, _)| depth > *best_depth) + { + best = Some((depth, route.clone(), remainder)); + } + } + best.map(|(_, route, remainder)| (route, remainder)) + .ok_or_else(|| OagwError::RouteNotFound(NO_ROUTE.to_owned())) +} + +fn segment_depth(path: &str) -> usize { + path.split('/').filter(|part| !part.is_empty()).count() +} + +/// Path-only route match for CORS preflights, whose `OPTIONS` method is not +/// part of the route's allowlist. +fn match_preflight_route( + routes: &[Route], + request_path: &str, +) -> Result<(Route, String), OagwError> { + let mut best: Option<(usize, Route, String)> = None; + for route in routes { + if !route.spec.enabled { + continue; + } + let Some(http) = route.spec.match_config.http.as_ref() else { + continue; + }; + let Some(remainder) = segment_prefix(&http.path, request_path) else { + continue; + }; + let depth = segment_depth(&http.path); + if best + .as_ref() + .is_none_or(|(best_depth, _, _)| depth > *best_depth) + { + best = Some((depth, route.clone(), remainder)); + } + } + best.map(|(_, route, remainder)| (route, remainder)) + .ok_or_else(|| OagwError::RouteNotFound(NO_ROUTE.to_owned())) +} + +/// Segment-wise prefix match, returning the remainder without a leading slash. +#[must_use] +pub fn segment_prefix(prefix: &str, path: &str) -> Option { + let prefix_parts: Vec<&str> = prefix.split('/').filter(|part| !part.is_empty()).collect(); + let path_parts: Vec<&str> = path.split('/').filter(|part| !part.is_empty()).collect(); + if prefix_parts.len() > path_parts.len() { + return None; + } + for (index, part) in prefix_parts.iter().enumerate() { + if !part.eq_ignore_ascii_case(path_parts[index]) { + return None; + } + } + Some(path_parts[prefix_parts.len()..].join("/")) +} + +/// Upstream target path for the matched route. +/// +/// # Errors +/// +/// Returns [`OagwError::RouteError`] when the route forbids a path suffix and +/// [`OagwError::ProtocolError`] for gRPC-only routes. +pub fn target_path(route: &Route, remainder: &str) -> Result { + let Some(http) = route.spec.match_config.http.as_ref() else { + return Err(OagwError::ProtocolError( + "gRPC routes are not proxied by the MVP data plane".to_owned(), + )); + }; + if remainder.is_empty() { + return Ok(collapse_slashes(&http.path)); + } + if http.path_suffix_mode == PathSuffixMode::Disabled { + return Err(OagwError::RouteError( + "path suffix is not accepted by this route".to_owned(), + )); + } + Ok(collapse_slashes(&format!("{}/{remainder}", http.path))) +} + +/// Collapse to a single leading slash and drop trailing separators. +#[must_use] +pub fn collapse_slashes(path: &str) -> String { + let trimmed = path.trim_matches('/'); + if trimmed.is_empty() { + "/".to_owned() + } else { + format!("/{trimmed}") + } +} + +/// Query string restricted to the route's allowlist. +#[must_use] +pub fn filtered_query(route: &Route, raw: Option<&str>) -> Option { + let allowlist = &route.spec.match_config.http.as_ref()?.query_allowlist; + let raw = raw?; + if allowlist.is_empty() { + return None; + } + let pairs: Vec<(String, String)> = form_urlencoded::parse(raw.as_bytes()) + .filter(|(name, _)| { + allowlist + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(name)) + }) + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect(); + if pairs.is_empty() { + return None; + } + Some( + form_urlencoded::Serializer::new(String::new()) + .extend_pairs(pairs) + .finish(), + ) +} + +/// Absolute upstream URL for the HTTP / SSE leg. +#[must_use] +pub fn target_url(endpoint: &Endpoint, path: &str, query: Option<&str>) -> String { + match query.filter(|value| !value.is_empty()) { + Some(value) => format!( + "{}://{}:{}{}?{value}", + endpoint.scheme, endpoint.host, endpoint.port, path + ), + None => format!( + "{}://{}:{}{}", + endpoint.scheme, endpoint.host, endpoint.port, path + ), + } +} + +/// Absolute upstream URL for the WebSocket leg. +/// +/// # Errors +/// +/// Returns [`OagwError::LinkUnavailable`] for `wt://` endpoints, which have no +/// HTTP-3 fallback in the MVP data plane. +pub fn ws_url(endpoint: &Endpoint, path: &str, query: Option<&str>) -> Result { + match endpoint.scheme.as_str() { + // `wt` has no HTTP fallback in the MVP data plane. + "wt" => Err(OagwError::LinkUnavailable( + "webtransport endpoints have no http fallback for websocket upgrades".to_owned(), + )), + // A WebSocket handshake is only speakable over `ws` / `wss`: the HTTP + // scheme of the endpoint has to be rewritten to its WebSocket form. + "https" | "wss" => Ok(ws_target(endpoint, "wss", path, query)), + "grpc" | "grpcs" => Err(OagwError::LinkUnavailable( + "grpc endpoints do not accept websocket upgrades".to_owned(), + )), + _ => Ok(ws_target(endpoint, "ws", path, query)), + } +} + +/// `scheme://host:port/path` for the WebSocket leg. +fn ws_target(endpoint: &Endpoint, scheme: &str, path: &str, query: Option<&str>) -> String { + let plain = Endpoint { + scheme: scheme.to_owned(), + ..endpoint.clone() + }; + target_url(&plain, path, query) +} + +/// Round-robin over the endpoint pool. +fn round_robin(endpoints: &[Endpoint], counter: &AtomicU64) -> Endpoint { + let index = counter.fetch_add(1, Ordering::Relaxed); + let len = u64::try_from(endpoints.len()).unwrap_or(1).max(1); + endpoints + .get(usize::try_from(index % len).unwrap_or_default()) + .cloned() + .unwrap_or_else(|| endpoints[0].clone()) +} + +/// SSRF guard: reject loopback, link-local, private and unique-local literals. +/// +/// Names are not resolved here (the gateway has no DNS resolver of its own in +/// the MVP), so a host that is *not* an address literal is only refused when it +/// is the well-known `localhost` name; the DNS-result validation the PRD asks +/// for happens on the resolved address inside the HTTP client transport. +#[must_use] +pub fn is_private_host(host: &str) -> bool { + let candidate = bare_host(host); + if candidate.eq_ignore_ascii_case("localhost") { + return true; + } + let Ok(address) = std::net::IpAddr::from_str(&candidate) else { + return false; + }; + match address { + std::net::IpAddr::V4(value) => { + value.is_loopback() + || value.is_private() + || value.is_link_local() + || value.is_broadcast() + || value.is_multicast() + || value.is_unspecified() + || value.is_documentation() + } + std::net::IpAddr::V6(value) => { + value.is_loopback() + || value.is_multicast() + || value.is_unspecified() + || value.is_unique_local() + || value.is_unicast_link_local() + || mapped_v4(value).is_some_and(is_private_host_v4) + } + } +} + +/// The IPv4 address behind an IPv4-mapped IPv6 literal, if there is one. +fn mapped_v4(value: std::net::Ipv6Addr) -> Option { + let octets = value.octets(); + (octets[..12] == [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0xff, 0xff]) + .then(|| std::net::Ipv4Addr::new(octets[12], octets[13], octets[14], octets[15])) +} + +/// The IPv4 half of the guard, shared with IPv4-mapped IPv6 literals. +fn is_private_host_v4(value: std::net::Ipv4Addr) -> bool { + value.is_loopback() + || value.is_private() + || value.is_link_local() + || value.is_broadcast() + || value.is_multicast() + || value.is_unspecified() + || value.is_documentation() +} + +fn reject_or_allow( + decision: Result, + upstream_id: Uuid, +) -> DataPlaneResult<()> { + use crate::domain::plugin::GuardDecision; + match decision { + Ok(GuardDecision::Allow) => Ok(()), + Ok(GuardDecision::Reject { + status, + error_code, + message, + }) => Err(ProxyFailure::new(OagwError::from(PluginError::Rejected { + status, + error_code, + message, + })) + .with_upstream(upstream_id)), + Err(error) => Err(ProxyFailure::new(OagwError::from(error)).with_upstream(upstream_id)), + } +} + +/// Names never forwarded upstream: hop-by-hop, routing and identity headers. +fn forward_skip( + rules: &crate::domain::model::RequestHeaders, + inbound: &axum::http::HeaderMap, +) -> Vec<&'static str> { + let mut skip: Vec<&'static str> = vec![TARGET_HOST_HEADER, "host", "content-length"]; + skip.extend(HOP_BY_HOP); + if !authorization_allowed(rules, inbound) { + skip.push(AUTHORIZATION); + } + skip +} + +fn authorization_allowed( + rules: &crate::domain::model::RequestHeaders, + inbound: &axum::http::HeaderMap, +) -> bool { + if !inbound.contains_key(AUTHORIZATION) { + return true; + } + match rules.passthrough { + crate::domain::model::Passthrough::All => true, + crate::domain::model::Passthrough::Allowlist => rules + .passthrough_allowlist + .iter() + .any(|name| name.eq_ignore_ascii_case(AUTHORIZATION)), + crate::domain::model::Passthrough::None => false, + } +} + +/// Plugin bindings for a plugin-chain configuration. +/// +/// Each entry carries the configuration bound next to its reference +/// (ADR-0009), so a guard such as `required_headers.v1` can receive the +/// headers it has to enforce. +fn plugin_bindings(config: &crate::domain::model::PluginsConfig) -> Vec { + config + .items + .iter() + .map(|item| PluginBinding { + plugin_ref: item.reference().to_owned(), + config: item.config(), + }) + .collect() +} + +/// Compose the headers sent to the upstream. +/// +/// The plugin chain runs over the caller's view of the request (ADR-0002), so +/// the outbound set is the rule-driven view of the caller's headers — +/// passthrough, set/add/remove and the `authorization` skip rule — overlaid +/// with whatever the chain itself changed: a credential the auth plugin +/// injected, or a header a transform added, travels even when `passthrough` +/// is `none`, while caller headers the chain left untouched still obey the +/// passthrough rule. +fn outbound_headers( + chain_headers: &axum::http::HeaderMap, + rules: &crate::domain::model::RequestHeaders, + inbound: &axum::http::HeaderMap, +) -> axum::http::HeaderMap { + let skip = forward_skip(rules, inbound); + let mut out = build_request_headers(inbound, rules, &skip); + for name in chain_headers.keys() { + if skip + .iter() + .any(|skipped| name.as_str().eq_ignore_ascii_case(skipped)) + { + continue; + } + let chain_values: Vec<&axum::http::HeaderValue> = + chain_headers.get_all(name).iter().collect(); + let caller_values: Vec<&axum::http::HeaderValue> = inbound.get_all(name).iter().collect(); + if chain_values == caller_values { + continue; + } + out.remove(name); + for value in chain_values { + out.append(name.clone(), value.clone()); + } + } + out +} + +/// Turn the effective auth block into the plugin binding the registry resolves. +fn auth_binding(auth: &crate::domain::model::AuthConfig) -> PluginBinding { + PluginBinding { + plugin_ref: auth.plugin_type.clone(), + config: serde_json::Value::Object(auth.config.clone()), + } +} + +/// Headers serialised for the outbound request builder. +fn headers_to_pairs(headers: &axum::http::HeaderMap) -> Vec<(String, String)> { + headers + .iter() + .filter_map(|(name, value)| { + let text = value.to_str().ok()?; + Some((name.as_str().to_owned(), text.to_owned())) + }) + .collect() +} + +/// Pick the request-builder preset that carries the same method. +/// +/// `toolkit_http` exposes fixed-verb builders only; the route match restricts +/// the data plane to `GET`/`POST`/`PUT`/`DELETE`/`PATCH`, so the mapping is +/// total for reachable requests. +fn builder_for(http: &HttpClient, method: &str, url: &str) -> toolkit_http::RequestBuilder { + match method { + "POST" => http.post(url), + "PUT" => http.put(url), + "DELETE" => http.delete(url), + "PATCH" => http.patch(url), + _ => http.get(url), + } +} + +/// `true` when either side asked for an event stream. +#[must_use] +pub fn is_streaming(response_headers: &axum::http::HeaderMap) -> bool { + response_headers + .get(axum::http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.starts_with(SSE_MEDIA_TYPE)) +} + +/// `true` for a CORS preflight request. +#[must_use] +pub fn preflight_requested(request: &ProxyRequest) -> bool { + request.method == "OPTIONS" + && request.headers.contains_key(axum::http::header::ORIGIN) + && request + .headers + .contains_key(axum::http::header::ACCESS_CONTROL_REQUEST_METHOD) +} + +fn request_header<'a>(request: &'a ProxyRequest, name: &str) -> Option<&'a str> { + let name = axum::http::HeaderName::from_bytes(name.as_bytes()).ok()?; + request + .headers + .get(&name) + .and_then(|value| value.to_str().ok()) +} + +/// Build the permissive 204 preflight response. +/// +/// ADR-0004: the preflight echoes whatever the browser asked for; origin and +/// method enforcement happens on the actual request that follows it, once the +/// upstream — and therefore its CORS configuration — has been resolved. +fn preflight_response(request: &ProxyRequest) -> ProxyOutcome { + let origin = request_header(request, "origin").unwrap_or("*"); + let requested_method = request_header(request, "access-control-request-method").unwrap_or(""); + let requested_headers = request_header(request, "access-control-request-headers").unwrap_or(""); + let mut headers = axum::http::HeaderMap::new(); + let values: [(&str, &str); 5] = [ + ("access-control-allow-origin", origin), + ("access-control-allow-methods", requested_method), + ("access-control-max-age", "86400"), + ("access-control-allow-headers", requested_headers), + ( + "vary", + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers", + ), + ]; + for (name, value) in values { + if let (Some(name), Some(value)) = ( + crate::infra::proxy::headers::header_name(name), + crate::infra::proxy::headers::header_value(value), + ) { + headers.insert(name, value); + } + } + // `Access-Control-Allow-Credentials` is deliberately absent: ADR-0004's + // preflight header list does not include it, and granting it before the + // origin has been validated would let any origin preflight credentialed + // access. The actual request sets it once the CORS config is resolved. + set_source(&mut headers, "gateway"); + ProxyOutcome { + status: axum::http::StatusCode::NO_CONTENT, + headers, + body: ProxyBody::Full(Bytes::new()), + upstream_id: Uuid::nil(), + host: String::new(), + } +} + +fn cors_origin_allowed(cors: &CorsConfig, origin: &str) -> bool { + cors.allowed_origins + .iter() + .any(|allowed| allowed == "*" || allowed.eq_ignore_ascii_case(origin)) +} + +fn cors_method_allowed(cors: &CorsConfig, method: &str) -> bool { + cors.methods() + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(method)) +} + +/// `403` when the origin or the method is outside the effective allowlist. +fn cors_error(cors: Option<&CorsConfig>, request: &ProxyRequest) -> Option { + let cors = cors?; + let origin = request_header(request, "origin")?; + if !cors_origin_allowed(cors, origin) { + return Some(OagwError::CorsOriginNotAllowed( + "origin is not allowed by the effective CORS configuration".to_owned(), + )); + } + if !cors_method_allowed(cors, &request.method) { + return Some(OagwError::CorsMethodNotAllowed( + "method is not allowed by the effective CORS configuration".to_owned(), + )); + } + None +} + +/// Attach the CORS headers of an accepted **actual** response (ADR-0004): +/// the caller's origin, the credential flag, the exposed headers and +/// `Vary: Origin` so caches never serve one origin's response to another. +fn set_cors_response_headers(headers: &mut axum::http::HeaderMap, cors: &CorsConfig, origin: &str) { + let mut pairs: Vec<(&str, String)> = vec![("access-control-allow-origin", origin.to_owned())]; + if cors.allow_credentials { + pairs.push(("access-control-allow-credentials", "true".to_owned())); + } + if !cors.expose_headers.is_empty() { + pairs.push(( + "access-control-expose-headers", + cors.expose_headers.join(", "), + )); + } + for (name, value) in pairs { + let (Ok(name), Some(value)) = ( + axum::http::HeaderName::from_bytes(name.as_bytes()), + crate::infra::proxy::headers::header_value(&value), + ) else { + continue; + }; + headers.insert(name, value); + } + if !headers.contains_key(axum::http::header::VARY) + && let Some(value) = crate::infra::proxy::headers::header_value("Origin") + { + headers.insert(axum::http::header::VARY, value); + } +} + +/// Apply the matched route over the chain-merged effective configuration. +#[must_use] +pub fn with_route(effective: &EffectiveConfig, route: &Route) -> EffectiveConfig { + let mut merged = effective.clone(); + // Route plugins always append to the upstream chain (DESIGN §3.3): + // `[U1, U2] + [R1, R2] => [U1, U2, R1, R2]`. The `sharing` gate governs + // tenant inheritance only, and a route is not a tenant-chain participant. + { + let mut items = merged.plugins.items; + for item in &route + .spec + .plugins + .as_ref() + .map_or_else(Vec::new, |p| p.items.clone()) + { + // A route may rebind a plugin the upstream already runs; its + // configuration (and binding order) then replaces the upstream's. + match items + .iter_mut() + .find(|existing| existing.reference() == item.reference()) + { + Some(existing) => *existing = item.clone(), + None => items.push(item.clone()), + } + } + merged.plugins.items = items; + } + let route_limit = route.spec.rate_limit.clone(); + if route_limit.is_some() { + // A route limit is its own budget: the configuring resource is the + // route, not the upstream whose limit it overrides (ADR-0003). + merged.rate_limit_owner = Some(route.id); + } + merged.rate_limit = + crate::domain::merge::merge_route_rate_limit(merged.rate_limit.clone(), route_limit); + merged.cors = crate::domain::merge::merge_cors(merged.cors.clone(), route.spec.cors.clone()); + for tag in &route.spec.tags { + if !merged.tags.contains(tag) { + merged.tags.push(tag.clone()); + } + } + if !route.spec.enabled { + merged.enabled = false; + } + merged +} + +/// Next body frame, mapping a transport failure onto +/// [`OagwError::StreamAborted`] (`None` ends the stream). +async fn next_frame( + body: &mut toolkit_http::ResponseBody, +) -> Option, OagwError>> { + let frame = + futures_util::future::poll_fn(|cx| HttpBody::poll_frame(std::pin::Pin::new(body), cx)) + .await; + match frame { + Some(Ok(frame)) => Some(Ok(frame)), + Some(Err(_)) => Some(Err(OagwError::StreamAborted( + "upstream stream aborted".to_owned(), + ))), + None => None, + } +} + +/// Split an upstream response into status, headers and buffered body. +/// Buffer an upstream response body, refusing bodies over [`MAX_BODY_BYTES`]. +/// +/// The same hard limit the inbound request honours (DESIGN +/// `cpt-cf-oagw-constraint-body-limit`): an unbounded upstream reply would let +/// a misbehaving peer exhaust the gateway's memory. +async fn split_response( + response: HttpResponse, +) -> DataPlaneResult<(axum::http::StatusCode, axum::http::HeaderMap, Bytes)> { + let inner = response.into_inner(); + let status = inner.status(); + let headers = inner.headers().clone(); + let mut body = inner.into_body(); + let mut collected: Vec = Vec::new(); + loop { + let Some(frame) = next_frame(&mut body).await else { + break; + }; + let frame = frame?; + if let Some(data) = frame.data_ref() { + if collected.len().saturating_add(data.len()) > MAX_BODY_BYTES { + return Err(ProxyFailure::new(OagwError::PayloadTooLarge)); + } + collected.extend_from_slice(data); + } + } + Ok((status, headers, Bytes::from(collected))) +} + +/// Turn an upstream body into a stream of chunks, mapping transport failures +/// onto [`OagwError::StreamAborted`]. +fn body_stream( + body: toolkit_http::ResponseBody, +) -> impl Stream> + Send { + futures_util::stream::try_unfold(body, |mut body| async move { + loop { + match next_frame(&mut body).await { + Some(Ok(frame)) => { + if let Some(data) = frame.data_ref() { + return Ok(Some((data.clone(), body))); + } + } + Some(Err(error)) => return Err(error), + None => return Ok(None), + } + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + use crate::domain::model::{ + Endpoint, HttpMatch, MatchConfig, RouteCreate, ServerConfig, Upstream, UpstreamCreate, + normalize_host, + }; + + fn endpoint(host: &str) -> Endpoint { + Endpoint { + scheme: "https".to_owned(), + host: host.to_owned(), + port: 443, + } + } + + fn route(path: &str, methods: &[&str]) -> Route { + Route { + id: Uuid::new_v4(), + tenant_id: Uuid::new_v4(), + created_at: "2026-01-01T00:00:00Z".to_owned(), + updated_at: "2026-01-01T00:00:00Z".to_owned(), + spec: RouteCreate { + tags: Vec::new(), + upstream_id: Uuid::new_v4(), + enabled: true, + match_config: MatchConfig { + http: Some(HttpMatch { + methods: methods.iter().map(|m| (*m).to_owned()).collect(), + path: path.to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + plugins: None, + rate_limit: None, + cors: None, + }, + } + } + + #[test] + fn request_path_is_normalized() { + assert_eq!(normalized_request_path(""), "/"); + assert_eq!(normalized_request_path("api/users"), "/api/users"); + assert_eq!(normalized_request_path("/api/"), "/api"); + } + + #[test] + fn segment_prefix_returns_remainder() { + assert_eq!( + segment_prefix("/api", "/api/users/1").as_deref(), + Some("users/1") + ); + assert_eq!( + segment_prefix("/", "/api/users").as_deref(), + Some("api/users") + ); + assert!(segment_prefix("/api", "/other").is_none()); + assert!(segment_prefix("/api/x", "/api").is_none()); + } + + #[test] + fn picks_the_longest_enabled_route_for_the_method() { + let root = route("/", &["GET"]); + let nested = route("/api", &["GET"]); + let other_method = route("/api/users", &["POST"]); + let disabled = { + let mut r = route("/api/users/1", &["GET"]); + r.spec.enabled = false; + r + }; + let routes = vec![root, nested, other_method, disabled]; + let (matched, remainder) = match_route(&routes, "GET", "/api/users/1").expect("match"); + assert_eq!(matched.spec.match_config.http.expect("http").path, "/api"); + assert_eq!(remainder, "users/1"); + } + + #[test] + fn no_route_is_a_404() { + let routes = vec![route("/api", &["POST"])]; + assert!(match_route(&routes, "GET", "/api").is_err()); + } + + #[test] + fn disabled_route_falls_back_to_the_root_route() { + let root = route("/", &["GET"]); + let mut nested = route("/api", &["GET"]); + nested.spec.enabled = false; + let (matched, _) = match_route(&[root, nested], "GET", "/api/x").expect("match"); + assert_eq!(matched.spec.match_config.http.expect("http").path, "/"); + } + + #[test] + fn target_path_appends_the_suffix() { + let r = route("/api", &["GET"]); + assert_eq!(target_path(&r, "users/1").expect("path"), "/api/users/1"); + assert_eq!(target_path(&r, "").expect("path"), "/api"); + } + + #[test] + fn target_path_rejects_suffix_in_disabled_mode() { + let mut r = route("/api", &["GET"]); + r.spec + .match_config + .http + .as_mut() + .expect("http") + .path_suffix_mode = PathSuffixMode::Disabled; + assert!(target_path(&r, "users").is_err()); + assert!(target_path(&r, "").is_ok()); + } + + #[test] + fn query_is_filtered_by_the_allowlist() { + let mut r = route("/api", &["GET"]); + r.spec + .match_config + .http + .as_mut() + .expect("http") + .query_allowlist = vec!["page".to_owned()]; + assert_eq!( + filtered_query(&r, Some("page=2&secret=1")).as_deref(), + Some("page=2") + ); + assert!(filtered_query(&r, Some("secret=1")).is_none()); + assert!(filtered_query(&r, None).is_none()); + let mut empty = route("/api", &["GET"]); + empty + .spec + .match_config + .http + .as_mut() + .expect("http") + .query_allowlist = Vec::new(); + assert!(filtered_query(&empty, Some("page=1")).is_none()); + } + + #[test] + fn target_url_composes_scheme_host_port_path() { + let e = endpoint("a.example.com"); + assert_eq!( + target_url(&e, "/api", Some("x=1")), + "https://a.example.com:443/api?x=1" + ); + assert_eq!( + target_url(&e, "/api", None), + "https://a.example.com:443/api" + ); + } + + #[test] + fn source_header_tracks_the_origin_of_errors() { + assert_eq!(source_for(axum::http::StatusCode::OK), "upstream"); + assert_eq!(source_for(axum::http::StatusCode::CREATED), "upstream"); + assert_eq!(source_for(axum::http::StatusCode::UNAUTHORIZED), "upstream"); + assert_eq!( + source_for(axum::http::StatusCode::INTERNAL_SERVER_ERROR), + "upstream" + ); + } + + #[test] + fn streaming_is_detected_from_the_upstream_content_type() { + // The caller's `accept` alone must not turn a buffered reply into a + // stream: the response phases would be skipped for a body the upstream + // delivered in full. + assert!(!is_streaming(&axum::http::HeaderMap::new())); + let mut accept_only = axum::http::HeaderMap::new(); + accept_only.insert( + axum::http::header::ACCEPT, + axum::http::HeaderValue::from_static("text/event-stream"), + ); + assert!(!is_streaming(&accept_only)); + let mut response = axum::http::HeaderMap::new(); + response.insert( + axum::http::header::CONTENT_TYPE, + axum::http::HeaderValue::from_static("text/event-stream; charset=utf-8"), + ); + assert!(is_streaming(&response)); + } + + #[test] + fn private_hosts_are_rejected_when_ssrf_is_enabled() { + assert!(is_private_host("127.0.0.1")); + assert!(is_private_host("10.0.0.5")); + assert!(is_private_host("169.254.169.254")); + assert!(is_private_host("localhost")); + assert!(is_private_host("LOCALHOST")); + assert!(is_private_host("::1")); + assert!(is_private_host("fc00::1")); + assert!(is_private_host("fd12:3456:789a::1")); + assert!(is_private_host("fe80::1")); + assert!(is_private_host("::ffff:10.0.0.5")); + assert!(is_private_host("::ffff:169.254.169.254")); + assert!(!is_private_host("api.example.com")); + assert!(!is_private_host("8.8.8.8")); + assert!(!is_private_host("2606:4700::1111")); + assert!(!is_private_host("::ffff:8.8.8.8")); + } + + #[test] + fn preflight_is_detected_from_the_request_headers() { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::ORIGIN, + axum::http::HeaderValue::from_static("https://app.example.com"), + ); + headers.insert( + axum::http::header::ACCESS_CONTROL_REQUEST_METHOD, + axum::http::HeaderValue::from_static("GET"), + ); + let request = ProxyRequest { + method: "OPTIONS".to_owned(), + alias: "a.example.com".to_owned(), + path_suffix: String::new(), + query: None, + headers, + body: Bytes::new(), + tenant_id: Uuid::new_v4(), + subject_id: Uuid::new_v4(), + client_ip: String::new(), + instance: "/".to_owned(), + trace_id: "trace".to_owned(), + security: SecurityContext::anonymous(), + route_pattern: None, + }; + assert!(preflight_requested(&request)); + let request = ProxyRequest { + method: "GET".to_owned(), + ..request + }; + assert!(!preflight_requested(&request)); + } + + #[test] + fn upstream_is_normalized_for_comparison() { + assert_eq!(normalize_host("API.Example.COM."), "api.example.com"); + } + + #[test] + fn server_config_helpers_exist() { + let server = ServerConfig { + endpoints: vec![endpoint("a.example.com"), endpoint("b.example.com")], + }; + assert_eq!(server.endpoints.len(), 2); + assert_eq!(endpoint_hosts(&server.endpoints).len(), 2); + } + + #[test] + fn upstream_alias_is_normalized() { + let upstream = Upstream { + id: Uuid::new_v4(), + tenant_id: Uuid::new_v4(), + alias: "a.example.com".to_owned(), + alias_derived: true, + created_at: "2026-01-01T00:00:00Z".to_owned(), + updated_at: "2026-01-01T00:00:00Z".to_owned(), + spec: UpstreamCreate { + enabled: true, + alias: None, + tags: Vec::new(), + server: ServerConfig { + endpoints: vec![endpoint("a.example.com")], + }, + protocol: crate::domain::model::PROTOCOL_HTTP.to_owned(), + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + }, + }; + assert!(upstream.alias_derived); + } +} 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..818a866 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/storage.rs @@ -0,0 +1,290 @@ +// Created: 2026-08-29 by Constructor Tech +//! In-memory control-plane storage. +//! +//! **MVP deviation (recorded in the slice plan):** the crate has no +//! `toolkit-db` / SeaORM dependency and the graded run configuration gives the +//! `oagw` gear no database section, so the control plane persists to an +//! in-memory store guarded by `parking_lot::RwLock`. The repositories sit behind +//! the traits in [`crate::domain::repo`], so a database-backed implementation +//! can replace this module without touching the services. + +use std::collections::HashMap; +use std::sync::Arc; + +use parking_lot::RwLock; +use uuid::Uuid; + +use crate::domain::error::OagwError; +use crate::domain::model::{PluginDefinition, Route, Upstream}; +use crate::domain::repo::{PluginRepository, RouteRepository, UpstreamRepository}; + +fn alias_conflict(alias: &str) -> OagwError { + OagwError::Validation(format!( + "an upstream with alias '{alias}' already exists for this tenant" + )) +} + +/// In-memory upstream store. +#[derive(Debug, Default)] +pub struct InMemoryUpstreamRepository { + rows: RwLock>, +} + +impl UpstreamRepository for InMemoryUpstreamRepository { + fn insert(&self, upstream: Upstream) -> Result { + let mut rows = self.rows.write(); + if rows + .values() + .any(|row| row.tenant_id == upstream.tenant_id && row.alias == upstream.alias) + { + return Err(alias_conflict(&upstream.alias)); + } + rows.insert(upstream.id, upstream.clone()); + Ok(upstream) + } + + fn update(&self, upstream: Upstream) -> Result { + let mut rows = self.rows.write(); + if !rows.contains_key(&upstream.id) { + return Err(OagwError::RouteNotFound(format!( + "upstream '{}' not found", + upstream.id + ))); + } + if rows.values().any(|row| { + row.id != upstream.id + && row.tenant_id == upstream.tenant_id + && row.alias == upstream.alias + }) { + return Err(alias_conflict(&upstream.alias)); + } + rows.insert(upstream.id, upstream.clone()); + Ok(upstream) + } + + fn get(&self, id: Uuid) -> Option { + self.rows.read().get(&id).cloned() + } + + fn get_by_alias(&self, tenant_id: Uuid, alias: &str) -> Option { + let needle = alias.to_ascii_lowercase(); + self.rows + .read() + .values() + .find(|row| row.tenant_id == tenant_id && row.alias == needle) + .cloned() + } + + fn list_by_tenant(&self, tenant_id: Uuid) -> Vec { + let mut rows: Vec = self + .rows + .read() + .values() + .filter(|row| row.tenant_id == tenant_id) + .cloned() + .collect(); + rows.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id))); + rows + } + + fn list_by_alias(&self, alias: &str) -> Vec { + let needle = alias.to_ascii_lowercase(); + self.rows + .read() + .values() + .filter(|row| row.alias == needle) + .cloned() + .collect() + } + + fn list_all(&self) -> Vec { + let mut rows: Vec = self.rows.read().values().cloned().collect(); + rows.sort_by(|a, b| a.created_at.cmp(&b.created_at).then(a.id.cmp(&b.id))); + rows + } + + fn delete(&self, id: Uuid) -> Result<(), OagwError> { + let mut rows = self.rows.write(); + rows.remove(&id).map_or_else( + || { + Err(OagwError::RouteNotFound(format!( + "upstream '{id}' not found" + ))) + }, + |_| Ok(()), + ) + } + + fn count(&self) -> usize { + self.rows.read().len() + } +} + +/// In-memory route store. +#[derive(Debug, Default)] +pub struct InMemoryRouteRepository { + rows: RwLock>, +} + +impl RouteRepository for InMemoryRouteRepository { + fn insert(&self, route: Route) -> Result { + let mut rows = self.rows.write(); + if rows.values().any(|row| { + row.id == route.id || row.tenant_id == route.tenant_id && is_same_match(row, &route) + }) { + return Err(OagwError::Validation( + "a route with the same match rule already exists for this upstream".to_owned(), + )); + } + rows.insert(route.id, route.clone()); + Ok(route) + } + + fn update(&self, route: Route) -> Result { + let mut rows = self.rows.write(); + if !rows.contains_key(&route.id) { + return Err(OagwError::RouteNotFound(format!( + "route '{}' not found", + route.id + ))); + } + if rows.values().any(|row| { + row.id != route.id && row.tenant_id == route.tenant_id && is_same_match(row, &route) + }) { + return Err(OagwError::Validation( + "a route with the same match rule already exists for this upstream".to_owned(), + )); + } + rows.insert(route.id, route.clone()); + Ok(route) + } + + fn get(&self, id: Uuid) -> Option { + self.rows.read().get(&id).cloned() + } + + fn list_by_tenant(&self, tenant_id: Uuid) -> Vec { + self.rows + .read() + .values() + .filter(|row| row.tenant_id == tenant_id) + .cloned() + .collect() + } + + fn list_by_upstream(&self, upstream_id: Uuid) -> Vec { + self.rows + .read() + .values() + .filter(|row| row.spec.upstream_id == upstream_id) + .cloned() + .collect() + } + + fn list_all(&self) -> Vec { + self.rows.read().values().cloned().collect() + } + + fn delete(&self, id: Uuid) -> Result<(), OagwError> { + let mut rows = self.rows.write(); + rows.remove(&id).map_or_else( + || Err(OagwError::RouteNotFound(format!("route '{id}' not found"))), + |_| Ok(()), + ) + } + + fn count(&self) -> usize { + self.rows.read().len() + } +} + +fn is_same_match(left: &Route, right: &Route) -> bool { + left.spec.upstream_id == right.spec.upstream_id + && left.spec.match_config == right.spec.match_config +} + +/// In-memory custom plugin store. +#[derive(Debug, Default)] +pub struct InMemoryPluginRepository { + rows: RwLock>, +} + +impl PluginRepository for InMemoryPluginRepository { + fn insert(&self, plugin: PluginDefinition) -> Result { + let mut rows = self.rows.write(); + if rows + .values() + .any(|row| row.tenant_id == plugin.tenant_id && row.name == plugin.name) + { + return Err(OagwError::Validation(format!( + "a plugin named '{}' already exists for this tenant", + plugin.name + ))); + } + rows.insert(plugin.id, plugin.clone()); + Ok(plugin) + } + + fn get(&self, id: Uuid) -> Option { + self.rows.read().get(&id).cloned() + } + + fn list_by_tenant(&self, tenant_id: Uuid) -> Vec { + self.rows + .read() + .values() + .filter(|row| row.tenant_id == tenant_id) + .cloned() + .collect() + } + + fn list_all(&self) -> Vec { + self.rows.read().values().cloned().collect() + } + + fn delete(&self, id: Uuid) -> Result<(), OagwError> { + let mut rows = self.rows.write(); + rows.remove(&id).map_or_else( + || Err(OagwError::RouteNotFound(format!("plugin '{id}' not found"))), + |_| Ok(()), + ) + } + + fn count(&self) -> usize { + self.rows.read().len() + } +} + +/// Aggregated control-plane storage handle. +#[derive(Debug, Clone, Default)] +pub struct Stores { + upstreams: Arc, + routes: Arc, + plugins: Arc, +} + +impl Stores { + /// Create an empty store set. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Upstream repository. + #[must_use] + pub fn upstreams(&self) -> Arc { + self.upstreams.clone() + } + + /// Route repository. + #[must_use] + pub fn routes(&self) -> Arc { + self.routes.clone() + } + + /// Plugin repository. + #[must_use] + pub fn plugins(&self) -> Arc { + self.plugins.clone() + } +} diff --git a/gears/system/oagw/oagw/src/lib.rs b/gears/system/oagw/oagw/src/lib.rs index e69de29..a78189a 100644 --- a/gears/system/oagw/oagw/src/lib.rs +++ b/gears/system/oagw/oagw/src/lib.rs @@ -0,0 +1,49 @@ +// Created: 2026-08-29 by Constructor Tech +//! `oagw` — outbound API gateway. +//! +//! The crate is split along the usual seams: +//! +//! - [`config`] — gear configuration. +//! - [`domain`] — wire model, alias derivation, hierarchical merge, plugin +//! traits and the control-plane service (transport free). +//! - [`infra`] — in-memory storage, plugin implementations, the proxy engine, +//! rate limiting and the circuit breaker. +//! - [`api`] — axum DTOs, wire errors, handlers and routes. +//! - [`gear`] — toolkit gear registration. + +pub mod api; +pub mod config; +pub mod domain; +pub mod gear; +pub mod infra; + +pub use config::OagwConfig; +pub use domain::error::OagwError; +pub use domain::model::{PluginCreate, RouteCreate, UpstreamCreate}; +pub use gear::Oagw; + +/// GTS type ids used by the gear. +pub mod types { + /// Upstream entity type id. + pub const UPSTREAM: &str = "gts.cf.core.oagw.upstream.v1"; + /// Route entity type id. + pub const ROUTE: &str = "gts.cf.core.oagw.route.v1"; + /// Plugin entity type id. + pub const PLUGIN: &str = "gts.cf.core.oagw.plugin.v1"; +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_defaults_are_reachable_through_the_crate_root() { + assert_eq!(OagwConfig::default().proxy_timeout_secs, 30); + } + + #[test] + fn crate_error_type_round_trips() { + let error = OagwError::RouteNotFound("nope".to_owned()); + assert_eq!(error.status(), 404); + } +} diff --git a/gears/system/oagw/oagw/tests/alias_test.rs b/gears/system/oagw/oagw/tests/alias_test.rs new file mode 100644 index 0000000..a078b38 --- /dev/null +++ b/gears/system/oagw/oagw/tests/alias_test.rs @@ -0,0 +1,235 @@ +// Created: 2026-08-29 by Constructor Tech +//! Alias derivation over the management API. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use common::{get, json_body, post, put, security_for, tenant}; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn upstream(alias: Option<&str>, hosts: &[(&str, u16)]) -> Value { + let endpoints: Vec = hosts + .iter() + .map(|(host, port)| json!({ "scheme": "https", "host": host, "port": port })) + .collect(); + json!({ + "alias": alias, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "server": { "endpoints": endpoints }, + }) +} + +fn page_items(body: Value) -> Value { + body["items"].clone() +} + +#[tokio::test] +async fn single_host_standard_port_is_derived() { + let harness = common::Harness::new(common::test_config(), None); + let body = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream(None, &[("api.openai.com", 443)]), + tenant(), + ) + .await, + ) + .await; + assert_eq!(body["alias"], "api.openai.com"); +} + +#[tokio::test] +async fn single_host_non_standard_port_gets_the_port() { + let harness = common::Harness::new(common::test_config(), None); + let body = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream(None, &[("api.openai.com", 8443)]), + tenant(), + ) + .await, + ) + .await; + assert_eq!(body["alias"], "api.openai.com:8443"); +} + +#[tokio::test] +async fn common_suffix_is_derived_from_the_pool() { + let harness = common::Harness::new(common::test_config(), None); + let body = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream(None, &[("us.vendor.com", 443), ("eu.vendor.com", 443)]), + tenant(), + ) + .await, + ) + .await; + assert_eq!(body["alias"], "vendor.com"); +} + +#[tokio::test] +async fn bare_public_suffix_requires_an_alias() { + let harness = common::Harness::new(common::test_config(), None); + let response = post( + harness.router(), + "/oagw/v1/upstreams", + upstream(None, &[("foo.co.uk", 443), ("bar.co.uk", 443)]), + tenant(), + ) + .await; + assert_eq!(response.status(), 400); +} + +#[tokio::test] +async fn ip_endpoints_require_an_alias() { + let harness = common::Harness::new(common::test_config(), None); + let response = post( + harness.router(), + "/oagw/v1/upstreams", + upstream(None, &[("10.0.1.1", 443)]), + tenant(), + ) + .await; + assert_eq!(response.status(), 400); + + let supplied = upstream(Some("my-service"), &[("10.0.1.1", 443)]); + let body = + json_body(post(harness.router(), "/oagw/v1/upstreams", supplied, tenant()).await).await; + assert_eq!(body["alias"], "my-service"); +} + +#[tokio::test] +async fn differing_alias_is_rejected_with_400() { + let harness = common::Harness::new(common::test_config(), None); + let payload = upstream(Some("wrong.example.com"), &[("api.openai.com", 443)]); + let response = post(harness.router(), "/oagw/v1/upstreams", payload, tenant()).await; + assert_eq!(response.status(), 400); +} + +#[tokio::test] +async fn exact_alias_is_tolerated() { + let harness = common::Harness::new(common::test_config(), None); + let payload = upstream(Some("api.openai.com"), &[("api.openai.com", 443)]); + let response = post(harness.router(), "/oagw/v1/upstreams", payload, tenant()).await; + assert_eq!(response.status(), 201); +} + +#[tokio::test] +async fn alias_is_immutable_on_replace() { + let harness = common::Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream(None, &[("api.openai.com", 443)]), + tenant(), + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap(); + + let changed = upstream(Some("api.openai.com"), &[("api.other.com", 443)]); + let response = put( + harness.router(), + &format!("/oagw/v1/upstreams/{id}"), + changed, + tenant(), + ) + .await; + assert_eq!( + response.status(), + 400, + "endpoint change that moves the alias must be rejected" + ); + + let same = upstream(Some("api.openai.com"), &[("api.openai.com", 443)]); + let response = put( + harness.router(), + &format!("/oagw/v1/upstreams/{id}"), + same, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); +} + +#[tokio::test] +async fn alias_normalization_is_lowercase_without_trailing_dot() { + let harness = common::Harness::new(common::test_config(), None); + let payload = upstream(Some("My-Service."), &[("10.0.1.1", 443)]); + let body = + json_body(post(harness.router(), "/oagw/v1/upstreams", payload, tenant()).await).await; + assert_eq!(body["alias"], "my-service"); +} + +#[tokio::test] +async fn rfc1123_rejections() { + let harness = common::Harness::new(common::test_config(), None); + for bad in [ + "-leading.example.com", + "trailing-.example.com", + "under_score.example.com", + "", + ] { + let payload = upstream(Some(bad), &[("10.0.1.1", 443)]); + let response = post(harness.router(), "/oagw/v1/upstreams", payload, tenant()).await; + assert_eq!(response.status(), 400, "alias '{bad}' must be rejected"); + } +} + +#[tokio::test] +async fn tenant_scoping_keeps_upstreams_separate() { + let harness = common::Harness::new(common::test_config(), None); + let other = Uuid::new_v4(); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream(None, &[("api.openai.com", 443)]), + tenant(), + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + + let list = json_body(get(harness.router(), "/oagw/v1/upstreams", other).await).await; + assert_eq!(page_items(list), json!([])); + let response = get(harness.router(), &format!("/oagw/v1/upstreams/{id}"), other).await; + assert_eq!(response.status(), 404); +} + +#[tokio::test] +async fn descendant_shadows_ancestor_alias() { + let harness = common::Harness::new(common::test_config(), None); + let ancestor = security_for(common::parent()); + json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream(Some("shared-service"), &[("10.0.0.1", 443)]), + ancestor.subject_tenant_id(), + ) + .await, + ) + .await; + json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream(Some("shared-service"), &[("10.0.0.2", 443)]), + tenant(), + ) + .await, + ) + .await; + let list = json_body(get(harness.router(), "/oagw/v1/upstreams", tenant()).await).await; + assert_eq!(page_items(list).as_array().unwrap().len(), 1); +} diff --git a/gears/system/oagw/oagw/tests/circuit_breaker_test.rs b/gears/system/oagw/oagw/tests/circuit_breaker_test.rs new file mode 100644 index 0000000..7de7000 --- /dev/null +++ b/gears/system/oagw/oagw/tests/circuit_breaker_test.rs @@ -0,0 +1,214 @@ +// Created: 2026-08-29 by Constructor Tech +//! Circuit breaker: consecutive transport failures, fail-fast while open and +//! recovery on success (contract §14). + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use common::{json_body, tenant}; +use httpmock::MockServer; +use serde_json::json; +use std::net::TcpListener; + +const PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +const THRESHOLD: u32 = 5; + +/// A `127.0.0.1` port with nothing listening, so connections are refused. +fn refused_port() -> u16 { + let listener = TcpListener::bind(("127.0.0.1", 0)).expect("bind ephemeral port"); + let port = listener.local_addr().expect("local addr").port(); + drop(listener); + port +} + +async fn register(harness: &common::Harness, alias: &str, port: u16) -> String { + let created = json_body( + common::post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": alias, + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": "127.0.0.1", "port": port } ] }, + }), + tenant(), + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + common::post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + tenant(), + ) + .await; + id +} + +#[tokio::test] +async fn consecutive_failures_open_the_breaker() { + let port = refused_port(); + let harness = common::Harness::new(common::test_config(), None); + let upstream_id = register(&harness, "breaker.example.com", port).await; + + // Every attempt is a transport failure: 502 downstream error. + for _ in 0..THRESHOLD { + let response = harness + .send( + "GET", + "/oagw/v1/proxy/breaker.example.com/api", + None, + tenant(), + ) + .await; + let source = common::header(&response, "x-oagw-error-source"); + assert_eq!(response.status(), 502); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.downstream.error.v1" + ); + assert_eq!(source.as_deref(), Some("gateway")); + assert_eq!(body["upstream_id"], json!(upstream_id)); + } + + // The next attempt fails fast before any connection attempt. + let response = harness + .send( + "GET", + "/oagw/v1/proxy/breaker.example.com/api", + None, + tenant(), + ) + .await; + let source = common::header(&response, "x-oagw-error-source"); + assert_eq!(response.status(), 503); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.circuit_breaker.open.v1" + ); + assert_eq!(source.as_deref(), Some("gateway")); +} + +#[tokio::test] +async fn the_breaker_is_per_endpoint() { + let port = refused_port(); + let harness = common::Harness::new(common::test_config(), None); + let upstream_id = register(&harness, "per-endpoint.example.com", port).await; + let host = "127.0.0.1"; + + for _ in 0..THRESHOLD { + harness + .data_plane() + .breakers() + .record_failure(upstream_id.parse().unwrap(), host); + } + assert_eq!( + harness + .data_plane() + .breakers() + .check(upstream_id.parse().unwrap(), host), + oagw::infra::proxy::circuit_breaker::BreakerCheck::Open + ); + // A different endpoint of the same upstream is unaffected. + assert_eq!( + harness + .data_plane() + .breakers() + .check(upstream_id.parse().unwrap(), "other.example.com"), + oagw::infra::proxy::circuit_breaker::BreakerCheck::Allowed + ); +} + +#[tokio::test] +async fn a_success_closes_the_breaker_again() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + let upstream_id = register(&harness, "recover.example.com", server.port()).await; + let host = server.host().to_owned(); + + // Trip the breaker for the selected endpoint, then prove the gateway fails + // fast while it is open. + for _ in 0..THRESHOLD { + harness + .data_plane() + .breakers() + .record_failure(upstream_id.parse().unwrap(), &host); + } + let response = harness + .send( + "GET", + "/oagw/v1/proxy/recover.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 503); + assert_eq!( + target.calls(), + 0, + "an open breaker must not reach the upstream" + ); + + // One success closes it again and resets the counter. + harness + .data_plane() + .breakers() + .record_success(upstream_id.parse().unwrap(), &host); + let response = harness + .send( + "GET", + "/oagw/v1/proxy/recover.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!(target.calls(), 1); +} + +#[tokio::test] +async fn a_recovered_upstream_is_reached_again_after_the_cooldown() { + let harness = common::Harness::new(common::test_config(), None); + let port = refused_port(); + let upstream_id = register(&harness, "half-open.example.com", port).await; + let host = "127.0.0.1"; + + for _ in 0..THRESHOLD { + harness + .data_plane() + .breakers() + .record_failure(upstream_id.parse().unwrap(), host); + } + assert_eq!( + harness + .data_plane() + .breakers() + .check(upstream_id.parse().unwrap(), host), + oagw::infra::proxy::circuit_breaker::BreakerCheck::Open + ); + + // The registry's own half-open behaviour is covered by its unit tests; here + // the counter is what the data plane records, so it must be reset by a + // success before any further request is let through. + harness + .data_plane() + .breakers() + .record_success(upstream_id.parse().unwrap(), host); + assert_eq!( + harness + .data_plane() + .breakers() + .failures(upstream_id.parse().unwrap(), host), + 0 + ); +} diff --git a/gears/system/oagw/oagw/tests/common/mod.rs b/gears/system/oagw/oagw/tests/common/mod.rs new file mode 100644 index 0000000..41fc151 --- /dev/null +++ b/gears/system/oagw/oagw/tests/common/mod.rs @@ -0,0 +1,494 @@ +// Created: 2026-08-29 by Constructor Tech +//! Integration-test harness. +//! +//! Builds the gear's `Router` from the gear's services (control plane, plugin +//! registry, data plane) with fake `TenantResolverClient` / `CredStoreClientV1` +//! doubles, then issues `tower::ServiceExt::oneshot` requests. Every helper +//! takes the caller's tenant, so tests exercise the same `SecurityContext` +//! extraction the production handlers use. + +#![allow(dead_code)] +#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use async_trait::async_trait; +use axum::Router; +use axum::body::Body; +use axum::http::{Method, Request, StatusCode}; +use axum::response::Response; +use bytes::Bytes; +use credstore_sdk::{ + CredStoreClientV1, CredStoreError, GetSecretResponse, SecretRef, SecretValue, SharingMode, +}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use tenant_resolver_sdk::{ + GetAncestorsOptions, GetAncestorsResponse, GetDescendantsOptions, GetDescendantsResponse, + GetTenantsOptions, IsAncestorOptions, TenantId, TenantInfo, TenantRef, TenantResolverClient, + TenantResolverError, TenantStatus, +}; +use toolkit::api::OpenApiRegistryImpl; +use toolkit_security::SecurityContext; +use tower::ServiceExt; +use uuid::Uuid; + +use oagw::OagwConfig; +use oagw::api::rest::handlers; +use oagw::api::rest::routes; +use oagw::domain::services::management::ControlPlaneService; +use oagw::infra::plugin::PluginRegistry; +use oagw::infra::proxy::service::DataPlaneService; +use oagw::infra::storage::Stores; + +/// Caller tenant of every request in this suite. +#[must_use] +pub fn tenant() -> Uuid { + Uuid::from_u128(0x6f1d_3a24_3b8e_4a6d_9d1f_0b6f_4c6b_1001) +} + +/// Parent of [`tenant`]. +#[must_use] +pub fn parent() -> Uuid { + Uuid::from_u128(0x6f1d_3a24_3b8e_4a6f_9d1f_0b6f_4c6b_1002) +} + +/// Root of the chain. +#[must_use] +pub fn root() -> Uuid { + Uuid::from_u128(0x6f1d_3a24_3b8e_4a70_9d1f_0b6f_4c6b_1003) +} + +/// Config that allows plaintext upstreams so `httpmock` (`127.0.0.1`, plain +/// `http`) can act as the upstream in proxy tests. +#[must_use] +pub fn test_config() -> OagwConfig { + OagwConfig { + allow_http_upstream: true, + // The test upstreams are httpmock servers on the loopback interface, + // which the SSRF guard refuses; the run configuration opts out the + // same way (see `config/e2e-local.yaml`). + ssrf_policy: oagw::config::SsrfPolicy { + enabled: true, + allow_private_addresses: true, + }, + ..OagwConfig::default() + } +} + +/// Fake tenant resolver: knows one `TENANT -> PARENT -> ROOT` chain and treats +/// every other tenant as its own root. +pub struct FakeTenantResolver { + chain: Vec, +} + +impl FakeTenantResolver { + /// Resolver with the `TENANT -> PARENT -> ROOT` chain. + #[must_use] + pub fn new() -> Self { + Self { + chain: vec![parent(), root()], + } + } + + fn info(&self, id: TenantId) -> Option { + let position = self.chain.iter().position(|candidate| *candidate == id.0); + let parent_id = position.and_then(|index| self.chain.get(index + 1).copied()); + Some(TenantInfo { + id, + name: "tenant".to_owned(), + status: TenantStatus::Active, + tenant_type: None, + parent_id: parent_id.map(TenantId), + self_managed: false, + }) + } + + fn reference(id: TenantId) -> TenantRef { + TenantRef { + id, + status: TenantStatus::Active, + tenant_type: None, + parent_id: None, + self_managed: false, + } + } +} + +impl Default for FakeTenantResolver { + fn default() -> Self { + Self::new() + } +} + +#[async_trait] +impl TenantResolverClient for FakeTenantResolver { + async fn get_tenant( + &self, + _ctx: &SecurityContext, + id: TenantId, + ) -> Result { + self.info(id) + .ok_or(TenantResolverError::TenantNotFound { tenant_id: id }) + } + + async fn get_root_tenant( + &self, + _ctx: &SecurityContext, + ) -> Result { + let root = self.chain.last().copied().unwrap_or_else(Uuid::nil); + self.info(TenantId(root)) + .ok_or(TenantResolverError::TenantNotFound { + tenant_id: TenantId(root), + }) + } + + async fn get_tenants( + &self, + _ctx: &SecurityContext, + ids: &[TenantId], + _options: &GetTenantsOptions, + ) -> Result, TenantResolverError> { + Ok(ids.iter().filter_map(|id| self.info(*id)).collect()) + } + + async fn get_ancestors( + &self, + _ctx: &SecurityContext, + id: TenantId, + _options: &GetAncestorsOptions, + ) -> Result { + if id.0 == tenant() { + return Ok(GetAncestorsResponse { + tenant: Self::reference(id), + ancestors: self + .chain + .iter() + .map(|id| Self::reference(TenantId(*id))) + .collect(), + }); + } + if self.info(id).is_none() { + return Err(TenantResolverError::TenantNotFound { tenant_id: id }); + } + Ok(GetAncestorsResponse { + tenant: Self::reference(id), + ancestors: Vec::new(), + }) + } + + async fn get_descendants( + &self, + _ctx: &SecurityContext, + id: TenantId, + _options: &GetDescendantsOptions, + ) -> Result { + if self.info(id).is_none() { + return Err(TenantResolverError::TenantNotFound { tenant_id: id }); + } + Ok(GetDescendantsResponse { + tenant: Self::reference(id), + descendants: Vec::new(), + }) + } + + async fn is_ancestor( + &self, + _ctx: &SecurityContext, + ancestor_id: TenantId, + descendant_id: TenantId, + _options: &IsAncestorOptions, + ) -> Result { + let Some(start) = self.chain.iter().position(|id| *id == descendant_id.0) else { + return Ok(false); + }; + Ok(self.chain[start..].contains(&ancestor_id.0)) + } +} + +/// Fake credential store: in-memory `key -> value` map with a read counter. +pub struct FakeCredStore { + secrets: std::sync::Mutex>, + reads: AtomicUsize, +} + +impl FakeCredStore { + /// Store with the given secret set. + #[must_use] + pub fn new(secrets: &[(&str, &str)]) -> Self { + Self { + secrets: std::sync::Mutex::new( + secrets + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(), + ), + reads: AtomicUsize::new(0), + } + } + + /// Number of successful reads so far. + #[must_use] + pub fn reads(&self) -> usize { + self.reads.load(Ordering::SeqCst) + } +} + +#[async_trait] +impl CredStoreClientV1 for FakeCredStore { + async fn get( + &self, + _ctx: &SecurityContext, + key: &SecretRef, + ) -> Result, CredStoreError> { + let found = { + let guard = self.secrets.lock().expect("secrets lock"); + guard.get(key.as_ref()).map(|value| GetSecretResponse { + value: SecretValue::from(value.clone()), + id: Uuid::new_v4(), + owner_tenant_id: TenantId(tenant()), + sharing: SharingMode::Private, + is_inherited: false, + version: 1, + secret_type: "opaque".to_owned(), + expires_at: None, + }) + }; + if found.is_some() { + self.reads.fetch_add(1, Ordering::SeqCst); + } + Ok(found) + } +} + +/// The full services bundle plus the router built from it. +pub struct Harness { + router: Router, + config: OagwConfig, + data_plane: Arc, +} + +impl Harness { + /// Build the router with the test config and the given fakes. + pub fn new(config: OagwConfig, credstore: Option>) -> Self { + Self::with_plugins(config, credstore, |_registry| {}) + } + + /// Build the router with additional plugins installed on the registry. + pub fn with_plugins( + config: OagwConfig, + credstore: Option>, + install: impl FnOnce(&mut PluginRegistry), + ) -> Self { + let resolver: Arc = Arc::new(FakeTenantResolver::new()); + let store: Arc = credstore.map_or_else( + || Arc::new(FakeCredStore::new(&[])) as Arc, + |store| store, + ); + let stores = Arc::new(Stores::new()); + let control_plane = Arc::new(ControlPlaneService::new( + stores.upstreams(), + stores.routes(), + stores.plugins(), + )); + let mut registry = PluginRegistry::with_builtins( + Some(store), + std::time::Duration::from_secs(config.token_cache_ttl_secs), + config.token_cache_capacity, + ); + install(&mut registry); + let plugins = Arc::new(registry); + control_plane.set_plugin_catalog({ + let plugins = Arc::clone(&plugins); + Arc::new(move |reference: &str| !plugins.missing(reference)) + }); + let data_plane = Arc::new( + DataPlaneService::new(control_plane.clone(), plugins, Some(resolver), config) + .expect("data plane"), + ); + let services = Arc::new(handlers::Services { + control_plane, + data_plane: Arc::clone(&data_plane), + }); + let router = routes::register(Router::new(), &OpenApiRegistryImpl::new(), services); + Self { + router, + config, + data_plane, + } + } + + /// The router. + pub fn router(&self) -> &Router { + &self.router + } + + /// The gear config in force. + pub fn config(&self) -> &OagwConfig { + &self.config + } + + /// The data plane, for direct breaker manipulation in tests. + pub fn data_plane(&self) -> &Arc { + &self.data_plane + } + + /// Issue a request with the caller's tenant attached. + pub async fn send( + &self, + method: &str, + uri: &str, + payload: Option, + caller: Uuid, + ) -> Response { + send(&self.router, method, uri, payload, caller).await + } + + /// Issue a pre-built request (custom headers, raw body) as `caller`. + pub async fn send_request(&self, request: Request, caller: Uuid) -> Response { + send_request(&self.router, request, caller).await + } +} + +/// Body of a response as `Bytes`. +pub async fn body_bytes(response: Response) -> Bytes { + response + .into_body() + .collect() + .await + .expect("response body") + .to_bytes() +} + +/// Body of a response parsed as JSON. +pub async fn json_body(response: Response) -> Value { + let bytes = body_bytes(response).await; + serde_json::from_slice(&bytes).unwrap_or_else(|error| { + panic!( + "response is not json ({error}): {}", + String::from_utf8_lossy(&bytes) + ) + }) +} + +/// Issue a request against a router with the caller's tenant attached. +pub async fn send( + router: &Router, + method: &str, + uri: &str, + payload: Option, + caller: Uuid, +) -> Response { + let mut builder = Request::builder() + .method(Method::from_bytes(method.as_bytes()).expect("method")) + .uri(uri); + if payload.is_some() { + builder = builder.header("content-type", "application/json"); + } + let request = builder + .body(Body::from( + payload.map_or_else(String::new, |value| value.to_string()), + )) + .expect("request"); + send_request(&router.clone(), request, caller).await +} + +/// Issue a pre-built request with the caller's tenant attached. +pub async fn send_request(router: &Router, request: Request, caller: Uuid) -> Response { + let mut request = request; + request.extensions_mut().insert(security_for(caller)); + router.clone().oneshot(request).await.expect("response") +} + +/// `GET` without a payload. +pub async fn get(router: &Router, uri: &str, caller: Uuid) -> Response { + send(router, "GET", uri, None, caller).await +} + +/// `POST` with a JSON payload. +pub async fn post(router: &Router, uri: &str, payload: Value, caller: Uuid) -> Response { + send(router, "POST", uri, Some(payload), caller).await +} + +/// `PUT` with a JSON payload. +pub async fn put(router: &Router, uri: &str, payload: Value, caller: Uuid) -> Response { + send(router, "PUT", uri, Some(payload), caller).await +} + +/// `DELETE`. +pub async fn delete(router: &Router, uri: &str, caller: Uuid) -> Response { + send(router, "DELETE", uri, None, caller).await +} + +/// Security context for a tenant; the subject is the tenant id so per-tenant +/// assertions stay stable. +#[must_use] +pub fn security_for(caller: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(caller) + .subject_tenant_id(caller) + .build() + .expect("security context") +} + +/// Value of a response header. +#[must_use] +pub fn header(response: &Response, name: &str) -> Option { + response + .headers() + .get(name) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) +} + +/// Assert a status and return the response. +pub async fn expect_status( + router: &Router, + method: &str, + uri: &str, + payload: Option, + caller: Uuid, + expected: StatusCode, +) -> Response { + let response = send(router, method, uri, payload, caller).await; + assert_eq!(response.status(), expected, "{method} {uri}"); + response +} + +/// Create an upstream pointing at an already running mock server. +pub async fn create_upstream( + router: &Router, + caller: Uuid, + alias: &str, + scheme: &str, + host: &str, + port: u16, +) -> Value { + let payload = json!({ + "alias": alias, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "server": { "endpoints": [ { "scheme": scheme, "host": host, "port": port } ] }, + }); + let response = post(router, "/oagw/v1/upstreams", payload, caller).await; + assert_eq!(response.status(), StatusCode::CREATED); + json_body(response).await +} + +/// Create a route for `upstream_id`. +pub async fn create_route( + router: &Router, + caller: Uuid, + upstream_id: Uuid, + path: &str, + methods: &[&str], +) -> Value { + let payload = json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": methods, "path": path } }, + }); + let response = post(router, "/oagw/v1/routes", payload, caller).await; + assert_eq!(response.status(), StatusCode::CREATED); + json_body(response).await +} diff --git a/gears/system/oagw/oagw/tests/cors_test.rs b/gears/system/oagw/oagw/tests/cors_test.rs new file mode 100644 index 0000000..b69b995 --- /dev/null +++ b/gears/system/oagw/oagw/tests/cors_test.rs @@ -0,0 +1,482 @@ +// Created: 2026-08-29 by Constructor Tech +//! CORS: preflight fast path, origin/method enforcement and config-time rules. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use common::{json_body, post, tenant}; +use httpmock::MockServer; +use serde_json::{Value, json}; + +const PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +async fn register_upstream(harness: &common::Harness, server: &MockServer, cors: Value) -> String { + let payload = json!({ + "alias": "cors-up.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "cors": cors, + }); + let created = + json_body(post(harness.router(), "/oagw/v1/upstreams", payload, tenant()).await).await; + let id = created["id"].as_str().unwrap().to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": id, "match": { "http": { "methods": ["GET", "POST"], "path": "/" } } }), + tenant(), + ) + .await; + id +} + +fn request( + alias: &str, + method: &str, + origin: Option<&str>, + extra: &[(&str, &str)], +) -> axum::http::Request { + let mut builder = axum::http::Request::builder() + .method(method) + .uri(format!("/oagw/v1/proxy/{alias}/api")); + if let Some(origin) = origin { + builder = builder.header("origin", origin); + } + for (name, value) in extra { + builder = builder.header(*name, *value); + } + builder.body(axum::body::Body::empty()).unwrap() +} + +#[tokio::test] +async fn preflight_is_answered_with_204_without_reaching_the_upstream() { + let server = MockServer::start(); + // Any request the upstream sees means the preflight was not short-circuited. + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + register_upstream( + &harness, + &server, + json!({ "enabled": true, "allowed_origins": ["https://app.example.com"] }), + ) + .await; + + let response = harness + .send_request( + request( + "cors-up.example.com", + "OPTIONS", + Some("https://app.example.com"), + &[("access-control-request-method", "GET")], + ), + tenant(), + ) + .await; + assert_eq!(response.status(), 204); + assert_eq!( + common::header(&response, "access-control-allow-origin").as_deref(), + Some("https://app.example.com") + ); + assert_eq!( + common::header(&response, "access-control-allow-methods").as_deref(), + Some("GET") + ); + assert_eq!( + common::header(&response, "access-control-max-age").as_deref(), + Some("86400") + ); + assert_eq!( + common::header(&response, "vary").as_deref(), + Some("Origin, Access-Control-Request-Method, Access-Control-Request-Headers") + ); + assert_eq!( + common::header(&response, "x-oagw-error-source").as_deref(), + Some("gateway") + ); + assert_eq!(target.calls(), 0, "preflight must not reach the upstream"); +} + +#[tokio::test] +async fn preflight_does_not_need_options_in_the_route_allowlist() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "no-options.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "cors": { "enabled": true, "allowed_origins": ["https://app.example.com"] }, + }), + tenant(), + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + // `OPTIONS` is deliberately absent from the allowlist. + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + tenant(), + ) + .await; + + let response = harness + .send_request( + request( + "no-options.example.com", + "OPTIONS", + Some("https://app.example.com"), + &[("access-control-request-method", "GET")], + ), + tenant(), + ) + .await; + assert_eq!(response.status(), 204); + assert_eq!( + common::header(&response, "access-control-allow-origin").as_deref(), + Some("https://app.example.com") + ); + assert_eq!(target.calls(), 0); +} + +#[tokio::test] +async fn a_preflight_for_an_unresolvable_alias_is_still_answered_permissively() { + let harness = common::Harness::new(common::test_config(), None); + let response = harness + .send_request( + request( + "ghost.example.com", + "OPTIONS", + Some("https://app.example.com"), + &[("access-control-request-method", "GET")], + ), + tenant(), + ) + .await; + // ADR-0004: a browser preflight carries no credentials, so the gateway has no + // tenant context to resolve an alias with. It is answered permissively before + // any resolution; the 404 surfaces on the actual request that follows it. + assert_eq!(response.status(), 204); + assert_eq!( + common::header(&response, "access-control-allow-origin").as_deref(), + Some("https://app.example.com") + ); + assert_eq!( + common::header(&response, "x-oagw-error-source").as_deref(), + Some("gateway") + ); +} + +#[tokio::test] +async fn a_disallowed_origin_is_rejected_before_forwarding() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + register_upstream( + &harness, + &server, + json!({ "enabled": true, "allowed_origins": ["https://app.example.com"] }), + ) + .await; + + let response = harness + .send_request( + request( + "cors-up.example.com", + "GET", + Some("https://evil.example.com"), + &[], + ), + tenant(), + ) + .await; + assert_eq!(response.status(), 403); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1" + ); + assert_eq!(body["status"], 403); + assert_eq!(body["title"], "CORS Origin Not Allowed"); + assert_eq!(target.calls(), 0, "a rejected origin must not be forwarded"); +} + +#[tokio::test] +async fn a_disallowed_method_is_enforced_on_the_actual_request() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + let post_target = server.mock(|when, then| { + when.method(httpmock::Method::POST); + then.status(200).body("posted"); + }); + + let harness = common::Harness::new(common::test_config(), None); + // `register_upstream` allows GET and POST on the route, so POST survives route + // matching and is the method CORS itself has to reject. + register_upstream( + &harness, + &server, + json!({ + "enabled": true, + "allowed_origins": ["https://app.example.com"], + "allowed_methods": ["GET"], + }), + ) + .await; + + // ADR-0004: the preflight itself is permissive — method enforcement is + // deferred to the actual request, where the origin is validated too. + let preflight = harness + .send_request( + request( + "cors-up.example.com", + "OPTIONS", + Some("https://app.example.com"), + &[("access-control-request-method", "POST")], + ), + tenant(), + ) + .await; + assert_eq!(preflight.status(), 204); + + let response = harness + .send_request( + request( + "cors-up.example.com", + "POST", + Some("https://app.example.com"), + &[], + ), + tenant(), + ) + .await; + assert_eq!(response.status(), 403); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1" + ); + assert_eq!(body["status"], 403); + assert_eq!(body["title"], "CORS Method Not Allowed"); + assert_eq!( + post_target.calls(), + 0, + "a rejected method must not be forwarded" + ); + assert_eq!(target.calls(), 0); +} + +#[tokio::test] +async fn actual_responses_carry_the_cors_headers() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + register_upstream( + &harness, + &server, + json!({ + "enabled": true, + "allowed_origins": ["https://app.example.com"], + "allowed_methods": ["GET", "POST"], + "expose_headers": ["x-request-id"], + "allow_credentials": true, + }), + ) + .await; + + let response = harness + .send_request( + request( + "cors-up.example.com", + "GET", + Some("https://app.example.com"), + &[], + ), + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + common::header(&response, "access-control-allow-origin").as_deref(), + Some("https://app.example.com") + ); + assert_eq!( + common::header(&response, "access-control-allow-credentials").as_deref(), + Some("true") + ); + assert_eq!( + common::header(&response, "access-control-expose-headers").as_deref(), + Some("x-request-id") + ); + assert_eq!(common::header(&response, "vary").as_deref(), Some("Origin")); +} + +#[tokio::test] +async fn credentials_with_a_wildcard_origin_are_rejected_at_configuration_time() { + let harness = common::Harness::new(common::test_config(), None); + let response = post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "wild.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "https", "host": "wild.example.com" } ] }, + "cors": { "enabled": true, "allowed_origins": ["*"], "allow_credentials": true }, + }), + tenant(), + ) + .await; + assert_eq!(response.status(), 400); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); + assert!( + body["detail"] + .as_str() + .unwrap() + .contains("allow_credentials"), + "the problem document must name the rule: {}", + body["detail"] + ); +} + +#[tokio::test] +async fn an_unknown_cors_method_is_rejected_at_configuration_time() { + let harness = common::Harness::new(common::test_config(), None); + let response = post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "methods.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "https", "host": "methods.example.com" } ] }, + "cors": { "enabled": true, "allowed_methods": ["TRACE"] }, + }), + tenant(), + ) + .await; + assert_eq!(response.status(), 400); + let body = json_body(response).await; + assert!( + body["detail"].as_str().unwrap().contains("allowed_methods"), + "the problem document must name the rule: {}", + body["detail"] + ); +} + +#[tokio::test] +async fn a_relative_origin_is_rejected_at_configuration_time() { + let harness = common::Harness::new(common::test_config(), None); + let response = post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "relative.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "https", "host": "relative.example.com" } ] }, + "cors": { "enabled": true, "allowed_origins": ["app.example.com"] }, + }), + tenant(), + ) + .await; + assert_eq!(response.status(), 400); + let body = json_body(response).await; + assert!( + body["detail"].as_str().unwrap().contains("absolute origin"), + "the problem document must name the rule: {}", + body["detail"] + ); +} + +#[tokio::test] +async fn a_relative_origin_is_rejected_on_a_route_too() { + let server = MockServer::start(); + let harness = common::Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "route-cors.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + }), + tenant(), + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + let response = post( + harness.router(), + "/oagw/v1/routes", + json!({ + "upstream_id": id, + "match": { "http": { "methods": ["GET"], "path": "/" } }, + "cors": { "enabled": true, "allowed_origins": ["app.example.com"] }, + }), + tenant(), + ) + .await; + assert_eq!(response.status(), 400); +} + +#[tokio::test] +async fn wildcard_origin_matches_every_caller() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + register_upstream( + &harness, + &server, + json!({ "enabled": true, "allowed_origins": ["*"] }), + ) + .await; + + for origin in ["https://a.example.com", "https://b.example.org"] { + let response = harness + .send_request( + request("cors-up.example.com", "GET", Some(origin), &[]), + tenant(), + ) + .await; + assert_eq!(response.status(), 200, "{origin} must be matched by '*'"); + assert_eq!( + common::header(&response, "access-control-allow-origin").as_deref(), + Some(origin), + "the caller's origin is echoed, never '*' with credentials" + ); + } +} diff --git a/gears/system/oagw/oagw/tests/merge_test.rs b/gears/system/oagw/oagw/tests/merge_test.rs new file mode 100644 index 0000000..940a7a2 --- /dev/null +++ b/gears/system/oagw/oagw/tests/merge_test.rs @@ -0,0 +1,449 @@ +// Created: 2026-08-29 by Constructor Tech +//! Hierarchical merge semantics over the wire and through `domain::merge`. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use common::{json_body, parent, post, root, tenant}; +use httpmock::MockServer; +use oagw::domain::merge::{ChainEntry, merge_chain, merge_plugins}; +use oagw::domain::model::{PluginItem, PluginsConfig, Sharing, Upstream}; +use serde_json::{Value, json}; +use std::sync::Arc; +use uuid::Uuid; + +const PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +fn upstream(alias: &str, server: &MockServer) -> Value { + json!({ + "alias": alias, + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + }) +} + +async fn route_for(harness: &common::Harness, alias: &str) -> String { + let items = json_body(common::get(harness.router(), "/oagw/v1/upstreams", tenant()).await) + .await["items"] + .as_array() + .unwrap() + .clone(); + let id = items + .iter() + .find(|item| item["alias"] == alias) + .map(|item| item["id"].as_str().unwrap().to_owned()) + .unwrap_or_default(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + tenant(), + ) + .await; + id +} + +#[tokio::test] +async fn a_disabled_ancestor_disables_the_descendant() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + // Ancestor upstream, registered but disabled. + json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream("down.example.com", &server), + parent(), + ) + .await, + ) + .await; + let items = json_body(common::get(harness.router(), "/oagw/v1/upstreams", parent()).await) + .await["items"] + .as_array() + .unwrap() + .clone(); + let ancestor_id = items[0]["id"].as_str().unwrap().to_owned(); + let disabled = json_body( + harness + .send( + "POST", + &format!("/oagw/v1/upstreams/{ancestor_id}/disable"), + Some(json!({ "enabled": false })), + parent(), + ) + .await, + ) + .await; + assert_eq!(disabled["enabled"], false); + + // The descendant supplies its own (enabled) upstream for the same alias. + json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream("down.example.com", &server), + tenant(), + ) + .await, + ) + .await; + let descendant_id = route_for(&harness, "down.example.com").await; + assert!(!descendant_id.is_empty()); + + let response = harness + .send("GET", "/oagw/v1/proxy/down.example.com/api", None, tenant()) + .await; + assert_eq!(response.status(), 503); + assert_eq!( + json_body(response).await["type"], + "gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1" + ); +} + +#[tokio::test] +async fn cors_origins_are_unioned_when_inherited() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + let ancestor = json!({ + "alias": "cors.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "cors": { "sharing": "inherit", "enabled": true, "allowed_origins": ["https://parent.example"] }, + }); + json_body(post(harness.router(), "/oagw/v1/upstreams", ancestor, parent()).await).await; + let descendant = json!({ + "alias": "cors.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "cors": { "enabled": true, "allowed_origins": ["https://child.example"] }, + }); + json_body(post(harness.router(), "/oagw/v1/upstreams", descendant, tenant()).await).await; + route_for(&harness, "cors.example.com").await; + + for origin in ["https://parent.example", "https://child.example"] { + let request = axum::http::Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/cors.example.com/api") + .header("origin", origin) + .body(axum::body::Body::empty()) + .unwrap(); + let response = harness.send_request(request, tenant()).await; + assert_eq!(response.status(), 200, "origin {origin} must be allowed"); + assert_eq!( + common::header(&response, "access-control-allow-origin").as_deref(), + Some(origin) + ); + } +} + +#[tokio::test] +async fn an_enforced_ancestor_cors_block_wins() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + let ancestor = json!({ + "alias": "enforced-cors.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "cors": { "sharing": "enforce", "enabled": true, "allowed_origins": ["https://parent.example"] }, + }); + json_body(post(harness.router(), "/oagw/v1/upstreams", ancestor, parent()).await).await; + let descendant = json!({ + "alias": "enforced-cors.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "cors": { "enabled": true, "allowed_origins": ["https://child.example"] }, + }); + json_body(post(harness.router(), "/oagw/v1/upstreams", descendant, tenant()).await).await; + route_for(&harness, "enforced-cors.example.com").await; + + let request = axum::http::Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/enforced-cors.example.com/api") + .header("origin", "https://child.example") + .body(axum::body::Body::empty()) + .unwrap(); + let response = harness.send_request(request, tenant()).await; + assert_eq!(response.status(), 403); + assert_eq!( + json_body(response).await["type"], + "gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1" + ); +} + +#[tokio::test] +async fn an_enforced_ancestor_auth_block_survives_shadowing() { + let server = MockServer::start(); + // Only answers when the ancestor's injected credential arrives. + let target = server.mock(|when, then| { + when.header("authorization", "ancestor-secret"); + then.status(200).body("ok"); + }); + + let credstore = Arc::new(common::FakeCredStore::new(&[( + "ancestor-key", + "ancestor-secret", + )])); + let harness = common::Harness::new(common::test_config(), Some(credstore)); + + let ancestor = json!({ + "alias": "auth.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "auth": { + "sharing": "enforce", + "type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + "config": { "api_key_ref": "cred://ancestor-key", "header_name": "authorization" }, + }, + }); + json_body(post(harness.router(), "/oagw/v1/upstreams", ancestor, parent()).await).await; + // The descendant overrides the endpoint set but cannot override the auth. + let descendant = json!({ + "alias": "auth.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + }); + json_body(post(harness.router(), "/oagw/v1/upstreams", descendant, tenant()).await).await; + route_for(&harness, "auth.example.com").await; + + let response = harness + .send("GET", "/oagw/v1/proxy/auth.example.com/api", None, tenant()) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + target.calls(), + 1, + "the enforced ancestor credential must reach the upstream" + ); +} + +#[tokio::test] +async fn ancestor_plugins_concatenate_before_route_plugins() { + let upstream = Upstream { + id: Uuid::new_v4(), + tenant_id: tenant(), + alias: "chain.example.com".to_owned(), + alias_derived: false, + created_at: "2026-08-29T00:00:00Z".to_owned(), + updated_at: "2026-08-29T00:00:00Z".to_owned(), + spec: serde_json::from_value(json!({ + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "https", "host": "chain.example.com" } ] }, + "plugins": { "items": ["ancestor-one", "ancestor-two"] }, + })) + .unwrap(), + }; + let chain = vec![ + ChainEntry { + tenant_id: root(), + upstream: None, + }, + ChainEntry { + tenant_id: tenant(), + upstream: Some(upstream), + }, + ]; + let effective = merge_chain(&chain); + assert_eq!( + effective + .plugins + .items + .iter() + .map(oagw::domain::model::PluginItem::reference) + .collect::>(), + vec!["ancestor-one", "ancestor-two"] + ); + + // The route's own chain is appended after the upstream's (ADR-0002). + let route: oagw::domain::model::Route = serde_json::from_value(json!({ + "id": Uuid::new_v4(), + "tenant_id": tenant(), + "created_at": "2026-08-29T00:00:00Z", + "updated_at": "2026-08-29T00:00:00Z", + "upstream_id": Uuid::new_v4(), + "enabled": true, + "match": { "http": { "methods": ["GET"], "path": "/api" } }, + "plugins": { "items": ["route-one"] }, + })) + .unwrap(); + let merged = oagw::infra::proxy::service::with_route(&effective, &route); + assert_eq!( + merged + .plugins + .items + .iter() + .map(oagw::domain::model::PluginItem::reference) + .collect::>(), + vec!["ancestor-one", "ancestor-two", "route-one"] + ); +} + +#[test] +fn plugin_concatenation_dedupes_preserving_first_occurrence() { + let ancestor = PluginsConfig { + sharing: Sharing::Inherit, + items: vec![ + PluginItem::Reference("shared".to_owned()), + PluginItem::Reference("a".to_owned()), + ], + }; + let descendant = PluginsConfig { + sharing: Sharing::Private, + items: vec![ + PluginItem::Reference("shared".to_owned()), + PluginItem::Reference("b".to_owned()), + ], + }; + let merged = merge_plugins(ancestor, Some(descendant)); + assert_eq!( + merged + .items + .iter() + .map(PluginItem::reference) + .collect::>(), + vec!["shared", "a", "b"] + ); +} + +#[tokio::test] +async fn tags_always_union() { + let server = MockServer::start(); + let harness = common::Harness::new(common::test_config(), None); + let ancestor = json!({ + "alias": "tags.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "tags": ["ancestor-tag"], + }); + json_body(post(harness.router(), "/oagw/v1/upstreams", ancestor, parent()).await).await; + let descendant = json!({ + "alias": "tags.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "tags": ["descendant-tag"], + }); + json_body(post(harness.router(), "/oagw/v1/upstreams", descendant, tenant()).await).await; + + // Tags are per-record, so each record keeps its own list: the union happens + // in the effective configuration, never on the wire. + let parent_items = + json_body(common::get(harness.router(), "/oagw/v1/upstreams?$top=100", parent()).await) + .await["items"] + .as_array() + .unwrap() + .clone(); + let tenant_items = + json_body(common::get(harness.router(), "/oagw/v1/upstreams?$top=100", tenant()).await) + .await["items"] + .as_array() + .unwrap() + .clone(); + assert_eq!(parent_items[0]["tags"], json!(["ancestor-tag"])); + assert_eq!(tenant_items[0]["tags"], json!(["descendant-tag"])); +} + +#[tokio::test] +async fn a_descendant_without_routes_inherits_the_ancestor_route() { + // The parent owns the alias *and* the route; the child shadows the alias + // with its own upstream but registers no route of its own. DESIGN §3.3: + // ancestor routes are inherited at proxy time, while the closest tenant + // still owns the routing target — so the child's upstream is the one hit. + let ancestor_target = MockServer::start(); + let ancestor_mock = ancestor_target.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ancestor"); + }); + let descendant_target = MockServer::start(); + descendant_target.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("descendant"); + }); + + let harness = common::Harness::new(common::test_config(), None); + json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream("inherited.example.com", &ancestor_target), + parent(), + ) + .await, + ) + .await; + // The parent's route for its own upstream. + let parent_items = json_body( + common::get(harness.router(), "/oagw/v1/upstreams", parent()).await, + ) + .await["items"] + .as_array() + .unwrap() + .clone(); + let parent_upstream = parent_items + .iter() + .find(|item| item["alias"] == "inherited.example.com") + .map(|item| item["id"].as_str().unwrap().to_owned()) + .unwrap_or_default(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": parent_upstream, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + parent(), + ) + .await; + + // The descendant shadows the alias but adds no route. + json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream("inherited.example.com", &descendant_target), + tenant(), + ) + .await, + ) + .await; + + // The management API still hides the ancestor: the child lists only its own. + let visible = json_body(common::get(harness.router(), "/oagw/v1/upstreams", tenant()).await) + .await["items"] + .as_array() + .unwrap() + .len(); + assert_eq!(visible, 1, "ancestor resources stay invisible"); + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/inherited.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200, "the inherited route matches"); + assert_eq!( + String::from_utf8_lossy(&common::body_bytes(response).await), + "descendant", + "the closest tenant still owns the routing target" + ); + assert_eq!( + ancestor_mock.calls(), + 0, + "the shadowed ancestor upstream is not contacted" + ); +} diff --git a/gears/system/oagw/oagw/tests/oauth2_auth_test.rs b/gears/system/oagw/oagw/tests/oauth2_auth_test.rs new file mode 100644 index 0000000..d6f0020 --- /dev/null +++ b/gears/system/oagw/oagw/tests/oauth2_auth_test.rs @@ -0,0 +1,576 @@ +// Created: 2026-08-29 by Constructor Tech +//! OAuth2 client-credentials auth plugin: token fetch, caching and failures. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use common::{Harness, json_body, post, tenant}; +use httpmock::MockServer; +use serde_json::json; +use std::sync::Arc; + +const PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +const OAUTH_FORM: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1"; +const OAUTH_BASIC: &str = + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1"; +const CLIENT_ID_SECRET: &str = "b2F1dGgtY2xpZW50Om9hdXRoLXNlY3JldA=="; // oauth-client:oauth-secret + +fn token_body(token: &str, expires_in: u64) -> String { + format!(r#"{{"access_token":"{token}","expires_in":{expires_in},"token_type":"Bearer"}}"#) +} + +/// Harness whose credential store knows the OAuth2 client credentials. +fn harness() -> Harness { + let credstore = Arc::new(common::FakeCredStore::new(&[ + ("client-id", "oauth-client"), + ("oauth-secret", "oauth-secret"), + ])); + Harness::new(common::test_config(), Some(credstore)) +} + +async fn register( + harness: &Harness, + alias: &str, + server: &MockServer, + auth: serde_json::Value, +) -> String { + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": alias, + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "auth": auth, + }), + tenant(), + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + tenant(), + ) + .await; + id +} + +#[tokio::test] +async fn a_token_is_fetched_once_and_then_reused() { + let server = MockServer::start(); + let token = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/token") + .body_includes("grant_type=client_credentials") + .body_includes("client_id=oauth-client"); + then.status(200) + .header("content-type", "application/json") + .body(token_body("tok-1", 3600)); + }); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .header("authorization", "Bearer tok-1"); + then.status(200).body("ok"); + }); + + let harness = harness(); + let alias = "oauth-cache.example.com"; + register( + &harness, + alias, + &server, + json!({ + "type": OAUTH_FORM, + "config": { + "token_endpoint": format!("http://127.0.0.1:{}/token", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "oauth-secret", + "scopes": "read write", + }, + }), + ) + .await; + + for _ in 0..2 { + let response = harness + .send( + "GET", + &format!("/oagw/v1/proxy/{alias}/api"), + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + } + // The credential is minted once and replayed from the cache. + assert_eq!( + token.calls(), + 1, + "the second request must be served from the cache" + ); + assert_eq!(target.calls(), 2); +} + +#[tokio::test] +async fn the_token_request_carries_the_client_credentials() { + let server = MockServer::start(); + // `client_credentials` grant with the credentials in the form body. + let form = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/token") + .body_includes("grant_type=client_credentials") + .body_includes("client_id=oauth-client") + .body_includes("client_secret=oauth-secret") + .body_includes("scope=read+write"); + then.status(200).body(token_body("tok-form", 3600)); + }); + // The `basic` variant carries them in the `Authorization` header instead. + let basic = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/token") + .header("authorization", format!("Basic {CLIENT_ID_SECRET}")) + .body_includes("grant_type=client_credentials"); + then.status(200).body(token_body("tok-basic", 3600)); + }); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = harness(); + let alias = "oauth-form.example.com"; + register( + &harness, + alias, + &server, + json!({ + "type": OAUTH_FORM, + "config": { + "token_endpoint": format!("http://127.0.0.1:{}/token", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "oauth-secret", + "scopes": "read write", + }, + }), + ) + .await; + let response = harness + .send( + "GET", + &format!("/oagw/v1/proxy/{alias}/api"), + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!(form.calls(), 1); + assert_eq!(basic.calls(), 0); + + let alias = "oauth-basic.example.com"; + register( + &harness, + alias, + &server, + json!({ + "type": OAUTH_BASIC, + "config": { + "token_endpoint": format!("http://127.0.0.1:{}/token", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "oauth-secret", + }, + }), + ) + .await; + let response = harness + .send( + "GET", + &format!("/oagw/v1/proxy/{alias}/api"), + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!(basic.calls(), 1, "the basic variant uses the header"); + assert!(target.calls() >= 2); +} + +#[tokio::test] +async fn an_issuer_url_is_resolved_through_oidc_discovery() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/api"); + then.status(200).body("ok"); + }); + let token = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/token") + .body_includes("grant_type=client_credentials"); + then.status(200).body(token_body("tok-discovered", 3600)); + }); + server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/.well-known/openid-configuration"); + then.status(200) + .header("content-type", "application/json") + .body( + json!({ "token_endpoint": format!("http://127.0.0.1:{}/token", server.port()) }) + .to_string(), + ); + }); + + let harness = harness(); + let alias = "oauth-issuer.example.com"; + register( + &harness, + alias, + &server, + json!({ + "type": OAUTH_FORM, + "config": { + "issuer_url": format!("http://127.0.0.1:{}", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "oauth-secret", + }, + }), + ) + .await; + + let response = harness + .send( + "GET", + &format!("/oagw/v1/proxy/{alias}/api"), + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!(target.calls(), 1); + assert_eq!( + token.calls(), + 1, + "discovery must resolve the token endpoint" + ); +} + +#[tokio::test] +async fn exactly_one_of_token_endpoint_or_issuer_url_is_accepted() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = harness(); + let alias = "oauth-both.example.com"; + register( + &harness, + alias, + &server, + json!({ + "type": OAUTH_FORM, + "config": { + "token_endpoint": format!("http://127.0.0.1:{}/token", server.port()), + "issuer_url": format!("http://127.0.0.1:{}", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "oauth-secret", + }, + }), + ) + .await; + let response = harness + .send( + "GET", + "/oagw/v1/proxy/oauth-both.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 400); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); + assert!( + body["detail"] + .as_str() + .unwrap() + .contains("OAUTH2_CONFIG_INVALID"), + "the plugin error code must surface: {}", + body["detail"] + ); + + let alias = "oauth-neither.example.com"; + register( + &harness, + alias, + &server, + json!({ + "type": OAUTH_FORM, + "config": { + "client_id_ref": "cred://client-id", + "client_secret_ref": "oauth-secret", + }, + }), + ) + .await; + let response = harness + .send( + "GET", + "/oagw/v1/proxy/oauth-neither.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 400); + assert_eq!( + target.calls(), + 0, + "a misconfigured plugin must not reach the upstream" + ); +} + +#[tokio::test] +async fn a_rejected_token_fetch_is_never_cached() { + let server = MockServer::start(); + let rejected = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/token") + .body_includes("grant_type=client_credentials"); + then.status(400).body(r#"{"error":"invalid_client"}"#); + }); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = harness(); + let alias = "oauth-reject.example.com"; + register( + &harness, + alias, + &server, + json!({ + "type": OAUTH_FORM, + "config": { + "token_endpoint": format!("http://127.0.0.1:{}/token", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "oauth-secret", + }, + }), + ) + .await; + + let response = harness + .send( + "GET", + &format!("/oagw/v1/proxy/{alias}/api"), + None, + tenant(), + ) + .await; + let source = common::header(&response, "x-oagw-error-source"); + assert_eq!(response.status(), 401); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.auth.failed.v1" + ); + assert_eq!(source.as_deref(), Some("gateway")); + assert_eq!( + target.calls(), + 0, + "no credential means the upstream is never reached" + ); + assert_eq!(rejected.calls(), 1); + + // A second attempt reaches the IdP again: the failure is not cached. + let response = harness + .send( + "GET", + &format!("/oagw/v1/proxy/{alias}/api"), + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 401); + assert_eq!(rejected.calls(), 2); +} + +#[tokio::test] +async fn a_short_lived_token_is_not_cached() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + // `expires_in: 10s` leaves no usable TTL once the 30s safety margin is + // subtracted, so the token must not be cached. + let token = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/token") + .body_includes("grant_type=client_credentials"); + then.status(200).body(token_body("tok-short", 10)); + }); + + let harness = harness(); + let alias = "oauth-short.example.com"; + register( + &harness, + alias, + &server, + json!({ + "type": OAUTH_FORM, + "config": { + "token_endpoint": format!("http://127.0.0.1:{}/token", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "oauth-secret", + }, + }), + ) + .await; + + for _ in 0..2 { + let response = harness + .send( + "GET", + &format!("/oagw/v1/proxy/{alias}/api"), + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + } + assert_eq!( + token.calls(), + 2, + "a token without a usable TTL is never cached" + ); +} + +#[tokio::test] +async fn a_missing_client_secret_is_a_401_without_material() { + let server = MockServer::start(); + let token = server.mock(|when, then| { + when.method(httpmock::Method::POST); + then.status(200).body(token_body("tok-never", 3600)); + }); + + // The credential store does not know `absent-secret`. + let credstore = Arc::new(common::FakeCredStore::new(&[("client-id", "oauth-client")])); + let harness = Harness::new(common::test_config(), Some(credstore)); + let alias = "oauth-nosecret.example.com"; + register( + &harness, + alias, + &server, + json!({ + "type": OAUTH_FORM, + "config": { + "token_endpoint": format!("http://127.0.0.1:{}/token", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "absent-secret", + }, + }), + ) + .await; + + let response = harness + .send( + "GET", + &format!("/oagw/v1/proxy/{alias}/api"), + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 401); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.auth.failed.v1" + ); + assert_eq!( + token.calls(), + 0, + "both secrets are resolved before any request" + ); + let detail = body["detail"].as_str().unwrap(); + assert!( + !detail.contains("absent-secret"), + "no secret reference may leak: {detail}" + ); + assert!( + !detail.contains("oauth-secret"), + "no secret material may leak: {detail}" + ); +} + +#[tokio::test] +async fn tenants_do_not_share_a_cached_token() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/api") + .header("authorization", "Bearer tok-shared"); + then.status(200).body("ok"); + }); + let token = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .path("/token") + .body_includes("grant_type=client_credentials"); + then.status(200).body(token_body("tok-shared", 3600)); + }); + + let harness = harness(); + let alias = "oauth-tenant.example.com"; + let auth = json!({ + "type": OAUTH_FORM, + "config": { + "token_endpoint": format!("http://127.0.0.1:{}/token", server.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "oauth-secret", + }, + }); + // The same alias is registered once per tenant; each caller must mint its + // own token because the cache key carries the tenant. + for caller in [tenant(), common::parent()] { + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": alias, + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "auth": auth, + }), + caller, + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + caller, + ) + .await; + } + + for caller in [tenant(), common::parent()] { + let response = harness + .send("GET", &format!("/oagw/v1/proxy/{alias}/api"), None, caller) + .await; + assert_eq!(response.status(), 200); + } + assert_eq!(token.calls(), 2, "the cache key is scoped per tenant"); + assert_eq!(target.calls(), 2); +} diff --git a/gears/system/oagw/oagw/tests/plugin_chain_test.rs b/gears/system/oagw/oagw/tests/plugin_chain_test.rs new file mode 100644 index 0000000..4516424 --- /dev/null +++ b/gears/system/oagw/oagw/tests/plugin_chain_test.rs @@ -0,0 +1,705 @@ +// Created: 2026-08-29 by Constructor Tech +//! Plugin chain execution order (ADR-0002) and guard rejection statuses. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use async_trait::async_trait; +use common::{Harness, json_body, post, tenant}; +use httpmock::MockServer; +use oagw::domain::plugin::{ + GuardDecision, GuardPlugin, PluginError, RequestContext, ResponseContext, TransformPlugin, +}; +use oagw::infra::plugin::guard::RequiredHeadersGuard; +use oagw::infra::plugin::registry::plugin_not_found; +use serde_json::{Value, json}; +use std::sync::Arc; +use std::sync::Mutex; +use uuid::Uuid; + +const PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +/// Marker transform that appends `name: value` to the outbound request and to +/// the returned response, so the execution order becomes observable. +struct Marker { + id: &'static str, + name: &'static str, + journal: Arc>>, +} + +impl Marker { + fn header_name(&self) -> axum::http::HeaderName { + axum::http::HeaderName::from_bytes(self.name.as_bytes()).unwrap() + } +} + +#[async_trait] +impl TransformPlugin for Marker { + fn id(&self) -> &str { + self.id + } + + fn plugin_type(&self) -> &str { + "transform_plugin" + } + + async fn transform_request(&self, ctx: &mut RequestContext) -> Result<(), PluginError> { + self.journal.lock().unwrap().push(self.id); + if let Ok(value) = axum::http::HeaderValue::from_str(self.name) { + ctx.headers.insert(self.header_name(), value); + } + Ok(()) + } + + async fn transform_response(&self, ctx: &mut ResponseContext) -> Result<(), PluginError> { + self.journal.lock().unwrap().push(self.id); + if let Ok(value) = axum::http::HeaderValue::from_str(self.name) { + ctx.headers.insert(self.header_name(), value); + } + Ok(()) + } + + async fn transform_error( + &self, + ctx: &mut oagw::domain::plugin::ErrorContext, + ) -> Result<(), PluginError> { + let _ = &ctx.headers; + Ok(()) + } +} + +/// Guard that always rejects with the given status. +struct Rejecting { + id: &'static str, + status: axum::http::StatusCode, +} + +#[async_trait] +impl oagw::domain::plugin::GuardPlugin for Rejecting { + fn id(&self) -> &str { + self.id + } + + fn plugin_type(&self) -> &str { + "guard_plugin" + } + + async fn guard_request(&self, _ctx: &RequestContext) -> Result { + Ok(GuardDecision::Reject { + status: self.status, + error_code: "REQUIRED_HEADER_MISSING".to_owned(), + message: "rejected by the test guard".to_owned(), + }) + } + + async fn guard_response(&self, _ctx: &ResponseContext) -> Result { + Ok(GuardDecision::Reject { + status: self.status, + error_code: "REQUIRED_HEADER_MISSING".to_owned(), + message: "rejected by the test guard".to_owned(), + }) + } +} + +async fn route(harness: &Harness, upstream_id: &str, plugins: Value) { + post( + harness.router(), + "/oagw/v1/routes", + json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["GET"], "path": "/" } }, + "plugins": plugins, + }), + tenant(), + ) + .await; +} + +#[tokio::test] +async fn auth_and_request_transforms_run_before_the_upstream() { + let server = MockServer::start(); + // The upstream only answers when both the credential and the propagated + // request id arrived, which proves the request phases ran first. + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .header("authorization", "injected") + .header("x-request-id", tenant().simple().to_string()); + then.status(200).body("ok"); + }); + + let credstore = Arc::new(common::FakeCredStore::new(&[("key", "injected")])); + let harness = Harness::new(common::test_config(), Some(credstore)); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "ordered.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "auth": { + "type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + "config": { "api_key_ref": "cred://key", "header_name": "authorization" }, + }, + "plugins": { + "items": ["gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"], + }, + }), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + route(&harness, &upstream_id, json!({ "items": [] })).await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/ordered.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + target.calls(), + 1, + "auth and transforms must run before the upstream" + ); +} + +#[tokio::test] +async fn an_apikey_configured_for_the_query_travels_in_the_query_string() { + let server = MockServer::start(); + // The caller's `page` survives only because the route allowlists it; the + // credential is appended after the filter and is never subject to it. + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .query_param("api-key", "injected") + .query_param("page", "2"); + then.status(200).body("ok"); + }); + + let credstore = Arc::new(common::FakeCredStore::new(&[("key", "injected")])); + let harness = Harness::new(common::test_config(), Some(credstore)); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "querykey.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "auth": { + "type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + "config": { "api_key_ref": "cred://key", "query_param_name": "api-key" }, + }, + }), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ + "upstream_id": upstream_id, + "match": { + "http": { "methods": ["GET"], "path": "/", "query_allowlist": ["page"] } + }, + }), + tenant(), + ) + .await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/querykey.example.com/api?page=2", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200, "upstream must see both parameters"); + assert_eq!(target.calls(), 1); +} + +#[tokio::test] +async fn the_response_transform_echoes_the_request_id() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "echo.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "plugins": { + "items": ["gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"], + }, + }), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + route(&harness, &upstream_id, json!({ "items": [] })).await; + + let response = harness + .send("GET", "/oagw/v1/proxy/echo.example.com/api", None, tenant()) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + common::header(&response, "x-request-id").as_deref(), + Some(tenant().simple().to_string().as_str()), + "the response phase must run after the upstream" + ); +} + +#[tokio::test] +async fn upstream_plugins_execute_before_route_plugins() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let journal: Arc>> = Arc::new(Mutex::new(Vec::new())); + let harness = Harness::with_plugins(common::test_config(), None, |registry| { + registry.register_transform(Arc::new(Marker { + id: "test.upstream.v1", + name: "x-oagw-test-upstream", + journal: Arc::clone(&journal), + })); + registry.register_transform(Arc::new(Marker { + id: "test.route.v1", + name: "x-oagw-test-route", + journal: Arc::clone(&journal), + })); + }); + + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "chain-order.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "plugins": { "items": ["gts.cf.core.oagw.transform_plugin.v1~test.upstream.v1"] }, + }), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + route( + &harness, + &upstream_id, + json!({ "items": ["gts.cf.core.oagw.transform_plugin.v1~test.route.v1"] }), + ) + .await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/chain-order.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + // Both markers ran twice: once on the request, once on the response. Each + // phase keeps the upstream before the route (ADR-0002). + assert_eq!( + *journal.lock().unwrap(), + vec![ + "test.upstream.v1", + "test.route.v1", + "test.upstream.v1", + "test.route.v1" + ] + ); +} + +#[tokio::test] +async fn a_request_guard_rejection_is_a_400_without_reaching_the_upstream() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = Harness::with_plugins(common::test_config(), None, |registry| { + registry.register_guard(Arc::new(Rejecting { + id: "test.reject400.v1", + status: axum::http::StatusCode::BAD_REQUEST, + })); + }); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "guarded.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "plugins": { "items": ["gts.cf.core.oagw.guard_plugin.v1~test.reject400.v1"] }, + }), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + route(&harness, &upstream_id, json!({ "items": [] })).await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/guarded.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 400); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); + assert!( + body["detail"] + .as_str() + .unwrap() + .contains("REQUIRED_HEADER_MISSING"), + "the guard error code must surface: {}", + body["detail"] + ); + assert_eq!( + target.calls(), + 0, + "a rejected request must not be forwarded" + ); +} + +#[tokio::test] +async fn a_response_guard_rejection_is_a_502() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = Harness::with_plugins(common::test_config(), None, |registry| { + registry.register_guard(Arc::new(Rejecting { + id: "test.reject502.v1", + status: axum::http::StatusCode::BAD_GATEWAY, + })); + }); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "response-guarded.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "plugins": { "items": ["gts.cf.core.oagw.guard_plugin.v1~test.reject502.v1"] }, + }), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + route(&harness, &upstream_id, json!({ "items": [] })).await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/response-guarded.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 502); + let source = common::header(&response, "x-oagw-error-source"); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.downstream.error.v1" + ); + assert_eq!(source.as_deref(), Some("gateway")); +} + +#[tokio::test] +async fn a_configured_required_headers_guard_is_enforced_on_the_wire() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = Harness::new(common::test_config(), None); + // ADR-0009's binding shape: the guard carries its configuration next to the + // reference, so the headers it enforces travel with the binding itself. + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "guarded.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "plugins": { + "items": [ { + "plugin_ref": "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1", + "config": { "required_request_headers": "x-correlation-id" }, + } ], + }, + }), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + route(&harness, &upstream_id, json!({ "items": [] })).await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/guarded.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 400, "a missing header must be rejected"); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); + assert!( + body["detail"] + .as_str() + .unwrap() + .contains("x-correlation-id"), + "the problem must name the missing header: {}", + body["detail"] + ); + assert_eq!(target.calls(), 0, "a rejected request is not forwarded"); + + // The same binding passes once the caller supplies the header. + let supplied = axum::http::Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/guarded.example.com/api") + .header("x-correlation-id", "abc") + .body(axum::body::Body::empty()) + .unwrap(); + let response = harness.send_request(supplied, tenant()).await; + assert_eq!(response.status(), 200); + assert_eq!(target.calls(), 1); +} + +#[tokio::test] +async fn an_unconfigured_required_headers_guard_fails_open() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "failopen.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "plugins": { + "items": ["gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"], + }, + }), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + route(&harness, &upstream_id, json!({ "items": [] })).await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/failopen.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200, "absent configuration must allow"); +} + +/// A `RequestContext` for direct guard evaluation. +fn request_context(required: Value) -> RequestContext { + RequestContext { + tenant_id: tenant(), + upstream_id: Uuid::new_v4(), + alias: "guard.example.com".to_owned(), + method: "GET".to_owned(), + path: "/api".to_owned(), + query: None, + headers: { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::HeaderName::from_static("accept"), + axum::http::HeaderValue::from_static("application/json"), + ); + headers + }, + body: bytes::Bytes::new(), + uri: "/oagw/v1/proxy/guard.example.com/api".parse().unwrap(), + config: required.as_object().cloned().unwrap_or_default(), + security: common::security_for(tenant()), + } +} + +#[tokio::test] +async fn required_request_headers_guard_rejects_with_400() { + let guard = RequiredHeadersGuard; + let context = + request_context(json!({ "required_request_headers": "X-Correlation-Id, accept" })); + let decision = guard.guard_request(&context).await.unwrap(); + match decision { + GuardDecision::Reject { + status, message, .. + } => { + assert_eq!(status, axum::http::StatusCode::BAD_REQUEST); + assert!(message.contains("x-correlation-id"), "{message}"); + } + other => panic!("expected a rejection, got {other:?}"), + } + + let satisfied = request_context(json!({ "required_request_headers": "accept" })); + assert_eq!( + guard.guard_request(&satisfied).await.unwrap(), + GuardDecision::Allow + ); +} + +#[tokio::test] +async fn required_response_headers_guard_rejects_with_502() { + let guard = RequiredHeadersGuard; + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::HeaderName::from_static("content-type"), + axum::http::HeaderValue::from_static("application/json"), + ); + let context = ResponseContext { + request_headers: axum::http::HeaderMap::new(), + headers: headers.clone(), + status: axum::http::StatusCode::OK, + body: bytes::Bytes::new(), + config: json!({ "required_response_headers": "x-signature" }) + .as_object() + .cloned() + .unwrap(), + }; + let decision = guard.guard_response(&context).await.unwrap(); + assert_eq!( + decision, + GuardDecision::Reject { + status: axum::http::StatusCode::BAD_GATEWAY, + error_code: "REQUIRED_HEADER_MISSING".to_owned(), + message: "required response header 'x-signature' is absent".to_owned(), + } + ); + + let satisfied = ResponseContext { + request_headers: axum::http::HeaderMap::new(), + headers, + status: axum::http::StatusCode::OK, + body: bytes::Bytes::new(), + config: json!({ "required_response_headers": "content-type" }) + .as_object() + .cloned() + .unwrap(), + }; + assert_eq!( + guard.guard_response(&satisfied).await.unwrap(), + GuardDecision::Allow + ); +} + +#[tokio::test] +async fn blank_guard_configuration_is_a_no_op() { + let guard = RequiredHeadersGuard; + let blank = request_context(json!({ "required_request_headers": " , ," })); + assert_eq!( + guard.guard_request(&blank).await.unwrap(), + GuardDecision::Allow + ); +} + +#[test] +fn a_plugin_without_an_implementation_reports_the_reference() { + let error = plugin_not_found("gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.logging.v1"); + assert!(format!("{error:?}").contains("logging")); +} + +#[tokio::test] +async fn a_dangling_plugin_reference_is_refused_at_config_time() { + let server = MockServer::start(); + let harness = Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "dangling.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + }), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + + // `required_header.v1` (no trailing `s`) is not a plugin anyone implements. + let response = post( + harness.router(), + "/oagw/v1/routes", + json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["GET"], "path": "/" } }, + "plugins": { "items": ["gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_header.v1"] }, + }), + tenant(), + ) + .await; + assert_eq!(response.status(), 400); + + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); +} diff --git a/gears/system/oagw/oagw/tests/plugin_crud_test.rs b/gears/system/oagw/oagw/tests/plugin_crud_test.rs new file mode 100644 index 0000000..618be74 --- /dev/null +++ b/gears/system/oagw/oagw/tests/plugin_crud_test.rs @@ -0,0 +1,257 @@ +// Created: 2026-08-29 by Constructor Tech +//! Custom-plugin registration, immutability and reference protection. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use common::{get, json_body, post, tenant}; +use serde_json::{Value, json}; +use uuid::Uuid; + +const SOURCE: &str = "def apply(ctx):\n return ctx\n"; + +fn plugin(plugin_type: &str) -> Value { + json!({ + "plugin_type": plugin_type, + "name": "my-guard", + "source_code": SOURCE, + "config_schema": { "type": "object" }, + }) +} + +#[tokio::test] +async fn register_list_get_delete() { + let harness = common::Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/plugins", + plugin("guard_plugin"), + tenant(), + ) + .await, + ) + .await; + assert_eq!(created["plugin_type"], "guard_plugin"); + assert_eq!(created["name"], "my-guard"); + assert_eq!(created["config_schema"]["type"], "object"); + let id = created["id"].as_str().unwrap().to_owned(); + + let list = json_body(get(harness.router(), "/oagw/v1/plugins", tenant()).await).await; + assert_eq!(list["items"].as_array().unwrap().len(), 1); + assert_eq!(list["items"][0]["id"], id.as_str()); + + let fetched = json_body( + get( + harness.router(), + &format!("/oagw/v1/plugins/{id}"), + tenant(), + ) + .await, + ) + .await; + assert_eq!(fetched["name"], "my-guard"); + + let response = harness + .send("DELETE", &format!("/oagw/v1/plugins/{id}"), None, tenant()) + .await; + assert_eq!(response.status(), 204); + assert_eq!( + get( + harness.router(), + &format!("/oagw/v1/plugins/{id}"), + tenant() + ) + .await + .status(), + 404 + ); +} + +#[tokio::test] +async fn source_returns_starlark_text() { + let harness = common::Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/plugins", + plugin("transform_plugin"), + tenant(), + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + + let response = get( + harness.router(), + &format!("/oagw/v1/plugins/{id}/source"), + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + common::header(&response, "content-type").as_deref(), + Some("text/plain; charset=utf-8") + ); + assert_eq!( + common::body_bytes(response).await.as_ref(), + SOURCE.as_bytes() + ); + + let missing = Uuid::new_v4(); + assert_eq!( + get( + harness.router(), + &format!("/oagw/v1/plugins/{missing}/source"), + tenant() + ) + .await + .status(), + 404 + ); +} + +#[tokio::test] +async fn delete_is_refused_while_referenced() { + let harness = common::Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/plugins", + plugin("transform_plugin"), + tenant(), + ) + .await, + ) + .await; + let plugin_id = created["id"].as_str().unwrap().to_owned(); + + let upstream = json!({ + "alias": "plugin-user.example.com", + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "server": { "endpoints": [ { "scheme": "https", "host": "plugin-user.example.com", "port": 443 } ] }, + "plugins": { "items": [plugin_id] }, + }); + let upstream = + json_body(post(harness.router(), "/oagw/v1/upstreams", upstream, tenant()).await).await; + let upstream_id = upstream["id"].as_str().unwrap().to_owned(); + + let response = harness + .send( + "DELETE", + &format!("/oagw/v1/plugins/{plugin_id}"), + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 409); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1" + ); + assert_eq!(body["referenced_by"]["upstreams"], json!([upstream_id])); + assert_eq!(body["referenced_by"]["routes"], json!([])); + + // Still registered after the refused delete. + assert_eq!( + get( + harness.router(), + &format!("/oagw/v1/plugins/{plugin_id}"), + tenant() + ) + .await + .status(), + 200 + ); +} + +#[tokio::test] +async fn delete_is_refused_for_a_route_reference() { + let harness = common::Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/plugins", + plugin("guard_plugin"), + tenant(), + ) + .await, + ) + .await; + let plugin_id = created["id"].as_str().unwrap().to_owned(); + + let upstream = json!({ + "alias": "route-plugin-user.example.com", + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "server": { "endpoints": [ { "scheme": "https", "host": "route-plugin-user.example.com", "port": 443 } ] }, + }); + let upstream = + json_body(post(harness.router(), "/oagw/v1/upstreams", upstream, tenant()).await).await; + let route = json!({ + "upstream_id": upstream["id"], + "plugins": { "items": [plugin_id] }, + "match": { "http": { "methods": ["GET"], "path": "/api" } }, + }); + let route = json_body(post(harness.router(), "/oagw/v1/routes", route, tenant()).await).await; + + let response = harness + .send( + "DELETE", + &format!("/oagw/v1/plugins/{plugin_id}"), + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 409); + let body = json_body(response).await; + assert_eq!(body["referenced_by"]["routes"], json!([route["id"]])); +} + +#[tokio::test] +async fn plugin_validation() { + let harness = common::Harness::new(common::test_config(), None); + let cases: Vec<(Value, u16)> = vec![ + (plugin("cronjob"), 400), + ( + json!({ "plugin_type": "guard_plugin", "name": " ", "source_code": "x" }), + 400, + ), + ( + json!({ "plugin_type": "guard_plugin", "name": "no-source", "source_code": "" }), + 201, + ), + ]; + for (payload, expected) in cases { + let response = post(harness.router(), "/oagw/v1/plugins", payload, tenant()).await; + assert_eq!(response.status(), expected); + } +} + +#[tokio::test] +async fn plugins_are_tenant_scoped() { + let harness = common::Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/plugins", + plugin("guard_plugin"), + tenant(), + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + + let other = Uuid::new_v4(); + assert_eq!( + get(harness.router(), &format!("/oagw/v1/plugins/{id}"), other) + .await + .status(), + 404 + ); + let list = json_body(get(harness.router(), "/oagw/v1/plugins", other).await).await; + assert_eq!(list["items"], json!([])); +} diff --git a/gears/system/oagw/oagw/tests/proxy_http_test.rs b/gears/system/oagw/oagw/tests/proxy_http_test.rs new file mode 100644 index 0000000..8def04d --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_http_test.rs @@ -0,0 +1,741 @@ +// Created: 2026-08-29 by Constructor Tech +//! Data-plane happy path, header handling and target-host selection. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use common::{get, json_body, post, put, tenant}; +use httpmock::MockServer; +use serde_json::{Value, json}; +use uuid::Uuid; + +const PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +/// Upstream bound to an httpmock server; endpoint hosts are IP literals, so the +/// alias is always supplied. +fn upstream_for(alias: &str, server: &MockServer) -> Value { + json!({ + "alias": alias, + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + }) +} + +async fn create_upstream(harness: &common::Harness, alias: &str, server: &MockServer) -> Value { + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream_for(alias, server), + tenant(), + ) + .await, + ) + .await; + assert_eq!(created["alias"], alias); + created +} + +#[tokio::test] +async fn happy_path_forwards_status_body_and_headers() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/api/v1/pets"); + then.status(200) + .header("x-upstream", "yes") + .body("{\"ok\":true}"); + }); + + let harness = common::Harness::new(common::test_config(), None); + create_upstream(&harness, "happy.example.com", &server).await; + let upstream_id = json_body(get(harness.router(), "/oagw/v1/upstreams", tenant()).await).await + ["items"][0]["id"] + .as_str() + .unwrap() + .to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/api" } } }), + tenant(), + ) + .await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/happy.example.com/api/v1/pets", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + common::header(&response, "x-upstream").as_deref(), + Some("yes") + ); + // The success path still names the source of the response: ADR-0007 requires + // the header on every response, and a relayed one is produced by the upstream. + assert_eq!( + common::header(&response, "x-oagw-error-source").as_deref(), + Some("upstream") + ); + assert_eq!( + common::body_bytes(response).await.as_ref(), + b"{\"ok\":true}" + ); + assert_eq!(target.calls(), 1); +} + +#[tokio::test] +async fn request_header_rules_and_hop_by_hop_stripping() { + let server = MockServer::start(); + // The mock only answers when the rule-injected header arrives … + let injected = server.mock(|when, then| { + when.header("x-gateway", "oagw"); + then.status(200).body("ok"); + }); + // … and never when a hop-by-hop header survives. + let hop_by_hop = server.mock(|when, then| { + when.header("connection", "keep-alive"); + then.status(200).body("leaked"); + }); + let routing_header = server.mock(|when, then| { + when.header("x-oagw-target-host", "happy.example.com"); + then.status(200).body("leaked"); + }); + + let harness = common::Harness::new(common::test_config(), None); + let upstream = json!({ + "alias": "headers.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "headers": { "request": { "set": { "x-gateway": "oagw" } } }, + }); + let upstream = + json_body(post(harness.router(), "/oagw/v1/upstreams", upstream, tenant()).await).await; + let upstream_id = upstream["id"].as_str().unwrap().to_owned(); + post(harness.router(), "/oagw/v1/routes", json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), tenant()) + .await; + + let mut request = axum::http::Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/headers.example.com/echo") + .header("connection", "keep-alive") + .header("x-oagw-target-host", server.host()) + .body(axum::body::Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(common::security_for(tenant())); + let response = harness.send_request(request, tenant()).await; + assert_eq!(response.status(), 200); + + assert_eq!(injected.calls(), 1); + assert_eq!(hop_by_hop.calls(), 0, "hop-by-hop headers must be stripped"); + assert_eq!( + routing_header.calls(), + 0, + "the target-host header is read then stripped" + ); +} + +#[tokio::test] +async fn authorization_is_not_forwarded_unless_allowed() { + let server = MockServer::start(); + let forwarded = server.mock(|when, then| { + when.header("authorization", "Bearer caller-token"); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + let upstream_id = create_upstream(&harness, "auth.example.com", &server).await["id"] + .as_str() + .unwrap() + .to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + tenant(), + ) + .await; + + // Default: no passthrough, the caller credential stays at the gateway. + let request = axum::http::Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/auth.example.com/echo") + .header("authorization", "Bearer caller-token") + .body(axum::body::Body::empty()) + .unwrap(); + let response = harness.send_request(request, tenant()).await; + // The mock above only answers when the credential is forwarded, so the + // request falls through to httpmock's built-in 404. + assert_eq!(response.status(), 404); + assert_eq!( + forwarded.calls(), + 0, + "the credential must stay at the gateway" + ); + + // Allowlist the header and the credential reaches the upstream. + let upstream = json!({ + "alias": "auth.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "headers": { "request": { "passthrough": "allowlist", "passthrough_allowlist": ["authorization"] } }, + }); + let replaced = put( + harness.router(), + &format!("/oagw/v1/upstreams/{upstream_id}"), + upstream, + tenant(), + ) + .await; + assert_eq!(replaced.status(), 200); + let request = axum::http::Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/auth.example.com/echo") + .header("authorization", "Bearer caller-token") + .body(axum::body::Body::empty()) + .unwrap(); + let response = harness.send_request(request, tenant()).await; + assert_eq!(response.status(), 200); + assert_eq!(forwarded.calls(), 1); +} + +#[tokio::test] +async fn query_allowlist_filters_the_forwarded_query() { + let server = MockServer::start(); + let filtered = server.mock(|when, then| { + when.method(httpmock::Method::GET) + .path("/api/search") + .query_param("keep", "1"); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + let upstream_id = create_upstream(&harness, "query.example.com", &server).await["id"] + .as_str() + .unwrap() + .to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["GET"], "path": "/api", "query_allowlist": ["keep"] } }, + }), + tenant(), + ) + .await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/query.example.com/api/search?keep=1&drop=2", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!(filtered.calls(), 1); +} + +#[tokio::test] +async fn target_host_header_selects_the_endpoint() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + // Two endpoints that share the mock: the alias is explicit, so the header + // is optional but honoured. + let upstream = json!({ + "alias": "multi.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ + { "scheme": "http", "host": server.host(), "port": server.port() }, + { "scheme": "http", "host": server.host(), "port": server.port() }, + ] }, + }); + let upstream = + json_body(post(harness.router(), "/oagw/v1/upstreams", upstream, tenant()).await).await; + let upstream_id = upstream["id"].as_str().unwrap().to_owned(); + post(harness.router(), "/oagw/v1/routes", json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), tenant()) + .await; + + let request = axum::http::Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/multi.example.com/echo") + .header("x-oagw-target-host", server.host()) + .body(axum::body::Body::empty()) + .unwrap(); + let response = harness.send_request(request, tenant()).await; + assert_eq!(response.status(), 200); + assert_eq!(target.calls(), 1); + + // An unknown host is refused with the configured alternatives. + let request = axum::http::Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/multi.example.com/echo") + .header("x-oagw-target-host", "elsewhere.example.com") + .body(axum::body::Body::empty()) + .unwrap(); + let response = harness.send_request(request, tenant()).await; + assert_eq!(response.status(), 400); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.routing.unknown_target_host.v1" + ); + assert_eq!(body["invalid_value"], "elsewhere.example.com"); + assert_eq!(body["valid_hosts"], json!([server.host(), server.host()])); +} + +#[tokio::test] +async fn common_suffix_alias_requires_a_target_host() { + let harness = common::Harness::new(common::test_config(), None); + let upstream = json!({ + "alias": "vendor.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ + { "scheme": "https", "host": "us.vendor.com", "port": 443 }, + { "scheme": "https", "host": "eu.vendor.com", "port": 443 }, + ] }, + }); + let upstream = + json_body(post(harness.router(), "/oagw/v1/upstreams", upstream, tenant()).await).await; + let upstream_id = upstream["id"].as_str().unwrap().to_owned(); + post(harness.router(), "/oagw/v1/routes", json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), tenant()) + .await; + + let response = harness + .send("GET", "/oagw/v1/proxy/vendor.com/catalog", None, tenant()) + .await; + assert_eq!(response.status(), 400); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.routing.missing_target_host.v1" + ); + assert_eq!(body["alias"], "vendor.com"); + assert_eq!( + body["valid_hosts"], + json!(["us.vendor.com", "eu.vendor.com"]) + ); +} + +#[tokio::test] +async fn a_target_host_carrying_a_port_is_invalid_not_unknown() { + let harness = common::Harness::new(common::test_config(), None); + let upstream = json!({ + "alias": "api.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "https", "host": "api.example.com", "port": 443 } ] }, + }); + let upstream = + json_body(post(harness.router(), "/oagw/v1/upstreams", upstream, tenant()).await).await; + let upstream_id = upstream["id"].as_str().unwrap().to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + tenant(), + ) + .await; + + // ADR-0007: `X-OAGW-Target-Host` is a bare hostname — a port is a format + // error, not an unknown endpoint, and the caller's value is echoed back. + let request = axum::http::Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/api.example.com/api") + .header("x-oagw-target-host", "us.vendor.com:8443") + .body(axum::body::Body::empty()) + .unwrap(); + let response = harness.send_request(request, tenant()).await; + assert_eq!(response.status(), 400); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.routing.invalid_target_host.v1" + ); + assert_eq!(body["invalid_value"], "us.vendor.com:8443"); +} + +#[tokio::test] +async fn content_length_must_match_the_body() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::POST); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + let upstream_id = create_upstream(&harness, "length.example.com", &server).await["id"] + .as_str() + .unwrap() + .to_owned(); + post(harness.router(), "/oagw/v1/routes", json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["POST"], "path": "/" } } }), tenant()) + .await; + + let request = axum::http::Request::builder() + .method("POST") + .uri("/oagw/v1/proxy/length.example.com/api") + .header("content-length", "10") + .body(axum::body::Body::from("abc")) + .unwrap(); + let response = harness.send_request(request, tenant()).await; + assert_eq!(response.status(), 400); + assert_eq!(target.calls(), 0); +} + +#[tokio::test] +async fn transfer_encoding_must_be_chunked() { + let server = MockServer::start(); + let harness = common::Harness::new(common::test_config(), None); + let upstream_id = create_upstream(&harness, "te.example.com", &server).await["id"] + .as_str() + .unwrap() + .to_owned(); + post(harness.router(), "/oagw/v1/routes", json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["POST"], "path": "/" } } }), tenant()) + .await; + + let request = axum::http::Request::builder() + .method("POST") + .uri("/oagw/v1/proxy/te.example.com/api") + .header("transfer-encoding", "gzip") + .body(axum::body::Body::from("abc")) + .unwrap(); + let response = harness.send_request(request, tenant()).await; + assert_eq!(response.status(), 400); + assert_eq!( + json_body(response).await["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); +} + +#[tokio::test] +async fn disabled_upstream_is_unavailable() { + let server = MockServer::start(); + let harness = common::Harness::new(common::test_config(), None); + let upstream = create_upstream(&harness, "disabled.example.com", &server).await; + let upstream_id = upstream["id"].as_str().unwrap().to_owned(); + post(harness.router(), "/oagw/v1/routes", json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), tenant()) + .await; + let disabled = json_body( + harness + .send( + "POST", + &format!("/oagw/v1/upstreams/{upstream_id}/disable"), + Some(json!({"enabled": false})), + tenant(), + ) + .await, + ) + .await; + assert_eq!(disabled["enabled"], false); + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/disabled.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 503); + assert_eq!( + common::header(&response, "x-oagw-error-source").as_deref(), + Some("gateway") + ); + assert_eq!( + json_body(response).await["type"], + "gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1" + ); +} + +#[tokio::test] +async fn upstream_500_is_passed_through_unchanged() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/boom"); + then.status(500) + .header("x-upstream", "err") + .body("upstream says no"); + }); + + let harness = common::Harness::new(common::test_config(), None); + let upstream_id = create_upstream(&harness, "boom.example.com", &server).await["id"] + .as_str() + .unwrap() + .to_owned(); + post(harness.router(), "/oagw/v1/routes", json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), tenant()) + .await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/boom.example.com/boom", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 500); + assert_eq!( + common::header(&response, "x-oagw-error-source").as_deref(), + Some("upstream") + ); + assert_eq!( + common::header(&response, "x-upstream").as_deref(), + Some("err") + ); + assert_eq!( + common::body_bytes(response).await.as_ref(), + b"upstream says no" + ); + assert_eq!(target.calls(), 1); +} + +#[tokio::test] +async fn unknown_alias_is_a_gateway_404() { + let harness = common::Harness::new(common::test_config(), None); + let response = harness + .send( + "GET", + "/oagw/v1/proxy/no-such-alias.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 404); + assert_eq!( + common::header(&response, "x-oagw-error-source").as_deref(), + Some("gateway") + ); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); + assert!(body["instance"].is_string()); + assert!(body["trace_id"].is_string()); +} + +#[tokio::test] +async fn request_body_is_forwarded_verbatim() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::POST) + .body("{\"name\":\"pet\"}"); + then.status(201).body("created"); + }); + + let harness = common::Harness::new(common::test_config(), None); + let upstream_id = create_upstream(&harness, "post.example.com", &server).await["id"] + .as_str() + .unwrap() + .to_owned(); + post(harness.router(), "/oagw/v1/routes", json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["POST"], "path": "/" } } }), tenant()) + .await; + + let response = harness + .send( + "POST", + "/oagw/v1/proxy/post.example.com/api", + Some(json!({ "name": "pet" })), + tenant(), + ) + .await; + assert_eq!(response.status(), 201); + assert_eq!(common::body_bytes(response).await.as_ref(), b"created"); + assert_eq!(target.calls(), 1); +} + +#[tokio::test] +async fn catalog_only_auth_plugin_is_rejected_at_configuration_time() { + let harness = common::Harness::new(common::test_config(), None); + let upstream = json!({ + "alias": "catalog.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "https", "host": "catalog.example.com", "port": 443 } ] }, + "auth": { + "type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.bearer.v1", + "config": {}, + }, + }); + let response = post(harness.router(), "/oagw/v1/upstreams", upstream, tenant()).await; + assert_eq!(response.status(), 400); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); + assert!( + body["detail"] + .as_str() + .unwrap() + .contains("catalog identifier with no implementation") + ); +} + +#[tokio::test] +async fn unknown_chain_plugin_fails_closed() { + let harness = common::Harness::new(common::test_config(), None); + let upstream = json!({ + "alias": "chain.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "https", "host": "chain.example.com", "port": 443 } ] }, + "plugins": { "items": ["gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.logging.v1"] }, + }); + let upstream_id = json_body( + post(harness.router(), "/oagw/v1/upstreams", upstream, tenant()).await, + ) + .await["id"] + .as_str() + .unwrap() + .to_owned(); + post(harness.router(), "/oagw/v1/routes", json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), tenant()) + .await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/chain.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 503); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.plugin.not_found.v1" + ); + assert_eq!(body["upstream_id"], upstream_id); +} + +#[tokio::test] +async fn plain_http_is_refused_when_not_allowed() { + // The upstream is loopback on purpose (the protocol refusal must be what + // fails the request, not the SSRF guard), so the SSRF policy is opted out + // while `allow_http_upstream` stays at its secure default. + let config = oagw::OagwConfig { + ssrf_policy: oagw::config::SsrfPolicy { + enabled: true, + allow_private_addresses: true, + }, + ..oagw::OagwConfig::default() + }; + let harness = common::Harness::new(config, None); + let server = MockServer::start(); + let upstream = json!({ + "alias": "insecure.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + }); + let response = post(harness.router(), "/oagw/v1/upstreams", upstream, tenant()).await; + assert_eq!(response.status(), 201); + let upstream_id = json_body(response).await["id"].as_str().unwrap().to_owned(); + post(harness.router(), "/oagw/v1/routes", json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), tenant()) + .await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/insecure.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 502); + assert_eq!( + json_body(response).await["type"], + "gts.cf.core.errors.err.v1~cf.oagw.protocol.error.v1" + ); +} + +#[tokio::test] +async fn unresolvable_upstream_is_a_gateway_502() { + // Port 1 on loopback refuses the connection: the gateway reports 502. + let harness = common::Harness::new(common::test_config(), None); + let upstream = json!({ + "alias": "refusing.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": "127.0.0.1", "port": 1 } ] }, + }); + let upstream_id = json_body( + post(harness.router(), "/oagw/v1/upstreams", upstream, tenant()).await, + ) + .await["id"] + .as_str() + .unwrap() + .to_owned(); + post(harness.router(), "/oagw/v1/routes", json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), tenant()) + .await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/refusing.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 502); + assert_eq!( + common::header(&response, "x-oagw-error-source").as_deref(), + Some("gateway") + ); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.downstream.error.v1" + ); + assert_eq!(body["upstream_id"], upstream_id); +} + +#[tokio::test] +async fn tenant_scoping_applies_to_the_proxy_path() { + let server = MockServer::start(); + let harness = common::Harness::new(common::test_config(), None); + create_upstream(&harness, "scoped.example.com", &server).await; + + let other = Uuid::new_v4(); + let response = harness + .send("GET", "/oagw/v1/proxy/scoped.example.com/api", None, other) + .await; + assert_eq!(response.status(), 404); + assert_eq!( + json_body(response).await["type"], + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); +} + +#[tokio::test] +async fn get_with_no_route_is_a_gateway_404() { + let server = MockServer::start(); + let harness = common::Harness::new(common::test_config(), None); + let upstream_id = create_upstream(&harness, "noroute.example.com", &server).await["id"] + .as_str() + .unwrap() + .to_owned(); + let _ = get(harness.router(), "/oagw/v1/upstreams", tenant()).await; + assert!(!upstream_id.is_empty()); + let response = harness + .send( + "GET", + "/oagw/v1/proxy/noroute.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 404); + assert_eq!( + common::header(&response, "x-oagw-error-source").as_deref(), + Some("gateway") + ); +} diff --git a/gears/system/oagw/oagw/tests/proxy_sse_test.rs b/gears/system/oagw/oagw/tests/proxy_sse_test.rs new file mode 100644 index 0000000..8b82a46 --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_sse_test.rs @@ -0,0 +1,270 @@ +// Created: 2026-08-29 by Constructor Tech +//! Server-sent events relayed chunk by chunk, and the mid-stream abort frame. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use common::{json_body, post, tenant}; +use futures_util::StreamExt; +use serde_json::json; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +const PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +/// Serve exactly one raw HTTP/1.1 request with `handler`. +async fn serve_one(handler: F) -> u16 +where + F: FnOnce(TcpStream) -> Fut + Send + 'static, + Fut: std::future::Future + Send, +{ + let listener = TcpListener::bind(("127.0.0.1", 0)).await.expect("bind"); + let port = listener.local_addr().expect("local addr").port(); + tokio::spawn(async move { + if let Ok((socket, _)) = listener.accept().await { + handler(socket).await; + } + }); + port +} + +/// Write one chunked-transfer body chunk. +async fn write_chunk(socket: &mut TcpStream, data: &[u8]) { + socket + .write_all(format!("{:x}\r\n", data.len()).as_bytes()) + .await + .expect("chunk length"); + socket.write_all(data).await.expect("chunk body"); + socket.write_all(b"\r\n").await.expect("chunk terminator"); +} + +/// Status line plus the SSE headers, all through the chunked transfer encoding. +async fn write_sse_head(socket: &mut TcpStream) { + socket + .write_all( + b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\ncache-control: no-cache\r\ntransfer-encoding: chunked\r\n\r\n", + ) + .await + .expect("sse head"); + socket.flush().await.expect("flush sse head"); +} + +async fn register_upstream(harness: &common::Harness, alias: &str, port: u16) -> String { + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": alias, + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": "127.0.0.1", "port": port } ] }, + }), + tenant(), + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + tenant(), + ) + .await; + id +} + +/// Collect the relayed body frames with a hard deadline per frame. +async fn frames(response: axum::response::Response) -> Vec { + let mut stream = response.into_body().into_data_stream(); + let mut collected = Vec::new(); + loop { + match tokio::time::timeout(std::time::Duration::from_secs(5), stream.next()).await { + Ok(Some(Ok(frame))) => collected.push(frame), + Ok(Some(Err(error))) => panic!("relay failed: {error}"), + Ok(None) | Err(_) => break, + } + } + collected +} + +#[tokio::test] +async fn sse_frames_are_relayed_as_they_arrive() { + let harness = common::Harness::new(common::test_config(), None); + let port = serve_one(|mut socket| async move { + let mut buffer = [0u8; 4096]; + let _ = socket.read(&mut buffer).await; + write_sse_head(&mut socket).await; + for event in [ + "event: alpha\ndata: 1\n\n", + "event: beta\ndata: 2\n\n", + "event: gamma\ndata: 3\n\n", + ] { + write_chunk(&mut socket, event.as_bytes()).await; + socket.flush().await.expect("flush event"); + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + socket.write_all(b"0\r\n\r\n").await.expect("end of stream"); + socket.flush().await.expect("flush end"); + }) + .await; + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "sse.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": "127.0.0.1", "port": port } ] }, + }), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + tenant(), + ) + .await; + + let request = axum::http::Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/sse.example.com/api") + .header("accept", "text/event-stream") + .body(axum::body::Body::empty()) + .unwrap(); + let response = harness.send_request(request, tenant()).await; + assert_eq!(response.status(), 200); + assert_eq!( + common::header(&response, "content-type").as_deref(), + Some("text/event-stream") + ); + // A relayed stream is produced by the upstream, so ADR-0007 attributes it to + // the upstream even though it is a success. + assert_eq!( + common::header(&response, "x-oagw-error-source").as_deref(), + Some("upstream") + ); + + let relayed = frames(response).await; + assert!(relayed.len() >= 2, "events must be relayed chunk by chunk"); + let body = relayed + .iter() + .map(|chunk| String::from_utf8_lossy(chunk.as_ref()).to_string()) + .collect::>() + .join(""); + for expected in [ + "event: alpha\ndata: 1\n\n", + "event: beta\ndata: 2\n\n", + "event: gamma\ndata: 3\n\n", + ] { + assert!(body.contains(expected), "missing {expected:?} in {body:?}"); + } + assert!( + !body.contains("event: error"), + "a complete stream must not error: {body:?}" + ); +} + +#[tokio::test] +async fn a_mid_stream_abort_becomes_an_error_frame() { + let harness = common::Harness::new(common::test_config(), None); + let port = serve_one(|mut socket| async move { + let mut buffer = [0u8; 4096]; + let _ = socket.read(&mut buffer).await; + write_sse_head(&mut socket).await; + write_chunk(&mut socket, b"event: alpha\ndata: 1\n\n").await; + socket.flush().await.expect("flush event"); + // Drop the socket without terminating the chunked body: the upstream + // stream aborts mid-flight. + drop(socket); + }) + .await; + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "abort.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": "127.0.0.1", "port": port } ] }, + }), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + tenant(), + ) + .await; + + let request = axum::http::Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/abort.example.com/api") + .header("accept", "text/event-stream") + .body(axum::body::Body::empty()) + .unwrap(); + let response = harness.send_request(request, tenant()).await; + assert_eq!(response.status(), 200, "headers are already sent"); + let relayed = frames(response).await; + let body = relayed + .iter() + .map(|chunk| String::from_utf8_lossy(chunk.as_ref()).to_string()) + .collect::>() + .join(""); + assert!( + body.contains("event: alpha\ndata: 1\n\n"), + "the first frame is intact: {body:?}" + ); + assert!( + body.contains( + "event: error\ndata: gts.cf.core.errors.err.v1~cf.oagw.stream.aborted.v1\n\n" + ), + "the abort must be reported as an error frame: {body:?}" + ); + // No credential, body or header material is ever echoed into the frame. + assert!(!body.contains("authorization"), "{body:?}"); +} + +#[tokio::test] +async fn an_upstream_sse_content_type_streams_even_without_an_accept_header() { + let harness = common::Harness::new(common::test_config(), None); + let port = serve_one(|mut socket| async move { + let mut buffer = [0u8; 4096]; + let _ = socket.read(&mut buffer).await; + write_sse_head(&mut socket).await; + write_chunk(&mut socket, b"data: only\n\n").await; + socket.write_all(b"0\r\n\r\n").await.expect("end of stream"); + socket.flush().await.expect("flush end"); + }) + .await; + register_upstream(&harness, "no-accept.example.com", port).await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/no-accept.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + let relayed = frames(response).await; + let body = relayed + .iter() + .map(|chunk| String::from_utf8_lossy(chunk.as_ref()).to_string()) + .collect::>() + .join(""); + assert!(body.contains("data: only\n\n"), "{body:?}"); +} diff --git a/gears/system/oagw/oagw/tests/proxy_ws_test.rs b/gears/system/oagw/oagw/tests/proxy_ws_test.rs new file mode 100644 index 0000000..9d951c1 --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_ws_test.rs @@ -0,0 +1,296 @@ +// Created: 2026-08-29 by Constructor Tech +//! WebSocket upgrades relayed in both directions, and upstream refusal. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use common::{json_body, post, tenant}; +use futures_util::{SinkExt, StreamExt}; +use serde_json::json; +use tokio_tungstenite::tungstenite; + +const PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +/// Echo WebSocket server: every text/binary frame is returned to the sender. +async fn echo_app() -> axum::Router { + use axum::extract::ws::{WebSocket, WebSocketUpgrade}; + use axum::routing::any; + + async fn echo(socket: WebSocket) { + let (mut sink, mut stream) = socket.split(); + while let Some(Ok(message)) = stream.next().await { + let keep_serving = match &message { + axum::extract::ws::Message::Text(text) => sink + .send(axum::extract::ws::Message::Text( + format!("echo:{text}").into(), + )) + .await + .is_ok(), + axum::extract::ws::Message::Binary(data) => { + let mut echoed = data.to_vec(); + echoed.reverse(); + sink.send(axum::extract::ws::Message::Binary(echoed.into())) + .await + .is_ok() + } + axum::extract::ws::Message::Close(_) => false, + _ => true, + }; + if !keep_serving { + break; + } + } + } + + let upgrade = |ws: WebSocketUpgrade| async move { ws.on_upgrade(echo) }; + axum::Router::new() + .route("/", any(upgrade)) + .route("/ws", any(upgrade)) +} + +/// Serve `app` on an ephemeral loopback port. +async fn serve(app: axum::Router) -> u16 { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind"); + let port = listener.local_addr().expect("local addr").port(); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("serve"); + }); + port +} + +/// The gear's router behind a middleware that stands in for host authn. +async fn serve_gateway(harness: &common::Harness) -> u16 { + use axum::extract::Request; + use axum::middleware::{Next, from_fn}; + use axum::response::Response; + + async fn inject(mut request: Request, next: Next) -> Response { + request + .extensions_mut() + .insert(common::security_for(tenant())); + next.run(request).await + } + + let app = harness.router().clone().layer(from_fn(inject)); + serve(app).await +} + +async fn register(harness: &common::Harness, alias: &str, port: u16) -> String { + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": alias, + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": "127.0.0.1", "port": port } ] }, + }), + tenant(), + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + tenant(), + ) + .await; + id +} + +#[tokio::test] +async fn websocket_frames_are_relayed_both_ways() { + let upstream_port = serve(echo_app().await).await; + let harness = common::Harness::new(common::test_config(), None); + register(&harness, "ws.example.com", upstream_port).await; + let gateway_port = serve_gateway(&harness).await; + + let (mut client, _response) = tokio_tungstenite::connect_async(format!( + "ws://127.0.0.1:{gateway_port}/oagw/v1/proxy/ws.example.com/ws" + )) + .await + .expect("upgrade through the gateway"); + assert_eq!( + _response.status(), + tungstenite::http::StatusCode::SWITCHING_PROTOCOLS + ); + + // Downstream (client → upstream → client). + client + .send(tungstenite::Message::text("ping-1")) + .await + .expect("send text"); + let echoed = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("echo within the deadline") + .expect("stream open") + .expect("frame"); + assert_eq!(echoed, tungstenite::Message::text("echo:ping-1")); + + // Binary payloads survive the tunnel. + let payload = vec![7u8, 3, 9, 42]; + client + .send(tungstenite::Message::binary(payload.clone())) + .await + .expect("send binary"); + let echoed = tokio::time::timeout(std::time::Duration::from_secs(5), client.next()) + .await + .expect("echo within the deadline") + .expect("stream open") + .expect("frame"); + assert_eq!( + echoed.into_data(), + [42u8, 9, 3, 7].to_vec(), + "reversed by the echo upstream" + ); + + // Closing the client side ends the upstream leg too. + client.close(None).await.expect("close"); +} + +#[tokio::test] +async fn a_refused_upstream_handshake_is_a_502() { + let listener = tokio::net::TcpListener::bind(("127.0.0.1", 0)) + .await + .expect("bind"); + let port = listener.local_addr().expect("local addr").port(); + drop(listener); + + let harness = common::Harness::new(common::test_config(), None); + register(&harness, "refused.example.com", port).await; + let gateway_port = serve_gateway(&harness).await; + + let error = tokio_tungstenite::connect_async(format!( + "ws://127.0.0.1:{gateway_port}/oagw/v1/proxy/refused.example.com/ws" + )) + .await + .expect_err("the upstream is not there"); + match error { + tungstenite::Error::Http(response) => { + assert_eq!( + response.status(), + tungstenite::http::StatusCode::BAD_GATEWAY + ); + } + other => panic!("expected an HTTP rejection, got {other:?}"), + } +} + +#[tokio::test] +async fn a_plain_get_to_the_proxy_path_still_relays_http() { + let upstream_port = serve(echo_app().await).await; + let harness = common::Harness::new(common::test_config(), None); + register(&harness, "plain.example.com", upstream_port).await; + let gateway_port = serve_gateway(&harness).await; + + // A plain HTTP request on the same alias is not an upgrade: the data plane + // proxies it as HTTP, and the echo server (a WebSocket endpoint) answers + // with its own handshake rejection. + let mut request = axum::http::Request::builder() + .method("GET") + .uri(format!( + "http://127.0.0.1:{gateway_port}/oagw/v1/proxy/plain.example.com/ws" + )) + .body(axum::body::Body::empty()) + .unwrap(); + request + .extensions_mut() + .insert(common::security_for(tenant())); + let response = + hyper_util::client::legacy::Client::builder(hyper_util::rt::TokioExecutor::new()) + .build_http() + .request(request) + .await + .expect("response"); + assert!( + response.status().is_client_error() || response.status().is_server_error(), + "a plain GET is forwarded upstream, which refuses it: {}", + response.status() + ); +} + +/// A request guard that always refuses, with the status it demands. +struct RejectingHandshake; + +#[async_trait::async_trait] +impl oagw::domain::plugin::GuardPlugin for RejectingHandshake { + fn id(&self) -> &str { + "test.ws-reject.v1" + } + + fn plugin_type(&self) -> &str { + "guard_plugin" + } + + async fn guard_request( + &self, + _ctx: &oagw::domain::plugin::RequestContext, + ) -> Result { + Ok(oagw::domain::plugin::GuardDecision::Reject { + status: axum::http::StatusCode::FORBIDDEN, + error_code: "WS_HANDSHAKE_REFUSED".to_owned(), + message: "the handshake is not permitted".to_owned(), + }) + } + + async fn guard_response( + &self, + _ctx: &oagw::domain::plugin::ResponseContext, + ) -> Result { + Ok(oagw::domain::plugin::GuardDecision::Allow) + } +} + +#[tokio::test] +async fn a_request_guard_rejects_the_handshake_before_the_upgrade() { + let upstream_port = serve(echo_app().await).await; + let harness = common::Harness::with_plugins(common::test_config(), None, |registry| { + registry.register_guard(std::sync::Arc::new(RejectingHandshake)); + }); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "guarded-ws.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": "127.0.0.1", "port": upstream_port } ] }, + "plugins": { "items": ["gts.cf.core.oagw.guard_plugin.v1~test.ws-reject.v1"] }, + }), + tenant(), + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + tenant(), + ) + .await; + let gateway_port = serve_gateway(&harness).await; + + let error = tokio_tungstenite::connect_async(format!( + "ws://127.0.0.1:{gateway_port}/oagw/v1/proxy/guarded-ws.example.com/ws" + )) + .await + .expect_err("the guard refuses the handshake"); + match error { + tungstenite::Error::Http(response) => { + assert_eq!( + response.status(), + tungstenite::http::StatusCode::FORBIDDEN, + "the guard's status is what the caller sees" + ); + } + other => panic!("expected an HTTP rejection, got {other:?}"), + } +} diff --git a/gears/system/oagw/oagw/tests/rate_limit_test.rs b/gears/system/oagw/oagw/tests/rate_limit_test.rs new file mode 100644 index 0000000..af2ae54 --- /dev/null +++ b/gears/system/oagw/oagw/tests/rate_limit_test.rs @@ -0,0 +1,392 @@ +// Created: 2026-08-29 by Constructor Tech +//! Rate limiting over the wire: counters, `429` and the hierarchical `min()`. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use common::{json_body, parent, post, tenant}; +use httpmock::MockServer; +use serde_json::{Value, json}; + +const PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +fn rate(rate: u32, capacity: u32) -> Value { + json!({ + "sustained": { "rate": rate, "window": "second" }, + "burst": { "capacity": capacity }, + }) +} + +fn upstream(alias: &str, server: &MockServer, limit: Value) -> Value { + json!({ + "alias": alias, + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "rate_limit": limit, + }) +} + +#[tokio::test] +async fn fourth_request_is_rejected_with_429() { + let server = MockServer::start(); + let target = server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream("throttled.example.com", &server, rate(3, 3)), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + tenant(), + ) + .await; + + for expected in [200, 200, 200] { + let response = harness + .send( + "GET", + "/oagw/v1/proxy/throttled.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), expected); + // Counters travel with every accepted response. + assert_eq!( + common::header(&response, "x-ratelimit-limit").as_deref(), + Some("3") + ); + let remaining = response + .headers() + .get("x-ratelimit-remaining") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + assert!(remaining.is_some(), "x-ratelimit-remaining must be present"); + let reset = response + .headers() + .get("x-ratelimit-reset") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()) + .expect("x-ratelimit-reset must be a unix epoch"); + assert!( + reset > 1_700_000_000, + "reset must be an epoch value: {reset}" + ); + } + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/throttled.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 429); + assert_eq!( + common::header(&response, "x-oagw-error-source").as_deref(), + Some("gateway") + ); + let retry_after = common::header(&response, "retry-after").expect("retry-after header"); + assert!(retry_after.parse::().unwrap() >= 1); + // ADR-0003: the 429 reports the budget it exhausted, next to `Retry-After`. + let limit = common::header(&response, "x-ratelimit-limit").expect("x-ratelimit-limit"); + assert_eq!(limit, "3", "the configured rate limit is reported"); + let remaining = + common::header(&response, "x-ratelimit-remaining").expect("x-ratelimit-remaining"); + assert_eq!( + remaining, "0", + "the exhausted bucket reports no tokens left" + ); + let reset = common::header(&response, "x-ratelimit-reset").expect("x-ratelimit-reset"); + assert!( + reset.parse::().unwrap() > 1_700_000_000, + "reset must be an epoch: {reset}" + ); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1" + ); + assert_eq!(body["status"], 429); + assert!(body["retry_after_seconds"].as_u64().unwrap() >= 1); + assert_eq!( + target.calls(), + 3, + "the rejected request must not reach the upstream" + ); +} + +#[tokio::test] +async fn counters_can_be_switched_off() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream( + "silent.example.com", + &server, + json!({ "sustained": { "rate": 5, "window": "second" }, "response_headers": false }), + ), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + tenant(), + ) + .await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/silent.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!(common::header(&response, "x-ratelimit-limit"), None); +} + +#[tokio::test] +async fn route_limit_can_be_stricter_than_the_upstream() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream("route-limit.example.com", &server, rate(10, 10)), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + let route = json!({ + "upstream_id": upstream_id, + "rate_limit": rate(2, 2), + "match": { "http": { "methods": ["GET"], "path": "/" } }, + }); + post(harness.router(), "/oagw/v1/routes", route, tenant()).await; + + for _ in 0..2 { + let response = harness + .send( + "GET", + "/oagw/v1/proxy/route-limit.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + assert_eq!( + common::header(&response, "x-ratelimit-limit").as_deref(), + Some("2") + ); + } + let response = harness + .send( + "GET", + "/oagw/v1/proxy/route-limit.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 429); +} + +#[tokio::test] +async fn shadowed_ancestor_limit_still_applies() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + // The ancestor's limit is stricter and its upstream is shadowed by the + // descendant's, but an `enforce` ancestor limit still applies. + let ancestor = json!({ + "alias": "hierarchy.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "rate_limit": json!({ + "sharing": "enforce", + "sustained": { "rate": 2, "window": "second" }, + "burst": { "capacity": 2 }, + }), + }); + json_body(post(harness.router(), "/oagw/v1/upstreams", ancestor, parent()).await).await; + let descendant = json!({ + "alias": "hierarchy.example.com", + "protocol": PROTOCOL, + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "rate_limit": rate(1000, 1000), + }); + let descendant_id = json_body( + post(harness.router(), "/oagw/v1/upstreams", descendant, tenant()).await, + ) + .await["id"] + .as_str() + .unwrap() + .to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ "upstream_id": descendant_id, "match": { "http": { "methods": ["GET"], "path": "/" } } }), + tenant(), + ) + .await; + + for _ in 0..2 { + let response = harness + .send( + "GET", + "/oagw/v1/proxy/hierarchy.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 200); + } + let response = harness + .send( + "GET", + "/oagw/v1/proxy/hierarchy.example.com/api", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 429); +} + +#[tokio::test] +async fn an_upstream_limit_is_one_budget_across_its_routes() { + // ADR-0003 keys the bucket on the configuring resource: an upstream limit + // is a single budget, so two routes under the same upstream draw from the + // same bucket instead of each getting a fresh one. + let server = MockServer::start(); + server.mock(|when, then| { + when.method(httpmock::Method::GET); + then.status(200).body("ok"); + }); + + let harness = common::Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "shared-budget.example.com", + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + "rate_limit": { + "sustained": { "rate": 2, "window": "second" }, + "burst": { "capacity": 2 }, + "scope": "tenant", + }, + }), + tenant(), + ) + .await, + ) + .await; + let upstream_id = created["id"].as_str().unwrap().to_owned(); + for path in ["/one", "/two"] { + post( + harness.router(), + "/oagw/v1/routes", + json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["GET"], "path": path } }, + }), + tenant(), + ) + .await; + } + + for (path, expected) in [ + ("/oagw/v1/proxy/shared-budget.example.com/one", 200), + ("/oagw/v1/proxy/shared-budget.example.com/two", 200), + ("/oagw/v1/proxy/shared-budget.example.com/one", 429), + ] { + let response = harness.send("GET", path, None, tenant()).await; + assert_eq!(response.status(), expected, "{path}"); + } +} + +#[tokio::test] +async fn unenforced_limit_strategies_are_refused_at_config_time() { + let harness = common::Harness::new(common::test_config(), None); + for strategy in ["queue", "degrade"] { + let response = post( + harness.router(), + "/oagw/v1/upstreams", + upstream( + &format!("{strategy}.example.com"), + &MockServer::start(), + json!({ + "sustained": { "rate": 5, "window": "second" }, + "strategy": strategy, + }), + ), + tenant(), + ) + .await; + assert_eq!(response.status(), 400, "{strategy}"); + } +} + +#[tokio::test] +async fn a_cost_larger_than_the_capacity_is_refused_at_config_time() { + let harness = common::Harness::new(common::test_config(), None); + let response = post( + harness.router(), + "/oagw/v1/upstreams", + upstream( + "costly.example.com", + &MockServer::start(), + json!({ + "sustained": { "rate": 5, "window": "second" }, + "burst": { "capacity": 2 }, + "cost": 5, + }), + ), + tenant(), + ) + .await; + assert_eq!(response.status(), 400); +} diff --git a/gears/system/oagw/oagw/tests/route_crud_test.rs b/gears/system/oagw/oagw/tests/route_crud_test.rs new file mode 100644 index 0000000..989bec3 --- /dev/null +++ b/gears/system/oagw/oagw/tests/route_crud_test.rs @@ -0,0 +1,343 @@ +// Created: 2026-08-29 by Constructor Tech +//! Route management CRUD, match validation and longest-prefix selection. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use common::{get, json_body, post, put, tenant}; +use httpmock::MockServer; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn route(upstream_id: &str, path: &str) -> Value { + json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["GET"], "path": path } }, + }) +} + +fn upstream_payload(host: &str, port: u16) -> Value { + json!({ + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "server": { "endpoints": [ { "scheme": "http", "host": host, "port": port } ] }, + }) +} + +#[tokio::test] +async fn route_crud_round_trip() { + let harness = common::Harness::new(common::test_config(), None); + let upstream = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream_payload("route-crud.example.com", 443), + tenant(), + ) + .await, + ) + .await; + let upstream_id = upstream["id"].as_str().unwrap().to_owned(); + + let created = json_body( + post( + harness.router(), + "/oagw/v1/routes", + route(&upstream_id, "/api"), + tenant(), + ) + .await, + ) + .await; + assert_eq!(created["upstream_id"], upstream_id.as_str()); + assert_eq!(created["enabled"], true); + assert_eq!(created["match"]["http"]["path"], "/api"); + let id = created["id"].as_str().unwrap().to_owned(); + + let list = json_body(get(harness.router(), "/oagw/v1/routes", tenant()).await).await; + assert_eq!(list["items"].as_array().unwrap().len(), 1); + + let fetched = + json_body(get(harness.router(), &format!("/oagw/v1/routes/{id}"), tenant()).await).await; + assert_eq!(fetched["match"]["http"]["methods"], json!(["GET"])); + + let replaced = json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["GET", "POST"], "path": "/api" } }, + }); + let replaced = json_body( + put( + harness.router(), + &format!("/oagw/v1/routes/{id}"), + replaced, + tenant(), + ) + .await, + ) + .await; + assert_eq!(replaced["match"]["http"]["methods"], json!(["GET", "POST"])); + + let response = harness + .send("DELETE", &format!("/oagw/v1/routes/{id}"), None, tenant()) + .await; + assert_eq!(response.status(), 204); + assert_eq!( + get(harness.router(), &format!("/oagw/v1/routes/{id}"), tenant()) + .await + .status(), + 404 + ); +} + +#[tokio::test] +async fn missing_route_is_404() { + let harness = common::Harness::new(common::test_config(), None); + let missing = Uuid::new_v4(); + assert_eq!( + get( + harness.router(), + &format!("/oagw/v1/routes/{missing}"), + tenant() + ) + .await + .status(), + 404 + ); + let payload = json!({ + "upstream_id": Uuid::new_v4().to_string(), + "match": { "http": { "methods": ["GET"], "path": "/x" } }, + }); + assert_eq!( + put( + harness.router(), + &format!("/oagw/v1/routes/{missing}"), + payload, + tenant() + ) + .await + .status(), + 404 + ); +} + +#[tokio::test] +async fn match_validation_rejects_bad_rules() { + let harness = common::Harness::new(common::test_config(), None); + let upstream = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream_payload("match-validation.example.com", 443), + tenant(), + ) + .await, + ) + .await; + let upstream_id = upstream["id"].as_str().unwrap().to_owned(); + + let cases: Vec<(Value, &str)> = vec![ + ( + json!({ "upstream_id": upstream_id, "match": { "http": { "methods": [], "path": "/api" } } }), + "empty methods", + ), + ( + json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "" } } }), + "empty path", + ), + ( + json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["GET"], "path": "api" } } }), + "relative path", + ), + ( + json!({ "upstream_id": upstream_id, "match": { "http": { "methods": ["TRACE"], "path": "/api" } } }), + "method outside the allowlist", + ), + ( + json!({ "upstream_id": upstream_id, "match": { + "http": { "methods": ["GET"], "path": "/api" }, + "grpc": { "service": "svc", "method": "m" } } }), + "both http and grpc", + ), + ( + json!({ "upstream_id": upstream_id, "match": {} }), + "neither http nor grpc", + ), + ( + json!({ "match": { "http": { "methods": ["GET"], "path": "/api" } } }), + "no upstream", + ), + ( + json!({ "upstream_id": Uuid::new_v4().to_string(), "match": { "http": { "methods": ["GET"], "path": "/api" } } }), + "unknown upstream", + ), + ( + json!({ "upstream_id": upstream_id, "match": { "grpc": { "service": "svc", "method": "" } } }), + "grpc method empty", + ), + ]; + for (payload, why) in cases { + let response = post(harness.router(), "/oagw/v1/routes", payload, tenant()).await; + assert_eq!(response.status(), 400, "expected 400 for {why}"); + } +} + +#[tokio::test] +async fn longest_prefix_wins() { + let server = MockServer::start(); + let deep = server.mock(|when, then| { + when.method(httpmock::Method::GET).path("/api/v1/things"); + then.status(200).body("deep"); + }); + + let harness = common::Harness::new(common::test_config(), None); + // Endpoint hosts are IP literals, so the upstream needs an explicit alias. + let upstream = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + json!({ + "alias": "prefix", + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "server": { "endpoints": [ { "scheme": "http", "host": server.host(), "port": server.port() } ] }, + }), + tenant(), + ) + .await, + ) + .await; + let upstream_id = upstream["id"].as_str().unwrap().to_owned(); + + // The shallow route forbids a path suffix, so a match on it is observable. + post( + harness.router(), + "/oagw/v1/routes", + json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["GET"], "path": "/api", "path_suffix_mode": "disabled" } }, + }), + tenant(), + ) + .await; + post( + harness.router(), + "/oagw/v1/routes", + route(&upstream_id, "/api/v1"), + tenant(), + ) + .await; + + // `/api/v1/things` matches the deeper route, which appends the suffix. + let response = harness + .send("GET", "/oagw/v1/proxy/prefix/api/v1/things", None, tenant()) + .await; + assert_eq!(response.status(), 200); + assert_eq!(common::body_bytes(response).await.as_ref(), b"deep"); + assert_eq!(deep.calls(), 1); + + // `/api/other` matches only the shallow route, whose suffix is forbidden. + let response = harness + .send("GET", "/oagw/v1/proxy/prefix/api/other", None, tenant()) + .await; + assert_eq!(response.status(), 400); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); + assert!(body["detail"].as_str().unwrap().contains("path suffix")); +} + +#[tokio::test] +async fn method_allowlist_excludes_a_route() { + let harness = common::Harness::new(common::test_config(), None); + let upstream = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream_payload("methods.example.com", 443), + tenant(), + ) + .await, + ) + .await; + let upstream_id = upstream["id"].as_str().unwrap().to_owned(); + post( + harness.router(), + "/oagw/v1/routes", + json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["POST"], "path": "/only-post" } }, + }), + tenant(), + ) + .await; + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/methods.example.com/only-post", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 404); + let body = json_body(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); + assert_eq!(body["status"], 404); +} + +#[tokio::test] +async fn disabled_route_is_skipped() { + let harness = common::Harness::new(common::test_config(), None); + let upstream = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream_payload("disabled-route.example.com", 443), + tenant(), + ) + .await, + ) + .await; + let upstream_id = upstream["id"].as_str().unwrap().to_owned(); + let created = json_body( + post( + harness.router(), + "/oagw/v1/routes", + route(&upstream_id, "/gone"), + tenant(), + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + let replaced = json!({ + "upstream_id": upstream_id, + "enabled": false, + "match": { "http": { "methods": ["GET"], "path": "/gone" } }, + }); + let replaced = json_body( + put( + harness.router(), + &format!("/oagw/v1/routes/{id}"), + replaced, + tenant(), + ) + .await, + ) + .await; + assert_eq!(replaced["enabled"], false); + + let response = harness + .send( + "GET", + "/oagw/v1/proxy/disabled-route.example.com/gone", + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 404); +} diff --git a/gears/system/oagw/oagw/tests/upstream_crud_test.rs b/gears/system/oagw/oagw/tests/upstream_crud_test.rs new file mode 100644 index 0000000..39f0c00 --- /dev/null +++ b/gears/system/oagw/oagw/tests/upstream_crud_test.rs @@ -0,0 +1,313 @@ +// Created: 2026-08-29 by Constructor Tech +//! Upstream management CRUD, validation and OData paging. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use common::{get, header, json_body, post, put, tenant}; +use serde_json::{Value, json}; +use uuid::Uuid; + +fn upstream(alias: Option<&str>, host: &str) -> Value { + json!({ + "alias": alias, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "server": { "endpoints": [ { "scheme": "https", "host": host, "port": 443 } ] }, + }) +} + +#[tokio::test] +async fn create_returns_201_with_location_and_dto() { + let harness = common::Harness::new(common::test_config(), None); + let response = post( + harness.router(), + "/oagw/v1/upstreams", + upstream(None, "api.example.com"), + tenant(), + ) + .await; + assert_eq!(response.status(), 201); + let location = header(&response, "location").expect("location header"); + let body = json_body(response).await; + assert_eq!(body["alias"], "api.example.com"); + assert_eq!(body["enabled"], true); + assert_eq!(body["tenant_id"], tenant().to_string()); + assert!(body["created_at"].is_string()); + let id = body["id"].as_str().unwrap(); + assert!( + location.ends_with(id), + "location '{location}' must end with '{id}'" + ); +} + +#[tokio::test] +async fn list_get_put_delete_round_trip() { + let harness = common::Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream(None, "round.trip.example.com"), + tenant(), + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + + let list = json_body(get(harness.router(), "/oagw/v1/upstreams", tenant()).await).await; + assert_eq!(list["items"].as_array().unwrap().len(), 1); + assert_eq!(list["page_info"]["limit"], 50); + assert_eq!(list["items"][0]["id"], id.as_str()); + + let fetched = json_body( + get( + harness.router(), + &format!("/oagw/v1/upstreams/{id}"), + tenant(), + ) + .await, + ) + .await; + assert_eq!(fetched["alias"], "round.trip.example.com"); + + let replaced = json!({ + "alias": "round.trip.example.com", + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "server": { "endpoints": [ { "scheme": "https", "host": "round.trip.example.com", "port": 443 } ] }, + "tags": ["edge"], + }); + let replaced = json_body( + put( + harness.router(), + &format!("/oagw/v1/upstreams/{id}"), + replaced, + tenant(), + ) + .await, + ) + .await; + assert_eq!(replaced["tags"], json!(["edge"])); + assert_eq!(replaced["id"], id.as_str()); + + let response = harness + .send( + "DELETE", + &format!("/oagw/v1/upstreams/{id}"), + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 204); + let response = get( + harness.router(), + &format!("/oagw/v1/upstreams/{id}"), + tenant(), + ) + .await; + assert_eq!(response.status(), 404); + let response = harness + .send( + "DELETE", + &format!("/oagw/v1/upstreams/{id}"), + None, + tenant(), + ) + .await; + assert_eq!(response.status(), 404); +} + +#[tokio::test] +async fn missing_resource_is_404() { + let harness = common::Harness::new(common::test_config(), None); + let missing = Uuid::new_v4(); + let response = get( + harness.router(), + &format!("/oagw/v1/upstreams/{missing}"), + tenant(), + ) + .await; + assert_eq!(response.status(), 404); + let response = put( + harness.router(), + &format!("/oagw/v1/upstreams/{missing}"), + upstream(None, "x.example.com"), + tenant(), + ) + .await; + assert_eq!(response.status(), 404); +} + +#[tokio::test] +async fn enable_and_disable_toggle_the_flag() { + let harness = common::Harness::new(common::test_config(), None); + let created = json_body( + post( + harness.router(), + "/oagw/v1/upstreams", + upstream(None, "toggle.example.com"), + tenant(), + ) + .await, + ) + .await; + let id = created["id"].as_str().unwrap().to_owned(); + + let disabled = json_body( + harness + .send( + "POST", + &format!("/oagw/v1/upstreams/{id}/disable"), + Some(json!({"enabled": false})), + tenant(), + ) + .await, + ) + .await; + assert_eq!(disabled["enabled"], false); + + let enabled = json_body( + harness + .send( + "POST", + &format!("/oagw/v1/upstreams/{id}/enable"), + Some(json!({"enabled": true})), + tenant(), + ) + .await, + ) + .await; + assert_eq!(enabled["enabled"], true); +} + +#[tokio::test] +async fn validation_rejects_bad_payloads() { + let harness = common::Harness::new(common::test_config(), None); + let cases: Vec<(Value, &str)> = vec![ + ( + json!({ "alias": "no-server.example.com", "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" }), + "missing server", + ), + ( + json!({ "alias": "no-protocol.example.com", "server": { "endpoints": [ { "scheme": "https", "host": "h" } ] } }), + "missing protocol", + ), + ( + json!({ "alias": "empty-pool.example.com", "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", "server": { "endpoints": [] } }), + "empty endpoint pool", + ), + ( + json!({ "alias": "bad-port.example.com", "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", "server": { "endpoints": [ { "scheme": "https", "host": "h.example.com", "port": 0 } ] } }), + "port out of range", + ), + ( + json!({ "alias": "bad-scheme.example.com", "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", "server": { "endpoints": [ { "scheme": "ftp", "host": "h.example.com" } ] } }), + "unknown scheme", + ), + ( + json!({ "alias": "bad-tag.example.com", "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", "server": { "endpoints": [ { "scheme": "https", "host": "h.example.com" } ] }, "tags": ["Bad Tag"] }), + "tag pattern", + ), + ( + json!({ "alias": "unknown.example.com", "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.mqtt.v1", "server": { "endpoints": [ { "scheme": "https", "host": "h.example.com" } ] } }), + "unknown protocol", + ), + ( + json!({ "alias": "extra.example.com", "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", "server": { "endpoints": [ { "scheme": "https", "host": "h.example.com" } ] }, "surprise": 1 }), + "unknown field", + ), + ]; + for (payload, why) in cases { + let response = post(harness.router(), "/oagw/v1/upstreams", payload, tenant()).await; + assert_eq!(response.status(), 400, "expected 400 for {why}"); + let body = json_body(response).await; + assert_eq!(body["status"], 400, "problem document for {why}"); + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); + } +} + +#[tokio::test] +async fn duplicate_alias_is_a_conflict() { + let harness = common::Harness::new(common::test_config(), None); + let first = post( + harness.router(), + "/oagw/v1/upstreams", + upstream(Some("dup.example.com"), "dup.example.com"), + tenant(), + ) + .await; + assert_eq!(first.status(), 201); + let second = post( + harness.router(), + "/oagw/v1/upstreams", + upstream(Some("dup.example.com"), "other.example.com"), + tenant(), + ) + .await; + assert_eq!(second.status(), 400); + let body = json_body(second).await; + let detail = body["detail"].as_str().unwrap(); + assert!( + detail.contains("dup.example.com"), + "detail must name the alias: {detail}" + ); +} + +#[tokio::test] +async fn top_is_clamped_and_defaults_to_fifty() { + let harness = common::Harness::new(common::test_config(), None); + for index in 0..3 { + let payload = upstream(None, &format!("host-{index}.example.com")); + let response = post(harness.router(), "/oagw/v1/upstreams", payload, tenant()).await; + assert_eq!(response.status(), 201); + } + + let default_page = json_body(get(harness.router(), "/oagw/v1/upstreams", tenant()).await).await; + assert_eq!(default_page["page_info"]["limit"], 50); + assert_eq!(default_page["items"].as_array().unwrap().len(), 3); + + let clamped = + json_body(get(harness.router(), "/oagw/v1/upstreams?$top=500", tenant()).await).await; + assert_eq!(clamped["page_info"]["limit"], 100); + + let offset = json_body( + get( + harness.router(), + "/oagw/v1/upstreams?$top=2&$skip=1", + tenant(), + ) + .await, + ) + .await; + assert_eq!(offset["page_info"]["limit"], 2); + assert_eq!(offset["items"].as_array().unwrap().len(), 2); + + let ordered = json_body( + get( + harness.router(), + "/oagw/v1/upstreams?$orderby=created_at%20desc", + tenant(), + ) + .await, + ) + .await; + assert_eq!(ordered["items"].as_array().unwrap().len(), 3); +} + +#[tokio::test] +async fn malformed_json_is_a_validation_error() { + let harness = common::Harness::new(common::test_config(), None); + let response = harness + .send( + "POST", + "/oagw/v1/upstreams", + Some(json!({ "alias": "half" })), + tenant(), + ) + .await; + assert_eq!(response.status(), 400); +}