Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 12 additions & 5 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE_MANIFEST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion docs/toolkit_unified_system/00_gear_overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 3 additions & 7 deletions gears/system/api-gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand All @@ -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 }
Expand Down
75 changes: 75 additions & 0 deletions gears/system/api-gateway/src/authn_adapter.rs
Original file line number Diff line number Diff line change
@@ -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<dyn AuthNResolverClient>,
}

impl AuthNResolverBearerAdapter {
/// Wrap an [`AuthNResolverClient`] as a [`BearerAuthenticator`].
#[must_use]
pub fn new(client: Arc<dyn AuthNResolverClient>) -> Self {
Self { client }
}
}

impl BearerAuthenticator for AuthNResolverBearerAdapter {
async fn authenticate(&self, token: &str) -> Result<SecurityContext, AuthNError> {
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}"),
}
}
73 changes: 8 additions & 65 deletions gears/system/api-gateway/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down Expand Up @@ -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<RoutePolicyRule>,
}

/// 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<String>,
/// Required scopes for this route. Request passes if token has ANY of these scopes.
/// Must not be empty.
pub required_scopes: Vec<String>,
// 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;
52 changes: 52 additions & 0 deletions gears/system/api-gateway/src/errors.rs
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading
Loading