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..7d4c738 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/mod.rs @@ -0,0 +1,3 @@ +//! Transport layer. + +pub mod rest; diff --git a/gears/system/oagw/oagw/src/api/rest/dto.rs b/gears/system/oagw/oagw/src/api/rest/dto.rs new file mode 100644 index 0000000..17d2845 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/dto.rs @@ -0,0 +1,89 @@ +//! REST representations that are not simply the domain aggregate. + +use serde::Serialize; +use uuid::Uuid; + +use crate::domain::model::{PluginDef, PluginKind, PluginPhase}; + +/// Envelope returned by every list endpoint. +/// +/// `total` is the number of matches *before* `$top`/`$skip`, so a client can +/// page without a second request. +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub struct ListResponse { + #[schema(value_type = Vec)] + pub items: Vec, + pub total: usize, +} + +impl ListResponse { + #[must_use] + pub fn new(items: Vec, total: usize) -> Self { + Self { items, total } + } +} + +/// A custom plugin definition, without its source. +/// +/// The source is served separately by `GET /oagw/v1/plugins/{id}/source` so a +/// listing never carries potentially large script bodies. +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub struct PluginResponse { + pub id: Uuid, + pub tenant_id: Uuid, + /// Anonymous GTS identifier, e.g. `gts.cf.core.oagw.guard_plugin.v1~{uuid}`. + pub gts_id: String, + pub plugin_type: PluginKind, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub description: Option, + pub phases: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + #[schema(value_type = Object)] + pub config_schema: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub last_used_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub gc_eligible_at: Option, +} + +impl From<&PluginDef> for PluginResponse { + fn from(plugin: &PluginDef) -> Self { + Self { + id: plugin.id, + tenant_id: plugin.tenant_id, + gts_id: plugin.gts_id(), + plugin_type: plugin.plugin_type, + name: plugin.name.clone(), + description: plugin.description.clone(), + phases: plugin.phases.clone(), + config_schema: plugin.config_schema.clone(), + last_used_at: plugin.last_used_at, + gc_eligible_at: plugin.gc_eligible_at, + } + } +} + +/// Body of `GET /oagw/v1/plugins/{id}/source`. +#[derive(Debug, Clone, Serialize, utoipa::ToSchema)] +pub struct PluginSourceResponse { + pub id: Uuid, + pub gts_id: String, + pub name: String, + pub source_code: String, +} + +impl From<&PluginDef> for PluginSourceResponse { + fn from(plugin: &PluginDef) -> Self { + Self { + id: plugin.id, + gts_id: plugin.gts_id(), + name: plugin.name.clone(), + source_code: plugin.source_code.clone(), + } + } +} + +impl toolkit::api::api_dto::ResponseApiDto for ListResponse {} +impl toolkit::api::api_dto::ResponseApiDto for PluginResponse {} +impl toolkit::api::api_dto::ResponseApiDto for PluginSourceResponse {} 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..4357fcd --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/error.rs @@ -0,0 +1,108 @@ +//! Rendering of gateway errors onto the wire. + +use axum::response::{IntoResponse, Response}; +use http::{HeaderName, HeaderValue, StatusCode, header}; + +use crate::domain::error::{ + ERROR_SOURCE_GATEWAY, ERROR_SOURCE_UPSTREAM, OagwError, PROBLEM_JSON, +}; + +/// `X-OAGW-Error-Source` as a header name. +pub const ERROR_SOURCE: HeaderName = HeaderName::from_static("x-oagw-error-source"); + +/// Render `error` as RFC 9457 problem details anchored at `instance`. +#[must_use] +pub fn problem_response(error: &OagwError, instance: Option<&str>) -> Response { + let status = + StatusCode::from_u16(error.status()).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let problem = error.to_problem(instance); + let body = serde_json::to_vec(&problem).unwrap_or_else(|_| { + // Serializing a problem document cannot realistically fail; if it + // does, still answer with a valid problem body rather than an empty + // one. + br#"{"type":"about:blank","title":"Internal Error","status":500,"detail":"error serialization failed"}"# + .to_vec() + }); + + let mut response = ( + status, + [(header::CONTENT_TYPE, HeaderValue::from_static(PROBLEM_JSON))], + body, + ) + .into_response(); + + mark_gateway(&mut response); + for (name, value) in &error.headers { + if let (Ok(name), Ok(value)) = ( + HeaderName::try_from(name.to_ascii_lowercase()), + HeaderValue::from_str(value), + ) { + response.headers_mut().insert(name, value); + } + } + if let Some(seconds) = error.retry_after_seconds { + response + .headers_mut() + .insert(header::RETRY_AFTER, HeaderValue::from(seconds)); + } + if error.kind.retriable() { + response.headers_mut().insert( + HeaderName::from_static("x-oagw-retriable"), + HeaderValue::from_static("true"), + ); + } + response +} + +/// Stamp a response as gateway-originated. +pub fn mark_gateway(response: &mut Response) { + response + .headers_mut() + .insert(ERROR_SOURCE, HeaderValue::from_static(ERROR_SOURCE_GATEWAY)); +} + +/// Stamp a response as relayed from the upstream. +pub fn mark_upstream(response: &mut Response) { + response + .headers_mut() + .insert(ERROR_SOURCE, HeaderValue::from_static(ERROR_SOURCE_UPSTREAM)); +} + +/// Wrapper that carries the request URI so `instance` can be filled in. +#[derive(Debug)] +pub struct ApiError { + pub inner: OagwError, + pub instance: Option, +} + +impl ApiError { + #[must_use] + pub fn new(inner: OagwError, instance: impl Into) -> Self { + Self { + inner, + instance: Some(instance.into()), + } + } +} + +impl From for ApiError { + fn from(inner: OagwError) -> Self { + Self { + inner, + instance: None, + } + } +} + +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + problem_response(&self.inner, self.instance.as_deref()) + } +} + +/// Result alias for the management handlers. +pub type ApiResult = Result; + +#[cfg(test)] +#[path = "error_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/api/rest/error_tests.rs b/gears/system/oagw/oagw/src/api/rest/error_tests.rs new file mode 100644 index 0000000..9ae993c --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/error_tests.rs @@ -0,0 +1,72 @@ +//! How a gateway error reaches the wire. + +use super::*; +use crate::domain::error::ErrorKind; +use axum::body::to_bytes; + +async fn body_json(response: Response) -> serde_json::Value { + let bytes = to_bytes(response.into_body(), 64 * 1024).await.unwrap(); + serde_json::from_slice(&bytes).unwrap() +} + +#[tokio::test] +async fn a_problem_response_is_rfc_9457_and_marked_gateway() { + let error = OagwError::new(ErrorKind::RouteNotFound, "no route here"); + let response = problem_response(&error, Some("/oagw/v1/proxy/x/y")); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + assert_eq!( + response.headers().get(header::CONTENT_TYPE).unwrap(), + "application/problem+json" + ); + assert_eq!(response.headers().get(ERROR_SOURCE).unwrap(), "gateway"); + + let json = body_json(response).await; + assert_eq!(json["status"], 404); + assert_eq!(json["instance"], "/oagw/v1/proxy/x/y"); +} + +#[tokio::test] +async fn a_retriable_error_advertises_retry_after_and_the_retriable_marker() { + let error = OagwError::new(ErrorKind::RateLimitExceeded, "slow down").with_retry_after(30); + let response = problem_response(&error, None); + + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + assert_eq!(response.headers().get(header::RETRY_AFTER).unwrap(), "30"); + assert_eq!(response.headers().get("x-oagw-retriable").unwrap(), "true"); +} + +#[tokio::test] +async fn a_non_retriable_error_carries_no_retriable_marker() { + let response = problem_response(&OagwError::validation("bad"), None); + assert!(response.headers().get("x-oagw-retriable").is_none()); + assert!(response.headers().get(header::RETRY_AFTER).is_none()); +} + +#[tokio::test] +async fn attached_headers_reach_the_response() { + let error = OagwError::new(ErrorKind::RateLimitExceeded, "slow down") + .with_header("X-RateLimit-Limit", "100") + .with_header("X-RateLimit-Remaining", "0"); + let response = problem_response(&error, None); + assert_eq!(response.headers().get("x-ratelimit-limit").unwrap(), "100"); + assert_eq!(response.headers().get("x-ratelimit-remaining").unwrap(), "0"); +} + +#[tokio::test] +async fn an_api_error_renders_through_into_response() { + let response = ApiError::new(OagwError::conflict("taken"), "/oagw/v1/upstreams") + .into_response(); + assert_eq!(response.status(), StatusCode::CONFLICT); + let json = body_json(response).await; + assert_eq!(json["instance"], "/oagw/v1/upstreams"); +} + +#[test] +fn responses_can_be_marked_as_relayed_from_the_upstream() { + let mut response = StatusCode::OK.into_response(); + mark_upstream(&mut response); + assert_eq!(response.headers().get(ERROR_SOURCE).unwrap(), "upstream"); + mark_gateway(&mut response); + assert_eq!(response.headers().get(ERROR_SOURCE).unwrap(), "gateway"); +} diff --git a/gears/system/oagw/oagw/src/api/rest/handlers.rs b/gears/system/oagw/oagw/src/api/rest/handlers.rs new file mode 100644 index 0000000..0df06af --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers.rs @@ -0,0 +1,540 @@ +//! Management API handlers (Control Plane). +//! +//! Bodies are deserialized by hand rather than through `Json` so a malformed +//! or schema-violating payload answers `400 ValidationError` in the gateway's +//! own problem format, not axum's default rejection. + +use std::sync::Arc; + +use axum::body::Bytes; +use axum::extract::{Extension, Path}; +use axum::response::{IntoResponse, Response}; +use http::{StatusCode, Uri, header}; +use serde::de::DeserializeOwned; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::{ErrorKind, OagwError, OagwResult}; +use crate::domain::gts; +use crate::domain::input::{PluginInput, RouteInput, UpstreamInput}; +use crate::domain::model::PluginKind; + +use super::dto::{ListResponse, PluginResponse, PluginSourceResponse}; +use super::error::{ApiError, mark_gateway, problem_response}; +use super::query::ListParams; +use super::state::{OagwState, actions}; + +type Handler = Result; + +// --------------------------------------------------------------------------- +// Upstreams +// --------------------------------------------------------------------------- + +/// `POST /oagw/v1/upstreams` +/// +/// # Errors +/// +/// `400` on validation failure, `403` when the policy denies, `409` on alias +/// conflict. +pub async fn create_upstream( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, + body: Bytes, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + state + .authorize(&ctx, gts::UPSTREAM_BASE, actions::CREATE) + .await?; + let input: UpstreamInput = parse_body(&body)?; + let upstream = state + .control + .create_upstream(ctx.subject_tenant_id(), input)?; + created(&upstream, &at, &upstream.id) + }; + finish(run.await, &at) +} + +/// `GET /oagw/v1/upstreams` +/// +/// # Errors +/// +/// `400` on a malformed query parameter, `403` when the policy denies. +pub async fn list_upstreams( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + state + .authorize(&ctx, gts::UPSTREAM_BASE, actions::READ) + .await?; + let params = ListParams::from_query(uri.query().unwrap_or_default())?; + let items = state.control.list_upstreams(ctx.subject_tenant_id()); + list_response(&state, ¶ms, &items) + }; + finish(run.await, &at) +} + +/// `GET /oagw/v1/upstreams/{id}` +/// +/// # Errors +/// +/// `400` on a malformed id, `403` when the policy denies, `404` when the +/// upstream is not the caller's. +pub async fn get_upstream( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, + Path(raw_id): Path, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + state + .authorize(&ctx, gts::UPSTREAM_BASE, actions::READ) + .await?; + let id = resource_id(&raw_id, gts::UPSTREAM_BASE, "upstream")?; + state + .control + .get_upstream(ctx.subject_tenant_id(), id) + .map(|upstream| json_response(StatusCode::OK, &upstream)) + .unwrap_or_else(|| Err(OagwError::not_found("upstream not found"))) + }; + finish(run.await, &at) +} + +/// `PUT /oagw/v1/upstreams/{id}` +/// +/// # Errors +/// +/// `400` on validation failure, `403` when the policy denies, `404` when the +/// upstream is not the caller's, `409` on alias conflict. +pub async fn replace_upstream( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, + Path(raw_id): Path, + body: Bytes, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + state + .authorize(&ctx, gts::UPSTREAM_BASE, actions::OVERRIDE) + .await?; + let id = resource_id(&raw_id, gts::UPSTREAM_BASE, "upstream")?; + let input: UpstreamInput = parse_body(&body)?; + let upstream = state + .control + .replace_upstream(ctx.subject_tenant_id(), id, input)?; + json_response(StatusCode::OK, &upstream) + }; + finish(run.await, &at) +} + +/// `DELETE /oagw/v1/upstreams/{id}` +/// +/// # Errors +/// +/// `400` on a malformed id, `403` when the policy denies, `404` when the +/// upstream is not the caller's. +pub async fn delete_upstream( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, + Path(raw_id): Path, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + state + .authorize(&ctx, gts::UPSTREAM_BASE, actions::DELETE) + .await?; + let id = resource_id(&raw_id, gts::UPSTREAM_BASE, "upstream")?; + state.control.delete_upstream(ctx.subject_tenant_id(), id)?; + state.sweep_unlinked_plugins(); + Ok(no_content()) + }; + finish(run.await, &at) +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// `POST /oagw/v1/routes` +/// +/// # Errors +/// +/// `400` on validation failure, `403` when the policy denies, `409` on a +/// duplicate match rule. +pub async fn create_route( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, + body: Bytes, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + state + .authorize(&ctx, gts::ROUTE_BASE, actions::CREATE) + .await?; + let input: RouteInput = parse_body(&body)?; + let route = state.control.create_route(ctx.subject_tenant_id(), input)?; + created(&route, &at, &route.id) + }; + finish(run.await, &at) +} + +/// `GET /oagw/v1/routes` +/// +/// # Errors +/// +/// `400` on a malformed query parameter, `403` when the policy denies. +pub async fn list_routes( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + state.authorize(&ctx, gts::ROUTE_BASE, actions::READ).await?; + let params = ListParams::from_query(uri.query().unwrap_or_default())?; + let items = state.control.list_routes(ctx.subject_tenant_id()); + list_response(&state, ¶ms, &items) + }; + finish(run.await, &at) +} + +/// `GET /oagw/v1/routes/{id}` +/// +/// # Errors +/// +/// `400` on a malformed id, `403` when the policy denies, `404` when the route +/// is not the caller's. +pub async fn get_route( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, + Path(raw_id): Path, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + state.authorize(&ctx, gts::ROUTE_BASE, actions::READ).await?; + let id = resource_id(&raw_id, gts::ROUTE_BASE, "route")?; + state + .control + .get_route(ctx.subject_tenant_id(), id) + .map(|route| json_response(StatusCode::OK, &route)) + .unwrap_or_else(|| Err(OagwError::not_found("route not found"))) + }; + finish(run.await, &at) +} + +/// `PUT /oagw/v1/routes/{id}` +/// +/// # Errors +/// +/// `400` on validation failure, `403` when the policy denies, `404` when the +/// route is not the caller's, `409` on a duplicate match rule. +pub async fn replace_route( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, + Path(raw_id): Path, + body: Bytes, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + state + .authorize(&ctx, gts::ROUTE_BASE, actions::OVERRIDE) + .await?; + let id = resource_id(&raw_id, gts::ROUTE_BASE, "route")?; + let input: RouteInput = parse_body(&body)?; + let route = state + .control + .replace_route(ctx.subject_tenant_id(), id, input)?; + json_response(StatusCode::OK, &route) + }; + finish(run.await, &at) +} + +/// `DELETE /oagw/v1/routes/{id}` +/// +/// # Errors +/// +/// `400` on a malformed id, `403` when the policy denies, `404` when the route +/// is not the caller's. +pub async fn delete_route( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, + Path(raw_id): Path, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + state + .authorize(&ctx, gts::ROUTE_BASE, actions::DELETE) + .await?; + let id = resource_id(&raw_id, gts::ROUTE_BASE, "route")?; + state.control.delete_route(ctx.subject_tenant_id(), id)?; + state.sweep_unlinked_plugins(); + Ok(no_content()) + }; + finish(run.await, &at) +} + +// --------------------------------------------------------------------------- +// Plugins +// --------------------------------------------------------------------------- + +/// `POST /oagw/v1/plugins` +/// +/// # Errors +/// +/// `400` on validation failure, `403` when the policy denies, `409` when the +/// name is taken. +pub async fn create_plugin( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, + body: Bytes, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + let input: PluginInput = parse_body(&body)?; + state + .authorize( + &ctx, + OagwState::plugin_resource(input.plugin_type), + actions::CREATE, + ) + .await?; + let plugin = state.control.create_plugin(ctx.subject_tenant_id(), input)?; + created(&PluginResponse::from(&plugin), &at, &plugin.id) + }; + finish(run.await, &at) +} + +/// `GET /oagw/v1/plugins` +/// +/// # Errors +/// +/// `400` on a malformed query parameter, `403` when the policy denies. +pub async fn list_plugins( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + state + .authorize(&ctx, gts::GUARD_PLUGIN_BASE, actions::READ) + .await?; + state.sweep_unlinked_plugins(); + let params = ListParams::from_query(uri.query().unwrap_or_default())?; + let items: Vec = state + .control + .list_plugins(ctx.subject_tenant_id()) + .iter() + .map(PluginResponse::from) + .collect(); + list_response(&state, ¶ms, &items) + }; + finish(run.await, &at) +} + +/// `GET /oagw/v1/plugins/{id}` +/// +/// # Errors +/// +/// `400` on a malformed id, `403` when the policy denies, `404` when the +/// plugin is not the caller's. +pub async fn get_plugin( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, + Path(raw_id): Path, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + let (id, kind) = plugin_id(&raw_id)?; + state + .authorize( + &ctx, + OagwState::plugin_resource(kind.unwrap_or(PluginKind::Guard)), + actions::READ, + ) + .await?; + state + .control + .get_plugin(ctx.subject_tenant_id(), id) + .map(|plugin| json_response(StatusCode::OK, &PluginResponse::from(&plugin))) + .unwrap_or_else(|| Err(OagwError::not_found("plugin not found"))) + }; + finish(run.await, &at) +} + +/// `GET /oagw/v1/plugins/{id}/source` +/// +/// # Errors +/// +/// `400` on a malformed id, `403` when the policy denies, `404` when the +/// plugin is not the caller's. +pub async fn get_plugin_source( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, + Path(raw_id): Path, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + let (id, kind) = plugin_id(&raw_id)?; + state + .authorize( + &ctx, + OagwState::plugin_resource(kind.unwrap_or(PluginKind::Guard)), + actions::READ, + ) + .await?; + state + .control + .get_plugin(ctx.subject_tenant_id(), id) + .map(|plugin| json_response(StatusCode::OK, &PluginSourceResponse::from(&plugin))) + .unwrap_or_else(|| Err(OagwError::not_found("plugin not found"))) + }; + finish(run.await, &at) +} + +/// `DELETE /oagw/v1/plugins/{id}` +/// +/// # Errors +/// +/// `400` on a malformed id, `403` when the policy denies, `404` when the +/// plugin is not the caller's, `409` when it is still referenced. +pub async fn delete_plugin( + uri: Uri, + Extension(ctx): Extension, + Extension(state): Extension>, + Path(raw_id): Path, +) -> Handler { + let at = uri.path().to_owned(); + let run = async { + let (id, kind) = plugin_id(&raw_id)?; + state + .authorize( + &ctx, + OagwState::plugin_resource(kind.unwrap_or(PluginKind::Guard)), + actions::DELETE, + ) + .await?; + let references = state.store.references_to_plugin(id); + state + .control + .delete_plugin(ctx.subject_tenant_id(), id, references)?; + Ok(no_content()) + }; + finish(run.await, &at) +} + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +fn finish(result: OagwResult, instance: &str) -> Handler { + match result { + Ok(response) => Ok(response), + Err(error) => Err(ApiError::new(error, instance)), + } +} + +/// Deserialize a request body, mapping every failure to `400 ValidationError`. +fn parse_body(body: &Bytes) -> OagwResult { + if body.is_empty() { + return Err(OagwError::validation("a JSON request body is required")); + } + serde_json::from_slice(body) + .map_err(|err| OagwError::validation(format!("invalid request body: {err}"))) +} + +/// Resolve a path parameter that may be a bare UUID or an anonymous GTS id. +fn resource_id(raw: &str, base: &'static str, label: &str) -> OagwResult { + gts::parse_resource_id(raw, Some(base)).ok_or_else(|| { + OagwError::validation(format!( + "'{raw}' is not a valid {label} identifier (expected a UUID or '{base}{{uuid}}')" + )) + }) +} + +fn plugin_id(raw: &str) -> OagwResult<(Uuid, Option)> { + let (base, id) = gts::parse_plugin_id(raw).ok_or_else(|| { + OagwError::validation(format!( + "'{raw}' is not a valid plugin identifier (expected a UUID or \ + 'gts.cf.core.oagw.{{type}}_plugin.v1~{{uuid}}')" + )) + })?; + Ok((id, base.and_then(PluginKind::from_base_type))) +} + +fn json_response(status: StatusCode, value: &T) -> OagwResult { + let body = serde_json::to_vec(value) + .map_err(|err| OagwError::internal(format!("response serialization failed: {err}")))?; + let mut response = ( + status, + [(header::CONTENT_TYPE, "application/json")], + body, + ) + .into_response(); + mark_gateway(&mut response); + Ok(response) +} + +fn created(value: &T, at: &str, id: &Uuid) -> OagwResult { + let mut response = json_response(StatusCode::CREATED, value)?; + let location = format!("{}/{id}", at.trim_end_matches('/')); + if let Ok(value) = http::HeaderValue::from_str(&location) { + response.headers_mut().insert(header::LOCATION, value); + } + Ok(response) +} + +fn no_content() -> Response { + let mut response = StatusCode::NO_CONTENT.into_response(); + mark_gateway(&mut response); + response +} + +fn list_response( + state: &OagwState, + params: &ListParams, + items: &[T], +) -> OagwResult { + let values: Vec = items + .iter() + .map(serde_json::to_value) + .collect::>() + .map_err(|err| OagwError::internal(format!("response serialization failed: {err}")))?; + let default_top = state.config.clamp_page_size(None); + let (page, total) = params.apply(values, default_top); + json_response(StatusCode::OK, &ListResponse::new(page, total)) +} + +/// Render a gateway error outside a handler's `Result` chain. +#[must_use] +pub fn render_error(error: &OagwError, instance: &str) -> Response { + problem_response(error, Some(instance)) +} + +/// The gear's error catalog is also its `Unsupported media type` surface: a +/// body that is not JSON never reaches a handler, so this is the single place +/// that shape is decided. +#[must_use] +pub fn unsupported_media_type(instance: &str) -> Response { + render_error( + &OagwError::new( + ErrorKind::ValidationError, + "request body must be application/json", + ), + instance, + ) +} 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..e3cfef6 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/mod.rs @@ -0,0 +1,12 @@ +//! REST transport for the management and proxy APIs. + +pub mod dto; +pub mod error; +pub mod handlers; +pub mod proxy; +pub mod query; +pub mod routes; +pub mod state; + +pub use routes::{BASE, register_routes}; +pub use state::OagwState; diff --git a/gears/system/oagw/oagw/src/api/rest/proxy.rs b/gears/system/oagw/oagw/src/api/rest/proxy.rs new file mode 100644 index 0000000..02bd930 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/proxy.rs @@ -0,0 +1,319 @@ +//! Proxy API handler (Data Plane transport). +//! +//! `{METHOD} /oagw/v1/proxy/{alias}[/{path_suffix}][?{query}]` +//! +//! The handler owns three shapes of answer: a streamed response, a buffered +//! one, and a `101` that hands the connection over to a byte splice. + +use std::collections::HashMap; +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Instant; + +use axum::body::{Body, Bytes}; +use axum::extract::{ConnectInfo, Extension, Path, Request}; +use axum::response::{IntoResponse, Response}; +use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header}; +use hyper_util::rt::TokioIo; +use toolkit_security::SecurityContext; +use tracing::{debug, warn}; + +use crate::domain::error::{ErrorKind, OagwError}; +use crate::domain::gts; +use crate::infra::proxy::service::{IncomingRequest, ProxyOutcome, audit_log}; +use crate::infra::proxy::websocket; + +use super::error::{mark_gateway, mark_upstream, problem_response}; +use super::state::{OagwState, actions}; + +/// How long a browser may cache a preflight result. +const PREFLIGHT_MAX_AGE: &str = "86400"; + +/// `{METHOD} /oagw/v1/proxy/{alias}[/{*path}]` +pub async fn proxy( + Extension(ctx): Extension, + Extension(state): Extension>, + Path(params): Path>, + request: Request, +) -> Response { + let started = Instant::now(); + let (mut parts, body) = request.into_parts(); + let instance = parts + .uri + .path_and_query() + .map_or_else(|| parts.uri.path().to_owned(), ToString::to_string); + + // A browser preflight carries no credentials, so there is no tenant to + // resolve an upstream with; answer permissively here and enforce the + // origin on the actual request (ADR 0004). + if is_preflight(&parts.method, &parts.headers) { + return preflight_response(&parts.headers); + } + + let alias = params.get("alias").cloned().unwrap_or_default(); + let path_suffix = params + .get("path") + .map(|suffix| format!("/{}", suffix.trim_start_matches('/'))) + .unwrap_or_default(); + + if let Err(error) = state + .authorize(&ctx, gts::PROXY_BASE, actions::INVOKE) + .await + { + return problem_response(&error, Some(&instance)); + } + + let wants_upgrade = websocket::is_upgrade_request(&parts.method, &parts.headers); + let on_upgrade = parts.extensions.remove::(); + if wants_upgrade && on_upgrade.is_none() { + return problem_response( + &OagwError::new( + ErrorKind::ProtocolError, + "the inbound connection cannot be upgraded", + ), + Some(&instance), + ); + } + + let payload = if wants_upgrade { + Bytes::new() + } else { + match axum::body::to_bytes(body, state.config.max_request_body_bytes).await { + Ok(bytes) => bytes, + Err(_) => { + return problem_response( + &OagwError::new( + ErrorKind::PayloadTooLarge, + format!( + "request body exceeds the {} byte limit", + state.config.max_request_body_bytes + ), + ), + Some(&instance), + ); + } + } + }; + + let query: Vec<(String, String)> = parts + .uri + .query() + .map(|raw| { + form_urlencoded::parse(raw.as_bytes()) + .map(|(key, value)| (key.into_owned(), value.into_owned())) + .collect() + }) + .unwrap_or_default(); + + let incoming = IncomingRequest { + method: parts.method.clone(), + alias: alias.clone(), + path_suffix, + query, + headers: parts.headers.clone(), + body: payload, + client_ip: client_ip(&parts), + instance: instance.clone(), + wants_upgrade, + }; + let method = parts.method.clone(); + let request_id = request_id(&parts.headers); + + let outcome = state.data_plane.execute(&ctx, incoming).await; + let duration_ms = started.elapsed().as_millis(); + + match outcome { + Ok(ProxyOutcome::Streamed { head, body }) => { + audit_log( + &request_id, + ctx.subject_tenant_id(), + ctx.subject_id(), + &alias, + instance.as_str(), + method.as_str(), + head.status.as_u16(), + duration_ms, + None, + ); + relay(head.status, head.headers, Body::from_stream(body)) + } + Ok(ProxyOutcome::Buffered { head, body }) => { + audit_log( + &request_id, + ctx.subject_tenant_id(), + ctx.subject_id(), + &alias, + instance.as_str(), + method.as_str(), + head.status.as_u16(), + duration_ms, + None, + ); + relay(head.status, head.headers, Body::from(body)) + } + Ok(ProxyOutcome::Upgraded { + headers, + stream, + leftover, + }) => { + let Some(on_upgrade) = on_upgrade else { + return problem_response( + &OagwError::new( + ErrorKind::ProtocolError, + "the inbound connection cannot be upgraded", + ), + Some(&instance), + ); + }; + tokio::spawn(async move { + match on_upgrade.await { + Ok(upgraded) => { + let io = TokioIo::new(upgraded); + match websocket::splice(io, stream, leftover).await { + Ok((from_client, from_upstream)) => debug!( + target: "oagw.upgrade", + from_client, + from_upstream, + "upgraded connection closed" + ), + Err(err) => debug!( + target: "oagw.upgrade", + error = %err, + "upgraded connection ended" + ), + } + } + Err(err) => warn!( + target: "oagw.upgrade", + error = %err, + "client connection could not be upgraded" + ), + } + }); + switching_protocols(headers) + } + Err(error) => { + audit_log( + &request_id, + ctx.subject_tenant_id(), + ctx.subject_id(), + &alias, + instance.as_str(), + method.as_str(), + error.status(), + duration_ms, + Some(error.kind.gts_type()), + ); + problem_response(&error, Some(&instance)) + } + } +} + +/// Build the client-facing response for a relayed upstream answer. +fn relay(status: StatusCode, headers: HeaderMap, body: Body) -> Response { + let mut response = Response::new(body); + *response.status_mut() = status; + for (name, value) in &headers { + response.headers_mut().append(name.clone(), value.clone()); + } + mark_upstream(&mut response); + response +} + +/// Build the `101` that hands the connection to the splice task. +fn switching_protocols(headers: HeaderMap) -> Response { + let mut response = Response::new(Body::empty()); + *response.status_mut() = StatusCode::SWITCHING_PROTOCOLS; + for (name, value) in &headers { + response.headers_mut().append(name.clone(), value.clone()); + } + // hyper completes the upgrade only when the response itself asks for one. + if !response.headers().contains_key(header::CONNECTION) { + response + .headers_mut() + .insert(header::CONNECTION, HeaderValue::from_static("upgrade")); + } + if !response.headers().contains_key(header::UPGRADE) { + response + .headers_mut() + .insert(header::UPGRADE, HeaderValue::from_static("websocket")); + } + mark_upstream(&mut response); + response +} + +/// A CORS preflight is `OPTIONS` plus `Origin` plus +/// `Access-Control-Request-Method` (WHATWG Fetch). +#[must_use] +pub fn is_preflight(method: &Method, headers: &HeaderMap) -> bool { + method == Method::OPTIONS + && headers.contains_key(header::ORIGIN) + && headers.contains_key(header::ACCESS_CONTROL_REQUEST_METHOD) +} + +/// Permissive `204` echoing the requested origin, method and headers. +#[must_use] +pub fn preflight_response(headers: &HeaderMap) -> Response { + let mut response = StatusCode::NO_CONTENT.into_response(); + let out = response.headers_mut(); + + if let Some(origin) = headers.get(header::ORIGIN) { + out.insert(header::ACCESS_CONTROL_ALLOW_ORIGIN, origin.clone()); + } + if let Some(method) = headers.get(header::ACCESS_CONTROL_REQUEST_METHOD) { + out.insert(header::ACCESS_CONTROL_ALLOW_METHODS, method.clone()); + } + if let Some(request_headers) = headers.get(header::ACCESS_CONTROL_REQUEST_HEADERS) { + out.insert(header::ACCESS_CONTROL_ALLOW_HEADERS, request_headers.clone()); + } + out.insert( + header::ACCESS_CONTROL_MAX_AGE, + HeaderValue::from_static(PREFLIGHT_MAX_AGE), + ); + // Vary on everything the answer depends on, or a shared cache will serve + // one origin's preflight to another. + out.insert( + header::VARY, + HeaderValue::from_static( + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers", + ), + ); + mark_gateway(&mut response); + response +} + +/// Correlation id for the audit log: the caller's, or a fresh one. +fn request_id(headers: &HeaderMap) -> String { + const CANDIDATES: [&str; 2] = ["x-request-id", "x-correlation-id"]; + for name in CANDIDATES { + if let Ok(name) = HeaderName::try_from(name) + && let Some(value) = headers.get(&name).and_then(|value| value.to_str().ok()) + { + return value.to_owned(); + } + } + uuid::Uuid::new_v4().to_string() +} + +/// Client address for `scope: ip` rate limiting. +fn client_ip(parts: &http::request::Parts) -> Option { + if let Some(forwarded) = parts + .headers + .get("x-forwarded-for") + .and_then(|value| value.to_str().ok()) + && let Some(first) = forwarded.split(',').next() + { + let first = first.trim(); + if !first.is_empty() { + return Some(first.to_owned()); + } + } + parts + .extensions + .get::>() + .map(|info| info.0.ip().to_string()) +} + +#[cfg(test)] +#[path = "proxy_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/api/rest/proxy_tests.rs b/gears/system/oagw/oagw/src/api/rest/proxy_tests.rs new file mode 100644 index 0000000..39a6609 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/proxy_tests.rs @@ -0,0 +1,55 @@ +//! Preflight handling at the transport boundary. + +use super::*; + +fn preflight_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(header::ORIGIN, HeaderValue::from_static("https://app.example.com")); + headers.insert( + header::ACCESS_CONTROL_REQUEST_METHOD, + HeaderValue::from_static("POST"), + ); + headers.insert( + header::ACCESS_CONTROL_REQUEST_HEADERS, + HeaderValue::from_static("content-type, authorization"), + ); + headers +} + +#[test] +fn a_preflight_needs_options_plus_origin_plus_the_requested_method() { + assert!(is_preflight(&Method::OPTIONS, &preflight_headers())); + assert!(!is_preflight(&Method::GET, &preflight_headers())); + + let mut without_origin = preflight_headers(); + without_origin.remove(header::ORIGIN); + assert!(!is_preflight(&Method::OPTIONS, &without_origin)); + + let mut without_method = preflight_headers(); + without_method.remove(header::ACCESS_CONTROL_REQUEST_METHOD); + assert!(!is_preflight(&Method::OPTIONS, &without_method)); +} + +#[test] +fn the_preflight_answer_echoes_the_request_and_varies_on_it() { + let response = preflight_response(&preflight_headers()); + assert_eq!(response.status(), StatusCode::NO_CONTENT); + + let headers = response.headers(); + assert_eq!( + headers.get(header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), + "https://app.example.com" + ); + assert_eq!(headers.get(header::ACCESS_CONTROL_ALLOW_METHODS).unwrap(), "POST"); + assert_eq!( + headers.get(header::ACCESS_CONTROL_ALLOW_HEADERS).unwrap(), + "content-type, authorization" + ); + assert_eq!(headers.get(header::ACCESS_CONTROL_MAX_AGE).unwrap(), "86400"); + assert_eq!( + headers.get(header::VARY).unwrap(), + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers" + ); + // The preflight is answered by the gateway itself, never by an upstream. + assert_eq!(headers.get("x-oagw-error-source").unwrap(), "gateway"); +} diff --git a/gears/system/oagw/oagw/src/api/rest/query.rs b/gears/system/oagw/oagw/src/api/rest/query.rs new file mode 100644 index 0000000..57892f8 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/query.rs @@ -0,0 +1,305 @@ +//! The OData subset the management list endpoints accept. +//! +//! `$filter`, `$select`, `$orderby`, `$top` and `$skip` are applied over the +//! JSON projection of each resource, so a new field becomes filterable without +//! a second schema to keep in step. + +use serde_json::Value; + +use crate::domain::error::{OagwError, OagwResult}; + +/// Parsed list query parameters. +#[derive(Debug, Clone, Default)] +pub struct ListParams { + pub filter: Vec, + pub select: Vec, + pub orderby: Vec, + pub top: Option, + pub skip: usize, +} + +/// One `field op value` conjunct. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FilterTerm { + pub field: String, + pub op: FilterOp, + pub value: String, +} + +/// Comparison operators of the supported subset. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FilterOp { + Eq, + Ne, + Contains, + StartsWith, + EndsWith, +} + +/// One `field [asc|desc]` sort key. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct OrderTerm { + pub field: String, + pub descending: bool, +} + +impl ListParams { + /// Parse the query string of a list request. + /// + /// # Errors + /// + /// `400` when a parameter is malformed or `$top`/`$skip` are not numbers. + pub fn from_query(query: &str) -> OagwResult { + let mut params = Self::default(); + for (key, value) in form_urlencoded::parse(query.as_bytes()) { + match key.as_ref() { + "$filter" => params.filter = parse_filter(&value)?, + "$select" => { + params.select = value + .split(',') + .map(|field| field.trim().to_owned()) + .filter(|field| !field.is_empty()) + .collect(); + } + "$orderby" => params.orderby = parse_orderby(&value)?, + "$top" => { + params.top = Some(value.trim().parse::().map_err(|_| { + OagwError::validation("$top must be a non-negative integer") + })?); + } + "$skip" => { + params.skip = value.trim().parse::().map_err(|_| { + OagwError::validation("$skip must be a non-negative integer") + })?; + } + _ => {} + } + } + Ok(params) + } + + /// Filter, sort, paginate and project `items`, returning + /// `(page, total_before_pagination)`. + #[must_use] + pub fn apply(&self, mut items: Vec, default_top: usize) -> (Vec, usize) { + items.retain(|item| self.filter.iter().all(|term| term.matches(item))); + let total = items.len(); + + for term in self.orderby.iter().rev() { + items.sort_by(|a, b| { + let ordering = compare(field_of(a, &term.field), field_of(b, &term.field)); + if term.descending { + ordering.reverse() + } else { + ordering + } + }); + } + + let page: Vec = items + .into_iter() + .skip(self.skip) + .take(self.top.unwrap_or(default_top)) + .map(|item| self.project(item)) + .collect(); + (page, total) + } + + fn project(&self, item: Value) -> Value { + if self.select.is_empty() { + return item; + } + let Value::Object(object) = item else { + return item; + }; + let mut projected = serde_json::Map::new(); + for field in &self.select { + if let Some(value) = object.get(field) { + projected.insert(field.clone(), value.clone()); + } + } + Value::Object(projected) + } +} + +impl FilterTerm { + /// Whether `item` satisfies this term. + #[must_use] + pub fn matches(&self, item: &Value) -> bool { + let Some(actual) = field_of(item, &self.field) else { + // An absent field only satisfies an inequality. + return self.op == FilterOp::Ne; + }; + let actual = scalar_to_string(actual); + match self.op { + FilterOp::Eq => actual == self.value, + FilterOp::Ne => actual != self.value, + FilterOp::Contains => actual.contains(&self.value), + FilterOp::StartsWith => actual.starts_with(&self.value), + FilterOp::EndsWith => actual.ends_with(&self.value), + } + } +} + +fn field_of<'a>(item: &'a Value, field: &str) -> Option<&'a Value> { + let mut current = item; + for segment in field.split('/') { + current = current.get(segment)?; + } + Some(current) +} + +fn scalar_to_string(value: &Value) -> String { + match value { + Value::String(text) => text.clone(), + other => other.to_string(), + } +} + +fn compare(a: Option<&Value>, b: Option<&Value>) -> std::cmp::Ordering { + match (a, b) { + (None, None) => std::cmp::Ordering::Equal, + (None, Some(_)) => std::cmp::Ordering::Less, + (Some(_), None) => std::cmp::Ordering::Greater, + (Some(a), Some(b)) => match (a.as_f64(), b.as_f64()) { + (Some(a), Some(b)) => a.partial_cmp(&b).unwrap_or(std::cmp::Ordering::Equal), + _ => scalar_to_string(a).cmp(&scalar_to_string(b)), + }, + } +} + +/// Parse `field eq 'value'` conjuncts joined by `and`. +fn parse_filter(raw: &str) -> OagwResult> { + let mut terms = Vec::new(); + for clause in split_conjuncts(raw) { + let clause = clause.trim(); + if clause.is_empty() { + continue; + } + terms.push(parse_clause(clause)?); + } + Ok(terms) +} + +/// Split on ` and `, ignoring separators inside quoted literals. +fn split_conjuncts(raw: &str) -> Vec { + let mut parts = Vec::new(); + let mut current = String::new(); + let mut in_quotes = false; + let mut window: Vec = Vec::new(); + + for ch in raw.chars() { + if ch == '\'' { + in_quotes = !in_quotes; + } + current.push(ch); + if !in_quotes { + window.push(ch.to_ascii_lowercase()); + if window.len() > 5 { + window.remove(0); + } + if window.iter().collect::() == " and " { + let keep = current.len() - 5; + current.truncate(keep); + parts.push(std::mem::take(&mut current)); + window.clear(); + } + } + } + parts.push(current); + parts +} + +fn parse_clause(clause: &str) -> OagwResult { + // Function form first: `contains(field,'value')`. + for (name, op) in [ + ("contains", FilterOp::Contains), + ("startswith", FilterOp::StartsWith), + ("endswith", FilterOp::EndsWith), + ] { + let prefix = format!("{name}("); + if clause.to_ascii_lowercase().starts_with(&prefix) + && let Some(inner) = clause[prefix.len()..].strip_suffix(')') + { + let (field, value) = inner.split_once(',').ok_or_else(|| { + OagwError::validation(format!("$filter: malformed '{name}' expression")) + })?; + return Ok(FilterTerm { + field: field.trim().to_owned(), + op, + value: unquote(value.trim()), + }); + } + } + + let mut tokens = clause.splitn(3, char::is_whitespace); + let field = tokens + .next() + .map(str::trim) + .filter(|field| !field.is_empty()) + .ok_or_else(|| OagwError::validation("$filter: missing field name"))?; + let op_token = tokens + .next() + .map(str::trim) + .ok_or_else(|| OagwError::validation("$filter: missing operator"))?; + let value = tokens + .next() + .map(str::trim) + .ok_or_else(|| OagwError::validation("$filter: missing value"))?; + + let op = match op_token.to_ascii_lowercase().as_str() { + "eq" => FilterOp::Eq, + "ne" => FilterOp::Ne, + other => { + return Err(OagwError::validation(format!( + "$filter: unsupported operator '{other}' (supported: eq, ne, contains, \ + startswith, endswith)" + ))); + } + }; + + Ok(FilterTerm { + field: field.to_owned(), + op, + value: unquote(value), + }) +} + +fn parse_orderby(raw: &str) -> OagwResult> { + let mut terms = Vec::new(); + for clause in raw.split(',') { + let clause = clause.trim(); + if clause.is_empty() { + continue; + } + let mut tokens = clause.split_whitespace(); + let field = tokens + .next() + .ok_or_else(|| OagwError::validation("$orderby: missing field name"))?; + let descending = match tokens.next().map(str::to_ascii_lowercase).as_deref() { + None | Some("asc") => false, + Some("desc") => true, + Some(other) => { + return Err(OagwError::validation(format!( + "$orderby: unsupported direction '{other}'" + ))); + } + }; + terms.push(OrderTerm { + field: field.to_owned(), + descending, + }); + } + Ok(terms) +} + +fn unquote(value: &str) -> String { + value + .strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + .map_or_else(|| value.to_owned(), |inner| inner.replace("''", "'")) +} + +#[cfg(test)] +#[path = "query_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/api/rest/query_tests.rs b/gears/system/oagw/oagw/src/api/rest/query_tests.rs new file mode 100644 index 0000000..39f82b2 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/query_tests.rs @@ -0,0 +1,154 @@ +//! The OData subset the list endpoints accept. + +use super::*; + +fn items() -> Vec { + vec![ + serde_json::json!({"id": "1", "alias": "api.openai.com", "enabled": true, "priority": 10}), + serde_json::json!({"id": "2", "alias": "api.stripe.com", "enabled": false, "priority": 5}), + serde_json::json!({"id": "3", "alias": "vendor.com", "enabled": true, "priority": 20}), + ] +} + +fn aliases(values: &[Value]) -> Vec { + values + .iter() + .map(|value| value["alias"].as_str().unwrap_or_default().to_owned()) + .collect() +} + +#[test] +fn an_empty_query_returns_everything() { + let params = ListParams::from_query("").unwrap(); + let (page, total) = params.apply(items(), 50); + assert_eq!(total, 3); + assert_eq!(page.len(), 3); +} + +#[test] +fn filter_eq_matches_a_string_field() { + let params = ListParams::from_query("$filter=alias%20eq%20%27vendor.com%27").unwrap(); + let (page, total) = params.apply(items(), 50); + assert_eq!(total, 1); + assert_eq!(aliases(&page), ["vendor.com"]); +} + +#[test] +fn filter_eq_matches_a_boolean_field() { + let params = ListParams::from_query("$filter=enabled eq true").unwrap(); + let (page, _) = params.apply(items(), 50); + assert_eq!(aliases(&page), ["api.openai.com", "vendor.com"]); +} + +#[test] +fn filter_ne_excludes_matches_and_admits_absent_fields() { + let params = ListParams::from_query("$filter=alias ne 'vendor.com'").unwrap(); + let (page, _) = params.apply(items(), 50); + assert_eq!(aliases(&page), ["api.openai.com", "api.stripe.com"]); + + let params = ListParams::from_query("$filter=missing ne 'x'").unwrap(); + let (_, total) = params.apply(items(), 50); + assert_eq!(total, 3); +} + +#[test] +fn conjunctions_are_applied_together() { + let params = ListParams::from_query("$filter=enabled eq true and alias ne 'vendor.com'").unwrap(); + let (page, _) = params.apply(items(), 50); + assert_eq!(aliases(&page), ["api.openai.com"]); +} + +#[test] +fn a_conjunction_separator_inside_a_literal_is_not_a_separator() { + let values = vec![serde_json::json!({"alias": "a and b"})]; + let params = ListParams::from_query("$filter=alias eq 'a and b'").unwrap(); + let (_, total) = params.apply(values, 50); + assert_eq!(total, 1); +} + +#[test] +fn the_string_functions_are_supported() { + for (query, expected) in [ + ("$filter=contains(alias,'openai')", vec!["api.openai.com"]), + ("$filter=startswith(alias,'api.')", vec!["api.openai.com", "api.stripe.com"]), + ("$filter=endswith(alias,'.com')", vec!["api.openai.com", "api.stripe.com", "vendor.com"]), + ] { + let params = ListParams::from_query(query).unwrap(); + let (page, _) = params.apply(items(), 50); + assert_eq!(aliases(&page), expected, "{query}"); + } +} + +#[test] +fn an_unsupported_operator_is_a_validation_error() { + let err = ListParams::from_query("$filter=alias gt 'x'").unwrap_err(); + assert_eq!(err.status(), 400); +} + +#[test] +fn select_projects_only_the_named_fields() { + let params = ListParams::from_query("$select=id,alias").unwrap(); + let (page, _) = params.apply(items(), 50); + let first = page[0].as_object().unwrap(); + assert_eq!(first.len(), 2); + assert!(first.contains_key("id")); + assert!(first.contains_key("alias")); + assert!(!first.contains_key("enabled")); +} + +#[test] +fn orderby_sorts_ascending_by_default_and_descending_on_request() { + let params = ListParams::from_query("$orderby=priority").unwrap(); + let (page, _) = params.apply(items(), 50); + assert_eq!(aliases(&page), ["api.stripe.com", "api.openai.com", "vendor.com"]); + + let params = ListParams::from_query("$orderby=priority desc").unwrap(); + let (page, _) = params.apply(items(), 50); + assert_eq!(aliases(&page), ["vendor.com", "api.openai.com", "api.stripe.com"]); +} + +#[test] +fn an_unsupported_sort_direction_is_a_validation_error() { + assert_eq!( + ListParams::from_query("$orderby=alias sideways").unwrap_err().status(), + 400 + ); +} + +#[test] +fn top_and_skip_page_the_result_and_total_counts_the_matches() { + let params = ListParams::from_query("$orderby=alias&$top=1&$skip=1").unwrap(); + let (page, total) = params.apply(items(), 50); + assert_eq!(total, 3, "total counts matches before pagination"); + assert_eq!(aliases(&page), ["api.stripe.com"]); +} + +#[test] +fn a_nonnumeric_top_or_skip_is_a_validation_error() { + assert_eq!(ListParams::from_query("$top=lots").unwrap_err().status(), 400); + assert_eq!(ListParams::from_query("$skip=-1").unwrap_err().status(), 400); +} + +#[test] +fn the_default_page_size_applies_when_top_is_absent() { + let params = ListParams::from_query("").unwrap(); + let (page, total) = params.apply(items(), 2); + assert_eq!(total, 3); + assert_eq!(page.len(), 2); +} + +#[test] +fn unknown_query_parameters_are_ignored() { + // Callers routinely append tracing or cache-busting parameters. + let params = ListParams::from_query("cacheBust=1&$top=1").unwrap(); + let (page, _) = params.apply(items(), 50); + assert_eq!(page.len(), 1); +} + +#[test] +fn quoted_literals_unescape_doubled_quotes() { + let values = vec![serde_json::json!({"alias": "it's"})]; + let params = ListParams::from_query("$filter=alias eq 'it''s'").unwrap(); + let (_, total) = params.apply(values, 50); + assert_eq!(total, 1); +} 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..84cd445 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/routes.rs @@ -0,0 +1,416 @@ +//! Route registration. +//! +//! Paths are **gear-relative**: the api-gateway nests this router under its own +//! `prefix_path`, so repeating a prefix here would double it. + +use std::sync::Arc; + +use axum::Router; +use axum::routing::any; +use http::StatusCode; +use toolkit::api::{OpenApiRegistry, OperationBuilder, ParamLocation, ParamSpec}; + +use crate::domain::input::{PluginInput, RouteInput, UpstreamInput}; + +use super::dto::{ListResponse, PluginResponse, PluginSourceResponse}; +use super::handlers; +use super::proxy; +use super::state::OagwState; + +const TAG: &str = "Outbound API Gateway"; + +/// Base path of the management and proxy APIs, relative to the gateway prefix. +pub const BASE: &str = "/oagw/v1"; + +/// Describe the optional list query parameters once. +fn list_params( + builder: OperationBuilder, +) -> OperationBuilder +where + H: toolkit::api::operation_builder::HandlerSlot, + A: toolkit::api::operation_builder::AuthState, + L: toolkit::api::operation_builder::LicenseState, +{ + builder + .param(query_param("$filter", "OData filter expression")) + .param(query_param("$select", "Comma-separated fields to return")) + .param(query_param("$orderby", "Sort order, e.g. `alias desc`")) + .param(query_param("$top", "Maximum number of results")) + .param(query_param("$skip", "Number of results to skip")) +} + +fn query_param(name: &str, description: &str) -> ParamSpec { + ParamSpec { + name: name.to_owned(), + location: ParamLocation::Query, + required: false, + description: Some(description.to_owned()), + param_type: "string".to_owned(), + array: false, + } +} + +fn target_host_param() -> ParamSpec { + ParamSpec { + name: "X-OAGW-Target-Host".to_owned(), + location: ParamLocation::Header, + required: false, + description: Some( + "Pins the request to one endpoint of a multi-endpoint upstream. Required when the \ + alias was derived from a common domain suffix." + .to_owned(), + ), + param_type: "string".to_owned(), + array: false, + } +} + +/// Register the management and proxy routes. +#[allow(clippy::too_many_lines, reason = "one linear table of route declarations")] +pub fn register_routes( + router: Router, + openapi: &dyn OpenApiRegistry, + state: Arc, +) -> Router { + let router = register_upstreams(router, openapi); + let router = register_routes_api(router, openapi); + let router = register_plugins(router, openapi); + let router = register_proxy(router, openapi); + router.layer(axum::Extension(state)) +} + +fn register_upstreams(router: Router, openapi: &dyn OpenApiRegistry) -> Router { + let router = OperationBuilder::post(format!("{BASE}/upstreams")) + .operation_id("oagw.create_upstream") + .summary("Create an upstream") + .description( + "Register an external service. The alias is auto-derived from hostname endpoints; \ + IP-based or otherwise non-derivable pools must supply one explicitly.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "Upstream configuration") + .handler(handlers::create_upstream) + .json_response(StatusCode::CREATED, "Upstream created") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_409(openapi) + .error_500(openapi) + .register(router, openapi); + + let router = list_params( + OperationBuilder::get(format!("{BASE}/upstreams")) + .operation_id("oagw.list_upstreams") + .summary("List upstreams") + .description("List the calling tenant's upstreams. Ancestor upstreams are not listed.") + .tag(TAG), + ) + .authenticated() + .no_license_required() + .handler(handlers::list_upstreams) + .json_response_with_schema::(openapi, StatusCode::OK, "Matching upstreams") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + let router = OperationBuilder::get(format!("{BASE}/upstreams/{{id}}")) + .operation_id("oagw.get_upstream") + .summary("Get an upstream") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Upstream UUID or `gts.cf.core.oagw.upstream.v1~{uuid}`") + .handler(handlers::get_upstream) + .json_response(StatusCode::OK, "The upstream") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + let router = OperationBuilder::put(format!("{BASE}/upstreams/{{id}}")) + .operation_id("oagw.replace_upstream") + .summary("Replace an upstream") + .description( + "Full replacement: omitted optional fields are cleared. The alias is immutable — an \ + endpoint change that would move it is rejected.", + ) + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Upstream UUID or `gts.cf.core.oagw.upstream.v1~{uuid}`") + .json_request::(openapi, "Replacement upstream configuration") + .handler(handlers::replace_upstream) + .json_response(StatusCode::OK, "The replaced upstream") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_409(openapi) + .error_500(openapi) + .register(router, openapi); + + OperationBuilder::delete(format!("{BASE}/upstreams/{{id}}")) + .operation_id("oagw.delete_upstream") + .summary("Delete an upstream") + .description("Deletes the upstream and cascades to its routes.") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Upstream UUID or `gts.cf.core.oagw.upstream.v1~{uuid}`") + .handler(handlers::delete_upstream) + .no_content_response(StatusCode::NO_CONTENT, "Upstream deleted") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi) +} + +fn register_routes_api(router: Router, openapi: &dyn OpenApiRegistry) -> Router { + let router = OperationBuilder::post(format!("{BASE}/routes")) + .operation_id("oagw.create_route") + .summary("Create a route") + .description("Bind a match rule to one of the calling tenant's upstreams.") + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "Route configuration") + .handler(handlers::create_route) + .json_response(StatusCode::CREATED, "Route created") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_409(openapi) + .error_500(openapi) + .register(router, openapi); + + let router = list_params( + OperationBuilder::get(format!("{BASE}/routes")) + .operation_id("oagw.list_routes") + .summary("List routes") + .tag(TAG), + ) + .authenticated() + .no_license_required() + .handler(handlers::list_routes) + .json_response_with_schema::(openapi, StatusCode::OK, "Matching routes") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + let router = OperationBuilder::get(format!("{BASE}/routes/{{id}}")) + .operation_id("oagw.get_route") + .summary("Get a route") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Route UUID or `gts.cf.core.oagw.route.v1~{uuid}`") + .handler(handlers::get_route) + .json_response(StatusCode::OK, "The route") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + let router = OperationBuilder::put(format!("{BASE}/routes/{{id}}")) + .operation_id("oagw.replace_route") + .summary("Replace a route") + .description("Full replacement. `upstream_id` is immutable.") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Route UUID or `gts.cf.core.oagw.route.v1~{uuid}`") + .json_request::(openapi, "Replacement route configuration") + .handler(handlers::replace_route) + .json_response(StatusCode::OK, "The replaced route") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_409(openapi) + .error_500(openapi) + .register(router, openapi); + + OperationBuilder::delete(format!("{BASE}/routes/{{id}}")) + .operation_id("oagw.delete_route") + .summary("Delete a route") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param("id", "Route UUID or `gts.cf.core.oagw.route.v1~{uuid}`") + .handler(handlers::delete_route) + .no_content_response(StatusCode::NO_CONTENT, "Route deleted") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi) +} + +fn register_plugins(router: Router, openapi: &dyn OpenApiRegistry) -> Router { + let router = OperationBuilder::post(format!("{BASE}/plugins")) + .operation_id("oagw.create_plugin") + .summary("Create a custom plugin") + .description("Plugin definitions are immutable; publish a new one to change behaviour.") + .tag(TAG) + .authenticated() + .no_license_required() + .json_request::(openapi, "Plugin definition") + .handler(handlers::create_plugin) + .json_response_with_schema::( + openapi, + StatusCode::CREATED, + "Plugin created", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_409(openapi) + .error_500(openapi) + .register(router, openapi); + + let router = list_params( + OperationBuilder::get(format!("{BASE}/plugins")) + .operation_id("oagw.list_plugins") + .summary("List custom plugins") + .tag(TAG), + ) + .authenticated() + .no_license_required() + .handler(handlers::list_plugins) + .json_response_with_schema::(openapi, StatusCode::OK, "Matching plugins") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_500(openapi) + .register(router, openapi); + + let router = OperationBuilder::get(format!("{BASE}/plugins/{{id}}")) + .operation_id("oagw.get_plugin") + .summary("Get a custom plugin") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param( + "id", + "Plugin UUID or `gts.cf.core.oagw.{type}_plugin.v1~{uuid}`", + ) + .handler(handlers::get_plugin) + .json_response_with_schema::(openapi, StatusCode::OK, "The plugin") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + let router = OperationBuilder::get(format!("{BASE}/plugins/{{id}}/source")) + .operation_id("oagw.get_plugin_source") + .summary("Get a custom plugin's source") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param( + "id", + "Plugin UUID or `gts.cf.core.oagw.{type}_plugin.v1~{uuid}`", + ) + .handler(handlers::get_plugin_source) + .json_response_with_schema::( + openapi, + StatusCode::OK, + "The plugin source", + ) + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_500(openapi) + .register(router, openapi); + + OperationBuilder::delete(format!("{BASE}/plugins/{{id}}")) + .operation_id("oagw.delete_plugin") + .summary("Delete a custom plugin") + .description("Fails with 409 while any upstream or route still references the plugin.") + .tag(TAG) + .authenticated() + .no_license_required() + .path_param( + "id", + "Plugin UUID or `gts.cf.core.oagw.{type}_plugin.v1~{uuid}`", + ) + .handler(handlers::delete_plugin) + .no_content_response(StatusCode::NO_CONTENT, "Plugin deleted") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_409(openapi) + .error_500(openapi) + .register(router, openapi) +} + +fn register_proxy(router: Router, openapi: &dyn OpenApiRegistry) -> Router { + // `any` rather than a fixed verb: the proxy relays whatever method the + // caller used, including the `GET` that opens a WebSocket and the + // `OPTIONS` of a CORS preflight. + let router = OperationBuilder::post(format!("{BASE}/proxy/{{alias}}")) + .operation_id("oagw.proxy_root") + .summary("Proxy a request to an upstream") + .description( + "Resolves the alias across the tenant hierarchy, applies the effective \ + configuration, runs the plugin chain and forwards the call. Any HTTP method is \ + accepted; SSE responses and protocol upgrades are streamed.", + ) + .tag(TAG) + .param(target_host_param()) + .authenticated() + .no_license_required() + .method_router(any(proxy::proxy)) + .json_response(StatusCode::OK, "The upstream response, relayed") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_429(openapi) + .error_500(openapi) + .error_502(openapi) + .error_503(openapi) + .error_504(openapi) + .register(router, openapi); + + OperationBuilder::post(format!("{BASE}/proxy/{{alias}}/{{*path}}")) + .operation_id("oagw.proxy") + .summary("Proxy a request to an upstream path") + .description( + "As `oagw.proxy_root`, with the path suffix appended to the matched route path when \ + `path_suffix_mode` is `append`.", + ) + .tag(TAG) + .param(target_host_param()) + .authenticated() + .no_license_required() + .method_router(any(proxy::proxy)) + .json_response(StatusCode::OK, "The upstream response, relayed") + .error_400(openapi) + .error_401(openapi) + .error_403(openapi) + .error_404(openapi) + .error_429(openapi) + .error_500(openapi) + .error_502(openapi) + .error_503(openapi) + .error_504(openapi) + .register(router, openapi) +} diff --git a/gears/system/oagw/oagw/src/api/rest/state.rs b/gears/system/oagw/oagw/src/api/rest/state.rs new file mode 100644 index 0000000..5985a88 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/state.rs @@ -0,0 +1,120 @@ +//! Shared handler state and the authorization gate in front of it. + +use std::sync::Arc; + +use authz_resolver_sdk::pep::{AccessRequest, EnforcerError, PolicyEnforcer, ResourceType}; +use toolkit_security::{SecurityContext, pep_properties}; + +use crate::config::OagwConfig; +use crate::domain::error::{ErrorKind, OagwError, OagwResult}; +use crate::domain::gts; +use crate::domain::model::PluginKind; +use crate::domain::services::ControlPlaneService; +use crate::infra::proxy::DataPlaneService; +use crate::infra::storage::InMemoryStore; + +/// Constraint properties this PEP can compile. OAGW resources are all +/// tenant-owned, so the owner tenant is the only axis a policy can clamp on. +const SUPPORTED_PROPERTIES: &[&str] = &[pep_properties::OWNER_TENANT_ID]; + +/// Actions evaluated against the OAGW resource types. +pub mod actions { + pub const CREATE: &str = "create"; + pub const READ: &str = "read"; + pub const OVERRIDE: &str = "override"; + pub const DELETE: &str = "delete"; + pub const INVOKE: &str = "invoke"; +} + +/// Everything the REST handlers need. +pub struct OagwState { + pub control: Arc, + pub data_plane: Arc, + pub store: Arc, + /// Absent when the deployment runs without an authorization resolver. + pub authz: Option, + pub config: OagwConfig, +} + +impl std::fmt::Debug for OagwState { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OagwState") + .field("authz", &self.authz.is_some()) + .finish_non_exhaustive() + } +} + +impl OagwState { + /// Authorize `action` on `resource_type` for the caller. + /// + /// Constraints are optional: OAGW scopes every read and write to the + /// caller's tenant itself, so the PDP's role here is the allow/deny + /// decision rather than a row filter. + /// + /// # Errors + /// + /// `403` when the policy denies, `503` when the PDP cannot be reached. + pub async fn authorize( + &self, + ctx: &SecurityContext, + resource_type: &'static str, + action: &str, + ) -> OagwResult<()> { + let Some(enforcer) = self.authz.as_ref() else { + return Ok(()); + }; + let resource = ResourceType::from_static(resource_type, SUPPORTED_PROPERTIES); + let request = AccessRequest::new() + .resource_property(pep_properties::OWNER_TENANT_ID, ctx.subject_tenant_id()) + .require_constraints(false); + + enforcer + .access_scope_with(ctx, &resource, action, None, &request) + .await + .map(|_scope| ()) + .map_err(|err| match err { + EnforcerError::Denied { .. } | EnforcerError::CompileFailed(_) => { + OagwError::new( + ErrorKind::PermissionDenied, + format!("not permitted to {action} {resource_type}"), + ) + } + EnforcerError::EvaluationFailed(source) => OagwError::new( + ErrorKind::LinkUnavailable, + format!("authorization evaluation failed: {source}"), + ) + .with_retry_after(5), + }) + } + + /// Retire custom plugins that have been unlinked for longer than the + /// configured TTL. + /// + /// The sweep is amortized onto the management API rather than run from a + /// timer: it is cheap over an in-process store, and the moments a plugin + /// can become unlinked — a binding removed, an upstream or route deleted — + /// are exactly the calls that land here. + pub fn sweep_unlinked_plugins(&self) { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |elapsed| elapsed.as_secs()); + let collected = self.store.run_gc(now, self.config.plugin_gc_ttl_secs); + if !collected.is_empty() { + tracing::info!( + target: "oagw.plugin", + count = collected.len(), + "collected unlinked custom plugins" + ); + } + } + + /// Resource type identifier for a plugin kind. + #[must_use] + pub fn plugin_resource(kind: PluginKind) -> &'static str { + match kind { + PluginKind::Auth => gts::AUTH_PLUGIN_BASE, + PluginKind::Guard => gts::GUARD_PLUGIN_BASE, + PluginKind::Transform => gts::TRANSFORM_PLUGIN_BASE, + } + } +} diff --git a/gears/system/oagw/oagw/src/config.rs b/gears/system/oagw/oagw/src/config.rs new file mode 100644 index 0000000..63ff3f2 --- /dev/null +++ b/gears/system/oagw/oagw/src/config.rs @@ -0,0 +1,149 @@ +//! Gear-level configuration (`gears.oagw.config` in the server YAML). + +use std::time::Duration; + +use serde::Deserialize; + +/// Hard ceiling on a buffered proxy request body (`cpt-cf-oagw-constraint-body-limit`). +pub const BODY_LIMIT_BYTES: usize = 100 * 1024 * 1024; + +/// Configuration for the OAGW gear. +/// +/// Every field has a default so the gear starts with an empty `config:` block. +/// Unknown keys are tolerated: an operator config that carries settings for a +/// newer build must not stop this one from booting. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct OagwConfig { + /// Wall-clock budget for connecting to an upstream and reading its + /// response head. Streaming bodies (SSE, WebSocket) are not bounded by it. + pub proxy_timeout_secs: u64, + /// TCP/TLS connect budget for a single upstream endpoint. + pub connect_timeout_secs: u64, + /// Permit plaintext (`http`/`ws`) connections to upstreams. + /// + /// This governs whether a plaintext *connection is made*, not which + /// schemes the management API accepts — `cpt-cf-oagw-constraint-https-only` + /// is the default posture and this flag is what lifts it. + pub allow_http_upstream: bool, + /// Maximum buffered request body before `413 PayloadTooLarge`. + pub max_request_body_bytes: usize, + /// Ceiling for a cached OAuth2 access token (ADR 0008). + pub token_cache_ttl_secs: u64, + /// Maximum number of cached OAuth2 access tokens (ADR 0008). + pub token_cache_capacity: usize, + /// Default `$top` for the management list endpoints. + pub default_page_size: usize, + /// Maximum accepted `$top` for the management list endpoints. + pub max_page_size: usize, + /// TTL for a cached tenant ancestor chain. + pub tenant_cache_ttl_secs: u64, + /// Age after which an unlinked custom plugin becomes collectable. + pub plugin_gc_ttl_secs: u64, + /// How often the plugin garbage collector sweeps. + pub plugin_gc_tick_secs: u64, + /// SSRF guardrails applied to every resolved upstream address. + pub ssrf_policy: SsrfPolicy, +} + +impl Default for OagwConfig { + fn default() -> Self { + Self { + proxy_timeout_secs: 30, + connect_timeout_secs: 10, + allow_http_upstream: false, + max_request_body_bytes: BODY_LIMIT_BYTES, + token_cache_ttl_secs: 300, + token_cache_capacity: 10_000, + default_page_size: 50, + max_page_size: 100, + tenant_cache_ttl_secs: 300, + plugin_gc_ttl_secs: 30 * 24 * 60 * 60, + plugin_gc_tick_secs: 3600, + ssrf_policy: SsrfPolicy::default(), + } + } +} + +impl OagwConfig { + #[must_use] + pub fn proxy_timeout(&self) -> Duration { + Duration::from_secs(self.proxy_timeout_secs.max(1)) + } + + #[must_use] + pub fn connect_timeout(&self) -> Duration { + Duration::from_secs(self.connect_timeout_secs.max(1)) + } + + #[must_use] + pub fn plugin_gc_tick(&self) -> Duration { + Duration::from_secs(self.plugin_gc_tick_secs.max(1)) + } + + #[must_use] + pub fn token_cache_ttl(&self) -> Duration { + Duration::from_secs(self.token_cache_ttl_secs.max(1)) + } + + /// Clamp a requested page size into `[1, max_page_size]`. + #[must_use] + pub fn clamp_page_size(&self, requested: Option) -> usize { + let max = self.max_page_size.max(1); + requested.unwrap_or(self.default_page_size).clamp(1, max) + } + + /// Validate internally inconsistent combinations. + /// + /// # Errors + /// + /// Returns a message describing the first inconsistency found. + pub fn validate(&self) -> Result<(), String> { + if self.max_request_body_bytes == 0 { + return Err("max_request_body_bytes must be greater than zero".to_owned()); + } + if self.max_request_body_bytes > BODY_LIMIT_BYTES { + return Err(format!( + "max_request_body_bytes must not exceed the {BODY_LIMIT_BYTES} byte hard limit" + )); + } + if self.max_page_size == 0 { + return Err("max_page_size must be greater than zero".to_owned()); + } + Ok(()) + } +} + +/// Server-Side Request Forgery guardrails (`cpt-cf-oagw-nfr-ssrf-protection`). +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct SsrfPolicy { + /// Master switch. When off no address checks are applied — intended for + /// local development and E2E runs against loopback mock servers. + pub enabled: bool, + /// Permit loopback destinations (`127.0.0.0/8`, `::1`). + pub allow_loopback: bool, + /// Permit RFC 1918 / unique-local destinations. + pub allow_private: bool, + /// Permit link-local destinations (`169.254.0.0/16`, `fe80::/10`) — these + /// cover the cloud metadata endpoints and stay blocked by default. + pub allow_link_local: bool, + /// Destination ports that are never dialled. + pub blocked_ports: Vec, +} + +impl Default for SsrfPolicy { + fn default() -> Self { + Self { + enabled: true, + allow_loopback: false, + allow_private: false, + allow_link_local: false, + blocked_ports: Vec::new(), + } + } +} + +#[cfg(test)] +#[path = "config_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/config_tests.rs b/gears/system/oagw/oagw/src/config_tests.rs new file mode 100644 index 0000000..485398c --- /dev/null +++ b/gears/system/oagw/oagw/src/config_tests.rs @@ -0,0 +1,79 @@ +//! Gear configuration defaults and validation. + +use super::*; + +#[test] +fn the_defaults_are_the_safe_posture() { + let config = OagwConfig::default(); + assert!(!config.allow_http_upstream, "plaintext upstreams are opt-in"); + assert!(config.ssrf_policy.enabled, "SSRF guardrails are on by default"); + assert!(!config.ssrf_policy.allow_loopback); + assert!(!config.ssrf_policy.allow_private); + assert!(!config.ssrf_policy.allow_link_local); + assert_eq!(config.max_request_body_bytes, BODY_LIMIT_BYTES); + assert_eq!(config.token_cache_ttl_secs, 300); + assert_eq!(config.token_cache_capacity, 10_000); + assert_eq!(config.default_page_size, 50); + assert_eq!(config.max_page_size, 100); +} + +#[test] +fn the_shipped_e2e_config_shape_deserializes() { + let config: OagwConfig = serde_json::from_value(serde_json::json!({ + "proxy_timeout_secs": 2, + "allow_http_upstream": true, + "ssrf_policy": {"enabled": false}, + })) + .unwrap(); + assert_eq!(config.proxy_timeout().as_secs(), 2); + assert!(config.allow_http_upstream); + assert!(!config.ssrf_policy.enabled); + // Unspecified keys keep their defaults. + assert_eq!(config.max_page_size, 100); +} + +#[test] +fn unknown_configuration_keys_do_not_stop_the_gear() { + let config: OagwConfig = + serde_json::from_value(serde_json::json!({"from_a_newer_build": 42})).unwrap(); + assert_eq!(config.proxy_timeout_secs, 30); +} + +#[test] +fn timeouts_never_collapse_to_zero() { + let config: OagwConfig = serde_json::from_value(serde_json::json!({ + "proxy_timeout_secs": 0, + "connect_timeout_secs": 0, + "token_cache_ttl_secs": 0, + })) + .unwrap(); + assert_eq!(config.proxy_timeout().as_secs(), 1); + assert_eq!(config.connect_timeout().as_secs(), 1); + assert_eq!(config.token_cache_ttl().as_secs(), 1); +} + +#[test] +fn page_sizes_are_clamped_into_range() { + let config = OagwConfig::default(); + assert_eq!(config.clamp_page_size(None), 50); + assert_eq!(config.clamp_page_size(Some(10)), 10); + assert_eq!(config.clamp_page_size(Some(1_000)), 100); + assert_eq!(config.clamp_page_size(Some(0)), 1); +} + +#[test] +fn validation_rejects_an_unusable_body_limit_or_page_size() { + let mut config = OagwConfig::default(); + config.max_request_body_bytes = 0; + assert!(config.validate().is_err()); + + let mut config = OagwConfig::default(); + config.max_request_body_bytes = BODY_LIMIT_BYTES + 1; + assert!(config.validate().is_err()); + + let mut config = OagwConfig::default(); + config.max_page_size = 0; + assert!(config.validate().is_err()); + + assert!(OagwConfig::default().validate().is_ok()); +} diff --git a/gears/system/oagw/oagw/src/domain/alias.rs b/gears/system/oagw/oagw/src/domain/alias.rs new file mode 100644 index 0000000..1d2c291 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/alias.rs @@ -0,0 +1,248 @@ +//! Alias derivation, normalization and update enforcement. +//! +//! An alias is the routing key in `/oagw/v1/proxy/{alias}/...`, not a free +//! label: hostname-based pools always auto-derive it, IP-based and otherwise +//! non-derivable pools must state it explicitly, and once set it never changes +//! (`cpt-cf-oagw-fr-alias-resolution`). + +use std::net::IpAddr; + +use crate::domain::error::{OagwError, OagwResult}; +use crate::domain::model::Endpoint; + +/// Longest hostname accepted, per RFC 1123. +const MAX_HOSTNAME_LEN: usize = 253; +/// Longest single label, per RFC 1123. +const MAX_LABEL_LEN: usize = 63; + +/// Normalize an alias or hostname: ASCII lowercase, trailing dots stripped. +#[must_use] +pub fn normalize(raw: &str) -> String { + raw.trim().trim_end_matches('.').to_ascii_lowercase() +} + +/// Whether `host` parses as an IP literal (v4, or v6 with or without brackets). +#[must_use] +pub fn is_ip_literal(host: &str) -> bool { + let bare = host + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .unwrap_or(host); + bare.parse::().is_ok() +} + +/// Validate a hostname per RFC 1123. IP literals are accepted as-is. +/// +/// # Errors +/// +/// Returns a `ValidationError` describing the first rule violated. +pub fn validate_host(raw: &str) -> OagwResult { + let host = normalize(raw); + if host.is_empty() { + return Err(OagwError::validation("endpoint host must not be empty")); + } + if is_ip_literal(&host) { + return Ok(host); + } + if host.len() > MAX_HOSTNAME_LEN { + return Err(OagwError::validation(format!( + "endpoint host exceeds {MAX_HOSTNAME_LEN} characters: {host}" + ))); + } + for label in host.split('.') { + if label.is_empty() || label.len() > MAX_LABEL_LEN { + return Err(OagwError::validation(format!( + "endpoint host label must be 1-{MAX_LABEL_LEN} characters: {host}" + ))); + } + if !label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + { + return Err(OagwError::validation(format!( + "endpoint host label may only contain ASCII letters, digits and hyphens: {host}" + ))); + } + if label.starts_with('-') || label.ends_with('-') { + return Err(OagwError::validation(format!( + "endpoint host label must not start or end with a hyphen: {host}" + ))); + } + } + Ok(host) +} + +/// Validate an explicit, user-supplied alias against the schema pattern +/// `^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$` (applied after normalization). +/// +/// # Errors +/// +/// Returns a `ValidationError` when the alias is empty or contains characters +/// outside the permitted set. +pub fn validate_alias(raw: &str) -> OagwResult { + let alias = normalize(raw); + if alias.is_empty() { + return Err(OagwError::validation("alias must not be empty")); + } + let bytes = alias.as_bytes(); + let is_edge_ok = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit(); + if !is_edge_ok(bytes[0]) || !is_edge_ok(bytes[bytes.len() - 1]) { + return Err(OagwError::validation(format!( + "alias must start and end with a lowercase letter or digit: {alias}" + ))); + } + if !bytes + .iter() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || matches!(b, b'.' | b':' | b'-')) + { + return Err(OagwError::validation(format!( + "alias may only contain lowercase letters, digits and the characters '.', ':', '-': {alias}" + ))); + } + Ok(alias) +} + +/// Longest common domain suffix of `hosts` that is a *registrable* domain. +/// +/// Requires at least two labels and rejects a bare public suffix +/// (`co.uk`), which is what the PSL check is for. +#[must_use] +pub fn common_domain_suffix(hosts: &[String]) -> Option { + let first = hosts.first()?; + let mut common: Vec<&str> = first.split('.').collect(); + for host in hosts.iter().skip(1) { + let labels: Vec<&str> = host.split('.').collect(); + let mut shared = Vec::new(); + for (a, b) in common.iter().rev().zip(labels.iter().rev()) { + if a == b { + shared.push(*a); + } else { + break; + } + } + shared.reverse(); + common = shared; + if common.is_empty() { + return None; + } + } + if common.len() < 2 { + return None; + } + let candidate = common.join("."); + // A shared suffix that is itself a public suffix is not a routing target. + if psl::suffix_str(&candidate).is_some_and(|s| s == candidate) { + return None; + } + Some(candidate) +} + +/// Derive the alias for an endpoint pool, or `None` when derivation fails. +/// +/// Derivation fails for IP-based pools, for heterogeneous hostnames with no +/// registrable common suffix, and for pools whose only common suffix is a bare +/// public suffix. +#[must_use] +pub fn compute_derived_alias(endpoints: &[Endpoint]) -> Option { + let first = endpoints.first()?; + let hosts: Vec = endpoints.iter().map(|e| normalize(&e.host)).collect(); + if hosts.iter().any(|h| is_ip_literal(h)) { + return None; + } + + let mut distinct: Vec = Vec::new(); + for host in &hosts { + if !distinct.contains(host) { + distinct.push(host.clone()); + } + } + + let base = if distinct.len() == 1 { + distinct[0].clone() + } else { + common_domain_suffix(&distinct)? + }; + + // Non-standard ports stay in the alias so pools that share a domain suffix + // on different ports do not collide. + if first.port == first.scheme.standard_port() { + Some(base) + } else { + Some(format!("{}:{}", base, first.port)) + } +} + +/// Decide the alias a *newly created* upstream gets. +/// +/// * Derivable pool — the derived value wins. A user-supplied alias is +/// rejected unless it is exactly the derived value (tolerated as an +/// idempotent no-op). +/// * Non-derivable pool — an explicit alias is mandatory. +/// +/// # Errors +/// +/// Returns a `ValidationError` when a user alias contradicts the derived one, +/// or when a non-derivable pool omits the alias. +pub fn resolve_alias_for_create( + endpoints: &[Endpoint], + requested: Option<&str>, +) -> OagwResult { + let derived = compute_derived_alias(endpoints); + match (derived, requested) { + (Some(derived), None) => Ok(derived), + (Some(derived), Some(requested)) => { + let requested = validate_alias(requested)?; + if requested == derived { + Ok(derived) + } else { + Err(OagwError::validation(format!( + "alias is auto-derived for hostname endpoints and cannot be overridden \ + (derived '{derived}', requested '{requested}')" + ))) + } + } + (None, Some(requested)) => validate_alias(requested), + (None, None) => Err(OagwError::validation( + "alias is required for IP-based or non-derivable endpoints", + )), + } +} + +/// Decide the alias a *replaced* upstream keeps. +/// +/// The alias is immutable: any endpoint change that would alter the derived +/// alias is rejected, and a differing user-supplied alias is never accepted. +/// The operator must delete and re-create instead. +/// +/// # Errors +/// +/// Returns a `ValidationError` when the update would change the alias. +pub fn enforce_alias_update( + existing_alias: &str, + endpoints: &[Endpoint], + requested: Option<&str>, +) -> OagwResult { + if let Some(requested) = requested { + let requested = validate_alias(requested)?; + if requested != existing_alias { + return Err(OagwError::validation(format!( + "alias is immutable once set (current '{existing_alias}', requested \ + '{requested}'); delete and re-create the upstream to change it" + ))); + } + } + + match compute_derived_alias(endpoints) { + Some(derived) if derived == existing_alias => Ok(derived), + Some(derived) => Err(OagwError::validation(format!( + "endpoint change would move the alias from '{existing_alias}' to '{derived}'; \ + the alias is immutable — delete and re-create the upstream" + ))), + // Non-derivable pools keep the alias they were created with. + None => Ok(existing_alias.to_owned()), + } +} + +#[cfg(test)] +#[path = "alias_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/alias_tests.rs b/gears/system/oagw/oagw/src/domain/alias_tests.rs new file mode 100644 index 0000000..7bc2d7e --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/alias_tests.rs @@ -0,0 +1,177 @@ +//! Alias derivation and enforcement — the full matrix from +//! `docs/DESIGN.md` §"Alias Enforcement Rules". + +use super::*; +use crate::domain::model::Scheme; + +fn endpoint(scheme: Scheme, host: &str, port: u16) -> Endpoint { + Endpoint { + scheme, + host: host.to_owned(), + port, + } +} + +fn https(host: &str) -> Endpoint { + endpoint(Scheme::Https, host, 443) +} + +#[test] +fn normalize_lowercases_and_strips_trailing_dots() { + assert_eq!(normalize(" Api.OpenAI.COM. "), "api.openai.com"); + assert_eq!(normalize("VENDOR.com:8443"), "vendor.com:8443"); +} + +#[test] +fn ip_literals_are_recognized_in_every_spelling() { + assert!(is_ip_literal("10.0.1.1")); + assert!(is_ip_literal("::1")); + assert!(is_ip_literal("[2001:db8::1]")); + assert!(!is_ip_literal("api.openai.com")); +} + +#[test] +fn hostname_validation_follows_rfc_1123() { + assert_eq!(validate_host("API.Example.COM.").unwrap(), "api.example.com"); + assert!(validate_host("").is_err()); + assert!(validate_host("-leading.example.com").is_err()); + assert!(validate_host("trailing-.example.com").is_err()); + assert!(validate_host("under_score.example.com").is_err()); + assert!(validate_host("a..b").is_err()); + let too_long = format!("{}.example.com", "a".repeat(64)); + assert!(validate_host(&too_long).is_err()); + // IP literals bypass label rules. + assert_eq!(validate_host("10.0.1.1").unwrap(), "10.0.1.1"); +} + +#[test] +fn single_hostname_on_a_standard_port_derives_the_bare_host() { + let derived = compute_derived_alias(&[https("api.openai.com")]); + assert_eq!(derived.as_deref(), Some("api.openai.com")); +} + +#[test] +fn single_hostname_on_a_nonstandard_port_keeps_the_port() { + let derived = compute_derived_alias(&[endpoint(Scheme::Https, "api.openai.com", 8443)]); + assert_eq!(derived.as_deref(), Some("api.openai.com:8443")); +} + +#[test] +fn plaintext_standard_port_is_eighty() { + let derived = compute_derived_alias(&[endpoint(Scheme::Http, "api.example.com", 80)]); + assert_eq!(derived.as_deref(), Some("api.example.com")); + let derived = compute_derived_alias(&[endpoint(Scheme::Http, "api.example.com", 443)]); + assert_eq!(derived.as_deref(), Some("api.example.com:443")); +} + +#[test] +fn multiple_hostnames_derive_the_registrable_common_suffix() { + let derived = compute_derived_alias(&[https("us.vendor.com"), https("eu.vendor.com")]); + assert_eq!(derived.as_deref(), Some("vendor.com")); +} + +#[test] +fn common_suffix_keeps_a_nonstandard_port() { + let derived = compute_derived_alias(&[ + endpoint(Scheme::Https, "us.vendor.com", 8443), + endpoint(Scheme::Https, "eu.vendor.com", 8443), + ]); + assert_eq!(derived.as_deref(), Some("vendor.com:8443")); +} + +#[test] +fn a_bare_public_suffix_is_not_derivable() { + // `co.uk` is a public suffix, so it is nobody's routing target. + assert_eq!(compute_derived_alias(&[https("foo.co.uk"), https("bar.co.uk")]), None); +} + +#[test] +fn heterogeneous_hostnames_without_a_common_suffix_are_not_derivable() { + assert_eq!(compute_derived_alias(&[https("us.foo.com"), https("eu.bar.com")]), None); +} + +#[test] +fn ip_pools_are_never_derivable() { + assert_eq!(compute_derived_alias(&[https("10.0.1.1"), https("10.0.1.2")]), None); +} + +#[test] +fn create_derives_when_no_alias_is_supplied() { + let alias = resolve_alias_for_create(&[https("api.openai.com")], None).unwrap(); + assert_eq!(alias, "api.openai.com"); +} + +#[test] +fn create_tolerates_an_alias_equal_to_the_derived_value() { + let alias = + resolve_alias_for_create(&[https("api.openai.com")], Some("API.OpenAI.com")).unwrap(); + assert_eq!(alias, "api.openai.com"); +} + +#[test] +fn create_rejects_an_alias_that_contradicts_derivation() { + let err = resolve_alias_for_create(&[https("api.openai.com")], Some("openai")).unwrap_err(); + assert_eq!(err.status(), 400); + assert!(err.detail.contains("auto-derived"), "{}", err.detail); +} + +#[test] +fn create_requires_an_alias_for_a_nonderivable_pool() { + let err = resolve_alias_for_create(&[https("10.0.1.1")], None).unwrap_err(); + assert_eq!(err.status(), 400); + assert!(err.detail.contains("required"), "{}", err.detail); +} + +#[test] +fn create_accepts_an_explicit_alias_for_an_ip_pool() { + let alias = resolve_alias_for_create(&[https("10.0.1.1")], Some("My-Service")).unwrap(); + assert_eq!(alias, "my-service"); +} + +#[test] +fn explicit_alias_must_match_the_schema_pattern() { + assert!(validate_alias("my-service").is_ok()); + assert!(validate_alias("vendor.com:8443").is_ok()); + assert!(validate_alias("-leading").is_err()); + assert!(validate_alias("trailing-").is_err()); + assert!(validate_alias("has space").is_err()); + assert!(validate_alias("").is_err()); +} + +#[test] +fn update_allows_an_endpoint_change_that_keeps_the_alias() { + let alias = + enforce_alias_update("vendor.com", &[https("us.vendor.com"), https("ap.vendor.com")], None) + .unwrap(); + assert_eq!(alias, "vendor.com"); +} + +#[test] +fn update_rejects_an_endpoint_change_that_would_move_the_alias() { + let err = enforce_alias_update("api.openai.com", &[https("api.anthropic.com")], None) + .unwrap_err(); + assert_eq!(err.status(), 400); + assert!(err.detail.contains("immutable"), "{}", err.detail); +} + +#[test] +fn update_rejects_a_differing_user_alias_even_for_an_ip_pool() { + let err = enforce_alias_update("my-service", &[https("10.0.1.1")], Some("other")).unwrap_err(); + assert_eq!(err.status(), 400); + assert!(err.detail.contains("immutable"), "{}", err.detail); +} + +#[test] +fn update_keeps_the_alias_of_a_nonderivable_pool() { + let alias = enforce_alias_update("my-service", &[https("10.0.1.2")], None).unwrap(); + assert_eq!(alias, "my-service"); +} + +#[test] +fn update_rejects_derivable_to_nonderivable_transitions() { + // hostname → IP: derivation now fails, so the old alias survives only if + // the operator restates it; a *different* one is refused. + let err = + enforce_alias_update("api.openai.com", &[https("10.0.1.1")], Some("ip-pool")).unwrap_err(); + assert_eq!(err.status(), 400); +} 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..953b3aa --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/error.rs @@ -0,0 +1,225 @@ +//! Gateway error catalog. +//! +//! Every error OAGW *originates* renders as RFC 9457 problem details with a GTS +//! `type` identifier and the `X-OAGW-Error-Source: gateway` header +//! (`cpt-cf-oagw-principle-rfc9457`, `cpt-cf-oagw-principle-error-source`). +//! Errors *returned by an upstream* are never wrapped — they pass through +//! unchanged, marked `X-OAGW-Error-Source: upstream`. + +use std::collections::BTreeMap; + +use serde::Serialize; +use toolkit_gts::gts_id; + +/// Response header naming who produced a response. +pub const ERROR_SOURCE_HEADER: &str = "x-oagw-error-source"; +/// Value for a response OAGW produced itself. +pub const ERROR_SOURCE_GATEWAY: &str = "gateway"; +/// Value for a response passed through from the upstream. +pub const ERROR_SOURCE_UPSTREAM: &str = "upstream"; + +/// RFC 9457 media type. +pub const PROBLEM_JSON: &str = "application/problem+json"; + +macro_rules! error_kinds { + ($( $variant:ident => ($status:expr, $gts:expr, $title:expr) ),* $(,)?) => { + /// The catalogued gateway error kinds (`docs/DESIGN.md` §3.3). + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum ErrorKind { + $( $variant, )* + } + + impl ErrorKind { + /// HTTP status this kind maps to. + #[must_use] + pub fn status(self) -> u16 { + match self { $( Self::$variant => $status, )* } + } + + /// GTS identifier carried in the problem `type` member. + #[must_use] + pub fn gts_type(self) -> &'static str { + match self { $( Self::$variant => $gts, )* } + } + + /// Human-readable summary carried in `title`. + #[must_use] + pub fn title(self) -> &'static str { + match self { $( Self::$variant => $title, )* } + } + } + }; +} + +error_kinds! { + ValidationError => (400, gts_id!("cf.core.errors.err.v1~cf.oagw.validation.error.v1"), "Validation Error"), + MissingTargetHost => (400, gts_id!("cf.core.errors.err.v1~cf.oagw.routing.missing_target_host.v1"), "Missing Target Host Header"), + InvalidTargetHost => (400, gts_id!("cf.core.errors.err.v1~cf.oagw.routing.invalid_target_host.v1"), "Invalid Target Host Format"), + UnknownTargetHost => (400, gts_id!("cf.core.errors.err.v1~cf.oagw.routing.unknown_target_host.v1"), "Unknown Target Host"), + AuthenticationFailed => (401, gts_id!("cf.core.errors.err.v1~cf.oagw.auth.failed.v1"), "Authentication Failed"), + PermissionDenied => (403, gts_id!("cf.core.errors.err.v1~cf.core.err.permission_denied.v1"), "Permission Denied"), + CorsOriginNotAllowed => (403, gts_id!("cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1"), "CORS Origin Not Allowed"), + CorsMethodNotAllowed => (403, gts_id!("cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1"), "CORS Method Not Allowed"), + RouteNotFound => (404, gts_id!("cf.core.errors.err.v1~cf.oagw.route.not_found.v1"), "Route Not Found"), + MethodNotAllowed => (405, gts_id!("cf.core.errors.err.v1~cf.oagw.method.not_allowed.v1"), "Method Not Allowed"), + Conflict => (409, gts_id!("cf.core.errors.err.v1~cf.oagw.resource.conflict.v1"), "Conflict"), + PluginInUse => (409, gts_id!("cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1"), "Plugin In Use"), + PayloadTooLarge => (413, gts_id!("cf.core.errors.err.v1~cf.oagw.payload.too_large.v1"), "Payload Too Large"), + RateLimitExceeded => (429, gts_id!("cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1"), "Rate Limit Exceeded"), + SecretNotFound => (500, gts_id!("cf.core.errors.err.v1~cf.oagw.secret.not_found.v1"), "Secret Not Found"), + Internal => (500, gts_id!("cf.core.errors.err.v1~cf.core.err.internal.v1"), "Internal Error"), + ProtocolError => (502, gts_id!("cf.core.errors.err.v1~cf.oagw.protocol.error.v1"), "Protocol Error"), + DownstreamError => (502, gts_id!("cf.core.errors.err.v1~cf.oagw.downstream.error.v1"), "Downstream Error"), + StreamAborted => (502, gts_id!("cf.core.errors.err.v1~cf.oagw.stream.aborted.v1"), "Stream Aborted"), + LinkUnavailable => (503, gts_id!("cf.core.errors.err.v1~cf.oagw.link.unavailable.v1"), "Link Unavailable"), + CircuitBreakerOpen => (503, gts_id!("cf.core.errors.err.v1~cf.oagw.circuit_breaker.open.v1"), "Circuit Breaker Open"), + PluginNotFound => (503, gts_id!("cf.core.errors.err.v1~cf.oagw.plugin.not_found.v1"), "Plugin Not Found"), + ConnectionTimeout => (504, gts_id!("cf.core.errors.err.v1~cf.oagw.timeout.connection.v1"), "Connection Timeout"), + RequestTimeout => (504, gts_id!("cf.core.errors.err.v1~cf.oagw.timeout.request.v1"), "Request Timeout"), + IdleTimeout => (504, gts_id!("cf.core.errors.err.v1~cf.oagw.timeout.idle.v1"), "Idle Timeout"), +} + +impl ErrorKind { + /// Whether a client may usefully retry (`docs/PRD.md` §5.6). + #[must_use] + pub fn retriable(self) -> bool { + matches!( + self, + Self::RateLimitExceeded + | Self::LinkUnavailable + | Self::CircuitBreakerOpen + | Self::ConnectionTimeout + | Self::RequestTimeout + | Self::IdleTimeout + ) + } +} + +/// A gateway error ready to be rendered as problem details. +#[derive(Debug, Clone)] +pub struct OagwError { + pub kind: ErrorKind, + pub detail: String, + /// Extra members merged into the problem document (`upstream_id`, `host`, + /// `valid_hosts`, `retry_after_seconds`, …). + pub extensions: BTreeMap, + /// Value for a `Retry-After` response header, in seconds. + pub retry_after_seconds: Option, + /// Extra response headers to emit alongside the problem document. + pub headers: Vec<(String, String)>, +} + +impl OagwError { + #[must_use] + pub fn new(kind: ErrorKind, detail: impl Into) -> Self { + Self { + kind, + detail: detail.into(), + extensions: BTreeMap::new(), + retry_after_seconds: None, + headers: Vec::new(), + } + } + + /// Attach a response header to emit with the problem document. + #[must_use] + pub fn with_header(mut self, name: &str, value: impl Into) -> Self { + self.headers.push((name.to_owned(), value.into())); + self + } + + #[must_use] + pub fn with(mut self, key: &str, value: impl Into) -> Self { + self.extensions.insert(key.to_owned(), value.into()); + self + } + + #[must_use] + pub fn with_retry_after(mut self, seconds: u64) -> Self { + self.retry_after_seconds = Some(seconds); + self.extensions.insert( + "retry_after_seconds".to_owned(), + serde_json::Value::from(seconds), + ); + self + } + + #[must_use] + pub fn status(&self) -> u16 { + self.kind.status() + } + + /// Build the wire document, anchoring `instance` at the request URI. + #[must_use] + pub fn to_problem(&self, instance: Option<&str>) -> ProblemJson { + ProblemJson { + problem_type: self.kind.gts_type().to_owned(), + title: self.kind.title().to_owned(), + status: self.kind.status(), + detail: self.detail.clone(), + instance: instance.map(ToOwned::to_owned), + extensions: self.extensions.clone(), + } + } + + // --- Constructors for the shapes used across the gear ------------------ + + #[must_use] + pub fn validation(detail: impl Into) -> Self { + Self::new(ErrorKind::ValidationError, detail) + } + + #[must_use] + pub fn not_found(detail: impl Into) -> Self { + Self::new(ErrorKind::RouteNotFound, detail) + } + + #[must_use] + pub fn conflict(detail: impl Into) -> Self { + Self::new(ErrorKind::Conflict, detail) + } + + #[must_use] + pub fn forbidden(detail: impl Into) -> Self { + Self::new(ErrorKind::PermissionDenied, detail) + } + + #[must_use] + pub fn internal(detail: impl Into) -> Self { + Self::new(ErrorKind::Internal, detail) + } + + #[must_use] + pub fn unavailable(detail: impl Into) -> Self { + Self::new(ErrorKind::LinkUnavailable, detail) + } +} + +impl std::fmt::Display for OagwError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({}): {}", self.kind.title(), self.status(), self.detail) + } +} + +impl std::error::Error for OagwError {} + +/// RFC 9457 problem document with OAGW extension members. +#[derive(Debug, Clone, Serialize)] +pub struct ProblemJson { + #[serde(rename = "type")] + pub problem_type: String, + pub title: String, + pub status: u16, + pub detail: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance: Option, + #[serde(flatten)] + pub extensions: BTreeMap, +} + +/// Convenience alias for fallible domain operations. +pub type OagwResult = Result; + +#[cfg(test)] +#[path = "error_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/error_tests.rs b/gears/system/oagw/oagw/src/domain/error_tests.rs new file mode 100644 index 0000000..e9f3595 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/error_tests.rs @@ -0,0 +1,109 @@ +//! The error catalog's status / type / retriability table. + +use super::*; + +#[test] +fn the_catalog_matches_the_documented_status_codes() { + for (kind, status) in [ + (ErrorKind::ValidationError, 400), + (ErrorKind::MissingTargetHost, 400), + (ErrorKind::InvalidTargetHost, 400), + (ErrorKind::UnknownTargetHost, 400), + (ErrorKind::AuthenticationFailed, 401), + (ErrorKind::CorsOriginNotAllowed, 403), + (ErrorKind::CorsMethodNotAllowed, 403), + (ErrorKind::RouteNotFound, 404), + (ErrorKind::PluginInUse, 409), + (ErrorKind::PayloadTooLarge, 413), + (ErrorKind::RateLimitExceeded, 429), + (ErrorKind::SecretNotFound, 500), + (ErrorKind::ProtocolError, 502), + (ErrorKind::DownstreamError, 502), + (ErrorKind::StreamAborted, 502), + (ErrorKind::LinkUnavailable, 503), + (ErrorKind::CircuitBreakerOpen, 503), + (ErrorKind::PluginNotFound, 503), + (ErrorKind::ConnectionTimeout, 504), + (ErrorKind::RequestTimeout, 504), + (ErrorKind::IdleTimeout, 504), + ] { + assert_eq!(kind.status(), status, "{kind:?}"); + } +} + +#[test] +fn the_catalog_matches_the_documented_gts_identifiers() { + assert_eq!( + ErrorKind::RouteNotFound.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); + assert_eq!( + ErrorKind::RateLimitExceeded.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1" + ); + assert_eq!( + ErrorKind::MissingTargetHost.gts_type(), + "gts.cf.core.errors.err.v1~cf.oagw.routing.missing_target_host.v1" + ); +} + +#[test] +fn retriability_follows_the_prd_table() { + assert!(ErrorKind::RateLimitExceeded.retriable()); + assert!(ErrorKind::CircuitBreakerOpen.retriable()); + assert!(ErrorKind::RequestTimeout.retriable()); + assert!(!ErrorKind::ValidationError.retriable()); + assert!(!ErrorKind::RouteNotFound.retriable()); + assert!(!ErrorKind::SecretNotFound.retriable()); +} + +#[test] +fn a_problem_document_carries_the_rfc_9457_members_and_extensions() { + let error = OagwError::new(ErrorKind::RateLimitExceeded, "too fast") + .with("host", "api.openai.com") + .with_retry_after(15); + let problem = error.to_problem(Some("/oagw/v1/proxy/api.openai.com/v1/chat")); + let json = serde_json::to_value(&problem).unwrap(); + + assert_eq!( + json["type"], + "gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1" + ); + assert_eq!(json["title"], "Rate Limit Exceeded"); + assert_eq!(json["status"], 429); + assert_eq!(json["detail"], "too fast"); + assert_eq!(json["instance"], "/oagw/v1/proxy/api.openai.com/v1/chat"); + // Extension members are flattened alongside the standard ones. + assert_eq!(json["host"], "api.openai.com"); + assert_eq!(json["retry_after_seconds"], 15); +} + +#[test] +fn retry_after_is_available_both_as_a_member_and_a_header_value() { + let error = OagwError::new(ErrorKind::LinkUnavailable, "down").with_retry_after(7); + assert_eq!(error.retry_after_seconds, Some(7)); + assert_eq!( + error.extensions.get("retry_after_seconds"), + Some(&serde_json::Value::from(7)) + ); +} + +#[test] +fn extra_headers_ride_along_with_the_error() { + let error = OagwError::new(ErrorKind::RateLimitExceeded, "too fast") + .with_header("X-RateLimit-Limit", "100"); + assert_eq!( + error.headers, + vec![("X-RateLimit-Limit".to_owned(), "100".to_owned())] + ); +} + +#[test] +fn the_shorthand_constructors_pick_the_right_kind() { + assert_eq!(OagwError::validation("x").status(), 400); + assert_eq!(OagwError::not_found("x").status(), 404); + assert_eq!(OagwError::conflict("x").status(), 409); + assert_eq!(OagwError::forbidden("x").status(), 403); + assert_eq!(OagwError::internal("x").status(), 500); + assert_eq!(OagwError::unavailable("x").status(), 503); +} diff --git a/gears/system/oagw/oagw/src/domain/gts.rs b/gears/system/oagw/oagw/src/domain/gts.rs new file mode 100644 index 0000000..23f4866 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/gts.rs @@ -0,0 +1,134 @@ +//! GTS identifiers owned by OAGW, and the helpers that parse them. +//! +//! Resource ids travel as *anonymous* GTS identifiers — +//! `gts.cf.core.oagw.upstream.v1~{uuid}` — while named plugins carry a +//! symbolic instance part (`…auth_plugin.v1~cf.core.oagw.apikey.v1`). Parsing +//! the instance part is therefore the single decision point between "resolve +//! from the in-process registry" and "resolve from the plugin store". + +use toolkit_gts::gts_id; +use uuid::Uuid; + +// --- Resource base types --------------------------------------------------- + +pub const UPSTREAM_BASE: &str = gts_id!("cf.core.oagw.upstream.v1~"); +pub const ROUTE_BASE: &str = gts_id!("cf.core.oagw.route.v1~"); +pub const PROXY_BASE: &str = gts_id!("cf.core.oagw.proxy.v1~"); + +pub const AUTH_PLUGIN_BASE: &str = gts_id!("cf.core.oagw.auth_plugin.v1~"); +pub const GUARD_PLUGIN_BASE: &str = gts_id!("cf.core.oagw.guard_plugin.v1~"); +pub const TRANSFORM_PLUGIN_BASE: &str = gts_id!("cf.core.oagw.transform_plugin.v1~"); + +pub const PROTOCOL_BASE: &str = gts_id!("cf.core.oagw.protocol.v1~"); +pub const PROTOCOL_HTTP: &str = gts_id!("cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"); +pub const PROTOCOL_GRPC: &str = gts_id!("cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1"); + +// --- Built-in auth plugins ------------------------------------------------- + +pub const NOOP_AUTH_PLUGIN_ID: &str = gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1"); +pub const APIKEY_AUTH_PLUGIN_ID: &str = + gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1"); +pub const OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID: &str = + gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1"); +pub const OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID: &str = + gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1"); +/// Catalog identifier only — no backing `AuthPlugin` implementation. +pub const BASIC_AUTH_PLUGIN_ID: &str = gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.basic.v1"); +/// Catalog identifier only — no backing `AuthPlugin` implementation. +pub const BEARER_AUTH_PLUGIN_ID: &str = + gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.bearer.v1"); + +// --- Built-in guard plugins ------------------------------------------------ + +pub const REQUIRED_HEADERS_GUARD_PLUGIN_ID: &str = + gts_id!("cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"); +/// Catalog identifier only — timeout is core Data Plane configuration. +pub const TIMEOUT_GUARD_PLUGIN_ID: &str = + gts_id!("cf.core.oagw.guard_plugin.v1~cf.core.oagw.timeout.v1"); +/// Catalog identifier only — CORS is configured via `Upstream.cors`. +pub const CORS_GUARD_PLUGIN_ID: &str = gts_id!("cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1"); + +// --- Built-in transform plugins -------------------------------------------- + +pub const REQUEST_ID_TRANSFORM_PLUGIN_ID: &str = + gts_id!("cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"); +/// Catalog identifier only — logging is core Data Plane instrumentation. +pub const LOGGING_TRANSFORM_PLUGIN_ID: &str = + gts_id!("cf.core.oagw.transform_plugin.v1~cf.core.oagw.logging.v1"); +/// Catalog identifier only — metrics are core Data Plane instrumentation. +pub const METRICS_TRANSFORM_PLUGIN_ID: &str = + gts_id!("cf.core.oagw.transform_plugin.v1~cf.core.oagw.metrics.v1"); + +// --- Parsing helpers ------------------------------------------------------- + +/// Split a GTS identifier into `(base_type_including_tilde, instance_part)`. +/// +/// Returns `None` when the identifier carries no instance part. +#[must_use] +pub fn split_instance(gts_id: &str) -> Option<(&str, &str)> { + let idx = gts_id.rfind('~')?; + let (base, instance) = gts_id.split_at(idx + 1); + if instance.is_empty() { + None + } else { + Some((base, instance)) + } +} + +/// The UUID an identifier resolves to, if its instance part is one. +/// +/// A bare UUID (no `~`) also resolves, so `plugins.items` may carry either the +/// full GTS form or a raw UUID as `schemas/upstream.v1.schema.json` allows. +#[must_use] +pub fn instance_uuid(gts_id: &str) -> Option { + match split_instance(gts_id) { + Some((_, instance)) => Uuid::parse_str(instance).ok(), + None => Uuid::parse_str(gts_id).ok(), + } +} + +/// Resolve a path parameter that may be a bare UUID or an anonymous GTS id. +/// +/// When `expected_base` is given, a GTS-shaped input whose base does not match +/// is rejected — `gts.cf.core.oagw.route.v1~{uuid}` must not address an +/// upstream. +#[must_use] +pub fn parse_resource_id(raw: &str, expected_base: Option<&str>) -> Option { + let raw = raw.trim(); + if let Some((base, instance)) = split_instance(raw) { + if let Some(expected) = expected_base + && !base.eq_ignore_ascii_case(expected) + { + return None; + } + return Uuid::parse_str(instance).ok(); + } + Uuid::parse_str(raw).ok() +} + +/// Resolve a plugin path parameter to `(kind_base, uuid)`. +#[must_use] +pub fn parse_plugin_id(raw: &str) -> Option<(Option<&'static str>, Uuid)> { + let raw = raw.trim(); + if let Some((base, instance)) = split_instance(raw) { + let known = [AUTH_PLUGIN_BASE, GUARD_PLUGIN_BASE, TRANSFORM_PLUGIN_BASE] + .into_iter() + .find(|k| k.eq_ignore_ascii_case(base))?; + return Uuid::parse_str(instance).ok().map(|id| (Some(known), id)); + } + Uuid::parse_str(raw).ok().map(|id| (None, id)) +} + +/// Whether `gts_id` names a plugin of `base`, either as a full GTS id or as a +/// bare UUID (which is kind-agnostic and therefore always a candidate). +#[must_use] +pub fn matches_plugin_base(gts_id: &str, base: &str) -> bool { + match split_instance(gts_id) { + Some((found, _)) => found.eq_ignore_ascii_case(base), + None => Uuid::parse_str(gts_id).is_ok(), + } +} + +#[cfg(test)] +#[path = "gts_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/gts_tests.rs b/gears/system/oagw/oagw/src/domain/gts_tests.rs new file mode 100644 index 0000000..265b91c --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/gts_tests.rs @@ -0,0 +1,102 @@ +//! Parsing of the GTS identifiers OAGW accepts on the wire. + +use super::*; + +const SAMPLE: &str = "3f2504e0-4f89-11d3-9a0c-0305e82c3301"; + +#[test] +fn split_instance_separates_base_and_instance() { + let (base, instance) = split_instance(APIKEY_AUTH_PLUGIN_ID).unwrap(); + assert_eq!(base, AUTH_PLUGIN_BASE); + assert_eq!(instance, "cf.core.oagw.apikey.v1"); +} + +#[test] +fn a_bare_base_type_has_no_instance() { + assert!(split_instance(UPSTREAM_BASE).is_none()); +} + +#[test] +fn instance_uuid_reads_both_spellings() { + let expected = Uuid::parse_str(SAMPLE).unwrap(); + assert_eq!( + instance_uuid(&format!("{GUARD_PLUGIN_BASE}{SAMPLE}")), + Some(expected) + ); + assert_eq!(instance_uuid(SAMPLE), Some(expected)); + assert_eq!(instance_uuid(APIKEY_AUTH_PLUGIN_ID), None); +} + +#[test] +fn resource_ids_accept_a_uuid_or_the_anonymous_gts_form() { + let expected = Uuid::parse_str(SAMPLE).unwrap(); + assert_eq!(parse_resource_id(SAMPLE, Some(UPSTREAM_BASE)), Some(expected)); + assert_eq!( + parse_resource_id(&format!("{UPSTREAM_BASE}{SAMPLE}"), Some(UPSTREAM_BASE)), + Some(expected) + ); +} + +#[test] +fn a_route_identifier_cannot_address_an_upstream() { + assert_eq!( + parse_resource_id(&format!("{ROUTE_BASE}{SAMPLE}"), Some(UPSTREAM_BASE)), + None + ); +} + +#[test] +fn malformed_resource_ids_are_rejected() { + assert_eq!(parse_resource_id("not-a-uuid", Some(UPSTREAM_BASE)), None); + assert_eq!( + parse_resource_id(&format!("{UPSTREAM_BASE}not-a-uuid"), Some(UPSTREAM_BASE)), + None + ); +} + +#[test] +fn plugin_ids_carry_their_kind_when_written_in_full() { + let expected = Uuid::parse_str(SAMPLE).unwrap(); + let (base, id) = parse_plugin_id(&format!("{TRANSFORM_PLUGIN_BASE}{SAMPLE}")).unwrap(); + assert_eq!(base, Some(TRANSFORM_PLUGIN_BASE)); + assert_eq!(id, expected); + + // A bare UUID is kind-agnostic. + let (base, id) = parse_plugin_id(SAMPLE).unwrap(); + assert_eq!(base, None); + assert_eq!(id, expected); + + // An unrelated base type is not a plugin identifier. + assert!(parse_plugin_id(&format!("{UPSTREAM_BASE}{SAMPLE}")).is_none()); +} + +#[test] +fn plugin_base_matching_admits_bare_uuids() { + assert!(matches_plugin_base( + REQUIRED_HEADERS_GUARD_PLUGIN_ID, + GUARD_PLUGIN_BASE + )); + assert!(!matches_plugin_base( + REQUIRED_HEADERS_GUARD_PLUGIN_ID, + AUTH_PLUGIN_BASE + )); + assert!(matches_plugin_base(SAMPLE, AUTH_PLUGIN_BASE)); +} + +#[test] +fn the_catalog_identifiers_are_the_documented_ones() { + assert_eq!(UPSTREAM_BASE, "gts.cf.core.oagw.upstream.v1~"); + assert_eq!(ROUTE_BASE, "gts.cf.core.oagw.route.v1~"); + assert_eq!( + PROTOCOL_HTTP, + "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + ); + assert_eq!( + REQUIRED_HEADERS_GUARD_PLUGIN_ID, + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1" + ); + assert_eq!( + REQUEST_ID_TRANSFORM_PLUGIN_ID, + "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1" + ); +} diff --git a/gears/system/oagw/oagw/src/domain/input.rs b/gears/system/oagw/oagw/src/domain/input.rs new file mode 100644 index 0000000..e374497 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/input.rs @@ -0,0 +1,112 @@ +//! Request payloads accepted by the management API. +//! +//! These mirror the published JSON schemas. `id` is accepted and ignored so a +//! client can round-trip a `GET` response straight back into a `PUT`. + +use serde::Deserialize; +use uuid::Uuid; + +use crate::domain::model::{ + AuthConfig, CorsConfig, HeadersConfig, MatchConfig, PluginKind, PluginPhase, PluginsConfig, + Protocol, RateLimitConfig, ServerConfig, default_true, +}; + +/// `POST`/`PUT` body for `/oagw/v1/upstreams`. +#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct UpstreamInput { + /// Server-generated; ignored on write. + #[serde(default)] + pub id: Option, + /// Server-owned; ignored on write. Accepted so a `GET` response can be + /// sent straight back as a `PUT` body. + #[serde(default)] + pub tenant_id: Option, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub alias: Option, + #[serde(default)] + pub tags: Vec, + pub server: ServerConfig, + pub protocol: Protocol, + #[serde(default)] + pub auth: Option, + #[serde(default)] + pub headers: Option, + #[serde(default)] + pub plugins: Option, + #[serde(default)] + pub rate_limit: Option, + #[serde(default)] + pub cors: Option, +} + +/// `POST`/`PUT` body for `/oagw/v1/routes`. +/// +/// `upstream_id` is required on create and immutable on replace. +#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] +pub struct RouteInput { + /// Server-generated; ignored on write. + #[serde(default)] + pub id: Option, + /// Server-owned; ignored on write. + #[serde(default)] + pub tenant_id: Option, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub priority: i32, + #[serde(default)] + pub tags: Vec, + #[serde(default)] + pub upstream_id: Option, + pub r#match: MatchConfig, + #[serde(default)] + pub plugins: Option, + #[serde(default)] + pub rate_limit: Option, + #[serde(default)] + pub cors: Option, +} + +/// `POST` body for `/oagw/v1/plugins`. Plugins are immutable — there is no +/// replace form. +#[derive(Debug, Clone, Deserialize, utoipa::ToSchema)] +pub struct PluginInput { + /// Server-generated; ignored on write. + #[serde(default)] + pub id: Option, + pub name: String, + #[serde(default)] + pub description: Option, + #[serde(rename = "plugin_type", alias = "type")] + pub plugin_type: PluginKind, + #[serde(default)] + pub phases: Vec, + #[serde(default)] + pub config_schema: Option, + #[serde(default)] + pub source_code: String, +} + +/// Query parameters shared by the list endpoints (`docs/DESIGN.md` §3.3). +#[derive(Debug, Clone, Default, Deserialize)] +pub struct ListQuery { + #[serde(rename = "$filter")] + pub filter: Option, + #[serde(rename = "$select")] + pub select: Option, + #[serde(rename = "$orderby")] + pub orderby: Option, + #[serde(rename = "$top")] + pub top: Option, + #[serde(rename = "$skip")] + pub skip: Option, +} + +// The `OperationBuilder` request-body helpers gate on these markers so only +// vetted DTOs can be published as schemas. +impl toolkit::api::api_dto::RequestApiDto for UpstreamInput {} +impl toolkit::api::api_dto::RequestApiDto for RouteInput {} +impl toolkit::api::api_dto::RequestApiDto for PluginInput {} diff --git a/gears/system/oagw/oagw/src/domain/merge.rs b/gears/system/oagw/oagw/src/domain/merge.rs new file mode 100644 index 0000000..d42faee --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/merge.rs @@ -0,0 +1,187 @@ +//! Hierarchical configuration merge. +//! +//! Two independent axes combine here: +//! +//! * **Layering** (`cpt-cf-oagw-fr-config-layering`) — Upstream (base) < +//! Route < Tenant. +//! * **Sharing** (`cpt-cf-oagw-fr-hierarchical-config`) — `private` hides a +//! field from descendants, `inherit` lets them override it, `enforce` pins +//! it. Rate limits additionally clamp to the stricter of the two, and +//! enforced ancestor limits survive alias shadowing. + +use crate::domain::model::{ + AuthConfig, CorsConfig, HeadersConfig, PluginBinding, RateLimitConfig, Route, SharingMode, + Upstream, +}; + +/// The configuration a single proxy request actually executes under. +#[derive(Debug, Clone, Default)] +pub struct EffectiveConfig { + pub auth: Option, + pub headers: HeadersConfig, + /// Upstream-bound bindings first, then route-bound ones. + pub plugins: Vec, + pub rate_limit: Option, + pub cors: Option, + pub tags: Vec, +} + +/// Merge the selected upstream, its matched route and the ancestor upstreams +/// that share the alias (ordered parent → root). +#[must_use] +pub fn effective_config( + selected: &Upstream, + ancestors: &[Upstream], + route: Option<&Route>, +) -> EffectiveConfig { + EffectiveConfig { + auth: merge_auth(selected, ancestors), + headers: selected.headers.clone().unwrap_or_default(), + plugins: merge_plugins(selected, ancestors, route), + rate_limit: merge_rate_limit(selected, ancestors, route), + cors: merge_cors(selected, ancestors, route), + tags: merge_tags(selected, ancestors, route), + } +} + +/// An ancestor with `sharing: enforce` pins the credential; otherwise the +/// closest configured auth wins, falling back to a visible ancestor's. +fn merge_auth(selected: &Upstream, ancestors: &[Upstream]) -> Option { + // Root-most enforcement wins: walk from the root down so the outermost + // `enforce` is the last one applied. + if let Some(enforced) = ancestors + .iter() + .rev() + .filter_map(|a| a.auth.as_ref()) + .find(|auth| auth.sharing.is_enforced()) + { + return Some(enforced.clone()); + } + if let Some(own) = selected.auth.as_ref().filter(|a| a.plugin_type.is_some()) { + return Some(own.clone()); + } + ancestors + .iter() + .filter_map(|a| a.auth.as_ref()) + .find(|auth| auth.sharing.is_visible_to_descendants() && auth.plugin_type.is_some()) + .cloned() +} + +/// Ancestor chains prepend (root-most first), then the selected upstream's, +/// then the route's. Enforced ancestor plugins can never be dropped. +fn merge_plugins( + selected: &Upstream, + ancestors: &[Upstream], + route: Option<&Route>, +) -> Vec { + let mut chain: Vec = Vec::new(); + for ancestor in ancestors.iter().rev() { + if let Some(plugins) = ancestor.plugins.as_ref() + && plugins.sharing.is_visible_to_descendants() + { + chain.extend(plugins.items.iter().cloned()); + } + } + if let Some(plugins) = selected.plugins.as_ref() { + chain.extend(plugins.items.iter().cloned()); + } + if let Some(plugins) = route.and_then(|r| r.plugins.as_ref()) { + chain.extend(plugins.items.iter().cloned()); + } + chain +} + +/// Route overrides upstream; every enforced ancestor limit then clamps the +/// result to the stricter value. +fn merge_rate_limit( + selected: &Upstream, + ancestors: &[Upstream], + route: Option<&Route>, +) -> Option { + let mut effective = route + .and_then(|r| r.rate_limit) + .or(selected.rate_limit); + + for ancestor in ancestors { + let Some(limit) = ancestor.rate_limit else { + continue; + }; + if !limit.sharing.is_visible_to_descendants() { + continue; + } + effective = Some(match effective { + // An `inherit` ancestor limit only applies when the descendant + // states none; an `enforce` one always clamps. + Some(own) if limit.sharing.is_enforced() => RateLimitConfig::stricter_of(own, limit), + Some(own) => own, + None => limit, + }); + } + effective +} + +/// `enforce` pins the ancestor policy, `inherit` unions the origin lists, and +/// a route-level policy overrides the upstream's. +fn merge_cors( + selected: &Upstream, + ancestors: &[Upstream], + route: Option<&Route>, +) -> Option { + if let Some(enforced) = ancestors + .iter() + .rev() + .filter_map(|a| a.cors.as_ref()) + .find(|cors| cors.sharing.is_enforced()) + { + return Some(enforced.clone()); + } + + let mut effective = route + .and_then(|r| r.cors.clone()) + .or_else(|| selected.cors.clone()); + + for ancestor in ancestors { + let Some(inherited) = ancestor.cors.as_ref() else { + continue; + }; + if inherited.sharing != SharingMode::Inherit { + continue; + } + effective = Some(match effective { + Some(mut own) => { + for origin in &inherited.allowed_origins { + if !own.allowed_origins.contains(origin) { + own.allowed_origins.push(origin.clone()); + } + } + own + } + None => inherited.clone(), + }); + } + effective +} + +/// Tags have no sharing mode: they always union, ancestors first. +fn merge_tags(selected: &Upstream, ancestors: &[Upstream], route: Option<&Route>) -> Vec { + let mut tags: Vec = Vec::new(); + let mut push = |candidates: &[String]| { + for tag in candidates { + if !tags.contains(tag) { + tags.push(tag.clone()); + } + } + }; + for ancestor in ancestors.iter().rev() { + push(&ancestor.tags); + } + push(&selected.tags); + if let Some(route) = route { + push(&route.tags); + } + tags +} + +#[cfg(test)] +#[path = "merge_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/merge_tests.rs b/gears/system/oagw/oagw/src/domain/merge_tests.rs new file mode 100644 index 0000000..a1a0a90 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/merge_tests.rs @@ -0,0 +1,285 @@ +//! Hierarchical merge: sharing modes and the upstream < route < tenant order. + +use super::*; +use crate::domain::model::{ + AuthConfig, BurstCapacity, ConfigMap, CorsConfig, Endpoint, HttpMatch, MatchConfig, + PathSuffixMode, PluginBinding, PluginsConfig, Protocol, RateLimitAlgorithm, RateLimitScope, + RateLimitStrategy, RateWindow, Scheme, ServerConfig, SustainedRate, +}; +use uuid::Uuid; + +fn upstream(alias: &str) -> Upstream { + Upstream { + id: Uuid::new_v4(), + tenant_id: Uuid::new_v4(), + alias: alias.to_owned(), + enabled: true, + protocol: Protocol::Http, + server: ServerConfig { + endpoints: vec![Endpoint { + scheme: Scheme::Https, + host: "api.openai.com".to_owned(), + port: 443, + }], + }, + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + tags: Vec::new(), + } +} + +fn route() -> Route { + Route { + id: Uuid::new_v4(), + tenant_id: Uuid::new_v4(), + upstream_id: Uuid::new_v4(), + enabled: true, + priority: 0, + r#match: MatchConfig { + http: Some(HttpMatch { + methods: vec!["GET".to_owned()], + path: "/".to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + plugins: None, + rate_limit: None, + cors: None, + tags: Vec::new(), + } +} + +fn auth(secret: &str, sharing: SharingMode) -> AuthConfig { + let mut config = ConfigMap::new(); + config.insert("secret_ref".to_owned(), secret.into()); + AuthConfig { + plugin_type: Some(crate::domain::gts::APIKEY_AUTH_PLUGIN_ID.to_owned()), + sharing, + config, + } +} + +fn limit(rate: u32, sharing: SharingMode) -> RateLimitConfig { + RateLimitConfig { + sharing, + algorithm: RateLimitAlgorithm::TokenBucket, + sustained: SustainedRate { + rate, + window: RateWindow::Minute, + }, + burst: BurstCapacity { capacity: None }, + budget: None, + scope: RateLimitScope::Tenant, + strategy: RateLimitStrategy::Reject, + cost: 1, + response_headers: true, + } +} + +fn binding(name: &str) -> PluginBinding { + PluginBinding { + plugin_ref: name.to_owned(), + plugin_uuid: None, + config: ConfigMap::new(), + } +} + +fn plugins(sharing: SharingMode, refs: &[&str]) -> PluginsConfig { + PluginsConfig { + sharing, + items: refs.iter().map(|name| binding(name)).collect(), + } +} + +fn cors(origins: &[&str], sharing: SharingMode) -> CorsConfig { + CorsConfig { + sharing, + enabled: true, + allowed_origins: origins.iter().map(|o| (*o).to_owned()).collect(), + allowed_methods: vec!["GET".to_owned()], + expose_headers: Vec::new(), + allow_credentials: false, + } +} + +#[test] +fn a_descendants_own_auth_wins_over_an_inherited_one() { + let mut child = upstream("api.openai.com"); + child.auth = Some(auth("cred://mine", SharingMode::Private)); + let mut parent = upstream("api.openai.com"); + parent.auth = Some(auth("cred://partner", SharingMode::Inherit)); + + let effective = effective_config(&child, &[parent], None); + let secret = effective.auth.unwrap().config.get("secret_ref").cloned(); + assert_eq!(secret, Some("cred://mine".into())); +} + +#[test] +fn an_enforced_ancestor_auth_pins_the_credential() { + let mut child = upstream("api.openai.com"); + child.auth = Some(auth("cred://mine", SharingMode::Private)); + let mut parent = upstream("api.openai.com"); + parent.auth = Some(auth("cred://partner", SharingMode::Enforce)); + + let effective = effective_config(&child, &[parent], None); + let secret = effective.auth.unwrap().config.get("secret_ref").cloned(); + assert_eq!(secret, Some("cred://partner".into())); +} + +#[test] +fn a_private_ancestor_auth_is_invisible() { + let child = upstream("api.openai.com"); + let mut parent = upstream("api.openai.com"); + parent.auth = Some(auth("cred://partner", SharingMode::Private)); + + let effective = effective_config(&child, &[parent], None); + assert!(effective.auth.is_none()); +} + +#[test] +fn an_inherited_ancestor_auth_fills_a_gap() { + let child = upstream("api.openai.com"); + let mut parent = upstream("api.openai.com"); + parent.auth = Some(auth("cred://partner", SharingMode::Inherit)); + + let effective = effective_config(&child, &[parent], None); + let secret = effective.auth.unwrap().config.get("secret_ref").cloned(); + assert_eq!(secret, Some("cred://partner".into())); +} + +#[test] +fn a_route_rate_limit_overrides_the_upstreams() { + let mut up = upstream("api.openai.com"); + up.rate_limit = Some(limit(1_000, SharingMode::Private)); + let mut route = route(); + route.rate_limit = Some(limit(10, SharingMode::Private)); + + let effective = effective_config(&up, &[], Some(&route)); + assert_eq!(effective.rate_limit.unwrap().sustained.rate, 10); +} + +#[test] +fn an_enforced_ancestor_limit_clamps_to_the_stricter_value() { + let mut child = upstream("api.openai.com"); + child.rate_limit = Some(limit(500, SharingMode::Private)); + let mut parent = upstream("api.openai.com"); + parent.rate_limit = Some(limit(100, SharingMode::Enforce)); + + let effective = effective_config(&child, &[parent], None); + assert_eq!(effective.rate_limit.unwrap().sustained.rate, 100); +} + +#[test] +fn an_enforced_ancestor_limit_never_loosens_a_stricter_descendant() { + let mut child = upstream("api.openai.com"); + child.rate_limit = Some(limit(10, SharingMode::Private)); + let mut parent = upstream("api.openai.com"); + parent.rate_limit = Some(limit(10_000, SharingMode::Enforce)); + + let effective = effective_config(&child, &[parent], None); + assert_eq!(effective.rate_limit.unwrap().sustained.rate, 10); +} + +#[test] +fn an_inherited_ancestor_limit_only_applies_when_the_descendant_states_none() { + let child = upstream("api.openai.com"); + let mut parent = upstream("api.openai.com"); + parent.rate_limit = Some(limit(250, SharingMode::Inherit)); + assert_eq!( + effective_config(&child, &[parent.clone()], None) + .rate_limit + .unwrap() + .sustained + .rate, + 250 + ); + + let mut child = upstream("api.openai.com"); + child.rate_limit = Some(limit(9_000, SharingMode::Private)); + assert_eq!( + effective_config(&child, &[parent], None) + .rate_limit + .unwrap() + .sustained + .rate, + 9_000 + ); +} + +#[test] +fn a_private_ancestor_limit_does_not_reach_the_descendant() { + let child = upstream("api.openai.com"); + let mut parent = upstream("api.openai.com"); + parent.rate_limit = Some(limit(5, SharingMode::Private)); + assert!(effective_config(&child, &[parent], None).rate_limit.is_none()); +} + +#[test] +fn plugin_chains_concatenate_root_first_then_upstream_then_route() { + let mut child = upstream("api.openai.com"); + child.plugins = Some(plugins(SharingMode::Private, &["u1", "u2"])); + let mut parent = upstream("api.openai.com"); + parent.plugins = Some(plugins(SharingMode::Inherit, &["p1"])); + let mut root = upstream("api.openai.com"); + root.plugins = Some(plugins(SharingMode::Enforce, &["r1"])); + let mut route = route(); + route.plugins = Some(plugins(SharingMode::Private, &["rt1"])); + + let effective = effective_config(&child, &[parent, root], Some(&route)); + let names: Vec<&str> = effective + .plugins + .iter() + .map(|item| item.plugin_ref.as_str()) + .collect(); + assert_eq!(names, ["r1", "p1", "u1", "u2", "rt1"]); +} + +#[test] +fn a_private_ancestor_chain_is_not_inherited() { + let child = upstream("api.openai.com"); + let mut parent = upstream("api.openai.com"); + parent.plugins = Some(plugins(SharingMode::Private, &["p1"])); + assert!(effective_config(&child, &[parent], None).plugins.is_empty()); +} + +#[test] +fn inherited_cors_origins_are_unioned() { + let mut child = upstream("api.openai.com"); + child.cors = Some(cors(&["https://admin.example.com"], SharingMode::Private)); + let mut parent = upstream("api.openai.com"); + parent.cors = Some(cors(&["https://app.example.com"], SharingMode::Inherit)); + + let effective = effective_config(&child, &[parent], None).cors.unwrap(); + assert!(effective.origin_allowed("https://admin.example.com")); + assert!(effective.origin_allowed("https://app.example.com")); +} + +#[test] +fn enforced_cors_pins_the_ancestor_policy() { + let mut child = upstream("api.openai.com"); + child.cors = Some(cors(&["https://anything.example"], SharingMode::Private)); + let mut parent = upstream("api.openai.com"); + parent.cors = Some(cors(&["https://app.example.com"], SharingMode::Enforce)); + + let effective = effective_config(&child, &[parent], None).cors.unwrap(); + assert!(effective.origin_allowed("https://app.example.com")); + assert!(!effective.origin_allowed("https://anything.example")); +} + +#[test] +fn tags_union_add_only_across_the_hierarchy() { + let mut child = upstream("api.openai.com"); + child.tags = vec!["llm".to_owned()]; + let mut parent = upstream("api.openai.com"); + parent.tags = vec!["openai".to_owned(), "llm".to_owned()]; + let mut route = route(); + route.tags = vec!["chat".to_owned()]; + + let effective = effective_config(&child, &[parent], Some(&route)); + assert_eq!(effective.tags, ["openai", "llm", "chat"]); +} 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..42641f0 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/mod.rs @@ -0,0 +1,12 @@ +//! Domain layer — business rules with no infrastructure dependencies. + +pub mod alias; +pub mod error; +pub mod gts; +pub mod input; +pub mod merge; +pub mod model; +pub mod plugin; +pub mod repo; +pub mod services; +pub mod tenant; 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..5c2a635 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/model.rs @@ -0,0 +1,695 @@ +//! Domain model — the configuration entities OAGW owns. +//! +//! The shapes here mirror `docs/schemas/upstream.v1.schema.json` and +//! `docs/schemas/route.v1.schema.json` field for field, so the same types are +//! usable as wire representations without a second, drifting copy. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::domain::gts; + +/// Free-form configuration blob handed to a plugin. +pub type ConfigMap = BTreeMap; + +// --------------------------------------------------------------------------- +// Shared enums +// --------------------------------------------------------------------------- + +/// Visibility of a configuration field across the tenant hierarchy. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum SharingMode { + /// Not visible to descendants. + #[default] + Private, + /// Visible; a descendant may override. + Inherit, + /// Visible; a descendant may not override. + Enforce, +} + +impl SharingMode { + #[must_use] + pub fn is_visible_to_descendants(self) -> bool { + matches!(self, Self::Inherit | Self::Enforce) + } + + #[must_use] + pub fn is_enforced(self) -> bool { + matches!(self, Self::Enforce) + } +} + +/// Transport scheme of an upstream endpoint. +/// +/// The TLS family is what `cpt-cf-oagw-constraint-https-only` describes as the +/// default posture. The plaintext members are accepted by the management API +/// regardless; whether a plaintext connection is actually dialled is decided at +/// proxy time by `allow_http_upstream`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "lowercase")] +pub enum Scheme { + Http, + Https, + Ws, + Wss, + /// WebTransport. + Wt, + Grpc, +} + +impl Default for Scheme { + fn default() -> Self { + Self::Https + } +} + +impl Scheme { + /// Whether a connection with this scheme is wrapped in TLS. + #[must_use] + pub fn is_tls(self) -> bool { + !matches!(self, Self::Http | Self::Ws) + } + + /// Port omitted from a derived alias (`docs/DESIGN.md` §"Standard ports"). + #[must_use] + pub fn standard_port(self) -> u16 { + if self.is_tls() { 443 } else { 80 } + } + + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Http => "http", + Self::Https => "https", + Self::Ws => "ws", + Self::Wss => "wss", + Self::Wt => "wt", + Self::Grpc => "grpc", + } + } +} + +/// Wire protocol spoken to the upstream. Serialized as its GTS identifier. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub enum Protocol { + #[serde(rename = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1")] + Http, + #[serde(rename = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1")] + Grpc, +} + +impl Protocol { + #[must_use] + pub fn as_gts_id(self) -> &'static str { + match self { + Self::Http => gts::PROTOCOL_HTTP, + Self::Grpc => gts::PROTOCOL_GRPC, + } + } +} + +/// Which inbound headers are forwarded to the upstream. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PassthroughMode { + /// Forward nothing beyond what OAGW itself sets. + #[default] + None, + /// Forward only the headers named in `passthrough_allowlist`. + Allowlist, + /// Forward every inbound header that is not routing- or hop-by-hop. + All, +} + +/// How the `/{path_suffix}` part of a proxy URL is treated. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PathSuffixMode { + /// A suffix beyond the matched route path is rejected. + Disabled, + /// The suffix is appended to the route path. + #[default] + Append, +} + +/// Rate limiting algorithm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum RateLimitAlgorithm { + #[default] + TokenBucket, + SlidingWindow, +} + +/// Counter scope for a rate limit. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum RateLimitScope { + Global, + #[default] + Tenant, + User, + Ip, + Route, +} + +/// Behaviour when a rate limit is exceeded. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum RateLimitStrategy { + /// `429 Too Many Requests` with `Retry-After`. + #[default] + Reject, + /// Wait for capacity within a bounded budget, then reject. + Queue, + /// Serve with reduced functionality (currently: forward without waiting). + Degrade, +} + +/// Replenishment window for a sustained rate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum RateWindow { + #[default] + Second, + Minute, + Hour, + Day, +} + +impl RateWindow { + #[must_use] + pub fn seconds(self) -> f64 { + match self { + Self::Second => 1.0, + Self::Minute => 60.0, + Self::Hour => 3600.0, + Self::Day => 86_400.0, + } + } +} + +/// The three plugin kinds. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PluginKind { + Auth, + Guard, + Transform, +} + +impl PluginKind { + #[must_use] + pub fn base_type(self) -> &'static str { + match self { + Self::Auth => gts::AUTH_PLUGIN_BASE, + Self::Guard => gts::GUARD_PLUGIN_BASE, + Self::Transform => gts::TRANSFORM_PLUGIN_BASE, + } + } + + #[must_use] + pub fn from_base_type(base: &str) -> Option { + match base { + gts::AUTH_PLUGIN_BASE => Some(Self::Auth), + gts::GUARD_PLUGIN_BASE => Some(Self::Guard), + gts::TRANSFORM_PLUGIN_BASE => Some(Self::Transform), + _ => None, + } + } +} + +/// Phase a transform plugin participates in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum PluginPhase { + OnRequest, + OnResponse, + OnError, +} + +// --------------------------------------------------------------------------- +// Value objects +// --------------------------------------------------------------------------- + +/// One member of an upstream's load-balance pool. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct Endpoint { + #[serde(default)] + pub scheme: Scheme, + pub host: String, + #[serde(default = "default_port")] + pub port: u16, +} + +fn default_port() -> u16 { + 443 +} + +impl Endpoint { + /// `host` for a standard port, `host:port` otherwise. + #[must_use] + pub fn authority(&self) -> String { + if self.port == self.scheme.standard_port() { + self.host.clone() + } else { + format!("{}:{}", self.host, self.port) + } + } +} + +/// The upstream's endpoint pool. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct ServerConfig { + pub endpoints: Vec, +} + +/// Auth plugin binding for an upstream. At most one per upstream. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +pub struct AuthConfig { + /// GTS identifier of the auth plugin. + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub plugin_type: Option, + #[serde(default)] + pub sharing: SharingMode, + #[serde(default)] + #[schema(value_type = Object)] + pub config: ConfigMap, +} + +/// Request-phase header rules. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct RequestHeaderRules { + #[serde(default)] + pub set: BTreeMap, + #[serde(default)] + pub add: BTreeMap, + #[serde(default)] + pub remove: Vec, + #[serde(default)] + pub passthrough: PassthroughMode, + #[serde(default)] + pub passthrough_allowlist: Vec, +} + +/// Response-phase header rules. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct ResponseHeaderRules { + #[serde(default)] + pub set: BTreeMap, + #[serde(default)] + pub add: BTreeMap, + #[serde(default)] + pub remove: Vec, +} + +/// Header transformation rules for an upstream. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct HeadersConfig { + #[serde(default)] + pub request: RequestHeaderRules, + #[serde(default)] + pub response: ResponseHeaderRules, +} + +/// A single plugin binding: the plugin identifier plus its per-binding config. +/// +/// Accepts both the object form documented in ADR 0009 +/// (`{"plugin_ref": "...", "config": {...}}`) and the bare-string form of +/// `schemas/upstream.v1.schema.json`; it always serializes as the object form. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, utoipa::ToSchema)] +pub struct PluginBinding { + /// Canonical plugin identifier — a full GTS id, or a bare UUID for a + /// custom plugin. + pub plugin_ref: String, + /// Extracted UUID when `plugin_ref` is UUID-backed. + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_uuid: Option, + #[serde(default)] + #[schema(value_type = Object)] + pub config: ConfigMap, +} + +impl<'de> Deserialize<'de> for PluginBinding { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + #[derive(Deserialize)] + #[serde(untagged)] + enum Repr { + Ref(String), + Object { + #[serde(alias = "ref", alias = "id", alias = "type")] + plugin_ref: String, + #[serde(default)] + config: ConfigMap, + }, + } + + let binding = match Repr::deserialize(deserializer)? { + Repr::Ref(plugin_ref) => Self { + plugin_ref, + plugin_uuid: None, + config: ConfigMap::new(), + }, + Repr::Object { plugin_ref, config } => Self { + plugin_ref, + plugin_uuid: None, + config, + }, + }; + Ok(binding.with_derived_uuid()) + } +} + +impl PluginBinding { + /// Fill [`Self::plugin_uuid`] from the instance part of `plugin_ref`. + #[must_use] + pub fn with_derived_uuid(mut self) -> Self { + self.plugin_uuid = gts::instance_uuid(&self.plugin_ref); + self + } +} + +/// An ordered plugin chain attached to an upstream or route. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct PluginsConfig { + #[serde(default)] + pub sharing: SharingMode, + #[serde(default)] + pub items: Vec, +} + +/// Sustained replenishment rate. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct SustainedRate { + pub rate: u32, + #[serde(default)] + pub window: RateWindow, +} + +/// Burst allowance (token bucket capacity). +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct BurstCapacity { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub capacity: Option, +} + +/// Hierarchical budget allocation (ADR 0003). +#[derive(Debug, Clone, Copy, Default, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct RateLimitBudget { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub total: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub overcommit_ratio: Option, +} + +/// Budget allocation mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(rename_all = "snake_case")] +pub enum BudgetMode { + #[default] + Unlimited, + Allocated, + Shared, +} + +/// Dual-rate token bucket configuration (ADR 0003). +#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct RateLimitConfig { + #[serde(default)] + pub sharing: SharingMode, + #[serde(default)] + pub algorithm: RateLimitAlgorithm, + pub sustained: SustainedRate, + #[serde(default)] + pub burst: BurstCapacity, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub budget: Option, + #[serde(default)] + pub scope: RateLimitScope, + #[serde(default)] + pub strategy: RateLimitStrategy, + #[serde(default = "default_cost")] + pub cost: u32, + #[serde(default = "default_true")] + pub response_headers: bool, +} + +fn default_cost() -> u32 { + 1 +} + +pub(crate) fn default_true() -> bool { + true +} + +impl RateLimitConfig { + /// Sustained tokens per second. + #[must_use] + pub fn refill_per_second(&self) -> f64 { + f64::from(self.sustained.rate) / self.sustained.window.seconds() + } + + /// Bucket capacity, defaulting to the sustained rate. + #[must_use] + pub fn capacity(&self) -> u32 { + self.burst.capacity.unwrap_or(self.sustained.rate).max(1) + } + + /// Take the stricter of two limits, field by field + /// (`effective = min(ancestor.enforced, descendant)`). + #[must_use] + pub fn stricter_of(a: Self, b: Self) -> Self { + let mut out = if a.refill_per_second() <= b.refill_per_second() { + a + } else { + b + }; + out.burst = BurstCapacity { + capacity: Some(a.capacity().min(b.capacity())), + }; + out.cost = a.cost.max(b.cost); + out + } +} + +/// CORS policy (ADR 0004). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct CorsConfig { + #[serde(default)] + pub sharing: SharingMode, + pub enabled: bool, + #[serde(default)] + pub allowed_origins: Vec, + #[serde(default = "default_cors_methods")] + pub allowed_methods: Vec, + #[serde(default)] + pub expose_headers: Vec, + #[serde(default)] + pub allow_credentials: bool, +} + +fn default_cors_methods() -> Vec { + vec!["GET".to_owned(), "POST".to_owned()] +} + +impl CorsConfig { + /// Exact, case-sensitive origin match (no regex — ADR 0004). + #[must_use] + pub fn origin_allowed(&self, origin: &str) -> bool { + self.allowed_origins + .iter() + .any(|allowed| allowed == "*" || allowed == origin) + } + + #[must_use] + pub fn method_allowed(&self, method: &str) -> bool { + self.allowed_methods + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(method)) + } + + #[must_use] + pub fn has_wildcard_origin(&self) -> bool { + self.allowed_origins.iter().any(|o| o == "*") + } +} + +/// HTTP inbound matching rules. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct HttpMatch { + pub methods: Vec, + pub path: String, + #[serde(default)] + pub query_allowlist: Vec, + #[serde(default)] + pub path_suffix_mode: PathSuffixMode, +} + +impl HttpMatch { + #[must_use] + pub fn allows_method(&self, method: &str) -> bool { + self.methods.iter().any(|m| m.eq_ignore_ascii_case(method)) + } + + #[must_use] + pub fn allows_query_param(&self, name: &str) -> bool { + self.query_allowlist.iter().any(|p| p == name) + } +} + +/// gRPC inbound matching rules (Phase 3 — stored, not yet routable). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct GrpcMatch { + pub service: String, + pub method: String, +} + +/// Protocol-scoped match rules. Exactly one member must be present. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, utoipa::ToSchema)] +#[serde(deny_unknown_fields)] +pub struct MatchConfig { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub http: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub grpc: Option, +} + +impl MatchConfig { + #[must_use] + pub fn match_type(&self) -> &'static str { + if self.grpc.is_some() { "grpc" } else { "http" } + } +} + +// --------------------------------------------------------------------------- +// Aggregates +// --------------------------------------------------------------------------- + +/// Tenant-scoped root configuration object for one external service. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Upstream { + pub id: Uuid, + pub tenant_id: Uuid, + pub alias: String, + pub enabled: bool, + pub protocol: Protocol, + pub server: ServerConfig, + #[serde(skip_serializing_if = "Option::is_none")] + pub auth: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub headers: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub plugins: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cors: Option, + pub tags: Vec, +} + +impl Upstream { + /// Anonymous GTS identifier for this upstream. + #[must_use] + pub fn gts_id(&self) -> String { + format!("{}{}", gts::UPSTREAM_BASE, self.id) + } + + /// Endpoint whose host matches `host`, case-insensitively. + #[must_use] + pub fn endpoint_for_host(&self, host: &str) -> Option<&Endpoint> { + self.server + .endpoints + .iter() + .find(|e| e.host.eq_ignore_ascii_case(host)) + } + + /// Distinct endpoint hostnames, in declaration order. + #[must_use] + pub fn endpoint_hosts(&self) -> Vec { + self.server + .endpoints + .iter() + .map(|e| e.host.clone()) + .collect() + } +} + +/// An API path on an upstream. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct Route { + pub id: Uuid, + pub tenant_id: Uuid, + pub upstream_id: Uuid, + pub enabled: bool, + pub priority: i32, + pub r#match: MatchConfig, + #[serde(skip_serializing_if = "Option::is_none")] + pub plugins: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub cors: Option, + pub tags: Vec, +} + +impl Route { + #[must_use] + pub fn gts_id(&self) -> String { + format!("{}{}", gts::ROUTE_BASE, self.id) + } + + #[must_use] + pub fn http(&self) -> Option<&HttpMatch> { + self.r#match.http.as_ref() + } +} + +/// A tenant-defined custom (Starlark) plugin. Immutable after creation. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub struct PluginDef { + pub id: Uuid, + pub tenant_id: Uuid, + pub plugin_type: PluginKind, + pub name: String, + pub description: Option, + pub phases: Vec, + pub config_schema: Option, + pub source_code: String, + /// Epoch seconds of the last proxy request that resolved this plugin. + pub last_used_at: Option, + /// Epoch seconds after which an unlinked plugin may be collected. + pub gc_eligible_at: Option, +} + +impl PluginDef { + #[must_use] + pub fn gts_id(&self) -> String { + format!("{}{}", self.plugin_type.base_type(), self.id) + } +} + +#[cfg(test)] +#[path = "model_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/model_tests.rs b/gears/system/oagw/oagw/src/domain/model_tests.rs new file mode 100644 index 0000000..9bbbabd --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/model_tests.rs @@ -0,0 +1,203 @@ +//! Wire shapes of the domain model. + +use super::*; + +#[test] +fn schemes_know_their_standard_port_and_tls_posture() { + assert!(Scheme::Https.is_tls()); + assert!(Scheme::Wss.is_tls()); + assert!(Scheme::Grpc.is_tls()); + assert!(Scheme::Wt.is_tls()); + assert!(!Scheme::Http.is_tls()); + assert!(!Scheme::Ws.is_tls()); + + assert_eq!(Scheme::Https.standard_port(), 443); + assert_eq!(Scheme::Http.standard_port(), 80); + assert_eq!(Scheme::Ws.standard_port(), 80); +} + +#[test] +fn plaintext_schemes_are_accepted_by_the_endpoint_shape() { + // `allow_http_upstream` governs whether a plaintext connection is dialled; + // which schemes deserialize is a separate question, and `http` is legal. + let endpoint: Endpoint = + serde_json::from_str(r#"{"scheme":"http","host":"127.0.0.1","port":80}"#).unwrap(); + assert_eq!(endpoint.scheme, Scheme::Http); + assert_eq!(endpoint.port, 80); +} + +#[test] +fn an_endpoint_defaults_to_https_on_443() { + let endpoint: Endpoint = serde_json::from_str(r#"{"host":"api.openai.com"}"#).unwrap(); + assert_eq!(endpoint.scheme, Scheme::Https); + assert_eq!(endpoint.port, 443); +} + +#[test] +fn an_endpoint_rejects_unknown_members() { + assert!(serde_json::from_str::(r#"{"host":"a.example.com","tls":true}"#).is_err()); +} + +#[test] +fn the_authority_omits_a_standard_port() { + let endpoint = Endpoint { + scheme: Scheme::Https, + host: "api.openai.com".to_owned(), + port: 443, + }; + assert_eq!(endpoint.authority(), "api.openai.com"); + let endpoint = Endpoint { + port: 8443, + ..endpoint + }; + assert_eq!(endpoint.authority(), "api.openai.com:8443"); +} + +#[test] +fn the_protocol_serializes_as_its_gts_identifier() { + let json = serde_json::to_string(&Protocol::Http).unwrap(); + assert_eq!( + json, + r#""gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1""# + ); + let parsed: Protocol = + serde_json::from_str(r#""gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1""#).unwrap(); + assert_eq!(parsed, Protocol::Grpc); +} + +#[test] +fn a_plugin_binding_accepts_the_bare_string_form() { + let binding: PluginBinding = + serde_json::from_str(&format!("\"{}\"", gts::REQUIRED_HEADERS_GUARD_PLUGIN_ID)).unwrap(); + assert_eq!(binding.plugin_ref, gts::REQUIRED_HEADERS_GUARD_PLUGIN_ID); + assert!(binding.config.is_empty()); + assert_eq!(binding.plugin_uuid, None); +} + +#[test] +fn a_plugin_binding_accepts_the_object_form_with_config() { + let binding: PluginBinding = serde_json::from_str( + r#"{"plugin_ref":"gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1", + "config":{"required_request_headers":"x-correlation-id,accept"}}"#, + ) + .unwrap(); + assert_eq!( + binding.config.get("required_request_headers").unwrap(), + "x-correlation-id,accept" + ); +} + +#[test] +fn a_uuid_backed_binding_extracts_its_plugin_uuid() { + let uuid = uuid::Uuid::new_v4(); + let binding: PluginBinding = + serde_json::from_str(&format!("\"{}{uuid}\"", gts::TRANSFORM_PLUGIN_BASE)).unwrap(); + assert_eq!(binding.plugin_uuid, Some(uuid)); +} + +#[test] +fn a_plugin_binding_always_serializes_as_the_object_form() { + let binding: PluginBinding = + serde_json::from_str(&format!("\"{}\"", gts::REQUEST_ID_TRANSFORM_PLUGIN_ID)).unwrap(); + let json = serde_json::to_value(&binding).unwrap(); + assert_eq!(json["plugin_ref"], gts::REQUEST_ID_TRANSFORM_PLUGIN_ID); + assert!(json.get("config").is_some()); +} + +fn limit(rate: u32, window: RateWindow, capacity: Option) -> RateLimitConfig { + RateLimitConfig { + sharing: SharingMode::Private, + algorithm: RateLimitAlgorithm::TokenBucket, + sustained: SustainedRate { rate, window }, + burst: BurstCapacity { capacity }, + budget: None, + scope: RateLimitScope::Tenant, + strategy: RateLimitStrategy::Reject, + cost: 1, + response_headers: true, + } +} + +#[test] +fn the_refill_rate_normalizes_the_window() { + assert!((limit(60, RateWindow::Minute, None).refill_per_second() - 1.0).abs() < f64::EPSILON); + assert!((limit(1, RateWindow::Second, None).refill_per_second() - 1.0).abs() < f64::EPSILON); +} + +#[test] +fn burst_capacity_defaults_to_the_sustained_rate() { + assert_eq!(limit(100, RateWindow::Minute, None).capacity(), 100); + assert_eq!(limit(100, RateWindow::Minute, Some(500)).capacity(), 500); +} + +#[test] +fn the_stricter_of_two_limits_wins_field_by_field() { + let ancestor = limit(10_000, RateWindow::Minute, Some(1_000)); + let descendant = limit(100, RateWindow::Minute, Some(50)); + let effective = RateLimitConfig::stricter_of(ancestor, descendant); + assert_eq!(effective.sustained.rate, 100); + assert_eq!(effective.capacity(), 50); +} + +#[test] +fn cors_origin_matching_is_exact_and_port_sensitive() { + let cors = CorsConfig { + sharing: SharingMode::Private, + enabled: true, + allowed_origins: vec!["https://app.example.com".to_owned()], + allowed_methods: vec!["GET".to_owned()], + expose_headers: Vec::new(), + allow_credentials: false, + }; + assert!(cors.origin_allowed("https://app.example.com")); + assert!(!cors.origin_allowed("https://app.example.com:8080")); + assert!(!cors.origin_allowed("http://app.example.com")); + assert!(!cors.origin_allowed("https://evil.com")); + assert!(cors.method_allowed("get")); + assert!(!cors.method_allowed("DELETE")); +} + +#[test] +fn a_wildcard_origin_matches_anything() { + let cors = CorsConfig { + sharing: SharingMode::Private, + enabled: true, + allowed_origins: vec!["*".to_owned()], + allowed_methods: vec!["GET".to_owned()], + expose_headers: Vec::new(), + allow_credentials: false, + }; + assert!(cors.has_wildcard_origin()); + assert!(cors.origin_allowed("https://anything.example")); +} + +#[test] +fn sharing_modes_know_their_visibility() { + assert!(!SharingMode::Private.is_visible_to_descendants()); + assert!(SharingMode::Inherit.is_visible_to_descendants()); + assert!(SharingMode::Enforce.is_visible_to_descendants()); + assert!(SharingMode::Enforce.is_enforced()); + assert!(!SharingMode::Inherit.is_enforced()); +} + +#[test] +fn plugin_kinds_round_trip_through_their_base_type() { + for kind in [PluginKind::Auth, PluginKind::Guard, PluginKind::Transform] { + assert_eq!(PluginKind::from_base_type(kind.base_type()), Some(kind)); + } + assert_eq!(PluginKind::from_base_type(gts::UPSTREAM_BASE), None); +} + +#[test] +fn an_http_match_is_method_and_query_aware() { + let http = HttpMatch { + methods: vec!["GET".to_owned(), "POST".to_owned()], + path: "/v1/chat".to_owned(), + query_allowlist: vec!["model".to_owned()], + path_suffix_mode: PathSuffixMode::Append, + }; + assert!(http.allows_method("get")); + assert!(!http.allows_method("DELETE")); + assert!(http.allows_query_param("model")); + assert!(!http.allows_query_param("secret")); +} diff --git a/gears/system/oagw/oagw/src/domain/plugin.rs b/gears/system/oagw/oagw/src/domain/plugin.rs new file mode 100644 index 0000000..b4b47a6 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/plugin.rs @@ -0,0 +1,243 @@ +//! Plugin traits and the contexts handed to them (ADR 0002). +//! +//! Execution order is fixed: Auth → Guards → Transform(on_request) → upstream +//! call → Transform(on_response / on_error). Upstream-bound plugins run before +//! route-bound ones. + +use async_trait::async_trait; +use bytes::Bytes; +use http::{HeaderMap, Method, StatusCode}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::error::{ErrorKind, OagwError}; +use crate::domain::model::ConfigMap; + +/// The outbound request as it is being assembled. +#[derive(Debug, Clone)] +pub struct ProxyRequest { + pub method: Method, + /// Absolute path on the upstream, always starting with `/`. + pub path: String, + /// Query parameters in order, already filtered by the route allowlist. + pub query: Vec<(String, String)>, + pub headers: HeaderMap, + pub body: Bytes, +} + +impl ProxyRequest { + /// `path` plus the encoded query string, ready for a request line. + #[must_use] + pub fn path_and_query(&self) -> String { + if self.query.is_empty() { + return self.path.clone(); + } + let encoded = form_urlencoded::Serializer::new(String::new()) + .extend_pairs(self.query.iter().map(|(k, v)| (k.as_str(), v.as_str()))) + .finish(); + format!("{}?{}", self.path, encoded) + } +} + +/// The upstream response head, before it is written back to the client. +#[derive(Debug, Clone)] +pub struct ProxyResponseHead { + pub status: StatusCode, + pub headers: HeaderMap, +} + +/// Ambient identity of the request every plugin phase shares. +#[derive(Debug, Clone, Copy)] +pub struct PluginScope<'a> { + pub security_context: &'a SecurityContext, + pub alias: &'a str, + pub upstream_id: Uuid, + pub route_id: Option, +} + +/// Context for [`AuthPlugin::authenticate`]. +pub struct AuthContext<'a> { + pub scope: PluginScope<'a>, + pub config: &'a ConfigMap, + pub headers: &'a mut HeaderMap, + pub query: &'a mut Vec<(String, String)>, +} + +impl AuthContext<'_> { + #[must_use] + pub fn security_context(&self) -> &SecurityContext { + self.scope.security_context + } +} + +/// Context for the request phase of guards and transforms. +pub struct RequestContext<'a> { + pub scope: PluginScope<'a>, + pub config: &'a ConfigMap, + pub request: &'a mut ProxyRequest, +} + +/// Context for the response phase of guards and transforms. +pub struct ResponseContext<'a> { + pub scope: PluginScope<'a>, + pub config: &'a ConfigMap, + pub response: &'a mut ProxyResponseHead, +} + +/// Context for [`TransformPlugin::transform_error`]. +pub struct ErrorContext<'a> { + pub scope: PluginScope<'a>, + pub config: &'a ConfigMap, + pub error: &'a mut OagwError, +} + +/// Outcome of a guard phase. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum GuardDecision { + /// Continue the chain. + Allow, + /// Stop and answer the client with this status. + Reject { + status: StatusCode, + error_code: String, + message: String, + }, +} + +impl GuardDecision { + #[must_use] + pub fn reject(status: StatusCode, error_code: &str, message: impl Into) -> Self { + Self::Reject { + status, + error_code: error_code.to_owned(), + message: message.into(), + } + } +} + +/// Failure modes a plugin can report. +#[derive(Debug, Clone, thiserror::Error)] +pub enum PluginError { + /// Credential preparation failed — the caller is not authenticated to the + /// upstream. + #[error("authentication failed: {0}")] + Unauthenticated(String), + /// A `cred://` reference did not resolve. + #[error("secret not found: {0}")] + SecretNotFound(String), + /// The plugin's own configuration is unusable. + #[error("invalid plugin configuration: {0}")] + InvalidConfig(String), + /// No implementation is registered for the requested identifier. + #[error("plugin not found: {0}")] + NotFound(String), + /// Anything else — never carries secret material. + #[error("plugin failure: {0}")] + Internal(String), +} + +impl From for OagwError { + fn from(err: PluginError) -> Self { + let detail = err.to_string(); + match err { + PluginError::Unauthenticated(_) => { + Self::new(ErrorKind::AuthenticationFailed, detail) + } + PluginError::SecretNotFound(_) => Self::new(ErrorKind::SecretNotFound, detail), + PluginError::InvalidConfig(_) => Self::new(ErrorKind::ValidationError, detail), + PluginError::NotFound(_) => Self::new(ErrorKind::PluginNotFound, detail), + PluginError::Internal(_) => Self::new(ErrorKind::Internal, detail), + } + } +} + +/// Credential injection. At most one per upstream. +#[async_trait] +pub trait AuthPlugin: Send + Sync { + /// Short symbolic name, e.g. `apikey`. + fn id(&self) -> &str; + /// Full GTS identifier this plugin is registered under. + fn plugin_type(&self) -> &str; + /// Inject credentials into the outbound request. + /// + /// # Errors + /// + /// Returns a [`PluginError`] when credentials cannot be prepared. + async fn authenticate(&self, ctx: &mut AuthContext<'_>) -> Result<(), PluginError>; +} + +/// Validation and policy enforcement. May reject a request. +#[async_trait] +pub trait GuardPlugin: Send + Sync { + fn id(&self) -> &str; + fn plugin_type(&self) -> &str; + + /// Inspect the outbound request. + /// + /// # Errors + /// + /// Returns a [`PluginError`] when the check itself could not run. + async fn guard_request(&self, ctx: &RequestContext<'_>) + -> Result; + + /// Inspect the upstream response. + /// + /// # Errors + /// + /// Returns a [`PluginError`] when the check itself could not run. + async fn guard_response( + &self, + ctx: &ResponseContext<'_>, + ) -> Result; +} + +/// Request/response/error mutation. +#[async_trait] +pub trait TransformPlugin: Send + Sync { + fn id(&self) -> &str; + fn plugin_type(&self) -> &str; + + /// Mutate the outbound request. + /// + /// # Errors + /// + /// Returns a [`PluginError`] on failure. + async fn transform_request(&self, ctx: &mut RequestContext<'_>) -> Result<(), PluginError>; + + /// Mutate the upstream response. + /// + /// # Errors + /// + /// Returns a [`PluginError`] on failure. + async fn transform_response(&self, ctx: &mut ResponseContext<'_>) -> Result<(), PluginError>; + + /// Mutate a gateway error before it is rendered. + /// + /// # Errors + /// + /// Returns a [`PluginError`] on failure. + async fn transform_error(&self, ctx: &mut ErrorContext<'_>) -> Result<(), PluginError> { + let _ = ctx; + Ok(()) + } +} + +/// Read a plugin config value as a string, accepting JSON scalars. +#[must_use] +pub fn config_str(config: &ConfigMap, key: &str) -> Option { + match config.get(key)? { + serde_json::Value::String(s) => Some(s.clone()), + serde_json::Value::Null => None, + other => Some(other.to_string()), + } +} + +/// Read a plugin config value as a non-blank string. +#[must_use] +pub fn config_nonblank(config: &ConfigMap, key: &str) -> Option { + config_str(config, key).filter(|s| !s.trim().is_empty()) +} + +#[cfg(test)] +#[path = "plugin_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/plugin_tests.rs b/gears/system/oagw/oagw/src/domain/plugin_tests.rs new file mode 100644 index 0000000..ee247c9 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/plugin_tests.rs @@ -0,0 +1,84 @@ +//! Plugin context helpers and error projection. + +use super::*; +use crate::domain::error::ErrorKind; + +#[test] +fn a_request_renders_its_path_and_query() { + let request = ProxyRequest { + method: Method::GET, + path: "/v1/chat".to_owned(), + query: vec![ + ("model".to_owned(), "gpt-4".to_owned()), + ("q".to_owned(), "a b&c".to_owned()), + ], + headers: HeaderMap::new(), + body: Bytes::new(), + }; + assert_eq!(request.path_and_query(), "/v1/chat?model=gpt-4&q=a+b%26c"); +} + +#[test] +fn an_empty_query_leaves_the_path_alone() { + let request = ProxyRequest { + method: Method::GET, + path: "/v1/chat".to_owned(), + query: Vec::new(), + headers: HeaderMap::new(), + body: Bytes::new(), + }; + assert_eq!(request.path_and_query(), "/v1/chat"); +} + +#[test] +fn config_values_read_as_strings_whatever_their_json_type() { + let mut config = ConfigMap::new(); + config.insert("text".to_owned(), "value".into()); + config.insert("number".to_owned(), 42.into()); + config.insert("flag".to_owned(), true.into()); + config.insert("nothing".to_owned(), serde_json::Value::Null); + config.insert("blank".to_owned(), " ".into()); + + assert_eq!(config_str(&config, "text").as_deref(), Some("value")); + assert_eq!(config_str(&config, "number").as_deref(), Some("42")); + assert_eq!(config_str(&config, "flag").as_deref(), Some("true")); + assert_eq!(config_str(&config, "nothing"), None); + assert_eq!(config_str(&config, "absent"), None); + + // The non-blank reader additionally rejects whitespace-only values. + assert_eq!(config_nonblank(&config, "blank"), None); + assert_eq!(config_nonblank(&config, "text").as_deref(), Some("value")); +} + +#[test] +fn plugin_errors_project_onto_the_catalogued_gateway_errors() { + let cases = [ + (PluginError::Unauthenticated("x".into()), ErrorKind::AuthenticationFailed, 401), + (PluginError::SecretNotFound("x".into()), ErrorKind::SecretNotFound, 500), + (PluginError::InvalidConfig("x".into()), ErrorKind::ValidationError, 400), + (PluginError::NotFound("x".into()), ErrorKind::PluginNotFound, 503), + (PluginError::Internal("x".into()), ErrorKind::Internal, 500), + ]; + for (error, kind, status) in cases { + let projected: OagwError = error.into(); + assert_eq!(projected.kind, kind); + assert_eq!(projected.status(), status); + } +} + +#[test] +fn a_guard_rejection_carries_its_status_and_code() { + let decision = GuardDecision::reject(StatusCode::BAD_REQUEST, "MISSING", "no header"); + match decision { + GuardDecision::Reject { + status, + error_code, + message, + } => { + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(error_code, "MISSING"); + assert_eq!(message, "no header"); + } + GuardDecision::Allow => panic!("expected a rejection"), + } +} 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..d7fda79 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/repo.rs @@ -0,0 +1,99 @@ +//! Repository contracts for the Control Plane. +//! +//! The methods are synchronous because the shipped implementation +//! ([`crate::infra::storage`]) is an in-process store; the trait boundary is +//! what keeps a future persistent backend a drop-in. + +use uuid::Uuid; + +use crate::domain::error::OagwResult; +use crate::domain::model::{PluginDef, Route, Upstream}; + +/// Storage for [`Upstream`] aggregates. +pub trait UpstreamRepository: Send + Sync { + /// Insert a new upstream. Fails with a conflict when `(tenant_id, alias)` + /// is taken. + /// + /// # Errors + /// + /// Returns a conflict error when the alias is already used by the tenant. + fn insert(&self, upstream: Upstream) -> OagwResult; + + /// Replace an existing upstream owned by `tenant_id`. + /// + /// # Errors + /// + /// Returns a not-found error when the upstream is absent or owned by + /// another tenant. + fn replace(&self, upstream: Upstream) -> OagwResult; + + /// Fetch one upstream, scoped to its owning tenant. + fn get(&self, tenant_id: Uuid, id: Uuid) -> Option; + + /// Fetch one upstream regardless of tenant — Data Plane use only. + fn get_unscoped(&self, id: Uuid) -> Option; + + /// All upstreams owned by `tenant_id`. + fn list(&self, tenant_id: Uuid) -> Vec; + + /// The upstream owned by `tenant_id` under `alias`, if any. + fn find_by_alias(&self, tenant_id: Uuid, alias: &str) -> Option; + + /// Remove an upstream owned by `tenant_id`; `true` when something was + /// removed. + fn delete(&self, tenant_id: Uuid, id: Uuid) -> bool; +} + +/// Storage for [`Route`] aggregates. +pub trait RouteRepository: Send + Sync { + /// Insert a new route. + /// + /// # Errors + /// + /// Returns a conflict error when an equivalent match rule already exists. + fn insert(&self, route: Route) -> OagwResult; + + /// Replace an existing route owned by `tenant_id`. + /// + /// # Errors + /// + /// Returns a not-found error when the route is absent or owned by another + /// tenant. + fn replace(&self, route: Route) -> OagwResult; + + fn get(&self, tenant_id: Uuid, id: Uuid) -> Option; + + fn list(&self, tenant_id: Uuid) -> Vec; + + /// Every route bound to `upstream_id`, regardless of tenant. + fn list_by_upstream(&self, upstream_id: Uuid) -> Vec; + + fn delete(&self, tenant_id: Uuid, id: Uuid) -> bool; + + /// Drop every route bound to `upstream_id` (cascade on upstream delete). + fn delete_by_upstream(&self, upstream_id: Uuid) -> usize; +} + +/// Storage for tenant-defined [`PluginDef`] rows. +pub trait PluginRepository: Send + Sync { + /// Insert a new plugin definition. + /// + /// # Errors + /// + /// Returns a conflict error when `(tenant_id, name)` is taken. + fn insert(&self, plugin: PluginDef) -> OagwResult; + + fn get(&self, tenant_id: Uuid, id: Uuid) -> Option; + + fn get_unscoped(&self, id: Uuid) -> Option; + + fn list(&self, tenant_id: Uuid) -> Vec; + + fn delete(&self, tenant_id: Uuid, id: Uuid) -> bool; + + /// Mark a plugin as used at `epoch_secs` (feeds the GC clock). + fn touch(&self, id: Uuid, epoch_secs: u64); + + /// Set or clear the instant after which an unlinked plugin is collectable. + fn set_gc_eligible_at(&self, id: Uuid, epoch_secs: Option); +} diff --git a/gears/system/oagw/oagw/src/domain/services.rs b/gears/system/oagw/oagw/src/domain/services.rs new file mode 100644 index 0000000..a2ad30b --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/services.rs @@ -0,0 +1,767 @@ +//! Control Plane — configuration ownership and resolution. +//! +//! Everything the management API writes goes through [`ControlPlaneService`], +//! which is also what the Data Plane asks for a resolved proxy target. Tenant +//! scoping is absolute here: ancestor resources are invisible (404) to the +//! management API and only reachable through +//! [`ControlPlaneService::resolve_proxy_target`]. + +use std::sync::Arc; + +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::domain::alias; +use crate::domain::error::{ErrorKind, OagwError, OagwResult}; +use crate::domain::gts; +use crate::domain::input::{PluginInput, RouteInput, UpstreamInput}; +use crate::domain::merge::{EffectiveConfig, effective_config}; +use crate::domain::model::{ + CorsConfig, Endpoint, MatchConfig, PluginBinding, PluginDef, PluginKind, PluginsConfig, + Protocol, RateLimitConfig, Route, Scheme, Upstream, +}; +use crate::domain::repo::{PluginRepository, RouteRepository, UpstreamRepository}; +use crate::domain::tenant::TenantDirectory; + +/// Named plugin identifiers that may be bound through `plugins.items[]`. +/// +/// Timeout, CORS, logging and metrics are core Data Plane behaviour; their GTS +/// identifiers exist for types-registry cataloging only. +const BINDABLE_NAMED_PLUGINS: &[&str] = &[ + gts::REQUIRED_HEADERS_GUARD_PLUGIN_ID, + gts::REQUEST_ID_TRANSFORM_PLUGIN_ID, +]; + +/// Auth plugin identifiers the catalog knows about. `basic` and `bearer` are +/// accepted here but have no backing implementation, so they fail at proxy +/// time with `unknown auth plugin`. +const CATALOG_AUTH_PLUGINS: &[&str] = &[ + gts::NOOP_AUTH_PLUGIN_ID, + gts::APIKEY_AUTH_PLUGIN_ID, + gts::OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID, + gts::OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID, + gts::BASIC_AUTH_PLUGIN_ID, + gts::BEARER_AUTH_PLUGIN_ID, +]; + +/// HTTP methods a route may match on (`schemas/route.v1.schema.json`). +const ALLOWED_ROUTE_METHODS: &[&str] = &["GET", "POST", "PUT", "DELETE", "PATCH"]; + +/// A resolved proxy target: which upstream, which route, and the configuration +/// the two produce once the tenant hierarchy is folded in. +#[derive(Debug, Clone)] +pub struct ProxyTarget { + pub upstream: Upstream, + /// Upstreams sharing the alias further up the chain, ordered parent → root. + pub ancestors: Vec, + pub route: Route, + pub effective: EffectiveConfig, +} + +/// Control Plane service. +pub struct ControlPlaneService { + upstreams: Arc, + routes: Arc, + plugins: Arc, + tenants: Arc, +} + +impl ControlPlaneService { + #[must_use] + pub fn new( + upstreams: Arc, + routes: Arc, + plugins: Arc, + tenants: Arc, + ) -> Self { + Self { + upstreams, + routes, + plugins, + tenants, + } + } + + // -- Upstreams --------------------------------------------------------- + + /// Create an upstream for the calling tenant. + /// + /// # Errors + /// + /// `400` when the payload fails validation, `409` when the alias is taken. + pub fn create_upstream(&self, tenant_id: Uuid, input: UpstreamInput) -> OagwResult { + let endpoints = self.validate_endpoints(&input.server.endpoints, input.protocol)?; + let alias = alias::resolve_alias_for_create(&endpoints, input.alias.as_deref())?; + let upstream = self.assemble_upstream(Uuid::new_v4(), tenant_id, alias, endpoints, input)?; + self.upstreams.insert(upstream) + } + + /// Full replacement of an upstream owned by the calling tenant. + /// + /// # Errors + /// + /// `400` on validation failure (including any change that would move the + /// alias), `404` when the upstream is not the tenant's. + pub fn replace_upstream( + &self, + tenant_id: Uuid, + id: Uuid, + input: UpstreamInput, + ) -> OagwResult { + let Some(existing) = self.upstreams.get(tenant_id, id) else { + return Err(OagwError::not_found("upstream not found")); + }; + let endpoints = self.validate_endpoints(&input.server.endpoints, input.protocol)?; + let alias = + alias::enforce_alias_update(&existing.alias, &endpoints, input.alias.as_deref())?; + let upstream = self.assemble_upstream(id, tenant_id, alias, endpoints, input)?; + self.upstreams.replace(upstream) + } + + /// Fetch an upstream owned by the calling tenant. + #[must_use] + pub fn get_upstream(&self, tenant_id: Uuid, id: Uuid) -> Option { + self.upstreams.get(tenant_id, id) + } + + /// Every upstream owned by the calling tenant. + #[must_use] + pub fn list_upstreams(&self, tenant_id: Uuid) -> Vec { + self.upstreams.list(tenant_id) + } + + /// Delete an upstream and cascade to its routes. + /// + /// # Errors + /// + /// `404` when the upstream is not the tenant's. + pub fn delete_upstream(&self, tenant_id: Uuid, id: Uuid) -> OagwResult<()> { + if !self.upstreams.delete(tenant_id, id) { + return Err(OagwError::not_found("upstream not found")); + } + self.routes.delete_by_upstream(id); + Ok(()) + } + + // -- Routes ------------------------------------------------------------ + + /// Create a route under an upstream owned by the calling tenant. + /// + /// # Errors + /// + /// `400` on validation failure (including an unknown `upstream_id`), `409` + /// when an equivalent match rule already exists. + pub fn create_route(&self, tenant_id: Uuid, input: RouteInput) -> OagwResult { + let Some(upstream_id) = input.upstream_id else { + return Err(OagwError::validation("upstream_id is required")); + }; + let upstream = self.require_own_upstream(tenant_id, upstream_id)?; + let route = self.assemble_route(Uuid::new_v4(), tenant_id, upstream_id, &upstream, input)?; + self.routes.insert(route) + } + + /// Full replacement of a route owned by the calling tenant. + /// + /// # Errors + /// + /// `400` on validation failure or an attempt to move the route to another + /// upstream, `404` when the route is not the tenant's, `409` on a + /// duplicate match rule. + pub fn replace_route(&self, tenant_id: Uuid, id: Uuid, input: RouteInput) -> OagwResult { + let Some(existing) = self.routes.get(tenant_id, id) else { + return Err(OagwError::not_found("route not found")); + }; + if let Some(requested) = input.upstream_id + && requested != existing.upstream_id + { + return Err(OagwError::validation( + "upstream_id is immutable; delete and re-create the route to move it", + )); + } + let upstream = self.require_own_upstream(tenant_id, existing.upstream_id)?; + let route = + self.assemble_route(id, tenant_id, existing.upstream_id, &upstream, input)?; + self.routes.replace(route) + } + + #[must_use] + pub fn get_route(&self, tenant_id: Uuid, id: Uuid) -> Option { + self.routes.get(tenant_id, id) + } + + #[must_use] + pub fn list_routes(&self, tenant_id: Uuid) -> Vec { + self.routes.list(tenant_id) + } + + /// Delete a route owned by the calling tenant. + /// + /// # Errors + /// + /// `404` when the route is not the tenant's. + pub fn delete_route(&self, tenant_id: Uuid, id: Uuid) -> OagwResult<()> { + if self.routes.delete(tenant_id, id) { + Ok(()) + } else { + Err(OagwError::not_found("route not found")) + } + } + + // -- Plugins ----------------------------------------------------------- + + /// Register a custom plugin definition. Definitions are immutable. + /// + /// # Errors + /// + /// `400` on validation failure, `409` when the name is taken. + pub fn create_plugin(&self, tenant_id: Uuid, input: PluginInput) -> OagwResult { + let name = input.name.trim().to_owned(); + if name.is_empty() { + return Err(OagwError::validation("plugin name must not be empty")); + } + if input.source_code.trim().is_empty() { + return Err(OagwError::validation("plugin source_code must not be empty")); + } + let plugin = PluginDef { + id: Uuid::new_v4(), + tenant_id, + plugin_type: input.plugin_type, + name, + description: input.description, + phases: default_phases(input.plugin_type, input.phases), + config_schema: input.config_schema, + source_code: input.source_code, + last_used_at: None, + gc_eligible_at: None, + }; + self.plugins.insert(plugin) + } + + #[must_use] + pub fn get_plugin(&self, tenant_id: Uuid, id: Uuid) -> Option { + self.plugins.get(tenant_id, id) + } + + #[must_use] + pub fn list_plugins(&self, tenant_id: Uuid) -> Vec { + self.plugins.list(tenant_id) + } + + /// Delete an unlinked plugin. + /// + /// # Errors + /// + /// `404` when the plugin is not the tenant's, `409` when it is still + /// referenced by an upstream or route. + pub fn delete_plugin( + &self, + tenant_id: Uuid, + id: Uuid, + referenced_by: (Vec, Vec), + ) -> OagwResult<()> { + let Some(plugin) = self.plugins.get(tenant_id, id) else { + return Err(OagwError::not_found("plugin not found")); + }; + let (upstreams, routes) = referenced_by; + if !upstreams.is_empty() || !routes.is_empty() { + return Err(OagwError::new( + ErrorKind::PluginInUse, + format!( + "Plugin is referenced by {} upstream(s) and {} route(s)", + upstreams.len(), + routes.len() + ), + ) + .with("plugin_id", plugin.gts_id()) + .with( + "referenced_by", + serde_json::json!({ + "upstreams": upstreams + .iter() + .map(|id| format!("{}{id}", gts::UPSTREAM_BASE)) + .collect::>(), + "routes": routes + .iter() + .map(|id| format!("{}{id}", gts::ROUTE_BASE)) + .collect::>(), + }), + )); + } + if self.plugins.delete(tenant_id, id) { + Ok(()) + } else { + Err(OagwError::not_found("plugin not found")) + } + } + + // -- Proxy resolution -------------------------------------------------- + + /// Resolve `alias` for `ctx`'s tenant and pick the route that matches + /// `method` + `path_suffix`. + /// + /// Walks the tenant chain from descendant to root: the closest upstream + /// under the alias wins, and enforced ancestor constraints still apply. + /// + /// # Errors + /// + /// `404` when no upstream or no route matches, `503` when the upstream (or + /// an ancestor's upstream under the same alias) is disabled. + pub async fn resolve_proxy_target( + &self, + ctx: &SecurityContext, + alias_raw: &str, + method: &str, + path_suffix: &str, + ) -> OagwResult { + let alias = alias::normalize(alias_raw); + let chain = self + .tenants + .ancestor_chain(ctx, ctx.subject_tenant_id()) + .await; + + let candidates: Vec = chain + .iter() + .filter_map(|tenant| self.upstreams.find_by_alias(*tenant, &alias)) + .collect(); + + let Some((selected, ancestors)) = candidates.split_first() else { + return Err( + OagwError::not_found(format!("no upstream is configured for alias '{alias}'")) + .with("alias", alias.clone()), + ); + }; + + // An ancestor that disables the alias disables it for every descendant. + if let Some(disabled) = candidates.iter().find(|u| !u.enabled) { + return Err(OagwError::new( + ErrorKind::LinkUnavailable, + format!("upstream '{alias}' is disabled"), + ) + .with("alias", alias.clone()) + .with("upstream_id", disabled.gts_id())); + } + + let route = candidates + .iter() + .find_map(|upstream| self.match_route(upstream, method, path_suffix)) + .ok_or_else(|| { + OagwError::not_found(format!( + "no route on upstream '{alias}' matches {method} {path_suffix}" + )) + .with("alias", alias.clone()) + .with("upstream_id", selected.gts_id()) + .with("path", path_suffix.to_owned()) + })?; + + let effective = effective_config(selected, ancestors, Some(&route)); + Ok(ProxyTarget { + upstream: selected.clone(), + ancestors: ancestors.to_vec(), + route, + effective, + }) + } + + /// Best enabled route on `upstream` for `method` + `path_suffix`: + /// longest matching path prefix, ties broken by higher priority. + fn match_route(&self, upstream: &Upstream, method: &str, path_suffix: &str) -> Option { + let inbound = normalize_path(path_suffix); + let mut best: Option<(usize, i32, Route)> = None; + + for route in self.routes.list_by_upstream(upstream.id) { + if !route.enabled { + continue; + } + let Some(http) = route.http() else { + // gRPC routes are stored but not yet routable. + continue; + }; + if !http.allows_method(method) { + continue; + } + let route_path = normalize_path(&http.path); + if !path_prefix_matches(&route_path, &inbound) { + continue; + } + let score = route_path.len(); + let better = best + .as_ref() + .is_none_or(|(len, prio, _)| score > *len || (score == *len && route.priority > *prio)); + if better { + best = Some((score, route.priority, route)); + } + } + best.map(|(_, _, route)| route) + } + + // -- Validation helpers ------------------------------------------------ + + fn require_own_upstream(&self, tenant_id: Uuid, upstream_id: Uuid) -> OagwResult { + self.upstreams.get(tenant_id, upstream_id).ok_or_else(|| { + OagwError::validation(format!( + "upstream '{upstream_id}' does not exist for this tenant" + )) + .with("upstream_id", upstream_id.to_string()) + }) + } + + /// Endpoint pool rules: at least one endpoint, valid RFC 1123 hosts, and a + /// homogeneous `(scheme, port)` across the pool. + fn validate_endpoints( + &self, + endpoints: &[Endpoint], + protocol: Protocol, + ) -> OagwResult> { + let Some(first) = endpoints.first() else { + return Err(OagwError::validation( + "server.endpoints must contain at least one endpoint", + )); + }; + let mut normalized = Vec::with_capacity(endpoints.len()); + for endpoint in endpoints { + if endpoint.scheme != first.scheme { + return Err(OagwError::validation( + "all endpoints in a pool must share the same scheme", + )); + } + if endpoint.port != first.port { + return Err(OagwError::validation( + "all endpoints in a pool must share the same port", + )); + } + if endpoint.port == 0 { + return Err(OagwError::validation("endpoint port must be 1-65535")); + } + normalized.push(Endpoint { + scheme: endpoint.scheme, + host: alias::validate_host(&endpoint.host)?, + port: endpoint.port, + }); + } + + match (protocol, first.scheme) { + (Protocol::Grpc, Scheme::Grpc) | (Protocol::Http, _) => {} + (Protocol::Grpc, _) => { + return Err(OagwError::validation( + "a gRPC upstream requires endpoints with the 'grpc' scheme", + )); + } + } + if protocol == Protocol::Http && first.scheme == Scheme::Grpc { + return Err(OagwError::validation( + "the 'grpc' scheme requires the gRPC protocol", + )); + } + + Ok(normalized) + } + + fn assemble_upstream( + &self, + id: Uuid, + tenant_id: Uuid, + alias: String, + endpoints: Vec, + input: UpstreamInput, + ) -> OagwResult { + validate_tags(&input.tags)?; + if let Some(auth) = input.auth.as_ref() + && let Some(plugin_type) = auth.plugin_type.as_deref() + { + self.validate_auth_ref(tenant_id, plugin_type)?; + } + let plugins = self.validate_plugins(tenant_id, input.plugins)?; + if let Some(limit) = input.rate_limit.as_ref() { + validate_rate_limit(limit)?; + } + if let Some(cors) = input.cors.as_ref() { + validate_cors(cors)?; + } + + Ok(Upstream { + id, + tenant_id, + alias, + enabled: input.enabled, + protocol: input.protocol, + server: crate::domain::model::ServerConfig { endpoints }, + auth: input.auth, + headers: input.headers, + plugins, + rate_limit: input.rate_limit, + cors: input.cors, + tags: normalize_tags(input.tags), + }) + } + + fn assemble_route( + &self, + id: Uuid, + tenant_id: Uuid, + upstream_id: Uuid, + upstream: &Upstream, + input: RouteInput, + ) -> OagwResult { + validate_tags(&input.tags)?; + let match_config = validate_match(&input.r#match, upstream.protocol)?; + let plugins = self.validate_plugins(tenant_id, input.plugins)?; + if let Some(limit) = input.rate_limit.as_ref() { + validate_rate_limit(limit)?; + } + if let Some(cors) = input.cors.as_ref() { + validate_cors(cors)?; + } + + Ok(Route { + id, + tenant_id, + upstream_id, + enabled: input.enabled, + priority: input.priority, + r#match: match_config, + plugins, + rate_limit: input.rate_limit, + cors: input.cors, + tags: normalize_tags(input.tags), + }) + } + + /// An auth reference must be a catalogued named plugin or a UUID-backed + /// auth plugin owned by the tenant. + fn validate_auth_ref(&self, tenant_id: Uuid, plugin_type: &str) -> OagwResult<()> { + if CATALOG_AUTH_PLUGINS + .iter() + .any(|known| known.eq_ignore_ascii_case(plugin_type)) + { + return Ok(()); + } + if let Some(uuid) = gts::instance_uuid(plugin_type) + && gts::matches_plugin_base(plugin_type, gts::AUTH_PLUGIN_BASE) + { + return match self.plugins.get(tenant_id, uuid) { + Some(plugin) if plugin.plugin_type == PluginKind::Auth => Ok(()), + Some(_) => Err(OagwError::validation(format!( + "plugin '{plugin_type}' is not an auth plugin" + ))), + None => Err(OagwError::validation(format!( + "unknown auth plugin: {plugin_type}" + ))), + }; + } + Err(OagwError::validation(format!( + "unknown auth plugin: {plugin_type}" + ))) + } + + /// Guard/transform bindings must name a bindable built-in or a UUID-backed + /// custom plugin owned by the tenant. + fn validate_plugins( + &self, + tenant_id: Uuid, + plugins: Option, + ) -> OagwResult> { + let Some(mut plugins) = plugins else { + return Ok(None); + }; + let mut validated = Vec::with_capacity(plugins.items.len()); + for item in plugins.items { + let item = item.with_derived_uuid(); + self.validate_plugin_binding(tenant_id, &item)?; + validated.push(item); + } + plugins.items = validated; + Ok(Some(plugins)) + } + + fn validate_plugin_binding(&self, tenant_id: Uuid, item: &PluginBinding) -> OagwResult<()> { + if BINDABLE_NAMED_PLUGINS + .iter() + .any(|known| known.eq_ignore_ascii_case(&item.plugin_ref)) + { + return Ok(()); + } + if let Some(uuid) = item.plugin_uuid { + return if self.plugins.get(tenant_id, uuid).is_some() { + Ok(()) + } else { + Err(OagwError::validation(format!( + "unknown plugin: {}", + item.plugin_ref + ))) + }; + } + Err(OagwError::validation(format!( + "plugin '{}' cannot be bound through plugins.items", + item.plugin_ref + ))) + } +} + +fn default_phases(kind: PluginKind, requested: Vec) -> Vec { + use crate::domain::model::PluginPhase; + if !requested.is_empty() { + return requested; + } + match kind { + PluginKind::Auth | PluginKind::Guard => vec![PluginPhase::OnRequest], + PluginKind::Transform => vec![PluginPhase::OnRequest, PluginPhase::OnResponse], + } +} + +/// Ensure a path starts with `/` and carries no trailing slash beyond the root. +#[must_use] +pub fn normalize_path(path: &str) -> String { + let trimmed = path.trim(); + let with_root = if trimmed.starts_with('/') { + trimmed.to_owned() + } else { + format!("/{trimmed}") + }; + let stripped = with_root.trim_end_matches('/'); + if stripped.is_empty() { + "/".to_owned() + } else { + stripped.to_owned() + } +} + +/// Segment-aware prefix test: `/v1` matches `/v1/chat` but not `/v11`. +#[must_use] +pub fn path_prefix_matches(route_path: &str, inbound: &str) -> bool { + if route_path == "/" { + return true; + } + if inbound == route_path { + return true; + } + inbound + .strip_prefix(route_path) + .is_some_and(|rest| rest.starts_with('/')) +} + +fn normalize_tags(tags: Vec) -> Vec { + let mut out: Vec = Vec::with_capacity(tags.len()); + for tag in tags { + let tag = tag.trim().to_ascii_lowercase(); + if !tag.is_empty() && !out.contains(&tag) { + out.push(tag); + } + } + out +} + +fn validate_tags(tags: &[String]) -> OagwResult<()> { + for tag in tags { + let normalized = tag.trim(); + if normalized.is_empty() + || !normalized + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + { + return Err(OagwError::validation(format!( + "tag '{tag}' must match ^[a-z0-9_-]+$" + ))); + } + } + Ok(()) +} + +fn validate_rate_limit(limit: &RateLimitConfig) -> OagwResult<()> { + if limit.sustained.rate == 0 { + return Err(OagwError::validation( + "rate_limit.sustained.rate must be at least 1", + )); + } + if limit.burst.capacity == Some(0) { + return Err(OagwError::validation( + "rate_limit.burst.capacity must be at least 1", + )); + } + if limit.cost == 0 { + return Err(OagwError::validation("rate_limit.cost must be at least 1")); + } + Ok(()) +} + +fn validate_cors(cors: &CorsConfig) -> OagwResult<()> { + if cors.allow_credentials && cors.has_wildcard_origin() { + return Err(OagwError::validation( + "cannot use allow_credentials with wildcard origin", + )); + } + for method in &cors.allowed_methods { + if !matches!( + method.to_ascii_uppercase().as_str(), + "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS" + ) { + return Err(OagwError::validation(format!( + "cors.allowed_methods contains an unsupported method: {method}" + ))); + } + } + Ok(()) +} + +fn validate_match(config: &MatchConfig, protocol: Protocol) -> OagwResult { + match (&config.http, &config.grpc) { + (Some(_), Some(_)) => Err(OagwError::validation( + "match must contain exactly one of 'http' or 'grpc'", + )), + (None, None) => Err(OagwError::validation( + "match must contain exactly one of 'http' or 'grpc'", + )), + (Some(http), None) => { + if protocol != Protocol::Http { + return Err(OagwError::validation( + "an HTTP match requires an upstream with the HTTP protocol", + )); + } + if http.methods.is_empty() { + return Err(OagwError::validation( + "match.http.methods must contain at least one method", + )); + } + for method in &http.methods { + if !ALLOWED_ROUTE_METHODS + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(method)) + { + return Err(OagwError::validation(format!( + "match.http.methods contains an unsupported method: {method}" + ))); + } + } + if http.path.trim().is_empty() { + return Err(OagwError::validation("match.http.path must not be empty")); + } + let mut normalized = http.clone(); + normalized.methods = http + .methods + .iter() + .map(|m| m.to_ascii_uppercase()) + .collect(); + normalized.path = normalize_path(&http.path); + Ok(MatchConfig { + http: Some(normalized), + grpc: None, + }) + } + (None, Some(grpc)) => { + if protocol != Protocol::Grpc { + return Err(OagwError::validation( + "a gRPC match requires an upstream with the gRPC protocol", + )); + } + if grpc.service.trim().is_empty() || grpc.method.trim().is_empty() { + return Err(OagwError::validation( + "match.grpc.service and match.grpc.method must not be empty", + )); + } + Ok(config.clone()) + } + } +} + +#[cfg(test)] +#[path = "services_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/domain/services_tests.rs b/gears/system/oagw/oagw/src/domain/services_tests.rs new file mode 100644 index 0000000..36007de --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/services_tests.rs @@ -0,0 +1,796 @@ +//! Control Plane behaviour: validation, tenant scoping, and the alias + +//! route resolution the Data Plane depends on. + +use std::sync::Arc; + +use super::*; +use crate::domain::model::{PluginKind, PluginPhase}; +use crate::domain::tenant::FlatTenantDirectory; +use crate::infra::storage::InMemoryStore; +use crate::test_utils::{StaticTenantDirectory, security_context}; + +const HTTP: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +fn service_with(directory: Arc) -> (ControlPlaneService, Arc) { + let store = InMemoryStore::shared(); + let service = ControlPlaneService::new( + Arc::clone(&store) as Arc, + Arc::clone(&store) as Arc, + Arc::clone(&store) as Arc, + directory, + ); + (service, store) +} + +fn service() -> (ControlPlaneService, Arc) { + service_with(Arc::new(FlatTenantDirectory)) +} + +fn upstream_input(json: serde_json::Value) -> UpstreamInput { + serde_json::from_value(json).expect("fixture must deserialize") +} + +fn route_input(json: serde_json::Value) -> RouteInput { + serde_json::from_value(json).expect("fixture must deserialize") +} + +fn basic_upstream(alias: Option<&str>, host: &str) -> UpstreamInput { + let mut value = serde_json::json!({ + "server": {"endpoints": [{"scheme": "http", "host": host, "port": 80}]}, + "protocol": HTTP, + }); + if let Some(alias) = alias { + value["alias"] = alias.into(); + } + upstream_input(value) +} + +// -- Upstream CRUD ---------------------------------------------------------- + +#[test] +fn creating_an_upstream_derives_the_alias_and_assigns_an_id() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let upstream = service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + assert_eq!(upstream.alias, "api.example.com"); + assert_eq!(upstream.tenant_id, tenant); + assert!(upstream.enabled); + assert!(upstream.gts_id().starts_with(gts::UPSTREAM_BASE)); +} + +#[test] +fn a_duplicate_alias_for_the_same_tenant_conflicts() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + let err = service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap_err(); + assert_eq!(err.status(), 409); +} + +#[test] +fn the_same_alias_is_free_for_a_different_tenant() { + let (service, _) = service(); + service + .create_upstream(Uuid::new_v4(), basic_upstream(None, "api.example.com")) + .unwrap(); + service + .create_upstream(Uuid::new_v4(), basic_upstream(None, "api.example.com")) + .unwrap(); +} + +#[test] +fn an_empty_endpoint_pool_is_rejected() { + let (service, _) = service(); + let input = upstream_input(serde_json::json!({ + "server": {"endpoints": []}, + "protocol": HTTP, + })); + let err = service.create_upstream(Uuid::new_v4(), input).unwrap_err(); + assert_eq!(err.status(), 400); + assert!(err.detail.contains("at least one"), "{}", err.detail); +} + +#[test] +fn a_pool_must_be_homogeneous_in_scheme_and_port() { + let (service, _) = service(); + let mixed_scheme = upstream_input(serde_json::json!({ + "alias": "pool", + "server": {"endpoints": [ + {"scheme": "https", "host": "10.0.0.1", "port": 443}, + {"scheme": "http", "host": "10.0.0.2", "port": 443} + ]}, + "protocol": HTTP, + })); + assert!( + service + .create_upstream(Uuid::new_v4(), mixed_scheme) + .unwrap_err() + .detail + .contains("scheme") + ); + + let mixed_port = upstream_input(serde_json::json!({ + "alias": "pool", + "server": {"endpoints": [ + {"scheme": "https", "host": "10.0.0.1", "port": 443}, + {"scheme": "https", "host": "10.0.0.2", "port": 8443} + ]}, + "protocol": HTTP, + })); + assert!( + service + .create_upstream(Uuid::new_v4(), mixed_port) + .unwrap_err() + .detail + .contains("port") + ); +} + +#[test] +fn an_unknown_auth_plugin_is_rejected_at_create_time() { + let (service, _) = service(); + let input = upstream_input(serde_json::json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.example.com", "port": 443}]}, + "protocol": HTTP, + "auth": {"type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.nonesuch.v1"}, + })); + let err = service.create_upstream(Uuid::new_v4(), input).unwrap_err(); + assert_eq!(err.status(), 400); + assert!(err.detail.contains("unknown auth plugin"), "{}", err.detail); +} + +#[test] +fn catalog_only_auth_identifiers_are_accepted_at_create_time() { + // `basic` and `bearer` are reserved identifiers; they only fail when a + // proxy request actually tries to resolve an implementation. + let (service, _) = service(); + for id in [gts::BASIC_AUTH_PLUGIN_ID, gts::BEARER_AUTH_PLUGIN_ID] { + let input = upstream_input(serde_json::json!({ + "alias": "catalog-only", + "server": {"endpoints": [{"scheme": "https", "host": "10.0.0.9", "port": 443}]}, + "protocol": HTTP, + "auth": {"type": id}, + })); + service.create_upstream(Uuid::new_v4(), input).unwrap(); + } +} + +#[test] +fn only_bindable_named_plugins_may_be_bound() { + let (service, _) = service(); + let ok = upstream_input(serde_json::json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.example.com", "port": 443}]}, + "protocol": HTTP, + "plugins": {"items": [gts::REQUIRED_HEADERS_GUARD_PLUGIN_ID]}, + })); + service.create_upstream(Uuid::new_v4(), ok).unwrap(); + + for rejected in [ + gts::TIMEOUT_GUARD_PLUGIN_ID, + gts::CORS_GUARD_PLUGIN_ID, + gts::LOGGING_TRANSFORM_PLUGIN_ID, + gts::METRICS_TRANSFORM_PLUGIN_ID, + ] { + let input = upstream_input(serde_json::json!({ + "alias": "bad", + "server": {"endpoints": [{"scheme": "https", "host": "10.0.0.7", "port": 443}]}, + "protocol": HTTP, + "plugins": {"items": [rejected]}, + })); + let err = service.create_upstream(Uuid::new_v4(), input).unwrap_err(); + assert_eq!(err.status(), 400, "{rejected}"); + assert!(err.detail.contains("cannot be bound"), "{}", err.detail); + } +} + +#[test] +fn cors_credentials_with_a_wildcard_origin_are_rejected() { + let (service, _) = service(); + let input = upstream_input(serde_json::json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.example.com", "port": 443}]}, + "protocol": HTTP, + "cors": {"enabled": true, "allowed_origins": ["*"], "allow_credentials": true}, + })); + let err = service.create_upstream(Uuid::new_v4(), input).unwrap_err(); + assert_eq!(err.status(), 400); +} + +#[test] +fn tags_must_match_the_published_pattern() { + let (service, _) = service(); + let input = upstream_input(serde_json::json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.example.com", "port": 443}]}, + "protocol": HTTP, + "tags": ["Not Valid"], + })); + assert_eq!( + service.create_upstream(Uuid::new_v4(), input).unwrap_err().status(), + 400 + ); +} + +#[test] +fn replacing_an_upstream_clears_omitted_optional_fields() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let created = service + .create_upstream( + tenant, + upstream_input(serde_json::json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.example.com", "port": 443}]}, + "protocol": HTTP, + "tags": ["llm"], + })), + ) + .unwrap(); + assert_eq!(created.tags, ["llm"]); + + let replaced = service + .replace_upstream(tenant, created.id, basic_upstream(None, "api.example.com")) + .unwrap(); + assert!(replaced.tags.is_empty()); + assert_eq!(replaced.id, created.id); +} + +#[test] +fn another_tenants_upstream_is_invisible_to_every_management_verb() { + let (service, _) = service(); + let owner = Uuid::new_v4(); + let other = Uuid::new_v4(); + let created = service + .create_upstream(owner, basic_upstream(None, "api.example.com")) + .unwrap(); + + assert!(service.get_upstream(other, created.id).is_none()); + assert_eq!( + service + .replace_upstream(other, created.id, basic_upstream(None, "api.example.com")) + .unwrap_err() + .status(), + 404 + ); + assert_eq!( + service.delete_upstream(other, created.id).unwrap_err().status(), + 404 + ); + assert!(service.list_upstreams(other).is_empty()); +} + +#[test] +fn deleting_an_upstream_cascades_to_its_routes() { + let (service, store) = service(); + let tenant = Uuid::new_v4(); + let upstream = service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + service + .create_route( + tenant, + route_input(serde_json::json!({ + "upstream_id": upstream.id, + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })), + ) + .unwrap(); + + service.delete_upstream(tenant, upstream.id).unwrap(); + assert!( + crate::domain::repo::RouteRepository::list_by_upstream(&*store, upstream.id).is_empty() + ); +} + +// -- Route CRUD ------------------------------------------------------------- + +#[test] +fn a_route_must_target_the_callers_own_upstream() { + let (service, _) = service(); + let owner = Uuid::new_v4(); + let upstream = service + .create_upstream(owner, basic_upstream(None, "api.example.com")) + .unwrap(); + + let err = service + .create_route( + Uuid::new_v4(), + route_input(serde_json::json!({ + "upstream_id": upstream.id, + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })), + ) + .unwrap_err(); + assert_eq!(err.status(), 400); + assert!(err.detail.contains("does not exist"), "{}", err.detail); +} + +#[test] +fn a_route_needs_exactly_one_match_family() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let upstream = service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + + let neither = route_input(serde_json::json!({"upstream_id": upstream.id, "match": {}})); + assert_eq!(service.create_route(tenant, neither).unwrap_err().status(), 400); + + let both = route_input(serde_json::json!({ + "upstream_id": upstream.id, + "match": { + "http": {"methods": ["GET"], "path": "/"}, + "grpc": {"service": "foo.v1.Svc", "method": "Get"} + }, + })); + assert_eq!(service.create_route(tenant, both).unwrap_err().status(), 400); +} + +#[test] +fn a_grpc_match_needs_a_grpc_upstream() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let upstream = service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + let input = route_input(serde_json::json!({ + "upstream_id": upstream.id, + "match": {"grpc": {"service": "foo.v1.Svc", "method": "Get"}}, + })); + assert_eq!(service.create_route(tenant, input).unwrap_err().status(), 400); +} + +#[test] +fn unsupported_methods_are_rejected() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let upstream = service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + let input = route_input(serde_json::json!({ + "upstream_id": upstream.id, + "match": {"http": {"methods": ["TRACE"], "path": "/"}}, + })); + assert_eq!(service.create_route(tenant, input).unwrap_err().status(), 400); +} + +#[test] +fn a_duplicate_match_rule_conflicts() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let upstream = service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + let make = || { + route_input(serde_json::json!({ + "upstream_id": upstream.id, + "match": {"http": {"methods": ["GET"], "path": "/v1"}}, + })) + }; + service.create_route(tenant, make()).unwrap(); + assert_eq!(service.create_route(tenant, make()).unwrap_err().status(), 409); +} + +#[test] +fn a_different_priority_makes_the_same_path_unique() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let upstream = service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + service + .create_route( + tenant, + route_input(serde_json::json!({ + "upstream_id": upstream.id, + "match": {"http": {"methods": ["GET"], "path": "/v1"}}, + })), + ) + .unwrap(); + service + .create_route( + tenant, + route_input(serde_json::json!({ + "upstream_id": upstream.id, + "priority": 10, + "match": {"http": {"methods": ["GET"], "path": "/v1"}}, + })), + ) + .unwrap(); +} + +#[test] +fn a_route_cannot_be_moved_to_another_upstream() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let first = service + .create_upstream(tenant, basic_upstream(None, "a.example.com")) + .unwrap(); + let second = service + .create_upstream(tenant, basic_upstream(None, "b.example.com")) + .unwrap(); + let route = service + .create_route( + tenant, + route_input(serde_json::json!({ + "upstream_id": first.id, + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })), + ) + .unwrap(); + + let err = service + .replace_route( + tenant, + route.id, + route_input(serde_json::json!({ + "upstream_id": second.id, + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })), + ) + .unwrap_err(); + assert_eq!(err.status(), 400); + assert!(err.detail.contains("immutable"), "{}", err.detail); +} + +#[test] +fn a_route_path_is_normalized() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let upstream = service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + let route = service + .create_route( + tenant, + route_input(serde_json::json!({ + "upstream_id": upstream.id, + "match": {"http": {"methods": ["get"], "path": "v1/chat/"}}, + })), + ) + .unwrap(); + let http = route.http().unwrap(); + assert_eq!(http.path, "/v1/chat"); + assert_eq!(http.methods, ["GET"]); +} + +// -- Plugins ---------------------------------------------------------------- + +#[test] +fn a_plugin_needs_a_name_and_a_source() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let blank_name: PluginInput = serde_json::from_value(serde_json::json!({ + "name": " ", + "plugin_type": "guard", + "source_code": "def on_request(ctx): pass", + })) + .unwrap(); + assert_eq!(service.create_plugin(tenant, blank_name).unwrap_err().status(), 400); + + let blank_source: PluginInput = serde_json::from_value(serde_json::json!({ + "name": "guard", + "plugin_type": "guard", + "source_code": "", + })) + .unwrap(); + assert_eq!(service.create_plugin(tenant, blank_source).unwrap_err().status(), 400); +} + +#[test] +fn a_transform_plugin_defaults_to_the_request_and_response_phases() { + let (service, _) = service(); + let plugin: PluginInput = serde_json::from_value(serde_json::json!({ + "name": "redact", + "type": "transform", + "source_code": "def on_response(ctx): pass", + })) + .unwrap(); + let created = service.create_plugin(Uuid::new_v4(), plugin).unwrap(); + assert_eq!(created.plugin_type, PluginKind::Transform); + assert_eq!( + created.phases, + [PluginPhase::OnRequest, PluginPhase::OnResponse] + ); + assert!(created.gts_id().starts_with(gts::TRANSFORM_PLUGIN_BASE)); +} + +#[test] +fn a_referenced_plugin_cannot_be_deleted() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let plugin: PluginInput = serde_json::from_value(serde_json::json!({ + "name": "guard", + "plugin_type": "guard", + "source_code": "def on_request(ctx): pass", + })) + .unwrap(); + let created = service.create_plugin(tenant, plugin).unwrap(); + + let err = service + .delete_plugin(tenant, created.id, (vec![Uuid::new_v4()], Vec::new())) + .unwrap_err(); + assert_eq!(err.status(), 409); + assert_eq!(err.kind, ErrorKind::PluginInUse); + + service + .delete_plugin(tenant, created.id, (Vec::new(), Vec::new())) + .unwrap(); +} + +#[test] +fn a_uuid_backed_binding_must_name_an_existing_plugin() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let missing = Uuid::new_v4(); + let input = upstream_input(serde_json::json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.example.com", "port": 443}]}, + "protocol": HTTP, + "plugins": {"items": [format!("{}{missing}", gts::GUARD_PLUGIN_BASE)]}, + })); + let err = service.create_upstream(tenant, input).unwrap_err(); + assert_eq!(err.status(), 400); + assert!(err.detail.contains("unknown plugin"), "{}", err.detail); +} + +// -- Route matching --------------------------------------------------------- + +#[test] +fn path_prefix_matching_respects_segment_boundaries() { + assert!(path_prefix_matches("/v1", "/v1")); + assert!(path_prefix_matches("/v1", "/v1/chat")); + assert!(!path_prefix_matches("/v1", "/v11")); + assert!(path_prefix_matches("/", "/anything/at/all")); +} + +#[test] +fn normalize_path_adds_a_root_and_trims_trailing_slashes() { + assert_eq!(normalize_path("v1/chat/"), "/v1/chat"); + assert_eq!(normalize_path(""), "/"); + assert_eq!(normalize_path("/"), "/"); +} + +#[tokio::test] +async fn the_longest_matching_prefix_wins() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let ctx = security_context(tenant); + let upstream = service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + for path in ["/", "/v1", "/v1/chat"] { + service + .create_route( + tenant, + route_input(serde_json::json!({ + "upstream_id": upstream.id, + "match": {"http": {"methods": ["GET"], "path": path}}, + })), + ) + .unwrap(); + } + + let target = service + .resolve_proxy_target(&ctx, "api.example.com", "GET", "/v1/chat/completions") + .await + .unwrap(); + assert_eq!(target.route.http().unwrap().path, "/v1/chat"); +} + +#[tokio::test] +async fn a_disabled_route_is_excluded_from_matching() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let ctx = security_context(tenant); + let upstream = service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + service + .create_route( + tenant, + route_input(serde_json::json!({ + "upstream_id": upstream.id, + "enabled": false, + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })), + ) + .unwrap(); + + let err = service + .resolve_proxy_target(&ctx, "api.example.com", "GET", "/x") + .await + .unwrap_err(); + assert_eq!(err.status(), 404); +} + +#[tokio::test] +async fn a_method_outside_the_allowlist_does_not_match() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let ctx = security_context(tenant); + let upstream = service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + service + .create_route( + tenant, + route_input(serde_json::json!({ + "upstream_id": upstream.id, + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })), + ) + .unwrap(); + + assert_eq!( + service + .resolve_proxy_target(&ctx, "api.example.com", "DELETE", "/x") + .await + .unwrap_err() + .status(), + 404 + ); +} + +#[tokio::test] +async fn alias_resolution_is_case_insensitive() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let ctx = security_context(tenant); + let upstream = service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + service + .create_route( + tenant, + route_input(serde_json::json!({ + "upstream_id": upstream.id, + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })), + ) + .unwrap(); + + let target = service + .resolve_proxy_target(&ctx, "API.Example.COM.", "GET", "/x") + .await + .unwrap(); + assert_eq!(target.upstream.id, upstream.id); +} + +#[tokio::test] +async fn an_unknown_alias_is_a_route_not_found() { + let (service, _) = service(); + let ctx = security_context(Uuid::new_v4()); + let err = service + .resolve_proxy_target(&ctx, "nope.example.com", "GET", "/x") + .await + .unwrap_err(); + assert_eq!(err.status(), 404); + assert_eq!(err.kind, ErrorKind::RouteNotFound); +} + +#[tokio::test] +async fn a_disabled_upstream_is_unavailable_rather_than_missing() { + let (service, _) = service(); + let tenant = Uuid::new_v4(); + let ctx = security_context(tenant); + let mut input = basic_upstream(None, "api.example.com"); + input.enabled = false; + let upstream = service.create_upstream(tenant, input).unwrap(); + service + .create_route( + tenant, + route_input(serde_json::json!({ + "upstream_id": upstream.id, + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })), + ) + .unwrap(); + + let err = service + .resolve_proxy_target(&ctx, "api.example.com", "GET", "/x") + .await + .unwrap_err(); + assert_eq!(err.status(), 503); +} + +// -- Hierarchy -------------------------------------------------------------- + +#[tokio::test] +async fn a_descendant_shadows_an_ancestor_alias() { + let child_tenant = Uuid::new_v4(); + let parent_tenant = Uuid::new_v4(); + let (service, _) = service_with(Arc::new(StaticTenantDirectory::new(vec![( + child_tenant, + parent_tenant, + )]))); + + for tenant in [parent_tenant, child_tenant] { + let upstream = service + .create_upstream(tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + service + .create_route( + tenant, + route_input(serde_json::json!({ + "upstream_id": upstream.id, + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })), + ) + .unwrap(); + } + + let target = service + .resolve_proxy_target(&security_context(child_tenant), "api.example.com", "GET", "/x") + .await + .unwrap(); + assert_eq!(target.upstream.tenant_id, child_tenant); + assert_eq!(target.ancestors.len(), 1); + assert_eq!(target.ancestors[0].tenant_id, parent_tenant); +} + +#[tokio::test] +async fn a_descendant_inherits_an_ancestors_upstream_and_route() { + let child_tenant = Uuid::new_v4(); + let parent_tenant = Uuid::new_v4(); + let (service, _) = service_with(Arc::new(StaticTenantDirectory::new(vec![( + child_tenant, + parent_tenant, + )]))); + + let upstream = service + .create_upstream(parent_tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + service + .create_route( + parent_tenant, + route_input(serde_json::json!({ + "upstream_id": upstream.id, + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })), + ) + .unwrap(); + + let target = service + .resolve_proxy_target(&security_context(child_tenant), "api.example.com", "GET", "/x") + .await + .unwrap(); + assert_eq!(target.upstream.tenant_id, parent_tenant); + // The management API still hides it from the descendant. + assert!(service.get_upstream(child_tenant, upstream.id).is_none()); +} + +#[tokio::test] +async fn an_ancestor_disabling_the_alias_disables_it_for_descendants() { + let child_tenant = Uuid::new_v4(); + let parent_tenant = Uuid::new_v4(); + let (service, _) = service_with(Arc::new(StaticTenantDirectory::new(vec![( + child_tenant, + parent_tenant, + )]))); + + let mut disabled = basic_upstream(None, "api.example.com"); + disabled.enabled = false; + service.create_upstream(parent_tenant, disabled).unwrap(); + + let child_upstream = service + .create_upstream(child_tenant, basic_upstream(None, "api.example.com")) + .unwrap(); + service + .create_route( + child_tenant, + route_input(serde_json::json!({ + "upstream_id": child_upstream.id, + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })), + ) + .unwrap(); + + let err = service + .resolve_proxy_target(&security_context(child_tenant), "api.example.com", "GET", "/x") + .await + .unwrap_err(); + assert_eq!(err.status(), 503); +} diff --git a/gears/system/oagw/oagw/src/domain/tenant.rs b/gears/system/oagw/oagw/src/domain/tenant.rs new file mode 100644 index 0000000..dfad68a --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/tenant.rs @@ -0,0 +1,28 @@ +//! Tenant hierarchy lookups used by alias shadowing and config inheritance. + +use async_trait::async_trait; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +/// Resolves the descendant → root chain a proxy request inherits from. +#[async_trait] +pub trait TenantDirectory: Send + Sync { + /// Chain starting with `tenant_id` itself, then each ancestor up to the + /// root. + /// + /// Implementations degrade to `[tenant_id]` rather than failing: a + /// hierarchy lookup outage must not take down proxying for a tenant's own + /// upstreams. + async fn ancestor_chain(&self, ctx: &SecurityContext, tenant_id: Uuid) -> Vec; +} + +/// Directory for deployments with no hierarchy — every tenant is its own root. +#[derive(Debug, Default, Clone, Copy)] +pub struct FlatTenantDirectory; + +#[async_trait] +impl TenantDirectory for FlatTenantDirectory { + async fn ancestor_chain(&self, _ctx: &SecurityContext, tenant_id: Uuid) -> Vec { + vec![tenant_id] + } +} diff --git a/gears/system/oagw/oagw/src/gear.rs b/gears/system/oagw/oagw/src/gear.rs new file mode 100644 index 0000000..31a0393 --- /dev/null +++ b/gears/system/oagw/oagw/src/gear.rs @@ -0,0 +1,185 @@ +//! ToolKit gear wiring. +//! +//! `init` builds the Control Plane, the Data Plane and the plugin registries +//! and hands the REST layer a single shared state. `post_init` publishes the +//! GTS type catalog, which needs the types-registry to already be in ready +//! mode — hence the later phase. + +use std::sync::{Arc, OnceLock}; + +use async_trait::async_trait; +use authz_resolver_sdk::{AuthZResolverClient, PolicyEnforcer}; +use credstore_sdk::CredStoreClientV1; +use tenant_resolver_sdk::TenantResolverClient; +use toolkit::api::OpenApiRegistry; +use toolkit::contracts::SystemCapability; +use toolkit::{Gear, GearCtx, RestApiCapability}; +use tracing::{info, warn}; +use types_registry_sdk::TypesRegistryClient; + +use crate::api::rest::state::OagwState; +use crate::config::OagwConfig; +use crate::domain::services::ControlPlaneService; +use crate::domain::tenant::TenantDirectory; +use crate::infra::metrics::OagwMetrics; +use crate::infra::plugin::PluginRegistries; +use crate::infra::plugin::oauth2_client_cred_auth::TokenCacheConfig; +use crate::infra::proxy::DataPlaneService; +use crate::infra::proxy::connector::UpstreamConnector; +use crate::infra::rate_limit::RateLimiterRegistry; +use crate::infra::storage::InMemoryStore; +use crate::infra::tenant::TenantResolverDirectory; +use crate::infra::type_catalog; + +/// The Outbound API Gateway gear. +#[toolkit::gear( + name = "oagw", + deps = [types_registry, tenant_resolver, authz_resolver, credstore], + capabilities = [system, rest] +)] +pub struct OagwGear { + state: OnceLock>, + types: OnceLock>, +} + +impl Default for OagwGear { + fn default() -> Self { + Self { + state: OnceLock::new(), + types: OnceLock::new(), + } + } +} + +impl std::fmt::Debug for OagwGear { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OagwGear") + .field("initialized", &self.state.get().is_some()) + .finish() + } +} + +#[async_trait] +impl Gear for OagwGear { + #[tracing::instrument(skip_all, fields(module = "oagw"))] + async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> { + let config: OagwConfig = ctx.config_or_default()?; + config + .validate() + .map_err(|err| anyhow::anyhow!("oagw config invalid: {err}"))?; + info!( + proxy_timeout_secs = config.proxy_timeout_secs, + allow_http_upstream = config.allow_http_upstream, + ssrf_enabled = config.ssrf_policy.enabled, + "initializing oagw gear" + ); + + // Fail closed on the credential store: an auth plugin that cannot + // resolve a `cred://` reference is not a degraded gateway, it is one + // that would forward uncredentialed requests to a third party. + let credstore = ctx + .client_hub() + .get::() + .map_err(|err| anyhow::anyhow!("failed to get CredStoreClientV1: {err}"))?; + + let tenants: Arc = + match ctx.client_hub().get::() { + Ok(client) => Arc::new(TenantResolverDirectory::new( + client, + config.tenant_cache_ttl_secs, + )), + Err(err) => { + // Alias shadowing degrades to single-tenant resolution + // rather than refusing to start. + warn!( + error = %err, + "tenant-resolver client unavailable; alias resolution will not walk the \ + tenant hierarchy" + ); + Arc::new(crate::domain::tenant::FlatTenantDirectory) + } + }; + + let authz = match ctx.client_hub().get::() { + Ok(client) => Some(PolicyEnforcer::new(client)), + Err(err) => { + warn!(error = %err, "authz-resolver client unavailable; policy checks disabled"); + None + } + }; + + if let Ok(client) = ctx.client_hub().get::() { + let _ = self.types.set(client); + } else { + warn!("types-registry client unavailable; the OAGW type catalog will not be published"); + } + + let store = InMemoryStore::shared(); + let control = Arc::new(ControlPlaneService::new( + Arc::clone(&store) as Arc, + Arc::clone(&store) as Arc, + Arc::clone(&store) as Arc, + tenants, + )); + + let registries = Arc::new(PluginRegistries::with_builtins( + credstore, + TokenCacheConfig { + ttl: config.token_cache_ttl(), + capacity: config.token_cache_capacity, + }, + )); + + let data_plane = Arc::new(DataPlaneService::new( + Arc::clone(&control), + UpstreamConnector::shared(&config), + registries, + Arc::clone(&store) as Arc, + Arc::new(RateLimiterRegistry::new()), + Arc::new(OagwMetrics::from_global()), + config.clone(), + )); + + let state = Arc::new(OagwState { + control, + data_plane, + store, + authz, + config, + }); + self.state + .set(state) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + + info!("oagw gear initialized"); + Ok(()) + } +} + +#[async_trait] +impl SystemCapability for OagwGear { + async fn post_init(&self, _sys: &toolkit::runtime::SystemContext) -> anyhow::Result<()> { + if let Some(client) = self.types.get() { + type_catalog::register_catalog(client).await; + } + Ok(()) + } +} + +impl RestApiCapability for OagwGear { + fn register_rest( + &self, + _ctx: &GearCtx, + router: axum::Router, + openapi: &dyn OpenApiRegistry, + ) -> anyhow::Result { + let state = self + .state + .get() + .cloned() + .ok_or_else(|| anyhow::anyhow!("oagw state not initialized"))?; + let router = crate::api::rest::register_routes(router, openapi, state); + info!("oagw REST routes registered under /oagw/v1"); + Ok(router) + } +} diff --git a/gears/system/oagw/oagw/src/infra/metrics.rs b/gears/system/oagw/oagw/src/infra/metrics.rs new file mode 100644 index 0000000..456d1aa --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/metrics.rs @@ -0,0 +1,207 @@ +//! OpenTelemetry instrumentation for the Data Plane +//! (`cpt-cf-oagw-nfr-observability`). +//! +//! Label keys follow the OTel HTTP semantic conventions so OAGW and the +//! inbound API gateway share dashboards. Cardinality is bounded deliberately: +//! no tenant labels, `http.route` is the matched pattern rather than the raw +//! path, and the method is normalized to a standard verb or `_OTHER`. + +use opentelemetry::KeyValue; +use opentelemetry::metrics::{Counter, Gauge, Histogram, Meter}; + +/// Instrumentation scope name. +pub const METER_NAME: &str = "oagw"; + +const OAGW_REQUESTS: &str = "oagw_requests_total"; +const OAGW_REQUEST_DURATION: &str = "oagw_request_duration_seconds"; +const OAGW_REQUESTS_IN_FLIGHT: &str = "oagw_requests_in_flight"; +const OAGW_ERRORS: &str = "oagw_errors_total"; +const OAGW_RATE_LIMIT_EXCEEDED: &str = "oagw_rate_limit_exceeded_total"; +const OAGW_RATE_LIMIT_USAGE_RATIO: &str = "oagw_rate_limit_usage_ratio"; +const OAGW_ROUTING_TARGET_HOST_USED: &str = "oagw_routing_target_host_used"; +const OAGW_ROUTING_ENDPOINT_SELECTED: &str = "oagw_routing_endpoint_selected"; + +/// The standard verbs; anything else collapses to `_OTHER`. +const STANDARD_METHODS: &[&str] = &[ + "GET", "HEAD", "POST", "PUT", "DELETE", "CONNECT", "OPTIONS", "TRACE", "PATCH", +]; + +/// Normalize a request method for use as a metric label. +#[must_use] +pub fn normalize_method(method: &str) -> &'static str { + STANDARD_METHODS + .iter() + .find(|candidate| candidate.eq_ignore_ascii_case(method)) + .copied() + .unwrap_or("_OTHER") +} + +/// How an endpoint was picked out of the pool. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SelectionMethod { + ExplicitHeader, + RoundRobin, + Default, +} + +impl SelectionMethod { + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::ExplicitHeader => "explicit_header", + Self::RoundRobin => "round_robin", + Self::Default => "default", + } + } +} + +/// Data Plane instrument set. +pub struct OagwMetrics { + requests: Counter, + duration: Histogram, + in_flight: Gauge, + errors: Counter, + rate_limit_exceeded: Counter, + rate_limit_usage_ratio: Gauge, + target_host_used: Counter, + endpoint_selected: Counter, +} + +impl std::fmt::Debug for OagwMetrics { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OagwMetrics").finish_non_exhaustive() + } +} + +impl OagwMetrics { + /// Build instruments from the process-global meter provider. + #[must_use] + pub fn from_global() -> Self { + Self::new(&opentelemetry::global::meter(METER_NAME)) + } + + #[must_use] + pub fn new(meter: &Meter) -> Self { + Self { + requests: meter + .u64_counter(OAGW_REQUESTS) + .with_description("Proxy requests by upstream alias, method, route and status") + .build(), + duration: meter + .f64_histogram(OAGW_REQUEST_DURATION) + .with_description("Proxy request duration in seconds, by phase") + .build(), + in_flight: meter + .i64_gauge(OAGW_REQUESTS_IN_FLIGHT) + .with_description("Proxy requests currently in flight, by upstream alias") + .build(), + errors: meter + .u64_counter(OAGW_ERRORS) + .with_description("Gateway-originated errors by type") + .build(), + rate_limit_exceeded: meter + .u64_counter(OAGW_RATE_LIMIT_EXCEEDED) + .with_description("Requests rejected by a rate limit") + .build(), + rate_limit_usage_ratio: meter + .f64_gauge(OAGW_RATE_LIMIT_USAGE_RATIO) + .with_description("Fraction of the rate limit budget consumed") + .build(), + target_host_used: meter + .u64_counter(OAGW_ROUTING_TARGET_HOST_USED) + .with_description("Requests routed by an explicit X-OAGW-Target-Host header") + .build(), + endpoint_selected: meter + .u64_counter(OAGW_ROUTING_ENDPOINT_SELECTED) + .with_description("Endpoint selections by method") + .build(), + } + } + + /// Record a completed proxy request. + pub fn record_request(&self, host: &str, method: &str, route: &str, status: u16, secs: f64) { + let labels = [ + KeyValue::new("host", host.to_owned()), + KeyValue::new("http.request.method", normalize_method(method)), + KeyValue::new("http.route", route.to_owned()), + KeyValue::new("http.response.status_code", i64::from(status)), + ]; + self.requests.add(1, &labels); + self.duration.record( + secs, + &[ + KeyValue::new("host", host.to_owned()), + KeyValue::new("http.route", route.to_owned()), + KeyValue::new("phase", "total"), + ], + ); + } + + /// Record a phase duration (`resolve`, `plugins`, `upstream`, …). + pub fn record_phase(&self, host: &str, route: &str, phase: &'static str, secs: f64) { + self.duration.record( + secs, + &[ + KeyValue::new("host", host.to_owned()), + KeyValue::new("http.route", route.to_owned()), + KeyValue::new("phase", phase), + ], + ); + } + + pub fn set_in_flight(&self, host: &str, value: i64) { + self.in_flight + .record(value, &[KeyValue::new("host", host.to_owned())]); + } + + pub fn record_error(&self, host: &str, route: &str, error_type: &str) { + self.errors.add( + 1, + &[ + KeyValue::new("host", host.to_owned()), + KeyValue::new("http.route", route.to_owned()), + KeyValue::new("error_type", error_type.to_owned()), + ], + ); + } + + pub fn record_rate_limit(&self, host: &str, path: &str, exceeded: bool, usage_ratio: f64) { + let labels = [ + KeyValue::new("host", host.to_owned()), + KeyValue::new("path", path.to_owned()), + ]; + if exceeded { + self.rate_limit_exceeded.add(1, &labels); + } + self.rate_limit_usage_ratio.record(usage_ratio, &labels); + } + + pub fn record_endpoint_selection( + &self, + upstream_id: &str, + endpoint_host: &str, + selection: SelectionMethod, + ) { + if selection == SelectionMethod::ExplicitHeader { + self.target_host_used.add( + 1, + &[ + KeyValue::new("upstream_id", upstream_id.to_owned()), + KeyValue::new("endpoint_host", endpoint_host.to_owned()), + ], + ); + } + self.endpoint_selected.add( + 1, + &[ + KeyValue::new("upstream_id", upstream_id.to_owned()), + KeyValue::new("endpoint_host", endpoint_host.to_owned()), + KeyValue::new("selection_method", selection.as_str()), + ], + ); + } +} + +#[cfg(test)] +#[path = "metrics_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/metrics_tests.rs b/gears/system/oagw/oagw/src/infra/metrics_tests.rs new file mode 100644 index 0000000..d3c3235 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/metrics_tests.rs @@ -0,0 +1,32 @@ +//! Metric label normalization (cardinality control). + +use super::*; + +#[test] +fn standard_verbs_pass_through_and_others_collapse() { + assert_eq!(normalize_method("get"), "GET"); + assert_eq!(normalize_method("POST"), "POST"); + assert_eq!(normalize_method("PATCH"), "PATCH"); + assert_eq!(normalize_method("PROPFIND"), "_OTHER"); + assert_eq!(normalize_method(""), "_OTHER"); +} + +#[test] +fn selection_methods_render_as_the_documented_labels() { + assert_eq!(SelectionMethod::ExplicitHeader.as_str(), "explicit_header"); + assert_eq!(SelectionMethod::RoundRobin.as_str(), "round_robin"); + assert_eq!(SelectionMethod::Default.as_str(), "default"); +} + +#[test] +fn the_instrument_set_builds_against_the_global_meter() { + // A no-op meter provider is installed by default, so this exercises the + // instrument construction without needing an exporter. + let metrics = OagwMetrics::from_global(); + metrics.record_request("api.openai.com", "GET", "/v1/chat", 200, 0.01); + metrics.record_phase("api.openai.com", "/v1/chat", "upstream", 0.005); + metrics.set_in_flight("api.openai.com", 1); + metrics.record_error("api.openai.com", "/v1/chat", "timeout"); + metrics.record_rate_limit("api.openai.com", "/v1/chat", true, 1.0); + metrics.record_endpoint_selection("id", "us.vendor.com", SelectionMethod::RoundRobin); +} 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..0dcf267 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/mod.rs @@ -0,0 +1,9 @@ +//! Infrastructure layer — implementations of the domain contracts. + +pub mod metrics; +pub mod plugin; +pub mod proxy; +pub mod rate_limit; +pub mod storage; +pub mod type_catalog; +pub mod tenant; 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..3186193 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/apikey_auth.rs @@ -0,0 +1,133 @@ +//! `cf.core.oagw.apikey.v1` — API key injection into a header or a query +//! parameter. + +use std::sync::Arc; + +use async_trait::async_trait; +use credstore_sdk::CredStoreClientV1; +use http::{HeaderName, HeaderValue}; + +use crate::domain::gts; +use crate::domain::plugin::{AuthContext, AuthPlugin, PluginError, config_nonblank}; + +use super::credref::resolve_secret; + +/// Default header when the binding does not name one. +const DEFAULT_HEADER: &str = "x-api-key"; + +/// Where the key material is placed on the outbound request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Placement { + Header, + Query, +} + +/// Injects a stored API key. +/// +/// Config keys (all optional except the reference): +/// +/// | Key | Default | Meaning | +/// |---|---|---| +/// | `secret_ref` (`credential_ref`, `cred_ref`, `key_ref`) | — | `cred://` reference to the key | +/// | `in` (`location`) | `header` | `header` or `query` | +/// | `name` (`header_name`, `param_name`, `query_param`) | `x-api-key` | Header or parameter name | +/// | `prefix` (`value_prefix`, `scheme`) | *(none)* | Text prepended to the key, e.g. `Bearer ` | +pub struct ApiKeyAuthPlugin { + credstore: Arc, +} + +impl ApiKeyAuthPlugin { + #[must_use] + pub fn new(credstore: Arc) -> Self { + Self { credstore } + } +} + +impl std::fmt::Debug for ApiKeyAuthPlugin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ApiKeyAuthPlugin").finish_non_exhaustive() + } +} + +#[async_trait] +impl AuthPlugin for ApiKeyAuthPlugin { + fn id(&self) -> &str { + "apikey" + } + + fn plugin_type(&self) -> &str { + gts::APIKEY_AUTH_PLUGIN_ID + } + + async fn authenticate(&self, ctx: &mut AuthContext<'_>) -> Result<(), PluginError> { + let config = ctx.config; + let reference = ["secret_ref", "credential_ref", "cred_ref", "key_ref"] + .into_iter() + .find_map(|key| config_nonblank(config, key)) + .ok_or_else(|| { + PluginError::InvalidConfig( + "apikey auth plugin requires a 'secret_ref' config key".to_owned(), + ) + })?; + + let placement = match ["in", "location"] + .into_iter() + .find_map(|key| config_nonblank(config, key)) + .unwrap_or_else(|| "header".to_owned()) + .to_ascii_lowercase() + .as_str() + { + "header" => Placement::Header, + "query" | "query_param" | "querystring" => Placement::Query, + other => { + return Err(PluginError::InvalidConfig(format!( + "apikey auth plugin: unsupported 'in' value '{other}' (expected 'header' or 'query')" + ))); + } + }; + + let name = ["name", "header_name", "param_name", "query_param"] + .into_iter() + .find_map(|key| config_nonblank(config, key)) + .unwrap_or_else(|| DEFAULT_HEADER.to_owned()); + + let prefix = ["prefix", "value_prefix", "scheme"] + .into_iter() + .find_map(|key| config_nonblank(config, key)) + .unwrap_or_default(); + + let secret = + resolve_secret(&self.credstore, ctx.security_context(), &reference).await?; + + // `prefix` is a config value, not secret material; the concatenation + // is scoped to this request and never logged. + let value = if prefix.is_empty() { + secret.expose().to_owned() + } else if prefix.ends_with(' ') { + format!("{prefix}{}", secret.expose()) + } else { + format!("{prefix} {}", secret.expose()) + }; + + match placement { + Placement::Header => { + let header_name = HeaderName::try_from(name.to_ascii_lowercase()) + .map_err(|_| { + PluginError::InvalidConfig(format!("invalid header name '{name}'")) + })?; + let mut header_value = HeaderValue::from_str(&value).map_err(|_| { + PluginError::InvalidConfig( + "resolved API key is not a valid header value".to_owned(), + ) + })?; + header_value.set_sensitive(true); + ctx.headers.insert(header_name, header_value); + } + Placement::Query => { + ctx.query.retain(|(key, _)| key != &name); + ctx.query.push((name, value)); + } + } + Ok(()) + } +} diff --git a/gears/system/oagw/oagw/src/infra/plugin/credref.rs b/gears/system/oagw/oagw/src/infra/plugin/credref.rs new file mode 100644 index 0000000..1f2ec83 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/credref.rs @@ -0,0 +1,58 @@ +//! Resolution of `cred://` references through the credential store. +//! +//! OAGW never stores secret material; every credential is a reference resolved +//! at request time (`cpt-cf-oagw-principle-cred-isolation`). Failures never +//! carry the resolved value, only the reference. + +use std::sync::Arc; + +use credstore_sdk::{CredStoreClientV1, SecretRef}; +use toolkit_auth::oauth2::SecretString; +use toolkit_security::SecurityContext; + +use crate::domain::plugin::PluginError; + +/// Strip the `cred://` scheme from a secret reference. +#[must_use] +pub fn strip_scheme(reference: &str) -> &str { + let trimmed = reference.trim(); + trimmed + .strip_prefix("cred://") + .or_else(|| trimmed.strip_prefix("credstore://")) + .unwrap_or(trimmed) +} + +/// Resolve `reference` for the caller's tenant. +/// +/// # Errors +/// +/// * [`PluginError::InvalidConfig`] — the reference is not a well-formed key. +/// * [`PluginError::SecretNotFound`] — nothing accessible resolves. +/// * [`PluginError::Unauthenticated`] — the credential store refused access. +pub async fn resolve_secret( + credstore: &Arc, + ctx: &SecurityContext, + reference: &str, +) -> Result { + let key = SecretRef::new(strip_scheme(reference)) + .map_err(|err| PluginError::InvalidConfig(format!("invalid secret_ref: {err}")))?; + + match credstore.get(ctx, &key).await { + Ok(Some(response)) => { + let value = String::from_utf8(response.value.as_bytes().to_vec()).map_err(|_| { + PluginError::InvalidConfig(format!( + "secret '{reference}' is not valid UTF-8 and cannot be injected as a header" + )) + })?; + Ok(SecretString::new(value)) + } + Ok(None) => Err(PluginError::SecretNotFound(reference.to_owned())), + Err(err) => Err(PluginError::Unauthenticated(format!( + "credential store refused '{reference}': {err}" + ))), + } +} + +#[cfg(test)] +#[path = "credref_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/plugin/credref_tests.rs b/gears/system/oagw/oagw/src/infra/plugin/credref_tests.rs new file mode 100644 index 0000000..81c0fc8 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/credref_tests.rs @@ -0,0 +1,58 @@ +//! `cred://` reference handling. + +use super::*; +use credstore_sdk::test_util::MockCredStoreClient; + +fn client(secrets: Vec<(String, String)>) -> Arc { + Arc::new(MockCredStoreClient::with_secrets(secrets)) +} + +#[test] +fn the_scheme_is_optional_and_stripped() { + assert_eq!(strip_scheme("cred://openai-key"), "openai-key"); + assert_eq!(strip_scheme("credstore://openai-key"), "openai-key"); + assert_eq!(strip_scheme(" openai-key "), "openai-key"); +} + +#[tokio::test] +async fn a_known_reference_resolves_to_its_value() { + let client = client(vec![("openai-key".to_owned(), "sk-test".to_owned())]); + let ctx = SecurityContext::anonymous(); + let secret = resolve_secret(&client, &ctx, "cred://openai-key").await.unwrap(); + assert_eq!(secret.expose(), "sk-test"); +} + +#[tokio::test] +async fn an_unknown_reference_is_a_secret_not_found() { + let client = client(Vec::new()); + let ctx = SecurityContext::anonymous(); + let err = resolve_secret(&client, &ctx, "cred://absent").await.unwrap_err(); + assert!(matches!(err, PluginError::SecretNotFound(_)), "{err}"); + // The reference may appear in the message; the value never can. + assert!(err.to_string().contains("absent")); +} + +#[tokio::test] +async fn a_malformed_reference_is_a_configuration_error() { + let client = client(Vec::new()); + let ctx = SecurityContext::anonymous(); + let err = resolve_secret(&client, &ctx, "cred://not a key!").await.unwrap_err(); + assert!(matches!(err, PluginError::InvalidConfig(_)), "{err}"); +} + +#[tokio::test] +async fn a_non_utf8_secret_cannot_be_injected_as_a_header() { + let client: Arc = + Arc::new(MockCredStoreClient::returning_raw_value(vec![0xff, 0xfe])); + let ctx = SecurityContext::anonymous(); + let err = resolve_secret(&client, &ctx, "cred://binary").await.unwrap_err(); + assert!(matches!(err, PluginError::InvalidConfig(_)), "{err}"); +} + +#[tokio::test] +async fn a_refusing_credential_store_surfaces_as_an_auth_failure() { + let client: Arc = Arc::new(MockCredStoreClient::always_failing()); + let ctx = SecurityContext::anonymous(); + let err = resolve_secret(&client, &ctx, "cred://openai-key").await.unwrap_err(); + assert!(matches!(err, PluginError::Unauthenticated(_)), "{err}"); +} 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..aaf0ad5 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/mod.rs @@ -0,0 +1,13 @@ +//! Built-in plugin implementations and the registries that resolve them. + +pub mod apikey_auth; +pub mod credref; +pub mod noop_auth; +pub mod oauth2_client_cred_auth; +pub mod registry; +pub mod request_id_transform; +pub mod required_headers_guard; + +pub use registry::{ + AuthPluginRegistry, GuardPluginRegistry, PluginRegistries, TransformPluginRegistry, +}; 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..b6ef614 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/noop_auth.rs @@ -0,0 +1,26 @@ +//! `cf.core.oagw.noop.v1` — an upstream that needs no credential. + +use async_trait::async_trait; + +use crate::domain::gts; +use crate::domain::plugin::{AuthContext, AuthPlugin, PluginError}; + +/// Injects nothing. Present so "no auth" is an explicit, auditable choice +/// rather than an omitted field. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopAuthPlugin; + +#[async_trait] +impl AuthPlugin for NoopAuthPlugin { + fn id(&self) -> &str { + "noop" + } + + fn plugin_type(&self) -> &str { + gts::NOOP_AUTH_PLUGIN_ID + } + + async fn authenticate(&self, _ctx: &mut AuthContext<'_>) -> Result<(), PluginError> { + 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..e7a02f9 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/oauth2_client_cred_auth.rs @@ -0,0 +1,217 @@ +//! `cf.core.oagw.oauth2_client_cred[_basic].v1` — OAuth2 Client Credentials +//! with an internal token cache (ADR 0008). +//! +//! Registered twice, once per client authentication method. Both variants +//! share this implementation; only `auth_method` differs. + +use std::collections::BTreeMap; +use std::hash::{Hash, Hasher}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use credstore_sdk::CredStoreClientV1; +use http::{HeaderValue, header}; +use pingora_memory_cache::MemoryCache; +use toolkit_auth::oauth2::{ + ClientAuthMethod, OAuthClientConfig, SecretString, fetch_token, +}; + +use crate::domain::gts; +use crate::domain::plugin::{AuthContext, AuthPlugin, PluginError, config_nonblank}; + +use super::credref::resolve_secret; + +/// Never cache a token that is about to expire. +const EXPIRY_SAFETY_MARGIN: Duration = Duration::from_secs(30); + +/// Gear-level cache settings threaded in from [`crate::OagwConfig`]. +#[derive(Debug, Clone, Copy)] +pub struct TokenCacheConfig { + pub ttl: Duration, + pub capacity: usize, +} + +impl Default for TokenCacheConfig { + fn default() -> Self { + Self { + ttl: Duration::from_secs(300), + capacity: 10_000, + } + } +} + +/// Cache entry that carries its own key. +/// +/// `TinyUfo` hashes keys to `u64` and does not compare them on hit, so the key +/// is re-checked here: a hash collision must degrade to a miss, never to +/// another tenant's token. +#[derive(Clone)] +struct CachedToken { + key: String, + token: SecretString, +} + +/// OAuth2 Client Credentials auth plugin. +pub struct OAuth2ClientCredAuthPlugin { + credstore: Arc, + auth_method: ClientAuthMethod, + cache: MemoryCache, + cache_ttl: Duration, +} + +impl OAuth2ClientCredAuthPlugin { + #[must_use] + pub fn new( + credstore: Arc, + auth_method: ClientAuthMethod, + cache: TokenCacheConfig, + ) -> Self { + Self { + credstore, + auth_method, + cache: MemoryCache::new(cache.capacity.max(1)), + cache_ttl: cache.ttl, + } + } + + fn method_tag(&self) -> &'static str { + match self.auth_method { + ClientAuthMethod::Basic => "basic", + ClientAuthMethod::Form => "form", + } + } + + /// Identity-complete cache key: tenant, subject, client-auth method and a + /// deterministic hash of the binding config. + fn build_cache_key(&self, ctx: &AuthContext<'_>) -> String { + let security = ctx.security_context(); + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + // BTreeMap iteration is already ordered, so the hash is stable. + let ordered: &BTreeMap = ctx.config; + for (key, value) in ordered { + key.hash(&mut hasher); + value.to_string().hash(&mut hasher); + } + format!( + "{}:{}:{}:{:016x}", + security.subject_tenant_id(), + security.subject_id(), + self.method_tag(), + hasher.finish() + ) + } +} + +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.method_tag()) + .field("cache_ttl", &self.cache_ttl) + .finish_non_exhaustive() + } +} + +#[async_trait] +impl AuthPlugin for OAuth2ClientCredAuthPlugin { + fn id(&self) -> &str { + match self.auth_method { + ClientAuthMethod::Basic => "oauth2_client_cred_basic", + ClientAuthMethod::Form => "oauth2_client_cred", + } + } + + fn plugin_type(&self) -> &str { + match self.auth_method { + ClientAuthMethod::Basic => gts::OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID, + ClientAuthMethod::Form => gts::OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID, + } + } + + async fn authenticate(&self, ctx: &mut AuthContext<'_>) -> Result<(), PluginError> { + let key = self.build_cache_key(ctx); + if let (Some(entry), _) = self.cache.get(&key) + && entry.key == key + { + inject_bearer(ctx, &entry.token)?; + return Ok(()); + } + + let config = ctx.config; + let token_endpoint = config_nonblank(config, "token_endpoint"); + let issuer_url = config_nonblank(config, "issuer_url"); + if token_endpoint.is_some() == issuer_url.is_some() { + return Err(PluginError::InvalidConfig( + "oauth2 client credentials plugin requires exactly one of 'token_endpoint' or \ + 'issuer_url'" + .to_owned(), + )); + } + + let client_id_ref = config_nonblank(config, "client_id_ref").ok_or_else(|| { + PluginError::InvalidConfig("missing 'client_id_ref' config key".to_owned()) + })?; + let client_secret_ref = config_nonblank(config, "client_secret_ref").ok_or_else(|| { + PluginError::InvalidConfig("missing 'client_secret_ref' config key".to_owned()) + })?; + + let security = ctx.security_context(); + let client_id = resolve_secret(&self.credstore, security, &client_id_ref).await?; + let client_secret = resolve_secret(&self.credstore, security, &client_secret_ref).await?; + + let mut oauth_config = OAuthClientConfig { + client_id: client_id.expose().to_owned(), + client_secret, + auth_method: self.auth_method, + scopes: config_nonblank(config, "scopes") + .map(|raw| { + raw.split_whitespace() + .map(ToOwned::to_owned) + .collect::>() + }) + .unwrap_or_default(), + ..OAuthClientConfig::default() + }; + if let Some(endpoint) = token_endpoint { + oauth_config.token_endpoint = Some(parse_url(&endpoint, "token_endpoint")?); + } + if let Some(issuer) = issuer_url { + oauth_config.issuer_url = Some(parse_url(&issuer, "issuer_url")?); + } + + // A failed fetch is never cached — the next request retries the IdP. + let fetched = fetch_token(oauth_config) + .await + .map_err(|err| PluginError::Unauthenticated(format!("token exchange failed: {err}")))?; + + let ttl = fetched + .expires_in + .checked_sub(EXPIRY_SAFETY_MARGIN) + .map(|remaining| remaining.min(self.cache_ttl)); + if let Some(ttl) = ttl.filter(|t| !t.is_zero()) { + self.cache.put( + &key, + CachedToken { + key: key.clone(), + token: SecretString::new(fetched.bearer.expose().to_owned()), + }, + Some(ttl), + ); + } + + inject_bearer(ctx, &fetched.bearer) + } +} + +fn parse_url(raw: &str, field: &str) -> Result { + url::Url::parse(raw) + .map_err(|err| PluginError::InvalidConfig(format!("invalid '{field}' URL: {err}"))) +} + +fn inject_bearer(ctx: &mut AuthContext<'_>, token: &SecretString) -> Result<(), PluginError> { + let mut value = HeaderValue::from_str(&format!("Bearer {}", token.expose())) + .map_err(|_| PluginError::Internal("access token is not a valid header value".to_owned()))?; + value.set_sensitive(true); + ctx.headers.insert(header::AUTHORIZATION, value); + Ok(()) +} 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..b8699b3 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/registry.rs @@ -0,0 +1,165 @@ +//! In-process plugin registries. +//! +//! Named plugins live here and are never persisted. UUID-backed custom plugins +//! are stored in the plugin repository instead; the instance part of the GTS +//! identifier decides which side resolves it +//! (`docs/DESIGN.md` §"Resolution Algorithm"). + +use std::collections::HashMap; +use std::sync::Arc; + +use credstore_sdk::CredStoreClientV1; +use toolkit_auth::oauth2::ClientAuthMethod; + +use crate::domain::gts; +use crate::domain::plugin::{AuthPlugin, GuardPlugin, TransformPlugin}; + +use super::apikey_auth::ApiKeyAuthPlugin; +use super::noop_auth::NoopAuthPlugin; +use super::oauth2_client_cred_auth::{OAuth2ClientCredAuthPlugin, TokenCacheConfig}; +use super::request_id_transform::RequestIdTransformPlugin; +use super::required_headers_guard::RequiredHeadersGuardPlugin; + +/// Registry of auth plugins, keyed by full GTS identifier. +#[derive(Default)] +pub struct AuthPluginRegistry { + plugins: HashMap>, +} + +impl AuthPluginRegistry { + /// Register every built-in auth plugin. + /// + /// `basic` and `bearer` are deliberately absent: they are catalog + /// identifiers with no backing implementation, so binding one fails with + /// `unknown auth plugin`. + #[must_use] + pub fn with_builtins( + credstore: Arc, + token_cache: TokenCacheConfig, + ) -> Self { + let mut plugins: HashMap> = HashMap::new(); + plugins.insert( + gts::NOOP_AUTH_PLUGIN_ID.to_owned(), + Arc::new(NoopAuthPlugin), + ); + plugins.insert( + gts::APIKEY_AUTH_PLUGIN_ID.to_owned(), + Arc::new(ApiKeyAuthPlugin::new(Arc::clone(&credstore))), + ); + plugins.insert( + gts::OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID.to_owned(), + Arc::new(OAuth2ClientCredAuthPlugin::new( + Arc::clone(&credstore), + ClientAuthMethod::Form, + token_cache, + )), + ); + plugins.insert( + gts::OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID.to_owned(), + Arc::new(OAuth2ClientCredAuthPlugin::new( + credstore, + ClientAuthMethod::Basic, + token_cache, + )), + ); + Self { plugins } + } + + /// Add or replace a plugin, e.g. one contributed by another gear. + pub fn register(&mut self, plugin: Arc) { + self.plugins.insert(plugin.plugin_type().to_owned(), plugin); + } + + #[must_use] + pub fn get(&self, gts_id: &str) -> Option> { + self.plugins.get(gts_id).map(Arc::clone) + } + + #[must_use] + pub fn ids(&self) -> Vec<&str> { + self.plugins.keys().map(String::as_str).collect() + } +} + +/// Registry of guard plugins, keyed by full GTS identifier. +#[derive(Default)] +pub struct GuardPluginRegistry { + plugins: HashMap>, +} + +impl GuardPluginRegistry { + /// `required_headers` is the only built-in guard: timeout and CORS are + /// core Data Plane logic, not `GuardPlugin` implementations. + #[must_use] + pub fn with_builtins() -> Self { + let mut plugins: HashMap> = HashMap::new(); + plugins.insert( + gts::REQUIRED_HEADERS_GUARD_PLUGIN_ID.to_owned(), + Arc::new(RequiredHeadersGuardPlugin), + ); + Self { plugins } + } + + pub fn register(&mut self, plugin: Arc) { + self.plugins.insert(plugin.plugin_type().to_owned(), plugin); + } + + #[must_use] + pub fn get(&self, gts_id: &str) -> Option> { + self.plugins.get(gts_id).map(Arc::clone) + } +} + +/// Registry of transform plugins, keyed by full GTS identifier. +#[derive(Default)] +pub struct TransformPluginRegistry { + plugins: HashMap>, +} + +impl TransformPluginRegistry { + /// `request_id` is the only built-in transform: logging and metrics are + /// core Data Plane instrumentation. + #[must_use] + pub fn with_builtins() -> Self { + let mut plugins: HashMap> = HashMap::new(); + plugins.insert( + gts::REQUEST_ID_TRANSFORM_PLUGIN_ID.to_owned(), + Arc::new(RequestIdTransformPlugin), + ); + Self { plugins } + } + + pub fn register(&mut self, plugin: Arc) { + self.plugins.insert(plugin.plugin_type().to_owned(), plugin); + } + + #[must_use] + pub fn get(&self, gts_id: &str) -> Option> { + self.plugins.get(gts_id).map(Arc::clone) + } +} + +/// The three registries, wired once during gear init. +pub struct PluginRegistries { + pub auth: AuthPluginRegistry, + pub guard: GuardPluginRegistry, + pub transform: TransformPluginRegistry, +} + +impl PluginRegistries { + #[must_use] + pub fn with_builtins( + credstore: Arc, + token_cache: TokenCacheConfig, + ) -> Self { + Self { + auth: AuthPluginRegistry::with_builtins(credstore, token_cache), + guard: GuardPluginRegistry::with_builtins(), + transform: TransformPluginRegistry::with_builtins(), + } + } +} + +#[cfg(test)] +#[path = "registry_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/plugin/registry_tests.rs b/gears/system/oagw/oagw/src/infra/plugin/registry_tests.rs new file mode 100644 index 0000000..5d2654f --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/registry_tests.rs @@ -0,0 +1,189 @@ +//! Which plugin identifiers the in-process registries resolve. + +use super::*; +use crate::domain::model::ConfigMap; +use crate::domain::plugin::{AuthContext, PluginScope}; +use credstore_sdk::test_util::MockCredStoreClient; +use http::HeaderMap; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn credstore(secrets: Vec<(&str, &str)>) -> Arc { + Arc::new(MockCredStoreClient::with_secrets( + secrets + .into_iter() + .map(|(k, v)| (k.to_owned(), v.to_owned())) + .collect(), + )) +} + +fn registries(secrets: Vec<(&str, &str)>) -> PluginRegistries { + PluginRegistries::with_builtins( + credstore(secrets), + super::super::oauth2_client_cred_auth::TokenCacheConfig::default(), + ) +} + +#[test] +fn every_implemented_auth_plugin_resolves() { + let registries = registries(Vec::new()); + for id in [ + gts::NOOP_AUTH_PLUGIN_ID, + gts::APIKEY_AUTH_PLUGIN_ID, + gts::OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID, + gts::OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID, + ] { + let plugin = registries.auth.get(id).unwrap_or_else(|| panic!("{id}")); + assert_eq!(plugin.plugin_type(), id); + } +} + +#[test] +fn the_catalog_only_auth_identifiers_have_no_implementation() { + let registries = registries(Vec::new()); + assert!(registries.auth.get(gts::BASIC_AUTH_PLUGIN_ID).is_none()); + assert!(registries.auth.get(gts::BEARER_AUTH_PLUGIN_ID).is_none()); +} + +#[test] +fn required_headers_is_the_only_builtin_guard() { + let registries = registries(Vec::new()); + assert!(registries.guard.get(gts::REQUIRED_HEADERS_GUARD_PLUGIN_ID).is_some()); + // Timeout and CORS are core Data Plane logic, not guard implementations. + assert!(registries.guard.get(gts::TIMEOUT_GUARD_PLUGIN_ID).is_none()); + assert!(registries.guard.get(gts::CORS_GUARD_PLUGIN_ID).is_none()); +} + +#[test] +fn request_id_is_the_only_builtin_transform() { + let registries = registries(Vec::new()); + assert!(registries.transform.get(gts::REQUEST_ID_TRANSFORM_PLUGIN_ID).is_some()); + // Logging and metrics are core instrumentation. + assert!(registries.transform.get(gts::LOGGING_TRANSFORM_PLUGIN_ID).is_none()); + assert!(registries.transform.get(gts::METRICS_TRANSFORM_PLUGIN_ID).is_none()); +} + +async fn inject(config: ConfigMap, secrets: Vec<(&str, &str)>) -> (HeaderMap, Vec<(String, String)>) { + let registries = registries(secrets); + let plugin = registries.auth.get(gts::APIKEY_AUTH_PLUGIN_ID).unwrap(); + let security = SecurityContext::anonymous(); + let mut headers = HeaderMap::new(); + let mut query = Vec::new(); + let mut ctx = AuthContext { + scope: PluginScope { + security_context: &security, + alias: "api.example.com", + upstream_id: Uuid::nil(), + route_id: None, + }, + config: &config, + headers: &mut headers, + query: &mut query, + }; + plugin.authenticate(&mut ctx).await.expect("injection should succeed"); + (headers, query) +} + +fn config(pairs: &[(&str, &str)]) -> ConfigMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), serde_json::Value::from(*v))) + .collect() +} + +#[tokio::test] +async fn the_api_key_plugin_injects_a_header_by_default() { + let (headers, query) = inject( + config(&[("secret_ref", "cred://openai-key")]), + vec![("openai-key", "sk-test")], + ) + .await; + assert_eq!(headers.get("x-api-key").unwrap(), "sk-test"); + assert!(query.is_empty()); +} + +#[tokio::test] +async fn the_api_key_plugin_honours_the_configured_header_and_prefix() { + let (headers, _) = inject( + config(&[ + ("secret_ref", "cred://openai-key"), + ("name", "Authorization"), + ("prefix", "Bearer"), + ]), + vec![("openai-key", "sk-test")], + ) + .await; + assert_eq!(headers.get("authorization").unwrap(), "Bearer sk-test"); + // Header values holding secret material are marked sensitive so tracing + // layers redact them. + assert!(headers.get("authorization").unwrap().is_sensitive()); +} + +#[tokio::test] +async fn the_api_key_plugin_can_inject_a_query_parameter() { + let (headers, query) = inject( + config(&[ + ("secret_ref", "cred://openai-key"), + ("in", "query"), + ("name", "api_key"), + ]), + vec![("openai-key", "sk-test")], + ) + .await; + assert!(headers.is_empty()); + assert_eq!(query, vec![("api_key".to_owned(), "sk-test".to_owned())]); +} + +#[tokio::test] +async fn the_api_key_plugin_needs_a_secret_reference() { + let registries = registries(Vec::new()); + let plugin = registries.auth.get(gts::APIKEY_AUTH_PLUGIN_ID).unwrap(); + let security = SecurityContext::anonymous(); + let empty = ConfigMap::new(); + let mut headers = HeaderMap::new(); + let mut query = Vec::new(); + let mut ctx = AuthContext { + scope: PluginScope { + security_context: &security, + alias: "api.example.com", + upstream_id: Uuid::nil(), + route_id: None, + }, + config: &empty, + headers: &mut headers, + query: &mut query, + }; + let err = plugin.authenticate(&mut ctx).await.unwrap_err(); + assert!(err.to_string().contains("secret_ref"), "{err}"); +} + +#[tokio::test] +async fn the_noop_plugin_injects_nothing() { + let registries = registries(Vec::new()); + let plugin = registries.auth.get(gts::NOOP_AUTH_PLUGIN_ID).unwrap(); + let security = SecurityContext::anonymous(); + let empty = ConfigMap::new(); + let mut headers = HeaderMap::new(); + let mut query = Vec::new(); + let mut ctx = AuthContext { + scope: PluginScope { + security_context: &security, + alias: "api.example.com", + upstream_id: Uuid::nil(), + route_id: None, + }, + config: &empty, + headers: &mut headers, + query: &mut query, + }; + plugin.authenticate(&mut ctx).await.unwrap(); + assert!(headers.is_empty()); + assert!(query.is_empty()); +} + +#[test] +fn a_gear_supplied_plugin_can_be_registered() { + let mut registry = GuardPluginRegistry::with_builtins(); + registry.register(Arc::new(super::super::required_headers_guard::RequiredHeadersGuardPlugin)); + assert!(registry.get(gts::REQUIRED_HEADERS_GUARD_PLUGIN_ID).is_some()); +} 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..e69afba --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/request_id_transform.rs @@ -0,0 +1,59 @@ +//! `cf.core.oagw.request_id.v1` — `X-Request-ID` propagation. + +use async_trait::async_trait; +use http::{HeaderName, HeaderValue}; +use uuid::Uuid; + +use crate::domain::gts; +use crate::domain::plugin::{ + PluginError, RequestContext, ResponseContext, TransformPlugin, config_nonblank, +}; + +/// Header carrying the correlation id. +pub const REQUEST_ID_HEADER: HeaderName = HeaderName::from_static("x-request-id"); + +/// Propagates an inbound `X-Request-ID` to the upstream, minting one when the +/// caller did not supply it, and echoes it back on the response. +#[derive(Debug, Default, Clone, Copy)] +pub struct RequestIdTransformPlugin; + +#[async_trait] +impl TransformPlugin for RequestIdTransformPlugin { + fn id(&self) -> &str { + "request_id" + } + + fn plugin_type(&self) -> &str { + gts::REQUEST_ID_TRANSFORM_PLUGIN_ID + } + + async fn transform_request(&self, ctx: &mut RequestContext<'_>) -> Result<(), PluginError> { + let header = header_name(ctx.config)?; + if ctx.request.headers.contains_key(&header) { + return Ok(()); + } + let value = HeaderValue::from_str(&Uuid::new_v4().to_string()) + .map_err(|err| PluginError::Internal(err.to_string()))?; + ctx.request.headers.insert(header, value); + Ok(()) + } + + async fn transform_response(&self, ctx: &mut ResponseContext<'_>) -> Result<(), PluginError> { + let header = header_name(ctx.config)?; + if ctx.response.headers.contains_key(&header) { + return Ok(()); + } + // Nothing to echo when the upstream dropped it and no id was minted; + // the Data Plane records the correlation id on the access log either + // way, so this is best-effort. + Ok(()) + } +} + +fn header_name(config: &crate::domain::model::ConfigMap) -> Result { + match config_nonblank(config, "header_name").or_else(|| config_nonblank(config, "name")) { + Some(raw) => HeaderName::try_from(raw.to_ascii_lowercase()) + .map_err(|_| PluginError::InvalidConfig(format!("invalid header name '{raw}'"))), + None => Ok(REQUEST_ID_HEADER), + } +} 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..5a70079 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/required_headers_guard.rs @@ -0,0 +1,86 @@ +//! `cf.core.oagw.required_headers.v1` — presence enforcement for named +//! headers on the request and/or the upstream response (ADR 0009). + +use async_trait::async_trait; +use http::{HeaderMap, StatusCode}; + +use crate::domain::gts; +use crate::domain::plugin::{ + GuardDecision, GuardPlugin, PluginError, RequestContext, ResponseContext, config_str, +}; + +/// Error code reported on either phase. +pub const REQUIRED_HEADER_MISSING: &str = "REQUIRED_HEADER_MISSING"; + +/// Stateless presence check. Fail-open when unconfigured: adding the plugin to +/// the registry changes nothing for upstreams that do not opt in. +#[derive(Debug, Default, Clone, Copy)] +pub struct RequiredHeadersGuardPlugin; + +/// Split a comma-separated config value into lowercase header names, dropping +/// blanks. An all-blank list yields nothing, which is a no-op phase. +fn parse_names(raw: &str) -> Vec { + raw.split(',') + .map(|name| name.trim().to_ascii_lowercase()) + .filter(|name| !name.is_empty()) + .collect() +} + +/// First configured name absent from `headers`, if any. +fn first_missing(headers: &HeaderMap, names: &[String]) -> Option { + names + .iter() + .find(|name| !headers.keys().any(|key| key.as_str() == name.as_str())) + .cloned() +} + +#[async_trait] +impl GuardPlugin for RequiredHeadersGuardPlugin { + fn id(&self) -> &str { + "required_headers" + } + + fn plugin_type(&self) -> &str { + gts::REQUIRED_HEADERS_GUARD_PLUGIN_ID + } + + async fn guard_request( + &self, + ctx: &RequestContext<'_>, + ) -> Result { + let Some(raw) = config_str(ctx.config, "required_request_headers") else { + return Ok(GuardDecision::Allow); + }; + let names = parse_names(&raw); + match first_missing(&ctx.request.headers, &names) { + Some(missing) => Ok(GuardDecision::reject( + StatusCode::BAD_REQUEST, + REQUIRED_HEADER_MISSING, + format!("required request header '{missing}' is missing"), + )), + None => Ok(GuardDecision::Allow), + } + } + + async fn guard_response( + &self, + ctx: &ResponseContext<'_>, + ) -> Result { + let Some(raw) = config_str(ctx.config, "required_response_headers") else { + return Ok(GuardDecision::Allow); + }; + let names = parse_names(&raw); + match first_missing(&ctx.response.headers, &names) { + Some(missing) => Ok(GuardDecision::reject( + StatusCode::BAD_GATEWAY, + REQUIRED_HEADER_MISSING, + format!("required response header '{missing}' is missing"), + )), + None => Ok(GuardDecision::Allow), + } + } +} + +#[cfg(test)] +#[path = "required_headers_guard_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/plugin/required_headers_guard_tests.rs b/gears/system/oagw/oagw/src/infra/plugin/required_headers_guard_tests.rs new file mode 100644 index 0000000..59bcffc --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/required_headers_guard_tests.rs @@ -0,0 +1,145 @@ +//! Presence enforcement, including its fail-open posture (ADR 0009). + +use super::*; +use crate::domain::model::ConfigMap; +use crate::domain::plugin::{PluginScope, ProxyRequest, ProxyResponseHead}; +use bytes::Bytes; +use http::{HeaderValue, Method}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +fn config(pairs: &[(&str, &str)]) -> ConfigMap { + pairs + .iter() + .map(|(k, v)| ((*k).to_owned(), serde_json::Value::from(*v))) + .collect() +} + +fn headers(pairs: &[(&str, &str)]) -> HeaderMap { + let mut map = HeaderMap::new(); + for (name, value) in pairs { + map.insert( + http::HeaderName::try_from(*name).unwrap(), + HeaderValue::from_str(value).unwrap(), + ); + } + map +} + +fn scope(security: &SecurityContext) -> PluginScope<'_> { + PluginScope { + security_context: security, + alias: "api.example.com", + upstream_id: Uuid::nil(), + route_id: None, + } +} + +async fn guard_request(config: &ConfigMap, headers: HeaderMap) -> GuardDecision { + let security = SecurityContext::anonymous(); + let mut request = ProxyRequest { + method: Method::GET, + path: "/".to_owned(), + query: Vec::new(), + headers, + body: Bytes::new(), + }; + let ctx = RequestContext { + scope: scope(&security), + config, + request: &mut request, + }; + RequiredHeadersGuardPlugin + .guard_request(&ctx) + .await + .unwrap() +} + +async fn guard_response(config: &ConfigMap, headers: HeaderMap) -> GuardDecision { + let security = SecurityContext::anonymous(); + let mut response = ProxyResponseHead { + status: StatusCode::OK, + headers, + }; + let ctx = ResponseContext { + scope: scope(&security), + config, + response: &mut response, + }; + RequiredHeadersGuardPlugin + .guard_response(&ctx) + .await + .unwrap() +} + +#[tokio::test] +async fn an_unconfigured_plugin_is_a_no_op_on_both_phases() { + let empty = ConfigMap::new(); + assert_eq!(guard_request(&empty, HeaderMap::new()).await, GuardDecision::Allow); + assert_eq!(guard_response(&empty, HeaderMap::new()).await, GuardDecision::Allow); +} + +#[tokio::test] +async fn a_blank_list_is_also_a_no_op() { + let config = config(&[("required_request_headers", ", , ,")]); + assert_eq!(guard_request(&config, HeaderMap::new()).await, GuardDecision::Allow); +} + +#[tokio::test] +async fn the_request_phase_rejects_with_400_on_the_first_missing_header() { + let config = config(&[("required_request_headers", "x-correlation-id, accept")]); + let decision = guard_request(&config, headers(&[("accept", "application/json")])).await; + match decision { + GuardDecision::Reject { + status, + error_code, + message, + } => { + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(error_code, REQUIRED_HEADER_MISSING); + assert!(message.contains("x-correlation-id"), "{message}"); + assert!(!message.contains("accept"), "only the first is reported: {message}"); + } + GuardDecision::Allow => panic!("expected a rejection"), + } +} + +#[tokio::test] +async fn header_names_are_matched_case_insensitively() { + let config = config(&[("required_request_headers", "X-Correlation-ID")]); + let decision = guard_request(&config, headers(&[("x-correlation-id", "abc")])).await; + assert_eq!(decision, GuardDecision::Allow); +} + +#[tokio::test] +async fn only_presence_is_checked_not_the_value() { + let config = config(&[("required_request_headers", "x-correlation-id")]); + let decision = guard_request(&config, headers(&[("x-correlation-id", "")])).await; + assert_eq!(decision, GuardDecision::Allow); +} + +#[tokio::test] +async fn the_response_phase_rejects_with_502() { + let config = config(&[("required_response_headers", "content-type")]); + let decision = guard_response(&config, HeaderMap::new()).await; + match decision { + GuardDecision::Reject { status, .. } => assert_eq!(status, StatusCode::BAD_GATEWAY), + GuardDecision::Allow => panic!("expected a rejection"), + } +} + +#[tokio::test] +async fn the_two_phases_are_configured_independently() { + // A request-phase config must not silently enforce anything on responses. + let config = config(&[("required_request_headers", "x-correlation-id")]); + assert_eq!(guard_response(&config, HeaderMap::new()).await, GuardDecision::Allow); +} + +#[test] +fn the_plugin_reports_its_catalogued_identity() { + assert_eq!(RequiredHeadersGuardPlugin.id(), "required_headers"); + assert_eq!( + RequiredHeadersGuardPlugin.plugin_type(), + gts::REQUIRED_HEADERS_GUARD_PLUGIN_ID + ); +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/connector.rs b/gears/system/oagw/oagw/src/infra/proxy/connector.rs new file mode 100644 index 0000000..0e419bc --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/connector.rs @@ -0,0 +1,400 @@ +//! Outbound connection management. +//! +//! Wraps Pingora's connectors so the Data Plane sees one API for the three +//! things it needs: resolve an endpoint to a peer, exchange an HTTP message +//! with a *streaming* response body, and open a raw byte stream for a protocol +//! upgrade. + +use std::net::{IpAddr, SocketAddr}; +use std::sync::Arc; +use std::time::Duration; + +use bytes::Bytes; +use futures_util::Stream; +use http::StatusCode; +use pingora_core::connectors::{ConnectorOptions, TransportConnector, http::Connector}; +use pingora_core::listeners::ALPN; +use pingora_core::protocols::Stream as TransportStream; +use pingora_core::protocols::http::client::HttpSession; +use pingora_core::upstreams::peer::HttpPeer; +use pingora_http::RequestHeader; + +use crate::config::{OagwConfig, SsrfPolicy}; +use crate::domain::error::{ErrorKind, OagwError, OagwResult}; +use crate::domain::model::{Endpoint, Scheme}; +use crate::domain::plugin::{ProxyRequest, ProxyResponseHead}; + +/// Response head plus a lazily-consumed body stream. +pub struct UpstreamResponse { + pub head: ProxyResponseHead, + pub body: BodyStream, +} + +/// Boxed chunk stream over the upstream body. +pub type BodyStream = + std::pin::Pin> + Send + 'static>>; + +/// Dials upstream endpoints under the gear's transport policy. +pub struct UpstreamConnector { + http: Connector, + transport: TransportConnector, + connect_timeout: Duration, + allow_http_upstream: bool, + ssrf: SsrfPolicy, +} + +impl std::fmt::Debug for UpstreamConnector { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("UpstreamConnector") + .field("connect_timeout", &self.connect_timeout) + .field("allow_http_upstream", &self.allow_http_upstream) + .finish_non_exhaustive() + } +} + +impl UpstreamConnector { + #[must_use] + pub fn new(config: &OagwConfig) -> Self { + Self { + http: Connector::new(Some(ConnectorOptions::new(128))), + transport: TransportConnector::new(Some(ConnectorOptions::new(128))), + connect_timeout: config.connect_timeout(), + allow_http_upstream: config.allow_http_upstream, + ssrf: config.ssrf_policy.clone(), + } + } + + #[must_use] + pub fn shared(config: &OagwConfig) -> Arc { + Arc::new(Self::new(config)) + } + + /// Resolve `endpoint` and build the peer to dial. + /// + /// # Errors + /// + /// * `400` — a plaintext scheme while `allow_http_upstream` is off, or an + /// address the SSRF policy blocks. + /// * `503` — the hostname does not resolve. + pub async fn peer_for(&self, endpoint: &Endpoint) -> OagwResult { + if !endpoint.scheme.is_tls() && !self.allow_http_upstream { + return Err(OagwError::validation(format!( + "plaintext upstream '{}' is refused: enable allow_http_upstream to permit \ + the '{}' scheme", + endpoint.host, + endpoint.scheme.as_str() + ))); + } + + let address = self.resolve(endpoint).await?; + self.check_ssrf(address, &endpoint.host)?; + + let mut peer = HttpPeer::new(address, endpoint.scheme.is_tls(), endpoint.host.clone()); + peer.options.connection_timeout = Some(self.connect_timeout); + peer.options.total_connection_timeout = Some(self.connect_timeout); + // HTTP/1.1 only: it is the version that carries protocol upgrades, and + // pinning it keeps request framing identical across schemes. + peer.options.alpn = ALPN::H1; + Ok(peer) + } + + async fn resolve(&self, endpoint: &Endpoint) -> OagwResult { + let host = endpoint + .host + .strip_prefix('[') + .and_then(|s| s.strip_suffix(']')) + .unwrap_or(&endpoint.host); + + if let Ok(ip) = host.parse::() { + return Ok(SocketAddr::new(ip, endpoint.port)); + } + + // `HttpPeer::new` panics on an unresolvable name, so resolution + // happens here where the failure is a normal 503. + let mut addresses = tokio::net::lookup_host((host, endpoint.port)) + .await + .map_err(|err| { + OagwError::new( + ErrorKind::LinkUnavailable, + format!("could not resolve upstream host '{host}': {err}"), + ) + .with("host", host.to_owned()) + })?; + + addresses.next().ok_or_else(|| { + OagwError::new( + ErrorKind::LinkUnavailable, + format!("upstream host '{host}' resolved to no addresses"), + ) + .with("host", host.to_owned()) + }) + } + + /// Reject destinations the SSRF policy does not permit. + fn check_ssrf(&self, address: SocketAddr, host: &str) -> OagwResult<()> { + if !self.ssrf.enabled { + return Ok(()); + } + if self.ssrf.blocked_ports.contains(&address.port()) { + return Err(OagwError::validation(format!( + "destination port {} is blocked by the SSRF policy", + address.port() + ))); + } + let refuse = |reason: &str| { + Err(OagwError::validation(format!( + "upstream '{host}' resolves to a {reason} address, which the SSRF policy blocks" + ))) + }; + match address.ip() { + IpAddr::V4(ip) => { + if ip.is_loopback() && !self.ssrf.allow_loopback { + return refuse("loopback"); + } + if ip.is_link_local() && !self.ssrf.allow_link_local { + return refuse("link-local"); + } + if ip.is_private() && !self.ssrf.allow_private { + return refuse("private"); + } + if ip.is_unspecified() || ip.is_broadcast() { + return refuse("reserved"); + } + } + IpAddr::V6(ip) => { + if ip.is_loopback() && !self.ssrf.allow_loopback { + return refuse("loopback"); + } + // `fe80::/10` — the v6 link-local block, which covers the + // metadata endpoints. + let is_link_local = (ip.segments()[0] & 0xffc0) == 0xfe80; + if is_link_local && !self.ssrf.allow_link_local { + return refuse("link-local"); + } + // `fc00::/7` — unique local addresses. + let is_unique_local = (ip.segments()[0] & 0xfe00) == 0xfc00; + if is_unique_local && !self.ssrf.allow_private { + return refuse("private"); + } + if ip.is_unspecified() { + return refuse("reserved"); + } + } + } + Ok(()) + } + + /// Send `request` to `endpoint` and return the response head together with + /// a streaming body. + /// + /// `head_timeout` bounds connect plus response-head read. The body stream + /// is deliberately unbounded so server-sent-event streams survive. + /// + /// # Errors + /// + /// `502` on a transport failure, `504` when the head does not arrive in + /// time. + pub async fn send( + &self, + endpoint: &Endpoint, + request: &ProxyRequest, + head_timeout: Duration, + ) -> OagwResult { + let peer = self.peer_for(endpoint).await?; + let exchange = self.exchange(&peer, endpoint, request); + + match tokio::time::timeout(head_timeout, exchange).await { + Ok(result) => result, + Err(_) => Err(OagwError::new( + ErrorKind::RequestTimeout, + format!( + "upstream '{}' did not send response headers within {}s", + endpoint.host, + head_timeout.as_secs() + ), + ) + .with("host", endpoint.host.clone()) + .with_retry_after(head_timeout.as_secs().max(1))), + } + } + + async fn exchange( + &self, + peer: &HttpPeer, + endpoint: &Endpoint, + request: &ProxyRequest, + ) -> OagwResult { + let (mut session, _reused) = self.http.get_http_session(peer).await.map_err(|err| { + OagwError::new( + ErrorKind::LinkUnavailable, + format!("could not connect to upstream '{}': {err}", endpoint.host), + ) + .with("host", endpoint.host.clone()) + })?; + + let header = build_request_header(endpoint, request)?; + session + .write_request_header(Box::new(header)) + .await + .map_err(|err| transport_error(endpoint, "write request header", &err))?; + + if !request.body.is_empty() { + session + .write_request_body(request.body.clone(), true) + .await + .map_err(|err| transport_error(endpoint, "write request body", &err))?; + } + session + .finish_request_body() + .await + .map_err(|err| transport_error(endpoint, "finish request body", &err))?; + + session + .read_response_header() + .await + .map_err(|err| transport_error(endpoint, "read response header", &err))?; + + let response = session + .response_header() + .ok_or_else(|| { + OagwError::new( + ErrorKind::ProtocolError, + format!("upstream '{}' returned no response header", endpoint.host), + ) + })?; + + let status = StatusCode::from_u16(response.status.as_u16()).map_err(|_| { + OagwError::new( + ErrorKind::ProtocolError, + format!("upstream '{}' returned an invalid status", endpoint.host), + ) + })?; + let headers = response.headers.clone(); + + Ok(UpstreamResponse { + head: ProxyResponseHead { status, headers }, + body: body_stream(session), + }) + } + + /// Open a raw byte stream to `endpoint`, for a protocol upgrade. + /// + /// # Errors + /// + /// `400`/`503` for the same reasons as [`Self::peer_for`], `502` when the + /// connection cannot be established. + pub async fn open_stream(&self, endpoint: &Endpoint) -> OagwResult { + let peer = self.peer_for(endpoint).await?; + let connect = self.transport.new_stream(&peer); + match tokio::time::timeout(self.connect_timeout, connect).await { + Ok(Ok(stream)) => Ok(stream), + Ok(Err(err)) => Err(OagwError::new( + ErrorKind::LinkUnavailable, + format!("could not connect to upstream '{}': {err}", endpoint.host), + ) + .with("host", endpoint.host.clone())), + Err(_) => Err(OagwError::new( + ErrorKind::ConnectionTimeout, + format!("connecting to upstream '{}' timed out", endpoint.host), + ) + .with("host", endpoint.host.clone()) + .with_retry_after(self.connect_timeout.as_secs().max(1))), + } + } +} + +/// Turn the pingora session into a chunk stream that ends on EOF or error. +fn body_stream(session: HttpSession) -> BodyStream { + Box::pin(futures_util::stream::unfold( + Some(session), + |state| async move { + let mut session = state?; + match session.read_response_body().await { + Ok(Some(chunk)) => Some((Ok(chunk), Some(session))), + Ok(None) => None, + // Yield the failure once, then end: re-polling a broken + // session would spin. + Err(err) => Some(( + Err(std::io::Error::other(format!("upstream body error: {err}"))), + None, + )), + } + }, + )) +} + +fn transport_error( + endpoint: &Endpoint, + phase: &str, + err: &pingora_core::Error, +) -> OagwError { + OagwError::new( + ErrorKind::DownstreamError, + format!( + "upstream '{}' failed during {phase}: {err}", + endpoint.host + ), + ) + .with("host", endpoint.host.clone()) +} + +/// Assemble the wire request header, forcing `Host` and the body framing. +fn build_request_header( + endpoint: &Endpoint, + request: &ProxyRequest, +) -> OagwResult { + let path_and_query = request.path_and_query(); + let mut header = RequestHeader::build( + request.method.clone(), + path_and_query.as_bytes(), + Some(request.headers.len() + 4), + ) + .map_err(|err| OagwError::validation(format!("could not build upstream request: {err}")))?; + + for (name, value) in &request.headers { + header + .append_header(name.clone(), value.clone()) + .map_err(|err| { + OagwError::validation(format!("invalid outbound header '{name}': {err}")) + })?; + } + + header + .insert_header(http::header::HOST, upstream_authority(endpoint)) + .map_err(|err| OagwError::validation(format!("invalid Host header: {err}")))?; + + // Explicit framing: pingora picks the request body writer from these + // headers, so an omitted Content-Length silently drops the body. + if request.body.is_empty() { + if matches!( + request.method, + http::Method::POST | http::Method::PUT | http::Method::PATCH + ) { + let _ = header.insert_header(http::header::CONTENT_LENGTH, "0"); + } + } else { + header + .insert_header(http::header::CONTENT_LENGTH, request.body.len().to_string()) + .map_err(|err| OagwError::validation(format!("invalid Content-Length: {err}")))?; + } + + Ok(header) +} + +/// `Host` value for the upstream: hostname, plus port when non-standard. +#[must_use] +pub fn upstream_authority(endpoint: &Endpoint) -> String { + let standard = match endpoint.scheme { + Scheme::Http | Scheme::Ws => 80, + Scheme::Https | Scheme::Wss | Scheme::Wt | Scheme::Grpc => 443, + }; + if endpoint.port == standard { + endpoint.host.clone() + } else { + format!("{}:{}", endpoint.host, endpoint.port) + } +} + +#[cfg(test)] +#[path = "connector_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/proxy/connector_tests.rs b/gears/system/oagw/oagw/src/infra/proxy/connector_tests.rs new file mode 100644 index 0000000..bdf81f1 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/connector_tests.rs @@ -0,0 +1,199 @@ +//! Transport policy: scheme gating, SSRF guardrails and outbound framing. + +use super::*; +use http::HeaderMap; +use crate::config::SsrfPolicy; +use crate::domain::model::Scheme; + +fn endpoint(scheme: Scheme, host: &str, port: u16) -> Endpoint { + Endpoint { + scheme, + host: host.to_owned(), + port, + } +} + +fn config(allow_http: bool, ssrf: SsrfPolicy) -> OagwConfig { + OagwConfig { + allow_http_upstream: allow_http, + ssrf_policy: ssrf, + ..OagwConfig::default() + } +} + +#[test] +fn the_authority_omits_the_scheme_default_port() { + assert_eq!( + upstream_authority(&endpoint(Scheme::Https, "api.example.com", 443)), + "api.example.com" + ); + assert_eq!( + upstream_authority(&endpoint(Scheme::Http, "api.example.com", 80)), + "api.example.com" + ); + assert_eq!( + upstream_authority(&endpoint(Scheme::Http, "api.example.com", 8080)), + "api.example.com:8080" + ); + assert_eq!( + upstream_authority(&endpoint(Scheme::Ws, "chat.example.com", 80)), + "chat.example.com" + ); +} + +#[tokio::test] +async fn a_plaintext_upstream_is_refused_unless_the_flag_lifts_the_default() { + let connector = UpstreamConnector::new(&config(false, SsrfPolicy { enabled: false, ..SsrfPolicy::default() })); + let err = connector + .peer_for(&endpoint(Scheme::Http, "127.0.0.1", 9099)) + .await + .unwrap_err(); + assert_eq!(err.status(), 400); + assert!(err.detail.contains("allow_http_upstream"), "{}", err.detail); +} + +#[tokio::test] +async fn a_plaintext_upstream_is_dialled_once_the_flag_is_set() { + let connector = UpstreamConnector::new(&config(true, SsrfPolicy { enabled: false, ..SsrfPolicy::default() })); + let peer = connector + .peer_for(&endpoint(Scheme::Http, "127.0.0.1", 9099)) + .await + .unwrap(); + assert!(!peer.is_tls()); + assert_eq!(peer.sni, "127.0.0.1"); +} + +#[tokio::test] +async fn a_tls_upstream_needs_no_flag() { + let connector = UpstreamConnector::new(&config(false, SsrfPolicy { enabled: false, ..SsrfPolicy::default() })); + let peer = connector + .peer_for(&endpoint(Scheme::Https, "127.0.0.1", 8443)) + .await + .unwrap(); + assert!(peer.is_tls()); +} + +#[tokio::test] +async fn the_ssrf_policy_blocks_loopback_by_default() { + let connector = UpstreamConnector::new(&config(true, SsrfPolicy::default())); + let err = connector + .peer_for(&endpoint(Scheme::Http, "127.0.0.1", 9099)) + .await + .unwrap_err(); + assert_eq!(err.status(), 400); + assert!(err.detail.contains("loopback"), "{}", err.detail); +} + +#[tokio::test] +async fn the_ssrf_policy_blocks_private_and_link_local_ranges() { + let connector = UpstreamConnector::new(&config(true, SsrfPolicy::default())); + for (host, reason) in [("10.0.1.1", "private"), ("169.254.169.254", "link-local")] { + let err = connector + .peer_for(&endpoint(Scheme::Http, host, 80)) + .await + .unwrap_err(); + assert!(err.detail.contains(reason), "{host}: {}", err.detail); + } +} + +#[tokio::test] +async fn loopback_can_be_allowed_explicitly() { + let policy = SsrfPolicy { + enabled: true, + allow_loopback: true, + ..SsrfPolicy::default() + }; + let connector = UpstreamConnector::new(&config(true, policy)); + assert!( + connector + .peer_for(&endpoint(Scheme::Http, "127.0.0.1", 9099)) + .await + .is_ok() + ); +} + +#[tokio::test] +async fn blocked_ports_are_refused() { + let policy = SsrfPolicy { + enabled: true, + allow_loopback: true, + blocked_ports: vec![25], + ..SsrfPolicy::default() + }; + let connector = UpstreamConnector::new(&config(true, policy)); + let err = connector + .peer_for(&endpoint(Scheme::Http, "127.0.0.1", 25)) + .await + .unwrap_err(); + assert!(err.detail.contains("port 25"), "{}", err.detail); +} + +#[tokio::test] +async fn an_unresolvable_host_is_a_link_failure_not_a_panic() { + let connector = UpstreamConnector::new(&config(true, SsrfPolicy { enabled: false, ..SsrfPolicy::default() })); + let err = connector + .peer_for(&endpoint(Scheme::Http, "no-such-host.invalid", 80)) + .await + .unwrap_err(); + assert_eq!(err.status(), 503); +} + +#[tokio::test] +async fn ipv6_literals_are_accepted_in_bracketed_form() { + let policy = SsrfPolicy { + enabled: true, + allow_loopback: true, + ..SsrfPolicy::default() + }; + let connector = UpstreamConnector::new(&config(true, policy)); + assert!( + connector + .peer_for(&endpoint(Scheme::Http, "[::1]", 9099)) + .await + .is_ok() + ); +} + +#[test] +fn the_outbound_head_replaces_host_and_frames_the_body() { + let request = ProxyRequest { + method: http::Method::POST, + path: "/v1/chat".to_owned(), + query: vec![("model".to_owned(), "gpt-4".to_owned())], + headers: HeaderMap::new(), + body: Bytes::from_static(b"{\"a\":1}"), + }; + let header = build_request_header(&endpoint(Scheme::Https, "api.example.com", 443), &request) + .unwrap(); + assert_eq!(header.uri.path_and_query().unwrap(), "/v1/chat?model=gpt-4"); + assert_eq!(header.headers.get(http::header::HOST).unwrap(), "api.example.com"); + assert_eq!(header.headers.get(http::header::CONTENT_LENGTH).unwrap(), "7"); +} + +#[test] +fn a_bodyless_write_method_still_declares_a_zero_length() { + let request = ProxyRequest { + method: http::Method::POST, + path: "/v1/ping".to_owned(), + query: Vec::new(), + headers: HeaderMap::new(), + body: Bytes::new(), + }; + let header = build_request_header(&endpoint(Scheme::Https, "api.example.com", 443), &request) + .unwrap(); + assert_eq!(header.headers.get(http::header::CONTENT_LENGTH).unwrap(), "0"); +} + +#[test] +fn a_bodyless_get_declares_no_length_at_all() { + let request = ProxyRequest { + method: http::Method::GET, + path: "/v1/models".to_owned(), + query: Vec::new(), + headers: HeaderMap::new(), + body: Bytes::new(), + }; + let header = build_request_header(&endpoint(Scheme::Https, "api.example.com", 443), &request) + .unwrap(); + assert!(header.headers.get(http::header::CONTENT_LENGTH).is_none()); +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/mod.rs b/gears/system/oagw/oagw/src/infra/proxy/mod.rs new file mode 100644 index 0000000..3cb493c --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/mod.rs @@ -0,0 +1,7 @@ +//! Data Plane — proxy orchestration. + +pub mod connector; +pub mod service; +pub mod websocket; + +pub use service::{DataPlaneService, IncomingRequest, ProxyOutcome}; 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..dc136a6 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/service.rs @@ -0,0 +1,1001 @@ +//! Data Plane — proxy request orchestration. +//! +//! One pass per request: resolve configuration through the Control Plane, +//! validate the inbound shape, apply the rate limit, run the plugin chain +//! (auth → guards → transforms), pick an endpoint, and forward. The response +//! body is never buffered — plain HTTP, server-sent events and protocol +//! upgrades all leave here as streams. + +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::time::Instant; + +use bytes::Bytes; +use dashmap::DashMap; +use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header}; +use pingora_core::protocols::Stream as TransportStream; +use toolkit_security::SecurityContext; +use tracing::{debug, info, warn}; +use uuid::Uuid; + +use crate::config::OagwConfig; +use crate::domain::alias; +use crate::domain::error::{ErrorKind, OagwError, OagwResult}; +use crate::domain::gts; +use crate::domain::merge::EffectiveConfig; +use crate::domain::model::{ + CorsConfig, Endpoint, PassthroughMode, PathSuffixMode, PluginBinding, RateLimitStrategy, + Upstream, +}; +use crate::domain::plugin::{ + AuthContext, ErrorContext, GuardDecision, PluginScope, ProxyRequest, ProxyResponseHead, + RequestContext, ResponseContext, +}; +use crate::domain::repo::PluginRepository; +use crate::domain::services::{ControlPlaneService, ProxyTarget, normalize_path}; +use crate::infra::metrics::{OagwMetrics, SelectionMethod}; +use crate::infra::plugin::PluginRegistries; +use crate::infra::proxy::connector::{BodyStream, UpstreamConnector}; +use crate::infra::proxy::websocket::{self, UpgradeOutcome}; +use crate::infra::rate_limit::{RateLimitSubject, RateLimiterRegistry}; + +/// Header that pins a request to one endpoint of a multi-endpoint pool. +pub const TARGET_HOST_HEADER: &str = "x-oagw-target-host"; + +/// Headers removed on both directions per RFC 9110 §7.6.1. +const HOP_BY_HOP: &[&str] = &[ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", +]; + +/// Headers OAGW consumes and never forwards, even under `passthrough: all`. +/// +/// `authorization` and `cookie` carry the *caller's* platform credentials; +/// forwarding them to a third party would defeat credential isolation. An +/// operator who genuinely wants one forwarded names it in +/// `passthrough_allowlist`. +const NEVER_PASSTHROUGH: &[&str] = &[ + "host", + "authorization", + "cookie", + TARGET_HOST_HEADER, + "x-oagw-error-source", + "content-length", +]; + +/// Entity headers that describe the body and therefore travel with it +/// regardless of the passthrough mode. +const ENTITY_HEADERS: &[&str] = &["content-type"]; + +/// A proxy request as it arrives from the transport layer. +pub struct IncomingRequest { + pub method: Method, + pub alias: String, + /// Path beyond the alias, e.g. `/v1/chat/completions`. May be empty. + pub path_suffix: String, + pub query: Vec<(String, String)>, + pub headers: HeaderMap, + pub body: Bytes, + pub client_ip: Option, + /// Request URI, used as the problem-details `instance`. + pub instance: String, + /// True when the caller offered an HTTP/1.1 upgrade. + pub wants_upgrade: bool, +} + +/// What the Data Plane produced. +pub enum ProxyOutcome { + /// Ordinary (possibly streaming) response. + Streamed { + head: ProxyResponseHead, + body: BodyStream, + }, + /// Fully-read response — used when the body was consumed to make a + /// decision, e.g. a refused upgrade. + Buffered { + head: ProxyResponseHead, + body: Bytes, + }, + /// The upstream switched protocols; the transport layer must now splice. + Upgraded { + headers: HeaderMap, + stream: TransportStream, + leftover: Bytes, + }, +} + +/// Data Plane service. +pub struct DataPlaneService { + control: Arc, + connector: Arc, + registries: Arc, + plugin_repo: Arc, + limiter: Arc, + metrics: Arc, + config: OagwConfig, + /// Round-robin cursor per upstream. + cursors: DashMap>, +} + +impl std::fmt::Debug for DataPlaneService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DataPlaneService").finish_non_exhaustive() + } +} + +impl DataPlaneService { + #[must_use] + pub fn new( + control: Arc, + connector: Arc, + registries: Arc, + plugin_repo: Arc, + limiter: Arc, + metrics: Arc, + config: OagwConfig, + ) -> Self { + Self { + control, + connector, + registries, + plugin_repo, + limiter, + metrics, + config, + cursors: DashMap::new(), + } + } + + /// Execute one proxy request end to end. + /// + /// # Errors + /// + /// Any catalogued gateway error; the transport layer renders it as + /// problem details with `X-OAGW-Error-Source: gateway`. + pub async fn execute( + &self, + ctx: &SecurityContext, + incoming: IncomingRequest, + ) -> OagwResult { + let started = Instant::now(); + let alias = alias::normalize(&incoming.alias); + let result = self.execute_inner(ctx, &alias, incoming).await; + let elapsed = started.elapsed().as_secs_f64(); + + match &result { + Ok(outcome) => { + let status = match outcome { + ProxyOutcome::Streamed { head, .. } | ProxyOutcome::Buffered { head, .. } => { + head.status.as_u16() + } + ProxyOutcome::Upgraded { .. } => StatusCode::SWITCHING_PROTOCOLS.as_u16(), + }; + self.metrics + .record_request(&alias, "", "", status, elapsed); + } + Err(err) => { + self.metrics + .record_error(&alias, "", err.kind.gts_type()); + } + } + result + } + + async fn execute_inner( + &self, + ctx: &SecurityContext, + alias: &str, + incoming: IncomingRequest, + ) -> OagwResult { + if incoming.body.len() > self.config.max_request_body_bytes { + return Err(OagwError::new( + ErrorKind::PayloadTooLarge, + format!( + "request body of {} bytes exceeds the {} byte limit", + incoming.body.len(), + self.config.max_request_body_bytes + ), + )); + } + + let target = self + .control + .resolve_proxy_target(ctx, alias, incoming.method.as_str(), &incoming.path_suffix) + .await?; + + let http_match = target.route.http().ok_or_else(|| { + OagwError::new( + ErrorKind::ProtocolError, + "gRPC proxying is not implemented in this build", + ) + })?; + + // Path suffix handling. Longest-prefix matching already guarantees the + // route path is a prefix, so the effective outbound path is the + // inbound suffix; `disabled` simply refuses anything beyond the route. + let route_path = normalize_path(&http_match.path); + let inbound_path = normalize_path(&incoming.path_suffix); + if http_match.path_suffix_mode == PathSuffixMode::Disabled && inbound_path != route_path { + return Err(OagwError::validation(format!( + "route '{route_path}' does not accept a path suffix" + )) + .with("path", inbound_path)); + } + + // Query allowlist: an unlisted parameter is a rejection, not a silent + // drop, so a caller learns their request was not honoured verbatim. + for (name, _) in &incoming.query { + if !http_match.allows_query_param(name) { + return Err(OagwError::validation(format!( + "query parameter '{name}' is not permitted on this route" + )) + .with("path", inbound_path.clone())); + } + } + + let effective = target.effective.clone(); + let origin = incoming + .headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + .map(ToOwned::to_owned); + if let Some(cors) = effective.cors.as_ref().filter(|cors| cors.enabled) + && let Some(origin) = origin.as_deref() + { + enforce_cors(cors, origin, incoming.method.as_str())?; + } + + let budget_headers = self + .enforce_rate_limit(ctx, &target, &incoming, &inbound_path) + .await?; + + let mut request = ProxyRequest { + method: incoming.method.clone(), + path: inbound_path.clone(), + query: incoming.query.clone(), + headers: self.build_outbound_headers(&incoming, &effective), + body: incoming.body.clone(), + }; + + let scope = PluginScope { + security_context: ctx, + alias, + upstream_id: target.upstream.id, + route_id: Some(target.route.id), + }; + + self.run_auth(&effective, scope, &mut request).await?; + let chain = self.resolve_chain(&effective.plugins); + self.run_guards_request(&chain, scope, &mut request).await?; + self.run_transform_request(&chain, scope, &mut request) + .await?; + + let endpoint = self.select_endpoint(&target.upstream, &incoming)?; + + let outcome = if incoming.wants_upgrade { + self.forward_upgrade(&endpoint, &request).await? + } else { + self.forward_http(&endpoint, &request).await? + }; + + let mut outcome = self + .finish(outcome, &chain, scope, &effective, origin.as_deref()) + .await?; + apply_rate_limit_headers(&mut outcome, &budget_headers); + Ok(outcome) + } + + // -- Response assembly ------------------------------------------------- + + async fn finish( + &self, + outcome: ProxyOutcome, + chain: &ResolvedChain, + scope: PluginScope<'_>, + effective: &EffectiveConfig, + origin: Option<&str>, + ) -> OagwResult { + match outcome { + ProxyOutcome::Upgraded { .. } => Ok(outcome), + ProxyOutcome::Streamed { mut head, body } => { + self.finish_head(&mut head, chain, scope, effective, origin) + .await?; + Ok(ProxyOutcome::Streamed { head, body }) + } + ProxyOutcome::Buffered { mut head, body } => { + self.finish_head(&mut head, chain, scope, effective, origin) + .await?; + Ok(ProxyOutcome::Buffered { head, body }) + } + } + } + + async fn finish_head( + &self, + head: &mut ProxyResponseHead, + chain: &ResolvedChain, + scope: PluginScope<'_>, + effective: &EffectiveConfig, + origin: Option<&str>, + ) -> OagwResult<()> { + strip_hop_by_hop(&mut head.headers); + + for (binding, plugin) in &chain.guards { + let ctx = ResponseContext { + scope, + config: &binding.config, + response: head, + }; + match plugin.guard_response(&ctx).await? { + GuardDecision::Allow => {} + GuardDecision::Reject { + status, + error_code, + message, + } => { + return Err(guard_rejection(status, &error_code, &message)); + } + } + } + + for (binding, plugin) in &chain.transforms { + let mut ctx = ResponseContext { + scope, + config: &binding.config, + response: head, + }; + plugin.transform_response(&mut ctx).await?; + } + + apply_response_header_rules(&mut head.headers, effective); + if let Some(cors) = effective.cors.as_ref().filter(|cors| cors.enabled) + && let Some(origin) = origin + { + apply_cors_response_headers(&mut head.headers, cors, origin); + } + Ok(()) + } + + // -- Forwarding -------------------------------------------------------- + + async fn forward_http( + &self, + endpoint: &Endpoint, + request: &ProxyRequest, + ) -> OagwResult { + let response = self + .connector + .send(endpoint, request, self.config.proxy_timeout()) + .await?; + Ok(ProxyOutcome::Streamed { + head: response.head, + body: response.body, + }) + } + + async fn forward_upgrade( + &self, + endpoint: &Endpoint, + request: &ProxyRequest, + ) -> OagwResult { + let outcome = websocket::perform_upgrade( + &self.connector, + endpoint, + request, + self.config.proxy_timeout(), + ) + .await?; + Ok(match outcome { + UpgradeOutcome::Switching { + headers, + stream, + leftover, + } => ProxyOutcome::Upgraded { + headers, + stream, + leftover, + }, + UpgradeOutcome::Rejected { head, body } => ProxyOutcome::Buffered { head, body }, + }) + } + + // -- Endpoint selection ------------------------------------------------ + + /// Apply the `X-OAGW-Target-Host` behaviour matrix (ADR 0001, Appendix A). + fn select_endpoint( + &self, + upstream: &Upstream, + incoming: &IncomingRequest, + ) -> OagwResult { + let endpoints = &upstream.server.endpoints; + let requested = incoming + .headers + .get(TARGET_HOST_HEADER) + .and_then(|value| value.to_str().ok()) + .map(str::trim) + .filter(|value| !value.is_empty()); + + if let Some(requested) = requested { + if !is_plain_host(requested) { + return Err(OagwError::new( + ErrorKind::InvalidTargetHost, + "X-OAGW-Target-Host must be a valid hostname or IP address (no port, path, \ + or special characters)", + ) + .with("upstream_id", upstream.gts_id()) + .with("invalid_value", requested.to_owned())); + } + let matched = upstream.endpoint_for_host(requested).ok_or_else(|| { + OagwError::new( + ErrorKind::UnknownTargetHost, + format!( + "X-OAGW-Target-Host '{requested}' does not match any configured endpoint" + ), + ) + .with("upstream_id", upstream.gts_id()) + .with("invalid_value", requested.to_owned()) + .with("valid_hosts", upstream.endpoint_hosts()) + })?; + self.metrics.record_endpoint_selection( + &upstream.gts_id(), + &matched.host, + SelectionMethod::ExplicitHeader, + ); + return Ok(matched.clone()); + } + + let Some(first) = endpoints.first() else { + return Err(OagwError::new( + ErrorKind::LinkUnavailable, + "upstream has no endpoints", + )); + }; + + if endpoints.len() == 1 { + self.metrics.record_endpoint_selection( + &upstream.gts_id(), + &first.host, + SelectionMethod::Default, + ); + return Ok(first.clone()); + } + + // A multi-endpoint pool whose alias was derived from a common suffix + // has no default member: the caller must say which host it means. + if alias::compute_derived_alias(endpoints).is_some() { + return Err(OagwError::new( + ErrorKind::MissingTargetHost, + format!( + "X-OAGW-Target-Host header required for multi-endpoint upstream with common \ + suffix alias. Valid hosts: [{}]", + upstream.endpoint_hosts().join(", ") + ), + ) + .with("upstream_id", upstream.gts_id()) + .with("alias", upstream.alias.clone()) + .with("valid_hosts", upstream.endpoint_hosts())); + } + + let cursor = self + .cursors + .entry(upstream.id) + .or_insert_with(|| Arc::new(AtomicUsize::new(0))) + .clone(); + let index = cursor.fetch_add(1, Ordering::Relaxed) % endpoints.len(); + let chosen = &endpoints[index]; + self.metrics.record_endpoint_selection( + &upstream.gts_id(), + &chosen.host, + SelectionMethod::RoundRobin, + ); + Ok(chosen.clone()) + } + + // -- Rate limiting ----------------------------------------------------- + + async fn enforce_rate_limit( + &self, + ctx: &SecurityContext, + target: &ProxyTarget, + incoming: &IncomingRequest, + path: &str, + ) -> OagwResult> { + let Some(config) = target.effective.rate_limit.as_ref() else { + return Ok(Vec::new()); + }; + let subject = RateLimitSubject { + tenant_id: ctx.subject_tenant_id(), + subject_id: ctx.subject_id(), + upstream_id: target.upstream.id, + route_id: target.route.id, + }; + let client_ip = incoming.client_ip.as_deref(); + + let verdict = match config.strategy { + RateLimitStrategy::Reject => self.limiter.check(config, &subject, client_ip), + RateLimitStrategy::Queue => { + self.limiter + .acquire_queued(config, &subject, client_ip, self.config.proxy_timeout()) + .await + } + // Degrade: record the overage and let the request through with the + // usage headers attached, rather than failing the caller. + RateLimitStrategy::Degrade => { + let verdict = self.limiter.check(config, &subject, client_ip); + self.metrics.record_rate_limit( + &target.upstream.alias, + path, + false, + verdict.usage_ratio, + ); + return Ok(rate_limit_headers(config, &verdict)); + } + }; + + self.metrics.record_rate_limit( + &target.upstream.alias, + path, + !verdict.allowed, + verdict.usage_ratio, + ); + + if verdict.allowed { + return Ok(rate_limit_headers(config, &verdict)); + } + + warn!( + target: "oagw.rate_limit", + alias = %target.upstream.alias, + path, + limit = verdict.limit, + "rate limit exceeded" + ); + let mut error = OagwError::new( + ErrorKind::RateLimitExceeded, + format!("Rate limit exceeded for upstream {}", target.upstream.alias), + ) + .with("host", target.upstream.alias.clone()) + .with("upstream_id", target.upstream.gts_id()) + .with("path", path.to_owned()) + .with("limit", verdict.limit) + .with("remaining", verdict.remaining) + .with_retry_after(verdict.retry_after_secs.max(1)); + // RFC 6585 / draft-ietf-httpapi-ratelimit-headers: a 429 always states + // the budget it enforced, whatever `response_headers` says. + for (name, value) in rate_limit_headers_forced(config, &verdict) { + error = error.with_header(&name, value); + } + Err(error) + } + + // -- Plugin chain ------------------------------------------------------ + + async fn run_auth( + &self, + effective: &EffectiveConfig, + scope: PluginScope<'_>, + request: &mut ProxyRequest, + ) -> OagwResult<()> { + let Some(auth) = effective.auth.as_ref() else { + return Ok(()); + }; + let Some(plugin_type) = auth.plugin_type.as_deref() else { + return Ok(()); + }; + let Some(plugin) = self.registries.auth.get(plugin_type) else { + return Err(OagwError::new( + ErrorKind::PluginNotFound, + format!("unknown auth plugin: {plugin_type}"), + ) + .with("plugin_id", plugin_type.to_owned())); + }; + + let mut ctx = AuthContext { + scope, + config: &auth.config, + headers: &mut request.headers, + query: &mut request.query, + }; + plugin.authenticate(&mut ctx).await?; + Ok(()) + } + + /// Resolve every binding to a concrete plugin, in chain order. + fn resolve_chain(&self, bindings: &[PluginBinding]) -> ResolvedChain { + let mut chain = ResolvedChain::default(); + for binding in bindings { + if let Some(plugin) = self.registries.guard.get(&binding.plugin_ref) { + chain.guards.push((binding.clone(), plugin)); + continue; + } + if let Some(plugin) = self.registries.transform.get(&binding.plugin_ref) { + chain.transforms.push((binding.clone(), plugin)); + continue; + } + if let Some(uuid) = binding.plugin_uuid { + // Custom (Starlark) plugin definitions are stored and served + // by the management API, but this build ships no interpreter, + // so the binding is recorded and skipped rather than failing + // every request through the upstream. + self.plugin_repo.touch(uuid, now_epoch_secs()); + debug!( + target: "oagw.plugin", + plugin_ref = %binding.plugin_ref, + "custom plugin binding skipped: no interpreter in this build" + ); + continue; + } + warn!( + target: "oagw.plugin", + plugin_ref = %binding.plugin_ref, + "plugin binding could not be resolved and was skipped" + ); + } + chain + } + + async fn run_guards_request( + &self, + chain: &ResolvedChain, + scope: PluginScope<'_>, + request: &mut ProxyRequest, + ) -> OagwResult<()> { + for (binding, plugin) in &chain.guards { + let ctx = RequestContext { + scope, + config: &binding.config, + request, + }; + match plugin.guard_request(&ctx).await? { + GuardDecision::Allow => {} + GuardDecision::Reject { + status, + error_code, + message, + } => return Err(guard_rejection(status, &error_code, &message)), + } + } + Ok(()) + } + + async fn run_transform_request( + &self, + chain: &ResolvedChain, + scope: PluginScope<'_>, + request: &mut ProxyRequest, + ) -> OagwResult<()> { + for (binding, plugin) in &chain.transforms { + let mut ctx = RequestContext { + scope, + config: &binding.config, + request, + }; + plugin.transform_request(&mut ctx).await?; + } + Ok(()) + } + + /// Give transform plugins a chance to shape a gateway error. + pub async fn transform_error( + &self, + bindings: &[PluginBinding], + ctx: &SecurityContext, + alias: &str, + upstream_id: Uuid, + error: &mut OagwError, + ) { + let chain = self.resolve_chain(bindings); + let scope = PluginScope { + security_context: ctx, + alias, + upstream_id, + route_id: None, + }; + for (binding, plugin) in &chain.transforms { + let mut error_ctx = ErrorContext { + scope, + config: &binding.config, + error, + }; + if let Err(err) = plugin.transform_error(&mut error_ctx).await { + warn!(target: "oagw.plugin", error = %err, "transform_error failed"); + } + } + } + + // -- Header assembly --------------------------------------------------- + + /// Build the outbound header set: passthrough policy first, then the + /// configured remove/set/add rules. + fn build_outbound_headers( + &self, + incoming: &IncomingRequest, + effective: &EffectiveConfig, + ) -> HeaderMap { + let rules = &effective.headers.request; + let mut out = HeaderMap::new(); + + for (name, value) in &incoming.headers { + let lower = name.as_str().to_ascii_lowercase(); + let allowlisted = rules + .passthrough_allowlist + .iter() + .any(|allowed| allowed.eq_ignore_ascii_case(&lower)); + + // Upgrade requests keep their handshake headers: they are + // hop-by-hop by definition but are exactly what is being relayed. + let is_upgrade_header = incoming.wants_upgrade + && (lower == "connection" + || lower == "upgrade" + || lower.starts_with("sec-websocket-")); + + let keep = if is_upgrade_header { + true + } else if HOP_BY_HOP.contains(&lower.as_str()) { + false + } else if NEVER_PASSTHROUGH.contains(&lower.as_str()) { + allowlisted && rules.passthrough == PassthroughMode::Allowlist + } else if ENTITY_HEADERS.contains(&lower.as_str()) { + true + } else { + match rules.passthrough { + PassthroughMode::None => false, + PassthroughMode::Allowlist => allowlisted, + PassthroughMode::All => true, + } + }; + + if keep { + out.append(name.clone(), value.clone()); + } + } + + for name in &rules.remove { + if let Ok(header_name) = HeaderName::try_from(name.to_ascii_lowercase()) { + out.remove(&header_name); + } + } + for (name, value) in &rules.set { + if let (Ok(name), Ok(value)) = ( + HeaderName::try_from(name.to_ascii_lowercase()), + HeaderValue::from_str(value), + ) { + out.insert(name, value); + } + } + for (name, value) in &rules.add { + if let (Ok(name), Ok(value)) = ( + HeaderName::try_from(name.to_ascii_lowercase()), + HeaderValue::from_str(value), + ) { + out.append(name, value); + } + } + out + } +} + +/// Standard rate-limit headers, emitted when the policy asks for them. +fn rate_limit_headers( + config: &crate::domain::model::RateLimitConfig, + verdict: &crate::infra::rate_limit::RateLimitVerdict, +) -> Vec<(String, String)> { + if config.response_headers { + rate_limit_headers_forced(config, verdict) + } else { + Vec::new() + } +} + +fn rate_limit_headers_forced( + _config: &crate::domain::model::RateLimitConfig, + verdict: &crate::infra::rate_limit::RateLimitVerdict, +) -> Vec<(String, String)> { + let reset_at = now_epoch_secs().saturating_add(verdict.reset_after_secs); + vec![ + ("x-ratelimit-limit".to_owned(), verdict.limit.to_string()), + ( + "x-ratelimit-remaining".to_owned(), + verdict.remaining.to_string(), + ), + ("x-ratelimit-reset".to_owned(), reset_at.to_string()), + ] +} + +/// Attach rate-limit headers to a successful outcome. +fn apply_rate_limit_headers(outcome: &mut ProxyOutcome, headers: &[(String, String)]) { + if headers.is_empty() { + return; + } + let target = match outcome { + ProxyOutcome::Streamed { head, .. } | ProxyOutcome::Buffered { head, .. } => { + &mut head.headers + } + // An upgraded connection stops being an HTTP message exchange; the + // 101 head is the handshake, not a place for budget accounting. + ProxyOutcome::Upgraded { .. } => return, + }; + for (name, value) in headers { + if let (Ok(name), Ok(value)) = ( + HeaderName::try_from(name.as_str()), + HeaderValue::from_str(value), + ) { + target.insert(name, value); + } + } +} + +/// Plugins resolved for one request, split by kind and kept in chain order. +#[derive(Default)] +struct ResolvedChain { + guards: Vec<(PluginBinding, Arc)>, + transforms: Vec<(PluginBinding, Arc)>, +} + +/// Map a guard rejection onto the catalogued error whose status it carries. +fn guard_rejection(status: StatusCode, error_code: &str, message: &str) -> OagwError { + let kind = match status { + StatusCode::UNAUTHORIZED => ErrorKind::AuthenticationFailed, + StatusCode::FORBIDDEN => ErrorKind::PermissionDenied, + StatusCode::NOT_FOUND => ErrorKind::RouteNotFound, + StatusCode::PAYLOAD_TOO_LARGE => ErrorKind::PayloadTooLarge, + StatusCode::TOO_MANY_REQUESTS => ErrorKind::RateLimitExceeded, + StatusCode::BAD_GATEWAY => ErrorKind::DownstreamError, + StatusCode::GATEWAY_TIMEOUT => ErrorKind::RequestTimeout, + _ => ErrorKind::ValidationError, + }; + OagwError::new(kind, message.to_owned()).with("error_code", error_code.to_owned()) +} + +/// Reject a cross-origin request the CORS policy does not permit. +fn enforce_cors(cors: &CorsConfig, origin: &str, method: &str) -> OagwResult<()> { + if !cors.origin_allowed(origin) { + return Err(OagwError::new( + ErrorKind::CorsOriginNotAllowed, + format!("Origin '{origin}' not in allowed origins list"), + )); + } + if !cors.method_allowed(method) { + return Err(OagwError::new( + ErrorKind::CorsMethodNotAllowed, + format!("Method '{method}' not in allowed methods list"), + )); + } + Ok(()) +} + +/// Attach the CORS members of an *actual* (non-preflight) response. +fn apply_cors_response_headers(headers: &mut HeaderMap, cors: &CorsConfig, origin: &str) { + let allow_origin = if cors.allow_credentials || !cors.has_wildcard_origin() { + origin.to_owned() + } else { + "*".to_owned() + }; + if let Ok(value) = HeaderValue::from_str(&allow_origin) { + headers.insert( + HeaderName::from_static("access-control-allow-origin"), + value, + ); + } + if cors.allow_credentials { + headers.insert( + HeaderName::from_static("access-control-allow-credentials"), + HeaderValue::from_static("true"), + ); + } + if !cors.expose_headers.is_empty() + && let Ok(value) = HeaderValue::from_str(&cors.expose_headers.join(", ")) + { + headers.insert( + HeaderName::from_static("access-control-expose-headers"), + value, + ); + } + // Always vary on Origin so a shared cache cannot serve one origin's + // response to another. + headers.append(header::VARY, HeaderValue::from_static("Origin")); +} + +/// Apply the configured response header rules. +fn apply_response_header_rules(headers: &mut HeaderMap, effective: &EffectiveConfig) { + let rules = &effective.headers.response; + for name in &rules.remove { + if let Ok(header_name) = HeaderName::try_from(name.to_ascii_lowercase()) { + headers.remove(&header_name); + } + } + for (name, value) in &rules.set { + if let (Ok(name), Ok(value)) = ( + HeaderName::try_from(name.to_ascii_lowercase()), + HeaderValue::from_str(value), + ) { + headers.insert(name, value); + } + } + for (name, value) in &rules.add { + if let (Ok(name), Ok(value)) = ( + HeaderName::try_from(name.to_ascii_lowercase()), + HeaderValue::from_str(value), + ) { + headers.append(name, value); + } + } +} + +/// Drop hop-by-hop headers from a response before it is relayed. +pub fn strip_hop_by_hop(headers: &mut HeaderMap) { + for name in HOP_BY_HOP { + if let Ok(header_name) = HeaderName::try_from(*name) { + headers.remove(&header_name); + } + } +} + +/// A bare hostname or IP literal — no port, path, scheme or userinfo. +#[must_use] +pub fn is_plain_host(value: &str) -> bool { + if value.is_empty() || value.len() > 253 { + return false; + } + if alias::is_ip_literal(value) { + return true; + } + value + .split('.') + .all(|label| { + !label.is_empty() + && label.len() <= 63 + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + && !label.starts_with('-') + && !label.ends_with('-') + }) +} + +fn now_epoch_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + +/// Log one completed proxy request in the audit format (ADR 0001, Appendix A). +pub fn audit_log( + request_id: &str, + tenant_id: Uuid, + principal_id: Uuid, + host: &str, + path: &str, + method: &str, + status: u16, + duration_ms: u128, + error_type: Option<&str>, +) { + info!( + target: "oagw.audit", + event = "proxy_request", + request_id, + tenant_id = %tenant_id, + principal_id = %principal_id, + host, + path, + method, + status, + duration_ms = duration_ms as u64, + error_type = error_type.unwrap_or_default(), + "proxy request completed" + ); +} + +/// GTS identifier of the proxy permission, exported for the transport layer. +pub const PROXY_INVOKE_PERMISSION: &str = gts::PROXY_BASE; + +#[cfg(test)] +#[path = "service_tests.rs"] +mod tests; 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..be43b84 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/service_tests.rs @@ -0,0 +1,57 @@ +//! Data Plane helpers that decide routing and header hygiene. + +use super::*; + +#[test] +fn a_plain_host_is_a_hostname_or_ip_and_nothing_else() { + assert!(is_plain_host("us.vendor.com")); + assert!(is_plain_host("10.0.1.1")); + assert!(is_plain_host("::1")); + + // Ports, schemes, paths and separators are all disqualifying. + assert!(!is_plain_host("us.vendor.com:8443")); + assert!(!is_plain_host("https://us.vendor.com")); + assert!(!is_plain_host("us.vendor.com/path")); + assert!(!is_plain_host("us vendor.com")); + assert!(!is_plain_host("-leading.example.com")); + assert!(!is_plain_host("")); +} + +#[test] +fn hop_by_hop_headers_are_stripped_from_a_response() { + let mut headers = HeaderMap::new(); + for name in [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + ] { + headers.insert( + HeaderName::try_from(name).unwrap(), + HeaderValue::from_static("x"), + ); + } + headers.insert( + HeaderName::from_static("content-type"), + HeaderValue::from_static("application/json"), + ); + + strip_hop_by_hop(&mut headers); + + assert_eq!(headers.len(), 1); + assert!(headers.contains_key("content-type")); +} + +#[test] +fn the_proxy_permission_is_the_documented_identifier() { + assert_eq!(PROXY_INVOKE_PERMISSION, "gts.cf.core.oagw.proxy.v1~"); +} + +#[test] +fn the_target_host_header_is_the_documented_name() { + assert_eq!(TARGET_HOST_HEADER, "x-oagw-target-host"); +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/websocket.rs b/gears/system/oagw/oagw/src/infra/proxy/websocket.rs new file mode 100644 index 0000000..4fbf6e2 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/websocket.rs @@ -0,0 +1,299 @@ +//! Protocol-upgrade proxying (WebSocket, and any other HTTP/1.1 upgrade). +//! +//! An upgrade cannot go through the message-oriented client path: after the +//! `101` the connection stops being HTTP and becomes an opaque byte stream in +//! both directions. So the handshake is written and parsed here, and the two +//! sockets are then spliced. + +use bytes::Bytes; +use http::{HeaderMap, HeaderName, HeaderValue, Method, StatusCode, header}; +use pingora_core::protocols::Stream as TransportStream; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +use crate::domain::error::{ErrorKind, OagwError, OagwResult}; +use crate::domain::model::Endpoint; +use crate::domain::plugin::{ProxyRequest, ProxyResponseHead}; + +use super::connector::{UpstreamConnector, upstream_authority}; + +/// Largest handshake response head accepted from an upstream. +const MAX_HEAD_BYTES: usize = 64 * 1024; +/// Largest body read from a refused upgrade before it is passed back. +const MAX_REJECT_BODY_BYTES: usize = 64 * 1024; + +/// What the upstream did with the upgrade offer. +pub enum UpgradeOutcome { + /// The upstream switched protocols; the stream is now opaque. + Switching { + headers: HeaderMap, + stream: TransportStream, + /// Bytes already read past the response head — must reach the client + /// before anything else. + leftover: Bytes, + }, + /// The upstream answered with an ordinary response instead. + Rejected { + head: ProxyResponseHead, + body: Bytes, + }, +} + +/// Whether `method`/`headers` form an HTTP/1.1 upgrade request. +#[must_use] +pub fn is_upgrade_request(method: &Method, headers: &HeaderMap) -> bool { + if method != Method::GET { + return false; + } + let connection_upgrade = headers + .get_all(header::CONNECTION) + .iter() + .filter_map(|value| value.to_str().ok()) + .any(|value| { + value + .split(',') + .any(|token| token.trim().eq_ignore_ascii_case("upgrade")) + }); + connection_upgrade && headers.contains_key(header::UPGRADE) +} + +/// The protocol named by the `Upgrade` header, lowercased. +#[must_use] +pub fn upgrade_protocol(headers: &HeaderMap) -> Option { + headers + .get(header::UPGRADE) + .and_then(|value| value.to_str().ok()) + .map(|value| value.trim().to_ascii_lowercase()) +} + +/// Perform the upgrade handshake against `endpoint`. +/// +/// # Errors +/// +/// `502` when the upstream speaks something that is not HTTP, `504` when the +/// handshake does not complete within `timeout`. +pub async fn perform_upgrade( + connector: &UpstreamConnector, + endpoint: &Endpoint, + request: &ProxyRequest, + timeout: std::time::Duration, +) -> OagwResult { + let mut stream = connector.open_stream(endpoint).await?; + let handshake = handshake(&mut stream, endpoint, request); + + match tokio::time::timeout(timeout, handshake).await { + Ok(Ok(HandshakeResult::Switching { headers, leftover })) => Ok(UpgradeOutcome::Switching { + headers, + stream, + leftover, + }), + Ok(Ok(HandshakeResult::Rejected { head, body })) => { + Ok(UpgradeOutcome::Rejected { head, body }) + } + Ok(Err(err)) => Err(err), + Err(_) => Err(OagwError::new( + ErrorKind::RequestTimeout, + format!( + "upstream '{}' did not complete the protocol upgrade within {}s", + endpoint.host, + timeout.as_secs() + ), + ) + .with("host", endpoint.host.clone())), + } +} + +enum HandshakeResult { + Switching { + headers: HeaderMap, + leftover: Bytes, + }, + Rejected { + head: ProxyResponseHead, + body: Bytes, + }, +} + +async fn handshake( + stream: &mut TransportStream, + endpoint: &Endpoint, + request: &ProxyRequest, +) -> OagwResult { + let wire = serialize_request(endpoint, request); + stream + .write_all(&wire) + .await + .map_err(|err| io_failure(endpoint, "write upgrade request", &err))?; + stream + .flush() + .await + .map_err(|err| io_failure(endpoint, "flush upgrade request", &err))?; + + let (head_len, buffer) = read_head(stream, endpoint).await?; + let mut header_slots = [httparse::EMPTY_HEADER; 96]; + let mut parsed = httparse::Response::new(&mut header_slots); + let status_of = |parsed: &httparse::Response<'_, '_>| parsed.code.unwrap_or(0); + + match parsed.parse(&buffer[..head_len]) { + Ok(httparse::Status::Complete(_)) => {} + Ok(httparse::Status::Partial) | Err(_) => { + return Err(OagwError::new( + ErrorKind::ProtocolError, + format!( + "upstream '{}' returned a malformed HTTP response to the upgrade request", + endpoint.host + ), + )); + } + } + + let code = status_of(&parsed); + let status = StatusCode::from_u16(code).map_err(|_| { + OagwError::new( + ErrorKind::ProtocolError, + format!("upstream '{}' returned status {code}", endpoint.host), + ) + })?; + + let mut headers = HeaderMap::with_capacity(parsed.headers.len()); + for parsed_header in parsed.headers.iter() { + let Ok(name) = HeaderName::from_bytes(parsed_header.name.as_bytes()) else { + continue; + }; + let Ok(value) = HeaderValue::from_bytes(parsed_header.value) else { + continue; + }; + headers.append(name, value); + } + + if status == StatusCode::SWITCHING_PROTOCOLS { + return Ok(HandshakeResult::Switching { + headers, + leftover: Bytes::copy_from_slice(&buffer[head_len..]), + }); + } + + // Not an upgrade after all: read what the upstream said so the client sees + // its answer rather than a synthetic gateway error. + let mut body = buffer[head_len..].to_vec(); + let content_length = headers + .get(header::CONTENT_LENGTH) + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.trim().parse::().ok()); + if let Some(expected) = content_length { + let expected = expected.min(MAX_REJECT_BODY_BYTES); + while body.len() < expected { + let mut chunk = vec![0_u8; (expected - body.len()).min(8192)]; + match stream.read(&mut chunk).await { + Ok(0) => break, + Ok(read) => body.extend_from_slice(&chunk[..read]), + Err(err) => return Err(io_failure(endpoint, "read upgrade response body", &err)), + } + } + } + + Ok(HandshakeResult::Rejected { + head: ProxyResponseHead { status, headers }, + body: Bytes::from(body), + }) +} + +/// Read until the end of the response head, returning `(head_len, buffer)`. +async fn read_head( + stream: &mut TransportStream, + endpoint: &Endpoint, +) -> OagwResult<(usize, Vec)> { + let mut buffer: Vec = Vec::with_capacity(1024); + let mut scan_from = 0_usize; + loop { + let mut chunk = [0_u8; 4096]; + let read = stream + .read(&mut chunk) + .await + .map_err(|err| io_failure(endpoint, "read upgrade response", &err))?; + if read == 0 { + return Err(OagwError::new( + ErrorKind::StreamAborted, + format!( + "upstream '{}' closed the connection during the protocol upgrade", + endpoint.host + ), + )); + } + buffer.extend_from_slice(&chunk[..read]); + + if let Some(offset) = find_head_end(&buffer, scan_from) { + return Ok((offset, buffer)); + } + scan_from = buffer.len().saturating_sub(3); + + if buffer.len() > MAX_HEAD_BYTES { + return Err(OagwError::new( + ErrorKind::ProtocolError, + format!( + "upstream '{}' sent an oversized response head during the upgrade", + endpoint.host + ), + )); + } + } +} + +/// Offset just past the first `\r\n\r\n`, searching from `from`. +fn find_head_end(buffer: &[u8], from: usize) -> Option { + buffer[from..] + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| from + position + 4) +} + +/// Render the upgrade request in HTTP/1.1 wire format. +fn serialize_request(endpoint: &Endpoint, request: &ProxyRequest) -> Vec { + let mut wire = format!( + "{} {} HTTP/1.1\r\n", + request.method.as_str(), + request.path_and_query() + ); + wire.push_str(&format!("Host: {}\r\n", upstream_authority(endpoint))); + for (name, value) in &request.headers { + if name == header::HOST { + continue; + } + if let Ok(value) = value.to_str() { + wire.push_str(&format!("{name}: {value}\r\n")); + } + } + wire.push_str("\r\n"); + wire.into_bytes() +} + +fn io_failure(endpoint: &Endpoint, phase: &str, err: &std::io::Error) -> OagwError { + OagwError::new( + ErrorKind::StreamAborted, + format!("upstream '{}' failed during {phase}: {err}", endpoint.host), + ) + .with("host", endpoint.host.clone()) +} + +/// Splice a client connection and an upstream stream until either side closes. +/// +/// `leftover` is whatever was already read past the upstream's response head; +/// it must be delivered before the copy loop starts or the first WebSocket +/// frame is lost. +pub async fn splice( + mut client: C, + mut upstream: TransportStream, + leftover: Bytes, +) -> std::io::Result<(u64, u64)> +where + C: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, +{ + if !leftover.is_empty() { + client.write_all(&leftover).await?; + client.flush().await?; + } + tokio::io::copy_bidirectional(&mut client, &mut upstream).await +} + +#[cfg(test)] +#[path = "websocket_tests.rs"] +mod tests; 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..e3260b1 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/websocket_tests.rs @@ -0,0 +1,101 @@ +//! Upgrade detection and handshake parsing. + +use super::*; + +fn upgrade_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert(header::CONNECTION, HeaderValue::from_static("Upgrade")); + headers.insert(header::UPGRADE, HeaderValue::from_static("websocket")); + headers +} + +#[test] +fn a_get_with_connection_upgrade_is_an_upgrade_request() { + assert!(is_upgrade_request(&Method::GET, &upgrade_headers())); +} + +#[test] +fn the_connection_token_may_sit_in_a_list() { + let mut headers = HeaderMap::new(); + headers.insert( + header::CONNECTION, + HeaderValue::from_static("keep-alive, Upgrade"), + ); + headers.insert(header::UPGRADE, HeaderValue::from_static("websocket")); + assert!(is_upgrade_request(&Method::GET, &headers)); +} + +#[test] +fn a_non_get_method_is_never_an_upgrade() { + assert!(!is_upgrade_request(&Method::POST, &upgrade_headers())); +} + +#[test] +fn an_upgrade_header_without_the_connection_token_does_not_count() { + let mut headers = HeaderMap::new(); + headers.insert(header::UPGRADE, HeaderValue::from_static("websocket")); + assert!(!is_upgrade_request(&Method::GET, &headers)); +} + +#[test] +fn an_ordinary_request_is_not_an_upgrade() { + assert!(!is_upgrade_request(&Method::GET, &HeaderMap::new())); +} + +#[test] +fn the_offered_protocol_is_reported_lowercased() { + let mut headers = HeaderMap::new(); + headers.insert(header::UPGRADE, HeaderValue::from_static("WebSocket")); + assert_eq!(upgrade_protocol(&headers).as_deref(), Some("websocket")); + assert_eq!(upgrade_protocol(&HeaderMap::new()), None); +} + +#[test] +fn the_head_terminator_is_found_at_the_right_offset() { + let buffer = b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\r\nPAYLOAD"; + let offset = find_head_end(buffer, 0).unwrap(); + assert_eq!(&buffer[offset..], b"PAYLOAD"); +} + +#[test] +fn an_incomplete_head_has_no_terminator_yet() { + assert_eq!(find_head_end(b"HTTP/1.1 101 Switching\r\n", 0), None); +} + +#[test] +fn scanning_can_resume_from_an_offset_without_missing_a_split_terminator() { + let buffer = b"a\r\n\r\nb"; + // Resuming three bytes back from the end still catches the terminator. + assert_eq!(find_head_end(buffer, buffer.len() - 6), Some(5)); +} + +#[test] +fn the_serialized_request_carries_the_upstream_host_and_the_handshake_headers() { + let endpoint = Endpoint { + scheme: crate::domain::model::Scheme::Ws, + host: "chat.example.com".to_owned(), + port: 8080, + }; + let mut headers = upgrade_headers(); + headers.insert( + HeaderName::from_static("sec-websocket-key"), + HeaderValue::from_static("dGhlIHNhbXBsZSBub25jZQ=="), + ); + headers.insert(header::HOST, HeaderValue::from_static("gateway.local")); + + let request = ProxyRequest { + method: Method::GET, + path: "/socket".to_owned(), + query: vec![("room".to_owned(), "42".to_owned())], + headers, + body: Bytes::new(), + }; + let wire = String::from_utf8(serialize_request(&endpoint, &request)).unwrap(); + + assert!(wire.starts_with("GET /socket?room=42 HTTP/1.1\r\n"), "{wire}"); + // The inbound Host is replaced by the upstream authority, never forwarded. + assert!(wire.contains("Host: chat.example.com:8080\r\n"), "{wire}"); + assert!(!wire.contains("gateway.local"), "{wire}"); + assert!(wire.contains("sec-websocket-key: dGhlIHNhbXBsZSBub25jZQ==\r\n"), "{wire}"); + assert!(wire.ends_with("\r\n\r\n"), "{wire}"); +} diff --git a/gears/system/oagw/oagw/src/infra/rate_limit.rs b/gears/system/oagw/oagw/src/infra/rate_limit.rs new file mode 100644 index 0000000..4f97491 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/rate_limit.rs @@ -0,0 +1,226 @@ +//! Per-instance token buckets (ADR 0003, ADR 0006). +//! +//! Rate limiting lives in the Data Plane because that is the only layer with +//! the full request context. State is per-instance for the MVP — distributed +//! coordination is deferred. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use parking_lot::Mutex; +use uuid::Uuid; + +use crate::domain::model::{RateLimitAlgorithm, RateLimitConfig, RateLimitScope}; + +/// Outcome of one limiter check. +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct RateLimitVerdict { + pub allowed: bool, + /// Configured sustained rate, for `X-RateLimit-Limit`. + pub limit: u32, + /// Whole tokens left in the bucket, for `X-RateLimit-Remaining`. + pub remaining: u32, + /// Seconds until enough tokens have replenished, for `Retry-After`. + pub retry_after_secs: u64, + /// Seconds until the bucket is full again, for `X-RateLimit-Reset`. + pub reset_after_secs: u64, + /// Fraction of the bucket consumed, in `[0.0, 1.0]`. + pub usage_ratio: f64, +} + +/// Classic token bucket: `tokens` replenish continuously up to `capacity`. +#[derive(Debug)] +struct TokenBucket { + tokens: f64, + capacity: f64, + refill_per_second: f64, + last_update: Instant, +} + +impl TokenBucket { + fn new(capacity: f64, refill_per_second: f64, now: Instant) -> Self { + Self { + tokens: capacity, + capacity, + refill_per_second, + last_update: now, + } + } + + fn refill(&mut self, now: Instant) { + let elapsed = now.saturating_duration_since(self.last_update).as_secs_f64(); + if elapsed > 0.0 { + self.tokens = (self.tokens + elapsed * self.refill_per_second).min(self.capacity); + self.last_update = now; + } + } + + /// Re-shape the bucket when the effective configuration changed, keeping + /// the consumed fraction so a config edit cannot be used to reset a + /// counter. + fn reconfigure(&mut self, capacity: f64, refill_per_second: f64) { + if (self.capacity - capacity).abs() > f64::EPSILON { + let consumed_ratio = 1.0 - (self.tokens / self.capacity).clamp(0.0, 1.0); + self.capacity = capacity; + self.tokens = capacity * (1.0 - consumed_ratio); + } + self.refill_per_second = refill_per_second; + } + + fn try_acquire(&mut self, cost: f64, now: Instant) -> RateLimitVerdict { + self.refill(now); + let allowed = self.tokens >= cost; + if allowed { + self.tokens -= cost; + } + let deficit = (cost - self.tokens).max(0.0); + let retry_after_secs = if allowed || self.refill_per_second <= 0.0 { + 0 + } else { + (deficit / self.refill_per_second).ceil().max(1.0) as u64 + }; + let reset_after_secs = if self.refill_per_second <= 0.0 { + 0 + } else { + (((self.capacity - self.tokens).max(0.0)) / self.refill_per_second).ceil() as u64 + }; + RateLimitVerdict { + allowed, + limit: 0, + remaining: self.tokens.max(0.0).floor() as u32, + retry_after_secs, + reset_after_secs, + usage_ratio: 1.0 - (self.tokens / self.capacity).clamp(0.0, 1.0), + } + } +} + +/// Identity a counter is kept against. +#[derive(Debug, Clone, Copy)] +pub struct RateLimitSubject { + pub tenant_id: Uuid, + pub subject_id: Uuid, + pub upstream_id: Uuid, + pub route_id: Uuid, +} + +/// All live buckets, keyed by `{resource}:{id}:{scope}:{scope_id}` so a +/// deleted resource's counters can be dropped by prefix. +#[derive(Default)] +pub struct RateLimiterRegistry { + buckets: DashMap>>, +} + +impl std::fmt::Debug for RateLimiterRegistry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RateLimiterRegistry") + .field("buckets", &self.buckets.len()) + .finish() + } +} + +impl RateLimiterRegistry { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Build the counter key for `config` and `subject`. + #[must_use] + pub fn key_for( + config: &RateLimitConfig, + subject: &RateLimitSubject, + client_ip: Option<&str>, + ) -> String { + let (scope_name, scope_id) = match config.scope { + RateLimitScope::Global => ("global", "-".to_owned()), + RateLimitScope::Tenant => ("tenant", subject.tenant_id.to_string()), + RateLimitScope::User => ("user", subject.subject_id.to_string()), + RateLimitScope::Ip => ("ip", client_ip.unwrap_or("unknown").to_owned()), + RateLimitScope::Route => ("route", subject.route_id.to_string()), + }; + format!( + "upstream:{}:{scope_name}:{scope_id}", + subject.upstream_id + ) + } + + /// Check and consume `config.cost` tokens. + #[must_use] + pub fn check( + &self, + config: &RateLimitConfig, + subject: &RateLimitSubject, + client_ip: Option<&str>, + ) -> RateLimitVerdict { + self.check_at(config, subject, client_ip, Instant::now()) + } + + /// [`Self::check`] with an injectable clock, for tests. + #[must_use] + pub fn check_at( + &self, + config: &RateLimitConfig, + subject: &RateLimitSubject, + client_ip: Option<&str>, + now: Instant, + ) -> RateLimitVerdict { + let key = Self::key_for(config, subject, client_ip); + let refill = config.refill_per_second(); + // A sliding window must not permit a boundary burst, so the bucket is + // sized to the sustained rate and `burst.capacity` is ignored. + let capacity = match config.algorithm { + RateLimitAlgorithm::TokenBucket => f64::from(config.capacity()), + RateLimitAlgorithm::SlidingWindow => f64::from(config.sustained.rate.max(1)), + }; + + let bucket = self + .buckets + .entry(key) + .or_insert_with(|| Arc::new(Mutex::new(TokenBucket::new(capacity, refill, now)))) + .clone(); + + let mut guard = bucket.lock(); + guard.reconfigure(capacity, refill); + let mut verdict = guard.try_acquire(f64::from(config.cost.max(1)), now); + verdict.limit = config.sustained.rate; + verdict + } + + /// Drop every counter belonging to `upstream_id`. + pub fn forget_upstream(&self, upstream_id: Uuid) { + let prefix = format!("upstream:{upstream_id}:"); + self.buckets.retain(|key, _| !key.starts_with(&prefix)); + } + + /// Wait for capacity within `budget`, then answer. + /// + /// Backs the `queue` strategy: a short bounded wait, never an unbounded + /// one, so a saturated upstream cannot pile up in-flight requests. + pub async fn acquire_queued( + &self, + config: &RateLimitConfig, + subject: &RateLimitSubject, + client_ip: Option<&str>, + budget: Duration, + ) -> RateLimitVerdict { + let deadline = Instant::now() + budget; + loop { + let verdict = self.check(config, subject, client_ip); + if verdict.allowed { + return verdict; + } + let now = Instant::now(); + if now >= deadline { + return verdict; + } + let step = Duration::from_millis(20).min(deadline.saturating_duration_since(now)); + tokio::time::sleep(step).await; + } + } +} + +#[cfg(test)] +#[path = "rate_limit_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/rate_limit_tests.rs b/gears/system/oagw/oagw/src/infra/rate_limit_tests.rs new file mode 100644 index 0000000..d9908c3 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/rate_limit_tests.rs @@ -0,0 +1,189 @@ +//! Token-bucket behaviour and counter scoping. + +use super::*; +use crate::domain::model::{BurstCapacity, RateWindow, SharingMode, SustainedRate}; + +fn config(rate: u32, capacity: Option) -> RateLimitConfig { + RateLimitConfig { + sharing: SharingMode::Private, + algorithm: RateLimitAlgorithm::TokenBucket, + sustained: SustainedRate { + rate, + window: RateWindow::Second, + }, + burst: BurstCapacity { capacity }, + budget: None, + scope: crate::domain::model::RateLimitScope::Tenant, + strategy: crate::domain::model::RateLimitStrategy::Reject, + cost: 1, + response_headers: true, + } +} + +fn subject() -> RateLimitSubject { + RateLimitSubject { + tenant_id: Uuid::from_u128(1), + subject_id: Uuid::from_u128(2), + upstream_id: Uuid::from_u128(3), + route_id: Uuid::from_u128(4), + } +} + +#[test] +fn a_burst_is_allowed_up_to_capacity_then_refused() { + let registry = RateLimiterRegistry::new(); + let config = config(1, Some(3)); + let subject = subject(); + let now = Instant::now(); + + for attempt in 0..3 { + let verdict = registry.check_at(&config, &subject, None, now); + assert!(verdict.allowed, "attempt {attempt} should be inside the burst"); + } + let verdict = registry.check_at(&config, &subject, None, now); + assert!(!verdict.allowed); + assert_eq!(verdict.remaining, 0); + assert!(verdict.retry_after_secs >= 1); + assert!(verdict.usage_ratio > 0.99); +} + +#[test] +fn tokens_replenish_with_elapsed_time() { + let registry = RateLimiterRegistry::new(); + let config = config(2, Some(2)); + let subject = subject(); + let start = Instant::now(); + + assert!(registry.check_at(&config, &subject, None, start).allowed); + assert!(registry.check_at(&config, &subject, None, start).allowed); + assert!(!registry.check_at(&config, &subject, None, start).allowed); + + // Two tokens per second: one second later the bucket is full again. + let later = start + Duration::from_secs(1); + assert!(registry.check_at(&config, &subject, None, later).allowed); + assert!(registry.check_at(&config, &subject, None, later).allowed); +} + +#[test] +fn cost_consumes_more_than_one_token() { + let registry = RateLimiterRegistry::new(); + let mut config = config(10, Some(10)); + config.cost = 4; + let subject = subject(); + let now = Instant::now(); + + assert!(registry.check_at(&config, &subject, None, now).allowed); + assert!(registry.check_at(&config, &subject, None, now).allowed); + // 8 of 10 consumed; a third request of cost 4 does not fit. + assert!(!registry.check_at(&config, &subject, None, now).allowed); +} + +#[test] +fn a_sliding_window_ignores_the_burst_capacity() { + let registry = RateLimiterRegistry::new(); + let mut config = config(2, Some(100)); + config.algorithm = RateLimitAlgorithm::SlidingWindow; + let subject = subject(); + let now = Instant::now(); + + assert!(registry.check_at(&config, &subject, None, now).allowed); + assert!(registry.check_at(&config, &subject, None, now).allowed); + assert!(!registry.check_at(&config, &subject, None, now).allowed); +} + +#[test] +fn counter_keys_separate_the_documented_scopes() { + let subject = subject(); + let mut config = config(1, None); + + config.scope = crate::domain::model::RateLimitScope::Tenant; + let tenant_key = RateLimiterRegistry::key_for(&config, &subject, None); + config.scope = crate::domain::model::RateLimitScope::User; + let user_key = RateLimiterRegistry::key_for(&config, &subject, None); + config.scope = crate::domain::model::RateLimitScope::Ip; + let ip_key = RateLimiterRegistry::key_for(&config, &subject, Some("203.0.113.9")); + config.scope = crate::domain::model::RateLimitScope::Route; + let route_key = RateLimiterRegistry::key_for(&config, &subject, None); + config.scope = crate::domain::model::RateLimitScope::Global; + let global_key = RateLimiterRegistry::key_for(&config, &subject, None); + + let keys = [&tenant_key, &user_key, &ip_key, &route_key, &global_key]; + for (index, key) in keys.iter().enumerate() { + // Every key is prefixed by its upstream so deletion can sweep by prefix. + assert!(key.starts_with(&format!("upstream:{}:", subject.upstream_id))); + for other in keys.iter().skip(index + 1) { + assert_ne!(key, other, "scopes must not share a counter"); + } + } + assert!(ip_key.ends_with("203.0.113.9")); +} + +#[test] +fn separate_tenants_do_not_share_a_counter() { + let registry = RateLimiterRegistry::new(); + let config = config(1, Some(1)); + let now = Instant::now(); + let first = subject(); + let second = RateLimitSubject { + tenant_id: Uuid::from_u128(99), + ..first + }; + + assert!(registry.check_at(&config, &first, None, now).allowed); + assert!(!registry.check_at(&config, &first, None, now).allowed); + assert!(registry.check_at(&config, &second, None, now).allowed); +} + +#[test] +fn forgetting_an_upstream_drops_its_counters() { + let registry = RateLimiterRegistry::new(); + let config = config(1, Some(1)); + let subject = subject(); + let now = Instant::now(); + + assert!(registry.check_at(&config, &subject, None, now).allowed); + assert!(!registry.check_at(&config, &subject, None, now).allowed); + + registry.forget_upstream(subject.upstream_id); + assert!(registry.check_at(&config, &subject, None, now).allowed); +} + +#[test] +fn a_verdict_reports_the_configured_limit_and_a_reset_horizon() { + let registry = RateLimiterRegistry::new(); + let config = config(4, Some(4)); + let verdict = registry.check(&config, &subject(), None); + assert_eq!(verdict.limit, 4); + assert_eq!(verdict.remaining, 3); + assert!(verdict.reset_after_secs >= 1); +} + +#[tokio::test] +async fn the_queue_strategy_waits_for_capacity_within_its_budget() { + let registry = RateLimiterRegistry::new(); + let config = config(20, Some(1)); + let subject = subject(); + + assert!(registry.check(&config, &subject, None).allowed); + // 20 tokens/s replenishes one in 50ms, comfortably inside the budget. + let verdict = registry + .acquire_queued(&config, &subject, None, Duration::from_millis(500)) + .await; + assert!(verdict.allowed); +} + +#[tokio::test] +async fn the_queue_strategy_gives_up_at_the_deadline() { + let registry = RateLimiterRegistry::new(); + let config = config(1, Some(1)); + let subject = RateLimitSubject { + upstream_id: Uuid::from_u128(77), + ..subject() + }; + + assert!(registry.check(&config, &subject, None).allowed); + let verdict = registry + .acquire_queued(&config, &subject, None, Duration::from_millis(30)) + .await; + assert!(!verdict.allowed); +} diff --git a/gears/system/oagw/oagw/src/infra/storage.rs b/gears/system/oagw/oagw/src/infra/storage.rs new file mode 100644 index 0000000..c367702 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/storage.rs @@ -0,0 +1,403 @@ +//! In-process configuration store. +//! +//! OAGW's configuration is small, read-heavy and rebuilt from the types +//! registry on start (`docs/DESIGN.md` §4.7 item 8), so the shipped repository +//! keeps it in memory behind `DashMap`. Tenant scoping is applied on every +//! read and write here, not by the callers. + +use std::sync::Arc; + +use dashmap::DashMap; +use uuid::Uuid; + +use crate::domain::error::{OagwError, OagwResult}; +use crate::domain::model::{PluginDef, Route, Upstream}; +use crate::domain::repo::{PluginRepository, RouteRepository, UpstreamRepository}; + +/// Concurrent, tenant-scoped store for upstreams, routes and plugins. +#[derive(Debug, Default)] +pub struct InMemoryStore { + upstreams: DashMap, + routes: DashMap, + plugins: DashMap, +} + +impl InMemoryStore { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + #[must_use] + pub fn shared() -> Arc { + Arc::new(Self::new()) + } + + /// Every upstream under `alias` across all tenants, used by the Data + /// Plane's hierarchy walk. + #[must_use] + pub fn find_alias_across_tenants(&self, alias: &str) -> Vec { + self.upstreams + .iter() + .filter(|entry| entry.alias == alias) + .map(|entry| entry.clone()) + .collect() + } + + /// Bindings that reference `plugin_uuid`, as `(upstream_ids, route_ids)`. + #[must_use] + pub fn references_to_plugin(&self, plugin_uuid: Uuid) -> (Vec, Vec) { + let upstreams = self + .upstreams + .iter() + .filter(|entry| { + let auth_match = entry + .auth + .as_ref() + .and_then(|a| a.plugin_type.as_deref()) + .and_then(crate::domain::gts::instance_uuid) + == Some(plugin_uuid); + let chain_match = entry.plugins.as_ref().is_some_and(|p| { + p.items + .iter() + .any(|item| item.plugin_uuid == Some(plugin_uuid)) + }); + auth_match || chain_match + }) + .map(|entry| entry.id) + .collect(); + + let routes = self + .routes + .iter() + .filter(|entry| { + entry.plugins.as_ref().is_some_and(|p| { + p.items + .iter() + .any(|item| item.plugin_uuid == Some(plugin_uuid)) + }) + }) + .map(|entry| entry.id) + .collect(); + + (upstreams, routes) + } + + /// Delete every plugin whose `gc_eligible_at` is at or before `now_secs`. + /// Returns the ids collected. + pub fn collect_garbage(&self, now_secs: u64) -> Vec { + let due: Vec = self + .plugins + .iter() + .filter(|entry| entry.gc_eligible_at.is_some_and(|at| at <= now_secs)) + .map(|entry| entry.id) + .collect(); + for id in &due { + self.plugins.remove(id); + } + due + } + + /// Mark newly unlinked plugins, then delete the ones whose deadline has + /// passed. Returns the ids collected on this pass. + /// + /// Two steps rather than one so a plugin that is unbound and re-bound + /// inside the TTL window is never collected: re-binding clears the + /// deadline via [`PluginRepository::touch`]. + pub fn run_gc(&self, now_secs: u64, ttl_secs: u64) -> Vec { + let unlinked = self.unlinked_plugins(); + for id in &unlinked { + let already_marked = self + .plugins + .get(id) + .is_some_and(|entry| entry.gc_eligible_at.is_some()); + if !already_marked { + self.set_gc_eligible_at(*id, Some(now_secs.saturating_add(ttl_secs))); + } + } + // A plugin that came back into use loses its deadline. The ids are + // collected before mutating: holding a `DashMap` iterator across a + // write to the same map deadlocks on the shard lock. + let revived: Vec = self + .plugins + .iter() + .filter(|entry| entry.gc_eligible_at.is_some() && !unlinked.contains(&entry.id)) + .map(|entry| entry.id) + .collect(); + for id in revived { + self.set_gc_eligible_at(id, None); + } + + self.collect_garbage(now_secs) + } + + /// Ids of stored plugins that no upstream or route references. + #[must_use] + pub fn unlinked_plugins(&self) -> Vec { + self.plugins + .iter() + .map(|entry| entry.id) + .filter(|id| { + let (upstreams, routes) = self.references_to_plugin(*id); + upstreams.is_empty() && routes.is_empty() + }) + .collect() + } +} + +impl UpstreamRepository for InMemoryStore { + fn insert(&self, upstream: Upstream) -> OagwResult { + if self + .upstreams + .iter() + .any(|e| e.tenant_id == upstream.tenant_id && e.alias == upstream.alias) + { + return Err(OagwError::conflict(format!( + "an upstream with alias '{}' already exists for this tenant", + upstream.alias + )) + .with("alias", upstream.alias.clone())); + } + self.upstreams.insert(upstream.id, upstream.clone()); + Ok(upstream) + } + + fn replace(&self, upstream: Upstream) -> OagwResult { + let Some(existing) = self.upstreams.get(&upstream.id).map(|e| e.clone()) else { + return Err(OagwError::not_found("upstream not found")); + }; + if existing.tenant_id != upstream.tenant_id { + return Err(OagwError::not_found("upstream not found")); + } + if existing.alias != upstream.alias + && self + .upstreams + .iter() + .any(|e| e.tenant_id == upstream.tenant_id && e.alias == upstream.alias) + { + return Err(OagwError::conflict(format!( + "an upstream with alias '{}' already exists for this tenant", + upstream.alias + ))); + } + self.upstreams.insert(upstream.id, upstream.clone()); + Ok(upstream) + } + + fn get(&self, tenant_id: Uuid, id: Uuid) -> Option { + self.upstreams + .get(&id) + .filter(|e| e.tenant_id == tenant_id) + .map(|e| e.clone()) + } + + fn get_unscoped(&self, id: Uuid) -> Option { + self.upstreams.get(&id).map(|e| e.clone()) + } + + fn list(&self, tenant_id: Uuid) -> Vec { + let mut items: Vec = self + .upstreams + .iter() + .filter(|e| e.tenant_id == tenant_id) + .map(|e| e.clone()) + .collect(); + items.sort_by(|a, b| a.alias.cmp(&b.alias).then(a.id.cmp(&b.id))); + items + } + + fn find_by_alias(&self, tenant_id: Uuid, alias: &str) -> Option { + self.upstreams + .iter() + .find(|e| e.tenant_id == tenant_id && e.alias == alias) + .map(|e| e.clone()) + } + + fn delete(&self, tenant_id: Uuid, id: Uuid) -> bool { + let owned = self + .upstreams + .get(&id) + .is_some_and(|e| e.tenant_id == tenant_id); + if owned { + self.upstreams.remove(&id); + } + owned + } +} + +impl RouteRepository for InMemoryStore { + fn insert(&self, route: Route) -> OagwResult { + ensure_match_unique(self, &route)?; + self.routes.insert(route.id, route.clone()); + Ok(route) + } + + fn replace(&self, route: Route) -> OagwResult { + let Some(existing) = self.routes.get(&route.id).map(|e| e.clone()) else { + return Err(OagwError::not_found("route not found")); + }; + if existing.tenant_id != route.tenant_id { + return Err(OagwError::not_found("route not found")); + } + ensure_match_unique(self, &route)?; + self.routes.insert(route.id, route.clone()); + Ok(route) + } + + fn get(&self, tenant_id: Uuid, id: Uuid) -> Option { + self.routes + .get(&id) + .filter(|e| e.tenant_id == tenant_id) + .map(|e| e.clone()) + } + + fn list(&self, tenant_id: Uuid) -> Vec { + let mut items: Vec = self + .routes + .iter() + .filter(|e| e.tenant_id == tenant_id) + .map(|e| e.clone()) + .collect(); + items.sort_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id))); + items + } + + fn list_by_upstream(&self, upstream_id: Uuid) -> Vec { + let mut items: Vec = self + .routes + .iter() + .filter(|e| e.upstream_id == upstream_id) + .map(|e| e.clone()) + .collect(); + items.sort_by(|a, b| b.priority.cmp(&a.priority).then(a.id.cmp(&b.id))); + items + } + + fn delete(&self, tenant_id: Uuid, id: Uuid) -> bool { + let owned = self + .routes + .get(&id) + .is_some_and(|e| e.tenant_id == tenant_id); + if owned { + self.routes.remove(&id); + } + owned + } + + fn delete_by_upstream(&self, upstream_id: Uuid) -> usize { + let doomed: Vec = self + .routes + .iter() + .filter(|e| e.upstream_id == upstream_id) + .map(|e| e.id) + .collect(); + for id in &doomed { + self.routes.remove(id); + } + doomed.len() + } +} + +/// No two enabled routes under the same upstream may share +/// `(path, priority)` for the same method. +fn ensure_match_unique(store: &InMemoryStore, candidate: &Route) -> OagwResult<()> { + let Some(candidate_http) = candidate.http() else { + return Ok(()); + }; + if !candidate.enabled { + return Ok(()); + } + for existing in store.routes.iter() { + if existing.id == candidate.id + || existing.upstream_id != candidate.upstream_id + || !existing.enabled + { + continue; + } + let Some(existing_http) = existing.http() else { + continue; + }; + if existing_http.path != candidate_http.path || existing.priority != candidate.priority { + continue; + } + let overlap = candidate_http + .methods + .iter() + .find(|m| existing_http.allows_method(m)); + if let Some(method) = overlap { + return Err(OagwError::conflict(format!( + "route {method} {} at priority {} already exists on this upstream", + candidate_http.path, candidate.priority + )) + .with("conflicting_route_id", existing.id.to_string())); + } + } + Ok(()) +} + +impl PluginRepository for InMemoryStore { + fn insert(&self, plugin: PluginDef) -> OagwResult { + if self + .plugins + .iter() + .any(|e| e.tenant_id == plugin.tenant_id && e.name == plugin.name) + { + return Err(OagwError::conflict(format!( + "a plugin named '{}' already exists for this tenant", + plugin.name + ))); + } + self.plugins.insert(plugin.id, plugin.clone()); + Ok(plugin) + } + + fn get(&self, tenant_id: Uuid, id: Uuid) -> Option { + self.plugins + .get(&id) + .filter(|e| e.tenant_id == tenant_id) + .map(|e| e.clone()) + } + + fn get_unscoped(&self, id: Uuid) -> Option { + self.plugins.get(&id).map(|e| e.clone()) + } + + fn list(&self, tenant_id: Uuid) -> Vec { + let mut items: Vec = self + .plugins + .iter() + .filter(|e| e.tenant_id == tenant_id) + .map(|e| e.clone()) + .collect(); + items.sort_by(|a, b| a.name.cmp(&b.name).then(a.id.cmp(&b.id))); + items + } + + fn delete(&self, tenant_id: Uuid, id: Uuid) -> bool { + let owned = self + .plugins + .get(&id) + .is_some_and(|e| e.tenant_id == tenant_id); + if owned { + self.plugins.remove(&id); + } + owned + } + + fn touch(&self, id: Uuid, epoch_secs: u64) { + if let Some(mut entry) = self.plugins.get_mut(&id) { + entry.last_used_at = Some(epoch_secs); + entry.gc_eligible_at = None; + } + } + + fn set_gc_eligible_at(&self, id: Uuid, epoch_secs: Option) { + if let Some(mut entry) = self.plugins.get_mut(&id) { + entry.gc_eligible_at = epoch_secs; + } + } +} + +#[cfg(test)] +#[path = "storage_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/storage_tests.rs b/gears/system/oagw/oagw/src/infra/storage_tests.rs new file mode 100644 index 0000000..288f2ed --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/storage_tests.rs @@ -0,0 +1,333 @@ +//! Tenant scoping and uniqueness invariants of the configuration store. + +use super::*; +use crate::domain::model::{ + ConfigMap, Endpoint, HttpMatch, MatchConfig, PathSuffixMode, PluginBinding, PluginKind, + PluginsConfig, Protocol, Scheme, ServerConfig, SharingMode, +}; + +fn upstream(tenant: Uuid, alias: &str) -> Upstream { + Upstream { + id: Uuid::new_v4(), + tenant_id: tenant, + alias: alias.to_owned(), + enabled: true, + protocol: Protocol::Http, + server: ServerConfig { + endpoints: vec![Endpoint { + scheme: Scheme::Https, + host: "api.example.com".to_owned(), + port: 443, + }], + }, + auth: None, + headers: None, + plugins: None, + rate_limit: None, + cors: None, + tags: Vec::new(), + } +} + +fn route(tenant: Uuid, upstream_id: Uuid, path: &str, method: &str, priority: i32) -> Route { + Route { + id: Uuid::new_v4(), + tenant_id: tenant, + upstream_id, + enabled: true, + priority, + r#match: MatchConfig { + http: Some(HttpMatch { + methods: vec![method.to_owned()], + path: path.to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }, + plugins: None, + rate_limit: None, + cors: None, + tags: Vec::new(), + } +} + +fn plugin(tenant: Uuid, name: &str) -> PluginDef { + PluginDef { + id: Uuid::new_v4(), + tenant_id: tenant, + plugin_type: PluginKind::Guard, + name: name.to_owned(), + description: None, + phases: Vec::new(), + config_schema: None, + source_code: "def on_request(ctx): pass".to_owned(), + last_used_at: None, + gc_eligible_at: None, + } +} + +#[test] +fn an_alias_is_unique_per_tenant_not_globally() { + let store = InMemoryStore::new(); + let a = Uuid::new_v4(); + let b = Uuid::new_v4(); + UpstreamRepository::insert(&store, upstream(a, "api.example.com")).unwrap(); + assert_eq!( + UpstreamRepository::insert(&store, upstream(a, "api.example.com")) + .unwrap_err() + .status(), + 409 + ); + UpstreamRepository::insert(&store, upstream(b, "api.example.com")).unwrap(); +} + +#[test] +fn reads_and_writes_are_scoped_to_the_owning_tenant() { + let store = InMemoryStore::new(); + let owner = Uuid::new_v4(); + let other = Uuid::new_v4(); + let created = UpstreamRepository::insert(&store, upstream(owner, "api.example.com")).unwrap(); + + assert!(UpstreamRepository::get(&store, other, created.id).is_none()); + assert!(UpstreamRepository::get(&store, owner, created.id).is_some()); + assert!(!UpstreamRepository::delete(&store, other, created.id)); + assert!(UpstreamRepository::delete(&store, owner, created.id)); +} + +#[test] +fn the_data_plane_can_read_an_upstream_across_tenants() { + let store = InMemoryStore::new(); + let owner = Uuid::new_v4(); + let created = UpstreamRepository::insert(&store, upstream(owner, "api.example.com")).unwrap(); + assert!(UpstreamRepository::get_unscoped(&store, created.id).is_some()); + assert_eq!(store.find_alias_across_tenants("api.example.com").len(), 1); +} + +#[test] +fn replacing_an_upstream_under_another_tenant_is_a_not_found() { + let store = InMemoryStore::new(); + let owner = Uuid::new_v4(); + let mut created = UpstreamRepository::insert(&store, upstream(owner, "api.example.com")).unwrap(); + created.tenant_id = Uuid::new_v4(); + assert_eq!( + UpstreamRepository::replace(&store, created).unwrap_err().status(), + 404 + ); +} + +#[test] +fn two_enabled_routes_may_not_share_path_priority_and_method() { + let store = InMemoryStore::new(); + let tenant = Uuid::new_v4(); + let upstream_id = Uuid::new_v4(); + RouteRepository::insert(&store, route(tenant, upstream_id, "/v1", "GET", 0)) + .unwrap(); + assert_eq!( + RouteRepository::insert(&store, route(tenant, upstream_id, "/v1", "GET", 0)) + .unwrap_err() + .status(), + 409 + ); + // A different method, priority or upstream is fine. + RouteRepository::insert(&store, route(tenant, upstream_id, "/v1", "POST", 0)) + .unwrap(); + RouteRepository::insert(&store, route(tenant, upstream_id, "/v1", "GET", 5)) + .unwrap(); + RouteRepository::insert(&store, route(tenant, Uuid::new_v4(), "/v1", "GET", 0)) + .unwrap(); +} + +#[test] +fn a_disabled_route_does_not_reserve_its_match_rule() { + let store = InMemoryStore::new(); + let tenant = Uuid::new_v4(); + let upstream_id = Uuid::new_v4(); + let mut disabled = route(tenant, upstream_id, "/v1", "GET", 0); + disabled.enabled = false; + RouteRepository::insert(&store, disabled).unwrap(); + RouteRepository::insert(&store, route(tenant, upstream_id, "/v1", "GET", 0)) + .unwrap(); +} + +#[test] +fn routes_cascade_when_their_upstream_goes_away() { + let store = InMemoryStore::new(); + let tenant = Uuid::new_v4(); + let upstream_id = Uuid::new_v4(); + RouteRepository::insert(&store, route(tenant, upstream_id, "/a", "GET", 0)) + .unwrap(); + RouteRepository::insert(&store, route(tenant, upstream_id, "/b", "GET", 0)) + .unwrap(); + assert_eq!(store.delete_by_upstream(upstream_id), 2); + assert!(RouteRepository::list_by_upstream(&store, upstream_id).is_empty()); +} + +#[test] +fn a_plugin_name_is_unique_per_tenant() { + let store = InMemoryStore::new(); + let tenant = Uuid::new_v4(); + PluginRepository::insert(&store, plugin(tenant, "guard")).unwrap(); + assert_eq!( + PluginRepository::insert(&store, plugin(tenant, "guard")) + .unwrap_err() + .status(), + 409 + ); + PluginRepository::insert(&store, plugin(Uuid::new_v4(), "guard")).unwrap(); +} + +#[test] +fn plugin_references_are_found_through_auth_and_chain_bindings() { + let store = InMemoryStore::new(); + let tenant = Uuid::new_v4(); + let plugin_id = Uuid::new_v4(); + + let mut bound_upstream = upstream(tenant, "chain.example.com"); + bound_upstream.plugins = Some(PluginsConfig { + sharing: SharingMode::Private, + items: vec![PluginBinding { + plugin_ref: format!("{}{plugin_id}", crate::domain::gts::GUARD_PLUGIN_BASE), + plugin_uuid: Some(plugin_id), + config: ConfigMap::new(), + }], + }); + let chain_id = UpstreamRepository::insert(&store, bound_upstream).unwrap().id; + + let mut auth_upstream = upstream(tenant, "auth.example.com"); + auth_upstream.auth = Some(crate::domain::model::AuthConfig { + plugin_type: Some(format!( + "{}{plugin_id}", + crate::domain::gts::AUTH_PLUGIN_BASE + )), + sharing: SharingMode::Private, + config: ConfigMap::new(), + }); + let auth_id = UpstreamRepository::insert(&store, auth_upstream).unwrap().id; + + let mut bound_route = route(tenant, chain_id, "/v1", "GET", 0); + bound_route.plugins = Some(PluginsConfig { + sharing: SharingMode::Private, + items: vec![PluginBinding { + plugin_ref: plugin_id.to_string(), + plugin_uuid: Some(plugin_id), + config: ConfigMap::new(), + }], + }); + let route_id = RouteRepository::insert(&store, bound_route).unwrap().id; + + let (upstreams, routes) = store.references_to_plugin(plugin_id); + assert!(upstreams.contains(&chain_id)); + assert!(upstreams.contains(&auth_id)); + assert_eq!(routes, vec![route_id]); + assert!(store.unlinked_plugins().is_empty()); +} + +#[test] +fn garbage_collection_removes_only_plugins_past_their_deadline() { + let store = InMemoryStore::new(); + let tenant = Uuid::new_v4(); + let due = PluginRepository::insert(&store, plugin(tenant, "due")).unwrap(); + let later = PluginRepository::insert(&store, plugin(tenant, "later")).unwrap(); + + PluginRepository::set_gc_eligible_at(&store, due.id, Some(100)); + PluginRepository::set_gc_eligible_at(&store, later.id, Some(10_000)); + + let collected = store.collect_garbage(500); + assert_eq!(collected, vec![due.id]); + assert!(PluginRepository::get(&store, tenant, due.id).is_none()); + assert!(PluginRepository::get(&store, tenant, later.id).is_some()); +} + +#[test] +fn touching_a_plugin_clears_its_collection_deadline() { + let store = InMemoryStore::new(); + let tenant = Uuid::new_v4(); + let created = PluginRepository::insert(&store, plugin(tenant, "used")).unwrap(); + PluginRepository::set_gc_eligible_at(&store, created.id, Some(100)); + PluginRepository::touch(&store, created.id, 12_345); + + let reloaded = PluginRepository::get(&store, tenant, created.id).unwrap(); + assert_eq!(reloaded.last_used_at, Some(12_345)); + assert_eq!(reloaded.gc_eligible_at, None); +} + +#[test] +fn an_unlinked_plugin_is_reported_as_collectable() { + let store = InMemoryStore::new(); + let tenant = Uuid::new_v4(); + let created = PluginRepository::insert(&store, plugin(tenant, "orphan")).unwrap(); + assert_eq!(store.unlinked_plugins(), vec![created.id]); +} + +#[test] +fn listings_are_stable_and_tenant_scoped() { + let store = InMemoryStore::new(); + let tenant = Uuid::new_v4(); + UpstreamRepository::insert(&store, upstream(tenant, "b.example.com")).unwrap(); + UpstreamRepository::insert(&store, upstream(tenant, "a.example.com")).unwrap(); + UpstreamRepository::insert(&store, upstream(Uuid::new_v4(), "c.example.com")).unwrap(); + + let aliases: Vec = UpstreamRepository::list(&store, tenant) + .into_iter() + .map(|u| u.alias) + .collect(); + assert_eq!(aliases, ["a.example.com", "b.example.com"]); +} + +#[test] +fn the_sweep_marks_then_collects_only_long_unlinked_plugins() { + let store = InMemoryStore::new(); + let tenant = Uuid::new_v4(); + let orphan = PluginRepository::insert(&store, plugin(tenant, "orphan")).unwrap(); + + // First pass only sets the deadline. + assert!(store.run_gc(1_000, 100).is_empty()); + assert_eq!( + PluginRepository::get(&store, tenant, orphan.id) + .unwrap() + .gc_eligible_at, + Some(1_100) + ); + + // Still inside the window. + assert!(store.run_gc(1_050, 100).is_empty()); + // Past it. + assert_eq!(store.run_gc(1_200, 100), vec![orphan.id]); + assert!(PluginRepository::get(&store, tenant, orphan.id).is_none()); +} + +#[test] +fn rebinding_a_plugin_clears_its_deadline() { + let store = InMemoryStore::new(); + let tenant = Uuid::new_v4(); + let plugin_row = PluginRepository::insert(&store, plugin(tenant, "reused")).unwrap(); + + store.run_gc(1_000, 100); + assert!( + PluginRepository::get(&store, tenant, plugin_row.id) + .unwrap() + .gc_eligible_at + .is_some() + ); + + let mut binder = upstream(tenant, "binder.example.com"); + binder.plugins = Some(PluginsConfig { + sharing: SharingMode::Private, + items: vec![PluginBinding { + plugin_ref: format!("{}{}", crate::domain::gts::GUARD_PLUGIN_BASE, plugin_row.id), + plugin_uuid: Some(plugin_row.id), + config: ConfigMap::new(), + }], + }); + UpstreamRepository::insert(&store, binder).unwrap(); + + // Now referenced again: the sweep must forget the deadline, not collect it. + assert!(store.run_gc(1_200, 100).is_empty()); + assert_eq!( + PluginRepository::get(&store, tenant, plugin_row.id) + .unwrap() + .gc_eligible_at, + None + ); +} diff --git a/gears/system/oagw/oagw/src/infra/tenant.rs b/gears/system/oagw/oagw/src/infra/tenant.rs new file mode 100644 index 0000000..fb25a2a --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/tenant.rs @@ -0,0 +1,102 @@ +//! Tenant hierarchy directory backed by the tenant-resolver gear. + +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use async_trait::async_trait; +use dashmap::DashMap; +use tenant_resolver_sdk::{GetAncestorsOptions, TenantId, TenantResolverClient}; +use toolkit_security::SecurityContext; +use tracing::warn; +use uuid::Uuid; + +use crate::domain::tenant::TenantDirectory; + +#[derive(Debug, Clone)] +struct CachedChain { + chain: Vec, + fetched_at: Instant, +} + +/// Caches the descendant → root chain for a short TTL. +/// +/// Every proxy request needs the chain, and the hierarchy changes rarely; a +/// short TTL keeps the hot path off the resolver without holding a stale view +/// for long. +pub struct TenantResolverDirectory { + client: Arc, + cache: DashMap, + ttl: Duration, +} + +impl std::fmt::Debug for TenantResolverDirectory { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TenantResolverDirectory") + .field("ttl", &self.ttl) + .field("cached", &self.cache.len()) + .finish_non_exhaustive() + } +} + +impl TenantResolverDirectory { + #[must_use] + pub fn new(client: Arc, ttl_secs: u64) -> Self { + Self { + client, + cache: DashMap::new(), + ttl: Duration::from_secs(ttl_secs.max(1)), + } + } + + /// Drop every cached chain — used when the hierarchy is known to have + /// changed. + pub fn invalidate(&self) { + self.cache.clear(); + } +} + +#[async_trait] +impl TenantDirectory for TenantResolverDirectory { + async fn ancestor_chain(&self, ctx: &SecurityContext, tenant_id: Uuid) -> Vec { + if let Some(entry) = self.cache.get(&tenant_id) + && entry.fetched_at.elapsed() < self.ttl + { + return entry.chain.clone(); + } + + let response = self + .client + .get_ancestors(ctx, TenantId(tenant_id), &GetAncestorsOptions::default()) + .await; + + let chain = match response { + Ok(response) => { + let mut chain = Vec::with_capacity(response.ancestors.len() + 1); + chain.push(tenant_id); + chain.extend(response.ancestors.iter().map(|ancestor| ancestor.id.0)); + chain + } + Err(err) => { + // Degrade to the tenant's own scope rather than failing every + // proxy request: a resolver outage must not take out upstreams + // the tenant owns outright. + warn!( + target: "oagw.tenant", + tenant_id = %tenant_id, + error = %err, + "tenant hierarchy lookup failed; falling back to a single-tenant chain" + ); + vec![tenant_id] + } + }; + + self.cache.insert( + tenant_id, + CachedChain { + chain: chain.clone(), + fetched_at: Instant::now(), + }, + ); + chain + } +} diff --git a/gears/system/oagw/oagw/src/infra/type_catalog.rs b/gears/system/oagw/oagw/src/infra/type_catalog.rs new file mode 100644 index 0000000..9ca3e77 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/type_catalog.rs @@ -0,0 +1,210 @@ +//! GTS type provisioning. +//! +//! Two mechanisms, matching the platform's split: +//! +//! * **Link time** — the authorization permissions OAGW grants are declared as +//! `AuthzPermissionV1` instances via `gts_instance!`; `types-registry` +//! collects them from the process-global inventory at boot, so there is no +//! registration call for them. +//! * **Runtime** — the resource and plugin type schemas are published from +//! [`register_catalog`] once the registry is in ready mode, using the +//! schemas shipped in `docs/schemas/`. + +use std::sync::Arc; + +use toolkit_gts::{AuthzPermissionV1, gts_instance}; +use tracing::{info, warn}; +use types_registry_sdk::TypesRegistryClient; + +use crate::api::rest::state::actions; +use crate::domain::gts; + +/// The published upstream schema, so the registry serves exactly what the +/// management API validates against. +const UPSTREAM_SCHEMA: &str = include_str!("../../../docs/schemas/upstream.v1.schema.json"); +/// The published route schema. +const ROUTE_SCHEMA: &str = include_str!("../../../docs/schemas/route.v1.schema.json"); + +// --- Management permissions (link-time inventory) -------------------------- + +gts_instance! { + AuthzPermissionV1 { + id: gts_id!("cf.toolkit.authz.permission.v1~cf.oagw._.upstream_create.v1"), + resource_type: gts::UPSTREAM_BASE.to_owned(), + action: actions::CREATE.to_owned(), + display_name: "Create outbound upstream".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: gts_id!("cf.toolkit.authz.permission.v1~cf.oagw._.upstream_read.v1"), + resource_type: gts::UPSTREAM_BASE.to_owned(), + action: actions::READ.to_owned(), + display_name: "Read outbound upstream".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: gts_id!("cf.toolkit.authz.permission.v1~cf.oagw._.upstream_override.v1"), + resource_type: gts::UPSTREAM_BASE.to_owned(), + action: actions::OVERRIDE.to_owned(), + display_name: "Replace outbound upstream".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: gts_id!("cf.toolkit.authz.permission.v1~cf.oagw._.upstream_delete.v1"), + resource_type: gts::UPSTREAM_BASE.to_owned(), + action: actions::DELETE.to_owned(), + display_name: "Delete outbound upstream".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: gts_id!("cf.toolkit.authz.permission.v1~cf.oagw._.route_create.v1"), + resource_type: gts::ROUTE_BASE.to_owned(), + action: actions::CREATE.to_owned(), + display_name: "Create outbound route".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: gts_id!("cf.toolkit.authz.permission.v1~cf.oagw._.route_read.v1"), + resource_type: gts::ROUTE_BASE.to_owned(), + action: actions::READ.to_owned(), + display_name: "Read outbound route".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: gts_id!("cf.toolkit.authz.permission.v1~cf.oagw._.route_override.v1"), + resource_type: gts::ROUTE_BASE.to_owned(), + action: actions::OVERRIDE.to_owned(), + display_name: "Replace outbound route".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: gts_id!("cf.toolkit.authz.permission.v1~cf.oagw._.route_delete.v1"), + resource_type: gts::ROUTE_BASE.to_owned(), + action: actions::DELETE.to_owned(), + display_name: "Delete outbound route".to_owned(), + } +} +gts_instance! { + AuthzPermissionV1 { + id: gts_id!("cf.toolkit.authz.permission.v1~cf.oagw._.proxy_invoke.v1"), + resource_type: gts::PROXY_BASE.to_owned(), + action: actions::INVOKE.to_owned(), + display_name: "Invoke the outbound proxy".to_owned(), + } +} + +// --- Type schemas (runtime registration) ----------------------------------- + +/// Publish the OAGW type schemas. +/// +/// Never fails the caller: a registry that refuses an entry is a catalog +/// problem, not a reason to keep the gateway from serving traffic. +pub async fn register_catalog(client: &Arc) { + let entities = catalog_entities(); + let count = entities.len(); + match client.register_type_schemas(entities).await { + Ok(results) => { + let failures: Vec = results + .iter() + .filter_map(|result| match result { + types_registry_sdk::RegisterResult::Err { gts_id, error } => Some(format!( + "{}: {error}", + gts_id.as_deref().unwrap_or("") + )), + types_registry_sdk::RegisterResult::Ok { .. } => None, + }) + .collect(); + if failures.is_empty() { + info!(target: "oagw.types", count, "registered OAGW type schemas"); + } else { + warn!( + target: "oagw.types", + count, + failures = ?failures, + "some OAGW type schemas were rejected by the registry" + ); + } + } + Err(err) => warn!( + target: "oagw.types", + error = %err, + "could not register OAGW type schemas" + ), + } +} + +/// The schema documents OAGW publishes, ordered parent-before-child. +#[must_use] +pub fn catalog_entities() -> Vec { + let mut entities = Vec::new(); + + if let Some(schema) = published_schema(UPSTREAM_SCHEMA, gts::UPSTREAM_BASE) { + entities.push(schema); + } + if let Some(schema) = published_schema(ROUTE_SCHEMA, gts::ROUTE_BASE) { + entities.push(schema); + } + + entities.push(plugin_base_schema( + gts::AUTH_PLUGIN_BASE, + "OAGW auth plugin — injects credentials on the outbound request", + )); + entities.push(plugin_base_schema( + gts::GUARD_PLUGIN_BASE, + "OAGW guard plugin — validates a request or response and may reject it", + )); + entities.push(plugin_base_schema( + gts::TRANSFORM_PLUGIN_BASE, + "OAGW transform plugin — mutates a request, response or error", + )); + entities.push(protocol_base_schema()); + + entities +} + +/// Attach a GTS `$id` to a published JSON Schema document. +fn published_schema(raw: &str, type_id: &str) -> Option { + let mut value: serde_json::Value = serde_json::from_str(raw).ok()?; + let object = value.as_object_mut()?; + object.insert( + "$id".to_owned(), + serde_json::Value::String(format!("gts://{type_id}")), + ); + Some(value) +} + +fn plugin_base_schema(type_id: &str, description: &str) -> serde_json::Value { + serde_json::json!({ + "$id": format!("gts://{type_id}"), + "$schema": "http://json-schema.org/draft-07/schema#", + "description": description, + "type": "object", + "properties": { + "id": { "type": "string", "format": "gts-identifier" }, + "config": { "type": "object" } + } + }) +} + +fn protocol_base_schema() -> serde_json::Value { + serde_json::json!({ + "$id": format!("gts://{}", gts::PROTOCOL_BASE), + "$schema": "http://json-schema.org/draft-07/schema#", + "description": "Protocol spoken to an OAGW upstream service", + "type": "object", + "properties": { + "id": { "type": "string", "format": "gts-identifier" } + } + }) +} + +#[cfg(test)] +#[path = "type_catalog_tests.rs"] +mod tests; diff --git a/gears/system/oagw/oagw/src/infra/type_catalog_tests.rs b/gears/system/oagw/oagw/src/infra/type_catalog_tests.rs new file mode 100644 index 0000000..953bed3 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/type_catalog_tests.rs @@ -0,0 +1,48 @@ +//! The GTS catalog OAGW publishes at boot. + +use super::*; + +#[test] +fn every_catalog_entity_carries_a_gts_id() { + let entities = catalog_entities(); + assert!(!entities.is_empty()); + for entity in &entities { + let id = entity["$id"].as_str().expect("every entity needs an $id"); + assert!(id.starts_with("gts://"), "{id}"); + assert!(id.ends_with('~'), "type schemas end with the chain marker: {id}"); + } +} + +#[test] +fn the_published_resource_schemas_are_the_documented_ones() { + let entities = catalog_entities(); + let upstream = entities + .iter() + .find(|entity| entity["$id"] == format!("gts://{}", gts::UPSTREAM_BASE)) + .expect("the upstream schema is published"); + // Carried verbatim from docs/schemas/upstream.v1.schema.json. + assert_eq!(upstream["title"], "OAGW Upstream Service"); + assert!(upstream["properties"]["server"].is_object()); + + let route = entities + .iter() + .find(|entity| entity["$id"] == format!("gts://{}", gts::ROUTE_BASE)) + .expect("the route schema is published"); + assert_eq!(route["title"], "OAGW Route"); +} + +#[test] +fn the_plugin_and_protocol_base_types_are_published() { + let ids: Vec = catalog_entities() + .iter() + .map(|entity| entity["$id"].as_str().unwrap_or_default().to_owned()) + .collect(); + for base in [ + gts::AUTH_PLUGIN_BASE, + gts::GUARD_PLUGIN_BASE, + gts::TRANSFORM_PLUGIN_BASE, + gts::PROTOCOL_BASE, + ] { + assert!(ids.contains(&format!("gts://{base}")), "{base} must be published"); + } +} diff --git a/gears/system/oagw/oagw/src/lib.rs b/gears/system/oagw/oagw/src/lib.rs index e69de29..e15780a 100644 --- a/gears/system/oagw/oagw/src/lib.rs +++ b/gears/system/oagw/oagw/src/lib.rs @@ -0,0 +1,32 @@ +//! # OAGW — Outbound API Gateway +//! +//! OAGW is the platform's egress gateway: application gears reach external +//! services through it instead of holding credentials and connection details +//! themselves. It is a single ToolKit gear with an internal Control +//! Plane / Data Plane split (see `docs/DESIGN.md`): +//! +//! * **Control Plane** ([`domain::services`]) owns configuration — +//! upstreams, routes and custom plugin definitions — and answers alias +//! resolution queries across the tenant hierarchy. +//! * **Data Plane** ([`infra::proxy`]) executes proxy requests: it resolves the +//! effective configuration, runs the plugin chain (auth → guards → +//! transforms), and forwards the call to the external service, streaming +//! plain HTTP responses, server-sent events and WebSocket upgrades alike. +//! +//! Routes are registered **gear-relative** (`/oagw/v1/...`); the api-gateway +//! nests the gear router under its own `prefix_path`. +#![forbid(unsafe_code)] + +pub mod api; +pub mod config; +pub mod domain; +pub mod gear; +pub mod infra; + +#[cfg(feature = "test-utils")] +pub mod test_utils; + + +pub use config::OagwConfig; +pub use gear::OagwGear; + diff --git a/gears/system/oagw/oagw/src/test_utils.rs b/gears/system/oagw/oagw/src/test_utils.rs new file mode 100644 index 0000000..2f12a1a --- /dev/null +++ b/gears/system/oagw/oagw/src/test_utils.rs @@ -0,0 +1,191 @@ +//! Test harness: an OAGW stack wired to in-process fakes. +//! +//! Behind the `test-utils` feature so the fakes never reach a release build. +//! Everything a test needs to exercise the real code paths — the same +//! Control Plane, Data Plane, plugin registries and router the gear wires in +//! production — with the credential store and the tenant hierarchy replaced by +//! doubles. + +use std::collections::HashMap; +use std::sync::Arc; + +use async_trait::async_trait; +use axum::Router; +use credstore_sdk::{CredStoreClientV1, test_util::MockCredStoreClient}; +use toolkit::api::OpenApiRegistryImpl; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::api::rest::state::OagwState; +use crate::config::OagwConfig; +use crate::domain::repo::{PluginRepository, RouteRepository, UpstreamRepository}; +use crate::domain::services::ControlPlaneService; +use crate::domain::tenant::TenantDirectory; +use crate::infra::metrics::OagwMetrics; +use crate::infra::plugin::PluginRegistries; +use crate::infra::plugin::oauth2_client_cred_auth::TokenCacheConfig; +use crate::infra::proxy::DataPlaneService; +use crate::infra::proxy::connector::UpstreamConnector; +use crate::infra::rate_limit::RateLimiterRegistry; +use crate::infra::storage::InMemoryStore; + +/// A tenant directory backed by a fixed parent map. +#[derive(Debug, Default)] +pub struct StaticTenantDirectory { + parents: HashMap, +} + +impl StaticTenantDirectory { + #[must_use] + pub fn new(parents: Vec<(Uuid, Uuid)>) -> Self { + Self { + parents: parents.into_iter().collect(), + } + } +} + +#[async_trait] +impl TenantDirectory for StaticTenantDirectory { + async fn ancestor_chain(&self, _ctx: &SecurityContext, tenant_id: Uuid) -> Vec { + let mut chain = vec![tenant_id]; + let mut current = tenant_id; + // Bounded walk: a cyclic fixture must not hang a test. + while let Some(parent) = self.parents.get(¤t) { + if chain.contains(parent) { + break; + } + chain.push(*parent); + current = *parent; + } + chain + } +} + +/// A fully wired OAGW stack for tests. +pub struct TestHarness { + pub state: Arc, + pub store: Arc, + pub config: OagwConfig, +} + +/// Builder for [`TestHarness`]. +pub struct HarnessBuilder { + config: OagwConfig, + secrets: Vec<(String, String)>, + parents: Vec<(Uuid, Uuid)>, +} + +impl Default for HarnessBuilder { + fn default() -> Self { + Self { + config: OagwConfig { + // Local fixtures are plaintext loopback servers, which the + // production defaults exist to refuse. + allow_http_upstream: true, + proxy_timeout_secs: 2, + connect_timeout_secs: 2, + ssrf_policy: crate::config::SsrfPolicy { + enabled: false, + ..crate::config::SsrfPolicy::default() + }, + ..OagwConfig::default() + }, + secrets: Vec::new(), + parents: Vec::new(), + } + } +} + +impl HarnessBuilder { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Seed the credential store double. + #[must_use] + pub fn with_secret(mut self, reference: &str, value: &str) -> Self { + self.secrets + .push((reference.to_owned(), value.to_owned())); + self + } + + /// Declare a `(child, parent)` edge in the tenant hierarchy. + #[must_use] + pub fn with_tenant_parent(mut self, child: Uuid, parent: Uuid) -> Self { + self.parents.push((child, parent)); + self + } + + /// Override the gear configuration. + #[must_use] + pub fn with_config(mut self, config: OagwConfig) -> Self { + self.config = config; + self + } + + #[must_use] + pub fn build(self) -> TestHarness { + let store = InMemoryStore::shared(); + let credstore: Arc = + Arc::new(MockCredStoreClient::with_secrets(self.secrets)); + let tenants: Arc = + Arc::new(StaticTenantDirectory::new(self.parents)); + + let control = Arc::new(ControlPlaneService::new( + Arc::clone(&store) as Arc, + Arc::clone(&store) as Arc, + Arc::clone(&store) as Arc, + tenants, + )); + let registries = Arc::new(PluginRegistries::with_builtins( + credstore, + TokenCacheConfig::default(), + )); + let data_plane = Arc::new(DataPlaneService::new( + Arc::clone(&control), + UpstreamConnector::shared(&self.config), + registries, + Arc::clone(&store) as Arc, + Arc::new(RateLimiterRegistry::new()), + Arc::new(OagwMetrics::from_global()), + self.config.clone(), + )); + + let state = Arc::new(OagwState { + control, + data_plane, + store: Arc::clone(&store), + authz: None, + config: self.config.clone(), + }); + + TestHarness { + state, + store, + config: self.config, + } + } +} + +impl TestHarness { + /// A router carrying the real OAGW routes, with `ctx` injected the way the + /// api-gateway's auth middleware would. + #[must_use] + pub fn router(&self, ctx: SecurityContext) -> Router { + let openapi = OpenApiRegistryImpl::new(); + let router = crate::api::rest::register_routes(Router::new(), &openapi, Arc::clone(&self.state)); + router.layer(axum::Extension(ctx)) + } +} + +/// A `SecurityContext` for `tenant_id` with a stable subject. +#[must_use] +pub fn security_context(tenant_id: Uuid) -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::from_u128(0x5eed)) + .subject_tenant_id(tenant_id) + .token_scopes(vec!["*".to_owned()]) + .build() + .unwrap_or_else(|_| SecurityContext::anonymous()) +} diff --git a/gears/system/oagw/oagw/tests/common/mod.rs b/gears/system/oagw/oagw/tests/common/mod.rs new file mode 100644 index 0000000..bc7c39a --- /dev/null +++ b/gears/system/oagw/oagw/tests/common/mod.rs @@ -0,0 +1,428 @@ +//! Shared fixtures for the OAGW integration tests. +//! +//! The mock upstream is a raw `tokio` listener rather than a framework: the +//! tests need to exercise chunked bodies, server-sent events and a protocol +//! upgrade, and a hand-written responder is the only way to control the bytes +//! on the wire that precisely. + +#![allow(dead_code, reason = "each integration test binary uses a subset")] + +use std::net::SocketAddr; +use std::sync::Arc; +use std::time::Duration; + +use axum::Router; +use axum::body::{Body, to_bytes}; +use axum::response::Response; +use http::{Request, StatusCode}; +use oagw::test_utils::{HarnessBuilder, TestHarness, security_context}; +use serde_json::Value; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::Mutex; +use tower::ServiceExt; +use uuid::Uuid; + +pub const HTTP_PROTOCOL: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; + +/// One recorded inbound request at the mock upstream. +#[derive(Debug, Clone, Default)] +pub struct RecordedRequest { + pub head: String, + pub body: String, +} + +impl RecordedRequest { + /// Value of `name`, matched case-insensitively. + #[must_use] + pub fn header(&self, name: &str) -> Option { + self.head + .lines() + .skip(1) + .filter_map(|line| line.split_once(':')) + .find(|(key, _)| key.trim().eq_ignore_ascii_case(name)) + .map(|(_, value)| value.trim().to_owned()) + } + + /// The request line, e.g. `GET /v1/chat HTTP/1.1`. + #[must_use] + pub fn request_line(&self) -> &str { + self.head.lines().next().unwrap_or_default() + } + + /// The request target from the request line. + #[must_use] + pub fn target(&self) -> String { + self.request_line() + .split_whitespace() + .nth(1) + .unwrap_or_default() + .to_owned() + } +} + +/// How the mock upstream answers. +#[derive(Clone)] +pub enum MockBehavior { + /// A fixed status, content type and body. + Fixed { + status: u16, + content_type: &'static str, + body: String, + }, + /// A `text/event-stream` that emits `events`, pausing `gap` between them. + Sse { events: Vec, gap: Duration }, + /// Wait `delay` before answering — for timeout coverage. + Slow { delay: Duration }, + /// Complete a WebSocket handshake and echo every subsequent byte. + WebSocketEcho, + /// Refuse an upgrade with an ordinary response. + WebSocketRefused, + /// Close the connection without answering. + Hangup, +} + +/// A minimal HTTP/1.1 upstream under the test's control. +pub struct MockUpstream { + pub addr: SocketAddr, + requests: Arc>>, +} + +impl MockUpstream { + /// Bind on an ephemeral loopback port and serve `behavior` on every + /// connection until the test drops. + pub async fn start(behavior: MockBehavior) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind mock"); + let addr = listener.local_addr().expect("mock addr"); + let requests = Arc::new(Mutex::new(Vec::new())); + let recorder = Arc::clone(&requests); + + tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + break; + }; + let behavior = behavior.clone(); + let recorder = Arc::clone(&recorder); + tokio::spawn(async move { + let _ = serve_connection(stream, behavior, recorder).await; + }); + } + }); + + Self { addr, requests } + } + + /// Everything the upstream has seen so far. + pub async fn requests(&self) -> Vec { + self.requests.lock().await.clone() + } + + /// The most recent request, panicking when there is none. + pub async fn last_request(&self) -> RecordedRequest { + self.requests + .lock() + .await + .last() + .cloned() + .expect("the upstream should have been called") + } + + #[must_use] + pub fn port(&self) -> u16 { + self.addr.port() + } +} + +async fn serve_connection( + mut stream: TcpStream, + behavior: MockBehavior, + recorder: Arc>>, +) -> std::io::Result<()> { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 4096]; + + // Read the head. + let head_end = loop { + let read = stream.read(&mut chunk).await?; + if read == 0 { + return Ok(()); + } + buffer.extend_from_slice(&chunk[..read]); + if let Some(position) = buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + { + break position + 4; + } + }; + + let head = String::from_utf8_lossy(&buffer[..head_end]).into_owned(); + let content_length = head + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(key, _)| key.trim().eq_ignore_ascii_case("content-length")) + .and_then(|(_, value)| value.trim().parse::().ok()) + .unwrap_or(0); + + let mut body = buffer[head_end..].to_vec(); + while body.len() < content_length { + let read = stream.read(&mut chunk).await?; + if read == 0 { + break; + } + body.extend_from_slice(&chunk[..read]); + } + + recorder.lock().await.push(RecordedRequest { + head: head.clone(), + body: String::from_utf8_lossy(&body).into_owned(), + }); + + match behavior { + MockBehavior::Fixed { + status, + content_type, + body, + } => { + let response = format!( + "HTTP/1.1 {status} OK\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\n\ + Connection: close\r\n\r\n{body}", + body.len() + ); + stream.write_all(response.as_bytes()).await?; + } + MockBehavior::Sse { events, gap } => { + stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\n\ + Cache-Control: no-cache\r\nConnection: close\r\n\r\n", + ) + .await?; + stream.flush().await?; + for event in events { + stream + .write_all(format!("data: {event}\n\n").as_bytes()) + .await?; + stream.flush().await?; + tokio::time::sleep(gap).await; + } + } + MockBehavior::Slow { delay } => { + tokio::time::sleep(delay).await; + stream + .write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok") + .await?; + } + MockBehavior::WebSocketEcho => { + let accept = websocket_accept(&head); + stream + .write_all( + format!( + "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\n\ + Connection: Upgrade\r\nSec-WebSocket-Accept: {accept}\r\n\r\n" + ) + .as_bytes(), + ) + .await?; + stream.flush().await?; + // Opaque byte echo — the proxy must not interpret the frames. + loop { + let read = stream.read(&mut chunk).await?; + if read == 0 { + break; + } + stream.write_all(&chunk[..read]).await?; + stream.flush().await?; + } + } + MockBehavior::WebSocketRefused => { + let body = "{\"error\":\"upgrade refused\"}"; + stream + .write_all( + format!( + "HTTP/1.1 426 Upgrade Required\r\nContent-Type: application/json\r\n\ + Content-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + .as_bytes(), + ) + .await?; + } + MockBehavior::Hangup => {} + } + + stream.flush().await?; + Ok(()) +} + +/// RFC 6455 handshake accept value for the key in `head`. +fn websocket_accept(head: &str) -> String { + const GUID: &str = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; + let key = head + .lines() + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.trim().eq_ignore_ascii_case("sec-websocket-key")) + .map(|(_, value)| value.trim().to_owned()) + .unwrap_or_default(); + // The tests only assert the header is present and stable, so a digest that + // is deterministic in the key is enough here. + let mut digest: u64 = 1469598103934665603; + for byte in format!("{key}{GUID}").bytes() { + digest ^= u64::from(byte); + digest = digest.wrapping_mul(1099511628211); + } + format!("{digest:016x}") +} + +/// A harness plus the tenant its requests run as. +pub struct Fixture { + pub harness: TestHarness, + pub tenant: Uuid, +} + +impl Fixture { + #[must_use] + pub fn new() -> Self { + Self::with_builder(HarnessBuilder::new()) + } + + #[must_use] + pub fn with_builder(builder: HarnessBuilder) -> Self { + Self { + harness: builder.build(), + tenant: Uuid::new_v4(), + } + } + + #[must_use] + pub fn router(&self) -> Router { + self.harness.router(security_context(self.tenant)) + } + + /// Router acting as `tenant`, for cross-tenant assertions. + #[must_use] + pub fn router_as(&self, tenant: Uuid) -> Router { + self.harness.router(security_context(tenant)) + } + + /// Send `request` through a freshly cloned router. + pub async fn send(&self, request: Request) -> Response { + self.router() + .oneshot(request) + .await + .expect("the router is infallible") + } + + /// Send `request` as `tenant`. + pub async fn send_as(&self, tenant: Uuid, request: Request) -> Response { + self.router_as(tenant) + .oneshot(request) + .await + .expect("the router is infallible") + } + + /// `POST` a JSON body and return `(status, parsed body)`. + pub async fn post_json(&self, path: &str, body: Value) -> (StatusCode, Value) { + let response = self.send(json_request("POST", path, &body)).await; + split(response).await + } + + /// `PUT` a JSON body and return `(status, parsed body)`. + pub async fn put_json(&self, path: &str, body: Value) -> (StatusCode, Value) { + let response = self.send(json_request("PUT", path, &body)).await; + split(response).await + } + + /// `GET` and return `(status, parsed body)`. + pub async fn get(&self, path: &str) -> (StatusCode, Value) { + let response = self.send(empty_request("GET", path)).await; + split(response).await + } + + /// `DELETE` and return the status. + pub async fn delete(&self, path: &str) -> StatusCode { + self.send(empty_request("DELETE", path)).await.status() + } + + /// Create an upstream, asserting it was accepted. + pub async fn create_upstream(&self, body: Value) -> Value { + let (status, value) = self.post_json("/oagw/v1/upstreams", body).await; + assert_eq!(status, StatusCode::CREATED, "create upstream: {value}"); + value + } + + /// Create a route, asserting it was accepted. + pub async fn create_route(&self, body: Value) -> Value { + let (status, value) = self.post_json("/oagw/v1/routes", body).await; + assert_eq!(status, StatusCode::CREATED, "create route: {value}"); + value + } + + /// Wire an upstream + catch-all route at `alias` pointing at `port`. + pub async fn wire_upstream(&self, alias: &str, port: u16, methods: Value) -> Uuid { + let upstream = self + .create_upstream(serde_json::json!({ + "alias": alias, + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": port}]}, + "protocol": HTTP_PROTOCOL, + })) + .await; + let id = Uuid::parse_str(upstream["id"].as_str().expect("id")).expect("uuid"); + self.create_route(serde_json::json!({ + "upstream_id": id, + "match": {"http": {"methods": methods, "path": "/"}}, + })) + .await; + id + } +} + +impl Default for Fixture { + fn default() -> Self { + Self::new() + } +} + +/// Build a JSON request. +#[must_use] +pub fn json_request(method: &str, path: &str, body: &Value) -> Request { + Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .expect("valid request") +} + +/// Build a bodyless request. +#[must_use] +pub fn empty_request(method: &str, path: &str) -> Request { + Request::builder() + .method(method) + .uri(path) + .body(Body::empty()) + .expect("valid request") +} + +/// Split a response into its status and parsed JSON body. +pub async fn split(response: Response) -> (StatusCode, Value) { + let status = response.status(); + let bytes = to_bytes(response.into_body(), 8 * 1024 * 1024) + .await + .expect("body"); + if bytes.is_empty() { + return (status, Value::Null); + } + let value = serde_json::from_slice(&bytes) + .unwrap_or_else(|_| Value::String(String::from_utf8_lossy(&bytes).into_owned())); + (status, value) +} + +/// Read a response body as text. +pub async fn body_text(response: Response) -> String { + let bytes = to_bytes(response.into_body(), 8 * 1024 * 1024) + .await + .expect("body"); + String::from_utf8_lossy(&bytes).into_owned() +} diff --git a/gears/system/oagw/oagw/tests/management_api.rs b/gears/system/oagw/oagw/tests/management_api.rs new file mode 100644 index 0000000..8ecc1e4 --- /dev/null +++ b/gears/system/oagw/oagw/tests/management_api.rs @@ -0,0 +1,547 @@ +//! Management API: routes, bodies, status codes and validation, exercised +//! through the real router the gear registers. + +mod common; + +use common::{Fixture, HTTP_PROTOCOL, empty_request, json_request}; +use http::StatusCode; +use oagw::test_utils::HarnessBuilder; +use serde_json::json; +use uuid::Uuid; + +fn hostname_upstream() -> serde_json::Value { + json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.openai.com", "port": 443}]}, + "protocol": HTTP_PROTOCOL, + }) +} + +// -- Routes are registered gear-relative ------------------------------------ + +#[tokio::test] +async fn the_management_api_is_served_under_the_gear_relative_prefix() { + let fixture = Fixture::new(); + let (status, _) = fixture.get("/oagw/v1/upstreams").await; + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn the_gear_does_not_repeat_the_gateway_prefix_itself() { + // The api-gateway nests this router under its own `prefix_path`; a gear + // that also registered `/api/...` would answer on the wrong path. + let fixture = Fixture::new(); + let response = fixture.send(empty_request("GET", "/api/oagw/v1/upstreams")).await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +// -- Upstream CRUD ---------------------------------------------------------- + +#[tokio::test] +async fn creating_an_upstream_answers_201_with_the_resource_and_a_location() { + let fixture = Fixture::new(); + let response = fixture + .send(json_request("POST", "/oagw/v1/upstreams", &hostname_upstream())) + .await; + + assert_eq!(response.status(), StatusCode::CREATED); + let location = response + .headers() + .get(http::header::LOCATION) + .expect("a Location header") + .to_str() + .unwrap() + .to_owned(); + let (_, body) = common::split(response).await; + + let id = body["id"].as_str().expect("an id"); + assert_eq!(location, format!("/oagw/v1/upstreams/{id}")); + assert_eq!(body["alias"], "api.openai.com"); + assert_eq!(body["enabled"], true); + assert_eq!(body["protocol"], HTTP_PROTOCOL); +} + +#[tokio::test] +async fn an_upstream_can_be_read_back_by_uuid_and_by_gts_identifier() { + let fixture = Fixture::new(); + let created = fixture.create_upstream(hostname_upstream()).await; + let id = created["id"].as_str().unwrap(); + + let (status, by_uuid) = fixture.get(&format!("/oagw/v1/upstreams/{id}")).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(by_uuid["id"], id); + + let (status, by_gts) = fixture + .get(&format!("/oagw/v1/upstreams/gts.cf.core.oagw.upstream.v1~{id}")) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(by_gts["id"], id); +} + +#[tokio::test] +async fn a_malformed_identifier_is_a_validation_error() { + let fixture = Fixture::new(); + let (status, body) = fixture.get("/oagw/v1/upstreams/not-a-uuid").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); +} + +#[tokio::test] +async fn a_missing_upstream_is_a_404_route_not_found() { + let fixture = Fixture::new(); + let (status, body) = fixture + .get(&format!("/oagw/v1/upstreams/{}", Uuid::new_v4())) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); +} + +#[tokio::test] +async fn replacing_an_upstream_answers_200_with_the_new_representation() { + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.openai.com", "port": 443}]}, + "protocol": HTTP_PROTOCOL, + "tags": ["llm"], + })) + .await; + let id = created["id"].as_str().unwrap(); + + let (status, replaced) = fixture + .put_json(&format!("/oagw/v1/upstreams/{id}"), hostname_upstream()) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(replaced["id"], id); + // A full replacement clears omitted optional fields. + assert_eq!(replaced["tags"], json!([])); +} + +#[tokio::test] +async fn deleting_an_upstream_answers_204_and_removes_it() { + let fixture = Fixture::new(); + let created = fixture.create_upstream(hostname_upstream()).await; + let id = created["id"].as_str().unwrap(); + + assert_eq!( + fixture.delete(&format!("/oagw/v1/upstreams/{id}")).await, + StatusCode::NO_CONTENT + ); + let (status, _) = fixture.get(&format!("/oagw/v1/upstreams/{id}")).await; + assert_eq!(status, StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn a_duplicate_alias_answers_409() { + let fixture = Fixture::new(); + fixture.create_upstream(hostname_upstream()).await; + let (status, _) = fixture.post_json("/oagw/v1/upstreams", hostname_upstream()).await; + assert_eq!(status, StatusCode::CONFLICT); +} + +// -- Validation ------------------------------------------------------------- + +#[tokio::test] +async fn a_missing_body_is_a_validation_error() { + let fixture = Fixture::new(); + let response = fixture.send(empty_request("POST", "/oagw/v1/upstreams")).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn malformed_json_is_a_400_not_a_422() { + let fixture = Fixture::new(); + let request = http::Request::builder() + .method("POST") + .uri("/oagw/v1/upstreams") + .header("content-type", "application/json") + .body(axum::body::Body::from("{ not json")) + .unwrap(); + let response = fixture.send(request).await; + assert_eq!(response.status(), StatusCode::BAD_REQUEST); + let (_, body) = common::split(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); +} + +#[tokio::test] +async fn a_missing_required_field_is_a_validation_error() { + let fixture = Fixture::new(); + let (status, _) = fixture + .post_json("/oagw/v1/upstreams", json!({"protocol": HTTP_PROTOCOL})) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn an_unknown_member_is_rejected_as_the_schema_requires() { + // `schemas/upstream.v1.schema.json` sets `additionalProperties: false`. + let fixture = Fixture::new(); + let mut body = hostname_upstream(); + body["not_a_field"] = json!(true); + let (status, _) = fixture.post_json("/oagw/v1/upstreams", body).await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn a_get_response_can_be_put_straight_back() { + // `id` is read-only but accepted, so a client can round-trip a resource. + let fixture = Fixture::new(); + let created = fixture.create_upstream(hostname_upstream()).await; + let id = created["id"].as_str().unwrap().to_owned(); + + let (status, _) = fixture + .put_json(&format!("/oagw/v1/upstreams/{id}"), created) + .await; + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn the_http_scheme_is_accepted_by_the_management_api() { + // `allow_http_upstream` governs whether a plaintext connection is dialled, + // not which schemes the API admits. + let fixture = Fixture::new(); + let (status, body) = fixture + .post_json( + "/oagw/v1/upstreams", + json!({ + "alias": "local", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": 80}]}, + "protocol": HTTP_PROTOCOL, + }), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{body}"); + assert_eq!(body["server"]["endpoints"][0]["scheme"], "http"); +} + +#[tokio::test] +async fn an_unknown_scheme_is_still_rejected() { + let fixture = Fixture::new(); + let (status, _) = fixture + .post_json( + "/oagw/v1/upstreams", + json!({ + "alias": "weird", + "server": {"endpoints": [{"scheme": "gopher", "host": "127.0.0.1", "port": 70}]}, + "protocol": HTTP_PROTOCOL, + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +// -- Listing ---------------------------------------------------------------- + +#[tokio::test] +async fn listing_returns_an_envelope_with_items_and_a_total() { + let fixture = Fixture::new(); + fixture.create_upstream(hostname_upstream()).await; + fixture + .create_upstream(json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.stripe.com", "port": 443}]}, + "protocol": HTTP_PROTOCOL, + })) + .await; + + let (status, body) = fixture.get("/oagw/v1/upstreams").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body["total"], 2); + assert_eq!(body["items"].as_array().unwrap().len(), 2); +} + +#[tokio::test] +async fn listing_supports_the_documented_query_parameters() { + let fixture = Fixture::new(); + for host in ["api.openai.com", "api.stripe.com", "api.twilio.com"] { + fixture + .create_upstream(json!({ + "server": {"endpoints": [{"scheme": "https", "host": host, "port": 443}]}, + "protocol": HTTP_PROTOCOL, + })) + .await; + } + + let (_, filtered) = fixture + .get("/oagw/v1/upstreams?%24filter=alias%20eq%20%27api.stripe.com%27") + .await; + assert_eq!(filtered["total"], 1); + assert_eq!(filtered["items"][0]["alias"], "api.stripe.com"); + + let (_, page) = fixture + .get("/oagw/v1/upstreams?%24orderby=alias&%24top=1&%24skip=1") + .await; + assert_eq!(page["total"], 3, "total counts matches before paging"); + assert_eq!(page["items"].as_array().unwrap().len(), 1); + assert_eq!(page["items"][0]["alias"], "api.stripe.com"); + + let (_, projected) = fixture.get("/oagw/v1/upstreams?%24select=id,alias").await; + let first = projected["items"][0].as_object().unwrap(); + assert_eq!(first.len(), 2); +} + +#[tokio::test] +async fn a_malformed_query_parameter_is_a_validation_error() { + let fixture = Fixture::new(); + let (status, _) = fixture.get("/oagw/v1/upstreams?%24top=lots").await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +// -- Tenant scoping --------------------------------------------------------- + +#[tokio::test] +async fn another_tenant_can_neither_see_nor_touch_the_resource() { + let fixture = Fixture::new(); + let created = fixture.create_upstream(hostname_upstream()).await; + let id = created["id"].as_str().unwrap(); + let intruder = Uuid::new_v4(); + + let response = fixture + .send_as(intruder, empty_request("GET", &format!("/oagw/v1/upstreams/{id}"))) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = fixture + .send_as( + intruder, + empty_request("DELETE", &format!("/oagw/v1/upstreams/{id}")), + ) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); + + let response = fixture + .send_as(intruder, empty_request("GET", "/oagw/v1/upstreams")) + .await; + let (_, body) = common::split(response).await; + assert_eq!(body["total"], 0); +} + +// -- Routes ----------------------------------------------------------------- + +#[tokio::test] +async fn creating_a_route_answers_201_and_normalizes_the_match() { + let fixture = Fixture::new(); + let upstream = fixture.create_upstream(hostname_upstream()).await; + let route = fixture + .create_route(json!({ + "upstream_id": upstream["id"], + "match": {"http": {"methods": ["get"], "path": "v1/chat/"}}, + })) + .await; + + assert_eq!(route["upstream_id"], upstream["id"]); + assert_eq!(route["match"]["http"]["path"], "/v1/chat"); + assert_eq!(route["match"]["http"]["methods"], json!(["GET"])); + assert_eq!(route["match"]["http"]["path_suffix_mode"], "append"); + assert_eq!(route["enabled"], true); +} + +#[tokio::test] +async fn a_route_on_an_unknown_upstream_is_a_validation_error() { + let fixture = Fixture::new(); + let (status, _) = fixture + .post_json( + "/oagw/v1/routes", + json!({ + "upstream_id": Uuid::new_v4(), + "match": {"http": {"methods": ["GET"], "path": "/"}}, + }), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn a_duplicate_match_rule_answers_409() { + let fixture = Fixture::new(); + let upstream = fixture.create_upstream(hostname_upstream()).await; + let body = json!({ + "upstream_id": upstream["id"], + "match": {"http": {"methods": ["GET"], "path": "/v1"}}, + }); + fixture.create_route(body.clone()).await; + let (status, _) = fixture.post_json("/oagw/v1/routes", body).await; + assert_eq!(status, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn deleting_an_upstream_takes_its_routes_with_it() { + let fixture = Fixture::new(); + let upstream = fixture.create_upstream(hostname_upstream()).await; + fixture + .create_route(json!({ + "upstream_id": upstream["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })) + .await; + + fixture + .delete(&format!( + "/oagw/v1/upstreams/{}", + upstream["id"].as_str().unwrap() + )) + .await; + + let (_, routes) = fixture.get("/oagw/v1/routes").await; + assert_eq!(routes["total"], 0); +} + +// -- Plugins ---------------------------------------------------------------- + +#[tokio::test] +async fn a_custom_plugin_is_created_read_and_deleted() { + let fixture = Fixture::new(); + let (status, plugin) = fixture + .post_json( + "/oagw/v1/plugins", + json!({ + "name": "redact_pii", + "plugin_type": "transform", + "phases": ["on_response"], + "config_schema": {"type": "object"}, + "source_code": "def on_response(ctx):\n return ctx.next()\n", + }), + ) + .await; + assert_eq!(status, StatusCode::CREATED, "{plugin}"); + let id = plugin["id"].as_str().unwrap().to_owned(); + assert_eq!( + plugin["gts_id"], + format!("gts.cf.core.oagw.transform_plugin.v1~{id}") + ); + // A listing must not carry script bodies. + assert!(plugin.get("source_code").is_none()); + + let (status, source) = fixture + .get(&format!("/oagw/v1/plugins/{id}/source")) + .await; + assert_eq!(status, StatusCode::OK); + assert!(source["source_code"].as_str().unwrap().contains("on_response")); + + assert_eq!( + fixture.delete(&format!("/oagw/v1/plugins/{id}")).await, + StatusCode::NO_CONTENT + ); +} + +#[tokio::test] +async fn a_plugin_that_is_still_bound_cannot_be_deleted() { + let fixture = Fixture::new(); + let (_, plugin) = fixture + .post_json( + "/oagw/v1/plugins", + json!({ + "name": "guard", + "plugin_type": "guard", + "source_code": "def on_request(ctx): pass", + }), + ) + .await; + let id = plugin["id"].as_str().unwrap().to_owned(); + + fixture + .create_upstream(json!({ + "server": {"endpoints": [{"scheme": "https", "host": "api.openai.com", "port": 443}]}, + "protocol": HTTP_PROTOCOL, + "plugins": {"items": [format!("gts.cf.core.oagw.guard_plugin.v1~{id}")]}, + })) + .await; + + let response = fixture + .send(empty_request("DELETE", &format!("/oagw/v1/plugins/{id}"))) + .await; + assert_eq!(response.status(), StatusCode::CONFLICT); + let (_, body) = common::split(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1" + ); + assert_eq!(body["referenced_by"]["upstreams"].as_array().unwrap().len(), 1); +} + +#[tokio::test] +async fn a_duplicate_plugin_name_answers_409() { + let fixture = Fixture::new(); + let body = json!({ + "name": "guard", + "plugin_type": "guard", + "source_code": "def on_request(ctx): pass", + }); + let (status, _) = fixture.post_json("/oagw/v1/plugins", body.clone()).await; + assert_eq!(status, StatusCode::CREATED); + let (status, _) = fixture.post_json("/oagw/v1/plugins", body).await; + assert_eq!(status, StatusCode::CONFLICT); +} + +#[tokio::test] +async fn plugins_are_immutable_so_there_is_no_replace_verb() { + let fixture = Fixture::new(); + let (_, plugin) = fixture + .post_json( + "/oagw/v1/plugins", + json!({ + "name": "guard", + "plugin_type": "guard", + "source_code": "def on_request(ctx): pass", + }), + ) + .await; + let id = plugin["id"].as_str().unwrap(); + + let response = fixture + .send(json_request( + "PUT", + &format!("/oagw/v1/plugins/{id}"), + &json!({"name": "guard"}), + )) + .await; + assert_eq!(response.status(), StatusCode::METHOD_NOT_ALLOWED); +} + +// -- Errors carry the gateway marker ---------------------------------------- + +#[tokio::test] +async fn every_management_answer_states_who_produced_it() { + let fixture = Fixture::new(); + let response = fixture + .send(json_request("POST", "/oagw/v1/upstreams", &hostname_upstream())) + .await; + assert_eq!( + response.headers().get("x-oagw-error-source").unwrap(), + "gateway" + ); + + let response = fixture.send(empty_request("GET", "/oagw/v1/upstreams/nope")).await; + assert_eq!( + response.headers().get("x-oagw-error-source").unwrap(), + "gateway" + ); +} + +// -- Hierarchy -------------------------------------------------------------- + +#[tokio::test] +async fn an_ancestor_upstream_stays_invisible_to_the_management_api() { + let parent = Uuid::new_v4(); + let child = Uuid::new_v4(); + let fixture = Fixture::with_builder(HarnessBuilder::new().with_tenant_parent(child, parent)); + + let response = fixture + .send_as(parent, json_request("POST", "/oagw/v1/upstreams", &hostname_upstream())) + .await; + assert_eq!(response.status(), StatusCode::CREATED); + let (_, created) = common::split(response).await; + let id = created["id"].as_str().unwrap(); + + let response = fixture + .send_as(child, empty_request("GET", &format!("/oagw/v1/upstreams/{id}"))) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} diff --git a/gears/system/oagw/oagw/tests/proxy_http.rs b/gears/system/oagw/oagw/tests/proxy_http.rs new file mode 100644 index 0000000..9dd109e --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_http.rs @@ -0,0 +1,1108 @@ +//! Proxy behaviour over plain HTTP and server-sent events, against a mock +//! upstream whose bytes the test controls. + +mod common; + +use std::time::{Duration, Instant}; + +use axum::body::Body; +use common::{Fixture, HTTP_PROTOCOL, MockBehavior, MockUpstream, body_text, empty_request}; +use futures_util::StreamExt; +use http::{Request, StatusCode}; +use oagw::OagwConfig; +use oagw::test_utils::HarnessBuilder; +use serde_json::json; +use uuid::Uuid; + +fn echo(body: &str) -> MockBehavior { + MockBehavior::Fixed { + status: 200, + content_type: "application/json", + body: body.to_owned(), + } +} + +fn permissive_config() -> OagwConfig { + OagwConfig { + allow_http_upstream: true, + proxy_timeout_secs: 2, + connect_timeout_secs: 2, + ssrf_policy: oagw::config::SsrfPolicy { + enabled: false, + ..oagw::config::SsrfPolicy::default() + }, + ..OagwConfig::default() + } +} + +// -- The happy path --------------------------------------------------------- + +#[tokio::test] +async fn a_get_is_forwarded_and_the_response_relayed() { + let upstream = MockUpstream::start(echo(r#"{"ok":true}"#)).await; + let fixture = Fixture::new(); + fixture.wire_upstream("mock", upstream.port(), json!(["GET"])).await; + + let response = fixture + .send(empty_request("GET", "/oagw/v1/proxy/mock/v1/models")) + .await; + + assert_eq!(response.status(), StatusCode::OK); + // The relayed answer states that it came from the upstream. + assert_eq!( + response.headers().get("x-oagw-error-source").unwrap(), + "upstream" + ); + assert_eq!( + response.headers().get(http::header::CONTENT_TYPE).unwrap(), + "application/json" + ); + assert_eq!(body_text(response).await, r#"{"ok":true}"#); + + let seen = upstream.last_request().await; + assert_eq!(seen.request_line(), "GET /v1/models HTTP/1.1"); + // Host is replaced by the upstream authority. + assert_eq!( + seen.header("host").as_deref(), + Some(format!("127.0.0.1:{}", upstream.port()).as_str()) + ); +} + +#[tokio::test] +async fn a_post_body_and_its_content_type_reach_the_upstream() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + fixture + .wire_upstream("mock", upstream.port(), json!(["GET", "POST"])) + .await; + + let request = Request::builder() + .method("POST") + .uri("/oagw/v1/proxy/mock/v1/chat") + .header("content-type", "application/json") + .body(Body::from(r#"{"model":"gpt-4"}"#)) + .unwrap(); + let response = fixture.send(request).await; + assert_eq!(response.status(), StatusCode::OK); + + let seen = upstream.last_request().await; + assert_eq!(seen.body, r#"{"model":"gpt-4"}"#); + assert_eq!(seen.header("content-type").as_deref(), Some("application/json")); + assert_eq!(seen.header("content-length").as_deref(), Some("17")); +} + +#[tokio::test] +async fn the_root_proxy_path_works_without_a_suffix() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + fixture.wire_upstream("mock", upstream.port(), json!(["GET"])).await; + + let response = fixture.send(empty_request("GET", "/oagw/v1/proxy/mock")).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(upstream.last_request().await.target(), "/"); +} + +// -- Header hygiene --------------------------------------------------------- + +#[tokio::test] +async fn hop_by_hop_and_credential_headers_never_reach_the_upstream() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + let id = fixture + .create_upstream(json!({ + "alias": "mock", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}]}, + "protocol": HTTP_PROTOCOL, + // Even the most permissive passthrough must not leak the caller's + // platform credentials to a third party. + "headers": {"request": {"passthrough": "all"}}, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": id["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })) + .await; + + let request = Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/mock/x") + .header("authorization", "Bearer platform-token") + .header("cookie", "session=secret") + .header("te", "trailers") + .header("x-oagw-target-host", "127.0.0.1") + .header("x-keep-me", "yes") + .body(Body::empty()) + .unwrap(); + fixture.send(request).await; + + let seen = upstream.last_request().await; + assert_eq!(seen.header("authorization"), None); + assert_eq!(seen.header("cookie"), None); + assert_eq!(seen.header("te"), None); + assert_eq!(seen.header("x-oagw-target-host"), None); + assert_eq!(seen.header("x-keep-me").as_deref(), Some("yes")); +} + +#[tokio::test] +async fn passthrough_none_forwards_nothing_beyond_the_entity_headers() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + fixture.wire_upstream("mock", upstream.port(), json!(["GET"])).await; + + let request = Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/mock/x") + .header("x-caller", "acme") + .body(Body::empty()) + .unwrap(); + fixture.send(request).await; + + assert_eq!(upstream.last_request().await.header("x-caller"), None); +} + +#[tokio::test] +async fn the_configured_header_rules_are_applied_in_order() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "alias": "mock", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}]}, + "protocol": HTTP_PROTOCOL, + "headers": { + "request": { + "passthrough": "allowlist", + "passthrough_allowlist": ["x-keep", "x-drop"], + "remove": ["x-drop"], + "set": {"x-tenant": "acme"}, + "add": {"x-trace": "on"} + }, + "response": {"set": {"x-served-by": "oagw"}, "remove": ["content-type"]} + }, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })) + .await; + + let request = Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/mock/x") + .header("x-keep", "kept") + .header("x-drop", "dropped") + .body(Body::empty()) + .unwrap(); + let response = fixture.send(request).await; + + assert_eq!(response.headers().get("x-served-by").unwrap(), "oagw"); + assert!(response.headers().get(http::header::CONTENT_TYPE).is_none()); + + let seen = upstream.last_request().await; + assert_eq!(seen.header("x-keep").as_deref(), Some("kept")); + assert_eq!(seen.header("x-drop"), None); + assert_eq!(seen.header("x-tenant").as_deref(), Some("acme")); + assert_eq!(seen.header("x-trace").as_deref(), Some("on")); +} + +// -- Routing and validation ------------------------------------------------- + +#[tokio::test] +async fn an_unknown_alias_is_a_404_route_not_found() { + let fixture = Fixture::new(); + let (status, body) = fixture.get("/oagw/v1/proxy/nope/x").await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); + assert_eq!(body["alias"], "nope"); +} + +#[tokio::test] +async fn a_method_no_route_matches_is_a_404() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + fixture.wire_upstream("mock", upstream.port(), json!(["GET"])).await; + + let response = fixture + .send(empty_request("DELETE", "/oagw/v1/proxy/mock/x")) + .await; + assert_eq!(response.status(), StatusCode::NOT_FOUND); +} + +#[tokio::test] +async fn a_disabled_upstream_is_503_not_404() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "alias": "mock", + "enabled": false, + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}]}, + "protocol": HTTP_PROTOCOL, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })) + .await; + + let (status, body) = fixture.get("/oagw/v1/proxy/mock/x").await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1" + ); +} + +#[tokio::test] +async fn an_unlisted_query_parameter_is_rejected_and_a_listed_one_is_forwarded() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "alias": "mock", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}]}, + "protocol": HTTP_PROTOCOL, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/", "query_allowlist": ["model"]}}, + })) + .await; + + let (status, _) = fixture.get("/oagw/v1/proxy/mock/x?model=gpt-4").await; + assert_eq!(status, StatusCode::OK); + assert_eq!(upstream.last_request().await.target(), "/x?model=gpt-4"); + + let (status, body) = fixture.get("/oagw/v1/proxy/mock/x?secret=1").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); +} + +#[tokio::test] +async fn a_route_with_the_suffix_disabled_refuses_a_deeper_path() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "alias": "mock", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}]}, + "protocol": HTTP_PROTOCOL, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": { + "http": {"methods": ["GET"], "path": "/v1/models", "path_suffix_mode": "disabled"} + }, + })) + .await; + + let (status, _) = fixture.get("/oagw/v1/proxy/mock/v1/models").await; + assert_eq!(status, StatusCode::OK); + + let (status, _) = fixture.get("/oagw/v1/proxy/mock/v1/models/gpt-4").await; + assert_eq!(status, StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn the_longest_matching_route_decides_the_upstream_path() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "alias": "mock", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}]}, + "protocol": HTTP_PROTOCOL, + })) + .await; + for path in ["/", "/v1", "/v1/chat"] { + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": path}}, + })) + .await; + } + + let (status, _) = fixture.get("/oagw/v1/proxy/mock/v1/chat/completions").await; + assert_eq!(status, StatusCode::OK); + // The suffix is appended to the matched route path, which reproduces the + // caller's path exactly. + assert_eq!(upstream.last_request().await.target(), "/v1/chat/completions"); +} + +// -- Error semantics -------------------------------------------------------- + +#[tokio::test] +async fn an_upstream_error_passes_through_unchanged() { + let upstream = MockUpstream::start(MockBehavior::Fixed { + status: 500, + content_type: "application/json", + body: r#"{"error":"upstream exploded"}"#.to_owned(), + }) + .await; + let fixture = Fixture::new(); + fixture.wire_upstream("mock", upstream.port(), json!(["GET"])).await; + + let response = fixture.send(empty_request("GET", "/oagw/v1/proxy/mock/x")).await; + assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + response.headers().get("x-oagw-error-source").unwrap(), + "upstream" + ); + // Not wrapped in problem details — the upstream's body verbatim. + assert_eq!(body_text(response).await, r#"{"error":"upstream exploded"}"#); +} + +#[tokio::test] +async fn a_slow_upstream_times_out_with_504_and_retry_guidance() { + let upstream = MockUpstream::start(MockBehavior::Slow { + delay: Duration::from_secs(5), + }) + .await; + let fixture = Fixture::new(); + fixture.wire_upstream("mock", upstream.port(), json!(["GET"])).await; + + let response = fixture.send(empty_request("GET", "/oagw/v1/proxy/mock/x")).await; + assert_eq!(response.status(), StatusCode::GATEWAY_TIMEOUT); + assert_eq!( + response.headers().get("x-oagw-error-source").unwrap(), + "gateway" + ); + assert!(response.headers().get(http::header::RETRY_AFTER).is_some()); + + let (_, body) = common::split(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.timeout.request.v1" + ); +} + +#[tokio::test] +async fn an_upstream_that_hangs_up_is_a_gateway_error() { + let upstream = MockUpstream::start(MockBehavior::Hangup).await; + let fixture = Fixture::new(); + fixture.wire_upstream("mock", upstream.port(), json!(["GET"])).await; + + let response = fixture.send(empty_request("GET", "/oagw/v1/proxy/mock/x")).await; + assert!( + response.status().is_server_error(), + "expected a 5xx, got {}", + response.status() + ); + assert_eq!( + response.headers().get("x-oagw-error-source").unwrap(), + "gateway" + ); +} + +#[tokio::test] +async fn a_plaintext_upstream_is_refused_when_the_flag_is_off() { + let upstream = MockUpstream::start(echo("{}")).await; + let mut config = permissive_config(); + config.allow_http_upstream = false; + let fixture = Fixture::with_builder(HarnessBuilder::new().with_config(config)); + fixture.wire_upstream("mock", upstream.port(), json!(["GET"])).await; + + let (status, body) = fixture.get("/oagw/v1/proxy/mock/x").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert!( + body["detail"].as_str().unwrap().contains("allow_http_upstream"), + "{body}" + ); +} + +#[tokio::test] +async fn a_body_over_the_configured_limit_is_413() { + let upstream = MockUpstream::start(echo("{}")).await; + let mut config = permissive_config(); + config.max_request_body_bytes = 32; + let fixture = Fixture::with_builder(HarnessBuilder::new().with_config(config)); + fixture + .wire_upstream("mock", upstream.port(), json!(["GET", "POST"])) + .await; + + let request = Request::builder() + .method("POST") + .uri("/oagw/v1/proxy/mock/x") + .header("content-type", "application/json") + .body(Body::from("x".repeat(1024))) + .unwrap(); + let response = fixture.send(request).await; + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + + let (_, body) = common::split(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.payload.too_large.v1" + ); +} + +// -- Streaming -------------------------------------------------------------- + +#[tokio::test] +async fn server_sent_events_arrive_incrementally_rather_than_buffered() { + let gap = Duration::from_millis(120); + let upstream = MockUpstream::start(MockBehavior::Sse { + events: vec!["one".to_owned(), "two".to_owned(), "three".to_owned()], + gap, + }) + .await; + let fixture = Fixture::new(); + fixture.wire_upstream("mock", upstream.port(), json!(["GET"])).await; + + let response = fixture + .send(empty_request("GET", "/oagw/v1/proxy/mock/sse")) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + response.headers().get(http::header::CONTENT_TYPE).unwrap(), + "text/event-stream" + ); + + let started = Instant::now(); + let mut stream = response.into_body().into_data_stream(); + let mut arrivals = Vec::new(); + let mut collected = String::new(); + while let Some(chunk) = stream.next().await { + let chunk = chunk.expect("chunk"); + collected.push_str(&String::from_utf8_lossy(&chunk)); + arrivals.push(started.elapsed()); + if collected.contains("three") { + break; + } + } + + assert!(collected.contains("data: one"), "{collected}"); + assert!(collected.contains("data: three"), "{collected}"); + assert!( + arrivals.len() >= 2, + "the stream should surface more than one frame: {arrivals:?}" + ); + // Buffering would deliver everything at once, well after the last event. + assert!( + arrivals[0] < gap, + "the first event should arrive before the upstream sends the second: {arrivals:?}" + ); +} + +#[tokio::test] +async fn a_stream_is_not_cut_short_by_the_request_timeout() { + // The timeout bounds connect + response head, not the body: an event + // stream that outlives it must keep flowing. + let mut config = permissive_config(); + config.proxy_timeout_secs = 1; + let upstream = MockUpstream::start(MockBehavior::Sse { + events: vec!["one".to_owned(), "two".to_owned()], + gap: Duration::from_millis(700), + }) + .await; + let fixture = Fixture::with_builder(HarnessBuilder::new().with_config(config)); + fixture.wire_upstream("mock", upstream.port(), json!(["GET"])).await; + + let response = fixture + .send(empty_request("GET", "/oagw/v1/proxy/mock/sse")) + .await; + assert_eq!(response.status(), StatusCode::OK); + + let mut stream = response.into_body().into_data_stream(); + let mut collected = String::new(); + while let Some(Ok(chunk)) = stream.next().await { + collected.push_str(&String::from_utf8_lossy(&chunk)); + if collected.contains("two") { + break; + } + } + assert!(collected.contains("data: two"), "{collected}"); +} + +// -- Endpoint selection ----------------------------------------------------- + +#[tokio::test] +async fn a_common_suffix_pool_requires_the_target_host_header() { + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "server": {"endpoints": [ + {"scheme": "https", "host": "us.vendor.com", "port": 443}, + {"scheme": "https", "host": "eu.vendor.com", "port": 443} + ]}, + "protocol": HTTP_PROTOCOL, + })) + .await; + assert_eq!(created["alias"], "vendor.com"); + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })) + .await; + + let (status, body) = fixture.get("/oagw/v1/proxy/vendor.com/x").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.routing.missing_target_host.v1" + ); + assert_eq!(body["valid_hosts"], json!(["us.vendor.com", "eu.vendor.com"])); +} + +#[tokio::test] +async fn a_malformed_or_unknown_target_host_is_reported_distinctly() { + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "server": {"endpoints": [ + {"scheme": "https", "host": "us.vendor.com", "port": 443}, + {"scheme": "https", "host": "eu.vendor.com", "port": 443} + ]}, + "protocol": HTTP_PROTOCOL, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })) + .await; + + let malformed = Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/vendor.com/x") + .header("x-oagw-target-host", "us.vendor.com:8443") + .body(Body::empty()) + .unwrap(); + let (status, body) = common::split(fixture.send(malformed).await).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.routing.invalid_target_host.v1" + ); + assert_eq!(body["invalid_value"], "us.vendor.com:8443"); + + let unknown = Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/vendor.com/x") + .header("x-oagw-target-host", "apac.vendor.com") + .body(Body::empty()) + .unwrap(); + let (status, body) = common::split(fixture.send(unknown).await).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.routing.unknown_target_host.v1" + ); +} + +#[tokio::test] +async fn an_explicit_target_host_selects_a_member_of_the_pool() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + // Two spellings of the same loopback address: an explicit alias pool, so + // the header is optional but honoured. + let created = fixture + .create_upstream(json!({ + "alias": "pool", + "server": {"endpoints": [ + {"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}, + {"scheme": "http", "host": "127.0.0.2", "port": upstream.port()} + ]}, + "protocol": HTTP_PROTOCOL, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })) + .await; + + let request = Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/pool/x") + .header("x-oagw-target-host", "127.0.0.1") + .body(Body::empty()) + .unwrap(); + let response = fixture.send(request).await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + upstream.last_request().await.header("host").as_deref(), + Some(format!("127.0.0.1:{}", upstream.port()).as_str()) + ); +} + +// -- Rate limiting ---------------------------------------------------------- + +#[tokio::test] +async fn a_rate_limit_rejects_with_429_and_the_standard_headers() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "alias": "mock", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}]}, + "protocol": HTTP_PROTOCOL, + "rate_limit": { + "sustained": {"rate": 2, "window": "minute"}, + "burst": {"capacity": 2}, + "strategy": "reject" + }, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })) + .await; + + for attempt in 0..2 { + let response = fixture.send(empty_request("GET", "/oagw/v1/proxy/mock/x")).await; + assert_eq!(response.status(), StatusCode::OK, "attempt {attempt}"); + assert!(response.headers().get("x-ratelimit-limit").is_some()); + } + + let response = fixture.send(empty_request("GET", "/oagw/v1/proxy/mock/x")).await; + assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS); + assert!(response.headers().get(http::header::RETRY_AFTER).is_some()); + assert_eq!(response.headers().get("x-ratelimit-limit").unwrap(), "2"); + assert_eq!(response.headers().get("x-ratelimit-remaining").unwrap(), "0"); + assert!(response.headers().get("x-ratelimit-reset").is_some()); + + let (_, body) = common::split(response).await; + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1" + ); +} + +#[tokio::test] +async fn the_degrade_strategy_serves_through_the_limit() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "alias": "mock", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}]}, + "protocol": HTTP_PROTOCOL, + "rate_limit": { + "sustained": {"rate": 1, "window": "hour"}, + "burst": {"capacity": 1}, + "strategy": "degrade" + }, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })) + .await; + + for _ in 0..3 { + let (status, _) = fixture.get("/oagw/v1/proxy/mock/x").await; + assert_eq!(status, StatusCode::OK); + } +} + +// -- Credential injection --------------------------------------------------- + +#[tokio::test] +async fn the_api_key_plugin_injects_the_resolved_credential() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::with_builder( + HarnessBuilder::new().with_secret("cred://openai-key", "sk-test-value"), + ); + let created = fixture + .create_upstream(json!({ + "alias": "mock", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}]}, + "protocol": HTTP_PROTOCOL, + "auth": { + "type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + "config": {"secret_ref": "cred://openai-key", "name": "x-api-key"} + }, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })) + .await; + + let (status, _) = fixture.get("/oagw/v1/proxy/mock/v1/models").await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + upstream.last_request().await.header("x-api-key").as_deref(), + Some("sk-test-value") + ); +} + +#[tokio::test] +async fn an_unresolvable_credential_answers_500_secret_not_found() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "alias": "mock", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}]}, + "protocol": HTTP_PROTOCOL, + "auth": { + "type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1", + "config": {"secret_ref": "cred://absent"} + }, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })) + .await; + + let (status, body) = fixture.get("/oagw/v1/proxy/mock/x").await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.secret.not_found.v1" + ); +} + +#[tokio::test] +async fn a_catalog_only_auth_identifier_fails_at_proxy_time_with_503() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "alias": "mock", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}]}, + "protocol": HTTP_PROTOCOL, + "auth": {"type": "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.bearer.v1"}, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })) + .await; + + let (status, body) = fixture.get("/oagw/v1/proxy/mock/x").await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.plugin.not_found.v1" + ); + assert!( + body["detail"].as_str().unwrap().contains("unknown auth plugin"), + "{body}" + ); +} + +// -- Plugin chain ----------------------------------------------------------- + +#[tokio::test] +async fn a_guard_rejects_before_the_upstream_is_contacted() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "alias": "mock", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}]}, + "protocol": HTTP_PROTOCOL, + "headers": {"request": { + "passthrough": "allowlist", + "passthrough_allowlist": ["x-correlation-id"] + }}, + "plugins": {"items": [{ + "plugin_ref": "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1", + "config": {"required_request_headers": "x-correlation-id"} + }]}, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })) + .await; + + let (status, body) = fixture.get("/oagw/v1/proxy/mock/x").await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!(body["error_code"], "REQUIRED_HEADER_MISSING"); + assert!( + upstream.requests().await.is_empty(), + "a rejected request must never reach the upstream" + ); + + let request = Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/mock/x") + .header("x-correlation-id", "abc") + .body(Body::empty()) + .unwrap(); + assert_eq!(fixture.send(request).await.status(), StatusCode::OK); + assert_eq!( + upstream.last_request().await.header("x-correlation-id").as_deref(), + Some("abc") + ); +} + +#[tokio::test] +async fn the_request_id_transform_mints_a_correlation_id() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "alias": "mock", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}]}, + "protocol": HTTP_PROTOCOL, + "plugins": {"items": ["gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"]}, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + })) + .await; + + let (status, _) = fixture.get("/oagw/v1/proxy/mock/x").await; + assert_eq!(status, StatusCode::OK); + let minted = upstream.last_request().await.header("x-request-id"); + assert!(minted.is_some(), "the transform should mint an id"); + assert!(Uuid::parse_str(&minted.unwrap()).is_ok()); +} + +// -- CORS ------------------------------------------------------------------- + +#[tokio::test] +async fn a_preflight_is_answered_without_resolving_an_upstream() { + let fixture = Fixture::new(); + let request = Request::builder() + .method("OPTIONS") + .uri("/oagw/v1/proxy/never-configured/users") + .header("origin", "https://app.example.com") + .header("access-control-request-method", "POST") + .header("access-control-request-headers", "content-type") + .body(Body::empty()) + .unwrap(); + + let response = fixture.send(request).await; + assert_eq!(response.status(), StatusCode::NO_CONTENT); + assert_eq!( + response + .headers() + .get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN) + .unwrap(), + "https://app.example.com" + ); + assert_eq!( + response.headers().get(http::header::ACCESS_CONTROL_MAX_AGE).unwrap(), + "86400" + ); +} + +#[tokio::test] +async fn an_actual_cross_origin_request_is_validated_and_annotated() { + let upstream = MockUpstream::start(echo("{}")).await; + let fixture = Fixture::new(); + let created = fixture + .create_upstream(json!({ + "alias": "mock", + "server": {"endpoints": [{"scheme": "http", "host": "127.0.0.1", "port": upstream.port()}]}, + "protocol": HTTP_PROTOCOL, + "cors": { + "enabled": true, + "allowed_origins": ["https://app.example.com"], + "allowed_methods": ["GET"], + "expose_headers": ["X-Request-ID"], + "allow_credentials": true + }, + })) + .await; + fixture + .create_route(json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET", "POST"], "path": "/"}}, + })) + .await; + + let allowed = Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/mock/x") + .header("origin", "https://app.example.com") + .body(Body::empty()) + .unwrap(); + let response = fixture.send(allowed).await; + assert_eq!(response.status(), StatusCode::OK); + let headers = response.headers(); + assert_eq!( + headers.get(http::header::ACCESS_CONTROL_ALLOW_ORIGIN).unwrap(), + "https://app.example.com" + ); + assert_eq!( + headers.get(http::header::ACCESS_CONTROL_ALLOW_CREDENTIALS).unwrap(), + "true" + ); + assert_eq!( + headers.get("access-control-expose-headers").unwrap(), + "X-Request-ID" + ); + assert!(headers.get(http::header::VARY).is_some()); + + let disallowed_origin = Request::builder() + .method("GET") + .uri("/oagw/v1/proxy/mock/x") + .header("origin", "https://evil.example") + .body(Body::empty()) + .unwrap(); + let (status, body) = common::split(fixture.send(disallowed_origin).await).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1" + ); + + let disallowed_method = Request::builder() + .method("POST") + .uri("/oagw/v1/proxy/mock/x") + .header("origin", "https://app.example.com") + .header("content-type", "application/json") + .body(Body::from("{}")) + .unwrap(); + let (status, body) = common::split(fixture.send(disallowed_method).await).await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!( + body["type"], + "gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1" + ); +} + +// -- Hierarchy at proxy time ------------------------------------------------ + +#[tokio::test] +async fn a_descendant_proxies_through_an_ancestors_upstream() { + let upstream = MockUpstream::start(echo("{}")).await; + let parent = Uuid::new_v4(); + let child = Uuid::new_v4(); + let fixture = Fixture::with_builder( + HarnessBuilder::new() + .with_config(permissive_config()) + .with_tenant_parent(child, parent), + ); + + let (status, created) = common::split( + fixture + .send_as( + parent, + common::json_request( + "POST", + "/oagw/v1/upstreams", + &json!({ + "alias": "shared", + "server": {"endpoints": [ + {"scheme": "http", "host": "127.0.0.1", "port": upstream.port()} + ]}, + "protocol": HTTP_PROTOCOL, + }), + ), + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::CREATED); + + let (status, _) = common::split( + fixture + .send_as( + parent, + common::json_request( + "POST", + "/oagw/v1/routes", + &json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + }), + ), + ) + .await, + ) + .await; + assert_eq!(status, StatusCode::CREATED); + + let response = fixture + .send_as(child, empty_request("GET", "/oagw/v1/proxy/shared/inherited")) + .await; + assert_eq!(response.status(), StatusCode::OK); + assert_eq!(upstream.last_request().await.target(), "/inherited"); +} + +#[tokio::test] +async fn an_ancestor_disabling_the_alias_stops_the_descendant() { + let upstream = MockUpstream::start(echo("{}")).await; + let parent = Uuid::new_v4(); + let child = Uuid::new_v4(); + let fixture = Fixture::with_builder( + HarnessBuilder::new() + .with_config(permissive_config()) + .with_tenant_parent(child, parent), + ); + + for (tenant, enabled) in [(parent, false), (child, true)] { + let (_, created) = common::split( + fixture + .send_as( + tenant, + common::json_request( + "POST", + "/oagw/v1/upstreams", + &json!({ + "alias": "shared", + "enabled": enabled, + "server": {"endpoints": [ + {"scheme": "http", "host": "127.0.0.1", "port": upstream.port()} + ]}, + "protocol": HTTP_PROTOCOL, + }), + ), + ) + .await, + ) + .await; + fixture + .send_as( + tenant, + common::json_request( + "POST", + "/oagw/v1/routes", + &json!({ + "upstream_id": created["id"], + "match": {"http": {"methods": ["GET"], "path": "/"}}, + }), + ), + ) + .await; + } + + let response = fixture + .send_as(child, empty_request("GET", "/oagw/v1/proxy/shared/x")) + .await; + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); +} diff --git a/gears/system/oagw/oagw/tests/proxy_websocket.rs b/gears/system/oagw/oagw/tests/proxy_websocket.rs new file mode 100644 index 0000000..aff361d --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy_websocket.rs @@ -0,0 +1,164 @@ +//! Protocol upgrades end to end. +//! +//! These tests bind a real listener rather than driving the router in-process: +//! an upgrade only exists once hyper owns the connection, so `Router::oneshot` +//! cannot exercise it. + +mod common; + +use std::net::SocketAddr; +use std::time::Duration; + +use common::{Fixture, MockBehavior, MockUpstream}; +use serde_json::json; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; + +/// Serve `fixture`'s router on an ephemeral port and return its address. +async fn serve(fixture: &Fixture) -> SocketAddr { + let listener = TcpListener::bind("127.0.0.1:0").await.expect("bind gateway"); + let addr = listener.local_addr().expect("gateway addr"); + let router = fixture.router(); + tokio::spawn(async move { + let _ = axum::serve(listener, router.into_make_service()).await; + }); + addr +} + +/// Read from `stream` until the end of an HTTP head, returning +/// `(head, leftover)`. +async fn read_head(stream: &mut TcpStream) -> (String, Vec) { + let mut buffer = Vec::new(); + let mut chunk = [0_u8; 1024]; + loop { + let read = stream.read(&mut chunk).await.expect("read head"); + assert_ne!(read, 0, "the gateway closed before answering"); + buffer.extend_from_slice(&chunk[..read]); + if let Some(position) = buffer.windows(4).position(|window| window == b"\r\n\r\n") { + let end = position + 4; + return ( + String::from_utf8_lossy(&buffer[..end]).into_owned(), + buffer[end..].to_vec(), + ); + } + } +} + +/// Send an upgrade request for `path` and return the connection plus the +/// response head. +async fn open_upgrade(addr: SocketAddr, path: &str) -> (TcpStream, String, Vec) { + let mut stream = TcpStream::connect(addr).await.expect("connect gateway"); + let request = format!( + "GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\ + Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n" + ); + stream + .write_all(request.as_bytes()) + .await + .expect("write upgrade request"); + let (head, leftover) = read_head(&mut stream).await; + (stream, head, leftover) +} + +async fn wire(fixture: &Fixture, alias: &str, port: u16) { + fixture.wire_upstream(alias, port, json!(["GET"])).await; +} + +#[tokio::test] +async fn an_upgrade_is_relayed_and_the_connection_spliced_both_ways() { + let upstream = MockUpstream::start(MockBehavior::WebSocketEcho).await; + let fixture = Fixture::new(); + wire(&fixture, "ws", upstream.port()).await; + let addr = serve(&fixture).await; + + let (mut stream, head, leftover) = open_upgrade(addr, "/oagw/v1/proxy/ws/socket").await; + + assert!(head.starts_with("HTTP/1.1 101 "), "{head}"); + let lower = head.to_ascii_lowercase(); + assert!(lower.contains("upgrade: websocket"), "{head}"); + assert!(lower.contains("connection: upgrade"), "{head}"); + // The upstream's handshake answer is relayed, not synthesized. + assert!(lower.contains("sec-websocket-accept:"), "{head}"); + assert!(leftover.is_empty(), "no payload should precede the frames"); + + // Bytes flow in both directions without the gateway interpreting them. + stream.write_all(b"opaque-frame").await.expect("write frame"); + stream.flush().await.expect("flush"); + + let mut echoed = vec![0_u8; 12]; + tokio::time::timeout(Duration::from_secs(5), stream.read_exact(&mut echoed)) + .await + .expect("the echo should arrive") + .expect("read echo"); + assert_eq!(&echoed, b"opaque-frame"); + + let seen = upstream.last_request().await; + assert_eq!(seen.request_line(), "GET /socket HTTP/1.1"); + // The handshake headers are hop-by-hop, but an upgrade is exactly what is + // being relayed, so they must survive. + assert_eq!( + seen.header("sec-websocket-key").as_deref(), + Some("dGhlIHNhbXBsZSBub25jZQ==") + ); + assert_eq!(seen.header("upgrade").as_deref(), Some("websocket")); + assert!( + seen.header("connection") + .is_some_and(|value| value.to_ascii_lowercase().contains("upgrade")) + ); +} + +#[tokio::test] +async fn a_refused_upgrade_is_relayed_as_an_ordinary_response() { + let upstream = MockUpstream::start(MockBehavior::WebSocketRefused).await; + let fixture = Fixture::new(); + wire(&fixture, "ws", upstream.port()).await; + let addr = serve(&fixture).await; + + let (_stream, head, leftover) = open_upgrade(addr, "/oagw/v1/proxy/ws/socket").await; + + assert!(head.starts_with("HTTP/1.1 426 "), "{head}"); + assert!( + head.to_ascii_lowercase().contains("x-oagw-error-source: upstream"), + "{head}" + ); + let body = String::from_utf8_lossy(&leftover); + assert!(body.contains("upgrade refused"), "{body}"); +} + +#[tokio::test] +async fn an_upgrade_to_an_unknown_alias_is_a_gateway_error() { + let fixture = Fixture::new(); + let addr = serve(&fixture).await; + + let (_stream, head, leftover) = open_upgrade(addr, "/oagw/v1/proxy/nope/socket").await; + + assert!(head.starts_with("HTTP/1.1 404 "), "{head}"); + assert!( + head.to_ascii_lowercase().contains("x-oagw-error-source: gateway"), + "{head}" + ); + let body = String::from_utf8_lossy(&leftover); + assert!(body.contains("cf.oagw.route.not_found.v1"), "{body}"); +} + +#[tokio::test] +async fn an_ordinary_request_still_works_on_the_same_listener() { + // Guards against an upgrade path that accidentally captures every request. + let upstream = MockUpstream::start(MockBehavior::Fixed { + status: 200, + content_type: "application/json", + body: r#"{"ok":true}"#.to_owned(), + }) + .await; + let fixture = Fixture::new(); + wire(&fixture, "ws", upstream.port()).await; + let addr = serve(&fixture).await; + + let mut stream = TcpStream::connect(addr).await.expect("connect gateway"); + stream + .write_all(format!("GET /oagw/v1/proxy/ws/plain HTTP/1.1\r\nHost: {addr}\r\n\r\n").as_bytes()) + .await + .expect("write request"); + let (head, _) = read_head(&mut stream).await; + assert!(head.starts_with("HTTP/1.1 200 "), "{head}"); +}