Skip to main content

agentd/a2a/
principals.rs

1// SPDX-License-Identifier: Apache-2.0
2//! **Principals, roles and authorization** (RFC 0029 §2): every A2A caller is
3//! resolved to a principal (identity from mTLS SAN / bearer subject / AAuth
4//! agent id) with a role (`operator | user | agent | anonymous`), a set of
5//! granted tool patterns, and optional per-principal quotas. The
6//! authorization matrix — which methods/commands a role may call — is decided
7//! here; the served surface calls [`Resolver::resolve`] then
8//! [`Principal::may`].
9
10use crate::config::v2::{self, Role};
11use crate::sec::secret;
12use serde_json::{Value, json};
13
14/// A resolved caller.
15#[derive(Debug, Clone, PartialEq)]
16pub struct Principal {
17    /// A stable id for logs/audit/context (`operator`, `user:<sub>`, `agent:<id>`).
18    pub id: String,
19    pub role: Role,
20    /// Explicit tool-name grants (patterns) beyond the role defaults.
21    pub grants: Vec<String>,
22    /// The rate quota (`"<burst>/<per>s"`) and budget scope key, if any.
23    pub rate: Option<String>,
24    pub budget: Option<v2::Budget>,
25}
26
27impl Principal {
28    pub fn anonymous() -> Principal {
29        Principal {
30            id: "anonymous".into(),
31            role: Role::Anonymous,
32            grants: Vec::new(),
33            rate: None,
34            budget: None,
35        }
36    }
37
38    pub fn is_operator(&self) -> bool {
39        self.role == Role::Operator
40    }
41    pub fn is_anonymous(&self) -> bool {
42        self.role == Role::Anonymous
43    }
44
45    /// The registry `Caller` this principal presents.
46    pub fn as_caller(&self) -> crate::registry::Caller<'_> {
47        crate::registry::Caller::Principal {
48            role: self.role,
49            grants: &self.grants,
50        }
51    }
52
53    /// May this principal invoke A2A method `method` (RFC 0029 §2 matrix)?
54    /// `op` is the command tool name for a command DataPart (else `None`).
55    pub fn may(&self, method: &str, op: Option<&str>) -> bool {
56        match self.role {
57            Role::Anonymous => false,
58            Role::Operator => true,
59            _ => match method {
60                // The read/task methods every non-anonymous role may use on its
61                // own conversations/tasks (ownership is enforced at the object).
62                // `SubscribeToEvents` (RFC 0032) is principal-scoped at the
63                // feed, so any non-anonymous role may attach.
64                "SendMessage"
65                | "SendStreamingMessage"
66                | "GetTask"
67                | "CancelTask"
68                | "ListTasks"
69                | "SubscribeToTask"
70                | "SubscribeToEvents"
71                // The push-notification family is scoped to the caller's own
72                // tasks the same way `GetTask` is — ownership is enforced at
73                // the task, so any named caller may manage webhooks on what it
74                // started.
75                | "CreateTaskPushNotificationConfig"
76                | "GetTaskPushNotificationConfig"
77                | "ListTaskPushNotificationConfigs"
78                | "DeleteTaskPushNotificationConfig"
79                // The extended card is the *authenticated* card: any named
80                // caller may read it, and it is scoped to what they may run.
81                | "GetExtendedAgentCard" => match op {
82                    None => true, // natural language / streaming
83                    Some(tool) => self.may_command(tool),
84                },
85                // Operator admin family is operator-only (handled by role above).
86                m if m.starts_with("a2a.") && is_admin(m) => false,
87                _ => false,
88            },
89        }
90    }
91
92    /// May this principal run command tool `tool`?
93    pub fn may_command(&self, tool: &str) -> bool {
94        if self.role == Role::Anonymous {
95            return false;
96        }
97        if tool == "status" || tool == "interface.info" {
98            // Always granted to non-anonymous roles (RFC 0029 §5 / RFC 0032 §5;
99            // the interface gate itself still applies at the op).
100            return true;
101        }
102        if self
103            .grants
104            .iter()
105            .any(|p| crate::registry::pattern_matches(p, tool))
106        {
107            return true;
108        }
109        // Role defaults (RFC 0029 §2 "command ops"). The debug reads
110        // (`conversation.get`/`run.get`) are owner-scoped at the object; the
111        // log ring (`debug.events`) stays operator-only.
112        match self.role {
113            Role::Operator => true,
114            Role::User => matches!(
115                tool,
116                "workflow.run"
117                    | "workflow.status"
118                    | "workflow.cancel"
119                    | "subagent.send"
120                    | "subagent.status"
121                    | "plan.get"
122                    | "ask_human"
123                    | "conversation.get"
124                    | "run.get"
125            ),
126            Role::Agent => matches!(tool, "workflow.run" | "workflow.status"),
127            Role::Anonymous => false,
128        }
129    }
130
131    pub fn scope_key(&self) -> String {
132        format!("principal:{}", self.id)
133    }
134}
135
136/// The admin methods (operator-only).
137pub fn is_admin(method: &str) -> bool {
138    matches!(
139        bare(method).as_str(),
140        "a2a.drain"
141            | "a2a.lameduck"
142            | "a2a.pause"
143            | "a2a.resume"
144            | "a2a.cancel"
145            | "drain"
146            | "lameduck"
147            | "pause"
148            | "resume"
149            | "cancel"
150    )
151}
152
153/// The method name folded for matching, owned.
154///
155/// Owned rather than `&'static str` because the only way to hand a lowercased
156/// copy back as `'static` is `String::leak`, and `m` is the `method` member of
157/// a JSON-RPC request: remote input, unbounded in length, and reached *before*
158/// the caller is known to be anybody — an `Authorization: Bearer junk` header
159/// resolves to the anonymous principal rather than a 401, and every request
160/// passes through [`is_admin`] on its way to being refused. One leak per
161/// request with an attacker-chosen name is an unbounded RSS climb driven from
162/// off the box, so nothing here may outlive the call.
163fn bare(m: &str) -> String {
164    m.strip_prefix("a2a.")
165        .map(|_| m)
166        .unwrap_or(m)
167        .to_ascii_lowercase()
168}
169
170/// What the transport learned about the caller.
171#[derive(Debug, Clone, Default)]
172pub struct CallerIdentity {
173    /// The verified client-cert subject/SANs (mTLS).
174    pub sans: Vec<String>,
175    pub subject: Option<String>,
176    /// A verified bearer subject (post token check), if the transport resolved it.
177    pub bearer_ref: Option<String>,
178    /// The verified AAuth agent id (roadmap).
179    pub aauth_agent: Option<String>,
180    /// Whether the connection is loopback (dev operator default).
181    pub loopback: bool,
182    /// Whether the framework already authenticated the peer as management
183    /// (a verified client cert / matched bearer).
184    pub management: bool,
185}
186
187/// Resolves a caller to a principal from the configured `a2a.principals`.
188pub struct Resolver {
189    principals: Vec<Compiled>,
190    /// A bearer whose match resolves to the operator, when `a2a.bearer` is set
191    /// and no principal claims it (the loopback/single-operator default).
192    default_operator_on_bearer: bool,
193    /// Loopback with no principals ⇒ operator (the 1.x default).
194    loopback_operator: bool,
195}
196
197struct Compiled {
198    matcher: v2::PrincipalMatch,
199    role: Role,
200    grants: Vec<String>,
201    rate: Option<String>,
202    budget: Option<v2::Budget>,
203    bearer_secret: Option<String>,
204}
205
206impl Resolver {
207    /// Build from settings, resolving `bearer_ref` secrets at startup.
208    pub fn build(a2a: &v2::A2a, env: &dyn Fn(&str) -> Option<String>) -> Result<Resolver, String> {
209        let mut principals = Vec::new();
210        for p in &a2a.principals {
211            let bearer_secret = match &p.matcher.bearer_ref {
212                Some(r) => Some(
213                    secret::resolve(r, env)
214                        .map_err(|e| format!("a2a principal bearer_ref: {e}"))?,
215                ),
216                None => None,
217            };
218            principals.push(Compiled {
219                matcher: p.matcher.clone(),
220                role: p.role,
221                grants: p.grants.clone(),
222                rate: p.quotas.as_ref().and_then(|q| q.rate.clone()),
223                budget: p.quotas.as_ref().and_then(|q| q.budget.clone()),
224                bearer_secret,
225            });
226        }
227        Ok(Resolver {
228            principals,
229            default_operator_on_bearer: a2a.bearer.is_some(),
230            loopback_operator: a2a.principals.is_empty(),
231        })
232    }
233
234    /// Resolve a caller. Matching order: explicit principal rules (first match),
235    /// then the operator defaults (verified management / loopback), then
236    /// anonymous.
237    pub fn resolve(&self, id: &CallerIdentity, presented_bearer: Option<&str>) -> Principal {
238        for c in &self.principals {
239            if let Some(p) = c.matches(id, presented_bearer) {
240                return p;
241            }
242        }
243        // A configured `a2a.bearer` (server bearer) that the transport matched
244        // ⇒ operator, unless a principal already claimed the connection.
245        if id.management && (self.default_operator_on_bearer || self.loopback_operator) {
246            return operator();
247        }
248        if id.loopback && self.loopback_operator {
249            return operator();
250        }
251        Principal::anonymous()
252    }
253
254    /// A status view of the configured principals.
255    pub fn status(&self) -> Value {
256        json!({
257            "principals": self.principals.iter().map(|c| json!({"role": format!("{:?}", c.role).to_lowercase(), "match": matcher_desc(&c.matcher), "grants": c.grants})).collect::<Vec<_>>(),
258            "loopback_operator": self.loopback_operator,
259        })
260    }
261}
262
263impl Compiled {
264    fn matches(&self, id: &CallerIdentity, presented_bearer: Option<&str>) -> Option<Principal> {
265        let m = &self.matcher;
266        let hit = if m.any {
267            true
268        } else if let Some(san) = &m.san {
269            id.sans.iter().any(|s| glob(san, s))
270                || id.subject.as_deref().is_some_and(|s| glob(san, s))
271        } else if let Some(sub) = &m.sub {
272            id.subject.as_deref().is_some_and(|s| s == sub)
273                || id.bearer_ref.as_deref().is_some_and(|b| b == sub)
274        } else if m.bearer_ref.is_some() {
275            match (&self.bearer_secret, presented_bearer) {
276                (Some(secret), Some(got)) => ct_eq(secret.as_bytes(), got.as_bytes()),
277                _ => false,
278            }
279        } else if let Some(agent) = &m.aauth_agent {
280            id.aauth_agent.as_deref().is_some_and(|a| glob(agent, a))
281        } else {
282            false
283        };
284        if !hit {
285            return None;
286        }
287        let pid = principal_id(self.role, id, m);
288        Some(Principal {
289            id: pid,
290            role: self.role,
291            grants: self.grants.clone(),
292            rate: self.rate.clone(),
293            budget: self.budget.clone(),
294        })
295    }
296}
297
298fn operator() -> Principal {
299    Principal {
300        id: "operator".into(),
301        role: Role::Operator,
302        grants: vec!["*".into()],
303        rate: None,
304        budget: None,
305    }
306}
307
308fn principal_id(role: Role, id: &CallerIdentity, m: &v2::PrincipalMatch) -> String {
309    let sub = id
310        .subject
311        .clone()
312        .or_else(|| id.sans.first().cloned())
313        .or_else(|| id.bearer_ref.clone())
314        .or_else(|| id.aauth_agent.clone())
315        .or_else(|| m.sub.clone())
316        .unwrap_or_else(|| "unknown".into());
317    match role {
318        Role::Operator => "operator".into(),
319        Role::User => format!("user:{sub}"),
320        Role::Agent => format!("agent:{sub}"),
321        Role::Anonymous => "anonymous".into(),
322    }
323}
324
325fn matcher_desc(m: &v2::PrincipalMatch) -> Value {
326    if m.any {
327        json!({"any": true})
328    } else if let Some(s) = &m.san {
329        json!({"san": s})
330    } else if let Some(s) = &m.sub {
331        json!({"sub": s})
332    } else if m.bearer_ref.is_some() {
333        json!({"bearer_ref": "***"})
334    } else if let Some(a) = &m.aauth_agent {
335        json!({"aauth_agent": a})
336    } else {
337        json!({})
338    }
339}
340
341/// A `*`-glob match (`*` = any run of chars; else literal).
342fn glob(pattern: &str, s: &str) -> bool {
343    if pattern == "*" {
344        return true;
345    }
346    if let Some(pos) = pattern.find('*') {
347        let (pre, post) = (&pattern[..pos], &pattern[pos + 1..]);
348        return s.starts_with(pre) && s.ends_with(post) && s.len() >= pre.len() + post.len();
349    }
350    pattern == s
351}
352
353/// Constant-time byte compare.
354fn ct_eq(a: &[u8], b: &[u8]) -> bool {
355    if a.len() != b.len() {
356        return false;
357    }
358    let mut d = 0u8;
359    for (x, y) in a.iter().zip(b.iter()) {
360        d |= x ^ y;
361    }
362    d == 0
363}
364
365#[cfg(test)]
366mod tests {
367    use super::*;
368    use serde_json::json;
369
370    fn a2a(doc: Value) -> v2::A2a {
371        serde_json::from_value(doc).unwrap()
372    }
373    fn ident(sans: &[&str], sub: Option<&str>, mgmt: bool, loopback: bool) -> CallerIdentity {
374        CallerIdentity {
375            sans: sans.iter().map(|s| s.to_string()).collect(),
376            subject: sub.map(str::to_string),
377            management: mgmt,
378            loopback,
379            ..Default::default()
380        }
381    }
382
383    #[test]
384    fn resolves_roles_and_enforces_the_matrix() {
385        let r = Resolver::build(
386            &a2a(json!({
387                "principals": [
388                    {"match": {"san": "spiffe://ops/*"}, "role": "operator"},
389                    {"match": {"san": "spiffe://team/*"}, "role": "user", "grants": ["knowledge.*"]},
390                    {"match": {"bearer_ref": "{{secret:PEER}}"}, "role": "agent"},
391                    {"match": {"any": true}, "role": "anonymous"}
392                ]
393            })),
394            &|k| (k == "PEER").then(|| "s3cr3t".to_string()),
395        )
396        .unwrap();
397        let op = r.resolve(&ident(&["spiffe://ops/admin"], None, true, false), None);
398        assert!(op.is_operator());
399        assert!(op.may("SendMessage", Some("a2a.Drain")) || op.may_command("workflow.delete"));
400        let user = r.resolve(&ident(&["spiffe://team/alice"], None, true, false), None);
401        assert_eq!(user.role, Role::User);
402        assert_eq!(user.id, "user:spiffe://team/alice");
403        assert!(user.may("SendMessage", None), "NL is allowed");
404        assert!(
405            user.may_command("status")
406                && user.may_command("workflow.run")
407                && user.may_command("knowledge.search")
408        );
409        assert!(
410            !user.may_command("workflow.delete"),
411            "not granted to a user"
412        );
413        assert!(!user.may("a2a.Drain", None), "admin is operator-only");
414        let agent = r.resolve(&ident(&[], None, false, false), Some("s3cr3t"));
415        assert_eq!(agent.role, Role::Agent);
416        assert!(agent.may_command("workflow.run") && !agent.may_command("subagent.send"));
417        assert!(
418            r.resolve(&ident(&[], None, false, false), Some("wrong"))
419                .is_anonymous()
420        );
421        let anon = r.resolve(&ident(&["spiffe://other/x"], None, false, false), None);
422        assert!(anon.is_anonymous());
423        assert!(!anon.may("SendMessage", None) && !anon.may_command("status"));
424    }
425
426    #[test]
427    fn loopback_and_bearer_defaults() {
428        // No principals + loopback ⇒ operator (the 1.x default).
429        let r = Resolver::build(&a2a(json!({})), &|_| None).unwrap();
430        assert!(r.resolve(&ident(&[], None, true, true), None).is_operator());
431        assert!(
432            r.resolve(&ident(&[], None, false, false), None)
433                .is_anonymous(),
434            "non-loopback without a match is anonymous"
435        );
436        // A server bearer the transport matched ⇒ operator.
437        let r = Resolver::build(&a2a(json!({"bearer": "{{secret:B}}"})), &|k| {
438            (k == "B").then(|| "t".to_string())
439        })
440        .unwrap();
441        assert!(
442            r.resolve(&ident(&[], None, true, false), None)
443                .is_operator()
444        );
445        assert!(glob("a*c", "abc") && glob("*", "x") && !glob("a*c", "abx"));
446    }
447}