1use serde::{Deserialize, Serialize};
24
25#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
31#[non_exhaustive]
32#[serde(rename_all = "snake_case")]
33pub enum PrincipalKind {
34 User,
35 Service,
36 Camp,
37}
38
39impl PrincipalKind {
40 pub const fn prefix(self) -> &'static str {
42 match self {
43 Self::User => "user",
44 Self::Service => "svc",
45 Self::Camp => "camp",
46 }
47 }
48
49 pub fn from_prefix(prefix: &str) -> Option<Self> {
52 Some(match prefix {
53 "user" => Self::User,
54 "svc" => Self::Service,
55 "camp" => Self::Camp,
56 _ => return None,
57 })
58 }
59}
60
61impl std::fmt::Display for PrincipalKind {
62 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
63 f.write_str(self.prefix())
64 }
65}
66
67#[derive(Debug, Clone, PartialEq, Eq, Hash)]
74pub struct PrincipalId {
75 pub kind: PrincipalKind,
76 pub id: String,
77}
78
79impl PrincipalId {
80 pub fn new(kind: PrincipalKind, id: impl Into<String>) -> Self {
81 Self {
82 kind,
83 id: id.into(),
84 }
85 }
86
87 pub fn user(id: impl Into<String>) -> Self {
88 Self::new(PrincipalKind::User, id)
89 }
90
91 pub fn service(id: impl Into<String>) -> Self {
92 Self::new(PrincipalKind::Service, id)
93 }
94
95 pub fn camp(id: impl Into<String>) -> Self {
96 Self::new(PrincipalKind::Camp, id)
97 }
98}
99
100impl std::fmt::Display for PrincipalId {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 write!(f, "{}:{}", self.kind.prefix(), self.id)
103 }
104}
105
106#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
108pub enum PrincipalIdParseError {
109 #[error("sub claim must be prefixed 'user:<id>', 'svc:<id>', or 'camp:<id>' — got '{0}'")]
112 MissingPrefix(String),
113 #[error("unknown principal kind prefix '{0}' — expected user, svc, or camp")]
115 UnknownPrefix(String),
116 #[error("empty principal id after prefix")]
118 EmptyId,
119}
120
121impl std::str::FromStr for PrincipalId {
122 type Err = PrincipalIdParseError;
123
124 fn from_str(s: &str) -> Result<Self, Self::Err> {
125 let (prefix, id) = s
126 .split_once(':')
127 .ok_or_else(|| PrincipalIdParseError::MissingPrefix(s.to_owned()))?;
128 let kind = PrincipalKind::from_prefix(prefix)
129 .ok_or_else(|| PrincipalIdParseError::UnknownPrefix(prefix.to_owned()))?;
130 if id.is_empty() {
131 return Err(PrincipalIdParseError::EmptyId);
132 }
133 Ok(Self {
134 kind,
135 id: id.to_owned(),
136 })
137 }
138}
139
140impl Serialize for PrincipalId {
141 fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
142 ser.collect_str(self)
143 }
144}
145
146impl<'de> Deserialize<'de> for PrincipalId {
147 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
148 let s = String::deserialize(de)?;
149 s.parse().map_err(serde::de::Error::custom)
150 }
151}
152
153#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
155#[non_exhaustive]
156#[serde(rename_all = "snake_case")]
157pub enum PrincipalStatus {
158 Active,
159 Revoked,
160}
161
162#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
164pub enum PrincipalError {
165 #[error("camp principal must declare bound_to: Some(user:<id>); got None")]
168 CampMissingBoundTo,
169 #[error("camp principal's bound_to must be a user (got {0})")]
171 CampBoundToWrongKind(PrincipalKind),
172 #[error("{0} principal must not declare bound_to")]
174 NonCampHasBoundTo(PrincipalKind),
175}
176
177#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
187#[non_exhaustive]
188pub struct Principal {
189 pub id: PrincipalId,
190 #[serde(skip_serializing_if = "Option::is_none")]
191 pub bound_to: Option<PrincipalId>,
192 pub status: PrincipalStatus,
193 pub created_at: i64,
194}
195
196impl Principal {
197 pub fn try_new(
199 id: PrincipalId,
200 bound_to: Option<PrincipalId>,
201 status: PrincipalStatus,
202 created_at: i64,
203 ) -> Result<Self, PrincipalError> {
204 match (id.kind, &bound_to) {
205 (PrincipalKind::Camp, None) => Err(PrincipalError::CampMissingBoundTo),
206 (PrincipalKind::Camp, Some(b)) if b.kind != PrincipalKind::User => {
207 Err(PrincipalError::CampBoundToWrongKind(b.kind))
208 }
209 (PrincipalKind::User | PrincipalKind::Service, Some(_)) => {
210 Err(PrincipalError::NonCampHasBoundTo(id.kind))
211 }
212 _ => Ok(Self {
213 id,
214 bound_to,
215 status,
216 created_at,
217 }),
218 }
219 }
220
221 pub fn kind(&self) -> PrincipalKind {
223 self.id.kind
224 }
225}
226
227#[derive(Deserialize)]
228struct RawPrincipal {
229 id: PrincipalId,
230 #[serde(default)]
231 bound_to: Option<PrincipalId>,
232 status: PrincipalStatus,
233 created_at: i64,
234}
235
236impl<'de> Deserialize<'de> for Principal {
237 fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
238 let raw = RawPrincipal::deserialize(de)?;
239 Principal::try_new(raw.id, raw.bound_to, raw.status, raw.created_at)
240 .map_err(serde::de::Error::custom)
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[test]
249 fn principal_kind_prefix_roundtrip() {
250 for k in [
251 PrincipalKind::User,
252 PrincipalKind::Service,
253 PrincipalKind::Camp,
254 ] {
255 assert_eq!(PrincipalKind::from_prefix(k.prefix()), Some(k));
256 }
257 assert_eq!(PrincipalKind::from_prefix("agent"), None);
258 assert_eq!(PrincipalKind::from_prefix(""), None);
259 assert_eq!(PrincipalKind::from_prefix("service"), None);
261 }
262
263 #[test]
264 fn principal_id_display_and_parse() {
265 let cases = [
266 (PrincipalId::user("alice"), "user:alice"),
267 (PrincipalId::service("yubaba-1"), "svc:yubaba-1"),
268 (PrincipalId::camp("camp-xyz"), "camp:camp-xyz"),
269 ];
270 for (pid, wire) in cases {
271 assert_eq!(pid.to_string(), wire);
272 assert_eq!(wire.parse::<PrincipalId>().unwrap(), pid);
273 }
274 }
275
276 #[test]
277 fn principal_id_id_may_contain_colons() {
278 let parsed: PrincipalId = "user:tenant:42".parse().unwrap();
280 assert_eq!(parsed.kind, PrincipalKind::User);
281 assert_eq!(parsed.id, "tenant:42");
282 assert_eq!(parsed.to_string(), "user:tenant:42");
283 }
284
285 #[test]
286 fn principal_id_rejects_unprefixed() {
287 let err = "alice".parse::<PrincipalId>().unwrap_err();
288 assert!(matches!(err, PrincipalIdParseError::MissingPrefix(ref s) if s == "alice"));
289 }
290
291 #[test]
292 fn principal_id_rejects_unknown_prefix() {
293 let err = "service:abc".parse::<PrincipalId>().unwrap_err();
294 assert!(matches!(err, PrincipalIdParseError::UnknownPrefix(ref s) if s == "service"));
295
296 let err = "agent:claude".parse::<PrincipalId>().unwrap_err();
297 assert!(matches!(err, PrincipalIdParseError::UnknownPrefix(ref s) if s == "agent"));
298 }
299
300 #[test]
301 fn principal_id_rejects_empty_id() {
302 let err = "user:".parse::<PrincipalId>().unwrap_err();
303 assert_eq!(err, PrincipalIdParseError::EmptyId);
304 }
305
306 #[test]
307 fn principal_id_serde_is_transparent_string() {
308 let pid = PrincipalId::camp("c-1");
309 let json = serde_json::to_string(&pid).unwrap();
310 assert_eq!(json, "\"camp:c-1\"");
311 let back: PrincipalId = serde_json::from_str(&json).unwrap();
312 assert_eq!(back, pid);
313 }
314
315 #[test]
316 fn principal_id_deserialize_rejects_unprefixed_string() {
317 let err = serde_json::from_str::<PrincipalId>("\"alice\"").unwrap_err();
318 assert!(err.to_string().contains("must be prefixed"));
319 }
320
321 #[test]
322 fn try_new_camp_requires_bound_to() {
323 let err = Principal::try_new(
324 PrincipalId::camp("c-1"),
325 None,
326 PrincipalStatus::Active,
327 100,
328 )
329 .unwrap_err();
330 assert_eq!(err, PrincipalError::CampMissingBoundTo);
331 }
332
333 #[test]
334 fn try_new_camp_rejects_non_user_bound_to() {
335 let err = Principal::try_new(
336 PrincipalId::camp("c-1"),
337 Some(PrincipalId::service("yubaba")),
338 PrincipalStatus::Active,
339 100,
340 )
341 .unwrap_err();
342 assert_eq!(
343 err,
344 PrincipalError::CampBoundToWrongKind(PrincipalKind::Service)
345 );
346 }
347
348 #[test]
349 fn try_new_user_or_service_rejects_bound_to() {
350 let err = Principal::try_new(
351 PrincipalId::user("alice"),
352 Some(PrincipalId::user("bob")),
353 PrincipalStatus::Active,
354 100,
355 )
356 .unwrap_err();
357 assert_eq!(err, PrincipalError::NonCampHasBoundTo(PrincipalKind::User));
358
359 let err = Principal::try_new(
360 PrincipalId::service("yubaba"),
361 Some(PrincipalId::user("alice")),
362 PrincipalStatus::Active,
363 100,
364 )
365 .unwrap_err();
366 assert_eq!(
367 err,
368 PrincipalError::NonCampHasBoundTo(PrincipalKind::Service)
369 );
370 }
371
372 #[test]
373 fn try_new_accepts_valid_combinations() {
374 Principal::try_new(
375 PrincipalId::user("alice"),
376 None,
377 PrincipalStatus::Active,
378 100,
379 )
380 .unwrap();
381 Principal::try_new(
382 PrincipalId::service("yubaba"),
383 None,
384 PrincipalStatus::Active,
385 100,
386 )
387 .unwrap();
388 let camp = Principal::try_new(
389 PrincipalId::camp("c-1"),
390 Some(PrincipalId::user("alice")),
391 PrincipalStatus::Active,
392 100,
393 )
394 .unwrap();
395 assert_eq!(camp.kind(), PrincipalKind::Camp);
396 }
397
398 #[test]
399 fn camp_principal_roundtrips_json() {
400 let p = Principal::try_new(
401 PrincipalId::camp("c-1"),
402 Some(PrincipalId::user("alice")),
403 PrincipalStatus::Active,
404 12345,
405 )
406 .unwrap();
407 let json = serde_json::to_string(&p).unwrap();
408 assert!(json.contains("\"id\":\"camp:c-1\""));
410 assert!(json.contains("\"bound_to\":\"user:alice\""));
411 assert!(json.contains("\"status\":\"active\""));
412 let back: Principal = serde_json::from_str(&json).unwrap();
413 assert_eq!(back, p);
414 }
415
416 #[test]
417 fn user_principal_omits_bound_to_on_wire() {
418 let p = Principal::try_new(
419 PrincipalId::user("alice"),
420 None,
421 PrincipalStatus::Active,
422 12345,
423 )
424 .unwrap();
425 let json = serde_json::to_string(&p).unwrap();
426 assert!(!json.contains("bound_to"), "unset bound_to must be omitted: {json}");
427 let back: Principal = serde_json::from_str(&json).unwrap();
428 assert_eq!(back, p);
429 }
430
431 #[test]
432 fn deserializing_camp_without_bound_to_fails() {
433 let json = r#"{"id":"camp:c-1","status":"active","created_at":1}"#;
435 let err = serde_json::from_str::<Principal>(json).unwrap_err();
436 assert!(
437 err.to_string().contains("camp principal must declare bound_to"),
438 "wrong error: {err}"
439 );
440
441 let json = r#"{"id":"camp:c-1","bound_to":null,"status":"active","created_at":1}"#;
443 let err = serde_json::from_str::<Principal>(json).unwrap_err();
444 assert!(err.to_string().contains("camp principal must declare bound_to"));
445 }
446
447 #[test]
448 fn deserializing_user_with_bound_to_fails() {
449 let json = r#"{"id":"user:alice","bound_to":"user:bob","status":"active","created_at":1}"#;
450 let err = serde_json::from_str::<Principal>(json).unwrap_err();
451 assert!(
452 err.to_string().contains("must not declare bound_to"),
453 "wrong error: {err}"
454 );
455 }
456
457 #[test]
458 fn deserializing_camp_with_service_bound_to_fails() {
459 let json = r#"{"id":"camp:c-1","bound_to":"svc:yubaba","status":"active","created_at":1}"#;
460 let err = serde_json::from_str::<Principal>(json).unwrap_err();
461 assert!(
462 err.to_string().contains("bound_to must be a user"),
463 "wrong error: {err}"
464 );
465 }
466}