From 643d9db93f1b3bcdabe28f9898f3ad5f5fc34aac Mon Sep 17 00:00:00 2001 From: Noy Itzikowitz Date: Mon, 27 Jul 2026 19:27:15 -0700 Subject: [PATCH 1/2] feat(filter): add external metering filter with identity header handling Adds an `external_metering` HTTP filter that strips tenant identity headers and client credentials from a request before it reaches the upstream provider. Tenant identity headers are trusted input produced by an authenticating layer in front of the proxy. Forwarding them upstream would leak tenant attribution to any provider that echoes or logs them, and would let a client that sets them itself impersonate a tenant. The filter therefore strips them unconditionally on every request rather than only when they parse as well-formed. The credential headers (`authorization`, `x-api-key`) are stripped alongside them, since the gateway terminates client authentication and reaches the upstream with its own provider credentials. Configuration is a single field, `identity_header_prefix`, defaulting to `x-tenant-`, so the filter can sit behind whichever authenticating layer a deployment already runs. Response hooks run in reverse declared order, so the example config declares `external_metering` before `token_count`: on the request pass it runs first and strips the headers, and on the response pass it runs last, after `token_count` has published the token metadata that later PRs in this series will consume. Part of #577. Signed-off-by: Noy Itzikowitz --- docs/filters/external_metering.md | 13 ++ docs/filters/reference.md | 6 + examples/README.md | 1 + examples/configs/external-metering.yaml | 45 +++++++ filters/src/lib.rs | 2 + filters/src/metering/config.rs | 40 ++++++ filters/src/metering/mod.rs | 105 +++++++++++++++ filters/src/metering/tests.rs | 127 ++++++++++++++++++ server/src/lib.rs | 4 + .../tests/suite/examples/external_metering.rs | 124 +++++++++++++++++ tests/integration/tests/suite/examples/mod.rs | 1 + 11 files changed, 468 insertions(+) create mode 100644 docs/filters/external_metering.md create mode 100644 examples/configs/external-metering.yaml create mode 100644 filters/src/metering/config.rs create mode 100644 filters/src/metering/mod.rs create mode 100644 filters/src/metering/tests.rs create mode 100644 tests/integration/tests/suite/examples/external_metering.rs 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 a26173b615..1f8e0bb868 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 2f5c158988..62f2230ed2 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 | | [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 763987bce0..7e50e1379a 100644 --- a/filters/src/lib.rs +++ b/filters/src/lib.rs @@ -12,6 +12,7 @@ pub mod agentic; pub mod guardrails; pub mod inference; +pub mod metering; pub mod prompt_enrich; mod token_count; mod token_usage_headers; @@ -19,6 +20,7 @@ mod token_usage_headers; 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_count::TokenCountFilter; pub use token_usage_headers::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 c510580ec3..694adc9345 100644 --- a/server/src/lib.rs +++ b/server/src/lib.rs @@ -56,6 +56,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 c28c270b3a..38cbc3cb5d 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; From 1bea3367a8242cba42b21dca593985c0f981a490 Mon Sep 17 00:00:00 2001 From: Noy Itzikowitz Date: Tue, 28 Jul 2026 10:13:31 -0700 Subject: [PATCH 2/2] fix(examples): regenerate README table for external metering entry The generated description row was truncated relative to what `cargo xtask sync-example-readme` produces, failing the lint gate. Signed-off-by: Noy Itzikowitz --- examples/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/README.md b/examples/README.md index d5c7508f01..c77c4f45a3 100644 --- a/examples/README.md +++ b/examples/README.md @@ -24,7 +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 | +| [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 |