From bddd0d382b45359d5dd3cdd262df33c6af4ad302 Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Thu, 23 Jul 2026 15:06:11 -0600 Subject: [PATCH 1/5] feat(delegation): RFC 8693 actor tokens + gateway/workload subjects. Signed-off-by: Teryl Taylor --- .../plugins/delegator-oauth/src/config.rs | 18 + .../plugins/delegator-oauth/src/delegator.rs | 87 +++- .../delegator-oauth/tests/oauth_e2e.rs | 282 +++++++++++- .../plugins/identity-jwt/src/claim_map.rs | 2 +- builtins/plugins/identity-jwt/src/resolver.rs | 16 +- .../plugins/identity-jwt/tests/jwt_e2e.rs | 140 +++++- crates/apl-cpex/src/delegation_invoker.rs | 220 ++++++++-- crates/cpex-core/src/delegation/mod.rs | 4 +- crates/cpex-core/src/delegation/payload.rs | 407 +++++++++++++++++- crates/cpex-core/src/extensions/filter.rs | 1 + .../src/extensions/raw_credentials.rs | 125 +++++- crates/cpex-core/tests/delegation_e2e.rs | 5 +- docs/content/docs/apl/delegation.md | 66 +++ docs/content/docs/apl/identity.md | 39 +- 14 files changed, 1338 insertions(+), 74 deletions(-) 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/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 From a68c760f199071c41ed32821ff48fa053eef2ef9 Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Thu, 23 Jul 2026 17:53:56 -0600 Subject: [PATCH 2/5] test(apl-cpex): approval-gates-delegation integration test Signed-off-by: Teryl Taylor --- .../tests/elicit_then_delegate_e2e.rs | 346 ++++++++++++++++++ 1 file changed, 346 insertions(+) create mode 100644 crates/apl-cpex/tests/elicit_then_delegate_e2e.rs 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", + ); +} From cbb95bb7faf23b1def330e667db7fa0c6346a53b Mon Sep 17 00:00:00 2001 From: Teryl Taylor Date: Mon, 27 Jul 2026 20:43:28 -0600 Subject: [PATCH 3/5] feat: add identity/delegation documentation, renamed gateway to this_workload, and added SVID scenario. Signed-off-by: Teryl Taylor --- .../plugins/delegator-oauth/src/config.rs | 22 ++ .../plugins/delegator-oauth/src/delegator.rs | 145 ++++++- .../delegator-oauth/tests/oauth_e2e.rs | 110 ++++-- crates/apl-cpex/src/delegation_invoker.rs | 12 +- crates/cpex-core/src/delegation/payload.rs | 57 +-- .../src/extensions/raw_credentials.rs | 15 +- docs/content/docs/apl/delegation.md | 18 +- docs/content/docs/apl/identity.md | 2 +- docs/content/docs/identity-delegation.md | 366 ++++++++++++++++++ 9 files changed, 649 insertions(+), 98 deletions(-) create mode 100644 docs/content/docs/identity-delegation.md diff --git a/builtins/plugins/delegator-oauth/src/config.rs b/builtins/plugins/delegator-oauth/src/config.rs index 473221bb..8807fdfb 100644 --- a/builtins/plugins/delegator-oauth/src/config.rs +++ b/builtins/plugins/delegator-oauth/src/config.rs @@ -72,6 +72,18 @@ pub struct OAuthDelegatorConfig { /// 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 @@ -102,6 +114,10 @@ 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 } @@ -155,6 +171,12 @@ mod tests { // 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 3ba01f39..4d6b2b5b 100644 --- a/builtins/plugins/delegator-oauth/src/delegator.rs +++ b/builtins/plugins/delegator-oauth/src/delegator.rs @@ -41,6 +41,7 @@ // scopes don't include all // requested permissions +use std::borrow::Cow; use std::sync::Arc; use async_trait::async_trait; @@ -63,9 +64,10 @@ use super::config::OAuthDelegatorConfig; 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. +/// 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 @@ -191,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. @@ -232,15 +313,23 @@ 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 + // `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_gateway = *payload.subject() == DelegationSubject::Gateway; + 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() && !as_gateway { + if bearer.is_empty() && !as_this_workload { return PluginResult::deny(PluginViolation::new( "delegation.bad_request", "DelegationPayload carried an empty bearer_token — outbound \ @@ -258,10 +347,24 @@ impl HookHandler for OAuthDelegator { let scope = Self::requested_scopes(payload); - // Build the form-encoded body: RFC 6749 §4.4 for the gateway + // 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_gateway { + let mut form: Vec<(&str, &str)> = if as_this_workload { vec![ ("grant_type", GRANT_TYPE_CLIENT_CREDENTIALS), ("audience", audience), @@ -269,7 +372,10 @@ impl HookHandler for OAuthDelegator { } else { vec![ ("grant_type", GRANT_TYPE_TOKEN_EXCHANGE), - ("subject_token", bearer), + // 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), ] @@ -288,11 +394,16 @@ impl HookHandler for OAuthDelegator { // 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 — + // 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_gateway { + 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)); } @@ -451,7 +562,7 @@ impl HookHandler for OAuthDelegator { /// /// 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 +/// 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. /// @@ -462,9 +573,9 @@ impl HookHandler for OAuthDelegator { fn mode_for_subject(subject: &DelegationSubject) -> DelegationMode { match subject { DelegationSubject::CallerWorkload => DelegationMode::AsCallerWorkload, - DelegationSubject::Gateway => DelegationMode::AsGateway, + DelegationSubject::ThisWorkload => DelegationMode::AsThisWorkload, // `DelegationSubject` is #[non_exhaustive]; User, Client and - // any future variant all describe a principal the gateway is + // any future variant all describe a principal this instance is // acting *for*, so on-behalf-of stays the safe default. _ => DelegationMode::OnBehalfOfUser, } diff --git a/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs b/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs index 4afce715..c9491de3 100644 --- a/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs +++ b/builtins/plugins/delegator-oauth/tests/oauth_e2e.rs @@ -18,8 +18,9 @@ // 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` +// * 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; @@ -388,7 +389,7 @@ fn ok_token_response() -> 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 +/// 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() { @@ -476,15 +477,15 @@ async fn absent_actor_leaves_no_actor_fields_on_the_wire() { mock.assert_async().await; } -/// `subject: gateway` — the gateway holds the access to the -/// downstream (the "gateway owns the tool credentials" deployment) +/// `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 the gateway's identity proven by the Basic +/// `subject_token`, and this instance's identity proven by the Basic /// auth header it already sends. #[tokio::test] -async fn gateway_subject_uses_client_credentials_not_token_exchange() { +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") @@ -504,17 +505,17 @@ async fn gateway_subject_uses_client_credentials_not_token_exchange() { .await; let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; - // Note the empty bearer token: for a gateway subject that is the + // 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::Gateway) + .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, - "gateway-subject exchange should mint a token; violation = {:?}", + "this_workload-subject exchange should mint a token; violation = {:?}", result.violation, ); @@ -523,9 +524,9 @@ async fn gateway_subject_uses_client_credentials_not_token_exchange() { assert!( matches!( final_payload.delegation_mode, - Some(DelegationMode::AsGateway), + Some(DelegationMode::AsThisWorkload), ), - "gateway subject must be attributed to the gateway, got {:?}", + "this_workload subject must be attributed to this instance, got {:?}", final_payload.delegation_mode, ); mock.assert_async().await; @@ -533,10 +534,10 @@ async fn gateway_subject_uses_client_credentials_not_token_exchange() { /// 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 +/// 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_gateway_subjects() { +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) @@ -554,11 +555,11 @@ async fn empty_bearer_still_rejected_for_non_gateway_subjects() { } /// `actor_token` is a token-exchange parameter with no meaning under -/// `client_credentials`, so a gateway-subject call must not send it +/// `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 gateway_subject_never_sends_actor_token() { +async fn this_workload_subject_never_sends_actor_token() { let mut server = Server::new_async().await; let mock = server .mock("POST", "/oauth/token") @@ -574,7 +575,7 @@ async fn gateway_subject_never_sends_actor_token() { let mgr = build_manager(&format!("{}/oauth/token", server.url())).await; let payload = DelegationPayload::new("", "get_compensation") - .with_subject(DelegationSubject::Gateway) + .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()]); @@ -588,25 +589,62 @@ async fn gateway_subject_never_sends_actor_token() { 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. +/// Mode A — the calling agent acts as itself. Its SVID is a *client +/// credential*, not a subject_token, so the delegator runs two legs: /// -/// 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. +/// 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_mints_as_caller_workload_not_on_behalf_of_user() { +async fn workload_subject_authenticates_by_svid_then_exchanges() { let mut server = Server::new_async().await; - let mock = server + + // 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::UrlEncoded( - "subject_token".into(), - "caller-bearer-token-bytes".into(), - )) + .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()) @@ -624,7 +662,7 @@ async fn workload_subject_mints_as_caller_workload_not_on_behalf_of_user() { let result = invoke(&mgr, payload).await; assert!( result.continue_processing, - "workload-subject exchange should mint a token; violation = {:?}", + "two-leg workload delegation should mint a token; violation = {:?}", result.violation, ); @@ -638,5 +676,9 @@ async fn workload_subject_mints_as_caller_workload_not_on_behalf_of_user() { "workload subject must be attributed to the calling agent, got {:?}", final_payload.delegation_mode, ); - mock.assert_async().await; + + // 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/crates/apl-cpex/src/delegation_invoker.rs b/crates/apl-cpex/src/delegation_invoker.rs index 05083ade..1bc75a2c 100644 --- a/crates/apl-cpex/src/delegation_invoker.rs +++ b/crates/apl-cpex/src/delegation_invoker.rs @@ -130,10 +130,10 @@ impl DelegationInvoker for DelegationPluginInvoker { // (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. + // `subject: this_workload` 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 + // 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 @@ -170,8 +170,8 @@ impl DelegationInvoker for DelegationPluginInvoker { // `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. + // `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 @@ -294,7 +294,7 @@ impl DelegationInvoker for DelegationPluginInvoker { /// 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`. +/// [`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 { diff --git a/crates/cpex-core/src/delegation/payload.rs b/crates/cpex-core/src/delegation/payload.rs index 20b6d4b8..f9df2e40 100644 --- a/crates/cpex-core/src/delegation/payload.rs +++ b/crates/cpex-core/src/delegation/payload.rs @@ -69,10 +69,10 @@ use crate::impl_plugin_payload; /// /// 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. +/// 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")] @@ -85,27 +85,29 @@ pub enum DelegationSubject { /// 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. + /// 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: the gateway proves who - /// it is with its own client credentials or its own SVID. - 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 [`Gateway`] — nothing the caller sent is being + /// `None` for [`ThisWorkload`] — nothing the caller sent is being /// exchanged, so there is no inbound slot to read. /// - /// [`Gateway`]: DelegationSubject::Gateway + /// [`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::Gateway => None, + DelegationSubject::ThisWorkload => None, } } @@ -113,14 +115,15 @@ impl DelegationSubject { /// 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`. + /// `"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), - "gateway" => Some(DelegationSubject::Gateway), + "this_workload" | "gateway" => Some(DelegationSubject::ThisWorkload), _ => None, } } @@ -723,23 +726,28 @@ mod tests { DelegationSubject::CallerWorkload.inbound_role(), Some(TokenRole::CallerWorkload), ); - assert_eq!(DelegationSubject::Gateway.inbound_role(), None); + assert_eq!(DelegationSubject::ThisWorkload.inbound_role(), None); } #[test] - fn subject_parses_from_config_including_legacy_workload_spelling() { + fn subject_parses_from_config_including_legacy_spellings() { assert_eq!( DelegationSubject::from_config_str("caller_workload"), Some(DelegationSubject::CallerWorkload), ); - // Configs written before the rename keep working. + // 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::Gateway), + Some(DelegationSubject::ThisWorkload), ); // A typo resolves to None so the caller applies its own // default rather than silently picking a principal. @@ -973,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 )); } @@ -1014,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/raw_credentials.rs b/crates/cpex-core/src/extensions/raw_credentials.rs index e987e172..16080ae9 100644 --- a/crates/cpex-core/src/extensions/raw_credentials.rs +++ b/crates/cpex-core/src/extensions/raw_credentials.rs @@ -130,23 +130,24 @@ pub enum DelegationMode { /// 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 + /// 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* gateway's own attested + /// Outbound token represents *this* CPEX instance'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. + /// enforcement point calls infrastructure it owns, or a downstream + /// that trusts only this instance with user context conveyed + /// separately. /// - /// The gateway's SVID is not an inbound credential: it comes from + /// 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`. - AsGateway, + AsThisWorkload, } /// One inbound credential, captured at the wire layer and stashed diff --git a/docs/content/docs/apl/delegation.md b/docs/content/docs/apl/delegation.md index 084db6d7..07200005 100644 --- a/docs/content/docs/apl/delegation.md +++ b/docs/content/docs/apl/delegation.md @@ -53,10 +53,10 @@ By default a `delegate` step exchanges the **user's** token: the minted credenti | Key | Meaning | Accepts | Default | |-----|---------|---------|---------| -| `subject` | Whose identity the minted token speaks for. | `user`, `client`, `caller_workload`, `gateway` | `user` | +| `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 `gateway` — us — which is the one principal with no inbound credential at all. +`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 @@ -80,23 +80,23 @@ pre_invocation: 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 enforcement point 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. +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: gateway, + - "delegate(workday-oauth, target: workday-api, subject: this_workload, 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. +`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: -- **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. +- **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 @@ -107,7 +107,7 @@ Each exchange is attributed to exactly one principal, and the attribution is **d | `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 | +| `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. diff --git a/docs/content/docs/apl/identity.md b/docs/content/docs/apl/identity.md index 5e2973e2..9e9c5bec 100644 --- a/docs/content/docs/apl/identity.md +++ b/docs/content/docs/apl/identity.md @@ -85,7 +85,7 @@ The SPIFFE ID is read from the SVID's `sub` claim, and the trust domain is deriv 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. +- **`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" >}}). diff --git a/docs/content/docs/identity-delegation.md b/docs/content/docs/identity-delegation.md new file mode 100644 index 00000000..71c46e4e --- /dev/null +++ b/docs/content/docs/identity-delegation.md @@ -0,0 +1,366 @@ +--- +title: "Identity & Delegation" +weight: 45 +--- + +# 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). This page is a recipe book. 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 actually been tested. + +CPEX is a policy engine, not a particular product: 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: + +``` + inbound outbound + ┌──────────────────┐ ┌──────────────────────┐ + │ identity.resolve │ route + policy │ token.delegate │ + │ (who called in) │ ─────────────────▶ │ (whom we call out as)│ + └──────────────────┘ └──────────────────────┘ + validates creds → mints the downstream + typed identity slots credential per the route +``` + +- **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. What the minted token + *speaks for* is chosen by the step's `subject:`. + +## 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 is not a claim that 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 exactly 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** — that's 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 SOLELY by the SVID — 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* — 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 just validates it inbound and lets the route forward it — 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 **just +> 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), just scoping the +*agent's* token instead of a user's. **One leg** (the scope) — the agent already 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 would wrongly route it +down the SVID two-leg (`client_assertion`) path — so match the subject to what +actually 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. What that means 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 + + From 809adca15842b19bcb6c4967c9be30625615f961 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Tue, 28 Jul 2026 00:12:04 -0400 Subject: [PATCH 4/5] docs: tighten identity-delegation prose and reorder after extensions Signed-off-by: Frederico Araujo --- docs/content/docs/identity-delegation.md | 102 +++++++++++------------ 1 file changed, 50 insertions(+), 52 deletions(-) diff --git a/docs/content/docs/identity-delegation.md b/docs/content/docs/identity-delegation.md index 71c46e4e..e2f41660 100644 --- a/docs/content/docs/identity-delegation.md +++ b/docs/content/docs/identity-delegation.md @@ -1,21 +1,19 @@ --- title: "Identity & Delegation" -weight: 45 +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). This page is a recipe book. 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 actually been tested. +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. -CPEX is a policy engine, not a particular product: 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." +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 @@ -35,12 +33,12 @@ Every request crosses two identity boundaries: typed identity slots credential per the route ``` -- **Inbound** — `identity.resolve` plugins each read one credential (from a header) +- **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. What the minted token - *speaks for* is chosen by the step's `subject:`. +- **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 @@ -58,12 +56,12 @@ The `subject:` on a `delegate(...)` step decides the OAuth mechanism the delegat | `subject:` | Minted token speaks for | Mechanism | |---|---|---| -| `user` (default) | the human — on-behalf-of | RFC 8693 token exchange (`subject_token` = the user's token) | +| `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` | **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 is not a claim that CPEX is a gateway. (`gateway` is accepted as a +> deployed it as. It does not claim CPEX is a gateway. (`gateway` is accepted as a > deprecated alias.) ### "I want to…" → mechanism @@ -74,14 +72,14 @@ The `subject:` on a `delegate(...)` step decides the OAuth mechanism the delegat | 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 | — | +| 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 +**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: @@ -93,13 +91,13 @@ The layers, broad to narrow: | **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: +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). +- **one tool** → a specific route (which overrides the `*` default; more specific wins). ### Identity example (the stack in one config) @@ -114,7 +112,7 @@ routes: meta: { tags: [hr-tools] } # inherits jwt-user + jwt-manager ``` -**The override** — a route that must stand alone drops the inherited layers: +**The override.** A route that must stand alone drops the inherited layers: ```yaml routes: @@ -124,7 +122,7 @@ routes: steps: [jwt-workload] # …authenticate by the SVID alone ``` -That is exactly what [Recipe 2](#recipe-2--agent-acting-as-itself-by-its-spiffe-svid) +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 @@ -192,12 +190,12 @@ 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 +> *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** — that's leg 1 below. (Contrast +> 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.) +> token already *minted from* an SVID. Add a workload resolver, scoped to the route so only it runs there: @@ -218,7 +216,7 @@ plugins: routes: - tool: get_directory - # Authenticate this route SOLELY by the SVID — drop the global user/client + # Authenticate this route by the SVID alone: drop the global user/client # resolvers. `jwt-workload` is NOT in global.authentication. authentication: replace_inherited: true @@ -233,18 +231,18 @@ For `subject: caller_workload`, the OAuth delegator runs **two legs**: leg 1 pre 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 — +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 +> 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* — no inbound credential to exchange +**When:** CPEX calls a downstream as *itself*, with no inbound credential to exchange (e.g. a scheduled job, or CPEX's own housekeeping). ```yaml @@ -255,26 +253,26 @@ routes: - "delegate(svc-oauth, target: workday-api, audience: workday-api, subject: this_workload)" ``` -`subject: this_workload` switches the delegator to `client_credentials` — no +`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 just validates it inbound and lets the route forward it — no `delegate` step. +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 +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 **just -> like a user token** — same header, same JWKS validation, `RS256` (an IdP-signed +> *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 @@ -284,7 +282,7 @@ plugins: hooks: [identity.resolve] config: role: client # the agent as an OAuth client - header: Authorization # a normal bearer token — NOT X-Workload-Token + header: Authorization # a normal bearer token, NOT X-Workload-Token trusted_issuers: - issuer: "https://idp.example.com/realms/corp" audiences: ["cpex"] @@ -297,12 +295,12 @@ routes: - "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), just scoping the -*agent's* token instead of a user's. **One leg** (the scope) — the agent already did +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* — +**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 | @@ -311,9 +309,9 @@ an SVID, or a token minted from one: | 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 would wrongly route it -down the SVID two-leg (`client_assertion`) path — so match the subject to what -actually arrived: **an SVID is a `caller_workload`; a JWT minted from it is a +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.** --- @@ -327,33 +325,33 @@ The same config runs at any enforcement point. See |---|---|---| | **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 | +| **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. +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. What that means per layer: +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 | +| 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 | +| 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 +(`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. +> "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 From 47b7b056ae275ad2278c00fd1cd892f8a579f8f3 Mon Sep 17 00:00:00 2001 From: Frederico Araujo Date: Tue, 28 Jul 2026 00:27:06 -0400 Subject: [PATCH 5/5] docs: replace identity ASCII diagram with styled PNG Signed-off-by: Frederico Araujo --- docs/content/docs/identity-delegation.md | 10 +---- .../static/images/identity_two_boundaries.png | Bin 0 -> 136983 bytes .../static/images/identity_two_boundaries.svg | 35 ++++++++++++++++++ 3 files changed, 36 insertions(+), 9 deletions(-) create mode 100644 docs/static/images/identity_two_boundaries.png create mode 100644 docs/static/images/identity_two_boundaries.svg diff --git a/docs/content/docs/identity-delegation.md b/docs/content/docs/identity-delegation.md index e2f41660..690bdd3e 100644 --- a/docs/content/docs/identity-delegation.md +++ b/docs/content/docs/identity-delegation.md @@ -23,15 +23,7 @@ For the conceptual model first, read [Use Cases]({{< relref "use-cases" >}}) and Every request crosses two identity boundaries: -``` - inbound outbound - ┌──────────────────┐ ┌──────────────────────┐ - │ identity.resolve │ route + policy │ token.delegate │ - │ (who called in) │ ─────────────────▶ │ (whom we call out as)│ - └──────────────────┘ └──────────────────────┘ - validates creds → mints the downstream - typed identity slots credential per the route -``` +![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 diff --git a/docs/static/images/identity_two_boundaries.png b/docs/static/images/identity_two_boundaries.png new file mode 100644 index 0000000000000000000000000000000000000000..8dfd052b524018a469f6d553c813c110a5332f29 GIT binary patch literal 136983 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 + + + + + + + + + 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 +