Skip to content

feat(identity): carry workload credential claims into policy and support optional authentication - #117

Draft
hexfusion wants to merge 2 commits into
praxis-proxy:mainfrom
hexfusion:identity-claims
Draft

hexfusion wants to merge 2 commits into
praxis-proxy:mainfrom
hexfusion:identity-claims

Conversation

@hexfusion

Copy link
Copy Markdown
Contributor

Broadens what a policy can key on beyond the end-user subject, and lets the JWT resolver authenticate optionally. Additive to the identity plugins and the extension model. No existing behavior changes by default.

  • WorkloadIdentity gains a claims map and a claim_str accessor, mirroring SubjectExtension, so a policy can read a scalar claim from the workload credential. Strings borrow, numbers and booleans render, and structured claims return None.
  • Reserved credential claims (iss, iat, and the rest) stay out of the copied claims, so a token's protocol fields do not leak into the claim namespace a policy reads.
  • identity-jwt gains a required flag, defaulting to true. When false, the resolver continues on an absent token but still denies a present-but-invalid one, so the default stays fail-closed.

Part of #115

@hexfusion
hexfusion requested a review from a team September 21, 2026 11:54
@hexfusion

Copy link
Copy Markdown
Contributor Author

cc @araujof

WorkloadIdentity gains a claims map and a claim_str accessor mirroring
SubjectExtension, and extract_workload flattens those claims into the bag
under caller_workload.claim.<name> through the same walker as subject and
client claims. Registered JWT claims (iss, iat, and the rest) and the
structured-field sources stay reserved, so a token's protocol fields never
reach the claim namespace a policy reads. The configured mapper carries only
its explicitly mapped fields.

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
A required flag (default true, fail closed) turns the resolver into an
optional authenticator: when false, a request with no token resolves no
identity and continues, letting a later filter resolve a different
credential, with a downstream default-deny gate as the backstop. A token
that is present but invalid is still denied. Tests pin the present-but-invalid
denial under required:false and the fail-closed default.

Signed-off-by: Sam Batschelet <sbatsche@redhat.com>
@terylt

terylt commented Sep 24, 2026

Copy link
Copy Markdown
Collaborator

Hi @hexfusion,

Can we understand a bit more about why claims are going on the workloadIdentity? We handle it a bit differently in PPE with role: client.

The identity design keeps caller_workload as attestation only. A SPIRE JWT-SVID carries sub, aud, exp and iat, so WorkloadIdentity has the SPIFFE ID, trust domain and selectors and nothing else. Authorization data is meant to live on ClientExtension (crates/ppe-core/src/extensions/security.rs:286), and identity-delegation.md draws the line by what the caller presents:

Caller presents Slot
its SVID (ES256, SPIRE JWKS) caller_workload
a token minted from its SVID (RS256, IdP JWKS) client

The test token here is a SPIFFE sub plus grid_model_families and grid_trust_domain. SPIRE does not mint SVIDs with grant claims, so this reads as an IdP-issued token for a workload, which is the second row. Under role: client those claims already reach policy as client.claim.*, including the claims include/exclude overrides.

Is there a reason that does not work for the grid case? For example, the token has no client_id or azp, or you need the SPIFFE ID and the grants in one slot, or a later delegate(... subject: caller_workload) has to use the same token. If so, it would help to say so in the PR, and the change should also update the WorkloadIdentity doc, the load-time warning at resolver.rs:274, and identity-delegation.md:716 ("A role: caller_workload resolver carries no claims bag"). If not, the claims half may not be needed.

2. The claims do not reach policy on the runtime path

The resolver always builds a ConfiguredClaimMap (resolver.rs:322 on main, 321 on this PR's base). claim_mapper: standard loads the standard.json preset, not StandardClaimMap, which is now a reference implementation used by the preset equivalence test. This PR populates claims only in StandardClaimMap::map_workload, and sets claims: Default::default() in the configured mapper. So a token going through the resolver still yields an empty workload claims map.

The new tests call StandardClaimMap and extract_workload directly, which is why they pass. If the claims half stays, I think it needs:

  • ConfiguredClaimMap::map_workload to fill claims from self.claims_bag(section, claims), as map_subject and map_client do. That also picks up JWT_MAPPING_PROFILE.reserved_names in place of a second hardcoded list.
  • A jwt_e2e.rs case that sends a token through the resolver and reads caller_workload.claim.*.

3. Conflicts with #119

#119 moved configured_mapper.rs to crates/ppe-core/src/identity/mapping/, so the edit here targets a file that no longer exists. Since #125 the API key resolver also calls map_workload, with reserved_names: &[], so once item 2 is fixed directory record fields would reach workload claims too. That is probably fine, but worth deciding on purpose.

Smaller points

  • WorkloadIdentity::claims has no skip_serializing_if = "HashMap::is_empty", unlike the subject and client maps, so every serialized workload identity (including this_workload) gains "claims": {}.
  • Workload claims ride on read_workload, while subject claims have their own read_claims capability. Client claims ride on read_client, so there is precedent either way, but existing plugins holding read_workload will start seeing every non-reserved token claim.
  • The claims change and the required flag are independent. Splitting required into its own PR would let it merge while item 1 is discussed.

/// resolves no identity and continues, but a present-but-invalid token
/// is still denied.
#[serde(default = "default_required")]
pub required: bool,

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.

Document required: false including absent vs. invalid/empty headers, the need for a positive auth gate, and that missing identity attributes evaluate false so negated predicates still pass.

selectors,
client_id,
// Explicit fields only, no preset-style remaining-claims carry.
claims: Default::default(),

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.

Use self.claims_bag(section, claims) for caller_workload instead of map_workload; otherwise workload JWT claims are discarded and policy checks always deny. Remove the obsolete no-claims warnings in resolver.rs/config.rs and the corresponding docs. Add a resolver-level test that passes a workload token through JwtIdentityResolver::handle and verifies a custom claim reaches policy, plus a preset-vs-StandardClaimMap test covering non-registered claims.

async fn an_optional_resolver_continues_on_a_missing_header() {
// required: false lets a request with no token through, so a shared
// chain can fall to another credential (e.g. a peer certificate) with
// a downstream default-deny gate as the backstop. A token that IS

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.

We'll wanna update the section comment: required: true denies missing headers, while false continues with an empty identity. Also clarify that require(authenticated) denies when subject.id is absent.

// sec.caller_workload.attestor → caller_workload.attestor : String
// sec.caller_workload.selectors → caller_workload.selectors : StringSet (always)
// sec.caller_workload.client_id → caller_workload.client_id : String
// sec.caller_workload.claims → caller_workload.claim.<k> : flattened JSON

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.

We should document <ns>.claim.<dotted> in the workload table matching the Client table and optionally reference caller_workload.claim.<k> in the APL identity docs.

@araujof araujof modified the milestones: 0.4.0, 0.5.0 Sep 29, 2026
@araujof araujof modified the milestones: 0.5.0, 0.6.0 Oct 14, 2026
@araujof

araujof commented Oct 21, 2026

Copy link
Copy Markdown
Member

@hexfusion just a gentle nudge, are you planning to address the reviews on this one? Please ping me if you need any help. Thanks!

@araujof
araujof marked this pull request as draft October 22, 2026 01:25
@araujof araujof modified the milestones: 0.6.0, 0.7.0 Oct 22, 2026

This branch has not been deployed

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

Projects

Status: Review

Development

Successfully merging this pull request may close these issues.

4 participants