Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
983d506
feat(identity-jwt): add claim paths with backslash escaping
araujof Aug 20, 2026
3edc497
test(identity-jwt): characterize the standard claim mapper against a …
araujof Aug 20, 2026
0685d00
feat(identity-jwt): add the authored claim map and its compilation
araujof Aug 20, 2026
5b244d2
feat(identity-jwt): add the mapper a compiled claim map drives
araujof Aug 20, 2026
004d605
feat(identity-jwt): ship standard, keycloak, auth0 and cognito presets
araujof Aug 20, 2026
0b8116e
feat(identity-jwt): route claim_mapper through the presets and accept…
araujof Aug 20, 2026
ec41cc9
test(identity-jwt): gate the standard preset against the Rust mapper
araujof Aug 20, 2026
6fbdb0c
docs(identity-jwt): document the claim map and cover it end to end
araujof Aug 20, 2026
c2f6b0c
docs: record configurable claim mapping in the changelog
araujof Aug 20, 2026
bbd6dbc
fix(identity-jwt)!: close two standard-preset parity breaks
araujof Aug 21, 2026
0b68325
fix(identity-jwt): escape a token-derived log value and un-vacuum the…
araujof Aug 21, 2026
5acf020
fix(identity-jwt): give each candidate flag one valid home, and deriv…
araujof Aug 21, 2026
474bf26
docs: link the changelog entries to their pull request
araujof Aug 21, 2026
a8a38c2
docs: mark the claim-mapping plan completed
araujof Aug 21, 2026
f0a89e1
fix(identity-jwt)!: reject unknown keys in the resolver and issuer co…
araujof Aug 21, 2026
fb6826a
refactor(identity-jwt): close the review's coverage and surface resid…
araujof Aug 21, 2026
3d3954c
feat(identity-jwt)!: make the claims-bag overrides work with a preset
araujof Aug 21, 2026
f9b827a
Merge remote-tracking branch 'origin/main' into feat/claim_mapping
araujof Aug 21, 2026
58e0d36
fix(identity-jwt): satisfy the hardened lints from main
araujof Aug 21, 2026
d7b34d5
docs: tighten the changelog entries
araujof Aug 21, 2026
1549a88
fix(identity-jwt)!: refuse a SPIFFE ID with no trust domain
araujof Aug 21, 2026
df8ae41
fix(identity-jwt): name every claim in both override lists at once
araujof Aug 21, 2026
c601474
test(identity-jwt): stop the diagnostics capture racing the interest …
araujof Aug 21, 2026
7c38c51
fix(identity-jwt): dedupe a union merge
araujof Aug 22, 2026
4c7eb9c
fix(identity-jwt): refuse a `claims` override that cannot apply
araujof Aug 22, 2026
f2b3cfe
perf(identity-jwt): render the miss diagnostics only when debug is on
araujof Aug 22, 2026
b9e42d3
docs(identity-jwt): say which merge mode the dedupe skips
araujof Aug 23, 2026
efbbb59
fix(identity-jwt): report every bad override name at once
araujof Aug 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,22 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/).

### Added

- **Claim mapping is configuration.** The JWT identity plugin's `claim_mapper` names any of four shipped presets (`standard`, `keycloak`, `auth0`, `cognito`), and a new `claim_map` field takes a map written inline, so an `IdP` that nests roles under `realm_access.roles` or namespaces them behind a URL no longer needs a patched crate. A field lists candidate paths tried in order, with options for shape, splitting, and whether a miss refuses the token, and `merge: union` takes every candidate that resolves, each value once, in first-seen order. Paths use dots for nesting, with `\.` for a literal dot. An existing config is unaffected: naming no mapper resolves to `standard`, which the tests hold to the previous Rust mapper. ([#31](https://github.com/praxis-proxy/policy/pull/31))

