Skip to content

feat(routing): fence intelligent_route candidates via entitlement claim - #1283

Open
hexfusion wants to merge 1 commit into
praxis-proxy:mainfrom
hexfusion:feat/candidate-claim-gating
Open

hexfusion wants to merge 1 commit into
praxis-proxy:mainfrom
hexfusion:feat/candidate-claim-gating

Conversation

@hexfusion

@hexfusion hexfusion commented Sep 22, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Adds a generic, config-driven fence to intelligent_route. Candidates carry a labels map, and match_claims keeps only candidates whose label equals a claim on the authenticated identity, before the model-name pick. Adding an entitlement dimension (residency, tier, sovereignty) is configuration, not code.

It fails closed. When a request carries no authenticated identity, is missing the gated claim, or matches no in-scope candidate, the gateway denies it rather than routing. A fenced-out capability returns the same 404 as an unknown one, so a caller cannot enumerate what it is fenced out of. Session-affinity reuse and weighted selection both respect the fence. The ungated path (no match_claims) is unchanged and allocates nothing new. It reuses the AuthenticatedIdentity extension token_rate_limit already reads, so it adds no new plumbing.

filters:
  - filter: intelligent_route
    model_header: X-Model
    match_claims:
      - { claim: grid_region, label: region }   # residency fence; a missing claim denies
    candidates:
      - { kind: inference_model, name: qwen, cluster: site-us, labels: { region: us-east-1 } }
      - { kind: inference_model, name: qwen, cluster: site-eu, labels: { region: eu-west-1 } }

Matching is exact-equality only. Richer operators (range, hierarchy, set membership) are out of scope and can be added later without breaking this config.

Granting a user an entitlement

The fence holds no per-user state. The entitlement rides in the caller's token as a claim, sourced from the IdP (for example a Keycloak user attribute mapped into the JWT). alice's token carries:

{ "sub": "alice", "grid_region": "eu-west-1", "tier": "gold" }

The gateway validates the token, lands grid_region on the authenticated identity, and match_claims keeps only candidates whose region label equals it, so alice routes to the eu-west-1 site. Granting or changing alice's residency is an IdP change (set the attribute), never a gateway change. A second dimension is the same move: the IdP adds grid_tier, and the config adds { claim: grid_tier, label: tier } plus a tier label on candidates.

Related issue

Closes #1282

Validation

  • cargo test -p praxis-ai-filters --lib routing:: gives 405 passed. Covers matching, unlabeled-candidate exclusion, no-eligible-member, weighted-draws-stay-in-scope, validation bounds, fail-closed-without-identity, and the ungated-unchanged path.
  • Example config intelligent-route-claim-gating.yaml plus a fail-closed integration test.
  • make lint clean. Commits are signed with a Signed-off-by trailer.

Breaking changes

None. match_claims and labels default empty, so existing configs route exactly as before.

…laim

Add generic claim-gated candidate selection. Candidates carry a `labels`
map; the filter carries `match_claims: [{claim, label}]`. Per request the
filter reads the claim off the authenticated identity and keeps only
candidates whose matching label equals it, so a request is never routed
outside the caller's entitlement (data residency, tier, and so on).

The gate reuses the AuthenticatedIdentity extension the policy engine
already publishes, so no new plumbing is added. It fails closed: a gate
with no identity or a missing claim denies. A fenced-out capability
returns the same 404 as an unknown one, so a caller cannot enumerate the
capabilities it is fenced out of. The ungated path is unchanged and
allocates nothing new; a gate also fences session-affinity reuse and
weighted selection, so no draw lands out of scope.

Both static and overlay candidate modes carry the label, so a fleet
registry can drive the fence through the hot-reloaded overlay.

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
@hexfusion
hexfusion requested review from a team and crstrn13 September 22, 2026 03:25
@hexfusion hexfusion changed the title feat(routing): fence intelligent_route candidates by an entitlement claim feat(routing): fence intelligent_route candidates via entitlement claim Sep 22, 2026

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

