use std::collections::{HashMap, HashSet};
use praxis_policy_core::delegation::DelegationPayload;
use praxis_policy_core::extensions::Extensions;
use praxis_policy_core::extensions::raw_credentials::{
DelegationKey, DelegationMode, RawDelegatedToken, RawInboundToken, TokenKind, TokenRole,
};
use praxis_policy_core::hooks::payload::PluginPayload;
use praxis_policy_core::identity::{IdentityPayload, TokenSource};
use serde::Serialize;
use serde_json::Value;
use crate::PayloadKind;
use crate::conversion::payload_kind_for_hook;
use crate::error::HostError;
pub const CREDENTIAL_FIELD: &str = "credential";
pub const CAP_READ_INBOUND: &str = "read_inbound_credentials";
pub const CAP_READ_DELEGATED: &str = "read_delegated_tokens";
const REDACTED: &str = "<redacted>";
#[derive(Clone, Default, Serialize)]
pub struct CredentialDto {
#[serde(skip_serializing_if = "Option::is_none")]
pub inbound: Option<InboundCredential>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delegated: Option<DelegatedCredential>,
}
impl std::fmt::Debug for CredentialDto {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("CredentialDto")
.field("inbound", &self.inbound.as_ref().map(|_| REDACTED))
.field("delegated", &self.delegated.as_ref().map(|_| REDACTED))
.finish()
}
}
#[derive(Clone, Serialize)]
pub struct InboundCredential {
pub token: String,
pub source_header: String,
pub kind: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub headers: Option<HashMap<String, String>>,
}
impl std::fmt::Debug for InboundCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("InboundCredential")
.field("token", &REDACTED)
.field("source_header", &self.source_header)
.field("kind", &self.kind)
.field(
"header_names",
&self.headers.as_ref().map(|h| h.keys().collect::<Vec<_>>()),
)
.finish()
}
}
#[derive(Clone, Serialize)]
pub struct DelegatedCredential {
pub token: String,
pub outbound_header: String,
pub audience: String,
pub scopes: Vec<String>,
}
impl std::fmt::Debug for DelegatedCredential {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("DelegatedCredential")
.field("token", &REDACTED)
.field("outbound_header", &self.outbound_header)
.field("audience", &self.audience)
.field("scopes", &self.scopes)
.finish()
}
}
fn token_kind_wire_name(kind: &TokenKind) -> &'static str {
match kind {
TokenKind::Jwt => "jwt",
TokenKind::Opaque => "opaque",
TokenKind::SpiffeJwt => "spiffe_jwt",
TokenKind::Ucan => "ucan",
TokenKind::TxnToken => "txn_token",
_ => "opaque",
}
}
pub fn attach_credential(
task: &mut Value,
hook_name: &str,
extensions: &Extensions,
capabilities: &HashSet<String>,
) -> Result<(), HostError> {
let kind = payload_kind_for_hook(hook_name);
let dto = match kind {
PayloadKind::IdentityResolve => {
if !capabilities.contains(CAP_READ_INBOUND) {
return Ok(());
}
CredentialDto {
inbound: Some(build_inbound(extensions, hook_name)?),
delegated: None,
}
},
PayloadKind::TokenDelegate => {
if !capabilities.contains(CAP_READ_DELEGATED) {
return Ok(());
}
let requested_audience = requested_audience(task);
CredentialDto {
inbound: None,
delegated: Some(build_delegated(
extensions,
hook_name,
requested_audience.as_deref(),
)?),
}
},
_ => return Ok(()),
};
insert_dto(task, &dto)
}
pub fn attach_credential_for_payload(
task: &mut Value,
hook_name: &str,
payload: &dyn PluginPayload,
extensions: &Extensions,
capabilities: &HashSet<String>,
) -> Result<(), HostError> {
let any = payload.as_any();
let dto = match payload_kind_for_hook(hook_name) {
PayloadKind::IdentityResolve => match any.downcast_ref::<IdentityPayload>() {
Some(identity) if capabilities.contains(CAP_READ_INBOUND) => CredentialDto {
inbound: Some(inbound_from_identity(identity, hook_name)?),
delegated: None,
},
Some(_) => return Ok(()),
None => return attach_credential(task, hook_name, extensions, capabilities),
},
PayloadKind::TokenDelegate => match any.downcast_ref::<DelegationPayload>() {
Some(delegation) if capabilities.contains(CAP_READ_INBOUND) => CredentialDto {
inbound: None,
delegated: Some(delegated_from_payload(delegation, hook_name)?),
},
Some(_) => return Ok(()),
None => return attach_credential(task, hook_name, extensions, capabilities),
},
_ => return attach_credential(task, hook_name, extensions, capabilities),
};
insert_dto(task, &dto)
}
fn inbound_from_identity(
identity: &IdentityPayload,
hook_name: &str,
) -> Result<InboundCredential, HostError> {
let token = crate::credential_payloads::inbound_token(identity).ok_or_else(|| {
HostError::Credential {
message: format!(
"plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but the identity \
payload carries no credential, in `raw_token` or its source header. Refusing \
to dispatch without the token it asked for"
),
}
})?;
let source_header = crate::credential_payloads::credential_header_name(identity);
let mut headers = identity.headers().clone();
if !headers
.keys()
.any(|name| name.eq_ignore_ascii_case(&source_header))
{
headers.insert(source_header.clone(), token.clone());
}
Ok(InboundCredential {
token,
source_header,
kind: token_source_kind(identity.source()).to_string(),
headers: Some(headers),
})
}
fn token_source_kind(source: &TokenSource) -> &'static str {
match source {
TokenSource::SpiffeJwtSvid => "spiffe_jwt",
_ => "opaque",
}
}
fn delegated_from_payload(
delegation: &DelegationPayload,
hook_name: &str,
) -> Result<DelegatedCredential, HostError> {
let token = delegation.bearer_token();
if token.trim().is_empty() {
return Err(HostError::Credential {
message: format!(
"plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but the delegation \
payload carries no bearer token. Refusing to dispatch without the token it \
asked for"
),
});
}
Ok(DelegatedCredential {
token: token.to_string(),
outbound_header: "Authorization".into(),
audience: delegation.target_audience().unwrap_or_default().to_string(),
scopes: delegation.required_permissions().to_vec(),
})
}
fn insert_dto(task: &mut Value, dto: &CredentialDto) -> Result<(), HostError> {
let object = task.as_object_mut().ok_or_else(|| HostError::Protocol {
message: "task must be a JSON object to carry a credential".into(),
})?;
let serialized = serde_json::to_value(dto).map_err(|e| HostError::Credential {
message: format!("could not serialize the credential DTO: {e}"),
})?;
object.insert(CREDENTIAL_FIELD.to_string(), serialized);
Ok(())
}
fn build_inbound(extensions: &Extensions, hook_name: &str) -> Result<InboundCredential, HostError> {
let raw = extensions
.raw_credentials
.as_ref()
.ok_or_else(|| HostError::Credential {
message: format!(
"plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but the request \
carries no raw-credentials extension — refusing to dispatch without the token \
it asked for"
),
})?;
let token = [
TokenRole::User,
TokenRole::Client,
TokenRole::CallerWorkload,
]
.iter()
.find_map(|role| raw.inbound_tokens.get(role))
.or_else(|| raw.inbound_tokens.values().next())
.ok_or_else(|| HostError::Credential {
message: format!(
"plugin declared '{CAP_READ_INBOUND}' for hook '{hook_name}' but no inbound token \
is present — refusing to dispatch without it"
),
})?;
inbound_from_token(token, hook_name)
}
fn inbound_from_token(
token: &RawInboundToken,
hook_name: &str,
) -> Result<InboundCredential, HostError> {
let plaintext = token.token.as_str();
if plaintext.trim().is_empty() {
return Err(HostError::Credential {
message: format!(
"the inbound token for hook '{hook_name}' is empty — refusing to send an empty \
credential (an empty bearer authenticates nowhere, and a plugin may read it as \
'no authentication required')"
),
});
}
Ok(InboundCredential {
token: plaintext.to_string(),
source_header: token.source_header.clone(),
kind: token_kind_wire_name(&token.kind).to_string(),
headers: Some(HashMap::from([(
token.source_header.clone(),
plaintext.to_string(),
)])),
})
}
fn requested_audience(task: &Value) -> Option<String> {
task.get("payload")?
.get("target_audience")?
.as_str()
.map(str::to_string)
}
fn build_delegated(
extensions: &Extensions,
hook_name: &str,
requested_audience: Option<&str>,
) -> Result<DelegatedCredential, HostError> {
let raw = extensions
.raw_credentials
.as_ref()
.ok_or_else(|| HostError::Credential {
message: format!(
"plugin declared '{CAP_READ_DELEGATED}' for hook '{hook_name}' but the request \
carries no raw-credentials extension — refusing to dispatch without the token \
it asked for"
),
})?;
let mut candidates: Vec<(&DelegationKey, &RawDelegatedToken)> = raw
.delegated_tokens
.iter()
.filter(|(key, token)| match requested_audience {
Some(audience) => key.audience == audience && token.audience == audience,
None => true,
})
.collect();
candidates.sort_by(|(left, _), (right, _)| {
mode_rank(&left.mode)
.cmp(&mode_rank(&right.mode))
.then_with(|| left.subject_id.cmp(&right.subject_id))
.then_with(|| left.scopes.cmp(&right.scopes))
});
let (_, token) = candidates.first().ok_or_else(|| {
let scope = match requested_audience {
Some(audience) => format!(" for audience '{audience}'"),
None => String::new(),
};
HostError::Credential {
message: format!(
"plugin declared '{CAP_READ_DELEGATED}' for hook '{hook_name}' but no delegated \
token{scope} is present — refusing to dispatch without it, and refusing to \
substitute a token minted for a different audience"
),
}
})?;
delegated_from_token(token, hook_name)
}
fn mode_rank(mode: &DelegationMode) -> u8 {
match mode {
DelegationMode::OnBehalfOfUser => 0,
DelegationMode::AsThisWorkload => 1,
_ => 2,
}
}
fn delegated_from_token(
token: &RawDelegatedToken,
hook_name: &str,
) -> Result<DelegatedCredential, HostError> {
let plaintext = token.token.as_str();
if plaintext.trim().is_empty() {
return Err(HostError::Credential {
message: format!(
"the delegated token for hook '{hook_name}' is empty — refusing to send an empty \
credential"
),
});
}
Ok(DelegatedCredential {
token: plaintext.to_string(),
outbound_header: token.outbound_header.clone(),
audience: token.audience.clone(),
scopes: token.scopes.clone(),
})
}
#[cfg(test)]
mod tests {
use std::sync::Arc;
use praxis_policy_core::extensions::raw_credentials::{DelegationKey, RawCredentialsExtension};
use super::*;
const INBOUND_SECRET: &str = "eyJhbGciOiJSUzI1NiJ9.INBOUND-SECRET-BYTES.sig";
const DELEGATED_SECRET: &str = "MINTED-DELEGATED-SECRET-BYTES";
fn caps(names: &[&str]) -> HashSet<String> {
names.iter().map(|s| (*s).to_string()).collect()
}
fn extensions_with_credentials() -> Extensions {
let mut raw = RawCredentialsExtension::default();
raw.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt),
);
raw.delegated_tokens.insert(
DelegationKey::new(
DelegationMode::OnBehalfOfUser,
"https://billing.example.com",
vec!["read".into()],
)
.with_subject_id("alice"),
RawDelegatedToken::new(
DELEGATED_SECRET,
"Authorization",
"https://billing.example.com",
vec!["read".into()],
chrono::Utc::now(),
),
);
Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
}
}
fn task() -> Value {
serde_json::json!({ "task_type": "load_and_run_hook" })
}
fn delegate_task(target_audience: &str) -> Value {
serde_json::json!({
"task_type": "load_and_run_hook",
"hook_type": "token.delegate",
"payload": {
"target_name": "billing",
"target_type": "tool",
"target_audience": target_audience,
},
})
}
fn delegated(
subject: &str,
audience: &str,
mode: DelegationMode,
secret: &str,
) -> (DelegationKey, RawDelegatedToken) {
(
DelegationKey::new(mode, audience, vec![]).with_subject_id(subject),
RawDelegatedToken::new(
secret,
"Authorization",
audience,
vec![],
chrono::Utc::now(),
),
)
}
fn extensions_from(tokens: Vec<(DelegationKey, RawDelegatedToken)>) -> Extensions {
let mut raw = RawCredentialsExtension::default();
for (key, token) in tokens {
raw.delegated_tokens.insert(key, token);
}
Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
}
}
fn pick_delegated(extensions: &Extensions, target_audience: &str) -> Result<String, HostError> {
let mut task = delegate_task(target_audience);
attach_credential(
&mut task,
"token.delegate",
extensions,
&caps(&[CAP_READ_DELEGATED]),
)?;
Ok(task[CREDENTIAL_FIELD]["delegated"]["token"]
.as_str()
.expect("a delegated token was attached")
.to_string())
}
#[test]
fn a_declaring_plugin_receives_the_inbound_token() {
let mut task = task();
attach_credential(
&mut task,
"identity.resolve",
&extensions_with_credentials(),
&caps(&[CAP_READ_INBOUND]),
)
.expect("a declared capability is honored");
let credential = &task[CREDENTIAL_FIELD];
assert_eq!(credential["inbound"]["token"], INBOUND_SECRET);
assert_eq!(credential["inbound"]["source_header"], "Authorization");
assert_eq!(credential["inbound"]["kind"], "jwt");
assert!(
credential.get("delegated").is_none(),
"an identity hook must not receive delegated material"
);
}
#[test]
fn a_non_declaring_plugin_receives_no_token_material() {
let mut task = task();
attach_credential(
&mut task,
"identity.resolve",
&extensions_with_credentials(),
&caps(&[]),
)
.expect("declaring nothing is not an error");
assert!(
task.get(CREDENTIAL_FIELD).is_none(),
"no credential field at all"
);
assert!(
!serde_json::to_string(&task)
.unwrap()
.contains("INBOUND-SECRET-BYTES"),
"no token bytes anywhere in the task"
);
}
#[test]
fn two_plugins_on_one_hook_are_gated_independently() {
let extensions = extensions_with_credentials();
let mut declaring = task();
attach_credential(
&mut declaring,
"identity.resolve",
&extensions,
&caps(&[CAP_READ_INBOUND]),
)
.unwrap();
let mut non_declaring = task();
attach_credential(
&mut non_declaring,
"identity.resolve",
&extensions,
&caps(&[]),
)
.unwrap();
assert!(
serde_json::to_string(&declaring)
.unwrap()
.contains(INBOUND_SECRET)
);
assert!(
!serde_json::to_string(&non_declaring)
.unwrap()
.contains("INBOUND-SECRET-BYTES")
);
}
#[test]
fn the_wrong_capability_does_not_unlock_a_hook() {
let extensions = extensions_with_credentials();
let mut identity = task();
attach_credential(
&mut identity,
"identity.resolve",
&extensions,
&caps(&[CAP_READ_DELEGATED]),
)
.unwrap();
assert!(identity.get(CREDENTIAL_FIELD).is_none());
let mut delegate = task();
attach_credential(
&mut delegate,
"token.delegate",
&extensions,
&caps(&[CAP_READ_INBOUND]),
)
.unwrap();
assert!(delegate.get(CREDENTIAL_FIELD).is_none());
}
#[test]
fn a_declaring_plugin_receives_the_delegated_token() {
let mut task = task();
attach_credential(
&mut task,
"token.delegate",
&extensions_with_credentials(),
&caps(&[CAP_READ_DELEGATED]),
)
.expect("a declared capability is honored");
let delegated = &task[CREDENTIAL_FIELD]["delegated"];
assert_eq!(delegated["token"], DELEGATED_SECRET);
assert_eq!(delegated["outbound_header"], "Authorization");
assert_eq!(delegated["audience"], "https://billing.example.com");
assert_eq!(delegated["scopes"][0], "read");
assert!(task[CREDENTIAL_FIELD].get("inbound").is_none());
}
#[test]
fn non_credential_hooks_never_receive_a_credential() {
for hook in [
"tool_pre_invoke",
"tool_post_invoke",
"prompt_pre_fetch",
"resource_post_fetch",
"cmf.tool_pre_invoke",
"cmf.prompt_pre_invoke",
"http.request",
"some_custom_hook",
"identity_resolve",
"token_delegate",
] {
let mut task = task();
attach_credential(
&mut task,
hook,
&extensions_with_credentials(),
&caps(&[CAP_READ_INBOUND, CAP_READ_DELEGATED]),
)
.unwrap();
assert!(
task.get(CREDENTIAL_FIELD).is_none(),
"hook '{hook}' must not receive credentials"
);
}
}
#[test]
fn a_declared_capability_with_no_extension_errors() {
let err = attach_credential(
&mut task(),
"identity.resolve",
&Extensions::default(),
&caps(&[CAP_READ_INBOUND]),
)
.expect_err("a declared capability that cannot be honored must error");
assert!(matches!(err, HostError::Credential { .. }), "got {err:?}");
}
#[test]
fn a_declared_capability_with_no_matching_token_errors() {
let mut raw = RawCredentialsExtension::default();
raw.delegated_tokens.insert(
DelegationKey::new(DelegationMode::AsThisWorkload, "aud", vec![])
.with_subject_id("alice"),
RawDelegatedToken::new("t", "Authorization", "aud", vec![], chrono::Utc::now()),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let err = attach_credential(
&mut task(),
"identity.resolve",
&extensions,
&caps(&[CAP_READ_INBOUND]),
)
.expect_err("no inbound token means the capability cannot be honored");
assert!(matches!(err, HostError::Credential { .. }));
}
#[test]
fn an_empty_token_is_rejected_rather_than_sent() {
for token in ["", " ", "\t\n"] {
let mut raw = RawCredentialsExtension::default();
raw.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new(token, "Authorization", TokenKind::Jwt),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let err = attach_credential(
&mut task(),
"identity.resolve",
&extensions,
&caps(&[CAP_READ_INBOUND]),
)
.expect_err("an empty or whitespace-only token must be refused");
assert!(matches!(err, HostError::Credential { .. }));
}
}
#[test]
fn an_empty_delegated_token_is_rejected() {
let mut raw = RawCredentialsExtension::default();
raw.delegated_tokens.insert(
DelegationKey::new(DelegationMode::OnBehalfOfUser, "aud", vec![])
.with_subject_id("alice"),
RawDelegatedToken::new("", "Authorization", "aud", vec![], chrono::Utc::now()),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let err = attach_credential(
&mut task(),
"token.delegate",
&extensions,
&caps(&[CAP_READ_DELEGATED]),
)
.expect_err("an empty delegated token must be refused");
assert!(matches!(err, HostError::Credential { .. }));
}
#[test]
fn a_fail_closed_error_never_names_the_token() {
let mut raw = RawCredentialsExtension::default();
raw.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new(" ", "Authorization", TokenKind::Jwt),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let err = attach_credential(
&mut task(),
"identity.resolve",
&extensions,
&caps(&[CAP_READ_INBOUND]),
)
.expect_err("errors");
let message = err.to_string();
assert!(
message.contains("identity.resolve"),
"the message should name the hook: {message}"
);
assert!(message.contains("empty"));
}
#[test]
fn production_credential_types_still_refuse_to_serialize_tokens() {
let inbound = RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt);
let json = serde_json::to_string(&inbound).unwrap();
assert!(
!json.contains("INBOUND-SECRET-BYTES"),
"the serde guard regressed: {json}"
);
let delegated = RawDelegatedToken::new(
DELEGATED_SECRET,
"Authorization",
"aud",
vec![],
chrono::Utc::now(),
);
let json = serde_json::to_string(&delegated).unwrap();
assert!(
!json.contains("MINTED-DELEGATED"),
"the serde guard regressed: {json}"
);
let extensions = extensions_with_credentials();
let raw = extensions.raw_credentials.as_ref().unwrap();
let json = serde_json::to_string(&raw.inbound_tokens).unwrap();
assert!(
!json.contains("INBOUND-SECRET-BYTES"),
"the serde guard regressed: {json}"
);
for token in raw.delegated_tokens.values() {
let json = serde_json::to_string(token).unwrap();
assert!(
!json.contains("MINTED-DELEGATED"),
"the serde guard regressed: {json}"
);
}
}
#[test]
fn a_serialize_then_reparse_would_have_yielded_an_empty_token() {
let inbound = RawInboundToken::new(INBOUND_SECRET, "Authorization", TokenKind::Jwt);
let round_tripped: RawInboundToken =
serde_json::from_str(&serde_json::to_string(&inbound).unwrap()).unwrap();
assert_eq!(
&*round_tripped.token, "",
"the guard strips the bytes on the way out"
);
assert_eq!(round_tripped.source_header, "Authorization");
}
#[test]
fn the_dto_debug_output_hides_the_token() {
let dto = CredentialDto {
inbound: Some(InboundCredential {
token: INBOUND_SECRET.into(),
source_header: "Authorization".into(),
kind: "jwt".into(),
headers: Some(HashMap::from([(
"Authorization".into(),
format!("Bearer {INBOUND_SECRET}"),
)])),
}),
delegated: None,
};
let outer = format!("{dto:?}");
let inner = format!("{:?}", dto.inbound.as_ref().unwrap());
for debug in [&outer, &inner] {
assert!(
!debug.contains("INBOUND-SECRET-BYTES"),
"token leaked into Debug: {debug}"
);
}
assert!(inner.contains("Authorization"));
assert!(inner.contains("jwt"));
}
#[test]
fn the_delegated_dto_debug_output_hides_the_token() {
let credential = DelegatedCredential {
token: DELEGATED_SECRET.into(),
outbound_header: "Authorization".into(),
audience: "https://billing.example.com".into(),
scopes: vec!["read".into()],
};
let debug = format!("{credential:?}");
assert!(
!debug.contains("MINTED-DELEGATED"),
"token leaked into Debug: {debug}"
);
assert!(debug.contains("billing.example.com"));
assert!(debug.contains("read"));
}
#[test]
fn token_kinds_use_the_wire_names_the_worker_matches_on() {
assert_eq!(token_kind_wire_name(&TokenKind::Jwt), "jwt");
assert_eq!(token_kind_wire_name(&TokenKind::Opaque), "opaque");
assert_eq!(token_kind_wire_name(&TokenKind::SpiffeJwt), "spiffe_jwt");
assert_eq!(token_kind_wire_name(&TokenKind::Ucan), "ucan");
assert_eq!(token_kind_wire_name(&TokenKind::TxnToken), "txn_token");
}
#[test]
fn wire_names_match_the_serde_representation() {
for kind in [
TokenKind::Jwt,
TokenKind::Opaque,
TokenKind::SpiffeJwt,
TokenKind::Ucan,
TokenKind::TxnToken,
] {
let via_serde = serde_json::to_value(&kind).unwrap();
assert_eq!(
via_serde.as_str().unwrap(),
token_kind_wire_name(&kind),
"hand-written wire name disagrees with serde for {kind:?}"
);
}
}
#[test]
fn the_user_token_is_preferred_over_the_client_token() {
let mut raw = RawCredentialsExtension::default();
raw.inbound_tokens.insert(
TokenRole::Client,
RawInboundToken::new("CLIENT-TOKEN", "X-Client", TokenKind::Opaque),
);
raw.inbound_tokens.insert(
TokenRole::User,
RawInboundToken::new("USER-TOKEN", "Authorization", TokenKind::Jwt),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let mut task = task();
attach_credential(
&mut task,
"identity.resolve",
&extensions,
&caps(&[CAP_READ_INBOUND]),
)
.unwrap();
assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], "USER-TOKEN");
}
#[test]
fn headers_are_synthesized_from_the_source_header() {
let mut task = task();
attach_credential(
&mut task,
"identity.resolve",
&extensions_with_credentials(),
&caps(&[CAP_READ_INBOUND]),
)
.unwrap();
assert_eq!(
task[CREDENTIAL_FIELD]["inbound"]["headers"]["Authorization"],
INBOUND_SECRET
);
}
#[test]
fn an_on_behalf_of_user_token_is_preferred_over_a_gateway_token() {
let mut raw = RawCredentialsExtension::default();
raw.delegated_tokens.insert(
DelegationKey::new(DelegationMode::AsThisWorkload, "aud", vec![]).with_subject_id("gw"),
RawDelegatedToken::new(
"GATEWAY-TOKEN",
"Authorization",
"aud",
vec![],
chrono::Utc::now(),
),
);
raw.delegated_tokens.insert(
DelegationKey::new(DelegationMode::OnBehalfOfUser, "aud", vec![])
.with_subject_id("alice"),
RawDelegatedToken::new(
"USER-DELEGATED-TOKEN",
"Authorization",
"aud",
vec![],
chrono::Utc::now(),
),
);
let extensions = Extensions {
raw_credentials: Some(Arc::new(raw)),
..Default::default()
};
let mut task = task();
attach_credential(
&mut task,
"token.delegate",
&extensions,
&caps(&[CAP_READ_DELEGATED]),
)
.unwrap();
assert_eq!(
task[CREDENTIAL_FIELD]["delegated"]["token"], "USER-DELEGATED-TOKEN",
"a delegator acting for the user must not fall back to the gateway's identity"
);
}
#[test]
fn the_delegated_pick_matches_the_requested_audience() {
let extensions = extensions_from(vec![
delegated(
"alice",
"https://billing.example.com",
DelegationMode::OnBehalfOfUser,
"BILLING-TOKEN",
),
delegated(
"alice",
"https://search.example.com",
DelegationMode::OnBehalfOfUser,
"SEARCH-TOKEN",
),
delegated(
"alice",
"https://payroll.example.com",
DelegationMode::OnBehalfOfUser,
"PAYROLL-TOKEN",
),
]);
assert_eq!(
pick_delegated(&extensions, "https://search.example.com").unwrap(),
"SEARCH-TOKEN",
"a token minted for another audience must never be substituted"
);
assert_eq!(
pick_delegated(&extensions, "https://billing.example.com").unwrap(),
"BILLING-TOKEN"
);
}
#[test]
fn the_delegated_pick_is_stable_across_runs_and_insertion_orders() {
let forward = vec![
delegated("alice", "aud-a", DelegationMode::OnBehalfOfUser, "A"),
delegated("bob", "aud-a", DelegationMode::OnBehalfOfUser, "B"),
delegated("carol", "aud-a", DelegationMode::OnBehalfOfUser, "C"),
delegated("dave", "aud-a", DelegationMode::OnBehalfOfUser, "D"),
];
let reversed: Vec<_> = forward.iter().rev().cloned().collect();
const EXPECTED: &str = "A";
for _ in 0..200 {
let one = extensions_from(forward.clone());
let other = extensions_from(reversed.clone());
assert_eq!(pick_delegated(&one, "aud-a").unwrap(), EXPECTED);
assert_eq!(
pick_delegated(&other, "aud-a").unwrap(),
EXPECTED,
"insertion order must not change the pick"
);
}
}
#[test]
fn no_token_for_the_requested_audience_fails_closed() {
let extensions = extensions_from(vec![delegated(
"alice",
"https://billing.example.com",
DelegationMode::OnBehalfOfUser,
"BILLING-TOKEN",
)]);
let err = pick_delegated(&extensions, "https://attacker.example.com")
.expect_err("no matching audience means no token");
let message = err.to_string();
assert!(matches!(err, HostError::Credential { .. }), "{message}");
assert!(
message.contains("attacker.example.com"),
"the error should name the audience that had no token: {message}"
);
assert!(
!message.contains("BILLING-TOKEN"),
"the error must not name the token it declined to substitute: {message}"
);
}
#[test]
fn the_audience_filter_is_applied_before_the_mode_preference() {
let extensions = extensions_from(vec![
delegated(
"alice",
"https://other.example.com",
DelegationMode::OnBehalfOfUser,
"OTHER-AUDIENCE-USER-TOKEN",
),
delegated(
"gw",
"https://billing.example.com",
DelegationMode::AsThisWorkload,
"BILLING-GATEWAY-TOKEN",
),
]);
assert_eq!(
pick_delegated(&extensions, "https://billing.example.com").unwrap(),
"BILLING-GATEWAY-TOKEN"
);
}
#[test]
fn within_one_audience_the_user_token_still_wins() {
let extensions = extensions_from(vec![
delegated(
"aaa-gw",
"aud",
DelegationMode::AsThisWorkload,
"GATEWAY-TOKEN",
),
delegated(
"zzz-user",
"aud",
DelegationMode::OnBehalfOfUser,
"USER-TOKEN",
),
]);
assert_eq!(pick_delegated(&extensions, "aud").unwrap(), "USER-TOKEN");
}
#[test]
fn a_key_whose_audience_disagrees_with_its_token_is_not_a_match() {
let (key, _) = delegated("alice", "aud-key", DelegationMode::OnBehalfOfUser, "T");
let token = RawDelegatedToken::new(
"MISMATCHED-TOKEN",
"Authorization",
"aud-token",
vec![],
chrono::Utc::now(),
);
let extensions = extensions_from(vec![(key, token)]);
for audience in ["aud-key", "aud-token"] {
assert!(
pick_delegated(&extensions, audience).is_err(),
"a self-inconsistent token must not satisfy '{audience}'"
);
}
}
#[test]
fn an_unscoped_delegation_accepts_any_audience() {
let tokens = vec![
delegated("alice", "aud-a", DelegationMode::OnBehalfOfUser, "A"),
delegated("bob", "aud-b", DelegationMode::OnBehalfOfUser, "B"),
];
for _ in 0..50 {
let mut task = serde_json::json!({
"task_type": "load_and_run_hook",
"payload": { "target_name": "billing", "target_type": "tool" },
});
attach_credential(
&mut task,
"token.delegate",
&extensions_from(tokens.clone()),
&caps(&[CAP_READ_DELEGATED]),
)
.expect("an unscoped delegation is still served");
assert_eq!(task[CREDENTIAL_FIELD]["delegated"]["token"], "A");
}
}
#[test]
fn the_mode_rank_puts_the_user_first_and_unknown_modes_last() {
assert!(
mode_rank(&DelegationMode::OnBehalfOfUser) < mode_rank(&DelegationMode::AsThisWorkload)
);
}
#[test]
fn the_requested_audience_is_read_from_the_payload() {
assert_eq!(
requested_audience(&delegate_task("https://billing.example.com")).as_deref(),
Some("https://billing.example.com")
);
assert_eq!(requested_audience(&task()), None);
assert_eq!(
requested_audience(&serde_json::json!({ "payload": { "target_audience": 7 } })),
None
);
assert_eq!(
requested_audience(&serde_json::json!({ "payload": {} })),
None
);
}
#[test]
fn the_credential_field_name_matches_the_worker_contract() {
assert_eq!(
CREDENTIAL_FIELD, "credential",
"the worker reads task_data['credential']"
);
}
use praxis_policy_core::delegation::DelegationPayload;
use praxis_policy_core::identity::{IdentityPayload, TokenSource};
fn identity_payload() -> IdentityPayload {
IdentityPayload::new(INBOUND_SECRET, TokenSource::Bearer).with_headers(HashMap::from([
("authorization".into(), format!("Bearer {INBOUND_SECRET}")),
("cookie".into(), "session=abc".into()),
("x-tenant".into(), "acme".into()),
]))
}
#[test]
fn an_identity_payload_supplies_its_own_token_to_a_declaring_plugin() {
let mut task = task();
attach_credential_for_payload(
&mut task,
"identity.resolve",
&identity_payload(),
&Extensions::default(),
&caps(&[CAP_READ_INBOUND]),
)
.expect("the payload's token honors the capability");
let inbound = &task[CREDENTIAL_FIELD]["inbound"];
assert_eq!(inbound["token"], INBOUND_SECRET);
assert_eq!(inbound["source_header"], "authorization");
assert_eq!(inbound["kind"], "opaque");
assert_eq!(inbound["headers"]["cookie"], "session=abc");
assert_eq!(inbound["headers"]["x-tenant"], "acme");
}
#[test]
fn a_header_borne_identity_credential_is_found() {
let payload: IdentityPayload = serde_json::from_value(serde_json::json!({
"source": "bearer",
"source_header": "X-User-Token",
"headers": {"x-user-token": INBOUND_SECRET},
}))
.unwrap();
let mut task = task();
attach_credential_for_payload(
&mut task,
"identity.resolve",
&payload,
&Extensions::default(),
&caps(&[CAP_READ_INBOUND]),
)
.unwrap();
assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], INBOUND_SECRET);
assert_eq!(
task[CREDENTIAL_FIELD]["inbound"]["source_header"],
"x-user-token"
);
}
#[test]
fn an_identity_payload_gives_a_non_declaring_plugin_nothing() {
let mut task = task();
attach_credential_for_payload(
&mut task,
"identity.resolve",
&identity_payload(),
&extensions_with_credentials(),
&caps(&[CAP_READ_DELEGATED]),
)
.unwrap();
assert!(task.get(CREDENTIAL_FIELD).is_none());
}
#[test]
fn an_identity_payload_without_a_credential_fails_closed() {
let payload = IdentityPayload::new("", TokenSource::Bearer);
let mut task = task();
let err = attach_credential_for_payload(
&mut task,
"identity.resolve",
&payload,
&extensions_with_credentials(),
&caps(&[CAP_READ_INBOUND]),
)
.unwrap_err();
assert!(matches!(err, HostError::Credential { .. }), "{err:?}");
assert!(task.get(CREDENTIAL_FIELD).is_none());
}
#[test]
fn a_delegation_payload_supplies_its_bearer_token_to_an_inbound_declaring_plugin() {
let payload = DelegationPayload::new(INBOUND_SECRET, "billing")
.with_target_audience("https://billing.example.com")
.with_required_permissions(vec!["read".into()]);
let mut gated = task();
attach_credential_for_payload(
&mut gated,
"token.delegate",
&payload,
&Extensions::default(),
&caps(&[CAP_READ_INBOUND]),
)
.unwrap();
let delegated = &gated[CREDENTIAL_FIELD]["delegated"];
assert_eq!(delegated["token"], INBOUND_SECRET);
assert_eq!(delegated["audience"], "https://billing.example.com");
assert_eq!(delegated["scopes"], serde_json::json!(["read"]));
let mut task = task();
attach_credential_for_payload(
&mut task,
"token.delegate",
&payload,
&Extensions::default(),
&caps(&[CAP_READ_DELEGATED]),
)
.unwrap();
assert!(task.get(CREDENTIAL_FIELD).is_none());
}
#[test]
fn a_delegation_payload_without_a_bearer_token_fails_closed() {
let payload = DelegationPayload::new(" ", "billing");
let mut task = task();
let err = attach_credential_for_payload(
&mut task,
"token.delegate",
&payload,
&extensions_with_credentials(),
&caps(&[CAP_READ_INBOUND]),
)
.unwrap_err();
assert!(matches!(err, HostError::Credential { .. }), "{err:?}");
}
#[test]
fn a_legacy_payload_still_reads_the_extensions() {
let mut task = task();
attach_credential_for_payload(
&mut task,
"identity.resolve",
&crate::legacy::IdentityResolvePayload::default(),
&extensions_with_credentials(),
&caps(&[CAP_READ_INBOUND]),
)
.unwrap();
assert_eq!(task[CREDENTIAL_FIELD]["inbound"]["token"], INBOUND_SECRET);
}
}