Skip to content
Open
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
13 changes: 13 additions & 0 deletions docs/filters/external_metering.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<!-- Generated by: cargo xtask generate-filter-docs -->
<!-- Do not edit manually -->

# `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-"
```
6 changes: 6 additions & 0 deletions docs/filters/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
45 changes: 45 additions & 0 deletions examples/configs/external-metering.yaml
Original file line number Diff line number Diff line change
@@ -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"
2 changes: 2 additions & 0 deletions filters/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down
40 changes: 40 additions & 0 deletions filters/src/metering/config.rs
Original file line number Diff line number Diff line change
@@ -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() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] This validates non-empty but not that the prefix consists of valid HTTP header name characters (RFC 7230 tchar). A misconfigured prefix like "x tenant " or "x-tenant\t" would silently match no headers, leaving tenant identity unstripped while the filter reports healthy. Add a validation that the lowercased prefix can form a valid HeaderName prefix, e.g.:

if HeaderName::from_bytes(cfg.identity_header_prefix.as_bytes()).is_err()
    && HeaderName::from_bytes(
        format!("{}test", cfg.identity_header_prefix).as_bytes(),
    ).is_err()
{
    return Err("external_metering: identity_header_prefix contains invalid header name characters".into());
}

Or simply attempt HeaderName::from_bytes(format!("{prefix}x").as_bytes()) and reject on error, since the prefix must be combinable with a suffix to form a valid header name.

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()
}
105 changes: 105 additions & 0 deletions filters/src/metering/mod.rs
Original file line number Diff line number Diff line change
@@ -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<Box<dyn HttpFilter>, FilterError> {
Ok(Box::new(Self::build(config)?))
}

/// Build the concrete filter from parsed YAML config.
fn build(config: &serde_yaml::Value) -> Result<Self, FilterError> {
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<FilterAction, FilterError> {
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()) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] HeaderName::as_str() already returns lowercase (the http crate normalizes header names on construction), so to_ascii_lowercase() on the key is a redundant String allocation per header per request. Additionally, the prefix is immutable after construction, so its lowercase form should be computed once in build() rather than on every request.

Pre-lower the prefix at construction:

Ok(Self {
    identity_header_prefix: cfg.identity_header_prefix.to_ascii_lowercase(),
})

Then simplify the hot-path function to zero allocations:

fn strip_identity_headers(ctx: &mut HttpFilterContext<'_>, prefix: &str) {
    for key in ctx.request.headers.keys() {
        if key.as_str().starts_with(prefix) {
            ctx.request_headers_to_remove.push(key.clone());
        }
    }
}

The header_matching_ignores_case test continues to pass because the http crate already lowercases the header name at insertion and the prefix is now pre-lowered at init.

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::<HeaderName>() {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] "x-api-key".parse::<HeaderName>() is infallible for this input, but if let Ok silently swallows a hypothetical failure. In a credential-stripping function, silent failure to remove a header is a security concern. Replace with HeaderName::from_static("x-api-key") which is compile-time validated and makes the intent unambiguous:

fn strip_client_credentials(ctx: &mut HttpFilterContext<'_>) {
    ctx.request_headers_to_remove.push(http::header::AUTHORIZATION);
    ctx.request_headers_to_remove
        .push(HeaderName::from_static("x-api-key"));
}

ctx.request_headers_to_remove.push(name);
}
}
127 changes: 127 additions & 0 deletions filters/src/metering/tests.rs
Original file line number Diff line number Diff line change
@@ -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-");

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] All 15 assert!/assert_eq! calls in the unit tests omit assertion messages. The workspace enforces missing_assert_message = "deny" (Cargo.toml line 223), and the test module's #[allow] only covers unwrap_used and expect_used — it does not suppress missing_assert_message.

The integration tests correctly include messages on every assertion. The unit tests should follow the same practice for consistent diagnostics on failure.

Example fix for this line:

assert_eq!(filter.name(), "external_metering", "filter name should be external_metering");
assert_eq!(filter.identity_header_prefix, "x-tenant-", "default prefix should be 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);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Medium] Missing test for multi-value tenant headers. A client could send x-tenant-username: alice and x-tenant-username: mallory on the same request. Add a test that inserts duplicate values for the same tenant header key and asserts the key appears in request_headers_to_remove (confirming all values are targeted for removal).

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()
}
4 changes: 4 additions & 0 deletions server/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading