1use super::{Authenticator, GrantScope};
7use std::fmt;
8
9#[derive(Debug, Clone, PartialEq, Eq)]
11pub enum FleetKeyError {
12 InvalidPersonId(String),
14 InvalidNonce(String),
16 NegativeTimestamp(i64),
18 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
99pub(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
112pub(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
118pub(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
137pub(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}