From 56c958bf22cc78974d6c3063ad668df6b42b0341 Mon Sep 17 00:00:00 2001 From: Kseniia Alekseitseva Date: Tue, 1 Sep 2026 15:07:55 +0000 Subject: [PATCH] B8-oagw-gateway__claude__deepseek-v4-flash__effort-max__fabric-gears-coding/B8-oagw-gateway__ZEgFT7r --- .../oagw/docs/ADR/0005-data-plane-caching.md | 10 +- .../oagw/docs/ADR/0006-state-management.md | 10 +- gears/system/oagw/oagw/Cargo.toml | 3 + gears/system/oagw/oagw/src/api/mod.rs | 4 + gears/system/oagw/oagw/src/api/rest/dto.rs | 39 + gears/system/oagw/oagw/src/api/rest/error.rs | 324 ++ .../system/oagw/oagw/src/api/rest/handlers.rs | 517 +++ gears/system/oagw/oagw/src/api/rest/mod.rs | 7 + gears/system/oagw/oagw/src/api/rest/odata.rs | 424 +++ gears/system/oagw/oagw/src/api/rest/routes.rs | 253 ++ gears/system/oagw/oagw/src/config.rs | 129 + gears/system/oagw/oagw/src/domain/error.rs | 100 + gears/system/oagw/oagw/src/domain/mod.rs | 7 + gears/system/oagw/oagw/src/domain/models.rs | 526 +++ .../system/oagw/oagw/src/domain/plugin/mod.rs | 190 ++ gears/system/oagw/oagw/src/domain/service.rs | 979 ++++++ .../system/oagw/oagw/src/domain/validation.rs | 849 +++++ gears/system/oagw/oagw/src/gear.rs | 174 + gears/system/oagw/oagw/src/infra/mod.rs | 6 + .../oagw/oagw/src/infra/plugin/apikey_auth.rs | 193 ++ .../system/oagw/oagw/src/infra/plugin/mod.rs | 9 + .../oagw/oagw/src/infra/plugin/noop_auth.rs | 21 + .../infra/plugin/oauth2_client_cred_auth.rs | 514 +++ .../oagw/oagw/src/infra/plugin/registry.rs | 223 ++ .../infra/plugin/required_headers_guard.rs | 184 ++ gears/system/oagw/oagw/src/infra/proxy.rs | 1841 +++++++++++ gears/system/oagw/oagw/src/infra/ratelimit.rs | 707 ++++ gears/system/oagw/oagw/src/infra/storage.rs | 61 + gears/system/oagw/oagw/src/lib.rs | 22 + gears/system/oagw/oagw/tests/proxy.rs | 2880 +++++++++++++++++ gears/system/oagw/oagw/tests/rest_api.rs | 467 +++ 31 files changed, 11663 insertions(+), 10 deletions(-) create mode 100644 gears/system/oagw/oagw/src/api/mod.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/dto.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/error.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/handlers.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/mod.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/odata.rs create mode 100644 gears/system/oagw/oagw/src/api/rest/routes.rs create mode 100644 gears/system/oagw/oagw/src/config.rs create mode 100644 gears/system/oagw/oagw/src/domain/error.rs create mode 100644 gears/system/oagw/oagw/src/domain/mod.rs create mode 100644 gears/system/oagw/oagw/src/domain/models.rs create mode 100644 gears/system/oagw/oagw/src/domain/plugin/mod.rs create mode 100644 gears/system/oagw/oagw/src/domain/service.rs create mode 100644 gears/system/oagw/oagw/src/domain/validation.rs create mode 100644 gears/system/oagw/oagw/src/gear.rs create mode 100644 gears/system/oagw/oagw/src/infra/mod.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/apikey_auth.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/mod.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/noop_auth.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/oauth2_client_cred_auth.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/registry.rs create mode 100644 gears/system/oagw/oagw/src/infra/plugin/required_headers_guard.rs create mode 100644 gears/system/oagw/oagw/src/infra/proxy.rs create mode 100644 gears/system/oagw/oagw/src/infra/ratelimit.rs create mode 100644 gears/system/oagw/oagw/src/infra/storage.rs create mode 100644 gears/system/oagw/oagw/tests/proxy.rs create mode 100644 gears/system/oagw/oagw/tests/rest_api.rs diff --git a/gears/system/oagw/docs/ADR/0005-data-plane-caching.md b/gears/system/oagw/docs/ADR/0005-data-plane-caching.md index 9d14701..2953276 100644 --- a/gears/system/oagw/docs/ADR/0005-data-plane-caching.md +++ b/gears/system/oagw/docs/ADR/0005-data-plane-caching.md @@ -39,7 +39,7 @@ Control Plane handles config resolution for Data Plane during proxy requests. Co ## Decision Drivers -* Fast lookups for hot configs (<1μs L1, ~1-2ms L2) +* Fast lookups for hot configs (<1us L1, ~1-2ms L2) * Reduced database load (queries only on cache miss) * Support for both single-exec (no Redis) and microservice (shared L2) deployment modes * Correct cache invalidation on config writes @@ -59,7 +59,7 @@ Chosen option: "Multi-layer caching: L1 (in-memory) + optional L2 (Redis) + Data | Layer | Scope | Capacity | TTL | Access Time | Notes | |---|---|---|---|---|---| -| L1 (In-Memory) | Per-instance LRU | 10,000 entries | No TTL (LRU eviction) | <1μs | | +| L1 (In-Memory) | Per-instance LRU | 10,000 entries | No TTL (LRU eviction) | <1us | | | L2 (Redis, optional) | Shared across instances | Unbounded | 5 minutes | ~1-2ms | MessagePack serialization | | Database (PostgreSQL) | Source of truth (JSON text) | Unlimited | N/A | ~5-10ms | Queried only on L1+L2 miss | @@ -69,7 +69,7 @@ Chosen option: "Multi-layer caching: L1 (in-memory) + optional L2 (Redis) + Data async fn get_config(key: &CacheKey) -> Result { // Check L1 if let Some(value) = l1_cache.get(key) { - return Ok(value); // <1μs + return Ok(value); // <1us } // Check L2 (if enabled) @@ -112,7 +112,7 @@ On config write (e.g., `PUT /upstreams/{id}`): (1) CP writes to database, (2) CP ### Consequences -* Good, because fast lookups for hot configs (<1μs L1) +* Good, because fast lookups for hot configs (<1us L1) * Good, because reduced database load * Good, because shared cache in microservice mode (L2) * Good, because simple deployment in single-exec mode (no Redis) @@ -163,5 +163,5 @@ Integration tests verify: L1 cache hit returns correct config, L1 miss falls thr This decision directly addresses the following requirements or design elements: -* `cpt-cf-oagw-nfr-low-latency` — L1 cache provides <1μs config lookups on hot path +* `cpt-cf-oagw-nfr-low-latency` — L1 cache provides <1us config lookups on hot path * `cpt-cf-oagw-fr-request-proxy` — Config resolution during proxy request execution diff --git a/gears/system/oagw/docs/ADR/0006-state-management.md b/gears/system/oagw/docs/ADR/0006-state-management.md index ec3b9fe..4ddad0f 100644 --- a/gears/system/oagw/docs/ADR/0006-state-management.md +++ b/gears/system/oagw/docs/ADR/0006-state-management.md @@ -98,7 +98,7 @@ pub struct CPState { ```text DP receives proxy request ├─ Check DP L1 cache for resolved (upstream, route) config -│ ├─ Hit: Use cached config (<1μs) +│ ├─ Hit: Use cached config (<1us) │ └─ Miss: Call CP.resolve_proxy_target(alias, method, path) │ ├─ Single tenant hierarchy walk: alias shadowing + route match │ ├─ Effective config merge (upstream < route < tenant) @@ -117,7 +117,7 @@ On config write: CP writes to DB, flushes own caches, returns success. API Handl ### Consequences -* Good, because fast path — DP serves hot configs from L1 (<1μs) +* Good, because fast path — DP serves hot configs from L1 (<1us) * Good, because reduced CP calls (only for cache misses) * Good, because simple rate limiting (no distributed coordination for MVP) * Bad, because DP L1 can temporarily diverge from CP (stale data) @@ -138,7 +138,7 @@ DP makes CP call for every request (no L1 cache). ### DP with L1 cache + rate limiters -* Good, because fast reads (<1μs for cached configs) +* Good, because fast reads (<1us for cached configs) * Good, because rate limiter has full request context * Bad, because cache consistency lag after writes @@ -155,7 +155,7 @@ DP calls CP to check rate limits. * DP handles every proxy request * Reduces CP calls for hot configs -* <1μs access time for cached configs +* <1us access time for cached configs * Small cache (1000 entries) has negligible memory overhead **Why rate limiters in DP**: @@ -188,6 +188,6 @@ DP calls CP to check rate limits. This decision directly addresses the following requirements or design elements: -* `cpt-cf-oagw-nfr-low-latency` — DP L1 cache provides <1μs config lookups on hot path +* `cpt-cf-oagw-nfr-low-latency` — DP L1 cache provides <1us config lookups on hot path * `cpt-cf-oagw-fr-rate-limiting` — Rate limiters owned by DP for per-instance enforcement * `cpt-cf-oagw-fr-request-proxy` — Caching strategy optimizes proxy request execution diff --git a/gears/system/oagw/oagw/Cargo.toml b/gears/system/oagw/oagw/Cargo.toml index a18b934..b4b82cc 100644 --- a/gears/system/oagw/oagw/Cargo.toml +++ b/gears/system/oagw/oagw/Cargo.toml @@ -32,6 +32,9 @@ test-utils = [ "tokio/rt", ] +[lints] +workspace = true + [dependencies] toolkit = { workspace = true } toolkit-auth = { workspace = true } diff --git a/gears/system/oagw/oagw/src/api/mod.rs b/gears/system/oagw/oagw/src/api/mod.rs new file mode 100644 index 0000000..33b20a1 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/mod.rs @@ -0,0 +1,4 @@ +//! REST layer: control-plane management handlers and the data-plane proxy +//! handler. + +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..7b13ae7 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/dto.rs @@ -0,0 +1,39 @@ +//! REST DTOs for the OAGW management API. +//! +//! Create/update request bodies and single-resource responses reuse the +//! domain models directly (`Upstream`, `Route`, `PluginRecord`); this module +//! only defines the wrapper shapes that differ (list pages, plugin source). + +use serde::Serialize; +use uuid::Uuid; + +/// List response shape: `{ "items": [...], "page_info": {...} }`. +#[derive(Debug, Clone, Serialize)] +pub struct ListResponse { + pub items: Vec, + pub page_info: PageInfoDto, +} + +#[derive(Debug, Clone, Serialize)] +pub struct PageInfoDto { + pub limit: u64, +} + +impl ListResponse { + /// Wrap a page of projected items (already OData-applied). + #[must_use] + pub fn of(items: Vec) -> Self { + let limit = items.len() as u64; + Self { + items, + page_info: PageInfoDto { limit }, + } + } +} + +/// Response for `GET /plugins/{id}/source`. +#[derive(Debug, Clone, Serialize)] +pub struct PluginSourceResponse { + pub id: Uuid, + pub source: String, +} 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..f117071 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/error.rs @@ -0,0 +1,324 @@ +//! RFC 9457 problem+json error rendering for the OAGW REST surfaces. +//! +//! Every OAGW response carries `X-OAGW-Error-Source: gateway|upstream` +//! (ADR 0007). Gateway-originated errors use `application/problem+json` with +//! GTS `type` identifiers and OAGW-specific extension fields. + +use axum::Json; +use axum::response::{IntoResponse, Response}; +use http::StatusCode; +use http::header::HeaderValue; +use serde::Serialize; + +/// Header present on every OAGW response (success and error). +pub const HEADER_ERROR_SOURCE: &str = "x-oagw-error-source"; +/// Value indicating the gateway generated the response. +pub const ERROR_SOURCE_GATEWAY: &str = "gateway"; +/// Value indicating the response is an upstream passthrough. +pub const ERROR_SOURCE_UPSTREAM: &str = "upstream"; + +/// GTS error type identifiers (DESIGN.md error table). +pub mod type_ids { + /// General route/request validation error (400). + pub const VALIDATION_ERROR: &str = "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1"; + /// Missing `X-OAGW-Target-Host` for multi-endpoint alias (400). + pub const MISSING_TARGET_HOST: &str = + "gts.cf.core.errors.err.v1~cf.oagw.routing.missing_target_host.v1"; + /// `X-OAGW-Target-Host` has an invalid format (400). + pub const INVALID_TARGET_HOST: &str = + "gts.cf.core.errors.err.v1~cf.oagw.routing.invalid_target_host.v1"; + /// `X-OAGW-Target-Host` matches no configured endpoint (400). + pub const UNKNOWN_TARGET_HOST: &str = + "gts.cf.core.errors.err.v1~cf.oagw.routing.unknown_target_host.v1"; + /// Authentication to the upstream failed (401). + pub const AUTH_FAILED: &str = "gts.cf.core.errors.err.v1~cf.oagw.auth.failed.v1"; + /// No matching route found (404). + pub const ROUTE_NOT_FOUND: &str = "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1"; + /// Requested management resource not found (404). + pub const NOT_FOUND: &str = "gts.cf.core.errors.err.v1~cf.oagw.not_found.v1"; + /// Plugin in use by upstream(s)/route(s) (409). + pub const PLUGIN_IN_USE: &str = "gts.cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1"; + /// Conflict (e.g. duplicate alias) (409). + pub const CONFLICT: &str = "gts.cf.core.errors.err.v1~cf.oagw.conflict.v1"; + /// Request payload too large (413). + pub const PAYLOAD_TOO_LARGE: &str = "gts.cf.core.errors.err.v1~cf.oagw.payload.too_large.v1"; + /// Rate limit exceeded (429). + pub const RATE_LIMIT_EXCEEDED: &str = + "gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1"; + /// Referenced secret not found (500). + pub const SECRET_NOT_FOUND: &str = "gts.cf.core.errors.err.v1~cf.oagw.secret.not_found.v1"; + /// Protocol-level error (502). + pub const PROTOCOL_ERROR: &str = "gts.cf.core.errors.err.v1~cf.oagw.protocol.error.v1"; + /// Upstream service error (502). + pub const DOWNSTREAM_ERROR: &str = "gts.cf.core.errors.err.v1~cf.oagw.downstream.error.v1"; + /// Stream connection aborted (502). + pub const STREAM_ABORTED: &str = "gts.cf.core.errors.err.v1~cf.oagw.stream.aborted.v1"; + /// Upstream link unavailable (503). + pub const LINK_UNAVAILABLE: &str = "gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1"; + /// Circuit breaker open (503). + pub const CIRCUIT_BREAKER_OPEN: &str = + "gts.cf.core.errors.err.v1~cf.oagw.circuit_breaker.open.v1"; + /// Plugin not found (503). + pub const PLUGIN_NOT_FOUND: &str = "gts.cf.core.errors.err.v1~cf.oagw.plugin.not_found.v1"; + /// Connection timeout (504). + pub const CONNECTION_TIMEOUT: &str = "gts.cf.core.errors.err.v1~cf.oagw.timeout.connection.v1"; + /// Request timeout (504). + pub const REQUEST_TIMEOUT: &str = "gts.cf.core.errors.err.v1~cf.oagw.timeout.request.v1"; + /// Idle timeout (504). + pub const IDLE_TIMEOUT: &str = "gts.cf.core.errors.err.v1~cf.oagw.timeout.idle.v1"; + /// CORS origin not allowed (403). + pub const CORS_ORIGIN_NOT_ALLOWED: &str = + "gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1"; + /// CORS method not allowed (403). + pub const CORS_METHOD_NOT_ALLOWED: &str = + "gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1"; + /// Required header missing (400 request / 502 response, ADR 0009). + pub const REQUIRED_HEADER_MISSING: &str = + "gts.cf.core.errors.err.v1~cf.oagw.required_header.missing.v1"; + /// Upstream TLS/unencrypted-scheme restrictions (gateway-side 502). + pub const UPSTREAM_UNSUPPORTED: &str = + "gts.cf.core.errors.err.v1~cf.oagw.upstream.unsupported.v1"; +} + +/// Referencing detail for the plugin-delete 409 body (ADR 0001). +#[derive(Debug, Clone, Serialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct ReferencedByDto { + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub upstreams: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub routes: Vec, +} + +/// An RFC 9457 Problem Details response with OAGW extensions. +#[derive(Debug, Clone, Serialize)] +pub struct OagwProblem { + #[serde(rename = "type")] + pub type_: String, + pub title: String, + pub status: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub detail: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub instance: Option, + // --- OAGW extensions --- + #[serde(skip_serializing_if = "Option::is_none")] + pub upstream_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_after_seconds: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub trace_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub alias: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub valid_hosts: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub invalid_value: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub plugin_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub referenced_by: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub missing_headers: Option>, +} + +impl OagwProblem { + /// Start a problem with the GTS type id, title, and HTTP status. + #[must_use] + pub fn new(type_: impl Into, title: impl Into, status: StatusCode) -> Self { + Self { + type_: type_.into(), + title: title.into(), + status: status.as_u16(), + detail: None, + instance: None, + upstream_id: None, + host: None, + path: None, + retry_after_seconds: None, + trace_id: None, + alias: None, + valid_hosts: None, + invalid_value: None, + plugin_id: None, + referenced_by: None, + missing_headers: None, + } + } + + /// Convenience: 400 validation problem. + #[must_use] + pub fn validation(detail: impl Into) -> Self { + Self::new( + type_ids::VALIDATION_ERROR, + "Validation Error", + StatusCode::BAD_REQUEST, + ) + .detail(detail) + } + + /// Convenience: 404 not-found problem. + #[must_use] + pub fn not_found(detail: impl Into) -> Self { + Self::new(type_ids::NOT_FOUND, "Not Found", StatusCode::NOT_FOUND).detail(detail) + } + + /// Convenience: 409 conflict problem. + #[must_use] + pub fn conflict(detail: impl Into) -> Self { + Self::new(type_ids::CONFLICT, "Conflict", StatusCode::CONFLICT).detail(detail) + } + + /// Convenience: gateway 502 downstream error. + #[must_use] + pub fn downstream(detail: impl Into) -> Self { + Self::new( + type_ids::DOWNSTREAM_ERROR, + "Downstream Error", + StatusCode::BAD_GATEWAY, + ) + .detail(detail) + } + + #[must_use] + pub fn detail(mut self, detail: impl Into) -> Self { + self.detail = Some(detail.into()); + self + } + + #[must_use] + pub fn instance(mut self, instance: impl Into) -> Self { + self.instance = Some(instance.into()); + self + } + + #[must_use] + pub fn upstream_id(mut self, id: impl Into) -> Self { + self.upstream_id = Some(id.into()); + self + } + + #[must_use] + pub fn host(mut self, host: impl Into) -> Self { + self.host = Some(host.into()); + self + } + + #[must_use] + pub fn path(mut self, path: impl Into) -> Self { + self.path = Some(path.into()); + self + } + + #[must_use] + pub fn retry_after_seconds(mut self, secs: u64) -> Self { + self.retry_after_seconds = Some(secs); + self + } + + #[must_use] + pub fn trace_id(mut self, trace: impl Into) -> Self { + self.trace_id = Some(trace.into()); + self + } + + #[must_use] + pub fn alias(mut self, alias: impl Into) -> Self { + self.alias = Some(alias.into()); + self + } + + #[must_use] + pub fn valid_hosts(mut self, hosts: Vec) -> Self { + self.valid_hosts = Some(hosts); + self + } + + #[must_use] + pub fn invalid_value(mut self, value: impl Into) -> Self { + self.invalid_value = Some(value.into()); + self + } + + #[must_use] + pub fn plugin_id(mut self, id: impl Into) -> Self { + self.plugin_id = Some(id.into()); + self + } + + #[must_use] + pub fn referenced_by(mut self, refs: ReferencedByDto) -> Self { + self.referenced_by = Some(refs); + self + } + + #[must_use] + pub fn missing_headers(mut self, headers: Vec) -> Self { + self.missing_headers = Some(headers); + self + } + + /// Render as a gateway error response: `application/problem+json` body + + /// `X-OAGW-Error-Source: gateway` (ADR 0007, RFC 9457 `type` member). + #[must_use] + pub fn into_response(self) -> Response { + let mut resp = ( + StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR), + Json(self), + ) + .into_response(); + // RFC 9457 media type: override axum's default `application/json`. + resp.headers_mut().insert( + http::header::CONTENT_TYPE, + HeaderValue::from_static("application/problem+json"), + ); + resp.headers_mut().insert( + HEADER_ERROR_SOURCE, + HeaderValue::from_static(ERROR_SOURCE_GATEWAY), + ); + resp + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[tokio::test] + async fn problem_response_carries_header_and_json() { + let resp = OagwProblem::validation("bad alias") + .alias("api") + .into_response(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + assert_eq!( + resp.headers().get(HEADER_ERROR_SOURCE).unwrap(), + ERROR_SOURCE_GATEWAY + ); + let body = axum::body::to_bytes(resp.into_body(), 1024 * 1024) + .await + .unwrap(); + let body = serde_json::from_slice::(&body).unwrap(); + assert_eq!(body["type"], type_ids::VALIDATION_ERROR); + assert_eq!(body["status"], 400); + assert_eq!(body["alias"], "api"); + assert!(body.get("context").is_none(), "no toolkit `context` field"); + } + + #[test] + fn retry_after_extension_serializes_for_429() { + let problem = OagwProblem::new( + type_ids::RATE_LIMIT_EXCEEDED, + "Rate Limit Exceeded", + StatusCode::TOO_MANY_REQUESTS, + ) + .retry_after_seconds(60); + let json = serde_json::to_value(&problem).unwrap(); + // DESIGN extension field names are snake_case (e.g. `retry_after_seconds`). + assert_eq!(json["retry_after_seconds"], 60); + } +} 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..6c858fe --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers.rs @@ -0,0 +1,517 @@ +//! Axum handlers for the OAGW REST surfaces. +//! +//! Management CRUD for upstreams, routes, and plugins, plus the data-plane +//! proxy endpoint. All gateway-originated errors are RFC 9457 problem+json +//! bodies with `X-OAGW-Error-Source: gateway`. + +use std::collections::HashMap; +use std::sync::Arc; + +use axum::body::Body; +use axum::extract::{OriginalUri, Path, Query}; +use axum::response::IntoResponse; +use axum::{Extension, Json}; +use http::header::{HeaderName, HeaderValue}; +use http::{Request, StatusCode}; +use tenant_resolver_sdk::{GetAncestorsOptions, TenantId, TenantResolverClient}; +use toolkit::api::canonical_prelude::{created_json, no_content}; +use toolkit_security::SecurityContext; +use tracing::{Instrument, info_span, warn}; +use uuid::Uuid; + +use crate::api::rest::dto::{ListResponse, PluginSourceResponse}; +use crate::api::rest::error::{ + ERROR_SOURCE_GATEWAY, HEADER_ERROR_SOURCE, OagwProblem, ReferencedByDto, type_ids, +}; +use crate::api::rest::odata::{ListOptions, apply, parse_params}; +use crate::domain::error::{ControlPlaneError, ResourceRef}; +use crate::domain::models::{PluginRecord, Route, Upstream}; +use crate::domain::service::ControlPlaneService; +use crate::infra::plugin::AuthPluginRegistry; +use crate::infra::proxy::proxy_request; +use crate::infra::ratelimit::RateLimiter; + +type Response = axum::response::Response; + +/// Correlation header (not shipped by `http` ≥ 1.0; defined locally). +const X_REQUEST_ID: HeaderName = HeaderName::from_static("x-request-id"); + +// --------------------------------------------------------------------------- +// Shared helpers +// --------------------------------------------------------------------------- + +/// Map a control-plane error to an OAGW problem response. +fn problem_from(e: ControlPlaneError) -> OagwProblem { + let detail = e.to_string(); + match e { + ControlPlaneError::NotFound(ref_) => { + let instance = resource_instance(&ref_); + OagwProblem::new(type_ids::NOT_FOUND, "Not Found", StatusCode::NOT_FOUND) + .detail(detail) + .instance(instance) + } + ControlPlaneError::Validation { details } => OagwProblem::validation(details), + ControlPlaneError::Duplicate(kind) => OagwProblem::conflict(kind.to_string()), + ControlPlaneError::ImmutableAlias => OagwProblem::validation( + "alias is immutable: delete and re-create the upstream to change it", + ), + ControlPlaneError::ImmutableUpstreamId => { + OagwProblem::validation("upstream_id is immutable after route creation") + } + ControlPlaneError::InUse(ref_, referenced) => { + let plugin_id = match &ref_ { + ResourceRef::Plugin(id) => id.clone(), + _ => String::new(), + }; + let referenced = ReferencedByDto { + upstreams: referenced.upstreams, + routes: referenced.routes, + }; + OagwProblem::new( + type_ids::PLUGIN_IN_USE, + "Plugin In Use", + StatusCode::CONFLICT, + ) + .detail(detail) + .plugin_id(plugin_id) + .referenced_by(referenced) + } + } +} + +fn resource_instance(ref_: &ResourceRef) -> String { + let kind = resource_kind(ref_); + let id = match ref_ { + ResourceRef::Upstream(id) | ResourceRef::Route(id) | ResourceRef::Plugin(id) => id, + }; + format!("/oagw/v1/{kind}/{id}") +} + +fn resource_kind(ref_: &ResourceRef) -> &'static str { + match ref_ { + ResourceRef::Upstream(_) => "upstreams", + ResourceRef::Route(_) => "routes", + ResourceRef::Plugin(_) => "plugins", + } +} + +fn tenant_id(ctx: &SecurityContext) -> Uuid { + ctx.subject_tenant_id() +} + +/// Parse OData-lite query params into list options, mapping errors to 400. +fn list_options(params: &Query>) -> Result> { + parse_params(¶ms.0).map_err(|e| Box::new(OagwProblem::validation(e.message))) +} + +// --------------------------------------------------------------------------- +// Upstreams +// --------------------------------------------------------------------------- + +/// `POST /oagw/v1/upstreams` — create an upstream (alias derived/validated). +pub async fn create_upstream( + Extension(ctx): Extension, + Extension(service): Extension>, + OriginalUri(uri): OriginalUri, + Json(body): Json, +) -> Response { + let out = match service.create_upstream(tenant_id(&ctx), body) { + Ok(u) => u, + Err(e) => return problem_from(e).into_response(), + }; + created_json(&out, &uri, &out.id.to_string()).into_response() +} + +/// `GET /oagw/v1/upstreams` — list with OData-lite query support. +#[allow(clippy::implicit_hasher)] // axum's `Query` extractor fixes the hasher. +pub async fn list_upstreams( + Extension(ctx): Extension, + Extension(service): Extension>, + params: Query>, +) -> Response { + let opts = match list_options(¶ms) { + Ok(o) => o, + Err(p) => return p.into_response(), + }; + let items = service.list_upstreams(tenant_id(&ctx)); + let page = apply(&items, &opts); + Json(ListResponse::of(page)).into_response() +} + +/// `GET /oagw/v1/upstreams/{id}` — fetch one upstream. +pub async fn get_upstream( + Extension(ctx): Extension, + Extension(service): Extension>, + Path(id): Path, +) -> Response { + match service.get_upstream(tenant_id(&ctx), id) { + Ok(u) => Json(u).into_response(), + Err(e) => problem_from(e).into_response(), + } +} + +/// `PUT /oagw/v1/upstreams/{id}` — update (alias immutable). +pub async fn update_upstream( + Extension(ctx): Extension, + Extension(service): Extension>, + Path(id): Path, + Json(body): Json, +) -> Response { + match service.update_upstream(tenant_id(&ctx), id, body) { + Ok(u) => Json(u).into_response(), + Err(e) => problem_from(e).into_response(), + } +} + +/// `DELETE /oagw/v1/upstreams/{id}` — delete one upstream (routes cascade; +/// the DP's rate buckets for the upstream are dropped so a re-created +/// upstream starts from a fresh budget). +pub async fn delete_upstream( + Extension(ctx): Extension, + Extension(service): Extension>, + Extension(rate): Extension>>, + Path(id): Path, +) -> Response { + match service.delete_upstream(tenant_id(&ctx), id) { + Ok(()) => { + if let Some(limiter) = rate { + limiter.clear_for_upstream(id); + } + no_content().into_response() + } + Err(e) => problem_from(e).into_response(), + } +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// `POST /oagw/v1/routes` — create a route. +pub async fn create_route( + Extension(ctx): Extension, + Extension(service): Extension>, + OriginalUri(uri): OriginalUri, + Json(body): Json, +) -> Response { + let out = match service.create_route(tenant_id(&ctx), body) { + Ok(r) => r, + Err(e) => return problem_from(e).into_response(), + }; + created_json(&out, &uri, &out.id.to_string()).into_response() +} + +/// `GET /oagw/v1/routes` — list with OData-lite query support. +#[allow(clippy::implicit_hasher)] // axum's `Query` extractor fixes the hasher. +pub async fn list_routes( + Extension(ctx): Extension, + Extension(service): Extension>, + params: Query>, +) -> Response { + let opts = match list_options(¶ms) { + Ok(o) => o, + Err(p) => return p.into_response(), + }; + let items = service.list_routes(tenant_id(&ctx)); + let page = apply(&items, &opts); + Json(ListResponse::of(page)).into_response() +} + +/// `GET /oagw/v1/routes/{id}` — fetch one route. +pub async fn get_route( + Extension(ctx): Extension, + Extension(service): Extension>, + Path(id): Path, +) -> Response { + match service.get_route(tenant_id(&ctx), id) { + Ok(r) => Json(r).into_response(), + Err(e) => problem_from(e).into_response(), + } +} + +/// `PUT /oagw/v1/routes/{id}` — update (`upstream_id` immutable). +pub async fn update_route( + Extension(ctx): Extension, + Extension(service): Extension>, + Path(id): Path, + Json(body): Json, +) -> Response { + match service.update_route(tenant_id(&ctx), id, body) { + Ok(r) => Json(r).into_response(), + Err(e) => problem_from(e).into_response(), + } +} + +/// `DELETE /oagw/v1/routes/{id}` — delete one route. +pub async fn delete_route( + Extension(ctx): Extension, + Extension(service): Extension>, + Path(id): Path, +) -> Response { + match service.delete_route(tenant_id(&ctx), id) { + Ok(()) => no_content().into_response(), + Err(e) => problem_from(e).into_response(), + } +} + +// --------------------------------------------------------------------------- +// Plugins +// --------------------------------------------------------------------------- + +/// `POST /oagw/v1/plugins` — create a custom plugin. +pub async fn create_plugin( + Extension(ctx): Extension, + Extension(service): Extension>, + OriginalUri(uri): OriginalUri, + Json(body): Json, +) -> Response { + let out = match service.create_plugin(tenant_id(&ctx), body) { + Ok(p) => p, + Err(e) => return problem_from(e).into_response(), + }; + created_json(&out, &uri, &out.id.to_string()).into_response() +} + +/// `GET /oagw/v1/plugins` — list custom plugins. +#[allow(clippy::implicit_hasher)] // axum's `Query` extractor fixes the hasher. +pub async fn list_plugins( + Extension(ctx): Extension, + Extension(service): Extension>, + params: Query>, +) -> Response { + let opts = match list_options(¶ms) { + Ok(o) => o, + Err(p) => return p.into_response(), + }; + let items = service.list_plugins(tenant_id(&ctx)); + let page = apply(&items, &opts); + Json(ListResponse::of(page)).into_response() +} + +/// `GET /oagw/v1/plugins/{id}` — fetch one plugin. +pub async fn get_plugin( + Extension(ctx): Extension, + Extension(service): Extension>, + Path(id): Path, +) -> Response { + match service.get_plugin(tenant_id(&ctx), id) { + Ok(p) => Json(p).into_response(), + Err(e) => problem_from(e).into_response(), + } +} + +/// `GET /oagw/v1/plugins/{id}/source` — fetch the plugin's source text. +pub async fn get_plugin_source( + Extension(ctx): Extension, + Extension(service): Extension>, + Path(id): Path, +) -> Response { + match service.get_plugin(tenant_id(&ctx), id) { + Ok(p) => Json(PluginSourceResponse { + id: p.id, + source: p.source, + }) + .into_response(), + Err(e) => problem_from(e).into_response(), + } +} + +/// `DELETE /oagw/v1/plugins/{id}` — delete an unlinked plugin (409 when used). +pub async fn delete_plugin( + Extension(ctx): Extension, + Extension(service): Extension>, + Path(id): Path, +) -> Response { + match service.delete_plugin(tenant_id(&ctx), id) { + Ok(()) => no_content().into_response(), + Err(e) => problem_from(e).into_response(), + } +} + +// --------------------------------------------------------------------------- +// Data plane (proxy) +// --------------------------------------------------------------------------- + +/// `{METHOD} /oagw/v1/proxy/{alias}/{*rest}` — proxy a request to an upstream. +/// +/// Delegates to the data-plane engine (`crate::infra::proxy`): alias +/// resolution across the tenant chain, route matching, endpoint selection, +/// body validation, header transforms, and forwarding (HTTP / SSE / WebSocket). +#[allow(clippy::too_many_arguments)] // signature dictated by axum's extractors. +pub async fn proxy( + Extension(ctx): Extension, + Extension(service): Extension>, + Extension(tenant_resolver): Extension>>, + Extension(auth): Extension>>, + Extension(rate): Extension>>, + Path((alias, rest)): Path<(String, String)>, + OriginalUri(uri): OriginalUri, + mut request: Request, +) -> Response { + // Correlation ID (caller-supplied `X-Request-ID` or a fresh UUID v4): + // logged on this span, echoed on the response, and injected as `trace_id` + // into gateway problem bodies below. + let request_id = request_id_of(&request); + let span = info_span!("oagw_proxy", request_id = %request_id); + // Instrument the async block instead of holding `span.enter()` across the + // `.await`s: an entered span held across a yield lets other tasks execute + // while "inside" it, producing incorrect traces (diagnostics only). The + // `oagw_proxy` span still records with the `request_id` field on every + // poll, but is exited while the handler awaits. + async { + // Tenant chain (descendant → root) used for alias resolution; falls + // back to the caller's own tenant when the resolver is absent/ + // unreachable. + let chain = build_chain(&ctx, tenant_resolver.as_deref()).await; + + // Client-side WebSocket upgrade future, present only when a real hyper + // server connection performed an upgrade (not in `oneshot` harnesses). + let wants_upgrade = request + .headers() + .get(http::header::UPGRADE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| !v.is_empty()); + let on_upgrade = if wants_upgrade + && request + .extensions() + .get::() + .is_some() + { + Some(hyper::upgrade::on(&mut request)) + } else { + None + }; + + // `{*rest}` is the decoded path suffix; append the raw query string so + // the proxy can route on path+query and pass the query through + // untouched. + let rest_with_query = match uri.query() { + Some(q) if !q.is_empty() => format!("{rest}?{q}"), + _ => rest, + }; + + let (parts, body) = request.into_parts(); + let response = proxy_request( + &service, + chain, + alias, + rest_with_query, + parts.method, + parts.headers, + body, + on_upgrade, + Some(&ctx), + auth.as_deref(), + rate.as_deref(), + ) + .await; + attach_request_id(response, &request_id).await + } + .instrument(span) + .await +} + +/// The correlation identifier for one request: the caller's `X-Request-ID` +/// when present and usable, else a freshly generated UUID v4. +fn request_id_of(request: &Request) -> String { + match request + .headers() + .get(&X_REQUEST_ID) + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|s| !s.is_empty()) + { + Some(v) => v.to_owned(), + None => Uuid::new_v4().to_string(), + } +} + +/// Attach the correlation ID to a proxy response: `X-Request-ID` is set on +/// every response header, and gateway-originated (`X-OAGW-Error-Source: +/// gateway`) RFC 9457 problem bodies additionally get `trace_id` injected and +/// their `Content-Length` recomputed. Upstream passthroughs (including +/// streaming SSE bodies and 101 upgrades) never have their body touched. +async fn attach_request_id(response: Response, request_id: &str) -> Response { + let is_gateway_problem = response + .headers() + .get(http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|ct| ct.starts_with("application/problem+json")) + && response + .headers() + .get(HEADER_ERROR_SOURCE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|src| src == ERROR_SOURCE_GATEWAY); + + let (parts, body) = response.into_parts(); + let status = parts.status; + let mut headers = parts.headers; + + let body = if is_gateway_problem { + if let Ok(bytes) = axum::body::to_bytes(body, 1024 * 1024).await { + let value = serde_json::from_slice::(&bytes) + .unwrap_or(serde_json::Value::Null); + let value = with_trace_id(value, request_id); + let encoded = serde_json::to_vec(&value).unwrap_or(bytes.to_vec()); + headers.insert(http::header::CONTENT_LENGTH, HeaderValue::from(encoded.len())); + Body::from(encoded) + } else { + // Body read failed: keep the correlation header but drop the + // body (the problem is still flagged by status + headers). + headers.insert(http::header::CONTENT_LENGTH, HeaderValue::from(0usize)); + Body::empty() + } + } else { + body + }; + + headers.insert( + X_REQUEST_ID, + HeaderValue::from_str(request_id).unwrap_or_else(|_| HeaderValue::from_static("")), + ); + let mut resp = Response::new(body); + *resp.status_mut() = status; + *resp.headers_mut() = headers; + resp +} + +/// Inject (or replace) the `trace_id` member of an RFC 9457 problem object. +fn with_trace_id(mut value: serde_json::Value, trace_id: &str) -> serde_json::Value { + if let Some(map) = value.as_object_mut() { + map.insert( + "trace_id".to_owned(), + serde_json::Value::String(trace_id.to_owned()), + ); + value + } else { + // Not an object (defensive): wrap it so the trace id is still present. + serde_json::json!({ "trace_id": trace_id, "payload": value }) + } +} + +/// Resolve the tenant ancestry (caller + ancestors, nearest first) for +/// hierarchical alias resolution (DESIGN: descendant → root, closest wins). +async fn build_chain( + ctx: &SecurityContext, + resolver: Option<&dyn TenantResolverClient>, +) -> Vec { + let me = ctx.subject_tenant_id(); + let Some(resolver) = resolver else { + return vec![me]; + }; + match resolver + .get_ancestors(ctx, TenantId(me), &GetAncestorsOptions::default()) + .await + { + Ok(resp) => { + let mut chain = Vec::with_capacity(1 + resp.ancestors.len()); + chain.push(me); + chain.extend(resp.ancestors.iter().map(|a| a.id.0)); + chain + } + Err(e) => { + warn!(err = %e, "tenant resolver unavailable; proxy falls back to the caller's tenant only"); + vec![me] + } + } +} 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..aa392d2 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/mod.rs @@ -0,0 +1,7 @@ +//! REST module for the OAGW control plane. + +pub mod dto; +pub mod error; +pub mod handlers; +pub mod odata; +pub mod routes; diff --git a/gears/system/oagw/oagw/src/api/rest/odata.rs b/gears/system/oagw/oagw/src/api/rest/odata.rs new file mode 100644 index 0000000..c7f746f --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/odata.rs @@ -0,0 +1,424 @@ +//! OData-lite list query support for the OAGW management endpoints. +//! +//! Implements a pragmatic subset of the DESIGN's list query parameters: +//! `$filter`, `$select`, `$orderby`, `$top`, `$skip`. +//! - `$top`: default 50, max 100 (the DESIGN caps at 100). +//! - `$filter`: `field eq 'value'` / `field ne 'value'` and boolean literals, +//! combined with `and`. +//! - `$orderby`: single `field [asc|desc]`. +//! - `$select`: comma-separated field projection. +//! +//! # DESIGN-led deviation +//! +//! Full `OData` (function calls, nested exprs, cursor pagination from +//! `toolkit-odata`) is not a crate dependency; the subset above covers the +//! documented examples while returning RFC-style 400 validation problems for +//! unsupported syntax. + +use serde_json::Value; + +/// Default page size. +pub const DEFAULT_TOP: usize = 50; +/// Maximum page size (DESIGN: max 100). +pub const MAX_TOP: usize = 100; + +/// A parsed `$filter` expression (minimal subset). +#[derive(Debug, Clone, PartialEq)] +pub enum FilterExpr { + /// `field eq 'value'`. + Eq(String, Value), + /// `field ne 'value'`. + Ne(String, Value), + /// `expr and expr`. + And(Box, Box), +} + +/// Parsed list options. +#[derive(Debug, Clone, Default)] +pub struct ListOptions { + /// Page size (clamped to `MAX_TOP`). + pub top: usize, + /// Offset. + pub skip: usize, + /// Optional filter. + pub filter: Option, + /// Optional `(field, ascending)` ordering. + pub order_by: Option<(String, bool)>, + /// Optional field projection. + pub select: Option>, +} + +/// Error type for malformed `OData` query parameters. +#[derive(Debug, thiserror::Error, PartialEq)] +#[error("{message}")] +pub struct ODataError { + pub message: String, +} + +/// Parse OData-lite query parameters from axum's query mapping. +/// +/// Accepts a map of raw query keys → first value (only `$`-prefixed keys are +/// consumed). +/// +/// # Errors +/// +/// Returns an [`ODataError`] when a `$`-prefixed parameter is malformed. +#[allow(clippy::implicit_hasher)] // mirrors axum's `Query` map shape. +pub fn parse_params( + params: &std::collections::HashMap, +) -> Result { + let mut opts = ListOptions { + top: DEFAULT_TOP, + skip: 0, + filter: None, + order_by: None, + select: None, + }; + + if let Some(v) = params.get("$top") { + opts.top = parse_bounded_usize(v, "$top")?; + } + if let Some(v) = params.get("$skip") { + opts.skip = parse_usize(v, "$skip")?; + } + if let Some(v) = params.get("$filter") + && !v.trim().is_empty() + { + opts.filter = Some(parse_filter(v).map_err(|m| ODataError { message: m })?); + } + if let Some(v) = params.get("$orderby") + && !v.trim().is_empty() + { + opts.order_by = Some(parse_orderby(v).map_err(|m| ODataError { message: m })?); + } + if let Some(v) = params.get("$select") { + let fields: Vec = v + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(ToOwned::to_owned) + .collect(); + if !fields.is_empty() { + opts.select = Some(fields); + } + } + Ok(opts) +} + +fn parse_usize(v: &str, name: &str) -> Result { + v.trim().parse::().map_err(|_| ODataError { + message: format!("{name} must be a non-negative integer"), + }) +} + +fn parse_bounded_usize(v: &str, name: &str) -> Result { + let n = parse_usize(v, name)?; + Ok(n.min(MAX_TOP)) +} + +/// Parse `field eq 'value'` / `field ne 'value'` / `true` / `false`, with +/// optional `and` composition. +fn parse_filter(input: &str) -> Result { + let mut rest = input.trim(); + let mut parts = Vec::new(); + // Split on top-level " and " (no parens in the subset). + while let Some(pos) = rest.find(" and ") { + parts.push(rest[..pos].trim().to_owned()); + rest = rest[pos + 5..].trim(); + } + parts.push(rest.to_owned()); + + let mut exprs = Vec::new(); + for part in parts { + exprs.push(parse_single_filter(&part)?); + } + let mut iter = exprs.into_iter(); + let Some(first) = iter.next() else { + return Err("$filter must not be empty".to_owned()); + }; + Ok(iter.fold(first, |acc, e| FilterExpr::And(Box::new(acc), Box::new(e)))) +} + +fn parse_single_filter(part: &str) -> Result { + let part = part.trim(); + if part == "true" { + return Ok(FilterExpr::Eq("enabled".to_owned(), Value::Bool(true))); + } + if part == "false" { + return Ok(FilterExpr::Eq("enabled".to_owned(), Value::Bool(false))); + } + for op in ["ne", "eq"] { + if let Some(pos) = part.find(&format!(" {op} ")) { + let field = part[..pos].trim().to_owned(); + let literal = part[pos + op.len() + 1..].trim(); + let value = parse_literal(literal); + return if op == "eq" { + Ok(FilterExpr::Eq(field, value)) + } else { + Ok(FilterExpr::Ne(field, value)) + }; + } + } + Err(format!( + "unsupported $filter expression '{part}' (supported: field eq 'value', field ne 'value', and-conjunctions)" + )) +} + +fn parse_literal(lit: &str) -> Value { + let lit = lit.trim(); + if (lit.starts_with('\'') && lit.ends_with('\'') && lit.len() >= 2) + || (lit.starts_with('\"') && lit.ends_with('\"') && lit.len() >= 2) + { + let inner = &lit[1..lit.len() - 1]; + // Strip OData double-'' escaping. + return Value::String(inner.replace("''", "'")); + } + if let Ok(b) = lit.parse::() { + return Value::Bool(b); + } + if let Ok(n) = lit.parse::() { + return Value::Number(n.into()); + } + if let Ok(u) = lit.parse::() { + return Value::Number(u.into()); + } + // Bare identifier: treat as string (e.g. uuid filter without quotes). + Value::String(lit.to_owned()) +} + +/// Parse `field` or `field desc`/`field asc`. +fn parse_orderby(input: &str) -> Result<(String, bool), String> { + let mut parts = input.split_whitespace(); + let field = parts + .next() + .ok_or_else(|| "$orderby must not be empty".to_owned())? + .to_owned(); + let asc = match parts.next() { + None => true, + Some(dir) if dir.eq_ignore_ascii_case("asc") => true, + Some(dir) if dir.eq_ignore_ascii_case("desc") => false, + Some(other) => { + return Err(format!( + "unsupported $orderby direction '{other}' (expected asc|desc)" + )); + } + }; + if parts.next().is_some() { + return Err("multiple $orderby fields are not supported".to_owned()); + } + Ok((field, asc)) +} + +/// Resolve a (possibly dotted) field path inside a JSON value, e.g. +/// `match.http.path` → `item["match"]["http"]["path"]`. Unresolvable paths +/// return `None`. +#[must_use] +pub fn resolve_field<'a>(item: &'a Value, field: &str) -> Option<&'a Value> { + let mut cur = item; + for seg in field.split('.') { + cur = cur.get(seg)?; + } + Some(cur) +} + +/// Evaluate a filter against an item's JSON value. +#[must_use] +pub fn matches_filter(item: &Value, filter: &FilterExpr) -> bool { + match filter { + FilterExpr::Eq(field, want) => value_eq(resolve_field(item, field), want), + FilterExpr::Ne(field, want) => !value_eq(resolve_field(item, field), want), + FilterExpr::And(a, b) => matches_filter(item, a) && matches_filter(item, b), + } +} + +fn value_eq(got: Option<&Value>, want: &Value) -> bool { + match (got, want) { + (Some(g), want) => match (g, want) { + // Strings compared case-insensitively (aliases resolve case-insensitively). + (Value::String(a), Value::String(b)) => a.eq_ignore_ascii_case(b), + (Value::String(a), Value::Number(n)) => a == &n.to_string(), + (Value::Number(a), Value::String(b)) => &a.to_string() == b, + _ => g == want, + }, + (None, Value::Null) => true, + _ => false, + } +} + +/// Sort `items` by the requested `(field, ascending)` ordering (string compare). +fn sort_items(items: &mut [Value], order: &(String, bool)) { + let (field, asc) = order; + // Stable sort with string comparison of the field's serialized value. + items.sort_by(|a, b| { + let av = resolve_field(a, field) + .map(value_to_ord) + .unwrap_or_default(); + let bv = resolve_field(b, field) + .map(value_to_ord) + .unwrap_or_default(); + let ord = av.cmp(&bv); + if *asc { ord } else { ord.reverse() } + }); +} + +/// Ordering key: string, else serialized value. +fn value_to_ord(v: &Value) -> String { + match v { + Value::String(s) => s.clone(), + Value::Bool(b) => b.to_string(), + Value::Number(n) => n.to_string(), + Value::Null => String::new(), + other => serde_json::to_string(other).unwrap_or_default(), + } +} + +/// Project an item to only the `$select`ed fields (dotted paths supported). +fn select_fields(value: &Value, fields: &[String]) -> Value { + if !value.is_object() { + return value.clone(); + } + let mut out = serde_json::Map::new(); + for f in fields { + if let Some(v) = resolve_field(value, f) { + out.insert(f.clone(), v.clone()); + } + } + Value::Object(out) +} + +/// Apply all list options to a set of items serialized as JSON values. +/// +/// Returns the paginated, projected page of items. +#[must_use] +pub fn apply(items: &[T], opts: &ListOptions) -> Vec { + let mut values: Vec = items + .iter() + .map(|i| serde_json::to_value(i).unwrap_or(Value::Null)) + .collect(); + + if let Some(filter) = &opts.filter { + values.retain(|v| matches_filter(v, filter)); + } + if let Some(order) = &opts.order_by { + sort_items(&mut values, order); + } + if let Some(fields) = &opts.select { + for v in &mut values { + *v = select_fields(v, fields); + } + } + values.into_iter().skip(opts.skip).take(opts.top).collect() +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use serde_json::json; + use std::collections::HashMap; + + #[test] + fn parses_defaults() { + let params: HashMap = HashMap::new(); + let opts = parse_params(¶ms).unwrap(); + assert_eq!(opts.top, DEFAULT_TOP); + assert_eq!(opts.skip, 0); + assert!(opts.filter.is_none()); + assert!(opts.order_by.is_none()); + assert!(opts.select.is_none()); + } + + #[test] + fn parses_top_skip_caps_at_100() { + let mut params = HashMap::new(); + params.insert("$top".into(), "500".into()); + params.insert("$skip".into(), "12".into()); + let opts = parse_params(¶ms).unwrap(); + assert_eq!(opts.top, MAX_TOP); + assert_eq!(opts.skip, 12); + } + + #[test] + fn invalid_top_is_error() { + let mut params = HashMap::new(); + params.insert("$top".into(), "abc".into()); + assert!(parse_params(¶ms).is_err()); + } + + #[test] + fn parses_filter_eq_and_ne() { + let mut params = HashMap::new(); + params.insert("$filter".into(), "alias eq 'api.openai.com'".into()); + let opts = parse_params(¶ms).unwrap(); + let f = opts.filter.unwrap(); + assert_eq!( + f, + FilterExpr::Eq("alias".into(), Value::String("api.openai.com".into())) + ); + let item = json!({ "alias": "api.openai.com" }); + assert!(matches_filter(&item, &f)); + } + + #[test] + fn parses_and_filter() { + let f = parse_filter("alias eq 'x' and enabled eq true").unwrap(); + let item = json!({ "alias": "x", "enabled": false }); + assert!(!matches_filter(&item, &f)); + let item2 = json!({ "alias": "x", "enabled": true }); + assert!(matches_filter(&item2, &f)); + } + + #[test] + fn filter_resolves_dotted_paths() { + let f = FilterExpr::Eq("match.http.path".into(), Value::String("/v1/chat".into())); + let item = json!({ "match": { "http": { "path": "/v1/chat" } } }); + assert!(matches_filter(&item, &f)); + let miss = json!({ "match": { "http": { "path": "/other" } } }); + assert!(!matches_filter(&miss, &f)); + } + + #[test] + fn filter_is_case_insensitive_for_strings() { + let f = FilterExpr::Eq("alias".into(), Value::String("API.X".into())); + let item = json!({ "alias": "api.x" }); + assert!(matches_filter(&item, &f)); + } + + #[test] + fn parses_order_by() { + let mut params = HashMap::new(); + params.insert("$orderby".into(), "alias desc".into()); + let opts = parse_params(¶ms).unwrap(); + assert_eq!(opts.order_by, Some(("alias".to_owned(), false))); + } + + #[test] + fn apply_filters_sorts_and_paginates() { + let items = vec![ + json!({ "alias": "b.example.com", "enabled": true }), + json!({ "alias": "a.example.com", "enabled": true }), + json!({ "alias": "c.example.com", "enabled": false }), + ]; + let mut params = HashMap::new(); + params.insert("$filter".into(), "enabled eq true".into()); + params.insert("$orderby".into(), "alias".into()); + params.insert("$top".into(), "1".into()); + params.insert("$skip".into(), "1".into()); + let opts = parse_params(¶ms).unwrap(); + let page = apply(&items, &opts); + assert_eq!(page.len(), 1); + assert_eq!(page[0]["alias"], "b.example.com"); + } + + #[test] + fn apply_select_projects_fields() { + let items = vec![json!({ "id": "1", "alias": "x", "tags": [] })]; + let mut params = HashMap::new(); + params.insert("$select".into(), "id,alias".into()); + let opts = parse_params(¶ms).unwrap(); + let page = apply(&items, &opts); + assert!(page[0].get("id").is_some()); + assert!(page[0].get("alias").is_some()); + assert!(page[0].get("tags").is_none()); + } +} 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..7396c59 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/routes.rs @@ -0,0 +1,253 @@ +//! Route registration for the OAGW control plane (`OperationBuilder` style). + +use std::sync::Arc; + +use axum::Router; +use tenant_resolver_sdk::TenantResolverClient; +use toolkit::api::OpenApiRegistry; +use toolkit::api::canonical_prelude::StatusCode; +use toolkit::api::operation_builder::{ + CORE_GLOBAL_BASE_LICENSE_FEATURE, LicenseFeature, OperationBuilder, +}; + +use crate::domain::service::ControlPlaneService; +use crate::infra::plugin::AuthPluginRegistry; +use crate::infra::ratelimit::RateLimiter; + +use super::handlers; + +const API_TAG: &str = "OAGW"; + +struct License; + +impl AsRef for License { + fn as_ref(&self) -> &'static str { + CORE_GLOBAL_BASE_LICENSE_FEATURE + } +} + +impl LicenseFeature for License {} + +/// Registers all OAGW REST routes (management + proxy) onto `router`. +/// +/// All paths are gear-relative (`/oagw/v1/...`); the host runtime nests the +/// returned router under the configured `prefix_path`. +/// +/// # Convention note +/// +/// Routes are registered through `OperationBuilder` (path, auth axis, handler, +/// standard errors) exactly like the other gears. The `json_request` / +/// `json_response_with_schema` `OpenAPI` steps are omitted: request/response +/// wire types are the domain models, which do not carry the +/// `#[toolkit_macros::api_dto]` derives — the generated `OpenAPI` document for +/// these routes therefore has path-level metadata but no component schemas. +/// Runtime behavior is unaffected. +#[allow(clippy::needless_pass_by_value)] +pub fn register_routes( + mut router: Router, + openapi: &dyn OpenApiRegistry, + service: Arc, + tenant_resolver: Option>, + auth: Option>, + rate: Option>, +) -> Router { + // --- Upstreams ------------------------------------------------------ + router = OperationBuilder::post("/oagw/v1/upstreams") + .operation_id("oagw.create_upstream") + .summary("Create an upstream service") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .handler(handlers::create_upstream) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/upstreams") + .operation_id("oagw.list_upstreams") + .summary("List upstream services (OData-lite)") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .handler(handlers::list_upstreams) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/upstreams/{id}") + .operation_id("oagw.get_upstream") + .summary("Fetch an upstream service") + .tag(API_TAG) + .authenticated() + .path_param("id", "Upstream UUID") + .require_license_features::([]) + .handler(handlers::get_upstream) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::put("/oagw/v1/upstreams/{id}") + .operation_id("oagw.update_upstream") + .summary("Update an upstream service (alias immutable)") + .tag(API_TAG) + .authenticated() + .path_param("id", "Upstream UUID") + .require_license_features::([]) + .handler(handlers::update_upstream) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/upstreams/{id}") + .operation_id("oagw.delete_upstream") + .summary("Delete an upstream service") + .tag(API_TAG) + .authenticated() + .path_param("id", "Upstream UUID") + .require_license_features::([]) + .handler(handlers::delete_upstream) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + // --- Routes ---------------------------------------------------------- + router = OperationBuilder::post("/oagw/v1/routes") + .operation_id("oagw.create_route") + .summary("Create a route") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .handler(handlers::create_route) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/routes") + .operation_id("oagw.list_routes") + .summary("List routes (OData-lite)") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .handler(handlers::list_routes) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/routes/{id}") + .operation_id("oagw.get_route") + .summary("Fetch a route") + .tag(API_TAG) + .authenticated() + .path_param("id", "Route UUID") + .require_license_features::([]) + .handler(handlers::get_route) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::put("/oagw/v1/routes/{id}") + .operation_id("oagw.update_route") + .summary("Update a route (upstream_id immutable)") + .tag(API_TAG) + .authenticated() + .path_param("id", "Route UUID") + .require_license_features::([]) + .handler(handlers::update_route) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/routes/{id}") + .operation_id("oagw.delete_route") + .summary("Delete a route") + .tag(API_TAG) + .authenticated() + .path_param("id", "Route UUID") + .require_license_features::([]) + .handler(handlers::delete_route) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + // --- Plugins --------------------------------------------------------- + router = OperationBuilder::post("/oagw/v1/plugins") + .operation_id("oagw.create_plugin") + .summary("Create a custom plugin") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .handler(handlers::create_plugin) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins") + .operation_id("oagw.list_plugins") + .summary("List custom plugins (OData-lite)") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .handler(handlers::list_plugins) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/{id}") + .operation_id("oagw.get_plugin") + .summary("Fetch a custom plugin") + .tag(API_TAG) + .authenticated() + .path_param("id", "Plugin UUID") + .require_license_features::([]) + .handler(handlers::get_plugin) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/{id}/source") + .operation_id("oagw.get_plugin_source") + .summary("Fetch a plugin's source text") + .tag(API_TAG) + .authenticated() + .path_param("id", "Plugin UUID") + .require_license_features::([]) + .handler(handlers::get_plugin_source) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/plugins/{id}") + .operation_id("oagw.delete_plugin") + .summary("Delete a custom plugin (409 when in use)") + .tag(API_TAG) + .authenticated() + .path_param("id", "Plugin UUID") + .require_license_features::([]) + .handler(handlers::delete_plugin) + .json_response(StatusCode::OK, "Success") + .standard_errors(openapi) + .register(router, openapi); + + // --- Data plane ------------------------------------------------------ + // Registered directly with axum `any(...)` because the proxy accepts + // every HTTP method (axum method-routers are per-method); the OperationBuilder + // style is preserved for the management routes above. `{alias}` routes to an + // upstream; `{*rest}` is the optional path suffix. + router = router.route( + "/oagw/v1/proxy/{alias}/{*rest}", + axum::routing::any(handlers::proxy), + ); + + router = router.layer(axum::Extension(service)); + // Optional tenant-resolver client (None when the provider gear is absent + // from the binary); the proxy handler falls back to the caller's own + // tenant for alias resolution. + router = router.layer(axum::Extension(tenant_resolver)); + // Optional auth-plugin registry (None in router-only tests); the proxy + // handler executes the upstream's bound auth plugin when both are present. + router = router.layer(axum::Extension(auth)); + // Optional DP-owned rate limiter (None in router-only tests); the proxy + // handler enforces upstream/route rate limits on the shared buckets. + router = router.layer(axum::Extension(rate)); + router +} diff --git a/gears/system/oagw/oagw/src/config.rs b/gears/system/oagw/oagw/src/config.rs new file mode 100644 index 0000000..ec8057b --- /dev/null +++ b/gears/system/oagw/oagw/src/config.rs @@ -0,0 +1,129 @@ +//! Configuration for the OAGW (outbound API gateway) gear. +//! +//! Read from the `gears.oagw.config` YAML section via +//! [`GearCtx::config_or_default::()`](crate::toolkit::GearCtx::config_or_default). + +use serde::Deserialize; + +/// Maximum upstream request/response body size in bytes (100 MiB). +/// +/// Per the DESIGN contract, bodies larger than this are rejected with a `413` +/// *before* buffering (when `Content-Length` is present and exceeds the limit) +/// or as soon as the limit is crossed while buffering. +pub const DEFAULT_BODY_LIMIT_BYTES: usize = 100 * 1024 * 1024; + +/// Default proxy timeout in seconds (applied when config omits it). +pub const DEFAULT_PROXY_TIMEOUT_SECS: u64 = 30; + +/// OAGW gear configuration. +/// +/// Every field has a safe default; a gear may be started with no `config` +/// section at all ([`GearCtx::config_or_default`] falls back to `Default`). +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct OagwConfig { + /// Maximum time to wait for an upstream to respond (seconds). + pub proxy_timeout_secs: u64, + /// Whether plain-HTTP (`http://`) upstreams are allowed. + /// + /// `false` (default) fails closed: an `http` upstream is rejected with a + /// gateway-side error. `true` is enabled only for testing. + pub allow_http_upstream: bool, + /// SSRF (server-side request forgery) protection policy. + pub ssrf_policy: SsrfPolicy, + /// Token-cache TTL in seconds for auth plugins (ADR 0008). + pub token_cache_ttl_secs: u64, + /// Token-cache capacity (number of entries) for auth plugins (ADR 0008). + pub token_cache_capacity: usize, + /// Maximum accepted upstream request body size in bytes. + pub body_limit_bytes: usize, + /// Circuit-breaker window override (kept here for config compatibility; + /// the MVP uses a fixed in-memory breaker window of 60s). + #[allow(dead_code)] + pub circuit_breaker_window_secs: u64, +} + +impl Default for OagwConfig { + fn default() -> Self { + Self { + proxy_timeout_secs: DEFAULT_PROXY_TIMEOUT_SECS, + allow_http_upstream: false, + ssrf_policy: SsrfPolicy::default(), + // ADR 0008 defaults. + token_cache_ttl_secs: 300, + token_cache_capacity: 10_000, + body_limit_bytes: DEFAULT_BODY_LIMIT_BYTES, + circuit_breaker_window_secs: 60, + } + } +} + +/// SSRF protection policy applied before any upstream is contacted. +#[derive(Debug, Clone, Deserialize)] +#[serde(default)] +pub struct SsrfPolicy { + /// Whether SSRF protection is enabled (defaults to `true` — fail closed). + pub enabled: bool, + /// Comma-separated hostname/IP allowlist; empty means "no explicit allowlist". + #[allow(dead_code)] + pub allowlist: Vec, + /// Comma-separated hostname/IP denylist; empty means "no explicit denylist". + #[allow(dead_code)] + pub denylist: Vec, +} + +impl Default for SsrfPolicy { + fn default() -> Self { + Self { + enabled: true, + allowlist: Vec::new(), + denylist: Vec::new(), + } + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[test] + fn defaults_are_safe() { + let cfg = OagwConfig::default(); + // Fail-closed defaults. + assert!(!cfg.allow_http_upstream); + assert!(cfg.ssrf_policy.enabled); + assert_eq!(cfg.body_limit_bytes, DEFAULT_BODY_LIMIT_BYTES); + // ADR 0008 defaults. + assert_eq!(cfg.token_cache_ttl_secs, 300); + assert_eq!(cfg.token_cache_capacity, 10_000); + } + + #[test] + fn deserializes_e2e_config_block() { + // Mirrors /app/config/e2e-local.yaml `gears.oagw.config` plus ADR 0008 keys. + let json = r#"{ + "proxy_timeout_secs": 2, + "allow_http_upstream": true, + "ssrf_policy": { "enabled": false }, + "token_cache_ttl_secs": 300, + "token_cache_capacity": 10000 + }"#; + let cfg: OagwConfig = + serde_json::from_str(json).expect("e2e config block must deserialize"); + assert_eq!(cfg.proxy_timeout_secs, 2); + assert!(cfg.allow_http_upstream); + assert!(!cfg.ssrf_policy.enabled); + assert_eq!(cfg.token_cache_ttl_secs, 300); + // Fields absent from the block fall back to `serde(default)` defaults. + assert_eq!(cfg.body_limit_bytes, DEFAULT_BODY_LIMIT_BYTES); + } + + #[test] + fn partial_config_falls_back_to_defaults() { + let cfg: OagwConfig = serde_json::from_str(r#"{"proxy_timeout_secs": 5}"#).unwrap(); + assert_eq!(cfg.proxy_timeout_secs, 5); + assert!(!cfg.allow_http_upstream); + assert!(cfg.ssrf_policy.enabled); + } +} 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..196395a --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/error.rs @@ -0,0 +1,100 @@ +//! Control-plane domain errors for the OAGW gear. + +use crate::domain::models::{DuplicateKey, ReferencedBy}; +use std::fmt; + +/// Errors produced by the control-plane service while manipulating upstreams, +/// routes, and plugins. +#[derive(Debug, thiserror::Error)] +pub enum ControlPlaneError { + /// The requested resource does not exist. + #[error("{0}")] + NotFound(ResourceRef), + /// A uniqueness constraint was violated. + #[error("{0}")] + Duplicate(DuplicateKind), + /// The resource cannot be deleted because something still references it. + #[error("{0} references it")] + InUse(ResourceRef, ReferencedBy), + /// Validation failed for a create/update payload. + #[error("validation failed: {details}")] + Validation { details: String }, + /// The alias is immutable after the resource is created. + #[error("alias is immutable after creation")] + ImmutableAlias, + /// The upstream id is immutable after the route is created. + #[error("upstream_id is immutable after creation")] + ImmutableUpstreamId, +} + +/// Identifies a resource in an error. +#[derive(Debug, Clone)] +pub enum ResourceRef { + Upstream(String), + Route(String), + Plugin(String), +} + +impl fmt::Display for ResourceRef { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ResourceRef::Upstream(id) => write!(f, "upstream '{id}'"), + ResourceRef::Route(id) => write!(f, "route '{id}'"), + ResourceRef::Plugin(id) => write!(f, "plugin '{id}'"), + } + } +} + +/// Kind of uniqueness violation. +#[derive(Debug, Clone)] +pub enum DuplicateKind { + /// `(tenant_id, alias)` already taken by another upstream. + AliasTaken { alias: String, owner: String }, + /// A route with the same id already exists. + RouteExists { id: String }, + /// A plugin with the same id already exists. + PluginExists { id: String }, +} + +impl fmt::Display for DuplicateKind { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + DuplicateKind::AliasTaken { alias, owner } => { + write!(f, "alias '{alias}' is already in use by upstream '{owner}'") + } + DuplicateKind::RouteExists { id } => write!(f, "route '{id}' already exists"), + DuplicateKind::PluginExists { id } => write!(f, "plugin '{id}' already exists"), + } + } +} + +/// Backing type for [`DuplicateKey`] used by in-memory stores. +impl From for DuplicateKey { + fn from(kind: DuplicateKind) -> Self { + match kind { + DuplicateKind::AliasTaken { alias, owner } => DuplicateKey::Alias(alias, owner), + DuplicateKind::RouteExists { id } => DuplicateKey::Route(id), + DuplicateKind::PluginExists { id } => DuplicateKey::Plugin(id), + } + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[test] + fn error_messages_are_reader_friendly() { + let e = ControlPlaneError::NotFound(ResourceRef::Route("r-1".into())); + assert!(e.to_string().contains("route 'r-1'")); + let e = ControlPlaneError::Duplicate(DuplicateKind::AliasTaken { + alias: "api".into(), + owner: "u-1".into(), + }); + assert!( + e.to_string() + .contains("alias 'api' is already in use by upstream 'u-1'") + ); + } +} 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..ef9fc35 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/mod.rs @@ -0,0 +1,7 @@ +//! Domain model and control-plane service for the OAGW gear. + +pub mod error; +pub mod models; +pub mod plugin; +pub mod service; +pub mod validation; diff --git a/gears/system/oagw/oagw/src/domain/models.rs b/gears/system/oagw/oagw/src/domain/models.rs new file mode 100644 index 0000000..20a148d --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/models.rs @@ -0,0 +1,526 @@ +//! Domain models for the OAGW control plane. +//! +//! These mirror the authoritative JSON Schemas +//! (`docs/schemas/upstream.v1.schema.json`, `docs/schemas/route.v1.schema.json`) +//! as Rust structs with serde defaults, plus the (server-owned) plugin record. +//! Validation rules (alias derivation, required fields, immutability) live in +//! `crate::domain::service` and `crate::api::rest::dto`. + +use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; +use uuid::Uuid; + +/// GTS identifier of an upstream's protocol. +pub const PROTOCOL_HTTP_V1: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"; +/// GTS identifier of a gRPC upstream protocol. +pub const PROTOCOL_GRPC_V1: &str = "gts.cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1"; + +/// Standard default ports per scheme. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Scheme { + Https, + Wss, + Wt, + Grpc, +} + +impl Scheme { + /// Match a wire scheme string (unknown schemes are rejected at validation). + #[must_use] + pub fn parse(s: &str) -> Option { + match s { + "https" => Some(Self::Https), + "wss" => Some(Self::Wss), + "wt" => Some(Self::Wt), + "grpc" => Some(Self::Grpc), + _ => None, + } + } + + /// Per-scheme default port. + #[must_use] + pub fn default_port(self) -> u16 { + let _ = self; + 443 + } + + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Https => "https", + Self::Wss => "wss", + Self::Wt => "wt", + Self::Grpc => "grpc", + } + } +} + +impl Serialize for Scheme { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for Scheme { + fn deserialize>(deserializer: D) -> Result { + let s = String::deserialize(deserializer)?; + Scheme::parse(&s).ok_or_else(|| serde::de::Error::custom(format!("invalid scheme '{s}'"))) + } +} + +/// Header transformation rules shared by request and response directions. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct HeaderOps { + /// Headers to set (overwrite if exists). + #[serde(default)] + pub set: BTreeMap, + /// Headers to add (append, allow duplicates). + #[serde(default)] + pub add: BTreeMap, + /// Header names to remove. + #[serde(default)] + pub remove: Vec, +} + +/// Inbound request-header forwarding policy. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum PassthroughMode { + /// Forward none of the inbound headers. + #[default] + None, + /// Forward only the headers named in `passthrough_allowlist`. + Allowlist, + /// Forward all inbound headers. + All, +} + +/// Request-direction header rules (includes the passthrough policy). +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct RequestHeadersConfig { + /// Headers to set on outbound requests. + #[serde(default)] + pub set: BTreeMap, + /// Headers to add on outbound requests. + #[serde(default)] + pub add: BTreeMap, + /// Header names to strip from inbound requests. + #[serde(default)] + pub remove: Vec, + /// Which inbound headers to forward. + #[serde(default)] + pub passthrough: PassthroughMode, + /// Headers to forward when `passthrough == allowlist`. + #[serde(default)] + pub passthrough_allowlist: Vec, +} + +/// Full headers configuration of an upstream. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)] +pub struct HeadersConfig { + /// Request-direction rules. + #[serde(default)] + pub request: RequestHeadersConfig, + /// Response-direction rules. + #[serde(default)] + pub response: HeaderOps, +} + +/// A single upstream endpoint. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Endpoint { + #[serde(default = "default_scheme")] + pub scheme: Scheme, + pub host: String, + #[serde(default = "default_endpoint_port")] + pub port: u16, +} + +fn default_scheme() -> Scheme { + Scheme::Https +} + +fn default_endpoint_port() -> u16 { + 443 +} + +/// The `server` section of an upstream. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct ServerConfig { + /// One or more upstream endpoints. + #[serde(default = "Vec::new")] + pub endpoints: Vec, +} + +/// Sharing mode for hierarchical config composition. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum SharingMode { + /// Not visible to descendant tenants. + #[default] + Private, + /// Descendants may override. + Inherit, + /// Descendants may not override (ancestor wins). + Enforce, +} + +/// Auth plugin configuration attached to an upstream. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct AuthConfig { + /// GTS identifier of the auth plugin type. + pub r#type: String, + #[serde(default)] + pub sharing: SharingMode, + /// Plugin-specific configuration object. + #[serde(default)] + pub config: serde_json::Value, +} + +/// Plugin chain attached to an upstream or route. +#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] +pub struct PluginsConfig { + #[serde(default)] + pub sharing: SharingMode, + /// Builtin plugins by GTS ID; custom plugins by UUID. + #[serde(default)] + pub items: Vec, +} + +/// Rate-limiting window. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateWindow { + #[default] + Second, + Minute, + Hour, + Day, +} + +impl RateWindow { + /// Number of seconds in the window. + #[must_use] + pub fn seconds(self) -> u64 { + match self { + Self::Second => 1, + Self::Minute => 60, + Self::Hour => 3600, + Self::Day => 86_400, + } + } +} + +/// Rate-limit scope for counters. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateLimitScope { + /// Process-wide. + Global, + /// Per-tenant. + #[default] + Tenant, + /// Per-user (from the security context). + User, + /// Per-source IP. + Ip, + /// Per-route. + Route, +} + +/// Rate-limit strategy when the limit is exceeded. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateLimitStrategy { + /// Reject with 429. + #[default] + Reject, + /// Queue (honored as `reject` in the MVP; see [`crate::infra::ratelimit`]). + Queue, + /// Degrade (honored as `reject` in the MVP). + Degrade, +} + +/// Rate-limit algorithm. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum RateLimitAlgorithm { + #[default] + TokenBucket, + SlidingWindow, +} + +/// Rate-limiting configuration (upstream or route). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct RateLimitConfig { + #[serde(default)] + pub sharing: SharingMode, + #[serde(default)] + pub algorithm: RateLimitAlgorithm, + /// Sustained rate (tokens per window). + pub sustained: SustainedRate, + /// Burst capacity (defaults to `sustained.rate`). + #[serde(default)] + pub burst: Option, + #[serde(default)] + pub scope: RateLimitScope, + #[serde(default)] + pub strategy: RateLimitStrategy, + /// Tokens consumed per request. + #[serde(default = "default_cost")] + pub cost: u64, +} + +fn default_cost() -> u64 { + 1 +} + +/// Sustained rate specification. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct SustainedRate { + pub rate: u64, + #[serde(default)] + pub window: RateWindow, +} + +/// Burst specification. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct BurstConfig { + pub capacity: u64, +} + +/// CORS configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct CorsConfig { + #[serde(default)] + pub sharing: SharingMode, + pub enabled: bool, + #[serde(default)] + pub allowed_origins: Vec, + #[serde(default)] + pub allowed_methods: Vec, + #[serde(default)] + pub expose_headers: Vec, + #[serde(default)] + pub allow_credentials: bool, +} + +/// An upstream service definition. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Upstream { + /// System-generated UUID. + #[serde(default = "gen_uuid")] + pub id: Uuid, + #[serde(default = "default_true")] + pub enabled: bool, + /// Routing alias (auto-derived for hostname endpoints; required for IP). + #[serde(default)] + pub alias: String, + #[serde(default)] + pub tags: Vec, + pub server: ServerConfig, + pub protocol: String, + #[serde(default)] + pub auth: Option, + #[serde(default)] + pub headers: HeadersConfig, + #[serde(default)] + pub plugins: PluginsConfig, + #[serde(default)] + pub rate_limit: Option, + #[serde(default)] + pub cors: Option, +} + +fn gen_uuid() -> Uuid { + Uuid::new_v4() +} + +fn default_true() -> bool { + true +} + +/// HTTP route matching rule. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct HttpMatch { + pub methods: Vec, + pub path: String, + #[serde(default)] + pub query_allowlist: Vec, + #[serde(default)] + pub path_suffix_mode: PathSuffixMode, +} + +/// How a proxy `path_suffix` is treated. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum PathSuffixMode { + /// Reject requests that carry a path suffix. + Disabled, + /// Append the suffix to the configured path. + #[default] + Append, +} + +/// gRPC route matching rule. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct GrpcMatch { + pub service: String, + pub method: String, +} + +/// Route matching rules — exactly one of http|grpc must be present. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct MatchRule { + #[serde(default)] + pub http: Option, + #[serde(default)] + pub grpc: Option, +} + +/// A route definition. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct Route { + #[serde(default = "gen_uuid")] + pub id: Uuid, + #[serde(default = "default_true")] + pub enabled: bool, + #[serde(default)] + pub tags: Vec, + /// Reference to the upstream for this route (immutable after creation). + pub upstream_id: Uuid, + #[serde(default)] + pub r#match: Option, + #[serde(default)] + pub plugins: PluginsConfig, + #[serde(default)] + pub rate_limit: Option, + #[serde(default)] + pub cors: Option, +} + +impl Route { + /// HTTP match rules, if this route is HTTP-scoped. + #[must_use] + pub fn http_match(&self) -> Option<&HttpMatch> { + self.r#match.as_ref().and_then(|m| m.http.as_ref()) + } +} + +/// Kind of custom plugin the control plane may store. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "snake_case")] +pub enum PluginKind { + /// Starlark custom plugin. Not executable in the MVP (no sandbox); stored + /// for management compatibility so plugin CRUD and delete-in-use 409 + /// semantics remain testable. + #[default] + Starlark, +} + +/// A custom (tenant-defined) plugin record. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct PluginRecord { + /// Deserializes to the nil UUID when omitted so the control plane can + /// reject client-supplied ids ("id is system-generated"). Unlike + /// `Upstream`/`Route`, the id is *not* generated at deserialization. + #[serde(default)] + pub id: Uuid, + #[serde(default)] + pub name: String, + #[serde(default)] + pub description: String, + #[serde(default)] + pub kind: PluginKind, + /// Starlark source (required for `kind: starlark`). + #[serde(default)] + pub source: String, + /// Whether the plugin is active (bindable). + #[serde(default = "default_true")] + pub enabled: bool, +} + +/// Reference detail for the plugin-delete 409 body (ADR 0001): which +/// upstreams and routes currently bind this plugin. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)] +pub struct ReferencedBy { + #[serde(default)] + pub upstreams: Vec, + #[serde(default)] + pub routes: Vec, +} + +impl ReferencedBy { + #[must_use] + pub fn is_empty(&self) -> bool { + self.upstreams.is_empty() && self.routes.is_empty() + } +} + +/// Key used by the in-memory stores for uniqueness bookkeeping. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum DuplicateKey { + /// `(tenant_id, alias)` pair. + Alias(String, String), + /// Route id already exists. + Route(String), + /// Plugin id already exists. + Plugin(String), +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[test] + fn upstream_deserializes_with_schema_defaults() { + let json = r#"{ + "server": { + "endpoints": [{ "scheme": "https", "host": "api.example.com" }] + }, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + }"#; + let u: Upstream = serde_json::from_str(json).unwrap(); + assert!(u.enabled); + assert_eq!(u.server.endpoints[0].port, 443); + assert_eq!(u.server.endpoints[0].scheme, Scheme::Https); + assert_eq!(u.rate_limit, None); + assert_eq!(u.headers.request.passthrough, PassthroughMode::None); + } + + #[test] + fn route_deserializes_with_schema_defaults() { + let json = r#"{ + "upstream_id": "6f0d7a2e-4a0a-4b0f-8d5a-2e2f0b1a3c4d", + "match": { + "http": { "methods": ["GET"], "path": "/v1/chat" } + } + }"#; + let r: Route = serde_json::from_str(json).unwrap(); + let m = r.http_match().expect("http match present"); + assert_eq!(m.path_suffix_mode, PathSuffixMode::Append); + assert!(r.plugins.items.is_empty()); + // Routes default to enabled (PRD cpt-cf-oagw-fr-enable-disable). + assert!(r.enabled); + } + + #[test] + fn invalid_endpoint_scheme_is_rejected() { + let json = r#"{ + "server": { "endpoints": [{ "scheme": "ftp", "host": "x.com" }] }, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + }"#; + assert!(serde_json::from_str::(json).is_err()); + } + + #[test] + fn scheme_defaults_and_roundtrip() { + assert_eq!(Scheme::Https.default_port(), 443); + assert_eq!(Scheme::Https.as_str(), "https"); + let ser = serde_json::to_string(&Scheme::Wss).unwrap(); + assert_eq!(ser, "\"wss\""); + assert_eq!(Scheme::parse("wt"), Some(Scheme::Wt)); + assert_eq!(Scheme::parse("nope"), None); + } +} diff --git a/gears/system/oagw/oagw/src/domain/plugin/mod.rs b/gears/system/oagw/oagw/src/domain/plugin/mod.rs new file mode 100644 index 0000000..b1d7863 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/plugin/mod.rs @@ -0,0 +1,190 @@ +//! Plugin contracts (DESIGN slices 5 and 6). +//! +//! Two plugin families are implemented in the MVP: +//! +//! - **Auth plugins** (slice 5): one `AuthPlugin` is bound per upstream via +//! `Upstream.auth`. The plugin resolves credentials from `cred_store` by +//! `secret_ref` at request time and injects them into the outbound request +//! (header or query parameter). +//! - **Guard plugins** (slice 6, ADR 0009): multiple may be bound per +//! upstream via `Upstream.plugins.items` and can reject a request or +//! response. `required_headers.v1` is the only guard identifier with a +//! backing implementation. +//! +//! # DESIGN-led deviations +//! +//! - The full generic plugin system (transform chains, per-route plugin +//! bindings, Starlark custom plugins) is out of scope for the MVP; only the +//! upstream-level **auth** and **guard** plugins are implemented here. +//! - `basic.v1` and `bearer.v1` are resolved exactly as the DESIGN states: +//! catalog-only identifiers with no backing implementation — using either +//! fails with `UnknownPlugin` (503 `plugin.not_found.v1`). +//! - Plugin executions are sequential and non-retrying (DESIGN "Retry Policy"). + +use http::HeaderMap; +use toolkit_security::SecurityContext; + +/// Well-known GTS identifiers for the built-in auth plugins +/// (DESIGN "Built-in auth plugins" / "Catalog-only identifiers"). +pub const NOOP_AUTH_PLUGIN_ID: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1"; +pub const API_KEY_AUTH_PLUGIN_ID: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1"; +pub const OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID: &str = + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1"; +pub const OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID: &str = + "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1"; + +/// Catalog-only auth plugin identifiers: reserved in the types-registry but +/// *not* resolvable via `AuthPluginRegistry` (DESIGN "Catalog-only identifiers"). +pub const BASIC_AUTH_PLUGIN_ID: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.basic.v1"; +pub const BEARER_AUTH_PLUGIN_ID: &str = "gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.bearer.v1"; + +/// GTS identifier of the built-in required-headers guard plugin (ADR 0009) — +/// the only guard identifier resolvable via `GuardPluginRegistry`. +pub const REQUIRED_HEADERS_GUARD_PLUGIN_ID: &str = + "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"; + +/// Everything an auth plugin needs to inject credentials into an outbound +/// request. +/// +/// - `headers` collects injected request headers (merged into the outbound +/// request with set semantics after the header-transform pipeline); +/// - `query_params` collects `(name, value)` pairs for query-mode credential +/// injection (appended to the outbound URL query). +pub struct AuthContext<'a> { + /// Authenticated caller (tenant + subject) driving the proxy request. + pub security_context: &'a SecurityContext, + /// The plugin-specific config object from `Upstream.auth.config`. + pub config: &'a serde_json::Value, + /// Outbound request headers being accumulated. + pub headers: &'a mut HeaderMap, + /// Outbound query parameters being accumulated. + pub query_params: &'a mut Vec<(String, String)>, +} + +/// Error reported by an auth plugin, mapped to the DESIGN error table by the +/// data plane: +/// +/// | Variant | HTTP | GTS type | +/// |---|---|---| +/// | `SecretNotFound` | 500 | `...secret.not_found.v1` | +/// | `UnknownPlugin` | 503 | `...plugin.not_found.v1` | +/// | `AuthenticationFailed` | 401 | `...auth.failed.v1` | +/// | `Internal` | 503 | `...link.unavailable.v1` | +#[derive(Debug, Clone)] +pub enum PluginError { + /// A `cred://` reference did not resolve to a secret in `cred_store` + /// (including not-found and non-accessible surfaces). + SecretNotFound(String), + /// The configured plugin id cannot be resolved by the registry (including + /// the catalog-only `basic`/`bearer` identifiers). + UnknownPlugin(String), + /// Credentials could not be produced (bad plugin config, empty secret + /// value, `IdP` exchange failure). + AuthenticationFailed(String), + /// The credential backend/unexpected failure (`cred_store` unreachable). + Internal(String), +} + +/// A credential-injection plugin bound to an upstream. +/// +/// Implementations are stateless where possible; the `OAuth2` plugin owns its +/// internal token cache (ADR 0008). +#[async_trait::async_trait] +pub trait AuthPlugin: Send + Sync { + /// The canonical plugin id (matches `Upstream.auth.type`). + fn id(&self) -> &'static str; + + /// Resolve and inject credentials for one proxied request. + /// + /// # Errors + /// + /// Returns a [`PluginError`] the data plane maps to the gateway error + /// table. Failed executions are never cached by the plugin. + async fn authenticate(&self, ctx: &mut AuthContext<'_>) -> Result<(), PluginError>; +} + +/// Shared helpers for plugin-config parsing (config values are free-form JSON +/// from `Upstream.auth.config`). +#[must_use] +pub fn cfg_string<'a>(config: &'a serde_json::Value, key: &str) -> Option<&'a str> { + config + .get(key) + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|s| !s.is_empty()) +} + +/// Strip a leading `cred://` scheme from a secret reference. `cred_store` +/// references are bare `[a-zA-Z0-9_-]+` names; the DESIGN spells them in +/// config as `cred://` URLs. +#[must_use] +pub fn secret_ref_name(reference: &str) -> &str { + reference.strip_prefix("cred://").unwrap_or(reference) +} + +// --------------------------------------------------------------------------- +// Guard plugins (slice 6, ADR 0009) +// --------------------------------------------------------------------------- + +/// Which phase of the request lifecycle a guard check applies to +/// (ADR 0009's symmetric request/response enforcement). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GuardPhase { + /// The inbound request, checked before proxying to the upstream. + Request, + /// The upstream's response, checked before returning to the caller. + Response, +} + +/// Everything a guard plugin needs to inspect one phase. +pub struct GuardContext<'a> { + /// Authenticated caller driving the proxy request (absent in router-less + /// tests and unauthenticated paths). + pub security_context: Option<&'a SecurityContext>, + /// The plugin-specific config object (from the plugin binding). + pub config: &'a serde_json::Value, + /// The headers being guarded (inbound request or upstream response). + pub headers: &'a HeaderMap, +} + +/// Error reported by a guard plugin, mapped to the DESIGN error table by the +/// data plane. +#[derive(Debug, Clone)] +pub enum GuardError { + /// A configured required header is missing (ADR 0009). The phase selects + /// the status: request → 400, response → 502 (both + /// `...required_header.missing.v1`). + RequiredHeaderMissing { + /// The phase in which the header was missing. + phase: GuardPhase, + /// The first missing header name (lowercased). + header: String, + }, +} + +/// A validation/policy-enforcement plugin (DESIGN "Plugin System"). Bound via +/// `Upstream.plugins.items`; multiple may be bound per upstream and they can +/// reject a request or response (execution order: Auth → Guards → Transform). +pub trait GuardPlugin: Send + Sync { + /// The canonical plugin id (matches the entry in `plugins.items`). + fn id(&self) -> &'static str; + + /// Guard the inbound request (before proxying). Default: allow. + /// + /// # Errors + /// + /// Returns a [`GuardError`] the data plane maps to the gateway error table. + fn guard_request(&self, _ctx: &GuardContext<'_>) -> Result<(), GuardError> { + Ok(()) + } + + /// Guard the upstream's response (before returning it to the caller). + /// Default: allow. + /// + /// # Errors + /// + /// Returns a [`GuardError`] the data plane maps to the gateway error table. + fn guard_response(&self, _ctx: &GuardContext<'_>) -> Result<(), GuardError> { + Ok(()) + } +} diff --git a/gears/system/oagw/oagw/src/domain/service.rs b/gears/system/oagw/oagw/src/domain/service.rs new file mode 100644 index 0000000..98eec85 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/service.rs @@ -0,0 +1,979 @@ +//! Control-plane service for the OAGW gear. +//! +//! Owns the in-memory tenant-scoped store and implements the management CRUD: +//! upstreams, routes, and plugins — with the DESIGN semantics (unique +//! `(tenant_id, alias)`, immutable alias, immutable route `upstream_id`, +//! delete-in-use 409). + +use std::sync::Arc; +use uuid::Uuid; + +use crate::config::OagwConfig; +use crate::domain::error::{ControlPlaneError, DuplicateKind, ResourceRef}; +use crate::domain::models::{PluginRecord, ReferencedBy, Route, Upstream}; +use crate::domain::validation::{ + derive_alias, normalize_alias, validate_route, validate_upstream, +}; +use crate::infra::storage::OagwStore; + +/// The OAGW control-plane service. +/// +/// # DESIGN-led deviation +/// +/// See [`crate::infra::storage`] — the control plane runs on an in-memory +/// store instead of `SeaORM`/`toolkit-db`; all tenant-scoping, uniqueness, and +/// immutability semantics from the DESIGN are preserved. Ancestor-tenant +/// inheritance ("bind", enforced sharing, inherited routes) is resolved at +/// proxy time via the tenant chain (see the data-plane slice); management +/// operations are scoped to the calling tenant. +#[derive(Debug)] +pub struct ControlPlaneService { + /// In-memory tenant-scoped store (upstreams / routes / plugins). + pub(crate) store: OagwStore, + /// Frozen gear config. + pub(crate) config: OagwConfig, +} + +impl ControlPlaneService { + /// Create a new control-plane service. + #[must_use] + pub fn new(config: OagwConfig) -> Self { + Self { + store: OagwStore::new(), + config, + } + } + + /// The gear configuration captured at boot. + #[must_use] + pub fn config(&self) -> &OagwConfig { + &self.config + } + + /// Shared handle for wiring into axum state. + #[must_use] + pub fn shared(config: OagwConfig) -> Arc { + Arc::new(Self::new(config)) + } + + // ------------------------------------------------------------------ + // Upstreams + // ------------------------------------------------------------------ + + /// Create an upstream in `tenant_id`. + /// + /// Validates the payload (alias derivation), rejects duplicate aliases + /// within the tenant (409), and assigns a fresh system id. + /// + /// # Errors + /// + /// Returns [`ControlPlaneError::Validation`] on invalid payloads and + /// [`ControlPlaneError::Duplicate`] when the alias is already taken. + pub fn create_upstream( + &self, + tenant_id: Uuid, + mut upstream: Upstream, + ) -> Result { + validate_upstream(&mut upstream) + .map_err(|details| ControlPlaneError::Validation { details })?; + let alias = upstream.alias.clone(); + + let table = self.store.upstreams(tenant_id); + let aliases = self.store.aliases(tenant_id); + // Atomic alias reservation: the DashMap `entry()` API performs the + // check-and-insert under a single shard lock, so two concurrent + // creates with the same alias cannot both succeed (one sees + // `Occupied` and returns 409). + match aliases.entry(alias.clone()) { + dashmap::mapref::entry::Entry::Occupied(occupied) => { + let owner = *occupied.get(); + // Exact same resource already exists under this alias → conflict. + return Err(ControlPlaneError::Duplicate(DuplicateKind::AliasTaken { + alias, + owner: owner.to_string(), + })); + } + dashmap::mapref::entry::Entry::Vacant(vacant) => { + // System-generated id wins. + upstream.id = Uuid::new_v4(); + let id = upstream.id; + table.insert(id, upstream.clone()); + // NOTE: `vacant` holds the aliases-map shard write-lock; the + // upstream row is a *different* DashMap so inserting into it + // is safe, but do NOT re-enter `self.store.aliases()` (e.g. + // via `entry()`) before `vacant` is dropped — that re-enters + // the same shard and deadlocks. + vacant.insert(id); + } + } + Ok(upstream) + } + + /// Fetch an upstream by id (tenant-scoped). + /// + /// # Errors + /// + /// Returns [`ControlPlaneError::NotFound`] when no upstream matches `id`. + pub fn get_upstream(&self, tenant_id: Uuid, id: Uuid) -> Result { + self.store + .upstreams(tenant_id) + .get(&id) + .map(|r| r.clone()) + .ok_or_else(|| ControlPlaneError::NotFound(ResourceRef::Upstream(id.to_string()))) + } + + /// List all upstreams visible in `tenant_id` (creation order). + #[must_use] + pub fn list_upstreams(&self, tenant_id: Uuid) -> Vec { + let mut out: Vec = self + .store + .upstreams(tenant_id) + .iter() + .map(|e| e.value().clone()) + .collect(); + out.sort_by_key(|u| u.id); + out + } + + /// Update (`PUT`) an upstream. The alias is immutable: any payload whose + /// recomputed alias differs from the stored alias is rejected (400). + /// + /// # Errors + /// + /// Returns [`ControlPlaneError::NotFound`] for unknown ids, + /// [`ControlPlaneError::ImmutableAlias`] on alias changes, and + /// [`ControlPlaneError::Validation`] on invalid payloads. + pub fn update_upstream( + &self, + tenant_id: Uuid, + id: Uuid, + mut upstream: Upstream, + ) -> Result { + let existing = self.get_upstream(tenant_id, id)?; + + // Alias immutability: an explicitly provided alias that differs from + // the stored one is rejected up front (before generic validation), + // surfacing the DESIGN's "alias is immutable" 400. + let stored = normalize_alias(&existing.alias); + let provided = normalize_alias(&upstream.alias); + if !provided.is_empty() && provided != stored { + return Err(ControlPlaneError::ImmutableAlias); + } + + // A PUT payload that omits the alias on an IP-based upstream (whose + // alias cannot be derived) is tolerated: carry over the stored alias. + // A provider *different* alias was already rejected above, and a + // hostname-based payload re-derives its alias below as usual. + if upstream.alias.trim().is_empty() && derive_alias(&upstream).is_ok_and(|d| d.is_none()) { + upstream.alias.clone_from(&existing.alias); + } + + // Validate + recompute the derived alias for the new endpoints. + validate_upstream(&mut upstream) + .map_err(|details| ControlPlaneError::Validation { details })?; + + // Endpoints that would re-derive a different alias are also an + // (attempted) alias change → immutable. + if normalize_alias(&upstream.alias) != stored { + return Err(ControlPlaneError::ImmutableAlias); + } + + // Alias slot is unchanged; keep the original id. + upstream.id = id; + self.store.upstreams(tenant_id).insert(id, upstream.clone()); + Ok(upstream) + } + + /// Delete an upstream by id (tenant-scoped), cascading to every route + /// bound to it in the tenant (a route cannot dangle on a missing + /// upstream). + /// + /// # Errors + /// + /// Returns [`ControlPlaneError::NotFound`] when no upstream matches `id`. + pub fn delete_upstream(&self, tenant_id: Uuid, id: Uuid) -> Result<(), ControlPlaneError> { + let existing = self.get_upstream(tenant_id, id)?; + self.store.upstreams(tenant_id).remove(&id); + self.store + .aliases(tenant_id) + .remove(&normalize_alias(&existing.alias)); + // Cascade: all routes of this tenant bound to the upstream are removed + // with it (the route's `upstream_id` is immutable, so none can be + // salvaged by re-pointing). + self.store.routes(tenant_id).retain(|_, r| r.upstream_id != id); + Ok(()) + } + + // ------------------------------------------------------------------ + // Routes + // ------------------------------------------------------------------ + + /// Create a route in `tenant_id`. `upstream_id` must belong to the tenant; + /// a duplicate (`upstream_id`, path, method) tuple is a 409 conflict. + /// + /// # Errors + /// + /// Returns [`ControlPlaneError::Validation`] when the route is invalid or + /// references an upstream outside the tenant, and + /// [`ControlPlaneError::Duplicate`] on a conflicting route. + pub fn create_route( + &self, + tenant_id: Uuid, + mut route: Route, + ) -> Result { + validate_route(&route).map_err(|details| ControlPlaneError::Validation { details })?; + if self + .store + .upstreams(tenant_id) + .get(&route.upstream_id) + .is_none() + { + return Err(ControlPlaneError::Validation { + details: format!( + "upstream_id '{}' does not belong to the calling tenant", + route.upstream_id + ), + }); + } + + let table = self.store.routes(tenant_id); + if let Some(existing) = table.iter().find(|e| routes_conflict(e.value(), &route)) { + return Err(ControlPlaneError::Duplicate(DuplicateKind::RouteExists { + id: existing.value().id.to_string(), + })); + } + + route.id = Uuid::new_v4(); + let id = route.id; + table.insert(id, route.clone()); + Ok(route) + } + + /// Fetch a route by id (tenant-scoped). + /// + /// # Errors + /// + /// Returns [`ControlPlaneError::NotFound`] when no route matches `id`. + pub fn get_route(&self, tenant_id: Uuid, id: Uuid) -> Result { + self.store + .routes(tenant_id) + .get(&id) + .map(|r| r.clone()) + .ok_or_else(|| ControlPlaneError::NotFound(ResourceRef::Route(id.to_string()))) + } + + /// List all routes visible in `tenant_id`. + #[must_use] + pub fn list_routes(&self, tenant_id: Uuid) -> Vec { + let mut out: Vec = self + .store + .routes(tenant_id) + .iter() + .map(|e| e.value().clone()) + .collect(); + out.sort_by_key(|r| r.id); + out + } + + /// Update (`PUT`) a route. `upstream_id` is immutable. + /// + /// # Errors + /// + /// Returns [`ControlPlaneError::NotFound`] for unknown ids, + /// [`ControlPlaneError::ImmutableUpstreamId`] on upstream changes, + /// [`ControlPlaneError::Validation`] on invalid payloads, and + /// [`ControlPlaneError::Duplicate`] on a conflicting route. + pub fn update_route( + &self, + tenant_id: Uuid, + id: Uuid, + mut route: Route, + ) -> Result { + let existing = self.get_route(tenant_id, id)?; + if route.upstream_id != existing.upstream_id { + return Err(ControlPlaneError::ImmutableUpstreamId); + } + validate_route(&route).map_err(|details| ControlPlaneError::Validation { details })?; + + let table = self.store.routes(tenant_id); + if let Some(conflict) = table + .iter() + .find(|e| e.key() != &id && routes_conflict(e.value(), &route)) + { + return Err(ControlPlaneError::Duplicate(DuplicateKind::RouteExists { + id: conflict.value().id.to_string(), + })); + } + + route.id = id; + table.insert(id, route.clone()); + Ok(route) + } + + /// Delete a route by id (tenant-scoped). + /// + /// # Errors + /// + /// Returns [`ControlPlaneError::NotFound`] when no route matches `id`. + pub fn delete_route(&self, tenant_id: Uuid, id: Uuid) -> Result<(), ControlPlaneError> { + self.get_route(tenant_id, id)?; + self.store.routes(tenant_id).remove(&id); + Ok(()) + } + + // ------------------------------------------------------------------ + // Plugins + // ------------------------------------------------------------------ + + /// Create a custom plugin record. Starlark plugins require source text. + /// + /// # Errors + /// + /// Returns [`ControlPlaneError::Validation`] for missing names/source and + /// client-supplied ids. + pub fn create_plugin( + &self, + tenant_id: Uuid, + mut plugin: PluginRecord, + ) -> Result { + if plugin.name.trim().is_empty() { + return Err(ControlPlaneError::Validation { + details: "plugin requires a non-empty 'name'".to_owned(), + }); + } + if matches!(plugin.kind, crate::domain::models::PluginKind::Starlark) + && plugin.source.trim().is_empty() + { + return Err(ControlPlaneError::Validation { + details: "starlark plugin requires non-empty 'source'".to_owned(), + }); + } + if plugin.id != Uuid::default() { + return Err(ControlPlaneError::Validation { + details: "plugin 'id' is system-generated".to_owned(), + }); + } + plugin.id = Uuid::new_v4(); + let id = plugin.id; + self.store.plugins(tenant_id).insert(id, plugin.clone()); + Ok(plugin) + } + + /// Fetch a plugin by id (tenant-scoped). + /// + /// # Errors + /// + /// Returns [`ControlPlaneError::NotFound`] when no plugin matches `id`. + pub fn get_plugin(&self, tenant_id: Uuid, id: Uuid) -> Result { + self.store + .plugins(tenant_id) + .get(&id) + .map(|r| r.clone()) + .ok_or_else(|| ControlPlaneError::NotFound(ResourceRef::Plugin(id.to_string()))) + } + + /// List all plugins visible in `tenant_id`. + #[must_use] + pub fn list_plugins(&self, tenant_id: Uuid) -> Vec { + let mut out: Vec = self + .store + .plugins(tenant_id) + .iter() + .map(|e| e.value().clone()) + .collect(); + out.sort_by_key(|p| p.id); + out + } + + /// Delete a plugin. Plugins still referenced by an upstream or route are + /// rejected with a 409 carrying the referencing ids (ADR 0001). + /// + /// # Errors + /// + /// Returns [`ControlPlaneError::NotFound`] when no plugin matches `id`, + /// and [`ControlPlaneError::InUse`] when the plugin is still bound. + pub fn delete_plugin(&self, tenant_id: Uuid, id: Uuid) -> Result<(), ControlPlaneError> { + self.get_plugin(tenant_id, id)?; + + let id_str = id.to_string(); + let mut referenced_by = ReferencedBy::default(); + for u in self.store.upstreams(tenant_id).iter() { + if u.value().plugins.items.iter().any(|p| p == &id_str) { + referenced_by.upstreams.push(u.value().id.to_string()); + } + } + for r in self.store.routes(tenant_id).iter() { + if r.value().plugins.items.iter().any(|p| p == &id_str) { + referenced_by.routes.push(r.value().id.to_string()); + } + } + referenced_by.upstreams.sort(); + referenced_by.routes.sort(); + + if !referenced_by.is_empty() { + return Err(ControlPlaneError::InUse( + ResourceRef::Plugin(id_str), + referenced_by, + )); + } + self.store.plugins(tenant_id).remove(&id); + Ok(()) + } + + // ------------------------------------------------------------------ + // Data-plane lookups + // ------------------------------------------------------------------ + + /// Resolve the closest enabled upstream by normalized alias across a + /// tenant chain (descendant → root; the calling tenant shadows ancestors, + /// DESIGN "Alias Resolution"). `chain[0]` must be the calling tenant. + /// + /// A disabled upstream that owns the alias short-circuits the chain: + /// [`AliasResolution::Disabled`] is returned instead of falling through to + /// an ancestor's enabled copy, so the proxy can reject with 503 + /// (PRD cpt-cf-oagw-fr-enable-disable). + #[must_use] + pub fn resolve_upstream_in_chain(&self, chain: &[Uuid], alias: &str) -> AliasResolution { + let norm = normalize_alias(alias); + for t in chain { + let owner = match self.store.aliases(*t).get(&norm) { + Some(id) => *id, + None => continue, + }; + let upstreams = self.store.upstreams(*t); + let Some(u) = upstreams.get(&owner) else { + continue; + }; + if u.enabled { + return AliasResolution::Found(Box::new(u.clone())); + } + // The closest owning tenant holds a disabled upstream: shadow. + return AliasResolution::Disabled; + } + AliasResolution::NotFound + } + + /// All routes bound to `upstream_id` found across the tenant chain. + /// `chain` order (descendant first) preserves the shadowing priority for + /// the caller's route-matching loop. + #[must_use] + pub fn list_routes_for_upstream(&self, chain: &[Uuid], upstream_id: Uuid) -> Vec { + let mut out = Vec::new(); + for t in chain { + for e in self.store.routes(*t).iter() { + if e.value().upstream_id == upstream_id { + out.push(e.value().clone()); + } + } + } + out + } +} + +/// Two HTTP routes conflict when they share `(upstream_id, path, method)`. +/// Paths are compared normalized (a leading '/' required), so `/v1` and +/// `/v1/` are the same route target. +fn routes_conflict(a: &Route, b: &Route) -> bool { + let Some(am) = a.http_match() else { + return false; + }; + let Some(bm) = b.http_match() else { + return false; + }; + if a.upstream_id != b.upstream_id || norm_path(&am.path) != norm_path(&bm.path) { + return false; + } + am.methods.iter().any(|m| bm.methods.contains(m)) +} + +/// Local path normalization for comparisons: guarantee a leading '/' and +/// drop an insignificant trailing slash, so `/v1` and `/v1/` are the same +/// route target (the root path "/" is preserved as-is). +fn norm_path(path: &str) -> String { + let p = if path.starts_with('/') { + path.to_owned() + } else { + format!("/{path}") + }; + if p.len() > 1 && p.ends_with('/') { + p[..p.len() - 1].to_owned() + } else { + p + } +} + +/// The outcome of resolving a route alias to an upstream across a tenant +/// chain (see [`ControlPlaneService::resolve_upstream_in_chain`]). Distinct +/// from a plain `Option` so callers can distinguish "no such alias" from "the +/// alias exists but is disabled and therefore shadows". +#[derive(Debug, Clone, PartialEq)] +pub enum AliasResolution { + /// The alias resolved to an enabled upstream. + Found(Box), + /// The closest alias-owning tenant holds a *disabled* upstream (which + /// shadows any ancestor's enabled copy). + Disabled, + /// No tenant in the chain owns the alias. + NotFound, +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use crate::domain::models::{ + Endpoint, HeadersConfig, HttpMatch, MatchRule, PROTOCOL_HTTP_V1, PathSuffixMode, + PluginKind, PluginRecord, PluginsConfig, Route, Scheme, ServerConfig, + }; + + fn tenant(id: u64) -> Uuid { + Uuid::from_u128(u128::from(id)) + } + + fn service() -> ControlPlaneService { + ControlPlaneService::new(crate::config::OagwConfig::default()) + } + + fn upstream(host: &str, port: u16) -> Upstream { + Upstream { + id: Uuid::new_v4(), + enabled: true, + alias: String::new(), + tags: vec![], + server: ServerConfig { + endpoints: vec![Endpoint { + scheme: Scheme::Https, + host: host.to_owned(), + port, + }], + }, + protocol: PROTOCOL_HTTP_V1.to_owned(), + auth: None, + headers: HeadersConfig::default(), + plugins: PluginsConfig::default(), + rate_limit: None, + cors: None, + } + } + + fn route(upstream_id: Uuid, methods: &[&str], path: &str) -> Route { + Route { + id: Uuid::new_v4(), + enabled: true, + tags: vec![], + upstream_id, + r#match: Some(MatchRule { + http: Some(HttpMatch { + methods: methods + .iter() + .map(std::string::ToString::to_string) + .collect(), + path: path.to_owned(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }), + plugins: PluginsConfig::default(), + rate_limit: None, + cors: None, + } + } + + fn plugin(name: &str) -> PluginRecord { + PluginRecord { + id: Uuid::default(), + name: name.to_owned(), + description: String::new(), + kind: PluginKind::Starlark, + source: "def handle(req):\n return req".to_owned(), + enabled: true, + } + } + + // ------------------------------------------------------------------ + // Upstreams + // ------------------------------------------------------------------ + + #[test] + fn create_upstream_derives_alias_and_populates_system_id() { + let svc = service(); + let u = upstream("api.example.com", 443); + let created = svc.create_upstream(tenant(1), u.clone()).unwrap(); + assert_eq!(created.alias, "api.example.com"); + assert_ne!(created.id, u.id, "system id replaces client id"); + assert_eq!( + svc.get_upstream(tenant(1), created.id).unwrap().alias, + "api.example.com" + ); + } + + #[test] + fn duplicate_alias_within_tenant_is_conflict() { + let svc = service(); + svc.create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap(); + let err = svc + .create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap_err(); + assert!(matches!( + err, + ControlPlaneError::Duplicate(DuplicateKind::AliasTaken { ref alias, .. }) + if alias == "api.example.com" + )); + } + + #[test] + fn same_alias_in_different_tenant_is_allowed() { + let svc = service(); + svc.create_upstream(tenant(1), upstream("shared.example.com", 443)) + .unwrap(); + let u2 = svc + .create_upstream(tenant(2), upstream("shared.example.com", 443)) + .unwrap(); + assert_eq!(u2.alias, "shared.example.com"); + } + + #[test] + fn get_missing_upstream_is_not_found() { + let svc = service(); + let err = svc.get_upstream(tenant(1), Uuid::new_v4()).unwrap_err(); + assert!(matches!( + err, + ControlPlaneError::NotFound(ResourceRef::Upstream(_)) + )); + } + + #[test] + fn update_upstream_alias_is_immutable() { + let svc = service(); + let created = svc + .create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap(); + // Payload tries to change the alias explicitly to a different value. + let mut changed = created.clone(); + changed.alias = "other.example.com".to_owned(); + let err = svc + .update_upstream(tenant(1), created.id, changed) + .unwrap_err(); + assert!(matches!(err, ControlPlaneError::ImmutableAlias)); + } + + #[test] + fn update_upstream_recomputes_derived_alias_and_rejects_change() { + let svc = service(); + let created = svc + .create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap(); + // Same alias kept, but endpoints now derive a different alias → 400 + // (either the generic alias/derived mismatch validation or the + // immutability error — both are 400-class rejections). + let mut changed = created.clone(); + changed.server.endpoints[0].host = "api2.example.com".to_owned(); + let err = svc + .update_upstream(tenant(1), created.id, changed) + .unwrap_err(); + assert!( + matches!( + err, + ControlPlaneError::ImmutableAlias | ControlPlaneError::Validation { .. } + ), + "endpoint change must be rejected with a 400-class error" + ); + + // Blank-alias payload: validation derives the new alias; the service + // still rejects because it differs from the stored one. + let mut changed2 = created.clone(); + changed2.alias.clear(); + changed2.server.endpoints[0].host = "api2.example.com".to_owned(); + let err2 = svc + .update_upstream(tenant(1), created.id, changed2) + .unwrap_err(); + assert!(matches!(err2, ControlPlaneError::ImmutableAlias)); + } + + #[test] + fn update_upstream_keeps_id_and_alias_on_legit_update() { + let svc = service(); + let created = svc + .create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap(); + let mut changed = created.clone(); + changed.tags = vec!["prod".to_owned()]; + let updated = svc.update_upstream(tenant(1), created.id, changed).unwrap(); + assert_eq!(updated.id, created.id); + assert_eq!(updated.alias, "api.example.com"); + assert_eq!(updated.tags, vec!["prod"]); + } + + #[test] + fn delete_upstream_frees_the_alias_slot() { + let svc = service(); + let created = svc + .create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap(); + assert!(svc.delete_upstream(tenant(1), created.id).is_ok()); + assert!(matches!( + svc.get_upstream(tenant(1), created.id), + Err(ControlPlaneError::NotFound(_)) + )); + // Alias slot is free again. + let again = svc + .create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap(); + assert_eq!(again.alias, "api.example.com"); + } + + #[test] + fn delete_missing_upstream_is_not_found() { + let svc = service(); + assert!(matches!( + svc.delete_upstream(tenant(1), Uuid::new_v4()), + Err(ControlPlaneError::NotFound(_)) + )); + } + + // ------------------------------------------------------------------ + // Routes + // ------------------------------------------------------------------ + + #[test] + fn create_route_rejects_upstream_of_another_tenant() { + let svc = service(); + let u = svc + .create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap(); + let err = svc + .create_route(tenant(2), route(u.id, &["GET"], "/v1")) + .unwrap_err(); + assert!( + matches!(err, ControlPlaneError::Validation { ref details } if details.contains("does not belong")) + ); + } + + #[test] + fn create_route_duplicate_path_and_method_is_conflict() { + let svc = service(); + let u = svc + .create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap(); + svc.create_route(tenant(1), route(u.id, &["GET", "POST"], "/v1/chat")) + .unwrap(); + // Same upstream + path, overlapping method → conflict. + let err = svc + .create_route(tenant(1), route(u.id, &["POST"], "/v1/chat")) + .unwrap_err(); + assert!(matches!( + err, + ControlPlaneError::Duplicate(DuplicateKind::RouteExists { .. }) + )); + } + + #[test] + fn create_route_same_path_different_method_is_allowed() { + let svc = service(); + let u = svc + .create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap(); + svc.create_route(tenant(1), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + svc.create_route(tenant(1), route(u.id, &["PUT"], "/v1/chat")) + .unwrap(); + assert_eq!(svc.list_routes(tenant(1)).len(), 2); + } + + #[test] + fn route_crud_roundtrip() { + let svc = service(); + let u = svc + .create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap(); + let r = svc + .create_route(tenant(1), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + assert_ne!(r.id, Uuid::new_v4()); + assert_eq!(svc.list_routes(tenant(1)).len(), 1); + + let mut updated = r.clone(); + updated.tags = vec!["internal".to_owned()]; + let saved = svc.update_route(tenant(1), r.id, updated).unwrap(); + assert_eq!(saved.id, r.id); + assert_eq!(saved.tags, vec!["internal"]); + + assert!(svc.delete_route(tenant(1), r.id).is_ok()); + assert_eq!(svc.list_routes(tenant(1)).len(), 0); + } + + #[test] + fn update_route_upstream_id_is_immutable() { + let svc = service(); + let u1 = svc + .create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap(); + let u2 = svc + .create_upstream(tenant(1), upstream("api2.example.com", 443)) + .unwrap(); + let r = svc + .create_route(tenant(1), route(u1.id, &["GET"], "/v1")) + .unwrap(); + let mut changed = r.clone(); + changed.upstream_id = u2.id; + let err = svc.update_route(tenant(1), r.id, changed).unwrap_err(); + assert!(matches!(err, ControlPlaneError::ImmutableUpstreamId)); + } + + // ------------------------------------------------------------------ + // Rf-008 / Rf-011 / Rf-014 regression tests (semantic review) + // ------------------------------------------------------------------ + + #[test] + fn delete_upstream_cascades_its_routes() { + let svc = service(); + let u = svc + .create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap(); + let r = svc + .create_route(tenant(1), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + svc.create_route(tenant(1), route(u.id, &["PUT"], "/v1/chat")) + .unwrap(); + assert_eq!(svc.list_routes(tenant(1)).len(), 2); + + svc.delete_upstream(tenant(1), u.id).unwrap(); + // The upstream's routes cascade away with it. + assert_eq!(svc.list_routes(tenant(1)).len(), 0); + assert!(matches!( + svc.get_route(tenant(1), r.id), + Err(ControlPlaneError::NotFound(_)) + )); + // The freed alias is immediately reusable, with a fresh route slot. + let again = svc + .create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap(); + assert_eq!(again.alias, "api.example.com"); + svc.create_route(tenant(1), route(again.id, &["GET"], "/v1/chat")) + .unwrap(); + } + + #[test] + fn concurrent_create_with_same_alias_yields_exactly_one_winner() { + let svc = Arc::new(service()); + let results: Vec<_> = (0..8) + .map(|_| { + let svc = Arc::clone(&svc); + std::thread::spawn(move || { + svc.create_upstream(tenant(1), upstream("race.example.com", 443)) + }) + .join() + .unwrap() + }) + .collect(); + let ok = results.iter().filter(|r| r.is_ok()).count(); + let taken = results + .iter() + .filter(|r| { + matches!( + r, + Err(ControlPlaneError::Duplicate(DuplicateKind::AliasTaken { .. })) + ) + }) + .count(); + assert_eq!(ok, 1, "exactly one thread may win the alias"); + assert_eq!(taken, results.len() - 1); + assert_eq!(svc.list_upstreams(tenant(1)).len(), 1); + } + + #[test] + fn route_paths_with_and_without_trailing_slash_conflict() { + let svc = service(); + let u = svc + .create_upstream(tenant(1), upstream("api.example.com", 443)) + .unwrap(); + // `/v1` and `/v1/` normalize to the same route target (Rf-014). + svc.create_route(tenant(1), route(u.id, &["GET"], "/v1")) + .unwrap(); + let err = svc + .create_route(tenant(1), route(u.id, &["GET"], "/v1/")) + .unwrap_err(); + assert!(matches!( + err, + ControlPlaneError::Duplicate(DuplicateKind::RouteExists { .. }) + )); + } + + // ------------------------------------------------------------------ + // Plugins + // ------------------------------------------------------------------ + + #[test] + fn create_plugin_requires_name_and_starlark_source() { + let svc = service(); + let mut p = plugin("x"); + p.name = " ".to_owned(); + assert!(matches!( + svc.create_plugin(tenant(1), p), + Err(ControlPlaneError::Validation { .. }) + )); + let mut p2 = plugin("x"); + p2.kind = PluginKind::Starlark; + p2.source = " ".to_owned(); + assert!(matches!( + svc.create_plugin(tenant(1), p2), + Err(ControlPlaneError::Validation { .. }) + )); + } + + #[test] + fn plugin_crud_roundtrip_and_client_id_rejected() { + let svc = service(); + let mut p = plugin("jwt-audit"); + p.id = Uuid::new_v4(); // client-supplied id must be ignored/rejected + let err = svc.create_plugin(tenant(1), p.clone()).unwrap_err(); + assert!( + matches!(err, ControlPlaneError::Validation { ref details } if details.contains("system-generated")) + ); + + p.id = Uuid::default(); + let created = svc.create_plugin(tenant(1), p).unwrap(); + assert_ne!(created.id, Uuid::default()); + assert_eq!( + svc.get_plugin(tenant(1), created.id).unwrap().name, + "jwt-audit" + ); + assert_eq!(svc.list_plugins(tenant(1)).len(), 1); + assert!(svc.delete_plugin(tenant(1), created.id).is_ok()); + } + + #[test] + fn delete_plugin_in_use_is_conflict_with_references() { + let svc = service(); + let p = svc.create_plugin(tenant(1), plugin("transform")).unwrap(); + let mut u = upstream("api.example.com", 443); + u.plugins.items = vec![p.id.to_string()]; + let up = svc.create_upstream(tenant(1), u).unwrap(); + + let mut r = route(up.id, &["GET"], "/v1"); + r.plugins.items = vec![p.id.to_string()]; + let route = svc.create_route(tenant(1), r).unwrap(); + + match svc.delete_plugin(tenant(1), p.id) { + Err(ControlPlaneError::InUse(ref_, referenced)) => { + assert!(matches!(ref_, ResourceRef::Plugin(_))); + assert!(referenced.upstreams.contains(&up.id.to_string())); + assert!(referenced.routes.contains(&route.id.to_string())); + } + other => panic!("expected InUse, got {other:?}"), + } + } + + #[test] + fn delete_plugin_is_allowed_once_unreferenced() { + let svc = service(); + let p = svc.create_plugin(tenant(1), plugin("transform")).unwrap(); + assert!(svc.delete_plugin(tenant(1), p.id).is_ok()); + assert!(matches!( + svc.get_plugin(tenant(1), p.id), + Err(ControlPlaneError::NotFound(_)) + )); + } +} diff --git a/gears/system/oagw/oagw/src/domain/validation.rs b/gears/system/oagw/oagw/src/domain/validation.rs new file mode 100644 index 0000000..57c2202 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/validation.rs @@ -0,0 +1,849 @@ +//! Create/update validation for OAGW domain objects, including alias +//! derivation (DESIGN.md "Alias Rules"). +//! +//! Alias derivation rules implemented here: +//! +//! - Single hostname endpoint, standard port (HTTP 80 / HTTPS/WSS/WT/gRPC 443) +//! → alias is the hostname. +//! - Single hostname endpoint, non-standard port → alias is `host:port`. +//! - Multiple hostname endpoints sharing a common domain suffix (≥ 2 labels, +//! PSL-validated registrable domain) → alias is the common suffix (with +//! `:port` when the shared port is non-standard). +//! - Any IP-based endpoint → not derivable; an explicit alias is required. +//! - A bare public suffix (e.g. `co.uk`) is never accepted as a derived alias. +//! - Aliases are normalized to ASCII lowercase with trailing dots stripped, +//! and resolved case-insensitively. +//! +//! Hostname-based upstreams *auto-derive* their alias: a user-provided alias +//! matching the derived value is an idempotent no-op; any other user-provided +//! alias is a validation error. Not-derivable (IP / mixed-port) upstreams +//! require an explicit alias. + +use crate::domain::models::{ + Endpoint, MatchRule, PROTOCOL_GRPC_V1, PROTOCOL_HTTP_V1, RateLimitConfig, Route, Upstream, +}; +use http::header::HeaderName; +use http::HeaderValue; +use std::collections::BTreeMap; +use std::net::IpAddr; +use std::str::FromStr; + +/// Allowed HTTP route methods (route.v1.schema.json). +const ALLOWED_METHODS: [&str; 5] = ["GET", "POST", "PUT", "DELETE", "PATCH"]; +/// Allowed upstream protocols. +const ALLOWED_PROTOCOLS: [&str; 2] = [PROTOCOL_HTTP_V1, PROTOCOL_GRPC_V1]; + +/// Minimum alias length. +pub const MIN_ALIAS_LEN: usize = 1; +/// Maximum alias length (defensive). +pub const MAX_ALIAS_LEN: usize = 253; + +/// Normalize an alias: ASCII lowercase, strip trailing dots. +#[must_use] +pub fn normalize_alias(alias: &str) -> String { + alias + .trim() + .to_ascii_lowercase() + .trim_end_matches('.') + .to_owned() +} + +/// Whether `alias` matches the upstream schema pattern: +/// `^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$`. +#[must_use] +pub fn alias_is_valid_format(alias: &str) -> bool { + if alias.is_empty() + || alias.len() > MAX_ALIAS_LEN + || !alias.bytes().all(|b| { + b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b':' || b == b'-' + }) + { + return false; + } + let first = alias.as_bytes()[0]; + let last = alias.as_bytes()[alias.len() - 1]; + let alnum = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit(); + alnum(first) && alnum(last) +} + +/// Whether a host string is an IP address. +#[must_use] +pub fn is_ip(host: &str) -> bool { + IpAddr::from_str(host).is_ok() +} + +fn hostname_labels(host: &str) -> Vec { + host.split('.').map(ToOwned::to_owned).collect() +} + +/// Longest common suffix (from the right) of `a` and `b` label lists. +fn common_suffix_labels(a: &[String], b: &[String]) -> Vec { + let mut common = Vec::new(); + let mut ai = a.len(); + let mut bi = b.len(); + while ai > 0 && bi > 0 && a[ai - 1] == b[bi - 1] { + common.push(a[ai - 1].clone()); + ai -= 1; + bi -= 1; + } + common.reverse(); + common +} + +/// Compute the derived alias for a set of endpoints, or `None` when the +/// upstream is not derivable (IP-based endpoints, mixed ports, or a common +/// suffix that is not a PSL-valid registrable domain with ≥ 2 labels). +fn derive_from_endpoints(endpoints: &[Endpoint]) -> Option { + if endpoints.is_empty() { + return None; + } + if endpoints.iter().any(|e| is_ip(&e.host)) { + return None; + } + // Collect normalized hostnames. + let hosts: Vec = endpoints.iter().map(|e| normalize_alias(&e.host)).collect(); + if hosts.iter().any(String::is_empty) { + return None; + } + // Single endpoint: hostname or hostname:port (non-standard port). + if endpoints.len() == 1 { + let e = &endpoints[0]; + let host = normalize_alias(&e.host); + if e.port == e.scheme.default_port() { + return Some(host); + } + return Some(format!("{host}:{}", e.port)); + } + // Multiple endpoints: longest common label suffix. + let mut common: Option> = None; + for h in &hosts { + let labels = hostname_labels(h); + common = Some(match common { + None => labels, + Some(cur) => common_suffix_labels(&cur, &labels), + }); + } + let common = common.unwrap_or_default(); + if common.len() < 2 { + return None; // requirement: ≥ 2 labels + } + let suffix = common.join("."); + // Reject bare public suffixes (e.g. `co.uk` has no registrable domain). + if psl::domain_str(&suffix) != Some(suffix.as_str()) { + return None; + } + // Ports: require consistency across endpoints; non-standard port appends `:port`. + let first_port = endpoints[0].port; + let first_scheme = endpoints[0].scheme; + let all_same_port = endpoints.iter().all(|e| e.port == first_port); + if !all_same_port { + return None; + } + if first_port == first_scheme.default_port() { + Some(suffix) + } else { + Some(format!("{suffix}:{first_port}")) + } +} + +/// Attempt to derive the alias for an upstream (without mutating it). +/// +/// Returns `Ok(Some(alias))` when derivable, `Ok(None)` when the upstream +/// requires an explicit alias, `Err(message)` on malformed endpoint data. +/// +/// # Errors +/// +/// Returns a human-readable message when the upstream defines no endpoints. +pub fn derive_alias(upstream: &Upstream) -> Result, String> { + if upstream.server.endpoints.is_empty() { + return Err("upstream must define at least one endpoint".to_owned()); + } + Ok(derive_from_endpoints(&upstream.server.endpoints)) +} + +/// Validate protocol identifier and normalize server config. +/// +/// # Errors +/// +/// Returns a human-readable message for unsupported protocol identifiers. +pub fn validate_protocol(protocol: &str) -> Result<(), String> { + if !ALLOWED_PROTOCOLS.contains(&protocol) { + return Err(format!( + "unsupported protocol '{protocol}' (expected {PROTOCOL_HTTP_V1} or {PROTOCOL_GRPC_V1})" + )); + } + Ok(()) +} + +/// Validate an endpoint's host is non-empty and scheme is supported. +fn validate_endpoint(e: &Endpoint) -> Result<(), String> { + if e.host.trim().is_empty() { + return Err("endpoint host must not be empty".to_owned()); + } + if !(1..=65535).contains(&e.port) { + return Err(format!("endpoint port {} out of range (1..65535)", e.port)); + } + let _ = e.scheme; // Scheme is validated at deserialization. + Ok(()) +} + +/// Full create-time validation of an upstream. On success the upstream's +/// `alias` is populated with the derived alias when derivable (an explicit +/// alias equal to the derived value is a no-op; a non-matching explicit alias +/// is an error for derivable upstreams). +/// +/// # Errors +/// Returns a human-readable validation message on failure. +pub fn validate_upstream(upstream: &mut Upstream) -> Result<(), String> { + if upstream.server.endpoints.is_empty() { + return Err("upstream must define at least one endpoint".to_owned()); + } + for e in &upstream.server.endpoints { + validate_endpoint(e)?; + } + validate_protocol(&upstream.protocol)?; + if upstream.tags.iter().any(|t| !valid_tag(t)) { + return Err("tags must match ^[a-z0-9_-]+$".to_owned()); + } + if let Some(cors) = &upstream.cors { + validate_cors(cors)?; + } + if let Some(rate) = &upstream.rate_limit { + validate_rate_limit(rate)?; + } + validate_headers_config(&upstream.headers)?; + + let derived = derive_from_endpoints(&upstream.server.endpoints); + let provided = upstream.alias.trim(); + let provided_norm = if provided.is_empty() { + None + } else { + Some(normalize_alias(provided)) + }; + + match (derived, provided_norm) { + (Some(d), None) => { + upstream.alias = d; + Ok(()) + } + (Some(d), Some(p)) if p == d => { + upstream.alias = d; + Ok(()) + } + (Some(d), Some(p)) => Err(format!( + "alias '{p}' does not match the derived alias '{d}' for hostname-based upstream" + )), + (None, None) => Err( + "alias is required: upstream endpoints are IP-based or do not share a \ + common registrable domain suffix" + .to_owned(), + ), + (None, Some(p)) => { + if !alias_is_valid_format(&p) { + return Err(format!( + "alias '{p}' does not match ^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$" + )); + } + upstream.alias = p; + Ok(()) + } + } +} + +fn valid_tag(tag: &str) -> bool { + !tag.is_empty() + && tag + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'_' || b == b'-') +} + +/// Validate a CORS configuration (ADR 0004): `allow_credentials` cannot be +/// combined with a `*` wildcard origin (a security bypass — the browser +/// rejects such responses, and the combination must fail at validation time). +/// +/// # Errors +/// Returns a human-readable validation message on failure. +pub fn validate_cors(cors: &crate::domain::models::CorsConfig) -> Result<(), String> { + if cors.allow_credentials && cors.allowed_origins.iter().any(|o| o == "*") { + return Err( + "cors: 'allow_credentials' cannot be used with a wildcard origin ('*')".to_owned(), + ); + } + Ok(()) +} + +/// Validate a route. `match` must be present with exactly one of `http` or +/// `grpc`; HTTP matches require non-empty method/path with valid method names. +/// +/// # Errors +/// Returns a human-readable validation message on failure. +pub fn validate_route(route: &Route) -> Result<(), String> { + if let Some(cors) = &route.cors { + validate_cors(cors)?; + } + if let Some(rate) = &route.rate_limit { + validate_rate_limit(rate)?; + } + let m = route + .r#match + .as_ref() + .ok_or_else(|| "route requires a 'match' rule".to_owned())?; + match m { + MatchRule { + http: None, + grpc: None, + } + | MatchRule { + http: Some(_), + grpc: Some(_), + } => Err("route 'match' must contain exactly one of 'http' or 'grpc'".to_owned()), + MatchRule { + http: Some(h), + grpc: None, + } => validate_http_match(h), + MatchRule { + http: None, + grpc: Some(g), + } => { + if g.service.trim().is_empty() || g.method.trim().is_empty() { + return Err("grpc match requires non-empty 'service' and 'method'".to_owned()); + } + Ok(()) + } + } +} + +fn validate_http_match(h: &crate::domain::models::HttpMatch) -> Result<(), String> { + if h.methods.is_empty() { + return Err("http match requires at least one method".to_owned()); + } + for m in &h.methods { + if !ALLOWED_METHODS.contains(&m.as_str()) { + return Err(format!("http match method '{m}' is not allowed")); + } + } + if h.path.trim().is_empty() { + return Err("http match requires a non-empty 'path'".to_owned()); + } + Ok(()) +} + +/// Validate a rate-limit configuration (upstream or route): sustained rate ≥ 1, +/// burst capacity ≥ 1 when present, cost ≥ 1, and cost ≤ capacity (burst +/// capacity when present, else the sustained rate — a cost above the bucket's +/// capacity could never be satisfied). +/// +/// # Errors +/// Returns a human-readable validation message on failure. +pub fn validate_rate_limit(c: &RateLimitConfig) -> Result<(), String> { + if c.sustained.rate < 1 { + return Err("rate limit 'sustained.rate' must be at least 1".to_owned()); + } + let capacity = match &c.burst { + Some(b) => { + if b.capacity < 1 { + return Err("rate limit 'burst.capacity' must be at least 1".to_owned()); + } + b.capacity + } + None => c.sustained.rate, + }; + if c.cost < 1 { + return Err("rate limit 'cost' must be at least 1".to_owned()); + } + if c.cost > capacity { + return Err(format!( + "rate limit 'cost' ({}) cannot exceed the burst capacity ({capacity})", + c.cost + )); + } + Ok(()) +} + +/// Whether `name` targets a gateway-managed header that the transform pipeline +/// may not touch: the hop-by-hop set the proxy strips (RFC 7230 §6.1 and +/// friends) plus the OAGW routing header and the `Proxy-*` family. +/// Case-insensitive. +fn is_gateway_managed_name(name: &str) -> bool { + let lower = name.to_ascii_lowercase(); + matches!( + lower.as_str(), + "connection" + | "host" + | "content-length" + | "transfer-encoding" + | "upgrade" + | "keep-alive" + | "trailer" + | "te" + | "x-oagw-target-host" + ) || lower.starts_with("proxy-") +} + +/// Validate upstream header `set`/`add`/`remove` rules (request and response +/// directions) at CRUD time: names must parse as `HeaderName`, values as +/// `HeaderValue`, `remove` entries must be valid names, and no rule may target +/// a gateway-managed (hop-by-hop / routing) header — the proxy strips those on +/// the hot path regardless, so cfg-level rules would silently do nothing. +/// +/// # Errors +/// Returns a human-readable validation message on failure. +pub fn validate_headers_config( + headers: &crate::domain::models::HeadersConfig, +) -> Result<(), String> { + validate_header_ops( + &headers.request.set, + &headers.request.add, + &headers.request.remove, + "request", + )?; + validate_header_ops( + &headers.response.set, + &headers.response.add, + &headers.response.remove, + "response", + )?; + Ok(()) +} + +fn validate_header_ops( + set: &BTreeMap, + add: &BTreeMap, + remove: &[String], + direction: &str, +) -> Result<(), String> { + for (name, value) in set { + validate_header_rule(name, value, direction)?; + } + for (name, value) in add { + validate_header_rule(name, value, direction)?; + } + for name in remove { + HeaderName::from_bytes(name.as_bytes()).map_err(|_| { + format!("{direction} header 'remove' entry '{name}' is not a valid HTTP header name") + })?; + if is_gateway_managed_name(name) { + return Err(format!( + "{direction} header 'remove' entry '{name}' targets a gateway-managed header and is not allowed" + )); + } + } + Ok(()) +} + +fn validate_header_rule(name: &str, value: &str, direction: &str) -> Result<(), String> { + HeaderName::from_bytes(name.as_bytes()) + .map_err(|_| format!("{direction} header rule '{name}' is not a valid HTTP header name"))?; + HeaderValue::from_str(value) + .map_err(|_| format!("{direction} header rule '{name}' has an invalid value"))?; + if is_gateway_managed_name(name) { + return Err(format!( + "{direction} header rule '{name}' targets a gateway-managed header and is not allowed" + )); + } + Ok(()) +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use crate::domain::models::{ + BurstConfig, Endpoint, HeadersConfig, HttpMatch, PathSuffixMode, PluginsConfig, + RateLimitAlgorithm, RateLimitConfig, RateLimitScope, RateLimitStrategy, RateWindow, Scheme, + ServerConfig, SharingMode, SustainedRate, + }; + + fn ep(scheme: Scheme, host: &str, port: u16) -> Endpoint { + Endpoint { + scheme, + host: host.to_owned(), + port, + } + } + + fn upstream_with(endpoints: Vec) -> Upstream { + Upstream { + id: uuid::Uuid::new_v4(), + enabled: true, + alias: String::new(), + tags: Vec::new(), + server: ServerConfig { endpoints }, + protocol: PROTOCOL_HTTP_V1.to_owned(), + auth: None, + headers: HeadersConfig::default(), + plugins: PluginsConfig::default(), + rate_limit: None, + cors: None, + } + } + + #[test] + fn single_hostname_standard_port_derives_hostname() { + let mut u = upstream_with(vec![ep(Scheme::Https, "api.openai.com", 443)]); + assert_eq!(validate_upstream(&mut u), Ok(())); + assert_eq!(u.alias, "api.openai.com"); + } + + #[test] + fn single_hostname_http_80_is_standard() { + // HTTP upstream on port 80 (standard for http scheme) still uses + // https default port 443 in the schema; port 443 is the only + // "standard" value considered here, so 80 produces host:80. + let mut u = upstream_with(vec![ep(Scheme::Https, "api.example.com", 80)]); + validate_upstream(&mut u).unwrap(); + assert_eq!(u.alias, "api.example.com:80"); + } + + #[test] + fn single_hostname_non_standard_port_appends_port() { + let mut u = upstream_with(vec![ep(Scheme::Https, "api.example.com", 8443)]); + validate_upstream(&mut u).unwrap(); + assert_eq!(u.alias, "api.example.com:8443"); + } + + #[test] + fn multi_hostname_common_suffix_derives_suffix() { + let mut u = upstream_with(vec![ + ep(Scheme::Https, "us.vendor.com", 443), + ep(Scheme::Https, "eu.vendor.com", 443), + ]); + validate_upstream(&mut u).unwrap(); + assert_eq!(u.alias, "vendor.com"); + } + + #[test] + fn multi_hostname_common_suffix_non_standard_port_appends_port() { + let mut u = upstream_with(vec![ + ep(Scheme::Https, "us.vendor.com", 8443), + ep(Scheme::Https, "eu.vendor.com", 8443), + ]); + validate_upstream(&mut u).unwrap(); + assert_eq!(u.alias, "vendor.com:8443"); + } + + #[test] + fn ip_endpoint_requires_explicit_alias() { + let mut u = upstream_with(vec![ep(Scheme::Https, "10.0.0.5", 443)]); + assert_eq!(derive_alias(&u), Ok(None)); + // Without explicit alias → validation error. + assert!(validate_upstream(&mut u).is_err()); + // With explicit alias → OK. + let mut u2 = upstream_with(vec![ep(Scheme::Https, "10.0.0.5", 443)]); + u2.alias = "internal-db".to_owned(); + assert_eq!(validate_upstream(&mut u2), Ok(())); + assert_eq!(u2.alias, "internal-db"); + } + + #[test] + fn bare_public_suffix_is_rejected() { + // common suffix `co.uk` is a public suffix → not derivable. + let mut u = upstream_with(vec![ + ep(Scheme::Https, "a.co.uk", 443), + ep(Scheme::Https, "b.co.uk", 443), + ]); + assert_eq!(derive_alias(&u), Ok(None)); + u.alias = "a.co.uk".to_owned(); + assert!( + validate_upstream(&mut u).is_ok(), + "explicit alias still needed" + ); + } + + #[test] + fn normalization_applies_to_derived_and_provided_aliases() { + // Uppercase host + trailing dot normalized. + let mut u = upstream_with(vec![ep(Scheme::Https, "API.Example.COM.", 443)]); + validate_upstream(&mut u).unwrap(); + assert_eq!(u.alias, "api.example.com"); + // Explicit alias matching derived (but written differently) is a no-op. + let mut u2 = upstream_with(vec![ep(Scheme::Https, "api.example.com", 443)]); + u2.alias = "API.EXAMPLE.COM.".to_owned(); + validate_upstream(&mut u2).unwrap(); + assert_eq!(u2.alias, "api.example.com"); + } + + #[test] + fn mismatched_explicit_alias_is_rejected_for_derivable_upstream() { + let mut u = upstream_with(vec![ep(Scheme::Https, "api.example.com", 443)]); + u.alias = "wrong.alias".to_owned(); + let err = validate_upstream(&mut u).unwrap_err(); + assert!(err.contains("does not match the derived alias")); + } + + #[test] + fn different_ports_are_not_derivable() { + let u = upstream_with(vec![ + ep(Scheme::Https, "us.vendor.com", 443), + ep(Scheme::Https, "eu.vendor.com", 8443), + ]); + assert_eq!(derive_alias(&u), Ok(None)); + } + + #[test] + fn mixed_ip_and_hostname_not_derivable() { + let u = upstream_with(vec![ + ep(Scheme::Https, "10.0.0.5", 443), + ep(Scheme::Https, "api.example.com", 443), + ]); + assert_eq!(derive_alias(&u), Ok(None)); + } + + #[test] + fn empty_endpoints_is_invalid() { + let mut u = upstream_with(vec![]); + assert!(validate_upstream(&mut u).is_err()); + } + + #[test] + fn invalid_protocol_rejected() { + let mut u = upstream_with(vec![ep(Scheme::Https, "x.com", 443)]); + u.protocol = "gts.bogus".to_owned(); + assert!(validate_upstream(&mut u).is_err()); + } + + #[test] + fn route_requires_exactly_one_match_kind() { + let base = || Route { + id: uuid::Uuid::new_v4(), + enabled: true, + tags: vec![], + upstream_id: uuid::Uuid::new_v4(), + r#match: None, + plugins: PluginsConfig::default(), + rate_limit: None, + cors: None, + }; + assert!(validate_route(&base()).is_err()); + + let mut both = base(); + both.r#match = Some(MatchRule { + http: Some(HttpMatch { + methods: vec!["GET".into()], + path: "/x".into(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: Some(crate::domain::models::GrpcMatch { + service: "s".into(), + method: "m".into(), + }), + }); + assert!(validate_route(&both).is_err()); + + let mut good = base(); + good.r#match = Some(MatchRule { + http: Some(HttpMatch { + methods: vec!["GET".into()], + path: "/x".into(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }); + assert_eq!(validate_route(&good), Ok(())); + } + + #[test] + fn route_http_methods_restricted() { + let r = Route { + id: uuid::Uuid::new_v4(), + enabled: true, + tags: vec![], + upstream_id: uuid::Uuid::new_v4(), + r#match: Some(MatchRule { + http: Some(HttpMatch { + methods: vec!["TRACE".into()], + path: "/x".into(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }), + plugins: PluginsConfig::default(), + rate_limit: None, + cors: None, + }; + assert!(validate_route(&r).is_err()); + } + + #[test] + fn alias_format_validation() { + assert!(alias_is_valid_format("api.example.com")); + assert!(alias_is_valid_format("my-alias")); + assert!(alias_is_valid_format("a1")); + assert!(alias_is_valid_format("10.0.0.5:8443")); + assert!(!alias_is_valid_format("-lead")); + assert!(!alias_is_valid_format("trail-")); + assert!(!alias_is_valid_format("UPPER")); + assert!(!alias_is_valid_format("")); + assert!(!alias_is_valid_format("under_score")); + } + + #[test] + fn normalize_alias_rules() { + assert_eq!(normalize_alias(" API.EXAMPLE.COM. "), "api.example.com"); + assert_eq!(normalize_alias("x.y.z."), "x.y.z"); + } + + #[test] + fn cors_rejects_credentials_with_wildcard_origin() { + use crate::domain::models::CorsConfig; + let bad = CorsConfig { + sharing: SharingMode::default(), + enabled: true, + allowed_origins: vec!["*".to_owned()], + allowed_methods: vec!["GET".to_owned()], + expose_headers: vec![], + allow_credentials: true, + }; + assert!(validate_cors(&bad).is_err()); + // Wildcard without credentials is fine (public API). + let public = CorsConfig { + allow_credentials: false, + ..bad.clone() + }; + assert_eq!(validate_cors(&public), Ok(())); + // Specific origins with credentials are fine. + let specific = CorsConfig { + allowed_origins: vec!["https://app.example.com".to_owned()], + ..bad + }; + assert_eq!(validate_cors(&specific), Ok(())); + } + + fn rate_config(rate: u64, cost: u64) -> RateLimitConfig { + RateLimitConfig { + sharing: SharingMode::Private, + algorithm: RateLimitAlgorithm::default(), + sustained: SustainedRate { + rate, + window: RateWindow::Second, + }, + burst: None, + scope: RateLimitScope::Tenant, + strategy: RateLimitStrategy::default(), + cost, + } + } + + #[test] + fn rate_limit_validation_rejects_zero_rate_zero_cost_and_cost_over_capacity() { + // rate 0 → rejected. + assert!(validate_rate_limit(&rate_config(0, 1)).is_err()); + // cost 0 → rejected. + assert!(validate_rate_limit(&rate_config(10, 0)).is_err()); + // cost above the default capacity (= rate) → rejected. + let err = validate_rate_limit(&rate_config(10, 11)).unwrap_err(); + assert!(err.contains("cannot exceed the burst capacity")); + // Valid config passes. + assert_eq!(validate_rate_limit(&rate_config(10, 1)), Ok(())); + // Explicit burst capacity below cost → rejected; at/above → passes. + let low_burst = RateLimitConfig { + burst: Some(BurstConfig { capacity: 2 }), + ..rate_config(10, 5) + }; + assert!(validate_rate_limit(&low_burst).is_err()); + let ok_burst = RateLimitConfig { + burst: Some(BurstConfig { capacity: 5 }), + ..rate_config(10, 5) + }; + assert_eq!(validate_rate_limit(&ok_burst), Ok(())); + // Zero burst capacity → rejected. + let zero_burst = RateLimitConfig { + burst: Some(BurstConfig { capacity: 0 }), + ..rate_config(10, 1) + }; + assert!(validate_rate_limit(&zero_burst).is_err()); + } + + #[test] + fn upstream_rate_limit_and_route_rate_limit_are_validated() { + // Upstream with an invalid rate limit is rejected at create time. + let mut u = upstream_with(vec![ep(Scheme::Https, "10.0.0.5", 443)]); + u.alias = "internal".to_owned(); + u.rate_limit = Some(rate_config(0, 1)); + assert!(validate_upstream(&mut u).is_err()); + + // Route with cost > capacity is rejected. + let r = Route { + id: uuid::Uuid::new_v4(), + enabled: true, + tags: vec![], + upstream_id: uuid::Uuid::new_v4(), + r#match: Some(MatchRule { + http: Some(HttpMatch { + methods: vec!["GET".into()], + path: "/x".into(), + query_allowlist: vec![], + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }), + plugins: PluginsConfig::default(), + rate_limit: Some(rate_config(10, 11)), + cors: None, + }; + assert!(validate_route(&r).is_err()); + } + + #[test] + fn headers_config_validates_names_values_and_gateway_managed_rules() { + let mut cfg = HeadersConfig::default(); + assert_eq!(validate_headers_config(&cfg), Ok(())); + + // Invalid header name → rejected. + cfg.request.set.insert("bad name!".to_owned(), "v".to_owned()); + assert!(validate_headers_config(&cfg).is_err()); + cfg.request.set.clear(); + + // Invalid header value (contains a forbidden byte) → rejected. + cfg.request.add.insert("x-test".to_owned(), "has\nnewline".to_owned()); + assert!(validate_headers_config(&cfg).is_err()); + cfg.request.add.clear(); + + // Gateway-managed names are rejected across set/add/remove and both + // directions. + for name in [ + "Connection", + "host", + "Content-Length", + "Transfer-Encoding", + "Upgrade", + "Keep-Alive", + "Trailer", + "TE", + "x-oagw-target-host", + "Proxy-Authorization", + "proxy-connection", + ] { + cfg.request.set.insert(name.to_owned(), "v".to_owned()); + assert!( + validate_headers_config(&cfg).is_err(), + "request set {name} must be rejected" + ); + cfg.request.set.clear(); + + cfg.request.remove.push(name.to_owned()); + assert!( + validate_headers_config(&cfg).is_err(), + "request remove {name} must be rejected" + ); + cfg.request.remove.clear(); + + cfg.response.add.insert(name.to_owned(), "v".to_owned()); + assert!( + validate_headers_config(&cfg).is_err(), + "response add {name} must be rejected" + ); + cfg.response.add.clear(); + } + + // Well-formed user headers pass in both directions. + cfg.request.set.insert("x-oagw-tenant".to_owned(), "acme".to_owned()); + cfg.request.add.insert("x-request-id".to_owned(), "abc".to_owned()); + cfg.response.set.insert("x-source".to_owned(), "gateway".to_owned()); + cfg.response.remove.push("x-internal".to_owned()); + assert_eq!(validate_headers_config(&cfg), Ok(())); + } +} diff --git a/gears/system/oagw/oagw/src/gear.rs b/gears/system/oagw/oagw/src/gear.rs new file mode 100644 index 0000000..346ded1 --- /dev/null +++ b/gears/system/oagw/oagw/src/gear.rs @@ -0,0 +1,174 @@ +//! Gear declaration for the OAGW (outbound API gateway) gear. + +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use credstore_sdk::CredStoreClientV1; +use tenant_resolver_sdk::TenantResolverClient; +use toolkit::api::OpenApiRegistry; +use toolkit::contracts::SystemCapability; +use toolkit::{Gear, GearCtx, RestApiCapability}; +use tracing::{debug, info}; + +use crate::config::OagwConfig; +use crate::domain::service::ControlPlaneService; +use crate::infra::plugin::{AuthPluginRegistry, TokenCacheConfig}; +use crate::infra::ratelimit::RateLimiter; + +/// OAGW gear. +/// +/// ## Capabilities +/// +/// - `system` — core infrastructure gear +/// - `rest` — exposes REST (management + proxy) endpoints +/// +/// ## Wiring +/// +/// At `init`, reads `gears.oagw.config` (or defaults), builds the in-memory +/// control-plane service, and resolves the typed dependency clients +/// (credstore, types-registry, tenant-resolver, authz-resolver) from the +/// `ClientHub` for use by the data plane. +#[toolkit::gear( + name = "oagw", + capabilities = [system, rest], + deps = [authz_resolver, tenant_resolver, types_registry, credstore] +)] +pub struct OagwGear { + config: OnceLock>, + service: OnceLock>, + auth: OnceLock>, + rate: OnceLock>, +} + +impl Default for OagwGear { + fn default() -> Self { + Self { + config: OnceLock::new(), + service: OnceLock::new(), + auth: OnceLock::new(), + rate: OnceLock::new(), + } + } +} + +impl OagwGear { + /// The frozen gear configuration, if `init` already ran. + #[must_use] + pub fn config(&self) -> Option> { + self.config.get().cloned() + } + + /// The control-plane service, if `init` already ran. + #[must_use] + pub fn service(&self) -> Option> { + self.service.get().cloned() + } + + /// The built-in auth-plugin registry, if `init` already ran. + #[must_use] + pub fn auth_plugins(&self) -> Option> { + self.auth.get().cloned() + } + + /// The DP-owned rate limiter, if `init` already ran (ADR 0006). + #[must_use] + pub fn rate_limiter(&self) -> Option> { + self.rate.get().cloned() + } +} + +#[async_trait::async_trait] +impl Gear for OagwGear { + async fn init(&self, ctx: &GearCtx) -> anyhow::Result<()> { + let cfg: OagwConfig = ctx.config_or_default()?; + debug!( + proxy_timeout_secs = cfg.proxy_timeout_secs, + allow_http_upstream = cfg.allow_http_upstream, + ssrf_enabled = cfg.ssrf_policy.enabled, + token_cache_ttl_secs = cfg.token_cache_ttl_secs, + token_cache_capacity = cfg.token_cache_capacity, + "Loaded oagw config" + ); + + let service = ControlPlaneService::shared(cfg.clone()); + self.service + .set(service) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + self.config + .set(Arc::new(cfg.clone())) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + + // Auth-plugin registry: resolves the `cred_store` client from the + // `ClientHub` (absent → builtins that need secrets are left out; see + // `AuthPluginRegistry::new`). The token-cache config comes from the + // gear config (ADR 0008). + let credstore = ctx.client_hub().get::().ok(); + let registry = AuthPluginRegistry::new( + credstore, + TokenCacheConfig { + ttl: Duration::from_secs(cfg.token_cache_ttl_secs), + capacity: cfg.token_cache_capacity, + }, + ); + self.auth + .set(Arc::new(registry)) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + + // DP-owned per-instance rate limiter (ADR 0006): shared by every + // proxy handler through the router's Extension layer. + self.rate + .set(Arc::new(RateLimiter::new())) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + + // Inter-gear clients (credstore, tenant-resolver, authz-resolver, + // types-registry) are resolved from the `ClientHub` on demand by the + // data plane (per-request), never cached at boot — provider gears may + // register later or be absent from the binary entirely. The `deps` + // list above guarantees link-time presence of the provider crates. + + info!("oagw gear initialized"); + Ok(()) + } +} + +#[async_trait::async_trait] +impl SystemCapability for OagwGear { + /// Post-init hook: nothing to publish for the MVP (types-registry + /// provisioning of OAGW schemas is deferred; see DESIGN type provisioning). + async fn post_init(&self, _sys: &toolkit::runtime::SystemContext) -> anyhow::Result<()> { + debug!("oagw post_init complete"); + Ok(()) + } +} + +impl RestApiCapability for OagwGear { + fn register_rest( + &self, + ctx: &GearCtx, + router: axum::Router, + openapi: &dyn OpenApiRegistry, + ) -> anyhow::Result { + let service = self + .service + .get() + .ok_or_else(|| anyhow::anyhow!("oagw service not initialized"))? + .clone(); + + // Optional tenant-resolver client: resolved at route registration when + // the provider gear is present, else `None` (proxy falls back to the + // caller's own tenant). Not boot-blocking — `deps` guarantees link-time + // presence, initialization order guarantees availability in real runs. + let tenant_resolver = ctx.client_hub().get::().ok(); + + let router = crate::api::rest::routes::register_routes( + router, + openapi, + service, + tenant_resolver, + self.auth.get().cloned(), + self.rate.get().cloned(), + ); + info!("oagw REST routes registered"); + Ok(router) + } +} diff --git a/gears/system/oagw/oagw/src/infra/mod.rs b/gears/system/oagw/oagw/src/infra/mod.rs new file mode 100644 index 0000000..c40f198 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/mod.rs @@ -0,0 +1,6 @@ +//! Infrastructure: in-memory stores, proxy engine, plugin registry. + +pub mod plugin; +pub mod proxy; +pub mod ratelimit; +pub mod storage; diff --git a/gears/system/oagw/oagw/src/infra/plugin/apikey_auth.rs b/gears/system/oagw/oagw/src/infra/plugin/apikey_auth.rs new file mode 100644 index 0000000..2ac165c --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/apikey_auth.rs @@ -0,0 +1,193 @@ +//! `apikey.v1` — API key injection (header or query) from `cred_store` +//! (PRD `cpt-cf-oagw-fr-auth-injection`). + +use std::sync::Arc; + +use credstore_sdk::{CredStoreClientV1, SecretRef}; +use http::HeaderName; +use http::header::HeaderValue; + +use crate::domain::plugin::{ + API_KEY_AUTH_PLUGIN_ID, AuthContext, AuthPlugin, PluginError, cfg_string, secret_ref_name, +}; + +/// `gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1` +/// +/// Resolves the API key from `cred_store` at request time and injects it as a +/// request header (`in: header`, the default) or query parameter +/// (`in: query`). +/// +/// Config keys: +/// +/// | Key | Required | Description | +/// |---|---|---| +/// | `value_ref` | yes | `cred://` reference to the API key secret | +/// | `name` | yes | Header name (or query parameter name) | +/// | `in` | no | `"header"` (default) or `"query"` | +pub struct ApiKeyAuthPlugin { + credstore: Arc, +} + +impl ApiKeyAuthPlugin { + /// Create the plugin bound to a `cred_store` client. + #[must_use] + pub fn new(credstore: Arc) -> Self { + Self { credstore } + } +} + +/// Where the injected credential is placed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Injection { + Header, + Query, +} + +#[derive(Debug)] +struct Config { + value_ref: String, + name: String, + injection: Injection, +} + +fn parse_config(config: &serde_json::Value) -> Result { + let value_ref = cfg_string(config, "value_ref").ok_or_else(|| { + PluginError::AuthenticationFailed( + "apikey plugin requires a 'value_ref' (cred://...) in auth.config".to_owned(), + ) + })?; + let name = cfg_string(config, "name").ok_or_else(|| { + PluginError::AuthenticationFailed( + "apikey plugin requires a 'name' (header or query param) in auth.config".to_owned(), + ) + })?; + let injection = match cfg_string(config, "in").unwrap_or("header") { + "header" => Injection::Header, + "query" => Injection::Query, + other => { + return Err(PluginError::AuthenticationFailed(format!( + "apikey plugin: unsupported injection location '{other}' (expected 'header' or 'query')" + ))); + } + }; + Ok(Config { + value_ref: value_ref.to_owned(), + name: name.to_owned(), + injection, + }) +} + +#[async_trait::async_trait] +impl AuthPlugin for ApiKeyAuthPlugin { + fn id(&self) -> &'static str { + API_KEY_AUTH_PLUGIN_ID + } + + async fn authenticate(&self, ctx: &mut AuthContext<'_>) -> Result<(), PluginError> { + let cfg = parse_config(ctx.config)?; + + let reference = SecretRef::new(secret_ref_name(&cfg.value_ref)).map_err(|e| { + PluginError::AuthenticationFailed(format!( + "apikey plugin: invalid secret reference '{}': {e}", + cfg.value_ref + )) + })?; + let Some(secret) = self + .credstore + .get(ctx.security_context, &reference) + .await + .map_err(|e| internal_from_credstore(&e))? + else { + return Err(PluginError::SecretNotFound(format!( + "apikey plugin: secret '{}' not found or not accessible", + cfg.value_ref + ))); + }; + let value = std::str::from_utf8(secret.value.as_bytes()).map_err(|_| { + PluginError::AuthenticationFailed(format!( + "apikey plugin: secret '{}' is not valid UTF-8", + cfg.value_ref + )) + })?; + if value.trim().is_empty() { + return Err(PluginError::AuthenticationFailed(format!( + "apikey plugin: secret '{}' is empty", + cfg.value_ref + ))); + } + + match cfg.injection { + Injection::Header => { + let name = HeaderName::from_bytes(cfg.name.as_bytes()).map_err(|e| { + PluginError::AuthenticationFailed(format!( + "apikey plugin: header name '{}' is invalid: {e}", + cfg.name + )) + })?; + let value = HeaderValue::from_str(value).map_err(|e| { + PluginError::AuthenticationFailed(format!( + "apikey plugin: secret '{}' is not a valid header value: {e}", + cfg.value_ref + )) + })?; + ctx.headers.insert(name, value); + } + Injection::Query => { + ctx.query_params.push((cfg.name.clone(), value.to_owned())); + } + } + Ok(()) + } +} + +/// Map a `cred_store` backend failure to [`PluginError::Internal`]. +fn internal_from_credstore(e: &credstore_sdk::CredStoreError) -> PluginError { + PluginError::Internal(format!("apikey plugin: cred_store error: {e}")) +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn parses_header_and_query_configs() { + let header = parse_config(&json!({"value_ref": "cred://k", "name": "x-api-key"})) + .expect("header config"); + assert_eq!(header.injection, Injection::Header); + let query = + parse_config(&json!({"value_ref": "cred://k", "name": "api_key", "in": "query"})) + .expect("query config"); + assert_eq!(query.injection, Injection::Query); + } + + #[test] + fn rejects_bad_configs() { + assert!(parse_config(&json!({"name": "x-api-key"})).is_err()); // no value_ref + assert!(parse_config(&json!({"value_ref": "cred://k"})).is_err()); // no name + let bad = parse_config(&json!({"value_ref": "cred://k", "name": "x", "in": "cookie"})); + assert!(bad.is_err()); + } + + #[tokio::test] + async fn missing_secret_reports_secret_not_found() { + let store = Arc::new(credstore_sdk::test_util::MockCredStoreClient::empty()); + let plugin = ApiKeyAuthPlugin::new(store); + let ctx = toolkit_security::SecurityContext::builder() + .subject_id(uuid::Uuid::new_v4()) + .subject_tenant_id(uuid::Uuid::from_u128(1)) + .build() + .unwrap(); + let mut headers = http::HeaderMap::new(); + let mut query = Vec::new(); + let mut actx = AuthContext { + security_context: &ctx, + config: &json!({"value_ref": "cred://ghost", "name": "x-api-key"}), + headers: &mut headers, + query_params: &mut query, + }; + let err = plugin.authenticate(&mut actx).await.unwrap_err(); + assert!(matches!(err, PluginError::SecretNotFound(_))); + } +} 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..db0ef09 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/mod.rs @@ -0,0 +1,9 @@ +//! Built-in plugin implementations and the auth-plugin registry. + +pub mod apikey_auth; +pub mod noop_auth; +pub mod oauth2_client_cred_auth; +pub mod registry; +pub mod required_headers_guard; + +pub use registry::{AuthPluginRegistry, GuardPluginRegistry, TokenCacheConfig}; 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..e831b1d --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/noop_auth.rs @@ -0,0 +1,21 @@ +//! `noop.v1` — no authentication (DESIGN built-in auth plugin). + +use crate::domain::plugin::{AuthContext, AuthPlugin, NOOP_AUTH_PLUGIN_ID, PluginError}; + +/// `gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1` +/// +/// Injects nothing; the request passes through unchanged. Requires no +/// credentials and ignores `auth.config`. +#[derive(Debug, Default, Clone, Copy)] +pub struct NoopAuthPlugin; + +#[async_trait::async_trait] +impl AuthPlugin for NoopAuthPlugin { + fn id(&self) -> &'static str { + 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..3eef5f7 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/oauth2_client_cred_auth.rs @@ -0,0 +1,514 @@ +//! `oauth2_client_cred.v1` / `oauth2_client_cred_basic.v1` — `OAuth2` Client +//! Credentials flow with an internal token cache (ADR 0008). +//! +//! The plugin exchanges `client_id` + `client_secret` (resolved from +//! `cred_store`) for a bearer token at a token endpoint (or via OIDC +//! discovery), injects `Authorization: Bearer `, and caches the token +//! keyed on `(tenant, subject, auth method, config)` so a multi-tenant data +//! plane issues one `IdP` call per distinct identity per TTL window. + +use std::collections::hash_map::DefaultHasher; +use std::future::Future; +use std::hash::{Hash, Hasher}; +use std::pin::Pin; +use std::sync::Arc; +use std::time::Duration; + +use credstore_sdk::{CredStoreClientV1, SecretRef}; +use dashmap::DashMap; +use futures_util::FutureExt; +use pingora_memory_cache::MemoryCache; +use toolkit_auth::oauth2::{ClientAuthMethod, OAuthClientConfig, SecretString, fetch_token}; +use url::Url; + +use crate::domain::plugin::{ + AuthContext, AuthPlugin, OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID, + OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID, PluginError, cfg_string, secret_ref_name, +}; + +/// Safety margin subtracted from the IdP-reported `expires_in` (ADR 0008). +const EXPIRY_SAFETY_MARGIN: Duration = Duration::from_secs(30); + +/// A cached access token tagged with the exact cache key it was stored under, +/// so a (2^-45) `TinyUfo` hash collision resolves to a miss instead of another +/// tenant's token (ADR 0008 "Hash-Collision Safety"). +#[derive(Clone)] +struct CachedToken { + key: String, + token: Arc, +} + +/// A shareable snapshot of a fetched token: the `IdP`'s `FetchedToken` is not +/// `Clone`, so the single-flight future's `Shared` output carries just the two +/// fields the cache + injection need. `Arc` keeps a single +/// zeroized copy shared by every waiter. +#[derive(Clone)] +struct FetchedTokenState { + bearer: Arc, + expires_in: Duration, +} + +/// The pinned, boxed token-exchange future stored in the single-flight map. +type TokenFetch = Pin> + Send>>; + +/// The shared, single-flight token-exchange future registered per cache key. +type SharedTokenFetch = futures_util::future::Shared; + +/// Cancellation-safe removal of the in-flight single-flight entry. +/// +/// The registering request holds one of these across its `.await` on its own +/// shared future. If the registering future is dropped mid-await (e.g. a +/// client disconnect drops the axum handler), the continuation that would have +/// removed the entry never runs — without this guard the entry (a `Shared` +/// future) is pinned forever, so a later `IdP` failure makes every subsequent +/// request for that key fail (auth outage). RAII keeps the removal on the +/// `Drop` path too, so the entry is released no matter how the await ends. +struct InFlightGuard { + key: String, + in_flight: Arc>, +} + +impl Drop for InFlightGuard { + fn drop(&mut self) { + self.in_flight.remove(&self.key); + } +} + +/// `gts.cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1` +/// (`Form`) and `...oauth2_client_cred_basic.v1` (`Basic`). +pub struct OAuth2ClientCredAuthPlugin { + credstore: Arc, + auth_method: toolkit_auth::oauth2::ClientAuthMethod, + cache: MemoryCache, + cache_ttl: Duration, + /// Per-key single-flight: the shared future of the one in-progress `IdP` + /// exchange per cache key, so a concurrent stampede of cache misses issues + /// exactly one token request (the future is removed when it completes). + /// Wrapped in an `Arc` so the cancellation-safe [`InFlightGuard`] can hold + /// a handle to the map across an await and remove its entry on drop. + in_flight: Arc>, +} + +impl OAuth2ClientCredAuthPlugin { + /// Create the plugin for one client-auth method with the shared token + /// cache configuration (ADR 0008 gear-level config). + #[must_use] + pub fn new( + credstore: Arc, + auth_method: ClientAuthMethod, + cache_ttl: Duration, + cache_capacity: usize, + ) -> Self { + Self { + credstore, + auth_method, + cache: MemoryCache::new(cache_capacity), + cache_ttl, + in_flight: Arc::new(DashMap::new()), + } + } + + fn gts_id(&self) -> &'static str { + match self.auth_method { + ClientAuthMethod::Form => OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID, + ClientAuthMethod::Basic => OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID, + } + } + + /// Deterministic tag of the client-auth method for cache-key separation. + fn auth_method_tag(&self) -> &'static str { + match self.auth_method { + ClientAuthMethod::Form => "form", + ClientAuthMethod::Basic => "basic", + } + } +} + +/// Parsed plugin configuration (`auth.config`). +struct Config { + token_endpoint: Option, + issuer_url: Option, + client_id_ref: String, + client_secret_ref: String, + scopes: Vec, +} + +fn parse_config(config: &serde_json::Value) -> Result { + let token_endpoint = match cfg_string(config, "token_endpoint") { + Some(raw) => Some(Url::parse(raw).map_err(|e| { + PluginError::AuthenticationFailed(format!( + "oauth2 plugin: invalid token_endpoint '{raw}': {e}" + )) + })?), + None => None, + }; + let issuer_url = match cfg_string(config, "issuer_url") { + Some(raw) => Some(Url::parse(raw).map_err(|e| { + PluginError::AuthenticationFailed(format!( + "oauth2 plugin: invalid issuer_url '{raw}': {e}" + )) + })?), + None => None, + }; + if token_endpoint.is_some() && issuer_url.is_some() { + return Err(PluginError::AuthenticationFailed( + "oauth2 plugin: token_endpoint and issuer_url are mutually exclusive".to_owned(), + )); + } + if token_endpoint.is_none() && issuer_url.is_none() { + return Err(PluginError::AuthenticationFailed( + "oauth2 plugin requires exactly one of 'token_endpoint' or 'issuer_url' in auth.config" + .to_owned(), + )); + } + let client_id_ref = cfg_string(config, "client_id_ref").ok_or_else(|| { + PluginError::AuthenticationFailed( + "oauth2 plugin requires a 'client_id_ref' (cred://...) in auth.config".to_owned(), + ) + })?; + let client_secret_ref = cfg_string(config, "client_secret_ref").ok_or_else(|| { + PluginError::AuthenticationFailed( + "oauth2 plugin requires a 'client_secret_ref' (cred://...) in auth.config".to_owned(), + ) + })?; + let scopes = cfg_string(config, "scopes") + .map(|s| s.split_whitespace().map(ToOwned::to_owned).collect()) + .unwrap_or_default(); + + Ok(Config { + token_endpoint, + issuer_url, + client_id_ref: client_id_ref.to_owned(), + client_secret_ref: client_secret_ref.to_owned(), + scopes, + }) +} + +/// Deterministic hash of the plugin config's string key/value pairs (keys +/// sorted) so different upstream configs get distinct cache entries +/// (ADR 0008 "Cache Key Design"). +fn hash_config(config: &serde_json::Value) -> u64 { + let mut keys: Vec<&str> = config + .as_object() + .map(|m| m.keys().map(String::as_str).collect()) + .unwrap_or_default(); + keys.sort_unstable(); + let mut hasher = DefaultHasher::new(); + for key in keys { + key.hash(&mut hasher); + if let Some(v) = config.get(key) { + v.to_string().hash(&mut hasher); + } + } + hasher.finish() +} + +/// Build the cache key from the full identity + config tuple +/// (ADR 0008 "Cache Key Design"). +fn build_cache_key( + tenant: uuid::Uuid, + subject: uuid::Uuid, + auth_tag: &str, + config_hash: u64, +) -> String { + format!("{tenant}:{subject}:{auth_tag}:{config_hash}") +} + +/// TTL for one cached token: the IdP-reported lifetime minus the safety +/// margin, capped at the gear-level cache TTL. `None` when the token has no +/// usable lifetime after the margin (not cached) (ADR 0008). +fn token_cache_ttl(expires_in: Duration, config_ttl: Duration) -> Option { + expires_in + .checked_sub(EXPIRY_SAFETY_MARGIN) + .filter(|t| !t.is_zero()) + .map(|t| t.min(config_ttl)) +} + +#[async_trait::async_trait] +impl AuthPlugin for OAuth2ClientCredAuthPlugin { + fn id(&self) -> &'static str { + self.gts_id() + } + + async fn authenticate(&self, ctx: &mut AuthContext<'_>) -> Result<(), PluginError> { + let cfg = parse_config(ctx.config)?; + let config_hash = hash_config(ctx.config); + let cache_key = build_cache_key( + ctx.security_context.subject_tenant_id(), + ctx.security_context.subject_id(), + self.auth_method_tag(), + config_hash, + ); + + // Cache hit (defense-in-depth: verify the stored key). + if let Some(entry) = self.cache.get(&cache_key).0 + && entry.key == cache_key + { + let token = entry.token.expose(); + insert_bearer(ctx, token); + return Ok(()); + } + + // Cache miss: resolve credentials and exchange for a token. All + // concurrent misses for the same key share one in-flight IdP exchange + // (single-flight) instead of stampeding the token endpoint. + let client_id = + resolve_secret(&self.credstore, ctx, "client_id", &cfg.client_id_ref).await?; + let client_secret = SecretString::new( + resolve_secret( + &self.credstore, + ctx, + "client_secret", + &cfg.client_secret_ref, + ) + .await?, + ); + + let oauth_config = OAuthClientConfig { + token_endpoint: cfg.token_endpoint, + issuer_url: cfg.issuer_url, + client_id, + client_secret, + scopes: cfg.scopes.clone(), + auth_method: self.auth_method, + extra_headers: Vec::new(), + // Field defaults mirroring `toolkit-auth`'s own defaults; only + // `default_ttl` matters for the cache fallback when the IdP omits + // `expires_in`. + refresh_offset: Duration::from_mins(30), + jitter_max: Duration::from_mins(5), + min_refresh_period: Duration::from_secs(10), + default_ttl: self.cache_ttl, + http_config: None, + }; + + // The fetched token is cached by the single-flight registerer (before + // its in-flight entry is removed), so every caller — registerer and + // waiter alike — observes the same cached value after the flight. + let state = self.single_flight_fetch(&cache_key, oauth_config).await?; + insert_bearer(ctx, state.bearer.expose()); + Ok(()) + } +} + +impl OAuth2ClientCredAuthPlugin { + /// Fetch a token for `cache_key`, single-flighted: the first request to + /// register registers the shared exchange future; concurrent waiters for + /// the same key await that same future. On completion (success or error) + /// only the registering request removes the entry, so all waiters observe + /// the same outcome and the `IdP` sees exactly one call per key per window. + async fn single_flight_fetch( + &self, + cache_key: &str, + oauth_config: OAuthClientConfig, + ) -> Result { + // Re-check the cache inside the single-flight path: a parallel miss + // may have completed and cached since the caller's first check, in + // which case there is nothing to register. + if let Some(entry) = self.cache.get(cache_key).0 + && entry.key == cache_key + { + return Ok(FetchedTokenState { + bearer: Arc::clone(&entry.token), + // Unused for injection; the registerer re-puts a fresh TTL + // with the same key once the new flight completes. + expires_in: self.cache_ttl, + }); + } + + let future: TokenFetch = Box::pin(async move { + let fetched = fetch_token(oauth_config).await.map_err(|e| { + PluginError::AuthenticationFailed(format!( + "oauth2 plugin: token exchange failed: {e}" + )) + })?; + Ok(FetchedTokenState { + bearer: Arc::new(fetched.bearer), + expires_in: fetched.expires_in, + }) + }); + let shared = future.shared(); + + // Register (or reuse) the in-flight exchange under the shard lock. The + // `vacant` registerer holds a guard across its own await: if the + // registering future is dropped mid-await (e.g. a client disconnect + // drops the axum handler), the guard's `Drop` still removes the entry, + // so a later IdP failure can never pin the key forever. Waiters never + // create a guard, keeping the existing rule that only the registerer + // removes the entry. + let mut guard = None; + let waiter = match self.in_flight.entry(cache_key.to_owned()) { + dashmap::mapref::entry::Entry::Occupied(occupied) => occupied.get().clone(), + dashmap::mapref::entry::Entry::Vacant(vacant) => { + vacant.insert(shared.clone()); + guard = Some(InFlightGuard { + key: cache_key.to_owned(), + in_flight: Arc::clone(&self.in_flight), + }); + shared + } + }; + + let result = waiter.await; + + // The registerer caches the fetched token BEFORE its guard drops, so + // waiters re-checking the cache after the flight observe the token + // and the in-flight entry stays present until the cache is warm (no + // duplicate-flight window between completion and cache put). + if guard.is_some() + && let Ok(state) = &result + && let Some(ttl) = token_cache_ttl(state.expires_in, self.cache_ttl) + { + let entry = CachedToken { + key: cache_key.to_owned(), + token: Arc::clone(&state.bearer), + }; + self.cache.put(cache_key, entry, Some(ttl)); + } + // `guard` drops here → the in-flight entry is removed (only the + // registerer removes, matching the existing rule). + result + } +} + +/// Resolve a `cred://` reference to a UTF-8 secret via `cred_store`. +async fn resolve_secret( + credstore: &Arc, + ctx: &mut AuthContext<'_>, + which: &str, + reference: &str, +) -> Result { + let reference = SecretRef::new(secret_ref_name(reference)).map_err(|e| { + PluginError::AuthenticationFailed(format!( + "oauth2 plugin: invalid {which} secret reference '{reference}': {e}" + )) + })?; + let Some(secret) = credstore + .get(ctx.security_context, &reference) + .await + .map_err(|e| { + PluginError::Internal(format!( + "oauth2 plugin: cred_store error resolving {which} '{}': {e}", + reference.as_ref() + )) + })? + else { + return Err(PluginError::SecretNotFound(format!( + "oauth2 plugin: {which} secret '{}' not found or not accessible", + reference.as_ref() + ))); + }; + let value = std::str::from_utf8(secret.value.as_bytes()).map_err(|_| { + PluginError::AuthenticationFailed(format!( + "oauth2 plugin: {which} secret '{}' is not valid UTF-8", + reference.as_ref() + )) + })?; + if value.is_empty() { + return Err(PluginError::AuthenticationFailed(format!( + "oauth2 plugin: {which} secret '{}' is empty", + reference.as_ref() + ))); + } + Ok(value.to_owned()) +} + +/// Inject `Authorization: Bearer ` into the outbound request. +fn insert_bearer(ctx: &mut AuthContext<'_>, token: &str) { + let value = format!("Bearer {token}"); + if let Ok(v) = http::HeaderValue::from_str(&value) { + ctx.headers.insert(http::header::AUTHORIZATION, v); + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn cache_key_is_deterministic_and_identity_scoped() { + let tenant = uuid::Uuid::from_u128(1); + let subject_a = uuid::Uuid::from_u128(2); + let subject_b = uuid::Uuid::from_u128(3); + let h = hash_config(&json!({"token_endpoint": "https://idp/token", "scopes": "a b"})); + + let k1 = build_cache_key(tenant, subject_a, "form", h); + let k1_repeat = build_cache_key(tenant, subject_a, "form", h); + // Deterministic. + assert_eq!(k1, k1_repeat); + // Cross-subject isolation (ADR 0008). + assert_ne!(k1, build_cache_key(tenant, subject_b, "form", h)); + // Cross-auth-method isolation (Form vs Basic never collide). + assert_ne!(k1, build_cache_key(tenant, subject_a, "basic", h)); + // Cross-config isolation. + assert_ne!( + k1, + build_cache_key( + tenant, + subject_a, + "form", + hash_config(&json!({"scopes": "c"})) + ) + ); + } + + #[test] + fn config_hash_is_order_independent_but_value_sensitive() { + let a = json!({"token_endpoint": "https://idp/token", "client_id_ref": "cred://x"}); + let b = json!({"client_id_ref": "cred://x", "token_endpoint": "https://idp/token"}); + assert_eq!(hash_config(&a), hash_config(&b)); + let c = json!({"token_endpoint": "https://idp/token", "client_id_ref": "cred://y"}); + assert_ne!(hash_config(&a), hash_config(&c)); + } + + #[test] + fn in_flight_guard_drop_removes_the_map_entry_without_awaiting() { + // Regression for the single-flight leak: if the registering future is + // dropped mid-await (client disconnect), the continuation that removed + // the entry never runs. The guard's `Drop` must release the key even + // when nothing is awaited. + let map: Arc> = Arc::new(DashMap::new()); + let future: TokenFetch = Box::pin(async { + Ok(FetchedTokenState { + bearer: Arc::new(SecretString::new("t".to_owned())), + expires_in: Duration::from_mins(1), + }) + }); + let shared = future.shared(); + map.insert("k".to_owned(), shared); + assert!(map.contains_key("k")); + + let guard = InFlightGuard { + key: "k".to_owned(), + in_flight: Arc::clone(&map), + }; + // Drop the guard without awaiting — this is what cancellation does to + // the registerer whose future is dropped mid-await. + drop(guard); + assert!( + !map.contains_key("k"), + "guard Drop must remove the in-flight entry even without awaiting" + ); + } + + #[test] + fn parse_config_requires_exactly_one_endpoint() { + assert!(parse_config(&json!({"token_endpoint": "https://idp/token"})).is_err()); + let ok = parse_config(&json!({ + "token_endpoint": "https://idp/token", + "client_id_ref": "cred://cid", + "client_secret_ref": "cred://cs" + })); + assert!(ok.is_ok()); + // Both endpoints are mutually exclusive (ADR 0008). + let both = parse_config(&json!({ + "token_endpoint": "https://idp/token", + "issuer_url": "https://issuer", + "client_id_ref": "cred://cid", + "client_secret_ref": "cred://cs" + })); + assert!(both.is_err()); + } +} 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..5113cf1 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/registry.rs @@ -0,0 +1,223 @@ +//! Built-in auth- and guard-plugin registries (DESIGN "`AuthPluginRegistry`", +//! ADR 0009 "`GuardPluginRegistry`"). + +use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; + +use credstore_sdk::CredStoreClientV1; + +use crate::domain::plugin::{ + API_KEY_AUTH_PLUGIN_ID, AuthPlugin, BASIC_AUTH_PLUGIN_ID, BEARER_AUTH_PLUGIN_ID, GuardPlugin, + NOOP_AUTH_PLUGIN_ID, OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID, + OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID, REQUIRED_HEADERS_GUARD_PLUGIN_ID, +}; + +use super::apikey_auth::ApiKeyAuthPlugin; +use super::noop_auth::NoopAuthPlugin; +use super::oauth2_client_cred_auth::OAuth2ClientCredAuthPlugin; +use super::required_headers_guard::RequiredHeadersGuardPlugin; + +/// Token-cache configuration threaded from `OagwConfig` (ADR 0008). +#[derive(Debug, Clone, Copy)] +pub struct TokenCacheConfig { + /// Ceiling for the cached access-token TTL. + pub ttl: Duration, + /// Maximum number of cache entries. + pub capacity: usize, +} + +/// Resolves an upstream's `auth.type` to a built-in [`AuthPlugin`] by its GTS +/// identifier. +/// +/// The catalog-only identifiers (`basic.v1`, `bearer.v1`) have no backing +/// implementation and are intentionally absent here — resolving them yields +/// `None`, which the data plane reports as `unknown auth plugin` +/// (503 `plugin.not_found.v1`). +pub struct AuthPluginRegistry { + plugins: HashMap<&'static str, Arc>, +} + +impl AuthPluginRegistry { + /// Build the registry with the built-in plugins. + /// + /// When no `cred_store` client is available (provider gear absent from the + /// binary), only `noop.v1` is registered: the credential-injecting plugins + /// cannot resolve secrets and fail fast as unknown at request time. + #[must_use] + pub fn new(credstore: Option>, cache_cfg: TokenCacheConfig) -> Self { + let mut plugins: HashMap<&'static str, Arc> = HashMap::new(); + plugins.insert(NOOP_AUTH_PLUGIN_ID, Arc::new(NoopAuthPlugin)); + + let Some(credstore) = credstore else { + return Self { plugins }; + }; + + plugins.insert( + API_KEY_AUTH_PLUGIN_ID, + Arc::new(ApiKeyAuthPlugin::new(credstore.clone())), + ); + plugins.insert( + OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID, + Arc::new(OAuth2ClientCredAuthPlugin::new( + credstore.clone(), + toolkit_auth::oauth2::ClientAuthMethod::Form, + cache_cfg.ttl, + cache_cfg.capacity, + )), + ); + plugins.insert( + OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID, + Arc::new(OAuth2ClientCredAuthPlugin::new( + credstore, + toolkit_auth::oauth2::ClientAuthMethod::Basic, + cache_cfg.ttl, + cache_cfg.capacity, + )), + ); + Self { plugins } + } + + /// Resolve a plugin by its exact GTS identifier. + #[must_use] + pub fn resolve(&self, id: &str) -> Option> { + self.plugins.get(id).cloned() + } + + /// The number of registered plugins (observability/tests). + #[must_use] + pub fn len(&self) -> usize { + self.plugins.len() + } + + /// Whether the registry has no plugins (infallible; always false in + /// practice because `noop` is always registered). + #[must_use] + pub fn is_empty(&self) -> bool { + self.plugins.is_empty() + } + + /// GTS identifiers that are cataloged in the types-registry but carry no + /// implementation (DESIGN "Catalog-only identifiers"). + #[allow(dead_code)] + pub const CATALOG_ONLY_IDS: [&'static str; 2] = [BASIC_AUTH_PLUGIN_ID, BEARER_AUTH_PLUGIN_ID]; +} + +/// Resolves an upstream's bound `plugins.items` entry to a built-in +/// [`GuardPlugin`] by its GTS identifier (ADR 0009). +/// +/// `required_headers.v1` is the only guard identifier with a backing +/// implementation; `timeout.v1` / `cors.v1` are catalog-only (core data-plane +/// logic, not `GuardPlugin` implementations) and resolve to `None`, which the +/// data plane ignores. +pub struct GuardPluginRegistry { + plugins: HashMap<&'static str, Arc>, +} + +impl GuardPluginRegistry { + /// Build the registry with the built-in guard plugins (ADR 0009 + /// "Registry Integration": currently only `required_headers.v1`). + #[must_use] + pub fn with_builtins() -> Self { + let mut plugins: HashMap<&'static str, Arc> = HashMap::new(); + plugins.insert( + REQUIRED_HEADERS_GUARD_PLUGIN_ID, + Arc::new(RequiredHeadersGuardPlugin), + ); + Self { plugins } + } + + /// Resolve a plugin by its exact GTS identifier. + #[must_use] + pub fn resolve(&self, id: &str) -> Option> { + self.plugins.get(id).cloned() + } + + /// The number of registered plugins (observability/tests). + #[must_use] + pub fn len(&self) -> usize { + self.plugins.len() + } + + /// Whether the registry has no plugins (infallible; always false with + /// builtins). + #[must_use] + pub fn is_empty(&self) -> bool { + self.plugins.is_empty() + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use crate::domain::plugin::{ + API_KEY_AUTH_PLUGIN_ID, NOOP_AUTH_PLUGIN_ID, OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID, + OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID, + }; + + fn cache_cfg() -> TokenCacheConfig { + TokenCacheConfig { + ttl: Duration::from_mins(5), + capacity: 10, + } + } + + #[test] + fn with_credstore_registers_all_builtins() { + let registry = AuthPluginRegistry::new( + Some(Arc::new( + credstore_sdk::test_util::MockCredStoreClient::empty(), + )), + cache_cfg(), + ); + for id in [ + NOOP_AUTH_PLUGIN_ID, + API_KEY_AUTH_PLUGIN_ID, + OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID, + OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID, + ] { + assert!(registry.resolve(id).is_some(), "{id} must be registered"); + assert_eq!(registry.resolve(id).unwrap().id(), id); + } + } + + #[test] + fn without_credstore_only_noop_is_registered() { + let registry = AuthPluginRegistry::new(None, cache_cfg()); + assert!(registry.resolve(NOOP_AUTH_PLUGIN_ID).is_some()); + assert!(registry.resolve(API_KEY_AUTH_PLUGIN_ID).is_none()); + } + + #[test] + fn catalog_only_ids_are_not_resolvable() { + let registry = AuthPluginRegistry::new( + Some(Arc::new( + credstore_sdk::test_util::MockCredStoreClient::empty(), + )), + cache_cfg(), + ); + for id in AuthPluginRegistry::CATALOG_ONLY_IDS { + assert!(registry.resolve(id).is_none(), "{id} must not resolve"); + } + } + + #[test] + fn guard_registry_resolves_required_headers_only() { + let registry = GuardPluginRegistry::with_builtins(); + assert_eq!(registry.len(), 1); + let plugin = registry.resolve(crate::domain::plugin::REQUIRED_HEADERS_GUARD_PLUGIN_ID); + assert!(plugin.is_some(), "required_headers.v1 must be registered"); + assert_eq!( + plugin.unwrap().id(), + crate::domain::plugin::REQUIRED_HEADERS_GUARD_PLUGIN_ID + ); + // Catalog-only/custom/unknown ids resolve to None and are ignored. + assert!( + registry + .resolve("gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.timeout.v1") + .is_none() + ); + assert!(registry.resolve("some-custom-plugin-uuid").is_none()); + } +} 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..4cacfa3 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugin/required_headers_guard.rs @@ -0,0 +1,184 @@ +//! `required_headers.v1` — request/response required-header enforcement +//! (ADR 0009). +//! +//! A stateless guard that checks for the presence of configured header names +//! (`required_request_headers` / `required_response_headers`) and rejects with +//! a phase-specific status code on the first missing header. Fail-open when a +//! phase is unconfigured or blank (ADR 0009 "Decision Flow"). + +use http::HeaderMap; + +use crate::domain::plugin::{ + GuardContext, GuardError, GuardPhase, GuardPlugin, REQUIRED_HEADERS_GUARD_PLUGIN_ID, cfg_string, +}; + +/// `gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1` +/// +/// Config keys (ADR 0009): +/// +/// | Key | Required | Description | +/// |---|---|---| +/// | `required_request_headers` | no | Comma-separated header names checked in `guard_request`; absent or blank → request phase is a no-op | +/// | `required_response_headers` | no | Comma-separated header names checked in `guard_response`; absent or blank → response phase is a no-op | +/// +/// Header names are matched case-insensitively; only presence is checked, not +/// value. Only the first missing header is reported per rejection. +pub struct RequiredHeadersGuardPlugin; + +/// Parse one comma-separated config value into lowercased, trimmed, non-empty +/// header names. +fn parse_header_list(config: &serde_json::Value, key: &str) -> Vec { + cfg_string(config, key) + .map(|raw| { + raw.split(',') + .map(str::trim) + .filter(|e| !e.is_empty()) + .map(str::to_ascii_lowercase) + .collect::>() + }) + .unwrap_or_default() +} + +/// Check each required name (in order) against `headers`; reject on the first +/// missing one (case-insensitive presence only). +fn guard_headers( + required: &[String], + headers: &HeaderMap, + phase: GuardPhase, +) -> Result<(), GuardError> { + if required.is_empty() { + return Ok(()); // fail-open when unconfigured/blank + } + for name in required { + if !headers.contains_key(name.as_str()) { + return Err(GuardError::RequiredHeaderMissing { + phase, + header: name.clone(), + }); + } + } + Ok(()) +} + +impl GuardPlugin for RequiredHeadersGuardPlugin { + fn id(&self) -> &'static str { + REQUIRED_HEADERS_GUARD_PLUGIN_ID + } + + fn guard_request(&self, ctx: &GuardContext<'_>) -> Result<(), GuardError> { + let required = parse_header_list(ctx.config, "required_request_headers"); + guard_headers(&required, ctx.headers, GuardPhase::Request) + } + + fn guard_response(&self, ctx: &GuardContext<'_>) -> Result<(), GuardError> { + let required = parse_header_list(ctx.config, "required_response_headers"); + guard_headers(&required, ctx.headers, GuardPhase::Response) + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use http::HeaderValue; + use serde_json::json; + use uuid::Uuid; + + fn sec_ctx() -> toolkit_security::SecurityContext { + toolkit_security::SecurityContext::builder() + .subject_id(Uuid::new_v4()) + .subject_tenant_id(Uuid::from_u128(1)) + .build() + .unwrap() + } + + /// Run a request-phase guard with the given config/headers. + fn req( + plugin: &RequiredHeadersGuardPlugin, + config: &serde_json::Value, + headers: &HeaderMap, + ) -> Result<(), GuardError> { + let sec = sec_ctx(); + let ctx = GuardContext { + security_context: Some(&sec), + config, + headers, + }; + plugin.guard_request(&ctx) + } + + /// Run a response-phase guard with the given config/headers. + fn resp( + plugin: &RequiredHeadersGuardPlugin, + config: &serde_json::Value, + headers: &HeaderMap, + ) -> Result<(), GuardError> { + let sec = sec_ctx(); + let ctx = GuardContext { + security_context: Some(&sec), + config, + headers, + }; + plugin.guard_response(&ctx) + } + + #[test] + fn unconfigured_phases_fail_open() { + let plugin = RequiredHeadersGuardPlugin; + let mut headers = HeaderMap::new(); + headers.insert("accept", HeaderValue::from_static("*/*")); + // No config keys at all. + assert!(req(&plugin, &json!({}), &headers).is_ok()); + assert!(resp(&plugin, &json!({}), &headers).is_ok()); + // Blank config values (even all-blank comma lists) are no-ops. + let blank = json!({ + "required_request_headers": ", , ,", + "required_response_headers": "" + }); + assert!(req(&plugin, &blank, &headers).is_ok()); + assert!(resp(&plugin, &blank, &headers).is_ok()); + } + + #[test] + fn request_phase_rejects_first_missing_with_400_semantics() { + let plugin = RequiredHeadersGuardPlugin; + let config = json!({ "required_request_headers": "x-correlation-id, accept" }); + let mut headers = HeaderMap::new(); + // Both present (mixed case on the configured name) → allow. + headers.insert("X-Correlation-Id", HeaderValue::from_static("abc")); + headers.insert("Accept", HeaderValue::from_static("application/json")); + assert!(req(&plugin, &config, &headers).is_ok()); + + // First missing → request phase error naming only that header. + headers.remove("accept"); + let err = req(&plugin, &config, &headers).unwrap_err(); + match err { + GuardError::RequiredHeaderMissing { phase, header } => { + assert_eq!(phase, GuardPhase::Request); + assert_eq!(header, "accept"); + } + } + } + + #[test] + fn response_phase_rejects_missing_with_502_semantics() { + let plugin = RequiredHeadersGuardPlugin; + let config = json!({ "required_response_headers": "content-type" }); + let bare = HeaderMap::new(); + assert!(resp(&plugin, &config, &bare).is_err()); + let mut with_ct = HeaderMap::new(); + with_ct.insert("Content-Type", HeaderValue::from_static("text/plain")); + assert!(resp(&plugin, &config, &with_ct).is_ok()); + } + + #[test] + fn naming_is_case_insensitive_and_only_first_missing_reported() { + let plugin = RequiredHeadersGuardPlugin; + // Two missing names: only the first ("x-a") is reported. + let config = json!({ "required_request_headers": "X-A, x-b" }); + let err = req(&plugin, &config, &HeaderMap::new()).unwrap_err(); + match err { + GuardError::RequiredHeaderMissing { header, .. } => assert_eq!(header, "x-a"), + } + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy.rs b/gears/system/oagw/oagw/src/infra/proxy.rs new file mode 100644 index 0000000..0f67786 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy.rs @@ -0,0 +1,1841 @@ +//! Data-plane proxy engine. +//! +//! Implements the DESIGN proxy flow: +//! 1. alias resolution (tenant chain, case-insensitive), +//! 2. route matching (method allowlist + longest path prefix, query +//! allowlist, path-suffix guard, HTTP-protocol upstreams only), +//! 3. endpoint selection + `X-OAGW-Target-Host` matrix (ADR 0001), +//! 4. header transforms (set/add/remove/passthrough) and hop-by-hop +//! stripping, +//! 5. body validation (Content-Length / Transfer-Encoding / 100 MiB hard +//! limit → 400/413), +//! 6. forwarding over plain HTTP via the hyper-util legacy client, +//! streaming SSE responses and bridging WebSocket upgrades, +//! 7. gateway error mapping (502/503/504) with `X-OAGW-Error-Source` +//! on every response (ADR 0007). +//! +//! # DESIGN-led deviations +//! +//! - TLS to upstreams is not implemented in the MVP (no TLS client +//! dependency); endpoints are reached over plain HTTP and `allow_http_upstream` +//! fails closed by default. See the module doc of `crate::gear`. +//! - gRPC / WebTransport match paths are not implemented or reachable +//! (DESIGN Phase 3); a gRPC upstream simply matches no routes. +//! - SSRF protection is a VPN-style feature; the MVP does not resolve the +//! endpoint hostname, so when `ssrf_policy.enabled` the proxy fails closed +//! with `502 UPSTREAM_UNSUPPORTED` (no DNS rebinding / private-IP checks). + +use std::pin::Pin; +use std::sync::{Arc, OnceLock}; +use std::task::{Context, Poll}; +use std::time::Duration; + +use axum::body::Body; +use axum::response::{IntoResponse, Response}; +use bytes::Bytes; +use dashmap::DashMap; +use http::header::{ + ACCESS_CONTROL_ALLOW_CREDENTIALS, ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_ALLOW_METHODS, + ACCESS_CONTROL_ALLOW_ORIGIN, ACCESS_CONTROL_EXPOSE_HEADERS, ACCESS_CONTROL_MAX_AGE, + ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD, CONNECTION, CONTENT_LENGTH, + HOST, HeaderMap, HeaderName, HeaderValue, ORIGIN, PROXY_AUTHENTICATE, PROXY_AUTHORIZATION, TE, + TRAILER, TRANSFER_ENCODING, UPGRADE, VARY, +}; + +// `http` ≥ 1.0 no longer ships a `KEEP_ALIVE` constant (it was dropped with +// the HTTP/1.0 header fold); define it locally for hop-by-hop stripping. +const KEEP_ALIVE: HeaderName = HeaderName::from_static("keep-alive"); +use http::{Method, Request, StatusCode, Uri}; +use hyper::body::Incoming; +use hyper_util::client::legacy::{Client, connect::HttpConnector}; +use hyper_util::rt::TokioExecutor; +use tracing::{debug, error, warn}; +use uuid::Uuid; + +use crate::api::rest::error::{ + ERROR_SOURCE_GATEWAY, ERROR_SOURCE_UPSTREAM, HEADER_ERROR_SOURCE, OagwProblem, type_ids, +}; +use crate::domain::models::{CorsConfig, Endpoint, HeaderOps, PathSuffixMode, Route, Upstream}; +use crate::domain::plugin::{AuthContext, GuardContext, GuardError, GuardPhase, PluginError}; +use crate::domain::service::{AliasResolution, ControlPlaneService}; +use crate::domain::validation::{alias_is_valid_format, derive_alias, is_ip, normalize_alias}; +use crate::infra::plugin::{AuthPluginRegistry, GuardPluginRegistry}; +use crate::infra::ratelimit::{RateLimitDecision, RateLimiter}; +use toolkit_security::SecurityContext; + +/// The routing header consumed by the data plane (ADR 0001). +pub const TARGET_HOST_HEADER: &str = "x-oagw-target-host"; + +/// Shared outbound HTTP client (plain HTTP, HTTP/1.1 upgrades enabled). +fn client() -> &'static Client { + static CLIENT: OnceLock> = OnceLock::new(); + CLIENT.get_or_init(|| Client::builder(TokioExecutor::new()).build(HttpConnector::new())) +} + +/// Per-upstream round-robin counters. +fn rr_counters() -> &'static Arc> { + static RR: OnceLock>> = OnceLock::new(); + RR.get_or_init(|| Arc::new(DashMap::new())) +} + +/// The built-in guard-plugin registry (stateless; `required_headers.v1` per +/// ADR 0009). A process global rather than an injected dependency because it +/// carries no state. +fn guard_registry() -> &'static GuardPluginRegistry { + static GUARDS: OnceLock = OnceLock::new(); + GUARDS.get_or_init(GuardPluginRegistry::with_builtins) +} + +/// Hop-by-hop headers always stripped (unless an upgrade is in flight). +const HOP_BY_HOP: [HeaderName; 9] = [ + CONNECTION, + KEEP_ALIVE, + PROXY_AUTHENTICATE, + PROXY_AUTHORIZATION, + TE, + TRAILER, + TRANSFER_ENCODING, + UPGRADE, + HOST, +]; + +/// The main proxy entry point, called by the REST handler. +/// +/// `chain` is the tenant chain (descendant → root) used for alias +/// resolution. `rest` is the decoded path suffix after the alias plus any +/// raw query string (`path?query`), used for route matching and passthrough. +/// `client_upgrade` is the downstream `OnUpgrade` when the caller asked for a +/// protocol upgrade (WebSocket). +#[allow(clippy::too_many_arguments)] +pub async fn proxy_request( + service: &Arc, + chain: Vec, + alias: String, + rest: String, + method: Method, + inbound_headers: HeaderMap, + body: Body, + client_upgrade: Option, + security_ctx: Option<&SecurityContext>, + auth: Option<&AuthPluginRegistry>, + rate: Option<&RateLimiter>, +) -> Response { + let cfg = service.config(); + + // 0. CORS preflight fast path (ADR 0004): an OPTIONS request carrying + // `Origin` + `Access-Control-Request-Method` is answered locally with a + // permissive 204 that echoes the requested origin/method/headers — no + // upstream resolution, no tenant context, no auth. Origin/method + // enforcement is deferred to the actual (non-preflight) request. + if method == Method::OPTIONS && is_cors_preflight(&inbound_headers) { + return cors_preflight_response(&inbound_headers); + } + + // 1. Alias resolution (case-insensitive, descendant→root). A disabled + // upstream in the owning tenant short-circuits the chain: proxies MUST + // reject with 503 (PRD cpt-cf-oagw-fr-enable-disable), never fall through + // to an ancestor's enabled copy. + let upstream = match service.resolve_upstream_in_chain(&chain, &alias) { + AliasResolution::Found(u) => *u, + AliasResolution::NotFound => { + return gateway( + OagwProblem::new( + type_ids::ROUTE_NOT_FOUND, + "Route Not Found", + StatusCode::NOT_FOUND, + ) + .detail(format!("no route matches alias '{alias}'")) + .alias(alias), + ); + } + AliasResolution::Disabled => { + return gateway( + OagwProblem::new( + type_ids::LINK_UNAVAILABLE, + "Upstream Unavailable", + StatusCode::SERVICE_UNAVAILABLE, + ) + .detail(format!("upstream alias '{alias}' is disabled")) + .alias(alias), + ); + } + }; + + let proxy_ctx = ProxyContext { + service, + cfg, + upstream, + alias, + }; + + // 2+3. Route match + endpoint selection (+ target-host matrix). + let route = match proxy_ctx.match_route(&chain, &method, &rest) { + Ok(r) => r, + Err(e) => return e.into_response(), + }; + + // 2c. CORS enforcement on actual requests (ADR 0004): after upstream/ + // route resolution, before body buffering/auth/forwarding. A cross-origin + // request (Origin present) must match the effective config's allowed + // origins + methods; disallowed → 403 (`origin_not_allowed` / + // `method_not_allowed`). Allowed requests carry CORS response headers on + // the forwarded response. + let cors = effective_cors(&proxy_ctx.upstream, &route.route); + let cors_response: Option<(CorsConfig, String)> = match cors + .as_ref() + .map(|cfg| cors_prepare(cfg, &inbound_headers, &method)) + { + Some(Ok(ok)) => ok, + Some(Err(e)) => return e.into_response(), + None => None, + }; + + // 5. Body validation + buffering. + let body_bytes = match validate_and_buffer(&inbound_headers, body, cfg.body_limit_bytes).await { + Ok(b) => b, + Err(e) => return e, + }; + + // 4a. Auth plugins (credential injection, DESIGN execution order: + // auth → rate limit → guards → transform). Executed after + // matching/body validation, before the outbound request is built. + let (auth_headers, auth_query) = + match run_auth(security_ctx, auth, &proxy_ctx.upstream, &method).await { + Ok(ok) => ok, + Err(e) => return e, + }; + + // 4b. Rate limiting (ADR 0006 flow: resolve → auth → rate limit → + // guards). DP-owned per-instance token buckets; effective limit is + // `min(upstream, route)` — stricter always wins. + if let Some(rate) = rate + && let Some(plan) = RateLimiter::plan_for( + &proxy_ctx.upstream, + &route.route, + security_ctx, + &inbound_headers, + ) + { + let decision = rate.try_acquire(&plan); + if !decision.allowed { + warn!( + alias = %proxy_ctx.alias, + limit = decision.limit, + retry_after_secs = decision.reset_after_secs, + "proxy request rate-limited" + ); + return rate_limit_response(&decision, &proxy_ctx.alias); + } + } + + // 4c. Request-phase guard plugins (ADR 0009), before the request is + // built/forwarded. + if let Err(e) = run_request_guards(security_ctx, &inbound_headers, &proxy_ctx.upstream) { + return e.into_response(); + } + + // 4. Build the outbound request (headers + URI). + let outbound = match proxy_ctx.build_request( + &method, + &rest, + &route, + &inbound_headers, + body_bytes, + &auth_headers, + auth_query, + ) { + Ok(r) => r, + Err(e) => return e.into_response(), + }; + + // 6. Forward (streaming response incl. SSE; WebSocket upgrade bridged), + // then attach CORS response headers to allowed cross-origin responses + // (ADR 0004: `Access-Control-Allow-Origin` + `Vary: Origin`, plus + // configured expose/credentials headers). + let mut response = proxy_ctx + .forward(outbound, client_upgrade, security_ctx) + .await; + if let Some((cfg, origin)) = &cors_response { + add_cors_response_headers(&mut response, cfg, origin); + } + response +} + +/// Everything the proxy needs after alias resolution. +struct ProxyContext<'a> { + service: &'a Arc, + cfg: &'a crate::config::OagwConfig, + upstream: Upstream, + alias: String, +} + +/// The outcome of route matching: the matched route plus the effective +/// outbound path. +struct MatchedRoute { + route: Route, + outbound_path: String, +} + +impl ProxyContext<'_> { + /// Route-match against the upstream's routes found across the tenant + /// chain, plus the target-host matrix. + fn match_route( + &self, + chain: &[Uuid], + method: &Method, + rest: &str, + ) -> Result> { + let routes = self + .service + .list_routes_for_upstream(chain, self.upstream.id); + + // Suffix path after the alias, normalized to a leading '/'. + let suffix = if rest.is_empty() { + "/".to_owned() + } else if rest.starts_with('/') { + rest.to_owned() + } else { + format!("/{rest}") + }; + + // Query params (parsed for the query-allowlist guard). + let query_params: Vec<(String, String)> = extract_query_params(&suffix); + let suffix_path = match suffix.split('?').next() { + Some(p) => p.to_owned(), + None => suffix.clone(), + }; + + // Longest path-prefix match among HTTP-method routes. Disabled routes + // are excluded from matching (PRD cpt-cf-oagw-fr-enable-disable). + let mut best: Option<(&Route, String)> = None; + for r in &routes { + if !r.enabled { + continue; + } + let Some(m) = r.http_match() else { continue }; + if !m + .methods + .iter() + .any(|x| x.eq_ignore_ascii_case(method.as_str())) + { + continue; + } + let prefix = normalize_route_path(&m.path); + if suffix_path.starts_with(&prefix) + && best + .as_ref() + .map_or(0, |(br, _)| br.http_match().map_or(0, |h| h.path.len())) + < m.path.len() + { + best = Some((r, prefix.clone())); + } + } + let Some((route, prefix)) = best else { + return Err(Box::new(gateway( + OagwProblem::new( + type_ids::ROUTE_NOT_FOUND, + "Route Not Found", + StatusCode::NOT_FOUND, + ) + .detail(format!( + "no route matches {} '{}' for alias '{}'", + method, suffix_path, self.alias + )) + .alias(self.alias.clone()), + ))); + }; + // `route` borrows from `routes` (a Vec owned here) — clone out. + let route = route.clone(); + + let Some(m) = route.http_match() else { + return Err(Box::new(gateway( + OagwProblem::new( + type_ids::ROUTE_NOT_FOUND, + "Route Not Found", + StatusCode::NOT_FOUND, + ) + .detail(format!( + "no route matches {} '{}' for alias '{}'", + method, suffix_path, self.alias + )) + .alias(self.alias.clone()), + ))); + }; + let outbound_path = suffix_path.clone(); + // There is a path suffix beyond the matched prefix. + if suffix_path.len() > prefix.len() && m.path_suffix_mode == PathSuffixMode::Disabled { + return Err(Box::new(gateway( + OagwProblem::validation(format!( + "path suffix '{}' is not allowed for route '{}' (path_suffix_mode: disabled)", + &suffix_path[prefix.len()..], + m.path + )) + .path(suffix_path), + ))); + } + // Append mode: the full suffix path (route.path + remainder) is + // forwarded as-is. + + // Query allowlist guard. + if !m.query_allowlist.is_empty() { + let allowed: Vec<&str> = m.query_allowlist.iter().map(String::as_str).collect(); + if let Some(bad) = query_params + .iter() + .find(|(k, _)| !allowed.contains(&k.as_str())) + { + return Err(Box::new(gateway( + OagwProblem::validation(format!( + "query parameter '{}' is not allowed by this route", + bad.0 + )) + .path(suffix_path), + ))); + } + } + + Ok(MatchedRoute { + route, + outbound_path, + }) + } + + /// Resolve the concrete endpoint per the `X-OAGW-Target-Host` matrix + /// (ADR 0001). + fn select_endpoint(&self, headers: &HeaderMap) -> Result> { + let endpoints = &self.upstream.server.endpoints; + let target = headers + .get(TARGET_HOST_HEADER) + .and_then(|v| v.to_str().ok()) + .map(str::trim) + .filter(|s| !s.is_empty()); + + if let Some(t) = target { + if !valid_target_host(t) { + return Err(Box::new(gateway( + OagwProblem::new( + type_ids::INVALID_TARGET_HOST, + "Invalid Target Host", + StatusCode::BAD_REQUEST, + ) + .detail(format!( + "X-OAGW-Target-Host '{t}' is invalid (must be a hostname or IP, no port/path)" + )) + .invalid_value(t) + .host(join_endpoint_hosts(endpoints)), + ))); + } + let tn = normalize_alias(t); + if let Some(e) = endpoints.iter().find(|e| normalize_alias(&e.host) == tn) { + return Ok(e.clone()); + } + return Err(Box::new(gateway( + OagwProblem::new( + type_ids::UNKNOWN_TARGET_HOST, + "Unknown Target Host", + StatusCode::BAD_REQUEST, + ) + .detail(format!( + "X-OAGW-Target-Host '{t}' does not match any configured endpoint" + )) + .invalid_value(t) + .valid_hosts(endpoints.iter().map(|e| e.host.clone()).collect()), + ))); + } + + if endpoints.len() == 1 { + return Ok(endpoints[0].clone()); + } + // Multi-endpoint pool: an alias that is a common derived suffix + // mandates the routing header; an explicit multi-endpoint alias + // round-robins. + let is_common_suffix = matches!( + derive_alias(&self.upstream), + Ok(Some(d)) if normalize_alias(&d) == normalize_alias(&self.upstream.alias) + ); + if is_common_suffix { + return Err(Box::new(gateway( + OagwProblem::new( + type_ids::MISSING_TARGET_HOST, + "Missing Target Host", + StatusCode::BAD_REQUEST, + ) + .detail( + "X-OAGW-Target-Host is required for a multi-endpoint upstream with a common suffix alias", + ) + .alias(self.alias.clone()) + .valid_hosts(endpoints.iter().map(|e| e.host.clone()).collect()), + ))); + } + // Explicit multi-endpoint alias: round-robin. + let n = rr_counters() + .entry(self.upstream.id) + .and_modify(|c| *c = c.wrapping_add(1)) + .or_insert(0); + #[allow( + clippy::cast_possible_truncation, + reason = "the round-robin counter is taken modulo the endpoint count first, so the value is bounded well below usize::MAX" + )] + let idx = (*n % endpoints.len() as u64) as usize; + Ok(endpoints[idx].clone()) + } + + /// Build the outbound HTTP/1.1 request. + #[allow( + clippy::too_many_arguments, + clippy::cognitive_complexity, + reason = "build_request's parameter list mirrors the full proxy-flow inputs (method, path, matched route, inbound headers, buffered body, auth-injected headers/query); the complexity comes from the linear, well-commented header-transform pipeline branches" + )] + fn build_request( + &self, + method: &Method, + rest: &str, + matched: &MatchedRoute, + inbound_headers: &HeaderMap, + body: Bytes, + auth_headers: &HeaderMap, + auth_query: Vec<(String, String)>, + ) -> Result, Box> { + let cfg = self.cfg; + if !cfg.allow_http_upstream { + return Err(Box::new(gateway( + OagwProblem::new( + type_ids::UPSTREAM_UNSUPPORTED, + "Upstream Unsupported", + StatusCode::BAD_GATEWAY, + ) + .detail("plain-HTTP upstream relay is disabled by configuration (allow_http_upstream: false)"), + ))); + } + if cfg.ssrf_policy.enabled { + return Err(Box::new(gateway( + OagwProblem::new( + type_ids::UPSTREAM_UNSUPPORTED, + "Upstream Unsupported", + StatusCode::BAD_GATEWAY, + ) + .detail( + "SSRF protection is enabled; upstream relay is not permitted in this build", + ), + ))); + } + + let endpoint = self.select_endpoint(inbound_headers)?; + debug!( + route_id = %matched.route.id, + outbound_path = %matched.outbound_path, + endpoint = %format!("{}:{}", endpoint.host, endpoint.port), + "resolved route -> endpoint" + ); + + // Query string passthrough + auth-injected query params (URL-encoded). + let query = rest.split_once('?').map_or("", |(_, q)| q); + let path = &matched.outbound_path; + let mut query_parts: Vec = Vec::new(); + if !query.is_empty() { + query_parts.push(query.to_owned()); + } + for (k, v) in auth_query { + let ek: String = form_urlencoded::byte_serialize(k.as_bytes()).collect(); + let ev: String = form_urlencoded::byte_serialize(v.as_bytes()).collect(); + query_parts.push(format!("{ek}={ev}")); + } + let joined_query = query_parts.join("&"); + // One bracketing decision shared by the request-line URI authority and + // the outbound `Host` header (Rr-003): an IPv6 endpoint literal like + // `2001:db8::1` must be bracketed in both, never emitted as the + // ambiguous `2001:db8::1:8080`. + let authority = authority_for(&endpoint.host, endpoint.port); + let out_uri = build_outbound_uri(&authority, path, &joined_query)?; + + let is_upgrade = inbound_headers + .get(UPGRADE) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| !v.is_empty()); + + // Assemble the outbound headers in one deterministic pass (DESIGN + // "Headers Transformation"): inbound passthrough appends, `set` rules + // replace any earlier value, `add` rules append, and auth-injected + // credentials use set semantics so exactly one `Authorization` reaches + // the upstream (a caller Authorization that slipped through an + // `All`/allowlist passthrough is replaced by the injected one). + let uc = &self.upstream.headers.request; + let mut headers = HeaderMap::new(); + // The single Content-Length comes from the buffered body (always set + // after full buffering); the inbound value is never forwarded. + headers.insert(CONTENT_LENGTH, HeaderValue::from(body.len())); + let host_value = HeaderValue::from_str(&authority).map_err(|e| { + gateway(OagwProblem::validation(format!( + "upstream endpoint host is not a valid header value: {e}" + ))) + })?; + headers.insert(HOST, host_value); + + // Inbound passthrough (hop-by-hop/protected + content-length skipped; + // WebSocket handshake headers are relayed by the upgrade block so a + // passthrough policy cannot drop them — RFC 6455 end-to-end). + for (name, value) in inbound_headers { + if is_protected_header(name) || name == CONTENT_LENGTH { + continue; + } + if is_upgrade && is_websocket_handshake_header(name) { + continue; + } + match uc.passthrough { + crate::domain::models::PassthroughMode::None => continue, + crate::domain::models::PassthroughMode::Allowlist => { + if !uc + .passthrough_allowlist + .iter() + .any(|a| a.as_str() == name.as_str()) + { + continue; + } + } + crate::domain::models::PassthroughMode::All => {} + } + if uc.remove.iter().any(|r| r.as_str() == name.as_str()) { + continue; + } + headers.append(name, value.clone()); + } + // `set` rules replace any earlier emitted value for the name. + for (name, value) in valid_rule_pairs(&uc.set) { + headers.insert(name, value); + } + // `add` rules append after the set pass. + for (name, value) in valid_rule_pairs(&uc.add) { + headers.append(name, value); + } + // Re-attach the upgrade headers for WebSocket relay (not + // passthrough-managed): Upgrade/Connection plus every inbound + // `Sec-WebSocket-*` handshake header (RFC 6455 end-to-end). + if is_upgrade { + if let Some(v) = inbound_headers.get(UPGRADE) { + headers.append(UPGRADE, v.clone()); + } + if let Some(v) = inbound_headers.get(CONNECTION) { + headers.append(CONNECTION, v.clone()); + } + for (name, value) in inbound_headers { + if is_websocket_handshake_header(name) { + // Append, never replace: a client that sends repeated + // `Sec-WebSocket-Protocol` / `Sec-WebSocket-Extensions` + // values (RFC 6455 allows multi-valued handshake headers) + // must have ALL of them relayed end-to-end, matching the + // passthrough ethos. Single-value `Key`/`Version` + // behavior is unchanged (one value appended after any + // transform-emitted value, as before). + headers.append(name, value.clone()); + } + } + } + + // Auth-injected credentials (set semantics) win over the transform + // pipeline — a header `remove` rule must not strip credentials, and a + // caller-supplied `authorization` must not leak alongside. + for (name, value) in auth_headers { + headers.insert(name, value.clone()); + } + + let mut req = Request::builder() + .method(method.clone()) + .uri(out_uri) + .body(Body::from(body)) + .map_err(|e| { + gateway(OagwProblem::downstream(format!( + "cannot build upstream request: {e}" + ))) + })?; + *req.headers_mut() = headers; + Ok(req) + } + + /// Send the request and map the response. + async fn forward( + &self, + request: Request, + client_upgrade: Option, + security_ctx: Option<&SecurityContext>, + ) -> Response { + let timeout = Duration::from_secs(self.cfg.proxy_timeout_secs.max(1)); + let result = tokio::time::timeout(timeout, client().request(request)).await; + + let resp = match result { + Ok(Ok(r)) => r, + Ok(Err(e)) => { + // Walk the error chain: hyper surfaces connect failures as + // `client error (Connect)` with the real cause (e.g. `Connection + // refused`) as the source, so classification needs the full chain + // (logged) while the client detail gets the single root cause. + let full = error_chain_string(&e); + let brief = root_cause_string(&e); + error!( + alias = %self.upstream.alias, + host = %self.upstream_host(), + error = %full, + "upstream request failed", + ); + return map_send_error(&full, &brief, false); + } + Err(_) => { + error!( + alias = %self.upstream.alias, + host = %self.upstream_host(), + timeout_secs = self.cfg.proxy_timeout_secs, + "upstream request timed out", + ); + return gateway( + OagwProblem::new( + type_ids::REQUEST_TIMEOUT, + "Request Timeout", + StatusCode::GATEWAY_TIMEOUT, + ) + .detail(format!("upstream request timed out after {timeout:?}")) + .host(self.upstream_host()), + ); + } + }; + + // WebSocket / protocol upgrade: bridge both sides. + if resp.status() == StatusCode::SWITCHING_PROTOCOLS { + return self.handle_upgrade(client_upgrade, resp).await; + } + + // Stream the upstream body through (SSE etc.). + let (mut parts, incoming) = resp.into_parts(); + apply_header_ops_set_add_remove(&mut parts.headers, &self.upstream.headers.response); + let mut response = Response::builder() + .status(parts.status) + .body(Body::new(incoming)) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()); + for (name, value) in &parts.headers { + if is_protected_header(name) { + continue; + } + response.headers_mut().append(name.clone(), value.clone()); + } + response.headers_mut().insert( + HEADER_ERROR_SOURCE, + HeaderValue::from_static(ERROR_SOURCE_UPSTREAM), + ); + + // Response-phase guard plugins (ADR 0009) with set semantics over the + // upstream-derived headers, before the response is returned. + if let Err(e) = run_response_guards(security_ctx, &self.upstream, response.headers()) { + return e.into_response(); + } + response + } + + fn upstream_host(&self) -> String { + self.upstream + .server + .endpoints + .first() + .map(|e| e.host.clone()) + .unwrap_or_default() + } + + /// Bridge a 101 response: return it to the client and relay bytes + /// between the client- and upstream-side upgraded connections. + async fn handle_upgrade( + &self, + client_upgrade: Option, + mut resp: http::Response, + ) -> Response { + // No further access to `self` beyond this point; `self` is only a + // borrowed context (service/config/upstream), kept for the 101 + // construction below. + let upstream_upgraded = match hyper::upgrade::on(&mut resp).await { + Ok(u) => u, + Err(e) => { + return gateway(OagwProblem::downstream(format!( + "upstream upgrade failed: {e}" + ))); + } + }; + let mut out = Response::builder() + .status(StatusCode::SWITCHING_PROTOCOLS) + .body(Body::empty()) + .unwrap_or_else(|_| StatusCode::INTERNAL_SERVER_ERROR.into_response()); + for (name, value) in resp.headers() { + if is_protected_header(name) { + // Keep the protocol-upgrade headers on a 101 so the client sees + // a well-formed upgrade response (the standard hop-by-hop + // stripping rule excludes the in-flight upgrade case). + if name == CONNECTION || name == UPGRADE { + out.headers_mut().append(name.clone(), value.clone()); + } + continue; + } + out.headers_mut().append(name.clone(), value.clone()); + } + out.headers_mut().insert( + HEADER_ERROR_SOURCE, + HeaderValue::from_static(ERROR_SOURCE_UPSTREAM), + ); + + // Bridge the client- and upstream-side upgraded connections. The + // client-side `OnUpgrade` resolves once this 101 response is written + // by our server. + let Some(client_upgrade) = client_upgrade else { + // No downstream upgrade was requested (e.g. test harness without a + // hyper server); the upstream side is consumed and dropped. + return out; + }; + tokio::spawn(async move { + let client_upgraded = match client_upgrade.await { + Ok(c) => c, + Err(e) => { + warn!(err = %e, "downstream connection upgrade failed"); + return; + } + }; + let mut a = HyperUpgradedIo::new(client_upgraded); + let mut b = HyperUpgradedIo::new(upstream_upgraded); + if let Err(e) = tokio::io::copy_bidirectional(&mut a, &mut b).await { + debug!(err = %e, "websocket relay closed with error"); + } + }); + out + } +} + +// --------------------------------------------------------------------------- +// Auth plugins +// --------------------------------------------------------------------------- + +/// Execute the upstream's bound auth plugin (credential injection) and return +/// the collected outbound headers + query params. +/// +/// Error mapping follows the DESIGN error table via [`PluginError`]: +/// +/// | `PluginError` | HTTP | GTS type | +/// |---|---|---| +/// | `SecretNotFound` | 500 | `secret.not_found.v1` | +/// | `UnknownPlugin` | 503 | `plugin.not_found.v1` | +/// | `AuthenticationFailed` | 401 | `auth.failed.v1` | +/// | `Internal` | 503 | `link.unavailable.v1` | +async fn run_auth( + security_ctx: Option<&SecurityContext>, + auth: Option<&AuthPluginRegistry>, + upstream: &Upstream, + _method: &Method, +) -> Result<(HeaderMap, Vec<(String, String)>), Response> { + if let (Some(ctx), Some(auth_cfg)) = (security_ctx, &upstream.auth) { + let mut headers = HeaderMap::new(); + let mut query_params: Vec<(String, String)> = Vec::new(); + { + let mut actx = AuthContext { + security_context: ctx, + config: &auth_cfg.config, + headers: &mut headers, + query_params: &mut query_params, + }; + execute_auth_plugin(auth, &auth_cfg.r#type, &mut actx).await?; + } + Ok((headers, query_params)) + } else { + Ok((HeaderMap::new(), Vec::new())) + } +} + +/// Resolve the plugin by GTS id and run it, mapping `PluginError`s to gateway +/// problems. +async fn execute_auth_plugin( + registry: Option<&AuthPluginRegistry>, + plugin_id: &str, + actx: &mut AuthContext<'_>, +) -> Result<(), Response> { + let Some(registry) = registry else { + return Err(gateway( + OagwProblem::new( + type_ids::PLUGIN_NOT_FOUND, + "Plugin Not Found", + StatusCode::SERVICE_UNAVAILABLE, + ) + .detail(format!( + "auth plugin '{plugin_id}' is not available (no auth plugin registry)" + )), + )); + }; + let Some(plugin) = registry.resolve(plugin_id) else { + return Err(gateway( + OagwProblem::new( + type_ids::PLUGIN_NOT_FOUND, + "Plugin Not Found", + StatusCode::SERVICE_UNAVAILABLE, + ) + .detail(format!("unknown auth plugin '{plugin_id}'")), + )); + }; + match plugin.authenticate(actx).await { + Ok(()) => Ok(()), + Err(PluginError::SecretNotFound(detail)) => Err(gateway( + OagwProblem::new( + type_ids::SECRET_NOT_FOUND, + "Secret Not Found", + StatusCode::INTERNAL_SERVER_ERROR, + ) + .detail(detail), + )), + Err(PluginError::UnknownPlugin(detail)) => Err(gateway( + OagwProblem::new( + type_ids::PLUGIN_NOT_FOUND, + "Plugin Not Found", + StatusCode::SERVICE_UNAVAILABLE, + ) + .detail(detail), + )), + Err(PluginError::AuthenticationFailed(detail)) => Err(gateway( + OagwProblem::new( + type_ids::AUTH_FAILED, + "Authentication Failed", + StatusCode::UNAUTHORIZED, + ) + .detail(detail), + )), + Err(PluginError::Internal(detail)) => Err(gateway( + OagwProblem::new( + type_ids::LINK_UNAVAILABLE, + "Link Unavailable", + StatusCode::SERVICE_UNAVAILABLE, + ) + .detail(detail), + )), + } +} + +// --------------------------------------------------------------------------- +// Guard plugins (ADR 0009) +// --------------------------------------------------------------------------- + +/// Execute the request-phase guard plugins bound on the upstream +/// (`plugins.items`) against the inbound request headers. +/// +/// Execution is fail-open per ADR 0009: plugins bound by GTS ID with no +/// matching builtin (custom UUIDs, catalog-only `timeout`/`cors`) are +/// skipped, and the MVP model's `plugins.items` carries no per-plugin config +/// object (the schema defines string IDs), so a bound `required_headers.v1` +/// runs with empty config — which is a no-op. The config-driven rejection +/// paths (400 request / 502 response) are exercised by the plugin unit tests. +fn run_request_guards( + security_ctx: Option<&SecurityContext>, + inbound_headers: &HeaderMap, + upstream: &Upstream, +) -> Result<(), Box> { + let registry = guard_registry(); + for id in &upstream.plugins.items { + let Some(plugin) = registry.resolve(id) else { + warn!(plugin_id = %id, "guard plugin not resolvable; failing open (skipping)"); + continue; + }; + let ctx = GuardContext { + security_context: security_ctx, + config: &serde_json::Value::Null, + headers: inbound_headers, + }; + if let Err(e) = plugin.guard_request(&ctx) { + warn!(plugin_id = %id, error = ?e, "request guard rejected the request"); + return Err(Box::new(map_guard_error(e))); + } + } + Ok(()) +} + +/// Execute the response-phase guard plugins bound on the upstream against the +/// upstream-derived response headers (ADR 0009), before it is returned. +fn run_response_guards( + security_ctx: Option<&SecurityContext>, + upstream: &Upstream, + headers: &HeaderMap, +) -> Result<(), Box> { + let registry = guard_registry(); + for id in &upstream.plugins.items { + let Some(plugin) = registry.resolve(id) else { + warn!(plugin_id = %id, "response guard plugin not resolvable; failing open (skipping)"); + continue; + }; + let ctx = GuardContext { + security_context: security_ctx, + config: &serde_json::Value::Null, + headers, + }; + if let Err(e) = plugin.guard_response(&ctx) { + warn!(plugin_id = %id, error = ?e, "response guard rejected the response"); + return Err(Box::new(map_guard_error(e))); + } + } + Ok(()) +} + +/// Map a [`GuardError`] to the DESIGN error table: request phase → 400, +/// response phase → 502, both `required_header.missing.v1` (ADR 0009). +fn map_guard_error(e: GuardError) -> Response { + match e { + GuardError::RequiredHeaderMissing { phase, header } => match phase { + GuardPhase::Request => gateway( + OagwProblem::new( + type_ids::REQUIRED_HEADER_MISSING, + "Required Header Missing", + StatusCode::BAD_REQUEST, + ) + .detail(format!("missing required request header '{header}'")) + .missing_headers(vec![header]), + ), + GuardPhase::Response => gateway( + OagwProblem::new( + type_ids::REQUIRED_HEADER_MISSING, + "Required Header Missing", + StatusCode::BAD_GATEWAY, + ) + .detail(format!("missing required response header '{header}'")) + .missing_headers(vec![header]), + ), + }, + } +} + +// --------------------------------------------------------------------------- +// CORS (ADR 0004) +// --------------------------------------------------------------------------- + +/// Detect a CORS preflight: `OPTIONS` + `Origin` + `Access-Control-Request-Method`. +fn is_cors_preflight(headers: &HeaderMap) -> bool { + headers.contains_key(ORIGIN) + && headers + .get(ACCESS_CONTROL_REQUEST_METHOD) + .and_then(|v| v.to_str().ok()) + .is_some_and(|v| !v.trim().is_empty()) +} + +/// Answer a preflight locally with a permissive 204 (ADR 0004 "Preflight +/// Request Handling"): echo the requested origin/method/headers and advertise +/// a one-day max-age, always with `Vary` so caches never serve a cross-client +/// CORS decision. +fn cors_preflight_response(inbound: &HeaderMap) -> Response { + let mut response = Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .unwrap_or_else(|_| StatusCode::NO_CONTENT.into_response()); + let origin = inbound + .get(ORIGIN) + .and_then(|v| v.to_str().ok()) + .unwrap_or("*"); + let requested_method = inbound + .get(ACCESS_CONTROL_REQUEST_METHOD) + .and_then(|v| v.to_str().ok()) + .unwrap_or("*"); + response.headers_mut().insert( + ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_str(origin).unwrap_or(HeaderValue::from_static("*")), + ); + response.headers_mut().insert( + ACCESS_CONTROL_ALLOW_METHODS, + HeaderValue::from_str(requested_method).unwrap_or(HeaderValue::from_static("*")), + ); + if let Some(v) = inbound.get(ACCESS_CONTROL_REQUEST_HEADERS) { + response + .headers_mut() + .insert(ACCESS_CONTROL_ALLOW_HEADERS, v.clone()); + } + response + .headers_mut() + .insert(ACCESS_CONTROL_MAX_AGE, HeaderValue::from_static("86400")); + response.headers_mut().insert( + VARY, + HeaderValue::from_static( + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers", + ), + ); + response.headers_mut().insert( + HEADER_ERROR_SOURCE, + HeaderValue::from_static(ERROR_SOURCE_GATEWAY), + ); + response +} + +/// The effective CORS config for one proxied request. +/// +/// # DESIGN-led deviation +/// +/// Route-level CORS fully overrides upstream-level CORS in the MVP; +/// hierarchical union (`inherit`) / enforcement (`enforce`) across the tenant +/// hierarchy is out of scope (the models retain `sharing` for API +/// compatibility). +fn effective_cors(upstream: &Upstream, route: &Route) -> Option { + route.cors.clone().or_else(|| upstream.cors.clone()) +} + +/// Validate an actual (non-preflight) cross-origin request against the +/// effective CORS config. Returns `None` when CORS is disabled or the request +/// carries no `Origin` (non-browser client — not applicable), and the concrete +/// `(config, origin)` pair to echo on the response when allowed. +fn cors_prepare( + cfg: &CorsConfig, + headers: &HeaderMap, + method: &Method, +) -> Result, Box> { + if !cfg.enabled { + return Ok(None); + } + let Some(origin) = headers.get(ORIGIN).and_then(|v| v.to_str().ok()) else { + return Ok(None); // no Origin → not a cross-origin request + }; + let origin = origin.trim(); + + // Origin must match exactly (protocol+host+port sensitive) or be `*`. + let allowed = cfg.allowed_origins.iter().any(|o| o == "*" || o == origin); + if !allowed { + return Err(Box::new(gateway( + OagwProblem::new( + type_ids::CORS_ORIGIN_NOT_ALLOWED, + "CORS Origin Not Allowed", + StatusCode::FORBIDDEN, + ) + .detail(format!("Origin '{origin}' not in allowed origins list")) + .invalid_value(origin), + ))); + } + // Method must be in `allowed_methods` (designated cross-origin methods). + if !cfg + .allowed_methods + .iter() + .any(|m| m.eq_ignore_ascii_case(method.as_str())) + { + return Err(Box::new(gateway( + OagwProblem::new( + type_ids::CORS_METHOD_NOT_ALLOWED, + "CORS Method Not Allowed", + StatusCode::FORBIDDEN, + ) + .detail(format!("Method '{method}' not in allowed methods list")) + .invalid_value(method.as_str()), + ))); + } + Ok(Some((cfg.clone(), origin.to_owned()))) +} + +/// Add CORS response headers to an allowed cross-origin response (ADR 0004 +/// "Actual Request Handling"), always with `Vary: Origin`. +fn add_cors_response_headers(response: &mut Response, cfg: &CorsConfig, origin: &str) { + response.headers_mut().insert( + ACCESS_CONTROL_ALLOW_ORIGIN, + HeaderValue::from_str(origin).unwrap_or(HeaderValue::from_static("*")), + ); + if !cfg.expose_headers.is_empty() { + response.headers_mut().insert( + ACCESS_CONTROL_EXPOSE_HEADERS, + HeaderValue::from_str(&cfg.expose_headers.join(", ")) + .unwrap_or_else(|_| HeaderValue::from_static("")), + ); + } + if cfg.allow_credentials { + response.headers_mut().insert( + ACCESS_CONTROL_ALLOW_CREDENTIALS, + HeaderValue::from_static("true"), + ); + } + // Append to any existing Vary (e.g. upstream-provided). + let vary = response + .headers_mut() + .get(VARY) + .and_then(|v| v.to_str().ok()) + .map_or_else(|| "Origin".to_owned(), |s| format!("{s}, Origin")); + response.headers_mut().insert( + VARY, + HeaderValue::from_str(&vary).unwrap_or(HeaderValue::from_static("Origin")), + ); +} + +// --------------------------------------------------------------------------- +// Rate-limit response (ADR 0003 response headers) +// --------------------------------------------------------------------------- + +/// Epoch seconds now (for `X-RateLimit-Reset`). +fn now_epoch_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.as_secs()) +} + +/// Build the 429 response: RFC 6585 / draft-ietf-httpapi-ratelimit-headers +/// (`Retry-After`, `X-RateLimit-*`) plus the DESIGN problem body. +fn rate_limit_response(decision: &RateLimitDecision, alias: &str) -> Response { + let mut response = OagwProblem::new( + type_ids::RATE_LIMIT_EXCEEDED, + "Rate Limit Exceeded", + StatusCode::TOO_MANY_REQUESTS, + ) + .detail(format!( + "rate limit exceeded (limit {}, retry after {}s)", + decision.limit, decision.reset_after_secs + )) + .retry_after_seconds(decision.reset_after_secs) + .alias(alias) + .into_response(); + + response.headers_mut().insert( + http::header::RETRY_AFTER, + HeaderValue::from(decision.reset_after_secs.max(1)), + ); + response + .headers_mut() + .insert("x-ratelimit-limit", HeaderValue::from(decision.limit)); + response.headers_mut().insert( + "x-ratelimit-remaining", + HeaderValue::from(decision.remaining), + ); + response.headers_mut().insert( + "x-ratelimit-reset", + HeaderValue::from(now_epoch_secs() + decision.reset_after_secs), + ); + response +} + +// --------------------------------------------------------------------------- +// Gateway error helpers +// --------------------------------------------------------------------------- + +/// Render an OAGW problem as a gateway error response. +fn gateway(problem: OagwProblem) -> Response { + problem.into_response() +} + +/// A 413 problem for the configurable body limit. +fn payload_too_large(limit: usize) -> OagwProblem { + OagwProblem::new( + type_ids::PAYLOAD_TOO_LARGE, + "Payload Too Large", + StatusCode::PAYLOAD_TOO_LARGE, + ) + .detail(format!("request body exceeds the {limit}-byte limit")) +} + +/// Join an error and its whole source chain into one message so classification +/// sees the root cause (hyper 1 wraps connect/io failures in a generic +/// `client error (Connect)` whose `source()` holds the real error). The full +/// chain is for gateway logging, NOT for the client: RFC 9457 details must not +/// leak long nested chains or raw OS error noise (Rf-015). +fn error_chain_string(e: &E) -> String { + let mut out = e.to_string(); + let mut next: Option<&(dyn std::error::Error + 'static)> = e.source(); + while let Some(s) = next { + out.push_str(": "); + out.push_str(&s.to_string()); + next = s.source(); + } + out +} + +/// The deepest cause of an error (or the error itself when it has no source); +/// a short, single-level description safe to surface in a problem detail. +fn root_cause_string(e: &E) -> String { + let mut deepest: &(dyn std::error::Error + 'static) = e; + while let Some(s) = deepest.source() { + deepest = s; + } + deepest.to_string() +} + +/// Map a hyper client send error to a gateway response (DESIGN error table). +/// `full` is the whole error chain used for classification; `brief` is the +/// single root cause surfaced in the RFC 9457 detail (no nested chains). +fn map_send_error(full: &str, brief: &str, connect_timeout: bool) -> Response { + let m = full.to_ascii_lowercase(); + let problem = if connect_timeout || m.contains("timed out") || m.contains("timeout") { + OagwProblem::new( + type_ids::CONNECTION_TIMEOUT, + "Connection Timeout", + StatusCode::GATEWAY_TIMEOUT, + ) + .detail(format!("upstream connection timed out: {brief}")) + } else if m.contains("refused") || m.contains("connection reset") { + OagwProblem::new( + type_ids::LINK_UNAVAILABLE, + "Link Unavailable", + StatusCode::SERVICE_UNAVAILABLE, + ) + .detail(format!("upstream link unavailable: {brief}")) + } else if m.contains("dns") || m.contains("resolve") || m.contains("cannot find") { + OagwProblem::new( + type_ids::DOWNSTREAM_ERROR, + "Downstream Error", + StatusCode::BAD_GATEWAY, + ) + .detail(format!("could not resolve upstream host: {brief}")) + } else if m.contains("protocol error") || m.contains("malformed") { + OagwProblem::new( + type_ids::PROTOCOL_ERROR, + "Protocol Error", + StatusCode::BAD_GATEWAY, + ) + .detail(format!("upstream protocol error: {brief}")) + } else if m.contains("connection closed") || m.contains("closed before message") { + OagwProblem::new( + type_ids::STREAM_ABORTED, + "Stream Aborted", + StatusCode::BAD_GATEWAY, + ) + .detail(format!("upstream stream aborted: {brief}")) + } else { + OagwProblem::new( + type_ids::DOWNSTREAM_ERROR, + "Downstream Error", + StatusCode::BAD_GATEWAY, + ) + .detail(format!("upstream request failed: {brief}")) + }; + gateway(problem) +} + +// --------------------------------------------------------------------------- +// Header helpers +// --------------------------------------------------------------------------- + +/// Headers the gateway manages and never forwards to the upstream: hop-by-hop +/// headers (HTTP spec) and the OAGW routing header (DESIGN "Routing Headers"). +fn is_protected_header(name: &HeaderName) -> bool { + HOP_BY_HOP.contains(name) || name.as_str().eq_ignore_ascii_case(TARGET_HOST_HEADER) +} + +/// A `Sec-WebSocket-*` handshake header (RFC 6455 §4.3, e.g. Key/Version/ +/// Protocol/Extension/Accept) relayed end-to-end on an upgrade, +/// case-insensitive on the name. +fn is_websocket_handshake_header(name: &HeaderName) -> bool { + name.as_str() + .get(..13) + .is_some_and(|p| p.eq_ignore_ascii_case("sec-websocket")) +} + +/// Collect `set`/`add` request-header rules into wire-ready header pairs +/// (skipping gateway-protected names and `Content-Length`, which the gateway +/// derives from the buffered body). +fn valid_rule_pairs( + rules: &std::collections::BTreeMap, +) -> Vec<(HeaderName, HeaderValue)> { + let mut pairs = Vec::new(); + for (name, value) in rules { + let (Ok(n), Ok(v)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(value), + ) else { + continue; + }; + if is_protected_header(&n) || n == CONTENT_LENGTH { + continue; + } + pairs.push((n, v)); + } + pairs +} + +/// Apply response-direction `set`/`add`/`remove` rules in place. +fn apply_header_ops_set_add_remove(headers: &mut HeaderMap, ops: &HeaderOps) { + for name in &ops.remove { + if let Ok(n) = HeaderName::from_bytes(name.as_bytes()) { + headers.remove(&n); + } + } + for (name, value) in &ops.set { + if let (Ok(n), Ok(v)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(value), + ) { + headers.insert(n, v); + } + } + for (name, value) in &ops.add { + if let (Ok(n), Ok(v)) = ( + HeaderName::from_bytes(name.as_bytes()), + HeaderValue::from_str(value), + ) { + headers.append(n, v); + } + } +} + +// --------------------------------------------------------------------------- +// URL helpers +// --------------------------------------------------------------------------- + +/// Ensure a route path has a leading '/'. +fn normalize_route_path(path: &str) -> String { + if path.starts_with('/') { + path.to_owned() + } else { + format!("/{path}") + } +} + +/// Parse the query string (the part after '?') from a suffix into decoded +/// `(name, value)` pairs. +fn extract_query_params(suffix: &str) -> Vec<(String, String)> { + let q = suffix.split_once('?').map_or("", |(_, q)| q); + form_urlencoded::parse(q.as_bytes()) + .map(|(k, v)| (k.into_owned(), v.into_owned())) + .collect() +} + +/// Validate an `X-OAGW-Target-Host` value: a hostname or IP, no port/path or +/// scheme decorations (DESIGN error table: `InvalidTargetHost`). +fn valid_target_host(t: &str) -> bool { + if t.is_empty() || t.len() > 253 { + return false; + } + // Scheme/port/userinfo/path/fragment/whitespace decorations are rejected + // before the IP check so an IPv6 literal ("2001:db8::1") — which contains + // ':' but is a perfectly valid target — is not misclassified. + if t.contains('/') + || t.contains('?') + || t.contains('#') + || t.contains('@') + || t.contains(' ') + { + return false; + } + if is_ip(t) { + return true; + } + // A bare ':' at this point is a port decoration on a hostname → invalid. + if t.contains(':') { + return false; + } + // `alias_is_valid_format` requires lower-case; hosts may arrive mixed-case. + alias_is_valid_format(&normalize_alias(t)) +} + +fn join_endpoint_hosts(endpoints: &[Endpoint]) -> String { + endpoints + .iter() + .map(|e| e.host.clone()) + .collect::>() + .join(",") +} + +/// The HTTP authority for an endpoint: `host:port`, with an IPv6 literal +/// bracketed (`[2001:db8::1]:8080`) — the same bracketing rule as RFC 3986 +/// §3.2.2. Shared by the outbound request-line URI authority and the outbound +/// `Host` header (Rr-003) so the two can never drift (a raw IPv6 host would +/// otherwise read as `2001:db8::1:8080`, ambiguous/malformed). +fn authority_for(host: &str, port: u16) -> String { + let host = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_owned() + }; + format!("{host}:{port}") +} + +/// Build the outbound request URI: an `http://authority` base (the host, +/// bracketed when an IPv6 literal, with the port — see [`authority_for`]), the +/// matched outbound path re-encoded segment-by-segment, and the joined query +/// string. +/// +/// The path is percent-encoded per segment and `./`/`..` dot segments are +/// collapsed per RFC 3986 §5.2.4, so a client-supplied `..` traversal can +/// never climb out of the matched route into a sibling path, and raw decoded +/// bytes (spaces, non-ASCII UTF-8, control bytes) are never copied verbatim +/// onto the wire. Control bytes are rejected with 400 before the URI is +/// parsed because the `url` crate would otherwise percent-encode them into a +/// well-formed-but-ambiguous target (Rf-004). +fn build_outbound_uri( + authority: &str, + path: &str, + query: &str, +) -> Result> { + if path + .as_bytes() + .iter() + .copied() + .find(|b| *b == 0 || *b < 0x20 || *b == 0x7f) + .is_some() + { + return Err(Box::new(gateway(OagwProblem::validation( + "request path contains an unsupported control byte", + )))); + } + + let base = format!("http://{authority}"); + let mut url = url::Url::parse(&base).map_err(|e| { + gateway(OagwProblem::downstream(format!( + "cannot build upstream authority '{authority}': {e}" + ))) + })?; + + // Split on '/' below the leading slash and resolve dot segments manually + // (PathSegmentsMut percent-encodes opaque values but ignores "."/".." + // instead of resolving them, so pop-semantics are applied here). + let mut segments: Vec<&str> = path + .trim_start_matches('/') + .split('/') + .filter(|s| !s.is_empty() && *s != ".") + .collect(); + let mut resolved: Vec<&str> = Vec::with_capacity(segments.len()); + for seg in segments.drain(..) { + if seg == ".." { + resolved.pop(); + } else { + resolved.push(seg); + } + } + + let mut segmented = url.path_segments_mut().map_err(|()| { + gateway(OagwProblem::downstream( + "cannot override the upstream URI path (cannot-be-a-base URL)", + )) + })?; + segmented.clear(); + for seg in resolved { + segmented.push(seg); + } + // Preserve the intent of a trailing '/'. + if path.ends_with('/') { + segmented.push(""); + } + drop(segmented); + + if !query.is_empty() { + url.set_query(Some(query)); + } + + url.as_str().parse::().map_err(|e| { + Box::new(gateway(OagwProblem::downstream(format!( + "cannot parse upstream URI: {e}" + )))) + }) +} + +// --------------------------------------------------------------------------- +// Body validation +// --------------------------------------------------------------------------- + +/// Validate the request body per DESIGN "Body Validation Rules" and buffer it: +/// +/// | Check | Rule | Error | +/// |---|---|---| +/// | Content-Length | Must be a valid integer if present; must match actual size | 400 | +/// | Max size | Hard limit 100 MiB; reject before buffering | 413 | +/// | Transfer-Encoding | Only `chunked` supported | 400 | +async fn validate_and_buffer( + headers: &HeaderMap, + body: Body, + limit: usize, +) -> Result { + // Transfer-Encoding: only chunked. + if let Some(te) = headers.get(TRANSFER_ENCODING) { + let te = te.to_str().unwrap_or("").to_ascii_lowercase(); + if te != "chunked" { + return Err(gateway(OagwProblem::validation(format!( + "unsupported Transfer-Encoding '{te}' (only 'chunked' is supported)" + )))); + } + } + + // Content-Length: must be a valid integer. + let declared: Option = match headers.get(CONTENT_LENGTH) { + None => None, + Some(v) => match v.to_str().ok().and_then(|s| s.trim().parse::().ok()) { + Some(n) => Some(n), + None => { + return Err(gateway(OagwProblem::validation( + "Content-Length must be a valid non-negative integer", + ))); + } + }, + }; + // Reject before buffering when the declared size already exceeds the limit. + if let Some(len) = declared + && len > limit as u64 + { + return Err(gateway(payload_too_large(limit))); + } + + // Buffer with the hard limit. + let bytes = match limited_body_bytes(body, limit).await { + Ok(b) => b, + Err(BodyReadError::OverLimit) => return Err(gateway(payload_too_large(limit))), + Err(BodyReadError::Read) => { + // A read/transport failure is not the client's fault: surface a + // gateway-sourced 502 rather than blaming the payload (DESIGN + // error table), distinct from an over-limit 413. + error!("failed reading request body from client (transport/read error)"); + return Err(gateway( + OagwProblem::downstream("failed to read the request body from the client"), + )); + } + }; + + // Content-Length must match the actual body size. + if let Some(len) = declared + && len != bytes.len() as u64 + { + return Err(gateway(OagwProblem::validation(format!( + "Content-Length {len} does not match actual body size {}", + bytes.len() + )))); + } + Ok(bytes) +} + +/// Why body buffering failed (DESIGN error table mapping by the caller). +enum BodyReadError { + /// The body exceeded the hard limit → 413. + OverLimit, + /// A read/transport failure occurred → 502 `DOWNSTREAM_ERROR`. + Read, +} + +/// Collect the body into memory, failing once more than `limit` bytes are +/// read (the body is buffered before forwarding per DESIGN). +async fn limited_body_bytes(mut body: Body, limit: usize) -> Result { + use futures_util::future::poll_fn; + use hyper::body::Body as _; + + let body_pin = Pin::new(&mut body); + let mut body = body_pin; + let mut buf: Vec = Vec::new(); + loop { + let polled = poll_fn(|cx| body.as_mut().poll_frame(cx)).await; + let Some(frame) = polled else { break }; + let frame = frame.map_err(|_| BodyReadError::Read)?; + if let Ok(data) = frame.into_data() { + if buf.len().saturating_add(data.len()) > limit { + return Err(BodyReadError::OverLimit); + } + buf.extend_from_slice(&data); + } + // Trailers are ignored for proxying. + } + Ok(Bytes::from(buf)) +} + +// --------------------------------------------------------------------------- +// Upgrade bridging +// --------------------------------------------------------------------------- + +/// Adapts `hyper::upgrade::Upgraded` (hyper's `rt::{Read, Write}` IO) onto +/// `tokio::io::{AsyncRead, AsyncWrite}` so it can be used with +/// `tokio::io::copy_bidirectional`. +struct HyperUpgradedIo { + inner: hyper::upgrade::Upgraded, +} + +impl HyperUpgradedIo { + fn new(inner: hyper::upgrade::Upgraded) -> Self { + Self { inner } + } +} + +impl tokio::io::AsyncRead for HyperUpgradedIo { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + tbuf: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + // Fill only up to the space tokio has left. + let (result, filled) = { + // `initialize_unfilled` (safe) views the remaining capacity; the + // hyper reader below fills it and reports how many bytes were + // written, bounding `set_filled`. + let unfilled = tbuf.initialize_unfilled(); + let mut hbuf = hyper::rt::ReadBuf::new(unfilled); + let r = hyper::rt::Read::poll_read(Pin::new(&mut self.inner), cx, hbuf.unfilled()); + (r, hbuf.filled().len()) + }; + match result { + Poll::Ready(Ok(())) => { + // `filled` bytes were written by the hyper reader. + tbuf.set_filled(filled); + Poll::Ready(Ok(())) + } + other => other, + } + } +} + +impl tokio::io::AsyncWrite for HyperUpgradedIo { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + hyper::rt::Write::poll_write(Pin::new(&mut self.inner), cx, buf) + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + hyper::rt::Write::poll_flush(Pin::new(&mut self.inner), cx) + } + + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + hyper::rt::Write::poll_shutdown(Pin::new(&mut self.inner), cx) + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + #[test] + fn route_path_normalization() { + assert_eq!(normalize_route_path("/v1/chat"), "/v1/chat"); + assert_eq!(normalize_route_path("v1/chat"), "/v1/chat"); + assert_eq!(normalize_route_path(""), "/"); + } + + #[test] + fn query_params_are_decoded_pairs() { + let params = extract_query_params("/v1/chat?model=gpt-4&x=a%20b"); + assert_eq!( + params, + vec![ + ("model".to_owned(), "gpt-4".to_owned()), + ("x".to_owned(), "a b".to_owned()), + ] + ); + assert!(extract_query_params("/plain").is_empty()); + } + + #[test] + fn target_host_validation() { + assert!(valid_target_host("api.example.com")); + assert!(valid_target_host("API.Example.COM")); + assert!(valid_target_host("10.0.0.5")); + // IPv6 literals are valid targets despite containing ':'. + assert!(valid_target_host("2001:db8::1")); + assert!(valid_target_host("::1")); + assert!(!valid_target_host("api.example.com:443")); + assert!(!valid_target_host("http://api.example.com")); + assert!(!valid_target_host("api.example.com/path")); + assert!(!valid_target_host("api.example.com?x=1")); + assert!(!valid_target_host("")); + assert!(!valid_target_host("a b")); + assert!(!valid_target_host("proto://host")); + } + + #[test] + fn authority_for_brackets_ipv6_literals_only() { + // IPv6 literals must be bracketed (Rr-003): a bare host would read as + // `2001:db8::1:8080`, ambiguous/malformed. + assert_eq!(authority_for("2001:db8::1", 8080), "[2001:db8::1]:8080"); + assert_eq!(authority_for("::1", 80), "[::1]:80"); + // Hostnames and IPv4 are untouched; already-bracketed hosts are not + // double-bracketed. + assert_eq!(authority_for("example.com", 443), "example.com:443"); + assert_eq!(authority_for("10.0.0.5", 80), "10.0.0.5:80"); + assert_eq!(authority_for("[::1]", 8080), "[::1]:8080"); + } + + #[test] + fn build_outbound_uri_normalizes_dot_segments_and_encodes() { + // Traversal is collapsed: no literal `..` escapes the matched path. + let uri = build_outbound_uri("api.example.com:8080", "/v1/../..", "") + .unwrap() + .to_string(); + assert_eq!(uri, "http://api.example.com:8080/"); + // Encoded traversal (`%2e%2e`) stays an opaque re-encoded segment — + // never decoded into a literal `..` and never a verbatim copy. + let uri = build_outbound_uri("up:8080", "/a/%2e%2e/b", "") + .unwrap() + .to_string(); + assert_eq!(uri, "http://up:8080/a/%252e%252e/b"); + // Raw decoded bytes (space) are re-encoded, never copied verbatim. + let uri = build_outbound_uri("up:8080", "/a b/c", "") + .unwrap() + .to_string(); + assert_eq!(uri, "http://up:8080/a%20b/c"); + // Unicode is percent-encoded. + let uri = build_outbound_uri("up:8080", "/h\u{e9}llo", "") + .unwrap() + .to_string(); + assert_eq!(uri, "http://up:8080/h%C3%A9llo"); + // A bracketed IPv6 authority with port round-trips through the URI. + let uri = build_outbound_uri("[2001:db8::1]:8080", "/v1", "") + .unwrap() + .to_string(); + assert_eq!(uri, "http://[2001:db8::1]:8080/v1"); + // Query passthrough is joined. + let uri = build_outbound_uri("up:8080", "/v1", "a=1&b=2") + .unwrap() + .to_string(); + assert_eq!(uri, "http://up:8080/v1?a=1&b=2"); + // Trailing slash intent is preserved. + let uri = build_outbound_uri("up:8080", "/v1/", "") + .unwrap() + .to_string(); + assert_eq!(uri, "http://up:8080/v1/"); + // Control bytes are rejected with a 400 validation. + let err = build_outbound_uri("up:8080", "/a\u{0}b", "").unwrap_err(); + assert_eq!(err.status(), StatusCode::BAD_REQUEST); + } + + #[test] + fn protected_headers_exclude_routing_and_hop_by_hop() { + assert!(is_protected_header(&HeaderName::from_static("host"))); + assert!(is_protected_header(&HeaderName::from_static("connection"))); + assert!(is_protected_header(&HeaderName::from_static( + "x-oagw-target-host" + ))); + assert!(!is_protected_header(&HeaderName::from_static( + "content-type" + ))); + assert!(!is_protected_header(&HeaderName::from_static( + "authorization" + ))); + } + + #[test] + fn map_send_error_classifies_by_message() { + let refused = map_send_error("tcp connect error: connection refused", "connection refused", false); + assert_eq!(refused.status(), StatusCode::SERVICE_UNAVAILABLE); + let timeout = map_send_error("connection timed out", "connection timed out", true); + assert_eq!(timeout.status(), StatusCode::GATEWAY_TIMEOUT); + let dns = map_send_error("dns error: no such host", "no such host", false); + assert_eq!(dns.status(), StatusCode::BAD_GATEWAY); + let other = map_send_error("something else", "something else", false); + assert_eq!(other.status(), StatusCode::BAD_GATEWAY); + } + + #[tokio::test] + async fn body_validation_rejects_oversize_and_bad_content_length() { + let limit = 10usize; + // Declared length beyond the limit → 413 before buffering. + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_LENGTH, HeaderValue::from_static("999")); + let resp = validate_and_buffer(&headers, Body::empty(), limit) + .await + .unwrap_err(); + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); + + // Declared length not matching the actual body → 400. + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_LENGTH, HeaderValue::from_static("5")); + let resp = validate_and_buffer(&headers, Body::from("hello!"), limit) + .await + .unwrap_err(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // Actual body beyond the limit → 413. + let resp = validate_and_buffer(&HeaderMap::new(), Body::from("0123456789X"), limit) + .await + .unwrap_err(); + assert_eq!(resp.status(), StatusCode::PAYLOAD_TOO_LARGE); + + // Unsupported transfer-encoding → 400. + let mut headers = HeaderMap::new(); + headers.insert(TRANSFER_ENCODING, HeaderValue::from_static("gzip")); + let resp = validate_and_buffer(&headers, Body::empty(), limit) + .await + .unwrap_err(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); + + // Valid request under the limit buffers fine. + let mut headers = HeaderMap::new(); + headers.insert(CONTENT_LENGTH, HeaderValue::from_static("3")); + let bytes = validate_and_buffer(&headers, Body::from("abc"), limit) + .await + .unwrap(); + assert_eq!(bytes, Bytes::from_static(b"abc")); + } +} diff --git a/gears/system/oagw/oagw/src/infra/ratelimit.rs b/gears/system/oagw/oagw/src/infra/ratelimit.rs new file mode 100644 index 0000000..0c9e2fe --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/ratelimit.rs @@ -0,0 +1,707 @@ +//! In-memory token-bucket rate limiting (ADR 0003, ADR 0006). +//! +//! The data plane owns per-instance token buckets keyed per the ADR 0003 key +//! shape `oagw:ratelimit:{resource_type}:{resource_id}:{scope}:{scope_id}:{window}` +//! so a common prefix enables prefix-based cleanup when an upstream is +//! deleted. Dual-rate configuration (sustained rate + burst capacity) and the +//! effective-limit merge (`min(ancestor, descendant)` — stricter always wins, +//! DESIGN "Hierarchical Configuration") are honored. +//! +//! # DESIGN-led deviations +//! +//! - The MVP is local-only (`strategy: queue|degrade` are honored as `reject`; +//! the models document this). Distributed coordination (Hybrid Local + +//! Periodic Sync into Redis/Valkey) is out of scope. +//! - Buckets are continuous (refilled lazily on access) rather than +//! fixed-window counters, so the ADR's fixed-window `YYYYMMDDHHMM` bucket id +//! is dropped and `{window}` is the window granularity (`second`/`minute`/ +//! `hour`/`day`). Stale buckets are swept amortized (once per `SWEEP_EVERY` +//! acquires) so the table stays bounded without a background task. +//! - Hierarchical budget allocation (`budget.mode: allocated|shared`) and +//! ancestor-enforced sharing beyond the upstream→route `min` merge are out +//! of scope for the MVP. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::{Duration, Instant}; + +use dashmap::DashMap; +use http::HeaderMap; +use http::header::HeaderName; +use toolkit_security::SecurityContext; +use tracing::debug; +use uuid::Uuid; + +/// `X-Forwarded-For` (not shipped by `http` ≥ 1.0; defined locally for the +/// IP counter-scope's source-IP heuristic). +const X_FORWARDED_FOR: HeaderName = HeaderName::from_static("x-forwarded-for"); + +use crate::domain::models::{RateLimitConfig, RateLimitScope, RateWindow, Route, Upstream}; + +/// Sweep the bucket table once every N acquires (amortized cleanup; bounded +/// memory without a background task). +const SWEEP_EVERY: u64 = 256; +/// Buckets idle longer than this are dropped by the amortized sweep. +const MAX_IDLE: Duration = Duration::from_mins(5); + +/// Effective rate-limit plan for one proxied request (merged upstream+route). +#[derive(Debug, Clone)] +pub struct RateLimitPlan { + /// Full bucket-map key (`oagw:ratelimit:...`). + key: String, + /// Tokens replenished per second. + tps: f64, + /// Burst capacity (max bucket size). + capacity: f64, + /// Tokens consumed per request (`cost`). + cost: u64, + /// Sustained rate (tokens per window) for `X-RateLimit-Limit`. + limit: u64, +} + +/// Outcome of a rate check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct RateLimitDecision { + /// Whether the request consumed tokens. + pub allowed: bool, + /// Effective limit (tokens per window) — `X-RateLimit-Limit`. + pub limit: u64, + /// Tokens left in the bucket (floored) — `X-RateLimit-Remaining`. + pub remaining: u64, + /// Seconds until the bucket refills to capacity — `Retry-After` and + /// `X-RateLimit-Reset` (epoch = now + this). + pub reset_after_secs: u64, +} + +/// DP-owned per-instance rate-limiter state (ADR 0006). Not `Clone`; share via +/// `Arc` so every proxy handler sees the same buckets. +#[derive(Debug, Default)] +pub struct RateLimiter { + buckets: DashMap, + acquire_count: AtomicU64, +} + +/// A lazily-refilled token bucket (ADR 0003 "Implementation Notes"). +#[derive(Debug)] +struct TokenBucket { + tokens: f64, + capacity: f64, + tps: f64, + last_update: Instant, + last_seen: Instant, +} + +impl TokenBucket { + fn new(capacity: f64, tps: f64) -> Self { + let now = Instant::now(); + Self { + tokens: capacity, + capacity, + tps, + last_update: now, + last_seen: now, + } + } + + /// Refill tokens proportionally to elapsed time, capped at capacity. + fn refill(&mut self) { + let now = Instant::now(); + let elapsed = now.duration_since(self.last_update).as_secs_f64(); + self.tokens = (self.tokens + elapsed * self.tps).min(self.capacity); + self.last_update = now; + } + + /// Try to take `cost` tokens. + fn try_acquire(&mut self, cost: f64) -> bool { + self.refill(); + if self.tokens >= cost { + self.tokens -= cost; + true + } else { + false + } + } +} + +impl RateLimiter { + /// Create an empty rate limiter. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Derive the effective rate-limit plan for one request from the upstream + /// (ancestor) and matched route (descendant) configs, resolving the + /// counter scope to a concrete scope id. + /// + /// Returns `None` when neither resource configures a rate limit (fail-open). + #[must_use] + pub fn plan_for( + upstream: &Upstream, + route: &Route, + security_ctx: Option<&SecurityContext>, + inbound_headers: &HeaderMap, + ) -> Option { + let effective = effective_config(upstream, route)?; + let scope_id = resolve_scope_id(effective.scope, route, security_ctx, inbound_headers); + let window_label = window_label(effective.window); + let key = format!( + "oagw:ratelimit:upstream:{}:{}:{}:{}", + upstream.id, + effective.scope_label(), + scope_id, + window_label + ); + Some(RateLimitPlan { + key, + tps: effective.tps, + capacity: effective.capacity, + cost: effective.cost, + limit: effective.limit, + }) + } + + /// Attempt to consume `cost` tokens from the plan's bucket. + #[must_use] + #[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "token-bucket accounting is f64 internally (ADR 0003); small u64 token counts are exactly representable in f64 and the 'as u64' truncations floor them intentionally" + )] + pub fn try_acquire(&self, plan: &RateLimitPlan) -> RateLimitDecision { + // Amortized stale-bucket sweep. + let count = self.acquire_count.fetch_add(1, Ordering::Relaxed); + if count.is_multiple_of(SWEEP_EVERY) { + self.sweep_stale(MAX_IDLE); + } + + let mut bucket = self + .buckets + .entry(plan.key.clone()) + .or_insert_with(|| TokenBucket::new(plan.capacity, plan.tps)); + // Detect config drift: when the plan (tps/capacity) changed since the + // bucket was created, the `or_insert_with` snapshot is stale — reset to + // the current plan so a rate-limit reconfiguration takes effect + // immediately (and a deleted+recreated aliased upstream never inherits + // a predecessor's counters). + if !f64_approx_eq(bucket.tps, plan.tps) || !f64_approx_eq(bucket.capacity, plan.capacity) { + *bucket = TokenBucket::new(plan.capacity, plan.tps); + } + bucket.last_seen = Instant::now(); + + if bucket.try_acquire(plan.cost as f64) { + RateLimitDecision { + allowed: true, + limit: plan.limit, + remaining: bucket.tokens.floor() as u64, + reset_after_secs: 0, + } + } else { + // Seconds until the bucket refills to capacity (how long the + // caller should back off before retrying the full budget). + // Defensive math: a non-positive/non-finite plan `tps` would make + // the division diverge — treat such a bucket as never refilling. + if !plan.tps.is_finite() || plan.tps <= 0.0 { + return RateLimitDecision { + allowed: false, + limit: plan.limit, + remaining: bucket.tokens.floor() as u64, + reset_after_secs: u64::MAX, + }; + } + let refill_gap = (plan.capacity - bucket.tokens).max(0.0); + let reset = ((refill_gap / plan.tps).ceil().max(1.0).min(u64::MAX as f64)) as u64; + RateLimitDecision { + allowed: false, + limit: plan.limit, + remaining: bucket.tokens.floor() as u64, + reset_after_secs: reset, + } + } + } + + /// Drop buckets idle longer than `max_idle`. + pub fn sweep_stale(&self, max_idle: Duration) { + self.buckets + .retain(|_, b| b.last_seen.elapsed() <= max_idle); + } + + /// Drop every bucket (used by tests; also a reset surface on config wipe). + pub fn clear(&self) { + self.buckets.clear(); + } + + /// Number of live buckets (observability/tests). + #[must_use] + pub fn len(&self) -> usize { + self.buckets.len() + } + + /// Whether the limiter has no buckets (infallible). + #[must_use] + pub fn is_empty(&self) -> bool { + self.buckets.is_empty() + } + + /// Drop all buckets for one upstream (prefix-based cleanup, ADR 0003) so a + /// deleted/reconfigured upstream's counters never leak into its successor. + pub fn clear_for_upstream(&self, upstream_id: Uuid) { + let prefix = format!("oagw:ratelimit:upstream:{upstream_id}:"); + self.buckets.retain(|key, _| !key.starts_with(&prefix)); + } +} + +/// Approximate float equality for token-bucket params (small non-negative +/// rates/capacities, so an absolute epsilon is appropriate). +fn f64_approx_eq(a: f64, b: f64) -> bool { + (a - b).abs() < 1e-9 +} + +/// The merged effective rate configuration. +struct EffectiveConfig { + /// Tokens per second (min over configured sustained rates). + tps: f64, + /// Capacity (min over configured burst capacities). + capacity: f64, + /// Integer rate of the primary config for `X-RateLimit-Limit`. + limit: u64, + /// Window granularity of the primary config. + window: RateWindow, + /// Cost per request (route overrides upstream). + cost: u64, + /// Counter scope (route overrides upstream). + scope: RateLimitScope, +} + +impl EffectiveConfig { + fn scope_label(&self) -> &'static str { + match self.scope { + RateLimitScope::Global => "global", + RateLimitScope::Tenant => "tenant", + RateLimitScope::User => "user", + RateLimitScope::Ip => "ip", + RateLimitScope::Route => "route", + } + } +} + +/// Merge `upstream.rate_limit` (ancestor) and `route.rate_limit` +/// (descendant): stricter always wins for the limit; the descendant overrides +/// `cost` and `scope`. `None` when neither configures a limit. +fn effective_config(upstream: &Upstream, route: &Route) -> Option { + let u = upstream.rate_limit.as_ref(); + let r = route.rate_limit.as_ref(); + if u.is_none() && r.is_none() { + return None; + } + + #[allow( + clippy::cast_precision_loss, + reason = "sustained rates are small u64 counters; f64 division is the intended tokens-per-second semantics" + )] + let tps = |c: &RateLimitConfig| c.sustained.rate as f64 / c.sustained.window.seconds() as f64; + #[allow( + clippy::cast_precision_loss, + reason = "burst capacities are small u64 counters exactly representable in f64" + )] + let cap = |c: &RateLimitConfig| { + c.burst + .as_ref() + .map_or(c.sustained.rate as f64, |b| b.capacity as f64) + }; + + let mut tps_min = f64::INFINITY; + let mut cap_min = f64::INFINITY; + for c in [u, r].into_iter().flatten() { + tps_min = tps_min.min(tps(c)); + cap_min = cap_min.min(cap(c)); + } + + // Primary (for window/label): the stricter config; the descendant wins a + // tie so its window is the basis of `X-RateLimit-Limit`/`Reset`. + let primary = match (u, r) { + (Some(u), Some(r)) if tps(r) <= tps(u) => r, + (Some(u), _) => u, + (None, Some(r)) => r, + (None, None) => unreachable!("checked above"), + }; + + // `cost`/`scope`: the descendant (route) wins when it configures a rate + // limit; otherwise the ancestor's (upstream) values apply — never dropped + // to defaults because the route itself has no limit block. + let merged_cost = r.as_ref().or(u.as_ref()).map_or(1, |c| c.cost); + // Clamp the merged cost to the effective (ancestor-min'd) burst capacity + // so a cross-resource pair (e.g. upstream `10/10/1` + route `100/20/cost + // 15`) can never yield a plan that is permanently unsatisfiable — with + // `cost > capacity` the very first request would 429 forever. When the + // effective cost exceeds the effective capacity, the request consumes the + // whole bucket, which is the existing per-bucket semantics for + // cost == capacity (belt-and-braces for pairs that per-config validation + // in validation.rs — which validates each config alone — cannot see). + #[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "small non-negative token counters: the effective capacity is an exact-integer f64 (derived from u64 burst/sustained), and the truncating cast only ever lowers the cost toward that capacity so the merged plan stays satisfiable" + )] + let cost = (merged_cost as f64).min(cap_min.max(1.0)) as u64; + if cost < merged_cost { + debug!( + upstream_id = %upstream.id, + route_id = %route.id, + merged_cost, + effective_capacity = cap_min, + clamped_cost = cost, + "merged rate-limit cost exceeds the effective burst capacity; clamped so the plan stays satisfiable" + ); + } + Some(EffectiveConfig { + tps: tps_min, + capacity: cap_min, + limit: primary.sustained.rate, + window: primary.sustained.window, + cost, + scope: r + .as_ref() + .or(u.as_ref()) + .map(|c| c.scope) + .unwrap_or_default(), + }) +} + +fn window_label(window: RateWindow) -> &'static str { + match window { + RateWindow::Second => "second", + RateWindow::Minute => "minute", + RateWindow::Hour => "hour", + RateWindow::Day => "day", + } +} + +/// Resolve a configured counter scope to a concrete scope id. +/// +/// - `tenant`/`user` come from the security context ("anonymous" when the +/// proxy runs without one); +/// - `ip` uses the first `X-Forwarded-For` hop ("0.0.0.0" when absent); +/// - `global`/`route` need no caller identity. +fn resolve_scope_id( + scope: RateLimitScope, + route: &Route, + security_ctx: Option<&SecurityContext>, + inbound_headers: &HeaderMap, +) -> String { + match scope { + RateLimitScope::Global => "global".to_owned(), + RateLimitScope::Route => route.id.to_string(), + RateLimitScope::Tenant => security_ctx.map_or_else( + || "anonymous".to_owned(), + |c| c.subject_tenant_id().to_string(), + ), + RateLimitScope::User => { + security_ctx.map_or_else(|| "anonymous".to_owned(), |c| c.subject_id().to_string()) + } + RateLimitScope::Ip => inbound_headers + .get(X_FORWARDED_FOR) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + .map(str::trim) + .filter(|s| !s.is_empty()) + .unwrap_or("0.0.0.0") + .to_owned(), + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use crate::domain::models::{ + BurstConfig, Endpoint, HeadersConfig, MatchRule, PROTOCOL_HTTP_V1, PluginsConfig, + RateLimitAlgorithm, RateLimitStrategy, Scheme, ServerConfig, SharingMode, SustainedRate, + }; + use http::HeaderValue; + + fn upstream_with(rate: Option) -> Upstream { + Upstream { + id: Uuid::new_v4(), + enabled: true, + alias: "api".to_owned(), + tags: Vec::new(), + server: ServerConfig { + endpoints: vec![Endpoint { + scheme: Scheme::Https, + host: "api.example.com".to_owned(), + port: 443, + }], + }, + protocol: PROTOCOL_HTTP_V1.to_owned(), + auth: None, + headers: HeadersConfig::default(), + plugins: PluginsConfig::default(), + rate_limit: rate, + cors: None, + } + } + + fn route_for(upstream_id: Uuid, rate: Option) -> Route { + Route { + id: Uuid::new_v4(), + enabled: true, + tags: Vec::new(), + upstream_id, + r#match: Some(MatchRule { + http: None, + grpc: None, + }), + plugins: PluginsConfig::default(), + rate_limit: rate, + cors: None, + } + } + + fn ctx(tenant: u128) -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::from_u128(1)) + .subject_tenant_id(Uuid::from_u128(tenant)) + .build() + .unwrap() + } + + fn second(rate: u64) -> RateLimitConfig { + RateLimitConfig { + sharing: SharingMode::Private, + algorithm: RateLimitAlgorithm::default(), + sustained: SustainedRate { + rate, + window: RateWindow::Second, + }, + burst: None, + scope: RateLimitScope::Tenant, + strategy: RateLimitStrategy::default(), + cost: 1, + } + } + + #[test] + fn effective_config_is_none_without_limits() { + let u = upstream_with(None); + let r = route_for(u.id, None); + assert!(effective_config(&u, &r).is_none()); + } + + #[test] + fn effective_config_takes_the_stricter_limit() { + // Upstream: 100/s; route: 10/s → effective 10/s. + let u = upstream_with(Some(second(100))); + let r = route_for(u.id, Some(second(10))); + let eff = effective_config(&u, &r).expect("configured"); + assert!((eff.tps - 10.0).abs() < f64::EPSILON); + assert_eq!(eff.limit, 10); + // Reverse: route less strict than upstream → upstream wins. + let u = upstream_with(Some(second(10))); + let r = route_for(u.id, Some(second(100))); + let eff = effective_config(&u, &r).expect("configured"); + assert!((eff.tps - 10.0).abs() < f64::EPSILON); + // Route cost + scope override the ancestor's. + let u = upstream_with(Some(second(10))); + let mut r = route_for(u.id, Some(second(100))); + r.rate_limit = Some(RateLimitConfig { + scope: RateLimitScope::User, + cost: 5, + ..second(100) + }); + let eff = effective_config(&u, &r).expect("configured"); + assert_eq!(eff.cost, 5); + assert_eq!(eff.scope, RateLimitScope::User); + } + + #[test] + fn burst_capacity_min_wins_and_defaults_to_sustained() { + let u = upstream_with(Some(RateLimitConfig { + burst: Some(BurstConfig { capacity: 50 }), + ..second(100) + })); + let r = route_for(u.id, Some(second(10))); // no burst → default 10 + let eff = effective_config(&u, &r).expect("configured"); + assert!((eff.capacity - 10.0).abs() < f64::EPSILON); + } + + #[test] + fn merged_cost_above_effective_capacity_is_clamped_and_first_request_allowed() { + // Upstream: 10/s with burst 10, cost 1; route: 100/s with burst 20 but + // cost 15. Effective plan: tps 10, capacity 10, cost 15 — without the + // clamp the first request (cost 15 > 10 tokens) would 429 forever, a + // permanently unsatisfiable merged plan (e.g. upstream `10/10/1` + + // route `100/20/cost 15`). The clamp drops cost to the capacity (10), + // and the first request consumes the whole bucket and is allowed. + let u = upstream_with(Some(RateLimitConfig { + burst: Some(BurstConfig { capacity: 10 }), + ..second(10) + })); + let r = route_for( + u.id, + Some(RateLimitConfig { + burst: Some(BurstConfig { capacity: 20 }), + cost: 15, + ..second(100) + }), + ); + let eff = effective_config(&u, &r).expect("configured"); + assert!((eff.capacity - 10.0).abs() < f64::EPSILON); + assert_eq!(eff.cost, 10, "merged cost must be clamped to the capacity"); + + let limiter = RateLimiter::new(); + let plan = RateLimiter::plan_for(&u, &r, Some(&ctx(1)), &HeaderMap::new()).unwrap(); + assert!( + limiter.try_acquire(&plan).allowed, + "first request must be allowed, not an immediate 429" + ); + // The bucket is now drained; a second immediate request is rejected + // (the clamped plan is satisfiable recharge-wise, not a lie). + assert!(!limiter.try_acquire(&plan).allowed); + } + + #[test] + fn scope_id_resolution() { + let hdrs = HeaderMap::new(); + let ctx = ctx(42); + // Tenant scope uses the security context. + assert_eq!( + resolve_scope_id( + RateLimitScope::Tenant, + &route_for(Uuid::new_v4(), None), + Some(&ctx), + &hdrs + ), + ctx.subject_tenant_id().to_string() + ); + // Without a context → anonymous. + assert_eq!( + resolve_scope_id( + RateLimitScope::Tenant, + &route_for(Uuid::new_v4(), None), + None, + &hdrs + ), + "anonymous" + ); + // IP scope reads the first X-Forwarded-For hop. + let mut fwd = HeaderMap::new(); + fwd.insert( + X_FORWARDED_FOR, + HeaderValue::from_static("10.0.0.1, 10.0.0.2"), + ); + assert_eq!( + resolve_scope_id( + RateLimitScope::Ip, + &route_for(Uuid::new_v4(), None), + None, + &fwd + ), + "10.0.0.1" + ); + // Global is fixed. + assert_eq!( + resolve_scope_id( + RateLimitScope::Global, + &route_for(Uuid::new_v4(), None), + None, + &hdrs + ), + "global" + ); + } + + #[test] + fn token_bucket_case_uses_flow_new_rates_and_rejects_burst() { + // 3 tokens/s capacity 3 → 3 fast requests pass, the 4th is rejected. + let u = upstream_with(Some(second(3))); + let r = route_for(u.id, None); + let limiter = RateLimiter::new(); + let plan = RateLimiter::plan_for(&u, &r, Some(&ctx(1)), &HeaderMap::new()).unwrap(); + assert!((plan.capacity - 3.0).abs() < f64::EPSILON); + for _ in 0..3 { + let d = limiter.try_acquire(&plan); + assert!(d.allowed, "burst allowance"); + } + let rejected = limiter.try_acquire(&plan); + assert!(!rejected.allowed); + assert_eq!(rejected.remaining, 0); + assert_eq!(rejected.limit, 3); + assert!(rejected.reset_after_secs >= 1); + } + + #[tokio::test] + async fn token_bucket_refills_with_time() { + let u = upstream_with(Some(second(10))); // 10 tokens/s, capacity 10 + let r = route_for(u.id, None); + let limiter = RateLimiter::new(); + let plan = RateLimiter::plan_for(&u, &r, Some(&ctx(1)), &HeaderMap::new()).unwrap(); + assert!(limiter.try_acquire(&plan).allowed); // 1 token used + assert_eq!(limiter.try_acquire(&plan).remaining, 8); // 10-1-1 + tokio::time::sleep(Duration::from_millis(1200)).await; + // ~12 tokens elapsed → refilled back to capacity 10. + let d = limiter.try_acquire(&plan); + assert!(d.allowed); + assert_eq!(d.remaining, 9); + } + + #[test] + fn tenant_scoping_keys_buckets_apart() { + let u = upstream_with(Some(second(1))); + let r = route_for(u.id, None); + let limiter = RateLimiter::new(); + let plan_a = RateLimiter::plan_for(&u, &r, Some(&ctx(1)), &HeaderMap::new()).unwrap(); + let plan_b = RateLimiter::plan_for(&u, &r, Some(&ctx(2)), &HeaderMap::new()).unwrap(); + assert_ne!(plan_a.key, plan_b.key); + assert!(limiter.try_acquire(&plan_a).allowed); // drains tenant 1 + assert!(!limiter.try_acquire(&plan_a).allowed); + assert!(limiter.try_acquire(&plan_b).allowed); // tenant 2 unaffected + } + + #[test] + fn reconfigured_plan_resets_the_bucket_immediately() { + // Drain a 3/s bucket fully, then reconfigure the SAME upstream to + // 5/s: the next acquire must observe the new plan (bucket reset), not + // the frozen 3-token bucket created by `or_insert_with`. + let u = upstream_with(Some(second(3))); + let r = route_for(u.id, None); + let limiter = RateLimiter::new(); + let plan = RateLimiter::plan_for(&u, &r, Some(&ctx(1)), &HeaderMap::new()).unwrap(); + for _ in 0..3 { + assert!(limiter.try_acquire(&plan).allowed); + } + assert!(!limiter.try_acquire(&plan).allowed); + + let mut reconfigured = u; + reconfigured.rate_limit = Some(second(5)); + let plan2 = RateLimiter::plan_for(&reconfigured, &r, Some(&ctx(1)), &HeaderMap::new()) + .unwrap(); + assert_eq!(plan.key, plan2.key, "same key, drift must reset the bucket"); + for _ in 0..5 { + assert!( + limiter.try_acquire(&plan2).allowed, + "drift-reset burst against the new capacity" + ); + } + assert!(!limiter.try_acquire(&plan2).allowed); + } + + #[test] + fn clear_for_upstream_removes_only_that_prefix() { + let u1 = upstream_with(Some(second(1))); + let u2 = upstream_with(Some(second(1))); + let r1 = route_for(u1.id, None); + let r2 = route_for(u2.id, None); + let limiter = RateLimiter::new(); + let _decision = limiter.try_acquire( + &RateLimiter::plan_for(&u1, &r1, Some(&ctx(1)), &HeaderMap::new()).unwrap(), + ); + let _decision = limiter.try_acquire( + &RateLimiter::plan_for(&u2, &r2, Some(&ctx(1)), &HeaderMap::new()).unwrap(), + ); + assert_eq!(limiter.len(), 2); + limiter.clear_for_upstream(u1.id); + assert_eq!(limiter.len(), 1); + } +} 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..562e6e7 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/storage.rs @@ -0,0 +1,61 @@ +//! Tenant-scoped in-memory stores for the OAGW control plane. +//! +//! # DESIGN-led deviation +//! +//! The DESIGN calls for `SeaORM` + `toolkit-db` persistence (`oagw_upstream`, +//! `oagw_route`, `oagw_plugin` tables). The crate manifest carries no `SeaORM` / +//! `toolkit-db` dependency, so — per the documented deviations — the control +//! plane is implemented on an in-memory store with the *same* semantics: +//! tenant-scoped CRUD, unique `(tenant_id, alias)`, immutable alias after +//! creation, immutable route `upstream_id`, delete-in-use → 409 for plugins, +//! and upstream deletion cascades to the routes bound to it. + +use crate::domain::models::{PluginRecord, Route, Upstream}; +use dashmap::{DashMap, mapref::one::RefMut}; +use uuid::Uuid; + +/// The full in-memory control-plane store. +#[derive(Debug, Default)] +pub struct OagwStore { + /// `tenant_id -> (upstream_id -> Upstream)`. + upstreams: DashMap>, + /// `tenant_id -> (route_id -> Route)`. + routes: DashMap>, + /// `tenant_id -> (plugin_id -> PluginRecord)`. + plugins: DashMap>, + /// `tenant_id -> (alias -> upstream_id)` (uniqueness index). + aliases: DashMap>, +} + +impl OagwStore { + /// Create an empty store. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Mutable tenant-scoped upstream table (created on demand). Reads through + /// the returned handle are fine; the entry is always materialized. + #[must_use] + pub fn upstreams(&self, tenant_id: Uuid) -> RefMut<'_, Uuid, DashMap> { + self.upstreams.entry(tenant_id).or_default() + } + + /// Mutable tenant-scoped route table (created on demand). + #[must_use] + pub fn routes(&self, tenant_id: Uuid) -> RefMut<'_, Uuid, DashMap> { + self.routes.entry(tenant_id).or_default() + } + + /// Mutable tenant-scoped plugin table (created on demand). + #[must_use] + pub fn plugins(&self, tenant_id: Uuid) -> RefMut<'_, Uuid, DashMap> { + self.plugins.entry(tenant_id).or_default() + } + + /// Mutable tenant-scoped alias index (created on demand). + #[must_use] + pub fn aliases(&self, tenant_id: Uuid) -> RefMut<'_, Uuid, DashMap> { + self.aliases.entry(tenant_id).or_default() + } +} diff --git a/gears/system/oagw/oagw/src/lib.rs b/gears/system/oagw/oagw/src/lib.rs index e69de29..2752edb 100644 --- a/gears/system/oagw/oagw/src/lib.rs +++ b/gears/system/oagw/oagw/src/lib.rs @@ -0,0 +1,22 @@ +//! OAGW — Outbound API Gateway gear. +//! +//! Manages upstreams, routes, and plugins (control plane) and proxies +//! outbound traffic to configured upstreams (data plane). + +/// REST layer (control-plane handlers; data-plane proxy handler lives in `infra::proxy`). +pub mod api; +/// Gear configuration. +pub mod config; +/// Domain model + control-plane service. +pub mod domain; +/// Gear declaration / registration with the ToolKit host. +pub mod gear; +/// Infrastructure: in-memory stores, proxy engine, plugin registry. +pub mod infra; + +pub use crate::config::{OagwConfig, SsrfPolicy}; +pub use crate::domain::service::ControlPlaneService; +pub use crate::gear::OagwGear; + +/// `ToolKit` re-export used pervasively by OAGW code. +pub use toolkit; diff --git a/gears/system/oagw/oagw/tests/proxy.rs b/gears/system/oagw/oagw/tests/proxy.rs new file mode 100644 index 0000000..a760f66 --- /dev/null +++ b/gears/system/oagw/oagw/tests/proxy.rs @@ -0,0 +1,2880 @@ +//! Data-plane proxy integration tests (DESIGN slice 4). +//! +//! Exercises `proxy_request` end-to-end against httpmock upstreams: +//! HTTP forwarding (method/path/query, header transforms, passthrough), +//! the `X-OAGW-Target-Host` routing matrix, the DESIGN body-validation and +//! error tables (400/404/413/502/503), SSE passthrough, and a real +//! WebSocket upgrade bridged through an `axum::serve`-hosted router to a +//! raw-TCP echo upstream. + +#![allow(clippy::unwrap_used)] + +use std::collections::BTreeMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use axum::Router; +use axum::body::Body; +use credstore_sdk::test_util::MockCredStoreClient; +use http::{HeaderMap, HeaderValue, Method, Request, StatusCode}; +use http_body_util::BodyExt; +use httpmock::prelude::*; +use oagw::api::rest::routes::register_routes; +use oagw::config::{OagwConfig, SsrfPolicy}; +use oagw::domain::models::{ + AuthConfig, BurstConfig, Endpoint, HeaderOps, HeadersConfig, HttpMatch, MatchRule, + PROTOCOL_HTTP_V1, PassthroughMode, PathSuffixMode, PluginsConfig, RateLimitAlgorithm, + RateLimitConfig, RateLimitScope, RateLimitStrategy, RateWindow, RequestHeadersConfig, Route, + Scheme, ServerConfig, SharingMode, SustainedRate, Upstream, +}; +use oagw::domain::plugin::{ + API_KEY_AUTH_PLUGIN_ID, BASIC_AUTH_PLUGIN_ID, BEARER_AUTH_PLUGIN_ID, NOOP_AUTH_PLUGIN_ID, + OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID, OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID, + REQUIRED_HEADERS_GUARD_PLUGIN_ID, +}; +use oagw::domain::service::ControlPlaneService; +use oagw::infra::plugin::{AuthPluginRegistry, TokenCacheConfig}; +use oagw::infra::proxy::proxy_request; +use oagw::infra::ratelimit::RateLimiter; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use toolkit::api::OpenApiRegistry; +use toolkit::api::operation_builder::OperationSpec; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +const TENANT: u128 = 1; + +fn tenant() -> Uuid { + Uuid::from_u128(TENANT) +} + +/// A permissive proxy config for tests: plain HTTP relay on, SSRF off, 1 MiB +/// body limit. +fn test_config() -> OagwConfig { + OagwConfig { + allow_http_upstream: true, + ssrf_policy: SsrfPolicy { + enabled: false, + allowlist: Vec::new(), + denylist: Vec::new(), + }, + body_limit_bytes: 1024 * 1024, + ..Default::default() + } +} + +fn service(cfg: OagwConfig) -> Arc { + Arc::new(ControlPlaneService::new(cfg)) +} + +/// Build an upstream on `127.0.0.1:{port}` with an explicit alias. +fn upstream(alias: &str, port: u16) -> Upstream { + Upstream { + id: Uuid::new_v4(), + enabled: true, + alias: alias.to_owned(), + tags: Vec::new(), + server: ServerConfig { + endpoints: vec![Endpoint { + scheme: Scheme::Https, // relayed over plain HTTP in this MVP + host: "127.0.0.1".to_owned(), + port, + }], + }, + protocol: PROTOCOL_HTTP_V1.to_owned(), + auth: None, + headers: HeadersConfig::default(), + plugins: PluginsConfig::default(), + rate_limit: None, + cors: None, + } +} + +/// Build a route on an upstream with one HTTP match (methods × path). +fn route(upstream_id: Uuid, methods: &[&str], path: &str) -> Route { + Route { + id: Uuid::new_v4(), + enabled: true, + tags: Vec::new(), + upstream_id, + r#match: Some(MatchRule { + http: Some(HttpMatch { + methods: methods + .iter() + .map(std::string::ToString::to_string) + .collect(), + path: path.to_owned(), + query_allowlist: Vec::new(), + path_suffix_mode: PathSuffixMode::Append, + }), + grpc: None, + }), + plugins: PluginsConfig::default(), + rate_limit: None, + cors: None, + } +} + +/// Register the given upstream+route and run a proxy request through the +/// engine directly (router-less; the gateway chain is the caller's tenant). +async fn proxy( + svc: &Arc, + alias: &str, + path: &str, + method: Method, + headers: HeaderMap, + body: Body, +) -> (StatusCode, HeaderMap, Vec) { + let resp = proxy_request( + svc, + vec![tenant()], + alias.to_owned(), + path.to_owned(), + method, + headers, + body, + None, + Some( + &SecurityContext::builder() + .subject_id(Uuid::new_v4()) + .subject_tenant_id(tenant()) + .build() + .unwrap(), + ), + None, + None, + ) + .await; + let status = resp.status(); + let hdrs = resp.headers().clone(); + let bytes = resp + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + (status, hdrs, bytes) +} + +/// A deterministic security context (fixed subject) so token-cache identity +/// stays constant across calls within a test. +fn ctx_for(tenant: u128, subject: u64) -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::from_u128(u128::from(subject))) + .subject_tenant_id(Uuid::from_u128(tenant)) + .build() + .unwrap() +} + +/// Run a proxy request with an explicit security context + auth registry. +#[allow( + clippy::too_many_arguments, + reason = "helper mirrors the full proxy_request argument list" +)] +async fn proxy_with( + svc: &Arc, + alias: &str, + path: &str, + method: Method, + headers: HeaderMap, + body: Body, + ctx: &SecurityContext, + registry: Option<&AuthPluginRegistry>, +) -> (StatusCode, HeaderMap, Vec) { + let resp = proxy_request( + svc, + vec![tenant()], + alias.to_owned(), + path.to_owned(), + method, + headers, + body, + None, + Some(ctx), + registry, + None, + ) + .await; + let status = resp.status(); + let hdrs = resp.headers().clone(); + let bytes = resp + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + (status, hdrs, bytes) +} + +/// An auth registry with the builtin plugins bound to a mock `cred_store`. +fn registry_with(store: MockCredStoreClient) -> AuthPluginRegistry { + AuthPluginRegistry::new( + Some(Arc::new(store)), + TokenCacheConfig { + ttl: Duration::from_mins(5), + capacity: 100, + }, + ) +} + +/// Run a proxy request with an explicit rate limiter (Slice 6). +#[allow( + clippy::too_many_arguments, + reason = "helper mirrors the full proxy_request argument list" +)] +async fn proxy_rate( + svc: &Arc, + alias: &str, + path: &str, + method: Method, + headers: HeaderMap, + body: Body, + ctx: &SecurityContext, + rate: &RateLimiter, +) -> (StatusCode, HeaderMap, Vec) { + let resp = proxy_request( + svc, + vec![tenant()], + alias.to_owned(), + path.to_owned(), + method, + headers, + body, + None, + Some(ctx), + None, + Some(rate), + ) + .await; + let status = resp.status(); + let hdrs = resp.headers().clone(); + let bytes = resp + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + (status, hdrs, bytes) +} + +/// A rate-limit config: `rate` tokens per second with an equal burst capacity. +fn per_second(rate: u64) -> RateLimitConfig { + RateLimitConfig { + sharing: SharingMode::Private, + algorithm: RateLimitAlgorithm::default(), + sustained: SustainedRate { + rate, + window: RateWindow::Second, + }, + burst: Some(BurstConfig { capacity: rate }), + scope: RateLimitScope::Tenant, + strategy: RateLimitStrategy::default(), + cost: 1, + } +} + +fn problem_type(status: StatusCode, headers: &HeaderMap, body: &[u8]) -> String { + assert_eq!( + headers + .get("x-oagw-error-source") + .map(|v| v.to_str().unwrap()), + Some("gateway") + ); + assert_eq!( + headers.get("content-type").map(|v| v.to_str().unwrap()), + Some("application/problem+json") + ); + let json: serde_json::Value = match serde_json::from_slice(body) { + Ok(value) => value, + Err(e) => panic!("problem+json body: {e}"), + }; + assert_eq!(json["status"], status.as_u16()); + json["type"].as_str().unwrap().to_owned() +} + +// --------------------------------------------------------------------------- +// Forwarding +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn forwards_request_and_preserves_status_headers_body() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200) + .header("content-type", "application/json") + .header("x-upstream", "yes") + .body(r#"{"ok":true,"model":"gpt-4"}"#); + }); + + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("mock-api", mock.port())) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let (status, hdrs, body) = proxy( + &svc, + "mock-api", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + + m.assert(); + assert_eq!(status, StatusCode::OK); + assert_eq!(hdrs.get("content-type").unwrap(), "application/json"); + assert_eq!(hdrs.get("x-upstream").unwrap(), "yes"); + // ADR 0007: upstream-passthrough responses carry error-source: upstream. + assert_eq!(hdrs.get("x-oagw-error-source").unwrap(), "upstream"); + assert_eq!(body, br#"{"ok":true,"model":"gpt-4"}"#); +} + +#[tokio::test] +async fn forwards_post_body_and_query_string() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(POST) + .path("/v1/chat") + .query_param("model", "gpt-4") + .query_param("stream", "true") + .body_includes("How are you"); + then.status(200).body("reply"); + }); + + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("mock-api", mock.port())) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["POST"], "/v1/chat")) + .unwrap(); + + let (status, _, body) = proxy( + &svc, + "mock-api", + "v1/chat?model=gpt-4&stream=true", + Method::POST, + HeaderMap::new(), + Body::from(r#"{"msg":"How are you"}"#), + ) + .await; + m.assert(); + assert_eq!(status, StatusCode::OK); + assert_eq!(body, b"reply"); +} + +#[tokio::test] +async fn applies_header_transforms_and_strips_routing_header() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(POST) + .path("/v1/chat") + .header_exists("x-allowed") // passthrough allowlist + .header_exists("x-set") // set rule + .header_exists("x-add") // add rule + .header_missing("x-drop") // remove rule + .header_missing("x-oagw-target-host") // routing header stripped + .header_missing("x-not-allowed"); // not in allowlist → dropped + then.status(204); + }); + + let mut u = upstream("mock-api", mock.port()); + u.headers = HeadersConfig { + request: RequestHeadersConfig { + passthrough: PassthroughMode::Allowlist, + passthrough_allowlist: vec!["x-allowed".to_owned()], + set: BTreeMap::from([("x-set".to_owned(), "s".to_owned())]), + add: BTreeMap::from([("x-add".to_owned(), "a".to_owned())]), + remove: vec!["x-drop".to_owned()], + }, + response: HeaderOps::default(), + }; + let svc = service(test_config()); + let u = svc.create_upstream(tenant(), u).unwrap(); + svc.create_route(tenant(), route(u.id, &["POST"], "/v1/chat")) + .unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert("x-allowed", "1".parse().unwrap()); + headers.insert("x-not-allowed", "1".parse().unwrap()); + headers.insert("x-drop", "1".parse().unwrap()); + headers.insert("x-oagw-target-host", "127.0.0.1".parse().unwrap()); + + let (status, _, _) = proxy( + &svc, + "mock-api", + "v1/chat", + Method::POST, + headers, + Body::from(r#"{"x":1}"#), + ) + .await; + m.assert(); + assert_eq!(status, StatusCode::NO_CONTENT); +} + +// --------------------------------------------------------------------------- +// Route matching + DESIGN error table +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn unknown_alias_is_404_route_not_found() { + let svc = service(test_config()); + let (status, hdrs, body) = proxy( + &svc, + "nope", + "v1/x", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["alias"], "nope"); +} + +#[tokio::test] +async fn disallowed_method_is_404_route_not_found() { + let mock = MockServer::start(); + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("mock-api", mock.port())) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let (status, hdrs, body) = proxy( + &svc, + "mock-api", + "v1/chat", + Method::POST, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); +} + +#[tokio::test] +async fn longest_path_prefix_wins() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/chat/summarize"); + then.status(200).body("summarized"); + }); + + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("mock-api", mock.port())) + .unwrap(); + // Broad prefix first, then a longer one — the longer must win. + svc.create_route(tenant(), route(u.id, &["GET"], "/v1")) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat/summarize")) + .unwrap(); + + let (status, _, body) = proxy( + &svc, + "mock-api", + "v1/chat/summarize", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + m.assert(); + assert_eq!(status, StatusCode::OK); + assert_eq!(body, b"summarized"); +} + +#[tokio::test] +async fn path_suffix_disabled_mode_is_400_validation() { + let mock = MockServer::start(); + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("mock-api", mock.port())) + .unwrap(); + let mut r = route(u.id, &["GET"], "/exact"); + r.r#match + .as_mut() + .unwrap() + .http + .as_mut() + .unwrap() + .path_suffix_mode = PathSuffixMode::Disabled; + svc.create_route(tenant(), r).unwrap(); + + // Exact path is fine: forwarded to the upstream (guarded by httpmock 200). + let m = mock.mock(|when, then| { + when.method(GET).path("/exact"); + then.status(200).body("ok"); + }); + let (status, _, body) = proxy( + &svc, + "mock-api", + "exact", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + m.assert(); + assert_eq!(status, StatusCode::OK); + assert_eq!(body, b"ok"); + + // A path suffix is rejected before any forwarding. + let (status, hdrs, body) = proxy( + &svc, + "mock-api", + "exact/extra", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); +} + +#[tokio::test] +async fn query_allowlist_rejects_unknown_params() { + let mock = MockServer::start(); + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("mock-api", mock.port())) + .unwrap(); + let mut r = route(u.id, &["GET"], "/v1/chat"); + r.r#match + .as_mut() + .unwrap() + .http + .as_mut() + .unwrap() + .query_allowlist = vec!["model".to_owned()]; + svc.create_route(tenant(), r).unwrap(); + + // Allowed param: guard passes (connect to httpmock succeeds → 200). + let m = mock.mock(|when, then| { + when.method(GET) + .path("/v1/chat") + .query_param("model", "gpt-4"); + then.status(200).body("ok"); + }); + let (status, _, _) = proxy( + &svc, + "mock-api", + "v1/chat?model=gpt-4", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + m.assert(); + assert_eq!(status, StatusCode::OK); + + // Disallowed param → 400 before forwarding. + let (status, hdrs, body) = proxy( + &svc, + "mock-api", + "v1/chat?model=gpt-4&extra=y", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); +} + +// --------------------------------------------------------------------------- +// Body validation +// --------------------------------------------------------------------------- + +/// Register a bare upstream (alias `a`, no live listener) + a POST route on +/// `/x`; used by the body-validation tests where validation fails before any +/// forwarding would occur. +fn register_bare_route(svc: &Arc) -> Uuid { + let u = svc.create_upstream(tenant(), upstream("a", 1)).unwrap(); + svc.create_route(tenant(), route(u.id, &["POST"], "/x")) + .unwrap(); + u.id +} + +#[tokio::test] +async fn oversized_body_is_413_payload_too_large() { + let mut cfg = test_config(); + cfg.body_limit_bytes = 16; + let svc = service(cfg); + register_bare_route(&svc); + + // No Content-Length: rejected as soon as the buffering limit is crossed. + let (status, hdrs, body) = proxy( + &svc, + "a", + "x", + Method::POST, + HeaderMap::new(), + Body::from(vec![b'x'; 20]), + ) + .await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.payload.too_large.v1" + ); + + // Declared Content-Length above the limit: rejected before buffering. + let mut headers = HeaderMap::new(); + headers.insert("content-length", "500".parse().unwrap()); + let (status, hdrs, body) = proxy( + &svc, + "a", + "x", + Method::POST, + headers, + Body::from(b"tiny".to_vec()), + ) + .await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.payload.too_large.v1" + ); +} + +#[tokio::test] +async fn content_length_mismatch_is_400() { + let svc = service(test_config()); + register_bare_route(&svc); + let mut headers = HeaderMap::new(); + headers.insert("content-length", "5".parse().unwrap()); + let (status, hdrs, body) = proxy( + &svc, + "a", + "x", + Method::POST, + headers, + Body::from(b"hello!".to_vec()), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); +} + +#[tokio::test] +async fn invalid_content_length_is_400() { + let svc = service(test_config()); + register_bare_route(&svc); + let mut headers = HeaderMap::new(); + headers.insert("content-length", "abc".parse().unwrap()); + let (status, hdrs, body) = proxy(&svc, "a", "x", Method::POST, headers, Body::empty()).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); +} + +#[tokio::test] +async fn unsupported_transfer_encoding_is_400() { + let svc = service(test_config()); + register_bare_route(&svc); + let mut headers = HeaderMap::new(); + headers.insert("transfer-encoding", "gzip".parse().unwrap()); + let (status, hdrs, body) = proxy(&svc, "a", "x", Method::POST, headers, Body::empty()).await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.validation.error.v1" + ); +} + +// --------------------------------------------------------------------------- +// X-OAGW-Target-Host matrix (ADR 0001) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn single_endpoint_target_host_selects_endpoint() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("selected"); + }); + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("mock-api", mock.port())) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert("x-oagw-target-host", "127.0.0.1".parse().unwrap()); + let (status, _, body) = proxy( + &svc, + "mock-api", + "v1/chat", + Method::GET, + headers, + Body::empty(), + ) + .await; + m.assert(); + assert_eq!(status, StatusCode::OK); + assert_eq!(body, b"selected"); +} + +#[tokio::test] +async fn common_suffix_pool_requires_target_host() { + // us.vendor.com / eu.vendor.com derive the alias "vendor.com"; the routing + // header is mandatory for the pool alias. + let mock = MockServer::start(); + let u = Upstream { + id: Uuid::new_v4(), + enabled: true, + alias: String::new(), // auto-derived to "vendor.com" + tags: Vec::new(), + server: ServerConfig { + endpoints: vec![ + Endpoint { + scheme: Scheme::Https, + host: "us.vendor.com".to_owned(), + port: mock.port(), + }, + Endpoint { + scheme: Scheme::Https, + host: "eu.vendor.com".to_owned(), + port: mock.port(), + }, + ], + }, + protocol: PROTOCOL_HTTP_V1.to_owned(), + auth: None, + headers: HeadersConfig::default(), + plugins: PluginsConfig::default(), + rate_limit: None, + cors: None, + }; + let svc = service(test_config()); + let u = svc.create_upstream(tenant(), u).unwrap(); + // Non-standard port appends `:port` to the derived common-suffix alias. + let pool_alias = format!("vendor.com:{}", mock.port()); + assert_eq!(u.alias, pool_alias); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + // Missing header → 400 missing_target_host. + let (status, hdrs, body) = proxy( + &svc, + &pool_alias, + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.routing.missing_target_host.v1" + ); + + // Malformed value (scheme/brackets) → 400 invalid_target_host. + let mut headers = HeaderMap::new(); + headers.insert( + "x-oagw-target-host", + "http://us.vendor.com".parse().unwrap(), + ); + let (status, hdrs, body) = proxy( + &svc, + &pool_alias, + "v1/chat", + Method::GET, + headers, + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.routing.invalid_target_host.v1" + ); + + // Well-formed but not an endpoint → 400 unknown_target_host. + let mut headers = HeaderMap::new(); + headers.insert("x-oagw-target-host", "other.vendor.com".parse().unwrap()); + let (status, hdrs, body) = proxy( + &svc, + &pool_alias, + "v1/chat", + Method::GET, + headers, + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::BAD_REQUEST); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.routing.unknown_target_host.v1" + ); + + // Valid pool member → forwarded (DNS for the fake domain fails → 502, + // proving the matrix selected a real endpoint and attempted a connection). + let mut headers = HeaderMap::new(); + headers.insert("x-oagw-target-host", "us.vendor.com".parse().unwrap()); + let (status, hdrs, body) = proxy( + &svc, + &pool_alias, + "v1/chat", + Method::GET, + headers, + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.downstream.error.v1" + ); +} + +#[tokio::test] +async fn explicit_multi_endpoint_alias_round_robins() { + let a = MockServer::start(); + let b = MockServer::start(); + let ma = a.mock(|when, then| { + when.method(GET).path("/ping"); + then.status(200).body("A"); + }); + let mb = b.mock(|when, then| { + when.method(GET).path("/ping"); + then.status(200).body("B"); + }); + + let svc = service(test_config()); + let u = Upstream { + id: Uuid::new_v4(), + enabled: true, + alias: "mock-pool".to_owned(), // IP endpoints → explicit alias required + tags: Vec::new(), + server: ServerConfig { + endpoints: vec![ + Endpoint { + scheme: Scheme::Https, + host: "127.0.0.1".to_owned(), + port: a.port(), + }, + Endpoint { + scheme: Scheme::Https, + host: "127.0.0.1".to_owned(), + port: b.port(), + }, + ], + }, + protocol: PROTOCOL_HTTP_V1.to_owned(), + auth: None, + headers: HeadersConfig::default(), + plugins: PluginsConfig::default(), + rate_limit: None, + cors: None, + }; + let u = svc.create_upstream(tenant(), u).unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/ping")) + .unwrap(); + + let (status, _, first) = proxy( + &svc, + "mock-pool", + "ping", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + let (status2, _, second) = proxy( + &svc, + "mock-pool", + "ping", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!((status, status2), (StatusCode::OK, StatusCode::OK)); + // Each endpoint is hit exactly once across the two requests. + ma.assert_calls(1); + mb.assert_calls(1); + assert_ne!(first, second); + assert!(first == b"A" || first == b"B"); + assert!(second == b"A" || second == b"B"); +} + +// --------------------------------------------------------------------------- +// Fail-closed configuration + connect errors +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn allow_http_upstream_disabled_is_502() { + let svc = service(test_config()); // allow_http_upstream=true by default in helper + let u = svc + .create_upstream(tenant(), upstream("mock-api", 1)) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + let _ = u; + + let mut cfg = test_config(); + cfg.allow_http_upstream = false; + let svc = service(cfg); + let u = svc + .create_upstream(tenant(), upstream("mock-api", 1)) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let (status, hdrs, body) = proxy( + &svc, + "mock-api", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.upstream.unsupported.v1" + ); +} + +#[tokio::test] +async fn ssrf_enabled_fails_closed_with_502() { + let svc = service(OagwConfig::default()); // ssrf_policy.enabled = true + let u = svc + .create_upstream(tenant(), upstream("mock-api", 1)) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let (status, hdrs, body) = proxy( + &svc, + "mock-api", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::BAD_GATEWAY); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.upstream.unsupported.v1" + ); +} + +#[tokio::test] +async fn connection_refused_is_503_link_unavailable() { + // Grab a free port, then close it so nothing is listening. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let port = listener.local_addr().unwrap().port(); + drop(listener); + + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("refused", port)) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let (status, hdrs, body) = proxy( + &svc, + "refused", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1" + ); +} + +// --------------------------------------------------------------------------- +// SSE passthrough +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn sse_response_streams_events_through() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/events"); + then.status(200) + .header("content-type", "text/event-stream") + .header("x-accel-buffering", "no") + .body("data: one\n\ndata: two\n\ndata: three\n\n"); + }); + + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("mock-api", mock.port())) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/events")) + .unwrap(); + + let (status, hdrs, body) = proxy( + &svc, + "mock-api", + "v1/events", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + m.assert(); + assert_eq!(status, StatusCode::OK); + assert_eq!(hdrs.get("content-type").unwrap(), "text/event-stream"); + assert_eq!(hdrs.get("x-accel-buffering").unwrap(), "no"); + assert_eq!(hdrs.get("x-oagw-error-source").unwrap(), "upstream"); + assert_eq!(body, b"data: one\n\ndata: two\n\ndata: three\n\n"); +} + +// --------------------------------------------------------------------------- +// WebSocket upgrade bridging (end-to-end through a real hyper server) +// --------------------------------------------------------------------------- + +/// Minimal `OpenAPI` registry for router registration in the WS test. +struct NoopOpenApiRegistry; + +impl OpenApiRegistry for NoopOpenApiRegistry { + fn register_operation(&self, _spec: &OperationSpec) {} + fn ensure_schema_raw( + &self, + name: &str, + _schemas: Vec<( + String, + utoipa::openapi::RefOr, + )>, + ) -> String { + name.to_owned() + } + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +/// Axum middleware that injects a security context, mirroring the host's auth +/// middleware so the proxy handler can extract `Extension`. +async fn inject_security_ctx( + mut req: Request, + next: axum::middleware::Next, +) -> axum::response::Response { + let ctx = SecurityContext::builder() + .subject_id(Uuid::new_v4()) + .subject_tenant_id(Uuid::from_u128(TENANT)) + .build() + .unwrap(); + req.extensions_mut().insert(ctx); + next.run(req).await +} + +/// Serve the OAGW router, returning the bound address. +async fn serve(svc: Arc) -> std::net::SocketAddr { + let registry = NoopOpenApiRegistry; + let app = register_routes(Router::new(), ®istry, svc, None, None, None) + .layer(axum::middleware::from_fn(inject_security_ctx)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _result = axum::serve(listener, app.into_make_service()).await; + }); + addr +} + +/// A raw-TCP upstream that answers any request with a 101 Switching Protocols +/// and then echoes bytes (a stand-in for a WebSocket echo server). Returns the +/// bound address plus the lowercased request head that reached the upstream. +async fn spawn_echo_upstream() -> (std::net::SocketAddr, Arc>>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let captured = Arc::new(Mutex::new(None)); + let captured_for_task = captured.clone(); + tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let captured_ref = captured_for_task.clone(); + tokio::spawn(async move { + // Read the request head. + let mut head = Vec::new(); + let mut chunk = [0u8; 512]; + loop { + let n = stream.read(&mut chunk).await.unwrap_or(0); + if n == 0 { + return; + } + head.extend_from_slice(&chunk[..n]); + if head.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + let head_lower = String::from_utf8_lossy(&head).to_ascii_lowercase(); + *captured_ref.lock().unwrap() = Some(head_lower); + stream + .write_all( + b"HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n", + ) + .await + .unwrap(); + // Echo loop. + let mut buf = [0u8; 4096]; + loop { + match stream.read(&mut buf).await { + Ok(0) | Err(_) => break, + Ok(n) => { + if stream.write_all(&buf[..n]).await.is_err() { + break; + } + } + } + } + }); + } + }); + (addr, captured) +} + +#[tokio::test] +async fn websocket_upgrade_is_bridged_to_the_upstream() { + let (echo_addr, echo_head) = spawn_echo_upstream().await; + + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("echo", echo_addr.port())) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/ws")) + .unwrap(); + let gw = serve(svc).await; + + // Raw-socket client performing a WebSocket handshake through the gateway. + let mut stream = tokio::net::TcpStream::connect(gw).await.unwrap(); + let handshake = format!( + "GET /oagw/v1/proxy/echo/ws HTTP/1.1\r\nHost: {gw}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\n\r\n" + ); + stream.write_all(handshake.as_bytes()).await.unwrap(); + stream.flush().await.unwrap(); + + let mut head = Vec::new(); + let mut chunk = [0u8; 512]; + loop { + let n = stream.read(&mut chunk).await.unwrap(); + assert!(n > 0, "gateway closed before answering the handshake"); + head.extend_from_slice(&chunk[..n]); + if head.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + let head = String::from_utf8_lossy(&head); + assert!( + head.starts_with("HTTP/1.1 101"), + "expected 101 Switching Protocols, got: {head:?}" + ); + assert!(head.contains("x-oagw-error-source: upstream")); + + // Round-trip bytes through the bridged connection. + stream.write_all(b"ping").await.unwrap(); + stream.flush().await.unwrap(); + let mut buf = [0u8; 4]; + stream.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"ping"); + + // A second exchange proves the bridge stays open. + stream.write_all(b"pong").await.unwrap(); + stream.flush().await.unwrap(); + let mut buf = [0u8; 4]; + stream.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"pong"); + + // Rf-007: the upstream must have received the client's RFC 6455 handshake + // headers end-to-end (they are re-attached, not passthrough-managed). + let head = echo_head.lock().unwrap().clone().expect("handshake head"); + assert!( + head.contains("sec-websocket-key:"), + "upstream must receive Sec-WebSocket-Key, got: {head}" + ); + assert!( + head.contains("sec-websocket-version: 13"), + "upstream must receive Sec-WebSocket-Version, got: {head}" + ); +} + +/// Rr-004: a client sending repeated `Sec-WebSocket-*` handshake headers (RFC +/// 6455 allows multi-valued `Sec-WebSocket-Protocol`/`-Extensions`) must have +/// ALL values relayed end-to-end — the re-attach path appends rather than +/// replacing, so no value is silently dropped. +#[tokio::test] +async fn websocket_multi_value_handshake_headers_are_all_relayed() { + let (echo_addr, echo_head) = spawn_echo_upstream().await; + + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("echo", echo_addr.port())) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/ws")) + .unwrap(); + let gw = serve(svc).await; + + let mut stream = tokio::net::TcpStream::connect(gw).await.unwrap(); + let handshake = format!( + "GET /oagw/v1/proxy/echo/ws HTTP/1.1\r\nHost: {gw}\r\nConnection: Upgrade\r\nUpgrade: websocket\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Protocol: chat\r\nSec-WebSocket-Protocol: superchat\r\n\r\n" + ); + stream.write_all(handshake.as_bytes()).await.unwrap(); + stream.flush().await.unwrap(); + + let mut head = Vec::new(); + let mut chunk = [0u8; 512]; + loop { + let n = stream.read(&mut chunk).await.unwrap(); + assert!(n > 0, "gateway closed before answering the handshake"); + head.extend_from_slice(&chunk[..n]); + if head.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + let head = String::from_utf8_lossy(&head); + assert!( + head.starts_with("HTTP/1.1 101"), + "expected 101 Switching Protocols, got: {head:?}" + ); + + // Round-trip once so the upstream head write is guaranteed visible. + stream.write_all(b"ping").await.unwrap(); + stream.flush().await.unwrap(); + let mut buf = [0u8; 4]; + stream.read_exact(&mut buf).await.unwrap(); + assert_eq!(&buf, b"ping"); + + let head = echo_head.lock().unwrap().clone().expect("handshake head"); + assert!( + head.contains("sec-websocket-protocol: chat"), + "first Sec-WebSocket-Protocol value must be relayed, got: {head}" + ); + assert!( + head.contains("sec-websocket-protocol: superchat"), + "second Sec-WebSocket-Protocol value must be relayed (append, not replace), got: {head}" + ); +} + +// --------------------------------------------------------------------------- +// Auth plugins (DESIGN slice 5) +// --------------------------------------------------------------------------- + +/// Register an upstream (with optional auth) + one GET route, returning the +/// upstream id. +fn register_upstream( + svc: &Arc, + alias: &str, + port: u16, + auth: Option, +) -> Uuid { + let mut u = upstream(alias, port); + u.auth = auth; + let u = svc.create_upstream(tenant(), u).unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + u.id +} + +#[tokio::test] +async fn apikey_header_injects_credential_from_cred_store() { + let upstream_mock = MockServer::start(); + let m = upstream_mock.mock(|when, then| { + when.method(GET) + .path("/v1/chat") + .header("x-api-key", "sk-1234"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let auth = AuthConfig { + r#type: API_KEY_AUTH_PLUGIN_ID.to_owned(), + sharing: SharingMode::default(), + config: serde_json::json!({ + "value_ref": "cred://partner-key", + "name": "x-api-key", + "in": "header", + }), + }; + register_upstream(&svc, "mock-api", upstream_mock.port(), Some(auth)); + + let registry = registry_with(MockCredStoreClient::with_secrets(vec![( + "cred://partner-key".to_owned(), + "sk-1234".to_owned(), + )])); + let ctx = ctx_for(TENANT, 7); + let (status, _, _) = proxy_with( + &svc, + "mock-api", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + Some(®istry), + ) + .await; + m.assert(); + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn apikey_query_injects_credential() { + let upstream_mock = MockServer::start(); + let m = upstream_mock.mock(|when, then| { + when.method(GET) + .path("/v1/chat") + .query_param("api_key", "sk-1234"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let auth = AuthConfig { + r#type: API_KEY_AUTH_PLUGIN_ID.to_owned(), + sharing: SharingMode::default(), + config: serde_json::json!({ + "value_ref": "cred://partner-key", + "name": "api_key", + "in": "query", + }), + }; + register_upstream(&svc, "mock-api", upstream_mock.port(), Some(auth)); + + let registry = registry_with(MockCredStoreClient::with_secrets(vec![( + "cred://partner-key".to_owned(), + "sk-1234".to_owned(), + )])); + let ctx = ctx_for(TENANT, 7); + let (status, _, _) = proxy_with( + &svc, + "mock-api", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + Some(®istry), + ) + .await; + m.assert(); + assert_eq!(status, StatusCode::OK); +} + +#[tokio::test] +async fn apikey_missing_secret_is_500_secret_not_found() { + let upstream_mock = MockServer::start(); + let svc = service(test_config()); + let auth = AuthConfig { + r#type: API_KEY_AUTH_PLUGIN_ID.to_owned(), + sharing: SharingMode::default(), + config: serde_json::json!({ + "value_ref": "cred://ghost", + "name": "x-api-key", + }), + }; + register_upstream(&svc, "mock-api", upstream_mock.port(), Some(auth)); + + // Empty store: every `get` resolves to Ok(None). + let registry = registry_with(MockCredStoreClient::empty()); + let ctx = ctx_for(TENANT, 7); + let (status, hdrs, body) = proxy_with( + &svc, + "mock-api", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + Some(®istry), + ) + .await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.secret.not_found.v1" + ); +} + +#[tokio::test] +async fn catalog_only_auth_plugins_are_unknown_plugin_503() { + for id in [BASIC_AUTH_PLUGIN_ID, BEARER_AUTH_PLUGIN_ID] { + let upstream_mock = MockServer::start(); + let svc = service(test_config()); + let auth = AuthConfig { + r#type: id.to_owned(), + sharing: SharingMode::default(), + config: serde_json::json!({}), + }; + register_upstream(&svc, "mock-api", upstream_mock.port(), Some(auth)); + + let registry = registry_with(MockCredStoreClient::empty()); + let ctx = ctx_for(TENANT, 7); + let (status, hdrs, body) = proxy_with( + &svc, + "mock-api", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + Some(®istry), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.plugin.not_found.v1" + ); + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert!( + json["detail"] + .as_str() + .unwrap() + .contains("unknown auth plugin"), + "detail: {}", + json["detail"] + ); + } +} + +#[tokio::test] +async fn noop_auth_passes_through_without_credentials() { + let upstream_mock = MockServer::start(); + let m = upstream_mock.mock(|when, then| { + when.method(GET) + .path("/v1/chat") + .header_missing("authorization"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let auth = AuthConfig { + r#type: NOOP_AUTH_PLUGIN_ID.to_owned(), + sharing: SharingMode::default(), + config: serde_json::json!({}), + }; + register_upstream(&svc, "mock-api", upstream_mock.port(), Some(auth)); + + let registry = registry_with(MockCredStoreClient::empty()); + let ctx = ctx_for(TENANT, 7); + let (status, _, _) = proxy_with( + &svc, + "mock-api", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + Some(®istry), + ) + .await; + m.assert(); + assert_eq!(status, StatusCode::OK); +} + +/// `OAuth2` Form variant: exchanges credentials at the token endpoint once, then +/// serves cached tokens without further `IdP` calls (ADR 0008). +#[tokio::test] +async fn oauth2_client_cred_form_exchanges_once_and_caches() { + let token_mock = MockServer::start(); + let token = token_mock.mock(|when, then| { + when.method(POST) + .path("/token") + .form_urlencoded_tuple("grant_type", "client_credentials") + .form_urlencoded_tuple("client_id", "cid") + .form_urlencoded_tuple("client_secret", "csecret"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"access_token":"abc.def","expires_in":3600,"token_type":"Bearer"}"#); + }); + + let upstream_mock = MockServer::start(); + let api = upstream_mock.mock(|when, then| { + when.method(GET) + .path("/v1/chat") + .header("authorization", "Bearer abc.def"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let auth = AuthConfig { + r#type: OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID.to_owned(), + sharing: SharingMode::default(), + config: serde_json::json!({ + "token_endpoint": token_mock.url("/token"), + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret", + "scopes": "", + }), + }; + register_upstream(&svc, "mock-api", upstream_mock.port(), Some(auth)); + + let registry = registry_with(MockCredStoreClient::with_secrets(vec![ + ("cred://client-id".to_owned(), "cid".to_owned()), + ("cred://client-secret".to_owned(), "csecret".to_owned()), + ])); + let ctx = ctx_for(TENANT, 42); + + let (status, _, _) = proxy_with( + &svc, + "mock-api", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + Some(®istry), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // Second request, same identity: served from the token cache. + let (status, _, _) = proxy_with( + &svc, + "mock-api", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + Some(®istry), + ) + .await; + assert_eq!(status, StatusCode::OK); + + token.assert_calls(1); + api.assert_calls(2); +} + +/// `OAuth2` Basic variant: client credentials travel in `Authorization: Basic`, +/// not in the form body. +#[tokio::test] +async fn oauth2_client_cred_basic_uses_basic_auth_header() { + let token_mock = MockServer::start(); + let token = token_mock.mock(|when, then| { + when.method(POST) + .path("/token") + // Credentials travel in `Authorization` (Basic), not the form body. + .header_exists("authorization") + .form_urlencoded_tuple("grant_type", "client_credentials") + .form_urlencoded_tuple_missing("client_secret"); + then.status(200) + .header("content-type", "application/json") + .body(r#"{"access_token":"tok-basic","expires_in":3600,"token_type":"Bearer"}"#); + }); + + let upstream_mock = MockServer::start(); + let api = upstream_mock.mock(|when, then| { + when.method(GET) + .path("/v1/chat") + .header("authorization", "Bearer tok-basic"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let auth = AuthConfig { + r#type: OAUTH2_CLIENT_CRED_BASIC_AUTH_PLUGIN_ID.to_owned(), + sharing: SharingMode::default(), + config: serde_json::json!({ + "token_endpoint": token_mock.url("/token"), + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret", + }), + }; + register_upstream(&svc, "mock-api", upstream_mock.port(), Some(auth)); + + let registry = registry_with(MockCredStoreClient::with_secrets(vec![ + ("cred://client-id".to_owned(), "cid".to_owned()), + ("cred://client-secret".to_owned(), "csecret".to_owned()), + ])); + let ctx = ctx_for(TENANT, 43); + let (status, _, _) = proxy_with( + &svc, + "mock-api", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + Some(®istry), + ) + .await; + assert_eq!(status, StatusCode::OK); + token.assert_calls(1); + api.assert_calls(1); +} + +#[tokio::test] +async fn oauth2_missing_client_secret_is_500() { + let token_mock = MockServer::start(); + let svc = service(test_config()); + let auth = AuthConfig { + r#type: OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID.to_owned(), + sharing: SharingMode::default(), + config: serde_json::json!({ + "token_endpoint": token_mock.url("/token"), + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret", + }), + }; + register_upstream(&svc, "mock-api", 1, Some(auth)); + + // Only the client id exists; the client secret reference is missing. + let registry = registry_with(MockCredStoreClient::with_secrets(vec![( + "cred://client-id".to_owned(), + "cid".to_owned(), + )])); + let ctx = ctx_for(TENANT, 44); + let (status, hdrs, body) = proxy_with( + &svc, + "mock-api", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + Some(®istry), + ) + .await; + assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.secret.not_found.v1" + ); +} + +#[tokio::test] +async fn oauth2_token_exchange_rejected_is_401_auth_failed() { + let token_mock = MockServer::start(); + // The token endpoint rejects the credentials. + token_mock.mock(|when, then| { + when.method(POST).path("/token"); + then.status(401).body("invalid_client"); + }); + + let svc = service(test_config()); + let auth = AuthConfig { + r#type: OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID.to_owned(), + sharing: SharingMode::default(), + config: serde_json::json!({ + "token_endpoint": token_mock.url("/token"), + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret", + }), + }; + register_upstream(&svc, "mock-api", 1, Some(auth)); + + let registry = registry_with(MockCredStoreClient::with_secrets(vec![ + ("cred://client-id".to_owned(), "bad".to_owned()), + ("cred://client-secret".to_owned(), "bad".to_owned()), + ])); + let ctx = ctx_for(TENANT, 45); + let (status, hdrs, body) = proxy_with( + &svc, + "mock-api", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + Some(®istry), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.auth.failed.v1" + ); +} + +// --------------------------------------------------------------------------- +// Rate limiting + guard plugins (DESIGN slice 6) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn rate_limit_rejects_with_429_retry_after_and_ratelimit_headers() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let mut up = upstream("rl", mock.port()); + up.rate_limit = Some(per_second(2)); + let u = svc.create_upstream(tenant(), up).unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let limiter = RateLimiter::new(); + let ctx = ctx_for(TENANT, 90); + + // Burst allowance: the first 2 requests pass. + for _ in 0..2 { + let (status, _, _) = proxy_rate( + &svc, + "rl", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + &limiter, + ) + .await; + assert_eq!(status, StatusCode::OK); + } + m.assert_calls(2); + + // The 3rd within the same window → 429 + RFC 6585 / rate-limit headers. + let (status, hdrs, body) = proxy_rate( + &svc, + "rl", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + &limiter, + ) + .await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1" + ); + let retry: u64 = hdrs + .get("retry-after") + .unwrap() + .to_str() + .unwrap() + .parse() + .unwrap(); + assert!(retry >= 1, "Retry-After must be positive, got {retry}"); + assert_eq!(hdrs.get("x-ratelimit-limit").unwrap(), "2"); + assert_eq!(hdrs.get("x-ratelimit-remaining").unwrap(), "0"); + let reset: u64 = hdrs + .get("x-ratelimit-reset") + .unwrap() + .to_str() + .unwrap() + .parse() + .unwrap(); + assert!(reset > 0, "X-RateLimit-Reset must be a future epoch"); + // The problem body carries the retry-guidance extension (DESIGN). + let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); + assert_eq!(json["retry_after_seconds"], retry); + assert_eq!(json["alias"], "rl"); + // Nothing reached the upstream for the rejected request. + m.assert_calls(2); +} + +#[tokio::test] +async fn route_rate_limit_wins_as_the_stricter_limit() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let mut up = upstream("rl2", mock.port()); + up.rate_limit = Some(per_second(100)); // generous upstream + let u = svc.create_upstream(tenant(), up).unwrap(); + let mut r = route(u.id, &["GET"], "/v1/chat"); + r.rate_limit = Some(per_second(2)); // strict route → effective limit 2/s + svc.create_route(tenant(), r).unwrap(); + + let limiter = RateLimiter::new(); + let ctx = ctx_for(TENANT, 91); + for _ in 0..2 { + let (status, _, _) = proxy_rate( + &svc, + "rl2", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + &limiter, + ) + .await; + assert_eq!(status, StatusCode::OK); + } + let (status, hdrs, _) = proxy_rate( + &svc, + "rl2", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + &limiter, + ) + .await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + assert_eq!(hdrs.get("x-ratelimit-limit").unwrap(), "2"); + m.assert_calls(2); +} + +#[tokio::test] +async fn rate_limiter_is_fail_open_when_unconfigured() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("norl", mock.port())) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + // A rate limiter is wired but the upstream/route configure nothing. + let limiter = RateLimiter::new(); + let ctx = ctx_for(TENANT, 92); + for _ in 0..5 { + let (status, _, _) = proxy_rate( + &svc, + "norl", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + &limiter, + ) + .await; + assert_eq!(status, StatusCode::OK); + } + m.assert_calls(5); +} + +#[tokio::test] +async fn bound_required_headers_guard_fails_open_at_runtime() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let mut up = upstream("guarded", mock.port()); + // Binding the guard by GTS ID runs it in the data plane; the MVP model + // carries no per-plugin config object, so it is fail-open (ADR 0009) and + // the request passes through unchanged. + up.plugins.items = vec![REQUIRED_HEADERS_GUARD_PLUGIN_ID.to_owned()]; + let u = svc.create_upstream(tenant(), up).unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let (status, _, _) = proxy_with( + &svc, + "guarded", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx_for(TENANT, 93), + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + m.assert(); +} + +/// Issue a raw HTTP/1.1 GET over a TCP stream and return (status, body). +async fn raw_get(addr: std::net::SocketAddr, path: &str) -> (u16, String) { + let mut stream = tokio::net::TcpStream::connect(addr).await.unwrap(); + let req = format!("GET {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n"); + stream.write_all(req.as_bytes()).await.unwrap(); + stream.flush().await.unwrap(); + let mut buf = Vec::new(); + let mut chunk = [0u8; 1024]; + loop { + match stream.read(&mut chunk).await { + Ok(0) | Err(_) => break, + Ok(n) => buf.extend_from_slice(&chunk[..n]), + } + } + let text = String::from_utf8_lossy(&buf); + let status = text + .split_whitespace() + .nth(1) + .and_then(|s| s.parse::().ok()) + .unwrap_or(0); + (status, text.into_owned()) +} + +#[tokio::test] +async fn rate_limit_enforced_over_the_rest_router() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let mut up = upstream("web", mock.port()); + up.rate_limit = Some(per_second(1)); + let u = svc.create_upstream(tenant(), up).unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let registry = NoopOpenApiRegistry; + let limiter = Arc::new(RateLimiter::new()); + let app = register_routes(Router::new(), ®istry, svc, None, None, Some(limiter)) + .layer(axum::middleware::from_fn(inject_security_ctx)); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _result = axum::serve(listener, app.into_make_service()).await; + }); + + // First request consumes the single token; the second is rejected with a + // 429 the client can see over the wire (handler → engine wiring). + let (s1, body1) = raw_get(addr, "/oagw/v1/proxy/web/v1/chat").await; + assert_eq!(s1, 200); + // Rf-016: even a successful proxied response carries the generated + // correlation id (the handler's `x-request-id`). + assert!( + body1.to_ascii_lowercase().contains("x-request-id:"), + "200 proxy response must echo a correlation id, got: {body1}" + ); + let (s2, body) = raw_get(addr, "/oagw/v1/proxy/web/v1/chat").await; + assert_eq!(s2, 429); + assert!( + body.to_ascii_lowercase().contains("retry-after:"), + "429 response must carry Retry-After, got: {body}" + ); + m.assert_calls(1); +} + +// --------------------------------------------------------------------------- +// CORS (DESIGN slice 7, ADR 0004) +// --------------------------------------------------------------------------- + +fn cors_config( + allowed_origins: Vec<&str>, + allowed_methods: Vec<&str>, + allow_credentials: bool, +) -> oagw::domain::models::CorsConfig { + oagw::domain::models::CorsConfig { + sharing: SharingMode::default(), + enabled: true, + allowed_origins: allowed_origins.into_iter().map(ToOwned::to_owned).collect(), + allowed_methods: allowed_methods.into_iter().map(ToOwned::to_owned).collect(), + expose_headers: vec!["X-Request-ID".to_owned()], + allow_credentials, + } +} + +#[tokio::test] +async fn cors_preflight_returns_permissive_204_without_resolving_upstream() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(OPTIONS).path("/v1/chat"); + then.status(204); + }); + + let svc = service(test_config()); + // Note: no upstream registered at all — the preflight must not resolve it. + let mut headers = HeaderMap::new(); + headers.insert( + "origin", + HeaderValue::from_static("https://app.example.com"), + ); + headers.insert( + "access-control-request-method", + HeaderValue::from_static("POST"), + ); + headers.insert( + "access-control-request-headers", + HeaderValue::from_static("Content-Type, Authorization"), + ); + let body = Body::empty(); + let resp = proxy_request( + &svc, + vec![tenant()], + "ghost".to_owned(), + "v1/chat".to_owned(), + Method::OPTIONS, + headers, + body, + None, + None, + None, + None, + ) + .await; + let status = resp.status(); + let hdrs = resp.headers().clone(); + // Per ADR 0004 the preflight is answered locally; nothing hits the upstream. + let _ = m; + assert_eq!(status, StatusCode::NO_CONTENT); + assert_eq!( + hdrs.get("access-control-allow-origin").unwrap(), + "https://app.example.com" + ); + assert_eq!(hdrs.get("access-control-allow-methods").unwrap(), "POST"); + assert_eq!( + hdrs.get("access-control-allow-headers").unwrap(), + "Content-Type, Authorization" + ); + assert_eq!(hdrs.get("access-control-max-age").unwrap(), "86400"); + assert!( + hdrs.get("vary") + .unwrap() + .to_str() + .unwrap() + .contains("Origin") + ); + assert_eq!(hdrs.get("x-oagw-error-source").unwrap(), "gateway"); +} + +#[tokio::test] +async fn cors_allowed_actual_request_adds_response_headers() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let mut up = upstream("cors", mock.port()); + up.cors = Some(cors_config( + vec!["https://app.example.com", "https://admin.example.com"], + vec!["GET", "POST"], + true, + )); + let u = svc.create_upstream(tenant(), up).unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert( + "origin", + HeaderValue::from_static("https://app.example.com"), + ); + let (status, hdrs, _) = + proxy(&svc, "cors", "v1/chat", Method::GET, headers, Body::empty()).await; + m.assert(); + assert_eq!(status, StatusCode::OK); + assert_eq!( + hdrs.get("access-control-allow-origin").unwrap(), + "https://app.example.com" + ); + assert_eq!( + hdrs.get("access-control-expose-headers").unwrap(), + "X-Request-ID" + ); + assert_eq!( + hdrs.get("access-control-allow-credentials").unwrap(), + "true" + ); + assert!( + hdrs.get("vary") + .unwrap() + .to_str() + .unwrap() + .contains("Origin") + ); +} + +#[tokio::test] +async fn cors_disallowed_origin_is_403_before_upstream_call() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let mut up = upstream("cors2", mock.port()); + up.cors = Some(cors_config( + vec!["https://app.example.com"], + vec!["GET"], + false, + )); + let u = svc.create_upstream(tenant(), up).unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert("origin", HeaderValue::from_static("https://evil.com")); + let (status, hdrs, body) = proxy( + &svc, + "cors2", + "v1/chat", + Method::GET, + headers, + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1" + ); + m.assert_calls(0); +} + +#[tokio::test] +async fn cors_disallowed_method_is_403_before_upstream_call() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(DELETE).path("/v1/chat"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let mut up = upstream("cors3", mock.port()); + up.cors = Some(cors_config( + vec!["https://app.example.com"], + vec!["GET"], + false, + )); + let u = svc.create_upstream(tenant(), up).unwrap(); + // Route matches DELETE so the CORS method check (not route matching) is + // what rejects it — DESIGN guard rules: route method membership first, + // then CORS method allowlist for cross-origin requests. + svc.create_route(tenant(), route(u.id, &["GET", "DELETE"], "/v1/chat")) + .unwrap(); + + let mut headers = HeaderMap::new(); + headers.insert( + "origin", + HeaderValue::from_static("https://app.example.com"), + ); + let (status, hdrs, body) = proxy( + &svc, + "cors3", + "v1/chat", + Method::DELETE, + headers, + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1" + ); + m.assert_calls(0); +} + +#[tokio::test] +async fn cors_is_not_applied_to_non_cross_origin_requests() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let mut up = upstream("cors4", mock.port()); + up.cors = Some(cors_config( + vec!["https://app.example.com"], + vec!["GET"], + false, + )); + let u = svc.create_upstream(tenant(), up).unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + // No Origin header → the request is not cross-origin; CORS is irrelevant. + let (status, hdrs, _) = proxy( + &svc, + "cors4", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + m.assert(); + assert_eq!(status, StatusCode::OK); + assert!(hdrs.get("access-control-allow-origin").is_none()); +} + +// --------------------------------------------------------------------------- +// Rf-020 regression tests (semantic review: tenant chain, enable/disable, +// header set/add/remove CRUD, guards, timeout, WebSocket keys, path +// normalization, rate-limit reconfiguration, oauth2 single-flight) +// --------------------------------------------------------------------------- + +/// A raw-TCP upstream that reads every request head (raw), appends it to a +/// shared buffer, and answers 200. Returns `(addr, captured_heads)`. +async fn spawn_capture_upstream() -> (std::net::SocketAddr, Arc>) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let captured = Arc::new(Mutex::new(String::new())); + let captured_for_task = captured.clone(); + tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let captured_ref = captured_for_task.clone(); + tokio::spawn(async move { + let mut head = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + let n = stream.read(&mut chunk).await.unwrap_or(0); + if n == 0 { + return; + } + head.extend_from_slice(&chunk[..n]); + if head.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + let text = String::from_utf8_lossy(&head).into_owned(); + captured_ref.lock().unwrap().push_str(&text); + let _res = stream + .write_all( + b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok", + ) + .await; + }); + } + }); + (addr, captured) +} + +/// Run a proxy request with an explicit tenant chain (`chain[0]` = caller); +/// the gateway resolves aliases descendant → root. +#[allow( + clippy::too_many_arguments, + reason = "helper mirrors the full proxy_request argument list" +)] +async fn proxy_with_chain( + svc: &Arc, + chain: &[Uuid], + alias: &str, + path: &str, + method: Method, + headers: HeaderMap, + body: Body, +) -> (StatusCode, HeaderMap, Vec) { + let ctx = SecurityContext::builder() + .subject_id(Uuid::new_v4()) + .subject_tenant_id(chain[0]) + .build() + .unwrap(); + let resp = proxy_request( + svc, + chain.to_vec(), + alias.to_owned(), + path.to_owned(), + method, + headers, + body, + None, + Some(&ctx), + None, + None, + ) + .await; + let status = resp.status(); + let hdrs = resp.headers().clone(); + let bytes = resp + .into_body() + .collect() + .await + .unwrap() + .to_bytes() + .to_vec(); + (status, hdrs, bytes) +} + +/// Register an alias + route in the **ancestor** tenant (different from +/// `TENANT`), returning a mock that records whether that copy was hit. +fn register_ancestor( + svc: &Arc, + tenant_id: u128, + alias: &str, + port: u16, + enabled: bool, +) -> httpmock::Mock<'static> { + // Leak the server handle so the returned `Mock<'static>` (which borrows + // it) can escape the helper; mock servers live for the whole test process. + let server: &'static MockServer = Box::leak(Box::new(MockServer::start())); + let m = server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let mut u = upstream(alias, port); + u.enabled = enabled; + let u = svc + .create_upstream(Uuid::from_u128(tenant_id), u) + .unwrap(); + svc.create_route( + Uuid::from_u128(tenant_id), + route(u.id, &["GET"], "/v1/chat"), + ) + .unwrap(); + m +} + +#[tokio::test] +async fn tenant_chain_shadows_alias_to_the_descendant_copy() { + let descendant_server = MockServer::start(); + let descendant_m = descendant_server.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + let svc = service(test_config()); + let ancestor_m = register_ancestor(&svc, 2, "m", 1, true); + let u = svc + .create_upstream(tenant(), upstream("m", descendant_server.port())) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let chain = vec![tenant(), Uuid::from_u128(2)]; + let (status, _, body) = proxy_with_chain( + &svc, + &chain, + "m", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, b"ok"); + // The descendant's copy won the closest-wins resolution. + descendant_m.assert_calls(1); + ancestor_m.assert_calls(0); +} + +#[tokio::test] +async fn tenant_chain_disabled_descendant_is_503_not_ancestor_fallthrough() { + let svc = service(test_config()); + let ancestor_m = register_ancestor(&svc, 2, "m", 1, true); + // The descendant owns a *disabled* copy: it may not fall through to the + // ancestor's enabled one (PRD cpt-cf-oagw-fr-enable-disable). + let mut d = upstream("m", 1); + d.enabled = false; + let d = svc.create_upstream(tenant(), d).unwrap(); + svc.create_route(tenant(), route(d.id, &["GET"], "/v1/chat")) + .unwrap(); + + let chain = vec![tenant(), Uuid::from_u128(2)]; + let (status, hdrs, body) = proxy_with_chain( + &svc, + &chain, + "m", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1" + ); + ancestor_m.assert_calls(0); +} + +#[tokio::test] +async fn tenant_chain_disabled_ancestor_is_503() { + let svc = service(test_config()); + register_ancestor(&svc, 2, "m", 1, false); + + let chain = vec![tenant(), Uuid::from_u128(2)]; + let (status, hdrs, body) = proxy_with_chain( + &svc, + &chain, + "m", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.link.unavailable.v1" + ); +} + +#[tokio::test] +async fn disabled_route_is_404_while_enabled_sibling_still_forwards() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/other"); + then.status(200).body("ok"); + }); + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("r", mock.port())) + .unwrap(); + let mut off = route(u.id, &["GET"], "/v1/chat"); + off.enabled = false; + svc.create_route(tenant(), off).unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/other")) + .unwrap(); + + // The disabled route must not match. + let (status, hdrs, body) = proxy( + &svc, + "r", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); + + // Its enabled sibling still forwards. + let (status, _, _) = proxy( + &svc, + "r", + "v1/other", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::OK); + m.assert(); +} + +#[tokio::test] +async fn response_direction_set_add_remove_apply_on_the_forwarded_response() { + let mock = MockServer::start(); + mock.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200) + .header("x-resp-remove", "gone") + .header("x-resp-keep", "kept") + .body("ok"); + }); + + let svc = service(test_config()); + let mut u = upstream("ro", mock.port()); + u.headers.response = HeaderOps { + set: BTreeMap::from([("x-resp-set".to_owned(), "s".to_owned())]), + add: BTreeMap::from([("x-resp-add".to_owned(), "a".to_owned())]), + remove: vec!["x-resp-remove".to_owned()], + }; + let u = svc.create_upstream(tenant(), u).unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let (status, hdrs, _) = proxy( + &svc, + "ro", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::OK); + // set replaces/normalizes, add appends, remove strips. + assert_eq!(hdrs.get("x-resp-set").unwrap(), "s"); + assert_eq!(hdrs.get("x-resp-add").unwrap(), "a"); + assert_eq!(hdrs.get("x-resp-keep").unwrap(), "kept"); + assert!(hdrs.get("x-resp-remove").is_none()); +} + +#[tokio::test] +async fn auth_injected_authorization_replaces_inbound_authorization() { + let upstream_mock = MockServer::start(); + let am = upstream_mock.mock(|when, then| { + when.method(GET) + .path("/v1/chat") + .header("authorization", "Bearer tok-hl"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + // Passthrough All: an inbound (leaked) `Authorization` would normally be + // forwarded — the injected credential must replace it, not ride alongside. + let mut u = upstream("auth", upstream_mock.port()); + u.headers.request.passthrough = PassthroughMode::All; + let u = svc.create_upstream(tenant(), u).unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + let auth = AuthConfig { + r#type: API_KEY_AUTH_PLUGIN_ID.to_owned(), + sharing: SharingMode::default(), + config: serde_json::json!({ + "value_ref": "cred://apikey", + "name": "authorization", + "in": "header", + }), + }; + svc.update_upstream(tenant(), u.id, { + let mut u = u.clone(); + u.auth = Some(auth); + u + }) + .unwrap(); + + let registry = registry_with(MockCredStoreClient::with_secrets(vec![( + "cred://apikey".to_owned(), + "Bearer tok-hl".to_owned(), + )])); + let ctx = ctx_for(TENANT, 47); + let mut headers = HeaderMap::new(); + headers.insert("authorization", HeaderValue::from_static("Basic bGVha2Vk")); + let (status, _, _) = proxy_with( + &svc, + "auth", + "v1/chat", + Method::GET, + headers, + Body::empty(), + &ctx, + Some(®istry), + ) + .await; + assert_eq!(status, StatusCode::OK); + // The upstream saw exactly one Authorization value: the injected bearer. + am.assert_calls(1); +} + +#[tokio::test] +async fn unresolvable_guard_plugin_fails_open_and_request_proceeds() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let mut up = upstream("guarded", mock.port()); + // A plugin id that no registry knows about: the guard must fail open + // (ADR 0009), never block the request. + up.plugins.items = vec![Uuid::new_v4().to_string()]; + let u = svc.create_upstream(tenant(), up).unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let (status, _, _) = proxy( + &svc, + "guarded", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::OK); + m.assert(); +} + +#[tokio::test] +async fn upstream_timeout_is_504_gateway_timeout() { + // A TCP upstream that accepts a connection and then never answers. + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + tokio::spawn(async move { + let _ok = listener.accept().await; + // Hold the connection open forever (never write a response). + std::future::pending::<()>().await; + }); + + let mut cfg = test_config(); + cfg.proxy_timeout_secs = 1; + let svc = service(cfg); + let u = svc + .create_upstream(tenant(), upstream("slow", addr.port())) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + + let (status, hdrs, body) = proxy( + &svc, + "slow", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::GATEWAY_TIMEOUT); + assert_eq!( + problem_type(status, &hdrs, &body), + "gts.cf.core.errors.err.v1~cf.oagw.timeout.request.v1" + ); +} + +#[tokio::test] +async fn encoded_and_dot_segment_paths_are_normalized_when_forwarded() { + // Dot-segment traversal: /v1/chat/../summarize must reach the upstream as + // /v1/summarize (no literal `..`, no 502). + let (dot_addr, dot_captured) = spawn_capture_upstream().await; + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("px", dot_addr.port())) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1")) + .unwrap(); + + let (status, _, body) = proxy( + &svc, + "px", + "v1/chat/../summarize", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(body, b"ok"); + let dot_head = dot_captured.lock().unwrap().clone(); + assert!( + dot_head.contains("GET /v1/summarize HTTP/1.1"), + "dot segments must be collapsed, got: {dot_head:?}" + ); + assert!(!dot_head.contains(".."), "no literal `..` may be forwarded"); + + // Encoded path (axum delivers the decoded suffix): spaces and non-ASCII + // must be re-encoded once on the wire. + let (enc_addr, enc_captured) = spawn_capture_upstream().await; + let svc = service(test_config()); + let u = svc + .create_upstream(tenant(), upstream("enc", enc_addr.port())) + .unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1")) + .unwrap(); + let (status, _, _) = proxy( + &svc, + "enc", + "v1/chat/h\u{e9}llo world", + Method::GET, + HeaderMap::new(), + Body::empty(), + ) + .await; + assert_eq!(status, StatusCode::OK); + let enc_head = enc_captured.lock().unwrap().clone(); + assert!( + enc_head.contains("GET /v1/chat/h%C3%A9llo%20world HTTP/1.1"), + "decoded path must be re-encoded once, got: {enc_head:?}" + ); +} + +#[tokio::test] +async fn rate_limit_reconfiguration_resets_the_bucket_immediately() { + let mock = MockServer::start(); + let m = mock.mock(|when, then| { + when.method(GET).path("/v1/chat"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let mut up = upstream("re", mock.port()); + up.rate_limit = Some(per_second(1)); + let u = svc.create_upstream(tenant(), up).unwrap(); + svc.create_route(tenant(), route(u.id, &["GET"], "/v1/chat")) + .unwrap(); + let limiter = RateLimiter::new(); + let ctx = ctx_for(TENANT, 91); + + // Consume the single initial token. + let (status, _, _) = proxy_rate( + &svc, + "re", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + &limiter, + ) + .await; + assert_eq!(status, StatusCode::OK); + + // Reconfigure with a different plan: the drift check resets the bucket so + // the new burst (2) is available right away, not after the old window. + let mut updated = u.clone(); + updated.rate_limit = Some(per_second(2)); + svc.update_upstream(tenant(), u.id, updated).unwrap(); + for _ in 0..2 { + let (status, _, _) = proxy_rate( + &svc, + "re", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + &limiter, + ) + .await; + assert_eq!(status, StatusCode::OK); + } + // 3 upstream calls total (1 pre-reconfig + 2 post-reconfig); the next + // request is limited. + let (status, _, _) = proxy_rate( + &svc, + "re", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + &limiter, + ) + .await; + assert_eq!(status, StatusCode::TOO_MANY_REQUESTS); + m.assert_calls(3); +} + +/// A raw-TCP token endpoint that counts received requests (one per in-flight +/// exchange) and answers 200 after a short delay, so concurrent first-requests +/// overlap instead of completing from an already-cached entry. +async fn spawn_delayed_token_server( + delay: Duration, +) -> (std::net::SocketAddr, Arc) { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let count = Arc::new(AtomicUsize::new(0)); + let count_for_task = count.clone(); + tokio::spawn(async move { + loop { + let Ok((mut stream, _)) = listener.accept().await else { + break; + }; + let count_ref = count_for_task.clone(); + tokio::spawn(async move { + let mut head = Vec::new(); + let mut chunk = [0u8; 4096]; + loop { + let n = stream.read(&mut chunk).await.unwrap_or(0); + if n == 0 { + return; + } + head.extend_from_slice(&chunk[..n]); + if head.windows(4).any(|w| w == b"\r\n\r\n") { + break; + } + } + if head.is_empty() { + return; + } + count_ref.fetch_add(1, Ordering::SeqCst); + tokio::time::sleep(delay).await; + let body = + br#"{"access_token":"stampede","expires_in":3600,"token_type":"Bearer"}"#; + let head_resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + ); + let _res = stream.write_all(head_resp.as_bytes()).await; + let _res = stream.write_all(body).await; + }); + } + }); + (addr, count) +} + +#[tokio::test] +async fn oauth2_concurrent_first_requests_issue_exactly_one_token_exchange() { + let (token_addr, token_hits) = + spawn_delayed_token_server(Duration::from_millis(300)).await; + + let upstream_mock = MockServer::start(); + let api = upstream_mock.mock(|when, then| { + when.method(GET) + .path("/v1/chat") + .header("authorization", "Bearer stampede"); + then.status(200).body("ok"); + }); + + let svc = service(test_config()); + let auth = AuthConfig { + r#type: OAUTH2_CLIENT_CRED_AUTH_PLUGIN_ID.to_owned(), + sharing: SharingMode::default(), + config: serde_json::json!({ + "token_endpoint": format!("http://127.0.0.1:{}/token", token_addr.port()), + "client_id_ref": "cred://client-id", + "client_secret_ref": "cred://client-secret", + "scopes": "", + }), + }; + register_upstream(&svc, "mock-api", upstream_mock.port(), Some(auth)); + + let registry = registry_with(MockCredStoreClient::with_secrets(vec![ + ("cred://client-id".to_owned(), "cid".to_owned()), + ("cred://client-secret".to_owned(), "csecret".to_owned()), + ])); + let ctx = ctx_for(TENANT, 48); + + // N concurrent first-requests, same identity → same cache key. The slow + // token endpoint keeps them all in flight so the single-flight path is + // exercised: exactly one IdP exchange for the stampede. + let mut futs = Vec::new(); + for _ in 0..8 { + futs.push(proxy_with( + &svc, + "mock-api", + "v1/chat", + Method::GET, + HeaderMap::new(), + Body::empty(), + &ctx, + Some(®istry), + )); + } + let results = futures_util::future::join_all(futs).await; + for (status, _, _) in results { + assert_eq!(status, StatusCode::OK); + } + assert_eq!(token_hits.load(Ordering::SeqCst), 1); + api.assert_calls(8); +} diff --git a/gears/system/oagw/oagw/tests/rest_api.rs b/gears/system/oagw/oagw/tests/rest_api.rs new file mode 100644 index 0000000..57fbc67 --- /dev/null +++ b/gears/system/oagw/oagw/tests/rest_api.rs @@ -0,0 +1,467 @@ +//! Router-level tests for the OAGW management REST API. +//! +//! Exercises `register_routes` end-to-end with `Router::oneshot`: status +//! codes, RFC 9457 problem+json bodies, the `X-OAGW-Error-Source: gateway` +//! header on gateway errors (ADR 0007), and OData-lite list behavior. + +#![allow(clippy::unwrap_used)] + +use std::sync::Arc; + +use axum::Router; +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use oagw::api::rest::routes::register_routes; +use oagw::config::OagwConfig; +use oagw::domain::service::ControlPlaneService; +use toolkit::api::OpenApiRegistry; +use toolkit::api::operation_builder::OperationSpec; +use toolkit_security::SecurityContext; +use tower::ServiceExt; +use uuid::Uuid; + +/// Minimal `OpenAPI` registry — route registration only records specs. +struct NoopOpenApiRegistry; + +impl OpenApiRegistry for NoopOpenApiRegistry { + fn register_operation(&self, _spec: &OperationSpec) {} + fn ensure_schema_raw( + &self, + name: &str, + _schemas: Vec<( + String, + utoipa::openapi::RefOr, + )>, + ) -> String { + name.to_owned() + } + fn as_any(&self) -> &dyn std::any::Any { + self + } +} + +fn router() -> Router { + let service = Arc::new(ControlPlaneService::new(OagwConfig::default())); + let registry = NoopOpenApiRegistry; + register_routes(Router::new(), ®istry, service, None, None, None) +} + +fn ctx_for(tenant: u64) -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::new_v4()) + .subject_tenant_id(Uuid::from_u128(u128::from(tenant))) + .build() + .unwrap() +} + +/// Put the security context into the request extensions, mirroring the host's +/// auth middleware (`toolkit` injects `Extension`; axum reads +/// it back out of `extensions_mut`). +async fn send_auth( + router: Router, + method: &str, + path: &str, + ctx: SecurityContext, + body: Option, +) -> (StatusCode, axum::http::HeaderMap, serde_json::Value) { + send_auth_headered(router, method, path, ctx, &[], body).await +} + +/// Like [`send_auth`], but with explicit extra request headers (e.g. a +/// caller-supplied `X-Request-ID`). +async fn send_auth_headered( + router: Router, + method: &str, + path: &str, + ctx: SecurityContext, + extra_headers: &[(&str, &str)], + body: Option, +) -> (StatusCode, axum::http::HeaderMap, serde_json::Value) { + let mut builder = Request::builder().method(method).uri(path); + if body.is_some() { + builder = builder.header("content-type", "application/json"); + } + for (name, value) in extra_headers { + builder = builder.header(*name, *value); + } + let mut req = builder.body(Body::empty()).unwrap(); + req.extensions_mut().insert(ctx); + let body = body.map(|j| j.to_string().into_bytes()); + let (parts, _old) = req.into_parts(); + let new_body = if let Some(bytes) = body { + Body::from(bytes) + } else { + Body::empty() + }; + let req = Request::from_parts(parts, new_body); + let resp = router.oneshot(req).await.unwrap(); + let status = resp.status(); + let headers = resp.headers().clone(); + let bytes = resp.into_body().collect().await.unwrap().to_bytes(); + let json = if bytes.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null) + }; + (status, headers, json) +} + +fn host_json(host: &str, port: u16) -> serde_json::Value { + serde_json::json!({ + "server": { "endpoints": [{ "scheme": "https", "host": host, "port": port }] }, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + }) +} + +#[tokio::test] +async fn create_upstream_returns_201_location_and_derived_alias() { + let app = router(); + let ctx = ctx_for(1); + let (status, headers, json) = send_auth( + app, + "POST", + "/oagw/v1/upstreams", + ctx, + Some(host_json("api.example.com", 443)), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + assert!(headers.get("location").is_some()); + assert_eq!(json["alias"], "api.example.com"); +} + +#[tokio::test] +async fn duplicate_alias_is_409_problem_with_gateway_header() { + let app = router(); + let ctx = ctx_for(1); + let _ = send_auth( + app.clone(), + "POST", + "/oagw/v1/upstreams", + ctx.clone(), + Some(host_json("api.example.com", 443)), + ) + .await; + let (status, headers, json) = send_auth( + app, + "POST", + "/oagw/v1/upstreams", + ctx, + Some(host_json("api.example.com", 443)), + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(headers.get("x-oagw-error-source").unwrap(), "gateway"); + assert_eq!( + headers.get("content-type").unwrap(), + "application/problem+json" + ); + assert_eq!(json["status"], 409); + assert!(json["detail"].as_str().unwrap().contains("already in use")); +} + +#[tokio::test] +async fn get_missing_upstream_is_404_problem_with_instance() { + let app = router(); + let ctx = ctx_for(1); + let (status, headers, json) = send_auth( + app, + "GET", + "/oagw/v1/upstreams/00000000-0000-0000-0000-000000000001", + ctx, + None, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(headers.get("x-oagw-error-source").unwrap(), "gateway"); + assert_eq!( + json["type"], + "gts.cf.core.errors.err.v1~cf.oagw.not_found.v1" + ); + assert!( + json["instance"] + .as_str() + .unwrap() + .starts_with("/oagw/v1/upstreams/") + ); +} + +#[tokio::test] +async fn list_upstreams_supports_odata_skip_top() { + let app = router(); + let ctx = ctx_for(1); + for i in 0..3u64 { + let _ = send_auth( + app.clone(), + "POST", + "/oagw/v1/upstreams", + ctx.clone(), + Some(host_json(&format!("api{i}.example.com"), 443)), + ) + .await; + } + let (status, headers, json) = + send_auth(app, "GET", "/oagw/v1/upstreams?$top=2&$skip=1", ctx, None).await; + assert_eq!(status, StatusCode::OK); + assert_eq!(headers.get("x-oagw-error-source"), None); // success: no error-source header required + assert_eq!(json["items"].as_array().unwrap().len(), 2); + assert_eq!(json["page_info"]["limit"], 2); +} + +#[tokio::test] +async fn create_route_then_plugin_delete_in_use_is_409_with_references() { + let app = router(); + let ctx = ctx_for(1); + + // Create an upstream, a route with that upstream, and a plugin; bind the + // plugin to both the upstream and the route. + let (_, _, upstream) = send_auth( + app.clone(), + "POST", + "/oagw/v1/upstreams", + ctx.clone(), + Some(host_json("api.example.com", 443)), + ) + .await; + let upstream_id = upstream["id"].as_str().unwrap().to_owned(); + + let route_body = serde_json::json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["GET", "POST"], "path": "/v1/chat" } } + }); + let (status, _, route) = send_auth( + app.clone(), + "POST", + "/oagw/v1/routes", + ctx.clone(), + Some(route_body), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let route_id = route["id"].as_str().unwrap().to_owned(); + + let plugin_body = serde_json::json!({ + "name": "transform", + "kind": "starlark", + "source": "def handle(req):\n return req" + }); + let (status, _, plugin) = send_auth( + app.clone(), + "POST", + "/oagw/v1/plugins", + ctx.clone(), + Some(plugin_body), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let plugin_id = plugin["id"].as_str().unwrap().to_owned(); + + // Bind plugin to upstream + route. + let upstream_new = serde_json::json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "api.example.com", "port": 443 }] }, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1", + "plugins": { "items": [plugin_id] } + }); + let (status, _, _) = send_auth( + app.clone(), + "PUT", + &format!("/oagw/v1/upstreams/{upstream_id}"), + ctx.clone(), + Some(upstream_new), + ) + .await; + assert_eq!(status, StatusCode::OK); + + let route_new = serde_json::json!({ + "upstream_id": upstream_id, + "match": { "http": { "methods": ["GET", "POST"], "path": "/v1/chat" } }, + "plugins": { "items": [plugin_id] } + }); + let (status, _, _) = send_auth( + app.clone(), + "PUT", + &format!("/oagw/v1/routes/{route_id}"), + ctx.clone(), + Some(route_new), + ) + .await; + assert_eq!(status, StatusCode::OK); + + // Delete must 409 with the referencing upstream and route. + let (status, headers, json) = send_auth( + app, + "DELETE", + &format!("/oagw/v1/plugins/{plugin_id}"), + ctx, + None, + ) + .await; + assert_eq!(status, StatusCode::CONFLICT); + assert_eq!(headers.get("x-oagw-error-source").unwrap(), "gateway"); + assert_eq!( + json["type"], + "gts.cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1" + ); + // DESIGN extension fields are snake_case: `referenced_by` holds the + // referencing upstream/route ids. + assert!( + json["referenced_by"]["upstreams"] + .as_array() + .unwrap() + .contains(&serde_json::json!(upstream_id)) + ); + assert!( + json["referenced_by"]["routes"] + .as_array() + .unwrap() + .contains(&serde_json::json!(route_id)) + ); +} + +#[tokio::test] +async fn list_routes_filters_by_odata_filter() { + let app = router(); + let ctx = ctx_for(1); + + // Two upstreams; routes on each. Filter by `upstream_id` (the DESIGN's + // documented route filter example) and expect only that upstream's route. + let (_, _, u1) = send_auth( + app.clone(), + "POST", + "/oagw/v1/upstreams", + ctx.clone(), + Some(host_json("api1.example.com", 443)), + ) + .await; + let (_, _, u2) = send_auth( + app.clone(), + "POST", + "/oagw/v1/upstreams", + ctx.clone(), + Some(host_json("api2.example.com", 443)), + ) + .await; + let u1_id = u1["id"].as_str().unwrap().to_owned(); + let u2_id = u2["id"].as_str().unwrap().to_owned(); + + for (uid, path) in [(&u1_id, "/v1/a"), (&u2_id, "/v1/b")] { + let body = serde_json::json!({ + "upstream_id": uid, + "match": { "http": { "methods": ["GET"], "path": path } } + }); + let _ = send_auth( + app.clone(), + "POST", + "/oagw/v1/routes", + ctx.clone(), + Some(body), + ) + .await; + } + + let filter = format!("$filter=upstream_id%20eq%20'{u1_id}'"); + let (status, _, json) = + send_auth(app, "GET", &format!("/oagw/v1/routes?{filter}"), ctx, None).await; + assert_eq!(status, StatusCode::OK); + let items = json["items"].as_array().unwrap(); + assert_eq!(items.len(), 1); + assert_eq!(items[0]["upstream_id"], u1_id); +} + +#[tokio::test] +async fn proxy_route_is_registered_and_answers_404() { + let app = router(); + let ctx = ctx_for(1); + let (status, headers, json) = + send_auth(app, "GET", "/oagw/v1/proxy/my-alias/some/rest", ctx, None).await; + assert_eq!(status, StatusCode::NOT_FOUND); + assert_eq!(headers.get("x-oagw-error-source").unwrap(), "gateway"); + assert_eq!( + json["type"], + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); + assert_eq!(json["alias"], "my-alias"); +} + +// --------------------------------------------------------------------------- +// Rf-012 / Rf-016 regression tests (semantic review) +// --------------------------------------------------------------------------- + +#[tokio::test] +async fn put_upstream_on_ip_based_alias_without_alias_is_tolerated() { + let app = router(); + let ctx = ctx_for(1); + // An IP-based upstream cannot derive an alias; the create carries one + // explicitly. + let create_body = serde_json::json!({ + "alias": "my-ip-api", + "server": { "endpoints": [{ "scheme": "https", "host": "127.0.0.1", "port": 443 }] }, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + }); + let (status, _, created) = send_auth( + app.clone(), + "POST", + "/oagw/v1/upstreams", + ctx.clone(), + Some(create_body), + ) + .await; + assert_eq!(status, StatusCode::CREATED); + let id = created["id"].as_str().unwrap().to_owned(); + assert_eq!(created["alias"], "my-ip-api"); + + // PUT omitting the alias (still IP-based, so nothing to derive from): + // the stored alias is carried over instead of failing validation. + let put_body = serde_json::json!({ + "server": { "endpoints": [{ "scheme": "https", "host": "127.0.0.1", "port": 443 }] }, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + }); + let (status, _, json) = send_auth( + app, + "PUT", + &format!("/oagw/v1/upstreams/{id}"), + ctx, + Some(put_body), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(json["alias"], "my-ip-api"); +} + +#[tokio::test] +async fn proxy_echoes_caller_x_request_id_and_trace_id_on_gateway_error() { + let app = router(); + let ctx = ctx_for(1); + let (status, headers, json) = send_auth_headered( + app, + "GET", + "/oagw/v1/proxy/my-alias/some/rest", + ctx, + &[("x-request-id", "caller-trace-42")], + None, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + // The caller-supplied id is echoed verbatim... + assert_eq!(headers.get("x-request-id").unwrap(), "caller-trace-42"); + // ...and lands in the RFC 9457 `trace_id` member of gateway problems. + assert_eq!(json["trace_id"], "caller-trace-42"); + assert_eq!( + json["type"], + "gts.cf.core.errors.err.v1~cf.oagw.route.not_found.v1" + ); +} + +#[tokio::test] +async fn proxy_generates_x_request_id_and_trace_id_for_gateway_errors() { + let app = router(); + let ctx = ctx_for(1); + let (status, headers, json) = + send_auth(app, "GET", "/oagw/v1/proxy/nope/x", ctx, None).await; + assert_eq!(status, StatusCode::NOT_FOUND); + // No caller id: a fresh correlation id is generated and echoed. + let rid = headers.get("x-request-id").unwrap().to_str().unwrap(); + assert!(Uuid::parse_str(rid).is_ok(), "generated id must be a UUID"); + assert_eq!(json["trace_id"], rid); +}