1use crate::config::v2::{self, Role};
11use crate::sec::secret;
12use serde_json::{Value, json};
13
14#[derive(Debug, Clone, PartialEq)]
16pub struct Principal {
17 pub id: String,
19 pub role: Role,
20 pub grants: Vec<String>,
22 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 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 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 "SendMessage"
65 | "SendStreamingMessage"
66 | "GetTask"
67 | "CancelTask"
68 | "ListTasks"
69 | "SubscribeToTask"
70 | "SubscribeToEvents"
71 | "CreateTaskPushNotificationConfig"
76 | "GetTaskPushNotificationConfig"
77 | "ListTaskPushNotificationConfigs"
78 | "DeleteTaskPushNotificationConfig"
79 | "GetExtendedAgentCard" => match op {
82 None => true, Some(tool) => self.may_command(tool),
84 },
85 m if m.starts_with("a2a.") && is_admin(m) => false,
87 _ => false,
88 },
89 }
90 }
91
92 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 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 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
136pub 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
153fn bare(m: &str) -> String {
164 m.strip_prefix("a2a.")
165 .map(|_| m)
166 .unwrap_or(m)
167 .to_ascii_lowercase()
168}
169
170#[derive(Debug, Clone, Default)]
172pub struct CallerIdentity {
173 pub sans: Vec<String>,
175 pub subject: Option<String>,
176 pub bearer_ref: Option<String>,
178 pub aauth_agent: Option<String>,
180 pub loopback: bool,
182 pub management: bool,
185}
186
187pub struct Resolver {
189 principals: Vec<Compiled>,
190 default_operator_on_bearer: bool,
193 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 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 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 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 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
341fn 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
353fn 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 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 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}