Skip to main content

gonzalo_domain/fleet/
keys.rs

1//! Record ids for fleet records. Composite ids are built only here: each
2//! component is escaped (`%` → `%25`, then `:` → `%3A`) before joining with
3//! `:`, so an id stays unambiguous when a component, such as an OIDC issuer
4//! URL, contains `:`.
5
6use super::{Authenticator, GrantScope};
7use std::fmt;
8
9/// Why a fleet record key could not be built.
10#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum FleetKeyError {
12    /// A person id outside `[A-Za-z0-9_-]{1,64}`.
13    InvalidPersonId(String),
14    /// An audit nonce outside `[A-Za-z0-9_-]{1,32}`.
15    InvalidNonce(String),
16    /// An audit timestamp before the Unix epoch.
17    NegativeTimestamp(i64),
18    /// A required key component was empty; names the component.
19    EmptyComponent(&'static str),
20}
21
22impl fmt::Display for FleetKeyError {
23    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24        match self {
25            Self::InvalidPersonId(id) => {
26                write!(f, "person id {id:?} must match [A-Za-z0-9_-]{{1,64}}")
27            }
28            Self::InvalidNonce(nonce) => {
29                write!(f, "audit nonce {nonce:?} must match [A-Za-z0-9_-]{{1,32}}")
30            }
31            Self::NegativeTimestamp(at) => {
32                write!(f, "audit timestamp {at} is before the Unix epoch")
33            }
34            Self::EmptyComponent(component) => write!(f, "{component} must not be empty"),
35        }
36    }
37}
38
39impl std::error::Error for FleetKeyError {}
40
41fn escape(component: &str) -> String {
42    component.replace('%', "%25").replace(':', "%3A")
43}
44
45fn is_id_char(c: char) -> bool {
46    c.is_ascii_alphanumeric() || c == '_' || c == '-'
47}
48
49fn non_empty(value: &str, component: &'static str) -> Result<(), FleetKeyError> {
50    if value.is_empty() {
51        Err(FleetKeyError::EmptyComponent(component))
52    } else {
53        Ok(())
54    }
55}
56
57pub(crate) fn validate_person_id(id: &str) -> Result<(), FleetKeyError> {
58    if (1..=64).contains(&id.len()) && id.chars().all(is_id_char) {
59        Ok(())
60    } else {
61        Err(FleetKeyError::InvalidPersonId(id.to_string()))
62    }
63}
64
65fn validate_nonce(nonce: &str) -> Result<(), FleetKeyError> {
66    if (1..=32).contains(&nonce.len()) && nonce.chars().all(is_id_char) {
67        Ok(())
68    } else {
69        Err(FleetKeyError::InvalidNonce(nonce.to_string()))
70    }
71}
72
73fn authenticator_segment(authenticator: &Authenticator) -> Result<String, FleetKeyError> {
74    Ok(match authenticator {
75        Authenticator::Discord => "discord".to_string(),
76        Authenticator::Slack => "slack".to_string(),
77        Authenticator::Teams => "teams".to_string(),
78        Authenticator::Oidc { issuer } => {
79            non_empty(issuer, "issuer")?;
80            format!("oidc:{}", escape(issuer))
81        }
82        Authenticator::Other(name) => {
83            non_empty(name, "authenticator name")?;
84            format!("other:{}", escape(name))
85        }
86    })
87}
88
89fn scope_segment(scope: &GrantScope) -> Result<String, FleetKeyError> {
90    Ok(match scope {
91        GrantScope::Fleet => "fleet".to_string(),
92        GrantScope::Workspace(workspace) => {
93            non_empty(workspace, "workspace")?;
94            format!("workspace:{}", escape(workspace))
95        }
96    })
97}
98
99/// `<authenticator>:<subject>`.
100pub(crate) fn binding_id(
101    authenticator: &Authenticator,
102    subject: &str,
103) -> Result<String, FleetKeyError> {
104    non_empty(subject, "subject")?;
105    Ok(format!(
106        "{}:{}",
107        authenticator_segment(authenticator)?,
108        escape(subject)
109    ))
110}
111
112/// `<person_id>:<scope>`.
113pub(crate) fn grant_id(person: &str, scope: &GrantScope) -> Result<String, FleetKeyError> {
114    validate_person_id(person)?;
115    Ok(format!("{person}:{}", scope_segment(scope)?))
116}
117
118/// `<provider>:<tenant>:<channel>`. The tenant (guild, workspace or team id) is
119/// part of the key because a platform channel id is only unique within its
120/// tenant on Slack and Teams (ariel ADR 0006; gonzalo ADR 0023).
121pub(crate) fn channel_id(
122    provider: &str,
123    tenant: &str,
124    channel: &str,
125) -> Result<String, FleetKeyError> {
126    non_empty(provider, "provider")?;
127    non_empty(tenant, "tenant")?;
128    non_empty(channel, "channel id")?;
129    Ok(format!(
130        "{}:{}:{}",
131        escape(provider),
132        escape(tenant),
133        escape(channel)
134    ))
135}
136
137/// `<at_ms zero-padded to 13 digits>-<nonce>`.
138pub(crate) fn audit_id(at: i64, nonce: &str) -> Result<String, FleetKeyError> {
139    if at < 0 {
140        return Err(FleetKeyError::NegativeTimestamp(at));
141    }
142    validate_nonce(nonce)?;
143    Ok(format!("{at:013}-{nonce}"))
144}
145
146#[cfg(test)]
147mod tests {
148    use super::*;
149
150    fn oidc(issuer: &str) -> Authenticator {
151        Authenticator::Oidc {
152            issuer: issuer.to_string(),
153        }
154    }
155
156    #[test]
157    fn binding_ids_are_readable_for_chat_accounts() {
158        assert_eq!(
159            binding_id(&Authenticator::Discord, "1234").unwrap(),
160            "discord:1234"
161        );
162        assert_eq!(
163            binding_id(&Authenticator::Slack, "U01").unwrap(),
164            "slack:U01"
165        );
166        assert_eq!(
167            binding_id(&Authenticator::Teams, "t-9").unwrap(),
168            "teams:t-9"
169        );
170        assert_eq!(
171            binding_id(&Authenticator::Other("matrix".into()), "@a").unwrap(),
172            "other:matrix:@a"
173        );
174    }
175
176    #[test]
177    fn oidc_components_are_escaped() {
178        assert_eq!(
179            binding_id(&oidc("https://id.example.com"), "u:1").unwrap(),
180            "oidc:https%3A//id.example.com:u%3A1"
181        );
182    }
183
184    #[test]
185    fn escaping_is_injective_across_colons_and_percents() {
186        assert_ne!(
187            binding_id(&oidc("https://a"), "b:c").unwrap(),
188            binding_id(&oidc("https://a:b"), "c").unwrap()
189        );
190        assert_ne!(
191            binding_id(&Authenticator::Discord, "%3A").unwrap(),
192            binding_id(&Authenticator::Discord, ":").unwrap()
193        );
194        assert_ne!(
195            channel_id("a:b", "t", "c").unwrap(),
196            channel_id("a", "b:t", "c").unwrap()
197        );
198        assert_ne!(
199            channel_id("discord", "t1", "c").unwrap(),
200            channel_id("discord", "t2", "c").unwrap(),
201            "the same channel id in two tenants keys differently"
202        );
203    }
204
205    #[test]
206    fn channel_ids_name_provider_tenant_and_channel() {
207        assert_eq!(
208            channel_id("discord", "guild-1", "42").unwrap(),
209            "discord:guild-1:42"
210        );
211    }
212
213    #[test]
214    fn grant_ids_name_person_and_scope() {
215        assert_eq!(grant_id("p1", &GrantScope::Fleet).unwrap(), "p1:fleet");
216        assert_eq!(
217            grant_id("p1", &GrantScope::Workspace("caliban".into())).unwrap(),
218            "p1:workspace:caliban"
219        );
220    }
221
222    #[test]
223    fn person_ids_are_validated() {
224        assert!(validate_person_id("abc_D-9").is_ok());
225        assert!(validate_person_id(&"a".repeat(64)).is_ok());
226        for bad in ["", "a:b", "a b", "é"] {
227            assert_eq!(
228                validate_person_id(bad),
229                Err(FleetKeyError::InvalidPersonId(bad.to_string()))
230            );
231        }
232        assert!(validate_person_id(&"a".repeat(65)).is_err());
233        assert!(grant_id("a:b", &GrantScope::Fleet).is_err());
234    }
235
236    #[test]
237    fn audit_ids_are_zero_padded_and_validated() {
238        assert_eq!(
239            audit_id(1_700_000_000_000, "n1").unwrap(),
240            "1700000000000-n1"
241        );
242        assert_eq!(audit_id(5, "x").unwrap(), "0000000000005-x");
243        assert_eq!(audit_id(-1, "x"), Err(FleetKeyError::NegativeTimestamp(-1)));
244        assert!(audit_id(0, "").is_err());
245        assert!(audit_id(0, "a b").is_err());
246        assert!(audit_id(0, &"n".repeat(32)).is_ok());
247        assert!(audit_id(0, &"n".repeat(33)).is_err());
248    }
249
250    #[test]
251    fn empty_components_are_rejected() {
252        assert_eq!(
253            binding_id(&Authenticator::Discord, ""),
254            Err(FleetKeyError::EmptyComponent("subject"))
255        );
256        assert_eq!(
257            binding_id(&oidc(""), "s"),
258            Err(FleetKeyError::EmptyComponent("issuer"))
259        );
260        assert_eq!(
261            channel_id("", "t", "c"),
262            Err(FleetKeyError::EmptyComponent("provider"))
263        );
264        assert_eq!(
265            channel_id("discord", "", "c"),
266            Err(FleetKeyError::EmptyComponent("tenant"))
267        );
268        assert_eq!(
269            channel_id("discord", "t", ""),
270            Err(FleetKeyError::EmptyComponent("channel id"))
271        );
272        assert_eq!(
273            grant_id("p1", &GrantScope::Workspace(String::new())),
274            Err(FleetKeyError::EmptyComponent("workspace"))
275        );
276    }
277}