diff --git a/builtins/plugins/delegator-oauth/src/config.rs b/builtins/plugins/delegator-oauth/src/config.rs index e01b3a67..8807fdfb 100644 --- a/builtins/plugins/delegator-oauth/src/config.rs +++ b/builtins/plugins/delegator-oauth/src/config.rs @@ -62,6 +62,28 @@ pub struct OAuthDelegatorConfig { /// deployments must leave this at the default (`false`). #[serde(default)] pub insecure_http: bool, + + /// The `actor_token_type` we tell the IdP the RFC 8693 + /// `actor_token` is — a token-type URN. Defaults to + /// `...:token-type:jwt` because the actor is almost always a + /// JWT-SVID. Only consulted when the `DelegationPayload` carries a + /// non-empty `actor_token` (attached upstream by the invoker from + /// the inbound workload SVID); otherwise the exchange stays + /// single-token and behaves exactly as before. + #[serde(default = "default_actor_token_type")] + pub actor_token_type: String, + + /// The `client_assertion_type` used in leg 1 of a workload + /// delegation (`subject: caller_workload`), where the calling + /// agent authenticates by presenting its JWT-SVID as an RFC 7523 + /// client assertion rather than a secret. Defaults to the + /// SPIFFE-specific URN from draft-ietf-oauth-spiffe-client-auth — + /// NOT the generic `...:jwt-bearer` — because that's what a SPIFFE + /// authorization server (e.g. Keycloak's SPIFFE provider) expects. + /// Only consulted on the `caller_workload` path; every other + /// subject authenticates with the client secret as before. + #[serde(default = "default_workload_assertion_type")] + pub workload_assertion_type: String, } /// Where the gateway's OAuth client secret is loaded from. Three @@ -88,6 +110,14 @@ fn default_subject_token_type() -> String { "urn:ietf:params:oauth:token-type:access_token".to_string() } +fn default_actor_token_type() -> String { + "urn:ietf:params:oauth:token-type:jwt".to_string() +} + +fn default_workload_assertion_type() -> String { + "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe".to_string() +} + fn default_timeout_seconds() -> u64 { 5 } @@ -137,6 +167,16 @@ mod tests { assert_eq!(cfg.client_id, "gateway"); assert_eq!(cfg.timeout_seconds, 5); assert_eq!(cfg.default_outbound_header, "Authorization"); + // actor_token_type defaults to the JWT token-type URN (the + // actor is almost always a JWT-SVID); only used when the + // payload carries a non-empty actor_token. + assert_eq!(cfg.actor_token_type, "urn:ietf:params:oauth:token-type:jwt"); + // workload_assertion_type defaults to the SPIFFE-specific + // client-assertion URN (leg 1 of a caller_workload delegation). + assert_eq!( + cfg.workload_assertion_type, + "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe" + ); } #[test] diff --git a/builtins/plugins/delegator-oauth/src/delegator.rs b/builtins/plugins/delegator-oauth/src/delegator.rs index 3dd99e83..4d6b2b5b 100644 --- a/builtins/plugins/delegator-oauth/src/delegator.rs +++ b/builtins/plugins/delegator-oauth/src/delegator.rs @@ -17,6 +17,8 @@ // subject_token_type= // audience= // scope= +// actor_token= (only if payload carries one) +// actor_token_type= (only if actor_token sent) // 3. POST to the IdP's token endpoint with HTTP Basic auth // (client_id / client_secret). // 4. Parse the JSON response: `{ access_token, token_type, @@ -39,6 +41,7 @@ // scopes don't include all // requested permissions +use std::borrow::Cow; use std::sync::Arc; use async_trait::async_trait; @@ -47,7 +50,7 @@ use serde::Deserialize; use zeroize::Zeroizing; use cpex_core::context::PluginContext; -use cpex_core::delegation::{DelegationPayload, TokenDelegateHook}; +use cpex_core::delegation::{DelegationPayload, DelegationSubject, TokenDelegateHook}; use cpex_core::error::{PluginError, PluginViolation}; use cpex_core::extensions::raw_credentials::{DelegationMode, RawDelegatedToken}; use cpex_core::hooks::payload::Extensions; @@ -60,6 +63,13 @@ use super::config::OAuthDelegatorConfig; /// `grant_type` in the form-encoded request body. const GRANT_TYPE_TOKEN_EXCHANGE: &str = "urn:ietf:params:oauth:grant-type:token-exchange"; +/// RFC 6749 §4.4 client-credentials grant — "give me a token as +/// myself". Used when the delegation subject is `this_workload` (this +/// CPEX instance itself): there is no inbound credential to exchange, +/// and its identity is the OAuth client identity it already +/// authenticates with. +const GRANT_TYPE_CLIENT_CREDENTIALS: &str = "client_credentials"; + /// Default issued-token-type RFC 8693 returns. We don't rely on it /// for behavior — it's reported back to operators in audit logs /// only. @@ -183,6 +193,85 @@ impl OAuthDelegator { } scopes.join(" ") } + + /// Leg 1 of a workload delegation (`subject: caller_workload`): + /// authenticate the calling agent by presenting its JWT-SVID as an + /// RFC 7523 client assertion, and return the IdP-issued base token. + /// + /// There is no Basic auth and no `client_id` — the assertion *is* + /// the client credential, and the IdP resolves which client from + /// the SVID's `sub` (draft-ietf-oauth-spiffe-client-auth). The base + /// token this returns then becomes the `subject_token` of the + /// ordinary exchange (leg 2), which is where the downstream + /// audience/scope — the authority the agent itself lacks — is + /// actually granted. Splitting it this way is what keeps the + /// enforcement point, not the agent, as the holder of downstream authority. + /// + /// Errors map to the same `delegation.*` violation codes the + /// exchange uses, so a failed leg 1 denies the whole delegation. + async fn mint_base_token(&self, svid: &str) -> Result { + let form = [ + ("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS), + ( + "client_assertion_type", + self.typed.workload_assertion_type.as_str(), + ), + ("client_assertion", svid), + ]; + + let response = match self + .http + .post(&self.typed.token_endpoint) + .form(&form) + .send() + .await + { + Ok(r) => r, + Err(e) if e.is_timeout() => { + return Err(PluginViolation::new( + "delegation.idp_timeout", + format!( + "workload client_assertion to {} timed out", + self.typed.token_endpoint + ), + )); + }, + Err(e) => { + return Err(PluginViolation::new( + "delegation.idp_unreachable", + format!( + "workload client_assertion POST to {} failed: {e}", + self.typed.token_endpoint + ), + )); + }, + }; + + let status = response.status(); + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let reason = match serde_json::from_str::(&body) { + Ok(err) => { + let mut reason = err.error.clone(); + if let Some(desc) = err.error_description { + reason.push_str(": "); + reason.push_str(&desc); + } + reason + }, + Err(_) => format!("IdP returned {status}: {body}"), + }; + return Err(PluginViolation::new("delegation.idp_rejected", reason)); + } + + match response.json::().await { + Ok(parsed) => Ok(parsed.access_token), + Err(e) => Err(PluginViolation::new( + "delegation.bad_response", + format!("workload client_assertion response wasn't valid token JSON: {e}"), + )), + } + } } /// Subset of the RFC 8693 response we care about. @@ -224,8 +313,23 @@ impl HookHandler for OAuthDelegator { _ext: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { + // `subject: this_workload` means *we* are the principal. There + // is no inbound credential to exchange — this instance's identity + // is its OAuth client identity, which it already proves via the + // Basic auth header below. The standard grant for "give me a + // token as myself" is client_credentials, not token exchange. + let as_this_workload = *payload.subject() == DelegationSubject::ThisWorkload; + + // `subject: caller_workload` means the calling agent acts as + // itself, and `bearer` is its JWT-SVID. An SVID is a *client + // credential*, not a `subject_token` — an authorization server + // won't accept it as an exchange subject — so leg 1 below trades + // it for an ordinary IdP token that the exchange (leg 2) can + // then scope down. + let is_workload = *payload.subject() == DelegationSubject::CallerWorkload; + let bearer = payload.bearer_token(); - if bearer.is_empty() { + if bearer.is_empty() && !as_this_workload { return PluginResult::deny(PluginViolation::new( "delegation.bad_request", "DelegationPayload carried an empty bearer_token — outbound \ @@ -243,17 +347,67 @@ impl HookHandler for OAuthDelegator { let scope = Self::requested_scopes(payload); - // Build the form-encoded body. RFC 8693 §2.1. - let mut form: Vec<(&str, &str)> = vec![ - ("grant_type", GRANT_TYPE_TOKEN_EXCHANGE), - ("subject_token", bearer), - ("subject_token_type", &self.typed.subject_token_type), - ("audience", audience), - ]; + // Leg 1 (workload only): the SVID in `bearer` authenticates the + // agent as a client; mint the IdP base token here and let the + // exchange below run on it. Every other subject exchanges its + // own `bearer` directly. `Cow` avoids cloning the (already + // borrowed) bearer on the non-workload path. + let subject_token: Cow = if is_workload { + match self.mint_base_token(bearer).await { + Ok(token) => Cow::Owned(token), + Err(violation) => return PluginResult::deny(violation), + } + } else { + Cow::Borrowed(bearer) + }; + + // Build the form-encoded body: RFC 6749 §4.4 for this instance + // acting as itself, RFC 8693 §2.1 for every exchange on behalf + // of somebody else. + let mut form: Vec<(&str, &str)> = if as_this_workload { + vec![ + ("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS), + ("audience", audience), + ] + } else { + vec![ + ("grant_type", GRANT_TYPE_TOKEN_EXCHANGE), + // On the workload path this is the leg-1 base token, not + // the raw SVID; on every other path it's the caller's + // own bearer, unchanged. + ("subject_token", subject_token.as_ref()), + ("subject_token_type", &self.typed.subject_token_type), + ("audience", audience), + ] + }; if !scope.is_empty() { form.push(("scope", &scope)); } + // RFC 8693 §2.1 actor_token. Present only when the invoker + // attached one (sourced from the inbound SVID in + // `RawCredentialsExtension[CallerWorkload]`). Including it + // makes the IdP mint a token carrying `act` = actor alongside + // `sub` = subject — the delegation is recorded in the token + // itself. Absent, the exchange stays single-token. + // + // Skipped entirely under client_credentials: `actor_token` is + // a token-exchange parameter and has no meaning in RFC 6749 + // §4.4, so sending it would be malformed. A route that wants + // this instance as principal *and* the calling agent recorded in + // `act` needs a real subject credential for this instance — + // i.e. its own SVID — rather than client_credentials. + // + // Also skipped on the workload path: there the workload *is* the + // subject (via the leg-1 base token), so there is no separate + // actor to record. `actor_token` belongs to the on-behalf-of + // shape (a user subject with the calling agent as actor). + let actor_token = payload.actor_token(); + if !actor_token.is_empty() && !as_this_workload && !is_workload { + form.push(("actor_token", actor_token)); + form.push(("actor_token_type", &self.typed.actor_token_type)); + } + // POST to the IdP. Basic auth carries our client credentials. let response = match self .http @@ -385,7 +539,7 @@ impl HookHandler for OAuthDelegator { let mut updated = payload.clone(); updated.delegated_token = Some(token); - updated.delegation_mode = Some(DelegationMode::OnBehalfOfUser); + updated.delegation_mode = Some(mode_for_subject(payload.subject())); updated.minted_at = Some(Utc::now()); if let Some(issued) = parsed.issued_token_type { updated.metadata.insert( @@ -403,6 +557,30 @@ impl HookHandler for OAuthDelegator { } } +/// Who the minted token speaks for, derived from the exchange's +/// subject rather than declared independently of it. +/// +/// A `CallerWorkload` subject means no user was in the picture — the +/// *calling agent* exchanged its own SPIFFE JWT-SVID, so the +/// resulting credential speaks for that agent. `ThisWorkload` means we +/// are the principal. Everything else (a user token, an OAuth client +/// token) is the ordinary on-behalf-of shape. +/// +/// This matters beyond bookkeeping: `apply_to_extensions` keys the +/// delegated-token cache off the mode, so calling a workload-subject +/// exchange `OnBehalfOfUser` would file the token under a user +/// identity that never participated. +fn mode_for_subject(subject: &DelegationSubject) -> DelegationMode { + match subject { + DelegationSubject::CallerWorkload => DelegationMode::AsCallerWorkload, + DelegationSubject::ThisWorkload => DelegationMode::AsThisWorkload, + // `DelegationSubject` is #[non_exhaustive]; User, Client and + // any future variant all describe a principal this instance is + // acting *for*, so on-behalf-of stays the safe default. + _ => DelegationMode::OnBehalfOfUser, + } +} + // Silence unused-import warning when only a subset of these is // reached in any given config path. Kept as a single place so the // crate's surface is visible at a glance. diff --git a/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs b/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs index 734ac307..c9491de3 100644 --- a/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs +++ b/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs @@ -16,14 +16,19 @@ // * IdP unreachable — surfaces `delegation.idp_unreachable` // * Request body shape — mockito's matcher verifies we send the // correct RFC 8693 fields +// * actor_token — present on the wire when the payload carries one +// (Mode B), fully absent when it doesn't +// * workload subject (Mode A) — the SVID authenticates the agent as +// a client_assertion (leg 1), then the exchange runs on that base +// token (leg 2); attributed `AsCallerWorkload`, not `AsThisWorkload` use std::sync::Arc; use cpex_core::delegation::{ - AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType, TokenDelegateHook, - HOOK_TOKEN_DELEGATE, + AttenuationConfig, AuthEnforcedBy, DelegationPayload, DelegationSubject, TargetType, + TokenDelegateHook, HOOK_TOKEN_DELEGATE, }; -use cpex_core::extensions::raw_credentials::DelegationMode; +use cpex_core::extensions::raw_credentials::{DelegationMode, TokenRole}; use cpex_core::hooks::payload::Extensions; use cpex_core::manager::PluginManager; use cpex_core::plugin::{OnError, PluginConfig, PluginMode}; @@ -364,3 +369,316 @@ async fn idp_exact_scope_match_succeeds() { ); mock.assert_async().await; } + +// ===================================================================== +// RFC 8693 actor_token / subject-role attribution +// ===================================================================== + +/// Standard 200 response body, factored out so the actor tests can +/// focus on what they're actually asserting (the request side). +fn ok_token_response() -> String { + json!({ + "access_token": "minted-downstream-jwt", + "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", + "expires_in": 300, + "scope": "read:compensation", + }) + .to_string() +} + +/// Mode B — user subject + workload actor. The delegator must put the +/// SVID on the wire as RFC 8693 §2.1 `actor_token`, tagged with the +/// configured `actor_token_type`, alongside the user's `subject_token`. +/// This is the on-behalf-of-a-user shape, and the +/// minted token still speaks for the user. +#[tokio::test] +async fn actor_token_reaches_the_idp_when_the_payload_carries_one() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/oauth/token") + .match_body(Matcher::AllOf(vec![ + // The user is still the subject... + Matcher::UrlEncoded("subject_token".into(), "caller-bearer-token-bytes".into()), + // ...and the workload SVID rides along as the actor. + Matcher::UrlEncoded("actor_token".into(), "workload.svid.bytes".into()), + Matcher::UrlEncoded( + "actor_token_type".into(), + "urn:ietf:params:oauth:token-type:jwt".into(), + ), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(ok_token_response()) + .create_async() + .await; + + let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; + let payload = build_payload( + "get_compensation", + "https://hr.example.com", + &["read:compensation"], + ) + .with_actor(TokenRole::CallerWorkload, "workload.svid.bytes"); + + let result = invoke(&mgr, payload).await; + assert!( + result.continue_processing, + "actor-token exchange should mint a token; violation = {:?}", + result.violation, + ); + + let final_payload = DelegationPayload::from_pipeline_result(&result) + .expect("delegation payload should be present"); + // Subject is the user, so the token still speaks for the user + // even though a workload actor was recorded. + assert!(matches!( + final_payload.delegation_mode, + Some(DelegationMode::OnBehalfOfUser), + )); + + // If the actor fields hadn't been sent, the matcher above would + // have failed to match and this assertion would fire. + mock.assert_async().await; +} + +/// The negative half: a payload with no actor must produce a plain +/// single-token exchange. Asserted by rejecting any request whose body +/// mentions `actor_token` at all — a stray empty `actor_token=` field +/// would confuse strict IdPs, so "absent" has to mean absent. +#[tokio::test] +async fn absent_actor_leaves_no_actor_fields_on_the_wire() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/oauth/token") + .match_request(|req| { + let body = req.body().expect("request has a body"); + !String::from_utf8_lossy(body).contains("actor_token") + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(ok_token_response()) + .create_async() + .await; + + let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; + // No `.with_actor_token(...)` — the ordinary single-token case. + let payload = build_payload( + "get_compensation", + "https://hr.example.com", + &["read:compensation"], + ); + + let result = invoke(&mgr, payload).await; + assert!( + result.continue_processing, + "single-token exchange should still succeed; violation = {:?}", + result.violation, + ); + mock.assert_async().await; +} + +/// `subject: this_workload` — this instance holds the access to the +/// downstream (the "hold the tool credentials here" deployment) +/// and calls it as itself. There is no inbound credential to +/// exchange, so this must switch to an RFC 6749 §4.4 +/// `client_credentials` grant rather than a token exchange: no +/// `subject_token`, and this instance's identity proven by the Basic +/// auth header it already sends. +#[tokio::test] +async fn this_workload_subject_uses_client_credentials_not_token_exchange() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/oauth/token") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("grant_type".into(), "client_credentials".into()), + Matcher::UrlEncoded("audience".into(), "https://hr.example.com".into()), + ])) + // A token exchange sends subject_token; this must not. + .match_request(|req| { + let body = req.body().expect("request has a body"); + !String::from_utf8_lossy(body).contains("subject_token") + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(ok_token_response()) + .create_async() + .await; + + let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; + // Note the empty bearer token: for a this_workload subject that is the + // expected state, not the "caller forgot the credential" error. + let payload = DelegationPayload::new("", "get_compensation") + .with_subject(DelegationSubject::ThisWorkload) + .with_target_audience("https://hr.example.com") + .with_required_permissions(vec!["read:compensation".into()]); + + let result = invoke(&mgr, payload).await; + assert!( + result.continue_processing, + "this_workload-subject exchange should mint a token; violation = {:?}", + result.violation, + ); + + let final_payload = DelegationPayload::from_pipeline_result(&result) + .expect("delegation payload should be present"); + assert!( + matches!( + final_payload.delegation_mode, + Some(DelegationMode::AsThisWorkload), + ), + "this_workload subject must be attributed to this instance, got {:?}", + final_payload.delegation_mode, + ); + mock.assert_async().await; +} + +/// An empty bearer token is still an error for every subject that +/// *does* have an inbound credential. Pins the boundary: the +/// this_workload's exemption must not silently swallow a genuinely missing +/// workload or user token. +#[tokio::test] +async fn empty_bearer_still_rejected_for_non_this_workload_subjects() { + let mgr = build_manager("https://unused.example.com/oauth/token").await; + let payload = DelegationPayload::new("", "get_compensation") + .with_subject(DelegationSubject::CallerWorkload) + .with_target_audience("https://hr.example.com"); + + let result = invoke(&mgr, payload).await; + assert!( + !result.continue_processing, + "a missing credential must still be an error for a workload subject", + ); + assert_eq!( + result.violation.expect("violation surfaced").code, + "delegation.bad_request", + ); +} + +/// `actor_token` is a token-exchange parameter with no meaning under +/// `client_credentials`, so a this_workload-subject call must not send it +/// even when the payload carries one — an IdP receiving both would be +/// getting a malformed request. +#[tokio::test] +async fn this_workload_subject_never_sends_actor_token() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/oauth/token") + .match_request(|req| { + let body = req.body().expect("request has a body"); + !String::from_utf8_lossy(body).contains("actor_token") + }) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(ok_token_response()) + .create_async() + .await; + + let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; + let payload = DelegationPayload::new("", "get_compensation") + .with_subject(DelegationSubject::ThisWorkload) + .with_actor(TokenRole::CallerWorkload, "workload.svid.bytes") + .with_target_audience("https://hr.example.com") + .with_required_permissions(vec!["read:compensation".into()]); + + let result = invoke(&mgr, payload).await; + assert!( + result.continue_processing, + "should still mint; violation = {:?}", + result.violation, + ); + mock.assert_async().await; +} + +/// Mode A — the calling agent acts as itself. Its SVID is a *client +/// credential*, not a subject_token, so the delegator runs two legs: +/// +/// leg 1 present the SVID as an RFC 7523 `client_assertion` +/// (client_credentials) → the agent's base IdP token; +/// leg 2 the ordinary exchange, run on that BASE token, scopes it +/// to the target audience. +/// +/// This is what keeps this instance (holder of the leg-2 client secret), +/// not the agent, as the grantor of downstream authority. The minted +/// credential still speaks for the agent, so `delegation_mode` must be +/// `AsCallerWorkload` (the delegated-token cache keys off it) — and +/// specifically not `AsThisWorkload`, which is this instance's own identity. +/// +/// Both legs are asserted: proving the SVID went out as a +/// `client_assertion` in leg 1 and that leg 2 exchanged the base token +/// — never the raw SVID as a subject_token. +#[tokio::test] +async fn workload_subject_authenticates_by_svid_then_exchanges() { + let mut server = Server::new_async().await; + + // Leg 1: SVID as client_assertion (jwt-spiffe) under + // client_credentials → the agent's base token. Must NOT be a + // subject_token here. + let leg1 = server + .mock("POST", "/oauth/token") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded("grant_type".into(), "client_credentials".into()), + Matcher::UrlEncoded( + "client_assertion_type".into(), + "urn:ietf:params:oauth:client-assertion-type:jwt-spiffe".into(), + ), + Matcher::UrlEncoded( + "client_assertion".into(), + "caller-bearer-token-bytes".into(), + ), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(json!({ "access_token": "agent-base-token", "expires_in": 300 }).to_string()) + .create_async() + .await; + + // Leg 2: the exchange runs on the BASE token from leg 1, not the + // SVID. Pinning subject_token here is what fails if leg 1 is + // skipped or the raw SVID leaks through as the subject. + let leg2 = server + .mock("POST", "/oauth/token") + .match_body(Matcher::AllOf(vec![ + Matcher::UrlEncoded( + "grant_type".into(), + "urn:ietf:params:oauth:grant-type:token-exchange".into(), + ), + Matcher::UrlEncoded("subject_token".into(), "agent-base-token".into()), + Matcher::UrlEncoded("audience".into(), "https://hr.example.com".into()), + ])) + .with_status(200) + .with_header("content-type", "application/json") + .with_body(ok_token_response()) + .create_async() + .await; + + let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; + let payload = build_payload( + "get_compensation", + "https://hr.example.com", + &["read:compensation"], + ) + .with_subject(DelegationSubject::CallerWorkload); + + let result = invoke(&mgr, payload).await; + assert!( + result.continue_processing, + "two-leg workload delegation should mint a token; violation = {:?}", + result.violation, + ); + + let final_payload = DelegationPayload::from_pipeline_result(&result) + .expect("delegation payload should be present"); + assert!( + matches!( + final_payload.delegation_mode, + Some(DelegationMode::AsCallerWorkload), + ), + "workload subject must be attributed to the calling agent, got {:?}", + final_payload.delegation_mode, + ); + + // Both legs actually fired — the SVID authenticated the agent (leg + // 1) and the exchange ran on the base token (leg 2). + leg1.assert_async().await; + leg2.assert_async().await; +} diff --git a/builtins/plugins/identity-jwt/src/claim_map.rs b/builtins/plugins/identity-jwt/src/claim_map.rs index bda5aa9e..2d138f9c 100644 --- a/builtins/plugins/identity-jwt/src/claim_map.rs +++ b/builtins/plugins/identity-jwt/src/claim_map.rs @@ -35,7 +35,7 @@ use cpex_core::extensions::{ClientExtension, SubjectExtension, WorkloadIdentity} /// `TokenRole::User`. /// * [`map_client`] — `client_id` plus client-shaped fields, for /// `TokenRole::Client`. -/// * [`map_workload`] — SPIFFE-style identity, for `TokenRole::Workload`. +/// * [`map_workload`] — SPIFFE-style identity, for `TokenRole::CallerWorkload`. /// /// Each defaults to `None` so existing custom mappers stay valid — /// they get implicit "this mapper doesn't know how to do that role," diff --git a/builtins/plugins/identity-jwt/src/resolver.rs b/builtins/plugins/identity-jwt/src/resolver.rs index 8f4793d2..858eae40 100644 --- a/builtins/plugins/identity-jwt/src/resolver.rs +++ b/builtins/plugins/identity-jwt/src/resolver.rs @@ -529,7 +529,7 @@ impl HookHandler for JwtIdentityResolver { )); }, }, - TokenRole::Workload => match self.claim_mapper.map_workload(&token_data.claims) { + TokenRole::CallerWorkload => match self.claim_mapper.map_workload(&token_data.claims) { Some(w) => updated.caller_workload = Some(w), None => { return PluginResult::deny(PluginViolation::new( @@ -562,13 +562,25 @@ impl HookHandler for JwtIdentityResolver { // stash by the resolver's configured role so multi-token // deployments (user + client + workload) keep each // credential addressable. + // + // Record the wire format accurately. A Workload token that + // reached this point has already been through + // `map_workload`, which only succeeds on a SPIFFE-shaped + // `sub` — so by construction it is a JWT-SVID, not a + // generic JWT. Consumers that branch on `TokenKind` (audit + // attribution, SPIFFE-aware validation) get the truth + // rather than having to re-parse the token to discover it. + let kind = match self.role { + TokenRole::CallerWorkload => TokenKind::SpiffeJwt, + _ => TokenKind::Jwt, + }; let mut raw_creds = updated .raw_credentials .clone() .unwrap_or_else(RawCredentialsExtension::default); raw_creds.inbound_tokens.insert( self.role.clone(), - RawInboundToken::new(raw_token, self.header.clone(), TokenKind::Jwt), + RawInboundToken::new(raw_token, self.header.clone(), kind), ); updated.raw_credentials = Some(raw_creds); updated.resolved_at = Some(chrono::Utc::now()); diff --git a/builtins/plugins/identity-jwt/tests/jwt_e2e.rs b/builtins/plugins/identity-jwt/tests/jwt_e2e.rs index a82b1417..d9395578 100644 --- a/builtins/plugins/identity-jwt/tests/jwt_e2e.rs +++ b/builtins/plugins/identity-jwt/tests/jwt_e2e.rs @@ -106,10 +106,30 @@ fn resolver_plugin_config() -> PluginConfig { } } +/// 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. +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 PluginManager + register the resolver + initialize. /// All four scenarios share this skeleton. async fn build_manager() -> Arc { - let cfg = resolver_plugin_config(); + 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(PluginManager::default()); @@ -125,11 +145,19 @@ async fn build_manager() -> Arc { /// Run a token through the full handler pipeline. async fn invoke(token: String) -> cpex_core::executor::PipelineResult { - let mgr = build_manager().await; + invoke_with(resolver_plugin_config(), token, TokenSource::Bearer).await +} + +async fn invoke_with( + cfg: PluginConfig, + token: String, + source: TokenSource, +) -> cpex_core::executor::PipelineResult { + let mgr = build_manager_with(cfg).await; let (result, _bg) = mgr .invoke_named::( HOOK_IDENTITY_RESOLVE, - IdentityPayload::new(token, TokenSource::Bearer), + IdentityPayload::new(token, source), Extensions::default(), None, ) @@ -191,6 +219,112 @@ async fn valid_jwt_resolves_subject() { assert!(matches!(user_token.kind, TokenKind::Jwt)); } +// --------------------------------------------------------------------- +// Workload role — SPIFFE JWT-SVID ingress +// --------------------------------------------------------------------- + +/// A resolver configured with `role: workload` is the ingress for the +/// caller's SPIFFE JWT-SVID. It must land the mapped identity in +/// `caller_workload` (the *calling agent*, distinct from the gateway's +/// own `this_workload`) and stash the raw bytes under +/// `TokenRole::CallerWorkload` — the slot a `delegate(...)` step reads from +/// when a route says `subject: workload` or `actor: workload`. +/// +/// The stash is tagged `TokenKind::SpiffeJwt`, not the generic `Jwt`: +/// reaching this point means `map_workload` already accepted the +/// SPIFFE-shaped `sub`, so the wire format is known, and consumers +/// that branch on kind shouldn't have to re-parse the token to learn +/// what the resolver already established. +#[tokio::test] +async fn workload_svid_resolves_caller_workload_and_stashes_as_spiffe_jwt() { + let svid = mint_jwt(json!({ + // SPIFFE JWT-SVID convention: the SPIFFE ID lives in `sub`. + "sub": "spiffe://corp.example/ns/default/sa/payroll-agent", + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "exp": now_unix() + 300, + "iat": now_unix(), + })); + + let result = invoke_with( + resolver_plugin_config_for("workload", "X-Workload-Token"), + svid.clone(), + TokenSource::SpiffeJwtSvid, + ) + .await; + assert!( + result.continue_processing, + "valid SVID should resolve: violation = {:?}", + result.violation, + ); + + let identity = + IdentityPayload::from_pipeline_result(&result).expect("payload should be present"); + + // Lands in caller_workload — the inbound peer — not subject. + let workload = identity + .caller_workload + .as_ref() + .expect("caller_workload populated"); + assert_eq!( + workload.spiffe_id.as_deref(), + Some("spiffe://corp.example/ns/default/sa/payroll-agent"), + ); + assert_eq!(workload.trust_domain.as_deref(), Some("corp.example")); + assert!( + identity.subject.is_none(), + "a workload-role resolver must not populate the user slot", + ); + + // Stashed under the Workload role, tagged as a SPIFFE JWT-SVID, + // and attributed to the header it arrived on. + let raw = identity + .raw_credentials + .as_ref() + .expect("raw_credentials populated"); + let workload_token = raw + .inbound_tokens + .get(&TokenRole::CallerWorkload) + .expect("workload-role token present"); + assert_eq!(&*workload_token.token, &svid); + assert_eq!(workload_token.source_header, "X-Workload-Token"); + assert!( + matches!(workload_token.kind, TokenKind::SpiffeJwt), + "workload SVID should be tagged SpiffeJwt, got {:?}", + workload_token.kind, + ); +} + +/// A `role: workload` resolver handed a perfectly valid *user* JWT +/// must refuse it rather than filing a non-SPIFFE identity into the +/// workload slot. Guards the boundary that makes `subject: workload` +/// meaningful: whatever is in that slot really is an attested +/// workload. +#[tokio::test] +async fn workload_role_rejects_a_non_spiffe_token() { + let user_jwt = mint_jwt(json!({ + "sub": "alice@corp.com", // no spiffe:// prefix + "iss": TEST_ISSUER, + "aud": TEST_AUDIENCE, + "exp": now_unix() + 300, + "iat": now_unix(), + })); + + let result = invoke_with( + resolver_plugin_config_for("workload", "X-Workload-Token"), + user_jwt, + TokenSource::SpiffeJwtSvid, + ) + .await; + + assert!( + !result.continue_processing, + "a non-SPIFFE token must not resolve as a workload", + ); + let violation = result.violation.expect("violation surfaced"); + assert_eq!(violation.code, "auth.mapping_failed"); +} + /// Token correctly signed by the test key but its `iss` doesn't /// match any trusted issuer in our config → `auth.untrusted_issuer`. /// This is the path where the peek-at-iss step does its job. diff --git a/crates/apl-cpex/src/delegation_invoker.rs b/crates/apl-cpex/src/delegation_invoker.rs index b4825d8e..1bc75a2c 100644 --- a/crates/apl-cpex/src/delegation_invoker.rs +++ b/crates/apl-cpex/src/delegation_invoker.rs @@ -45,7 +45,7 @@ use tokio::sync::Mutex; use cpex_core::delegation::{ payload::{AuthEnforcedBy, TargetType}, - DelegationPayload, TokenDelegateHook, + DelegationPayload, DelegationSubject, TokenDelegateHook, }; use cpex_core::extensions::raw_credentials::TokenRole; use cpex_core::hooks::payload::Extensions; @@ -111,33 +111,79 @@ impl DelegationInvoker for DelegationPluginInvoker { // snapshot is the per-call working copy. let current_extensions = self.extensions.lock().await.clone(); - // Pull the inbound bearer token from raw_credentials. Looks for - // the User-role token; future iterations can surface multi-token - // selection (Client / Workload) via step config. - let bearer_token = current_extensions - .raw_credentials - .as_ref() - .and_then(|rc| rc.inbound_tokens.get(&TokenRole::User)) - .map(|tok| (*tok.token).clone()) - .unwrap_or_default(); - - // Read step args. Step `config_override` is a yaml map per the IR - // — extract a few well-known keys onto the typed DelegationPayload + // Read step args first — the subject / actor role selection below + // reads from them. Step `config_override` is a yaml map per the IR; + // extract a few well-known keys onto the typed DelegationPayload // builders. Unknown keys still flow through to the plugin via the - // per-call config-override pathway (plugins consume them from - // their `cfg.config`). `target` is required (delegation needs to - // know who the downstream call is for); `audience`, `permissions`, - // `mode`, `auth_enforced_by` are recognized; everything else stays - // opaque. + // per-call config-override pathway (plugins consume them from their + // `cfg.config`). Recognized keys: `target` (required), `subject`, + // `actor`, `audience`, `permissions`, `target_type`, + // `auth_enforced_by`; everything else stays opaque. + // + // There is deliberately no `mode` key: the delegation mode is + // *derived* from `subject` by the handler rather than declared, so a + // route can't claim on-behalf-of-user while handing over a workload + // SVID. let cfg = step.config_override.as_ref().and_then(|v| v.as_mapping()); + // Resolve who the exchange is *for*. Defaults to the user + // (on-behalf-of); `subject: caller_workload` selects the + // caller's SVID for the no-user, agent-acting-autonomously + // exchange, `subject: client` the OAuth client token, and + // `subject: this_workload` means *we* are the principal. + // + // this_workload is the one subject with no inbound credential to + // read — this instance proves who it is with its own + // credentials, not with anything the caller sent. So + // `inbound_role()` returns None and the bearer token stays + // empty *by design*; the handler must not treat that as the + // "missing credential" error it is for every other subject. + let subject = cfg + .and_then(|m| m.get(serde_yaml::Value::String("subject".into()))) + .and_then(|v| v.as_str()) + .and_then(DelegationSubject::from_config_str) + .unwrap_or_default(); + let bearer_token = subject + .inbound_role() + .and_then(|role| { + current_extensions + .raw_credentials + .as_ref() + .and_then(|rc| rc.inbound_tokens.get(&role)) + .map(|tok| (*tok.token).clone()) + }) + .unwrap_or_default(); + let target_name: String = cfg .and_then(|m| m.get(serde_yaml::Value::String("target".into()))) .and_then(|v| v.as_str()) .unwrap_or(&step.plugin_name) .to_string(); - let mut payload = DelegationPayload::new(bearer_token, target_name); + // Carry the subject onto the payload. The delegator sees only + // opaque token bytes, so this is the only way it can tell an + // agent-acting-autonomously exchange from an on-behalf-of-user + // one — and that decides how the minted token gets attributed. + let mut payload = DelegationPayload::new(bearer_token, target_name).with_subject(subject); + + // Optional RFC 8693 actor. When the step opts in with e.g. + // `actor: caller_workload`, attach that inbound credential as + // the actor_token so the minted token records `act` = actor + // alongside `sub` = subject. Pairs naturally with + // `subject: this_workload`: this instance is the principal the + // backend trusts, while `act` records which agent caused the call. + // An absent credential leaves the exchange single-token. + if let Some(actor_role) = role_from_cfg(cfg, "actor") { + let actor_token = current_extensions + .raw_credentials + .as_ref() + .and_then(|rc| rc.inbound_tokens.get(&actor_role)) + .map(|tok| (*tok.token).clone()) + .unwrap_or_default(); + if !actor_token.is_empty() { + payload = payload.with_actor(actor_role, actor_token); + } + } if let Some(audience) = cfg .and_then(|m| m.get(serde_yaml::Value::String("audience".into()))) @@ -170,10 +216,10 @@ impl DelegationInvoker for DelegationPluginInvoker { payload = payload.with_auth_enforced_by(auth_enforced_by_from_str(enforcer)); } - // 5. Dispatch. The plan's pre-resolved entry already has any - // per-route config override merged into the plugin's - // instance config; what we're passing on this call is the - // typed payload (target / audience / permissions / etc.). + // Dispatch. The plan's pre-resolved entry already has any + // per-route config override merged into the plugin's + // instance config; what we're passing on this call is the + // typed payload (target / audience / permissions / etc.). let (result, _bg) = self .manager .invoke_entries::( @@ -184,7 +230,7 @@ impl DelegationInvoker for DelegationPluginInvoker { ) .await; - // 6. Translate the result. + // Translate the result. if !result.continue_processing { // Plugin denied (IdP refusal, validation failure, etc.). let decision = match result.violation { @@ -203,9 +249,9 @@ impl DelegationInvoker for DelegationPluginInvoker { return Ok(DelegationOutcome::deny(decision)); } - // 7. Pull the resolved DelegationPayload and apply to shared - // extensions so downstream code sees the minted token / - // updated chain. + // Pull the resolved DelegationPayload and apply to shared + // extensions so downstream code sees the minted token / + // updated chain. let resolved = DelegationPayload::from_pipeline_result(&result).ok_or_else(|| { DelegationError::Dispatch(format!( "plugin `{}` returned allow but no DelegationPayload", @@ -219,7 +265,7 @@ impl DelegationInvoker for DelegationPluginInvoker { *ext_lock = merged; } - // 8. Extract granted_* for the evaluator to surface into the bag. + // Extract granted_* for the evaluator to surface into the bag. let (granted_permissions, granted_audience, granted_expires_at) = match resolved.delegated_token { Some(tok) => ( @@ -239,6 +285,30 @@ impl DelegationInvoker for DelegationPluginInvoker { } } +/// Resolve a `TokenRole` from the `actor` step-config key, whose +/// value names an *inbound* credential. Returns `None` when the key +/// is absent or unrecognized, so the actor is simply omitted and the +/// exchange stays single-token — never silently substituted for a +/// typo'd role. +/// +/// Unlike `subject`, an actor is always an inbound credential: the +/// actor is by definition a party that presented itself to us. That's +/// why this returns `TokenRole` while the subject resolves to a +/// [`DelegationSubject`], which additionally admits `this_workload`. +/// +/// `"workload"` is accepted as a legacy spelling of `caller_workload`. +fn role_from_cfg(cfg: Option<&serde_yaml::Mapping>, key: &str) -> Option { + match cfg + .and_then(|m| m.get(serde_yaml::Value::String(key.into()))) + .and_then(|v| v.as_str()) + { + Some("user") => Some(TokenRole::User), + Some("client") => Some(TokenRole::Client), + Some("caller_workload") | Some("workload") => Some(TokenRole::CallerWorkload), + _ => None, + } +} + fn target_type_from_str(s: &str) -> TargetType { match s.to_ascii_lowercase().as_str() { "tool" => TargetType::Tool, @@ -257,3 +327,97 @@ fn auth_enforced_by_from_str(s: &str) -> AuthEnforcedBy { _ => AuthEnforcedBy::Caller, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Parse a YAML fragment into the step `config_override` mapping + /// shape the invoker receives (`step.config_override.as_mapping()`). + fn cfg(yaml: &str) -> serde_yaml::Mapping { + serde_yaml::from_str::(yaml) + .expect("valid yaml") + .as_mapping() + .expect("yaml is a mapping") + .clone() + } + + // --- subject selection (which credential is the exchange subject) --- + + #[test] + fn subject_workload_selects_svid_role() { + // Mode A: `subject: workload` routes the caller's SVID in as + // the subject_token of the exchange. + let m = cfg("subject: workload"); + assert_eq!( + role_from_cfg(Some(&m), "subject"), + Some(TokenRole::CallerWorkload) + ); + } + + #[test] + fn subject_user_selects_user_role() { + let m = cfg("subject: user"); + assert_eq!(role_from_cfg(Some(&m), "subject"), Some(TokenRole::User)); + } + + #[test] + fn subject_client_selects_client_role() { + let m = cfg("subject: client"); + assert_eq!(role_from_cfg(Some(&m), "subject"), Some(TokenRole::Client)); + } + + #[test] + fn subject_absent_returns_none_so_caller_defaults_to_user() { + // The helper does NOT bake in the User default — the caller + // (`.unwrap_or(TokenRole::User)`) does. An absent key must + // return None so on-behalf-of stays the default. + let m = cfg("target: hr-service"); + assert_eq!(role_from_cfg(Some(&m), "subject"), None); + } + + #[test] + fn unknown_role_returns_none_rather_than_guessing() { + // A typo'd role is not silently mapped to some default role — + // it returns None so the caller applies its own policy. + let m = cfg("subject: workloadd"); + assert_eq!(role_from_cfg(Some(&m), "subject"), None); + } + + // --- actor selection (RFC 8693 actor_token, Mode B) --- + + #[test] + fn actor_workload_selects_svid_role() { + // Mode B: `actor: workload` records the SVID as the act party + // alongside the user subject. + let m = cfg("actor: workload"); + assert_eq!( + role_from_cfg(Some(&m), "actor"), + Some(TokenRole::CallerWorkload) + ); + } + + #[test] + fn actor_absent_returns_none_so_exchange_stays_single_token() { + let m = cfg("subject: user"); + assert_eq!(role_from_cfg(Some(&m), "actor"), None); + } + + #[test] + fn missing_mapping_returns_none() { + assert_eq!(role_from_cfg(None, "subject"), None); + assert_eq!(role_from_cfg(None, "actor"), None); + } + + // --- both keys coexist (Mode B: user subject + workload actor) --- + + #[test] + fn subject_and_actor_resolve_independently() { + let m = cfg("subject: user\nactor: workload"); + assert_eq!(role_from_cfg(Some(&m), "subject"), Some(TokenRole::User)); + assert_eq!( + role_from_cfg(Some(&m), "actor"), + Some(TokenRole::CallerWorkload) + ); + } +} diff --git a/crates/apl-cpex/tests/elicit_then_delegate_e2e.rs b/crates/apl-cpex/tests/elicit_then_delegate_e2e.rs new file mode 100644 index 00000000..dd16c985 --- /dev/null +++ b/crates/apl-cpex/tests/elicit_then_delegate_e2e.rs @@ -0,0 +1,346 @@ +// Location: ./crates/apl-cpex/tests/elicit_then_delegate_e2e.rs +// Copyright 2026 +// SPDX-License-Identifier: Apache-2.0 +// Authors: Teryl Taylor +// +// Integration test for the composition of two effects in one route: +// an approval gate (`require_approval` → ElicitationPluginInvoker) that +// must pass before a delegation (`delegate` → DelegationPluginInvoker) +// runs. This is the manager-approval-then-apply pattern. +// +// The apl-core evaluator already unit-tests elicit sequencing and +// delegate dispatch in isolation. What this proves is the CPEX +// integration level: both real bridge invokers plugged into one +// `evaluate_route`, dispatching to real (fake-backed) plugins, in a +// single route. +// +// * Approval DENIED → route halts at the gate; the delegate plugin +// is never called (no token minted). +// * Approval APPROVED → gate passes; the delegate runs, mints a +// token, and `delegation.granted.*` is visible +// to the trailing post-check. + +use std::sync::{Arc, Mutex}; + +use async_trait::async_trait; +use chrono::{Duration, Utc}; + +use cpex_core::context::PluginContext; +use cpex_core::delegation::{DelegationPayload, TokenDelegateHook, HOOK_TOKEN_DELEGATE}; +use cpex_core::elicitation::{ + ElicitationHook, ElicitationOp, ElicitationOutcomeKind, ElicitationPayload, + ElicitationStatusKind, HOOK_ELICIT, +}; +use cpex_core::extensions::raw_credentials::{ + RawCredentialsExtension, RawDelegatedToken, RawInboundToken, TokenKind, TokenRole, +}; +use cpex_core::hooks::payload::Extensions; +use cpex_core::hooks::trait_def::{HookHandler, PluginResult}; +use cpex_core::manager::PluginManager; +use cpex_core::plugin::{OnError, Plugin, PluginConfig, PluginMode}; + +use apl_core::{ + compile_config, evaluate_route, AttributeBag, Decision, PdpCall, PdpDecision, PdpDialect, + PdpError, PdpResolver, RoutePayload, +}; +use apl_cpex::{ + CmfPluginInvoker, DelegationPluginInvoker, DispatchCache, ElicitationPluginInvoker, + MemorySessionStore, SessionStore, +}; + +// --------------------------------------------------------------------- +// Fake elicit plugin — approves or denies on `check`, per config. +// --------------------------------------------------------------------- + +struct FakeApprover { + cfg: PluginConfig, + check_outcome: ElicitationOutcomeKind, +} + +#[async_trait] +impl Plugin for FakeApprover { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for FakeApprover { + async fn handle( + &self, + payload: &ElicitationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + let mut out = payload.clone(); + match payload.operation() { + ElicitationOp::Dispatch => { + out.id = Some("elic-1".to_string()); + out.status = Some(ElicitationStatusKind::Pending); + out.approver = Some(payload.from().to_string()); + out.intent_id = Some("intent-1".to_string()); + out.expires_at = Some("2099-12-31T00:00:00Z".to_string()); + }, + // Resolve immediately (approved or denied) so a single + // evaluate pass reaches the delegate step or halts at it. + ElicitationOp::Check => { + out.status = Some(ElicitationStatusKind::Resolved); + out.outcome = Some(self.check_outcome); + }, + ElicitationOp::Validate => { + out.valid = Some(true); + out.approver = Some("manager@corp.com".to_string()); + out.intent_id = Some("intent-1".to_string()); + }, + } + PluginResult::modify_payload(out) + } +} + +fn approver_cfg() -> PluginConfig { + PluginConfig { + name: "manager-approver".to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: vec![HOOK_ELICIT.to_string()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + capabilities: std::collections::HashSet::new(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } +} + +// --------------------------------------------------------------------- +// Fake delegate plugin — records that it ran and mints a token. +// --------------------------------------------------------------------- + +struct RecordingDelegate { + cfg: PluginConfig, + /// Every target the plugin was asked to mint for. Empty ⇒ never ran. + ledger: Arc>>, +} + +#[async_trait] +impl Plugin for RecordingDelegate { + fn config(&self) -> &PluginConfig { + &self.cfg + } +} + +impl HookHandler for RecordingDelegate { + async fn handle( + &self, + payload: &DelegationPayload, + _ext: &Extensions, + _ctx: &mut PluginContext, + ) -> PluginResult { + self.ledger + .lock() + .unwrap() + .push(payload.target_name().to_string()); + + let token = RawDelegatedToken::new( + "fake.delegated.token", + "Authorization", + "workday-api", + vec!["read_compensation".to_string()], + Utc::now() + Duration::seconds(300), + ); + let mut updated = payload.clone(); + updated.delegated_token = Some(token); + PluginResult::modify_payload(updated) + } +} + +fn delegate_cfg() -> PluginConfig { + PluginConfig { + name: "workday-oauth".to_string(), + kind: "test".to_string(), + description: None, + author: None, + version: None, + hooks: vec![HOOK_TOKEN_DELEGATE.to_string()], + mode: PluginMode::Sequential, + priority: 10, + on_error: OnError::Fail, + capabilities: std::collections::HashSet::new(), + tags: Vec::new(), + conditions: Vec::new(), + config: None, + } +} + +// --------------------------------------------------------------------- +// Always-allow PDP stub — no scenario here exercises a PDP step. +// --------------------------------------------------------------------- + +struct AllowPdp; +#[async_trait] +impl PdpResolver for AllowPdp { + fn dialect(&self) -> PdpDialect { + PdpDialect::Cedar + } + async fn evaluate( + &self, + _call: &PdpCall, + _bag: &AttributeBag, + ) -> Result { + Ok(PdpDecision { + decision: Decision::Allow, + diagnostics: vec![], + }) + } +} + +// One route: approval gate, then delegation, then a granted post-check. +const ROUTE_YAML: &str = r#" +plugins: + - name: manager-approver + kind: test + hooks: [elicit] + - name: workday-oauth + kind: test + hooks: [token.delegate] +routes: + payroll_adjust: + pre_invocation: + - "require_approval(manager-approver, from: claim.manager, channel: \"ciba\", scope: \"args.amount <= 25000\", purpose: \"Approve raise\")" + - "delegate(workday-oauth, target: workday-api, permissions: [read_compensation])" + - "!delegation.granted: deny" +"#; + +/// Drive the whole route once with the approver resolving to +/// `outcome`. Returns `(route decision, number of delegate calls)`. +async fn run_route(outcome: ElicitationOutcomeKind) -> (Decision, usize) { + let ledger: Arc>> = Arc::new(Mutex::new(Vec::new())); + + let mgr = Arc::new(PluginManager::default()); + mgr.register_handler::( + Arc::new(FakeApprover { + cfg: approver_cfg(), + check_outcome: outcome, + }), + approver_cfg(), + ) + .expect("register approver"); + mgr.register_handler::( + Arc::new(RecordingDelegate { + cfg: delegate_cfg(), + ledger: Arc::clone(&ledger), + }), + delegate_cfg(), + ) + .expect("register delegate"); + mgr.initialize().await.expect("initialize"); + + let cfg = compile_config(ROUTE_YAML).expect("compile route YAML"); + let route = cfg.routes.get("payroll_adjust").expect("route present"); + let cache = Arc::new(DispatchCache::new()); + let plan = cache.get_or_build(route, &cfg.plugins, &mgr).await; + + // A User inbound token so the (default user-subject) delegation has + // a bearer to carry. + let mut raw = RawCredentialsExtension::default(); + raw.inbound_tokens.insert( + TokenRole::User, + RawInboundToken::new("eyJ.fake.user", "Authorization", TokenKind::Jwt), + ); + let extensions = Extensions { + raw_credentials: Some(Arc::new(raw)), + ..Default::default() + }; + + let session_store: Arc = Arc::new(MemorySessionStore::new()); + let invoker = Arc::new( + CmfPluginInvoker::for_request( + Arc::clone(&mgr), + extensions, + cpex_core::cmf::MessagePayload { + message: cpex_core::cmf::Message::text( + cpex_core::cmf::enums::Role::User, + "adjust payroll", + ), + }, + Arc::clone(&plan), + Arc::clone(&session_store), + ) + .await + .expect("for_request"), + ); + + // Both bridge invokers share the request's extensions + plan. + let delegations = Arc::new(DelegationPluginInvoker::new( + Arc::clone(&mgr), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + let elicitations = Arc::new(ElicitationPluginInvoker::new( + Arc::clone(&mgr), + invoker.extensions_arc(), + invoker.plan_arc(), + )); + + let mut bag = apl_cmf::BagBuilder::new() + .with_extensions(&invoker.current_extensions().await) + .with_route_key(&route.route_key) + .build(); + // Seed the two attributes the gate reads: `from` must resolve to an + // identity, and the approval `scope` predicate is checked against + // the live args. + bag.set("claim.manager", "manager@corp.com"); + bag.set("args.amount", 1000_i64); + + let mut payload = RoutePayload::new(serde_json::Value::Null); + let decision = evaluate_route( + route, + &mut bag, + &mut payload, + &(Arc::new(AllowPdp) as Arc), + &(invoker.clone() as Arc), + &(delegations.clone() as Arc), + &(elicitations.clone() as Arc), + ) + .await; + + let calls = ledger.lock().unwrap().len(); + (decision.decision, calls) +} + +/// Approval denied ⇒ the gate halts the route and the delegation never +/// runs. This is the property that makes "approval gates delegation" +/// real: a denied approval must stop the token from ever being minted. +#[tokio::test] +async fn denied_approval_halts_before_delegation() { + let (decision, delegate_calls) = run_route(ElicitationOutcomeKind::Denied).await; + + assert!( + matches!(decision, Decision::Deny { .. }), + "denied approval must halt the route; got {decision:?}", + ); + assert_eq!( + delegate_calls, 0, + "delegation must NOT run when approval was denied", + ); +} + +/// Approval granted ⇒ the gate passes, the delegation runs and mints a +/// token, and the trailing `!delegation.granted: deny` post-check is +/// satisfied so the route allows. +#[tokio::test] +async fn approved_gate_lets_delegation_run() { + let (decision, delegate_calls) = run_route(ElicitationOutcomeKind::Approved).await; + + assert_eq!( + decision, + Decision::Allow, + "approved gate + successful delegation should allow; got {decision:?}", + ); + assert_eq!( + delegate_calls, 1, + "delegation must run exactly once after approval", + ); +} diff --git a/crates/cpex-core/src/delegation/mod.rs b/crates/cpex-core/src/delegation/mod.rs index 61c344fe..2d47b186 100644 --- a/crates/cpex-core/src/delegation/mod.rs +++ b/crates/cpex-core/src/delegation/mod.rs @@ -18,4 +18,6 @@ pub mod hook; pub mod payload; pub use hook::{TokenDelegateHook, HOOK_TOKEN_DELEGATE}; -pub use payload::{AttenuationConfig, AuthEnforcedBy, DelegationPayload, TargetType}; +pub use payload::{ + AttenuationConfig, AuthEnforcedBy, DelegationPayload, DelegationSubject, TargetType, +}; diff --git a/crates/cpex-core/src/delegation/payload.rs b/crates/cpex-core/src/delegation/payload.rs index f2a9bc44..f9df2e40 100644 --- a/crates/cpex-core/src/delegation/payload.rs +++ b/crates/cpex-core/src/delegation/payload.rs @@ -8,8 +8,9 @@ // `IdentityPayload`: // // * **Input** (private — host-supplied, never mutated by handlers) — -// `bearer_token`, `target_name`, `target_type`, `target_audience`, -// `required_permissions`, `trust_domain`, `auth_enforced_by`, +// `bearer_token`, `actor_token`, `actor_role`, `subject`, `target_name`, +// `target_type`, `target_audience`, `required_permissions`, +// `trust_domain`, `auth_enforced_by`, // `route_attenuation`. Set once at the call site that needs to mint // a downstream credential. Privacy is enforced at the module // boundary: external code reads through accessors and has no @@ -57,12 +58,77 @@ use serde::{Deserialize, Serialize}; use zeroize::Zeroizing; use crate::executor::PipelineResult; -use crate::extensions::raw_credentials::DelegationMode; +use crate::extensions::raw_credentials::{DelegationMode, TokenRole}; use crate::extensions::{ DelegationExtension, Extensions, RawCredentialsExtension, RawDelegatedToken, }; use crate::impl_plugin_payload; +/// Which principal a delegation exchange is *for* — the party whose +/// identity the minted credential will speak for. +/// +/// Deliberately a separate type from [`TokenRole`]. `TokenRole` keys +/// `RawCredentialsExtension.inbound_tokens`, so it can only ever name +/// a credential that arrived on the wire. `ThisWorkload` names *this +/// instance's own* identity, which by definition does not arrive on the +/// wire — it has no inbound slot and no `TokenRole`. Collapsing the two +/// would make "which workload?" ambiguous all over again. +#[non_exhaustive] +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum DelegationSubject { + /// The end user. The ordinary on-behalf-of exchange. + #[default] + User, + /// The OAuth client / application brokering the request. + Client, + /// The *calling* workload — an agent acting autonomously, with no + /// user in the loop. Exchanges the caller's own JWT-SVID. + CallerWorkload, + /// This CPEX instance's own identity — used when the enforcement + /// point holds the downstream access (the "hold the tool credentials + /// here" deployment) and calls the downstream as itself rather than + /// as the caller. Deployment-agnostic: not a claim that CPEX is a + /// gateway. + /// + /// Has no inbound credential to exchange: proves who it is with its + /// own client credentials or its own SVID. + ThisWorkload, +} + +impl DelegationSubject { + /// Which inbound credential supplies this subject's token, or + /// `None` for [`ThisWorkload`] — nothing the caller sent is being + /// exchanged, so there is no inbound slot to read. + /// + /// [`ThisWorkload`]: DelegationSubject::ThisWorkload + pub fn inbound_role(&self) -> Option { + match self { + DelegationSubject::User => Some(TokenRole::User), + DelegationSubject::Client => Some(TokenRole::Client), + DelegationSubject::CallerWorkload => Some(TokenRole::CallerWorkload), + DelegationSubject::ThisWorkload => None, + } + } + + /// Parse the value of a `subject:` step key. Returns `None` for + /// anything unrecognized so callers apply their own policy rather + /// than silently substituting a principal for a typo'd one. + /// + /// `"workload"` is accepted as a legacy spelling of `caller_workload`, + /// and `"gateway"` as a deprecated spelling of `this_workload` (the + /// keyword was renamed because CPEX is not necessarily a gateway). + pub fn from_config_str(s: &str) -> Option { + match s { + "user" => Some(DelegationSubject::User), + "client" => Some(DelegationSubject::Client), + "caller_workload" | "workload" => Some(DelegationSubject::CallerWorkload), + "this_workload" | "gateway" => Some(DelegationSubject::ThisWorkload), + _ => None, + } + } +} + /// Kind of downstream entity the credential is being minted for. /// `Custom(String)` is the escape hatch for host-defined entity /// types beyond the well-known shapes. @@ -164,6 +230,45 @@ pub struct DelegationPayload { #[serde(skip)] bearer_token: Zeroizing, + /// The RFC 8693 `actor_token` — the credential of the party + /// *acting on behalf of* the subject, typically the caller + /// workload's SPIFFE JWT-SVID. Sourced by the invoker from + /// `RawCredentialsExtension[Workload]`, exactly as `bearer_token` + /// is sourced from `[User]`. Empty when the delegation carries no + /// actor (the common single-token exchange). Cleared on drop via + /// `Zeroizing`; `#[serde(skip)]` — never serialized, same + /// invariant as `bearer_token`. + #[serde(skip)] + actor_token: Zeroizing, + + /// Which principal `actor_token` belongs to. `None` when the + /// exchange carries no actor. Travels with `actor_token` because + /// the bytes alone don't say whose they are, and the cache key + /// needs to know whether a workload took part. + #[serde(default, skip_serializing_if = "Option::is_none")] + actor_role: Option, + + /// Which principal this exchange is *for* — whose identity the + /// minted credential will speak for. + /// + /// Handlers can't tell a user token from a workload JWT-SVID by + /// looking at the bytes, but the distinction decides how the + /// minted credential must be attributed: a `CallerWorkload` + /// subject with no user in the picture speaks for the calling + /// agent (`DelegationMode::AsCallerWorkload`), a `User` subject + /// speaks for the user (`OnBehalfOfUser`), and `Gateway` speaks + /// for us (`AsGateway`). Recording it here lets a handler + /// *derive* the attribution rather than guess it. + /// + /// `Gateway` additionally tells a handler there is no inbound + /// credential to exchange — `bearer_token` is empty by design, + /// and the handler authenticates as itself instead. + /// + /// Defaults to `User`, which keeps every existing single-token + /// call site meaning exactly what it meant before. + #[serde(default)] + subject: DelegationSubject, + /// Name of the tool / agent / resource being called. target_name: String, @@ -243,6 +348,9 @@ impl DelegationPayload { pub fn new(bearer_token: impl Into, target_name: impl Into) -> Self { Self { bearer_token: Zeroizing::new(bearer_token.into()), + actor_token: Zeroizing::new(String::new()), + actor_role: None, + subject: DelegationSubject::default(), target_name: target_name.into(), target_type: TargetType::Tool, target_audience: None, @@ -258,6 +366,33 @@ impl DelegationPayload { } } + /// Attach the RFC 8693 actor — the party acting on behalf of the + /// subject — as a (role, credential) pair. The invoker sets this + /// from the inbound workload SVID + /// (`RawCredentialsExtension[CallerWorkload]`) when a delegation step + /// opts into an actor, mirroring how `bearer_token` is sourced + /// from the User-role token. A delegator forwards the token as + /// `actor_token` only when non-empty. + /// + /// Role and token are set together deliberately: a token whose + /// principal is unknown can't be attributed in the audit trail or + /// partitioned correctly in the delegated-token cache. + pub fn with_actor(mut self, role: TokenRole, actor_token: impl Into) -> Self { + self.actor_token = Zeroizing::new(actor_token.into()); + self.actor_role = Some(role); + self + } + + /// Record which principal this exchange is for. The invoker sets + /// this from the step's `subject:` key, so handlers can attribute + /// the minted token correctly instead of assuming a user is + /// present — and can tell that a `Gateway` subject means "no + /// inbound credential, authenticate as yourself." + pub fn with_subject(mut self, subject: DelegationSubject) -> Self { + self.subject = subject; + self + } + pub fn with_target_type(mut self, t: TargetType) -> Self { self.target_type = t; self @@ -294,6 +429,39 @@ impl DelegationPayload { &self.bearer_token } + /// The actor token — borrowed. Empty string when no actor was + /// attached (the common single-token exchange). Same borrow-only + /// discipline as `bearer_token`: no way to move or replace the + /// underlying `Zeroizing` through this. + pub fn actor_token(&self) -> &str { + &self.actor_token + } + + /// Which principal this exchange is for. + pub fn subject(&self) -> &DelegationSubject { + &self.subject + } + + /// Which principal the actor token speaks for, or `None` when the + /// exchange carries no actor. + pub fn actor_role(&self) -> Option<&TokenRole> { + self.actor_role.as_ref() + } + + /// Whether the caller's attested workload identity took part in + /// this exchange — either as the subject (a workload acting + /// autonomously) or as the RFC 8693 actor alongside a user. + /// + /// Drives whether the minted token's cache key is partitioned by + /// `caller_workload.spiffe_id`. It has to be, in both cases: the + /// minted credential names the specific workload (as `sub` or as + /// `act`), so a token minted for one agent is not interchangeable + /// with one minted for another. + pub fn involves_workload(&self) -> bool { + self.subject == DelegationSubject::CallerWorkload + || self.actor_role == Some(TokenRole::CallerWorkload) + } + pub fn target_name(&self) -> &str { &self.target_name } @@ -376,18 +544,29 @@ impl DelegationPayload { /// - **`delegation`** — `delegation_update` overlays on top of /// the existing chain (Some replaces None / appends). /// - /// # Open work + /// # Key composition + /// + /// `audience`, `scopes` and `mode` come off the payload. The two + /// principal fields are read out of the request's `Extensions` + /// here rather than asking outbound callers to thread them + /// through: + /// + /// - `subject_id` from `security.subject.id`, empty when no user + /// took part (a workload acting autonomously). + /// - `workload_id` from `security.caller_workload.spiffe_id`, + /// populated only when [`involves_workload`] — i.e. when a + /// workload credential was the subject or the RFC 8693 actor. /// - /// The `DelegationKey` we synthesize here uses only fields the - /// payload knows about — `audience`, `scopes` (derived from the - /// effective scopes on the minted token), `mode`. The `subject_id` - /// field of `DelegationKey` requires reading the request's - /// `Extensions.security.subject.id`; we plumb that lookup here - /// rather than asking outbound callers to thread the subject - /// through. If `security.subject.id` is absent the key falls back - /// to the empty string — flagged via tracing but not fatal, - /// because some delegation flows are gateway-as-principal - /// (AsGateway mode) and don't need a subject. + /// Both are needed. An empty `subject_id` is not a unique + /// principal: every workload-subject exchange has one, so without + /// `workload_id` two different calling agents requesting the same + /// audience and scopes collide on a single key and get served + /// each other's tokens. Populating `workload_id` only when a + /// workload actually participated keeps ordinary user-only + /// delegations sharing one entry rather than being partitioned + /// per caller for no reason. + /// + /// [`involves_workload`]: DelegationPayload::involves_workload pub fn apply_to_extensions(&self, mut ext: Extensions) -> Extensions { if let Some(ref token) = self.delegated_token { use crate::extensions::raw_credentials::DelegationKey; @@ -399,6 +578,19 @@ impl DelegationPayload { .and_then(|s| s.id.clone()) .unwrap_or_default(); + // Which calling agent this token was minted for. Only set + // when a workload actually participated — see the + // "Key composition" note above for why both principals + // have to be in the key. + let workload_id = if self.involves_workload() { + ext.security + .as_ref() + .and_then(|s| s.caller_workload.as_ref()) + .and_then(|w| w.spiffe_id.clone()) + } else { + None + }; + // Default to OnBehalfOfUser when the handler didn't // populate `delegation_mode`. Backward-compatible with // earlier handlers; future handlers should @@ -409,6 +601,7 @@ impl DelegationPayload { .unwrap_or(DelegationMode::OnBehalfOfUser); let key = DelegationKey { subject_id, + workload_id, audience: token.audience.clone(), scopes: token.scopes.clone(), mode, @@ -463,6 +656,116 @@ mod tests { assert_eq!(p.target_name(), "get_compensation"); } + #[test] + fn actor_token_defaults_empty_and_builder_sets_it() { + // No actor by default — the common single-token exchange. + let p = DelegationPayload::new("caller.tok", "get_compensation"); + assert_eq!(p.actor_token(), ""); + assert_eq!(p.actor_role(), None); + + // Builder attaches the workload SVID (as the invoker does), + // recording whose credential it is at the same time. + let p = p.with_actor(TokenRole::CallerWorkload, "svid.jwt.bytes"); + assert_eq!(p.actor_token(), "svid.jwt.bytes"); + assert_eq!(p.actor_role(), Some(&TokenRole::CallerWorkload)); + // Subject is untouched — the two tokens are independent slots. + assert_eq!(p.bearer_token(), "caller.tok"); + } + + #[test] + fn involves_workload_covers_both_subject_and_actor_positions() { + // Neither position: a plain user delegation. + let user_only = DelegationPayload::new("caller.tok", "t"); + assert!(!user_only.involves_workload()); + + // Subject position (Mode A) — workload acting autonomously. + let mode_a = + DelegationPayload::new("svid", "t").with_subject(DelegationSubject::CallerWorkload); + assert!(mode_a.involves_workload()); + + // Actor position (Mode B) — user subject, workload actor. The + // minted token names the workload in `act`, so it still has to + // partition the cache. + let mode_b = DelegationPayload::new("user.tok", "t") + .with_actor(TokenRole::CallerWorkload, "svid.jwt.bytes"); + assert!(mode_b.involves_workload()); + + // A non-workload actor doesn't trigger it — nothing to key by. + let client_actor = + DelegationPayload::new("user.tok", "t").with_actor(TokenRole::Client, "client.tok"); + assert!(!client_actor.involves_workload()); + } + + #[test] + fn subject_defaults_to_user_and_builder_overrides_it() { + // Default keeps every pre-existing single-token call site + // meaning what it always meant: on-behalf-of a user. + let p = DelegationPayload::new("caller.tok", "get_compensation"); + assert_eq!(p.subject(), &DelegationSubject::User); + + // The calling agent acting autonomously. + let p = p.with_subject(DelegationSubject::CallerWorkload); + assert_eq!(p.subject(), &DelegationSubject::CallerWorkload); + } + + /// `Gateway` is the one subject with no inbound credential — it + /// proves who it is by being itself rather than by exchanging + /// something the caller sent. Handlers key the "an empty bearer + /// token is expected here" decision off exactly this. + #[test] + fn only_gateway_has_no_inbound_role() { + assert_eq!( + DelegationSubject::User.inbound_role(), + Some(TokenRole::User), + ); + assert_eq!( + DelegationSubject::Client.inbound_role(), + Some(TokenRole::Client), + ); + assert_eq!( + DelegationSubject::CallerWorkload.inbound_role(), + Some(TokenRole::CallerWorkload), + ); + assert_eq!(DelegationSubject::ThisWorkload.inbound_role(), None); + } + + #[test] + fn subject_parses_from_config_including_legacy_spellings() { + assert_eq!( + DelegationSubject::from_config_str("caller_workload"), + Some(DelegationSubject::CallerWorkload), + ); + // Configs written before the renames keep working. + assert_eq!( + DelegationSubject::from_config_str("workload"), + Some(DelegationSubject::CallerWorkload), + ); + assert_eq!( + DelegationSubject::from_config_str("this_workload"), + Some(DelegationSubject::ThisWorkload), + ); + // `gateway` is the deprecated spelling of `this_workload`. + assert_eq!( + DelegationSubject::from_config_str("gateway"), + Some(DelegationSubject::ThisWorkload), + ); + // A typo resolves to None so the caller applies its own + // default rather than silently picking a principal. + assert_eq!(DelegationSubject::from_config_str("gatewy"), None); + } + + #[test] + fn actor_token_does_not_serialize() { + let p = DelegationPayload::new("caller.tok", "get_compensation") + .with_actor(TokenRole::CallerWorkload, "eyJ.workload.svid"); + let json = serde_json::to_string(&p).unwrap(); + assert!( + !json.contains("eyJ.workload.svid"), + "actor_token leaked into serialized form: {}", + json, + ); + } + #[test] fn input_builders_chain() { let p = DelegationPayload::new("tok", "get_compensation") @@ -574,6 +877,9 @@ mod tests { // Look up by the synthesized key. let expected_key = crate::extensions::raw_credentials::DelegationKey { subject_id: "alice".into(), + // No workload in this exchange, so the key isn't + // partitioned by one. + workload_id: None, audience: "https://hr.example.com".into(), scopes: vec!["read:compensation".into()], mode: DelegationMode::OnBehalfOfUser, @@ -581,6 +887,87 @@ mod tests { assert!(raw.delegated_tokens.contains_key(&expected_key)); } + /// The end-to-end version of the collision guard: two different + /// calling agents run the same workload-subject exchange against + /// the same audience and scopes, sharing one `delegated_tokens` + /// map. Neither has a user, so both keys carry an empty + /// `subject_id`; only `workload_id` keeps them apart. If it didn't, + /// the second agent would overwrite the first's entry and — once a + /// cross-request cache exists — be served the first agent's token. + #[test] + fn two_agents_do_not_share_one_cache_entry() { + use crate::extensions::security::WorkloadIdentity; + + /// A Mode A payload: workload subject, no user. + fn workload_exchange(minted: &str) -> DelegationPayload { + let mut p = DelegationPayload::new("svid-bytes", "get_compensation") + .with_subject(DelegationSubject::CallerWorkload); + p.delegated_token = Some(RawDelegatedToken::new( + minted, + "Authorization", + "https://hr.example.com", + vec!["read:compensation".into()], + Utc::now() + chrono::Duration::seconds(300), + )); + p.delegation_mode = Some(DelegationMode::AsCallerWorkload); + p + } + + /// Extensions whose attested caller is `spiffe_id`, carrying + /// over any already-cached tokens so the two exchanges share + /// one map. + fn ext_for(spiffe_id: &str, carry: Option>) -> Extensions { + Extensions { + security: Some(Arc::new(crate::extensions::SecurityExtension { + caller_workload: Some(WorkloadIdentity { + spiffe_id: Some(spiffe_id.into()), + ..Default::default() + }), + ..Default::default() + })), + raw_credentials: carry, + ..Default::default() + } + } + + // Agent 1 mints, then agent 2 mints against the same map. + let after_payroll = workload_exchange("payroll-token") + .apply_to_extensions(ext_for("spiffe://corp/payroll", None)); + let carried = after_payroll.raw_credentials.clone(); + let after_both = workload_exchange("recruiting-token") + .apply_to_extensions(ext_for("spiffe://corp/recruiting", carried)); + + let raw = after_both.raw_credentials.as_ref().unwrap(); + assert_eq!( + raw.delegated_tokens.len(), + 2, + "each calling agent must get its own cache entry; keys: {:?}", + raw.delegated_tokens.keys().collect::>(), + ); + + // And each entry holds that agent's own token — the point of + // the exercise. + let lookup = |spiffe: &str| { + raw.delegated_tokens + .get(&crate::extensions::raw_credentials::DelegationKey { + subject_id: String::new(), + workload_id: Some(spiffe.into()), + audience: "https://hr.example.com".into(), + scopes: vec!["read:compensation".into()], + mode: DelegationMode::AsCallerWorkload, + }) + .map(|t| (*t.token).clone()) + }; + assert_eq!( + lookup("spiffe://corp/payroll").as_deref(), + Some("payroll-token"), + ); + assert_eq!( + lookup("spiffe://corp/recruiting").as_deref(), + Some("recruiting-token"), + ); + } + #[test] fn apply_to_extensions_respects_explicit_delegation_mode() { // Handler that mints an AsGateway-mode token (gateway-as-principal @@ -594,14 +981,15 @@ mod tests { vec!["service:call".into()], Utc::now(), )); - p.delegation_mode = Some(crate::extensions::raw_credentials::DelegationMode::AsGateway); + p.delegation_mode = + Some(crate::extensions::raw_credentials::DelegationMode::AsThisWorkload); let updated = p.apply_to_extensions(Extensions::default()); let raw = updated.raw_credentials.as_ref().unwrap(); let key = raw.delegated_tokens.keys().next().unwrap(); assert!(matches!( key.mode, - crate::extensions::raw_credentials::DelegationMode::AsGateway + crate::extensions::raw_credentials::DelegationMode::AsThisWorkload )); } @@ -635,11 +1023,11 @@ mod tests { // base.delegation_mode = None let mut overlay = DelegationPayload::new("", ""); overlay.delegation_mode = - Some(crate::extensions::raw_credentials::DelegationMode::AsGateway); + Some(crate::extensions::raw_credentials::DelegationMode::AsThisWorkload); base.merge(overlay); assert!(matches!( base.delegation_mode, - Some(crate::extensions::raw_credentials::DelegationMode::AsGateway) + Some(crate::extensions::raw_credentials::DelegationMode::AsThisWorkload) )); } diff --git a/crates/cpex-core/src/extensions/filter.rs b/crates/cpex-core/src/extensions/filter.rs index 2b8722f4..e9723683 100644 --- a/crates/cpex-core/src/extensions/filter.rs +++ b/crates/cpex-core/src/extensions/filter.rs @@ -780,6 +780,7 @@ mod tests { raw.delegated_tokens.insert( DelegationKey { subject_id: "alice".into(), + workload_id: None, audience: "https://api.example.com".into(), scopes: vec!["read".into()], mode: DelegationMode::OnBehalfOfUser, diff --git a/crates/cpex-core/src/extensions/raw_credentials.rs b/crates/cpex-core/src/extensions/raw_credentials.rs index bc11b454..16080ae9 100644 --- a/crates/cpex-core/src/extensions/raw_credentials.rs +++ b/crates/cpex-core/src/extensions/raw_credentials.rs @@ -69,9 +69,19 @@ pub enum TokenRole { /// The OAuth client / gateway-access token (e.g. `Authorization: /// Bearer ...` from a session JWT). Client, - /// A JWT-SVID presented by the inbound workload, when SPIFFE - /// attestation is JWT-based instead of mTLS-based. - Workload, + /// A JWT-SVID presented by the *calling* workload, when SPIFFE + /// attestation is JWT-based instead of mTLS-based. Maps to + /// `SecurityExtension.caller_workload`. + /// + /// Named for the caller specifically: the gateway's own workload + /// identity (`this_workload`) is a different principal and never + /// appears here, because this map holds *inbound* credentials and + /// the gateway's own credential does not arrive on the wire. + /// + /// `alias = "workload"` keeps configs written before the rename + /// working. + #[serde(rename = "caller_workload", alias = "workload")] + CallerWorkload, /// Host-defined role. #[serde(untagged)] Custom(String), @@ -95,19 +105,49 @@ pub enum TokenKind { TxnToken, } -/// Whether a delegated outbound token represents the user's identity -/// or the gateway's own identity to the downstream service. Affects -/// scope-narrowing rules and audit-log attribution. +/// Which principal a delegated outbound token speaks for. Affects +/// scope-narrowing rules, audit-log attribution, and how +/// `DelegationKey` partitions the delegated-token cache. +/// +/// The three variants track three of the four identity slots on +/// `SecurityExtension` — `subject`, `caller_workload`, and +/// `this_workload`. Keeping the calling agent distinct from the +/// gateway matters: they are different principals, they present +/// different credentials, and a token minted for one must never be +/// handed to the other. #[non_exhaustive] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum DelegationMode { /// Outbound token represents the original user (RFC 8693 /// on-behalf-of / actor-token flows, UCAN delegation). + /// Corresponds to `SecurityExtension.subject`. OnBehalfOfUser, - /// Outbound token represents the gateway / agent itself as the - /// principal; user identity is conveyed via separate context. - AsGateway, + + /// Outbound token represents the *calling* workload — the attested + /// agent on the inbound network peer + /// (`SecurityExtension.caller_workload`), acting autonomously with + /// no user in the loop. The credential exchanged is the caller's + /// own JWT-SVID, arriving as `inbound_tokens[TokenRole::CallerWorkload]`. + /// + /// Distinct from `AsThisWorkload`: many different agents call through + /// one enforcement point, so this mode does *not* identify a single + /// principal on its own — `DelegationKey.workload_id` carries the + /// specific caller. + AsCallerWorkload, + + /// Outbound token represents *this* CPEX instance's own attested + /// identity (`SecurityExtension.this_workload`) — used when the + /// enforcement point calls infrastructure it owns, or a downstream + /// that trusts only this instance with user context conveyed + /// separately. + /// + /// This instance's SVID is not an inbound credential: it comes from + /// the SPIFFE Workload API, not off the wire. Until a source that + /// populates `this_workload` exists, no handler produces this + /// mode — do not reach for it as a stand-in for + /// `AsCallerWorkload`. + AsThisWorkload, } /// One inbound credential, captured at the wire layer and stashed @@ -154,8 +194,19 @@ impl RawInboundToken { } /// Composite key for cached delegated tokens. Token cache lookups -/// hit on `(subject, audience, scopes, mode)` so different audiences -/// or scope sets for the same subject mint independent tokens. +/// hit on `(subject, workload, audience, scopes, mode)` so different +/// audiences or scope sets for the same subject mint independent +/// tokens. +/// +/// # Every distinguishing principal must appear here +/// +/// A cache key has to carry *everything that makes two token requests +/// different*, or a lookup returns a credential minted for somebody +/// else. `subject_id` alone is not enough: a workload-subject exchange +/// has no user, so `subject_id` is empty for every caller, and two +/// different agents requesting the same audience and scopes would +/// otherwise collide on one key — and be served each other's tokens. +/// `workload_id` is what keeps them apart. /// /// `scopes` is a `Vec` (not a `HashSet`) because Cedar / OPA /// policies frequently care about scope *order* — `["read", "write"]` @@ -163,7 +214,19 @@ impl RawInboundToken { /// Callers that want set semantics should sort before constructing. #[derive(Debug, Hash, Eq, PartialEq, Clone, Serialize, Deserialize)] pub struct DelegationKey { + /// The user the token speaks for. Empty when no user took part + /// (a workload acting autonomously). pub subject_id: String, + + /// SPIFFE-ID of the calling workload, when one participated in + /// the exchange — as the subject (`AsCallerWorkload`) or as the + /// RFC 8693 actor alongside a user. `None` when the exchange + /// involved no workload credential, which keeps ordinary + /// user-only delegations sharing one cache entry instead of + /// being needlessly partitioned per caller. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workload_id: Option, + pub audience: String, pub scopes: Vec, pub mode: DelegationMode, @@ -307,12 +370,14 @@ mod tests { fn delegation_key_hash_eq_consistency() { let k1 = DelegationKey { subject_id: "alice".into(), + workload_id: None, audience: "https://api.example.com".into(), scopes: vec!["read".into(), "write".into()], mode: DelegationMode::OnBehalfOfUser, }; let k2 = DelegationKey { subject_id: "alice".into(), + workload_id: None, audience: "https://api.example.com".into(), scopes: vec!["read".into(), "write".into()], mode: DelegationMode::OnBehalfOfUser, @@ -328,6 +393,47 @@ mod tests { assert_ne!(k1, k3); } + /// The collision `workload_id` exists to prevent: two different + /// calling agents doing a workload-subject exchange for the same + /// audience and scopes. There is no user, so `subject_id` is empty + /// for both — without the workload in the key they'd be one entry + /// and the second agent would be served the first agent's token. + #[test] + fn workload_subject_keys_are_distinct_per_calling_agent() { + let payroll = DelegationKey { + subject_id: String::new(), + workload_id: Some("spiffe://corp/payroll".into()), + audience: "https://hr.example.com".into(), + scopes: vec!["read".into()], + mode: DelegationMode::AsCallerWorkload, + }; + let recruiting = DelegationKey { + workload_id: Some("spiffe://corp/recruiting".into()), + ..payroll.clone() + }; + assert_ne!( + payroll, recruiting, + "two agents with no user must not share a cache entry", + ); + + // Sanity: everything else being equal, the same agent does + // reuse its own entry — the key isn't accidentally unique. + let payroll_again = payroll.clone(); + assert_eq!(payroll, payroll_again); + + // And a user-only delegation (no workload involved) stays + // distinct from a workload one rather than colliding on the + // empty-subject fallback. + let user_only = DelegationKey { + subject_id: "alice".into(), + workload_id: None, + audience: "https://hr.example.com".into(), + scopes: vec!["read".into()], + mode: DelegationMode::OnBehalfOfUser, + }; + assert_ne!(payroll, user_only); + } + #[test] fn extension_round_trip_drops_tokens() { let mut ext = RawCredentialsExtension::default(); diff --git a/crates/cpex-core/tests/delegation_e2e.rs b/crates/cpex-core/tests/delegation_e2e.rs index f0a821af..270f6df3 100644 --- a/crates/cpex-core/tests/delegation_e2e.rs +++ b/crates/cpex-core/tests/delegation_e2e.rs @@ -450,9 +450,12 @@ async fn apply_to_extensions_writes_delegated_token_keyed_by_subject() { .expect("raw_credentials slot populated"); assert_eq!(raw.delegated_tokens.len(), 1); - // The key is synthesized from (subject.id, audience, scopes, mode). + // The key is synthesized from + // (subject.id, workload, audience, scopes, mode). let expected_key = DelegationKey { subject_id: "alice@corp.com".into(), + // No workload participated in this exchange. + workload_id: None, audience: "https://hr.example.com".into(), // Order matches what StubExchanger produces (required_permissions // first, then attenuation capabilities). diff --git a/docs/content/docs/apl/delegation.md b/docs/content/docs/apl/delegation.md index 349ea7be..07200005 100644 --- a/docs/content/docs/apl/delegation.md +++ b/docs/content/docs/apl/delegation.md @@ -47,6 +47,72 @@ plugins: It exchanges the caller's inbound token for one scoped to `audience` with the requested `permissions`, and attaches it to the outbound request. The result populates delegation attributes that later rules read. +## Choosing who the exchange is for + +By default a `delegate` step exchanges the **user's** token: the minted credential speaks for the user, on-behalf-of style. Two step keys change that: + +| Key | Meaning | Accepts | Default | +|-----|---------|---------|---------| +| `subject` | Whose identity the minted token speaks for. | `user`, `client`, `caller_workload`, `this_workload` | `user` | +| `actor` | An additional credential recorded as the RFC 8693 `actor_token`, naming who is *acting*. | `user`, `client`, `caller_workload` | none | + +`actor` accepts only inbound credentials, because an actor is by definition a party that presented itself to us. `subject` additionally accepts `this_workload` — us — which is the one principal with no inbound credential at all. (`gateway` is accepted as a deprecated alias of `this_workload`, from before CPEX was decoupled from the gateway shape.) + +### On-behalf-of a user, with the agent named + +The common agentic shape: a user asked for something, and an agent is carrying it out. The user is the subject; the calling agent's SVID rides along as the actor, so the minted token carries `act` alongside `sub` and the backend can see both parties. + +```yaml +pre_invocation: + - "delegate(workday-oauth, target: workday-api, subject: user, actor: caller_workload, + audience: workday-api, permissions: [read_compensation])" +``` + +### An agent acting on its own + +A scheduled or background agent with no user in the loop exchanges its own SVID: + +```yaml +pre_invocation: + - "delegate(workday-oauth, target: workday-api, subject: caller_workload, + audience: workday-api, permissions: [read_compensation])" +``` + +This requires a `role: caller_workload` identity resolver to have populated the SVID slot. With no workload credential present the exchange has no subject token and is denied rather than silently falling back. + +### The enforcement point calling as itself + +A common deployment (an MCP gateway is one shape): agents authenticate to CPEX, but *this instance* is the one holding access to the backend tools. The agent never possesses a credential the backend would accept. + +```yaml +pre_invocation: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, subject: this_workload, + audience: workday-api, permissions: [read_compensation])" +``` + +`subject: this_workload` has no inbound credential to exchange, so the delegator switches from RFC 8693 token exchange to an RFC 6749 §4.4 **`client_credentials`** grant: no `subject_token` is sent, and this instance's identity is the OAuth client identity it already authenticates with. Nothing extra to configure — the delegator's existing `client_id` / `client_secret` *is* this instance's identity. + +Two consequences worth understanding before choosing this shape: + +- **This instance is the only enforcement point.** The backend sees a token that says "this instance" and has no idea which agent triggered the call. Whatever the `require` gates allow is what happens; there is no second opinion downstream. Gate accordingly. +- **The minted token cannot name the calling agent.** `actor_token` is a token-exchange parameter with no meaning under `client_credentials`, so it is not sent even if the step asks for one. Attribution to the calling agent lives in your audit log, not in the credential. Carrying the agent inside the token requires this instance to have a real subject credential of its own to exchange — its own SVID — which is not yet wired up. + +### Who the minted token speaks for + +Each exchange is attributed to exactly one principal, and the attribution is **derived from `subject`** rather than declared: + +| `subject` | Attribution | Speaks for | +|-----------|-------------|-----------| +| `user` (default) | `on_behalf_of_user` | The end user | +| `client` | `on_behalf_of_user` | The brokering application | +| `caller_workload` | `as_caller_workload` | The calling agent | +| `this_workload` | `as_this_workload` | This instance itself | + +There is deliberately **no `mode` key**. If routes could declare the attribution independently of the credential they hand over, a route could claim to act on behalf of a user while exchanging a workload SVID. Deriving it means the operator states one thing — which credential — and the consequence follows. + +The attribution is load-bearing beyond the audit trail: minted credentials are cached per `(subject, workload, audience, scopes, attribution)`, so tokens minted for one calling agent are never served to another. + ## Delegation attributes After a `delegate` effect, policy can read the outcome and the delegation context: diff --git a/docs/content/docs/apl/identity.md b/docs/content/docs/apl/identity.md index 5d5b9818..9e9c5bec 100644 --- a/docs/content/docs/apl/identity.md +++ b/docs/content/docs/apl/identity.md @@ -50,7 +50,44 @@ So `require(role.hr)` is true when the verified token carried the `hr` role, and ## Multiple sources -A request often carries more than one identity: the end user and the calling application. Register an identity plugin per source. The bundled JWT plugin takes a `role` (`user` or `client`) and a `header`, so a user token on `X-User-Token` and a client token on `Authorization` resolve into the `subject.*` and `client.*` namespaces respectively. Policy can then require both: `require(authenticated) & client.authorized_scopes contains "tools:invoke"`. +A request often carries more than one identity: the end user, the calling application, and the calling workload. Register an identity plugin per source. The bundled JWT plugin takes a `role` (`user`, `client`, or `caller_workload`) and a `header`, so each inbound credential resolves into its own namespace: + +| `role` | Namespace | Typical credential | +|--------|-----------|--------------------| +| `user` | `subject.*` | End-user OIDC token on `X-User-Token` | +| `client` | `client.*` | OAuth client token on `Authorization` | +| `caller_workload` | `caller_workload.*` | SPIFFE JWT-SVID on `X-Workload-Token` | + +Policy can then require several at once: `require(authenticated) & client.authorized_scopes contains "tools:invoke"`. + +### Workload identity + +A `role: caller_workload` resolver is the ingress for the **calling agent's** SPIFFE JWT-SVID: + +```yaml +plugins: + - name: jwt-workload + kind: identity/jwt + hooks: [identity.resolve] + config: + role: caller_workload + header: X-Workload-Token + trusted_issuers: + - issuer: "https://spire.example.com" + audiences: ["cpex-gateway"] + decoding_key: + kind: jwks_url + url: "https://spire.example.com/keys" +``` + +The SPIFFE ID is read from the SVID's `sub` claim, and the trust domain is derived from it, populating `caller_workload.spiffe_id` and `caller_workload.trust_domain`. A token whose `sub` is not SPIFFE-shaped is **rejected** rather than filed into the workload slot, so anything policy finds there really is an attested workload. + +Note the distinction the bag makes between two different machine identities: + +- **`caller_workload`** — the attested workload on the inbound network peer. The agent calling *us*. Many different agents call through one gateway. +- **`this_workload`** — this CPEX instance's *own* attested identity, used for outbound calls. A single principal. + +They are not interchangeable, and confusing them is how a token minted for one agent ends up presented by another. Delegation depends on the distinction — see [Delegation]({{< relref "/docs/apl/delegation" >}}). ## How it connects to the pipeline diff --git a/docs/content/docs/identity-delegation.md b/docs/content/docs/identity-delegation.md new file mode 100644 index 00000000..690bdd3e --- /dev/null +++ b/docs/content/docs/identity-delegation.md @@ -0,0 +1,356 @@ +--- +title: "Identity & Delegation" +weight: 55 +--- + +# Identity & Delegation + +CPEX does two identity jobs on every request: it resolves **who is calling in** +(inbound identity) and mints **the credential it calls out with** (outbound +delegation). Find the shape you need ("a user acting through an agent", "an agent +acting as itself", "a service acting as itself"), copy the plugin config and the +route layout, and check the support matrix for where it has been tested. + +The same config runs wherever you place it: in front of the tools, inside the tool +server, or agent-side (see [Where to place CPEX](#where-to-place-cpex)). Throughout +this page "the enforcement point" means "wherever this CPEX instance runs." + +For the conceptual model first, read [Use Cases]({{< relref "use-cases" >}}) and +[Deployment]({{< relref "deployment" >}}); for the full config schema, see +[Configuration]({{< relref "configuration" >}}). + +## The model in one picture + +Every request crosses two identity boundaries: + +![Two identity boundaries: an inbound identity.resolve box (who is calling in) that validates credentials and lands typed identity slots, an arrow labelled route + policy, and an outbound token.delegate box (who we call out as) that mints the downstream credential per the route + subject; identity is additive across slots while delegation is chosen per route by subject](images/identity_two_boundaries.png) + +- **Inbound.** `identity.resolve` plugins each read one credential (from a header) + and land a typed identity in a slot. They are additive: one request can carry a + user token *and* a workload SVID, both validated. +- **Outbound.** A `delegate(...)` step in the route runs a `token.delegate` plugin + that mints the credential attached to the upstream call. The step's `subject:` + chooses what the minted token *speaks for*. + +## Building blocks + +### Identity slots (inbound) + +| Slot | Who it is | Typical header | Resolver | +|---|---|---|---| +| `subject` | the human on whose behalf the call is made | `X-User-Token` | `identity/jwt` (`role: user`) | +| `client` | the OAuth app / agent as a registered client | `Authorization` | `identity/jwt` (`role: client`) | +| `caller_workload` | the *calling workload's own* identity (e.g. a SPIFFE SVID) | `X-Workload-Token` | `identity/jwt` (`role: caller_workload`) | + +### Delegation subjects (outbound) + +The `subject:` on a `delegate(...)` step decides the OAuth mechanism the delegator uses: + +| `subject:` | Minted token speaks for | Mechanism | +|---|---|---| +| `user` (default) | the human, on-behalf-of | RFC 8693 token exchange (`subject_token` = the user's token) | +| `caller_workload` | the calling agent, as itself | RFC 7523 client assertion (the SVID) → then scope down | +| `this_workload` | **the CPEX instance's own identity**, as itself, no inbound credential | RFC 6749 §4.4 `client_credentials` | + +> `this_workload` names *this CPEX instance acting as its own identity*, whatever you've +> deployed it as. It does not claim CPEX is a gateway. (`gateway` is accepted as a +> deprecated alias.) + +### "I want to…" → mechanism + +| I want to… | Use | Spec | +|---|---|---| +| act as a service with no user | `subject: this_workload` → `client_credentials` | RFC 6749 §4.4 | +| act on behalf of a signed-in user | `subject: user` → token exchange | RFC 8693 | +| let a workload authenticate as *itself* by its SVID | `subject: caller_workload` → client assertion | RFC 7523 + `draft-ietf-oauth-spiffe-client-auth` | +| record both the user *and* the calling agent in the token | `subject: user`, `actor: caller_workload` | RFC 8693 `actor_token` | +| forward a token the caller already obtained | no `delegate`, pass the header through | — | + +--- + +## Scoping — how broadly to apply it + +CPEX resolves the pipeline for each request across one **broad → narrow stack**, and +**both identity and policy (including delegation) ride it**. Narrower layers add to +(or override) broader ones. Pick the broadest layer that's still correct. + +The layers, broad to narrow: + +| Layer | Applies to | Identity uses… | Policy / delegation uses… | +|---|---|---|---| +| **Global** | every request | `global.authentication` | the `all` policy group's `plugins` | +| **Default** (per entity type) | every tool / prompt / resource | — | `global.defaults.` | +| **Tag bundle** | routes tagged with `` | `global.policies..authentication` | `global.policies..plugins` | +| **Route (entity)** | one route (a `tool: "*"` route is the catch-all) | route `authentication:` | route `apl:` steps / `plugins:` | + +So a `delegate(...)` is **not** route-only. To pick its breadth, put it (or a +`token.delegate` plugin) at the matching layer: + +- **every tool** → a `delegate()` in a `tool: "*"` route, or the delegator plugin in + `defaults.tool` / the `all` group, +- **a tagged class** → a tag bundle, +- **one tool** → a specific route (which overrides the `*` default; more specific wins). + +### Identity example (the stack in one config) + +```yaml +global: + authentication: [jwt-user] # every request gets user identity + policies: + hr-tools: + authentication: [jwt-manager] # + manager identity on HR-tagged routes +routes: + - tool: get_compensation + meta: { tags: [hr-tools] } # inherits jwt-user + jwt-manager +``` + +**The override.** A route that must stand alone drops the inherited layers: + +```yaml +routes: + - tool: get_directory + authentication: + replace_inherited: true # ignore global + tag layers… + steps: [jwt-workload] # …authenticate by the SVID alone +``` + +That is what [Recipe 2](#recipe-2--agent-acting-as-itself-by-its-spiffe-svid) +uses. (Full tag/bundle/defaults syntax: [Configuration]({{< relref "configuration" >}}).) + +### Rule of thumb + +| You want… | Put it at | +|---|---| +| the same identity everywhere | **global** `authentication` | +| a recurring identity/plugin set across many tools | a **tag bundle** | +| one route handled differently, standalone | a **route** (`authentication: replace_inherited` for identity) | +| one delegation for most tools, exceptions for a few | a **default** (`tool: "*"` route or `defaults.tool`) + specific overrides | + +--- + +## Recipes + +Each recipe is a drop-in: the plugins it needs, the route layout, and where it has +been run. All config is [unified-config]({{< relref "configuration" >}}) YAML. + +### Recipe 1 — User acting through an agent (on-behalf-of) + +**When:** a human is signed in; the agent calls a downstream API *as that user*. +CPEX exchanges the user's IdP token for a downstream-audience token. + +```yaml +plugins: + - name: jwt-user + kind: identity/jwt + hooks: [identity.resolve] + config: + role: user + header: X-User-Token + trusted_issuers: + - issuer: "https://idp.example.com/realms/corp" + audiences: ["cpex"] # the audience the user token is minted for + algorithms: ["RS256"] + decoding_key: { kind: jwks_url, url: "https://idp.example.com/realms/corp/protocol/openid-connect/certs" } + + - name: workday-oauth + kind: delegator/oauth + hooks: [token.delegate] + capabilities: [read_inbound_credentials, write_delegated_tokens] + config: + token_endpoint: "https://idp.example.com/realms/corp/protocol/openid-connect/token" + client_id: "cpex" # this CPEX instance's own OAuth client + client_secret_source: { kind: env_var, name: CPEX_CLIENT_SECRET } + +global: + authentication: [jwt-user] + +routes: + - tool: get_compensation + apl: + pre_invocation: + - "require(role.hr)" + - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation])" +``` + +The minted `workday-api` token is attached to the upstream call. **Tested: Keycloak +26.x (Standard Token Exchange v2).** + +### Recipe 2 — Agent acting as itself, by its SPIFFE SVID + +**When:** the *agent* is the principal (no human), and you don't trust the agent to +hold downstream authority. The agent presents its SVID; CPEX brokers a scoped +downstream token. The agent holds no standing entitlement to the target. + +> **The SVID is a JWT, but not an IdP token.** A JWT-SVID is an `ES256` JWT signed by +> *SPIRE* (validated against SPIRE's JWKS, not your IdP's): a SPIFFE identity +> credential, not an OAuth access token. It can't be forwarded to the downstream or +> used as a bearer/subject token as-is; CPEX has to **turn it into an IdP-issued token +> first** (leg 1 below). Contrast +> [Recipe 5](#recipe-5--scope-a-token-the-agent-already-holds-1-leg), whose input is a +> token already *minted from* an SVID. + +Add a workload resolver, scoped to the route so only it runs there: + +```yaml +plugins: + - name: jwt-workload + kind: identity/jwt + hooks: [identity.resolve] + on_error: fail + config: + role: caller_workload + header: X-Workload-Token + trusted_issuers: + - issuer: "https://spire-oidc.internal:8443" # SPIRE OIDC discovery + audiences: ["https://idp.example.com/realms/corp"] # SVID aud = the IdP + algorithms: ["ES256"] # SVIDs are EC-signed + decoding_key: { kind: jwks_url, url: "https://spire-oidc.internal:8443/keys" } + +routes: + - tool: get_directory + # Authenticate this route by the SVID alone: drop the global user/client + # resolvers. `jwt-workload` is NOT in global.authentication. + authentication: + replace_inherited: true + steps: [jwt-workload] + apl: + pre_invocation: + - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation], subject: caller_workload)" + - "!delegation.granted: deny" +``` + +For `subject: caller_workload`, the OAuth delegator runs **two legs**: leg 1 presents +the SVID as an RFC 7523 `client_assertion` (type `…:jwt-spiffe`) to authenticate the +agent as its IdP client; leg 2 exchanges that for the scoped downstream token. The +IdP side needs a SPIFFE identity provider (validates the SVID against SPIRE's trust +bundle) and a client bound to that SVID via SPIFFE/federated client authentication; +consult your IdP's SPIFFE client-auth docs. **Tested: Keycloak 26.6 (feature +`spiffe:v1`).** + +> Why route-scope it: keeping the workload authority off the agent's own identity, +> and requiring the enforcement point's credential for the scope-up, is what makes +> CPEX the trust boundary. A compromised agent can prove who it is but cannot mint +> the downstream token itself. + +### Recipe 3 — A service acting as itself + +**When:** CPEX calls a downstream as *itself*, with no inbound credential to exchange +(e.g. a scheduled job, or CPEX's own housekeeping). + +```yaml +routes: + - tool: sync_directory + apl: + pre_invocation: + - "delegate(svc-oauth, target: workday-api, audience: workday-api, subject: this_workload)" +``` + +`subject: this_workload` switches the delegator to `client_credentials`: no +`subject_token`, CPEX's own `client_id`/secret is the identity. **Tested: Keycloak +(client_credentials).** + +### Recipe 4 — Forward a token the caller already has (passthrough) + +**When:** the agent authenticated to the IdP itself and hands CPEX a ready token. +CPEX validates it inbound and lets the route forward it, with no `delegate` step. +This is the "agent-brokered" case; it needs no delegation code, only that the +inbound resolver validates the token and the route allows the call. + +### Recipe 5 — Scope a token the agent already holds (1-leg) + +**When:** the agent authenticated to the IdP *itself* with its SVID and got back a +normal JWT, and you still want CPEX to narrow that token per-tool (least privilege +at the boundary) without ever handling the SVID. + +> **The token is not the SVID.** The agent presented its SVID as a `client_assertion` +> *upstream* and received an ordinary IdP access token. That token arrives here **like +> a user token**: same header, same JWKS validation, `RS256` (an IdP-signed +> JWT), *not* the `ES256` SVID. CPEX never sees the SVID; it sees a normal token. + +```yaml +plugins: + - name: jwt-agent + kind: identity/jwt + hooks: [identity.resolve] + config: + role: client # the agent as an OAuth client + header: Authorization # a normal bearer token, NOT X-Workload-Token + trusted_issuers: + - issuer: "https://idp.example.com/realms/corp" + audiences: ["cpex"] + algorithms: ["RS256"] # an IdP-issued JWT, not the ES256 SVID + decoding_key: { kind: jwks_url, url: "https://idp.example.com/realms/corp/protocol/openid-connect/certs" } +routes: + - tool: get_directory + apl: + pre_invocation: + - "delegate(workday-oauth, target: workday-api, audience: workday-api, permissions: [read_compensation], subject: client)" +``` + +This is a **plain RFC 8693 exchange**, the same engine as +[Recipe 1](#recipe-1--user-acting-through-an-agent-on-behalf-of), scoping the +*agent's* token instead of a user's. **One leg** (the scope): the agent did +the authenticate leg upstream, so CPEX doesn't. + +**Don't confuse this with Recipe 2.** The trigger is *what the agent presents*: +an SVID, or a token minted from one: + +| Agent presents | Slot → subject | CPEX does | Legs | +|---|---|---|---| +| its **SVID** (`ES256`, SPIRE JWKS) | `caller_workload` → `subject: caller_workload` | authenticate **+** scope | 2 (Recipe 2) | +| a **token minted from its SVID** (`RS256`, IdP JWKS) | `client` → `subject: client` | scope only | 1 (this recipe) | +| a **token already right** for the tool | — | forward as-is | 0 (Recipe 4) | + +Using `subject: caller_workload` on an already-minted token misroutes it +down the SVID two-leg (`client_assertion`) path. Match the subject to what +arrived: **an SVID is a `caller_workload`; a JWT minted from it is a +`client` (or `user`) token.** + +--- + +## Where to place CPEX + +The same config runs at any enforcement point. See +[Deployment → Placement guidance]({{< relref "deployment" >}}). The identity-specific read: + +| Placement | Use it when | Because | +|---|---|---| +| **In front of the tools** (proxy / gateway) | agents are untrusted; you want one chokepoint | CPEX becomes the trust boundary that holds downstream authority; agents can't bypass it | +| **In the MCP / tool server** | defense in depth, or no proxy in the path | the resource enforces even if a front door is skipped; validates the caller right at the data | +| **Agent-side** | the agent is trusted and you want it to self-limit | least-privilege hygiene at the source, not a control against a compromised agent | + +You can run CPEX at more than one point at once (agent hygiene + a chokepoint + +resource defense-in-depth): same policy, different boundaries. + +## IdP support: tested vs. should-work + +CPEX's identity/delegation plugins are configured against OAuth/OIDC **standards**, +not any one vendor. Per layer: + +| Capability | Standard | Tested | Should work (untested) | +|---|---|---|---| +| JWT validation (any inbound token) | JWT + JWKS (RFC 7519/7517) | Keycloak | Okta, IBM Verify, Auth0, any OIDC IdP; point at its JWKS + issuer | +| Service token (`client_credentials`) | RFC 6749 §4.4 | Keycloak | broadly supported | +| On-behalf-of exchange | RFC 8693 | Keycloak (STE v2) | IdPs vary in RFC 8693 support; verify per target | +| SVID as client credential | RFC 7523 + `draft-ietf-oauth-spiffe-client-auth` | Keycloak 26.6 (`spiffe:v1`) | emerging; an IETF OAuth WG draft, other IdPs not yet confirmed | + +**If your IdP doesn't (yet) speak SPIFFE**, you are not blocked. CPEX can validate +the SVID *itself* (it already fetches SPIRE's JWKS in `identity.resolve`), establish +`caller_workload`, and then mint the downstream token using its *own* credentials +(`subject: this_workload`), carrying the workload identity as a claim. That +"CPEX-validates, CPEX-mints-as-itself" mode works with any OIDC IdP today; only the +recipe-2 *native* flow needs the IdP to understand SVIDs. + +> Testing note: "Tested" means we have run it end-to-end against that IdP. +> "Should work" means it relies only on standards that IdP documents supporting. +> Treat it as a starting point, not a guarantee, and tell us what you find. + +## What to add next + + diff --git a/docs/static/images/identity_two_boundaries.png b/docs/static/images/identity_two_boundaries.png new file mode 100644 index 00000000..8dfd052b Binary files /dev/null and b/docs/static/images/identity_two_boundaries.png differ diff --git a/docs/static/images/identity_two_boundaries.svg b/docs/static/images/identity_two_boundaries.svg new file mode 100644 index 00000000..d8d49fd2 --- /dev/null +++ b/docs/static/images/identity_two_boundaries.svg @@ -0,0 +1,35 @@ + + + + + + + + + + INBOUND + OUTBOUND + + + + identity.resolve + who is calling in + + + + route + policy + + + + token.delegate + who we call out as + + + validates credentials, + lands typed identity slots + mints the downstream credential + per the route + subject + + + identity is additive across slots; delegation is chosen per route by subject +