use std::collections::BTreeMap;
use crate::{IdentityError, Issuer};
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct WorkloadId(String);
impl WorkloadId {
pub const MAX_LEN: usize = 2048;
pub fn build(
trust_domain: &TrustDomain,
service: &str,
tenant_slug: &str,
) -> Result<Self, IdentityError> {
validate_path_segment(service, "service")?;
validate_path_segment(tenant_slug, "tenant_slug")?;
let raw = format!(
"spiffe://{}/{}/{}",
trust_domain.as_str(),
service,
tenant_slug
);
if raw.len() > Self::MAX_LEN {
return Err(IdentityError::InvalidSpiffeId(format!(
"URI exceeds {} chars",
Self::MAX_LEN
)));
}
Ok(Self(raw))
}
pub fn parse(raw: &str) -> Result<Self, IdentityError> {
if raw.len() > Self::MAX_LEN {
return Err(IdentityError::InvalidSpiffeId(format!(
"URI exceeds {} chars",
Self::MAX_LEN
)));
}
let after_scheme = raw.strip_prefix("spiffe://").ok_or_else(|| {
IdentityError::InvalidSpiffeId(format!("missing 'spiffe://' scheme prefix: {raw}"))
})?;
if after_scheme.contains('?') || after_scheme.contains('#') {
return Err(IdentityError::InvalidSpiffeId(
"query and fragment components not permitted".to_string(),
));
}
let path_start = after_scheme.find('/').unwrap_or(after_scheme.len());
let authority = &after_scheme[..path_start];
if authority.contains('@') {
return Err(IdentityError::InvalidSpiffeId(
"userinfo component not permitted".to_string(),
));
}
if authority.contains(':') {
return Err(IdentityError::InvalidSpiffeId(
"port component not permitted".to_string(),
));
}
TrustDomain::new(authority).map_err(|e| match e {
IdentityError::InvalidTrustDomain(msg) => {
IdentityError::InvalidSpiffeId(format!("invalid trust domain: {msg}"))
}
other => other,
})?;
if path_start < after_scheme.len() {
let path = &after_scheme[path_start..];
if !path.starts_with('/') {
return Err(IdentityError::InvalidSpiffeId(
"path must start with '/'".to_string(),
));
}
for segment in path[1..].split('/') {
validate_path_segment(segment, "path segment")?;
}
}
Ok(Self(raw.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for WorkloadId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(transparent))]
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct TrustDomain(String);
impl TrustDomain {
pub const MAX_LEN: usize = 255;
pub fn new(raw: &str) -> Result<Self, IdentityError> {
if raw.is_empty() {
return Err(IdentityError::InvalidTrustDomain(
"trust domain must not be empty".to_string(),
));
}
if raw.len() > Self::MAX_LEN {
return Err(IdentityError::InvalidTrustDomain(format!(
"trust domain exceeds {} chars",
Self::MAX_LEN
)));
}
let mut chars = raw.chars();
let first = chars.next().expect("non-empty checked above");
if !first.is_ascii_alphanumeric() {
return Err(IdentityError::InvalidTrustDomain(format!(
"must start with an alphanumeric: {raw}"
)));
}
for c in std::iter::once(first).chain(chars) {
let valid = c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '.';
if !valid {
return Err(IdentityError::InvalidTrustDomain(format!(
"invalid character '{c}' in trust domain '{raw}' (expected [a-z0-9.-])"
)));
}
}
Ok(Self(raw.to_string()))
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl std::fmt::Display for TrustDomain {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkloadPrincipal {
pub workload_id: WorkloadId,
pub trust_domain: TrustDomain,
pub issuer: Issuer,
pub tenant_id: crate::TenantId,
pub tenant_slug: String,
pub service_name: String,
pub attributes: BTreeMap<String, serde_json::Value>,
}
fn validate_path_segment(segment: &str, role: &str) -> Result<(), IdentityError> {
if segment.is_empty() {
return Err(IdentityError::InvalidComponent(format!(
"{role} must not be empty"
)));
}
for c in segment.chars() {
let valid = c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '~' || c == '-';
if !valid {
return Err(IdentityError::InvalidComponent(format!(
"invalid character '{c}' in {role} '{segment}' (expected [A-Za-z0-9._~-])"
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn trust_domain_accepts_valid_forms() {
assert!(TrustDomain::new("gnomes.local").is_ok());
assert!(TrustDomain::new("gnomes.internal").is_ok());
assert!(TrustDomain::new("example.com").is_ok());
assert!(TrustDomain::new("a").is_ok());
assert!(TrustDomain::new("prod-1.example.com").is_ok());
}
#[test]
fn trust_domain_rejects_invalid_forms() {
assert!(TrustDomain::new("").is_err());
assert!(TrustDomain::new("UPPER.case").is_err());
assert!(TrustDomain::new("-leading-hyphen").is_err());
assert!(TrustDomain::new("has spaces").is_err());
assert!(TrustDomain::new("has/slash").is_err());
assert!(TrustDomain::new("has:port").is_err());
let too_long = "a".repeat(TrustDomain::MAX_LEN + 1);
assert!(TrustDomain::new(&too_long).is_err());
}
#[test]
fn workload_id_build_round_trips_through_parse() {
let trust = TrustDomain::new("gnomes.local").unwrap();
let wid = WorkloadId::build(&trust, "compute-worker", "ekekrantz").unwrap();
assert_eq!(
wid.as_str(),
"spiffe://gnomes.local/compute-worker/ekekrantz"
);
let reparsed = WorkloadId::parse(wid.as_str()).unwrap();
assert_eq!(wid, reparsed);
}
#[test]
fn workload_id_build_rejects_empty_service() {
let trust = TrustDomain::new("gnomes.local").unwrap();
assert!(WorkloadId::build(&trust, "", "ekekrantz").is_err());
}
#[test]
fn workload_id_build_rejects_empty_tenant_slug() {
let trust = TrustDomain::new("gnomes.local").unwrap();
assert!(WorkloadId::build(&trust, "compute-worker", "").is_err());
}
#[test]
fn workload_id_build_rejects_invalid_chars_in_segment() {
let trust = TrustDomain::new("gnomes.local").unwrap();
assert!(WorkloadId::build(&trust, "compute worker", "ekekrantz").is_err());
assert!(WorkloadId::build(&trust, "compute-worker", "eke/krantz").is_err());
assert!(WorkloadId::build(&trust, "compute-worker", "eke?krantz").is_err());
}
#[test]
fn workload_id_parse_accepts_canonical_spiffe_uri() {
let raw = "spiffe://gnomes.local/compute-worker/ekekrantz";
let parsed = WorkloadId::parse(raw).unwrap();
assert_eq!(parsed.as_str(), raw);
}
#[test]
fn workload_id_parse_accepts_trust_domain_only() {
let parsed = WorkloadId::parse("spiffe://gnomes.local").unwrap();
assert_eq!(parsed.as_str(), "spiffe://gnomes.local");
}
#[test]
fn workload_id_parse_rejects_non_spiffe_scheme() {
assert!(WorkloadId::parse("https://gnomes.local/x/y").is_err());
assert!(WorkloadId::parse("http://gnomes.local/x/y").is_err());
assert!(WorkloadId::parse("/gnomes.local/x/y").is_err());
}
#[test]
fn workload_id_parse_rejects_userinfo() {
assert!(WorkloadId::parse("spiffe://user@gnomes.local/x").is_err());
}
#[test]
fn workload_id_parse_rejects_port() {
assert!(WorkloadId::parse("spiffe://gnomes.local:8443/x").is_err());
}
#[test]
fn workload_id_parse_rejects_query_and_fragment() {
assert!(WorkloadId::parse("spiffe://gnomes.local/x?y=1").is_err());
assert!(WorkloadId::parse("spiffe://gnomes.local/x#frag").is_err());
}
#[test]
fn workload_id_parse_rejects_empty_segment() {
assert!(WorkloadId::parse("spiffe://gnomes.local//x").is_err());
assert!(WorkloadId::parse("spiffe://gnomes.local/x//y").is_err());
}
#[test]
fn workload_id_parse_rejects_over_length_uri() {
let trust = "gnomes.local";
let path: String = std::iter::repeat_n('a', WorkloadId::MAX_LEN).collect();
let raw = format!("spiffe://{trust}/{path}");
assert!(WorkloadId::parse(&raw).is_err());
}
#[test]
fn workload_id_parse_rejects_uppercase_trust_domain() {
assert!(WorkloadId::parse("spiffe://Gnomes.Local/x").is_err());
}
#[test]
fn workload_id_display_matches_as_str() {
let trust = TrustDomain::new("gnomes.local").unwrap();
let wid = WorkloadId::build(&trust, "feed-worker", "ekekrantz").unwrap();
assert_eq!(format!("{wid}"), wid.as_str());
}
#[cfg(feature = "serde")]
#[test]
fn workload_id_serializes_as_transparent_string() {
let trust = TrustDomain::new("gnomes.local").unwrap();
let wid = WorkloadId::build(&trust, "compute-worker", "ekekrantz").unwrap();
let json = serde_json::to_string(&wid).unwrap();
assert_eq!(json, "\"spiffe://gnomes.local/compute-worker/ekekrantz\"");
let back: WorkloadId = serde_json::from_str(&json).unwrap();
assert_eq!(wid, back);
}
#[cfg(feature = "serde")]
#[test]
fn trust_domain_serializes_as_transparent_string() {
let trust = TrustDomain::new("gnomes.local").unwrap();
let json = serde_json::to_string(&trust).unwrap();
assert_eq!(json, "\"gnomes.local\"");
let back: TrustDomain = serde_json::from_str(&json).unwrap();
assert_eq!(trust, back);
}
#[test]
fn workload_id_parse_rejects_question_mark_without_hash() {
let result = WorkloadId::parse("spiffe://gnomes.local/x?y");
assert!(result.is_err());
let err = result.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("query and fragment"),
"must reject at the early `?/#` guard, got: {msg}"
);
}
#[test]
fn trust_domain_display_matches_as_str() {
let trust = TrustDomain::new("gnomes.local").unwrap();
assert_eq!(format!("{trust}"), "gnomes.local");
}
#[test]
fn workload_id_build_accepts_uri_at_exact_max_len() {
let trust = TrustDomain::new("a").unwrap();
let prefix_len = "spiffe://a/svc/".len();
let pad = WorkloadId::MAX_LEN - prefix_len;
let tenant = "a".repeat(pad);
let result = WorkloadId::build(&trust, "svc", &tenant);
assert!(
result.is_ok(),
"URI of EXACTLY MAX_LEN must build successfully"
);
assert_eq!(result.unwrap().as_str().len(), WorkloadId::MAX_LEN);
}
#[test]
fn workload_id_parse_accepts_uri_at_exact_max_len() {
let prefix_len = "spiffe://a/svc/".len();
let pad = WorkloadId::MAX_LEN - prefix_len;
let raw = format!("spiffe://a/svc/{}", "a".repeat(pad));
assert_eq!(raw.len(), WorkloadId::MAX_LEN);
let result = WorkloadId::parse(&raw);
assert!(
result.is_ok(),
"URI of EXACTLY MAX_LEN must parse successfully"
);
}
}