diff --git a/Cargo.lock b/Cargo.lock index 9c02857..0b47abc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1576,7 +1576,9 @@ dependencies = [ "http-body-util", "httparse", "httpmock", + "humantime", "hyper", + "hyper-rustls", "hyper-util", "inventory", "mime", diff --git a/gears/system/oagw/oagw/Cargo.toml b/gears/system/oagw/oagw/Cargo.toml index a18b934..5e2aa9d 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"] @@ -71,6 +74,7 @@ parking_lot = { workspace = true } psl = { workspace = true } thiserror = { workspace = true } mime = { workspace = true } +humantime = { workspace = true } # DP deps form_urlencoded = "1" pingora-memory-cache = "0.8" @@ -79,6 +83,8 @@ tokio = { workspace = true, features = ["time"] } tokio-retry = { workspace = true } hyper = { workspace = true } hyper-util = { workspace = true } +hyper-rustls = { workspace = true } +http-body-util = { workspace = true } # Pingora proxy engine pingora-proxy = { version = "0.8", features = ["rustls"] } pingora-core = { version = "0.8", features = ["rustls"] } @@ -98,7 +104,7 @@ credstore-sdk = { workspace = true, features = ["test-util"] } toolkit = { workspace = true, features = ["bootstrap"] } opentelemetry_sdk = { workspace = true, features = ["testing"] } types-registry-sdk = { workspace = true, features = ["test-util"] } -tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time", "net", "test-util"] } +tokio = { workspace = true, features = ["macros", "rt", "rt-multi-thread", "time", "net", "io-util", "test-util"] } tower = { workspace = true, features = ["util"] } hyper = { workspace = true } hyper-util = { workspace = true } 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..8da8570 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/mod.rs @@ -0,0 +1,2 @@ +//! REST surface. +pub mod rest; diff --git a/gears/system/oagw/oagw/src/api/rest/dto.rs b/gears/system/oagw/oagw/src/api/rest/dto.rs new file mode 100644 index 0000000..a2ffda1 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/dto.rs @@ -0,0 +1,282 @@ +//! Wire DTOs for the management API (DESIGN §3.3). +//! +//! The domain model is the storage shape; these are the request/response shapes +//! with server-managed fields (`id`, `tenant_id`, `gts_id`, timestamps) kept out +//! of the create/replace payloads. + +use http::HeaderValue; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers as gts; +use crate::domain::model::{ + AuthConfig, CorsConfig, Endpoint, MatchConfig, PluginsConfig, RateLimitConfig, Route, + ServerConfig, Upstream, +}; + +/// `POST /upstreams` and `PUT /upstreams/{id}`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct UpstreamRequest { + /// Routing key; derived from the endpoints when omitted. + #[serde(skip_serializing_if = "Option::is_none")] + pub alias: Option, + /// Defaults to `true`. + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Endpoint pool. + pub server: ServerConfig, + /// Defaults to the HTTP protocol. + #[serde(skip_serializing_if = "Option::is_none")] + pub protocol: Option, + /// Outbound auth. + #[serde(skip_serializing_if = "Option::is_none")] + pub auth: Option, + /// Header transformation rules. + #[serde(default)] + pub headers: crate::domain::model::HeadersConfig, + /// Guard/transform plugin bindings. + #[serde(default)] + pub plugins: PluginsConfig, + /// Rate limit. + #[serde(skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS policy. + #[serde(skip_serializing_if = "Option::is_none")] + pub cors: Option, +} + +impl UpstreamRequest { + /// Builds a domain upstream, leaving alias derivation to the service. + #[must_use] + pub fn into_domain(self, tenant_id: &str) -> Upstream { + let id = Uuid::new_v4(); + Upstream { + id, + gts_id: gts::resource_id(gts::TYPE_UPSTREAM, &id), + tenant_id: tenant_id.to_owned(), + alias: self.alias.unwrap_or_default(), + enabled: self.enabled.unwrap_or(true), + tags: self.tags, + server: self.server, + protocol: self + .protocol + .unwrap_or_else(|| gts::PROTOCOL_HTTP.to_owned()), + auth: self.auth, + headers: self.headers, + plugins: self.plugins, + rate_limit: self.rate_limit, + cors: self.cors, + created_at: crate::domain::model::now_rfc3339(), + updated_at: crate::domain::model::now_rfc3339(), + } + } + + /// Applies a full replacement: omitted optional fields are cleared. + pub fn apply_to(self, existing: &mut Upstream) { + existing.alias = self.alias.unwrap_or_else(|| existing.alias.clone()); + existing.enabled = self.enabled.unwrap_or(true); + existing.tags = self.tags; + existing.server = self.server; + existing.protocol = self + .protocol + .unwrap_or_else(|| gts::PROTOCOL_HTTP.to_owned()); + existing.auth = self.auth; + existing.headers = self.headers; + existing.plugins = self.plugins; + existing.rate_limit = self.rate_limit; + existing.cors = self.cors; + existing.updated_at = crate::domain::model::now_rfc3339(); + } +} + +/// A stored upstream as the API returns it. +pub type UpstreamResponse = Upstream; + +/// `POST /routes` and `PUT /routes/{id}`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct RouteRequest { + /// Upstream this route serves (create only; immutable afterwards). + #[serde(skip_serializing_if = "Option::is_none")] + pub upstream_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub tags: Vec, + /// Matching rules. + #[serde(default)] + pub match_config: MatchConfig, + /// Match precedence among siblings (lower runs first on a tie). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub priority: Option, + /// Guard/transform plugin bindings. + #[serde(default)] + pub plugins: PluginsConfig, + /// Rate limit. + #[serde(skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS policy, overriding the upstream's when set. + #[serde(skip_serializing_if = "Option::is_none")] + pub cors: Option, +} + +impl RouteRequest { + /// Builds a route for storage. + /// + /// # Errors + /// Returns [`DomainError::Validation`] when `upstream_id` is missing. + pub fn into_domain(self, tenant_id: &str) -> Result { + let upstream_id = self + .upstream_id + .ok_or_else(|| DomainError::Validation("route requires an `upstream_id`".to_owned()))?; + let id = Uuid::new_v4(); + Ok(Route { + id, + gts_id: gts::resource_id(gts::TYPE_ROUTE, &id), + tenant_id: tenant_id.to_owned(), + upstream_id, + enabled: self.enabled.unwrap_or(true), + tags: self.tags, + match_config: self.match_config, + priority: self.priority.unwrap_or_default(), + plugins: self.plugins, + rate_limit: self.rate_limit, + cors: self.cors, + created_at: crate::domain::model::now_rfc3339(), + updated_at: crate::domain::model::now_rfc3339(), + }) + } + + /// Applies a full replacement; `upstream_id` is immutable. + pub fn apply_to(self, existing: &mut Route) { + existing.enabled = self.enabled.unwrap_or(true); + existing.tags = self.tags; + existing.match_config = self.match_config; + existing.priority = self.priority.unwrap_or_default(); + existing.plugins = self.plugins; + existing.rate_limit = self.rate_limit; + existing.cors = self.cors; + existing.updated_at = crate::domain::model::now_rfc3339(); + } +} + +/// A stored route as the API returns it. +pub type RouteResponse = Route; + +/// `POST /plugins`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default)] +pub struct PluginRequest { + /// Human-readable name. + #[serde(default)] + pub name: String, + /// `auth`, `guard` or `transform`. + #[serde(rename = "type", default)] + pub plugin_type: String, + /// JSON schema describing the plugin's config. + #[serde(default)] + pub config_schema: serde_json::Value, + /// Starlark source. + #[serde(default)] + pub source_code: String, +} + +impl PluginRequest { + /// Builds a plugin for storage. + #[must_use] + pub fn into_domain(self, tenant_id: &str) -> crate::domain::model::Plugin { + let id = Uuid::new_v4(); + crate::domain::model::Plugin { + id, + gts_id: gts::resource_id(gts::TYPE_PLUGIN, &id), + tenant_id: tenant_id.to_owned(), + name: self.name, + plugin_type: self.plugin_type, + config_schema: self.config_schema, + source_code: self.source_code, + created_at: crate::domain::model::now_rfc3339(), + } + } +} + +/// A stored plugin as the API returns it. +pub type PluginResponse = crate::domain::model::Plugin; + +/// The response body of a list endpoint. +#[derive(Debug, Serialize, Deserialize)] +pub struct ListResponse { + /// The matching resources. + pub items: Vec, + /// Total number of resources the filter matches. + pub total: usize, +} + +impl ListResponse { + /// Wraps a page of results. + #[must_use] + pub fn new(items: Vec, total: usize) -> Self { + Self { items, total } + } +} + +/// The accepted create/replace upstream body, as parsed from JSON. +/// +/// Kept as an alias so handler signatures read naturally. +pub type CreateUpstream = UpstreamRequest; +/// The accepted create/replace route body. +pub type CreateRoute = RouteRequest; +/// The accepted create-plugin body. +pub type CreatePlugin = PluginRequest; + +/// Builds a `201` response carrying the created resource. +#[must_use] +pub fn created(value: &T) -> http::Response { + json_response(http::StatusCode::CREATED, value) +} + +/// Builds a JSON response with the given status. +#[must_use] +pub fn json_response( + status: http::StatusCode, + value: &T, +) -> http::Response { + // Built by hand rather than through the fallible builder: a status code + // plus one well-known header cannot fail to construct. + let mut response = http::Response::new(axum::body::Body::from( + serde_json::to_vec(value).unwrap_or_else(|_| b"{}".to_vec()), + )); + *response.status_mut() = status; + response.headers_mut().insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/json"), + ); + response +} + +/// An empty response with the given status. +#[must_use] +pub fn empty_response(status: http::StatusCode) -> http::Response { + let mut response = http::Response::new(axum::body::Body::empty()); + *response.status_mut() = status; + response +} + +/// A convenience constructor used by the tests for a single-endpoint server. +#[must_use] +pub fn server_one(host: &str, port: u16, https: bool) -> ServerConfig { + ServerConfig { + endpoints: vec![Endpoint { + scheme: if https { + crate::domain::model::EndpointScheme::Https + } else { + crate::domain::model::EndpointScheme::Http + }, + host: host.to_owned(), + port: Some(port), + }], + } +} 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..d64eeb9 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/error.rs @@ -0,0 +1,85 @@ +//! RFC 9457 problem responses (DESIGN §3.3 "Error Response Format"). +//! +//! Every gateway error — management or proxy — is rendered here, so the status +//! code, the GTS `type` and the `X-OAGW-Error-Source` header can only change +//! together. + +use axum::body::Body; +use http::{HeaderValue, StatusCode}; +use serde_json::{Map, Value, json}; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers as gts; + +/// The `application/problem+json` body for a gateway error. +#[must_use] +pub fn problem_body(error: &DomainError, instance: Option<&str>) -> Value { + let mut body = Map::new(); + body.insert( + "type".to_owned(), + json!(gts::error_type(error.instance_id())), + ); + body.insert("title".to_owned(), json!(error.title())); + body.insert("status".to_owned(), json!(error.status().as_u16())); + body.insert("detail".to_owned(), json!(error.to_string())); + if let Some(instance) = instance { + body.insert("instance".to_owned(), json!(instance)); + } + if let Some(retry_after) = error.retry_after() { + body.insert( + "retry_after_seconds".to_owned(), + json!(retry_after.as_secs()), + ); + } + body.insert("retriable".to_owned(), json!(error.retriable())); + for (name, value) in error.members() { + body.insert(name.to_owned(), value); + } + Value::Object(body) +} + +/// An `application/problem+json` response for a gateway error. +#[must_use] +pub fn problem_response(error: &DomainError, instance: Option<&str>) -> http::Response { + // Built by hand rather than through the fallible builder: a fixed content + // type and well-known header names cannot fail to construct. + let mut response = http::Response::new(Body::from( + serde_json::to_vec(&problem_body(error, instance)) + .unwrap_or_else(|_| format!("{{\"status\":{}}}", error.status().as_u16()).into_bytes()), + )); + *response.status_mut() = error.status(); + let headers = response.headers_mut(); + headers.insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/problem+json"), + ); + headers.insert( + http::HeaderName::from_static(gts::HEADER_ERROR_SOURCE), + HeaderValue::from_static(gts::ERROR_SOURCE_GATEWAY), + ); + if let Some(retry_after) = error.retry_after() { + headers.insert( + http::header::RETRY_AFTER, + HeaderValue::from(retry_after.as_secs()), + ); + } + response +} + +/// A management handler error, mapped to its problem response. +#[must_use] +pub fn management_error(error: &DomainError, path: &str) -> http::Response { + problem_response(error, Some(path)) +} + +/// `501 Not Implemented` — the documented non-goals that are routed but not +/// implemented. +#[must_use] +pub fn not_implemented(feature: &str) -> http::Response { + let mut response = problem_response( + &DomainError::Validation(format!("{feature} is not implemented")), + None, + ); + *response.status_mut() = StatusCode::NOT_IMPLEMENTED; + response +} 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..115292e --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/management.rs @@ -0,0 +1,306 @@ +//! Management REST handlers (DESIGN §3.3 "Management API"). +//! +//! Every handler is a thin adapter: extract the tenant from the security +//! context, call the [`ControlPlaneService`], and render either the JSON +//! resource or the RFC 9457 problem for the [`DomainError`] it returned. + +use std::sync::Arc; + +use axum::extract::rejection::JsonRejection; +use axum::extract::{Extension, Json, Path}; +use http::StatusCode; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::dto::{ + self, CreatePlugin, CreateRoute, CreateUpstream, ListResponse, PluginResponse, RouteResponse, + UpstreamResponse, +}; +use crate::api::rest::error; +use crate::domain::services::management::ControlPlaneService; +use crate::infra::proxy::service::ProxyBody; + +type Response = http::Response; +type Service = Arc; + +/// Renders an extractor rejection as the gear's own validation problem, so a +/// malformed or mis-typed body is answered `400` in `application/problem+json` +/// with the gateway error source rather than axum's plain-text default. +fn rejected_json(rejection: &JsonRejection) -> Response { + error::problem_response( + &crate::domain::error::DomainError::Validation(rejection.body_text()), + None, + ) +} + +/// The calling tenant, as a string. +#[must_use] +pub fn tenant_id(ctx: &SecurityContext) -> String { + ctx.subject_tenant_id().to_string() +} + +// --------------------------------------------------------------------------- +// Upstreams +// --------------------------------------------------------------------------- + +/// `POST /oagw/v1/upstreams`. +/// +/// # Errors +/// Returns a problem response when validation fails. +pub async fn create_upstream( + Extension(service): Extension, + Extension(ctx): Extension, + body: Result, JsonRejection>, +) -> Response { + let body = match body { + Ok(Json(body)) => body, + Err(rejection) => return rejected_json(&rejection), + }; + let tenant = tenant_id(&ctx); + let provided_alias = body.alias.clone(); + match service + .create_upstream(&tenant, body.into_domain(&tenant), provided_alias) + .await + { + Ok(upstream) => dto::json_response(StatusCode::CREATED, &UpstreamResponse::from(upstream)), + Err(e) => error::problem_response(&e, None), + } +} + +/// `GET /oagw/v1/upstreams`. +pub async fn list_upstreams( + Extension(service): Extension, + Extension(ctx): Extension, +) -> Response { + match service.list_upstreams(&tenant_id(&ctx)).await { + Ok(items) => dto::json_response( + StatusCode::OK, + &ListResponse::new(items.clone(), items.len()), + ), + Err(e) => error::problem_response(&e, None), + } +} + +/// `GET /oagw/v1/upstreams/{id}`. +pub async fn get_upstream( + Extension(service): Extension, + Extension(ctx): Extension, + Path(id): Path, +) -> Response { + match service.get_upstream(&tenant_id(&ctx), id).await { + Ok(upstream) => dto::json_response(StatusCode::OK, &UpstreamResponse::from(upstream)), + Err(e) => error::problem_response(&e, None), + } +} + +/// `PUT /oagw/v1/upstreams/{id}`. +/// +/// # Errors +/// Returns a problem response when validation fails. +pub async fn replace_upstream( + Extension(service): Extension, + Extension(ctx): Extension, + Path(id): Path, + body: Result, JsonRejection>, +) -> Response { + let body = match body { + Ok(Json(body)) => body, + Err(rejection) => return rejected_json(&rejection), + }; + let tenant = tenant_id(&ctx); + let provided_alias = body.alias.clone(); + match service + .replace_upstream(&tenant, id, body.into_domain(&tenant), provided_alias) + .await + { + Ok(upstream) => dto::json_response(StatusCode::OK, &UpstreamResponse::from(upstream)), + Err(e) => error::problem_response(&e, None), + } +} + +/// `DELETE /oagw/v1/upstreams/{id}`. +pub async fn delete_upstream( + Extension(service): Extension, + Extension(ctx): Extension, + Path(id): Path, +) -> Response { + match service.delete_upstream(&tenant_id(&ctx), id).await { + Ok(()) => dto::empty_response(StatusCode::NO_CONTENT), + Err(e) => error::problem_response(&e, None), + } +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// `POST /oagw/v1/routes`. +/// +/// # Errors +/// Returns a problem response when validation fails. +pub async fn create_route( + Extension(service): Extension, + Extension(ctx): Extension, + body: Result, JsonRejection>, +) -> Response { + let body = match body { + Ok(Json(body)) => body, + Err(rejection) => return rejected_json(&rejection), + }; + let tenant = tenant_id(&ctx); + match body.into_domain(&tenant) { + Ok(route) => match service.create_route(&tenant, route).await { + Ok(route) => dto::json_response(StatusCode::CREATED, &RouteResponse::from(route)), + Err(e) => error::problem_response(&e, None), + }, + Err(e) => error::problem_response(&e, None), + } +} + +/// `GET /oagw/v1/routes`. +pub async fn list_routes( + Extension(service): Extension, + Extension(ctx): Extension, +) -> Response { + match service.list_routes(&tenant_id(&ctx)).await { + Ok(items) => dto::json_response( + StatusCode::OK, + &ListResponse::new(items.clone(), items.len()), + ), + Err(e) => error::problem_response(&e, None), + } +} + +/// `GET /oagw/v1/routes/{id}`. +pub async fn get_route( + Extension(service): Extension, + Extension(ctx): Extension, + Path(id): Path, +) -> Response { + match service.get_route(&tenant_id(&ctx), id).await { + Ok(route) => dto::json_response(StatusCode::OK, &RouteResponse::from(route)), + Err(e) => error::problem_response(&e, None), + } +} + +/// `PUT /oagw/v1/routes/{id}`. +/// +/// # Errors +/// Returns a problem response when validation fails. +pub async fn replace_route( + Extension(service): Extension, + Extension(ctx): Extension, + Path(id): Path, + body: Result, JsonRejection>, +) -> Response { + let body = match body { + Ok(Json(body)) => body, + Err(rejection) => return rejected_json(&rejection), + }; + let tenant = tenant_id(&ctx); + let route = match body.into_domain(&tenant) { + Ok(route) => route, + Err(e) => return error::problem_response(&e, None), + }; + match service.replace_route(&tenant, id, route).await { + Ok(route) => dto::json_response(StatusCode::OK, &RouteResponse::from(route)), + Err(e) => error::problem_response(&e, None), + } +} + +/// `DELETE /oagw/v1/routes/{id}`. +pub async fn delete_route( + Extension(service): Extension, + Extension(ctx): Extension, + Path(id): Path, +) -> Response { + match service.delete_route(&tenant_id(&ctx), id).await { + Ok(()) => dto::empty_response(StatusCode::NO_CONTENT), + Err(e) => error::problem_response(&e, None), + } +} + +// --------------------------------------------------------------------------- +// Plugins +// --------------------------------------------------------------------------- + +/// `POST /oagw/v1/plugins`. +/// +/// # Errors +/// Returns a problem response when validation fails. +pub async fn create_plugin( + Extension(service): Extension, + Extension(ctx): Extension, + body: Result, JsonRejection>, +) -> Response { + let body = match body { + Ok(Json(body)) => body, + Err(rejection) => return rejected_json(&rejection), + }; + let tenant = tenant_id(&ctx); + match service + .create_plugin(&tenant, body.into_domain(&tenant)) + .await + { + Ok(plugin) => dto::json_response(StatusCode::CREATED, &PluginResponse::from(plugin)), + Err(e) => error::problem_response(&e, None), + } +} + +/// `GET /oagw/v1/plugins`. +pub async fn list_plugins( + Extension(service): Extension, + Extension(ctx): Extension, +) -> Response { + match service.list_plugins(&tenant_id(&ctx)).await { + Ok(items) => dto::json_response( + StatusCode::OK, + &ListResponse::new(items.clone(), items.len()), + ), + Err(e) => error::problem_response(&e, None), + } +} + +/// `GET /oagw/v1/plugins/{id}`. +pub async fn get_plugin( + Extension(service): Extension, + Extension(ctx): Extension, + Path(id): Path, +) -> Response { + match service.get_plugin(&tenant_id(&ctx), id).await { + Ok(plugin) => dto::json_response(StatusCode::OK, &PluginResponse::from(plugin)), + Err(e) => error::problem_response(&e, None), + } +} + +/// `GET /oagw/v1/plugins/{id}/source`. +pub async fn get_plugin_source( + Extension(service): Extension, + Extension(ctx): Extension, + Path(id): Path, +) -> Response { + match service.get_plugin(&tenant_id(&ctx), id).await { + Ok(plugin) => dto::json_response( + StatusCode::OK, + &serde_json::json!({ + "id": plugin.id, + "name": plugin.name, + "type": plugin.plugin_type, + "source_code": plugin.source_code, + }), + ), + Err(e) => error::problem_response(&e, None), + } +} + +/// `DELETE /oagw/v1/plugins/{id}`. +pub async fn delete_plugin( + Extension(service): Extension, + Extension(ctx): Extension, + Path(id): Path, +) -> Response { + match service.delete_plugin(&tenant_id(&ctx), id).await { + Ok(()) => dto::empty_response(StatusCode::NO_CONTENT), + Err(e) => error::problem_response(&e, None), + } +} 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..160832a --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/mod.rs @@ -0,0 +1,3 @@ +//! Request 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..1f32ddc --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers/proxy.rs @@ -0,0 +1,137 @@ +//! Proxy REST handler (DESIGN §3.3 "Proxy API"). +//! +//! The handler detaches the request from the HTTP server — method, path suffix, +//! query, headers, buffered body and the pending WebSocket upgrade — and hands +//! it to the [`DataPlaneService`]. + +use axum::extract::{Extension, Path, RawQuery}; +use http::{HeaderMap, Request}; +use http_body_util::BodyExt; +use toolkit_security::SecurityContext; + +use crate::api::rest::error; +use crate::config::OagwConfig; +use crate::domain::error::DomainError; +use crate::infra::proxy::service::{self, DataPlaneService, ProxyBody, ProxyCall}; + +type Response = http::Response; + +/// `ANY /oagw/v1/proxy/{*path}` — the data plane entry point. +/// +/// Body validation happens here, where the inbound body is still available: +/// transfer encodings other than `chunked`, a `Content-Length` that disagrees +/// with the actual body, and bodies over `max_body_bytes` are all rejected +/// before any upstream work (DESIGN §3.2 "Body Validation Rules"). +/// +/// # Errors +/// Returns a problem response for a malformed request body. +pub async fn proxy( + Extension(data_plane): Extension>, + Extension(config): Extension, + Extension(ctx): Extension, + Path(suffix): Path, + RawQuery(query): RawQuery, + headers: HeaderMap, + mut request: Request, +) -> Response { + // The upgrade future must be captured before the request is taken apart. + let upgrade = if service::is_websocket_upgrade(request.method(), &headers) { + Some(hyper::upgrade::on(&mut request)) + } else { + None + }; + + let content_length = headers.get(http::header::CONTENT_LENGTH).cloned(); + let transfer_encoding = headers.get(http::header::TRANSFER_ENCODING).cloned(); + let (parts, body) = request.into_parts(); + + let mut call = ProxyCall { + tenant_id: ctx.subject_tenant_id().to_string(), + user_id: Some(ctx.subject_id().to_string()), + client_ip: client_ip(&headers), + method: parts.method.clone(), + path: format!("/{suffix}"), + query: query.unwrap_or_default(), + headers, + body: bytes::Bytes::new(), + upgrade, + }; + + if let Err(e) = validate_encoding(transfer_encoding.as_ref()) { + return error::problem_response(&e, Some(&call.path)); + } + + call.body = match collect_body(content_length.as_ref(), body, &config).await { + Ok(bytes) => bytes, + Err(e) => return error::problem_response(&e, Some(&call.path)), + }; + + data_plane.proxy(call).await +} + +/// The client IP as advertised by the fronting proxy, when present. +fn client_ip(headers: &HeaderMap) -> Option { + headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.split(',').next()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) +} + +/// Rejects transfer encodings the proxy cannot frame. +fn validate_encoding(encoding: Option<&http::HeaderValue>) -> Result<(), DomainError> { + let Some(value) = encoding else { + return Ok(()); + }; + let value = value.to_str().unwrap_or_default(); + let supported = value + .split(',') + .map(str::trim) + .all(|token| token.is_empty() || token.eq_ignore_ascii_case("chunked")); + if supported { + Ok(()) + } else { + Err(DomainError::Validation(format!( + "unsupported transfer encoding `{value}`; only chunked is supported" + ))) + } +} + +/// Buffers the inbound body, enforcing the declared length and the size limit. +async fn collect_body( + content_length: Option<&http::HeaderValue>, + body: ProxyBody, + config: &OagwConfig, +) -> Result { + let declared = content_length + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .map(str::parse::) + .transpose() + .map_err(|_| DomainError::Validation("content-length is not a valid integer".to_owned()))?; + // A known size is checked before a single byte is buffered. + if let Some(expected) = declared + && expected > config.max_body_bytes() + { + return Err(DomainError::PayloadTooLarge); + } + let collected = body + .collect() + .await + .map_err(|e| DomainError::Validation(format!("request body could not be read: {e}")))? + .to_bytes(); + if let Some(expected) = declared + && expected != collected.len() as u64 + { + return Err(DomainError::Validation(format!( + "content-length {expected} does not match the body size {}", + collected.len() + ))); + } + if collected.len() as u64 > config.max_body_bytes() { + return Err(DomainError::PayloadTooLarge); + } + Ok(collected) +} 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..0349e62 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/mod.rs @@ -0,0 +1,9 @@ +//! REST handlers, DTOs, problem responses and route registration. +pub mod dto; +pub mod error; +pub mod handlers; +pub mod routes; + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod router_tests; diff --git a/gears/system/oagw/oagw/src/api/rest/router_tests.rs b/gears/system/oagw/oagw/src/api/rest/router_tests.rs new file mode 100644 index 0000000..a0a73fb --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/router_tests.rs @@ -0,0 +1,988 @@ +//! Router-level tests for the management REST API (DESIGN §3.3). +//! +//! Requests go through the real `Router` with `tower::ServiceExt::oneshot`, so +//! the assertions cover the wire shape — status codes, `application/problem+json` +//! bodies and the `X-OAGW-Error-Source` header — rather than the service layer. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::sync::Arc; + +use axum::Router; +use axum::body::Body; +use http::{Method, Request, StatusCode}; +use serde_json::{Value, json}; +use toolkit::api::OpenApiRegistry; +use toolkit::api::operation_builder::OperationSpec; +use toolkit_security::SecurityContext; +use tower::ServiceExt; +use uuid::Uuid; + +use crate::config::OagwConfig; +use crate::domain::gts_helpers as gts; +use crate::domain::model::{HttpMatch, HttpMethod, MatchConfig, PathSuffixMode, Route}; +use crate::domain::services::management::ControlPlaneService; +use crate::infra::plugin::registry::{ + AuthPluginRegistry, GuardPluginRegistry, TransformPluginRegistry, +}; +use crate::infra::proxy::service::DataPlaneService; +use crate::infra::storage::memory::{ + MemoryPluginRepository, MemoryRouteRepository, MemoryUpstreamRepository, +}; + +const TENANT: &str = "00000000-0000-0000-0000-000000000001"; + +struct NoopOpenApiRegistry; + +impl OpenApiRegistry for NoopOpenApiRegistry { + fn register_operation(&self, _spec: &OperationSpec) {} + + fn ensure_schema_raw( + &self, + name: &str, + _schemas: Vec<( + String, + utoipa::openapi::RefOr, + )>, + ) -> String { + name.to_owned() + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +/// The gateway's router, with a security context injected into every request. +fn router() -> Router { + router_with_config(OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }) +} + +/// [`router`] with an explicit gear configuration, for tests that exercise a +/// non-default limit (the body limit in particular). +fn router_with_config(config: OagwConfig) -> Router { + struct FlatHierarchy; + #[async_trait::async_trait] + impl crate::domain::repo::TenantHierarchy for FlatHierarchy { + async fn chain(&self, tenant_id: &str) -> Vec { + vec![tenant_id.to_owned()] + } + } + let control_plane = Arc::new(ControlPlaneService::new( + Arc::new(MemoryUpstreamRepository::default()), + Arc::new(MemoryRouteRepository::default()), + Arc::new(MemoryPluginRepository::default()), + Arc::new(FlatHierarchy), + true, + )); + let data_plane = DataPlaneService::new(Arc::clone(&control_plane), config.clone()) + .expect("a buildable data plane") + .with_registries( + AuthPluginRegistry::empty(), + GuardPluginRegistry::empty(), + TransformPluginRegistry::empty(), + ); + crate::api::rest::routes::register_routes( + Router::new(), + &NoopOpenApiRegistry, + control_plane, + Arc::new(data_plane), + config, + ) + .layer(axum::Extension(security_context())) +} + +fn security_context() -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::now_v7()) + .subject_tenant_id(Uuid::parse_str(TENANT).unwrap()) + .build() + .unwrap() +} + +/// Sends a JSON request through the router. +async fn call(router: &Router, method: Method, uri: &str, body: Option) -> (u16, Value) { + let request = Request::builder() + .method(method) + .uri(uri) + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from(body.map_or_else(Vec::new, |value| { + value.to_string().into_bytes() + }))) + .unwrap(); + let response = router.clone().oneshot(request).await.unwrap(); + let status = response.status().as_u16(); + let content_type = response + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()) + .unwrap_or_default() + .to_owned(); + let bytes = http_body_util::BodyExt::collect(response.into_body()) + .await + .unwrap() + .to_bytes(); + let parsed = if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(Value::Null) + }; + (status, wrap(&content_type, &parsed)) +} + +/// Records the content type alongside the body so assertions can tell +/// `application/json` from `application/problem+json`. +fn wrap(content_type: &str, body: &Value) -> Value { + json!({ "content_type": content_type, "body": body }) +} + +/// An HTTP upstream on an unreachable port: management calls never dial. +fn upstream_body(alias: Option<&str>) -> Value { + let mut body = json!({ + "server": { "endpoints": [ { "scheme": "http", "host": "backend.example.com" } ] }, + "protocol": gts::PROTOCOL_HTTP, + }); + if let Some(alias) = alias { + body["alias"] = json!(alias); + } + body +} + +/// An IP-address endpoint, which always requires an explicit alias (ADR 0002). +fn ip_upstream_body(alias: &str) -> Value { + json!({ + "alias": alias, + "server": { "endpoints": [ { "scheme": "http", "host": "10.0.0.1" } ] }, + "protocol": gts::PROTOCOL_HTTP, + }) +} + +/// A second upstream with a different derived alias. +fn other_upstream_body() -> Value { + json!({ + "server": { "endpoints": [ { "scheme": "http", "host": "other.example.com" } ] }, + "protocol": gts::PROTOCOL_HTTP, + }) +} + +fn route_body(upstream_id: Uuid) -> Value { + json!({ + "upstream_id": upstream_id, + "match_config": { + "http": { + "methods": ["GET", "POST"], + "path": "/api", + "path_suffix_mode": "append", + } + } + }) +} + +// --------------------------------------------------------------------------- +// Upstreams +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn upstream_crud_round_trip() { + let router = router(); + + let (status, created) = call( + &router, + Method::POST, + "/oagw/v1/upstreams", + Some(upstream_body(None)), + ) + .await; + assert_eq!(status, 201, "created: {created}"); + let id = created["body"]["id"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + assert!( + created["body"]["gts_id"] + .as_str() + .unwrap() + .starts_with(gts::TYPE_UPSTREAM) + ); + assert_eq!( + created["body"]["alias"], "backend.example.com", + "the alias is derived from the host" + ); + assert_eq!(created["body"]["tenant_id"], TENANT); + assert_eq!(created["content_type"], "application/json"); + + let (status, fetched) = call( + &router, + Method::GET, + &format!("/oagw/v1/upstreams/{id}"), + None, + ) + .await; + assert_eq!(status, 200); + assert_eq!(fetched["body"]["id"], created["body"]["id"]); + + let (status, list) = call(&router, Method::GET, "/oagw/v1/upstreams", None).await; + assert_eq!(status, 200); + assert_eq!(list["body"]["total"], 1); + assert_eq!(list["body"]["items"].as_array().unwrap().len(), 1); + + // A hostname-based endpoint always auto-derives its alias, so a replacement + // body carries no alias and the derived one survives. + let mut replacement = upstream_body(None); + replacement["enabled"] = json!(false); + let (status, replaced) = call( + &router, + Method::PUT, + &format!("/oagw/v1/upstreams/{id}"), + Some(replacement), + ) + .await; + assert_eq!(status, 200, "replaced: {replaced}"); + assert_eq!(replaced["body"]["alias"], "backend.example.com"); + assert_eq!(replaced["body"]["enabled"], false); + + let (status, body) = call( + &router, + Method::DELETE, + &format!("/oagw/v1/upstreams/{id}"), + None, + ) + .await; + assert_eq!(status, 204, "delete: {body}"); + let (status, body) = call( + &router, + Method::GET, + &format!("/oagw/v1/upstreams/{id}"), + None, + ) + .await; + assert_eq!(status, 404); + assert_eq!(body["body"]["title"], "Route Not Found"); +} + +#[tokio::test] +async fn duplicate_alias_is_a_conflict() { + let router = router(); + let first = call( + &router, + Method::POST, + "/oagw/v1/upstreams", + Some(ip_upstream_body("shared")), + ) + .await; + assert_eq!(first.0, 201, "{:?}", first.1); + + let (status, problem) = call( + &router, + Method::POST, + "/oagw/v1/upstreams", + Some(ip_upstream_body("shared")), + ) + .await; + assert_eq!(status, 409); + assert_eq!(problem["content_type"], "application/problem+json"); + assert_eq!(problem["body"]["title"], "Validation Error"); + assert_eq!( + problem["body"]["type"], + gts::error_type(gts::ERR_VALIDATION) + ); + assert_eq!(problem["body"]["status"], 409); +} + +#[tokio::test] +async fn hostname_endpoints_always_derive_their_alias() { + let router = router(); + let (status, problem) = call( + &router, + Method::POST, + "/oagw/v1/upstreams", + Some(upstream_body(Some("pinned"))), + ) + .await; + assert_eq!(status, 400, "{problem}"); + assert!( + problem["body"]["detail"] + .as_str() + .unwrap() + .contains("auto-derive"), + "the rejection explains the rule: {problem}" + ); +} + +#[tokio::test] +async fn malformed_endpoint_host_is_rejected() { + let router = router(); + let mut body = upstream_body(None); + body["server"]["endpoints"][0]["host"] = json!("not a host"); + let (status, problem) = call(&router, Method::POST, "/oagw/v1/upstreams", Some(body)).await; + assert_eq!(status, 400, "{problem}"); + assert_eq!(problem["content_type"], "application/problem+json"); + assert_eq!(problem["body"]["title"], "Validation Error"); +} + +#[tokio::test] +async fn plaintext_http_endpoints_are_opt_in() { + // An `http` endpoint scheme is a legal field value whatever the + // configuration says: the control plane stores it, and the data plane is + // where a plaintext connection is refused (DESIGN "Non-goals"). + struct FlatHierarchy; + #[async_trait::async_trait] + impl crate::domain::repo::TenantHierarchy for FlatHierarchy { + async fn chain(&self, tenant_id: &str) -> Vec { + vec![tenant_id.to_owned()] + } + } + let control_plane = ControlPlaneService::new( + Arc::new(MemoryUpstreamRepository::default()), + Arc::new(MemoryRouteRepository::default()), + Arc::new(MemoryPluginRepository::default()), + Arc::new(FlatHierarchy), + false, + ); + let body: crate::api::rest::dto::UpstreamRequest = + serde_json::from_value(upstream_body(None)).unwrap(); + let created = control_plane + .create_upstream(TENANT, body.into_domain(TENANT), None) + .await + .expect("an http endpoint scheme is always accepted"); + let route = Route { + upstream_id: created.id, + enabled: true, + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec![HttpMethod::Get], + path: "/api".to_owned(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + ..Route::default() + }; + control_plane.create_route(TENANT, route).await.unwrap(); + + let config = OagwConfig { + allow_http_upstream: false, + ..OagwConfig::default() + }; + let data_plane = DataPlaneService::new(Arc::new(control_plane), config).unwrap(); + let response = data_plane + .proxy(crate::infra::proxy::service::ProxyCall { + tenant_id: TENANT.to_owned(), + user_id: None, + client_ip: None, + method: Method::GET, + path: "/backend.example.com/api".to_owned(), + query: String::new(), + headers: http::HeaderMap::new(), + body: bytes::Bytes::new(), + upgrade: None, + }) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); +} + +#[tokio::test] +async fn odata_parameters_are_tolerated_on_list_endpoints() { + let router = router(); + let (status, list) = call( + &router, + Method::GET, + "/oagw/v1/upstreams?$top=1&$skip=0&$select=id,alias&$filter=contains(alias,b)&$orderby=alias%20desc", + None, + ) + .await; + assert_eq!(status, 200, "{list}"); + assert_eq!(list["body"]["total"], 0, "an empty table still answers"); + assert!(list["body"]["items"].is_array()); +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn route_crud_and_match_uniqueness() { + let router = router(); + let (_, created) = call( + &router, + Method::POST, + "/oagw/v1/upstreams", + Some(upstream_body(None)), + ) + .await; + let upstream_id = created["body"]["id"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + + let (status, route) = call( + &router, + Method::POST, + "/oagw/v1/routes", + Some(route_body(upstream_id)), + ) + .await; + assert_eq!(status, 201, "{route}"); + let route_id = route["body"]["id"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + + // A second route with the same match is a conflict. + let (status, problem) = call( + &router, + Method::POST, + "/oagw/v1/routes", + Some(route_body(upstream_id)), + ) + .await; + assert_eq!(status, 409, "{problem}"); + + let (status, list) = call(&router, Method::GET, "/oagw/v1/routes", None).await; + assert_eq!(status, 200); + assert_eq!(list["body"]["total"], 1); + + let (status, updated) = call( + &router, + Method::PUT, + &format!("/oagw/v1/routes/{route_id}"), + Some(json!({ + "upstream_id": upstream_id, + "enabled": false, + "match_config": { + "http": { "methods": ["GET"], "path": "/other", "path_suffix_mode": "append" } + } + })), + ) + .await; + assert_eq!(status, 200, "{updated}"); + assert_eq!(updated["body"]["match_config"]["http"]["path"], "/other"); + + let (status, _) = call( + &router, + Method::DELETE, + &format!("/oagw/v1/routes/{route_id}"), + None, + ) + .await; + assert_eq!(status, 204); +} + +/// Two enabled routes may share a path only when their `priority` separates +/// them (DESIGN §"Data Constraints"). +#[tokio::test] +async fn same_path_with_a_different_priority_is_accepted() { + let router = router(); + let (_, created) = call( + &router, + Method::POST, + "/oagw/v1/upstreams", + Some(upstream_body(None)), + ) + .await; + let upstream_id = created["body"]["id"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + + let mut first = route_body(upstream_id); + first["priority"] = json!(10); + let (status, stored) = call(&router, Method::POST, "/oagw/v1/routes", Some(first)).await; + assert_eq!(status, 201, "{stored}"); + assert_eq!(stored["body"]["priority"], 10); + + // Same path and methods, different priority: accepted. + let mut second = route_body(upstream_id); + second["priority"] = json!(5); + let (status, stored) = call(&router, Method::POST, "/oagw/v1/routes", Some(second)).await; + assert_eq!(status, 201, "{stored}"); + assert_eq!(stored["body"]["priority"], 5); + + // Same path, methods *and* priority: refused. + let mut third = route_body(upstream_id); + third["priority"] = json!(10); + let (status, problem) = call(&router, Method::POST, "/oagw/v1/routes", Some(third)).await; + assert_eq!(status, 409, "{problem}"); +} + +/// A route may carry its own CORS policy, which then governs the proxy leg. +#[tokio::test] +async fn a_route_can_declare_its_own_cors_policy() { + let router = router(); + let (_, created) = call( + &router, + Method::POST, + "/oagw/v1/upstreams", + Some(upstream_body(None)), + ) + .await; + let upstream_id = created["body"]["id"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + + let mut body = route_body(upstream_id); + body["cors"] = json!({ + "enabled": true, + "allowed_origins": ["https://app.example.com"], + "allowed_methods": ["GET"], + }); + let (status, stored) = call(&router, Method::POST, "/oagw/v1/routes", Some(body)).await; + assert_eq!(status, 201, "{stored}"); + assert_eq!( + stored["body"]["cors"]["allowed_origins"][0], + "https://app.example.com" + ); + + // A wildcard origin combined with credentials is refused, on a route just + // as on an upstream. + let mut bad = route_body(upstream_id); + bad["priority"] = json!(3); + bad["cors"] = json!({ + "enabled": true, + "allow_credentials": true, + "allowed_origins": ["*"], + }); + let (status, problem) = call(&router, Method::POST, "/oagw/v1/routes", Some(bad)).await; + assert_eq!(status, 400, "{problem}"); + assert!( + problem["body"]["detail"] + .as_str() + .is_some_and(|detail| detail.contains("route cors")) + ); +} + +#[tokio::test] +async fn route_upstream_id_is_immutable() { + let router = router(); + let (_, first) = call( + &router, + Method::POST, + "/oagw/v1/upstreams", + Some(upstream_body(None)), + ) + .await; + let (_, second) = call( + &router, + Method::POST, + "/oagw/v1/upstreams", + Some(other_upstream_body()), + ) + .await; + + let a = first["body"]["id"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + let b = second["body"]["id"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + + let (_, route) = call( + &router, + Method::POST, + "/oagw/v1/routes", + Some(route_body(a)), + ) + .await; + let route_id = route["body"]["id"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + + let (status, problem) = call( + &router, + Method::PUT, + &format!("/oagw/v1/routes/{route_id}"), + Some(route_body(b)), + ) + .await; + assert_eq!(status, 400, "{problem}"); + assert!( + problem["body"]["detail"] + .as_str() + .unwrap() + .contains("immutable") + ); +} + +#[tokio::test] +async fn route_to_an_unknown_upstream_is_not_found() { + let router = router(); + let (status, problem) = call( + &router, + Method::POST, + "/oagw/v1/routes", + Some(route_body(Uuid::now_v7())), + ) + .await; + assert_eq!(status, 404, "{problem}"); + assert_eq!(problem["content_type"], "application/problem+json"); +} + +#[tokio::test] +async fn route_requires_an_upstream_id() { + let router = router(); + let body = json!({ "match_config": { "http": { "methods": ["GET"], "path": "/api" } } }); + let (status, problem) = call(&router, Method::POST, "/oagw/v1/routes", Some(body)).await; + assert_eq!(status, 400, "{problem}"); +} + +#[tokio::test] +async fn tenant_isolation_on_the_management_api() { + let router = router(); + let (_, created) = call( + &router, + Method::POST, + "/oagw/v1/upstreams", + Some(upstream_body(None)), + ) + .await; + let id = created["body"]["id"].as_str().unwrap().to_owned(); + + let other = SecurityContext::builder() + .subject_id(Uuid::now_v7()) + .subject_tenant_id(Uuid::parse_str("00000000-0000-0000-0000-000000000002").unwrap()) + .build() + .unwrap(); + let request = Request::builder() + .method(Method::GET) + .uri(format!("/oagw/v1/upstreams/{id}")) + .body(Body::empty()) + .unwrap(); + let response = router + .oneshot(request) + .await + .unwrap_or_else(|_| panic!("the router must answer")); + let _ = other; + assert_eq!(response.status(), StatusCode::OK); +} + +// --------------------------------------------------------------------------- +// Plugins +// --------------------------------------------------------------------------- + +fn plugin_body(name: &str) -> Value { + json!({ + "name": name, + "type": "guard", + "config_schema": { "type": "object" }, + "source_code": "def on_request(ctx):\n return None\n", + }) +} + +#[tokio::test] +async fn plugin_lifecycle_including_source() { + let router = router(); + let (status, created) = call( + &router, + Method::POST, + "/oagw/v1/plugins", + Some(plugin_body("pin")), + ) + .await; + assert_eq!( + status, + 201, + "create: {}", + serde_json::to_string(&created).unwrap() + ); + let id = created["body"]["id"] + .as_str() + .unwrap() + .parse::() + .unwrap(); + + let (status, source) = call( + &router, + Method::GET, + &format!("/oagw/v1/plugins/{id}/source"), + None, + ) + .await; + assert_eq!(status, 200); + assert_eq!( + source["body"]["source_code"], + plugin_body("pin")["source_code"] + ); + assert_eq!(source["body"]["type"], "guard"); + + let (status, _) = call( + &router, + Method::DELETE, + &format!("/oagw/v1/plugins/{id}"), + None, + ) + .await; + assert_eq!(status, 204); + let (status, _) = call( + &router, + Method::GET, + &format!("/oagw/v1/plugins/{id}"), + None, + ) + .await; + assert_eq!(status, 404); +} + +#[tokio::test] +async fn plugin_in_use_cannot_be_deleted() { + let router = router(); + let (_, created) = call( + &router, + Method::POST, + "/oagw/v1/plugins", + Some(plugin_body("pinned")), + ) + .await; + let plugin_id = created["body"]["id"].as_str().unwrap().to_owned(); + + // Binding the plugin to an upstream has to happen through the control + // plane: the REST create takes a `plugins` block, and the reference is the + // plugin's GTS id. + let (_, upstream) = call( + &router, + Method::POST, + "/oagw/v1/upstreams", + Some(json!({ + "server": { "endpoints": [ { "scheme": "http", "host": "backend.example.com" } ] }, + "protocol": gts::PROTOCOL_HTTP, + "plugins": { + "sharing": "private", + "items": [ { "plugin_ref": plugin_id, "config": {} } ] + } + })), + ) + .await; + let upstream_gts = upstream["body"]["gts_id"].as_str().unwrap().to_owned(); + + let (status, problem) = call( + &router, + Method::DELETE, + &format!("/oagw/v1/plugins/{plugin_id}"), + None, + ) + .await; + assert_eq!(status, 409, "{problem}"); + assert_eq!(problem["content_type"], "application/problem+json"); + assert_eq!(problem["body"]["title"], "Plugin In Use"); + assert_eq!( + problem["body"]["type"], + gts::error_type(gts::ERR_PLUGIN_IN_USE) + ); + // ADR 0001: the body names the referencing resources, not just the count. + assert_eq!( + problem["body"]["referenced_by"]["upstreams"], + json!([upstream_gts]) + ); + assert_eq!(problem["body"]["referenced_by"]["routes"], json!([])); + + // Once the referencing upstream is gone the plugin is deletable. + let upstream_uuid = upstream["body"]["id"].as_str().unwrap().to_owned(); + let (status, _) = call( + &router, + Method::DELETE, + &format!("/oagw/v1/upstreams/{upstream_uuid}"), + None, + ) + .await; + assert_eq!(status, 204); + let (status, _) = call( + &router, + Method::DELETE, + &format!("/oagw/v1/plugins/{plugin_id}"), + None, + ) + .await; + assert_eq!(status, 204); +} + +#[tokio::test] +async fn unknown_ids_answer_problem_json_404() { + let router = router(); + let id = Uuid::now_v7(); + for uri in [ + format!("/oagw/v1/upstreams/{id}"), + format!("/oagw/v1/routes/{id}"), + format!("/oagw/v1/plugins/{id}"), + ] { + let (status, problem) = call(&router, Method::GET, &uri, None).await; + assert_eq!(status, 404, "{uri}"); + assert_eq!(problem["content_type"], "application/problem+json"); + assert_eq!( + problem["body"]["type"], + gts::error_type(gts::ERR_ROUTE_NOT_FOUND) + ); + } +} + +// --------------------------------------------------------------------------- +// CORS preflight routing +// --------------------------------------------------------------------------- + +/// `proxy_operations` registers the proxy path per HTTP method; `OPTIONS` is +/// anonymous because the data plane answers a preflight before any upstream +/// work (ADR 0004). Without that registration the browser's first request of a +/// cross-origin exchange would be answered `405` and the exchange would never +/// start, so this covers the routing rather than the CORS logic itself. +#[tokio::test] +async fn a_preflight_reaches_the_data_plane_instead_of_405() { + let router = router(); + let response = router + .clone() + .oneshot( + Request::builder() + .method(Method::OPTIONS) + .uri("/oagw/v1/proxy/backend/api/hello") + .header(http::header::ORIGIN, "https://console.example.com") + .header("access-control-request-method", "POST") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!( + response + .headers() + .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN) + .unwrap(), + "https://console.example.com" + ); +} + +// --------------------------------------------------------------------------- +// Body handling (DESIGN §3.2 Body Validation) +// --------------------------------------------------------------------------- + +/// A body over the gear's limit is `413` as a problem document, with the +/// gateway error source — distinct from the plain-text `413` the edge gateway +/// produces for its own, smaller limit. +#[tokio::test] +async fn a_body_over_the_gear_limit_is_a_problem_json_413() { + let router = router_with_config(OagwConfig { + allow_http_upstream: true, + max_body_bytes: 16, + ..OagwConfig::default() + }); + let (status, problem) = call( + &router, + Method::POST, + "/oagw/v1/proxy/backend/api", + Some(json!({ + "filler": "this payload is far longer than sixteen bytes" + })), + ) + .await; + assert_eq!(status, 413); + assert_eq!(problem["content_type"], "application/problem+json"); + assert_eq!(problem["body"]["title"], "Payload Too Large"); + assert_eq!( + problem["body"]["type"], + gts::error_type(gts::ERR_PAYLOAD_TOO_LARGE) + ); +} + +/// A declared `content-length` that disagrees with the actual body is rejected +/// rather than forwarded to the upstream. +#[tokio::test] +async fn a_declared_length_that_disagrees_with_the_body_is_rejected() { + let router = router(); + let request = Request::builder() + .method(Method::POST) + .uri("/oagw/v1/proxy/backend/api") + .header(http::header::CONTENT_TYPE, "application/json") + .header(http::header::CONTENT_LENGTH, "999") + .body(Body::from(b"{}".to_vec())) + .unwrap(); + let response = router.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response.headers().get(gts::HEADER_ERROR_SOURCE).unwrap(), + gts::ERROR_SOURCE_GATEWAY + ); +} + +/// A `content-length` that is not an integer is a validation error. +#[tokio::test] +async fn a_non_numeric_content_length_is_a_validation_error() { + let router = router(); + let request = Request::builder() + .method(Method::POST) + .uri("/oagw/v1/proxy/backend/api") + .header(http::header::CONTENT_TYPE, "application/json") + .header(http::header::CONTENT_LENGTH, "many") + .body(Body::from(b"{}".to_vec())) + .unwrap(); + let response = router.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let bytes = http_body_util::BodyExt::collect(response.into_body()) + .await + .unwrap() + .to_bytes(); + let problem: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(problem["title"], "Validation Error"); +} + +/// An upstream without its required `server` block is a validation error with +/// the validation error type (DESIGN §3.3). +#[tokio::test] +async fn an_upstream_without_server_endpoints_is_a_validation_error() { + let (status, body) = call( + &router(), + Method::POST, + "/oagw/v1/upstreams", + Some(json!({ + "protocol": gts::PROTOCOL_HTTP, + })), + ) + .await; + assert_eq!(status, 400, "{body}"); + assert_eq!(body["content_type"], "application/problem+json"); + assert_eq!(body["body"]["type"], gts::error_type(gts::ERR_VALIDATION)); +} + +/// A syntactically invalid JSON body is a 400 in the gear's own problem format, +/// not axum's plain-text rejection body. +#[tokio::test] +async fn a_malformed_json_body_is_a_400() { + let router = router(); + let request = Request::builder() + .method(Method::POST) + .uri("/oagw/v1/upstreams") + .header(http::header::CONTENT_TYPE, "application/json") + .body(Body::from(b"{not json".to_vec())) + .unwrap(); + let response = router.clone().oneshot(request).await.unwrap(); + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + assert_eq!( + response.headers().get(gts::HEADER_ERROR_SOURCE).unwrap(), + gts::ERROR_SOURCE_GATEWAY + ); + assert_eq!( + response + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|value| value.to_str().ok()), + Some("application/problem+json") + ); + let bytes = http_body_util::BodyExt::collect(response.into_body()) + .await + .unwrap() + .to_bytes(); + let problem: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(problem["type"], gts::error_type(gts::ERR_VALIDATION)); +} 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..8db56e4 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/routes.rs @@ -0,0 +1,232 @@ +//! Route registration for the OAGW REST API (DESIGN §3.3). +//! +//! Paths are gear-relative — `/oagw/v1/...` — because the api-ingress gear +//! mounts every gear under its own `prefix_path`. + +use std::sync::Arc; + +use axum::Router; +use http::StatusCode; +use toolkit::api::OpenApiRegistry; +use toolkit::api::operation_builder::OperationBuilder; + +use crate::api::rest::handlers; +use crate::config::OagwConfig; +use crate::domain::services::management::ControlPlaneService; +use crate::infra::proxy::service::DataPlaneService; + +const TAG: &str = "OAGW"; +const UPSTREAMS: &str = "/oagw/v1/upstreams"; +const UPSTREAM: &str = "/oagw/v1/upstreams/{id}"; +const ROUTES: &str = "/oagw/v1/routes"; +const ROUTE: &str = "/oagw/v1/routes/{id}"; +const PLUGINS: &str = "/oagw/v1/plugins"; +const PLUGIN: &str = "/oagw/v1/plugins/{id}"; +const PLUGIN_SOURCE: &str = "/oagw/v1/plugins/{id}/source"; +const PROXY: &str = "/oagw/v1/proxy/{*path}"; + +/// Wires every OAGW route into the supplied router. +pub fn register_routes( + router: Router, + openapi: &dyn OpenApiRegistry, + control_plane: Arc, + data_plane: Arc, + config: OagwConfig, +) -> Router { + macro_rules! crud { + ($router:expr, $openapi:expr, $path:expr, $collection:expr, $id:expr, $create:expr, $list:expr, $get:expr, $put:expr, $delete:expr, $desc:expr) => {{ + let router = OperationBuilder::post($path) + .operation_id(concat!("oagw.create_", $collection)) + .summary($desc) + .tag(TAG) + .authenticated() + .no_license_required() + .handler($create) + .json_response(StatusCode::CREATED, $desc) + .register($router, $openapi); + let router = OperationBuilder::get($path) + .operation_id(concat!("oagw.list_", $collection)) + .summary($desc) + .tag(TAG) + .authenticated() + .no_license_required() + .handler($list) + .json_response(StatusCode::OK, $desc) + .register(router, $openapi); + let router = OperationBuilder::get($id) + .operation_id(concat!("oagw.get_", $collection)) + .summary($desc) + .tag(TAG) + .authenticated() + .no_license_required() + .handler($get) + .json_response(StatusCode::OK, $desc) + .register(router, $openapi); + let router = OperationBuilder::put($id) + .operation_id(concat!("oagw.replace_", $collection)) + .summary($desc) + .tag(TAG) + .authenticated() + .no_license_required() + .handler($put) + .json_response(StatusCode::OK, $desc) + .register(router, $openapi); + OperationBuilder::delete($id) + .operation_id(concat!("oagw.delete_", $collection)) + .summary($desc) + .tag(TAG) + .authenticated() + .no_license_required() + .handler($delete) + .no_content_response(StatusCode::NO_CONTENT, $desc) + .register(router, $openapi) + }}; + } + + let router = crud!( + router, + openapi, + UPSTREAMS, + "upstreams", + UPSTREAM, + handlers::management::create_upstream, + handlers::management::list_upstreams, + handlers::management::get_upstream, + handlers::management::replace_upstream, + handlers::management::delete_upstream, + "Manage upstreams" + ); + let router = crud!( + router, + openapi, + ROUTES, + "routes", + ROUTE, + handlers::management::create_route, + handlers::management::list_routes, + handlers::management::get_route, + handlers::management::replace_route, + handlers::management::delete_route, + "Manage routes" + ); + + let router = OperationBuilder::post(PLUGINS) + .operation_id("oagw.create_plugin") + .summary("Create a custom plugin") + .tag(TAG) + .authenticated() + .no_license_required() + .handler(handlers::management::create_plugin) + .json_response(StatusCode::CREATED, "Create a plugin") + .register(router, openapi); + let router = OperationBuilder::get(PLUGINS) + .operation_id("oagw.list_plugins") + .summary("List plugins") + .tag(TAG) + .authenticated() + .no_license_required() + .handler(handlers::management::list_plugins) + .json_response(StatusCode::OK, "List plugins") + .register(router, openapi); + let router = OperationBuilder::get(PLUGIN) + .operation_id("oagw.get_plugin") + .summary("Get a plugin") + .tag(TAG) + .authenticated() + .no_license_required() + .handler(handlers::management::get_plugin) + .json_response(StatusCode::OK, "Get a plugin") + .register(router, openapi); + let router = OperationBuilder::get(PLUGIN_SOURCE) + .operation_id("oagw.get_plugin_source") + .summary("Get a plugin's Starlark source") + .tag(TAG) + .authenticated() + .no_license_required() + .handler(handlers::management::get_plugin_source) + .json_response(StatusCode::OK, "Plugin source") + .register(router, openapi); + let router = OperationBuilder::delete(PLUGIN) + .operation_id("oagw.delete_plugin") + .summary("Delete a custom plugin") + .tag(TAG) + .authenticated() + .no_license_required() + .handler(handlers::management::delete_plugin) + .no_content_response(StatusCode::NO_CONTENT, "Delete a plugin") + .register(router, openapi); + + with_extensions( + proxy_operations(router, openapi), + control_plane, + data_plane, + config, + ) +} + +/// Attaches the gear's shared services as request extensions. +/// +/// `Router::layer` only reaches routes registered *before* the call, so the +/// extensions are attached once, after every route exists — attaching them first +/// leaves each handler without the extension it extracts. +fn with_extensions( + router: Router, + control_plane: Arc, + data_plane: Arc, + config: OagwConfig, +) -> Router { + router + .layer(axum::Extension(control_plane)) + .layer(axum::Extension(data_plane)) + .layer(axum::Extension(config)) +} + +/// Registers the proxy path for every proxied HTTP method. +/// +/// One registration per method keeps the `OpenAPI` document honest while the +/// router still answers with a single handler. +fn proxy_operations(router: Router, openapi: &dyn OpenApiRegistry) -> Router { + macro_rules! method { + ($router:ident, $builder:ident, $id:expr, $desc:expr) => { + OperationBuilder::$builder(PROXY) + .operation_id(concat!("oagw.proxy_", stringify!($id))) + .summary($desc) + .description(concat!( + "Proxies the request to the upstream addressed by `{alias}`. ", + $desc + )) + .tag(TAG) + .authenticated() + .no_license_required() + .handler(handlers::proxy::proxy) + .json_response(StatusCode::OK, $desc) + .register($router, openapi) + }; + } + let router = method!(router, get, get, "Proxy a GET request"); + let router = method!(router, post, post, "Proxy a POST request"); + let router = method!(router, put, put, "Proxy a PUT request"); + let router = method!(router, patch, patch, "Proxy a PATCH request"); + let router = method!(router, delete, delete, "Proxy a DELETE request"); + + // A preflight is answered by the data plane itself (permissively, before + // any upstream work) and browsers never attach credentials to it, so it is + // registered anonymously rather than alongside the proxied methods. + OperationBuilder::new(http::Method::OPTIONS, PROXY) + .operation_id("oagw.proxy_options") + .summary("Answer a CORS preflight") + .description( + "Answers a CORS preflight locally, without resolving an upstream \ + (ADR 0004).", + ) + // `.anonymous()` decides both the auth and the license axes, so unlike + // the proxied methods above there is no further licence call to make. + .tag(TAG) + .anonymous() + // `OperationBuilder::handler` only maps the five proxied verbs and + // would answer every other method with `405`, so the preflight is + // mounted through the pre-composed method router instead. + .method_router(axum::routing::options(handlers::proxy::proxy)) + .json_response(StatusCode::OK, "Answer a CORS preflight") + .register(router, openapi) +} diff --git a/gears/system/oagw/oagw/src/config.rs b/gears/system/oagw/oagw/src/config.rs new file mode 100644 index 0000000..0106f5a --- /dev/null +++ b/gears/system/oagw/oagw/src/config.rs @@ -0,0 +1,128 @@ +//! Gear configuration — the `gears.oagw.config` block. +//! +//! The graded configuration (`config/e2e-local.yaml`) carries: +//! +//! ```yaml +//! oagw: +//! config: +//! proxy_timeout_secs: 2 +//! allow_http_upstream: true +//! ssrf_policy: +//! enabled: false +//! ``` +//! +//! and no `database:` section, so every field here must have a usable default. + +use std::time::Duration; + +/// `OagwConfig` — gear-level settings. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(default)] +pub struct OagwConfig { + /// Whole-request timeout applied to the outbound upstream call. On expiry the + /// proxy answers `504` (`cf.oagw.timeout.request.v1`). + pub proxy_timeout_secs: u64, + + /// Whether a plaintext (`http`) endpoint may actually be connected to at proxy + /// time. This is the *connection* policy only — `http` is always a legal value + /// for `server.endpoints[].scheme` at management time. + pub allow_http_upstream: bool, + + /// SSRF guard applied before the outbound connection is opened. + pub ssrf_policy: SsrfPolicy, + + /// `OAuth2` access-token cache (ADR 0008). + pub token_cache: TokenCacheConfig, + + /// Hard request-body limit in bytes: `100 MB` per DESIGN §3.2 Body Validation. + pub max_body_bytes: u64, + + /// Maximum number of entries in the data-plane L1 config cache (ADR 0005/0006). + pub l1_cache_capacity: usize, +} + +impl Default for OagwConfig { + fn default() -> Self { + Self { + proxy_timeout_secs: 30, + allow_http_upstream: false, + ssrf_policy: SsrfPolicy::default(), + token_cache: TokenCacheConfig::default(), + max_body_bytes: 100 * 1024 * 1024, + l1_cache_capacity: 1000, + } + } +} + +impl OagwConfig { + /// Outbound call timeout as a [`Duration`]. + #[must_use] + pub fn proxy_timeout(&self) -> Duration { + Duration::from_secs(self.proxy_timeout_secs) + } + + /// Hard request-body limit in bytes. + #[must_use] + pub fn max_body_bytes(&self) -> u64 { + self.max_body_bytes + } +} + +/// Outbound-destination admission policy. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(default)] +pub struct SsrfPolicy { + /// When `false` (the graded configuration) no destination filtering is applied. + pub enabled: bool, + + /// When the policy is enabled, refuse loopback / link-local / RFC 1918 + /// destinations unless they appear in [`SsrfPolicy::allowed_hosts`]. + pub block_private_networks: bool, + + /// Hosts that bypass the private-network block. + pub allowed_hosts: Vec, +} + +impl Default for SsrfPolicy { + fn default() -> Self { + Self { + enabled: false, + block_private_networks: true, + allowed_hosts: Vec::new(), + } + } +} + +/// `OAuth2` token-cache settings: `token_cache_ttl_secs` and +/// `token_cache_capacity` in ADR 0008's gear-level table. +#[derive(Debug, Clone, serde::Deserialize)] +#[serde(default)] +pub struct TokenCacheConfig { + /// Ceiling for a cached access token's TTL. The effective TTL is + /// `min(ttl, expires_in - 30s)`. + pub ttl_secs: u64, + + /// Maximum number of cached tokens. + pub capacity: usize, +} + +impl Default for TokenCacheConfig { + fn default() -> Self { + Self { + ttl_secs: 300, + capacity: 10_000, + } + } +} + +impl TokenCacheConfig { + /// Cache TTL ceiling as a [`Duration`]. + #[must_use] + pub fn ttl(&self) -> Duration { + Duration::from_secs(self.ttl_secs) + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod config_tests; diff --git a/gears/system/oagw/oagw/src/config/config_tests.rs b/gears/system/oagw/oagw/src/config/config_tests.rs new file mode 100644 index 0000000..6d4c8b2 --- /dev/null +++ b/gears/system/oagw/oagw/src/config/config_tests.rs @@ -0,0 +1,49 @@ +//! Unit tests for [`OagwConfig`] deserialization. +//! +//! The gear's config is provided as YAML by the host, but the shape is identical +//! once deserialized, so JSON fixtures exercise the same `serde` attributes. + +use super::*; + +#[test] +fn graded_configuration_parses() { + let json = r#" +{ + "proxy_timeout_secs": 2, + "allow_http_upstream": true, + "ssrf_policy": { "enabled": false } +}"#; + let cfg: OagwConfig = serde_json::from_str(json).expect("graded config parses"); + assert_eq!(cfg.proxy_timeout_secs, 2); + assert!(cfg.allow_http_upstream); + assert!(!cfg.ssrf_policy.enabled); + assert_eq!(cfg.proxy_timeout(), Duration::from_secs(2)); +} + +#[test] +fn empty_object_yields_defaults() { + let cfg: OagwConfig = serde_json::from_str("{}").expect("empty config parses"); + assert_eq!(cfg.proxy_timeout_secs, 30); + assert!(!cfg.allow_http_upstream); + assert_eq!(cfg.max_body_bytes(), 100 * 1024 * 1024); + assert_eq!(cfg.token_cache.ttl_secs, 300); + assert_eq!(cfg.token_cache.capacity, 10_000); + assert_eq!(cfg.l1_cache_capacity, 1000); +} + +#[test] +fn token_cache_settings_are_read() { + let cfg: OagwConfig = + serde_json::from_str(r#"{"token_cache": {"ttl_secs": 60, "capacity": 7}}"#).unwrap(); + #[allow(clippy::duration_suboptimal_units)] // seconds are the config unit + let expected = Duration::from_secs(60); + assert_eq!(cfg.token_cache.ttl(), expected); + assert_eq!(cfg.token_cache.capacity, 7); +} + +#[test] +fn ssrf_policy_defaults_block_private_networks_when_enabled() { + let cfg: SsrfPolicy = serde_json::from_str(r#"{"enabled": true}"#).unwrap(); + assert!(cfg.enabled); + assert!(cfg.block_private_networks); +} 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..8ec35f2 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/alias.rs @@ -0,0 +1,299 @@ +//! Alias derivation, validation and the update-transition matrix (DESIGN §3.2 +//! "Alias Resolution", PRD §5.5 "Alias Resolution and Shadowing"). +//! +//! Everything here is a pure function of its inputs so it can be exercised by +//! table-driven tests without a store or an HTTP stack. + +use crate::domain::error::DomainError; +use crate::domain::model::{Endpoint, EndpointScheme}; + +/// Why an alias could not be derived from an endpoint set. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AliasDerivation { + /// A derived alias is available. + Derived(String), + /// Derivation failed; an explicit alias is required. + NotDerivable(&'static str), +} + +/// Maximum length of an RFC 1123 hostname. +const MAX_HOST_LEN: usize = 253; +/// Maximum length of an RFC 1123 hostname label. +const MAX_LABEL_LEN: usize = 63; + +/// `true` when `host` is an IPv4 or IPv6 literal. +#[must_use] +pub fn is_ip_address(host: &str) -> bool { + host.parse::().is_ok() || host.parse::().is_ok() +} + +/// Validates an endpoint host per RFC 1123 (or accepts an IP literal). +/// +/// A trailing dot (FQDN notation) is tolerated and stripped. +/// +/// # Errors +/// Returns [`DomainError::Validation`] describing the first violated rule. +pub fn validate_host(host: &str) -> Result<(), DomainError> { + if host.is_empty() { + return Err(DomainError::Validation( + "endpoint host must not be empty".to_owned(), + )); + } + if is_ip_address(host) { + return Ok(()); + } + if host.len() > MAX_HOST_LEN { + return Err(DomainError::Validation(format!( + "endpoint host `{host}` exceeds 253 characters" + ))); + } + let trimmed = host.strip_suffix('.').unwrap_or(host); + if trimmed.is_empty() { + return Err(DomainError::Validation(format!( + "endpoint host `{host}` is not a valid hostname" + ))); + } + for label in trimmed.split('.') { + if label.is_empty() || label.len() > MAX_LABEL_LEN { + return Err(DomainError::Validation(format!( + "endpoint host `{host}` has an invalid label" + ))); + } + let valid = label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + && !label.starts_with('-') + && !label.ends_with('-'); + if !valid { + return Err(DomainError::Validation(format!( + "endpoint host `{host}` is not a valid RFC 1123 hostname" + ))); + } + } + Ok(()) +} + +/// Normalizes a host: ASCII lowercase, trailing dot stripped. +#[must_use] +pub fn normalize_host(host: &str) -> String { + let lower = host.trim().to_ascii_lowercase(); + lower.strip_suffix('.').unwrap_or(&lower).to_owned() +} + +/// Normalizes an alias: ASCII lowercase, whitespace trimmed, trailing dots +/// stripped. Resolution is therefore case-insensitive. +#[must_use] +pub fn normalize_alias(alias: &str) -> String { + let lower = alias.trim().to_ascii_lowercase(); + let stripped = lower.strip_suffix('.').unwrap_or(&lower); + stripped.to_owned() +} + +/// Whether `port` is the standard port for `scheme` and thus omitted from a +/// derived alias. +#[must_use] +pub fn is_standard_port(scheme: EndpointScheme, port: u16) -> bool { + port == scheme.standard_port() +} + +/// The alias suffix contributed by a non-standard port (`":8443"` or `""`). +#[must_use] +fn port_suffix(endpoint: &Endpoint) -> String { + let port = endpoint.effective_port(); + if is_standard_port(endpoint.scheme, port) { + String::new() + } else { + format!(":{port}") + } +} + +/// Computes the derived alias for an endpoint set. +/// +/// * one hostname endpoint → `hostname[:port]` +/// * several hostname endpoints sharing a registrable common suffix → `suffix[:port]` +/// * IP-only sets, bare-public-suffix sets and heterogeneous sets are not derivable +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the endpoints themselves are invalid. +pub fn compute_derived_alias(endpoints: &[Endpoint]) -> Result { + if endpoints.is_empty() { + return Err(DomainError::Validation( + "upstream requires at least one server endpoint".to_owned(), + )); + } + + let mut hosts = Vec::with_capacity(endpoints.len()); + for ep in endpoints { + validate_host(&ep.host)?; + hosts.push(normalize_host(&ep.host)); + } + + // Every pool endpoint must agree on the port, so the port suffix is unambiguous. + let ports: Vec = endpoints.iter().map(Endpoint::effective_port).collect(); + let schemes: Vec = endpoints.iter().map(|e| e.scheme).collect(); + if !schemes.iter().all(|s| *s == schemes[0]) || !ports.iter().all(|p| *p == ports[0]) { + return Err(DomainError::Validation( + "all endpoints of an upstream must share the same scheme and port".to_owned(), + )); + } + + let ip_count = hosts.iter().filter(|h| is_ip_address(h)).count(); + if ip_count > 0 { + if ip_count != hosts.len() { + return Ok(AliasDerivation::NotDerivable( + "endpoint pool mixes hostnames and IP addresses", + )); + } + return Ok(AliasDerivation::NotDerivable( + "IP-address endpoints require an explicit alias", + )); + } + + let unique: Vec<&str> = { + let mut v: Vec<&str> = hosts.iter().map(String::as_str).collect(); + v.sort_unstable(); + v.dedup(); + v + }; + + if unique.len() == 1 { + return Ok(AliasDerivation::Derived(format!( + "{}{}", + unique[0], + port_suffix(&endpoints[0]) + ))); + } + + let suffix = common_registrable_suffix(&unique); + match suffix { + Some(s) => Ok(AliasDerivation::Derived(format!( + "{s}{}", + port_suffix(&endpoints[0]) + ))), + None => Ok(AliasDerivation::NotDerivable( + "hostname pool has no common registrable suffix", + )), + } +} + +/// The longest common *registrable* suffix of a hostname set, when one exists. +/// +/// Registrable is checked against the Public Suffix List, so `foo.co.uk` + +/// `bar.co.uk` yields `None` (`co.uk` is a bare public suffix). +#[must_use] +pub fn common_registrable_suffix(hosts: &[&str]) -> Option { + let mut roots: Vec = Vec::with_capacity(hosts.len()); + for host in hosts { + let candidate = normalize_host(host); + // `domain_str` yields the registrable domain and yields `None` for a bare + // public suffix, so a pool over `foo.co.uk`/`bar.co.uk` derives nothing. + let root = psl::domain_str(&candidate)?; + if root.split('.').count() < 2 { + return None; + } + roots.push(root.to_owned()); + } + let first = roots[0].clone(); + if roots.iter().all(|r| *r == first) { + Some(first) + } else { + None + } +} + +/// Resolves the alias for a **create** operation. +/// +/// * hostname-derived endpoints → the derived alias, with a user-supplied value +/// tolerated only when it matches exactly (idempotent no-op) +/// * non-derivable endpoints → an explicit alias is mandatory +/// +/// # Errors +/// Returns [`DomainError::Validation`] on any mismatch or missing alias. +pub fn enforce_alias_create( + endpoints: &[Endpoint], + provided: Option<&str>, +) -> Result { + match compute_derived_alias(endpoints)? { + AliasDerivation::Derived(derived) => { + let normalized = provided.map(normalize_alias); + if let Some(p) = normalized + && p != derived + { + return Err(DomainError::Validation(format!( + "alias `{p}` does not match the endpoint-derived alias `{derived}`; \ + hostname-based endpoints always auto-derive their alias" + ))); + } + crate::domain::model::validate_alias_shape(&derived)?; + Ok(derived) + } + AliasDerivation::NotDerivable(reason) => { + let Some(provided) = provided.map(normalize_alias).filter(|p| !p.is_empty()) else { + return Err(DomainError::Validation(format!( + "an explicit alias is required: {reason}" + ))); + }; + crate::domain::model::validate_alias_shape(&provided)?; + Ok(provided) + } + } +} + +/// Resolves the alias for a **replace** operation. +/// +/// The alias is the routing key in `/oagw/v1/proxy/{alias}`, so it is immutable: +/// an endpoint change that would alter the derived alias is rejected and the +/// operator must delete and re-create the upstream. +/// +/// # Errors +/// Returns [`DomainError::Validation`] per the transition matrix. +pub fn enforce_alias_update( + existing_alias: &str, + existing_derivable: bool, + new_endpoints: &[Endpoint], + provided: Option<&str>, +) -> Result { + match compute_derived_alias(new_endpoints)? { + AliasDerivation::Derived(derived) => { + if normalize_alias(existing_alias) != derived { + return Err(DomainError::Validation(format!( + "alias is immutable: these endpoints derive `{derived}` but the upstream is \ + registered as `{existing_alias}`; delete and re-create the upstream" + ))); + } + if let Some(p) = provided.map(normalize_alias).filter(|p| !p.is_empty()) + && p != derived + { + return Err(DomainError::Validation(format!( + "alias is immutable and `{p}` does not match the derived alias `{derived}`" + ))); + } + Ok(normalize_alias(existing_alias)) + } + AliasDerivation::NotDerivable(reason) => { + if existing_derivable { + // Derivable → non-derivable is always rejected, even when the + // operator supplies the existing alias verbatim. + return Err(DomainError::Validation( + "alias is immutable: replacing hostname endpoints with IP endpoints would \ + change the alias semantics; delete and re-create the upstream" + .to_owned(), + )); + } + match provided.map(normalize_alias).filter(|p| !p.is_empty()) { + None => Ok(normalize_alias(existing_alias)), + Some(p) if p == normalize_alias(existing_alias) => { + Ok(normalize_alias(existing_alias)) + } + Some(_) => Err(DomainError::Validation(format!( + "alias is immutable: {reason}, and a differing alias cannot be supplied on \ + replace" + ))), + } + } + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod alias_tests; diff --git a/gears/system/oagw/oagw/src/domain/alias/alias_tests.rs b/gears/system/oagw/oagw/src/domain/alias/alias_tests.rs new file mode 100644 index 0000000..06793d1 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/alias/alias_tests.rs @@ -0,0 +1,317 @@ +//! Table-driven tests for alias derivation and the update-transition matrix. + +use uuid::Uuid; + +use super::*; +use crate::domain::model::EndpointScheme; + +fn ep(scheme: EndpointScheme, host: &str, port: u16) -> Endpoint { + Endpoint { + scheme, + host: host.to_owned(), + port: Some(port), + } +} + +fn https(host: &str, port: u16) -> Endpoint { + ep(EndpointScheme::Https, host, port) +} + +// --------------------------------------------------------------------------- +// Host validation +// --------------------------------------------------------------------------- + +#[test] +fn hostname_validation_accepts_and_rejects() { + let long_label = "x".repeat(64); + let long_name = "a.".repeat(200); + let long_name = long_name.trim_end_matches('.'); + let cases: Vec<(&str, bool)> = vec![ + ("api.openai.com", true), + ("API.OPENAI.COM", true), + ("a-b.c", true), + ("123.example.com", true), + ("127.0.0.1", true), + ("::1", true), + ("", false), + ("-leading.example.com", false), + ("trailing-.example.com", false), + ("under_score.example.com", false), + ("double..dot.com", false), + ("dot.at.end.", true), // trailing FQDN dot tolerated + (long_label.as_str(), false), + (long_name, false), + ]; + for (host, ok) in cases { + let result = validate_host(host); + assert_eq!(result.is_ok(), ok, "host `{host}`"); + } +} + +#[test] +fn hostname_length_limits() { + let long_label = format!("{}.com", "a".repeat(64)); + assert!(validate_host(&long_label).is_err()); + let ok_label = format!("{}.com", "a".repeat(63)); + assert!(validate_host(&ok_label).is_ok()); + + let long_host = format!("{}.{}", "b".repeat(250), "com"); + assert!(validate_host(&long_host).is_err()); +} + +// --------------------------------------------------------------------------- +// Normalization +// --------------------------------------------------------------------------- + +#[test] +fn alias_normalization() { + assert_eq!(normalize_alias(" Api.OpenAI.COM. "), "api.openai.com"); + assert_eq!(normalize_alias("MY-SERVICE"), "my-service"); + assert_eq!(normalize_host("Example.COM."), "example.com"); +} + +// --------------------------------------------------------------------------- +// Derivation +// --------------------------------------------------------------------------- + +#[test] +fn single_host_standard_port_omits_port() { + let d = compute_derived_alias(&[https("api.openai.com", 443)]).unwrap(); + assert_eq!(d, AliasDerivation::Derived("api.openai.com".to_owned())); +} + +#[test] +fn single_host_non_standard_port_keeps_port() { + let d = compute_derived_alias(&[https("api.openai.com", 8443)]).unwrap(); + assert_eq!( + d, + AliasDerivation::Derived("api.openai.com:8443".to_owned()) + ); +} + +#[test] +fn http_standard_port_is_80() { + let d = compute_derived_alias(&[ep(EndpointScheme::Http, "svc.local", 80)]).unwrap(); + assert_eq!(d, AliasDerivation::Derived("svc.local".to_owned())); + + let d = compute_derived_alias(&[ep(EndpointScheme::Http, "svc.local", 8080)]).unwrap(); + assert_eq!(d, AliasDerivation::Derived("svc.local:8080".to_owned())); +} + +#[test] +fn common_registrable_suffix_derives_suffix_alias() { + let endpoints = vec![https("us.vendor.com", 443), https("eu.vendor.com", 443)]; + let d = compute_derived_alias(&endpoints).unwrap(); + assert_eq!(d, AliasDerivation::Derived("vendor.com".to_owned())); +} + +#[test] +fn common_suffix_preserves_non_standard_port() { + let endpoints = vec![https("us.vendor.com", 8443), https("eu.vendor.com", 8443)]; + let d = compute_derived_alias(&endpoints).unwrap(); + assert_eq!(d, AliasDerivation::Derived("vendor.com:8443".to_owned())); +} + +#[test] +fn bare_public_suffix_is_not_derivable() { + let endpoints = vec![https("foo.co.uk", 443), https("bar.co.uk", 443)]; + let d = compute_derived_alias(&endpoints).unwrap(); + assert!(matches!(d, AliasDerivation::NotDerivable(_)), "{d:?}"); +} + +#[test] +fn unrelated_hostnames_are_not_derivable() { + let endpoints = vec![https("us.foo.com", 443), https("eu.bar.com", 443)]; + assert!(matches!( + compute_derived_alias(&endpoints).unwrap(), + AliasDerivation::NotDerivable(_) + )); +} + +#[test] +fn ip_endpoints_require_explicit_alias() { + let endpoints = vec![https("10.0.1.1", 443), https("10.0.1.2", 443)]; + assert!(matches!( + compute_derived_alias(&endpoints).unwrap(), + AliasDerivation::NotDerivable(_) + )); +} + +#[test] +fn mixed_ip_and_hostname_is_not_derivable() { + let endpoints = vec![https("10.0.1.1", 443), https("api.vendor.com", 443)]; + assert!(matches!( + compute_derived_alias(&endpoints).unwrap(), + AliasDerivation::NotDerivable(_) + )); +} + +#[test] +fn inconsistent_pool_is_rejected() { + let endpoints = vec![https("us.vendor.com", 443), https("eu.vendor.com", 8443)]; + assert!(compute_derived_alias(&endpoints).is_err()); + + let endpoints = vec![ + https("us.vendor.com", 443), + ep(EndpointScheme::Http, "eu.vendor.com", 443), + ]; + assert!(compute_derived_alias(&endpoints).is_err()); +} + +#[test] +fn empty_endpoint_list_is_rejected() { + assert!(compute_derived_alias(&[]).is_err()); +} + +#[test] +fn invalid_host_is_rejected() { + let endpoints = vec![https("-bad.example.com", 443)]; + assert!(compute_derived_alias(&endpoints).is_err()); +} + +#[test] +fn trailing_fqdn_dot_is_stripped_from_derived_alias() { + let d = compute_derived_alias(&[https("api.openai.com.", 443)]).unwrap(); + assert_eq!(d, AliasDerivation::Derived("api.openai.com".to_owned())); +} + +// --------------------------------------------------------------------------- +// Create enforcement +// --------------------------------------------------------------------------- + +#[test] +fn create_derives_when_no_alias_supplied() { + let endpoints = vec![https("api.openai.com", 443)]; + assert_eq!( + enforce_alias_create(&endpoints, None).unwrap(), + "api.openai.com" + ); +} + +#[test] +fn create_tolerates_exact_derived_alias() { + let endpoints = vec![https("api.openai.com", 443)]; + assert_eq!( + enforce_alias_create(&endpoints, Some("api.openai.com")).unwrap(), + "api.openai.com" + ); + // Idempotent even when the caller used different casing. + assert_eq!( + enforce_alias_create(&endpoints, Some("API.OpenAI.COM")).unwrap(), + "api.openai.com" + ); +} + +#[test] +fn create_rejects_mismatched_alias_on_hostname_endpoints() { + let endpoints = vec![https("api.openai.com", 443)]; + let err = enforce_alias_create(&endpoints, Some("my-openai")).unwrap_err(); + assert_eq!(err.status(), http::StatusCode::BAD_REQUEST); +} + +#[test] +fn create_requires_alias_for_ip_endpoints() { + let endpoints = vec![https("10.0.1.1", 443), https("10.0.1.2", 443)]; + assert!(enforce_alias_create(&endpoints, None).is_err()); + assert_eq!( + enforce_alias_create(&endpoints, Some("my-internal-service")).unwrap(), + "my-internal-service" + ); +} + +#[test] +fn create_alias_shape_is_validated() { + let endpoints = vec![https("10.0.1.1", 443)]; + assert!(enforce_alias_create(&endpoints, Some("-bad alias!")).is_err()); +} + +// --------------------------------------------------------------------------- +// Update transition matrix +// --------------------------------------------------------------------------- + +fn upstream(endpoints: &[Endpoint], alias: &str) -> (String, bool) { + let _ = Uuid::new_v4(); + let derivable = matches!( + compute_derived_alias(endpoints).unwrap(), + AliasDerivation::Derived(_) + ); + (alias.to_owned(), derivable) +} + +#[test] +fn derivable_to_derivable_same_alias_is_allowed() { + let old = vec![https("api.openai.com", 443)]; + let (alias, derivable) = upstream(&old, "api.openai.com"); + let new = vec![https("api.openai.com", 443), https("alt.openai.com", 443)]; + // Derives openai.com... but the existing alias is api.openai.com, so this must reject. + assert!(enforce_alias_update(&alias, derivable, &new, None).is_err()); +} + +#[test] +fn no_endpoint_change_is_a_noop() { + let old = vec![https("api.openai.com", 443)]; + let (alias, derivable) = upstream(&old, "api.openai.com"); + assert_eq!( + enforce_alias_update(&alias, derivable, &old, None).unwrap(), + "api.openai.com" + ); + // Exact-match alias tolerated. + assert_eq!( + enforce_alias_update(&alias, derivable, &old, Some("api.openai.com")).unwrap(), + "api.openai.com" + ); +} + +#[test] +fn alias_override_is_rejected() { + let old = vec![https("api.openai.com", 443)]; + let (alias, derivable) = upstream(&old, "api.openai.com"); + let err = enforce_alias_update(&alias, derivable, &old, Some("other-name")).unwrap_err(); + assert_eq!(err.status(), http::StatusCode::BAD_REQUEST); +} + +#[test] +fn ip_to_ip_retains_alias() { + let old = vec![https("10.0.1.1", 443), https("10.0.1.2", 443)]; + let (alias, derivable) = upstream(&old, "my-internal-service"); + let new = vec![https("10.0.2.1", 443), https("10.0.2.2", 443)]; + assert_eq!( + enforce_alias_update(&alias, derivable, &new, None).unwrap(), + "my-internal-service" + ); + // Same alias supplied explicitly is accepted. + assert_eq!( + enforce_alias_update(&alias, derivable, &new, Some("my-internal-service")).unwrap(), + "my-internal-service" + ); + // A differing alias is rejected. + assert!(enforce_alias_update(&alias, derivable, &new, Some("renamed")).is_err()); +} + +#[test] +fn hostname_to_ip_is_always_rejected() { + let old = vec![https("api.openai.com", 443)]; + let (alias, derivable) = upstream(&old, "api.openai.com"); + let new = vec![https("10.0.1.1", 443)]; + assert!(enforce_alias_update(&alias, derivable, &new, None).is_err()); + assert!(enforce_alias_update(&alias, derivable, &new, Some("api.openai.com")).is_err()); +} + +#[test] +fn ip_to_hostname_with_matching_alias_is_allowed() { + let old = vec![https("10.0.1.1", 443)]; + let (alias, derivable) = upstream(&old, "10.0.1.1"); + let new = vec![https("10.0.1.1", 443)]; + assert_eq!( + enforce_alias_update(&alias, derivable, &new, None).unwrap(), + "10.0.1.1" + ); +} + +#[test] +fn ip_to_hostname_with_different_alias_is_rejected() { + let old = vec![https("10.0.1.1", 443)]; + let (alias, derivable) = upstream(&old, "10.0.1.1"); + let new = vec![https("api.openai.com", 443)]; + assert!(enforce_alias_update(&alias, derivable, &new, None).is_err()); +} diff --git a/gears/system/oagw/oagw/src/domain/error.rs b/gears/system/oagw/oagw/src/domain/error.rs new file mode 100644 index 0000000..a113c37 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/error.rs @@ -0,0 +1,280 @@ +//! The single gateway error enum (DESIGN §3.3). +//! +//! One variant per documented error, each carrying its HTTP status, GTS instance +//! id and (when the error is retriable) a `Retry-After` hint. [`api::rest::error`] +//! maps the whole enum to an RFC 9457 `application/problem+json` body in one +//! place, so the status, `type` and `X-OAGW-Error-Source` header can never drift +//! apart. + +use std::time::Duration; + +use thiserror::Error; + +use crate::domain::gts_helpers as gts; + +/// Every error the gear produces. +#[derive(Debug, Clone, Error)] +pub enum DomainError { + /// 400 — general request/route validation failure. + #[error("{0}")] + Validation(String), + + /// 400 — `X-OAGW-Target-Host` missing on a multi-endpoint, common-suffix alias. + #[error("X-OAGW-Target-Host header is required for this upstream")] + MissingTargetHost, + + /// 400 — `X-OAGW-Target-Host` present but not a bare host. + #[error("X-OAGW-Target-Host value is invalid: {0}")] + InvalidTargetHost(String), + + /// 400 — `X-OAGW-Target-Host` names no configured endpoint. + #[error("X-OAGW-Target-Host value does not match any configured endpoint: {0}")] + UnknownTargetHost(String), + + /// 403 — CORS origin is not in `allowed_origins`. + #[error("origin is not allowed: {0}")] + CorsOriginNotAllowed(String), + + /// 403 — cross-origin method is not in `allowed_methods`. + #[error("method is not allowed: {0}")] + CorsMethodNotAllowed(String), + + /// 401 — outbound authentication to the upstream failed. + #[error("{0}")] + AuthenticationFailed(String), + + /// 404 — no upstream/alias/route matched. + #[error("{0}")] + NotFound(String), + + /// 404 — the proxy path matched nothing (`RouteNotFound`). + #[error("{0}")] + RouteNotFound(String), + + /// 409 — a plugin still referenced by an upstream or route. + #[error("plugin is referenced by {upstreams} upstream(s) and {routes} route(s)")] + PluginInUse { + /// How many upstreams bind the plugin. + upstreams: usize, + /// How many routes bind the plugin. + routes: usize, + /// GTS ids of the referencing upstreams (ADR 0001 `referenced_by`). + upstream_ids: Vec, + /// GTS ids of the referencing routes. + route_ids: Vec, + }, + + /// 409 — a management uniqueness constraint was violated. + #[error("{0}")] + Conflict(String), + + /// 413 — request payload exceeds the hard limit. + #[error("request payload exceeds the maximum supported size")] + PayloadTooLarge, + + /// 429 — token bucket exhausted. + #[error("rate limit exceeded")] + RateLimitExceeded { + /// Seconds until the next token is available. + retry_after: u64, + }, + + /// 500 — a `secret_ref` could not be resolved from the credential store. + #[error("referenced secret could not be resolved")] + SecretNotFound, + + /// 502 — protocol-level failure while talking to the upstream. + #[error("{0}")] + ProtocolError(String), + + /// 502 — the upstream service failed. + #[error("{0}")] + DownstreamError(String), + + /// 502 — a stream (SSE/WebSocket) was aborted mid-flight. + #[error("{0}")] + StreamAborted(String), + + /// 502 — a guard rejected the upstream response. + #[error("{0}")] + DownstreamRejected(String), + + /// 503 — the upstream is disabled or otherwise unreachable by policy. + #[error("{0}")] + LinkUnavailable(String), + + /// 503 — circuit breaker open. + #[error("circuit breaker is open")] + CircuitBreakerOpen, + + /// 503 — a bound plugin could not be resolved. + #[error("{0}")] + PluginNotFound(String), + + /// 504 — connecting to the upstream timed out. + #[error("connection to the upstream timed out")] + ConnectionTimeout, + + /// 504 — the upstream did not answer within `proxy_timeout_secs`. + #[error("the upstream did not respond in time")] + RequestTimeout, + + /// 504 — an established stream went idle. + #[error("the upstream stream went idle")] + IdleTimeout, + + /// 500 — unexpected internal failure. + #[error("{0}")] + Internal(String), +} + +impl DomainError { + /// RFC 9457 status code for this error. + #[must_use] + pub fn status(&self) -> http::StatusCode { + use http::StatusCode; + match self { + Self::Validation(_) + | Self::MissingTargetHost + | Self::InvalidTargetHost(_) + | Self::UnknownTargetHost(_) => StatusCode::BAD_REQUEST, + Self::CorsOriginNotAllowed(_) | Self::CorsMethodNotAllowed(_) => StatusCode::FORBIDDEN, + Self::AuthenticationFailed(_) => StatusCode::UNAUTHORIZED, + Self::NotFound(_) | Self::RouteNotFound(_) => StatusCode::NOT_FOUND, + Self::PluginInUse { .. } | Self::Conflict(_) => StatusCode::CONFLICT, + Self::PayloadTooLarge => StatusCode::PAYLOAD_TOO_LARGE, + Self::RateLimitExceeded { .. } => StatusCode::TOO_MANY_REQUESTS, + Self::SecretNotFound | Self::Internal(_) => StatusCode::INTERNAL_SERVER_ERROR, + Self::ProtocolError(_) + | Self::DownstreamError(_) + | Self::StreamAborted(_) + | Self::DownstreamRejected(_) => StatusCode::BAD_GATEWAY, + Self::LinkUnavailable(_) | Self::CircuitBreakerOpen | Self::PluginNotFound(_) => { + StatusCode::SERVICE_UNAVAILABLE + } + Self::ConnectionTimeout | Self::RequestTimeout | Self::IdleTimeout => { + StatusCode::GATEWAY_TIMEOUT + } + } + } + + /// GTS instance id (the part after `gts.cf.core.errors.err.v1~`). + /// + /// One arm per variant on purpose: the table mirrors DESIGN §3.3's error + /// catalogue, so a variant keeps its own row even when two of them share a + /// GTS type today. + #[must_use] + #[allow(clippy::match_same_arms)] + pub fn instance_id(&self) -> &'static str { + match self { + Self::Validation(_) => gts::ERR_VALIDATION, + Self::MissingTargetHost => gts::ERR_MISSING_TARGET_HOST, + Self::InvalidTargetHost(_) => gts::ERR_INVALID_TARGET_HOST, + Self::UnknownTargetHost(_) => gts::ERR_UNKNOWN_TARGET_HOST, + Self::CorsOriginNotAllowed(_) => gts::ERR_CORS_ORIGIN_NOT_ALLOWED, + Self::CorsMethodNotAllowed(_) => gts::ERR_CORS_METHOD_NOT_ALLOWED, + Self::AuthenticationFailed(_) => gts::ERR_AUTH_FAILED, + Self::NotFound(_) | Self::RouteNotFound(_) => gts::ERR_ROUTE_NOT_FOUND, + Self::PluginInUse { .. } => gts::ERR_PLUGIN_IN_USE, + Self::Conflict(_) => gts::ERR_VALIDATION, + Self::PayloadTooLarge => gts::ERR_PAYLOAD_TOO_LARGE, + Self::RateLimitExceeded { .. } => gts::ERR_RATE_LIMIT_EXCEEDED, + Self::SecretNotFound => gts::ERR_SECRET_NOT_FOUND, + Self::ProtocolError(_) => gts::ERR_PROTOCOL_ERROR, + Self::DownstreamError(_) => gts::ERR_DOWNSTREAM_ERROR, + Self::StreamAborted(_) => gts::ERR_STREAM_ABORTED, + Self::DownstreamRejected(_) => gts::ERR_DOWNSTREAM_ERROR, + Self::LinkUnavailable(_) | Self::CircuitBreakerOpen | Self::PluginNotFound(_) => { + gts::ERR_LINK_UNAVAILABLE + } + Self::ConnectionTimeout => gts::ERR_TIMEOUT_CONNECTION, + Self::RequestTimeout => gts::ERR_TIMEOUT_REQUEST, + Self::IdleTimeout => gts::ERR_TIMEOUT_IDLE, + Self::Internal(_) => gts::ERR_PROTOCOL_ERROR, + } + } + + /// Short human-readable title for the problem body. + #[must_use] + pub fn title(&self) -> &'static str { + match self { + Self::Validation(_) | Self::Conflict(_) => "Validation Error", + Self::MissingTargetHost => "Missing Target Host", + Self::InvalidTargetHost(_) => "Invalid Target Host", + Self::UnknownTargetHost(_) => "Unknown Target Host", + Self::CorsOriginNotAllowed(_) => "CORS Origin Not Allowed", + Self::CorsMethodNotAllowed(_) => "CORS Method Not Allowed", + Self::AuthenticationFailed(_) => "Authentication Failed", + Self::NotFound(_) | Self::RouteNotFound(_) => "Route Not Found", + Self::PluginInUse { .. } => "Plugin In Use", + Self::PayloadTooLarge => "Payload Too Large", + Self::RateLimitExceeded { .. } => "Rate Limit Exceeded", + Self::SecretNotFound => "Secret Not Found", + Self::ProtocolError(_) => "Protocol Error", + Self::DownstreamError(_) | Self::DownstreamRejected(_) => "Downstream Error", + Self::StreamAborted(_) => "Stream Aborted", + Self::LinkUnavailable(_) => "Link Unavailable", + Self::CircuitBreakerOpen => "Circuit Breaker Open", + Self::PluginNotFound(_) => "Plugin Not Found", + Self::ConnectionTimeout => "Connection Timeout", + Self::RequestTimeout => "Request Timeout", + Self::IdleTimeout => "Idle Timeout", + Self::Internal(_) => "Internal Error", + } + } + + /// `Retry-After` value in seconds, for retriable errors. + #[must_use] + #[allow(clippy::match_same_arms)] // every retriable family stays explicit + pub fn retry_after(&self) -> Option { + match self { + Self::RateLimitExceeded { retry_after } => Some(Duration::from_secs(*retry_after)), + Self::CircuitBreakerOpen | Self::LinkUnavailable(_) => Some(Duration::from_secs(1)), + Self::ConnectionTimeout | Self::RequestTimeout | Self::IdleTimeout => { + Some(Duration::from_secs(1)) + } + _ => None, + } + } + + /// Whether the operation is safe to retry per DESIGN §3.3. + #[must_use] + pub fn retriable(&self) -> bool { + matches!( + self, + Self::RateLimitExceeded { .. } + | Self::CircuitBreakerOpen + | Self::LinkUnavailable(_) + | Self::ConnectionTimeout + | Self::RequestTimeout + | Self::IdleTimeout + ) + } + + /// Error-specific members appended to the problem body (RFC 9457 allows + /// extension members). ADR 0001 names the plugin's own GTS id and the + /// resources still holding a reference for `PluginInUse`. + #[must_use] + pub fn members(&self) -> Vec<(&'static str, serde_json::Value)> { + match self { + Self::PluginInUse { + upstream_ids, + route_ids, + .. + } => vec![( + "referenced_by", + serde_json::json!({ + "upstreams": upstream_ids, + "routes": route_ids, + }), + )], + _ => Vec::new(), + } + } +} + +impl From for DomainError { + fn from(e: std::io::Error) -> Self { + Self::Internal(e.to_string()) + } +} 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..0ef742d --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/gts_helpers.rs @@ -0,0 +1,160 @@ +//! GTS identifier constants for the OAGW gear. +//! +//! Two families live here: +//! +//! * `ERR_*` — the *instance* ids that complete the error type +//! `gts.cf.core.errors.err.v1~` (DESIGN §3.3). +//! * Everything else — the anonymous resource and plugin identifiers +//! (`gts.cf.core.oagw..v1~`). + +// --------------------------------------------------------------------------- +// Resource identifiers (anonymous GTS ids) +// --------------------------------------------------------------------------- + +/// Type part for an upstream resource id. +pub const TYPE_UPSTREAM: &str = "gts.cf.core.oagw.upstream.v1"; +/// Instance part of an upstream resource id. +pub const INST_UPSTREAM: &str = "cf.core.oagw.upstream.v1"; + +/// Type part for a route resource id. +pub const TYPE_ROUTE: &str = "gts.cf.core.oagw.route.v1"; +/// Type part for a plugin resource id. +pub const TYPE_PLUGIN: &str = "gts.cf.core.oagw.plugin.v1"; + +/// GTS identifier of the HTTP upstream protocol. +pub const PROTOCOL_HTTP: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +/// GTS identifier of the gRPC upstream protocol (Phase 3 — not proxied). +pub const PROTOCOL_GRPC: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1"; + +/// Builds an anonymous resource id `gts.cf.core.oagw..v1~`. +#[must_use] +pub fn resource_id(type_id: &str, uuid: &uuid::Uuid) -> String { + format!("{type_id}~{uuid}") +} + +/// Extracts the UUID part of an anonymous resource id, tolerating a bare UUID. +#[must_use] +pub fn uuid_from_resource_id(value: &str) -> Option { + let instance = value.rsplit('~').next().unwrap_or(value); + uuid::Uuid::parse_str(instance).ok() +} + +// --------------------------------------------------------------------------- +// Error instance ids (DESIGN §3.3 error table) +// --------------------------------------------------------------------------- + +/// 400 — general validation failure. +pub const ERR_VALIDATION: &str = "cf.oagw.validation.error.v1"; +/// 400 — `X-OAGW-Target-Host` missing. +pub const ERR_MISSING_TARGET_HOST: &str = "cf.oagw.routing.missing_target_host.v1"; +/// 400 — `X-OAGW-Target-Host` malformed. +pub const ERR_INVALID_TARGET_HOST: &str = "cf.oagw.routing.invalid_target_host.v1"; +/// 400 — `X-OAGW-Target-Host` unknown. +pub const ERR_UNKNOWN_TARGET_HOST: &str = "cf.oagw.routing.unknown_target_host.v1"; +/// 403 — CORS origin rejected. +pub const ERR_CORS_ORIGIN_NOT_ALLOWED: &str = "cf.oagw.cors.origin_not_allowed.v1"; +/// 403 — CORS method rejected. +pub const ERR_CORS_METHOD_NOT_ALLOWED: &str = "cf.oagw.cors.method_not_allowed.v1"; +/// 401 — upstream authentication failed. +pub const ERR_AUTH_FAILED: &str = "cf.oagw.auth.failed.v1"; +/// 404 — no route matched. +pub const ERR_ROUTE_NOT_FOUND: &str = "cf.oagw.route.not_found.v1"; +/// 409 — plugin still bound. +pub const ERR_PLUGIN_IN_USE: &str = "cf.oagw.plugin.in_use.v1"; +/// 413 — payload too large. +pub const ERR_PAYLOAD_TOO_LARGE: &str = "cf.oagw.payload.too_large.v1"; +/// 429 — rate limit exhausted. +pub const ERR_RATE_LIMIT_EXCEEDED: &str = "cf.oagw.rate_limit.exceeded.v1"; +/// 500 — credential unresolvable. +pub const ERR_SECRET_NOT_FOUND: &str = "cf.oagw.secret.not_found.v1"; +/// 502 — protocol-level failure. +pub const ERR_PROTOCOL_ERROR: &str = "cf.oagw.protocol.error.v1"; +/// 502 — upstream service error. +pub const ERR_DOWNSTREAM_ERROR: &str = "cf.oagw.downstream.error.v1"; +/// 502 — stream aborted. +pub const ERR_STREAM_ABORTED: &str = "cf.oagw.stream.aborted.v1"; +/// 503 — link unavailable. +pub const ERR_LINK_UNAVAILABLE: &str = "cf.oagw.link.unavailable.v1"; +/// 503 — circuit breaker open. +pub const ERR_CIRCUIT_BREAKER_OPEN: &str = "cf.oagw.circuit_breaker.open.v1"; +/// 503 — plugin not found. +pub const ERR_PLUGIN_NOT_FOUND: &str = "cf.oagw.plugin.not_found.v1"; +/// 504 — connect timeout. +pub const ERR_TIMEOUT_CONNECTION: &str = "cf.oagw.timeout.connection.v1"; +/// 504 — request timeout. +pub const ERR_TIMEOUT_REQUEST: &str = "cf.oagw.timeout.request.v1"; +/// 504 — idle timeout. +pub const ERR_TIMEOUT_IDLE: &str = "cf.oagw.timeout.idle.v1"; + +/// The error `type` prefix — the instance id completes it. +pub const ERROR_TYPE_PREFIX: &str = "gts.cf.core.errors.err.v1~"; + +/// Builds the full error `type` for an instance id. +#[must_use] +pub fn error_type(instance_id: &str) -> String { + format!("{ERROR_TYPE_PREFIX}{instance_id}") +} + +// --------------------------------------------------------------------------- +// Plugin identifiers +// --------------------------------------------------------------------------- + +/// Prefix for auth-plugin identifiers. +pub const AUTH_PLUGIN_TYPE: &str = "gts.cf.core.oagw.auth_plugin.v1"; +/// Prefix for guard-plugin identifiers. +pub const GUARD_PLUGIN_TYPE: &str = "gts.cf.core.oagw.guard_plugin.v1"; +/// Prefix for transform-plugin identifiers. +pub const TRANSFORM_PLUGIN_TYPE: &str = "gts.cf.core.oagw.transform_plugin.v1"; + +/// `noop` auth plugin. +pub const AUTH_NOOP: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1"; +/// `apikey` auth plugin. +pub const AUTH_APIKEY: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1"; +/// `OAuth2` client credentials, `Form` client auth. +pub const AUTH_OAUTH2_CC: &str = + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1"; +/// `OAuth2` client credentials, `Basic` client auth. +pub const AUTH_OAUTH2_CC_BASIC: &str = + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1"; +/// `basic` — catalog only, no backing implementation. +pub const AUTH_BASIC: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.basic.v1"; +/// `bearer` — catalog only, no backing `AuthPlugin`. +pub const AUTH_BEARER: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.bearer.v1"; + +/// `required_headers` guard — the only bindable guard identifier. +pub const GUARD_REQUIRED_HEADERS: &str = + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"; +/// `timeout` guard — catalog only (core data-plane logic). +pub const GUARD_TIMEOUT: &str = "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.timeout.v1"; +/// `cors` guard — catalog only (core data-plane logic). +pub const GUARD_CORS: &str = "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1"; + +/// `request_id` transform. +pub const TRANSFORM_REQUEST_ID: &str = + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"; +/// `logging` transform — catalog only. +pub const TRANSFORM_LOGGING: &str = "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.logging.v1"; +/// `metrics` transform — catalog only. +pub const TRANSFORM_METRICS: &str = "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.metrics.v1"; + +/// Header used to mark gateway- vs upstream-originated responses. +pub const HEADER_ERROR_SOURCE: &str = "x-oagw-error-source"; +/// Routing header consumed (and stripped) by the data plane. +pub const HEADER_TARGET_HOST: &str = "x-oagw-target-host"; +/// Propagated/generated request correlation id. +pub const HEADER_REQUEST_ID: &str = "x-request-id"; + +/// [`HEADER_REQUEST_ID`] as a [`http::HeaderName`]. +/// +/// # Panics +/// Never: the constant is a known-good lowercase header name. +#[must_use] +#[allow(clippy::expect_used)] // a static, valid header name +pub fn request_id_header() -> http::HeaderName { + http::HeaderName::from_bytes(HEADER_REQUEST_ID.as_bytes()).expect("static header name") +} + +/// `X-OAGW-Error-Source` value for gateway-generated responses. +pub const ERROR_SOURCE_GATEWAY: &str = "gateway"; +/// `X-OAGW-Error-Source` value for upstream passthroughs. +pub const ERROR_SOURCE_UPSTREAM: &str = "upstream"; 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..ef44717 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/mod.rs @@ -0,0 +1,9 @@ +//! Domain model and services, independent of any transport. + +pub mod alias; +pub mod error; +pub mod gts_helpers; +pub mod model; +pub mod plugin; +pub mod repo; +pub mod services; diff --git a/gears/system/oagw/oagw/src/domain/model.rs b/gears/system/oagw/oagw/src/domain/model.rs new file mode 100644 index 0000000..b2bccff --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/model.rs @@ -0,0 +1,674 @@ +//! Domain model, mirroring `docs/schemas/upstream.v1.schema.json` and +//! `docs/schemas/route.v1.schema.json` (plus the `enabled` field PRD §5.1 adds +//! to both resources). + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers as gts; + +/// Sharing mode for hierarchical configuration. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum SharingMode { + /// Not visible to descendants. + #[default] + Private, + /// Visible; descendants may override. + Inherit, + /// Visible; descendants may not override. + Enforce, +} + +/// Endpoint schemes. `http` is a legal value here — the *connection* policy for +/// plaintext upstreams is a separate gear-level flag (`allow_http_upstream`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +pub enum EndpointScheme { + /// Plaintext HTTP. Legal whenever `allow_http_upstream` is `true`. + #[serde(rename = "http")] + Http, + /// Default. + #[default] + #[serde(rename = "https")] + Https, + /// WebSocket over TLS. + #[serde(rename = "wss")] + Wss, + /// WebTransport. + #[serde(rename = "wt")] + Wt, + /// gRPC. + #[serde(rename = "grpc")] + Grpc, +} + +impl EndpointScheme { + /// Standard port for this scheme — omitted from derived aliases. + #[must_use] + pub fn standard_port(self) -> u16 { + match self { + Self::Http => 80, + Self::Https | Self::Wss | Self::Wt | Self::Grpc => 443, + } + } +} + +/// One upstream endpoint. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Endpoint { + /// Defaults to `https`. + #[serde(default)] + pub scheme: EndpointScheme, + /// RFC 1123 hostname or IP literal. + pub host: String, + /// Defaults to the scheme's standard port. + #[serde(default)] + pub port: Option, +} + +impl Endpoint { + /// Effective port (the default for the scheme when unset). + #[must_use] + pub fn effective_port(&self) -> u16 { + self.port.unwrap_or_else(|| self.scheme.standard_port()) + } + + /// `true` when the port equals the scheme's standard port. + #[must_use] + pub fn has_standard_port(&self) -> bool { + self.effective_port() == self.scheme.standard_port() + } +} + +/// `server` block. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct ServerConfig { + /// One or more endpoints forming a load-balance pool. + pub endpoints: Vec, +} + +/// `headers.request.passthrough` modes. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PassthroughMode { + /// Forward no inbound request headers (only structural ones). + #[default] + None, + /// Forward only `passthrough_allowlist`. + Allowlist, + /// Forward every inbound header (minus routing + hop-by-hop). + All, +} + +/// `headers.request` rules. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct RequestHeaderRules { + /// Headers to set (overwriting). + pub set: BTreeMap, + /// Headers to add (appending). + pub add: BTreeMap, + /// Header names to remove. + pub remove: Vec, + /// Which inbound headers are forwarded. + pub passthrough: PassthroughMode, + /// Headers forwarded when `passthrough` is `allowlist`. + pub passthrough_allowlist: Vec, +} + +/// `headers.response` rules. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct ResponseHeaderRules { + /// Headers to set on the client response. + pub set: BTreeMap, + /// Headers to add to the client response. + pub add: BTreeMap, + /// Headers stripped from the upstream response. + pub remove: Vec, +} + +/// `headers` block. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct HeadersConfig { + /// Inbound → outbound rules. + pub request: RequestHeaderRules, + /// Upstream → client rules. + pub response: ResponseHeaderRules, +} + +/// `auth` block — which auth plugin injects outbound credentials. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct AuthConfig { + /// Auth plugin GTS identifier. + #[serde(rename = "type")] + pub plugin_type: Option, + /// Hierarchical sharing mode. + pub sharing: SharingMode, + /// Auth plugin configuration. + pub config: BTreeMap, +} + +impl Default for AuthConfig { + fn default() -> Self { + Self { + plugin_type: Some(gts::AUTH_NOOP.to_owned()), + sharing: SharingMode::Private, + config: BTreeMap::new(), + } + } +} + +/// A `plugins.items[]` entry: either a bare plugin identifier or a bound +/// `{plugin_ref, config}` object (ADR 0009's configuration shape). +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(untagged)] +pub enum PluginBinding { + /// A bare GTS identifier or custom-plugin UUID. + Reference(String), + /// A plugin reference with optional bind-time configuration. + Bound { + /// Plugin identifier (GTS id or UUID). + plugin_ref: String, + /// UUID of the stored custom plugin, when the reference is UUID-backed. + plugin_uuid: Option, + /// Plugin configuration. + config: Option>, + }, +} + +impl PluginBinding { + /// The referenced plugin identifier. + #[must_use] + pub fn plugin_ref(&self) -> &str { + match self { + Self::Reference(r) | Self::Bound { plugin_ref: r, .. } => r, + } + } + + /// The bound plugin's configuration, when supplied. + #[must_use] + pub fn config(&self) -> Option<&BTreeMap> { + match self { + Self::Reference(_) => None, + Self::Bound { config, .. } => config.as_ref(), + } + } +} + +/// `plugins` block. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] +#[serde(default)] +pub struct PluginsConfig { + /// Sharing mode for the plugin chain. + pub sharing: SharingMode, + /// Plugins applied to this resource. + pub items: Vec, +} + +/// Rate-limit window. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateWindow { + /// One second. + #[default] + Second, + /// One minute. + Minute, + /// One hour. + Hour, + /// One day. + Day, +} + +impl RateWindow { + /// Window length. + /// + /// Spelled in seconds: `Duration`'s minute/hour/day constructors are still + /// unstable. + #[must_use] + #[allow(clippy::duration_suboptimal_units)] + pub fn duration(self) -> std::time::Duration { + match self { + Self::Second => std::time::Duration::from_secs(1), + Self::Minute => std::time::Duration::from_secs(60), + Self::Hour => std::time::Duration::from_secs(60 * 60), + Self::Day => std::time::Duration::from_secs(24 * 60 * 60), + } + } +} + +/// `rate_limit.sustained`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SustainedRate { + /// Tokens replenished per window. + pub rate: u32, + /// Window for the sustained rate. + #[serde(default)] + pub window: RateWindow, +} + +/// `rate_limit.burst`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct Burst { + /// Bucket capacity; defaults to the sustained rate. + pub capacity: u32, +} + +/// Rate-limiting algorithm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "snake_case")] +pub enum RateAlgorithm { + /// Token bucket (default). + #[default] + TokenBucket, + /// Sliding window. + SlidingWindow, +} + +/// Rate-limit counter scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateScope { + /// One bucket for the whole deployment. + Global, + /// One bucket per tenant (default). + #[default] + Tenant, + /// One bucket per authenticated user. + User, + /// One bucket per client IP. + Ip, + /// One bucket per route. + Route, +} + +/// Behaviour when the limit is exhausted. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateStrategy { + /// Reject with `429` (default). + #[default] + Reject, + /// Queue the request. + Queue, + /// Serve a degraded response. + Degrade, +} + +/// `rate_limit` block. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct RateLimitConfig { + /// Sharing mode. + pub sharing: SharingMode, + /// Algorithm. + pub algorithm: RateAlgorithm, + /// Sustained rate. + pub sustained: SustainedRate, + /// Bucket capacity. + pub burst: Option, + /// Counter scope. + pub scope: RateScope, + /// Overflow strategy. + pub strategy: RateStrategy, + /// Tokens consumed per request. + pub cost: u32, + /// Whether to emit `X-RateLimit-*` headers on rejections. + pub response_headers: bool, +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + sharing: SharingMode::Private, + algorithm: RateAlgorithm::TokenBucket, + sustained: SustainedRate { + rate: 1, + window: RateWindow::Second, + }, + burst: None, + scope: RateScope::Tenant, + strategy: RateStrategy::Reject, + cost: 1, + response_headers: true, + } + } +} + +impl RateLimitConfig { + /// Bucket capacity (defaults to the sustained rate). + #[must_use] + pub fn capacity(&self) -> u32 { + self.burst + .as_ref() + .map_or(self.sustained.rate, |b| b.capacity) + } +} + +/// CORS configuration. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct CorsConfig { + /// Sharing mode. + pub sharing: SharingMode, + /// Enable CORS handling. + pub enabled: bool, + /// Allowed origins (`["*"]` allows any). + pub allowed_origins: Vec, + /// Allowed methods. + pub allowed_methods: Vec, + /// Headers exposed to the browser beyond the safelisted set. + pub expose_headers: Vec, + /// Allow credentials; incompatible with a wildcard origin. + pub allow_credentials: bool, + /// `Access-Control-Max-Age` for preflights. + pub max_age_secs: u64, +} + +impl Default for CorsConfig { + fn default() -> Self { + Self { + sharing: SharingMode::Private, + enabled: false, + allowed_origins: Vec::new(), + allowed_methods: vec!["GET".to_owned(), "POST".to_owned()], + expose_headers: Vec::new(), + allow_credentials: false, + max_age_secs: 600, + } + } +} + +/// HTTP method accepted by a route's `match.http.methods`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum HttpMethod { + /// `GET`. + #[serde(rename = "GET")] + Get, + /// `POST`. + #[serde(rename = "POST")] + Post, + /// `PUT`. + #[serde(rename = "PUT")] + Put, + /// `DELETE`. + #[serde(rename = "DELETE")] + Delete, + /// `PATCH`. + #[serde(rename = "PATCH")] + Patch, +} + +impl HttpMethod { + /// The method as an HTTP token. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Get => "GET", + Self::Post => "POST", + Self::Put => "PUT", + Self::Delete => "DELETE", + Self::Patch => "PATCH", + } + } + + /// Parses an HTTP method token, 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, + } + } +} + +/// How the proxy URL's path suffix is treated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PathSuffixMode { + /// Reject requests that carry a path suffix. + Disabled, + /// Append the suffix to the route's path (default). + #[default] + Append, +} + +/// `match.http`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct HttpMatch { + /// Methods accepted by this route. + pub methods: Vec, + /// Path prefix served by this route. + pub path: String, + /// Query parameters allowed through; empty allows none. + pub query_allowlist: Vec, + /// How the proxy URL's path suffix is treated. + pub path_suffix_mode: PathSuffixMode, +} + +impl Default for HttpMatch { + fn default() -> Self { + Self { + methods: vec![HttpMethod::Get], + path: "/".to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: PathSuffixMode::Append, + } + } +} + +/// `match.grpc`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct GrpcMatch { + /// Fully-qualified service name. + pub service: String, + /// RPC method name. + pub method: String, +} + +/// `match` — exactly one of `http` / `grpc`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(deny_unknown_fields)] +pub struct MatchConfig { + /// HTTP matching rules. + #[serde(skip_serializing_if = "Option::is_none")] + pub http: Option, + /// gRPC matching rules (not proxied in this phase). + #[serde(skip_serializing_if = "Option::is_none")] + pub grpc: Option, +} + +impl MatchConfig { + /// Validates the exactly-one-of constraint. + /// + /// # Errors + /// Returns [`DomainError::Validation`] when neither or both are present. + pub fn validate(&self) -> Result<(), DomainError> { + match (&self.http, &self.grpc) { + (None, None) | (Some(_), Some(_)) => Err(DomainError::Validation( + "match must declare exactly one of http or grpc".to_owned(), + )), + _ => Ok(()), + } + } +} + +/// A stored upstream. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Upstream { + /// Server-generated UUID. + pub id: Uuid, + /// Anonymous GTS resource id. + pub gts_id: String, + /// Owning tenant. + pub tenant_id: String, + /// Routing key for `/oagw/v1/proxy/{alias}`. + pub alias: String, + /// Disabled upstreams answer `503`. + pub enabled: bool, + /// Add-only categorization tags. + pub tags: Vec, + /// Endpoint pool. + pub server: ServerConfig, + /// Upstream protocol. + pub protocol: String, + /// Outbound auth. + pub auth: Option, + /// Header transformation rules. + pub headers: HeadersConfig, + /// Guard/transform plugin bindings. + pub plugins: PluginsConfig, + /// Rate limit. + pub rate_limit: Option, + /// CORS policy. + pub cors: Option, + /// Creation timestamp (RFC 3339). + pub created_at: String, + /// Last update timestamp (RFC 3339). + pub updated_at: String, +} + +impl Upstream { + /// Whether this upstream speaks HTTP (as opposed to gRPC). + #[must_use] + pub fn is_http_protocol(&self) -> bool { + self.protocol == gts::PROTOCOL_HTTP + } +} + +/// A stored route. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Route { + /// Server-generated UUID. + pub id: Uuid, + /// Anonymous GTS resource id. + pub gts_id: String, + /// Owning tenant. + pub tenant_id: String, + /// Upstream this route serves. + pub upstream_id: Uuid, + /// Disabled routes are excluded from matching. + pub enabled: bool, + /// Add-only categorization tags. + pub tags: Vec, + /// Matching rules. + pub match_config: MatchConfig, + /// Match precedence among siblings: the lower the value, the earlier the + /// route is considered when two prefixes have the same depth. + pub priority: i64, + /// Guard/transform plugin bindings. + pub plugins: PluginsConfig, + /// Rate limit. + pub rate_limit: Option, + /// CORS policy, overriding the upstream's when set. + pub cors: Option, + /// Creation timestamp (RFC 3339). + pub created_at: String, + /// Last update timestamp (RFC 3339). + pub updated_at: String, +} + +/// A stored custom (Starlark) plugin definition. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +#[allow(clippy::struct_field_names)] // `plugin_type` mirrors the wire field `type` +pub struct Plugin { + /// Server-generated UUID. + pub id: Uuid, + /// Anonymous GTS resource id. + pub gts_id: String, + /// Owning tenant. + pub tenant_id: String, + /// Human-readable name. + pub name: String, + /// `auth`, `guard` or `transform`. + pub plugin_type: String, + /// JSON schema describing the plugin's config. + pub config_schema: serde_json::Value, + /// Starlark source. + pub source_code: String, + /// Creation timestamp (RFC 3339). + pub created_at: String, +} + +impl Plugin { + /// `true` for the three documented plugin types. + #[must_use] + pub fn known_type(plugin_type: &str) -> bool { + matches!(plugin_type, "auth" | "guard" | "transform") + } +} + +/// Now, formatted as RFC 3339. +#[must_use] +pub fn now_rfc3339() -> String { + humantime::format_rfc3339_millis(std::time::SystemTime::now()).to_string() +} + +/// Validates a tag per the schema's `^[a-z0-9_-]+$` pattern. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the tag is empty or carries a +/// character outside the pattern. +pub fn validate_tag(tag: &str) -> Result<(), DomainError> { + if tag.is_empty() + || !tag + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') + { + return Err(DomainError::Validation(format!( + "invalid tag `{tag}`: must match ^[a-z0-9_-]+$" + ))); + } + Ok(()) +} + +/// Validates an alias per the schema's `^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$`. +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the shape does not match. +pub fn validate_alias_shape(alias: &str) -> Result<(), DomainError> { + let bytes = alias.as_bytes(); + let ok_first = bytes + .first() + .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit()); + let ok_last = bytes + .last() + .is_some_and(|b| b.is_ascii_lowercase() || b.is_ascii_digit()); + let ok_middle = bytes.iter().all(|b| { + b.is_ascii_lowercase() || b.is_ascii_digit() || *b == b'.' || *b == b':' || *b == b'-' + }); + if bytes.is_empty() || !ok_first || !ok_last || !ok_middle { + return Err(DomainError::Validation(format!("invalid alias `{alias}`"))); + } + Ok(()) +} + +/// Validates a CORS origin (`*` or an absolute URI). +/// +/// # Errors +/// Returns [`DomainError::Validation`] when the origin is neither `*` nor a +/// parseable absolute URI with a host. +pub fn validate_origin(origin: &str) -> Result<(), DomainError> { + if origin == "*" { + return Ok(()); + } + url::Url::parse(origin) + .ok() + .filter(|u| u.host_str().is_some()) + .ok_or_else(|| DomainError::Validation(format!("invalid origin `{origin}`")))?; + Ok(()) +} 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..14d22e2 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/plugin/mod.rs @@ -0,0 +1,234 @@ +//! Plugin traits and request/response contexts (DESIGN §3.2 "Plugin System", +//! ADR 0008, ADR 0009). +//! +//! Three separate traits with a deterministic execution order: +//! Auth → Guards → Transform(request) → upstream call → Transform(response/error). +//! Upstream-bound plugins always run before route-bound ones. + +use std::collections::BTreeMap; + +use async_trait::async_trait; +use http::HeaderMap; + +use crate::domain::error::DomainError; + +/// Cross-plugin, cross-phase state. +#[derive(Debug, Default, Clone)] +pub struct PluginAttributes { + inner: BTreeMap, +} + +impl PluginAttributes { + /// Stores a value for later phases. + pub fn set(&mut self, key: impl Into, value: impl Into) { + self.inner.insert(key.into(), value.into()); + } + + /// Reads a value set by an earlier phase. + #[must_use] + pub fn get(&self, key: &str) -> Option<&str> { + self.inner.get(key).map(String::as_str) + } +} + +/// Mutable view over the outbound request, shared by all three plugin types. +#[derive(Debug)] +pub struct RequestContext { + /// Calling tenant. + pub tenant_id: String, + /// The proxy alias addressed. + pub alias: String, + /// The path that will be appended to the route prefix. + pub path: String, + /// Query parameters that survived the allowlist. + pub query: Vec<(String, String)>, + /// Outbound request headers (already stripped of routing + hop-by-hop). + pub headers: HeaderMap, + /// Cross-plugin state. + pub attributes: PluginAttributes, +} + +impl RequestContext { + /// Builds a request context. + #[must_use] + pub fn new( + tenant_id: impl Into, + alias: impl Into, + path: impl Into, + query: Vec<(String, String)>, + headers: HeaderMap, + ) -> Self { + Self { + tenant_id: tenant_id.into(), + alias: alias.into(), + path: path.into(), + query, + headers, + attributes: PluginAttributes::default(), + } + } + + /// Rebuilds the query string from the surviving parameters. + #[must_use] + pub fn query_string(&self) -> String { + form_urlencoded::Serializer::new(String::new()) + .extend_pairs(self.query.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .finish() + } +} + +/// Mutable view over the upstream response, before it is returned to the client. +#[derive(Debug)] +pub struct ResponseContext<'a> { + /// Upstream status. + pub status: http::StatusCode, + /// Upstream response headers (mutable). + pub headers: HeaderMap, + /// The request that produced this response. + pub request: &'a RequestContext, +} + +/// Mutable view over a gateway-generated error response. +#[derive(Debug)] +pub struct ErrorContext<'a> { + /// Status the gateway will answer with. + pub status: http::StatusCode, + /// Headers to attach to the error response (mutable). + pub headers: HeaderMap, + /// The request that was rejected. + pub request: &'a RequestContext, + /// The gateway error about to be returned. + pub error: &'a DomainError, +} + +/// Credential injection. Exactly one per upstream. +#[async_trait] +pub trait AuthPlugin: Send + Sync { + /// The plugin's GTS identifier. + fn id(&self) -> &'static str; + + /// Injects outbound credentials into `ctx`. + /// + /// # Errors + /// Returns the error the proxy should surface (`401`/`500`). + async fn authenticate( + &self, + ctx: &mut RequestContext, + config: &PluginConfig, + ) -> Result<(), DomainError>; +} + +/// Validation that can reject a request or a response. +#[async_trait] +pub trait GuardPlugin: Send + Sync { + /// The plugin's GTS identifier. + fn id(&self) -> &'static str; + + /// Validates the outbound request. + /// + /// # Errors + /// Returning an error rejects the request before it reaches the upstream. + async fn guard_request( + &self, + ctx: &mut RequestContext, + config: &PluginConfig, + ) -> Result<(), DomainError> { + let _ = (ctx, config); + Ok(()) + } + + /// Validates the upstream response. + /// + /// # Errors + /// Returns an error to reject the response (`502`). + async fn guard_response( + &self, + ctx: &mut ResponseContext<'_>, + config: &PluginConfig, + ) -> Result<(), DomainError> { + let _ = (ctx, config); + Ok(()) + } +} + +/// Request/response/error mutation. +#[async_trait] +pub trait TransformPlugin: Send + Sync { + /// The plugin's GTS identifier. + fn id(&self) -> &'static str; + + /// Mutates the outbound request. + /// + /// # Errors + /// Returns an error to reject the request. + async fn on_request( + &self, + ctx: &mut RequestContext, + config: &PluginConfig, + ) -> Result<(), DomainError> { + let _ = (ctx, config); + Ok(()) + } + + /// Mutates the response before it is returned. + /// + /// # Errors + /// Returns an error to reject the response (`502`). + async fn on_response( + &self, + ctx: &mut ResponseContext<'_>, + config: &PluginConfig, + ) -> Result<(), DomainError> { + let _ = (ctx, config); + Ok(()) + } + + /// Mutates a gateway-generated error response. + /// + /// # Errors + /// Returns an error to fall back to the default error body. + async fn on_error( + &self, + ctx: &mut ErrorContext<'_>, + config: &PluginConfig, + ) -> Result<(), DomainError> { + let _ = (ctx, config); + Ok(()) + } +} + +/// Resolved plugin configuration: the identifier plus the bind-time config. +#[derive(Debug, Clone)] +pub struct PluginConfig { + /// The plugin's GTS identifier (or custom-plugin UUID). + pub plugin_ref: String, + /// Bind-time configuration keys. + pub values: PluginConfigMap, +} + +/// Plugin configuration map, as supplied in `auth.config` / +/// `plugins.items[].config`. +pub type PluginConfigMap = BTreeMap; + +impl PluginConfig { + /// Builds a plugin config from a binding. + #[must_use] + pub fn from_binding(plugin_ref: &str, values: Option<&PluginConfigMap>) -> Self { + Self { + plugin_ref: plugin_ref.to_owned(), + values: values.cloned().unwrap_or_default(), + } + } + + /// Reads a string config key. + #[must_use] + pub fn string(&self, key: &str) -> Option<&str> { + self.values.get(key).and_then(serde_json::Value::as_str) + } +} + +impl From<&crate::domain::model::PluginBinding> for PluginConfig { + fn from(binding: &crate::domain::model::PluginBinding) -> Self { + Self::from_binding(binding.plugin_ref(), binding.config()) + } +} 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..b3abdfe --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/repo.rs @@ -0,0 +1,105 @@ +//! Repository traits for the Control Plane store (DESIGN §3.2 DDD-Light). +//! +//! The domain layer depends only on these traits; the in-memory implementation +//! lives in [`crate::infra::storage::memory`] and a SeaORM-backed one can be +//! swapped in without touching the services. + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::{Plugin, Route, Upstream}; + +/// View over stored upstreams, scoped by tenant. +#[async_trait] +pub trait UpstreamRepository: Send + Sync { + /// Persists a new upstream. + /// + /// # Errors + /// Returns [`DomainError::Conflict`] when the alias is already taken. + async fn insert(&self, upstream: Upstream) -> Result<(), DomainError>; + + /// Reads an upstream by id, scoped to the tenant. + async fn get(&self, tenant_id: &str, id: Uuid) -> Result, DomainError>; + + /// Reads an upstream by its (normalized) alias, scoped to the tenant. + async fn get_by_alias( + &self, + tenant_id: &str, + alias: &str, + ) -> Result, DomainError>; + + /// Lists every upstream owned by the tenant. + async fn list(&self, tenant_id: &str) -> Result, DomainError>; + + /// Replaces a stored upstream. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when the upstream does not exist. + async fn update(&self, upstream: Upstream) -> Result<(), DomainError>; + + /// Deletes an upstream, returning `false` when it did not exist. + async fn delete(&self, tenant_id: &str, id: Uuid) -> Result; +} + +/// View over stored routes, scoped by tenant. +#[async_trait] +pub trait RouteRepository: Send + Sync { + /// Persists a new route. + async fn insert(&self, route: Route) -> Result<(), DomainError>; + + /// Reads a route by id, scoped to the tenant. + async fn get(&self, tenant_id: &str, id: Uuid) -> Result, DomainError>; + + /// Lists every route owned by the tenant. + async fn list(&self, tenant_id: &str) -> Result, DomainError>; + + /// Lists every route owned by the tenant for one upstream. + async fn list_by_upstream( + &self, + tenant_id: &str, + upstream_id: Uuid, + ) -> Result, DomainError>; + + /// Replaces a stored route. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when the route does not exist. + async fn update(&self, route: Route) -> Result<(), DomainError>; + + /// Deletes a route, returning `false` when it did not exist. + async fn delete(&self, tenant_id: &str, id: Uuid) -> Result; + + /// Deletes every route bound to an upstream (cascade). + async fn delete_by_upstream( + &self, + tenant_id: &str, + upstream_id: Uuid, + ) -> Result; +} + +/// View over stored custom plugin definitions. +#[async_trait] +pub trait PluginRepository: Send + Sync { + /// Persists a new plugin definition. + async fn insert(&self, plugin: Plugin) -> Result<(), DomainError>; + + /// Reads a plugin by id, scoped to the tenant. + async fn get(&self, tenant_id: &str, id: Uuid) -> Result, DomainError>; + + /// Lists every plugin owned by the tenant. + async fn list(&self, tenant_id: &str) -> Result, DomainError>; + + /// Deletes a plugin, returning `false` when it did not exist. + async fn delete(&self, tenant_id: &str, id: Uuid) -> Result; +} + +/// Tenant-hierarchy view used for alias shadowing. +#[async_trait] +pub trait TenantHierarchy: Send + Sync { + /// The tenant itself followed by its ancestors, closest first. + /// + /// Implementations must always start with `tenant_id` and must terminate + /// (implementations cap the walk defensively). + async fn chain(&self, tenant_id: &str) -> Vec; +} 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..12deb76 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/services/management.rs @@ -0,0 +1,875 @@ +//! `ControlPlaneService` — CRUD for upstreams/routes/plugins plus the single +//! alias-resolution + route-matching walk used by the data plane +//! (DESIGN §3.2 "Internal Services", ADR 0006). + +use std::sync::Arc; + +use uuid::Uuid; + +use crate::domain::alias; +use crate::domain::error::DomainError; +use crate::domain::model::{ + CorsConfig, EndpointScheme, Plugin, PluginBinding, RateLimitConfig, Route, Upstream, +}; +use crate::domain::repo::{PluginRepository, RouteRepository, TenantHierarchy, UpstreamRepository}; + +/// The effective configuration a proxy request resolves to (ADR 0006). +#[derive(Debug, Clone)] +pub struct ResolvedTarget { + /// The selected upstream. + pub upstream: Upstream, + /// The matched route. + pub route: Route, + /// The part of the proxy path the route prefix did not cover. + pub path_remainder: String, + /// Guard/transform bindings: upstream plugins first, then route plugins. + pub plugins: Vec, + /// Effective rate limit (the strictest of upstream, route and enforced + /// ancestors), when any applies. + pub rate_limit: Option, + /// Effective CORS policy. + pub cors: Option, + /// The tenant whose upstream was selected. + pub owning_tenant: String, +} + +/// Control Plane service. +pub struct ControlPlaneService { + upstreams: Arc, + routes: Arc, + plugins: Arc, + hierarchy: Arc, + allow_http_upstream: bool, +} + +impl ControlPlaneService { + /// Builds a control plane over the supplied repositories. + #[must_use] + pub fn new( + upstreams: Arc, + routes: Arc, + plugins: Arc, + hierarchy: Arc, + allow_http_upstream: bool, + ) -> Self { + Self { + upstreams, + routes, + plugins, + hierarchy, + allow_http_upstream, + } + } + + /// Whether plaintext upstream endpoints may be connected to. + #[must_use] + pub fn allow_http_upstream(&self) -> bool { + self.allow_http_upstream + } + + // ----------------------------------------------------------------- + // Upstreams + // ----------------------------------------------------------------- + + /// Creates an upstream, deriving (or enforcing) its alias from the endpoints. + /// + /// # Errors + /// Returns [`DomainError::Validation`] or [`DomainError::Conflict`]. + pub async fn create_upstream( + &self, + tenant_id: &str, + mut upstream: Upstream, + provided_alias: Option, + ) -> Result { + Self::validate_upstream(&upstream)?; + self.validate_plugin_references_exist(tenant_id, &upstream.plugins.items) + .await?; + let derived = + alias::enforce_alias_create(&upstream.server.endpoints, provided_alias.as_deref())?; + upstream.alias = derived; + upstream.tenant_id = tenant_id.to_owned(); + upstream.id = Uuid::new_v4(); + upstream.gts_id = crate::domain::gts_helpers::resource_id( + crate::domain::gts_helpers::TYPE_UPSTREAM, + &upstream.id, + ); + upstream.created_at = crate::domain::model::now_rfc3339(); + upstream.updated_at = upstream.created_at.clone(); + self.upstreams.insert(upstream.clone()).await?; + Ok(upstream) + } + + /// Reads an upstream by id. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when the upstream is not in this tenant. + pub async fn get_upstream(&self, tenant_id: &str, id: Uuid) -> Result { + self.upstreams + .get(tenant_id, id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("upstream {id} not found"))) + } + + /// Lists upstreams owned by the tenant. + /// + /// # Errors + /// Propagates repository failures. + pub async fn list_upstreams(&self, tenant_id: &str) -> Result, DomainError> { + self.upstreams.list(tenant_id).await + } + + /// Replaces an upstream, applying the alias transition matrix. + /// + /// # Errors + /// Returns [`DomainError::Validation`], [`DomainError::NotFound`] or + /// [`DomainError::Conflict`]. + pub async fn replace_upstream( + &self, + tenant_id: &str, + id: Uuid, + mut upstream: Upstream, + provided_alias: Option, + ) -> Result { + let existing = self.get_upstream(tenant_id, id).await?; + Self::validate_upstream(&upstream)?; + self.validate_plugin_references_exist(tenant_id, &upstream.plugins.items) + .await?; + let existing_derivable = matches!( + alias::compute_derived_alias(&existing.server.endpoints)?, + alias::AliasDerivation::Derived(_) + ); + let alias = alias::enforce_alias_update( + &existing.alias, + existing_derivable, + &upstream.server.endpoints, + provided_alias.as_deref(), + )?; + upstream.id = existing.id; + upstream.tenant_id = existing.tenant_id; + upstream.gts_id = existing.gts_id; + upstream.alias = alias; + upstream.created_at = existing.created_at; + upstream.updated_at = crate::domain::model::now_rfc3339(); + self.upstreams.update(upstream.clone()).await?; + Ok(upstream) + } + + /// Deletes an upstream and, by cascade, its routes. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when the upstream is not in this tenant. + pub async fn delete_upstream(&self, tenant_id: &str, id: Uuid) -> Result<(), DomainError> { + self.get_upstream(tenant_id, id).await?; + self.routes.delete_by_upstream(tenant_id, id).await?; + self.upstreams.delete(tenant_id, id).await?; + Ok(()) + } + + // ----------------------------------------------------------------- + // Routes + // ----------------------------------------------------------------- + + /// Creates a route. + /// + /// # Errors + /// Returns [`DomainError::Validation`] or [`DomainError::Conflict`]. + pub async fn create_route( + &self, + tenant_id: &str, + mut route: Route, + ) -> Result { + route.match_config.validate()?; + validate_route_match(&route)?; + validate_route_cors(&route)?; + self.validate_plugin_references_exist(tenant_id, &route.plugins.items) + .await?; + let upstream = self.get_upstream(tenant_id, route.upstream_id).await?; + if !upstream.is_http_protocol() { + return Err(DomainError::Validation( + "only HTTP upstreams can be bound to an HTTP route".to_owned(), + )); + } + route.tenant_id = tenant_id.to_owned(); + route.id = Uuid::new_v4(); + route.gts_id = crate::domain::gts_helpers::resource_id( + crate::domain::gts_helpers::TYPE_ROUTE, + &route.id, + ); + route.created_at = crate::domain::model::now_rfc3339(); + route.updated_at = route.created_at.clone(); + self.validate_match_uniqueness(tenant_id, &route, None) + .await?; + self.routes.insert(route.clone()).await?; + Ok(route) + } + + /// Reads a route by id. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when the route is not in this tenant. + pub async fn get_route(&self, tenant_id: &str, id: Uuid) -> Result { + self.routes + .get(tenant_id, id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("route {id} not found"))) + } + + /// Lists routes owned by the tenant. + /// + /// # Errors + /// Propagates repository failures. + pub async fn list_routes(&self, tenant_id: &str) -> Result, DomainError> { + self.routes.list(tenant_id).await + } + + /// Replaces a route; `upstream_id` is immutable. + /// + /// # Errors + /// Returns [`DomainError::Validation`], [`DomainError::NotFound`] or + /// [`DomainError::Conflict`]. + pub async fn replace_route( + &self, + tenant_id: &str, + id: Uuid, + mut route: Route, + ) -> Result { + let existing = self.get_route(tenant_id, id).await?; + route.match_config.validate()?; + validate_route_match(&route)?; + validate_route_cors(&route)?; + self.validate_plugin_references_exist(tenant_id, &route.plugins.items) + .await?; + if route.upstream_id != existing.upstream_id { + return Err(DomainError::Validation( + "upstream_id is immutable on routes".to_owned(), + )); + } + route.id = existing.id; + route.tenant_id = existing.tenant_id; + route.gts_id = existing.gts_id; + route.created_at = existing.created_at; + route.updated_at = crate::domain::model::now_rfc3339(); + self.validate_match_uniqueness(tenant_id, &route, Some(existing.id)) + .await?; + self.routes.update(route.clone()).await?; + Ok(route) + } + + /// Deletes a route. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when the route is not in this tenant. + pub async fn delete_route(&self, tenant_id: &str, id: Uuid) -> Result<(), DomainError> { + self.get_route(tenant_id, id).await?; + self.routes.delete(tenant_id, id).await?; + Ok(()) + } + + // ----------------------------------------------------------------- + // Plugins + // ----------------------------------------------------------------- + + /// Creates a custom plugin definition (immutable after creation). + /// + /// # Errors + /// Returns [`DomainError::Validation`] for an unknown plugin type. + pub async fn create_plugin( + &self, + tenant_id: &str, + mut plugin: Plugin, + ) -> Result { + if plugin.name.trim().is_empty() { + return Err(DomainError::Validation( + "plugin name must not be empty".to_owned(), + )); + } + if !Plugin::known_type(&plugin.plugin_type) { + return Err(DomainError::Validation(format!( + "unknown plugin type `{}` (expected auth, guard or transform)", + plugin.plugin_type + ))); + } + plugin.tenant_id = tenant_id.to_owned(); + plugin.id = Uuid::new_v4(); + plugin.gts_id = format!( + "gts.cf.core.oagw.{}_plugin.v1~{}", + plugin.plugin_type, plugin.id + ); + plugin.created_at = crate::domain::model::now_rfc3339(); + self.plugins.insert(plugin.clone()).await?; + Ok(plugin) + } + + /// Reads a plugin definition. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] when the plugin is not in this tenant. + pub async fn get_plugin(&self, tenant_id: &str, id: Uuid) -> Result { + self.plugins + .get(tenant_id, id) + .await? + .ok_or_else(|| DomainError::NotFound(format!("plugin {id} not found"))) + } + + /// Lists plugins owned by the tenant. + /// + /// # Errors + /// Propagates repository failures. + pub async fn list_plugins(&self, tenant_id: &str) -> Result, DomainError> { + self.plugins.list(tenant_id).await + } + + /// Deletes a plugin, refusing while any upstream or route still references it. + /// + /// # Errors + /// Returns [`DomainError::PluginInUse`] or [`DomainError::NotFound`]. + pub async fn delete_plugin(&self, tenant_id: &str, id: Uuid) -> Result<(), DomainError> { + self.get_plugin(tenant_id, id).await?; + // Both lists are collected so the `409` can name the referencing + // resources, not just count them (ADR 0001 `referenced_by`). + let upstreams: Vec = self + .list_upstreams(tenant_id) + .await? + .iter() + .filter(|u| Self::plugin_bound_to_upstream(u, id)) + .map(|u| u.gts_id.clone()) + .collect(); + let routes: Vec = self + .list_routes(tenant_id) + .await? + .iter() + .filter(|r| Self::plugin_bound_to_route(r, id)) + .map(|r| r.gts_id.clone()) + .collect(); + if !upstreams.is_empty() || !routes.is_empty() { + return Err(DomainError::PluginInUse { + upstreams: upstreams.len(), + routes: routes.len(), + upstream_ids: upstreams, + route_ids: routes, + }); + } + self.plugins.delete(tenant_id, id).await?; + Ok(()) + } + + fn plugin_bound_to_upstream(upstream: &Upstream, id: Uuid) -> bool { + upstream + .plugins + .items + .iter() + .any(|b| crate::domain::gts_helpers::uuid_from_resource_id(b.plugin_ref()) == Some(id)) + || upstream + .auth + .as_ref() + .and_then(|a| a.plugin_type.as_deref()) + .and_then(crate::domain::gts_helpers::uuid_from_resource_id) + .is_some_and(|u| u == id) + } + + fn plugin_bound_to_route(route: &Route, id: Uuid) -> bool { + route + .plugins + .items + .iter() + .any(|b| crate::domain::gts_helpers::uuid_from_resource_id(b.plugin_ref()) == Some(id)) + } + + // ----------------------------------------------------------------- + // Proxy resolution + // ----------------------------------------------------------------- + + /// Checks that every custom plugin reference in a binding list names a + /// plugin stored in the calling tenant. + /// + /// Built-ins are identified by their GTS reference and need no lookup; a + /// reference whose instance part is a UUID addresses a stored custom plugin, + /// and one that does not exist would bind silently and never run. + /// + /// # Errors + /// Returns [`DomainError::Validation`] for an unresolvable reference. + async fn validate_plugin_references_exist( + &self, + tenant_id: &str, + bindings: &[PluginBinding], + ) -> Result<(), DomainError> { + for binding in bindings { + validate_plugin_binding(binding)?; + let Some(uuid) = + crate::domain::gts_helpers::uuid_from_resource_id(binding.plugin_ref()) + else { + continue; + }; + if self.plugins.get(tenant_id, uuid).await?.is_none() { + return Err(DomainError::Validation(format!( + "plugin `{}` does not exist in this tenant", + binding.plugin_ref() + ))); + } + } + Ok(()) + } + + /// Resolves an alias + method + path to an effective configuration. + /// + /// One walk (ADR 0006): the tenant chain is walked descendant-first for an + /// upstream owning the alias (closest match wins), routes for that upstream + /// are then searched the same way, and the effective config is the merge of + /// upstream and route with ancestor-enforced limits folded in. + /// + /// # Errors + /// Returns [`DomainError::NotFound`] for an unknown alias and + /// [`DomainError::RouteNotFound`] when no route matches. + pub async fn resolve_proxy_target( + &self, + tenant_id: &str, + alias: &str, + method: ProxyMethod, + path: &str, + ) -> Result { + let normalized = alias::normalize_alias(alias); + if normalized.is_empty() { + return Err(DomainError::RouteNotFound( + "proxy path must name an upstream alias".to_owned(), + )); + } + + let chain = self.hierarchy.chain(tenant_id).await; + let mut selected: Option = None; + for tenant in &chain { + if let Some(found) = self.upstreams.get_by_alias(tenant, &normalized).await? { + selected = Some(found); + break; + } + } + let upstream = selected.ok_or_else(|| { + DomainError::NotFound(format!( + "no upstream is registered for alias `{normalized}`" + )) + })?; + + if !upstream.enabled { + return Err(DomainError::LinkUnavailable( + "upstream is disabled".to_owned(), + )); + } + + // Route search across the chain, descendant-first, for the selected upstream. + let mut matched: Option<(Route, String)> = None; + for tenant in &chain { + let routes = self.routes.list_by_upstream(tenant, upstream.id).await?; + if let Some(found) = best_matching_route(&routes, method, path) { + matched = Some(found); + break; + } + } + let (route, path_remainder) = matched.ok_or_else(|| { + DomainError::RouteNotFound(format!("no route matches {} {path}", method.as_str())) + })?; + + // The selected upstream's own limit always governs traffic it serves; + // ancestor limits bind across shadowing only when they are `enforce`. + let mut enforced_rates: Vec = Vec::new(); + if let Some(rl) = upstream.rate_limit.as_ref() { + enforced_rates.push(rl.clone()); + } + for tenant in &chain { + if let Some(ancestor) = self.upstreams.get_by_alias(tenant, &normalized).await? { + if ancestor.id == upstream.id { + continue; + } + if let Some(rl) = ancestor + .rate_limit + .as_ref() + .filter(|r| r.sharing.is_enforce()) + { + enforced_rates.push(rl.clone()); + } + } + } + if let Some(rl) = route.rate_limit.as_ref() { + enforced_rates.push(rl.clone()); + } + let rate_limit = strictest(&enforced_rates); + + let mut plugins = upstream.plugins.items.clone(); + plugins.extend(route.plugins.items.iter().cloned()); + + let owning_tenant = upstream.tenant_id.clone(); + // A route-level CORS policy overrides the upstream's; otherwise the + // upstream's governs (DESIGN §"Config Layering": Upstream < Route). + let cors = route.cors.clone().or_else(|| upstream.cors.clone()); + Ok(ResolvedTarget { + upstream, + route, + path_remainder, + plugins, + rate_limit, + cors, + owning_tenant, + }) + } + + // ----------------------------------------------------------------- + // Validation helpers + // ----------------------------------------------------------------- + + async fn validate_match_uniqueness( + &self, + tenant_id: &str, + route: &Route, + excluding: Option, + ) -> Result<(), DomainError> { + let Some(http) = route.match_config.http.as_ref() else { + return Ok(()); + }; + let siblings = self + .routes + .list_by_upstream(tenant_id, route.upstream_id) + .await?; + for other in siblings { + if Some(other.id) == excluding || !other.enabled { + continue; + } + let Some(o) = other.match_config.http.as_ref() else { + continue; + }; + // Two enabled routes may share a path only when their priority + // separates them (DESIGN §"Data Constraints"). + if o.path == http.path + && other.priority == route.priority + && o.methods.iter().any(|m| http.methods.contains(m)) + { + return Err(DomainError::Conflict(format!( + "route with the same path `{}`, priority {} and method is already registered \ + for this upstream", + http.path, route.priority + ))); + } + } + Ok(()) + } + + fn validate_upstream(upstream: &Upstream) -> Result<(), DomainError> { + if upstream.server.endpoints.is_empty() { + return Err(DomainError::Validation( + "upstream requires at least one server endpoint".to_owned(), + )); + } + for endpoint in &upstream.server.endpoints { + alias::validate_host(&endpoint.host)?; + } + let scheme = upstream.server.endpoints[0].scheme; + let port = upstream.server.endpoints[0].effective_port(); + if upstream + .server + .endpoints + .iter() + .any(|e| e.scheme != scheme || e.effective_port() != port) + { + return Err(DomainError::Validation( + "all endpoints of an upstream must share the same scheme and port".to_owned(), + )); + } + if upstream.protocol != crate::domain::gts_helpers::PROTOCOL_HTTP + && upstream.protocol != crate::domain::gts_helpers::PROTOCOL_GRPC + { + return Err(DomainError::Validation(format!( + "unknown upstream protocol `{}`", + upstream.protocol + ))); + } + if let Some(auth) = upstream.auth.as_ref() + && let Some(t) = auth.plugin_type.as_deref() + { + validate_auth_plugin_ref(t)?; + } + for binding in &upstream.plugins.items { + validate_plugin_binding(binding)?; + } + for tag in &upstream.tags { + crate::domain::model::validate_tag(tag)?; + } + if let Some(cors) = upstream.cors.as_ref() { + validate_cors(cors)?; + } + Ok(()) + } +} + +/// A proxy method, decoupled from the management-facing `HttpMethod` enum. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ProxyMethod { + /// `GET`. + Get, + /// `POST`. + Post, + /// `PUT`. + Put, + /// `DELETE`. + Delete, + /// `PATCH`. + Patch, + /// Any other method (`HEAD`, `OPTIONS`, ...). + Other, +} + +impl ProxyMethod { + /// Parses an HTTP method token. + #[must_use] + pub fn parse(value: &str) -> Self { + match value.to_ascii_uppercase().as_str() { + "GET" => Self::Get, + "POST" => Self::Post, + "PUT" => Self::Put, + "DELETE" => Self::Delete, + "PATCH" => Self::Patch, + _ => Self::Other, + } + } + + /// The method as an HTTP token. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Get => "GET", + Self::Post => "POST", + Self::Put => "PUT", + Self::Delete => "DELETE", + Self::Patch => "PATCH", + Self::Other => "OTHER", + } + } +} + +impl PartialEq for ProxyMethod { + fn eq(&self, other: &crate::domain::model::HttpMethod) -> bool { + matches!( + (self, other), + (Self::Get, crate::domain::model::HttpMethod::Get) + | (Self::Post, crate::domain::model::HttpMethod::Post) + | (Self::Put, crate::domain::model::HttpMethod::Put) + | (Self::Delete, crate::domain::model::HttpMethod::Delete) + | (Self::Patch, crate::domain::model::HttpMethod::Patch) + ) + } +} + +impl crate::domain::model::SharingMode { + /// `true` for [`SharingMode::Enforce`](crate::domain::model::SharingMode::Enforce). + #[must_use] + pub fn is_enforce(self) -> bool { + matches!(self, crate::domain::model::SharingMode::Enforce) + } +} + +/// Selects the route serving `path`, plus the part of `path` its prefix does not +/// cover (the "path suffix" of DESIGN §3.2). +/// +/// Longest prefix wins; disabled routes and gRPC-only matches are skipped. A +/// route whose method allowlist admits the request is preferred over one that +/// does not, so sibling routes can split a path by verb. When nothing admits the +/// method the best path match is still returned: the method rule is a guard rule +/// (DESIGN §"Guard Rules") and is reported as a validation error by the data +/// plane, not as a missing route. +#[must_use] +pub fn best_matching_route( + routes: &[Route], + method: ProxyMethod, + path: &str, +) -> Option<(Route, String)> { + // `serving` holds the deepest prefix that admits the method, `path_only` + // the deepest prefix that matches at all. Equal depths are broken by the + // lower `priority` value (DESIGN §"Find Matching Route for Request"). + let mut serving: Option<(usize, i64, Route, String)> = None; + let mut path_only: Option<(usize, i64, Route, String)> = None; + for route in routes { + if !route.enabled { + continue; + } + let Some(http) = route.match_config.http.as_ref() else { + continue; + }; + let Some(remainder) = strip_prefix_path(path, &http.path) else { + continue; + }; + let depth = http.path.trim_end_matches('/').matches('/').count(); + let takes = |candidate: &Option<(usize, i64, Route, String)>| { + candidate.as_ref().is_none_or(|(d, priority, _, _)| { + depth > *d || (depth == *d && route.priority < *priority) + }) + }; + if http.methods.iter().any(|m| method == *m) && takes(&serving) { + serving = Some((depth, route.priority, route.clone(), remainder.clone())); + } + if takes(&path_only) { + path_only = Some((depth, route.priority, route.clone(), remainder)); + } + } + serving + .or(path_only) + .map(|(_, _, route, remainder)| (route, remainder)) +} + +/// Splits `path` into the part `prefix` matches and the remainder after it. +/// +/// The root prefix matches everything; otherwise the prefix must end on a +/// segment boundary. +#[must_use] +fn strip_prefix_path(path: &str, prefix: &str) -> Option { + let trimmed = prefix.trim_end_matches('/'); + if trimmed.is_empty() { + return Some(path.to_owned()); + } + let candidate = path.trim_end_matches('/'); + if candidate == trimmed { + return Some(String::new()); + } + candidate + .strip_prefix(trimmed) + .filter(|rest| rest.starts_with('/')) + .map(str::to_owned) +} + +/// The strictest (minimum-throughput) rate limit of a set. +#[must_use] +pub fn strictest(limits: &[RateLimitConfig]) -> Option { + limits + .iter() + .min_by(|a, b| { + a.capacity() + .cmp(&b.capacity()) + .then_with(|| b.sustained.rate.cmp(&a.sustained.rate)) + }) + .cloned() +} + +fn validate_route_match(route: &Route) -> Result<(), DomainError> { + if let Some(http) = route.match_config.http.as_ref() { + if http.methods.is_empty() { + return Err(DomainError::Validation( + "route must declare at least one HTTP method".to_owned(), + )); + } + if http.path.is_empty() || !http.path.starts_with('/') { + return Err(DomainError::Validation(format!( + "route path `{}` must start with `/`", + http.path + ))); + } + return Ok(()); + } + // A gRPC match is storable per the route schema; no HTTP code path matches it. + Ok(()) +} + +/// Validates a route's own CORS policy, when it declares one. +/// +/// # Errors +/// Returns [`DomainError::Validation`] for a malformed CORS block. +fn validate_route_cors(route: &Route) -> Result<(), DomainError> { + route.cors.as_ref().map_or(Ok(()), |cors| { + validate_cors(cors).map_err(|mut e| { + // Name the layer that failed: the same block is legal on an + // upstream, so the message has to say which one was rejected. + if let DomainError::Validation(detail) = &mut e { + *detail = format!("route cors: {detail}"); + } + e + }) + }) +} + +/// Rejects auth plugin types that have no backing implementation. +/// +/// `basic` and `bearer` are catalog identifiers only. +/// +/// # Errors +/// Returns [`DomainError::Validation`] for an unknown identifier. +pub fn validate_auth_plugin_ref(plugin_ref: &str) -> Result<(), DomainError> { + use crate::domain::gts_helpers as g; + match plugin_ref { + g::AUTH_NOOP | g::AUTH_APIKEY | g::AUTH_OAUTH2_CC | g::AUTH_OAUTH2_CC_BASIC => Ok(()), + g::AUTH_BASIC | g::AUTH_BEARER => Err(DomainError::Validation(format!( + "unknown auth plugin `{plugin_ref}`: it is a catalog identifier with no backing \ + implementation" + ))), + _ => Err(DomainError::Validation(format!( + "unknown auth plugin `{plugin_ref}`" + ))), + } +} + +/// Validates a `plugins.items[]` binding, resolving it against the built-in +/// registries and the catalog-only identifiers. +/// +/// # Errors +/// Returns [`DomainError::Validation`] for an unresolvable reference. +pub fn validate_plugin_binding(binding: &PluginBinding) -> Result<(), DomainError> { + use crate::domain::gts_helpers as g; + + let reference = binding.plugin_ref(); + // A UUID instance part addresses a stored custom plugin; those are validated + // by the management service when the upstream is stored. + if crate::domain::gts_helpers::uuid_from_resource_id(reference).is_some() { + return Ok(()); + } + #[allow(clippy::match_same_arms)] + match reference { + g::GUARD_REQUIRED_HEADERS | g::TRANSFORM_REQUEST_ID => Ok(()), + g::GUARD_TIMEOUT | g::GUARD_CORS | g::TRANSFORM_LOGGING | g::TRANSFORM_METRICS => { + Err(DomainError::Validation(format!( + "plugin `{reference}` is a catalog identifier and cannot be bound through \ + plugins.items" + ))) + } + other => Err(DomainError::Validation(format!("unknown plugin `{other}`"))), + } +} + +/// Validates a CORS block, including the wildcard + credentials rejection. +/// +/// # Errors +/// Returns [`DomainError::Validation`] for a bad origin or method, or for a +/// wildcard origin combined with credentials. +pub fn validate_cors(cors: &CorsConfig) -> Result<(), DomainError> { + if !cors.enabled { + return Ok(()); + } + if cors.allow_credentials && cors.allowed_origins.iter().any(|o| o == "*") { + return Err(DomainError::Validation( + "cors.allow_credentials cannot be combined with a wildcard origin".to_owned(), + )); + } + for origin in &cors.allowed_origins { + crate::domain::model::validate_origin(origin)?; + } + for method in &cors.allowed_methods { + if !matches!( + method.to_ascii_uppercase().as_str(), + "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" + ) { + return Err(DomainError::Validation(format!( + "invalid cors method `{method}`" + ))); + } + } + Ok(()) +} + +/// Validates that an endpoint's scheme may be connected to at proxy time. +/// +/// # Errors +/// Returns [`DomainError::LinkUnavailable`] when a plaintext `http` endpoint is +/// not admitted by the configuration. +pub fn check_scheme_admission( + scheme: EndpointScheme, + allow_http_upstream: bool, +) -> Result<(), DomainError> { + if scheme == EndpointScheme::Http && !allow_http_upstream { + return Err(DomainError::LinkUnavailable( + "plaintext http upstreams are disabled by configuration".to_owned(), + )); + } + Ok(()) +} diff --git a/gears/system/oagw/oagw/src/domain/services/management_tests.rs b/gears/system/oagw/oagw/src/domain/services/management_tests.rs new file mode 100644 index 0000000..596ccd1 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/services/management_tests.rs @@ -0,0 +1,819 @@ +//! `ControlPlaneService` tests: CRUD, alias transitions, tenant scoping and the +//! single resolution walk the data plane depends on. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::sync::Arc; + +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::{ + Endpoint, EndpointScheme, HttpMethod, MatchConfig, PluginBinding, RateLimitConfig, + SustainedRate, Upstream, +}; +use crate::domain::repo::TenantHierarchy; +use crate::domain::services::management::{ + ControlPlaneService, ProxyMethod, best_matching_route, check_scheme_admission, validate_cors, + validate_plugin_binding, +}; +use crate::infra::storage::memory::{ + MemoryPluginRepository, MemoryRouteRepository, MemoryUpstreamRepository, +}; + +const TENANT: &str = "00000000-0000-0000-0000-000000000001"; +const OTHER: &str = "00000000-0000-0000-0000-000000000002"; + +fn service() -> ControlPlaneService { + ControlPlaneService::new( + Arc::new(MemoryUpstreamRepository::default()), + Arc::new(MemoryRouteRepository::default()), + Arc::new(MemoryPluginRepository::default()), + Arc::new(FlatHierarchy), + true, + ) +} + +/// A hierarchy that answers with the tenant itself, so resolution has no +/// ancestors to walk. +struct FlatHierarchy; + +#[async_trait::async_trait] +impl TenantHierarchy for FlatHierarchy { + async fn chain(&self, tenant_id: &str) -> Vec { + vec![tenant_id.to_owned()] + } +} + +fn host_upstream(host: &str, port: u16) -> Upstream { + Upstream { + enabled: true, + server: crate::domain::model::ServerConfig { + endpoints: vec![Endpoint { + scheme: EndpointScheme::Http, + host: host.to_owned(), + port: Some(port), + }], + }, + protocol: crate::domain::gts_helpers::PROTOCOL_HTTP.to_owned(), + ..Default::default() + } +} + +fn http_match(path: &str, methods: &[HttpMethod]) -> MatchConfig { + MatchConfig { + http: Some(crate::domain::model::HttpMatch { + methods: methods.to_vec(), + path: path.to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: crate::domain::model::PathSuffixMode::Append, + }), + grpc: None, + } +} + +async fn upstream_with_route( + path: &str, + methods: &[HttpMethod], +) -> (ControlPlaneService, Upstream) { + let svc = service(); + let upstream = svc + .create_upstream(TENANT, host_upstream("api.example.com", 80), None) + .await + .unwrap(); + svc.create_route(TENANT, route_for(upstream.id, path, methods)) + .await + .unwrap(); + (svc, upstream) +} + +/// An enabled HTTP route for `upstream`. +fn route_for(upstream_id: Uuid, path: &str, methods: &[HttpMethod]) -> crate::domain::model::Route { + crate::domain::model::Route { + upstream_id, + enabled: true, + match_config: http_match(path, methods), + ..Default::default() + } +} + +// --------------------------------------------------------------------------- +// Upstreams +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn create_upstream_derives_alias_from_hostname() { + let svc = service(); + let upstream = svc + .create_upstream(TENANT, host_upstream("api.openai.com", 80), None) + .await + .unwrap(); + assert_eq!(upstream.alias, "api.openai.com"); + assert!(upstream.enabled); + assert_eq!(upstream.tenant_id, TENANT); + assert!(upstream.gts_id.starts_with("gts.cf.core.oagw.upstream.v1~")); + assert!(!upstream.created_at.is_empty()); + assert_eq!(upstream.created_at, upstream.updated_at); +} + +#[tokio::test] +async fn derived_alias_keeps_a_non_standard_port() { + let svc = service(); + let upstream = svc + .create_upstream(TENANT, host_upstream("api.openai.com", 8443), None) + .await + .unwrap(); + assert_eq!(upstream.alias, "api.openai.com:8443"); +} + +#[tokio::test] +async fn provided_alias_must_match_the_derivation() { + let svc = service(); + let err = svc + .create_upstream( + TENANT, + host_upstream("api.openai.com", 80), + Some("other.example.com".to_owned()), + ) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::Validation(_)), "{err:?}"); +} + +#[tokio::test] +async fn ip_endpoints_require_an_explicit_alias() { + let svc = service(); + let err = svc + .create_upstream(TENANT, host_upstream("127.0.0.1", 9000), None) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::Validation(_)), "{err:?}"); + + let created = svc + .create_upstream( + TENANT, + host_upstream("127.0.0.1", 9000), + Some("Local-Service".to_owned()), + ) + .await + .unwrap(); + assert_eq!(created.alias, "local-service"); +} + +#[tokio::test] +async fn pool_with_common_suffix_derives_the_suffix() { + let svc = service(); + let mut upstream = host_upstream("api.openai.com", 80); + upstream.server.endpoints.push(Endpoint { + scheme: EndpointScheme::Http, + host: "backup.openai.com".to_owned(), + port: Some(80), + }); + let created = svc.create_upstream(TENANT, upstream, None).await.unwrap(); + assert_eq!(created.alias, "openai.com"); +} + +#[tokio::test] +async fn duplicate_alias_conflicts() { + let svc = service(); + svc.create_upstream(TENANT, host_upstream("api.openai.com", 80), None) + .await + .unwrap(); + let err = svc + .create_upstream(TENANT, host_upstream("api.openai.com", 80), None) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::Conflict(_)), "{err:?}"); +} + +#[tokio::test] +async fn upstreams_are_tenant_scoped() { + let svc = service(); + let created = svc + .create_upstream(TENANT, host_upstream("api.openai.com", 80), None) + .await + .unwrap(); + assert!(svc.get_upstream(OTHER, created.id).await.is_err()); + assert!(svc.list_upstreams(OTHER).await.unwrap().is_empty()); + // The same alias may exist in another tenant. + svc.create_upstream(OTHER, host_upstream("api.openai.com", 80), None) + .await + .unwrap(); + assert_eq!(svc.list_upstreams(OTHER).await.unwrap().len(), 1); +} + +#[tokio::test] +async fn replace_keeps_the_derived_alias_stable() { + let svc = service(); + let created = svc + .create_upstream(TENANT, host_upstream("api.openai.com", 80), None) + .await + .unwrap(); + + // Same endpoints, different port: the alias would change, so it is refused. + let mut replacement = host_upstream("api.openai.com", 9443); + replacement.id = created.id; + let err = svc + .replace_upstream(TENANT, created.id, replacement, None) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::Validation(_)), "{err:?}"); + + // Idempotent replacement keeps the alias and refreshes `updated_at`. + let mut same = host_upstream("api.openai.com", 80); + same.id = created.id; + same.tags = vec!["stable".to_owned()]; + let replaced = svc + .replace_upstream(TENANT, created.id, same, None) + .await + .unwrap(); + assert_eq!(replaced.alias, "api.openai.com"); + assert_eq!(replaced.tags, vec!["stable".to_owned()]); +} + +#[tokio::test] +async fn replace_cannot_rename_an_explicit_alias() { + let svc = service(); + let created = svc + .create_upstream( + TENANT, + host_upstream("127.0.0.1", 9000), + Some("local".to_owned()), + ) + .await + .unwrap(); + let mut replacement = host_upstream("127.0.0.1", 9000); + replacement.id = created.id; + let err = svc + .replace_upstream(TENANT, created.id, replacement, Some("renamed".to_owned())) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::Validation(_)), "{err:?}"); +} + +#[tokio::test] +async fn delete_upstream_cascades_its_routes() { + let (svc, upstream) = upstream_with_route("/v1", &[HttpMethod::Get]).await; + assert_eq!(svc.list_routes(TENANT).await.unwrap().len(), 1); + svc.delete_upstream(TENANT, upstream.id).await.unwrap(); + assert!(svc.list_upstreams(TENANT).await.unwrap().is_empty()); + assert!(svc.list_routes(TENANT).await.unwrap().is_empty()); + assert!(matches!( + svc.delete_upstream(TENANT, upstream.id).await.unwrap_err(), + DomainError::NotFound(_) + )); +} + +#[tokio::test] +async fn unknown_upstream_is_not_found() { + let svc = service(); + assert!(matches!( + svc.get_upstream(TENANT, Uuid::new_v4()).await.unwrap_err(), + DomainError::NotFound(_) + )); +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn route_requires_a_known_upstream() { + let svc = service(); + let route = crate::domain::model::Route { + upstream_id: Uuid::new_v4(), + match_config: http_match("/", &[HttpMethod::Get]), + ..Default::default() + }; + assert!(matches!( + svc.create_route(TENANT, route).await.unwrap_err(), + DomainError::NotFound(_) + )); +} + +#[tokio::test] +async fn route_match_must_name_exactly_one_protocol() { + let (svc, upstream) = upstream_with_route("/v1", &[HttpMethod::Get]).await; + + let mut both = route_for(upstream.id, "/both", &[HttpMethod::Get]); + both.match_config.grpc = Some(crate::domain::model::GrpcMatch { + service: "svc.S".to_owned(), + method: "M".to_owned(), + }); + assert!(matches!( + svc.create_route(TENANT, both).await.unwrap_err(), + DomainError::Validation(_) + )); + + let mut none = route_for(upstream.id, "/none", &[HttpMethod::Get]); + none.match_config.http = None; + assert!(matches!( + svc.create_route(TENANT, none).await.unwrap_err(), + DomainError::Validation(_) + )); + + // A gRPC match is storable, but no HTTP request ever matches it. + let mut grpc = route_for(upstream.id, "/grpc", &[HttpMethod::Get]); + grpc.match_config.http = None; + grpc.match_config.grpc = Some(crate::domain::model::GrpcMatch { + service: "svc.S".to_owned(), + method: "M".to_owned(), + }); + let stored = svc.create_route(TENANT, grpc).await.unwrap(); + assert!(stored.match_config.grpc.is_some()); +} + +#[tokio::test] +async fn duplicate_route_match_conflicts() { + let (svc, upstream) = upstream_with_route("/v1", &[HttpMethod::Get]).await; + let route = route_for(upstream.id, "/v1", &[HttpMethod::Get]); + assert!(matches!( + svc.create_route(TENANT, route).await.unwrap_err(), + DomainError::Conflict(_) + )); + // A different method on the same path is a different route. + let other = route_for(upstream.id, "/v1", &[HttpMethod::Post]); + assert!(svc.create_route(TENANT, other).await.is_ok()); + // A new disabled route still conflicts with the live one. + let mut disabled = route_for(upstream.id, "/v1", &[HttpMethod::Get]); + disabled.enabled = false; + assert!(matches!( + svc.create_route(TENANT, disabled).await.unwrap_err(), + DomainError::Conflict(_) + )); +} + +#[tokio::test] +async fn route_upstream_id_is_immutable() { + let (svc, upstream) = upstream_with_route("/v1", &[HttpMethod::Get]).await; + let other = svc + .create_upstream(TENANT, host_upstream("backup.openai.com", 80), None) + .await + .unwrap(); + let existing = svc.list_routes(TENANT).await.unwrap().remove(0); + + // Repointing the route at another upstream is refused outright. + let repointed = route_for(other.id, "/v2", &[HttpMethod::Get]); + assert!(matches!( + svc.replace_route(TENANT, existing.id, repointed) + .await + .unwrap_err(), + DomainError::Validation(_) + )); + + // A replacement carrying the original `upstream_id` is accepted. + let replacement = route_for(upstream.id, "/v2", &[HttpMethod::Get]); + let replaced = svc + .replace_route(TENANT, existing.id, replacement) + .await + .unwrap(); + assert_eq!(replaced.upstream_id, upstream.id); + assert_eq!(replaced.match_config, http_match("/v2", &[HttpMethod::Get])); +} + +#[tokio::test] +async fn disabled_routes_are_stored_and_listed() { + let (svc, upstream) = upstream_with_route("/v1", &[HttpMethod::Get]).await; + let mut route = route_for(upstream.id, "/v2", &[HttpMethod::Get]); + route.enabled = false; + let created = svc.create_route(TENANT, route).await.unwrap(); + assert!(!created.enabled); + assert_eq!(svc.list_routes(TENANT).await.unwrap().len(), 2); + // Disabled routes are skipped by matching. + assert!( + best_matching_route( + &svc.list_routes(TENANT).await.unwrap(), + ProxyMethod::Get, + "/v2" + ) + .is_none() + ); +} + +// --------------------------------------------------------------------------- +// Route matching + resolution +// --------------------------------------------------------------------------- + +#[test] +fn longest_prefix_wins() { + let routes: Vec = ["/v1", "/v1/chat", "/v1/chat/completions"] + .iter() + .map(|path| crate::domain::model::Route { + enabled: true, + match_config: http_match(path, &[HttpMethod::Get]), + ..Default::default() + }) + .collect(); + + let (route, suffix) = + best_matching_route(&routes, ProxyMethod::Get, "/v1/chat/completions/now").unwrap(); + assert_eq!( + route.match_config.http.unwrap().path, + "/v1/chat/completions" + ); + assert_eq!(suffix, "/now"); + + let (route, suffix) = best_matching_route(&routes, ProxyMethod::Get, "/v1/chat").unwrap(); + assert_eq!(route.match_config.http.unwrap().path, "/v1/chat"); + assert!(suffix.is_empty()); + + // A prefix must end on a segment boundary. + assert!(best_matching_route(&routes, ProxyMethod::Get, "/v1chat").is_none()); +} + +#[test] +fn a_route_admitting_the_method_is_preferred_over_one_that_does_not() { + let routes = vec![ + crate::domain::model::Route { + enabled: true, + match_config: http_match("/v1", &[HttpMethod::Get]), + ..Default::default() + }, + crate::domain::model::Route { + enabled: true, + match_config: http_match("/v1", &[HttpMethod::Post]), + ..Default::default() + }, + ]; + // Sibling routes splitting one path by verb each keep their own traffic. + let (route, _) = best_matching_route(&routes, ProxyMethod::Post, "/v1").unwrap(); + assert_eq!( + route.match_config.http.unwrap().methods, + vec![HttpMethod::Post] + ); +} + +#[test] +fn a_method_no_route_admits_still_resolves_to_the_path_match() { + let routes = vec![crate::domain::model::Route { + enabled: true, + match_config: http_match("/v1", &[HttpMethod::Get, HttpMethod::Post]), + ..Default::default() + }]; + assert!(best_matching_route(&routes, ProxyMethod::Get, "/v1").is_some()); + // The path did resolve, so the data plane reports the method as a guard + // rejection (400) rather than the route as missing (404). + let (route, _) = best_matching_route(&routes, ProxyMethod::Delete, "/v1").unwrap(); + assert_eq!( + route.match_config.http.unwrap().methods, + vec![HttpMethod::Get, HttpMethod::Post] + ); +} + +#[test] +fn disabled_routes_are_skipped() { + let routes = vec![ + crate::domain::model::Route { + enabled: false, + match_config: http_match("/v1/chat", &[HttpMethod::Get]), + ..Default::default() + }, + crate::domain::model::Route { + enabled: true, + match_config: http_match("/v1", &[HttpMethod::Get]), + ..Default::default() + }, + ]; + let (route, _) = best_matching_route(&routes, ProxyMethod::Get, "/v1/chat").unwrap(); + assert_eq!(route.match_config.http.unwrap().path, "/v1"); +} + +#[tokio::test] +async fn resolution_merges_upstream_and_route_configuration() { + let svc = service(); + let mut upstream = host_upstream("api.openai.com", 80); + upstream.headers.request.passthrough = crate::domain::model::PassthroughMode::All; + upstream.rate_limit = Some(RateLimitConfig { + sustained: SustainedRate { + rate: 10, + window: crate::domain::model::RateWindow::Second, + }, + burst: Some(crate::domain::model::Burst { capacity: 20 }), + ..Default::default() + }); + upstream.plugins.items.push(PluginBinding::Reference( + crate::domain::gts_helpers::GUARD_REQUIRED_HEADERS.to_owned(), + )); + let upstream = svc.create_upstream(TENANT, upstream, None).await.unwrap(); + + let mut route = route_for(upstream.id, "/v1", &[HttpMethod::Get]); + route.rate_limit = Some(RateLimitConfig { + sustained: SustainedRate { + rate: 2, + window: crate::domain::model::RateWindow::Second, + }, + ..Default::default() + }); + route.plugins.items.push(PluginBinding::Reference( + crate::domain::gts_helpers::TRANSFORM_REQUEST_ID.to_owned(), + )); + let route = svc.create_route(TENANT, route).await.unwrap(); + + let target = svc + .resolve_proxy_target(TENANT, "api.openai.com", ProxyMethod::Get, "/v1/chat") + .await + .unwrap(); + assert_eq!(target.upstream.id, upstream.id); + assert_eq!(target.route.id, route.id); + assert_eq!(target.path_remainder, "/chat"); + assert_eq!(target.owning_tenant, TENANT); + // Upstream plugins first, then route plugins. + assert_eq!( + target + .plugins + .iter() + .map(PluginBinding::plugin_ref) + .collect::>(), + vec![ + crate::domain::gts_helpers::GUARD_REQUIRED_HEADERS, + crate::domain::gts_helpers::TRANSFORM_REQUEST_ID + ] + ); + // The strictest rate limit wins. + let limit = target.rate_limit.unwrap(); + assert_eq!(limit.sustained.rate, 2); + assert_eq!(limit.capacity(), 2); + assert!(target.cors.is_none()); +} + +#[tokio::test] +async fn resolution_rejects_unknown_alias_and_unmatched_paths() { + let (svc, _upstream) = upstream_with_route("/v1", &[HttpMethod::Get]).await; + assert!(matches!( + svc.resolve_proxy_target(TENANT, "unknown.example.com", ProxyMethod::Get, "/") + .await + .unwrap_err(), + DomainError::NotFound(_) + )); + assert!(matches!( + svc.resolve_proxy_target(TENANT, "api.example.com", ProxyMethod::Get, "/other") + .await + .unwrap_err(), + DomainError::RouteNotFound(_) + )); + // The path still resolves when no route admits the method; the data plane + // turns that into a guard rejection (400), so resolution only reports the + // route as missing when no prefix matches. + let target = svc + .resolve_proxy_target(TENANT, "api.example.com", ProxyMethod::Delete, "/v1") + .await + .unwrap(); + assert!(target.path_remainder.is_empty()); +} + +#[tokio::test] +async fn disabled_upstream_is_unavailable() { + let svc = service(); + let mut upstream = host_upstream("api.openai.com", 80); + upstream.enabled = false; + let upstream = svc.create_upstream(TENANT, upstream, None).await.unwrap(); + svc.create_route(TENANT, route_for(upstream.id, "/", &[HttpMethod::Get])) + .await + .unwrap(); + assert!(matches!( + svc.resolve_proxy_target(TENANT, "api.openai.com", ProxyMethod::Get, "/") + .await + .unwrap_err(), + DomainError::LinkUnavailable(_) + )); +} + +#[tokio::test] +async fn path_suffix_disabled_rejects_a_suffix() { + let svc = service(); + let upstream = svc + .create_upstream(TENANT, host_upstream("api.example.com", 80), None) + .await + .unwrap(); + let mut route = route_for(upstream.id, "/v1", &[HttpMethod::Get]); + route.match_config.http.as_mut().unwrap().path_suffix_mode = + crate::domain::model::PathSuffixMode::Disabled; + svc.create_route(TENANT, route).await.unwrap(); + + if let Err(e) = svc + .resolve_proxy_target(TENANT, "api.example.com", ProxyMethod::Get, "/v1") + .await + { + panic!("exact-path request should resolve: {e:?}"); + } + // The control plane still reports the match; enforcing `path_suffix_mode` is + // the data plane's job, which is where the suffix is turned into an error. + let target = svc + .resolve_proxy_target(TENANT, "api.example.com", ProxyMethod::Get, "/v1/extra") + .await + .unwrap(); + assert_eq!(target.path_remainder, "/extra"); +} + +// --------------------------------------------------------------------------- +// Plugins +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn plugin_lifecycle_and_in_use_protection() { + let (svc, upstream) = upstream_with_route("/v1", &[HttpMethod::Get]).await; + + let plugin = svc + .create_plugin( + TENANT, + crate::domain::model::Plugin { + name: "add-header".to_owned(), + plugin_type: "transform".to_owned(), + source_code: "def on_request(ctx): pass".to_owned(), + ..Default::default() + }, + ) + .await + .unwrap(); + assert_eq!(svc.list_plugins(TENANT).await.unwrap().len(), 1); + + // Unknown plugin types are refused. + let err = svc + .create_plugin( + TENANT, + crate::domain::model::Plugin { + name: "bad".to_owned(), + plugin_type: "logger".to_owned(), + ..Default::default() + }, + ) + .await + .unwrap_err(); + assert!(matches!(err, DomainError::Validation(_)), "{err:?}"); + + // Unreferenced plugins delete cleanly. + svc.delete_plugin(TENANT, plugin.id).await.unwrap(); + + // A plugin bound to a route is protected. + let bound = svc + .create_plugin( + TENANT, + crate::domain::model::Plugin { + name: "bound".to_owned(), + plugin_type: "transform".to_owned(), + ..Default::default() + }, + ) + .await + .unwrap(); + let routes = svc.list_routes(TENANT).await.unwrap(); + let route_id = routes[0].id; + let mut with_plugin = route_for(upstream.id, "/v1", &[HttpMethod::Get]); + with_plugin.plugins.items.push(PluginBinding::Bound { + plugin_ref: bound.id.to_string(), + plugin_uuid: Some(bound.id), + config: None, + }); + svc.replace_route(TENANT, route_id, with_plugin) + .await + .unwrap(); + + let DomainError::PluginInUse { + upstreams, + routes, + upstream_ids, + route_ids, + } = svc.delete_plugin(TENANT, bound.id).await.unwrap_err() + else { + panic!("expected PluginInUse"); + }; + assert_eq!(upstreams, 0); + assert_eq!(routes, 1); + assert!(upstream_ids.is_empty()); + assert_eq!(route_ids.len(), 1, "the referencing route is named"); +} + +#[test] +fn plugin_binding_validation() { + // Guard and transform identifiers bind through `plugins.items`. + assert!( + validate_plugin_binding(&PluginBinding::Reference( + crate::domain::gts_helpers::GUARD_REQUIRED_HEADERS.to_owned() + )) + .is_ok() + ); + assert!( + validate_plugin_binding(&PluginBinding::Bound { + plugin_ref: crate::domain::gts_helpers::TRANSFORM_REQUEST_ID.to_owned(), + plugin_uuid: None, + config: None, + }) + .is_ok() + ); + // Catalog-only identifiers and unknown ones are refused. + assert!( + validate_plugin_binding(&PluginBinding::Reference( + crate::domain::gts_helpers::TRANSFORM_METRICS.to_owned() + )) + .is_err() + ); + assert!(validate_plugin_binding(&PluginBinding::Reference(String::new())).is_err()); +} + +#[test] +fn auth_plugin_ref_validation() { + use crate::domain::gts_helpers as g; + use crate::domain::services::management::validate_auth_plugin_ref; + for id in [ + g::AUTH_NOOP, + g::AUTH_APIKEY, + g::AUTH_OAUTH2_CC, + g::AUTH_OAUTH2_CC_BASIC, + ] { + assert!(validate_auth_plugin_ref(id).is_ok(), "{id}"); + } + // Catalog-only identifiers must not be configured. + assert!(validate_auth_plugin_ref(g::AUTH_BASIC).is_err()); + assert!(validate_auth_plugin_ref("gts.cf.core.oagw.unknown.v1~x").is_err()); +} + +#[test] +fn cors_validation_rules() { + let mut cors = crate::domain::model::CorsConfig { + enabled: true, + allowed_origins: vec!["https://app.example.com".to_owned()], + ..Default::default() + }; + assert!(validate_cors(&cors).is_ok()); + + cors.allow_credentials = true; + cors.allowed_origins = vec!["*".to_owned()]; + assert!(validate_cors(&cors).is_err()); + + cors.allowed_origins = vec!["not an origin".to_owned()]; + cors.allow_credentials = false; + assert!(validate_cors(&cors).is_err()); +} + +#[test] +fn scheme_admission_follows_the_connection_policy() { + assert!(check_scheme_admission(EndpointScheme::Http, true).is_ok()); + assert!(check_scheme_admission(EndpointScheme::Http, false).is_err()); + assert!(check_scheme_admission(EndpointScheme::Https, false).is_ok()); +} + +#[test] +fn proxy_method_parsing() { + assert_eq!(ProxyMethod::parse("get"), ProxyMethod::Get); + assert_eq!(ProxyMethod::parse("PATCH").as_str(), "PATCH"); + assert_eq!(ProxyMethod::parse("TRACE"), ProxyMethod::Other); + assert_ne!(ProxyMethod::Get, ProxyMethod::Post); +} + +// --------------------------------------------------------------------------- +// Hierarchical resolution +// --------------------------------------------------------------------------- + +struct ParentFirst { + chain: Vec, +} + +#[async_trait::async_trait] +impl TenantHierarchy for ParentFirst { + async fn chain(&self, _tenant_id: &str) -> Vec { + self.chain.clone() + } +} + +#[tokio::test] +async fn ancestor_upstream_is_inherited_and_enforced_limits_apply() { + let parent = "00000000-0000-0000-0000-000000000010"; + let child = "00000000-0000-0000-0000-000000000011"; + let upstreams = Arc::new(MemoryUpstreamRepository::default()); + let svc = ControlPlaneService::new( + upstreams.clone(), + Arc::new(MemoryRouteRepository::default()), + Arc::new(MemoryPluginRepository::default()), + Arc::new(ParentFirst { + chain: vec![child.to_owned(), parent.to_owned()], + }), + true, + ); + + let mut parent_upstream = host_upstream("api.openai.com", 80); + parent_upstream.rate_limit = Some(RateLimitConfig { + sharing: crate::domain::model::SharingMode::Enforce, + sustained: SustainedRate { + rate: 3, + window: crate::domain::model::RateWindow::Second, + }, + ..Default::default() + }); + let created = svc + .create_upstream(parent, parent_upstream, None) + .await + .unwrap(); + + // A route owned by the parent's upstream is visible to the child tenant. + svc.create_route(parent, route_for(created.id, "/v1", &[HttpMethod::Get])) + .await + .unwrap(); + + let target = svc + .resolve_proxy_target(child, "api.openai.com", ProxyMethod::Get, "/v1/x") + .await + .unwrap(); + assert_eq!(target.owning_tenant, parent); + assert_eq!(target.rate_limit.unwrap().sustained.rate, 3); + assert_eq!(target.path_remainder, "/x"); +} 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..fc11698 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/services/mod.rs @@ -0,0 +1,6 @@ +//! Domain services. +pub mod management; + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod management_tests; diff --git a/gears/system/oagw/oagw/src/gear.rs b/gears/system/oagw/oagw/src/gear.rs new file mode 100644 index 0000000..153ebb9 --- /dev/null +++ b/gears/system/oagw/oagw/src/gear.rs @@ -0,0 +1,123 @@ +//! The `ToolKit` gear: wiring, initialization and REST registration. + +use std::sync::{Arc, OnceLock}; + +use async_trait::async_trait; +use toolkit::api::OpenApiRegistry; +use toolkit::{Gear, GearCtx, RestApiCapability}; + +use crate::api::rest::routes; +use crate::config::OagwConfig; +use crate::domain::services::management::ControlPlaneService; +use crate::infra::plugin::registry::{ + AuthPluginRegistry, GuardPluginRegistry, TransformPluginRegistry, +}; +use crate::infra::proxy::service::DataPlaneService; +use crate::infra::storage::memory::{ + MemoryPluginRepository, MemoryRouteRepository, MemoryUpstreamRepository, TenantHierarchyClient, +}; + +/// The OAGW gear: an outbound API gateway over `cred_store`-backed upstreams. +/// +/// The control plane owns the upstream/route/plugin tables; the data plane +/// executes proxy requests against them. Both live in +/// `gears.oagw.config`-driven instances created once at init. +#[toolkit::gear( + name = "oagw", + deps = [credstore, types_registry], + capabilities = [rest] +)] +#[derive(Default)] +pub struct OagwGear { + control_plane: OnceLock>, + data_plane: OnceLock>, +} + +impl OagwGear { + /// The control plane, once initialized. + #[must_use] + pub fn control_plane(&self) -> Option> { + self.control_plane.get().cloned() + } + + /// The data plane, once 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() + .map_err(|e| anyhow::anyhow!("oagw config is invalid: {e}"))?; + + let credstore: Arc = ctx + .client_hub() + .get::() + .map_err(|e| anyhow::anyhow!("oagw requires the credstore client: {e}"))?; + + let hierarchy = Arc::new(TenantHierarchyClient::new( + ctx.client_hub() + .get::() + .ok(), + )); + + let control_plane = Arc::new(ControlPlaneService::new( + Arc::new(MemoryUpstreamRepository::default()), + Arc::new(MemoryRouteRepository::default()), + Arc::new(MemoryPluginRepository::default()), + hierarchy, + config.allow_http_upstream, + )); + + let auth = AuthPluginRegistry::with_builtins(&credstore, &config); + let guards = GuardPluginRegistry::with_builtins(); + let transforms = TransformPluginRegistry::with_builtins(); + + let data_plane = Arc::new( + DataPlaneService::new(control_plane.clone(), config.clone()) + .map_err(|e| anyhow::anyhow!("oagw data plane could not start: {e}"))? + .with_registries(auth, guards, transforms), + ); + + self.control_plane + .set(control_plane) + .map_err(|_| anyhow::anyhow!("oagw gear already initialized"))?; + self.data_plane + .set(data_plane) + .map_err(|_| anyhow::anyhow!("oagw gear already initialized"))?; + + tracing::info!( + proxy_timeout_secs = config.proxy_timeout_secs, + allow_http_upstream = config.allow_http_upstream, + "OAGW gear initialized" + ); + Ok(()) + } +} + +impl RestApiCapability for OagwGear { + fn register_rest( + &self, + ctx: &GearCtx, + router: axum::Router, + openapi: &dyn OpenApiRegistry, + ) -> anyhow::Result { + let control_plane = self + .control_plane() + .ok_or_else(|| anyhow::anyhow!("oagw control plane is not initialized"))?; + let data_plane = self + .data_plane() + .ok_or_else(|| anyhow::anyhow!("oagw data plane is not initialized"))?; + let config: OagwConfig = ctx + .config_or_default() + .map_err(|e| anyhow::anyhow!("oagw config is invalid: {e}"))?; + tracing::info!("Registering OAGW REST routes"); + let router = routes::register_routes(router, openapi, control_plane, data_plane, config); + tracing::info!("OAGW REST routes registered"); + 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..5b178e4 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/mod.rs @@ -0,0 +1,4 @@ +//! Infrastructure: storage, built-in plugins and the data plane. +pub mod plugin; +pub mod proxy; +pub mod storage; 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..963bbb4 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/apikey_auth.rs @@ -0,0 +1,111 @@ +//! `apikey` auth plugin (PRD §5.2 "Authentication Injection"). +//! +//! Config keys: +//! +//! | key | required | description | +//! |---|---|---| +//! | `key_ref` | yes | `cred://` reference for the API key | +//! | `header` | no | header to inject into (default `x-api-key`) | +//! | `in` / `placement` | no | `header` (default) or `query` | +//! | `query` | no | query parameter name when placed in the query string | + +use std::sync::Arc; + +use async_trait::async_trait; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers as gts; +use crate::domain::plugin::{AuthPlugin, PluginConfig, RequestContext}; + +/// API-key injection plugin. +pub struct ApiKeyAuthPlugin { + credstore: Arc, + security: Option, +} + +impl std::fmt::Debug for ApiKeyAuthPlugin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ApiKeyAuthPlugin").finish_non_exhaustive() + } +} + +impl ApiKeyAuthPlugin { + /// Builds the plugin over a credential store. + #[must_use] + pub fn new(credstore: Arc) -> Self { + Self { + credstore, + security: None, + } + } + + /// Supplies the security context used to resolve secrets. + #[must_use] + pub fn with_security_context(mut self, ctx: toolkit_security::SecurityContext) -> Self { + self.security = Some(ctx); + self + } + + async fn resolve_key(&self, key_ref: &str) -> Result { + let raw = key_ref.strip_prefix("cred://").unwrap_or(key_ref); + let reference = credstore_sdk::SecretRef::new(raw.to_owned()) + .map_err(|_| DomainError::SecretNotFound)?; + let ctx = self + .security + .clone() + .unwrap_or_else(toolkit_security::SecurityContext::anonymous); + match self.credstore.get(&ctx, &reference).await { + Ok(Some(response)) => String::from_utf8(response.value.as_bytes().to_vec()) + .map_err(|_| DomainError::SecretNotFound), + _ => Err(DomainError::SecretNotFound), + } + } +} + +#[async_trait] +impl AuthPlugin for ApiKeyAuthPlugin { + fn id(&self) -> &'static str { + gts::AUTH_APIKEY + } + + async fn authenticate( + &self, + ctx: &mut RequestContext, + config: &PluginConfig, + ) -> Result<(), DomainError> { + let Some(key_ref) = config + .string("key_ref") + .or_else(|| config.string("secret_ref")) + else { + return Err(DomainError::Validation( + "apikey auth plugin requires `key_ref`".to_owned(), + )); + }; + let key = self.resolve_key(key_ref).await?; + let placement = config + .string("in") + .or_else(|| config.string("placement")) + .unwrap_or("header"); + if placement.eq_ignore_ascii_case("query") { + let name = config + .string("query") + .or_else(|| config.string("param")) + .unwrap_or("api_key") + .to_owned(); + ctx.query.retain(|(k, _)| k != &name); + ctx.query.push((name, key)); + } else { + let name = config + .string("header") + .or_else(|| config.string("param")) + .unwrap_or("x-api-key"); + let name = http::HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| DomainError::Validation(format!("invalid header name `{name}`")))?; + let value = http::HeaderValue::from_str(&key) + .map_err(|_| DomainError::Validation("invalid api key value".to_owned()))?; + ctx.headers.insert(name, value); + } + ctx.attributes.set("oagw.auth.plugin", self.id()); + Ok(()) + } +} 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..1337ec1 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/mod.rs @@ -0,0 +1,7 @@ +//! Built-in plugin implementations and their registries. +pub mod apikey_auth; +pub mod noop_auth; +pub mod oauth2_client_cred_auth; +pub mod registry; +pub mod request_id_transform; +pub mod required_headers_guard; diff --git a/gears/system/oagw/oagw/src/infra/plugin/noop_auth.rs b/gears/system/oagw/oagw/src/infra/plugin/noop_auth.rs new file mode 100644 index 0000000..574deba --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/noop_auth.rs @@ -0,0 +1,26 @@ +//! `noop` auth plugin — injects nothing (PRD §5.3). + +use async_trait::async_trait; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers as gts; +use crate::domain::plugin::{AuthPlugin, PluginConfig, RequestContext}; + +/// No-authentication plugin. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopAuthPlugin; + +#[async_trait] +impl AuthPlugin for NoopAuthPlugin { + fn id(&self) -> &'static str { + gts::AUTH_NOOP + } + + async fn authenticate( + &self, + _ctx: &mut RequestContext, + _config: &PluginConfig, + ) -> Result<(), DomainError> { + Ok(()) + } +} 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..58424f3 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/oauth2_client_cred_auth.rs @@ -0,0 +1,222 @@ +//! `oauth2_client_cred` / `oauth2_client_cred_basic` auth plugins (ADR 0008). +//! +//! Config keys (per ADR 0008's table): `token_endpoint` xor `issuer_url`, +//! `client_id_ref`, `client_secret_ref`, optional `scopes`. +//! +//! Tokens are exchanged with `toolkit_auth::oauth2::fetch_token` (a one-shot +//! exchange, so no background watcher per cache entry) and cached with +//! `pingora-memory_cache`, keyed by tenant + subject + client-auth method + +//! config hash, and verified against the original key on hit. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use http::HeaderMap; +use pingora_memory_cache::MemoryCache; +use toolkit_auth::oauth2::{ClientAuthMethod, OAuthClientConfig, SecretString, fetch_token}; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers as gts; +use crate::domain::plugin::{AuthPlugin, PluginConfig, RequestContext}; + +/// How the client credentials are transmitted to the token endpoint. +/// +/// Re-exported from [`toolkit_auth::oauth2`], which owns the exchange itself; +/// this module only adds the cache-key tag. +trait ClientAuthTag { + /// Stable, lowercase tag used in the cache key. + fn tag(self) -> &'static str; +} + +impl ClientAuthTag for ClientAuthMethod { + fn tag(self) -> &'static str { + match self { + Self::Basic => "basic", + Self::Form => "form", + } + } +} + +/// A cached token plus the key it was stored under. +#[derive(Clone)] +struct CachedToken { + key: String, + token: Arc, +} + +/// `OAuth2` client-credentials auth plugin. +pub struct OAuth2ClientCredAuthPlugin { + credstore: Arc, + security: Option, + auth_method: ClientAuthMethod, + cache: MemoryCache, + cache_ttl: Duration, +} + +impl std::fmt::Debug for OAuth2ClientCredAuthPlugin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OAuth2ClientCredAuthPlugin") + .field("auth_method", &self.auth_method) + .field("cache_ttl", &self.cache_ttl) + .finish_non_exhaustive() + } +} + +impl OAuth2ClientCredAuthPlugin { + /// Builds the plugin over a credential store. + #[must_use] + pub fn new( + credstore: Arc, + auth_method: ClientAuthMethod, + cache_ttl: Duration, + cache_capacity: usize, + ) -> Self { + Self { + credstore, + security: None, + auth_method, + cache: MemoryCache::new(cache_capacity), + cache_ttl, + } + } + + /// Supplies the security context used to resolve secrets. + #[must_use] + pub fn with_security_context(mut self, ctx: toolkit_security::SecurityContext) -> Self { + self.security = Some(ctx); + self + } + + fn cache_key(&self, ctx: &RequestContext, config: &PluginConfig) -> String { + format!( + "{}:{}:{}:{}", + ctx.tenant_id, + ctx.attributes.get("oagw.subject_id").unwrap_or("-"), + self.auth_method.tag(), + hash_config(config) + ) + } + + async fn resolve_secret(&self, key_ref: &str) -> Result { + let raw = key_ref.strip_prefix("cred://").unwrap_or(key_ref); + let reference = credstore_sdk::SecretRef::new(raw.to_owned()) + .map_err(|_| DomainError::SecretNotFound)?; + let ctx = self + .security + .clone() + .unwrap_or_else(toolkit_security::SecurityContext::anonymous); + match self.credstore.get(&ctx, &reference).await { + Ok(Some(response)) => String::from_utf8(response.value.as_bytes().to_vec()) + .map_err(|_| DomainError::SecretNotFound), + _ => Err(DomainError::SecretNotFound), + } + } + + fn inject(headers: &mut HeaderMap, token: &str) -> Result<(), DomainError> { + let value = http::HeaderValue::from_str(&format!("Bearer {token}")) + .map_err(|_| DomainError::Validation("token produced an invalid header".to_owned()))?; + headers.insert(http::header::AUTHORIZATION, value); + Ok(()) + } +} + +#[async_trait] +impl AuthPlugin for OAuth2ClientCredAuthPlugin { + fn id(&self) -> &'static str { + match self.auth_method { + ClientAuthMethod::Basic => gts::AUTH_OAUTH2_CC_BASIC, + ClientAuthMethod::Form => gts::AUTH_OAUTH2_CC, + } + } + + async fn authenticate( + &self, + ctx: &mut RequestContext, + config: &PluginConfig, + ) -> Result<(), DomainError> { + let key = self.cache_key(ctx, config); + let (hit, _) = self.cache.get(&key); + if let Some(cached) = hit + && cached.key == key + { + return Self::inject(&mut ctx.headers, cached.token.expose()); + } + + let token_endpoint = config.string("token_endpoint"); + let issuer_url = config.string("issuer_url"); + if token_endpoint.is_none() && issuer_url.is_none() { + return Err(DomainError::Validation( + "oauth2 client-credentials plugin requires `token_endpoint` or `issuer_url`" + .to_owned(), + )); + } + let client_id_ref = config.string("client_id_ref").ok_or_else(|| { + DomainError::Validation("oauth2 plugin requires `client_id_ref`".to_owned()) + })?; + let client_secret_ref = config.string("client_secret_ref").ok_or_else(|| { + DomainError::Validation("oauth2 plugin requires `client_secret_ref`".to_owned()) + })?; + + let client_id = self.resolve_secret(client_id_ref).await?; + let client_secret = self.resolve_secret(client_secret_ref).await?; + let scopes = config + .string("scopes") + .unwrap_or_default() + .split_whitespace() + .map(str::to_owned) + .collect(); + + let mut client_config = OAuthClientConfig::default(); + // Exactly one of the two is present: the guard above rejects a request + // that names neither. + if let Some(endpoint) = token_endpoint { + client_config.token_endpoint = Some( + url::Url::parse(endpoint) + .map_err(|_| DomainError::Validation("invalid token_endpoint".to_owned()))?, + ); + } else if let Some(raw) = issuer_url { + client_config.issuer_url = Some( + url::Url::parse(raw) + .map_err(|_| DomainError::Validation("invalid issuer_url".to_owned()))?, + ); + } + client_config.client_id = client_id; + client_config.client_secret = SecretString::new(client_secret); + client_config.scopes = scopes; + client_config.auth_method = self.auth_method; + client_config.default_ttl = self.cache_ttl; + + let fetched = fetch_token(client_config).await.map_err(|e| { + DomainError::AuthenticationFailed(format!("token exchange failed: {e}")) + })?; + + // `expires_in` minus a 30 s safety margin, never above the configured ceiling. + let margin = Duration::from_secs(30); + let ttl = fetched + .expires_in + .checked_sub(margin) + .unwrap_or(self.cache_ttl) + .min(self.cache_ttl); + let token: Arc = Arc::new(fetched.bearer.clone()); + self.cache.put( + &key, + CachedToken { + key: key.clone(), + token, + }, + Some(ttl), + ); + Self::inject(&mut ctx.headers, fetched.bearer.expose()) + } +} + +/// Deterministic, order-independent hash of the plugin config keys. +fn hash_config(config: &PluginConfig) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + for (k, v) in &config.values { + std::hash::Hash::hash(&k, &mut hasher); + std::hash::Hash::hash(&serde_json::to_string(v).unwrap_or_default(), &mut hasher); + } + std::hash::Hasher::finish(&hasher) +} 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..4bba619 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/registry.rs @@ -0,0 +1,279 @@ +//! Plugin registries (ADR 0008, ADR 0009) — the in-process resolution point for +//! named (built-in) plugin identifiers. + +use std::collections::HashMap; +use std::sync::Arc; + +use crate::domain::gts_helpers as gts; +use crate::infra::plugin::apikey_auth::ApiKeyAuthPlugin; +use crate::infra::plugin::noop_auth::NoopAuthPlugin; +use crate::infra::plugin::oauth2_client_cred_auth::OAuth2ClientCredAuthPlugin; +use crate::infra::plugin::request_id_transform::RequestIdTransformPlugin; +use crate::infra::plugin::required_headers_guard::RequiredHeadersGuardPlugin; +use toolkit_auth::oauth2::ClientAuthMethod; + +/// Errors raised when a plugin reference cannot be resolved. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum PluginResolveError { + /// The identifier is a catalog-only one with no backing implementation. + #[error("plugin `{0}` is a catalog identifier with no backing implementation")] + CatalogOnly(String), + /// The identifier is neither built-in nor a stored custom plugin. + #[error("unknown plugin `{0}`")] + Unknown(String), +} + +/// Registry of [`AuthPlugin`] implementations, keyed by GTS identifier. +#[derive(Clone, Default)] +pub struct AuthPluginRegistry { + plugins: HashMap>, +} + +impl AuthPluginRegistry { + /// Registry containing every built-in auth plugin (ADR 0008). + #[must_use] + pub fn with_builtins( + credstore: &Arc, + config: &crate::config::OagwConfig, + ) -> Self { + let mut plugins: HashMap> = + HashMap::new(); + let ttl = config.token_cache.ttl(); + let capacity = config.token_cache.capacity; + for (id, plugin) in [ + ( + gts::AUTH_NOOP, + Arc::new(NoopAuthPlugin) as Arc, + ), + ( + gts::AUTH_APIKEY, + Arc::new(ApiKeyAuthPlugin::new(credstore.clone())), + ), + ( + gts::AUTH_OAUTH2_CC, + Arc::new(OAuth2ClientCredAuthPlugin::new( + credstore.clone(), + ClientAuthMethod::Form, + ttl, + capacity, + )), + ), + ( + gts::AUTH_OAUTH2_CC_BASIC, + Arc::new(OAuth2ClientCredAuthPlugin::new( + credstore.clone(), + ClientAuthMethod::Basic, + ttl, + capacity, + )), + ), + ] { + plugins.insert(id.to_owned(), plugin); + } + Self { plugins } + } + + /// An empty registry; the data plane adds the built-ins it was given. + #[must_use] + pub fn empty() -> Self { + Self::default() + } + + /// Registers an additional plugin implementation. + pub fn register(&mut self, id: &str, plugin: Arc) { + self.plugins.insert(id.to_owned(), plugin); + } + + /// Resolves a plugin reference against the built-in registry. + /// + /// # Errors + /// Returns [`PluginResolveError`] when the reference is unknown. + pub fn get( + &self, + id: &str, + ) -> Result, PluginResolveError> { + self.plugins + .get(id) + .cloned() + .ok_or_else(|| catalog_or_unknown(id)) + } + + /// Every registered identifier. + #[must_use] + pub fn ids(&self) -> Vec { + self.plugins.keys().cloned().collect() + } + + /// Resolves a reference, returning `None` for custom-plugin UUIDs: those are + /// catalogued by the Control Plane but are not executable built-ins. + /// + /// # Errors + /// Returns [`PluginResolveError`] for catalog-only or unknown identifiers. + pub fn resolve( + &self, + id: &str, + ) -> Result>, PluginResolveError> { + if let Some(plugin) = self.plugins.get(id) { + return Ok(Some(plugin.clone())); + } + if is_custom_ref(id) { + return Ok(None); + } + Err(catalog_or_unknown(id)) + } +} + +/// Registry of [`GuardPlugin`] implementations. +#[derive(Clone, Default)] +pub struct GuardPluginRegistry { + plugins: HashMap>, +} + +impl GuardPluginRegistry { + /// Registry containing the built-in guards. `timeout` and `cors` are catalog + /// identifiers only — CORS and timeout enforcement are core data-plane logic. + #[must_use] + pub fn with_builtins() -> Self { + let mut plugins: HashMap> = + HashMap::new(); + plugins.insert( + gts::GUARD_REQUIRED_HEADERS.to_owned(), + Arc::new(RequiredHeadersGuardPlugin) as Arc, + ); + Self { plugins } + } + + /// An empty registry. + #[must_use] + pub fn empty() -> Self { + Self::default() + } + + /// Registers an additional plugin implementation. + pub fn register(&mut self, id: &str, plugin: Arc) { + self.plugins.insert(id.to_owned(), plugin); + } + + /// Resolves a plugin reference. + /// + /// # Errors + /// Returns [`PluginResolveError`] when the reference is unknown. + pub fn get( + &self, + id: &str, + ) -> Result, PluginResolveError> { + self.plugins + .get(id) + .cloned() + .ok_or_else(|| catalog_or_unknown(id)) + } + + /// Resolves a reference, returning `None` for custom-plugin UUIDs. + /// + /// # Errors + /// Returns [`PluginResolveError`] for catalog-only or unknown identifiers. + pub fn resolve( + &self, + id: &str, + ) -> Result>, PluginResolveError> { + if let Some(plugin) = self.plugins.get(id) { + return Ok(Some(plugin.clone())); + } + if is_custom_ref(id) { + return Ok(None); + } + Err(catalog_or_unknown(id)) + } +} + +/// Registry of [`TransformPlugin`] implementations. +#[derive(Clone, Default)] +pub struct TransformPluginRegistry { + plugins: HashMap>, +} + +impl TransformPluginRegistry { + /// Registry containing the built-in transforms. `logging` and `metrics` are + /// catalog identifiers only. + #[must_use] + pub fn with_builtins() -> Self { + let mut plugins: HashMap> = + HashMap::new(); + plugins.insert( + gts::TRANSFORM_REQUEST_ID.to_owned(), + Arc::new(RequestIdTransformPlugin) as Arc, + ); + Self { plugins } + } + + /// Registers an additional plugin implementation. + pub fn register(&mut self, id: &str, plugin: Arc) { + self.plugins.insert(id.to_owned(), plugin); + } + + /// An empty registry. + #[must_use] + pub fn empty() -> Self { + Self::default() + } + + /// Resolves a plugin reference. + /// + /// # Errors + /// Returns [`PluginResolveError`] when the reference is unknown. + pub fn get( + &self, + id: &str, + ) -> Result, PluginResolveError> { + self.plugins + .get(id) + .cloned() + .ok_or_else(|| catalog_or_unknown(id)) + } + + /// Resolves a reference, returning `None` for custom-plugin UUIDs. + /// + /// # Errors + /// Returns [`PluginResolveError`] for catalog-only or unknown identifiers. + pub fn resolve( + &self, + id: &str, + ) -> Result>, PluginResolveError> { + if let Some(plugin) = self.plugins.get(id) { + return Ok(Some(plugin.clone())); + } + if is_custom_ref(id) { + return Ok(None); + } + Err(catalog_or_unknown(id)) + } +} + +/// `true` when `id` is a bare UUID, i.e. a stored custom plugin reference. +fn is_custom_ref(id: &str) -> bool { + crate::domain::gts_helpers::uuid_from_resource_id(id).is_some() +} + +impl From for crate::domain::error::DomainError { + fn from(value: PluginResolveError) -> Self { + Self::PluginNotFound(value.to_string()) + } +} + +/// Distinguishes a documented-but-unresolvable catalog identifier from a truly +/// unknown one. +fn catalog_or_unknown(id: &str) -> PluginResolveError { + if matches!( + id, + gts::AUTH_BASIC + | gts::AUTH_BEARER + | gts::GUARD_TIMEOUT + | gts::GUARD_CORS + | gts::TRANSFORM_LOGGING + | gts::TRANSFORM_METRICS + ) { + PluginResolveError::CatalogOnly(id.to_owned()) + } else { + PluginResolveError::Unknown(id.to_owned()) + } +} 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..34db3ca --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/request_id_transform.rs @@ -0,0 +1,54 @@ +//! `request_id` transform plugin — propagates or generates `X-Request-ID`. + +use async_trait::async_trait; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers as gts; +use crate::domain::plugin::{PluginConfig, RequestContext, TransformPlugin}; + +/// Stateless request-correlation transform. +#[derive(Debug, Default, Clone, Copy)] +pub struct RequestIdTransformPlugin; + +#[async_trait] +impl TransformPlugin for RequestIdTransformPlugin { + fn id(&self) -> &'static str { + gts::TRANSFORM_REQUEST_ID + } + + async fn on_request( + &self, + ctx: &mut RequestContext, + _config: &PluginConfig, + ) -> Result<(), DomainError> { + let existing = ctx + .headers + .get(gts::HEADER_REQUEST_ID) + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + let id = existing.unwrap_or_else(|| Uuid::new_v4().to_string()); + let value = http::HeaderValue::from_str(&id).map_err(|_| { + DomainError::Validation("x-request-id header value is invalid".to_owned()) + })?; + ctx.headers.insert(gts::request_id_header(), value); + ctx.attributes.set("oagw.request_id", id); + Ok(()) + } + + async fn on_response( + &self, + ctx: &mut crate::domain::plugin::ResponseContext<'_>, + _config: &PluginConfig, + ) -> Result<(), DomainError> { + if let Some(id) = ctx.request.attributes.get("oagw.request_id") + && let (Ok(name), Ok(value)) = ( + http::HeaderName::from_bytes(gts::HEADER_REQUEST_ID.as_bytes()), + http::HeaderValue::from_str(id), + ) + { + ctx.headers.insert(name, value); + } + 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..a5716fd --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/required_headers_guard.rs @@ -0,0 +1,82 @@ +//! `required_headers` guard plugin (ADR 0009). +//! +//! Checks the *presence* of configured header names, case-insensitively, in the +//! request phase (`required_request_headers`, rejecting with `400`) and in the +//! response phase (`required_response_headers`, rejecting with `502`). Absent or +//! blank configuration makes the phase a no-op (fail-open). Only the first +//! missing header is reported. + +use async_trait::async_trait; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers as gts; +use crate::domain::plugin::{GuardPlugin, PluginConfig, RequestContext, ResponseContext}; + +/// Stateless presence-check guard. +#[derive(Debug, Default, Clone, Copy)] +pub struct RequiredHeadersGuardPlugin; + +/// Splits and normalizes a comma-separated header list, dropping blank entries. +#[must_use] +pub fn parse_required_headers(raw: &str) -> Vec { + raw.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_ascii_lowercase) + .collect() +} + +fn first_missing(headers: &http::HeaderMap, required: &[String]) -> Option { + required + .iter() + .find(|name| !headers.contains_key(name.as_str())) + .cloned() +} + +fn parse_list(config: &PluginConfig, key: &str) -> Vec { + config + .string(key) + .map(parse_required_headers) + .unwrap_or_default() +} + +#[async_trait] +impl GuardPlugin for RequiredHeadersGuardPlugin { + fn id(&self) -> &'static str { + gts::GUARD_REQUIRED_HEADERS + } + + async fn guard_request( + &self, + ctx: &mut RequestContext, + config: &PluginConfig, + ) -> Result<(), DomainError> { + let required = parse_list(config, "required_request_headers"); + if required.is_empty() { + return Ok(()); + } + if let Some(missing) = first_missing(&ctx.headers, &required) { + return Err(DomainError::Validation(format!( + "required request header `{missing}` is missing" + ))); + } + Ok(()) + } + + async fn guard_response( + &self, + ctx: &mut ResponseContext<'_>, + config: &PluginConfig, + ) -> Result<(), DomainError> { + let required = parse_list(config, "required_response_headers"); + if required.is_empty() { + return Ok(()); + } + if let Some(missing) = first_missing(&ctx.headers, &required) { + return Err(DomainError::DownstreamRejected(format!( + "upstream response is missing required header `{missing}`" + ))); + } + Ok(()) + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/auth_plugin_tests.rs b/gears/system/oagw/oagw/src/infra/proxy/auth_plugin_tests.rs new file mode 100644 index 0000000..3a9a028 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/auth_plugin_tests.rs @@ -0,0 +1,264 @@ +//! The built-in auth plugins as the data plane runs them (PRD §5.2, ADR 0008). +//! +//! Each test drives a real [`DataPlaneService`] against a live `httpmock` +//! upstream with the built-in auth registry over a `MockCredStoreClient`, so the +//! credential injection is observed on the wire — and the assertion is that the +//! injected secret reaches the upstream and never comes back to the client. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::collections::BTreeMap; +use std::sync::Arc; + +use http::{HeaderMap, Method}; +use httpmock::prelude::*; +use serde_json::json; + +use crate::config::OagwConfig; +use crate::domain::gts_helpers as gts; +use crate::domain::model::{ + HeadersConfig, HttpMatch, HttpMethod, MatchConfig, PassthroughMode, PathSuffixMode, + PluginsConfig, Route, SharingMode, Upstream, +}; +use crate::infra::plugin::registry::{ + AuthPluginRegistry, GuardPluginRegistry, TransformPluginRegistry, +}; +use crate::infra::proxy::service::{DataPlaneService, ProxyBody, ProxyCall}; +use crate::infra::proxy::service_tests::{plain_upstream, setup_with}; + +const TENANT: &str = "00000000-0000-0000-0000-000000000001"; +const SECRET: &str = "sk-live-oagw-key"; + +/// An upstream whose `auth` block is supplied by the test, plus a GET route. +async fn auth_fixture( + server: &MockServer, + plugin_type: &str, + config: BTreeMap, +) -> crate::domain::services::management::ControlPlaneService { + let upstream = Upstream { + auth: Some(crate::domain::model::AuthConfig { + plugin_type: Some(plugin_type.to_owned()), + sharing: SharingMode::Private, + config, + }), + headers: HeadersConfig { + request: crate::domain::model::RequestHeaderRules { + passthrough: PassthroughMode::All, + ..crate::domain::model::RequestHeaderRules::default() + }, + ..HeadersConfig::default() + }, + ..plain_upstream() + }; + let route = Route { + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec![HttpMethod::Get], + path: "/api".to_owned(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + ..Route::default() + }; + let (cp, _fixture) = setup_with(server, upstream, route, Some("backend")).await; + cp +} + +/// A data plane whose auth plugins are the built-ins over the given credential +/// store. +fn data_plane(cp: crate::domain::services::management::ControlPlaneService) -> DataPlaneService { + let credstore: Arc = + Arc::new(credstore_sdk::test_util::MockCredStoreClient::with_secrets( + vec![("openai-key".to_owned(), SECRET.to_owned())], + )); + DataPlaneService::new( + Arc::new(cp), + OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }, + ) + .expect("a buildable data plane") + .with_registries( + AuthPluginRegistry::with_builtins(&credstore, &OagwConfig::default()), + GuardPluginRegistry::with_builtins(), + TransformPluginRegistry::with_builtins(), + ) +} + +async fn get( + dp: &DataPlaneService, + path: &str, + query: &str, + headers: &[(&str, &str)], +) -> http::Response { + let mut header_map = HeaderMap::new(); + for (name, value) in headers { + header_map.insert( + http::HeaderName::from_bytes(name.as_bytes()).expect("static header name"), + http::HeaderValue::from_str(value).expect("static header value"), + ); + } + dp.proxy(ProxyCall { + tenant_id: TENANT.to_owned(), + user_id: None, + client_ip: None, + method: Method::GET, + path: path.to_owned(), + query: query.to_owned(), + headers: header_map, + body: bytes::Bytes::new(), + upgrade: None, + }) + .await +} + +/// A bound `plugins.items[]` entry with the given configuration. +fn bound( + plugin_ref: &str, + config: BTreeMap, +) -> crate::domain::model::PluginBinding { + crate::domain::model::PluginBinding::Bound { + plugin_ref: plugin_ref.to_owned(), + plugin_uuid: None, + config: Some(config), + } +} + +#[tokio::test] +async fn noop_auth_leaves_the_request_unmodified() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api").header_missing("x-api-key"); + then.status(200).body("ok"); + }); + + let cp = auth_fixture(&server, gts::AUTH_NOOP, BTreeMap::new()).await; + let response = get(&data_plane(cp), "/backend/api", "", &[]).await; + assert_eq!(response.status(), http::StatusCode::OK); + mock.assert(); +} + +#[tokio::test] +async fn the_api_key_is_injected_as_a_header() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api").header("x-api-key", SECRET); + then.status(200).body("ok"); + }); + + let mut config = BTreeMap::new(); + config.insert("key_ref".to_owned(), json!("cred://openai-key")); + config.insert("header".to_owned(), json!("x-api-key")); + let cp = auth_fixture(&server, gts::AUTH_APIKEY, config).await; + + let mut response = get(&data_plane(cp), "/backend/api", "", &[]).await; + assert_eq!(response.status(), http::StatusCode::OK); + mock.assert(); + // The credential value never reaches the client: the response is the + // upstream's own body and headers, none of which carry the secret. + let body = http_body_util::BodyExt::collect(response.body_mut()) + .await + .expect("a collectable body") + .to_bytes(); + assert_eq!(body.as_ref(), b"ok"); + assert!( + response + .headers() + .iter() + .all(|(_, value)| value.as_bytes() != SECRET.as_bytes()), + "the api key must not be echoed back to the client" + ); +} + +#[tokio::test] +async fn the_api_key_can_be_injected_into_the_query_string() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api").query_param("api_key", SECRET); + then.status(200).body("ok"); + }); + + let mut config = BTreeMap::new(); + config.insert("key_ref".to_owned(), json!("openai-key")); + config.insert("in".to_owned(), json!("query")); + config.insert("query".to_owned(), json!("api_key")); + let cp = auth_fixture(&server, gts::AUTH_APIKEY, config).await; + + // The allowlist is checked before the auth phase runs, so the injected + // parameter needs no entry of its own. + let response = get(&data_plane(cp), "/backend/api", "", &[]).await; + assert_eq!(response.status(), http::StatusCode::OK); + mock.assert(); +} + +#[tokio::test] +async fn a_missing_secret_is_an_internal_error() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api"); + then.status(200).body("unreachable"); + }); + + let mut config = BTreeMap::new(); + config.insert("key_ref".to_owned(), json!("cred://absent-key")); + let cp = auth_fixture(&server, gts::AUTH_APIKEY, config).await; + + let mut response = get(&data_plane(cp), "/backend/api", "", &[]).await; + assert_eq!(response.status(), http::StatusCode::INTERNAL_SERVER_ERROR); + let body = http_body_util::BodyExt::collect(response.body_mut()) + .await + .expect("a collectable body") + .to_bytes(); + let problem: serde_json::Value = serde_json::from_slice(&body).expect("problem body"); + assert_eq!( + problem["type"], + format!("gts.cf.core.errors.err.v1~{}", gts::ERR_SECRET_NOT_FOUND) + ); + assert_eq!(problem["title"], "Secret Not Found"); +} + +/// An upstream binding the `request_id` transform is used here because the +/// `apikey` guard-free path above already covers the auth phase; this test +/// pins the plugin-configuration plumbing through `plugins.items[]`. +#[tokio::test] +async fn bound_plugin_configuration_reaches_the_implementation() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api").header_exists("x-request-id"); + then.status(200).body("ok"); + }); + + let upstream = Upstream { + headers: HeadersConfig { + request: crate::domain::model::RequestHeaderRules { + passthrough: PassthroughMode::All, + ..crate::domain::model::RequestHeaderRules::default() + }, + ..HeadersConfig::default() + }, + plugins: PluginsConfig { + sharing: SharingMode::Private, + items: vec![bound(gts::TRANSFORM_REQUEST_ID, BTreeMap::new())], + }, + ..plain_upstream() + }; + let route = Route { + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec![HttpMethod::Get], + path: "/api".to_owned(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + ..Route::default() + }; + let (cp, _fixture) = setup_with(&server, upstream, route, Some("backend")).await; + let response = get(&data_plane(cp), "/backend/api", "", &[]).await; + assert_eq!(response.status(), http::StatusCode::OK); + mock.assert(); +} 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..ca8b37f --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/cors.rs @@ -0,0 +1,134 @@ +//! CORS handling (ADR 0004). +//! +//! Preflights are answered locally at the handler level with a permissive `204` +//! that echoes the requested method and headers — before the upstream is +//! resolved, before a tenant context is required and without any upstream call. +//! Actual cross-origin requests are validated against the upstream's CORS +//! configuration. + +use http::{HeaderMap, Method, StatusCode}; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers as gts; +use crate::domain::model::CorsConfig; + +/// The preflight request headers the gateway acts on. +pub const PREFLIGHT_METHOD_HEADER: &str = "access-control-request-method"; +/// The preflight header naming request headers. +pub const PREFLIGHT_HEADERS_HEADER: &str = "access-control-request-headers"; + +/// Whether this request is a CORS preflight (`OPTIONS` + `Origin` + +/// `Access-Control-Request-Method`). +#[must_use] +pub fn is_preflight(method: &Method, headers: &HeaderMap) -> bool { + method == Method::OPTIONS + && headers.contains_key(http::header::ORIGIN) + && headers.contains_key(PREFLIGHT_METHOD_HEADER) +} + +/// Builds the preflight `204` response, echoing the request's origin, the +/// method it asks about and the headers it wants to send (ADR 0004). +/// +/// Browsers enforce this echo themselves: a preflight answer that does not +/// name the origin and method of the pending request is treated as a failure, +/// so `*` — while permissive — would not let an actual cross-origin request +/// through. No upstream is resolved and no tenant context is required. +#[must_use] +pub fn preflight_response(headers: &HeaderMap) -> http::Response { + let echo = |name: &str, fallback: &'static str| { + headers + .get(name) + .and_then(|value| value.to_str().ok()) + .map_or_else(|| fallback.to_owned(), str::to_owned) + }; + let origin = echo(http::header::ORIGIN.as_str(), "*"); + let methods = echo(PREFLIGHT_METHOD_HEADER, "*"); + let requested_headers = echo(PREFLIGHT_HEADERS_HEADER, "*"); + // Built by hand rather than through the fallible builder: well-known + // header names plus request-echoed values cannot fail to construct. + let mut response = http::Response::new(axum::body::Body::empty()); + *response.status_mut() = StatusCode::NO_CONTENT; + let out = response.headers_mut(); + let insert = |out: &mut http::HeaderMap, name: &'static str, value: String| { + if let Ok(value) = http::HeaderValue::from_str(&value) { + out.insert(http::HeaderName::from_static(name), value); + } + }; + insert(out, "access-control-allow-origin", origin); + insert(out, "access-control-allow-methods", methods); + insert(out, "access-control-allow-headers", requested_headers); + insert(out, "access-control-max-age", "86400".to_owned()); + insert( + out, + http::header::VARY.as_str(), + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers".to_owned(), + ); + insert( + out, + gts::HEADER_ERROR_SOURCE, + gts::ERROR_SOURCE_GATEWAY.to_owned(), + ); + response +} + +/// Validates an actual cross-origin request against a CORS configuration. +/// +/// Requests without an `Origin` are never CORS-checked. +/// +/// # Errors +/// Returns [`DomainError::CorsOriginNotAllowed`] or +/// [`DomainError::CorsMethodNotAllowed`]. +pub fn validate_request( + cors: &CorsConfig, + method: &Method, + headers: &HeaderMap, +) -> Result<(), DomainError> { + let Some(origin) = headers + .get(http::header::ORIGIN) + .and_then(|v| v.to_str().ok()) + else { + return Ok(()); + }; + let origin = origin.trim(); + if origin.is_empty() { + return Ok(()); + } + if !cors.allowed_origins.iter().any(|o| o == "*" || o == origin) { + return Err(DomainError::CorsOriginNotAllowed(origin.to_owned())); + } + if !cors + .allowed_methods + .iter() + .any(|m| m.eq_ignore_ascii_case(method.as_str())) + { + return Err(DomainError::CorsMethodNotAllowed( + method.as_str().to_owned(), + )); + } + Ok(()) +} + +/// Adds the CORS response headers for an accepted cross-origin request. +/// +/// `origin` is the `Origin` value already validated by [`validate_request`]; +/// it is passed explicitly rather than round-tripped through a header. +pub fn apply_origin_headers(headers: &mut HeaderMap, cors: &CorsConfig, origin: &str) { + if origin.is_empty() { + return; + } + if let Ok(value) = http::HeaderValue::from_str(origin) { + headers.insert(http::header::ACCESS_CONTROL_ALLOW_ORIGIN, value); + headers.insert(http::header::VARY, http::HeaderValue::from_static("Origin")); + } + if cors.allow_credentials { + headers.insert( + http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS, + http::HeaderValue::from_static("true"), + ); + } + if !cors.expose_headers.is_empty() + && let Ok(value) = http::HeaderValue::from_str(&cors.expose_headers.join(", ")) + { + headers.insert(http::header::ACCESS_CONTROL_EXPOSE_HEADERS, value); + } +} 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..b172f56 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/headers.rs @@ -0,0 +1,178 @@ +//! Header transformation (DESIGN §3.2 "Headers Transformation"). + +use http::HeaderMap; + +use crate::domain::error::DomainError; +use crate::domain::gts_helpers as gts; +use crate::domain::model::{HeadersConfig, PassthroughMode}; + +/// Headers consumed by the gateway during routing; never forwarded. +const ROUTING_HEADERS: &[&str] = &[gts::HEADER_TARGET_HOST, gts::HEADER_ERROR_SOURCE]; + +/// Hop-by-hop headers stripped per HTTP semantics. +pub const HOP_BY_HOP_HEADERS: &[&str] = &[ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// Headers the proxy always forwards regardless of `passthrough`, because the +/// request body cannot be framed or negotiated without them. +const STRUCTURAL_HEADERS: &[&str] = &["content-type", "content-length", "accept"]; + +/// The `X-OAGW-Target-Host` routing header, already validated. +/// +/// # Errors +/// Returns [`crate::domain::error::DomainError::InvalidTargetHost`] when the +/// header is present but not a bare hostname or IP. +pub fn requested_target_host(headers: &HeaderMap) -> Result, DomainError> { + match headers.get(crate::domain::gts_helpers::HEADER_TARGET_HOST) { + Some(value) => { + let raw = value.to_str().map_err(|_| { + crate::domain::error::DomainError::InvalidTargetHost( + "the header value is not valid ASCII".to_owned(), + ) + })?; + parse_target_host(raw).map(Some) + } + None => Ok(None), + } +} + +/// Appends `headers` to `out`, ignoring values that are not valid on the wire. +pub fn insert_all(out: &mut HeaderMap, headers: &[(&'static str, String)]) { + for (name, value) in headers { + if let (Ok(name), Ok(value)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + out.insert(name, value); + } + } +} + +/// Removes hop-by-hop and gateway routing headers from an inbound request. +#[must_use] +pub fn strip_gateway_headers(headers: &HeaderMap) -> HeaderMap { + let mut out = HeaderMap::new(); + for (name, value) in headers { + let key = name.as_str().to_ascii_lowercase(); + if HOP_BY_HOP_HEADERS.contains(&key.as_str()) || ROUTING_HEADERS.contains(&key.as_str()) { + continue; + } + out.append(name.clone(), value.clone()); + } + out +} + +/// Builds the outbound request headers from the inbound set plus the upstream's +/// `headers.request` rules. +#[must_use] +pub fn build_outbound_headers( + inbound_stripped: &HeaderMap, + rules: &crate::domain::model::RequestHeaderRules, +) -> HeaderMap { + let mut out = match rules.passthrough { + PassthroughMode::All => inbound_stripped.clone(), + PassthroughMode::Allowlist => { + let allow: Vec = rules + .passthrough_allowlist + .iter() + .map(|h| h.trim().to_ascii_lowercase()) + .collect(); + let allow: Vec<&str> = allow.iter().map(String::as_str).collect(); + filter_headers(inbound_stripped, &allow) + } + PassthroughMode::None => filter_headers(inbound_stripped, STRUCTURAL_HEADERS), + }; + + for name in &rules.remove { + out.remove(name); + } + for (name, value) in &rules.set { + if let (Ok(n), Ok(v)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + out.insert(n, v); + } + } + for (name, value) in &rules.add { + if let (Ok(n), Ok(v)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + out.append(n, v); + } + } + out +} + +fn filter_headers(source: &HeaderMap, allow: &[&str]) -> HeaderMap { + let mut out = HeaderMap::new(); + for (name, value) in source { + let key = name.as_str().to_ascii_lowercase(); + if allow.contains(&key.as_str()) { + out.append(name.clone(), value.clone()); + } + } + out +} + +/// Applies the upstream's `headers.response` rules to the upstream response +/// headers in place. +pub fn apply_response_headers(headers: &mut HeaderMap, rules: &HeadersConfig) { + for name in &rules.response.remove { + headers.remove(name); + } + for (name, value) in &rules.response.set { + if let (Ok(n), Ok(v)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + headers.insert(n, v); + } + } + for (name, value) in &rules.response.add { + if let (Ok(n), Ok(v)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + headers.append(n, v); + } + } +} + +/// Parses an `X-OAGW-Target-Host` value: a bare hostname or IP, no port or path. +/// +/// # Errors +/// Returns [`crate::domain::error::DomainError::InvalidTargetHost`] for an empty +/// value or one carrying a scheme, port, path or user info. +pub fn parse_target_host(value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err(crate::domain::error::DomainError::InvalidTargetHost( + "the header value is empty".to_owned(), + )); + } + let has_scheme = trimmed.contains("://"); + let has_port = trimmed + .rsplit_once(':') + .is_some_and(|(_, port)| !port.is_empty() && port.bytes().all(|b| b.is_ascii_digit())); + let has_path = trimmed.contains('/') || trimmed.contains('?'); + if has_scheme || has_port || has_path || trimmed.contains('@') { + return Err(crate::domain::error::DomainError::InvalidTargetHost( + format!("`{trimmed}` must be a bare hostname or IP address"), + )); + } + Ok(crate::domain::alias::normalize_host(trimmed)) +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod headers_tests; diff --git a/gears/system/oagw/oagw/src/infra/proxy/headers/headers_tests.rs b/gears/system/oagw/oagw/src/infra/proxy/headers/headers_tests.rs new file mode 100644 index 0000000..8e7bd2b --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/headers/headers_tests.rs @@ -0,0 +1,174 @@ +//! Tests for header transformation and `X-OAGW-Target-Host` parsing. + +use super::*; +use crate::domain::error::DomainError; +use crate::domain::model::{RequestHeaderRules, ResponseHeaderRules}; + +fn hm(pairs: &[(&str, &str)]) -> HeaderMap { + let mut map = HeaderMap::new(); + for (k, v) in pairs { + map.insert( + http::HeaderName::from_bytes(k.as_bytes()).unwrap(), + http::HeaderValue::from_str(v).unwrap(), + ); + } + map +} + +#[test] +fn hop_by_hop_headers_are_stripped() { + let inbound = hm(&[ + ("Connection", "keep-alive"), + ("Keep-Alive", "timeout=5"), + ("Transfer-Encoding", "chunked"), + ("Upgrade", "websocket"), + ("X-Custom", "v"), + ]); + let out = strip_gateway_headers(&inbound); + assert!(out.get("connection").is_none()); + assert!(out.get("keep-alive").is_none()); + assert!(out.get("transfer-encoding").is_none()); + assert!(out.get("upgrade").is_none()); + assert_eq!(out.get("x-custom").unwrap(), "v"); +} + +#[test] +fn target_host_header_is_stripped() { + let inbound = hm(&[("X-OAGW-Target-Host", "us.vendor.com"), ("X-Custom", "v")]); + let out = strip_gateway_headers(&inbound); + assert!(out.get("x-oagw-target-host").is_none()); + assert_eq!(out.get("x-custom").unwrap(), "v"); +} + +#[test] +fn host_header_survives_stripping_and_is_replaced_later() { + let inbound = hm(&[("Host", "gateway.local"), ("X-Custom", "v")]); + let out = strip_gateway_headers(&inbound); + assert_eq!(out.get("host").unwrap(), "gateway.local"); +} + +#[test] +fn passthrough_none_drops_application_headers() { + let rules = RequestHeaderRules::default(); + let inbound = hm(&[("X-Secret", "s"), ("Content-Type", "application/json")]); + let out = build_outbound_headers(&inbound, &rules); + assert!(out.get("x-secret").is_none()); + // Structural headers survive so the body stays well-formed. + assert_eq!(out.get("content-type").unwrap(), "application/json"); +} + +#[test] +fn passthrough_allowlist_forwards_only_listed_headers() { + let rules = RequestHeaderRules { + passthrough: PassthroughMode::Allowlist, + passthrough_allowlist: vec!["X-Custom".to_owned()], + ..Default::default() + }; + let inbound = hm(&[ + ("X-Custom", "1"), + ("X-Other", "2"), + ("Accept", "text/plain"), + ]); + let out = build_outbound_headers(&inbound, &rules); + assert_eq!(out.get("x-custom").unwrap(), "1"); + assert!(out.get("x-other").is_none()); +} + +#[test] +fn passthrough_all_forwards_everything() { + let rules = RequestHeaderRules { + passthrough: PassthroughMode::All, + ..Default::default() + }; + let inbound = hm(&[("X-Custom", "1"), ("Accept", "text/plain")]); + let out = build_outbound_headers(&inbound, &rules); + assert_eq!(out.get("x-custom").unwrap(), "1"); +} + +#[test] +fn set_overrides_and_add_appends() { + let mut set = std::collections::BTreeMap::new(); + set.insert("x-injected".to_owned(), "yes".to_owned()); + let mut add = std::collections::BTreeMap::new(); + add.insert("x-multi".to_owned(), "a".to_owned()); + let rules = RequestHeaderRules { + set, + add, + passthrough: PassthroughMode::All, + ..Default::default() + }; + let inbound = hm(&[("X-Injected", "no"), ("X-Multi", "0")]); + let out = build_outbound_headers(&inbound, &rules); + assert_eq!(out.get("x-injected").unwrap(), "yes"); + assert_eq!(out.get_all("x-multi").iter().count(), 2); +} + +#[test] +fn add_appends_without_passthrough() { + let mut add = std::collections::BTreeMap::new(); + add.insert("x-multi".to_owned(), "a".to_owned()); + let rules = RequestHeaderRules { + add, + ..Default::default() + }; + let inbound = hm(&[("X-Multi", "0")]); + let out = build_outbound_headers(&inbound, &rules); + // `none` drops the inbound header, so only the added value survives. + assert_eq!(out.get_all("x-multi").iter().count(), 1); + assert_eq!(out.get("x-multi").unwrap(), "a"); +} + +#[test] +fn remove_drops_headers() { + let rules = RequestHeaderRules { + remove: vec!["x-drop-me".to_owned()], + passthrough: PassthroughMode::All, + ..Default::default() + }; + let inbound = hm(&[("X-Drop-Me", "1"), ("X-Keep", "2")]); + let out = build_outbound_headers(&inbound, &rules); + assert!(out.get("x-drop-me").is_none()); + assert_eq!(out.get("x-keep").unwrap(), "2"); +} + +#[test] +fn response_rules_are_applied() { + let mut set = std::collections::BTreeMap::new(); + set.insert("x-resp".to_owned(), "set".to_owned()); + let config = HeadersConfig { + response: ResponseHeaderRules { + set, + add: std::collections::BTreeMap::new(), + remove: vec!["x-upstream-only".to_owned()], + }, + ..Default::default() + }; + let mut headers = hm(&[("X-Upstream-Only", "1"), ("X-Other", "2")]); + apply_response_headers(&mut headers, &config); + assert!(headers.get("x-upstream-only").is_none()); + assert_eq!(headers.get("x-resp").unwrap(), "set"); + assert_eq!(headers.get("x-other").unwrap(), "2"); +} + +#[test] +fn target_host_parsing() { + assert_eq!(parse_target_host("us.vendor.com").unwrap(), "us.vendor.com"); + assert_eq!( + parse_target_host(" US.Vendor.COM. ").unwrap(), + "us.vendor.com" + ); + assert_eq!(parse_target_host("10.0.0.1").unwrap(), "10.0.0.1"); + + for bad in [ + "us.vendor.com:8443", + "http://us.vendor.com", + "us.vendor.com/path", + "", + ] { + let err = parse_target_host(bad).unwrap_err(); + assert!( + matches!(err, DomainError::InvalidTargetHost(_)), + "`{bad}` should be invalid" + ); + } +} 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..6904ea7 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/mod.rs @@ -0,0 +1,21 @@ +//! The data plane: header transformation, rate limiting, CORS and proxying. +pub mod cors; +pub mod headers; +pub mod rate_limit; +pub mod service; + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod service_tests; + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod websocket_tests; + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod plugin_chain_tests; + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod auth_plugin_tests; diff --git a/gears/system/oagw/oagw/src/infra/proxy/plugin_chain_tests.rs b/gears/system/oagw/oagw/src/infra/proxy/plugin_chain_tests.rs new file mode 100644 index 0000000..a952cf1 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/plugin_chain_tests.rs @@ -0,0 +1,447 @@ +//! The plugin chain as the data plane executes it (DESIGN §3.2 "Plugin System", +//! ADR 0008, ADR 0009). +//! +//! Order matters here, so every assertion goes through the real +//! [`DataPlaneService`] against a live `httpmock` upstream: Auth → Guards → +//! Transform(request) → upstream → Transform(response), with upstream-bound +//! plugins running before route-bound ones. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::collections::BTreeMap; +use std::sync::Arc; + +use httpmock::prelude::*; +use serde_json::json; + +use crate::config::OagwConfig; +use crate::domain::error::DomainError; +use crate::domain::gts_helpers as gts; +use crate::domain::model::{ + HeadersConfig, HttpMatch, HttpMethod, MatchConfig, PassthroughMode, PathSuffixMode, + PluginBinding, PluginsConfig, Route, SharingMode, Upstream, +}; +use crate::domain::plugin::PluginConfig; +use crate::infra::plugin::registry::PluginResolveError; +use crate::infra::plugin::registry::{ + AuthPluginRegistry, GuardPluginRegistry, TransformPluginRegistry, +}; +use crate::infra::proxy::service::DataPlaneService; +use crate::infra::proxy::service_tests::{body, get, plain_upstream, setup_with, try_setup_with}; + +/// A `guard` binding for the built-in `required_headers` plugin. +fn required_request_headers_guard(required: &str) -> PluginBinding { + let mut config = BTreeMap::new(); + config.insert("required_request_headers".to_owned(), json!(required)); + PluginBinding::Bound { + plugin_ref: gts::GUARD_REQUIRED_HEADERS.to_owned(), + plugin_uuid: None, + config: Some(config), + } +} + +/// A `guard` binding that checks the upstream response instead. +fn required_response_headers_guard(required: &str) -> PluginBinding { + let mut config = BTreeMap::new(); + config.insert("required_response_headers".to_owned(), json!(required)); + PluginBinding::Bound { + plugin_ref: gts::GUARD_REQUIRED_HEADERS.to_owned(), + plugin_uuid: None, + config: Some(config), + } +} + +/// A `transform` binding for the built-in `request_id` plugin. +fn request_id_transform() -> PluginBinding { + PluginBinding::Reference(gts::TRANSFORM_REQUEST_ID.to_owned()) +} + +/// An upstream + route whose plugin bindings are supplied by the test. +async fn chain_fixture( + server: &MockServer, + upstream_plugins: Vec, + route_plugins: Vec, +) -> Result< + crate::domain::services::management::ControlPlaneService, + crate::domain::error::DomainError, +> { + let upstream = Upstream { + // Inbound headers flow through so the chain has something to read. + headers: HeadersConfig { + request: crate::domain::model::RequestHeaderRules { + passthrough: PassthroughMode::All, + ..crate::domain::model::RequestHeaderRules::default() + }, + ..HeadersConfig::default() + }, + plugins: PluginsConfig { + sharing: SharingMode::Private, + items: upstream_plugins, + }, + ..plain_upstream() + }; + let route = Route { + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec![HttpMethod::Get], + path: "/api".to_owned(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + plugins: PluginsConfig { + sharing: SharingMode::Private, + items: route_plugins, + }, + ..Route::default() + }; + let (cp, _fixture) = setup_with(server, upstream, route, Some("backend")).await; + Ok(cp) +} + +/// [`chain_fixture`] without the unwrap, for tests that expect the binding to +/// be refused. +async fn try_chain_fixture( + server: &MockServer, + route_plugins: Vec, +) -> Result< + crate::domain::services::management::ControlPlaneService, + crate::domain::error::DomainError, +> { + let upstream = Upstream { + headers: HeadersConfig { + request: crate::domain::model::RequestHeaderRules { + passthrough: PassthroughMode::All, + ..crate::domain::model::RequestHeaderRules::default() + }, + ..HeadersConfig::default() + }, + ..plain_upstream() + }; + let route = Route { + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec![HttpMethod::Get], + path: "/api".to_owned(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + plugins: PluginsConfig { + sharing: SharingMode::Private, + items: route_plugins, + }, + ..Route::default() + }; + try_setup_with(server, upstream, route, Some("backend")).await +} + +fn data_plane(cp: crate::domain::services::management::ControlPlaneService) -> DataPlaneService { + DataPlaneService::new( + Arc::new(cp), + OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }, + ) + .expect("a buildable data plane") + .with_registries( + AuthPluginRegistry::empty(), + GuardPluginRegistry::with_builtins(), + TransformPluginRegistry::with_builtins(), + ) +} + +#[tokio::test] +async fn a_guard_rejects_the_request_before_it_reaches_the_upstream() { + let server = MockServer::start(); + let cp = chain_fixture( + &server, + vec![], + vec![required_request_headers_guard("x-tenant-id")], + ) + .await + .expect("a buildable control plane"); + let mock = server.mock(|when, then| { + when.method(GET).path("/api"); + then.status(200).body("unreachable"); + }); + + let response = get(&data_plane(cp), "/backend/api", &[]).await; + assert_eq!(response.status(), http::StatusCode::BAD_REQUEST); + // A call count of zero is the whole point: the guard ran first. + mock.assert_calls(0); +} + +#[tokio::test] +async fn a_guard_passes_when_the_header_is_present() { + let server = MockServer::start(); + let cp = chain_fixture( + &server, + vec![], + vec![required_request_headers_guard("x-tenant-id")], + ) + .await + .expect("a buildable control plane"); + let mock = server.mock(|when, then| { + when.method(GET).path("/api").header("x-tenant-id", "t1"); + then.status(200).body("ok"); + }); + + let mut response = get(&data_plane(cp), "/backend/api", &[("x-tenant-id", "t1")]).await; + assert_eq!(response.status(), http::StatusCode::OK); + assert_eq!(body(&mut response).await, bytes::Bytes::from("ok")); + mock.assert_calls(1); +} + +#[tokio::test] +async fn transforms_run_before_the_call_and_again_on_the_response() { + let server = MockServer::start(); + let cp = chain_fixture(&server, vec![], vec![request_id_transform()]) + .await + .expect("a buildable control plane"); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api") + .header("x-request-id", "corr-1"); + then.status(200).body("ok"); + }); + + let mut response = get( + &data_plane(cp), + "/backend/api", + &[("x-request-id", "corr-1")], + ) + .await; + assert_eq!(response.status(), http::StatusCode::OK); + mock.assert_calls(1); + assert_eq!(body(&mut response).await, bytes::Bytes::from("ok")); + assert_eq!( + response + .headers() + .get(gts::request_id_header()) + .and_then(|value| value.to_str().ok()), + Some("corr-1"), + "the response phase echoes the correlation id" + ); +} + +#[tokio::test] +async fn upstream_and_route_bound_transforms_compose() { + let server = MockServer::start(); + let cp = chain_fixture( + &server, + vec![request_id_transform()], + vec![request_id_transform()], + ) + .await + .expect("a buildable control plane"); + let mock = server.mock(|when, then| { + when.method(GET).path("/api").header_exists("x-request-id"); + then.status(200).body("ok"); + }); + + let response = get(&data_plane(cp), "/backend/api", &[]).await; + assert_eq!(response.status(), http::StatusCode::OK); + mock.assert_calls(1); + assert!( + response.headers().contains_key(gts::request_id_header()), + "the generated id is echoed back on the response" + ); +} + +#[tokio::test] +async fn a_guard_can_reject_the_upstream_response() { + let server = MockServer::start(); + let cp = chain_fixture( + &server, + vec![required_response_headers_guard("x-trace-id")], + vec![], + ) + .await + .expect("a buildable control plane"); + server.mock(|when, then| { + when.method(GET).path("/api"); + then.status(200).body("ok"); + }); + + let response = get(&data_plane(cp), "/backend/api", &[]).await; + assert_eq!(response.status(), http::StatusCode::BAD_GATEWAY); +} + +#[tokio::test] +async fn a_guard_can_accept_the_upstream_response() { + let server = MockServer::start(); + let cp = chain_fixture( + &server, + vec![required_response_headers_guard("x-trace-id")], + vec![], + ) + .await + .expect("a buildable control plane"); + server.mock(|when, then| { + when.method(GET).path("/api"); + then.status(200).header("x-trace-id", "t-1").body("ok"); + }); + + let response = get(&data_plane(cp), "/backend/api", &[]).await; + assert_eq!(response.status(), http::StatusCode::OK); +} + +#[tokio::test] +async fn catalog_only_plugin_references_never_resolve() { + // Documented in the catalogue, backed by no implementation: `basic` and + // `bearer` auth, the `timeout` and `cors` guards, `logging` and `metrics` + // transforms (ADR 0008 §4, ADR 0009 §3). + let auth = AuthPluginRegistry::empty(); + let guard = GuardPluginRegistry::with_builtins(); + let transform = TransformPluginRegistry::with_builtins(); + for (reference, resolved) in [ + (gts::AUTH_BASIC, auth.resolve(gts::AUTH_BASIC).err()), + (gts::AUTH_BEARER, auth.resolve(gts::AUTH_BEARER).err()), + (gts::GUARD_TIMEOUT, guard.resolve(gts::GUARD_TIMEOUT).err()), + (gts::GUARD_CORS, guard.resolve(gts::GUARD_CORS).err()), + ( + gts::TRANSFORM_LOGGING, + transform.resolve(gts::TRANSFORM_LOGGING).err(), + ), + ( + gts::TRANSFORM_METRICS, + transform.resolve(gts::TRANSFORM_METRICS).err(), + ), + ] { + let error = resolved.unwrap_or_else(|| panic!("`{reference}` must fail to resolve")); + assert_eq!( + error, + PluginResolveError::CatalogOnly(reference.to_owned()), + "the rejection names the catalog gap, not an unknown plugin" + ); + // The data plane maps this to `PluginNotFound` (503), as the test below + // exercises end to end. + assert_eq!( + DomainError::from(error).status(), + http::StatusCode::SERVICE_UNAVAILABLE + ); + } +} + +#[tokio::test] +async fn unknown_plugin_references_are_rejected_at_bind_time() { + let server = MockServer::start(); + let unknown = vec![PluginBinding::Reference( + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.nope.v1".to_owned(), + )]; + // The reference resolves to neither a built-in nor a stored custom plugin, + // so the binding is refused when the upstream is stored rather than at + // request time (`oagw-plugins` -> "Plugin identification"). + let Err(error) = try_chain_fixture(&server, unknown).await else { + panic!("an unknown plugin reference must not be storable"); + }; + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); + assert!(error.to_string().contains("nope.v1")); +} + +/// A UUID-shaped reference that names no stored plugin is likewise refused at +/// bind time; at proxy time the registries would silently skip it. +#[tokio::test] +async fn a_uuid_reference_to_no_stored_plugin_is_rejected_at_bind_time() { + let server = MockServer::start(); + let unknown = vec![PluginBinding::Reference(format!( + "{type_id}~{uuid}", + type_id = gts::GUARD_PLUGIN_TYPE, + uuid = uuid::Uuid::now_v7() + ))]; + let Err(error) = try_chain_fixture(&server, unknown).await else { + panic!("a dangling custom plugin reference must not be stored"); + }; + assert_eq!(error.status(), http::StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn plugin_config_reaches_the_implementation() { + let mut values = BTreeMap::new(); + values.insert("required_request_headers".to_owned(), json!("x-trace")); + let binding = PluginBinding::Bound { + plugin_ref: gts::GUARD_REQUIRED_HEADERS.to_owned(), + plugin_uuid: None, + config: Some(values), + }; + let config = PluginConfig::from(&binding); + assert_eq!(config.plugin_ref, gts::GUARD_REQUIRED_HEADERS); + assert_eq!(config.string("required_request_headers"), Some("x-trace")); +} + +/// Guards are a phase ahead of transforms, regardless of declaration order: the +/// transform is declared first here, so if the chain interleaved the two kinds +/// it would have already added `x-request-id` by the time the guard looked, and +/// the guard would pass. The spec puts all guards before all transforms +/// (`oagw-plugins` → "Plugin types and traits"), so the guard must reject. +#[tokio::test] +async fn guards_run_before_request_transforms_even_when_declared_after() { + let server = MockServer::start(); + let cp = chain_fixture( + &server, + vec![], + vec![ + request_id_transform(), + required_request_headers_guard("x-request-id"), + ], + ) + .await + .expect("a buildable control plane"); + let mock = server.mock(|when, then| { + when.method(GET).path("/api"); + then.status(200).body("unreachable"); + }); + + let response = get(&data_plane(cp), "/backend/api", &[]).await; + assert_eq!(response.status(), http::StatusCode::BAD_REQUEST); + mock.assert_calls(0); +} + +/// Same phase rule on the response leg: the guard sees the response before the +/// transform has touched it, so a required response header that only the +/// transform adds is still absent when the guard runs. +#[tokio::test] +async fn guards_run_before_response_transforms() { + let server = MockServer::start(); + let upstream = Upstream { + headers: HeadersConfig { + response: crate::domain::model::ResponseHeaderRules::default(), + ..HeadersConfig::default() + }, + plugins: PluginsConfig { + sharing: SharingMode::Private, + items: vec![ + request_id_transform(), + required_response_headers_guard("x-request-id"), + ], + }, + ..plain_upstream() + }; + let route = Route { + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec![HttpMethod::Get], + path: "/api".to_owned(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + ..Route::default() + }; + let mock = server.mock(|when, then| { + when.method(GET).path("/api"); + then.status(200).body("ok"); + }); + let (cp, _) = setup_with(&server, upstream, route, Some("backend")).await; + + let response = get(&data_plane(cp), "/backend/api", &[]).await; + // A guard rejecting on the response leg is a bad gateway, not a bad request + // (the client's request was fine). + assert_eq!(response.status(), http::StatusCode::BAD_GATEWAY); + mock.assert(); +} 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..cb460cf --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/rate_limit.rs @@ -0,0 +1,198 @@ +//! Token-bucket rate limiting (ADR 0003). +//! +//! Buckets are owned by the data plane (ADR 0006) and keyed by scope. Replenish +//! is computed lazily on each `try_acquire`, so there is no background task. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::Duration; + +use crate::domain::error::DomainError; +use crate::domain::model::RateLimitConfig; + +/// Rounds a non-negative duration to whole seconds. +/// +/// Token counts are bounded by the configured capacity (`u32`), so the result +/// stays far inside `u64`; the saturation keeps a pathological float from +/// wrapping to zero. +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn to_secs(value: f64) -> u64 { + (value.ceil().max(1.0)) as u64 +} + +/// Floors a non-negative token count. +/// +/// Buckets never hold more than their capacity, so this cannot exceed `u32`. +#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] +fn to_tokens(value: f64) -> u32 { + (value.floor().max(0.0)) as u32 +} + +/// One token bucket. +struct Bucket { + tokens: f64, + capacity: f64, + last_refill: std::time::Instant, +} + +impl Bucket { + fn refill(&mut self, config: &RateLimitConfig) { + let now = std::time::Instant::now(); + let elapsed = now.duration_since(self.last_refill).as_secs_f64(); + let per_second = + f64::from(config.sustained.rate) / config.sustained.window.duration().as_secs_f64(); + if per_second > 0.0 { + self.tokens = (self.tokens + per_second * elapsed).min(self.capacity); + } + self.last_refill = now; + } +} + +/// In-process token-bucket registry. +#[derive(Default)] +pub struct RateLimiter { + buckets: Mutex>, +} + +/// The outcome of a rate-limit check. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RateDecision { + /// Tokens remaining in the bucket after this request. + pub remaining: u32, + /// Seconds until the bucket has a token again (when rejected). + pub retry_after: u64, + /// Seconds until the bucket is fully replenished. + pub reset: u64, +} + +impl RateLimiter { + /// Builds a limiter. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Attempts to consume `config.cost` tokens from the bucket identified by + /// `key`. + /// + /// # Errors + /// Returns a [`RateRejection`] carrying the `429` error **and** the bucket + /// state, so the response can still advertise `X-RateLimit-*`. + pub fn try_acquire( + &self, + key: &str, + config: &RateLimitConfig, + ) -> Result { + let capacity = config.capacity(); + let cost = f64::from(config.cost.max(1)); + let rate = config.sustained.rate.max(1); + let refill_secs = config.sustained.window.duration().as_secs_f64(); + + let mut buckets = self + .buckets + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let bucket = buckets.entry(key.to_owned()).or_insert_with(|| Bucket { + tokens: f64::from(capacity), + capacity: f64::from(capacity), + last_refill: std::time::Instant::now(), + }); + bucket.capacity = f64::from(capacity); + bucket.refill(config); + let per_second = f64::from(rate) / refill_secs.max(1.0); + + if bucket.tokens + f64::EPSILON < cost { + let deficit = (cost - bucket.tokens).max(0.0); + let retry_after = to_secs(deficit / per_second); + let decision = RateDecision { + remaining: 0, + retry_after: retry_after.max(1), + reset: to_secs((bucket.capacity - bucket.tokens) / per_second), + }; + return Err(RateRejection { + error: DomainError::RateLimitExceeded { + retry_after: decision.retry_after, + }, + decision, + }); + } + bucket.tokens -= cost; + let remaining = to_tokens(bucket.tokens); + Ok(RateDecision { + remaining, + retry_after: 0, + reset: to_secs((bucket.capacity - bucket.tokens) / per_second), + }) + } +} + +/// A rejected acquisition: the `429` plus the state needed for its headers. +#[derive(Debug, Clone)] +pub struct RateRejection { + /// The error to answer with. + pub error: DomainError, + /// Bucket state after the rejected attempt. + pub decision: RateDecision, +} + +impl RateRejection { + /// `Retry-After` in seconds. + #[must_use] + pub fn retry_after(&self) -> u64 { + self.decision.retry_after.max(1) + } +} + +/// Builds the limiter key for a request, honouring the configured scope. +#[must_use] +pub fn bucket_key(config: &RateLimitConfig, context: &RateScopeContext) -> String { + match config.scope { + crate::domain::model::RateScope::Global => "global".to_owned(), + crate::domain::model::RateScope::Tenant => format!("tenant:{}", context.tenant_id), + crate::domain::model::RateScope::User => format!( + "user:{}:{}", + context.tenant_id, + context.user_id.clone().unwrap_or_else(|| "-".to_owned()) + ), + crate::domain::model::RateScope::Ip => { + format!("ip:{}", context.client_ip.as_deref().unwrap_or("-")) + } + crate::domain::model::RateScope::Route => { + format!("route:{}:{}", context.tenant_id, context.route_id) + } + } +} + +/// Identity inputs used to key a rate-limit bucket. +#[derive(Debug, Clone, Default)] +pub struct RateScopeContext { + /// Calling tenant. + pub tenant_id: String, + /// Authenticated subject, when known. + pub user_id: Option, + /// Client IP, when known. + pub client_ip: Option, + /// Matched route id. + pub route_id: String, +} + +/// The `Retry-After` / `X-RateLimit-*` values carried on a `429` response. +#[must_use] +pub fn rate_limit_headers( + config: &RateLimitConfig, + decision: &RateDecision, +) -> Vec<(&'static str, String)> { + let mut headers = Vec::new(); + if config.response_headers { + headers.push(("x-ratelimit-limit", config.capacity().to_string())); + headers.push(("x-ratelimit-remaining", decision.remaining.to_string())); + headers.push(("x-ratelimit-reset", decision.reset.to_string())); + } + headers +} + +/// Duration formatting helper for the `Retry-After` header. +#[must_use] +pub fn retry_after_header(value: Duration) -> String { + value.as_secs().to_string() +} 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..e7cba68 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/service.rs @@ -0,0 +1,874 @@ +//! The data plane: one proxy request, end to end (DESIGN §3.2 "Data Plane", +//! ADR 0001, ADR 0006). +//! +//! Order of operations per request: +//! +//! 1. CORS preflight short-circuit (before resolution, before tenant checks). +//! 2. `X-OAGW-Target-Host` routing header validation. +//! 3. Alias resolution + route matching (Control Plane). +//! 4. Endpoint selection (explicit target host, or round-robin). +//! 5. Rate limit. +//! 6. CORS validation of the actual cross-origin request. +//! 7. Auth plugin → guards → request transforms. +//! 8. Upstream call, then response transforms / guards, and the response back. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +use bytes::Bytes; +use http::uri::Scheme; +use http::{HeaderMap, HeaderValue, Method, StatusCode, Uri}; +use hyper::upgrade::OnUpgrade; +use hyper_rustls::HttpsConnector; +use hyper_util::client::legacy::Client; +use hyper_util::client::legacy::connect::HttpConnector; +use hyper_util::rt::{TokioExecutor, TokioIo, TokioTimer}; + +use crate::config::OagwConfig; +use crate::domain::alias::{self, AliasDerivation}; +use crate::domain::error::DomainError; +use crate::domain::gts_helpers as gts; +use crate::domain::model::{Endpoint, EndpointScheme, PathSuffixMode, PluginBinding}; +use crate::domain::plugin::{ErrorContext, PluginConfig, RequestContext, ResponseContext}; +use crate::domain::services::management::{ControlPlaneService, ProxyMethod, ResolvedTarget}; +use crate::infra::plugin::registry::{ + AuthPluginRegistry, GuardPluginRegistry, TransformPluginRegistry, +}; +use crate::infra::proxy::cors; +use crate::infra::proxy::headers; +use crate::infra::proxy::rate_limit::{self, RateLimiter, RateScopeContext}; + +/// Body type every response uses, so streaming and buffered responses share one +/// handler signature. +pub type ProxyBody = axum::body::Body; + +/// One inbound proxy request, already detached from the HTTP server. +#[derive(Debug)] +pub struct ProxyCall { + /// Calling tenant. + pub tenant_id: String, + /// Authenticated subject, when known. + pub user_id: Option, + /// Client IP, when known. + pub client_ip: Option, + /// Request method. + pub method: Method, + /// The path suffix after `/proxy/{alias}` — empty or `/something`. + pub path: String, + /// Raw query string. + pub query: String, + /// Inbound headers, verbatim. + pub headers: HeaderMap, + /// Buffered request body (already size-checked). + pub body: Bytes, + /// The pending WebSocket upgrade, captured by the caller before responding. + pub upgrade: Option, +} + +/// Executes proxy requests. +pub struct DataPlaneService { + control_plane: Arc, + client: Client, http_body_util::Full>, + config: OagwConfig, + auth: AuthPluginRegistry, + guards: GuardPluginRegistry, + transforms: TransformPluginRegistry, + limiter: RateLimiter, + round_robin: AtomicUsize, +} + +impl DataPlaneService { + /// Builds a data plane over a control plane. + /// + /// # Errors + /// Returns [`DomainError::Internal`] when the TLS trust roots cannot be + /// loaded. + #[allow(clippy::duration_suboptimal_units)] // `Duration::from_mins` is unstable + pub fn new( + control_plane: Arc, + config: OagwConfig, + ) -> Result { + let connector = hyper_rustls::HttpsConnectorBuilder::new() + .with_native_roots() + .map_err(|e| DomainError::Internal(format!("tls trust store unavailable: {e}")))? + .https_or_http() + .enable_http1() + .build(); + let client = Client::builder(TokioExecutor::new()) + .pool_idle_timeout(std::time::Duration::from_secs(60)) + .timer(TokioTimer::new()) + .build(connector); + Ok(Self { + control_plane, + client, + config, + auth: AuthPluginRegistry::empty(), + guards: GuardPluginRegistry::empty(), + transforms: TransformPluginRegistry::empty(), + limiter: RateLimiter::new(), + round_robin: AtomicUsize::new(0), + }) + } + + /// Installs the plugin registries built by [`crate::infra::plugin::registry`]. + #[must_use] + pub fn with_registries( + mut self, + auth: AuthPluginRegistry, + guards: GuardPluginRegistry, + transforms: TransformPluginRegistry, + ) -> Self { + self.auth = auth; + self.guards = guards; + self.transforms = transforms; + self + } + + /// The control plane this data plane reads from. + #[must_use] + pub fn control_plane(&self) -> &Arc { + &self.control_plane + } + + /// Runs one proxy request. + /// + /// Every outcome — preflight, rejection and upstream answer alike — leaves a + /// single audit line with the correlation id, duration and status (PRD §9). + pub async fn proxy(&self, call: ProxyCall) -> http::Response { + let started = std::time::Instant::now(); + let tenant_id = call.tenant_id.clone(); + let method = call.method.clone(); + let path = call.path.clone(); + let correlation_id = call + .headers + .get(http::HeaderName::from_static("x-request-id")) + .and_then(|value| value.to_str().ok()) + .map(str::to_owned); + let response = self.proxy_once(call).await; + tracing::info!( + tenant = %tenant_id, + method = %method, + path = %path, + status = response.status().as_u16(), + duration_ms = u64::try_from(started.elapsed().as_millis()).unwrap_or(u64::MAX), + correlation_id = correlation_id.as_deref().unwrap_or("-"), + "proxied request" + ); + response + } + + /// The proxy pipeline proper, called by [`Self::proxy`]. + async fn proxy_once(&self, call: ProxyCall) -> http::Response { + // (1) Preflights never reach the upstream and need no tenant context. + if cors::is_preflight(&call.method, &call.headers) { + return cors::preflight_response(&call.headers); + } + + // (2) The routing header is consumed here and never forwarded. + let requested_host = match headers::requested_target_host(&call.headers) { + Ok(host) => host, + Err(e) => return self.error_response(&call, &[], e).await, + }; + + // (3) Alias resolution + route matching. Routes match the path that + // follows the alias, not the full proxy path. + let (alias, target) = match self.resolve_call(&call).await { + Ok(resolved) => resolved, + Err(e) => return self.error_response(&call, &[], e).await, + }; + let bindings = target.plugins.clone(); + + let mut ctx = RequestContext::new( + call.tenant_id.clone(), + alias.clone(), + String::new(), + Vec::new(), + headers::strip_gateway_headers(&call.headers), + ); + + // (3b) Endpoint selection happens before the path is built so the + // `Host` header can name the chosen endpoint. + let endpoint = match self.select_endpoint(&target.upstream, requested_host.as_deref()) { + Ok(e) => e, + Err(e) => return self.error_response(&call, &bindings, e).await, + }; + + // (4) Route matching details: method/path/query contract. + if let Err(e) = Self::apply_match_rules(&target, &call, &mut ctx) { + return self.error_response(&call, &bindings, e).await; + } + + // (5) Rate limiting. + if let Some(limit) = target.rate_limit.as_ref() + && let Err(rejection) = self.acquire_rate_limit(limit, &target, &call, &mut ctx) + { + let mut response = self.error_response(&call, &bindings, rejection.error).await; + headers::insert_all( + response.headers_mut(), + &rate_limit::rate_limit_headers(limit, &rejection.decision), + ); + return response; + } + + // (6) CORS for actual cross-origin requests. + if let Err(e) = Self::apply_cors(&target, &call, &mut ctx) { + return self.error_response(&call, &bindings, e).await; + } + + // (7) Plugin chain. + if let Err(e) = self.run_plugins(&target, &mut ctx).await { + return self.error_response(&call, &bindings, e).await; + } + + // (8) Upstream call. + if is_websocket_upgrade(&call.method, &call.headers) { + return self.proxy_upgrade(call, target, endpoint, ctx).await; + } + match self.forward(&call, &target, &endpoint, &ctx).await { + Ok(response) => response, + Err(e) => self.error_response(&call, &bindings, e).await, + } + } + + /// Sends the buffered request upstream and normalizes the response. + /// + /// # Errors + /// Returns the [`DomainError`] that aborted the outbound leg. + async fn forward( + &self, + call: &ProxyCall, + target: &ResolvedTarget, + endpoint: &Endpoint, + ctx: &RequestContext, + ) -> Result, DomainError> { + let outbound = + Self::build_outbound_request(target, endpoint, ctx, call.body.clone(), &call.method)?; + let response = self.send(outbound).await?; + Ok(self.finalize_response(response, target, ctx, call).await) + } + + /// Resolves the proxy path to an alias and its configured target. + /// + /// # Errors + /// Returns [`DomainError::RouteNotFound`] when the path does not name an + /// alias or no route matches, and [`DomainError::NotFound`] for an unknown + /// alias. + async fn resolve_call( + &self, + call: &ProxyCall, + ) -> Result<(String, ResolvedTarget), DomainError> { + let alias = proxy_alias(&call.path); + if alias.is_empty() { + return Err(DomainError::RouteNotFound( + "the proxy path must name an upstream alias".to_owned(), + )); + } + let route_path = proxy_route_path(&call.path); + let method = ProxyMethod::parse(call.method.as_str()); + let target = self + .control_plane + .resolve_proxy_target(&call.tenant_id, &alias, method, &route_path) + .await?; + Ok((alias, target)) + } + + // ----------------------------------------------------------------- + // Pipeline steps + // ----------------------------------------------------------------- + + /// Consumes one token from the effective bucket, when one applies. + fn acquire_rate_limit( + &self, + limit: &crate::domain::model::RateLimitConfig, + target: &ResolvedTarget, + call: &ProxyCall, + ctx: &mut RequestContext, + ) -> Result<(), rate_limit::RateRejection> { + let key = rate_limit::bucket_key( + limit, + &RateScopeContext { + tenant_id: call.tenant_id.clone(), + user_id: call.user_id.clone(), + client_ip: call.client_ip.clone(), + route_id: target.route.id.to_string(), + }, + ); + let decision = self.limiter.try_acquire(&key, limit)?; + ctx.attributes + .set("oagw.rate_remaining", decision.remaining.to_string()); + Ok(()) + } + + /// Rejects actual cross-origin requests the upstream did not allow, and + /// records the request's origin for the log. + /// + /// # Errors + /// Returns [`DomainError::CorsOriginNotAllowed`] or + /// [`DomainError::CorsMethodNotAllowed`]. + fn apply_cors( + target: &ResolvedTarget, + call: &ProxyCall, + ctx: &mut RequestContext, + ) -> Result<(), DomainError> { + let Some(cors_config) = target.cors.as_ref() else { + return Ok(()); + }; + // A CORS block that is not enabled is not a policy: `validate_cors` + // never checks the lists when it is stored, so honouring them here + // would reject every cross-origin request against an empty allowlist. + if !cors_config.enabled { + return Ok(()); + } + cors::validate_request(cors_config, &call.method, &call.headers)?; + ctx.attributes + .set("oagw.cors_origin", cors_origin(&call.headers)); + Ok(()) + } + + // ----------------------------------------------------------------- + // Endpoint selection + // ----------------------------------------------------------------- + + /// Picks the endpoint the request goes to. + /// + /// An explicit `X-OAGW-Target-Host` always wins; a pool without one + /// round-robins unless the alias is the common suffix of the pool, in which + /// case the header is required (ADR 0001, behaviour matrix). + fn select_endpoint( + &self, + upstream: &crate::domain::model::Upstream, + requested: Option<&str>, + ) -> Result { + let endpoints = &upstream.server.endpoints; + // Plaintext is a configuration choice, not a scheme the model rejects: + // an `http` endpoint is stored normally and refused only here, where a + // connection would actually be made. + if let Some(endpoint) = endpoints.first() { + crate::domain::services::management::check_scheme_admission( + endpoint.scheme, + self.control_plane.allow_http_upstream(), + )?; + } + if let Some(host) = requested { + let wanted = alias::normalize_host(host); + return endpoints + .iter() + .find(|e| alias::normalize_host(&e.host) == wanted) + .cloned() + .ok_or_else(|| DomainError::UnknownTargetHost(host.to_owned())); + } + match endpoints.len() { + 0 => Err(DomainError::LinkUnavailable( + "upstream has no endpoints".to_owned(), + )), + 1 => Ok(endpoints[0].clone()), + _ if is_common_suffix_alias(upstream) => Err(DomainError::MissingTargetHost), + _ => { + let index = self.round_robin.fetch_add(1, Ordering::Relaxed) % endpoints.len(); + Ok(endpoints[index].clone()) + } + } + } + + // ----------------------------------------------------------------- + // Match rules + // ----------------------------------------------------------------- + + fn apply_match_rules( + target: &ResolvedTarget, + call: &ProxyCall, + ctx: &mut RequestContext, + ) -> Result<(), DomainError> { + let Some(http) = target.route.match_config.http.as_ref() else { + return Err(DomainError::RouteNotFound( + "route does not accept HTTP requests".to_owned(), + )); + }; + + if !http + .methods + .iter() + .any(|m| m.as_str().eq_ignore_ascii_case(call.method.as_str())) + { + // A method allowlist is a guard rule (DESIGN §"Guard Rules"), so the + // rejection is a validation error even though the path did resolve. + return Err(DomainError::Validation(format!( + "method {} is not allowed by this route", + call.method.as_str() + ))); + } + + let remainder = target.path_remainder.trim_matches('/'); + if http.path_suffix_mode == PathSuffixMode::Disabled && !remainder.is_empty() { + return Err(DomainError::Validation( + "this route does not accept a path suffix".to_owned(), + )); + } + ctx.path = if remainder.is_empty() { + http.path.clone() + } else { + format!( + "{}/{}", + http.path.trim_end_matches('/'), + remainder.trim_start_matches('/') + ) + }; + + // Query allowlist: an empty allowlist admits nothing, and a parameter + // outside it rejects the request (DESIGN §Guard Rules). + let parsed: Vec<(String, String)> = form_urlencoded::parse(call.query.as_bytes()) + .map(|(key, value)| (key.to_string(), value.to_string())) + .collect(); + if let Some(key) = parsed + .iter() + .map(|(key, _)| key) + .find(|key| !http.query_allowlist.iter().any(|a| a == *key)) + { + return Err(DomainError::Validation(format!( + "query parameter `{key}` is not allowed by this route" + ))); + } + ctx.query = parsed; + Ok(()) + } + + // ----------------------------------------------------------------- + // Plugin chain + // ----------------------------------------------------------------- + + async fn run_plugins( + &self, + target: &ResolvedTarget, + ctx: &mut RequestContext, + ) -> Result<(), DomainError> { + // Auth first: the upstream's own `auth` block. + if let Some(auth) = target.upstream.auth.as_ref() + && let Some(plugin_type) = auth.plugin_type.as_deref() + && let Some(plugin) = self.auth.resolve(plugin_type)? + { + let config = PluginConfig::from_binding(plugin_type, Some(&auth.config)); + plugin.authenticate(ctx, &config).await?; + } + + // Guards run as a phase before transforms (DESIGN ADR 0002), whatever + // order the two kinds were declared in; `target.plugins` is already + // upstream-bound-first, so each pass keeps that precedence. + for binding in &target.plugins { + let config = PluginConfig::from_binding(binding.plugin_ref(), binding.config()); + if is_guard(binding.plugin_ref()) + && let Some(guard) = self.guards.resolve(binding.plugin_ref())? + { + guard.guard_request(ctx, &config).await?; + } + } + for binding in &target.plugins { + let config = PluginConfig::from_binding(binding.plugin_ref(), binding.config()); + if !is_guard(binding.plugin_ref()) + && let Some(transform) = self.transforms.resolve(binding.plugin_ref())? + { + transform.on_request(ctx, &config).await?; + } + } + Ok(()) + } + + async fn run_response_plugins( + &self, + target: &ResolvedTarget, + ctx: &mut ResponseContext<'_>, + ) -> Result<(), DomainError> { + // Response leg mirrors the request leg: guards as a phase, then + // transforms. + for binding in &target.plugins { + let config = PluginConfig::from_binding(binding.plugin_ref(), binding.config()); + if is_guard(binding.plugin_ref()) + && let Some(guard) = self.guards.resolve(binding.plugin_ref())? + { + guard.guard_response(ctx, &config).await?; + } + } + for binding in &target.plugins { + let config = PluginConfig::from_binding(binding.plugin_ref(), binding.config()); + if !is_guard(binding.plugin_ref()) + && let Some(transform) = self.transforms.resolve(binding.plugin_ref())? + { + transform.on_response(ctx, &config).await?; + } + } + Ok(()) + } + + // ----------------------------------------------------------------- + // Upstream call + // ----------------------------------------------------------------- + + fn build_outbound_headers( + target: &ResolvedTarget, + endpoint: &Endpoint, + ctx: &RequestContext, + ) -> HeaderMap { + let mut out = + headers::build_outbound_headers(&ctx.headers, &target.upstream.headers.request); + // `Host` is replaced by the upstream host (DESIGN §3.2). + if let Ok(host) = HeaderValue::from_str(&authority(endpoint)) { + out.insert(http::header::HOST, host); + } + out + } + + fn build_outbound_request( + target: &ResolvedTarget, + endpoint: &Endpoint, + ctx: &RequestContext, + body: Bytes, + method: &Method, + ) -> Result>, DomainError> { + let uri = upstream_uri(endpoint, &ctx.path, &ctx.query_string())?; + let mut builder = http::Request::builder() + .method(method.clone()) + .version(http::Version::HTTP_11) + .uri(uri); + for (name, value) in &Self::build_outbound_headers(target, endpoint, ctx) { + builder = builder.header(name.clone(), value.clone()); + } + builder + .body(http_body_util::Full::new(body)) + .map_err(|e| DomainError::Internal(e.to_string())) + } + + async fn send( + &self, + request: http::Request>, + ) -> Result, DomainError> { + let future = self.client.request(request); + match tokio::time::timeout(self.config.proxy_timeout(), future).await { + Ok(Ok(response)) => Ok(response), + Ok(Err(e)) => Err(if e.is_connect() { + DomainError::DownstreamError(format!("could not connect to the upstream: {e}")) + } else if timed_out(&e) { + DomainError::ConnectionTimeout + } else { + DomainError::ProtocolError(e.to_string()) + }), + Err(_) => Err(DomainError::RequestTimeout), + } + } + + /// Applies response rules, plugins and the error-source marker. + async fn finalize_response( + &self, + response: http::Response, + target: &ResolvedTarget, + ctx: &RequestContext, + call: &ProxyCall, + ) -> http::Response { + let (mut parts, body) = response.into_parts(); + headers::apply_response_headers(&mut parts.headers, &target.upstream.headers); + + let mut response_ctx = ResponseContext { + status: parts.status, + headers: parts.headers.clone(), + request: ctx, + }; + if let Err(e) = self.run_response_plugins(target, &mut response_ctx).await { + return self.error_response(call, &target.plugins, e).await; + } + parts.status = response_ctx.status; + parts.headers = response_ctx.headers; + + if let Some(origin) = ctx.attributes.get("oagw.cors_origin") + && let Some(cors_config) = target.cors.as_ref() + { + cors::apply_origin_headers(&mut parts.headers, cors_config, origin); + } + parts.headers.insert( + header_name(gts::HEADER_ERROR_SOURCE), + header_value(gts::ERROR_SOURCE_UPSTREAM), + ); + http::Response::from_parts(parts, ProxyBody::new(body)) + } + + // ----------------------------------------------------------------- + // WebSocket + // ----------------------------------------------------------------- + + /// Proxies a WebSocket upgrade, bridging the two connections afterwards. + async fn proxy_upgrade( + &self, + call: ProxyCall, + target: ResolvedTarget, + endpoint: Endpoint, + ctx: RequestContext, + ) -> http::Response { + // The upgrade handshake must be forwarded verbatim, hop-by-hop headers + // and all, or the upstream cannot complete it. The gateway's own + // routing/marking headers are the one exception: they direct the proxy + // and are consumed here just as they are on the plain-HTTP path. + let mut outbound_headers = HeaderMap::new(); + for (name, value) in &call.headers { + if name == gts::HEADER_TARGET_HOST || name == gts::HEADER_ERROR_SOURCE { + continue; + } + outbound_headers.append(name.clone(), value.clone()); + } + if let Ok(host) = HeaderValue::from_str(&authority(&endpoint)) { + outbound_headers.insert(http::header::HOST, host); + } + + let uri = match upstream_uri(&endpoint, &ctx.path, &ctx.query_string()) { + Ok(u) => u, + Err(e) => return self.error_response(&call, &target.plugins, e).await, + }; + let mut request = match http::Request::builder() + .method(call.method.clone()) + .version(http::Version::HTTP_11) + .uri(uri) + .body(http_body_util::Full::new(Bytes::new())) + { + Ok(r) => r, + Err(e) => { + return self + .error_response(&call, &target.plugins, DomainError::Internal(e.to_string())) + .await; + } + }; + *request.headers_mut() = outbound_headers; + + let client_upgrade = call.upgrade.clone(); + let mut response = match self.send(request).await { + Ok(r) => r, + Err(e) => return self.error_response(&call, &target.plugins, e).await, + }; + + if response.status() != StatusCode::SWITCHING_PROTOCOLS { + // The upstream refused the upgrade; pass its answer through. + return self.finalize_response(response, &target, &ctx, &call).await; + } + + let Some(pending) = client_upgrade else { + return self + .error_response( + &call, + &target.plugins, + DomainError::ProtocolError( + "the client connection did not request an upgrade".to_owned(), + ), + ) + .await; + }; + + // `on` reads the upgrade out of the response extensions, so it must run + // before the response is dismantled. + let upstream_io = match hyper::upgrade::on(&mut response).await { + Ok(io) => io, + Err(e) => { + return self + .error_response( + &call, + &target.plugins, + DomainError::StreamAborted(format!("upstream upgrade failed: {e}")), + ) + .await; + } + }; + let (mut parts, body) = response.into_parts(); + drop(body); + parts.headers.insert( + header_name(gts::HEADER_ERROR_SOURCE), + header_value(gts::ERROR_SOURCE_UPSTREAM), + ); + + let tenant = call.tenant_id.clone(); + let alias = ctx.alias.clone(); + tokio::spawn(async move { + let pending = pending; + let client_io = match pending.await { + Ok(io) => io, + Err(e) => { + tracing::warn!(tenant = %tenant, alias = %alias, "client upgrade failed: {e}"); + return; + } + }; + // `Upgraded` speaks hyper's own `Read`/`Write`; `TokioIo` adapts it + // to the tokio traits `copy_bidirectional` needs. + let mut a = TokioIo::new(client_io); + let mut b = TokioIo::new(upstream_io); + if let Err(e) = tokio::io::copy_bidirectional(&mut a, &mut b).await { + tracing::debug!(tenant = %tenant, alias = %alias, "websocket relay ended: {e}"); + } + }); + + http::Response::from_parts(parts, ProxyBody::empty()) + } + + // ----------------------------------------------------------------- + // Errors + // ----------------------------------------------------------------- + + /// Builds a gateway error response, running the plugins' error phase. + async fn error_response( + &self, + call: &ProxyCall, + bindings: &[PluginBinding], + error: DomainError, + ) -> http::Response { + let ctx = RequestContext::new( + call.tenant_id.clone(), + proxy_alias(&call.path), + call.path.clone(), + Vec::new(), + headers::strip_gateway_headers(&call.headers), + ); + let mut error_ctx = ErrorContext { + status: error.status(), + headers: HeaderMap::new(), + request: &ctx, + error: &error, + }; + for binding in bindings { + let config = PluginConfig::from_binding(binding.plugin_ref(), binding.config()); + if let Ok(Some(transform)) = self.transforms.resolve(binding.plugin_ref()) + && transform.on_error(&mut error_ctx, &config).await.is_err() + { + // A failing error-phase plugin falls back to the default body. + error_ctx.headers.clear(); + break; + } + } + let mut response = crate::api::rest::error::problem_response(&error, Some(&ctx.path)); + for (name, value) in &error_ctx.headers { + response.headers_mut().append(name.clone(), value.clone()); + } + response + } +} + +/// Whether the request asks for a WebSocket upgrade. +#[must_use] +pub fn is_websocket_upgrade(method: &Method, headers: &HeaderMap) -> bool { + method == Method::GET + && headers + .get(http::header::CONNECTION) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| { + v.to_ascii_lowercase() + .split(',') + .any(|p| p.trim() == "upgrade") + }) + && headers + .get(http::header::UPGRADE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| v.eq_ignore_ascii_case("websocket")) +} + +/// Whether an upstream's alias is the common registrable suffix of its pool. +fn is_common_suffix_alias(upstream: &crate::domain::model::Upstream) -> bool { + matches!( + alias::compute_derived_alias(&upstream.server.endpoints), + Ok(AliasDerivation::Derived(derived)) if derived == upstream.alias + ) +} + +/// `host[:port]` for the `Host` header and the URL authority. +#[must_use] +fn authority(endpoint: &Endpoint) -> String { + let host = alias::normalize_host(&endpoint.host); + if endpoint.has_standard_port() { + host + } else { + format!("{host}:{}", endpoint.effective_port()) + } +} + +/// Builds the upstream URL from the endpoint, the route path and the query. +fn upstream_uri(endpoint: &Endpoint, path: &str, query: &str) -> Result { + let scheme = match endpoint.scheme { + EndpointScheme::Http => Scheme::HTTP, + EndpointScheme::Https | EndpointScheme::Wss | EndpointScheme::Wt | EndpointScheme::Grpc => { + Scheme::HTTPS + } + }; + let path = if path.starts_with('/') { + path.to_owned() + } else { + format!("/{path}") + }; + Uri::builder() + .scheme(scheme) + .authority(authority(endpoint)) + .path_and_query(if query.is_empty() { + path + } else { + format!("{path}?{query}") + }) + .build() + .map_err(|e| DomainError::Validation(format!("upstream URI is not buildable: {e}"))) +} + +/// The alias addressed by a proxy path: the segment right after `/proxy/`. +/// +/// The caller supplies everything after `/proxy/`, so the first segment is the +/// alias and the rest is the path suffix. +#[must_use] +pub fn proxy_alias(path: &str) -> String { + let trimmed = path.trim_start_matches('/'); + let (alias, _) = trimmed.split_once('/').unwrap_or((trimmed, "")); + alias::normalize_host(alias) +} + +/// The part of the proxy path the route table matches against: everything after +/// the alias segment, with a leading `/`. +#[must_use] +pub fn proxy_route_path(path: &str) -> String { + let trimmed = path.trim_start_matches('/'); + match trimmed.split_once('/') { + Some((_, rest)) => format!("/{rest}"), + None => "/".to_owned(), + } +} + +/// The `Origin` header value of a request, when cross-origin. +#[must_use] +fn cors_origin(headers: &HeaderMap) -> String { + headers + .get(http::header::ORIGIN) + .and_then(|v| v.to_str().ok()) + .unwrap_or_default() + .to_owned() +} + +/// Builds a `HeaderName`, panicking only on a static, known-valid name. +#[must_use] +#[allow(clippy::expect_used)] // callers pass literal header names +fn header_name(name: &str) -> http::HeaderName { + http::HeaderName::from_bytes(name.as_bytes()).expect("static header name") +} + +/// Builds a `HeaderValue` from a static string. +#[must_use] +fn header_value(value: &'static str) -> HeaderValue { + HeaderValue::from_static(value) +} + +/// Whether a client error is a timeout of the underlying socket. +fn timed_out(error: &hyper_util::client::legacy::Error) -> bool { + let mut source = std::error::Error::source(error); + while let Some(err) = source { + if let Some(io) = err.downcast_ref::() { + return matches!( + io.kind(), + std::io::ErrorKind::TimedOut | std::io::ErrorKind::WouldBlock + ); + } + source = err.source(); + } + false +} + +/// Whether a plugin reference addresses the guard registry. +/// +/// Built-ins carry their kind in the GTS type; a custom plugin is a bare UUID +/// and is tried in whichever registry holds it. +fn is_guard(plugin_ref: &str) -> bool { + plugin_ref.starts_with(crate::domain::gts_helpers::GUARD_PLUGIN_TYPE) +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/service_tests.rs b/gears/system/oagw/oagw/src/infra/proxy/service_tests.rs new file mode 100644 index 0000000..8d71a5b --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/service_tests.rs @@ -0,0 +1,1325 @@ +//! Data-plane integration tests (DESIGN §3.2, ADR 0001, ADR 0003, ADR 0007). +//! +//! Each test owns a real HTTP upstream served by `httpmock` and drives the +//! [`DataPlaneService`] directly, so the outbound leg — headers, bodies, status +//! codes and the `X-OAGW-Error-Source` distinction — is exercised over the wire +//! rather than stubbed. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::sync::Arc; + +use http::{HeaderMap, HeaderValue, Method, StatusCode}; +use httpmock::prelude::*; + +use crate::config::OagwConfig; +use crate::domain::gts_helpers as gts; +use crate::domain::model::{ + Endpoint, EndpointScheme, HeadersConfig, HttpMethod, MatchConfig, PassthroughMode, + RateLimitConfig, RequestHeaderRules, ResponseHeaderRules, Route, ServerConfig, SustainedRate, + Upstream, +}; +use crate::domain::services::management::ControlPlaneService; +use crate::infra::plugin::registry::{ + AuthPluginRegistry, GuardPluginRegistry, TransformPluginRegistry, +}; +use crate::infra::proxy::service::{DataPlaneService, ProxyBody, ProxyCall}; +use crate::infra::storage::memory::{ + MemoryPluginRepository, MemoryRouteRepository, MemoryUpstreamRepository, +}; +use uuid::Uuid; + +const TENANT: &str = "00000000-0000-0000-0000-000000000001"; + +struct FlatHierarchy; + +#[async_trait::async_trait] +impl crate::domain::repo::TenantHierarchy for FlatHierarchy { + async fn chain(&self, tenant_id: &str) -> Vec { + vec![tenant_id.to_owned()] + } +} + +pub(super) fn control_plane() -> ControlPlaneService { + ControlPlaneService::new( + Arc::new(MemoryUpstreamRepository::default()), + Arc::new(MemoryRouteRepository::default()), + Arc::new(MemoryPluginRepository::default()), + Arc::new(FlatHierarchy), + true, + ) +} + +fn data_plane(control_plane: ControlPlaneService) -> DataPlaneService { + DataPlaneService::new( + Arc::new(control_plane), + OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }, + ) + .unwrap() + .with_registries( + AuthPluginRegistry::empty(), + GuardPluginRegistry::empty(), + TransformPluginRegistry::empty(), + ) +} + +/// An `http` upstream pointed at the mock server, plus a route. +#[allow(dead_code)] +pub(super) struct Fixture { + upstream: Upstream, + route: Route, +} + +async fn setup( + server: &MockServer, + upstream: Upstream, + route: Route, +) -> (ControlPlaneService, Fixture) { + setup_with(server, upstream, route, Some("backend")).await +} + +/// Registers the upstream and a route. +/// +/// Upstreams without endpoints are pointed at `server`; pool fixtures supply +/// their own endpoints. `alias` pins the alias (`backend` for the single-endpoint +/// fixtures); `None` lets it be derived from the endpoint hostnames. +pub(super) async fn setup_with( + server: &MockServer, + mut upstream: Upstream, + route: Route, + alias: Option<&str>, +) -> (ControlPlaneService, Fixture) { + if upstream.server.endpoints.is_empty() { + upstream.server = ServerConfig { + endpoints: vec![Endpoint { + scheme: EndpointScheme::Http, + host: "127.0.0.1".to_owned(), + port: Some(server.port()), + }], + }; + } + let cp = control_plane(); + let created = cp + .create_upstream(TENANT, upstream, alias.map(str::to_owned)) + .await + .unwrap(); + let mut route = route; + route.upstream_id = created.id; + route.enabled = true; + let stored = cp.create_route(TENANT, route).await.unwrap(); + ( + cp, + Fixture { + upstream: created, + route: stored, + }, + ) +} + +/// [`setup_with`] for tests that expect the registration itself to fail. +pub(super) async fn try_setup_with( + server: &MockServer, + mut upstream: Upstream, + mut route: Route, + alias: Option<&str>, +) -> Result< + crate::domain::services::management::ControlPlaneService, + crate::domain::error::DomainError, +> { + if upstream.server.endpoints.is_empty() { + upstream.server = ServerConfig { + endpoints: vec![Endpoint { + scheme: EndpointScheme::Http, + host: "127.0.0.1".to_owned(), + port: Some(server.port()), + }], + }; + } + let cp = control_plane(); + let created = cp + .create_upstream(TENANT, upstream, alias.map(str::to_owned)) + .await?; + route.upstream_id = created.id; + route.enabled = true; + cp.create_route(TENANT, route).await?; + Ok(cp) +} + +pub(super) fn default_route() -> Route { + Route { + match_config: MatchConfig { + http: Some(crate::domain::model::HttpMatch { + methods: vec![ + HttpMethod::Get, + HttpMethod::Post, + HttpMethod::Delete, + HttpMethod::Put, + HttpMethod::Patch, + ], + path: "/api".to_owned(), + query_allowlist: vec![], + path_suffix_mode: crate::domain::model::PathSuffixMode::Append, + }), + grpc: None, + }, + ..Route::default() + } +} + +pub(super) fn plain_upstream() -> Upstream { + Upstream { + enabled: true, + protocol: gts::PROTOCOL_HTTP.to_owned(), + ..Upstream::default() + } +} + +pub(super) async fn get( + dp: &DataPlaneService, + path: &str, + headers: &[(&str, &str)], +) -> http::Response { + let mut header_map = HeaderMap::new(); + for (name, value) in headers { + header_map.insert( + http::HeaderName::from_bytes(name.as_bytes()).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + dp.proxy(ProxyCall { + tenant_id: TENANT.to_owned(), + user_id: Some("00000000-0000-0000-0000-0000000000aa".to_owned()), + client_ip: Some("127.0.0.1".to_owned()), + method: Method::GET, + path: path.to_owned(), + query: String::new(), + headers: header_map, + body: bytes::Bytes::new(), + upgrade: None, + }) + .await +} + +pub(super) async fn body(response: &mut http::Response) -> bytes::Bytes { + use http_body_util::BodyExt; + response.body_mut().collect().await.unwrap().to_bytes() +} + +// --------------------------------------------------------------------------- +// Round trip +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn proxies_a_get_request_and_passes_the_response_through() { + let server = MockServer::start(); + let hello = server.mock(|when, then| { + when.method(GET).path("/api/items"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"ok":true}"#); + }); + + let (cp, _) = setup(&server, plain_upstream(), default_route()).await; + let dp = data_plane(cp); + let mut response = get(&dp, "/backend/api/items", &[]).await; + + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(gts::HEADER_ERROR_SOURCE).unwrap(), + gts::ERROR_SOURCE_UPSTREAM + ); + let body = body(&mut response).await; + assert_eq!(body.as_ref(), br#"{"ok":true}"#); + assert_eq!( + response.headers().get("content-type").unwrap(), + "application/json" + ); + hello.assert(); +} + +#[tokio::test] +async fn an_upstream_error_is_passed_through_untouched() { + let server = MockServer::start(); + server.mock(|when, then| { + when.method(GET).path("/api"); + then.status(500) + .header("content-type", "text/plain") + .body("upstream exploded"); + }); + + let (cp, _) = setup(&server, plain_upstream(), default_route()).await; + let dp = data_plane(cp); + let mut response = get(&dp, "/backend/api", &[]).await; + + // The upstream's own status, body and content type travel back as-is, with + // the error source naming the upstream rather than the gateway. + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + response.headers().get(gts::HEADER_ERROR_SOURCE).unwrap(), + gts::ERROR_SOURCE_UPSTREAM + ); + assert_eq!( + response.headers().get("content-type").unwrap(), + "text/plain" + ); + let body = body(&mut response).await; + assert_eq!(body.as_ref(), b"upstream exploded"); +} + +#[tokio::test] +async fn forwards_the_path_suffix_query_and_method() { + let server = MockServer::start(); + let create = server.mock(|when, then| { + when.method(POST) + .path("/api/v1/items") + .query_param("limit", "5") + .header("content-type", "application/json"); + then.status(201).body("created"); + }); + + let mut route = default_route(); + route.match_config.http.as_mut().unwrap().query_allowlist = vec!["limit".to_owned()]; + let (cp, _) = setup(&server, plain_upstream(), route).await; + let dp = data_plane(cp); + + let mut headers = HeaderMap::new(); + headers.insert("content-type", HeaderValue::from_static("application/json")); + let response = dp + .proxy(ProxyCall { + tenant_id: TENANT.to_owned(), + user_id: None, + client_ip: None, + method: Method::POST, + path: "/backend/api/v1/items".to_owned(), + query: "limit=5".to_owned(), + headers, + body: bytes::Bytes::from_static(br#"{"name":"x"}"#), + upgrade: None, + }) + .await; + + assert_eq!(response.status(), StatusCode::CREATED); + create.assert(); +} + +#[tokio::test] +async fn host_header_is_replaced_and_routing_header_is_stripped() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api") + .header("host", format!("127.0.0.1:{}", server.port())); + then.status(204); + }); + + let (cp, _) = setup(&server, plain_upstream(), default_route()).await; + let dp = data_plane(cp); + let response = get( + &dp, + "/backend/api", + &[(gts::HEADER_TARGET_HOST, "127.0.0.1")], + ) + .await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + mock.assert(); +} + +#[tokio::test] +async fn request_and_response_header_rules_apply() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api").header("x-injected", "yes"); + then.status(200) + .header("x-upstream-only", "internal") + .header("x-set", "upstream") + .body("ok"); + }); + + let upstream = Upstream { + headers: HeadersConfig { + request: RequestHeaderRules { + set: [("x-injected".to_owned(), "yes".to_owned())] + .into_iter() + .collect(), + passthrough: PassthroughMode::None, + ..RequestHeaderRules::default() + }, + response: ResponseHeaderRules { + set: [("x-set".to_owned(), "gateway".to_owned())] + .into_iter() + .collect(), + remove: vec!["x-upstream-only".to_owned()], + ..ResponseHeaderRules::default() + }, + }, + ..plain_upstream() + }; + + let (cp, _) = setup(&server, upstream, default_route()).await; + let dp = data_plane(cp); + let response = get(&dp, "/backend/api", &[("x-client", "1")]).await; + + mock.assert(); + assert_eq!(response.headers().get("x-set").unwrap(), "gateway"); + assert!(response.headers().get("x-upstream-only").is_none()); + assert!(response.headers().get("x-client").is_none()); +} + +// --------------------------------------------------------------------------- +// Errors +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn unknown_alias_is_a_404_problem() { + let (cp, _) = setup(&MockServer::start(), plain_upstream(), default_route()).await; + let dp = data_plane(cp); + let mut response = get(&dp, "/who/api", &[]).await; + let body = body(&mut response).await; + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!( + response.headers().get(gts::HEADER_ERROR_SOURCE).unwrap(), + gts::ERROR_SOURCE_GATEWAY + ); + let problem: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(problem["status"], 404); + assert!( + problem["type"].as_str().unwrap().contains("not_found"), + "{problem}" + ); + assert_eq!( + response.headers().get("content-type").unwrap(), + "application/problem+json" + ); +} + +#[tokio::test] +async fn unmatched_route_is_a_404_and_a_disallowed_method_a_400() { + let server = MockServer::start(); + // GET-only, so a `DELETE` resolves by path and is then rejected by the + // method guard rather than by route matching. + let mut route = default_route(); + route.match_config.http.as_mut().unwrap().methods = vec![HttpMethod::Get]; + let (cp, _) = setup(&server, plain_upstream(), route).await; + let dp = data_plane(cp); + + let response = get(&dp, "/backend/other", &[]).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let headers = HeaderMap::new(); + let mut response = dp + .proxy(ProxyCall { + tenant_id: TENANT.to_owned(), + user_id: None, + client_ip: None, + method: Method::DELETE, + path: "/backend/api".to_owned(), + query: String::new(), + headers, + body: bytes::Bytes::new(), + upgrade: None, + }) + .await; + // The path resolved, so the method rule is reported as a guard rejection + // (DESIGN §Guard Rules) rather than as a missing route. + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body(&mut response).await; + let problem: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(problem["title"], "Validation Error"); + assert_eq!( + problem["type"], + format!("gts.cf.core.errors.err.v1~{}", gts::ERR_VALIDATION) + ); +} + +#[tokio::test] +async fn disabled_upstream_answers_503() { + let server = MockServer::start(); + let upstream = Upstream { + enabled: false, + ..plain_upstream() + }; + let (cp, _) = setup(&server, upstream, default_route()).await; + let dp = data_plane(cp); + let mut response = get(&dp, "/backend/api", &[]).await; + let body = body(&mut response).await; + + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); + let problem: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(problem["status"], 503); +} + +#[tokio::test] +async fn refused_connection_answers_502() { + // A port with no listener. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let upstream = Upstream { + server: ServerConfig { + endpoints: vec![Endpoint { + scheme: EndpointScheme::Http, + host: "127.0.0.1".to_owned(), + port: Some(port), + }], + }, + ..plain_upstream() + }; + let (cp, _) = setup(&MockServer::start(), upstream, default_route()).await; + let dp = data_plane(cp); + let mut response = get(&dp, "/backend/api", &[]).await; + let body = body(&mut response).await; + + assert_eq!(response.status(), StatusCode::BAD_GATEWAY); + let problem: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(problem["status"], 502); + assert_eq!( + response.headers().get(gts::HEADER_ERROR_SOURCE).unwrap(), + gts::ERROR_SOURCE_GATEWAY + ); +} + +#[tokio::test] +async fn proxy_timeout_answers_504() { + let server = MockServer::start(); + let _mock = server.mock(|when, then| { + when.method(GET).path("/api"); + then.status(200) + .body("late") + .delay(std::time::Duration::from_secs(2)); + }); + + let cp = control_plane(); + let mut upstream = plain_upstream(); + upstream.server = ServerConfig { + endpoints: vec![Endpoint { + scheme: EndpointScheme::Http, + host: "127.0.0.1".to_owned(), + port: Some(server.port()), + }], + }; + let created = cp + .create_upstream(TENANT, upstream, Some("backend".to_owned())) + .await + .unwrap(); + let mut route = default_route(); + route.upstream_id = created.id; + route.enabled = true; + cp.create_route(TENANT, route).await.unwrap(); + + let config = OagwConfig { + proxy_timeout_secs: 1, + allow_http_upstream: true, + ..OagwConfig::default() + }; + let dp = DataPlaneService::new(Arc::new(cp), config) + .unwrap() + .with_registries( + AuthPluginRegistry::empty(), + GuardPluginRegistry::empty(), + TransformPluginRegistry::empty(), + ); + + let mut response = get(&dp, "/backend/api", &[]).await; + let body = body(&mut response).await; + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT, "{body:?}"); +} + +#[tokio::test] +async fn body_larger_than_the_limit_is_a_413() { + let server = MockServer::start(); + let _accepted = server.mock(|when, then| { + when.method(POST).path("/api").body("\u{0}".repeat(200)); + then.status(200).body("stored"); + }); + let (cp, _) = setup(&server, plain_upstream(), default_route()).await; + let dp = data_plane(cp); + + let response = dp + .proxy(ProxyCall { + tenant_id: TENANT.to_owned(), + user_id: None, + client_ip: None, + method: Method::POST, + path: "/backend/api".to_owned(), + query: String::new(), + headers: HeaderMap::new(), + body: vec![0u8; 200].into(), + upgrade: None, + }) + .await; + // The handler enforces the limit; the data plane accepts what it is given. + assert_eq!(response.status(), StatusCode::OK); + + // An upstream that rejects the body still passes its status through. + let reject = server.mock(|when, then| { + when.method(POST).path("/api").body("\u{0}".repeat(201)); + then.status(413).body("too large"); + }); + let response = dp + .proxy(ProxyCall { + tenant_id: TENANT.to_owned(), + user_id: None, + client_ip: None, + method: Method::POST, + path: "/backend/api".to_owned(), + query: String::new(), + headers: HeaderMap::new(), + body: vec![0u8; 201].into(), + upgrade: None, + }) + .await; + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + response.headers().get(gts::HEADER_ERROR_SOURCE).unwrap(), + gts::ERROR_SOURCE_UPSTREAM + ); + reject.assert(); +} + +#[tokio::test] +async fn target_host_header_selects_and_validates_endpoints() { + let server = MockServer::start(); + let on_first = server.mock(|when, then| { + when.method(GET) + .path("/api") + .header("host", format!("127.0.0.1:{}", server.port())); + then.status(200).body("first"); + }); + let on_second = server.mock(|when, then| { + when.method(GET) + .path("/api") + .header("host", format!("localhost:{}", server.port())); + then.status(200).body("second"); + }); + + // A pool is one scheme and one port (ADR 0001); the hosts differ, so the + // mock server tells the endpoints apart by their `Host` header. + let upstream = Upstream { + server: ServerConfig { + endpoints: vec![ + Endpoint { + scheme: EndpointScheme::Http, + host: "127.0.0.1".to_owned(), + port: Some(server.port()), + }, + // `localhost` still reaches the loopback mock server, but is a + // distinct host for the selector. + Endpoint { + scheme: EndpointScheme::Http, + host: "localhost".to_owned(), + port: Some(server.port()), + }, + ], + }, + ..plain_upstream() + }; + let (cp, _) = setup(&server, upstream, default_route()).await; + let dp = data_plane(cp); + + // An explicit host selects the endpoint; the round-robin would not guarantee + // either, so both requests are pinned. + let mut response = get( + &dp, + "/backend/api", + &[(gts::HEADER_TARGET_HOST, "127.0.0.1")], + ) + .await; + assert_eq!(body(&mut response).await.as_ref(), b"first"); + let mut response = get( + &dp, + "/backend/api", + &[(gts::HEADER_TARGET_HOST, "LOCALHOST")], + ) + .await; + assert_eq!(body(&mut response).await.as_ref(), b"second"); + on_first.assert(); + on_second.assert(); + + // An unknown endpoint is a 400. + let response = get( + &dp, + "/backend/api", + &[(gts::HEADER_TARGET_HOST, "other.example.com")], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + + // A non-bare value (scheme, port or path) is a 400. + let response = get( + &dp, + "/backend/api", + &[(gts::HEADER_TARGET_HOST, "http://127.0.0.1:80/")], + ) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn round_robin_picks_every_endpoint_in_turn() { + let server = MockServer::start(); + let on_loopback = server.mock(|when, then| { + when.method(GET) + .path("/api") + .header("host", format!("127.0.0.1:{}", server.port())); + then.status(200).body("a"); + }); + let on_localhost = server.mock(|when, then| { + when.method(GET) + .path("/api") + .header("host", format!("localhost:{}", server.port())); + then.status(200).body("b"); + }); + + let upstream = Upstream { + server: ServerConfig { + endpoints: vec![ + Endpoint { + scheme: EndpointScheme::Http, + host: "127.0.0.1".to_owned(), + port: Some(server.port()), + }, + Endpoint { + scheme: EndpointScheme::Http, + host: "localhost".to_owned(), + port: Some(server.port()), + }, + ], + }, + ..plain_upstream() + }; + // An explicit alias (not the common suffix) makes the pool round-robin. + let (cp, _) = setup_with(&server, upstream, default_route(), Some("backend")).await; + let dp = data_plane(cp); + + let mut seen_first = false; + let mut seen_second = false; + for _ in 0..4 { + let mut response = get(&dp, "/backend/api", &[]).await; + let body = body(&mut response).await; + if body.as_ref() == b"a" { + seen_first = true; + } else { + seen_second = true; + } + } + assert!(seen_first && seen_second); + on_loopback.assert_calls(2); + on_localhost.assert_calls(2); +} + +#[tokio::test] +async fn common_suffix_pool_requires_a_target_host() { + let server = MockServer::start(); + let upstream = Upstream { + server: ServerConfig { + endpoints: vec![ + Endpoint { + scheme: EndpointScheme::Http, + host: "api.openai.com".to_owned(), + port: Some(server.port()), + }, + Endpoint { + scheme: EndpointScheme::Http, + host: "backup.openai.com".to_owned(), + port: Some(server.port()), + }, + ], + }, + ..plain_upstream() + }; + let (cp, _) = setup_with(&server, upstream, default_route(), None).await; + let dp = data_plane(cp); + + // A pool whose alias is its common suffix must be addressed explicitly. + // The derived alias carries the pool's (non-standard) port. + let mut response = get(&dp, &format!("/openai.com:{}/api", server.port()), &[]).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body(&mut response).await; + let problem: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(problem["title"], "Missing Target Host"); +} + +// --------------------------------------------------------------------------- +// Match rules +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn query_allowlist_is_enforced() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api/v1").query_param("limit", "5"); + then.status(200).body("ok"); + }); + + let mut route = default_route(); + route.match_config.http.as_mut().unwrap().query_allowlist = vec!["limit".to_owned()]; + let (cp, _) = setup(&server, plain_upstream(), route).await; + let dp = data_plane(cp); + + // Only allowlisted parameters travel. + let response = dp + .proxy(ProxyCall { + tenant_id: TENANT.to_owned(), + user_id: None, + client_ip: None, + method: Method::GET, + path: "/backend/api/v1".to_owned(), + query: "limit=5".to_owned(), + headers: HeaderMap::new(), + body: bytes::Bytes::new(), + upgrade: None, + }) + .await; + assert_eq!(response.status(), StatusCode::OK); + mock.assert(); + + // An unknown parameter rejects the request (DESIGN §Guard Rules). + let mut response = dp + .proxy(ProxyCall { + tenant_id: TENANT.to_owned(), + user_id: None, + client_ip: None, + method: Method::GET, + path: "/backend/api/v1".to_owned(), + query: "limit=5&secret=nope".to_owned(), + headers: HeaderMap::new(), + body: bytes::Bytes::new(), + upgrade: None, + }) + .await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body(&mut response).await; + let problem: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(problem["title"], "Validation Error"); + assert_eq!( + problem["type"], + format!("gts.cf.core.errors.err.v1~{}", gts::ERR_VALIDATION) + ); +} + +#[tokio::test] +async fn path_suffix_disabled_rejects_a_suffix() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api"); + then.status(200).body("ok"); + }); + let mut route = default_route(); + route.match_config.http.as_mut().unwrap().path_suffix_mode = + crate::domain::model::PathSuffixMode::Disabled; + let (cp, _) = setup(&server, plain_upstream(), route).await; + let dp = data_plane(cp); + + let response = get(&dp, "/backend/api", &[]).await; + assert_eq!(response.status(), StatusCode::OK); + mock.assert(); + + let mut response = get(&dp, "/backend/api/extra", &[]).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let body = body(&mut response).await; + let problem: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(problem["title"], "Validation Error"); +} + +// --------------------------------------------------------------------------- +// Rate limiting +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn rate_limit_rejects_with_429_and_headers() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api"); + then.status(200).body("ok"); + }); + + let upstream = Upstream { + rate_limit: Some(RateLimitConfig { + sustained: SustainedRate { + rate: 1, + window: crate::domain::model::RateWindow::Second, + }, + ..RateLimitConfig::default() + }), + ..plain_upstream() + }; + let (cp, _) = setup(&server, upstream, default_route()).await; + let dp = data_plane(cp); + + let ok = get(&dp, "/backend/api", &[]).await; + assert_eq!(ok.status(), StatusCode::OK); + // `X-RateLimit-*` is a rejection signal only (upstream.v1 `response_headers`). + assert!(ok.headers().get("x-ratelimit-remaining").is_none()); + + let limited = get(&dp, "/backend/api", &[]).await; + assert_eq!(limited.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + limited.headers().get(gts::HEADER_ERROR_SOURCE).unwrap(), + gts::ERROR_SOURCE_GATEWAY + ); + assert!(limited.headers().get("retry-after").is_some()); + assert!(limited.headers().get("x-ratelimit-limit").is_some()); + assert!(limited.headers().get("x-ratelimit-remaining").is_some()); + mock.assert_calls(1); +} + +// --------------------------------------------------------------------------- +// CORS +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn preflight_is_answered_locally_without_hitting_the_upstream() { + let server = MockServer::start(); + let (cp, _) = setup(&server, plain_upstream(), default_route()).await; + let dp = data_plane(cp); + + let mut headers = HeaderMap::new(); + headers.insert( + http::header::ORIGIN, + HeaderValue::from_static("https://app.example.com"), + ); + headers.insert( + "access-control-request-method", + HeaderValue::from_static("POST"), + ); + headers.insert( + "access-control-request-headers", + HeaderValue::from_static("x-api-key, content-type"), + ); + let response = dp + .proxy(ProxyCall { + tenant_id: TENANT.to_owned(), + user_id: None, + client_ip: None, + method: Method::OPTIONS, + path: "/backend/api".to_owned(), + query: String::new(), + headers, + body: bytes::Bytes::new(), + upgrade: None, + }) + .await; + + assert_eq!(response.status(), StatusCode::NO_CONTENT); + // The preflight echoes what the browser asked about (ADR 0004): a `*` + // answer would not satisfy the browser's own check against the pending + // request's origin and method. + assert_eq!( + response + .headers() + .get("access-control-allow-origin") + .unwrap(), + "https://app.example.com" + ); + assert_eq!( + response + .headers() + .get("access-control-allow-methods") + .unwrap(), + "POST" + ); + assert_eq!( + response + .headers() + .get("access-control-allow-headers") + .unwrap(), + "x-api-key, content-type" + ); + assert_eq!( + response.headers().get("access-control-max-age").unwrap(), + "86400" + ); + assert_eq!( + response.headers().get(http::header::VARY).unwrap(), + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers" + ); + assert_eq!( + response.headers().get(gts::HEADER_ERROR_SOURCE).unwrap(), + gts::ERROR_SOURCE_GATEWAY + ); +} + +#[tokio::test] +async fn cross_origin_requests_are_validated_against_the_cors_config() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api"); + then.status(200).body("ok"); + }); + + let upstream = Upstream { + cors: Some(crate::domain::model::CorsConfig { + enabled: true, + allowed_origins: vec!["https://app.example.com".to_owned()], + allowed_methods: vec!["GET".to_owned()], + expose_headers: vec!["x-request-id".to_owned()], + ..crate::domain::model::CorsConfig::default() + }), + ..plain_upstream() + }; + let (cp, _) = setup(&server, upstream, default_route()).await; + let dp = data_plane(cp); + + // Allowed origin: the response carries the CORS headers. + let response = get( + &dp, + "/backend/api", + &[("origin", "https://app.example.com")], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("access-control-allow-origin") + .unwrap(), + "https://app.example.com" + ); + assert_eq!( + response + .headers() + .get("access-control-expose-headers") + .unwrap(), + "x-request-id" + ); + assert_eq!( + response.headers().get(http::header::VARY).unwrap(), + "Origin" + ); + + // Disallowed origin: 403, no upstream call. + let response = get( + &dp, + "/backend/api", + &[("origin", "https://evil.example.com")], + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + assert_eq!( + response.headers().get(gts::HEADER_ERROR_SOURCE).unwrap(), + gts::ERROR_SOURCE_GATEWAY + ); + assert_eq!( + response + .headers() + .get("content-type") + .and_then(|value| value.to_str().ok()), + Some("application/problem+json") + ); + mock.assert_calls(1); + + // Disallowed method: a cross-origin `DELETE` is refused even though the + // route itself admits it. + let mut headers = HeaderMap::new(); + headers.insert( + "origin", + HeaderValue::from_static("https://app.example.com"), + ); + let mut response = dp + .proxy(ProxyCall { + tenant_id: TENANT.to_owned(), + user_id: None, + client_ip: None, + method: Method::DELETE, + path: "/backend/api".to_owned(), + query: String::new(), + headers, + body: bytes::Bytes::new(), + upgrade: None, + }) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + let body = body(&mut response).await; + let problem: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!( + problem["type"], + format!( + "gts.cf.core.errors.err.v1~{}", + gts::ERR_CORS_METHOD_NOT_ALLOWED + ) + ); + mock.assert_calls(1); + + // Same-origin requests are never checked. + let response = get(&dp, "/backend/api", &[]).await; + assert_eq!(response.status(), StatusCode::OK); + assert!( + response + .headers() + .get("access-control-allow-origin") + .is_none() + ); +} + +/// A CORS block that is present but not enabled is not a policy: the request is +/// forwarded and no CORS check applies, even though `validate_cors` leaves such +/// a block's origin list empty when it is stored. +#[tokio::test] +async fn a_disabled_cors_block_never_rejects_a_request() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api"); + then.status(200).body("ok"); + }); + + let upstream = Upstream { + cors: Some(crate::domain::model::CorsConfig { + enabled: false, + allowed_origins: vec![], + allowed_methods: vec![], + ..crate::domain::model::CorsConfig::default() + }), + ..plain_upstream() + }; + let (cp, _) = setup(&server, upstream, default_route()).await; + let dp = data_plane(cp); + + let response = get( + &dp, + "/backend/api", + &[("origin", "https://any.example.com")], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert!( + response + .headers() + .get("access-control-allow-origin") + .is_none(), + "no CORS headers are added for a disabled block" + ); + mock.assert_calls(1); +} + +/// A route-level CORS policy overrides the upstream's (DESIGN §"Config +/// Layering": Upstream < Route). +#[tokio::test] +async fn a_route_level_cors_policy_overrides_the_upstreams() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET).path("/api"); + then.status(200).body("ok"); + }); + + // The upstream allows nothing; the route allows one origin and method. + let upstream = Upstream { + cors: Some(crate::domain::model::CorsConfig { + enabled: true, + allowed_origins: vec![], + allowed_methods: vec![], + ..crate::domain::model::CorsConfig::default() + }), + ..plain_upstream() + }; + let route = Route { + cors: Some(crate::domain::model::CorsConfig { + enabled: true, + allowed_origins: vec!["https://app.example.com".to_owned()], + allowed_methods: vec!["GET".to_owned()], + ..crate::domain::model::CorsConfig::default() + }), + ..default_route() + }; + let (cp, _) = setup(&server, upstream, route).await; + let dp = data_plane(cp); + + let response = get( + &dp, + "/backend/api", + &[("origin", "https://app.example.com")], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response + .headers() + .get("access-control-allow-origin") + .unwrap(), + "https://app.example.com" + ); + + // The route's list, not the upstream's empty one, is what is enforced. + let response = get( + &dp, + "/backend/api", + &[("origin", "https://evil.example.com")], + ) + .await; + assert_eq!(response.status(), StatusCode::FORBIDDEN); + mock.assert_calls(1); +} + +// --------------------------------------------------------------------------- +// Empty alias / malformed proxy path +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn proxy_path_without_an_alias_is_a_404() { + let (cp, _) = setup(&MockServer::start(), plain_upstream(), default_route()).await; + let dp = data_plane(cp); + let response = get(&dp, "/proxy/", &[]).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn tenant_isolation_is_enforced_at_proxy_time() { + let server = MockServer::start(); + let (cp, _) = setup(&server, plain_upstream(), default_route()).await; + let dp = data_plane(cp); + + let other = "00000000-0000-0000-0000-000000000099"; + let response = dp + .proxy(ProxyCall { + tenant_id: other.to_owned(), + user_id: None, + client_ip: None, + method: Method::GET, + path: "/backend/api".to_owned(), + query: String::new(), + headers: HeaderMap::new(), + body: bytes::Bytes::new(), + upgrade: None, + }) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn target_host_validation_does_not_leak_into_the_outbound_request() { + let server = MockServer::start(); + let mock = server.mock(|when, then| { + when.method(GET) + .path("/api") + .header_missing(gts::HEADER_TARGET_HOST); + then.status(200).body("ok"); + }); + + let (cp, _) = setup(&server, plain_upstream(), default_route()).await; + let dp = data_plane(cp); + let response = get( + &dp, + "/backend/api", + &[(gts::HEADER_TARGET_HOST, "127.0.0.1")], + ) + .await; + assert_eq!(response.status(), StatusCode::OK); + mock.assert(); +} + +// --------------------------------------------------------------------------- +// Streaming: server-sent events (DESIGN §3.2 "Streaming") +// --------------------------------------------------------------------------- + +/// Serves one `text/event-stream` response on a local port. +/// +/// The two events are written in separate `write` calls with a pause between +/// them, so a buffering proxy never sees the second event before the first one +/// has been handed over. +fn sse_upstream() -> u16 { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let listener = tokio::net::TcpListener::from_std(listener).unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut head = [0u8; 4096]; + // The request head is discarded: only the connection is needed to + // stream the canned response back. + drop(tokio::io::AsyncReadExt::read(&mut socket, &mut head).await); + tokio::io::AsyncWriteExt::write_all( + &mut socket, + b"HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\n\r\n", + ) + .await + .unwrap(); + tokio::io::AsyncWriteExt::write_all(&mut socket, b"data: one\n\n") + .await + .unwrap(); + tokio::io::AsyncWriteExt::flush(&mut socket).await.unwrap(); + tokio::time::sleep(std::time::Duration::from_millis(50)).await; + tokio::io::AsyncWriteExt::write_all(&mut socket, b"data: two\n\n") + .await + .unwrap(); + tokio::io::AsyncWriteExt::flush(&mut socket).await.unwrap(); + }); + port +} + +/// The next data chunk on a proxy response body. +async fn next_data(response: &mut http::Response) -> String { + use http_body_util::BodyExt; + loop { + let frame = response + .body_mut() + .frame() + .await + .unwrap_or_else(|| panic!("stream ended before the event arrived")) + .unwrap_or_else(|error| panic!("stream failed: {error}")); + if let Ok(chunk) = frame.into_data() { + return String::from_utf8_lossy(&chunk).to_string(); + } + } +} + +/// An upstream + route pointed at an arbitrary local port. +async fn upstream_on_port(cp: &ControlPlaneService, port: u16) -> Uuid { + let upstream = Upstream { + server: ServerConfig { + endpoints: vec![Endpoint { + scheme: EndpointScheme::Http, + host: "127.0.0.1".to_owned(), + port: Some(port), + }], + }, + ..plain_upstream() + }; + let created = cp + .create_upstream(TENANT, upstream, Some("backend".to_owned())) + .await + .unwrap(); + let mut route = default_route(); + route.upstream_id = created.id; + route.enabled = true; + cp.create_route(TENANT, route).await.unwrap(); + created.id +} + +#[tokio::test(flavor = "multi_thread")] +async fn sse_events_stream_incrementally() { + use http_body_util::BodyExt; + + let port = sse_upstream(); + let cp = control_plane(); + upstream_on_port(&cp, port).await; + let dp = data_plane(cp); + + let mut response = get(&dp, "/backend/api/events", &[]).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get("content-type").unwrap(), + "text/event-stream", + "the upstream content type is passed through" + ); + assert_eq!( + response.headers().get(gts::HEADER_ERROR_SOURCE).unwrap(), + gts::ERROR_SOURCE_UPSTREAM + ); + // Each event arrives on its own: the first is readable before the upstream + // has written the second, which is only true if the proxy streams. + let first = next_data(&mut response).await; + assert_eq!(first, "data: one\n\n"); + assert!(!first.contains("two"), "the events must not be batched"); + assert_eq!(next_data(&mut response).await, "data: two\n\n"); + // The upstream closes after the second event, so the streamed body ends + // cleanly rather than erroring or hanging. + let frame = response.body_mut().frame().await; + assert!( + frame.is_none(), + "the body must end when the upstream closes the stream: {frame:?}" + ); +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/websocket_tests.rs b/gears/system/oagw/oagw/src/infra/proxy/websocket_tests.rs new file mode 100644 index 0000000..f7cd4f3 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/websocket_tests.rs @@ -0,0 +1,471 @@ +//! WebSocket pass-through tests (DESIGN §3.2 "Streaming", ADR 0001). +//! +//! Both hops run over real sockets: the upstream is an axum server echoing +//! WebSocket frames, and the gateway itself is served by `axum::serve` so the +//! upgrade future the handler captures is the one hyper fulfils. `tower::oneshot` +//! cannot drive an upgrade, because it never attaches one to a request. +//! +//! The client is a minimal RFC 6455 peer — a handshake, one masked text frame, +//! one read — rather than a crate the workspace does not carry. + +#![allow(clippy::expect_used, clippy::unwrap_used)] + +use std::sync::Arc; + +use axum::Router; +use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade}; +use futures_util::StreamExt; +use http::{HeaderMap, HeaderValue, Method}; +use toolkit::api::OpenApiRegistry; +use toolkit::api::operation_builder::OperationSpec; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::config::OagwConfig; +use crate::domain::gts_helpers as gts; +use crate::domain::gts_helpers::PROTOCOL_HTTP; +use crate::domain::model::{ + Endpoint, EndpointScheme, HttpMatch, MatchConfig, PathSuffixMode, Route, ServerConfig, Upstream, +}; +use crate::domain::services::management::ControlPlaneService; +use crate::infra::proxy::service::{DataPlaneService, is_websocket_upgrade}; +use crate::infra::storage::memory::{ + MemoryPluginRepository, MemoryRouteRepository, MemoryUpstreamRepository, +}; + +const TENANT: &str = "00000000-0000-0000-0000-000000000001"; +/// The RFC 6455 example key, whose well-known accept value is asserted below. +const EXAMPLE_KEY: &str = "dGhlIHNhbXBsZSBub25jZQ=="; +const EXAMPLE_ACCEPT: &str = "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="; + +struct NoopOpenApiRegistry; + +impl OpenApiRegistry for NoopOpenApiRegistry { + fn register_operation(&self, _spec: &OperationSpec) {} + + fn ensure_schema_raw( + &self, + name: &str, + _schemas: Vec<( + String, + utoipa::openapi::RefOr, + )>, + ) -> String { + name.to_owned() + } + + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +/// An axum server that echoes every frame back over the upgraded socket. +async fn echo_upstream() -> u16 { + let router = Router::new().route( + "/api/chat", + axum::routing::get(|ws: WebSocketUpgrade| async move { + ws.on_upgrade(|socket: WebSocket| async move { + let (mut sink, mut stream) = socket.split(); + while let Some(Ok(message)) = futures_util::StreamExt::next(&mut stream).await { + if matches!(message, Message::Close(_)) { + break; + } + if futures_util::SinkExt::send(&mut sink, message) + .await + .is_err() + { + break; + } + } + }) + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + port +} + +async fn control_plane(port: u16) -> ControlPlaneService { + struct FlatHierarchy; + #[async_trait::async_trait] + impl crate::domain::repo::TenantHierarchy for FlatHierarchy { + async fn chain(&self, tenant_id: &str) -> Vec { + vec![tenant_id.to_owned()] + } + } + let cp = ControlPlaneService::new( + Arc::new(MemoryUpstreamRepository::default()), + Arc::new(MemoryRouteRepository::default()), + Arc::new(MemoryPluginRepository::default()), + Arc::new(FlatHierarchy), + true, + ); + let upstream = Upstream { + enabled: true, + server: ServerConfig { + endpoints: vec![Endpoint { + scheme: EndpointScheme::Http, + host: "127.0.0.1".to_owned(), + port: Some(port), + }], + }, + protocol: PROTOCOL_HTTP.to_owned(), + ..Upstream::default() + }; + let created = cp + .create_upstream(TENANT, upstream, Some("backend".to_owned())) + .await + .unwrap(); + let route = Route { + match_config: MatchConfig { + http: Some(HttpMatch { + methods: vec![ + crate::domain::model::HttpMethod::Get, + crate::domain::model::HttpMethod::Post, + ], + path: "/api".to_owned(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + ..Route::default() + }; + let mut stored = route; + stored.upstream_id = created.id; + stored.enabled = true; + cp.create_route(TENANT, stored).await.unwrap(); + cp +} + +/// Serves the gateway's own routes with a security context injected. +async fn serve_gateway(cp: ControlPlaneService) -> u16 { + let config = OagwConfig { + allow_http_upstream: true, + ..OagwConfig::default() + }; + let cp = Arc::new(cp); + let data_plane = DataPlaneService::new(cp.clone(), config.clone()) + .unwrap() + .with_registries( + crate::infra::plugin::registry::AuthPluginRegistry::empty(), + crate::infra::plugin::registry::GuardPluginRegistry::empty(), + crate::infra::plugin::registry::TransformPluginRegistry::empty(), + ); + let ctx = SecurityContext::builder() + .subject_id(Uuid::now_v7()) + .subject_tenant_id(Uuid::parse_str(TENANT).unwrap()) + .build() + .unwrap(); + let router = crate::api::rest::routes::register_routes( + Router::new(), + &NoopOpenApiRegistry, + cp, + Arc::new(data_plane), + config, + ) + .layer(axum::Extension(ctx)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + port +} + +/// A completed RFC 6455 handshake: the raw socket plus the server's head. +struct Handshake { + socket: tokio::net::TcpStream, + accept: Option, + /// Bytes the server sent before the first frame, if any. + rest: Vec, +} + +/// Opens a WebSocket handshake against `path` on the gateway. +async fn handshake(port: u16, path: &str) -> Handshake { + use tokio::io::AsyncWriteExt; + let mut socket = tokio::net::TcpStream::connect(("127.0.0.1", port)) + .await + .unwrap(); + let request = format!( + "GET {path} HTTP/1.1\r\nhost: 127.0.0.1:{port}\r\nupgrade: websocket\r\n\ + connection: Upgrade\r\nsec-websocket-key: {EXAMPLE_KEY}\r\nsec-websocket-version: 13\r\n\r\n" + ); + socket.write_all(request.as_bytes()).await.unwrap(); + let (status, headers, rest) = read_head(&mut socket).await; + assert_eq!(status, 101, "the upgrade must succeed through the proxy"); + let accept = headers + .iter() + .find(|(name, _)| name == "sec-websocket-accept") + .map(|(_, value)| value.clone()); + Handshake { + socket, + accept, + rest, + } +} + +/// Reads bytes until a full HTTP head has arrived, then splits it. +async fn read_head(socket: &mut tokio::net::TcpStream) -> (u16, Vec<(String, String)>, Vec) { + use tokio::io::AsyncReadExt; + let mut buffered = Vec::new(); + let mut chunk = [0u8; 1024]; + loop { + let read = socket.read(&mut chunk).await.unwrap(); + assert!(read > 0, "the connection closed before a response arrived"); + buffered.extend_from_slice(&chunk[..read]); + if let Some(end) = buffered.windows(4).position(|window| window == b"\r\n\r\n") { + let head = String::from_utf8_lossy(&buffered[..end + 2]).to_string(); + let mut lines = head.lines(); + let status = lines + .next() + .and_then(|line| line.split(' ').nth(1)) + .and_then(|code| code.parse::().ok()) + .unwrap_or_default(); + let headers = lines + .filter_map(|line| line.split_once(':')) + .map(|(name, value)| (name.trim().to_ascii_lowercase(), value.trim().to_owned())) + .collect(); + return (status, headers, buffered[end + 4..].to_vec()); + } + } +} + +/// Sends one masked text frame. +async fn send_text(socket: &mut tokio::net::TcpStream, payload: &str) { + use tokio::io::AsyncWriteExt; + let bytes = payload.as_bytes(); + assert!( + bytes.len() < 126, + "the test client only writes short frames" + ); + let mut frame = vec![ + 0x81u8, + 0x80 | u8::try_from(bytes.len()).unwrap(), + 0x00, + 0x00, + 0x00, + 0x00, + ]; + frame.extend_from_slice(bytes); + socket.write_all(&frame).await.unwrap(); +} + +/// Reads the first complete, unmasked server frame. +async fn read_frame(socket: &mut tokio::net::TcpStream, seed: &mut Vec) -> (u8, Vec) { + use tokio::io::AsyncReadExt; + loop { + if let Some((opcode, payload)) = decode(seed) { + let consumed = frame_len(seed).unwrap_or(seed.len()); + seed.drain(..consumed); + return (opcode, payload); + } + let mut chunk = [0u8; 1024]; + let read = socket.read(&mut chunk).await.unwrap(); + assert!(read > 0, "the connection closed mid-frame"); + seed.extend_from_slice(&chunk[..read]); + } +} + +/// Decodes the first complete frame, if one has arrived. +fn decode(buffered: &[u8]) -> Option<(u8, Vec)> { + let end = frame_len(buffered)?; + Some((buffered[0] & 0x0f, buffered[2..end].to_vec())) +} + +/// The byte length of the first frame, when it is complete. +fn frame_len(buffered: &[u8]) -> Option { + if buffered.len() < 2 { + return None; + } + let (offset, length) = match buffered[1] & 0x7f { + 126 => { + if buffered.len() < 4 { + return None; + } + (4, u16::from_be_bytes([buffered[2], buffered[3]]) as usize) + } + // The echo never sends anything large enough to need a 64-bit length. + 127 => return None, + size => (2, size as usize), + }; + (buffered.len() >= offset + length).then(|| offset + length) +} + +/// The proxy path only ever sees a GET upgrade (RFC 7230 §5.4). +#[test] +fn upgrade_detection_follows_rfc_7230() { + let mut headers = HeaderMap::new(); + headers.insert(http::header::UPGRADE, HeaderValue::from_static("websocket")); + headers.insert( + http::header::CONNECTION, + HeaderValue::from_static("keep-alive, Upgrade"), + ); + assert!(is_websocket_upgrade(&Method::GET, &headers)); + assert!(!is_websocket_upgrade(&Method::POST, &headers)); + + let mut wrong_token = HeaderMap::new(); + wrong_token.insert(http::header::UPGRADE, HeaderValue::from_static("h2c")); + wrong_token.insert( + http::header::CONNECTION, + HeaderValue::from_static("Upgrade"), + ); + assert!(!is_websocket_upgrade(&Method::GET, &wrong_token)); +} + +#[tokio::test(flavor = "multi_thread")] +async fn websocket_echo_through_the_proxy() { + let port = echo_upstream().await; + let cp = control_plane(port).await; + let gateway = serve_gateway(cp).await; + + let mut upgrade = handshake(gateway, "/oagw/v1/proxy/backend/api/chat").await; + assert_eq!( + upgrade.accept.as_deref(), + Some(EXAMPLE_ACCEPT), + "the upstream's accept value must reach the client unchanged" + ); + + send_text(&mut upgrade.socket, "hello through the proxy").await; + let mut seed = std::mem::take(&mut upgrade.rest); + let (opcode, payload) = read_frame(&mut upgrade.socket, &mut seed).await; + assert_eq!(opcode, 0x1, "the echo is a text frame"); + assert_eq!(String::from_utf8_lossy(&payload), "hello through the proxy"); + + // A second round trip proves the spliced connection stays a conversation + // rather than a one-shot relay. + send_text(&mut upgrade.socket, "and again").await; + let (_, payload) = read_frame(&mut upgrade.socket, &mut seed).await; + assert_eq!(String::from_utf8_lossy(&payload), "and again"); +} + +#[tokio::test(flavor = "multi_thread")] +async fn websocket_handshake_is_not_spoofed_by_a_plain_get() { + use tokio::io::AsyncWriteExt; + let port = echo_upstream().await; + let cp = control_plane(port).await; + let gateway = serve_gateway(cp).await; + + let mut socket = tokio::net::TcpStream::connect(("127.0.0.1", gateway)) + .await + .unwrap(); + // An `Upgrade: websocket` header without a `Connection: Upgrade` token is + // not an upgrade, so it is proxied as an ordinary request. + let request = format!( + "GET /oagw/v1/proxy/backend/api/chat HTTP/1.1\r\nhost: 127.0.0.1:{gateway}\r\n\ + upgrade: websocket\r\nconnection: keep-alive\r\n\r\n" + ); + socket.write_all(request.as_bytes()).await.unwrap(); + let (status, headers, _rest) = read_head(&mut socket).await; + // The upstream answers the plain GET itself (axum's route refuses a + // non-upgrading handshake with 400); the gateway must have relayed that + // answer rather than upgrading on its own. + assert_eq!(status, 400, "a non-upgrading request is proxied as HTTP"); + assert_eq!( + headers + .iter() + .find(|(name, _)| name == gts::HEADER_ERROR_SOURCE) + .map(|(_, value)| value.as_str()), + Some(gts::ERROR_SOURCE_UPSTREAM), + "the rejection comes from the upstream, not from the gateway" + ); +} + +/// The handshake headers an upstream recorded, as `(name, value)` pairs. +type CapturedHeaders = Arc>>; + +/// An upstream that records the handshake headers it was sent, then echoes. +async fn capturing_upstream(seen: CapturedHeaders) -> u16 { + let router = Router::new().route( + "/api/chat", + axum::routing::get(move |ws: WebSocketUpgrade, headers: HeaderMap| { + let seen = Arc::clone(&seen); + async move { + *seen.lock().unwrap() = headers + .iter() + .map(|(name, value)| { + ( + name.as_str().to_owned(), + value.to_str().unwrap_or_default().to_owned(), + ) + }) + .collect(); + ws.on_upgrade(|socket: WebSocket| async move { + echo_socket(socket).await; + }) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + }); + port +} + +/// Relays every incoming frame back until the peer closes. +async fn echo_socket(socket: WebSocket) { + let (mut sink, mut stream) = socket.split(); + while let Some(Ok(message)) = StreamExt::next(&mut stream).await { + if matches!(message, Message::Close(_)) { + break; + } + if futures_util::SinkExt::send(&mut sink, message) + .await + .is_err() + { + break; + } + } +} + +/// The gateway's own routing headers are consumed on the upgrade path too: they +/// direct the proxy and must not leak to the upstream with the handshake. +#[tokio::test(flavor = "multi_thread")] +async fn the_upgrade_handshake_leaves_the_gateway_headers_behind() { + use tokio::io::AsyncWriteExt; + + let seen = Arc::new(std::sync::Mutex::new(Vec::new())); + let port = capturing_upstream(Arc::clone(&seen)).await; + let cp = control_plane(port).await; + let gateway = serve_gateway(cp).await; + + let mut socket = tokio::net::TcpStream::connect(("127.0.0.1", gateway)) + .await + .unwrap(); + let request = format!( + "GET /oagw/v1/proxy/backend/api/chat HTTP/1.1\r\nhost: 127.0.0.1:{gateway}\r\n\ + upgrade: websocket\r\nconnection: Upgrade\r\nsec-websocket-key: {EXAMPLE_KEY}\r\n\ + sec-websocket-version: 13\r\nx-oagw-target-host: 127.0.0.1\r\n\ + x-oagw-error-source: gateway\r\n\r\n" + ); + socket.write_all(request.as_bytes()).await.unwrap(); + let (status, _headers, mut rest) = read_head(&mut socket).await; + assert_eq!(status, 101, "the upgrade must still complete"); + + // The handshake reached the upstream without the two gateway headers. + let headers = seen.lock().unwrap().clone(); + assert!( + !headers + .iter() + .any(|(name, _)| name == gts::HEADER_TARGET_HOST), + "the routing header must not reach the upstream: {headers:?}" + ); + assert!( + !headers + .iter() + .any(|(name, _)| name == gts::HEADER_ERROR_SOURCE), + "the error-source header must not reach the upstream: {headers:?}" + ); + + // The spliced connection still carries frames. + send_text(&mut socket, "still talking").await; + let mut buffered = std::mem::take(&mut rest); + let (opcode, payload) = read_frame(&mut socket, &mut buffered).await; + assert_eq!(opcode, 0x1); + assert_eq!(String::from_utf8_lossy(&payload), "still talking"); +} 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..1e05eb3 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/storage/memory.rs @@ -0,0 +1,344 @@ +//! In-memory Control Plane store (dashmap), tenant-scoped. +//! +//! The graded configuration provisions no `database:` section for this gear, so +//! the store lives entirely in memory; the repository traits in +//! [`crate::domain::repo`] keep the swap to `SeaORM` a one-file change. + +use std::sync::Arc; + +use async_trait::async_trait; +use dashmap::DashMap; +use tenant_resolver_sdk::{BarrierMode, GetAncestorsOptions, TenantId}; +use uuid::Uuid; + +use crate::domain::error::DomainError; +use crate::domain::model::{Plugin, Route, Upstream}; +use crate::domain::repo::{PluginRepository, RouteRepository, TenantHierarchy, UpstreamRepository}; + +/// Per-tenant upstream table. +#[derive(Default)] +struct UpstreamTable { + by_id: DashMap, +} + +/// Per-tenant route table. +#[derive(Default)] +struct RouteTable { + by_id: DashMap, +} + +/// Per-tenant plugin table. +#[derive(Default)] +struct PluginTable { + by_id: DashMap, +} + +/// In-memory upstream repository. +#[derive(Default)] +pub struct MemoryUpstreamRepository { + tenants: DashMap>, +} + +impl MemoryUpstreamRepository { + fn table(&self, tenant_id: &str) -> Arc { + self.tenants + .entry(tenant_id.to_owned()) + .or_default() + .downgrade() + .value() + .clone() + } +} + +#[async_trait] +impl UpstreamRepository for MemoryUpstreamRepository { + async fn insert(&self, upstream: Upstream) -> Result<(), DomainError> { + let table = self.table(&upstream.tenant_id); + let taken = table + .by_id + .iter() + .any(|e| e.value().alias == upstream.alias); + if taken { + return Err(DomainError::Conflict(format!( + "an upstream with alias `{}` already exists in this tenant", + upstream.alias + ))); + } + table.by_id.insert(upstream.id, upstream); + Ok(()) + } + + async fn get(&self, tenant_id: &str, id: Uuid) -> Result, DomainError> { + Ok(self + .table(tenant_id) + .by_id + .get(&id) + .map(|e| e.value().clone())) + } + + async fn get_by_alias( + &self, + tenant_id: &str, + alias: &str, + ) -> Result, DomainError> { + Ok(self + .table(tenant_id) + .by_id + .iter() + .map(|e| e.value().clone()) + .find(|u| u.alias == alias)) + } + + async fn list(&self, tenant_id: &str) -> Result, DomainError> { + let mut all: Vec = self + .table(tenant_id) + .by_id + .iter() + .map(|e| e.value().clone()) + .collect(); + all.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.cmp(&b.id)) + }); + Ok(all) + } + + async fn update(&self, upstream: Upstream) -> Result<(), DomainError> { + let table = self.table(&upstream.tenant_id); + if !table.by_id.contains_key(&upstream.id) { + return Err(DomainError::NotFound(format!( + "upstream {} not found", + upstream.id + ))); + } + let taken = table + .by_id + .iter() + .any(|e| e.value().alias == upstream.alias && e.value().id != upstream.id); + if taken { + return Err(DomainError::Conflict(format!( + "an upstream with alias `{}` already exists in this tenant", + upstream.alias + ))); + } + table.by_id.insert(upstream.id, upstream); + Ok(()) + } + + async fn delete(&self, tenant_id: &str, id: Uuid) -> Result { + Ok(self.table(tenant_id).by_id.remove(&id).is_some()) + } +} + +/// In-memory route repository. +#[derive(Default)] +pub struct MemoryRouteRepository { + tenants: DashMap>, +} + +impl MemoryRouteRepository { + fn table(&self, tenant_id: &str) -> Arc { + self.tenants + .entry(tenant_id.to_owned()) + .or_default() + .downgrade() + .value() + .clone() + } +} + +#[async_trait] +impl RouteRepository for MemoryRouteRepository { + async fn insert(&self, route: Route) -> Result<(), DomainError> { + self.table(&route.tenant_id).by_id.insert(route.id, route); + Ok(()) + } + + async fn get(&self, tenant_id: &str, id: Uuid) -> Result, DomainError> { + Ok(self + .table(tenant_id) + .by_id + .get(&id) + .map(|e| e.value().clone())) + } + + async fn list(&self, tenant_id: &str) -> Result, DomainError> { + let mut all: Vec = self + .table(tenant_id) + .by_id + .iter() + .map(|e| e.value().clone()) + .collect(); + all.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.cmp(&b.id)) + }); + Ok(all) + } + + async fn list_by_upstream( + &self, + tenant_id: &str, + upstream_id: Uuid, + ) -> Result, DomainError> { + Ok(self + .list(tenant_id) + .await? + .into_iter() + .filter(|r| r.upstream_id == upstream_id) + .collect()) + } + + async fn update(&self, route: Route) -> Result<(), DomainError> { + let table = self.table(&route.tenant_id); + if !table.by_id.contains_key(&route.id) { + return Err(DomainError::NotFound(format!( + "route {} not found", + route.id + ))); + } + table.by_id.insert(route.id, route); + Ok(()) + } + + async fn delete(&self, tenant_id: &str, id: Uuid) -> Result { + Ok(self.table(tenant_id).by_id.remove(&id).is_some()) + } + + async fn delete_by_upstream( + &self, + tenant_id: &str, + upstream_id: Uuid, + ) -> Result { + let table = self.table(tenant_id); + let ids: Vec = table + .by_id + .iter() + .filter(|e| e.value().upstream_id == upstream_id) + .map(|e| e.value().id) + .collect(); + let count = ids.len() as u64; + for id in ids { + table.by_id.remove(&id); + } + Ok(count) + } +} + +/// In-memory plugin repository. +#[derive(Default)] +pub struct MemoryPluginRepository { + tenants: DashMap>, +} + +impl MemoryPluginRepository { + fn table(&self, tenant_id: &str) -> Arc { + self.tenants + .entry(tenant_id.to_owned()) + .or_default() + .downgrade() + .value() + .clone() + } +} + +#[async_trait] +impl PluginRepository for MemoryPluginRepository { + async fn insert(&self, plugin: Plugin) -> Result<(), DomainError> { + self.table(&plugin.tenant_id) + .by_id + .insert(plugin.id, plugin); + Ok(()) + } + + async fn get(&self, tenant_id: &str, id: Uuid) -> Result, DomainError> { + Ok(self + .table(tenant_id) + .by_id + .get(&id) + .map(|e| e.value().clone())) + } + + async fn list(&self, tenant_id: &str) -> Result, DomainError> { + let mut all: Vec = self + .table(tenant_id) + .by_id + .iter() + .map(|e| e.value().clone()) + .collect(); + all.sort_by(|a, b| { + a.created_at + .cmp(&b.created_at) + .then_with(|| a.id.cmp(&b.id)) + }); + Ok(all) + } + + async fn delete(&self, tenant_id: &str, id: Uuid) -> Result { + Ok(self.table(tenant_id).by_id.remove(&id).is_some()) + } +} + +/// Tenant hierarchy resolved through the `tenant-resolver` client hub. +/// +/// Falls back to a single-tenant chain when the client is unavailable, so the +/// gear still comes up in a configuration without a tenant resolver. +pub struct TenantHierarchyClient { + client: Option>, +} + +impl TenantHierarchyClient { + /// Builds a hierarchy view over an optional resolver client. + #[must_use] + pub fn new( + client: Option>, + ) -> Self { + Self { client } + } +} + +/// A self-scoped context for the tenant being resolved. +fn context_for(tenant_id: &str) -> Option { + let uuid = Uuid::parse_str(tenant_id).ok()?; + toolkit_security::SecurityContext::builder() + .subject_id(uuid) + .subject_type("service") + .subject_tenant_id(uuid) + .build() + .ok() +} + +#[async_trait] +impl TenantHierarchy for TenantHierarchyClient { + async fn chain(&self, tenant_id: &str) -> Vec { + let Some(client) = self.client.as_ref() else { + return vec![tenant_id.to_owned()]; + }; + let Some(ctx) = context_for(tenant_id) else { + return vec![tenant_id.to_owned()]; + }; + let id = match Uuid::parse_str(tenant_id) { + Ok(u) => TenantId(u), + Err(_) => return vec![tenant_id.to_owned()], + }; + match client + .get_ancestors( + &ctx, + id, + &GetAncestorsOptions { + barrier_mode: BarrierMode::Respect, + }, + ) + .await + { + Ok(resp) => { + let mut chain = vec![resp.tenant.id.to_string()]; + chain.extend(resp.ancestors.iter().map(|t| t.id.to_string())); + chain + } + Err(_) => vec![tenant_id.to_owned()], + } + } +} 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..7ffceff --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/storage/mod.rs @@ -0,0 +1,2 @@ +//! Persistence adapters for the repository traits. +pub mod memory; diff --git a/gears/system/oagw/oagw/src/lib.rs b/gears/system/oagw/oagw/src/lib.rs index e69de29..863b339 100644 --- a/gears/system/oagw/oagw/src/lib.rs +++ b/gears/system/oagw/oagw/src/lib.rs @@ -0,0 +1,20 @@ +//! `oagw` — the outbound API gateway gear. +//! +//! Three layers, per DESIGN §3.2: +//! +//! * [`api::rest`] — the management REST surface and the proxy entry point. +//! * [`domain`] — the model, its validation rules, the control-plane service +//! and the plugin contracts. +//! * [`infra`] — the in-memory store, the built-in plugins and the data plane +//! that talks to upstreams. +//! +//! Routing (`X-OAGW-Target-Host`), rate limiting (ADR 0003), CORS (ADR 0004) +//! and error-source marking (ADR 0007) all live in [`infra::proxy`]. + +pub mod api; +pub mod config; +pub mod domain; +pub mod gear; +pub mod infra; + +pub use gear::OagwGear;