diff --git a/docs/filters/external_metering.md b/docs/filters/external_metering.md new file mode 100644 index 0000000000..d5c33227d8 --- /dev/null +++ b/docs/filters/external_metering.md @@ -0,0 +1,13 @@ + + + +# `external_metering` + +Strips tenant identity headers and client credentials from metered inference requests before they reach the upstream provider. + +## Example + +```yaml +filter: external_metering +identity_header_prefix: "x-tenant-" +``` diff --git a/docs/filters/reference.md b/docs/filters/reference.md index 71b72a1078..562b05d772 100644 --- a/docs/filters/reference.md +++ b/docs/filters/reference.md @@ -61,6 +61,12 @@ see the [Praxis core filter reference][core-ref]. |--------|-------------| | [`model_to_header`](model_to_header.md) | Promotes the JSON `"model"` field from the request body to a request header. | +### Metering + +| Filter | Description | +|--------|-------------| +| [`external_metering`](external_metering.md) | Strips tenant identity headers and client credentials from metered inference requests before they reach the upstream provider. | + ### Prompt Enrich | Filter | Description | diff --git a/examples/README.md b/examples/README.md index a1a668a5e5..c77c4f45a3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,6 +24,7 @@ before sending requests. | [a2a-task-routing.yaml](configs/a2a-task-routing.yaml) | Captures task and context ownership from SendMessage JSON responses and SendStreamingMessage / SubscribeToTask SSE responses, then routes follow-up requests back to the backend cluster that created the task or owns the context | | [ai-inference-body-based-routing.yaml](configs/ai-inference-body-based-routing.yaml) | Routes LLM API requests to different backends based on the `model` field in the JSON request body | | [credential-injection.yaml](configs/credential-injection.yaml) | Injects per-cluster API credentials into upstream requests and strips client-provided credentials to prevent forwarding | +| [external-metering.yaml](configs/external-metering.yaml) | Strips tenant identity headers and client credentials from metered inference requests before they reach the upstream provider | | [json-rpc-routing.yaml](configs/json-rpc-routing.yaml) | Routes JSON-RPC 2.0 requests to different backends based on the "method" field in the JSON request body | | [mcp-classifier-routing.yaml](configs/mcp-classifier-routing.yaml) | Routes MCP requests by body-derived method and tool name | | [mcp-stateless-broker.yaml](configs/mcp-stateless-broker.yaml) | Configurable stateless MCP broker using the 2026-07-28 release candidate profile | diff --git a/examples/configs/external-metering.yaml b/examples/configs/external-metering.yaml new file mode 100644 index 0000000000..88cce67a66 --- /dev/null +++ b/examples/configs/external-metering.yaml @@ -0,0 +1,45 @@ +# External Metering +# +# Strips tenant identity headers and client credentials from metered +# inference requests before they reach the upstream provider. +# +# Tenant headers are trusted input produced by an authenticating layer in +# front of the proxy. Forwarding them upstream would leak tenant attribution, +# and letting a client set them itself would allow tenant impersonation, so +# the filter removes every header carrying the configured prefix along with +# any client-supplied credential. +# +# Usage: +# cargo run -p praxis-ai-proxy -- -c examples/configs/external-metering.yaml +# curl -X POST http://localhost:8080/v1/chat/completions \ +# -H "Content-Type: application/json" \ +# -H "x-tenant-username: alice" \ +# -H "x-tenant-group: engineering" \ +# -H "x-tenant-subscription: sub-42" \ +# -d '{"model":"gpt-4","messages":[{"role":"user","content":"hi"}]}' +# +# The upstream receives the request without x-tenant-* , authorization, +# or x-api-key headers. + +listeners: + - name: default + address: "127.0.0.1:8080" + filter_chains: + - main + +filter_chains: + - name: main + filters: + - filter: router + routes: + - path_prefix: "/" + cluster: backend + + - filter: external_metering + identity_header_prefix: "x-tenant-" + + - filter: load_balancer + clusters: + - name: backend + endpoints: + - "127.0.0.1:3000" diff --git a/filters/src/lib.rs b/filters/src/lib.rs index df59ffedc4..b151c3fbc1 100644 --- a/filters/src/lib.rs +++ b/filters/src/lib.rs @@ -11,12 +11,14 @@ pub mod agentic; pub mod guardrails; pub mod inference; +pub mod metering; pub mod prompt_enrich; mod token_usage; pub use agentic::{a2a::A2aFilter, mcp::McpFilter}; pub use guardrails::AiGuardrailsFilter; pub use inference::ModelToHeaderFilter; +pub use metering::ExternalMeteringFilter; pub use prompt_enrich::PromptEnrichFilter; pub use token_usage::{TokenCountFilter, TokenUsageHeadersFilter}; diff --git a/filters/src/metering/config.rs b/filters/src/metering/config.rs new file mode 100644 index 0000000000..0a0eef9215 --- /dev/null +++ b/filters/src/metering/config.rs @@ -0,0 +1,40 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Deserialized YAML configuration types for the external metering filter. + +use praxis_filter::FilterError; +use serde::Deserialize; + +/// Default header prefix for tenant identity headers. +const DEFAULT_IDENTITY_HEADER_PREFIX: &str = "x-tenant-"; + +/// Deserialized YAML config for the `external_metering` filter. +/// +/// ```yaml +/// filter: external_metering +/// identity_header_prefix: "x-tenant-" +/// ``` +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +pub(super) struct ExternalMeteringConfig { + /// Prefix for tenant identity headers to strip. + /// Expected headers: `{prefix}username`, `{prefix}group`, + /// `{prefix}subscription`, `{prefix}model`. + #[serde(default = "default_identity_header_prefix")] + pub identity_header_prefix: String, +} + +/// Validate config at construction time. +pub(super) fn validate_config(cfg: &ExternalMeteringConfig) -> Result<(), FilterError> { + if cfg.identity_header_prefix.is_empty() { + return Err("external_metering: identity_header_prefix must not be empty".into()); + } + + Ok(()) +} + +/// Serde default for `identity_header_prefix`. +fn default_identity_header_prefix() -> String { + DEFAULT_IDENTITY_HEADER_PREFIX.to_owned() +} diff --git a/filters/src/metering/mod.rs b/filters/src/metering/mod.rs new file mode 100644 index 0000000000..df920dc998 --- /dev/null +++ b/filters/src/metering/mod.rs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! External metering filter: tenant identity handling for metered inference. +//! +//! Removes the tenant identity headers and the client credentials from a +//! request before it reaches the upstream provider. Tenant headers are trusted +//! input from an authenticating layer in front of the proxy, so they must never +//! be forwarded: an upstream that echoes or logs them would leak tenant +//! attribution, and a client that sets them itself must not be able to +//! impersonate a tenant. + +mod config; + +#[cfg(test)] +#[expect(clippy::allow_attributes, reason = "blanket test suppressions")] +#[allow(clippy::unwrap_used, clippy::expect_used, reason = "tests")] +mod tests; + +use async_trait::async_trait; +use http::header::HeaderName; +use praxis_filter::{FilterAction, FilterError, HttpFilter, HttpFilterContext, parse_filter_config}; + +use self::config::{ExternalMeteringConfig, validate_config}; + +// ----------------------------------------------------------------------------- +// ExternalMeteringFilter +// ----------------------------------------------------------------------------- + +/// Strips tenant identity headers and client credentials from metered +/// inference requests before they reach the upstream provider. +/// +/// # YAML +/// +/// ```yaml +/// filter: external_metering +/// identity_header_prefix: "x-tenant-" +/// ``` +pub struct ExternalMeteringFilter { + /// Prefix of the tenant identity headers to strip. + identity_header_prefix: String, +} + +impl ExternalMeteringFilter { + /// Create from parsed YAML config. + /// + /// # Errors + /// + /// Returns [`FilterError`] if config parsing or validation fails. + pub fn from_config(config: &serde_yaml::Value) -> Result, FilterError> { + Ok(Box::new(Self::build(config)?)) + } + + /// Build the concrete filter from parsed YAML config. + fn build(config: &serde_yaml::Value) -> Result { + let cfg: ExternalMeteringConfig = parse_filter_config("external_metering", config)?; + validate_config(&cfg)?; + + Ok(Self { + identity_header_prefix: cfg.identity_header_prefix, + }) + } +} + +#[async_trait] +impl HttpFilter for ExternalMeteringFilter { + fn name(&self) -> &'static str { + "external_metering" + } + + async fn on_request(&self, ctx: &mut HttpFilterContext<'_>) -> Result { + strip_identity_headers(ctx, &self.identity_header_prefix); + strip_client_credentials(ctx); + + Ok(FilterAction::Continue) + } +} + +// ----------------------------------------------------------------------------- +// Header Removal +// ----------------------------------------------------------------------------- + +/// Mark every header carrying the tenant identity prefix for removal. +fn strip_identity_headers(ctx: &mut HttpFilterContext<'_>, prefix: &str) { + let prefix_lower = prefix.to_ascii_lowercase(); + + for key in ctx.request.headers.keys() { + if key.as_str().to_ascii_lowercase().starts_with(prefix_lower.as_str()) { + ctx.request_headers_to_remove.push(key.clone()); + } + } +} + +/// Mark client-supplied credentials for removal. +/// +/// The proxy authenticates to the provider with its own credentials, so a +/// client-supplied key is never useful upstream and forwarding one would let a +/// client bill an account the gateway does not control. +fn strip_client_credentials(ctx: &mut HttpFilterContext<'_>) { + ctx.request_headers_to_remove.push(http::header::AUTHORIZATION); + + if let Ok(name) = "x-api-key".parse::() { + ctx.request_headers_to_remove.push(name); + } +} diff --git a/filters/src/metering/tests.rs b/filters/src/metering/tests.rs new file mode 100644 index 0000000000..c222c0f1f4 --- /dev/null +++ b/filters/src/metering/tests.rs @@ -0,0 +1,127 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +use super::*; +use crate::test_utils::{make_filter_context, make_request}; + +// ----------------------------------------------------------------------------- +// Config Parsing +// ----------------------------------------------------------------------------- + +#[test] +fn default_config_parses() { + let filter = filter_from_yaml("{}"); + + assert_eq!(filter.name(), "external_metering"); + assert_eq!(filter.identity_header_prefix, "x-tenant-"); +} + +#[test] +fn custom_prefix_parses() { + let filter = filter_from_yaml("identity_header_prefix: \"x-myco-\"\n"); + + assert_eq!(filter.identity_header_prefix, "x-myco-"); +} + +#[test] +fn config_empty_prefix_fails() { + let yaml: serde_yaml::Value = serde_yaml::from_str("identity_header_prefix: \"\"\n").unwrap(); + + assert!(ExternalMeteringFilter::from_config(&yaml).is_err()); +} + +#[test] +fn config_unknown_field_fails() { + let yaml: serde_yaml::Value = serde_yaml::from_str("not_a_real_field: true\n").unwrap(); + + assert!(ExternalMeteringFilter::from_config(&yaml).is_err()); +} + +// ----------------------------------------------------------------------------- +// Header Removal +// ----------------------------------------------------------------------------- + +#[tokio::test] +async fn strips_tenant_and_credential_headers() { + let mut req = make_request(http::Method::POST, "/v1/chat/completions"); + req.headers.insert("x-tenant-username", "alice".parse().unwrap()); + req.headers.insert("x-tenant-group", "engineering".parse().unwrap()); + req.headers.insert("authorization", "Bearer redacted".parse().unwrap()); + let mut ctx = make_filter_context(&req); + + let action = filter_from_yaml("{}").on_request(&mut ctx).await.unwrap(); + + assert!(matches!(action, FilterAction::Continue)); + let removed = removed_headers(&ctx); + assert!(removed.contains(&"x-tenant-username")); + assert!(removed.contains(&"x-tenant-group")); + assert!(removed.contains(&"authorization")); + assert!(removed.contains(&"x-api-key")); +} + +#[tokio::test] +async fn strips_tenant_headers_under_a_custom_prefix() { + let mut req = make_request(http::Method::POST, "/v1/chat/completions"); + req.headers.insert("x-myco-username", "bob".parse().unwrap()); + let mut ctx = make_filter_context(&req); + + let _action = filter_from_yaml("identity_header_prefix: \"x-myco-\"\n") + .on_request(&mut ctx) + .await + .unwrap(); + + assert!(removed_headers(&ctx).contains(&"x-myco-username")); +} + +#[tokio::test] +async fn leaves_unrelated_headers_in_place() { + let mut req = make_request(http::Method::POST, "/v1/chat/completions"); + req.headers.insert("x-tenant-username", "alice".parse().unwrap()); + req.headers.insert("content-type", "application/json".parse().unwrap()); + let mut ctx = make_filter_context(&req); + + let _action = filter_from_yaml("{}").on_request(&mut ctx).await.unwrap(); + + assert!(!removed_headers(&ctx).contains(&"content-type")); +} + +#[tokio::test] +async fn header_matching_ignores_case() { + let mut req = make_request(http::Method::POST, "/v1/chat/completions"); + req.headers.insert("X-Tenant-Username", "alice".parse().unwrap()); + let mut ctx = make_filter_context(&req); + + let _action = filter_from_yaml("identity_header_prefix: \"X-Tenant-\"\n") + .on_request(&mut ctx) + .await + .unwrap(); + + assert!(removed_headers(&ctx).contains(&"x-tenant-username")); +} + +#[tokio::test] +async fn credentials_are_stripped_even_without_tenant_headers() { + let req = make_request(http::Method::POST, "/v1/chat/completions"); + let mut ctx = make_filter_context(&req); + + let _action = filter_from_yaml("{}").on_request(&mut ctx).await.unwrap(); + + let removed = removed_headers(&ctx); + assert!(removed.contains(&"authorization")); + assert!(removed.contains(&"x-api-key")); +} + +// ----------------------------------------------------------------------------- +// Test Utilities +// ----------------------------------------------------------------------------- + +/// Build the concrete filter from a YAML fragment. +fn filter_from_yaml(yaml: &str) -> ExternalMeteringFilter { + let parsed: serde_yaml::Value = serde_yaml::from_str(yaml).unwrap(); + ExternalMeteringFilter::build(&parsed).unwrap() +} + +/// Names of the headers the filter marked for removal. +fn removed_headers<'ctx>(ctx: &'ctx HttpFilterContext<'_>) -> Vec<&'ctx str> { + ctx.request_headers_to_remove.iter().map(HeaderName::as_str).collect() +} diff --git a/server/src/lib.rs b/server/src/lib.rs index 8fbc723816..3de097f4d9 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -83,6 +83,10 @@ fn register_general_ai_filters(registry: &mut praxis_filter::FilterRegistry) { @register registry, http "ai_guardrails" => praxis_ai_filters::AiGuardrailsFilter::from_config ); + praxis_filter::register_filters!( + @register registry, + http "external_metering" => praxis_ai_filters::ExternalMeteringFilter::from_config + ); praxis_filter::register_filters!( @register registry, http "model_to_header" => praxis_ai_filters::ModelToHeaderFilter::from_config diff --git a/tests/integration/tests/suite/examples/external_metering.rs b/tests/integration/tests/suite/examples/external_metering.rs new file mode 100644 index 0000000000..5bf5fda1d1 --- /dev/null +++ b/tests/integration/tests/suite/examples/external_metering.rs @@ -0,0 +1,124 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Praxis Contributors + +//! Tests for the external metering example configuration. + +use std::collections::HashMap; + +use praxis_test_utils::{free_port, http_send, parse_body, parse_status, start_header_echo_backend}; + +// ----------------------------------------------------------------------------- +// Config Parsing +// ----------------------------------------------------------------------------- + +#[test] +fn external_metering_config_parses() { + let config = super::load_example_config( + "external-metering.yaml", + 29800, + HashMap::from([("127.0.0.1:3000", 29801_u16)]), + ); + + assert_eq!(config.listeners.len(), 1, "should have 1 listener"); +} + +// ----------------------------------------------------------------------------- +// Header Removal +// ----------------------------------------------------------------------------- + +#[test] +fn external_metering_strips_tenant_and_credential_headers() { + let backend_guard = start_header_echo_backend(); + let proxy_port = free_port(); + + let config = super::load_example_config( + "external-metering.yaml", + proxy_port, + HashMap::from([("127.0.0.1:3000", backend_guard.port())]), + ); + + let proxy = praxis_test_utils::start_proxy(&config); + let raw = http_send( + proxy.addr(), + "POST /v1/chat/completions HTTP/1.1\r\n\ + Host: localhost\r\n\ + x-tenant-username: alice\r\n\ + x-tenant-group: engineering\r\n\ + Authorization: Bearer redacted-client-token\r\n\ + x-api-key: redacted-client-key\r\n\ + Connection: close\r\n\r\n", + ); + + assert_eq!(parse_status(&raw), 200, "should proxy successfully"); + let body = parse_body(&raw); + assert!( + !body.contains("x-tenant-username"), + "tenant header should be stripped from upstream: {body}" + ); + assert!( + !body.contains("x-tenant-group"), + "tenant header should be stripped from upstream: {body}" + ); + assert!( + !body.contains("redacted-client-token"), + "authorization should be stripped from upstream: {body}" + ); + assert!( + !body.contains("redacted-client-key"), + "x-api-key should be stripped from upstream: {body}" + ); +} + +#[test] +fn external_metering_forwards_unrelated_headers() { + let backend_guard = start_header_echo_backend(); + let proxy_port = free_port(); + + let config = super::load_example_config( + "external-metering.yaml", + proxy_port, + HashMap::from([("127.0.0.1:3000", backend_guard.port())]), + ); + + let proxy = praxis_test_utils::start_proxy(&config); + let raw = http_send( + proxy.addr(), + "POST /v1/chat/completions HTTP/1.1\r\n\ + Host: localhost\r\n\ + x-tenant-username: alice\r\n\ + x-request-trace: keep-me\r\n\ + Connection: close\r\n\r\n", + ); + + assert_eq!(parse_status(&raw), 200, "should proxy successfully"); + assert!( + parse_body(&raw).contains("x-request-trace"), + "unrelated headers should reach the upstream" + ); +} + +#[test] +fn external_metering_proxies_requests_without_tenant_headers() { + let backend_guard = start_header_echo_backend(); + let proxy_port = free_port(); + + let config = super::load_example_config( + "external-metering.yaml", + proxy_port, + HashMap::from([("127.0.0.1:3000", backend_guard.port())]), + ); + + let proxy = praxis_test_utils::start_proxy(&config); + let raw = http_send( + proxy.addr(), + "GET / HTTP/1.1\r\n\ + Host: localhost\r\n\ + Connection: close\r\n\r\n", + ); + + assert_eq!( + parse_status(&raw), + 200, + "should proxy when no tenant headers are present" + ); +} diff --git a/tests/integration/tests/suite/examples/mod.rs b/tests/integration/tests/suite/examples/mod.rs index e4bbacd0ed..a48b0adeba 100644 --- a/tests/integration/tests/suite/examples/mod.rs +++ b/tests/integration/tests/suite/examples/mod.rs @@ -10,6 +10,7 @@ pub use test_utils::load_example_config; mod agentic_routing; mod anthropic_messages; mod credential_injection; +mod external_metering; mod full_flow; mod mcp_broker; mod model_to_header;