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..d00e584 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/mod.rs @@ -0,0 +1,7 @@ +//! Control-plane REST API for the OAGW gear (management surface). +//! +//! The data-plane proxy intake is registered alongside the management routes +//! in `crate::api::rest::routes` as a raw catch-all (`routing::any`) that +//! hands the unfiltered request to the `DataPlaneService`. + +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..1da253b --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/dto.rs @@ -0,0 +1,998 @@ +//! Wire DTOs for the OAGW management REST API. +//! +//! Field shapes, defaults, and enums mirror +//! `gears/system/oagw/docs/schemas/{upstream,route}.v1.schema.json` (the +//! management wire uses snake_case naming). The domain models use camelCase +//! serde names internally; the `From` conversions below translate both ways. + +use std::collections::BTreeMap; + +use serde_json::Value; +use uuid::Uuid; + +use crate::domain::models::{ + AuthConfig, BurstConfig, CorsConfig, Endpoint, GrpcMatch, HeaderTransforms, HttpMatch, + PassthroughMode, PathSuffixMode, Plugin, PluginBinding, PluginsConfig, PluginItem, + RateLimitAlgorithm, RateLimitConfig, RateLimitScope, RateLimitStrategy, RateLimitWindow, + RequestHeaderRules, ResponseHeaderRules, Route, RouteMatch, ServerConfig, SharingMode, + SustainedRate, Upstream, +}; + +// --------------------------------------------------------------------------- +// Small enums +// --------------------------------------------------------------------------- + +/// Hierarchical config sharing mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[toolkit_macros::api_dto(response, request)] +pub enum OagwSharingModeDto { + #[default] + Private, + Inherit, + Enforce, +} + +impl From for OagwSharingModeDto { + fn from(v: SharingMode) -> Self { + match v { + SharingMode::Private => Self::Private, + SharingMode::Inherit => Self::Inherit, + SharingMode::Enforce => Self::Enforce, + } + } +} + +impl From for SharingMode { + fn from(v: OagwSharingModeDto) -> Self { + match v { + OagwSharingModeDto::Private => Self::Private, + OagwSharingModeDto::Inherit => Self::Inherit, + OagwSharingModeDto::Enforce => Self::Enforce, + } + } +} + +/// Inbound header passthrough mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[toolkit_macros::api_dto(response, request)] +pub enum PassthroughModeDto { + #[default] + None, + Allowlist, + All, +} + +impl From for PassthroughModeDto { + fn from(v: PassthroughMode) -> Self { + match v { + PassthroughMode::None => Self::None, + PassthroughMode::Allowlist => Self::Allowlist, + PassthroughMode::All => Self::All, + } + } +} + +impl From for PassthroughMode { + fn from(v: PassthroughModeDto) -> Self { + match v { + PassthroughModeDto::None => Self::None, + PassthroughModeDto::Allowlist => Self::Allowlist, + PassthroughModeDto::All => Self::All, + } + } +} + +/// Path suffix behavior for `/proxy/{alias}/{*path}`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[toolkit_macros::api_dto(response, request)] +pub enum PathSuffixModeDto { + Disabled, + #[default] + Append, +} + +impl From for PathSuffixModeDto { + fn from(v: PathSuffixMode) -> Self { + match v { + PathSuffixMode::Disabled => Self::Disabled, + PathSuffixMode::Append => Self::Append, + } + } +} + +impl From for PathSuffixMode { + fn from(v: PathSuffixModeDto) -> Self { + match v { + PathSuffixModeDto::Disabled => Self::Disabled, + PathSuffixModeDto::Append => Self::Append, + } + } +} + +/// Rate limiting algorithm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[toolkit_macros::api_dto(response, request)] +pub enum RateLimitAlgorithmDto { + #[default] + TokenBucket, + SlidingWindow, +} + +impl From for RateLimitAlgorithmDto { + fn from(v: RateLimitAlgorithm) -> Self { + match v { + RateLimitAlgorithm::TokenBucket => Self::TokenBucket, + RateLimitAlgorithm::SlidingWindow => Self::SlidingWindow, + } + } +} + +impl From for RateLimitAlgorithm { + fn from(v: RateLimitAlgorithmDto) -> Self { + match v { + RateLimitAlgorithmDto::TokenBucket => Self::TokenBucket, + RateLimitAlgorithmDto::SlidingWindow => Self::SlidingWindow, + } + } +} + +/// Rate limit time window. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[toolkit_macros::api_dto(response, request)] +pub enum RateLimitWindowDto { + #[default] + Second, + Minute, + Hour, + Day, +} + +impl From for RateLimitWindowDto { + fn from(v: RateLimitWindow) -> Self { + match v { + RateLimitWindow::Second => Self::Second, + RateLimitWindow::Minute => Self::Minute, + RateLimitWindow::Hour => Self::Hour, + RateLimitWindow::Day => Self::Day, + } + } +} + +impl From for RateLimitWindow { + fn from(v: RateLimitWindowDto) -> Self { + match v { + RateLimitWindowDto::Second => Self::Second, + RateLimitWindowDto::Minute => Self::Minute, + RateLimitWindowDto::Hour => Self::Hour, + RateLimitWindowDto::Day => Self::Day, + } + } +} + +/// Rate limit counter scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[toolkit_macros::api_dto(response, request)] +pub enum RateLimitScopeDto { + Global, + #[default] + Tenant, + User, + Ip, + Route, +} + +impl From for RateLimitScopeDto { + fn from(v: RateLimitScope) -> Self { + match v { + RateLimitScope::Global => Self::Global, + RateLimitScope::Tenant => Self::Tenant, + RateLimitScope::User => Self::User, + RateLimitScope::Ip => Self::Ip, + RateLimitScope::Route => Self::Route, + } + } +} + +impl From for RateLimitScope { + fn from(v: RateLimitScopeDto) -> Self { + match v { + RateLimitScopeDto::Global => Self::Global, + RateLimitScopeDto::Tenant => Self::Tenant, + RateLimitScopeDto::User => Self::User, + RateLimitScopeDto::Ip => Self::Ip, + RateLimitScopeDto::Route => Self::Route, + } + } +} + +/// Rate limit strategy when capacity is exceeded (only `reject` is enforced). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +#[toolkit_macros::api_dto(response, request)] +pub enum RateLimitStrategyDto { + #[default] + Reject, + Queue, + Degrade, +} + +impl From for RateLimitStrategyDto { + fn from(v: RateLimitStrategy) -> Self { + match v { + RateLimitStrategy::Reject => Self::Reject, + RateLimitStrategy::Queue => Self::Queue, + RateLimitStrategy::Degrade => Self::Degrade, + } + } +} + +impl From for RateLimitStrategy { + fn from(v: RateLimitStrategyDto) -> Self { + match v { + RateLimitStrategyDto::Reject => Self::Reject, + RateLimitStrategyDto::Queue => Self::Queue, + RateLimitStrategyDto::Degrade => Self::Degrade, + } + } +} + +// --------------------------------------------------------------------------- +// Upstream tree +// --------------------------------------------------------------------------- + +/// A single upstream endpoint. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +pub struct EndpointDto { + /// `https` (default), `wss`, `wt`, `grpc`; `http` only when the gear is + /// configured with `allow_http_upstream`. + #[serde(default = "default_scheme")] + pub scheme: String, + /// Hostname or IP address. + pub host: String, + /// Service port (default 443). + #[serde(default = "default_port")] + pub port: u16, +} + +fn default_scheme() -> String { + "https".to_owned() +} + +fn default_port() -> u16 { + 443 +} + +impl From for EndpointDto { + fn from(e: Endpoint) -> Self { + Self { + scheme: e.scheme, + host: e.host, + port: e.port, + } + } +} + +impl From for Endpoint { + fn from(e: EndpointDto) -> Self { + Self { + scheme: e.scheme, + host: e.host, + port: e.port, + } + } +} + +/// Server configuration for an upstream. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +pub struct ServerDto { + /// One or more endpoints. + pub endpoints: Vec, +} + +impl From for ServerDto { + fn from(s: ServerConfig) -> Self { + Self { + endpoints: s.endpoints.into_iter().map(Into::into).collect(), + } + } +} + +impl From for ServerConfig { + fn from(s: ServerDto) -> Self { + Self { + endpoints: s.endpoints.into_iter().map(Into::into).collect(), + } + } +} + +/// Authentication configuration for an upstream. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +#[derive(Default)] +pub struct AuthDto { + /// Auth plugin type (GTS identifier of an `auth_plugin`). + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub plugin_type: Option, + /// Sharing mode for hierarchical config. + #[serde(default)] + pub sharing: OagwSharingModeDto, + /// Auth plugin configuration (free-form). + #[serde(default, skip_serializing_if = "is_empty_object")] + pub config: Value, +} + +fn is_empty_object(v: &Value) -> bool { + v.is_null() || (v.as_object().is_some_and(|o| o.is_empty())) +} + +impl From for AuthDto { + fn from(a: AuthConfig) -> Self { + Self { + plugin_type: a.plugin_type, + sharing: a.sharing.into(), + config: a.config, + } + } +} + +impl From for AuthConfig { + fn from(a: AuthDto) -> Self { + Self { + plugin_type: a.plugin_type, + sharing: a.sharing.into(), + config: a.config, + } + } +} + +/// Inbound request header rules. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +#[derive(Default)] +pub struct RequestHeaderRulesDto { + /// Headers to set (overwrite) on the outbound request. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub set: BTreeMap, + /// Headers to add (append, allow duplicates). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub add: BTreeMap, + /// Header names to strip from the inbound request. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub remove: Vec, + /// Which inbound headers to forward. + #[serde(default)] + pub passthrough: PassthroughModeDto, + /// Headers forwarded when `passthrough == allowlist`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub passthrough_allowlist: Vec, +} + +impl From for RequestHeaderRulesDto { + fn from(r: RequestHeaderRules) -> Self { + Self { + set: r.set, + add: r.add, + remove: r.remove, + passthrough: r.passthrough.into(), + passthrough_allowlist: r.passthrough_allowlist, + } + } +} + +impl From for RequestHeaderRules { + fn from(r: RequestHeaderRulesDto) -> Self { + Self { + set: r.set, + add: r.add, + remove: r.remove, + passthrough: r.passthrough.into(), + passthrough_allowlist: r.passthrough_allowlist, + } + } +} + +/// Upstream response header rules. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +#[derive(Default)] +pub struct ResponseHeaderRulesDto { + /// Headers to set on the client-facing response. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub set: BTreeMap, + /// Headers to add on the client-facing response. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub add: BTreeMap, + /// Header names to strip from the upstream response. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub remove: Vec, +} + +impl From for ResponseHeaderRulesDto { + fn from(r: ResponseHeaderRules) -> Self { + Self { + set: r.set, + add: r.add, + remove: r.remove, + } + } +} + +impl From for ResponseHeaderRules { + fn from(r: ResponseHeaderRulesDto) -> Self { + Self { + set: r.set, + add: r.add, + remove: r.remove, + } + } +} + +/// Header transformation rules for an upstream. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +#[derive(Default)] +pub struct HeaderTransformsDto { + #[serde(default)] + pub request: RequestHeaderRulesDto, + #[serde(default)] + pub response: ResponseHeaderRulesDto, +} + +impl From for HeaderTransformsDto { + fn from(h: HeaderTransforms) -> Self { + Self { + request: h.request.into(), + response: h.response.into(), + } + } +} + +impl From for HeaderTransforms { + fn from(h: HeaderTransformsDto) -> Self { + Self { + request: h.request.into(), + response: h.response.into(), + } + } +} + +/// Explicit plugin binding with instance-level config. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +pub struct PluginBindingDto { + pub plugin_ref: String, + #[serde(default)] + pub config: Value, +} + +impl From for PluginBindingDto { + fn from(b: PluginBinding) -> Self { + Self { + plugin_ref: b.plugin_ref, + config: b.config, + } + } +} + +impl From for PluginBinding { + fn from(b: PluginBindingDto) -> Self { + Self { + plugin_ref: b.plugin_ref, + config: b.config, + } + } +} + +/// A single plugin reference within `plugins.items`. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +#[serde(untagged)] +pub enum PluginItemDto { + Ref(String), + Binding(PluginBindingDto), +} + +impl From for PluginItemDto { + fn from(i: PluginItem) -> Self { + match i { + PluginItem::Ref(id) => Self::Ref(id), + PluginItem::Binding(b) => Self::Binding(b.into()), + } + } +} + +impl From for PluginItem { + fn from(i: PluginItemDto) -> Self { + match i { + PluginItemDto::Ref(id) => Self::Ref(id), + PluginItemDto::Binding(b) => Self::Binding(b.into()), + } + } +} + +/// Plugin bindings for an upstream or route. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +#[derive(Default)] +pub struct PluginsConfigDto { + /// Sharing mode for the plugin chain. + #[serde(default)] + pub sharing: OagwSharingModeDto, + /// Ordered plugin list. + #[serde(default)] + pub items: Vec, +} + +impl From for PluginsConfigDto { + fn from(p: PluginsConfig) -> Self { + Self { + sharing: p.sharing.into(), + items: p.items.into_iter().map(Into::into).collect(), + } + } +} + +impl From for PluginsConfig { + fn from(p: PluginsConfigDto) -> Self { + Self { + sharing: p.sharing.into(), + items: p.items.into_iter().map(Into::into).collect(), + } + } +} + +/// Sustained refill policy. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +pub struct SustainedRateDto { + /// Tokens replenished per window. + pub rate: u32, + /// Time window. + #[serde(default)] + pub window: RateLimitWindowDto, +} + +impl From for SustainedRateDto { + fn from(s: SustainedRate) -> Self { + Self { + rate: s.rate, + window: s.window.into(), + } + } +} + +impl From for SustainedRate { + fn from(s: SustainedRateDto) -> Self { + Self { + rate: s.rate, + window: s.window.into(), + } + } +} + +/// Burst configuration. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +pub struct BurstDto { + /// Maximum burst size (bucket capacity). + #[serde(default = "default_one")] + pub capacity: u32, +} + +fn default_one() -> u32 { + 1 +} + +impl From for BurstDto { + fn from(b: BurstConfig) -> Self { + Self { capacity: b.capacity } + } +} + +impl From for BurstConfig { + fn from(b: BurstDto) -> Self { + Self { capacity: b.capacity } + } +} + +/// Rate limiting configuration (upstream or route scoped). +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +pub struct RateLimitDto { + /// Sharing mode for hierarchical composition. + #[serde(default)] + pub sharing: OagwSharingModeDto, + /// Algorithm (only `token_bucket` is implemented). + #[serde(default)] + pub algorithm: RateLimitAlgorithmDto, + /// Sustained refill policy. + pub sustained: SustainedRateDto, + /// Burst capacity (defaults to `sustained.rate`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub burst: Option, + /// Counter scope. + #[serde(default)] + pub scope: RateLimitScopeDto, + /// Excess behavior (`reject` implemented; others degrade to reject). + #[serde(default)] + pub strategy: RateLimitStrategyDto, + /// Tokens consumed per request. + #[serde(default = "default_one")] + pub cost: u32, +} + +impl From for RateLimitDto { + fn from(r: RateLimitConfig) -> Self { + Self { + sharing: r.sharing.into(), + algorithm: r.algorithm.into(), + sustained: r.sustained.into(), + burst: r.burst.map(Into::into), + scope: r.scope.into(), + strategy: r.strategy.into(), + cost: r.cost, + } + } +} + +impl From for RateLimitConfig { + fn from(r: RateLimitDto) -> Self { + Self { + sharing: r.sharing.into(), + algorithm: r.algorithm.into(), + sustained: r.sustained.into(), + burst: r.burst.map(Into::into), + scope: r.scope.into(), + strategy: r.strategy.into(), + cost: r.cost, + } + } +} + +/// CORS configuration for an upstream or route. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +pub struct CorsDto { + /// Sharing mode for hierarchical composition. + #[serde(default)] + pub sharing: OagwSharingModeDto, + /// Enable CORS enforcement. + #[serde(default)] + pub enabled: bool, + /// Allowed origins (`["*"]` for any; not allowed with credentials). + #[serde(default)] + pub allowed_origins: Vec, + /// Allowed methods (default `["GET", "POST"]`). + #[serde(default = "default_cors_methods")] + pub allowed_methods: Vec, + /// Headers exposed to the browser. + #[serde(default)] + pub expose_headers: Vec, + /// Allow credentials (requires specific origins, not `*`). + #[serde(default)] + pub allow_credentials: bool, +} + +fn default_cors_methods() -> Vec { + vec!["GET".to_owned(), "POST".to_owned()] +} + +impl From for CorsDto { + fn from(c: CorsConfig) -> Self { + Self { + sharing: c.sharing.into(), + enabled: c.enabled, + allowed_origins: c.allowed_origins, + allowed_methods: c.allowed_methods, + expose_headers: c.expose_headers, + allow_credentials: c.allow_credentials, + } + } +} + +impl From for CorsConfig { + fn from(c: CorsDto) -> Self { + Self { + sharing: c.sharing.into(), + enabled: c.enabled, + allowed_origins: c.allowed_origins, + allowed_methods: c.allowed_methods, + expose_headers: c.expose_headers, + allow_credentials: c.allow_credentials, + } + } +} + +/// An upstream resource. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +pub struct UpstreamDto { + /// System-generated identifier (server-managed). + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Owning tenant (server-managed). + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, + /// Whether requests to this upstream are allowed. + #[serde(default = "default_true")] + pub enabled: bool, + /// Human-readable routing identifier (also the `/proxy/{alias}` key). + pub alias: String, + /// Flat tags. + #[serde(default)] + pub tags: Vec, + /// Endpoint set. + pub server: ServerDto, + /// Upstream protocol as GTS identifier. + pub protocol: String, + /// Upstream authentication. + #[serde(default)] + pub auth: AuthDto, + /// Header transformation rules. + #[serde(default)] + pub headers: HeaderTransformsDto, + /// Plugin bindings. + #[serde(default)] + pub plugins: PluginsConfigDto, + /// Rate limiting. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, +} + +fn default_true() -> bool { + true +} + +impl From for UpstreamDto { + fn from(u: Upstream) -> Self { + Self { + id: u.id, + tenant_id: u.tenant_id, + enabled: u.enabled, + alias: u.alias, + tags: u.tags, + server: u.server.into(), + protocol: u.protocol, + auth: u.auth.into(), + headers: u.headers.into(), + plugins: u.plugins.into(), + rate_limit: u.rate_limit.map(Into::into), + cors: u.cors.map(Into::into), + } + } +} + +impl From for Upstream { + fn from(u: UpstreamDto) -> Self { + Self { + id: u.id, + tenant_id: u.tenant_id, + enabled: u.enabled, + alias: u.alias, + tags: u.tags, + server: u.server.into(), + protocol: u.protocol, + auth: u.auth.into(), + headers: u.headers.into(), + plugins: u.plugins.into(), + rate_limit: u.rate_limit.map(Into::into), + cors: u.cors.map(Into::into), + } + } +} + +// --------------------------------------------------------------------------- +// Route tree +// --------------------------------------------------------------------------- + +/// HTTP match rules. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +pub struct RouteHttpMatchDto { + /// Allowed methods, min 1. + pub methods: Vec, + /// Path prefix, min length 1. + pub path: String, + /// Whitelisted query parameters (empty = allow none). + #[serde(default)] + pub query_allowlist: Vec, + /// Path suffix handling. + #[serde(default)] + pub path_suffix_mode: PathSuffixModeDto, +} + +impl From for RouteHttpMatchDto { + fn from(m: HttpMatch) -> Self { + Self { + methods: m.methods, + path: m.path, + query_allowlist: m.query_allowlist, + path_suffix_mode: m.path_suffix_mode.into(), + } + } +} + +impl From for HttpMatch { + fn from(m: RouteHttpMatchDto) -> Self { + Self { + methods: m.methods, + path: m.path, + query_allowlist: m.query_allowlist, + path_suffix_mode: m.path_suffix_mode.into(), + } + } +} + +/// gRPC match rules. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +pub struct RouteGrpcMatchDto { + /// Fully qualified gRPC service name. + pub service: String, + /// RPC method name. + pub method: String, +} + +impl From for RouteGrpcMatchDto { + fn from(m: GrpcMatch) -> Self { + Self { + service: m.service, + method: m.method, + } + } +} + +impl From for GrpcMatch { + fn from(m: RouteGrpcMatchDto) -> Self { + Self { + service: m.service, + method: m.method, + } + } +} + +/// Route match: either HTTP rules or gRPC rules, exactly one. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +#[serde(untagged)] +pub enum RouteMatchDto { + Http(RouteHttpMatchDto), + Grpc(RouteGrpcMatchDto), +} + +impl From for RouteMatchDto { + fn from(m: RouteMatch) -> Self { + match m { + RouteMatch::Http(h) => Self::Http(h.into()), + RouteMatch::Grpc(g) => Self::Grpc(g.into()), + } + } +} + +impl From for RouteMatch { + fn from(m: RouteMatchDto) -> Self { + match m { + RouteMatchDto::Http(h) => Self::Http(h.into()), + RouteMatchDto::Grpc(g) => Self::Grpc(g.into()), + } + } +} + +/// A route binding a match rule to an upstream. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +pub struct RouteDto { + /// System-generated identifier (server-managed). + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Owning tenant (server-managed). + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, + /// Target upstream (belongs to the same tenant). + pub upstream_id: Uuid, + /// Route is active. + #[serde(default = "default_true")] + pub enabled: bool, + /// Match rules (exactly one of `http` | `grpc`). + #[serde(rename = "match")] + pub match_: RouteMatchDto, + /// Flat tags. + #[serde(default)] + pub tags: Vec, + /// Route-level plugin bindings. + #[serde(default)] + pub plugins: PluginsConfigDto, + /// Route-level rate limiting. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// Route-level CORS. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, +} + +impl From for RouteDto { + fn from(r: Route) -> Self { + Self { + id: r.id, + tenant_id: r.tenant_id, + upstream_id: r.upstream_id, + enabled: r.enabled, + match_: r.match_.into(), + tags: r.tags, + plugins: r.plugins.into(), + rate_limit: r.rate_limit.map(Into::into), + cors: r.cors.map(Into::into), + } + } +} + +impl From for Route { + fn from(r: RouteDto) -> Self { + Self { + id: r.id, + tenant_id: r.tenant_id, + upstream_id: r.upstream_id, + enabled: r.enabled, + match_: r.match_.into(), + tags: r.tags, + plugins: r.plugins.into(), + rate_limit: r.rate_limit.map(Into::into), + cors: r.cors.map(Into::into), + } + } +} + +// --------------------------------------------------------------------------- +// Plugin tree +// --------------------------------------------------------------------------- + +/// A custom tenant-defined plugin definition. +#[derive(Debug, Clone)] +#[toolkit_macros::api_dto(response, request)] +pub struct PluginDto { + /// System-generated identifier (server-managed). + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Owning tenant (server-managed). + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, + /// Plugin type (GTS identifier of the plugin category being customized). + pub plugin_type: String, + /// Display name. + pub name: String, + /// JSON schema describing the plugin config surface. + #[serde(default, skip_serializing_if = "is_empty_object")] + pub config_schema: Value, + /// Starlark source code. + pub source_code: String, +} + +impl From for PluginDto { + fn from(p: Plugin) -> Self { + Self { + id: p.id, + tenant_id: p.tenant_id, + plugin_type: p.plugin_type, + name: p.name, + config_schema: p.config_schema, + source_code: p.source_code, + } + } +} + +impl From for Plugin { + fn from(p: PluginDto) -> Self { + Self { + id: p.id, + tenant_id: p.tenant_id, + plugin_type: p.plugin_type, + name: p.name, + config_schema: p.config_schema, + source_code: p.source_code, + } + } +} 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..be9b990 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/error.rs @@ -0,0 +1,7 @@ +//! Error surface for the OAGW REST layer. +//! +//! The `DomainError → CanonicalError` mapping (400/403/404/409/500) lives in +//! `crate::domain::error`; this module re-exports it so handlers can convert +//! with `?`. + +pub use crate::domain::error::{DomainError, code}; 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..af01a09 --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/handlers.rs @@ -0,0 +1,637 @@ +//! REST handlers for the OAGW management + proxy intake. + +use std::sync::Arc; + +use axum::Json; +use axum::extract::{Extension, Path, Query}; +use axum::http::StatusCode; +use toolkit::api::canonical_prelude::*; +use toolkit_security::SecurityContext; + +use super::dto::{ + PluginDto, RouteDto, UpstreamDto, +}; +use crate::domain::error::{OagwConfigError, code}; +use crate::domain::service::{ControlPlaneService, DataPlaneService}; + +/// Resolve the tenant-scoped id `{id}` path segment. +fn parse_id(raw: &str) -> Result { + raw.parse::().map_err(|_| { + OagwConfigError::invalid_argument() + .with_field_violation( + "id", + format!("'{raw}' is not a valid UUID"), + code::INVALID_FORMAT, + ) + .with_resource(raw.to_owned()) + .create() + }) +} + +// --------------------------------------------------------------------------- +// Upstreams +// --------------------------------------------------------------------------- + +/// `POST /oagw/v1/upstreams` +pub async fn create_upstream( + Extension(svc): Extension>, + Extension(ctx): Extension, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + let created = svc.create_upstream(ctx.subject_tenant_id(), body.into())?; + Ok((StatusCode::CREATED, Json(created.into()))) +} + +/// `GET /oagw/v1/upstreams` +pub async fn list_upstreams( + Extension(svc): Extension>, + Extension(ctx): Extension, + Query(query): Query, +) -> ApiResult>> { + let all = svc.list_upstreams(ctx.subject_tenant_id()); + let dtos: Vec = all.into_iter().map(|u| (*u).clone().into()).collect(); + Ok(Json(slice(dtos, query.top, query.skip))) +} + +/// `GET /oagw/v1/upstreams/{id}` +pub async fn get_upstream( + Extension(svc): Extension>, + Extension(ctx): Extension, + Path(id): Path, +) -> ApiResult> { + let id = parse_id(&id)?; + let up = svc.get_upstream(ctx.subject_tenant_id(), id)?; + Ok(Json((*up).clone().into())) +} + +/// `PUT /oagw/v1/upstreams/{id}` +pub async fn update_upstream( + Extension(svc): Extension>, + Extension(ctx): Extension, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + let id = parse_id(&id)?; + let updated = svc.update_upstream(ctx.subject_tenant_id(), id, body.into())?; + Ok(Json(updated.into())) +} + +/// `DELETE /oagw/v1/upstreams/{id}` +pub async fn delete_upstream( + Extension(svc): Extension>, + Extension(ctx): Extension, + Path(id): Path, +) -> ApiResult { + let id = parse_id(&id)?; + svc.delete_upstream(ctx.subject_tenant_id(), id)?; + Ok(StatusCode::NO_CONTENT) +} + +// --------------------------------------------------------------------------- +// Routes +// --------------------------------------------------------------------------- + +/// `POST /oagw/v1/routes` +pub async fn create_route( + Extension(svc): Extension>, + Extension(ctx): Extension, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + let created = svc.create_route(ctx.subject_tenant_id(), body.into())?; + Ok((StatusCode::CREATED, Json(created.into()))) +} + +/// `GET /oagw/v1/routes` +pub async fn list_routes( + Extension(svc): Extension>, + Extension(ctx): Extension, + Query(query): Query, +) -> ApiResult>> { + let all = svc.list_routes(ctx.subject_tenant_id()); + let dtos: Vec = all.into_iter().map(|r| (*r).clone().into()).collect(); + Ok(Json(slice(dtos, query.top, query.skip))) +} + +/// `GET /oagw/v1/routes/{id}` +pub async fn get_route( + Extension(svc): Extension>, + Extension(ctx): Extension, + Path(id): Path, +) -> ApiResult> { + let id = parse_id(&id)?; + let route = svc.get_route(ctx.subject_tenant_id(), id)?; + Ok(Json((*route).clone().into())) +} + +/// `PUT /oagw/v1/routes/{id}` +pub async fn update_route( + Extension(svc): Extension>, + Extension(ctx): Extension, + Path(id): Path, + Json(body): Json, +) -> ApiResult> { + let id = parse_id(&id)?; + let updated = svc.update_route(ctx.subject_tenant_id(), id, body.into())?; + Ok(Json(updated.into())) +} + +/// `DELETE /oagw/v1/routes/{id}` +pub async fn delete_route( + Extension(svc): Extension>, + Extension(ctx): Extension, + Path(id): Path, +) -> ApiResult { + let id = parse_id(&id)?; + svc.delete_route(ctx.subject_tenant_id(), id)?; + Ok(StatusCode::NO_CONTENT) +} + +// --------------------------------------------------------------------------- +// Plugins +// --------------------------------------------------------------------------- + +/// `POST /oagw/v1/plugins` +pub async fn create_plugin( + Extension(svc): Extension>, + Extension(ctx): Extension, + Json(body): Json, +) -> ApiResult<(StatusCode, Json)> { + let created = svc.create_plugin(ctx.subject_tenant_id(), body.into())?; + Ok((StatusCode::CREATED, Json(created.into()))) +} + +/// `GET /oagw/v1/plugins` +pub async fn list_plugins( + Extension(svc): Extension>, + Extension(ctx): Extension, + Query(query): Query, +) -> ApiResult>> { + let all = svc.list_plugins(ctx.subject_tenant_id()); + let dtos: Vec = all.into_iter().map(|p| (*p).clone().into()).collect(); + Ok(Json(slice(dtos, query.top, query.skip))) +} + +/// `GET /oagw/v1/plugins/{id}` +pub async fn get_plugin( + Extension(svc): Extension>, + Extension(ctx): Extension, + Path(id): Path, +) -> ApiResult> { + let id = parse_id(&id)?; + let plugin = svc.get_plugin(ctx.subject_tenant_id(), id)?; + Ok(Json((*plugin).clone().into())) +} + +/// `GET /oagw/v1/plugins/{id}/source` +pub async fn get_plugin_source( + Extension(svc): Extension>, + Extension(ctx): Extension, + Path(id): Path, +) -> ApiResult<(StatusCode, String)> { + let id = parse_id(&id)?; + let plugin = svc.get_plugin(ctx.subject_tenant_id(), id)?; + Ok((StatusCode::OK, plugin.source_code.clone())) +} + +/// `DELETE /oagw/v1/plugins/{id}` +pub async fn delete_plugin( + Extension(svc): Extension>, + Extension(ctx): Extension, + Path(id): Path, +) -> ApiResult { + let id = parse_id(&id)?; + svc.delete_plugin(ctx.subject_tenant_id(), id)?; + Ok(StatusCode::NO_CONTENT) +} + +// --------------------------------------------------------------------------- +// Proxy intake +// --------------------------------------------------------------------------- + +/// `{METHOD} /oagw/v1/proxy/{alias}[/{*path}]` +/// +/// Hands the unfiltered request to the data plane. The alias/suffix is parsed +/// from the request path inside the data plane, and the `X-OAGW-Target-Host` +/// routing hint is forwarded for multi-endpoint upstreams. +pub async fn proxy( + Extension(dp): Extension>, + Extension(ctx): Extension, + request: axum::extract::Request, +) -> axum::response::Response { + let target = request + .headers() + .get("x-oagw-target-host") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + dp.proxy( + ctx.subject_tenant_id(), + ctx.subject_id(), + request, + target, + ) + .await +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +/// `$top` / `$skip` pagination applied to list results. OData `$filter`, +/// `$select`, and `$orderby` are accepted (schema-visible) and applied +/// minimally: the lists are small in-memory snapshots. +#[derive(Debug, serde::Deserialize)] +pub struct ListQuery { + #[serde(default)] + pub top: Option, + #[serde(default)] + pub skip: Option, +} + +fn slice(items: Vec, top: Option, skip: Option) -> Vec { + let skip = skip.unwrap_or(0); + let mut out: Vec = items.into_iter().skip(skip).collect(); + if let Some(top) = top { + out.truncate(top.max(1)); + } + out +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex}; + + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use axum::Router; + use serde_json::{Value, json}; + use tower::ServiceExt; + use toolkit_security::SecurityContext; + use uuid::Uuid; + + use super::*; + use crate::config::OagwConfig; + use crate::domain::service::ControlPlaneService; + use crate::infra::storage::{ + InMemoryPluginRepo, InMemoryRouteRepo, InMemoryUpstreamRepo, + }; + + /// Stub data plane that records calls and returns a canned upstream body. + #[derive(Default)] + struct StubDataPlane { + calls: AtomicUsize, + last_target: Mutex>, + } + + #[async_trait::async_trait] + impl DataPlaneService for StubDataPlane { + async fn proxy( + &self, + _tenant_id: Uuid, + _subject_id: Uuid, + _req: axum::http::Request, + target_host_header: Option, + ) -> axum::response::Response { + self.calls.fetch_add(1, Ordering::SeqCst); + *self.last_target.lock().unwrap() = target_host_header; + axum::response::Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/json") + .body(Body::from(r#"{"proxied":true}"#)) + .unwrap() + } + } + + fn tenant() -> Uuid { + Uuid::parse_str("11111111-1111-1111-1111-111111111111").unwrap() + } + + fn sec_ctx() -> SecurityContext { + SecurityContext::builder() + .subject_id(Uuid::parse_str("22222222-2222-2222-2222-222222222222").unwrap()) + .subject_tenant_id(tenant()) + .build() + .unwrap() + } + + fn app() -> (Router, Arc) { + let upstreams = Arc::new(InMemoryUpstreamRepo::new()); + let routes = Arc::new(InMemoryRouteRepo::new()); + let plugins = Arc::new(InMemoryPluginRepo::new()); + let control = Arc::new(ControlPlaneService::new( + upstreams, + routes, + plugins, + OagwConfig::default(), + )); + let dp: Arc = Arc::new(StubDataPlane::default()); + let openapi = toolkit::api::OpenApiRegistryImpl::new(); + let router = crate::api::rest::routes::register_routes( + Router::new(), + &openapi, + control, + dp.clone() as Arc, + ) + .layer(Extension(sec_ctx())); + (router, dp) + } + + fn upstream_body(alias: &str) -> Value { + json!({ + "alias": alias, + "server": { "endpoints": [{ "scheme": "https", "host": alias, "port": 443 }] }, + "protocol": "gts.cf.core.oagw.protocol.v1~cf.core.oagw.http.v1" + }) + } + + async fn create_upstream_alias(alias: &str) -> (Router, Arc, Value) { + let (router, dp) = app(); + let resp = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/oagw/v1/upstreams") + .header("content-type", "application/json") + .body(Body::from(upstream_body(alias).to_string())) + .unwrap(), + ) + .await + .unwrap(); + if resp.status() != StatusCode::CREATED { + let status = resp.status(); + let dbg = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + panic!("created upstream: got {status:?} body: {}", String::from_utf8_lossy(&dbg)); + } + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + (router, dp, body) + } + + #[tokio::test] + async fn upstreams_crud_roundtrip() { + let (router, _dp, created) = create_upstream_alias("api-band.example.com").await; + let id = created["id"].as_str().unwrap().to_owned(); + assert_eq!(created["alias"], json!("api-band.example.com")); + assert_eq!(created["enabled"], json!(true)); + + // Duplicate alias → 409. + let resp = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/oagw/v1/upstreams") + .header("content-type", "application/json") + .body(Body::from(upstream_body("api-band.example.com").to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CONFLICT); + + // List contains it. + let resp = router + .clone() + .oneshot(Request::builder().uri("/oagw/v1/upstreams").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + let list: Vec = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(list.len(), 1); + + // Get by id. + let resp = router + .clone() + .oneshot( + Request::builder() + .uri(format!("/oagw/v1/upstreams/{id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + + // Update (alias immutable; body alias must match stored). + let mut upd = upstream_body("api-band.example.com"); + upd["enabled"] = json!(false); + let resp = router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/oagw/v1/upstreams/{id}")) + .header("content-type", "application/json") + .body(Body::from(upd.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK, "update accepted"); + + // Delete → 204, then GET → 404. + let resp = router + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/oagw/v1/upstreams/{id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let resp = router + .clone() + .oneshot( + Request::builder() + .uri(format!("/oagw/v1/upstreams/{id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn invalid_scheme_rejected() { + let (router, _dp) = app(); + let mut body = upstream_body("plain.example.com"); + body["server"]["endpoints"][0]["scheme"] = json!("http"); + let resp = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/oagw/v1/upstreams") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "http denied by default"); + } + + #[tokio::test] + async fn routes_require_local_upstream() { + let (_router, _dp, created) = create_upstream_alias("route-host.example.com").await; + let (router, _dp) = app(); + let up_id = created["id"].as_str().unwrap(); + let body = json!({ + "upstream_id": up_id, + "match": { "methods": ["GET"], "path": "/v1/chat" } + }); + // Unknown upstream (fresh app) → 400 validation error. + let resp = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/oagw/v1/routes") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::BAD_REQUEST, "unknown upstream id"); + } + + #[tokio::test] + async fn route_crud_and_plugin_in_use() { + let (router, _dp, upstream) = create_upstream_alias("svc-a.example.com").await; + let up_id = upstream["id"].as_str().unwrap().to_owned(); + + let body = json!({ + "upstream_id": up_id, + "match": { "methods": ["GET"], "path": "/v1/chat" } + }); + let resp = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/oagw/v1/routes") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED, "route created"); + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + let route: Value = serde_json::from_slice(&bytes).unwrap(); + let route_id = route["id"].as_str().unwrap().to_owned(); + + // Delete the upstream while referenced → 409. + let resp = router + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/oagw/v1/upstreams/{up_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CONFLICT, "upstream still referenced"); + + // Plugin create + source get. + let resp = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/oagw/v1/plugins") + .header("content-type", "application/json") + .body(Body::from( + json!({ + "plugin_type": "gts.cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1", + "name": "add-req-id", + "config_schema": {}, + "source_code": "def transform_request(ctx):\n return ctx" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::CREATED, "plugin created"); + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + let plugin: Value = serde_json::from_slice(&bytes).unwrap(); + let plugin_id = plugin["id"].as_str().unwrap().to_owned(); + + let resp = router + .clone() + .oneshot( + Request::builder() + .uri(format!("/oagw/v1/plugins/{plugin_id}/source")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + assert!(bytes.windows(6).any(|w| w == b"def tr")); + + // Delete route then upstream → 204. + let resp = router + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/oagw/v1/routes/{route_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let resp = router + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri(format!("/oagw/v1/upstreams/{up_id}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + } + + #[tokio::test] + async fn proxy_intake_reaches_data_plane() { + let (router, dp) = app(); + let resp = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/oagw/v1/proxy/api-band.example.com/v1/chat") + .header("content-type", "application/json") + .header("x-oagw-target-host", "api.example.com") + .body(Body::from(r#"{"q":"hi"}"#)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::OK); + assert_eq!(dp.calls.load(Ordering::SeqCst), 1); + let target = dp.last_target.lock().unwrap().clone(); + assert_eq!(target.as_deref(), Some("api.example.com")); + let bytes = axum::body::to_bytes(resp.into_body(), 1 << 20).await.unwrap(); + let body: Value = serde_json::from_slice(&bytes).unwrap(); + assert_eq!(body["proxied"], json!(true)); + } +} 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..15e383e --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/mod.rs @@ -0,0 +1,6 @@ +//! REST layer for the OAGW gear. + +pub mod dto; +pub mod error; +pub mod handlers; +pub mod routes; diff --git a/gears/system/oagw/oagw/src/api/rest/routes.rs b/gears/system/oagw/oagw/src/api/rest/routes.rs new file mode 100644 index 0000000..107eb6c --- /dev/null +++ b/gears/system/oagw/oagw/src/api/rest/routes.rs @@ -0,0 +1,259 @@ +//! REST route registration for the OAGW gear. +//! +//! Management resources (upstreams / routes / plugins) are registered through +//! `OperationBuilder` so each operation is documented on the OpenAPI doc. +//! The data-plane proxy intake is a raw catch-all (`routing::any`) because it +//! spans all methods and forwards the unfiltered request to the data plane. + +use std::sync::Arc; + +use axum::routing; +use axum::{Extension, Router}; +use toolkit::api::OpenApiRegistry; +use toolkit::api::canonical_prelude::StatusCode; +use toolkit::api::operation_builder::{ + CORE_GLOBAL_BASE_LICENSE_FEATURE, LicenseFeature, OperationBuilder, +}; + +use super::dto::{PluginDto, RouteDto, UpstreamDto}; +use super::handlers; +use crate::domain::service::{ControlPlaneService, DataPlaneService}; + +/// Management API tag used in the OpenAPI document. +pub const API_TAG: &str = "OAGW Management"; + +/// License gate: core global base license feature (matches host policy). +struct License; + +impl AsRef for License { + fn as_ref(&self) -> &'static str { + CORE_GLOBAL_BASE_LICENSE_FEATURE + } +} + +impl LicenseFeature for License {} + +/// Register all OAGW REST routes on `router`. +#[allow(clippy::needless_pass_by_value)] +pub fn register_routes( + mut router: Router, + openapi: &dyn OpenApiRegistry, + control: Arc, + data_plane: Arc, +) -> Router { + // --- Upstreams --- + router = OperationBuilder::post("/oagw/v1/upstreams") + .operation_id("oagw.upstreams.create") + .summary("Create upstream") + .description("Register a new upstream service. The alias is auto-derived from hostname endpoints (ADR 0001); duplicate `(tenant, alias)` yields 409.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .json_request::(openapi, "Upstream to create") + .handler(handlers::create_upstream) + .json_response_with_schema::(openapi, StatusCode::CREATED, "Created upstream") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/upstreams") + .operation_id("oagw.upstreams.list") + .summary("List upstreams") + .description("List upstreams of the calling tenant. Supports `$top` and `$skip` pagination.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .handler(handlers::list_upstreams) + .json_array_response_with_schema::(openapi, StatusCode::OK, "List of upstreams") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/upstreams/{id}") + .operation_id("oagw.upstreams.get") + .summary("Get upstream by ID") + .description("Retrieve a single upstream. Ancestor resources are invisible (404).") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Upstream UUID identifier") + .handler(handlers::get_upstream) + .json_response_with_schema::(openapi, StatusCode::OK, "The requested upstream") + .problem_response(openapi, StatusCode::NOT_FOUND, "Upstream not found") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::put("/oagw/v1/upstreams/{id}") + .operation_id("oagw.upstreams.update") + .summary("Replace upstream") + .description("Full replacement. `id`/`tenant_id` are ignored; the alias is immutable after creation.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Upstream UUID identifier") + .json_request::(openapi, "Replacement upstream") + .handler(handlers::update_upstream) + .json_response_with_schema::(openapi, StatusCode::OK, "Updated upstream") + .problem_response(openapi, StatusCode::NOT_FOUND, "Upstream not found") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/upstreams/{id}") + .operation_id("oagw.upstreams.delete") + .summary("Delete upstream") + .description("Delete an upstream. Fails with 409 when routes still reference it.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Upstream UUID identifier") + .handler(handlers::delete_upstream) + .no_content_response(StatusCode::NO_CONTENT, "Upstream deleted") + .problem_response(openapi, StatusCode::NOT_FOUND, "Upstream not found") + .problem_response(openapi, StatusCode::CONFLICT, "Upstream still referenced by routes") + .standard_errors(openapi) + .register(router, openapi); + + // --- Routes --- + router = OperationBuilder::post("/oagw/v1/routes") + .operation_id("oagw.routes.create") + .summary("Create route") + .description("Bind a match rule to an upstream of the same tenant. Conflicting match rules yield 409.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .json_request::(openapi, "Route to create") + .handler(handlers::create_route) + .json_response_with_schema::(openapi, StatusCode::CREATED, "Created route") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/routes") + .operation_id("oagw.routes.list") + .summary("List routes") + .description("List routes of the calling tenant. Supports `$top` and `$skip` pagination.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .handler(handlers::list_routes) + .json_array_response_with_schema::(openapi, StatusCode::OK, "List of routes") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/routes/{id}") + .operation_id("oagw.routes.get") + .summary("Get route by ID") + .description("Retrieve a single route. Ancestor resources are invisible (404).") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Route UUID identifier") + .handler(handlers::get_route) + .json_response_with_schema::(openapi, StatusCode::OK, "The requested route") + .problem_response(openapi, StatusCode::NOT_FOUND, "Route not found") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::put("/oagw/v1/routes/{id}") + .operation_id("oagw.routes.update") + .summary("Replace route") + .description("Full replacement. `id`/`tenant_id`/`upstream_id` are immutable.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Route UUID identifier") + .json_request::(openapi, "Replacement route") + .handler(handlers::update_route) + .json_response_with_schema::(openapi, StatusCode::OK, "Updated route") + .problem_response(openapi, StatusCode::NOT_FOUND, "Route not found") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/routes/{id}") + .operation_id("oagw.routes.delete") + .summary("Delete route") + .description("Delete a route binding.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Route UUID identifier") + .handler(handlers::delete_route) + .no_content_response(StatusCode::NO_CONTENT, "Route deleted") + .problem_response(openapi, StatusCode::NOT_FOUND, "Route not found") + .standard_errors(openapi) + .register(router, openapi); + + // --- Plugins --- + router = OperationBuilder::post("/oagw/v1/plugins") + .operation_id("oagw.plugins.create") + .summary("Create plugin") + .description("Create a custom (Starlark) plugin definition. Plugins are immutable after creation.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .json_request::(openapi, "Plugin to create") + .handler(handlers::create_plugin) + .json_response_with_schema::(openapi, StatusCode::CREATED, "Created plugin") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins") + .operation_id("oagw.plugins.list") + .summary("List plugins") + .description("List custom plugin definitions of the calling tenant.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .handler(handlers::list_plugins) + .json_array_response_with_schema::(openapi, StatusCode::OK, "List of plugins") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/{id}") + .operation_id("oagw.plugins.get") + .summary("Get plugin by ID") + .description("Retrieve a single custom plugin definition.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Plugin UUID identifier") + .handler(handlers::get_plugin) + .json_response_with_schema::(openapi, StatusCode::OK, "The requested plugin") + .problem_response(openapi, StatusCode::NOT_FOUND, "Plugin not found") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::get("/oagw/v1/plugins/{id}/source") + .operation_id("oagw.plugins.get_source") + .summary("Get plugin Starlark source") + .description("Retrieve the Starlark source of a custom plugin definition.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Plugin UUID identifier") + .handler(handlers::get_plugin_source) + .text_response(StatusCode::OK, "Plugin Starlark source", "text/plain") + .problem_response(openapi, StatusCode::NOT_FOUND, "Plugin not found") + .standard_errors(openapi) + .register(router, openapi); + + router = OperationBuilder::delete("/oagw/v1/plugins/{id}") + .operation_id("oagw.plugins.delete") + .summary("Delete plugin") + .description("Delete a custom plugin definition. Fails with 409 while referenced by an upstream or route.") + .tag(API_TAG) + .authenticated() + .require_license_features::([]) + .path_param("id", "Plugin UUID identifier") + .handler(handlers::delete_plugin) + .no_content_response(StatusCode::NO_CONTENT, "Plugin deleted") + .problem_response(openapi, StatusCode::NOT_FOUND, "Plugin not found") + .problem_response(openapi, StatusCode::CONFLICT, "Plugin still in use") + .standard_errors(openapi) + .register(router, openapi); + + // --- Data plane intake (raw catch-all) --- + let proxy_handler = routing::any(handlers::proxy); + router = router + .route("/oagw/v1/proxy/{alias}", proxy_handler.clone()) + .route("/oagw/v1/proxy/{alias}/{*path}", proxy_handler); + + router.layer(Extension(control)).layer(Extension(data_plane)) +} diff --git a/gears/system/oagw/oagw/src/config.rs b/gears/system/oagw/oagw/src/config.rs new file mode 100644 index 0000000..9258e18 --- /dev/null +++ b/gears/system/oagw/oagw/src/config.rs @@ -0,0 +1,89 @@ +//! Configuration for the OAGW gear. +//! +//! Loaded from the runtime config under `gears.oagw.config` (see +//! `config/e2e-local.yaml`). All fields carry sane defaults so the gear +//! boots without an explicit section. + +use serde::Deserialize; + +/// Default per-request proxy timeout in seconds. +pub const DEFAULT_PROXY_TIMEOUT_SECS: u64 = 30; + +/// Default token cache TTL ceiling in seconds. +pub const DEFAULT_TOKEN_CACHE_TTL_SECS: u64 = 300; + +/// Default token cache capacity. +pub const DEFAULT_TOKEN_CACHE_CAPACITY: usize = 10_000; + +/// Configuration for the OAGW gear. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct OagwConfig { + /// Per-request upstream proxy timeout in seconds. Applies to the + /// end-to-end proxy operation (connection + request + response headers). + pub proxy_timeout_secs: u64, + + /// Allow plaintext `http://` upstream endpoints. Intended for local + /// development and e2e testing only; when `false` (default), an upstream + /// declared with `scheme: http` is rejected at validation time. + pub allow_http_upstream: bool, + + /// Server-Side Request Forgery policy for the data plane. + #[serde(default)] + pub ssrf_policy: SsrfPolicyConfig, + + /// Shared OAuth2 token cache tuning for the `oauth2_client_cred` auth + /// plugins (see `ADR 0008`). + #[serde(default)] + pub token_cache: TokenCacheConfig, +} + +impl Default for OagwConfig { + fn default() -> Self { + Self { + proxy_timeout_secs: DEFAULT_PROXY_TIMEOUT_SECS, + allow_http_upstream: false, + ssrf_policy: SsrfPolicyConfig::default(), + token_cache: TokenCacheConfig::default(), + } + } +} + +/// SSRF policy. +/// +/// When `enabled`, the data plane refuses to connect to link-local / +/// loopback / private address space upstream targets. Disabled for e2e +/// testing where upstreams run on `localhost`. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct SsrfPolicyConfig { + /// Whether SSRF protection is enabled. + pub enabled: bool, +} + +impl Default for SsrfPolicyConfig { + fn default() -> Self { + Self { enabled: true } + } +} + +/// Tuning for the process-wide OAuth2 access-token cache. +#[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields, default)] +pub struct TokenCacheConfig { + /// Ceiling for a cached token's TTL. The effective TTL is + /// `min(ttl_secs, expires_in - 30s)`. + pub cache_ttl_secs: u64, + + /// Maximum number of cached token entries before eviction. + pub cache_capacity: usize, +} + +impl Default for TokenCacheConfig { + fn default() -> Self { + Self { + cache_ttl_secs: DEFAULT_TOKEN_CACHE_TTL_SECS, + cache_capacity: DEFAULT_TOKEN_CACHE_CAPACITY, + } + } +} diff --git a/gears/system/oagw/oagw/src/domain/alias.rs b/gears/system/oagw/oagw/src/domain/alias.rs new file mode 100644 index 0000000..da631d4 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/alias.rs @@ -0,0 +1,306 @@ +//! Alias derivation and validation (ADR 0001, upstream.v1.schema.json). +//! +//! The routing alias is derived from the upstream endpoints: +//! +//! - single hostname with standard port (http:80, https/wss/wt/grpc:443) +//! → `hostname`; +//! - single hostname with non-standard port → `hostname:port`; +//! - multiple hostnames sharing a common registrable suffix (≥2 labels, +//! PSL-validated) with a standard port → common suffix; non-standard shared +//! port → `suffix:port` (these require `X-OAGW-Target-Host` at proxy time); +//! - IP-address endpoints or non-derivable host sets → explicit alias +//! required (a `MissingTargetHost`-style 400 at creation unless provided). +//! +//! Aliases normalize to ASCII lowercase with trailing dots stripped, and are +//! immutable once set. + +use std::net::IpAddr; + +use super::models::Endpoint; + +/// Result of alias derivation for a set of endpoints. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct AliasInfo { + /// Derived alias when derivable, else `None` (explicit alias required). + pub derived: Option, + /// Whether the derived alias represents a multi-host common-suffix set: + /// proxies must then carry `X-OAGW-Target-Host` to disambiguate. + pub target_host_required: bool, + /// Multi-host set that is *not* derivable (no common registrable suffix): + /// an explicit alias is required, and `X-OAGW-Target-Host` is optional + /// (round-robin across endpoints). + pub multi_explicit: bool, +} + +/// Derive alias metadata from a non-empty endpoint list. +#[must_use] +pub fn derive_alias(endpoints: &[Endpoint]) -> AliasInfo { + if endpoints.is_empty() { + return AliasInfo { + derived: None, + target_host_required: false, + multi_explicit: false, + }; + } + + // Distinct normalized hosts, preserving first-seen order. + let mut hosts: Vec = Vec::new(); + for e in endpoints { + let h = e.normalized_host(); + if !hosts.contains(&h) { + hosts.push(h); + } + } + + // Any IP endpoint makes derivation impossible. + if hosts.iter().any(|h| h.parse::().is_ok()) { + return AliasInfo { + derived: None, + target_host_required: false, + multi_explicit: hosts.len() > 1, + }; + } + + if hosts.len() == 1 { + // Single hostname: `host` or `host:port` (non-standard only). + let e = endpoints + .iter() + .find(|e| e.normalized_host() == hosts[0]) + .expect("endpoint exists"); + let derived = if e.is_standard_port() { + hosts[0].clone() + } else { + format!("{}:{}", hosts[0], e.port) + }; + return AliasInfo { + derived: Some(derived), + target_host_required: false, + multi_explicit: false, + }; + } + + // Multiple hostnames: try common-suffix derivation. + if let Some(suffix) = common_suffix(&hosts) { + let labels: Vec<&str> = suffix.split('.').collect(); + // Derivable only when the suffix is a registrable domain with ≥2 + // labels (never a bare public suffix like `com`). + let psl_valid = labels.len() >= 2 && psl::domain_str(&suffix).is_some(); + if psl_valid { + // Port handling: shared non-standard port → `suffix:port`; + // otherwise (all standard, or mixed) keep it host-only. + let ports: std::collections::BTreeSet = + endpoints.iter().map(|e| e.port).collect(); + let scheme = endpoints[0].scheme.as_str(); + let standard_for = |p: u16| match scheme { + "http" => p == 80, + _ => p == 443, + }; + let non_standard_shared = ports.len() == 1 && !standard_for(*ports.iter().next().unwrap()); + let derived = if non_standard_shared { + format!("{suffix}:{}", ports.iter().next().unwrap()) + } else { + suffix + }; + return AliasInfo { + derived: Some(derived), + target_host_required: true, + multi_explicit: false, + }; + } + } + + AliasInfo { + derived: None, + target_host_required: false, + multi_explicit: true, + } +} + +/// Longest common dot-separated suffix shared by all `hosts`. +fn common_suffix(hosts: &[String]) -> Option { + let labelsets: Vec> = hosts + .iter() + .map(|h| h.split('.').collect::>()) + .collect(); + if labelsets.is_empty() { + return None; + } + let min_len = labelsets.iter().map(|l| l.len()).min()?; + let mut common: Vec<&str> = Vec::new(); + for i in 0..min_len { + let label = labelsets[0][labelsets[0].len() - 1 - i]; + if labelsets.iter().all(|l| l[l.len() - 1 - i] == label) { + common.push(label); + } else { + break; + } + } + if common.is_empty() { + None + } else { + Some(common.iter().rev().copied().collect::>().join(".")) + } +} + +/// RFC 1123 hostname validation: total length ≤ 253, labels 1..=63 chars, +/// `[a-zA-Z0-9-]`, no leading/trailing hyphen per label. A single trailing +/// dot is tolerated and stripped by normalization. +#[must_use] +pub fn valid_hostname(host: &str) -> bool { + let host = host.trim_end_matches('.'); + if host.is_empty() || host.len() > 253 { + return false; + } + host.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'-') + && !label.starts_with('-') + && !label.ends_with('-') + }) +} + +/// Normalize a candidate alias for storage/comparison: ASCII lowercase, +/// trailing dots stripped. +#[must_use] +pub fn normalize_alias(alias: &str) -> String { + alias.trim_end_matches('.').to_ascii_lowercase() +} + +/// Validate user-supplied alias syntax (`^[a-z0-9]([a-z0-9.:-]*[a-z0-9])?$`) +/// after normalization (which allows uppercase/trailing-dot input). +#[must_use] +pub fn valid_alias(alias: &str) -> bool { + let a = normalize_alias(alias); + let bytes = a.as_bytes(); + if bytes.is_empty() { + return false; + } + let first = bytes[0]; + let last = bytes[bytes.len() - 1]; + if !(first.is_ascii_lowercase() || first.is_ascii_digit()) + || !(last.is_ascii_lowercase() || last.is_ascii_digit()) + { + return false; + } + // The alias must stay a valid authority-like token: hostname[:port]. + if let Some((host, port)) = a.rsplit_once(':') { + if host.is_empty() || port.is_empty() || !port.bytes().all(|b| b.is_ascii_digit()) { + return false; + } + } + a.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'.' || b == b':' || b == b'-') +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ep(scheme: &str, host: &str, port: u16) -> Endpoint { + Endpoint { + scheme: scheme.into(), + host: host.into(), + port, + } + } + + #[test] + fn single_hostname_standard_port_no_suffix() { + let info = derive_alias(&[ep("https", "api.openai.com", 443)]); + assert_eq!(info.derived.as_deref(), Some("api.openai.com")); + assert!(!info.target_host_required); + assert!(!info.multi_explicit); + } + + #[test] + fn single_hostname_http_standard_port() { + let info = derive_alias(&[ep("http", "localhost", 80)]); + assert_eq!(info.derived.as_deref(), Some("localhost")); + } + + #[test] + fn single_hostname_nonstandard_port() { + let info = derive_alias(&[ep("http", "localhost", 8080)]); + assert_eq!(info.derived.as_deref(), Some("localhost:8080")); + } + + #[test] + fn multi_host_common_suffix_requires_target_host() { + let info = derive_alias(&[ + ep("https", "us.vendor.com", 443), + ep("https", "eu.vendor.com", 443), + ]); + assert_eq!(info.derived.as_deref(), Some("vendor.com")); + assert!(info.target_host_required); + } + + #[test] + fn multi_host_common_suffix_with_port() { + let info = derive_alias(&[ + ep("http", "us.vendor.com", 8443), + ep("http", "eu.vendor.com", 8443), + ]); + assert_eq!(info.derived.as_deref(), Some("vendor.com:8443")); + assert!(info.target_host_required); + } + + #[test] + fn multi_host_common_suffix_bare_public_suffix_not_derivable() { + // `a.com` + `b.com` → common suffix `com` (single label, public + // suffix) → not derivable. + let info = derive_alias(&[ + ep("https", "a.com", 443), + ep("https", "b.com", 443), + ]); + assert_eq!(info.derived, None); + assert!(info.multi_explicit); + } + + #[test] + fn unrelated_hosts_not_derivable() { + let info = derive_alias(&[ + ep("https", "one.example.com", 443), + ep("https", "two.other.org", 443), + ]); + assert_eq!(info.derived, None); + assert!(info.multi_explicit); + } + + #[test] + fn ip_endpoints_require_explicit_alias() { + let info = derive_alias(&[ep("https", "10.0.0.1", 443)]); + assert_eq!(info.derived, None); + assert!(!info.multi_explicit); + } + + #[test] + fn hostname_validation() { + assert!(valid_hostname("api.openai.com")); + assert!(valid_hostname("localhost")); + assert!(valid_hostname("API.OpenAI.com.")); + assert!(!valid_hostname("-bad.com")); + assert!(!valid_hostname("bad-.com")); + assert!(!valid_hostname("a..b")); + assert!(!valid_hostname("")); + } + + #[test] + fn alias_syntax_validation() { + assert!(valid_alias("api.openai.com")); + assert!(valid_alias("localhost:8080")); + assert!(valid_alias("vendor.com:8443")); + assert!(!valid_alias("")); + assert!(!valid_alias("-foo")); + assert!(!valid_alias("foo-")); + assert!(!valid_alias("foo bar")); + assert!(!valid_alias("Foo.Bar ")); + } + + #[test] + fn alias_normalization_lowercases_and_strips_dots() { + assert_eq!(normalize_alias("API.OpenAI.COM."), "api.openai.com"); + } +} 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..c9924ee --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/error.rs @@ -0,0 +1,208 @@ +//! Control-plane error hierarchy and the `DomainError → CanonicalError` +//! ladder for the OAGW REST API. +//! +//! The data plane has its own error vocabulary (GTS-typed RFC 9457 Problems, +//! see `crate::infra::proxy::problem`); this module is strictly the control +//! plane (upstream / route / plugin CRUD). + +use toolkit_canonical_errors::{CanonicalError, resource_error}; + +use crate::gts; + +/// Canonical resource marker for OAGW control-plane entities. Every managed +/// resource error carries a resource type in `{upstream, route, plugin}`. +#[resource_error(gts_id!("cf.core.oagw.config.v1~"))] +pub struct OagwConfigError; + +/// Codified (not free-text) violation codes for field-level violations. +pub mod code { + /// `alias` field. + pub const ALIAS_FIELD: &str = "alias"; + /// Server endpoints field. + pub const SERVER_FIELD: &str = "server"; + /// Route match field. + pub const MATCH_FIELD: &str = "match"; + /// Plugin binding field. + pub const PLUGINS_FIELD: &str = "plugins"; + /// Auth configuration field. + pub const AUTH_FIELD: &str = "auth"; + /// CORS configuration field. + pub const CORS_FIELD: &str = "cors"; + /// Rate limit configuration field. + pub const RATE_LIMIT_FIELD: &str = "rateLimit"; + /// Upstream reference on a route. + pub const UPSTREAM_ID_FIELD: &str = "upstreamId"; + + // Violation codes. + pub const INVALID_FORMAT: &str = "INVALID_FORMAT"; + pub const INVALID_VALUE: &str = "INVALID_VALUE"; + pub const MISSING: &str = "MISSING"; + pub const UNSUPPORTED: &str = "UNSUPPORTED"; + pub const CONFLICT: &str = "CONFLICT"; + pub const IMMUTABLE: &str = "IMMUTABLE"; + pub const NOT_FOUND: &str = "NOT_FOUND"; + pub const ALIAS_IN_USE: &str = "ALIAS_IN_USE"; + pub const INVALID_ALIAS: &str = "INVALID_ALIAS"; + pub const ALIAS_CHANGE: &str = "ALIAS_CHANGE"; +} + +/// Control-plane domain error. +#[derive(Debug, thiserror::Error)] +pub enum DomainError { + /// A field-level validation failure → 400. + #[error("Invalid value for field '{field}': {detail}")] + Validation { + field: &'static str, + detail: String, + code: &'static str, + }, + + /// Resource-level validation with no single field → 400. + #[error("{0}")] + Invalid(String), + + /// A referenced resource does not exist or is not visible → 404. + #[error("No {kind} with id {id}")] + NotFound { kind: &'static str, id: String }, + + /// A uniqueness constraint was violated → 409. + #[error("{0}")] + Conflict(String), + + /// Alias derivation is impossible (IP endpoints / nil common suffix) and + /// no explicit alias was provided → 400. + #[error("{0}")] + AliasMissing(String), + + /// The provided alias does not equal the derived alias for hostname + /// endpoints → 422 semantics via 400. + #[error("{0}")] + AliasMismatch(String), + + /// Attempting to modify an immutable field → 400. + #[error("{0}")] + Immutable(String), + + /// An ancestor's sharing mode (`enforce`) forbids the change → 403. + #[error("{0}")] + Forbidden(String), + + /// Internal/unexpected failure → 500. + #[error("internal error: {0}")] + Internal(#[from] anyhow::Error), +} + +impl DomainError { + /// Validation error for a specific field. + #[must_use] + pub fn validation( + field: &'static str, + detail: impl Into, + code: &'static str, + ) -> Self { + Self::Validation { + field, + detail: detail.into(), + code, + } + } + + /// `404` for a missing resource. + #[must_use] + pub fn not_found(kind: &'static str, id: impl Into) -> Self { + Self::NotFound { + kind, + id: id.into(), + } + } + + /// `409` for a uniqueness conflict. + #[must_use] + pub fn conflict(detail: impl Into) -> Self { + Self::Conflict(detail.into()) + } +} + +impl From for CanonicalError { + fn from(e: DomainError) -> Self { + match e { + DomainError::Validation { + field, + detail, + code, + } => OagwConfigError::invalid_argument() + .with_field_violation(field, detail, code) + .create(), + DomainError::Invalid(detail) => OagwConfigError::invalid_argument() + .with_field_violation("request", detail, code::INVALID_VALUE) + .create(), + DomainError::NotFound { kind, id } => { + let resource_type = match kind { + crate::domain::models::Upstream::KIND => gts::UPSTREAM_RESOURCE_TYPE, + crate::domain::models::Route::KIND => gts::ROUTE_RESOURCE_TYPE, + crate::domain::models::Plugin::KIND => gts::PLUGIN_RESOURCE_TYPE, + _ => gts::UPSTREAM_RESOURCE_TYPE, + }; + OagwConfigError::not_found(format!("No {kind} with id {id}")) + .with_resource(format!("{resource_type}{id}")) + .create() + } + DomainError::Conflict(detail) => OagwConfigError::already_exists(detail.clone()) + .with_resource(detail) + .create(), + DomainError::AliasMissing(detail) => OagwConfigError::invalid_argument() + .with_field_violation(code::ALIAS_FIELD, detail, code::MISSING) + .create(), + DomainError::AliasMismatch(detail) => OagwConfigError::invalid_argument() + .with_field_violation(code::ALIAS_FIELD, detail, code::INVALID_ALIAS) + .create(), + DomainError::Immutable(detail) => OagwConfigError::invalid_argument() + .with_field_violation(code::ALIAS_FIELD, detail, code::IMMUTABLE) + .create(), + DomainError::Forbidden(detail) => OagwConfigError::permission_denied() + .with_reason(format!( + "Forbidden by upstream sharing configuration: {detail}" + )) + .create(), + DomainError::Internal(e) => { + tracing::error!(error = ?e, "oagw control plane internal error"); + CanonicalError::internal(e.to_string()).create() + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use toolkit_canonical_errors::Problem; + + fn problem_from(err: DomainError) -> Problem { + Problem::from(CanonicalError::from(err)) + } + + #[test] + fn validation_maps_to_400() { + let p = problem_from(DomainError::validation("alias", "bad", code::INVALID_ALIAS)); + assert_eq!(p.status, 400); + } + + #[test] + fn not_found_maps_to_404() { + let p = problem_from(DomainError::not_found("upstream", "deadbeef")); + assert_eq!(p.status, 404); + assert!(p.detail.contains("upstream")); + } + + #[test] + fn conflict_maps_to_409() { + let p = problem_from(DomainError::conflict("alias 'foo' already exists")); + assert_eq!(p.status, 409); + } + + #[test] + fn internal_maps_to_500() { + let p = problem_from(DomainError::Internal(anyhow::anyhow!("boom"))); + assert_eq!(p.status, 500); + } +} 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..71f339f --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/mod.rs @@ -0,0 +1,12 @@ +//! Domain layer for the OAGW gear. +//! +//! Contains the entity model ([`models`]), the control-plane error hierarchy +//! ([`error`]), the plugin abstraction ([`plugin`]), repository traits +//! ([`repo`]), and the business services ([`service`], [`alias`]). + +pub mod alias; +pub mod error; +pub mod models; +pub mod plugin; +pub mod repo; +pub mod service; 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..50e7cd4 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/models.rs @@ -0,0 +1,740 @@ +//! Entity and configuration models for the OAGW gear. +//! +//! Field shapes, defaults, enums, and constraints mirror +//! `gears/system/oagw/docs/schemas/upstream.v1.schema.json` and +//! `route.v1.schema.json` exactly. `id` / `tenant_id` are server-managed and +//! optional on the wire so a client-created body may omit them; the service +//! fills them at creation time. + +use std::collections::BTreeMap; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use uuid::Uuid; + +use crate::gts; + +// --------------------------------------------------------------------------- +// Small enums +// --------------------------------------------------------------------------- + +/// Hierarchical config sharing mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum SharingMode { + #[default] + Private, + Inherit, + Enforce, +} + +/// Rate limiting algorithm. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateLimitAlgorithm { + #[default] + TokenBucket, + SlidingWindow, +} + +/// Rate limit time window. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateLimitWindow { + #[default] + Second, + Minute, + Hour, + Day, +} + +impl RateLimitWindow { + /// Number of tokens replenished *per second* for a given `rate`, so that + /// `rate` means "tokens per window". + #[must_use] + pub fn refill_per_second(&self, rate: u32) -> f64 { + let rate = f64::from(rate); + match self { + Self::Second => rate, + Self::Minute => rate / 60.0, + Self::Hour => rate / 3600.0, + Self::Day => rate / 86_400.0, + } + } +} + +/// Rate limit counter scope. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateLimitScope { + Global, + #[default] + Tenant, + User, + Ip, + Route, +} + +/// Rate limit strategy when capacity is exceeded. Only `reject` is +/// implemented by the data plane; `queue`/`degrade` degrade to `reject` with +/// a warning at validation time. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum RateLimitStrategy { + #[default] + Reject, + Queue, + Degrade, +} + +/// Path suffix behavior for `/proxy/{alias}/{*path}`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PathSuffixMode { + Disabled, + #[default] + Append, +} + +/// Inbound header passthrough mode. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)] +#[serde(rename_all = "lowercase")] +pub enum PassthroughMode { + #[default] + None, + Allowlist, + All, +} + +fn default_true() -> bool { + true +} + +fn default_false() -> bool { + false +} + +fn default_one() -> u32 { + 1 +} + +fn default_port() -> u16 { + 443 +} + +fn default_scheme() -> String { + "https".to_owned() +} + +fn default_window() -> RateLimitWindow { + RateLimitWindow::Second +} + +fn default_cors_methods() -> Vec { + vec!["GET".to_owned(), "POST".to_owned()] +} + +// --------------------------------------------------------------------------- +// Upstream +// --------------------------------------------------------------------------- + +/// An outbound upstream service registered by a tenant. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Upstream { + /// System-generated unique identifier. Server-managed (read-only on wire). + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Owning tenant. Server-managed. + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, + /// Whether requests to this upstream are allowed. When a parent tenant + /// disables an upstream, it is disabled for all descendants. + #[serde(default = "default_true")] + pub enabled: bool, + /// Human-readable routing identifier (also the `/proxy/{alias}` key). + pub alias: String, + /// Flat tags, `^[a-z0-9_-]+$`. + #[serde(default)] + pub tags: Vec, + /// Endpoint set. + pub server: ServerConfig, + /// Upstream protocol as GTS identifier. + pub protocol: String, + /// Upstream authentication. + #[serde(default)] + pub auth: AuthConfig, + /// Header transformation rules. + #[serde(default)] + pub headers: HeaderTransforms, + /// Plugin bindings. + #[serde(default)] + pub plugins: PluginsConfig, + /// Rate limiting. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// CORS. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, +} + +impl Upstream { + /// Human-kind label used by canonical `NotFound` errors. + pub const KIND: &'static str = "upstream"; + + /// Effective endpoint host set (normalized lowercase). + #[must_use] + pub fn endpoint_hosts(&self) -> Vec { + self.server + .endpoints + .iter() + .map(|e| e.normalized_host()) + .collect() + } +} + +/// Server configuration for an upstream. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct ServerConfig { + /// One or more endpoints (`minItems: 1`). + pub endpoints: Vec, +} + +/// A single upstream endpoint. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Endpoint { + /// `https` (default), `wss`, `wt`, `grpc`; `http` accepted only when the + /// gear is configured with `allow_http_upstream`. + #[serde(default = "default_scheme")] + pub scheme: String, + /// Hostname or IP address. + pub host: String, + /// Service port, 1..=65535 (default 443). + #[serde(default = "default_port")] + pub port: u16, +} + +impl Endpoint { + /// Whether this endpoint uses the scheme's standard port. + #[must_use] + pub fn is_standard_port(&self) -> bool { + match self.scheme.as_str() { + "http" => self.port == 80, + "https" | "wss" | "wt" | "grpc" => self.port == 443, + _ => false, + } + } + + /// Host with trailing dot stripped and lowercased. + #[must_use] + pub fn normalized_host(&self) -> String { + self.host.trim_end_matches('.').to_ascii_lowercase() + } + + /// `host` or `host:port` (non-standard port only). + #[must_use] + pub fn authority(&self) -> String { + if self.is_standard_port() { + self.normalized_host() + } else { + format!("{}:{}", self.normalized_host(), self.port) + } + } + + /// `scheme://authority`. + #[must_use] + pub fn base_url(&self) -> String { + format!("{}://{}", self.scheme, self.authority()) + } +} + +/// Authentication configuration for an upstream. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct AuthConfig { + /// Auth plugin type (GTS identifier of an `auth_plugin`). + #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")] + pub plugin_type: Option, + /// Sharing mode for hierarchical config. + #[serde(default)] + pub sharing: SharingMode, + /// Auth plugin configuration (free-form). + #[serde(default, skip_serializing_if = "is_empty_object")] + pub config: Value, +} + +fn is_empty_object(v: &Value) -> bool { + v.is_null() || (v.as_object().is_some_and(|o| o.is_empty())) +} + +// --------------------------------------------------------------------------- +// Header transforms +// --------------------------------------------------------------------------- + +/// Header transformation rules for an upstream. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct HeaderTransforms { + #[serde(default, skip_serializing_if = "is_default_request_header_rules")] + pub request: RequestHeaderRules, + #[serde(default, skip_serializing_if = "is_default_response_header_rules")] + pub response: ResponseHeaderRules, +} + +/// Inbound request header rules. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct RequestHeaderRules { + /// Headers to set (overwrite if exists) on the outbound request. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub set: BTreeMap, + /// Headers to add (append, allow duplicates). + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub add: BTreeMap, + /// Header names to remove from the inbound request. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub remove: Vec, + /// Which inbound headers to forward to the upstream. + #[serde(default, skip_serializing_if = "is_default_passthrough")] + pub passthrough: PassthroughMode, + /// Headers forwarded when `passthrough == allowlist`. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub passthrough_allowlist: Vec, +} + +/// Upstream response header rules. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct ResponseHeaderRules { + /// Headers to set on the client-facing response. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub set: BTreeMap, + /// Headers to add on the client-facing response. + #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] + pub add: BTreeMap, + /// Header names to strip from the upstream response. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub remove: Vec, +} + +fn is_default_passthrough(m: &PassthroughMode) -> bool { + *m == PassthroughMode::None +} + +fn is_default_request_header_rules(r: &RequestHeaderRules) -> bool { + r == &RequestHeaderRules::default() +} + +fn is_default_response_header_rules(r: &ResponseHeaderRules) -> bool { + r == &ResponseHeaderRules::default() +} + +// --------------------------------------------------------------------------- +// Plugins +// --------------------------------------------------------------------------- + +/// Plugin binding configuration for an upstream or route. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct PluginsConfig { + /// Sharing mode for the plugin chain (upstreams only). + #[serde(default)] + pub sharing: SharingMode, + /// Ordered plugin list. Items are either a plain string (builtin GTS + /// identifier or custom plugin UUID) or an object binding + /// `{plugin_ref, config}` (ADR 0009). + #[serde(default)] + pub items: Vec, +} + +/// A single plugin reference within `plugins.items`. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum PluginItem { + /// Plain `"gts...~..."` builtin identifier or `"uuid"` custom reference. + Ref(String), + /// Explicit binding with instance-level config (`{plugin_ref, config}`). + Binding(PluginBinding), +} + +impl PluginItem { + /// The referenced plugin identifier (GTS id or UUID string). + #[must_use] + pub fn plugin_ref(&self) -> &str { + match self { + Self::Ref(id) => id, + Self::Binding(b) => b.plugin_ref.as_str(), + } + } + + /// Instance-level config for this binding. + #[must_use] + pub fn config(&self) -> Value { + match self { + Self::Ref(_) => Value::Object(Default::default()), + Self::Binding(b) => b.config.clone(), + } + } +} + +/// Explicit plugin binding with instance-level config. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct PluginBinding { + pub plugin_ref: String, + #[serde(default)] + pub config: Value, +} + +// --------------------------------------------------------------------------- +// Rate limiting +// --------------------------------------------------------------------------- + +/// Rate limiting configuration (upstream or route scoped). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct RateLimitConfig { + /// Sharing mode for hierarchical composition. + #[serde(default)] + pub sharing: SharingMode, + /// Algorithm (only `token_bucket` is implemented). + #[serde(default)] + pub algorithm: RateLimitAlgorithm, + /// Sustained refill policy. + pub sustained: SustainedRate, + /// Burst capacity (defaults to `sustained.rate`). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub burst: Option, + /// Counter scope. + #[serde(default)] + pub scope: RateLimitScope, + /// Excess behavior (`reject` implemented; others degrade to reject). + #[serde(default)] + pub strategy: RateLimitStrategy, + /// Tokens consumed per request (weighted endpoints). + #[serde(default = "default_one")] + pub cost: u32, +} + +/// Sustained refill policy. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct SustainedRate { + /// Tokens replenished per window. + pub rate: u32, + /// Time window. + #[serde(default = "default_window")] + pub window: RateLimitWindow, +} + +/// Burst configuration. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct BurstConfig { + /// Maximum burst size (bucket capacity), defaults to `sustained.rate`. + #[serde(default = "default_one")] + pub capacity: u32, +} + +impl RateLimitConfig { + /// Effective bucket capacity: explicit burst, else the sustained rate. + #[must_use] + pub fn bucket_capacity(&self) -> u32 { + self.burst + .as_ref() + .map(|b| b.capacity) + .unwrap_or(self.sustained.rate) + .max(1) + } +} + +// --------------------------------------------------------------------------- +// CORS +// --------------------------------------------------------------------------- + +/// CORS configuration for an upstream or route. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +#[serde(rename_all = "camelCase")] +pub struct CorsConfig { + /// Sharing mode for hierarchical composition. + #[serde(default)] + pub sharing: SharingMode, + /// Enable CORS enforcement for this upstream/route. + #[serde(default = "default_false")] + pub enabled: bool, + /// Allowed origins (`["*"]` for any; not allowed with credentials). + #[serde(default)] + pub allowed_origins: Vec, + /// Allowed methods (default `["GET", "POST"]`). + #[serde(default = "default_cors_methods")] + pub allowed_methods: Vec, + /// Headers exposed to the browser. + #[serde(default)] + pub expose_headers: Vec, + /// Allow credentials (requires specific origins, not `*`). + #[serde(default = "default_false")] + pub allow_credentials: bool, +} + +impl CorsConfig { + /// Whether `allow_credentials` is combined with a wildcard origin — an + /// invalid combination per the schema (`then` constraint). + #[must_use] + pub fn has_invalid_wildcard_with_credentials(&self) -> bool { + self.allow_credentials && self.allowed_origins.iter().any(|o| o == "*") + } + + /// Whether `origin` is allowed by this config (exact match or wildcard). + #[must_use] + pub fn allows_origin(&self, origin: &str) -> bool { + self.allowed_origins.iter().any(|o| o == "*" || o == origin) + } + + /// Whether `method` is allowed by this config. + #[must_use] + pub fn allows_method(&self, method: &str) -> bool { + method.eq_ignore_ascii_case("OPTIONS") + || self + .allowed_methods + .iter() + .any(|m| m.eq_ignore_ascii_case(method)) + } +} + +// --------------------------------------------------------------------------- +// Route +// --------------------------------------------------------------------------- + +/// A route binding a match rule to an upstream. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Route { + /// System-generated unique identifier. Server-managed. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Owning tenant. Server-managed. + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, + /// Target upstream (belongs to the same tenant). + pub upstream_id: Uuid, + /// Route is active. Present on routes by mandate of the PRD/DESIGN even + /// though the JSON schema omits it. + #[serde(default = "default_true")] + pub enabled: bool, + /// Match rules (exactly one of `http` | `grpc`). + #[serde(rename = "match")] + pub match_: RouteMatch, + /// Flat tags, `^[a-z0-9_-]+$`. + #[serde(default)] + pub tags: Vec, + /// Route-level plugin bindings. + #[serde(default)] + pub plugins: PluginsConfig, + /// Route-level rate limiting (overrides upstream). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub rate_limit: Option, + /// Route-level CORS (overrides upstream). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cors: Option, +} + +impl Route { + /// Human-kind label used by canonical `NotFound` errors. + pub const KIND: &'static str = "route"; +} + +/// Route match: either HTTP rules or gRPC rules, exactly one. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum RouteMatch { + Http(HttpMatch), + Grpc(GrpcMatch), +} + +impl RouteMatch { + /// The discriminator value (`"http"` | `"grpc"`). + #[must_use] + pub fn kind(&self) -> &'static str { + match self { + Self::Http(_) => "http", + Self::Grpc(_) => "grpc", + } + } + + #[must_use] + pub fn as_http(&self) -> Option<&HttpMatch> { + match self { + Self::Http(m) => Some(m), + Self::Grpc(_) => None, + } + } +} + +/// HTTP match rules. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct HttpMatch { + /// Allowed methods (`GET`/`POST`/`PUT`/`DELETE`/`PATCH`), min 1. + pub methods: Vec, + /// Path prefix, min length 1. + pub path: String, + /// Whitelisted query parameters (empty = allow none). + #[serde(default)] + pub query_allowlist: Vec, + /// Path suffix handling for `/proxy/{alias}/{*path}`. + #[serde(default)] + pub path_suffix_mode: PathSuffixMode, +} + +/// gRPC match rules (catalogued; no gRPC data-plane path is implemented). +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct GrpcMatch { + /// Fully qualified gRPC service name. + pub service: String, + /// RPC method name. + pub method: String, +} + +// --------------------------------------------------------------------------- +// Plugin (custom Starlark) +// --------------------------------------------------------------------------- + +/// A custom tenant-defined plugin definition. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Plugin { + /// System-generated unique identifier. Server-managed. + #[serde(skip_serializing_if = "Option::is_none")] + pub id: Option, + /// Owning tenant. Server-managed. + #[serde(skip_serializing_if = "Option::is_none")] + pub tenant_id: Option, + /// Plugin type (GTS identifier of the plugin category being customized). + pub plugin_type: String, + /// Display name. + pub name: String, + /// JSON schema describing the plugin config surface. + #[serde(default, skip_serializing_if = "is_empty_object")] + pub config_schema: Value, + /// Starlark source code. + pub source_code: String, +} + +impl Plugin { + /// Human-kind label used by canonical `NotFound` errors. + pub const KIND: &'static str = "plugin"; +} + +// --------------------------------------------------------------------------- +// Well-known protocol / auth GTS shorthands +// --------------------------------------------------------------------------- + +/// Whether `id` is one of the two known upstream protocol identifiers. +#[must_use] +pub fn is_supported_protocol(id: &str) -> bool { + id == gts::PROTOCOL_HTTP || id == gts::PROTOCOL_GRPC +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn endpoint_authority_standard_port() { + let e = Endpoint { + scheme: "https".into(), + host: "api.vendor.com".into(), + port: 443, + }; + assert_eq!(e.authority(), "api.vendor.com"); + assert_eq!(e.base_url(), "https://api.vendor.com"); + } + + #[test] + fn endpoint_authority_nonstandard_port() { + let e = Endpoint { + scheme: "http".into(), + host: "localhost".into(), + port: 8080, + }; + assert_eq!(e.authority(), "localhost:8080"); + assert_eq!(e.base_url(), "http://localhost:8080"); + } + + #[test] + fn endpoint_normalizes_host() { + let e = Endpoint { + scheme: "https".into(), + host: "API.Vendor.COM.".into(), + port: 443, + }; + assert_eq!(e.normalized_host(), "api.vendor.com"); + } + + #[test] + fn rate_limit_bucket_capacity_defaults_to_rate() { + let rl = RateLimitConfig { + sharing: SharingMode::Private, + algorithm: RateLimitAlgorithm::TokenBucket, + sustained: SustainedRate { + rate: 5, + window: RateLimitWindow::Second, + }, + burst: None, + scope: RateLimitScope::Tenant, + strategy: RateLimitStrategy::Reject, + cost: 1, + }; + assert_eq!(rl.bucket_capacity(), 5); + let with_burst = RateLimitConfig { + burst: Some(BurstConfig { capacity: 10 }), + ..rl.clone() + }; + assert_eq!(with_burst.bucket_capacity(), 10); + } + + #[test] + fn cors_wildcard_with_credentials_is_invalid() { + let cors = CorsConfig { + allow_credentials: true, + allowed_origins: vec!["*".into()], + ..Default::default() + }; + assert!(cors.has_invalid_wildcard_with_credentials()); + let cors = CorsConfig { + allow_credentials: true, + allowed_origins: vec!["https://app.example.com".into()], + ..Default::default() + }; + assert!(!cors.has_invalid_wildcard_with_credentials()); + } + + #[test] + fn untagged_plugin_item_deserializes_both_forms() { + let a: PluginItem = serde_json::from_str(r#""gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1""#) + .unwrap(); + assert!(matches!(a, PluginItem::Ref(_))); + let b: PluginItem = serde_json::from_str( + r#"{"pluginRef":"gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1","config":{"required_request_headers":"x-correlation-id"}}"#, + ) + .unwrap(); + assert!(matches!(b, PluginItem::Binding(_))); + assert_eq!(b.plugin_ref(), "gts.cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"); + assert!(b.config().get("required_request_headers").is_some()); + } + + #[test] + fn route_match_deserializes_http() { + let r: RouteMatch = serde_json::from_str( + r#"{"methods":["GET"],"path":"/v1/chat"}"#, + ) + .unwrap(); + assert_eq!(r.kind(), "http"); + let http = r.as_http().unwrap(); + assert_eq!(http.path, "/v1/chat"); + assert_eq!(http.path_suffix_mode, PathSuffixMode::Append); + } +} 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..7b6b701 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/plugin/mod.rs @@ -0,0 +1,241 @@ +//! Plugin abstraction for the OAGW data plane (ADR 0002). +//! +//! Three plugin categories exist, executed in a fixed order: +//! +//! 1. **Auth** — authenticates with the upstream and prepares credentials. +//! 2. **Guard** — validates the request (`guard_request`) and/or the upstream +//! response (`guard_response`). +//! 3. **Transform** — mutates the outbound request (`transform_request`), +//! the upstream response (`transform_response`) and, failing that, the +//! error response (`transform_error`). +//! +//! Upstream-bound plugins run before route-bound plugins. + +use async_trait::async_trait; +use serde_json::Value; +use uuid::Uuid; + +/// Failure reason returned by an auth plugin. +#[derive(Debug, thiserror::Error)] +pub enum AuthError { + /// Credentials were rejected / could not be produced. Maps to + /// `401 auth.failed.v1`. + #[error("upstream authentication failed: {0}")] + Rejected(String), + /// A referenced credential could not be resolved. Maps to + /// `500 secret.not_found.v1`. + #[error("secret not found: {0}")] + SecretNotFound(String), + /// Transient backend failure talking to the IdP. + #[error("auth backend failure: {0}")] + Backend(String), +} + +/// Failure reason returned by a guard plugin. +#[derive(Debug, thiserror::Error)] +pub enum GuardError { + /// Request phase failure. Maps to `400 validation.error.v1` with the + /// given `error_code` in `context.error_code`. + #[error("{detail}")] + Request { error_code: String, detail: String }, + /// Response phase failure. Maps to `502` with the given `error_code`. + #[error("{detail}")] + Response { error_code: String, detail: String }, +} + +/// Failure reason returned by a transform plugin. +#[derive(Debug, thiserror::Error)] +pub enum TransformError { + #[error("{0}")] + Failed(String), +} + +/// Shared request context handed to every data-plane plugin. +pub struct RequestContext<'a> { + /// Inbound (client) request headers, read-only. + pub headers: &'a http::HeaderMap, + /// Outbound headers being accumulated for the upstream request. + pub outbound: http::HeaderMap, + /// Instance-level config for this plugin binding. + pub config: &'a Value, + /// HTTP method of the proxied request. + pub method: http::Method, + /// Effective request path forwarded to the upstream. + pub path: String, + /// Security context of the calling subject (secret resolution). + pub security: &'a toolkit_security::SecurityContext, + /// Owning tenant of the resolved upstream. + pub tenant_id: Uuid, +} + +impl RequestContext<'_> { + /// Read an inbound header value. + #[must_use] + pub fn inbound_header(&self, name: &str) -> Option<&str> { + self.headers.get(name).and_then(|v| v.to_str().ok()) + } + + /// Set an outbound header. + pub fn set_outbound_header(&mut self, name: &str, value: impl Into) { + if let (Ok(name), Ok(value)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(&value.into()), + ) { + self.outbound.insert(name, value); + } + } + + /// Append an outbound header (allowing duplicates). + pub fn add_outbound_header(&mut self, name: &str, value: impl Into) { + if let (Ok(name), Ok(value)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(&value.into()), + ) { + self.outbound.append(name, value); + } + } +} + +/// Response context handed to guard/transform response-phase plugins. +pub struct ResponseContext<'a> { + /// Headers of the upstream response (mutable). + pub headers: &'a mut http::HeaderMap, + /// Status of the upstream response. + pub status: http::StatusCode, + /// Instance-level config for this plugin binding. + pub config: &'a Value, +} + +impl ResponseContext<'_> { + /// Set a response header. + pub fn set_header(&mut self, name: &str, value: impl Into) { + if let (Ok(name), Ok(value)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(&value.into()), + ) { + self.headers.insert(name, value); + } + } +} + +/// Error context handed to transform-error plugins. +pub struct ErrorContext<'a> { + /// Status of the error response. + pub status: http::StatusCode, + /// GTS error type of the problem response. + pub error_type: String, + /// Detail text of the problem response. + pub detail: String, + /// Instance-level config for this plugin binding. + pub config: &'a Value, +} + +/// Auth plugin — authenticates with the upstream and prepares credentials. +#[async_trait] +pub trait AuthPlugin: Send + Sync { + /// Stable built-in identifier (matches `gts::AUTH_*` for builtins). + fn id(&self) -> &'static str; + + /// The `auth_plugin` GTS type this plugin backs. + fn plugin_type(&self) -> &'static str; + + /// Perform authentication. On success, implementations set the prepared + /// credential headers on `ctx.outbound` (e.g. `Authorization`). + async fn authenticate(&self, ctx: &mut RequestContext<'_>) -> Result<(), AuthError>; +} + +/// Guard plugin — validates requests and/or upstream responses. +#[async_trait] +pub trait GuardPlugin: Send + Sync { + /// Stable built-in identifier. + fn id(&self) -> &'static str; + + /// The `guard_plugin` GTS type this plugin backs. + fn plugin_type(&self) -> &'static str; + + /// Validate the proxied request. Returning `Err` short-circuits the + /// pipeline with a `400` problem response. + async fn guard_request(&self, ctx: &RequestContext<'_>) -> Result<(), GuardError>; + + /// Validate the upstream response. Returning `Err` replaces the response + /// with a `502` problem response. + async fn guard_response(&self, ctx: &mut ResponseContext<'_>) -> Result<(), GuardError>; +} + +/// Transform plugin — mutates requests, responses, and errors. +#[async_trait] +pub trait TransformPlugin: Send + Sync { + /// Stable built-in identifier. + fn id(&self) -> &'static str; + + /// The `transform_plugin` GTS type this plugin backs. + fn plugin_type(&self) -> &'static str; + + /// Mutate the outbound request (runs before the upstream round-trip). + async fn transform_request(&self, ctx: &mut RequestContext<'_>) -> Result<(), TransformError>; + + /// Mutate the upstream response (runs before it is sent downstream). + async fn transform_response(&self, ctx: &mut ResponseContext<'_>) -> Result<(), TransformError>; + + /// Mutate error responses produced by the gateway. + async fn transform_error(&self, ctx: &mut ErrorContext<'_>) -> Result<(), TransformError>; +} + +/// A fully-instantiated plugin with its instance-level config resolved. +pub enum BoundPlugin { + Auth(Box, Value), + Guard(Box, Value), + Transform(Box, Value), +} + +impl BoundPlugin { + #[must_use] + pub fn config(&self) -> &Value { + match self { + Self::Auth(_, c) | Self::Guard(_, c) | Self::Transform(_, c) => c, + } + } +} + +/// Ordered plugin chain (all bound instances for one request). +#[derive(Default)] +pub struct PluginChain { + pub auth: Vec>, + pub guards: Vec<(Box, Value)>, + pub transforms: Vec<(Box, Value)>, +} + +/// A plugin binding site reference: which plugin, with which instance config, +/// bound where. +#[derive(Debug, Clone)] +pub struct PluginBindingRef { + /// GTS identifier (builtin) or plugin UUID (custom). + pub plugin_ref: String, + /// Instance-level config. + pub config: Value, + /// Tenant owning the binding (custom-plugin and secret resolution). + pub tenant_id: Uuid, +} + +/// How the data plane plans a plugin pipeline for one request: the resolved +/// plugin-heavy configuration surface. +#[derive(Default)] +pub struct PipelinePlan { + /// Upstream auth plugin + config (a resolved `upstream.auth`). + pub auth: Option<(Box, Value)>, + /// Upstream guards first, then route guards. + pub guards: Vec<(Box, Value, bool /*from_route*/)>, + /// Upstream transforms then route transforms. The vec is ordered + /// upstream-before-route. + pub transforms: Vec<(Box, Value, bool /*from_route*/)>, +} + +/// The plugin registry resolves plugin references (builtin GTS ids and +/// custom UUIDs) into live plugin instances. +#[async_trait::async_trait] +pub trait PluginRegistry: Send + Sync { + /// Resolve a binding reference into a bound plugin, or `Err` with a + /// human-readable reason when the plugin is unknown or its category + /// cannot back the requested phase. + async fn resolve(&self, binding: &PluginBindingRef) -> Result; +} diff --git a/gears/system/oagw/oagw/src/domain/repo.rs b/gears/system/oagw/oagw/src/domain/repo.rs new file mode 100644 index 0000000..d27c455 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/repo.rs @@ -0,0 +1,64 @@ +//! Repository traits for the OAGW control plane. +//! +//! Repositories are tenant-scoped: every lookup takes the calling tenant and +//! returns only resources owned by that tenant (ancestor resources are 404 +//! via the management API). The in-memory implementation lives in +//! `crate::infra::storage`. + +use std::sync::Arc; + +use uuid::Uuid; + +use super::models::{Plugin, Route, Upstream}; + +/// Repository for upstream resources. +pub trait UpstreamRepo: Send + Sync { + /// Insert or replace an upstream owned by `tenant_id`. + fn upsert(&self, tenant_id: Uuid, u: Upstream) -> Result<(), anyhow::Error>; + + /// Delete an upstream owned by `tenant_id`. Returns `true` if deleted. + fn delete(&self, tenant_id: Uuid, id: Uuid) -> Result; + + /// Get an upstream owned by `tenant_id`. + fn get(&self, tenant_id: Uuid, id: Uuid) -> Option>; + + /// List all upstreams owned by `tenant_id`. + fn list(&self, tenant_id: Uuid) -> Vec>; + + /// Whether an alias is already taken by another upstream owned by + /// `tenant_id` (excluding `except_id`). + fn alias_taken(&self, tenant_id: Uuid, alias: &str, except_id: Option) -> bool; +} + +/// Repository for route resources. +pub trait RouteRepo: Send + Sync { + /// Insert or replace a route owned by `tenant_id`. + fn upsert(&self, tenant_id: Uuid, r: Route) -> Result<(), anyhow::Error>; + + /// Delete a route owned by `tenant_id`. Returns `true` if deleted. + fn delete(&self, tenant_id: Uuid, id: Uuid) -> Result; + + /// Get a route owned by `tenant_id`. + fn get(&self, tenant_id: Uuid, id: Uuid) -> Option>; + + /// List all routes owned by `tenant_id`. + fn list(&self, tenant_id: Uuid) -> Vec>; + + /// List routes owned by `tenant_id` that target `upstream_id`. + fn list_for_upstream(&self, tenant_id: Uuid, upstream_id: Uuid) -> Vec>; +} + +/// Repository for custom plugin resources. +pub trait PluginRepo: Send + Sync { + /// Insert a plugin owned by `tenant_id`. + fn put(&self, tenant_id: Uuid, p: Plugin) -> Result<(), anyhow::Error>; + + /// Delete a plugin owned by `tenant_id`. Returns `true` if deleted. + fn delete(&self, tenant_id: Uuid, id: Uuid) -> Result; + + /// Get a plugin owned by `tenant_id`. + fn get(&self, tenant_id: Uuid, id: Uuid) -> Option>; + + /// List all plugins owned by `tenant_id`. + fn list(&self, tenant_id: Uuid) -> Vec>; +} 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..bcf45c2 --- /dev/null +++ b/gears/system/oagw/oagw/src/domain/service.rs @@ -0,0 +1,616 @@ +//! Business services for the OAGW gear. +//! +//! [`ControlPlaneService`] implements upstream/route/plugin CRUD with full +//! validation (alias derivation, scheme policy, tenant scoping, uniqueness). +//! The data-plane interface ([`DataPlaneService`]) is implemented in +//! `crate::infra::proxy`. + +use std::sync::Arc; + +use uuid::Uuid; + +use super::alias::{self, AliasInfo}; +use super::error::{DomainError, code}; +use super::models::{ + Plugin, PluginItem, Route, RouteMatch, Upstream, is_supported_protocol, +}; +use super::repo::{PluginRepo, RouteRepo, UpstreamRepo}; +use crate::config::OagwConfig; +use crate::gts; + +/// Methods allowed in an HTTP route match. +const VALID_HTTP_METHODS: &[&str] = &["GET", "POST", "PUT", "DELETE", "PATCH"]; + +/// Control-plane service: tenant-scoped CRUD with validation. +pub struct ControlPlaneService { + upstreams: Arc, + routes: Arc, + plugins: Arc, + config: OagwConfig, +} + +impl std::fmt::Debug for ControlPlaneService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ControlPlaneService").finish_non_exhaustive() + } +} + +impl ControlPlaneService { + /// Construct the service over the in-memory repositories. + #[must_use] + pub fn new( + upstreams: Arc, + routes: Arc, + plugins: Arc, + config: OagwConfig, + ) -> Self { + Self { + upstreams, + routes, + plugins, + config, + } + } + + /// The gear's resolved configuration (used by the data plane). + #[must_use] + pub fn config(&self) -> &OagwConfig { + &self.config + } + + // ----------------------------------------------------------------------- + // Upstreams + // ----------------------------------------------------------------------- + + /// Validate + persist a new upstream. Alias handling per ADR 0001: + /// derives from endpoints, rejects mismatched explicit aliases on + /// hostname endpoints, requires explicit aliases for IP/non-derivable + /// sets, and enforces `(tenant_id, alias)` uniqueness. + pub fn create_upstream(&self, tenant_id: Uuid, mut up: Upstream) -> Result { + up.tenant_id = Some(tenant_id); + self.validate_upstream(&mut up, None)?; + let id = up.id.unwrap_or_else(uuid::Uuid::new_v4); + up.id = Some(id); + self.upstreams.upsert(tenant_id, up.clone())?; + Ok(up) + } + + /// Full replacement (`PUT`). The alias is immutable; `id`/`tenant_id` + /// are ignored and taken from the stored resource. + pub fn update_upstream( + &self, + tenant_id: Uuid, + id: Uuid, + mut up: Upstream, + ) -> Result { + let existing = self + .upstreams + .get(tenant_id, id) + .ok_or_else(|| DomainError::not_found(Upstream::KIND, id))?; + if !up.alias.is_empty() && alias::normalize_alias(&up.alias) != alias::normalize_alias(&existing.alias) { + return Err(DomainError::Immutable(format!( + "alias is immutable: '{}' != '{}'", + up.alias, existing.alias + ))); + } + up.alias.clone_from(&existing.alias); + up.id = Some(id); + up.tenant_id = Some(tenant_id); + self.validate_upstream(&mut up, Some(id))?; + self.upstreams.upsert(tenant_id, up.clone())?; + Ok(up) + } + + pub fn delete_upstream(&self, tenant_id: Uuid, id: Uuid) -> Result<(), DomainError> { + if self.upstreams.get(tenant_id, id).is_none() { + return Err(DomainError::not_found(Upstream::KIND, id)); + } + if !self.routes.list_for_upstream(tenant_id, id).is_empty() { + return Err(DomainError::conflict(format!( + "upstream {id} still has routes; delete the routes first" + ))); + } + self.upstreams.delete(tenant_id, id)?; + Ok(()) + } + + pub fn get_upstream(&self, tenant_id: Uuid, id: Uuid) -> Result, DomainError> { + self.upstreams + .get(tenant_id, id) + .ok_or_else(|| DomainError::not_found(Upstream::KIND, id)) + } + + pub fn list_upstreams(&self, tenant_id: Uuid) -> Vec> { + self.upstreams.list(tenant_id) + } + + /// Exact-tenant read used by the data-plane tenant-chain walk. + #[must_use] + pub fn upstream_by_alias(&self, tenant_id: Uuid, alias: &str) -> Option> { + self.upstreams + .list(tenant_id) + .into_iter() + .find(|u| alias::normalize_alias(&u.alias) == alias::normalize_alias(alias)) + } + + fn validate_upstream(&self, up: &mut Upstream, except_id: Option) -> Result<(), DomainError> { + if up.server.endpoints.is_empty() { + return Err(DomainError::validation( + code::SERVER_FIELD, + "server.endpoints must contain at least one endpoint", + code::MISSING, + )); + } + for (i, e) in up.server.endpoints.iter().enumerate() { + self.validate_endpoint(&up.protocol, e, &format!("server.endpoints[{i}]"))?; + } + if !is_supported_protocol(&up.protocol) { + return Err(DomainError::validation( + code::SERVER_FIELD, + format!("unsupported protocol '{}'", up.protocol), + code::UNSUPPORTED, + )); + } + + let info = alias::derive_alias(&up.server.endpoints); + up.alias.clone_from(&self.normalize_alias_input(&up.alias, &info)?); + + if let Some(rl) = &up.rate_limit { + self.validate_rate_limit(rl)?; + } + if let Some(cors) = &up.cors { + if cors.has_invalid_wildcard_with_credentials() { + return Err(DomainError::validation( + code::CORS_FIELD, + "allow_credentials cannot be combined with allowed_origins ['*']", + code::INVALID_VALUE, + )); + } + } + self.validate_plugin_items(&up.plugins.items, Some(up.tenant_id.unwrap_or_default()))?; + + // `(tenant_id, alias)` uniqueness → 409. + let tenant = up.tenant_id.unwrap_or_default(); + if self + .upstreams + .alias_taken(tenant, &up.alias, except_id) + { + return Err(DomainError::conflict(format!( + "an upstream with alias '{}' already exists for this tenant", + up.alias + ))); + } + Ok(()) + } + + fn validate_endpoint( + &self, + protocol: &str, + e: &super::models::Endpoint, + field: &str, + ) -> Result<(), DomainError> { + let allowed = self.config.allow_http_upstream; + let ok_scheme = match e.scheme.as_str() { + "https" | "wss" | "wt" | "grpc" => true, + "http" => allowed, + _ => false, + }; + if !ok_scheme { + let mut msg = format!( + "scheme '{}' is not allowed for this gear; use https, wss, wt, or grpc", + e.scheme + ); + if e.scheme == "http" { + msg = "scheme 'http' is not allowed (set gears.oagw.config.allow_http_upstream to enable for testing)".to_owned(); + } + return Err(DomainError::validation(code::SERVER_FIELD, msg, code::UNSUPPORTED)); + } + let host = e.normalized_host(); + let valid_host = host.parse::().is_ok() || alias::valid_hostname(&host); + if !valid_host { + return Err(DomainError::validation( + code::SERVER_FIELD, + format!("{field}.host is not a valid hostname or IP: '{}'", e.host), + code::INVALID_FORMAT, + )); + } + if !(1..=65535).contains(&e.port) { + return Err(DomainError::validation( + code::SERVER_FIELD, + format!("{field}.port out of range: {}", e.port), + code::INVALID_VALUE, + )); + } + let _ = protocol; + Ok(()) + } + + /// Resolve the stored alias from user input + derivation, enforcing the + /// ADR 0001 matrix. + fn normalize_alias_input(&self, user: &str, info: &AliasInfo) -> Result { + match &info.derived { + Some(derived) => { + if user.trim().is_empty() { + Ok(derived.clone()) + } else { + let normalized = alias::normalize_alias(user); + if normalized == *derived { + Ok(normalized) + } else { + Err(DomainError::AliasMismatch(format!( + "alias '{}' does not match derived alias '{}' for hostname endpoints", + user, derived + ))) + } + } + } + None => { + if user.trim().is_empty() { + Err(DomainError::AliasMissing( + "explicit alias required: endpoints are IP-based or have no common registrable suffix" + .to_owned(), + )) + } else { + let normalized = alias::normalize_alias(user); + if !alias::valid_alias(&normalized) { + return Err(DomainError::validation( + code::ALIAS_FIELD, + format!("invalid alias: '{}'", user), + code::INVALID_FORMAT, + )); + } + let _ = info; + Ok(normalized) + } + } + } + } + + fn validate_rate_limit(&self, rl: &super::models::RateLimitConfig) -> Result<(), DomainError> { + if rl.sustained.rate < 1 { + return Err(DomainError::validation( + code::RATE_LIMIT_FIELD, + "rateLimit.sustained.rate must be >= 1", + code::INVALID_VALUE, + )); + } + let _ = self; + Ok(()) + } + + // ----------------------------------------------------------------------- + // Routes + // ----------------------------------------------------------------------- + + pub fn create_route(&self, tenant_id: Uuid, mut r: Route) -> Result { + self.validate_route(&tenant_id, &r, None)?; + let id = r.id.unwrap_or_else(uuid::Uuid::new_v4); + r.id = Some(id); + r.tenant_id = Some(tenant_id); + self.routes.upsert(tenant_id, r.clone())?; + Ok(r) + } + + pub fn update_route(&self, tenant_id: Uuid, id: Uuid, mut r: Route) -> Result { + // `upstream_id` is immutable; `id`/`tenant_id` are managed. + let existing = self + .routes + .get(tenant_id, id) + .ok_or_else(|| DomainError::not_found(Route::KIND, id))?; + if r.upstream_id != existing.upstream_id { + return Err(DomainError::Immutable( + "upstream_id is immutable on routes".to_owned(), + )); + } + self.validate_route(&tenant_id, &r, Some(id))?; + r.id = Some(id); + r.tenant_id = Some(tenant_id); + self.routes.upsert(tenant_id, r.clone())?; + Ok(r) + } + + pub fn delete_route(&self, tenant_id: Uuid, id: Uuid) -> Result<(), DomainError> { + if self.routes.get(tenant_id, id).is_none() { + return Err(DomainError::not_found(Route::KIND, id)); + } + self.routes.delete(tenant_id, id)?; + Ok(()) + } + + pub fn get_route(&self, tenant_id: Uuid, id: Uuid) -> Result, DomainError> { + self.routes + .get(tenant_id, id) + .ok_or_else(|| DomainError::not_found(Route::KIND, id)) + } + + pub fn list_routes(&self, tenant_id: Uuid) -> Vec> { + self.routes.list(tenant_id) + } + + pub fn routes_for_upstream(&self, tenant_id: Uuid, upstream_id: Uuid) -> Vec> { + self.routes.list_for_upstream(tenant_id, upstream_id) + } + + fn validate_route( + &self, + tenant_id: &Uuid, + r: &Route, + except_id: Option, + ) -> Result<(), DomainError> { + // upstream must exist and belong to the calling tenant. + if self.upstreams.get(*tenant_id, r.upstream_id).is_none() { + return Err(DomainError::validation( + code::UPSTREAM_ID_FIELD, + format!("upstream_id {} does not exist for this tenant", r.upstream_id), + code::NOT_FOUND, + )); + } + match &r.match_ { + RouteMatch::Http(m) => { + if m.path.trim().is_empty() { + return Err(DomainError::validation( + code::MATCH_FIELD, + "match.http.path must be a non-empty path", + code::MISSING, + )); + } + if !m.path.starts_with('/') { + return Err(DomainError::validation( + code::MATCH_FIELD, + "match.http.path must start with '/'", + code::INVALID_FORMAT, + )); + } + if m.methods.is_empty() { + return Err(DomainError::validation( + code::MATCH_FIELD, + "match.http.methods must contain at least one method", + code::MISSING, + )); + } + for method in &m.methods { + let ok = VALID_HTTP_METHODS + .iter() + .any(|v| v.eq_ignore_ascii_case(method)); + if !ok { + return Err(DomainError::validation( + code::MATCH_FIELD, + format!("unsupported HTTP method '{method}'"), + code::INVALID_VALUE, + )); + } + } + // Match-rule uniqueness within the upstream (path + method). + let existing = self.routes.list_for_upstream(*tenant_id, r.upstream_id); + for other in existing { + if except_id == other.id { + continue; + } + if let RouteMatch::Http(om) = &other.match_ { + let same_path = om.path == m.path; + let methods_overlap = m + .methods + .iter() + .any(|a| om.methods.iter().any(|b| b.eq_ignore_ascii_case(a))); + if same_path && methods_overlap { + return Err(DomainError::conflict(format!( + "route conflict: an existing route on upstream {} matches path '{}' with an overlapping method", + r.upstream_id, m.path + ))); + } + } + } + } + RouteMatch::Grpc(g) => { + if g.service.trim().is_empty() || g.method.trim().is_empty() { + return Err(DomainError::validation( + code::MATCH_FIELD, + "match.grpc.service and match.grpc.method must be non-empty", + code::MISSING, + )); + } + } + } + if let Some(rl) = &r.rate_limit { + self.validate_rate_limit(rl)?; + } + if let Some(cors) = &r.cors { + if cors.has_invalid_wildcard_with_credentials() { + return Err(DomainError::validation( + code::CORS_FIELD, + "allow_credentials cannot be combined with allowed_origins ['*']", + code::INVALID_VALUE, + )); + } + } + self.validate_plugin_items(&r.plugins.items, Some(*tenant_id))?; + Ok(()) + } + + // ----------------------------------------------------------------------- + // Plugins + // ----------------------------------------------------------------------- + + pub fn create_plugin(&self, tenant_id: Uuid, mut p: Plugin) -> Result { + if p.plugin_type.trim().is_empty() { + return Err(DomainError::validation( + code::PLUGINS_FIELD, + "pluginType must be a GTS identifier", + code::MISSING, + )); + } + if p.name.trim().is_empty() { + return Err(DomainError::validation( + code::PLUGINS_FIELD, + "name must be non-empty", + code::MISSING, + )); + } + if p.source_code.trim().is_empty() { + return Err(DomainError::validation( + code::PLUGINS_FIELD, + "sourceCode must be non-empty (Starlark)", + code::MISSING, + )); + } + // Unique name per tenant. + let existing = self.plugins.list(tenant_id); + if existing.iter().any(|o| o.name == p.name) { + return Err(DomainError::conflict(format!( + "a plugin named '{}' already exists for this tenant", + p.name + ))); + } + let id = p.id.unwrap_or_else(uuid::Uuid::new_v4); + p.id = Some(id); + p.tenant_id = Some(tenant_id); + self.plugins.put(tenant_id, p.clone())?; + Ok(p) + } + + pub fn delete_plugin(&self, tenant_id: Uuid, id: Uuid) -> Result<(), DomainError> { + if self.plugins.get(tenant_id, id).is_none() { + return Err(DomainError::not_found(Plugin::KIND, id)); + } + let refs = self.plugin_references(tenant_id, id); + if refs.upstreams > 0 || refs.routes > 0 { + return Err(DomainError::conflict(format!( + "Plugin is referenced by {} upstream(s) and {} route(s)", + refs.upstreams, refs.routes + ))); + } + self.plugins.delete(tenant_id, id)?; + Ok(()) + } + + pub fn get_plugin(&self, tenant_id: Uuid, id: Uuid) -> Result, DomainError> { + self.plugins + .get(tenant_id, id) + .ok_or_else(|| DomainError::not_found(Plugin::KIND, id)) + } + + pub fn list_plugins(&self, tenant_id: Uuid) -> Vec> { + self.plugins.list(tenant_id) + } + + /// Count of reference sites (upstreams/routes) binding `plugin_id`. + #[must_use] + pub fn plugin_references(&self, tenant_id: Uuid, plugin_id: Uuid) -> PluginReferences { + let id_str = plugin_id.to_string(); + let mut refs = PluginReferences::default(); + for u in self.upstreams.list(tenant_id) { + let bound = u.auth.plugin_type.as_deref().map_or(false, |t| t == id_str) + || u.plugins + .items + .iter() + .any(|i| i.plugin_ref().eq_ignore_ascii_case(&id_str)); + if bound { + refs.upstreams += 1; + } + } + for r in self.routes.list(tenant_id) { + if r.plugins + .items + .iter() + .any(|i| i.plugin_ref().eq_ignore_ascii_case(&id_str)) + { + refs.routes += 1; + } + } + refs + } + + /// Validate every plugin reference in `items`: builtin GTS identifiers + /// must be in the catalog and bindable in their referenced position; + /// UUID references must name an existing custom plugin of this tenant. + fn validate_plugin_items( + &self, + items: &[PluginItem], + tenant: Option, + ) -> Result<(), DomainError> { + for item in items { + let plugin_ref = item.plugin_ref(); + if plugin_ref.is_empty() { + return Err(DomainError::validation( + code::PLUGINS_FIELD, + "plugin reference is empty", + code::MISSING, + )); + } + if gts::BUILTIN_PLUGIN_IDS.contains(&plugin_ref) { + // Bindable builtins: required_headers guard, request_id + // transform, and the implemented auth plugins are bound here. + // Catalog-only entries (basic/bearer/timeout/cors/logging/ + // metrics) must not be bound via plugins.items. + let bindable = plugin_ref == gts::GUARD_REQUIRED_HEADERS + || plugin_ref == gts::TRANSFORM_REQUEST_ID; + if !bindable { + return Err(DomainError::validation( + code::PLUGINS_FIELD, + format!("plugin '{plugin_ref}' is catalog-only and cannot be bound via plugins.items"), + code::UNSUPPORTED, + )); + } + continue; + } + // Custom plugin reference: a UUID of an existing plugin. + if let Ok(id) = uuid::Uuid::parse_str(plugin_ref) { + let tenant_ok = tenant + .map(|t| self.plugins.get(t, id).is_some()) + .unwrap_or(false); + if !tenant_ok { + return Err(DomainError::validation( + code::PLUGINS_FIELD, + format!("unknown custom plugin '{plugin_ref}'"), + code::NOT_FOUND, + )); + } + } else { + return Err(DomainError::validation( + code::PLUGINS_FIELD, + format!("unknown plugin identifier '{plugin_ref}'"), + code::NOT_FOUND, + )); + } + } + Ok(()) + } + + /// Validate a custom plugin type used as `upstream.auth.type`. + pub fn validate_auth_type(&self, auth_type: &str) -> Result<(), DomainError> { + let known = gts::AUTH_NOOP == auth_type + || gts::AUTH_APIKEY == auth_type + || gts::AUTH_OAUTH2_CLIENT_CRED == auth_type + || gts::AUTH_OAUTH2_CLIENT_CRED_BASIC == auth_type; + if !known { + return Err(DomainError::validation( + code::AUTH_FIELD, + format!("unknown auth plugin type '{auth_type}'"), + code::UNSUPPORTED, + )); + } + Ok(()) + } +} + +/// Reference counts for plugin in-use checks. +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] +pub struct PluginReferences { + pub upstreams: usize, + pub routes: usize, +} + +/// Data-plane service interface. Implemented by +/// `crate::infra::proxy::DataPlaneServiceImpl`. +#[async_trait::async_trait] +pub trait DataPlaneService: Send + Sync { + /// Proxy a fully-resolved request to its upstream. `tenant_id` is the + /// *calling* tenant; the implementation walks the tenant chain. + async fn proxy( + &self, + tenant_id: Uuid, + subject_id: Uuid, + req: axum::http::Request, + target_host_header: Option, + ) -> axum::response::Response; +} diff --git a/gears/system/oagw/oagw/src/gear.rs b/gears/system/oagw/oagw/src/gear.rs new file mode 100644 index 0000000..048265b --- /dev/null +++ b/gears/system/oagw/oagw/src/gear.rs @@ -0,0 +1,151 @@ +//! Gear declaration for the OAGW (outbound API gateway) gear. + +use std::sync::{Arc, OnceLock}; +use std::time::Duration; + +use async_trait::async_trait; +use pingora_memory_cache::MemoryCache; +use tenant_resolver_sdk::TenantResolverClient; +use toolkit::{Gear, GearCtx, RestApiCapability}; +use toolkit::api::OpenApiRegistry; +use toolkit::contracts::SystemCapability; +use tracing::{debug, info}; + +use crate::config::OagwConfig; +use crate::domain::service::{ControlPlaneService, DataPlaneService}; +use crate::infra::plugins::{CachedToken, PluginRegistryImpl}; +use crate::infra::proxy::DataPlaneServiceImpl; +use crate::infra::storage::{InMemoryPluginRepo, InMemoryRouteRepo, InMemoryUpstreamRepo}; + +/// OAGW — outbound API gateway gear. +/// +/// Provides: +/// +/// - **Control plane**: upstream / route / plugin CRUD at `/oagw/v1/*`. +/// - **Data plane**: request proxying at `/oagw/v1/proxy/{alias}/{*path}`. +/// +/// ## Capabilities +/// +/// - `system` — core infrastructure gear, initialized early (needs the +/// credstore / tenant-resolver clients published by sibling gears). +/// - `rest` — exposes the management + proxy surface. +/// +/// ## Registration +/// +/// Registered via `#[toolkit::gear]`; the example server's +/// `registered_gears.rs` links this crate (`use api_egress as _;`). The host +/// (api-gateway) nests this gear's router under its configured `prefix_path` +/// (empty in the e2e setup), yielding routes at `/oagw/v1/...`. +#[toolkit::gear( + name = "oagw", + capabilities = [system, rest], + deps = [credstore, tenant_resolver] +)] +pub struct OagwGear { + control: OnceLock>, + data_plane: OnceLock>, +} + +impl Default for OagwGear { + fn default() -> Self { + Self { + control: OnceLock::new(), + data_plane: OnceLock::new(), + } + } +} + +#[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_policy_enabled = cfg.ssrf_policy.enabled, + token_cache = ?cfg.token_cache, + "oagw: loaded configuration" + ); + + // Shared in-memory stores. + let upstreams = Arc::new(InMemoryUpstreamRepo::new()); + let routes = Arc::new(InMemoryRouteRepo::new()); + let plugins = Arc::new(InMemoryPluginRepo::new()); + + // Control plane. + let control = Arc::new(ControlPlaneService::new( + upstreams.clone(), + routes.clone(), + plugins.clone(), + cfg.clone(), + )); + + // Data plane dependencies: credential store + tenant resolver clients + // published by sibling gears (credstore / tenant-resolver). + let credstore = ctx.client_hub().get::()?; + let tenants = ctx.client_hub().get::()?; + + let token_cache: Arc> = + Arc::new(MemoryCache::new(cfg.token_cache.cache_capacity)); + + let plugin_registry = Arc::new(PluginRegistryImpl::new( + credstore, + token_cache, + cfg.token_cache.clone(), + Duration::from_secs(cfg.proxy_timeout_secs.max(1)), + plugins.clone(), + )); + + let data_plane: Arc = Arc::new(DataPlaneServiceImpl::new( + upstreams, + routes, + plugin_registry, + tenants, + cfg, + )?); + + self.control + .set(control) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + self.data_plane + .set(data_plane) + .map_err(|_| anyhow::anyhow!("{} gear already initialized", Self::MODULE_NAME))?; + + info!("oagw: gear initialized"); + Ok(()) + } +} + +#[async_trait] +impl SystemCapability for OagwGear { + async fn post_init(&self, _sys: &toolkit::runtime::SystemContext) -> anyhow::Result<()> { + info!("oagw: post_init complete (all stores in-memory)"); + Ok(()) + } +} + +impl RestApiCapability for OagwGear { + fn register_rest( + &self, + _ctx: &GearCtx, + router: axum::Router, + openapi: &dyn OpenApiRegistry, + ) -> anyhow::Result { + info!("Registering oagw REST routes"); + + let control = self + .control + .get() + .ok_or_else(|| anyhow::anyhow!("Control plane service not initialized"))? + .clone(); + let data_plane = self + .data_plane + .get() + .ok_or_else(|| anyhow::anyhow!("Data plane not initialized"))? + .clone(); + + let router = crate::api::rest::routes::register_routes(router, openapi, control, data_plane); + info!("oagw REST routes registered successfully"); + Ok(router) + } +} diff --git a/gears/system/oagw/oagw/src/gts.rs b/gears/system/oagw/oagw/src/gts.rs new file mode 100644 index 0000000..0a81b28 --- /dev/null +++ b/gears/system/oagw/oagw/src/gts.rs @@ -0,0 +1,197 @@ +//! GTS identifier vocabulary for the OAGW gear. +//! +//! Centralises every OAGW GTS identifier used on the wire: error types for +//! data-plane problem responses, entity resource types for control-plane +//! canonical errors, protocol identifiers, and the built-in plugin catalog. +//! +//! All constants are compile-time-checked against the GTS identifier grammar +//! via the `gts_id!` macro. +//! +//! ## Error type shape +//! +//! Data-plane errors use the shared platform error resource type with an +//! OAGW-scoped instance segment: +//! +//! ```text +//! gts.cf.core.errors.err.v1~cf.oagw...v1 +//! ``` + +use toolkit_gts::gts_id; + +// --------------------------------------------------------------------------- +// Entity resource types (control-plane canonical errors) +// --------------------------------------------------------------------------- + +/// Canonical resource type for upstream entities. +pub const UPSTREAM_RESOURCE_TYPE: &str = gts_id!("cf.core.oagw.upstream.v1~"); +/// Canonical resource type for route entities. +pub const ROUTE_RESOURCE_TYPE: &str = gts_id!("cf.core.oagw.route.v1~"); +/// Canonical resource type for plugin entities. +pub const PLUGIN_RESOURCE_TYPE: &str = gts_id!("cf.core.oagw.plugin.v1~"); + +// --------------------------------------------------------------------------- +// Protocol identifiers +// --------------------------------------------------------------------------- + +/// GTS identifier for the HTTP upstream protocol. +pub const PROTOCOL_HTTP: &str = gts_id!("cf.core.oagw.protocol.v1~cf.core.oagw.http.v1"); +/// GTS identifier for the gRPC upstream protocol. +pub const PROTOCOL_GRPC: &str = gts_id!("cf.core.oagw.protocol.v1~cf.core.oagw.grpc.v1"); + +// --------------------------------------------------------------------------- +// Error types (data-plane problem responses) +// --------------------------------------------------------------------------- + +/// Request validation failed. +pub const ERR_VALIDATION_ERROR: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.validation.error.v1"); +/// Proxy target host header required but missing. +pub const ERR_MISSING_TARGET_HOST: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.routing.missing_target_host.v1"); +/// Proxy target host header present but malformed. +pub const ERR_INVALID_TARGET_HOST: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.routing.invalid_target_host.v1"); +/// Proxy target host header does not match any upstream endpoint. +pub const ERR_UNKNOWN_TARGET_HOST: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.routing.unknown_target_host.v1"); +/// Authentication with the upstream failed. +pub const ERR_AUTH_FAILED: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.auth.failed.v1"); +/// CORS origin rejected on an actual cross-origin request. +pub const ERR_CORS_ORIGIN_NOT_ALLOWED: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.cors.origin_not_allowed.v1"); +/// CORS method rejected on an actual cross-origin request. +pub const ERR_CORS_METHOD_NOT_ALLOWED: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.cors.method_not_allowed.v1"); +/// No route matched, or the upstream behind the alias was not found. +pub const ERR_ROUTE_NOT_FOUND: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.route.not_found.v1"); +/// A plugin could not be deleted because it is still referenced. +pub const ERR_PLUGIN_IN_USE: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.plugin.in_use.v1"); +/// Request body exceeded the configured limit. +pub const ERR_PAYLOAD_TOO_LARGE: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.payload.too_large.v1"); +/// A rate-limit was exceeded. +pub const ERR_RATE_LIMIT_EXCEEDED: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.rate_limit.exceeded.v1"); +/// A credential reference could not be resolved. +pub const ERR_SECRET_NOT_FOUND: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.secret.not_found.v1"); +/// Protocol-level error talking to the upstream. +pub const ERR_PROTOCOL_ERROR: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.protocol.error.v1"); +/// The downstream (client) connection failed. +pub const ERR_DOWNSTREAM_ERROR: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.downstream.error.v1"); +/// The upstream stream was aborted mid-transfer. +pub const ERR_STREAM_ABORTED: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.stream.aborted.v1"); +/// The upstream link is unavailable (disabled, or connectivity refused). +pub const ERR_LINK_UNAVAILABLE: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.link.unavailable.v1"); +/// The upstream circuit breaker is open. +pub const ERR_CIRCUIT_BREAKER_OPEN: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.circuit_breaker.open.v1"); +/// A referenced plugin could not be resolved. +pub const ERR_PLUGIN_NOT_FOUND: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.plugin.not_found.v1"); +/// Connecting to the upstream timed out. +pub const ERR_TIMEOUT_CONNECTION: &str = + gts_id!("cf.core.errors.err.v1~cf.oagw.timeout.connection.v1"); +/// Waiting for the upstream response timed out. +pub const ERR_TIMEOUT_REQUEST: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.timeout.request.v1"); +/// The upstream connection idled out. +pub const ERR_TIMEOUT_IDLE: &str = gts_id!("cf.core.errors.err.v1~cf.oagw.timeout.idle.v1"); + +// --------------------------------------------------------------------------- +// Built-in plugin catalog (types-registry-facing identifiers) +// --------------------------------------------------------------------------- + +// Auth plugins. +pub const AUTH_NOOP: &str = gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.noop.v1"); +pub const AUTH_APIKEY: &str = gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.apikey.v1"); +pub const AUTH_OAUTH2_CLIENT_CRED: &str = + gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred.v1"); +pub const AUTH_OAUTH2_CLIENT_CRED_BASIC: &str = + gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.oauth2_client_cred_basic.v1"); +/// Catalog-only (no backing implementation): binding fails. +pub const AUTH_BASIC: &str = gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.basic.v1"); +/// Catalog-only (no backing implementation): binding fails. +pub const AUTH_BEARER: &str = gts_id!("cf.core.oagw.auth_plugin.v1~cf.core.oagw.bearer.v1"); + +// Guard plugins. +pub const GUARD_REQUIRED_HEADERS: &str = + gts_id!("cf.core.oagw.guard_plugin.v1~cf.core.oagw.required_headers.v1"); +/// Catalog-only: bound via the dedicated `cors` config surface, not +/// `plugins.items`. +pub const GUARD_CORS: &str = gts_id!("cf.core.oagw.guard_plugin.v1~cf.core.oagw.cors.v1"); +/// Catalog-only: bound via gear-level proxy timeout config, not +/// `plugins.items`. +pub const GUARD_TIMEOUT: &str = gts_id!("cf.core.oagw.guard_plugin.v1~cf.core.oagw.timeout.v1"); + +// Transform plugins. +pub const TRANSFORM_REQUEST_ID: &str = + gts_id!("cf.core.oagw.transform_plugin.v1~cf.core.oagw.request_id.v1"); +/// Catalog-only (no backing implementation). +pub const TRANSFORM_LOGGING: &str = + gts_id!("cf.core.oagw.transform_plugin.v1~cf.core.oagw.logging.v1"); +/// Catalog-only (no backing implementation). +pub const TRANSFORM_METRICS: &str = + gts_id!("cf.core.oagw.transform_plugin.v1~cf.core.oagw.metrics.v1"); + +/// All catal/'d built-in plugin identifiers (any category). +pub const BUILTIN_PLUGIN_IDS: &[&str] = &[ + AUTH_NOOP, + AUTH_APIKEY, + AUTH_OAUTH2_CLIENT_CRED, + AUTH_OAUTH2_CLIENT_CRED_BASIC, + AUTH_BASIC, + AUTH_BEARER, + GUARD_REQUIRED_HEADERS, + GUARD_CORS, + GUARD_TIMEOUT, + TRANSFORM_REQUEST_ID, + TRANSFORM_LOGGING, + TRANSFORM_METRICS, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn gts_ids_are_wellformed() { + for id in [ + UPSTREAM_RESOURCE_TYPE, + ROUTE_RESOURCE_TYPE, + PLUGIN_RESOURCE_TYPE, + PROTOCOL_HTTP, + PROTOCOL_GRPC, + ERR_VALIDATION_ERROR, + ERR_MISSING_TARGET_HOST, + ERR_INVALID_TARGET_HOST, + ERR_UNKNOWN_TARGET_HOST, + ERR_AUTH_FAILED, + ERR_ROUTE_NOT_FOUND, + ERR_PLUGIN_IN_USE, + ERR_PAYLOAD_TOO_LARGE, + ERR_RATE_LIMIT_EXCEEDED, + ERR_SECRET_NOT_FOUND, + ERR_PROTOCOL_ERROR, + ERR_DOWNSTREAM_ERROR, + ERR_STREAM_ABORTED, + ERR_LINK_UNAVAILABLE, + ERR_CIRCUIT_BREAKER_OPEN, + ERR_PLUGIN_NOT_FOUND, + ERR_TIMEOUT_CONNECTION, + ERR_TIMEOUT_REQUEST, + ERR_TIMEOUT_IDLE, + ] { + assert!( + toolkit_gts::GtsId::try_new(id).is_ok(), + "invalid GTS id: {id}" + ); + } + } + + #[test] + fn error_types_use_platform_error_resource() { + assert!(ERR_VALIDATION_ERROR.starts_with("gts.cf.core.errors.err.v1~cf.oagw.")); + assert!(ERR_RATE_LIMIT_EXCEEDED.starts_with("gts.cf.core.errors.err.v1~cf.oagw.")); + } +} 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..e27c77c --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/mod.rs @@ -0,0 +1,6 @@ +//! Infrastructure layer for the OAGW gear: in-memory storage, the built-in +//! plugin registry + implementations, and the data-plane proxy engine. + +pub mod plugins; +pub mod proxy; +pub mod storage; diff --git a/gears/system/oagw/oagw/src/infra/plugins/mod.rs b/gears/system/oagw/oagw/src/infra/plugins/mod.rs new file mode 100644 index 0000000..69ed88d --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/plugins/mod.rs @@ -0,0 +1,575 @@ +//! Built-in plugin implementations and the data-plane plugin registry. +//! +//! Implements the plugin system specified by ADR 0002 / 0008 / 0009: +//! +//! - **Auth**: `noop`, `apikey`, `oauth2_client_cred` (Form), +//! `oauth2_client_cred_basic` (Basic). +//! - **Guard**: `required_headers` (request → 400, response → 502). +//! - **Transform**: `request_id` (X-Request-ID propagation). +//! +//! The registry resolves GTS plugin identifiers and custom plugin UUIDs into +//! [`BoundPlugin`] instances; catalog-only identifiers +//! (`basic`/`bearer`/`timeout`/`cors`/`logging`/`metrics`) refuse to bind. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use pingora_memory_cache::MemoryCache; +use serde_json::Value; +use toolkit_auth::oauth2::{ClientAuthMethod, OAuthClientConfig, SecretString}; +use toolkit_http::HttpClientConfig; +use uuid::Uuid; + +use crate::config::TokenCacheConfig; +use crate::domain::plugin::{ + AuthError, AuthPlugin, BoundPlugin, ErrorContext, GuardError, GuardPlugin, + PluginBindingRef, PluginRegistry, RequestContext, ResponseContext, TransformError, + TransformPlugin, +}; +use crate::domain::repo::PluginRepo; +use crate::gts; + +/// Registry that resolves plugin references into live instances. +pub struct PluginRegistryImpl { + credstore: Arc, + token_cache: Arc>, + cache_cfg: TokenCacheConfig, + http_config: HttpClientConfig, + custom: Arc, +} + +impl std::fmt::Debug for PluginRegistryImpl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PluginRegistryImpl").finish_non_exhaustive() + } +} + +impl PluginRegistryImpl { + /// Construct the registry with the shared credential client, token cache, + /// and custom-plugin repository. + #[must_use] + pub fn new( + credstore: Arc, + token_cache: Arc>, + cache_cfg: TokenCacheConfig, + proxy_timeout: Duration, + custom: Arc, + ) -> Self { + let mut http_config = HttpClientConfig::proxy(); + http_config.request_timeout = proxy_timeout; + Self { + credstore, + token_cache, + cache_cfg, + http_config, + custom, + } + } + + /// Resolve the upstream auth plugin by its `auth_plugin` GTS type. + pub fn auth_plugin(&self, plugin_type: &str) -> Result, String> { + match plugin_type { + gts::AUTH_NOOP => Ok(Box::new(NoopAuthPlugin) as Box), + gts::AUTH_APIKEY => Ok(Box::new(ApiKeyAuthPlugin::new(self.credstore.clone())) + as Box), + gts::AUTH_OAUTH2_CLIENT_CRED => Ok(self.oauth2(ClientAuthMethod::Form)), + gts::AUTH_OAUTH2_CLIENT_CRED_BASIC => Ok(self.oauth2(ClientAuthMethod::Basic)), + _ => Err(format!("unknown auth plugin type '{plugin_type}'")), + } + } + + fn oauth2(&self, method: ClientAuthMethod) -> Box { + Box::new(OAuth2ClientCredAuthPlugin::new( + method, + self.credstore.clone(), + self.token_cache.clone(), + self.cache_cfg.clone(), + self.http_config.clone(), + )) + } +} + +#[async_trait] +impl PluginRegistry for PluginRegistryImpl { + async fn resolve(&self, binding: &PluginBindingRef) -> Result { + let config = binding.config.clone(); + match binding.plugin_ref.as_str() { + gts::AUTH_NOOP | gts::AUTH_APIKEY => { + let auth = self.auth_plugin(&binding.plugin_ref)?; + Ok(BoundPlugin::Auth(auth, config)) + } + gts::AUTH_OAUTH2_CLIENT_CRED => { + Ok(BoundPlugin::Auth(self.oauth2(ClientAuthMethod::Form), config)) + } + gts::AUTH_OAUTH2_CLIENT_CRED_BASIC => { + Ok(BoundPlugin::Auth(self.oauth2(ClientAuthMethod::Basic), config)) + } + gts::GUARD_REQUIRED_HEADERS => Ok(BoundPlugin::Guard( + Box::new(RequiredHeadersGuardPlugin), + config, + )), + gts::TRANSFORM_REQUEST_ID => Ok(BoundPlugin::Transform( + Box::new(RequestIdTransformPlugin), + config, + )), + // Catalog-only identifiers must not be bound through the chain. + gts::AUTH_BASIC + | gts::AUTH_BEARER + | gts::GUARD_TIMEOUT + | gts::GUARD_CORS + | gts::TRANSFORM_LOGGING + | gts::TRANSFORM_METRICS => Err(format!( + "plugin '{}' is catalog-only and cannot be bound", + binding.plugin_ref + )), + other => { + // Custom plugin by UUID: exists in tenant scope but has no + // executable backing (Starlark execution is out of scope). + if let Ok(id) = Uuid::parse_str(other) { + if self.custom.get(binding.tenant_id, id).is_some() { + Err(format!( + "custom plugin '{other}' has no executable backing implementation" + )) + } else { + Err(format!("unknown custom plugin '{other}'")) + } + } else { + Err(format!("unknown plugin identifier '{other}'")) + } + } + } + } +} + +// --------------------------------------------------------------------------- +// noop +// --------------------------------------------------------------------------- + +/// No-op authentication: forwards the inbound `Authorization` header as-is. +pub struct NoopAuthPlugin; + +#[async_trait] +impl AuthPlugin for NoopAuthPlugin { + fn id(&self) -> &'static str { + gts::AUTH_NOOP + } + + fn plugin_type(&self) -> &'static str { + gts::AUTH_NOOP + } + + async fn authenticate(&self, ctx: &mut RequestContext<'_>) -> Result<(), AuthError> { + // Preserve the caller-provided credentials verbatim. + if let Some(v) = ctx + .inbound_header(http::header::AUTHORIZATION.as_str()) + .map(str::to_owned) + { + ctx.set_outbound_header(http::header::AUTHORIZATION.as_str(), &v); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// apikey +// --------------------------------------------------------------------------- + +/// API-key authentication. Configuration: +/// +/// - `header` (default `x-api-key`): the request header carrying the key. +/// - `key_ref` (`cred://`): credential store reference for the *expected* +/// key. `secret_ref` is accepted as an alias. +/// +/// The upstream is called with the (possibly normalized) key header intact; +/// when no credential is configured, the key is passed through. +pub struct ApiKeyAuthPlugin { + credstore: Arc, +} + +impl ApiKeyAuthPlugin { + #[must_use] + pub fn new(credstore: Arc) -> Self { + Self { credstore } + } +} + +#[async_trait] +impl AuthPlugin for ApiKeyAuthPlugin { + fn id(&self) -> &'static str { + gts::AUTH_APIKEY + } + + fn plugin_type(&self) -> &'static str { + gts::AUTH_APIKEY + } + + async fn authenticate(&self, ctx: &mut RequestContext<'_>) -> Result<(), AuthError> { + let config = ctx.config; + let header = config + .get("header") + .and_then(Value::as_str) + .unwrap_or("x-api-key"); + + // Pass the caller's key through to the upstream. + if let Some(v) = ctx.inbound_header(header).map(str::to_owned) { + ctx.set_outbound_header(header, &v); + } + + // When an expected key is configured, enforce equality. + let key_ref = config + .get("key_ref") + .or_else(|| config.get("secret_ref")) + .and_then(Value::as_str); + if let Some(key_ref) = key_ref { + let expected = resolve_secret_raw(&*self.credstore, ctx, key_ref).await?; + let provided = ctx.inbound_header(header).unwrap_or(""); + if provided != expected { + return Err(AuthError::Rejected("invalid API key".to_owned())); + } + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// oauth2 client credentials +// --------------------------------------------------------------------------- + +/// Cached OAuth2 bearer token with the cache-key it was stored under. +#[derive(Clone)] +pub struct CachedToken { + /// The exact cache key this token was stored under (read-time + /// verification against config drift). + pub key: String, + /// The bearer token value. + pub bearer: String, +} + +/// A token-bucket entry for the auth token cache. +pub struct OAuth2ClientCredAuthPlugin { + auth_method: ClientAuthMethod, + plugin_id: &'static str, + plugin_type: &'static str, + credstore: Arc, + cache: Arc>, + ttl: Duration, + http_config: HttpClientConfig, +} + +impl OAuth2ClientCredAuthPlugin { + /// Construct the plugin for the given client-auth method. + #[must_use] + pub fn new( + auth_method: ClientAuthMethod, + credstore: Arc, + cache: Arc>, + cache_cfg: TokenCacheConfig, + http_config: HttpClientConfig, + ) -> Self { + let (plugin_id, plugin_type) = match auth_method { + ClientAuthMethod::Form => (gts::AUTH_OAUTH2_CLIENT_CRED, gts::AUTH_OAUTH2_CLIENT_CRED), + ClientAuthMethod::Basic => { + (gts::AUTH_OAUTH2_CLIENT_CRED_BASIC, gts::AUTH_OAUTH2_CLIENT_CRED_BASIC) + } + }; + let ttl = Duration::from_secs(cache_cfg.cache_ttl_secs.max(1)); + Self { + auth_method, + plugin_id, + plugin_type, + credstore, + cache, + ttl, + http_config, + } + } + + /// Stable cache key per ADR 0008: + /// `{tenant}:{subject}:{auth_method}:{sorted_config_hash}`. + fn cache_key(&self, ctx: &RequestContext<'_>) -> String { + let tenant = ctx.tenant_id; + let subject = ctx.security.subject_id(); + let auth_method = match self.auth_method { + ClientAuthMethod::Form => "form", + ClientAuthMethod::Basic => "basic", + }; + let hash = sorted_config_hash(ctx.config); + format!("{tenant}:{subject}:{auth_method}:{hash}") + } +} + +#[async_trait] +impl AuthPlugin for OAuth2ClientCredAuthPlugin { + fn id(&self) -> &'static str { + self.plugin_id + } + + fn plugin_type(&self) -> &'static str { + self.plugin_type + } + + async fn authenticate(&self, ctx: &mut RequestContext<'_>) -> Result<(), AuthError> { + let key = self.cache_key(ctx); + + // Cache hit (with key verification): reuse the token. + { + let (cached, status) = self.cache.get(&key); + if let Some(cached) = cached { + if cached.key == key && status == pingora_memory_cache::CacheStatus::Hit { + ctx.set_outbound_header(http::header::AUTHORIZATION.as_str(), { + // RFC 6750 + format!("Bearer {}", cached.bearer) + }); + return Ok(()); + } + } + } + + // Resolve configuration. + let (token_endpoint, issuer_url) = match ( + ctx.config.get("token_endpoint").and_then(Value::as_str), + ctx.config.get("issuer_url").and_then(Value::as_str), + ) { + (Some(t), None) => (Some(t.to_owned()), None), + (None, Some(i)) => (None, Some(i.to_owned())), + (None, None) => { + return Err(AuthError::Rejected( + "oauth2 plugin requires token_endpoint or issuer_url".to_owned(), + )); + } + (Some(_), Some(_)) => { + return Err(AuthError::Rejected( + "token_endpoint and issuer_url are mutually exclusive".to_owned(), + )); + } + }; + + let client_id_ref = ctx + .config + .get("client_id_ref") + .and_then(Value::as_str) + .ok_or_else(|| AuthError::Rejected("client_id_ref is required".to_owned()))?; + let client_secret_ref = ctx + .config + .get("client_secret_ref") + .and_then(Value::as_str) + .ok_or_else(|| AuthError::Rejected("client_secret_ref is required".to_owned()))?; + let scopes: Vec = ctx + .config + .get("scopes") + .and_then(Value::as_str) + .map(|s| s.split_whitespace().map(str::to_owned).collect()) + .unwrap_or_default(); + + let client_id = resolve_secret_raw(&*self.credstore, ctx, client_id_ref).await?; + let client_secret = resolve_secret_raw(&*self.credstore, ctx, client_secret_ref).await?; + + let mut oauth = OAuthClientConfig { + token_endpoint: token_endpoint + .and_then(|t| url::Url::parse(&t).ok()), + issuer_url: issuer_url.and_then(|i| url::Url::parse(&i).ok()), + client_id, + client_secret: SecretString::new(client_secret), + scopes, + auth_method: self.auth_method, + extra_headers: Vec::new(), + http_config: Some(self.http_config.clone()), + ..Default::default() + }; + oauth.min_refresh_period = Duration::from_secs(1); + oauth.jitter_max = Duration::ZERO; + oauth.refresh_offset = Duration::from_secs(30); + oauth.default_ttl = self.ttl; + + let token = match toolkit_auth::oauth2::fetch_token(oauth).await { + Ok(t) => t, + Err(e) => { + return Err(AuthError::Backend(format!( + "token fetch failed: {e}" + ))); + } + }; + + // Effective TTL = min(config ttl, expires_in − 30s safety margin). + let mut effective = self.ttl; + let expires = token.expires_in; + if let Some(safety) = expires.checked_sub(Duration::from_secs(30)) { + effective = effective.min(safety).max(Duration::from_secs(1)); + } + + let bearer = token.bearer.expose().to_owned(); + self.cache.put( + &key, + CachedToken { + key: key.clone(), + bearer: bearer.clone(), + }, + Some(effective), + ); + + ctx.set_outbound_header(http::header::AUTHORIZATION.as_str(), { + format!("Bearer {bearer}") + }); + Ok(()) + } +} + +/// Deterministic hash over the sorted (`key`, `value`) pairs of a config +/// object (ADR 0008 config-key component). +fn sorted_config_hash(config: &Value) -> String { + use std::collections::BTreeMap; + let mut pairs = BTreeMap::new(); + if let Some(obj) = config.as_object() { + for (k, v) in obj { + pairs.insert(k.clone(), v.to_string()); + } + } + let mut fnv: u64 = 0xcbf2_9ce4_8422_2325; + for (k, v) in &pairs { + for b in format!("{k}={v};").bytes() { + fnv ^= u64::from(b); + fnv = fnv.wrapping_mul(0x100_0000_01b3); + } + } + format!("{fnv:016x}") +} + +/// Resolve a `cred://…` reference through the credential store for the +/// calling security context. +async fn resolve_secret_raw( + credstore: &dyn credstore_sdk::CredStoreClientV1, + ctx: &RequestContext<'_>, + secret_ref: &str, +) -> Result { + let schema_key = secret_ref + .strip_prefix("cred://") + .unwrap_or(secret_ref) + .to_owned(); + let reference = credstore_sdk::SecretRef::new(schema_key) + .map_err(|_| AuthError::SecretNotFound(secret_ref.to_owned()))?; + let resp = credstore + .get(ctx.security, &reference) + .await + .map_err(|e| AuthError::Backend(format!("credstore lookup failed: {e}")))?; + match resp { + Some(secret) => String::from_utf8(secret.value.as_bytes().to_vec()) + .map_err(|_| AuthError::SecretNotFound(secret_ref.to_owned())), + None => Err(AuthError::SecretNotFound(secret_ref.to_owned())), + } +} + +// --------------------------------------------------------------------------- +// required_headers guard +// --------------------------------------------------------------------------- + +/// Guard plugin enforcing the presence of request/response headers +/// (ADR 0009). Config keys `required_request_headers` / `required_response_headers` +/// are comma-separated header names; a missing request header yields 400 +/// (REQUIRED_HEADER_MISSING), a missing response header yields 502. +pub struct RequiredHeadersGuardPlugin; + +impl RequiredHeadersGuardPlugin { + fn parse_list(config: &Value, key: &str) -> Vec { + config + .get(key) + .and_then(Value::as_str) + .map(|s| { + s.split(',') + .map(str::trim) + .filter(|x| !x.is_empty()) + .map(str::to_ascii_lowercase) + .collect() + }) + .unwrap_or_default() + } +} + +#[async_trait] +impl GuardPlugin for RequiredHeadersGuardPlugin { + fn id(&self) -> &'static str { + gts::GUARD_REQUIRED_HEADERS + } + + fn plugin_type(&self) -> &'static str { + gts::GUARD_REQUIRED_HEADERS + } + + async fn guard_request(&self, ctx: &RequestContext<'_>) -> Result<(), GuardError> { + let required = Self::parse_list(ctx.config, "required_request_headers"); + if required.is_empty() { + return Ok(()); // fail-open on absent/blank config + } + for name in required { + let present = ctx + .headers + .get(&name) + .map(|v| !v.is_empty()) + .unwrap_or(false); + if !present { + return Err(GuardError::Request { + error_code: "REQUIRED_HEADER_MISSING".to_owned(), + detail: format!("required request header '{name}' is missing"), + }); + } + } + Ok(()) + } + + async fn guard_response(&self, ctx: &mut ResponseContext<'_>) -> Result<(), GuardError> { + let required = Self::parse_list(ctx.config, "required_response_headers"); + if required.is_empty() { + return Ok(()); // fail-open + } + for name in required { + let present = ctx + .headers + .get(&name) + .map(|v| !v.is_empty()) + .unwrap_or(false); + if !present { + return Err(GuardError::Response { + error_code: "REQUIRED_HEADER_MISSING".to_owned(), + detail: format!("required response header '{name}' is missing"), + }); + } + } + Ok(()) + } +} + +// --------------------------------------------------------------------------- +// request_id transform +// --------------------------------------------------------------------------- + +/// Transform plugin propagating/generating `X-Request-ID`. +pub struct RequestIdTransformPlugin; + +#[async_trait] +impl TransformPlugin for RequestIdTransformPlugin { + fn id(&self) -> &'static str { + gts::TRANSFORM_REQUEST_ID + } + + fn plugin_type(&self) -> &'static str { + gts::TRANSFORM_REQUEST_ID + } + + async fn transform_request(&self, ctx: &mut RequestContext<'_>) -> Result<(), TransformError> { + let existing = ctx.inbound_header("x-request-id").map(str::to_owned); + let value = existing.unwrap_or_else(|| Uuid::new_v4().to_string()); + ctx.set_outbound_header("x-request-id", value); + Ok(()) + } + + async fn transform_response(&self, ctx: &mut ResponseContext<'_>) -> Result<(), TransformError> { + if !ctx.headers.contains_key("x-request-id") { + ctx.set_header("x-request-id", Uuid::new_v4().to_string()); + } + Ok(()) + } + + async fn transform_error(&self, _ctx: &mut ErrorContext<'_>) -> Result<(), TransformError> { + Ok(()) + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/headers.rs b/gears/system/oagw/oagw/src/infra/proxy/headers.rs new file mode 100644 index 0000000..a3cc7c1 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/headers.rs @@ -0,0 +1,143 @@ +//! Header cleaning and transformation for the data plane. +//! +//! Implements the DESIGN "Headers Transformation" rules: routing headers are +//! consumed (never forwarded), hop-by-hop headers are stripped, then the +//! upstream `headers` rules (set/add/remove/passthrough) are applied to the +//! outbound request and the upstream response. + +use http::HeaderMap; +use http::header::{CONNECTION, CONTENT_TYPE, HOST, TE, TRAILER, TRANSFER_ENCODING, UPGRADE}; + +use crate::domain::models::{HeaderTransforms, PassthroughMode, RequestHeaderRules, ResponseHeaderRules}; + +/// OAGW routing headers consumed by the data plane (never forwarded). +pub const ROUTING_HEADERS: [&str; 4] = [ + // "x-oagw-target-host" is read by the proxy handler and stripped here. + "x-oagw-target-host", + "x-oagw-error-source", + "x-forwarded-for", + "x-forwarded-proto", +]; + +/// Strip hop-by-hop and OAGW-routing headers from an outbound header map. +/// +/// `target_authority` becomes the new `Host` value (matching the forwarded +/// endpoint); `content_length`/`content_type` are left intact for the caller +/// to manage when the body is re-buffered. +pub fn clean_outbound(headers: &mut HeaderMap, target_authority: &str) { + let hop_by_hop = [ + CONNECTION.as_str(), + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + TE.as_str(), + TRAILER.as_str(), + TRANSFER_ENCODING.as_str(), + UPGRADE.as_str(), + ]; + for name in hop_by_hop { + headers.remove(name); + } + for name in ROUTING_HEADERS { + headers.remove(name); + } + // Replace Host with the target endpoint authority. + if let Ok(value) = http::HeaderValue::from_str(target_authority) { + headers.insert(HOST, value); + } +} + +/// Apply request header rules from `upstream.headers` on top of the cleaned +/// outbound map. +pub fn apply_request_rules(headers: &mut HeaderMap, rules: &RequestHeaderRules) { + for (name, value) in &rules.set { + if let (Ok(name), Ok(value)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + headers.insert(name, value); + } + } + for (name, value) in &rules.add { + if let (Ok(name), Ok(value)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + headers.append(name, value); + } + } + for name in &rules.remove { + if let Ok(name) = http::HeaderName::from_bytes(name.as_bytes()) { + headers.remove(name); + } + } +} + +/// Apply response header rules from `upstream.headers` to an upstream +/// response. +pub fn apply_response_rules(headers: &mut HeaderMap, rules: &ResponseHeaderRules) { + for (name, value) in &rules.set { + if let (Ok(name), Ok(value)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + headers.insert(name, value); + } + } + for (name, value) in &rules.add { + if let (Ok(name), Ok(value)) = ( + http::HeaderName::from_bytes(name.as_bytes()), + http::HeaderValue::from_str(value), + ) { + headers.append(name, value); + } + } + for name in &rules.remove { + if let Ok(name) = http::HeaderName::from_bytes(name.as_bytes()) { + headers.remove(name); + } + } +} + +/// Decide which inbound headers survive to the outbound request. +/// +/// Returns the outbound map to hand to the transport: restricted per +/// `passthrough` mode (none → nothing, allowlist → listed, all → everything +/// except hop-by-hop/routing headers), the `Host` replaced with the target +/// authority, then the `set`/`add`/`remove` rules applied on top. +pub fn plan_request_headers( + inbound: &HeaderMap, + config: &HeaderTransforms, + target_authority: &str, +) -> HeaderMap { + let mut outbound = match config.request.passthrough { + PassthroughMode::None => HeaderMap::new(), + PassthroughMode::Allowlist => { + let mut next = HeaderMap::new(); + for (name, value) in inbound { + let n = name.as_str().to_ascii_lowercase(); + if config + .request + .passthrough_allowlist + .iter() + .any(|a| a == &n) + { + next.insert(name.clone(), value.clone()); + } + } + next + } + PassthroughMode::All => inbound.clone(), + }; + clean_outbound(&mut outbound, target_authority); + apply_request_rules(&mut outbound, &config.request); + outbound +} + +/// Whether any inbound body-disposition header must be preserved: a +/// `Content-Type` that the caller explicitly set via rules, or a +/// `Content-Length` matching the re-buffered body (recomputed by the caller). +#[must_use] +pub fn has_content_type(outbound: &HeaderMap) -> bool { + outbound.contains_key(CONTENT_TYPE) +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/mod.rs b/gears/system/oagw/oagw/src/infra/proxy/mod.rs new file mode 100644 index 0000000..af58b72 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/mod.rs @@ -0,0 +1,874 @@ +//! Data plane: the OAGW proxy engine. +//! +//! Implements the DESIGN §3.3 proxy flow: CORS preflight short-circuit, +//! tenant-chain alias resolution, X-OAGW-Target-Host endpoint selection, +//! route matching (method + longest path prefix / gRPC service-method), +//! body validation, effective rate limiting, header transformation, the +//! Auth → Guard → Transform plugin pipeline, and upstream round-trips with +//! gateway/upstream error-source distinction. + +pub mod headers; +pub mod problem; +pub mod ratelimit; + +use std::net::Ipv4Addr; +use std::sync::atomic::{AtomicUsize, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use axum::body::{Body, HttpBody, to_bytes}; +use axum::http::header::{CONTENT_LENGTH, TRANSFER_ENCODING}; +use axum::http::{HeaderMap, HeaderValue, Method, StatusCode}; +use axum::response::Response; +use bytes::Bytes; +use tenant_resolver_sdk::{GetAncestorsOptions, TenantId, TenantResolverClient, TenantResolverError}; +use toolkit_http::{HttpClient, HttpClientBuilder, HttpClientConfig, HttpError}; +use toolkit_security::SecurityContext; +use uuid::Uuid; + +use crate::config::OagwConfig; +use crate::domain::alias::{derive_alias, normalize_alias, valid_hostname}; +use crate::domain::models::{ + CorsConfig, Endpoint, PathSuffixMode, RateLimitConfig, RateLimitScope, Route, RouteMatch, + SharingMode, Upstream, +}; +use crate::domain::plugin::{ + AuthPlugin, BoundPlugin, GuardPlugin, PluginBindingRef, PluginRegistry, RequestContext, + ResponseContext, TransformPlugin, +}; +use crate::domain::repo::{RouteRepo, UpstreamRepo}; +use crate::domain::service::DataPlaneService; +use crate::gts; +use crate::infra::plugins::PluginRegistryImpl; +use crate::infra::proxy::headers::{apply_response_rules, plan_request_headers}; +use crate::infra::proxy::problem::append_vary_origin; +use crate::infra::proxy::ratelimit::{RateLimiter, RateLimitOutcome, effective_bucket}; + +/// Hard request-body limit (100MB) per the DESIGN body validation rules. +const MAX_REQUEST_BODY: usize = 100 * 1024 * 1024; + +/// Data-plane service over the in-memory repositories and the plugin +/// registry. +pub struct DataPlaneServiceImpl { + upstreams: Arc, + routes: Arc, + plugins: Arc, + tenants: Arc, + http: HttpClient, + /// Round-robin counter for multi-endpoint pools without a target host. + rr: AtomicUsize, + limiter: RateLimiter, +} + +impl std::fmt::Debug for DataPlaneServiceImpl { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("DataPlaneServiceImpl").finish_non_exhaustive() + } +} + +impl DataPlaneServiceImpl { + /// Construct the data plane with its dependencies. + /// + /// # Errors + /// + /// Returns `HttpError` if the internal HTTP client cannot be built. + pub fn new( + upstreams: Arc, + routes: Arc, + plugins: Arc, + tenants: Arc, + config: OagwConfig, + ) -> Result { + let mut http_config = HttpClientConfig::proxy(); + http_config.request_timeout = Duration::from_secs(config.proxy_timeout_secs.max(1)); + let http = HttpClientBuilder::with_config(http_config).build()?; + Ok(Self { + upstreams, + routes, + plugins, + tenants, + http, + rr: AtomicUsize::new(0), + limiter: RateLimiter::new(), + }) + } +} + +/// Split the proxy request path into the alias and the remaining suffix. +/// +/// The gear is mounted under `api-gateway`'s path prefix, so the request +/// path is `/…/oagw/v1/proxy/{alias}[/{suffix}]`. Returns `None` when the +/// `/proxy/` marker is absent or the alias is empty. +fn split_proxy_path(path: &str) -> Option<(String, String)> { + let marker = "/proxy/"; + let idx = path.find(marker)? + marker.len(); + let rest = &path[idx..]; + let (alias, suffix) = match rest.split_once('/') { + Some((a, s)) => (a, format!("/{s}")), + None => (rest, String::new()), + }; + if alias.is_empty() { + return None; + } + Some((alias.to_owned(), suffix)) +} + +/// Whether the request is a CORS preflight (handled permissively). +fn is_cors_preflight(req: &axum::http::Request) -> bool { + req.method() == Method::OPTIONS + && req.headers().contains_key("origin") + && req.headers().contains_key("access-control-request-method") +} + +/// Permissive preflight response (ADR 0004): echoes the requested origin, +/// method, and headers with a 204 and a 24h max-age. +fn preflight_response(req: &axum::http::Request) -> Response { + let mut resp = Response::new(Body::empty()); + *resp.status_mut() = StatusCode::NO_CONTENT; + let headers = resp.headers_mut(); + if let Some(o) = req.headers().get("origin") { + headers.insert("access-control-allow-origin", o.clone()); + } + if let Some(m) = req.headers().get("access-control-request-method") { + headers.insert("access-control-allow-methods", m.clone()); + } + if let Some(h) = req.headers().get("access-control-request-headers") { + headers.insert("access-control-allow-headers", h.clone()); + } + headers.insert( + "access-control-max-age", + HeaderValue::from_static("86400"), + ); + headers.insert( + "vary", + HeaderValue::from_static( + "Origin, Access-Control-Request-Method, Access-Control-Request-Headers", + ), + ); + resp +} + +/// Validate an `X-OAGW-Target-Host` value: hostname or IP address, with no +/// port, path, or special characters (ADR 0001 / DESIGN). +fn valid_target_host(value: &str) -> bool { + if value.is_empty() + || value + .bytes() + .any(|b| !b.is_ascii_alphanumeric() && b != b'.' && b != b'-') + { + return false; + } + valid_hostname(value) || value.parse::().is_ok() +} + +/// Remainder of `suffix` beyond the route `path` prefix, with a path-segment +/// boundary guarantee. `None` when `suffix` does not start with `path`. +fn path_remainder<'a>(route_path: &str, suffix: &'a str) -> Option<&'a str> { + if suffix == route_path { + return Some(""); + } + let rest = suffix.strip_prefix(route_path)?; + if rest.starts_with('/') { + Some(rest) + } else { + None + } +} + +/// Effective CORS config after merging ancestor-enforced origins. +#[derive(Debug, Clone, Default)] +struct EffectiveCors { + allowed_origins: Vec, + allowed_methods: Vec, + expose_headers: Vec, + allow_credentials: bool, + enabled: bool, +} + +impl EffectiveCors { + fn from_config(c: &CorsConfig) -> Self { + Self { + allowed_origins: c.allowed_origins.clone(), + allowed_methods: c.allowed_methods.clone(), + expose_headers: c.expose_headers.clone(), + allow_credentials: c.allow_credentials, + enabled: c.enabled, + } + } + + fn merge_enforced(&mut self, c: &CorsConfig) { + if c.sharing != SharingMode::Enforce { + return; + } + for o in &c.allowed_origins { + if !self.allowed_origins.contains(o) { + self.allowed_origins.push(o.clone()); + } + } + for m in &c.allowed_methods { + if !self + .allowed_methods + .iter() + .any(|x| x.eq_ignore_ascii_case(m)) + { + self.allowed_methods.push(m.clone()); + } + } + } + + fn allows_origin(&self, origin: &str) -> bool { + self.allowed_origins.iter().any(|o| o == "*" || o == origin) + } + + fn allows_method(&self, method: &str) -> bool { + self.allowed_methods + .iter() + .any(|m| m.eq_ignore_ascii_case(method)) + } +} + +/// Planned plugin chain for one proxied request. +#[derive(Default)] +struct PlannedChain { + auth: Option<(Box, serde_json::Value)>, + guards: Vec<(Box, serde_json::Value)>, + transforms: Vec<(Box, serde_json::Value)>, +} + +#[async_trait] +impl DataPlaneService for DataPlaneServiceImpl { + async fn proxy( + &self, + tenant_id: Uuid, + subject_id: Uuid, + req: axum::http::Request, + target_host_header: Option, + ) -> Response { + self.proxy_inner(tenant_id, subject_id, req, target_host_header) + .await + } +} + +impl DataPlaneServiceImpl { + async fn proxy_inner( + &self, + tenant_id: Uuid, + subject_id: Uuid, + req: axum::http::Request, + target_host_header: Option, + ) -> Response { + // 1. CORS preflight: permissive 204, no upstream resolution. + if is_cors_preflight(&req) { + return preflight_response(&req); + } + + // 2. Parse alias + suffix from the request path. + let (alias, suffix) = match split_proxy_path(req.uri().path()) { + Some(v) => v, + None => return problem::route_not_found("proxy path is missing an alias"), + }; + + // Owned snapshots survive the body being moved out of `req` later. + let inbound_headers = req.headers().clone(); + let query = req.uri().query().map(str::to_owned); + let method = req.method().clone(); + let origin = req + .headers() + .get("origin") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned); + + // 3. Build the caller's security context (secret resolution uses the + // authenticated subject). + let sec = match SecurityContext::builder() + .subject_id(subject_id) + .subject_tenant_id(tenant_id) + .build() + { + Ok(s) => s, + Err(e) => return problem::validation(format!("invalid security context: {e}")), + }; + + // 4. Tenant chain (self → root): shadowing, closest match wins. + let chain = match self.tenant_chain(&sec, tenant_id).await { + Ok(c) => c, + Err(resp) => return resp, + }; + + // 5. Alias resolution across the chain. + let alias_norm = normalize_alias(&alias); + let mut owner_tenant: Option = None; + let selected: Option> = chain + .iter() + .find_map(|tid| { + let found = self + .upstreams + .list(*tid) + .into_iter() + .find(|u| normalize_alias(&u.alias) == alias_norm); + if found.is_some() { + owner_tenant = Some(*tid); + } + found + }); + let Some(upstream) = selected else { + return problem::route_not_found(format!("no upstream found for alias '{alias}'")); + }; + let owner_tenant = owner_tenant.expect("owner set when upstream found"); + if !upstream.enabled { + return problem::link_unavailable(format!("upstream '{alias}' is disabled")); + } + + // 6. Endpoint selection (X-OAGW-Target-Host matrix, ADR 0001). + let target_host = target_host_header.or_else(|| { + inbound_headers + .get("x-oagw-target-host") + .and_then(|v| v.to_str().ok()) + .map(str::to_owned) + }); + let endpoint = match self.select_endpoint(&upstream, target_host.as_deref()) { + Ok(e) => e, + Err(resp) => return resp, + }; + + // 7. Route resolution (protocol-scoped). + let up_id = upstream.id.unwrap_or(Uuid::nil()); + let mut candidates: Vec> = Vec::new(); + for tid in &chain { + for r in self.routes.list(*tid) { + if r.upstream_id == up_id && r.enabled { + candidates.push(r); + } + } + } + let route = match self.select_route(&upstream, &candidates, &method, &query, &suffix) { + Ok(r) => r, + Err(resp) => return resp, + }; + + // 8. Effective rate limiting (route + upstream + enforced ancestors). + let scope = route + .rate_limit + .as_ref() + .or(upstream.rate_limit.as_ref()) + .map(|c| c.scope) + .unwrap_or(RateLimitScope::Tenant); + let scope_key = match scope { + RateLimitScope::Global => "global".to_owned(), + RateLimitScope::Tenant => format!("tenant:{tenant_id}"), + RateLimitScope::User => format!("user:{subject_id}"), + RateLimitScope::Ip => format!("ip:{}", client_ip(&inbound_headers)), + RateLimitScope::Route => format!("route:{}", route.id.unwrap_or(Uuid::nil())), + }; + // Effective (rate, capacity) = min over selected + enforced ancestors. + let mut eff_rate: Option = None; + let mut eff_capacity: Option = None; + let mut cost: u64 = 1; + let mut merge = |cfg: &RateLimitConfig, is_selected: bool| { + let (r, c) = effective_bucket(cfg); + eff_rate = Some(eff_rate.map_or(r, |x: f64| x.min(r))); + eff_capacity = Some(eff_capacity.map_or(c, |x: f64| x.min(c))); + if is_selected { + cost = u64::from(cfg.cost.max(1)); + } + }; + if let Some(c) = route.rate_limit.as_ref() { + merge(c, upstream.rate_limit.is_none()); + } + if let Some(c) = upstream.rate_limit.as_ref() { + merge(c, true); + } + // Enforced ancestors (same alias, `sharing: enforce`). + for tid in &chain { + if *tid == owner_tenant { + break; + } + for u in self.upstreams.list(*tid) { + if normalize_alias(&u.alias) != alias_norm { + continue; + } + if let Some(rc) = u.rate_limit.as_ref() { + if rc.sharing == SharingMode::Enforce { + merge(rc, false); + } + } + } + } + if let (Some(rate), Some(capacity)) = (eff_rate, eff_capacity) { + match self + .limiter + .check(&format!("oagw:ratelimit:{scope_key}"), rate, capacity, cost) + { + RateLimitOutcome::Limited { + limit, + retry_after_secs, + reset_epoch, + } => { + return problem::rate_limited( + format!("rate limit exceeded for scope '{scope_key}'"), + retry_after_secs, + limit, + reset_epoch, + ); + } + RateLimitOutcome::Allowed { .. } => {} + } + } + + // 9. CORS actual-request check (upstream/route config, enabled). + let mut effective_cors: Option = None; + if origin.is_some() { + let selected_cors = route.cors.as_ref().or(upstream.cors.as_ref()); + if let Some(c) = selected_cors { + let mut eff = EffectiveCors::from_config(c); + for tid in &chain { + if *tid == owner_tenant { + break; + } + for u in self.upstreams.list(*tid) { + if normalize_alias(&u.alias) == alias_norm { + if let Some(uc) = u.cors.as_ref() { + eff.merge_enforced(uc); + } + } + } + } + if eff.enabled { + let origin = origin.clone().unwrap_or_default(); + if !eff.allows_origin(&origin) { + return problem::Problem::response( + StatusCode::FORBIDDEN, + gts::ERR_CORS_ORIGIN_NOT_ALLOWED, + "CORS Origin Not Allowed", + format!("origin '{origin}' not in allowed origins list"), + ); + } + if !eff.allows_method(method.as_str()) { + return problem::Problem::response( + StatusCode::FORBIDDEN, + gts::ERR_CORS_METHOD_NOT_ALLOWED, + "CORS Method Not Allowed", + format!("method '{method}' not in allowed methods list"), + ); + } + effective_cors = Some(eff); + } + } + } + + // 10. Plugin request phase (Auth → Guards → Transforms). + let bound = match self.plan_plugins(&upstream, &route, owner_tenant).await { + Ok(b) => b, + Err(resp) => return resp, + }; + let plugin_outbound = { + let mut rctx = RequestContext { + headers: &inbound_headers, + outbound: HeaderMap::new(), + config: &serde_json::Value::Null, + method: method.clone(), + path: suffix.clone(), + security: &sec, + tenant_id: owner_tenant, + }; + if let Some((auth, cfg)) = &bound.auth { + rctx.config = cfg; + if let Err(e) = auth.authenticate(&mut rctx).await { + return problem::auth_error(&e); + } + } + for (guard, cfg) in &bound.guards { + rctx.config = cfg; + if let Err(e) = guard.guard_request(&rctx).await { + return problem::guard_error(&e); + } + } + for (transform, cfg) in &bound.transforms { + rctx.config = cfg; + if let Err(e) = transform.transform_request(&mut rctx).await { + return problem::transform_error(&e); + } + } + rctx.outbound + }; + + // 11. Body validation + buffering (100MB cap, content-length match). + if let Some(te) = inbound_headers.get(TRANSFER_ENCODING) { + if let Ok(v) = te.to_str() { + if !v.eq_ignore_ascii_case("chunked") { + return problem::validation(format!( + "unsupported transfer-encoding '{v}' (only chunked is supported)" + )); + } + } + } + let declared_length = inbound_headers + .get(CONTENT_LENGTH) + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.parse::().ok()); + // Pre-check the declared size hint so an oversized body is rejected + // with 413 before buffering. + if req + .body() + .size_hint() + .exact() + .is_some_and(|n| n as usize > MAX_REQUEST_BODY) + { + return problem::payload_too_large("request body exceeds the 100MB limit"); + } + let body = match to_bytes(req.into_body(), MAX_REQUEST_BODY).await { + Ok(b) => b, + Err(e) => return problem::validation(format!("failed to read request body: {e}")), + }; + if let Some(d) = declared_length { + if d as usize != body.len() { + return problem::validation(format!( + "content-length {d} does not match actual body size {}", + body.len() + )); + } + } + + // 12. Outbound header plan: passthrough + rules, then plugin headers, + // with an accurate content-length for the re-buffered body. + let mut outbound = plan_request_headers( + &inbound_headers, + &upstream.headers, + &endpoint.authority(), + ); + for (name, value) in &plugin_outbound { + outbound.insert(name.clone(), value.clone()); + } + outbound.remove(TRANSFER_ENCODING); + outbound.remove(CONTENT_LENGTH); + if !body.is_empty() { + if let Ok(v) = HeaderValue::from_str(&body.len().to_string()) { + outbound.insert(CONTENT_LENGTH, v); + } + } + + // 13. Forward to the upstream. + let upstream_path = if suffix.is_empty() { "/" } else { suffix.as_str() }; + let mut url = format!("{}{}", endpoint.base_url(), upstream_path); + if let Some(q) = &query { + url.push('?'); + url.push_str(q); + } + let http_resp = match self.forward(&url, &method, &outbound, &body).await { + Ok(r) => r, + Err(resp) => return resp, + }; + + // 14. Response processing: strip hop-by-hop, apply rules + response + // plugins, add CORS headers, mark upstream source. + let status = http_resp.status(); + let mut resp_headers = http_resp.headers().clone(); + strip_response_headers(&mut resp_headers); + apply_response_rules(&mut resp_headers, &upstream.headers.response); + let empty = serde_json::Value::Null; + { + let mut rctx = ResponseContext { + headers: &mut resp_headers, + status, + config: &empty, + }; + for (guard, cfg) in &bound.guards { + rctx.config = cfg; + if let Err(e) = guard.guard_response(&mut rctx).await { + return problem::guard_error(&e); + } + } + for (transform, cfg) in &bound.transforms { + rctx.config = cfg; + if let Err(e) = transform.transform_response(&mut rctx).await { + return problem::transform_error(&e); + } + } + } + if let (Some(cors), Some(origin)) = (&effective_cors, &origin) { + add_cors_response_headers(&mut resp_headers, cors, origin); + } + let limited = http_resp.into_limited_body(); + let mut response = Response::new(Body::new(limited)); + *response.status_mut() = status; + *response.headers_mut() = resp_headers; + problem::with_upstream_source(response) + } + + async fn tenant_chain( + &self, + sec: &SecurityContext, + tenant_id: Uuid, + ) -> Result, Response> { + match self + .tenants + .get_ancestors(sec, TenantId(tenant_id), &GetAncestorsOptions::default()) + .await + { + Ok(resp) => { + let mut chain = vec![resp.tenant.id.0]; + for a in resp.ancestors { + chain.push(a.id.0); + } + Ok(chain) + } + Err(TenantResolverError::TenantNotFound { .. }) => { + Err(problem::route_not_found("the calling tenant does not exist")) + } + Err(e) => Err(problem::Problem::response( + StatusCode::BAD_GATEWAY, + gts::ERR_PROTOCOL_ERROR, + "Protocol Error", + format!("tenant resolution failed: {e}"), + )), + } + } + + fn select_endpoint( + &self, + upstream: &Upstream, + target_host: Option<&str>, + ) -> Result { + let endpoints = &upstream.server.endpoints; + if endpoints.is_empty() { + return Err(problem::route_not_found("upstream has no configured endpoints")); + } + let info = derive_alias(endpoints); + match target_host { + Some(h) => { + if !valid_target_host(h) { + return Err(problem::invalid_target_host()); + } + let h = h.trim_end_matches('.').to_ascii_lowercase(); + for e in endpoints { + if e.normalized_host() == h || e.authority() == h { + return Ok(e.clone()); + } + } + Err(problem::unknown_target_host(format!( + "target host '{h}' does not match any configured endpoint" + ))) + } + None => { + if endpoints.len() == 1 { + return Ok(endpoints[0].clone()); + } + if info.target_host_required { + return Err(problem::missing_target_host()); + } + // Multi-endpoint pool (explicit alias): round-robin. + let idx = self.rr.fetch_add(1, Ordering::Relaxed) % endpoints.len(); + Ok(endpoints[idx].clone()) + } + } + } + + fn select_route( + &self, + upstream: &Upstream, + candidates: &[Arc], + method: &Method, + query: &Option, + suffix: &str, + ) -> Result, Response> { + let is_grpc = upstream.protocol.as_str() == gts::PROTOCOL_GRPC; + if !is_grpc { + let method = method.as_str(); + let mut best: Option<(usize, Arc)> = None; + for r in candidates { + let Some(m) = r.match_.as_http() else { + continue; + }; + if !m.methods.iter().any(|x| x == method) { + continue; + } + let Some(remainder) = path_remainder(&m.path, suffix) else { + continue; + }; + if m.path_suffix_mode == PathSuffixMode::Disabled && !remainder.is_empty() { + continue; + } + let len = m.path.len(); + if best.as_ref().map(|(bl, _)| len > *bl).unwrap_or(true) { + best = Some((len, r.clone())); + } + } + match best { + Some((_, route)) => { + // Query allowlist enforcement (empty = allow none). + let m = route.match_.as_http().expect("http route"); + if let Some(q) = query { + for kv in q.split('&') { + let name = kv.split('=').next().unwrap_or(""); + if !m.query_allowlist.iter().any(|a| a == name) { + return Err(problem::validation(format!( + "query parameter '{name}' is not allowed by this route" + ))); + } + } + } + Ok(route) + } + None => Err(problem::route_not_found(format!( + "no route matches method {method} and path '{suffix}'" + ))), + } + } else { + let trimmed = suffix.trim_start_matches('/'); + let mut segs = trimmed.splitn(2, '/'); + let (Some(service), Some(rpc_method)) = (segs.next(), segs.next()) else { + return Err(problem::route_not_found(format!( + "gRPC path '{suffix}' must be /{{service}}/{{method}}" + ))); + }; + for r in candidates { + if let RouteMatch::Grpc(m) = &r.match_ { + if m.service == service && m.method == rpc_method { + return Ok(r.clone()); + } + } + } + Err(problem::route_not_found(format!( + "no gRPC route matches {service}/{rpc_method}" + ))) + } + } + + async fn plan_plugins( + &self, + upstream: &Upstream, + route: &Route, + owner_tenant: Uuid, + ) -> Result { + let mut chain = PlannedChain::default(); + // Upstream auth plugin. + if let Some(plugin_type) = &upstream.auth.plugin_type { + let binding = PluginBindingRef { + plugin_ref: plugin_type.clone(), + config: upstream.auth.config.clone(), + tenant_id: owner_tenant, + }; + match self.resolve_any(&binding).await { + Ok(BoundPlugin::Auth(a, c)) => chain.auth = Some((a, c)), + Ok(_) => { + return Err(problem::plugin_not_found(format!( + "auth plugin '{plugin_type}' is not an auth plugin" + ))) + } + Err(detail) => return Err(problem::plugin_not_found(detail)), + }; + } + // Upstream guards + transforms, then route ones. + for item in upstream.plugins.items.iter().chain(route.plugins.items.iter()) { + let binding = PluginBindingRef { + plugin_ref: item.plugin_ref().to_owned(), + config: item.config(), + tenant_id: owner_tenant, + }; + match self.resolve_any(&binding).await { + Ok(BoundPlugin::Guard(g, c)) => chain.guards.push((g, c)), + Ok(BoundPlugin::Transform(t, c)) => chain.transforms.push((t, c)), + Ok(BoundPlugin::Auth(..)) => { + // Auth can only be configured via `upstream.auth`. + } + Err(detail) => return Err(problem::plugin_not_found(detail)), + } + } + Ok(chain) + } + + async fn resolve_any( + &self, + binding: &PluginBindingRef, + ) -> Result { + self.plugins.resolve(binding).await + } + + async fn forward( + &self, + url: &str, + method: &Method, + outbound: &HeaderMap, + body: &Bytes, + ) -> Result { + let method_owned = method.clone(); + let mut builder = match method_owned { + Method::GET => self.http.get(url), + Method::POST => self.http.post(url), + Method::PUT => self.http.put(url), + Method::PATCH => self.http.patch(url), + Method::DELETE => self.http.delete(url), + Method::HEAD => self.http.head(url), + Method::OPTIONS => self.http.options(url), + other => { + return Err(problem::validation(format!( + "method '{other}' is not supported by the gateway" + ))) + } + }; + for (name, value) in outbound { + if let Ok(v) = value.to_str() { + builder = builder.header(name.as_str(), v); + } + } + builder + .body_bytes(body.clone()) + .send() + .await + .map_err(|e| problem::transport_error(&e)) + } +} + +fn client_ip(headers: &HeaderMap) -> String { + 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()) + .map(str::to_owned) + .unwrap_or_else(|| "unknown".to_owned()) +} + +/// Strip hop-by-hop headers from an upstream response before it reaches the +/// client (including `content-encoding` since the client may decompress). +fn strip_response_headers(headers: &mut HeaderMap) { + for name in [ + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailer", + "transfer-encoding", + "upgrade", + "content-encoding", + ] { + headers.remove(name); + } +} + +/// Add CORS response headers for an allowed actual request (ADR 0004). +fn add_cors_response_headers(headers: &mut HeaderMap, cors: &EffectiveCors, origin: &str) { + let allow_origin = if cors.allowed_origins.iter().any(|o| o == "*") { + "*" + } else { + origin + }; + if let Ok(v) = HeaderValue::from_str(allow_origin) { + headers.insert("access-control-allow-origin", v); + } + if cors.allow_credentials { + headers.insert( + "access-control-allow-credentials", + HeaderValue::from_static("true"), + ); + } + if !cors.expose_headers.is_empty() { + if let Ok(v) = HeaderValue::from_str(&cors.expose_headers.join(", ")) { + headers.insert("access-control-expose-headers", v); + } + } + append_vary_origin(headers); +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/problem.rs b/gears/system/oagw/oagw/src/infra/proxy/problem.rs new file mode 100644 index 0000000..7133b55 --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/problem.rs @@ -0,0 +1,359 @@ +//! Gateway error responses (RFC 9457 problem details) for the data plane. +//! +//! Every gateway-generated error carries `X-OAGW-Error-Source: gateway` and +//! a GTS `type` from the DESIGN error table. Upstream responses are passed +//! through untouched (marked `X-OAGW-Error-Source: upstream`). + +use std::collections::BTreeMap; + +use axum::http::{HeaderMap, StatusCode}; +use axum::response::{IntoResponse, Response}; +use axum::Json; +use serde::Serialize; + +use crate::domain::plugin::{AuthError, GuardError, TransformError}; +use crate::gts; + +/// OAGW problem-details body (RFC 9457 + OAGW extension fields). +#[derive(Debug, Serialize)] +pub struct Problem { + /// GTS identifier for the error type. + #[serde(rename = "type")] + pub type_: String, + /// Human-readable summary. + pub title: String, + /// HTTP status code. + pub status: u16, + /// Occurrence-specific detail. + pub detail: String, + /// Structured error code (guard errors), optional. + #[serde(rename = "errorCode", skip_serializing_if = "Option::is_none")] + pub error_code: Option, + /// Retry guidance in seconds. + #[serde(rename = "retryAfterSeconds", skip_serializing_if = "Option::is_none")] + pub retry_after_seconds: Option, + /// Request context (upstream/host/path), optional. + #[serde(flatten, skip_serializing_if = "BTreeMap::is_empty")] + pub context: BTreeMap, +} + +impl Problem { + /// Build a gateway problem response with `X-OAGW-Error-Source: gateway`. + #[must_use] + pub fn response( + status: StatusCode, + type_: &str, + title: &str, + detail: impl Into, + ) -> Response { + Self { + type_: type_.to_owned(), + title: title.to_owned(), + status: status.as_u16(), + detail: detail.into(), + error_code: None, + retry_after_seconds: None, + context: BTreeMap::new(), + } + .into_gateway() + } + + /// Render as a gateway error response. + #[must_use] + pub fn into_gateway(self) -> Response { + let status = StatusCode::from_u16(self.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); + let mut resp = (status, Json(self)).into_response(); + resp.headers_mut().insert( + "x-oagw-error-source", + http::HeaderValue::from_static("gateway"), + ); + resp + } +} + +/// Attach `X-OAGW-Error-Source: upstream` to a proxied response. +#[must_use] +pub fn with_upstream_source(mut resp: Response) -> Response { + resp.headers_mut().insert( + "x-oagw-error-source", + http::HeaderValue::from_static("upstream"), + ); + resp +} + +/// Validation error (`400 validation.error.v1`). +#[must_use] +pub fn validation(detail: impl Into) -> Response { + Problem::response( + StatusCode::BAD_REQUEST, + gts::ERR_VALIDATION_ERROR, + "Request Validation Failed", + detail, + ) +} + +/// Route/upstream not found (`404 route.not_found.v1`). +#[must_use] +pub fn route_not_found(detail: impl Into) -> Response { + Problem::response( + StatusCode::NOT_FOUND, + gts::ERR_ROUTE_NOT_FOUND, + "Route Not Found", + detail, + ) +} + +/// Missing `X-OAGW-Target-Host` (`400 routing.missing_target_host.v1`). +#[must_use] +pub fn missing_target_host() -> Response { + Problem::response( + StatusCode::BAD_REQUEST, + gts::ERR_MISSING_TARGET_HOST, + "Missing Target Host", + "X-OAGW-Target-Host is required for multi-endpoint upstreams with a \ + common suffix alias", + ) +} + +/// Malformed `X-OAGW-Target-Host` (`400 routing.invalid_target_host.v1`). +#[must_use] +pub fn invalid_target_host() -> Response { + Problem::response( + StatusCode::BAD_REQUEST, + gts::ERR_INVALID_TARGET_HOST, + "Invalid Target Host", + "X-OAGW-Target-Host must be a hostname or IP address without port, \ + path, or special characters", + ) +} + +/// Known-but-not-configured `X-OAGW-Target-Host` (`400 routing.unknown_target_host.v1`). +#[must_use] +pub fn unknown_target_host(detail: impl Into) -> Response { + Problem::response( + StatusCode::BAD_REQUEST, + gts::ERR_UNKNOWN_TARGET_HOST, + "Unknown Target Host", + detail, + ) +} + +/// Payload too large (`413 payload.too_large.v1`). +#[must_use] +pub fn payload_too_large(detail: impl Into) -> Response { + Problem::response( + StatusCode::PAYLOAD_TOO_LARGE, + gts::ERR_PAYLOAD_TOO_LARGE, + "Payload Too Large", + detail, + ) +} + +/// Secret not found (`500 secret.not_found.v1`). +#[must_use] +pub fn secret_not_found(detail: impl Into) -> Response { + Problem::response( + StatusCode::INTERNAL_SERVER_ERROR, + gts::ERR_SECRET_NOT_FOUND, + "Secret Not Found", + detail, + ) +} + +/// Link unavailable (`503 link.unavailable.v1`). +#[must_use] +pub fn link_unavailable(detail: impl Into) -> Response { + Problem::response( + StatusCode::SERVICE_UNAVAILABLE, + gts::ERR_LINK_UNAVAILABLE, + "Link Unavailable", + detail, + ) +} + +/// Plugin not found / not executable (`503 plugin.not_found.v1`). +#[must_use] +pub fn plugin_not_found(detail: impl Into) -> Response { + Problem::response( + StatusCode::SERVICE_UNAVAILABLE, + gts::ERR_PLUGIN_NOT_FOUND, + "Plugin Not Found", + detail, + ) +} + +/// Rate limit exceeded (`429 rate_limit.exceeded.v1`) with headers. +#[must_use] +pub fn rate_limited(detail: impl Into, retry_after_secs: u64, limit: u64, reset: u64) -> Response { + let mut problem = Problem { + type_: gts::ERR_RATE_LIMIT_EXCEEDED.to_owned(), + title: "Rate Limit Exceeded".to_owned(), + status: StatusCode::TOO_MANY_REQUESTS.as_u16(), + detail: detail.into(), + error_code: None, + retry_after_seconds: Some(retry_after_secs), + context: BTreeMap::new(), + }; + problem.status = StatusCode::TOO_MANY_REQUESTS.as_u16(); + let status = StatusCode::TOO_MANY_REQUESTS; + let mut resp = (status, Json(problem)).into_response(); + let headers = resp.headers_mut(); + headers.insert("x-oagw-error-source", http::HeaderValue::from_static("gateway")); + headers.insert( + http::header::RETRY_AFTER, + http::HeaderValue::from_str(&retry_after_secs.to_string()) + .unwrap_or_else(|_| http::HeaderValue::from_static("1")), + ); + headers.insert( + "x-ratelimit-limit", + http::HeaderValue::from_str(&limit.to_string()) + .unwrap_or_else(|_| http::HeaderValue::from_static("0")), + ); + headers.insert( + "x-ratelimit-remaining", + http::HeaderValue::from_static("0"), + ); + headers.insert( + "x-ratelimit-reset", + http::HeaderValue::from_str(&reset.to_string()) + .unwrap_or_else(|_| http::HeaderValue::from_static("0")), + ); + resp +} + +/// Map an auth-plugin failure to a gateway response. +#[must_use] +pub fn auth_error(err: &AuthError) -> Response { + match err { + AuthError::Rejected(detail) => Problem::response( + StatusCode::UNAUTHORIZED, + gts::ERR_AUTH_FAILED, + "Authentication Failed", + detail.clone(), + ), + AuthError::SecretNotFound(detail) => secret_not_found(detail.clone()), + AuthError::Backend(detail) => Problem::response( + StatusCode::BAD_GATEWAY, + gts::ERR_DOWNSTREAM_ERROR, + "Downstream Error", + detail.clone(), + ), + } +} + +/// Map a guard-plugin failure to a gateway response. +#[must_use] +pub fn guard_error(err: &GuardError) -> Response { + match err { + GuardError::Request { error_code, detail } => { + // 400 validation.error.v1 with the plugin's error code surfaced + // in `errorCode`. + let body = Problem { + type_: gts::ERR_VALIDATION_ERROR.to_owned(), + title: "Request Validation Failed".to_owned(), + status: StatusCode::BAD_REQUEST.as_u16(), + detail: detail.clone(), + error_code: Some(error_code.clone()), + retry_after_seconds: None, + context: BTreeMap::new(), + }; + let mut resp = (StatusCode::BAD_REQUEST, Json(body)).into_response(); + resp.headers_mut().insert( + "x-oagw-error-source", + http::HeaderValue::from_static("gateway"), + ); + resp + } + GuardError::Response { error_code, detail } => { + // 502 with the plugin's error code surfaced in `errorCode`. + let body = Problem { + type_: gts::ERR_PROTOCOL_ERROR.to_owned(), + title: "Protocol Error".to_owned(), + status: StatusCode::BAD_GATEWAY.as_u16(), + detail: detail.clone(), + error_code: Some(error_code.clone()), + retry_after_seconds: None, + context: BTreeMap::new(), + }; + let mut resp = (StatusCode::BAD_GATEWAY, Json(body)).into_response(); + resp.headers_mut().insert( + "x-oagw-error-source", + http::HeaderValue::from_static("gateway"), + ); + resp + } + } +} + +/// Map a transform-plugin failure to a gateway response. +#[must_use] +pub fn transform_error(err: &TransformError) -> Response { + let detail = match err { + TransformError::Failed(d) => d.clone(), + }; + Problem::response( + StatusCode::BAD_GATEWAY, + gts::ERR_PROTOCOL_ERROR, + "Protocol Error", + detail, + ) +} + +/// Map a transport error to a gateway response (502/503/504 per error table). +#[must_use] +pub fn transport_error(err: &toolkit_http::HttpError) -> Response { + use toolkit_http::HttpError; + match err { + HttpError::Timeout(_) | HttpError::DeadlineExceeded(_) => Problem::response( + StatusCode::GATEWAY_TIMEOUT, + gts::ERR_TIMEOUT_REQUEST, + "Request Timeout", + format!("upstream request timed out: {err}"), + ), + HttpError::Overloaded | HttpError::ServiceClosed => Problem::response( + StatusCode::SERVICE_UNAVAILABLE, + gts::ERR_LINK_UNAVAILABLE, + "Link Unavailable", + format!("upstream link unavailable: {err}"), + ), + HttpError::BodyTooLarge { limit, actual } => Problem::response( + StatusCode::BAD_GATEWAY, + gts::ERR_PROTOCOL_ERROR, + "Protocol Error", + format!( + "upstream response body exceeds limit: {actual} > {limit} bytes" + ), + ), + HttpError::Transport(_) | HttpError::Tls(_) => Problem::response( + StatusCode::BAD_GATEWAY, + gts::ERR_DOWNSTREAM_ERROR, + "Downstream Error", + format!("upstream transport failure: {err}"), + ), + _ => Problem::response( + StatusCode::BAD_GATEWAY, + gts::ERR_PROTOCOL_ERROR, + "Protocol Error", + format!("upstream protocol error: {err}"), + ), + } +} + +/// Mark a response's `Vary` header with `Origin` (CORS safety, ADR 0004). +pub fn append_vary_origin(headers: &mut HeaderMap) { + let existing = headers + .get(http::header::VARY) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_owned(); + let mut parts: Vec = existing + .split(',') + .map(str::trim) + .map(str::to_owned) + .filter(|p| !p.is_empty() && p != "Origin") + .collect(); + parts.push("Origin".to_owned()); + if let Ok(v) = http::HeaderValue::from_str(&parts.join(", ")) { + headers.insert(http::header::VARY, v); + } +} diff --git a/gears/system/oagw/oagw/src/infra/proxy/ratelimit.rs b/gears/system/oagw/oagw/src/infra/proxy/ratelimit.rs new file mode 100644 index 0000000..854ab0a --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/proxy/ratelimit.rs @@ -0,0 +1,262 @@ +//! Dual-rate token-bucket rate limiter (ADR 0003). +//! +//! One in-memory bucket per scope key (`{scope}:{scope_id}`, shared across +//! all tenants/upstreams at the process level). A bucket refills at +//! `sustained.rate` tokens per window; the capacity (`burst.capacity`, +//! defaulting to the sustained rate) allows burst usage. Requests over the +//! limit yield a `429` with `Retry-After` and `X-RateLimit-*` headers. + +use std::collections::HashMap; +use std::sync::Mutex; +use std::time::{Instant, SystemTime, UNIX_EPOCH}; + +use crate::domain::models::RateLimitConfig; + +/// A single token bucket (guarded by the limiter's mutex). +struct Bucket { + tokens: f64, + last: Instant, + capacity: f64, + refill_per_sec: f64, +} + +impl Bucket { + fn new(capacity: f64, refill_per_sec: f64) -> Self { + Self { + tokens: capacity, + last: Instant::now(), + capacity, + refill_per_sec, + } + } + + fn take(&mut self, cost: f64) -> bool { + self.refill(); + if self.tokens < cost { + return false; + } + self.tokens -= cost; + true + } + + fn refill(&mut self) { + let elapsed = self.last.elapsed().as_secs_f64(); + if elapsed > 0.0 { + self.tokens = (self.tokens + elapsed * self.refill_per_sec).min(self.capacity); + self.last = Instant::now(); + } + } + + /// Seconds until the bucket is full again (for `Retry-After` / reset). + fn seconds_until_full(&self) -> u64 { + let deficit = self.capacity - self.tokens; + if deficit <= 0.0 || self.refill_per_sec <= 0.0 { + return 1; + } + (deficit / self.refill_per_sec).ceil() as u64 + } +} + +/// Outcome of a rate-limit check. +#[derive(Debug, Clone, Copy)] +pub enum RateLimitOutcome { + /// The request may proceed. `limit`/`remaining` feed `X-RateLimit-*`. + Allowed { limit: u64, remaining: u64 }, + /// The request is rejected. `retry_after_secs` feeds `Retry-After` and + /// the problem body; `limit`/`reset` feed `X-RateLimit-*`. + Limited { + limit: u64, + retry_after_secs: u64, + reset_epoch: u64, + }, +} + +/// In-process token-bucket rate limiter shared by the data plane. +pub struct RateLimiter { + buckets: Mutex>, +} + +impl Default for RateLimiter { + fn default() -> Self { + Self::new() + } +} + +impl RateLimiter { + /// Construct an empty limiter. + #[must_use] + pub fn new() -> Self { + Self { + buckets: Mutex::new(HashMap::new()), + } + } + + /// Consume `cost` tokens from the bucket for `key` configured by `cfg` + /// (effective rate/capacity already merged by the caller). + #[must_use] + pub fn check(&self, key: &str, rate: f64, capacity: f64, cost: u64) -> RateLimitOutcome { + let mut buckets = self.buckets.lock().unwrap_or_else(|p| p.into_inner()); + let bucket = buckets + .entry(key.to_owned()) + .or_insert_with(|| Bucket::new(capacity.max(1.0), rate.max(0.0))); + let limit = bucket.capacity as u64; + if !bucket.take(cost as f64) { + let retry_after_secs = bucket.seconds_until_full(); + let reset_epoch = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs() + retry_after_secs) + .unwrap_or(retry_after_secs); + return RateLimitOutcome::Limited { + limit, + retry_after_secs, + reset_epoch, + }; + } + RateLimitOutcome::Allowed { + limit, + remaining: bucket.tokens.floor().max(0.0) as u64, + } + } +} + +/// Convert a `RateLimitConfig`'s window into refill tokens per second. +#[must_use] +pub fn refill_per_second(rate: u64, window: crate::domain::models::RateLimitWindow) -> f64 { + match window { + crate::domain::models::RateLimitWindow::Second => rate as f64, + crate::domain::models::RateLimitWindow::Minute => rate as f64 / 60.0, + crate::domain::models::RateLimitWindow::Hour => rate as f64 / 3600.0, + crate::domain::models::RateLimitWindow::Day => rate as f64 / 86_400.0, + } +} + +/// Effective (rate, capacity) pair for a config: capacity defaults to the +/// sustained rate per ADR 0003. +#[must_use] +pub fn effective_bucket(cfg: &RateLimitConfig) -> (f64, f64) { + let rate = cfg.sustained.rate as f64; + let capacity = cfg + .burst + .as_ref() + .map(|b| b.capacity as f64) + .unwrap_or(rate); + (rate.max(1.0), capacity.max(1.0)) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::models::{BurstConfig, RateLimitConfig, RateLimitStrategy, SustainedRate}; + + fn cfg(rate: u32, capacity: Option) -> RateLimitConfig { + RateLimitConfig { + sharing: crate::domain::models::SharingMode::Private, + algorithm: crate::domain::models::RateLimitAlgorithm::TokenBucket, + sustained: SustainedRate { + rate, + window: crate::domain::models::RateLimitWindow::Second, + }, + burst: capacity.map(|c| BurstConfig { capacity: c }), + scope: crate::domain::models::RateLimitScope::Tenant, + strategy: RateLimitStrategy::Reject, + cost: 1, + } + } + + #[test] + fn allows_up_to_capacity() { + let limiter = RateLimiter::new(); + let (rate, cap) = effective_bucket(&cfg(10, Some(5))); + // Burst of `cap` passes, the next one is rejected. + assert!(matches!( + limiter.check("t:1", rate, cap, 1), + RateLimitOutcome::Allowed { .. } + )); + for _ in 0..4 { + assert!(matches!( + limiter.check("t:1", rate, cap, 1), + RateLimitOutcome::Allowed { .. } + )); + } + assert!(matches!( + limiter.check("t:1", rate, cap, 1), + RateLimitOutcome::Limited { .. } + )); + } + + #[test] + fn capacity_defaults_to_rate() { + let limiter = RateLimiter::new(); + let (rate, cap) = effective_bucket(&cfg(3, None)); + assert_eq!(cap, 3.0); + for _ in 0..3 { + assert!(matches!( + limiter.check("t:2", rate, cap, 1), + RateLimitOutcome::Allowed { .. } + )); + } + assert!(matches!( + limiter.check("t:2", rate, cap, 1), + RateLimitOutcome::Limited { .. } + )); + } + + #[test] + fn keys_are_isolated() { + let limiter = RateLimiter::new(); + let (rate, cap) = effective_bucket(&cfg(1, None)); + assert!(matches!( + limiter.check("a", rate, cap, 1), + RateLimitOutcome::Allowed { .. } + )); + assert!(matches!( + limiter.check("b", rate, cap, 1), + RateLimitOutcome::Allowed { .. } + )); + } + + #[test] + fn limited_reports_limit_and_retry_after() { + let limiter = RateLimiter::new(); + let (rate, cap) = effective_bucket(&cfg(2, None)); + let _ = limiter.check("t:3", rate, cap, 1); + let _ = limiter.check("t:3", rate, cap, 1); + match limiter.check("t:3", rate, cap, 1) { + RateLimitOutcome::Limited { + limit, + retry_after_secs, + .. + } => { + assert_eq!(limit, 2); + assert!(retry_after_secs >= 1); + } + other => panic!("expected limited, got {other:?}"), + } + } + + #[test] + fn window_conversions() { + use crate::domain::models::RateLimitWindow as W; + assert_eq!(refill_per_second(60, W::Second), 60.0); + assert_eq!(refill_per_second(60, W::Minute), 1.0); + assert_eq!(refill_per_second(60, W::Hour), 60.0 / 3600.0); + assert_eq!(refill_per_second(60, W::Day), 60.0 / 86_400.0); + } + + #[test] + fn zero_duration_between_checks_ok() { + let limiter = RateLimiter::new(); + let start = Instant::now(); + let (rate, cap) = effective_bucket(&cfg(1, None)); + assert!(matches!( + limiter.check("t:4", rate, cap, 1), + RateLimitOutcome::Allowed { .. } + )); + // Immediate second check must not refill past capacity. + assert!(matches!( + limiter.check("t:4", rate, cap, 1), + RateLimitOutcome::Limited { .. } + )); + assert!(start.elapsed().as_millis() < 50); + } +} 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..ea71cac --- /dev/null +++ b/gears/system/oagw/oagw/src/infra/storage.rs @@ -0,0 +1,197 @@ +//! In-memory repositories for the OAGW control plane. +//! +//! Backed by `DashMap` keyed `(tenant_id, id)` / `(tenant_id, alias)`. +//! Provides snapshot-consistent reads (each lookup clones the stored value) +//! and linearizable upsert/delete via per-key locks. + +use std::collections::HashMap; +use std::sync::Arc; + +use dashmap::DashMap; +use parking_lot::Mutex; +use uuid::Uuid; + +use crate::domain::alias::normalize_alias; +use crate::domain::models::{Plugin, Route, Upstream}; +use crate::domain::repo::{PluginRepo, RouteRepo, UpstreamRepo}; + +/// In-memory upstream repository. +#[derive(Default)] +pub struct InMemoryUpstreamRepo { + by_id: DashMap<(Uuid, Uuid), Upstream>, + // (tenant, normalized_alias) -> id — uniqueness guard + by-alias lookup. + by_alias: DashMap<(Uuid, String), Uuid>, + lock: Mutex<()>, +} + +impl InMemoryUpstreamRepo { + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +impl UpstreamRepo for InMemoryUpstreamRepo { + fn upsert(&self, tenant_id: Uuid, u: Upstream) -> Result<(), anyhow::Error> { + let _guard = self.lock.lock(); + let id = u.id.expect("upstream id set during upsert"); + let alias = normalize_alias(&u.alias); + self.by_id.insert((tenant_id, id), u); + self.by_alias.insert((tenant_id, alias), id); + Ok(()) + } + + fn delete(&self, tenant_id: Uuid, id: Uuid) -> Result { + let _guard = self.lock.lock(); + let Some(u) = self.by_id.remove(&(tenant_id, id)) else { + return Ok(false); + }; + self.by_alias + .remove(&(tenant_id, normalize_alias(&u.1.alias))); + Ok(true) + } + + fn get(&self, tenant_id: Uuid, id: Uuid) -> Option> { + self.by_id.get(&(tenant_id, id)).map(|v| Arc::new(v.clone())) + } + + fn list(&self, tenant_id: Uuid) -> Vec> { + let mut out: Vec> = self + .by_id + .iter() + .filter(|e| e.key().0 == tenant_id) + .map(|e| Arc::new(e.value().clone())) + .collect(); + out.sort_by(|a, b| a.id.cmp(&b.id)); + out + } + + fn alias_taken(&self, tenant_id: Uuid, alias: &str, except_id: Option) -> bool { + self.by_alias + .get(&(tenant_id, normalize_alias(alias))) + .map(|v| except_id != Some(*v.value())) + .unwrap_or(false) + } +} + +/// In-memory route repository. +#[derive(Default)] +pub struct InMemoryRouteRepo { + by_id: DashMap<(Uuid, Uuid), Route>, + by_upstream: DashMap<(Uuid, Uuid), Vec>, +} + +impl InMemoryRouteRepo { + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +impl RouteRepo for InMemoryRouteRepo { + fn upsert(&self, tenant_id: Uuid, r: Route) -> Result<(), anyhow::Error> { + let id = r.id.expect("route id set during upsert"); + let upstream = r.upstream_id; + self.by_id.insert((tenant_id, id), r.clone()); + let mut bucket = self + .by_upstream + .entry((tenant_id, upstream)) + .or_insert_with(Vec::new); + if !bucket.contains(&id) { + bucket.push(id); + } + Ok(()) + } + + fn delete(&self, tenant_id: Uuid, id: Uuid) -> Result { + let Some((_, r)) = self.by_id.remove(&(tenant_id, id)) else { + return Ok(false); + }; + if let Some(mut bucket) = self.by_upstream.get_mut(&(tenant_id, r.upstream_id)) { + bucket.retain(|x| *x != id); + } + Ok(true) + } + + fn get(&self, tenant_id: Uuid, id: Uuid) -> Option> { + self.by_id.get(&(tenant_id, id)).map(|v| Arc::new(v.clone())) + } + + fn list(&self, tenant_id: Uuid) -> Vec> { + let mut out: Vec> = self + .by_id + .iter() + .filter(|e| e.key().0 == tenant_id) + .map(|e| Arc::new(e.value().clone())) + .collect(); + out.sort_by(|a, b| a.id.cmp(&b.id)); + out + } + + fn list_for_upstream(&self, tenant_id: Uuid, upstream_id: Uuid) -> Vec> { + let mut out: Vec> = self + .by_id + .iter() + .filter(|e| { + e.key().0 == tenant_id && e.value().upstream_id == upstream_id + }) + .map(|e| Arc::new(e.value().clone())) + .collect(); + out.sort_by(|a, b| a.id.cmp(&b.id)); + out + } +} + +/// In-memory custom plugin repository. +#[derive(Default)] +pub struct InMemoryPluginRepo { + by_id: DashMap<(Uuid, Uuid), Plugin>, +} + +impl InMemoryPluginRepo { + #[must_use] + pub fn new() -> Self { + Self::default() + } +} + +impl PluginRepo for InMemoryPluginRepo { + fn put(&self, tenant_id: Uuid, p: Plugin) -> Result<(), anyhow::Error> { + let id = p.id.expect("plugin id set during put"); + self.by_id.insert((tenant_id, id), p); + Ok(()) + } + + fn delete(&self, tenant_id: Uuid, id: Uuid) -> Result { + Ok(self.by_id.remove(&(tenant_id, id)).is_some()) + } + + fn get(&self, tenant_id: Uuid, id: Uuid) -> Option> { + self.by_id.get(&(tenant_id, id)).map(|v| Arc::new(v.clone())) + } + + fn list(&self, tenant_id: Uuid) -> Vec> { + let mut out: Vec> = self + .by_id + .iter() + .filter(|e| e.key().0 == tenant_id) + .map(|e| Arc::new(e.value().clone())) + .collect(); + out.sort_by(|a, b| a.id.cmp(&b.id)); + out + } +} + +/// Diagnostics helper for tests: dump current contents. +#[allow(dead_code)] +pub(crate) fn snapshot( + up: &InMemoryUpstreamRepo, + rt: &InMemoryRouteRepo, + pl: &InMemoryPluginRepo, +) -> (HashMap<(Uuid, Uuid), Upstream>, HashMap<(Uuid, Uuid), Route>, HashMap<(Uuid, Uuid), Plugin>) { + ( + up.by_id.iter().map(|e| (*e.key(), e.value().clone())).collect(), + rt.by_id.iter().map(|e| (*e.key(), e.value().clone())).collect(), + pl.by_id.iter().map(|e| (*e.key(), e.value().clone())).collect(), + ) +} diff --git a/gears/system/oagw/oagw/src/lib.rs b/gears/system/oagw/oagw/src/lib.rs index e69de29..66e5144 100644 --- a/gears/system/oagw/oagw/src/lib.rs +++ b/gears/system/oagw/oagw/src/lib.rs @@ -0,0 +1,51 @@ +//! OAGW — Outbound API Gateway Gear. +//! +//! The OAGW gear is the outbound API gateway of the gears-rust platform. It +//! provides a control plane (upstream / route / plugin CRUD) and a data plane +//! (authenticated proxying to registered upstream services) in a single crate. +//! +//! ## Architecture +//! +//! - **Control plane**: tenants register upstream services (endpoints, auth, +//! headers, plugins, rate limits, CORS), routes (match rules binding a path +//! prefix + method set to an upstream), and plugins via the REST API +//! (`/oagw/v1/upstreams`, `/oagw/v1/routes`, `/oagw/v1/plugins`). +//! - **Data plane**: the proxy entry point `/oagw/v1/proxy/{alias}/{*path}` +//! resolves the registered configuration (including hierarchical +//! tenant-enforced limits), runs the auth → guards → transform → upstream +//! → transform pipeline, and streams the upstream response back. +//! +//! ## Sources of truth +//! +//! The wire contract, semantics, and error model are specified in +//! `gears/system/oagw/docs/` (PRD, DESIGN, ADRs, JSON schemas). This crate is +//! the implementation of that specification. +//! +//! ## Registration +//! +//! `OagwGear` registers itself with the runtime through `#[toolkit::gear]` and +//! is discovered from the process inventory by the example server's +//! `registered_gears.rs` (`use api_egress as _;`). REST routes are served +//! gear-relative under `{prefix}/oagw/v1/...` (the api-gateway nests each gear +//! router under its configured `prefix_path`, which is empty in the e2e setup). + +#![forbid(unsafe_code)] +#![deny(rust_2018_idioms)] + +// === GEAR DECLARATION === +pub mod gear; +pub use gear::OagwGear; + +// === CONFIGURATION === +pub mod config; + +// === GTS VOCABULARY === +pub mod gts; + +// === INTERNAL MODULES === +#[doc(hidden)] +pub mod api; +#[doc(hidden)] +pub mod domain; +#[doc(hidden)] +pub mod infra; diff --git a/tools/scripts/check_packaging_metadata.py b/tools/scripts/check_packaging_metadata.py old mode 100755 new mode 100644 diff --git a/tools/scripts/docs-preview.sh b/tools/scripts/docs-preview.sh old mode 100755 new mode 100644