Skip to content

feat(filter): add external metering filter with identity header handling - #581

Open
noyitz wants to merge 3 commits into
praxis-proxy:mainfrom
noyitz:feat/external-metering-identity-headers
Open

feat(filter): add external metering filter with identity header handling#581
noyitz wants to merge 3 commits into
praxis-proxy:mainfrom
noyitz:feat/external-metering-identity-headers

Conversation

@noyitz

@noyitz noyitz commented Jul 28, 2026

Copy link
Copy Markdown

Adds an external_metering HTTP filter that strips tenant identity headers and
client credentials from a request before it reaches the upstream provider.

Part of #577.

Why strip unconditionally

Tenant identity headers are trusted input produced by an authenticating layer in
front of the proxy. They must never reach the upstream, for two reasons:

  • an upstream that echoes or logs them leaks tenant attribution;
  • a client that sets them itself must not be able to impersonate a tenant.

The filter therefore strips them on every request rather than only when they
parse as well-formed. authorization and x-api-key are stripped alongside
them, since the gateway terminates client authentication and reaches the
upstream with its own provider credentials.

Configuration

filter: external_metering
identity_header_prefix: "x-tenant-"   # default

Expected headers under that prefix are {prefix}username, {prefix}group,
{prefix}subscription, and {prefix}model. The prefix is configurable so the
filter can sit behind whichever authenticating layer a deployment already runs.

Ordering in the pipeline

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 identity headers; on the response pass it runs last, after
token_count has published the token metadata that later PRs consume.

Why this is the first of a series

The full metering capability is roughly 1,900 lines. The pr-conventions job
caps a PR at 750 counted additions, and a change that size is not reviewable in
one pass regardless of the gate. It is split into four independently useful,
independently testable PRs:

PR Capability Counted additions
1 (this one) Identity header and credential stripping 278
2 Identity capture, balance check, admission control ~530 (est.)
3 Usage reporting via CloudEvents ~535 (est.)
4 Prompt cache accounting in token_count 413

PR 2 — admission control. Captures the tenant identity into filter state and
calls an external metering service before the request is proxied. Adds
metering_url, timeout_seconds, feature_key, and fail_open. A tenant with
no remaining entitlement is rejected with 429; an unreachable metering service
follows fail_open, defaulting to true so a metering outage degrades to
unmetered service rather than an inference outage.

PR 3 — usage reporting. Emits a CloudEvents 1.0 inference.tokens.used event
after the response completes, and inference.request.error for failed requests.
Reads the counts token_count has already written to filter metadata, so there
is no second extraction path to keep in sync. Fire-and-forget; never blocks the
response.

PR 4 — prompt cache accounting. Disjoint file set, so it can land in any
order relative to PRs 2 and 3.

PRs 2 and 3 build on this one and are sequential.

Tests

  • 9 unit tests in filters/src/metering/tests.rs covering config defaults,
    empty-prefix rejection, unknown-field rejection, prefix matching including
    case-insensitivity, and credential stripping.
  • 4 functional tests in tests/integration/tests/suite/examples/external_metering.rs
    asserting against the example config that identity and credential headers do
    not reach the upstream and that unrelated headers pass through untouched.
  • examples/configs/external-metering.yaml with the full pipeline.

Verification

make build, make test, and make lint all pass on this branch
(3,134 tests, 0 failures).

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 praxis-proxy#577.

Signed-off-by: Noy Itzikowitz <nitzikow@redhat.com>
@noyitz
noyitz requested review from a team and leseb July 28, 2026 02:34
noyitz and others added 2 commits July 27, 2026 19:42
The generated description row was truncated relative to what
`cargo xtask sync-example-readme` produces, failing the lint gate.

Signed-off-by: Noy Itzikowitz <nitzikow@redhat.com>

@praxis-bot praxis-bot left a comment

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.

Review Summary

Clean, well-structured filter with good test coverage and correct conventions. Three medium-severity items found.

Severity Count
Critical 0
Large 0
Medium 3

Findings:

  1. [Medium] strip_client_credentials uses if let Ok for an infallible HeaderName parse, creating a silent-failure pattern for security-critical credential stripping. Use HeaderName::from_static("x-api-key") instead.
  2. [Medium] validate_config only checks for empty prefix but does not validate that the prefix contains valid HTTP header name characters. An invalid prefix (e.g. containing spaces or control characters) would silently match no headers, leaving tenant identity headers unstripped.
  3. [Medium] No test covers multi-value tenant headers (x-tenant-username: alice\r\nx-tenant-username: mallory). Add a unit test verifying all values are marked for removal when a client sends duplicate tenant headers.

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"));
}


/// 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.

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).

@praxis-bot praxis-bot left a comment

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.

Review Summary

Clean, well-structured filter with good test coverage and correct conventions. The prior review covered three medium items; this pass adds one more.

Severity Count
Critical 0
Large 0
Medium 1

Findings:

  1. [Medium] strip_identity_headers performs two unnecessary per-request allocations: lowering HeaderName::as_str() (already lowercase by http crate contract) and lowering the immutable prefix on every call instead of once at construction.

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.

@praxis-bot praxis-bot left a comment

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.

No new findings beyond prior review.

@praxis-bot praxis-bot left a comment

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.

No new findings beyond prior reviews.

Fourth pass confirms the four existing findings (infallible HeaderName parse, prefix validation gap, missing multi-value header test, redundant to_ascii_lowercase) are the substantive items. Code structure, conventions, test coverage, integration tests, documentation, and registry wiring are all correct.

@praxis-bot praxis-bot left a comment

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.

Review Summary

Fifth pass. One new medium finding (assertion messages in unit tests). Four items from prior reviews remain unaddressed in the code.

Severity Count (new) Count (prior, open)
Critical 0 0
Large 0 0
Medium 1 4

New finding:

  1. [Medium] Unit test assertions missing messages (tests.rs). All 15 assert!/assert_eq! calls lack a message string. The workspace enforces missing_assert_message = "deny" (Cargo.toml line 223), and the test module's #[allow] only covers unwrap_used and expect_used. The integration tests correctly include messages on every assertion.

Open prior findings (no code changes since last review):

  1. HeaderName::from_static("x-api-key") instead of fallible .parse() with if let Ok (mod.rs:102)
  2. Prefix validation for valid HTTP header characters (config.rs:30)
  3. Missing multi-value tenant header test (tests.rs)
  4. Redundant per-request to_ascii_lowercase on both key and prefix (mod.rs:88)

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-");

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants