From 5934e4adc0bc12632bca6c90b919581c100a8bab Mon Sep 17 00:00:00 2001 From: "matt.garmon" Date: Wed, 1 Jul 2026 17:35:43 -0700 Subject: [PATCH] refactor(http-middleware): consolidate server-side HTTP middleware into shared crate Signed-off-by: matt.garmon --- Cargo.lock | 17 +- Cargo.toml | 1 + docs/ARCHITECTURE_MANIFEST.md | 2 +- .../00_gear_overview.md | 2 +- gears/system/api-gateway/Cargo.toml | 10 +- gears/system/api-gateway/src/authn_adapter.rs | 75 + gears/system/api-gateway/src/config.rs | 73 +- gears/system/api-gateway/src/errors.rs | 52 + gears/system/api-gateway/src/gear.rs | 147 +- gears/system/api-gateway/src/gear.rs.orig | 1297 ----------------- gears/system/api-gateway/src/lib.rs | 2 + gears/system/api-gateway/src/middleware.rs | 197 +++ .../system/api-gateway/src/middleware/auth.rs | 512 ------- .../api-gateway/src/middleware/errors.rs | 16 - .../system/api-gateway/src/middleware/mod.rs | 10 - .../api-gateway/tests/access_log_tests.rs | 6 +- .../api-gateway/tests/auth_middleware.rs.orig | 1116 -------------- .../tests/license_middleware.rs.orig | 370 ----- .../api-gateway/tests/middleware_order.rs | 2 +- .../tests/middleware_order.rs.orig | 302 ---- .../tests/mime_validation_integration.rs | 52 +- gears/system/api-gateway/tests/request_id.rs | 4 +- gears/system/oagw/oagw/src/infra/metrics.rs | 4 +- libs/toolkit-canonical-errors/src/builder.rs | 39 + libs/toolkit-canonical-errors/tests/error.rs | 57 + libs/toolkit-http-middleware/Cargo.toml | 26 + libs/toolkit-http-middleware/README.md | 51 +- .../src}/access_log.rs | 7 +- libs/toolkit-http-middleware/src/auth.rs | 225 ++- .../toolkit-http-middleware/src/auth_tests.rs | 188 ++- .../toolkit-http-middleware/src}/common.rs | 33 + .../src}/http_metrics.rs | 0 libs/toolkit-http-middleware/src/lib.rs | 32 +- .../src}/license_validation.rs | 52 +- .../src}/mime_validation.rs | 153 +- libs/toolkit-http-middleware/src/policy.rs | 234 +++ .../src}/rate_limit.rs | 93 +- .../src}/request_id.rs | 10 + .../src}/scope_enforcement.rs | 239 +-- 39 files changed, 1552 insertions(+), 4156 deletions(-) create mode 100644 gears/system/api-gateway/src/authn_adapter.rs create mode 100644 gears/system/api-gateway/src/errors.rs delete mode 100644 gears/system/api-gateway/src/gear.rs.orig create mode 100644 gears/system/api-gateway/src/middleware.rs delete mode 100644 gears/system/api-gateway/src/middleware/auth.rs delete mode 100644 gears/system/api-gateway/src/middleware/errors.rs delete mode 100644 gears/system/api-gateway/src/middleware/mod.rs delete mode 100644 gears/system/api-gateway/tests/auth_middleware.rs.orig delete mode 100644 gears/system/api-gateway/tests/license_middleware.rs.orig delete mode 100644 gears/system/api-gateway/tests/middleware_order.rs.orig rename {gears/system/api-gateway/src/middleware => libs/toolkit-http-middleware/src}/access_log.rs (97%) rename {gears/system/api-gateway/src/middleware => libs/toolkit-http-middleware/src}/common.rs (77%) rename {gears/system/api-gateway/src/middleware => libs/toolkit-http-middleware/src}/http_metrics.rs (100%) rename {gears/system/api-gateway/src/middleware => libs/toolkit-http-middleware/src}/license_validation.rs (54%) rename {gears/system/api-gateway/src/middleware => libs/toolkit-http-middleware/src}/mime_validation.rs (50%) create mode 100644 libs/toolkit-http-middleware/src/policy.rs rename {gears/system/api-gateway/src/middleware => libs/toolkit-http-middleware/src}/rate_limit.rs (68%) rename {gears/system/api-gateway/src/middleware => libs/toolkit-http-middleware/src}/request_id.rs (71%) rename {gears/system/api-gateway/src/middleware => libs/toolkit-http-middleware/src}/scope_enforcement.rs (75%) diff --git a/Cargo.lock b/Cargo.lock index beac31de4..eb5276d27 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1377,18 +1377,14 @@ dependencies = [ "cf-gears-toolkit", "cf-gears-toolkit-canonical-errors", "cf-gears-toolkit-http", + "cf-gears-toolkit-http-middleware", "cf-gears-toolkit-macros", "cf-gears-toolkit-security", "chrono", "dashmap", "futures-core", - "glob", - "governor", "http", - "http-body", "inventory", - "matchit 0.9.2", - "nanoid", "opentelemetry", "opentelemetry_sdk", "parking_lot", @@ -2493,14 +2489,25 @@ dependencies = [ name = "cf-gears-toolkit-http-middleware" version = "0.1.0" dependencies = [ + "anyhow", "axum", + "bytes", "cf-gears-toolkit-canonical-errors", "cf-gears-toolkit-security", + "dashmap", + "glob", + "governor", "http", + "http-body", + "matchit 0.9.2", + "nanoid", + "opentelemetry", "secrecy", + "serde", "thiserror 2.0.18", "tokio", "tower", + "tower-http", "tracing", ] diff --git a/Cargo.toml b/Cargo.toml index 5be84037d..847462ba3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -232,6 +232,7 @@ verbose_file_reads = "deny" # libs toolkit = { package = "cf-gears-toolkit", version = "0.6.13", path = "libs/toolkit" } toolkit-http = { package = "cf-gears-toolkit-http", version = "0.6.8", path = "libs/toolkit-http" } +toolkit-http-middleware = { package = "cf-gears-toolkit-http-middleware", version = "0.1.0", path = "libs/toolkit-http-middleware" } toolkit-auth = { package = "cf-gears-toolkit-auth", version = "0.7.2", path = "libs/toolkit-auth" } toolkit-canonical-errors = { package = "cf-gears-toolkit-canonical-errors", version = "0.7.4", path = "libs/toolkit-canonical-errors" } toolkit-canonical-errors-macro = { package = "cf-gears-toolkit-canonical-errors-macro", version = "0.6.1", path = "libs/toolkit-canonical-errors-macro" } diff --git a/docs/ARCHITECTURE_MANIFEST.md b/docs/ARCHITECTURE_MANIFEST.md index 0c7fec074..63faaa216 100644 --- a/docs/ARCHITECTURE_MANIFEST.md +++ b/docs/ARCHITECTURE_MANIFEST.md @@ -141,7 +141,7 @@ This produces one recognizable API dialect across gears: - Shared pagination/filter/order conventions - OData-style filtering for collection resources where applicable - Consistent auth, rate-limit, timeout, and observability behavior at the gateway -- [x] Rate limiting — governor-based rate limiter with policy headers and inflight semaphores is implemented in the API Gateway middleware stack (`gears/system/api-gateway/src/middleware/rate_limit.rs`). OAGW has a separate rate-limiting implementation for outbound traffic. +- [x] Rate limiting — governor-based rate limiter with policy headers and inflight semaphores is implemented in the shared HTTP middleware crate (`libs/toolkit-http-middleware/src/rate_limit.rs`) and installed by the API Gateway. OAGW has a separate rate-limiting implementation for outbound traffic. - [~] License posture declaration — OperationBuilder declaration and base-license gate implemented; per-feature entitlement validation against license resolver pending. **Why.** Consumers, SDK authors, tests, docs, and gateway behavior all stay predictable. A gear does not invent its own filtering language, pagination rules, or error envelope, so cross-gear tooling and client generation remain feasible. diff --git a/docs/toolkit_unified_system/00_gear_overview.md b/docs/toolkit_unified_system/00_gear_overview.md index 0b2e48385..8fc4c586d 100644 --- a/docs/toolkit_unified_system/00_gear_overview.md +++ b/docs/toolkit_unified_system/00_gear_overview.md @@ -112,7 +112,7 @@ SetRequestId → PropagateRequestId → Trace → push_req_id_to_extensions requirement (`OperationBuilder::authenticated()` registers the route as `Required`), extracts the bearer token, calls the AuthN Resolver, and injects `SecurityContext` as a request `Extension`. See - `@gears/system/api-gateway/src/middleware/auth.rs:205-251`. + `security_context_middleware` in `@libs/toolkit-http-middleware/src/auth.rs`. - **ScopeEnforcement** — optional coarse-grained `token_scopes` gate (see *Gateway Scope Enforcement* in the authorization DESIGN); rejects early without calling the PDP. diff --git a/gears/system/api-gateway/Cargo.toml b/gears/system/api-gateway/Cargo.toml index e6ea57347..b3d838b36 100644 --- a/gears/system/api-gateway/Cargo.toml +++ b/gears/system/api-gateway/Cargo.toml @@ -21,6 +21,7 @@ workspace = true [dependencies] toolkit = { workspace = true } toolkit-http = { workspace = true } +toolkit-http-middleware = { workspace = true } toolkit-security = { workspace = true } toolkit-canonical-errors = { workspace = true, features = ["axum"] } authn-resolver-sdk = { package = "cf-gears-authn-resolver-sdk", version = "0.3.18", path = "../authn-resolver/authn-resolver-sdk" } @@ -37,26 +38,21 @@ parking_lot = { workspace = true } dashmap = { workspace = true } arc-swap = { workspace = true } -nanoid = { workspace = true } axum = { workspace = true } tower = { workspace = true } tower-http = { workspace = true } -matchit = { workspace = true } -governor = { workspace = true } -glob = { workspace = true } -opentelemetry = { workspace = true } chrono = { workspace = true } utoipa = { workspace = true } http = { workspace = true } -http-body = { workspace = true } -bytes = { workspace = true } rust-embed = { workspace = true } [dev-dependencies] futures-core = { workspace = true } +bytes = { workspace = true } +opentelemetry = { workspace = true } opentelemetry_sdk = { workspace = true, features = ["testing"] } tracing-subscriber = { workspace = true } uuid = { workspace = true } diff --git a/gears/system/api-gateway/src/authn_adapter.rs b/gears/system/api-gateway/src/authn_adapter.rs new file mode 100644 index 000000000..6ef5679c2 --- /dev/null +++ b/gears/system/api-gateway/src/authn_adapter.rs @@ -0,0 +1,75 @@ +//! `AuthN` Resolver bearer adapter. +//! +//! Bridges the gear-facing [`AuthNResolverClient`] to the transport-agnostic +//! [`BearerAuthenticator`] consumed by +//! `toolkit_http_middleware::security_context_middleware`, keeping the shared +//! middleware free of any dependency on the `AuthN` Resolver SDK. + +use std::sync::Arc; + +use authn_resolver_sdk::{AuthNResolverClient, AuthNResolverError}; +use toolkit_security::{AuthNError, BearerAuthenticator, SecurityContext}; + +/// Adapts the gear-facing [`AuthNResolverClient`] to the transport-agnostic +/// [`BearerAuthenticator`] consumed by +/// [`toolkit_http_middleware::security_context_middleware`]. +/// +/// This keeps the shared middleware free of any dependency on the `AuthN` +/// Resolver SDK: the gateway owns the mapping from `AuthNResolverError` onto the +/// neutral [`AuthNError`]. +pub struct AuthNResolverBearerAdapter { + client: Arc, +} + +impl AuthNResolverBearerAdapter { + /// Wrap an [`AuthNResolverClient`] as a [`BearerAuthenticator`]. + #[must_use] + pub fn new(client: Arc) -> Self { + Self { client } + } +} + +impl BearerAuthenticator for AuthNResolverBearerAdapter { + async fn authenticate(&self, token: &str) -> Result { + match self.client.authenticate(token).await { + Ok(result) => Ok(result.security_context), + Err(err) => { + log_authn_error(&err); + Err(map_authn_error(&err)) + } + } + } +} + +/// Map an `AuthNResolverError` onto the neutral [`AuthNError`] consumed by the +/// shared middleware, which renders the canonical `problem+json` response and +/// RFC 6750 challenge. No provider-specific detail is surfaced on the wire. +fn map_authn_error(err: &AuthNResolverError) -> AuthNError { + match err { + AuthNResolverError::Unauthorized(_) => AuthNError::InvalidToken, + AuthNResolverError::NoPluginAvailable | AuthNResolverError::ServiceUnavailable(_) => { + AuthNError::Unavailable + } + AuthNResolverError::TokenAcquisitionFailed(msg) | AuthNResolverError::Internal(msg) => { + AuthNError::Other(msg.clone()) + } + } +} + +/// Log authentication errors at appropriate levels. +/// +/// Cognitive complexity is inflated by tracing macro expansion. +#[allow(clippy::cognitive_complexity)] +fn log_authn_error(err: &AuthNResolverError) { + match err { + AuthNResolverError::Unauthorized(msg) => tracing::debug!("AuthN rejected: {msg}"), + AuthNResolverError::NoPluginAvailable => tracing::error!("No AuthN plugin available"), + AuthNResolverError::ServiceUnavailable(msg) => { + tracing::error!("AuthN service unavailable: {msg}"); + } + AuthNResolverError::TokenAcquisitionFailed(msg) => { + tracing::error!("AuthN token acquisition failed: {msg}"); + } + AuthNResolverError::Internal(msg) => tracing::error!("AuthN internal error: {msg}"), + } +} diff --git a/gears/system/api-gateway/src/config.rs b/gears/system/api-gateway/src/config.rs index 0ca959f4f..9f84d0ef6 100644 --- a/gears/system/api-gateway/src/config.rs +++ b/gears/system/api-gateway/src/config.rs @@ -77,23 +77,10 @@ impl Default for Defaults { } } -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields, default)] -pub struct RateLimitDefaults { - pub rps: u32, - pub burst: u32, - pub in_flight: u32, -} - -impl Default for RateLimitDefaults { - fn default() -> Self { - Self { - rps: 50, - burst: 100, - in_flight: 64, - } - } -} +/// Fallback rate-limit parameters. Aliased to the shared crate's config schema +/// so it lives with the middleware it configures. See +/// [`toolkit_http_middleware::rate_limit::RateLimitConfig`]. +pub use toolkit_http_middleware::rate_limit::RateLimitConfig as RateLimitDefaults; #[derive(Debug, Clone, Deserialize, Serialize)] #[serde(deny_unknown_fields, default)] @@ -166,51 +153,7 @@ impl Default for OpenApiConfig { } } -/// Route-level policy configuration. -/// -/// Enables coarse-grained early rejection of requests based on token scopes -/// without calling the PDP. This is an optimization for performance-critical routes. -/// -/// # Example YAML -/// -/// ```yaml -/// route_policies: -/// enabled: true -/// rules: -/// - path: "/admin/**" -/// required_scopes: ["admin"] -/// - path: "/events/v1/*" -/// required_scopes: ["read:events", "write:events"] # any of these -/// ``` -/// -/// # Behavior -/// -/// - Rules are evaluated in declaration order (first match wins) -/// - If `token_scopes: ["*"]` → always pass (first-party app) -/// - If `token_scopes` contains any of `required_scopes` → pass -/// - Otherwise → 403 Forbidden (before PDP call) -#[derive(Debug, Clone, Default, Deserialize, Serialize)] -#[serde(deny_unknown_fields, default)] -pub struct RoutePoliciesConfig { - /// Whether route policy enforcement is enabled. - pub enabled: bool, - /// Route policy rules evaluated in declaration order. - /// Patterns support glob syntax (e.g., `/admin/*`, `/events/v1/**`). - pub rules: Vec, -} - -/// A single route policy rule. -#[derive(Debug, Clone, Deserialize, Serialize)] -#[serde(deny_unknown_fields)] -pub struct RoutePolicyRule { - /// Path pattern to match. Supports glob syntax (`*` = one segment, `**` = any depth). - pub path: String, - /// HTTP method to match (GET, POST, PUT, PATCH, DELETE, etc.). - /// If not specified, matches any method. - #[serde(default, skip_serializing_if = "Option::is_none")] - pub method: Option, - /// Required scopes for this route. Request passes if token has ANY of these scopes. - /// Must not be empty. - pub required_scopes: Vec, - // Future fields: rate_limit, timeout, operation_id, etc. -} +/// Route-level policy (scope enforcement) configuration. Aliased to the shared +/// crate's config schema so it lives with the middleware it configures. See +/// [`toolkit_http_middleware::scope_enforcement::ScopeEnforcementConfig`]. +pub use toolkit_http_middleware::scope_enforcement::ScopeEnforcementConfig as RoutePoliciesConfig; diff --git a/gears/system/api-gateway/src/errors.rs b/gears/system/api-gateway/src/errors.rs new file mode 100644 index 000000000..c2f85511d --- /dev/null +++ b/gears/system/api-gateway/src/errors.rs @@ -0,0 +1,52 @@ +//! Canonical resource scopes for api-gateway request-pipeline errors. +use toolkit_canonical_errors::resource_error; + +/// Errors attributable to a registered API gateway route +/// (scope / license / RBAC). +#[resource_error("gts.cf.core.api_gateway.route.v1~")] +pub struct ApiGatewayRouteError; + +/// Umbrella scope for request-pipeline errors that don't target a +/// specific route resource (MIME validation, rate limit, request +/// timeout). Required because `invalid_argument`, `resource_exhausted`, +/// and `deadline_exceeded` are only available on `#[resource_error]` +/// scopes — there are no top-level `CanonicalError::*` constructors for +/// those categories. +#[resource_error("gts.cf.core.api_gateway.gateway.v1~")] +pub struct ApiGatewayGatewayError; + +/// GTS resource scope rendered (as `resource_type`) for +/// [`ApiGatewayRouteError`]. Passed to the shared, scope-agnostic middleware +/// (`toolkit_http_middleware`) so route-scoped rejections keep the gateway's +/// wire identity. Kept in sync with the `#[resource_error]` attribute above by +/// `scope_consts_match_resource_error_types`. +pub const ROUTE_SCOPE: &str = "gts.cf.core.api_gateway.route.v1~"; + +/// GTS resource scope rendered (as `resource_type`) for +/// [`ApiGatewayGatewayError`]. See [`ROUTE_SCOPE`]. +pub const GATEWAY_SCOPE: &str = "gts.cf.core.api_gateway.gateway.v1~"; + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use toolkit_canonical_errors::Problem; + + #[test] + fn scope_consts_match_resource_error_types() { + // The consts are duplicated from the `#[resource_error]` literals (a + // macro attribute can't reference a const); assert they stay in lockstep + // with what actually renders on the wire. + let route: Problem = ApiGatewayRouteError::permission_denied() + .with_reason("TEST") + .create() + .into(); + assert_eq!(route.context["resource_type"], ROUTE_SCOPE); + + let gateway: Problem = ApiGatewayGatewayError::resource_exhausted("test") + .with_quota_violation("test", "test") + .create() + .into(); + assert_eq!(gateway.context["resource_type"], GATEWAY_SCOPE); + } +} diff --git a/gears/system/api-gateway/src/gear.rs b/gears/system/api-gateway/src/gear.rs index 402bbe34b..b59844dec 100644 --- a/gears/system/api-gateway/src/gear.rs +++ b/gears/system/api-gateway/src/gear.rs @@ -27,7 +27,7 @@ use tower_http::{ }; use tracing::debug; -use crate::middleware::errors::ApiGatewayGatewayError; +use crate::errors::ApiGatewayGatewayError; /// Map a `tower::timeout` `Elapsed` (or any other unexpected `BoxError`) /// into a canonical `application/problem+json` response. @@ -49,8 +49,8 @@ async fn timeout_to_canonical(err: BoxError) -> axum::response::Response { use authn_resolver_sdk::AuthNResolverClient; +use crate::authn_adapter::AuthNResolverBearerAdapter; use crate::config::ApiGatewayConfig; -use crate::middleware::auth; use toolkit_security::SecurityContext; use toolkit_security::constants::{DEFAULT_SUBJECT_ID, DEFAULT_TENANT_ID}; @@ -152,49 +152,6 @@ impl ApiGateway { Ok(()) } - /// Build route policy from operation specs. - fn build_route_policy_from_specs(&self) -> Result { - let mut authenticated_routes = std::collections::HashSet::new(); - let mut public_routes = std::collections::HashSet::new(); - - // Always mark built-in health check routes as public - public_routes.insert((Method::GET, "/health".to_owned())); - public_routes.insert((Method::GET, "/healthz".to_owned())); - - public_routes.insert((Method::GET, "/docs".to_owned())); - public_routes.insert((Method::GET, "/openapi.json".to_owned())); - - for spec in &self.openapi_registry.operation_specs { - let spec = spec.value(); - - let route_key = (spec.method.clone(), spec.path.clone()); - - if spec.authenticated { - authenticated_routes.insert(route_key.clone()); - } - - if spec.is_public { - public_routes.insert(route_key); - } - } - - let config = self.get_cached_config(); - let requirements_count = authenticated_routes.len(); - let public_routes_count = public_routes.len(); - - let route_policy = auth::build_route_policy(&config, authenticated_routes, public_routes)?; - - tracing::info!( - auth_disabled = config.auth_disabled, - require_auth_by_default = config.require_auth_by_default, - requirements_count = requirements_count, - public_routes_count = public_routes_count, - "Route policy built from operation specs" - ); - - Ok(route_policy) - } - fn normalize_prefix_path(raw: &str) -> Result { let trimmed = raw.trim(); // Collapse consecutive slashes then strip trailing slash(es). @@ -240,8 +197,18 @@ impl ApiGateway { mut router: Router, authn_client: Option>, ) -> Result { + let config = self.get_cached_config(); + + // Collect specs once; used for the route policy + MIME/rate/license maps. + let specs: Vec<_> = self + .openapi_registry + .operation_specs + .iter() + .map(|e| e.value().clone()) + .collect(); + // Build route policy once - let route_policy = self.build_route_policy_from_specs()?; + let route_policy = middleware::build_route_policy_from_specs(&specs, &config)?; // IMPORTANT: `axum::Router::layer(...)` behaves like Tower layers: the **last** added layer // becomes the **outermost** layer and therefore runs **first** on the request path. @@ -256,26 +223,16 @@ impl ApiGateway { // 14) Propagate MatchedPath to response extensions (route_layer — innermost). // This copies MatchedPath from the request (populated by Axum route matching) // into the response so outer layer() middleware (metrics) can read it. - router = router.route_layer(from_fn(middleware::http_metrics::propagate_matched_path)); - - let config = self.get_cached_config(); - - // Collect specs once; used by MIME validation + rate limiting maps. - let specs: Vec<_> = self - .openapi_registry - .operation_specs - .iter() - .map(|e| e.value().clone()) - .collect(); + router = router.route_layer(from_fn( + toolkit_http_middleware::http_metrics::propagate_matched_path, + )); // 12) License validation - let license_map = middleware::license_validation::LicenseRequirementMap::from_specs(&specs); + let license_map = middleware::build_license_requirement_map(&specs); - router = router.layer(from_fn( - move |req: axum::extract::Request, next: axum::middleware::Next| { - let map = license_map.clone(); - middleware::license_validation::license_validation_middleware(map, req, next) - }, + router = router.layer(from_fn_with_state( + license_map, + toolkit_http_middleware::license_validation::license_validation_middleware, )); // 11) Route Policy Enforcement (runs after auth, checks token_scopes against route requirements) @@ -288,14 +245,10 @@ impl ApiGateway { )); } - let scope_rules = middleware::scope_enforcement::ScopeEnforcementRules::from_config( - &config.route_policies, - )?; - let scope_state = - middleware::scope_enforcement::ScopeEnforcementState { rules: scope_rules }; + let scope_rules = middleware::build_scope_enforcement_rules(&config.route_policies)?; router = router.layer(from_fn_with_state( - scope_state, - middleware::scope_enforcement::scope_enforcement_middleware, + scope_rules, + toolkit_http_middleware::scope_enforcement::scope_enforcement_middleware, )); } @@ -322,11 +275,17 @@ impl ApiGateway { }, )); } else if let Some(client) = authn_client { - let auth_state = auth::AuthState { - authn_client: client, - route_policy, - }; - router = router.layer(from_fn_with_state(auth_state, auth::authn_middleware)); + let adapter = Arc::new(AuthNResolverBearerAdapter::new(client)); + let policy: Arc = Arc::new(route_policy); + // The gateway is the edge: it installs a CORS layer, so preflight + // requests must bypass auth (see edge-architecture ADR). + let auth_state = + toolkit_http_middleware::SecurityContextLayerState::new(adapter, policy) + .with_cors_preflight_bypass(); + router = router.layer(from_fn_with_state( + auth_state, + toolkit_http_middleware::security_context_middleware::, + )); } else { return Err(anyhow::anyhow!( "auth is enabled but no AuthN Resolver client is available; \ @@ -338,22 +297,18 @@ impl ApiGateway { router = router.layer(from_fn(toolkit::api::error_layer::error_mapping_middleware)); // 10) Per-route rate limiting & in-flight limits - let rate_map = middleware::rate_limit::RateLimiterMap::from_specs(&specs, &config)?; + let rate_map = middleware::build_rate_limiter_map(&specs, &config)?; - router = router.layer(from_fn( - move |req: axum::extract::Request, next: axum::middleware::Next| { - let map = rate_map.clone(); - middleware::rate_limit::rate_limit_middleware(map, req, next) - }, + router = router.layer(from_fn_with_state( + rate_map, + toolkit_http_middleware::rate_limit::rate_limit_middleware, )); // 9) MIME type validation - let mime_map = middleware::mime_validation::build_mime_validation_map(&specs); - router = router.layer(from_fn( - move |req: axum::extract::Request, next: axum::middleware::Next| { - let map = mime_map.clone(); - middleware::mime_validation::mime_validation_middleware(map, req, next) - }, + let mime_map = middleware::build_mime_validation_map(&specs); + router = router.layer(from_fn_with_state( + mime_map, + toolkit_http_middleware::mime_validation::mime_validation_middleware, )); // 8) CORS (must be outer to auth/limits so OPTIONS preflight short-circuits) @@ -386,20 +341,24 @@ impl ApiGateway { router = router.layer(from_fn(toolkit::api::canonical_error_middleware)); // 4) HTTP metrics (layer — captures all middleware responses including auth/rate-limit/timeout) - let http_metrics = Arc::new(middleware::http_metrics::HttpMetrics::new( + let http_metrics = Arc::new(toolkit_http_middleware::http_metrics::HttpMetrics::new( Self::MODULE_NAME, &config.metrics.prefix, )); router = router.layer(from_fn_with_state( http_metrics, - middleware::http_metrics::http_metrics_middleware, + toolkit_http_middleware::http_metrics::http_metrics_middleware, )); // 3.5) Structured access log (runs after push_req_id populates XRequestId extension) - router = router.layer(from_fn(middleware::access_log::access_log_middleware)); + router = router.layer(from_fn( + toolkit_http_middleware::access_log::access_log_middleware, + )); // 3) Record request_id into span + extensions (requires span to exist first => must be inner to Trace) - router = router.layer(from_fn(middleware::request_id::push_req_id_to_extensions)); + router = router.layer(from_fn( + toolkit_http_middleware::request_id::push_req_id_to_extensions, + )); // 2) Trace (outer to push_req_id_to_extensions) router = router.layer({ @@ -409,7 +368,7 @@ impl ApiGateway { TraceLayer::new_for_http() .make_span_with(move |req: &axum::http::Request| { - let hdr = middleware::request_id::header(); + let hdr = toolkit_http_middleware::request_id::header(); let rid = req .headers() .get(&hdr) @@ -459,12 +418,12 @@ impl ApiGateway { }); // 1) Request ID handling (outermost) - let x_request_id = crate::middleware::request_id::header(); + let x_request_id = toolkit_http_middleware::request_id::header(); // If missing, generate x-request-id first; then propagate it to the response. router = router.layer(PropagateRequestIdLayer::new(x_request_id.clone())); router = router.layer(SetRequestIdLayer::new( x_request_id, - crate::middleware::request_id::MakeReqId, + toolkit_http_middleware::request_id::MakeReqId, )); Ok(router) diff --git a/gears/system/api-gateway/src/gear.rs.orig b/gears/system/api-gateway/src/gear.rs.orig deleted file mode 100644 index 79a4b9fc6..000000000 --- a/gears/system/api-gateway/src/gear.rs.orig +++ /dev/null @@ -1,1297 +0,0 @@ -//! API Gateway Gear definition -//! -//! Contains the `ApiGateway` gear struct and its trait implementations. - -use async_trait::async_trait; -use std::sync::Arc; - -use arc_swap::ArcSwap; -use dashmap::DashMap; - -use anyhow::Result; -use axum::error_handling::HandleErrorLayer; -use axum::http::Method; -use axum::middleware::from_fn_with_state; -use axum::{Router, extract::DefaultBodyLimit, middleware::from_fn, routing::get}; -use toolkit::api::{OpenApiRegistry, OpenApiRegistryImpl}; -use toolkit::lifecycle::ReadySignal; -use parking_lot::Mutex; -use std::net::SocketAddr; -use std::time::Duration; -use tokio_util::sync::CancellationToken; -use tower::{BoxError, ServiceBuilder}; -use tower_http::{ - catch_panic::CatchPanicLayer, - limit::RequestBodyLimitLayer, - request_id::{PropagateRequestIdLayer, SetRequestIdLayer}, -}; -use tracing::debug; - -use crate::middleware::errors::ApiGatewayGatewayError; - -/// Map a `tower::timeout` `Elapsed` (or any other unexpected `BoxError`) -/// into a canonical `application/problem+json` response. -async fn timeout_to_canonical(err: BoxError) -> axum::response::Response { - use axum::response::IntoResponse; - - if err.is::() { - let canonical = - ApiGatewayGatewayError::deadline_exceeded("Request exceeded 30s timeout").create(); - return canonical.into_response(); - } - - let canonical = - toolkit_canonical_errors::CanonicalError::internal(format!("request pipeline error: {err}")) - .create(); - canonical.into_response() -} - -use authn_resolver_sdk::AuthNResolverClient; - -use crate::config::ApiGatewayConfig; -use crate::middleware::auth; -use toolkit_security::SecurityContext; -use toolkit_security::constants::{DEFAULT_SUBJECT_ID, DEFAULT_TENANT_ID}; - -use crate::middleware; -use crate::router_cache::RouterCache; -use crate::web; - -/// Main API Gateway gear — owns the HTTP server (`rest_host`) and collects -/// typed operation specs to emit a single `OpenAPI` document. -#[toolkit::gear( - name = "api-gateway", - capabilities = [rest_host, rest, stateful], - deps = ["grpc-hub", "authn-resolver"], - lifecycle(entry = "serve", stop_timeout = "30s", await_ready) -)] -pub struct ApiGateway { - // Lock-free config using arc-swap for read-mostly access - pub(crate) config: ArcSwap, - // OpenAPI registry for operations and schemas - pub(crate) openapi_registry: Arc, - // Built router cache for zero-lock hot path access - pub(crate) router_cache: RouterCache, - // Store the finalized router from REST phase for serving - pub(crate) final_router: Mutex>, - // AuthN Resolver client (resolved during init, None when auth_disabled) - pub(crate) authn_client: Mutex>>, - - // Duplicate detection (per (method, path) and per handler id) - pub(crate) registered_routes: DashMap<(Method, String), ()>, - pub(crate) registered_handlers: DashMap, -} - -impl Default for ApiGateway { - fn default() -> Self { - let default_router = Router::new(); - Self { - config: ArcSwap::from_pointee(ApiGatewayConfig::default()), - openapi_registry: Arc::new(OpenApiRegistryImpl::new()), - router_cache: RouterCache::new(default_router), - final_router: Mutex::new(None), - authn_client: Mutex::new(None), - registered_routes: DashMap::new(), - registered_handlers: DashMap::new(), - } - } -} - -impl ApiGateway { - fn apply_prefix_nesting(mut router: Router, prefix: &str) -> Router { - if prefix.is_empty() { - return router; - } - - let top = Router::new() - .route("/health", get(web::health_check)) - .route("/healthz", get(|| async { "ok" })); - - router = Router::new().nest(prefix, router); - top.merge(router) - } - - /// Create a new `ApiGateway` instance with the given configuration - #[must_use] - pub fn new(config: ApiGatewayConfig) -> Self { - let default_router = Router::new(); - Self { - config: ArcSwap::from_pointee(config), - openapi_registry: Arc::new(OpenApiRegistryImpl::new()), - router_cache: RouterCache::new(default_router), - final_router: Mutex::new(None), - authn_client: Mutex::new(None), - registered_routes: DashMap::new(), - registered_handlers: DashMap::new(), - } - } - - /// Get the current configuration (cheap clone from `ArcSwap`) - pub fn get_config(&self) -> ApiGatewayConfig { - (**self.config.load()).clone() - } - - /// Get cached configuration (lock-free with `ArcSwap`) - pub fn get_cached_config(&self) -> ApiGatewayConfig { - (**self.config.load()).clone() - } - - /// Get the cached router without rebuilding (useful for performance-critical paths) - pub fn get_cached_router(&self) -> Arc { - self.router_cache.load() - } - - /// Force rebuild and cache of the router. - /// - /// # Errors - /// Returns an error if router building fails. - pub fn rebuild_and_cache_router(&self) -> Result<()> { - let new_router = self.build_router()?; - self.router_cache.store(new_router); - Ok(()) - } - - /// Build route policy from operation specs. - fn build_route_policy_from_specs(&self) -> Result { - let mut authenticated_routes = std::collections::HashSet::new(); - let mut public_routes = std::collections::HashSet::new(); - - // Always mark built-in health check routes as public - public_routes.insert((Method::GET, "/health".to_owned())); - public_routes.insert((Method::GET, "/healthz".to_owned())); - - public_routes.insert((Method::GET, "/docs".to_owned())); - public_routes.insert((Method::GET, "/openapi.json".to_owned())); - - for spec in &self.openapi_registry.operation_specs { - let spec = spec.value(); - - let route_key = (spec.method.clone(), spec.path.clone()); - - if spec.authenticated { - authenticated_routes.insert(route_key.clone()); - } - - if spec.is_public { - public_routes.insert(route_key); - } - } - - let config = self.get_cached_config(); - let requirements_count = authenticated_routes.len(); - let public_routes_count = public_routes.len(); - - let route_policy = auth::build_route_policy(&config, authenticated_routes, public_routes)?; - - tracing::info!( - auth_disabled = config.auth_disabled, - require_auth_by_default = config.require_auth_by_default, - requirements_count = requirements_count, - public_routes_count = public_routes_count, - "Route policy built from operation specs" - ); - - Ok(route_policy) - } - - fn normalize_prefix_path(raw: &str) -> Result { - let trimmed = raw.trim(); - // Collapse consecutive slashes then strip trailing slash(es). - let collapsed: String = - trimmed - .chars() - .fold(String::with_capacity(trimmed.len()), |mut acc, c| { - if c == '/' && acc.ends_with('/') { - // skip duplicate slash - } else { - acc.push(c); - } - acc - }); - let prefix = collapsed.trim_end_matches('/'); - let result = if prefix.is_empty() { - String::new() - } else if prefix.starts_with('/') { - prefix.to_owned() - } else { - format!("/{prefix}") - }; - // Reject characters that are unsafe in URL paths or HTML attributes. - if !result - .bytes() - .all(|b| b.is_ascii_alphanumeric() || b == b'/' || b == b'_' || b == b'-' || b == b'.') - { - anyhow::bail!( - "prefix_path contains invalid characters (must match [a-zA-Z0-9/_\\-.]): {raw:?}" - ); - } - - if result.split('/').any(|seg| seg == "." || seg == "..") { - anyhow::bail!("prefix_path must not contain '.' or '..' segments: {raw:?}"); - } - - Ok(result) - } - - /// Apply all middleware layers to a router (request ID, tracing, timeout, body limit, CORS, rate limiting, error mapping, auth) - pub(crate) fn apply_middleware_stack( - &self, - mut router: Router, - authn_client: Option>, - ) -> Result { - // Build route policy once - let route_policy = self.build_route_policy_from_specs()?; - - // IMPORTANT: `axum::Router::layer(...)` behaves like Tower layers: the **last** added layer - // becomes the **outermost** layer and therefore runs **first** on the request path. - // - // Desired request execution order (outermost -> innermost): - // SetRequestId -> PropagateRequestId -> Trace -> push_req_id_to_extensions - // -> Timeout -> BodyLimit -> CORS -> MIME validation -> RateLimit -> ErrorMapping -> Auth -> ScopeEnforcement -> License -> Router - // - // Therefore we must add layers in the reverse order (innermost -> outermost) below. - // Due future refactoring, this order must be maintained. - - // 14) Propagate MatchedPath to response extensions (route_layer — innermost). - // This copies MatchedPath from the request (populated by Axum route matching) - // into the response so outer layer() middleware (metrics) can read it. - router = router.route_layer(from_fn(middleware::http_metrics::propagate_matched_path)); - - let config = self.get_cached_config(); - - // Collect specs once; used by MIME validation + rate limiting maps. - let specs: Vec<_> = self - .openapi_registry - .operation_specs - .iter() - .map(|e| e.value().clone()) - .collect(); - - // 12) License validation - let license_map = middleware::license_validation::LicenseRequirementMap::from_specs(&specs); - - router = router.layer(from_fn( - move |req: axum::extract::Request, next: axum::middleware::Next| { - let map = license_map.clone(); - middleware::license_validation::license_validation_middleware(map, req, next) - }, - )); - - // 11) Route Policy Enforcement (runs after auth, checks token_scopes against route requirements) - if config.route_policies.enabled { - // Reject invalid combination: route_policies requires authentication to work - if config.auth_disabled { - return Err(anyhow::anyhow!( - "Invalid configuration: route_policies.enabled=true requires authentication. \ - Set auth_disabled=false or disable route_policies." - )); - } - - let scope_rules = middleware::scope_enforcement::ScopeEnforcementRules::from_config( - &config.route_policies, - )?; - let scope_state = - middleware::scope_enforcement::ScopeEnforcementState { rules: scope_rules }; - router = router.layer(from_fn_with_state( - scope_state, - middleware::scope_enforcement::scope_enforcement_middleware, - )); - } - - // 10) Auth - if config.auth_disabled { - // Build security contexts for compatibility during migration - let default_security_context = SecurityContext::builder() - .subject_id(DEFAULT_SUBJECT_ID) - .subject_tenant_id(DEFAULT_TENANT_ID) - .build()?; - - tracing::warn!( - "API Gateway auth is DISABLED: all requests will run with default tenant SecurityContext. \ - This mode bypasses authentication and is intended ONLY for single-user on-premises deployments without an IdP. \ - Permission checks and secure ORM still apply. DO NOT use this mode in multi-tenant or production environments." - ); - router = router.layer(from_fn( - move |mut req: axum::extract::Request, next: axum::middleware::Next| { - let sec_context = default_security_context.clone(); - async move { - req.extensions_mut().insert(sec_context); - next.run(req).await - } - }, - )); - } else if let Some(client) = authn_client { - let auth_state = auth::AuthState { - authn_client: client, - route_policy, - }; - router = router.layer(from_fn_with_state(auth_state, auth::authn_middleware)); - } else { - return Err(anyhow::anyhow!( - "auth is enabled but no AuthN Resolver client is available; \ - ensure `authn_resolver` gear is loaded or set `auth_disabled: true`" - )); - } - - // 11) Error mapping (outer to auth so it can translate auth/handler errors) - router = router.layer(from_fn(toolkit::api::error_layer::error_mapping_middleware)); - - // 10) Per-route rate limiting & in-flight limits - let rate_map = middleware::rate_limit::RateLimiterMap::from_specs(&specs, &config)?; - - router = router.layer(from_fn( - move |req: axum::extract::Request, next: axum::middleware::Next| { - let map = rate_map.clone(); - middleware::rate_limit::rate_limit_middleware(map, req, next) - }, - )); - - // 9) MIME type validation - let mime_map = middleware::mime_validation::build_mime_validation_map(&specs); - router = router.layer(from_fn( - move |req: axum::extract::Request, next: axum::middleware::Next| { - let map = mime_map.clone(); - middleware::mime_validation::mime_validation_middleware(map, req, next) - }, - )); - - // 8) CORS (must be outer to auth/limits so OPTIONS preflight short-circuits) - if config.cors_enabled { - router = router.layer(crate::cors::build_cors_layer(&config)); - } - - // 7) Body limit - router = router.layer(RequestBodyLimitLayer::new(config.defaults.body_limit_bytes)); - router = router.layer(DefaultBodyLimit::max(config.defaults.body_limit_bytes)); - - // 6) Timeout — emits canonical `deadline_exceeded` Problem with - // `application/problem+json` body when the inner service exceeds - // the deadline. Layer position is unchanged (between BodyLimit - // and CatchPanic). - router = router.layer( - ServiceBuilder::new() - .layer(HandleErrorLayer::new(timeout_to_canonical)) - .timeout(Duration::from_secs(30)), - ); - - // 5) CatchPanic (converts panics to 500 before metrics sees them) - router = router.layer(CatchPanicLayer::new()); - - // 4.5) Canonical error middleware — fills trace_id / instance on - // application/problem+json bodies and logs WARN/ERROR. Sits inside - // http_metrics so metrics observe the canonical-final body, and - // outside CatchPanicLayer so panics still reach the panic handler - // before this middleware tries to rewrite them. - router = router.layer(from_fn(toolkit::api::canonical_error_middleware)); - - // 4) HTTP metrics (layer — captures all middleware responses including auth/rate-limit/timeout) - let http_metrics = Arc::new(middleware::http_metrics::HttpMetrics::new( - Self::MODULE_NAME, - &config.metrics.prefix, - )); - router = router.layer(from_fn_with_state( - http_metrics, - middleware::http_metrics::http_metrics_middleware, - )); - - // 3.5) Structured access log (runs after push_req_id populates XRequestId extension) - router = router.layer(from_fn(middleware::access_log::access_log_middleware)); - - // 3) Record request_id into span + extensions (requires span to exist first => must be inner to Trace) - router = router.layer(from_fn(middleware::request_id::push_req_id_to_extensions)); - - // 2) Trace (outer to push_req_id_to_extensions) - router = router.layer({ - use toolkit_http::otel; - use tower_http::trace::TraceLayer; - use tracing::field::Empty; - - TraceLayer::new_for_http() - .make_span_with(move |req: &axum::http::Request| { - let hdr = middleware::request_id::header(); - let rid = req - .headers() - .get(&hdr) - .and_then(|v| v.to_str().ok()) - .unwrap_or("n/a"); - - let span = tracing::info_span!( - "http_request", - method = %req.method(), - uri = %req.uri().path(), - version = ?req.version(), - gear = "api_gateway", - endpoint = %req.uri().path(), - request_id = %rid, - status = Empty, - latency_ms = Empty, - // OpenTelemetry semantic conventions - "http.method" = %req.method(), - "http.target" = %req.uri().path(), - "http.scheme" = req.uri().scheme_str().unwrap_or("http"), - "http.host" = req.headers().get("host") - .and_then(|h| h.to_str().ok()) - .unwrap_or("unknown"), - "user_agent.original" = req.headers().get("user-agent") - .and_then(|h| h.to_str().ok()) - .unwrap_or("unknown"), - // Trace context placeholders (for log correlation) - trace_id = Empty, - parent.trace_id = Empty - ); - - // Set parent OTel trace context (W3C traceparent), if any - // This also populates trace_id and parent.trace_id from headers - otel::set_parent_from_headers(&span, req.headers()); - - span - }) - .on_response( - |res: &axum::http::Response, - latency: std::time::Duration, - span: &tracing::Span| { - let ms = latency.as_millis(); - span.record("status", res.status().as_u16()); - span.record("latency_ms", ms); - }, - ) - }); - - // 1) Request ID handling (outermost) - let x_request_id = crate::middleware::request_id::header(); - // If missing, generate x-request-id first; then propagate it to the response. - router = router.layer(PropagateRequestIdLayer::new(x_request_id.clone())); - router = router.layer(SetRequestIdLayer::new( - x_request_id, - crate::middleware::request_id::MakeReqId, - )); - - Ok(router) - } - - /// Build the HTTP router from registered routes and operations. - /// - /// # Errors - /// Returns an error if router building or middleware setup fails. - pub fn build_router(&self) -> Result { - // If the cached router is currently held elsewhere (e.g., by the running server), - // return it without rebuilding to avoid unnecessary allocations. - let cached_router = self.router_cache.load(); - if Arc::strong_count(&cached_router) > 1 { - tracing::debug!("Using cached router"); - return Ok((*cached_router).clone()); - } - - tracing::debug!("Building new router (standalone/fallback mode)"); - // In standalone mode (no REST pipeline), register both health endpoints here. - // In normal operation, rest_prepare() registers these instead. - let mut router = Router::new() - .route("/health", get(web::health_check)) - .route("/healthz", get(|| async { "ok" })); - - // Apply all middleware layers including auth, above the router - let authn_client = self.authn_client.lock().clone(); - router = self.apply_middleware_stack(router, authn_client)?; - - let config = self.get_cached_config(); - let prefix = Self::normalize_prefix_path(&config.prefix_path)?; - router = Self::apply_prefix_nesting(router, &prefix); - - // Cache the built router for future use - self.router_cache.store(router.clone()); - - Ok(router) - } - - /// Build `OpenAPI` specification from registered routes and components. - /// - /// # Errors - /// Returns an error if `OpenAPI` specification building fails. - pub fn build_openapi(&self) -> Result { - let config = self.get_cached_config(); - let prefix = Self::normalize_prefix_path(&config.prefix_path)?; - let info = toolkit::api::OpenApiInfo { - title: config.openapi.title.clone(), - version: config.openapi.version.clone(), - description: config.openapi.description, - servers: (!prefix.is_empty()).then_some(prefix).into_iter().collect(), - }; - self.openapi_registry.build_openapi(&info) - } - - /// Parse bind address from configuration string. - fn parse_bind_address(bind_addr: &str) -> anyhow::Result { - bind_addr - .parse() - .map_err(|e| anyhow::anyhow!("Invalid bind address '{bind_addr}': {e}")) - } - - /// Get the finalized router or build a default one. - fn get_or_build_router(self: &Arc) -> anyhow::Result { - let stored = { self.final_router.lock().take() }; - - if let Some(router) = stored { - tracing::debug!("Using router from REST phase"); - Ok(router) - } else { - tracing::debug!("No router from REST phase, building default router"); - self.build_router() - } - } - - /// Background HTTP server: bind, notify ready, serve until cancelled. - /// - /// This method is the lifecycle entry-point generated by the macro - /// (`#[toolkit::gear(..., lifecycle(...))]`). - pub(crate) async fn serve( - self: Arc, - cancel: CancellationToken, - ready: ReadySignal, - ) -> anyhow::Result<()> { - let cfg = self.get_cached_config(); - let addr = Self::parse_bind_address(&cfg.bind_addr)?; - let router = self.get_or_build_router()?; - - // Bind the socket, only now consider the service "ready" - let listener = tokio::net::TcpListener::bind(addr).await?; - tracing::info!("HTTP server bound on {}", addr); - ready.notify(); // Starting -> Running - - // Graceful shutdown on cancel - let shutdown = { - let cancel = cancel.clone(); - async move { - cancel.cancelled().await; - tracing::info!("HTTP server shutting down gracefully (cancellation)"); - } - }; - - axum::serve( - listener, - router.into_make_service_with_connect_info::(), - ) - .with_graceful_shutdown(shutdown) - .await - .map_err(|e| anyhow::anyhow!(e)) - } - - /// Check if `handler_id` is already registered (returns true if duplicate) - fn check_duplicate_handler(&self, spec: &toolkit::api::OperationSpec) -> bool { - if self - .registered_handlers - .insert(spec.handler_id.clone(), ()) - .is_some() - { - tracing::error!( - handler_id = %spec.handler_id, - method = %spec.method.as_str(), - path = %spec.path, - "Duplicate handler_id detected; ignoring subsequent registration" - ); - return true; - } - false - } - - /// Check if route (method, path) is already registered (returns true if duplicate) - fn check_duplicate_route(&self, spec: &toolkit::api::OperationSpec) -> bool { - let route_key = (spec.method.clone(), spec.path.clone()); - if self.registered_routes.insert(route_key, ()).is_some() { - tracing::error!( - method = %spec.method.as_str(), - path = %spec.path, - "Duplicate (method, path) detected; ignoring subsequent registration" - ); - return true; - } - false - } - - /// Log successful operation registration - fn log_operation_registration(&self, spec: &toolkit::api::OperationSpec) { - let current_count = self.openapi_registry.operation_specs.len(); - tracing::debug!( - handler_id = %spec.handler_id, - method = %spec.method.as_str(), - path = %spec.path, - summary = %spec.summary.as_deref().unwrap_or("No summary"), - total_operations = current_count, - "Registered API operation" - ); - } - - /// Add `OpenAPI` documentation routes to the router - fn add_openapi_routes(&self, mut router: axum::Router) -> anyhow::Result { - // Build once, serve as static JSON (no per-request parsing) - let op_count = self.openapi_registry.operation_specs.len(); - tracing::info!( - "rest_finalize: emitting OpenAPI with {} operations", - op_count - ); - - let openapi_doc = Arc::new(self.build_openapi()?); - let config = self.get_cached_config(); - let prefix = Self::normalize_prefix_path(&config.prefix_path)?; - let html_doc = web::serve_docs(&prefix); - - router = router - .route( - "/openapi.json", - get({ - use axum::{http::header, response::IntoResponse}; - let doc = openapi_doc; - move || async move { - let json_string = match serde_json::to_string_pretty(doc.as_ref()) { - Ok(json) => json, - Err(e) => { - tracing::error!("Failed to serialize OpenAPI doc: {}", e); - return (http::StatusCode::INTERNAL_SERVER_ERROR).into_response(); - } - }; - ( - [ - (header::CONTENT_TYPE, "application/json"), - (header::CACHE_CONTROL, "no-store"), - ], - json_string, - ) - .into_response() - } - }), - ) - .route("/docs", get(move || async move { html_doc })); - - #[cfg(feature = "embed_elements")] - { - router = router.route( - "/docs/assets/{*file}", - get(crate::assets::serve_elements_asset), - ); - } - - Ok(router) - } -} - -// Manual implementation of Module trait with config loading -#[async_trait] -impl toolkit::Gear for ApiGateway { - async fn init(&self, ctx: &toolkit::context::GearCtx) -> anyhow::Result<()> { - let cfg = ctx.config_or_default::()?; - self.config.store(Arc::new(cfg.clone())); - - debug!( - "Effective api_gateway configuration:\n{:#?}", - self.config.load() - ); - - if cfg.auth_disabled { - tracing::info!( - tenant_id = %DEFAULT_TENANT_ID, - "Auth-disabled mode enabled with default tenant" - ); - } else { - // Resolve AuthN Resolver client from ClientHub - let authn_client = ctx.client_hub().get::()?; - *self.authn_client.lock() = Some(authn_client); - tracing::info!("AuthN Resolver client resolved from ClientHub"); - } - - Ok(()) - } -} - -// REST host role: prepare/finalize the router, but do not start the server here. -impl toolkit::contracts::ApiGatewayCapability for ApiGateway { - fn rest_prepare( - &self, - _ctx: &toolkit::context::GearCtx, - router: axum::Router, - ) -> anyhow::Result { - // Add health check endpoints: - // - /health: detailed JSON response with status and timestamp - // - /healthz: simple "ok" liveness probe (Kubernetes-style) - let router = router - .route("/health", get(web::health_check)) - .route("/healthz", get(|| async { "ok" })); - - // You may attach global middlewares here (trace, compression, cors), but do not start server. - tracing::debug!("REST host prepared base router with health check endpoints"); - Ok(router) - } - - fn rest_finalize( - &self, - _ctx: &toolkit::context::GearCtx, - mut router: axum::Router, - ) -> anyhow::Result { - let config = self.get_cached_config(); - - if config.enable_docs { - router = self.add_openapi_routes(router)?; - } - - // Apply middleware stack (including auth) to the final router - tracing::debug!("Applying middleware stack to finalized router"); - let authn_client = self.authn_client.lock().clone(); - router = self.apply_middleware_stack(router, authn_client)?; - - let prefix = Self::normalize_prefix_path(&config.prefix_path)?; - router = Self::apply_prefix_nesting(router, &prefix); - - // Keep the finalized router to be used by `serve()` - *self.final_router.lock() = Some(router.clone()); - - tracing::info!("REST host finalized router with OpenAPI endpoints and auth middleware"); - Ok(router) - } - - fn as_registry(&self) -> &dyn toolkit::contracts::OpenApiRegistry { - self - } -} - -impl toolkit::contracts::RestApiCapability for ApiGateway { - fn register_rest( - &self, - _ctx: &toolkit::context::GearCtx, - router: axum::Router, - _openapi: &dyn toolkit::contracts::OpenApiRegistry, - ) -> anyhow::Result { - // This gear acts as both rest_host and rest, but actual REST endpoints - // are handled in the host methods above. - Ok(router) - } -} - -impl OpenApiRegistry for ApiGateway { - fn register_operation(&self, spec: &toolkit::api::OperationSpec) { - // Reject duplicates with "first wins" policy (second registration = programmer error). - if self.check_duplicate_handler(spec) { - return; - } - - if self.check_duplicate_route(spec) { - return; - } - - // Delegate to the internal registry - self.openapi_registry.register_operation(spec); - self.log_operation_registration(spec); - } - - fn ensure_schema_raw( - &self, - root_name: &str, - schemas: Vec<( - String, - utoipa::openapi::RefOr, - )>, - ) -> String { - // Delegate to the internal registry - self.openapi_registry.ensure_schema_raw(root_name, schemas) - } - - fn as_any(&self) -> &dyn std::any::Any { - self - } -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::*; - - #[test] - fn test_openapi_generation() { - let mut config = ApiGatewayConfig::default(); - config.openapi.title = "Test API".to_owned(); - config.openapi.version = "1.0.0".to_owned(); - config.openapi.description = Some("Test Description".to_owned()); - let api = ApiGateway::new(config); - - // Test that we can build OpenAPI without any operations - let doc = api.build_openapi().unwrap(); - let json = serde_json::to_value(&doc).unwrap(); - - // Verify it's valid OpenAPI document structure - assert!(json.get("openapi").is_some()); - assert!(json.get("info").is_some()); - assert!(json.get("paths").is_some()); - - // Verify info section - let info = json.get("info").unwrap(); - assert_eq!(info.get("title").unwrap(), "Test API"); - assert_eq!(info.get("version").unwrap(), "1.0.0"); - assert_eq!(info.get("description").unwrap(), "Test Description"); - } - - #[test] - fn test_openapi_servers_with_prefix() { - let mut config = ApiGatewayConfig::default(); - config.prefix_path = "/cf".to_owned(); - let api = ApiGateway::new(config); - - let doc = api.build_openapi().unwrap(); - let json = serde_json::to_value(&doc).unwrap(); - - let servers = json.get("servers").expect("servers field should be present"); - let arr = servers.as_array().expect("servers should be an array"); - assert_eq!(arr.len(), 1); - assert_eq!(arr[0].get("url").unwrap(), "/cf"); - } - - #[test] - fn test_openapi_no_servers_without_prefix() { - let config = ApiGatewayConfig::default(); // prefix_path is "" - let api = ApiGateway::new(config); - - let doc = api.build_openapi().unwrap(); - let json = serde_json::to_value(&doc).unwrap(); - - // When prefix is empty, servers should be absent (None → omitted from JSON) - assert!( - json.get("servers").is_none(), - "servers should be absent when prefix_path is empty" - ); - } -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod normalize_prefix_path_tests { - use super::*; - - #[test] - fn empty_string_returns_empty() { - assert_eq!(ApiGateway::normalize_prefix_path("").unwrap(), ""); - } - - #[test] - fn sole_slash_returns_empty() { - assert_eq!(ApiGateway::normalize_prefix_path("/").unwrap(), ""); - } - - #[test] - fn multiple_slashes_return_empty() { - assert_eq!(ApiGateway::normalize_prefix_path("///").unwrap(), ""); - } - - #[test] - fn whitespace_only_returns_empty() { - assert_eq!(ApiGateway::normalize_prefix_path(" ").unwrap(), ""); - } - - #[test] - fn simple_prefix_preserved() { - assert_eq!(ApiGateway::normalize_prefix_path("/cf").unwrap(), "/cf"); - } - - #[test] - fn trailing_slash_stripped() { - assert_eq!(ApiGateway::normalize_prefix_path("/cf/").unwrap(), "/cf"); - } - - #[test] - fn leading_slash_prepended_when_missing() { - assert_eq!(ApiGateway::normalize_prefix_path("cf").unwrap(), "/cf"); - } - - #[test] - fn consecutive_leading_slashes_collapsed() { - assert_eq!(ApiGateway::normalize_prefix_path("//cf").unwrap(), "/cf"); - } - - #[test] - fn consecutive_slashes_mid_path_collapsed() { - assert_eq!( - ApiGateway::normalize_prefix_path("/api//v1").unwrap(), - "/api/v1" - ); - } - - #[test] - fn many_consecutive_slashes_collapsed() { - assert_eq!( - ApiGateway::normalize_prefix_path("///api///v1///").unwrap(), - "/api/v1" - ); - } - - #[test] - fn surrounding_whitespace_trimmed() { - assert_eq!(ApiGateway::normalize_prefix_path(" /cf ").unwrap(), "/cf"); - } - - #[test] - fn nested_path_preserved() { - assert_eq!( - ApiGateway::normalize_prefix_path("/api/v1").unwrap(), - "/api/v1" - ); - } - - #[test] - fn dot_in_path_allowed() { - assert_eq!( - ApiGateway::normalize_prefix_path("/api/v1.0").unwrap(), - "/api/v1.0" - ); - } - - #[test] - fn rejects_html_injection() { - let result = ApiGateway::normalize_prefix_path(r#"">"#); - assert!(result.is_err()); - } - - #[test] - fn rejects_spaces_in_path() { - let result = ApiGateway::normalize_prefix_path("/my path"); - assert!(result.is_err()); - } - - #[test] - fn rejects_query_string_chars() { - let result = ApiGateway::normalize_prefix_path("/api?foo=bar"); - assert!(result.is_err()); - } -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod problem_openapi_tests { - use super::*; - use axum::Json; - use toolkit::api::{Missing, OperationBuilder}; - use serde_json::Value; - - async fn dummy_handler() -> Json { - Json(serde_json::json!({"ok": true})) - } - - #[tokio::test] - async fn openapi_includes_problem_schema_and_response() { - let api = ApiGateway::default(); - let router = axum::Router::new(); - - // Build a route with a problem+json response - let _router = OperationBuilder::::get("/tests/v1/problem-demo") - .public() - .summary("Problem demo") - .problem_response(&api, http::StatusCode::BAD_REQUEST, "Bad Request") // <-- registers Problem + sets content type - .handler(dummy_handler) - .register(router, &api); - - let doc = api.build_openapi().expect("openapi"); - let v = serde_json::to_value(&doc).expect("json"); - - // 1) Problem exists in components.schemas - let problem = v - .pointer("/components/schemas/Problem") - .expect("Problem schema missing"); - assert!( - problem.get("$ref").is_none(), - "Problem must be a real object, not a self-ref" - ); - - // 2) Response under /paths/... references Problem and has correct media type - let path_obj = v - .pointer("/paths/~1tests~1v1~1problem-demo/get/responses/400") - .expect("400 response missing"); - - // Check what content types exist - let content_obj = path_obj.get("content").expect("content object missing"); - assert!( - content_obj.get("application/problem+json").is_some(), - "application/problem+json content missing. Available content: {}", - serde_json::to_string_pretty(content_obj).unwrap() - ); - - let content = path_obj - .pointer("/content/application~1problem+json") - .expect("application/problem+json content missing"); - // $ref to Problem - let schema_ref = content - .pointer("/schema/$ref") - .and_then(|r| r.as_str()) - .unwrap_or(""); - assert_eq!(schema_ref, "#/components/schemas/Problem"); - } -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod sse_openapi_tests { - use super::*; - use axum::Json; - use toolkit::api::{Missing, OperationBuilder}; - use serde_json::Value; - - #[derive(Clone)] - #[toolkit_macros::api_dto(request, response)] - struct UserEvent { - id: u32, - message: String, - } - - async fn sse_handler() -> axum::response::sse::Sse< - impl futures_core::Stream>, - > { - let b = toolkit::SseBroadcaster::::new(4); - b.sse_response() - } - - #[tokio::test] - async fn openapi_has_sse_content() { - let api = ApiGateway::default(); - let router = axum::Router::new(); - - let _router = OperationBuilder::::get("/tests/v1/demo/sse") - .summary("Demo SSE") - .handler(sse_handler) - .public() - .sse_json::(&api, "SSE of UserEvent") - .register(router, &api); - - let doc = api.build_openapi().expect("openapi"); - let v = serde_json::to_value(&doc).expect("json"); - - // schema is materialized - let schema = v - .pointer("/components/schemas/UserEvent") - .expect("UserEvent missing"); - assert!(schema.get("$ref").is_none()); - - // content is text/event-stream with $ref to our schema - let refp = v - .pointer("/paths/~1tests~1v1~1demo~1sse/get/responses/200/content/text~1event-stream/schema/$ref") - .and_then(|x| x.as_str()) - .unwrap_or_default(); - assert_eq!(refp, "#/components/schemas/UserEvent"); - } - - #[tokio::test] - async fn openapi_sse_additional_response() { - async fn mixed_handler() -> Json { - Json(serde_json::json!({"ok": true})) - } - - let api = ApiGateway::default(); - let router = axum::Router::new(); - - let _router = OperationBuilder::::get("/tests/v1/demo/mixed") - .summary("Mixed responses") - .public() - .handler(mixed_handler) - .json_response(http::StatusCode::OK, "Success response") - .sse_json::(&api, "Additional SSE stream") - .register(router, &api); - - let doc = api.build_openapi().expect("openapi"); - let v = serde_json::to_value(&doc).expect("json"); - - // Check that both response types are present - let responses = v - .pointer("/paths/~1tests~1v1~1demo~1mixed/get/responses") - .expect("responses"); - - // JSON response exists - assert!(responses.get("200").is_some()); - - // SSE response exists (could be another 200 or different status) - let response_content = responses.get("200").and_then(|r| r.get("content")); - assert!(response_content.is_some()); - - // UserEvent schema is registered - let schema = v - .pointer("/components/schemas/UserEvent") - .expect("UserEvent missing"); - assert!(schema.get("$ref").is_none()); - } - - #[tokio::test] - async fn test_axum_to_openapi_path_conversion() { - // Define a route with path parameters using Axum 0.8+ style {id} - async fn user_handler() -> Json { - Json(serde_json::json!({"user_id": "123"})) - } - - let api = ApiGateway::default(); - let router = axum::Router::new(); - - let _router = OperationBuilder::::get("/tests/v1/users/{id}") - .summary("Get user by ID") - .public() - .path_param("id", "User ID") - .handler(user_handler) - .json_response(http::StatusCode::OK, "User details") - .register(router, &api); - - // Verify the operation was stored with {id} path (same for Axum 0.8 and OpenAPI) - let ops: Vec<_> = api - .openapi_registry - .operation_specs - .iter() - .map(|e| e.value().clone()) - .collect(); - assert_eq!(ops.len(), 1); - assert_eq!(ops[0].path, "/tests/v1/users/{id}"); - - // Verify OpenAPI doc also has {id} (no conversion needed for regular params) - let doc = api.build_openapi().expect("openapi"); - let v = serde_json::to_value(&doc).expect("json"); - - let paths = v.get("paths").expect("paths"); - assert!( - paths.get("/tests/v1/users/{id}").is_some(), - "OpenAPI should use {{id}} placeholder" - ); - } - - #[tokio::test] - async fn test_multiple_path_params_conversion() { - async fn item_handler() -> Json { - Json(serde_json::json!({"ok": true})) - } - - let api = ApiGateway::default(); - let router = axum::Router::new(); - - let _router = OperationBuilder::::get( - "/tests/v1/projects/{project_id}/items/{item_id}", - ) - .summary("Get project item") - .public() - .path_param("project_id", "Project ID") - .path_param("item_id", "Item ID") - .handler(item_handler) - .json_response(http::StatusCode::OK, "Item details") - .register(router, &api); - - // Verify storage and OpenAPI both use {param} syntax - let ops: Vec<_> = api - .openapi_registry - .operation_specs - .iter() - .map(|e| e.value().clone()) - .collect(); - assert_eq!( - ops[0].path, - "/tests/v1/projects/{project_id}/items/{item_id}" - ); - - let doc = api.build_openapi().expect("openapi"); - let v = serde_json::to_value(&doc).expect("json"); - let paths = v.get("paths").expect("paths"); - assert!( - paths - .get("/tests/v1/projects/{project_id}/items/{item_id}") - .is_some() - ); - } - - #[tokio::test] - async fn test_wildcard_path_conversion() { - async fn static_handler() -> Json { - Json(serde_json::json!({"ok": true})) - } - - let api = ApiGateway::default(); - let router = axum::Router::new(); - - // Axum 0.8 uses {*path} for wildcards - let _router = OperationBuilder::::get("/tests/v1/static/{*path}") - .summary("Serve static files") - .public() - .handler(static_handler) - .json_response(http::StatusCode::OK, "File content") - .register(router, &api); - - // Verify internal storage keeps Axum wildcard syntax {*path} - let ops: Vec<_> = api - .openapi_registry - .operation_specs - .iter() - .map(|e| e.value().clone()) - .collect(); - assert_eq!(ops[0].path, "/tests/v1/static/{*path}"); - - // Verify OpenAPI converts wildcard to {path} (without asterisk) - let doc = api.build_openapi().expect("openapi"); - let v = serde_json::to_value(&doc).expect("json"); - let paths = v.get("paths").expect("paths"); - assert!( - paths.get("/tests/v1/static/{path}").is_some(), - "Wildcard {{*path}} should be converted to {{path}} in OpenAPI" - ); - assert!( - paths.get("/static/{*path}").is_none(), - "OpenAPI should not have Axum-style {{*path}}" - ); - } - - #[tokio::test] - async fn test_multipart_file_upload_openapi() { - async fn upload_handler() -> Json { - Json(serde_json::json!({"uploaded": true})) - } - - let api = ApiGateway::default(); - let router = axum::Router::new(); - - let _router = OperationBuilder::::post("/tests/v1/files/upload") - .operation_id("upload_file") - .public() - .summary("Upload a file") - .multipart_file_request("file", Some("File to upload")) - .handler(upload_handler) - .json_response(http::StatusCode::OK, "Upload successful") - .register(router, &api); - - // Build OpenAPI and verify multipart schema - let doc = api.build_openapi().expect("openapi"); - let v = serde_json::to_value(&doc).expect("json"); - - let paths = v.get("paths").expect("paths"); - let upload_path = paths - .get("/tests/v1/files/upload") - .expect("/tests/v1/files/upload path"); - let post_op = upload_path.get("post").expect("POST operation"); - - // Verify request body exists - let request_body = post_op.get("requestBody").expect("requestBody"); - let content = request_body.get("content").expect("content"); - let multipart = content - .get("multipart/form-data") - .expect("multipart/form-data content type"); - - // Verify schema structure - let schema = multipart.get("schema").expect("schema"); - assert_eq!( - schema.get("type").and_then(|v| v.as_str()), - Some("object"), - "Schema should be of type object" - ); - - // Verify properties - let properties = schema.get("properties").expect("properties"); - let file_prop = properties.get("file").expect("file property"); - assert_eq!( - file_prop.get("type").and_then(|v| v.as_str()), - Some("string"), - "File field should be of type string" - ); - assert_eq!( - file_prop.get("format").and_then(|v| v.as_str()), - Some("binary"), - "File field should have format binary" - ); - - // Verify required fields - let required = schema.get("required").expect("required"); - let required_arr = required.as_array().expect("required should be array"); - assert_eq!(required_arr.len(), 1); - assert_eq!(required_arr[0].as_str(), Some("file")); - } -} diff --git a/gears/system/api-gateway/src/lib.rs b/gears/system/api-gateway/src/lib.rs index 0ae1df430..f50c97071 100644 --- a/gears/system/api-gateway/src/lib.rs +++ b/gears/system/api-gateway/src/lib.rs @@ -10,8 +10,10 @@ pub use gear::ApiGateway; // === INTERNAL MODULES === mod assets; +mod authn_adapter; mod config; mod cors; +pub mod errors; pub mod middleware; mod router_cache; mod web; diff --git a/gears/system/api-gateway/src/middleware.rs b/gears/system/api-gateway/src/middleware.rs new file mode 100644 index 000000000..d5f1b85aa --- /dev/null +++ b/gears/system/api-gateway/src/middleware.rs @@ -0,0 +1,197 @@ +//! The gateway's middleware layer. +//! +//! The middleware themselves live in the shared `toolkit_http_middleware` crate +//! and operate on neutral lookup tables/rules. This module owns the projection +//! that turns the gateway's own inputs — typed [`OperationSpec`]s and the parsed +//! `crate::config` — into those neutral inputs (the per-route validation maps, +//! rate limiters, scope-enforcement rules, and the route auth policy). It is +//! deliberately the only place that couples the gateway's specs/config/error +//! identity to the shared middleware. +use std::collections::HashSet; + +use anyhow::Result; +use http::Method; +use toolkit::api::OperationSpec; +use toolkit_http_middleware::MatchitRouteAuthPolicy; +use toolkit_http_middleware::license_validation::LicenseRequirementMap; +use toolkit_http_middleware::mime_validation::MimeValidationMap; +use toolkit_http_middleware::rate_limit::{RateLimitConfig, RateLimiterMap}; +use toolkit_http_middleware::scope_enforcement::ScopeEnforcementRules; + +use crate::config::{ApiGatewayConfig, RoutePoliciesConfig}; +use crate::errors; + +// Re-exported so integration tests (separate crates) can drive the middleware +// through the gateway facade without depending on `toolkit-http-middleware`. +pub use toolkit_http_middleware::mime_validation::mime_validation_middleware; + +/// Build the MIME validation map from operation specs. +#[must_use] +pub fn build_mime_validation_map(specs: &[OperationSpec]) -> MimeValidationMap { + MimeValidationMap::from_pairs( + errors::GATEWAY_SCOPE, + specs.iter().filter_map(|spec| { + spec.allowed_request_content_types + .as_ref() + .map(|allowed| ((spec.method.clone(), spec.path.clone()), allowed.clone())) + }), + ) +} + +/// Build the per-route required-feature map from operation specs. +#[must_use] +pub fn build_license_requirement_map(specs: &[OperationSpec]) -> LicenseRequirementMap { + LicenseRequirementMap::from_pairs( + errors::ROUTE_SCOPE, + specs.iter().filter_map(|spec| { + spec.license_requirement.as_ref().map(|req| { + ( + (spec.method.clone(), spec.path.clone()), + req.license_names.clone(), + ) + }) + }), + ) +} + +/// Build the per-route rate limiter map from operation specs and gateway +/// configuration (falling back to configured defaults where a route declares no +/// explicit rate limit). +/// +/// # Errors +/// Returns an error if any effective `rps` or `burst` is 0. +// TODO: Add support for per-route rate limiting keys. +pub fn build_rate_limiter_map( + specs: &[OperationSpec], + cfg: &ApiGatewayConfig, +) -> Result { + RateLimiterMap::from_pairs( + errors::GATEWAY_SCOPE, + specs.iter().map(|spec| { + let limit = spec.rate_limit.as_ref().map_or_else( + || cfg.defaults.rate_limit.clone(), + |r| RateLimitConfig { + rps: r.rps, + burst: r.burst, + in_flight: r.in_flight, + }, + ); + ((spec.method.clone(), spec.path.clone()), limit) + }), + ) +} + +/// Build compiled scope enforcement rules from the gateway's route-policy +/// configuration. +/// +/// # Errors +/// Returns an error if any glob pattern is invalid or if any rule has empty +/// `required_scopes`. +pub fn build_scope_enforcement_rules( + config: &RoutePoliciesConfig, +) -> Result { + ScopeEnforcementRules::from_config(errors::ROUTE_SCOPE, config) +} + +/// Build the gateway's route auth policy from operation specs. +/// +/// Collects authenticated/public route sets from the specs (plus the built-in +/// public health/docs routes) and projects them into the shared +/// [`MatchitRouteAuthPolicy`] consumed by +/// `toolkit_http_middleware::security_context_middleware`. +/// +/// # Errors +/// Returns an error if a route pattern cannot be inserted into the matcher. +pub fn build_route_policy_from_specs( + specs: &[OperationSpec], + config: &ApiGatewayConfig, +) -> Result { + let mut authenticated_routes = HashSet::new(); + let mut public_routes = HashSet::new(); + + // Always mark built-in health check routes as public + public_routes.insert((Method::GET, "/health".to_owned())); + public_routes.insert((Method::GET, "/healthz".to_owned())); + + public_routes.insert((Method::GET, "/docs".to_owned())); + public_routes.insert((Method::GET, "/openapi.json".to_owned())); + + for spec in specs { + let route_key = (spec.method.clone(), spec.path.clone()); + + if spec.authenticated { + authenticated_routes.insert(route_key.clone()); + } + + if spec.is_public { + public_routes.insert(route_key); + } + } + + let requirements_count = authenticated_routes.len(); + let public_routes_count = public_routes.len(); + + let route_policy = MatchitRouteAuthPolicy::from_route_sets( + authenticated_routes, + public_routes, + config.require_auth_by_default, + )?; + + tracing::info!( + auth_disabled = config.auth_disabled, + require_auth_by_default = config.require_auth_by_default, + requirements_count = requirements_count, + public_routes_count = public_routes_count, + "Route policy built from operation specs" + ); + + Ok(route_policy) +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + use http::Method; + use toolkit::api::operation_builder::VendorExtensions; + + #[test] + fn test_build_mime_validation_map() { + use toolkit::api::operation_builder::{RequestBodySchema, RequestBodySpec}; + + let specs = vec![OperationSpec { + method: Method::POST, + path: "/files/v1/upload".to_owned(), + operation_id: None, + summary: None, + description: None, + tags: vec![], + params: vec![], + request_body: Some(RequestBodySpec { + content_type: "multipart/form-data", + description: None, + schema: RequestBodySchema::MultipartFile { + field_name: "file".to_owned(), + }, + required: true, + }), + responses: vec![], + handler_id: "test".to_owned(), + authenticated: false, + is_public: false, + license_requirement: None, + rate_limit: None, + allowed_request_content_types: Some(vec!["multipart/form-data", "application/pdf"]), + vendor_extensions: VendorExtensions::default(), + }]; + + let map = build_mime_validation_map(&specs); + + let allowed = map + .get(&Method::POST, "/files/v1/upload") + .expect("route should be present"); + assert_eq!(allowed.len(), 2); + assert!(allowed.contains(&"multipart/form-data")); + assert!(allowed.contains(&"application/pdf")); + } +} diff --git a/gears/system/api-gateway/src/middleware/auth.rs b/gears/system/api-gateway/src/middleware/auth.rs deleted file mode 100644 index 45061d695..000000000 --- a/gears/system/api-gateway/src/middleware/auth.rs +++ /dev/null @@ -1,512 +0,0 @@ -use axum::http::Method; -use axum::response::IntoResponse; -use std::{collections::HashMap, sync::Arc}; - -use crate::middleware::common; - -use authn_resolver_sdk::{AuthNResolverClient, AuthNResolverError}; -use toolkit_canonical_errors::CanonicalError; -use toolkit_security::SecurityContext; - -/// Route matcher for a specific HTTP method (authenticated routes). -#[derive(Clone)] -pub struct RouteMatcher { - matcher: matchit::Router<()>, -} - -impl RouteMatcher { - fn new() -> Self { - Self { - matcher: matchit::Router::new(), - } - } - - fn insert(&mut self, path: &str) -> Result<(), matchit::InsertError> { - self.matcher.insert(path, ()) - } - - fn find(&self, path: &str) -> bool { - self.matcher.at(path).is_ok() - } -} - -/// Public route matcher for explicitly public routes -#[derive(Clone)] -pub struct PublicRouteMatcher { - matcher: matchit::Router<()>, -} - -impl PublicRouteMatcher { - fn new() -> Self { - Self { - matcher: matchit::Router::new(), - } - } - - fn insert(&mut self, path: &str) -> Result<(), matchit::InsertError> { - self.matcher.insert(path, ()) - } - - fn find(&self, path: &str) -> bool { - self.matcher.at(path).is_ok() - } -} - -/// Whether a route requires authentication. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum AuthRequirement { - /// No authentication required (public route). - None, - /// Authentication required. - Required, -} - -/// Gateway-specific route policy implementation -#[derive(Clone)] -pub struct GatewayRoutePolicy { - route_matchers: Arc>, - public_matchers: Arc>, - require_auth_by_default: bool, -} - -impl GatewayRoutePolicy { - #[must_use] - pub fn new( - route_matchers: Arc>, - public_matchers: Arc>, - require_auth_by_default: bool, - ) -> Self { - Self { - route_matchers, - public_matchers, - require_auth_by_default, - } - } - - /// Resolve the authentication requirement for a given (method, path). - #[must_use] - pub fn resolve(&self, method: &Method, path: &str) -> AuthRequirement { - // Check if route is explicitly authenticated - let is_authenticated = self - .route_matchers - .get(method) - .is_some_and(|matcher| matcher.find(path)); - - // Check if route is explicitly public using pattern matching - let is_public = self - .public_matchers - .get(method) - .is_some_and(|matcher| matcher.find(path)); - - // Public routes should not be forced to auth by default - if is_public { - return AuthRequirement::None; - } - - if is_authenticated { - return AuthRequirement::Required; - } - - if self.require_auth_by_default { - AuthRequirement::Required - } else { - AuthRequirement::None - } - } -} - -/// Shared state for the authentication middleware. -#[derive(Clone)] -pub struct AuthState { - pub authn_client: Arc, - pub route_policy: GatewayRoutePolicy, -} - -/// Helper to build `GatewayRoutePolicy` from operation requirements. -/// -/// # Errors -/// -/// Returns an error if a route pattern cannot be inserted into the matcher. -#[allow(clippy::implicit_hasher)] -pub fn build_route_policy( - cfg: &crate::config::ApiGatewayConfig, - authenticated_routes: std::collections::HashSet<(Method, String)>, - public_routes: std::collections::HashSet<(Method, String)>, -) -> Result { - // Build route matchers per HTTP method (authenticated routes) - let mut route_matchers_map: HashMap = HashMap::new(); - - for (method, path) in authenticated_routes { - let matcher = route_matchers_map - .entry(method) - .or_insert_with(RouteMatcher::new); - matcher - .insert(&path) - .map_err(|e| anyhow::anyhow!("Failed to insert route pattern '{path}': {e}"))?; - } - - // Build public matchers per HTTP method - let mut public_matchers_map: HashMap = HashMap::new(); - - for (method, path) in public_routes { - let matcher = public_matchers_map - .entry(method) - .or_insert_with(PublicRouteMatcher::new); - matcher - .insert(&path) - .map_err(|e| anyhow::anyhow!("Failed to insert public route pattern '{path}': {e}"))?; - } - - Ok(GatewayRoutePolicy::new( - Arc::new(route_matchers_map), - Arc::new(public_matchers_map), - cfg.require_auth_by_default, - )) -} - -/// Authentication middleware that uses the `AuthN` Resolver to validate bearer tokens. -/// -/// For each request: -/// 1. Skips CORS preflight requests -/// 2. Resolves the route's auth requirement via `GatewayRoutePolicy` -/// 3. For public routes: inserts anonymous `SecurityContext` -/// 4. For required routes: extracts bearer token, calls `AuthN` Resolver, inserts `SecurityContext` -pub async fn authn_middleware( - axum::extract::State(state): axum::extract::State, - mut req: axum::extract::Request, - next: axum::middleware::Next, -) -> axum::response::Response { - // Skip CORS preflight — insert anonymous SecurityContext so downstream - // handlers that extract Extension don't panic. - if is_preflight_request(req.method(), req.headers()) { - req.extensions_mut().insert(SecurityContext::anonymous()); - return next.run(req).await; - } - - let path = req - .extensions() - .get::() - .map_or_else(|| req.uri().path().to_owned(), |p| p.as_str().to_owned()); - - let path = common::resolve_path(&req, path.as_str()); - - let requirement = state.route_policy.resolve(req.method(), path.as_str()); - - match requirement { - AuthRequirement::None => { - log_auth_skipped(req.method(), path.as_str()); - req.extensions_mut().insert(SecurityContext::anonymous()); - next.run(req).await - } - AuthRequirement::Required => { - let Some(token) = extract_bearer_token(req.headers()) else { - log_missing_bearer(req.method(), path.as_str()); - // `instance` / `trace_id` are filled by the canonical - // error middleware (`toolkit::api::canonical_error_middleware`) - // on the way out — this middleware sits inside its layer. - let mut response = CanonicalError::unauthenticated() - .with_reason("MISSING_BEARER") - .create() - .into_response(); - // No bearer credentials were presented (RFC 6750 §3). - common::append_bearer_challenge( - &mut response, - common::BearerChallenge::NoCredentials, - ); - return response; - }; - - match state.authn_client.authenticate(token).await { - Ok(result) => { - log_auth_succeeded(req.method(), path.as_str(), &result.security_context); - req.extensions_mut().insert(result.security_context); - next.run(req).await - } - Err(err) => authn_error_to_response(&err), - } - } - } -} - -fn log_auth_skipped(method: &Method, path: &str) { - tracing::debug!(method = %method, path, "authentication skipped: public route"); -} - -fn log_missing_bearer(method: &Method, path: &str) { - tracing::debug!(method = %method, path, "authentication failed: missing bearer token"); -} - -fn log_auth_succeeded(method: &Method, path: &str, security_context: &SecurityContext) { - tracing::debug!( - method = %method, - path, - subject_id = %security_context.subject_id(), - "authentication succeeded" - ); -} - -/// Convert `AuthNResolverError` to a canonical Problem Details response. -/// -/// `instance` / `trace_id` are filled by the canonical error middleware -/// (`toolkit::api::canonical_error_middleware`) on the way out — this -/// middleware sits inside its layer. -fn authn_error_to_response(err: &AuthNResolverError) -> axum::response::Response { - log_authn_error(err); - match err { - AuthNResolverError::Unauthorized(_) => { - // A token was presented but rejected (RFC 6750 §3). - let mut response = CanonicalError::unauthenticated() - .with_reason("AUTHN_FAILED") - .create() - .into_response(); - common::append_bearer_challenge(&mut response, common::BearerChallenge::InvalidToken); - response - } - AuthNResolverError::NoPluginAvailable | AuthNResolverError::ServiceUnavailable(_) => { - CanonicalError::service_unavailable() - .with_retry_after_seconds(5) - .create() - .into_response() - } - AuthNResolverError::TokenAcquisitionFailed(_) | AuthNResolverError::Internal(_) => { - CanonicalError::internal("authentication infrastructure failure") - .create() - .into_response() - } - } -} - -/// Log authentication errors at appropriate levels. -/// -/// Cognitive complexity is inflated by tracing macro expansion. -#[allow(clippy::cognitive_complexity)] -fn log_authn_error(err: &AuthNResolverError) { - match err { - AuthNResolverError::Unauthorized(msg) => tracing::debug!("AuthN rejected: {msg}"), - AuthNResolverError::NoPluginAvailable => tracing::error!("No AuthN plugin available"), - AuthNResolverError::ServiceUnavailable(msg) => { - tracing::error!("AuthN service unavailable: {msg}"); - } - AuthNResolverError::TokenAcquisitionFailed(msg) => { - tracing::error!("AuthN token acquisition failed: {msg}"); - } - AuthNResolverError::Internal(msg) => tracing::error!("AuthN internal error: {msg}"), - } -} - -/// Extract Bearer token from Authorization header -fn extract_bearer_token(headers: &axum::http::HeaderMap) -> Option<&str> { - headers - .get(axum::http::header::AUTHORIZATION) - .and_then(|v| v.to_str().ok()) - .and_then(|s| s.strip_prefix("Bearer ").map(str::trim)) -} - -/// Check if this is a CORS preflight request -/// -/// Preflight requests are OPTIONS requests with: -/// - Origin header present -/// - Access-Control-Request-Method header present -fn is_preflight_request(method: &Method, headers: &axum::http::HeaderMap) -> bool { - method == Method::OPTIONS - && headers.contains_key(axum::http::header::ORIGIN) - && headers.contains_key(axum::http::header::ACCESS_CONTROL_REQUEST_METHOD) -} - -#[cfg(test)] -#[cfg_attr(coverage_nightly, coverage(off))] -mod tests { - use super::*; - use axum::http::Method; - - /// Helper to build `GatewayRoutePolicy` with given matchers - fn build_test_policy( - route_matchers: HashMap, - public_matchers: HashMap, - require_auth_by_default: bool, - ) -> GatewayRoutePolicy { - GatewayRoutePolicy::new( - Arc::new(route_matchers), - Arc::new(public_matchers), - require_auth_by_default, - ) - } - - #[test] - fn test_matchit_router_with_params() { - let mut router = matchit::Router::new(); - router.insert("/users/{id}", "user_route").unwrap(); - - let result = router.at("/users/42"); - assert!( - result.is_ok(), - "matchit should match /users/{{id}} against /users/42" - ); - assert_eq!(*result.unwrap().value, "user_route"); - } - - #[test] - fn build_route_policy_allows_colon_in_literal_paths() { - let cfg = crate::config::ApiGatewayConfig::default(); - let authenticated_routes = std::collections::HashSet::from([ - (Method::GET, "events:poll".to_owned()), - (Method::GET, "events:stream".to_owned()), - ]); - - if let Err(err) = - build_route_policy(&cfg, authenticated_routes, std::collections::HashSet::new()) - { - panic!("literal colon route paths must not be interpreted as path parameters: {err}"); - } - } - - #[test] - fn explicit_public_route_with_path_params_returns_none() { - let mut public_matchers = HashMap::new(); - let mut matcher = PublicRouteMatcher::new(); - matcher.insert("/users/{id}").unwrap(); - - public_matchers.insert(Method::GET, matcher); - - let policy = build_test_policy(HashMap::new(), public_matchers, true); - - // Path parameters should match concrete values - let result = policy.resolve(&Method::GET, "/users/42"); - assert_eq!(result, AuthRequirement::None); - } - - #[test] - fn explicit_public_route_exact_match_returns_none() { - let mut public_matchers = HashMap::new(); - let mut matcher = PublicRouteMatcher::new(); - matcher.insert("/health").unwrap(); - public_matchers.insert(Method::GET, matcher); - - let policy = build_test_policy(HashMap::new(), public_matchers, true); - - let result = policy.resolve(&Method::GET, "/health"); - assert_eq!(result, AuthRequirement::None); - } - - #[test] - fn explicit_authenticated_route_returns_required() { - let mut route_matchers = HashMap::new(); - let mut matcher = RouteMatcher::new(); - matcher.insert("/admin/metrics").unwrap(); - route_matchers.insert(Method::GET, matcher); - - let policy = build_test_policy(route_matchers, HashMap::new(), false); - - let result = policy.resolve(&Method::GET, "/admin/metrics"); - assert_eq!(result, AuthRequirement::Required); - } - - #[test] - fn route_without_requirement_with_require_auth_by_default_returns_required() { - let policy = build_test_policy(HashMap::new(), HashMap::new(), true); - - let result = policy.resolve(&Method::GET, "/profile"); - assert_eq!(result, AuthRequirement::Required); - } - - #[test] - fn route_without_requirement_without_require_auth_by_default_returns_none() { - let policy = build_test_policy(HashMap::new(), HashMap::new(), false); - - let result = policy.resolve(&Method::GET, "/profile"); - assert_eq!(result, AuthRequirement::None); - } - - #[test] - fn unknown_route_with_require_auth_by_default_true_returns_required() { - let policy = build_test_policy(HashMap::new(), HashMap::new(), true); - - let result = policy.resolve(&Method::POST, "/unknown"); - assert_eq!(result, AuthRequirement::Required); - } - - #[test] - fn unknown_route_with_require_auth_by_default_false_returns_none() { - let policy = build_test_policy(HashMap::new(), HashMap::new(), false); - - let result = policy.resolve(&Method::POST, "/unknown"); - assert_eq!(result, AuthRequirement::None); - } - - #[test] - fn public_route_overrides_require_auth_by_default() { - let mut public_matchers = HashMap::new(); - let mut matcher = PublicRouteMatcher::new(); - matcher.insert("/public").unwrap(); - public_matchers.insert(Method::GET, matcher); - - let policy = build_test_policy(HashMap::new(), public_matchers, true); - - let result = policy.resolve(&Method::GET, "/public"); - assert_eq!(result, AuthRequirement::None); - } - - #[test] - fn authenticated_route_has_priority_over_default() { - let mut route_matchers = HashMap::new(); - let mut matcher = RouteMatcher::new(); - matcher.insert("/users/{id}").unwrap(); - route_matchers.insert(Method::GET, matcher); - - let policy = build_test_policy(route_matchers, HashMap::new(), false); - - let result = policy.resolve(&Method::GET, "/users/123"); - assert_eq!(result, AuthRequirement::Required); - } - - #[test] - fn explicit_public_overrides_wildcard_authenticated_fallback() { - // When a gateway registers a wildcard authenticated 404 the fallback - // like `/{*rest}` (used to convert anonymous 404s to 401s), - // grabs the public routes too, causing 401 on them - let mut public_matchers = HashMap::new(); - let mut public_matcher = PublicRouteMatcher::new(); - public_matcher.insert("/v1/auth/config").unwrap(); - public_matchers.insert(Method::GET, public_matcher); - - let mut route_matchers = HashMap::new(); - let mut auth_matcher = RouteMatcher::new(); - auth_matcher.insert("/{*rest}").unwrap(); - route_matchers.insert(Method::GET, auth_matcher); - - let policy = build_test_policy(route_matchers, public_matchers, true); - - assert_eq!( - policy.resolve(&Method::GET, "/v1/auth/config"), - AuthRequirement::None, - "explicit public must win over wildcard authenticated fallback" - ); - // Sanity: a path that only matches the wildcard fallback still requires auth. - assert_eq!( - policy.resolve(&Method::GET, "/some/other/path"), - AuthRequirement::Required, - "wildcard authenticated still applies to non-public paths" - ); - } - - #[test] - fn different_methods_resolve_independently() { - let mut route_matchers = HashMap::new(); - - // GET /users is authenticated - let mut get_matcher = RouteMatcher::new(); - get_matcher.insert("/user-management/v1/users").unwrap(); - route_matchers.insert(Method::GET, get_matcher); - - // POST /users is not in matchers - let policy = build_test_policy(route_matchers, HashMap::new(), false); - - // GET should be authenticated - let get_result = policy.resolve(&Method::GET, "/user-management/v1/users"); - assert_eq!(get_result, AuthRequirement::Required); - - // POST should be public (no requirement, require_auth_by_default=false) - let post_result = policy.resolve(&Method::POST, "/user-management/v1/users"); - assert_eq!(post_result, AuthRequirement::None); - } -} diff --git a/gears/system/api-gateway/src/middleware/errors.rs b/gears/system/api-gateway/src/middleware/errors.rs deleted file mode 100644 index ac48055a5..000000000 --- a/gears/system/api-gateway/src/middleware/errors.rs +++ /dev/null @@ -1,16 +0,0 @@ -//! Canonical resource scopes for api-gateway middleware. -use toolkit_canonical_errors::resource_error; - -/// Errors attributable to a registered API gateway route -/// (scope / license / RBAC). -#[resource_error("gts.cf.core.api_gateway.route.v1~")] -pub struct ApiGatewayRouteError; - -/// Umbrella scope for request-pipeline errors that don't target a -/// specific route resource (MIME validation, rate limit, request -/// timeout). Required because `invalid_argument`, `resource_exhausted`, -/// and `deadline_exceeded` are only available on `#[resource_error]` -/// scopes — there are no top-level `CanonicalError::*` constructors for -/// those categories. -#[resource_error("gts.cf.core.api_gateway.gateway.v1~")] -pub struct ApiGatewayGatewayError; diff --git a/gears/system/api-gateway/src/middleware/mod.rs b/gears/system/api-gateway/src/middleware/mod.rs deleted file mode 100644 index 7e0fbfd14..000000000 --- a/gears/system/api-gateway/src/middleware/mod.rs +++ /dev/null @@ -1,10 +0,0 @@ -pub mod access_log; -pub mod auth; -pub mod common; -pub mod errors; -pub mod http_metrics; -pub mod license_validation; -pub mod mime_validation; -pub mod rate_limit; -pub mod request_id; -pub mod scope_enforcement; diff --git a/gears/system/api-gateway/tests/access_log_tests.rs b/gears/system/api-gateway/tests/access_log_tests.rs index 38fcf852f..0bdee435c 100644 --- a/gears/system/api-gateway/tests/access_log_tests.rs +++ b/gears/system/api-gateway/tests/access_log_tests.rs @@ -22,7 +22,7 @@ use tower_http::request_id::{PropagateRequestIdLayer, SetRequestIdLayer}; use tracing_subscriber::layer::SubscriberExt; use uuid::Uuid; -use api_gateway::middleware::request_id::{MakeReqId, header}; +use toolkit_http_middleware::request_id::{MakeReqId, header}; /// Captured access log event fields. #[derive(Debug, Default, Clone)] @@ -80,10 +80,10 @@ fn test_app() -> Router { .route("/test", get(handler_ok)) .route("/error", get(handler_err)) .layer(from_fn( - api_gateway::middleware::access_log::access_log_middleware, + toolkit_http_middleware::access_log::access_log_middleware, )) .layer(from_fn( - api_gateway::middleware::request_id::push_req_id_to_extensions, + toolkit_http_middleware::request_id::push_req_id_to_extensions, )) .layer(PropagateRequestIdLayer::new(x_request_id.clone())) .layer(SetRequestIdLayer::new(x_request_id, MakeReqId)) diff --git a/gears/system/api-gateway/tests/auth_middleware.rs.orig b/gears/system/api-gateway/tests/auth_middleware.rs.orig deleted file mode 100644 index 19ae84c61..000000000 --- a/gears/system/api-gateway/tests/auth_middleware.rs.orig +++ /dev/null @@ -1,1116 +0,0 @@ -#![allow(clippy::unwrap_used, clippy::expect_used)] - -//! Integration tests for auth middleware -//! -//! These tests verify that: -//! 1. Auth middleware is properly attached to the router -//! 2. `SecurityContext` is always inserted by middleware -//! 3. Public routes work without authentication -//! 4. Protected routes enforce authentication when enabled - -use anyhow::Result; -use async_trait::async_trait; -use authn_resolver_sdk::{ - AuthNResolverClient, AuthNResolverError, AuthenticationResult, ClientCredentialsRequest, -}; -use axum::{ - Extension, Json, Router, - body::Body, - http::{Method, Request, StatusCode, header}, - response::Response, -}; -use toolkit::{ - ClientHub, Module, - api::{OperationBuilder, operation_builder::LicenseFeature}, - config::ConfigProvider, - context::GearCtx, - contracts::{ApiGatewayCapability, OpenApiRegistry, RestApiCapability}, -}; -use toolkit_canonical_errors::Problem; -use toolkit_security::SecurityContext; -use serde_json::json; -use std::sync::Arc; -use tower::ServiceExt; -use uuid::Uuid; - -const UNAUTHENTICATED_TYPE: &str = - "gts://gts.cf.core.errors.err.v1~cf.core.err.unauthenticated.v1~"; -const SERVICE_UNAVAILABLE_TYPE: &str = - "gts://gts.cf.core.errors.err.v1~cf.core.err.service_unavailable.v1~"; -const INTERNAL_TYPE: &str = "gts://gts.cf.core.errors.err.v1~cf.core.err.internal.v1~"; -const PROBLEM_JSON: &str = "application/problem+json"; - -async fn problem_from(response: Response) -> Problem { - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("read body"); - serde_json::from_slice(&body).expect("parse Problem JSON") -} - -fn content_type(response: &Response) -> &str { - response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or("") -} - -/// Test configuration provider -struct TestConfigProvider { - config: serde_json::Value, -} - -impl ConfigProvider for TestConfigProvider { - fn get_gear_config(&self, gear: &str) -> Option<&serde_json::Value> { - self.config.get(gear) - } -} - -/// Create test context for `api_gateway` gear -fn create_api_gateway_ctx(config: serde_json::Value) -> GearCtx { - let hub = Arc::new(ClientHub::new()); - - GearCtx::new( - "api-gateway", - Uuid::new_v4(), - Arc::new(TestConfigProvider { config }), - hub, - tokio_util::sync::CancellationToken::new(), - ) -} - -/// Create test context for other test gears -fn create_test_gear_ctx() -> GearCtx { - GearCtx::new( - "test_gear", - Uuid::new_v4(), - Arc::new(TestConfigProvider { config: json!({}) }), - Arc::new(ClientHub::new()), - tokio_util::sync::CancellationToken::new(), - ) -} - -/// Test response type -#[derive(Clone)] -#[toolkit_macros::api_dto(response)] -struct TestResponse { - message: String, - user_id: String, -} - -/// Handler that requires `SecurityContext` (via Extension extractor) -async fn protected_handler(Extension(ctx): Extension) -> Json { - Json(TestResponse { - message: "Protected resource accessed".to_owned(), - user_id: ctx.subject_id().to_string(), - }) -} - -/// Handler that doesn't require auth -async fn public_handler() -> Json { - Json(TestResponse { - message: "Public resource accessed".to_owned(), - user_id: "anonymous".to_owned(), - }) -} - -/// Test gear with protected and public routes -pub struct TestAuthGear; - -#[async_trait] -impl Gear for TestAuthGear { - async fn init(&self, _ctx: &GearCtx) -> Result<()> { - Ok(()) - } -} - -struct License; - -impl AsRef for License { - fn as_ref(&self) -> &'static str { - "gts.cf.core.lic.feat.v1~cf.core.global.base.v1" - } -} - -impl LicenseFeature for License {} - -impl RestApiCapability for TestAuthGear { - fn register_rest( - &self, - _ctx: &GearCtx, - router: Router, - openapi: &dyn OpenApiRegistry, - ) -> Result { - // Protected route with explicit auth requirement - let router = OperationBuilder::get("/tests/v1/api/protected") - .operation_id("test.protected") - .authenticated() - .require_license_features::([]) - .summary("Protected endpoint") - .handler(protected_handler) - .json_response_with_schema::(openapi, http::StatusCode::OK, "Success") - .error_401(openapi) - .error_403(openapi) - .register(router, openapi); - - // Protected route with path parameter (to test pattern matching) - let router = OperationBuilder::get("/tests/v1/api/users/{id}") - .operation_id("test.get_user") - .authenticated() - .require_license_features::([]) - .summary("Get user by ID") - .path_param("id", "User ID") - .handler(protected_handler) - .json_response_with_schema::(openapi, http::StatusCode::OK, "Success") - .error_401(openapi) - .error_403(openapi) - .register(router, openapi); - - // Public route with explicit public marking - let router = OperationBuilder::get("/tests/v1/api/public") - .operation_id("test.public") - .public() - .summary("Public endpoint") - .handler(public_handler) - .json_response_with_schema::(openapi, http::StatusCode::OK, "Success") - .register(router, openapi); - - Ok(router) - } -} - -#[tokio::test] -async fn test_auth_disabled_mode() { - // Create api-gateway with auth disabled - let config = json!({ - "api-gateway": { - "config": { - "bind_addr": "0.0.0.0:8080", - "enable_docs": true, - "cors_enabled": false, - "auth_disabled": true, - } - } - }); - - let api_ctx = create_api_gateway_ctx(config); - let test_ctx = create_test_gear_ctx(); - - let api_gateway = api_gateway::ApiGateway::default(); - api_gateway.init(&api_ctx).await.expect("Failed to init"); - - // Register test gear - let router = Router::new(); - let test_gear = TestAuthGear; - let router = test_gear - .register_rest(&test_ctx, router, &api_gateway) - .expect("Failed to register routes"); - - // Finalize router (applies middleware) - let router = api_gateway - .rest_finalize(&api_ctx, router) - .expect("Failed to finalize"); - - // Test protected route WITHOUT token (should work because auth is disabled) - let response = router - .clone() - .oneshot( - Request::builder() - .uri("/tests/v1/api/protected") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::OK, - "Protected route should work when auth is disabled" - ); - - // Test public route - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/api/public") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::OK, - "Public route should work" - ); -} - -#[tokio::test] -async fn test_public_routes_accessible() { - // Create api-gateway with auth enabled but test public routes - let config = json!({ - "api-gateway": { - "config": { - "bind_addr": "0.0.0.0:8080", - "enable_docs": true, - "cors_enabled": false, - "auth_disabled": true, // Using disabled for simplicity in test - } - } - }); - - let api_ctx = create_api_gateway_ctx(config); - let test_ctx = create_test_gear_ctx(); - - let api_gateway = api_gateway::ApiGateway::default(); - api_gateway.init(&api_ctx).await.expect("Failed to init"); - - // First call rest_prepare to add built-in routes - let router = Router::new(); - let router = api_gateway - .rest_prepare(&api_ctx, router) - .expect("Failed to prepare"); - - // Then register test gear routes - let test_gear = TestAuthGear; - let router = test_gear - .register_rest(&test_ctx, router, &api_gateway) - .expect("Failed to register routes"); - - // Finally finalize - let router = api_gateway - .rest_finalize(&api_ctx, router) - .expect("Failed to finalize"); - - // Test built-in health endpoints - let response = router - .clone() - .oneshot( - Request::builder() - .uri("/healthz") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::OK, - "Health endpoint should be accessible" - ); - - // Test OpenAPI endpoint - let response = router - .oneshot( - Request::builder() - .uri("/openapi.json") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::OK, - "OpenAPI endpoint should be accessible" - ); -} - -#[tokio::test] -async fn test_public_routes_with_prefix_accessible() { - // Create api-gateway with auth disabled and test prefixed public routes - let config = json!({ - "api-gateway": { - "config": { - "bind_addr": "0.0.0.0:8080", - "enable_docs": true, - "cors_enabled": false, - "auth_disabled": true, // Using disabled for simplicity in test - "prefix_path": "/cf", - } - } - }); - - let api_ctx = create_api_gateway_ctx(config); - let test_ctx = create_test_gear_ctx(); - - let api_gateway = api_gateway::ApiGateway::default(); - api_gateway.init(&api_ctx).await.expect("Failed to init"); - - // First call rest_prepare to add built-in routes - let router = Router::new(); - let router = api_gateway - .rest_prepare(&api_ctx, router) - .expect("Failed to prepare"); - - // Then register test gear routes - let test_gear = TestAuthGear; - let router = test_gear - .register_rest(&test_ctx, router, &api_gateway) - .expect("Failed to register routes"); - - // Finally finalize - let router = api_gateway - .rest_finalize(&api_ctx, router) - .expect("Failed to finalize"); - - // Test built-in health endpoints - let response = router - .clone() - .oneshot( - Request::builder() - .uri("/healthz") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::OK, - "Health endpoint should be accessible" - ); - - // Test OpenAPI endpoint - let response = router - .clone() - .oneshot( - Request::builder() - .uri("/cf/openapi.json") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::OK, - "OpenAPI endpoint should be accessible" - ); - - // Test OpenAPI endpoint - let response = router - .oneshot( - Request::builder() - .uri("/openapi.json") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::NOT_FOUND, - "OpenAPI endpoint should be inaccessible without prefix" - ); -} - -#[tokio::test] -async fn test_middleware_always_inserts_security_ctx() { - // This test verifies that SecurityContext is always available in handlers - let config = json!({ - "api-gateway": { - "config": { - "bind_addr": "0.0.0.0:8080", - "enable_docs": false, - "cors_enabled": false, - "auth_disabled": true, - } - } - }); - - let api_ctx = create_api_gateway_ctx(config); - let test_ctx = create_test_gear_ctx(); - - let api_gateway = api_gateway::ApiGateway::default(); - api_gateway.init(&api_ctx).await.expect("Failed to init"); - - let mut router: Router = Router::new(); - let test_gear = TestAuthGear; - router = test_gear - .register_rest(&test_ctx, router, &api_gateway) - .expect("Failed to register routes"); - - let router = api_gateway - .rest_finalize(&api_ctx, router) - .expect("Failed to finalize"); - - // Make request to protected handler that extracts SecurityContext - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/api/protected") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - // Should NOT get 500 error about missing SecurityContext - assert_eq!( - response.status(), - StatusCode::OK, - "Handler should receive SecurityContext from middleware" - ); -} - -#[tokio::test] -async fn test_openapi_includes_security_metadata() { - let config = json!({ - "api-gateway": { - "config": { - "bind_addr": "0.0.0.0:8080", - "enable_docs": true, - "cors_enabled": false, - "auth_disabled": true, - "require_auth_by_default": true, - } - } - }); - - let api_ctx = create_api_gateway_ctx(config); - let test_ctx = create_test_gear_ctx(); - - let api_gateway = api_gateway::ApiGateway::default(); - api_gateway.init(&api_ctx).await.expect("Failed to init"); - - let router = Router::new(); - let test_gear = TestAuthGear; - let _router = test_gear - .register_rest(&test_ctx, router, &api_gateway) - .expect("Failed to register routes"); - - // Build OpenAPI spec - let openapi = api_gateway - .build_openapi() - .expect("Failed to build OpenAPI"); - let spec = serde_json::to_value(&openapi).expect("Failed to serialize"); - - // Verify security scheme exists - let security_schemes = spec - .pointer("/components/securitySchemes") - .expect("Security schemes should exist"); - assert!( - security_schemes.get("bearerAuth").is_some(), - "bearerAuth scheme should be registered" - ); - - // Verify protected route has security requirement - // Path is /tests/v1/api/protected, JSON pointer escapes / as ~1 - let protected_security = spec.pointer("/paths/~1tests~1v1~1api~1protected/get/security"); - assert!( - protected_security.is_some(), - "Protected route should have security requirement in OpenAPI" - ); - - // Verify public route does NOT have security requirement - let public_security = spec.pointer("/paths/~1tests~1v1~1api~1public/get/security"); - assert!( - public_security.is_none() - || public_security - .unwrap() - .as_array() - .is_some_and(Vec::is_empty), - "Public route should NOT have security requirement in OpenAPI" - ); -} - -#[tokio::test] -async fn test_route_pattern_matching_with_path_params() { - // This test verifies that routes with path parameters (e.g., /users/{id}) - // are properly matched under a configured prefix (auth disabled in this test) - let config = json!({ - "api-gateway": { - "config": { - "bind_addr": "0.0.0.0:8080", - "enable_docs": false, - "cors_enabled": false, - "auth_disabled": true, // Disabled for test simplicity - } - } - }); - - let api_ctx = create_api_gateway_ctx(config); - let test_ctx = create_test_gear_ctx(); - - let api_gateway = api_gateway::ApiGateway::default(); - api_gateway.init(&api_ctx).await.expect("Failed to init"); - - let mut router = Router::new(); - let test_gear = TestAuthGear; - router = test_gear - .register_rest(&test_ctx, router, &api_gateway) - .expect("Failed to register routes"); - - let router = api_gateway - .rest_finalize(&api_ctx, router) - .expect("Failed to finalize"); - - // Test that /tests/v1/api/users/123 is accessible (matches /tests/v1/api/users/{id}) - let response = router - .clone() - .oneshot( - Request::builder() - .uri("/tests/v1/api/users/123") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::OK, - "Route with path parameter should be accessible and matched correctly" - ); - - // Test that /tests/v1/api/users/abc-def-456 is also accessible - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/api/users/abc-def-456") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::OK, - "Route with different path parameter value should also be accessible" - ); -} - -#[tokio::test] -async fn test_route_pattern_matching_with_prefix_path_params() { - // This test verifies that routes with path parameters (e.g., /users/{id}) - // are properly matched and authorization is enforced - let config = json!({ - "api-gateway": { - "config": { - "bind_addr": "0.0.0.0:8080", - "enable_docs": false, - "cors_enabled": false, - "auth_disabled": true, // Disabled for test simplicity - "prefix_path": "/cf", - } - } - }); - - let api_ctx = create_api_gateway_ctx(config); - let test_ctx = create_test_gear_ctx(); - - let api_gateway = api_gateway::ApiGateway::default(); - api_gateway.init(&api_ctx).await.expect("Failed to init"); - - let mut router = Router::new(); - let test_gear = TestAuthGear; - router = test_gear - .register_rest(&test_ctx, router, &api_gateway) - .expect("Failed to register routes"); - - let router = api_gateway - .rest_finalize(&api_ctx, router) - .expect("Failed to finalize"); - - // Test that /tests/v1/api/users/123 is accessible (matches /tests/v1/api/users/{id}) - let response = router - .clone() - .oneshot( - Request::builder() - .uri("/cf/tests/v1/api/users/123") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::OK, - "Route with path parameter should be accessible and matched correctly" - ); - - // Test that /tests/v1/api/users/abc-def-456 is also accessible - let response = router - .oneshot( - Request::builder() - .uri("/cf/tests/v1/api/users/abc-def-456") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::OK, - "Route with different path parameter value should also be accessible" - ); -} - -// --------------------------------------------------------------------------- -// Auth-enabled tests: verify the actual authn_middleware with a mock AuthN client -// --------------------------------------------------------------------------- - -/// Handler function type for the mock `AuthN` Resolver. -type MockAuthNHandler = - dyn Fn(&str) -> Result + Send + Sync; - -/// Configurable mock `AuthN` Resolver client for auth-enabled tests. -struct MockAuthNResolverClient { - handler: Arc, -} - -#[async_trait] -impl AuthNResolverClient for MockAuthNResolverClient { - async fn authenticate( - &self, - bearer_token: &str, - ) -> Result { - (self.handler)(bearer_token) - } - - async fn exchange_client_credentials( - &self, - _request: &ClientCredentialsRequest, - ) -> Result { - Err(AuthNResolverError::Internal( - "not implemented in mock".to_owned(), - )) - } -} - -/// Test gear for auth-enabled tests. -/// -/// Registers both a protected and a public route. -/// The public route also extracts `SecurityContext` so tests can verify -/// that anonymous context is injected for public endpoints. -pub struct TestAuthEnabledGear; - -#[async_trait] -impl Gear for TestAuthEnabledGear { - async fn init(&self, _ctx: &GearCtx) -> Result<()> { - Ok(()) - } -} - -impl RestApiCapability for TestAuthEnabledGear { - fn register_rest( - &self, - _ctx: &GearCtx, - router: Router, - openapi: &dyn OpenApiRegistry, - ) -> Result { - let router = OperationBuilder::get("/tests/v1/api/protected") - .operation_id("test_auth.protected") - .authenticated() - .require_license_features::([]) - .summary("Protected endpoint") - .handler(protected_handler) - .json_response_with_schema::(openapi, http::StatusCode::OK, "Success") - .error_401(openapi) - .error_403(openapi) - .register(router, openapi); - - // Public route that extracts SecurityContext so tests can verify anonymous ctx - let router = OperationBuilder::get("/tests/v1/api/public-ctx") - .operation_id("test_auth.public_ctx") - .public() - .summary("Public endpoint with security context") - .handler(protected_handler) // reuse: extracts SecurityContext - .json_response_with_schema::(openapi, http::StatusCode::OK, "Success") - .register(router, openapi); - - Ok(router) - } -} - -async fn create_router(config: serde_json::Value, mock: MockAuthNResolverClient) -> Router { - let hub = Arc::new(ClientHub::new()); - hub.register::(Arc::new(mock)); - - let api_ctx = GearCtx::new( - "api-gateway", - Uuid::new_v4(), - Arc::new(TestConfigProvider { config }), - hub, - tokio_util::sync::CancellationToken::new(), - ); - let test_ctx = create_test_gear_ctx(); - - let api_gateway = api_gateway::ApiGateway::default(); - api_gateway.init(&api_ctx).await.expect("Failed to init"); - - let mut router = Router::new(); - let test_gear = TestAuthEnabledGear; - router = test_gear - .register_rest(&test_ctx, router, &api_gateway) - .expect("Failed to register routes"); - - api_gateway - .rest_finalize(&api_ctx, router) - .expect("Failed to finalize") -} - -/// Create a finalized router with auth **enabled** and the given mock `AuthN` client. -async fn create_auth_enabled_router(mock: MockAuthNResolverClient, cors_enabled: bool) -> Router { - let config = json!({ - "api-gateway": { - "config": { - "bind_addr": "0.0.0.0:8080", - "enable_docs": false, - "cors_enabled": cors_enabled, - "auth_disabled": false, - } - } - }); - - create_router(config, mock).await -} - -async fn create_auth_enabled_with_prefix_router( - mock: MockAuthNResolverClient, - cors_enabled: bool, -) -> Router { - let config = json!({ - "api-gateway": { - "config": { - "bind_addr": "0.0.0.0:8080", - "enable_docs": false, - "cors_enabled": cors_enabled, - "auth_disabled": false, - "prefix_path": "/cf", - } - } - }); - - create_router(config, mock).await -} - -/// Build a mock that accepts a specific token and returns a `SecurityContext` with known IDs. -fn mock_accepting_token( - valid_token: &'static str, - subject_id: Uuid, - tenant_id: Uuid, -) -> MockAuthNResolverClient { - MockAuthNResolverClient { - handler: Arc::new(move |token| { - if token == valid_token { - Ok(AuthenticationResult { - security_context: SecurityContext::builder() - .subject_id(subject_id) - .subject_tenant_id(tenant_id) - .build() - .unwrap(), - }) - } else { - Err(AuthNResolverError::Unauthorized("invalid token".to_owned())) - } - }), - } -} - -/// Build a mock that always returns the given error. -fn mock_returning_error(err_fn: fn() -> AuthNResolverError) -> MockAuthNResolverClient { - MockAuthNResolverClient { - handler: Arc::new(move |_| Err(err_fn())), - } -} - -// --- Auth-enabled integration tests --- - -#[tokio::test] -async fn test_valid_token_returns_200() { - let subject_id = Uuid::new_v4(); - let tenant_id = Uuid::new_v4(); - let mock = mock_accepting_token("valid-test-token", subject_id, tenant_id); - - let router = create_auth_enabled_router(mock, false).await; - - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/api/protected") - .header(header::AUTHORIZATION, "Bearer valid-test-token") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!(response.status(), StatusCode::OK); - - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!(json["user_id"], subject_id.to_string()); -} - -#[tokio::test] -async fn test_missing_token_returns_401() { - let mock = mock_accepting_token("any", Uuid::new_v4(), Uuid::new_v4()); - let router = create_auth_enabled_router(mock, false).await; - - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/api/protected") - // No Authorization header - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::UNAUTHORIZED, - "Missing token should yield 401" - ); - assert_eq!(content_type(&response), PROBLEM_JSON); - let problem = problem_from(response).await; - assert_eq!(problem.problem_type, UNAUTHENTICATED_TYPE); - assert_eq!(problem.context["reason"], "MISSING_BEARER"); -} - -#[tokio::test] -async fn test_invalid_token_returns_401() { - let mock = mock_accepting_token("good-token", Uuid::new_v4(), Uuid::new_v4()); - let router = create_auth_enabled_router(mock, false).await; - - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/api/protected") - .header(header::AUTHORIZATION, "Bearer bad-token") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::UNAUTHORIZED, - "Invalid token should yield 401" - ); - assert_eq!(content_type(&response), PROBLEM_JSON); - let problem = problem_from(response).await; - assert_eq!(problem.problem_type, UNAUTHENTICATED_TYPE); - assert_eq!(problem.context["reason"], "AUTHN_FAILED"); -} - -#[tokio::test] -async fn test_no_plugin_available_returns_503() { - let mock = mock_returning_error(|| AuthNResolverError::NoPluginAvailable); - let router = create_auth_enabled_router(mock, false).await; - - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/api/protected") - .header(header::AUTHORIZATION, "Bearer some-token") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::SERVICE_UNAVAILABLE, - "NoPluginAvailable should yield 503" - ); - assert_eq!(content_type(&response), PROBLEM_JSON); - let problem = problem_from(response).await; - assert_eq!(problem.problem_type, SERVICE_UNAVAILABLE_TYPE); - assert_eq!(problem.context["retry_after_seconds"], 5); -} - -#[tokio::test] -async fn test_service_unavailable_returns_503() { - let mock = - mock_returning_error(|| AuthNResolverError::ServiceUnavailable("plugin down".to_owned())); - let router = create_auth_enabled_router(mock, false).await; - - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/api/protected") - .header(header::AUTHORIZATION, "Bearer some-token") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::SERVICE_UNAVAILABLE, - "ServiceUnavailable should yield 503" - ); - assert_eq!(content_type(&response), PROBLEM_JSON); - let problem = problem_from(response).await; - assert_eq!(problem.problem_type, SERVICE_UNAVAILABLE_TYPE); - assert_eq!(problem.context["retry_after_seconds"], 5); -} - -#[tokio::test] -async fn test_internal_error_returns_500() { - let mock = mock_returning_error(|| AuthNResolverError::Internal("boom".to_owned())); - let router = create_auth_enabled_router(mock, false).await; - - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/api/protected") - .header(header::AUTHORIZATION, "Bearer some-token") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::INTERNAL_SERVER_ERROR, - "Internal error should yield 500" - ); - assert_eq!(content_type(&response), PROBLEM_JSON); - let problem = problem_from(response).await; - assert_eq!(problem.problem_type, INTERNAL_TYPE); -} - -#[tokio::test] -async fn test_public_route_with_auth_enabled() { - // Mock that would reject any token — proves it is never called for public routes - let mock = - mock_returning_error(|| AuthNResolverError::Internal("should not be called".to_owned())); - let router = create_auth_enabled_router(mock, false).await; - - // No Authorization header on a public route - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/api/public-ctx") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::OK, - "Public route should return 200 even with auth enabled and no token" - ); - - // Verify the handler received an anonymous SecurityContext (subject_id = Uuid::default) - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!( - json["user_id"], - Uuid::default().to_string(), - "Public route should receive anonymous SecurityContext" - ); -} - -#[tokio::test] -async fn test_public_route_with_prefix_auth_enabled() { - // Mock that would reject any token — proves it is never called for public routes - let mock = - mock_returning_error(|| AuthNResolverError::Internal("should not be called".to_owned())); - let router = create_auth_enabled_with_prefix_router(mock, false).await; - - // No Authorization header on a public route - let response = router - .clone() - .oneshot( - Request::builder() - .uri("/cf/tests/v1/api/public-ctx") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::OK, - "Public route should return 200 even with auth enabled and no token" - ); - - // Verify the handler received an anonymous SecurityContext (subject_id = Uuid::default) - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .unwrap(); - let json: serde_json::Value = serde_json::from_slice(&body).unwrap(); - assert_eq!( - json["user_id"], - Uuid::default().to_string(), - "Public route should receive anonymous SecurityContext" - ); - - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/api/public-ctx") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!( - response.status(), - StatusCode::NOT_FOUND, - "Public route should return 404 for unknown paths" - ); -} - -#[tokio::test] -async fn test_cors_preflight_skips_auth() { - // Mock that rejects everything — proves auth is skipped for preflight - let mock = - mock_returning_error(|| AuthNResolverError::Internal("should not be called".to_owned())); - let router = create_auth_enabled_router(mock, true).await; - - let response = router - .oneshot( - Request::builder() - .method(Method::OPTIONS) - .uri("/tests/v1/api/protected") - .header(header::ORIGIN, "https://example.com") - .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - // With CORS enabled, a preflight should NOT be rejected by auth. - // The exact status depends on the CORS layer (usually 200), - // but it must NOT be 401/403. - assert_ne!( - response.status(), - StatusCode::UNAUTHORIZED, - "CORS preflight must not be blocked by auth" - ); - assert_ne!( - response.status(), - StatusCode::FORBIDDEN, - "CORS preflight must not be blocked by auth" - ); -} diff --git a/gears/system/api-gateway/tests/license_middleware.rs.orig b/gears/system/api-gateway/tests/license_middleware.rs.orig deleted file mode 100644 index e8a2a62e8..000000000 --- a/gears/system/api-gateway/tests/license_middleware.rs.orig +++ /dev/null @@ -1,370 +0,0 @@ -#![allow(clippy::unwrap_used, clippy::expect_used)] - -use anyhow::Result; -use async_trait::async_trait; -use axum::{ - Router, - body::Body, - http::{Request, StatusCode, header}, - response::IntoResponse, -}; -use toolkit::{ - ClientHub, Module, - api::OperationBuilder, - api::operation_builder::LicenseFeature, - config::ConfigProvider, - context::GearCtx, - contracts::{ApiGatewayCapability, OpenApiRegistry, RestApiCapability}, -}; -use toolkit_canonical_errors::Problem; -use serde_json::json; -use std::sync::Arc; -use tower::ServiceExt; -use uuid::Uuid; - -const PERMISSION_DENIED_TYPE: &str = - "gts://gts.cf.core.errors.err.v1~cf.core.err.permission_denied.v1~"; -const PROBLEM_JSON: &str = "application/problem+json"; - -struct TestConfigProvider { - config: serde_json::Value, -} - -impl ConfigProvider for TestConfigProvider { - fn get_gear_config(&self, gear: &str) -> Option<&serde_json::Value> { - self.config.get(gear) - } -} - -fn create_api_gateway_ctx(config: serde_json::Value) -> GearCtx { - let hub = Arc::new(ClientHub::new()); - - GearCtx::new( - "api-gateway", - Uuid::new_v4(), - Arc::new(TestConfigProvider { config }), - hub, - tokio_util::sync::CancellationToken::new(), - ) -} - -fn create_test_gear_ctx() -> GearCtx { - GearCtx::new( - "test_gear", - Uuid::new_v4(), - Arc::new(TestConfigProvider { config: json!({}) }), - Arc::new(ClientHub::new()), - tokio_util::sync::CancellationToken::new(), - ) -} - -async fn ok_handler() -> impl IntoResponse { - StatusCode::OK -} - -pub struct TestLicenseGear; - -#[async_trait] -impl Gear for TestLicenseGear { - async fn init(&self, _ctx: &GearCtx) -> Result<()> { - Ok(()) - } -} - -struct NonBaseFeature; - -impl AsRef for NonBaseFeature { - fn as_ref(&self) -> &'static str { - "some_other_feature" - } -} - -impl LicenseFeature for NonBaseFeature {} - -struct BaseFeature; - -impl AsRef for BaseFeature { - fn as_ref(&self) -> &'static str { - "gts.cf.core.lic.feat.v1~cf.core.global.base.v1" - } -} - -impl LicenseFeature for BaseFeature {} - -impl RestApiCapability for TestLicenseGear { - fn register_rest( - &self, - _ctx: &GearCtx, - router: Router, - openapi: &dyn OpenApiRegistry, - ) -> Result { - let feature = NonBaseFeature; - - let router = OperationBuilder::get("/tests/v1/license/bad") - .operation_id("test.license.bad") - .authenticated() - .require_license_features([&feature]) - .handler(ok_handler) - .json_response(http::StatusCode::OK, "OK") - .register(router, openapi); - - let base_feature = BaseFeature; - - let router = OperationBuilder::get("/tests/v1/license/good") - .operation_id("test.license.good") - .authenticated() - .require_license_features([&base_feature]) - .handler(ok_handler) - .json_response(http::StatusCode::OK, "OK") - .register(router, openapi); - - let router = OperationBuilder::get("/tests/v1/license/none") - .operation_id("test.license.none") - .authenticated() - .require_license_features::([]) - .handler(ok_handler) - .json_response(http::StatusCode::OK, "OK") - .register(router, openapi); - - Ok(router) - } -} - -#[tokio::test] -async fn rejects_non_base_feature_requirement() { - let config = json!({ - "api-gateway": { - "config": { - "bind_addr": "0.0.0.0:8080", - "enable_docs": false, - "cors_enabled": false, - "auth_disabled": true, - } - } - }); - - let api_ctx = create_api_gateway_ctx(config); - let test_ctx = create_test_gear_ctx(); - - let api_gateway = api_gateway::ApiGateway::default(); - api_gateway.init(&api_ctx).await.expect("Failed to init"); - - let mut router = Router::new(); - let test_gear = TestLicenseGear; - router = test_gear - .register_rest(&test_ctx, router, &api_gateway) - .expect("Failed to register routes"); - - router = api_gateway - .rest_finalize(&api_ctx, router) - .expect("Failed to finalize"); - - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/license/bad") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!(response.status(), StatusCode::FORBIDDEN); - assert_eq!( - response - .headers() - .get(header::CONTENT_TYPE) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""), - PROBLEM_JSON - ); - let body = axum::body::to_bytes(response.into_body(), usize::MAX) - .await - .expect("read body"); - let problem: Problem = serde_json::from_slice(&body).expect("parse Problem JSON"); - assert_eq!(problem.problem_type, PERMISSION_DENIED_TYPE); - assert_eq!(problem.context["reason"], "LICENSE_FEATURE_REQUIRED"); -} - -#[tokio::test] -async fn rejects_non_base_feature_requirement_with_prefix() { - let config = json!({ - "api-gateway": { - "config": { - "bind_addr": "0.0.0.0:8080", - "enable_docs": false, - "cors_enabled": false, - "auth_disabled": true, - "prefix_path": "/cf", - } - } - }); - - let api_ctx = create_api_gateway_ctx(config); - let test_ctx = create_test_gear_ctx(); - - let api_gateway = api_gateway::ApiGateway::default(); - api_gateway.init(&api_ctx).await.expect("Failed to init"); - - let mut router = Router::new(); - let test_gear = TestLicenseGear; - router = test_gear - .register_rest(&test_ctx, router, &api_gateway) - .expect("Failed to register routes"); - - router = api_gateway - .rest_finalize(&api_ctx, router) - .expect("Failed to finalize"); - - let response = router - .clone() - .oneshot( - Request::builder() - .uri("/cf/tests/v1/license/bad") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!(response.status(), StatusCode::FORBIDDEN); - - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/license/bad") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!(response.status(), StatusCode::NOT_FOUND); -} - -#[tokio::test] -async fn allows_base_feature_requirement() { - let config = json!({ - "api-gateway": { - "config": { - "bind_addr": "0.0.0.0:8080", - "enable_docs": false, - "cors_enabled": false, - "auth_disabled": true, - } - } - }); - - let api_ctx = create_api_gateway_ctx(config); - let test_ctx = create_test_gear_ctx(); - - let api_gateway = api_gateway::ApiGateway::default(); - api_gateway.init(&api_ctx).await.expect("Failed to init"); - - let router = Router::new(); - let test_gear = TestLicenseGear; - let router = test_gear - .register_rest(&test_ctx, router, &api_gateway) - .expect("Failed to register routes"); - - let router = api_gateway - .rest_finalize(&api_ctx, router) - .expect("Failed to finalize"); - - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/license/good") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!(response.status(), StatusCode::OK); -} - -#[tokio::test] -async fn allows_base_feature_requirement_with_prefix() { - let config = json!({ - "api-gateway": { - "config": { - "bind_addr": "0.0.0.0:8080", - "enable_docs": false, - "cors_enabled": false, - "auth_disabled": true, - "prefix_path": "/cf", - } - } - }); - - let api_ctx = create_api_gateway_ctx(config); - let test_ctx = create_test_gear_ctx(); - - let api_gateway = api_gateway::ApiGateway::default(); - api_gateway.init(&api_ctx).await.expect("Failed to init"); - - let router = Router::new(); - let test_gear = TestLicenseGear; - let router = test_gear - .register_rest(&test_ctx, router, &api_gateway) - .expect("Failed to register routes"); - - let router = api_gateway - .rest_finalize(&api_ctx, router) - .expect("Failed to finalize"); - - let response = router - .oneshot( - Request::builder() - .uri("/cf/tests/v1/license/good") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!(response.status(), StatusCode::OK); -} - -#[tokio::test] -async fn allows_no_license_requirement() { - let config = json!({ - "api-gateway": { - "config": { - "bind_addr": "0.0.0.0:8080", - "enable_docs": false, - "cors_enabled": false, - "auth_disabled": true, - } - } - }); - - let api_ctx = create_api_gateway_ctx(config); - let test_ctx = create_test_gear_ctx(); - - let api_gateway = api_gateway::ApiGateway::default(); - api_gateway.init(&api_ctx).await.expect("Failed to init"); - - let mut router = Router::new(); - let test_gear = TestLicenseGear; - router = test_gear - .register_rest(&test_ctx, router, &api_gateway) - .expect("Failed to register routes"); - - let router = api_gateway - .rest_finalize(&api_ctx, router) - .expect("Failed to finalize"); - - let response = router - .oneshot( - Request::builder() - .uri("/tests/v1/license/none") - .body(Body::empty()) - .unwrap(), - ) - .await - .expect("Request failed"); - - assert_eq!(response.status(), StatusCode::OK); -} diff --git a/gears/system/api-gateway/tests/middleware_order.rs b/gears/system/api-gateway/tests/middleware_order.rs index 12b682089..d4770df58 100644 --- a/gears/system/api-gateway/tests/middleware_order.rs +++ b/gears/system/api-gateway/tests/middleware_order.rs @@ -7,7 +7,6 @@ //! -> timeout -> body limit -> CORS -> MIME validation -> rate limit -> error mapping -> auth -> router //! use anyhow::Result; -use api_gateway::middleware::request_id::XRequestId; use axum::{ Router, body::Body, @@ -21,6 +20,7 @@ use toolkit::{ Gear, api::OperationBuilder, config::ConfigProvider, context::GearCtx, contracts::ApiGatewayCapability, }; +use toolkit_http_middleware::request_id::XRequestId; use tower::ServiceExt; use uuid::Uuid; diff --git a/gears/system/api-gateway/tests/middleware_order.rs.orig b/gears/system/api-gateway/tests/middleware_order.rs.orig deleted file mode 100644 index 580d6cbd5..000000000 --- a/gears/system/api-gateway/tests/middleware_order.rs.orig +++ /dev/null @@ -1,302 +0,0 @@ -#![allow(clippy::expect_used)] - -//! Validates the *actual* middleware execution order of `ApiGateway::apply_middleware_stack`. -//! -//! The intended order is documented in `gears/api_gateway/src/lib.rs`: -//! set request id -> propagate request id -> trace -> push request id to extensions -//! -> timeout -> body limit -> CORS -> MIME validation -> rate limit -> error mapping -> auth -> router -//! -use anyhow::Result; -use api_gateway::middleware::request_id::XRequestId; -use axum::{ - Router, - body::Body, - extract::{Extension, Json}, - http::{Request, StatusCode}, - response::IntoResponse, -}; -use toolkit::{ - Gear, api::OperationBuilder, config::ConfigProvider, context::GearCtx, - contracts::ApiGatewayCapability, -}; -use serde_json::json; -use std::sync::Arc; -use tower::ServiceExt; -use uuid::Uuid; - -struct TestConfigProvider { - config: serde_json::Value, -} - -impl ConfigProvider for TestConfigProvider { - fn get_gear_config(&self, gear: &str) -> Option<&serde_json::Value> { - self.config.get(gear) - } -} - -fn create_api_gateway_ctx(config: serde_json::Value) -> GearCtx { - let hub = Arc::new(toolkit::ClientHub::new()); - - GearCtx::new( - "api-gateway", - Uuid::new_v4(), - Arc::new(TestConfigProvider { config }), - hub, - tokio_util::sync::CancellationToken::new(), - ) -} - -async fn handler(Extension(XRequestId(rid)): Extension) -> impl IntoResponse { - Json(json!({ "request_id": rid })) -} - -#[tokio::test] -async fn real_middlewares_observe_documented_order() -> Result<()> { - // Configure strict + deterministic rate limiting for the test route. - let cfg = json!({ - "api-gateway": { - "config": { - "bind_addr": "127.0.0.1:0", - "cors_enabled": true, - "auth_disabled": true, - "defaults": { - "rate_limit": { "rps": 1, "burst": 1, "in_flight": 64 } - }, - } - } - }); - let ctx = create_api_gateway_ctx(cfg); - - let api = api_gateway::ApiGateway::default(); - api.init(&ctx).await?; - - // Register an endpoint that enables both MIME validation and rate limiting. - let mut router = Router::new(); - let mut builder = OperationBuilder::post("/tests/v1/middleware-order"); - builder.require_rate_limit(1, 1, 64); - router = builder - .operation_id("test:middleware-order") - .summary("Middleware order test endpoint") - .public() - .allow_content_types(&["application/json"]) // turns on MIME validation - .json_response(StatusCode::OK, "OK") - .handler(axum::routing::post(handler)) - .register(router, &api); - - // Apply the real gateway middleware stack. - let app = api.rest_finalize(&ctx, router)?; - - // -------------------- - // Req1: invalid Content-Type -> should be rejected by MIME validation (BAD_REQUEST / 400), - // but MUST still have CORS headers (CORS wraps MIME). - // Also: x-request-id must be echoed (request-id is outermost). - // -------------------- - let res1 = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/tests/v1/middleware-order") - .header("origin", "https://example.com") - .header("x-request-id", "fixed-req-1") - .header("content-type", "text/plain") - .body(Body::from("hi"))?, - ) - .await?; - assert_eq!(res1.status(), StatusCode::BAD_REQUEST); - assert_eq!( - res1.headers() - .get("x-request-id") - .and_then(|v| v.to_str().ok()), - Some("fixed-req-1") - ); - assert!( - res1.headers().get("access-control-allow-origin").is_some(), - "CORS header must be present on 400 => CORS wraps MIME validation" - ); - - // -------------------- - // Req2: valid Content-Type -> should pass MIME + consume rate-limit token. - // Also handler must see request-id via extensions. - // -------------------- - let res2 = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/tests/v1/middleware-order") - .header("origin", "https://example.com") - .header("content-type", "application/json") - .body(Body::from(r#"{"ok":true}"#))?, - ) - .await?; - assert_eq!(res2.status(), StatusCode::OK); - let res2_rid = res2 - .headers() - .get("x-request-id") - .and_then(|v| v.to_str().ok()) - .expect("x-request-id must be set on success") - .to_owned(); - let body2 = axum::body::to_bytes(res2.into_body(), usize::MAX).await?; - let json2: serde_json::Value = serde_json::from_slice(&body2)?; - assert_eq!( - json2.get("request_id").and_then(|v| v.as_str()), - Some(res2_rid.as_str()), - "handler must receive request_id via extensions (push_req_id_to_extensions)" - ); - - // -------------------- - // Req3: another valid request immediately -> must be rate-limited (429), - // proving Req1 didn't consume token (MIME before rate limit), while Req2 did. - // -------------------- - let res3 = app - .oneshot( - Request::builder() - .method("POST") - .uri("/tests/v1/middleware-order") - .header("origin", "https://example.com") - .header("content-type", "application/json") - .body(Body::from(r#"{"ok":true}"#))?, - ) - .await?; - assert_eq!( - res3.status(), - StatusCode::TOO_MANY_REQUESTS, - "Second valid request should be rate-limited (token consumed by Req2, not by Req1)" - ); - - Ok(()) -} - -#[tokio::test] -async fn real_middlewares_observe_documented_order_with_prefix() -> Result<()> { - // Configure strict + deterministic rate limiting for the test route. - let cfg = json!({ - "api-gateway": { - "config": { - "bind_addr": "127.0.0.1:0", - "cors_enabled": true, - "auth_disabled": true, - "prefix_path": "/cf", - "defaults": { - "rate_limit": { "rps": 1, "burst": 1, "in_flight": 64 } - }, - } - } - }); - let ctx = create_api_gateway_ctx(cfg); - - let api = api_gateway::ApiGateway::default(); - api.init(&ctx).await?; - - // Register an endpoint that enables both MIME validation and rate limiting. - let mut router = Router::new(); - let mut builder = OperationBuilder::post("/tests/v1/middleware-order"); - builder.require_rate_limit(1, 1, 64); - router = builder - .operation_id("test:middleware-order") - .summary("Middleware order test endpoint") - .public() - .allow_content_types(&["application/json"]) // turns on MIME validation - .json_response(StatusCode::OK, "OK") - .handler(axum::routing::post(handler)) - .register(router, &api); - - // Apply the real gateway middleware stack. - let app = api.rest_finalize(&ctx, router)?; - - // -------------------- - // Req1: invalid Content-Type -> should be rejected by MIME validation (BAD_REQUEST / 400), - // but MUST still have CORS headers (CORS wraps MIME). - // Also: x-request-id must be echoed (request-id is outermost). - // -------------------- - let res1 = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/cf/tests/v1/middleware-order") - .header("origin", "https://example.com") - .header("x-request-id", "fixed-req-1") - .header("content-type", "text/plain") - .body(Body::from("hi"))?, - ) - .await?; - assert_eq!(res1.status(), StatusCode::BAD_REQUEST); - assert_eq!( - res1.headers() - .get("x-request-id") - .and_then(|v| v.to_str().ok()), - Some("fixed-req-1") - ); - assert!( - res1.headers().get("access-control-allow-origin").is_some(), - "CORS header must be present on 400 => CORS wraps MIME validation" - ); - - // -------------------- - // Req2: valid Content-Type -> should pass MIME + consume rate-limit token. - // Also handler must see request-id via extensions. - // -------------------- - let res2 = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/cf/tests/v1/middleware-order") - .header("origin", "https://example.com") - .header("content-type", "application/json") - .body(Body::from(r#"{"ok":true}"#))?, - ) - .await?; - assert_eq!(res2.status(), StatusCode::OK); - let res2_rid = res2 - .headers() - .get("x-request-id") - .and_then(|v| v.to_str().ok()) - .expect("x-request-id must be set on success") - .to_owned(); - let body2 = axum::body::to_bytes(res2.into_body(), usize::MAX).await?; - let json2: serde_json::Value = serde_json::from_slice(&body2)?; - assert_eq!( - json2.get("request_id").and_then(|v| v.as_str()), - Some(res2_rid.as_str()), - "handler must receive request_id via extensions (push_req_id_to_extensions)" - ); - - // -------------------- - // Req3: another valid request immediately -> must be rate-limited (429), - // proving Req1 didn't consume token (MIME before rate limit), while Req2 did. - // -------------------- - let res3 = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri("/cf/tests/v1/middleware-order") - .header("origin", "https://example.com") - .header("content-type", "application/json") - .body(Body::from(r#"{"ok":true}"#))?, - ) - .await?; - assert_eq!( - res3.status(), - StatusCode::TOO_MANY_REQUESTS, - "Second valid request should be rate-limited (token consumed by Req2, not by Req1)" - ); - - let res1 = app - .oneshot( - Request::builder() - .method("POST") - .uri("/tests/v1/middleware-order") - .header("origin", "https://example.com") - .header("x-request-id", "fixed-req-1") - .header("content-type", "text/plain") - .body(Body::from("hi"))?, - ) - .await?; - assert_eq!(res1.status(), StatusCode::NOT_FOUND); - - Ok(()) -} diff --git a/gears/system/api-gateway/tests/mime_validation_integration.rs b/gears/system/api-gateway/tests/mime_validation_integration.rs index 471e93f06..e63c480f1 100644 --- a/gears/system/api-gateway/tests/mime_validation_integration.rs +++ b/gears/system/api-gateway/tests/mime_validation_integration.rs @@ -18,9 +18,7 @@ use toolkit::api::OperationSpec; use toolkit_canonical_errors::Problem; use tower::ServiceExt; // for oneshot -use api_gateway::middleware::mime_validation::{ - build_mime_validation_map, mime_validation_middleware, -}; +use api_gateway::middleware::{build_mime_validation_map, mime_validation_middleware}; use toolkit::api::operation_builder::VendorExtensions; const INVALID_ARGUMENT_TYPE: &str = @@ -72,12 +70,9 @@ async fn test_middleware_allows_configured_content_type() { let validation_map = build_mime_validation_map(&specs); - let app = - Router::new() - .route("/api/data", post(test_handler)) - .layer(axum::middleware::from_fn(move |req, next| { - mime_validation_middleware(validation_map.clone(), req, next) - })); + let app = Router::new().route("/api/data", post(test_handler)).layer( + axum::middleware::from_fn_with_state(validation_map, mime_validation_middleware), + ); // Test: Send request with allowed content type let request = Request::builder() @@ -117,12 +112,9 @@ async fn test_middleware_strips_content_type_parameters() { let validation_map = build_mime_validation_map(&specs); - let app = - Router::new() - .route("/api/data", post(test_handler)) - .layer(axum::middleware::from_fn(move |req, next| { - mime_validation_middleware(validation_map.clone(), req, next) - })); + let app = Router::new().route("/api/data", post(test_handler)).layer( + axum::middleware::from_fn_with_state(validation_map, mime_validation_middleware), + ); // Test: Send request with charset parameter let request = Request::builder() @@ -162,12 +154,9 @@ async fn test_middleware_rejects_disallowed_content_type() { let validation_map = build_mime_validation_map(&specs); - let app = - Router::new() - .route("/api/data", post(test_handler)) - .layer(axum::middleware::from_fn(move |req, next| { - mime_validation_middleware(validation_map.clone(), req, next) - })); + let app = Router::new().route("/api/data", post(test_handler)).layer( + axum::middleware::from_fn_with_state(validation_map, mime_validation_middleware), + ); // Test: Send request with disallowed content type let request = Request::builder() @@ -225,9 +214,10 @@ async fn test_middleware_rejects_missing_content_type() { let app = Router::new() .route("/files/v1/upload", post(test_handler)) - .layer(axum::middleware::from_fn(move |req, next| { - mime_validation_middleware(validation_map.clone(), req, next) - })); + .layer(axum::middleware::from_fn_with_state( + validation_map, + mime_validation_middleware, + )); // Test: Send request without content-type header let request = Request::builder() @@ -271,9 +261,10 @@ async fn test_middleware_passes_through_unconfigured_routes() { // Apply middleware AFTER routing (like in real usage) let app = Router::new() .route("/tests/v1/public", post(test_handler)) - .layer(axum::middleware::from_fn(move |req, next| { - mime_validation_middleware(validation_map.clone(), req, next) - })); + .layer(axum::middleware::from_fn_with_state( + validation_map, + mime_validation_middleware, + )); // Test: Send request with JSON body (even without content-type, should work if no validation) let request = Request::builder() @@ -319,9 +310,10 @@ async fn test_middleware_allows_multiple_content_types() { let app = Router::new() .route("/tests/v1/flexible", post(test_handler)) - .layer(axum::middleware::from_fn(move |req, next| { - mime_validation_middleware(validation_map.clone(), req, next) - })); + .layer(axum::middleware::from_fn_with_state( + validation_map, + mime_validation_middleware, + )); // Test: application/json should work let request = Request::builder() diff --git a/gears/system/api-gateway/tests/request_id.rs b/gears/system/api-gateway/tests/request_id.rs index 39921ba67..35f00bc75 100644 --- a/gears/system/api-gateway/tests/request_id.rs +++ b/gears/system/api-gateway/tests/request_id.rs @@ -11,7 +11,7 @@ use axum::{ use serde_json::json; use tower::util::ServiceExt; // for `oneshot` -use api_gateway::middleware::request_id::{MakeReqId, XRequestId, header}; +use toolkit_http_middleware::request_id::{MakeReqId, XRequestId, header}; #[tokio::test] async fn generates_request_id_when_missing() { @@ -107,7 +107,7 @@ fn test_app() -> Router { Router::new() .merge(routes) .layer(from_fn( - api_gateway::middleware::request_id::push_req_id_to_extensions, + toolkit_http_middleware::request_id::push_req_id_to_extensions, )) .layer(PropagateRequestIdLayer::new(x_request_id.clone())) .layer(SetRequestIdLayer::new(x_request_id, MakeReqId)) diff --git a/gears/system/oagw/oagw/src/infra/metrics.rs b/gears/system/oagw/oagw/src/infra/metrics.rs index ab24058ee..a2541c870 100644 --- a/gears/system/oagw/oagw/src/infra/metrics.rs +++ b/gears/system/oagw/oagw/src/infra/metrics.rs @@ -13,8 +13,8 @@ use crate::domain::ports::metric_labels::{METHOD_OTHER, key}; /// Map an HTTP method to a low-cardinality `http.request.method` label value. /// /// Standard verbs from RFC 9110 are returned verbatim; anything else collapses -/// to [`METHOD_OTHER`] (`"_OTHER"`), mirroring `api-gateway`'s normalizer -/// (`gears/system/api-gateway/src/middleware/http_metrics.rs::normalize_method`) +/// to [`METHOD_OTHER`] (`"_OTHER"`), mirroring the shared normalizer +/// (`libs/toolkit-http-middleware/src/http_metrics.rs::normalize_method`) /// so both gears emit the same `http.request.method` vocabulary. /// /// Lives in the infra layer because the domain layer must not depend on diff --git a/libs/toolkit-canonical-errors/src/builder.rs b/libs/toolkit-canonical-errors/src/builder.rs index 75aa9969d..c45bfc92f 100644 --- a/libs/toolkit-canonical-errors/src/builder.rs +++ b/libs/toolkit-canonical-errors/src/builder.rs @@ -629,6 +629,45 @@ impl CanonicalError { } } +// --------------------------------------------------------------------------- +// Runtime-scope constructors +// --------------------------------------------------------------------------- +// +// These mirror the constructors generated by `#[resource_error]` but accept the +// GTS resource-type (scope) at *runtime* rather than binding it at compile time. +// They let generic, reusable middleware (e.g. `toolkit-http-middleware`) emit +// canonical errors under a scope chosen by the consuming gear, without that +// gear's `#[resource_error]` type being reachable from the shared crate. + +impl CanonicalError { + /// `invalid_argument` builder with a runtime GTS `scope` as the resource type. + #[must_use] + pub fn scoped_invalid_argument( + scope: &'static str, + ) -> ResourceErrorBuilder { + ResourceErrorBuilder::__invalid_argument(scope, "Request validation failed") + } + + /// `permission_denied` builder with a runtime GTS `scope` as the resource type. + #[must_use] + pub fn scoped_permission_denied( + scope: &'static str, + ) -> ResourceErrorBuilder { + ResourceErrorBuilder::__permission_denied( + scope, + "You do not have permission to perform this operation", + ) + } + + /// `resource_exhausted` builder with a runtime GTS `scope` as the resource type. + #[must_use] + pub fn scoped_resource_exhausted( + scope: &'static str, + ) -> ResourceErrorBuilder { + ResourceErrorBuilder::__resource_exhausted(scope, "Quota exceeded") + } +} + // --------------------------------------------------------------------------- // create() — gated by Resource + Context Resolved traits // --------------------------------------------------------------------------- diff --git a/libs/toolkit-canonical-errors/tests/error.rs b/libs/toolkit-canonical-errors/tests/error.rs index af63e2801..cddaacbe8 100644 --- a/libs/toolkit-canonical-errors/tests/error.rs +++ b/libs/toolkit-canonical-errors/tests/error.rs @@ -157,6 +157,63 @@ fn question_mark_propagation_serde_json() { ); } +// ========================================================================= +// Runtime-scope constructors — used by generic middleware (e.g. +// toolkit-http-middleware) to emit canonical errors under a GTS scope chosen +// at runtime, without a compile-time #[resource_error] type. +// ========================================================================= + +const SCOPE: &str = "gts.cf.core.gateway.route.v1~"; + +#[test] +fn scoped_invalid_argument_carries_category_and_scope() { + let err = CanonicalError::scoped_invalid_argument(SCOPE) + .with_field_violation("field", "bad format", "INVALID_FORMAT") + .create(); + + assert_eq!(err.status_code(), 400); + assert_eq!(err.title(), "Invalid Argument"); + assert_eq!( + err.gts_type(), + "gts.cf.core.errors.err.v1~cf.core.err.invalid_argument.v1~" + ); + // The runtime scope is stamped as the resource type... + assert_eq!(err.resource_type(), Some(SCOPE)); + // ...and survives conversion to the wire Problem. + let problem = Problem::from(err); + assert_eq!(problem.context["resource_type"], SCOPE); +} + +#[test] +fn scoped_permission_denied_carries_category_and_scope() { + let err = CanonicalError::scoped_permission_denied(SCOPE) + .with_reason("INSUFFICIENT_ROLE") + .create(); + + assert_eq!(err.status_code(), 403); + assert_eq!(err.title(), "Permission Denied"); + assert_eq!( + err.gts_type(), + "gts.cf.core.errors.err.v1~cf.core.err.permission_denied.v1~" + ); + assert_eq!(err.resource_type(), Some(SCOPE)); +} + +#[test] +fn scoped_resource_exhausted_carries_category_and_scope() { + let err = CanonicalError::scoped_resource_exhausted(SCOPE) + .with_quota_violation("requests", "limit reached") + .create(); + + assert_eq!(err.status_code(), 429); + assert_eq!(err.title(), "Resource Exhausted"); + assert_eq!( + err.gts_type(), + "gts.cf.core.errors.err.v1~cf.core.err.resource_exhausted.v1~" + ); + assert_eq!(err.resource_type(), Some(SCOPE)); +} + // ========================================================================= // GTS ID validation — ensures all IDs in the crate are valid GTS identifiers // ========================================================================= diff --git a/libs/toolkit-http-middleware/Cargo.toml b/libs/toolkit-http-middleware/Cargo.toml index 24d855bdf..4e987eb82 100644 --- a/libs/toolkit-http-middleware/Cargo.toml +++ b/libs/toolkit-http-middleware/Cargo.toml @@ -33,6 +33,32 @@ secrecy = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +# request_id middleware: X-Request-Id generation/propagation. +tower-http = { workspace = true, features = ["request-id"] } +nanoid = { workspace = true } +# access_log middleware: byte-counting response body wrapper. +bytes = { workspace = true } +http-body = { workspace = true } + +# mime/license validation + rate limiting: per-route lookup maps. +dashmap = { workspace = true } +# rate_limit middleware: token-bucket + in-flight limiting. +governor = { workspace = true } +tokio = { workspace = true, features = ["sync"] } +anyhow = { workspace = true } +# scope_enforcement middleware: glob route matching. +glob = { workspace = true } +# MatchitRouteAuthPolicy: per-method route matching for the RouteAuthPolicy impl. +matchit = { workspace = true } +# Deserializable config schemas for the config-driven middleware +# (rate_limit defaults, scope_enforcement rules). +serde = { workspace = true, features = ["derive"] } + +# http_metrics middleware: OpenTelemetry instruments. HTTP server metrics are a +# baseline capability for every gear serving HTTP, so this is unconditional. +# When no meter provider is installed the global meter is a no-op (near-zero cost). +opentelemetry = { workspace = true } + [dev-dependencies] tokio = { workspace = true } tower = { workspace = true, features = ["util"] } diff --git a/libs/toolkit-http-middleware/README.md b/libs/toolkit-http-middleware/README.md index 3211933c1..5097af163 100644 --- a/libs/toolkit-http-middleware/README.md +++ b/libs/toolkit-http-middleware/README.md @@ -9,14 +9,38 @@ maintaining their own. ## What it does - **Two-plane authentication** as axum layers: - - `security_context_middleware` (tenant plane) — always re-validates the bearer token via - an injected `BearerAuthenticator` and inserts a `SecurityContext` + - `security_context_middleware` (tenant plane) — resolves each route's + `AuthRequirement` via an injected `RouteAuthPolicy`, then, for authenticated + routes, always re-validates the bearer token via an injected + `BearerAuthenticator` and inserts a `SecurityContext` - `internal_auth_middleware` (platform plane) — validates the `X-ToolKit-Internal-Token` via an injected `InternalAuthenticator` and inserts a `PlatformSecurityContext` plus `PeerAuthenticated` - Header extractors for `Authorization: Bearer` and `X-ToolKit-Internal-Token` -- `PublicRoute` marker so routes that carry no JWT pass through without `401` -- Renders rejections as canonical RFC 9457 `application/problem+json` +- `RouteAuthPolicy` decides, per `(method, path)`, whether a route requires a + JWT; public routes receive an anonymous `SecurityContext` and pass through. + The crate ships a ready-made `MatchitRouteAuthPolicy` (per-method `matchit` + matching of explicit authenticated/public route sets, with a + `require_auth_by_default` fallback) that most gears can use directly +- Renders rejections as canonical RFC 9457 `application/problem+json` with RFC + 6750 `WWW-Authenticate` challenges +- **CORS-preflight bypass is opt-in** (`SecurityContextLayerState::with_cors_preflight_bypass`) + and off by default — CORS is an edge concern, so normal OoP gears stay lightweight and + only the gateway enables it +- **Generic HTTP hygiene** layers any gear serving HTTP can install: + - `request_id` — `X-Request-Id` generation/propagation into request extensions + - `access_log` — one structured `tracing` event per request, with accurate + `bytes_sent` (byte-counting body wrapper for chunked/SSE responses) + - `http_metrics` — OpenTelemetry HTTP server metrics, a baseline capability for + every gear serving HTTP. A no-op meter is used until the consumer installs a + meter provider, so it is near-zero cost when metrics aren't exported +- **Per-route request policy** middleware, driven by lookup tables/rules the gear + builds from its own operation specs and config (the crate stays free of gear + spec/config/error types; rejections render under a caller-supplied GTS scope): + - `mime_validation` — per-route `Content-Type` allow-listing + - `license_validation` — per-route feature-entitlement gating + - `rate_limit` — per-route token-bucket + in-flight limiting + - `scope_enforcement` — coarse-grained, pre-PDP token-scope checks ## What it does NOT do @@ -30,13 +54,28 @@ maintaining their own. ```rust use std::sync::Arc; use axum::{Router, middleware::from_fn_with_state, routing::get}; -use toolkit_http_middleware::{internal_auth_middleware, security_context_middleware}; +use toolkit_http_middleware::{ + MatchitRouteAuthPolicy, SecurityContextLayerState, internal_auth_middleware, + security_context_middleware, +}; // `bearer` and `internal` are your concrete `BearerAuthenticator` / // `InternalAuthenticator` adapters, supplied at the bootstrap layer. +// +// Build the route policy from your authenticated/public route sets (or +// implement `RouteAuthPolicy` yourself for bespoke matching): +let policy = Arc::new(MatchitRouteAuthPolicy::from_route_sets( + authenticated_routes, // HashSet<(Method, String)> + public_routes, // HashSet<(Method, String)> + /* require_auth_by_default */ true, +)?); +let bearer_state = SecurityContextLayerState::new(bearer, policy); +// At an edge that installs a CORS layer (the gateway), also call +// `.with_cors_preflight_bypass()`. + let router = Router::new() .route("/widgets", get(list_widgets)) - .route_layer(from_fn_with_state(bearer, security_context_middleware::)) + .route_layer(from_fn_with_state(bearer_state, security_context_middleware::)) .route_layer(from_fn_with_state(internal, internal_auth_middleware::)); ``` diff --git a/gears/system/api-gateway/src/middleware/access_log.rs b/libs/toolkit-http-middleware/src/access_log.rs similarity index 97% rename from gears/system/api-gateway/src/middleware/access_log.rs rename to libs/toolkit-http-middleware/src/access_log.rs index d18e5e1b4..b1727c9cf 100644 --- a/gears/system/api-gateway/src/middleware/access_log.rs +++ b/libs/toolkit-http-middleware/src/access_log.rs @@ -14,7 +14,8 @@ use axum::{body::Body, extract::ConnectInfo, middleware::Next, response::Respons use bytes::Bytes; use http_body::Frame; -use super::request_id::XRequestId; +use crate::common::{TRACEPARENT, parse_trace_id}; +use crate::request_id::XRequestId; /// Middleware that emits a structured access log line for every HTTP request. /// @@ -57,9 +58,9 @@ pub async fn access_log_middleware(req: axum::extract::Request, next: Next) -> R let trace_id = req .headers() - .get(toolkit_http::otel::TRACEPARENT) + .get(TRACEPARENT) .and_then(|v| v.to_str().ok()) - .and_then(toolkit_http::otel::parse_trace_id) + .and_then(parse_trace_id) .unwrap_or_default(); let (remote_addr, remote_addr_ip, remote_addr_port) = req diff --git a/libs/toolkit-http-middleware/src/auth.rs b/libs/toolkit-http-middleware/src/auth.rs index bda9586cf..c55c54230 100644 --- a/libs/toolkit-http-middleware/src/auth.rs +++ b/libs/toolkit-http-middleware/src/auth.rs @@ -2,12 +2,17 @@ //! //! Two complementary middlewares: //! -//! - [`security_context_middleware`] (**tenant plane**) extracts the bearer token from -//! the incoming `Authorization` header and **always** re-validates it via an -//! injected [`BearerAuthenticator`] — there is no trusted-peer fast path -//! (zero-trust). On success the reconstructed [`SecurityContext`](toolkit_security::SecurityContext) -//! is inserted into the request extensions for downstream handlers and the -//! `AuthZ` resolver. +//! - [`security_context_middleware`] (**tenant plane**) resolves the route's +//! [`AuthRequirement`] via an injected [`RouteAuthPolicy`], then, for +//! authenticated routes, extracts the bearer token from the incoming +//! `Authorization` header and **always** re-validates it via an injected +//! [`BearerAuthenticator`] — there is no trusted-peer fast path (zero-trust). +//! On success the reconstructed +//! [`SecurityContext`] is inserted into the +//! request extensions for downstream handlers and the `AuthZ` resolver. CORS +//! preflight requests and public routes receive an anonymous +//! `SecurityContext` and pass through; rejections carry an RFC 6750 +//! `WWW-Authenticate` challenge. //! - [`internal_auth_middleware`] (**platform plane**) extracts the //! `X-ToolKit-Internal-Token` header and, if present, validates it via an //! injected [`InternalAuthenticator`], inserting [`PeerAuthenticated`] and a @@ -21,25 +26,26 @@ //! is never a prerequisite for JWT validation; [`security_context_middleware`] does not //! consult it. //! -//! Routes that carry no tenant JWT (probes, platform-plane-only handlers) are -//! marked with the [`PublicRoute`] request extension by the gear/bootstrap layer; -//! note this is distinct from `OperationSpec.is_public`, which controls gateway -//! registration. The concrete authenticator adapters are injected via Axum state -//! at the same layer. +//! Which routes require a tenant JWT is decided by the [`RouteAuthPolicy`] +//! supplied via Axum state at the gear/bootstrap layer; the concrete +//! authenticator adapters are injected the same way. use std::sync::Arc; use axum::{ - extract::{Request, State}, + extract::{MatchedPath, Request, State}, middleware::Next, response::{IntoResponse, Response}, }; +use http::{Method, header::ACCESS_CONTROL_REQUEST_METHOD, header::ORIGIN}; use toolkit_canonical_errors::CanonicalError; use toolkit_security::{ AuthNError, BearerAuthenticator, InternalAuthNError, InternalAuthenticator, PeerAuthenticated, - PlatformSecurityContext, + PlatformSecurityContext, SecurityContext, }; +use crate::common::{BearerChallenge, append_bearer_challenge, resolve_path}; +use crate::policy::{AuthRequirement, RouteAuthPolicy}; use crate::security::{ InternalTokenHttpError, SecurityContextHttpError, extract_bearer_http, extract_internal_token_http, @@ -54,79 +60,154 @@ const AUTH_RETRY_AFTER_SECONDS: u64 = 5; /// mirroring `api-gateway`'s authn middleware. Carries no diagnostic specifics. const AUTH_INFRA_FAILURE_DETAIL: &str = "authentication infrastructure failure"; -/// Per-route marker indicating the route carries **no tenant JWT** and must not -/// require a [`SecurityContext`](toolkit_security::SecurityContext). +/// Injected state for [`security_context_middleware`]. /// -/// Inserted by the gear/bootstrap layer for routes that never carry an -/// `Authorization: Bearer` header — framework probe endpoints (`/healthz`, -/// `/readyz`), and platform-plane-only handlers that authenticate via -/// `X-ToolKit-Internal-Token` instead. When present, [`security_context_middleware`] lets -/// a request without an `Authorization` header pass through instead of -/// returning `401`. -/// -/// **Not the same as `OperationSpec.is_public`.** `is_public` controls whether -/// a route is registered in the gateway for external access; this marker -/// controls whether [`security_context_middleware`] requires a JWT. The two are -/// independent: most gateway-exposed routes DO carry a JWT and do NOT need this -/// marker; most probe routes are NOT gateway-exposed but DO need it. -#[derive(Clone, Copy, Debug)] -pub struct PublicRoute; +/// Bundles the concrete [`BearerAuthenticator`] adapter (`Arc`) with the +/// [`RouteAuthPolicy`] that decides which routes require a tenant JWT. Supplied +/// via `axum::middleware::from_fn_with_state` at the gear/bootstrap layer. +pub struct SecurityContextLayerState { + authenticator: Arc, + policy: Arc, + cors_preflight_bypass: bool, +} + +impl SecurityContextLayerState { + /// Build the middleware state from an authenticator and a route policy. + /// + /// CORS-preflight bypass is **off** by default: per the edge-architecture + /// ADR, CORS is an edge concern terminated by the gateway (or an external + /// gateway), so normal `OoP` gears sitting behind the edge never receive + /// preflights and stay lightweight. Enable it with + /// [`with_cors_preflight_bypass`](Self::with_cors_preflight_bypass) at an + /// edge that installs its own CORS layer. + pub fn new(authenticator: Arc, policy: Arc) -> Self { + Self { + authenticator, + policy, + cors_preflight_bypass: false, + } + } + + /// Let CORS preflight requests (`OPTIONS` carrying `Origin` + + /// `Access-Control-Request-Method`) bypass authentication, receiving an + /// anonymous `SecurityContext` so the CORS layer can answer them. + /// + /// This is an **edge** concern — enable it only where a CORS layer is + /// installed (the api-gateway). Normal gears leave it off. + #[must_use] + pub fn with_cors_preflight_bypass(mut self) -> Self { + self.cors_preflight_bypass = true; + self + } +} + +// Manual `Clone` so `A` need not be `Clone` (both `Arc` fields plus a `bool`). +// Axum requires the state type to be `Clone`. +impl Clone for SecurityContextLayerState { + fn clone(&self) -> Self { + Self { + authenticator: Arc::clone(&self.authenticator), + policy: Arc::clone(&self.policy), + cors_preflight_bypass: self.cors_preflight_bypass, + } + } +} /// Tenant-plane `SecurityContext` middleware. /// /// Behaviour: -/// - A bearer token, if present, is **always** re-validated via the injected -/// [`BearerAuthenticator`]; on success the [`SecurityContext`](toolkit_security::SecurityContext) is inserted -/// into request extensions. -/// - A protected route (no [`PublicRoute`] marker) with a missing or invalid -/// `Authorization` header is rejected with `401`. -/// - A public / system-only route (carrying the [`PublicRoute`] marker) with no -/// `Authorization` header passes through. -/// - A rejected token is `401`; an unreachable backend is `503`; any other -/// unexpected authentication failure is `500`. +/// - **CORS preflight** (`OPTIONS` carrying `Origin` + +/// `Access-Control-Request-Method`) is never authenticated **when +/// [`with_cors_preflight_bypass`](SecurityContextLayerState::with_cors_preflight_bypass) +/// is enabled** (edge/gateway only): an anonymous +/// [`SecurityContext`] is inserted and the +/// request passes through so the CORS layer can answer it. When disabled (the +/// default for `OoP` gears) a preflight is treated like any other request and +/// subject to the route policy. +/// - The route's [`AuthRequirement`] is resolved via the injected +/// [`RouteAuthPolicy`]. A [`None`](AuthRequirement::None) route receives an +/// anonymous `SecurityContext` and passes through. +/// - For a [`Required`](AuthRequirement::Required) route the bearer token is +/// **always** re-validated via the injected [`BearerAuthenticator`]; on +/// success the reconstructed `SecurityContext` is inserted into request +/// extensions. +/// - A missing credential is `401` (`MISSING_BEARER`) with a +/// `WWW-Authenticate: Bearer realm="api"` challenge; a malformed credential +/// is `401` (`INVALID_BEARER`) with an `invalid_token` challenge; a rejected +/// token is `401` (`AUTHN_FAILED`) with an `invalid_token` challenge; an +/// unreachable backend is `503`; any other unexpected failure is `500`. /// /// Rejections are rendered as canonical RFC 9457 `application/problem+json` /// responses (via [`CanonicalError`]) so they match the platform-wide error /// contract; `instance` / `trace_id` enrichment is left to the outer canonical /// error middleware installed at the gear/bootstrap layer. /// -/// The handler is generic over `A`; the concrete authenticator is supplied via -/// Axum state as `Arc` at the gear/bootstrap layer. -/// -/// TODO: Rework to align with the gateway's `authn_middleware` -/// (`gears/system/api-gateway/src/middleware/auth.rs`), the more mature -/// implementation: route-policy-driven auth requirements (vs. the binary -/// `PublicRoute` marker), CORS-preflight handling, anonymous-`SecurityContext` -/// insertion for public routes, and RFC 6750 `WWW-Authenticate` Bearer -/// challenges. Part of consolidating all gateway middlewares into this crate. +/// The handler is generic over `A`; the concrete authenticator and route policy +/// are supplied via [`SecurityContextLayerState`] at the gear/bootstrap layer. pub async fn security_context_middleware( - State(authenticator): State>, + State(state): State>, mut request: Request, next: Next, ) -> Response where A: BearerAuthenticator + 'static, { - let is_public = request.extensions().get::().is_some(); + // CORS preflight bypass is an edge concern (see edge-architecture ADR): only + // when explicitly enabled do we insert an anonymous context so downstream + // `Extension` extractors don't panic and let the CORS layer + // answer the preflight. `OoP` gears leave this off and stay lightweight. + if state.cors_preflight_bypass && is_preflight_request(request.method(), request.headers()) { + request + .extensions_mut() + .insert(SecurityContext::anonymous()); + return next.run(request).await; + } - match extract_bearer_http(request.headers()) { - Ok(token) => match authenticator.authenticate(token.expose_secret()).await { - Ok(secctx) => { - request.extensions_mut().insert(secctx); - next.run(request).await + let path = request.extensions().get::().map_or_else( + || request.uri().path().to_owned(), + |p| p.as_str().to_owned(), + ); + let path = resolve_path(&request, path.as_str()); + + match state.policy.resolve(request.method(), path.as_str()) { + AuthRequirement::None => { + request + .extensions_mut() + .insert(SecurityContext::anonymous()); + next.run(request).await + } + AuthRequirement::Required => match extract_bearer_http(request.headers()) { + Ok(token) => match state + .authenticator + .authenticate(token.expose_secret()) + .await + { + Ok(secctx) => { + request.extensions_mut().insert(secctx); + next.run(request).await + } + Err(err) => authn_error_to_response(&err), + }, + // No credential presented (RFC 6750 §3): challenge without an error code. + Err(SecurityContextHttpError::MissingAuthHeader) => { + unauthenticated("MISSING_BEARER", Some(BearerChallenge::NoCredentials)) } - Err(err) => authn_error_to_response(&err), + // A credential was presented but malformed: treat as invalid_token. + Err( + SecurityContextHttpError::InvalidAuthHeader | SecurityContextHttpError::EmptyToken, + ) => unauthenticated("INVALID_BEARER", Some(BearerChallenge::InvalidToken)), }, - // No credential presented: allow through only for public/system-only - // routes; protected routes require a user context. - Err(SecurityContextHttpError::MissingAuthHeader) if is_public => next.run(request).await, - Err(SecurityContextHttpError::MissingAuthHeader) => unauthenticated("MISSING_BEARER"), - Err(SecurityContextHttpError::InvalidAuthHeader | SecurityContextHttpError::EmptyToken) => { - unauthenticated("INVALID_BEARER") - } } } +/// Detect a CORS preflight request: an `OPTIONS` carrying both `Origin` and +/// `Access-Control-Request-Method`. +fn is_preflight_request(method: &Method, headers: &http::HeaderMap) -> bool { + method == Method::OPTIONS + && headers.contains_key(ORIGIN) + && headers.contains_key(ACCESS_CONTROL_REQUEST_METHOD) +} + /// Map a neutral [`AuthNError`] to a canonical `problem+json` response. /// /// The token and any provider-specific detail are never surfaced on the wire. @@ -136,7 +217,7 @@ fn authn_error_to_response(err: &AuthNError) -> Response { // is bad (401). AuthNError::InvalidToken => { tracing::warn!("bearer token rejected: invalid or expired"); - unauthenticated("AUTHN_FAILED") + unauthenticated("AUTHN_FAILED", Some(BearerChallenge::InvalidToken)) } // The backend could not be reached: surface 503 with a retry hint so // callers can distinguish "try later" from "your token is bad". @@ -162,12 +243,18 @@ fn authn_error_to_response(err: &AuthNError) -> Response { } /// Build a canonical `Unauthenticated` (`401`) `problem+json` response with the -/// given machine-readable reason. -fn unauthenticated(reason: &str) -> Response { - CanonicalError::unauthenticated() +/// given machine-readable reason and, for tenant-plane bearer rejections, an +/// RFC 6750 `WWW-Authenticate` challenge. Platform-plane (internal-token) +/// rejections pass `None` since they do not use the `Bearer` scheme. +fn unauthenticated(reason: &str, challenge: Option) -> Response { + let mut response = CanonicalError::unauthenticated() .with_reason(reason) .create() - .into_response() + .into_response(); + if let Some(challenge) = challenge { + append_bearer_challenge(&mut response, challenge); + } + response } /// Platform-plane internal-auth middleware. @@ -221,7 +308,7 @@ where // A credential was presented but is malformed: reject before the // tenant plane runs. Err(InternalTokenHttpError::InvalidHeader | InternalTokenHttpError::EmptyToken) => { - unauthenticated("INVALID_INTERNAL_TOKEN") + unauthenticated("INVALID_INTERNAL_TOKEN", None) } } } @@ -234,7 +321,7 @@ fn internal_authn_error_to_response(err: &InternalAuthNError) -> Response { // A reachable backend that rejected the credential: it is bad (401). InternalAuthNError::InvalidToken => { tracing::warn!("internal token rejected: invalid or expired credential"); - unauthenticated("INTERNAL_AUTH_FAILED") + unauthenticated("INTERNAL_AUTH_FAILED", None) } // The validation backend (e.g. K8s TokenReview) was unreachable: 503. InternalAuthNError::Unavailable => { diff --git a/libs/toolkit-http-middleware/src/auth_tests.rs b/libs/toolkit-http-middleware/src/auth_tests.rs index 40bcee76a..a36408086 100644 --- a/libs/toolkit-http-middleware/src/auth_tests.rs +++ b/libs/toolkit-http-middleware/src/auth_tests.rs @@ -5,12 +5,29 @@ use axum::{ body::{Body, to_bytes}, routing::get, }; -use http::{Request as HttpRequest, StatusCode, header}; +use http::{Method, Request as HttpRequest, StatusCode, header}; use toolkit_security::{ InternalAuthNError, InternalAuthenticator, PlatformIdentity, SecurityContext, }; use tower::ServiceExt; +use crate::policy::{AuthRequirement, RouteAuthPolicy}; + +/// Route policy stand-in returning a fixed [`AuthRequirement`] for every route. +struct StubPolicy(AuthRequirement); + +impl RouteAuthPolicy for StubPolicy { + fn resolve(&self, _method: &Method, _path: &str) -> AuthRequirement { + self.0 + } +} + +/// Build tenant-plane middleware state for the given authenticator + requirement. +fn secctx_state(requirement: AuthRequirement) -> SecurityContextLayerState { + let policy: Arc = Arc::new(StubPolicy(requirement)); + SecurityContextLayerState::new(Arc::new(StubAuthenticator), policy) +} + const GOOD_TOKEN: &str = "valid-token"; const UNAVAILABLE_TOKEN: &str = "unavailable-token"; const INTERNAL_TOKEN: &str = "internal-failure-token"; @@ -38,26 +55,21 @@ impl BearerAuthenticator for StubAuthenticator { } fn app(is_public: bool) -> Router { - let authenticator = Arc::new(StubAuthenticator); - - // `security_context_middleware` runs as a `route_layer` (after routing); the - // `PublicRoute` marker is added as an outer router `layer` so it is - // present in the request extensions by the time the middleware reads it - // (this mirrors how the bootstrap layer surfaces `OperationSpec.is_public` per-route). + // The route policy decides whether `/` requires a JWT; a public route + // resolves to `AuthRequirement::None`, a protected one to `Required`. + let requirement = if is_public { + AuthRequirement::None + } else { + AuthRequirement::Required + }; let secctx = axum::middleware::from_fn_with_state( - authenticator, + secctx_state(requirement), security_context_middleware::, ); - let router = Router::new() + Router::new() .route("/", get(|| async { StatusCode::OK })) - .route_layer(secctx); - - if is_public { - router.layer(axum::Extension(PublicRoute)) - } else { - router - } + .route_layer(secctx) } /// Drive a request through `router` and return `(status, content_type)`. @@ -121,10 +133,9 @@ fn platform_app() -> Router { /// Stacked app: `internal_auth_middleware` (outermost, runs first) then /// `security_context_middleware`, mirroring the DESIGN § 3.2 middleware order. fn stacked_app() -> Router { - let bearer = Arc::new(StubAuthenticator); let internal = Arc::new(StubInternalAuthenticator); let secctx = axum::middleware::from_fn_with_state( - bearer, + secctx_state(AuthRequirement::Required), security_context_middleware::, ); let internal_layer = axum::middleware::from_fn_with_state( @@ -138,10 +149,9 @@ fn stacked_app() -> Router { } fn stacked_public_app() -> Router { - let bearer = Arc::new(StubAuthenticator); let internal = Arc::new(StubInternalAuthenticator); let secctx = axum::middleware::from_fn_with_state( - bearer, + secctx_state(AuthRequirement::None), security_context_middleware::, ); let internal_layer = axum::middleware::from_fn_with_state( @@ -152,7 +162,6 @@ fn stacked_public_app() -> Router { .route("/", get(|| async { StatusCode::OK })) .route_layer(secctx) .route_layer(internal_layer) - .layer(axum::Extension(PublicRoute)) } /// Drive a request with arbitrary headers through `router`, returning @@ -229,9 +238,59 @@ async fn public_route_without_auth_passes_through() { } #[tokio::test] -async fn public_route_revalidates_present_token() { +async fn public_route_ignores_present_token() { + // Converged (gateway) behaviour: a `None` route is never authenticated, so + // even a forged token is ignored and the request passes through with an + // anonymous `SecurityContext`. let (status, _) = send(app(true), Some("Bearer forged-token")).await; - assert_eq!(status, StatusCode::UNAUTHORIZED); + assert_eq!(status, StatusCode::OK); +} + +/// Build a `Required`-policy app whose CORS-preflight bypass is toggled. +fn preflight_app(bypass: bool) -> Router { + let policy: Arc = Arc::new(StubPolicy(AuthRequirement::Required)); + let mut state = SecurityContextLayerState::new(Arc::new(StubAuthenticator), policy); + if bypass { + state = state.with_cors_preflight_bypass(); + } + let secctx = axum::middleware::from_fn_with_state( + state, + security_context_middleware::, + ); + Router::new() + .route( + "/", + get(|| async { StatusCode::OK }).options(|| async { StatusCode::OK }), + ) + .route_layer(secctx) +} + +/// Drive a CORS preflight (`OPTIONS` + `Origin` + `Access-Control-Request-Method`). +async fn send_preflight(router: Router) -> StatusCode { + let request = HttpRequest::builder() + .method(Method::OPTIONS) + .uri("/") + .header(header::ORIGIN, "https://example.com") + .header(header::ACCESS_CONTROL_REQUEST_METHOD, "GET") + .body(Body::empty()) + .unwrap(); + router.oneshot(request).await.unwrap().status() +} + +#[tokio::test] +async fn cors_preflight_bypasses_auth_when_enabled() { + // Edge/gateway behaviour: preflight skips auth and reaches the handler. + assert_eq!(send_preflight(preflight_app(true)).await, StatusCode::OK); +} + +#[tokio::test] +async fn cors_preflight_enforced_by_default() { + // Default (OoP gears): CORS bypass is edge-only, so a preflight to a + // `Required` route with no token is rejected like any other request. + assert_eq!( + send_preflight(preflight_app(false)).await, + StatusCode::UNAUTHORIZED + ); } #[tokio::test] @@ -324,14 +383,93 @@ async fn invalid_internal_token_rejected_before_tenant_plane() { #[tokio::test] async fn system_call_to_public_endpoint_passes() { - // Valid SA token, no JWT, route is PublicRoute — the normal probe/platform path. + // Valid SA token, no JWT, public route — the normal probe/platform path. let (status, _, _) = send_headers(stacked_public_app(), &[(INTERNAL_HEADER, SA_GOOD)]).await; assert_eq!(status, StatusCode::OK); } #[tokio::test] async fn public_endpoint_with_no_credentials_passes() { - // No SA token and no JWT on a PublicRoute: passes (health probe). + // No SA token and no JWT on a public route: passes (health probe). let (status, _, _) = send_headers(stacked_public_app(), &[]).await; assert_eq!(status, StatusCode::OK); } + +/// App driven by the real [`MatchitRouteAuthPolicy`] (the tests above use a +/// fixed `StubPolicy`; these exercise per-route matching): `/public` is +/// explicitly public, `/protected` explicitly authenticated, and `/other` is +/// unmatched (governed by `require_auth_by_default`). +fn matchit_policy_app(require_auth_by_default: bool) -> Router { + let authenticated: std::collections::HashSet<(Method, String)> = + [(Method::GET, "/protected".to_owned())] + .into_iter() + .collect(); + let public: std::collections::HashSet<(Method, String)> = + [(Method::GET, "/public".to_owned())].into_iter().collect(); + let policy: Arc = Arc::new( + crate::MatchitRouteAuthPolicy::from_route_sets( + authenticated, + public, + require_auth_by_default, + ) + .expect("valid route patterns"), + ); + let secctx = axum::middleware::from_fn_with_state( + SecurityContextLayerState::new(Arc::new(StubAuthenticator), policy), + security_context_middleware::, + ); + Router::new() + .route("/public", get(|| async { StatusCode::OK })) + .route("/protected", get(|| async { StatusCode::OK })) + .route("/other", get(|| async { StatusCode::OK })) + .route_layer(secctx) +} + +/// Drive a `GET path` through `router` and return the response status. +async fn get_status(router: Router, path: &str, auth: Option<&str>) -> StatusCode { + let mut builder = HttpRequest::builder().uri(path).method(Method::GET); + if let Some(value) = auth { + builder = builder.header(header::AUTHORIZATION, value); + } + let request = builder.body(Body::empty()).unwrap(); + router.oneshot(request).await.unwrap().status() +} + +#[tokio::test] +async fn matchit_policy_public_route_passes_without_token() { + assert_eq!( + get_status(matchit_policy_app(true), "/public", None).await, + StatusCode::OK + ); +} + +#[tokio::test] +async fn matchit_policy_protected_route_rejects_without_token() { + assert_eq!( + get_status(matchit_policy_app(false), "/protected", None).await, + StatusCode::UNAUTHORIZED + ); +} + +#[tokio::test] +async fn matchit_policy_protected_route_accepts_valid_token() { + let bearer = format!("Bearer {GOOD_TOKEN}"); + assert_eq!( + get_status(matchit_policy_app(false), "/protected", Some(&bearer)).await, + StatusCode::OK + ); +} + +#[tokio::test] +async fn matchit_policy_unmatched_route_follows_require_auth_by_default() { + // require_auth_by_default = true → unmatched `/other` needs a token. + assert_eq!( + get_status(matchit_policy_app(true), "/other", None).await, + StatusCode::UNAUTHORIZED + ); + // require_auth_by_default = false → unmatched `/other` passes anonymously. + assert_eq!( + get_status(matchit_policy_app(false), "/other", None).await, + StatusCode::OK + ); +} diff --git a/gears/system/api-gateway/src/middleware/common.rs b/libs/toolkit-http-middleware/src/common.rs similarity index 77% rename from gears/system/api-gateway/src/middleware/common.rs rename to libs/toolkit-http-middleware/src/common.rs index 12a9139cc..e995bd374 100644 --- a/gears/system/api-gateway/src/middleware/common.rs +++ b/libs/toolkit-http-middleware/src/common.rs @@ -1,3 +1,12 @@ +//! Shared, transport-agnostic helpers for the server-side middleware. +//! +//! - [`BearerChallenge`] / [`append_bearer_challenge`] — RFC 6750 §3 +//! `WWW-Authenticate: Bearer` challenge rendering, attached to `401`/`403` +//! rejections so clients receive a standards-compliant challenge. +//! - [`resolve_path`] — normalises the matched request path by stripping any +//! `NestedPath` prefix so route policies match the gear-local path regardless +//! of where the router was nested (e.g. under a gateway `prefix_path`). + use axum::extract::Request; use axum::response::Response; use http::HeaderValue; @@ -41,6 +50,30 @@ pub fn append_bearer_challenge(response: &mut Response, challenge: BearerChallen ); } +/// W3C Trace Context header name (`traceparent`). +/// +/// Duplicated (deliberately, as a 4-line stable-format helper) from +/// `toolkit_http::otel` so this server-side crate need not depend on the heavy +/// outbound HTTP client stack (hyper/rustls) just to read a trace id. +pub const TRACEPARENT: &str = "traceparent"; + +/// Parse the trace id from a W3C `traceparent` header value. +/// +/// Format: `00-{trace_id}-{span_id}-{flags}`. Returns `None` for any value that +/// isn't a supported (`00`) version with the expected field count. +#[must_use] +pub fn parse_trace_id(traceparent: &str) -> Option { + let parts: Vec<&str> = traceparent.split('-').collect(); + if parts.len() >= 4 && parts[0] == "00" { + Some(parts[1].to_owned()) + } else { + None + } +} + +/// Resolve the gear-local request path from a matched path, stripping any +/// `NestedPath` prefix so route policies match regardless of nesting. +#[must_use] pub fn resolve_path(req: &Request, matched_path: &str) -> String { req.extensions() .get::() diff --git a/gears/system/api-gateway/src/middleware/http_metrics.rs b/libs/toolkit-http-middleware/src/http_metrics.rs similarity index 100% rename from gears/system/api-gateway/src/middleware/http_metrics.rs rename to libs/toolkit-http-middleware/src/http_metrics.rs diff --git a/libs/toolkit-http-middleware/src/lib.rs b/libs/toolkit-http-middleware/src/lib.rs index 1f0687d0f..ddcd1ef19 100644 --- a/libs/toolkit-http-middleware/src/lib.rs +++ b/libs/toolkit-http-middleware/src/lib.rs @@ -14,17 +14,47 @@ //! plane) or a [`toolkit_security::PlatformSecurityContext`] (platform plane), //! rejecting invalid credentials with canonical RFC 9457 `problem+json` //! responses. +//! - [`policy`] — the [`RouteAuthPolicy`] abstraction that decides, per +//! `(method, path)`, whether the tenant-plane middleware requires a JWT, plus +//! the built-in [`MatchitRouteAuthPolicy`] most gears can use directly. +//! - [`common`] — shared, transport-agnostic helpers: RFC 6750 +//! `WWW-Authenticate` challenge rendering and nested-path resolution. //! - [`security`] — the supporting extractors that pull the tenant-plane bearer //! token and the platform-plane internal token out of inbound request headers. +//! - [`request_id`] — `X-Request-Id` generation/propagation into extensions. +//! - [`access_log`] — structured per-request access log with byte counting. +//! - [`http_metrics`] — OpenTelemetry HTTP server metrics. A no-op meter is used +//! until the consumer installs a meter provider, so it is near-zero cost. +//! - [`mime_validation`] — per-route `Content-Type` allow-listing. +//! - [`license_validation`] — per-route feature-entitlement gating. +//! - [`rate_limit`] — per-route token-bucket + in-flight limiting. +//! - [`scope_enforcement`] — coarse-grained, pre-PDP token-scope checks. +//! +//! The last four operate on lookup tables/rules the consuming gear builds from +//! its own operation specs and config, and render rejections under a +//! caller-supplied GTS scope (via [`toolkit_canonical_errors::CanonicalError`]'s +//! `scoped_*` constructors) so this crate stays free of any gear-specific error +//! identity, config, or spec type. //! //! Keeping these out of `toolkit-http` (the outbound HTTP client) keeps the //! client crate free of `axum` and the canonical-error stack, and gives every //! gear a single place to depend on for the server-side auth planes. +pub mod access_log; pub mod auth; +pub mod common; +pub mod http_metrics; +pub mod license_validation; +pub mod mime_validation; +pub mod policy; +pub mod rate_limit; +pub mod request_id; +pub mod scope_enforcement; pub mod security; -pub use auth::{PublicRoute, internal_auth_middleware, security_context_middleware}; +pub use auth::{SecurityContextLayerState, internal_auth_middleware, security_context_middleware}; +pub use common::{BearerChallenge, append_bearer_challenge, resolve_path}; +pub use policy::{AuthRequirement, MatchitRouteAuthPolicy, RouteAuthPolicy}; pub use security::{ InternalTokenHttpError, SecurityContextHttpError, extract_bearer_http, extract_internal_token_http, diff --git a/gears/system/api-gateway/src/middleware/license_validation.rs b/libs/toolkit-http-middleware/src/license_validation.rs similarity index 54% rename from gears/system/api-gateway/src/middleware/license_validation.rs rename to libs/toolkit-http-middleware/src/license_validation.rs index 3d483f181..a6ce23bbd 100644 --- a/gears/system/api-gateway/src/middleware/license_validation.rs +++ b/libs/toolkit-http-middleware/src/license_validation.rs @@ -1,39 +1,50 @@ -use axum::extract::Request; +//! License (feature-entitlement) validation middleware. +//! +//! The [`LicenseRequirementMap`] (`(method, path)` → required feature names) is +//! built by the consuming gear from its operation specs; this crate owns the +//! runtime type and the request-time middleware. Rejections are rendered under a +//! caller-supplied GTS `scope`. + +use std::sync::Arc; + +use axum::extract::{Request, State}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use dashmap::DashMap; use http::Method; -use std::sync::Arc; -use toolkit::api::OperationSpec; +use toolkit_canonical_errors::CanonicalError; -use crate::middleware::common; -use crate::middleware::errors::ApiGatewayRouteError; +use crate::common; -const BASE_FEATURE: &str = "gts.cf.core.lic.feat.v1~cf.core.global.base.v1"; +/// The always-available base feature; a route requiring only this needs no +/// additional entitlement. +pub const BASE_FEATURE: &str = "gts.cf.core.lic.feat.v1~cf.core.global.base.v1"; type LicenseKey = (Method, String); -#[derive(Clone)] +/// Per-route required-feature lookup, plus the GTS `scope` under which +/// rejections are rendered. +#[derive(Clone, Default)] pub struct LicenseRequirementMap { requirements: Arc>>, + scope: &'static str, } impl LicenseRequirementMap { + /// Build the map from `(method, path)` → required feature-name pairs, rendering + /// rejections under `scope`. #[must_use] - pub fn from_specs(specs: &[OperationSpec]) -> Self { + pub fn from_pairs( + scope: &'static str, + pairs: impl IntoIterator)>, + ) -> Self { let requirements = DashMap::new(); - - for spec in specs { - if let Some(req) = spec.license_requirement.as_ref() { - requirements.insert( - (spec.method.clone(), spec.path.clone()), - req.license_names.clone(), - ); - } + for (key, features) in pairs { + requirements.insert(key, features); } - Self { requirements: Arc::new(requirements), + scope, } } @@ -44,8 +55,11 @@ impl LicenseRequirementMap { } } +/// License validation middleware. Rejects with a canonical `permission_denied` +/// Problem (`reason` = `LICENSE_FEATURE_REQUIRED`) under `scope` when the route +/// requires a non-base feature. pub async fn license_validation_middleware( - map: LicenseRequirementMap, + State(map): State, req: Request, next: Next, ) -> Response { @@ -68,7 +82,7 @@ pub async fn license_validation_middleware( // `instance` / `trace_id` are filled by the canonical error // middleware (`toolkit::api::canonical_error_middleware`) on the way // out — this middleware sits inside its layer. - return ApiGatewayRouteError::permission_denied() + return CanonicalError::scoped_permission_denied(map.scope) .with_reason("LICENSE_FEATURE_REQUIRED") .create() .into_response(); diff --git a/gears/system/api-gateway/src/middleware/mime_validation.rs b/libs/toolkit-http-middleware/src/mime_validation.rs similarity index 50% rename from gears/system/api-gateway/src/middleware/mime_validation.rs rename to libs/toolkit-http-middleware/src/mime_validation.rs index 9c678a47f..f62adf92a 100644 --- a/gears/system/api-gateway/src/middleware/mime_validation.rs +++ b/libs/toolkit-http-middleware/src/mime_validation.rs @@ -1,39 +1,64 @@ -//! MIME type validation middleware for enforcing per-operation allowed Content-Type headers -use axum::extract::Request; +//! MIME type validation middleware for enforcing per-operation allowed +//! `Content-Type` headers. +//! +//! The [`MimeValidationMap`] (a `(method, path)` → allowed-types lookup) is +//! built by the consuming gear from its operation specs; this crate owns the +//! runtime type and the request-time middleware. Rejections are rendered under a +//! caller-supplied GTS `scope` so the consumer keeps ownership of its error +//! identity. + +use std::sync::Arc; + +use axum::extract::{Request, State}; use axum::middleware::Next; use axum::response::{IntoResponse, Response}; use dashmap::DashMap; use http::Method; -use std::sync::Arc; +use toolkit_canonical_errors::CanonicalError; -use toolkit::api::OperationSpec; +use crate::common; -use crate::middleware::common; -use crate::middleware::errors::ApiGatewayGatewayError; +type MimeKey = (Method, String); -/// Map from (method, path) to allowed content types -pub type MimeValidationMap = Arc>>; - -/// Build MIME validation map from operation specs -#[must_use] -pub fn build_mime_validation_map(specs: &[OperationSpec]) -> MimeValidationMap { - let map = DashMap::new(); - - for spec in specs { - if let Some(ref allowed) = spec.allowed_request_content_types { - let key = (spec.method.clone(), spec.path.clone()); +/// Per-route allowed-`Content-Type` lookup, plus the GTS `scope` under which +/// rejections are rendered. +#[derive(Clone, Default)] +pub struct MimeValidationMap { + allowed: Arc>>, + scope: &'static str, +} - map.insert(key, allowed.clone()); +impl MimeValidationMap { + /// Build the map from `(method, path)` → allowed-content-type pairs, rendering + /// rejections under `scope`. + #[must_use] + pub fn from_pairs( + scope: &'static str, + pairs: impl IntoIterator)>, + ) -> Self { + let allowed = DashMap::new(); + for (key, types) in pairs { + allowed.insert(key, types); + } + Self { + allowed: Arc::new(allowed), + scope, } } - Arc::new(map) + /// Look up the allowed content types configured for `(method, path)`, if any. + #[must_use] + pub fn get(&self, method: &Method, path: &str) -> Option> { + self.allowed + .get(&(method.clone(), path.to_owned())) + .map(|v| v.value().clone()) + } } -/// Extract and normalize the Content-Type header value. +/// Extract and normalize the `Content-Type` header value. /// -/// Strips parameters like charset from "application/json; charset=utf-8" -/// to just "application/json". +/// Strips parameters like charset from `application/json; charset=utf-8` +/// to just `application/json`. fn extract_content_type(req: &Request) -> Option { let ct_header = req.headers().get(http::header::CONTENT_TYPE)?; let ct_str = ct_header.to_str().ok()?; @@ -42,11 +67,13 @@ fn extract_content_type(req: &Request) -> Option { } /// Create a canonical `invalid_argument` Problem response for an unsupported -/// or missing Content-Type. Routed through the gateway umbrella scope -/// because `invalid_argument` is only available on `#[resource_error]` -/// scopes (no top-level `CanonicalError::*` constructor for this category). -fn create_unsupported_media_type_error(detail: String, reason: &str) -> Response { - let err = ApiGatewayGatewayError::invalid_argument() +/// or missing Content-Type, under the caller-supplied GTS `scope`. +fn create_unsupported_media_type_error( + scope: &'static str, + detail: String, + reason: &str, +) -> Response { + let err = CanonicalError::scoped_invalid_argument(scope) .with_field_violation("content-type", detail, reason) .create(); err.into_response() @@ -54,8 +81,9 @@ fn create_unsupported_media_type_error(detail: String, reason: &str) -> Response /// Validate that the content type is in the allowed list. /// -/// Returns Ok(()) if allowed, Err(Response) with error details if not. +/// Returns `Ok(())` if allowed, `Err(Response)` with error details if not. fn validate_content_type( + scope: &'static str, content_type: &str, allowed_types: &[&str], method: &Method, @@ -80,20 +108,21 @@ fn validate_content_type( ); Err(Box::new(create_unsupported_media_type_error( + scope, detail, "UNSUPPORTED_MEDIA_TYPE", ))) } -/// MIME validation middleware +/// MIME validation middleware. /// -/// Checks the Content-Type header against the allowed types configured -/// for the operation. Returns 400 Bad Request with a canonical -/// `invalid_argument` Problem (`field_violations[0].reason` = -/// `UNSUPPORTED_MEDIA_TYPE` or `MISSING_CONTENT_TYPE`) if the content -/// type is not allowed. +/// Checks the `Content-Type` header against the allowed types configured for the +/// operation. Returns 400 Bad Request with a canonical `invalid_argument` +/// Problem (`field_violations[0].reason` = `UNSUPPORTED_MEDIA_TYPE` or +/// `MISSING_CONTENT_TYPE`) if the content type is not allowed. Rejections are +/// rendered under `scope`. pub async fn mime_validation_middleware( - validation_map: MimeValidationMap, + State(validation_map): State, req: Request, next: Next, ) -> Response { @@ -107,17 +136,19 @@ pub async fn mime_validation_middleware( let path = common::resolve_path(&req, path.as_str()); // Check if this operation has MIME validation configured - let Some(allowed_types) = validation_map.get(&(method.clone(), path.clone())) else { + let Some(allowed_types) = validation_map.get(&method, &path) else { // No validation configured - proceed return next.run(req).await; }; + let scope = validation_map.scope; + // Extract and validate Content-Type header let Some(content_type) = extract_content_type(&req) else { tracing::warn!( method = %method, path = %path, - allowed_types = ?allowed_types.value(), + allowed_types = ?allowed_types, "Missing Content-Type header for endpoint with MIME validation" ); @@ -125,12 +156,12 @@ pub async fn mime_validation_middleware( "Missing Content-Type header. Allowed types: {}", allowed_types.join(", ") ); - return create_unsupported_media_type_error(detail, "MISSING_CONTENT_TYPE"); + return create_unsupported_media_type_error(scope, detail, "MISSING_CONTENT_TYPE"); }; // Validate the content type if let Err(error_response) = - validate_content_type(&content_type, &allowed_types, &method, &path) + validate_content_type(scope, &content_type, &allowed_types, &method, &path) { return *error_response; } @@ -142,50 +173,6 @@ pub async fn mime_validation_middleware( #[cfg(test)] #[cfg_attr(coverage_nightly, coverage(off))] mod tests { - use super::*; - use toolkit::api::operation_builder::VendorExtensions; - - #[test] - fn test_build_mime_validation_map() { - use toolkit::api::operation_builder::{RequestBodySchema, RequestBodySpec}; - - let specs = vec![OperationSpec { - method: Method::POST, - path: "/files/v1/upload".to_owned(), - operation_id: None, - summary: None, - description: None, - tags: vec![], - params: vec![], - request_body: Some(RequestBodySpec { - content_type: "multipart/form-data", - description: None, - schema: RequestBodySchema::MultipartFile { - field_name: "file".to_owned(), - }, - required: true, - }), - responses: vec![], - handler_id: "test".to_owned(), - authenticated: false, - is_public: false, - license_requirement: None, - rate_limit: None, - allowed_request_content_types: Some(vec!["multipart/form-data", "application/pdf"]), - vendor_extensions: VendorExtensions::default(), - }]; - - let map = build_mime_validation_map(&specs); - - assert!(map.contains_key(&(Method::POST, "/files/v1/upload".to_owned()))); - let allowed = map - .get(&(Method::POST, "/files/v1/upload".to_owned())) - .unwrap(); - assert_eq!(allowed.len(), 2); - assert!(allowed.contains(&"multipart/form-data")); - assert!(allowed.contains(&"application/pdf")); - } - #[test] fn test_content_type_parameter_stripping() { // Test the logic for stripping parameters from Content-Type diff --git a/libs/toolkit-http-middleware/src/policy.rs b/libs/toolkit-http-middleware/src/policy.rs new file mode 100644 index 000000000..3eef9db70 --- /dev/null +++ b/libs/toolkit-http-middleware/src/policy.rs @@ -0,0 +1,234 @@ +//! Route authentication policy abstraction. +//! +//! [`security_context_middleware`](crate::auth::security_context_middleware) is +//! transport- and gear-agnostic: it does not know which routes require a tenant +//! JWT. That decision is delegated to a [`RouteAuthPolicy`] supplied via Axum +//! state at the gear/bootstrap layer. +//! +//! This replaces the earlier binary `PublicRoute` request-extension marker with +//! a per-`(method, path)` policy, matching the api-gateway's mature model: a +//! gear can mark routes public, mark them authenticated, and choose a default +//! for unmatched routes — all without touching this crate. + +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use http::Method; + +/// Whether a route requires tenant-plane authentication. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum AuthRequirement { + /// No authentication required (public route). The middleware inserts an + /// anonymous [`SecurityContext`](toolkit_security::SecurityContext) and + /// passes the request through. + None, + /// Authentication required. A missing or invalid bearer token is rejected. + Required, +} + +/// Resolves the [`AuthRequirement`] for an inbound request. +/// +/// Injected into +/// [`security_context_middleware`](crate::auth::security_context_middleware) via +/// Axum state at the gear/bootstrap layer. Most consumers can use the built-in +/// [`MatchitRouteAuthPolicy`]; implement this trait directly only for bespoke +/// policies. Kept object-safe so it can be shared as `Arc`. +pub trait RouteAuthPolicy: Send + Sync { + /// Resolve the authentication requirement for a `(method, path)` pair. The + /// `path` is the gear-local path (already stripped of any nesting prefix by + /// [`resolve_path`](crate::common::resolve_path)). + fn resolve(&self, method: &Method, path: &str) -> AuthRequirement; +} + +/// A [`RouteAuthPolicy`] backed by per-method [`matchit`] routers. +/// +/// Routes are classified into two explicit sets — **authenticated** (require a +/// tenant JWT) and **public** (exempt) — with everything else governed by +/// `require_auth_by_default`. A path that matches both sets is treated as +/// **public** (an explicit public route always wins over a broad authenticated +/// pattern such as a `/{*rest}` fallback). +/// +/// Build one with [`from_route_sets`](Self::from_route_sets). This is the +/// default policy for any gear serving HTTP (the api-gateway and the `OoP` +/// bootstrap HTTP server both use it); implement [`RouteAuthPolicy`] directly +/// only if you need different matching semantics. +#[derive(Clone)] +pub struct MatchitRouteAuthPolicy { + authenticated: Arc>>, + public: Arc>>, + require_auth_by_default: bool, +} + +impl MatchitRouteAuthPolicy { + /// Build the policy from explicit authenticated/public `(method, path)` + /// route sets. `require_auth_by_default` decides the requirement for routes + /// that match neither set. + /// + /// Route patterns use `matchit` syntax (`{id}`, `{*rest}`); literal `:` in a + /// segment is matched literally. + /// + /// # Errors + /// Returns an error if a route pattern cannot be inserted into the matcher. + pub fn from_route_sets( + authenticated_routes: HashSet<(Method, String)>, + public_routes: HashSet<(Method, String)>, + require_auth_by_default: bool, + ) -> Result { + Ok(Self { + authenticated: Arc::new(build_matchers(authenticated_routes)?), + public: Arc::new(build_matchers(public_routes)?), + require_auth_by_default, + }) + } +} + +fn build_matchers( + routes: HashSet<(Method, String)>, +) -> Result>, anyhow::Error> { + let mut by_method: HashMap> = HashMap::new(); + for (method, path) in routes { + let matcher = by_method.entry(method).or_default(); + matcher + .insert(path.as_str(), ()) + .map_err(|e| anyhow::anyhow!("Failed to insert route pattern '{path}': {e}"))?; + } + Ok(by_method) +} + +impl RouteAuthPolicy for MatchitRouteAuthPolicy { + fn resolve(&self, method: &Method, path: &str) -> AuthRequirement { + // Explicit public wins over authenticated (e.g. a `/{*rest}` fallback). + if self.public.get(method).is_some_and(|m| m.at(path).is_ok()) { + return AuthRequirement::None; + } + + if self + .authenticated + .get(method) + .is_some_and(|m| m.at(path).is_ok()) + { + return AuthRequirement::Required; + } + + if self.require_auth_by_default { + AuthRequirement::Required + } else { + AuthRequirement::None + } + } +} + +#[cfg(test)] +#[cfg_attr(coverage_nightly, coverage(off))] +mod tests { + use super::*; + + fn policy( + authenticated: &[(Method, &str)], + public: &[(Method, &str)], + require_auth_by_default: bool, + ) -> MatchitRouteAuthPolicy { + let to_set = |routes: &[(Method, &str)]| { + routes + .iter() + .map(|(m, p)| (m.clone(), (*p).to_owned())) + .collect::>() + }; + MatchitRouteAuthPolicy::from_route_sets( + to_set(authenticated), + to_set(public), + require_auth_by_default, + ) + .unwrap() + } + + #[test] + fn literal_colon_paths_are_not_treated_as_params() { + // Must not error: literal `:` segments are valid matchit routes. + policy( + &[(Method::GET, "events:poll"), (Method::GET, "events:stream")], + &[], + false, + ); + } + + #[test] + fn explicit_public_route_with_path_params_returns_none() { + let p = policy(&[], &[(Method::GET, "/users/{id}")], true); + assert_eq!(p.resolve(&Method::GET, "/users/42"), AuthRequirement::None); + } + + #[test] + fn explicit_public_route_exact_match_returns_none() { + let p = policy(&[], &[(Method::GET, "/health")], true); + assert_eq!(p.resolve(&Method::GET, "/health"), AuthRequirement::None); + } + + #[test] + fn explicit_authenticated_route_returns_required() { + let p = policy(&[(Method::GET, "/admin/metrics")], &[], false); + assert_eq!( + p.resolve(&Method::GET, "/admin/metrics"), + AuthRequirement::Required + ); + } + + #[test] + fn unmatched_route_follows_require_auth_by_default() { + assert_eq!( + policy(&[], &[], true).resolve(&Method::POST, "/unknown"), + AuthRequirement::Required + ); + assert_eq!( + policy(&[], &[], false).resolve(&Method::POST, "/unknown"), + AuthRequirement::None + ); + } + + #[test] + fn public_route_overrides_require_auth_by_default() { + let p = policy(&[], &[(Method::GET, "/public")], true); + assert_eq!(p.resolve(&Method::GET, "/public"), AuthRequirement::None); + } + + #[test] + fn authenticated_route_has_priority_over_default() { + let p = policy(&[(Method::GET, "/users/{id}")], &[], false); + assert_eq!( + p.resolve(&Method::GET, "/users/123"), + AuthRequirement::Required + ); + } + + #[test] + fn explicit_public_overrides_wildcard_authenticated_fallback() { + let p = policy( + &[(Method::GET, "/{*rest}")], + &[(Method::GET, "/v1/auth/config")], + true, + ); + assert_eq!( + p.resolve(&Method::GET, "/v1/auth/config"), + AuthRequirement::None, + "explicit public must win over wildcard authenticated fallback" + ); + assert_eq!( + p.resolve(&Method::GET, "/some/other/path"), + AuthRequirement::Required, + "wildcard authenticated still applies to non-public paths" + ); + } + + #[test] + fn different_methods_resolve_independently() { + let p = policy(&[(Method::GET, "/user-management/v1/users")], &[], false); + assert_eq!( + p.resolve(&Method::GET, "/user-management/v1/users"), + AuthRequirement::Required + ); + assert_eq!( + p.resolve(&Method::POST, "/user-management/v1/users"), + AuthRequirement::None + ); + } +} diff --git a/gears/system/api-gateway/src/middleware/rate_limit.rs b/libs/toolkit-http-middleware/src/rate_limit.rs similarity index 68% rename from gears/system/api-gateway/src/middleware/rate_limit.rs rename to libs/toolkit-http-middleware/src/rate_limit.rs index 7f2fb7ebd..e81590e37 100644 --- a/gears/system/api-gateway/src/middleware/rate_limit.rs +++ b/libs/toolkit-http-middleware/src/rate_limit.rs @@ -1,31 +1,66 @@ -use crate::config::ApiGatewayConfig; +//! Per-route rate limiting and in-flight limiting middleware. +//! +//! The [`RateLimiterMap`] (`(method, path)` → token bucket + in-flight +//! semaphore) is built by the consuming gear from its operation specs and +//! configuration; this crate owns the runtime type and the request-time +//! middleware. The rate-limit rejection is rendered under a caller-supplied GTS +//! `scope`. + +use std::collections::HashMap; +use std::num::NonZeroU32; +use std::sync::Arc; + use anyhow::{Context, Result, anyhow}; use axum::http::{HeaderValue, Method, header}; use axum::{ - extract::Request, + extract::{Request, State}, middleware::Next, response::{IntoResponse, Response}, }; use governor::clock::Clock; use governor::middleware::StateInformationMiddleware; use governor::{DefaultDirectRateLimiter, Quota, RateLimiter}; -use std::collections::HashMap; -use std::num::NonZeroU32; -use std::sync::Arc; +use serde::{Deserialize, Serialize}; use tokio::sync::Semaphore; use toolkit_canonical_errors::CanonicalError; -use crate::middleware::common; -use crate::middleware::errors::ApiGatewayGatewayError; +use crate::common; + +/// Deserializable rate-limit parameters (requests/sec, burst capacity, and +/// max concurrent in-flight requests). Consumers typically use this as the +/// per-gear fallback applied to routes that declare no explicit limit. +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields, default)] +pub struct RateLimitConfig { + /// Sustained requests per second. + pub rps: u32, + /// Maximum burst capacity. + pub burst: u32, + /// Maximum concurrent in-flight requests. + pub in_flight: u32, +} + +impl Default for RateLimitConfig { + fn default() -> Self { + Self { + rps: 50, + burst: 100, + in_flight: 64, + } + } +} type RateLimitKey = (Method, String); type BucketMap = Arc>>; type InflightMap = Arc>>; +/// Per-route rate-limit + in-flight lookup, plus the GTS `scope` under which +/// rejections are rendered. #[derive(Default, Clone)] pub struct RateLimiterMap { buckets: BucketMap, inflight: InflightMap, + scope: &'static str, } struct BucketMapEntry { @@ -35,7 +70,7 @@ struct BucketMapEntry { } impl BucketMapEntry { - pub fn new(rps: u32, burst: u32) -> Result { + fn new(rps: u32, burst: u32) -> Result { let bucket = RateLimiter::direct( Quota::per_second(NonZeroU32::new(rps).with_context(|| anyhow!("rps is zero"))?) .allow_burst(NonZeroU32::new(burst).with_context(|| anyhow!("burst is zero"))?), @@ -52,44 +87,44 @@ impl BucketMapEntry { } impl RateLimiterMap { + /// Build from `(key, `[`RateLimitConfig`]`)` pairs, rendering rejections under + /// `scope`. + /// /// # Errors - /// Returns an error if any rate limit spec is 0. - pub fn from_specs( - specs: &Vec, - cfg: &ApiGatewayConfig, + /// Returns an error if any `rps` or `burst` is 0. + pub fn from_pairs( + scope: &'static str, + pairs: impl IntoIterator, ) -> Result { let mut buckets = HashMap::new(); let mut inflight = HashMap::new(); - // TODO: Add support for per-route rate limiting - for spec in specs { - let (rps, burst, max_in_flight) = spec.rate_limit.as_ref().map_or( - ( - cfg.defaults.rate_limit.rps, - cfg.defaults.rate_limit.burst, - cfg.defaults.rate_limit.in_flight, - ), - |r| (r.rps, r.burst, r.in_flight), - ); - - let key = (spec.method.clone(), spec.path.clone()); + for (key, cfg) in pairs { buckets.insert( key.clone(), Arc::new( - BucketMapEntry::new(rps, burst) - .with_context(|| anyhow!("RateLimit spec invalid {spec:?} invalid"))?, + BucketMapEntry::new(cfg.rps, cfg.burst) + .with_context(|| anyhow!("RateLimit entry invalid for {key:?}"))?, ), ); - inflight.insert(key, Arc::new(Semaphore::new(max_in_flight as usize))); + inflight.insert(key, Arc::new(Semaphore::new(cfg.in_flight as usize))); } Ok(Self { buckets: Arc::new(buckets), inflight: Arc::new(inflight), + scope, }) } } // TODO: Use tower-governor instead of own implementation (upd: https://github.com/benwis/tower-governor/issues/59 ) -pub async fn rate_limit_middleware(map: RateLimiterMap, mut req: Request, next: Next) -> Response { +/// Rate-limit + in-flight middleware. Emits a canonical `resource_exhausted` +/// Problem under `scope` when the token bucket is exhausted, and a scope-free +/// `service_unavailable` when the in-flight limit is reached. +pub async fn rate_limit_middleware( + State(map): State, + mut req: Request, + next: Next, +) -> Response { let method = req.method().clone(); // Use MatchedPath extension (set by Axum router) for accurate route matching let path = req @@ -123,7 +158,7 @@ pub async fn rate_limit_middleware(map: RateLimiterMap, mut req: Request, next: log_rate_limit_exceeded(&key, wait_secs); let policy = bucker_map_entry.policy.clone(); let burst = bucker_map_entry.burst.clone(); - let err = ApiGatewayGatewayError::resource_exhausted("rate limit exceeded") + let err = CanonicalError::scoped_resource_exhausted(map.scope) .with_quota_violation("rate_limit", format!("retry_after_seconds={wait_secs}")) .create(); let mut response = err.into_response(); diff --git a/gears/system/api-gateway/src/middleware/request_id.rs b/libs/toolkit-http-middleware/src/request_id.rs similarity index 71% rename from gears/system/api-gateway/src/middleware/request_id.rs rename to libs/toolkit-http-middleware/src/request_id.rs index db1b985f2..bea921946 100644 --- a/gears/system/api-gateway/src/middleware/request_id.rs +++ b/libs/toolkit-http-middleware/src/request_id.rs @@ -1,15 +1,25 @@ +//! `X-Request-Id` generation and propagation. +//! +//! [`MakeReqId`] generates a fresh id for requests that arrive without one; +//! [`push_req_id_to_extensions`] copies the (incoming or generated) id into +//! request extensions as [`XRequestId`] so handlers can read it, and records it +//! on the current tracing span. + use axum::http::{HeaderName, Request}; use axum::{body::Body, middleware::Next, response::Response}; use tower_http::request_id::{MakeRequestId, RequestId}; +/// The request id carried in request extensions for handler access. #[derive(Clone, Debug)] pub struct XRequestId(pub String); +/// The `x-request-id` header name. #[must_use] pub fn header() -> HeaderName { HeaderName::from_static("x-request-id") } +/// [`MakeRequestId`] implementation that mints a fresh id per request. #[derive(Clone, Default)] pub struct MakeReqId; diff --git a/gears/system/api-gateway/src/middleware/scope_enforcement.rs b/libs/toolkit-http-middleware/src/scope_enforcement.rs similarity index 75% rename from gears/system/api-gateway/src/middleware/scope_enforcement.rs rename to libs/toolkit-http-middleware/src/scope_enforcement.rs index ef9bd92f6..93c4734b6 100644 --- a/gears/system/api-gateway/src/middleware/scope_enforcement.rs +++ b/libs/toolkit-http-middleware/src/scope_enforcement.rs @@ -1,21 +1,71 @@ -//! Gateway Scope Enforcement Middleware +//! Gateway-style scope enforcement middleware. //! //! Performs coarse-grained early rejection of requests based on token scopes -//! without calling the PDP. This is an optimization for performance-critical routes. +//! without calling the PDP — an optimization for performance-critical routes. //! -//! See `docs/arch/authorization/DESIGN.md` section "Gateway Scope Enforcement" for details. +//! The consuming gear supplies the compiled rules (built from its own +//! configuration via [`ScopeEnforcementRules::from_rules`]) and the GTS `scope` +//! used to render `permission_denied` rejections, so this crate stays free of +//! any gear-specific config or error identity. use std::sync::Arc; use axum::response::IntoResponse; use glob::{MatchOptions, Pattern}; +use serde::{Deserialize, Serialize}; -use crate::config::RoutePoliciesConfig; -use crate::middleware::common; -use crate::middleware::errors::ApiGatewayRouteError; use toolkit_canonical_errors::CanonicalError; use toolkit_security::SecurityContext; +use crate::common; + +/// A single scope enforcement rule (the deserializable config unit and the +/// input to [`ScopeEnforcementRules::from_rules`]). +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct ScopeRule { + /// Glob path pattern (`*` = one segment, `**` = any depth), e.g. `/admin/**`. + pub path: String, + /// HTTP method to match. `None` (omitted) matches any method. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub method: Option, + /// Required scopes; the request passes if the token has ANY of these. + /// Must not be empty. + pub required_scopes: Vec, +} + +/// Deserializable configuration for the scope enforcement middleware. +/// +/// Enables coarse-grained early rejection of requests based on token scopes +/// without calling the PDP — an optimization for performance-critical routes. +/// +/// # Example YAML +/// +/// ```yaml +/// route_policies: +/// enabled: true +/// rules: +/// - path: "/admin/**" +/// required_scopes: ["admin"] +/// - path: "/events/v1/*" +/// required_scopes: ["read:events", "write:events"] # any of these +/// ``` +/// +/// # Behavior +/// +/// - Rules are evaluated in declaration order (first match wins) +/// - If `token_scopes: ["*"]` → always pass (first-party app) +/// - If `token_scopes` contains any of `required_scopes` → pass +/// - Otherwise → 403 Forbidden (before PDP call) +#[derive(Clone, Debug, Default, Deserialize, Serialize)] +#[serde(deny_unknown_fields, default)] +pub struct ScopeEnforcementConfig { + /// Whether scope enforcement is enabled. + pub enabled: bool, + /// Rules evaluated in declaration order (first match wins). + pub rules: Vec, +} + /// Compiled scope enforcement rules for efficient runtime matching. #[derive(Clone, Debug)] pub struct ScopeEnforcementRules { @@ -23,6 +73,8 @@ pub struct ScopeEnforcementRules { rules: Arc<[CompiledRule]>, /// Whether scope enforcement is enabled. enabled: bool, + /// GTS scope used to render `permission_denied` rejections. + scope: &'static str, } /// A single compiled rule: glob pattern + optional method + required scopes. @@ -35,22 +87,47 @@ struct CompiledRule { } impl ScopeEnforcementRules { - /// Build scope enforcement rules from configuration. + /// Build compiled rules from a [`ScopeEnforcementConfig`]. + /// + /// `scope` is the GTS resource-type under which `permission_denied` + /// rejections are rendered. + /// + /// # Errors + /// + /// Returns an error if any glob pattern is invalid or if any rule has empty + /// `required_scopes`. + pub fn from_config( + scope: &'static str, + config: &ScopeEnforcementConfig, + ) -> Result { + Self::from_rules(scope, config.enabled, config.rules.iter().cloned()) + } + + /// Build compiled rules from neutral [`ScopeRule`]s. + /// + /// `scope` is the GTS resource-type under which `permission_denied` + /// rejections are rendered. /// /// # Errors /// - /// Returns an error if any glob pattern is invalid or if any rule has empty `required_scopes`. - pub fn from_config(config: &RoutePoliciesConfig) -> Result { - if !config.enabled { + /// Returns an error if any glob pattern is invalid or if any rule has empty + /// `required_scopes`. + pub fn from_rules( + scope: &'static str, + enabled: bool, + rules: impl IntoIterator, + ) -> Result { + if !enabled { return Ok(Self { rules: Arc::from([]), enabled: false, + scope, }); } - let mut rules = Vec::with_capacity(config.rules.len()); + let mut compiled = Vec::new(); - for rule in &config.rules { + for rule in rules { // Validate: empty required_scopes is likely a config mistake if rule.required_scopes.is_empty() { return Err(anyhow::anyhow!( @@ -67,7 +144,7 @@ impl ScopeEnforcementRules { ) })?; - rules.push(CompiledRule { + compiled.push(CompiledRule { pattern, method: rule.method.as_ref().map(|m| m.to_uppercase()), required_scopes: rule.required_scopes.clone(), @@ -75,14 +152,15 @@ impl ScopeEnforcementRules { } tracing::info!( - rules_count = rules.len(), + rules_count = compiled.len(), "Route policy enforcement enabled with {} rules", - rules.len() + compiled.len() ); Ok(Self { - rules: Arc::from(rules), + rules: Arc::from(compiled), enabled: true, + scope, }) } @@ -165,7 +243,7 @@ impl ScopeEnforcementRules { "Route policy enforcement denied: insufficient scopes" ); - return Err(ApiGatewayRouteError::permission_denied() + return Err(CanonicalError::scoped_permission_denied(self.scope) .with_reason("INSUFFICIENT_SCOPES") .create()); } @@ -176,12 +254,6 @@ impl ScopeEnforcementRules { } } -/// Scope enforcement middleware state. -#[derive(Clone)] -pub struct ScopeEnforcementState { - pub rules: ScopeEnforcementRules, -} - /// Scope enforcement middleware. /// /// Checks if the request's token scopes satisfy the configured requirements @@ -189,12 +261,12 @@ pub struct ScopeEnforcementState { /// /// This middleware MUST run AFTER the auth middleware (which populates `SecurityContext`). pub async fn scope_enforcement_middleware( - axum::extract::State(state): axum::extract::State, + axum::extract::State(rules): axum::extract::State, req: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { // Skip if enforcement is disabled - if !state.rules.enabled { + if !rules.enabled { return next.run(req).await; } @@ -209,7 +281,7 @@ pub async fn scope_enforcement_middleware( // No SecurityContext means auth middleware didn't run or request is unauthenticated. // If the path matches a protected route, reject with 401 Unauthorized. // Otherwise, let it pass through for public/unprotected routes. - if state.rules.matches_protected_route(&path, method) { + if rules.matches_protected_route(&path, method) { tracing::warn!( path = %path, method = %method, @@ -230,10 +302,7 @@ pub async fn scope_enforcement_middleware( }; // Check scopes - if let Err(canonical) = state - .rules - .check(&path, method, security_context.token_scopes()) - { + if let Err(canonical) = rules.check(&path, method, security_context.token_scopes()) { // `instance` / `trace_id` are filled by the canonical error // middleware (`toolkit::api::canonical_error_middleware`) on the // way out — this middleware sits inside its layer. @@ -250,35 +319,35 @@ pub async fn scope_enforcement_middleware( #[cfg_attr(coverage_nightly, coverage(off))] mod tests { use super::*; - use crate::config::RoutePolicyRule; use toolkit_canonical_errors::Problem; - fn build_config(enabled: bool, routes: Vec<(&str, Vec<&str>)>) -> RoutePoliciesConfig { - build_config_with_methods( - enabled, - routes.into_iter().map(|(p, s)| (p, None, s)).collect(), - ) + /// Test GTS scope (any valid `&'static str`; not asserted on the wire). + const TEST_SCOPE: &str = "gts.cf.core.api_gateway.route.v1~"; + + fn rules(routes: Vec<(&str, Vec<&str>)>) -> Vec { + rules_with_methods(routes.into_iter().map(|(p, s)| (p, None, s)).collect()) } type TestRoute<'a> = (&'a str, Option<&'a str>, Vec<&'a str>); - fn build_config_with_methods(enabled: bool, routes: Vec>) -> RoutePoliciesConfig { - let rules = routes + fn rules_with_methods(routes: Vec>) -> Vec { + routes .into_iter() - .map(|(path, method, scopes)| RoutePolicyRule { + .map(|(path, method, scopes)| ScopeRule { path: path.to_owned(), method: method.map(String::from), required_scopes: scopes.into_iter().map(String::from).collect(), }) - .collect(); + .collect() + } - RoutePoliciesConfig { enabled, rules } + fn compiled(enabled: bool, routes: Vec<(&str, Vec<&str>)>) -> ScopeEnforcementRules { + ScopeEnforcementRules::from_rules(TEST_SCOPE, enabled, rules(routes)).unwrap() } #[test] fn disabled_enforcement_always_passes() { - let config = build_config(false, vec![("/admin/*", vec!["admin"])]); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); + let rules = compiled(false, vec![("/admin/*", vec!["admin"])]); // Even with no scopes, should pass when disabled assert!(rules.check("/admin/users", "GET", &[]).is_ok()); @@ -286,8 +355,7 @@ mod tests { #[test] fn first_party_app_always_passes() { - let config = build_config(true, vec![("/admin/*", vec!["admin"])]); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); + let rules = compiled(true, vec![("/admin/*", vec!["admin"])]); // First-party apps have ["*"] scope let scopes = vec!["*".to_owned()]; @@ -296,8 +364,7 @@ mod tests { #[test] fn matching_scope_passes() { - let config = build_config(true, vec![("/admin/*", vec!["admin"])]); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); + let rules = compiled(true, vec![("/admin/*", vec!["admin"])]); let scopes = vec!["admin".to_owned()]; assert!(rules.check("/admin/users", "GET", &scopes).is_ok()); @@ -305,11 +372,10 @@ mod tests { #[test] fn any_of_required_scopes_passes() { - let config = build_config( + let rules = compiled( true, vec![("/events/v1/*", vec!["read:events", "write:events"])], ); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); // Having just one of the required scopes should pass let scopes = vec!["read:events".to_owned()]; @@ -321,8 +387,7 @@ mod tests { #[test] fn missing_scope_returns_forbidden() { - let config = build_config(true, vec![("/admin/*", vec!["admin"])]); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); + let rules = compiled(true, vec![("/admin/*", vec!["admin"])]); // No matching scope let scopes = vec!["read:events".to_owned()]; @@ -341,8 +406,7 @@ mod tests { #[test] fn empty_scopes_returns_forbidden() { - let config = build_config(true, vec![("/admin/*", vec!["admin"])]); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); + let rules = compiled(true, vec![("/admin/*", vec!["admin"])]); // Empty scopes = no permissions (fail-closed) let result = rules.check("/admin/users", "GET", &[]); @@ -359,8 +423,7 @@ mod tests { #[test] fn unmatched_route_passes() { - let config = build_config(true, vec![("/admin/*", vec!["admin"])]); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); + let rules = compiled(true, vec![("/admin/*", vec!["admin"])]); // Route doesn't match any pattern, should pass even with unrelated scope let scopes = vec!["unrelated:scope".to_owned()]; @@ -369,8 +432,7 @@ mod tests { #[test] fn glob_single_star_matches_single_segment_only() { - let config = build_config(true, vec![("/api/*/items", vec!["api:read"])]); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); + let rules = compiled(true, vec![("/api/*/items", vec!["api:read"])]); let scopes = vec!["api:read".to_owned()]; @@ -389,8 +451,7 @@ mod tests { #[test] fn glob_double_star_matches_multiple_segments() { - let config = build_config(true, vec![("/api/**", vec!["api:access"])]); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); + let rules = compiled(true, vec![("/api/**", vec!["api:access"])]); let scopes = vec!["api:access".to_owned()]; @@ -406,15 +467,18 @@ mod tests { #[test] fn invalid_glob_pattern_returns_error() { - let config = build_config(true, vec![("/admin/[invalid", vec!["admin"])]); - let result = ScopeEnforcementRules::from_config(&config); + let result = ScopeEnforcementRules::from_rules( + TEST_SCOPE, + true, + rules(vec![("/admin/[invalid", vec!["admin"])]), + ); assert!(result.is_err()); } #[test] fn empty_required_scopes_returns_error() { - let config = build_config(true, vec![("/admin/*", vec![])]); - let result = ScopeEnforcementRules::from_config(&config); + let result = + ScopeEnforcementRules::from_rules(TEST_SCOPE, true, rules(vec![("/admin/*", vec![])])); let err = result.expect_err("should fail with empty required_scopes"); assert!( err.to_string().contains("empty required_scopes"), @@ -425,14 +489,13 @@ mod tests { #[test] fn multiple_non_overlapping_rules() { // Non-overlapping patterns: each path matches exactly one rule - let config = build_config( + let rules = compiled( true, vec![ ("/admin/*", vec!["admin"]), ("/events/**", vec!["events:read"]), ], ); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); // Admin route needs admin scope let admin_scopes = vec!["admin".to_owned()]; @@ -461,14 +524,13 @@ mod tests { fn overlapping_rules_first_match_wins() { // Path /api/admin/users matches BOTH rules with DIFFERENT scope requirements. // First-match-wins: only the first matching rule is evaluated. - let config = build_config( + let rules = compiled( true, vec![ ("/api/**", vec!["basic"]), // Matches /api/admin/users, requires "basic" ("/api/admin/**", vec!["admin"]), // Also matches, requires "admin" (never evaluated) ], ); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); // /api/admin/users matches both rules, but first rule wins let basic_scopes = vec!["basic".to_owned()]; @@ -495,8 +557,7 @@ mod tests { #[test] fn matches_protected_route_returns_true_for_matching_path() { - let config = build_config(true, vec![("/admin/*", vec!["admin"])]); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); + let rules = compiled(true, vec![("/admin/*", vec!["admin"])]); assert!(rules.matches_protected_route("/admin/users", "GET")); assert!(rules.matches_protected_route("/admin/settings", "POST")); @@ -504,8 +565,7 @@ mod tests { #[test] fn matches_protected_route_returns_false_for_non_matching_path() { - let config = build_config(true, vec![("/admin/*", vec!["admin"])]); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); + let rules = compiled(true, vec![("/admin/*", vec!["admin"])]); assert!(!rules.matches_protected_route("/public/health", "GET")); assert!(!rules.matches_protected_route("/api/v1/users", "GET")); @@ -513,8 +573,7 @@ mod tests { #[test] fn matches_protected_route_returns_false_when_disabled() { - let config = build_config(false, vec![("/admin/*", vec!["admin"])]); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); + let rules = compiled(false, vec![("/admin/*", vec!["admin"])]); // Even matching paths return false when enforcement is disabled assert!(!rules.matches_protected_route("/admin/users", "GET")); @@ -523,14 +582,13 @@ mod tests { #[test] fn first_match_wins_more_specific_rule_first() { // More specific rule declared first should take precedence - let config = build_config( + let rules = compiled( true, vec![ ("/api/admin/**", vec!["admin"]), // More specific, declared first ("/api/**", vec!["basic"]), // Broader, declared second ], ); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); // /api/admin/users matches first rule, requires "admin" let admin_scopes = vec!["admin".to_owned()]; @@ -557,14 +615,13 @@ mod tests { #[test] fn first_match_wins_broader_rule_first() { // If broader rule is declared first, it takes precedence - let config = build_config( + let rules = compiled( true, vec![ ("/api/**", vec!["basic"]), // Broader, declared first ("/api/admin/**", vec!["admin"]), // More specific, declared second (never reached) ], ); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); let basic_scopes = vec!["basic".to_owned()]; @@ -579,14 +636,15 @@ mod tests { #[test] fn method_matching_specific_method() { // Rule with specific method only matches that method - let config = build_config_with_methods( + let rules = ScopeEnforcementRules::from_rules( + TEST_SCOPE, true, - vec![ + rules_with_methods(vec![ ("/users/*", Some("POST"), vec!["users:write"]), ("/users/*", Some("GET"), vec!["users:read"]), - ], - ); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); + ]), + ) + .unwrap(); let read_scopes = vec!["users:read".to_owned()]; let write_scopes = vec!["users:write".to_owned()]; @@ -608,8 +666,12 @@ mod tests { #[test] fn method_matching_any_method() { // Rule without method matches any method - let config = build_config_with_methods(true, vec![("/api/**", None, vec!["api:access"])]); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); + let rules = ScopeEnforcementRules::from_rules( + TEST_SCOPE, + true, + rules_with_methods(vec![("/api/**", None, vec!["api:access"])]), + ) + .unwrap(); let scopes = vec!["api:access".to_owned()]; @@ -623,9 +685,12 @@ mod tests { #[test] fn method_matching_case_insensitive() { // Method matching should be case-insensitive - let config = - build_config_with_methods(true, vec![("/api/**", Some("get"), vec!["api:read"])]); - let rules = ScopeEnforcementRules::from_config(&config).unwrap(); + let rules = ScopeEnforcementRules::from_rules( + TEST_SCOPE, + true, + rules_with_methods(vec![("/api/**", Some("get"), vec!["api:read"])]), + ) + .unwrap(); let scopes = vec!["api:read".to_owned()];