Skip to main content

cheers_core/
principal.rs

1//! Principal kinds and identifier types for MCP authentication.
2//!
3//! Cheers's session contract has one principal kind (`User`); MCP auth needs
4//! three — see `.yah/docs/working/mcp-auth-and-ownership.md` §Principal kinds.
5//! [`PrincipalKind`] enumerates them; [`PrincipalId`] is the typed `sub`-claim
6//! shape carried on the wire (`user:<id>`, `svc:<id>`, `camp:<id>`);
7//! [`Principal`] is the durable record stored in cheers's principal table.
8//!
9//! The wire-form parser [`PrincipalId::from_str`] **rejects unprefixed input**
10//! at parse time — the prefix is the discriminator, and an MCP token whose
11//! `sub` is bare (legacy session shape) must not be confused for a user
12//! principal silently.
13//!
14//! [`Principal`]'s deserializer enforces the invariants the doc calls out:
15//!
16//! - `Camp` principals MUST set `bound_to: Some(user:<U>)`.
17//! - `User` and `Service` principals MUST set `bound_to: None`.
18//! - A `Camp` whose `bound_to` names a non-user principal is a parse error.
19//!
20//! Use [`Principal::try_new`] to construct from owned parts; the validation
21//! path is the same.
22
23use serde::{Deserialize, Serialize};
24
25/// The three principal kinds MCP auth recognises.
26///
27/// See `.yah/docs/working/mcp-auth-and-ownership.md` §Principal kinds for the
28/// audit-trail / revocation-cascade / grant-constraint reasoning behind the
29/// split.
30#[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    /// The wire prefix used in `sub` claims: `user`, `svc`, `camp`.
41    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    /// Inverse of [`prefix`](Self::prefix). Returns `None` for any other
50    /// string — the parser uses this to reject unknown discriminators.
51    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/// Typed `sub`-claim shape — a principal kind paired with an opaque id.
68///
69/// Wire format is the single string `<prefix>:<id>` (e.g. `user:alice`,
70/// `svc:yubaba-1`, `camp:abc`). [`Serialize`] writes that string;
71/// [`Deserialize`] / [`FromStr`](std::str::FromStr) parse it back and reject
72/// unprefixed input.
73#[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/// Why a `sub`-claim string failed to parse into a [`PrincipalId`].
107#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
108pub enum PrincipalIdParseError {
109    /// No `:` separator — an unprefixed `sub` (the legacy session shape) is
110    /// rejected so it cannot be mistaken for a `user:` principal.
111    #[error("sub claim must be prefixed 'user:<id>', 'svc:<id>', or 'camp:<id>' — got '{0}'")]
112    MissingPrefix(String),
113    /// Prefix isn't one of `user`, `svc`, `camp`.
114    #[error("unknown principal kind prefix '{0}' — expected user, svc, or camp")]
115    UnknownPrefix(String),
116    /// Empty id after the prefix (`"user:"`).
117    #[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/// Lifecycle state of a [`Principal`].
154#[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/// Invariant violations a [`Principal`] constructor / deserializer rejects.
163#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
164pub enum PrincipalError {
165    /// `Camp` requires `bound_to: Some(user:<id>)` (the user the camp was
166    /// bootstrapped on behalf of).
167    #[error("camp principal must declare bound_to: Some(user:<id>); got None")]
168    CampMissingBoundTo,
169    /// `Camp.bound_to` must name a user, not a service or another camp.
170    #[error("camp principal's bound_to must be a user (got {0})")]
171    CampBoundToWrongKind(PrincipalKind),
172    /// `User` and `Service` principals are not bound to anything.
173    #[error("{0} principal must not declare bound_to")]
174    NonCampHasBoundTo(PrincipalKind),
175}
176
177/// The durable principal record stored in cheers's principal table.
178///
179/// Invariants (enforced by [`Principal::try_new`] and the [`Deserialize`] impl):
180///
181/// - `kind == Camp` ⇒ `bound_to == Some(PrincipalId { kind: User, .. })`
182/// - `kind ∈ {User, Service}` ⇒ `bound_to == None`
183///
184/// A `Camp { bound_to: None }` JSON payload is a parse error — see the
185/// roundtrip test in this module.
186#[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    /// Build a `Principal` and check the kind ↔ `bound_to` invariants.
198    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    /// Convenience accessor — same as `self.id.kind`.
222    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        // The legacy long form must NOT match: prefix is `svc`, not `service`.
260        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        // Only the *first* `:` is the separator — opaque ids may contain more.
279        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        // PrincipalIds serialize as plain strings.
409        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        // Bare camp record with no bound_to is a parse error.
434        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        // Explicit null is rejected too.
442        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}