diff --git a/Cargo.lock b/Cargo.lock index 9c02857..0253592 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1592,12 +1592,14 @@ dependencies = [ "rcgen", "rustls", "rustls-pki-types", + "secrecy", "serde", "serde_json", "thiserror 2.0.18", "tokio", "tokio-retry", "tokio-rustls", + "tokio-util", "tower", "tracing", "url", diff --git a/gears/system/oagw/oagw/Cargo.toml b/gears/system/oagw/oagw/Cargo.toml index a18b934..6fb5733 100644 --- a/gears/system/oagw/oagw/Cargo.toml +++ b/gears/system/oagw/oagw/Cargo.toml @@ -15,6 +15,9 @@ metadata.docs.rs.all-features = true name = "oagw" path = "src/lib.rs" +[lints] +workspace = true + [features] # FIPS-140-3: compile TLS deps with FIPS-approved cipher suites only fips = ["toolkit-http/fips"] @@ -39,6 +42,7 @@ toolkit-canonical-errors = { workspace = true, features = ["axum"] } toolkit-gts = { workspace = true } toolkit-http = { workspace = true } toolkit-security = { workspace = true } +secrecy = { workspace = true } toolkit-macros = { workspace = true } inventory = { workspace = true } async-trait = { workspace = true } @@ -109,3 +113,5 @@ httpmock = { workspace = true } tokio-rustls = { workspace = true } rustls = { workspace = true } futures-util = { workspace = true } +tokio-util = { workspace = true } +axum = { workspace = true, features = ["ws", "macros"] } 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..bc779cc --- /dev/null +++ b/gears/system/oagw/oagw/src/api/mod.rs @@ -0,0 +1,3 @@ +//! HTTP surface of the `oagw` gear. + +pub mod rest; diff --git a/gears/system/oagw/oagw/src/api/rest/convert.rs b/gears/system/oagw/oagw/src/api/rest/convert.rs new file mode 100644 index 0000000..f7c49cd --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/convert.rs @@ -0,0 +1,823 @@ +//! Conversions between the wire DTOs and the domain model. + +use std::collections::BTreeMap; + +use uuid::Uuid; + +use crate::api::rest::dto as wire; +use crate::domain::dto as domain; +use crate::domain::error::DomainError; + +/// Parses a scheme name. +fn parse_scheme(name: &str) -> Result { + match name { + "http" => Ok(domain::Scheme::Http), + "https" => Ok(domain::Scheme::Https), + "ws" => Ok(domain::Scheme::Ws), + "wss" => Ok(domain::Scheme::Wss), + "wt" => Ok(domain::Scheme::Wt), + "grpc" => Ok(domain::Scheme::Grpc), + other => Err(DomainError::Validation(format!( + "endpoint scheme `{other}` is not one of https, wss, wt, grpc, http" + ))), + } +} + +fn parse_protocol(id: &str) -> Result { + domain::Protocol::from_gts_id(id).ok_or_else(|| { + DomainError::Validation(format!( + "protocol `{id}` is not a known oagw protocol identifier" + )) + }) +} + +fn parse_sharing(name: &str, field: &str) -> Result { + match name { + "" | "private" => Ok(domain::Sharing::Private), + "inherit" => Ok(domain::Sharing::Inherit), + "enforce" => Ok(domain::Sharing::Enforce), + other => Err(DomainError::Validation(format!( + "`{field}` must be private, inherit or enforce, not `{other}`" + ))), + } +} + +fn parse_passthrough(name: &str) -> Result { + match name { + "" | "none" => Ok(domain::HeaderPassthrough::None), + "allowlist" => Ok(domain::HeaderPassthrough::Allowlist), + "all" => Ok(domain::HeaderPassthrough::All), + other => Err(DomainError::Validation(format!( + "passthrough must be none, allowlist or all, not `{other}`" + ))), + } +} + +fn parse_window(name: &str) -> Result { + match name { + "" | "second" => Ok(domain::RateWindow::Second), + "minute" => Ok(domain::RateWindow::Minute), + "hour" => Ok(domain::RateWindow::Hour), + "day" => Ok(domain::RateWindow::Day), + other => Err(DomainError::Validation(format!( + "rate window must be second, minute, hour or day, not `{other}`" + ))), + } +} + +fn parse_algorithm(name: &str) -> Result { + match name { + "" | "token_bucket" => Ok(domain::RateAlgorithm::TokenBucket), + "sliding_window" => Ok(domain::RateAlgorithm::SlidingWindow), + other => Err(DomainError::Validation(format!( + "rate algorithm must be token_bucket or sliding_window, not `{other}`" + ))), + } +} + +fn parse_scope(name: &str) -> Result { + match name { + "" | "tenant" => Ok(domain::RateScope::Tenant), + "global" => Ok(domain::RateScope::Global), + "user" => Ok(domain::RateScope::User), + "ip" => Ok(domain::RateScope::Ip), + "route" => Ok(domain::RateScope::Route), + other => Err(DomainError::Validation(format!( + "rate scope must be global, tenant, user, ip or route, not `{other}`" + ))), + } +} + +fn parse_strategy(name: &str) -> Result { + match name { + "" | "reject" => Ok(domain::RateStrategy::Reject), + "queue" => Ok(domain::RateStrategy::Queue), + "degrade" => Ok(domain::RateStrategy::Degrade), + other => Err(DomainError::Validation(format!( + "rate strategy must be reject, queue or degrade, not `{other}`" + ))), + } +} + +fn parse_methods(values: &[String]) -> Result, DomainError> { + if values.is_empty() { + return Err(DomainError::Validation( + "match.http.methods must name at least one method".into(), + )); + } + values + .iter() + .map(|value| { + domain::HttpMethod::parse(value).ok_or_else(|| { + DomainError::Validation(format!( + "method `{value}` is not one of GET, POST, PUT, DELETE, PATCH" + )) + }) + }) + .collect() +} + +fn parse_suffix_mode(name: &str) -> Result { + match name { + "" | "append" => Ok(domain::PathSuffixMode::Append), + "disabled" => Ok(domain::PathSuffixMode::Disabled), + other => Err(DomainError::Validation(format!( + "path_suffix_mode must be append or disabled, not `{other}`" + ))), + } +} + +fn json_object(value: &serde_json::Value) -> BTreeMap { + match value { + serde_json::Value::Object(map) => map.clone().into_iter().collect(), + serde_json::Value::Null => BTreeMap::new(), + other => { + let mut map = BTreeMap::new(); + map.insert("value".to_owned(), other.clone()); + map + } + } +} + +fn json_value(map: &BTreeMap) -> serde_json::Value { + serde_json::Value::Object(map.clone().into_iter().collect()) +} + +/// DTO → domain. +/// +/// # Errors +/// +/// Returns a validation error when `scheme` is not one of the supported +/// protocol schemes. +pub fn to_endpoint(source: &wire::EndpointDto) -> Result { + Ok(domain::Endpoint { + scheme: parse_scheme(&source.scheme)?, + host: source.host.clone(), + port: source.port, + }) +} + +/// DTO → domain. +/// +/// # Errors +/// +/// Returns a validation error when any endpoint of the server carries an +/// unsupported scheme. +pub fn to_server(source: &wire::ServerDto) -> Result { + let endpoints: Result, DomainError> = + source.endpoints.iter().map(to_endpoint).collect(); + Ok(domain::ServerConfig { + endpoints: endpoints?, + }) +} + +/// DTO → domain. +/// +/// # Errors +/// +/// Returns a validation error when `auth.sharing` is not one of `private`, +/// `inherit` or `enforce`. +pub fn to_auth(source: &wire::AuthDto) -> Result { + Ok(domain::AuthConfig { + auth_type: source.auth_type.clone(), + sharing: parse_sharing(&source.sharing, "auth.sharing")?, + config: json_value(&source.config), + }) +} + +/// DTO → domain. +/// +/// # Errors +/// +/// Returns a validation error when `passthrough` is not one of `none`, +/// `allowlist` or `all`. +pub fn to_request_rules( + source: &wire::RequestHeaderRulesDto, +) -> Result { + Ok(domain::RequestHeaderRules { + set: source.set.clone(), + add: source.add.clone(), + remove: source.remove.clone(), + passthrough: parse_passthrough(&source.passthrough)?, + passthrough_allowlist: source.passthrough_allowlist.clone(), + }) +} + +/// DTO → domain. +/// +/// # Errors +/// +/// Returns a validation error when either the request or the response header +/// rules carry an unsupported `passthrough` value. +pub fn to_headers(source: &wire::HeadersDto) -> Result { + Ok(domain::HeadersConfig { + request: source + .request + .as_ref() + .map(to_request_rules) + .transpose()?, + response: source + .response + .as_ref() + .map(|rules| domain::ResponseHeaderRules { + set: rules.set.clone(), + add: rules.add.clone(), + remove: rules.remove.clone(), + }), + }) +} + +/// DTO → domain. +/// +/// An item may be a bare reference or an object carrying that plugin's +/// configuration; the ADR-0009 form is the object one, the published schema's +/// is the bare one, and both are admitted here. +/// +/// # Errors +/// +/// Returns a validation error when a `plugins.items[]` entry is neither a +/// reference nor an object carrying a `plugin_ref`, when the object form lacks +/// a usable reference, or when `sharing` is not one of `private`, `inherit` or +/// `enforce`. +pub fn to_plugins(source: &wire::PluginsDto) -> Result { + let mut items = Vec::with_capacity(source.items.len()); + let mut config = BTreeMap::new(); + for item in &source.items { + let (reference, config_document) = plugin_item(item)?; + items.push(reference.to_owned()); + if let Some(document) = config_document { + config.insert(reference.to_owned(), document); + } + } + Ok(domain::PluginsConfig { + sharing: parse_sharing(&source.sharing, "plugins.sharing")?, + items, + config, + }) +} + +/// Splits one `plugins.items[]` entry into its reference and config document. +fn plugin_item(item: &serde_json::Value) -> Result<(&str, Option), DomainError> { + match item { + serde_json::Value::String(reference) => Ok((reference.as_str(), None)), + serde_json::Value::Object(fields) => { + let reference = fields + .get("plugin_ref") + .or_else(|| fields.get("ref")) + .or_else(|| fields.get("id")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + DomainError::Validation( + "a plugin item object requires a `plugin_ref` string".into(), + ) + })?; + let config = fields + .get("config") + .filter(|config| !config.is_null()) + .cloned(); + Ok((reference, config)) + } + _ => Err(DomainError::Validation( + "a plugin item must be a reference or an object with a `plugin_ref`".into(), + )), + } +} + +/// DTO → domain. +/// +/// # Errors +/// +/// Returns a validation error when `sharing`, `algorithm`, `window`, `scope` +/// or `strategy` names an unsupported value. +pub fn to_rate_limit(source: &wire::RateLimitDto) -> Result { + Ok(domain::RateLimitConfig { + sharing: parse_sharing(&source.sharing, "rate_limit.sharing")?, + algorithm: parse_algorithm(&source.algorithm)?, + sustained: domain::SustainedRate { + rate: source.sustained.rate, + window: parse_window(&source.sustained.window)?, + }, + burst: source.burst.as_ref().map(|burst| domain::Burst { + capacity: burst.capacity, + }), + scope: parse_scope(&source.scope)?, + strategy: parse_strategy(&source.strategy)?, + cost: source.cost, + }) +} + +/// DTO → domain. +/// +/// # Errors +/// +/// Returns a validation error when `cors.sharing` is not one of `private`, +/// `inherit` or `enforce`. +pub fn to_cors(source: &wire::CorsDto) -> Result { + Ok(domain::CorsConfig { + sharing: parse_sharing(&source.sharing, "cors.sharing")?, + enabled: source.enabled, + allowed_origins: source.allowed_origins.clone(), + allowed_methods: source.allowed_methods.clone(), + expose_headers: source.expose_headers.clone(), + allow_credentials: source.allow_credentials, + }) +} + +/// DTO → domain. +/// +/// # Errors +/// +/// Returns a validation error when the match carries both or neither of `http` +/// and `grpc`, when the HTTP match names no method or an unknown one, when the +/// gRPC match omits `service` or `method`, or when `path_suffix_mode` is +/// unsupported. +pub fn to_match(source: &wire::MatchDto) -> Result { + match (&source.http, &source.grpc) { + // Both arms reject the same two shapes (both set, neither set) with the + // same message, so they stay a single arm. + (Some(_), Some(_)) | (None, None) => Err(DomainError::Validation( + "match must carry exactly one of `http` or `grpc`".into(), + )), + (Some(http), None) => Ok(domain::MatchRule::Http(domain::HttpMatch { + methods: parse_methods(&http.methods)?, + path: http.path.clone(), + query_allowlist: http.query_allowlist.clone(), + path_suffix_mode: parse_suffix_mode(&http.path_suffix_mode)?, + })), + (None, Some(grpc)) => { + if grpc.service.is_empty() || grpc.method.is_empty() { + return Err(DomainError::Validation( + "match.grpc requires both `service` and `method`".into(), + )); + } + Ok(domain::MatchRule::Grpc(domain::GrpcMatch { + service: grpc.service.clone(), + method: grpc.method.clone(), + })) + } + } +} + +fn from_endpoint(source: &domain::Endpoint) -> wire::EndpointDto { + wire::EndpointDto { + scheme: source.scheme.as_str().to_owned(), + host: source.host.clone(), + port: source.port, + } +} + +fn from_server(source: &domain::ServerConfig) -> wire::ServerDto { + wire::ServerDto { + endpoints: source + .endpoints + .iter() + .map(from_endpoint) + .collect(), + } +} + +fn from_auth(source: &domain::AuthConfig) -> wire::AuthDto { + wire::AuthDto { + auth_type: source.auth_type.clone(), + sharing: source.sharing.as_wire().to_owned(), + config: json_object(&source.config), + } +} + +fn from_request_rules(source: &domain::RequestHeaderRules) -> wire::RequestHeaderRulesDto { + wire::RequestHeaderRulesDto { + set: source.set.clone(), + add: source.add.clone(), + remove: source.remove.clone(), + passthrough: match source.passthrough { + domain::HeaderPassthrough::None => "none".to_owned(), + domain::HeaderPassthrough::Allowlist => "allowlist".to_owned(), + domain::HeaderPassthrough::All => "all".to_owned(), + }, + passthrough_allowlist: source.passthrough_allowlist.clone(), + } +} + +fn from_headers(source: &domain::HeadersConfig) -> wire::HeadersDto { + wire::HeadersDto { + request: source.request.as_ref().map(from_request_rules), + response: source.response.as_ref().map(|rules| wire::ResponseHeaderRulesDto { + set: rules.set.clone(), + add: rules.add.clone(), + remove: rules.remove.clone(), + }), + } +} + +fn from_plugins(source: &domain::PluginsConfig) -> wire::PluginsDto { + // A reference with a configuration document round-trips as the object form; + // a bare one stays bare, matching the published schema's primary shape. A + // UUID-backed reference always carries the `plugin_uuid` extracted from it, + // which is how a custom plugin's binding is keyed in storage. + let items = source + .items + .iter() + .map(|reference| match (source.config.get(reference), crate::domain::gts_helpers::plugin_uuid_of(reference)) { + (Some(config), _) => serde_json::json!({"plugin_ref": reference, "config": config}), + (None, Some(uuid)) => serde_json::json!({"plugin_ref": reference, "plugin_uuid": uuid}), + (None, None) => serde_json::Value::String(reference.clone()), + }) + .collect(); + wire::PluginsDto { + sharing: source.sharing.as_wire().to_owned(), + items, + } +} + +fn from_rate_limit(source: &domain::RateLimitConfig) -> wire::RateLimitDto { + wire::RateLimitDto { + sharing: source.sharing.as_wire().to_owned(), + algorithm: match source.algorithm { + domain::RateAlgorithm::TokenBucket => "token_bucket".to_owned(), + domain::RateAlgorithm::SlidingWindow => "sliding_window".to_owned(), + }, + sustained: wire::SustainedRateDto { + rate: source.sustained.rate, + window: match source.sustained.window { + domain::RateWindow::Second => "second".to_owned(), + domain::RateWindow::Minute => "minute".to_owned(), + domain::RateWindow::Hour => "hour".to_owned(), + domain::RateWindow::Day => "day".to_owned(), + }, + }, + burst: source.burst.as_ref().map(|burst| wire::BurstDto { + capacity: burst.capacity, + }), + scope: match source.scope { + domain::RateScope::Global => "global".to_owned(), + domain::RateScope::Tenant => "tenant".to_owned(), + domain::RateScope::User => "user".to_owned(), + domain::RateScope::Ip => "ip".to_owned(), + domain::RateScope::Route => "route".to_owned(), + }, + strategy: match source.strategy { + domain::RateStrategy::Reject => "reject".to_owned(), + domain::RateStrategy::Queue => "queue".to_owned(), + domain::RateStrategy::Degrade => "degrade".to_owned(), + }, + cost: source.cost, + } +} + +fn from_cors(source: &domain::CorsConfig) -> wire::CorsDto { + wire::CorsDto { + sharing: source.sharing.as_wire().to_owned(), + enabled: source.enabled, + allowed_origins: source.allowed_origins.clone(), + allowed_methods: source.allowed_methods.clone(), + expose_headers: source.expose_headers.clone(), + allow_credentials: source.allow_credentials, + } +} + +fn from_match(source: &domain::MatchRule) -> wire::MatchDto { + match source { + domain::MatchRule::Http(http) => wire::MatchDto { + http: Some(wire::HttpMatchDto { + methods: http.methods.iter().map(|method| method.as_str().to_owned()).collect(), + path: http.path.clone(), + query_allowlist: http.query_allowlist.clone(), + path_suffix_mode: match http.path_suffix_mode { + domain::PathSuffixMode::Append => "append".to_owned(), + domain::PathSuffixMode::Disabled => "disabled".to_owned(), + }, + }), + grpc: None, + }, + domain::MatchRule::Grpc(grpc) => wire::MatchDto { + http: None, + grpc: Some(wire::GrpcMatchDto { + service: grpc.service.clone(), + method: grpc.method.clone(), + }), + }, + } +} + +/// Domain → wire. +#[must_use] +pub fn from_upstream(source: &domain::Upstream) -> wire::UpstreamDto { + wire::UpstreamDto { + id: source.id, + tenant_id: source.tenant_id, + enabled: source.enabled, + alias: source.alias.clone(), + tags: source.tags.clone(), + server: from_server(&source.server), + protocol: source.protocol.gts_id().to_owned(), + auth: source.auth.as_ref().map(from_auth), + headers: source.headers.as_ref().map(from_headers), + plugins: source.plugins.as_ref().map(from_plugins), + rate_limit: source.rate_limit.as_ref().map(from_rate_limit), + cors: source.cors.as_ref().map(from_cors), + created_at: source.created_at.clone(), + updated_at: source.updated_at.clone(), + } +} + +/// Domain → wire. +#[must_use] +pub fn from_route(source: &domain::Route) -> wire::RouteDto { + wire::RouteDto { + id: source.id, + tenant_id: source.tenant_id, + upstream_id: source.upstream_id, + enabled: source.enabled, + tags: source.tags.clone(), + match_rule: from_match(&source.match_rule), + plugins: source.plugins.as_ref().map(from_plugins), + rate_limit: source.rate_limit.as_ref().map(from_rate_limit), + cors: source.cors.as_ref().map(from_cors), + created_at: source.created_at.clone(), + updated_at: source.updated_at.clone(), + } +} + +/// Domain → wire. +#[must_use] +pub fn from_plugin(source: &domain::Plugin) -> wire::PluginDto { + wire::PluginDto { + id: source.id, + tenant_id: source.tenant_id, + plugin_type: source.plugin_type.clone(), + name: source.name.clone(), + source: source.source.clone(), + config: json_object(&source.config), + gc_eligible_at: source.gc_eligible_at.clone(), + created_at: source.created_at.clone(), + } +} + +/// Builds a domain upstream from a create request. +/// +/// # Errors +/// +/// Returns a validation error when the protocol identifier, endpoint schemes or +/// any nested auth, header, plugin, rate-limit or CORS configuration is +/// invalid. +pub fn to_upstream( + request: &wire::CreateUpstreamRequest, + id: Uuid, + tenant_id: Uuid, +) -> Result { + Ok(domain::Upstream { + id, + tenant_id, + enabled: request.enabled, + alias: request.alias.clone().unwrap_or_default(), + tags: request.tags.clone(), + server: to_server(&request.server)?, + protocol: parse_protocol(&request.protocol)?, + auth: request.auth.as_ref().map(to_auth).transpose()?, + headers: request.headers.as_ref().map(to_headers).transpose()?, + plugins: request.plugins.as_ref().map(to_plugins).transpose()?, + rate_limit: request.rate_limit.as_ref().map(to_rate_limit).transpose()?, + cors: request.cors.as_ref().map(to_cors).transpose()?, + created_at: None, + updated_at: None, + }) +} + +/// Builds a domain upstream from a replace request. +/// +/// # Errors +/// +/// Returns a validation error when a field the request overrides (protocol, +/// server, auth, headers, plugins, rate limit or CORS) fails to convert; fields +/// the request omits keep the base upstream's values and cannot fail. +pub fn to_upstream_update( + request: &wire::UpdateUpstreamRequest, + base: &domain::Upstream, +) -> Result { + Ok(domain::Upstream { + id: base.id, + tenant_id: base.tenant_id, + enabled: request.enabled.unwrap_or(base.enabled), + alias: base.alias.clone(), + tags: request.tags.clone().unwrap_or_else(|| base.tags.clone()), + server: match &request.server { + Some(server) => to_server(server)?, + None => base.server.clone(), + }, + protocol: match &request.protocol { + Some(protocol) => parse_protocol(protocol)?, + None => base.protocol, + }, + auth: match &request.auth { + Some(auth) => Some(to_auth(auth)?), + None => base.auth.clone(), + }, + headers: match &request.headers { + Some(headers) => Some(to_headers(headers)?), + None => base.headers.clone(), + }, + plugins: match &request.plugins { + Some(plugins) => Some(to_plugins(plugins)?), + None => base.plugins.clone(), + }, + rate_limit: match &request.rate_limit { + Some(rate_limit) => Some(to_rate_limit(rate_limit)?), + None => base.rate_limit.clone(), + }, + cors: match &request.cors { + Some(cors) => Some(to_cors(cors)?), + None => base.cors.clone(), + }, + created_at: base.created_at.clone(), + updated_at: base.updated_at.clone(), + }) +} + +/// Builds a domain route from a create request. +/// +/// # Errors +/// +/// Returns a validation error when the match rule is not exactly one of `http` +/// or `grpc`, or when a nested plugin, rate-limit or CORS configuration is +/// invalid. +pub fn to_route( + request: &wire::CreateRouteRequest, + id: Uuid, + tenant_id: Uuid, +) -> Result { + Ok(domain::Route { + id, + tenant_id, + upstream_id: request.upstream_id, + enabled: request.enabled, + tags: request.tags.clone(), + match_rule: to_match(&request.match_rule)?, + plugins: request.plugins.as_ref().map(to_plugins).transpose()?, + rate_limit: request.rate_limit.as_ref().map(to_rate_limit).transpose()?, + cors: request.cors.as_ref().map(to_cors).transpose()?, + created_at: None, + updated_at: None, + }) +} + +/// Builds a domain route from a replace request. +/// +/// # Errors +/// +/// Returns a validation error when a field the request overrides (match rule, +/// plugins, rate limit or CORS) fails to convert; fields the request omits keep +/// the base route's values and cannot fail. +pub fn to_route_update( + request: &wire::UpdateRouteRequest, + base: &domain::Route, +) -> Result { + Ok(domain::Route { + id: base.id, + tenant_id: base.tenant_id, + upstream_id: base.upstream_id, + enabled: request.enabled.unwrap_or(base.enabled), + tags: request.tags.clone().unwrap_or_else(|| base.tags.clone()), + match_rule: match &request.match_rule { + Some(match_rule) => to_match(match_rule)?, + None => base.match_rule.clone(), + }, + plugins: match &request.plugins { + Some(plugins) => Some(to_plugins(plugins)?), + None => base.plugins.clone(), + }, + rate_limit: match &request.rate_limit { + Some(rate_limit) => Some(to_rate_limit(rate_limit)?), + None => base.rate_limit.clone(), + }, + cors: match &request.cors { + Some(cors) => Some(to_cors(cors)?), + None => base.cors.clone(), + }, + created_at: base.created_at.clone(), + updated_at: base.updated_at.clone(), + }) +} + +/// Domain → the replacement request a `PUT` echoes back. +#[must_use] +pub fn upstream_to_update_request(source: &domain::Upstream) -> wire::UpdateUpstreamRequest { + wire::UpdateUpstreamRequest { + enabled: Some(source.enabled), + tags: Some(source.tags.clone()), + server: Some(from_server(&source.server)), + protocol: Some(source.protocol.gts_id().to_owned()), + auth: source.auth.as_ref().map(from_auth), + headers: source.headers.as_ref().map(from_headers), + plugins: source.plugins.as_ref().map(from_plugins), + rate_limit: source.rate_limit.as_ref().map(from_rate_limit), + cors: source.cors.as_ref().map(from_cors), + } +} + +/// Domain → the replacement request a `PUT` echoes back. +#[must_use] +pub fn route_to_update_request(source: &domain::Route) -> wire::UpdateRouteRequest { + wire::UpdateRouteRequest { + enabled: Some(source.enabled), + tags: Some(source.tags.clone()), + match_rule: Some(from_match(&source.match_rule)), + plugins: source.plugins.as_ref().map(from_plugins), + rate_limit: source.rate_limit.as_ref().map(from_rate_limit), + cors: source.cors.as_ref().map(from_cors), + } +} + +/// Builds a domain plugin from a create request. +/// +/// # Errors +/// +/// Returns a validation error when `name` is blank or when `plugin_type` is not +/// one of `auth_plugin`, `guard_plugin` or `transform_plugin`. +pub fn to_plugin( + request: &wire::CreatePluginRequest, + id: Uuid, + tenant_id: Uuid, +) -> Result { + if request.name.trim().is_empty() { + return Err(DomainError::Validation( + "a plugin requires a non-empty `name`".into(), + )); + } + if !matches!( + request.plugin_type.as_str(), + "auth_plugin" | "guard_plugin" | "transform_plugin" + ) { + return Err(DomainError::Validation(format!( + "plugin_type must be auth_plugin, guard_plugin or transform_plugin, not `{}`", + request.plugin_type + ))); + } + Ok(domain::Plugin { + id, + tenant_id, + plugin_type: request.plugin_type.clone(), + name: request.name.clone(), + source: request.source.clone(), + config: json_value(&request.config), + gc_eligible_at: None, + created_at: None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn endpoint(scheme: &str, host: &str, port: u16) -> wire::EndpointDto { + wire::EndpointDto { + scheme: scheme.to_owned(), + host: host.to_owned(), + port, + } + } + + #[test] + fn round_trips_an_upstream() { + let request = wire::CreateUpstreamRequest { + server: wire::ServerDto { + endpoints: vec![endpoint("https", "api.openai.com", 443)], + }, + protocol: crate::domain::gts_helpers::PROTOCOL_HTTP.to_owned(), + ..wire::CreateUpstreamRequest::default() + }; + let upstream = to_upstream(&request, Uuid::new_v4(), Uuid::new_v4()).expect("converts"); + let back = from_upstream(&upstream); + assert_eq!(back.protocol, crate::domain::gts_helpers::PROTOCOL_HTTP); + assert_eq!(back.server.endpoints.len(), 1); + assert_eq!(back.server.endpoints[0].scheme, "https"); + } + + #[test] + fn rejects_an_unknown_scheme() { + let request = wire::EndpointDto { + scheme: "ftp".to_owned(), + host: "a.com".to_owned(), + port: 21, + }; + let error = to_endpoint(&request).expect_err("rejected"); + assert_eq!(error.status(), 400); + } + + #[test] + fn rejects_both_match_variants() { + let dto = wire::MatchDto { + http: Some(wire::HttpMatchDto { + methods: vec!["GET".to_owned()], + path: "/v1".to_owned(), + query_allowlist: vec![], + path_suffix_mode: "append".to_owned(), + }), + grpc: Some(wire::GrpcMatchDto { + service: "svc".to_owned(), + method: "Get".to_owned(), + }), + }; + let error = to_match(&dto).expect_err("rejected"); + assert_eq!(error.status(), 400); + } +} 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..bf421b8 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/dto.rs @@ -0,0 +1,590 @@ +//! Wire DTOs mirroring `docs/schemas/*.json`. +//! +//! The wire shapes are deliberately separate from `domain::dto`: the wire +//! spells `match`, accepts an optional `port` defaulted per scheme, and +//! serializes `protocol` as the GTS identifier the JSON Schemas declare. + +use std::collections::BTreeMap; + +use uuid::Uuid; + + +/// One endpoint of an upstream pool. +#[toolkit_macros::api_dto(request, response)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct EndpointDto { + /// `https`, `wss`, `wt`, `grpc` and, in this deployment, `http`. + #[serde(default = "default_scheme")] + pub scheme: String, + /// Hostname or IP address. + pub host: String, + /// Port; defaults to the scheme's default. + #[serde(default = "default_port")] + pub port: u16, +} + +fn default_scheme() -> String { + "https".to_owned() +} + +fn default_port() -> u16 { + 443 +} + +/// `server` block. +#[toolkit_macros::api_dto(request, response)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ServerDto { + /// At least one endpoint. + pub endpoints: Vec, +} + +/// Auth block. +#[toolkit_macros::api_dto(request, response)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct AuthDto { + /// Auth plugin GTS identifier. + #[serde(rename = "type")] + pub auth_type: String, + /// Sharing mode. + #[serde(default)] + pub sharing: String, + /// Plugin configuration. + #[serde(default)] + pub config: BTreeMap, +} + +/// Header rules for one direction. +#[toolkit_macros::api_dto(request, response)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct RequestHeaderRulesDto { + /// Overwrite if present. + #[serde(default)] + pub set: BTreeMap, + /// Append, allowing duplicates. + #[serde(default)] + pub add: BTreeMap, + /// Drop if present. + #[serde(default)] + pub remove: Vec, + /// Which inbound headers to forward. + #[serde(default)] + pub passthrough: String, + /// Headers forwarded when `passthrough` is `allowlist`. + #[serde(default)] + pub passthrough_allowlist: Vec, +} + +/// Inbound response header rules. +#[toolkit_macros::api_dto(request, response)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct ResponseHeaderRulesDto { + /// Overwrite if present. + #[serde(default)] + pub set: BTreeMap, + /// Append, allowing duplicates. + #[serde(default)] + pub add: BTreeMap, + /// Drop if present. + #[serde(default)] + pub remove: Vec, +} + +/// Header transformation configuration. +#[toolkit_macros::api_dto(request, response)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HeadersDto { + /// Rules applied to the outbound request. + #[serde(default)] + pub request: Option, + /// Rules applied to the response. + #[serde(default)] + pub response: Option, +} + +/// Plugin chain. +#[toolkit_macros::api_dto(request, response)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PluginsDto { + /// Sharing mode. + #[serde(default)] + pub sharing: String, + /// Built-in plugins by GTS identifier, custom plugins by UUID. + /// + /// An entry is either a bare reference or an object carrying that + /// plugin's configuration document: + /// + /// ```json + /// {"items": ["gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1", + /// {"plugin_ref": "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1", + /// "config": {"required_request_headers": "x-correlation-id"}}]} + /// ``` + #[serde(default)] + pub items: Vec, +} + +/// Sustained rate component. +#[toolkit_macros::api_dto(request, response)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct SustainedRateDto { + /// Tokens replenished per window. + pub rate: u64, + /// Window length. + #[serde(default = "default_window")] + pub window: String, +} + +fn default_window() -> String { + "second".to_owned() +} + +/// Burst component. +#[toolkit_macros::api_dto(request, response)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BurstDto { + /// Bucket capacity. + pub capacity: u64, +} + +/// Rate-limit configuration. +#[toolkit_macros::api_dto(request, response)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct RateLimitDto { + /// Sharing mode. + #[serde(default)] + pub sharing: String, + /// Algorithm. + #[serde(default = "default_algorithm")] + pub algorithm: String, + /// Sustained rate; the only required field. + pub sustained: SustainedRateDto, + /// Burst capacity; defaults to `sustained.rate`. + #[serde(default)] + pub burst: Option, + /// Counter scope. + #[serde(default = "default_scope")] + pub scope: String, + /// Overflow behaviour. + #[serde(default = "default_strategy")] + pub strategy: String, + /// Tokens consumed per request. + #[serde(default = "default_cost")] + pub cost: u64, +} + +fn default_algorithm() -> String { + "token_bucket".to_owned() +} + +fn default_scope() -> String { + "tenant".to_owned() +} + +fn default_strategy() -> String { + "reject".to_owned() +} + +fn default_cost() -> u64 { + 1 +} + +/// CORS configuration. +#[toolkit_macros::api_dto(request, response)] +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CorsDto { + /// Sharing mode. + #[serde(default)] + pub sharing: String, + /// Whether CORS handling is active. + pub enabled: bool, + /// Allowed origins; `["*"]` permits any origin. + #[serde(default)] + pub allowed_origins: Vec, + /// Allowed methods. + #[serde(default = "default_allowed_methods")] + pub allowed_methods: Vec, + /// Headers exposed to the browser. + #[serde(default)] + pub expose_headers: Vec, + /// Whether credentials are allowed. + #[serde(default)] + pub allow_credentials: bool, +} + +fn default_allowed_methods() -> Vec { + vec!["GET".to_owned(), "POST".to_owned()] +} + +/// Upstream creation request. +#[toolkit_macros::api_dto(request)] +#[derive(Clone, Debug, Default)] +pub struct CreateUpstreamRequest { + /// Optional explicit alias. + #[serde(default)] + pub alias: Option, + /// Whether the upstream accepts traffic. + #[serde(default = "crate::api::rest::dto::default_true")] + pub enabled: bool, + /// Tags, unioned across the hierarchy. + #[serde(default)] + pub tags: Vec, + /// Endpoint pool. + pub server: ServerDto, + /// Protocol GTS identifier. + pub protocol: String, + /// Authentication configuration. + #[serde(default)] + pub auth: Option, + /// Header transformation rules. + #[serde(default)] + pub headers: Option, + /// Plugin chain. + #[serde(default)] + pub plugins: Option, + /// Rate limit. + #[serde(default)] + pub rate_limit: Option, + /// CORS policy. + #[serde(default)] + pub cors: Option, +} + +/// Upstream replacement request; `alias` is never part of it. +#[toolkit_macros::api_dto(request)] +#[derive(Clone, Debug, Default)] +pub struct UpdateUpstreamRequest { + /// Whether the upstream accepts traffic. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Tags. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + /// Endpoint pool. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub server: Option, + /// Protocol GTS identifier. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub protocol: Option, + /// Authentication configuration. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub auth: Option, + /// Header transformation rules. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub headers: Option, + /// Plugin chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limit. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS policy. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, +} + +/// Upstream response. +#[toolkit_macros::api_dto(response)] +#[derive(Clone, Debug)] +pub struct UpstreamDto { + /// System-generated identifier. + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Whether the upstream accepts traffic. + pub enabled: bool, + /// Routing key, unique per tenant. + pub alias: String, + /// Tags. + pub tags: Vec, + /// Endpoint pool. + pub server: ServerDto, + /// Protocol GTS identifier. + pub protocol: String, + /// Authentication configuration. + pub auth: Option, + /// Header transformation rules. + pub headers: Option, + /// Plugin chain. + pub plugins: Option, + /// Rate limit. + pub rate_limit: Option, + /// CORS policy. + pub cors: Option, + /// Creation timestamp, RFC 3339. + pub created_at: Option, + /// Last-modified timestamp, RFC 3339. + pub updated_at: Option, +} + +/// HTTP match rules. +#[toolkit_macros::api_dto(request, response)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct HttpMatchDto { + /// Method allowlist; non-empty. + #[serde(default)] + pub methods: Vec, + /// Path prefix. + #[serde(default)] + pub path: String, + /// Query parameters the caller may send; empty permits none. + #[serde(default)] + pub query_allowlist: Vec, + /// Suffix handling. + #[serde(default = "default_suffix_mode")] + pub path_suffix_mode: String, +} + +fn default_suffix_mode() -> String { + "append".to_owned() +} + +/// gRPC match rules. +#[toolkit_macros::api_dto(request, response)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct GrpcMatchDto { + /// Fully qualified service name. + #[serde(default)] + pub service: String, + /// RPC method name. + #[serde(default)] + pub method: String, +} + +/// `match` block: exactly one of `http` or `grpc`. +#[toolkit_macros::api_dto(request, response)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct MatchDto { + /// HTTP match. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http: Option, + /// gRPC match. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grpc: Option, +} + +/// Route creation request. +#[toolkit_macros::api_dto(request)] +#[derive(Clone, Debug, Default)] +pub struct CreateRouteRequest { + /// Owning upstream. + pub upstream_id: Uuid, + /// Whether the route participates in matching. + #[serde(default = "default_true")] + pub enabled: bool, + /// Tags. + #[serde(default)] + pub tags: Vec, + /// Match rules. + #[serde(rename = "match")] + pub match_rule: MatchDto, + /// Plugin chain. + #[serde(default)] + pub plugins: Option, + /// Rate limit. + #[serde(default)] + pub rate_limit: Option, + /// CORS configuration. + #[serde(default)] + pub cors: Option, +} + +/// Route replacement request; `upstream_id` is immutable and absent. +#[toolkit_macros::api_dto(request)] +#[derive(Clone, Debug, Default)] +pub struct UpdateRouteRequest { + /// Whether the route participates in matching. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub enabled: Option, + /// Tags. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tags: Option>, + /// Match rules. + #[serde(rename = "match", default, skip_serializing_if = "Option::is_none")] + pub match_rule: Option, + /// Plugin chain. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub plugins: Option, + /// Rate limit. + #[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, +} + +/// Route response. +#[toolkit_macros::api_dto(response)] +#[derive(Clone, Debug)] +pub struct RouteDto { + /// System-generated identifier. + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Owning upstream. + pub upstream_id: Uuid, + /// Whether the route participates in matching. + pub enabled: bool, + /// Tags. + pub tags: Vec, + /// Match rules. + #[serde(rename = "match")] + pub match_rule: MatchDto, + /// Plugin chain. + pub plugins: Option, + /// Rate limit. + pub rate_limit: Option, + /// CORS configuration. + pub cors: Option, + /// Creation timestamp, RFC 3339. + pub created_at: Option, + /// Last-modified timestamp, RFC 3339. + pub updated_at: Option, +} + +/// Plugin creation request. +#[toolkit_macros::api_dto(request)] +#[derive(Clone, Debug, Default)] +pub struct CreatePluginRequest { + /// Plugin family (`auth_plugin`, `guard_plugin`, `transform_plugin`). + pub plugin_type: String, + /// Human readable name. + pub name: String, + /// Starlark source. + #[serde(default)] + pub source: String, + /// Plugin configuration document. + #[serde(default)] + pub config: BTreeMap, +} + +/// Plugin response. +#[toolkit_macros::api_dto(response)] +#[derive(Clone, Debug)] +pub struct PluginDto { + /// System-generated identifier. + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Plugin family. + pub plugin_type: String, + /// Human readable name. + pub name: String, + /// Starlark source. + pub source: String, + /// Plugin configuration document. + pub config: BTreeMap, + /// Earliest instant at which an unlinked plugin may be collected. + pub gc_eligible_at: Option, + /// Creation timestamp, RFC 3339. + pub created_at: Option, +} + +/// Plugin source response. +#[toolkit_macros::api_dto(response)] +#[derive(Clone, Debug)] +pub struct PluginSourceDto { + /// The plugin's identifier. + pub id: Uuid, + /// The plugin's name. + pub name: String, + /// Starlark source. + pub source: String, +} + +/// List query parameters. +#[derive(Debug, Default, Clone, serde::Deserialize)] +pub struct ListParams { + /// `$filter=field eq 'value'`. + #[serde(rename = "$filter", default)] + pub filter: Option, + /// `$orderby=field [asc|desc]`. + #[serde(rename = "$orderby", default)] + pub orderby: Option, + /// `$select=field,field`. + #[serde(rename = "$select", default)] + pub select: Option, + /// `$top`, default 50 and capped at 100. + #[serde(rename = "$top", default)] + pub top: Option, + /// `$skip`. + #[serde(rename = "$skip", default)] + pub skip: Option, +} + +fn default_true() -> bool { + true +} + +/// Merges a replacement's optional fields over the stored record. +pub(crate) trait MergeUpdate { + /// Fills every `None` field from `base`. + fn merged_with(&self, base: &Self) -> Self; +} + +impl MergeUpdate for UpdateUpstreamRequest { + fn merged_with(&self, base: &Self) -> Self { + Self { + enabled: pick(self.enabled.as_ref(), base.enabled.as_ref()), + tags: pick(self.tags.as_ref(), base.tags.as_ref()), + server: self.server.clone().or_else(|| base.server.clone()), + protocol: self.protocol.clone().or_else(|| base.protocol.clone()), + auth: self.auth.clone().or_else(|| base.auth.clone()), + headers: self.headers.clone().or_else(|| base.headers.clone()), + plugins: self.plugins.clone().or_else(|| base.plugins.clone()), + rate_limit: self.rate_limit.clone().or_else(|| base.rate_limit.clone()), + cors: self.cors.clone().or_else(|| base.cors.clone()), + } + } +} + +impl MergeUpdate for UpdateRouteRequest { + fn merged_with(&self, base: &Self) -> Self { + Self { + enabled: pick(self.enabled.as_ref(), base.enabled.as_ref()), + tags: pick(self.tags.as_ref(), base.tags.as_ref()), + match_rule: self.match_rule.clone().or_else(|| base.match_rule.clone()), + plugins: self.plugins.clone().or_else(|| base.plugins.clone()), + rate_limit: self.rate_limit.clone().or_else(|| base.rate_limit.clone()), + cors: self.cors.clone().or_else(|| base.cors.clone()), + } + } +} + +fn pick(chosen: Option<&T>, base: Option<&T>) -> Option { + chosen.cloned().or_else(|| base.cloned()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enabled_defaults_to_true() { + let text = r#"{"server":{"endpoints":[{"scheme":"https","host":"a.com","port":443}]},"protocol":"http"}"#; + let request: CreateUpstreamRequest = serde_json::from_str(text).expect("parses"); + assert!(request.enabled); + assert_eq!(request.server.endpoints.len(), 1); + assert_eq!(request.server.endpoints[0].port, 443); + } + + #[test] + fn route_match_is_spelled_match() { + let text = r#"{"upstream_id":"00000000-0000-0000-0000-000000000000","match":{"http":{"methods":["GET"],"path":"/v1"}}}"#; + let request: CreateRouteRequest = serde_json::from_str(text).expect("parses"); + let http = request.match_rule.http.expect("http variant"); + assert_eq!(http.methods, vec!["GET"]); + assert_eq!(http.path, "/v1"); + assert_eq!(http.path_suffix_mode, "append"); + assert!(request.match_rule.grpc.is_none()); + } + + #[test] + fn rate_limit_defaults_fill_in() { + let text = r#"{"sustained":{"rate":5}}"#; + let config: RateLimitDto = serde_json::from_str(text).expect("parses"); + assert_eq!(config.sustained.window, "second"); + assert_eq!(config.scope, "tenant"); + assert_eq!(config.cost, 1); + assert!(config.burst.is_none()); + } +} 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..6aa6018 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/error.rs @@ -0,0 +1,272 @@ +//! Error responses. +//! +//! Every gateway-generated response carries `application/problem+json` with +//! the oagw GTS instance identifier in `type` (DESIGN.md §3.3) and the +//! `X-OAGW-Error-Source: gateway` header (ADR-0007). + +use axum::Json; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use serde::Serialize; +use uuid::Uuid; + +use crate::domain::error::DomainError; + +/// `X-OAGW-Error-Source` value for a response the gateway produced. +pub const SOURCE_GATEWAY: &str = "gateway"; + +/// `X-OAGW-Error-Source` value for a response that came from the upstream. +pub const SOURCE_UPSTREAM: &str = "upstream"; + +/// The header that separates gateway errors from forwarded upstream errors. +pub const ERROR_SOURCE_HEADER: &str = "x-oagw-error-source"; + +/// Typed header name for [`ERROR_SOURCE_HEADER`]. +pub const ERROR_SOURCE_HEADER_NAME: http::HeaderName = + http::HeaderName::from_static("x-oagw-error-source"); + +/// A problem+json body. +#[derive(Debug, Serialize)] +pub struct ProblemBody { + /// The GTS instance identifier. + #[serde(rename = "type")] + pub problem_type: String, + /// Short human-readable summary. + pub title: String, + /// HTTP status. + pub status: u16, + /// Human-readable explanation. + pub detail: String, + /// The request path that produced the error. + pub instance: String, + /// Seconds after which a retry may succeed. + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_after_seconds: Option, + /// The upstream the request resolved to, when one had. + #[serde(skip_serializing_if = "Option::is_none")] + pub upstream_id: Option, + /// The `Host` the caller addressed, when it sent one. + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + /// The proxied path, when the request was a proxy one. + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + /// The correlation id the request settled on, when it has one. + #[serde(skip_serializing_if = "Option::is_none")] + pub trace_id: Option, +} + +/// A [`DomainError`] ready to be written to the wire. +#[derive(Debug)] +pub struct OagwError { + /// The error. + pub error: DomainError, + /// The request path, used as the problem `instance`. + pub instance: String, + /// Extra headers, such as `Retry-After`. + pub extra_headers: Vec<(String, String)>, + /// The upstream the request had resolved to. + pub upstream_id: Option, + /// The `Host` the caller sent. + pub host: Option, + /// The proxied path. + pub path: Option, + /// The correlation id the request settled on. + pub trace_id: Option, +} + +impl OagwError { + /// Wraps a domain error with an empty instance. + #[must_use] + pub fn new(error: DomainError) -> Self { + Self { + error, + instance: String::new(), + extra_headers: Vec::new(), + upstream_id: None, + host: None, + path: None, + trace_id: None, + } + } + + /// Sets the problem `instance`. + #[must_use] + pub fn with_instance(mut self, instance: impl Into) -> Self { + self.instance = instance.into(); + self + } + + /// Names the upstream the request had resolved to. + #[must_use] + pub fn with_upstream_id(mut self, upstream_id: Uuid) -> Self { + self.upstream_id = Some(upstream_id); + self + } + + /// Records the `Host` the caller sent. + #[must_use] + pub fn with_host(mut self, host: Option) -> Self { + self.host = host; + self + } + + /// Records the proxied path. + #[must_use] + pub fn with_path(mut self, path: impl Into) -> Self { + self.path = Some(path.into()); + self + } + + /// Records the correlation id the request settled on. + #[must_use] + pub fn with_trace_id(mut self, trace_id: Option) -> Self { + self.trace_id = trace_id; + self + } + + /// Adds a header to the response. + #[must_use] + pub fn with_header(mut self, name: impl Into, value: impl Into) -> Self { + self.extra_headers.push((name.into(), value.into())); + self + } +} + +impl From for OagwError { + fn from(error: DomainError) -> Self { + Self::new(error) + } +} + +impl From for OagwError { + fn from(failure: crate::infra::proxy::service::ProxyFailure) -> Self { + Self { + error: failure.error, + instance: String::new(), + extra_headers: failure.extra_headers, + upstream_id: failure.upstream_id, + host: None, + path: None, + trace_id: None, + } + } +} + +impl IntoResponse for OagwError { + fn into_response(self) -> Response { + let descriptor = self.error.descriptor(); + let status = + StatusCode::from_u16(descriptor.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let body = ProblemBody { + problem_type: descriptor.gts_type, + title: descriptor.title, + status: descriptor.status, + detail: self.error.to_string(), + instance: self.instance.clone(), + retry_after_seconds: retry_after(&self.extra_headers), + upstream_id: self.upstream_id, + host: self.host, + path: self.path, + trace_id: self.trace_id, + }; + let mut response = (status, Json(body)).into_response(); + response.headers_mut().insert( + http::header::CONTENT_TYPE, + http::HeaderValue::from_static("application/problem+json"), + ); + response + .headers_mut() + .insert(ERROR_SOURCE_HEADER_NAME, http::HeaderValue::from_static(SOURCE_GATEWAY)); + for (name, value) in &self.extra_headers { + if let (Ok(name), Ok(value)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + response.headers_mut().insert(name, value); + } + } + response + } +} + +fn retry_after(extra: &[(String, String)]) -> Option { + extra + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case("retry-after")) + .and_then(|(_, value)| value.parse().ok()) +} + +/// Adds `X-OAGW-Error-Source: upstream` to a forwarded response's headers. +pub fn mark_upstream(headers: &mut http::HeaderMap) { + headers.insert( + ERROR_SOURCE_HEADER_NAME, + http::HeaderValue::from_static(SOURCE_UPSTREAM), + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn gateway_errors_are_problem_json_with_the_oagw_type() { + let response = OagwError::new(DomainError::RouteNotFound("nope".into())) + .with_instance("/oagw/v1/proxy/nope") + .into_response(); + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!( + response + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/problem+json") + ); + assert_eq!( + response + .headers() + .get(ERROR_SOURCE_HEADER_NAME) + .and_then(|value| value.to_str().ok()), + Some(SOURCE_GATEWAY) + ); + 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["type"], + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); + assert_eq!(value["status"], 404); + assert_eq!(value["instance"], "/oagw/v1/proxy/nope"); + assert!(value["title"].is_string()); + assert!(value["detail"].is_string()); + } + + #[tokio::test] + async fn a_rate_limit_failure_carries_the_retry_hint() { + let response = OagwError::new(DomainError::RateLimitExceeded("exhausted".into())) + .with_header("retry-after", "7") + .into_response(); + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + response + .headers() + .get(http::header::RETRY_AFTER) + .and_then(|value| value.to_str().ok()), + Some("7") + ); + 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["retry_after_seconds"], 7); + } + + #[test] + fn a_409_is_produced_for_a_plugin_in_use() { + let response = + OagwError::new(DomainError::PluginInUse("referenced".into())).into_response(); + assert_eq!(response.status(), StatusCode::CONFLICT); + } +} 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..96357fb --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/management.rs @@ -0,0 +1,420 @@ +//! Management handlers: upstream, route and plugin CRUD. + +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Extension, Path, Query}; +use serde_json::Value; +use toolkit::api::canonical_prelude::StatusCode; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::convert; +use crate::api::rest::dto::{ + CreatePluginRequest, CreateRouteRequest, CreateUpstreamRequest, ListParams, PluginDto, + PluginSourceDto, RouteDto, UpdateRouteRequest, UpdateUpstreamRequest, UpstreamDto, +}; +use crate::api::rest::dto::MergeUpdate; +use crate::api::rest::error::OagwError; +use crate::api::rest::list::ListQuery; +use crate::domain::services::management::ControlPlane; + +fn query(params: &ListParams) -> ListQuery { + ListQuery { + orderby: params + .orderby + .as_deref() + .and_then(crate::api::rest::list::parse_orderby), + filter: params + .filter + .as_deref() + .and_then(crate::api::rest::list::parse_filter), + select: params + .select + .as_deref() + .map(|raw| { + raw.split(',') + .map(str::trim) + .filter(|field| !field.is_empty()) + .map(str::to_owned) + .collect() + }) + .unwrap_or_default(), + skip: params.skip.unwrap_or(0), + top: params + .top + .unwrap_or(crate::api::rest::list::DEFAULT_TOP) + .min(crate::api::rest::list::MAX_TOP), + } +} + +fn to_page(items: &[T], render: F, params: &ListParams) -> Vec +where + F: Fn(&T) -> Value, +{ + let rendered: Vec = items.iter().map(render).collect(); + let parsed = query(params); + parsed.project(parsed.apply(rendered)) +} + +/// Renders the page envelope the management API returns. +fn page_json(items: &[Value]) -> Value { + serde_json::json!({ + "context": { "page": { "count": items.len(), "limit": 100, "start": 0 } }, + "data": items, + }) +} + +/// The current instant as an RFC 3339 timestamp, with second precision. +fn stamp() -> String { + stamp_from_unix( + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()), + ) +} + +/// Formats a Unix timestamp as RFC 3339, with second precision. +/// +// The divisions below are the calendar arithmetic itself: they are exact by +// construction, so switching to floats would only add rounding error. +#[allow(clippy::integer_division)] +fn stamp_from_unix(seconds: u64) -> String { + let (year, month, day) = civil_from_days(seconds / 86_400); + let rest = seconds % 86_400; + format!( + "{year:04}-{month:02}-{day:02}T{:02}:{:02}:{:02}Z", + rest / 3_600, + (rest % 3_600) / 60, + rest % 60 + ) +} + +/// Converts days since the Unix epoch to a proleptic Gregorian date. +/// +/// Howard Hinnant's `civil_from_days`: the shift into a March-based year makes +/// the leap rule a plain division, so no per-month table is needed. +/// +// Every division here is a deliberate truncating division; the algorithm's +// correctness depends on it, so the exact integer arithmetic is kept. +#[allow(clippy::integer_division)] +fn civil_from_days(days: u64) -> (i64, u32, u32) { + let shifted = i64::try_from(days).unwrap_or(i64::MAX) + 719_468; + let era = shifted.div_euclid(146_097); + let day_of_era = shifted.rem_euclid(146_097); + let year_of_era = (day_of_era - day_of_era / 1_460 + day_of_era / 36_524 - day_of_era / 146_096) + / 365; + let year = year_of_era + era * 400; + let day_of_year = day_of_era - (365 * year_of_era + year_of_era / 4 - year_of_era / 100); + let shifted_month = (5 * day_of_year + 2) / 153; + let day = day_of_year - (153 * shifted_month + 2) / 5 + 1; + let month = if shifted_month < 10 { + shifted_month + 3 + } else { + shifted_month - 9 + }; + let year = if month <= 2 { year + 1 } else { year }; + ( + year, + u32::try_from(month).unwrap_or(1), + u32::try_from(day).unwrap_or(1), + ) +} + +/// `POST /oagw/v1/upstreams` +/// +/// # Errors +/// Returns an [`OagwError`] when validation fails or the alias is taken. +pub async fn create_upstream( + Extension(context): Extension, + Extension(cp): Extension>, + Json(request): Json, +) -> Result<(StatusCode, Json), OagwError> { + let mut upstream = convert::to_upstream(&request, Uuid::new_v4(), context.subject_tenant_id())?; + upstream.created_at = Some(stamp()); + let created = cp + .create_upstream(context.subject_tenant_id(), upstream) + .await + .map_err(OagwError::from)?; + Ok((StatusCode::CREATED, Json(convert::from_upstream(&created)))) +} + +/// `GET /oagw/v1/upstreams` +/// +/// # Errors +/// Returns an [`OagwError`] when the store fails. +pub async fn list_upstreams( + Extension(context): Extension, + Extension(cp): Extension>, + Query(params): Query, +) -> Result, OagwError> { + let items = cp + .list_upstreams(context.subject_tenant_id()) + .await + .map_err(OagwError::from)?; + Ok(Json(page_json(&to_page( + &items, + |upstream| serde_json::to_value(convert::from_upstream(upstream)).unwrap_or_default(), + ¶ms, + )))) +} + +/// `GET /oagw/v1/upstreams/{id}` +/// +/// # Errors +/// Returns an [`OagwError`] when the resource is absent. +pub async fn get_upstream( + Extension(context): Extension, + Extension(cp): Extension>, + Path(id): Path, +) -> Result, OagwError> { + let upstream = cp + .get_upstream(context.subject_tenant_id(), id) + .await + .map_err(OagwError::from)?; + Ok(Json(convert::from_upstream(&upstream))) +} + +/// `PUT /oagw/v1/upstreams/{id}` +/// +/// # Errors +/// Returns an [`OagwError`] when validation fails or the resource is absent. +pub async fn replace_upstream( + Extension(context): Extension, + Extension(cp): Extension>, + Path(id): Path, + Json(request): Json, +) -> Result, OagwError> { + let tenant = context.subject_tenant_id(); + let existing = cp.get_upstream(tenant, id).await.map_err(OagwError::from)?; + let stored = convert::upstream_to_update_request(&existing); + let merged = request.merged_with(&stored); + let mut next = convert::to_upstream_update(&merged, &existing)?; + next.updated_at = Some(stamp()); + let updated = cp + .replace_upstream(tenant, id, next) + .await + .map_err(OagwError::from)?; + Ok(Json(convert::from_upstream(&updated))) +} + +/// `DELETE /oagw/v1/upstreams/{id}` +/// +/// # Errors +/// Returns an [`OagwError`] when the resource is absent. +pub async fn delete_upstream( + Extension(context): Extension, + Extension(cp): Extension>, + Path(id): Path, +) -> Result { + cp.delete_upstream(context.subject_tenant_id(), id) + .await + .map_err(OagwError::from)?; + Ok(StatusCode::NO_CONTENT) +} + +/// `POST /oagw/v1/routes` +/// +/// # Errors +/// Returns an [`OagwError`] when validation fails. +pub async fn create_route( + Extension(context): Extension, + Extension(cp): Extension>, + Json(request): Json, +) -> Result<(StatusCode, Json), OagwError> { + let mut route = convert::to_route(&request, Uuid::new_v4(), context.subject_tenant_id())?; + route.created_at = Some(stamp()); + let created = cp + .create_route(context.subject_tenant_id(), route) + .await + .map_err(OagwError::from)?; + Ok((StatusCode::CREATED, Json(convert::from_route(&created)))) +} + +/// `GET /oagw/v1/routes` +/// +/// # Errors +/// Returns an [`OagwError`] when the store fails. +pub async fn list_routes( + Extension(context): Extension, + Extension(cp): Extension>, + Query(params): Query, +) -> Result, OagwError> { + let items = cp + .list_routes(context.subject_tenant_id()) + .await + .map_err(OagwError::from)?; + Ok(Json(page_json(&to_page( + &items, + |route| serde_json::to_value(convert::from_route(route)).unwrap_or_default(), + ¶ms, + )))) +} + +/// `GET /oagw/v1/routes/{id}` +/// +/// # Errors +/// Returns an [`OagwError`] when the resource is absent. +pub async fn get_route( + Extension(context): Extension, + Extension(cp): Extension>, + Path(id): Path, +) -> Result, OagwError> { + let route = cp + .get_route(context.subject_tenant_id(), id) + .await + .map_err(OagwError::from)?; + Ok(Json(convert::from_route(&route))) +} + +/// `PUT /oagw/v1/routes/{id}` +/// +/// # Errors +/// Returns an [`OagwError`] when validation fails. +pub async fn replace_route( + Extension(context): Extension, + Extension(cp): Extension>, + Path(id): Path, + Json(request): Json, +) -> Result, OagwError> { + let tenant = context.subject_tenant_id(); + let existing = cp.get_route(tenant, id).await.map_err(OagwError::from)?; + let stored = convert::route_to_update_request(&existing); + let merged = request.merged_with(&stored); + let mut next = convert::to_route_update(&merged, &existing)?; + next.updated_at = Some(stamp()); + let updated = cp.replace_route(tenant, id, next).await.map_err(OagwError::from)?; + Ok(Json(convert::from_route(&updated))) +} + +/// `DELETE /oagw/v1/routes/{id}` +/// +/// # Errors +/// Returns an [`OagwError`] when the resource is absent. +pub async fn delete_route( + Extension(context): Extension, + Extension(cp): Extension>, + Path(id): Path, +) -> Result { + cp.delete_route(context.subject_tenant_id(), id) + .await + .map_err(OagwError::from)?; + Ok(StatusCode::NO_CONTENT) +} + +/// `POST /oagw/v1/plugins` +/// +/// # Errors +/// Returns an [`OagwError`] when validation fails. +pub async fn create_plugin( + Extension(context): Extension, + Extension(cp): Extension>, + Json(request): Json, +) -> Result<(StatusCode, Json), OagwError> { + let plugin = convert::to_plugin(&request, Uuid::new_v4(), context.subject_tenant_id())?; + let created = cp + .create_plugin(context.subject_tenant_id(), plugin) + .await + .map_err(OagwError::from)?; + Ok((StatusCode::CREATED, Json(convert::from_plugin(&created)))) +} + +/// `GET /oagw/v1/plugins` +/// +/// # Errors +/// Returns an [`OagwError`] when the store fails. +pub async fn list_plugins( + Extension(context): Extension, + Extension(cp): Extension>, + Query(params): Query, +) -> Result, OagwError> { + let items = cp + .list_plugins(context.subject_tenant_id()) + .await + .map_err(OagwError::from)?; + Ok(Json(page_json(&to_page( + &items, + |plugin| serde_json::to_value(convert::from_plugin(plugin)).unwrap_or_default(), + ¶ms, + )))) +} + +/// `GET /oagw/v1/plugins/{id}` +/// +/// # Errors +/// Returns an [`OagwError`] when the resource is absent. +pub async fn get_plugin( + Extension(context): Extension, + Extension(cp): Extension>, + Path(id): Path, +) -> Result, OagwError> { + let plugin = cp + .get_plugin(context.subject_tenant_id(), id) + .await + .map_err(OagwError::from)?; + Ok(Json(convert::from_plugin(&plugin))) +} + +/// `DELETE /oagw/v1/plugins/{id}` +/// +/// # Errors +/// Returns an [`OagwError`] when the plugin is referenced. +pub async fn delete_plugin( + Extension(context): Extension, + Extension(cp): Extension>, + Path(id): Path, +) -> Result { + cp.delete_plugin(context.subject_tenant_id(), id) + .await + .map_err(OagwError::from)?; + Ok(StatusCode::NO_CONTENT) +} + +/// `GET /oagw/v1/plugins/{id}/source` +/// +/// # Errors +/// Returns an [`OagwError`] when the resource is absent. +pub async fn get_plugin_source( + Extension(context): Extension, + Extension(cp): Extension>, + Path(id): Path, +) -> Result, OagwError> { + let plugin = cp + .get_plugin(context.subject_tenant_id(), id) + .await + .map_err(OagwError::from)?; + Ok(Json(PluginSourceDto { + id: plugin.id, + name: plugin.name, + source: plugin.source, + })) +} + +#[cfg(test)] +mod civil_tests { + use super::civil_from_days; + + #[test] + fn the_epoch_is_new_years_day_1970() { + assert_eq!(civil_from_days(0), (1970, 1, 1)); + } + + #[test] + fn known_dates_round_trip() { + assert_eq!(civil_from_days(20_698), (2026, 9, 2)); + assert_eq!(civil_from_days(1), (1970, 1, 2)); + assert_eq!(civil_from_days(31), (1970, 2, 1)); + assert_eq!(civil_from_days(365), (1971, 1, 1)); + assert_eq!(civil_from_days(11_016), (2000, 2, 29)); + assert_eq!(civil_from_days(20_697), (2026, 9, 1)); + } + + #[test] + fn stamps_are_rfc3339() { + assert_eq!(super::stamp_from_unix(0), "1970-01-01T00:00:00Z"); + assert_eq!( + super::stamp_from_unix(1_788_177_600), + "2026-08-31T12:00:00Z" + ); + } +} 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..d875b4b --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/mod.rs @@ -0,0 +1,4 @@ +//! REST handlers. + +pub mod management; +pub mod proxy; 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..7f46d19 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/proxy.rs @@ -0,0 +1,326 @@ +//! Proxy handlers: the data plane's HTTP surface. + +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::RawQuery; +use axum::extract::{Extension, Path, Request}; +use axum::response::Response; +use bytes::Bytes; +use futures_util::StreamExt; +use secrecy::ExposeSecret; +use toolkit_security::SecurityContext; + +use crate::api::rest::error::{OagwError, mark_upstream}; +use crate::infra::plugin::request_id_transform::REQUEST_ID_HEADER; +use crate::config::OagwConfig; +use crate::domain::error::DomainError; +use crate::infra::proxy::body as body_rules; +use crate::infra::proxy::cors; +use crate::infra::proxy::service::{DataPlaneServiceImpl, ProxyRequestContext}; + +/// Bytes a bounded body read speculatively reserves before the first chunk. +const INITIAL_BODY_ALLOCATION: usize = 64 * 1024; + +/// `POST /oagw/v1/proxy/{alias}/{*path}` and friends. +/// +/// # Errors +/// Returns an [`OagwError`] for every gateway-generated failure. +pub async fn proxy( + context: Option>, + Extension(plane): Extension>, + Path((alias, suffix)): Path<(String, String)>, + RawQuery(raw): RawQuery, + request: Request, +) -> Result { + let (method, headers, body, upgrade) = split(request); + run( + context.map(|Extension(context)| context), + &plane, + alias, + Some(suffix), + raw, + method, + headers, + body, + upgrade, + ) + .await +} + +/// `POST /oagw/v1/proxy/{alias}` and friends: the same handler with no suffix. +/// +/// # Errors +/// Returns an [`OagwError`] for every gateway-generated failure. +pub async fn proxy_root( + context: Option>, + Extension(plane): Extension>, + Path(alias): Path, + RawQuery(raw): RawQuery, + request: Request, +) -> Result { + let (method, headers, body, upgrade) = split(request); + run( + context.map(|Extension(context)| context), + &plane, + alias, + None, + raw, + method, + headers, + body, + upgrade, + ) + .await +} + +/// Splits the inbound request into the parts the pipeline wants. +/// +/// The `OnUpgrade` extension is present only when the client asked to switch +/// protocols; `None` means there is nothing to tunnel. +fn split(request: Request) -> (http::Method, http::HeaderMap, Body, Option) { + let (parts, body) = request.into_parts(); + let upgrade = parts.extensions.get::().cloned(); + (parts.method, parts.headers, body, upgrade) +} + +/// Shared body of both proxy entry points. +#[allow(clippy::too_many_lines, clippy::too_many_arguments)] +async fn run( + context: Option, + plane: &DataPlaneServiceImpl, + alias: String, + suffix: Option, + raw: Option, + method: http::Method, + headers: http::HeaderMap, + body: Body, + upgrade: Option, +) -> Result { + let config = plane.config().clone(); + + // A preflight is answered before anything else: it carries no credentials, + // needs no tenant and must not reach the upstream (ADR-0004). + if cors::is_preflight(&method, &headers) { + return Ok(cors::preflight_response(&headers)); + } + + let context = context.ok_or_else(|| { + OagwError::new(DomainError::AuthenticationFailed( + "the proxy requires an authenticated security context".into(), + )) + })?; + + let query = parse_query(raw.as_deref()); + let instance = proxy_instance(&alias, suffix.as_deref()); + + let declared = + body_rules::validate_content_length_declaration(&headers).map_err(OagwError::new)?; + body_rules::validate_transfer_encoding(&headers).map_err(OagwError::new)?; + let body = read_body(body, declared, &config).await.map_err(OagwError::new)?; + + let mut request = ProxyRequestContext { + alias, + path_suffix: suffix.unwrap_or_default(), + query, + inbound_headers: headers, + method, + tenant_id: context.subject_tenant_id(), + subject_id: context.subject_id(), + client_ip: None, + bearer_token: context + .bearer_token() + .map(|token| token.expose_secret().to_owned()), + }; + + if is_websocket_upgrade(&request.inbound_headers) { + return respond( + plane + .proxy_websocket(&mut request, upgrade) + .await, + &instance, + &request, + ); + } + respond( + plane.proxy(&mut request, Body::from(body)).await, + &instance, + &request, + ) +} + +/// Turns a data-plane outcome into an axum response. +fn respond( + outcome: Result< + crate::infra::proxy::service::ForwardedResponse, + crate::infra::proxy::service::ProxyFailure, + >, + instance: &str, + request: &ProxyRequestContext, +) -> Result { + match outcome { + Ok(forwarded) => { + let mut headers = forwarded.headers; + mark_upstream(&mut headers); + let mut response = Response::new(forwarded.body); + *response.status_mut() = forwarded.status; + *response.headers_mut() = headers; + Ok(response) + } + Err(failure) => Err(OagwError::from(failure) + .with_instance(instance) + .with_path(request_path(request)) + .with_host(host_header(request)) + .with_trace_id(trace_id(request))), + } +} + +/// The path the caller asked to be proxied, as the upstream would see it. +fn request_path(request: &ProxyRequestContext) -> String { + let suffix = request.path_suffix.trim_start_matches('/'); + match request.query.first() { + Some((name, value)) => format!("/{suffix}?{name}={value}"), + None => format!("/{suffix}"), + } +} + +/// The `Host` the caller sent, if it sent one. +fn host_header(request: &ProxyRequestContext) -> Option { + request + .inbound_headers + .get(http::header::HOST) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) +} + +/// The correlation id the request settled on, if it has one. +fn trace_id(request: &ProxyRequestContext) -> Option { + request + .inbound_headers + .get(REQUEST_ID_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned) +} + +/// Whether the inbound request carries a WebSocket upgrade. +fn is_websocket_upgrade(headers: &http::HeaderMap) -> bool { + headers + .get(http::header::UPGRADE) + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.eq_ignore_ascii_case("websocket")) +} + +/// Builds the problem `instance` for a proxy request. +fn proxy_instance(alias: &str, suffix: Option<&str>) -> String { + match suffix { + Some(suffix) if !suffix.is_empty() => format!("/oagw/v1/proxy/{alias}/{suffix}"), + _ => format!("/oagw/v1/proxy/{alias}"), + } +} + +/// Decodes the query string into ordered pairs. +fn parse_query(raw: Option<&str>) -> Vec<(String, String)> { + let Some(raw) = raw else { + return Vec::new(); + }; + url::form_urlencoded::parse(raw.as_bytes()) + .map(|(name, value)| (name.into_owned(), value.into_owned())) + .collect() +} + +/// Reads the request body, applying the ceiling and the declared length. +/// +/// Reading stops as soon as the limit is crossed, so an oversized body is +/// refused while it is still arriving and never buffered past the limit. +/// +/// # Errors +/// Returns [`DomainError::PayloadTooLarge`] when the body crosses the limit and +/// [`DomainError::Validation`] when it disagrees with the declared length. +async fn read_body( + body: Body, + declared: Option, + config: &OagwConfig, +) -> Result { + let limit = config.max_body_bytes; + // The declared length is subtracted to pre-size the buffer, so a declared + // length wider than `usize` simply leaves no room; `saturating_sub` keeps + // that outcome, and the narrowing cast is therefore harmless. + #[allow(clippy::cast_possible_truncation)] + let room = declared.map_or(usize::MIN, |declared| { + limit.saturating_sub(declared as usize) + }); + let mut buffer = Vec::with_capacity(room.min(INITIAL_BODY_ALLOCATION)); + let mut stream = body.into_data_stream(); + while let Some(chunk) = stream.next().await { + let bytes = chunk.map_err(|error| DomainError::DownstreamError(error.to_string()))?; + if buffer.len() + bytes.len() > limit { + return Err(DomainError::PayloadTooLarge(format!( + "request body exceeds the {limit} byte limit" + ))); + } + buffer.extend_from_slice(&bytes); + } + body_rules::check_body_size(buffer.len(), declared, limit)?; + Ok(Bytes::from(buffer)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn websocket_upgrades_are_detected() { + let mut headers = http::HeaderMap::new(); + headers.insert(http::header::UPGRADE, http::HeaderValue::from_static("WebSocket")); + assert!(is_websocket_upgrade(&headers)); + headers.insert(http::header::UPGRADE, http::HeaderValue::from_static("h2c")); + assert!(!is_websocket_upgrade(&headers)); + } + + #[test] + fn the_instance_names_the_alias_and_suffix() { + assert_eq!(proxy_instance("api", None), "/oagw/v1/proxy/api"); + assert_eq!(proxy_instance("api", Some("v1/models")), "/oagw/v1/proxy/api/v1/models"); + } + + #[test] + fn query_pairs_are_decoded_in_order() { + assert_eq!(parse_query(Some("a=1&b=two")), vec![ + ("a".to_owned(), "1".to_owned()), + ("b".to_owned(), "two".to_owned()), + ]); + assert!(parse_query(None).is_empty()); + } + + #[tokio::test] + async fn an_oversized_body_is_rejected_with_413() { + let mut config = OagwConfig::default(); + config.max_body_bytes = 10; + let body = Body::from("this body is much longer than ten bytes"); + let error = read_body(body, None, &config) + .await + .expect_err("too large"); + assert_eq!(error.status(), 413); + } + + #[tokio::test] + async fn a_declared_body_shorter_than_its_content_length_is_rejected() { + let mut config = OagwConfig::default(); + config.max_body_bytes = 1024; + let body = Body::from("short"); + let error = read_body(body, Some(20), &config) + .await + .expect_err("mismatch"); + assert_eq!(error.status(), 400); + } + + #[tokio::test] + async fn a_body_within_the_limit_is_read_in_full() { + let mut config = OagwConfig::default(); + config.max_body_bytes = 1024; + let body = Body::from("a small body"); + let read = read_body(body, Some(12), &config).await.expect("read"); + assert_eq!(&read[..], b"a small body"); + } +} + diff --git a/gears/system/oagw/oagw/src/api/rest/list.rs b/gears/system/oagw/oagw/src/api/rest/list.rs new file mode 100644 index 0000000..f3094fa --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/list.rs @@ -0,0 +1,203 @@ +//! The `OData` subset the management list endpoints accept. +//! +//! `$top` (default 50, cap 100), `$skip`, `$orderby=field [asc|desc]`, +//! `$filter=field eq 'value'` and `$select=field,field` are parsed from the +//! query string and applied in-process. + +use serde_json::Value; + +/// The parsed list query. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ListQuery { + /// Field to sort on and its direction. + pub orderby: Option<(String, bool)>, + /// `field eq 'value'` filter. + pub filter: Option<(String, String)>, + /// Fields the caller asked for. + pub select: Vec, + /// Number of records to skip. + pub skip: usize, + /// Number of records to return. + pub top: usize, +} + +/// Default page size. +pub const DEFAULT_TOP: usize = 50; + +/// Maximum page size. +pub const MAX_TOP: usize = 100; + +impl Default for ListQuery { + fn default() -> Self { + Self { + orderby: None, + filter: None, + select: Vec::new(), + skip: 0, + top: DEFAULT_TOP, + } + } +} + +impl ListQuery { + /// Applies `skip`/`top` to an ordered list. + #[must_use] + pub fn page(&self, items: Vec) -> Vec { + items.into_iter().skip(self.skip).take(self.top).collect() + } + + /// Applies `$orderby` and `$filter` to a list of JSON documents. + #[must_use] + pub fn apply(&self, items: Vec) -> Vec { + let filtered: Vec = match &self.filter { + Some((field, expected)) => items + .into_iter() + .filter(|item| { + item.get(field) + .and_then(Value::as_str) + .is_some_and(|actual| actual == expected) + }) + .collect(), + None => items, + }; + let mut filtered = filtered; + if let Some((field, descending)) = &self.orderby { + let key = |item: &Value| -> String { + item.get(field).map_or_else(String::new, |value| match value { + Value::String(text) => text.clone(), + other => other.to_string(), + }) + }; + filtered.sort_by_key(|left| key(left)); + if *descending { + filtered.reverse(); + } + } + self.page(filtered) + } + + /// Projects each document onto `$select`'s fields. + #[must_use] + pub fn project(&self, items: Vec) -> Vec { + if self.select.is_empty() { + return items; + } + items + .into_iter() + .map(|item| { + let mut projected = serde_json::Map::new(); + for field in &self.select { + if let Some(value) = item.get(field) { + projected.insert(field.clone(), value.clone()); + } + } + Value::Object(projected) + }) + .collect() + } +} + +/// Parses `$orderby=field [asc|desc]`. +#[must_use] +pub fn parse_orderby(raw: &str) -> Option<(String, bool)> { + let mut parts = raw.split_whitespace(); + let field = parts.next()?.to_owned(); + let descending = match parts.next() { + None => false, + Some(dir) => dir.eq_ignore_ascii_case("desc"), + }; + Some((field, descending)) +} + +/// Parses `$filter=field eq 'value'`. +#[must_use] +pub fn parse_filter(raw: &str) -> Option<(String, String)> { + let mut parts = raw.trim().splitn(3, ' '); + let field = parts.next()?.to_owned(); + let operator = parts.next()?; + if !operator.eq_ignore_ascii_case("eq") { + return None; + } + let value = parts.next()?; + Some((field, value.trim_matches('\'').to_owned())) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn item(alias: &str, enabled: bool) -> Value { + serde_json::json!({ "alias": alias, "enabled": enabled }) + } + + #[test] + fn top_and_skip_slice_the_page() { + let query = ListQuery { + skip: 1, + top: 2, + ..ListQuery::default() + }; + let items = query.apply(vec![ + item("a", true), + item("b", true), + item("c", true), + item("d", true), + ]); + assert_eq!(items.len(), 2); + assert_eq!(items[0]["alias"], "b"); + } + + #[test] + fn filter_narrows_results() { + let query = ListQuery { + filter: Some(("alias".to_owned(), "b".to_owned())), + ..ListQuery::default() + }; + let items = query.apply(vec![item("a", true), item("b", true)]); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["alias"], "b"); + } + + #[test] + fn orderby_sorts_both_ways() { + let ascending = ListQuery { + orderby: Some(("alias".to_owned(), false)), + ..ListQuery::default() + }; + let descending = ListQuery { + orderby: Some(("alias".to_owned(), true)), + ..ListQuery::default() + }; + let source = vec![item("c", true), item("a", true), item("b", true)]; + let up = ascending.apply(source.clone()); + assert_eq!(up[0]["alias"], "a"); + let down = descending.apply(source); + assert_eq!(down[0]["alias"], "c"); + } + + #[test] + fn select_projects_fields() { + let query = ListQuery { + select: vec!["alias".to_owned()], + ..ListQuery::default() + }; + let items = query.project(vec![item("a", true)]); + assert_eq!(items[0]["alias"], "a"); + assert!(items[0].get("enabled").is_none()); + } + + #[test] + fn parse_orderby_reads_the_direction() { + assert_eq!(parse_orderby("alias desc"), Some(("alias".to_owned(), true))); + assert_eq!(parse_orderby("alias"), Some(("alias".to_owned(), false))); + } + + #[test] + fn parse_filter_accepts_eq_only() { + assert_eq!( + parse_filter("alias eq 'x'"), + Some(("alias".to_owned(), "x".to_owned())) + ); + assert!(parse_filter("alias ne 'x'").is_none()); + } +} 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..f8683d4 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/mod.rs @@ -0,0 +1,8 @@ +//! REST API layer: DTOs, error mapping, handlers and route registration. + +pub mod convert; +pub mod dto; +pub mod error; +pub mod handlers; +pub mod list; +pub mod routes; 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..e7a20ad --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/routes.rs @@ -0,0 +1,342 @@ +//! REST route registration for the `oagw` gear. +//! +//! Everything mounts gear-relative — `/oagw/v1/...`, with no `/api` prefix. + +use std::sync::Arc; + +use axum::Router; +use http::Method; +use toolkit::api::OpenApiRegistry; +use toolkit::api::canonical_prelude::StatusCode; +use toolkit::api::operation_builder::{ + CORE_GLOBAL_BASE_LICENSE_FEATURE, LicenseFeature, OperationBuilder, ResponseSpec, +}; + +use super::dto::{ + CreatePluginRequest, CreateRouteRequest, CreateUpstreamRequest, PluginDto, PluginSourceDto, + RouteDto, UpdateRouteRequest, UpdateUpstreamRequest, UpstreamDto, +}; +use super::handlers; +use crate::domain::services::management::ControlPlane; +use crate::infra::proxy::service::DataPlaneServiceImpl; + +const API_TAG: &str = "OAGW"; + +struct License; + +impl AsRef for License { + fn as_ref(&self) -> &'static str { + CORE_GLOBAL_BASE_LICENSE_FEATURE + } +} + +impl LicenseFeature for License {} + +/// The wildcard response every proxy operation declares. +fn proxy_response(status: u16, description: &str) -> ResponseSpec { + ResponseSpec { + status, + content_type: "*/*", + description: description.to_owned(), + schema: None, + } +} + +/// Registers all REST routes for the `oagw` gear. +/// +/// # Errors +/// Propagates a failure to build the data plane. +/// +// The `Result` is part of this crate's public API and every other gear builder +// in the workspace returns one, so the always-`Ok` wrapper is kept deliberately. +#[allow(clippy::unnecessary_wraps)] +pub fn register_routes( + router: Router, + openapi: &dyn OpenApiRegistry, + control_plane: Arc, + data_plane: Arc, +) -> anyhow::Result { + let router = management_routes(router, openapi); + let router = proxy_routes(router, openapi); + Ok(router + .layer(axum::Extension(control_plane)) + .layer(axum::Extension(data_plane))) +} + +#[allow(clippy::too_many_lines)] +fn management_routes(mut router: Router, openapi: &dyn OpenApiRegistry) -> Router { + router = OperationBuilder::post("/oagw/v1/upstreams") + .operation_id("oagw.create_upstream") + .summary("Create an upstream") + .description("Registers an upstream service definition and derives its routing alias.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .json_request::(openapi, "Upstream to create") + .handler(handlers::management::create_upstream) + .json_response_with_schema::(openapi, StatusCode::CREATED, "Created upstream") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/upstreams") + .operation_id("oagw.list_upstreams") + .summary("List upstreams") + .description("Lists the tenant's upstreams with the OData subset the gear supports.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .handler(handlers::management::list_upstreams) + .json_response(StatusCode::OK, "The tenant's upstreams") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/upstreams/{id}") + .operation_id("oagw.get_upstream") + .summary("Read an upstream") + .description("Returns one upstream by identifier.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Upstream identifier") + .handler(handlers::management::get_upstream) + .json_response_with_schema::(openapi, StatusCode::OK, "The upstream") + .problem_response(openapi, StatusCode::NOT_FOUND, "Upstream not found") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::put("/oagw/v1/upstreams/{id}") + .operation_id("oagw.replace_upstream") + .summary("Replace an upstream") + .description("Replaces an upstream; omitted fields fall back to the stored values.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .json_request::(openapi, "Upstream fields to replace") + .handler(handlers::management::replace_upstream) + .json_response_with_schema::(openapi, StatusCode::OK, "The upstream") + .problem_response(openapi, StatusCode::NOT_FOUND, "Upstream not found") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/upstreams/{id}") + .operation_id("oagw.delete_upstream") + .summary("Delete an upstream") + .description("Deletes an upstream together with every route that references it.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Upstream identifier") + .handler(handlers::management::delete_upstream) + .no_content_response(StatusCode::NO_CONTENT, "Deleted") + .problem_response(openapi, StatusCode::NOT_FOUND, "Upstream not found") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/oagw/v1/routes") + .operation_id("oagw.create_route") + .summary("Create a route") + .description("Registers a route under an existing upstream.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .json_request::(openapi, "Route to create") + .handler(handlers::management::create_route) + .json_response_with_schema::(openapi, StatusCode::CREATED, "Created route") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/routes") + .operation_id("oagw.list_routes") + .summary("List routes") + .description("Lists the tenant's routes with the OData subset the gear supports.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .handler(handlers::management::list_routes) + .json_response(StatusCode::OK, "The tenant's routes") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/routes/{id}") + .operation_id("oagw.get_route") + .summary("Read a route") + .description("Returns one route by identifier.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Route identifier") + .handler(handlers::management::get_route) + .json_response_with_schema::(openapi, StatusCode::OK, "The route") + .problem_response(openapi, StatusCode::NOT_FOUND, "Route not found") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::put("/oagw/v1/routes/{id}") + .operation_id("oagw.replace_route") + .summary("Replace a route") + .description("Replaces a route; `upstream_id` is immutable and never part of the body.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .json_request::(openapi, "Route fields to replace") + .handler(handlers::management::replace_route) + .json_response_with_schema::(openapi, StatusCode::OK, "The route") + .problem_response(openapi, StatusCode::NOT_FOUND, "Route not found") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/routes/{id}") + .operation_id("oagw.delete_route") + .summary("Delete a route") + .description("Deletes a route.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Route identifier") + .handler(handlers::management::delete_route) + .no_content_response(StatusCode::NO_CONTENT, "Deleted") + .problem_response(openapi, StatusCode::NOT_FOUND, "Route not found") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::post("/oagw/v1/plugins") + .operation_id("oagw.create_plugin") + .summary("Create a plugin") + .description("Registers a custom plugin definition with its Starlark source.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .json_request::(openapi, "Plugin to create") + .handler(handlers::management::create_plugin) + .json_response_with_schema::(openapi, StatusCode::CREATED, "Created plugin") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins") + .operation_id("oagw.list_plugins") + .summary("List plugins") + .description("Lists the tenant's plugins with the OData subset the gear supports.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .handler(handlers::management::list_plugins) + .json_response(StatusCode::OK, "The tenant's plugins") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/{id}") + .operation_id("oagw.get_plugin") + .summary("Read a plugin") + .description("Returns one plugin by identifier.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Plugin identifier") + .handler(handlers::management::get_plugin) + .json_response_with_schema::(openapi, StatusCode::OK, "The plugin") + .problem_response(openapi, StatusCode::NOT_FOUND, "Plugin not found") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/plugins/{id}") + .operation_id("oagw.delete_plugin") + .summary("Delete a plugin") + .description("Deletes a plugin, refusing while an upstream or route still references it.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Plugin identifier") + .handler(handlers::management::delete_plugin) + .no_content_response(StatusCode::NO_CONTENT, "Deleted") + .problem_response(openapi, StatusCode::NOT_FOUND, "Plugin not found") + .problem_response(openapi, StatusCode::CONFLICT, "Plugin is still referenced") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/{id}/source") + .operation_id("oagw.get_plugin_source") + .summary("Read a plugin's source") + .description("Returns the Starlark source of one plugin.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Plugin identifier") + .handler(handlers::management::get_plugin_source) + .json_response_with_schema::(openapi, StatusCode::OK, "The plugin source") + .problem_response(openapi, StatusCode::NOT_FOUND, "Plugin not found") + .standard_errors(openapi) + .register(router, openapi); + + router +} + +fn proxy_routes(router: Router, openapi: &dyn OpenApiRegistry) -> Router { + let router = document_proxy( + router, + openapi, + "/oagw/v1/proxy/{alias}", + handlers::proxy::proxy_root, + ); + document_proxy( + router, + openapi, + "/oagw/v1/proxy/{alias}/{*path}", + handlers::proxy::proxy, + ) +} + +/// Documents and routes one proxy path. +/// +/// Every method shares one handler, so the path is routed once with `any` and +/// the remaining methods are documented against a throwaway router. +/// +// The handler is taken by value because axum's routing methods consume it; each +// method registration only needs a clone of it. +#[allow(clippy::needless_pass_by_value)] +fn document_proxy(mut router: Router, openapi: &dyn OpenApiRegistry, path: &'static str, handler: H) -> Router +where + H: axum::handler::Handler + Clone + Send + 'static, + T: 'static, +{ + let methods = [ + (Method::GET, "get"), + (Method::POST, "post"), + (Method::PUT, "put"), + (Method::DELETE, "delete"), + (Method::PATCH, "patch"), + ]; + + for (index, (method, name)) in methods.iter().enumerate() { + let builder = OperationBuilder::new(method.clone(), path) + .operation_id(format!("oagw.proxy_{name}")) + .summary("Proxy a request") + .description( + "Forwards the request to the upstream the alias resolves to, streaming the \ + response back. Errors the gateway generates are `application/problem+json`.", + ) + .tag(API_TAG) + .authenticated() + .require_license_features::([]); + if index == 0 { + router = builder + .response(proxy_response( + 200, + "The upstream response, streamed back as the gateway received it; a WebSocket upgrade is reported as 101 Switching Protocols.", + )) + .method_router(axum::routing::any(handler.clone())) + .standard_errors(openapi) + .register(router, openapi); + } else { + let documented = builder + .response(proxy_response( + 200, + "The upstream response, streamed back as the gateway received it; a WebSocket upgrade is reported as 101 Switching Protocols.", + )) + .handler(handler.clone()) + .standard_errors(openapi) + .register(Router::new(), openapi); + drop(documented); + } + } + router +} diff --git a/gears/system/oagw/oagw/src/config.rs b/gears/system/oagw/oagw/src/config.rs new file mode 100644 index 0000000..6acc48f --- /dev/null +++ b/gears/system/oagw/oagw/src/config.rs @@ -0,0 +1,150 @@ +//! Gear configuration, deserialized from the `oagw.config` YAML block. + +use serde::Deserialize; + +/// Hard ceiling on a proxied request body, before it is buffered. +pub const MAX_BODY_BYTES: usize = 100 * 1024 * 1024; + +/// Default lifetime of a cached `OAuth2` token, in seconds. +pub const DEFAULT_TOKEN_CACHE_TTL_SECS: u64 = 300; + +/// Default capacity of the `OAuth2` token cache. +pub const DEFAULT_TOKEN_CACHE_CAPACITY: usize = 10_000; + +/// SSRF guard posture. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case", default)] +pub struct SsrfPolicy { + /// Whether SSRF checks run at all. + pub enabled: bool, + /// Whether private/loopback/link-local addresses may be dialled. + pub allow_private_addresses: bool, + /// Explicit host allowlist; consulted only when `enabled` is true. + pub allowlist: Vec, +} + +impl Default for SsrfPolicy { + fn default() -> Self { + Self { + enabled: true, + allow_private_addresses: false, + allowlist: Vec::new(), + } + } +} + +/// Gear configuration. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case", default)] +pub struct OagwConfig { + /// Hard cap on a proxied request body, in bytes. Checked before buffering. + pub max_body_bytes: usize, + /// Total budget for a proxy round trip, in seconds. + pub proxy_timeout_secs: u64, + /// Budget for establishing the upstream connection, in seconds. + pub connect_timeout_secs: u64, + /// Whether a plaintext (`http`/`ws`) connection may actually be made. + /// + /// This governs *connection*, not *acceptance*: an `http` endpoint scheme is + /// always accepted at create time (see `domain::alias` and the wire + /// contract), and this switch decides only whether the gateway will dial it. + pub allow_http_upstream: bool, + /// SSRF guard posture. + pub ssrf_policy: SsrfPolicy, + /// Optional cap on the size of one upstream endpoint pool. + pub max_endpoints_per_upstream: Option, + /// `OAuth2` token-cache entry lifetime, in seconds. + pub token_cache_ttl_secs: u64, + /// `OAuth2` token-cache capacity, in entries. + pub token_cache_capacity: usize, +} + +impl Default for OagwConfig { + fn default() -> Self { + Self { + max_body_bytes: MAX_BODY_BYTES, + proxy_timeout_secs: 60, + connect_timeout_secs: 10, + allow_http_upstream: false, + ssrf_policy: SsrfPolicy::default(), + max_endpoints_per_upstream: None, + token_cache_ttl_secs: DEFAULT_TOKEN_CACHE_TTL_SECS, + token_cache_capacity: DEFAULT_TOKEN_CACHE_CAPACITY, + } + } +} + +impl OagwConfig { + /// Validates the configuration. + /// + /// # Errors + /// Returns an error when a numeric bound is out of range. + pub fn validate(&self) -> anyhow::Result<()> { + if self.max_body_bytes == 0 || self.max_body_bytes > MAX_BODY_BYTES { + return Err(anyhow::anyhow!( + "oagw.config.max_body_bytes must be between 1 and {MAX_BODY_BYTES}" + )); + } + if self.proxy_timeout_secs == 0 { + return Err(anyhow::anyhow!("oagw.config.proxy_timeout_secs must be > 0")); + } + if self.connect_timeout_secs == 0 { + return Err(anyhow::anyhow!( + "oagw.config.connect_timeout_secs must be > 0" + )); + } + if let Some(max) = self.max_endpoints_per_upstream + && max == 0 + { + return Err(anyhow::anyhow!( + "oagw.config.max_endpoints_per_upstream must be > 0" + )); + } + if self.token_cache_ttl_secs == 0 { + return Err(anyhow::anyhow!("oagw.config.token_cache_ttl_secs must be > 0")); + } + if self.token_cache_capacity == 0 { + return Err(anyhow::anyhow!( + "oagw.config.token_cache_capacity must be > 0" + )); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_are_sane() { + let config = OagwConfig::default(); + assert_eq!(config.max_body_bytes, MAX_BODY_BYTES); + assert!(!config.allow_http_upstream); + assert!(config.ssrf_policy.enabled); + assert!(config.validate().is_ok()); + } + + #[test] + fn e2e_shape_parses() { + let raw = serde_json::json!({ + "proxy_timeout_secs": 2, + "allow_http_upstream": true, + "ssrf_policy": { "enabled": false } + }); + let config: OagwConfig = serde_json::from_value(raw).expect("valid config"); + assert_eq!(config.proxy_timeout_secs, 2); + assert!(config.allow_http_upstream); + assert!(!config.ssrf_policy.enabled); + assert!(config.validate().is_ok()); + } + + #[test] + fn zero_timeout_is_rejected() { + let config = OagwConfig { + proxy_timeout_secs: 0, + ..OagwConfig::default() + }; + assert!(config.validate().is_err()); + } +} 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..2af1f5d --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/alias.rs @@ -0,0 +1,545 @@ +//! Alias derivation and enforcement. +//! +//! Pure functions: derivation is table-driven over the endpoint pool and the +//! public suffix list, so the whole derivation matrix in `docs/DESIGN.md` is +//! one unit test with no runtime. + +use crate::domain::dto::Endpoint; +use crate::domain::error::DomainError; + +/// Longest alias a caller may supply, and the RFC 1123 limit. +const MAX_HOSTNAME_LEN: usize = 253; + +/// Normalizes a host: ASCII lower-case, trailing dot stripped, brackets +/// removed from an IPv6 literal. +#[must_use] +pub fn normalize_host(host: &str) -> String { + let trimmed = host.trim(); + let without_brackets = trimmed + .strip_prefix('[') + .and_then(|rest| rest.strip_suffix(']')) + .unwrap_or(trimmed); + without_brackets + .trim_end_matches('.') + .to_ascii_lowercase() +} + +/// Validates a host label per RFC 1123: 1–63 characters, ASCII alphanumeric or +/// hyphen, no leading or trailing hyphen. +fn is_valid_label(label: &str) -> bool { + if label.is_empty() || label.len() > 63 { + return false; + } + if label.starts_with('-') || label.ends_with('-') { + return false; + } + label + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-') +} + +/// RFC 1123 hostname validation. A trailing dot is tolerated and stripped. +#[must_use] +pub fn is_valid_hostname(host: &str) -> bool { + let normalized = normalize_host(host); + if normalized.is_empty() || normalized.len() > MAX_HOSTNAME_LEN { + return false; + } + normalized.split('.').all(is_valid_label) +} + +/// Whether the string is an IP literal (v4 or v6, brackets tolerated). +#[must_use] +pub fn is_ip_literal(host: &str) -> bool { + normalize_host(host).parse::().is_ok() +} + +/// Normalizes an alias: ASCII lower-case, trailing dots stripped. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the normalized alias is empty or +/// does not match the routing-key character set. +pub fn normalize_alias(alias: &str) -> Result { + let normalized = alias.trim().trim_end_matches('.').to_ascii_lowercase(); + if normalized.is_empty() { + return Err(DomainError::Validation("alias must not be empty".into())); + } + if normalized.len() > MAX_HOSTNAME_LEN { + return Err(DomainError::Validation( + "alias exceeds the 253 character limit".into(), + )); + } + let body = normalized.as_bytes(); + let first = *body.first().unwrap_or(&b'-'); + let last = *body.last().unwrap_or(&b'-'); + let label_ok = |byte: u8| byte.is_ascii_alphanumeric() || matches!(byte, b'.' | b':' | b'-'); + if !(first.is_ascii_alphanumeric() + && last.is_ascii_alphanumeric() + && body.iter().all(|byte| label_ok(*byte))) + { + return Err(DomainError::Validation(format!( + "alias `{normalized}` must match ^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$" + ))); + } + Ok(normalized) +} + +/// The trailing sequence of complete labels shared by every hostname. +/// +/// Returns `None` when the endpoints are not all hostnames, when any hostname +/// is invalid, or when there is no shared trailing label sequence. +fn longest_common_label_suffix(hosts: &[&str]) -> Option { + let mut splits = Vec::with_capacity(hosts.len()); + for host in hosts { + let labels: Vec<&str> = host.split('.').collect(); + splits.push(labels); + } + let shortest = splits.iter().map(Vec::len).min()?; + let mut shared: Vec<&str> = Vec::new(); + for index in 1..=shortest { + let candidate = splits[0][splits[0].len() - index]; + if splits + .iter() + .all(|labels| labels[labels.len() - index] == candidate) + { + shared.insert(0, candidate); + } else { + break; + } + } + if shared.is_empty() { + None + } else { + Some(shared.join(".")) + } +} + +/// Whether `candidate` is a registrable domain: at least two labels and not a +/// bare public suffix. +fn is_registrable(candidate: &str) -> bool { + if candidate.split('.').count() < 2 { + return false; + } + // A bare public suffix has no registrable domain above it. + psl::domain_str(candidate) == Some(candidate) +} + +/// Computes the alias an endpoint pool derives, or `None` when the pool is not +/// derivable and an explicit alias is required. +#[must_use] +pub fn compute_derived_alias(endpoints: &[Endpoint]) -> Option { + if endpoints.is_empty() { + return None; + } + if endpoints.iter().any(Endpoint::is_ip) { + return None; + } + let port = endpoints[0].port; + if endpoints.iter().any(|endpoint| endpoint.port != port) { + return None; + } + let scheme = endpoints[0].scheme; + let non_standard = port != scheme.default_port(); + let suffix = if non_standard { format!(":{port}") } else { String::new() }; + + if endpoints.len() == 1 { + let host = normalize_host(&endpoints[0].host); + if is_valid_hostname(&host) { + return Some(format!("{host}{suffix}")); + } + return None; + } + + let hosts: Vec = endpoints + .iter() + .map(|endpoint| normalize_host(&endpoint.host)) + .collect(); + if hosts.iter().any(|host| !is_valid_hostname(host)) { + return None; + } + let refs: Vec<&str> = hosts.iter().map(String::as_str).collect(); + let common = longest_common_label_suffix(&refs)?; + if is_registrable(&common) { + Some(format!("{common}{suffix}")) + } else { + None + } +} + +/// Whether the endpoint pool can derive an alias at all. +#[must_use] +pub fn is_derivable(endpoints: &[Endpoint]) -> bool { + compute_derived_alias(endpoints).is_some() +} + +/// Enforces alias behaviour at create time. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when a supplied alias conflicts with the +/// derived value, when derivation is impossible and no alias was supplied, or +/// when the supplied alias is not a legal routing key. +pub fn enforce_alias_create( + endpoints: &[Endpoint], + provided: Option<&str>, +) -> Result { + let derived = compute_derived_alias(endpoints); + match derived { + Some(expected) => match provided { + None => Ok(expected), + Some(raw) => { + let supplied = normalize_alias(raw)?; + if supplied == expected { + Ok(expected) + } else { + Err(DomainError::Validation(format!( + "alias `{supplied}` conflicts with the derived alias `{expected}`" + ))) + } + } + }, + None => match provided { + None => Err(DomainError::Validation( + "an explicit alias is required for this endpoint set".into(), + )), + Some(raw) => normalize_alias(raw), + } + } +} + +/// Enforces alias immutability on update. +/// +/// `old_endpoints` are the endpoints currently stored; `new_endpoints` are the +/// ones the caller proposes. The alias is the routing key, so any endpoint +/// change that would alter the derived alias is rejected. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the transition would change the +/// alias, or when a supplied alias differs from the retained one. +pub fn enforce_alias_update( + old_endpoints: &[Endpoint], + existing_alias: &str, + new_endpoints: &[Endpoint], + provided: Option<&str>, +) -> Result { + let retained = match ( + compute_derived_alias(old_endpoints), + compute_derived_alias(new_endpoints), + ) { + (Some(previous), Some(next)) => { + if previous == next { + next + } else { + return Err(DomainError::Validation( + "changing these endpoints would change the derived alias; delete and re-create the upstream" + .into(), + )); + } + } + (Some(_), None) => { + return Err(DomainError::Validation( + "changing these endpoints would make the alias non-derivable; delete and re-create the upstream" + .into(), + )); + } + (None, Some(next)) => { + if next == existing_alias { + next + } else { + return Err(DomainError::Validation( + "changing these endpoints would change the derived alias; delete and re-create the upstream" + .into(), + )); + } + } + (None, None) => existing_alias.to_owned(), + }; + + match provided { + None => Ok(retained), + Some(raw) => { + let supplied = normalize_alias(raw)?; + if supplied == retained { + Ok(retained) + } else { + Err(DomainError::Validation(format!( + "alias `{supplied}` differs from the retained alias `{retained}`; the alias is immutable" + ))) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn endpoint(scheme: crate::domain::dto::Scheme, host: &str, port: u16) -> Endpoint { + Endpoint { + scheme, + host: host.to_owned(), + port, + } + } + + fn https(host: &str) -> Endpoint { + endpoint(crate::domain::dto::Scheme::Https, host, 443) + } + + fn http(host: &str) -> Endpoint { + endpoint(crate::domain::dto::Scheme::Http, host, 80) + } + + // ---- derivation table ------------------------------------------------- + + #[test] + fn single_hostname_standard_port() { + let derived = compute_derived_alias(&[https("api.openai.com")]); + assert_eq!(derived.as_deref(), Some("api.openai.com")); + } + + #[test] + fn single_hostname_non_standard_port() { + let endpoints = [endpoint(crate::domain::dto::Scheme::Https, "api.openai.com", 8443)]; + assert_eq!( + compute_derived_alias(&endpoints).as_deref(), + Some("api.openai.com:8443") + ); + } + + #[test] + fn single_plaintext_hostname_standard_port() { + assert_eq!(compute_derived_alias(&[http("localhost")]).as_deref(), Some("localhost")); + } + + #[test] + fn common_suffix_across_a_pool() { + let endpoints = [https("us.vendor.com"), https("eu.vendor.com")]; + assert_eq!(compute_derived_alias(&endpoints).as_deref(), Some("vendor.com")); + } + + #[test] + fn common_suffix_with_non_standard_port() { + let endpoints = [ + endpoint(crate::domain::dto::Scheme::Https, "us.vendor.com", 8443), + endpoint(crate::domain::dto::Scheme::Https, "eu.vendor.com", 8443), + ]; + assert_eq!( + compute_derived_alias(&endpoints).as_deref(), + Some("vendor.com:8443") + ); + } + + #[test] + fn bare_public_suffix_is_not_derivable() { + let endpoints = [https("foo.co.uk"), https("bar.co.uk")]; + assert!(compute_derived_alias(&endpoints).is_none()); + } + + #[test] + fn no_common_suffix_is_not_derivable() { + let endpoints = [https("api.openai.com"), https("api.another.com")]; + assert!(compute_derived_alias(&endpoints).is_none()); + } + + #[test] + fn ip_endpoints_are_not_derivable() { + let endpoints = [endpoint(crate::domain::dto::Scheme::Https, "10.0.1.1", 443)]; + assert!(compute_derived_alias(&endpoints).is_none()); + } + + #[test] + fn heterogeneous_ports_are_not_derivable() { + let endpoints = [ + endpoint(crate::domain::dto::Scheme::Https, "us.vendor.com", 443), + endpoint(crate::domain::dto::Scheme::Https, "eu.vendor.com", 8443), + ]; + assert!(compute_derived_alias(&endpoints).is_none()); + } + + #[test] + fn single_host_with_public_suffix_is_still_derivable() { + assert_eq!(compute_derived_alias(&[https("foo.co.uk")]).as_deref(), Some("foo.co.uk")); + } + + #[test] + fn mixed_ip_and_hostname_is_not_derivable() { + let endpoints = [https("us.vendor.com"), endpoint(crate::domain::dto::Scheme::Https, "10.0.1.1", 443)]; + assert!(compute_derived_alias(&endpoints).is_none()); + } + + #[test] + fn empty_pool_is_not_derivable() { + assert!(compute_derived_alias(&[]).is_none()); + } + + // ---- create-time enforcement ----------------------------------------- + + #[test] + fn create_without_alias_uses_derivation() { + let endpoints = [https("api.openai.com")]; + assert_eq!( + enforce_alias_create(&endpoints, None).expect("derives"), + "api.openai.com" + ); + } + + #[test] + fn create_with_conflicting_alias_is_rejected() { + let endpoints = [https("api.openai.com")]; + let error = enforce_alias_create(&endpoints, Some("other")) + .expect_err("conflicts"); + assert_eq!(error.status(), 400); + } + + #[test] + fn create_with_derived_alias_is_idempotent() { + let endpoints = [https("api.openai.com")]; + assert_eq!( + enforce_alias_create(&endpoints, Some("api.openai.com")).expect("matches"), + "api.openai.com" + ); + } + + #[test] + fn create_with_ip_and_no_alias_is_rejected() { + let endpoints = [endpoint(crate::domain::dto::Scheme::Https, "10.0.1.1", 443)]; + assert!(enforce_alias_create(&endpoints, None).is_err()); + } + + #[test] + fn create_with_ip_and_explicit_alias_succeeds() { + let endpoints = [endpoint(crate::domain::dto::Scheme::Https, "10.0.1.1", 443)]; + assert_eq!( + enforce_alias_create(&endpoints, Some("My-Internal-Service")) + .expect("explicit"), + "my-internal-service" + ); + } + + #[test] + fn create_with_bare_public_suffix_pool_and_no_alias_is_rejected() { + let endpoints = [https("foo.co.uk"), https("bar.co.uk")]; + assert!(enforce_alias_create(&endpoints, None).is_err()); + } + + #[test] + fn create_normalizes_case_and_trailing_dot() { + let endpoints = [https("api.openai.com")]; + assert_eq!( + enforce_alias_create(&endpoints, None).expect("derives"), + "api.openai.com" + ); + let raw = Endpoint { + scheme: crate::domain::dto::Scheme::Https, + host: "API.OpenAI.COM.".into(), + port: 443, + }; + assert_eq!( + compute_derived_alias(&[raw]).as_deref(), + Some("api.openai.com") + ); + } + + #[test] + fn create_rejects_an_illegal_alias() { + let endpoints = [endpoint(crate::domain::dto::Scheme::Https, "10.0.1.1", 443)]; + assert!(enforce_alias_create(&endpoints, Some("-bad-")).is_err()); + assert!(enforce_alias_create(&endpoints, Some("a b")).is_err()); + } + + // ---- update-time enforcement ----------------------------------------- + + #[test] + fn update_that_would_change_a_derived_alias_is_rejected() { + let old = [https("api.openai.com")]; + let new = [https("api.another.com")]; + let error = enforce_alias_update(&old, "api.openai.com", &new, None).expect_err("rejected"); + assert_eq!(error.status(), 400); + } + + #[test] + fn update_that_preserves_the_derived_alias_is_allowed() { + let old = [https("api.openai.com")]; + let new = [https("api.openai.com")]; + assert_eq!( + enforce_alias_update(&old, "api.openai.com", &new, None).expect("kept"), + "api.openai.com" + ); + } + + #[test] + fn update_from_derivable_to_non_derivable_is_always_rejected() { + let old = [https("api.openai.com")]; + let new = [endpoint(crate::domain::dto::Scheme::Https, "10.0.1.1", 443)]; + assert!(enforce_alias_update(&old, "api.openai.com", &new, Some("api.openai.com")).is_err()); + } + + #[test] + fn update_between_non_derivable_sets_retains_the_alias() { + let old = [endpoint(crate::domain::dto::Scheme::Https, "10.0.1.1", 443)]; + let new = [endpoint(crate::domain::dto::Scheme::Https, "10.0.1.2", 443)]; + assert_eq!( + enforce_alias_update(&old, "my-internal-service", &new, None).expect("retained"), + "my-internal-service" + ); + assert!(enforce_alias_update(&old, "my-internal-service", &new, Some("other")).is_err()); + } + + #[test] + fn update_from_non_derivable_to_derivable_is_allowed_when_the_alias_matches() { + let old = [endpoint(crate::domain::dto::Scheme::Https, "10.0.1.1", 443)]; + let new = [https("api.openai.com")]; + assert_eq!( + enforce_alias_update(&old, "api.openai.com", &new, None).expect("allowed"), + "api.openai.com" + ); + assert!(enforce_alias_update(&old, "other", &new, None).is_err()); + } + + #[test] + fn update_without_endpoint_change_tolerates_the_exact_alias() { + let old = [https("api.openai.com")]; + assert_eq!( + enforce_alias_update(&old, "api.openai.com", &old, Some("api.openai.com")) + .expect("no-op"), + "api.openai.com" + ); + } + + // ---- hostname validation --------------------------------------------- + + #[test] + fn rfc1123_rules() { + assert!(is_valid_hostname("api.openai.com")); + assert!(is_valid_hostname("api.openai.com.")); + assert!(is_valid_hostname("a-b.example.com")); + assert!(!is_valid_hostname("-bad.example.com")); + assert!(!is_valid_hostname("bad-.example.com")); + assert!(!is_valid_hostname("")); + assert!(!is_valid_hostname(&"a".repeat(254))); + assert!(!is_valid_hostname(&format!("{}.example.com", "a".repeat(64)))); + assert!(is_valid_hostname(&format!("{}.example.com", "a".repeat(63)))); + assert!(!is_valid_hostname("exa mple.com")); + assert!(is_valid_hostname("10.0.1.1")); + } + + #[test] + fn common_suffix_helper() { + assert_eq!( + longest_common_label_suffix(&["us.vendor.com", "eu.vendor.com"]).as_deref(), + Some("vendor.com") + ); + assert_eq!( + longest_common_label_suffix(&["api.openai.com", "api.another.com"]).as_deref(), + Some("com") + ); + // `a.b` is the shared *prefix* here; c, d and e disagree, so no + // trailing label sequence is shared at all. + assert!(longest_common_label_suffix(&["a.b.c", "a.b.d", "a.b.e"]).is_none()); + assert_eq!( + longest_common_label_suffix(&["a.b.c", "x.b.c"]).as_deref(), + Some("b.c") + ); + assert!(longest_common_label_suffix(&["alpha", "beta"]).is_none()); + } +} diff --git a/gears/system/oagw/oagw/src/domain/dto.rs b/gears/system/oagw/oagw/src/domain/dto.rs new file mode 100644 index 0000000..22f3f3b --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/dto.rs @@ -0,0 +1,692 @@ +//! Domain DTOs mirroring `docs/schemas/*.json`. +//! +//! These are the storage and execution shapes; `api::rest::dto` holds the wire +//! shapes. The `scheme` enum is a *schema-level* enum: it admits the plaintext +//! family unconditionally, and whether a plaintext connection is actually made +//! is governed separately by `OagwConfig::allow_http_upstream`. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + + +/// Endpoint scheme. Admits the TLS family and the plaintext family. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Scheme { + /// Plaintext HTTP. + Http, + /// TLS HTTP. + Https, + /// Plaintext WebSocket. + Ws, + /// TLS WebSocket. + Wss, + /// WebTransport. + Wt, + /// gRPC over HTTP/2. + Grpc, +} + +// The receiver stays `&self` on these `Copy` types so the public method +// signatures are unchanged. +#[allow(clippy::trivially_copy_pass_by_ref)] +impl Scheme { + /// Whether the scheme is plaintext. + #[must_use] + pub const fn is_plaintext(&self) -> bool { + matches!(self, Self::Http | Self::Ws) + } + + /// Whether the scheme is a WebSocket-family scheme. + #[must_use] + pub const fn is_websocket(&self) -> bool { + matches!(self, Self::Ws | Self::Wss) + } + + /// The default port for this scheme. + #[must_use] + pub const fn default_port(&self) -> u16 { + match self { + Self::Http | Self::Ws => 80, + Self::Https | Self::Wss | Self::Wt | Self::Grpc => 443, + } + } + + /// The wire name of the scheme. + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Http => "http", + Self::Https => "https", + Self::Ws => "ws", + Self::Wss => "wss", + Self::Wt => "wt", + Self::Grpc => "grpc", + } + } +} + +/// One upstream endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct Endpoint { + /// Endpoint scheme. + pub scheme: Scheme, + /// Hostname or IP address. + pub host: String, + /// Port. + pub port: u16, +} + +impl Endpoint { + /// Whether `host` is a literal IP address. + #[must_use] + pub fn is_ip(&self) -> bool { + self.host.parse::().is_ok() + } + + /// The `host[:port]` form, omitting the port when it is the scheme default. + #[must_use] + pub fn host_with_port(&self) -> String { + if self.port == self.scheme.default_port() { + self.host.clone() + } else { + format!("{}:{}", self.host, self.port) + } + } +} + +/// `server` block. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ServerConfig { + /// At least one endpoint. + pub endpoints: Vec, +} + +/// Hierarchical sharing mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Sharing { + /// Not visible to descendants. + #[default] + Private, + /// Descendants may override. + Inherit, + /// Descendants may not override. + Enforce, +} + +impl Sharing { + /// The wire name of this sharing mode. + #[must_use] + pub const fn as_wire(self) -> &'static str { + match self { + Self::Private => "private", + Self::Inherit => "inherit", + Self::Enforce => "enforce", + } + } +} + +/// Header passthrough posture. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum HeaderPassthrough { + /// Forward no inbound header. + #[default] + None, + /// Forward only `passthrough_allowlist`. + Allowlist, + /// Forward everything except the hop-by-hop and routing headers. + All, +} + +/// Outbound request header rules. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct RequestHeaderRules { + /// Overwrite if present. + #[serde(default)] + pub set: BTreeMap, + /// Append, allowing duplicates. + #[serde(default)] + pub add: BTreeMap, + /// Drop if present. + #[serde(default)] + pub remove: Vec, + /// Which inbound headers to forward. + #[serde(default)] + pub passthrough: HeaderPassthrough, + /// Headers forwarded when `passthrough` is `allowlist`. + #[serde(default)] + pub passthrough_allowlist: Vec, +} + +/// Inbound response header rules. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct ResponseHeaderRules { + /// Overwrite if present. + #[serde(default)] + pub set: BTreeMap, + /// Append, allowing duplicates. + #[serde(default)] + pub add: BTreeMap, + /// Drop if present. + #[serde(default)] + pub remove: Vec, +} + +/// Header transformation configuration. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct HeadersConfig { + /// Rules applied to the outbound request. + #[serde(default)] + pub request: Option, + /// Rules applied to the response. + #[serde(default)] + pub response: Option, +} + +/// Rate window. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RateWindow { + /// One second. + #[default] + Second, + /// One minute. + Minute, + /// One hour. + Hour, + /// One day. + Day, +} + +impl RateWindow { + /// Window length in seconds. + #[must_use] + pub const fn seconds(self) -> u64 { + match self { + Self::Second => 1, + Self::Minute => 60, + Self::Hour => 3600, + Self::Day => 86_400, + } + } +} + +/// Rate-limiting algorithm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum RateAlgorithm { + /// Classic token bucket; permits bursts. + #[default] + TokenBucket, + /// Sliding window; prevents boundary bursts. + SlidingWindow, +} + +/// Counter scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RateScope { + /// One bucket for the whole gateway. + Global, + /// One bucket per tenant. + #[default] + Tenant, + /// One bucket per authenticated subject. + User, + /// One bucket per client address. + Ip, + /// One bucket per route. + Route, +} + +/// Behaviour when the budget is exhausted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum RateStrategy { + /// Return `429`. + #[default] + Reject, + /// Hold the request until a token is available. + Queue, + /// Serve a degraded response. + Degrade, +} + +/// Sustained rate component. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct SustainedRate { + /// Tokens replenished per window. + pub rate: u64, + /// Window length. + #[serde(default)] + pub window: RateWindow, +} + +/// Burst component. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct Burst { + /// Bucket capacity. + pub capacity: u64, +} + +/// Rate-limit configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct RateLimitConfig { + /// Sharing mode. + #[serde(default)] + pub sharing: Sharing, + /// Algorithm. + #[serde(default)] + pub algorithm: RateAlgorithm, + /// Sustained rate; the only required field. + pub sustained: SustainedRate, + /// Burst capacity; defaults to `sustained.rate`. + #[serde(default)] + pub burst: Option, + /// Counter scope. + #[serde(default)] + pub scope: RateScope, + /// Overflow behaviour. + #[serde(default)] + pub strategy: RateStrategy, + /// Tokens consumed per request. + #[serde(default = "default_cost")] + pub cost: u64, +} + +fn default_cost() -> u64 { + 1 +} + +impl RateLimitConfig { + /// Effective bucket capacity: `burst.capacity` or `sustained.rate`. + #[must_use] + pub fn capacity(&self) -> u64 { + self.burst.map_or(self.sustained.rate, |burst| burst.capacity) + } + + /// Tokens replenished per second. + /// + // The widening to `f64` is the contract of this method (it returns a float + // rate); the casts are kept explicit and exact for realistic rates. + #[allow(clippy::cast_precision_loss)] + #[must_use] + pub fn refill_per_second(&self) -> f64 { + self.sustained.rate as f64 / self.sustained.window.seconds() as f64 + } +} + +/// CORS configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct CorsConfig { + /// Sharing mode. + #[serde(default)] + pub sharing: Sharing, + /// Whether CORS handling is active. + pub enabled: bool, + /// Allowed origins; `["*"]` permits any origin. + #[serde(default)] + pub allowed_origins: Vec, + /// Allowed methods. + #[serde(default = "default_allowed_methods")] + pub allowed_methods: Vec, + /// Headers exposed to the browser. + #[serde(default)] + pub expose_headers: Vec, + /// Whether credentials are allowed. + #[serde(default)] + pub allow_credentials: bool, +} + +fn default_allowed_methods() -> Vec { + vec!["GET".to_owned(), "POST".to_owned()] +} + +impl CorsConfig { + /// Whether `origin` is allowed, exactly and case-sensitively. + #[must_use] + pub fn allows_origin(&self, origin: &str) -> bool { + if self.allowed_origins.iter().any(|allowed| allowed == "*") { + return true; + } + self.allowed_origins.iter().any(|allowed| allowed == origin) + } + + /// Whether `method` is allowed (case-insensitive). + #[must_use] + pub fn allows_method(&self, method: &str) -> bool { + self.allowed_methods + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(method)) + } +} + +/// Plugin-chain configuration. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct PluginsConfig { + /// Sharing mode. + #[serde(default)] + pub sharing: Sharing, + /// Built-in plugins by GTS identifier, custom plugins by UUID. + #[serde(default)] + pub items: Vec, + /// Per-plugin configuration, keyed by the plugin reference. + #[serde(default)] + pub config: std::collections::BTreeMap, +} + +impl PluginsConfig { + /// The configuration declared for `reference`, or an empty object. + #[must_use] + pub fn config_for(&self, reference: &str) -> serde_json::Value { + self.config + .get(reference) + .cloned() + .unwrap_or_else(|| serde_json::Value::Object(serde_json::Map::default())) + } +} + +/// Upstream protocol. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Protocol { + /// Plaintext/TLS HTTP. + Http, + /// gRPC. + Grpc, +} + +impl Protocol { + /// The GTS identifier for this protocol. + #[must_use] + pub const fn gts_id(self) -> &'static str { + match self { + Self::Http => crate::domain::gts_helpers::PROTOCOL_HTTP, + Self::Grpc => crate::domain::gts_helpers::PROTOCOL_GRPC, + } + } + + /// Parses a protocol from its GTS identifier. + #[must_use] + pub fn from_gts_id(value: &str) -> Option { + match value { + crate::domain::gts_helpers::PROTOCOL_HTTP => Some(Self::Http), + crate::domain::gts_helpers::PROTOCOL_GRPC => Some(Self::Grpc), + _ => None, + } + } +} + +/// An upstream service definition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct Upstream { + /// System-generated identifier. + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Whether the upstream accepts traffic. + pub enabled: bool, + /// Routing key, unique per tenant. + pub alias: String, + /// Tags, unioned across the hierarchy. + #[serde(default)] + pub tags: Vec, + /// Endpoint pool. + pub server: ServerConfig, + /// Upstream protocol. + pub protocol: Protocol, + /// Authentication configuration. + #[serde(default)] + pub auth: Option, + /// Header transformation rules. + #[serde(default)] + pub headers: Option, + /// Plugin chain. + #[serde(default)] + pub plugins: Option, + /// Rate limit. + #[serde(default)] + pub rate_limit: Option, + /// CORS policy. + #[serde(default)] + pub cors: Option, + /// Creation timestamp, RFC 3339. + #[serde(default)] + pub created_at: Option, + /// Last-modified timestamp, RFC 3339. + #[serde(default)] + pub updated_at: Option, +} + +/// Authentication configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct AuthConfig { + /// Auth plugin identifier. + #[serde(rename = "type")] + pub auth_type: String, + /// Sharing mode. + #[serde(default)] + pub sharing: Sharing, + /// Plugin configuration. + #[serde(default)] + pub config: serde_json::Value, +} + +/// HTTP method accepted by a route. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +pub enum HttpMethod { + /// `GET` + Get, + /// `POST` + Post, + /// `PUT` + Put, + /// `DELETE` + Delete, + /// `PATCH` + Patch, +} + +impl HttpMethod { + /// Parses an HTTP method, case-insensitively. + #[must_use] + pub fn parse(value: &str) -> Option { + match value.to_ascii_uppercase().as_str() { + "GET" => Some(Self::Get), + "POST" => Some(Self::Post), + "PUT" => Some(Self::Put), + "DELETE" => Some(Self::Delete), + "PATCH" => Some(Self::Patch), + _ => None, + } + } + + /// The upper-case wire name. + // The receiver stays `&self` so the public method signature is unchanged. + #[allow(clippy::trivially_copy_pass_by_ref)] + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Get => "GET", + Self::Post => "POST", + Self::Put => "PUT", + Self::Delete => "DELETE", + Self::Patch => "PATCH", + } + } +} + +/// How a request's path suffix is treated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PathSuffixMode { + /// Append the suffix to `match.http.path`. + #[default] + Append, + /// Reject any request carrying a suffix. + Disabled, +} + +/// HTTP match rules. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct HttpMatch { + /// Method allowlist; non-empty. + pub methods: Vec, + /// Path prefix. + pub path: String, + /// Query parameters the caller may send; empty permits none. + #[serde(default)] + pub query_allowlist: Vec, + /// Suffix handling. + #[serde(default)] + pub path_suffix_mode: PathSuffixMode, +} + +/// gRPC match rules. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct GrpcMatch { + /// Fully qualified service name. + pub service: String, + /// RPC method name. + pub method: String, +} + +/// Exactly one of HTTP or gRPC matching. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MatchRule { + /// HTTP match. + Http(HttpMatch), + /// gRPC match. + Grpc(GrpcMatch), +} + +impl MatchRule { + /// The priority of this rule: longer HTTP paths win. + #[must_use] + pub fn priority(&self) -> usize { + match self { + Self::Http(http) => http.path.len(), + Self::Grpc(_) => 0, + } + } +} + +/// A route definition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct Route { + /// System-generated identifier. + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Owning upstream. + pub upstream_id: Uuid, + /// Whether the route participates in matching. + pub enabled: bool, + /// Tags. + #[serde(default)] + pub tags: Vec, + /// Match rules. + pub match_rule: MatchRule, + /// Plugin chain. + #[serde(default)] + pub plugins: Option, + /// Rate limit. + #[serde(default)] + pub rate_limit: Option, + /// CORS configuration. + #[serde(default)] + pub cors: Option, + /// Creation timestamp, RFC 3339. + #[serde(default)] + pub created_at: Option, + /// Last-modified timestamp, RFC 3339. + #[serde(default)] + pub updated_at: Option, +} + +/// A stored plugin definition. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub struct Plugin { + /// System-generated identifier. + pub id: Uuid, + /// Owning tenant. + pub tenant_id: Uuid, + /// Plugin family (`auth_plugin`, `guard_plugin`, `transform_plugin`). + // `plugin_type` is the field's name in the published JSON schema, so the + // prefix cannot be dropped. + #[allow(clippy::struct_field_names)] + pub plugin_type: String, + /// Human readable name. + pub name: String, + /// Starlark source, served by `GET /oagw/v1/plugins/{id}/source`. + #[serde(default)] + pub source: String, + /// Plugin configuration document. + #[serde(default)] + pub config: serde_json::Value, + /// Earliest instant at which an unlinked plugin may be collected. + #[serde(default)] + pub gc_eligible_at: Option, + /// Creation timestamp, RFC 3339. + #[serde(default)] + pub created_at: Option, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn scheme_defaults() { + assert_eq!(Scheme::Http.default_port(), 80); + assert_eq!(Scheme::Https.default_port(), 443); + assert!(Scheme::Http.is_plaintext()); + assert!(Scheme::Ws.is_websocket()); + } + + #[test] + fn endpoint_ip_detection() { + let endpoint = Endpoint { + scheme: Scheme::Https, + host: "10.0.1.1".into(), + port: 443, + }; + assert!(endpoint.is_ip()); + assert_eq!(endpoint.host_with_port(), "10.0.1.1"); + let nonstandard = Endpoint { + scheme: Scheme::Https, + host: "api.openai.com".into(), + port: 8443, + }; + assert_eq!(nonstandard.host_with_port(), "api.openai.com:8443"); + } + + #[test] + fn http_method_parse_is_case_insensitive() { + assert_eq!(HttpMethod::parse("get"), Some(HttpMethod::Get)); + assert_eq!(HttpMethod::parse("PATCH"), Some(HttpMethod::Patch)); + assert_eq!(HttpMethod::parse("OPTIONS"), None); + } +} 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..d9a0b96 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/error.rs @@ -0,0 +1,351 @@ +//! The gateway error table. +//! +//! One enum, one `(status, GTS type, title, retriable)` row per error, so the +//! table in `docs/DESIGN.md` §3.3 is checkable in a single place. + +use thiserror::Error; + +use crate::domain::gts_helpers::error_id; + +/// Every error the gateway can generate. +#[derive(Debug, Error)] +pub enum DomainError { + /// Configuration or request validation failed. + #[error("{0}")] + Validation(String), + /// A required `X-OAGW-Target-Host` header was absent. + #[error("a target host header is required for this upstream")] + MissingTargetHost, + /// `X-OAGW-Target-Host` was not a bare hostname. + #[error("invalid target host: {0}")] + InvalidTargetHost(String), + /// `X-OAGW-Target-Host` named no configured endpoint. + #[error("unknown target host: {0}")] + UnknownTargetHost(String), + /// Credentials could not be resolved or the auth plugin is unknown. + #[error("authentication failed: {0}")] + AuthenticationFailed(String), + /// No route matched the request. + #[error("{0}")] + RouteNotFound(String), + /// A referenced plugin would be invalidated by the operation. + #[error("{0}")] + PluginInUse(String), + /// The request body exceeded the configured limit. + #[error("{0}")] + PayloadTooLarge(String), + /// The caller exhausted its token budget. + #[error("{0}")] + RateLimitExceeded(String), + /// A secret reference could not be resolved. + #[error("{0}")] + SecretNotFound(String), + /// The upstream returned a transport-level failure. + #[error("{0}")] + DownstreamError(String), + /// The circuit breaker for this upstream is open. + #[error("circuit breaker open")] + CircuitBreakerOpen, + /// No upstream endpoint could be reached. + #[error("upstream link unavailable")] + LinkUnavailable, + /// The upstream did not answer within the request budget. + #[error("upstream request timed out")] + RequestTimeout, + /// Establishing the upstream connection timed out. + #[error("upstream connection timed out")] + ConnectionTimeout, + /// A resource already exists with the same key. + #[error("{0}")] + Conflict(String), + /// A named resource does not exist for this tenant. + #[error("{0}")] + NotFound(String), + /// A cross-origin request used an origin the policy does not allow. + #[error("{0}")] + CorsOriginNotAllowed(String), + /// A cross-origin request used a method the policy does not allow. + #[error("{0}")] + CorsMethodNotAllowed(String), + /// The upstream answered 101 Switching Protocols but no upgrade followed. + #[error("{0}")] + UpgradeFailed(String), +} + +impl DomainError { + /// The error-table row for this variant. + #[must_use] + pub fn descriptor(&self) -> ErrorDescriptor { + match self { + Self::Validation(_) => row(400, "validation", "error", "Validation Error", false), + Self::MissingTargetHost => row( + 400, + "routing", + "missing_target_host", + "Missing Target Host", + false, + ), + Self::InvalidTargetHost(_) => row( + 400, + "routing", + "invalid_target_host", + "Invalid Target Host", + false, + ), + Self::UnknownTargetHost(_) => row( + 400, + "routing", + "unknown_target_host", + "Unknown Target Host", + false, + ), + Self::AuthenticationFailed(_) => row(401, "auth", "failed", "Authentication Failed", false), + Self::RouteNotFound(_) => row(404, "route", "not_found", "Route Not Found", false), + Self::PluginInUse(_) => row(409, "plugin", "in_use", "Plugin In Use", false), + Self::PayloadTooLarge(_) => row(413, "payload", "too_large", "Payload Too Large", false), + Self::RateLimitExceeded(_) => row( + 429, + "rate_limit", + "exceeded", + "Rate Limit Exceeded", + true, + ), + Self::SecretNotFound(_) => row(500, "secret", "not_found", "Secret Not Found", false), + Self::DownstreamError(text) => row( + 502, + "downstream", + "error", + "Downstream Error", + downstream_is_retriable(text), + ), + Self::CircuitBreakerOpen => row( + 503, + "circuit_breaker", + "open", + "Circuit Breaker Open", + true, + ), + Self::LinkUnavailable => row(503, "link", "unavailable", "Link Unavailable", true), + Self::RequestTimeout => row(504, "timeout", "request", "Request Timeout", true), + Self::ConnectionTimeout => row(504, "timeout", "connection", "Connection Timeout", true), + Self::Conflict(_) => row(409, "validation", "conflict", "Conflict", false), + Self::NotFound(_) => row(404, "route", "not_found", "Not Found", false), + Self::CorsOriginNotAllowed(_) => row( + 403, + "cors", + "origin_not_allowed", + "Origin Not Allowed", + false, + ), + Self::CorsMethodNotAllowed(_) => row( + 403, + "cors", + "method_not_allowed", + "Method Not Allowed", + false, + ), + Self::UpgradeFailed(_) => row(502, "downstream", "error", "Upgrade Failed", true), + } + } + + /// HTTP status code for this error. + #[must_use] + pub fn status(&self) -> u16 { + self.descriptor().status + } + + /// GTS error type identifier. + #[must_use] + pub fn gts_type(&self) -> String { + self.descriptor().gts_type + } + + /// Whether retrying the same request may succeed. + #[must_use] + pub fn retriable(&self) -> bool { + self.descriptor().retriable + } +} + +/// Whether a downstream failure looks like one a retry could clear. +/// +/// The error table marks `DownstreamError` "Depends": an upstream that dropped +/// the connection or never answered may well answer next time, while a request +/// the gateway could not even build will fail identically forever. The message +/// is the only evidence available at this point, so it is what decides. +fn downstream_is_retriable(detail: &str) -> bool { + const TRANSIENT: [&str; 12] = [ + "connection reset", + "broken pipe", + "connection closed", + "incomplete message", + "timed out", + "deadline", + "refused", + "unreachable", + "dns", + "handshake", + "tls", + "io error", + ]; + let lowered = detail.to_ascii_lowercase(); + TRANSIENT.iter().any(|marker| lowered.contains(marker)) +} + +/// The classification of one `DomainError` variant. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ErrorDescriptor { + /// HTTP status code. + pub status: u16, + /// GTS error type identifier. + pub gts_type: String, + /// Human readable title. + pub title: String, + /// Whether a retry may succeed. + pub retriable: bool, +} + +fn row( + status: u16, + family: &'static str, + name: &'static str, + title: &'static str, + retriable: bool, +) -> ErrorDescriptor { + ErrorDescriptor { + status, + gts_type: error_id(family, name), + title: title.to_owned(), + retriable, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_row(error: &DomainError, status: u16, gts_type: &str, retriable: bool) { + assert_eq!(error.status(), status, "{error}"); + assert_eq!(error.gts_type(), gts_type, "{error}"); + assert_eq!(error.retriable(), retriable, "{error}"); + } + + #[test] + fn error_table_matches_design() { + assert_row( + &DomainError::Validation("x".into()), + 400, + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1", + false, + ); + assert_row( + &DomainError::MissingTargetHost, + 400, + "gts.cf.core.errors.err.v1~cf.oagw.routing.missing_target_host.v1", + false, + ); + assert_row( + &DomainError::InvalidTargetHost("x".into()), + 400, + "gts.cf.core.errors.err.v1~cf.oagw.routing.invalid_target_host.v1", + false, + ); + assert_row( + &DomainError::UnknownTargetHost("x".into()), + 400, + "gts.cf.core.errors.err.v1~cf.oagw.routing.unknown_target_host.v1", + false, + ); + assert_row( + &DomainError::AuthenticationFailed("x".into()), + 401, + "gts.cf.core.errors.err.v1~cf.oagw.auth.failed.v1", + false, + ); + assert_row( + &DomainError::RouteNotFound("x".into()), + 404, + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1", + false, + ); + assert_row( + &DomainError::PluginInUse("x".into()), + 409, + "gts.cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1", + false, + ); + assert_row( + &DomainError::PayloadTooLarge("x".into()), + 413, + "gts.cf.core.errors.err.v1~cf.oagw.payload.too_large.v1", + false, + ); + assert_row( + &DomainError::RateLimitExceeded("x".into()), + 429, + "gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1", + true, + ); + assert_row( + &DomainError::SecretNotFound("x".into()), + 500, + "gts.cf.core.errors.err.v1~cf.oagw.secret.not_found.v1", + false, + ); + assert_row( + &DomainError::DownstreamError("x".into()), + 502, + "gts.cf.core.errors.err.v1~cf.oagw.downstream.error.v1", + false, + ); + assert!( + DomainError::DownstreamError("connection closed before message completed".into()).retriable(), + "a dropped connection may succeed on a retry" + ); + assert!( + !DomainError::DownstreamError("cannot build the upstream request: x".into()) + .retriable(), + "a request the gateway could not build fails identically next time" + ); + assert_row( + &DomainError::CircuitBreakerOpen, + 503, + "gts.cf.core.errors.err.v1~cf.oagw.circuit_breaker.open.v1", + true, + ); + assert_row( + &DomainError::LinkUnavailable, + 503, + "gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1", + true, + ); + assert_row( + &DomainError::RequestTimeout, + 504, + "gts.cf.core.errors.err.v1~cf.oagw.timeout.request.v1", + true, + ); + assert_row( + &DomainError::ConnectionTimeout, + 504, + "gts.cf.core.errors.err.v1~cf.oagw.timeout.connection.v1", + true, + ); + } + + #[test] + fn cors_rows_use_the_cors_family() { + assert_row( + &DomainError::CorsOriginNotAllowed("origin".into()), + 403, + "gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1", + false, + ); + assert_row( + &DomainError::CorsMethodNotAllowed("POST".into()), + 403, + "gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1", + false, + ); + } +} diff --git a/gears/system/oagw/oagw/src/domain/gts_helpers.rs b/gears/system/oagw/oagw/src/domain/gts_helpers.rs new file mode 100644 index 0000000..19d558e --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/gts_helpers.rs @@ -0,0 +1,127 @@ +//! GTS identifiers used by the `oagw` gear. +//! +//! Every error carries a `gts.cf.core.errors.err.v1~cf.oagw...v1` +//! identifier; every configuration object references the protocol and plugin +//! identifiers tabulated here. + +use uuid::Uuid; + +/// Prefix of every gateway-generated error type identifier. +pub const ERROR_PREFIX: &str = "gts.cf.core.errors.err.v1~cf.oagw"; + +/// Builds the error type identifier for `family.name`. +#[must_use] +pub fn error_id(family: &str, name: &str) -> String { + format!("{ERROR_PREFIX}.{family}.{name}.v1") +} + +/// Identifier of the HTTP upstream protocol. +pub const PROTOCOL_HTTP: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +/// Identifier of the gRPC upstream protocol. +pub const PROTOCOL_GRPC: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1"; + +/// Type-schema identifier of the upstream resource. +pub const UPSTREAM_TYPE_ID: &str = "gts.cf.core.oagw.upstream.v1~"; + +/// Type-schema identifier of the route resource. +pub const ROUTE_TYPE_ID: &str = "gts.cf.core.oagw.route.v1~"; + +/// Type-schema identifier of the auth-plugin family. +pub const AUTH_PLUGIN_TYPE_ID: &str = "gts.cf.core.oagw.auth_plugin.v1~"; + +/// Type-schema identifier of the guard-plugin family. +pub const GUARD_PLUGIN_TYPE_ID: &str = "gts.cf.core.oagw.guard_plugin.v1~"; + +/// Type-schema identifier of the transform-plugin family. +pub const TRANSFORM_PLUGIN_TYPE_ID: &str = "gts.cf.core.oagw.transform_plugin.v1~"; + +/// Identifier of the built-in API-key authentication plugin. +pub const AUTH_PLUGIN_APIKEY: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1"; + +/// Identifier of the built-in `OAuth2` client-credentials authentication plugin. +pub const AUTH_PLUGIN_OAUTH2_CLIENT_CRED: &str = + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1"; + +/// Identifier of the built-in no-op authentication plugin. +pub const AUTH_PLUGIN_NOOP: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1"; + +/// Identifier of the `OAuth2` client-credentials plugin using `Basic` client auth. +pub const AUTH_PLUGIN_OAUTH2_CLIENT_CRED_BASIC: &str = + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1"; + +/// Identifier of the built-in required-headers guard plugin. +pub const REQUIRED_HEADERS_GUARD_PLUGIN_ID: &str = + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"; + +/// Identifier of the built-in request-id transform plugin. +pub const REQUEST_ID_TRANSFORM_PLUGIN_ID: &str = + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"; + +/// The `basic` auth identifier: cataloged, never resolvable. +pub const CATALOG_ONLY_BASIC: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.basic.v1"; + +/// The `bearer` auth identifier: cataloged, never resolvable. +pub const CATALOG_ONLY_BEARER: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.bearer.v1"; + +/// The `timeout` guard identifier: cataloged, never bindable. +pub const CATALOG_ONLY_TIMEOUT: &str = "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.timeout.v1"; + +/// The `cors` guard identifier: cataloged, never bindable. +pub const CATALOG_ONLY_CORS: &str = "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1"; + +/// The `logging` transform identifier: cataloged, never resolvable. +pub const CATALOG_ONLY_LOGGING: &str = + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.logging.v1"; + +/// The `metrics` transform identifier: cataloged, never resolvable. +pub const CATALOG_ONLY_METRICS: &str = "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.metrics.v1"; + +/// Plugin identifiers present in the GTS type catalog but with no runtime +/// implementation: resolving one must fail closed. +pub const CATALOG_ONLY_PLUGIN_IDS: [&str; 6] = [ + CATALOG_ONLY_BASIC, + CATALOG_ONLY_BEARER, + CATALOG_ONLY_TIMEOUT, + CATALOG_ONLY_CORS, + CATALOG_ONLY_LOGGING, + CATALOG_ONLY_METRICS, +]; + +/// Returns `true` when `id` is registered in the catalog but has no runtime +/// plugin implementation. +#[must_use] +pub fn is_catalog_only_plugin(id: &str) -> bool { + CATALOG_ONLY_PLUGIN_IDS.contains(&id) +} + +/// The UUID a plugin identifier is backed by, if it has one. +/// +/// A custom plugin's instance part — everything after `~` — is the UUID the +/// control plane minted for it; a named plugin's instance part is a name. +#[must_use] +pub fn plugin_uuid_of(reference: &str) -> Option { + let instance = reference.split('~').next_back()?; + Uuid::parse_str(instance).ok() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn error_ids_are_canonical() { + assert_eq!( + error_id("route", "not_found"), + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); + } + + #[test] + fn basic_and_bearer_are_catalog_only() { + assert!(is_catalog_only_plugin( + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.basic.v1" + )); + assert!(!is_catalog_only_plugin(AUTH_PLUGIN_APIKEY)); + } +} 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..0c4327e --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/mod.rs @@ -0,0 +1,9 @@ +//! Domain layer: pure business logic, no `axum`/`hyper`/network types. + +pub mod alias; +pub mod dto; +pub mod error; +pub mod gts_helpers; +pub mod plugin; +pub mod repo; +pub mod services; 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..44fdf17 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/plugin/mod.rs @@ -0,0 +1,166 @@ +//! Plugin traits. +//! +//! Three families, executed in the order authentication → guards → +//! transform(request) → upstream call → transform(response/error). A plugin +//! sees the resolved configuration and mutates headers only; it never owns the +//! body or the connection. + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::domain::error::DomainError; + +/// Execution context handed to every plugin. +#[derive(Debug, Clone)] +pub struct PluginContext { + /// Owning tenant. + pub tenant_id: Uuid, + /// Authenticated subject, or nil for an anonymous call. + pub subject_id: Uuid, + /// Resolved upstream. + pub upstream_id: Uuid, + /// Matched route, when one was matched. + pub route_id: Option, + /// The alias the request addressed. + pub alias: String, + /// The bearer token on the inbound request, if any. + pub bearer_token: Option, + /// The correlation id the request phase settled on, propagated or minted. + /// + /// The response phase echoes it, so the caller can correlate a reply with + /// the request it belongs to without inspecting the upstream's headers. + pub request_id: Option, +} + +impl PluginContext { + /// Cache namespace for per-tenant, per-subject artefacts such as `OAuth2` + /// tokens. + #[must_use] + pub fn cache_scope(&self) -> String { + format!("{}|{}", self.tenant_id, self.subject_id) + } +} + +/// Resolves credential material at request time, by reference. +#[async_trait] +pub trait SecretResolver: Send + Sync { + /// Resolves a secret reference to its value. + /// + /// # Errors + /// Returns [`DomainError::SecretNotFound`] when the store cannot resolve + /// the reference, so callers can distinguish a missing secret from an + /// unusable one. + async fn resolve( + &self, + context: &PluginContext, + reference: &str, + ) -> Result, DomainError>; +} + +/// A no-op resolver used when no credential store is wired in. +#[derive(Debug, Default)] +pub struct NullSecretResolver; + +#[async_trait] +impl SecretResolver for NullSecretResolver { + async fn resolve( + &self, + _context: &PluginContext, + _reference: &str, + ) -> Result, DomainError> { + Ok(None) + } +} + +/// Injects credentials into an outbound request. +#[async_trait] +pub trait AuthPlugin: Send + Sync { + /// The plugin's GTS identifier. + fn id(&self) -> &'static str; + + /// Applies credentials to `headers`. + /// + /// # Errors + /// Returns [`DomainError::AuthenticationFailed`] when credentials cannot be + /// resolved or the configuration is unusable. + async fn apply( + &self, + context: &PluginContext, + config: &serde_json::Value, + headers: &mut http::HeaderMap, + query: &mut Vec<(String, String)>, + ) -> Result<(), DomainError>; +} + +/// Checks a request or response against a policy without transforming it. +#[async_trait] +pub trait GuardPlugin: Send + Sync { + /// The plugin's GTS identifier. + fn id(&self) -> &'static str; + + /// Validates the inbound request. + /// + /// Guards judge rather than rewrite, so this sees the caller's headers as + /// they arrived, before the header rules select what the upstream receives. + /// + /// # Errors + /// Returns a `400`-family [`DomainError`] when the request must be refused. + async fn guard_request( + &self, + context: &PluginContext, + config: &serde_json::Value, + headers: &http::HeaderMap, + ) -> Result<(), DomainError>; + + /// Validates the upstream response. + /// + /// Guards see the response as the upstream sent it, before the gateway's + /// own header rules run, so a header the gateway adds for the caller does + /// not satisfy a check on the upstream's behaviour. + /// + /// # Errors + /// Returns a `502`-family [`DomainError`] when the response must be + /// replaced by a gateway error. + async fn guard_response( + &self, + context: &PluginContext, + config: &serde_json::Value, + status: http::StatusCode, + headers: &mut http::HeaderMap, + ) -> Result<(), DomainError>; +} + +/// Rewrites a request or a response. +#[async_trait] +pub trait TransformPlugin: Send + Sync { + /// The plugin's GTS identifier. + fn id(&self) -> &'static str; + + /// Rewrites the outbound request. + /// + /// `inbound` is what the caller sent, untouched by the header rules, so a + /// transform that propagates a caller-supplied value can still read it once + /// the rules have dropped it from what the upstream receives. + /// + /// # Errors + /// Returns a [`DomainError`] when the request cannot be transformed. + async fn transform_request( + &self, + context: &PluginContext, + config: &serde_json::Value, + inbound: &http::HeaderMap, + headers: &mut http::HeaderMap, + ) -> Result<(), DomainError>; + + /// Rewrites the response seen by the client. + /// + /// # Errors + /// Returns a [`DomainError`] when the response cannot be transformed. + async fn transform_response( + &self, + context: &PluginContext, + config: &serde_json::Value, + status: http::StatusCode, + headers: &mut http::HeaderMap, + ) -> Result<(), DomainError>; +} 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..55b3ba0 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/repo.rs @@ -0,0 +1,144 @@ +//! Repository traits for the control plane's persisted resources. +//! +//! The domain depends only on these traits; `infra::storage` implements them. +//! The graded configuration has no database block, so the only implementation +//! is in-memory, but the seam is what makes a `SeaORM` implementation additive. + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::domain::dto::{Plugin, Route, Upstream}; +use crate::domain::error::DomainError; + +/// A storage backend failure. +#[derive(Debug, thiserror::Error)] +pub enum RepoError { + /// The backend refused the write. + #[error("storage failure: {0}")] + Backend(String), +} + +impl From for DomainError { + fn from(error: RepoError) -> Self { + Self::DownstreamError(error.to_string()) + } +} + +/// Outcome of a unique-key write. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WriteOutcome { + /// The resource was created. + Created, + /// A resource with the same key already exists. + KeyExists, +} + +/// Upstream persistence. +#[async_trait] +pub trait UpstreamRepository: Send + Sync { + /// Inserts an upstream; fails with [`WriteOutcome::KeyExists`] when the + /// alias is already taken in the tenant. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the write. + async fn insert(&self, upstream: Upstream) -> Result; + + /// Replaces a stored upstream by `(tenant_id, id)`. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the write. + async fn update(&self, upstream: Upstream) -> Result<(), RepoError>; + + /// Deletes an upstream, reporting whether it existed. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the write. + async fn delete(&self, tenant_id: Uuid, id: Uuid) -> Result; + + /// Fetches an upstream by `(tenant_id, id)`. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the read. + async fn find_by_id(&self, tenant_id: Uuid, id: Uuid) -> Result, RepoError>; + + /// Fetches an upstream by `(tenant_id, alias)`, case-insensitively. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the read. + async fn find_by_alias(&self, tenant_id: Uuid, alias: &str) + -> Result, RepoError>; + + /// Lists every upstream owned by the tenant. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the read. + async fn list(&self, tenant_id: Uuid) -> Result, RepoError>; +} + +/// Route persistence. +#[async_trait] +pub trait RouteRepository: Send + Sync { + /// Inserts a route. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the write. + async fn insert(&self, route: Route) -> Result<(), RepoError>; + + /// Replaces a stored route by `(tenant_id, id)`. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the write. + async fn update(&self, route: Route) -> Result<(), RepoError>; + + /// Deletes a route, reporting whether it existed. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the write. + async fn delete(&self, tenant_id: Uuid, id: Uuid) -> Result; + + /// Fetches a route by `(tenant_id, id)`. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the read. + async fn find_by_id(&self, tenant_id: Uuid, id: Uuid) -> Result, RepoError>; + + /// Lists every route owned by the tenant. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the read. + async fn list(&self, tenant_id: Uuid) -> Result, RepoError>; + + /// Lists every route of an upstream, across all tenants the caller can see. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the read. + async fn list_by_upstream(&self, upstream_id: Uuid) -> Result, RepoError>; +} + +/// Plugin persistence. +#[async_trait] +pub trait PluginRepository: Send + Sync { + /// Inserts a plugin. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the write. + async fn insert(&self, plugin: Plugin) -> Result<(), RepoError>; + + /// Deletes a plugin, reporting whether it existed. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the write. + async fn delete(&self, tenant_id: Uuid, id: Uuid) -> Result; + + /// Fetches a plugin by `(tenant_id, id)`. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the read. + async fn find_by_id(&self, tenant_id: Uuid, id: Uuid) -> Result, RepoError>; + + /// Lists every plugin owned by the tenant. + /// + /// # Errors + /// Returns [`RepoError`] when the backend cannot complete the read. + async fn list(&self, tenant_id: Uuid) -> Result, RepoError>; +} 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..dd88c29 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/services/management.rs @@ -0,0 +1,756 @@ +//! Control-plane service: upstream, route and plugin lifecycle. +//! +//! This is the domain's management surface — validation, alias derivation, +//! match-rule uniqueness and OData-ish listing — independent of HTTP. + +use std::sync::Arc; + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::domain::dto::{ + Endpoint, GrpcMatch, MatchRule, Plugin, Route, ServerConfig, Upstream, +}; +use crate::domain::error::DomainError; +use crate::domain::repo::{PluginRepository, RouteRepository, UpstreamRepository}; + +/// Tag pattern every `tags` entry must match. +const TAG_PATTERN: &str = "^[a-z0-9_-]+$"; + +/// Writes that must clear the data plane's L1 cache. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Invalidation { + /// An upstream changed. + Upstream, + /// A route changed. + Route, +} + +/// Control-plane operations. +#[async_trait] +pub trait ControlPlane: Send + Sync { + /// Creates an upstream. + /// + /// # Errors + /// Returns [`DomainError`] when validation fails or the alias is taken. + async fn create_upstream( + &self, + tenant_id: Uuid, + upstream: Upstream, + ) -> Result; + + /// Replaces an upstream. + /// + /// # Errors + /// Returns [`DomainError`] when validation fails or the resource is absent. + async fn replace_upstream( + &self, + tenant_id: Uuid, + id: Uuid, + upstream: Upstream, + ) -> Result; + + /// Deletes an upstream and every route that references it. + /// + /// # Errors + /// Returns [`DomainError`] when the resource is absent. + async fn delete_upstream(&self, tenant_id: Uuid, id: Uuid) -> Result<(), DomainError>; + + /// Reads an upstream. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when absent. + async fn get_upstream(&self, tenant_id: Uuid, id: Uuid) -> Result; + + /// Fetches an upstream by alias. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when absent. + async fn get_upstream_by_alias( + &self, + tenant_id: Uuid, + alias: &str, + ) -> Result; + + /// Lists upstreams. + /// + /// # Errors + /// Returns [`DomainError`] when the store fails. + async fn list_upstreams(&self, tenant_id: Uuid) -> Result, DomainError>; + + /// Creates a route. + /// + /// # Errors + /// Returns [`DomainError`] when validation fails. + async fn create_route(&self, tenant_id: Uuid, route: Route) -> Result; + + /// Replaces a route; `upstream_id` is immutable. + /// + /// # Errors + /// Returns [`DomainError`] when validation fails. + async fn replace_route( + &self, + tenant_id: Uuid, + id: Uuid, + route: Route, + ) -> Result; + + /// Deletes a route. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when absent. + async fn delete_route(&self, tenant_id: Uuid, id: Uuid) -> Result<(), DomainError>; + + /// Reads a route. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when absent. + async fn get_route(&self, tenant_id: Uuid, id: Uuid) -> Result; + + /// Lists routes. + /// + /// # Errors + /// Returns [`DomainError`] when the store fails. + async fn list_routes(&self, tenant_id: Uuid) -> Result, DomainError>; + + /// Creates a plugin. + /// + /// # Errors + /// Returns [`DomainError`] when validation fails. + async fn create_plugin(&self, tenant_id: Uuid, plugin: Plugin) -> Result; + + /// Deletes a plugin, refusing while it is still referenced. + /// + /// # Errors + /// Returns [`DomainError::PluginInUse`] when referenced. + async fn delete_plugin(&self, tenant_id: Uuid, id: Uuid) -> Result<(), DomainError>; + + /// Reads a plugin. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when absent. + async fn get_plugin(&self, tenant_id: Uuid, id: Uuid) -> Result; + + /// Lists plugins. + /// + /// # Errors + /// Returns [`DomainError`] when the store fails. + async fn list_plugins(&self, tenant_id: Uuid) -> Result, DomainError>; +} + +/// The in-process control plane. +pub struct ControlPlaneService { + upstreams: Arc, + routes: Arc, + plugins: Arc, +} + +impl ControlPlaneService { + /// Builds a control plane over the given repositories. + #[must_use] + pub fn new( + upstreams: Arc, + routes: Arc, + plugins: Arc, + ) -> Self { + Self { + upstreams, + routes, + plugins, + } + } + + /// The repositories behind this service, for the data plane's resolution + /// walk. + /// + // The tuple is spelled out because it is this crate's public API shape; + // introducing a type alias would add a new public name. + #[allow(clippy::type_complexity)] + #[must_use] + pub fn repositories( + &self, + ) -> ( + Arc, + Arc, + Arc, + ) { + ( + Arc::clone(&self.upstreams), + Arc::clone(&self.routes), + Arc::clone(&self.plugins), + ) + } + + /// Validates an upstream document. + /// + /// # Errors + /// Returns [`DomainError::Validation`] when a rule is violated. + /// + // Kept as a method so callers keep using `service.validate_upstream(..)`; + // it reads no state from `self`. + #[allow(clippy::unused_self)] + pub fn validate_upstream(&self, upstream: &Upstream) -> Result<(), DomainError> { + let endpoints = &upstream.server.endpoints; + if endpoints.is_empty() { + return Err(DomainError::Validation( + "server.endpoints must contain at least one endpoint".into(), + )); + } + for endpoint in endpoints { + validate_endpoint(endpoint)?; + } + if endpoints.len() > 1 { + let first = &endpoints[0]; + if endpoints + .iter() + .any(|endpoint| endpoint.scheme != first.scheme || endpoint.port != first.port) + { + return Err(DomainError::Validation( + "all endpoints in one pool must share scheme, protocol and port".into(), + )); + } + } + for tag in &upstream.tags { + if !is_valid_tag(tag) { + return Err(DomainError::Validation(format!( + "tag `{tag}` must match {TAG_PATTERN}" + ))); + } + } + if let Some(cors) = &upstream.cors { + validate_cors(cors)?; + } + if let Some(rate_limit) = &upstream.rate_limit { + validate_rate_limit(rate_limit)?; + } + if let Some(auth) = &upstream.auth { + validate_auth_plugin_reference(&auth.auth_type)?; + } + if let Some(plugins) = &upstream.plugins { + for reference in &plugins.items { + validate_bindable_plugin_reference(reference)?; + } + } + Ok(()) + } + + /// Validates a route document. + /// + /// # Errors + /// Returns [`DomainError::Validation`] when a rule is violated. + /// + // Kept as a method so callers keep using `service.validate_route(..)`; + // it reads no state from `self`. + #[allow(clippy::unused_self)] + pub fn validate_route(&self, route: &Route) -> Result<(), DomainError> { + match &route.match_rule { + crate::domain::dto::MatchRule::Http(http) => { + if http.methods.is_empty() { + return Err(DomainError::Validation( + "match.http.methods must not be empty".into(), + )); + } + if http.path.is_empty() { + return Err(DomainError::Validation("match.http.path is required".into())); + } + if !http.path.starts_with('/') { + return Err(DomainError::Validation( + "match.http.path must be an absolute path".into(), + )); + } + } + crate::domain::dto::MatchRule::Grpc(GrpcMatch { service, method }) => { + if service.is_empty() || method.is_empty() { + return Err(DomainError::Validation( + "match.grpc requires both service and method".into(), + )); + } + } + } + for tag in &route.tags { + if !is_valid_tag(tag) { + return Err(DomainError::Validation(format!( + "tag `{tag}` must match {TAG_PATTERN}" + ))); + } + } + if let Some(cors) = &route.cors { + validate_cors(cors)?; + } + if let Some(rate_limit) = &route.rate_limit { + validate_rate_limit(rate_limit)?; + } + if let Some(plugins) = &route.plugins { + for reference in &plugins.items { + validate_bindable_plugin_reference(reference)?; + } + } + Ok(()) + } + + /// Resolves every UUID-backed entry in a plugin chain against the plugin + /// repository. + /// + /// A named identifier is checked by [`validate_bindable_plugin_reference`] + /// alone; a UUID instance names a custom plugin stored in `oagw_plugin`, so + /// the reference only binds if a row is actually there. + /// + /// # Errors + /// Returns [`DomainError::Validation`] when a UUID instance names no plugin + /// this tenant owns. + async fn ensure_plugins_resolvable( + &self, + tenant_id: Uuid, + plugins: Option<&crate::domain::dto::PluginsConfig>, + ) -> Result<(), DomainError> { + let Some(plugins) = plugins else { + return Ok(()); + }; + for reference in &plugins.items { + let Some(id) = crate::domain::gts_helpers::plugin_uuid_of(reference) else { + continue; + }; + if self + .plugins + .find_by_id(tenant_id, id) + .await + .map_err(DomainError::from)? + .is_none() + { + return Err(DomainError::Validation(format!( + "plugin reference `{reference}` does not name a plugin in this tenant" + ))); + } + } + Ok(()) + } + + /// Rejects a match rule that collides with an existing one under the same + /// upstream. + /// + /// # Errors + /// Returns [`DomainError::Conflict`] on a duplicate. + pub async fn ensure_match_unique( + &self, + tenant_id: Uuid, + upstream_id: Uuid, + candidate: &MatchRule, + ignore_route_id: Option, + ) -> Result<(), DomainError> { + let existing = self + .routes + .list_by_upstream(upstream_id) + .await + .map_err(DomainError::from)?; + for route in existing { + if route.tenant_id != tenant_id || Some(route.id) == ignore_route_id { + continue; + } + if match_rules_conflict(&route.match_rule, candidate) { + return Err(DomainError::Conflict(format!( + "a route with the same path, priority and methods already exists ({})", + route.id + ))); + } + } + Ok(()) + } +} + +#[async_trait] +impl ControlPlane for ControlPlaneService { + async fn create_upstream( + &self, + tenant_id: Uuid, + mut upstream: Upstream, + ) -> Result { + self.validate_upstream(&upstream)?; + self.ensure_plugins_resolvable(tenant_id, upstream.plugins.as_ref()).await?; + upstream.tenant_id = tenant_id; + let provided = if upstream.alias.trim().is_empty() { + None + } else { + Some(upstream.alias.as_str()) + }; + let alias = crate::domain::alias::enforce_alias_create(&upstream.server.endpoints, provided)?; + upstream.alias = alias; + if self + .upstreams + .find_by_alias(tenant_id, &upstream.alias) + .await + .map_err(DomainError::from)? + .is_some() + { + return Err(DomainError::Conflict(format!( + "an upstream with alias `{}` already exists in this tenant", + upstream.alias + ))); + } + let outcome = self + .upstreams + .insert(upstream.clone()) + .await + .map_err(DomainError::from)?; + if outcome == crate::domain::repo::WriteOutcome::KeyExists { + return Err(DomainError::Conflict(format!( + "an upstream with alias `{}` already exists in this tenant", + upstream.alias + ))); + } + Ok(upstream) + } + + async fn replace_upstream( + &self, + tenant_id: Uuid, + id: Uuid, + mut upstream: Upstream, + ) -> Result { + self.validate_upstream(&upstream)?; + self.ensure_plugins_resolvable(tenant_id, upstream.plugins.as_ref()).await?; + let existing = self + .upstreams + .find_by_id(tenant_id, id) + .await + .map_err(DomainError::from)? + .ok_or_else(|| DomainError::NotFound("upstream not found".into()))?; + let provided = if upstream.alias.trim().is_empty() { + None + } else { + Some(upstream.alias.as_str()) + }; + let alias = crate::domain::alias::enforce_alias_update( + &existing.server.endpoints, + &existing.alias, + &upstream.server.endpoints, + provided, + )?; + upstream.id = existing.id; + upstream.tenant_id = existing.tenant_id; + upstream.alias = alias; + upstream.created_at = existing.created_at.clone(); + self.upstreams + .update(upstream.clone()) + .await + .map_err(DomainError::from)?; + Ok(upstream) + } + + async fn delete_upstream(&self, tenant_id: Uuid, id: Uuid) -> Result<(), DomainError> { + let deleted = self + .upstreams + .delete(tenant_id, id) + .await + .map_err(DomainError::from)?; + if !deleted { + return Err(DomainError::NotFound("upstream not found".into())); + } + for route in self.routes.list_by_upstream(id).await.map_err(DomainError::from)? { + if route.tenant_id == tenant_id { + // Best-effort cascade: a stray route that cannot be removed + // must not fail the upstream deletion, so its result is + // dropped here. + drop(self.routes.delete(tenant_id, route.id).await); + } + } + Ok(()) + } + + async fn get_upstream(&self, tenant_id: Uuid, id: Uuid) -> Result { + self.upstreams + .find_by_id(tenant_id, id) + .await + .map_err(DomainError::from)? + .ok_or_else(|| DomainError::NotFound("upstream not found".into())) + } + + async fn get_upstream_by_alias( + &self, + tenant_id: Uuid, + alias: &str, + ) -> Result { + self.upstreams + .find_by_alias(tenant_id, alias) + .await + .map_err(DomainError::from)? + .ok_or_else(|| DomainError::NotFound(format!("no upstream for alias `{alias}`"))) + } + + async fn list_upstreams(&self, tenant_id: Uuid) -> Result, DomainError> { + self.upstreams.list(tenant_id).await.map_err(DomainError::from) + } + + async fn create_route(&self, tenant_id: Uuid, mut route: Route) -> Result { + self.validate_route(&route)?; + self.ensure_plugins_resolvable(tenant_id, route.plugins.as_ref()).await?; + let upstream = self + .upstreams + .find_by_id(tenant_id, route.upstream_id) + .await + .map_err(DomainError::from)? + .ok_or_else(|| { + DomainError::Validation("upstream_id does not name an upstream in this tenant".into()) + })?; + self.ensure_match_unique(tenant_id, upstream.id, &route.match_rule, None) + .await?; + route.tenant_id = tenant_id; + route.id = Uuid::new_v4(); + self.routes.insert(route.clone()).await.map_err(DomainError::from)?; + Ok(route) + } + + async fn replace_route( + &self, + tenant_id: Uuid, + id: Uuid, + mut route: Route, + ) -> Result { + self.validate_route(&route)?; + self.ensure_plugins_resolvable(tenant_id, route.plugins.as_ref()).await?; + let existing = self + .routes + .find_by_id(tenant_id, id) + .await + .map_err(DomainError::from)? + .ok_or_else(|| DomainError::NotFound("route not found".into()))?; + self.ensure_match_unique(tenant_id, existing.upstream_id, &route.match_rule, Some(existing.id)) + .await?; + route.id = existing.id; + route.tenant_id = existing.tenant_id; + route.upstream_id = existing.upstream_id; + route.created_at = existing.created_at.clone(); + self.routes.update(route.clone()).await.map_err(DomainError::from)?; + Ok(route) + } + + async fn delete_route(&self, tenant_id: Uuid, id: Uuid) -> Result<(), DomainError> { + let deleted = self + .routes + .delete(tenant_id, id) + .await + .map_err(DomainError::from)?; + if !deleted { + return Err(DomainError::NotFound("route not found".into())); + } + Ok(()) + } + + async fn get_route(&self, tenant_id: Uuid, id: Uuid) -> Result { + self.routes + .find_by_id(tenant_id, id) + .await + .map_err(DomainError::from)? + .ok_or_else(|| DomainError::NotFound("route not found".into())) + } + + async fn list_routes(&self, tenant_id: Uuid) -> Result, DomainError> { + self.routes.list(tenant_id).await.map_err(DomainError::from) + } + + async fn create_plugin(&self, tenant_id: Uuid, mut plugin: Plugin) -> Result { + if plugin.name.trim().is_empty() { + return Err(DomainError::Validation("plugin name is required".into())); + } + plugin.tenant_id = tenant_id; + plugin.id = Uuid::new_v4(); + self.plugins.insert(plugin.clone()).await.map_err(DomainError::from)?; + Ok(plugin) + } + + async fn delete_plugin(&self, tenant_id: Uuid, id: Uuid) -> Result<(), DomainError> { + self.plugins + .find_by_id(tenant_id, id) + .await + .map_err(DomainError::from)? + .ok_or_else(|| DomainError::NotFound("plugin not found".into()))?; + let referenced = |items: &[String]| { + items + .iter() + // A custom plugin is bound by its GTS identifier, whose + // instance part is the id minted here; a bare id also counts. + .any(|item| crate::domain::gts_helpers::plugin_uuid_of(item) == Some(id)) + }; + for upstream in self.upstreams.list(tenant_id).await.map_err(DomainError::from)? { + if upstream + .plugins + .as_ref() + .is_some_and(|plugins| referenced(&plugins.items)) + { + return Err(DomainError::PluginInUse( + "plugin is referenced by an upstream".into(), + )); + } + } + for route in self.routes.list(tenant_id).await.map_err(DomainError::from)? { + if route + .plugins + .as_ref() + .is_some_and(|plugins| referenced(&plugins.items)) + { + return Err(DomainError::PluginInUse( + "plugin is referenced by a route".into(), + )); + } + } + self.plugins.delete(tenant_id, id).await.map_err(DomainError::from)?; + Ok(()) + } + + async fn get_plugin(&self, tenant_id: Uuid, id: Uuid) -> Result { + self.plugins + .find_by_id(tenant_id, id) + .await + .map_err(DomainError::from)? + .ok_or_else(|| DomainError::NotFound("plugin not found".into())) + } + + async fn list_plugins(&self, tenant_id: Uuid) -> Result, DomainError> { + self.plugins.list(tenant_id).await.map_err(DomainError::from) + } +} + +/// Validates the `auth.type` an upstream declares. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the identifier is not a resolvable +/// auth plugin, including the catalog-only `basic` and `bearer` identifiers. +pub fn validate_auth_plugin_reference(identifier: &str) -> Result<(), DomainError> { + if crate::domain::gts_helpers::is_catalog_only_plugin(identifier) { + return Err(DomainError::Validation(format!( + "auth plugin `{identifier}` is cataloged but has no runtime implementation" + ))); + } + let known = [ + crate::domain::gts_helpers::AUTH_PLUGIN_NOOP, + crate::domain::gts_helpers::AUTH_PLUGIN_APIKEY, + crate::domain::gts_helpers::AUTH_PLUGIN_OAUTH2_CLIENT_CRED, + crate::domain::gts_helpers::AUTH_PLUGIN_OAUTH2_CLIENT_CRED_BASIC, + ]; + if known.contains(&identifier) { + return Ok(()); + } + // A custom plugin is referenced by the UUID the CP minted for it, either + // bare or as the instance part of a full GTS identifier. + if crate::domain::gts_helpers::plugin_uuid_of(identifier).is_some() { + return Ok(()); + } + Err(DomainError::Validation(format!( + "`{identifier}` is not a known auth plugin identifier" + ))) +} + +/// Validates a `plugins.items[]` reference. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the reference names a catalog-only +/// plugin or is not a resolvable identifier. +pub fn validate_bindable_plugin_reference(reference: &str) -> Result<(), DomainError> { + if crate::domain::gts_helpers::is_catalog_only_plugin(reference) { + return Err(DomainError::Validation(format!( + "plugin `{reference}` is core data-plane logic and cannot be bound through plugins.items" + ))); + } + if reference == crate::domain::gts_helpers::REQUIRED_HEADERS_GUARD_PLUGIN_ID + || reference == crate::domain::gts_helpers::REQUEST_ID_TRANSFORM_PLUGIN_ID + { + return Ok(()); + } + // A custom plugin's instance part is the UUID the CP minted for it. + if crate::domain::gts_helpers::plugin_uuid_of(reference).is_some() { + return Ok(()); + } + Err(DomainError::Validation(format!( + "plugin reference `{reference}` does not name a bindable plugin" + ))) +} + +/// Whether a tag matches `^[a-z0-9_-]+$`. +#[must_use] +pub fn is_valid_tag(tag: &str) -> bool { + !tag.is_empty() + && tag + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'_' || byte == b'-') +} + +fn validate_endpoint(endpoint: &Endpoint) -> Result<(), DomainError> { + if crate::domain::alias::is_ip_literal(&endpoint.host) { + return Ok(()); + } + if !crate::domain::alias::is_valid_hostname(&endpoint.host) { + return Err(DomainError::Validation(format!( + "host `{}` is not a valid RFC 1123 hostname or IP literal", + endpoint.host + ))); + } + Ok(()) +} + +/// Validates the CORS block: credentials and a wildcard origin are mutually +/// exclusive. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the block is contradictory. +pub fn validate_cors(cors: &crate::domain::dto::CorsConfig) -> Result<(), DomainError> { + if cors.allow_credentials && cors.allowed_origins.iter().any(|origin| origin == "*") { + return Err(DomainError::Validation( + "cors.allow_credentials cannot be combined with a wildcard origin".into(), + )); + } + Ok(()) +} + +/// Validates the rate-limit block: `sustained.rate` is required. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the block is incomplete. +pub fn validate_rate_limit(rate_limit: &crate::domain::dto::RateLimitConfig) -> Result<(), DomainError> { + if rate_limit.sustained.rate == 0 { + return Err(DomainError::Validation( + "rate_limit.sustained.rate is required and must be at least 1".into(), + )); + } + if rate_limit.capacity() == 0 { + return Err(DomainError::Validation("rate_limit.burst.capacity must be at least 1".into())); + } + Ok(()) +} + +/// Whether two match rules claim the same path, priority and methods. +#[must_use] +pub fn match_rules_conflict(left: &MatchRule, right: &MatchRule) -> bool { + match (left, right) { + (MatchRule::Http(left), MatchRule::Http(right)) => { + let same_path = left.path == right.path; + let same_priority = left.path.len() == right.path.len(); + let overlapping = left.methods.iter().any(|method| right.methods.contains(method)); + same_path && same_priority && overlapping + } + (MatchRule::Grpc(left), MatchRule::Grpc(right)) => { + left.service == right.service && left.method == right.method + } + _ => false, + } +} + +/// Validates a `ServerConfig`, shared by the wire DTO layer. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the pool is empty or heterogeneous. +pub fn validate_server(server: &ServerConfig) -> Result<(), DomainError> { + if server.endpoints.is_empty() { + return Err(DomainError::Validation( + "server.endpoints must contain at least one endpoint".into(), + )); + } + let first = &server.endpoints[0]; + for endpoint in &server.endpoints { + if endpoint.scheme != first.scheme || endpoint.port != first.port { + return Err(DomainError::Validation( + "all endpoints in one pool must share scheme, protocol and port".into(), + )); + } + validate_endpoint(endpoint)?; + } + Ok(()) +} 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..7e634de --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/services/mod.rs @@ -0,0 +1,4 @@ +//! Domain services: the control-plane service and the resolution engine. + +pub mod management; +pub mod resolution; diff --git a/gears/system/oagw/oagw/src/domain/services/resolution.rs b/gears/system/oagw/oagw/src/domain/services/resolution.rs new file mode 100644 index 0000000..5c82bbb --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/services/resolution.rs @@ -0,0 +1,350 @@ +//! Tenant-hierarchy walk and effective-config merge. +//! +//! Alias resolution walks descendant → root and the closest match wins; an +//! ancestor's *enforced* limits still apply across shadowing. Merge rules: +//! `min()` for rate limits, union for tags and CORS origins, concatenation for +//! plugin chains. + +use uuid::Uuid; + +use crate::domain::dto::{ + CorsConfig, HeadersConfig, PluginsConfig, RateLimitConfig, Route, Sharing, Upstream, +}; + +/// The upstream a request resolved to, with everything the chain contributed. +#[derive(Debug, Clone)] +pub struct ResolvedUpstream { + /// The upstream that matched. + pub upstream: Upstream, + /// The tenant that owns it. + pub tenant_id: Uuid, + /// Every tenant id in the walk, closest first. + pub chain: Vec, +} + +/// Ancestor's configuration, in `descendant → root` order. +#[derive(Debug, Clone, Default)] +pub struct ChainConfig { + /// Upstreams seen during the walk, closest first. + pub upstreams: Vec, + /// Routes seen for the resolved upstream, closest first. + pub routes: Vec, +} + +impl ChainConfig { + /// The effective upstream: the closest match in the walk. + #[must_use] + pub fn effective_upstream(&self) -> Option<&Upstream> { + self.upstreams.first() + } + + /// Whether any ancestor disabled the effective upstream. + #[must_use] + pub fn is_disabled(&self) -> bool { + self.upstreams.iter().any(|upstream| !upstream.enabled) + } +} + +/// Merges two header-rule blocks, `override` winning on conflicts. +#[must_use] +pub fn merge_headers(base: Option<&HeadersConfig>, over: Option<&HeadersConfig>) -> HeadersConfig { + match (base, over) { + (None, None) => HeadersConfig::default(), + (Some(only), None) | (None, Some(only)) => only.clone(), + (Some(base), Some(over)) => { + let mut merged = HeadersConfig { + request: None, + response: None, + }; + merged.request = match (&base.request, &over.request) { + (None, None) => None, + (Some(only), None) | (None, Some(only)) => Some(only.clone()), + (Some(base), Some(over)) => Some(crate::domain::dto::RequestHeaderRules { + set: merge_maps(&base.set, &over.set), + add: merge_maps(&base.add, &over.add), + remove: { + let mut items = base.remove.clone(); + for name in &over.remove { + if !items.contains(name) { + items.push(name.clone()); + } + } + items + }, + passthrough: over.passthrough, + passthrough_allowlist: { + let mut items = base.passthrough_allowlist.clone(); + for name in &over.passthrough_allowlist { + if !items.contains(name) { + items.push(name.clone()); + } + } + items + }, + }), + }; + merged.response = match (&base.response, &over.response) { + (None, None) => None, + (Some(only), None) | (None, Some(only)) => Some(only.clone()), + (Some(base), Some(over)) => Some(crate::domain::dto::ResponseHeaderRules { + set: merge_maps(&base.set, &over.set), + add: merge_maps(&base.add, &over.add), + remove: { + let mut items = base.remove.clone(); + for name in &over.remove { + if !items.contains(name) { + items.push(name.clone()); + } + } + items + }, + }), + }; + merged + } + } +} + +fn merge_maps( + base: &std::collections::BTreeMap, + over: &std::collections::BTreeMap, +) -> std::collections::BTreeMap { + let mut merged = base.clone(); + for (key, value) in over { + merged.insert(key.clone(), value.clone()); + } + merged +} + +/// Unions two tag sets, preserving order and dropping duplicates. +#[must_use] +pub fn merge_tags(base: &[String], over: &[String]) -> Vec { + let mut merged = base.to_vec(); + for tag in over { + if !merged.contains(tag) { + merged.push(tag.clone()); + } + } + merged +} + +/// Concatenates two plugin chains, ancestor first. +#[must_use] +pub fn merge_plugins(base: Option<&PluginsConfig>, over: Option<&PluginsConfig>) -> PluginsConfig { + match (base, over) { + (None, None) => PluginsConfig::default(), + (Some(only), None) | (None, Some(only)) => only.clone(), + (Some(base), Some(over)) => PluginsConfig { + sharing: over.sharing, + items: { + let mut items = base.items.clone(); + for item in &over.items { + if !items.contains(item) { + items.push(item.clone()); + } + } + items + }, + config: merge_plugin_config(&base.config, &over.config), + }, + } +} + +fn merge_plugin_config( + base: &std::collections::BTreeMap, + over: &std::collections::BTreeMap, +) -> std::collections::BTreeMap { + let mut merged = base.clone(); + for (key, value) in over { + merged.insert(key.clone(), value.clone()); + } + merged +} + +/// Merges two rate limits: the tighter budget wins, and `enforce` blocks the +/// descendant from loosening it. +#[must_use] +pub fn merge_rate_limit( + base: Option<&RateLimitConfig>, + over: Option<&RateLimitConfig>, +) -> Option { + match (base, over) { + (None, None) => None, + (Some(only), None) | (None, Some(only)) => Some(only.clone()), + (Some(base), Some(over)) => { + // The budget itself is order-independent: the tighter of the two + // levels wins, so a descendant can tighten but never loosen. Only + // who the merged policy answers to is directional — `over` is the + // more specific level, and an ancestor's `enforce` survives it. + let capacity = base.capacity().min(over.capacity()); + let sustained = if base.refill_per_second() <= over.refill_per_second() { + base.sustained + } else { + over.sustained + }; + Some(RateLimitConfig { + sharing: if base.sharing == Sharing::Enforce { + Sharing::Enforce + } else { + over.sharing + }, + algorithm: over.algorithm, + sustained, + burst: Some(crate::domain::dto::Burst { capacity }), + scope: over.scope, + strategy: over.strategy, + cost: over.cost, + }) + } + } +} + +/// Merges two CORS blocks; the descendant wins unless the ancestor enforces. +#[must_use] +pub fn merge_cors(base: Option<&CorsConfig>, over: Option<&CorsConfig>) -> Option { + match (base, over) { + (None, None) => None, + (Some(only), None) | (None, Some(only)) => Some(only.clone()), + (Some(base), Some(over)) => { + if base.sharing == Sharing::Enforce { + return Some(base.clone()); + } + Some(CorsConfig { + sharing: over.sharing, + enabled: over.enabled || base.enabled, + allowed_origins: { + // A wildcard anywhere collapses the whole list: it grants + // everything the narrower entries already granted. + if base.allowed_origins.iter().chain(&over.allowed_origins) + .any(|origin| origin == "*") + { + vec!["*".to_owned()] + } else { + merge_tags(&base.allowed_origins, &over.allowed_origins) + } + }, + allowed_methods: merge_tags(&base.allowed_methods, &over.allowed_methods), + expose_headers: merge_tags(&base.expose_headers, &over.expose_headers), + allow_credentials: over.allow_credentials || base.allow_credentials, + }) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::dto::{Burst, SustainedRate, RateWindow}; + + fn rate(rate: u64, _seconds: u64) -> RateLimitConfig { + RateLimitConfig { + sharing: Sharing::Inherit, + algorithm: crate::domain::dto::RateAlgorithm::TokenBucket, + sustained: SustainedRate { + rate, + window: RateWindow::Second, + }, + burst: Some(Burst { capacity: rate }), + scope: crate::domain::dto::RateScope::Tenant, + strategy: crate::domain::dto::RateStrategy::Reject, + cost: 1, + } + } + + #[test] + fn rate_limit_merge_takes_the_minimum() { + let merged = merge_rate_limit(Some(&rate(100, 1)), Some(&rate(50, 1))).expect("merged"); + assert_eq!(merged.capacity(), 50); + } + + #[test] + fn rate_limit_enforce_blocks_loosening() { + let mut ancestor = rate(10, 1); + ancestor.sharing = Sharing::Enforce; + let merged = merge_rate_limit(Some(&ancestor), Some(&rate(1000, 1))).expect("merged"); + assert_eq!(merged.capacity(), 10); + } + + #[test] + fn tags_are_unioned() { + let merged = merge_tags(&["a".into(), "b".into()], &["b".into(), "c".into()]); + assert_eq!(merged, vec!["a", "b", "c"]); + } + + #[test] + fn plugins_are_concatenated_without_duplicates() { + let base = PluginsConfig { + sharing: Sharing::Private, + items: vec!["p1".into()], + config: std::collections::BTreeMap::new(), + }; + let over = PluginsConfig { + sharing: Sharing::Private, + items: vec!["p1".into(), "p2".into()], + config: std::collections::BTreeMap::new(), + }; + let merged = merge_plugins(Some(&base), Some(&over)); + assert_eq!(merged.items, vec!["p1", "p2"]); + } + + #[test] + fn upstream_plugins_run_before_route_plugins() { + let upstream = PluginsConfig { + sharing: Sharing::Private, + items: vec!["u1".into(), "u2".into()], + config: std::collections::BTreeMap::new(), + }; + let route = PluginsConfig { + sharing: Sharing::Private, + items: vec!["r1".into(), "r2".into()], + config: std::collections::BTreeMap::new(), + }; + let merged = merge_plugins(Some(&upstream), Some(&route)); + assert_eq!(merged.items, vec!["u1", "u2", "r1", "r2"]); + } + + #[test] + fn cors_origins_are_unioned() { + let base = CorsConfig { + sharing: Sharing::Inherit, + enabled: true, + allowed_origins: vec!["https://a.example.com".into()], + allowed_methods: vec!["GET".into()], + expose_headers: vec![], + allow_credentials: false, + }; + let over = CorsConfig { + sharing: Sharing::Inherit, + enabled: true, + allowed_origins: vec!["https://b.example.com".into()], + allowed_methods: vec!["POST".into()], + expose_headers: vec![], + allow_credentials: false, + }; + let merged = merge_cors(Some(&base), Some(&over)).expect("merged"); + assert_eq!(merged.allowed_origins.len(), 2); + assert_eq!(merged.allowed_methods.len(), 2); + } + + #[test] + fn wildcard_origin_dominates() { + let base = CorsConfig { + sharing: Sharing::Inherit, + enabled: true, + allowed_origins: vec!["https://a.example.com".into()], + allowed_methods: vec![], + expose_headers: vec![], + allow_credentials: false, + }; + let over = CorsConfig { + sharing: Sharing::Inherit, + enabled: true, + allowed_origins: vec!["*".into()], + allowed_methods: vec![], + expose_headers: vec![], + allow_credentials: false, + }; + let merged = merge_cors(Some(&base), Some(&over)).expect("merged"); + assert_eq!(merged.allowed_origins, vec!["*"]); + } +} diff --git a/gears/system/oagw/oagw/src/gear.rs b/gears/system/oagw/oagw/src/gear.rs new file mode 100644 index 0000000..7372bd9 --- /dev/null +++ b/gears/system/oagw/oagw/src/gear.rs @@ -0,0 +1,162 @@ +//! Gear declaration for the `oagw` outbound API gateway. + +use std::sync::{Arc, OnceLock}; + +use async_trait::async_trait; +use toolkit::api::OpenApiRegistry; +use toolkit::contracts::SystemCapability; +use toolkit::{Gear, GearCtx, RestApiCapability}; +use tracing::{debug, info}; + +use crate::config::OagwConfig; +use crate::infra::plugin::registry::{ + AuthPluginRegistry, GuardPluginRegistry, TransformPluginRegistry, +}; +use crate::infra::plugin::secret_store::HubSecretResolver; +use crate::infra::proxy::service::DataPlaneServiceImpl; +use crate::infra::proxy::tenant::{HubTenantHierarchy, TenantHierarchy}; + +/// The outbound API gateway gear. +/// +/// ## Capabilities +/// +/// - `system` — owns its control-plane state, initialized early in start-up +/// - `rest` — serves the management API and the proxy +/// +/// ## Surfaces +/// +/// Both are mounted gear-relative: +/// +/// - management: `/oagw/v1/{upstreams,routes,plugins}` +/// - proxy: `/oagw/v1/proxy/{alias}[/{*path}]` +#[toolkit::gear( + name = "oagw", + capabilities = [system, rest] +)] +pub struct OagwGear { + control_plane: OnceLock>, + data_plane: OnceLock>, +} + +impl Default for OagwGear { + fn default() -> Self { + Self { + control_plane: OnceLock::new(), + data_plane: OnceLock::new(), + } + } +} + +impl OagwGear { + /// The control plane, once the gear is initialized. + #[must_use] + pub fn control_plane(&self) -> Option> { + self.control_plane.get().cloned() + } + + /// The data plane, once the gear is initialized. + #[must_use] + pub fn data_plane(&self) -> Option> { + self.data_plane.get().cloned() + } +} + +#[async_trait] +impl Gear for OagwGear { + async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> { + let config: OagwConfig = ctx.config_or_default()?; + config.validate()?; + debug!( + proxy_timeout_secs = config.proxy_timeout_secs, + connect_timeout_secs = config.connect_timeout_secs, + allow_http_upstream = config.allow_http_upstream, + token_cache_ttl_secs = config.token_cache_ttl_secs, + "Loaded oagw config" + ); + debug!("{}", crate::infra::type_provisioning::describe()); + + let control_plane = Arc::new(crate::infra::storage::memory::in_memory_control_plane()); + let (upstreams, routes, plugins) = control_plane.repositories(); + + let tenants = tenant_hierarchy(ctx); + let secrets = secret_resolver(ctx); + let auth_plugins = AuthPluginRegistry::with_builtins( + secrets, + std::time::Duration::from_secs(config.token_cache_ttl_secs), + config.token_cache_capacity, + ); + + let data_plane = Arc::new(DataPlaneServiceImpl::new( + upstreams, + routes, + plugins, + auth_plugins, + GuardPluginRegistry::with_builtins(), + TransformPluginRegistry::with_builtins(), + tenants, + config, + )); + + let plane: Arc = control_plane; + self.control_plane + .set(plane) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + self.data_plane + .set(data_plane) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + + info!("oagw gear initialized: management and proxy surfaces ready"); + Ok(()) + } +} + +/// The tenant hierarchy, read through the client hub. +/// +/// This gear initializes before the tenant resolver does, so the client is +/// probed per lookup rather than captured here: capturing it at init would +/// freeze a `None` that never recovers. +fn tenant_hierarchy(ctx: &GearCtx) -> Arc { + debug!("oagw reads the tenant hierarchy through the client hub"); + Arc::new(HubTenantHierarchy::new(ctx.client_hub())) +} + +/// The secret resolver, read through the client hub. +/// +/// As with [`tenant_hierarchy`], the credential store registers its client +/// after this gear initializes, so the resolver probes the hub at resolve time. +fn secret_resolver(ctx: &GearCtx) -> Arc { + debug!("oagw resolves credentials through the client hub"); + Arc::new(HubSecretResolver::new(ctx.client_hub())) +} + +// System gear: oagw owns its control-plane state and needs no pre/post-init +// work beyond what `init` already does; the capability buys the early ordering. +impl SystemCapability for OagwGear {} + +impl RestApiCapability for OagwGear { + fn register_rest( + &self, + _ctx: &GearCtx, + router: axum::Router, + openapi: &dyn OpenApiRegistry, + ) -> anyhow::Result { + info!("Registering oagw REST routes"); + + let control_plane = self + .control_plane + .get() + .ok_or_else(|| anyhow::anyhow!("oagw control plane not initialized"))? + .clone(); + let data_plane = self + .data_plane + .get() + .ok_or_else(|| anyhow::anyhow!("oagw data plane not initialized"))? + .clone(); + + let router = + crate::api::rest::routes::register_routes(router, openapi, control_plane, data_plane)?; + + info!("oagw REST routes registered successfully"); + Ok(router) + } +} 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..544f8b7 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/mod.rs @@ -0,0 +1,6 @@ +//! Infrastructure: storage, plugins, the data plane and type provisioning. + +pub mod plugin; +pub mod proxy; +pub mod storage; +pub mod type_provisioning; diff --git a/gears/system/oagw/oagw/src/infra/plugin/apikey_auth.rs b/gears/system/oagw/oagw/src/infra/plugin/apikey_auth.rs new file mode 100644 index 0000000..ea27438 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/apikey_auth.rs @@ -0,0 +1,270 @@ +//! The `noop` and `apikey` built-in authentication plugins. +//! +//! `NoopAuthPlugin` injects nothing. `ApiKeyAuthPlugin` resolves the credential +//! material from the credential store at request time and injects it into a +//! header or a query parameter; the value is never echoed to the client. + +use async_trait::async_trait; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers::{AUTH_PLUGIN_APIKEY, AUTH_PLUGIN_NOOP}; +use crate::domain::plugin::{AuthPlugin, PluginContext, SecretResolver}; + +/// Injects nothing; used when an upstream opts out of credential injection. +#[derive(Debug, Default)] +pub struct NoopAuthPlugin; + +#[async_trait] +impl AuthPlugin for NoopAuthPlugin { + fn id(&self) -> &'static str { + AUTH_PLUGIN_NOOP + } + + async fn apply( + &self, + _context: &PluginContext, + _config: &serde_json::Value, + _headers: &mut http::HeaderMap, + _query: &mut Vec<(String, String)>, + ) -> Result<(), DomainError> { + Ok(()) + } +} + +/// Where the credential is injected. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum CredentialLocation { + Header, + Query, +} + +/// Injects an API key resolved from the credential store. +pub struct ApiKeyAuthPlugin { + secrets: std::sync::Arc, +} + +impl ApiKeyAuthPlugin { + /// Builds the plugin over a credential resolver. + #[must_use] + pub fn new(secrets: std::sync::Arc) -> Self { + Self { secrets } + } +} + +#[async_trait] +impl AuthPlugin for ApiKeyAuthPlugin { + fn id(&self) -> &'static str { + AUTH_PLUGIN_APIKEY + } + + async fn apply( + &self, + context: &PluginContext, + config: &serde_json::Value, + headers: &mut http::HeaderMap, + query: &mut Vec<(String, String)>, + ) -> Result<(), DomainError> { + let reference = config + .get("secret_ref") + .or_else(|| config.get("value_ref")) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + DomainError::AuthenticationFailed( + "apikey auth config requires `secret_ref`".into(), + ) + })?; + let name = config + .get("name") + .or_else(|| config.get("header_name")) + .or_else(|| config.get("query_name")) + .and_then(serde_json::Value::as_str) + .unwrap_or("x-api-key") + .to_owned(); + let location = config + .get("in") + .or_else(|| config.get("location")) + .and_then(serde_json::Value::as_str) + .map_or(CredentialLocation::Header, |value| { + if value.eq_ignore_ascii_case("query") { + CredentialLocation::Query + } else { + CredentialLocation::Header + } + }); + + let Some(secret) = self.secrets.resolve(context, reference).await? else { + return Err(DomainError::AuthenticationFailed(format!( + "credential reference `{reference}` could not be resolved" + ))); + }; + + match location { + CredentialLocation::Header => { + let value = http::HeaderValue::from_str(&secret).map_err(|_| { + DomainError::AuthenticationFailed( + "resolved credential is not a valid header value".into(), + ) + })?; + headers.insert( + http::HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| { + DomainError::AuthenticationFailed(format!( + "`{name}` is not a valid header name" + )) + })?, + value, + ); + } + CredentialLocation::Query => { + replace_or_push(query, &name, &secret); + } + } + Ok(()) + } +} + +/// Replaces an existing query parameter of the same name, or appends one. +fn replace_or_push(query: &mut Vec<(String, String)>, name: &str, value: &str) { + let mut replaced = false; + for (existing_name, existing_value) in query.iter_mut() { + if existing_name.eq_ignore_ascii_case(name) { + existing_value.clear(); + existing_value.push_str(value); + replaced = true; + } + } + if !replaced { + query.push((name.to_owned(), value.to_owned())); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use std::sync::{Arc, Mutex}; + + /// Resolver backed by a fixed table, for tests. + #[derive(Default)] + struct MapSecretResolver { + values: Mutex>, + } + + impl MapSecretResolver { + fn with(values: &[(&str, &str)]) -> std::sync::Arc { + let map: BTreeMap = values + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(); + std::sync::Arc::new(Self { + values: Mutex::new(map), + }) + } + } + + #[async_trait] + impl SecretResolver for MapSecretResolver { + async fn resolve( + &self, + _context: &PluginContext, + reference: &str, + ) -> Result, DomainError> { + Ok(self + .values + .lock() + .map_or(None, |values| values.get(reference).cloned())) + } + } + + fn context() -> PluginContext { + PluginContext { + tenant_id: uuid::Uuid::nil(), + subject_id: uuid::Uuid::nil(), + upstream_id: uuid::Uuid::nil(), + route_id: None, + alias: "api.openai.com".into(), + bearer_token: None, + request_id: None, + } + } + + #[tokio::test] + async fn apikey_is_injected_into_a_header() { + let plugin = ApiKeyAuthPlugin::new(MapSecretResolver::with(&[( + "cred://sk", + "sk-123", + )])); + let mut headers = http::HeaderMap::new(); + let mut query = Vec::new(); + plugin + .apply( + &context(), + &serde_json::json!({"name": "x-api-key", "secret_ref": "cred://sk"}), + &mut headers, + &mut query, + ) + .await + .expect("injected"); + assert_eq!(headers.get("x-api-key").and_then(|value| value.to_str().ok()), Some("sk-123")); + assert!(query.is_empty()); + } + + #[tokio::test] + async fn apikey_can_be_injected_into_the_query() { + let plugin = ApiKeyAuthPlugin::new(MapSecretResolver::with(&[("cred://sk", "sk-123")])); + let mut headers = http::HeaderMap::new(); + let mut query = Vec::new(); + plugin + .apply( + &context(), + &serde_json::json!({"in": "query", "name": "api_key", "secret_ref": "cred://sk"}), + &mut headers, + &mut query, + ) + .await + .expect("injected"); + assert!(headers.is_empty()); + assert_eq!(query, vec![("api_key".to_owned(), "sk-123".to_owned())]); + } + + #[tokio::test] + async fn unresolvable_secret_is_an_authentication_failure() { + let plugin = ApiKeyAuthPlugin::new(Arc::new(MapSecretResolver::default())); + let mut headers = http::HeaderMap::new(); + let mut query = Vec::new(); + let error = plugin + .apply( + &context(), + &serde_json::json!({"name": "x-api-key", "secret_ref": "cred://missing"}), + &mut headers, + &mut query, + ) + .await + .expect_err("unresolvable"); + assert_eq!(error.status(), 401); + } + + #[tokio::test] + async fn missing_secret_ref_is_an_authentication_failure() { + let plugin = ApiKeyAuthPlugin::new(Arc::new(MapSecretResolver::default())); + let mut headers = http::HeaderMap::new(); + let mut query = Vec::new(); + let error = plugin + .apply(&context(), &serde_json::json!({}), &mut headers, &mut query) + .await + .expect_err("misconfigured"); + assert_eq!(error.status(), 401); + } + + #[tokio::test] + async fn noop_injects_nothing() { + let plugin = NoopAuthPlugin; + let mut headers = http::HeaderMap::new(); + let mut query = Vec::new(); + plugin + .apply(&context(), &serde_json::json!({}), &mut headers, &mut query) + .await + .expect("no-op"); + assert!(headers.is_empty()); + } +} 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..f0e1c7d --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/mod.rs @@ -0,0 +1,8 @@ +//! Plugin implementations and their registries. + +pub mod apikey_auth; +pub mod oauth2_client_cred_auth; +pub mod registry; +pub mod required_headers_guard; +pub mod request_id_transform; +pub mod secret_store; diff --git a/gears/system/oagw/oagw/src/infra/plugin/oauth2_client_cred_auth.rs b/gears/system/oagw/oagw/src/infra/plugin/oauth2_client_cred_auth.rs new file mode 100644 index 0000000..2ac3c06 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/oauth2_client_cred_auth.rs @@ -0,0 +1,392 @@ +//! The `OAuth2` client-credentials authentication plugin. +//! +//! Implements ADR-0008: a token is fetched once per +//! `(tenant, subject, auth_method, config)` tuple with +//! `toolkit_auth::oauth2::fetch_token` — which spawns nothing — and cached in +//! `pingora-memory-cache` with a TTL of `min(config_ttl, expires_in − 30 s)`. +//! A `CachedToken` wrapper carries the original key so a `u64` hash collision +//! can only ever look like a miss, never another tenant's token. + +use std::time::Duration; + +use async_trait::async_trait; +use toolkit_auth::oauth2::{ClientAuthMethod, OAuthClientConfig, fetch_token}; +use toolkit_auth::SecretString; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers::{AUTH_PLUGIN_OAUTH2_CLIENT_CRED, AUTH_PLUGIN_OAUTH2_CLIENT_CRED_BASIC}; +use crate::domain::plugin::{AuthPlugin, PluginContext}; + +/// Cache-timeout ceiling, when the configuration does not name one. +pub const DEFAULT_TOKEN_CACHE_TTL: Duration = Duration::from_mins(5); + +/// Maximum number of entries in the token cache. +pub const DEFAULT_TOKEN_CACHE_CAPACITY: usize = 10_000; + +/// Safety margin subtracted from the `IdP`'s `expires_in`. +const EXPIRY_MARGIN: Duration = Duration::from_secs(30); + +/// Which client-auth method the variant uses. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ClientCredentialMethod { + /// Credentials in the request body. + Form, + /// Credentials in an `Authorization: Basic` header. + Basic, +} + +impl ClientCredentialMethod { + fn auth_method(self) -> ClientAuthMethod { + match self { + Self::Form => ClientAuthMethod::Form, + Self::Basic => ClientAuthMethod::Basic, + } + } + + fn tag(self) -> &'static str { + match self { + Self::Form => "form", + Self::Basic => "basic", + } + } + + fn gts_id(self) -> &'static str { + match self { + Self::Form => AUTH_PLUGIN_OAUTH2_CLIENT_CRED, + Self::Basic => AUTH_PLUGIN_OAUTH2_CLIENT_CRED_BASIC, + } + } +} + +/// A cached token plus the key it was stored under. +#[derive(Clone)] +struct CachedToken { + key: String, + token: SecretString, +} + +impl std::fmt::Debug for CachedToken { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("CachedToken") + .field("key", &self.key) + .field("token", &"[REDACTED]") + .finish() + } +} + +/// `OAuth2` client-credentials plugin with an internal token cache. +pub struct OAuth2ClientCredAuthPlugin { + secrets: std::sync::Arc, + method: ClientCredentialMethod, + cache: pingora_memory_cache::MemoryCache, + cache_ttl: Duration, +} + +impl std::fmt::Debug for OAuth2ClientCredAuthPlugin { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter + .debug_struct("OAuth2ClientCredAuthPlugin") + .field("method", &self.method) + .field("cache_ttl", &self.cache_ttl) + .finish_non_exhaustive() + } +} + +impl OAuth2ClientCredAuthPlugin { + /// Builds the plugin. + #[must_use] + pub fn new( + secrets: std::sync::Arc, + method: ClientCredentialMethod, + cache_ttl: Duration, + cache_capacity: usize, + ) -> Self { + Self { + secrets, + method, + cache: pingora_memory_cache::MemoryCache::new(cache_capacity), + cache_ttl, + } + } + + /// Deterministic, tenant- and subject-scoped cache key. + fn cache_key(&self, context: &PluginContext, config: &serde_json::Value) -> String { + format!( + "{}:{}:{}:{}", + context.tenant_id, + context.subject_id, + self.method.tag(), + hash_config(config) + ) + } + + /// The configured TTL ceiling. + fn ttl_ceiling(&self) -> Duration { + self.cache_ttl + } +} + +/// Stable, order-independent hash of the plugin configuration. +fn hash_config(config: &serde_json::Value) -> u64 { + let Some(object) = config.as_object() else { + return 0; + }; + let mut keys: Vec<&String> = object.keys().collect(); + keys.sort(); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for key in keys { + std::hash::Hash::hash(&key.as_str(), &mut hasher); + std::hash::Hash::hash(&object[key].to_string().as_str(), &mut hasher); + } + std::hash::Hasher::finish(&hasher) +} + +#[async_trait] +impl AuthPlugin for OAuth2ClientCredAuthPlugin { + fn id(&self) -> &'static str { + self.method.gts_id() + } + + async fn apply( + &self, + context: &PluginContext, + config: &serde_json::Value, + headers: &mut http::HeaderMap, + _query: &mut Vec<(String, String)>, + ) -> Result<(), DomainError> { + let key = self.cache_key(context, config); + if let Some(token) = self.lookup(&key) { + inject(headers, &token); + return Ok(()); + } + + let client_id = self.resolve_secret(context, config, "client_id_ref").await?; + let client_secret = self.resolve_secret(context, config, "client_secret_ref").await?; + let endpoint = endpoint_url(config)?; + let scopes = config + .get("scopes") + .and_then(serde_json::Value::as_str) + .map_or_else(Vec::new, |raw| { + raw.split_whitespace().map(str::to_owned).collect() + }); + + let oauth = OAuthClientConfig { + token_endpoint: Some(endpoint.clone()), + issuer_url: None, + client_id, + client_secret: SecretString::new(client_secret), + scopes, + auth_method: self.method.auth_method(), + extra_headers: Vec::new(), + refresh_offset: Duration::from_mins(1), + jitter_max: Duration::from_secs(0), + min_refresh_period: Duration::from_secs(10), + default_ttl: Duration::from_mins(5), + http_config: None, + }; + + let fetched = fetch_token(oauth).await.map_err(|error| { + DomainError::AuthenticationFailed(format!("token endpoint rejected the exchange: {error}")) + })?; + + let ttl = fetched + .expires_in + .saturating_sub(EXPIRY_MARGIN) + .min(self.ttl_ceiling()); + if ttl.is_zero() { + // A token that is already inside the safety margin, or an IdP that + // grants less than it, cannot be cached: `put` refuses a zero TTL + // and the entry would vanish before it was read back. The exchange + // still succeeded, so the caller gets the token it paid for. + inject(headers, fetched.bearer.expose()); + return Ok(()); + } + self.cache.put( + &key, + CachedToken { + key: key.clone(), + token: fetched.bearer, + }, + Some(ttl), + ); + + let token = self.lookup(&key).ok_or_else(|| { + DomainError::AuthenticationFailed("token cache rejected the entry".into()) + })?; + inject(headers, &token); + Ok(()) + } +} + +impl OAuth2ClientCredAuthPlugin { + fn lookup(&self, key: &str) -> Option { + let (entry, _status) = self.cache.get(key); + let cached = entry?; + if cached.key != key { + return None; + } + Some(cached.token.expose().to_owned()) + } + + async fn resolve_secret( + &self, + context: &PluginContext, + config: &serde_json::Value, + field: &str, + ) -> Result { + let reference = config + .get(field) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + DomainError::AuthenticationFailed(format!("oauth2 auth config requires `{field}`")) + })?; + self.secrets + .resolve(context, reference) + .await? + .ok_or_else(|| { + DomainError::AuthenticationFailed(format!( + "credential reference `{reference}` could not be resolved" + )) + }) + } +} + +fn endpoint_url(config: &serde_json::Value) -> Result { + let raw = config + .get("token_endpoint") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| { + DomainError::AuthenticationFailed( + "oauth2 auth config requires `token_endpoint` or `issuer_url`".into(), + ) + })?; + url::Url::parse(raw).map_err(|_| { + DomainError::AuthenticationFailed(format!("`{raw}` is not a valid token endpoint URL")) + }) +} + +fn inject(headers: &mut http::HeaderMap, token: &str) { + if let Ok(value) = http::HeaderValue::from_str(&format!("Bearer {token}")) { + headers.insert(http::header::AUTHORIZATION, value); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::plugin::PluginContext; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + /// Resolver over a fixed table, counting how often it was consulted. + #[derive(Default)] + struct CountingSecretResolver { + values: Mutex>, + lookups: AtomicUsize, + } + + impl CountingSecretResolver { + fn with(values: &[(&str, &str)]) -> std::sync::Arc { + let map: std::collections::BTreeMap = values + .iter() + .map(|(key, value)| ((*key).to_owned(), (*value).to_owned())) + .collect(); + std::sync::Arc::new(Self { + values: Mutex::new(map), + lookups: AtomicUsize::new(0), + }) + } + } + + #[async_trait] + impl crate::domain::plugin::SecretResolver for CountingSecretResolver { + async fn resolve( + &self, + _context: &PluginContext, + reference: &str, + ) -> Result, DomainError> { + self.lookups.fetch_add(1, Ordering::SeqCst); + Ok(self.values.lock().map_or(None, |values| values.get(reference).cloned())) + } + } + + fn context() -> PluginContext { + PluginContext { + tenant_id: uuid::Uuid::new_v4(), + subject_id: uuid::Uuid::new_v4(), + upstream_id: uuid::Uuid::nil(), + route_id: None, + alias: "graph.microsoft.com".into(), + bearer_token: None, + request_id: None, + } + } + + fn config() -> serde_json::Value { + serde_json::json!({ + "token_endpoint": "http://127.0.0.1:1/token", + "client_id_ref": "cred://id", + "client_secret_ref": "cred://secret", + "scopes": "read write" + }) + } + + #[test] + fn cache_key_is_scoped_per_tenant_subject_method_and_config() { + let secrets: std::sync::Arc = + Arc::new(CountingSecretResolver::default()); + let plugin = OAuth2ClientCredAuthPlugin::new( + secrets, + ClientCredentialMethod::Form, + Duration::from_mins(5), + 128, + ); + let context = context(); + let first = plugin.cache_key(&context, &config()); + let second = plugin.cache_key(&context, &config()); + assert_eq!(first, second, "same inputs must collide on purpose"); + + let reordered = serde_json::json!({ + "scopes": "read write", + "client_secret_ref": "cred://secret", + "client_id_ref": "cred://id", + "token_endpoint": "http://127.0.0.1:1/token" + }); + assert_eq!(first, plugin.cache_key(&context, &reordered)); + } + + #[tokio::test] + async fn unresolvable_secret_yields_401() { + let plugin = OAuth2ClientCredAuthPlugin::new( + Arc::new(CountingSecretResolver::default()), + ClientCredentialMethod::Form, + Duration::from_mins(5), + 128, + ); + let mut headers = http::HeaderMap::new(); + let error = plugin + .apply(&context(), &config(), &mut headers, &mut Vec::new()) + .await + .expect_err("unresolvable"); + assert_eq!(error.status(), 401); + } + + #[tokio::test] + async fn unreachable_idp_yields_401_and_is_not_cached() { + let resolver = CountingSecretResolver::with(&[("cred://id", "client"), ("cred://secret", "shh")]); + let plugin = OAuth2ClientCredAuthPlugin::new( + resolver.clone(), + ClientCredentialMethod::Form, + Duration::from_mins(5), + 128, + ); + let mut headers = http::HeaderMap::new(); + let error = plugin + .apply(&context(), &config(), &mut headers, &mut Vec::new()) + .await + .expect_err("idp unreachable"); + assert_eq!(error.status(), 401); + assert_eq!(resolver.lookups.load(Ordering::SeqCst), 2, "both secrets resolved"); + } +} 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..0926038 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/registry.rs @@ -0,0 +1,233 @@ +//! The three plugin registries. +//! +//! Only built-in identifiers resolve here; a catalog-only identifier returns +//! `None`, which the data plane turns into a fail-closed error rather than a +//! silent pass-through. + +use std::collections::BTreeMap; +use std::sync::Arc; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers::is_catalog_only_plugin; +use crate::domain::plugin::{AuthPlugin, GuardPlugin, SecretResolver, TransformPlugin}; +use crate::infra::plugin::apikey_auth::{ApiKeyAuthPlugin, NoopAuthPlugin}; +use crate::infra::plugin::oauth2_client_cred_auth::{ + ClientCredentialMethod, OAuth2ClientCredAuthPlugin, +}; +use crate::infra::plugin::request_id_transform::RequestIdTransformPlugin; +use crate::infra::plugin::required_headers_guard::RequiredHeadersGuardPlugin; + +/// Auth-plugin registry. +pub struct AuthPluginRegistry { + plugins: BTreeMap<&'static str, Arc>, +} + +impl AuthPluginRegistry { + /// Registers the built-in auth plugins. + #[must_use] + pub fn with_builtins( + secrets: Arc, + cache_ttl: std::time::Duration, + cache_capacity: usize, + ) -> Self { + let mut plugins: BTreeMap<&'static str, Arc> = BTreeMap::new(); + let noop = NoopAuthPlugin; + plugins.insert(noop.id(), Arc::new(noop)); + let apikey = ApiKeyAuthPlugin::new(Arc::clone(&secrets)); + plugins.insert(apikey.id(), Arc::new(apikey)); + let form = OAuth2ClientCredAuthPlugin::new( + Arc::clone(&secrets), + ClientCredentialMethod::Form, + cache_ttl, + cache_capacity, + ); + plugins.insert(form.id(), Arc::new(form)); + let basic = OAuth2ClientCredAuthPlugin::new( + secrets, + ClientCredentialMethod::Basic, + cache_ttl, + cache_capacity, + ); + plugins.insert(basic.id(), Arc::new(basic)); + Self { plugins } + } + + /// Resolves an auth plugin by GTS identifier. + #[must_use] + pub fn resolve(&self, id: &str) -> Option> { + if is_catalog_only_plugin(id) { + return None; + } + self.plugins.get(id).cloned() + } + + /// Every registered identifier. + #[must_use] + pub fn ids(&self) -> Vec<&'static str> { + self.plugins.keys().copied().collect() + } +} + +/// Guard-plugin registry. +#[derive(Default)] +#[allow(clippy::missing_fields_in_debug)] +pub struct GuardPluginRegistry { + plugins: BTreeMap<&'static str, Arc>, +} + +impl GuardPluginRegistry { + /// Registers the built-in guard plugins. + #[must_use] + pub fn with_builtins() -> Self { + let mut plugins: BTreeMap<&'static str, Arc> = BTreeMap::new(); + plugins.insert( + RequiredHeadersGuardPlugin.id(), + Arc::new(RequiredHeadersGuardPlugin), + ); + Self { plugins } + } + + /// Resolves a guard plugin by GTS identifier. + #[must_use] + pub fn resolve(&self, id: &str) -> Option> { + if is_catalog_only_plugin(id) { + return None; + } + self.plugins.get(id).cloned() + } + + /// Every registered identifier. + #[must_use] + pub fn ids(&self) -> Vec<&'static str> { + self.plugins.keys().copied().collect() + } +} + +/// Transform-plugin registry. +#[derive(Default)] +#[allow(clippy::missing_fields_in_debug)] +pub struct TransformPluginRegistry { + plugins: BTreeMap<&'static str, Arc>, +} + +impl TransformPluginRegistry { + /// Registers the built-in transform plugins. + #[must_use] + pub fn with_builtins() -> Self { + let mut plugins: BTreeMap<&'static str, Arc> = BTreeMap::new(); + plugins.insert( + RequestIdTransformPlugin.id(), + Arc::new(RequestIdTransformPlugin), + ); + Self { plugins } + } + + /// Resolves a transform plugin by GTS identifier. + #[must_use] + pub fn resolve(&self, id: &str) -> Option> { + if is_catalog_only_plugin(id) { + return None; + } + self.plugins.get(id).cloned() + } + + /// Every registered identifier. + #[must_use] + pub fn ids(&self) -> Vec<&'static str> { + self.plugins.keys().copied().collect() + } +} + +/// The error produced when a plugin identifier cannot be resolved. +/// +/// # Errors +/// Returns [`DomainError::AuthenticationFailed`] for auth plugins and +/// [`DomainError::Validation`] for the other families. +#[must_use] +pub fn unresolvable_plugin_error(family: &str, id: &str) -> DomainError { + match family { + "auth" => DomainError::AuthenticationFailed(format!( + "auth plugin `{id}` is not resolvable in this deployment" + )), + _ => DomainError::Validation(format!( + "{family} plugin `{id}` is not resolvable in this deployment" + )), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::plugin::NullSecretResolver; + use crate::infra::plugin::oauth2_client_cred_auth::{DEFAULT_TOKEN_CACHE_CAPACITY, DEFAULT_TOKEN_CACHE_TTL}; + + fn auth_registry() -> AuthPluginRegistry { + AuthPluginRegistry::with_builtins( + Arc::new(NullSecretResolver), + DEFAULT_TOKEN_CACHE_TTL, + DEFAULT_TOKEN_CACHE_CAPACITY, + ) + } + + #[test] + fn builtin_auth_plugins_resolve() { + let registry = auth_registry(); + assert!(registry + .resolve("gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1") + .is_some()); + assert!(registry + .resolve("gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1") + .is_some()); + assert!(registry + .resolve("gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1") + .is_some()); + assert!(registry + .resolve("gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1") + .is_some()); + } + + #[test] + fn catalog_only_auth_identifiers_resolve_to_nothing() { + let registry = auth_registry(); + assert!(registry + .resolve("gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.basic.v1") + .is_none()); + assert!(registry + .resolve("gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.bearer.v1") + .is_none()); + } + + #[test] + fn guard_and_transform_registries_only_admit_their_builtins() { + let guards = GuardPluginRegistry::with_builtins(); + assert!(guards + .resolve("gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1") + .is_some()); + assert!(guards + .resolve("gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.timeout.v1") + .is_none()); + assert!(guards + .resolve("gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1") + .is_none()); + + let transforms = TransformPluginRegistry::with_builtins(); + assert!(transforms + .resolve("gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1") + .is_some()); + assert!(transforms + .resolve("gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.logging.v1") + .is_none()); + assert!(transforms + .resolve("gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.metrics.v1") + .is_none()); + } + + #[test] + fn unresolvable_auth_plugin_is_an_authentication_failure() { + let error = unresolvable_plugin_error( + "auth", + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.bearer.v1", + ); + assert_eq!(error.status(), 401); + } +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/request_id_transform.rs b/gears/system/oagw/oagw/src/infra/plugin/request_id_transform.rs new file mode 100644 index 0000000..9eb7edd --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/request_id_transform.rs @@ -0,0 +1,183 @@ +//! The `request_id` built-in transform plugin. +//! +//! Propagates an inbound `X-Request-ID` when present and generates one when +//! absent, then echoes the same value on the response. + +use async_trait::async_trait; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers::REQUEST_ID_TRANSFORM_PLUGIN_ID; +use crate::domain::plugin::PluginContext; + +/// The header this plugin manages. +pub const REQUEST_ID_HEADER: &str = "x-request-id"; + +/// Propagates or generates `X-Request-ID`. +#[derive(Debug, Default)] +pub struct RequestIdTransformPlugin; + +impl RequestIdTransformPlugin { + /// The value the request should carry, propagated or freshly minted. + #[must_use] + pub fn ensure_request_id(headers: &http::HeaderMap) -> String { + headers + .get(REQUEST_ID_HEADER) + .and_then(|value| value.to_str().ok()) + .filter(|value| !value.trim().is_empty()) + .map_or_else(|| uuid::Uuid::new_v4().to_string(), str::to_owned) + } +} + +#[async_trait] +impl crate::domain::plugin::TransformPlugin for RequestIdTransformPlugin { + fn id(&self) -> &'static str { + REQUEST_ID_TRANSFORM_PLUGIN_ID + } + + async fn transform_request( + &self, + _context: &PluginContext, + _config: &serde_json::Value, + inbound: &http::HeaderMap, + headers: &mut http::HeaderMap, + ) -> Result<(), DomainError> { + // The caller's correlation id is read from what arrived: the default + // passthrough drops every inbound header, but propagation is the + // plugin's whole purpose. + let value = Self::ensure_request_id(inbound); + if let Ok(header) = http::HeaderValue::from_str(&value) { + headers.insert(http::HeaderName::from_static(REQUEST_ID_HEADER), header); + } + Ok(()) + } + + async fn transform_response( + &self, + context: &PluginContext, + _config: &serde_json::Value, + _status: http::StatusCode, + headers: &mut http::HeaderMap, + ) -> Result<(), DomainError> { + if headers.get(REQUEST_ID_HEADER).is_some() { + return Ok(()); + } + // The request phase settled the value; echoing it here is what lets a + // caller correlate a reply with the request it belongs to. + if let Some(request_id) = context.request_id.as_deref() + && let Ok(header) = http::HeaderValue::from_str(request_id) + { + headers.insert(http::HeaderName::from_static(REQUEST_ID_HEADER), header); + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::plugin::TransformPlugin; + + fn context() -> PluginContext { + PluginContext { + tenant_id: uuid::Uuid::nil(), + subject_id: uuid::Uuid::nil(), + upstream_id: uuid::Uuid::nil(), + route_id: None, + alias: "api.openai.com".into(), + bearer_token: None, + request_id: None, + } + } + + fn headers() -> http::HeaderMap { + let mut map = http::HeaderMap::new(); + map.insert( + http::HeaderName::from_static(REQUEST_ID_HEADER), + http::HeaderValue::from_static("from-client"), + ); + map + } + + #[tokio::test] + async fn propagates_an_inbound_request_id() { + let plugin = RequestIdTransformPlugin; + let inbound = headers(); + let mut outbound = http::HeaderMap::new(); + plugin + .transform_request(&context(), &serde_json::json!({}), &inbound, &mut outbound) + .await + .expect("propagated"); + assert_eq!( + outbound.get(REQUEST_ID_HEADER).and_then(|value| value.to_str().ok()), + Some("from-client") + ); + } + + #[tokio::test] + async fn propagates_past_dropped_header_rules() { + // The default passthrough removes every inbound header, so the value + // the upstream would receive is gone; the plugin still propagates it. + let plugin = RequestIdTransformPlugin; + let inbound = headers(); + let mut outbound = http::HeaderMap::new(); + plugin + .transform_request(&context(), &serde_json::json!({}), &inbound, &mut outbound) + .await + .expect("propagated"); + assert_eq!( + outbound.get(REQUEST_ID_HEADER).and_then(|value| value.to_str().ok()), + Some("from-client") + ); + } + + #[tokio::test] + async fn generates_one_when_absent() { + let plugin = RequestIdTransformPlugin; + let mut outbound = http::HeaderMap::new(); + plugin + .transform_request(&context(), &serde_json::json!({}), &http::HeaderMap::new(), &mut outbound) + .await + .expect("generated"); + let value = outbound + .get(REQUEST_ID_HEADER) + .and_then(|header| header.to_str().ok()) + .expect("present"); + assert!(value.parse::().is_ok()); + } + + #[tokio::test] + async fn response_echoes_the_request_value() { + let plugin = RequestIdTransformPlugin; + let inbound = headers(); + let mut outbound = http::HeaderMap::new(); + plugin + .transform_request(&context(), &serde_json::json!({}), &inbound, &mut outbound) + .await + .expect("request"); + let echoed = outbound.get(REQUEST_ID_HEADER).and_then(|value| value.to_str().ok()).map(str::to_owned); + let mut response = http::HeaderMap::new(); + let context = PluginContext { + request_id: echoed, + ..context() + }; + plugin + .transform_response(&context, &serde_json::json!({}), http::StatusCode::OK, &mut response) + .await + .expect("response"); + assert_eq!( + response.get(REQUEST_ID_HEADER).and_then(|header| header.to_str().ok()), + Some("from-client") + ); + } + + #[test] + fn empty_value_is_regenerated() { + let mut headers = http::HeaderMap::new(); + headers.insert( + http::HeaderName::from_static(REQUEST_ID_HEADER), + http::HeaderValue::from_static(" "), + ); + let value = RequestIdTransformPlugin::ensure_request_id(&headers); + assert!(value.parse::().is_ok()); + } +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/required_headers_guard.rs b/gears/system/oagw/oagw/src/infra/plugin/required_headers_guard.rs new file mode 100644 index 0000000..07880f7 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/required_headers_guard.rs @@ -0,0 +1,204 @@ +//! The `required_headers` built-in guard plugin (ADR-0009). +//! +//! Stateless and fail-open: an absent or blank config turns the phase into a +//! no-op. Header names are matched case-insensitively and only presence is +//! checked; the first missing name is reported. + +use async_trait::async_trait; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers::REQUIRED_HEADERS_GUARD_PLUGIN_ID; +use crate::domain::plugin::{GuardPlugin, PluginContext}; + +/// Splits a comma-separated header list into lower-case, trimmed names. +#[must_use] +pub fn parse_header_list(raw: Option<&str>) -> Vec { + raw.map_or_else(Vec::new, |value| { + value + .split(',') + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_ascii_lowercase) + .collect() + }) +} + +/// The first name in `required` missing from `headers`, if any. +#[must_use] +pub fn first_missing(required: &[String], headers: &http::HeaderMap) -> Option { + required.iter().find(|name| headers.get(name.as_str()).is_none()).cloned() +} + +#[derive(Debug, Default)] +struct GuardConfig { + request: Vec, + response: Vec, +} + +fn config_for(config: &serde_json::Value) -> GuardConfig { + GuardConfig { + request: parse_header_list( + config + .get("required_request_headers") + .and_then(serde_json::Value::as_str), + ), + response: parse_header_list( + config + .get("required_response_headers") + .and_then(serde_json::Value::as_str), + ), + } +} + +/// Enforces the presence of configured request and response headers. +#[derive(Debug, Default)] +pub struct RequiredHeadersGuardPlugin; + +#[async_trait] +impl GuardPlugin for RequiredHeadersGuardPlugin { + fn id(&self) -> &'static str { + REQUIRED_HEADERS_GUARD_PLUGIN_ID + } + + async fn guard_request( + &self, + _context: &PluginContext, + config: &serde_json::Value, + headers: &http::HeaderMap, + ) -> Result<(), DomainError> { + let config = config_for(config); + if config.request.is_empty() { + return Ok(()); + } + match first_missing(&config.request, headers) { + None => Ok(()), + Some(name) => Err(DomainError::Validation(format!( + "required request header `{name}` is missing" + ))), + } + } + + async fn guard_response( + &self, + _context: &PluginContext, + config: &serde_json::Value, + _status: http::StatusCode, + headers: &mut http::HeaderMap, + ) -> Result<(), DomainError> { + let config = config_for(config); + if config.response.is_empty() { + return Ok(()); + } + match first_missing(&config.response, headers) { + None => Ok(()), + Some(name) => Err(DomainError::DownstreamError(format!( + "upstream response is missing required header `{name}`" + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn headers(pairs: &[(&str, &str)]) -> http::HeaderMap { + let mut map = http::HeaderMap::new(); + for (name, value) in pairs { + map.insert( + http::HeaderName::from_bytes(name.as_bytes()).expect("valid name"), + http::HeaderValue::from_str(value).expect("valid value"), + ); + } + map + } + + #[test] + fn list_parsing_trims_and_lowercases() { + assert_eq!( + parse_header_list(Some(" X-Correlation-Id, accept ,,")), + vec!["x-correlation-id", "accept"] + ); + assert!(parse_header_list(Some(" , , ")).is_empty()); + assert!(parse_header_list(None).is_empty()); + } + + #[test] + fn first_missing_reports_only_the_first() { + let required = vec!["x-a".to_owned(), "x-b".to_owned()]; + let present = headers(&[("x-a", "1")]); + assert_eq!(first_missing(&required, &present).as_deref(), Some("x-b")); + } + + #[tokio::test] + async fn absent_config_fails_open() { + let plugin = RequiredHeadersGuardPlugin; + let mut headers = headers(&[]); + plugin + .guard_request( + &context(), + &serde_json::json!({}), + &mut headers, + ) + .await + .expect("fail-open"); + } + + #[tokio::test] + async fn missing_request_header_is_a_400() { + let plugin = RequiredHeadersGuardPlugin; + let mut headers = headers(&[("accept", "application/json")]); + let error = plugin + .guard_request( + &context(), + &serde_json::json!({"required_request_headers": "x-correlation-id,accept"}), + &mut headers, + ) + .await + .expect_err("missing"); + assert_eq!(error.status(), 400); + assert!(error.to_string().contains("x-correlation-id")); + } + + #[tokio::test] + async fn all_present_is_allowed() { + let plugin = RequiredHeadersGuardPlugin; + let mut headers = headers(&[("x-correlation-id", "abc"), ("accept", "json")]); + plugin + .guard_request( + &context(), + &serde_json::json!({"required_request_headers": "x-correlation-id,accept"}), + &mut headers, + ) + .await + .expect("present"); + } + + #[tokio::test] + async fn missing_response_header_is_a_502() { + let plugin = RequiredHeadersGuardPlugin; + let mut headers = headers(&[]); + let error = plugin + .guard_response( + &context(), + &serde_json::json!({"required_response_headers": "content-type"}), + http::StatusCode::OK, + &mut headers, + ) + .await + .expect_err("missing"); + assert_eq!(error.status(), 502); + } + + fn context() -> PluginContext { + PluginContext { + tenant_id: uuid::Uuid::nil(), + subject_id: uuid::Uuid::nil(), + upstream_id: uuid::Uuid::nil(), + route_id: None, + alias: "api.openai.com".into(), + bearer_token: None, + request_id: None, + } + } +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/secret_store.rs b/gears/system/oagw/oagw/src/infra/plugin/secret_store.rs new file mode 100644 index 0000000..d40cfdf --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/secret_store.rs @@ -0,0 +1,203 @@ +//! The credential-store-backed [`SecretResolver`]. +//! +//! A configuration references material as `cred://`; the prefix is +//! stripped before the key is handed to the credential store, so the store only +//! ever sees the key it validates against. + +use std::sync::Arc; + +use async_trait::async_trait; +use credstore_sdk::{CredStoreClientV1, CredStoreError, SecretRef}; +use toolkit_security::SecurityContext; + +use crate::domain::error::DomainError; +use crate::domain::plugin::{PluginContext, SecretResolver}; + +/// The prefix that marks a reference as credential-store-backed. +pub const CREDENTIAL_SCHEME: &str = "cred://"; + +/// Strips [`CREDENTIAL_SCHEME`] from a reference, if it carries one. +#[must_use] +pub fn strip_scheme(reference: &str) -> &str { + reference.strip_prefix(CREDENTIAL_SCHEME).unwrap_or(reference) +} + +/// Resolves `cred://` references through a client hub. +/// +/// Gears that register the credential-store client initialize after this gear +/// does, so the client is looked up at resolve time rather than captured at +/// init. The lookup is a read-locked map probe, not a network call. +pub struct HubSecretResolver { + hub: Arc, +} + +impl HubSecretResolver { + /// Builds the resolver over a client hub. + #[must_use] + pub fn new(hub: Arc) -> Self { + Self { hub } + } + + /// The credential-store client, when one is registered. + fn store(&self) -> Option> { + self.hub.try_get::() + } +} + +#[async_trait] +impl SecretResolver for HubSecretResolver { + async fn resolve( + &self, + context: &PluginContext, + reference: &str, + ) -> Result, DomainError> { + let Some(store) = self.store() else { + return Ok(None); + }; + CredStoreSecretResolver::new(store).resolve(context, reference).await + } +} + +/// Resolves `cred://` references through the credential store. +pub struct CredStoreSecretResolver { + store: Arc, +} + +impl CredStoreSecretResolver { + /// Builds the resolver over a credential-store client. + #[must_use] + pub fn new(store: Arc) -> Self { + Self { store } + } +} + +#[async_trait] +impl SecretResolver for CredStoreSecretResolver { + async fn resolve( + &self, + context: &PluginContext, + reference: &str, + ) -> Result, DomainError> { + let Ok(key) = SecretRef::new(strip_scheme(reference)) else { + return Err(DomainError::AuthenticationFailed(format!( + "credential reference `{reference}` is not a valid secret key" + ))); + }; + let caller = SecurityContext::builder() + .subject_id(context.subject_id) + .subject_tenant_id(context.tenant_id) + .build() + .map_err(|error| { + DomainError::AuthenticationFailed(format!( + "cannot build a credential-store context: {error}" + )) + })?; + match self.store.get(&caller, &key).await { + Ok(Some(response)) => Ok(Some( + String::from_utf8_lossy(response.value.as_bytes()).into_owned(), + )), + // A missing secret and a denied lookup both read as "no secret + // configured", so they share one arm. + Ok(None) | Err(CredStoreError::AccessDenied | CredStoreError::NotFound) => Ok(None), + Err(error) => Err(DomainError::DownstreamError(format!( + "credential store unavailable: {error}" + ))), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use credstore_sdk::test_util::MockCredStoreClient; + + fn context() -> PluginContext { + PluginContext { + tenant_id: uuid::Uuid::nil(), + subject_id: uuid::Uuid::nil(), + upstream_id: uuid::Uuid::nil(), + route_id: None, + alias: "alias".to_owned(), + bearer_token: None, + request_id: None, + } + } + + async fn resolve(store: Arc, reference: &str) -> Result, DomainError> { + CredStoreSecretResolver::new(store) + .resolve(&context(), reference) + .await + } + + #[tokio::test] + async fn a_cred_reference_resolves_to_the_stored_value() { + let store: Arc = + Arc::new(MockCredStoreClient::with_secrets(vec![( + "openai-key".to_owned(), + "sk-123".to_owned(), + )])); + let resolved = resolve(store, "cred://openai-key") + .await + .expect("resolves"); + assert_eq!(resolved.as_deref(), Some("sk-123")); + } + + #[tokio::test] + async fn an_unknown_reference_resolves_to_nothing() { + let store: Arc = Arc::new(MockCredStoreClient::empty()); + let resolved = resolve(store, "cred://missing").await.expect("resolves"); + assert!(resolved.is_none()); + } + + #[tokio::test] + async fn an_absent_client_resolves_to_nothing() { + let hub = toolkit::client_hub::ClientHub::default(); + let resolved = HubSecretResolver::new(std::sync::Arc::new(hub)) + .resolve(&context(), "cred://openai-key") + .await + .expect("resolves"); + assert!(resolved.is_none(), "no credential store, no credential"); + } + + #[tokio::test] + async fn a_late_registered_client_is_picked_up() { + let hub = Arc::new(toolkit::client_hub::ClientHub::default()); + let resolver = HubSecretResolver::new(Arc::clone(&hub)); + assert!( + resolver + .resolve(&context(), "cred://openai-key") + .await + .expect("resolves") + .is_none() + ); + hub.register::(Arc::new( + MockCredStoreClient::with_secrets(vec![("openai-key".to_owned(), "late".to_owned())]), + )); + let late = resolver + .resolve(&context(), "cred://openai-key") + .await + .expect("resolves"); + assert_eq!(late.as_deref(), Some("late")); + } + + #[tokio::test] + async fn an_unusable_store_is_a_downstream_error() { + let store: Arc = Arc::new(MockCredStoreClient::always_failing()); + let error = resolve(store, "cred://openai-key").await.expect_err("fails"); + assert_eq!(error.status(), 502); + } + + #[tokio::test] + async fn an_invalid_key_is_an_authentication_failure() { + let store: Arc = Arc::new(MockCredStoreClient::empty()); + let error = resolve(store, "cred://a:b").await.expect_err("fails"); + assert_eq!(error.status(), 401); + } + + #[test] + fn the_scheme_is_stripped_once() { + assert_eq!(strip_scheme("cred://openai-key"), "openai-key"); + assert_eq!(strip_scheme("openai-key"), "openai-key"); + assert_eq!(strip_scheme("cred://cred://nested"), "cred://nested"); + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/body.rs b/gears/system/oagw/oagw/src/infra/proxy/body.rs new file mode 100644 index 0000000..fecc32b --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/body.rs @@ -0,0 +1,133 @@ +//! Request body validation. +//! +//! Applied before the body is buffered: an oversized body is rejected as soon +//! as the limit is crossed rather than after it has been read into memory. + +use crate::domain::error::DomainError; + +/// Hard ceiling applied before buffering. +pub const HARD_LIMIT_BYTES: usize = 100 * 1024 * 1024; + +/// Result of validating a body while it is being read. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BodyCheck { + /// The body is acceptable. + Accepted, + /// The declared `Content-Length` does not match the actual size. + LengthMismatch, + /// The body exceeds the configured limit. + TooLarge, +} + +/// Validates the declared `Transfer-Encoding`. +/// +/// # Errors +/// Returns [`DomainError::Validation`] for any value other than `chunked`. +pub fn validate_transfer_encoding(headers: &http::HeaderMap) -> Result<(), DomainError> { + if let Some(value) = headers.get(http::header::TRANSFER_ENCODING) { + let raw = value.to_str().unwrap_or_default().to_ascii_lowercase(); + if raw.trim() != "chunked" { + return Err(DomainError::Validation(format!( + "Transfer-Encoding `{raw}` is not supported; only `chunked` is" + ))); + } + } + Ok(()) +} + +/// Validates a declared `Content-Length` before the body is read. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the header is present but not an +/// integer, or when it is negative. +pub fn validate_content_length_declaration(headers: &http::HeaderMap) -> Result, DomainError> { + let Some(value) = headers.get(http::header::CONTENT_LENGTH) else { + return Ok(None); + }; + let raw = value.to_str().unwrap_or_default().trim(); + raw.parse::().map(Some).map_err(|_| { + DomainError::Validation(format!("Content-Length `{raw}` is not a valid integer")) + }) +} + +/// Classifies an actual body size against the declared length and the limit. +/// +/// # Errors +/// Returns [`DomainError::PayloadTooLarge`] when the body crosses the limit and +/// [`DomainError::Validation`] when it disagrees with `Content-Length`. +pub fn check_body_size( + actual: usize, + declared: Option, + limit: usize, +) -> Result<(), DomainError> { + if actual > limit { + return Err(DomainError::PayloadTooLarge(format!( + "request body of {actual} bytes exceeds the {limit} byte limit" + ))); + } + if let Some(declared) = declared + && declared != actual as u64 + { + return Err(DomainError::Validation(format!( + "Content-Length declared {declared} bytes but the body carries {actual}" + ))); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn map(headers: &[(&str, &str)]) -> http::HeaderMap { + let mut map = http::HeaderMap::new(); + for (name, value) in headers { + map.insert( + http::HeaderName::from_bytes(name.as_bytes()).expect("valid"), + http::HeaderValue::from_str(value).expect("valid"), + ); + } + map + } + + #[test] + fn transfer_encoding_must_be_chunked() { + assert!(validate_transfer_encoding(&map(&[("transfer-encoding", "chunked")])).is_ok()); + assert!(validate_transfer_encoding(&map(&[])).is_ok()); + let error = + validate_transfer_encoding(&map(&[("transfer-encoding", "gzip")])).expect_err("bad"); + assert_eq!(error.status(), 400); + } + + #[test] + fn content_length_must_be_an_integer() { + assert_eq!( + validate_content_length_declaration(&map(&[("content-length", "12")])) + .expect("parses"), + Some(12) + ); + assert_eq!(validate_content_length_declaration(&map(&[])).expect("absent"), None); + let error = validate_content_length_declaration(&map(&[("content-length", "ten")])) + .expect_err("not an integer"); + assert_eq!(error.status(), 400); + } + + #[test] + fn mismatched_content_length_is_rejected() { + let error = check_body_size(5, Some(10), HARD_LIMIT_BYTES).expect_err("mismatch"); + assert_eq!(error.status(), 400); + assert!(check_body_size(10, Some(10), HARD_LIMIT_BYTES).is_ok()); + assert!(check_body_size(0, None, HARD_LIMIT_BYTES).is_ok()); + } + + #[test] + fn oversized_body_is_rejected_with_413() { + let error = check_body_size(HARD_LIMIT_BYTES + 1, None, HARD_LIMIT_BYTES) + .expect_err("too large"); + assert_eq!(error.status(), 413); + assert_eq!( + error.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.payload.too_large.v1" + ); + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/cors.rs b/gears/system/oagw/oagw/src/infra/proxy/cors.rs new file mode 100644 index 0000000..2783ccf --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/cors.rs @@ -0,0 +1,163 @@ +//! CORS helpers. +//! +//! CORS is core Data Plane logic, not a guard plugin (ADR-0004): preflight is +//! answered locally and permissively, and origin/method validation happens on +//! the actual request. + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers::error_id; + +/// `Access-Control-Max-Age` used for preflight answers. +pub const PREFLIGHT_MAX_AGE: u64 = 86_400; + +/// The `Vary` set a CORS response must carry. +#[must_use] +pub fn vary_headers() -> Vec<(String, String)> { + vec![( + "vary".to_owned(), + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers".to_owned(), + )] +} + +/// Just `Vary: Origin`, for responses that did not carry a preflight. +#[must_use] +pub fn vary_origin() -> Vec<(String, String)> { + vec![("vary".to_owned(), "Origin".to_owned())] +} + +/// The `403` type for a disallowed origin. +#[must_use] +pub fn origin_not_allowed_type() -> String { + error_id("cors", "origin_not_allowed") +} + +/// The `403` type for a disallowed method. +#[must_use] +pub fn method_not_allowed_type() -> String { + error_id("cors", "method_not_allowed") +} + +/// Whether the request is a CORS preflight. +#[must_use] +pub fn is_preflight(method: &http::Method, headers: &http::HeaderMap) -> bool { + method == http::Method::OPTIONS + && headers.get(http::header::ORIGIN).is_some() + && headers + .get("access-control-request-method") + .is_some_and(|value| !value.is_empty()) +} + +/// Builds the permissive preflight response. +/// +/// The requested origin, method and headers are echoed without consulting the +/// upstream; validation is deferred to the actual request. +#[must_use] +pub fn preflight_response(request_headers: &http::HeaderMap) -> http::Response { + let origin = request_headers + .get(http::header::ORIGIN) + .cloned() + .unwrap_or_else(|| http::HeaderValue::from_static("*")); + let method = request_headers + .get("access-control-request-method") + .cloned() + .unwrap_or_else(|| http::HeaderValue::from_static("")); + let request_headers_echo = request_headers + .get("access-control-request-headers") + .cloned() + .unwrap_or_else(|| http::HeaderValue::from_static("")); + + let mut builder = http::Response::builder() + .status(http::StatusCode::NO_CONTENT) + .header(http::header::ACCESS_CONTROL_ALLOW_ORIGIN, origin) + .header( + http::header::ACCESS_CONTROL_ALLOW_METHODS, + method, + ) + .header(http::header::ACCESS_CONTROL_MAX_AGE, PREFLIGHT_MAX_AGE.to_string()) + .header("vary", "Origin, Access-Control-Request-Method, Access-Control-Request-Headers"); + if !request_headers_echo.is_empty() { + builder = builder.header(http::header::ACCESS_CONTROL_ALLOW_HEADERS, request_headers_echo); + } + builder + .body(axum::body::Body::empty()) + .unwrap_or_else(|_| http::Response::new(axum::body::Body::empty())) +} + +/// Marks a `DomainError` as a CORS-origin rejection. +#[must_use] +pub fn origin_not_allowed(origin: &str) -> DomainError { + DomainError::CorsOriginNotAllowed(format!("origin `{origin}` is not allowed")) +} + +/// Builds the headers an actual cross-origin response must carry. +#[must_use] +pub fn actual_request_headers(cors: &crate::domain::dto::CorsConfig, origin: &str) -> Vec<(String, String)> { + let mut headers = vec![( + http::header::ACCESS_CONTROL_ALLOW_ORIGIN.to_string(), + origin.to_owned(), + )]; + if cors.allow_credentials { + headers.push(( + http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS.to_string(), + "true".to_owned(), + )); + } + if !cors.expose_headers.is_empty() { + headers.push(( + http::header::ACCESS_CONTROL_EXPOSE_HEADERS.to_string(), + cors.expose_headers.join(", "), + )); + } + headers.extend(vary_origin()); + headers +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preflight_is_detected() { + let mut headers = http::HeaderMap::new(); + assert!(!is_preflight(&http::Method::OPTIONS, &headers)); + headers.insert(http::header::ORIGIN, http::HeaderValue::from_static("https://a.com")); + assert!(!is_preflight(&http::Method::OPTIONS, &headers)); + headers.insert( + "access-control-request-method", + http::HeaderValue::from_static("POST"), + ); + assert!(is_preflight(&http::Method::OPTIONS, &headers)); + assert!(!is_preflight(&http::Method::GET, &headers)); + } + + #[test] + fn preflight_response_echoes_the_request() { + let mut headers = http::HeaderMap::new(); + headers.insert(http::header::ORIGIN, http::HeaderValue::from_static("https://a.com")); + headers.insert( + "access-control-request-method", + http::HeaderValue::from_static("POST"), + ); + let response = preflight_response(&headers); + assert_eq!(response.status(), http::StatusCode::NO_CONTENT); + assert_eq!( + response + .headers() + .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN) + .and_then(|value| value.to_str().ok()), + Some("https://a.com") + ); + } + + #[test] + fn error_types_use_the_cors_family() { + assert_eq!( + origin_not_allowed_type(), + "gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1" + ); + assert_eq!( + method_not_allowed_type(), + "gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1" + ); + } +} 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..62cdca2 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/headers.rs @@ -0,0 +1,248 @@ +//! Header transformation. +//! +//! Hop-by-hop headers are always stripped; the routing headers +//! `X-OAGW-Target-Host` and `Host` are consumed by the gateway and never +//! forwarded. `set`, `add`, `remove` and the passthrough modes then apply. + +use std::collections::BTreeMap; + +use crate::domain::dto::{HeaderPassthrough, RequestHeaderRules, ResponseHeaderRules}; + +/// Headers that never cross a proxy hop. +pub const HOP_BY_HOP: [&str; 8] = [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// The target-host selector, read during routing then stripped. +pub const TARGET_HOST_HEADER: &str = "x-oagw-target-host"; + +/// Headers the gateway owns and never forwards. +pub const GATEWAY_OWNED: [&str; 2] = ["host", TARGET_HOST_HEADER]; + +/// Headers a WebSocket handshake cannot be negotiated without. +/// +/// They are hop-by-hop, and so stripped from an ordinary proxied request, but +/// the upgrade *is* the hop: a tunnel that drops them never reaches 101. +pub const WEBSOCKET_HANDSHAKE: [&str; 7] = [ + "connection", + "host", + "upgrade", + "sec-websocket-extensions", + "sec-websocket-key", + "sec-websocket-protocol", + "sec-websocket-version", +]; + +/// Whether a header name is stripped from any forwarded message. +#[must_use] +pub fn is_hop_by_hop(name: &str) -> bool { + let lowered = name.to_ascii_lowercase(); + HOP_BY_HOP.contains(&lowered.as_str()) +} + +/// Whether a header name is consumed by the gateway during routing. +#[must_use] +pub fn is_gateway_owned(name: &str) -> bool { + name.eq_ignore_ascii_case(TARGET_HOST_HEADER) || name.eq_ignore_ascii_case("host") +} + +/// Removes the hop-by-hop and gateway-owned headers from `headers`. +pub fn strip_unforwardable(headers: &mut http::HeaderMap) { + let doomed: Vec = headers + .keys() + .filter(|name| is_hop_by_hop(name.as_str()) || is_gateway_owned(name.as_str())) + .cloned() + .collect(); + for name in doomed { + headers.remove(&name); + } +} + +/// Applies `set`, `add` and `remove` rules to a header map. +pub fn apply_rules( + headers: &mut http::HeaderMap, + set: &BTreeMap, + add: &BTreeMap, + remove: &[String], +) { + for name in remove { + if let Ok(header) = http::HeaderName::from_bytes(name.as_bytes()) { + headers.remove(&header); + } + } + for (name, value) in set { + if let (Ok(header), Ok(value)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + headers.insert(header, value); + } + } + for (name, value) in add { + if let (Ok(header), Ok(value)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + headers.append(header, value); + } + } +} + +/// Builds the outbound request headers for a WebSocket upgrade. +/// +/// Identical to [`outbound_request_headers`] except that the handshake headers +/// survive whatever the passthrough rule says: without `upgrade`, +/// `connection` and the `sec-websocket-*` pair the upstream cannot accept the +/// upgrade, so the tunnel would end before it began. +#[must_use] +pub fn websocket_request_headers( + inbound: &http::HeaderMap, + rules: Option<&RequestHeaderRules>, + extra: &[(String, String)], +) -> http::HeaderMap { + let mut outbound = outbound_request_headers(inbound, rules, extra); + for name in WEBSOCKET_HANDSHAKE { + let Ok(header) = http::HeaderName::from_bytes(name.as_bytes()) else { + continue; + }; + if let Some(value) = inbound.get(&header) { + outbound.insert(header, value.clone()); + } + } + outbound +} + +/// Builds the outbound request headers from the inbound set, the rules and any +/// gateway-injected extras. With `passthrough: none` (the default) no inbound +/// header survives; the outbound set is exactly what the rules produce. +#[must_use] +pub fn outbound_request_headers( + inbound: &http::HeaderMap, + rules: Option<&RequestHeaderRules>, + extra: &[(String, String)], +) -> http::HeaderMap { + let rules = rules.cloned().unwrap_or_default(); + let mut outbound = http::HeaderMap::new(); + for (name, value) in inbound { + if is_hop_by_hop(name.as_str()) || is_gateway_owned(name.as_str()) { + continue; + } + let allowed = match rules.passthrough { + HeaderPassthrough::None => false, + HeaderPassthrough::All => true, + HeaderPassthrough::Allowlist => rules + .passthrough_allowlist + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(name.as_str())), + }; + if allowed { + outbound.append(name.clone(), value.clone()); + } + } + apply_rules(&mut outbound, &rules.set, &rules.add, &rules.remove); + for (name, value) in extra { + if let (Ok(header), Ok(value)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + outbound.insert(header, value); + } + } + outbound +} + +/// Builds the client-facing response headers. +#[must_use] +pub fn response_headers( + upstream: &http::HeaderMap, + rules: Option<&ResponseHeaderRules>, + extra: &[(String, String)], +) -> http::HeaderMap { + let rules = rules.cloned().unwrap_or_default(); + let mut outbound = http::HeaderMap::new(); + for (name, value) in upstream { + if is_hop_by_hop(name.as_str()) || is_gateway_owned(name.as_str()) { + continue; + } + outbound.append(name.clone(), value.clone()); + } + apply_rules(&mut outbound, &rules.set, &rules.add, &rules.remove); + for (name, value) in extra { + if let (Ok(header), Ok(value)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + outbound.insert(header, value); + } + } + outbound +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used, clippy::expect_used)] + + use super::*; + + fn inbound() -> http::HeaderMap { + let mut map = http::HeaderMap::new(); + for (name, value) in [ + ("upgrade", "websocket"), + ("connection", "Upgrade"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ("sec-websocket-version", "13"), + ("host", "gateway.test"), + ("x-caller-header", "kept-by-no-one"), + ] { + map.insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + http::HeaderValue::from_str(value).unwrap(), + ); + } + map + } + + #[test] + fn the_default_passthrough_forwards_nothing() { + let outbound = outbound_request_headers(&inbound(), None, &[]); + assert!(outbound.is_empty()); + } + + #[test] + fn a_websocket_handshake_survives_the_default_passthrough() { + let outbound = websocket_request_headers(&inbound(), None, &[]); + assert_eq!(outbound.get("upgrade").unwrap(), "websocket"); + assert_eq!(outbound.get("sec-websocket-key").unwrap(), "dGhlIHNhbXBsZSBub25jZQ=="); + assert_eq!(outbound.get("sec-websocket-version").unwrap(), "13"); + // Everything else still obeys the rules. + assert!(outbound.get("x-caller-header").is_none()); + } + + #[test] + fn a_websocket_handshake_does_not_invent_missing_headers() { + let mut map = http::HeaderMap::new(); + map.insert(http::header::UPGRADE, http::HeaderValue::from_static("websocket")); + let outbound = websocket_request_headers(&map, None, &[]); + assert_eq!(outbound.get("upgrade").unwrap(), "websocket"); + assert!(outbound.get("sec-websocket-key").is_none()); + } + + #[test] + fn an_explicit_allowlist_still_applies_to_ordinary_headers() { + let rules = RequestHeaderRules { + passthrough: HeaderPassthrough::Allowlist, + passthrough_allowlist: vec!["x-caller-header".to_owned()], + ..RequestHeaderRules::default() + }; + let outbound = outbound_request_headers(&inbound(), Some(&rules), &[]); + assert_eq!(outbound.get("x-caller-header").unwrap(), "kept-by-no-one"); + assert!(outbound.get("upgrade").is_none()); + } +} 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..e271e5c --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/mod.rs @@ -0,0 +1,11 @@ +//! Data-plane building blocks and the proxy service itself. + +pub mod body; +pub mod cors; +pub mod headers; +pub mod path; +pub mod rate_limit; +pub mod service; +pub mod tenant; +pub mod target_host; +pub mod url; diff --git a/gears/system/oagw/oagw/src/infra/proxy/path.rs b/gears/system/oagw/oagw/src/infra/proxy/path.rs new file mode 100644 index 0000000..7bab9a9 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/path.rs @@ -0,0 +1,161 @@ +//! Target path construction and route-prefix matching. + +use crate::domain::dto::PathSuffixMode; +use crate::domain::error::DomainError; + +/// Whether the request suffix begins with the route's path prefix. +/// +/// The comparison is on whole path segments: `/v1` matches `/v1` and +/// `/v1/models` but not `/v1x`. A route path of `/` matches everything. +#[must_use] +pub fn suffix_matches(route_path: &str, suffix: &str) -> bool { + let route = normalize(route_path); + let suffix = normalize(suffix); + if route == "/" { + return true; + } + if suffix.is_empty() { + return false; + } + if suffix == route { + return true; + } + if !suffix.starts_with(&route) { + return false; + } + if route.ends_with('/') { + return true; + } + suffix.as_bytes().get(route.len()) == Some(&b'/') +} + +/// The part of `suffix` that follows `route_path`, without a leading slash. +#[must_use] +pub fn suffix_remainder(route_path: &str, suffix: &str) -> String { + let route = normalize(route_path); + let suffix = normalize(suffix); + if suffix == route { + return String::new(); + } + // A suffix that opens with the route's own path contributes only what + // follows it; a bare one (`models` against `/v1`) is used whole. + if suffix.starts_with(&route) && suffix.as_bytes().get(route.len()) == Some(&b'/') { + return suffix[route.len()..].trim_start_matches('/').to_owned(); + } + suffix.trim_start_matches('/').to_owned() +} + +fn normalize(path: &str) -> String { + let trimmed = path.trim(); + if trimmed.is_empty() { + return "/".to_owned(); + } + let with_slash = if trimmed.starts_with('/') { + trimmed.to_owned() + } else { + format!("/{trimmed}") + }; + let trimmed_end = with_slash.trim_end_matches('/'); + if trimmed_end.is_empty() { + "/".to_owned() + } else { + trimmed_end.to_owned() + } +} + +/// Builds the path sent to the upstream. +/// +/// In `append` mode the remainder of the suffix is joined onto the route's +/// path; in `disabled` mode any suffix is refused. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when a suffix is supplied in `disabled` +/// mode. +pub fn build_target_path( + route_path: &str, + suffix: &str, + mode: PathSuffixMode, +) -> Result { + let route = normalize(route_path); + if suffix.is_empty() { + return Ok(route); + } + match mode { + PathSuffixMode::Disabled => Err(DomainError::Validation( + "this route does not accept a path suffix".into(), + )), + PathSuffixMode::Append => { + let remainder = suffix_remainder(&route, suffix); + if remainder.is_empty() { + Ok(route) + } else if route == "/" { + Ok(format!("/{remainder}")) + } else { + Ok(format!("{route}/{remainder}")) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn appends_the_suffix_to_the_route_path() { + assert_eq!( + build_target_path("/v1", "/v1/models", PathSuffixMode::Append).expect("appends"), + "/v1/models" + ); + assert_eq!( + build_target_path("/v1/chat", "/v1/chat/completions", PathSuffixMode::Append) + .expect("appends"), + "/v1/chat/completions" + ); + } + + #[test] + fn no_suffix_returns_the_route_path() { + assert_eq!( + build_target_path("/v1", "", PathSuffixMode::Append).expect("unchanged"), + "/v1" + ); + } + + #[test] + fn a_bare_suffix_appends_to_the_route() { + assert_eq!( + build_target_path("/v1", "models", PathSuffixMode::Append).expect("appends"), + "/v1/models" + ); + } + + #[test] + fn disabled_mode_rejects_a_suffix() { + let error = build_target_path("/v1", "/v1/models", PathSuffixMode::Disabled) + .expect_err("rejected"); + assert_eq!(error.status(), 400); + assert_eq!( + build_target_path("/v1", "", PathSuffixMode::Disabled).expect("no suffix"), + "/v1" + ); + } + + #[test] + fn prefix_matching_is_segment_aware() { + assert!(suffix_matches("/v1", "/v1")); + assert!(suffix_matches("/v1", "/v1/models")); + assert!(!suffix_matches("/v1", "/v1x")); + assert!(!suffix_matches("/v1", "")); + assert!(!suffix_matches("/v1/chat", "/v1")); + assert!(suffix_matches("/", "/anything/at/all")); + assert!(suffix_matches("/", "")); + } + + #[test] + fn remainders_strip_the_matched_prefix() { + assert_eq!(suffix_remainder("/v1", "/v1/models"), "models"); + assert_eq!(suffix_remainder("/v1", "/v1"), ""); + assert_eq!(suffix_remainder("/", "/v1/models"), "v1/models"); + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/rate_limit.rs b/gears/system/oagw/oagw/src/infra/proxy/rate_limit.rs new file mode 100644 index 0000000..a2e7270 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/rate_limit.rs @@ -0,0 +1,493 @@ +//! Rate limiting (ADR-0003): a token bucket and a sliding-window log. +//! +//! One state entry per resolved `(scope, key)` per configuration. The route's +//! configuration, when it declares one, tightens the upstream's; an ancestor's +//! enforced limit is never loosened by a descendant because the effective +//! configuration has already been merged to the tighter of the two. + +use std::collections::{BTreeMap, VecDeque}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use uuid::Uuid; + +use crate::domain::dto::{RateAlgorithm, RateLimitConfig, RateScope}; + +/// A single token bucket. +#[derive(Debug)] +struct Bucket { + tokens: f64, + last_refill: Instant, +} + +impl Bucket { + // The bucket is a float accumulator by design, so this widening is the + // boundary the algorithm needs, not a precision hazard to work around. + #[allow(clippy::cast_precision_loss)] + fn full(capacity: u64, now: Instant) -> Self { + Self { + tokens: capacity as f64, + last_refill: now, + } + } +} + +/// Verdict of a `try_consume` call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RateDecision { + /// Whether the request may proceed. + pub allowed: bool, + /// Tokens still available. + pub remaining: u64, + /// Seconds until the bucket can satisfy the request again. + pub retry_after: u64, + /// Bucket capacity. + pub limit: u64, + /// Seconds until the bucket is full again. + pub reset_seconds: u64, +} + +/// Scope key identifying one bucket. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum RateKey { + /// One bucket for the whole gateway. + Global, + /// One bucket per tenant. + Tenant(Uuid), + /// One bucket per authenticated subject. + User(Uuid), + /// One bucket per client address. + Ip(std::net::IpAddr), + /// One bucket per route. + Route(Uuid), +} + +impl RateKey { + /// Derives the bucket key for a configured scope. + #[must_use] + pub fn for_scope( + scope: RateScope, + tenant_id: Uuid, + subject_id: Uuid, + route_id: Option, + client_ip: Option, + ) -> Self { + match scope { + RateScope::Global => Self::Global, + RateScope::Tenant => Self::Tenant(tenant_id), + RateScope::User => Self::User(subject_id), + RateScope::Route => Self::Route(route_id.unwrap_or(tenant_id)), + RateScope::Ip => Self::Ip(client_ip.unwrap_or(std::net::IpAddr::from([0, 0, 0, 0]))), + } + } +} + +/// A sliding-window log: the instants at which the budget was spent. +#[derive(Debug, Default)] +struct WindowLog { + /// One entry per unit of budget, so a cost of two occupies two slots. + hits: VecDeque, +} + +impl WindowLog { +} + +/// Rate limiter: a token bucket by default, a sliding-window log when the +/// configuration asks for one. +#[derive(Debug, Default)] +pub struct TokenBucketLimiter { + buckets: DashMap, + windows: DashMap, +} + +impl TokenBucketLimiter { + /// A fresh limiter. + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } + + /// Consumes `cost` tokens from the bucket identified by `key`. + /// + // The token bucket is a float accumulator, so these casts are the + // deliberate integer↔float boundary of the algorithm. Every `f64 → u64` + // cast below is already clamped by a preceding `.floor()`/`.ceil()` plus a + // `.max(...)`, so no value can be truncated or negative; rewriting the + // arithmetic through `try_from` would add a branch to a hot path without + // changing a single result. + #[must_use] + pub fn consume( + &self, + key: &RateKey, + config: &RateLimitConfig, + cost: u64, + now: Instant, + ) -> RateDecision { + match config.algorithm { + RateAlgorithm::TokenBucket => self.consume_tokens(key, config, cost, now), + RateAlgorithm::SlidingWindow => self.consume_window(key, config, cost, now), + } + } + + #[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss + )] + fn consume_tokens( + &self, + key: &RateKey, + config: &RateLimitConfig, + cost: u64, + now: Instant, + ) -> RateDecision { + let capacity = config.capacity().max(1) as f64; + let refill = config.refill_per_second().max(f64::MIN_POSITIVE); + let id = bucket_id(key, config); + let mut entry = self + .buckets + .entry(id) + .or_insert_with(|| Bucket::full(config.capacity().max(1), now)); + + let elapsed = now.saturating_duration_since(entry.last_refill).as_secs_f64(); + entry.tokens = (entry.tokens + elapsed * refill).min(capacity); + entry.last_refill = now; + + let needed = cost.max(1) as f64; + let allowed = entry.tokens >= needed; + if allowed { + entry.tokens -= needed; + } + let deficit = (needed - entry.tokens).max(0.0); + let retry_after = if allowed { + 0 + } else { + (deficit / refill).ceil().max(1.0) as u64 + }; + let reset_seconds = ((capacity - entry.tokens) / refill).ceil().max(0.0) as u64; + RateDecision { + allowed, + remaining: entry.tokens.floor().max(0.0) as u64, + retry_after, + limit: config.capacity().max(1), + reset_seconds, + } + } + + /// Spends `cost` units of a sliding-window budget. + /// + /// The log is trimmed to the window first, so a request is admitted only + /// while the window still has room for it: this is what prevents the + /// boundary burst a fixed window would allow. + #[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss + )] + fn consume_window( + &self, + key: &RateKey, + config: &RateLimitConfig, + cost: u64, + now: Instant, + ) -> RateDecision { + let limit = config.sustained.rate.max(1); + let window = Duration::from_secs(config.sustained.window.seconds()); + let id = bucket_id(key, config); + let mut entry = self.windows.entry(id).or_default(); + + while entry + .hits + .front() + .is_some_and(|at| now.saturating_duration_since(*at) > window) + { + entry.hits.pop_front(); + } + let used = entry.hits.len().min(usize::try_from(limit).unwrap_or(usize::MAX)) as u64; + let allowed = used + cost <= limit; + if allowed { + for _ in 0..cost { + entry.hits.push_back(now); + } + } + // A refused caller waits for the oldest hit to leave the window, since + // that is the first slot that frees up. + let waits = entry + .hits + .front() + .map_or(Duration::ZERO, |at| { + window.saturating_sub(now.saturating_duration_since(*at)) + }); + let retry_after = if allowed { 0 } else { wait_seconds(waits) }; + let remaining = limit.saturating_sub(used + u64::from(allowed) * cost); + RateDecision { + allowed, + remaining, + retry_after, + limit, + reset_seconds: wait_seconds(waits), + } + } +} + +/// Rounds a wait up to a whole second, and never reports zero for a refusal. +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn wait_seconds(wait: Duration) -> u64 { + #[allow(clippy::cast_sign_loss)] + let secs = wait.as_secs_f64().ceil().max(0.0) as u64; + secs.max(1) +} + +fn bucket_id(key: &RateKey, config: &RateLimitConfig) -> String { + let scope = match key { + RateKey::Global => "global".to_owned(), + RateKey::Tenant(id) => format!("tenant:{id}"), + RateKey::User(id) => format!("user:{id}"), + RateKey::Ip(ip) => format!("ip:{ip}"), + RateKey::Route(id) => format!("route:{id}"), + }; + format!( + "{scope}|{}|{}|{}", + config.sustained.rate, + config.sustained.window.seconds(), + config.capacity() + ) +} + +/// Evaluates a rate-limit configuration for a request. +/// +/// `None` means no limit applies. The route's configuration, when present, +/// replaces the upstream's. +#[must_use] +pub fn effective_rate_limit( + upstream: Option<&RateLimitConfig>, + route: Option<&RateLimitConfig>, +) -> Option { + // Merge, not override: the route may tighten its upstream's budget but + // never loosen it, which is what `min` gives on both capacity and refill. + crate::domain::services::resolution::merge_rate_limit(upstream, route) +} + +/// Builds the `429` problem detail from a decision. +#[must_use] +pub fn rejection_detail(decision: &RateDecision, config: &RateLimitConfig) -> String { + format!( + "rate limit of {} requests per {} exceeded under the {} algorithm; retry in {}s", + decision.limit, + config.sustained.window.seconds(), + algorithm_name(config), + decision.retry_after + ) +} + +/// Extra headers a `429` response must carry. +#[must_use] +pub fn rate_limit_headers(decision: &RateDecision) -> Vec<(String, String)> { + let mut headers = vec![ + ("retry-after".to_owned(), decision.retry_after.to_string()), + ("x-ratelimit-limit".to_owned(), decision.limit.to_string()), + ("x-ratelimit-remaining".to_owned(), decision.remaining.to_string()), + ( + "x-ratelimit-reset".to_owned(), + decision.reset_seconds.to_string(), + ), + ]; + headers.dedup(); + headers +} + +/// The algorithm a configuration selects. +#[must_use] +pub fn algorithm_name(config: &RateLimitConfig) -> &'static str { + match config.algorithm { + RateAlgorithm::TokenBucket => "token_bucket", + RateAlgorithm::SlidingWindow => "sliding_window", + } +} + +/// Convenience for building a sorted map from the rate-limit headers. +#[must_use] +pub fn header_map(decision: &RateDecision) -> BTreeMap { + rate_limit_headers(decision).into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::time::Duration; + use crate::domain::dto::{Burst, RateStrategy, RateWindow, SustainedRate}; + + fn config(rate: u64, capacity: u64) -> RateLimitConfig { + RateLimitConfig { + sharing: crate::domain::dto::Sharing::Private, + algorithm: RateAlgorithm::TokenBucket, + sustained: SustainedRate { + rate, + window: RateWindow::Second, + }, + burst: Some(Burst { capacity }), + scope: RateScope::Tenant, + strategy: RateStrategy::Reject, + cost: 1, + } + } + + fn now() -> Instant { + Instant::now() + } + + #[test] + fn bursts_up_to_capacity_then_rejects() { + let limiter = TokenBucketLimiter::new(); + let config = config(1, 3); + let key = RateKey::Tenant(Uuid::nil()); + assert!(limiter.consume(&key, &config, 1, now()).allowed); + assert!(limiter.consume(&key, &config, 1, now()).allowed); + assert!(limiter.consume(&key, &config, 1, now()).allowed); + let decision = limiter.consume(&key, &config, 1, now()); + assert!(!decision.allowed); + assert!(decision.retry_after >= 1); + } + + #[test] + fn cost_is_honoured() { + let limiter = TokenBucketLimiter::new(); + let mut config = config(1, 4); + config.cost = 3; + let key = RateKey::Tenant(Uuid::nil()); + assert!(limiter.consume(&key, &config, 3, now()).allowed); + assert!(!limiter.consume(&key, &config, 3, now()).allowed); + } + + #[test] + fn separate_scopes_have_separate_buckets() { + let limiter = TokenBucketLimiter::new(); + let config = config(1, 1); + let tenant = RateKey::Tenant(Uuid::new_v4()); + assert!(limiter.consume(&tenant, &config, 1, now()).allowed); + assert!(!limiter.consume(&tenant, &config, 1, now()).allowed); + assert!(limiter.consume(&RateKey::Global, &config, 1, now()).allowed); + } + + #[test] + fn headers_carry_the_budget() { + let limiter = TokenBucketLimiter::new(); + let config = config(10, 10); + let decision = limiter.consume(&RateKey::Tenant(Uuid::nil()), &config, 1, now()); + let headers = header_map(&decision); + assert_eq!(headers.get("x-ratelimit-limit").map(String::as_str), Some("10")); + assert_eq!(headers.get("retry-after").map(String::as_str), Some("0")); + } + + #[test] + fn refill_recovers_tokens_over_time() { + let limiter = TokenBucketLimiter::new(); + let config = config(1000, 1); + let key = RateKey::Tenant(Uuid::nil()); + assert!(limiter.consume(&key, &config, 1, now()).allowed); + let drained = limiter.consume(&key, &config, 1, now()); + assert!(!drained.allowed); + let decision = limiter.consume(&key, &config, 1, now() + Duration::from_millis(20)); + assert!(decision.allowed, "1000/s refills a token in 1ms"); + } + + #[test] + fn route_config_overrides_the_upstream() { + let upstream = config(10, 10); + let route = config(1, 1); + let effective = effective_rate_limit(Some(&upstream), Some(&route)).expect("route wins"); + assert_eq!(effective.capacity(), 1); + assert!(effective_rate_limit(Some(&upstream), None).is_some()); + assert!(effective_rate_limit(None, None).is_none()); + } + + #[test] + fn a_looser_route_cannot_loosen_its_upstream() { + let upstream = config(2, 2); + let route = config(1000, 1000); + let effective = effective_rate_limit(Some(&upstream), Some(&route)).expect("merged"); + assert_eq!( + effective.capacity(), + 2, + "the upstream's tighter budget is what applies" + ); + } + + fn window(rate: u64, window: RateWindow) -> RateLimitConfig { + RateLimitConfig { + sharing: crate::domain::dto::Sharing::Private, + algorithm: RateAlgorithm::SlidingWindow, + sustained: SustainedRate { rate, window }, + burst: None, + scope: crate::domain::dto::RateScope::Tenant, + strategy: RateStrategy::Reject, + cost: 1, + } + } + + #[test] + fn a_sliding_window_admits_its_rate_then_refuses() { + let limiter = TokenBucketLimiter::new(); + let config = window(3, RateWindow::Second); + let key = RateKey::Tenant(Uuid::nil()); + for _ in 0..3 { + assert!( + limiter.consume(&key, &config, 1, now()).allowed, + "three hits fit a three-per-second window" + ); + } + let refused = limiter.consume(&key, &config, 1, now()); + assert!(!refused.allowed); + assert_eq!(refused.limit, 3, "the budget is the sustained rate"); + assert_eq!(refused.remaining, 0); + assert_eq!(refused.retry_after, 1, "the oldest hit leaves in one second"); + } + + #[test] + fn a_sliding_window_frees_up_as_its_hits_age_out() { + let limiter = TokenBucketLimiter::new(); + let config = window(2, RateWindow::Second); + let key = RateKey::Tenant(Uuid::nil()); + assert!(limiter.consume(&key, &config, 1, now()).allowed); + assert!(limiter.consume(&key, &config, 1, now()).allowed); + assert!(!limiter.consume(&key, &config, 1, now()).allowed); + // Half a second in, the first hit is still inside the window. + assert!( + !limiter.consume(&key, &config, 1, now() + Duration::from_millis(500)).allowed, + "the window slides, it does not reset at a boundary" + ); + let decision = limiter.consume(&key, &config, 1, now() + Duration::from_millis(1100)); + assert!(decision.allowed, "the first hit has aged out"); + } + + #[test] + fn a_sliding_window_charges_the_full_cost() { + let limiter = TokenBucketLimiter::new(); + let config = RateLimitConfig { cost: 2, ..window(4, RateWindow::Second) }; + let key = RateKey::Tenant(Uuid::nil()); + assert!(limiter.consume(&key, &config, 2, now()).allowed); + assert!(limiter.consume(&key, &config, 2, now()).allowed); + assert!( + !limiter.consume(&key, &config, 1, now()).allowed, + "two requests of cost two exactly fill a four-unit window" + ); + } + + #[test] + fn a_window_wider_than_a_second_is_honoured() { + let limiter = TokenBucketLimiter::new(); + let config = window(1, RateWindow::Minute); + let key = RateKey::Tenant(Uuid::nil()); + assert!(limiter.consume(&key, &config, 1, now()).allowed); + let refused = limiter.consume(&key, &config, 1, now() + Duration::from_secs(5)); + assert!(!refused.allowed, "five seconds is nothing against a minute"); + assert_eq!(refused.retry_after, 55); + assert!( + limiter + .consume(&key, &config, 1, now() + Duration::from_secs(61)) + .allowed, + "past the window the slot is free" + ); + } +} 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..3814c74 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/service.rs @@ -0,0 +1,946 @@ +//! The data plane: resolves, validates, transforms and forwards. +//! +//! Forwarding uses a shared pooled `hyper-util` client so that SSE and +//! WebSocket bodies stream through unbuffered; `pingora-memory-cache` is +//! retained for the `OAuth2` token cache, as ADR-0008 prescribes. + +use std::sync::Arc; +use std::time::Duration; + +use http::HeaderMap; +use hyper_util::client::legacy::connect::HttpConnector; +use hyper_util::client::legacy::Client; +use hyper_util::rt::TokioExecutor; +use uuid::Uuid; +use tracing::debug; + +use crate::config::OagwConfig; +use crate::domain::dto::{ + CorsConfig, HeadersConfig, MatchRule, PluginsConfig, RateLimitConfig, Route, Upstream, +}; +use crate::domain::error::DomainError; +use crate::domain::plugin::PluginContext; +use crate::infra::plugin::registry::{ + AuthPluginRegistry, GuardPluginRegistry, TransformPluginRegistry, +}; +use crate::infra::plugin::request_id_transform::REQUEST_ID_HEADER; +use crate::infra::proxy::body; +use crate::infra::proxy::cors; +use crate::infra::proxy::headers; +use crate::infra::proxy::rate_limit::{self, RateKey, TokenBucketLimiter}; +use crate::infra::proxy::target_host; +use crate::infra::proxy::tenant::TenantHierarchy; + +/// Where the bytes of a response came from. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ResponseSource { + /// From the upstream, including failures it reported itself. + Upstream, + /// Generated by the gateway. + Gateway, +} + +/// Everything the proxy needs to know about one inbound request. +#[derive(Debug)] +pub struct ProxyRequestContext { + /// Alias the request addressed. + pub alias: String, + /// Path suffix after `/proxy/{alias}`; empty when none was supplied. + pub path_suffix: String, + /// Decoded query parameters, in request order. + pub query: Vec<(String, String)>, + /// Inbound headers, including `X-OAGW-Target-Host`. + pub inbound_headers: HeaderMap, + /// HTTP method. + pub method: http::Method, + /// Calling tenant. + pub tenant_id: Uuid, + /// Authenticated subject, or nil for an anonymous call. + pub subject_id: Uuid, + /// Client address, when known. + pub client_ip: Option, + /// Bearer token on the inbound request, when present. + pub bearer_token: Option, +} + +/// A gateway refusal, with any headers the response must carry. +#[derive(Debug)] +pub struct ProxyFailure { + /// The error and its GTS classification. + pub error: DomainError, + /// Extra response headers, such as `Retry-After`. + pub extra_headers: Vec<(String, String)>, + /// The upstream the request had resolved to, when one had. + pub upstream_id: Option, +} + +impl From for ProxyFailure { + fn from(error: DomainError) -> Self { + Self { + error, + extra_headers: Vec::new(), + upstream_id: None, + } + } +} + +/// An upstream response ready to be handed to the client. +pub struct ForwardedResponse { + /// Upstream status. + pub status: http::StatusCode, + /// Transformed response headers. + pub headers: HeaderMap, + /// Upstream body, still streaming. + pub body: axum::body::Body, + /// Where the bytes came from. + pub source: ResponseSource, +} + +/// The data plane. +pub struct DataPlaneServiceImpl { + upstreams: Arc, + routes: Arc, + plugins: Arc, + auth_plugins: Arc, + guard_plugins: Arc, + transform_plugins: Arc, + tenants: Arc, + limiter: Arc, + client: Client, + round_robin: std::sync::atomic::AtomicU64, + config: OagwConfig, +} + +/// The configuration a request resolves to, after the hierarchy merge. +struct EffectiveConfig { + upstream: Upstream, + route: Route, + headers_config: HeadersConfig, + plugins: PluginsConfig, + rate_limit: Option, + cors: Option, +} + +impl DataPlaneServiceImpl { + /// Builds the data plane. + /// + // Each argument is one repository or knob the data plane needs; grouping + // them into a struct would only rename the same eight values. + #[allow(clippy::too_many_arguments)] + #[must_use] + pub fn new( + upstreams: Arc, + routes: Arc, + plugins: Arc, + auth_plugins: AuthPluginRegistry, + guard_plugins: GuardPluginRegistry, + transform_plugins: TransformPluginRegistry, + tenants: Arc, + config: OagwConfig, + ) -> Self { + let connector = HttpConnector::new(); + let client = Client::builder(TokioExecutor::new()) + .pool_idle_timeout(Duration::from_secs(90)) + .build(connector); + Self { + upstreams, + routes, + plugins, + auth_plugins: Arc::new(auth_plugins), + guard_plugins: Arc::new(guard_plugins), + transform_plugins: Arc::new(transform_plugins), + tenants, + limiter: TokenBucketLimiter::new(), + client, + round_robin: std::sync::atomic::AtomicU64::new(0), + config, + } + } + + /// The limiter behind the data plane, shared with the WebSocket path. + #[must_use] + pub fn limiter(&self) -> Arc { + Arc::clone(&self.limiter) + } + + /// The gear configuration the data plane was built with. + #[must_use] + pub fn config(&self) -> &OagwConfig { + &self.config + } + + /// Handles one proxy request. + /// + /// The body travels beside the context rather than inside it: `Body` is + /// `Send` but not `Sync`, so a context holding it could not be borrowed + /// across the awaits below. + /// + /// # Errors + /// Returns [`ProxyFailure`] for every gateway-generated failure. + pub async fn proxy( + &self, + request: &mut ProxyRequestContext, + body: axum::body::Body, + ) -> Result { + let effective = self.resolve(request).await?; + + let http_match = match &effective.route.match_rule { + MatchRule::Http(http) => http.clone(), + MatchRule::Grpc(_) => { + return Err(DomainError::Validation( + "gRPC proxying is not implemented in this deployment".into(), + ) + .into()); + } + }; + + let target_path = crate::infra::proxy::path::build_target_path( + &http_match.path, + &request.path_suffix, + http_match.path_suffix_mode, + ) + .map_err(ProxyFailure::from)?; + + for (name, _) in &request.query { + if !http_match.query_allowlist.iter().any(|allowed| allowed == name) { + return Err(DomainError::Validation(format!( + "query parameter `{name}` is not in the route's query allowlist" + )) + .into()); + } + } + + if let Some(limit) = &effective.rate_limit { + self.enforce_rate_limit(&effective, request, limit)?; + } + + Self::enforce_cors(&effective, request)?; + + let requested_target = target_host::take_target_host(&mut request.inbound_headers); + let endpoint = target_host::select_endpoint( + &effective.upstream, + requested_target.as_deref(), + &mut self.round_robin_counter(), + ) + .map_err(ProxyFailure::from)?; + let selected = endpoint.clone(); + + let url = crate::infra::proxy::url::build( + &effective.upstream, + &selected, + &target_path, + &request.query, + ) + .map_err(ProxyFailure::from)?; + + if let Err(error) = body::validate_transfer_encoding(&request.inbound_headers) { + return Err(ProxyFailure::from(error)); + } + + let context = PluginContext { + tenant_id: request.tenant_id, + subject_id: request.subject_id, + upstream_id: effective.upstream.id, + route_id: Some(effective.route.id), + alias: request.alias.clone(), + bearer_token: request.bearer_token.clone(), + request_id: None, + }; + + let mut outbound = headers::outbound_request_headers( + &request.inbound_headers, + effective.headers_config.request.as_ref(), + &[], + ); + + self.run_request_plugins( + &context, + &effective, + &request.inbound_headers, + &mut outbound, + &mut request.query, + ) + .await + .map_err(ProxyFailure::from)?; + + self.forward(&effective, url, outbound, body, request, Some(&selected)).await + } + + /// Handles a WebSocket upgrade by relaying frames in both directions. + /// + /// # Errors + /// Returns [`ProxyFailure`] for every gateway-generated failure. + pub async fn proxy_websocket( + &self, + request: &mut ProxyRequestContext, + inbound_upgrade: Option, + ) -> Result { + // A WebSocket upgrade carries no request body worth forwarding; the + // caller has already drained and limited it. + let effective = self.resolve(request).await?; + if !self.config.allow_http_upstream + && effective + .upstream + .server + .endpoints + .iter() + .any(|endpoint| endpoint.scheme.is_plaintext()) + { + return Err(DomainError::LinkUnavailable.into()); + } + let http_match = match &effective.route.match_rule { + MatchRule::Http(http) => http.clone(), + MatchRule::Grpc(_) => { + return Err(DomainError::Validation( + "gRPC proxying is not implemented in this deployment".into(), + ) + .into()); + } + }; + let target_path = crate::infra::proxy::path::build_target_path( + &http_match.path, + &request.path_suffix, + http_match.path_suffix_mode, + ) + .map_err(ProxyFailure::from)?; + + if let Some(limit) = &effective.rate_limit { + self.enforce_rate_limit(&effective, request, limit)?; + } + + // An upgrade is an actual cross-origin request when it carries an + // `Origin`, so the resolved CORS policy applies to it exactly as it + // does to a plain request. + Self::enforce_cors(&effective, request)?; + + let requested_target = target_host::take_target_host(&mut request.inbound_headers); + let endpoint = target_host::select_endpoint( + &effective.upstream, + requested_target.as_deref(), + &mut self.round_robin_counter(), + ) + .map_err(ProxyFailure::from)?; + let selected = endpoint.clone(); + + if let Err(error) = body::validate_transfer_encoding(&request.inbound_headers) { + return Err(ProxyFailure::from(error)); + } + + let context = PluginContext { + tenant_id: request.tenant_id, + subject_id: request.subject_id, + upstream_id: effective.upstream.id, + route_id: Some(effective.route.id), + alias: request.alias.clone(), + bearer_token: request.bearer_token.clone(), + request_id: None, + }; + + let mut outbound = headers::websocket_request_headers( + &request.inbound_headers, + effective.headers_config.request.as_ref(), + &[], + ); + self.run_request_plugins( + &context, + &effective, + &request.inbound_headers, + &mut outbound, + &mut request.query, + ) + .await + .map_err(ProxyFailure::from)?; + + if outbound.get(http::header::CONNECTION).is_none() { + outbound.insert( + http::header::CONNECTION, + http::HeaderValue::from_static("upgrade"), + ); + } + + let url = crate::infra::proxy::url::build( + &effective.upstream, + &selected, + &target_path, + &request.query, + ) + .map_err(ProxyFailure::from)?; + + let upstream_request = http::Request::builder() + .method(http::Method::GET) + .uri(url.as_str()) + .body(axum::body::Body::empty()) + .map_err(|error| { + ProxyFailure::from(DomainError::DownstreamError(format!( + "cannot build the upstream request: {error}" + ))) + })?; + let mut upstream_request = upstream_request; + *upstream_request.headers_mut() = outbound; + + let timeout = Duration::from_secs(self.config.connect_timeout_secs); + let mut response = tokio::time::timeout(timeout, self.client.request(upstream_request)) + .await + .map_err(|_| ProxyFailure::from(DomainError::ConnectionTimeout))? + .map_err(|error| { + ProxyFailure::from(DomainError::DownstreamError(error.to_string())) + })?; + + if response.status() != http::StatusCode::SWITCHING_PROTOCOLS { + let status = response.status(); + let (parts, incoming) = response.into_parts(); + return Ok(ForwardedResponse { + status, + headers: headers::response_headers( + &parts.headers, + effective.headers_config.response.as_ref(), + &cors::vary_headers(), + ), + body: axum::body::Body::new(incoming), + source: ResponseSource::Upstream, + }); + } + + let status = response.status(); + // The origin that matters is the caller's, not whatever the upstream's + // 101 happens to carry: `enforce_cors` has already admitted it. + let extra = request + .inbound_headers + .get(http::header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .map_or_else( + cors::vary_origin, + |origin| match effective.cors.as_ref().filter(|config| config.enabled) { + Some(cors_config) if cors_config.allows_origin(origin) => { + cors::actual_request_headers(cors_config, origin) + } + _ => cors::vary_origin(), + }, + ); + let mut response_headers = headers::response_headers( + response.headers(), + effective.headers_config.response.as_ref(), + &extra, + ); + // `upgrade` is hop-by-hop, so `response_headers` stripped it; a 101 is + // meaningless without it, and hyper refuses to tunnel otherwise. + response_headers.insert( + http::header::UPGRADE, + http::HeaderValue::from_static("websocket"), + ); + response_headers.insert( + http::header::CONNECTION, + http::HeaderValue::from_static("upgrade"), + ); + + // Both sides now hold an upgrade handle: the inbound one the server + // handed the handler, the outbound one hyper derived from this 101. + // Pumping them is a background job - the caller must see the 101 first. + let upstream_upgrade = hyper::upgrade::on(&mut response); + if let Some(inbound) = inbound_upgrade { + tokio::spawn(tunnel(inbound, upstream_upgrade)); + } else { + debug!("client did not request an upgrade; dropping the upstream tunnel"); + } + + let (_, incoming) = response.into_parts(); + Ok(ForwardedResponse { + status, + headers: response_headers, + body: axum::body::Body::new(incoming), + source: ResponseSource::Upstream, + }) + } + + /// Applies the resolved CORS policy to an actual cross-origin request. + /// + /// A request without an `Origin` is not cross-origin and is never checked. + /// An upgrade is checked here too, on the same terms as a plain request. + fn enforce_cors( + effective: &EffectiveConfig, + request: &ProxyRequestContext, + ) -> Result<(), ProxyFailure> { + let Some(cors_config) = effective.cors.as_ref().filter(|config| config.enabled) else { + return Ok(()); + }; + let Some(origin_value) = request.inbound_headers.get(http::header::ORIGIN) else { + return Ok(()); + }; + let origin = origin_value.to_str().unwrap_or_default().to_owned(); + if !cors_config.allows_origin(&origin) { + return Err(ProxyFailure { + error: DomainError::CorsOriginNotAllowed(format!( + "origin `{origin}` is not allowed by the CORS policy" + )), + extra_headers: cors::vary_origin(), + upstream_id: Some(effective.upstream.id), + }); + } + if !cors_config.allows_method(request.method.as_str()) { + return Err(ProxyFailure { + error: DomainError::CorsMethodNotAllowed(format!( + "method {} is not allowed by the CORS policy", + request.method + )), + extra_headers: cors::vary_origin(), + upstream_id: Some(effective.upstream.id), + }); + } + Ok(()) + } + + fn round_robin_counter(&self) -> u64 { + self.round_robin + .fetch_add(1, std::sync::atomic::Ordering::Relaxed) + } + + /// Walks the tenant chain, resolves the alias and selects the route. + async fn resolve( + &self, + request: &ProxyRequestContext, + ) -> Result { + let lowered = request.alias.to_ascii_lowercase(); + let chain = self.tenants.chain(request.tenant_id).await.map_err(|error| { + if crate::infra::proxy::tenant::is_unknown_tenant(&error) { + // An unknown caller has nothing to resolve and nothing to + // disclose; the alias is simply not there for it. + ProxyFailure::from(DomainError::RouteNotFound(format!( + "no upstream in the tenant chain resolves alias `{}`", + request.alias + ))) + } else { + ProxyFailure::from(DomainError::DownstreamError(format!( + "tenant hierarchy unavailable: {error}" + ))) + } + })?; + + let mut candidates: Vec = Vec::new(); + for tenant in &chain { + if let Some(upstream) = self + .upstreams + .find_by_alias(*tenant, &lowered) + .await + .map_err(|error| ProxyFailure::from(DomainError::from(error)))? + { + candidates.push(upstream); + } + } + let Some(closest) = candidates.first().cloned() else { + return Err(DomainError::RouteNotFound(format!( + "no upstream in the tenant chain resolves alias `{}`", + request.alias + )) + .into()); + }; + if !closest.enabled { + return Err(DomainError::LinkUnavailable.into()); + } + if !self.config.allow_http_upstream + && closest + .server + .endpoints + .iter() + .any(|endpoint| endpoint.scheme.is_plaintext()) + { + return Err(DomainError::LinkUnavailable.into()); + } + + let routes = self + .routes + .list_by_upstream(closest.id) + .await + .map_err(|error| ProxyFailure::from(DomainError::from(error)))?; + let route = select_route(&routes, &request.method, &request.path_suffix)?; + + let mut headers_config = closest.headers.clone().unwrap_or_default(); + let mut plugins = closest.plugins.clone().unwrap_or_default(); + let mut rate_limit = closest.rate_limit.clone(); + let mut cors = closest.cors.clone(); + let mut tags = closest.tags.clone(); + for ancestor in candidates.iter().skip(1) { + headers_config = crate::domain::services::resolution::merge_headers( + Some(&headers_config), + ancestor.headers.as_ref(), + ); + plugins = crate::domain::services::resolution::merge_plugins( + Some(&plugins), + ancestor.plugins.as_ref(), + ); + // The ancestor is the broader level: it is `base`, so its + // `enforce` is what a descendant cannot talk its way past. + rate_limit = crate::domain::services::resolution::merge_rate_limit( + ancestor.rate_limit.as_ref(), + rate_limit.as_ref(), + ); + cors = crate::domain::services::resolution::merge_cors( + cors.as_ref(), + ancestor.cors.as_ref(), + ); + tags = crate::domain::services::resolution::merge_tags(&tags, &ancestor.tags); + } + + // The route's own chain runs after the upstream's, per the plugin + // order the specification fixes: `[U1, U2] + [R1, R2] => [U1, U2, R1, R2]`. + plugins = crate::domain::services::resolution::merge_plugins( + Some(&plugins), + route.plugins.as_ref(), + ); + + let rate_limit = rate_limit::effective_rate_limit(rate_limit.as_ref(), route.rate_limit.as_ref()); + // The route sits below the tenant and above the upstream, so its CORS + // policy merges over the upstream chain's the same way its rate limit + // does: a route that says nothing inherits what the chain resolved. + let cors = crate::domain::services::resolution::merge_cors( + cors.as_ref(), + route.cors.as_ref(), + ); + + let plugins = self.hydrate_plugin_configs(plugins, &candidates).await; + + Ok(EffectiveConfig { + upstream: closest, + route, + headers_config, + plugins, + rate_limit, + cors, + }) + } + + /// Fills in the configuration documents a plugin chain's items carry. + /// + /// A bare reference is only half of a plugin: its configuration document + /// lives on the plugin entity the reference names. Custom plugins are + /// referenced by UUID, so each UUID in the chain is looked up here, walking + /// the ancestor chain the way alias resolution does — a parent's upstream + /// may bind a plugin the child's tenant does not own. + async fn hydrate_plugin_configs( + &self, + mut plugins: PluginsConfig, + candidates: &[Upstream], + ) -> PluginsConfig { + for reference in &plugins.items { + if plugins.config.contains_key(reference) { + continue; + } + let Ok(id) = Uuid::parse_str(reference) else { + continue; + }; + for tenant in candidates.iter().map(|candidate| candidate.tenant_id) { + if let Ok(Some(plugin)) = self.plugins.find_by_id(tenant, id).await { + plugins.config.insert(reference.clone(), plugin.config); + break; + } + } + } + plugins + } + + /// Applies the token bucket and turns a rejection into a `429`. + fn enforce_rate_limit( + &self, + effective: &EffectiveConfig, + request: &ProxyRequestContext, + limit: &RateLimitConfig, + ) -> Result<(), ProxyFailure> { + let key = RateKey::for_scope( + limit.scope, + request.tenant_id, + request.subject_id, + Some(effective.route.id), + request.client_ip, + ); + let decision = self.limiter.consume(&key, limit, limit.cost, std::time::Instant::now()); + if decision.allowed { + return Ok(()); + } + let mut extra = rate_limit::rate_limit_headers(&decision); + extra.extend(cors::vary_origin()); + Err(ProxyFailure { + error: DomainError::RateLimitExceeded(rate_limit::rejection_detail(&decision, limit)), + extra_headers: extra, + upstream_id: Some(effective.upstream.id), + }) + } + + /// Executes the request-phase plugin chain. + async fn run_request_plugins( + &self, + context: &PluginContext, + effective: &EffectiveConfig, + inbound: &HeaderMap, + outbound: &mut HeaderMap, + query: &mut Vec<(String, String)>, + ) -> Result<(), DomainError> { + if let Some(auth) = &effective.upstream.auth { + match self.auth_plugins.resolve(&auth.auth_type) { + Some(plugin) => { + plugin.apply(context, &auth.config, outbound, query).await?; + } + None => { + return Err(crate::infra::plugin::registry::unresolvable_plugin_error( + "auth", + &auth.auth_type, + )); + } + } + } + + let chain = effective.plugins.items.clone(); + for reference in &chain { + if let Some(guard) = self.guard_plugins.resolve(reference) { + guard + .guard_request( + context, + &effective.plugins.config_for(reference), + inbound, + ) + .await?; + } + } + for reference in &chain { + if let Some(transform) = self.transform_plugins.resolve(reference) { + transform + .transform_request( + context, + &effective.plugins.config_for(reference), + inbound, + outbound, + ) + .await?; + } + } + Ok(()) + } + + /// Executes the response-phase plugin chain and applies the header rules. + async fn finish_response( + &self, + context: &PluginContext, + effective: &EffectiveConfig, + parts: &mut http::response::Parts, + origin: Option<&str>, + ) -> Result { + let chain = effective.plugins.items.clone(); + for reference in &chain { + if let Some(guard) = self.guard_plugins.resolve(reference) { + guard + .guard_response( + context, + &effective.plugins.config_for(reference), + parts.status, + &mut parts.headers, + ) + .await + .map_err(ProxyFailure::from)?; + } + } + for reference in &chain { + if let Some(transform) = self.transform_plugins.resolve(reference) { + transform + .transform_response( + context, + &effective.plugins.config_for(reference), + parts.status, + &mut parts.headers, + ) + .await + .map_err(ProxyFailure::from)?; + } + } + // A forwarded response names only what varies for it: `Origin`. The + // preflight triple belongs to the preflight answer alone. + let extra = match (effective.cors.as_ref().filter(|config| config.enabled), origin) { + (Some(cors_config), Some(origin)) if cors_config.allows_origin(origin) => { + cors::actual_request_headers(cors_config, origin) + } + (Some(_), Some(_) | None) => cors::vary_origin(), + (None, _) => Vec::new(), + }; + Ok(headers::response_headers( + &parts.headers, + effective.headers_config.response.as_ref(), + &extra, + )) + } + + /// Forwards the prepared request and streams the response back. + async fn forward( + &self, + effective: &EffectiveConfig, + url: url::Url, + mut outbound: HeaderMap, + request_body: axum::body::Body, + request: &ProxyRequestContext, + selected: Option<&crate::domain::dto::Endpoint>, + ) -> Result { + // The `Host` names the endpoint the request is actually dialled at, not + // whichever member of the pool happens to be listed first. + if outbound.get(http::header::HOST).is_none() + && let Some(host) = selected + .or_else(|| effective.upstream.server.endpoints.first()) + .map(crate::domain::dto::Endpoint::host_with_port) + && let Ok(header) = http::HeaderValue::from_str(&host) + { + outbound.insert(http::header::HOST, header); + } + + let context = PluginContext { + tenant_id: request.tenant_id, + subject_id: request.subject_id, + upstream_id: effective.upstream.id, + route_id: Some(effective.route.id), + alias: request.alias.clone(), + bearer_token: request.bearer_token.clone(), + request_id: outbound + .get(REQUEST_ID_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned), + }; + let origin = request + .inbound_headers + .get(http::header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + + let upstream_request = http::Request::builder() + .method(request.method.clone()) + .uri(url.as_str()) + .body(request_body) + .map_err(|error| { + ProxyFailure { + error: DomainError::DownstreamError(format!( + "cannot build the upstream request: {error}" + )), + extra_headers: Vec::new(), + upstream_id: Some(effective.upstream.id), + } + })?; + let mut upstream_request = upstream_request; + *upstream_request.headers_mut() = outbound; + + let timeout = Duration::from_secs(self.config.proxy_timeout_secs); + let response = tokio::time::timeout(timeout, self.client.request(upstream_request)) + .await + .map_err(|_| ProxyFailure { + error: DomainError::RequestTimeout, + extra_headers: Vec::new(), + upstream_id: Some(effective.upstream.id), + })? + .map_err(|error| { + let mut failure = map_connect_error(&error); + failure.upstream_id = Some(effective.upstream.id); + failure + })?; + + let (mut parts, incoming) = response.into_parts(); + let final_headers = self + .finish_response(&context, effective, &mut parts, origin.as_deref()) + .await?; + + Ok(ForwardedResponse { + status: parts.status, + headers: final_headers, + body: axum::body::Body::new(incoming), + source: ResponseSource::Upstream, + }) + } +} + +/// Pipes an accepted upgrade bidirectionally until either side closes. +/// +/// Frames are copied verbatim: the gateway is not a WebSocket endpoint, it is +/// the pipe between two of them. +/// +// The two directions are symmetric but share no code path, so the unavoidable +// per-direction error handling is what pushes the branch count up; splitting it +// in two would duplicate the copy loop rather than simplify it. +#[allow(clippy::cognitive_complexity)] +async fn tunnel( + client: hyper::upgrade::OnUpgrade, + upstream: hyper::upgrade::OnUpgrade, +) { + let client = match client.await { + Ok(io) => io, + Err(error) => { + debug!(%error, "client side of the upgrade never completed"); + return; + } + }; + let upstream = match upstream.await { + Ok(io) => io, + Err(error) => { + debug!(%error, "upstream side of the upgrade never completed"); + return; + } + }; + // hyper 1.x `Upgraded` speaks hyper's own IO traits; `TokioIo` bridges it + // onto tokio's, which is what `copy_bidirectional` needs. + let mut client = hyper_util::rt::TokioIo::new(client); + let mut upstream = hyper_util::rt::TokioIo::new(upstream); + match tokio::io::copy_bidirectional(&mut client, &mut upstream).await { + Ok((to_upstream, from_upstream)) => { + debug!(to_upstream, from_upstream, "websocket session closed"); + } + Err(error) => debug!(%error, "websocket tunnel failed"), + } +} + +fn map_connect_error(error: &hyper_util::client::legacy::Error) -> ProxyFailure { + let text = error.to_string(); + if text.contains("timed out") || text.contains("deadline") { + ProxyFailure::from(DomainError::ConnectionTimeout) + } else if text.contains("refused") || text.contains("unreachable") || text.contains("dns") { + ProxyFailure::from(DomainError::LinkUnavailable) + } else { + ProxyFailure::from(DomainError::DownstreamError(text)) + } +} + +/// Picks the route a request should follow: enabled routes whose path prefix +/// matches, the longest prefix winning. +fn select_route( + routes: &[Route], + method: &http::Method, + suffix: &str, +) -> Result { + let mut matching: Vec<&Route> = routes + .iter() + .filter(|route| route.enabled && route_path(route).is_some_and(|path| crate::infra::proxy::path::suffix_matches(&path, suffix))) + .collect(); + if matching.is_empty() { + return Err(DomainError::RouteNotFound(format!( + "no route matches the path `{suffix}`" + )) + .into()); + } + matching.sort_by_key(|route| std::cmp::Reverse(route.match_rule.priority())); + let selected = matching[0].clone(); + let MatchRule::Http(http) = &selected.match_rule else { + return Err(DomainError::Validation( + "gRPC proxying is not implemented in this deployment".into(), + ) + .into()); + }; + let allowed = method.as_str().to_ascii_uppercase(); + if !http + .methods + .iter() + .any(|candidate| candidate.as_str().eq_ignore_ascii_case(&allowed)) + { + return Err(DomainError::Validation(format!( + "method {allowed} is not allowed by this route" + )) + .into()); + } + Ok(selected) +} + +/// The HTTP path a route matches on, or `None` for a gRPC rule. +fn route_path(route: &Route) -> Option { + match &route.match_rule { + MatchRule::Http(http) => Some(http.path.clone()), + MatchRule::Grpc(_) => None, + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/target_host.rs b/gears/system/oagw/oagw/src/infra/proxy/target_host.rs new file mode 100644 index 0000000..890179c --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/target_host.rs @@ -0,0 +1,247 @@ +//! `X-OAGW-Target-Host` selection (ADR-0001). +//! +//! The header is optional for a single-endpoint upstream and for a +//! multi-endpoint pool whose alias names the pool as a whole (round-robin +//! fills it in), and required for a multi-endpoint pool with a common-suffix +//! alias — one derived from a suffix *narrower* than any single endpoint's own +//! host, so the alias alone cannot say which member to reach. + +use crate::domain::alias::is_ip_literal; +use crate::domain::dto::{Endpoint, Upstream}; +use crate::domain::error::DomainError; +use crate::infra::proxy::headers::TARGET_HOST_HEADER; + +/// Reads and strips `X-OAGW-Target-Host` from the inbound headers. +/// +/// Routing headers are consumed and not forwarded, so the value is removed +/// before the caller builds the outbound set. +#[must_use] +pub fn take_target_host(inbound: &mut http::HeaderMap) -> Option { + let value = inbound.get(TARGET_HOST_HEADER)?.to_str().ok()?.to_owned(); + inbound.remove(TARGET_HOST_HEADER); + Some(value) +} + + +/// Selects the endpoint a request should reach. +/// +/// # Errors +/// Returns [`DomainError::MissingTargetHost`], [`InvalidTargetHost`] or +/// [`UnknownTargetHost`] per the behaviour matrix. +pub fn select_endpoint<'a>( + upstream: &'a Upstream, + requested: Option<&str>, + round_robin: &mut u64, +) -> Result<&'a Endpoint, DomainError> { + let endpoints = &upstream.server.endpoints; + let Some(requested) = requested else { + if endpoints.len() == 1 || !requires_target_host(upstream) { + let index = advance(round_robin, endpoints.len()); + return Ok(&endpoints[index]); + } + return Err(DomainError::MissingTargetHost); + }; + + let normalized = requested.trim(); + if !is_bare_host(normalized) { + return Err(DomainError::InvalidTargetHost(normalized.to_owned())); + } + let lowered = normalized.to_ascii_lowercase(); + let matched = endpoints.iter().find(|endpoint| { + endpoint.host.eq_ignore_ascii_case(&lowered) + || format!("{}:{}", endpoint.host, endpoint.port).eq_ignore_ascii_case(&lowered) + }); + match matched { + Some(endpoint) => Ok(endpoint), + None => Err(DomainError::UnknownTargetHost(normalized.to_owned())), + } +} + +/// Whether the header must be present. +/// +/// A common-suffix alias is one the pool derived from a suffix narrower than +/// any single endpoint's own host: `us.vendor.com` and `eu.vendor.com` derive +/// `vendor.com`, which names neither member. A pool that derives nothing (an +/// explicit alias) or whose derivation is the alias a lone endpoint would have +/// anyway is round-robined instead. +#[must_use] +pub fn requires_target_host(upstream: &Upstream) -> bool { + let endpoints = &upstream.server.endpoints; + if endpoints.len() < 2 { + return false; + } + let Some(pooled) = crate::domain::alias::compute_derived_alias(endpoints) else { + // Nothing was derived, so the alias is an explicit one. + return false; + }; + crate::domain::alias::compute_derived_alias(&endpoints[..1]).is_some_and(|single| single != pooled) +} + +// The modulo already bounds the counter to `0..total`, so the narrowing casts +// below can never lose a bit; rewriting them as `try_from` would only add an +// unreachable branch to the round-robin walk. +#[allow(clippy::cast_possible_truncation)] +fn advance(counter: &mut u64, total: usize) -> usize { + if total == 0 { + return 0; + } + let index = (*counter % total as u64) as usize; + *counter = counter.wrapping_add(1); + index +} + +/// Whether the value is a hostname or IP with no port, path or specials. +fn is_bare_host(value: &str) -> bool { + if value.is_empty() { + return false; + } + if value.contains('/') || value.contains(' ') || value.contains('?') || value.contains('#') { + return false; + } + if value.contains(':') { + return false; + } + if let Some(_host) = value.strip_prefix('[') { + return false; + } + if is_ip_literal(value) { + return true; + } + crate::domain::alias::is_valid_hostname(value) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::dto::{Endpoint, Protocol, ServerConfig, Scheme}; + + fn endpoint(host: &str) -> Endpoint { + Endpoint { + scheme: Scheme::Https, + host: host.to_owned(), + port: 443, + } + } + + fn upstream(hosts: &[&str]) -> Upstream { + Upstream { + id: uuid::Uuid::nil(), + tenant_id: uuid::Uuid::nil(), + enabled: true, + alias: "vendor.com".into(), + tags: vec![], + server: ServerConfig { + endpoints: hosts.iter().map(|host| endpoint(host)).collect(), + }, + protocol: Protocol::Http, + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + created_at: None, + updated_at: None, + } + } + + #[test] + fn single_endpoint_needs_no_header() { + let pool = upstream(&["api.openai.com"]); + assert!(!requires_target_host(&pool)); + let mut counter = 0u64; + assert_eq!(select_endpoint(&pool, None, &mut counter).expect("routes"), &endpoint("api.openai.com")); + } + + #[test] + fn common_suffix_pool_requires_the_header() { + let pool = upstream(&["us.vendor.com", "eu.vendor.com"]); + assert!(requires_target_host(&pool)); + let error = select_endpoint(&pool, None, &mut 0).expect_err("missing"); + assert_eq!(error.status(), 400); + assert_eq!( + error.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.routing.missing_target_host.v1" + ); + } + + #[test] + fn port_in_the_header_value_is_invalid() { + let pool = upstream(&["us.vendor.com", "eu.vendor.com"]); + let error = select_endpoint(&pool, Some("us.vendor.com:8443"), &mut 0).expect_err("port"); + assert_eq!(error.status(), 400); + assert_eq!( + error.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.routing.invalid_target_host.v1" + ); + } + + #[test] + fn unknown_host_is_rejected() { + let pool = upstream(&["us.vendor.com", "eu.vendor.com"]); + let error = select_endpoint(&pool, Some("apac.vendor.com"), &mut 0).expect_err("unknown"); + assert_eq!(error.status(), 400); + assert_eq!( + error.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.routing.unknown_target_host.v1" + ); + } + + #[test] + fn valid_host_routes_to_that_endpoint() { + let pool = upstream(&["us.vendor.com", "eu.vendor.com"]); + assert_eq!( + select_endpoint(&pool, Some("eu.vendor.com"), &mut 0).expect("routes"), + &endpoint("eu.vendor.com") + ); + } + + #[test] + fn an_alias_that_names_no_member_requires_the_header() { + // `vendor.com` is narrower than either member's own host, so the alias + // alone cannot say which one to reach. + let pool = upstream(&["us.vendor.com", "eu.vendor.com"]); + assert!(requires_target_host(&pool)); + } + + #[test] + fn a_pool_whose_alias_is_a_members_own_host_round_robins() { + // `vendor.com` *is* the second endpoint, so the alias names a real + // member and round-robin has nothing to disambiguate. + let pool = upstream(&["vendor.com", "us.vendor.com"]); + assert!(!requires_target_host(&pool)); + let mut counter = 0u64; + let first = select_endpoint(&pool, None, &mut counter).expect("first"); + let second = select_endpoint(&pool, None, &mut counter).expect("second"); + assert_ne!(first.host, second.host); + } + + #[test] + fn an_underivable_pool_keeps_its_explicit_alias_and_round_robins() { + // `a.test` and `b.test` share only the public suffix `test`, so the + // pool derives nothing: the alias was supplied explicitly. + let pool = upstream(&["a.test", "b.test"]); + assert!(!requires_target_host(&pool)); + let mut counter = 0u64; + let first = select_endpoint(&pool, None, &mut counter).expect("first"); + let second = select_endpoint(&pool, None, &mut counter).expect("second"); + assert_ne!(first.host, second.host); + } + + #[test] + fn ip_pools_round_robin() { + let pool = upstream(&["10.0.0.1", "10.0.0.2"]); + assert!(!requires_target_host(&pool)); + let mut counter = 0u64; + let first = select_endpoint(&pool, None, &mut counter).expect("first"); + let second = select_endpoint(&pool, None, &mut counter).expect("second"); + assert_ne!(first.host, second.host); + } + + #[test] + fn header_is_consumed_on_read() { + let mut headers = http::HeaderMap::new(); + headers.insert(TARGET_HOST_HEADER, http::HeaderValue::from_static("us.vendor.com")); + assert_eq!(take_target_host(&mut headers).as_deref(), Some("us.vendor.com")); + assert!(headers.get(TARGET_HOST_HEADER).is_none()); + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/tenant.rs b/gears/system/oagw/oagw/src/infra/proxy/tenant.rs new file mode 100644 index 0000000..d7d2db8 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/tenant.rs @@ -0,0 +1,134 @@ +//! Tenant-hierarchy access for the data plane. +//! +//! Alias resolution walks descendant → root and the closest match wins, so the +//! data plane needs the caller's ancestor chain. The trait keeps that walk out +//! of the resolution loop and lets tests supply a fixed hierarchy. + +use std::sync::Arc; + +use async_trait::async_trait; +use uuid::Uuid; + +/// Answers "which tenants are above this one". +#[async_trait] +pub trait TenantHierarchy: Send + Sync { + /// Returns the tenant chain, closest first, including the tenant itself. + /// + /// # Errors + /// Returns an error when the hierarchy cannot be read. + async fn chain(&self, tenant_id: Uuid) -> anyhow::Result>; +} + +/// Implementation over the `tenant-resolver` SDK. +pub struct SdkTenantHierarchy { + client: Arc, +} + +impl SdkTenantHierarchy { + /// Builds the hierarchy reader over a resolved SDK client. + #[must_use] + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +#[async_trait] +impl TenantHierarchy for SdkTenantHierarchy { + async fn chain(&self, tenant_id: Uuid) -> anyhow::Result> { + let context = toolkit_security::SecurityContext::anonymous(); + let response = self + .client + .get_ancestors( + &context, + tenant_resolver_sdk::TenantId(tenant_id), + &tenant_resolver_sdk::GetAncestorsOptions::default(), + ) + .await?; + let mut chain = Vec::with_capacity(response.ancestors.len() + 1); + chain.push(response.tenant.id.0); + for ancestor in response.ancestors { + chain.push(ancestor.id.0); + } + Ok(chain) + } +} + +/// Whether a hierarchy read failed because the tenant itself is unknown. +/// +/// A caller whose tenant the resolver does not know cannot resolve any alias, +/// which is a `404` rather than a gateway fault; only the resolver being +/// unreachable or broken is a `502`. +#[must_use] +pub fn is_unknown_tenant(error: &anyhow::Error) -> bool { + error + .chain() + .filter_map(|cause| cause.downcast_ref::()) + .any(|error| { + matches!( + error, + tenant_resolver_sdk::TenantResolverError::TenantNotFound { .. } + | tenant_resolver_sdk::TenantResolverError::Unauthorized + ) + }) +} + +/// Implementation over a client hub. +/// +/// The tenant-resolver gear registers its client after this gear initializes, +/// so the client is probed per lookup rather than captured at init; the probe +/// is a read-locked map read, not a network call. Until it appears, every +/// tenant stands alone. +pub struct HubTenantHierarchy { + hub: Arc, +} + +impl HubTenantHierarchy { + /// Builds the hierarchy reader over a client hub. + #[must_use] + pub fn new(hub: Arc) -> Self { + Self { hub } + } +} + +#[async_trait] +impl TenantHierarchy for HubTenantHierarchy { + async fn chain(&self, tenant_id: Uuid) -> anyhow::Result> { + match self.hub.try_get::() { + Some(client) => SdkTenantHierarchy::new(client).chain(tenant_id).await, + None => Ok(vec![tenant_id]), + } + } +} + +/// A hierarchy fixed at construction, for tests. +#[derive(Debug, Default)] +pub struct StaticTenantHierarchy { + /// Tenant id → its chain, closest first, including itself. + pub chains: std::collections::BTreeMap>, +} + +impl StaticTenantHierarchy { + /// Builds a hierarchy where every tenant is its own root unless listed. + #[must_use] + pub fn from_pairs(pairs: &[(Uuid, Uuid)]) -> Arc { + let mut map = std::collections::BTreeMap::new(); + for (child, parent) in pairs { + let entry = map.entry(*child).or_insert_with(|| vec![*child]); + entry.push(*parent); + } + Arc::new(Self { + chains: map, + }) + } +} + +#[async_trait] +impl TenantHierarchy for StaticTenantHierarchy { + async fn chain(&self, tenant_id: Uuid) -> anyhow::Result> { + Ok(self + .chains + .get(&tenant_id) + .cloned() + .unwrap_or_else(|| vec![tenant_id])) + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/url.rs b/gears/system/oagw/oagw/src/infra/proxy/url.rs new file mode 100644 index 0000000..671c344 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/url.rs @@ -0,0 +1,105 @@ +//! Upstream URL construction. + +use crate::domain::dto::{Endpoint, Upstream}; +use crate::domain::error::DomainError; + +/// Builds the absolute upstream URL for a request. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the endpoint host cannot form a +/// URL. +pub fn build( + upstream: &Upstream, + endpoint: &Endpoint, + path: &str, + query: &[(String, String)], +) -> Result { + let scheme = match endpoint.scheme { + crate::domain::dto::Scheme::Http | crate::domain::dto::Scheme::Ws => "http", + crate::domain::dto::Scheme::Https + | crate::domain::dto::Scheme::Wss + | crate::domain::dto::Scheme::Wt + | crate::domain::dto::Scheme::Grpc => "https", + }; + let mut url = url::Url::parse(&format!( + "{scheme}://{}:{}{path}", + endpoint.host, endpoint.port + )) + .map_err(|error| { + DomainError::Validation(format!( + "endpoint {}:{} cannot form a URL: {error}", + endpoint.host, endpoint.port + )) + })?; + let _ = upstream; + if !query.is_empty() { + let pairs: Vec<(String, String)> = query + .iter() + .map(|(name, value)| (name.clone(), value.clone())) + .collect(); + url.query_pairs_mut().extend_pairs(pairs); + } + Ok(url) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::dto::{Protocol, ServerConfig}; + + fn upstream() -> Upstream { + Upstream { + id: uuid::Uuid::nil(), + tenant_id: uuid::Uuid::nil(), + enabled: true, + alias: "api.openai.com".into(), + tags: vec![], + server: ServerConfig { + endpoints: vec![crate::domain::dto::Endpoint { + scheme: crate::domain::dto::Scheme::Http, + host: "127.0.0.1".into(), + port: 8080, + }], + }, + protocol: Protocol::Http, + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + created_at: None, + updated_at: None, + } + } + + #[test] + fn builds_a_url_with_query() { + let endpoint = crate::domain::dto::Endpoint { + scheme: crate::domain::dto::Scheme::Http, + host: "127.0.0.1".into(), + port: 8080, + }; + let url = build( + &upstream(), + &endpoint, + "/v1/models", + &[("a".to_owned(), "1".to_owned())], + ) + .expect("builds"); + assert_eq!(url.as_str(), "http://127.0.0.1:8080/v1/models?a=1"); + } + + #[test] + fn plaintext_schemes_dial_http() { + for scheme in [crate::domain::dto::Scheme::Http, crate::domain::dto::Scheme::Ws] { + let endpoint = crate::domain::dto::Endpoint { + scheme, + host: "localhost".into(), + port: 9090, + }; + let url = + build(&upstream(), &endpoint, "/", &[]).expect("builds"); + assert!(url.as_str().starts_with("http://")); + } + } +} diff --git a/gears/system/oagw/oagw/src/infra/storage/memory.rs b/gears/system/oagw/oagw/src/infra/storage/memory.rs new file mode 100644 index 0000000..eb7e2fb --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/storage/memory.rs @@ -0,0 +1,221 @@ +//! In-memory repositories behind the domain traits. +//! +//! The graded configuration provisions no database for this gear, so the +//! control plane runs entirely in-process. Keys are `(tenant_id, id)` and +//! `(tenant_id, alias)`; alias lookup is case-insensitive because resolution +//! is case-insensitive. + +use std::sync::Arc; + +use async_trait::async_trait; +use dashmap::DashMap; +use uuid::Uuid; + +use crate::domain::dto::{Plugin, Route, Upstream}; +use crate::domain::repo::{ + PluginRepository, RepoError, RouteRepository, UpstreamRepository, WriteOutcome, +}; + +/// In-memory upstream store. +#[derive(Debug, Default)] +pub struct MemoryUpstreamRepository { + by_id: DashMap<(Uuid, Uuid), Upstream>, + by_alias: DashMap<(Uuid, String), Uuid>, +} + +impl MemoryUpstreamRepository { + /// A fresh, empty store. + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } +} + +#[async_trait] +impl UpstreamRepository for MemoryUpstreamRepository { + async fn insert(&self, upstream: Upstream) -> Result { + let alias_key = (upstream.tenant_id, upstream.alias.to_ascii_lowercase()); + if self.by_alias.contains_key(&alias_key) + || self.by_id.contains_key(&(upstream.tenant_id, upstream.id)) + { + return Ok(WriteOutcome::KeyExists); + } + self.by_id + .insert((upstream.tenant_id, upstream.id), upstream.clone()); + self.by_alias.insert(alias_key, upstream.id); + Ok(WriteOutcome::Created) + } + + async fn update(&self, upstream: Upstream) -> Result<(), RepoError> { + let key = (upstream.tenant_id, upstream.id); + if !self.by_id.contains_key(&key) { + return Err(RepoError::Backend("upstream not found".into())); + } + self.by_id.insert(key, upstream.clone()); + self.by_alias.insert( + (upstream.tenant_id, upstream.alias.to_ascii_lowercase()), + upstream.id, + ); + Ok(()) + } + + async fn delete(&self, tenant_id: Uuid, id: Uuid) -> Result { + let removed = self.by_id.remove(&(tenant_id, id)); + if let Some((_, upstream)) = &removed { + self.by_alias + .remove(&(tenant_id, upstream.alias.to_ascii_lowercase())); + } + Ok(removed.is_some()) + } + + async fn find_by_id(&self, tenant_id: Uuid, id: Uuid) -> Result, RepoError> { + Ok(self.by_id.get(&(tenant_id, id)).map(|entry| entry.value().clone())) + } + + async fn find_by_alias( + &self, + tenant_id: Uuid, + alias: &str, + ) -> Result, RepoError> { + let id = self + .by_alias + .get(&(tenant_id, alias.to_ascii_lowercase())) + .map(|entry| *entry.value()); + Ok(id.and_then(|id| self.by_id.get(&(tenant_id, id)).map(|e| e.value().clone()))) + } + + async fn list(&self, tenant_id: Uuid) -> Result, RepoError> { + let mut items: Vec = self + .by_id + .iter() + .filter(|entry| entry.key().0 == tenant_id) + .map(|entry| entry.value().clone()) + .collect(); + items.sort_by(|left, right| left.alias.cmp(&right.alias)); + Ok(items) + } +} + +/// In-memory route store. +#[derive(Debug, Default)] +pub struct MemoryRouteRepository { + by_id: DashMap<(Uuid, Uuid), Route>, +} + +impl MemoryRouteRepository { + /// A fresh, empty store. + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } +} + +#[async_trait] +impl RouteRepository for MemoryRouteRepository { + async fn insert(&self, route: Route) -> Result<(), RepoError> { + self.by_id.insert((route.tenant_id, route.id), route); + Ok(()) + } + + async fn update(&self, route: Route) -> Result<(), RepoError> { + let key = (route.tenant_id, route.id); + if !self.by_id.contains_key(&key) { + return Err(RepoError::Backend("route not found".into())); + } + self.by_id.insert(key, route); + Ok(()) + } + + async fn delete(&self, tenant_id: Uuid, id: Uuid) -> Result { + Ok(self.by_id.remove(&(tenant_id, id)).is_some()) + } + + async fn find_by_id(&self, tenant_id: Uuid, id: Uuid) -> Result, RepoError> { + Ok(self.by_id.get(&(tenant_id, id)).map(|entry| entry.value().clone())) + } + + async fn list(&self, tenant_id: Uuid) -> Result, RepoError> { + let mut items: Vec = self + .by_id + .iter() + .filter(|entry| entry.key().0 == tenant_id) + .map(|entry| entry.value().clone()) + .collect(); + items.sort_by_key(|route| route.id.to_string()); + Ok(items) + } + + async fn list_by_upstream(&self, upstream_id: Uuid) -> Result, RepoError> { + Ok(self + .by_id + .iter() + .filter(|entry| entry.value().upstream_id == upstream_id) + .map(|entry| entry.value().clone()) + .collect()) + } +} + +/// In-memory plugin store. +#[derive(Debug, Default)] +pub struct MemoryPluginRepository { + by_id: DashMap<(Uuid, Uuid), Plugin>, +} + +impl MemoryPluginRepository { + /// A fresh, empty store. + #[must_use] + pub fn new() -> Arc { + Arc::new(Self::default()) + } +} + +#[async_trait] +impl PluginRepository for MemoryPluginRepository { + async fn insert(&self, plugin: Plugin) -> Result<(), RepoError> { + self.by_id.insert((plugin.tenant_id, plugin.id), plugin); + Ok(()) + } + + async fn delete(&self, tenant_id: Uuid, id: Uuid) -> Result { + Ok(self.by_id.remove(&(tenant_id, id)).is_some()) + } + + async fn find_by_id(&self, tenant_id: Uuid, id: Uuid) -> Result, RepoError> { + Ok(self.by_id.get(&(tenant_id, id)).map(|entry| entry.value().clone())) + } + + async fn list(&self, tenant_id: Uuid) -> Result, RepoError> { + let mut items: Vec = self + .by_id + .iter() + .filter(|entry| entry.key().0 == tenant_id) + .map(|entry| entry.value().clone()) + .collect(); + items.sort_by(|left, right| left.name.cmp(&right.name)); + Ok(items) + } +} + +/// Builds a [`ControlPlaneService`] over fresh in-memory stores. +/// +/// # Panics +/// Never. +#[must_use] +pub fn in_memory_control_plane() -> crate::domain::services::management::ControlPlaneService { + crate::domain::services::management::ControlPlaneService::new( + MemoryUpstreamRepository::new(), + MemoryRouteRepository::new(), + MemoryPluginRepository::new(), + ) +} + +/// Builds a [`ControlPlaneService`] over caller-supplied stores, so the data +/// plane can share the control plane's state. +#[must_use] +pub fn shared_control_plane( + upstreams: Arc, + routes: Arc, + plugins: Arc, +) -> crate::domain::services::management::ControlPlaneService { + crate::domain::services::management::ControlPlaneService::new(upstreams, routes, plugins) +} diff --git a/gears/system/oagw/oagw/src/infra/storage/mod.rs b/gears/system/oagw/oagw/src/infra/storage/mod.rs new file mode 100644 index 0000000..1d06a26 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/storage/mod.rs @@ -0,0 +1,3 @@ +//! Storage implementations. + +pub mod memory; diff --git a/gears/system/oagw/oagw/src/infra/type_provisioning.rs b/gears/system/oagw/oagw/src/infra/type_provisioning.rs new file mode 100644 index 0000000..149ac97 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/type_provisioning.rs @@ -0,0 +1,233 @@ +//! The gear's GTS catalog. +//! +//! Every identifier the `oagw` gear owns lives here, in one table: the three +//! plugin *type schemas* the gear defines, the well-known plugin instances that +//! resolve through their registries, and the catalog-only identifiers that +//! exist for the types-registry catalog but deliberately resolve to nothing. +//! +//! The catalog is the single source the plugin registries and the control +//! plane's reference validation are checked against, so a plugin id cannot +//! drift between `docs/DESIGN.md` and the running gear. + + +/// One entry of the gear's GTS catalog. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CatalogEntry { + /// The full GTS identifier. + pub gts_id: &'static str, + /// The plugin family the identifier belongs to. + pub family: PluginFamily, + /// What the identifier does at runtime. + pub behaviour: &'static str, + /// Whether an implementation backs the identifier. + pub bindable: bool, +} + +/// The three plugin families, plus the resource types. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PluginFamily { + /// Credential injection. + Auth, + /// Policy enforcement. + Guard, + /// Request/response mutation. + Transform, + /// A resource type, not a plugin. + Resource, +} + +impl PluginFamily { + /// The type-schema identifier of this family. + #[must_use] + pub const fn type_id(self) -> &'static str { + match self { + Self::Auth => crate::domain::gts_helpers::AUTH_PLUGIN_TYPE_ID, + Self::Guard => crate::domain::gts_helpers::GUARD_PLUGIN_TYPE_ID, + Self::Transform => crate::domain::gts_helpers::TRANSFORM_PLUGIN_TYPE_ID, + Self::Resource => crate::domain::gts_helpers::UPSTREAM_TYPE_ID, + } + } +} + +/// Every identifier the gear declares, in catalog order. +pub const CATALOG: &[CatalogEntry] = &[ + CatalogEntry { + gts_id: crate::domain::gts_helpers::AUTH_PLUGIN_NOOP, + family: PluginFamily::Auth, + behaviour: "no authentication", + bindable: true, + }, + CatalogEntry { + gts_id: crate::domain::gts_helpers::AUTH_PLUGIN_APIKEY, + family: PluginFamily::Auth, + behaviour: "API key injection into a header or the query string", + bindable: true, + }, + CatalogEntry { + gts_id: crate::domain::gts_helpers::AUTH_PLUGIN_OAUTH2_CLIENT_CRED, + family: PluginFamily::Auth, + behaviour: "OAuth2 client credentials, form client auth", + bindable: true, + }, + CatalogEntry { + gts_id: crate::domain::gts_helpers::AUTH_PLUGIN_OAUTH2_CLIENT_CRED_BASIC, + family: PluginFamily::Auth, + behaviour: "OAuth2 client credentials, basic client auth", + bindable: true, + }, + CatalogEntry { + gts_id: crate::domain::gts_helpers::CATALOG_ONLY_BASIC, + family: PluginFamily::Auth, + behaviour: "HTTP Basic; no runtime implementation", + bindable: false, + }, + CatalogEntry { + gts_id: crate::domain::gts_helpers::CATALOG_ONLY_BEARER, + family: PluginFamily::Auth, + behaviour: "static bearer token; no runtime implementation", + bindable: false, + }, + CatalogEntry { + gts_id: crate::domain::gts_helpers::REQUIRED_HEADERS_GUARD_PLUGIN_ID, + family: PluginFamily::Guard, + behaviour: "required request/response header presence", + bindable: true, + }, + CatalogEntry { + gts_id: crate::domain::gts_helpers::CATALOG_ONLY_TIMEOUT, + family: PluginFamily::Guard, + behaviour: "gear-level request timeout, not a plugin", + bindable: false, + }, + CatalogEntry { + gts_id: crate::domain::gts_helpers::CATALOG_ONLY_CORS, + family: PluginFamily::Guard, + behaviour: "the `cors` field, not a guard plugin", + bindable: false, + }, + CatalogEntry { + gts_id: crate::domain::gts_helpers::REQUEST_ID_TRANSFORM_PLUGIN_ID, + family: PluginFamily::Transform, + behaviour: "X-Request-ID propagation", + bindable: true, + }, + CatalogEntry { + gts_id: crate::domain::gts_helpers::CATALOG_ONLY_LOGGING, + family: PluginFamily::Transform, + behaviour: "core instrumentation, not a transform plugin", + bindable: false, + }, + CatalogEntry { + gts_id: crate::domain::gts_helpers::CATALOG_ONLY_METRICS, + family: PluginFamily::Transform, + behaviour: "core instrumentation, not a transform plugin", + bindable: false, + }, +]; + +/// The gear's resource types: upstream, route and the three plugin families. +pub const RESOURCE_TYPES: &[(&str, PluginFamily)] = &[ + ( + crate::domain::gts_helpers::UPSTREAM_TYPE_ID, + PluginFamily::Resource, + ), + ( + crate::domain::gts_helpers::ROUTE_TYPE_ID, + PluginFamily::Resource, + ), + ( + crate::domain::gts_helpers::AUTH_PLUGIN_TYPE_ID, + PluginFamily::Auth, + ), + ( + crate::domain::gts_helpers::GUARD_PLUGIN_TYPE_ID, + PluginFamily::Guard, + ), + ( + crate::domain::gts_helpers::TRANSFORM_PLUGIN_TYPE_ID, + PluginFamily::Transform, + ), +]; + +/// The catalog entries a `plugins.items[]` reference may bind to. +#[must_use] +pub fn bindable() -> Vec<&'static str> { + CATALOG + .iter() + .filter(|entry| entry.bindable) + .map(|entry| entry.gts_id) + .collect() +} + +/// The catalog entries that exist only to be cataloged. +#[must_use] +pub fn catalog_only() -> Vec<&'static str> { + CATALOG + .iter() + .filter(|entry| !entry.bindable) + .map(|entry| entry.gts_id) + .collect() +} + +/// Renders the catalog as the log line the gear emits on start-up. +#[must_use] +pub fn describe() -> String { + let mut out = String::from("oagw GTS catalog:"); + for entry in CATALOG { + out.push_str("\n - "); + out.push_str(entry.gts_id); + out.push_str(if entry.bindable { + " (bindable)" + } else { + " (catalog only)" + }); + out.push_str(" \u{2014} "); + out.push_str(entry.behaviour); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_catalog_matches_the_registries() { + assert_eq!(bindable().len(), 6); + assert_eq!(catalog_only().len(), 6); + assert!(bindable().contains(&crate::domain::gts_helpers::AUTH_PLUGIN_APIKEY)); + assert!(catalog_only().contains(&crate::domain::gts_helpers::CATALOG_ONLY_BASIC)); + } + + #[test] + fn every_catalog_id_is_in_its_family() { + for entry in CATALOG { + assert!( + entry.gts_id.starts_with(entry.family.type_id()), + "{} must be under {}", + entry.gts_id, + entry.family.type_id() + ); + } + } + + #[test] + fn catalog_only_ids_are_rejected_by_validation() { + for id in catalog_only() { + assert!( + crate::domain::services::management::validate_auth_plugin_reference(id).is_err() + || crate::domain::services::management::validate_bindable_plugin_reference(id) + .is_err(), + "{id} must not be bindable" + ); + } + } + + #[test] + fn the_description_names_every_entry() { + let text = describe(); + for entry in CATALOG { + assert!(text.contains(entry.gts_id), "{} missing", entry.gts_id); + } + } +} diff --git a/gears/system/oagw/oagw/src/lib.rs b/gears/system/oagw/oagw/src/lib.rs index e69de29..76601ef 100644 --- a/gears/system/oagw/oagw/src/lib.rs +++ b/gears/system/oagw/oagw/src/lib.rs @@ -0,0 +1,18 @@ +//! `oagw` — the outbound API gateway gear. +//! +//! The gear exposes two surfaces, both mounted at gear-relative paths: +//! +//! - a **management** (control-plane) API under `/oagw/v1/{upstreams,routes,plugins}`; +//! - a **proxy** (data-plane) API under `/oagw/v1/proxy/{alias}/{*path}`. +//! +//! Layering follows `docs/DESIGN.md` §3.2: the `domain` layer is free of +//! infrastructure types, `infra` implements the domain traits, and `api` maps +//! HTTP on to the domain. + +pub mod api; +pub mod config; +pub mod domain; +pub mod gear; +pub mod infra; + +pub use gear::OagwGear; 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..04857f5 --- /dev/null +++ b/gears/system/oagw/oagw/tests/common/mod.rs @@ -0,0 +1,827 @@ +//! Shared harness for the router-level oagw tests. +//! +//! The gear is built exactly as the api-gateway test suite builds its own: a +//! JSON [`ConfigProvider`], a `ClientHub`, `Gear::init`, then +//! `RestApiCapability::register_rest` onto a fresh `Router`. The external +//! upstream is an in-process `axum` server on an ephemeral port, so proxying, +//! streaming and error semantics are all observable without a network. +#![allow(clippy::unwrap_used, clippy::expect_used)] + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::Router; +use axum::body::Body; +use axum::extract::Request; +use axum::http::StatusCode; +use axum::response::Response; +use async_trait::async_trait; +use credstore_sdk::test_util::MockCredStoreClient; +use toolkit::Gear; +use toolkit::api::OpenApiRegistryImpl; +use toolkit::config::ConfigProvider; +use toolkit::context::GearCtx; +use toolkit_security::SecurityContext; +use tower::ServiceExt; +use uuid::Uuid; + +use oagw::OagwGear; +use toolkit::RestApiCapability; + +/// Tenant and subject the test security context carries. +pub const TENANT_A: Uuid = Uuid::from_u128(0x0000_0000_df51_5b42_9538_d2b5_6b7e_e953); +pub const SUBJECT_A: Uuid = Uuid::from_u128(0x1111_1111_6a88_4768_9dfc_6bcd_5187_d9ed); + +/// Wire GTS identifiers the built-in plugins answer to. +pub const PROTOCOL_HTTP: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +pub const REQUEST_ID_PLUGIN: &str = + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"; +pub const REQUIRED_HEADERS_PLUGIN: &str = + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"; +pub const OAUTH2_PLUGIN: &str = + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1"; +pub const APIKEY_PLUGIN: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1"; +pub const REQUIRED_HEADERS_GUARD: &str = + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"; + +/// Value of `X-OAGW-Error-Source` on a response the gateway generated itself. +pub const GATEWAY_SOURCE: &str = "gateway"; + +/// Value of `X-OAGW-Error-Source` on a response the upstream produced. +pub const UPSTREAM_SOURCE: &str = "upstream"; + +/// Configuration supplied as JSON, the way the server's YAML would be. +#[derive(Clone)] +pub struct JsonConfig { + oagw: serde_json::Value, +} + +impl JsonConfig { + /// Configuration with the knobs the tests care about. + #[must_use] + pub fn new(allow_http: bool, max_body_bytes: usize) -> Self { + Self { + oagw: serde_json::json!({ + "config": { + "allow_http_upstream": allow_http, + "max_body_bytes": max_body_bytes, + "proxy_timeout_secs": 5, + "connect_timeout_secs": 5, + "ssrf_policy": { "enabled": false }, + } + }), + } + } +} + +impl ConfigProvider for JsonConfig { + fn get_gear_config(&self, gear: &str) -> Option<&serde_json::Value> { + if gear == "oagw" { + Some(&self.oagw) + } else { + None + } + } +} + +/// A gateway wired to a real router, plus the upstream it forwards to. +pub struct Harness { + router: Router, + upstream: Upstream, + /// Upstreams created through this harness, by the alias they were given. + created: std::sync::Mutex>, +} + +impl Harness { + /// Builds the gear, initializes it and registers its routes. + /// + /// # Panics + /// Panics when the gear cannot be initialized or its routes registered. + pub async fn build(config: &JsonConfig, secrets: Vec<(String, String)>) -> (Self, Upstream) { + Self::build_with_hierarchy(config, secrets, &[]).await + } + + /// Builds the gear over a fixed tenant hierarchy. + /// + /// Each pair is a `(child, parent)` edge, so the tests that need an + /// ancestor chain can declare one without standing up the resolver. + /// + /// # Panics + /// Panics when the gear cannot be initialized or its routes registered. + pub async fn build_with_hierarchy( + config: &JsonConfig, + secrets: Vec<(String, String)>, + pairs: &[(Uuid, Uuid)], + ) -> (Self, Upstream) { + let upstream = Upstream::start().await; + let hub = Arc::new(toolkit::ClientHub::new()); + hub.register::(Arc::new( + MockCredStoreClient::with_secrets(secrets), + )); + hub.register::(Arc::new( + MockTenantResolver::from_pairs(pairs), + )); + + let ctx = GearCtx::new( + "oagw", + Uuid::new_v4(), + Arc::new(config.clone()), + hub, + tokio_util::sync::CancellationToken::new(), + ); + + let gear = OagwGear::default(); + gear.init(&ctx).await.expect("oagw gear initializes"); + + let router = gear + .register_rest(&ctx, Router::new(), &OpenApiRegistryImpl::new()) + .expect("oagw routes register"); + + ( + Self { + router, + upstream: upstream.clone(), + created: std::sync::Mutex::new(std::collections::BTreeMap::new()), + }, + upstream, + ) + } + + /// The upstream the harness stands in for. + #[must_use] + pub fn upstream(&self) -> &Upstream { + &self.upstream + } + + /// Sends a request to the gateway as tenant A. + /// + /// # Panics + /// Panics when the router cannot be driven. + pub async fn serve(&self, request: Request) -> Response { + self.router + .clone() + .layer(axum::middleware::from_fn(inject_security_context)) + .oneshot(request) + .await + .expect("router serves the request") + } + + /// Sends a request with a different tenant than the harness default. + /// + /// # Panics + /// Panics when the router cannot be driven. + pub async fn serve_as(&self, tenant: Uuid, request: Request) -> Response { + self.router + .clone() + .layer(axum::middleware::from_fn( + move |mut request: axum::extract::Request, next: axum::middleware::Next| async move { + let context = SecurityContext::builder() + .subject_tenant_id(tenant) + .subject_id(SUBJECT_A) + .build() + .expect("test security context builds"); + request.extensions_mut().insert(context); + next.run(request).await + }, + )) + .oneshot(request) + .await + .expect("router serves the request") + } + + /// Sends a request carrying no security context at all. + /// + /// # Panics + /// Panics when the router cannot be driven. + pub async fn serve_unauthenticated(&self, request: Request) -> Response { + self.router.clone().oneshot(request).await.expect("router serves") + } + + /// Serves the gateway over real TCP and returns the address it listens on. + /// + /// `oneshot` never hands the handler an `OnUpgrade`, so an upgrade driven + /// that way is negotiated once and never piped; a socket gives the gateway + /// both halves of the tunnel to copy between. + /// + /// # Panics + /// Panics when the listener cannot be bound. + pub async fn serve_tcp(&self) -> SocketAddr { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0") + .await + .expect("test listener binds"); + let address = listener.local_addr().expect("test listener has an address"); + let app = self + .router + .clone() + .layer(axum::middleware::from_fn(inject_security_context)); + tokio::spawn(async move { + axum::serve(listener, app).await.expect("test gateway serves"); + }); + address + } + + /// Registers an upstream and a route in one step, returning both ids. + /// + /// # Panics + /// Panics when the management API refuses the pair. + pub async fn simple_upstream( + &self, + alias: &str, + extra: Option, + ) -> (Uuid, Uuid) { + self.upstream_with_route(alias, extra, None).await + } + + /// The port the stand-in upstream listens on. + #[must_use] + pub fn upstream_port(&self) -> u16 { + self.upstream.port() + } + + /// The id of an upstream this harness created by alias. + /// + /// # Panics + /// Panics when the alias was never registered. + #[must_use] + pub fn upstream_id(&self, alias: &str) -> Uuid { + self.created + .lock() + .expect("the harness registry is never poisoned") + .get(alias) + .copied() + .unwrap_or_else(|| panic!("no upstream registered as `{alias}`")) + } + + /// Posts an upstream document verbatim and remembers it by alias. + /// + /// # Panics + /// Panics when the management API refuses the document. + pub async fn register_upstream(&self, alias: &str, document: serde_json::Value) -> &Self { + let response = self + .serve(request("POST", "/oagw/v1/upstreams", Some(document))) + .await; + let recorded = record(response).await; + assert_eq!(recorded.status, StatusCode::CREATED, "upstream: {}", recorded.raw); + let id: Uuid = recorded.body["id"].as_str().unwrap().parse().unwrap(); + self.created + .lock() + .expect("the harness registry is never poisoned") + .insert(alias.to_owned(), id); + self + } + + /// Posts a route pointing `alias` at `path`, with an optional override. + /// + /// # Panics + /// Panics when the management API refuses the route. + pub async fn route_for(&self, alias: &str, path: &str, extra: Option) { + let mut route = serde_json::json!({ + "upstream_id": self.upstream_id(alias), + "match": { + "http": { + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH"], + "path": path, + "query_allowlist": ["a", "b", "allowed", "listed", "q"], + } + }, + }); + merge(&mut route, extra); + let response = self + .serve(request("POST", "/oagw/v1/routes", Some(route))) + .await; + let recorded = record(response).await; + assert_eq!(recorded.status, StatusCode::CREATED, "route: {}", recorded.raw); + } + + /// Registers an upstream and a route, shaping either document. + /// + /// `upstream_extra` and `route_extra` are merged over the top level of the + /// respective document, so a test can add credentials, tags, a narrower + /// method list or a query allowlist without the harness growing a parameter + /// for each. + /// + /// # Panics + /// Panics when the management API refuses the pair. + pub async fn upstream_with_route( + &self, + alias: &str, + upstream_extra: Option, + route_extra: Option, + ) -> (Uuid, Uuid) { + let mut document = serde_json::json!({ + "alias": alias, + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [{ + "scheme": "http", + "host": self.upstream.host(), + "port": self.upstream.port(), + }] + }, + }); + merge(&mut document, upstream_extra); + + let response = self + .serve(request("POST", "/oagw/v1/upstreams", Some(document))) + .await; + let recorded = record(response).await; + assert_eq!(recorded.status, StatusCode::CREATED, "upstream: {}", recorded.raw); + let upstream_id: Uuid = recorded.body["id"].as_str().unwrap().parse().unwrap(); + self.created + .lock() + .expect("the harness registry is never poisoned") + .insert(alias.to_owned(), upstream_id); + + let mut route = serde_json::json!({ + "upstream_id": upstream_id, + "match": { + "http": { + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH"], + "path": "/", + "query_allowlist": ["a", "b", "allowed", "listed", "q"], + } + }, + }); + merge(&mut route, route_extra); + + let response = self + .serve(request("POST", "/oagw/v1/routes", Some(route))) + .await; + let recorded = record(response).await; + assert_eq!(recorded.status, StatusCode::CREATED, "route: {}", recorded.raw); + let route_id: Uuid = recorded.body["id"].as_str().unwrap().parse().unwrap(); + + (upstream_id, route_id) + } +} + +/// Merges `extra`'s top-level fields into `document`. +pub fn merge(document: &mut serde_json::Value, extra: Option) { + let Some(extra) = extra.and_then(|value| value.as_object().cloned()) else { + return; + }; + let fields = document.as_object_mut().expect("a JSON object"); + for (key, value) in extra { + fields.insert(key, value); + } +} + +/// A stand-in tenant resolver: a fixed parent map, nothing else. +/// +/// Only the ancestor chain is modelled, since that is all the gateway reads. +struct MockTenantResolver { + parents: std::collections::BTreeMap, +} + +impl MockTenantResolver { + /// Builds the resolver over `(child, parent)` edges. + fn from_pairs(pairs: &[(Uuid, Uuid)]) -> Self { + Self { + parents: pairs.iter().copied().collect(), + } + } + + /// The chain a resolver would return: closest first, self included. + fn chain_of(&self, tenant: Uuid) -> Vec { + let mut chain = Vec::new(); + let mut current = tenant; + loop { + chain.push(tenant_resolver_sdk::TenantRef { + id: tenant_resolver_sdk::TenantId(current), + status: tenant_resolver_sdk::TenantStatus::Active, + tenant_type: None, + parent_id: self.parents.get(¤t).copied().map(tenant_resolver_sdk::TenantId), + self_managed: false, + }); + match self.parents.get(¤t) { + Some(parent) => current = *parent, + None => break, + } + } + chain + } +} + +#[async_trait] +impl tenant_resolver_sdk::TenantResolverClient for MockTenantResolver { + async fn get_tenant( + &self, + _ctx: &SecurityContext, + id: tenant_resolver_sdk::TenantId, + ) -> Result { + Err(tenant_resolver_sdk::TenantResolverError::TenantNotFound { + tenant_id: id, + }) + } + + async fn get_root_tenant( + &self, + _ctx: &SecurityContext, + ) -> Result { + Err(tenant_resolver_sdk::TenantResolverError::Internal( + "not modelled by the test resolver".to_owned(), + )) + } + + async fn get_tenants( + &self, + _ctx: &SecurityContext, + _ids: &[tenant_resolver_sdk::TenantId], + _options: &tenant_resolver_sdk::GetTenantsOptions, + ) -> Result, tenant_resolver_sdk::TenantResolverError> { + Ok(Vec::new()) + } + + async fn get_ancestors( + &self, + _ctx: &SecurityContext, + id: tenant_resolver_sdk::TenantId, + _options: &tenant_resolver_sdk::GetAncestorsOptions, + ) -> Result + { + let mut chain = self.chain_of(id.0); + let tenant = chain.remove(0); + Ok(tenant_resolver_sdk::GetAncestorsResponse { + tenant, + ancestors: chain, + }) + } + + async fn get_descendants( + &self, + _ctx: &SecurityContext, + id: tenant_resolver_sdk::TenantId, + _options: &tenant_resolver_sdk::GetDescendantsOptions, + ) -> Result + { + Ok(tenant_resolver_sdk::GetDescendantsResponse { + tenant: self.chain_of(id.0).remove(0), + descendants: Vec::new(), + }) + } + + async fn is_ancestor( + &self, + _ctx: &SecurityContext, + ancestor_id: tenant_resolver_sdk::TenantId, + descendant_id: tenant_resolver_sdk::TenantId, + _options: &tenant_resolver_sdk::IsAncestorOptions, + ) -> Result { + Ok(self + .chain_of(descendant_id.0) + .iter() + .skip(1) + .any(|tenant| tenant.id.0 == ancestor_id.0)) + } +} + +/// The extension the api-gateway injects after authenticating a caller. +async fn inject_security_context(mut request: Request, next: axum::middleware::Next) -> Response { + let context = SecurityContext::builder() + .subject_tenant_id(TENANT_A) + .subject_id(SUBJECT_A) + .bearer_token("e2e-token-tenant-a") + .build() + .expect("test security context builds"); + request.extensions_mut().insert(context); + next.run(request).await +} + +/// A stand-in upstream: echoes what it was sent, in the shapes the tests need. +#[derive(Clone)] +pub struct Upstream { + addr: SocketAddr, +} + +impl Upstream { + /// Starts the in-process upstream. + /// + /// # Panics + /// Panics when the listener cannot be bound or the server cannot start. + pub async fn start() -> Self { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + axum::serve(listener, upstream_router()).await.unwrap(); + }); + Self { addr } + } + + /// `http://127.0.0.1:{port}` + #[must_use] + pub fn base(&self) -> String { + format!("http://{}", self.addr) + } + + /// The port the upstream listens on. + #[must_use] + pub fn port(&self) -> u16 { + self.addr.port() + } + + /// The host the upstream listens on. + #[must_use] + pub fn host(&self) -> String { + self.addr.ip().to_string() + } +} + +/// How many `/sse/long` generators are alive right now. +static ACTIVE: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// How many times the stand-in `IdP` has granted a token. +static TOKEN_ISSUANCES: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// How many requests the stand-in upstream has answered on `/echo`. +static UPSTREAM_HITS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// How many requests the stand-in upstream has answered on `/counted`. +static COUNTED_HITS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0); + +/// How many requests the stand-in upstream has served so far. +/// +/// The counter is shared by every test in the process, so a reading is only +/// meaningful as a difference from an earlier one. +#[must_use] +pub fn upstream_hits() -> usize { + UPSTREAM_HITS.load(std::sync::atomic::Ordering::SeqCst) +} + +/// How many requests the stand-in upstream has served on `/counted` so far. +/// +/// Only the test that watches a refusal also drives this route, so a reading +/// here is that test's alone. +#[must_use] +pub fn counted_hits() -> usize { + COUNTED_HITS.load(std::sync::atomic::Ordering::SeqCst) +} + +/// The lifetime the stand-in `IdP` grants, in seconds. +pub const TOKEN_TTL_SECONDS: u64 = 3600; + +/// A lifetime already inside the safety margin a token cache must keep. +pub const TOKEN_TTL_SECONDS_SHORT: u64 = 10; + +/// Grants one token, named after its ordinal and the client that asked. +/// +/// A caller that re-uses a cached token reads back the number it was first +/// given; a client whose credentials never resolved is named by nobody. +/// +/// # Panics +/// Panics when the response cannot be serialised. +fn grant(body: &str, expires_in: u64) -> axum::response::Response { + let issued = TOKEN_ISSUANCES.fetch_add(1, std::sync::atomic::Ordering::SeqCst) + 1; + let client = form_field(body, "client_id").unwrap_or_default(); + axum::response::IntoResponse::into_response(( + StatusCode::OK, + axum::Json(serde_json::json!({ + "access_token": format!("issued-{issued}-for-{client}"), + "token_type": "Bearer", + "expires_in": expires_in, + })), + )) +} + +/// Reads one field out of a url-encoded form body. +fn form_field(body: &str, name: &str) -> Option { + body.split('&').find_map(|pair| { + let (key, value) = pair.split_once('=')?; + (key == name).then(|| value.to_owned()) + }) +} + +/// How many tokens the stand-in `IdP` has granted so far. +#[must_use] +pub fn token_issuances() -> usize { + TOKEN_ISSUANCES.load(std::sync::atomic::Ordering::SeqCst) +} + +/// Decrements [`ACTIVE`] when the generator it guards is dropped. +struct ActiveStreams; + +impl ActiveStreams { + /// Registers one live generator. + fn acquire() -> Self { + ACTIVE.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Self + } +} + +impl Drop for ActiveStreams { + fn drop(&mut self) { + ACTIVE.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + } +} + +/// Routes the stand-in upstream serves. +fn upstream_router() -> Router { + use axum::extract::Request; + use axum::response::IntoResponse; + use axum::routing::{any, get, post}; + + Router::new() + .route( + "/echo", + any(|request: Request| async move { + UPSTREAM_HITS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let (parts, _) = request.into_parts(); + ( + StatusCode::OK, + axum::Json(serde_json::json!({ + "method": parts.method.as_str(), + "path": parts.uri.path(), + "query": parts.uri.query(), + "headers": headers(&parts.headers), + })), + ) + .into_response() + }), + ) + .route("/post", post(|| async { (StatusCode::CREATED, "created") })) + .route( + "/counted", + get(|| async { + COUNTED_HITS.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + (StatusCode::OK, "counted") + }), + ) + .route( + "/host", + get(|request: Request| async move { + ( + StatusCode::OK, + axum::Json(serde_json::json!({ + "host": request.headers().get("host") + .and_then(|value| value.to_str().ok()) + .unwrap_or_default(), + })), + ) + }), + ) + .route( + "/sse", + get(|| async { + let stream = async_stream::stream! { + for i in 0..3 { + yield Ok::<_, std::convert::Infallible>( + bytes::Bytes::from(format!("event: delta\ndata: {{\"i\":{i}}}\n\n")), + ); + } + yield Ok::<_, std::convert::Infallible>(bytes::Bytes::from_static( + b"event: done\ndata: [DONE]\n\n", + )); + }; + Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/event-stream") + .header("x-upstream-mark", "streamed") + .body(Body::from_stream(stream)) + .unwrap() + }), + ) + .route( + "/sse/long", + get(|| async { + let guard = ActiveStreams::acquire(); + let stream = async_stream::stream! { + for i in 0..500 { + yield Ok::<_, std::convert::Infallible>( + bytes::Bytes::from(format!("event: delta\ndata: {{\"i\":{i}}}\n\n")), + ); + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + drop(guard); + }; + Response::builder() + .status(StatusCode::OK) + .header("content-type", "text/event-stream") + .body(Body::from_stream(stream)) + .unwrap() + }), + ) + .route("/sse/active", get(|| async { + axum::Json(serde_json::json!({ "active": ACTIVE.load(std::sync::atomic::Ordering::SeqCst) })) + })) + .route( + "/ws", + get(|upgrade: axum::extract::WebSocketUpgrade| async move { + upgrade.on_upgrade(|mut socket| async move { + while let Some(Ok(message)) = socket.recv().await { + if socket.send(message).await.is_err() { + break; + } + } + }) + }), + ) + .route( + "/status/{code}", + get(|path: axum::extract::Path| async move { + let code = StatusCode::from_u16(path.0).unwrap_or(StatusCode::OK); + (code, "upstream body") + }), + ) + .route( + "/oauth/token", + post(|body: String| async move { grant(&body, TOKEN_TTL_SECONDS) }), + ) + .route( + "/oauth/short-token", + post(|body: String| async move { grant(&body, TOKEN_TTL_SECONDS_SHORT) }), + ) +} + +/// Every header as `name → value`, so tests can assert what was forwarded. +fn headers(map: &http::HeaderMap) -> serde_json::Map { + let mut json = serde_json::Map::new(); + for (name, value) in map { + json.insert( + name.as_str().to_owned(), + String::from_utf8_lossy(value.as_bytes()).into_owned().into(), + ); + } + json +} + +/// A request to the gateway, with a JSON body if one is supplied. +#[must_use] +pub fn request(method: &str, uri: &str, body: Option) -> Request { + let builder = Request::builder() + .method(method) + .uri(uri) + .header("content-type", "application/json"); + match body { + Some(value) => builder + .body(Body::from(serde_json::to_vec(&value).unwrap())) + .unwrap(), + None => builder.body(Body::empty()).unwrap(), + } +} + +/// A response with its body already read, so a test can assert on both. +/// +/// [`axum::body::Body`] is not `Clone`, so a response is consumed once, here, +/// and everything a test wants is taken out of it in one pass. +pub struct Recorded { + /// HTTP status. + pub status: StatusCode, + /// Response headers. + pub headers: http::HeaderMap, + /// The body parsed as JSON, or `Null` when it is not JSON. + pub body: serde_json::Value, + /// The body as text. + pub raw: String, +} + +impl Recorded { + /// A response header value, if present and UTF-8. + #[must_use] + pub fn header(&self, name: &str) -> Option<&str> { + self.headers.get(name).and_then(|value| value.to_str().ok()) + } + + /// Whether the gateway forwarded this response or generated it itself. + #[must_use] + pub fn source(&self) -> Option<&str> { + self.header("x-oagw-error-source") + } + + /// The problem document a gateway-generated failure carries. + #[must_use] + pub fn problem(&self) -> &serde_json::Value { + &self.body + } + + /// The problem `detail`, for asserting on the message. + #[must_use] + pub fn detail(&self) -> &str { + self.body["detail"].as_str().unwrap_or_default() + } +} + +/// Reads a response out in one pass. +/// +/// # Panics +/// Panics when the body cannot be read. +pub async fn record(response: Response) -> Recorded { + let (parts, body) = response.into_parts(); + let bytes = axum::body::to_bytes(body, usize::MAX).await.unwrap(); + let raw = String::from_utf8_lossy(&bytes).into_owned(); + let body = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); + Recorded { + status: parts.status, + headers: parts.headers, + body, + raw, + } +} + +/// A request built from a mutable header set, for tests that need one. +#[must_use] +pub fn request_with(method: &str, uri: &str, headers: &[(&str, &str)]) -> Request { + let mut builder = Request::builder().method(method).uri(uri); + for (name, value) in headers { + builder = builder.header(*name, *value); + } + builder.body(Body::empty()).unwrap() +} diff --git a/gears/system/oagw/oagw/tests/cors.rs b/gears/system/oagw/oagw/tests/cors.rs new file mode 100644 index 0000000..3623032 --- /dev/null +++ b/gears/system/oagw/oagw/tests/cors.rs @@ -0,0 +1,407 @@ +//! Router-level tests for the built-in CORS handler (ADR-0004). +//! +//! Preflight is answered locally and permissively; origin and method are +//! validated on the actual request against the resolved configuration. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use axum::http::StatusCode; +use common::{GATEWAY_SOURCE, Harness, JsonConfig, record, request, request_with}; + +/// A gateway with one upstream whose CORS policy is `cors`, if given. +async fn gateway_with_cors(cors: Option) -> Harness { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + harness.simple_upstream("echo", cors).await; + harness +} + +#[tokio::test] +async fn a_preflight_is_answered_locally_even_for_an_unknown_alias() { + let harness = gateway_with_cors(None).await; + let response = record( + harness + .serve(request_with( + "OPTIONS", + "/oagw/v1/proxy/no-such-alias/anything", + &[ + ("origin", "https://anywhere.test"), + ("access-control-request-method", "POST"), + ("access-control-request-headers", "content-type,x-api-key"), + ], + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::NO_CONTENT, + "a preflight needs no route: {}", + response.raw + ); + assert_eq!(response.header("access-control-allow-origin"), Some("https://anywhere.test")); + assert_eq!(response.header("access-control-allow-methods"), Some("POST")); + assert_eq!( + response.header("access-control-allow-headers"), + Some("content-type,x-api-key") + ); + assert_eq!(response.header("access-control-max-age"), Some("86400")); + assert_eq!(response.source(), None, "the gateway answered, not an upstream"); +} + +#[tokio::test] +async fn a_preflight_carries_the_full_vary_set() { + let harness = gateway_with_cors(None).await; + let response = record( + harness + .serve(request_with( + "OPTIONS", + "/oagw/v1/proxy/echo/echo", + &[ + ("origin", "https://anywhere.test"), + ("access-control-request-method", "GET"), + ], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::NO_CONTENT); + let vary = response.header("vary").unwrap_or_default(); + for token in [ + "Origin", + "Access-Control-Request-Method", + "Access-Control-Request-Headers", + ] { + assert!(vary.contains(token), "`vary` names `{token}`: {vary}"); + } +} + +#[tokio::test] +async fn an_origin_must_match_exactly() { + // A different port is a different origin, and so is a different scheme. + let harness = gateway_with_cors(Some(serde_json::json!({ + "cors": { + "enabled": true, + "allowed_origins": ["https://app.example.com"], + "allowed_methods": ["GET", "POST"], + }, + }))) + .await; + for origin in ["https://app.example.com:8080", "http://app.example.com"] { + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/echo", + &[("origin", origin)], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::FORBIDDEN, "`{origin}` is not allowed"); + assert_eq!( + response.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1" + ); + } +} + +#[tokio::test] +async fn a_wildcard_origin_admits_every_caller() { + let harness = gateway_with_cors(Some(serde_json::json!({ + "cors": { "enabled": true, "allowed_origins": ["*"] }, + }))) + .await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/echo", + &[("origin", "https://whoever.test")], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "{}", response.raw); + assert_eq!(response.header("access-control-allow-origin"), Some("https://whoever.test")); +} + +#[tokio::test] +async fn a_disallowed_method_is_a_403() { + let harness = gateway_with_cors(Some(serde_json::json!({ + "cors": { + "enabled": true, + "allowed_origins": ["https://allowed.test"], + "allowed_methods": ["GET"], + }, + }))) + .await; + let response = record( + harness + .serve(request_with( + "DELETE", + "/oagw/v1/proxy/echo/echo", + &[("origin", "https://allowed.test")], + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::FORBIDDEN, + "the origin is fine, the method is not: {}", + response.raw + ); + assert_eq!( + response.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1" + ); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); +} + +#[tokio::test] +async fn an_allowed_origin_and_method_are_proxied_with_the_cors_headers() { + let harness = gateway_with_cors(Some(serde_json::json!({ + "cors": { + "enabled": true, + "allowed_origins": ["https://allowed.test"], + "allowed_methods": ["GET", "POST"], + "expose_headers": ["X-Request-ID"], + "allow_credentials": true, + }, + }))) + .await; + let response = record( + harness + .serve(request_with( + "POST", + "/oagw/v1/proxy/echo/echo", + &[("origin", "https://allowed.test")], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "{}", response.raw); + assert_eq!(response.header("access-control-allow-origin"), Some("https://allowed.test")); + assert_eq!(response.header("access-control-expose-headers"), Some("X-Request-ID")); + assert_eq!(response.header("access-control-allow-credentials"), Some("true")); + assert_eq!(response.header("vary"), Some("Origin")); + assert_eq!(response.source(), Some("upstream"), "the request was forwarded"); +} + +#[tokio::test] +async fn a_request_without_an_origin_is_not_a_cross_origin_request() { + let harness = gateway_with_cors(Some(serde_json::json!({ + "cors": { "enabled": true, "allowed_origins": ["https://allowed.test"] }, + }))) + .await; + let response = record( + harness + .serve(request("DELETE", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::OK, + "no `Origin`, no CORS check: {}", + response.raw + ); + assert_eq!(response.source(), Some("upstream")); +} + +#[tokio::test] +async fn cors_is_off_unless_it_is_enabled() { + let harness = gateway_with_cors(Some(serde_json::json!({ + "cors": { "enabled": false, "allowed_origins": [] }, + }))) + .await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/echo", + &[("origin", "https://whoever.test")], + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::OK, + "a disabled policy lets the request through: {}", + response.raw + ); + assert_eq!( + response.header("access-control-allow-origin"), + None, + "no CORS headers are added when the policy is off" + ); +} + +#[tokio::test] +async fn an_upstream_with_no_cors_field_at_all_imposes_nothing() { + let harness = gateway_with_cors(None).await; + let response = record( + harness + .serve(request_with( + "DELETE", + "/oagw/v1/proxy/echo/echo", + &[("origin", "https://whoever.test")], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "{}", response.raw); + assert_eq!(response.header("access-control-allow-origin"), None); +} + +#[tokio::test] +async fn a_preflight_needs_no_security_context() { + let harness = gateway_with_cors(None).await; + let response = record( + harness + .serve_unauthenticated(request_with( + "OPTIONS", + "/oagw/v1/proxy/echo/echo", + &[ + ("origin", "https://browser.test"), + ("access-control-request-method", "POST"), + ], + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::NO_CONTENT, + "a browser sends no credentials with a preflight: {}", + response.raw + ); + assert_eq!(response.header("access-control-allow-origin"), Some("https://browser.test")); +} + +#[tokio::test] +async fn an_upgrade_is_refused_for_a_disallowed_origin() { + let harness = gateway_with_cors(Some(serde_json::json!({ + "cors": { "enabled": true, "allowed_origins": ["https://allowed.test"] }, + }))) + .await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/ws", + &[ + ("origin", "https://disallowed.test"), + ("host", "127.0.0.1"), + ("connection", "Upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::FORBIDDEN, + "an upgrade is a cross-origin request like any other: {}", + response.raw + ); + assert_eq!( + response.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1" + ); +} + +#[tokio::test] +async fn an_upgrade_for_an_allowed_origin_carries_the_cors_headers() { + let harness = gateway_with_cors(Some(serde_json::json!({ + "cors": { "enabled": true, "allowed_origins": ["https://allowed.test"] }, + }))) + .await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/ws", + &[ + ("origin", "https://allowed.test"), + ("host", "127.0.0.1"), + ("connection", "Upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::SWITCHING_PROTOCOLS, + "the upgrade is allowed and negotiated: {}", + response.raw + ); + assert_eq!( + response.header("access-control-allow-origin"), + Some("https://allowed.test"), + "the 101 carries the CORS answer for the caller" + ); + assert_eq!(response.header("vary"), Some("Origin")); +} + +#[tokio::test] +async fn a_route_level_cors_policy_is_applied() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + // The upstream declares no CORS policy; the route does. + harness + .upstream_with_route( + "echo", + None, + Some(serde_json::json!({ + "cors": { "enabled": true, "allowed_origins": ["https://route.test"] }, + })), + ) + .await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/echo", + &[("origin", "https://elsewhere.test")], + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::FORBIDDEN, + "the route's own policy is what governs: {}", + response.raw + ); + assert_eq!( + response.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1" + ); + + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/echo", + &[("origin", "https://route.test")], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "{}", response.raw); + assert_eq!(response.header("access-control-allow-origin"), Some("https://route.test")); +} diff --git a/gears/system/oagw/oagw/tests/management_api.rs b/gears/system/oagw/oagw/tests/management_api.rs new file mode 100644 index 0000000..fe66f17 --- /dev/null +++ b/gears/system/oagw/oagw/tests/management_api.rs @@ -0,0 +1,1328 @@ +//! Router-level tests for the management (control-plane) API. +//! +//! Every test drives the gear's own `Router`, built exactly as the server +//! builds it, so the status codes, bodies and error ids asserted here are the +//! ones a client of the management API observes on the wire. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use axum::http::StatusCode; +use common::{Harness, JsonConfig, PROTOCOL_HTTP, record, request}; +use uuid::Uuid; + +/// A working upstream document, with a placeholder endpoint. +fn upstream_document(alias: &str, port: u16) -> serde_json::Value { + serde_json::json!({ + "alias": alias, + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": port }] }, + }) +} + +async fn harness() -> Harness { + Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()).await.0 +} + +#[tokio::test] +async fn a_created_upstream_is_listed_and_readable() { + let harness = harness().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("readable", 9000)), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::CREATED, "create returns 201"); + assert!(response.body["id"].as_str().is_some(), "an id is minted"); + assert_eq!(response.body["alias"], "readable"); + assert_eq!(response.body["protocol"], PROTOCOL_HTTP); + assert_eq!(response.body["enabled"], true); + assert_eq!(response.body["server"]["endpoints"][0]["scheme"], "http"); + + let id = response.body["id"].as_str().unwrap().to_owned(); + let response = record( + harness + .serve(request("GET", &format!("/oagw/v1/upstreams/{id}"), None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + assert_eq!(response.body["alias"], "readable"); + + let response = record( + harness + .serve(request("GET", "/oagw/v1/upstreams", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + assert_eq!(response.body["context"]["page"]["count"], 1, "the page counts its items"); + assert!( + response.body["data"] + .as_array() + .is_some_and(|items| items.iter().any(|item| item["alias"] == "readable")), + "the new upstream appears in the list" + ); +} + +#[tokio::test] +async fn an_alias_is_derived_from_the_host_when_not_supplied() { + let harness = harness().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [{ "scheme": "https", "host": "api.openai.com" }] }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::CREATED); + assert_eq!(response.body["alias"], "api.openai.com"); +} + +#[tokio::test] +async fn a_duplicate_alias_is_rejected() { + let harness = harness().await; + let first = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("duplicate", 9001)), + )) + .await, + ) + .await; + assert_eq!(first.status, StatusCode::CREATED); + let second = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("duplicate", 9002)), + )) + .await, + ) + .await; + assert_eq!(second.status, StatusCode::CONFLICT, "a second alias in the same tenant is 409"); +} + +#[tokio::test] +async fn an_empty_endpoint_pool_is_rejected() { + let harness = harness().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "no-endpoints", + "protocol": PROTOCOL_HTTP, + "server": { "endpoints": [] }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::BAD_REQUEST); + assert_eq!( + response.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); + assert!( + response.detail().to_lowercase().contains("endpoint"), + "the problem names the failing field: {}", + response.detail() + ); +} + +#[tokio::test] +async fn an_unknown_endpoint_scheme_is_rejected() { + let harness = harness().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "odd-scheme", + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [{ "scheme": "gopher", "host": "example.test" }] + }, + })), + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::BAD_REQUEST, + "the scheme is not one of the five" + ); +} + +#[tokio::test] +async fn http_scheme_is_accepted_by_the_management_api() { + let harness = harness().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("plaintext", 9003)), + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::CREATED, + "`http` is a legal endpoint scheme; only dialling it is gated" + ); +} + +#[tokio::test] +async fn an_alias_that_is_not_addressable_in_a_path_is_rejected() { + let harness = harness().await; + for alias in ["/leading-slash", "trailing/", "sp ace"] { + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document(alias, 9004)), + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::BAD_REQUEST, + "alias `{alias}` is not addressable in a path" + ); + } +} + +#[tokio::test] +async fn a_put_replaces_the_upstream_without_changing_its_alias() { + let harness = harness().await; + let created = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("replaceable", 9005)), + )) + .await, + ) + .await; + let id = created.body["id"].as_str().unwrap().to_owned(); + + let response = record( + harness + .serve(request( + "PUT", + &format!("/oagw/v1/upstreams/{id}"), + Some(serde_json::json!({ + "enabled": false, + "tags": ["after"], + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "{}", response.raw); + assert_eq!(response.body["alias"], "replaceable", "the alias survives a replacement"); + assert_eq!(response.body["enabled"], false); + assert_eq!(response.body["tags"][0], "after"); +} + +#[tokio::test] +async fn an_endpoint_change_that_keeps_the_alias_is_allowed() { + let harness = harness().await; + let created = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [{ "scheme": "https", "host": "api.openai.com", "port": 443 }] + }, + })), + )) + .await, + ) + .await; + assert_eq!(created.status, StatusCode::CREATED); + let id = created.body["id"].as_str().unwrap().to_owned(); + + let response = record( + harness + .serve(request( + "PUT", + &format!("/oagw/v1/upstreams/{id}"), + Some(serde_json::json!({ + "tags": ["same-alias"], + "server": { + "endpoints": [{ "scheme": "https", "host": "api.openai.com", "port": 443 }] + }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "{}", response.raw); + assert_eq!(response.body["alias"], "api.openai.com"); +} + +#[tokio::test] +async fn an_endpoint_change_that_would_change_the_alias_is_rejected() { + let harness = harness().await; + let created = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [{ "scheme": "https", "host": "api.openai.com", "port": 443 }] + }, + })), + )) + .await, + ) + .await; + let id = created.body["id"].as_str().unwrap().to_owned(); + + let response = record( + harness + .serve(request( + "PUT", + &format!("/oagw/v1/upstreams/{id}"), + Some(serde_json::json!({ + "server": { + "endpoints": [{ "scheme": "https", "host": "api.another.com", "port": 443 }] + }, + })), + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::BAD_REQUEST, + "the alias is the routing key; the operator deletes and re-creates instead" + ); +} + +#[tokio::test] +async fn a_deleted_upstream_is_gone() { + let harness = harness().await; + let created = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("deletable", 9006)), + )) + .await, + ) + .await; + let id = created.body["id"].as_str().unwrap().to_owned(); + + let response = record( + harness + .serve(request("DELETE", &format!("/oagw/v1/upstreams/{id}"), None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::NO_CONTENT); + let response = record( + harness + .serve(request("GET", &format!("/oagw/v1/upstreams/{id}"), None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn an_unknown_upstream_id_is_a_404() { + let harness = harness().await; + let response = record( + harness + .serve(request( + "GET", + "/oagw/v1/upstreams/00000000-0000-0000-0000-00000000000a", + None, + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::NOT_FOUND); + assert_eq!( + response.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); +} + +#[tokio::test] +async fn a_route_is_created_read_updated_and_deleted() { + let harness = harness().await; + let created = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("routed", 9007)), + )) + .await, + ) + .await; + let upstream_id = created.body["id"].as_str().unwrap().to_owned(); + + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": upstream_id, + "match": { + "http": { "methods": ["GET"], "path": "/v1", "path_suffix_mode": "append" } + }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::CREATED); + let route_id = response.body["id"].as_str().unwrap().to_owned(); + assert_eq!(response.body["upstream_id"], upstream_id.as_str()); + assert_eq!(response.body["match"]["http"]["methods"][0], "GET"); + + let response = record( + harness + .serve(request("GET", &format!("/oagw/v1/routes/{route_id}"), None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + + let response = record( + harness + .serve(request( + "PUT", + &format!("/oagw/v1/routes/{route_id}"), + Some(serde_json::json!({ "enabled": false })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + assert_eq!(response.body["enabled"], false); + + let response = record( + harness + .serve(request("DELETE", &format!("/oagw/v1/routes/{route_id}"), None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::NO_CONTENT); + let response = record( + harness + .serve(request("GET", &format!("/oagw/v1/routes/{route_id}"), None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn a_route_with_neither_http_nor_grpc_match_is_rejected() { + let harness = harness().await; + let created = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("matchless", 9008)), + )) + .await, + ) + .await; + let upstream_id = created.body["id"].as_str().unwrap().to_owned(); + + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ "upstream_id": upstream_id, "match": {} })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn a_route_with_both_http_and_grpc_match_is_rejected() { + let harness = harness().await; + let created = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("ambiguous", 9009)), + )) + .await, + ) + .await; + let upstream_id = created.body["id"].as_str().unwrap().to_owned(); + + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": upstream_id, + "match": { + "http": { "methods": ["GET"] }, + "grpc": { "service": "svc", "method": "rpc" }, + }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn a_route_for_an_unknown_upstream_is_rejected() { + let harness = harness().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": "00000000-0000-0000-0000-00000000000b", + "match": { "http": { "methods": ["GET"] } }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::BAD_REQUEST, "the upstream must exist"); +} + +#[tokio::test] +async fn a_route_with_an_empty_method_list_is_rejected() { + let harness = harness().await; + let created = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("methodless", 9010)), + )) + .await, + ) + .await; + let upstream_id = created.body["id"].as_str().unwrap().to_owned(); + + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": [] } }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn the_list_endpoint_supports_a_filter() { + let harness = harness().await; + for alias in ["filter-one", "filter-two"] { + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document(alias, 9011)), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::CREATED); + } + + let response = record( + harness + .serve(request( + "GET", + "/oagw/v1/upstreams?$filter=alias%20eq%20'filter-one'", + None, + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + let items = response.body["data"].as_array().cloned().unwrap_or_default(); + assert_eq!(items.len(), 1, "only the matching upstream comes back"); + assert_eq!(items[0]["alias"], "filter-one"); +} + +#[tokio::test] +async fn the_list_endpoint_supports_top_and_orderby() { + let harness = harness().await; + for alias in ["alpha", "beta", "gamma"] { + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document(alias, 9015)), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::CREATED); + } + + let response = record( + harness + .serve(request("GET", "/oagw/v1/upstreams?$top=2&$orderby=alias%20desc", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + let aliases: Vec<&str> = response.body["data"] + .as_array() + .map(|items| items.iter().filter_map(|item| item["alias"].as_str()).collect()) + .unwrap_or_default(); + assert_eq!(aliases.len(), 2, "`$top` limits the page"); + assert_eq!(aliases, vec!["gamma", "beta"], "`$orderby alias desc` sorts descending"); + + let response = record( + harness + .serve(request("GET", "/oagw/v1/upstreams?$select=alias", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + let first = response.body["data"][0].clone(); + assert_eq!(first.as_object().map(serde_json::Map::len), Some(1), "`$select` projects"); +} + +#[tokio::test] +async fn a_plugin_chain_round_trips_through_the_management_api() { + let harness = harness().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "plugged", + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": 9012 }] + }, + "plugins": { + "items": [ + common::REQUEST_ID_PLUGIN, + { + "plugin_ref": common::REQUIRED_HEADERS_PLUGIN, + "config": { "required_request_headers": "x-correlation-id" }, + }, + ] + }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::CREATED); + let items = response.body["plugins"]["items"].as_array().cloned().unwrap_or_default(); + assert_eq!(items.len(), 2, "both plugin references survive the round trip"); + assert_eq!(items[0], common::REQUEST_ID_PLUGIN); + assert_eq!(items[1]["plugin_ref"], common::REQUIRED_HEADERS_PLUGIN); + assert_eq!( + items[1]["config"]["required_request_headers"], + "x-correlation-id", + "the configuration document is persisted" + ); +} + +#[tokio::test] +async fn a_core_plugin_reference_cannot_be_bound_through_plugins_items() { + let harness = harness().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "catalog-plugged", + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": 9013 }] + }, + "plugins": { + "items": [ + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_token_exchange.v1" + ] + }, + })), + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::BAD_REQUEST, + "core data-plane logic cannot be bound through plugins.items" + ); +} + +#[tokio::test] +async fn credentials_with_a_wildcard_origin_are_rejected_at_validation_time() { + let harness = harness().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "cred-wildcard", + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": 9014 }] + }, + "cors": { + "enabled": true, + "allowed_origins": ["*"], + "allow_credentials": true + }, + })), + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::BAD_REQUEST, + "allow_credentials with a wildcard origin is refused at validation time" + ); +} + +#[tokio::test] +async fn an_unauthenticated_management_request_is_refused() { + let harness = harness().await; + let response = record( + harness + .serve_unauthenticated(request("GET", "/oagw/v1/upstreams", None)) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::INTERNAL_SERVER_ERROR, + "the handlers extract a SecurityContext extension; without one the request fails" + ); +} + +#[tokio::test] +async fn a_plugin_reference_naming_no_plugin_is_refused() { + let harness = harness().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "dangling-plugin", + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": 9014 }] + }, + "plugins": { + "items": ["0b7f5a3e-6f5c-4b8e-9a2d-1c3e5f7a9b99"] + }, + })), + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::BAD_REQUEST, + "a UUID instance names a stored plugin, and there is none: {}", + response.raw + ); +} + +#[tokio::test] +async fn a_stored_custom_plugin_reference_binds() { + let harness = harness().await; + let created = record( + harness + .serve(request( + "POST", + "/oagw/v1/plugins", + Some(serde_json::json!({ + "plugin_type": "guard_plugin", + "name": "allow-list", + })), + )) + .await, + ) + .await; + assert_eq!(created.status, StatusCode::CREATED, "{}", created.raw); + let id = created.body["id"].as_str().expect("plugin id").to_owned(); + + let bound = format!("gts.cf.core.oagw.guard_plugin.v1~{id}"); + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "custom-plugged", + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": 9015 }] + }, + "plugins": { "items": [bound] }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::CREATED, "{}", response.raw); + let items = response.body["plugins"]["items"].as_array().cloned().unwrap_or_default(); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["plugin_ref"], bound.as_str()); + assert_eq!( + items[0]["plugin_uuid"].as_str(), + Some(id.as_str()), + "the UUID is carried beside the reference" + ); +} + +#[tokio::test] +async fn a_named_plugin_binding_carries_no_uuid() { + let harness = harness().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "named-plugged", + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": 9016 }] + }, + "plugins": { "items": [common::REQUIRED_HEADERS_PLUGIN] }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::CREATED, "{}", response.raw); + let items = response.body["plugins"]["items"].as_array().cloned().unwrap_or_default(); + assert_eq!(items[0], common::REQUIRED_HEADERS_PLUGIN, "a bare reference stays bare"); +} + +#[tokio::test] +async fn a_route_keeps_its_upstream_through_a_replacement() { + let harness = harness().await; + let created = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("routed", 9017)), + )) + .await, + ) + .await; + let upstream_id = created.body["id"].as_str().unwrap().to_owned(); + let route = record( + harness + .serve(request( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["GET"], "path": "/v1" } }, + })), + )) + .await, + ) + .await; + let route_id = route.body["id"].as_str().unwrap().to_owned(); + + let replaced = record( + harness + .serve(request( + "PUT", + &format!("/oagw/v1/routes/{route_id}"), + Some(serde_json::json!({ "enabled": false })), + )) + .await, + ) + .await; + assert_eq!(replaced.status, StatusCode::OK, "{}", replaced.raw); + assert_eq!( + replaced.body["upstream_id"], upstream_id.as_str(), + "`upstream_id` is immutable and never part of a replacement body" + ); +} + +#[tokio::test] +async fn a_cors_document_round_trips_through_the_management_api() { + let harness = harness().await; + let cors = serde_json::json!({ + "enabled": true, + "allowed_origins": ["https://app.example.com", "https://alt.example.com"], + "allowed_methods": ["GET", "POST"], + "expose_headers": ["X-Request-ID"], + "allow_credentials": true, + }); + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "cross-origin", + "protocol": PROTOCOL_HTTP, + "cors": cors, + "server": { + "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": 9018 }] + }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::CREATED, "{}", response.raw); + for (field, expected) in [ + ("enabled", serde_json::json!(true)), + ("allowed_origins", cors["allowed_origins"].clone()), + ("allowed_methods", cors["allowed_methods"].clone()), + ("expose_headers", cors["expose_headers"].clone()), + ("allow_credentials", serde_json::json!(true)), + ] { + assert_eq!(response.body["cors"][field], expected, "field `{field}`"); + } +} + +#[tokio::test] +async fn a_heterogeneous_endpoint_pool_is_rejected() { + let harness = harness().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "mixed-pool", + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [ + { "scheme": "http", "host": "127.0.0.1", "port": 9019 }, + { "scheme": "http", "host": "127.0.0.1", "port": 9020 }, + ] + }, + })), + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::BAD_REQUEST, + "one pool, one port: {}", + response.raw + ); +} + +#[tokio::test] +async fn a_homogeneous_endpoint_pool_is_accepted() { + let harness = harness().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "same-pool", + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [ + { "scheme": "http", "host": "127.0.0.1", "port": 9021 }, + { "scheme": "http", "host": "127.0.0.2", "port": 9021 }, + ] + }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::CREATED, "{}", response.raw); +} + +#[tokio::test] +async fn a_plugin_can_be_created_read_and_deleted() { + let harness = harness().await; + let created = record( + harness + .serve(request( + "POST", + "/oagw/v1/plugins", + Some(serde_json::json!({ + "plugin_type": "transform_plugin", + "name": "annotator", + "source": "def transform(ctx): pass", + })), + )) + .await, + ) + .await; + assert_eq!(created.status, StatusCode::CREATED, "{}", created.raw); + let id = created.body["id"].as_str().expect("plugin id").to_owned(); + + let read = record( + harness + .serve(request("GET", &format!("/oagw/v1/plugins/{id}"), None)) + .await, + ) + .await; + assert_eq!(read.status, StatusCode::OK, "{}", read.raw); + assert_eq!(read.body["name"], "annotator"); + + let source = record( + harness + .serve(request("GET", &format!("/oagw/v1/plugins/{id}/source"), None)) + .await, + ) + .await; + assert_eq!(source.status, StatusCode::OK, "{}", source.raw); + assert_eq!(source.body["source"], "def transform(ctx): pass"); + + let deleted = record( + harness + .serve(request("DELETE", &format!("/oagw/v1/plugins/{id}"), None)) + .await, + ) + .await; + assert_eq!( + deleted.status, + StatusCode::NO_CONTENT, + "an unreferenced plugin goes without a fight: {}", + deleted.raw + ); + + let gone = record( + harness + .serve(request("GET", &format!("/oagw/v1/plugins/{id}"), None)) + .await, + ) + .await; + assert_eq!(gone.status, StatusCode::NOT_FOUND, "{}", gone.raw); +} + +#[tokio::test] +async fn a_plugin_in_use_cannot_be_deleted() { + let harness = harness().await; + let created = record( + harness + .serve(request( + "POST", + "/oagw/v1/plugins", + Some(serde_json::json!({ + "plugin_type": "guard_plugin", + "name": "referenced", + })), + )) + .await, + ) + .await; + let id = created.body["id"].as_str().expect("plugin id").to_owned(); + let bound = format!("gts.cf.core.oagw.guard_plugin.v1~{id}"); + let upstream = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "in-use", + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": 9022 }] + }, + "plugins": { "items": [bound] }, + })), + )) + .await, + ) + .await; + assert_eq!(upstream.status, StatusCode::CREATED, "{}", upstream.raw); + + let deleted = record( + harness + .serve(request("DELETE", &format!("/oagw/v1/plugins/{id}"), None)) + .await, + ) + .await; + assert_eq!( + deleted.status, + StatusCode::CONFLICT, + "a bound plugin cannot be deleted: {}", + deleted.raw + ); + assert_eq!( + deleted.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1" + ); +} + +#[tokio::test] +async fn a_plugin_has_no_replacement_operation() { + let harness = harness().await; + let created = record( + harness + .serve(request( + "POST", + "/oagw/v1/plugins", + Some(serde_json::json!({ "plugin_type": "guard_plugin", "name": "fixed" })), + )) + .await, + ) + .await; + let id = created.body["id"].as_str().expect("plugin id").to_owned(); + let response = record( + harness + .serve(request( + "PUT", + &format!("/oagw/v1/plugins/{id}"), + Some(serde_json::json!({ "name": "renamed" })), + )) + .await, + ) + .await; + assert_ne!( + response.status, + StatusCode::OK, + "plugins are not replaceable: {}", + response.raw + ); +} + +#[tokio::test] +async fn a_descendant_cannot_read_or_delete_an_ancestor_upstream() { + // A child below a parent that owns an upstream: the child's own tenant + // scope is what every management read answers to. + let child = Uuid::new_v4(); + let parent = Uuid::new_v4(); + let harness = Harness::build_with_hierarchy( + &JsonConfig::new(true, 1024 * 1024), + Vec::new(), + &[(child, parent)], + ) + .await + .0; + let created = record( + harness + .serve_as( + parent, + request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("inheritance", 9100)), + ), + ) + .await, + ) + .await; + assert_eq!(created.status, StatusCode::CREATED, "{}", created.raw); + let id = created.body["id"].as_str().expect("upstream id").to_owned(); + + let read = record( + harness + .serve_as(child, request("GET", &format!("/oagw/v1/upstreams/{id}"), None)) + .await, + ) + .await; + assert_eq!( + read.status, + StatusCode::NOT_FOUND, + "the ancestor's upstream is not in the descendant's scope: {}", + read.raw + ); + + let deleted = record( + harness + .serve_as( + child, + request("DELETE", &format!("/oagw/v1/upstreams/{id}"), None), + ) + .await, + ) + .await; + assert_eq!( + deleted.status, + StatusCode::NOT_FOUND, + "nor can the descendant tear it down: {}", + deleted.raw + ); + + let still_there = record( + harness + .serve_as( + parent, + request("GET", &format!("/oagw/v1/upstreams/{id}"), None), + ) + .await, + ) + .await; + assert_eq!( + still_there.status, + StatusCode::OK, + "the ancestor keeps what it owns: {}", + still_there.raw + ); +} + +#[tokio::test] +async fn a_descendant_sees_its_own_upstream_and_not_its_ancestors() { + let child = Uuid::new_v4(); + let parent = Uuid::new_v4(); + let harness = Harness::build_with_hierarchy( + &JsonConfig::new(true, 1024 * 1024), + Vec::new(), + &[(child, parent)], + ) + .await + .0; + let parent_upstream = record( + harness + .serve_as( + parent, + request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("shared", 9200)), + ), + ) + .await, + ) + .await; + assert_eq!(parent_upstream.status, StatusCode::CREATED); + let child_upstream = record( + harness + .serve_as( + child, + request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("shared", 9300)), + ), + ) + .await, + ) + .await; + assert_eq!( + child_upstream.status, + StatusCode::CREATED, + "an alias is unique per tenant, not per deployment: {}", + child_upstream.raw + ); + + let listed = record( + harness + .serve_as(child, request("GET", "/oagw/v1/upstreams", None)) + .await, + ) + .await; + let aliases: Vec<&str> = listed.body["data"] + .as_array() + .map(|items| { + items + .iter() + .filter_map(|item| item["alias"].as_str()) + .collect() + }) + .unwrap_or_default(); + assert_eq!( + aliases, + vec!["shared"], + "the listing is one tenant's, not the family's: {aliases:?}" + ); +} + +#[tokio::test] +async fn an_alias_is_stored_normalized() { + // Aliases are ASCII-lowercase with trailing dots stripped, and resolution + // is case-insensitive, so what the management API hands back is the + // normalized key, never what was typed. + let harness = harness().await; + let created = record( + harness + .serve( + request( + "POST", + "/oagw/v1/upstreams", + Some(upstream_document("MixedCase.API.", 9400)), + ), + ) + .await, + ) + .await; + assert_eq!(created.status, StatusCode::CREATED, "{}", created.raw); + assert_eq!( + created.body["alias"], "mixedcase.api", + "the alias is stored in its normalized form" + ); + + let read = record( + harness + .serve(request("GET", "/oagw/v1/proxy/mixedcase.api/echo", None)) + .await, + ) + .await; + assert!( + read.detail().contains("no route matches the path"), + "the alias resolved and only the route was missing: {}", + read.detail() + ); +} diff --git a/gears/system/oagw/oagw/tests/oauth2_token_cache.rs b/gears/system/oagw/oagw/tests/oauth2_token_cache.rs new file mode 100644 index 0000000..882d925 --- /dev/null +++ b/gears/system/oagw/oagw/tests/oauth2_token_cache.rs @@ -0,0 +1,112 @@ +//! The `OAuth2` client-credentials plugin against a stand-in identity provider. +//! +//! ADR-0008: one token exchange per `(tenant, subject, method, config)` tuple, +//! cached for `min(configured ceiling, expires_in − 30 s)`. The stand-in `IdP` +//! names every token it grants after its own issuance counter and the client +//! that asked, so a cached token reads back as the number its owner was first +//! given. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use axum::http::StatusCode; +use common::{ + Harness, JsonConfig, OAUTH2_PLUGIN, TOKEN_TTL_SECONDS, TOKEN_TTL_SECONDS_SHORT, record, + request, token_issuances, +}; + +/// Secrets the credential store answers to, in the form the plugin references. +fn credentials() -> Vec<(String, String)> { + vec![ + ("idp-client-id".to_owned(), "the-client".to_owned()), + ("idp-client-secret".to_owned(), "the-secret".to_owned()), + ] +} + +/// A gateway whose stand-in upstream is also its identity provider. +/// +/// `endpoint` is the path on it that grants tokens. +async fn gateway(endpoint: &str) -> Harness { + let (harness, _) = Harness::build(&JsonConfig::new(true, 1024 * 1024), credentials()).await; + let auth = serde_json::json!({ + "auth": { + "type": OAUTH2_PLUGIN, + "config": { + "token_endpoint": format!("http://127.0.0.1:{}/{}", harness.upstream_port(), endpoint), + "client_id_ref": "cred://idp-client-id", + "client_secret_ref": "cred://idp-client-secret", + }, + } + }); + harness.upstream_with_route("idp", Some(auth), None).await; + harness +} + +/// Proxies a request and returns the `authorization` header the upstream saw. +async fn forwarded_authorization(harness: &Harness) -> Option { + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/idp/echo", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "{}", response.raw); + response.body["headers"]["authorization"] + .as_str() + .map(str::to_owned) +} + +/// The issuance ordinal a granted token carries, `issued-N-for-the-client`. +/// +/// The counter is shared by every test in the process, so an ordinal is only +/// meaningful relative to another read of the same header. +fn ordinal(token: Option<&str>) -> usize { + let raw = token.expect("a token was forwarded").trim_start_matches("Bearer issued-"); + let body = raw.strip_suffix("-for-the-client").expect("the client is named"); + body.parse().expect("the ordinal is a number") +} + +#[tokio::test] +async fn a_second_request_reuses_the_cached_token() { + let harness = gateway("oauth/token").await; + + let first = forwarded_authorization(&harness).await; + assert!( + first.as_deref().is_some_and(|token| token.starts_with("Bearer issued-")), + "the exchange names the client whose credentials were resolved: {first:?}" + ); + + let second = forwarded_authorization(&harness).await; + assert_eq!( + second, first, + "the second request for the same identity reads the cache" + ); + assert_eq!( + ordinal(first.as_deref()), + ordinal(second.as_deref()), + "one exchange, not two" + ); +} + +#[tokio::test] +async fn a_token_already_inside_the_safety_margin_is_not_cached() { + let harness = gateway("oauth/short-token").await; + + let first = forwarded_authorization(&harness).await; + assert!( + first.as_deref().is_some_and(|token| token.starts_with("Bearer issued-")), + "the exchange still succeeds and the caller still gets its token: {first:?}" + ); + + let second = forwarded_authorization(&harness).await; + assert_ne!( + second, first, + "a token whose useful life the margin has eaten cannot be reused" + ); + assert_ne!( + ordinal(first.as_deref()), + ordinal(second.as_deref()), + "each request exchanges again, since nothing was stored" + ); +} diff --git a/gears/system/oagw/oagw/tests/proxy_behaviour.rs b/gears/system/oagw/oagw/tests/proxy_behaviour.rs new file mode 100644 index 0000000..133c347 --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_behaviour.rs @@ -0,0 +1,1573 @@ +//! Router-level tests for the proxy (data-plane) surface. +//! +//! Every test drives the gear's own `Router` against an in-process upstream, so +//! what is asserted here is what a caller sees: status codes, the headers the +//! upstream actually received, and the error semantics the gateway applies. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use axum::http::StatusCode; +use common::{GATEWAY_SOURCE, Harness, JsonConfig, PROTOCOL_HTTP, record, request, request_with}; +use serde_json::Value; +use uuid::Uuid; + +const UPSTREAM_SOURCE: &str = "upstream"; + +/// An upstream with one route matching everything, and nothing else configured. +async fn gateway() -> Harness { + let (harness, _) = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()).await; + harness.simple_upstream("echo", None).await; + harness +} + +/// A gateway whose stand-in upstream is registered under `alias` with a +/// catch-all route, so a test can address it at any path. +async fn harness_with_upstream(alias: &str) -> Harness { + let harness = gateway_named(alias).await; + harness +} + +/// A gateway with an upstream named `alias` and no route yet, for the tests +/// that register their own. +async fn harness_without_route(alias: &str) -> Harness { + let (harness, _) = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()).await; + let document = serde_json::json!({ + "alias": alias, + "protocol": common::PROTOCOL_HTTP, + "server": { + "endpoints": [{ + "scheme": "http", + "host": harness.upstream().host(), + "port": harness.upstream_port(), + }] + }, + }); + harness.register_upstream(alias, document).await; + harness +} + +/// A gateway with one upstream named `alias` and a catch-all route. +async fn gateway_named(alias: &str) -> Harness { + let (harness, _) = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()).await; + harness.simple_upstream(alias, None).await; + harness +} + +/// An upstream carrying extra upstream configuration, for the policy tests. +async fn gateway_with(extra: Value, secrets: Vec<(String, String)>) -> Harness { + let (harness, _) = Harness::build(&JsonConfig::new(true, 1024 * 1024), secrets).await; + harness.simple_upstream("echo", Some(extra)).await; + harness +} + +#[tokio::test] +async fn an_unresolvable_alias_is_a_404_from_the_gateway() { + let harness = gateway().await; + let response = record(harness.serve(request("GET", "/oagw/v1/proxy/nosuch", None)).await).await; + assert_eq!(response.status, StatusCode::NOT_FOUND); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); + assert_eq!(response.body["type"], "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1"); + assert_eq!(response.body["status"], 404); + assert_eq!(response.body["title"], "Route Not Found"); + assert!(response.detail().contains("nosuch"), "{}", response.detail()); +} + +#[tokio::test] +async fn a_tenant_without_the_alias_sees_a_404_not_another_tenants_upstream() { + let harness = gateway().await; + // Tenant A registered `echo`; another tenant has no upstream by that alias. + let response = record( + harness + .serve_as( + uuid::Uuid::from_u128(0x9999), + request("GET", "/oagw/v1/proxy/echo/echo", None), + ) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::NOT_FOUND); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); +} + +#[tokio::test] +async fn a_get_is_forwarded_with_the_path_appended() { + let harness = gateway().await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + assert_eq!(response.source(), Some(UPSTREAM_SOURCE)); + assert_eq!(response.body["method"], "GET"); + assert_eq!(response.body["path"], "/echo"); +} + +#[tokio::test] +async fn a_query_string_is_forwarded_verbatim() { + let harness = gateway().await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo?a=1&b=two", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + assert_eq!(response.body["query"], "a=1&b=two"); +} + +#[tokio::test] +async fn a_post_body_is_forwarded() { + let harness = gateway().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/proxy/echo/echo", + Some(serde_json::json!({ "hello": "world" })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + assert_eq!(response.body["method"], "POST"); +} + +#[tokio::test] +async fn an_upstream_status_is_forwarded_untouched() { + let harness = gateway().await; + for code in [201, 204, 404, 500, 503] { + let response = record( + harness + .serve(request("GET", &format!("/oagw/v1/proxy/echo/status/{code}"), None)) + .await, + ) + .await; + assert_eq!(response.status.as_u16(), code, "the upstream's status is not rewritten"); + assert_eq!(response.source(), Some(UPSTREAM_SOURCE)); + } +} + +#[tokio::test] +async fn an_upstream_5xx_is_marked_upstream_not_gateway() { + let harness = gateway().await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/status/503", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + response.source(), + Some(UPSTREAM_SOURCE), + "a 5xx the upstream sent is the upstream's, not the gateway's" + ); +} + +#[tokio::test] +async fn an_unreachable_upstream_is_a_502_from_the_gateway() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + // Nothing listens on port 1. + harness + .simple_upstream( + "echo", + Some(serde_json::json!({ + "server": { "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": 1 }] } + })), + ) + .await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::BAD_GATEWAY); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); + assert_eq!( + response.body["upstream_id"], + harness.upstream_id("echo").to_string(), + "the problem names the upstream that could not be reached" + ); +} + +#[tokio::test] +async fn a_plaintext_upstream_is_refused_when_allow_http_is_false() { + let harness = Harness::build(&JsonConfig::new(false, 1024 * 1024), Vec::new()) + .await + .0; + harness.simple_upstream("echo", None).await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::SERVICE_UNAVAILABLE, + "the scheme is accepted at create time and refused at dial time" + ); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); +} + +#[tokio::test] +async fn a_plaintext_upstream_is_dialled_when_allow_http_is_true() { + let harness = gateway().await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::OK, + "the same document the false case refuses is dialled here" + ); +} + +#[tokio::test] +async fn hop_by_hop_headers_do_not_reach_the_upstream() { + let harness = gateway().await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/echo", + &[ + ("connection", "keep-alive"), + ("keep-alive", "timeout=5"), + ("proxy-connection", "keep-alive"), + ("x-caller-header", "value"), + ], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + let headers = response.body["headers"].clone(); + for hop in ["connection", "keep-alive", "proxy-connection"] { + assert!(headers.get(hop).is_none(), "`{hop}` must not reach the upstream"); + } + assert!( + headers.get("x-caller-header").is_none(), + "the default passthrough is `none`: nothing the caller sent is forwarded" + ); +} + +#[tokio::test] +async fn the_upstream_receives_a_host_header_for_itself() { + let harness = gateway().await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + let host = response.body["headers"]["host"].as_str().unwrap_or_default(); + assert!(!host.is_empty(), "the upstream must receive a Host header"); +} + +#[tokio::test] +async fn a_response_header_rule_reaches_the_caller() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + harness + .simple_upstream( + "echo", + Some(serde_json::json!({ + "headers": { "response": { "set": { "x-gateway-mark": "present" } } }, + })), + ) + .await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/status/200", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + assert_eq!(response.header("x-gateway-mark"), Some("present")); +} + +#[tokio::test] +async fn a_response_header_rule_can_remove_an_upstream_header() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + harness + .simple_upstream( + "echo", + Some(serde_json::json!({ + "headers": { "response": { "remove": ["x-upstream-mark"] } }, + })), + ) + .await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/sse", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + assert!( + response.header("x-upstream-mark").is_none(), + "a removed header must not reach the caller" + ); +} + +#[tokio::test] +async fn a_request_header_allowlist_selects_what_the_upstream_sees() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + harness + .simple_upstream( + "echo", + Some(serde_json::json!({ + "headers": { + "request": { + "passthrough": "allowlist", + "passthrough_allowlist": ["x-keep-me"], + "set": { "x-gateway-set": "yes" }, + }, + }, + })), + ) + .await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/echo", + &[("x-keep-me", "kept"), ("x-drop-me", "dropped")], + )) + .await, + ) + .await; + let headers = response.body["headers"].clone(); + assert!( + headers.get("x-drop-me").is_none(), + "a header outside the allowlist is not forwarded" + ); + assert_eq!(headers.get("x-keep-me").and_then(Value::as_str), Some("kept")); + assert_eq!(headers.get("x-gateway-set").and_then(Value::as_str), Some("yes")); +} + +#[tokio::test] +async fn a_request_header_rule_can_remove_a_header() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + harness + .simple_upstream( + "echo", + Some(serde_json::json!({ + "headers": { "request": { "remove": ["x-secret"] } }, + })), + ) + .await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/echo", + &[("x-secret", "value")], + )) + .await, + ) + .await; + let headers = response.body["headers"].clone(); + assert!(headers.get("x-secret").is_none(), "a removed header must not be forwarded"); +} + +#[tokio::test] +async fn an_api_key_plugin_injects_the_credential_upstream() { + let harness = gateway_with( + serde_json::json!({ + "auth": { + "type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + "sharing": "private", + "config": { "secret_ref": "cred://openai-key" }, + }, + }), + vec![("openai-key".to_owned(), "sk-test-value".to_owned())], + ) + .await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "{}", response.raw); + assert_eq!( + response.body["headers"].get("x-api-key").and_then(Value::as_str), + Some("sk-test-value"), + "the resolved secret is presented upstream" + ); +} + +#[tokio::test] +async fn an_injected_credential_is_absent_from_a_response_that_does_not_echo_it() { + let harness = gateway_with( + serde_json::json!({ + "auth": { + "type": common::APIKEY_PLUGIN, + "sharing": "private", + "config": { "secret_ref": "cred://openai-key" }, + }, + }), + vec![("openai-key".to_owned(), "sk-test-value".to_owned())], + ) + .await; + let response = record( + harness + .serve(request("POST", "/oagw/v1/proxy/echo/post", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::CREATED, "{}", response.raw); + assert!( + !response.raw.contains("sk-test-value"), + "the credential travels upstream only: {}", + response.raw + ); +} + +#[tokio::test] +async fn an_unresolvable_credential_is_a_401() { + let harness = gateway_with( + serde_json::json!({ + "auth": { + "type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + "sharing": "private", + "config": { "secret_ref": "cred://missing-key" }, + }, + }), + Vec::new(), + ) + .await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::UNAUTHORIZED); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); + assert_eq!(response.body["type"], "gts.cf.core.errors.err.v1~cf.oagw.auth.failed.v1"); + assert_eq!(response.body["status"], 401); +} + +#[tokio::test] +async fn an_unknown_auth_plugin_type_is_a_401() { + // A UUID reference passes create-time validation (the CP mints identifiers + // for custom plugins) and is only discovered missing when a request runs. + let harness = gateway_with( + serde_json::json!({ + "auth": { "type": "0b7f5a3e-6f5c-4b8e-9a2d-1c3e5f7a9b01" }, + }), + Vec::new(), + ) + .await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::UNAUTHORIZED, + "an unresolvable plugin cannot authenticate the request: {}", + response.raw + ); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); +} + +#[tokio::test] +async fn a_required_headers_guard_refuses_a_request_without_them() { + let harness = gateway_with( + serde_json::json!({ + "plugins": { + "items": [{ + "plugin_ref": common::REQUIRED_HEADERS_PLUGIN, + "config": { "required_request_headers": "x-correlation-id" }, + }], + }, + }), + Vec::new(), + ) + .await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::BAD_REQUEST); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); + assert_eq!( + response.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); + assert!(response.detail().contains("x-correlation-id"), "{}", response.detail()); +} + +#[tokio::test] +async fn a_required_headers_guard_passes_when_the_header_is_present() { + let harness = gateway_with( + serde_json::json!({ + "plugins": { + "items": [{ + "plugin_ref": common::REQUIRED_HEADERS_PLUGIN, + "config": { "required_request_headers": "x-correlation-id" }, + }], + }, + }), + Vec::new(), + ) + .await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/echo", + &[("x-correlation-id", "abc")], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "{}", response.raw); +} + +#[tokio::test] +async fn a_guard_judges_the_callers_headers_not_the_rules_output() { + // The rule set drops `x-correlation-id` before the upstream sees it; the + // guard must still accept the request, because it judges what arrived. + let harness = gateway_with( + serde_json::json!({ + "headers": { "request": { "remove": ["x-correlation-id"] } }, + "plugins": { + "items": [{ + "plugin_ref": common::REQUIRED_HEADERS_PLUGIN, + "config": { "required_request_headers": "x-correlation-id" }, + }], + }, + }), + Vec::new(), + ) + .await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/echo", + &[("x-correlation-id", "abc")], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "the guard ran before the header rules"); + assert!( + response.body["headers"].get("x-correlation-id").is_none(), + "the rule still removed it upstream" + ); +} + +#[tokio::test] +async fn a_guard_that_rejects_the_upstream_response_replaces_it_with_a_502() { + let harness = gateway_with( + serde_json::json!({ + "plugins": { + "items": [{ + "plugin_ref": common::REQUIRED_HEADERS_PLUGIN, + "config": { "required_response_headers": "x-upstream-mark" }, + }], + }, + }), + Vec::new(), + ) + .await; + // `/status/200` on the upstream sends no `x-upstream-mark`. + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/status/200", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::BAD_GATEWAY); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); +} + +#[tokio::test] +async fn a_guard_that_accepts_the_upstream_response_leaves_it_alone() { + let harness = gateway_with( + serde_json::json!({ + "plugins": { + "items": [{ + "plugin_ref": common::REQUIRED_HEADERS_PLUGIN, + "config": { "required_response_headers": "x-upstream-mark" }, + }], + }, + }), + Vec::new(), + ) + .await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/sse", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "the upstream marks this response itself"); +} + +#[tokio::test] +async fn a_request_id_is_minted_when_the_caller_supplies_none() { + let harness = gateway_with( + serde_json::json!({ "plugins": { "items": [common::REQUEST_ID_PLUGIN] } }), + Vec::new(), + ) + .await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + let minted = response.header("x-request-id").map(str::to_owned); + assert!( + minted.as_ref().is_some_and(|value| !value.is_empty()), + "a correlation id is minted and echoed to the caller" + ); + assert_eq!( + response.body["headers"].get("x-request-id").and_then(Value::as_str), + minted.as_deref(), + "the same id is presented upstream" + ); +} + +#[tokio::test] +async fn a_supplied_request_id_is_propagated_and_echoed() { + let harness = gateway_with( + serde_json::json!({ "plugins": { "items": [common::REQUEST_ID_PLUGIN] } }), + Vec::new(), + ) + .await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/echo", + &[("x-request-id", "caller-supplied-id")], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + assert_eq!( + response.header("x-request-id"), + Some("caller-supplied-id"), + "the caller's id is echoed, not replaced" + ); + assert_eq!( + response.body["headers"].get("x-request-id").and_then(Value::as_str), + Some("caller-supplied-id") + ); +} + +#[tokio::test] +async fn an_exhausted_token_bucket_is_a_429_with_a_retry_after() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + harness + .simple_upstream( + "echo", + Some(serde_json::json!({ + "rate_limit": { + "sustained": { "rate": 2, "window": "second" }, + "burst": { "capacity": 2 }, + "scope": "tenant", + "strategy": "reject", + }, + })), + ) + .await; + for _ in 0..2 { + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "the first two fit the bucket"); + } + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); + assert!( + response.header("retry-after").is_some(), + "a rejected request is told when it may retry" + ); + assert_eq!( + response.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1" + ); + assert!( + response.body["retry_after_seconds"].as_u64().is_some(), + "the problem carries the same budget as the header" + ); +} + +#[tokio::test] +async fn a_disallowed_origin_is_a_403() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + harness + .simple_upstream( + "echo", + Some(serde_json::json!({ + "cors": { "enabled": true, "allowed_origins": ["https://allowed.test"] }, + })), + ) + .await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/echo", + &[("origin", "https://disallowed.test")], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::FORBIDDEN); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); + assert_eq!( + response.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1" + ); +} + +#[tokio::test] +async fn an_allowed_origin_is_proxied_with_cors_headers() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + harness + .simple_upstream( + "echo", + Some(serde_json::json!({ + "cors": { "enabled": true, "allowed_origins": ["https://allowed.test"] }, + })), + ) + .await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/echo/echo", + &[("origin", "https://allowed.test")], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + assert_eq!(response.header("access-control-allow-origin"), Some("https://allowed.test")); +} + +#[tokio::test] +async fn a_preflight_is_answered_permissively_without_reaching_the_upstream() { + let harness = gateway().await; + let response = record( + harness + .serve(request_with( + "OPTIONS", + "/oagw/v1/proxy/echo/echo", + &[ + ("origin", "https://anywhere.test"), + ("access-control-request-method", "GET"), + ], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::NO_CONTENT); + assert_eq!( + response.header("access-control-allow-origin"), + Some("https://anywhere.test"), + "the preflight echo is permissive; enforcement happens on the actual request" + ); + assert_eq!(response.header("access-control-allow-methods"), Some("GET")); +} + +#[tokio::test] +async fn a_method_outside_the_route_allowlist_is_refused() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + harness + .upstream_with_route( + "echo", + None, + Some(serde_json::json!({ + "match": { "http": { "methods": ["GET"], "path": "/" } }, + })), + ) + .await; + let response = record( + harness + .serve(request("PATCH", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::BAD_REQUEST, + "PATCH is not in the route's method list" + ); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "a listed method passes"); +} + +#[tokio::test] +async fn a_query_parameter_outside_the_allowlist_is_refused() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + harness + .upstream_with_route( + "echo", + None, + Some(serde_json::json!({ + "match": { + "http": { + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH"], + "path": "/", + "query_allowlist": ["allowed"], + } + }, + })), + ) + .await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo?unlisted=1", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::BAD_REQUEST, "{}", response.raw); + assert!( + response.detail().contains("unlisted"), + "the problem names the offending parameter: {}", + response.detail() + ); + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo?allowed=1", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "the listed parameter passes: {}", response.raw); +} + +#[tokio::test] +async fn a_body_over_the_configured_limit_is_a_413() { + let harness = Harness::build(&JsonConfig::new(true, 64), Vec::new()).await.0; + harness.simple_upstream("echo", None).await; + let oversized = "x".repeat(128); + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/proxy/echo/echo", + Some(serde_json::json!({ "body": oversized })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); + assert_eq!( + response.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.payload.too_large.v1" + ); +} + +#[tokio::test] +async fn a_declared_body_over_the_limit_is_refused_before_it_is_read() { + let harness = Harness::build(&JsonConfig::new(true, 16), Vec::new()).await.0; + harness.simple_upstream("echo", None).await; + let request = axum::http::Request::builder() + .method("POST") + .uri("/oagw/v1/proxy/echo/echo") + .header("content-length", "1024") + .body(axum::body::Body::from("short body, long declaration")) + .unwrap(); + let response = record(harness.serve(request).await).await; + assert_eq!(response.status, StatusCode::PAYLOAD_TOO_LARGE); +} + +#[tokio::test] +async fn the_proxy_requires_a_security_context() { + let harness = gateway().await; + let response = record( + harness + .serve_unauthenticated(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::UNAUTHORIZED, + "a proxied request needs a security context: {}", + response.raw + ); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); + assert_eq!( + response.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.auth.failed.v1", + "the refusal is the gateway's own problem document" + ); +} + +#[tokio::test] +async fn a_route_bound_to_one_upstream_does_not_shadow_another() { + let harness = gateway().await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "second", + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": 1 }] + }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::CREATED); + let upstream_id = response.body["id"].as_str().unwrap().to_owned(); + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["GET"], "path": "/second-only" } }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::CREATED); + + // `second` matches only `/second-only`, so `/echo` still resolves to `echo`. + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); +} + +#[tokio::test] +async fn the_host_header_names_the_endpoint_the_request_is_dialled_at() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + // Two endpoints that resolve to the same loopback under different names, + // so the `Host` the upstream saw says which one was picked. + let port = harness.upstream_port(); + let harness = harness + .register_upstream( + "pool", + serde_json::json!({ + "alias": "pool.test", + "protocol": common::PROTOCOL_HTTP, + "server": { + "endpoints": [ + { "scheme": "http", "host": "localhost", "port": port }, + { "scheme": "http", "host": "127.0.0.1", "port": port }, + ] + }, + }), + ) + .await; + harness.route_for("pool", "/host", None).await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/pool.test/host", + &[("x-oagw-target-host", "127.0.0.1")], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "{}", response.raw); + let host = response.body["host"].as_str().unwrap_or_default(); + assert!( + host.starts_with("127.0.0.1:"), + "`host` names the endpoint that answered, not the pool's first member: {host}" + ); +} + +#[tokio::test] +async fn a_disabled_upstream_is_a_503_from_the_gateway() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + let harness = harness + .register_upstream( + "shuttered", + serde_json::json!({ + "alias": "shuttered", + "protocol": common::PROTOCOL_HTTP, + "enabled": false, + "server": { + "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": harness.upstream_port() }] + }, + }), + ) + .await; + harness.route_for("shuttered", "/", None).await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/shuttered/echo", None)) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::SERVICE_UNAVAILABLE, + "{}", + response.raw + ); + assert_eq!(response.source(), Some(common::GATEWAY_SOURCE)); + assert_eq!( + response.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1" + ); +} + +#[tokio::test] +async fn a_disabled_route_is_not_matched() { + let harness = harness_without_route("dark").await; + harness.route_for("dark", "/open", None).await; + // Same upstream, same path band, but switched off. + let route = record( + harness + .serve(request( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": harness.upstream_id("dark"), + "match": { "http": { "methods": ["GET"], "path": "/closed", "query_allowlist": [] } }, + "enabled": false, + })), + )) + .await, + ) + .await; + assert_eq!(route.status, StatusCode::CREATED, "{}", route.raw); + + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/dark/closed/echo", None)) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::NOT_FOUND, + "a disabled route is invisible to matching: {}", + response.raw + ); + assert_eq!(response.source(), Some(common::GATEWAY_SOURCE)); +} + +#[tokio::test] +async fn a_resolved_upstream_without_a_matching_route_is_a_404() { + let harness = harness_without_route("routed").await; + harness.route_for("routed", "/known", None).await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/routed/elsewhere", None)) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::NOT_FOUND, + "the alias resolved, so this is a routing miss: {}", + response.raw + ); + assert_eq!(response.source(), Some(common::GATEWAY_SOURCE)); + assert_eq!( + response.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); +} + +#[tokio::test] +async fn two_routes_with_the_same_match_conflict() { + let harness = harness_with_upstream("clash").await; + harness.route_for("clash", "/only", None).await; + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": harness.upstream_id("clash"), + "match": { "http": { "methods": ["GET"], "path": "/only", "query_allowlist": [] } }, + })), + )) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::CONFLICT, + "the same path, method and priority twice is a conflict: {}", + response.raw + ); + assert_eq!( + response.body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.conflict.v1" + ); +} + +#[tokio::test] +async fn the_longest_path_prefix_wins() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + let harness = harness + .register_upstream( + "deep", + serde_json::json!({ + "alias": "deep", + "protocol": common::PROTOCOL_HTTP, + "server": { + "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": harness.upstream_port() }] + }, + }), + ) + .await; + // `/` matches everything and lets any query through, so only the `/v1/chat` + // route - which admits no query at all - can refuse one. + harness.route_for("deep", "/", None).await; + harness + .route_for("deep", "/v1/chat", Some(serde_json::json!({ + "match": { + "http": { + "methods": ["GET", "POST", "PUT", "DELETE", "PATCH"], + "path": "/v1/chat", + "query_allowlist": [], + } + }, + }))) + .await; + + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/deep/v1/chat/echo?q=1", None)) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::BAD_REQUEST, + "the `/v1/chat` route is what matched, and it admits no query: {}", + response.raw + ); + assert_eq!(response.source(), Some(common::GATEWAY_SOURCE)); +} + +#[tokio::test] +async fn a_request_header_set_rule_overwrites_the_callers_value() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + let harness = harness + .register_upstream( + "overwritten", + serde_json::json!({ + "alias": "overwritten", + "protocol": common::PROTOCOL_HTTP, + "headers": { + "request": { + "passthrough": "all", + "set": { "x-env": "prod" }, + } + }, + "server": { + "endpoints": [{ "scheme": "http", "host": "127.0.0.1", "port": harness.upstream_port() }] + }, + }), + ) + .await; + harness.route_for("overwritten", "/", None).await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/overwritten/echo", + &[("x-env", "dev")], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "{}", response.raw); + assert_eq!( + response.body["headers"]["x-env"], "prod", + "`set` wins over what the caller sent" + ); +} + +#[tokio::test] +async fn the_target_host_header_never_reaches_the_upstream() { + let harness = harness_with_upstream("pooltwo").await; + let response = record( + harness + .serve(request_with( + "GET", + "/oagw/v1/proxy/pooltwo/echo", + &[("x-oagw-target-host", "127.0.0.1")], + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "{}", response.raw); + assert!( + response.body["headers"].get("x-oagw-target-host").is_none(), + "a routing header is consumed, not forwarded: {}", + response.raw + ); +} + +#[tokio::test] +async fn an_alias_matches_regardless_of_its_case() { + let harness = harness_with_upstream("mixedcase").await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/MixedCase/echo", None)) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::OK, + "an alias is a case-insensitive routing key: {}", + response.raw + ); +} + +#[tokio::test] +async fn a_refused_request_never_reaches_the_upstream() { + // One request's worth of budget: the second caller is refused. + let (harness, _) = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()).await; + harness + .upstream_with_route( + "metered", + Some(serde_json::json!({ + "rate_limit": { + "sustained": { "rate": 1, "window": "second" }, + "burst": { "capacity": 1 }, + "scope": "tenant", + } + })), + None, + ) + .await; + + let before = common::counted_hits(); + let admitted = record( + harness + .serve(request("GET", "/oagw/v1/proxy/metered/counted", None)) + .await, + ) + .await; + assert_eq!(admitted.status, StatusCode::OK, "{}", admitted.raw); + + // A query the route's allowlist refuses, and a caller the budget refuses: + // neither may so much as open a connection to the upstream. + let unknown_query = record( + harness + .serve(request("GET", "/oagw/v1/proxy/metered/counted?unlisted=1", None)) + .await, + ) + .await; + assert_eq!(unknown_query.status, StatusCode::BAD_REQUEST); + let over_budget = record( + harness + .serve(request("GET", "/oagw/v1/proxy/metered/counted", None)) + .await, + ) + .await; + assert_eq!(over_budget.status, StatusCode::TOO_MANY_REQUESTS); + + assert_eq!( + common::counted_hits(), + before + 1, + "only the admitted request was dialled" + ); +} + +#[tokio::test] +async fn an_upstream_plugin_runs_before_the_route_owns_the_request() { + // The upstream requires a header, the route carries a transform of its + // own: both levels contribute, and the upstream's chain is what stands + // between the caller and the route's. + let (harness, _) = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()).await; + harness + .upstream_with_route( + "chained", + Some(serde_json::json!({ + "plugins": { + "items": [{ + "plugin_ref": common::REQUIRED_HEADERS_GUARD, + "config": { "required_request_headers": "x-upstream-level" }, + }] + } + })), + Some(serde_json::json!({ + "plugins": { "items": [common::REQUEST_ID_PLUGIN] } + })), + ) + .await; + + let without = record( + harness + .serve(request("GET", "/oagw/v1/proxy/chained/echo", None)) + .await, + ) + .await; + assert_eq!( + without.status, + StatusCode::BAD_REQUEST, + "the upstream's own guard ran even though the route binds a plugin: {}", + without.raw + ); + + let with = record( + harness.serve(request_with( + "GET", + "/oagw/v1/proxy/chained/echo", + &[("x-upstream-level", "yes")], + )) + .await, + ) + .await; + assert_eq!(with.status, StatusCode::OK, "{}", with.raw); + assert!( + with.body["headers"].get("x-request-id").is_some(), + "the route's transform ran after the upstream's guard passed it: {}", + with.raw + ); +} + +#[tokio::test] +async fn a_descendant_shadows_its_ancestors_alias() { + // Both tenants publish the same alias, each marking its own response; the + // closest match in the walk is the one that answers. + let child = Uuid::new_v4(); + let parent = Uuid::new_v4(); + let harness = Harness::build_with_hierarchy( + &JsonConfig::new(true, 1024 * 1024), + Vec::new(), + &[(child, parent)], + ) + .await + .0; + // Only the descendant marks its upstream: a response that carries the mark + // was served by the descendant's own definition, one without it by the + // ancestor's. + for (tenant, mark) in [(parent, None), (child, Some("child"))] { + let mut document = serde_json::json!({ + "alias": "shadow", + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [{ + "scheme": "http", + "host": harness.upstream().host(), + "port": harness.upstream_port(), + }] + }, + }); + if let Some(owner) = mark { + document["headers"] = serde_json::json!({ "response": { "set": { "x-owner": owner } } }); + } + let created = record( + harness + .serve_as( + tenant, + request("POST", "/oagw/v1/upstreams", Some(document)), + ) + .await, + ) + .await; + assert_eq!(created.status, StatusCode::CREATED, "{}", created.raw); + + let route = record( + harness + .serve_as( + tenant, + request( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": created.body["id"], + "match": { "http": { "methods": ["GET"], "path": "/" } }, + })), + ), + ) + .await, + ) + .await; + assert_eq!(route.status, StatusCode::CREATED, "{}", route.raw); + } + + let from_child = record( + harness + .serve_as(child, request("GET", "/oagw/v1/proxy/shadow/status/200", None)) + .await, + ) + .await; + assert_eq!(from_child.status, StatusCode::OK, "{}", from_child.raw); + assert_eq!( + from_child.header("x-owner"), + Some("child"), + "the descendant's own upstream is the closest match" + ); + + let from_parent = record( + harness + .serve_as( + parent, + request("GET", "/oagw/v1/proxy/shadow/status/200", None), + ) + .await, + ) + .await; + assert_eq!(from_parent.status, StatusCode::OK, "{}", from_parent.raw); + assert_eq!( + from_parent.header("x-owner"), + None, + "the parent is served by its own unmarked upstream" + ); +} + +#[tokio::test] +async fn an_ancestor_rate_limit_bounds_its_descendant() { + // The parent publishes an enforced budget and the alias the child routes + // to; the child's own looser figure may not loosen it. + let child = Uuid::new_v4(); + let parent = Uuid::new_v4(); + let harness = Harness::build_with_hierarchy( + &JsonConfig::new(true, 1024 * 1024), + Vec::new(), + &[(child, parent)], + ) + .await + .0; + let created = record( + harness + .serve_as( + parent, + request( + "POST", + "/oagw/v1/upstreams", + Some(serde_json::json!({ + "alias": "family", + "protocol": PROTOCOL_HTTP, + "server": { + "endpoints": [{ + "scheme": "http", + "host": harness.upstream().host(), + "port": harness.upstream_port(), + }] + }, + "rate_limit": { + "sustained": { "rate": 1, "window": "second" }, + "burst": { "capacity": 1 }, + "scope": "tenant", + "sharing": "enforce", + }, + })), + ), + ) + .await, + ) + .await; + assert_eq!(created.status, StatusCode::CREATED, "{}", created.raw); + let route = record( + harness + .serve_as( + parent, + request( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": created.body["id"], + "match": { "http": { "methods": ["GET"], "path": "/" } }, + })), + ), + ) + .await, + ) + .await; + assert_eq!(route.status, StatusCode::CREATED, "{}", route.raw); + + let first = record( + harness + .serve_as(child, request("GET", "/oagw/v1/proxy/family/status/200", None)) + .await, + ) + .await; + assert_eq!(first.status, StatusCode::OK, "{}", first.raw); + let second = record( + harness + .serve_as(child, request("GET", "/oagw/v1/proxy/family/status/200", None)) + .await, + ) + .await; + assert_eq!( + second.status, + StatusCode::TOO_MANY_REQUESTS, + "the ancestor's enforced budget is what the descendant spends: {}", + second.raw + ); +} diff --git a/gears/system/oagw/oagw/tests/rate_limiting.rs b/gears/system/oagw/oagw/tests/rate_limiting.rs new file mode 100644 index 0000000..94c9735 --- /dev/null +++ b/gears/system/oagw/oagw/tests/rate_limiting.rs @@ -0,0 +1,310 @@ +//! Router-level tests for the token-bucket rate limiter. +//! +//! The limiter is in-memory and owned by the data plane; the scenarios here are +//! the observable consequences of that: what is allowed, what is refused, what +//! a refused caller is told and what the upstream never sees. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use axum::http::StatusCode; +use common::{GATEWAY_SOURCE, Harness, JsonConfig, record, request}; + +/// A gateway with one upstream carrying `rate_limit`, if given. +async fn gateway_with_rate_limit(rate_limit: serde_json::Value) -> Harness { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + harness + .simple_upstream("echo", Some(serde_json::json!({ "rate_limit": rate_limit }))) + .await; + harness +} + +/// How many of `n` back-to-back requests the gateway lets through. +async fn let_through(harness: &Harness, n: usize) -> usize { + let mut allowed = 0; + for _ in 0..n { + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + if response.status == StatusCode::OK { + allowed += 1; + } + } + allowed +} + +#[tokio::test] +async fn requests_within_the_sustained_rate_are_allowed() { + let harness = gateway_with_rate_limit(serde_json::json!({ + "sustained": { "rate": 5, "window": "second" }, + })) + .await; + assert_eq!( + let_through(&harness, 3).await, + 3, + "three requests fit a bucket of five" + ); +} + +#[tokio::test] +async fn burst_capacity_allows_a_burst_above_the_sustained_rate() { + let harness = gateway_with_rate_limit(serde_json::json!({ + "sustained": { "rate": 1, "window": "second" }, + "burst": { "capacity": 5 }, + })) + .await; + assert_eq!( + let_through(&harness, 5).await, + 5, + "the burst capacity, not the sustained rate, is the bucket size" + ); +} + +#[tokio::test] +async fn without_a_burst_the_capacity_is_the_sustained_rate() { + let harness = gateway_with_rate_limit(serde_json::json!({ + "sustained": { "rate": 1, "window": "second" }, + })) + .await; + let allowed = let_through(&harness, 3).await; + assert_eq!(allowed, 1, "one token, so only the first request passes: {allowed}"); +} + +#[tokio::test] +async fn a_rejected_request_is_a_429_that_never_reaches_the_upstream() { + let harness = gateway_with_rate_limit(serde_json::json!({ + "sustained": { "rate": 1, "window": "second" }, + })) + .await; + assert_eq!(let_through(&harness, 1).await, 1); + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(response.source(), Some(GATEWAY_SOURCE)); + assert!( + response.header("retry-after").is_some(), + "the caller is told when it may retry" + ); + assert_eq!( + response.header("x-ratelimit-limit"), + Some("1"), + "the budget the bucket was built with" + ); + assert_eq!( + response.header("x-ratelimit-remaining"), + Some("0"), + "nothing is left" + ); + assert!( + response + .header("x-ratelimit-reset") + .and_then(|value| value.parse::().ok()) + .is_some(), + "the reset is a number of seconds" + ); +} + +#[tokio::test] +async fn the_bucket_refills_after_a_window() { + let harness = gateway_with_rate_limit(serde_json::json!({ + "sustained": { "rate": 1, "window": "second" }, + })) + .await; + let first = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!(first.status, StatusCode::OK); + let exhausted = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!(exhausted.status, StatusCode::TOO_MANY_REQUESTS); + + tokio::time::sleep(std::time::Duration::from_millis(1100)).await; + let refilled = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!( + refilled.status, + StatusCode::OK, + "a second later the tokens are back: {}", + refilled.raw + ); +} + +#[tokio::test] +async fn a_cost_of_two_drains_the_budget_twice_as_fast() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + harness + .upstream_with_route( + "echo", + Some(serde_json::json!({ + "rate_limit": { + "sustained": { "rate": 4, "window": "second" }, + "cost": 2, + }, + })), + None, + ) + .await; + // 3 requests × 2 tokens against a 4-token bucket: the third cannot be paid. + let allowed = let_through(&harness, 3).await; + assert_eq!(allowed, 2, "two requests consume the four-token budget: {allowed}"); +} + +#[tokio::test] +async fn a_stricter_route_limit_wins_and_leaves_other_routes_alone() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + let (upstream_id, _) = harness + .upstream_with_route( + "shared", + Some(serde_json::json!({ + "rate_limit": { "sustained": { "rate": 1000, "window": "second" } }, + })), + None, + ) + .await; + // A second route on the same upstream, carrying the tighter limit. + let response = harness + .serve(request( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": upstream_id, + "match": { + "http": { "methods": ["GET"], "path": "/status", "query_allowlist": [] } + }, + "rate_limit": { "sustained": { "rate": 2, "window": "second" } }, + })), + )) + .await; + let recorded = record(response).await; + assert_eq!(recorded.status, StatusCode::CREATED, "route: {}", recorded.raw); + + // Two on the narrow route fit; the third does not. + let mut narrow_allowed = 0; + for _ in 0..3 { + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/shared/status/200", None)) + .await, + ) + .await; + if response.status == StatusCode::OK { + narrow_allowed += 1; + } else { + assert_eq!(response.status, StatusCode::TOO_MANY_REQUESTS, "{}", response.raw); + } + } + assert_eq!(narrow_allowed, 2, "the route's own limit is what applies: {narrow_allowed}"); + + // The other route of the same upstream still has its thousand. + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/shared/echo", None)) + .await, + ) + .await; + assert_eq!( + response.status, + StatusCode::OK, + "a sibling route is not throttled by its neighbour: {}", + response.raw + ); +} + +#[tokio::test] +async fn a_looser_route_limit_does_not_loosen_the_upstream() { + let harness = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()) + .await + .0; + let (upstream_id, _) = harness + .upstream_with_route( + "tight", + Some(serde_json::json!({ + "rate_limit": { "sustained": { "rate": 2, "window": "second" } }, + })), + None, + ) + .await; + // A second route declaring a far wider budget than its upstream allows. + let response = record( + harness + .serve(request( + "POST", + "/oagw/v1/routes", + Some(serde_json::json!({ + "upstream_id": upstream_id, + "match": { + "http": { "methods": ["GET"], "path": "/status", "query_allowlist": [] } + }, + "rate_limit": { "sustained": { "rate": 1000, "window": "second" } }, + })), + )) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::CREATED, "{}", response.raw); + + // Two fit the upstream's budget of two; the third is refused even though + // the route it matched declared a thousand. + let mut allowed = 0; + for _ in 0..3 { + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/tight/status/200", None)) + .await, + ) + .await; + if response.status == StatusCode::OK { + allowed += 1; + } else { + assert_eq!(response.status, StatusCode::TOO_MANY_REQUESTS, "{}", response.raw); + assert_eq!(response.header("x-ratelimit-limit"), Some("2")); + } + } + assert_eq!( + allowed, 2, + "the merged budget is the tighter of the two levels: {allowed}" + ); +} + +#[tokio::test] +async fn a_sliding_window_refuses_beyond_its_rate() { + let harness = gateway_with_rate_limit(serde_json::json!({ + "algorithm": "sliding_window", + "sustained": { "rate": 2, "window": "second" }, + })) + .await; + assert_eq!(let_through(&harness, 2).await, 2); + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/echo/echo", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::TOO_MANY_REQUESTS, "{}", response.raw); + assert_eq!(response.header("x-ratelimit-limit"), Some("2")); +} diff --git a/gears/system/oagw/oagw/tests/streaming.rs b/gears/system/oagw/oagw/tests/streaming.rs new file mode 100644 index 0000000..9185a1a --- /dev/null +++ b/gears/system/oagw/oagw/tests/streaming.rs @@ -0,0 +1,326 @@ +//! Router-level tests for streaming: server-sent events and WebSocket. +//! +//! The proxy must not buffer a stream to forward it. Both tests here observe +//! that the bytes arrive as the upstream produces them. + +#![allow(clippy::unwrap_used, clippy::expect_used)] + +mod common; + +use futures_util::StreamExt; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use axum::http::StatusCode; +use common::{Harness, JsonConfig, record, request, request_with}; + +/// A gateway whose single upstream serves `/sse` and `/ws`. +async fn gateway() -> Harness { + let (harness, _) = Harness::build(&JsonConfig::new(true, 1024 * 1024), Vec::new()).await; + harness.simple_upstream("stream", None).await; + harness +} + +#[tokio::test] +async fn an_event_stream_is_forwarded_in_full() { + let harness = gateway().await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/stream/sse", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK); + assert_eq!( + response.header("content-type"), + Some("text/event-stream"), + "the upstream's content type is preserved" + ); + assert_eq!(response.header("x-upstream-mark"), Some("streamed")); + assert_eq!( + response.source(), + Some("upstream"), + "a forwarded stream is the upstream's response, not the gateway's" + ); + assert!( + response.raw.contains("event: delta") && response.raw.contains("event: done"), + "both the deltas and the terminating event are forwarded: {:?}", + response.raw + ); +} + +#[tokio::test] +async fn an_event_stream_arrives_before_the_upstream_finishes() { + let harness = gateway().await; + let response = harness + .serve(request("GET", "/oagw/v1/proxy/stream/sse", None)) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let mut stream = http_body_util::BodyStream::new(response.into_body()); + + // The first event must be readable on its own; a buffering proxy would + // return nothing until the upstream closed the stream. + let first = tokio::time::timeout(std::time::Duration::from_secs(5), stream.next()) + .await + .expect("the first chunk arrives without waiting for the stream to end") + .expect("the stream yields a first chunk"); + let first = match first { + Ok(chunk) => match chunk.into_data() { + Ok(data) => String::from_utf8_lossy(&data).into_owned(), + Err(frame) => panic!("the stream yields data, not a frame: {frame:?}"), + }, + Err(error) => panic!("the stream yields a frame, not an error: {error}"), + }; + assert!(first.contains("event: delta"), "the first chunk is the first event: {first}"); +} + +#[tokio::test] +async fn a_websocket_upgrade_is_tunneled() { + let harness = gateway().await; + let request = request_with( + "GET", + "/oagw/v1/proxy/stream/ws", + &[ + ("host", "127.0.0.1"), + ("connection", "Upgrade"), + ("upgrade", "websocket"), + ("sec-websocket-version", "13"), + ("sec-websocket-key", "dGhlIHNhbXBsZSBub25jZQ=="), + ], + ); + let response = record(harness.serve(request).await).await; + assert_eq!( + response.status, + StatusCode::SWITCHING_PROTOCOLS, + "the upgrade is negotiated, not refused: {}", + response.raw + ); + assert_eq!( + response.header("sec-websocket-accept"), + Some("s3pPLMBiTxaQ9kYGzzhZRbK+xOo="), + "the accept key is derived as the WebSocket handshake requires" + ); + assert_eq!( + response.source(), + Some(common::UPSTREAM_SOURCE), + "a streamed protocol carries the error-source header too" + ); +} + +#[tokio::test] +async fn a_websocket_upgrade_without_the_handshake_headers_is_not_tunneled() { + let harness = gateway().await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/stream/ws", None)) + .await, + ) + .await; + // Without `upgrade: websocket` the upstream answers with a plain HTTP + // response rather than switching protocols; either way the gateway does not + // pretend to tunnel. + assert_ne!(response.status, StatusCode::SWITCHING_PROTOCOLS); +} + +#[tokio::test] +async fn the_end_of_the_upstream_stream_ends_the_forwarded_response() { + let harness = gateway().await; + let response = harness + .serve(request("GET", "/oagw/v1/proxy/stream/sse", None)) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let mut stream = http_body_util::BodyStream::new(response.into_body()); + + let mut saw_done = false; + loop { + match tokio::time::timeout(std::time::Duration::from_secs(5), stream.next()).await { + Err(elapsed) => panic!("the stream did not end on its own: {elapsed}"), + Ok(None) => break, + Ok(Some(Ok(frame))) => { + let Ok(data) = frame.into_data() else { break }; + saw_done |= String::from_utf8_lossy(&data).contains("event: done"); + } + Ok(Some(Err(error))) => panic!("the stream errored rather than ending: {error}"), + } + } + assert!( + saw_done, + "the terminating event was forwarded and the body ended when the upstream closed it" + ); +} + +/// A WebSocket client speaking just enough of RFC 6455 to prove frames cross +/// the gateway in both directions. +struct Client { + socket: tokio::net::TcpStream, +} + +/// The mask a client frame carries: the value is irrelevant, its presence is +/// not, since RFC 6455 requires every client frame to be masked. +const MASK: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + +impl Client { + /// Dials the gateway and completes the opening handshake. + /// + /// # Panics + /// Panics when the gateway refuses the upgrade. + async fn connect(address: std::net::SocketAddr, path: &str) -> Self { + let mut socket = + tokio::net::TcpStream::connect(address).await.expect("the gateway is listening"); + let request = format!( + "GET {path} HTTP/1.1\r\nHost: {address}\r\nUpgrade: websocket\r\n\ + Connection: Upgrade\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\n\ + Sec-WebSocket-Version: 13\r\n\r\n" + ); + socket.write_all(request.as_bytes()).await.expect("handshake is written"); + let head = read_head(&mut socket).await; + assert!( + head.starts_with("HTTP/1.1 101"), + "the upgrade is negotiated, not refused: {head}" + ); + assert!( + head.contains("sec-websocket-accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo="), + "the accept key is derived by the upstream, not invented by the gateway: {head}" + ); + Self { socket } + } + + /// Sends one masked text frame. + /// + /// # Panics + /// Panics when the frame cannot be written. + async fn send_text(&mut self, text: &str) { + let payload = text.as_bytes(); + let mut frame = vec![0x81, u8::try_from(payload.len()).expect("a short frame") | 0x80]; + frame.extend_from_slice(&MASK); + frame.extend( + payload + .iter() + .zip(MASK.iter().cycle()) + .map(|(byte, mask)| byte ^ mask), + ); + self.socket.write_all(&frame).await.expect("the frame is written"); + self.socket.flush().await.expect("the frame is flushed"); + } + + /// Reads one frame and returns its payload, insisting it is text. + /// + /// # Panics + /// Panics when the frame is not text. + async fn recv_text(&mut self) -> String { + let mut head = [0_u8; 2]; + self.socket.read_exact(&mut head).await.expect("a frame header arrives"); + assert_eq!(head[0] & 0x0f, 1, "the echo is a text frame, not {:#x}", head[0]); + let length = usize::from(head[1] & 0x7f); + let mut payload = vec![0_u8; length]; + self.socket.read_exact(&mut payload).await.expect("a frame body arrives"); + String::from_utf8(payload).expect("the echo is utf-8") + } + + /// Sends a close frame. + /// + /// # Panics + /// Panics when the frame cannot be written. + async fn send_close(&mut self) { + let mut frame = vec![0x88, 0x80]; + frame.extend_from_slice(&MASK); + self.socket.write_all(&frame).await.expect("the close is written"); + self.socket.flush().await.expect("the close is flushed"); + } + + /// Reads until the peer hangs up, expecting no further frames. + /// + /// # Panics + /// Panics when the tunnel keeps the connection open past the close. + async fn assert_ends_after_close(&mut self) { + let mut trailing = Vec::new(); + loop { + let mut chunk = [0_u8; 256]; + match self.socket.read(&mut chunk).await { + Ok(0) | Err(_) => break, + Ok(read) => trailing.extend_from_slice(&chunk[..read]), + } + } + // A peer that answers the close with its own close frame is correct; + // anything else still open is not. + assert!( + trailing.iter().all(|byte| *byte == 0x88 || *byte == 0), + "nothing but a close frame follows the close: {trailing:?}" + ); + } +} + +/// Reads an HTTP response head, byte by byte, so the socket keeps the rest. +/// +/// # Panics +/// Panics when the response head cannot be read. +async fn read_head(socket: &mut tokio::net::TcpStream) -> String { + let mut head = Vec::new(); + while !head.ends_with(b"\r\n\r\n") { + let mut byte = [0_u8; 1]; + let read = socket.read(&mut byte).await.expect("the response head is readable"); + assert!(read > 0, "the connection closed before the head ended"); + head.push(byte[0]); + } + String::from_utf8_lossy(&head).into_owned() +} + +#[tokio::test] +async fn websocket_frames_cross_the_gateway_in_both_directions() { + let harness = gateway().await; + let address = harness.serve_tcp().await; + let mut client = Client::connect(address, "/oagw/v1/proxy/stream/ws").await; + + // One frame each way, then a second pair on the same tunnel: the pipe has + // to stay open and keep its direction, not hand one message across. + for round in ["client to upstream", "and back again"] { + client.send_text(round).await; + let echo = client.recv_text().await; + assert_eq!(echo, round, "the upstream's echo comes back over the tunnel"); + } + + client.send_close().await; + client.assert_ends_after_close().await; +} + +#[tokio::test] +async fn a_client_that_stops_reading_does_not_hang_the_gateway() { + let harness = gateway().await; + let response = harness + .serve(request("GET", "/oagw/v1/proxy/stream/sse/long", None)) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let mut stream = http_body_util::BodyStream::new(response.into_body()); + + let first = tokio::time::timeout(std::time::Duration::from_secs(5), stream.next()) + .await + .expect("the first chunk arrives") + .expect("the stream yields a first chunk") + .expect("the first chunk is data"); + assert!(first.into_data().is_ok()); + + // Dropping the body is a client disconnect: the gateway must stop reading + // the upstream rather than keep pumping a stream nobody is consuming. The + // upstream counts its live generators, so the release is observable. + drop(stream); + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + assert!( + std::time::Instant::now() < deadline, + "the upstream generator is never released" + ); + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + let response = record( + harness + .serve(request("GET", "/oagw/v1/proxy/stream/sse/active", None)) + .await, + ) + .await; + assert_eq!(response.status, StatusCode::OK, "{}", response.raw); + if response.body["active"].as_u64() == Some(0) { + break; + } + } +}