From 983d50607e5eee282609f58b9cb4f48f418424a3 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 17:50:35 -0400 Subject: [PATCH 01/27] feat(identity-jwt): add claim paths with backslash escaping A path addresses a claim value by dot-separated segments. Only `.` and `\` are special, so `cognito:groups` and a URL-named Auth0 claim are reachable, the latter by escaping its dots. Malformed paths are rejected at parse time, which is construction time for a configured map. Signed-off-by: Frederico Araujo --- .../plugins/identity-jwt/src/claim_path.rs | 368 ++++++++++++++++++ builtins/plugins/identity-jwt/src/lib.rs | 3 + 2 files changed, 371 insertions(+) create mode 100644 builtins/plugins/identity-jwt/src/claim_path.rs diff --git a/builtins/plugins/identity-jwt/src/claim_path.rs b/builtins/plugins/identity-jwt/src/claim_path.rs new file mode 100644 index 0000000..dbdb66b --- /dev/null +++ b/builtins/plugins/identity-jwt/src/claim_path.rs @@ -0,0 +1,368 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +// Claim paths address a value inside a validated claim set by +// dot-separated segments. Only `.` and `\` are special, which is what +// makes real claim names reachable: `cognito:groups`, +// `custom:department`, and `https://my-app.example.com/roles` are all +// single segments, the last one written with escaped dots. + +use std::collections::HashMap; +use std::fmt; + +use serde_json::Value; + +/// A parsed claim path: the segments to walk to reach one claim value. +/// +/// Parse once at construction with [`ClaimPath::parse`], then +/// [`ClaimPath::resolve`] on the request path. Nothing parses a path per +/// request. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ClaimPath { + segments: Vec, +} + +impl ClaimPath { + /// Parse an authored path. + /// + /// `.` separates segments. `\.` is a literal dot and `\\` a literal + /// backslash; every other character is a literal, `:` and `/` included. + /// + /// # Errors + /// + /// Returns a message naming the offending path when it is empty, has an + /// empty segment (`a..b`, `.a`, `a.`), ends in a lone `\`, or carries an + /// escape other than `\.` or `\\`. The caller prepends the field name, + /// since only it knows which field the path was written for. + pub fn parse(input: &str) -> Result { + let mut segments: Vec = Vec::new(); + let mut current = String::new(); + let mut chars = input.chars(); + + while let Some(c) = chars.next() { + match c { + '\\' => match chars.next() { + Some('.') => current.push('.'), + Some('\\') => current.push('\\'), + Some(other) => { + return Err(format!( + "path '{input}': unrecognized escape `\\{other}`; only `\\.` and \ + `\\\\` are escapes" + )); + }, + None => { + return Err(format!("path '{input}': trailing `\\` escapes nothing")); + }, + }, + '.' => { + if current.is_empty() { + return Err(format!("path '{input}': empty segment")); + } + segments.push(std::mem::take(&mut current)); + }, + other => current.push(other), + } + } + + if current.is_empty() { + return Err(if segments.is_empty() { + "path is empty".to_owned() + } else { + format!("path '{input}': empty segment") + }); + } + segments.push(current); + + Ok(Self { segments }) + } + + /// Resolve against a claim set, or `None` when the path leads nowhere. + /// + /// A path crossing a scalar or an array resolves to `None`: traversal + /// needs objects, and array indexing is not part of the grammar. A claim + /// whose value is JSON `null` resolves to `Some(Value::Null)`, which is + /// distinct from absent. + pub fn resolve<'a>(&self, claims: &'a HashMap) -> Option<&'a Value> { + let (first, rest) = self.segments.split_first()?; + let mut current = claims.get(first)?; + for segment in rest { + current = current.get(segment.as_str())?; + } + Some(current) + } + + /// The path's segments, already unescaped. + pub fn segments(&self) -> &[String] { + &self.segments + } + + /// The single segment this path consumes whole, or `None` when it + /// traverses. + /// + /// The claims bag excludes a claim a single-segment path consumed and + /// leaves a traversed parent intact, so it needs to tell the two apart. + pub fn single_segment(&self) -> Option<&str> { + match self.segments.as_slice() { + [only] => Some(only.as_str()), + _ => None, + } + } +} + +impl fmt::Display for ClaimPath { + /// Render the path back in authored form, re-escaping `.` and `\` so a + /// diagnostic echoes what the operator wrote. + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (i, segment) in self.segments.iter().enumerate() { + if i > 0 { + f.write_str(".")?; + } + for c in segment.chars() { + match c { + '.' => f.write_str("\\.")?, + '\\' => f.write_str("\\\\")?, + other => write!(f, "{other}")?, + } + } + } + Ok(()) + } +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "tests" +)] +mod tests { + use super::*; + use serde_json::json; + + fn claims(value: Value) -> HashMap { + value.as_object().unwrap().clone().into_iter().collect() + } + + fn parse(input: &str) -> ClaimPath { + ClaimPath::parse(input).unwrap_or_else(|e| panic!("'{input}' should parse: {e}")) + } + + fn segments(input: &str) -> Vec { + parse(input).segments().to_vec() + } + + // ---- traversal -------------------------------------------------------- + + #[test] + fn a_single_segment_resolves_a_top_level_claim() { + let claims = claims(json!({"sub": "alice"})); + assert_eq!(parse("sub").resolve(&claims), Some(&json!("alice"))); + } + + #[test] + fn a_dotted_path_traverses_into_a_nested_object() { + let claims = claims(json!({"realm_access": {"roles": ["admin", "hr"]}})); + assert_eq!( + parse("realm_access.roles").resolve(&claims), + Some(&json!(["admin", "hr"])), + ); + } + + #[test] + fn a_three_deep_path_resolves() { + let claims = claims(json!({"a": {"b": {"c": "deep"}}})); + assert_eq!(parse("a.b.c").resolve(&claims), Some(&json!("deep"))); + } + + /// A colon is not a separator, so a Cognito claim name is one segment and + /// needs no escaping. + #[test] + fn a_colon_is_a_literal_and_needs_no_escape() { + assert_eq!(segments("cognito:groups"), vec!["cognito:groups"]); + let claims = claims(json!({"cognito:groups": ["eng"]})); + assert_eq!( + parse("cognito:groups").resolve(&claims), + Some(&json!(["eng"])), + ); + } + + /// Auth0's documented namespaced-claim name, verbatim: the whole URL is + /// the claim name, so every dot in it is escaped and the slashes are not. + #[test] + fn an_escaped_url_claim_name_is_one_segment() { + let authored = "https://my-app\\.example\\.com/roles"; + assert_eq!(segments(authored), vec!["https://my-app.example.com/roles"],); + let claims = claims(json!({"https://my-app.example.com/roles": ["editor"]})); + assert_eq!(parse(authored).resolve(&claims), Some(&json!(["editor"]))); + } + + /// Auth0 also documents a namespace with no path segment, where the claim + /// name is a bare URL. + #[test] + fn a_url_claim_name_with_no_path_segment_resolves() { + let authored = "https://namespace\\.exampleco\\.com"; + let claims = claims(json!({"https://namespace.exampleco.com": "value"})); + assert_eq!(parse(authored).resolve(&claims), Some(&json!("value"))); + } + + #[test] + fn a_doubled_backslash_is_one_literal_backslash() { + assert_eq!(segments("a\\\\b"), vec!["a\\b"]); + let claims = claims(json!({"a\\b": 1})); + assert_eq!(parse("a\\\\b").resolve(&claims), Some(&json!(1))); + } + + /// A Kubernetes projected `ServiceAccount` token puts a dot inside a + /// top-level claim name whose value is an object, so one path needs an + /// escaped dot and then real traversal. + #[test] + fn an_escaped_dot_and_traversal_combine_in_one_path() { + let authored = "kubernetes\\.io.serviceaccount.name"; + assert_eq!( + segments(authored), + vec!["kubernetes.io", "serviceaccount", "name"], + ); + let claims = claims(json!({ + "sub": "system:serviceaccount:default:agent", + "kubernetes.io": { + "namespace": "default", + "serviceaccount": {"name": "agent", "uid": "b3c1"}, + }, + })); + assert_eq!(parse(authored).resolve(&claims), Some(&json!("agent"))); + } + + /// Characters that are neither `.` nor `\` are literals wherever they + /// appear, including inside a traversed leaf segment. + #[test] + fn non_separator_characters_need_no_escaping() { + let claims = claims(json!({ + "cognito:groups": ["eng"], + "custom:department": "platform", + "allowed-origins": ["https://app.example"], + "trusted-certs": [], + "cnf": {"x5t#S256": "abc123"}, + })); + assert_eq!( + parse("custom:department").resolve(&claims), + Some(&json!("platform")), + ); + assert_eq!( + parse("allowed-origins").resolve(&claims), + Some(&json!(["https://app.example"])), + ); + assert_eq!(parse("trusted-certs").resolve(&claims), Some(&json!([]))); + assert_eq!( + parse("cnf.x5t#S256").resolve(&claims), + Some(&json!("abc123")), + ); + } + + // ---- resolution misses ------------------------------------------------ + + #[test] + fn an_unmatched_first_segment_resolves_to_none() { + let claims = claims(json!({"sub": "alice"})); + assert!(parse("roles").resolve(&claims).is_none()); + } + + #[test] + fn a_path_crossing_a_scalar_resolves_to_none() { + let claims = claims(json!({"sub": "alice"})); + assert!(parse("sub.x").resolve(&claims).is_none()); + } + + /// Array indexing is not part of the grammar, so a numeric segment against + /// an array is a miss rather than an element. + #[test] + fn a_path_into_an_array_resolves_to_none() { + let claims = claims(json!({"roles": ["admin"]})); + assert!(parse("roles.0").resolve(&claims).is_none()); + } + + /// A `null`-valued claim is present. Callers decide it is unusable for + /// their field; the path resolver must not conflate it with absence. + #[test] + fn a_null_claim_resolves_to_null_rather_than_absent() { + let claims = claims(json!({"teams": Value::Null})); + assert_eq!(parse("teams").resolve(&claims), Some(&Value::Null)); + } + + // ---- rejection -------------------------------------------------------- + + #[test] + fn a_trailing_lone_escape_is_rejected() { + let err = ClaimPath::parse("roles\\").expect_err("a trailing `\\` escapes nothing"); + assert!(err.contains("roles\\"), "message must echo the path: {err}"); + assert!(err.contains("trailing"), "{err}"); + } + + #[test] + fn an_unrecognized_escape_is_rejected_and_named() { + let err = ClaimPath::parse("roles\\x").expect_err("`\\x` is not an escape"); + assert!(err.contains("\\x"), "message must name the escape: {err}"); + } + + /// Escaping a colon is the likely mistake for anyone who reaches for a + /// backslash on sight of a URL. It is rejected rather than accepted as the + /// colon, so the operator learns the rule at load instead of debugging a + /// path that quietly addresses a different claim name. + #[test] + fn an_escaped_colon_is_rejected_because_a_colon_is_already_a_literal() { + let err = ClaimPath::parse("https\\://my-app\\.example\\.com/roles") + .expect_err("`\\:` is not an escape"); + assert!(err.contains("\\:"), "message must name the escape: {err}"); + } + + #[test] + fn an_empty_path_is_rejected() { + let err = ClaimPath::parse("").expect_err("an empty path addresses nothing"); + assert!(err.contains("empty"), "{err}"); + } + + #[test] + fn an_empty_segment_is_rejected_wherever_it_appears() { + for input in ["a..b", ".a", "a."] { + let err = ClaimPath::parse(input) + .expect_err("a path with an empty segment addresses nothing"); + assert!( + err.contains("empty segment"), + "'{input}' must be rejected as an empty segment, got: {err}" + ); + } + } + + // ---- display ---------------------------------------------------------- + + /// `Display` echoes the authored form, not the resolved text, so a + /// diagnostic naming a tried path matches what the operator wrote. + #[test] + fn display_round_trips_every_accepted_path() { + for authored in [ + "sub", + "realm_access.roles", + "a.b.c", + "cognito:groups", + "custom:department", + "allowed-origins", + "cnf.x5t#S256", + "https://my-app\\.example\\.com/roles", + "https://namespace\\.exampleco\\.com", + "a\\\\b", + "kubernetes\\.io.serviceaccount.name", + ] { + assert_eq!(parse(authored).to_string(), authored); + } + } + + #[test] + fn single_segment_distinguishes_a_consumed_claim_from_a_traversed_one() { + assert_eq!(parse("roles").single_segment(), Some("roles")); + assert_eq!( + parse("https://my-app\\.example\\.com/roles").single_segment(), + Some("https://my-app.example.com/roles"), + ); + assert!(parse("realm_access.roles").single_segment().is_none()); + } +} diff --git a/builtins/plugins/identity-jwt/src/lib.rs b/builtins/plugins/identity-jwt/src/lib.rs index ba93318..a1a8e31 100644 --- a/builtins/plugins/identity-jwt/src/lib.rs +++ b/builtins/plugins/identity-jwt/src/lib.rs @@ -46,6 +46,8 @@ /// Maps validated claims onto the identity slots. pub mod claim_map; +/// Addresses a claim value by a dot-separated path. +pub mod claim_path; /// Plugin configuration and its validation. pub mod config; /// Constructs the resolver from configuration. @@ -56,6 +58,7 @@ pub mod resolver; pub mod trusted_issuer; pub use claim_map::{ClaimMap, ClaimMapper, StandardClaimMap}; +pub use claim_path::ClaimPath; pub use config::{DecodingKeySource, JwtIdentityResolverConfig, TrustedIssuerConfig}; pub use factory::{JwtIdentityFactory, KIND}; pub use resolver::JwtIdentityResolver; From 3edc497a25bd728c7c2c01f78220b4d1e96ffdd7 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 17:56:27 -0400 Subject: [PATCH 02/27] test(identity-jwt): characterize the standard claim mapper against a token corpus 38 token shapes paired with the identity they produce, drawn from Keycloak, Auth0, Cognito, SPIRE and Kubernetes documentation, plus the shapes a reimplementation is most likely to get wrong: a string where an array is expected, an empty array that must not fall through, a non-string element, and a null claim. Each entry records its provenance as a data field. Structural checks pin the coverage: every role, both branches of every fallback, all three aud shapes, and a declining entry per role. Signed-off-by: Frederico Araujo --- .../tests/fixtures/claim-corpus.json | 717 ++++++++++++++++++ .../tests/standard_preset_equivalence.rs | 433 +++++++++++ 2 files changed, 1150 insertions(+) create mode 100644 builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json create mode 100644 builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs diff --git a/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json b/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json new file mode 100644 index 0000000..a164154 --- /dev/null +++ b/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json @@ -0,0 +1,717 @@ +[ + { + "name": "subject-standard-oidc-shape", + "role": "user", + "provenance": "Standard OIDC shape, constructed to exercise every subject field at once.", + "claims": { + "sub": "alice@corp.example", + "roles": ["hr", "admin"], + "permissions": ["call_tool", "list_tools"], + "teams": ["platform"], + "email": "alice@corp.example", + "preferred_username": "alice", + "iss": "https://idp.example", + "aud": "my-api", + "exp": 2000000000, + "iat": 1999999400, + "jti": "b1f0" + }, + "expected": { + "id": "alice@corp.example", + "roles": ["hr", "admin"], + "permissions": ["call_tool", "list_tools"], + "teams": ["platform"], + "claims": { + "email": "alice@corp.example", + "preferred_username": "alice" + } + } + }, + { + "name": "subject-permissions-from-permissions-array", + "role": "user", + "provenance": "Constructed to exercise the permissions branch of the permissions/scope fallback, with both claims present.", + "claims": { + "sub": "alice", + "permissions": ["call_tool", "list_tools"], + "scope": "read write" + }, + "expected": { + "id": "alice", + "permissions": ["call_tool", "list_tools"], + "claims": {} + } + }, + { + "name": "subject-permissions-from-scope-fallback", + "role": "user", + "provenance": "Constructed to exercise the scope branch of the permissions/scope fallback. `scope` is space-delimited in Keycloak, Auth0 and Cognito alike.", + "claims": { + "sub": "alice", + "scope": "read write delete" + }, + "expected": { + "id": "alice", + "permissions": ["read", "write", "delete"], + "claims": {} + } + }, + { + "name": "subject-teams-from-teams-array", + "role": "user", + "provenance": "Constructed to exercise the teams branch of the teams/groups fallback, with both claims present.", + "claims": { + "sub": "alice", + "teams": ["explicit-team"], + "groups": ["fallback-group"] + }, + "expected": { + "id": "alice", + "teams": ["explicit-team"], + "claims": {} + } + }, + { + "name": "subject-teams-from-groups-fallback", + "role": "user", + "provenance": "Constructed to exercise the groups branch of the teams/groups fallback.", + "claims": { + "sub": "alice", + "groups": ["engineering", "platform"] + }, + "expected": { + "id": "alice", + "teams": ["engineering", "platform"], + "claims": {} + } + }, + { + "name": "subject-string-valued-roles-is-ignored", + "role": "user", + "provenance": "Constructed: a string where an array is expected. The shape a configured map is most likely to accept where the Rust mapper does not.", + "claims": { + "sub": "alice", + "roles": "admin" + }, + "expected": { + "id": "alice", + "roles": [], + "claims": {} + } + }, + { + "name": "subject-string-valued-teams-falls-through-to-groups", + "role": "user", + "provenance": "Constructed: an unusable shape on the first candidate must not stop the fallback chain.", + "claims": { + "sub": "alice", + "teams": "engineering", + "groups": ["platform"] + }, + "expected": { + "id": "alice", + "teams": ["platform"], + "claims": {} + } + }, + { + "name": "subject-string-valued-permissions-falls-through-to-scope", + "role": "user", + "provenance": "Constructed: the permissions/scope pair with an unusable first candidate.", + "claims": { + "sub": "alice", + "permissions": "read write", + "scope": "list create" + }, + "expected": { + "id": "alice", + "permissions": ["list", "create"], + "claims": {} + } + }, + { + "name": "subject-non-string-elements-in-a-role-array-are-skipped", + "role": "user", + "provenance": "Constructed: a mixed-type array. Elements that are not strings are dropped, the array as a whole is not rejected.", + "claims": { + "sub": "alice", + "roles": ["admin", 42, null, { "nested": true }, ["inner"], "hr"] + }, + "expected": { + "id": "alice", + "roles": ["admin", "hr"], + "claims": {} + } + }, + { + "name": "subject-empty-permissions-array-does-not-fall-through-to-scope", + "role": "user", + "provenance": "Constructed: an empty array is a usable shape, so it resolves and the chain stops. A configured map that treated empty as unresolved would diverge here.", + "claims": { + "sub": "alice", + "permissions": [], + "scope": "read write" + }, + "expected": { + "id": "alice", + "permissions": [], + "claims": {} + } + }, + { + "name": "subject-a-null-claim-reaches-the-bag-and-maps-nothing", + "role": "user", + "provenance": "Constructed: JSON null is present but unusable for a collection field, and is still a policy-visible claim where it was not consumed.", + "claims": { + "sub": "alice", + "roles": null, + "department": null + }, + "expected": { + "id": "alice", + "roles": [], + "claims": { "department": null } + } + }, + { + "name": "subject-missing-anchor-declines", + "role": "user", + "provenance": "Constructed: no `sub`, so there is no subject to gate on and the mapper declines.", + "claims": { + "email": "alice@corp.example" + }, + "expected": null + }, + { + "name": "subject-non-string-anchor-declines", + "role": "user", + "provenance": "Constructed: `sub` present but not a string, which is a decline rather than a coercion.", + "claims": { + "sub": 42 + }, + "expected": null + }, + { + "name": "subject-keycloak-access-token", + "role": "user", + "provenance": "Keycloak 26 access token, per the server-admin guide and OIDCLoginProtocolFactory's built-in mappers. Nested realm and per-client roles, space-delimited scope, hyphenated allowed-origins.", + "claims": { + "exp": 2000000000, + "iat": 1999999400, + "jti": "6f1c9f3e", + "iss": "https://kc.example/realms/demo", + "aud": ["account"], + "sub": "f:2c1b:alice", + "typ": "Bearer", + "azp": "my-api", + "sid": "9a7d", + "acr": "1", + "allowed-origins": ["https://app.example"], + "realm_access": { + "roles": ["offline_access", "default-roles-demo", "uma_authorization"] + }, + "resource_access": { + "my-api": { "roles": ["viewer", "editor"] }, + "account": { "roles": ["manage-account"] } + }, + "scope": "openid profile email", + "email_verified": true, + "preferred_username": "alice", + "email": "alice@example.com" + }, + "expected": { + "id": "f:2c1b:alice", + "roles": [], + "permissions": ["openid", "profile", "email"], + "teams": [], + "claims": { + "typ": "Bearer", + "azp": "my-api", + "sid": "9a7d", + "acr": "1", + "allowed-origins": ["https://app.example"], + "realm_access": { + "roles": ["offline_access", "default-roles-demo", "uma_authorization"] + }, + "resource_access": { + "my-api": { "roles": ["viewer", "editor"] }, + "account": { "roles": ["manage-account"] } + }, + "email_verified": true, + "preferred_username": "alice", + "email": "alice@example.com" + } + } + }, + { + "name": "subject-auth0-user-token-with-a-namespaced-roles-claim", + "role": "user", + "provenance": "Auth0 user access token. The roles claim is URL-namespaced because Auth0's restricted-claim list forbids a bare `roles`; `permissions` requires RBAC plus the add-permissions toggle.", + "claims": { + "iss": "https://tenant.eu.auth0.com/", + "sub": "auth0|507f1f77bcf86cd799439011", + "aud": ["https://my-api.example", "https://tenant.eu.auth0.com/userinfo"], + "iat": 1999999400, + "exp": 2000000000, + "azp": "6MZ2Wt3rBGxOA1example", + "scope": "openid profile email", + "permissions": ["read:reports"], + "https://my-app.example.com/roles": ["editor"] + }, + "expected": { + "id": "auth0|507f1f77bcf86cd799439011", + "roles": [], + "permissions": ["read:reports"], + "teams": [], + "claims": { + "azp": "6MZ2Wt3rBGxOA1example", + "https://my-app.example.com/roles": ["editor"] + } + } + }, + { + "name": "subject-cognito-id-token", + "role": "user", + "provenance": "Cognito ID token, per the Cognito Developer Guide. `cognito:groups` holds group names; `cognito:roles` holds IAM role ARNs, which are not application roles.", + "claims": { + "sub": "a1b2c3d4-1111-2222-3333-444455556666", + "cognito:groups": ["admins", "engineering"], + "cognito:username": "alice", + "cognito:roles": ["arn:aws:iam::123456789012:role/AppRole"], + "cognito:preferred_role": "arn:aws:iam::123456789012:role/AppRole", + "identities": [ + { + "userId": "1122334455", + "providerName": "Google", + "providerType": "Google", + "primary": "true", + "dateCreated": "1999990000" + } + ], + "custom:department": "platform", + "email": "alice@example.com", + "email_verified": true, + "token_use": "id", + "auth_time": 1999999400, + "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_example", + "aud": "1example23456789", + "exp": 2000000000, + "iat": 1999999400, + "jti": "0e2f" + }, + "expected": { + "id": "a1b2c3d4-1111-2222-3333-444455556666", + "roles": [], + "permissions": [], + "teams": [], + "claims": { + "cognito:groups": ["admins", "engineering"], + "cognito:username": "alice", + "cognito:roles": ["arn:aws:iam::123456789012:role/AppRole"], + "cognito:preferred_role": "arn:aws:iam::123456789012:role/AppRole", + "identities": [ + { + "userId": "1122334455", + "providerName": "Google", + "providerType": "Google", + "primary": "true", + "dateCreated": "1999990000" + } + ], + "custom:department": "platform", + "email": "alice@example.com", + "email_verified": true, + "token_use": "id", + "auth_time": 1999999400 + } + } + }, + { + "name": "subject-kubernetes-projected-service-account-token", + "role": "user", + "provenance": "Kubernetes projected ServiceAccount token, per the authentication reference. `kubernetes.io` is a top-level claim name containing a dot over a nested object.", + "claims": { + "aud": ["https://kubernetes.default.svc"], + "exp": 2000000000, + "iat": 1999999400, + "iss": "https://kubernetes.default.svc", + "jti": "5c7a", + "nbf": 1999999400, + "sub": "system:serviceaccount:default:agent", + "kubernetes.io": { + "namespace": "default", + "node": { "name": "node-1", "uid": "3f2b" }, + "pod": { "name": "agent-7d9f", "uid": "7c1e" }, + "serviceaccount": { "name": "agent", "uid": "9b4d" } + } + }, + "expected": { + "id": "system:serviceaccount:default:agent", + "roles": [], + "permissions": [], + "teams": [], + "claims": { + "kubernetes.io": { + "namespace": "default", + "node": { "name": "node-1", "uid": "3f2b" }, + "pod": { "name": "agent-7d9f", "uid": "7c1e" }, + "serviceaccount": { "name": "agent", "uid": "9b4d" } + } + } + } + }, + { + "name": "client-anchor-from-client-id", + "role": "client", + "provenance": "Constructed to exercise the client_id branch of the client_id/azp fallback, with both claims present.", + "claims": { + "client_id": "svc-billing", + "azp": "svc-other" + }, + "expected": { + "client_id": "svc-billing", + "claims": {} + } + }, + { + "name": "client-anchor-from-azp", + "role": "client", + "provenance": "Constructed to exercise the azp branch of the client_id/azp fallback. Keycloak user flows and Auth0's default profile both send azp rather than client_id.", + "claims": { + "azp": "svc-billing" + }, + "expected": { + "client_id": "svc-billing", + "claims": {} + } + }, + { + "name": "client-scopes-from-authorized-scopes", + "role": "client", + "provenance": "Constructed to exercise the authorized_scopes branch of the authorized_scopes/scope fallback, with both claims present.", + "claims": { + "client_id": "svc", + "authorized_scopes": ["read", "write"], + "scope": "ignored other" + }, + "expected": { + "client_id": "svc", + "authorized_scopes": ["read", "write"], + "claims": {} + } + }, + { + "name": "client-scopes-from-scope-fallback", + "role": "client", + "provenance": "Constructed to exercise the scope branch of the authorized_scopes/scope fallback.", + "claims": { + "client_id": "svc", + "scope": "read write admin" + }, + "expected": { + "client_id": "svc", + "authorized_scopes": ["read", "write", "admin"], + "claims": {} + } + }, + { + "name": "client-empty-authorized-scopes-array-does-not-fall-through-to-scope", + "role": "client", + "provenance": "Constructed: an empty array is a usable shape, so it resolves and `scope` is never read.", + "claims": { + "client_id": "svc", + "authorized_scopes": [], + "scope": "read write" + }, + "expected": { + "client_id": "svc", + "authorized_scopes": [], + "claims": {} + } + }, + { + "name": "client-aud-as-a-string", + "role": "client", + "provenance": "Keycloak serializes a bare string at one audience (StringOrArraySerializer); Auth0 does the same for a pure M2M token.", + "claims": { + "client_id": "svc", + "aud": "gateway" + }, + "expected": { + "client_id": "svc", + "authorized_audiences": ["gateway"], + "claims": {} + } + }, + { + "name": "client-aud-as-an-array", + "role": "client", + "provenance": "Keycloak serializes an array at two or more audiences; SPIRE and Kubernetes always do.", + "claims": { + "client_id": "svc", + "aud": ["gateway", "api"] + }, + "expected": { + "client_id": "svc", + "authorized_audiences": ["gateway", "api"], + "claims": {} + } + }, + { + "name": "client-aud-absent", + "role": "client", + "provenance": "Cognito access tokens carry no aud by default, and an M2M token can never have one.", + "claims": { + "client_id": "svc" + }, + "expected": { + "client_id": "svc", + "authorized_audiences": [], + "claims": {} + } + }, + { + "name": "client-non-string-aud-is-ignored", + "role": "client", + "provenance": "Constructed: an unusable aud shape produces no audience rather than failing the map.", + "claims": { + "client_id": "svc", + "aud": 42 + }, + "expected": { + "client_id": "svc", + "authorized_audiences": [], + "claims": {} + } + }, + { + "name": "client-roles-from-an-array", + "role": "client", + "provenance": "Constructed: platform-native client roles, which are ordered on the client extension.", + "claims": { + "client_id": "svc", + "roles": ["service", "admin"] + }, + "expected": { + "client_id": "svc", + "roles": ["service", "admin"], + "claims": {} + } + }, + { + "name": "client-string-valued-roles-is-ignored", + "role": "client", + "provenance": "Constructed: a string where an array is expected, on a field with no fallback candidate behind it.", + "claims": { + "client_id": "svc", + "roles": "admin" + }, + "expected": { + "client_id": "svc", + "roles": [], + "claims": {} + } + }, + { + "name": "client-duplicate-role-elements-are-preserved", + "role": "client", + "provenance": "Constructed: the client roles field is a Vec, so a repeated element survives. This is what makes the no-deduplication choice observable.", + "claims": { + "client_id": "svc", + "roles": ["admin", "admin", "viewer"] + }, + "expected": { + "client_id": "svc", + "roles": ["admin", "admin", "viewer"], + "claims": {} + } + }, + { + "name": "client-name-is-carried-when-present", + "role": "client", + "provenance": "Constructed: no researched provider mints client_name, so only an operator's custom claim reaches it.", + "claims": { + "client_id": "svc", + "client_name": "Billing Service" + }, + "expected": { + "client_id": "svc", + "client_name": "Billing Service", + "claims": {} + } + }, + { + "name": "client-keycloak-service-account-token", + "role": "client", + "provenance": "Keycloak service-account access token, per ClientManager.addServiceAccountProtocolMappersViaScope. Carries client_id, clientHost, clientAddress and a service-account preferred_username.", + "claims": { + "exp": 2000000000, + "iat": 1999999400, + "jti": "d41f", + "iss": "https://kc.example/realms/demo", + "aud": "account", + "sub": "8b2c:service-account-my-service", + "typ": "Bearer", + "azp": "my-service", + "client_id": "my-service", + "clientHost": "10.0.0.5", + "clientAddress": "10.0.0.5", + "preferred_username": "service-account-my-service", + "scope": "openid profile email", + "realm_access": { "roles": ["offline_access", "uma_authorization"] }, + "resource_access": { "my-service": { "roles": ["svc-role"] } } + }, + "expected": { + "client_id": "my-service", + "authorized_scopes": ["openid", "profile", "email"], + "authorized_audiences": ["account"], + "roles": [], + "claims": { + "typ": "Bearer", + "clientHost": "10.0.0.5", + "clientAddress": "10.0.0.5", + "preferred_username": "service-account-my-service", + "realm_access": { "roles": ["offline_access", "uma_authorization"] }, + "resource_access": { "my-service": { "roles": ["svc-role"] } } + } + } + }, + { + "name": "client-auth0-m2m-access-token", + "role": "client", + "provenance": "Auth0 default-profile machine-to-machine access token, per the organizations token sample. `sub` is @clients and `aud` is a bare string.", + "claims": { + "iss": "https://tenant.eu.auth0.com/", + "sub": "6MZ2Wt3rBGxOA1example@clients", + "aud": "https://my-api.example", + "iat": 1999999400, + "exp": 2000000000, + "azp": "6MZ2Wt3rBGxOA1example", + "gty": "client-credentials", + "scope": "read:reports write:reports" + }, + "expected": { + "client_id": "6MZ2Wt3rBGxOA1example", + "authorized_scopes": ["read:reports", "write:reports"], + "authorized_audiences": ["https://my-api.example"], + "roles": [], + "claims": { + "gty": "client-credentials" + } + } + }, + { + "name": "client-cognito-access-token-without-aud", + "role": "client", + "provenance": "Cognito access token, per the Cognito Developer Guide. No azp exists, no aud by default, and a resource-server scope carries a dot and a slash.", + "claims": { + "sub": "a1b2c3d4-1111-2222-3333-444455556666", + "token_use": "access", + "scope": "resourceserver.1/appclient2", + "auth_time": 1999999400, + "iss": "https://cognito-idp.us-east-1.amazonaws.com/us-east-1_example", + "exp": 2000000000, + "iat": 1999999400, + "version": 2, + "jti": "3a8e", + "client_id": "1example23456789" + }, + "expected": { + "client_id": "1example23456789", + "authorized_scopes": ["resourceserver.1/appclient2"], + "authorized_audiences": [], + "claims": { + "token_use": "access", + "auth_time": 1999999400, + "version": 2 + } + } + }, + { + "name": "client-missing-anchor-declines", + "role": "client", + "provenance": "Constructed: neither client_id nor azp, so there is no client to gate on.", + "claims": { + "client_name": "Billing" + }, + "expected": null + }, + { + "name": "workload-spiffe-id-from-sub", + "role": "workload", + "provenance": "SPIFFE JWT-SVID standard: `sub` MUST hold the SPIFFE ID.", + "claims": { + "sub": "spiffe://corp.example/ns/default/sa/agent", + "aud": ["spire-server"], + "exp": 2000000000, + "iat": 1999999400 + }, + "expected": { + "spiffe_id": "spiffe://corp.example/ns/default/sa/agent", + "trust_domain": "corp.example", + "attestor": "jwt" + } + }, + { + "name": "workload-spiffe-id-from-the-spiffe-id-claim-fallback", + "role": "workload", + "provenance": "Constructed: a provider that surfaces the SPIFFE ID in its own claim rather than in `sub`.", + "claims": { + "sub": "svc-account-123", + "spiffe_id": "spiffe://corp.example/ns/default/sa/agent" + }, + "expected": { + "spiffe_id": "spiffe://corp.example/ns/default/sa/agent", + "trust_domain": "corp.example", + "attestor": "jwt" + } + }, + { + "name": "workload-non-spiffe-sub-with-a-bogus-spiffe-id-declines", + "role": "workload", + "provenance": "Constructed: the prefix check applies to every candidate, so a non-SPIFFE sub cannot smuggle in an arbitrary spiffe_id claim.", + "claims": { + "sub": "alice@corp.example", + "spiffe_id": "not-a-spiffe-id" + }, + "expected": null + }, + { + "name": "workload-non-spiffe-sub-alone-declines", + "role": "workload", + "provenance": "Constructed: a perfectly valid user token must not file a non-SPIFFE identity into the workload slot.", + "claims": { + "sub": "alice@corp.example" + }, + "expected": null + }, + { + "name": "workload-spire-jwt-svid", + "role": "workload", + "provenance": "SPIRE JWT-SVID, per credtemplate/builder.go. `aud` is invariantly an array and there is no `iss`, which the JWT-SVID standard does not specify.", + "claims": { + "aud": ["spire-server", "https://my-api.example"], + "exp": 2000000000, + "iat": 1999999400, + "sub": "spiffe://example.org/ns/prod/sa/api" + }, + "expected": { + "spiffe_id": "spiffe://example.org/ns/prod/sa/api", + "trust_domain": "example.org", + "attestor": "jwt" + } + }, + { + "name": "workload-trust-domain-from-a-spiffe-id-with-no-path", + "role": "workload", + "provenance": "Constructed: the trust domain is the URI authority, which is the whole identifier when there is no path.", + "claims": { + "sub": "spiffe://example.org" + }, + "expected": { + "spiffe_id": "spiffe://example.org", + "trust_domain": "example.org", + "attestor": "jwt" + } + } +] diff --git a/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs b/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs new file mode 100644 index 0000000..bd471f0 --- /dev/null +++ b/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs @@ -0,0 +1,433 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +//! A deployment that upgrades without touching its config must see the identity +//! it saw before. +//! +//! `fixtures/claim-corpus.json` pairs a claim set with the typed identity it +//! produces, one entry per token shape, each recording where its shape came +//! from. The corpus is the contract: it is asserted against `StandardClaimMap`, +//! so it describes the mapper rather than any later reimplementation of it. +//! +//! The corpus is embedded rather than read at run time, so a missing or +//! unparseable file is a compile or test failure and never a silently skipped +//! entry. + +#![allow( + missing_docs, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + clippy::print_stderr, + clippy::print_stdout, + clippy::unwrap_used, + reason = "test and example code" +)] + +use std::collections::{HashMap, HashSet}; + +use praxis_policy_core::extensions::{ClientExtension, SubjectExtension, WorkloadIdentity}; +use praxis_policy_plugin_identity_jwt::{ClaimMapper as _, StandardClaimMap}; +use serde::Deserialize; +use serde_json::Value; + +const CORPUS_JSON: &str = include_str!("fixtures/claim-corpus.json"); + +// ===================================================================== +// Corpus shape +// ===================================================================== + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Deserialize)] +#[serde(rename_all = "snake_case")] +enum CorpusRole { + User, + Client, + Workload, +} + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct CorpusEntry { + name: String, + role: CorpusRole, + /// Where the shape came from: a provider document, or what it was + /// constructed to exercise. A data field because JSON has no comments. + provenance: String, + claims: HashMap, + /// The typed identity for this entry's role, or `null` when the mapper + /// declines the token. + expected: Option, +} + +fn corpus() -> Vec { + serde_json::from_str(CORPUS_JSON).expect( + "the corpus must parse; a malformed entry fails the suite rather than being skipped", + ) +} + +/// Field names each role's `expected` block may use. +/// +/// The extension types do not deny unknown fields, so a typo in the corpus +/// would otherwise deserialize to a default and pass. +const SUBJECT_FIELDS: &[&str] = &[ + "id", + "subject_type", + "roles", + "permissions", + "teams", + "claims", +]; +const CLIENT_FIELDS: &[&str] = &[ + "client_id", + "client_name", + "trust_level", + "authorized_scopes", + "authorized_audiences", + "roles", + "permissions", + "teams", + "claims", +]; +const WORKLOAD_FIELDS: &[&str] = &[ + "spiffe_id", + "trust_domain", + "attested_at", + "attestor", + "selectors", + "client_id", +]; + +fn allowed_fields(role: CorpusRole) -> &'static [&'static str] { + match role { + CorpusRole::User => SUBJECT_FIELDS, + CorpusRole::Client => CLIENT_FIELDS, + CorpusRole::Workload => WORKLOAD_FIELDS, + } +} + +fn expected_subject(entry: &CorpusEntry) -> Option { + let value = entry.expected.as_ref()?; + Some( + serde_json::from_value(value.clone()) + .unwrap_or_else(|e| panic!("{}: `expected` is not a subject: {e}", entry.name)), + ) +} + +fn expected_client(entry: &CorpusEntry) -> Option { + let value = entry.expected.as_ref()?; + Some( + serde_json::from_value(value.clone()) + .unwrap_or_else(|e| panic!("{}: `expected` is not a client: {e}", entry.name)), + ) +} + +fn expected_workload(entry: &CorpusEntry) -> Option { + let value = entry.expected.as_ref()?; + Some( + serde_json::from_value(value.clone()).unwrap_or_else(|e| { + panic!("{}: `expected` is not a workload identity: {e}", entry.name) + }), + ) +} + +// ===================================================================== +// Comparison +// ===================================================================== + +/// Serialize a subject with its set-typed fields sorted. +/// +/// `roles`, `permissions` and `teams` are `HashSet`s, so their serialized order +/// is not stable. Everything else compares as serialized, which is what makes +/// this a catch-all for fields the per-field assertions do not name. +fn subject_shape(subject: &SubjectExtension) -> Value { + let mut value = serde_json::to_value(subject).expect("a subject serializes"); + for field in ["roles", "permissions", "teams"] { + if let Some(Value::Array(elements)) = value.get_mut(field) { + elements.sort_by_key(ToString::to_string); + } + } + value +} + +fn sorted(set: &HashSet) -> Vec<&str> { + let mut items: Vec<&str> = set.iter().map(String::as_str).collect(); + items.sort_unstable(); + items +} + +/// Assert two subjects agree, naming the field that diverged. +fn assert_subjects_agree(context: &str, actual: &SubjectExtension, expected: &SubjectExtension) { + assert_eq!(actual.id, expected.id, "{context}: subject id"); + assert_eq!( + sorted(&actual.roles), + sorted(&expected.roles), + "{context}: subject roles" + ); + assert_eq!( + sorted(&actual.permissions), + sorted(&expected.permissions), + "{context}: subject permissions" + ); + assert_eq!( + sorted(&actual.teams), + sorted(&expected.teams), + "{context}: subject teams" + ); + assert_eq!( + actual.claims, expected.claims, + "{context}: subject claims bag" + ); + assert_eq!( + subject_shape(actual), + subject_shape(expected), + "{context}: subject, whole" + ); +} + +/// Assert two clients agree. The collection fields are `Vec`s, so order is part +/// of the comparison: candidate-declaration order and no deduplication are only +/// observable here. +fn assert_clients_agree(context: &str, actual: &ClientExtension, expected: &ClientExtension) { + assert_eq!(actual.client_id, expected.client_id, "{context}: client id"); + assert_eq!( + actual.client_name, expected.client_name, + "{context}: client name" + ); + assert_eq!( + actual.authorized_scopes, expected.authorized_scopes, + "{context}: client authorized scopes, in order" + ); + assert_eq!( + actual.authorized_audiences, expected.authorized_audiences, + "{context}: client authorized audiences, in order" + ); + assert_eq!( + actual.roles, expected.roles, + "{context}: client roles, in order" + ); + assert_eq!( + actual.permissions, expected.permissions, + "{context}: client permissions, in order" + ); + assert_eq!( + actual.teams, expected.teams, + "{context}: client teams, in order" + ); + assert_eq!( + actual.claims, expected.claims, + "{context}: client claims bag" + ); + assert_eq!( + serde_json::to_value(actual).expect("a client serializes"), + serde_json::to_value(expected).expect("a client serializes"), + "{context}: client, whole" + ); +} + +/// Assert two workload identities agree. +fn assert_workloads_agree(context: &str, actual: &WorkloadIdentity, expected: &WorkloadIdentity) { + assert_eq!(actual.spiffe_id, expected.spiffe_id, "{context}: spiffe id"); + assert_eq!( + actual.trust_domain, expected.trust_domain, + "{context}: trust domain" + ); + assert_eq!(actual.attestor, expected.attestor, "{context}: attestor"); + assert_eq!(actual.selectors, expected.selectors, "{context}: selectors"); + assert_eq!(actual.client_id, expected.client_id, "{context}: client id"); + assert_eq!( + serde_json::to_value(actual).expect("a workload identity serializes"), + serde_json::to_value(expected).expect("a workload identity serializes"), + "{context}: workload identity, whole" + ); +} + +// ===================================================================== +// The baseline: the corpus describes today's Rust mapper +// ===================================================================== + +#[test] +fn every_corpus_entry_matches_the_rust_standard_mapper() { + for entry in corpus() { + let context = format!("{} (rust mapper)", entry.name); + match entry.role { + CorpusRole::User => match ( + StandardClaimMap.map_subject(&entry.claims), + expected_subject(&entry), + ) { + (Some(actual), Some(expected)) => { + assert_subjects_agree(&context, &actual, &expected); + }, + (None, None) => {}, + (actual, expected) => panic!( + "{context}: mapper produced {:?} but the corpus expects {:?}", + actual.is_some(), + expected.is_some() + ), + }, + CorpusRole::Client => match ( + StandardClaimMap.map_client(&entry.claims), + expected_client(&entry), + ) { + (Some(actual), Some(expected)) => { + assert_clients_agree(&context, &actual, &expected); + }, + (None, None) => {}, + (actual, expected) => panic!( + "{context}: mapper produced {:?} but the corpus expects {:?}", + actual.is_some(), + expected.is_some() + ), + }, + CorpusRole::Workload => match ( + StandardClaimMap.map_workload(&entry.claims), + expected_workload(&entry), + ) { + (Some(actual), Some(expected)) => { + assert_workloads_agree(&context, &actual, &expected); + }, + (None, None) => {}, + (actual, expected) => panic!( + "{context}: mapper produced {:?} but the corpus expects {:?}", + actual.is_some(), + expected.is_some() + ), + }, + } + } +} + +// ===================================================================== +// The corpus covers what it claims to cover +// ===================================================================== + +#[test] +fn the_corpus_parses_and_every_entry_is_usable() { + let entries = corpus(); + assert!(!entries.is_empty(), "the corpus must not be empty"); + for entry in &entries { + assert!(!entry.name.trim().is_empty(), "every entry needs a name"); + assert!( + !entry.provenance.trim().is_empty(), + "{}: every entry records where its shape came from", + entry.name + ); + assert!( + !entry.claims.is_empty(), + "{}: an entry with no claims tests nothing", + entry.name + ); + if let Some(Value::Object(fields)) = entry.expected.as_ref() { + for field in fields.keys() { + assert!( + allowed_fields(entry.role).contains(&field.as_str()), + "{}: `{field}` is not a field of the {:?} identity; a typo here would \ + deserialize to a default and pass", + entry.name, + entry.role + ); + } + } + } +} + +#[test] +fn entry_names_are_unique() { + let mut seen: HashSet = HashSet::new(); + for entry in corpus() { + assert!( + seen.insert(entry.name.clone()), + "duplicate corpus entry name '{}'; the coverage checks address entries by name", + entry.name + ); + } +} + +/// A later edit must not quietly drop a role from the corpus, which would leave +/// that role's mapping unmeasured while the suite still passed. +#[test] +fn all_three_roles_have_entries() { + let roles: HashSet = corpus().into_iter().map(|entry| entry.role).collect(); + for role in [CorpusRole::User, CorpusRole::Client, CorpusRole::Workload] { + assert!(roles.contains(&role), "the corpus has no {role:?} entry"); + } +} + +/// Every fallback the Rust mapper implements, and the entry covering each +/// branch of it. Asserted structurally rather than trusted to review: a +/// fallback covered on one branch only can pass on the strength of the other. +const FALLBACK_BRANCHES: &[(&str, &str, &str)] = &[ + ( + "client anchor: client_id then azp", + "client-anchor-from-client-id", + "client-anchor-from-azp", + ), + ( + "client scopes: authorized_scopes then scope", + "client-scopes-from-authorized-scopes", + "client-scopes-from-scope-fallback", + ), + ( + "subject permissions: permissions then scope", + "subject-permissions-from-permissions-array", + "subject-permissions-from-scope-fallback", + ), + ( + "subject teams: teams then groups", + "subject-teams-from-teams-array", + "subject-teams-from-groups-fallback", + ), + ( + "workload identity: sub then spiffe_id", + "workload-spiffe-id-from-sub", + "workload-spiffe-id-from-the-spiffe-id-claim-fallback", + ), +]; + +/// The `aud` shapes, which are not a fallback but a polymorphic single claim. +/// One provider flips between all three. +const AUD_SHAPES: &[&str] = &[ + "client-aud-as-a-string", + "client-aud-as-an-array", + "client-aud-absent", +]; + +#[test] +fn every_fallback_has_an_entry_on_both_branches() { + let names: HashSet = corpus().into_iter().map(|entry| entry.name).collect(); + for (fallback, first, second) in FALLBACK_BRANCHES { + for branch in [first, second] { + assert!( + names.contains(*branch), + "{fallback}: no entry named '{branch}' covers this branch" + ); + } + } +} + +#[test] +fn every_aud_shape_has_an_entry() { + let names: HashSet = corpus().into_iter().map(|entry| entry.name).collect(); + for shape in AUD_SHAPES { + assert!( + names.contains(*shape), + "no entry named '{shape}' covers this aud shape" + ); + } +} + +/// A token the mapper declines is as much a contract as one it accepts, and +/// each role declines for its own reason. +#[test] +fn every_role_has_a_declining_entry() { + let declining: HashSet = corpus() + .into_iter() + .filter(|entry| entry.expected.is_none()) + .map(|entry| entry.role) + .collect(); + for role in [CorpusRole::User, CorpusRole::Client, CorpusRole::Workload] { + assert!( + declining.contains(&role), + "no {role:?} entry expects the mapper to decline" + ); + } +} From 0685d00129b32084052538d90810dba59fba7236 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 18:00:29 -0400 Subject: [PATCH 03/27] feat(identity-jwt): add the authored claim map and its compilation A field is written as a path, a list of candidates, or an object carrying `paths` plus `merge`, `split` and `on_missing`. Compilation parses every path and checks every field name against its role's own set, so a typo, a malformed path, or `merge: union` on a field holding one value fails at plugin construction with the field named. Field shapes are read from the JSON value rather than through an untagged enum, whose one error text would name neither the field nor the path. Signed-off-by: Frederico Araujo --- .../identity-jwt/src/claim_map_config.rs | 829 ++++++++++++++++++ builtins/plugins/identity-jwt/src/lib.rs | 6 + 2 files changed, 835 insertions(+) create mode 100644 builtins/plugins/identity-jwt/src/claim_map_config.rs diff --git a/builtins/plugins/identity-jwt/src/claim_map_config.rs b/builtins/plugins/identity-jwt/src/claim_map_config.rs new file mode 100644 index 0000000..f78ad7a --- /dev/null +++ b/builtins/plugins/identity-jwt/src/claim_map_config.rs @@ -0,0 +1,829 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +// The claim map an operator authors, and its compilation into the form the +// mapper runs. Every path is parsed here, once, so nothing parses a path on the +// request path and a malformed map fails at plugin construction. +// +// Field shapes are read out of `serde_json::Value` rather than through derived +// deserializers. An untagged enum over the three authored forms collapses every +// mistake into "data did not match any variant", and the whole point of failing +// at construction is telling the operator which field and which path. + +use std::collections::BTreeMap; + +use praxis_policy_core::extensions::raw_credentials::TokenRole; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +use crate::claim_path::ClaimPath; + +/// Fields a `subject` section may map. +pub const SUBJECT_FIELDS: &[&str] = &["id", "permissions", "roles", "teams"]; + +/// Fields a `client` section may map. +/// +/// `permissions` and `teams` are mappable although no researched provider mints +/// a source for either. They exist on the client identity and no path reaches +/// them otherwise. +pub const CLIENT_FIELDS: &[&str] = &[ + "authorized_audiences", + "authorized_scopes", + "client_id", + "client_name", + "permissions", + "roles", + "teams", +]; + +/// Fields a `workload` section may map. +pub const WORKLOAD_FIELDS: &[&str] = &["client_id", "selectors", "spiffe_id", "trust_domain"]; + +/// Fields whose destination holds one string, so the first candidate resolving +/// to a string wins and `merge: union` is meaningless. +const SCALAR_FIELDS: &[&str] = &[ + "client_id", + "client_name", + "id", + "spiffe_id", + "trust_domain", +]; + +/// How a field combines its resolving candidates. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum MergeMode { + /// Stop at the first candidate that resolves. + #[default] + FirstMatch, + /// Every candidate that resolves contributes. + Union, +} + +/// How a resolved string is broken into elements. +/// +/// An enum rather than a bare bool so a delimiter form can be added later +/// without invalidating a config anyone has already written. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SplitMode { + /// Split on runs of whitespace, which is how all three researched providers + /// delimit `scope`. + Whitespace, +} + +/// What happens when no candidate resolves. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OnMissing { + /// Leave the field empty and emit a diagnostic. + #[default] + Ignore, + /// Decline the mapping, which the resolver turns into a denial. + Deny, +} + +/// One authored candidate: a path, plus whether only an array satisfies it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Candidate { + /// The path as authored. + pub path: String, + /// Require an array. A string is then unusable and the chain continues. + pub array_only: bool, +} + +/// One authored field: its ordered candidates and its options. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FieldMap { + /// Candidates in the order the author wrote them. + pub paths: Vec, + /// How resolving candidates combine. + pub merge: MergeMode, + /// How a resolved string is broken into elements. + pub split: Option, + /// What happens when no candidate resolves. + pub on_missing: OnMissing, +} + +const FIELD_OPTIONS: &[&str] = &["merge", "on_missing", "paths", "split"]; +const CANDIDATE_KEYS: &[&str] = &["array_only", "path"]; + +fn kind_of(value: &Value) -> &'static str { + match value { + Value::Null => "null", + Value::Bool(_) => "a boolean", + Value::Number(_) => "a number", + Value::String(_) => "a string", + Value::Array(_) => "a list", + Value::Object(_) => "an object", + } +} + +fn option_from_value Deserialize<'de>>( + field: &str, + option: &str, + value: &Value, +) -> Result { + serde_json::from_value(value.clone()) + .map_err(|e| format!("{field}: `{option}` is not valid: {e}")) +} + +impl Candidate { + fn from_value(field: &str, value: &Value) -> Result { + match value { + Value::String(path) => Ok(Self { + path: path.clone(), + array_only: false, + }), + Value::Object(entries) => { + for key in entries.keys() { + if !CANDIDATE_KEYS.contains(&key.as_str()) { + return Err(format!( + "{field}: unknown candidate key `{key}`; a candidate takes {}", + CANDIDATE_KEYS.join(", ") + )); + } + } + let path = match entries.get("path") { + Some(Value::String(path)) => path.clone(), + Some(other) => { + return Err(format!( + "{field}: a candidate's `path` must be a string, got {}", + kind_of(other) + )); + }, + None => return Err(format!("{field}: a candidate object needs a `path`")), + }; + let array_only = match entries.get("array_only") { + Some(value) => option_from_value(field, "array_only", value)?, + None => false, + }; + Ok(Self { path, array_only }) + }, + other => Err(format!( + "{field}: a candidate is a path or an object with `path`, got {}", + kind_of(other) + )), + } + } +} + +impl FieldMap { + /// Read a field's authored form: a single path, an ordered list of + /// candidates, or an object carrying `paths` plus options. + /// + /// `field` is the qualified name (`subject.roles`) and appears in every + /// error, since the value alone does not say which field it was written for. + /// + /// # Errors + /// + /// Returns a message naming the field when the value is not one of the three + /// forms, when an option or candidate key is unrecognized, or when `paths` + /// is missing. + pub fn from_value(field: &str, value: &Value) -> Result { + match value { + Value::String(path) => Ok(Self { + paths: vec![Candidate { + path: path.clone(), + array_only: false, + }], + merge: MergeMode::default(), + split: None, + on_missing: OnMissing::default(), + }), + Value::Array(items) => Ok(Self { + paths: candidates_from_list(field, items)?, + merge: MergeMode::default(), + split: None, + on_missing: OnMissing::default(), + }), + Value::Object(entries) => { + for key in entries.keys() { + if !FIELD_OPTIONS.contains(&key.as_str()) { + return Err(format!( + "{field}: unknown option `{key}`; a field takes {}", + FIELD_OPTIONS.join(", ") + )); + } + } + let paths = match entries.get("paths") { + Some(Value::Array(items)) => candidates_from_list(field, items)?, + Some(Value::String(path)) => vec![Candidate { + path: path.clone(), + array_only: false, + }], + Some(other) => { + return Err(format!( + "{field}: `paths` is a path or a list of candidates, got {}", + kind_of(other) + )); + }, + None => { + return Err(format!( + "{field}: the expanded form needs `paths`; write the field as a path \ + or a list of paths if it has no options" + )); + }, + }; + Ok(Self { + paths, + merge: match entries.get("merge") { + Some(value) => option_from_value(field, "merge", value)?, + None => MergeMode::default(), + }, + split: match entries.get("split") { + Some(value) => Some(option_from_value(field, "split", value)?), + None => None, + }, + on_missing: match entries.get("on_missing") { + Some(value) => option_from_value(field, "on_missing", value)?, + None => OnMissing::default(), + }, + }) + }, + other => Err(format!( + "{field}: a field maps to a path, a list of paths, or an object with `paths`, got \ + {}", + kind_of(other) + )), + } + } +} + +fn candidates_from_list(field: &str, items: &[Value]) -> Result, String> { + items + .iter() + .map(|item| Candidate::from_value(field, item)) + .collect() +} + +/// Claim names to drop from, or restore to, the policy-visible claims bag. +/// +/// Plain names rather than paths: the bag is keyed by top-level claim name. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClaimsOverrides { + /// Claims to drop even though nothing consumed them. + #[serde(default)] + pub exclude: Vec, + /// Claims to keep even though a path consumed them, or because they are + /// registered JWT claims the inference always drops. `iss` is the reason + /// this exists: it is otherwise unreachable from a policy. + #[serde(default)] + pub include: Vec, +} + +/// One role's authored section: field name to field map. +/// +/// Field names are checked against the role's own set during compilation, which +/// is where the role is known and can be named in the error. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(transparent)] +pub struct RoleMapConfig(pub BTreeMap); + +/// The claim map an operator writes under `claim_map:`. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ClaimMapConfig { + /// The section a `role: user` resolver uses. + #[serde(default)] + pub subject: Option, + /// The section a `role: client` resolver uses. + #[serde(default)] + pub client: Option, + /// The section a `role: caller_workload` resolver uses. + #[serde(default)] + pub workload: Option, + /// Overrides for the inferred claims-bag exclusions. + #[serde(default)] + pub claims: Option, +} + +// ===================================================================== +// Compiled form +// ===================================================================== + +/// A candidate with its path parsed. +#[derive(Debug, Clone)] +pub struct CompiledCandidate { + path: ClaimPath, + array_only: bool, +} + +impl CompiledCandidate { + /// The parsed path. + pub fn path(&self) -> &ClaimPath { + &self.path + } + + /// Whether only an array satisfies this candidate. + pub fn array_only(&self) -> bool { + self.array_only + } +} + +/// A field with every candidate path parsed. +#[derive(Debug, Clone)] +pub struct CompiledField { + candidates: Vec, + merge: MergeMode, + split: Option, + on_missing: OnMissing, +} + +impl CompiledField { + /// The candidates, in the order the author wrote them. + pub fn candidates(&self) -> &[CompiledCandidate] { + &self.candidates + } + + /// How resolving candidates combine. + pub fn merge(&self) -> MergeMode { + self.merge + } + + /// How a resolved string is broken into elements. + pub fn split(&self) -> Option { + self.split + } + + /// What happens when no candidate resolves. + pub fn on_missing(&self) -> OnMissing { + self.on_missing + } +} + +/// One role's compiled section. +#[derive(Debug, Clone, Default)] +pub struct CompiledRoleMap { + fields: BTreeMap<&'static str, CompiledField>, +} + +impl CompiledRoleMap { + /// The field's compiled form, or `None` when the section declares no path + /// for it. + pub fn field(&self, name: &str) -> Option<&CompiledField> { + self.fields.get(name) + } + + /// Every declared field, by name. + pub fn fields(&self) -> impl Iterator { + self.fields.iter().map(|(name, field)| (*name, field)) + } +} + +/// A claim map with every path parsed and every field name checked. +#[derive(Debug, Clone, Default)] +pub struct CompiledClaimMap { + subject: Option, + client: Option, + workload: Option, + claims: ClaimsOverrides, +} + +impl CompiledClaimMap { + /// The section matching a resolver's configured role. + /// + /// # Errors + /// + /// Returns a message naming the role when the map declares no section for + /// it, so a misconfigured pairing fails at load rather than denying every + /// request. + pub fn role(&self, role: &TokenRole) -> Result<&CompiledRoleMap, String> { + let (name, section) = match role { + TokenRole::User => ("subject", self.subject.as_ref()), + TokenRole::Client => ("client", self.client.as_ref()), + TokenRole::CallerWorkload => ("workload", self.workload.as_ref()), + other => { + return Err(format!( + "no claim-map section can serve role {other:?}; use `user`, `client` or \ + `caller_workload`" + )); + }, + }; + section + .ok_or_else(|| format!("the claim map declares no `{name}` section for `role: {name}`")) + } + + /// The claims-bag overrides. + pub fn claims(&self) -> &ClaimsOverrides { + &self.claims + } +} + +impl ClaimMapConfig { + /// Parse every path, check every field name, and reject the combinations + /// that cannot mean anything. + /// + /// # Errors + /// + /// Returns a message naming the field and, where relevant, the path, for a + /// malformed path, an unknown field name in a role section, an empty + /// candidate list, `merge: union` on a field holding one string, and a claim + /// named in both `exclude` and `include`. + pub fn compile(&self) -> Result { + let claims = self.claims.clone().unwrap_or_default(); + for claim in &claims.include { + if claims.exclude.iter().any(|excluded| excluded == claim) { + return Err(format!( + "claims: `{claim}` is in both `exclude` and `include`; pick one" + )); + } + } + + Ok(CompiledClaimMap { + subject: compile_role("subject", SUBJECT_FIELDS, self.subject.as_ref())?, + client: compile_role("client", CLIENT_FIELDS, self.client.as_ref())?, + workload: compile_role("workload", WORKLOAD_FIELDS, self.workload.as_ref())?, + claims, + }) + } +} + +fn compile_role( + role: &str, + allowed: &[&'static str], + section: Option<&RoleMapConfig>, +) -> Result, String> { + let Some(RoleMapConfig(authored)) = section else { + return Ok(None); + }; + + let mut fields: BTreeMap<&'static str, CompiledField> = BTreeMap::new(); + for (name, value) in authored { + let interned = allowed + .iter() + .find(|candidate| **candidate == name.as_str()) + .ok_or_else(|| { + format!( + "{role}: unknown field `{name}`; a {role} section maps {}", + allowed.join(", ") + ) + })?; + let qualified = format!("{role}.{name}"); + let authored_field = FieldMap::from_value(&qualified, value)?; + + if authored_field.paths.is_empty() { + return Err(format!( + "{qualified}: `paths` is empty, so nothing can resolve" + )); + } + if authored_field.merge == MergeMode::Union && SCALAR_FIELDS.contains(interned) { + return Err(format!( + "{qualified}: `merge: union` needs a field that holds a collection, and \ + {qualified} holds one value" + )); + } + + let mut candidates = Vec::with_capacity(authored_field.paths.len()); + for candidate in &authored_field.paths { + let path = ClaimPath::parse(&candidate.path) + .map_err(|reason| format!("{qualified}: {reason}"))?; + candidates.push(CompiledCandidate { + path, + array_only: candidate.array_only, + }); + } + + fields.insert( + interned, + CompiledField { + candidates, + merge: authored_field.merge, + split: authored_field.split, + on_missing: authored_field.on_missing, + }, + ); + } + + Ok(Some(CompiledRoleMap { fields })) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "tests" +)] +mod tests { + use super::*; + use serde_json::json; + + fn config(value: Value) -> ClaimMapConfig { + serde_json::from_value(value).expect("the map should deserialize") + } + + fn compiled(value: Value) -> CompiledClaimMap { + config(value).compile().expect("the map should compile") + } + + fn compile_err(value: Value) -> String { + config(value) + .compile() + .expect_err("the map should be rejected") + } + + fn authored_paths(field: &CompiledField) -> Vec { + field + .candidates() + .iter() + .map(|candidate| candidate.path().to_string()) + .collect() + } + + // ---- the three field forms ------------------------------------------- + + /// A shorthand path, an ordered list, and the expanded object form all reach + /// the same compiled shape, and each keeps the order the author wrote. + #[test] + fn all_three_field_forms_compile_to_the_authored_candidate_order() { + let map = compiled(json!({ + "subject": { + "id": "sub", + "teams": ["teams", "groups"], + "roles": { + "paths": ["realm_access.roles", "resource_access.my-api.roles"], + "merge": "union", + }, + } + })); + let subject = map.role(&TokenRole::User).unwrap(); + + assert_eq!(authored_paths(subject.field("id").unwrap()), vec!["sub"]); + assert_eq!( + authored_paths(subject.field("teams").unwrap()), + vec!["teams", "groups"], + ); + let roles = subject.field("roles").unwrap(); + assert_eq!( + authored_paths(roles), + vec!["realm_access.roles", "resource_access.my-api.roles"], + ); + assert_eq!(roles.merge(), MergeMode::Union); + } + + #[test] + fn a_candidate_object_carries_the_array_only_flag() { + let map = compiled(json!({ + "subject": { + "permissions": { + "paths": [{"path": "permissions", "array_only": true}, "scope"], + "split": "whitespace", + } + } + })); + let permissions = map + .role(&TokenRole::User) + .unwrap() + .field("permissions") + .unwrap(); + let flags: Vec = permissions + .candidates() + .iter() + .map(CompiledCandidate::array_only) + .collect(); + assert_eq!( + flags, + vec![true, false], + "the declared flag survives compilation and a bare path leaves it unset" + ); + assert_eq!(permissions.split(), Some(SplitMode::Whitespace)); + } + + #[test] + fn every_option_round_trips_and_omitted_options_take_their_defaults() { + let declared = compiled(json!({ + "client": { + "roles": { + "paths": ["roles"], + "merge": "union", + "split": "whitespace", + "on_missing": "deny", + } + } + })); + let field = declared + .role(&TokenRole::Client) + .unwrap() + .field("roles") + .unwrap(); + assert_eq!(field.merge(), MergeMode::Union); + assert_eq!(field.split(), Some(SplitMode::Whitespace)); + assert_eq!(field.on_missing(), OnMissing::Deny); + + let bare = compiled(json!({"client": {"roles": "roles"}})); + let field = bare + .role(&TokenRole::Client) + .unwrap() + .field("roles") + .unwrap(); + assert_eq!(field.merge(), MergeMode::FirstMatch); + assert_eq!(field.split(), None); + assert_eq!(field.on_missing(), OnMissing::Ignore); + } + + /// The expanded form accepts a bare path for `paths` too, which is the + /// natural thing to write when a field needs an option but only one source. + #[test] + fn the_expanded_form_accepts_a_single_path_for_paths() { + let map = compiled(json!({ + "subject": {"permissions": {"paths": "scope", "split": "whitespace"}} + })); + let field = map + .role(&TokenRole::User) + .unwrap() + .field("permissions") + .unwrap(); + assert_eq!(authored_paths(field), vec!["scope"]); + assert_eq!(field.split(), Some(SplitMode::Whitespace)); + } + + // ---- claims overrides ------------------------------------------------- + + #[test] + fn claims_overrides_compile_and_default_to_empty() { + let with = compiled(json!({ + "subject": {"id": "sub"}, + "claims": {"exclude": ["internal_debug"], "include": ["iss"]}, + })); + assert_eq!(with.claims().exclude, vec!["internal_debug"]); + assert_eq!(with.claims().include, vec!["iss"]); + + let without = compiled(json!({"subject": {"id": "sub"}})); + assert!(without.claims().exclude.is_empty()); + assert!(without.claims().include.is_empty()); + } + + #[test] + fn a_claim_in_both_exclude_and_include_is_rejected_and_named() { + let err = compile_err(json!({ + "subject": {"id": "sub"}, + "claims": {"exclude": ["tenant"], "include": ["tenant"]}, + })); + assert!(err.contains("tenant"), "{err}"); + assert!(err.contains("exclude") && err.contains("include"), "{err}"); + } + + // ---- role sections ---------------------------------------------------- + + /// An empty section still declares the role, which is what the role check + /// asks. The anchor then denies at runtime rather than at load. + #[test] + fn a_declared_but_empty_role_section_compiles() { + let map = compiled(json!({"client": {}})); + let client = map + .role(&TokenRole::Client) + .expect("an empty section still declares the role"); + assert_eq!(client.fields().count(), 0); + } + + #[test] + fn asking_for_an_undeclared_role_names_it() { + let map = compiled(json!({"subject": {"id": "sub"}})); + let err = map + .role(&TokenRole::Client) + .expect_err("a subject-only map cannot serve a client resolver"); + assert!(err.contains("client"), "{err}"); + } + + #[test] + fn a_custom_role_cannot_be_served() { + let map = compiled(json!({"subject": {"id": "sub"}})); + assert!( + map.role(&TokenRole::Custom("bespoke".to_owned())).is_err(), + "there is no section for a host-defined role" + ); + } + + // ---- rejection -------------------------------------------------------- + + #[test] + fn a_malformed_path_names_both_the_field_and_the_path() { + let err = compile_err(json!({"subject": {"roles": "realm_access..roles"}})); + assert!(err.contains("subject.roles"), "{err}"); + assert!(err.contains("realm_access..roles"), "{err}"); + } + + #[test] + fn a_malformed_path_inside_a_candidate_list_is_rejected() { + let err = compile_err(json!({"subject": {"roles": ["roles", "teams\\"]}})); + assert!(err.contains("subject.roles"), "{err}"); + assert!(err.contains("teams\\"), "{err}"); + } + + #[test] + fn an_unknown_field_name_names_the_field_and_the_role() { + let err = compile_err(json!({"subject": {"rolez": "roles"}})); + assert!(err.contains("rolez"), "{err}"); + assert!(err.contains("subject"), "{err}"); + assert!( + err.contains("roles"), + "the message should list the valid fields: {err}" + ); + } + + /// A field valid for one role is not valid for another, so the check is per + /// role rather than a single union. + #[test] + fn a_field_belonging_to_another_role_is_rejected() { + let err = compile_err(json!({"subject": {"spiffe_id": "sub"}})); + assert!( + err.contains("spiffe_id") && err.contains("subject"), + "{err}" + ); + } + + #[test] + fn union_on_a_field_holding_one_value_is_rejected() { + for (role, field) in [ + ("subject", "id"), + ("client", "client_id"), + ("workload", "spiffe_id"), + ] { + let err = compile_err(json!({ + role: {field: {"paths": ["a", "b"], "merge": "union"}} + })); + assert!( + err.contains(&format!("{role}.{field}")), + "{role}.{field}: {err}" + ); + } + } + + #[test] + fn an_empty_candidate_list_is_rejected() { + let err = compile_err(json!({"subject": {"roles": []}})); + assert!(err.contains("subject.roles"), "{err}"); + assert!(err.contains("empty"), "{err}"); + } + + #[test] + fn the_expanded_form_without_paths_names_paths() { + let err = compile_err(json!({"subject": {"roles": {}}})); + assert!(err.contains("subject.roles"), "{err}"); + assert!(err.contains("paths"), "{err}"); + } + + /// The three authored forms are dispatched on the JSON kind, so anything + /// else is rejected by naming the field and the forms, not by dumping a + /// serde variant list. + #[test] + fn a_field_given_a_number_or_boolean_names_the_field() { + for value in [json!(42), json!(true), json!(null)] { + let err = compile_err(json!({"subject": {"roles": value}})); + assert!(err.contains("subject.roles"), "{err}"); + assert!( + !err.contains("did not match any variant"), + "the message must not be a serde variant dump: {err}" + ); + } + } + + #[test] + fn an_unknown_field_option_is_rejected_and_listed() { + let err = compile_err(json!({ + "subject": {"roles": {"paths": ["roles"], "mergemode": "union"}} + })); + assert!(err.contains("mergemode"), "{err}"); + assert!(err.contains("merge"), "{err}"); + } + + #[test] + fn an_unknown_candidate_key_is_rejected() { + let err = compile_err(json!({ + "subject": {"roles": [{"path": "roles", "arrayonly": true}]} + })); + assert!(err.contains("arrayonly"), "{err}"); + } + + #[test] + fn a_candidate_object_without_a_path_is_rejected() { + let err = compile_err(json!({"subject": {"roles": [{"array_only": true}]}})); + assert!(err.contains("path"), "{err}"); + } + + #[test] + fn an_unrecognized_option_value_is_rejected() { + for (option, value) in [ + ("merge", json!("intersection")), + ("split", json!("comma")), + ("on_missing", json!("warn")), + ] { + let err = compile_err(json!({ + "subject": {"roles": {"paths": ["roles"], option: value}} + })); + assert!(err.contains("subject.roles"), "{option}: {err}"); + assert!(err.contains(option), "{option}: {err}"); + } + } + + /// A misspelled section name is caught by the top level, which lists the + /// sections a map may declare. + #[test] + fn an_unknown_top_level_section_is_rejected() { + let err = serde_json::from_value::(json!({"subjekt": {"id": "sub"}})) + .expect_err("a misspelled section must not be ignored"); + assert!(err.to_string().contains("subjekt"), "{err}"); + } +} diff --git a/builtins/plugins/identity-jwt/src/lib.rs b/builtins/plugins/identity-jwt/src/lib.rs index a1a8e31..dc8041e 100644 --- a/builtins/plugins/identity-jwt/src/lib.rs +++ b/builtins/plugins/identity-jwt/src/lib.rs @@ -46,6 +46,8 @@ /// Maps validated claims onto the identity slots. pub mod claim_map; +/// The claim map an operator authors, and its compiled form. +pub mod claim_map_config; /// Addresses a claim value by a dot-separated path. pub mod claim_path; /// Plugin configuration and its validation. @@ -58,6 +60,10 @@ pub mod resolver; pub mod trusted_issuer; pub use claim_map::{ClaimMap, ClaimMapper, StandardClaimMap}; +pub use claim_map_config::{ + ClaimMapConfig, ClaimsOverrides, CompiledClaimMap, CompiledRoleMap, MergeMode, OnMissing, + SplitMode, +}; pub use claim_path::ClaimPath; pub use config::{DecodingKeySource, JwtIdentityResolverConfig, TrustedIssuerConfig}; pub use factory::{JwtIdentityFactory, KIND}; From 5b244d269851375452f4f0c3114ec279e826e69c Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 18:06:48 -0400 Subject: [PATCH 04/27] feat(identity-jwt): add the mapper a compiled claim map drives One resolution routine for all three roles. A candidate that is present but the wrong shape counts as not resolving, so the fallback chain continues; a claim holding an empty array does resolve, so it stops the chain and is reported as empty rather than missing. The claims bag excludes the registered JWT claims plus every single-segment declared path, which reproduces the Rust mapper's static reserved lists, and a nested path leaves its parent policy-visible. `claims.include` can restore any name, which is what makes gating on the issuer expressible. The SPIFFE prefix is checked per candidate and has no config surface. Misses aggregate into one debug event per call naming every path tried, distinct from the event for a field that resolved to nothing. Signed-off-by: Frederico Araujo --- .../identity-jwt/src/claim_map_config.rs | 59 +- .../identity-jwt/src/configured_mapper.rs | 1135 +++++++++++++++++ builtins/plugins/identity-jwt/src/lib.rs | 3 + 3 files changed, 1192 insertions(+), 5 deletions(-) create mode 100644 builtins/plugins/identity-jwt/src/configured_mapper.rs diff --git a/builtins/plugins/identity-jwt/src/claim_map_config.rs b/builtins/plugins/identity-jwt/src/claim_map_config.rs index f78ad7a..bb6f463 100644 --- a/builtins/plugins/identity-jwt/src/claim_map_config.rs +++ b/builtins/plugins/identity-jwt/src/claim_map_config.rs @@ -468,11 +468,34 @@ fn compile_role( "{qualified}: `paths` is empty, so nothing can resolve" )); } - if authored_field.merge == MergeMode::Union && SCALAR_FIELDS.contains(interned) { - return Err(format!( - "{qualified}: `merge: union` needs a field that holds a collection, and \ - {qualified} holds one value" - )); + // A field holding one value takes the first candidate resolving to a + // string, so the three collection options cannot mean anything on it. + // Rejecting beats ignoring: an ignored `array_only` would make the field + // resolve never, surfacing as a runtime denial instead of a load error. + if SCALAR_FIELDS.contains(interned) { + if authored_field.merge == MergeMode::Union { + return Err(format!( + "{qualified}: `merge: union` needs a field that holds a collection, and \ + {qualified} holds one value" + )); + } + if authored_field.split.is_some() { + return Err(format!( + "{qualified}: `split` needs a field that holds a collection, and {qualified} \ + holds one value" + )); + } + if let Some(candidate) = authored_field + .paths + .iter() + .find(|candidate| candidate.array_only) + { + return Err(format!( + "{qualified}: `array_only` on '{}' would let nothing resolve, because \ + {qualified} holds one value", + candidate.path + )); + } } let mut candidates = Vec::with_capacity(authored_field.paths.len()); @@ -751,6 +774,32 @@ mod tests { } } + /// `split` and `array_only` are as meaningless on a field holding one value + /// as `union` is. Ignoring `array_only` would be worse than rejecting it: it + /// would let nothing resolve, turning a config mistake into a runtime denial. + #[test] + fn split_and_array_only_on_a_field_holding_one_value_are_rejected() { + let split = compile_err(json!({ + "subject": {"id": {"paths": ["sub"], "split": "whitespace"}} + })); + assert!( + split.contains("subject.id") && split.contains("split"), + "{split}" + ); + + let array_only = compile_err(json!({ + "subject": {"id": [{"path": "sub", "array_only": true}]} + })); + assert!( + array_only.contains("subject.id") && array_only.contains("array_only"), + "{array_only}" + ); + assert!( + array_only.contains("sub"), + "the offending candidate is named: {array_only}" + ); + } + #[test] fn an_empty_candidate_list_is_rejected() { let err = compile_err(json!({"subject": {"roles": []}})); diff --git a/builtins/plugins/identity-jwt/src/configured_mapper.rs b/builtins/plugins/identity-jwt/src/configured_mapper.rs new file mode 100644 index 0000000..0ace6bd --- /dev/null +++ b/builtins/plugins/identity-jwt/src/configured_mapper.rs @@ -0,0 +1,1135 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +// The `ClaimMapper` a compiled claim map drives. One resolution routine serves +// all three roles: walk a field's candidates in the order they were authored, +// take what each contributes, and stop at the first that resolves unless the +// field asked for a union. +// +// A candidate whose value is present but the wrong shape counts as not +// resolving, so the chain keeps looking. That is what the Rust standard mapper +// does (`and_then(Value::as_array)` returning `None` runs the `else if`), and it +// is what makes an unusable shape ignorable rather than fatal. + +use std::collections::{HashMap, HashSet}; + +use praxis_policy_core::extensions::raw_credentials::TokenRole; +use praxis_policy_core::extensions::{ClientExtension, SubjectExtension, WorkloadIdentity}; +use serde_json::Value; + +use crate::claim_map::{ClaimMap, ClaimMapper}; +use crate::claim_map_config::{ + CompiledClaimMap, CompiledField, CompiledRoleMap, MergeMode, OnMissing, SplitMode, +}; + +/// Every SPIFFE ID starts here, and no configuration can turn the check off. +const SPIFFE_SCHEME: &str = "spiffe://"; + +/// The registered JWT claims, which the claims bag drops unless a map asks for +/// one back. They are properties of token validation rather than subject +/// attributes. +const REGISTERED_CLAIMS: &[&str] = &["aud", "exp", "iat", "iss", "jti", "nbf", "sub"]; + +/// A `ClaimMapper` driven by a compiled claim map. +/// +/// Holds no mutable state and parses nothing: every path was parsed when the map +/// compiled, so a request only walks claims. +#[derive(Debug, Clone)] +pub struct ConfiguredClaimMap { + map: CompiledClaimMap, +} + +impl ConfiguredClaimMap { + /// Wrap a compiled map as a mapper. + pub fn new(map: CompiledClaimMap) -> Self { + Self { map } + } + + /// The compiled map this mapper runs. + pub fn compiled(&self) -> &CompiledClaimMap { + &self.map + } + + /// The policy-visible claims for a role, after the inferred exclusions and + /// the map's overrides. + /// + /// Exclusion is computed from *declared* paths, not resolved ones, which is + /// what the Rust mapper's static reserved lists do: `azp` is dropped whether + /// or not the token carries it, and `scope` is dropped even when + /// `permissions` won. Only a single-segment path consumes its claim, so a + /// nested path leaves its parent whole. + fn claims_bag(&self, section: &CompiledRoleMap, claims: &ClaimMap) -> HashMap { + let mut excluded: HashSet<&str> = REGISTERED_CLAIMS.iter().copied().collect(); + for (_, field) in section.fields() { + for candidate in field.candidates() { + if let Some(name) = candidate.path().single_segment() { + excluded.insert(name); + } + } + } + let overrides = self.map.claims(); + for name in &overrides.exclude { + excluded.insert(name.as_str()); + } + for name in &overrides.include { + excluded.remove(name.as_str()); + } + + claims + .iter() + .filter(|(name, _)| !excluded.contains(name.as_str())) + .map(|(name, value)| (name.clone(), value.clone())) + .collect() + } +} + +// ===================================================================== +// Resolution +// ===================================================================== + +/// What a field's candidates produced. +struct FieldOutcome { + values: Vec, + /// Whether any candidate resolved. Distinct from `values` being empty: a + /// claim holding `[]` resolves and contributes nothing, which is not the + /// same as a path that led nowhere. + resolved: bool, + paths_tried: Vec, +} + +/// Append what one value contributes, or report that its shape cannot serve +/// this field. +fn contribute( + value: &Value, + array_only: bool, + split: Option, + out: &mut Vec, +) -> bool { + match value { + Value::Array(items) => { + for item in items { + if let Some(text) = item.as_str() { + push_text(text, split, out); + } + } + true + }, + Value::String(text) => { + if array_only { + return false; + } + push_text(text, split, out); + true + }, + Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => false, + } +} + +fn push_text(text: &str, split: Option, out: &mut Vec) { + match split { + Some(SplitMode::Whitespace) => { + out.extend(text.split_whitespace().map(str::to_owned)); + }, + None => out.push(text.to_owned()), + } +} + +fn resolve_collection(field: &CompiledField, claims: &ClaimMap) -> FieldOutcome { + let mut values = Vec::new(); + let mut resolved = false; + let mut paths_tried = Vec::with_capacity(field.candidates().len()); + + for candidate in field.candidates() { + paths_tried.push(candidate.path().to_string()); + let Some(value) = candidate.path().resolve(claims) else { + continue; + }; + if !contribute(value, candidate.array_only(), field.split(), &mut values) { + continue; + } + resolved = true; + if field.merge() == MergeMode::FirstMatch { + break; + } + } + + FieldOutcome { + values, + resolved, + paths_tried, + } +} + +/// Resolve a field holding one value: the first candidate resolving to a string +/// `accept` allows. +/// +/// `accept` is how the SPIFFE prefix is enforced per candidate rather than after +/// the fact, so a non-SPIFFE `sub` is skipped and a later SPIFFE-shaped claim +/// still wins. +fn resolve_scalar( + field: &CompiledField, + claims: &ClaimMap, + accept: impl Fn(&str) -> bool, +) -> (Option, Vec) { + let mut paths_tried = Vec::with_capacity(field.candidates().len()); + for candidate in field.candidates() { + paths_tried.push(candidate.path().to_string()); + if let Some(text) = candidate.path().resolve(claims).and_then(Value::as_str) + && accept(text) + { + return (Some(text.to_owned()), paths_tried); + } + } + (None, paths_tried) +} + +// ===================================================================== +// Diagnostics +// ===================================================================== + +/// A mapping call's misses, gathered so a badly configured map costs one event +/// per request rather than one per field. +struct Diagnostics { + role: &'static str, + missed: Vec<(&'static str, Vec)>, + empty: Vec<&'static str>, + denied: Vec<&'static str>, +} + +impl Diagnostics { + fn new(role: &'static str) -> Self { + Self { + role, + missed: Vec::new(), + empty: Vec::new(), + denied: Vec::new(), + } + } + + fn record(&mut self, name: &'static str, on_missing: OnMissing, outcome: &FieldOutcome) { + if outcome.resolved { + if outcome.values.is_empty() { + self.empty.push(name); + } + return; + } + self.missed.push((name, outcome.paths_tried.clone())); + if on_missing == OnMissing::Deny { + self.denied.push(name); + } + } + + fn record_scalar_miss( + &mut self, + name: &'static str, + on_missing: OnMissing, + paths_tried: Vec, + ) { + self.missed.push((name, paths_tried)); + if on_missing == OnMissing::Deny { + self.denied.push(name); + } + } + + /// Whether a field declared `on_missing: deny` and did not resolve. + fn declined(&self) -> bool { + !self.denied.is_empty() + } + + fn emit(&self) { + if !self.missed.is_empty() { + let fields: Vec<&str> = self.missed.iter().map(|(name, _)| *name).collect(); + let tried: Vec = self + .missed + .iter() + .map(|(name, paths)| format!("{name}: {}", paths.join(", "))) + .collect(); + tracing::debug!( + role = self.role, + fields = %fields.join(", "), + paths_tried = %tried.join("; "), + "claim map: no candidate resolved for these fields", + ); + } + if !self.empty.is_empty() { + tracing::debug!( + role = self.role, + fields = %self.empty.join(", "), + "claim map: these fields resolved to an empty collection", + ); + } + if !self.denied.is_empty() { + tracing::warn!( + role = self.role, + fields = %self.denied.join(", "), + "claim map: declining the token because a field declared `on_missing: deny` and \ + no candidate resolved", + ); + } + } +} + +/// Resolve a collection field, or an empty list when the section declares none. +fn collection( + section: &CompiledRoleMap, + name: &'static str, + claims: &ClaimMap, + diag: &mut Diagnostics, +) -> Vec { + let Some(field) = section.field(name) else { + return Vec::new(); + }; + let outcome = resolve_collection(field, claims); + diag.record(name, field.on_missing(), &outcome); + outcome.values +} + +/// Resolve a field holding one value, or `None` when the section declares none. +fn scalar( + section: &CompiledRoleMap, + name: &'static str, + claims: &ClaimMap, + diag: &mut Diagnostics, + accept: impl Fn(&str) -> bool, +) -> Option { + let field = section.field(name)?; + let (value, paths_tried) = resolve_scalar(field, claims, accept); + if value.is_none() { + diag.record_scalar_miss(name, field.on_missing(), paths_tried); + } + value +} + +fn accept_any(_: &str) -> bool { + true +} + +// ===================================================================== +// The mapper +// ===================================================================== + +impl ClaimMapper for ConfiguredClaimMap { + fn map_subject(&self, claims: &ClaimMap) -> Option { + let section = self.map.role(&TokenRole::User).ok()?; + let mut diag = Diagnostics::new("subject"); + + let id = scalar(section, "id", claims, &mut diag, accept_any); + let roles = collection(section, "roles", claims, &mut diag); + let permissions = collection(section, "permissions", claims, &mut diag); + let teams = collection(section, "teams", claims, &mut diag); + + diag.emit(); + if diag.declined() { + return None; + } + + Some(SubjectExtension { + id: Some(id?), + roles: roles.into_iter().collect(), + permissions: permissions.into_iter().collect(), + teams: teams.into_iter().collect(), + claims: self.claims_bag(section, claims), + ..Default::default() + }) + } + + fn map_client(&self, claims: &ClaimMap) -> Option { + let section = self.map.role(&TokenRole::Client).ok()?; + let mut diag = Diagnostics::new("client"); + + let client_id = scalar(section, "client_id", claims, &mut diag, accept_any); + let client_name = scalar(section, "client_name", claims, &mut diag, accept_any); + let authorized_scopes = collection(section, "authorized_scopes", claims, &mut diag); + let authorized_audiences = collection(section, "authorized_audiences", claims, &mut diag); + let roles = collection(section, "roles", claims, &mut diag); + let permissions = collection(section, "permissions", claims, &mut diag); + let teams = collection(section, "teams", claims, &mut diag); + + diag.emit(); + if diag.declined() { + return None; + } + + Some(ClientExtension { + client_id: client_id?, + client_name, + authorized_scopes, + authorized_audiences, + roles, + permissions, + teams, + claims: self.claims_bag(section, claims), + ..Default::default() + }) + } + + fn map_workload(&self, claims: &ClaimMap) -> Option { + let section = self.map.role(&TokenRole::CallerWorkload).ok()?; + let mut diag = Diagnostics::new("workload"); + + // Prefix-check every candidate before it counts as resolving: a + // non-SPIFFE `sub` must not smuggle in an arbitrary `spiffe_id` claim, + // and a later SPIFFE-shaped candidate must still win. + let spiffe_id = scalar(section, "spiffe_id", claims, &mut diag, |text| { + text.starts_with(SPIFFE_SCHEME) + }); + let client_id = scalar(section, "client_id", claims, &mut diag, accept_any); + let selectors = collection(section, "selectors", claims, &mut diag); + let mapped_trust_domain = section + .field("trust_domain") + .map(|_| scalar(section, "trust_domain", claims, &mut diag, accept_any)); + + diag.emit(); + if diag.declined() { + return None; + } + + let spiffe_id = spiffe_id?; + let trust_domain = match mapped_trust_domain { + Some(mapped) => mapped, + None => trust_domain_of(&spiffe_id), + }; + + Some(WorkloadIdentity { + spiffe_id: Some(spiffe_id), + trust_domain, + attested_at: None, + attestor: Some("jwt".to_owned()), + selectors, + client_id, + }) + } +} + +/// The trust domain is the SPIFFE URI's authority, which the standard makes the +/// trust boundary. Deriving it from `iss` instead is explicitly discouraged. +fn trust_domain_of(spiffe_id: &str) -> Option { + spiffe_id + .strip_prefix(SPIFFE_SCHEME) + .and_then(|rest| rest.split('/').next()) + .map(str::to_owned) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "tests" +)] +mod tests { + use std::sync::{Arc, Mutex}; + + use serde_json::json; + + use super::*; + use crate::claim_map_config::ClaimMapConfig; + + fn claims(value: Value) -> ClaimMap { + value.as_object().unwrap().clone().into_iter().collect() + } + + fn mapper(map: Value) -> ConfiguredClaimMap { + let config: ClaimMapConfig = serde_json::from_value(map).expect("the map deserializes"); + ConfiguredClaimMap::new(config.compile().expect("the map compiles")) + } + + fn sorted(values: &HashSet) -> Vec<&str> { + let mut items: Vec<&str> = values.iter().map(String::as_str).collect(); + items.sort_unstable(); + items + } + + // ---- tracing capture -------------------------------------------------- + // + // A minimal subscriber rather than a dev-dependency: the diagnostics are + // asserted on, so they need capturing, and `tracing` alone is enough to do + // it. + + #[derive(Clone, Default)] + struct Events(Arc>>); + + impl Events { + fn recorded(&self) -> Vec { + self.0 + .lock() + .expect("the event log is not poisoned") + .clone() + } + + fn matching(&self, needle: &str) -> Vec { + self.recorded() + .into_iter() + .filter(|event| event.contains(needle)) + .collect() + } + } + + struct Capture(Events); + + struct Render(String); + + impl tracing::field::Visit for Render { + fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) { + self.0.push_str(&format!(" {}={value:?}", field.name())); + } + + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + self.0.push_str(&format!(" {}={value}", field.name())); + } + } + + impl tracing::Subscriber for Capture { + fn enabled(&self, _: &tracing::Metadata<'_>) -> bool { + true + } + + fn new_span(&self, _: &tracing::span::Attributes<'_>) -> tracing::span::Id { + tracing::span::Id::from_u64(1) + } + + fn record(&self, _: &tracing::span::Id, _: &tracing::span::Record<'_>) {} + + fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} + + fn event(&self, event: &tracing::Event<'_>) { + let mut render = Render(format!("[{}]", event.metadata().level())); + event.record(&mut render); + self.0 + .0 + .lock() + .expect("the event log is not poisoned") + .push(render.0); + } + + fn enter(&self, _: &tracing::span::Id) {} + + fn exit(&self, _: &tracing::span::Id) {} + } + + /// Run `body` with events captured. + fn capturing(body: impl FnOnce() -> T) -> (T, Events) { + let events = Events::default(); + let subscriber = Capture(events.clone()); + let value = tracing::subscriber::with_default(subscriber, body); + (value, events) + } + + // ---- candidate resolution and merge ----------------------------------- + + /// Nested realm roles and per-client roles, the shape that motivates the + /// whole map. `union` takes both; `first_match` takes only the first. + #[test] + fn union_takes_every_resolving_candidate_and_first_match_takes_one() { + let token = claims(json!({ + "sub": "alice", + "realm_access": {"roles": ["realm-admin"]}, + "resource_access": {"my-api": {"roles": ["viewer", "editor"]}}, + })); + let paths = ["realm_access.roles", "resource_access.my-api.roles"]; + + let union = mapper(json!({ + "subject": {"id": "sub", "roles": {"paths": paths, "merge": "union"}} + })) + .map_subject(&token) + .unwrap(); + assert_eq!( + sorted(&union.roles), + vec!["editor", "realm-admin", "viewer"] + ); + + let first = mapper(json!({ + "subject": {"id": "sub", "roles": {"paths": paths}} + })) + .map_subject(&token) + .unwrap(); + assert_eq!(sorted(&first.roles), vec!["realm-admin"]); + } + + /// Order is candidate-declaration order, then in-array order, and a value in + /// two candidates appears twice. Only a `Vec` destination shows it; the + /// deduplication a set gives is the set's, not the engine's. + #[test] + fn union_preserves_declaration_order_and_does_not_deduplicate() { + let token = claims(json!({ + "client_id": "svc", + "primary": ["a", "b"], + "secondary": ["b", "c"], + })); + let client = mapper(json!({ + "client": { + "client_id": "client_id", + "roles": {"paths": ["primary", "secondary"], "merge": "union"}, + } + })) + .map_client(&token) + .unwrap(); + assert_eq!(client.roles, vec!["a", "b", "b", "c"]); + } + + #[test] + fn a_set_destination_deduplicates_where_a_vec_destination_does_not() { + let token = claims(json!({ + "sub": "alice", + "client_id": "svc", + "primary": ["a", "a", "b"], + })); + let subject = mapper(json!({"subject": {"id": "sub", "roles": "primary"}})) + .map_subject(&token) + .unwrap(); + assert_eq!(sorted(&subject.roles), vec!["a", "b"]); + + let client = mapper(json!({"client": {"client_id": "client_id", "roles": "primary"}})) + .map_client(&token) + .unwrap(); + assert_eq!(client.roles, vec!["a", "a", "b"]); + } + + // ---- shape handling --------------------------------------------------- + + #[test] + fn split_breaks_a_delimited_string_and_its_absence_keeps_it_whole() { + let token = claims(json!({"sub": "alice", "scope": "read write delete"})); + + let split = mapper(json!({ + "subject": { + "id": "sub", + "permissions": {"paths": ["scope"], "split": "whitespace"}, + } + })) + .map_subject(&token) + .unwrap(); + assert_eq!(sorted(&split.permissions), vec!["delete", "read", "write"]); + + let whole = mapper(json!({"subject": {"id": "sub", "permissions": "scope"}})) + .map_subject(&token) + .unwrap(); + assert_eq!(sorted(&whole.permissions), vec!["read write delete"]); + } + + /// One field-level `split` serves an array candidate and a delimited-string + /// candidate at once: splitting a whitespace-free array element is a no-op. + #[test] + fn one_split_declaration_serves_both_an_array_and_a_delimited_string() { + let map = json!({ + "subject": { + "id": "sub", + "permissions": { + "paths": [{"path": "permissions", "array_only": true}, "scope"], + "split": "whitespace", + }, + } + }); + let from_array = mapper(map.clone()) + .map_subject(&claims(json!({ + "sub": "alice", "permissions": ["read:all", "write:all"], + }))) + .unwrap(); + assert_eq!( + sorted(&from_array.permissions), + vec!["read:all", "write:all"] + ); + + let from_string = mapper(map) + .map_subject(&claims(json!({"sub": "alice", "scope": "read write"}))) + .unwrap(); + assert_eq!(sorted(&from_string.permissions), vec!["read", "write"]); + } + + /// The shape matrix, one row per resolved JSON kind, on a default candidate + /// and on an `array_only` one. + #[test] + fn the_shape_matrix_holds_for_every_resolved_kind() { + for (value, default_expected, array_only_expected) in [ + (json!(["a", "b"]), vec!["a", "b"], vec!["a", "b"]), + (json!("a b"), vec!["a b"], vec!["fallback"]), + (json!("a"), vec!["a"], vec!["fallback"]), + (json!(42), vec!["fallback"], vec!["fallback"]), + (json!(true), vec!["fallback"], vec!["fallback"]), + (json!({"nested": true}), vec!["fallback"], vec!["fallback"]), + (json!(null), vec!["fallback"], vec!["fallback"]), + (json!(["a", 42, {"n": 1}]), vec!["a"], vec!["a"]), + ] { + let token = claims(json!({ + "sub": "alice", "primary": value, "backup": ["fallback"], + })); + + let default = mapper(json!({ + "subject": {"id": "sub", "roles": ["primary", "backup"]} + })) + .map_subject(&token) + .unwrap(); + assert_eq!( + sorted(&default.roles), + default_expected, + "default candidate over {:?}", + token.get("primary") + ); + + let array_only = mapper(json!({ + "subject": { + "id": "sub", + "roles": [{"path": "primary", "array_only": true}, "backup"], + } + })) + .map_subject(&token) + .unwrap(); + assert_eq!( + sorted(&array_only.roles), + array_only_expected, + "array_only candidate over {:?}", + token.get("primary") + ); + } + } + + /// An absent claim, a scalar crossed mid-path, and a numeric or object value + /// are each ignored, so the map produces an identity rather than failing. + #[test] + fn an_unusable_shape_is_ignored_rather_than_rejected() { + let token = claims(json!({ + "client_id": "svc", + "aud": 42, + "roles": {"not": "a list"}, + "teams": ["ok", 7, null], + })); + let client = mapper(json!({ + "client": { + "client_id": "client_id", + "authorized_audiences": "aud", + "roles": "roles", + "teams": "teams", + } + })) + .map_client(&token) + .unwrap(); + assert!(client.authorized_audiences.is_empty()); + assert!(client.roles.is_empty()); + assert_eq!(client.teams, vec!["ok"]); + } + + /// A bare `aud` accepts both shapes on one path, which is what a provider + /// that flips between them by audience count needs. + #[test] + fn one_path_accepts_aud_as_a_string_and_as_an_array() { + let map = json!({ + "client": {"client_id": "client_id", "authorized_audiences": "aud"} + }); + let one = mapper(map.clone()) + .map_client(&claims(json!({"client_id": "svc", "aud": "gateway"}))) + .unwrap(); + assert_eq!(one.authorized_audiences, vec!["gateway"]); + + let many = mapper(map) + .map_client(&claims( + json!({"client_id": "svc", "aud": ["gateway", "api"]}), + )) + .unwrap(); + assert_eq!(many.authorized_audiences, vec!["gateway", "api"]); + } + + // ---- claims bag ------------------------------------------------------- + + /// A single-segment path consumes its claim; a nested path leaves the parent + /// whole, which is what keeps a policy reading the nested object working. + #[test] + fn a_nested_path_leaves_its_parent_in_the_bag_and_a_single_segment_does_not() { + let token = claims(json!({ + "sub": "alice", + "realm_access": {"roles": ["admin"]}, + "groups": ["eng"], + })); + let subject = mapper(json!({ + "subject": {"id": "sub", "roles": "realm_access.roles", "teams": "groups"} + })) + .map_subject(&token) + .unwrap(); + + assert_eq!( + subject.claims.get("realm_access"), + Some(&json!({"roles": ["admin"]})), + "a traversed parent stays policy-visible" + ); + assert!( + !subject.claims.contains_key("groups"), + "a single-segment path consumed `groups`" + ); + } + + /// The inference reproduces the Rust mapper's static reserved lists exactly, + /// which is what makes an unchanged config produce an unchanged bag. + #[test] + fn the_inferred_exclusions_reproduce_the_rust_mappers_reserved_lists() { + let every_claim = json!({ + "sub": "alice", "roles": [], "permissions": [], "scope": "", "teams": [], + "groups": [], "iss": "i", "aud": "a", "exp": 1, "nbf": 1, "iat": 1, "jti": "j", + "client_id": "c", "azp": "z", "client_name": "n", "authorized_scopes": [], + "kept": "yes", + }); + + let subject = mapper(json!({ + "subject": { + "id": "sub", + "roles": "roles", + "permissions": ["permissions", "scope"], + "teams": ["teams", "groups"], + } + })) + .map_subject(&claims(every_claim.clone())) + .unwrap(); + let mut visible: Vec<&str> = subject.claims.keys().map(String::as_str).collect(); + visible.sort_unstable(); + assert_eq!( + visible, + vec![ + "authorized_scopes", + "azp", + "client_id", + "client_name", + "kept" + ], + "the subject bag drops exactly sub/roles/permissions/scope/teams/groups plus the \ + registered claims" + ); + + let client = mapper(json!({ + "client": { + "client_id": ["client_id", "azp"], + "client_name": "client_name", + "authorized_scopes": ["authorized_scopes", "scope"], + "authorized_audiences": "aud", + "roles": "roles", + } + })) + .map_client(&claims(every_claim)) + .unwrap(); + let mut visible: Vec<&str> = client.claims.keys().map(String::as_str).collect(); + visible.sort_unstable(); + assert_eq!( + visible, + vec!["groups", "kept", "permissions", "teams"], + "the client bag drops exactly the claims its declarations name plus the registered \ + claims" + ); + } + + #[test] + fn exclude_drops_a_visible_claim_and_include_restores_a_dropped_one() { + let token = claims(json!({ + "sub": "alice", "groups": ["eng"], "internal_debug": "noisy", "tenant": "acme", + })); + let subject = mapper(json!({ + "subject": {"id": "sub", "teams": "groups"}, + "claims": {"exclude": ["internal_debug"], "include": ["groups"]}, + })) + .map_subject(&token) + .unwrap(); + + assert!(!subject.claims.contains_key("internal_debug")); + assert_eq!( + subject.claims.get("groups"), + Some(&json!(["eng"])), + "include restores a claim a path consumed" + ); + assert_eq!(subject.claims.get("tenant"), Some(&json!("acme"))); + } + + /// A registered claim is reachable through `include`, with no allowlist. + /// This is what makes gating on which `IdP` minted the token expressible: the + /// subject claims bag is the only route from a claim to a policy. + #[test] + fn include_restores_any_registered_claim() { + let token = claims(json!({ + "sub": "alice", "iss": "https://internal.idp", "jti": "abc", "exp": 2_000_000_000_i64, + })); + let subject = mapper(json!({ + "subject": {"id": "sub"}, + "claims": {"include": ["iss", "jti", "exp"]}, + })) + .map_subject(&token) + .unwrap(); + assert_eq!( + subject.claims.get("iss"), + Some(&json!("https://internal.idp")) + ); + assert_eq!(subject.claims.get("jti"), Some(&json!("abc"))); + assert_eq!(subject.claims.get("exp"), Some(&json!(2_000_000_000_i64))); + } + + // ---- diagnostics ------------------------------------------------------ + + /// A mistyped path leaves the field empty and says so, naming the field and + /// every path it tried. Without the paths an operator cannot tell a typo + /// from a claim the `IdP` never minted. + #[test] + fn a_field_that_resolved_nothing_names_itself_and_every_path_tried() { + let (subject, events) = capturing(|| { + mapper(json!({ + "subject": {"id": "sub", "roles": ["realm_acces.roles", "rolez"]} + })) + .map_subject(&claims(json!({ + "sub": "alice", "realm_access": {"roles": ["admin"]}, + }))) + }); + + assert!( + subject.unwrap().roles.is_empty(), + "a mistyped path leaves the field empty rather than denying" + ); + let misses = events.matching("no candidate resolved"); + assert_eq!(misses.len(), 1, "one aggregated event per call: {misses:?}"); + let event = misses.first().expect("one miss event"); + assert!(event.contains("roles"), "{event}"); + assert!(event.contains("realm_acces.roles"), "{event}"); + assert!(event.contains("rolez"), "{event}"); + } + + /// A field that resolved to nothing and a field that resolved to an empty + /// collection are different states, and an operator reading one flag's worth + /// of output has to be able to tell them apart. + #[test] + fn an_empty_collection_is_a_different_event_from_a_miss() { + let (_, events) = capturing(|| { + mapper(json!({ + "subject": {"id": "sub", "roles": "roles", "teams": "absent"} + })) + .map_subject(&claims(json!({"sub": "alice", "roles": []}))) + }); + + let empty = events.matching("empty collection"); + assert_eq!(empty.len(), 1, "{:?}", events.recorded()); + let event = empty.first().expect("one empty event"); + assert!(event.contains("roles"), "{event}"); + assert!( + !event.contains("paths_tried"), + "the empty event names the field only: {event}" + ); + + let misses = events.matching("no candidate resolved"); + let event = misses.first().expect("the absent field is a miss"); + assert!(event.contains("teams"), "{event}"); + assert!( + !event.contains("roles"), + "a field that resolved is not a miss: {event}" + ); + } + + /// Every missed field lands in one event, so a wholly mistyped map costs one + /// event per request rather than one per field. + #[test] + fn every_missed_field_shares_one_event() { + let (_, events) = capturing(|| { + mapper(json!({ + "subject": {"id": "sub", "roles": "nope", "teams": "also-nope", "permissions": "neither"} + })) + .map_subject(&claims(json!({"sub": "alice"}))) + }); + let misses = events.matching("no candidate resolved"); + assert_eq!(misses.len(), 1, "{misses:?}"); + let event = misses.first().expect("one miss event"); + for field in ["roles", "teams", "permissions"] { + assert!(event.contains(field), "{field} missing from {event}"); + } + } + + #[test] + fn a_map_that_resolves_everything_emits_neither_event() { + let (_, events) = capturing(|| { + mapper(json!({"subject": {"id": "sub", "roles": "roles"}})) + .map_subject(&claims(json!({"sub": "alice", "roles": ["admin"]}))) + }); + assert!( + events.matching("claim map").is_empty(), + "{:?}", + events.recorded() + ); + } + + // ---- on_missing ------------------------------------------------------- + + /// The same mistyped path is permissive by default and fatal on request. + #[test] + fn on_missing_deny_declines_where_the_default_leaves_the_field_empty() { + let token = claims(json!({"sub": "alice"})); + + let permissive = mapper(json!({"subject": {"id": "sub", "roles": "rolez"}})) + .map_subject(&token) + .expect("the default leaves the field empty"); + assert!(permissive.roles.is_empty()); + + let (strict, events) = capturing(|| { + mapper(json!({ + "subject": {"id": "sub", "roles": {"paths": ["rolez"], "on_missing": "deny"}} + })) + .map_subject(&token) + }); + assert!(strict.is_none(), "`on_missing: deny` declines the mapping"); + let warning = events.matching("on_missing"); + let event = warning.first().expect("the field is named in a warning"); + assert!(event.contains("WARN"), "{event}"); + assert!(event.contains("roles"), "{event}"); + } + + /// An empty collection satisfies `on_missing: deny`: the claim was there. + #[test] + fn on_missing_deny_accepts_a_claim_that_resolved_to_an_empty_collection() { + let subject = mapper(json!({ + "subject": {"id": "sub", "roles": {"paths": ["roles"], "on_missing": "deny"}} + })) + .map_subject(&claims(json!({"sub": "alice", "roles": []}))) + .expect("a present-but-empty claim resolved"); + assert!(subject.roles.is_empty()); + } + + // ---- anchors ---------------------------------------------------------- + + #[test] + fn a_missing_anchor_declines_for_each_role() { + let map = mapper(json!({ + "subject": {"id": "sub"}, + "client": {"client_id": ["client_id", "azp"]}, + "workload": {"spiffe_id": "sub"}, + })); + let empty = claims(json!({"unrelated": "value"})); + assert!(map.map_subject(&empty).is_none()); + assert!(map.map_client(&empty).is_none()); + assert!(map.map_workload(&empty).is_none()); + } + + /// A section that declares the role but no anchor path compiles, and then + /// declines every token. The role check is about the section existing; the + /// anchor is a runtime denial. + #[test] + fn a_section_declaring_no_anchor_declines_at_runtime() { + let map = mapper(json!({"subject": {"roles": "roles"}})); + assert!( + map.map_subject(&claims(json!({"sub": "alice", "roles": ["admin"]}))) + .is_none() + ); + } + + #[test] + fn a_role_the_map_does_not_declare_declines() { + let map = mapper(json!({"subject": {"id": "sub"}})); + let token = claims(json!({"sub": "alice", "client_id": "svc"})); + assert!(map.map_client(&token).is_none()); + assert!(map.map_workload(&token).is_none()); + } + + // ---- workload invariants ---------------------------------------------- + + /// The prefix check applies per candidate, so a non-SPIFFE `sub` is skipped + /// rather than accepted, and a valid SPIFFE candidate behind it still wins. + #[test] + fn the_spiffe_prefix_is_checked_on_every_candidate() { + let map = mapper(json!({"workload": {"spiffe_id": ["sub", "spiffe_id"]}})); + + let bogus = claims(json!({"sub": "alice@corp.example", "spiffe_id": "not-a-spiffe-id"})); + assert!( + map.map_workload(&bogus).is_none(), + "a non-SPIFFE sub must not be rescued by a bogus spiffe_id claim" + ); + + let rescued = claims(json!({ + "sub": "alice@corp.example", + "spiffe_id": "spiffe://corp.example/ns/default/sa/agent", + })); + let workload = map + .map_workload(&rescued) + .expect("a valid SPIFFE candidate behind a non-SPIFFE one still resolves"); + assert_eq!( + workload.spiffe_id.as_deref(), + Some("spiffe://corp.example/ns/default/sa/agent") + ); + } + + /// There is no configuration that turns the prefix check off: it is not a + /// field, an option, or a candidate key, so every one of these is rejected + /// or has no bearing on it. + #[test] + fn no_config_surface_can_disable_the_spiffe_prefix_check() { + for attempt in [ + json!({"workload": {"spiffe_id": "sub", "spiffe_prefix": "none"}}), + json!({"workload": {"require_spiffe": false, "spiffe_id": "sub"}}), + ] { + let config: ClaimMapConfig = + serde_json::from_value(attempt).expect("the shape deserializes"); + assert!( + config.compile().is_err(), + "an invented field must be rejected rather than quietly ignored" + ); + } + + let permissive = mapper(json!({ + "workload": {"spiffe_id": {"paths": ["sub"], "on_missing": "ignore"}} + })); + assert!( + permissive + .map_workload(&claims(json!({"sub": "alice@corp.example"}))) + .is_none(), + "`on_missing: ignore` does not make a non-SPIFFE subject acceptable" + ); + } + + #[test] + fn trust_domain_is_derived_when_unmapped_and_taken_from_the_path_when_mapped() { + let derived = mapper(json!({"workload": {"spiffe_id": "sub"}})) + .map_workload(&claims(json!({"sub": "spiffe://corp.example/ns/a/sa/b"}))) + .unwrap(); + assert_eq!(derived.trust_domain.as_deref(), Some("corp.example")); + + let mapped = mapper(json!({ + "workload": {"spiffe_id": "sub", "trust_domain": "td"} + })) + .map_workload(&claims(json!({ + "sub": "spiffe://corp.example/ns/a/sa/b", "td": "declared.example", + }))) + .unwrap(); + assert_eq!(mapped.trust_domain.as_deref(), Some("declared.example")); + } + + #[test] + fn a_workload_carries_its_selectors_and_client_id_when_mapped() { + let workload = mapper(json!({ + "workload": { + "spiffe_id": "sub", + "selectors": "selectors", + "client_id": "client_id", + } + })) + .map_workload(&claims(json!({ + "sub": "spiffe://corp.example/w", + "selectors": ["k8s:ns:prod", "unix:uid:1000"], + "client_id": "svc", + }))) + .unwrap(); + assert_eq!(workload.selectors, vec!["k8s:ns:prod", "unix:uid:1000"]); + assert_eq!(workload.client_id.as_deref(), Some("svc")); + assert_eq!(workload.attestor.as_deref(), Some("jwt")); + assert!(workload.attested_at.is_none()); + } + + // ---- escaped and prefixed claim names end to end ---------------------- + + /// An escaped URL-named claim and a colon-prefixed one each populate their + /// field through the mapper, which is the pair a policy language cannot + /// address directly. + #[test] + fn escaped_and_colon_prefixed_claim_names_populate_their_fields() { + let subject = mapper(json!({ + "subject": { + "id": "sub", + "roles": "https://my-app\\.example\\.com/roles", + "teams": "cognito:groups", + } + })) + .map_subject(&claims(json!({ + "sub": "alice", + "https://my-app.example.com/roles": ["editor"], + "cognito:groups": ["admins"], + }))) + .unwrap(); + assert_eq!(sorted(&subject.roles), vec!["editor"]); + assert_eq!(sorted(&subject.teams), vec!["admins"]); + } +} diff --git a/builtins/plugins/identity-jwt/src/lib.rs b/builtins/plugins/identity-jwt/src/lib.rs index dc8041e..b3f8674 100644 --- a/builtins/plugins/identity-jwt/src/lib.rs +++ b/builtins/plugins/identity-jwt/src/lib.rs @@ -52,6 +52,8 @@ pub mod claim_map_config; pub mod claim_path; /// Plugin configuration and its validation. pub mod config; +/// The mapper a compiled claim map drives. +pub mod configured_mapper; /// Constructs the resolver from configuration. pub mod factory; /// The identity hook handler. @@ -66,6 +68,7 @@ pub use claim_map_config::{ }; pub use claim_path::ClaimPath; pub use config::{DecodingKeySource, JwtIdentityResolverConfig, TrustedIssuerConfig}; +pub use configured_mapper::ConfiguredClaimMap; pub use factory::{JwtIdentityFactory, KIND}; pub use resolver::JwtIdentityResolver; pub use trusted_issuer::TrustedIssuer; From 004d605b4f1325ea4874104ea57aefa2f4022758 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 18:12:12 -0400 Subject: [PATCH 05/27] feat(identity-jwt): ship standard, keycloak, auth0 and cognito presets Four embedded JSON maps behind one registry table, so a preset cannot go missing at deploy and a preset added without a test is not possible. An unknown name lists every valid one. Each provider preset declares a candidate only where the provider mints the claim, and its description names what it omits: Keycloak's groups claim holds realm roles rather than groups, Auth0 forbids a bare roles claim so roles are per-deployment namespaced, and Cognito's roles claims hold IAM ARNs. Filling a field with the wrong concept is worse than leaving it empty, because the operator has no reason to look, so each omission is pinned by a test. `split` applies to a string, not to an array's elements: an array already says where its elements end, and splitting them would change what a claim carrying an element with a space in it produces. Signed-off-by: Frederico Araujo --- .../identity-jwt/src/configured_mapper.rs | 28 +- builtins/plugins/identity-jwt/src/lib.rs | 3 + builtins/plugins/identity-jwt/src/presets.rs | 446 ++++++++++++++++++ .../identity-jwt/src/presets/auth0.json | 17 + .../identity-jwt/src/presets/cognito.json | 14 + .../identity-jwt/src/presets/keycloak.json | 15 + .../identity-jwt/src/presets/standard.json | 30 ++ .../tests/fixtures/claim-corpus.json | 384 ++++++++++++--- 8 files changed, 857 insertions(+), 80 deletions(-) create mode 100644 builtins/plugins/identity-jwt/src/presets.rs create mode 100644 builtins/plugins/identity-jwt/src/presets/auth0.json create mode 100644 builtins/plugins/identity-jwt/src/presets/cognito.json create mode 100644 builtins/plugins/identity-jwt/src/presets/keycloak.json create mode 100644 builtins/plugins/identity-jwt/src/presets/standard.json diff --git a/builtins/plugins/identity-jwt/src/configured_mapper.rs b/builtins/plugins/identity-jwt/src/configured_mapper.rs index 0ace6bd..aca01d7 100644 --- a/builtins/plugins/identity-jwt/src/configured_mapper.rs +++ b/builtins/plugins/identity-jwt/src/configured_mapper.rs @@ -106,10 +106,15 @@ fn contribute( out: &mut Vec, ) -> bool { match value { + // An array already says where its elements end, so `split` does not + // apply to them. Splitting them too would change what a claim carrying + // an element with a space in it produces, and one field-level `split` + // covers a delimited-string candidate and an array candidate at once + // precisely because it leaves the array alone. Value::Array(items) => { for item in items { if let Some(text) = item.as_str() { - push_text(text, split, out); + out.push(text.to_owned()); } } true @@ -118,22 +123,18 @@ fn contribute( if array_only { return false; } - push_text(text, split, out); + match split { + Some(SplitMode::Whitespace) => { + out.extend(text.split_whitespace().map(str::to_owned)); + }, + None => out.push(text.to_owned()), + } true }, Value::Null | Value::Bool(_) | Value::Number(_) | Value::Object(_) => false, } } -fn push_text(text: &str, split: Option, out: &mut Vec) { - match split { - Some(SplitMode::Whitespace) => { - out.extend(text.split_whitespace().map(str::to_owned)); - }, - None => out.push(text.to_owned()), - } -} - fn resolve_collection(field: &CompiledField, claims: &ClaimMap) -> FieldOutcome { let mut values = Vec::new(); let mut resolved = false; @@ -622,12 +623,13 @@ mod tests { }); let from_array = mapper(map.clone()) .map_subject(&claims(json!({ - "sub": "alice", "permissions": ["read:all", "write:all"], + "sub": "alice", "permissions": ["read:all", "write all reports"], }))) .unwrap(); assert_eq!( sorted(&from_array.permissions), - vec!["read:all", "write:all"] + vec!["read:all", "write all reports"], + "an array says where its elements end, so `split` leaves them whole" ); let from_string = mapper(map) diff --git a/builtins/plugins/identity-jwt/src/lib.rs b/builtins/plugins/identity-jwt/src/lib.rs index b3f8674..f0513ee 100644 --- a/builtins/plugins/identity-jwt/src/lib.rs +++ b/builtins/plugins/identity-jwt/src/lib.rs @@ -56,6 +56,8 @@ pub mod config; pub mod configured_mapper; /// Constructs the resolver from configuration. pub mod factory; +/// The shipped claim maps, by name. +pub mod presets; /// The identity hook handler. pub mod resolver; /// A trusted issuer, its key store, and its accepted algorithms. @@ -70,5 +72,6 @@ pub use claim_path::ClaimPath; pub use config::{DecodingKeySource, JwtIdentityResolverConfig, TrustedIssuerConfig}; pub use configured_mapper::ConfiguredClaimMap; pub use factory::{JwtIdentityFactory, KIND}; +pub use presets::{DEFAULT_PRESET, PRESETS, Preset}; pub use resolver::JwtIdentityResolver; pub use trusted_issuer::TrustedIssuer; diff --git a/builtins/plugins/identity-jwt/src/presets.rs b/builtins/plugins/identity-jwt/src/presets.rs new file mode 100644 index 0000000..03c11fb --- /dev/null +++ b/builtins/plugins/identity-jwt/src/presets.rs @@ -0,0 +1,446 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +// The shipped claim maps, embedded as JSON. Embedding rather than reading from +// disk means a preset cannot go missing at deploy, and the registry is one table +// so a single test covers every entry: adding a preset without covering it is not +// possible. +// +// A preset ships a candidate only where the provider actually mints the claim. +// Each `description` names what it omits and why, because a preset that quietly +// fills a field with the wrong concept is worse than one that leaves it empty: +// the operator has no reason to look. + +use serde::Deserialize; + +use crate::claim_map_config::{ClaimMapConfig, CompiledClaimMap}; + +/// Every shipped preset, by the name an operator writes in `claim_mapper`. +/// +/// Sorted by name so the unknown-name error lists them in a stable order. +pub const PRESETS: &[(&str, &str)] = &[ + ("auth0", include_str!("presets/auth0.json")), + ("cognito", include_str!("presets/cognito.json")), + ("keycloak", include_str!("presets/keycloak.json")), + ("standard", include_str!("presets/standard.json")), +]; + +/// The preset an absent `claim_mapper` resolves to. +pub const DEFAULT_PRESET: &str = "standard"; + +#[derive(Debug, Deserialize)] +#[serde(deny_unknown_fields)] +struct PresetFile { + description: String, + claim_map: ClaimMapConfig, +} + +/// A shipped preset: what it covers, and the map it compiles to. +#[derive(Debug, Clone)] +pub struct Preset { + name: &'static str, + description: String, + claim_map: CompiledClaimMap, +} + +impl Preset { + /// The name an operator writes. + pub fn name(&self) -> &'static str { + self.name + } + + /// What the preset covers, what it deliberately omits, and which of its + /// claims are opt-in at the provider. + pub fn description(&self) -> &str { + &self.description + } + + /// The compiled map. + pub fn claim_map(&self) -> &CompiledClaimMap { + &self.claim_map + } + + /// Take the compiled map, dropping the metadata. + pub fn into_claim_map(self) -> CompiledClaimMap { + self.claim_map + } +} + +/// Every preset name, in the order the error text lists them. +pub fn names() -> impl Iterator { + PRESETS.iter().map(|(name, _)| *name) +} + +/// The valid names, formatted for an error message. +pub fn valid_names() -> String { + names().collect::>().join(", ") +} + +/// Parse and compile a preset by name. +/// +/// # Errors +/// +/// Returns a message listing every valid name when `name` is not one of them, +/// and the parse or compile failure when a shipped preset is itself malformed, +/// which a table-driven test rules out before release. +pub fn lookup(name: &str) -> Result { + let (interned, source) = PRESETS + .iter() + .find(|(preset, _)| *preset == name) + .ok_or_else(|| format!("unknown claim_mapper '{name}'; valid: [{}]", valid_names()))?; + + let file: PresetFile = serde_json::from_str(source) + .map_err(|e| format!("the '{interned}' preset does not parse: {e}"))?; + let claim_map = file + .claim_map + .compile() + .map_err(|e| format!("the '{interned}' preset does not compile: {e}"))?; + + Ok(Preset { + name: interned, + description: file.description, + claim_map, + }) +} + +#[cfg(test)] +#[allow( + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + reason = "tests" +)] +mod tests { + use std::collections::HashSet; + + use praxis_policy_core::extensions::raw_credentials::TokenRole; + use serde_json::{Value, json}; + + use super::*; + use crate::claim_map::{ClaimMap, ClaimMapper as _}; + use crate::configured_mapper::ConfiguredClaimMap; + + fn claims(value: Value) -> ClaimMap { + value.as_object().unwrap().clone().into_iter().collect() + } + + fn mapper(preset: &str) -> ConfiguredClaimMap { + ConfiguredClaimMap::new( + lookup(preset) + .unwrap_or_else(|e| panic!("the '{preset}' preset must load: {e}")) + .into_claim_map(), + ) + } + + fn sorted(values: &HashSet) -> Vec<&str> { + let mut items: Vec<&str> = values.iter().map(String::as_str).collect(); + items.sort_unstable(); + items + } + + fn authored_paths(map: &CompiledClaimMap, role: &TokenRole, field: &str) -> Vec { + map.role(role) + .unwrap_or_else(|e| panic!("the section must exist: {e}")) + .field(field) + .unwrap_or_else(|| panic!("`{field}` must be declared")) + .candidates() + .iter() + .map(|candidate| candidate.path().to_string()) + .collect() + } + + // ---- the table -------------------------------------------------------- + + /// Table-driven, so a preset added without a test is not possible: this + /// parses, compiles, and sanity-checks every entry in the registry. + #[test] + fn every_shipped_preset_parses_compiles_and_declares_a_role() { + for name in names() { + let preset = lookup(name).unwrap_or_else(|e| panic!("'{name}': {e}")); + assert_eq!(preset.name(), name); + assert!( + preset.description().len() > 40, + "'{name}': the description must say what the preset covers and omits" + ); + let declares_a_role = [ + TokenRole::User, + TokenRole::Client, + TokenRole::CallerWorkload, + ] + .iter() + .any(|role| preset.claim_map().role(role).is_ok()); + assert!(declares_a_role, "'{name}' declares no role section"); + } + } + + /// Compilation already parses every path; asserting it per preset is what + /// makes a failure name the preset rather than a line in a table. + #[test] + fn every_shipped_presets_paths_parse() { + for name in names() { + for role in [ + TokenRole::User, + TokenRole::Client, + TokenRole::CallerWorkload, + ] { + let preset = lookup(name).unwrap_or_else(|e| panic!("'{name}': {e}")); + if let Ok(section) = preset.claim_map().role(&role) { + for (field, compiled) in section.fields() { + assert!( + !compiled.candidates().is_empty(), + "'{name}' {role:?} {field}: no candidates" + ); + } + } + } + } + } + + #[test] + fn preset_names_are_unique_and_sorted() { + let names: Vec<&str> = names().collect(); + let mut expected = names.clone(); + expected.sort_unstable(); + assert_eq!(names, expected, "the error text's order must be stable"); + assert_eq!( + names.iter().collect::>().len(), + names.len(), + "duplicate preset name" + ); + } + + #[test] + fn an_unknown_name_lists_every_valid_name() { + let err = lookup("made-up").expect_err("an unknown preset must not resolve"); + assert!(err.contains("made-up"), "{err}"); + for name in names() { + assert!(err.contains(name), "'{name}' missing from: {err}"); + } + } + + #[test] + fn the_default_preset_is_in_the_table() { + assert!(lookup(DEFAULT_PRESET).is_ok()); + } + + // ---- the standard preset ---------------------------------------------- + + /// The standard preset must check in the order the Rust mapper checks, since + /// the order is what decides which claim wins. + #[test] + fn the_standard_preset_declares_the_rust_mappers_candidate_order() { + let map = lookup("standard").expect("standard loads").into_claim_map(); + + for (field, expected) in [ + ("id", vec!["sub"]), + ("roles", vec!["roles"]), + ("permissions", vec!["permissions", "scope"]), + ("teams", vec!["teams", "groups"]), + ] { + assert_eq!( + authored_paths(&map, &TokenRole::User, field), + expected, + "subject.{field}" + ); + } + + for (field, expected) in [ + ("client_id", vec!["client_id", "azp"]), + ("client_name", vec!["client_name"]), + ("authorized_scopes", vec!["authorized_scopes", "scope"]), + ("authorized_audiences", vec!["aud"]), + ("roles", vec!["roles"]), + ] { + assert_eq!( + authored_paths(&map, &TokenRole::Client, field), + expected, + "client.{field}" + ); + } + + assert_eq!( + authored_paths(&map, &TokenRole::CallerWorkload, "spiffe_id"), + vec!["sub", "spiffe_id"], + ); + assert!( + map.role(&TokenRole::CallerWorkload) + .expect("standard declares a workload section") + .field("trust_domain") + .is_none(), + "the trust domain is derived from the SPIFFE URI, not mapped" + ); + } + + /// Only `standard` has a workload shape to offer. A provider preset that + /// declared one would be guessing. + #[test] + fn standard_is_the_only_preset_with_a_workload_section() { + for name in names() { + let declares = lookup(name) + .unwrap_or_else(|e| panic!("'{name}': {e}")) + .claim_map() + .role(&TokenRole::CallerWorkload) + .is_ok(); + assert_eq!( + declares, + name == "standard", + "'{name}': only standard should declare a workload section" + ); + } + } + + /// `role: workload` against a provider preset fails at construction naming + /// the role, which is the right outcome: better than a section of guesses. + #[test] + fn a_provider_preset_refuses_the_workload_role_and_names_it() { + for name in ["auth0", "cognito", "keycloak"] { + let err = lookup(name) + .unwrap_or_else(|e| panic!("'{name}': {e}")) + .claim_map() + .role(&TokenRole::CallerWorkload) + .expect_err("a provider preset has no workload shape"); + assert!(err.contains("workload"), "'{name}': {err}"); + } + } + + // ---- provider presets resolve real tokens ----------------------------- + + #[test] + fn the_keycloak_preset_reads_realm_roles_and_an_azp_anchor() { + let subject = mapper("keycloak") + .map_subject(&claims(json!({ + "sub": "f:2c1b:alice", + "realm_access": {"roles": ["viewer", "editor"]}, + "scope": "openid profile", + }))) + .expect("a Keycloak access token resolves"); + assert_eq!(sorted(&subject.roles), vec!["editor", "viewer"]); + assert_eq!(sorted(&subject.permissions), vec!["openid", "profile"]); + + let client = mapper("keycloak") + .map_client(&claims(json!({"azp": "my-api", "scope": "openid"}))) + .expect("azp anchors a Keycloak client"); + assert_eq!(client.client_id, "my-api"); + } + + #[test] + fn the_auth0_preset_reads_permissions_and_an_azp_anchor() { + let subject = mapper("auth0") + .map_subject(&claims(json!({ + "sub": "auth0|507f", + "permissions": ["read:reports"], + "scope": "openid profile", + }))) + .expect("an Auth0 token resolves"); + assert_eq!( + sorted(&subject.permissions), + vec!["read:reports"], + "the permissions array wins over scope" + ); + + let client = mapper("auth0") + .map_client(&claims(json!({ + "azp": "6MZ2Wt3rBGxOA1example", "scope": "read:reports", + }))) + .expect("azp anchors an Auth0 client"); + assert_eq!(client.client_id, "6MZ2Wt3rBGxOA1example"); + } + + #[test] + fn the_cognito_preset_reads_groups_into_teams_and_a_client_id_anchor() { + let subject = mapper("cognito") + .map_subject(&claims(json!({ + "sub": "a1b2", "cognito:groups": ["admins", "engineering"], + }))) + .expect("a Cognito token resolves"); + assert_eq!(sorted(&subject.teams), vec!["admins", "engineering"]); + + let client = mapper("cognito") + .map_client(&claims(json!({ + "client_id": "1example23456789", "scope": "resourceserver.1/appclient2", + }))) + .expect("client_id anchors a Cognito client"); + assert_eq!(client.client_id, "1example23456789"); + assert_eq!( + client.authorized_scopes, + vec!["resourceserver.1/appclient2"] + ); + } + + // ---- the deliberate omissions ----------------------------------------- + + /// Each omission is asserted rather than left to review, because a candidate + /// added later out of helpfulness would otherwise pass silently, and each of + /// these would fill a field with the wrong concept. + #[test] + fn each_preset_leaves_the_fields_it_omits_empty() { + // Keycloak's `groups` claim holds realm roles, not groups. + let keycloak = mapper("keycloak") + .map_subject(&claims(json!({ + "sub": "alice", "groups": ["offline_access", "uma_authorization"], + }))) + .expect("resolves"); + assert!( + keycloak.teams.is_empty(), + "mapping Keycloak's groups to teams would fill teams with realm roles" + ); + + // Auth0 forbids a bare `roles` claim, so roles are per-deployment + // namespaced and no preset can name the path. + let auth0 = mapper("auth0") + .map_subject(&claims(json!({ + "sub": "auth0|507f", + "https://my-app.example.com/roles": ["editor"], + "roles": ["editor"], + }))) + .expect("resolves"); + assert!( + auth0.roles.is_empty(), + "the Auth0 preset cannot know a deployment's namespace" + ); + assert!(auth0.teams.is_empty()); + + // Cognito's `cognito:roles` holds IAM role ARNs. + let cognito = mapper("cognito") + .map_subject(&claims(json!({ + "sub": "a1b2", + "cognito:roles": ["arn:aws:iam::123456789012:role/AppRole"], + }))) + .expect("resolves"); + assert!( + cognito.roles.is_empty(), + "cognito:roles holds IAM ARNs, which are not application roles" + ); + + // Cognito access tokens carry no aud, so the preset does not read one. + let cognito_client = mapper("cognito") + .map_client(&claims( + json!({"client_id": "svc", "aud": "would-be-wrong"}), + )) + .expect("resolves"); + assert!(cognito_client.authorized_audiences.is_empty()); + } + + /// A field no preset declares is still reachable, which is the point of the + /// map: no provider mints `client_name`, so only a hand-written map fills it. + #[test] + fn no_preset_declares_a_client_name_candidate_except_standard() { + for name in ["auth0", "cognito", "keycloak"] { + let preset = lookup(name).unwrap_or_else(|e| panic!("'{name}': {e}")); + let section = preset + .claim_map() + .role(&TokenRole::Client) + .unwrap_or_else(|e| panic!("'{name}': {e}")); + assert!( + section.field("client_name").is_none(), + "'{name}': no researched provider mints client_name" + ); + for field in ["permissions", "teams"] { + assert!( + section.field(field).is_none(), + "'{name}': no researched provider mints a client {field} source" + ); + } + } + } +} diff --git a/builtins/plugins/identity-jwt/src/presets/auth0.json b/builtins/plugins/identity-jwt/src/presets/auth0.json new file mode 100644 index 0000000..5c65a1b --- /dev/null +++ b/builtins/plugins/identity-jwt/src/presets/auth0.json @@ -0,0 +1,17 @@ +{ + "description": "Auth0 subject, permissions and client anchor. Covers sub, the permissions array ahead of scope, and client_id / azp as the client anchor: azp is what Auth0's default access-token profile emits and client_id what the RFC 9068 profile emits. permissions is doubly opt-in at the tenant, needing both RBAC and Add Permissions in the Access Token, and enabling it switches the token dialect; without it, scope carries the grant. Deliberately omitted: roles and teams, because Auth0's restricted-claim list forbids roles, groups, permissions and entitlements as bare custom-claim names, so roles can only arrive URL-namespaced under a deployment's own namespace, which no preset can know. Write that path in a hand-written claim_map, escaping its dots. sub is not a client-id candidate: an Auth0 machine-to-machine sub is @clients and stripping that suffix is a value transform this plugin does not do.", + "claim_map": { + "subject": { + "id": "sub", + "permissions": { + "paths": [{ "path": "permissions", "array_only": true }, "scope"], + "split": "whitespace" + } + }, + "client": { + "client_id": ["client_id", "azp"], + "authorized_scopes": { "paths": ["scope"], "split": "whitespace" }, + "authorized_audiences": "aud" + } + } +} diff --git a/builtins/plugins/identity-jwt/src/presets/cognito.json b/builtins/plugins/identity-jwt/src/presets/cognito.json new file mode 100644 index 0000000..6230d63 --- /dev/null +++ b/builtins/plugins/identity-jwt/src/presets/cognito.json @@ -0,0 +1,14 @@ +{ + "description": "Cognito groups and scopes. Covers sub, cognito:groups into subject teams, scope into permissions, and client_id as the client anchor. Cognito never mints azp, so client_id is the only anchor candidate. Deliberately omitted: roles, because cognito:roles and cognito:preferred_role hold IAM role ARNs rather than application roles, and filling roles with ARNs would leave a policy author gating on something that is not a role; and the client audience, because an access token carries no aud unless resource binding was requested and a machine-to-machine token can never carry one. cognito:groups appears in both the ID token and the access token. Application roles need a hand-written claim_map over a custom claim.", + "claim_map": { + "subject": { + "id": "sub", + "permissions": { "paths": ["scope"], "split": "whitespace" }, + "teams": [{ "path": "cognito:groups", "array_only": true }] + }, + "client": { + "client_id": ["client_id"], + "authorized_scopes": { "paths": ["scope"], "split": "whitespace" } + } + } +} diff --git a/builtins/plugins/identity-jwt/src/presets/keycloak.json b/builtins/plugins/identity-jwt/src/presets/keycloak.json new file mode 100644 index 0000000..eb2ef81 --- /dev/null +++ b/builtins/plugins/identity-jwt/src/presets/keycloak.json @@ -0,0 +1,15 @@ +{ + "description": "Keycloak realm roles and scopes. Works on access tokens: both role mappers are registered with idToken=false, so a resolver pointed at an ID token gets no roles. Covers realm_access.roles into subject roles, scope into permissions, and client_id / azp / clientId as the client anchor, clientId being pre-2023 Keycloak's camelCase spelling. Deliberately omitted: per-client roles, because resource_access..roles embeds the client id an operator chose and no shipped preset can know it; and teams, because Keycloak's default groups claim comes from the microprofile-jwt scope and holds realm roles rather than groups, so mapping it would fill teams with roles. Real group paths need a Group Membership mapper whose claim name the admin types, which has no default. Both are reachable with a hand-written claim_map. A realm running the lightweight-access-token policy strips realm_access unless each mapper opts in, which leaves roles empty; on_missing: deny in a hand-written map is how to make that loud.", + "claim_map": { + "subject": { + "id": "sub", + "roles": [{ "path": "realm_access.roles", "array_only": true }], + "permissions": { "paths": ["scope"], "split": "whitespace" } + }, + "client": { + "client_id": ["client_id", "azp", "clientId"], + "authorized_scopes": { "paths": ["scope"], "split": "whitespace" }, + "authorized_audiences": "aud" + } + } +} diff --git a/builtins/plugins/identity-jwt/src/presets/standard.json b/builtins/plugins/identity-jwt/src/presets/standard.json new file mode 100644 index 0000000..fd0ea41 --- /dev/null +++ b/builtins/plugins/identity-jwt/src/presets/standard.json @@ -0,0 +1,30 @@ +{ + "description": "Standard OIDC claim shape, and what an absent claim_mapper resolves to. Reads sub, roles, permissions or scope, and teams or groups for a subject; client_id or azp, client_name, authorized_scopes or scope, aud and roles for a client; and a SPIFFE ID from sub or spiffe_id for a workload. Equivalent to the built-in Rust standard mapper, which a corpus-backed test holds it to: a deployment that names no mapper sees exactly what it saw before. The collection candidates are array-only because the Rust mapper reads them with an array accessor, so a string-valued roles claim contributes nothing and falls through where there is a next candidate.", + "claim_map": { + "subject": { + "id": "sub", + "roles": [{ "path": "roles", "array_only": true }], + "permissions": { + "paths": [{ "path": "permissions", "array_only": true }, "scope"], + "split": "whitespace" + }, + "teams": [ + { "path": "teams", "array_only": true }, + { "path": "groups", "array_only": true } + ] + }, + "client": { + "client_id": ["client_id", "azp"], + "client_name": "client_name", + "authorized_scopes": { + "paths": [{ "path": "authorized_scopes", "array_only": true }, "scope"], + "split": "whitespace" + }, + "authorized_audiences": "aud", + "roles": [{ "path": "roles", "array_only": true }] + }, + "workload": { + "spiffe_id": ["sub", "spiffe_id"] + } + } +} diff --git a/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json b/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json index a164154..3f5189a 100644 --- a/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json +++ b/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json @@ -5,9 +5,17 @@ "provenance": "Standard OIDC shape, constructed to exercise every subject field at once.", "claims": { "sub": "alice@corp.example", - "roles": ["hr", "admin"], - "permissions": ["call_tool", "list_tools"], - "teams": ["platform"], + "roles": [ + "hr", + "admin" + ], + "permissions": [ + "call_tool", + "list_tools" + ], + "teams": [ + "platform" + ], "email": "alice@corp.example", "preferred_username": "alice", "iss": "https://idp.example", @@ -18,9 +26,17 @@ }, "expected": { "id": "alice@corp.example", - "roles": ["hr", "admin"], - "permissions": ["call_tool", "list_tools"], - "teams": ["platform"], + "roles": [ + "hr", + "admin" + ], + "permissions": [ + "call_tool", + "list_tools" + ], + "teams": [ + "platform" + ], "claims": { "email": "alice@corp.example", "preferred_username": "alice" @@ -33,12 +49,18 @@ "provenance": "Constructed to exercise the permissions branch of the permissions/scope fallback, with both claims present.", "claims": { "sub": "alice", - "permissions": ["call_tool", "list_tools"], + "permissions": [ + "call_tool", + "list_tools" + ], "scope": "read write" }, "expected": { "id": "alice", - "permissions": ["call_tool", "list_tools"], + "permissions": [ + "call_tool", + "list_tools" + ], "claims": {} } }, @@ -52,7 +74,11 @@ }, "expected": { "id": "alice", - "permissions": ["read", "write", "delete"], + "permissions": [ + "read", + "write", + "delete" + ], "claims": {} } }, @@ -62,12 +88,18 @@ "provenance": "Constructed to exercise the teams branch of the teams/groups fallback, with both claims present.", "claims": { "sub": "alice", - "teams": ["explicit-team"], - "groups": ["fallback-group"] + "teams": [ + "explicit-team" + ], + "groups": [ + "fallback-group" + ] }, "expected": { "id": "alice", - "teams": ["explicit-team"], + "teams": [ + "explicit-team" + ], "claims": {} } }, @@ -77,11 +109,17 @@ "provenance": "Constructed to exercise the groups branch of the teams/groups fallback.", "claims": { "sub": "alice", - "groups": ["engineering", "platform"] + "groups": [ + "engineering", + "platform" + ] }, "expected": { "id": "alice", - "teams": ["engineering", "platform"], + "teams": [ + "engineering", + "platform" + ], "claims": {} } }, @@ -106,11 +144,15 @@ "claims": { "sub": "alice", "teams": "engineering", - "groups": ["platform"] + "groups": [ + "platform" + ] }, "expected": { "id": "alice", - "teams": ["platform"], + "teams": [ + "platform" + ], "claims": {} } }, @@ -125,7 +167,10 @@ }, "expected": { "id": "alice", - "permissions": ["list", "create"], + "permissions": [ + "list", + "create" + ], "claims": {} } }, @@ -135,11 +180,25 @@ "provenance": "Constructed: a mixed-type array. Elements that are not strings are dropped, the array as a whole is not rejected.", "claims": { "sub": "alice", - "roles": ["admin", 42, null, { "nested": true }, ["inner"], "hr"] + "roles": [ + "admin", + 42, + null, + { + "nested": true + }, + [ + "inner" + ], + "hr" + ] }, "expected": { "id": "alice", - "roles": ["admin", "hr"], + "roles": [ + "admin", + "hr" + ], "claims": {} } }, @@ -158,6 +217,26 @@ "claims": {} } }, + { + "name": "subject-an-array-element-containing-whitespace-stays-whole", + "role": "user", + "provenance": "Constructed: the shape that separates splitting a delimited string from splitting an array. A field whose candidates are an array and a delimited string must not split the array's elements.", + "claims": { + "sub": "alice", + "permissions": [ + "read reports", + "write" + ] + }, + "expected": { + "id": "alice", + "permissions": [ + "read reports", + "write" + ], + "claims": {} + } + }, { "name": "subject-a-null-claim-reaches-the-bag-and-maps-nothing", "role": "user", @@ -170,7 +249,9 @@ "expected": { "id": "alice", "roles": [], - "claims": { "department": null } + "claims": { + "department": null + } } }, { @@ -200,19 +281,36 @@ "iat": 1999999400, "jti": "6f1c9f3e", "iss": "https://kc.example/realms/demo", - "aud": ["account"], + "aud": [ + "account" + ], "sub": "f:2c1b:alice", "typ": "Bearer", "azp": "my-api", "sid": "9a7d", "acr": "1", - "allowed-origins": ["https://app.example"], + "allowed-origins": [ + "https://app.example" + ], "realm_access": { - "roles": ["offline_access", "default-roles-demo", "uma_authorization"] + "roles": [ + "offline_access", + "default-roles-demo", + "uma_authorization" + ] }, "resource_access": { - "my-api": { "roles": ["viewer", "editor"] }, - "account": { "roles": ["manage-account"] } + "my-api": { + "roles": [ + "viewer", + "editor" + ] + }, + "account": { + "roles": [ + "manage-account" + ] + } }, "scope": "openid profile email", "email_verified": true, @@ -222,20 +320,39 @@ "expected": { "id": "f:2c1b:alice", "roles": [], - "permissions": ["openid", "profile", "email"], + "permissions": [ + "openid", + "profile", + "email" + ], "teams": [], "claims": { "typ": "Bearer", "azp": "my-api", "sid": "9a7d", "acr": "1", - "allowed-origins": ["https://app.example"], + "allowed-origins": [ + "https://app.example" + ], "realm_access": { - "roles": ["offline_access", "default-roles-demo", "uma_authorization"] + "roles": [ + "offline_access", + "default-roles-demo", + "uma_authorization" + ] }, "resource_access": { - "my-api": { "roles": ["viewer", "editor"] }, - "account": { "roles": ["manage-account"] } + "my-api": { + "roles": [ + "viewer", + "editor" + ] + }, + "account": { + "roles": [ + "manage-account" + ] + } }, "email_verified": true, "preferred_username": "alice", @@ -250,22 +367,33 @@ "claims": { "iss": "https://tenant.eu.auth0.com/", "sub": "auth0|507f1f77bcf86cd799439011", - "aud": ["https://my-api.example", "https://tenant.eu.auth0.com/userinfo"], + "aud": [ + "https://my-api.example", + "https://tenant.eu.auth0.com/userinfo" + ], "iat": 1999999400, "exp": 2000000000, "azp": "6MZ2Wt3rBGxOA1example", "scope": "openid profile email", - "permissions": ["read:reports"], - "https://my-app.example.com/roles": ["editor"] + "permissions": [ + "read:reports" + ], + "https://my-app.example.com/roles": [ + "editor" + ] }, "expected": { "id": "auth0|507f1f77bcf86cd799439011", "roles": [], - "permissions": ["read:reports"], + "permissions": [ + "read:reports" + ], "teams": [], "claims": { "azp": "6MZ2Wt3rBGxOA1example", - "https://my-app.example.com/roles": ["editor"] + "https://my-app.example.com/roles": [ + "editor" + ] } } }, @@ -275,9 +403,14 @@ "provenance": "Cognito ID token, per the Cognito Developer Guide. `cognito:groups` holds group names; `cognito:roles` holds IAM role ARNs, which are not application roles.", "claims": { "sub": "a1b2c3d4-1111-2222-3333-444455556666", - "cognito:groups": ["admins", "engineering"], + "cognito:groups": [ + "admins", + "engineering" + ], "cognito:username": "alice", - "cognito:roles": ["arn:aws:iam::123456789012:role/AppRole"], + "cognito:roles": [ + "arn:aws:iam::123456789012:role/AppRole" + ], "cognito:preferred_role": "arn:aws:iam::123456789012:role/AppRole", "identities": [ { @@ -305,9 +438,14 @@ "permissions": [], "teams": [], "claims": { - "cognito:groups": ["admins", "engineering"], + "cognito:groups": [ + "admins", + "engineering" + ], "cognito:username": "alice", - "cognito:roles": ["arn:aws:iam::123456789012:role/AppRole"], + "cognito:roles": [ + "arn:aws:iam::123456789012:role/AppRole" + ], "cognito:preferred_role": "arn:aws:iam::123456789012:role/AppRole", "identities": [ { @@ -331,7 +469,9 @@ "role": "user", "provenance": "Kubernetes projected ServiceAccount token, per the authentication reference. `kubernetes.io` is a top-level claim name containing a dot over a nested object.", "claims": { - "aud": ["https://kubernetes.default.svc"], + "aud": [ + "https://kubernetes.default.svc" + ], "exp": 2000000000, "iat": 1999999400, "iss": "https://kubernetes.default.svc", @@ -340,9 +480,18 @@ "sub": "system:serviceaccount:default:agent", "kubernetes.io": { "namespace": "default", - "node": { "name": "node-1", "uid": "3f2b" }, - "pod": { "name": "agent-7d9f", "uid": "7c1e" }, - "serviceaccount": { "name": "agent", "uid": "9b4d" } + "node": { + "name": "node-1", + "uid": "3f2b" + }, + "pod": { + "name": "agent-7d9f", + "uid": "7c1e" + }, + "serviceaccount": { + "name": "agent", + "uid": "9b4d" + } } }, "expected": { @@ -353,9 +502,18 @@ "claims": { "kubernetes.io": { "namespace": "default", - "node": { "name": "node-1", "uid": "3f2b" }, - "pod": { "name": "agent-7d9f", "uid": "7c1e" }, - "serviceaccount": { "name": "agent", "uid": "9b4d" } + "node": { + "name": "node-1", + "uid": "3f2b" + }, + "pod": { + "name": "agent-7d9f", + "uid": "7c1e" + }, + "serviceaccount": { + "name": "agent", + "uid": "9b4d" + } } } } @@ -391,12 +549,18 @@ "provenance": "Constructed to exercise the authorized_scopes branch of the authorized_scopes/scope fallback, with both claims present.", "claims": { "client_id": "svc", - "authorized_scopes": ["read", "write"], + "authorized_scopes": [ + "read", + "write" + ], "scope": "ignored other" }, "expected": { "client_id": "svc", - "authorized_scopes": ["read", "write"], + "authorized_scopes": [ + "read", + "write" + ], "claims": {} } }, @@ -410,7 +574,11 @@ }, "expected": { "client_id": "svc", - "authorized_scopes": ["read", "write", "admin"], + "authorized_scopes": [ + "read", + "write", + "admin" + ], "claims": {} } }, @@ -429,6 +597,26 @@ "claims": {} } }, + { + "name": "client-an-array-element-containing-whitespace-stays-whole", + "role": "client", + "provenance": "Constructed: the client-side pair of the subject case, where order makes the result fully observable.", + "claims": { + "client_id": "svc", + "authorized_scopes": [ + "read reports", + "write" + ] + }, + "expected": { + "client_id": "svc", + "authorized_scopes": [ + "read reports", + "write" + ], + "claims": {} + } + }, { "name": "client-aud-as-a-string", "role": "client", @@ -439,7 +627,9 @@ }, "expected": { "client_id": "svc", - "authorized_audiences": ["gateway"], + "authorized_audiences": [ + "gateway" + ], "claims": {} } }, @@ -449,11 +639,17 @@ "provenance": "Keycloak serializes an array at two or more audiences; SPIRE and Kubernetes always do.", "claims": { "client_id": "svc", - "aud": ["gateway", "api"] + "aud": [ + "gateway", + "api" + ] }, "expected": { "client_id": "svc", - "authorized_audiences": ["gateway", "api"], + "authorized_audiences": [ + "gateway", + "api" + ], "claims": {} } }, @@ -490,11 +686,17 @@ "provenance": "Constructed: platform-native client roles, which are ordered on the client extension.", "claims": { "client_id": "svc", - "roles": ["service", "admin"] + "roles": [ + "service", + "admin" + ] }, "expected": { "client_id": "svc", - "roles": ["service", "admin"], + "roles": [ + "service", + "admin" + ], "claims": {} } }, @@ -518,11 +720,19 @@ "provenance": "Constructed: the client roles field is a Vec, so a repeated element survives. This is what makes the no-deduplication choice observable.", "claims": { "client_id": "svc", - "roles": ["admin", "admin", "viewer"] + "roles": [ + "admin", + "admin", + "viewer" + ] }, "expected": { "client_id": "svc", - "roles": ["admin", "admin", "viewer"], + "roles": [ + "admin", + "admin", + "viewer" + ], "claims": {} } }, @@ -558,21 +768,49 @@ "clientAddress": "10.0.0.5", "preferred_username": "service-account-my-service", "scope": "openid profile email", - "realm_access": { "roles": ["offline_access", "uma_authorization"] }, - "resource_access": { "my-service": { "roles": ["svc-role"] } } + "realm_access": { + "roles": [ + "offline_access", + "uma_authorization" + ] + }, + "resource_access": { + "my-service": { + "roles": [ + "svc-role" + ] + } + } }, "expected": { "client_id": "my-service", - "authorized_scopes": ["openid", "profile", "email"], - "authorized_audiences": ["account"], + "authorized_scopes": [ + "openid", + "profile", + "email" + ], + "authorized_audiences": [ + "account" + ], "roles": [], "claims": { "typ": "Bearer", "clientHost": "10.0.0.5", "clientAddress": "10.0.0.5", "preferred_username": "service-account-my-service", - "realm_access": { "roles": ["offline_access", "uma_authorization"] }, - "resource_access": { "my-service": { "roles": ["svc-role"] } } + "realm_access": { + "roles": [ + "offline_access", + "uma_authorization" + ] + }, + "resource_access": { + "my-service": { + "roles": [ + "svc-role" + ] + } + } } } }, @@ -592,8 +830,13 @@ }, "expected": { "client_id": "6MZ2Wt3rBGxOA1example", - "authorized_scopes": ["read:reports", "write:reports"], - "authorized_audiences": ["https://my-api.example"], + "authorized_scopes": [ + "read:reports", + "write:reports" + ], + "authorized_audiences": [ + "https://my-api.example" + ], "roles": [], "claims": { "gty": "client-credentials" @@ -618,7 +861,9 @@ }, "expected": { "client_id": "1example23456789", - "authorized_scopes": ["resourceserver.1/appclient2"], + "authorized_scopes": [ + "resourceserver.1/appclient2" + ], "authorized_audiences": [], "claims": { "token_use": "access", @@ -642,7 +887,9 @@ "provenance": "SPIFFE JWT-SVID standard: `sub` MUST hold the SPIFFE ID.", "claims": { "sub": "spiffe://corp.example/ns/default/sa/agent", - "aud": ["spire-server"], + "aud": [ + "spire-server" + ], "exp": 2000000000, "iat": 1999999400 }, @@ -690,7 +937,10 @@ "role": "workload", "provenance": "SPIRE JWT-SVID, per credtemplate/builder.go. `aud` is invariantly an array and there is no `iss`, which the JWT-SVID standard does not specify.", "claims": { - "aud": ["spire-server", "https://my-api.example"], + "aud": [ + "spire-server", + "https://my-api.example" + ], "exp": 2000000000, "iat": 1999999400, "sub": "spiffe://example.org/ns/prod/sa/api" From 0b8116e437bbaa9fc7506ccea3b08a4d4e79eef1 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 18:18:14 -0400 Subject: [PATCH 06/27] feat(identity-jwt): route claim_mapper through the presets and accept claim_map `claim_mapper` now names any shipped preset instead of only `standard`, and a new `claim_map` field takes an inline map. Setting both is a config error rather than a precedence rule. An absent setting still resolves to the standard preset, so a deployment that changes nothing sees what it saw. The section matching the configured role is required at construction, so a map paired with the wrong role fails at startup rather than denying every request. The three mapping denials keep their code and stop naming `sub` and `client_id` as though they were fixed, pointing at the debug diagnostics instead. Signed-off-by: Frederico Araujo --- builtins/plugins/identity-jwt/src/config.rs | 29 ++- builtins/plugins/identity-jwt/src/factory.rs | 65 ++++- builtins/plugins/identity-jwt/src/presets.rs | 5 +- builtins/plugins/identity-jwt/src/resolver.rs | 233 +++++++++++++++--- 4 files changed, 290 insertions(+), 42 deletions(-) diff --git a/builtins/plugins/identity-jwt/src/config.rs b/builtins/plugins/identity-jwt/src/config.rs index d935b0a..4d02b2e 100644 --- a/builtins/plugins/identity-jwt/src/config.rs +++ b/builtins/plugins/identity-jwt/src/config.rs @@ -18,6 +18,7 @@ use praxis_policy_core::extensions::raw_credentials::TokenRole; use serde::{Deserialize, Serialize}; use super::trusted_issuer::{KeyStore, TrustedIssuer}; +use crate::claim_map_config::ClaimMapConfig; /// Top-level plugin config — what operators write under /// `plugins[].config:` in unified-config YAML. @@ -55,12 +56,32 @@ pub struct JwtIdentityResolverConfig { #[serde(default = "default_header")] pub header: String, - /// Which claim mapper to use. `"standard"` is the OIDC default; - /// future named mappers (e.g., `"keycloak"`, `"cognito"`) plug - /// in via the registry pattern in `resolver.rs`. Omitted → - /// `StandardClaimMap`. + /// Which shipped preset to map claims with: `standard`, `keycloak`, + /// `auth0` or `cognito`. Omitted resolves to `standard`, which reproduces + /// the OIDC shape this plugin has always mapped. An unknown name fails at + /// construction and lists the valid ones. + /// + /// Each preset's `description` in `src/presets/` records what it covers and + /// what it deliberately omits, which matters: two of the three providers + /// namespace or parameterize their roles claim per deployment, so no preset + /// can carry it. Reach those with [`claim_map`]. + /// + /// Mutually exclusive with [`claim_map`]. + /// + /// [`claim_map`]: Self::claim_map #[serde(default)] pub claim_mapper: Option, + + /// An inline claim map, for a shape no preset covers. + /// + /// Mutually exclusive with [`claim_mapper`]; setting both is a config error + /// rather than a precedence rule. + /// + /// See [`ClaimMapConfig`] for the surface and its escaping rules. + /// + /// [`claim_mapper`]: Self::claim_mapper + #[serde(default)] + pub claim_map: Option, } fn default_role() -> TokenRole { diff --git a/builtins/plugins/identity-jwt/src/factory.rs b/builtins/plugins/identity-jwt/src/factory.rs index 49c8856..61d44f5 100644 --- a/builtins/plugins/identity-jwt/src/factory.rs +++ b/builtins/plugins/identity-jwt/src/factory.rs @@ -58,7 +58,12 @@ impl PluginFactory for JwtIdentityFactory { } #[cfg(test)] -#[allow(clippy::expect_used, clippy::indexing_slicing, reason = "tests")] +#[allow( + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + reason = "tests" +)] mod tests { use super::*; @@ -100,6 +105,64 @@ mod tests { ); } + /// A claim map the resolver rejects has to fail here too. The alternative is + /// a registered resolver that denies every request, which reads as an outage + /// rather than as the configuration mistake it is. + #[test] + fn a_claim_map_fault_fails_the_factory_rather_than_the_first_request() { + let base = valid_config(); + let issuers = base + .get("trusted_issuers") + .expect("the base config declares issuers") + .clone(); + + for faulty in [ + serde_json::json!({"trusted_issuers": issuers.clone(), "claim_mapper": "made-up"}), + serde_json::json!({ + "trusted_issuers": issuers.clone(), + "claim_map": {"subject": {"roles": "realm_access..roles"}}, + }), + serde_json::json!({ + "trusted_issuers": issuers.clone(), + "claim_map": {"subject": {"id": "sub"}}, + "role": "client", + }), + serde_json::json!({ + "trusted_issuers": issuers, + "claim_mapper": "standard", + "claim_map": {"subject": {"id": "sub"}}, + }), + ] { + let err = JwtIdentityFactory + .create(&cfg(faulty.clone())) + .err() + .unwrap_or_else(|| panic!("{faulty} must not build")); + assert!( + matches!(*err, PluginError::Config { .. }), + "{faulty}: expected a config error, got {err:?}" + ); + } + } + + /// Every shipped preset is nameable through the factory, which is the path a + /// host actually takes. + #[test] + fn every_shipped_preset_is_nameable_through_the_factory() { + let issuers = valid_config() + .get("trusted_issuers") + .expect("the base config declares issuers") + .clone(); + for name in crate::presets::names() { + let config = serde_json::json!({ + "trusted_issuers": issuers.clone(), + "claim_mapper": name, + }); + JwtIdentityFactory + .create(&cfg(config)) + .unwrap_or_else(|_| panic!("'{name}' must build through the factory")); + } + } + /// The factory propagates construction failure rather than registering a /// resolver that would deny every request at runtime. #[test] diff --git a/builtins/plugins/identity-jwt/src/presets.rs b/builtins/plugins/identity-jwt/src/presets.rs index 03c11fb..072f262 100644 --- a/builtins/plugins/identity-jwt/src/presets.rs +++ b/builtins/plugins/identity-jwt/src/presets.rs @@ -218,8 +218,11 @@ mod tests { } } + /// An absent `claim_mapper` resolves here, so changing this name would + /// change what every deployment that names no mapper gets. #[test] - fn the_default_preset_is_in_the_table() { + fn the_default_preset_is_standard_and_is_in_the_table() { + assert_eq!(DEFAULT_PRESET, "standard"); assert!(lookup(DEFAULT_PRESET).is_ok()); } diff --git a/builtins/plugins/identity-jwt/src/resolver.rs b/builtins/plugins/identity-jwt/src/resolver.rs index be96422..0b6f92e 100644 --- a/builtins/plugins/identity-jwt/src/resolver.rs +++ b/builtins/plugins/identity-jwt/src/resolver.rs @@ -58,8 +58,10 @@ use praxis_policy_core::hooks::trait_def::{HookHandler, PluginResult}; use praxis_policy_core::identity::{IdentityHook, IdentityPayload}; use praxis_policy_core::plugin::{Plugin, PluginConfig}; -use super::claim_map::{ClaimMap, ClaimMapper, StandardClaimMap}; +use super::claim_map::{ClaimMap, ClaimMapper}; use super::config::{JwtIdentityResolverConfig, TrustedIssuerConfig}; +use super::configured_mapper::ConfiguredClaimMap; +use super::presets; use super::trusted_issuer::{KeyStore, TrustedIssuer}; /// Default clock-skew tolerance, in seconds. Matches what most OIDC @@ -193,22 +195,6 @@ impl JwtIdentityResolver { } } - // Resolve the claim mapper by name. Unknown names are a - // config error rather than a silent fallback — fail fast - // so operators notice typos. - let claim_mapper: Arc = match typed.claim_mapper.as_deref() { - None | Some("standard") => Arc::new(StandardClaimMap), - Some(other) => { - return Err(Box::new(PluginError::Config { - message: format!( - "plugin '{}' (praxis-policy-plugin-identity-jwt): unknown claim_mapper \ - '{other}'; valid: [standard]", - cfg.name - ), - })); - }, - }; - // Reject `role: Custom(...)` at construction — the framework // has slots for User / Client / Workload (the three named // entries on SecurityExtension). Custom roles would write to @@ -224,6 +210,42 @@ impl JwtIdentityResolver { ), })); } + + // Resolve the claim map: an inline `claim_map`, a preset named by + // `claim_mapper`, or the standard preset. Unknown names and malformed + // maps are config errors rather than silent fallbacks, so an operator's + // typo fails at load rather than denying every request. + let config_error = |message: String| { + Box::new(PluginError::Config { + message: format!( + "plugin '{}' (praxis-policy-plugin-identity-jwt): {message}", + cfg.name + ), + }) + }; + + let compiled = match (typed.claim_map.as_ref(), typed.claim_mapper.as_deref()) { + (Some(_), Some(named)) => { + return Err(config_error(format!( + "`claim_map` and `claim_mapper: {named}` are both set; pick one, an inline \ + map or a preset by name" + ))); + }, + (Some(inline), None) => inline + .compile() + .map_err(|e| config_error(format!("`claim_map` is not usable: {e}")))?, + (None, named) => presets::lookup(named.unwrap_or(presets::DEFAULT_PRESET)) + .map_err(&config_error)? + .into_claim_map(), + }; + + // Require the section matching the configured role now, so a + // misconfigured pairing is a startup failure rather than a resolver that + // denies every request. + compiled.role(&typed.role).map_err(&config_error)?; + + let claim_mapper: Arc = Arc::new(ConfiguredClaimMap::new(compiled)); + if typed.header.trim().is_empty() { return Err(Box::new(PluginError::Config { message: format!( @@ -534,8 +556,10 @@ impl HookHandler for JwtIdentityResolver { None => { return PluginResult::deny(PluginViolation::new( "auth.mapping_failed", - "claim mapper produced no subject — required `sub` \ - claim missing or wrong shape", + "the claim map produced no subject: no candidate resolved for the \ + subject id, or a field declaring `on_missing: deny` resolved \ + nothing. Raise the log level to debug to see which fields and \ + which paths were tried", )); }, }, @@ -544,8 +568,10 @@ impl HookHandler for JwtIdentityResolver { None => { return PluginResult::deny(PluginViolation::new( "auth.mapping_failed", - "claim mapper produced no client — required `client_id` \ - / `azp` claim missing", + "the claim map produced no client: no candidate resolved for the \ + client id, or a field declaring `on_missing: deny` resolved \ + nothing. Raise the log level to debug to see which fields and \ + which paths were tried", )); }, }, @@ -554,8 +580,10 @@ impl HookHandler for JwtIdentityResolver { None => { return PluginResult::deny(PluginViolation::new( "auth.mapping_failed", - "claim mapper produced no workload — token doesn't look \ - like a SPIFFE-JWT-SVID (sub doesn't start with `spiffe://`)", + "the claim map produced no workload: no candidate resolved to a \ + `spiffe://` identity, which every candidate must, or a field \ + declaring `on_missing: deny` resolved nothing. Raise the log \ + level to debug to see which fields and which paths were tried", )); }, }, @@ -844,21 +872,154 @@ mod tests { assert!(format!("{err}").contains("trusted_issuers")); } + /// A config carrying the test issuer plus whatever mapper settings the case + /// needs. Every claim-map test goes through `new` like production does. + fn cfg_with_mapper(settings: Value) -> PluginConfig { + let mut config = json!({ + "trusted_issuers": [{ + "issuer": "https://idp.example.com", + "algorithms": ["HS256"], + "decoding_key": { "kind": "secret", "secret": "x" }, + }], + }); + if let (Some(target), Some(extra)) = (config.as_object_mut(), settings.as_object()) { + for (key, value) in extra { + target.insert(key.clone(), value.clone()); + } + } + cfg_with_config("jwt", config) + } + + fn build_err(settings: Value) -> String { + format!( + "{}", + JwtIdentityResolver::new(cfg_with_mapper(settings)) + .expect_err("this config must not build") + ) + } + #[test] fn new_rejects_unknown_claim_mapper() { - let cfg = cfg_with_config( - "jwt", - json!({ - "trusted_issuers": [{ - "issuer": "https://idp.example.com", - "algorithms": ["HS256"], - "decoding_key": { "kind": "secret", "secret": "x" }, - }], - "claim_mapper": "made-up-mapper", - }), - ); - let err = JwtIdentityResolver::new(cfg).expect_err("unknown mapper should fail"); - assert!(format!("{err}").contains("claim_mapper")); + let err = build_err(json!({"claim_mapper": "made-up-mapper"})); + assert!(err.contains("claim_mapper"), "{err}"); + assert!(err.contains("made-up-mapper"), "{err}"); + for name in presets::names() { + assert!(err.contains(name), "'{name}' missing from: {err}"); + } + } + + /// The two mapper settings are alternatives, not layers, so setting both is + /// a mistake with no coherent reading. The message names both. + #[test] + fn new_rejects_both_claim_mapper_and_claim_map() { + let err = build_err(json!({ + "claim_mapper": "keycloak", + "claim_map": {"subject": {"id": "sub"}}, + })); + assert!(err.contains("claim_mapper"), "{err}"); + assert!(err.contains("claim_map"), "{err}"); + } + + /// An absent setting and the `standard` name are the same thing, and both + /// have to keep working: an upgrading deployment changes neither. + #[test] + fn an_absent_mapper_and_the_standard_name_both_build() { + for settings in [json!({}), json!({"claim_mapper": "standard"})] { + JwtIdentityResolver::new(cfg_with_mapper(settings.clone())) + .unwrap_or_else(|e| panic!("{settings} must build: {e}")); + } + } + + #[test] + fn every_shipped_preset_builds_a_resolver_for_a_role_it_declares() { + for name in presets::names() { + JwtIdentityResolver::new(cfg_with_mapper(json!({"claim_mapper": name}))) + .unwrap_or_else(|e| panic!("'{name}' must build for the default role: {e}")); + JwtIdentityResolver::new(cfg_with_mapper(json!({ + "claim_mapper": name, "role": "client", + }))) + .unwrap_or_else(|e| panic!("'{name}' must build for role: client: {e}")); + } + } + + /// `keycloak` is the case that motivated the work: it was rejected before, + /// because only `standard` was a name the resolver knew. + #[test] + fn a_provider_preset_builds_where_it_previously_failed() { + JwtIdentityResolver::new(cfg_with_mapper(json!({"claim_mapper": "keycloak"}))) + .expect("keycloak must build"); + } + + /// No provider preset has a workload shape, so pairing one with + /// `role: workload` fails at load naming the role, which beats a section of + /// guesses about a shape the provider does not mint. + #[test] + fn a_preset_without_a_workload_section_refuses_the_workload_role() { + for name in ["auth0", "cognito", "keycloak"] { + let err = build_err(json!({"claim_mapper": name, "role": "workload"})); + assert!(err.contains("workload"), "'{name}': {err}"); + } + JwtIdentityResolver::new(cfg_with_mapper(json!({ + "claim_mapper": "standard", "role": "workload", + }))) + .expect("standard declares a workload section"); + } + + #[test] + fn an_inline_claim_map_builds() { + JwtIdentityResolver::new(cfg_with_mapper(json!({ + "claim_map": { + "subject": { + "id": "sub", + "roles": { + "paths": ["realm_access.roles", "resource_access.my-api.roles"], + "merge": "union", + }, + } + } + }))) + .expect("an inline map must build"); + } + + /// A map that declares the wrong section fails at load rather than denying + /// every request, which is the whole point of checking at construction. + #[test] + fn an_inline_claim_map_missing_the_configured_role_names_the_role() { + let err = build_err(json!({ + "claim_map": {"subject": {"id": "sub"}}, + "role": "client", + })); + assert!(err.contains("client"), "{err}"); + } + + #[test] + fn a_malformed_path_in_an_inline_claim_map_names_the_field_and_the_path() { + let err = build_err(json!({ + "claim_map": {"subject": {"id": "sub", "roles": "realm_access..roles"}} + })); + assert!(err.contains("subject.roles"), "{err}"); + assert!(err.contains("realm_access..roles"), "{err}"); + } + + /// A map of the wrong JSON shape entirely fails at construction, not at the + /// first request. + #[test] + fn an_inline_claim_map_of_the_wrong_shape_is_refused_at_load() { + for map in [ + json!({"claim_map": "standard"}), + json!({"claim_map": {"subjekt": {"id": "sub"}}}), + json!({"claim_map": {"subject": {"id": 42}}}), + json!({"claim_map": {"subject": {"id": "sub"}, "claims": {"exclude": "iss"}}}), + ] { + let Err(err) = JwtIdentityResolver::new(cfg_with_mapper(map.clone())) else { + panic!("{map} must not build"); + }; + let err = format!("{err}"); + assert!( + err.contains("praxis-policy-plugin-identity-jwt"), + "{map}: the message must name the plugin: {err}" + ); + } } #[test] From ec41cc9ed1e8bdd7e54cde22ad03a63cb42ae8bd Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 18:20:51 -0400 Subject: [PATCH 07/27] test(identity-jwt): gate the standard preset against the Rust mapper Every corpus entry maps through both paths and must agree field for field, including the whole claims bag and the order of the client fields, where the declaration-order and no-deduplication choices are the only observable ones. A token the Rust mapper declines must be declined by the preset too. Dropping array_only, reordering the client anchor candidates, dropping the scope split, and dropping the aud candidate each fail the gate, so it is not passing vacuously. Signed-off-by: Frederico Araujo --- .../tests/standard_preset_equivalence.rs | 183 +++++++++++++++++- 1 file changed, 182 insertions(+), 1 deletion(-) diff --git a/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs b/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs index bd471f0..a289125 100644 --- a/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs +++ b/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs @@ -9,6 +9,10 @@ //! from. The corpus is the contract: it is asserted against `StandardClaimMap`, //! so it describes the mapper rather than any later reimplementation of it. //! +//! The gate then holds the shipped `standard` preset to that same corpus. Where +//! the two disagree the preset is what changes, never the Rust mapper and never +//! the corpus, unless the corpus entry is itself wrong about what an `IdP` mints. +//! //! The corpus is embedded rather than read at run time, so a missing or //! unparseable file is a compile or test failure and never a silently skipped //! entry. @@ -27,7 +31,9 @@ use std::collections::{HashMap, HashSet}; use praxis_policy_core::extensions::{ClientExtension, SubjectExtension, WorkloadIdentity}; -use praxis_policy_plugin_identity_jwt::{ClaimMapper as _, StandardClaimMap}; +use praxis_policy_plugin_identity_jwt::{ + ClaimMapper as _, ConfiguredClaimMap, StandardClaimMap, presets, +}; use serde::Deserialize; use serde_json::Value; @@ -431,3 +437,178 @@ fn every_role_has_a_declining_entry() { ); } } + +// ===================================================================== +// The gate: the standard preset agrees with the Rust mapper +// ===================================================================== + +fn standard_preset() -> ConfiguredClaimMap { + ConfiguredClaimMap::new( + presets::lookup("standard") + .expect("the shipped standard preset must load") + .into_claim_map(), + ) +} + +/// Map one entry through both paths and assert they agree. +/// +/// The preset is the `actual` side so a failure reads as the preset diverging, +/// which is the side that changes when it does. +fn assert_entry_agrees(entry: &CorpusEntry, preset: &ConfiguredClaimMap) { + let context = format!("{} (standard preset vs rust mapper)", entry.name); + match entry.role { + CorpusRole::User => { + match ( + preset.map_subject(&entry.claims), + StandardClaimMap.map_subject(&entry.claims), + ) { + (Some(from_preset), Some(from_rust)) => { + assert_subjects_agree(&context, &from_preset, &from_rust); + }, + (None, None) => {}, + (from_preset, from_rust) => panic!( + "{context}: the preset {} but the mapper {}", + described(from_preset.is_some()), + described(from_rust.is_some()) + ), + } + }, + CorpusRole::Client => { + match ( + preset.map_client(&entry.claims), + StandardClaimMap.map_client(&entry.claims), + ) { + (Some(from_preset), Some(from_rust)) => { + assert_clients_agree(&context, &from_preset, &from_rust); + }, + (None, None) => {}, + (from_preset, from_rust) => panic!( + "{context}: the preset {} but the mapper {}", + described(from_preset.is_some()), + described(from_rust.is_some()) + ), + } + }, + CorpusRole::Workload => { + match ( + preset.map_workload(&entry.claims), + StandardClaimMap.map_workload(&entry.claims), + ) { + (Some(from_preset), Some(from_rust)) => { + assert_workloads_agree(&context, &from_preset, &from_rust); + }, + (None, None) => {}, + (from_preset, from_rust) => panic!( + "{context}: the preset {} but the mapper {}", + described(from_preset.is_some()), + described(from_rust.is_some()) + ), + } + }, + } +} + +fn described(produced: bool) -> &'static str { + if produced { + "produced an identity" + } else { + "declined" + } +} + +/// The compatibility promise, in one test: an upgrading deployment that names no +/// mapper sees the identity it saw before, for every shape in the corpus. +#[test] +fn the_standard_preset_agrees_with_the_rust_mapper_across_the_corpus() { + let preset = standard_preset(); + for entry in corpus() { + assert_entry_agrees(&entry, &preset); + } +} + +/// Both branches of every fallback, asserted through both paths and named by +/// their fallback. Without this a preset expressing a fallback differently could +/// pass on the strength of the branch that happens to agree. +#[test] +fn both_branches_of_every_fallback_agree_through_both_paths() { + let preset = standard_preset(); + let entries = corpus(); + let find = |name: &str| { + entries + .iter() + .find(|entry| entry.name == name) + .unwrap_or_else(|| panic!("the corpus must carry an entry named '{name}'")) + }; + + for (fallback, first, second) in FALLBACK_BRANCHES { + for branch in [first, second] { + let entry = find(branch); + assert_entry_agrees(entry, &preset); + println!("{fallback}: '{branch}' agrees"); + } + } + for shape in AUD_SHAPES { + assert_entry_agrees(find(shape), &preset); + } +} + +/// A token the Rust mapper declines must be declined by the preset too. An +/// anchor the preset accepts where the mapper does not is a widened surface, not +/// a compatible one. +#[test] +fn a_token_the_rust_mapper_declines_is_declined_by_the_preset() { + let preset = standard_preset(); + let declining: Vec = corpus() + .into_iter() + .filter(|entry| entry.expected.is_none()) + .collect(); + assert!( + !declining.is_empty(), + "the corpus must carry entries the mapper declines" + ); + for entry in declining { + let produced = match entry.role { + CorpusRole::User => preset.map_subject(&entry.claims).is_some(), + CorpusRole::Client => preset.map_client(&entry.claims).is_some(), + CorpusRole::Workload => preset.map_workload(&entry.claims).is_some(), + }; + assert!( + !produced, + "{}: the Rust mapper declines this token and the preset must too", + entry.name + ); + } +} + +/// The claims bag is compared key for key and value for value inside +/// `assert_*_agree`. This spells out why: the bag is the only route from a claim +/// to a policy, so a claim the preset fails to exclude is a visible change even +/// when every typed field matches. +#[test] +fn the_claims_bag_comparison_is_exhaustive() { + let preset = standard_preset(); + let entry = corpus() + .into_iter() + .find(|entry| entry.name == "subject-keycloak-access-token") + .expect("the Keycloak entry carries the widest claims bag in the corpus"); + + let from_preset = preset + .map_subject(&entry.claims) + .expect("the Keycloak token resolves"); + let from_rust = StandardClaimMap + .map_subject(&entry.claims) + .expect("the Keycloak token resolves"); + + assert_eq!( + from_preset.claims.keys().collect::>(), + from_rust.claims.keys().collect::>(), + "same key set" + ); + for (name, value) in &from_rust.claims { + assert_eq!(from_preset.claims.get(name), Some(value), "claim `{name}`"); + } + assert!( + from_preset.claims.contains_key("realm_access"), + "a nested claim no single-segment path consumed stays visible" + ); +} From 6fbdb0c16101829ff4e2ff61cc9eceb9edeaf1c5 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 18:32:34 -0400 Subject: [PATCH 08/27] docs(identity-jwt): document the claim map and cover it end to end Thirteen tests through the real resolver on signed tokens: nested and per-client roles through a union map, an escaped URL claim name alongside the unescaped path that resolves nothing, cognito:groups without escaping, a delimited scope split and unsplit, a preset by name, iss made policy-visible, a mistyped path both permissive and fatal, the SPIFFE prefix per candidate, and construction failures reaching the host through the factory. The rustdoc carries the quoting trap: the plugin receives JSON, so a double-quoted YAML scalar needs doubled backslashes where a plain or single-quoted one does not. Signed-off-by: Frederico Araujo --- CHANGELOG.md | 6 + .../identity-jwt/src/claim_map_config.rs | 51 + builtins/plugins/identity-jwt/src/factory.rs | 24 + builtins/plugins/identity-jwt/src/lib.rs | 6 + .../identity-jwt/tests/claim_map_e2e.rs | 615 +++++++++ ...configurable-claim-mapping-requirements.md | 149 +++ ...01-feat-configurable-claim-mapping-plan.md | 1130 +++++++++++++++++ 7 files changed, 1981 insertions(+) create mode 100644 builtins/plugins/identity-jwt/tests/claim_map_e2e.rs create mode 100644 docs/brainstorms/2026-08-20-configurable-claim-mapping-requirements.md create mode 100644 docs/plans/2026-08-20-001-feat-configurable-claim-mapping-plan.md diff --git a/CHANGELOG.md b/CHANGELOG.md index d54a2ab..2c96f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,12 @@ 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` now 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 Rust and a rebuild. A field takes an ordered candidate list resolved first-match or unioned, can require an array, and can split a delimited string. Paths address nested claims by dots, with `\.` escaping a dot so a claim whose whole name is a URL is reachable; a colon needs no escaping. **An existing config is unaffected:** naming no mapper resolves to `standard`, which is held to the previous Rust mapper over a corpus of provider token shapes, so an upgrading deployment sees the identity it saw before. Setting both fields, naming an unknown preset, writing a malformed path, or pairing a map with a role it does not declare each fail at startup naming the fault, rather than denying every request. A field whose paths all miss is logged at debug with every path it tried, or refuses the token if it declared `on_missing: deny`. ([#28](https://github.com/praxis-proxy/policy/pull/28)) + +- **A policy can gate on which `IdP` minted a token.** A claim map's `claims.include` puts a claim back in the policy-visible bag, registered JWT claims included, so `claims: {include: [iss]}` makes `claim.iss` readable. A deployment trusting several issuers could not express "only tokens from the internal `IdP` may call this tool" before, because the subject claims bag is the only route from a claim to a policy and registered claims were always dropped. `claims.exclude` drops one the other way. ([#28](https://github.com/praxis-proxy/policy/pull/28)) + +- **Each shipped preset records what it deliberately omits.** Two of the three providers put their roles claim somewhere no preset can name: Auth0 forbids a bare `roles` claim so roles arrive under a deployment's own URL namespace, and Keycloak's per-client roles sit under the client id an operator chose. Both need a hand-written `claim_map`. Presets also leave a field empty rather than filling it with the wrong concept — Keycloak's `groups` claim holds realm roles, and Cognito's `cognito:roles` holds IAM role ARNs — because a field quietly filled with the wrong thing gives an operator no reason to look. Each preset's description names its gaps and which of its claims are opt-in at the provider. ([#28](https://github.com/praxis-proxy/policy/pull/28)) + - **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.` booleans. The flattened boolean keys are unchanged. ([#7](https://github.com/praxis-proxy/policy/pull/7)) ### Fixed diff --git a/builtins/plugins/identity-jwt/src/claim_map_config.rs b/builtins/plugins/identity-jwt/src/claim_map_config.rs index bb6f463..b9e30dd 100644 --- a/builtins/plugins/identity-jwt/src/claim_map_config.rs +++ b/builtins/plugins/identity-jwt/src/claim_map_config.rs @@ -282,6 +282,57 @@ pub struct ClaimsOverrides { pub struct RoleMapConfig(pub BTreeMap); /// The claim map an operator writes under `claim_map:`. +/// +/// One section per role, and a resolver uses the section matching its own +/// `role:`. A field is written in one of three forms: +/// +/// ```yaml +/// claim_map: +/// subject: +/// id: sub # a path +/// teams: [teams, groups] # ordered candidates, first match wins +/// roles: # candidates plus options +/// paths: +/// - realm_access.roles +/// - resource_access.my-api.roles +/// merge: union # first_match (default) | union +/// permissions: +/// paths: +/// - { path: permissions, array_only: true } +/// - scope +/// split: whitespace # break a delimited string into elements +/// on_missing: deny # ignore (default) | deny +/// claims: +/// exclude: [internal_debug] # drop an otherwise-visible claim +/// include: [iss] # keep one the inference drops +/// ``` +/// +/// A field with no candidate that resolves is left empty and logged at debug, +/// naming every path tried. `on_missing: deny` makes that a refusal instead. +/// `array_only` requires an array, so a string-valued claim is skipped and the +/// next candidate is tried. +/// +/// # Escaping, and the quoting trap +/// +/// `.` separates path segments and `\` escapes; every other character, `:` and +/// `/` included, is a literal. So `cognito:groups` is one segment written +/// plainly, and a claim whose whole name is a URL needs its dots escaped and +/// nothing else. +/// +/// The plugin receives JSON, so how many backslashes to type depends on the YAML +/// scalar style. Both of these authorize the same path: +/// +/// ```yaml +/// # double-quoted: YAML consumes one backslash, so double them +/// roles: "https://my-app\\.example\\.com/roles" +/// +/// # plain or single-quoted: YAML passes the backslash through +/// roles: https://my-app\.example\.com/roles +/// roles: 'https://my-app\.example\.com/roles' +/// ``` +/// +/// Escaping the colon is the common mistake, and it is rejected rather than +/// accepted: a colon is already a literal, so `\:` is not an escape. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ClaimMapConfig { diff --git a/builtins/plugins/identity-jwt/src/factory.rs b/builtins/plugins/identity-jwt/src/factory.rs index 61d44f5..464ed0d 100644 --- a/builtins/plugins/identity-jwt/src/factory.rs +++ b/builtins/plugins/identity-jwt/src/factory.rs @@ -19,6 +19,30 @@ // algorithms: [RS256] // decoding_key: { kind: jwks_url, url: ... } // +// Claim mapping is configuration. Either name a shipped preset: +// +// claim_mapper: keycloak +// +// or write a map, for a shape no preset covers. Mutually exclusive with +// `claim_mapper`: +// +// claim_map: +// subject: +// id: sub +// roles: +// paths: +// - realm_access.roles +// - resource_access.my-api.roles +// merge: union +// teams: 'https://my-app\.example\.com/teams' +// permissions: +// paths: [{ path: permissions, array_only: true }, scope] +// split: whitespace +// claims: +// include: [iss] +// +// See `ClaimMapConfig` for the field forms and the backslash-quoting rules. +// // The `kind: identity/jwt` string is part of this crate's public API. // Hosts call `mgr.register_factory("identity/jwt", Box::new(JwtIdentityFactory))` // before `load_config_yaml`. diff --git a/builtins/plugins/identity-jwt/src/lib.rs b/builtins/plugins/identity-jwt/src/lib.rs index f0513ee..9ecaad3 100644 --- a/builtins/plugins/identity-jwt/src/lib.rs +++ b/builtins/plugins/identity-jwt/src/lib.rs @@ -43,6 +43,12 @@ //! into the subject, client, or workload slot. This is the lightweight identity //! path: it establishes who is calling, independent of any decision point that //! runs later in the route. +//! +//! Which claims fill which field is configuration. Name a shipped preset with +//! `claim_mapper` (`standard`, `keycloak`, `auth0`, `cognito`) or write a +//! [`ClaimMapConfig`] under `claim_map` for a shape no preset covers, including +//! the nested and URL-namespaced claims that otherwise need Rust. Naming no +//! mapper resolves to `standard`, which maps what this plugin has always mapped. /// Maps validated claims onto the identity slots. pub mod claim_map; diff --git a/builtins/plugins/identity-jwt/tests/claim_map_e2e.rs b/builtins/plugins/identity-jwt/tests/claim_map_e2e.rs new file mode 100644 index 0000000..d4ca88e --- /dev/null +++ b/builtins/plugins/identity-jwt/tests/claim_map_e2e.rs @@ -0,0 +1,615 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +//! What an operator authors, proved through the real resolver on signed tokens. +//! +//! Each test wires a plugin config carrying a `claim_map` or a preset name, mints +//! a token matching a provider shape, and asserts the identity that reaches the +//! payload. The unit tests cover the engine; these cover the surface an operator +//! actually writes, including the escaping that is the likeliest thing to get +//! wrong. +//! +//! The harness is copied from `jwt_e2e.rs` rather than shared: integration test +//! binaries do not share code, and that file's helpers are private to it. + +#![allow( + missing_docs, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + clippy::print_stderr, + clippy::print_stdout, + clippy::unwrap_used, + reason = "test and example code" +)] + +use std::sync::{Arc, OnceLock}; + +use praxis_policy_core::engine::PolicyEngine; +use praxis_policy_core::error::PluginError; +use praxis_policy_core::extensions::raw_credentials::{TokenKind, TokenRole}; +use praxis_policy_core::factory::PluginFactory as _; +use praxis_policy_core::hooks::payload::Extensions; +use praxis_policy_core::identity::{ + HOOK_IDENTITY_RESOLVE, IdentityHook, IdentityPayload, TokenSource, +}; +use praxis_policy_core::plugin::{OnError, PluginConfig, PluginMode}; + +use praxis_policy_plugin_identity_jwt::{JwtIdentityFactory, JwtIdentityResolver, KIND}; + +use rsa::pkcs8::{EncodePrivateKey as _, EncodePublicKey as _, LineEnding}; +use rsa::{RsaPrivateKey, RsaPublicKey}; + +use serde_json::{Value, json}; + +const TEST_ISSUER: &str = "https://idp.test.local"; +const TEST_AUDIENCE: &str = "test-api"; + +// ===================================================================== +// Harness +// ===================================================================== + +struct Keypair { + private_pem: String, + public_pem: String, +} + +fn keypair() -> &'static Keypair { + static KP: OnceLock = OnceLock::new(); + KP.get_or_init(|| { + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); + let pub_key = RsaPublicKey::from(&priv_key); + Keypair { + private_pem: priv_key + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM") + .to_string(), + public_pem: pub_key + .to_public_key_pem(LineEnding::LF) + .expect("encode public PEM"), + } + }) +} + +fn now_unix() -> i64 { + chrono::Utc::now().timestamp() +} + +/// Sign a token carrying `extra` plus the registered claims the resolver +/// validates against. +fn mint(extra: Value) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + + let mut claims = json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "exp": now_unix() + 300, + "iat": now_unix(), + }); + match (claims.as_object_mut(), extra.as_object()) { + (Some(target), Some(source)) => { + for (key, value) in source { + target.insert(key.clone(), value.clone()); + } + }, + _ => panic!("both claim sets must be JSON objects"), + } + + let key = EncodingKey::from_rsa_pem(keypair().private_pem.as_bytes()) + .expect("build EncodingKey from the test private PEM"); + encode(&Header::new(Algorithm::RS256), &claims, &key).expect("sign JWT") +} + +/// A plugin config wiring the test key, plus whatever mapper and role settings +/// the case needs. Mirrors what an operator writes in unified-config YAML. +fn plugin_config(settings: Value) -> PluginConfig { + let mut config = json!({ + "trusted_issuers": [{ + "issuer": TEST_ISSUER, + "audiences": [TEST_AUDIENCE], + "algorithms": ["RS256"], + "decoding_key": { "kind": "pem", "pem": keypair().public_pem }, + "leeway_seconds": 60, + }], + }); + match (config.as_object_mut(), settings.as_object()) { + (Some(target), Some(source)) => { + for (key, value) in source { + target.insert(key.clone(), value.clone()); + } + }, + _ => panic!("both config blocks must be JSON objects"), + } + + PluginConfig { + name: "jwt-resolver".into(), + kind: KIND.into(), + hooks: vec![HOOK_IDENTITY_RESOLVE.into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(config), + ..Default::default() + } +} + +async fn invoke( + settings: Value, + token: String, + source: TokenSource, +) -> praxis_policy_core::executor::PipelineResult { + let cfg = plugin_config(settings); + let resolver = JwtIdentityResolver::new(cfg.clone()).expect("the resolver must construct"); + + let mgr = Arc::new(PolicyEngine::default()); + mgr.register_handler_for_names::( + Arc::new(resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .expect("registration"); + mgr.initialize().await.expect("initialize"); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + IdentityPayload::new(token, source), + Extensions::default(), + None, + ) + .await; + result +} + +/// Resolve a token and return the identity, failing with the violation when the +/// resolver denied. +async fn identity_from(settings: Value, token: String) -> IdentityPayload { + let result = invoke(settings, token, TokenSource::Bearer).await; + assert!( + result.continue_processing, + "the token should have resolved: violation = {:?}", + result.violation + ); + IdentityPayload::from_pipeline_result(&result).expect("the payload is present") +} + +async fn subject_from( + settings: Value, + token: String, +) -> praxis_policy_core::extensions::SubjectExtension { + identity_from(settings, token) + .await + .subject + .expect("the subject slot is populated") +} + +fn sorted(values: &std::collections::HashSet) -> Vec<&str> { + let mut items: Vec<&str> = values.iter().map(String::as_str).collect(); + items.sort_unstable(); + items +} + +/// A token the resolver refused, and the violation code it refused with. +async fn denial_code(settings: Value, token: String, source: TokenSource) -> String { + let result = invoke(settings, token, source).await; + assert!( + !result.continue_processing, + "the token should have been refused" + ); + result.violation.expect("a violation surfaced").code +} + +// ===================================================================== +// Nested and per-client roles +// ===================================================================== + +/// The shape that motivated the work: roles nested under `realm_access` and +/// under a per-client key, neither reachable before without writing Rust. +#[tokio::test] +async fn a_union_map_gathers_realm_and_per_client_roles() { + let token = mint(json!({ + "sub": "f:2c1b:alice", + "realm_access": {"roles": ["realm-admin"]}, + "resource_access": {"my-api": {"roles": ["viewer", "editor"]}}, + })); + + let subject = subject_from( + json!({ + "claim_map": { + "subject": { + "id": "sub", + "roles": { + "paths": ["realm_access.roles", "resource_access.my-api.roles"], + "merge": "union", + }, + } + } + }), + token, + ) + .await; + + assert_eq!( + sorted(&subject.roles), + vec!["editor", "realm-admin", "viewer"] + ); +} + +/// A nested path consumes only what it addressed, so a policy already reading +/// the whole object through the claims bag keeps working. +#[tokio::test] +async fn a_nested_role_path_leaves_the_parent_claim_whole_in_the_bag() { + let token = mint(json!({ + "sub": "alice", + "realm_access": {"roles": ["admin"], "extra": "kept"}, + })); + + let subject = subject_from( + json!({ + "claim_map": {"subject": {"id": "sub", "roles": "realm_access.roles"}} + }), + token, + ) + .await; + + assert_eq!(sorted(&subject.roles), vec!["admin"]); + assert_eq!( + subject.claims.get("realm_access"), + Some(&json!({"roles": ["admin"], "extra": "kept"})), + "the traversed parent must reach the policy bag intact" + ); +} + +// ===================================================================== +// Escaped and prefixed claim names +// ===================================================================== + +/// Auth0's own documented claim name, where the whole URL is the key. The +/// escaped dots are what does the work: the same path unescaped addresses three +/// segments that do not exist. +#[tokio::test] +async fn an_escaped_url_claim_name_populates_roles_and_the_unescaped_one_does_not() { + let token = mint(json!({ + "sub": "auth0|507f1f77bcf86cd799439011", + "https://my-app.example.com/roles": ["editor"], + })); + + let escaped = subject_from( + json!({ + "claim_map": { + "subject": {"id": "sub", "roles": "https://my-app\\.example\\.com/roles"} + } + }), + token.clone(), + ) + .await; + assert_eq!(sorted(&escaped.roles), vec!["editor"]); + + let unescaped = subject_from( + json!({ + "claim_map": { + "subject": {"id": "sub", "roles": "https://my-app.example.com/roles"} + } + }), + token, + ) + .await; + assert!( + unescaped.roles.is_empty(), + "unescaped, the dots split the name into segments that do not exist" + ); +} + +/// A colon is a literal, so a Cognito claim name needs no escaping at all. +#[tokio::test] +async fn a_cognito_groups_claim_populates_teams_without_escaping() { + let token = mint(json!({ + "sub": "a1b2c3d4", "cognito:groups": ["admins", "engineering"], + })); + + let subject = subject_from( + json!({ + "claim_map": {"subject": {"id": "sub", "teams": "cognito:groups"}} + }), + token, + ) + .await; + assert_eq!(sorted(&subject.teams), vec!["admins", "engineering"]); +} + +// ===================================================================== +// Splitting +// ===================================================================== + +#[tokio::test] +async fn a_delimited_permission_string_splits_when_declared_and_stays_whole_when_not() { + let token = mint(json!({"sub": "alice", "scope": "read write delete"})); + + let split = subject_from( + json!({ + "claim_map": { + "subject": { + "id": "sub", + "permissions": {"paths": ["scope"], "split": "whitespace"}, + } + } + }), + token.clone(), + ) + .await; + assert_eq!(sorted(&split.permissions), vec!["delete", "read", "write"]); + + let whole = subject_from( + json!({"claim_map": {"subject": {"id": "sub", "permissions": "scope"}}}), + token, + ) + .await; + assert_eq!(sorted(&whole.permissions), vec!["read write delete"]); +} + +// ===================================================================== +// Presets by name +// ===================================================================== + +/// A preset named in the existing `claim_mapper` field, end to end. Before this +/// work every name but `standard` was refused at load. +#[tokio::test] +async fn a_preset_named_in_claim_mapper_resolves_a_provider_token() { + let token = mint(json!({ + "sub": "f:2c1b:alice", + "realm_access": {"roles": ["viewer"]}, + "scope": "openid profile", + })); + + let subject = subject_from(json!({"claim_mapper": "keycloak"}), token).await; + assert_eq!(sorted(&subject.roles), vec!["viewer"]); + assert_eq!(sorted(&subject.permissions), vec!["openid", "profile"]); +} + +/// The default is unchanged: a config naming no mapper maps what it always did. +#[tokio::test] +async fn a_config_naming_no_mapper_resolves_the_standard_shape() { + let token = mint(json!({ + "sub": "alice@corp.com", "roles": ["hr", "reader"], "email": "alice@corp.com", + })); + + let subject = subject_from(json!({}), token).await; + assert_eq!(subject.id.as_deref(), Some("alice@corp.com")); + assert_eq!(sorted(&subject.roles), vec!["hr", "reader"]); + assert_eq!( + subject.claims.get("email"), + Some(&json!("alice@corp.com")), + "an unconsumed claim still reaches the policy bag" + ); +} + +// ===================================================================== +// The claims bag +// ===================================================================== + +/// `iss` is otherwise unreachable from a policy, because the subject claims bag +/// is the only route from a claim to one. A deployment trusting several issuers +/// can now gate on which of them minted the token. +#[tokio::test] +async fn including_iss_makes_the_issuing_idp_visible_to_a_policy() { + let token = mint(json!({"sub": "alice"})); + + let subject = subject_from( + json!({ + "claim_map": { + "subject": {"id": "sub"}, + "claims": {"include": ["iss"]}, + } + }), + token.clone(), + ) + .await; + assert_eq!(subject.claims.get("iss"), Some(&json!(TEST_ISSUER))); + + let without = subject_from(json!({"claim_map": {"subject": {"id": "sub"}}}), token).await; + assert!( + !without.claims.contains_key("iss"), + "a registered claim stays out of the bag unless the map asks for it" + ); +} + +#[tokio::test] +async fn excluding_a_claim_keeps_it_out_of_the_policy_bag() { + let token = mint(json!({ + "sub": "alice", "internal_debug": "noisy", "tenant": "acme", + })); + + let subject = subject_from( + json!({ + "claim_map": { + "subject": {"id": "sub"}, + "claims": {"exclude": ["internal_debug"]}, + } + }), + token, + ) + .await; + assert!(!subject.claims.contains_key("internal_debug")); + assert_eq!(subject.claims.get("tenant"), Some(&json!("acme"))); +} + +// ===================================================================== +// A mistyped path +// ===================================================================== + +/// The permissive default and the strict opt-in, on the same mistyped path. The +/// default is a resolved request with an empty field; `on_missing: deny` turns +/// the same mistake into a refusal under the existing mapping-failure code. +#[tokio::test] +async fn a_mistyped_path_is_permissive_by_default_and_fatal_on_request() { + let token = mint(json!({ + "sub": "alice", "realm_access": {"roles": ["admin"]}, + })); + + let permissive = subject_from( + json!({ + "claim_map": {"subject": {"id": "sub", "roles": "realm_acces.roles"}} + }), + token.clone(), + ) + .await; + assert!( + permissive.roles.is_empty(), + "a typo leaves the field empty rather than refusing the request" + ); + assert_eq!(permissive.id.as_deref(), Some("alice")); + + let code = denial_code( + json!({ + "claim_map": { + "subject": { + "id": "sub", + "roles": {"paths": ["realm_acces.roles"], "on_missing": "deny"}, + } + } + }), + token, + TokenSource::Bearer, + ) + .await; + assert_eq!(code, "auth.mapping_failed"); +} + +// ===================================================================== +// The workload role +// ===================================================================== + +/// The prefix check is not configurable, and it applies to every candidate: a +/// map pointing at both a non-SPIFFE `sub` and a bogus `spiffe_id` resolves +/// neither, and the same map accepts as soon as one candidate is a real SPIFFE ID. +#[tokio::test] +async fn the_workload_role_requires_a_spiffe_id_on_whichever_candidate_resolves() { + let map = json!({ + "role": "workload", + "header": "X-Workload-Token", + "claim_map": {"workload": {"spiffe_id": ["sub", "spiffe_id"]}}, + }); + + let code = denial_code( + map.clone(), + mint(json!({"sub": "alice@corp.example", "spiffe_id": "not-a-spiffe-id"})), + TokenSource::SpiffeJwtSvid, + ) + .await; + assert_eq!(code, "auth.mapping_failed"); + + let identity = identity_from( + map, + mint(json!({ + "sub": "alice@corp.example", + "spiffe_id": "spiffe://corp.example/ns/default/sa/agent", + })), + ) + .await; + let workload = identity + .caller_workload + .expect("a valid SPIFFE candidate resolves the workload slot"); + assert_eq!( + workload.spiffe_id.as_deref(), + Some("spiffe://corp.example/ns/default/sa/agent") + ); + assert_eq!( + workload.trust_domain.as_deref(), + Some("corp.example"), + "the trust domain is derived from the URI authority" + ); +} + +// ===================================================================== +// Construction failures reach the host +// ===================================================================== + +/// A map paired with the wrong role, and a map carrying a malformed path, are +/// both startup failures through the factory. Neither can become a resolver that +/// denies every request. +#[test] +fn a_role_mismatch_and_a_malformed_path_each_fail_at_plugin_construction() { + for (case, settings, expected) in [ + ( + "a subject-only map on a client resolver", + json!({ + "claim_map": {"subject": {"id": "sub"}}, + "role": "client", + }), + vec!["client"], + ), + ( + "a malformed path", + json!({ + "claim_map": {"subject": {"id": "sub", "roles": "realm_access..roles"}} + }), + vec!["subject.roles", "realm_access..roles"], + ), + ] { + let err = JwtIdentityFactory + .create(&plugin_config(settings)) + .err() + .unwrap_or_else(|| panic!("{case} must not build")); + assert!( + matches!(*err, PluginError::Config { .. }), + "{case}: expected a config error, got {err:?}" + ); + let message = format!("{err}"); + for needle in expected { + assert!( + message.contains(needle), + "{case}: '{needle}' missing from: {message}" + ); + } + } +} + +// ===================================================================== +// What the map does not change +// ===================================================================== + +/// The map decides what is typed, not what is stashed. The raw token still +/// reaches forwarding plugins under the configured role, and the full claim set +/// still passes through, both untouched by any of this. +#[tokio::test] +async fn the_raw_token_and_the_full_claim_set_still_pass_through() { + let token = mint(json!({ + "sub": "alice", + "realm_access": {"roles": ["admin"]}, + "internal_debug": "noisy", + })); + + let identity = identity_from( + json!({ + "claim_map": { + "subject": {"id": "sub", "roles": "realm_access.roles"}, + "claims": {"exclude": ["internal_debug"]}, + } + }), + token.clone(), + ) + .await; + + let stash = identity + .raw_credentials + .as_ref() + .expect("the raw credentials slot is populated"); + let stashed = stash + .inbound_tokens + .get(&TokenRole::User) + .expect("the token is stashed under the configured role"); + assert_eq!(*stashed.token, token); + assert_eq!(stashed.source_header, "Authorization"); + assert!(matches!(stashed.kind, TokenKind::Jwt)); + + assert_eq!( + identity.raw_claims.get("internal_debug"), + Some(&json!("noisy")), + "a claim the map excluded from the policy bag still passes through raw_claims" + ); + assert_eq!( + identity.raw_claims.get("realm_access"), + Some(&json!({"roles": ["admin"]})), + ); +} diff --git a/docs/brainstorms/2026-08-20-configurable-claim-mapping-requirements.md b/docs/brainstorms/2026-08-20-configurable-claim-mapping-requirements.md new file mode 100644 index 0000000..dd06b80 --- /dev/null +++ b/docs/brainstorms/2026-08-20-configurable-claim-mapping-requirements.md @@ -0,0 +1,149 @@ +--- +date: 2026-08-20 +topic: configurable-claim-mapping +--- + +# Configurable claim mapping for identity plugins + +## Summary + +Operators get a declarative claim map in the JWT identity plugin's config — ordered candidate paths per typed field, dotted traversal into nested claims, and backslash escaping for literal dotted claim names — covering the subject, client, and workload roles. The OIDC standard shape becomes a shipped preset that runs through that same engine, pinned to today's behavior by an equivalence test. + +Addresses [praxis-proxy/policy#27](https://github.com/praxis-proxy/policy/issues/27). + +--- + +## Problem Frame + +A deployment whose IdP puts roles anywhere other than a top-level `roles` array cannot express that from configuration. The mapping from claims to typed identity fields is hardcoded to the OIDC standard shape, so an operator running Keycloak — where realm roles live under a nested object, and per-client roles live under another — has to write Rust and inject a custom mapper at resolver construction. That is a source change and a rebuild, in a component an operator is otherwise expected to configure. + +The config hook exists but leads nowhere: the mapper field accepts a name, and the only name accepted is the standard one. Everything else is rejected at construction. The doc comment anticipates named presets, but named presets cannot close the gap on their own — Keycloak's nested role location is fixed and presettable, while a namespaced Auth0 claim is per deployment, so no shipped preset can cover it. + +The cost lands in two places. Deployments that need it fork the plugin or lose IdP coverage. Deployments that work around it push role logic into policy expressions against the raw claims bag, which means every policy author has to know the IdP's claim layout instead of gating on `subject.roles` — and a claim name containing a colon, as Cognito mints, is not addressable in the policy language at all. + +Claim values keep their JSON shape as of [#9](https://github.com/praxis-proxy/policy/pull/9), so nested claim structure now survives into the identity extensions. Before that there was nothing for a path to point at. + +--- + +## Actors + +- A1. Deployment operator: writes the plugin config in unified-config YAML, wires the IdP, and owns the claim map. Cannot write or build Rust. +- A2. Policy author: writes rules that gate on typed identity fields and on the claims bag. Affected by what the map consumes and what stays visible. +- A3. Plugin integrator: builds a host that embeds the plugin, and may inject a custom Rust mapper for identity flows this config surface does not cover. + +--- + +## Requirements + +**Path syntax and resolution** + +- R1. A field path addresses a value in the validated claim set by dot-separated segments, traversing nested objects. No new inputs: the map consumes only the claims the mapper already receives. +- R2. A backslash escapes a dot, making the escaped dot part of a single literal segment, so a namespaced claim name containing dots is addressable. A doubled backslash is a literal backslash. +- R3. A colon is not a separator and needs no escaping, so a colon-prefixed claim name is addressable as written. +- R4. A malformed path fails at construction, naming the field and the offending path — covering a trailing escape, an unrecognized escape, an empty segment, and an empty path. + +**Per-field mapping** + +- R5. Each mappable field accepts either a shorthand single path or an expanded form carrying an ordered list of candidate paths. +- R6. A candidate list resolves first-match by default. A field may instead declare that every resolving candidate contributes to the result, so a collection can be assembled from more than one source. +- R7. By default an array value contributes its elements and a string value contributes as one element. A field may declare that a delimited string is split into multiple elements. +- R8. A resolved value whose JSON shape cannot satisfy the field is ignored rather than rejected, preserving how an unusable audience shape behaves today. + +**Role coverage** + +- R9. A map covers the subject, client, and workload roles. A resolver instance uses the section matching its configured role. +- R10. A map that declares no section for the resolver's configured role fails at construction, rather than denying every request at runtime. +- R11. Workload mapping enforces the SPIFFE prefix on every candidate source and derives the trust domain from the identity URI when the trust domain is not explicitly mapped. Neither is configurable. +- R12. The required anchor for each role — subject identifier, client identifier, workload identity — continues to deny at runtime when no candidate resolves, under today's denial code. + +**Presets and compatibility** + +- R13. An absent mapper setting and the standard mapper name produce the same identity output as today for the same token, across all three roles. +- R14. The standard shape is expressed as a preset and is what the default resolves to. The Rust standard mapper remains part of the crate's public API. +- R15. An equivalence check compares the standard preset against the Rust standard mapper over a token corpus spanning all three roles and every fallback the Rust mapper implements. Divergence fails the gate. +- R16. Presets ship for Keycloak, Auth0, and Cognito, written in the same declarative surface an operator writes, and readable as configuration. +- R17. An unrecognized preset name fails at construction and lists the valid names, matching how an unknown mapper name fails today. +- R18. The custom Rust mapper trait remains available and its public shape is unchanged, so an integrator with an identity flow this surface does not cover keeps the code path. + +**Diagnostics** + +- R19. A field where no candidate resolved is distinguishable at runtime from a field whose path resolved to an empty collection. Both name the field; the former names every path tried. +- R20. A field may opt into denying the request when no candidate resolves, so a mistyped path fails loudly instead of minting an under-privileged identity. The default stays permissive. + +**Claims bag** + +- R21. A top-level claim consumed by a single-segment path is excluded from the claims bag. A nested path leaves its parent claim intact and visible to policy. Registered JWT claims are always excluded. +- R22. A map may override the inferred set, both to exclude an additional claim and to re-include one that would otherwise be dropped. + +--- + +## Acceptance Examples + +- AE1. **Covers R1, R5, R6.** Given a Keycloak token carrying realm roles in a nested object and per-client roles in another, when the subject role field lists both paths and declares that every candidate contributes, the subject's roles are the union of both sources. +- AE2. **Covers R2.** Given an Auth0 token carrying a namespaced roles claim whose name contains dots, when that name is written as one segment with its dots escaped, the roles resolve from that claim and are not treated as a traversal. +- AE3. **Covers R3.** Given a Cognito token carrying a colon-prefixed groups claim, when that name is written verbatim as a path, the teams resolve from it. +- AE4. **Covers R7.** Given a token whose permissions arrive as a single space-separated string, when the field declares splitting, each entry becomes its own permission; without that declaration the whole string is one entry. +- AE5. **Covers R13, R15.** Given any token in the equivalence corpus, when it is mapped through the standard preset and through the Rust standard mapper, both produce identical typed fields and identical claims bags. +- AE6. **Covers R19, R20.** Given a map with a mistyped role path, when a token is mapped, the diagnostic names the field and the path tried and is distinct from the diagnostic a genuinely empty role array produces; when that field opted into strict handling, the request is denied instead. +- AE7. **Covers R21.** Given a Keycloak map that reads roles from a nested path, when a token is mapped, the parent claim still appears whole in the claims bag, so a policy reading through it keeps working. +- AE8. **Covers R11.** Given a token whose subject is not SPIFFE-shaped but which carries a SPIFFE-shaped claim elsewhere, when mapped for the workload role, no workload identity is produced from the non-SPIFFE subject and the guard cannot be configured off. +- AE9. **Covers R10.** Given a resolver configured for the client role and a map declaring only a subject section, construction fails and names the missing role. +- AE10. **Covers R4.** Given a path ending in a lone escape character, construction fails and names both the field and the path. + +--- + +## Success Criteria + +- An operator running Keycloak, Auth0, or Cognito wires roles, permissions, and teams from configuration alone, with no Rust and no rebuild — including the nested and namespaced shapes that motivated the work. +- A deployment that upgrades without touching its config sees identity output identical to what it sees today, and the equivalence check is what proves it rather than review judgment. +- A policy author gating on typed identity fields no longer needs to know the IdP's claim layout, and a colon- or dot-containing claim name is reachable through the map even where the policy language cannot address it directly. +- A mistyped path is diagnosable from what the plugin emits, without reading source. +- Planning does not need to invent the config surface: path syntax, escaping rule, per-field shape, role coverage, preset behavior, and claims-bag rule are all decided here. + +--- + +## Scope Boundaries + +- Array indexing and wildcard segments in paths. +- Value transforms — casing, prefixing to disambiguate roles drawn from several sources, filtering, regex extraction. +- Mapping the client trust level or the workload attestation timestamp. +- Layering a preset with per-field overrides. An operator picks a preset or writes a map. +- A per-field expression language. +- Validating a map against a sample token as a config lint or CLI check. +- Any change to how claims flatten into the policy attribute bag downstream. +- The header-projection and capability-gating discussion in the referenced upstream thread. This work is the claim-map half only. + +--- + +## Key Decisions + +- **Backslash escaping over quoting, segment arrays, or a literal-name sigil**: one separator and one escape, general enough to escape a single segment inside a longer path, and authorable as a plain scalar in YAML. Quoting collides with YAML's own quoting; a segment array collides with candidate lists; a whole-name sigil cannot express a dotted segment inside a path. +- **Union merge ships in v1, though the issue did not ask for it**: Keycloak splits roles across realm-wide and per-client scopes and operators commonly want both. First-match cannot express that, so without union the Keycloak case is addressable but not usable. +- **Ordered candidate lists are not optional**: the standard shape is built on fallbacks — client identifier to authorized party, permissions to scope, teams to groups, subject to explicit identity claim. A single path per field cannot express the standard mapper, which would make expressing presets as configuration impossible. +- **The standard preset is the runtime path, not a tested twin**: one runtime mapping path, presets readable and forkable as configuration, and the standard shape serves as the worked example for preset authors. The Rust mapper stays public for API compatibility and becomes the equivalence oracle. +- **SPIFFE guards are invariants, not knobs**: the prefix check on every candidate source exists so a non-SPIFFE subject cannot smuggle in an arbitrary identity claim. Exposing it as configuration would make the config path a security downgrade from the Rust mapper. +- **Claims-bag exclusion is inferred, with overrides**: inference reproduces today's reserved set exactly, because every claim the standard shape consumes is addressed by a single-segment path — while a nested role path leaves its parent visible, so existing policies reading through it keep working. Excluding consumed parents would break them silently. +- **Strict handling is opt-in per field, not the default**: a legitimately absent optional claim is routine, so denying on it by default would deny users who simply hold no teams. + +--- + +## Dependencies / Assumptions + +- [#9](https://github.com/praxis-proxy/policy/pull/9) is merged, so claim values keep their JSON shape into the identity extensions. The structure paths address exists because of it. +- Plugin configuration reaches the plugin as JSON regardless of the format an operator authors, so the escaping rule must be authorable in both YAML and JSON. Verified against the plugin's config type. +- The plugin crate has no path-resolution helper reachable today, and the one that exists elsewhere in the workspace is in a crate this plugin does not depend on and has neither escaping nor the semantics required here. Verified against the crate's dependencies and that helper's implementation. +- The plugin crate carries no YAML dependency today. Verified against its manifest. Preset files are consumed in a format the crate can already parse; which format is a planning decision. +- Equivalence between the standard preset and the Rust mapper rests on corpus coverage, not proof. The corpus is a deliverable of this work, not test scaffolding. +- Repo convention: requirement identifiers from this document must not appear in commit messages, code comments, rustdoc, changelog entries, or pull-request descriptions. Describe the behavior instead. + +--- + +## Outstanding Questions + +### Deferred to Planning + +- [Affects R7][Technical] Split vocabulary — whitespace only, or an arbitrary delimiter. Whitespace covers every OAuth-style shape known to be needed; an arbitrary delimiter costs little but widens the surface. +- [Affects R6][Technical] Whether union deduplicates, and how ordering is made deterministic for the identity fields that preserve insertion order rather than holding a set. +- [Affects R15, R16][Needs research] Sourcing realistic token shapes for the corpus and the three presets, so fixtures reflect what these IdPs actually mint rather than what documentation summarizes. +- [Affects R14, R16][Technical] How preset definitions are embedded in the crate and validated as part of the gate, so a broken preset cannot ship. +- [Affects R19][Technical] Diagnostic levels and whether a per-field miss is rate-limited, given this runs on every request. diff --git a/docs/plans/2026-08-20-001-feat-configurable-claim-mapping-plan.md b/docs/plans/2026-08-20-001-feat-configurable-claim-mapping-plan.md new file mode 100644 index 0000000..7da7496 --- /dev/null +++ b/docs/plans/2026-08-20-001-feat-configurable-claim-mapping-plan.md @@ -0,0 +1,1130 @@ +--- +title: "feat: Configurable claim mapping for the JWT identity plugin" +type: feat +status: active +date: 2026-08-20 +origin: docs/brainstorms/2026-08-20-configurable-claim-mapping-requirements.md +--- + +# feat: Configurable claim mapping for the JWT identity plugin + +## Summary + +Add a declarative claim map to `builtins/plugins/identity-jwt`: a path resolver with +backslash escaping, per-field ordered candidate lists with first-match or union merge, +and role sections for subject / client / workload. The OIDC standard shape moves out of +Rust and into an embedded JSON preset that drives the same engine, with the existing +`StandardClaimMap` retained as the equivalence oracle for a corpus-backed parity gate. +The corpus and the parity gate are written **first**, against the current Rust mapper, +so parity is measured from a baseline rather than asserted at the end. + +--- + +## Implementation Guidelines + +These apply to every unit below. They govern what ships in the repository, not what this +document says about it. + +**1. No requirement or plan identifiers in durable text.** Nothing that ships may cite +`R7`, `U3`, `AE5`, or any other identifier from this plan or the origin document. That +covers rustdoc, code comments, commit messages, the CHANGELOG entry, test names, and the +pull-request description. These documents do not ship with the code and an identifier is +meaningless to a reader a year out. Describe the behavior or the constraint instead: + +``` +no // Enforces R11: SPIFFE prefix on every candidate. +yes // Prefix-check every candidate: a non-SPIFFE `sub` must not smuggle in an + // arbitrary `spiffe_id` claim. +``` + +This is `CONTRIBUTING.md`'s rule, not a preference for this plan. + +**2. Keep comments and rustdoc short.** One or two sentences per item is the target. State +what a reader needs in order to change the code safely, then stop. + +- No em dashes. Use a comma, a colon, or a second sentence. +- No restating the signature in prose. `fn parse(s: &str) -> Result` + does not need "Parses a string into a `ClaimPath`, returning an error on failure." +- No history, no progress notes, no internal milestone names. `CONTRIBUTING.md` has the + full list and worked examples. +- Rationale earns its place when the code looks wrong without it. The `array_only` flag and + the no-dedup choice are the two places in this work where a short "why" is worth writing. +- `missing_docs` and `missing_errors_doc` are denied workspace-wide, so every public item + needs a doc line. Meeting the lint is not a reason to pad it. + +The existing files in this crate run long on comments in places. Match the concise end of +what is there, not the verbose end. + +**3. Commits.** Sign off every commit: `git commit -s`. No AI attribution trailers of any +kind. Keep the subject short and in the imperative, following the conventional-commit style +already in `git log`. A body only when the reason is not obvious from the diff, wrapped and +brief. + +--- + +## Problem Frame + +The mapping from validated JWT claims to typed identity fields is fixed in +`builtins/plugins/identity-jwt/src/claim_map.rs` (`StandardClaimMap`). The config hook +exists but leads nowhere: `resolver.rs` accepts `claim_mapper: "standard"` and rejects +every other name. An operator whose IdP nests roles (Keycloak `realm_access.roles`), +namespaces them behind a dotted URL (Auth0), or prefixes them with a colon (Cognito) +must write Rust and rebuild. See origin for the full framing and cost. + +--- + +## Requirements + +- R1. A field path addresses a value in the validated claim set by dot-separated segments, traversing nested objects, consuming only the claims the mapper already receives. +- R2. A backslash escapes a dot into a literal segment character; a doubled backslash is a literal backslash. +- R3. A colon is not a separator and needs no escaping. +- R4. A malformed path fails at construction, naming the field and the offending path — trailing escape, unrecognized escape, empty segment, empty path. +- R5. Each mappable field accepts a shorthand single path or an expanded form with an ordered candidate list. +- R6. Candidates resolve first-match by default; a field may declare that every resolving candidate contributes. +- R7. By default an array contributes its elements and a string contributes as one element; a field may declare that a delimited string is split. +- R8. A resolved value whose JSON shape cannot satisfy the field is ignored, not rejected. +- R9. A map covers subject, client, and workload; a resolver uses the section matching its configured role. +- R10. A map declaring no section for the resolver's role fails at construction. +- R11. Workload mapping enforces the SPIFFE prefix on every candidate and derives the trust domain from the identity URI when unmapped. Neither is configurable. +- R12. The required anchor per role continues to deny at runtime under today's denial code when no candidate resolves. +- R13. An absent mapper setting and the `standard` name produce identical identity output to today, across all three roles. +- R14. The standard shape is a preset and is what the default resolves to; the Rust standard mapper stays public. +- R15. An equivalence check compares the standard preset against the Rust standard mapper over a corpus spanning all three roles and every fallback the Rust mapper implements. Divergence fails the gate. +- R16. Presets ship for Keycloak, Auth0, and Cognito, written in the same surface an operator writes. +- R17. An unrecognized preset name fails at construction and lists the valid names. +- R18. The custom Rust mapper trait remains available with its public shape unchanged. +- R19. A field where no candidate resolved is distinguishable at runtime from a field that resolved to an empty collection. Both name the field; the former names every path tried. +- R20. A field may opt into denying when no candidate resolves. The default stays permissive. +- R21. A top-level claim consumed by a single-segment path is excluded from the claims bag; a nested path leaves its parent intact. Registered JWT claims are always excluded. +- R22. A map may override the inferred exclusion set, both to add and to re-include. + +**Origin actors:** A1 (deployment operator), A2 (policy author), A3 (plugin integrator) + +**Origin acceptance examples:** AE1 (R1, R5, R6), AE2 (R2), AE3 (R3), AE4 (R7), AE5 (R13, R15), AE6 (R19, R20), AE7 (R21), AE8 (R11), AE9 (R10), AE10 (R4) + +--- + +## Success Criteria + +Carried from origin, with one qualification: + +- An operator running Keycloak, Auth0, or Cognito wires roles, permissions, and teams from + configuration alone, with no Rust and no rebuild — including the nested and namespaced + shapes that motivated the work. **Qualification, sharpened by the provider research:** + two of the three motivating role shapes are per-deployment by construction and no preset + can carry them. Auth0's roles must live under the deployment's own URL namespace, because + Auth0 forbids a bare `roles` claim outright. Keycloak's per-client + `resource_access..roles` embeds the operator's client id. Both are reachable by + a hand-written map, which is the capability this work adds; neither is reachable by a + preset. Each preset's `description` names what it omits, so the gap is visible rather than + inferred from an empty roles set. +- A deployment that upgrades without touching its config sees identity output identical to + today, and U7's gate is what proves it rather than review judgment. +- A policy author gating on typed identity fields no longer needs to know the IdP's claim + layout, and a colon- or dot-containing claim name is reachable through the map even where + the policy language cannot address it directly. +- A mistyped path is diagnosable from what the plugin emits, without reading source. + +--- + +## Scope Boundaries + +Carried from origin — none of these are built here: + +- Array indexing and wildcard path segments. +- Value transforms: casing, prefixing, filtering, regex extraction. +- Mapping `ClientExtension.trust_level` or `WorkloadIdentity.attested_at`. +- Layering a preset with per-field overrides. An operator picks a preset or writes a map. +- A per-field expression language. +- Validating a map against a sample token as a config lint or CLI check. +- Any change to how claims flatten into the policy attribute bag downstream + (`crates/ppe-apl-cmf/src/security.rs`, `payload::walk`). +- The header-projection and capability-gating half of the upstream thread. + +### Deferred to Follow-Up Work + +- **A public constructor that injects a custom `ClaimMapper`.** The trait is public and + documented as injectable, but `JwtIdentityResolver::new` is the only constructor and + it always builds the mapper itself. Adding an injection point is a separate API change + (R18 only requires the trait's shape stay unchanged, which it does). +- **Deduplicating union results in `Vec`-typed client fields.** See Key Technical + Decisions; the parity-preserving choice is no dedup, and dedup can be added later + behind a field-level declaration if operators find duplicates noisy. +- **A Keycloak preset covering `resource_access..roles`.** The path embeds the + operator's client id, so no shipped preset can fill it. The Keycloak preset covers + realm roles; per-client roles need a hand-written map, which is exactly what this work + makes possible. Documented in the preset's own description rather than silently absent. +- **Auth0 roles and teams in the shipped preset.** Auth0's restricted-claim list forbids + `roles`, `groups`, `permissions`, and `entitlements` as bare custom-claim names, so roles + can only arrive as a URL-namespaced claim under the deployment's own namespace, which no + preset can know. Same shape of gap as Keycloak per-client roles, same answer: a + hand-written map, which is the capability this work adds. +- **`IdentityPayload.raw_claims` is write-only.** The resolver populates it and the merge + carries it, but no consumer reads it and it reaches no decision point. Either wire it up + or remove it; either way it is pre-existing and not this work's to change. +- **A typed issuer surface.** `claims.include: [iss]` makes issuer gating possible, but the + issuer is a property of token validation rather than a subject attribute, so + `sec.subject.issuer` → `subject.issuer` would be the better home. Separate change, + separate crate. +- **Keycloak Authorization Services permissions (`authorization.permissions`).** An array of + objects whose `scopes` sit one level inside each element, so reaching them needs array + indexing and wildcard segments, both out of scope above. Revisit if path indexing is + ever added. + +--- + +## Context & Research + +### Relevant code and patterns + +| Path | Why it matters | +|---|---| +| `builtins/plugins/identity-jwt/src/claim_map.rs` | `ClaimMapper` trait + `StandardClaimMap`. Stays as-is (R14, R18) and becomes the equivalence oracle. | +| `builtins/plugins/identity-jwt/src/resolver.rs:199-212` | The `claim_mapper` name match — the one place that rejects every name but `standard`. | +| `builtins/plugins/identity-jwt/src/resolver.rs:530-560` | Per-role dispatch into `map_subject` / `map_client` / `map_workload`, and the three `auth.mapping_failed` denials. | +| `builtins/plugins/identity-jwt/src/config.rs` | `JwtIdentityResolverConfig` and the `DecodingKeySource` → `build()` config-to-runtime pattern this work should mirror. | +| `crates/ppe-core/src/extensions/security.rs:31-60, 189-240, 253-293` | Destination field types. **`SubjectExtension.roles/permissions/teams` are `HashSet`; the `ClientExtension` equivalents are `Vec`.** This asymmetry decides the union ordering and dedup question. | +| `crates/ppe-apl-core/src/route.rs:327-333` | `get_dotted` — the existing dotted-path helper. In a crate `identity-jwt` does not depend on, with no escaping and no candidate semantics. Confirms origin's assumption: not reusable. | +| `crates/ppe-core/tests/wire_compatibility.rs:26` | The workspace's only `include_str!` fixture precedent, for the preset and corpus embedding decision. | +| `builtins/pdps/cedar-direct/tests/fixtures/` | Precedent for checked-in test fixtures in a builtin's own `tests/fixtures/`. | + +### Provider claim shapes (researched 2026-08-20 against primary sources) + +Grounded in Keycloak 26.7.2 upstream source plus its server-admin guide, Auth0's docs +(the markdown editions listed in `llms.txt`), the AWS Cognito Developer Guide PDF (the HTML +renders client-side and returns empty to a fetch), the SPIFFE JWT-SVID standard, and SPIRE's +`credtemplate/builder.go`. The findings that change preset content: + +| Finding | Consequence | +|---|---| +| Keycloak `realm_access.roles` and `resource_access..roles` are **access-token only** (`idToken=false` on both mappers). | The Keycloak preset works on access tokens. A resolver pointed at an ID token gets empty roles, which the preset description must say. | +| Keycloak's `groups` claim (from the optional `microprofile-jwt` scope) **contains realm roles, not groups**. Real group paths need a hand-added `Group Membership` mapper whose claim name the admin types, so it has no default name. | The Keycloak preset must **not** map `groups` to `teams`. Doing so silently fills teams with roles. The highest-value trap in the research. | +| Auth0 maintains a restricted-claim list that silently drops non-namespaced custom claims. It includes `roles`, `groups`, `permissions`, and `entitlements`. Roles must arrive URL-namespaced, and the namespace is per deployment. | **No shipped Auth0 preset can carry a roles or teams path.** Auth0's preset covers `sub`, `azp`/`client_id`, `scope`, and the opt-in `permissions`. Roles are exactly the case a hand-written map exists for. | +| Auth0's `permissions` array is **doubly opt-in** (enable RBAC, then "Add Permissions in the Access Token"), and enabling it switches the token dialect. | Carried as a candidate ahead of `scope`, with the opt-in named in the description. | +| Auth0's default profile emits `azp`; the RFC 9068 profile emits `client_id`. Auth0 M2M `sub` is `@clients`. | `client_id` candidates are `client_id`, `azp`. **`sub` is deliberately not a candidate**: stripping `@clients` is a value transform, out of scope, and the suffix is conditionally absent anyway. | +| Cognito has **no `azp`, ever**; the access token carries `client_id`. Cognito access tokens have **no `aud` by default**, and an M2M token can never have one (resource binding is user-flows-only). | The Cognito preset reads `client_id` only and does not lean on `aud`. | +| Cognito's `cognito:roles` and `cognito:preferred_role` hold **IAM role ARNs**, not application roles. `cognito:groups` holds group names and appears in **both** tokens. | Cognito preset maps `cognito:groups` to `teams` and maps nothing to `roles`, rather than filling roles with ARNs. | +| **None of the three IdPs mint a `client_name`.** | The field stays mappable for an operator with a custom claim; no preset ships a candidate for it. | +| Keycloak's `authorization.permissions` is an **array of objects** (`rsid`, `rsname`, `scopes`), only in an Authorization Services RPT. The published doc example (`resource_set_id`) is stale versus the serializer. | Unreachable without array indexing and wildcard segments, both out of scope. Not in the preset; recorded as follow-up. | +| `scope` is a space-delimited string in all three IdPs, never an array. | The `split: whitespace` decision is unanimous across providers, not an OAuth-era relic. | +| `aud` is shape-polymorphic *within* one IdP: Keycloak serializes a bare string at one audience and an array at two or more; Auth0 is a string for pure M2M and an array once `openid` is requested; SPIRE and Kubernetes always emit an array. | Confirms the bare-`aud` candidate must accept both shapes on one path, which is what the default (no `array_only`) does. | +| Keycloak's lightweight-access-token policy strips everything but `exp, iat, jti, iss, typ, azp, sid, scope, cnf` unless each mapper opts in. | A preset assuming `realm_access` produces an empty identity there. Named in the preset description; also the case `on_missing: deny` exists for. | +| SPIFFE JWT-SVID: `sub` **MUST** hold the SPIFFE ID. `iss` is not part of the spec and deriving trust from it is explicitly NOT RECOMMENDED. SPIRE's `aud` is invariantly an array; SPIRE's newer WIT-SVID has no `aud` at all. | Confirms deriving `trust_domain` from the URI authority rather than from `iss`. | +| Kubernetes projected ServiceAccount tokens carry **`kubernetes.io`** as a top-level claim name containing a dot, whose value is a nested object, with `sub` of the form `system:serviceaccount::`. | The best real-world exercise of the escape rule: `kubernetes\.io.serviceaccount.name` needs an escaped dot *and then* traversal. Goes in the corpus and in U1's tests. | + +Real claim names worth having in tests because they prove only `.` and `\` are special: +`cognito:groups`, `custom:department`, `allowed-origins`, `trusted-certs`, `cnf.x5t#S256`, +`https://my-app.example.com/roles`, `https://namespace.exampleco.com` (the whole URL is the +key, with no path segment), and an Auth0 `sub` containing `|`. + +Deliberately **not** asserted anywhere, because the research could not verify them: what +Cognito puts in `sub` for a client-credentials token; whether Auth0 ever emits `permissions` +for a client-credentials grant; whether `cognito:groups` is omitted or emitted as `[]` for a +user in no groups. A preset or corpus entry that needed one of these would be a guess. + +### `subject.claims` is the only route from a claim to a policy + +Traced while resolving the claims-bag override question, and it changes that answer: + +- `resolver.rs:609` sets `IdentityPayload.raw_claims` to the full claim map, and + `payload.rs:274` merges it across resolvers, but **nothing reads it**. It reaches no PDP. +- The CMF namespace map (`crates/ppe-apl-cmf/src/security.rs:30-70`) has no issuer key, no + `jti`, no `exp`. `sec.subject.claims` → `claim.` and `sec.client.claims` → + `client.claim.` are the only claim-derived bag keys. + +So every registered JWT claim is currently unreachable from policy. That matters because +this plugin accepts `trusted_issuers` as a **list**: a deployment wanting "only tokens from +the internal IdP may call this tool" cannot express it today. `claims.include: [iss]` is +what closes that, which is why `include` accepts registered claims. + +Worth a follow-up, not fixed here: `raw_claims` is write-only. Either something should read +it or it should go, and the issuer arguably deserves a typed surface +(`sec.subject.issuer` → `subject.issuer`) rather than living in a subject-attribute bag. + +### Repo constraints that shape the work + +- `COVERAGE_FLOOR = 95` in the `Makefile`, enforced by `make coverage` in CI. Every new + branch needs a test or the gate drops. +- `[workspace.lints]` denies `unwrap_used`, `expect_used`, `panic`, `indexing_slicing`, + `missing_docs`, and `missing_errors_doc`. Path parsing and traversal must be written + without indexing or unwrap in non-test code, and every public item needs rustdoc. +- The crate has **no YAML dependency**; `serde_json` is already a direct dependency. +- `CONTRIBUTING.md`: durable text carries no planning identifiers. **No `R7` / `U3` in + commit messages, comments, rustdoc, changelog entries, or the PR description.** +- File headers: exactly the two SPDX/copyright lines, `#` for JSON-adjacent config + formats where comments are possible. JSON preset and corpus files cannot carry the + header — carry provenance as a data field instead (see U2, U5). +- No `docs/solutions/` in this repo, so there are no institutional learnings to carry. + +--- + +## Key Technical Decisions + +- **Presets are named through the existing `claim_mapper` field; an inline map is a new + `claim_map` field; setting both is a config error.** R17 asks the unknown-name failure + to match today's, which means the name lives where it lives today. A separate field for + the inline map avoids an untagged `String | Map` enum, whose serde error ("data did not + match any variant") is exactly the diagnostic R4 and R17 are trying to avoid. + +- **Presets are JSON files under `src/presets/`, embedded with `include_str!`, listed in + one table.** The crate can already parse JSON and cannot parse YAML; embedding removes + runtime file I/O so a preset cannot go missing at deploy. The registry is a + `&[(&str, &str)]` table so a single table-driven test covers every preset, and adding a + preset without covering it is not possible. *(resolves origin's deferred question on + preset embedding and gate validation)* + +- **Union preserves order and does not deduplicate; the destination type decides.** + `HashSet` fields dedup inherently. `Vec` fields keep candidate-declaration order, then + in-array order within each candidate — fully deterministic, and byte-identical to today + for the single-candidate standard case. Deduplicating `Vec` fields would change the + output for a token carrying a repeated element inside one claim array, which R15 would + score as divergence. Duplicates in `client.roles` are harmless to set-membership + predicates. *(resolves origin's deferred question on union dedup and ordering)* + +- **Splitting is field-level and whitespace-only, deserialized as an enum so a delimiter + is additive.** Field-level is sufficient even where only one candidate needs it: + splitting an array element that contains no whitespace is a no-op, so + `permissions: [...]` and `scope: "a b"` can share one `split: whitespace` declaration. + `split` parses from the string `whitespace`, which leaves room for a later + `split: {on: ","}` map form without invalidating any authored config. *(resolves + origin's deferred question on split vocabulary)* + +- **A candidate may declare `array_only: true`, and the standard preset uses it.** This is + the one knob origin did not name, and R13 forces it. Today `subject.roles` reads + `claims.get("roles").and_then(Value::as_array)`: a string-valued `roles` yields nothing + and, for `permissions` / `teams`, falls through to the next candidate. R7's default + ("a string contributes as one element") would instead accept it, diverging on exactly + the fallback chains R15 gates. `array_only` is a shape requirement, not a value + transform, so it stays inside origin's scope boundary. The alternative — accept the + divergence and exclude those shapes from the corpus — was rejected because it hides a + behavior change behind a gap in the oracle. + +- **A candidate whose value is present but unusable counts as not resolving, so the + fallback chain continues.** This is what today does (`and_then(Value::as_array)` returns + `None` and the `else if` runs) and it is what R8 means in a candidate list: ignore the + shape, keep looking. + +- **Claims-bag exclusion is computed from *declared* paths, not resolved ones.** Today's + `RESERVED` lists are static: `azp` is excluded whether or not the token carries it, and + `scope` is excluded even when `permissions` won. Inferring from declarations reproduces + that exactly. Verified by hand against both `RESERVED` arrays — the standard preset's + single-segment paths plus the registered JWT claims equal today's subject set + (`sub, roles, permissions, scope, teams, groups` + registered) and today's client set + (`client_id, azp, client_name, authorized_scopes, scope, aud, roles` + registered). + +- **A strict-field miss returns `None` and denies under `auth.mapping_failed`; the field + name reaches the operator through a log event, not the deny reason.** `ClaimMapper` + returns `Option`, and R18 fixes its public shape, so there is no channel for a richer + failure. R20 asks for a denial and R12 fixes the code; both are satisfied. The + resolver's three deny reasons are reworded to stop naming `sub` / `client_id` + specifically, since a configured map need not use those claims. Codes are unchanged and + are what the tests pin (`tests/jwt_e2e.rs:335,366`). + +- **Diagnostics are two distinct `debug!` events, emitted once per mapping call, with no + rate limiting.** A no-candidate-resolved event names the field and every path tried; a + resolved-but-empty event names the field only. Same level so one flag shows an operator + both; distinct message and distinct structured fields so they are distinguishable. Misses + are aggregated into one event per call rather than one per field, so a badly configured + map costs one event per request, not N. No rate limiter: `debug` is off in production, so + the hot-path cost is a level check, and a limiter would add state and suppress the very + miss an operator turned the level up to see. *(resolves origin's deferred question on + diagnostic levels and rate limiting)* + +- **A preset ships a candidate only where the provider actually mints the claim.** Where a + provider has no source for a field the preset declares nothing rather than guessing, and + its `description` names what it omits and why. Three consequences fall out of the + research: the Keycloak preset maps nothing to `teams`, because its `groups` claim holds + realm roles; the Auth0 preset maps nothing to `roles` or `teams`, because Auth0 forbids + those as bare claim names so they are per-deployment namespaced claims by construction; + the Cognito preset maps nothing to `roles`, because `cognito:roles` holds IAM ARNs. A + preset that quietly fills a field with the wrong concept is worse than one that leaves it + empty, because the operator has no reason to look. + +- **`client_id` candidates are `client_id`, `azp`, `clientId`, and never `sub`.** That covers + Cognito (`client_id` only, no `azp` ever), Auth0's RFC 9068 profile (`client_id`), Auth0's + default profile and Keycloak user flows (`azp`), and pre-2023 Keycloak's camelCase + `clientId`. `sub` is excluded on purpose: Auth0's M2M `sub` is `@clients` and + stripping that suffix is a value transform this work does not do, while Keycloak's `sub` is + a user UUID. Today's Rust mapper checks `client_id` then `azp`, so the `clientId` tail + appears only in the Keycloak preset. **`standard` keeps exactly two candidates**, since it + must stay byte-identical to the Rust mapper. + +- **Config types compile into runtime types, mirroring `DecodingKeySource::build()`.** + Serde structs hold authored strings; `compile()` parses every path once at construction + and returns the error R4 requires. Nothing parses a path on the request path. + +--- + +## High-Level Technical Design + +> *This illustrates the intended approach and is directional guidance for review, not +> implementation specification. The implementing agent should treat it as context, not +> code to reproduce.* + +### The config surface an operator writes + +```yaml +plugins: + - name: jwt-resolver + kind: identity/jwt + config: + trusted_issuers: [...] + role: user + + # Either a preset by name (existing field) ... + claim_mapper: keycloak + + # ... or an inline map (new field). Both set is a config error. + claim_map: + subject: + id: sub # shorthand: one path + roles: # expanded: candidates + options + paths: + - realm_access.roles + - resource_access.my-api.roles + merge: union # first_match (default) | union + permissions: + paths: + - { path: permissions, array_only: true } + - scope + split: whitespace + on_missing: deny # ignore (default) | deny + teams: ["https\\://my-app\\.example\\.com/teams", "groups"] + client: + client_id: [client_id, azp] + workload: + spiffe_id: [sub, spiffe_id] + claims: + exclude: [internal_debug] + include: [scope] +``` + +### Path grammar + +``` +path := segment ( '.' segment )* +segment := ( literal | escape )+ # never empty +escape := '\.' -> '.' + | '\\' -> '\' +literal := any char except '.' and '\' # ':' and '/' are literals +``` + +Rejected at construction, naming the field and the path: empty path, empty segment +(`a..b`, `.a`, `a.`), trailing lone `\`, unrecognized escape (`\x`). + +Note the YAML double-backslash: the escape is a single `\` in the JSON the plugin +receives, so a YAML double-quoted scalar needs `\\.` and a single-quoted or plain scalar +needs `\.`. Worth a rustdoc example in both quoting styles. + +### Per-field resolution + +```mermaid +flowchart TD + A[field: ordered candidates] --> B{next candidate?} + B -- no --> M{anything collected?} + B -- yes --> C[resolve path against claims] + C --> D{present?} + D -- no --> R[record path as tried] --> B + D -- yes --> E{shape usable for this field?} + E -- no --> R + E -- yes --> F[contribute: array elements, or string
as one element, split if declared] + F --> G{merge mode} + G -- first_match --> H[done] + G -- union --> B + M -- yes --> H + M -- no --> N{on_missing} + N -- ignore --> O[leave field empty
debug: field + paths tried] + N -- deny --> P[mapper returns None
resolver denies auth.mapping_failed] +``` + +### Shape handling matrix + +| Resolved JSON | default candidate | `array_only: true` | with `split: whitespace` | +|---|---|---|---| +| `["a","b"]` | elements `a`, `b` | elements `a`, `b` | elements, each split | +| `"a b"` | one element `a b` | unusable → next candidate | `a`, `b` | +| `"a"` | one element `a` | unusable → next candidate | `a` | +| `42` / `true` | unusable → next candidate | unusable → next candidate | unusable → next candidate | +| `{...}` | unusable → next candidate | unusable → next candidate | unusable → next candidate | +| `["a", 42, {...}]` | `a`; non-strings skipped | same | same | +| absent | not resolved → next candidate | same | same | + +Scalar destinations (`subject.id`, `client_id`, `client_name`, `spiffe_id`, +`trust_domain`) take the first candidate resolving to a string; `merge: union` on a scalar +field is a config error. + +### Workload invariants (not configurable) + +Every `spiffe_id` candidate is filtered by the `spiffe://` prefix *before* it counts as +resolving, so a non-SPIFFE `sub` is skipped and a later SPIFFE-shaped claim still wins — +matching `tests/jwt_e2e.rs:344-368`. `trust_domain` derives from the URI authority when +no candidate is declared for it. + +--- + +## Output Structure + + builtins/plugins/identity-jwt/ + src/ + claim_map.rs # unchanged: ClaimMapper trait + StandardClaimMap + claim_path.rs # NEW path parsing, escaping, traversal + claim_map_config.rs # NEW authored config types + compile() + configured_mapper.rs # NEW ClaimMapper impl driven by a compiled map + presets.rs # NEW registry table + lookup + unknown-name error + presets/ + standard.json # NEW today's OIDC shape, as configuration + keycloak.json # NEW + auth0.json # NEW + cognito.json # NEW + tests/ + fixtures/ + claim-corpus.json # NEW the equivalence corpus, a deliverable + standard_preset_equivalence.rs # NEW the parity gate + claim_map_e2e.rs # NEW operator-facing end-to-end + +--- + +## Implementation Units + +- U1. **Path parsing, escaping, and traversal** + +**Goal:** A `ClaimPath` that parses an authored string into segments with backslash +escaping, and resolves it against a `&HashMap` claim set. + +**Requirements:** R1, R2, R3, R4 + +**Dependencies:** None + +**Files:** +- Create: `builtins/plugins/identity-jwt/src/claim_path.rs` +- Modify: `builtins/plugins/identity-jwt/src/lib.rs` (declare the module, re-export) + +**Approach:** +- `ClaimPath::parse(&str) -> Result`; segments as `Vec` since + an escaped segment is not a borrow of the input. +- Single character-by-character pass. `:` and `/` are literals with no special handling. +- Resolution: first segment indexes the claim map, subsequent segments use + `Value::get`, which already returns `None` when the path crosses a non-object (R1). +- Errors return the offending path and the reason; the caller prepends the field name + (R4's "naming the field" is the caller's job because only it knows the field). +- `Display` renders the path back in authored form so a diagnostic can echo it. +- No indexing and no `unwrap` — the workspace denies both. + +**Patterns to follow:** `crates/ppe-apl-core/src/route.rs:327-333` for the traversal +shape; `builtins/plugins/identity-jwt/src/config.rs` `build()` methods for +`Result<_, String>` errors the caller wraps into `PluginError::Config`. + +**Test scenarios:** +- Happy path: `sub` resolves a top-level scalar; `realm_access.roles` resolves a nested array; a three-deep path resolves. +- Happy path: Covers AE3. `cognito:groups` parses as one segment and resolves verbatim. +- Happy path: Covers AE2. `https\://my-app\.example\.com/roles` parses to the single segment `https://my-app.example.com/roles` and resolves a claim of exactly that name. Use the verbatim name from Auth0's own docs, not an invented one. +- Happy path: `https\://namespace\.exampleco\.com` resolves a claim whose whole key is a URL with no path segment, which is a shape Auth0 documents. +- Happy path: `a\\b` parses to the single segment `a\b`. +- Happy path: `kubernetes\.io.serviceaccount.name` parses to three segments — `kubernetes.io`, `serviceaccount`, `name` — and resolves against a Kubernetes projected ServiceAccount token. An escaped dot followed by real traversal in one path, which is the rule's hardest case and a real claim shape rather than a constructed one. +- Happy path: literal characters that are not separators need no escaping and traverse normally: `cognito:groups`, `custom:department`, `allowed-origins`, `trusted-certs`, and `cnf.x5t#S256` (a `#` inside a traversed leaf segment). +- Edge case: a path whose first segment matches no claim resolves to `None`; a path crossing a scalar (`sub.x` where `sub` is a string) resolves to `None`; a path into an array (`roles.0`) resolves to `None`, since indexing is out of scope. +- Edge case: a claim whose value is `null` resolves to `Some(Value::Null)`, distinct from absent. +- Error path: Covers AE10. `roles\` (trailing lone escape) is rejected and the message contains the path. +- Error path: `roles\x` (unrecognized escape) is rejected and names the offending escape. +- Error path: `""`, `"a..b"`, `".a"`, `"a."` are each rejected as an empty path or empty segment. +- Error path: `Display` round-trips every accepted path so an escaped path echoes back as authored, not as its resolved text. + +**Verification:** Every grammar case in the design section has a test; `make lint` passes +with no new allow attributes. + +--- + +- U2. **The equivalence corpus and a characterization baseline for the Rust mapper** + +**Goal:** Land the token corpus as a reviewable data artifact, with expected typed output +per entry, and a test proving the *current* `StandardClaimMap` produces exactly that. +This is the baseline the preset is later measured against. + +**Requirements:** R15 (the corpus half), R13 + +**Dependencies:** None — deliberately before the engine exists. + +**Execution note:** Characterization-first. Write the corpus and assert today's Rust +mapper against it *before* any of U3–U6 exists. A corpus written after the preset tends +to encode the preset's behavior rather than the mapper's. + +**Files:** +- Create: `builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json` +- Create: `builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs` + +**Approach:** +- Corpus is a JSON array of entries: `{ name, role, provenance, claims, expected }`. + `provenance` is a data field, not a comment — JSON has no comments, and the SPDX header + convention cannot apply to a `.json` fixture. It records where the shape came from + (which IdP doc, or "constructed to exercise the `azp` fallback"). +- `expected` mirrors the typed extension for that role, so an entry is readable as + "this token in, that identity out" without running anything. +- Embedded with `include_str!`, following `crates/ppe-core/tests/wire_compatibility.rs:26`. +- Coverage obligation from R15: **every fallback the Rust mapper implements** needs an + entry that exercises it, and an entry that exercises the *other* branch: + `client_id`/`azp`; `authorized_scopes`/`scope`; `permissions`/`scope`; `teams`/`groups`; + workload `sub`/`spiffe_id`; `aud` as string and as array. +- Deliberately included, because they are where the preset is most likely to diverge: + a string-valued `roles`, a string-valued `teams`, a non-string element inside a role + array, a non-string `aud`, an empty array, and a claim whose value is `null`. +- Realistic entries drawn from the researched provider shapes, each with its source in + `provenance`: a Keycloak access token with `realm_access.roles`, + `resource_access..roles`, `azp`, a space-delimited `scope`, and the + hyphenated `allowed-origins`; a Keycloak service-account token carrying `client_id`, + `clientHost`, `clientAddress`, and `preferred_username: service-account-`; an + Auth0 default-profile M2M token with `sub: @clients`, bare-string `aud`, `azp`, + and `gty: client-credentials`; an Auth0 user token with a namespaced + `https://my-app.example.com/roles`; a Cognito ID token with `cognito:groups`, + `cognito:username`, `cognito:roles` holding ARNs, and `identities` as an array of objects; + a Cognito access token with `client_id`, a dotted-and-slashed `scope` + (`resourceserver.1/appclient2`), and **no `aud`**; a SPIRE JWT-SVID with a SPIFFE `sub`, + an array `aud`, and no `iss`; and a Kubernetes projected ServiceAccount token carrying + `kubernetes.io` as a dotted top-level claim over a nested object. +- Shape coverage the research says the corpus must not miss: `aud` as a bare string **and** + as an array on otherwise-identical tokens (Keycloak flips between them by audience count), + and a token with no `aud` at all (Cognito's default access token). +- This unit's test asserts corpus `expected` against `StandardClaimMap`. U7 adds the + preset side to the same file. + +**Patterns to follow:** `crates/ppe-core/tests/wire_compatibility.rs` for the +`include_str!` fixture test; `builtins/plugins/identity-jwt/src/claim_map.rs` tests for +the claim-construction helper shape. + +**Test scenarios:** +- Happy path: every corpus entry maps through the Rust mapper for its declared role and equals `expected`, field by field — id/anchor, roles, permissions, teams, scopes, audiences, and the full claims bag. +- Edge case: the corpus is non-empty and covers all three roles; the test fails if any role has zero entries, so a later edit cannot quietly drop a role. +- Edge case: every fallback pair listed above has at least one entry per branch; assert this structurally (by entry name) rather than trusting review. +- Edge case: `aud` present as a string, present as an array, and absent entirely each produce the audience list today's mapper produces. +- Error path: an entry whose `claims` lack the role's anchor declares `expected: null` and the mapper returns `None`. +- Integration: the corpus file parses and every entry deserializes — a malformed corpus fails the suite rather than silently skipping entries. + +**Verification:** `cargo test -p praxis-policy-plugin-identity-jwt` passes against the +unmodified mapper, and deliberately perturbing one `expected` value fails the test. + +--- + +- U3. **Authored claim-map config types and compilation** + +**Goal:** The serde types an operator writes, and a `compile()` that parses every path and +validates the map, returning the construction-time errors R4 and R10 require. + +**Requirements:** R4, R5, R6, R7, R10, R22 + +**Dependencies:** U1 + +**Files:** +- Create: `builtins/plugins/identity-jwt/src/claim_map_config.rs` +- Modify: `builtins/plugins/identity-jwt/src/lib.rs` + +**Approach:** +- `ClaimMapConfig { subject: Option, client: Option, workload: Option, claims: Option }`. +- `RoleMap` is a map of field name → `FieldMap`, with `deny_unknown_fields` so a misspelled + field name fails at construction instead of silently mapping nothing. Field names per + role are fixed: subject `{id, roles, permissions, teams}`; client + `{client_id, client_name, authorized_scopes, authorized_audiences, roles, permissions, teams}`; + workload `{spiffe_id, trust_domain, client_id, selectors}`. `client.permissions` and + `client.teams` are included even though the Rust mapper never fills them — they exist on + the extension and no path can reach them today. +- `FieldMap` has a **hand-written `Deserialize`** that dispatches on the JSON value kind: + string → one candidate; array → ordered candidates; object → the expanded form with + `paths` required. An untagged enum would collapse all three into "data did not match any + variant", which defeats R4's naming requirement. +- A candidate is a bare path string or `{ path, array_only }`. +- Expanded `FieldMap`: `paths`, `merge: first_match|union`, `split: whitespace`, + `on_missing: ignore|deny`. `split` deserializes from the bare string `whitespace` via an + enum, leaving room for a map form later. +- `compile()` returns `Result`: parses every `ClaimPath`, + prefixing the field name onto U1's error; rejects `merge: union` on a scalar-destination + field; rejects an empty `paths` list. +- `CompiledClaimMap::role(&TokenRole)` returns the section or the R10 error naming the + missing role. +- `ClaimsOverrides { exclude: Vec, include: Vec }` — plain claim names, + not paths, since the bag is keyed by top-level claim name. + +**Patterns to follow:** `builtins/plugins/identity-jwt/src/config.rs` — serde config type +plus `validate()`/`build()` returning `Result<_, String>`; `DecodingKeySource`'s +`#[serde(tag = "kind", rename_all = "snake_case")]` for the vocabulary style. + +**Test scenarios:** +- Happy path: Covers AE1. All three field forms deserialize — `roles: sub`, `roles: [a, b]`, `roles: {paths: [a, b], merge: union}` — and compile to the same candidate order the author wrote. +- Happy path: a candidate written as `{path: permissions, array_only: true}` compiles with the flag set; a bare string compiles with it unset. +- Happy path: `split: whitespace`, `merge: union`, `on_missing: deny` each round-trip; omitted options take the documented defaults (`first_match`, no split, `ignore`). +- Happy path: `claims: {exclude: [...], include: [...]}` compiles, and an absent `claims` block compiles to no overrides. +- Edge case: a role section present but empty compiles (it declares the role, which is what R10 checks) — and the anchor still denies at runtime. +- Error path: Covers AE10. A malformed path inside `subject.roles` fails compilation and the message names both `subject.roles` and the offending path. +- Error path: Covers AE9. `CompiledClaimMap::role(Client)` on a subject-only map errors and the message names `client`. +- Error path: an unknown field name in a role section is rejected and names the field and the role. +- Error path: `merge: union` on `subject.id` is rejected as meaningless for a scalar. +- Error path: `paths: []` is rejected; `roles: {}` (no `paths`) is rejected naming `paths`. +- Error path: a `FieldMap` given a number or boolean is rejected with a message naming the field, not a serde variant dump. + +**Verification:** Every construction-time rejection has a message naming the field, and +no rejection path produces serde's untagged-enum error text. + +--- + +- U4. **The configured mapper: resolution engine, invariants, claims bag, diagnostics** + +**Goal:** A `ConfiguredClaimMap` implementing `ClaimMapper` from a `CompiledClaimMap` — +candidate resolution, shape handling, merge, split, SPIFFE invariants, claims-bag +inference with overrides, and the two diagnostic events. + +**Requirements:** R6, R7, R8, R9, R11, R12, R19, R20, R21, R22 + +**Dependencies:** U1, U3 + +**Files:** +- Create: `builtins/plugins/identity-jwt/src/configured_mapper.rs` +- Modify: `builtins/plugins/identity-jwt/src/lib.rs` + +**Approach:** +- One resolution routine shared by all three roles: given a field's compiled candidates, + produce an ordered `Vec` plus the set of paths tried. Collection destinations + absorb the vec (`HashSet` dedups; `Vec` keeps order and duplicates); scalar destinations + take the first string. +- Shape handling follows the design section's matrix. A present-but-unusable value counts + as not resolving so the chain continues (R8). +- Workload: the `spiffe://` prefix filter is applied inside candidate resolution for + `spiffe_id`, so a non-SPIFFE candidate is skipped rather than accepted; `trust_domain` + derives from the URI authority when the section declares no path for it (R11). +- Anchors (`subject.id`, `client.client_id`, `workload.spiffe_id`) returning nothing → + `None` from `map_*`, which the resolver turns into `auth.mapping_failed` (R12). +- `on_missing: deny` on a non-anchor field also returns `None`, with a `warn!` naming the + field, because the trait has no richer channel (R18). +- Claims bag: exclusion = registered JWT claims (`iss, aud, exp, nbf, iat, jti, sub`) ∪ + the first segment of every **single-segment** declared candidate path in this role's + section, then `+ overrides.exclude`, then `- overrides.include` (R21, R22). Multi-segment + paths contribute nothing, which is what leaves a nested parent visible (AE7). + `include` accepts any claim name, registered ones included; a name appearing in both + `exclude` and `include` is a construction error rather than a silent precedence rule. + `WorkloadIdentity` has no claims field, so this applies to subject and client only. +- Diagnostics: aggregate all missed fields into one `debug!` per mapping call carrying + field names and the paths tried; a separate `debug!` for fields that resolved to an + empty collection, carrying field names only (R19). +- `attestor` stays hardcoded `"jwt"` and `attested_at` stays `None` — both out of scope. + +**Patterns to follow:** `builtins/plugins/identity-jwt/src/claim_map.rs` `impl ClaimMapper +for StandardClaimMap` for the per-role method shape and the reserved-claim loop it +replaces. + +**Test scenarios:** +- Happy path: Covers AE1. A Keycloak-shaped token with `realm_access.roles` and `resource_access.my-api.roles`, mapped with `merge: union`, yields the union of both; the same map with `first_match` yields only the first resolving source. +- Happy path: Covers AE4. `permissions` declared with `split: whitespace` turns `scope: "read write delete"` into three entries; without the declaration the same claim yields one entry `"read write delete"`. +- Happy path: Covers AE2, AE3. An escaped dotted namespaced claim and a `cognito:groups` claim each populate their field end-to-end through the mapper. +- Happy path: union order is candidate-declaration order then in-array order, asserted on a `Vec`-typed client field where order is observable; a value appearing in two candidates appears twice, which is the documented no-dedup decision. +- Edge case: Covers AE7. A nested role path leaves its parent claim whole in the claims bag; a single-segment path excludes its claim. +- Edge case: R21's inference reproduces today's reserved set — assert the claims bag for the standard preset's declarations equals the two `RESERVED` arrays, for both subject and client. +- Edge case: `claims.exclude` drops an otherwise-visible claim, and `claims.include` restores one the inference dropped. +- Edge case: `claims.include: [iss]` puts a registered JWT claim back in the bag — the case that makes issuer gating expressible. Assert it for `iss`, `jti`, and `exp`, since the rule is "any claim" with no allowlist. +- Error path: a claim named in both `exclude` and `include` fails at construction and the message names the claim. +- Edge case: a field resolving to `[]` yields an empty collection and the resolved-but-empty diagnostic, not the miss diagnostic. +- Edge case: a `Vec` destination and a `HashSet` destination given the same duplicate-bearing input differ as documented — set dedups, vec does not. +- Error path: Covers AE6. A mistyped path leaves the field empty, emits the miss diagnostic naming the field and every path tried, and is distinguishable from the empty-collection event; the same map with `on_missing: deny` returns `None` instead. +- Error path: R8 — a numeric `aud`, an object where a string list is expected, and a non-string element inside a role array are each ignored rather than failing the map. +- Error path: Covers AE8. A non-SPIFFE `sub` with a bogus `spiffe_id` produces no workload identity; a non-SPIFFE `sub` with a *valid* `spiffe://` in a later candidate still resolves, and the prefix filter has no config surface that can disable it. +- Error path: a missing anchor returns `None` for each of the three roles. +- Integration: `trust_domain` is derived from the SPIFFE URI when unmapped and taken from the declared path when mapped. + +**Verification:** Behavior matches the shape matrix and the resolution flowchart for every +cell and branch; diagnostics are asserted by capturing `tracing` events, not by eyeballing +output. + +--- + +- U5. **Preset registry and the four embedded presets** + +**Goal:** `standard`, `keycloak`, `auth0`, and `cognito` presets as embedded JSON, a +registry table, and a lookup whose unknown-name error lists the valid names. + +**Requirements:** R14, R16, R17 + +**Dependencies:** U3, U4 + +**Files:** +- Create: `builtins/plugins/identity-jwt/src/presets.rs` +- Create: `builtins/plugins/identity-jwt/src/presets/standard.json` +- Create: `builtins/plugins/identity-jwt/src/presets/keycloak.json` +- Create: `builtins/plugins/identity-jwt/src/presets/auth0.json` +- Create: `builtins/plugins/identity-jwt/src/presets/cognito.json` +- Modify: `builtins/plugins/identity-jwt/src/lib.rs` + +**Approach:** +- `PRESETS: &[(&str, &str)]` with `include_str!`, sorted by name so the error text is + deterministic. `lookup(name)` returns the parsed-and-compiled map or an error listing + every valid name, matching the existing text shape at `resolver.rs:203-208`. +- The `standard` preset must reproduce the Rust mapper exactly. Per the array-only + decision: `subject.roles`, `subject.teams`' both candidates, `client.roles`, and the + first candidate of `subject.permissions` / `client.authorized_scopes` carry + `array_only: true`; `scope` candidates carry `split: whitespace`; + `client.authorized_audiences` reads a bare `aud` so both the string and array shapes are + accepted. +- Provider presets follow the researched shapes in Context & Research, and each carries a + `description` naming what it covers, what it omits, and which claims are opt-in at the + IdP. Per the honesty decision above, a field with no genuine provider source gets no + candidate. + + | Preset | subject.roles | subject.permissions | subject.teams | client anchor | Description must say | + |---|---|---|---|---|---| + | `keycloak` | `realm_access.roles` | `scope` (split) | **nothing** | `client_id`, `azp`, `clientId` | Access tokens only (`realm_access` is `idToken=false`); per-client roles need a hand-written map; `groups` is *not* mapped because it holds realm roles; a lightweight-access-token policy strips `realm_access` entirely. | + | `auth0` | **nothing** | `permissions` (array-only), then `scope` (split) | **nothing** | `client_id`, `azp` | Roles and teams are per-deployment namespaced claims and cannot be presettable; `permissions` requires both RBAC and "Add Permissions in the Access Token"; `sub` is not a client-id candidate because of the `@clients` suffix. | + | `cognito` | **nothing** | `scope` (split) | `cognito:groups` | `client_id` only | No `azp` exists; access tokens have no `aud` unless resource binding was requested and never for M2M; `cognito:roles` holds IAM ARNs and is deliberately not mapped to roles. | + +- A preset declares a role section wherever the provider has a real shape for that role. + None of the three has a workload shape, so none declares a workload section — an operator + wiring `role: workload` uses `standard` or a hand-written map, and the R10 construction + error is the correct outcome rather than a section full of guesses. +- `standard` is the only preset with a workload section, and it keeps today's two candidates + (`sub` then `spiffe_id`, both prefix-filtered). +- No SPDX header on the `.json` files (JSON has no comment syntax); provenance and + description live in data fields. + +**Patterns to follow:** `crates/ppe-core/tests/wire_compatibility.rs:26` for `include_str!`; +`resolver.rs:199-212` for the unknown-name error text. + +**Test scenarios:** +- Happy path: table-driven — every entry in `PRESETS` parses, compiles, and declares at least one role section. Adding a preset without a test is impossible because the test iterates the table. +- Happy path: each preset's declared paths all parse (implied by compile, asserted explicitly so a failure names the preset). +- Happy path: the `standard` preset compiles to the candidate order the Rust mapper checks in, asserted per field. +- Happy path: the Keycloak preset resolves realm roles from a Keycloak access token and its client anchor from `azp`; Auth0's resolves `permissions` and its anchor from `azp`; Cognito's resolves `cognito:groups` into teams and its anchor from `client_id`. +- Edge case: the honesty rule holds — the Keycloak preset leaves `teams` empty for a token carrying `groups`; the Auth0 preset leaves `roles` empty for a token carrying a namespaced roles claim; the Cognito preset leaves `roles` empty for a token carrying `cognito:roles`. Each is asserted, because an accidental candidate added later would otherwise pass silently. +- Edge case: no provider preset declares a workload section, so `role: workload` against one fails at construction naming the role. +- Edge case: `PRESETS` names are unique and sorted, so the R17 error text is stable. +- Error path: `lookup("made-up")` errors and the message lists every valid name. +- Error path: a preset with a deliberately broken path fails the table test rather than shipping (verified by temporarily perturbing one, not by a permanent fixture). + +**Verification:** `make test` fails if any shipped preset is malformed; the unknown-name +message lists all four names. + +--- + +- U6. **Resolver and plugin-config wiring** + +**Goal:** Route `claim_mapper` through the preset registry, accept the new `claim_map` +field, reject setting both, enforce the role-section requirement at construction, and keep +the default identical to today. + +**Requirements:** R9, R10, R12, R13, R17 + +**Dependencies:** U3, U4, U5 + +**Files:** +- Modify: `builtins/plugins/identity-jwt/src/config.rs` (add `claim_map`, document the + preset names on `claim_mapper`) +- Modify: `builtins/plugins/identity-jwt/src/resolver.rs` (replace the name match at + 199-212; reword the three deny reasons at 530-560) +- Modify: `builtins/plugins/identity-jwt/src/lib.rs` (re-exports) + +**Approach:** +- Resolution order at construction: both fields set → config error naming both; `claim_map` + set → compile it; `claim_mapper` set → preset lookup; neither → the `standard` preset. +- After building the map, require the section matching `typed.role` (R10) so a + misconfigured pairing fails at load rather than denying every request. +- The resolver keeps holding `Arc`; `ConfiguredClaimMap` is just another + implementor, so `resolver.rs`'s dispatch at 530-560 is untouched apart from wording. +- Reword the three `auth.mapping_failed` reasons to stop naming `sub` / `client_id` / + `spiffe://` as though they were fixed, and to point at the debug diagnostics. Codes are + unchanged — `tests/jwt_e2e.rs:335,366` pin those. +- `StandardClaimMap` stays exported and untouched (R14, R18); after this unit nothing in + the crate constructs it outside tests, which is the intended end state. + +**Patterns to follow:** the existing `PluginError::Config` message convention in +`resolver.rs::new` — every message leads with `plugin '{name}' +(praxis-policy-plugin-identity-jwt)`. + +**Test scenarios:** +- Happy path: R13 — a config with no `claim_mapper` and no `claim_map` builds, and `claim_mapper: "standard"` builds; both produce the same identity for the same token, for all three roles. +- Happy path: `claim_mapper: "keycloak"` builds where it previously failed. +- Happy path: an inline `claim_map` builds and is used in preference to nothing else being set. +- Edge case: `role: client` with a client-declaring preset builds; the same preset with `role: workload` fails if it declares no workload section, and the message names the role. +- Error path: Covers AE9. `claim_map` declaring only `subject` with `role: client` fails at construction naming `client`. +- Error path: Covers AE10. A malformed path in an inline `claim_map` fails at construction naming the field and path. +- Error path: `claim_mapper` and `claim_map` both set fails, and the message names both fields and says to pick one. +- Error path: R17 — `claim_mapper: "made-up-mapper"` fails and lists the valid names; the existing test at `resolver.rs:848` is updated to the new list rather than deleted. +- Error path: an unparseable `claim_map` (wrong JSON shape) fails at construction, not at first request. +- Integration: the factory (`factory.rs`) propagates each of the above as `PluginError::Config`, so a bad map is a startup failure and not a resolver that denies everything. + +**Verification:** Existing `tests/jwt_e2e.rs` and `tests/jwks_url_e2e.rs` pass unchanged +except for any deliberate message rewording; a deployment config that sets neither field +behaves as before. + +--- + +- U7. **The equivalence gate** + +**Goal:** Close R15 — the standard preset and the Rust standard mapper produce identical +typed fields and identical claims bags across the whole corpus, and divergence fails CI. + +**Requirements:** R13, R15 + +**Dependencies:** U2, U5, U6 + +**Files:** +- Modify: `builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs` +- Modify: `builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json` (only if the + gate reveals a fallback the corpus does not reach) + +**Approach:** +- For each corpus entry, map through both `StandardClaimMap` and the compiled `standard` + preset for the entry's role, and assert equality field by field — including the full + claims bag, not just the typed collections. A whole-struct comparison plus per-field + assertions on failure, so a diff names the field rather than dumping two structs. +- Assert `None`-vs-`Some` agreement too: an entry the Rust mapper declines must be declined + by the preset. +- Set membership alone is not enough for the `Vec`-typed client fields — compare order as + well, since the no-dedup / declaration-order decision is only observable there. +- If the gate finds divergence, the resolution is to change the **preset**, never the Rust + mapper (R14, R18) and never the corpus, unless the corpus entry is itself wrong about + what the IdP mints. + +**Test scenarios:** +- Happy path: Covers AE5. Every corpus entry produces identical output from both paths, across all three roles. +- Happy path: every fallback branch the corpus enumerates is asserted through both paths, so a fallback the preset expresses differently cannot pass on the strength of the other branch. +- Edge case: the claims bag comparison is exhaustive — same key set and same `Value` per key, so a claim the preset fails to exclude fails the gate. +- Edge case: `Vec`-typed client fields are compared for order, not just membership. +- Error path: deliberately perturbing one preset candidate (locally, not committed) fails the gate — confirms the gate has teeth rather than passing vacuously. +- Error path: a corpus entry the Rust mapper declines is also declined by the preset. + +**Verification:** `make test` fails on any divergence; the gate runs in the standard CI +test job with no extra wiring. + +--- + +- U8. **Operator-facing end-to-end coverage and documentation** + +**Goal:** Prove the acceptance examples through the real resolver with real tokens, and +document the surface for the operator who has to author it. + +**Requirements:** R16, R19, R20, plus end-to-end confirmation of AE1–AE4 and AE6–AE10 + +**Dependencies:** U6 + +**Files:** +- Create: `builtins/plugins/identity-jwt/tests/claim_map_e2e.rs` +- Modify: `builtins/plugins/identity-jwt/src/claim_map_config.rs` (rustdoc: the authored + surface, with both YAML quoting styles for the escape) +- Modify: `builtins/plugins/identity-jwt/src/lib.rs` (crate-level docs mention the map) +- Modify: `builtins/plugins/identity-jwt/src/factory.rs` (the header comment shows the + operator YAML; extend it with a `claim_map` example) +- Modify: `CHANGELOG.md` (an `### Added` entry under `[Unreleased]`) +- Modify: `README.md` (only if it advertises the JWT plugin's config surface) + +**Approach:** +- Reuse the existing e2e harness: `mint_jwt`, `resolver_plugin_config_for`, `invoke_with` + from `tests/jwt_e2e.rs`. Copy or lift the helpers rather than making `jwt_e2e.rs` a + module of the new file — integration test binaries do not share code, and the existing + file's helpers are private to it. +- Each e2e test wires a plugin config containing a `claim_map`, mints a matching token, and + asserts the resulting `SubjectExtension` / `ClientExtension` / `WorkloadIdentity`. +- Rustdoc carries the escaping trap explicitly: the plugin receives JSON, so a YAML + double-quoted scalar needs `\\.` and a plain or single-quoted scalar needs `\.`. Both + forms shown, since getting this wrong is the most likely operator error. +- CHANGELOG entry describes the behavior in operator terms and **cites no requirement + identifiers**, per `CONTRIBUTING.md`. + +**Patterns to follow:** `builtins/plugins/identity-jwt/tests/jwt_e2e.rs` for the whole e2e +shape; the `[Unreleased] / ### Added` bullet style in `CHANGELOG.md` — one bold lead +sentence, then the consequence, then the PR link. + +**Test scenarios:** +- Happy path: Covers AE1. A Keycloak token through a union map yields the union of realm and per-client roles in `subject.roles`. +- Happy path: Covers AE2. An Auth0 token carrying `https://my-app.example.com/roles` (Auth0's own documented claim name), mapped with the escaped path, populates `subject.roles`; the same path unescaped resolves nothing, proving the escape is what does the work. +- Happy path: Covers AE3. A Cognito token populates `subject.teams` from `cognito:groups`. +- Happy path: Covers AE4. A space-separated permissions string splits when declared and stays whole when not. +- Happy path: `claim_mapper: keycloak` (preset by name) works end-to-end, not only as a unit-level lookup. +- Happy path: a map declaring `claims: {include: [iss]}` produces a subject whose claims bag carries `iss`, so a multi-issuer deployment can gate on which IdP minted the token. This is a new capability, and the CHANGELOG entry should say so. +- Edge case: Covers AE7. A policy-visible claims bag still contains the whole `realm_access` object after a nested path consumed `realm_access.roles`. +- Error path: Covers AE6. A mistyped role path yields a permitted request with empty roles; the same map with `on_missing: deny` yields `auth.mapping_failed`. +- Error path: Covers AE8. The workload role rejects a non-SPIFFE subject even when a SPIFFE-shaped claim sits elsewhere and the map points at both, and accepts when a valid SPIFFE candidate resolves. +- Error path: Covers AE9, AE10. A role-mismatched map and a malformed path each fail at plugin construction through the factory, before any request. +- Integration: the raw token still lands in `RawCredentialsExtension.inbound_tokens` under the configured role, and `raw_claims` still carries the full claim set — the map changes what is typed, not what is stashed. + +**Verification:** Every acceptance example in the origin document has an end-to-end test; +`make lint`, `make test`, and `make coverage` pass at the existing floor. + +--- + +## Unit Dependency Graph + +```mermaid +flowchart LR + U1[U1 path parsing] --> U3[U3 config types] + U3 --> U4[U4 mapper engine] + U4 --> U5[U5 presets] + U3 --> U5 + U5 --> U6[U6 resolver wiring] + U4 --> U6 + U2[U2 corpus + baseline] --> U7[U7 equivalence gate] + U5 --> U7 + U6 --> U7 + U6 --> U8[U8 e2e + docs] +``` + +U1 and U2 are independent and can land in either order or in parallel. U2 before U4 is the +point of the sequencing: the baseline exists before anything can be tuned to fit it. + +--- + +## System-Wide Impact + +- **Interaction graph:** `JwtIdentityFactory::create` → `JwtIdentityResolver::new` is the + only construction path, so every new failure mode surfaces as a startup + `PluginError::Config`. `crates/ppe/src/lib.rs:110,162` re-exports the factory behind the + `jwt` feature; no facade change is needed because no new public type crosses that + boundary. +- **Error propagation:** construction errors are `PluginError::Config`; runtime denials stay + `auth.mapping_failed` with today's code (R12). Nothing new can panic on the request path — + paths are parsed once at construction. +- **State lifecycle risks:** the compiled map is immutable and shared behind the existing + `Arc`. No new interior mutability, no new background task, nothing added + to the `Drop` path. +- **API surface parity:** `ClaimMapper` and `StandardClaimMap` keep their exact public shape + (R14, R18). `JwtIdentityResolverConfig` gains one optional field, so every existing config + still deserializes. `claim_mapper`'s accepted value set widens, which is additive. +- **Integration coverage:** the claims-bag rule is only observable downstream, through + `crates/ppe-apl-cmf/src/security.rs` flattening `subject.claims` into `claim.*`. AE7's + guarantee — a policy reading `claim.realm_access.roles` keeps working — is asserted at the + extension boundary in U4/U8; the flattening itself is out of scope and unchanged. +- **Unchanged invariants:** the SPIFFE prefix check has no config surface; `attestor` stays + `"jwt"`; `TokenKind::SpiffeJwt` selection, the `inbound_tokens` stash, and `raw_claims` + pass-through are untouched; the three denial codes are unchanged. + +--- + +## Risks & Dependencies + +| Risk | Mitigation | +|---|---| +| The preset diverges from the Rust mapper on a shape nobody thought to test, and R13's compatibility promise is quietly false. | U2 lands the corpus and the baseline *before* the engine exists, the corpus deliberately includes the shapes most likely to diverge (string-valued collection claims, non-string elements, empty arrays, `null`), and U7 asserts the corpus structurally covers both branches of every fallback. | +| `array_only` is a knob origin did not name, so a reviewer reads it as scope creep. | Recorded as a Key Technical Decision with the concrete divergence it prevents and the rejected alternative. It is a shape requirement, not a value transform. | +| Union on `Vec`-typed client fields produces visible duplicates and operators read it as a bug. | Documented in rustdoc and the CHANGELOG as the deliberate no-dedup behavior; dedup is listed as follow-up work rather than smuggled in against R15. | +| Operators get the YAML escaping wrong (`\.` vs `\\.` depending on quoting style) and see silent misses. | Both forms in rustdoc; the miss diagnostic names every path tried; `on_missing: deny` exists precisely so a mistyped path can be made loud. | +| Presets encode claim shapes an IdP does not actually mint, or that require an opt-in protocol mapper the operator has not enabled. | Presets follow the researched shapes in Context & Research; each carries a `description` naming what it covers, what it omits, and which claims are opt-in; the corpus records provenance per entry; U5 asserts the deliberate omissions so a later "helpful" addition cannot slip in silently. | +| A preset fills a field with the wrong concept and the operator has no reason to look — Keycloak `groups` into teams (it holds realm roles), Cognito `cognito:roles` into roles (it holds IAM ARNs). | Both are named as omissions in the preset table and pinned by a test asserting the field stays empty. This is the failure mode the research was commissioned to find, and it is the one a reviewer cannot catch by reading the preset. | +| The new code is branch-dense (path parsing, shape matrix, merge modes) and drags line coverage below the 95 floor. | Test scenarios are enumerated per unit to cover each branch, and `make coverage` runs before the PR per `CONTRIBUTING.md`. | +| Requirement identifiers leak from this document into commits, comments, or the PR body. | `CONTRIBUTING.md` forbids it; called out in Documentation Notes so it is checked at PR time. | + +--- + +## Open Questions + +### Resolved during planning + +- **Split vocabulary (origin: affects R7):** whitespace-only, deserialized from the bare + string `whitespace` via an enum, so a later `split: {on: ","}` map form is additive. + Field-level rather than per-candidate, which is sufficient because splitting a + whitespace-free array element is a no-op. +- **Union dedup and ordering (origin: affects R6):** no dedup at the engine level; the + destination type decides. Order is candidate-declaration order, then in-array order. + Driven by `SubjectExtension` using `HashSet` and `ClientExtension` using `Vec`. +- **Preset embedding and gate validation (origin: affects R14, R16):** JSON under + `src/presets/`, `include_str!`, one `&[(&str, &str)]` registry table, one table-driven + test. The crate has no YAML dependency and already has `serde_json`. +- **Diagnostic level and rate limiting (origin: affects R19):** two distinct `debug!` + events, aggregated to one per mapping call, no rate limiting. +- **Corpus and preset sourcing (origin: affects R15, R16):** grounded in provider + documentation, with provenance recorded per corpus entry and a `description` per preset. + No live IdP access required, and no fixture claims to be a capture of real traffic. +- **Whether `client.permissions` and `client.teams` earn their place in the surface:** yes, + as mappable fields; no, as preset content. The research confirms none of the three + providers mints a source for either (Auth0 and Cognito have no client-role concept at all, + and Keycloak routes service-account roles through `realm_access`). They stay mappable so an + operator with a custom claim can reach fields that are otherwise unreachable, and no preset + declares a candidate for them. +- **How a strict-field miss denies without changing the trait:** return `None`, deny under + `auth.mapping_failed`, name the field in a log event. R18 forbids widening the trait's + public shape. +- **Whether the claims-bag exclusion is inferred from declared or resolved paths:** + declared. Verified by hand against both `RESERVED` arrays that this reproduces today's + behavior exactly. + +- **Whether `claims.include` can re-include a registered JWT claim:** yes, any claim. + `include` overrides the inference for any name the operator lists, registered or not. + R21's "always" describes the inference; R22 is the override for the inference. The + deciding fact is that `subject.claims` is the *only* route from a JWT claim to a policy + (see Context & Research), so reading R21 as binding over R22 would leave `iss` + permanently unreachable with no alternative in this release. The footgun — a policy + re-checking `exp` with different leeway than the engine used — is accepted, because it + already exists for any `claim.` an IdP happens to mint. +- **Precedence when a claim appears in both `exclude` and `include`:** construction error + naming the claim. There is no coherent intent to honour, and silently picking a winner + would hide a config mistake. +- **Config vocabulary:** `paths`, `merge` (`first_match` | `union`), `split` + (`whitespace`), `on_missing` (`ignore` | `deny`), and `array_only` on a candidate. `paths` + names the field's content; `merge` is the ordinary word for combining candidate results + and has room for further modes; `on_missing` reads as a policy for an event and could + later gain `warn`; `array_only` states a constraint as a fact. Settled here rather than at + authoring time because the surface is cheap to choose now and expensive to change once + operators have written configs against it. +- **Whether the reworded deny reasons need test updates:** no. Verified across the whole + workspace — the only assertions are on the code (`tests/jwt_e2e.rs:335,366`), and the + `claim_map.rs` doc comments reference `auth.mapping_failed` by code too. Nothing asserts + on reason text. +- **Corpus file layout:** one file. Roughly 20 to 25 entries at ~15 lines each lands near + 350 lines, which reviews fine, and one file keeps both the all-three-roles check and the + `include_str!` trivial. Splitting later is a mechanical refactor with no design content, + so it does not need to be a plan decision. + +### Deferred to implementation + +Nothing. Every question this plan opened is answered above; what remains genuinely unknown +is recorded as follow-up work in Scope Boundaries rather than as an open question here. + +--- + +## Documentation / Operational Notes + +- `CHANGELOG.md` gets one `### Added` entry under `[Unreleased]`, in the established style: + a bold lead sentence, the operator-visible consequence, and the PR link. It must state + that an existing config is unaffected, since that is the question an upgrading operator + has. Two things are worth their own mention: which presets ship and what each deliberately + omits, and that `claims.include` can surface a registered claim such as `iss`, which makes + gating on the issuing IdP expressible for the first time. +- **No requirement or plan identifiers in the commit messages, code comments, rustdoc, + CHANGELOG entry, or PR description** — `CONTRIBUTING.md`. Describe the behavior. See + Implementation Guidelines for this and the comment-length and commit rules. +- Each source file carries exactly the two-line SPDX header. The `.json` preset and corpus + files cannot (JSON has no comments); provenance and description live in data fields. +- No new dependency, no new feature flag, no migration, and no rollout gate. The default + path is byte-identical to today, which is what U7 proves. +- `make lint && make test && make coverage` before the PR; `make coverage` is the one that + catches the branch-dense new code. + +--- + +## Sources & References + +- **Origin document:** [docs/brainstorms/2026-08-20-configurable-claim-mapping-requirements.md](docs/brainstorms/2026-08-20-configurable-claim-mapping-requirements.md) +- Upstream issue: [praxis-proxy/policy#27](https://github.com/praxis-proxy/policy/issues/27) +- Prerequisite, merged: [preserve JSON shape in subject claims (#9)](https://github.com/praxis-proxy/policy/pull/9) — `d0f0536` +- Code: `builtins/plugins/identity-jwt/src/claim_map.rs`, `src/config.rs`, `src/resolver.rs`, `src/factory.rs` +- Destination types: `crates/ppe-core/src/extensions/security.rs` +- Non-reusable prior helper: `crates/ppe-apl-core/src/route.rs` (`get_dotted`) +- Downstream flattening, unchanged: `crates/ppe-apl-cmf/src/security.rs` +- Repo conventions: `CONTRIBUTING.md`, `Cargo.toml` (`[workspace.lints]`), `Makefile` (`COVERAGE_FLOOR`) + +### Provider primary sources (consulted 2026-08-20) + +Keycloak 26.7.2: +- `services/src/main/java/org/keycloak/protocol/oidc/OIDCLoginProtocolFactory.java` — the built-in client scopes and which are default vs optional, and the `realm_access.roles` / `resource_access.${client_id}.roles` mapper definitions with `idToken=false` +- `services/src/main/java/org/keycloak/services/managers/ClientManager.java` — `addServiceAccountProtocolMappersViaScope` +- `core/src/main/java/org/keycloak/json/StringOrArraySerializer.java` — why `aud` flips between string and array +- — client scopes, protocol mappers, audience support, lightweight access tokens +- — the 26.1.0 `service_account` client scope + +Auth0: +- — the Auth0 vs RFC 9068 dialects, and which carries `azp` vs `client_id` +- — the restricted-claim list that forbids a bare `roles` claim, and the namespacing rules +- — the two toggles `permissions` needs +- — the M2M access-token sample with `sub: @clients` +- — the canonical namespaced-roles recipe + +AWS Cognito: +- — used because the HTML guide renders client-side and returns an empty document to a fetch +- and `...-the-access-token.html` — the default payloads +- — resource binding, and why an M2M token can never carry `aud` + +SPIFFE and Kubernetes: +- — `sub` MUST hold the SPIFFE ID; `iss` is not part of the spec +- `spiffe/spire`, `pkg/server/credtemplate/builder.go` — what SPIRE actually mints, including the invariant array `aud` +- — why `iss`-based trust-domain derivation is NOT RECOMMENDED +- — the projected-token shape carrying `kubernetes.io` From c2f6b0c7d8f6caac9450bf45e8f4369cdd9e6430 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 19:29:01 -0400 Subject: [PATCH 09/27] docs: record configurable claim mapping in the changelog Signed-off-by: Frederico Araujo --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c96f6e..7529308 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,11 +17,11 @@ 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` now 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 Rust and a rebuild. A field takes an ordered candidate list resolved first-match or unioned, can require an array, and can split a delimited string. Paths address nested claims by dots, with `\.` escaping a dot so a claim whose whole name is a URL is reachable; a colon needs no escaping. **An existing config is unaffected:** naming no mapper resolves to `standard`, which is held to the previous Rust mapper over a corpus of provider token shapes, so an upgrading deployment sees the identity it saw before. Setting both fields, naming an unknown preset, writing a malformed path, or pairing a map with a role it does not declare each fail at startup naming the fault, rather than denying every request. A field whose paths all miss is logged at debug with every path it tried, or refuses the token if it declared `on_missing: deny`. ([#28](https://github.com/praxis-proxy/policy/pull/28)) +- **Claim mapping is configuration.** The JWT identity plugin's `claim_mapper` now 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 Rust and a rebuild. A field takes an ordered candidate list resolved first-match or unioned, can require an array, and can split a delimited string. Paths address nested claims by dots, with `\.` escaping a dot so a claim whose whole name is a URL is reachable; a colon needs no escaping. **An existing config is unaffected:** naming no mapper resolves to `standard`, which is held to the previous Rust mapper over a corpus of provider token shapes, so an upgrading deployment sees the identity it saw before. Setting both fields, naming an unknown preset, writing a malformed path, or pairing a map with a role it does not declare each fail at startup naming the fault, rather than denying every request. A field whose paths all miss is logged at debug with every path it tried, or refuses the token if it declared `on_missing: deny`. (PR_LINK_PLACEHOLDER) -- **A policy can gate on which `IdP` minted a token.** A claim map's `claims.include` puts a claim back in the policy-visible bag, registered JWT claims included, so `claims: {include: [iss]}` makes `claim.iss` readable. A deployment trusting several issuers could not express "only tokens from the internal `IdP` may call this tool" before, because the subject claims bag is the only route from a claim to a policy and registered claims were always dropped. `claims.exclude` drops one the other way. ([#28](https://github.com/praxis-proxy/policy/pull/28)) +- **A policy can gate on which `IdP` minted a token.** A claim map's `claims.include` puts a claim back in the policy-visible bag, registered JWT claims included, so `claims: {include: [iss]}` makes `claim.iss` readable. A deployment trusting several issuers could not express "only tokens from the internal `IdP` may call this tool" before, because the subject claims bag is the only route from a claim to a policy and registered claims were always dropped. `claims.exclude` drops one the other way. (PR_LINK_PLACEHOLDER) -- **Each shipped preset records what it deliberately omits.** Two of the three providers put their roles claim somewhere no preset can name: Auth0 forbids a bare `roles` claim so roles arrive under a deployment's own URL namespace, and Keycloak's per-client roles sit under the client id an operator chose. Both need a hand-written `claim_map`. Presets also leave a field empty rather than filling it with the wrong concept — Keycloak's `groups` claim holds realm roles, and Cognito's `cognito:roles` holds IAM role ARNs — because a field quietly filled with the wrong thing gives an operator no reason to look. Each preset's description names its gaps and which of its claims are opt-in at the provider. ([#28](https://github.com/praxis-proxy/policy/pull/28)) +- **Each shipped preset records what it deliberately omits.** Two of the three providers put their roles claim somewhere no preset can name: Auth0 forbids a bare `roles` claim so roles arrive under a deployment's own URL namespace, and Keycloak's per-client roles sit under the client id an operator chose. Both need a hand-written `claim_map`. Presets also leave a field empty rather than filling it with the wrong concept, and Keycloak's `groups` claim holds realm roles where Cognito's `cognito:roles` holds IAM role ARNs, because a field quietly filled with the wrong thing gives an operator no reason to look. Each preset's description names its gaps and which of its claims are opt-in at the provider. (PR_LINK_PLACEHOLDER) - **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.` booleans. The flattened boolean keys are unchanged. ([#7](https://github.com/praxis-proxy/policy/pull/7)) From bbd6dbc3af3729862aeee0b1ed95e62e0b9bc0f6 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 20:42:46 -0400 Subject: [PATCH 10/27] fix(identity-jwt)!: close two standard-preset parity breaks The preset diverged from the Rust standard mapper on two token shapes the corpus never reached, both a claim carrying the wrong JSON type as the second candidate of a chain. An array-valued `scope` granted its elements as permissions where the mapper, reading `scope` through a string accessor, grants none. That affected `subject.permissions` and `client.authorized_scopes`, so a token could gain permissions it did not have before. The grammar had `array_only` with no inverse, so no preset could express the accessor: `string_only` is that mirror, and the two together are rejected because nothing would satisfy them. A non-string `client_id` fell through to `azp` where the mapper declines, because the mapper picks the first anchor key that *exists* and only then requires a string of it. `stop_if_present` expresses that chain, and the universal present-but-unusable-keeps-looking rule stays the default, which is what the collection chains and the SPIFFE chain need. Also: a mapped workload trust domain is now required to equal the SPIFFE ID authority rather than replacing it unchecked, and falls back to derivation when its path resolves nothing. A section declaring no path for its anchor warns at load and names the anchor in its miss event, instead of denying every token while the log the denial points at says nothing. A shape sweep now enumerates every JSON shape each mapped claim can hold, and every pair across each fallback, 1040 cases. It fails on either parity break; the 46-entry corpus did not. Signed-off-by: Frederico Araujo --- CHANGELOG.md | 2 +- .../identity-jwt/src/claim_map_config.rs | 130 ++++++++- .../identity-jwt/src/configured_mapper.rs | 265 ++++++++++++++++-- .../identity-jwt/src/presets/auth0.json | 26 +- .../identity-jwt/src/presets/cognito.json | 31 +- .../identity-jwt/src/presets/keycloak.json | 33 ++- .../identity-jwt/src/presets/standard.json | 61 +++- builtins/plugins/identity-jwt/src/resolver.rs | 20 +- .../tests/fixtures/claim-corpus.json | 54 ++++ .../tests/standard_preset_equivalence.rs | 196 ++++++++++++- 10 files changed, 748 insertions(+), 70 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7529308..280fac3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ 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` now 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 Rust and a rebuild. A field takes an ordered candidate list resolved first-match or unioned, can require an array, and can split a delimited string. Paths address nested claims by dots, with `\.` escaping a dot so a claim whose whole name is a URL is reachable; a colon needs no escaping. **An existing config is unaffected:** naming no mapper resolves to `standard`, which is held to the previous Rust mapper over a corpus of provider token shapes, so an upgrading deployment sees the identity it saw before. Setting both fields, naming an unknown preset, writing a malformed path, or pairing a map with a role it does not declare each fail at startup naming the fault, rather than denying every request. A field whose paths all miss is logged at debug with every path it tried, or refuses the token if it declared `on_missing: deny`. (PR_LINK_PLACEHOLDER) +- **Claim mapping is configuration.** The JWT identity plugin's `claim_mapper` now 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 Rust and a rebuild. A field takes an ordered candidate list resolved first-match or unioned, can require an array or a string, can stop the chain on the first claim that exists, and can split a delimited string. Paths address nested claims by dots, with `\.` escaping a dot so a claim whose whole name is a URL is reachable; a colon needs no escaping. **An existing config is unaffected:** naming no mapper resolves to `standard`, which is held to the previous Rust mapper both over a corpus of provider token shapes and over a sweep of every JSON shape each mapped claim can hold, so an upgrading deployment sees the identity it saw before. Setting both fields, naming an unknown preset, writing a malformed path, or pairing a map with a role it does not declare each fail at startup naming the fault, rather than denying every request. A field whose paths all miss is logged at debug with every path it tried, or refuses the token if it declared `on_missing: deny`. (PR_LINK_PLACEHOLDER) - **A policy can gate on which `IdP` minted a token.** A claim map's `claims.include` puts a claim back in the policy-visible bag, registered JWT claims included, so `claims: {include: [iss]}` makes `claim.iss` readable. A deployment trusting several issuers could not express "only tokens from the internal `IdP` may call this tool" before, because the subject claims bag is the only route from a claim to a policy and registered claims were always dropped. `claims.exclude` drops one the other way. (PR_LINK_PLACEHOLDER) diff --git a/builtins/plugins/identity-jwt/src/claim_map_config.rs b/builtins/plugins/identity-jwt/src/claim_map_config.rs index b9e30dd..3fb6c41 100644 --- a/builtins/plugins/identity-jwt/src/claim_map_config.rs +++ b/builtins/plugins/identity-jwt/src/claim_map_config.rs @@ -83,13 +83,26 @@ pub enum OnMissing { Deny, } -/// One authored candidate: a path, plus whether only an array satisfies it. +/// One authored candidate: a path, plus the rules for what satisfies it. #[derive(Debug, Clone, PartialEq, Eq)] pub struct Candidate { /// The path as authored. pub path: String, /// Require an array. A string is then unusable and the chain continues. pub array_only: bool, + /// Require a string. An array is then unusable and the chain continues. + /// + /// The mirror of `array_only`, and what a claim read as a delimited string + /// needs: an array-valued `scope` must contribute nothing rather than + /// contributing each element. + pub string_only: bool, + /// End the chain as soon as this path resolves to anything, usable or not. + /// + /// The default is to keep looking when a value is present but the wrong + /// shape. A chain that picks the first claim that *exists* and then requires + /// a shape of it needs this instead, so a present-but-unusable value denies + /// rather than falling through to a later candidate. + pub stop_if_present: bool, } /// One authored field: its ordered candidates and its options. @@ -106,7 +119,7 @@ pub struct FieldMap { } const FIELD_OPTIONS: &[&str] = &["merge", "on_missing", "paths", "split"]; -const CANDIDATE_KEYS: &[&str] = &["array_only", "path"]; +const CANDIDATE_KEYS: &[&str] = &["array_only", "path", "stop_if_present", "string_only"]; fn kind_of(value: &Value) -> &'static str { match value { @@ -134,6 +147,8 @@ impl Candidate { Value::String(path) => Ok(Self { path: path.clone(), array_only: false, + string_only: false, + stop_if_present: false, }), Value::Object(entries) => { for key in entries.keys() { @@ -154,11 +169,26 @@ impl Candidate { }, None => return Err(format!("{field}: a candidate object needs a `path`")), }; - let array_only = match entries.get("array_only") { - Some(value) => option_from_value(field, "array_only", value)?, - None => false, + let flag = |name: &str| -> Result { + match entries.get(name) { + Some(value) => option_from_value(field, name, value), + None => Ok(false), + } }; - Ok(Self { path, array_only }) + let array_only = flag("array_only")?; + let string_only = flag("string_only")?; + if array_only && string_only { + return Err(format!( + "{field}: '{path}' declares both `array_only` and `string_only`, so \ + nothing can satisfy it" + )); + } + Ok(Self { + path, + array_only, + string_only, + stop_if_present: flag("stop_if_present")?, + }) }, other => Err(format!( "{field}: a candidate is a path or an object with `path`, got {}", @@ -186,6 +216,8 @@ impl FieldMap { paths: vec![Candidate { path: path.clone(), array_only: false, + string_only: false, + stop_if_present: false, }], merge: MergeMode::default(), split: None, @@ -211,6 +243,8 @@ impl FieldMap { Some(Value::String(path)) => vec![Candidate { path: path.clone(), array_only: false, + string_only: false, + stop_if_present: false, }], Some(other) => { return Err(format!( @@ -309,8 +343,18 @@ pub struct RoleMapConfig(pub BTreeMap); /// /// A field with no candidate that resolves is left empty and logged at debug, /// naming every path tried. `on_missing: deny` makes that a refusal instead. -/// `array_only` requires an array, so a string-valued claim is skipped and the -/// next candidate is tried. +/// +/// Three per-candidate flags control what satisfies a candidate and when the +/// chain stops: +/// +/// | Flag | Effect | +/// |---|---| +/// | `array_only` | Only an array satisfies it; a string is skipped and the next candidate is tried. | +/// | `string_only` | Only a string satisfies it; an array is skipped. What a claim read as a delimited value needs, so an array-valued `scope` contributes nothing rather than contributing each element. | +/// | `stop_if_present` | The candidate claims the field the moment its path resolves at all. A present but unusable value then leaves the field empty instead of falling through, which is what a chain that picks the first claim that *exists* and only then requires a shape of it needs. | +/// +/// `array_only` and `string_only` together are rejected: nothing could satisfy +/// such a candidate. All three are rejected on a field that holds one value. /// /// # Escaping, and the quoting trap /// @@ -359,6 +403,8 @@ pub struct ClaimMapConfig { pub struct CompiledCandidate { path: ClaimPath, array_only: bool, + string_only: bool, + stop_if_present: bool, } impl CompiledCandidate { @@ -371,6 +417,16 @@ impl CompiledCandidate { pub fn array_only(&self) -> bool { self.array_only } + + /// Whether only a string satisfies this candidate. + pub fn string_only(&self) -> bool { + self.string_only + } + + /// Whether a present but unusable value ends the chain here. + pub fn stop_if_present(&self) -> bool { + self.stop_if_present + } } /// A field with every candidate path parsed. @@ -536,14 +592,14 @@ fn compile_role( holds one value" )); } - if let Some(candidate) = authored_field + if let Some((flag, candidate)) = authored_field .paths .iter() - .find(|candidate| candidate.array_only) + .find_map(|candidate| candidate.array_only.then_some(("array_only", candidate))) { return Err(format!( - "{qualified}: `array_only` on '{}' would let nothing resolve, because \ - {qualified} holds one value", + "{qualified}: `{flag}` on '{}' would let nothing resolve, because {qualified} \ + holds one value", candidate.path )); } @@ -556,6 +612,8 @@ fn compile_role( candidates.push(CompiledCandidate { path, array_only: candidate.array_only, + string_only: candidate.string_only, + stop_if_present: candidate.stop_if_present, }); } @@ -889,6 +947,54 @@ mod tests { assert!(err.contains("merge"), "{err}"); } + #[test] + fn the_candidate_flags_round_trip_and_default_to_unset() { + let map = compiled(json!({ + "subject": { + "permissions": { + "paths": [ + {"path": "permissions", "array_only": true}, + {"path": "scope", "string_only": true, "stop_if_present": true}, + "plain", + ], + "split": "whitespace", + } + } + })); + let flags: Vec<(bool, bool, bool)> = map + .role(&TokenRole::User) + .unwrap() + .field("permissions") + .unwrap() + .candidates() + .iter() + .map(|c| (c.array_only(), c.string_only(), c.stop_if_present())) + .collect(); + assert_eq!( + flags, + vec![ + (true, false, false), + (false, true, true), + (false, false, false) + ], + ); + } + + /// Nothing can be both an array and a string, so a candidate declaring both + /// could never resolve. That is a config mistake, not a way to disable a + /// candidate. + #[test] + fn a_candidate_declaring_both_shape_flags_is_rejected() { + let err = compile_err(json!({ + "subject": {"roles": [{"path": "roles", "array_only": true, "string_only": true}]} + })); + assert!( + err.contains("array_only") && err.contains("string_only"), + "{err}" + ); + assert!(err.contains("roles"), "{err}"); + } + #[test] fn an_unknown_candidate_key_is_rejected() { let err = compile_err(json!({ diff --git a/builtins/plugins/identity-jwt/src/configured_mapper.rs b/builtins/plugins/identity-jwt/src/configured_mapper.rs index aca01d7..52a9700 100644 --- a/builtins/plugins/identity-jwt/src/configured_mapper.rs +++ b/builtins/plugins/identity-jwt/src/configured_mapper.rs @@ -19,7 +19,8 @@ use serde_json::Value; use crate::claim_map::{ClaimMap, ClaimMapper}; use crate::claim_map_config::{ - CompiledClaimMap, CompiledField, CompiledRoleMap, MergeMode, OnMissing, SplitMode, + CompiledCandidate, CompiledClaimMap, CompiledField, CompiledRoleMap, MergeMode, OnMissing, + SplitMode, }; /// Every SPIFFE ID starts here, and no configuration can turn the check off. @@ -101,7 +102,7 @@ struct FieldOutcome { /// this field. fn contribute( value: &Value, - array_only: bool, + candidate: &CompiledCandidate, split: Option, out: &mut Vec, ) -> bool { @@ -112,6 +113,9 @@ fn contribute( // covers a delimited-string candidate and an array candidate at once // precisely because it leaves the array alone. Value::Array(items) => { + if candidate.string_only() { + return false; + } for item in items { if let Some(text) = item.as_str() { out.push(text.to_owned()); @@ -120,7 +124,7 @@ fn contribute( true }, Value::String(text) => { - if array_only { + if candidate.array_only() { return false; } match split { @@ -145,7 +149,13 @@ fn resolve_collection(field: &CompiledField, claims: &ClaimMap) -> FieldOutcome let Some(value) = candidate.path().resolve(claims) else { continue; }; - if !contribute(value, candidate.array_only(), field.split(), &mut values) { + if !contribute(value, candidate, field.split(), &mut values) { + // A present value the candidate cannot use normally leaves the chain + // running. `stop_if_present` is for a chain that picks the first + // claim that exists and then requires a shape of it. + if candidate.stop_if_present() { + break; + } continue; } resolved = true; @@ -175,10 +185,15 @@ fn resolve_scalar( let mut paths_tried = Vec::with_capacity(field.candidates().len()); for candidate in field.candidates() { paths_tried.push(candidate.path().to_string()); - if let Some(text) = candidate.path().resolve(claims).and_then(Value::as_str) - && accept(text) - { - return (Some(text.to_owned()), paths_tried); + let Some(value) = candidate.path().resolve(claims) else { + continue; + }; + match value.as_str() { + Some(text) if accept(text) => return (Some(text.to_owned()), paths_tried), + // Present but unusable. The chain continues unless this candidate + // claims the field the moment its path resolves at all. + _ if candidate.stop_if_present() => break, + _ => {}, } } (None, paths_tried) @@ -195,6 +210,7 @@ struct Diagnostics { missed: Vec<(&'static str, Vec)>, empty: Vec<&'static str>, denied: Vec<&'static str>, + undeclared_anchor: Option<&'static str>, } impl Diagnostics { @@ -204,6 +220,7 @@ impl Diagnostics { missed: Vec::new(), empty: Vec::new(), denied: Vec::new(), + undeclared_anchor: None, } } @@ -232,6 +249,16 @@ impl Diagnostics { } } + /// An anchor the section declares no path for, which denies every token. + /// + /// Recorded so the miss event names it: without this the denial says to raise + /// the log level and the raised log says nothing, because a field nothing + /// asked for never reaches the resolution path. + fn record_undeclared_anchor(&mut self, name: &'static str) { + self.missed.push((name, Vec::new())); + self.undeclared_anchor = Some(name); + } + /// Whether a field declared `on_missing: deny` and did not resolve. fn declined(&self) -> bool { !self.denied.is_empty() @@ -259,6 +286,14 @@ impl Diagnostics { "claim map: these fields resolved to an empty collection", ); } + if let Some(anchor) = self.undeclared_anchor { + tracing::warn!( + role = self.role, + field = anchor, + "claim map: the section declares no path for its anchor, so every token is \ + declined", + ); + } if !self.denied.is_empty() { tracing::warn!( role = self.role, @@ -301,6 +336,22 @@ fn scalar( value } +/// Resolve a role's anchor, reporting an undeclared one rather than silently +/// declining every token. +fn anchor( + section: &CompiledRoleMap, + name: &'static str, + claims: &ClaimMap, + diag: &mut Diagnostics, + accept: impl Fn(&str) -> bool, +) -> Option { + if section.field(name).is_none() { + diag.record_undeclared_anchor(name); + return None; + } + scalar(section, name, claims, diag, accept) +} + fn accept_any(_: &str) -> bool { true } @@ -314,7 +365,7 @@ impl ClaimMapper for ConfiguredClaimMap { let section = self.map.role(&TokenRole::User).ok()?; let mut diag = Diagnostics::new("subject"); - let id = scalar(section, "id", claims, &mut diag, accept_any); + let id = anchor(section, "id", claims, &mut diag, accept_any); let roles = collection(section, "roles", claims, &mut diag); let permissions = collection(section, "permissions", claims, &mut diag); let teams = collection(section, "teams", claims, &mut diag); @@ -338,7 +389,7 @@ impl ClaimMapper for ConfiguredClaimMap { let section = self.map.role(&TokenRole::Client).ok()?; let mut diag = Diagnostics::new("client"); - let client_id = scalar(section, "client_id", claims, &mut diag, accept_any); + let client_id = anchor(section, "client_id", claims, &mut diag, accept_any); let client_name = scalar(section, "client_name", claims, &mut diag, accept_any); let authorized_scopes = collection(section, "authorized_scopes", claims, &mut diag); let authorized_audiences = collection(section, "authorized_audiences", claims, &mut diag); @@ -371,7 +422,7 @@ impl ClaimMapper for ConfiguredClaimMap { // Prefix-check every candidate before it counts as resolving: a // non-SPIFFE `sub` must not smuggle in an arbitrary `spiffe_id` claim, // and a later SPIFFE-shaped candidate must still win. - let spiffe_id = scalar(section, "spiffe_id", claims, &mut diag, |text| { + let spiffe_id = anchor(section, "spiffe_id", claims, &mut diag, |text| { text.starts_with(SPIFFE_SCHEME) }); let client_id = scalar(section, "client_id", claims, &mut diag, accept_any); @@ -386,9 +437,24 @@ impl ClaimMapper for ConfiguredClaimMap { } let spiffe_id = spiffe_id?; - let trust_domain = match mapped_trust_domain { - Some(mapped) => mapped, - None => trust_domain_of(&spiffe_id), + let derived = trust_domain_of(&spiffe_id); + // The trust domain is the SPIFFE URI's authority. A declared path may + // name where to read it, but it cannot disagree with the identity it is + // the authority of: a policy gating the trust boundary would be reading + // one workload's domain off another's identity. + let trust_domain = match mapped_trust_domain.flatten() { + Some(mapped) if Some(&mapped) == derived.as_ref() => Some(mapped), + Some(mapped) => { + tracing::warn!( + role = "workload", + mapped = %mapped, + derived = derived.as_deref().unwrap_or(""), + "claim map: declining, the mapped trust domain disagrees with the SPIFFE ID \ + authority", + ); + return None; + }, + None => derived, }; Some(WorkloadIdentity { @@ -1073,21 +1139,170 @@ mod tests { ); } + /// The trust domain is the SPIFFE URI's authority. A declared path can name + /// where to read it, but it cannot disagree with the identity it is the + /// authority of, and it cannot suppress the derivation by resolving nothing. #[test] - fn trust_domain_is_derived_when_unmapped_and_taken_from_the_path_when_mapped() { + fn the_trust_domain_always_matches_the_spiffe_authority() { + let spiffe = "spiffe://corp.example/ns/a/sa/b"; + let derived = mapper(json!({"workload": {"spiffe_id": "sub"}})) - .map_workload(&claims(json!({"sub": "spiffe://corp.example/ns/a/sa/b"}))) - .unwrap(); + .map_workload(&claims(json!({"sub": spiffe}))) + .expect("an unmapped trust domain is derived"); assert_eq!(derived.trust_domain.as_deref(), Some("corp.example")); - let mapped = mapper(json!({ - "workload": {"spiffe_id": "sub", "trust_domain": "td"} - })) - .map_workload(&claims(json!({ - "sub": "spiffe://corp.example/ns/a/sa/b", "td": "declared.example", - }))) - .unwrap(); - assert_eq!(mapped.trust_domain.as_deref(), Some("declared.example")); + let declared = json!({"workload": {"spiffe_id": "sub", "trust_domain": "td"}}); + + let agreeing = mapper(declared.clone()) + .map_workload(&claims(json!({"sub": spiffe, "td": "corp.example"}))) + .expect("a mapped trust domain that agrees is used"); + assert_eq!(agreeing.trust_domain.as_deref(), Some("corp.example")); + + let unresolved = mapper(declared.clone()) + .map_workload(&claims(json!({"sub": spiffe}))) + .expect("a declared path that resolves nothing falls back to derivation"); + assert_eq!( + unresolved.trust_domain.as_deref(), + Some("corp.example"), + "a declared path must not suppress the derivable authority" + ); + + let (disagreeing, events) = capturing(|| { + mapper(declared).map_workload(&claims(json!({"sub": spiffe, "td": "attacker.example"}))) + }); + assert!( + disagreeing.is_none(), + "a trust domain that contradicts the SPIFFE authority must not reach a policy \ + gating the trust boundary" + ); + let warning = events.matching("disagrees with the SPIFFE ID authority"); + let event = warning.first().expect("the disagreement is named"); + assert!(event.contains("attacker.example"), "{event}"); + assert!(event.contains("corp.example"), "{event}"); + } + + /// The mirror of `array_only`: what a claim read as a delimited string needs, + /// so an array-valued `scope` contributes nothing rather than contributing + /// each element as a permission. + #[test] + fn string_only_rejects_an_array_and_lets_the_chain_continue() { + let map = json!({ + "subject": { + "id": "sub", + "permissions": { + "paths": [{"path": "scope", "string_only": true}, "backup"], + "split": "whitespace", + }, + } + }); + + let from_string = mapper(map.clone()) + .map_subject(&claims(json!({"sub": "alice", "scope": "read write"}))) + .unwrap(); + assert_eq!(sorted(&from_string.permissions), vec!["read", "write"]); + + let array_falls_through = mapper(map) + .map_subject(&claims(json!({ + "sub": "alice", "scope": ["admin", "root"], "backup": ["safe"], + }))) + .unwrap(); + assert_eq!( + sorted(&array_falls_through.permissions), + vec!["safe"], + "an array-valued scope must not grant its elements as permissions" + ); + } + + /// A chain that picks the first claim that *exists* and then requires a shape + /// of it, rather than skipping to the next candidate. Without this a + /// present-but-unusable anchor falls through and accepts an identity the + /// stricter reading refuses. + #[test] + fn stop_if_present_ends_the_chain_on_a_present_but_unusable_value() { + let map = json!({ + "client": {"client_id": [{"path": "client_id", "stop_if_present": true}, "azp"]} + }); + + for unusable in [json!(null), json!(42), json!(["svc"]), json!({})] { + assert!( + mapper(map.clone()) + .map_client(&claims( + json!({"client_id": unusable, "azp": "svc-billing"}) + )) + .is_none(), + "a present but unusable client_id must not fall through to azp" + ); + } + + let absent = mapper(map.clone()) + .map_client(&claims(json!({"azp": "svc-billing"}))) + .expect("an absent candidate still falls through"); + assert_eq!(absent.client_id, "svc-billing"); + + let usable = mapper(map) + .map_client(&claims(json!({"client_id": "explicit", "azp": "ignored"}))) + .expect("a usable value wins"); + assert_eq!(usable.client_id, "explicit"); + } + + /// The same rule on a collection field. + #[test] + fn stop_if_present_also_ends_a_collection_chain() { + let map = json!({ + "subject": { + "id": "sub", + "roles": [{"path": "primary", "stop_if_present": true}, "backup"], + } + }); + // An object is unusable for a collection field. A bare string is not: + // it contributes as one element unless the candidate is array-only. + let stopped = mapper(map.clone()) + .map_subject(&claims(json!({ + "sub": "alice", "primary": {"k": "v"}, "backup": ["fallback"], + }))) + .unwrap(); + assert!( + stopped.roles.is_empty(), + "a present but unusable primary claims the field" + ); + + let fell_through = mapper(map) + .map_subject(&claims(json!({"sub": "alice", "backup": ["fallback"]}))) + .unwrap(); + assert_eq!(sorted(&fell_through.roles), vec!["fallback"]); + } + + /// A section that declares no path for its anchor denies every token. The + /// denial tells the operator to raise the log level, so the raised log has to + /// say something. + #[test] + fn an_undeclared_anchor_names_itself_in_a_warning() { + for (role, section, anchor) in [ + ("subject", json!({"subject": {"roles": "roles"}}), "id"), + ("client", json!({"client": {"roles": "roles"}}), "client_id"), + ( + "workload", + json!({"workload": {"selectors": "sel"}}), + "spiffe_id", + ), + ] { + let (identity, events) = capturing(|| { + let map = mapper(section.clone()); + let token = claims(json!({"sub": "alice", "roles": ["admin"], "sel": ["a"]})); + match role { + "subject" => map.map_subject(&token).is_some(), + "client" => map.map_client(&token).is_some(), + _ => map.map_workload(&token).is_some(), + } + }); + assert!(!identity, "{role}: an undeclared anchor declines"); + let warning = events.matching("declares no path for its anchor"); + let event = warning + .first() + .unwrap_or_else(|| panic!("{role}: the undeclared anchor must be named")); + assert!(event.contains(anchor), "{role}: {event}"); + assert!(event.contains("WARN"), "{role}: {event}"); + } } #[test] diff --git a/builtins/plugins/identity-jwt/src/presets/auth0.json b/builtins/plugins/identity-jwt/src/presets/auth0.json index 5c65a1b..74cc003 100644 --- a/builtins/plugins/identity-jwt/src/presets/auth0.json +++ b/builtins/plugins/identity-jwt/src/presets/auth0.json @@ -4,13 +4,33 @@ "subject": { "id": "sub", "permissions": { - "paths": [{ "path": "permissions", "array_only": true }, "scope"], + "paths": [ + { + "path": "permissions", + "array_only": true + }, + { + "path": "scope", + "string_only": true + } + ], "split": "whitespace" } }, "client": { - "client_id": ["client_id", "azp"], - "authorized_scopes": { "paths": ["scope"], "split": "whitespace" }, + "client_id": [ + "client_id", + "azp" + ], + "authorized_scopes": { + "paths": [ + { + "path": "scope", + "string_only": true + } + ], + "split": "whitespace" + }, "authorized_audiences": "aud" } } diff --git a/builtins/plugins/identity-jwt/src/presets/cognito.json b/builtins/plugins/identity-jwt/src/presets/cognito.json index 6230d63..ecc1baf 100644 --- a/builtins/plugins/identity-jwt/src/presets/cognito.json +++ b/builtins/plugins/identity-jwt/src/presets/cognito.json @@ -3,12 +3,35 @@ "claim_map": { "subject": { "id": "sub", - "permissions": { "paths": ["scope"], "split": "whitespace" }, - "teams": [{ "path": "cognito:groups", "array_only": true }] + "permissions": { + "paths": [ + { + "path": "scope", + "string_only": true + } + ], + "split": "whitespace" + }, + "teams": [ + { + "path": "cognito:groups", + "array_only": true + } + ] }, "client": { - "client_id": ["client_id"], - "authorized_scopes": { "paths": ["scope"], "split": "whitespace" } + "client_id": [ + "client_id" + ], + "authorized_scopes": { + "paths": [ + { + "path": "scope", + "string_only": true + } + ], + "split": "whitespace" + } } } } diff --git a/builtins/plugins/identity-jwt/src/presets/keycloak.json b/builtins/plugins/identity-jwt/src/presets/keycloak.json index eb2ef81..21ff1a2 100644 --- a/builtins/plugins/identity-jwt/src/presets/keycloak.json +++ b/builtins/plugins/identity-jwt/src/presets/keycloak.json @@ -3,12 +3,37 @@ "claim_map": { "subject": { "id": "sub", - "roles": [{ "path": "realm_access.roles", "array_only": true }], - "permissions": { "paths": ["scope"], "split": "whitespace" } + "roles": [ + { + "path": "realm_access.roles", + "array_only": true + } + ], + "permissions": { + "paths": [ + { + "path": "scope", + "string_only": true + } + ], + "split": "whitespace" + } }, "client": { - "client_id": ["client_id", "azp", "clientId"], - "authorized_scopes": { "paths": ["scope"], "split": "whitespace" }, + "client_id": [ + "client_id", + "azp", + "clientId" + ], + "authorized_scopes": { + "paths": [ + { + "path": "scope", + "string_only": true + } + ], + "split": "whitespace" + }, "authorized_audiences": "aud" } } diff --git a/builtins/plugins/identity-jwt/src/presets/standard.json b/builtins/plugins/identity-jwt/src/presets/standard.json index fd0ea41..226b169 100644 --- a/builtins/plugins/identity-jwt/src/presets/standard.json +++ b/builtins/plugins/identity-jwt/src/presets/standard.json @@ -1,30 +1,73 @@ { - "description": "Standard OIDC claim shape, and what an absent claim_mapper resolves to. Reads sub, roles, permissions or scope, and teams or groups for a subject; client_id or azp, client_name, authorized_scopes or scope, aud and roles for a client; and a SPIFFE ID from sub or spiffe_id for a workload. Equivalent to the built-in Rust standard mapper, which a corpus-backed test holds it to: a deployment that names no mapper sees exactly what it saw before. The collection candidates are array-only because the Rust mapper reads them with an array accessor, so a string-valued roles claim contributes nothing and falls through where there is a next candidate.", + "description": "Standard OIDC claim shape, and what an absent claim_mapper resolves to. Reads sub, roles, permissions or scope, and teams or groups for a subject; client_id or azp, client_name, authorized_scopes or scope, aud and roles for a client; and a SPIFFE ID from sub or spiffe_id for a workload. Equivalent to the built-in Rust standard mapper, which a corpus-backed test and a shape sweep hold it to: a deployment that names no mapper sees exactly what it saw before. Every candidate flag here encodes an accessor the Rust mapper uses, so none of them is cosmetic. The collection candidates are array-only because the mapper reads them with an array accessor, so a string-valued roles claim contributes nothing and falls through where there is a next candidate. The scope candidates are string-only because the mapper reads scope with a string accessor, so an array-valued scope must contribute nothing rather than contributing each element as a permission. The client_id candidate stops if present because the mapper picks the first anchor key that exists and only then requires a string of it, so a present-but-unusable client_id denies rather than falling through to azp.", "claim_map": { "subject": { "id": "sub", - "roles": [{ "path": "roles", "array_only": true }], + "roles": [ + { + "path": "roles", + "array_only": true + } + ], "permissions": { - "paths": [{ "path": "permissions", "array_only": true }, "scope"], + "paths": [ + { + "path": "permissions", + "array_only": true + }, + { + "path": "scope", + "string_only": true + } + ], "split": "whitespace" }, "teams": [ - { "path": "teams", "array_only": true }, - { "path": "groups", "array_only": true } + { + "path": "teams", + "array_only": true + }, + { + "path": "groups", + "array_only": true + } ] }, "client": { - "client_id": ["client_id", "azp"], + "client_id": [ + { + "path": "client_id", + "stop_if_present": true + }, + "azp" + ], "client_name": "client_name", "authorized_scopes": { - "paths": [{ "path": "authorized_scopes", "array_only": true }, "scope"], + "paths": [ + { + "path": "authorized_scopes", + "array_only": true + }, + { + "path": "scope", + "string_only": true + } + ], "split": "whitespace" }, "authorized_audiences": "aud", - "roles": [{ "path": "roles", "array_only": true }] + "roles": [ + { + "path": "roles", + "array_only": true + } + ] }, "workload": { - "spiffe_id": ["sub", "spiffe_id"] + "spiffe_id": [ + "sub", + "spiffe_id" + ] } } } diff --git a/builtins/plugins/identity-jwt/src/resolver.rs b/builtins/plugins/identity-jwt/src/resolver.rs index 0b6f92e..9ea24fc 100644 --- a/builtins/plugins/identity-jwt/src/resolver.rs +++ b/builtins/plugins/identity-jwt/src/resolver.rs @@ -242,7 +242,25 @@ impl JwtIdentityResolver { // Require the section matching the configured role now, so a // misconfigured pairing is a startup failure rather than a resolver that // denies every request. - compiled.role(&typed.role).map_err(&config_error)?; + let section = compiled.role(&typed.role).map_err(&config_error)?; + + // A section that declares no path for its anchor compiles, because + // declaring the role is what the section check asks. It then denies every + // token, so say so at load rather than leaving it to be discovered one + // denial at a time. + let anchor = match typed.role { + TokenRole::Client => "client_id", + TokenRole::CallerWorkload => "spiffe_id", + _ => "id", + }; + if section.field(anchor).is_none() { + tracing::warn!( + plugin = %cfg.name, + role = ?typed.role, + field = anchor, + "claim map declares no path for its anchor, so every token will be declined", + ); + } let claim_mapper: Arc = Arc::new(ConfiguredClaimMap::new(compiled)); diff --git a/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json b/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json index 3f5189a..6b58562 100644 --- a/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json +++ b/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json @@ -217,6 +217,23 @@ "claims": {} } }, + { + "name": "subject-array-valued-scope-contributes-nothing", + "role": "user", + "provenance": "Constructed: the Rust mapper reads scope through an as_str accessor, so an array-valued scope contributes nothing. A candidate that accepted the array would grant permissions the mapper does not.", + "claims": { + "sub": "alice", + "scope": [ + "admin", + "write" + ] + }, + "expected": { + "id": "alice", + "permissions": [], + "claims": {} + } + }, { "name": "subject-an-array-element-containing-whitespace-stays-whole", "role": "user", @@ -543,6 +560,26 @@ "claims": {} } }, + { + "name": "client-a-present-but-unusable-client-id-declines-rather-than-using-azp", + "role": "client", + "provenance": "Constructed: the Rust mapper picks the first anchor key that is present and only then requires a string, so a null client_id denies rather than falling through to azp.", + "claims": { + "client_id": null, + "azp": "svc-billing" + }, + "expected": null + }, + { + "name": "client-a-numeric-client-id-declines-rather-than-using-azp", + "role": "client", + "provenance": "Constructed: the same presence-before-shape rule for a non-null wrong type.", + "claims": { + "client_id": 42, + "azp": "svc-billing" + }, + "expected": null + }, { "name": "client-scopes-from-authorized-scopes", "role": "client", @@ -597,6 +634,23 @@ "claims": {} } }, + { + "name": "client-array-valued-scope-contributes-nothing", + "role": "client", + "provenance": "Constructed: the client-side pair of the array-valued scope case.", + "claims": { + "client_id": "svc", + "scope": [ + "read", + "write" + ] + }, + "expected": { + "client_id": "svc", + "authorized_scopes": [], + "claims": {} + } + }, { "name": "client-an-array-element-containing-whitespace-stays-whole", "role": "client", diff --git a/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs b/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs index a289125..5cf205b 100644 --- a/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs +++ b/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs @@ -35,7 +35,7 @@ use praxis_policy_plugin_identity_jwt::{ ClaimMapper as _, ConfiguredClaimMap, StandardClaimMap, presets, }; use serde::Deserialize; -use serde_json::Value; +use serde_json::{Value, json}; const CORPUS_JSON: &str = include_str!("fixtures/claim-corpus.json"); @@ -456,14 +456,24 @@ fn standard_preset() -> ConfiguredClaimMap { /// which is the side that changes when it does. fn assert_entry_agrees(entry: &CorpusEntry, preset: &ConfiguredClaimMap) { let context = format!("{} (standard preset vs rust mapper)", entry.name); - match entry.role { + compare_both_paths(entry.role, preset, &entry.claims, &context); +} + +/// Map one claim set through both paths and assert they agree. +fn compare_both_paths( + role: CorpusRole, + preset: &ConfiguredClaimMap, + claims: &HashMap, + context: &str, +) { + match role { CorpusRole::User => { match ( - preset.map_subject(&entry.claims), - StandardClaimMap.map_subject(&entry.claims), + preset.map_subject(claims), + StandardClaimMap.map_subject(claims), ) { (Some(from_preset), Some(from_rust)) => { - assert_subjects_agree(&context, &from_preset, &from_rust); + assert_subjects_agree(context, &from_preset, &from_rust); }, (None, None) => {}, (from_preset, from_rust) => panic!( @@ -475,11 +485,11 @@ fn assert_entry_agrees(entry: &CorpusEntry, preset: &ConfiguredClaimMap) { }, CorpusRole::Client => { match ( - preset.map_client(&entry.claims), - StandardClaimMap.map_client(&entry.claims), + preset.map_client(claims), + StandardClaimMap.map_client(claims), ) { (Some(from_preset), Some(from_rust)) => { - assert_clients_agree(&context, &from_preset, &from_rust); + assert_clients_agree(context, &from_preset, &from_rust); }, (None, None) => {}, (from_preset, from_rust) => panic!( @@ -491,11 +501,11 @@ fn assert_entry_agrees(entry: &CorpusEntry, preset: &ConfiguredClaimMap) { }, CorpusRole::Workload => { match ( - preset.map_workload(&entry.claims), - StandardClaimMap.map_workload(&entry.claims), + preset.map_workload(claims), + StandardClaimMap.map_workload(claims), ) { (Some(from_preset), Some(from_rust)) => { - assert_workloads_agree(&context, &from_preset, &from_rust); + assert_workloads_agree(context, &from_preset, &from_rust); }, (None, None) => {}, (from_preset, from_rust) => panic!( @@ -612,3 +622,167 @@ fn the_claims_bag_comparison_is_exhaustive() { "a nested claim no single-segment path consumed stays visible" ); } + +// ===================================================================== +// The shape sweep +// ===================================================================== +// +// The corpus samples token shapes a human thought to write down. This +// enumerates them instead: every claim the standard preset declares, against +// every JSON shape a claim can hold, and every pair of shapes across each +// fallback. Two parity breaks reached the corpus-backed gate without being +// caught, both of them a claim carrying the wrong JSON type as the second +// candidate of a chain, which is the axis a hand-written corpus does not think +// to vary. + +/// Every JSON shape a claim value can take, named for the failure message. +fn shapes() -> Vec<(&'static str, Option)> { + vec![ + ("absent", None), + ("null", Some(json!(null))), + ("bool", Some(json!(true))), + ("number", Some(json!(42))), + ("empty string", Some(json!(""))), + ("whitespace string", Some(json!(" "))), + ("one word", Some(json!("one"))), + ("two words", Some(json!("two words"))), + ("empty array", Some(json!([]))), + ("string array", Some(json!(["a", "b"]))), + ("array with a spaced element", Some(json!(["a b", "c"]))), + ("mixed array", Some(json!([null, 42, ["x"], {}, "s"]))), + ("object", Some(json!({"k": "v"}))), + ] +} + +fn claim_set(base: &Value, overrides: &[(&str, &Option)]) -> HashMap { + let mut claims: HashMap = base + .as_object() + .expect("the base claim set is an object") + .clone() + .into_iter() + .collect(); + for (name, shape) in overrides { + match shape { + Some(value) => claims.insert((*name).to_owned(), value.clone()), + None => claims.remove(*name), + }; + } + claims +} + +/// Every claim name the standard preset declares a path for, per role. +const SWEEP: &[(CorpusRole, &[&str])] = &[ + ( + CorpusRole::User, + &["sub", "roles", "permissions", "scope", "teams", "groups"], + ), + ( + CorpusRole::Client, + &[ + "client_id", + "azp", + "client_name", + "authorized_scopes", + "scope", + "aud", + "roles", + ], + ), + (CorpusRole::Workload, &["sub", "spiffe_id"]), +]; + +fn base_for(role: CorpusRole) -> Value { + match role { + CorpusRole::User => json!({"sub": "alice"}), + CorpusRole::Client => json!({"client_id": "svc"}), + CorpusRole::Workload => json!({"sub": "spiffe://corp.example/w"}), + } +} + +#[test] +fn the_preset_agrees_with_the_rust_mapper_over_every_shape_of_every_declared_claim() { + let preset = standard_preset(); + let mut checked = 0_usize; + for (role, names) in SWEEP { + for name in *names { + for (label, shape) in shapes() { + let claims = claim_set(&base_for(*role), &[(name, &shape)]); + compare_both_paths( + *role, + &preset, + &claims, + &format!("{role:?}: {name} = {label}"), + ); + checked += 1; + } + } + } + println!("{checked} single-claim shapes agreed"); +} + +/// Each fallback pair, with the base claim needed to reach it. A chain is where +/// the two implementations can differ on which candidate wins, so both +/// positions vary together. +const FALLBACK_PAIRS: &[(CorpusRole, &str, &str)] = &[ + (CorpusRole::User, "permissions", "scope"), + (CorpusRole::User, "teams", "groups"), + (CorpusRole::Client, "authorized_scopes", "scope"), + (CorpusRole::Client, "client_id", "azp"), + (CorpusRole::Workload, "sub", "spiffe_id"), +]; + +#[test] +fn the_preset_agrees_with_the_rust_mapper_over_every_shape_pair_of_every_fallback() { + let preset = standard_preset(); + let mut checked = 0_usize; + for (role, first, second) in FALLBACK_PAIRS { + // A pair that is itself the anchor starts from nothing, so neither + // position is pinned by the base. + let anchored = match role { + CorpusRole::User => *first != "sub", + CorpusRole::Client => *first != "client_id", + CorpusRole::Workload => *first != "sub", + }; + let base = if anchored { base_for(*role) } else { json!({}) }; + + for (first_label, first_shape) in shapes() { + for (second_label, second_shape) in shapes() { + let claims = claim_set(&base, &[(first, &first_shape), (second, &second_shape)]); + compare_both_paths( + *role, + &preset, + &claims, + &format!("{role:?}: {first} = {first_label}, {second} = {second_label}"), + ); + checked += 1; + } + } + } + println!("{checked} shape pairs agreed"); +} + +/// A SPIFFE-shaped value has to appear in the workload sweep, or every case +/// declines on both sides and the sweep proves nothing about that role. +#[test] +fn the_workload_sweep_reaches_a_resolving_case() { + let preset = standard_preset(); + for (name, other) in [("sub", "spiffe_id"), ("spiffe_id", "sub")] { + let claims = claim_set( + &json!({}), + &[ + (name, &Some(json!("spiffe://corp.example/w"))), + (other, &None), + ], + ); + assert!( + preset.map_workload(&claims).is_some(), + "{name} carrying a SPIFFE ID must resolve" + ); + compare_both_paths( + CorpusRole::Workload, + &preset, + &claims, + &format!("workload anchored on {name}"), + ); + } +} From 0b6832586dbe4cf816ceb752e216aa093e49c917 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 20:56:29 -0400 Subject: [PATCH 11/27] fix(identity-jwt): escape a token-derived log value and un-vacuum the shape sweep The trust-domain disagreement warning is the one diagnostic carrying a value read from the token rather than from operator config, and it was formatted for Display, so a claim holding a newline could forge a log line. It is Debug-escaped now, with a test asserting no raw newline reaches the record. The shape sweep had no SPIFFE-shaped string, so every workload case declined on both sides and agreed vacuously: 169 of its pairs proved nothing about the role whose invariants matter most. Adding a SPIFFE ID and a non-SPIFFE URI takes the sweep to 1350 cases and makes the workload half real. Also corrects a rustdoc claim that all three candidate flags are rejected on a field holding one value. Only the shape flags are, and only they should be: the standard preset sets `stop_if_present` on its client anchor, which is such a field, so the documented rule would not have compiled. Signed-off-by: Frederico Araujo --- .../identity-jwt/src/claim_map_config.rs | 64 ++++++++++++++----- .../identity-jwt/src/configured_mapper.rs | 32 +++++++++- .../tests/standard_preset_equivalence.rs | 4 ++ 3 files changed, 82 insertions(+), 18 deletions(-) diff --git a/builtins/plugins/identity-jwt/src/claim_map_config.rs b/builtins/plugins/identity-jwt/src/claim_map_config.rs index 3fb6c41..a225ef3 100644 --- a/builtins/plugins/identity-jwt/src/claim_map_config.rs +++ b/builtins/plugins/identity-jwt/src/claim_map_config.rs @@ -354,7 +354,9 @@ pub struct RoleMapConfig(pub BTreeMap); /// | `stop_if_present` | The candidate claims the field the moment its path resolves at all. A present but unusable value then leaves the field empty instead of falling through, which is what a chain that picks the first claim that *exists* and only then requires a shape of it needs. | /// /// `array_only` and `string_only` together are rejected: nothing could satisfy -/// such a candidate. All three are rejected on a field that holds one value. +/// such a candidate. Both are also rejected on a field that holds one value, +/// which already requires a string. `stop_if_present` is a chain rule rather +/// than a shape rule, so it stays valid on every field. /// /// # Escaping, and the quoting trap /// @@ -592,14 +594,23 @@ fn compile_role( holds one value" )); } - if let Some((flag, candidate)) = authored_field - .paths - .iter() - .find_map(|candidate| candidate.array_only.then_some(("array_only", candidate))) - { + // The shape flags say nothing a field holding one value does not + // already say: it requires a string, so `array_only` would let nothing + // resolve. `stop_if_present` is a chain rule rather than a shape rule + // and stays valid here, which is what the standard preset's client + // anchor needs to reproduce the Rust mapper. + if let Some((flag, candidate)) = authored_field.paths.iter().find_map(|candidate| { + if candidate.array_only { + Some(("array_only", candidate)) + } else if candidate.string_only { + Some(("string_only", candidate)) + } else { + None + } + }) { return Err(format!( - "{qualified}: `{flag}` on '{}' would let nothing resolve, because {qualified} \ - holds one value", + "{qualified}: `{flag}` on '{}' says nothing a field holding one value does \ + not already say", candidate.path )); } @@ -887,7 +898,7 @@ mod tests { /// as `union` is. Ignoring `array_only` would be worse than rejecting it: it /// would let nothing resolve, turning a config mistake into a runtime denial. #[test] - fn split_and_array_only_on_a_field_holding_one_value_are_rejected() { + fn split_and_the_shape_flags_on_a_field_holding_one_value_are_rejected() { let split = compile_err(json!({ "subject": {"id": {"paths": ["sub"], "split": "whitespace"}} })); @@ -896,16 +907,35 @@ mod tests { "{split}" ); - let array_only = compile_err(json!({ - "subject": {"id": [{"path": "sub", "array_only": true}]} + for flag in ["array_only", "string_only"] { + let err = compile_err(json!({ + "subject": {"id": [{"path": "sub", flag: true}]} + })); + assert!(err.contains("subject.id"), "{flag}: {err}"); + assert!(err.contains(flag), "{flag}: {err}"); + assert!(err.contains("sub"), "the candidate is named: {err}"); + } + } + + /// `stop_if_present` is a chain rule, not a shape rule, so a field holding one + /// value accepts it. The standard preset's client anchor depends on that: it is + /// how the first anchor key that exists claims the field. + #[test] + fn stop_if_present_is_accepted_on_a_field_holding_one_value() { + let map = compiled(json!({ + "client": {"client_id": [{"path": "client_id", "stop_if_present": true}, "azp"]} })); + let candidates = map + .role(&TokenRole::Client) + .unwrap() + .field("client_id") + .unwrap() + .candidates(); assert!( - array_only.contains("subject.id") && array_only.contains("array_only"), - "{array_only}" - ); - assert!( - array_only.contains("sub"), - "the offending candidate is named: {array_only}" + candidates + .first() + .is_some_and(CompiledCandidate::stop_if_present), + "the flag must survive compilation on a scalar field" ); } diff --git a/builtins/plugins/identity-jwt/src/configured_mapper.rs b/builtins/plugins/identity-jwt/src/configured_mapper.rs index 52a9700..51de8b8 100644 --- a/builtins/plugins/identity-jwt/src/configured_mapper.rs +++ b/builtins/plugins/identity-jwt/src/configured_mapper.rs @@ -447,7 +447,9 @@ impl ClaimMapper for ConfiguredClaimMap { Some(mapped) => { tracing::warn!( role = "workload", - mapped = %mapped, + // Debug-escaped, not Display: this value comes from the token, + // and an unescaped newline in it would forge a log line. + mapped = ?mapped, derived = derived.as_deref().unwrap_or(""), "claim map: declining, the mapped trust domain disagrees with the SPIFFE ID \ authority", @@ -1181,6 +1183,34 @@ mod tests { assert!(event.contains("corp.example"), "{event}"); } + /// The trust-domain warning is the one diagnostic carrying a value taken from + /// the token rather than from operator config, so it is the one place a claim + /// could forge a log line. It is Debug-escaped for that reason. + #[test] + fn a_token_derived_value_reaches_the_log_escaped() { + let (declined, events) = capturing(|| { + mapper(json!({ + "workload": {"spiffe_id": "sub", "trust_domain": "td"} + })) + .map_workload(&claims(json!({ + "sub": "spiffe://corp.example/w", + "td": "attacker\n2026-08-20 WARN forged log line", + }))) + }); + assert!(declined.is_none(), "a disagreeing trust domain declines"); + + let warning = events.matching("disagrees with the SPIFFE ID authority"); + let event = warning.first().expect("the disagreement is logged"); + assert!( + !event.contains('\n'), + "a claim value must not put a raw newline in a log record: {event:?}" + ); + assert!( + event.contains("\\n"), + "the newline should survive as an escape rather than vanish: {event:?}" + ); + } + /// The mirror of `array_only`: what a claim read as a delimited string needs, /// so an array-valued `scope` contributes nothing rather than contributing /// each element as a permission. diff --git a/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs b/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs index 5cf205b..64d07f7 100644 --- a/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs +++ b/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs @@ -645,6 +645,10 @@ fn shapes() -> Vec<(&'static str, Option)> { ("empty string", Some(json!(""))), ("whitespace string", Some(json!(" "))), ("one word", Some(json!("one"))), + // Without a SPIFFE-shaped string every workload case declines on both + // sides, which agrees vacuously and proves nothing about that role. + ("spiffe id", Some(json!("spiffe://corp.example/ns/a/sa/b"))), + ("non-spiffe uri", Some(json!("https://corp.example/ns/a"))), ("two words", Some(json!("two words"))), ("empty array", Some(json!([]))), ("string array", Some(json!(["a", "b"]))), From 5acf0204c8e8f42d50b89d3ed0578b63b044da84 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 21:18:28 -0400 Subject: [PATCH 12/27] fix(identity-jwt): give each candidate flag one valid home, and derive the trust domain Review found the trust domain and the chain flag had each acquired a second, incoherent meaning, so both are narrowed rather than documented. A mapped workload trust domain could only be redundant or fatal: required to equal the SPIFFE authority, a differing string denied every token while a wrong-typed one was ignored. It is no longer mappable. The authority is derived from the identity, always, which is what makes a policy gating the trust boundary read it off the identity it belongs to. `stop_if_present` on a collection truncated a `merge: union` chain, dropped the candidates behind it, and reported the stop as an ordinary miss with a truncated path list. It is rejected there. Each flag is now valid exactly where it means something: the shape flags on a field holding a collection, the chain flag on one holding a single value. The undeclared-anchor warning fired once per request for a condition that is static; it is emitted once at construction and the anchor is still named in the per-request miss event the denial points at. All four presets now decline a present-but-unusable client anchor rather than three of them falling through to `azp`. Two tests close a gap that let stripping `string_only` from any provider preset pass the whole suite. Signed-off-by: Frederico Araujo --- CHANGELOG.md | 2 +- .../identity-jwt/src/claim_map_config.rs | 65 +++++-- .../identity-jwt/src/configured_mapper.rs | 178 +++++------------- builtins/plugins/identity-jwt/src/presets.rs | 51 +++++ .../identity-jwt/src/presets/auth0.json | 7 +- .../identity-jwt/src/presets/keycloak.json | 7 +- .../identity-jwt/src/presets/standard.json | 2 +- 7 files changed, 161 insertions(+), 151 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 280fac3..2ece672 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ 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` now 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 Rust and a rebuild. A field takes an ordered candidate list resolved first-match or unioned, can require an array or a string, can stop the chain on the first claim that exists, and can split a delimited string. Paths address nested claims by dots, with `\.` escaping a dot so a claim whose whole name is a URL is reachable; a colon needs no escaping. **An existing config is unaffected:** naming no mapper resolves to `standard`, which is held to the previous Rust mapper both over a corpus of provider token shapes and over a sweep of every JSON shape each mapped claim can hold, so an upgrading deployment sees the identity it saw before. Setting both fields, naming an unknown preset, writing a malformed path, or pairing a map with a role it does not declare each fail at startup naming the fault, rather than denying every request. A field whose paths all miss is logged at debug with every path it tried, or refuses the token if it declared `on_missing: deny`. (PR_LINK_PLACEHOLDER) +- **Claim mapping is configuration.** The JWT identity plugin's `claim_mapper` now 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 Rust and a rebuild. A field takes an ordered candidate list resolved first-match or unioned, can require an array or a string, can stop the chain on the first claim that exists, and can split a delimited string. A workload's trust domain is always derived from its SPIFFE identity's authority and is deliberately not mappable, so no claim can decouple the trust boundary a policy gates on from the identity it belongs to. Paths address nested claims by dots, with `\.` escaping a dot so a claim whose whole name is a URL is reachable; a colon needs no escaping. **An existing config is unaffected:** naming no mapper resolves to `standard`, which is held to the previous Rust mapper both over a corpus of provider token shapes and over a sweep of every JSON shape each mapped claim can hold, so an upgrading deployment sees the identity it saw before. Setting both fields, naming an unknown preset, writing a malformed path, or pairing a map with a role it does not declare each fail at startup naming the fault, rather than denying every request. A field whose paths all miss is logged at debug with every path it tried, or refuses the token if it declared `on_missing: deny`. (PR_LINK_PLACEHOLDER) - **A policy can gate on which `IdP` minted a token.** A claim map's `claims.include` puts a claim back in the policy-visible bag, registered JWT claims included, so `claims: {include: [iss]}` makes `claim.iss` readable. A deployment trusting several issuers could not express "only tokens from the internal `IdP` may call this tool" before, because the subject claims bag is the only route from a claim to a policy and registered claims were always dropped. `claims.exclude` drops one the other way. (PR_LINK_PLACEHOLDER) diff --git a/builtins/plugins/identity-jwt/src/claim_map_config.rs b/builtins/plugins/identity-jwt/src/claim_map_config.rs index a225ef3..8705176 100644 --- a/builtins/plugins/identity-jwt/src/claim_map_config.rs +++ b/builtins/plugins/identity-jwt/src/claim_map_config.rs @@ -37,7 +37,12 @@ pub const CLIENT_FIELDS: &[&str] = &[ ]; /// Fields a `workload` section may map. -pub const WORKLOAD_FIELDS: &[&str] = &["client_id", "selectors", "spiffe_id", "trust_domain"]; +/// +/// `trust_domain` is deliberately absent. It is the SPIFFE URI's authority, so it +/// is derived from the identity rather than read from a claim: a mapped value +/// that disagreed would let a policy read one workload's trust boundary off +/// another's identity, and one that agreed would be decoration. +pub const WORKLOAD_FIELDS: &[&str] = &["client_id", "selectors", "spiffe_id"]; /// Fields whose destination holds one string, so the first candidate resolving /// to a string wins and `merge: union` is meaningless. @@ -351,12 +356,13 @@ pub struct RoleMapConfig(pub BTreeMap); /// |---|---| /// | `array_only` | Only an array satisfies it; a string is skipped and the next candidate is tried. | /// | `string_only` | Only a string satisfies it; an array is skipped. What a claim read as a delimited value needs, so an array-valued `scope` contributes nothing rather than contributing each element. | -/// | `stop_if_present` | The candidate claims the field the moment its path resolves at all. A present but unusable value then leaves the field empty instead of falling through, which is what a chain that picks the first claim that *exists* and only then requires a shape of it needs. | +/// | `stop_if_present` | The candidate claims the field the moment its path resolves at all, so a present but unusable value leaves the field empty instead of falling through. What a chain that picks the first claim that *exists* and only then requires a string of it needs. Valid only on a field holding one value. | /// /// `array_only` and `string_only` together are rejected: nothing could satisfy /// such a candidate. Both are also rejected on a field that holds one value, -/// which already requires a string. `stop_if_present` is a chain rule rather -/// than a shape rule, so it stays valid on every field. +/// which already requires a string, and `stop_if_present` is rejected on a field +/// that holds a collection. Each flag is valid exactly where it can mean +/// something. /// /// # Escaping, and the quoting trap /// @@ -616,6 +622,23 @@ fn compile_role( } } + // `stop_if_present` decides which whole claim wins a chain, which only a + // field holding one value has. On a collection it would truncate a union + // mid-chain and drop the candidates behind it, so it is rejected there + // rather than given a meaning nobody asked for. + if !SCALAR_FIELDS.contains(interned) + && let Some(candidate) = authored_field + .paths + .iter() + .find(|candidate| candidate.stop_if_present) + { + return Err(format!( + "{qualified}: `stop_if_present` on '{}' needs a field that holds one value, and \ + {qualified} holds a collection", + candidate.path + )); + } + let mut candidates = Vec::with_capacity(authored_field.paths.len()); for candidate in &authored_field.paths { let path = ClaimPath::parse(&candidate.path) @@ -979,35 +1002,45 @@ mod tests { #[test] fn the_candidate_flags_round_trip_and_default_to_unset() { + // Shape flags belong to a collection field, the chain flag to a field + // holding one value, so each is exercised where it is valid. let map = compiled(json!({ "subject": { "permissions": { "paths": [ {"path": "permissions", "array_only": true}, - {"path": "scope", "string_only": true, "stop_if_present": true}, + {"path": "scope", "string_only": true}, "plain", ], "split": "whitespace", } - } + }, + "client": { + "client_id": [{"path": "client_id", "stop_if_present": true}, "azp"], + }, })); - let flags: Vec<(bool, bool, bool)> = map + + let shapes: Vec<(bool, bool)> = map .role(&TokenRole::User) .unwrap() .field("permissions") .unwrap() .candidates() .iter() - .map(|c| (c.array_only(), c.string_only(), c.stop_if_present())) + .map(|c| (c.array_only(), c.string_only())) .collect(); - assert_eq!( - flags, - vec![ - (true, false, false), - (false, true, true), - (false, false, false) - ], - ); + assert_eq!(shapes, vec![(true, false), (false, true), (false, false)]); + + let chain: Vec = map + .role(&TokenRole::Client) + .unwrap() + .field("client_id") + .unwrap() + .candidates() + .iter() + .map(CompiledCandidate::stop_if_present) + .collect(); + assert_eq!(chain, vec![true, false]); } /// Nothing can be both an array and a string, so a candidate declaring both diff --git a/builtins/plugins/identity-jwt/src/configured_mapper.rs b/builtins/plugins/identity-jwt/src/configured_mapper.rs index 51de8b8..00aeeba 100644 --- a/builtins/plugins/identity-jwt/src/configured_mapper.rs +++ b/builtins/plugins/identity-jwt/src/configured_mapper.rs @@ -150,12 +150,10 @@ fn resolve_collection(field: &CompiledField, claims: &ClaimMap) -> FieldOutcome continue; }; if !contribute(value, candidate, field.split(), &mut values) { - // A present value the candidate cannot use normally leaves the chain - // running. `stop_if_present` is for a chain that picks the first - // claim that exists and then requires a shape of it. - if candidate.stop_if_present() { - break; - } + // A present value the candidate cannot use leaves the chain running, + // which is what the Rust mapper's collection accessors do. Only a + // field holding one value can declare otherwise, so there is no + // `stop_if_present` case here. continue; } resolved = true; @@ -210,7 +208,6 @@ struct Diagnostics { missed: Vec<(&'static str, Vec)>, empty: Vec<&'static str>, denied: Vec<&'static str>, - undeclared_anchor: Option<&'static str>, } impl Diagnostics { @@ -220,7 +217,6 @@ impl Diagnostics { missed: Vec::new(), empty: Vec::new(), denied: Vec::new(), - undeclared_anchor: None, } } @@ -253,10 +249,10 @@ impl Diagnostics { /// /// Recorded so the miss event names it: without this the denial says to raise /// the log level and the raised log says nothing, because a field nothing - /// asked for never reaches the resolution path. + /// asked for never reaches the resolution path. The condition is static, so + /// the loud warning belongs at construction rather than once per request. fn record_undeclared_anchor(&mut self, name: &'static str) { self.missed.push((name, Vec::new())); - self.undeclared_anchor = Some(name); } /// Whether a field declared `on_missing: deny` and did not resolve. @@ -286,14 +282,6 @@ impl Diagnostics { "claim map: these fields resolved to an empty collection", ); } - if let Some(anchor) = self.undeclared_anchor { - tracing::warn!( - role = self.role, - field = anchor, - "claim map: the section declares no path for its anchor, so every token is \ - declined", - ); - } if !self.denied.is_empty() { tracing::warn!( role = self.role, @@ -427,9 +415,6 @@ impl ClaimMapper for ConfiguredClaimMap { }); let client_id = scalar(section, "client_id", claims, &mut diag, accept_any); let selectors = collection(section, "selectors", claims, &mut diag); - let mapped_trust_domain = section - .field("trust_domain") - .map(|_| scalar(section, "trust_domain", claims, &mut diag, accept_any)); diag.emit(); if diag.declined() { @@ -437,27 +422,7 @@ impl ClaimMapper for ConfiguredClaimMap { } let spiffe_id = spiffe_id?; - let derived = trust_domain_of(&spiffe_id); - // The trust domain is the SPIFFE URI's authority. A declared path may - // name where to read it, but it cannot disagree with the identity it is - // the authority of: a policy gating the trust boundary would be reading - // one workload's domain off another's identity. - let trust_domain = match mapped_trust_domain.flatten() { - Some(mapped) if Some(&mapped) == derived.as_ref() => Some(mapped), - Some(mapped) => { - tracing::warn!( - role = "workload", - // Debug-escaped, not Display: this value comes from the token, - // and an unescaped newline in it would forge a log line. - mapped = ?mapped, - derived = derived.as_deref().unwrap_or(""), - "claim map: declining, the mapped trust domain disagrees with the SPIFFE ID \ - authority", - ); - return None; - }, - None => derived, - }; + let trust_domain = trust_domain_of(&spiffe_id); Some(WorkloadIdentity { spiffe_id: Some(spiffe_id), @@ -498,6 +463,10 @@ mod tests { value.as_object().unwrap().clone().into_iter().collect() } + fn config(map: Value) -> ClaimMapConfig { + serde_json::from_value(map).expect("the map deserializes") + } + fn mapper(map: Value) -> ConfiguredClaimMap { let config: ClaimMapConfig = serde_json::from_value(map).expect("the map deserializes"); ConfiguredClaimMap::new(config.compile().expect("the map compiles")) @@ -1141,74 +1110,28 @@ mod tests { ); } - /// The trust domain is the SPIFFE URI's authority. A declared path can name - /// where to read it, but it cannot disagree with the identity it is the - /// authority of, and it cannot suppress the derivation by resolving nothing. + /// The trust domain is the SPIFFE URI's authority, always. It is not a + /// mappable field, so no claim can decouple the trust boundary a policy gates + /// on from the identity it belongs to. #[test] - fn the_trust_domain_always_matches_the_spiffe_authority() { - let spiffe = "spiffe://corp.example/ns/a/sa/b"; - - let derived = mapper(json!({"workload": {"spiffe_id": "sub"}})) - .map_workload(&claims(json!({"sub": spiffe}))) - .expect("an unmapped trust domain is derived"); - assert_eq!(derived.trust_domain.as_deref(), Some("corp.example")); - - let declared = json!({"workload": {"spiffe_id": "sub", "trust_domain": "td"}}); - - let agreeing = mapper(declared.clone()) - .map_workload(&claims(json!({"sub": spiffe, "td": "corp.example"}))) - .expect("a mapped trust domain that agrees is used"); - assert_eq!(agreeing.trust_domain.as_deref(), Some("corp.example")); - - let unresolved = mapper(declared.clone()) - .map_workload(&claims(json!({"sub": spiffe}))) - .expect("a declared path that resolves nothing falls back to derivation"); + fn the_trust_domain_is_always_the_spiffe_authority() { + let workload = mapper(json!({"workload": {"spiffe_id": "sub"}})) + .map_workload(&claims(json!({ + "sub": "spiffe://corp.example/ns/a/sa/b", + "trust_domain": "attacker.example", + }))) + .expect("the token resolves"); assert_eq!( - unresolved.trust_domain.as_deref(), + workload.trust_domain.as_deref(), Some("corp.example"), - "a declared path must not suppress the derivable authority" + "a trust_domain claim must not displace the SPIFFE authority" ); - let (disagreeing, events) = capturing(|| { - mapper(declared).map_workload(&claims(json!({"sub": spiffe, "td": "attacker.example"}))) - }); - assert!( - disagreeing.is_none(), - "a trust domain that contradicts the SPIFFE authority must not reach a policy \ - gating the trust boundary" - ); - let warning = events.matching("disagrees with the SPIFFE ID authority"); - let event = warning.first().expect("the disagreement is named"); - assert!(event.contains("attacker.example"), "{event}"); - assert!(event.contains("corp.example"), "{event}"); - } - - /// The trust-domain warning is the one diagnostic carrying a value taken from - /// the token rather than from operator config, so it is the one place a claim - /// could forge a log line. It is Debug-escaped for that reason. - #[test] - fn a_token_derived_value_reaches_the_log_escaped() { - let (declined, events) = capturing(|| { - mapper(json!({ - "workload": {"spiffe_id": "sub", "trust_domain": "td"} - })) - .map_workload(&claims(json!({ - "sub": "spiffe://corp.example/w", - "td": "attacker\n2026-08-20 WARN forged log line", - }))) - }); - assert!(declined.is_none(), "a disagreeing trust domain declines"); - - let warning = events.matching("disagrees with the SPIFFE ID authority"); - let event = warning.first().expect("the disagreement is logged"); - assert!( - !event.contains('\n'), - "a claim value must not put a raw newline in a log record: {event:?}" - ); - assert!( - event.contains("\\n"), - "the newline should survive as an escape rather than vanish: {event:?}" - ); + let err = config(json!({"workload": {"spiffe_id": "sub", "trust_domain": "td"}})) + .compile() + .expect_err("trust_domain is not a mappable field"); + assert!(err.contains("trust_domain"), "{err}"); + assert!(err.contains("workload"), "{err}"); } /// The mirror of `array_only`: what a claim read as a delimited string needs, @@ -1275,31 +1198,26 @@ mod tests { assert_eq!(usable.client_id, "explicit"); } - /// The same rule on a collection field. + /// The chain flag decides which whole claim wins, which only a field holding + /// one value has. On a collection it would truncate a union and drop the + /// candidates behind it, so it is a construction error there. #[test] - fn stop_if_present_also_ends_a_collection_chain() { - let map = json!({ + fn stop_if_present_is_rejected_on_a_field_holding_a_collection() { + let config: ClaimMapConfig = serde_json::from_value(json!({ "subject": { "id": "sub", - "roles": [{"path": "primary", "stop_if_present": true}, "backup"], + "roles": { + "paths": ["a", {"path": "b", "stop_if_present": true}, "c"], + "merge": "union", + }, } - }); - // An object is unusable for a collection field. A bare string is not: - // it contributes as one element unless the candidate is array-only. - let stopped = mapper(map.clone()) - .map_subject(&claims(json!({ - "sub": "alice", "primary": {"k": "v"}, "backup": ["fallback"], - }))) - .unwrap(); - assert!( - stopped.roles.is_empty(), - "a present but unusable primary claims the field" - ); - - let fell_through = mapper(map) - .map_subject(&claims(json!({"sub": "alice", "backup": ["fallback"]}))) - .unwrap(); - assert_eq!(sorted(&fell_through.roles), vec!["fallback"]); + })) + .expect("the shape deserializes"); + let err = config + .compile() + .expect_err("a collection field cannot stop on presence"); + assert!(err.contains("subject.roles"), "{err}"); + assert!(err.contains("stop_if_present"), "{err}"); } /// A section that declares no path for its anchor denies every token. The @@ -1326,12 +1244,14 @@ mod tests { } }); assert!(!identity, "{role}: an undeclared anchor declines"); - let warning = events.matching("declares no path for its anchor"); - let event = warning + // The loud warning is emitted once at construction, since the + // condition is static. Per request the anchor is named in the miss + // event, which is what the denial reason points an operator at. + let misses = events.matching("no candidate resolved"); + let event = misses .first() .unwrap_or_else(|| panic!("{role}: the undeclared anchor must be named")); assert!(event.contains(anchor), "{role}: {event}"); - assert!(event.contains("WARN"), "{role}: {event}"); } } diff --git a/builtins/plugins/identity-jwt/src/presets.rs b/builtins/plugins/identity-jwt/src/presets.rs index 072f262..e7117de 100644 --- a/builtins/plugins/identity-jwt/src/presets.rs +++ b/builtins/plugins/identity-jwt/src/presets.rs @@ -424,6 +424,57 @@ mod tests { assert!(cognito_client.authorized_audiences.is_empty()); } + /// Every preset reads `scope` as a delimited string, so an array-valued + /// `scope` must contribute nothing rather than contributing each element as a + /// permission. Without this, dropping `string_only` from a provider preset + /// would fail no test, and only the standard preset's parity gate would + /// notice. + #[test] + fn no_preset_grants_permissions_from_an_array_valued_scope() { + let array_scope = json!({ + "sub": "alice", + "client_id": "svc", + "azp": "svc", + "scope": ["admin", "root"], + }); + for name in names() { + let map = mapper(name); + + let subject = map + .map_subject(&claims(array_scope.clone())) + .unwrap_or_else(|| panic!("'{name}': the subject resolves")); + assert!( + subject.permissions.is_empty(), + "'{name}': an array-valued scope granted {:?} as permissions", + sorted(&subject.permissions) + ); + + let client = map + .map_client(&claims(array_scope.clone())) + .unwrap_or_else(|| panic!("'{name}': the client resolves")); + assert!( + client.authorized_scopes.is_empty(), + "'{name}': an array-valued scope granted {:?} as authorized scopes", + client.authorized_scopes + ); + } + } + + /// The four presets have to agree on the same token. A present but unusable + /// anchor declines everywhere rather than falling through to the next + /// candidate in some presets and not others. + #[test] + fn every_preset_declines_a_present_but_unusable_client_anchor() { + for name in names() { + let declined = + mapper(name).map_client(&claims(json!({"client_id": null, "azp": "svc-billing"}))); + assert!( + declined.is_none(), + "'{name}': a null client_id must not fall through to azp" + ); + } + } + /// A field no preset declares is still reachable, which is the point of the /// map: no provider mints `client_name`, so only a hand-written map fills it. #[test] diff --git a/builtins/plugins/identity-jwt/src/presets/auth0.json b/builtins/plugins/identity-jwt/src/presets/auth0.json index 74cc003..94ee576 100644 --- a/builtins/plugins/identity-jwt/src/presets/auth0.json +++ b/builtins/plugins/identity-jwt/src/presets/auth0.json @@ -1,5 +1,5 @@ { - "description": "Auth0 subject, permissions and client anchor. Covers sub, the permissions array ahead of scope, and client_id / azp as the client anchor: azp is what Auth0's default access-token profile emits and client_id what the RFC 9068 profile emits. permissions is doubly opt-in at the tenant, needing both RBAC and Add Permissions in the Access Token, and enabling it switches the token dialect; without it, scope carries the grant. Deliberately omitted: roles and teams, because Auth0's restricted-claim list forbids roles, groups, permissions and entitlements as bare custom-claim names, so roles can only arrive URL-namespaced under a deployment's own namespace, which no preset can know. Write that path in a hand-written claim_map, escaping its dots. sub is not a client-id candidate: an Auth0 machine-to-machine sub is @clients and stripping that suffix is a value transform this plugin does not do.", + "description": "Auth0 subject, permissions and client anchor. Covers sub, the permissions array ahead of scope, and client_id / azp as the client anchor: azp is what Auth0's default access-token profile emits and client_id what the RFC 9068 profile emits. permissions is doubly opt-in at the tenant, needing both RBAC and Add Permissions in the Access Token, and enabling it switches the token dialect; without it, scope carries the grant. Deliberately omitted: roles and teams, because Auth0's restricted-claim list forbids roles, groups, permissions and entitlements as bare custom-claim names, so roles can only arrive URL-namespaced under a deployment's own namespace, which no preset can know. Write that path in a hand-written claim_map, escaping its dots. sub is not a client-id candidate: an Auth0 machine-to-machine sub is @clients and stripping that suffix is a value transform this plugin does not do. A present but unusable client_id declines rather than falling through to the next anchor candidate, matching the standard preset.", "claim_map": { "subject": { "id": "sub", @@ -19,7 +19,10 @@ }, "client": { "client_id": [ - "client_id", + { + "path": "client_id", + "stop_if_present": true + }, "azp" ], "authorized_scopes": { diff --git a/builtins/plugins/identity-jwt/src/presets/keycloak.json b/builtins/plugins/identity-jwt/src/presets/keycloak.json index 21ff1a2..4305fd0 100644 --- a/builtins/plugins/identity-jwt/src/presets/keycloak.json +++ b/builtins/plugins/identity-jwt/src/presets/keycloak.json @@ -1,5 +1,5 @@ { - "description": "Keycloak realm roles and scopes. Works on access tokens: both role mappers are registered with idToken=false, so a resolver pointed at an ID token gets no roles. Covers realm_access.roles into subject roles, scope into permissions, and client_id / azp / clientId as the client anchor, clientId being pre-2023 Keycloak's camelCase spelling. Deliberately omitted: per-client roles, because resource_access..roles embeds the client id an operator chose and no shipped preset can know it; and teams, because Keycloak's default groups claim comes from the microprofile-jwt scope and holds realm roles rather than groups, so mapping it would fill teams with roles. Real group paths need a Group Membership mapper whose claim name the admin types, which has no default. Both are reachable with a hand-written claim_map. A realm running the lightweight-access-token policy strips realm_access unless each mapper opts in, which leaves roles empty; on_missing: deny in a hand-written map is how to make that loud.", + "description": "Keycloak realm roles and scopes. Works on access tokens: both role mappers are registered with idToken=false, so a resolver pointed at an ID token gets no roles. Covers realm_access.roles into subject roles, scope into permissions, and client_id / azp / clientId as the client anchor, clientId being pre-2023 Keycloak's camelCase spelling. Deliberately omitted: per-client roles, because resource_access..roles embeds the client id an operator chose and no shipped preset can know it; and teams, because Keycloak's default groups claim comes from the microprofile-jwt scope and holds realm roles rather than groups, so mapping it would fill teams with roles. Real group paths need a Group Membership mapper whose claim name the admin types, which has no default. Both are reachable with a hand-written claim_map. A realm running the lightweight-access-token policy strips realm_access unless each mapper opts in, which leaves roles empty; on_missing: deny in a hand-written map is how to make that loud. A present but unusable client_id declines rather than falling through to the next anchor candidate, matching the standard preset.", "claim_map": { "subject": { "id": "sub", @@ -21,7 +21,10 @@ }, "client": { "client_id": [ - "client_id", + { + "path": "client_id", + "stop_if_present": true + }, "azp", "clientId" ], diff --git a/builtins/plugins/identity-jwt/src/presets/standard.json b/builtins/plugins/identity-jwt/src/presets/standard.json index 226b169..6be4a99 100644 --- a/builtins/plugins/identity-jwt/src/presets/standard.json +++ b/builtins/plugins/identity-jwt/src/presets/standard.json @@ -1,5 +1,5 @@ { - "description": "Standard OIDC claim shape, and what an absent claim_mapper resolves to. Reads sub, roles, permissions or scope, and teams or groups for a subject; client_id or azp, client_name, authorized_scopes or scope, aud and roles for a client; and a SPIFFE ID from sub or spiffe_id for a workload. Equivalent to the built-in Rust standard mapper, which a corpus-backed test and a shape sweep hold it to: a deployment that names no mapper sees exactly what it saw before. Every candidate flag here encodes an accessor the Rust mapper uses, so none of them is cosmetic. The collection candidates are array-only because the mapper reads them with an array accessor, so a string-valued roles claim contributes nothing and falls through where there is a next candidate. The scope candidates are string-only because the mapper reads scope with a string accessor, so an array-valued scope must contribute nothing rather than contributing each element as a permission. The client_id candidate stops if present because the mapper picks the first anchor key that exists and only then requires a string of it, so a present-but-unusable client_id denies rather than falling through to azp.", + "description": "Standard OIDC claim shape, and what an absent claim_mapper resolves to. Reads sub, roles, permissions or scope, and teams or groups for a subject; client_id or azp, client_name, authorized_scopes or scope, aud and roles for a client; and a SPIFFE ID from sub or spiffe_id for a workload, whose trust domain is always derived from that identity's authority rather than read from a claim. Equivalent to the built-in Rust standard mapper, which a corpus-backed test and a shape sweep hold it to: a deployment that names no mapper sees exactly what it saw before. Every candidate flag here encodes an accessor the Rust mapper uses, so none of them is cosmetic. The collection candidates are array-only because the mapper reads them with an array accessor, so a string-valued roles claim contributes nothing and falls through where there is a next candidate. The scope candidates are string-only because the mapper reads scope with a string accessor, so an array-valued scope must contribute nothing rather than contributing each element as a permission. The client_id candidate stops if present because the mapper picks the first anchor key that exists and only then requires a string of it, so a present-but-unusable client_id denies rather than falling through to azp.", "claim_map": { "subject": { "id": "sub", From 474bf26e48eeb5d15057975c70437eb6093db954 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 21:21:31 -0400 Subject: [PATCH 13/27] docs: link the changelog entries to their pull request Signed-off-by: Frederico Araujo --- CHANGELOG.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2ece672..ab2c9fd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,11 +17,11 @@ 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` now 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 Rust and a rebuild. A field takes an ordered candidate list resolved first-match or unioned, can require an array or a string, can stop the chain on the first claim that exists, and can split a delimited string. A workload's trust domain is always derived from its SPIFFE identity's authority and is deliberately not mappable, so no claim can decouple the trust boundary a policy gates on from the identity it belongs to. Paths address nested claims by dots, with `\.` escaping a dot so a claim whose whole name is a URL is reachable; a colon needs no escaping. **An existing config is unaffected:** naming no mapper resolves to `standard`, which is held to the previous Rust mapper both over a corpus of provider token shapes and over a sweep of every JSON shape each mapped claim can hold, so an upgrading deployment sees the identity it saw before. Setting both fields, naming an unknown preset, writing a malformed path, or pairing a map with a role it does not declare each fail at startup naming the fault, rather than denying every request. A field whose paths all miss is logged at debug with every path it tried, or refuses the token if it declared `on_missing: deny`. (PR_LINK_PLACEHOLDER) +- **Claim mapping is configuration.** The JWT identity plugin's `claim_mapper` now 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 Rust and a rebuild. A field takes an ordered candidate list resolved first-match or unioned, can require an array or a string, can stop the chain on the first claim that exists, and can split a delimited string. A workload's trust domain is always derived from its SPIFFE identity's authority and is deliberately not mappable, so no claim can decouple the trust boundary a policy gates on from the identity it belongs to. Paths address nested claims by dots, with `\.` escaping a dot so a claim whose whole name is a URL is reachable; a colon needs no escaping. **An existing config is unaffected:** naming no mapper resolves to `standard`, which is held to the previous Rust mapper both over a corpus of provider token shapes and over a sweep of every JSON shape each mapped claim can hold, so an upgrading deployment sees the identity it saw before. Setting both fields, naming an unknown preset, writing a malformed path, or pairing a map with a role it does not declare each fail at startup naming the fault, rather than denying every request. A field whose paths all miss is logged at debug with every path it tried, or refuses the token if it declared `on_missing: deny`. ([#31](https://github.com/praxis-proxy/policy/pull/31)) -- **A policy can gate on which `IdP` minted a token.** A claim map's `claims.include` puts a claim back in the policy-visible bag, registered JWT claims included, so `claims: {include: [iss]}` makes `claim.iss` readable. A deployment trusting several issuers could not express "only tokens from the internal `IdP` may call this tool" before, because the subject claims bag is the only route from a claim to a policy and registered claims were always dropped. `claims.exclude` drops one the other way. (PR_LINK_PLACEHOLDER) +- **A policy can gate on which `IdP` minted a token.** A claim map's `claims.include` puts a claim back in the policy-visible bag, registered JWT claims included, so `claims: {include: [iss]}` makes `claim.iss` readable. A deployment trusting several issuers could not express "only tokens from the internal `IdP` may call this tool" before, because the subject claims bag is the only route from a claim to a policy and registered claims were always dropped. `claims.exclude` drops one the other way. ([#31](https://github.com/praxis-proxy/policy/pull/31)) -- **Each shipped preset records what it deliberately omits.** Two of the three providers put their roles claim somewhere no preset can name: Auth0 forbids a bare `roles` claim so roles arrive under a deployment's own URL namespace, and Keycloak's per-client roles sit under the client id an operator chose. Both need a hand-written `claim_map`. Presets also leave a field empty rather than filling it with the wrong concept, and Keycloak's `groups` claim holds realm roles where Cognito's `cognito:roles` holds IAM role ARNs, because a field quietly filled with the wrong thing gives an operator no reason to look. Each preset's description names its gaps and which of its claims are opt-in at the provider. (PR_LINK_PLACEHOLDER) +- **Each shipped preset records what it deliberately omits.** Two of the three providers put their roles claim somewhere no preset can name: Auth0 forbids a bare `roles` claim so roles arrive under a deployment's own URL namespace, and Keycloak's per-client roles sit under the client id an operator chose. Both need a hand-written `claim_map`. Presets also leave a field empty rather than filling it with the wrong concept, and Keycloak's `groups` claim holds realm roles where Cognito's `cognito:roles` holds IAM role ARNs, because a field quietly filled with the wrong thing gives an operator no reason to look. Each preset's description names its gaps and which of its claims are 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.` booleans. The flattened boolean keys are unchanged. ([#7](https://github.com/praxis-proxy/policy/pull/7)) From a8a38c2781cbef6312d376aa8b6826274ab9c54a Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 21:23:10 -0400 Subject: [PATCH 14/27] docs: mark the claim-mapping plan completed Signed-off-by: Frederico Araujo --- .../2026-08-20-001-feat-configurable-claim-mapping-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/plans/2026-08-20-001-feat-configurable-claim-mapping-plan.md b/docs/plans/2026-08-20-001-feat-configurable-claim-mapping-plan.md index 7da7496..d4bcd6b 100644 --- a/docs/plans/2026-08-20-001-feat-configurable-claim-mapping-plan.md +++ b/docs/plans/2026-08-20-001-feat-configurable-claim-mapping-plan.md @@ -1,7 +1,7 @@ --- title: "feat: Configurable claim mapping for the JWT identity plugin" type: feat -status: active +status: completed date: 2026-08-20 origin: docs/brainstorms/2026-08-20-configurable-claim-mapping-requirements.md --- From f0a89e1191f21581c8062d85c347918efd76551b Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 21:36:57 -0400 Subject: [PATCH 15/27] fix(identity-jwt)!: reject unknown keys in the resolver and issuer config Every field in both structs is optional or defaulted, so a misspelling deserialized to the default and took effect silently. `claim_maps` left the resolver on the standard preset while the operator believed their map was live, and a misspelled `audiences` was worse than confusing: the field defaults to an empty list, and an empty list turns audience validation off, so a typo silently accepted tokens minted for any audience. `deny_unknown_fields` on both, which is the reason the policy parser already carries it. **Breaking** for a config that passes a key this plugin does not read: it now fails at load naming the key, where before it was ignored. Signed-off-by: Frederico Araujo --- CHANGELOG.md | 2 +- builtins/plugins/identity-jwt/src/config.rs | 7 ++ builtins/plugins/identity-jwt/src/resolver.rs | 66 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ab2c9fd..ef8c010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ 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` now 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 Rust and a rebuild. A field takes an ordered candidate list resolved first-match or unioned, can require an array or a string, can stop the chain on the first claim that exists, and can split a delimited string. A workload's trust domain is always derived from its SPIFFE identity's authority and is deliberately not mappable, so no claim can decouple the trust boundary a policy gates on from the identity it belongs to. Paths address nested claims by dots, with `\.` escaping a dot so a claim whose whole name is a URL is reachable; a colon needs no escaping. **An existing config is unaffected:** naming no mapper resolves to `standard`, which is held to the previous Rust mapper both over a corpus of provider token shapes and over a sweep of every JSON shape each mapped claim can hold, so an upgrading deployment sees the identity it saw before. Setting both fields, naming an unknown preset, writing a malformed path, or pairing a map with a role it does not declare each fail at startup naming the fault, rather than denying every request. A field whose paths all miss is logged at debug with every path it tried, or refuses the token if it declared `on_missing: deny`. ([#31](https://github.com/praxis-proxy/policy/pull/31)) +- **Claim mapping is configuration.** The JWT identity plugin's `claim_mapper` now 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 Rust and a rebuild. A field takes an ordered candidate list resolved first-match or unioned, can require an array or a string, can stop the chain on the first claim that exists, and can split a delimited string. A workload's trust domain is always derived from its SPIFFE identity's authority and is deliberately not mappable, so no claim can decouple the trust boundary a policy gates on from the identity it belongs to. Paths address nested claims by dots, with `\.` escaping a dot so a claim whose whole name is a URL is reachable; a colon needs no escaping. **An existing config is unaffected:** naming no mapper resolves to `standard`, which is held to the previous Rust mapper both over a corpus of provider token shapes and over a sweep of every JSON shape each mapped claim can hold, so an upgrading deployment sees the identity it saw before. Setting both fields, naming an unknown preset, writing a malformed path, misspelling any config key, or pairing a map with a role it does not declare each fail at startup naming the fault, rather than denying every request. A field whose paths all miss is logged at debug with every path it tried, or refuses the token if it declared `on_missing: deny`. ([#31](https://github.com/praxis-proxy/policy/pull/31)) - **A policy can gate on which `IdP` minted a token.** A claim map's `claims.include` puts a claim back in the policy-visible bag, registered JWT claims included, so `claims: {include: [iss]}` makes `claim.iss` readable. A deployment trusting several issuers could not express "only tokens from the internal `IdP` may call this tool" before, because the subject claims bag is the only route from a claim to a policy and registered claims were always dropped. `claims.exclude` drops one the other way. ([#31](https://github.com/praxis-proxy/policy/pull/31)) diff --git a/builtins/plugins/identity-jwt/src/config.rs b/builtins/plugins/identity-jwt/src/config.rs index 4d02b2e..06e78b0 100644 --- a/builtins/plugins/identity-jwt/src/config.rs +++ b/builtins/plugins/identity-jwt/src/config.rs @@ -28,7 +28,13 @@ use crate::claim_map_config::ClaimMapConfig; /// expects multiple inbound tokens — e.g. user JWT in /// `X-User-Token`, OAuth client token in `Authorization`, and a /// SPIFFE JWT-SVID in `X-Workload-Token`. +/// +/// Unknown keys are rejected. Every field here is optional or defaulted, so a +/// misspelling would otherwise deserialize to the default and take effect +/// silently: `claim_maps` would leave the resolver on the standard preset while +/// the operator believed their map was live. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct JwtIdentityResolverConfig { /// One or more trusted issuers. At least one required. pub trusted_issuers: Vec, @@ -104,6 +110,7 @@ fn default_header() -> String { /// One issuer's config — issuer URL, audiences, decoding key /// source, accepted algorithms. #[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] pub struct TrustedIssuerConfig { /// Expected `iss` claim value. pub issuer: String, diff --git a/builtins/plugins/identity-jwt/src/resolver.rs b/builtins/plugins/identity-jwt/src/resolver.rs index 9ea24fc..e4add76 100644 --- a/builtins/plugins/identity-jwt/src/resolver.rs +++ b/builtins/plugins/identity-jwt/src/resolver.rs @@ -928,6 +928,72 @@ mod tests { /// The two mapper settings are alternatives, not layers, so setting both is /// a mistake with no coherent reading. The message names both. + /// Every field in this config is optional or defaulted, so a misspelling + /// would deserialize to the default and take effect silently. `claim_maps` + /// is the one that matters most: the resolver would stay on the standard + /// preset while the operator believed their map was live. + #[test] + fn new_rejects_a_misspelled_config_key_and_names_it() { + for typo in [ + "claim_maps", + "claim_mappers", + "roles", + "headers", + "trusted_issuer", + ] { + let err = build_err(json!({typo: "whatever"})); + assert!( + err.contains(typo), + "a misspelled `{typo}` must be named, not ignored: {err}" + ); + } + } + + /// The same hole one level down, and this one is a validation bypass rather + /// than a surprise: `audiences` is defaulted and an empty list turns audience + /// checking off, so a misspelling would silently accept a token minted for + /// any audience. + #[test] + fn new_rejects_a_misspelled_issuer_key_rather_than_dropping_audience_validation() { + let err = format!( + "{}", + JwtIdentityResolver::new(cfg_with_config( + "jwt", + json!({ + "trusted_issuers": [{ + "issuer": "https://idp.example.com", + "audience": ["my-api"], + "algorithms": ["HS256"], + "decoding_key": { "kind": "secret", "secret": "x" }, + }], + }), + )) + .expect_err("a misspelled `audiences` must not silently disable aud validation") + ); + assert!(err.contains("audience"), "{err}"); + } + + /// The rejection must not be so eager that a valid config stops building. + #[test] + fn every_documented_config_key_is_still_accepted() { + JwtIdentityResolver::new(cfg_with_config( + "jwt", + json!({ + "trusted_issuers": [{ + "issuer": "https://idp.example.com", + "audiences": ["my-api"], + "algorithms": ["HS256"], + "decoding_key": { "kind": "secret", "secret": "x" }, + "leeway_seconds": 30, + }], + "role": "client", + "header": "X-Client-Token", + "claim_mapper": "keycloak", + }), + )) + .expect("every documented key together must still build"); + } + #[test] fn new_rejects_both_claim_mapper_and_claim_map() { let err = build_err(json!({ From fb6826a463a328a41d9508166a677fcae2f6e0ee Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 21:55:23 -0400 Subject: [PATCH 16/27] refactor(identity-jwt): close the review's coverage and surface residuals Tests for what was unexercised: the remaining malformed-candidate shapes, a malformed `claims` block, `on_missing: deny` on a field holding one value, and Keycloak's camelCase `clientId` tail candidate. Each provider preset's candidates are now pinned in order, which only the standard preset had through its parity gate. Fallback coverage is asserted on the claims an entry carries rather than on its name, with each chain's accept rule written down, so an entry edited until it no longer reaches its branch fails instead of passing quietly. A test tying `SCALAR_FIELDS` to the fields the mapper resolves as one value found a dead `trust_domain` entry left behind when that field stopped being mappable. A malformed `claims` block now names the field, matching every other error in the module. Diagnostic field lists are emitted as arrays rather than pre-joined strings, so a JSON subscriber does not have to re-split them. Path rendering moved off the resolving path: a field's candidates are counted while resolving and rendered only when a miss is recorded, so escaping no longer costs a request that resolved. `SplitMode` is `#[non_exhaustive]`, which its own documentation already assumed. The raw preset table and the authored field types are no longer public, since nothing outside the crate can use them. Signed-off-by: Frederico Araujo --- .../identity-jwt/src/claim_map_config.rs | 132 +++++++++++++++--- .../identity-jwt/src/configured_mapper.rs | 100 +++++++++---- builtins/plugins/identity-jwt/src/lib.rs | 2 +- builtins/plugins/identity-jwt/src/presets.rs | 76 +++++++++- .../tests/standard_preset_equivalence.rs | 81 ++++++++++- 5 files changed, 339 insertions(+), 52 deletions(-) diff --git a/builtins/plugins/identity-jwt/src/claim_map_config.rs b/builtins/plugins/identity-jwt/src/claim_map_config.rs index 8705176..100ee55 100644 --- a/builtins/plugins/identity-jwt/src/claim_map_config.rs +++ b/builtins/plugins/identity-jwt/src/claim_map_config.rs @@ -46,13 +46,7 @@ pub const WORKLOAD_FIELDS: &[&str] = &["client_id", "selectors", "spiffe_id"]; /// Fields whose destination holds one string, so the first candidate resolving /// to a string wins and `merge: union` is meaningless. -const SCALAR_FIELDS: &[&str] = &[ - "client_id", - "client_name", - "id", - "spiffe_id", - "trust_domain", -]; +const SCALAR_FIELDS: &[&str] = &["client_id", "client_name", "id", "spiffe_id"]; /// How a field combines its resolving candidates. #[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -71,6 +65,7 @@ pub enum MergeMode { /// without invalidating a config anyone has already written. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] +#[non_exhaustive] pub enum SplitMode { /// Split on runs of whitespace, which is how all three researched providers /// delimit `scope`. @@ -90,37 +85,37 @@ pub enum OnMissing { /// One authored candidate: a path, plus the rules for what satisfies it. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct Candidate { +pub(crate) struct Candidate { /// The path as authored. - pub path: String, + pub(crate) path: String, /// Require an array. A string is then unusable and the chain continues. - pub array_only: bool, + pub(crate) array_only: bool, /// Require a string. An array is then unusable and the chain continues. /// /// The mirror of `array_only`, and what a claim read as a delimited string /// needs: an array-valued `scope` must contribute nothing rather than /// contributing each element. - pub string_only: bool, + pub(crate) string_only: bool, /// End the chain as soon as this path resolves to anything, usable or not. /// /// The default is to keep looking when a value is present but the wrong /// shape. A chain that picks the first claim that *exists* and then requires /// a shape of it needs this instead, so a present-but-unusable value denies /// rather than falling through to a later candidate. - pub stop_if_present: bool, + pub(crate) stop_if_present: bool, } /// One authored field: its ordered candidates and its options. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct FieldMap { +pub(crate) struct FieldMap { /// Candidates in the order the author wrote them. - pub paths: Vec, + pub(crate) paths: Vec, /// How resolving candidates combine. - pub merge: MergeMode, + pub(crate) merge: MergeMode, /// How a resolved string is broken into elements. - pub split: Option, + pub(crate) split: Option, /// What happens when no candidate resolves. - pub on_missing: OnMissing, + pub(crate) on_missing: OnMissing, } const FIELD_OPTIONS: &[&str] = &["merge", "on_missing", "paths", "split"]; @@ -215,7 +210,7 @@ impl FieldMap { /// Returns a message naming the field when the value is not one of the three /// forms, when an option or candidate key is unrecognized, or when `paths` /// is missing. - pub fn from_value(field: &str, value: &Value) -> Result { + pub(crate) fn from_value(field: &str, value: &Value) -> Result { match value { Value::String(path) => Ok(Self { paths: vec![Candidate { @@ -398,8 +393,11 @@ pub struct ClaimMapConfig { #[serde(default)] pub workload: Option, /// Overrides for the inferred claims-bag exclusions. + /// + /// Read as a raw value so a malformed one names the field, the same reason + /// the role sections are. #[serde(default)] - pub claims: Option, + pub claims: Option, } // ===================================================================== @@ -537,7 +535,12 @@ impl ClaimMapConfig { /// candidate list, `merge: union` on a field holding one string, and a claim /// named in both `exclude` and `include`. pub fn compile(&self) -> Result { - let claims = self.claims.clone().unwrap_or_default(); + let claims: ClaimsOverrides = match self.claims.as_ref() { + Some(value) => serde_json::from_value(value.clone()).map_err(|e| { + format!("claims: expected `exclude` and `include` lists of claim names: {e}") + })?, + None => ClaimsOverrides::default(), + }; for claim in &claims.include { if claims.exclude.iter().any(|excluded| excluded == claim) { return Err(format!( @@ -1066,6 +1069,95 @@ mod tests { assert!(err.contains("arrayonly"), "{err}"); } + /// The remaining shape-rejection branches. Each names the field rather than + /// dumping a serde type error, which is the whole reason the field forms are + /// read from the JSON value by hand. + #[test] + fn every_malformed_candidate_shape_names_the_field() { + for (label, value) in [ + ( + "a non-string path", + json!({"subject": {"roles": [{"path": 42}]}}), + ), + ( + "a numeric candidate", + json!({"subject": {"roles": ["ok", 42]}}), + ), + ("a boolean candidate", json!({"subject": {"roles": [true]}})), + ("a null candidate", json!({"subject": {"roles": [null]}})), + ( + "a numeric paths value", + json!({"subject": {"roles": {"paths": 42}}}), + ), + ( + "an object paths value", + json!({"subject": {"roles": {"paths": {"path": "roles"}}}}), + ), + ] { + let err = compile_err(value); + assert!(err.contains("subject.roles"), "{label}: {err}"); + assert!( + !err.contains("did not match any variant"), + "{label}: must not be a serde variant dump: {err}" + ); + } + } + + /// A malformed `claims` block names the field too. It is read as a raw value + /// for exactly that reason. + #[test] + fn a_malformed_claims_block_names_the_field() { + for value in [ + json!({"subject": {"id": "sub"}, "claims": {"exclude": "iss"}}), + json!({"subject": {"id": "sub"}, "claims": {"include": 42}}), + json!({"subject": {"id": "sub"}, "claims": ["iss"]}), + json!({"subject": {"id": "sub"}, "claims": {"exclud": ["iss"]}}), + ] { + let err = config(value.clone()) + .compile() + .expect_err("a malformed claims block must be rejected"); + assert!(err.contains("claims"), "{value}: {err}"); + assert!( + !err.contains("did not match any variant"), + "{value}: must not be a serde variant dump: {err}" + ); + } + } + + /// Every field name the mapper resolves as a single value is in + /// `SCALAR_FIELDS`, and nothing else is. Without this, adding a scalar field + /// to one list and not the other lets `merge: union` and the shape flags + /// compile with no effect. + #[test] + fn scalar_fields_is_exactly_what_the_mapper_resolves_as_one_value() { + // The mapper's scalar call sites, per role. Kept here rather than derived + // so a change to either side has to be made deliberately in both. + const RESOLVED_AS_ONE_VALUE: &[&str] = &["id", "client_id", "client_name", "spiffe_id"]; + + for name in RESOLVED_AS_ONE_VALUE { + assert!( + SCALAR_FIELDS.contains(name), + "the mapper resolves `{name}` as one value, so it must be in SCALAR_FIELDS" + ); + } + for name in SCALAR_FIELDS { + assert!( + RESOLVED_AS_ONE_VALUE.contains(name), + "`{name}` is in SCALAR_FIELDS but the mapper does not resolve it as one value" + ); + } + // And every scalar name is a real field of some role, so a typo in either + // list fails here rather than silently never matching. + for name in SCALAR_FIELDS { + assert!( + SUBJECT_FIELDS.contains(name) + || CLIENT_FIELDS.contains(name) + || WORKLOAD_FIELDS.contains(name), + "`{name}` is in SCALAR_FIELDS but is not a field of any role" + ); + } + } + #[test] fn a_candidate_object_without_a_path_is_rejected() { let err = compile_err(json!({"subject": {"roles": [{"array_only": true}]}})); diff --git a/builtins/plugins/identity-jwt/src/configured_mapper.rs b/builtins/plugins/identity-jwt/src/configured_mapper.rs index 00aeeba..e123685 100644 --- a/builtins/plugins/identity-jwt/src/configured_mapper.rs +++ b/builtins/plugins/identity-jwt/src/configured_mapper.rs @@ -95,7 +95,10 @@ struct FieldOutcome { /// claim holding `[]` resolves and contributes nothing, which is not the /// same as a path that led nowhere. resolved: bool, - paths_tried: Vec, + /// How many candidates were reached, so a diagnostic can name them. A count + /// rather than rendered paths: rendering re-escapes every path, and the + /// request path pays that only when something actually missed. + tried: usize, } /// Append what one value contributes, or report that its shape cannot serve @@ -142,10 +145,10 @@ fn contribute( fn resolve_collection(field: &CompiledField, claims: &ClaimMap) -> FieldOutcome { let mut values = Vec::new(); let mut resolved = false; - let mut paths_tried = Vec::with_capacity(field.candidates().len()); + let mut tried = 0_usize; for candidate in field.candidates() { - paths_tried.push(candidate.path().to_string()); + tried += 1; let Some(value) = candidate.path().resolve(claims) else { continue; }; @@ -165,7 +168,7 @@ fn resolve_collection(field: &CompiledField, claims: &ClaimMap) -> FieldOutcome FieldOutcome { values, resolved, - paths_tried, + tried, } } @@ -179,22 +182,33 @@ fn resolve_scalar( field: &CompiledField, claims: &ClaimMap, accept: impl Fn(&str) -> bool, -) -> (Option, Vec) { - let mut paths_tried = Vec::with_capacity(field.candidates().len()); +) -> (Option, usize) { + let mut tried = 0_usize; for candidate in field.candidates() { - paths_tried.push(candidate.path().to_string()); + tried += 1; let Some(value) = candidate.path().resolve(claims) else { continue; }; match value.as_str() { - Some(text) if accept(text) => return (Some(text.to_owned()), paths_tried), + Some(text) if accept(text) => return (Some(text.to_owned()), tried), // Present but unusable. The chain continues unless this candidate // claims the field the moment its path resolves at all. _ if candidate.stop_if_present() => break, _ => {}, } } - (None, paths_tried) + (None, tried) +} + +/// Render the paths a field reached, for a diagnostic. Called only when a field +/// missed, so the escaping cost never lands on a resolving request. +fn paths_tried(field: &CompiledField, tried: usize) -> Vec { + field + .candidates() + .iter() + .take(tried) + .map(|candidate| candidate.path().to_string()) + .collect() } // ===================================================================== @@ -220,27 +234,28 @@ impl Diagnostics { } } - fn record(&mut self, name: &'static str, on_missing: OnMissing, outcome: &FieldOutcome) { + fn record( + &mut self, + name: &'static str, + field: &CompiledField, + outcome: &FieldOutcome, + ) -> bool { if outcome.resolved { if outcome.values.is_empty() { self.empty.push(name); } - return; + return true; } - self.missed.push((name, outcome.paths_tried.clone())); - if on_missing == OnMissing::Deny { + self.missed.push((name, paths_tried(field, outcome.tried))); + if field.on_missing() == OnMissing::Deny { self.denied.push(name); } + false } - fn record_scalar_miss( - &mut self, - name: &'static str, - on_missing: OnMissing, - paths_tried: Vec, - ) { - self.missed.push((name, paths_tried)); - if on_missing == OnMissing::Deny { + fn record_scalar_miss(&mut self, name: &'static str, field: &CompiledField, tried: usize) { + self.missed.push((name, paths_tried(field, tried))); + if field.on_missing() == OnMissing::Deny { self.denied.push(name); } } @@ -270,22 +285,22 @@ impl Diagnostics { .collect(); tracing::debug!( role = self.role, - fields = %fields.join(", "), - paths_tried = %tried.join("; "), + fields = ?fields, + paths_tried = ?tried, "claim map: no candidate resolved for these fields", ); } if !self.empty.is_empty() { tracing::debug!( role = self.role, - fields = %self.empty.join(", "), + fields = ?self.empty, "claim map: these fields resolved to an empty collection", ); } if !self.denied.is_empty() { tracing::warn!( role = self.role, - fields = %self.denied.join(", "), + fields = ?self.denied, "claim map: declining the token because a field declared `on_missing: deny` and \ no candidate resolved", ); @@ -304,7 +319,7 @@ fn collection( return Vec::new(); }; let outcome = resolve_collection(field, claims); - diag.record(name, field.on_missing(), &outcome); + diag.record(name, field, &outcome); outcome.values } @@ -317,9 +332,9 @@ fn scalar( accept: impl Fn(&str) -> bool, ) -> Option { let field = section.field(name)?; - let (value, paths_tried) = resolve_scalar(field, claims, accept); + let (value, tried) = resolve_scalar(field, claims, accept); if value.is_none() { - diag.record_scalar_miss(name, field.on_missing(), paths_tried); + diag.record_scalar_miss(name, field, tried); } value } @@ -1009,6 +1024,35 @@ mod tests { assert!(event.contains("roles"), "{event}"); } + /// `on_missing: deny` on a field holding one value takes the scalar miss path, + /// which is a different branch from the collection one and reports through the + /// same warning. + #[test] + fn on_missing_deny_on_a_field_holding_one_value_declines_and_names_it() { + let (declined, events) = capturing(|| { + mapper(json!({ + "client": { + "client_id": ["client_id", "azp"], + "client_name": {"paths": ["client_name", "app_name"], "on_missing": "deny"}, + } + })) + .map_client(&claims(json!({"client_id": "svc"}))) + }); + assert!( + declined.is_none(), + "a strict field holding one value declines when nothing resolves" + ); + + let warning = events.matching("on_missing"); + let event = warning.first().expect("the field is named in a warning"); + assert!(event.contains("client_name"), "{event}"); + + let misses = events.matching("no candidate resolved"); + let miss = misses.first().expect("the miss names every path tried"); + assert!(miss.contains("client_name"), "{miss}"); + assert!(miss.contains("app_name"), "both paths are named: {miss}"); + } + /// An empty collection satisfies `on_missing: deny`: the claim was there. #[test] fn on_missing_deny_accepts_a_claim_that_resolved_to_an_empty_collection() { diff --git a/builtins/plugins/identity-jwt/src/lib.rs b/builtins/plugins/identity-jwt/src/lib.rs index 9ecaad3..93b8603 100644 --- a/builtins/plugins/identity-jwt/src/lib.rs +++ b/builtins/plugins/identity-jwt/src/lib.rs @@ -78,6 +78,6 @@ pub use claim_path::ClaimPath; pub use config::{DecodingKeySource, JwtIdentityResolverConfig, TrustedIssuerConfig}; pub use configured_mapper::ConfiguredClaimMap; pub use factory::{JwtIdentityFactory, KIND}; -pub use presets::{DEFAULT_PRESET, PRESETS, Preset}; +pub use presets::{DEFAULT_PRESET, Preset}; pub use resolver::JwtIdentityResolver; pub use trusted_issuer::TrustedIssuer; diff --git a/builtins/plugins/identity-jwt/src/presets.rs b/builtins/plugins/identity-jwt/src/presets.rs index e7117de..66f8b79 100644 --- a/builtins/plugins/identity-jwt/src/presets.rs +++ b/builtins/plugins/identity-jwt/src/presets.rs @@ -17,8 +17,10 @@ use crate::claim_map_config::{ClaimMapConfig, CompiledClaimMap}; /// Every shipped preset, by the name an operator writes in `claim_mapper`. /// -/// Sorted by name so the unknown-name error lists them in a stable order. -pub const PRESETS: &[(&str, &str)] = &[ +/// Sorted by name so the unknown-name error lists them in a stable order. Not +/// public: the embedded JSON is an implementation detail, and [`names`] plus +/// [`lookup`] are what a caller needs. +const PRESETS: &[(&str, &str)] = &[ ("auth0", include_str!("presets/auth0.json")), ("cognito", include_str!("presets/cognito.json")), ("keycloak", include_str!("presets/keycloak.json")), @@ -274,6 +276,76 @@ mod tests { ); } + /// Each provider preset's candidates, pinned in order. The standard preset has + /// a parity gate; these have only their own tests, so without this a candidate + /// could be added, reordered or dropped silently. + #[test] + fn every_provider_preset_declares_the_candidates_it_is_documented_to() { + for (name, role, field, expected) in [ + ("keycloak", TokenRole::User, "id", vec!["sub"]), + ( + "keycloak", + TokenRole::User, + "roles", + vec!["realm_access.roles"], + ), + ("keycloak", TokenRole::User, "permissions", vec!["scope"]), + ( + "keycloak", + TokenRole::Client, + "client_id", + vec!["client_id", "azp", "clientId"], + ), + ("auth0", TokenRole::User, "id", vec!["sub"]), + ( + "auth0", + TokenRole::User, + "permissions", + vec!["permissions", "scope"], + ), + ( + "auth0", + TokenRole::Client, + "client_id", + vec!["client_id", "azp"], + ), + ("cognito", TokenRole::User, "id", vec!["sub"]), + ("cognito", TokenRole::User, "teams", vec!["cognito:groups"]), + ("cognito", TokenRole::Client, "client_id", vec!["client_id"]), + ] { + let map = lookup(name) + .unwrap_or_else(|e| panic!("'{name}': {e}")) + .into_claim_map(); + assert_eq!( + authored_paths(&map, &role, field), + expected, + "'{name}' {role:?}.{field}" + ); + } + } + + /// Pre-2023 Keycloak spells the claim `clientId`. It is the tail candidate, so + /// nothing else in the suite reaches it. + #[test] + fn the_keycloak_preset_accepts_the_camel_case_client_id() { + let client = mapper("keycloak") + .map_client(&claims( + json!({"clientId": "legacy-service", "scope": "openid"}), + )) + .expect("a pre-2023 Keycloak token resolves"); + assert_eq!(client.client_id, "legacy-service"); + + let precedence = mapper("keycloak") + .map_client(&claims(json!({ + "client_id": "modern", "azp": "middle", "clientId": "legacy", + }))) + .expect("resolves"); + assert_eq!( + precedence.client_id, "modern", + "the candidates are tried in the order the preset declares" + ); + } + /// Only `standard` has a workload shape to offer. A provider preset that /// declared one would be guessing. #[test] diff --git a/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs b/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs index 64d07f7..9b6e2f7 100644 --- a/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs +++ b/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs @@ -397,9 +397,47 @@ const AUD_SHAPES: &[&str] = &[ "client-aud-absent", ]; +/// What makes the first candidate of a chain win, which is what the second +/// branch's entry has to avoid in order to reach the fallback at all. +#[derive(Debug, Clone, Copy)] +enum Wins { + /// The candidate is array-only, so only an array wins. + Array, + /// A plain scalar candidate: any string wins. + AnyString, + /// The workload chain filters every candidate by the SPIFFE prefix. + SpiffeString, +} + +impl Wins { + fn satisfied_by(self, value: &Value) -> bool { + match self { + Self::Array => value.is_array(), + Self::AnyString => value.is_string(), + Self::SpiffeString => value + .as_str() + .is_some_and(|text| text.starts_with("spiffe://")), + } + } +} + +/// The claim each fallback branch is about, and the rule that decides the first +/// candidate. Coverage is asserted on content rather than on an entry's name: a +/// rename is a rename, but an entry edited until it no longer exercises its +/// branch is a silent loss of coverage, and only this catches that. +const FALLBACK_CLAIMS: &[(&str, &str, Wins)] = &[ + ("client_id", "azp", Wins::AnyString), + ("authorized_scopes", "scope", Wins::Array), + ("permissions", "scope", Wins::Array), + ("teams", "groups", Wins::Array), + ("sub", "spiffe_id", Wins::SpiffeString), +]; + #[test] fn every_fallback_has_an_entry_on_both_branches() { - let names: HashSet = corpus().into_iter().map(|entry| entry.name).collect(); + let entries = corpus(); + let names: HashSet<&str> = entries.iter().map(|entry| entry.name.as_str()).collect(); + for (fallback, first, second) in FALLBACK_BRANCHES { for branch in [first, second] { assert!( @@ -408,6 +446,47 @@ fn every_fallback_has_an_entry_on_both_branches() { ); } } + + for ((first_claim, second_claim, wins), (fallback, first_entry, second_entry)) in + FALLBACK_CLAIMS.iter().zip(FALLBACK_BRANCHES.iter()) + { + let find = |name: &str| { + entries + .iter() + .find(|entry| entry.name == name) + .unwrap_or_else(|| panic!("{fallback}: no entry named '{name}'")) + }; + + // The first branch exercises the winning candidate, so its claim must be + // present in a shape that actually wins. + let winner = find(first_entry); + let value = winner + .claims + .get(*first_claim) + .unwrap_or_else(|| panic!("{fallback}: '{first_entry}' must carry `{first_claim}`")); + assert!( + wins.satisfied_by(value), + "{fallback}: '{first_entry}' carries `{first_claim}` as {value}, which does not \ + satisfy {wins:?}, so it does not exercise the winning branch" + ); + + // The second branch exercises the fallback, so the first candidate must + // not win and the second claim must be present. + let fell_through = find(second_entry); + assert!( + fell_through.claims.contains_key(*second_claim), + "{fallback}: '{second_entry}' must carry `{second_claim}`" + ); + let first_would_win = fell_through + .claims + .get(*first_claim) + .is_some_and(|value| wins.satisfied_by(value)); + assert!( + !first_would_win, + "{fallback}: '{second_entry}' carries a winning `{first_claim}`, so it never \ + reaches the fallback branch it is named for" + ); + } } #[test] From 3d3954c35c85649c448e6a20ea80e99ab2a803ff Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 22:32:19 -0400 Subject: [PATCH 17/27] feat(identity-jwt)!: make the claims-bag overrides work with a preset The overrides lived inside `claim_map`, which is mutually exclusive with `claim_mapper`, so gating on `iss` meant copying a whole shipped preset into an inline map to add two lines. They are now a plugin-level `claims:` field, a sibling of both mapper fields, so a preset and an inline map reach them the same way and there is no precedence rule to learn. The alternative was to let a `claim_map` carrying only a `claims` block sit alongside `claim_mapper` as an overlay, which makes the same field mean either a whole map or a fragment depending on its contents. The bag is a separate output from the typed fields, so it reads as its own setting. **Breaking** for a map written against the previous shape: a `claims` block inside `claim_map` is now rejected, and the error lists the sections a map may declare. Also extracts the end-to-end harness into `tests/common/`. The keypair, minter, config builder and pipeline call were duplicated between two suites; there is now one copy. `jwks_url_e2e` keeps its own keypairs deliberately, since it exercises rotation and multiple key ids, which a process-global key cannot express. Signed-off-by: Frederico Araujo --- CHANGELOG.md | 2 +- .../identity-jwt/src/claim_map_config.rs | 157 ++++++++----- builtins/plugins/identity-jwt/src/config.rs | 16 ++ .../identity-jwt/src/configured_mapper.rs | 33 ++- builtins/plugins/identity-jwt/src/resolver.rs | 60 +++++ .../identity-jwt/tests/claim_map_e2e.rs | 211 ++++-------------- .../plugins/identity-jwt/tests/common/mod.rs | 163 ++++++++++++++ .../plugins/identity-jwt/tests/jwt_e2e.rs | 183 +++------------ 8 files changed, 442 insertions(+), 383 deletions(-) create mode 100644 builtins/plugins/identity-jwt/tests/common/mod.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index ef8c010..62332dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - **Claim mapping is configuration.** The JWT identity plugin's `claim_mapper` now 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 Rust and a rebuild. A field takes an ordered candidate list resolved first-match or unioned, can require an array or a string, can stop the chain on the first claim that exists, and can split a delimited string. A workload's trust domain is always derived from its SPIFFE identity's authority and is deliberately not mappable, so no claim can decouple the trust boundary a policy gates on from the identity it belongs to. Paths address nested claims by dots, with `\.` escaping a dot so a claim whose whole name is a URL is reachable; a colon needs no escaping. **An existing config is unaffected:** naming no mapper resolves to `standard`, which is held to the previous Rust mapper both over a corpus of provider token shapes and over a sweep of every JSON shape each mapped claim can hold, so an upgrading deployment sees the identity it saw before. Setting both fields, naming an unknown preset, writing a malformed path, misspelling any config key, or pairing a map with a role it does not declare each fail at startup naming the fault, rather than denying every request. A field whose paths all miss is logged at debug with every path it tried, or refuses the token if it declared `on_missing: deny`. ([#31](https://github.com/praxis-proxy/policy/pull/31)) -- **A policy can gate on which `IdP` minted a token.** A claim map's `claims.include` puts a claim back in the policy-visible bag, registered JWT claims included, so `claims: {include: [iss]}` makes `claim.iss` readable. A deployment trusting several issuers could not express "only tokens from the internal `IdP` may call this tool" before, because the subject claims bag is the only route from a claim to a policy and registered claims were always dropped. `claims.exclude` drops one the other way. ([#31](https://github.com/praxis-proxy/policy/pull/31)) +- **A policy can gate on which `IdP` minted a token.** A plugin-level `claims.include` puts a claim back in the policy-visible bag, registered JWT claims included, so `claims: {include: [iss]}` makes `claim.iss` readable. It is a sibling of `claim_mapper` and `claim_map` rather than part of either, so a shipped preset reaches it without being copied into an inline map. A deployment trusting several issuers could not express "only tokens from the internal `IdP` may call this tool" before, because the subject claims bag is the only route from a claim to a policy and registered claims were always dropped. `claims.exclude` drops one the other way. ([#31](https://github.com/praxis-proxy/policy/pull/31)) - **Each shipped preset records what it deliberately omits.** Two of the three providers put their roles claim somewhere no preset can name: Auth0 forbids a bare `roles` claim so roles arrive under a deployment's own URL namespace, and Keycloak's per-client roles sit under the client id an operator chose. Both need a hand-written `claim_map`. Presets also leave a field empty rather than filling it with the wrong concept, and Keycloak's `groups` claim holds realm roles where Cognito's `cognito:roles` holds IAM role ARNs, because a field quietly filled with the wrong thing gives an operator no reason to look. Each preset's description names its gaps and which of its claims are opt-in at the provider. ([#31](https://github.com/praxis-proxy/policy/pull/31)) diff --git a/builtins/plugins/identity-jwt/src/claim_map_config.rs b/builtins/plugins/identity-jwt/src/claim_map_config.rs index 100ee55..0486efc 100644 --- a/builtins/plugins/identity-jwt/src/claim_map_config.rs +++ b/builtins/plugins/identity-jwt/src/claim_map_config.rs @@ -294,6 +294,12 @@ fn candidates_from_list(field: &str, items: &[Value]) -> Result, /// Claim names to drop from, or restore to, the policy-visible claims bag. /// /// Plain names rather than paths: the bag is keyed by top-level claim name. +/// +/// A plugin-level setting rather than part of [`ClaimMapConfig`], so it applies +/// to a preset named by `claim_mapper` and to an inline `claim_map` alike. The +/// bag is a separate output from the typed fields, and pinning the overrides to +/// one of the two ways of choosing a map would leave the other unable to reach +/// them. #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ClaimsOverrides { @@ -307,6 +313,26 @@ pub struct ClaimsOverrides { pub include: Vec, } +impl ClaimsOverrides { + /// Check the two lists do not disagree with each other. + /// + /// # Errors + /// + /// Returns a message naming the claim when one appears in both lists. There + /// is no coherent intent to honour, and picking a winner silently would hide + /// the mistake. + pub fn validate(&self) -> Result<(), String> { + for claim in &self.include { + if self.exclude.iter().any(|excluded| excluded == claim) { + return Err(format!( + "claims: `{claim}` is in both `exclude` and `include`; pick one" + )); + } + } + Ok(()) + } +} + /// One role's authored section: field name to field map. /// /// Field names are checked against the role's own set during compilation, which @@ -336,9 +362,16 @@ pub struct RoleMapConfig(pub BTreeMap); /// - scope /// split: whitespace # break a delimited string into elements /// on_missing: deny # ignore (default) | deny -/// claims: -/// exclude: [internal_debug] # drop an otherwise-visible claim -/// include: [iss] # keep one the inference drops +/// ``` +/// +/// The claims-bag overrides are a sibling of `claim_map`, not part of it, so they +/// work with a preset too: +/// +/// ```yaml +/// claim_mapper: keycloak +/// claims: +/// exclude: [internal_debug] # drop an otherwise-visible claim +/// include: [iss] # keep one the inference drops /// ``` /// /// A field with no candidate that resolves is left empty and logged at debug, @@ -392,12 +425,6 @@ pub struct ClaimMapConfig { /// The section a `role: caller_workload` resolver uses. #[serde(default)] pub workload: Option, - /// Overrides for the inferred claims-bag exclusions. - /// - /// Read as a raw value so a malformed one names the field, the same reason - /// the role sections are. - #[serde(default)] - pub claims: Option, } // ===================================================================== @@ -522,6 +549,16 @@ impl CompiledClaimMap { pub fn claims(&self) -> &ClaimsOverrides { &self.claims } + + /// Attach the plugin-level claims-bag overrides. + /// + /// Applied after the map compiles, so a preset and an inline map reach them + /// the same way. + #[must_use] + pub fn with_claims(mut self, claims: ClaimsOverrides) -> Self { + self.claims = claims; + self + } } impl ClaimMapConfig { @@ -535,25 +572,11 @@ impl ClaimMapConfig { /// candidate list, `merge: union` on a field holding one string, and a claim /// named in both `exclude` and `include`. pub fn compile(&self) -> Result { - let claims: ClaimsOverrides = match self.claims.as_ref() { - Some(value) => serde_json::from_value(value.clone()).map_err(|e| { - format!("claims: expected `exclude` and `include` lists of claim names: {e}") - })?, - None => ClaimsOverrides::default(), - }; - for claim in &claims.include { - if claims.exclude.iter().any(|excluded| excluded == claim) { - return Err(format!( - "claims: `{claim}` is in both `exclude` and `include`; pick one" - )); - } - } - Ok(CompiledClaimMap { subject: compile_role("subject", SUBJECT_FIELDS, self.subject.as_ref())?, client: compile_role("client", CLIENT_FIELDS, self.client.as_ref())?, workload: compile_role("workload", WORKLOAD_FIELDS, self.workload.as_ref())?, - claims, + claims: ClaimsOverrides::default(), }) } } @@ -811,29 +834,64 @@ mod tests { // ---- claims overrides ------------------------------------------------- #[test] - fn claims_overrides_compile_and_default_to_empty() { - let with = compiled(json!({ - "subject": {"id": "sub"}, - "claims": {"exclude": ["internal_debug"], "include": ["iss"]}, - })); - assert_eq!(with.claims().exclude, vec!["internal_debug"]); - assert_eq!(with.claims().include, vec!["iss"]); - - let without = compiled(json!({"subject": {"id": "sub"}})); - assert!(without.claims().exclude.is_empty()); - assert!(without.claims().include.is_empty()); + fn claims_overrides_deserialize_and_default_to_empty() { + let with: ClaimsOverrides = + serde_json::from_value(json!({"exclude": ["internal_debug"], "include": ["iss"]})) + .expect("the overrides deserialize"); + assert_eq!(with.exclude, vec!["internal_debug"]); + assert_eq!(with.include, vec!["iss"]); + with.validate().expect("distinct lists are coherent"); + + let without = ClaimsOverrides::default(); + assert!(without.exclude.is_empty()); + assert!(without.include.is_empty()); + without.validate().expect("empty lists are coherent"); } #[test] fn a_claim_in_both_exclude_and_include_is_rejected_and_named() { - let err = compile_err(json!({ - "subject": {"id": "sub"}, - "claims": {"exclude": ["tenant"], "include": ["tenant"]}, - })); + let overrides: ClaimsOverrides = + serde_json::from_value(json!({"exclude": ["tenant"], "include": ["tenant"]})) + .expect("the overrides deserialize"); + let err = overrides + .validate() + .expect_err("a claim cannot be both dropped and kept"); assert!(err.contains("tenant"), "{err}"); assert!(err.contains("exclude") && err.contains("include"), "{err}"); } + /// The overrides are a plugin-level setting, so a `claims` block written inside + /// a map is rejected and the valid sections are listed. + #[test] + fn a_claims_block_inside_a_map_is_rejected() { + let err = serde_json::from_value::(json!({ + "subject": {"id": "sub"}, + "claims": {"include": ["iss"]}, + })) + .expect_err("`claims` is not part of a claim map"); + let message = err.to_string(); + assert!(message.contains("claims"), "{message}"); + assert!( + message.contains("subject"), + "the valid sections are listed: {message}" + ); + } + + /// A malformed override names the field rather than dumping a serde type + /// error, which is why the resolver reads it as a raw value first. + #[test] + fn a_malformed_claims_block_is_rejected() { + for value in [ + json!({"exclude": "iss"}), + json!({"include": 42}), + json!({"exclud": ["iss"]}), + json!(["iss"]), + ] { + serde_json::from_value::(value.clone()) + .expect_err(&format!("{value} must be rejected")); + } + } + // ---- role sections ---------------------------------------------------- /// An empty section still declares the role, which is what the role check @@ -1103,27 +1161,6 @@ mod tests { } } - /// A malformed `claims` block names the field too. It is read as a raw value - /// for exactly that reason. - #[test] - fn a_malformed_claims_block_names_the_field() { - for value in [ - json!({"subject": {"id": "sub"}, "claims": {"exclude": "iss"}}), - json!({"subject": {"id": "sub"}, "claims": {"include": 42}}), - json!({"subject": {"id": "sub"}, "claims": ["iss"]}), - json!({"subject": {"id": "sub"}, "claims": {"exclud": ["iss"]}}), - ] { - let err = config(value.clone()) - .compile() - .expect_err("a malformed claims block must be rejected"); - assert!(err.contains("claims"), "{value}: {err}"); - assert!( - !err.contains("did not match any variant"), - "{value}: must not be a serde variant dump: {err}" - ); - } - } - /// Every field name the mapper resolves as a single value is in /// `SCALAR_FIELDS`, and nothing else is. Without this, adding a scalar field /// to one list and not the other lets `merge: union` and the shape flags diff --git a/builtins/plugins/identity-jwt/src/config.rs b/builtins/plugins/identity-jwt/src/config.rs index 06e78b0..e302c5f 100644 --- a/builtins/plugins/identity-jwt/src/config.rs +++ b/builtins/plugins/identity-jwt/src/config.rs @@ -88,6 +88,22 @@ pub struct JwtIdentityResolverConfig { /// [`claim_mapper`]: Self::claim_mapper #[serde(default)] pub claim_map: Option, + + /// Which claims stay visible to a policy, overriding what the map's declared + /// paths imply. + /// + /// A sibling of [`claim_mapper`] and [`claim_map`] rather than part of either, + /// so it applies whichever way the map was chosen. `include` accepts any claim + /// name, registered ones included: `claims: {include: [iss]}` is what makes + /// gating on the issuing `IdP` expressible, since the subject claims bag is + /// the only route from a claim to a policy. + /// + /// Read as a raw value so a malformed one names the field. + /// + /// [`claim_mapper`]: Self::claim_mapper + /// [`claim_map`]: Self::claim_map + #[serde(default)] + pub claims: Option, } fn default_role() -> TokenRole { diff --git a/builtins/plugins/identity-jwt/src/configured_mapper.rs b/builtins/plugins/identity-jwt/src/configured_mapper.rs index e123685..2ca670a 100644 --- a/builtins/plugins/identity-jwt/src/configured_mapper.rs +++ b/builtins/plugins/identity-jwt/src/configured_mapper.rs @@ -472,7 +472,7 @@ mod tests { use serde_json::json; use super::*; - use crate::claim_map_config::ClaimMapConfig; + use crate::claim_map_config::{ClaimMapConfig, ClaimsOverrides}; fn claims(value: Value) -> ClaimMap { value.as_object().unwrap().clone().into_iter().collect() @@ -487,6 +487,21 @@ mod tests { ConfiguredClaimMap::new(config.compile().expect("the map compiles")) } + /// A mapper with the plugin-level claims-bag overrides attached, which is how + /// the resolver assembles one. + fn mapper_with_claims(map: Value, claims: Value) -> ConfiguredClaimMap { + let config: ClaimMapConfig = serde_json::from_value(map).expect("the map deserializes"); + let overrides: ClaimsOverrides = + serde_json::from_value(claims).expect("the overrides deserialize"); + overrides.validate().expect("the overrides are coherent"); + ConfiguredClaimMap::new( + config + .compile() + .expect("the map compiles") + .with_claims(overrides), + ) + } + fn sorted(values: &HashSet) -> Vec<&str> { let mut items: Vec<&str> = values.iter().map(String::as_str).collect(); items.sort_unstable(); @@ -872,10 +887,10 @@ mod tests { let token = claims(json!({ "sub": "alice", "groups": ["eng"], "internal_debug": "noisy", "tenant": "acme", })); - let subject = mapper(json!({ - "subject": {"id": "sub", "teams": "groups"}, - "claims": {"exclude": ["internal_debug"], "include": ["groups"]}, - })) + let subject = mapper_with_claims( + json!({"subject": {"id": "sub", "teams": "groups"}}), + json!({"exclude": ["internal_debug"], "include": ["groups"]}), + ) .map_subject(&token) .unwrap(); @@ -896,10 +911,10 @@ mod tests { let token = claims(json!({ "sub": "alice", "iss": "https://internal.idp", "jti": "abc", "exp": 2_000_000_000_i64, })); - let subject = mapper(json!({ - "subject": {"id": "sub"}, - "claims": {"include": ["iss", "jti", "exp"]}, - })) + let subject = mapper_with_claims( + json!({"subject": {"id": "sub"}}), + json!({"include": ["iss", "jti", "exp"]}), + ) .map_subject(&token) .unwrap(); assert_eq!( diff --git a/builtins/plugins/identity-jwt/src/resolver.rs b/builtins/plugins/identity-jwt/src/resolver.rs index e4add76..430baf7 100644 --- a/builtins/plugins/identity-jwt/src/resolver.rs +++ b/builtins/plugins/identity-jwt/src/resolver.rs @@ -59,6 +59,7 @@ use praxis_policy_core::identity::{IdentityHook, IdentityPayload}; use praxis_policy_core::plugin::{Plugin, PluginConfig}; use super::claim_map::{ClaimMap, ClaimMapper}; +use super::claim_map_config::ClaimsOverrides; use super::config::{JwtIdentityResolverConfig, TrustedIssuerConfig}; use super::configured_mapper::ConfiguredClaimMap; use super::presets; @@ -224,6 +225,20 @@ impl JwtIdentityResolver { }) }; + let claims_overrides: ClaimsOverrides = match typed.claims.as_ref() { + Some(value) => { + let parsed: ClaimsOverrides = + serde_json::from_value(value.clone()).map_err(|e| { + config_error(format!( + "`claims` takes `exclude` and `include` lists of claim names: {e}" + )) + })?; + parsed.validate().map_err(&config_error)?; + parsed + }, + None => ClaimsOverrides::default(), + }; + let compiled = match (typed.claim_map.as_ref(), typed.claim_mapper.as_deref()) { (Some(_), Some(named)) => { return Err(config_error(format!( @@ -239,6 +254,8 @@ impl JwtIdentityResolver { .into_claim_map(), }; + let compiled = compiled.with_claims(claims_overrides); + // Require the section matching the configured role now, so a // misconfigured pairing is a startup failure rather than a resolver that // denies every request. @@ -1006,6 +1023,49 @@ mod tests { /// An absent setting and the `standard` name are the same thing, and both /// have to keep working: an upgrading deployment changes neither. + /// The claims-bag overrides are a sibling of the two mapper fields, so they + /// build alongside either one. + #[test] + fn claims_overrides_build_with_a_preset_and_with_an_inline_map() { + for settings in [ + json!({"claim_mapper": "keycloak", "claims": {"include": ["iss"]}}), + json!({ + "claim_map": {"subject": {"id": "sub"}}, + "claims": {"exclude": ["internal_debug"]}, + }), + json!({"claims": {"include": ["iss"], "exclude": ["jti"]}}), + ] { + JwtIdentityResolver::new(cfg_with_mapper(settings.clone())) + .unwrap_or_else(|e| panic!("{settings} must build: {e}")); + } + } + + /// A malformed or incoherent overrides block fails at load naming `claims`, + /// rather than quietly dropping the overrides an operator asked for. + #[test] + fn a_bad_claims_block_is_refused_at_load_and_names_the_field() { + for settings in [ + json!({"claims": {"exclude": "iss"}}), + json!({"claims": {"include": 42}}), + json!({"claims": ["iss"]}), + json!({"claims": {"exclud": ["iss"]}}), + json!({"claims": {"exclude": ["tenant"], "include": ["tenant"]}}), + ] { + let err = build_err(settings.clone()); + assert!(err.contains("claims"), "{settings}: {err}"); + } + } + + /// The claim named in both lists is the one the message has to identify. + #[test] + fn a_claim_in_both_override_lists_is_named() { + let err = build_err(json!({ + "claims": {"exclude": ["tenant", "jti"], "include": ["tenant"]} + })); + assert!(err.contains("tenant"), "{err}"); + assert!(err.contains("exclude") && err.contains("include"), "{err}"); + } + #[test] fn an_absent_mapper_and_the_standard_name_both_build() { for settings in [json!({}), json!({"claim_mapper": "standard"})] { diff --git a/builtins/plugins/identity-jwt/tests/claim_map_e2e.rs b/builtins/plugins/identity-jwt/tests/claim_map_e2e.rs index d4ca88e..670b615 100644 --- a/builtins/plugins/identity-jwt/tests/claim_map_e2e.rs +++ b/builtins/plugins/identity-jwt/tests/claim_map_e2e.rs @@ -8,164 +8,32 @@ //! payload. The unit tests cover the engine; these cover the surface an operator //! actually writes, including the escaping that is the likeliest thing to get //! wrong. -//! -//! The harness is copied from `jwt_e2e.rs` rather than shared: integration test -//! binaries do not share code, and that file's helpers are private to it. #![allow( missing_docs, clippy::expect_used, clippy::indexing_slicing, clippy::panic, - clippy::print_stderr, - clippy::print_stdout, clippy::unwrap_used, reason = "test and example code" )] -use std::sync::{Arc, OnceLock}; +mod common; + +use common::{TEST_ISSUER, invoke, mint, plugin_config, sorted}; -use praxis_policy_core::engine::PolicyEngine; use praxis_policy_core::error::PluginError; +use praxis_policy_core::extensions::SubjectExtension; use praxis_policy_core::extensions::raw_credentials::{TokenKind, TokenRole}; use praxis_policy_core::factory::PluginFactory as _; -use praxis_policy_core::hooks::payload::Extensions; -use praxis_policy_core::identity::{ - HOOK_IDENTITY_RESOLVE, IdentityHook, IdentityPayload, TokenSource, -}; -use praxis_policy_core::plugin::{OnError, PluginConfig, PluginMode}; - -use praxis_policy_plugin_identity_jwt::{JwtIdentityFactory, JwtIdentityResolver, KIND}; - -use rsa::pkcs8::{EncodePrivateKey as _, EncodePublicKey as _, LineEnding}; -use rsa::{RsaPrivateKey, RsaPublicKey}; - +use praxis_policy_core::identity::{IdentityPayload, TokenSource}; +use praxis_policy_plugin_identity_jwt::JwtIdentityFactory; use serde_json::{Value, json}; -const TEST_ISSUER: &str = "https://idp.test.local"; -const TEST_AUDIENCE: &str = "test-api"; - -// ===================================================================== -// Harness -// ===================================================================== - -struct Keypair { - private_pem: String, - public_pem: String, -} - -fn keypair() -> &'static Keypair { - static KP: OnceLock = OnceLock::new(); - KP.get_or_init(|| { - let mut rng = rand::thread_rng(); - let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); - let pub_key = RsaPublicKey::from(&priv_key); - Keypair { - private_pem: priv_key - .to_pkcs8_pem(LineEnding::LF) - .expect("encode private PEM") - .to_string(), - public_pem: pub_key - .to_public_key_pem(LineEnding::LF) - .expect("encode public PEM"), - } - }) -} - -fn now_unix() -> i64 { - chrono::Utc::now().timestamp() -} - -/// Sign a token carrying `extra` plus the registered claims the resolver -/// validates against. -fn mint(extra: Value) -> String { - use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; - - let mut claims = json!({ - "iss": TEST_ISSUER, - "aud": TEST_AUDIENCE, - "exp": now_unix() + 300, - "iat": now_unix(), - }); - match (claims.as_object_mut(), extra.as_object()) { - (Some(target), Some(source)) => { - for (key, value) in source { - target.insert(key.clone(), value.clone()); - } - }, - _ => panic!("both claim sets must be JSON objects"), - } - - let key = EncodingKey::from_rsa_pem(keypair().private_pem.as_bytes()) - .expect("build EncodingKey from the test private PEM"); - encode(&Header::new(Algorithm::RS256), &claims, &key).expect("sign JWT") -} - -/// A plugin config wiring the test key, plus whatever mapper and role settings -/// the case needs. Mirrors what an operator writes in unified-config YAML. -fn plugin_config(settings: Value) -> PluginConfig { - let mut config = json!({ - "trusted_issuers": [{ - "issuer": TEST_ISSUER, - "audiences": [TEST_AUDIENCE], - "algorithms": ["RS256"], - "decoding_key": { "kind": "pem", "pem": keypair().public_pem }, - "leeway_seconds": 60, - }], - }); - match (config.as_object_mut(), settings.as_object()) { - (Some(target), Some(source)) => { - for (key, value) in source { - target.insert(key.clone(), value.clone()); - } - }, - _ => panic!("both config blocks must be JSON objects"), - } - - PluginConfig { - name: "jwt-resolver".into(), - kind: KIND.into(), - hooks: vec![HOOK_IDENTITY_RESOLVE.into()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - config: Some(config), - ..Default::default() - } -} - -async fn invoke( - settings: Value, - token: String, - source: TokenSource, -) -> praxis_policy_core::executor::PipelineResult { - let cfg = plugin_config(settings); - let resolver = JwtIdentityResolver::new(cfg.clone()).expect("the resolver must construct"); - - let mgr = Arc::new(PolicyEngine::default()); - mgr.register_handler_for_names::( - Arc::new(resolver), - cfg, - &[HOOK_IDENTITY_RESOLVE], - ) - .expect("registration"); - mgr.initialize().await.expect("initialize"); - - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - IdentityPayload::new(token, source), - Extensions::default(), - None, - ) - .await; - result -} - /// Resolve a token and return the identity, failing with the violation when the /// resolver denied. async fn identity_from(settings: Value, token: String) -> IdentityPayload { - let result = invoke(settings, token, TokenSource::Bearer).await; + let result = invoke(plugin_config(settings), token, TokenSource::Bearer).await; assert!( result.continue_processing, "the token should have resolved: violation = {:?}", @@ -174,25 +42,16 @@ async fn identity_from(settings: Value, token: String) -> IdentityPayload { IdentityPayload::from_pipeline_result(&result).expect("the payload is present") } -async fn subject_from( - settings: Value, - token: String, -) -> praxis_policy_core::extensions::SubjectExtension { +async fn subject_from(settings: Value, token: String) -> SubjectExtension { identity_from(settings, token) .await .subject .expect("the subject slot is populated") } -fn sorted(values: &std::collections::HashSet) -> Vec<&str> { - let mut items: Vec<&str> = values.iter().map(String::as_str).collect(); - items.sort_unstable(); - items -} - /// A token the resolver refused, and the violation code it refused with. async fn denial_code(settings: Value, token: String, source: TokenSource) -> String { - let result = invoke(settings, token, source).await; + let result = invoke(plugin_config(settings), token, source).await; assert!( !result.continue_processing, "the token should have been refused" @@ -397,10 +256,8 @@ async fn including_iss_makes_the_issuing_idp_visible_to_a_policy() { let subject = subject_from( json!({ - "claim_map": { - "subject": {"id": "sub"}, - "claims": {"include": ["iss"]}, - } + "claim_map": {"subject": {"id": "sub"}}, + "claims": {"include": ["iss"]}, }), token.clone(), ) @@ -414,6 +271,40 @@ async fn including_iss_makes_the_issuing_idp_visible_to_a_policy() { ); } +/// The overrides are a sibling of the two ways of choosing a map, so a preset +/// reaches them too. Before this they lived inside `claim_map`, which is mutually +/// exclusive with `claim_mapper`, so gating on `iss` meant copying a whole preset +/// into an inline map. +#[tokio::test] +async fn claims_overrides_apply_to_a_preset_named_by_claim_mapper() { + let token = mint(json!({ + "sub": "f:2c1b:alice", + "realm_access": {"roles": ["viewer"]}, + "internal_debug": "noisy", + })); + + let subject = subject_from( + json!({ + "claim_mapper": "keycloak", + "claims": {"include": ["iss"], "exclude": ["internal_debug"]}, + }), + token, + ) + .await; + + assert_eq!( + sorted(&subject.roles), + vec!["viewer"], + "the preset still maps what it maps" + ); + assert_eq!( + subject.claims.get("iss"), + Some(&json!(TEST_ISSUER)), + "a registered claim is reachable without forking the preset" + ); + assert!(!subject.claims.contains_key("internal_debug")); +} + #[tokio::test] async fn excluding_a_claim_keeps_it_out_of_the_policy_bag() { let token = mint(json!({ @@ -422,10 +313,8 @@ async fn excluding_a_claim_keeps_it_out_of_the_policy_bag() { let subject = subject_from( json!({ - "claim_map": { - "subject": {"id": "sub"}, - "claims": {"exclude": ["internal_debug"]}, - } + "claim_map": {"subject": {"id": "sub"}}, + "claims": {"exclude": ["internal_debug"]}, }), token, ) @@ -582,10 +471,8 @@ async fn the_raw_token_and_the_full_claim_set_still_pass_through() { let identity = identity_from( json!({ - "claim_map": { - "subject": {"id": "sub", "roles": "realm_access.roles"}, - "claims": {"exclude": ["internal_debug"]}, - } + "claim_map": {"subject": {"id": "sub", "roles": "realm_access.roles"}}, + "claims": {"exclude": ["internal_debug"]}, }), token.clone(), ) diff --git a/builtins/plugins/identity-jwt/tests/common/mod.rs b/builtins/plugins/identity-jwt/tests/common/mod.rs new file mode 100644 index 0000000..ba7ec15 --- /dev/null +++ b/builtins/plugins/identity-jwt/tests/common/mod.rs @@ -0,0 +1,163 @@ +// SPDX-License-Identifier: Apache-2.0 +// Copyright (c) 2026 Praxis Contributors + +//! Shared harness for the end-to-end suites. +//! +//! An RSA keypair, a token minter, a plugin-config builder, and one call that +//! drives a token through the real handler pipeline. Integration test binaries do +//! not share code by default, so this is a `mod common;` each suite declares. +//! +//! `jwks_url_e2e.rs` deliberately does not use the keypair here: it generates one +//! per test because it exercises key rotation, multiple `kid`s, and an unknown +//! `kid`, none of which a process-global key can express. + +#![allow( + missing_docs, + clippy::expect_used, + clippy::indexing_slicing, + clippy::panic, + clippy::unwrap_used, + reason = "test and example code" +)] + +use std::collections::HashSet; +use std::sync::{Arc, OnceLock}; + +use praxis_policy_core::engine::PolicyEngine; +use praxis_policy_core::executor::PipelineResult; +use praxis_policy_core::hooks::payload::Extensions; +use praxis_policy_core::identity::{ + HOOK_IDENTITY_RESOLVE, IdentityHook, IdentityPayload, TokenSource, +}; +use praxis_policy_core::plugin::{OnError, PluginConfig, PluginMode}; +use praxis_policy_plugin_identity_jwt::{JwtIdentityResolver, KIND}; +use rsa::pkcs8::{EncodePrivateKey as _, EncodePublicKey as _, LineEnding}; +use rsa::{RsaPrivateKey, RsaPublicKey}; +use serde_json::{Value, json}; + +pub(crate) const TEST_ISSUER: &str = "https://idp.test.local"; +pub(crate) const TEST_AUDIENCE: &str = "test-api"; + +pub(crate) struct Keypair { + pub(crate) private_pem: String, + pub(crate) public_pem: String, +} + +/// Process-global keypair. RSA 2048 is ~50-100ms, which is not worth paying per +/// test. +pub(crate) fn keypair() -> &'static Keypair { + static KP: OnceLock = OnceLock::new(); + KP.get_or_init(|| { + let mut rng = rand::thread_rng(); + let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); + let pub_key = RsaPublicKey::from(&priv_key); + Keypair { + private_pem: priv_key + .to_pkcs8_pem(LineEnding::LF) + .expect("encode private PEM") + .to_string(), + public_pem: pub_key + .to_public_key_pem(LineEnding::LF) + .expect("encode public PEM"), + } + }) +} + +pub(crate) fn now_unix() -> i64 { + chrono::Utc::now().timestamp() +} + +/// Sign exactly the claims given, for a test that spells out its own registered +/// claims in order to make one of them wrong. +pub(crate) fn mint_exact(claims: Value) -> String { + use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; + + let key = EncodingKey::from_rsa_pem(keypair().private_pem.as_bytes()) + .expect("build EncodingKey from the test private PEM"); + encode(&Header::new(Algorithm::RS256), &claims, &key).expect("sign JWT") +} + +/// Sign `extra` plus the registered claims the resolver validates against, for a +/// test whose subject is the claim mapping rather than validation. +pub(crate) fn mint(extra: Value) -> String { + let mut claims = json!({ + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "exp": now_unix() + 300, + "iat": now_unix(), + }); + merge_into(&mut claims, &extra, "claim set"); + mint_exact(claims) +} + +/// A plugin config wiring the test key, plus whatever settings the case needs. +/// Mirrors what an operator writes in unified-config YAML. +pub(crate) fn plugin_config(settings: Value) -> PluginConfig { + let mut config = json!({ + "trusted_issuers": [{ + "issuer": TEST_ISSUER, + "audiences": [TEST_AUDIENCE], + "algorithms": ["RS256"], + "decoding_key": { "kind": "pem", "pem": keypair().public_pem }, + "leeway_seconds": 60, + }], + }); + merge_into(&mut config, &settings, "config block"); + + PluginConfig { + name: "jwt-resolver".into(), + kind: KIND.into(), + hooks: vec![HOOK_IDENTITY_RESOLVE.into()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + config: Some(config), + ..Default::default() + } +} + +fn merge_into(target: &mut Value, source: &Value, what: &str) { + match (target.as_object_mut(), source.as_object()) { + (Some(target), Some(source)) => { + for (key, value) in source { + target.insert(key.clone(), value.clone()); + } + }, + _ => panic!("both halves of the {what} must be JSON objects"), + } +} + +/// Drive a token through the real handler pipeline. +pub(crate) async fn invoke( + cfg: PluginConfig, + token: String, + source: TokenSource, +) -> PipelineResult { + let resolver = JwtIdentityResolver::new(cfg.clone()).expect("the resolver must construct"); + + let mgr = Arc::new(PolicyEngine::default()); + mgr.register_handler_for_names::( + Arc::new(resolver), + cfg, + &[HOOK_IDENTITY_RESOLVE], + ) + .expect("registration"); + mgr.initialize().await.expect("initialize"); + + let (result, _bg) = mgr + .invoke_named::( + HOOK_IDENTITY_RESOLVE, + IdentityPayload::new(token, source), + Extensions::default(), + None, + ) + .await; + result +} + +/// A set's contents in a stable order, for an assertion message worth reading. +pub(crate) fn sorted(values: &HashSet) -> Vec<&str> { + let mut items: Vec<&str> = values.iter().map(String::as_str).collect(); + items.sort_unstable(); + items +} diff --git a/builtins/plugins/identity-jwt/tests/jwt_e2e.rs b/builtins/plugins/identity-jwt/tests/jwt_e2e.rs index 14186db..1e0feb5 100644 --- a/builtins/plugins/identity-jwt/tests/jwt_e2e.rs +++ b/builtins/plugins/identity-jwt/tests/jwt_e2e.rs @@ -14,8 +14,8 @@ // * audience mismatch // * signature tamper // -// Keypair is generated once per test process (RSA 2048 takes -// ~50-100ms; one-time cost) and shared across tests via OnceLock. +// The keypair, minter, config builder and pipeline call live in `common`, which +// the claim-map suite shares. #![allow( missing_docs, @@ -27,156 +27,37 @@ clippy::unwrap_used, reason = "test and example code" )] -use std::sync::Arc; -use std::sync::OnceLock; -use praxis_policy_core::engine::PolicyEngine; -use praxis_policy_core::extensions::raw_credentials::{TokenKind, TokenRole}; -use praxis_policy_core::hooks::payload::Extensions; -use praxis_policy_core::identity::{ - HOOK_IDENTITY_RESOLVE, IdentityHook, IdentityPayload, TokenSource, -}; -use praxis_policy_core::plugin::{OnError, PluginConfig, PluginMode}; - -use praxis_policy_plugin_identity_jwt::JwtIdentityResolver; - -use rsa::pkcs8::{EncodePrivateKey as _, EncodePublicKey as _, LineEnding}; -use rsa::{RsaPrivateKey, RsaPublicKey}; - -use serde_json::{Value, json}; - -const TEST_ISSUER: &str = "https://idp.test.local"; -const TEST_AUDIENCE: &str = "test-api"; - -// ===================================================================== -// Test fixtures -// ===================================================================== +mod common; -struct Keypair { - private_pem: String, - public_pem: String, -} +use common::{TEST_AUDIENCE, TEST_ISSUER, invoke, mint_exact as mint_jwt, now_unix, plugin_config}; -/// Process-global keypair. Generated once on first access; RSA 2048 -/// is ~50-100ms which we don't want to pay per-test. -fn keypair() -> &'static Keypair { - static KP: OnceLock = OnceLock::new(); - KP.get_or_init(|| { - let mut rng = rand::thread_rng(); - let priv_key = RsaPrivateKey::new(&mut rng, 2048).expect("generate RSA"); - let pub_key = RsaPublicKey::from(&priv_key); - Keypair { - private_pem: priv_key - .to_pkcs8_pem(LineEnding::LF) - .expect("encode private PEM") - .to_string(), - public_pem: pub_key - .to_public_key_pem(LineEnding::LF) - .expect("encode public PEM"), - } - }) -} +use praxis_policy_core::extensions::raw_credentials::{TokenKind, TokenRole}; +use praxis_policy_core::identity::{IdentityPayload, TokenSource}; +use praxis_policy_core::plugin::PluginConfig; -/// Sign `claims` as an RS256 JWT using the test private key. JWT -/// payload is whatever JSON the caller hands in. -fn mint_jwt(claims: Value) -> String { - use jsonwebtoken::{Algorithm, EncodingKey, Header, encode}; - let header = Header::new(Algorithm::RS256); - let key = EncodingKey::from_rsa_pem(keypair().private_pem.as_bytes()) - .expect("build EncodingKey from test private PEM"); - encode(&header, &claims, &key).expect("sign JWT") -} +use serde_json::json; -/// Construct a `PluginConfig` whose `config:` block declares the -/// test public key as the trusted-issuer signing material. Mirrors -/// what an operator writes in unified-config YAML. +/// The config every scenario starts from: the test key, and the standard mapper +/// named explicitly so the default is not what is under test. fn resolver_plugin_config() -> PluginConfig { - let plugin_config = json!({ - "trusted_issuers": [{ - "issuer": TEST_ISSUER, - "audiences": [TEST_AUDIENCE], - "algorithms": ["RS256"], - "decoding_key": { - "kind": "pem", - "pem": keypair().public_pem, - }, - "leeway_seconds": 60, - }], - "claim_mapper": "standard", - }); - PluginConfig { - name: "jwt-resolver".into(), - kind: "test".into(), - hooks: vec![HOOK_IDENTITY_RESOLVE.into()], - mode: PluginMode::Sequential, - priority: 10, - on_error: OnError::Fail, - config: Some(plugin_config), - ..Default::default() - } + plugin_config(json!({ "claim_mapper": "standard" })) } -/// Role-aware variant of [`resolver_plugin_config`]. `role` and -/// `header` are the two knobs that decide which identity slot a -/// resolver instance fills and where it reads its token from — one -/// instance per inbound credential, so a deployment expecting a user -/// JWT *and* a workload SVID wires two. +/// Role-aware variant of [`resolver_plugin_config`]. `role` and `header` are the +/// two knobs that decide which identity slot a resolver instance fills and where +/// it reads its token from, so a deployment expecting a user JWT *and* a workload +/// SVID wires two. fn resolver_plugin_config_for(role: &str, header: &str) -> PluginConfig { - let mut cfg = resolver_plugin_config(); - match cfg.config.as_mut() { - Some(Value::Object(map)) => { - map.insert("role".into(), json!(role)); - map.insert("header".into(), json!(header)); - }, - other => panic!("resolver config should be a JSON object, got {other:?}"), - } - cfg -} - -/// Build the `PolicyEngine` + register the resolver + initialize. -/// All four scenarios share this skeleton. -async fn build_manager() -> Arc { - build_manager_with(resolver_plugin_config()).await -} - -async fn build_manager_with(cfg: PluginConfig) -> Arc { - let resolver = JwtIdentityResolver::new(cfg.clone()).expect("resolver should construct"); - - let mgr = Arc::new(PolicyEngine::default()); - mgr.register_handler_for_names::( - Arc::new(resolver), - cfg, - &[HOOK_IDENTITY_RESOLVE], - ) - .unwrap(); - mgr.initialize().await.unwrap(); - mgr -} - -/// Run a token through the full handler pipeline. -async fn invoke(token: String) -> praxis_policy_core::executor::PipelineResult { - invoke_with(resolver_plugin_config(), token, TokenSource::Bearer).await -} - -async fn invoke_with( - cfg: PluginConfig, - token: String, - source: TokenSource, -) -> praxis_policy_core::executor::PipelineResult { - let mgr = build_manager_with(cfg).await; - let (result, _bg) = mgr - .invoke_named::( - HOOK_IDENTITY_RESOLVE, - IdentityPayload::new(token, source), - Extensions::default(), - None, - ) - .await; - result + plugin_config(json!({ + "claim_mapper": "standard", + "role": role, + "header": header, + })) } -fn now_unix() -> i64 { - chrono::Utc::now().timestamp() +async fn invoke_bearer(token: String) -> praxis_policy_core::executor::PipelineResult { + invoke(resolver_plugin_config(), token, TokenSource::Bearer).await } // ===================================================================== @@ -197,7 +78,7 @@ async fn valid_jwt_resolves_subject() { "email": "alice@corp.com", })); - let result = invoke(token.clone()).await; + let result = invoke_bearer(token.clone()).await; assert!( result.continue_processing, "valid token should resolve: violation = {:?}", @@ -256,7 +137,7 @@ async fn workload_svid_resolves_caller_workload_and_stashes_as_spiffe_jwt() { "iat": now_unix(), })); - let result = invoke_with( + let result = invoke( resolver_plugin_config_for("workload", "X-Workload-Token"), svid.clone(), TokenSource::SpiffeJwtSvid, @@ -320,7 +201,7 @@ async fn workload_role_rejects_a_non_spiffe_token() { "iat": now_unix(), })); - let result = invoke_with( + let result = invoke( resolver_plugin_config_for("workload", "X-Workload-Token"), user_jwt, TokenSource::SpiffeJwtSvid, @@ -350,7 +231,7 @@ async fn workload_role_rejects_non_spiffe_sub_with_bogus_spiffe_id_claim() { "iat": now_unix(), })); - let result = invoke_with( + let result = invoke( resolver_plugin_config_for("workload", "X-Workload-Token"), jwt, TokenSource::SpiffeJwtSvid, @@ -381,7 +262,7 @@ async fn workload_role_accepts_valid_spiffe_id_claim_fallback() { "iat": now_unix(), })); - let result = invoke_with( + let result = invoke( resolver_plugin_config_for("workload", "X-Workload-Token"), jwt, TokenSource::SpiffeJwtSvid, @@ -406,7 +287,7 @@ async fn untrusted_issuer_rejects() { "exp": now_unix() + 300, })); - let result = invoke(token).await; + let result = invoke_bearer(token).await; assert!(!result.continue_processing); let v = result.violation.expect("rejection should surface"); assert_eq!(v.code, "auth.untrusted_issuer"); @@ -423,7 +304,7 @@ async fn expired_token_rejects() { "exp": now_unix() - 3600, })); - let result = invoke(token).await; + let result = invoke_bearer(token).await; assert!(!result.continue_processing); let v = result.violation.expect("rejection should surface"); assert_eq!(v.code, "auth.token_expired"); @@ -439,7 +320,7 @@ async fn wrong_audience_rejects() { "exp": now_unix() + 300, })); - let result = invoke(token).await; + let result = invoke_bearer(token).await; assert!(!result.continue_processing); let v = result.violation.expect("rejection should surface"); assert_eq!(v.code, "auth.audience_mismatch"); @@ -477,7 +358,7 @@ async fn tampered_signature_rejects() { let new_sig: String = sig_chars.into_iter().collect(); let tampered = format!("{}.{}.{}", parts[0], parts[1], new_sig); - let result = invoke(tampered).await; + let result = invoke_bearer(tampered).await; assert!(!result.continue_processing); let v = result.violation.expect("rejection should surface"); assert_eq!(v.code, "auth.signature_invalid"); @@ -494,7 +375,7 @@ async fn missing_iss_rejects() { "exp": now_unix() + 300, })); - let result = invoke(token).await; + let result = invoke_bearer(token).await; assert!(!result.continue_processing); let v = result.violation.expect("rejection should surface"); assert_eq!(v.code, "auth.malformed_header"); From 58e0d36cb1d20fe57c1e63b8eafb72b321142a31 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 22:48:47 -0400 Subject: [PATCH 18/27] fix(identity-jwt): satisfy the hardened lints from main Merging main brought a stricter clippy set and a spell checker. Three fixes in this crate: `assert!(x.is_ok())` on the default preset lookup becomes `expect`, which is what `assertions_on_result_states` asks for and reads better anyway. Two test fixtures used deliberately misspelled strings that the spell checker reads as mistakes in the source rather than as the fixtures they are. A mistyped path now misspells the leaf segment instead of `access`, and a misspelled config key is `excludes` rather than a misspelled `exclude`. Both still test what they tested. Also corrects `unparseable` in a doc line, the one pre-existing hit the checker finds in this crate. The others it reports are in crates this branch does not touch. Signed-off-by: Frederico Araujo --- builtins/plugins/identity-jwt/src/claim_map_config.rs | 2 +- builtins/plugins/identity-jwt/src/configured_mapper.rs | 4 ++-- builtins/plugins/identity-jwt/src/presets.rs | 2 +- builtins/plugins/identity-jwt/src/resolver.rs | 4 ++-- builtins/plugins/identity-jwt/tests/claim_map_e2e.rs | 4 ++-- .../plugins/identity-jwt/tests/standard_preset_equivalence.rs | 2 +- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/builtins/plugins/identity-jwt/src/claim_map_config.rs b/builtins/plugins/identity-jwt/src/claim_map_config.rs index 0486efc..b5c2274 100644 --- a/builtins/plugins/identity-jwt/src/claim_map_config.rs +++ b/builtins/plugins/identity-jwt/src/claim_map_config.rs @@ -884,7 +884,7 @@ mod tests { for value in [ json!({"exclude": "iss"}), json!({"include": 42}), - json!({"exclud": ["iss"]}), + json!({"excludes": ["iss"]}), json!(["iss"]), ] { serde_json::from_value::(value.clone()) diff --git a/builtins/plugins/identity-jwt/src/configured_mapper.rs b/builtins/plugins/identity-jwt/src/configured_mapper.rs index 2ca670a..46551e9 100644 --- a/builtins/plugins/identity-jwt/src/configured_mapper.rs +++ b/builtins/plugins/identity-jwt/src/configured_mapper.rs @@ -934,7 +934,7 @@ mod tests { fn a_field_that_resolved_nothing_names_itself_and_every_path_tried() { let (subject, events) = capturing(|| { mapper(json!({ - "subject": {"id": "sub", "roles": ["realm_acces.roles", "rolez"]} + "subject": {"id": "sub", "roles": ["realm_access.rolez", "rolez"]} })) .map_subject(&claims(json!({ "sub": "alice", "realm_access": {"roles": ["admin"]}, @@ -949,7 +949,7 @@ mod tests { assert_eq!(misses.len(), 1, "one aggregated event per call: {misses:?}"); let event = misses.first().expect("one miss event"); assert!(event.contains("roles"), "{event}"); - assert!(event.contains("realm_acces.roles"), "{event}"); + assert!(event.contains("realm_access.rolez"), "{event}"); assert!(event.contains("rolez"), "{event}"); } diff --git a/builtins/plugins/identity-jwt/src/presets.rs b/builtins/plugins/identity-jwt/src/presets.rs index 66f8b79..7526539 100644 --- a/builtins/plugins/identity-jwt/src/presets.rs +++ b/builtins/plugins/identity-jwt/src/presets.rs @@ -225,7 +225,7 @@ mod tests { #[test] fn the_default_preset_is_standard_and_is_in_the_table() { assert_eq!(DEFAULT_PRESET, "standard"); - assert!(lookup(DEFAULT_PRESET).is_ok()); + lookup(DEFAULT_PRESET).expect("the default preset must load"); } // ---- the standard preset ---------------------------------------------- diff --git a/builtins/plugins/identity-jwt/src/resolver.rs b/builtins/plugins/identity-jwt/src/resolver.rs index 7950a53..bd3609d 100644 --- a/builtins/plugins/identity-jwt/src/resolver.rs +++ b/builtins/plugins/identity-jwt/src/resolver.rs @@ -125,7 +125,7 @@ impl JwtIdentityResolver { /// /// Returns `PluginError::Config` for any config-time failure: /// missing config block, malformed JSON, no trusted issuers, - /// unparseable decoding key, unknown claim mapper, etc. + /// unparsable decoding key, unknown claim mapper, etc. /// # Errors /// /// Returns `PluginError::Config` when the `config:` block is absent or does @@ -1048,7 +1048,7 @@ mod tests { json!({"claims": {"exclude": "iss"}}), json!({"claims": {"include": 42}}), json!({"claims": ["iss"]}), - json!({"claims": {"exclud": ["iss"]}}), + json!({"claims": {"excludes": ["iss"]}}), json!({"claims": {"exclude": ["tenant"], "include": ["tenant"]}}), ] { let err = build_err(settings.clone()); diff --git a/builtins/plugins/identity-jwt/tests/claim_map_e2e.rs b/builtins/plugins/identity-jwt/tests/claim_map_e2e.rs index 670b615..244072b 100644 --- a/builtins/plugins/identity-jwt/tests/claim_map_e2e.rs +++ b/builtins/plugins/identity-jwt/tests/claim_map_e2e.rs @@ -338,7 +338,7 @@ async fn a_mistyped_path_is_permissive_by_default_and_fatal_on_request() { let permissive = subject_from( json!({ - "claim_map": {"subject": {"id": "sub", "roles": "realm_acces.roles"}} + "claim_map": {"subject": {"id": "sub", "roles": "realm_access.rolez"}} }), token.clone(), ) @@ -354,7 +354,7 @@ async fn a_mistyped_path_is_permissive_by_default_and_fatal_on_request() { "claim_map": { "subject": { "id": "sub", - "roles": {"paths": ["realm_acces.roles"], "on_missing": "deny"}, + "roles": {"paths": ["realm_access.rolez"], "on_missing": "deny"}, } } }), diff --git a/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs b/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs index 9b6e2f7..63d8853 100644 --- a/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs +++ b/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs @@ -14,7 +14,7 @@ //! the corpus, unless the corpus entry is itself wrong about what an `IdP` mints. //! //! The corpus is embedded rather than read at run time, so a missing or -//! unparseable file is a compile or test failure and never a silently skipped +//! unparsable file is a compile or test failure and never a silently skipped //! entry. #![allow( From d7b34d5790d055d7ff7a88da8d6146696e1fc06d Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Thu, 20 Aug 2026 23:04:56 -0400 Subject: [PATCH 19/27] docs: tighten the changelog entries The three entries for this work ran long and buried the breaking parts in prose. Shortened to the style the rest of the file uses, and the breaking changes moved to their own section. Signed-off-by: Frederico Araujo --- CHANGELOG.md | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 62332dc..b3c0118 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,14 +17,20 @@ 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` now 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 Rust and a rebuild. A field takes an ordered candidate list resolved first-match or unioned, can require an array or a string, can stop the chain on the first claim that exists, and can split a delimited string. A workload's trust domain is always derived from its SPIFFE identity's authority and is deliberately not mappable, so no claim can decouple the trust boundary a policy gates on from the identity it belongs to. Paths address nested claims by dots, with `\.` escaping a dot so a claim whose whole name is a URL is reachable; a colon needs no escaping. **An existing config is unaffected:** naming no mapper resolves to `standard`, which is held to the previous Rust mapper both over a corpus of provider token shapes and over a sweep of every JSON shape each mapped claim can hold, so an upgrading deployment sees the identity it saw before. Setting both fields, naming an unknown preset, writing a malformed path, misspelling any config key, or pairing a map with a role it does not declare each fail at startup naming the fault, rather than denying every request. A field whose paths all miss is logged at debug with every path it tried, or refuses the token if it declared `on_missing: deny`. ([#31](https://github.com/praxis-proxy/policy/pull/31)) +- **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. 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.** A plugin-level `claims.include` puts a claim back in the policy-visible bag, registered JWT claims included, so `claims: {include: [iss]}` makes `claim.iss` readable. It is a sibling of `claim_mapper` and `claim_map` rather than part of either, so a shipped preset reaches it without being copied into an inline map. A deployment trusting several issuers could not express "only tokens from the internal `IdP` may call this tool" before, because the subject claims bag is the only route from a claim to a policy and registered claims were always dropped. `claims.exclude` drops one the other way. ([#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. ([#31](https://github.com/praxis-proxy/policy/pull/31)) -- **Each shipped preset records what it deliberately omits.** Two of the three providers put their roles claim somewhere no preset can name: Auth0 forbids a bare `roles` claim so roles arrive under a deployment's own URL namespace, and Keycloak's per-client roles sit under the client id an operator chose. Both need a hand-written `claim_map`. Presets also leave a field empty rather than filling it with the wrong concept, and Keycloak's `groups` claim holds realm roles where Cognito's `cognito:roles` holds IAM role ARNs, because a field quietly filled with the wrong thing gives an operator no reason to look. Each preset's description names its gaps and which of its claims are opt-in at the provider. ([#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.` 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 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.`, 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)) From 1549a880e08ccefe478216ec57611a5e14345232 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 21 Aug 2026 10:18:47 -0400 Subject: [PATCH 20/27] fix(identity-jwt)!: refuse a SPIFFE ID with no trust domain The authority carries the trust domain and SPIFFE requires it, so `spiffe:///ns/default/sa/agent` named no trust boundary yet still became a workload identity whose trust domain was the empty string. Both mappers now share one check, so they cannot drift on what a SPIFFE ID is. Signed-off-by: Frederico Araujo --- CHANGELOG.md | 2 + .../plugins/identity-jwt/src/claim_map.rs | 86 ++++++++++++++++--- .../identity-jwt/src/configured_mapper.rs | 49 +++++++---- builtins/plugins/identity-jwt/src/resolver.rs | 7 +- .../tests/fixtures/claim-corpus.json | 9 ++ .../tests/standard_preset_equivalence.rs | 17 +++- 6 files changed, 134 insertions(+), 36 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b3c0118..6f75067 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,8 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - **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 diff --git a/builtins/plugins/identity-jwt/src/claim_map.rs b/builtins/plugins/identity-jwt/src/claim_map.rs index e8c2e49..acf1091 100644 --- a/builtins/plugins/identity-jwt/src/claim_map.rs +++ b/builtins/plugins/identity-jwt/src/claim_map.rs @@ -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), @@ -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 { + 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")] @@ -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 diff --git a/builtins/plugins/identity-jwt/src/configured_mapper.rs b/builtins/plugins/identity-jwt/src/configured_mapper.rs index 46551e9..57d0b6e 100644 --- a/builtins/plugins/identity-jwt/src/configured_mapper.rs +++ b/builtins/plugins/identity-jwt/src/configured_mapper.rs @@ -17,15 +17,12 @@ use praxis_policy_core::extensions::raw_credentials::TokenRole; use praxis_policy_core::extensions::{ClientExtension, SubjectExtension, WorkloadIdentity}; use serde_json::Value; -use crate::claim_map::{ClaimMap, ClaimMapper}; +use crate::claim_map::{ClaimMap, ClaimMapper, is_spiffe_id, trust_domain_of}; use crate::claim_map_config::{ CompiledCandidate, CompiledClaimMap, CompiledField, CompiledRoleMap, MergeMode, OnMissing, SplitMode, }; -/// Every SPIFFE ID starts here, and no configuration can turn the check off. -const SPIFFE_SCHEME: &str = "spiffe://"; - /// The registered JWT claims, which the claims bag drops unless a map asks for /// one back. They are properties of token validation rather than subject /// attributes. @@ -422,12 +419,10 @@ impl ClaimMapper for ConfiguredClaimMap { let section = self.map.role(&TokenRole::CallerWorkload).ok()?; let mut diag = Diagnostics::new("workload"); - // Prefix-check every candidate before it counts as resolving: a - // non-SPIFFE `sub` must not smuggle in an arbitrary `spiffe_id` claim, - // and a later SPIFFE-shaped candidate must still win. - let spiffe_id = anchor(section, "spiffe_id", claims, &mut diag, |text| { - text.starts_with(SPIFFE_SCHEME) - }); + // Check every candidate before it counts as resolving: a non-SPIFFE + // `sub` must not smuggle in an arbitrary `spiffe_id` claim, and a later + // SPIFFE-shaped candidate must still win. + let spiffe_id = anchor(section, "spiffe_id", claims, &mut diag, is_spiffe_id); let client_id = scalar(section, "client_id", claims, &mut diag, accept_any); let selectors = collection(section, "selectors", claims, &mut diag); @@ -437,6 +432,8 @@ impl ClaimMapper for ConfiguredClaimMap { } let spiffe_id = spiffe_id?; + // `is_spiffe_id` already required the authority, so this cannot be + // `None`. let trust_domain = trust_domain_of(&spiffe_id); Some(WorkloadIdentity { @@ -450,15 +447,6 @@ impl ClaimMapper for ConfiguredClaimMap { } } -/// The trust domain is the SPIFFE URI's authority, which the standard makes the -/// trust boundary. Deriving it from `iss` instead is explicitly discouraged. -fn trust_domain_of(spiffe_id: &str) -> Option { - spiffe_id - .strip_prefix(SPIFFE_SCHEME) - .and_then(|rest| rest.split('/').next()) - .map(str::to_owned) -} - #[cfg(test)] #[allow( clippy::unwrap_used, @@ -1141,6 +1129,29 @@ mod tests { ); } + /// The scheme alone is not a SPIFFE ID: the authority carries the trust + /// domain the standard makes mandatory. An authority-less candidate is + /// unusable like any other, so it is skipped and a valid one behind it wins. + #[test] + fn a_spiffe_id_with_no_authority_declines() { + let map = mapper(json!({"workload": {"spiffe_id": ["sub", "spiffe_id"]}})); + + for id in ["spiffe:///ns/default/sa/agent", "spiffe://", "spiffe:///"] { + assert!( + map.map_workload(&claims(json!({"sub": id}))).is_none(), + "`{id}` names no trust domain, so it is not an identity" + ); + } + + let workload = map + .map_workload(&claims(json!({ + "sub": "spiffe:///ns/default/sa/agent", + "spiffe_id": "spiffe://corp.example/ns/default/sa/agent", + }))) + .expect("a valid SPIFFE candidate behind an authority-less one resolves"); + assert_eq!(workload.trust_domain.as_deref(), Some("corp.example")); + } + /// There is no configuration that turns the prefix check off: it is not a /// field, an option, or a candidate key, so every one of these is rejected /// or has no bearing on it. diff --git a/builtins/plugins/identity-jwt/src/resolver.rs b/builtins/plugins/identity-jwt/src/resolver.rs index bd3609d..ad5fb86 100644 --- a/builtins/plugins/identity-jwt/src/resolver.rs +++ b/builtins/plugins/identity-jwt/src/resolver.rs @@ -616,9 +616,10 @@ impl HookHandler for JwtIdentityResolver { return PluginResult::deny(PluginViolation::new( "auth.mapping_failed", "the claim map produced no workload: no candidate resolved to a \ - `spiffe://` identity, which every candidate must, or a field \ - declaring `on_missing: deny` resolved nothing. Raise the log \ - level to debug to see which fields and which paths were tried", + `spiffe://` identity carrying a trust domain, which every \ + candidate must, or a field declaring `on_missing: deny` resolved \ + nothing. Raise the log level to debug to see which fields and \ + which paths were tried", )); }, }, diff --git a/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json b/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json index 6b58562..56c4bd9 100644 --- a/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json +++ b/builtins/plugins/identity-jwt/tests/fixtures/claim-corpus.json @@ -1005,6 +1005,15 @@ "attestor": "jwt" } }, + { + "name": "workload-spiffe-id-with-no-authority-declines", + "role": "workload", + "provenance": "Constructed: the trust domain is the URI authority and SPIFFE requires it, so a string carrying the scheme alone names no trust boundary. Tightened in this PR; the mapper previously filed it with an empty trust domain.", + "claims": { + "sub": "spiffe:///ns/default/sa/agent" + }, + "expected": null + }, { "name": "workload-trust-domain-from-a-spiffe-id-with-no-path", "role": "workload", diff --git a/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs b/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs index 63d8853..eca702d 100644 --- a/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs +++ b/builtins/plugins/identity-jwt/tests/standard_preset_equivalence.rs @@ -405,7 +405,8 @@ enum Wins { Array, /// A plain scalar candidate: any string wins. AnyString, - /// The workload chain filters every candidate by the SPIFFE prefix. + /// The workload chain filters every candidate by the SPIFFE scheme and a + /// non-empty authority. SpiffeString, } @@ -414,9 +415,11 @@ impl Wins { match self { Self::Array => value.is_array(), Self::AnyString => value.is_string(), - Self::SpiffeString => value - .as_str() - .is_some_and(|text| text.starts_with("spiffe://")), + Self::SpiffeString => value.as_str().is_some_and(|text| { + text.strip_prefix("spiffe://") + .and_then(|rest| rest.split('/').next()) + .is_some_and(|authority| !authority.is_empty()) + }), } } } @@ -727,6 +730,12 @@ fn shapes() -> Vec<(&'static str, Option)> { // Without a SPIFFE-shaped string every workload case declines on both // sides, which agrees vacuously and proves nothing about that role. ("spiffe id", Some(json!("spiffe://corp.example/ns/a/sa/b"))), + // Carries the scheme but no trust domain, so it is the case where the + // two sides could disagree about what the prefix check accepts. + ( + "spiffe id with no authority", + Some(json!("spiffe:///ns/a/sa/b")), + ), ("non-spiffe uri", Some(json!("https://corp.example/ns/a"))), ("two words", Some(json!("two words"))), ("empty array", Some(json!([]))), From df8ae41542bd96f51474c41797d4590a0f9c7e6f Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 21 Aug 2026 10:19:57 -0400 Subject: [PATCH 21/27] fix(identity-jwt): name every claim in both override lists at once Validation returned on the first claim found in both `exclude` and `include`, so an operator with two of them fixed one, restarted, and met the next. Signed-off-by: Frederico Araujo --- .../identity-jwt/src/claim_map_config.rs | 61 +++++++++++++++---- 1 file changed, 50 insertions(+), 11 deletions(-) diff --git a/builtins/plugins/identity-jwt/src/claim_map_config.rs b/builtins/plugins/identity-jwt/src/claim_map_config.rs index b5c2274..2bb68d1 100644 --- a/builtins/plugins/identity-jwt/src/claim_map_config.rs +++ b/builtins/plugins/identity-jwt/src/claim_map_config.rs @@ -10,7 +10,7 @@ // mistake into "data did not match any variant", and the whole point of failing // at construction is telling the operator which field and which path. -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use praxis_policy_core::extensions::raw_credentials::TokenRole; use serde::{Deserialize, Serialize}; @@ -318,18 +318,25 @@ impl ClaimsOverrides { /// /// # Errors /// - /// Returns a message naming the claim when one appears in both lists. There - /// is no coherent intent to honour, and picking a winner silently would hide - /// the mistake. + /// Returns a message naming every claim that appears in both lists. There is + /// no coherent intent to honour, and picking a winner silently would hide the + /// mistake. All of them at once, so fixing one does not uncover the next on + /// the following startup. pub fn validate(&self) -> Result<(), String> { - for claim in &self.include { - if self.exclude.iter().any(|excluded| excluded == claim) { - return Err(format!( - "claims: `{claim}` is in both `exclude` and `include`; pick one" - )); - } + let both: BTreeSet<&str> = self + .include + .iter() + .filter(|claim| self.exclude.contains(claim)) + .map(String::as_str) + .collect(); + if both.is_empty() { + return Ok(()); } - Ok(()) + let named: Vec = both.iter().map(|claim| format!("`{claim}`")).collect(); + Err(format!( + "claims: {} in both `exclude` and `include`; pick one list for each", + named.join(", ") + )) } } @@ -860,6 +867,38 @@ mod tests { assert!(err.contains("exclude") && err.contains("include"), "{err}"); } + /// Every overlap in one message: an operator fixing them one startup at a + /// time is a round-trip per mistake. + #[test] + fn every_claim_in_both_lists_is_named_at_once() { + let overrides: ClaimsOverrides = serde_json::from_value(json!({ + "exclude": ["tenant", "jti", "region"], + "include": ["region", "iss", "tenant"], + })) + .expect("the overrides deserialize"); + let err = overrides + .validate() + .expect_err("two claims are both dropped and kept"); + assert!(err.contains("tenant"), "{err}"); + assert!(err.contains("region"), "{err}"); + assert!( + !err.contains("jti") && !err.contains("iss"), + "a claim in one list only is not a conflict: {err}" + ); + } + + /// A name repeated within `include` is one conflict, not two. + #[test] + fn a_repeated_claim_is_named_once() { + let overrides: ClaimsOverrides = serde_json::from_value(json!({ + "exclude": ["tenant"], + "include": ["tenant", "tenant"], + })) + .expect("the overrides deserialize"); + let err = overrides.validate().expect_err("tenant conflicts"); + assert_eq!(err.matches("tenant").count(), 1, "{err}"); + } + /// The overrides are a plugin-level setting, so a `claims` block written inside /// a map is rejected and the valid sections are listed. #[test] From c601474b37969de0fb83f255aecde570191abfaf Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Fri, 21 Aug 2026 10:52:53 -0400 Subject: [PATCH 22/27] test(identity-jwt): stop the diagnostics capture racing the interest cache Callsite interest is cached process-wide, so `with_default` per test did not own whether an event fired: a parallel test installing its own subscriber rebuilt the cache and could leave the callsite disabled between the `debug!` and the assertion. One subscriber, installed once and always interested, with a thread-local sink per test. Signed-off-by: Frederico Araujo --- .../identity-jwt/src/configured_mapper.rs | 68 +++++++++++++++---- 1 file changed, 56 insertions(+), 12 deletions(-) diff --git a/builtins/plugins/identity-jwt/src/configured_mapper.rs b/builtins/plugins/identity-jwt/src/configured_mapper.rs index 57d0b6e..667ee83 100644 --- a/builtins/plugins/identity-jwt/src/configured_mapper.rs +++ b/builtins/plugins/identity-jwt/src/configured_mapper.rs @@ -455,7 +455,8 @@ impl ClaimMapper for ConfiguredClaimMap { reason = "tests" )] mod tests { - use std::sync::{Arc, Mutex}; + use std::cell::RefCell; + use std::sync::{Arc, Mutex, OnceLock}; use serde_json::json; @@ -501,6 +502,14 @@ mod tests { // A minimal subscriber rather than a dev-dependency: the diagnostics are // asserted on, so they need capturing, and `tracing` alone is enough to do // it. + // + // One global subscriber with a thread-local sink, not `with_default` per + // test. Callsite interest is cached process-wide, so a thread-local + // subscriber does not own whether an event fires: installing one rebuilds + // the cache, and a test running in parallel can have its callsite recached + // as disabled between the `debug!` and the assertion. A subscriber that is + // installed once and always interested takes the cache out of the race, and + // the sink keeps each test reading only its own events. #[derive(Clone, Default)] struct Events(Arc>>); @@ -521,7 +530,21 @@ mod tests { } } - struct Capture(Events); + thread_local! { + static SINK: RefCell> = const { RefCell::new(None) }; + } + + struct Capture; + + /// Clears the sink even if the body panics, so a failing test cannot leak + /// its events into whichever test the runner puts on this thread next. + struct Sink; + + impl Drop for Sink { + fn drop(&mut self) { + SINK.with_borrow_mut(|sink| *sink = None); + } + } struct Render(String); @@ -536,6 +559,16 @@ mod tests { } impl tracing::Subscriber for Capture { + /// Always, so the cached interest never depends on which thread first + /// reached the callsite. + fn register_callsite(&self, _: &tracing::Metadata<'_>) -> tracing::subscriber::Interest { + tracing::subscriber::Interest::always() + } + + fn max_level_hint(&self) -> Option { + Some(tracing::level_filters::LevelFilter::TRACE) + } + fn enabled(&self, _: &tracing::Metadata<'_>) -> bool { true } @@ -549,13 +582,18 @@ mod tests { fn record_follows_from(&self, _: &tracing::span::Id, _: &tracing::span::Id) {} fn event(&self, event: &tracing::Event<'_>) { - let mut render = Render(format!("[{}]", event.metadata().level())); - event.record(&mut render); - self.0 - .0 - .lock() - .expect("the event log is not poisoned") - .push(render.0); + SINK.with_borrow(|sink| { + let Some(events) = sink.as_ref() else { + return; + }; + let mut render = Render(format!("[{}]", event.metadata().level())); + event.record(&mut render); + events + .0 + .lock() + .expect("the event log is not poisoned") + .push(render.0); + }); } fn enter(&self, _: &tracing::span::Id) {} @@ -565,10 +603,16 @@ mod tests { /// Run `body` with events captured. fn capturing(body: impl FnOnce() -> T) -> (T, Events) { + static INSTALLED: OnceLock<()> = OnceLock::new(); + INSTALLED.get_or_init(|| { + tracing::subscriber::set_global_default(Capture) + .expect("no other subscriber is installed in this test binary"); + }); + let events = Events::default(); - let subscriber = Capture(events.clone()); - let value = tracing::subscriber::with_default(subscriber, body); - (value, events) + SINK.with_borrow_mut(|sink| *sink = Some(events.clone())); + let _guard = Sink; + (body(), events) } // ---- candidate resolution and merge ----------------------------------- From 7c38c5173eb7d858207e29008046e3bdbc16894b Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Sat, 22 Aug 2026 07:55:46 -0400 Subject: [PATCH 23/27] fix(identity-jwt): dedupe a union merge A `merge: union` field concatenated its candidates, so a value two of them carry arrived twice. The subject fields are sets downstream and swallowed it, but the client fields are `Vec`s, so the same map behaved differently per role and the duplicate reached audit logs and serialized output. Keycloak naming one role under both `realm_access` and `resource_access.` is the ordinary case. Deduped in the engine, first-seen order kept. `first_match` is left alone: duplicates there are the claim's own content. Signed-off-by: Frederico Araujo --- CHANGELOG.md | 2 +- .../identity-jwt/src/configured_mapper.rs | 49 +++++++++++++++++-- 2 files changed, 45 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f75067..9e95734 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,7 @@ 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. 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)) +- **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. ([#31](https://github.com/praxis-proxy/policy/pull/31)) diff --git a/builtins/plugins/identity-jwt/src/configured_mapper.rs b/builtins/plugins/identity-jwt/src/configured_mapper.rs index 667ee83..3fd4eb0 100644 --- a/builtins/plugins/identity-jwt/src/configured_mapper.rs +++ b/builtins/plugins/identity-jwt/src/configured_mapper.rs @@ -162,6 +162,19 @@ fn resolve_collection(field: &CompiledField, claims: &ClaimMap) -> FieldOutcome } } + // A union merges candidate lists, so a value both of them carry arrives + // twice: Keycloak naming the same role under `realm_access` and + // `resource_access.` is the ordinary case. The subject fields are + // sets downstream and would swallow it, but the client fields are `Vec`s and + // would carry it into audit logs and serialized output, so the same map + // would behave differently per role. Deduped here, first-seen order kept, so + // it behaves the same everywhere. A single candidate is left alone under + // `first_match`: duplicates there are the claim's own content. + if field.merge() == MergeMode::Union { + let mut seen: HashSet = HashSet::with_capacity(values.len()); + values.retain(|value| seen.insert(value.clone())); + } + FieldOutcome { values, resolved, @@ -646,11 +659,12 @@ mod tests { assert_eq!(sorted(&first.roles), vec!["realm-admin"]); } - /// Order is candidate-declaration order, then in-array order, and a value in - /// two candidates appears twice. Only a `Vec` destination shows it; the - /// deduplication a set gives is the set's, not the engine's. + /// Order is candidate-declaration order, then in-array order, and a value two + /// candidates carry appears once, at its first position. A `Vec` destination + /// is where this is visible at all: a set would have hidden it, which is + /// exactly why the engine cannot leave it to the destination. #[test] - fn union_preserves_declaration_order_and_does_not_deduplicate() { + fn union_preserves_declaration_order_and_deduplicates() { let token = claims(json!({ "client_id": "svc", "primary": ["a", "b"], @@ -664,9 +678,34 @@ mod tests { })) .map_client(&token) .unwrap(); - assert_eq!(client.roles, vec!["a", "b", "b", "c"]); + assert_eq!(client.roles, vec!["a", "b", "c"]); + } + + /// The union case a Keycloak realm actually hits: a role granted both at the + /// realm and on the client reaches `client.roles` once. + #[test] + fn a_role_in_both_unioned_candidates_reaches_a_client_once() { + let token = claims(json!({ + "client_id": "svc", + "realm_access": {"roles": ["admin", "viewer"]}, + "resource_access": {"api": {"roles": ["admin", "auditor"]}}, + })); + let client = mapper(json!({ + "client": { + "client_id": "client_id", + "roles": { + "paths": ["realm_access.roles", "resource_access.api.roles"], + "merge": "union", + }, + } + })) + .map_client(&token) + .unwrap(); + assert_eq!(client.roles, vec!["admin", "viewer", "auditor"]); } + /// Under `first_match` a repeated value is the claim's own content, not an + /// artifact of merging, so it is carried through as authored. #[test] fn a_set_destination_deduplicates_where_a_vec_destination_does_not() { let token = claims(json!({ From 4c7eb9c1d4191852167910c851abf73b881715a8 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Sat, 22 Aug 2026 07:56:10 -0400 Subject: [PATCH 24/27] fix(identity-jwt): refuse a `claims` override that cannot apply Both override lists take top-level claim names, because the claims bag is keyed by name. Nothing said so: every other path-shaped field in this config takes dotted syntax, so `claims: {exclude: [realm_access.roles]}` loaded without complaint, matched nothing, and left the claim visible to policy. The names are parsed at load now, the same way a path is, so a dotted entry fails there and a claim whose own name holds a dot is reachable by writing `\.`. The parsed names reach the mapper, so nothing parses per request. A `role: caller_workload` resolver has no claims bag at all, so the setting is inert there. It warns at load, which is how the undeclared anchor one field over is handled. Signed-off-by: Frederico Araujo --- CHANGELOG.md | 2 +- .../identity-jwt/src/claim_map_config.rs | 136 +++++++++++++++--- builtins/plugins/identity-jwt/src/config.rs | 5 + .../identity-jwt/src/configured_mapper.rs | 35 ++++- builtins/plugins/identity-jwt/src/lib.rs | 4 +- builtins/plugins/identity-jwt/src/resolver.rs | 42 +++++- 6 files changed, 189 insertions(+), 35 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e95734..690c360 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,7 +19,7 @@ The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). - **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. ([#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)) diff --git a/builtins/plugins/identity-jwt/src/claim_map_config.rs b/builtins/plugins/identity-jwt/src/claim_map_config.rs index 2bb68d1..bc813c4 100644 --- a/builtins/plugins/identity-jwt/src/claim_map_config.rs +++ b/builtins/plugins/identity-jwt/src/claim_map_config.rs @@ -10,7 +10,7 @@ // mistake into "data did not match any variant", and the whole point of failing // at construction is telling the operator which field and which path. -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; use praxis_policy_core::extensions::raw_credentials::TokenRole; use serde::{Deserialize, Serialize}; @@ -314,23 +314,23 @@ pub struct ClaimsOverrides { } impl ClaimsOverrides { - /// Check the two lists do not disagree with each other. + /// Parse every name, and check the two lists do not disagree with each + /// other. /// /// # Errors /// - /// Returns a message naming every claim that appears in both lists. There is - /// no coherent intent to honour, and picking a winner silently would hide the + /// Returns a message naming an entry that does not address one top-level + /// claim, or every claim that appears in both lists. A claim in both has no + /// coherent intent to honour, and picking a winner silently would hide the /// mistake. All of them at once, so fixing one does not uncover the next on /// the following startup. - pub fn validate(&self) -> Result<(), String> { - let both: BTreeSet<&str> = self - .include - .iter() - .filter(|claim| self.exclude.contains(claim)) - .map(String::as_str) - .collect(); + pub fn compile(&self) -> Result { + let exclude = compile_claim_names("exclude", &self.exclude)?; + let include = compile_claim_names("include", &self.include)?; + + let both: BTreeSet<&str> = include.intersection(&exclude).map(String::as_str).collect(); if both.is_empty() { - return Ok(()); + return Ok(CompiledClaimsOverrides { exclude, include }); } let named: Vec = both.iter().map(|claim| format!("`{claim}`")).collect(); Err(format!( @@ -340,6 +340,57 @@ impl ClaimsOverrides { } } +/// Parse the names in one override list, rejecting anything that does not +/// address a single top-level claim. +/// +/// The bag is keyed by claim name, so a dotted entry such as +/// `realm_access.roles` would match nothing. Every other path-shaped field in +/// this config takes dotted syntax, which makes writing one here a plausible +/// mistake rather than a contrived one, so it fails at load instead. A claim +/// whose name really holds a dot is written `\.`, the escape a path already +/// uses. +fn compile_claim_names(list: &str, names: &[String]) -> Result, String> { + names + .iter() + .map(|name| { + let path = ClaimPath::parse(name).map_err(|e| format!("claims.{list}: {e}"))?; + path.single_segment().map(str::to_owned).ok_or_else(|| { + format!( + "claims.{list}: `{name}` addresses a nested claim, and the claims bag is \ + keyed by top-level claim name; name the top-level claim, or write a literal \ + dot as `\\.`" + ) + }) + }) + .collect() +} + +/// The overrides with every name parsed: single top-level claim names, +/// unescaped, ready to match a key in the bag. +#[derive(Debug, Clone, Default)] +pub struct CompiledClaimsOverrides { + exclude: HashSet, + include: HashSet, +} + +impl CompiledClaimsOverrides { + /// Claims to drop even though nothing consumed them. + pub fn exclude(&self) -> impl Iterator { + self.exclude.iter().map(String::as_str) + } + + /// Claims to keep even though a path consumed them, or because the inferred + /// exclusions always drop them. + pub fn include(&self) -> impl Iterator { + self.include.iter().map(String::as_str) + } + + /// Whether the operator asked for neither. + pub fn is_empty(&self) -> bool { + self.exclude.is_empty() && self.include.is_empty() + } +} + /// One role's authored section: field name to field map. /// /// Field names are checked against the role's own set during compilation, which @@ -525,7 +576,7 @@ pub struct CompiledClaimMap { subject: Option, client: Option, workload: Option, - claims: ClaimsOverrides, + claims: CompiledClaimsOverrides, } impl CompiledClaimMap { @@ -553,7 +604,7 @@ impl CompiledClaimMap { } /// The claims-bag overrides. - pub fn claims(&self) -> &ClaimsOverrides { + pub fn claims(&self) -> &CompiledClaimsOverrides { &self.claims } @@ -562,7 +613,7 @@ impl CompiledClaimMap { /// Applied after the map compiles, so a preset and an inline map reach them /// the same way. #[must_use] - pub fn with_claims(mut self, claims: ClaimsOverrides) -> Self { + pub fn with_claims(mut self, claims: CompiledClaimsOverrides) -> Self { self.claims = claims; self } @@ -583,7 +634,7 @@ impl ClaimMapConfig { subject: compile_role("subject", SUBJECT_FIELDS, self.subject.as_ref())?, client: compile_role("client", CLIENT_FIELDS, self.client.as_ref())?, workload: compile_role("workload", WORKLOAD_FIELDS, self.workload.as_ref())?, - claims: ClaimsOverrides::default(), + claims: CompiledClaimsOverrides::default(), }) } } @@ -847,12 +898,12 @@ mod tests { .expect("the overrides deserialize"); assert_eq!(with.exclude, vec!["internal_debug"]); assert_eq!(with.include, vec!["iss"]); - with.validate().expect("distinct lists are coherent"); + with.compile().expect("distinct lists are coherent"); let without = ClaimsOverrides::default(); assert!(without.exclude.is_empty()); assert!(without.include.is_empty()); - without.validate().expect("empty lists are coherent"); + without.compile().expect("empty lists are coherent"); } #[test] @@ -861,7 +912,7 @@ mod tests { serde_json::from_value(json!({"exclude": ["tenant"], "include": ["tenant"]})) .expect("the overrides deserialize"); let err = overrides - .validate() + .compile() .expect_err("a claim cannot be both dropped and kept"); assert!(err.contains("tenant"), "{err}"); assert!(err.contains("exclude") && err.contains("include"), "{err}"); @@ -877,7 +928,7 @@ mod tests { })) .expect("the overrides deserialize"); let err = overrides - .validate() + .compile() .expect_err("two claims are both dropped and kept"); assert!(err.contains("tenant"), "{err}"); assert!(err.contains("region"), "{err}"); @@ -895,10 +946,53 @@ mod tests { "include": ["tenant", "tenant"], })) .expect("the overrides deserialize"); - let err = overrides.validate().expect_err("tenant conflicts"); + let err = overrides.compile().expect_err("tenant conflicts"); assert_eq!(err.matches("tenant").count(), 1, "{err}"); } + /// The bag is keyed by claim name, so a dotted entry would match nothing. It + /// is a plausible mistake, since every other path-shaped field here takes + /// dotted syntax, so it fails at load rather than quietly doing nothing. + #[test] + fn a_dotted_override_entry_is_rejected_and_named() { + for list in ["exclude", "include"] { + let overrides: ClaimsOverrides = + serde_json::from_value(json!({list: ["realm_access.roles"]})) + .expect("the overrides deserialize"); + let err = overrides + .compile() + .expect_err("a nested path is not a claim name"); + assert!(err.contains("realm_access.roles"), "{err}"); + assert!(err.contains(list), "the message names the list: {err}"); + } + } + + /// A claim name that really holds dots, an Auth0 namespaced claim being the + /// usual one, stays reachable through the same escape a path uses. + #[test] + fn an_escaped_dot_names_a_claim_whose_name_holds_one() { + let overrides: ClaimsOverrides = serde_json::from_value(json!({ + "exclude": ["https://my-app\\.example\\.com/roles"], + })) + .expect("the overrides deserialize"); + let compiled = overrides.compile().expect("an escaped dot is a claim name"); + assert_eq!( + compiled.exclude().collect::>(), + vec!["https://my-app.example.com/roles"], + "the name is stored unescaped, which is how it reaches the bag" + ); + } + + /// A malformed escape is the operator's typo, not a claim name, so it says so + /// rather than dropping a claim nothing is named after. + #[test] + fn a_malformed_escape_in_an_override_entry_is_rejected() { + let overrides: ClaimsOverrides = serde_json::from_value(json!({"include": ["tenant\\x"]})) + .expect("the overrides deserialize"); + let err = overrides.compile().expect_err("`\\x` is not an escape"); + assert!(err.contains("include"), "{err}"); + } + /// The overrides are a plugin-level setting, so a `claims` block written inside /// a map is rejected and the valid sections are listed. #[test] diff --git a/builtins/plugins/identity-jwt/src/config.rs b/builtins/plugins/identity-jwt/src/config.rs index 008c8aa..2c13626 100644 --- a/builtins/plugins/identity-jwt/src/config.rs +++ b/builtins/plugins/identity-jwt/src/config.rs @@ -98,6 +98,11 @@ pub struct JwtIdentityResolverConfig { /// gating on the issuing `IdP` expressible, since the subject claims bag is /// the only route from a claim to a policy. /// + /// Both lists take top-level claim names rather than paths, because the bag + /// is keyed by name. A dotted entry is refused at load, and a claim whose own + /// name holds a dot is written with `\.`. A `caller_workload` resolver has no + /// claims bag, so the setting is inert there and says so at load. + /// /// Read as a raw value so a malformed one names the field. /// /// [`claim_mapper`]: Self::claim_mapper diff --git a/builtins/plugins/identity-jwt/src/configured_mapper.rs b/builtins/plugins/identity-jwt/src/configured_mapper.rs index 3fd4eb0..0c224b2 100644 --- a/builtins/plugins/identity-jwt/src/configured_mapper.rs +++ b/builtins/plugins/identity-jwt/src/configured_mapper.rs @@ -66,11 +66,11 @@ impl ConfiguredClaimMap { } } let overrides = self.map.claims(); - for name in &overrides.exclude { - excluded.insert(name.as_str()); + for name in overrides.exclude() { + excluded.insert(name); } - for name in &overrides.include { - excluded.remove(name.as_str()); + for name in overrides.include() { + excluded.remove(name); } claims @@ -495,12 +495,11 @@ mod tests { let config: ClaimMapConfig = serde_json::from_value(map).expect("the map deserializes"); let overrides: ClaimsOverrides = serde_json::from_value(claims).expect("the overrides deserialize"); - overrides.validate().expect("the overrides are coherent"); ConfiguredClaimMap::new( config .compile() .expect("the map compiles") - .with_claims(overrides), + .with_claims(overrides.compile().expect("the overrides are coherent")), ) } @@ -996,6 +995,30 @@ mod tests { assert_eq!(subject.claims.get("exp"), Some(&json!(2_000_000_000_i64))); } + /// The override names reach the bag unescaped, so a claim whose name holds a + /// dot is dropped by the escaped spelling and not by the literal one. + #[test] + fn an_escaped_override_name_matches_the_claim_it_names() { + let token = claims(json!({ + "sub": "alice", + "https://my-app.example.com/roles": ["admin"], + "tenant": "acme", + })); + let subject = mapper_with_claims( + json!({"subject": {"id": "sub"}}), + json!({"exclude": ["https://my-app\\.example\\.com/roles"]}), + ) + .map_subject(&token) + .unwrap(); + + assert!( + !subject + .claims + .contains_key("https://my-app.example.com/roles") + ); + assert_eq!(subject.claims.get("tenant"), Some(&json!("acme"))); + } + // ---- diagnostics ------------------------------------------------------ /// A mistyped path leaves the field empty and says so, naming the field and diff --git a/builtins/plugins/identity-jwt/src/lib.rs b/builtins/plugins/identity-jwt/src/lib.rs index 93b8603..0f97e11 100644 --- a/builtins/plugins/identity-jwt/src/lib.rs +++ b/builtins/plugins/identity-jwt/src/lib.rs @@ -71,8 +71,8 @@ pub mod trusted_issuer; pub use claim_map::{ClaimMap, ClaimMapper, StandardClaimMap}; pub use claim_map_config::{ - ClaimMapConfig, ClaimsOverrides, CompiledClaimMap, CompiledRoleMap, MergeMode, OnMissing, - SplitMode, + ClaimMapConfig, ClaimsOverrides, CompiledClaimMap, CompiledClaimsOverrides, CompiledRoleMap, + MergeMode, OnMissing, SplitMode, }; pub use claim_path::ClaimPath; pub use config::{DecodingKeySource, JwtIdentityResolverConfig, TrustedIssuerConfig}; diff --git a/builtins/plugins/identity-jwt/src/resolver.rs b/builtins/plugins/identity-jwt/src/resolver.rs index ad5fb86..3ddd79e 100644 --- a/builtins/plugins/identity-jwt/src/resolver.rs +++ b/builtins/plugins/identity-jwt/src/resolver.rs @@ -59,7 +59,7 @@ use praxis_policy_core::identity::{IdentityHook, IdentityPayload}; use praxis_policy_core::plugin::{Plugin, PluginConfig}; use super::claim_map::{ClaimMap, ClaimMapper}; -use super::claim_map_config::ClaimsOverrides; +use super::claim_map_config::{ClaimsOverrides, CompiledClaimsOverrides}; use super::config::{JwtIdentityResolverConfig, TrustedIssuerConfig}; use super::configured_mapper::ConfiguredClaimMap; use super::presets; @@ -225,7 +225,7 @@ impl JwtIdentityResolver { }) }; - let claims_overrides: ClaimsOverrides = match typed.claims.as_ref() { + let claims_overrides: CompiledClaimsOverrides = match typed.claims.as_ref() { Some(value) => { let parsed: ClaimsOverrides = serde_json::from_value(value.clone()).map_err(|e| { @@ -233,12 +233,22 @@ impl JwtIdentityResolver { "`claims` takes `exclude` and `include` lists of claim names: {e}" )) })?; - parsed.validate().map_err(&config_error)?; - parsed + parsed.compile().map_err(&config_error)? }, - None => ClaimsOverrides::default(), + None => CompiledClaimsOverrides::default(), }; + // A workload identity carries no claims bag, so the overrides would sit + // there doing nothing. Same treatment as the undeclared anchor below: + // the condition is static, so say it once at load. + if matches!(typed.role, TokenRole::CallerWorkload) && !claims_overrides.is_empty() { + tracing::warn!( + plugin = %cfg.name, + "`claims` has no effect under `role: caller_workload`, which carries no claims \ + bag; the overrides will be ignored", + ); + } + let compiled = match (typed.claim_map.as_ref(), typed.claim_mapper.as_deref()) { (Some(_), Some(named)) => { return Err(config_error(format!( @@ -1012,6 +1022,28 @@ mod tests { .expect("every documented key together must still build"); } + /// A dotted override entry matches nothing, so the resolver refuses it at + /// load rather than starting with a claim the operator meant to drop still + /// visible to policy. + #[test] + fn new_rejects_a_dotted_claim_override() { + let err = build_err(json!({"claims": {"exclude": ["realm_access.roles"]}})); + assert!(err.contains("realm_access.roles"), "{err}"); + assert!(err.contains("exclude"), "{err}"); + } + + /// A workload identity has no claims bag, so the overrides cannot do + /// anything. Building still succeeds, with a warning, which is how the + /// undeclared anchor is handled one field over. + #[test] + fn a_workload_resolver_still_builds_with_claims_overrides_it_cannot_use() { + JwtIdentityResolver::new(cfg_with_mapper(json!({ + "role": "caller_workload", + "claims": {"include": ["iss"]}, + }))) + .expect("the overrides are inert here, not a config error"); + } + #[test] fn new_rejects_both_claim_mapper_and_claim_map() { let err = build_err(json!({ From f2b3cfeede7292fbeb73c94837f7366cfd64ad55 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Sat, 22 Aug 2026 07:56:16 -0400 Subject: [PATCH 25/27] perf(identity-jwt): render the miss diagnostics only when debug is on `Diagnostics` rendered every path a field tried and formatted the event strings before the subscriber's level was consulted. The comment justified it by a miss being rare, which it is not: a plain `{sub, email}` token under the standard preset misses `roles`, `permissions` and `teams`, so every request paid five path renderings and three `format!`s that were then dropped. The level is checked once per mapping call. The `on_missing: deny` bookkeeping stays ungated, since it decides the answer rather than what gets logged. Signed-off-by: Frederico Araujo --- .../identity-jwt/src/configured_mapper.rs | 28 +++++++++++++++---- 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/builtins/plugins/identity-jwt/src/configured_mapper.rs b/builtins/plugins/identity-jwt/src/configured_mapper.rs index 0c224b2..49b500d 100644 --- a/builtins/plugins/identity-jwt/src/configured_mapper.rs +++ b/builtins/plugins/identity-jwt/src/configured_mapper.rs @@ -210,8 +210,9 @@ fn resolve_scalar( (None, tried) } -/// Render the paths a field reached, for a diagnostic. Called only when a field -/// missed, so the escaping cost never lands on a resolving request. +/// Render the paths a field reached, for a diagnostic. Called only for a field +/// that missed while something is listening at `debug`, since the escaping cost +/// buys nothing otherwise. fn paths_tried(field: &CompiledField, tried: usize) -> Vec { field .candidates() @@ -229,6 +230,14 @@ fn paths_tried(field: &CompiledField, tried: usize) -> Vec { /// per request rather than one per field. struct Diagnostics { role: &'static str, + /// Whether anything is listening at `debug`, checked once per mapping call. + /// + /// A miss is the common case rather than the rare one: a plain `{sub, email}` + /// token under the standard preset misses `roles`, `permissions` and + /// `teams`, so rendering every path tried would cost every request a handful + /// of allocations the subscriber then drops. The `deny` bookkeeping below is + /// not gated: it decides the answer, not what gets logged. + detailed: bool, missed: Vec<(&'static str, Vec)>, empty: Vec<&'static str>, denied: Vec<&'static str>, @@ -238,6 +247,7 @@ impl Diagnostics { fn new(role: &'static str) -> Self { Self { role, + detailed: tracing::enabled!(tracing::Level::DEBUG), missed: Vec::new(), empty: Vec::new(), denied: Vec::new(), @@ -251,12 +261,14 @@ impl Diagnostics { outcome: &FieldOutcome, ) -> bool { if outcome.resolved { - if outcome.values.is_empty() { + if outcome.values.is_empty() && self.detailed { self.empty.push(name); } return true; } - self.missed.push((name, paths_tried(field, outcome.tried))); + if self.detailed { + self.missed.push((name, paths_tried(field, outcome.tried))); + } if field.on_missing() == OnMissing::Deny { self.denied.push(name); } @@ -264,7 +276,9 @@ impl Diagnostics { } fn record_scalar_miss(&mut self, name: &'static str, field: &CompiledField, tried: usize) { - self.missed.push((name, paths_tried(field, tried))); + if self.detailed { + self.missed.push((name, paths_tried(field, tried))); + } if field.on_missing() == OnMissing::Deny { self.denied.push(name); } @@ -277,7 +291,9 @@ impl Diagnostics { /// asked for never reaches the resolution path. The condition is static, so /// the loud warning belongs at construction rather than once per request. fn record_undeclared_anchor(&mut self, name: &'static str) { - self.missed.push((name, Vec::new())); + if self.detailed { + self.missed.push((name, Vec::new())); + } } /// Whether a field declared `on_missing: deny` and did not resolve. From b9e42d39c5395d029338d93c6ae2088244153461 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Sat, 22 Aug 2026 23:02:55 -0400 Subject: [PATCH 26/27] docs(identity-jwt): say which merge mode the dedupe skips Signed-off-by: Frederico Araujo --- builtins/plugins/identity-jwt/src/configured_mapper.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/builtins/plugins/identity-jwt/src/configured_mapper.rs b/builtins/plugins/identity-jwt/src/configured_mapper.rs index 49b500d..4d1d6d5 100644 --- a/builtins/plugins/identity-jwt/src/configured_mapper.rs +++ b/builtins/plugins/identity-jwt/src/configured_mapper.rs @@ -168,8 +168,12 @@ fn resolve_collection(field: &CompiledField, claims: &ClaimMap) -> FieldOutcome // sets downstream and would swallow it, but the client fields are `Vec`s and // would carry it into audit logs and serialized output, so the same map // would behave differently per role. Deduped here, first-seen order kept, so - // it behaves the same everywhere. A single candidate is left alone under - // `first_match`: duplicates there are the claim's own content. + // it behaves the same everywhere. The dedupe covers the whole result rather + // than just the seam between candidates, so a lone candidate's own repeats + // go too, which is the "each value once" the union is documented to give. + // `first_match` never dedupes: it selects one candidate instead of combining + // any, so a repeat reaching it is the claim's own content, and passing it + // through is what the Rust standard mapper does. if field.merge() == MergeMode::Union { let mut seen: HashSet = HashSet::with_capacity(values.len()); values.retain(|value| seen.insert(value.clone())); From efbbb590ef475b4083a41ca1e2d45894eb1e8f4c Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Sat, 22 Aug 2026 23:02:55 -0400 Subject: [PATCH 27/27] fix(identity-jwt): report every bad override name at once Signed-off-by: Frederico Araujo --- .../identity-jwt/src/claim_map_config.rs | 92 ++++++++++++++----- 1 file changed, 69 insertions(+), 23 deletions(-) diff --git a/builtins/plugins/identity-jwt/src/claim_map_config.rs b/builtins/plugins/identity-jwt/src/claim_map_config.rs index bc813c4..c7c0281 100644 --- a/builtins/plugins/identity-jwt/src/claim_map_config.rs +++ b/builtins/plugins/identity-jwt/src/claim_map_config.rs @@ -319,14 +319,20 @@ impl ClaimsOverrides { /// /// # Errors /// - /// Returns a message naming an entry that does not address one top-level - /// claim, or every claim that appears in both lists. A claim in both has no - /// coherent intent to honour, and picking a winner silently would hide the - /// mistake. All of them at once, so fixing one does not uncover the next on - /// the following startup. + /// Returns a message naming every entry that does not address one + /// top-level claim, or every claim that appears in both lists. A claim in + /// both has no coherent intent to honour, and picking a winner silently + /// would hide the mistake. Bad names across both lists at once, so fixing + /// one does not uncover the next on the following startup. The overlap + /// check waits for the names to parse, since an entry that is not a claim + /// name cannot conflict with one. pub fn compile(&self) -> Result { - let exclude = compile_claim_names("exclude", &self.exclude)?; - let include = compile_claim_names("include", &self.include)?; + let (exclude, mut problems) = compile_claim_names("exclude", &self.exclude); + let (include, include_problems) = compile_claim_names("include", &self.include); + problems.extend(include_problems); + if !problems.is_empty() { + return Err(problems.join("; ")); + } let both: BTreeSet<&str> = include.intersection(&exclude).map(String::as_str).collect(); if both.is_empty() { @@ -340,8 +346,8 @@ impl ClaimsOverrides { } } -/// Parse the names in one override list, rejecting anything that does not -/// address a single top-level claim. +/// Parse the names in one override list, returning the ones that address a +/// single top-level claim and a message for each entry that does not. /// /// The bag is keyed by claim name, so a dotted entry such as /// `realm_access.roles` would match nothing. Every other path-shaped field in @@ -349,20 +355,41 @@ impl ClaimsOverrides { /// mistake rather than a contrived one, so it fails at load instead. A claim /// whose name really holds a dot is written `\.`, the escape a path already /// uses. -fn compile_claim_names(list: &str, names: &[String]) -> Result, String> { - names - .iter() - .map(|name| { - let path = ClaimPath::parse(name).map_err(|e| format!("claims.{list}: {e}"))?; - path.single_segment().map(str::to_owned).ok_or_else(|| { - format!( - "claims.{list}: `{name}` addresses a nested claim, and the claims bag is \ - keyed by top-level claim name; name the top-level claim, or write a literal \ - dot as `\\.`" - ) - }) - }) - .collect() +/// +/// Every bad entry is reported rather than the first, so an operator fixing a +/// list sees all of it. The nested ones share one message, since they share one +/// remedy; a malformed escape carries the parser's own reason. +fn compile_claim_names(list: &str, names: &[String]) -> (HashSet, Vec) { + let mut parsed = HashSet::new(); + let mut problems = Vec::new(); + let mut nested = Vec::new(); + + for name in names { + match ClaimPath::parse(name) { + Err(e) => problems.push(format!("claims.{list}: {e}")), + Ok(path) => match path.single_segment() { + Some(segment) => { + parsed.insert(segment.to_owned()); + }, + None => nested.push(format!("`{name}`")), + }, + } + } + + if !nested.is_empty() { + let addresses = if nested.len() == 1 { + "addresses a nested claim" + } else { + "address nested claims" + }; + problems.push(format!( + "claims.{list}: {} {addresses}, and the claims bag is keyed by top-level claim \ + name; name the top-level claim, or write a literal dot as `\\.`", + nested.join(", ") + )); + } + + (parsed, problems) } /// The overrides with every name parsed: single top-level claim names, @@ -967,6 +994,25 @@ mod tests { } } + /// The doc promises every bad name at once. Two dotted entries in different + /// lists, so a fix to one does not uncover the other on the next startup. + #[test] + fn every_bad_override_entry_is_named_at_once() { + let overrides: ClaimsOverrides = serde_json::from_value(json!({ + "exclude": ["realm_access.roles", "tenant"], + "include": ["resource_access.api", "tenant\\x"], + })) + .expect("the overrides deserialize"); + + let err = overrides.compile().expect_err("three entries are unusable"); + assert!(err.contains("realm_access.roles"), "{err}"); + assert!(err.contains("resource_access.api"), "{err}"); + assert!( + err.contains("tenant\\x"), + "the malformed escape is named too: {err}" + ); + } + /// A claim name that really holds dots, an Auth0 namespaced claim being the /// usual one, stays reachable through the same escape a path uses. #[test]