agentguard_core/
principal.rs

1//! Principals in agentguard: human `User`s and AI `Agent`s.
2//!
3//! Agents may have a parent (for sub-agent delegation chains): a `summarizer`
4//! agent's parent might be a `research` agent, whose parent is a `User`.
5
6use indexmap::IndexMap;
7use serde::{Deserialize, Serialize};
8use std::fmt;
9
10use crate::ids::PrincipalId;
11
12/// A principal is either a `User` (human) or an `Agent` (AI). Both become
13/// Cedar entities of type `User` or `Agent`.
14///
15/// # Construction
16/// Use [`Principal::user`], [`Principal::agent`], [`Principal::subagent`],
17/// or [`Principal::with_attr`] — never construct the enum variants
18/// directly. The variant fields are public only because `serde`
19/// deserializes into them; treating them as a stable public API is
20/// unsupported. `#[non_exhaustive]` keeps external matches safe.
21#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
22#[serde(tag = "type", rename_all = "snake_case")]
23#[non_exhaustive]
24pub enum Principal {
25    /// A human user.
26    ///
27    /// Invariant: `parent_uid` is meaningless here; Cedar policies
28    /// should not branch on a `User`'s parent.
29    User {
30        uid: PrincipalId,
31        #[serde(default)]
32        attrs: IndexMap<String, serde_json::Value>,
33    },
34    /// An AI agent, optionally with a parent (sub-agent pattern).
35    Agent {
36        uid: PrincipalId,
37        #[serde(default, skip_serializing_if = "Option::is_none")]
38        parent_uid: Option<PrincipalId>,
39        #[serde(default)]
40        attrs: IndexMap<String, serde_json::Value>,
41    },
42}
43
44impl Principal {
45    /// Construct a `User` principal with the given UID.
46    ///
47    /// # Examples
48    ///
49    /// ```
50    /// use agentguard_core::Principal;
51    /// let p = Principal::user("alice");
52    /// assert_eq!(p.entity_uid(), "User::\"alice\"");
53    /// ```
54    pub fn user(uid: impl Into<PrincipalId>) -> Self {
55        Principal::User {
56            uid: uid.into(),
57            attrs: IndexMap::new(),
58        }
59    }
60
61    /// Construct an `Agent` principal with the given UID and no parent.
62    ///
63    /// # Examples
64    ///
65    /// ```
66    /// use agentguard_core::Principal;
67    /// let p = Principal::agent("email-bot");
68    /// assert_eq!(p.entity_type(), "Agent");
69    /// ```
70    pub fn agent(uid: impl Into<PrincipalId>) -> Self {
71        Principal::Agent {
72            uid: uid.into(),
73            parent_uid: None,
74            attrs: IndexMap::new(),
75        }
76    }
77
78    /// Construct a sub-agent whose parent is another agent.
79    ///
80    /// # Examples
81    ///
82    /// ```
83    /// use agentguard_core::Principal;
84    /// let p = Principal::subagent("summarizer", "research");
85    /// assert_eq!(p.entity_uid(), "Agent::\"summarizer\"");
86    /// ```
87    pub fn subagent(uid: impl Into<PrincipalId>, parent: impl Into<PrincipalId>) -> Self {
88        Principal::Agent {
89            uid: uid.into(),
90            parent_uid: Some(parent.into()),
91            attrs: IndexMap::new(),
92        }
93    }
94
95    /// Add an attribute and return `self` for chaining.
96    pub fn with_attr(mut self, k: impl Into<String>, v: impl Into<serde_json::Value>) -> Self {
97        let map = match &mut self {
98            Principal::User { attrs, .. } => attrs,
99            Principal::Agent { attrs, .. } => attrs,
100        };
101        map.insert(k.into(), v.into());
102        self
103    }
104
105    /// Cedar entity type name (`User` or `Agent`).
106    pub fn entity_type(&self) -> &'static str {
107        match self {
108            Principal::User { .. } => "User",
109            Principal::Agent { .. } => "Agent",
110        }
111    }
112
113    /// Full Cedar entity UID like `User::"alice"`.
114    ///
115    /// Prefer the [`Display`](std::fmt::Display) impl for formatting into
116    /// an existing buffer (avoids a `String` allocation on the hot path).
117    /// This method is retained for callers that genuinely need an owned
118    /// `String` (e.g. serialization to JSON via `serde_json::json!`).
119    #[deprecated(
120        since = "0.2.1",
121        note = "Use the Display impl to write into an existing buffer (no allocation)."
122    )]
123    pub fn entity_uid(&self) -> String {
124        format!("{}::\"{}\"", self.entity_type(), self.id())
125    }
126
127    /// The principal's UID without the type prefix.
128    pub fn id(&self) -> &PrincipalId {
129        match self {
130            Principal::User { uid, .. } | Principal::Agent { uid, .. } => uid,
131        }
132    }
133}
134
135impl fmt::Display for Principal {
136    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
137        write!(f, "{}::\"{}\"", self.entity_type(), self.id())
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn user_principal_entity_uid() {
147        let p = Principal::user("alice").with_attr("role", "admin");
148        assert_eq!(p.entity_type(), "User");
149        assert_eq!(format!("{}", p), "User::\"alice\"");
150    }
151
152    #[test]
153    fn subagent_has_parent() {
154        let p = Principal::subagent("summarizer", "research");
155        assert_eq!(p.entity_type(), "Agent");
156        assert_eq!(format!("{}", p), "Agent::\"summarizer\"");
157        assert_eq!(p.id(), &PrincipalId::from("summarizer"));
158    }
159
160    #[test]
161    fn agent_no_parent_omits_parent_uid() {
162        let p = Principal::agent("solo");
163        assert_eq!(p.entity_type(), "Agent");
164        assert_eq!(p.id(), &PrincipalId::from("solo"));
165        // `parent_uid` is `skip_serializing_if = "Option::is_none"`, so a
166        // parentless agent must not emit the field at all (avoids ambiguity
167        // between `null` and absent on the deserializing side).
168        let json = serde_json::to_value(&p).unwrap();
169        assert!(
170            json.get("parent_uid").is_none(),
171            "parent_uid leaked: {json}"
172        );
173    }
174
175    #[test]
176    fn with_attr_supports_multiple_attrs() {
177        let p = Principal::user("alice")
178            .with_attr("role", "admin")
179            .with_attr("tenant", "acme-corp")
180            .with_attr("level", "5");
181        let json = serde_json::to_value(&p).unwrap();
182        assert_eq!(json["attrs"]["role"], "admin");
183        assert_eq!(json["attrs"]["tenant"], "acme-corp");
184        assert_eq!(json["attrs"]["level"], "5");
185    }
186
187    #[test]
188    fn principal_serde_round_trips() {
189        let p = Principal::subagent("research-bot", "admin");
190        let json = serde_json::to_string(&p).unwrap();
191        let parsed: Principal = serde_json::from_str(&json).unwrap();
192        assert_eq!(parsed, p);
193    }
194
195    #[test]
196    fn user_and_agent_with_same_id_differ() {
197        // Two principals with the same id but different variants are
198        // not equal because their entity_type differs.
199        let u = Principal::user("alice");
200        let a = Principal::agent("alice");
201        assert_ne!(u, a);
202        assert_eq!(u.entity_type(), "User");
203        assert_eq!(a.entity_type(), "Agent");
204    }
205
206    #[test]
207    fn user_display_uses_entity_uid() {
208        let p = Principal::user("alice");
209        assert_eq!(format!("{}", p), "User::\"alice\"");
210    }
211
212    #[test]
213    fn subagent_display_uses_entity_uid() {
214        let p = Principal::subagent("summarizer", "research");
215        assert_eq!(format!("{}", p), "Agent::\"summarizer\"");
216    }
217}