- **A policy can gate on which `IdP` minted a token.** `claims: {include: [iss]}` returns a claim to the policy-visible bag, registered claims included, so `claim.iss` becomes readable. Registered claims were always dropped, so a deployment trusting several issuers could not gate on which one signed the token. `claims.exclude` drops a claim the other way, and both work with a preset or an inline map. Both lists take top-level claim names, since the bag is keyed by name: a dotted entry is refused at load rather than matching nothing, and a claim whose own name holds a dot is written with `\.`. A `role: caller_workload` resolver carries no claims bag, and says so at load rather than ignoring the setting quietly. ([#31](https://github.com/praxis-proxy/policy/pull/31))

- **Each shipped preset records what it omits.** Auth0 and Keycloak put their roles claim where no preset can name it, so those need a hand-written `claim_map`. Presets leave a field empty rather than filling it with the wrong concept, because Keycloak's `groups` holds realm roles and Cognito's `cognito:roles` holds IAM role ARNs. Each preset's description says what it covers and what is opt-in at the provider. ([#31](https://github.com/praxis-proxy/policy/pull/31))

- **Roles and permissions are readable as whole sets.** `subject.roles`, `subject.permissions`, `client.roles`, and `client.permissions` join `subject.teams` as `StringSet` bag keys, so a policy can write `"hr" in subject.roles` rather than enumerating `role.<name>` booleans. The flattened boolean keys are unchanged. ([#7](https://github.com/praxis-proxy/policy/pull/7))

### Changed

- **Unknown keys in the JWT plugin's config are rejected.** The resolver config and each `trusted_issuers` entry default every field, so a misspelling took effect silently, and a misspelled `audiences` turned audience checking off. **Breaking** for a config carrying a key the plugin does not read. ([#31](https://github.com/praxis-proxy/policy/pull/31))

- **A SPIFFE ID with no trust domain is refused.** `spiffe:///ns/default/sa/agent` carries the scheme but no authority, so it named no trust boundary and the mapper still filed it as a workload identity whose trust domain was the empty string. It now declines, the same as any other non-SPIFFE subject, and a valid candidate behind it still resolves. **Breaking** for a deployment minting such a token, which was never a valid SPIFFE ID. ([#31](https://github.com/praxis-proxy/policy/pull/31))

- **A workload's trust domain is no longer mappable.** It is the authority of the SPIFFE ID, so it is derived from the identity rather than read from a claim. ([#31](https://github.com/praxis-proxy/policy/pull/31))

### Fixed

- **Subject claims keep their JSON shape.** `SubjectExtension.claims` holds `serde_json::Value` and flattens into the attribute bag through `payload::walk`, so Keycloak's nested `realm_access.roles` is a `StringSet` a policy can test instead of one opaque string. Client claims always worked this way. **Breaking** for Rust callers reading `claims`; `SubjectExtension::claim_str` covers the scalar lookups. Scalar policies such as `claim.tenant == 'acme'` are unaffected, but a structured claim now sets only the flattened children beneath `claim.<name>`, not the key itself, and a claim whose value is `{}` or `null` sets no key at all where it previously landed as stringified text. ([#9](https://github.com/praxis-proxy/policy/pull/9))
Expand Down
86 changes: 76 additions & 10 deletions builtins/plugins/identity-jwt/src/claim_map.rs
Original file line number Diff line number Diff line change
Expand Up @@ -184,19 +184,17 @@ impl ClaimMapper for StandardClaimMap {
let spiffe_id = claims
.get("sub")
.and_then(Value::as_str)
.filter(|s| s.starts_with("spiffe://"))
.filter(|s| is_spiffe_id(s))
.or_else(|| claims.get("spiffe_id").and_then(Value::as_str))
// Guard the `spiffe_id` fallback with the SAME prefix check as
// `sub`: a non-SPIFFE `sub` must not smuggle in an arbitrary
// `spiffe_id` claim and be accepted as a workload identity.
.filter(|s| s.starts_with("spiffe://"))
// Guard the `spiffe_id` fallback with the SAME check as `sub`: a
// non-SPIFFE `sub` must not smuggle in an arbitrary `spiffe_id`
// claim and be accepted as a workload identity.
.filter(|s| is_spiffe_id(s))
.map(str::to_owned)?;

// Trust domain — pull from the SPIFFE-ID host part.
let trust_domain = spiffe_id
.strip_prefix("spiffe://")
.and_then(|rest| rest.split('/').next())
.map(str::to_owned);
// The URI authority, which `is_spiffe_id` already required, so this
// cannot be `None`.
let trust_domain = trust_domain_of(&spiffe_id);

Some(WorkloadIdentity {
spiffe_id: Some(spiffe_id),
Expand Down Expand Up @@ -289,6 +287,31 @@ impl ClaimMapper for StandardClaimMap {
}
}

/// Every SPIFFE ID starts here, and no configuration can turn the check off.
const SPIFFE_SCHEME: &str = "spiffe://";

/// Whether a string is usable as a SPIFFE ID.
///
/// The scheme alone is not enough: the authority carries the trust domain, and
/// the SPIFFE standard makes it mandatory. `spiffe:///ns/default/sa/agent` names
/// no trust boundary, so it is not an identity this plugin can file.
pub(crate) fn is_spiffe_id(text: &str) -> bool {
trust_domain_of(text).is_some()
}

/// The trust domain is the SPIFFE URI's authority, which the standard makes the
/// trust boundary. Deriving it from `iss` instead is explicitly discouraged.
///
/// `None` when the authority is absent, which is what makes the string unusable
/// as an identity rather than an identity with no trust domain.
pub(crate) fn trust_domain_of(spiffe_id: &str) -> Option<String> {
spiffe_id
.strip_prefix(SPIFFE_SCHEME)
.and_then(|rest| rest.split('/').next())
.filter(|domain| !domain.is_empty())
.map(str::to_owned)
}

#[cfg(test)]
#[allow(clippy::unreadable_literal, reason = "tests")]
#[allow(clippy::unwrap_used, reason = "tests")]
Expand Down Expand Up @@ -612,6 +635,49 @@ mod tests {
}
}

// ---- workload identity ------------------------------------------------

/// The scheme alone is not a SPIFFE ID. The authority carries the trust
/// domain, which the standard makes mandatory, so an authority-less string
/// declines rather than filing an identity whose trust boundary is `""`.
#[test]
fn a_spiffe_id_with_no_authority_declines() {
for id in ["spiffe:///ns/default/sa/agent", "spiffe://", "spiffe:///"] {
let claims = make_claims(json!({"sub": id}));
assert!(
StandardClaimMap.map_workload(&claims).is_none(),
"`{id}` names no trust domain, so it is not an identity"
);
}
}

/// Checked per candidate, like the prefix itself: an authority-less `sub`
/// does not poison the `spiffe_id` fallback behind it.
#[test]
fn an_authority_less_sub_still_falls_back_to_the_spiffe_id_claim() {
let claims = make_claims(json!({
"sub": "spiffe:///ns/default/sa/agent",
"spiffe_id": "spiffe://corp.example/ns/default/sa/agent",
}));
let workload = StandardClaimMap.map_workload(&claims).unwrap();
assert_eq!(workload.trust_domain.as_deref(), Some("corp.example"));
}

/// The authority is the whole identifier when there is no path, and every
/// accepted identity has one.
#[test]
fn the_trust_domain_is_the_uri_authority() {
for (id, domain) in [
("spiffe://example.org", Some("example.org")),
("spiffe://example.org/ns/a/sa/b", Some("example.org")),
("spiffe://", None),
("https://example.org/ns/a", None),
] {
assert_eq!(trust_domain_of(id).as_deref(), domain, "{id}");
assert_eq!(is_spiffe_id(id), domain.is_some(), "{id}");
}
}

// ---- trait defaults ---------------------------------------------------

/// A custom mapper that implements none of the three methods is valid: the
Expand Down
Loading
Loading