agentguard_auth/
spiffe.rs1use crate::error::{AuthError, Result};
9use serde::{Deserialize, Serialize};
10use std::time::Duration;
11
12#[cfg(feature = "spiffe")]
13const WORKLOAD_API_TIMEOUT: Duration = Duration::from_secs(5);
14
15#[derive(Debug, Clone)]
17pub struct SpiffeValidator {
18 pub allowed_trust_domains: Vec<String>,
19 pub clock_skew: Duration,
20 pub workload_endpoint: String,
23}
24
25impl SpiffeValidator {
26 pub fn new(trust_domain: impl Into<String>) -> Self {
27 Self {
28 allowed_trust_domains: vec![trust_domain.into()],
29 clock_skew: Duration::from_secs(60),
30 workload_endpoint: "unix:///run/spire/sockets/agent.sock".into(),
31 }
32 }
33
34 pub fn allow_trust_domain(&mut self, domain: impl Into<String>) -> &mut Self {
36 self.allowed_trust_domains.push(domain.into());
37 self
38 }
39
40 pub fn validate_spiffe_id(&self, id: &str) -> Result<()> {
43 let rest = id.strip_prefix("spiffe://").ok_or_else(|| {
44 AuthError::SpiffeFetch(format!("invalid SPIFFE ID (missing scheme): {}", id))
45 })?;
46 let domain = rest.split('/').next().unwrap_or("");
47 if !self.allowed_trust_domains.iter().any(|d| d == domain) {
48 return Err(AuthError::SpiffeFetch(format!(
49 "trust domain {} not in allowlist",
50 domain
51 )));
52 }
53 Ok(())
54 }
55
56 #[cfg(feature = "spiffe")]
62 pub async fn fetch_svid(&self) -> Result<SpiffeId> {
63 use rust_spiffe::workload_api::client::WorkloadApiClient;
64 let x509_svid = tokio::time::timeout(WORKLOAD_API_TIMEOUT, async {
67 let client = WorkloadApiClient::connect_to(&self.workload_endpoint)
68 .await
69 .map_err(|e| AuthError::SpiffeFetch(format!("workload api connect: {e}")))?;
70 client
71 .fetch_x509_svid()
72 .await
73 .map_err(|e| AuthError::SpiffeFetch(format!("fetch x509-svid: {e}")))
74 })
75 .await
76 .map_err(|_| {
77 AuthError::SpiffeFetch(format!(
78 "timed out fetching SVID from SPIFFE Workload API at {}",
79 self.workload_endpoint
80 ))
81 })??;
82 let (_, certificate) = x509_parser::parse_x509_certificate(x509_svid.leaf().as_bytes())
86 .map_err(|e| AuthError::SpiffeFetch(format!("parse SVID certificate: {}", e)))?;
87 let now = chrono::Utc::now();
88 let skew =
89 chrono::Duration::from_std(self.clock_skew).unwrap_or(chrono::Duration::seconds(0));
90 let not_after =
91 chrono::DateTime::from_timestamp(certificate.validity().not_after.timestamp(), 0)
92 .ok_or_else(|| AuthError::SpiffeFetch("invalid SVID not-after timestamp".into()))?;
93 if not_after + skew < now {
94 return Err(AuthError::SpiffeFetch(format!(
95 "SVID expired at {}",
96 not_after.to_rfc3339()
97 )));
98 }
99 let not_before =
100 chrono::DateTime::from_timestamp(certificate.validity().not_before.timestamp(), 0)
101 .ok_or_else(|| {
102 AuthError::SpiffeFetch("invalid SVID not-before timestamp".into())
103 })?;
104 if not_before > now + skew {
105 return Err(AuthError::SpiffeFetch(format!(
106 "SVID not valid until {}",
107 not_before.to_rfc3339()
108 )));
109 }
110 let spiffe_id = x509_svid.spiffe_id().to_string();
111 self.validate_spiffe_id(&spiffe_id)?;
112 Ok(SpiffeId { id: spiffe_id })
113 }
114
115 #[cfg(not(feature = "spiffe"))]
118 pub async fn fetch_svid(&self) -> Result<SpiffeId> {
119 Err(AuthError::SpiffeFetch(
120 "SPIFFE Workload API integration requires the 'spiffe' feature on agentguard-auth"
121 .into(),
122 ))
123 }
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct SpiffeId {
129 pub id: String,
131}
132
133#[cfg(test)]
134mod tests {
135 use super::*;
136
137 #[test]
138 fn valid_spiffe_id_passes() {
139 let v = SpiffeValidator::new("acme.com");
140 v.validate_spiffe_id("spiffe://acme.com/agent/email-bot")
141 .unwrap();
142 }
143
144 #[test]
145 fn wrong_trust_domain_rejected() {
146 let v = SpiffeValidator::new("acme.com");
147 assert!(v.validate_spiffe_id("spiffe://evil.com/agent").is_err());
148 }
149
150 #[test]
151 fn missing_scheme_rejected() {
152 let v = SpiffeValidator::new("acme.com");
153 assert!(v.validate_spiffe_id("acme.com/agent").is_err());
154 }
155
156 #[test]
157 fn multiple_trust_domains() {
158 let mut v = SpiffeValidator::new("acme.com");
159 v.allow_trust_domain("partner.com");
160 v.validate_spiffe_id("spiffe://acme.com/x").unwrap();
161 v.validate_spiffe_id("spiffe://partner.com/y").unwrap();
162 assert!(v.validate_spiffe_id("spiffe://other.com/z").is_err());
163 }
164
165 #[cfg(feature = "spiffe")]
166 #[tokio::test]
167 async fn fetch_svid_fails_cleanly_when_workload_socket_is_missing() {
168 let dir = tempfile::tempdir().unwrap();
169 let socket = dir.path().join("missing.sock");
170 let mut validator = SpiffeValidator::new("acme.com");
171 validator.workload_endpoint = format!("unix://{}", socket.display());
172
173 let error = validator.fetch_svid().await.unwrap_err();
174 assert!(error.to_string().contains("workload api connect"));
175 }
176}