Skip to main content

axess_identity/
workload.rs

1//! Workload principals: services, batch jobs, agents, CI runners.
2//!
3//! Identified by a SPIFFE-ID URI from day one (`spiffe://<trust-domain>/<path>`),
4//! even when resolved from a non-SPIFFE source. The format choice is
5//! forward-compatible: when [`Issuer::JwtSvid`](crate::Issuer) and
6//! friends land, the on-wire identity string does not change; only
7//! the [`crate::Issuer`] variant flips.
8
9use std::collections::BTreeMap;
10
11use crate::{IdentityError, Issuer};
12
13/// SPIFFE-ID-shaped workload identifier.
14///
15/// Wire format: `spiffe://<trust-domain>/<path>` where the trust domain is
16/// the URI authority and the path is a slash-separated sequence of non-empty
17/// segments. Validation follows the [SPIFFE-ID spec](https://github.com/spiffe/spiffe/blob/main/standards/SPIFFE-ID.md):
18///
19/// - scheme must be exactly `spiffe`
20/// - no userinfo, no port, no query, no fragment
21/// - trust domain matches `[a-z0-9][a-z0-9.-]*`, lowercase, max 255 chars
22/// - each path segment is non-empty and matches `[A-Za-z0-9._~-]+`
23/// - full URI length ≤ 2048 characters
24///
25/// Constructors:
26/// - [`WorkloadId::build`] builds from the three platform components
27///   used by `CliResolver` (trust domain, service name, tenant slug).
28/// - [`WorkloadId::parse`] validates an arbitrary string. Used when
29///   loading from external sources (JWT-SVID `sub` claim, mTLS SAN, etc.).
30#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
31#[cfg_attr(feature = "serde", serde(transparent))]
32#[derive(Clone, Debug, PartialEq, Eq, Hash)]
33pub struct WorkloadId(String);
34
35impl WorkloadId {
36    /// Maximum URI length per the SPIFFE-ID spec.
37    pub const MAX_LEN: usize = 2048;
38
39    /// Build a SPIFFE-ID URI from the platform identity components.
40    ///
41    /// Format: `spiffe://<trust_domain>/<service>/<tenant_slug>`.
42    /// Validates `service` and `tenant_slug` as SPIFFE path segments;
43    /// the trust domain is already validated by [`TrustDomain::new`].
44    pub fn build(
45        trust_domain: &TrustDomain,
46        service: &str,
47        tenant_slug: &str,
48    ) -> Result<Self, IdentityError> {
49        validate_path_segment(service, "service")?;
50        validate_path_segment(tenant_slug, "tenant_slug")?;
51        let raw = format!(
52            "spiffe://{}/{}/{}",
53            trust_domain.as_str(),
54            service,
55            tenant_slug
56        );
57        if raw.len() > Self::MAX_LEN {
58            return Err(IdentityError::InvalidSpiffeId(format!(
59                "URI exceeds {} chars",
60                Self::MAX_LEN
61            )));
62        }
63        Ok(Self(raw))
64    }
65
66    /// Validate and adopt an arbitrary SPIFFE-ID string. Rejects any
67    /// URI that does not conform to the SPIFFE-ID spec.
68    pub fn parse(raw: &str) -> Result<Self, IdentityError> {
69        if raw.len() > Self::MAX_LEN {
70            return Err(IdentityError::InvalidSpiffeId(format!(
71                "URI exceeds {} chars",
72                Self::MAX_LEN
73            )));
74        }
75        let after_scheme = raw.strip_prefix("spiffe://").ok_or_else(|| {
76            IdentityError::InvalidSpiffeId(format!("missing 'spiffe://' scheme prefix: {raw}"))
77        })?;
78        if after_scheme.contains('?') || after_scheme.contains('#') {
79            return Err(IdentityError::InvalidSpiffeId(
80                "query and fragment components not permitted".to_string(),
81            ));
82        }
83        let path_start = after_scheme.find('/').unwrap_or(after_scheme.len());
84        let authority = &after_scheme[..path_start];
85        if authority.contains('@') {
86            return Err(IdentityError::InvalidSpiffeId(
87                "userinfo component not permitted".to_string(),
88            ));
89        }
90        if authority.contains(':') {
91            return Err(IdentityError::InvalidSpiffeId(
92                "port component not permitted".to_string(),
93            ));
94        }
95        TrustDomain::new(authority).map_err(|e| match e {
96            IdentityError::InvalidTrustDomain(msg) => {
97                IdentityError::InvalidSpiffeId(format!("invalid trust domain: {msg}"))
98            }
99            other => other,
100        })?;
101        if path_start < after_scheme.len() {
102            let path = &after_scheme[path_start..];
103            if !path.starts_with('/') {
104                return Err(IdentityError::InvalidSpiffeId(
105                    "path must start with '/'".to_string(),
106                ));
107            }
108            for segment in path[1..].split('/') {
109                validate_path_segment(segment, "path segment")?;
110            }
111        }
112        Ok(Self(raw.to_string()))
113    }
114
115    /// Borrow the underlying URI as a string slice.
116    pub fn as_str(&self) -> &str {
117        &self.0
118    }
119}
120
121impl std::fmt::Display for WorkloadId {
122    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
123        f.write_str(&self.0)
124    }
125}
126
127/// SPIFFE trust domain: the authority component of a SPIFFE-ID URI.
128///
129/// Per the spec: non-empty, lowercase ASCII, alphanumeric / hyphen / dot,
130/// must start with an alphanumeric, max 255 characters.
131#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
132#[cfg_attr(feature = "serde", serde(transparent))]
133#[derive(Clone, Debug, PartialEq, Eq, Hash)]
134pub struct TrustDomain(String);
135
136impl TrustDomain {
137    /// Maximum trust-domain length per the SPIFFE-ID spec.
138    pub const MAX_LEN: usize = 255;
139
140    /// Construct after validating that `raw` is a syntactically valid
141    /// SPIFFE trust domain.
142    pub fn new(raw: &str) -> Result<Self, IdentityError> {
143        if raw.is_empty() {
144            return Err(IdentityError::InvalidTrustDomain(
145                "trust domain must not be empty".to_string(),
146            ));
147        }
148        if raw.len() > Self::MAX_LEN {
149            return Err(IdentityError::InvalidTrustDomain(format!(
150                "trust domain exceeds {} chars",
151                Self::MAX_LEN
152            )));
153        }
154        let mut chars = raw.chars();
155        let first = chars.next().expect("non-empty checked above");
156        if !first.is_ascii_alphanumeric() {
157            return Err(IdentityError::InvalidTrustDomain(format!(
158                "must start with an alphanumeric: {raw}"
159            )));
160        }
161        for c in std::iter::once(first).chain(chars) {
162            let valid = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.';
163            if !valid {
164                return Err(IdentityError::InvalidTrustDomain(format!(
165                    "invalid character '{c}' in trust domain '{raw}' (expected [a-z0-9.-])"
166                )));
167            }
168        }
169        Ok(Self(raw.to_string()))
170    }
171
172    /// Borrow the trust domain as a string slice.
173    pub fn as_str(&self) -> &str {
174        &self.0
175    }
176}
177
178impl std::fmt::Display for TrustDomain {
179    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
180        f.write_str(&self.0)
181    }
182}
183
184/// A workload principal: a service, batch job, agent, or other
185/// non-human compute identity. Carries the SPIFFE-shaped workload id,
186/// its trust domain, the issuer that vouched for it, the tenant
187/// scope, and arbitrary attributes (empty today; populated from JWT
188/// claims when JWT-SVID resolution lands).
189#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
190#[derive(Clone, Debug, PartialEq, Eq)]
191pub struct WorkloadPrincipal {
192    /// SPIFFE-shaped workload identifier.
193    pub workload_id: WorkloadId,
194    /// Trust domain the workload belongs to. Redundant with the
195    /// authority component of [`workload_id`](Self::workload_id) but
196    /// surfaced explicitly for ergonomic policy access.
197    pub trust_domain: TrustDomain,
198    /// How the workload's identity was vouched for at resolution time.
199    pub issuer: Issuer,
200    /// Tenant the workload is scoped to.
201    pub tenant_id: crate::TenantId,
202    /// Human-readable tenant slug (matches `tenants.name` in the
203    /// adopter's storage). Carried alongside the typed
204    /// [`tenant_id`](Self::tenant_id) for log lines and admin UIs that
205    /// need the readable form without a registry lookup.
206    pub tenant_slug: String,
207    /// Service identifier: `"compute-worker"`, `"feed-worker"`, etc.
208    pub service_name: String,
209    /// Arbitrary key-value attributes from the resolver. Empty for
210    /// `CliResolver`; populated from JWT claims by future federation
211    /// resolvers.
212    pub attributes: BTreeMap<String, serde_json::Value>,
213}
214
215fn validate_path_segment(segment: &str, role: &str) -> Result<(), IdentityError> {
216    if segment.is_empty() {
217        return Err(IdentityError::InvalidComponent(format!(
218            "{role} must not be empty"
219        )));
220    }
221    for c in segment.chars() {
222        let valid = c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '~' || c == '-';
223        if !valid {
224            return Err(IdentityError::InvalidComponent(format!(
225                "invalid character '{c}' in {role} '{segment}' (expected [A-Za-z0-9._~-])"
226            )));
227        }
228    }
229    Ok(())
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235
236    #[test]
237    fn trust_domain_accepts_valid_forms() {
238        assert!(TrustDomain::new("gnomes.local").is_ok());
239        assert!(TrustDomain::new("gnomes.internal").is_ok());
240        assert!(TrustDomain::new("example.com").is_ok());
241        assert!(TrustDomain::new("a").is_ok());
242        assert!(TrustDomain::new("prod-1.example.com").is_ok());
243    }
244
245    #[test]
246    fn trust_domain_rejects_invalid_forms() {
247        assert!(TrustDomain::new("").is_err());
248        assert!(TrustDomain::new("UPPER.case").is_err());
249        assert!(TrustDomain::new("-leading-hyphen").is_err());
250        assert!(TrustDomain::new("has spaces").is_err());
251        assert!(TrustDomain::new("has/slash").is_err());
252        assert!(TrustDomain::new("has:port").is_err());
253        let too_long = "a".repeat(TrustDomain::MAX_LEN + 1);
254        assert!(TrustDomain::new(&too_long).is_err());
255    }
256
257    #[test]
258    fn workload_id_build_round_trips_through_parse() {
259        let trust = TrustDomain::new("gnomes.local").unwrap();
260        let wid = WorkloadId::build(&trust, "compute-worker", "ekekrantz").unwrap();
261        assert_eq!(
262            wid.as_str(),
263            "spiffe://gnomes.local/compute-worker/ekekrantz"
264        );
265        let reparsed = WorkloadId::parse(wid.as_str()).unwrap();
266        assert_eq!(wid, reparsed);
267    }
268
269    #[test]
270    fn workload_id_build_rejects_empty_service() {
271        let trust = TrustDomain::new("gnomes.local").unwrap();
272        assert!(WorkloadId::build(&trust, "", "ekekrantz").is_err());
273    }
274
275    #[test]
276    fn workload_id_build_rejects_empty_tenant_slug() {
277        let trust = TrustDomain::new("gnomes.local").unwrap();
278        assert!(WorkloadId::build(&trust, "compute-worker", "").is_err());
279    }
280
281    #[test]
282    fn workload_id_build_rejects_invalid_chars_in_segment() {
283        let trust = TrustDomain::new("gnomes.local").unwrap();
284        assert!(WorkloadId::build(&trust, "compute worker", "ekekrantz").is_err());
285        assert!(WorkloadId::build(&trust, "compute-worker", "eke/krantz").is_err());
286        assert!(WorkloadId::build(&trust, "compute-worker", "eke?krantz").is_err());
287    }
288
289    #[test]
290    fn workload_id_parse_accepts_canonical_spiffe_uri() {
291        let raw = "spiffe://gnomes.local/compute-worker/ekekrantz";
292        let parsed = WorkloadId::parse(raw).unwrap();
293        assert_eq!(parsed.as_str(), raw);
294    }
295
296    #[test]
297    fn workload_id_parse_accepts_trust_domain_only() {
298        let parsed = WorkloadId::parse("spiffe://gnomes.local").unwrap();
299        assert_eq!(parsed.as_str(), "spiffe://gnomes.local");
300    }
301
302    #[test]
303    fn workload_id_parse_rejects_non_spiffe_scheme() {
304        assert!(WorkloadId::parse("https://gnomes.local/x/y").is_err());
305        assert!(WorkloadId::parse("http://gnomes.local/x/y").is_err());
306        assert!(WorkloadId::parse("/gnomes.local/x/y").is_err());
307    }
308
309    #[test]
310    fn workload_id_parse_rejects_userinfo() {
311        assert!(WorkloadId::parse("spiffe://user@gnomes.local/x").is_err());
312    }
313
314    #[test]
315    fn workload_id_parse_rejects_port() {
316        assert!(WorkloadId::parse("spiffe://gnomes.local:8443/x").is_err());
317    }
318
319    #[test]
320    fn workload_id_parse_rejects_query_and_fragment() {
321        assert!(WorkloadId::parse("spiffe://gnomes.local/x?y=1").is_err());
322        assert!(WorkloadId::parse("spiffe://gnomes.local/x#frag").is_err());
323    }
324
325    #[test]
326    fn workload_id_parse_rejects_empty_segment() {
327        assert!(WorkloadId::parse("spiffe://gnomes.local//x").is_err());
328        assert!(WorkloadId::parse("spiffe://gnomes.local/x//y").is_err());
329    }
330
331    #[test]
332    fn workload_id_parse_rejects_over_length_uri() {
333        let trust = "gnomes.local";
334        let path: String = std::iter::repeat_n('a', WorkloadId::MAX_LEN).collect();
335        let raw = format!("spiffe://{trust}/{path}");
336        assert!(WorkloadId::parse(&raw).is_err());
337    }
338
339    #[test]
340    fn workload_id_parse_rejects_uppercase_trust_domain() {
341        assert!(WorkloadId::parse("spiffe://Gnomes.Local/x").is_err());
342    }
343
344    #[test]
345    fn workload_id_display_matches_as_str() {
346        let trust = TrustDomain::new("gnomes.local").unwrap();
347        let wid = WorkloadId::build(&trust, "feed-worker", "ekekrantz").unwrap();
348        assert_eq!(format!("{wid}"), wid.as_str());
349    }
350
351    #[cfg(feature = "serde")]
352    #[test]
353    fn workload_id_serializes_as_transparent_string() {
354        let trust = TrustDomain::new("gnomes.local").unwrap();
355        let wid = WorkloadId::build(&trust, "compute-worker", "ekekrantz").unwrap();
356        let json = serde_json::to_string(&wid).unwrap();
357        assert_eq!(json, "\"spiffe://gnomes.local/compute-worker/ekekrantz\"");
358        let back: WorkloadId = serde_json::from_str(&json).unwrap();
359        assert_eq!(wid, back);
360    }
361
362    #[cfg(feature = "serde")]
363    #[test]
364    fn trust_domain_serializes_as_transparent_string() {
365        let trust = TrustDomain::new("gnomes.local").unwrap();
366        let json = serde_json::to_string(&trust).unwrap();
367        assert_eq!(json, "\"gnomes.local\"");
368        let back: TrustDomain = serde_json::from_str(&json).unwrap();
369        assert_eq!(trust, back);
370    }
371
372    /// `parse` rejects only when `?` OR `#` is present. Test the
373    /// `?`-only path explicitly so the `|| → &&` mutation can be
374    /// observed: with `&&`, a URI containing only `?` would slip past
375    /// the early reject and (potentially) succeed via downstream
376    /// segment validation.
377    #[test]
378    fn workload_id_parse_rejects_question_mark_without_hash() {
379        let result = WorkloadId::parse("spiffe://gnomes.local/x?y");
380        assert!(result.is_err());
381        let err = result.unwrap_err();
382        let msg = err.to_string();
383        // The early-reject branch yields a specific message; if the
384        // mutation fell through to segment validation, the rejection
385        // message would mention the segment instead.
386        assert!(
387            msg.contains("query and fragment"),
388            "must reject at the early `?/#` guard, got: {msg}"
389        );
390    }
391
392    /// `TrustDomain::Display` writes the trust-domain string. Mutation
393    /// `-> Ok(())` would yield an empty format output.
394    #[test]
395    fn trust_domain_display_matches_as_str() {
396        let trust = TrustDomain::new("gnomes.local").unwrap();
397        assert_eq!(format!("{trust}"), "gnomes.local");
398    }
399
400    /// `WorkloadId::build` rejects URIs ABOVE `MAX_LEN` with strict
401    /// `>`. At exactly `MAX_LEN` bytes the result must succeed.
402    /// Mutations `==` and `>=` would reject at the boundary.
403    #[test]
404    fn workload_id_build_accepts_uri_at_exact_max_len() {
405        let trust = TrustDomain::new("a").unwrap();
406        // Build a URI exactly at MAX_LEN: "spiffe://a/svc/" = 15 chars,
407        // then pad tenant_slug to (MAX_LEN - 15) chars.
408        let prefix_len = "spiffe://a/svc/".len();
409        let pad = WorkloadId::MAX_LEN - prefix_len;
410        let tenant = "a".repeat(pad);
411        let result = WorkloadId::build(&trust, "svc", &tenant);
412        assert!(
413            result.is_ok(),
414            "URI of EXACTLY MAX_LEN must build successfully"
415        );
416        assert_eq!(result.unwrap().as_str().len(), WorkloadId::MAX_LEN);
417    }
418
419    /// `WorkloadId::parse` rejects URIs ABOVE `MAX_LEN`. At exactly
420    /// `MAX_LEN` bytes the parse must succeed. Mutation `>=` would
421    /// reject at the boundary.
422    #[test]
423    fn workload_id_parse_accepts_uri_at_exact_max_len() {
424        let prefix_len = "spiffe://a/svc/".len();
425        let pad = WorkloadId::MAX_LEN - prefix_len;
426        let raw = format!("spiffe://a/svc/{}", "a".repeat(pad));
427        assert_eq!(raw.len(), WorkloadId::MAX_LEN);
428        let result = WorkloadId::parse(&raw);
429        assert!(
430            result.is_ok(),
431            "URI of EXACTLY MAX_LEN must parse successfully"
432        );
433    }
434}