agentguard_auth/
spiffe.rs

1//! SPIFFE/SPIRE X509-SVID verifier.
2//!
3//! Validates SPIFFE IDs against an allowlist of trust domains. To fetch
4//! SVIDs from a running SPIRE agent, use [`SpiffeValidator::fetch_svid`]
5//! which calls the SPIFFE Workload API over the abstract Unix domain
6//! socket or HTTPS endpoint configured by the workload.
7
8use 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/// Trust domain allowlist and optional workload endpoint.
16#[derive(Debug, Clone)]
17pub struct SpiffeValidator {
18    pub allowed_trust_domains: Vec<String>,
19    pub clock_skew: Duration,
20    /// Path or URL of the SPIFFE Workload API.
21    /// Default: `unix:///run/spire/sockets/agent.sock`.
22    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    /// Add another trust domain to the allowlist.
35    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    /// Parse and validate a SPIFFE ID string. A SPIFFE ID looks like
41    /// `spiffe://<trust-domain>/<workload-path>`.
42    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    /// Fetch an SVID from the SPIFFE Workload API.
57    ///
58    /// Requires the `spiffe` feature (pulls in the SPIFFE Workload API
59    /// client). Without the feature, this returns a configuration error
60    /// pointing the user at the feature flag.
61    #[cfg(feature = "spiffe")]
62    pub async fn fetch_svid(&self) -> Result<SpiffeId> {
63        use rust_spiffe::workload_api::client::WorkloadApiClient;
64        // Bound the full connect + RPC operation; bounding only channel
65        // creation still allowed an unresponsive Workload API to hang callers.
66        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        // Validate the leaf certificate's validity window. rust-spiffe
83        // exposes the DER certificate but not its validity timestamps, so
84        // parse the already-validated leaf with the pinned X.509 parser.
85        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    /// Fetch an SVID stub: returns the trust domain if configured. Useful
116    /// for testing and when SPIRE isn't available.
117    #[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/// A SPIFFE identity, returned by [`SpiffeValidator::fetch_svid`].
127#[derive(Debug, Clone, Serialize, Deserialize)]
128pub struct SpiffeId {
129    /// Full SPIFFE ID like `spiffe://acme.com/agent/email-bot`.
130    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}