-
Notifications
You must be signed in to change notification settings - Fork 40
feat(filter): add external metering filter with identity header handling #581
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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-" | ||
| ``` |
| 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" |
| 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() { | ||
| 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() | ||
| } | ||
| 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()) { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] 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 |
||
| 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>() { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] 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); | ||
| } | ||
| } | ||
| 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-"); | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] All 15 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); | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [Medium] Missing test for multi-value tenant headers. A client could send |
||
| 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() | ||
| } | ||
There was a problem hiding this comment.
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 validHeaderNameprefix, e.g.: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.