1use std::collections::HashMap;
20
21use serde_json::Value;
22
23use crate::client::A2AClient;
24use crate::protocol::{metadata_keys, A2ARequest, A2AResponse, AgentCard};
25use crate::A2AError;
26
27#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum GatewayError {
31 #[error("caller org '{0}' is not allowed by federation policy")]
33 CallerOrgNotAllowed(String),
34 #[error("skill '{0}' is not allowed by federation policy")]
36 SkillNotAllowed(String),
37 #[error("request payload of {actual} bytes exceeds the {max} byte limit")]
39 PayloadTooLarge {
40 actual: usize,
42 max: usize,
44 },
45 #[error("request does not carry a caller identity")]
47 MissingCaller,
48 #[error("downstream route '{0}' does not satisfy the data contract")]
50 ContractUnsatisfied(String),
51 #[error("no downstream route registered for '{0}'")]
53 NoRoute(String),
54 #[error("A2A client error: {0}")]
56 Client(#[from] A2AError),
57}
58
59#[derive(Debug, Clone)]
64pub struct CallPolicy {
65 pub allowed_caller_orgs: Option<Vec<String>>,
68 pub allowed_skills: Option<Vec<String>>,
70 pub max_payload_size: usize,
72}
73
74impl Default for CallPolicy {
75 fn default() -> Self {
76 Self {
77 allowed_caller_orgs: None,
78 allowed_skills: None,
79 max_payload_size: 1024 * 1024,
80 }
81 }
82}
83
84impl CallPolicy {
85 pub fn new() -> Self {
87 Self::default()
88 }
89
90 pub fn allow_caller_org(mut self, org: impl Into<String>) -> Self {
92 self.allowed_caller_orgs
93 .get_or_insert_with(Vec::new)
94 .push(org.into());
95 self
96 }
97
98 pub fn allow_skill(mut self, skill: impl Into<String>) -> Self {
100 self.allowed_skills
101 .get_or_insert_with(Vec::new)
102 .push(skill.into());
103 self
104 }
105
106 pub fn with_max_payload_size(mut self, bytes: usize) -> Self {
108 self.max_payload_size = bytes;
109 self
110 }
111
112 fn caller_org_allowed(&self, org: &str) -> bool {
113 self.allowed_caller_orgs
114 .as_ref()
115 .is_none_or(|orgs| orgs.iter().any(|o| o == org))
116 }
117
118 fn skill_allowed(&self, skill: &str) -> bool {
119 self.allowed_skills
120 .as_ref()
121 .is_none_or(|skills| skills.iter().any(|s| s == skill))
122 }
123
124 fn payload_allowed(&self, len: usize) -> bool {
125 len <= self.max_payload_size
126 }
127}
128
129#[derive(Debug, Clone)]
137pub struct DataContract {
138 pub required_classification: String,
140 pub purpose: String,
142 pub retention: String,
144 pub allow_forwarding: bool,
146}
147
148impl DataContract {
149 pub fn new(
151 required_classification: impl Into<String>,
152 purpose: impl Into<String>,
153 retention: impl Into<String>,
154 allow_forwarding: bool,
155 ) -> Self {
156 Self {
157 required_classification: required_classification.into(),
158 purpose: purpose.into(),
159 retention: retention.into(),
160 allow_forwarding,
161 }
162 }
163
164 pub fn admits(&self, data_class: Option<&str>) -> bool {
168 match data_class.and_then(classification_rank) {
169 Some(agent_rank) => match classification_rank(&self.required_classification) {
170 Some(required_rank) => agent_rank >= required_rank,
171 None => false,
173 },
174 None => false,
175 }
176 }
177}
178
179fn classification_rank(c: &str) -> Option<u8> {
181 match c.trim().to_ascii_lowercase().as_str() {
182 "public" => Some(0),
183 "internal" => Some(1),
184 "confidential" => Some(2),
185 _ => None,
186 }
187}
188
189pub struct FederationGateway {
191 org: String,
193 policy: CallPolicy,
195 contract: Option<DataContract>,
197 clients: HashMap<String, A2AClient>,
199 minimize_metadata: bool,
201}
202
203impl FederationGateway {
204 pub fn new(org: impl Into<String>, policy: CallPolicy) -> Self {
206 Self {
207 org: org.into(),
208 policy,
209 contract: None,
210 clients: HashMap::new(),
211 minimize_metadata: true,
212 }
213 }
214
215 pub fn with_contract(mut self, contract: DataContract) -> Self {
217 self.contract = Some(contract);
218 self
219 }
220
221 pub fn with_route(mut self, key: impl Into<String>, client: A2AClient) -> Self {
223 self.clients.insert(key.into(), client);
224 self
225 }
226
227 pub fn minimize_metadata(mut self, on: bool) -> Self {
230 self.minimize_metadata = on;
231 self
232 }
233
234 pub fn org(&self) -> &str {
236 &self.org
237 }
238
239 pub fn policy(&self) -> &CallPolicy {
241 &self.policy
242 }
243
244 pub fn enforce(&self, req: &A2ARequest, raw_len: usize) -> Result<(), GatewayError> {
252 if !self.policy.payload_allowed(raw_len) {
253 return Err(GatewayError::PayloadTooLarge {
254 actual: raw_len,
255 max: self.policy.max_payload_size,
256 });
257 }
258 let owner = req.owner().ok_or(GatewayError::MissingCaller)?;
259 let org = org_from_owner(owner);
260 if !self.policy.caller_org_allowed(org) {
261 return Err(GatewayError::CallerOrgNotAllowed(org.to_string()));
262 }
263 if let Some(skill) = request_skill(req) {
264 if !self.policy.skill_allowed(skill) {
265 return Err(GatewayError::SkillNotAllowed(skill.to_string()));
266 }
267 }
268 Ok(())
269 }
270
271 pub fn minimize(&self, req: &A2ARequest) -> A2ARequest {
278 let mut slim = serde_json::Map::new();
279 if let Some(meta) = &req.metadata {
280 if let Some(v) = meta.get(metadata_keys::TRACE_ID) {
281 slim.insert(metadata_keys::TRACE_ID.to_string(), v.clone());
282 }
283 if let Some(v) = meta.get(metadata_keys::MESSAGE_ID) {
284 slim.insert(metadata_keys::MESSAGE_ID.to_string(), v.clone());
285 }
286 if !self.minimize_metadata {
287 if let Some(v) = meta.get(metadata_keys::OWNER) {
288 slim.insert(metadata_keys::OWNER.to_string(), v.clone());
289 }
290 }
291 }
292 A2ARequest {
293 jsonrpc: req.jsonrpc.clone(),
294 id: req.id,
295 method: req.method.clone(),
296 params: req.params.clone(),
297 metadata: (!slim.is_empty()).then_some(Value::Object(slim)),
298 }
299 }
300
301 pub fn contract_admits(&self, card: &AgentCard) -> Result<(), GatewayError> {
307 if let Some(contract) = &self.contract {
308 if !contract.admits(card.data_class.as_deref()) {
309 return Err(GatewayError::ContractUnsatisfied(card.url.clone()));
310 }
311 }
312 Ok(())
313 }
314
315 pub async fn verify_downstream(&self, key: &str) -> Result<AgentCard, GatewayError> {
317 let client = self
318 .clients
319 .get(key)
320 .ok_or_else(|| GatewayError::NoRoute(key.to_string()))?;
321 let card = client.get_agent_card().await?;
322 self.contract_admits(&card)?;
323 Ok(card)
324 }
325
326 pub async fn forward(
333 &self,
334 key: &str,
335 req: &A2ARequest,
336 raw_len: usize,
337 ) -> Result<A2AResponse, GatewayError> {
338 self.enforce(req, raw_len)?;
339 let client = self
340 .clients
341 .get(key)
342 .ok_or_else(|| GatewayError::NoRoute(key.to_string()))?;
343 let outbound = self.minimize(req);
344 Ok(client.post_request(outbound).await?)
345 }
346}
347
348fn org_from_owner(owner: &str) -> &str {
351 owner.split_once(':').map(|(org, _)| org).unwrap_or(owner)
352}
353
354fn request_skill(req: &A2ARequest) -> Option<&str> {
356 req.params
357 .as_ref()
358 .and_then(|p| p.get("skillId"))
359 .and_then(Value::as_str)
360}
361
362#[cfg(test)]
363mod tests {
364 use super::*;
365
366 use std::sync::atomic::{AtomicUsize, Ordering};
367 use std::sync::{Arc, Mutex};
368
369 use crate::protocol::A2AMessage;
370
371 fn request() -> A2ARequest {
372 A2ARequest::send_task(1, &A2AMessage::user("hi"))
373 }
374
375 #[test]
376 fn policy_denies_unknown_caller_org() {
377 let policy = CallPolicy::new().allow_caller_org("acme");
378 let gw = FederationGateway::new("gw", policy);
379 let req = request().with_owner("evil:user");
380 let err = gw.enforce(&req, 100).unwrap_err();
381 assert!(matches!(err, GatewayError::CallerOrgNotAllowed(o) if o == "evil"));
382 }
383
384 #[test]
385 fn policy_allows_known_org_and_denies_unknown_skill() {
386 let policy = CallPolicy::new()
387 .allow_caller_org("acme")
388 .allow_skill("research");
389 let gw = FederationGateway::new("gw", policy);
390 let req = request().with_owner("acme:alice");
391 let with_skill = {
393 let mut params = req.params.clone().unwrap();
394 params["skillId"] = Value::String("research".to_string());
395 A2ARequest {
396 jsonrpc: req.jsonrpc.clone(),
397 id: req.id,
398 method: req.method.clone(),
399 params: Some(params),
400 metadata: req.metadata.clone(),
401 }
402 };
403 gw.enforce(&with_skill, 100).unwrap();
404
405 let unknown_skill = {
407 let mut params = req.params.clone().unwrap();
408 params["skillId"] = Value::String("summarize".to_string());
409 A2ARequest {
410 jsonrpc: req.jsonrpc.clone(),
411 id: req.id,
412 method: req.method.clone(),
413 params: Some(params),
414 metadata: req.metadata.clone(),
415 }
416 };
417 let err = gw.enforce(&unknown_skill, 100).unwrap_err();
418 assert!(matches!(err, GatewayError::SkillNotAllowed(s) if s == "summarize"));
419 }
420
421 #[test]
422 fn policy_denies_oversized_payload() {
423 let policy = CallPolicy::new().with_max_payload_size(16);
424 let gw = FederationGateway::new("gw", policy);
425 let req = request().with_owner("acme:alice");
426 let err = gw.enforce(&req, 100).unwrap_err();
427 assert!(matches!(
428 err,
429 GatewayError::PayloadTooLarge {
430 actual: 100,
431 max: 16
432 }
433 ));
434 }
435
436 #[test]
437 fn policy_requires_caller_identity() {
438 let gw = FederationGateway::new("gw", CallPolicy::new());
439 let err = gw.enforce(&request(), 100).unwrap_err();
440 assert!(matches!(err, GatewayError::MissingCaller));
441 }
442
443 #[test]
444 fn minimize_strips_owner_but_keeps_trace_and_message_id() {
445 let gw = FederationGateway::new("gw", CallPolicy::new());
446 let req = request()
447 .with_owner("acme:alice")
448 .with_trace_id("trace-1")
449 .with_message_id("msg-1");
450
451 let out = gw.minimize(&req);
452 assert_eq!(out.owner(), None, "caller identity must not leak");
453 assert_eq!(out.trace_id(), Some("trace-1"));
454 assert_eq!(out.message_id(), Some("msg-1"));
455 assert_eq!(out.method, "tasks/send");
456 assert!(out.params.is_some());
457 }
458
459 #[test]
460 fn minimize_can_relay_caller_identity_when_disabled() {
461 let gw = FederationGateway::new("gw", CallPolicy::new()).minimize_metadata(false);
462 let req = request().with_owner("acme:alice").with_trace_id("trace-1");
463 let out = gw.minimize(&req);
464 assert_eq!(out.owner(), Some("acme:alice"));
465 }
466
467 #[test]
468 fn org_extraction_uses_org_prefix() {
469 assert_eq!(org_from_owner("acme:alice"), "acme");
470 assert_eq!(org_from_owner("acme"), "acme");
471 assert_eq!(org_from_owner("alice@acme.org"), "alice@acme.org");
472 }
473
474 #[test]
475 fn data_contract_admits_classification() {
476 let contract = DataContract::new("internal", "task-execution", "session", false);
477 assert!(contract.admits(Some("internal")));
479 assert!(contract.admits(Some("confidential")));
481 assert!(!contract.admits(Some("public")));
483 assert!(!contract.admits(None));
485 assert!(!contract.admits(Some("top-secret")));
487 }
488
489 #[test]
490 fn contract_admits_checks_downstream_card() {
491 let gw = FederationGateway::new("gw", CallPolicy::new())
492 .with_contract(DataContract::new("internal", "x", "session", false));
493
494 let ok = AgentCard::new("a", "a", "http://a").with_data_class("internal");
495 gw.contract_admits(&ok).unwrap();
496
497 let bad = AgentCard::new("b", "b", "http://b").with_data_class("public");
498 let err = gw.contract_admits(&bad).unwrap_err();
499 assert!(matches!(err, GatewayError::ContractUnsatisfied(url) if url == "http://b"));
500 }
501
502 type Handler = Arc<dyn Fn(&str, &str) -> (u16, String) + Send + Sync>;
505
506 async fn spawn_server(handler: Handler) -> String {
507 use tokio::io::{AsyncReadExt, AsyncWriteExt};
508 use tokio::net::TcpListener;
509 use tokio::net::TcpStream;
510
511 async fn read_request(stream: &mut TcpStream) -> (String, String) {
512 let mut buf = vec![0u8; 4096];
513 let mut request = Vec::new();
514 let mut head_end = None;
515 while head_end.is_none() {
516 let n = stream.read(&mut buf).await.unwrap_or(0);
517 if n == 0 {
518 break;
519 }
520 request.extend_from_slice(&buf[..n]);
521 head_end = request.windows(4).position(|w| w == b"\r\n\r\n");
522 }
523 let head_end = head_end.expect("head terminator");
524 let head = String::from_utf8_lossy(&request[..head_end]).to_string();
525 let body_len = head
526 .lines()
527 .find_map(|l| l.strip_prefix("Content-Length:"))
528 .and_then(|v| v.trim().parse::<usize>().ok())
529 .unwrap_or(0);
530 let mut body = request[head_end + 4..].to_vec();
531 while body.len() < body_len {
532 let n = stream.read(&mut buf).await.unwrap_or(0);
533 if n == 0 {
534 break;
535 }
536 body.extend_from_slice(&buf[..n]);
537 }
538 (String::new(), String::from_utf8_lossy(&body).to_string())
539 }
540
541 async fn write_response(stream: &mut TcpStream, status: u16, body: &str) {
542 let head = format!(
543 "HTTP/1.1 {status} OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
544 body.len()
545 );
546 let _ = stream.write_all(head.as_bytes()).await;
547 let _ = stream.write_all(body.as_bytes()).await;
548 let _ = stream.shutdown().await;
549 }
550
551 let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
552 let port = listener.local_addr().unwrap().port();
553 tokio::spawn(async move {
554 loop {
555 let (stream, _) = match listener.accept().await {
556 Ok(s) => s,
557 Err(_) => break,
558 };
559 let handler = handler.clone();
560 tokio::spawn(async move {
561 let mut stream = stream;
562 let (_, body) = read_request(&mut stream).await;
563 let (status, response) = handler("", &body);
564 write_response(&mut stream, status, &response).await;
565 });
566 }
567 });
568 format!("http://127.0.0.1:{port}")
569 }
570
571 fn downstream_ok_response() -> String {
572 r#"{"jsonrpc":"2.0","id":1,"result":{"task":{"id":"fwd-1","message":{"role":"user","content":"hi"},"status":"completed","result":{"output":"ok"}}}}"#.to_string()
573 }
574
575 #[tokio::test]
576 async fn forward_enforces_policy_and_forwards_minimized_request() {
577 let captured = Arc::new(Mutex::new(String::new()));
578 let cap = captured.clone();
579 let hits = Arc::new(AtomicUsize::new(0));
580 let h = hits.clone();
581 let handler: Handler = Arc::new(move |_path, body| {
582 h.fetch_add(1, Ordering::SeqCst);
583 *cap.lock().unwrap_or_else(|e| e.into_inner()) = body.to_string();
584 (200, downstream_ok_response())
585 });
586 let base = spawn_server(handler).await;
587
588 let gw = FederationGateway::new("gw", CallPolicy::new().allow_caller_org("acme"))
589 .with_route("partner", A2AClient::new(base).unwrap());
590
591 let req = request()
592 .with_owner("acme:alice")
593 .with_trace_id("trace-9")
594 .with_message_id("msg-9");
595
596 let resp = gw.forward("partner", &req, 200).await.unwrap();
597 assert!(resp.result.is_some());
598 assert_eq!(hits.load(Ordering::SeqCst), 1);
599
600 let forwarded = captured.lock().unwrap_or_else(|e| e.into_inner()).clone();
603 assert!(
604 forwarded.contains("trace_id"),
605 "trace must survive forwarding"
606 );
607 assert!(
608 forwarded.contains("message_id"),
609 "idempotency key must survive"
610 );
611 assert!(
612 !forwarded.contains("acme:alice"),
613 "caller identity must be stripped before forwarding"
614 );
615 }
616
617 #[tokio::test]
618 async fn forward_rejects_policy_violation_without_calling_downstream() {
619 let hits = Arc::new(AtomicUsize::new(0));
620 let h = hits.clone();
621 let handler: Handler = Arc::new(move |_path, _body| {
622 h.fetch_add(1, Ordering::SeqCst);
623 (200, downstream_ok_response())
624 });
625 let base = spawn_server(handler).await;
626
627 let gw = FederationGateway::new("gw", CallPolicy::new().allow_caller_org("acme"))
628 .with_route("partner", A2AClient::new(base).unwrap());
629
630 let req = request().with_owner("evil:user");
632 let err = gw.forward("partner", &req, 200).await.unwrap_err();
633 assert!(matches!(err, GatewayError::CallerOrgNotAllowed(o) if o == "evil"));
634 assert_eq!(hits.load(Ordering::SeqCst), 0);
635 }
636
637 #[tokio::test]
638 async fn forward_missing_route_is_a_no_route_error() {
639 let gw = FederationGateway::new("gw", CallPolicy::new());
640 let req = request().with_owner("acme:alice");
641 let err = gw.forward("nope", &req, 200).await.unwrap_err();
642 assert!(matches!(err, GatewayError::NoRoute(r) if r == "nope"));
643 }
644}