Skip to main content

lc_a2a/
gateway.rs

1//! P2-7: cross-organization federation gateway.
2//!
3//! Each organization deploys a [`FederationGateway`] in front of its agents. A
4//! federation call from another org arrives at the gateway, which:
5//!
6//! 1. **Enforces the [`CallPolicy`]** — only callers from `allowed_caller_orgs`
7//!    may invoke only `allowed_skills`, and only within `max_payload_size`.
8//! 2. **Minimizes the data** — strips caller identity and any non-essential
9//!    metadata before forwarding downstream (only `trace_id` and `message_id`
10//!    are preserved, so tracing and idempotency survive federation hops).
11//! 3. **Honors the data contract** — an optional [`DataContract`] is verified
12//!    against the downstream agent's advertised `data_class` before the request
13//!    is forwarded, so classified data never flows to an agent that has not
14//!    signed up to handle it.
15//!
16//! The gateway holds downstream [`A2AClient`]s keyed by route (typically the
17//! partner org's name) and forwards minimized requests over plain A2A.
18
19use 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/// Errors raised by the federation gateway.
28#[derive(Debug, thiserror::Error)]
29#[non_exhaustive]
30pub enum GatewayError {
31    /// The caller's org is not on the allow-list.
32    #[error("caller org '{0}' is not allowed by federation policy")]
33    CallerOrgNotAllowed(String),
34    /// The requested skill is not on the allow-list.
35    #[error("skill '{0}' is not allowed by federation policy")]
36    SkillNotAllowed(String),
37    /// The inbound request body exceeds the configured limit.
38    #[error("request payload of {actual} bytes exceeds the {max} byte limit")]
39    PayloadTooLarge {
40        /// Actual request payload size in bytes.
41        actual: usize,
42        /// Maximum allowed payload size in bytes.
43        max: usize,
44    },
45    /// The request carried no caller identity, so it cannot be authorized.
46    #[error("request does not carry a caller identity")]
47    MissingCaller,
48    /// The downstream agent's card does not satisfy the data contract.
49    #[error("downstream route '{0}' does not satisfy the data contract")]
50    ContractUnsatisfied(String),
51    /// No downstream route is registered for the requested key.
52    #[error("no downstream route registered for '{0}'")]
53    NoRoute(String),
54    /// A downstream A2A call failed.
55    #[error("A2A client error: {0}")]
56    Client(#[from] A2AError),
57}
58
59/// Access policy enforced on every inbound federation call (P2-7).
60///
61/// `None` means "no restriction" for that dimension; an empty `Some(Vec)`
62/// denies everything. The payload limit always applies.
63#[derive(Debug, Clone)]
64pub struct CallPolicy {
65    /// Orgs permitted to call this gateway. Caller identity comes from request
66    /// metadata `owner`, using the `org:user` convention.
67    pub allowed_caller_orgs: Option<Vec<String>>,
68    /// Skills permitted to be invoked (matched against `skillId`).
69    pub allowed_skills: Option<Vec<String>>,
70    /// Maximum inbound request body size in bytes.
71    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    /// An allow-everything policy (payload limit still applies).
86    pub fn new() -> Self {
87        Self::default()
88    }
89
90    /// Permit calls from an org (repeatable).
91    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    /// Permit a skill (repeatable).
99    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    /// Cap the inbound payload size in bytes.
107    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/// Data-processing contract between the gateway and a downstream agent (P2-7).
130///
131/// Federation only forwards classified data to agents that advertise a
132/// `data_class` at least as protective as `required_classification`. The
133/// classification ordering is `public` < `internal` < `confidential`; an agent
134/// that advertises no classification (or an unknown one) is *not* admitted —
135/// fail closed.
136#[derive(Debug, Clone)]
137pub struct DataContract {
138    /// Minimum `data_class` the downstream agent must advertise.
139    pub required_classification: String,
140    /// Purpose the data may be used for (informational, surfaces in errors).
141    pub purpose: String,
142    /// Retention the downstream promises (e.g. "session", "7d").
143    pub retention: String,
144    /// Whether the downstream may forward data to further agents.
145    pub allow_forwarding: bool,
146}
147
148impl DataContract {
149    /// Create a contract.
150    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    /// Whether an agent advertising `data_class` satisfies this contract.
165    ///
166    /// Fail-closed: an unadvertised or unknown `data_class` never admits.
167    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                // Unknown requirement — fail closed.
172                None => false,
173            },
174            None => false,
175        }
176    }
177}
178
179/// The protective rank of a data classification, if recognized.
180fn 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
189/// Cross-organization federation gateway (P2-7).
190pub struct FederationGateway {
191    /// The org this gateway fronts.
192    org: String,
193    /// Access policy for inbound calls.
194    policy: CallPolicy,
195    /// Optional data-processing contract verified against downstream cards.
196    contract: Option<DataContract>,
197    /// Downstream routes: route key -> A2A client.
198    clients: HashMap<String, A2AClient>,
199    /// Whether caller identity is stripped from forwarded requests.
200    minimize_metadata: bool,
201}
202
203impl FederationGateway {
204    /// Create a gateway for `org`, enforcing `policy`.
205    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    /// Attach a data contract that downstream agents must satisfy.
216    pub fn with_contract(mut self, contract: DataContract) -> Self {
217        self.contract = Some(contract);
218        self
219    }
220
221    /// Register a downstream route (e.g. a partner org) to an A2A client.
222    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    /// Toggle data minimization. On (default) the caller identity is stripped
228    /// from forwarded requests; off relays it unchanged.
229    pub fn minimize_metadata(mut self, on: bool) -> Self {
230        self.minimize_metadata = on;
231        self
232    }
233
234    /// The org this gateway fronts.
235    pub fn org(&self) -> &str {
236        &self.org
237    }
238
239    /// The active call policy.
240    pub fn policy(&self) -> &CallPolicy {
241        &self.policy
242    }
243
244    /// Validate an inbound request against the policy (P2-7).
245    ///
246    /// `raw_len` is the size of the request body as received on the wire; it is
247    /// checked against [`CallPolicy::max_payload_size`]. Caller org is taken
248    /// from request metadata `owner` (the `org:user` convention), the skill
249    /// from the `skillId` param, and both must be allowed when a policy lists
250    /// them.
251    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    /// Data minimization: a copy of `req` carrying only the fields a downstream
272    /// agent strictly needs.
273    ///
274    /// The `trace_id` (distributed tracing) and `message_id` (idempotency) are
275    /// preserved; the caller's `owner` is dropped while minimization is on.
276    /// Method, id, and params pass through unchanged — they are the request.
277    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    /// Verify a downstream agent card against the attached data contract.
302    ///
303    /// With no contract attached every card passes. Otherwise the agent must
304    /// advertise a `data_class` the contract admits, or [`GatewayError::ContractUnsatisfied`]
305    /// is returned (P2-7).
306    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    /// Fetch the downstream card for `key` and verify it against the contract.
316    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    /// Enforce the policy, minimize the request, and forward it to the
327    /// downstream route `key` (P2-7).
328    ///
329    /// `raw_len` is the size of the request body as received on the wire. The
330    /// outbound request is minimized before it is sent, and the downstream
331    /// A2A response is returned verbatim.
332    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
348/// The organization part of a caller identity. Identities use the `org:user`
349/// convention; an identity without a separator is its own org.
350fn org_from_owner(owner: &str) -> &str {
351    owner.split_once(':').map(|(org, _)| org).unwrap_or(owner)
352}
353
354/// The `skillId` param of a request, if any.
355fn 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        // Allowed org, allowed skill -> passes.
392        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        // Same org, unknown skill -> rejected.
406        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        // Same classification -> admitted.
478        assert!(contract.admits(Some("internal")));
479        // More protective -> admitted.
480        assert!(contract.admits(Some("confidential")));
481        // Less protective -> denied.
482        assert!(!contract.admits(Some("public")));
483        // No classification advertised -> fail closed.
484        assert!(!contract.admits(None));
485        // Unknown classification -> fail closed.
486        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    // ---- downstream HTTP forwarding ----
503
504    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        // The forwarded body must be minimized: trace + message id kept,
601        // caller identity stripped.
602        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        // Caller from an unlisted org.
631        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}