Skip to content

feat(token-rate-limit): key quotas by authenticated subject - #980

Open
nerdalert wants to merge 1 commit into
praxis-proxy:mainfrom
nerdalert:feat/authenticated-subject-token-quota
Open

feat(token-rate-limit): key quotas by authenticated subject#980
nerdalert wants to merge 1 commit into
praxis-proxy:mainfrom
nerdalert:feat/authenticated-subject-token-quota

Conversation

@nerdalert

@nerdalert nerdalert commented Sep 7, 2026

Copy link
Copy Markdown
Member

🔴 Dependency and merge readiness

Praxis PR praxis-proxy/praxis#1108 has merged into Praxis main. This AI PR is therefore ready to leave draft and receive review.

It can merge after all of the following are complete:

  1. Praxis publishes a release containing feat(basic-auth): publish authenticated identity praxis#1108.
  2. AI updates its Praxis dependency and lockfile from 0.5.4 to that released version.
  3. A cross-filter integration test proves that Basic Auth establishes AuthenticatedIdentity and key: authenticated_subject consumes it successfully without any local path or [patch.crates-io] override.

Merging the code before that release would not change existing quota behavior because key: global remains the default. However, Basic Auth with key: authenticated_subject would fail closed with HTTP 401 while AI still resolves Praxis 0.5.4. The dependency bump and integration test are therefore required before merge, not merely before publication.

Summary

Adds an opt-in authenticated_subject key to the token-rate-limit filter. When selected, each verified application or user receives an independent quota while gateway replicas share that subject's budget through the existing Valkey backend.

The default remains global, preserving the current behavior and configuration compatibility.

Cross-repository contract

This complements the broader token-rate-limiting work tracked in #121. It implements one secure bucket-key source; it does not close the epic.

Praxis PR praxis-proxy/praxis#1108 is merged. It makes Basic Auth publish its verified username through the private request-local AuthenticatedIdentity extension. This filter consumes that same authentication-neutral type. Policy/JWT authentication already uses the type, allowing the quota key to be reused by JWT/OIDC/OAuth-backed authentication without coupling token quota to Basic Auth.

The important boundary is that AI never derives quota identity from a caller-controlled header.

Behavior

  • Adds key: authenticated_subject at the token-rate-limit filter level.
  • Keeps key: global as the default.
  • Reads only the trusted request-local AuthenticatedIdentity subject.
  • Fails closed with HTTP 401 when authenticated-subject keying is configured but no verified identity exists.
  • Hashes the subject with SHA-256 and URL-safe base64 before using it in memory, Valkey keys, metadata, or metrics.
  • Uses the same resolved opaque key for reservation and settlement.
  • Retains existing rule matching, quota algorithms, reservation bounds, and backend behavior.

Use case

Three applications can authenticate through the same endpoint and share the same quota rule configuration while receiving separate subject-keyed budgets. Requests for one application share quota state across gateway replicas, but cannot consume another application's capacity. Grid provider selection remains independent and occurs only after quota admission.

Tests and validation

Focused coverage includes:

  • backward-compatible global default;
  • parsing and rejection of key-source values;
  • stable, distinct, opaque subject keys;
  • fail-closed behavior without identity;
  • independent subjects under one rule;
  • shared subject state across filter instances;
  • reservation and settlement using the same key.

The complete Grid qualification passed twice, 9/9 scenarios per run, using this code with the merged Praxis identity producer. The committed branch contains no [patch.crates-io], sibling path dependency, generated evidence, or local build compatibility changes.

Related to #121 and praxis-proxy/grid#101.
Depends on praxis-proxy/praxis#1108.

Companion qualification: praxis-proxy/grid#127.
Companion demo: praxis-proxy/demos#20.

Add an opt-in authenticated_subject quota key that consumes Praxis request-local identity, hashes it into an opaque backend key, and fails closed when verified identity is unavailable. Global keying remains the default.

Signed-off-by: Brent Salisbury <bsalisbu@redhat.com>
@nerdalert
nerdalert force-pushed the feat/authenticated-subject-token-quota branch from 8dd9aa0 to 0a93307 Compare September 9, 2026 01:29
@nerdalert
nerdalert marked this pull request as ready for review September 9, 2026 01:35
@nerdalert
nerdalert requested review from a team and leseb September 9, 2026 01:35

@leseb leseb 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.

need a new praxis core version

@praxis-bot praxis-bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

praxis-bot review: feat(token-rate-limit): key quotas by authenticated subject

Clean, well-structured change. The security model is sound: fail-closed on missing identity, SHA-256 hashing before storage, trusted extension only (never caller-controlled headers). Two findings, both medium.

# Severity File Finding
1 Medium mod.rs Stale doc comment on AdmittedReservation::key
2 Medium tests.rs Missing positive-path unit test for authenticated-subject keying

1. Stale doc comment (not in diff -- body-only note)

AdmittedReservation::key (mod.rs ~L709) still reads:

The budget key this reservation was admitted under (see FALLBACK_KEY -- always that sentinel in this milestone).

After this PR the key can be a subject-derived hash (subject:v1:<base64>), so "always that sentinel" is no longer accurate. Update to reflect that the key is either FALLBACK_KEY (global mode) or a hashed subject (authenticated-subject mode).

2. Missing positive-path unit test

See inline comment.

let mut ctx = crate::test_utils::make_filter_context(&req);

let action = filter.on_request(&mut ctx).await.unwrap();

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Medium -- missing positive-path unit test for authenticated-subject keying.

This test proves the None (fail-closed) path, but no test exercises the Some path: two requests with different AuthenticatedIdentity subjects getting independent budgets through on_request.

ctx.extensions is public, so the test is straightforward:

#[tokio::test]
async fn authenticated_subject_keying_partitions_budgets_by_identity() {
    let yaml = single_rule_yaml_with(
        "key: authenticated_subject",
        "algorithm: sliding_window\nwindow: 1h\ncapacity: 100\nreserved_tokens: 100",
    );
    let filter = TokenRateLimitFilter::from_config(&yaml).unwrap();
    let req = crate::test_utils::make_request(http::Method::POST, "/v1/chat");

    // Subject A exhausts its budget.
    let mut ctx_a = crate::test_utils::make_filter_context(&req);
    ctx_a.extensions.insert(AuthenticatedIdentity::new("app-a"));
    assert!(matches!(filter.on_request(&mut ctx_a).await.unwrap(), FilterAction::Continue));

    let mut ctx_a2 = crate::test_utils::make_filter_context(&req);
    ctx_a2.extensions.insert(AuthenticatedIdentity::new("app-a"));
    assert!(matches!(filter.on_request(&mut ctx_a2).await.unwrap(), FilterAction::Reject(_)));

    // Subject B is independent -- still has full budget.
    let mut ctx_b = crate::test_utils::make_filter_context(&req);
    ctx_b.extensions.insert(AuthenticatedIdentity::new("app-b"));
    assert!(matches!(filter.on_request(&mut ctx_b).await.unwrap(), FilterAction::Continue));
}

This would close the unit-level coverage gap by proving the end-to-end wiring from extension lookup through resolve_key through per-key backend isolation. Adjust the AuthenticatedIdentity constructor to whatever the actual API is.

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.

3 participants