Findings

[MAJOR][Defect] Gated round-robin can starve candidates — picker.rs:128
Different entitlement cohorts share one counter. Common interleavings can make a cohort continually select only one eligible backend.

[MINOR][Defect] Request claims are cloned on every gated request — intelligent_route.rs:560
Borrowed label/value pairs can satisfy the synchronous comparison without copying request-derived strings.

[MINOR][Defect] JWT documentation incorrectly says reserved claims never resolve — intelligent_route.md:81
The pinned mapper’s claims.include can restore registered or promoted claims into AuthenticatedIdentity::custom_claims().

Missing proof

[MAJOR] No successful identity-gated or overlay-backed end-to-end test — intelligent_route_claim_gating.rs:49
The example contains no identity producer and can only return 403. Its test would pass even if matching, successful routing, or overlay-label propagation were broken.

return Err(format!("intelligent_route: match_claims exceeds maximum of {MAX_MATCH_CLAIMS}").into());
}
for gate in &gates {
if gate.claim.trim().is_empty() || gate.label.trim().is_empty() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

.trim() catches whitespace-only strings, but the stored value keeps its whitespace. claim: " grid_region " passes validation yet never matches the JWT's "grid_region" at runtime, silently denying every request. Let's drop the .trim() to match validate_name's pattern, or trim-and-store. Worth adding a length bound on claim/label too, the way validate_labels caps at MAX_LABEL_LEN.

@hexfusion

Copy link
Copy Markdown
Contributor Author

I think I would like to make this concept more general to start

@araujof

araujof commented Oct 9, 2026

Copy link
Copy Markdown
Member

@hexfusion PPE already has the policy mechanism for this, but the use case is not fully supported end to end. In PPE, an llm: route can emit restrict: { allow_regions: claim.grid_region, on_empty: deny }. PPE resolves the verified claim and produces a typed candidate constraint; its matcher excludes candidates without the required region. See PPE’s restriction contract and claim resolution.

The missing piece is in the host: the Praxis policy filter does not pass PPE’s candidate_constraint to routing, and Praxis AI’s intelligent_route does not consume it. PR 1283 implements a separate claim check inside the router. Its example has no identity producer, and its integration test proves only that every request gets a 403.

I’d steer the PR toward wiring PPE’s constraint into candidate selection, including affinity reuse, then add an authenticated test that reaches an eligible backend. One remaining PPE gap matters for the PR’s broader claim: restrict.custom takes literal values, so arbitrary claim → custom label matching would need a PPE extension. Region matching is already expressible; we could extend it to express the complete generic fence.

Do you want to recast the issue/PR based on the above? We can tag team, review, and prioritize this. This could also be a nice demo for the policy engine in Praxis demos.

cc: @leseb @shaneutt @terylt @alexsnaps

@redhat-chai-bot redhat-chai-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.

Adversarial Review — feat(routing): fence intelligent_route via entitlement claim

Well-designed entitlement fencing that adds config-driven access control to intelligent_route. Fail-closed design and enumeration-resistant 404 are security-positive choices. Reuses AuthenticatedIdentity cleanly.

Findings

  • M1: Exact-equality matching only — hierarchical entitlements (e.g., us-east-1 matching us-*) are not supported. The PR correctly states "Richer operators are out of scope" but document this limitation prominently.
  • M2: Fenced 404 is indistinguishable from unknown model — good for security, but could confuse operators debugging access issues. Consider a debug-level log when a candidate is fenced out.
  • L1: Documentation concisely explains the fencing model. 👍
  • L2: Reusing AuthenticatedIdentity from token_rate_limit avoids new identity plumbing. Clean design.

Verdict: Recommend addressing outstanding review comments and rebasing before merge.


AI-generated. Review for accuracy.

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

Labels

None yet

Projects

Development

Successfully merging this pull request may close these issues.

feat(routing): configurable entitlement fence for intelligent_route candidates

6 participants