use std::collections::HashMap;
use std::sync::Arc;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use zeroize::Zeroizing;
use crate::executor::PipelineResult;
use crate::extensions::raw_credentials::DelegationMode;
use crate::extensions::{
DelegationExtension, Extensions, RawCredentialsExtension, RawDelegatedToken,
};
use crate::impl_plugin_payload;
#[non_exhaustive]
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum TargetType {
Tool,
Agent,
Resource,
Service,
#[serde(untagged)]
Custom(String),
}
impl Default for TargetType {
fn default() -> Self {
TargetType::Tool
}
}
#[non_exhaustive]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthEnforcedBy {
Caller,
Target,
Both,
}
impl Default for AuthEnforcedBy {
fn default() -> Self {
AuthEnforcedBy::Caller
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct AttenuationConfig {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub capabilities: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub resource_template: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub actions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub ttl_seconds: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DelegationPayload {
#[serde(skip)]
bearer_token: Zeroizing<String>,
target_name: String,
#[serde(default)]
target_type: TargetType,
#[serde(default, skip_serializing_if = "Option::is_none")]
target_audience: Option<String>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
required_permissions: Vec<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
trust_domain: Option<String>,
#[serde(default)]
auth_enforced_by: AuthEnforcedBy,
#[serde(default, skip_serializing_if = "Option::is_none")]
route_attenuation: Option<AttenuationConfig>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delegated_token: Option<RawDelegatedToken>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delegation_update: Option<DelegationExtension>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub delegation_mode: Option<DelegationMode>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub minted_at: Option<DateTime<Utc>>,
#[serde(default, skip_serializing_if = "HashMap::is_empty")]
pub metadata: HashMap<String, serde_json::Value>,
}
impl DelegationPayload {
pub fn new(bearer_token: impl Into<String>, target_name: impl Into<String>) -> Self {
Self {
bearer_token: Zeroizing::new(bearer_token.into()),
target_name: target_name.into(),
target_type: TargetType::Tool,
target_audience: None,
required_permissions: Vec::new(),
trust_domain: None,
auth_enforced_by: AuthEnforcedBy::Caller,
route_attenuation: None,
delegated_token: None,
delegation_update: None,
delegation_mode: None,
minted_at: None,
metadata: HashMap::new(),
}
}
pub fn with_target_type(mut self, t: TargetType) -> Self {
self.target_type = t;
self
}
pub fn with_target_audience(mut self, aud: impl Into<String>) -> Self {
self.target_audience = Some(aud.into());
self
}
pub fn with_required_permissions(mut self, perms: Vec<String>) -> Self {
self.required_permissions = perms;
self
}
pub fn with_trust_domain(mut self, td: impl Into<String>) -> Self {
self.trust_domain = Some(td.into());
self
}
pub fn with_auth_enforced_by(mut self, who: AuthEnforcedBy) -> Self {
self.auth_enforced_by = who;
self
}
pub fn with_route_attenuation(mut self, cfg: AttenuationConfig) -> Self {
self.route_attenuation = Some(cfg);
self
}
pub fn bearer_token(&self) -> &str {
&self.bearer_token
}
pub fn target_name(&self) -> &str {
&self.target_name
}
pub fn target_type(&self) -> &TargetType {
&self.target_type
}
pub fn target_audience(&self) -> Option<&str> {
self.target_audience.as_deref()
}
pub fn required_permissions(&self) -> &[String] {
&self.required_permissions
}
pub fn trust_domain(&self) -> Option<&str> {
self.trust_domain.as_deref()
}
pub fn auth_enforced_by(&self) -> AuthEnforcedBy {
self.auth_enforced_by
}
pub fn route_attenuation(&self) -> Option<&AttenuationConfig> {
self.route_attenuation.as_ref()
}
pub fn merge(&mut self, other: DelegationPayload) {
if other.delegated_token.is_some() {
self.delegated_token = other.delegated_token;
}
if other.delegation_update.is_some() {
self.delegation_update = other.delegation_update;
}
if other.delegation_mode.is_some() {
self.delegation_mode = other.delegation_mode;
}
if other.minted_at.is_some() {
self.minted_at = other.minted_at;
}
for (k, v) in other.metadata {
self.metadata.insert(k, v);
}
}
pub fn from_pipeline_result(result: &PipelineResult) -> Option<Self> {
result
.modified_payload
.as_ref()
.and_then(|p| p.as_any().downcast_ref::<DelegationPayload>())
.cloned()
}
pub fn apply_to_extensions(&self, mut ext: Extensions) -> Extensions {
if let Some(ref token) = self.delegated_token {
use crate::extensions::raw_credentials::DelegationKey;
let subject_id = ext
.security
.as_ref()
.and_then(|s| s.subject.as_ref())
.and_then(|s| s.id.clone())
.unwrap_or_default();
let mode = self
.delegation_mode
.clone()
.unwrap_or(DelegationMode::OnBehalfOfUser);
let key = DelegationKey {
subject_id,
audience: token.audience.clone(),
scopes: token.scopes.clone(),
mode,
};
let mut raw = ext
.raw_credentials
.as_ref()
.map(|arc| (**arc).clone())
.unwrap_or_else(RawCredentialsExtension::default);
raw.delegated_tokens.insert(key, token.clone());
ext.raw_credentials = Some(Arc::new(raw));
}
if let Some(ref update) = self.delegation_update {
ext.delegation = Some(Arc::new(update.clone()));
}
ext
}
}
impl_plugin_payload!(DelegationPayload);
#[cfg(test)]
mod tests {
use super::*;
use crate::extensions::raw_credentials::RawDelegatedToken;
#[test]
fn bearer_token_does_not_serialize() {
let p = DelegationPayload::new("eyJ.caller.tok", "get_compensation");
let json = serde_json::to_string(&p).unwrap();
assert!(
!json.contains("eyJ.caller.tok"),
"bearer_token leaked into serialized form: {}",
json,
);
assert!(json.contains("get_compensation"));
}
#[test]
fn deserialize_yields_empty_bearer_token() {
let json = r#"{"target_name":"get_compensation"}"#;
let p: DelegationPayload = serde_json::from_str(json).unwrap();
assert_eq!(p.bearer_token(), "");
assert_eq!(p.target_name(), "get_compensation");
}
#[test]
fn input_builders_chain() {
let p = DelegationPayload::new("tok", "get_compensation")
.with_target_type(TargetType::Tool)
.with_target_audience("https://hr.example.com")
.with_required_permissions(vec!["read:compensation".into()])
.with_trust_domain("hr.example.com")
.with_auth_enforced_by(AuthEnforcedBy::Target)
.with_route_attenuation(AttenuationConfig {
capabilities: vec!["read:compensation".into()],
resource_template: Some("hr://employees/{{ args.employee_id }}".into()),
actions: vec!["read".into()],
ttl_seconds: Some(60),
});
assert_eq!(p.bearer_token(), "tok");
assert_eq!(p.target_name(), "get_compensation");
assert_eq!(p.target_audience(), Some("https://hr.example.com"));
assert_eq!(p.required_permissions(), &["read:compensation".to_string()]);
assert_eq!(p.trust_domain(), Some("hr.example.com"));
assert_eq!(p.auth_enforced_by(), AuthEnforcedBy::Target);
let att = p.route_attenuation().unwrap();
assert_eq!(att.ttl_seconds, Some(60));
assert_eq!(att.actions, vec!["read"]);
}
#[test]
fn target_type_custom_round_trips() {
let t = TargetType::Custom("workflow".into());
let json = serde_json::to_string(&t).unwrap();
let back: TargetType = serde_json::from_str(&json).unwrap();
assert_eq!(t, back);
}
#[test]
fn handler_can_populate_output_on_clone() {
let original = DelegationPayload::new("caller-tok", "downstream-tool");
let mut updated = original.clone();
updated.delegated_token = Some(RawDelegatedToken::new(
"minted-bytes",
"Authorization",
"https://api.example.com",
vec!["read".into()],
Utc::now(),
));
assert_eq!(updated.bearer_token(), "caller-tok");
assert_eq!(updated.target_name(), "downstream-tool");
assert!(updated.delegated_token.is_some());
assert!(original.delegated_token.is_none());
}
#[test]
fn merge_overlays_outputs() {
let mut base = DelegationPayload::new("tok", "tool");
base.metadata.insert("attempt".into(), serde_json::json!(1));
let mut overlay = DelegationPayload::new("", "");
overlay.delegated_token = Some(RawDelegatedToken::new(
"x",
"Authorization",
"aud",
vec![],
Utc::now(),
));
overlay
.metadata
.insert("latency_ms".into(), serde_json::json!(42));
base.merge(overlay);
assert!(base.delegated_token.is_some());
assert!(base.metadata.contains_key("attempt"));
assert!(base.metadata.contains_key("latency_ms"));
}
#[test]
fn apply_to_extensions_writes_delegated_token_keyed_by_audience() {
use crate::extensions::raw_credentials::DelegationMode;
use crate::extensions::SubjectExtension;
let mut p = DelegationPayload::new("tok", "get_compensation");
p.delegated_token = Some(RawDelegatedToken::new(
"minted-jwt",
"Authorization",
"https://hr.example.com",
vec!["read:compensation".into()],
Utc::now() + chrono::Duration::seconds(300),
));
let initial_ext = Extensions {
security: Some(Arc::new(crate::extensions::SecurityExtension {
subject: Some(SubjectExtension {
id: Some("alice".into()),
..Default::default()
}),
..Default::default()
})),
..Default::default()
};
let updated = p.apply_to_extensions(initial_ext);
let raw = updated.raw_credentials.as_ref().unwrap();
assert_eq!(raw.delegated_tokens.len(), 1);
let expected_key = crate::extensions::raw_credentials::DelegationKey {
subject_id: "alice".into(),
audience: "https://hr.example.com".into(),
scopes: vec!["read:compensation".into()],
mode: DelegationMode::OnBehalfOfUser,
};
assert!(raw.delegated_tokens.contains_key(&expected_key));
}
#[test]
fn apply_to_extensions_respects_explicit_delegation_mode() {
let mut p = DelegationPayload::new("tok", "tool");
p.delegated_token = Some(RawDelegatedToken::new(
"gateway-token",
"Authorization",
"https://downstream.example.com",
vec!["service:call".into()],
Utc::now(),
));
p.delegation_mode = Some(crate::extensions::raw_credentials::DelegationMode::AsGateway);
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
));
}
#[test]
fn apply_to_extensions_defaults_delegation_mode_when_unset() {
let mut p = DelegationPayload::new("tok", "tool");
p.delegated_token = Some(RawDelegatedToken::new(
"user-token",
"Authorization",
"https://aud.example.com",
vec!["read".into()],
Utc::now(),
));
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::OnBehalfOfUser
));
}
#[test]
fn merge_threads_delegation_mode_through_chain() {
let mut base = DelegationPayload::new("tok", "tool");
let mut overlay = DelegationPayload::new("", "");
overlay.delegation_mode =
Some(crate::extensions::raw_credentials::DelegationMode::AsGateway);
base.merge(overlay);
assert!(matches!(
base.delegation_mode,
Some(crate::extensions::raw_credentials::DelegationMode::AsGateway)
));
}
#[test]
fn apply_to_extensions_falls_back_to_empty_subject_id_when_no_subject() {
let mut p = DelegationPayload::new("tok", "tool");
p.delegated_token = Some(RawDelegatedToken::new(
"minted",
"Authorization",
"aud",
vec![],
Utc::now(),
));
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_eq!(key.subject_id, "");
}
#[test]
fn auth_enforced_by_defaults_to_caller() {
let p = DelegationPayload::new("tok", "tool");
assert_eq!(p.auth_enforced_by(), AuthEnforcedBy::Caller);
}
#[test]
fn target_type_defaults_to_tool() {
let p = DelegationPayload::new("tok", "tool");
assert_eq!(p.target_type(), &TargetType::Tool);
}
}