Skip to main content

aiegis_harness/
upstream.rs

1// SPDX-License-Identifier: Apache-2.0 OR MIT
2//! Upstream-receipt POSTer.
3//!
4//! Forwards an action payload to a Velo-style `/v1/harness/receipt`
5//! endpoint, returning the upstream receipt id (`rcp_…`) and signed
6//! receipt blob.
7//!
8//! WHY THIS LIVES BEHIND A FLAG
9//! ----------------------------
10//! The harness daemon's contract is local-evaluation-first. The
11//! upstream POST is opt-in (`--upstream-protect <URL>`) for the
12//! integration scenario where the daemon must mint a cryptographic
13//! receipt against a trusted authority before the action is allowed to
14//! fire. When the upstream is unreachable / 5xx, we DO NOT fake a
15//! success — we surface `upstream_error=true` to both the caller and
16//! the audit ledger and fall back to the daemon's local decision.
17
18use serde::Deserialize;
19use std::time::Duration;
20
21#[derive(Debug, Clone, Deserialize)]
22pub struct UpstreamReceipt {
23    pub rid: String,
24    #[serde(default)]
25    pub receipt: String,
26    #[serde(default)]
27    pub decision: Option<String>,
28    #[serde(default)]
29    pub decision_ms: Option<u64>,
30}
31
32#[derive(Debug)]
33pub enum UpstreamError {
34    Http(String),
35    Status(u16, String),
36    Json(String),
37    NoTag,
38}
39
40impl std::fmt::Display for UpstreamError {
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        match self {
43            UpstreamError::Http(s) => write!(f, "upstream_http:{s}"),
44            UpstreamError::Status(c, b) => write!(f, "upstream_status:{c}:{b}"),
45            UpstreamError::Json(s) => write!(f, "upstream_json:{s}"),
46            UpstreamError::NoTag => write!(f, "upstream_no_aegis_tag"),
47        }
48    }
49}
50
51impl std::error::Error for UpstreamError {}
52
53#[derive(Clone)]
54pub struct UpstreamClient {
55    client: reqwest::Client,
56    url: String,
57}
58
59impl UpstreamClient {
60    pub fn new(url: String) -> Self {
61        let client = reqwest::Client::builder()
62            .user_agent(format!("aiegis-harness-rs/{}", env!("CARGO_PKG_VERSION")))
63            .timeout(Duration::from_secs(10))
64            .build()
65            .unwrap_or_else(|_| reqwest::Client::new());
66        Self { client, url }
67    }
68
69    /// POST the action payload upstream. The original X-AEGIS-Tag header
70    /// is required — without it the upstream will L1-DENY for missing
71    /// identity and we'll capture that as an upstream_error (NOT as a
72    /// silent success).
73    pub async fn post(
74        &self,
75        body: &serde_json::Value,
76        aegis_tag: &str,
77    ) -> Result<UpstreamReceipt, UpstreamError> {
78        if aegis_tag.is_empty() {
79            return Err(UpstreamError::NoTag);
80        }
81        let resp = self
82            .client
83            .post(&self.url)
84            .header("X-AEGIS-Tag", aegis_tag)
85            .header("Content-Type", "application/json")
86            .json(body)
87            .send()
88            .await
89            .map_err(|e| UpstreamError::Http(format!("{e}")))?;
90        let status = resp.status();
91        let body_bytes = resp
92            .bytes()
93            .await
94            .map_err(|e| UpstreamError::Http(format!("read:{e}")))?;
95        if !status.is_success() {
96            let body_str = String::from_utf8_lossy(&body_bytes).to_string();
97            return Err(UpstreamError::Status(status.as_u16(), body_str));
98        }
99        serde_json::from_slice::<UpstreamReceipt>(&body_bytes)
100            .map_err(|e| UpstreamError::Json(format!("{e}")))
101    }
102}