Skip to main content

batuta/serve/banco/
middleware.rs

1//! Privacy tier middleware for Banco.
2//!
3//! Adds `X-Privacy-Tier` header to every response and, in Sovereign mode,
4//! rejects requests that hint at external backends.
5
6use axum::{
7    body::Body,
8    http::{HeaderValue, Request, Response, StatusCode},
9    middleware::Next,
10};
11
12use crate::serve::backends::PrivacyTier;
13
14use super::types::ErrorResponse;
15
16/// Axum middleware function — inject privacy header and enforce sovereign gate.
17pub async fn privacy_layer(
18    tier: PrivacyTier,
19    request: Request<Body>,
20    next: Next,
21) -> Result<Response<Body>, StatusCode> {
22    // Sovereign mode: reject requests with an external backend hint header
23    if tier == PrivacyTier::Sovereign {
24        if let Some(val) = request.headers().get("x-banco-backend") {
25            let hint = val.to_str().unwrap_or("");
26            let is_external = ![
27                "realizar",
28                "ollama",
29                "llamacpp",
30                "llamafile",
31                "candle",
32                "vllm",
33                "tgi",
34                "localai",
35            ]
36            .iter()
37            .any(|local| hint.eq_ignore_ascii_case(local));
38
39            if is_external {
40                let body = serde_json::to_string(&ErrorResponse::new(
41                    "External backend not allowed in Sovereign mode",
42                    "privacy_violation",
43                    403,
44                ))
45                .unwrap_or_default();
46
47                return Ok(Response::builder()
48                    .status(StatusCode::FORBIDDEN)
49                    .header("content-type", "application/json")
50                    .header("x-privacy-tier", tier_header(tier))
51                    .body(Body::from(body))
52                    .expect("valid response"));
53            }
54        }
55    }
56
57    // Handle CORS preflight
58    if request.method() == axum::http::Method::OPTIONS {
59        return Ok(cors_preflight(tier));
60    }
61
62    let mut response = next.run(request).await;
63    let headers = response.headers_mut();
64    headers.insert("x-privacy-tier", tier_header(tier));
65    // CORS headers on every response
66    headers.insert("access-control-allow-origin", HeaderValue::from_static("*"));
67    headers.insert(
68        "access-control-allow-methods",
69        HeaderValue::from_static("GET, POST, PUT, DELETE, OPTIONS"),
70    );
71    headers.insert(
72        "access-control-allow-headers",
73        HeaderValue::from_static("content-type, authorization, x-banco-backend"),
74    );
75    headers.insert("access-control-expose-headers", HeaderValue::from_static("x-privacy-tier"));
76    Ok(response)
77}
78
79/// CORS preflight response for OPTIONS requests.
80fn cors_preflight(tier: PrivacyTier) -> Response<Body> {
81    Response::builder()
82        .status(StatusCode::NO_CONTENT)
83        .header("access-control-allow-origin", "*")
84        .header("access-control-allow-methods", "GET, POST, PUT, DELETE, OPTIONS")
85        .header("access-control-allow-headers", "content-type, authorization, x-banco-backend")
86        .header("access-control-expose-headers", "x-privacy-tier")
87        .header("access-control-max-age", "86400")
88        .header("x-privacy-tier", tier_header(tier))
89        .body(Body::empty())
90        .expect("valid response")
91}
92
93fn tier_header(tier: PrivacyTier) -> HeaderValue {
94    match tier {
95        PrivacyTier::Sovereign => HeaderValue::from_static("sovereign"),
96        PrivacyTier::Private => HeaderValue::from_static("private"),
97        PrivacyTier::Standard => HeaderValue::from_static("standard"),
98    }
99}