diff --git a/builtins/plugins/delegator-oauth/src/config.rs b/builtins/plugins/delegator-oauth/src/config.rs index e01b3a67..473221bb 100644 --- a/builtins/plugins/delegator-oauth/src/config.rs +++ b/builtins/plugins/delegator-oauth/src/config.rs @@ -62,6 +62,16 @@ 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, } /// Where the gateway's OAuth client secret is loaded from. Three @@ -88,6 +98,10 @@ 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_timeout_seconds() -> u64 { 5 } @@ -137,6 +151,10 @@ 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"); } #[test] diff --git a/builtins/plugins/delegator-oauth/src/delegator.rs b/builtins/plugins/delegator-oauth/src/delegator.rs index 3dd99e83..3ba01f39 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, @@ -47,7 +49,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 +62,12 @@ 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 the gateway: there is +/// no inbound credential to exchange, and the gateway's 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. @@ -224,8 +232,15 @@ impl HookHandler for OAuthDelegator { _ext: &Extensions, _ctx: &mut PluginContext, ) -> PluginResult { + // `subject: gateway` means *we* are the principal. There is no + // inbound credential to exchange — the gateway'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_gateway = *payload.subject() == DelegationSubject::Gateway; + let bearer = payload.bearer_token(); - if bearer.is_empty() { + if bearer.is_empty() && !as_gateway { return PluginResult::deny(PluginViolation::new( "delegation.bad_request", "DelegationPayload carried an empty bearer_token — outbound \ @@ -243,17 +258,45 @@ 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), - ]; + // Build the form-encoded body: RFC 6749 §4.4 for the gateway + // acting as itself, RFC 8693 §2.1 for every exchange on behalf + // of somebody else. + let mut form: Vec<(&str, &str)> = if as_gateway { + vec![ + ("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS), + ("audience", audience), + ] + } else { + vec![ + ("grant_type", GRANT_TYPE_TOKEN_EXCHANGE), + ("subject_token", bearer), + ("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 + // the gateway as principal *and* the calling agent recorded in + // `act` needs a real subject credential for the gateway — + // i.e. its own SVID — rather than client_credentials. + let actor_token = payload.actor_token(); + if !actor_token.is_empty() && !as_gateway { + 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 +428,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 +446,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. `Gateway` 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::Gateway => DelegationMode::AsGateway, + // `DelegationSubject` is #[non_exhaustive]; User, Client and + // any future variant all describe a principal the gateway 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..4afce715 100644 --- a/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs +++ b/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs @@ -16,14 +16,18 @@ // * 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 +// * subject role — a workload subject (Mode A) is attributed +// `AsGateway`, not `OnBehalfOfUser` 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 +368,275 @@ 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 "gateway acting 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: gateway` — the gateway holds the access to the +/// downstream (the "gateway owns the tool credentials" 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 the gateway's identity proven by the Basic +/// auth header it already sends. +#[tokio::test] +async fn gateway_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 gateway subject that is the + // expected state, not the "caller forgot the credential" error. + let payload = DelegationPayload::new("", "get_compensation") + .with_subject(DelegationSubject::Gateway) + .with_target_audience("https://hr.example.com") + .with_required_permissions(vec!["read:compensation".into()]); + + let result = invoke(&mgr, payload).await; + assert!( + result.continue_processing, + "gateway-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::AsGateway), + ), + "gateway subject must be attributed to the gateway, 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 +/// gateway's exemption must not silently swallow a genuinely missing +/// workload or user token. +#[tokio::test] +async fn empty_bearer_still_rejected_for_non_gateway_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 gateway-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 gateway_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::Gateway) + .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 exchanges its own SVID, no user +/// anywhere in the request. The minted credential speaks for that +/// agent, so `delegation_mode` must be `AsCallerWorkload`: +/// `apply_to_extensions` keys the delegated-token cache off this, and +/// filing the token under a user identity that never participated +/// would be wrong. +/// +/// Specifically *not* `AsGateway` — that mode belongs to the +/// gateway's own `this_workload` identity, which is a different +/// principal from whichever agent happens to be calling. +#[tokio::test] +async fn workload_subject_mints_as_caller_workload_not_on_behalf_of_user() { + let mut server = Server::new_async().await; + let mock = server + .mock("POST", "/oauth/token") + .match_body(Matcher::UrlEncoded( + "subject_token".into(), + "caller-bearer-token-bytes".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, + "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::AsCallerWorkload), + ), + "workload subject must be attributed to the calling agent, got {:?}", + final_payload.delegation_mode, + ); + mock.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..05083ade 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: gateway` means *we* are the principal. + // + // Gateway is the one subject with no inbound credential to + // read — the gateway 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: gateway`: the gateway 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 `gateway`. +/// +/// `"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..20b6d4b8 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,74 @@ 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. `Gateway` names *our 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 gateway itself. Used when the gateway holds the access to + /// a downstream — the common "gateway owns the tool credentials" + /// deployment — and calls it as itself rather than as the caller. + /// + /// Has no inbound credential to exchange: the gateway proves who + /// it is with its own client credentials or its own SVID. + Gateway, +} + +impl DelegationSubject { + /// Which inbound credential supplies this subject's token, or + /// `None` for [`Gateway`] — nothing the caller sent is being + /// exchanged, so there is no inbound slot to read. + /// + /// [`Gateway`]: DelegationSubject::Gateway + pub fn inbound_role(&self) -> Option { + match self { + DelegationSubject::User => Some(TokenRole::User), + DelegationSubject::Client => Some(TokenRole::Client), + DelegationSubject::CallerWorkload => Some(TokenRole::CallerWorkload), + DelegationSubject::Gateway => 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`. + pub fn from_config_str(s: &str) -> Option { + match s { + "user" => Some(DelegationSubject::User), + "client" => Some(DelegationSubject::Client), + "caller_workload" | "workload" => Some(DelegationSubject::CallerWorkload), + "gateway" => Some(DelegationSubject::Gateway), + _ => 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 +227,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 +345,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 +363,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 +426,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 +541,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 +575,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 +598,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 +653,111 @@ 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::Gateway.inbound_role(), None); + } + + #[test] + fn subject_parses_from_config_including_legacy_workload_spelling() { + assert_eq!( + DelegationSubject::from_config_str("caller_workload"), + Some(DelegationSubject::CallerWorkload), + ); + // Configs written before the rename keep working. + assert_eq!( + DelegationSubject::from_config_str("workload"), + Some(DelegationSubject::CallerWorkload), + ); + assert_eq!( + DelegationSubject::from_config_str("gateway"), + Some(DelegationSubject::Gateway), + ); + // 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 +869,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 +879,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 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..e987e172 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,18 +105,47 @@ 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. + + /// 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 `AsGateway`: many different agents call through + /// one gateway, so this mode does *not* identify a single + /// principal on its own — `DelegationKey.workload_id` carries the + /// specific caller. + AsCallerWorkload, + + /// Outbound token represents *this* gateway's own attested + /// identity (`SecurityExtension.this_workload`) — used when the + /// gateway calls infrastructure it owns, or a downstream that + /// trusts only the gateway with user context conveyed separately. + /// + /// The gateway'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`. AsGateway, } @@ -154,8 +193,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 +213,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 +369,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 +392,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..084db6d7 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`, `gateway` | `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 `gateway` — us — which is the one principal with no inbound credential at all. + +### 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 gateway calling as itself + +The common MCP-gateway deployment: agents authenticate to the gateway, but the *gateway* 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: gateway, + audience: workday-api, permissions: [read_compensation])" +``` + +`subject: gateway` 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 the gateway's identity is the OAuth client identity it already authenticates with. Nothing extra to configure — the delegator's existing `client_id` / `client_secret` *is* the gateway's identity. + +Two consequences worth understanding before choosing this shape: + +- **The gateway is the only enforcement point.** The backend sees a token that says "the gateway" 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 the gateway 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 | +| `gateway` | `as_gateway` | The gateway 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..5e2973e2 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`** — the gateway'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