Skip to main content

cts_common/auth/claims/
services.rs

1use serde::de::IntoDeserializer;
2use serde::{Deserialize, Serialize};
3use std::collections::BTreeMap;
4use url::Url;
5
6/// The type of service endpoint included in a CTS-issued JWT.
7#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
8#[serde(rename_all = "lowercase")]
9pub enum ServiceType {
10    ZeroKms,
11    Secrets,
12}
13
14impl ServiceType {
15    /// Return the lowercase string representation (matching the serde serialization).
16    pub fn as_str(&self) -> &'static str {
17        match self {
18            ServiceType::ZeroKms => "zerokms",
19            ServiceType::Secrets => "secrets",
20        }
21    }
22}
23
24/// A map of service types to their endpoint URLs.
25///
26/// Included in CTS-issued JWTs so clients can discover service endpoints
27/// without guessing URL schemes from the `aud` claim.
28///
29/// Unknown service types are skipped during deserialization with a warning,
30/// so that adding new services doesn't break existing clients.
31#[derive(Debug, Clone, Default, PartialEq, Serialize)]
32#[serde(transparent)]
33pub struct Services(BTreeMap<ServiceType, Url>);
34
35impl<'de> Deserialize<'de> for Services {
36    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
37    where
38        D: serde::Deserializer<'de>,
39    {
40        let raw: BTreeMap<String, Url> = BTreeMap::deserialize(deserializer)?;
41        let mut services = BTreeMap::new();
42        for (key, url) in raw {
43            match ServiceType::deserialize(
44                <&str as IntoDeserializer<serde::de::value::Error>>::into_deserializer(
45                    key.as_str(),
46                ),
47            ) {
48                Ok(service_type) => {
49                    services.insert(service_type, url);
50                }
51                Err(_) => {
52                    tracing::warn!(service_type = %key, "Unknown service type in token; ignoring");
53                }
54            }
55        }
56        Ok(Services(services))
57    }
58}
59
60impl Services {
61    pub fn new() -> Self {
62        Self(BTreeMap::new())
63    }
64
65    pub fn insert(&mut self, service_type: ServiceType, url: Url) {
66        self.0.insert(service_type, url);
67    }
68
69    pub fn get(&self, service_type: ServiceType) -> Option<&Url> {
70        self.0.get(&service_type)
71    }
72
73    pub fn is_empty(&self) -> bool {
74        self.0.is_empty()
75    }
76
77    pub fn iter(&self) -> impl Iterator<Item = (&ServiceType, &Url)> {
78        self.0.iter()
79    }
80}
81
82#[cfg(test)]
83mod tests {
84    use super::*;
85
86    #[test]
87    fn test_services_roundtrip() {
88        let mut services = Services::new();
89        services.insert(
90            ServiceType::ZeroKms,
91            Url::parse("https://us-east-1.aws.viturhosted.net/").unwrap(),
92        );
93
94        let json = serde_json::to_string(&services).unwrap();
95        assert_eq!(
96            json,
97            r#"{"zerokms":"https://us-east-1.aws.viturhosted.net/"}"#
98        );
99
100        let deserialized: Services = serde_json::from_str(&json).unwrap();
101        assert_eq!(
102            deserialized.get(ServiceType::ZeroKms).map(|u| u.as_str()),
103            Some("https://us-east-1.aws.viturhosted.net/")
104        );
105    }
106
107    #[test]
108    fn test_empty_services_not_serialized_as_null() {
109        let services = Services::new();
110        let json = serde_json::to_string(&services).unwrap();
111        assert_eq!(json, "{}");
112    }
113
114    #[test]
115    fn test_default_is_empty() {
116        let services = Services::default();
117        assert!(services.is_empty());
118    }
119
120    #[test]
121    fn test_missing_key_returns_none() {
122        let services = Services::new();
123        assert_eq!(services.get(ServiceType::ZeroKms), None);
124    }
125
126    #[test]
127    fn test_service_type_as_str() {
128        assert_eq!(ServiceType::ZeroKms.as_str(), "zerokms");
129        assert_eq!(ServiceType::Secrets.as_str(), "secrets");
130    }
131
132    #[test]
133    fn test_services_roundtrip_with_multiple_services() {
134        let mut services = Services::new();
135        services.insert(
136            ServiceType::Secrets,
137            Url::parse("https://dashboard.cipherstash.com/").unwrap(),
138        );
139        services.insert(
140            ServiceType::ZeroKms,
141            Url::parse("https://us-east-1.aws.viturhosted.net/").unwrap(),
142        );
143
144        let json = serde_json::to_string(&services).unwrap();
145        let deserialized: Services = serde_json::from_str(&json).unwrap();
146
147        assert_eq!(
148            deserialized.get(ServiceType::Secrets).map(|u| u.as_str()),
149            Some("https://dashboard.cipherstash.com/"),
150            "secrets endpoint should roundtrip through serde"
151        );
152        assert_eq!(
153            deserialized.get(ServiceType::ZeroKms).map(|u| u.as_str()),
154            Some("https://us-east-1.aws.viturhosted.net/"),
155            "zerokms endpoint should roundtrip through serde"
156        );
157    }
158
159    #[test]
160    fn test_iter_populated_services() {
161        let mut services = Services::new();
162        let url = Url::parse("https://zerokms.example.com/").unwrap();
163        services.insert(ServiceType::ZeroKms, url.clone());
164
165        let entries: Vec<_> = services.iter().collect();
166        assert_eq!(entries.len(), 1);
167        assert_eq!(entries[0], (&ServiceType::ZeroKms, &url));
168    }
169
170    #[test]
171    fn test_iter_empty_services() {
172        let services = Services::new();
173        assert_eq!(services.iter().count(), 0);
174    }
175
176    #[test]
177    fn test_unknown_service_type_is_skipped() {
178        let json = r#"{"zerokms":"https://us-east-1.aws.viturhosted.net/","newservice":"https://new.example.com/"}"#;
179        let services: Services = serde_json::from_str(json).unwrap();
180
181        assert_eq!(
182            services.get(ServiceType::ZeroKms).map(|u| u.as_str()),
183            Some("https://us-east-1.aws.viturhosted.net/"),
184            "known service should be preserved"
185        );
186        assert!(
187            services.iter().all(|(k, _)| *k != ServiceType::Secrets),
188            "unknown service should not appear as a known variant"
189        );
190        assert_eq!(
191            services.iter().count(),
192            1,
193            "only the known service should be present"
194        );
195    }
196
197    #[test]
198    fn test_all_unknown_services_produces_empty() {
199        let json = r#"{"foo":"https://foo.example.com/"}"#;
200        let services: Services = serde_json::from_str(json).unwrap();
201        assert!(services.is_empty());
202    }
203}