Skip to main content

agentd/a2a/
principals.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! **Principals, roles and authorization**: every A2A caller is resolved to a
3//! principal (identity from mTLS SAN / bearer subject / AAuth agent id) with a
4//! role (`operator | user | agent | anonymous`), a set of granted tool
5//! patterns, and optional per-principal quotas. The authorization matrix —
6//! which methods and commands a role may call — is decided here; the served
7//! surface calls [`Resolver::resolve`] then [`Principal::may`]. Anything that
8//! does not match a rule lands on the anonymous principal, which may call
9//! nothing, so a caller agentd cannot identify gets no surface at all.
10
11use crate::config::v2::{self, Role};
12use crate::sec::secret;
13use serde_json::{Value, json};
14
15/// A resolved caller.
16#[derive(Debug, Clone, PartialEq)]
17pub struct Principal {
18    /// A stable id for logs/audit/context (`operator`, `user:<sub>`, `agent:<id>`).
19    pub id: String,
20    pub role: Role,
21    /// Explicit tool-name grants (patterns) beyond the role defaults.
22    pub grants: Vec<String>,
23    /// The rate quota (`"<burst>/<per>s"`) and budget scope key, if any.
24    pub rate: Option<String>,
25    pub budget: Option<v2::Budget>,
26    /// Operator-declared attributes that travel with everything this
27    /// principal causes (the run, the MCP `_meta`, the audit line).
28    pub labels: std::collections::BTreeMap<String, String>,
29}
30
31impl Principal {
32    pub fn anonymous() -> Principal {
33        Principal {
34            id: "anonymous".into(),
35            role: Role::Anonymous,
36            grants: Vec::new(),
37            rate: None,
38            budget: None,
39            labels: Default::default(),
40        }
41    }
42
43    pub fn is_operator(&self) -> bool {
44        self.role == Role::Operator
45    }
46    pub fn is_anonymous(&self) -> bool {
47        self.role == Role::Anonymous
48    }
49
50    /// May this principal invoke A2A method `method`?
51    /// `op` is the command tool name for a command DataPart (else `None`).
52    ///
53    /// The matrix is deny-by-default: anonymous callers get nothing, operators
54    /// get everything, and every other role is limited to the named read/task
55    /// methods below — an unrecognised method falls through to `false`.
56    pub fn may(&self, method: &str, op: Option<&str>) -> bool {
57        match self.role {
58            Role::Anonymous => false,
59            Role::Operator => true,
60            _ => match method {
61                // The read/task methods every non-anonymous role may use on its
62                // own conversations/tasks (ownership is enforced at the object).
63                // `SubscribeToEvents` is principal-scoped at the feed itself,
64                // so any non-anonymous role may attach and will only see the
65                // frames belonging to it.
66                "SendMessage"
67                | "SendStreamingMessage"
68                | "GetTask"
69                | "CancelTask"
70                | "ListTasks"
71                | "SubscribeToTask"
72                | "SubscribeToEvents"
73                // The push-notification family is scoped to the caller's own
74                // tasks the same way `GetTask` is — ownership is enforced at
75                // the task, so any named caller may manage webhooks on what it
76                // started.
77                | "CreateTaskPushNotificationConfig"
78                | "GetTaskPushNotificationConfig"
79                | "ListTaskPushNotificationConfigs"
80                | "DeleteTaskPushNotificationConfig"
81                // The extended card is the *authenticated* card: any named
82                // caller may read it, and it is scoped to what they may run.
83                | "GetExtendedAgentCard" => match op {
84                    None => true, // natural language / streaming
85                    Some(tool) => self.may_command(tool),
86                },
87                // Operator admin family is operator-only (handled by role above).
88                m if m.starts_with("a2a.") && is_admin(m) => false,
89                _ => false,
90            },
91        }
92    }
93
94    /// May this principal run command tool `tool`?
95    pub fn may_command(&self, tool: &str) -> bool {
96        if self.role == Role::Anonymous {
97            return false;
98        }
99        if tool == "status" || tool == "interface.info" {
100            // Liveness and capability discovery: a named caller must be able to
101            // learn whether agentd is up and what interface it offers before it
102            // can ask for anything else, so these need no grant. Neither leaks
103            // work product, and the interface gate still applies at the op.
104            return true;
105        }
106        if self
107            .grants
108            .iter()
109            .any(|p| crate::registry::pattern_matches(p, tool))
110        {
111            return true;
112        }
113        // Role defaults for command ops. The debug reads
114        // (`conversation.get`/`run.get`) are owner-scoped at the object; the
115        // log ring (`debug.events`) stays operator-only, because it spans every
116        // principal's activity and cannot be scoped to the caller.
117        match self.role {
118            Role::Operator => true,
119            Role::User => matches!(
120                tool,
121                "workflow.run"
122                    | "workflow.status"
123                    | "workflow.cancel"
124                    | "subagent.send"
125                    | "subagent.status"
126                    | "plan.get"
127                    | "ask_human"
128                    | "conversation.get"
129                    | "run.get"
130            ),
131            Role::Agent => matches!(tool, "workflow.run" | "workflow.status"),
132            Role::Anonymous => false,
133        }
134    }
135
136    /// The governor scope this principal's spend is charged to.
137    pub fn scope_key(&self) -> String {
138        scope_key_for(&self.id)
139    }
140}
141
142/// **Who must answer a gate.** An `ask_human`/`human` `to:` declaration.
143///
144/// An addressee is what makes a gate's record TRUE: "the finance lead
145/// approved this refund" is only worth writing down if someone else could not
146/// have satisfied it. Every declared condition must hold, so adding one always
147/// narrows — the same direction as `security.policies` matching, and for the
148/// same reason: an operator tightening a gate should never widen it by
149/// accident.
150///
151/// Declared either as a bare principal-id glob:
152///
153/// ```yaml
154/// to: "*@finance.acme.example"
155/// ```
156///
157/// or structurally, when identity is better described than enumerated:
158///
159/// ```yaml
160/// to: {role: user, labels: {team: finance}}
161/// ```
162///
163/// Labels are the durable form — people change, teams do not — and they come
164/// from `a2a.principals[].labels`, which is operator-declared and closed.
165#[derive(Debug, Clone, Default, PartialEq)]
166pub struct Addressee {
167    /// Principal-id glob (`*` suffix, or exact).
168    pub id: Option<String>,
169    pub role: Option<Role>,
170    /// Every pair must be present on the principal with that value.
171    pub labels: std::collections::BTreeMap<String, String>,
172}
173
174impl Addressee {
175    /// Parse a `to:` declaration. A string is an id glob; an object names any
176    /// of `id`, `role`, `labels`.
177    pub fn parse(v: &Value) -> Result<Addressee, String> {
178        match v {
179            Value::String(s) if !s.trim().is_empty() => Ok(Addressee {
180                id: Some(s.trim().to_string()),
181                ..Default::default()
182            }),
183            Value::String(_) => Err("`to` must not be empty".into()),
184            Value::Object(o) => {
185                for k in o.keys() {
186                    if !["id", "role", "labels"].contains(&k.as_str()) {
187                        return Err(format!("unknown `to` field {k:?} (want id|role|labels)"));
188                    }
189                }
190                let role = match o.get("role").and_then(Value::as_str) {
191                    None => None,
192                    Some("operator") => Some(Role::Operator),
193                    Some("user") => Some(Role::User),
194                    Some("agent") => Some(Role::Agent),
195                    // Addressing anonymous is not a mistake worth allowing: an
196                    // anonymous caller is precisely the one whose identity
197                    // nothing vouches for, so a gate "answered by anonymous"
198                    // records nothing at all.
199                    Some("anonymous") => {
200                        return Err("`to.role: anonymous` names nobody — a gate answered by an                                     unidentified caller records nothing"
201                            .into());
202                    }
203                    Some(other) => {
204                        return Err(format!(
205                            "unknown `to.role` {other:?} (want operator|user|agent)"
206                        ));
207                    }
208                };
209                let mut labels = std::collections::BTreeMap::new();
210                if let Some(m) = o.get("labels") {
211                    let Some(m) = m.as_object() else {
212                        return Err("`to.labels` must be an object of string values".into());
213                    };
214                    for (k, v) in m {
215                        let Some(v) = v.as_str() else {
216                            return Err(format!("`to.labels.{k}` must be a string"));
217                        };
218                        labels.insert(k.clone(), v.to_string());
219                    }
220                }
221                let id = o
222                    .get("id")
223                    .and_then(Value::as_str)
224                    .map(|s| s.trim().to_string())
225                    .filter(|s| !s.is_empty());
226                let a = Addressee { id, role, labels };
227                if a.is_empty() {
228                    // An addressee matching everyone is not an addressee, and
229                    // silently accepting one would make a gate look routed
230                    // when it is not.
231                    return Err("`to` names nobody — give an id, a role or labels".into());
232                }
233                Ok(a)
234            }
235            _ => Err("`to` must be a principal-id glob or {id, role, labels}".into()),
236        }
237    }
238
239    fn is_empty(&self) -> bool {
240        self.id.is_none() && self.role.is_none() && self.labels.is_empty()
241    }
242
243    /// Whether this principal is who the gate is waiting for.
244    pub fn matches(&self, p: &Principal) -> bool {
245        // This module's own `glob`, not the registry's tool-name matcher: a
246        // principal id is domain-shaped (`user:alice@acme.example`), so the
247        // useful pattern is a SUFFIX (`*@finance.example`) — which the tool
248        // matcher cannot express, since it only strips a trailing star.
249        // Widening that one to suit this would widen every tool grant with it.
250        if let Some(pat) = &self.id
251            && !glob(pat, &p.id)
252        {
253            return false;
254        }
255        if let Some(r) = self.role
256            && p.role != r
257        {
258            return false;
259        }
260        self.labels
261            .iter()
262            .all(|(k, v)| p.labels.get(k).map(String::as_str) == Some(v.as_str()))
263    }
264
265    /// A human-readable rendering, for the question, the task and the refusal
266    /// — a gate that will not accept your answer has to say whose it wants.
267    pub fn describe(&self) -> String {
268        let mut parts = Vec::new();
269        if let Some(id) = &self.id {
270            parts.push(id.clone());
271        }
272        if let Some(r) = self.role {
273            parts.push(format!("role {}", format!("{r:?}").to_lowercase()));
274        }
275        for (k, v) in &self.labels {
276            parts.push(format!("{k}={v}"));
277        }
278        parts.join(", ")
279    }
280}
281
282/// The governor scope key for a principal id. One source for the format,
283/// because the runtime carries only the id once work is under way while the
284/// A2A layer still holds the whole `Principal`.
285pub fn scope_key_for(id: &str) -> String {
286    format!("principal:{id}")
287}
288
289/// The admin methods (operator-only).
290pub fn is_admin(method: &str) -> bool {
291    matches!(
292        bare(method).as_str(),
293        "a2a.drain"
294            | "a2a.lameduck"
295            | "a2a.pause"
296            | "a2a.resume"
297            | "a2a.cancel"
298            | "drain"
299            | "lameduck"
300            | "pause"
301            | "resume"
302            | "cancel"
303    )
304}
305
306/// The method name folded for matching, owned.
307///
308/// Owned rather than `&'static str` because the only way to hand a lowercased
309/// copy back as `'static` is `String::leak`, and `m` is the `method` member of
310/// a JSON-RPC request: remote input, unbounded in length, and reached *before*
311/// the caller is known to be anybody — an `Authorization: Bearer junk` header
312/// resolves to the anonymous principal rather than a 401, and every request
313/// passes through [`is_admin`] on its way to being refused. One leak per
314/// request with an attacker-chosen name is an unbounded RSS climb driven from
315/// off the box, so nothing here may outlive the call.
316fn bare(m: &str) -> String {
317    m.strip_prefix("a2a.")
318        .map(|_| m)
319        .unwrap_or(m)
320        .to_ascii_lowercase()
321}
322
323/// What the transport learned about the caller.
324#[derive(Debug, Clone, Default)]
325pub struct CallerIdentity {
326    /// The verified client-cert subject/SANs (mTLS).
327    pub sans: Vec<String>,
328    pub subject: Option<String>,
329    /// A verified bearer subject (post token check), if the transport resolved it.
330    pub bearer_ref: Option<String>,
331    /// The verified AAuth agent id, set only when an inbound AAuth verifier
332    /// established one; an `aauth_agent` principal rule matches against it.
333    pub aauth_agent: Option<String>,
334    /// Whether the connection is loopback (dev operator default).
335    pub loopback: bool,
336    /// Whether the framework already authenticated the peer as management
337    /// (a verified client cert / matched bearer).
338    pub management: bool,
339}
340
341/// Resolves a caller to a principal from the configured `a2a.principals`.
342pub struct Resolver {
343    principals: Vec<Compiled>,
344    /// A bearer whose match resolves to the operator, when `a2a.bearer` is set
345    /// and no principal claims it (the loopback/single-operator default).
346    default_operator_on_bearer: bool,
347    /// Whether an unconfigured deployment treats a loopback caller as the
348    /// operator. True only while `a2a.principals` is empty: once an operator
349    /// has written any rule, the implicit local operator disappears rather
350    /// than sitting behind their matrix as a way in.
351    loopback_operator: bool,
352}
353
354struct Compiled {
355    matcher: v2::PrincipalMatch,
356    role: Role,
357    grants: Vec<String>,
358    rate: Option<String>,
359    budget: Option<v2::Budget>,
360    labels: std::collections::BTreeMap<String, String>,
361    bearer_secret: Option<String>,
362}
363
364impl Resolver {
365    /// Build from settings, resolving `bearer_ref` secrets at startup.
366    pub fn build(a2a: &v2::A2a, env: &dyn Fn(&str) -> Option<String>) -> Result<Resolver, String> {
367        let mut principals = Vec::new();
368        for p in &a2a.principals {
369            let bearer_secret = match &p.matcher.bearer_ref {
370                Some(r) => Some(
371                    secret::resolve(r, env)
372                        .map_err(|e| format!("a2a principal bearer_ref: {e}"))?,
373                ),
374                None => None,
375            };
376            principals.push(Compiled {
377                matcher: p.matcher.clone(),
378                role: p.role,
379                grants: p.grants.clone(),
380                rate: p.quotas.as_ref().and_then(|q| q.rate.clone()),
381                budget: p.quotas.as_ref().and_then(|q| q.budget.clone()),
382                labels: p.labels.clone(),
383                bearer_secret,
384            });
385        }
386        Ok(Resolver {
387            principals,
388            default_operator_on_bearer: a2a.bearer.is_some(),
389            loopback_operator: a2a.principals.is_empty(),
390        })
391    }
392
393    /// Resolve a caller. Matching order: explicit principal rules (first match),
394    /// then the operator defaults (verified management / loopback), then
395    /// anonymous.
396    pub fn resolve(&self, id: &CallerIdentity, presented_bearer: Option<&str>) -> Principal {
397        for c in &self.principals {
398            if let Some(p) = c.matches(id, presented_bearer) {
399                return p;
400            }
401        }
402        // A configured `a2a.bearer` (server bearer) that the transport matched
403        // ⇒ operator, unless a principal already claimed the connection.
404        if id.management && (self.default_operator_on_bearer || self.loopback_operator) {
405            return operator();
406        }
407        if id.loopback && self.loopback_operator {
408            return operator();
409        }
410        Principal::anonymous()
411    }
412
413    /// A status view of the configured principals.
414    pub fn status(&self) -> Value {
415        json!({
416            "principals": self.principals.iter().map(|c| json!({"role": format!("{:?}", c.role).to_lowercase(), "match": matcher_desc(&c.matcher), "grants": c.grants})).collect::<Vec<_>>(),
417            "loopback_operator": self.loopback_operator,
418        })
419    }
420}
421
422impl Compiled {
423    fn matches(&self, id: &CallerIdentity, presented_bearer: Option<&str>) -> Option<Principal> {
424        let m = &self.matcher;
425        let hit = if m.any {
426            true
427        } else if let Some(san) = &m.san {
428            id.sans.iter().any(|s| glob(san, s))
429                || id.subject.as_deref().is_some_and(|s| glob(san, s))
430        } else if let Some(sub) = &m.sub {
431            id.subject.as_deref().is_some_and(|s| s == sub)
432                || id.bearer_ref.as_deref().is_some_and(|b| b == sub)
433        } else if m.bearer_ref.is_some() {
434            match (&self.bearer_secret, presented_bearer) {
435                (Some(secret), Some(got)) => ct_eq(secret.as_bytes(), got.as_bytes()),
436                _ => false,
437            }
438        } else if let Some(agent) = &m.aauth_agent {
439            id.aauth_agent.as_deref().is_some_and(|a| glob(agent, a))
440        } else {
441            false
442        };
443        if !hit {
444            return None;
445        }
446        let pid = principal_id(self.role, id, m);
447        Some(Principal {
448            id: pid,
449            role: self.role,
450            grants: self.grants.clone(),
451            rate: self.rate.clone(),
452            budget: self.budget.clone(),
453            labels: self.labels.clone(),
454        })
455    }
456}
457
458fn operator() -> Principal {
459    Principal {
460        id: "operator".into(),
461        role: Role::Operator,
462        grants: vec!["*".into()],
463        rate: None,
464        budget: None,
465        labels: Default::default(),
466    }
467}
468
469fn principal_id(role: Role, id: &CallerIdentity, m: &v2::PrincipalMatch) -> String {
470    let sub = id
471        .subject
472        .clone()
473        .or_else(|| id.sans.first().cloned())
474        .or_else(|| id.bearer_ref.clone())
475        .or_else(|| id.aauth_agent.clone())
476        .or_else(|| m.sub.clone())
477        .unwrap_or_else(|| "unknown".into());
478    match role {
479        Role::Operator => "operator".into(),
480        Role::User => format!("user:{sub}"),
481        Role::Agent => format!("agent:{sub}"),
482        Role::Anonymous => "anonymous".into(),
483    }
484}
485
486fn matcher_desc(m: &v2::PrincipalMatch) -> Value {
487    if m.any {
488        json!({"any": true})
489    } else if let Some(s) = &m.san {
490        json!({"san": s})
491    } else if let Some(s) = &m.sub {
492        json!({"sub": s})
493    } else if m.bearer_ref.is_some() {
494        json!({"bearer_ref": "***"})
495    } else if let Some(a) = &m.aauth_agent {
496        json!({"aauth_agent": a})
497    } else {
498        json!({})
499    }
500}
501
502/// A `*`-glob match (`*` = any run of chars; else literal).
503fn glob(pattern: &str, s: &str) -> bool {
504    if pattern == "*" {
505        return true;
506    }
507    if let Some(pos) = pattern.find('*') {
508        let (pre, post) = (&pattern[..pos], &pattern[pos + 1..]);
509        return s.starts_with(pre) && s.ends_with(post) && s.len() >= pre.len() + post.len();
510    }
511    pattern == s
512}
513
514/// Constant-time byte compare.
515fn ct_eq(a: &[u8], b: &[u8]) -> bool {
516    if a.len() != b.len() {
517        return false;
518    }
519    let mut d = 0u8;
520    for (x, y) in a.iter().zip(b.iter()) {
521        d |= x ^ y;
522    }
523    d == 0
524}
525
526#[cfg(test)]
527mod tests {
528    use super::*;
529    use serde_json::json;
530
531    fn a2a(doc: Value) -> v2::A2a {
532        serde_json::from_value(doc).unwrap()
533    }
534    fn ident(sans: &[&str], sub: Option<&str>, mgmt: bool, loopback: bool) -> CallerIdentity {
535        CallerIdentity {
536            sans: sans.iter().map(|s| s.to_string()).collect(),
537            subject: sub.map(str::to_string),
538            management: mgmt,
539            loopback,
540            ..Default::default()
541        }
542    }
543
544    #[test]
545    fn resolves_roles_and_enforces_the_matrix() {
546        let r = Resolver::build(
547            &a2a(json!({
548                "principals": [
549                    {"match": {"san": "spiffe://ops/*"}, "role": "operator"},
550                    {"match": {"san": "spiffe://team/*"}, "role": "user", "grants": ["knowledge.*"]},
551                    {"match": {"bearer_ref": "{{secret:PEER}}"}, "role": "agent"},
552                    {"match": {"any": true}, "role": "anonymous"}
553                ]
554            })),
555            &|k| (k == "PEER").then(|| "s3cr3t".to_string()),
556        )
557        .unwrap();
558        let op = r.resolve(&ident(&["spiffe://ops/admin"], None, true, false), None);
559        assert!(op.is_operator());
560        assert!(op.may("SendMessage", Some("a2a.Drain")) || op.may_command("workflow.delete"));
561        let user = r.resolve(&ident(&["spiffe://team/alice"], None, true, false), None);
562        assert_eq!(user.role, Role::User);
563        assert_eq!(user.id, "user:spiffe://team/alice");
564        assert!(user.may("SendMessage", None), "NL is allowed");
565        assert!(
566            user.may_command("status")
567                && user.may_command("workflow.run")
568                && user.may_command("knowledge.search")
569        );
570        assert!(
571            !user.may_command("workflow.delete"),
572            "not granted to a user"
573        );
574        assert!(!user.may("a2a.Drain", None), "admin is operator-only");
575        let agent = r.resolve(&ident(&[], None, false, false), Some("s3cr3t"));
576        assert_eq!(agent.role, Role::Agent);
577        assert!(agent.may_command("workflow.run") && !agent.may_command("subagent.send"));
578        assert!(
579            r.resolve(&ident(&[], None, false, false), Some("wrong"))
580                .is_anonymous()
581        );
582        let anon = r.resolve(&ident(&["spiffe://other/x"], None, false, false), None);
583        assert!(anon.is_anonymous());
584        assert!(!anon.may("SendMessage", None) && !anon.may_command("status"));
585    }
586
587    #[test]
588    fn loopback_and_bearer_defaults() {
589        // No principals configured + loopback ⇒ operator.
590        let r = Resolver::build(&a2a(json!({})), &|_| None).unwrap();
591        assert!(r.resolve(&ident(&[], None, true, true), None).is_operator());
592        assert!(
593            r.resolve(&ident(&[], None, false, false), None)
594                .is_anonymous(),
595            "non-loopback without a match is anonymous"
596        );
597        // A server bearer the transport matched ⇒ operator.
598        let r = Resolver::build(&a2a(json!({"bearer": "{{secret:B}}"})), &|k| {
599            (k == "B").then(|| "t".to_string())
600        })
601        .unwrap();
602        assert!(
603            r.resolve(&ident(&[], None, true, false), None)
604                .is_operator()
605        );
606        assert!(glob("a*c", "abc") && glob("*", "x") && !glob("a*c", "abx"));
607    }
608
609    fn person(id: &str, role: Role, labels: &[(&str, &str)]) -> Principal {
610        Principal {
611            id: id.into(),
612            role,
613            grants: Vec::new(),
614            rate: None,
615            budget: None,
616            labels: labels
617                .iter()
618                .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
619                .collect(),
620        }
621    }
622
623    /// The whole point of an addressee: someone else cannot satisfy it. Every
624    /// declared condition must hold, so adding one narrows.
625    #[test]
626    fn an_addressee_admits_only_who_it_names() {
627        let by_id = Addressee::parse(&json!("*@finance.example")).unwrap();
628        assert!(by_id.matches(&person("lead@finance.example", Role::User, &[])));
629        assert!(!by_id.matches(&person("dev@eng.example", Role::User, &[])));
630
631        // Labels are the durable form — people change, teams do not.
632        let by_label =
633            Addressee::parse(&json!({"role": "user", "labels": {"team": "finance"}})).unwrap();
634        assert!(by_label.matches(&person("anyone", Role::User, &[("team", "finance")])));
635        assert!(
636            !by_label.matches(&person("anyone", Role::User, &[("team", "eng")])),
637            "a different team is a different decider"
638        );
639        assert!(
640            !by_label.matches(&person("anyone", Role::Agent, &[("team", "finance")])),
641            "conditions AND: the role must hold too"
642        );
643        assert!(
644            !by_label.matches(&person("anyone", Role::User, &[])),
645            "a principal with no labels matches no label condition"
646        );
647    }
648
649    /// Three declarations are refused rather than accepted-and-ignored,
650    /// because each would produce a gate that LOOKS routed and is not.
651    #[test]
652    fn an_addressee_that_names_nobody_is_refused() {
653        // Matches everyone.
654        assert!(Addressee::parse(&json!({})).is_err());
655        assert!(Addressee::parse(&json!("")).is_err());
656        // Anonymous is precisely the identity nothing vouches for, so a gate
657        // "answered by anonymous" records nothing at all.
658        assert!(Addressee::parse(&json!({"role": "anonymous"})).is_err());
659        // And a typo must not silently widen the gate.
660        assert!(Addressee::parse(&json!({"rolle": "user"})).is_err());
661        assert!(Addressee::parse(&json!({"role": "auditor"})).is_err());
662        assert!(Addressee::parse(&json!({"labels": {"team": 1}})).is_err());
663    }
664
665    /// A gate that will not take your answer has to say whose it wants.
666    #[test]
667    fn an_addressee_describes_itself() {
668        let a = Addressee::parse(&json!({"id": "u:*", "role": "user", "labels": {"team": "fin"}}))
669            .unwrap();
670        let d = a.describe();
671        assert!(
672            d.contains("u:*") && d.contains("user") && d.contains("team=fin"),
673            "{d}"
674        );
675    }
676}