1use crate::config::v2::{self, Role};
12use crate::sec::secret;
13use serde_json::{Value, json};
14
15#[derive(Debug, Clone, PartialEq)]
17pub struct Principal {
18 pub id: String,
20 pub role: Role,
21 pub grants: Vec<String>,
23 pub rate: Option<String>,
25 pub budget: Option<v2::Budget>,
26 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 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 "SendMessage"
67 | "SendStreamingMessage"
68 | "GetTask"
69 | "CancelTask"
70 | "ListTasks"
71 | "SubscribeToTask"
72 | "SubscribeToEvents"
73 | "CreateTaskPushNotificationConfig"
78 | "GetTaskPushNotificationConfig"
79 | "ListTaskPushNotificationConfigs"
80 | "DeleteTaskPushNotificationConfig"
81 | "GetExtendedAgentCard" => match op {
84 None => true, Some(tool) => self.may_command(tool),
86 },
87 m if m.starts_with("a2a.") && is_admin(m) => false,
89 _ => false,
90 },
91 }
92 }
93
94 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 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 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 pub fn scope_key(&self) -> String {
138 scope_key_for(&self.id)
139 }
140}
141
142#[derive(Debug, Clone, Default, PartialEq)]
166pub struct Addressee {
167 pub id: Option<String>,
169 pub role: Option<Role>,
170 pub labels: std::collections::BTreeMap<String, String>,
172}
173
174impl Addressee {
175 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 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 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 pub fn matches(&self, p: &Principal) -> bool {
245 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 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
282pub fn scope_key_for(id: &str) -> String {
286 format!("principal:{id}")
287}
288
289pub 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
306fn bare(m: &str) -> String {
317 m.strip_prefix("a2a.")
318 .map(|_| m)
319 .unwrap_or(m)
320 .to_ascii_lowercase()
321}
322
323#[derive(Debug, Clone, Default)]
325pub struct CallerIdentity {
326 pub sans: Vec<String>,
328 pub subject: Option<String>,
329 pub bearer_ref: Option<String>,
331 pub aauth_agent: Option<String>,
334 pub loopback: bool,
336 pub management: bool,
339}
340
341pub struct Resolver {
343 principals: Vec<Compiled>,
344 default_operator_on_bearer: bool,
347 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 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 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 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 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
502fn 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
514fn 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 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 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 #[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 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 #[test]
652 fn an_addressee_that_names_nobody_is_refused() {
653 assert!(Addressee::parse(&json!({})).is_err());
655 assert!(Addressee::parse(&json!("")).is_err());
656 assert!(Addressee::parse(&json!({"role": "anonymous"})).is_err());
659 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 #[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}